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 020d0d569..042d2083c 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 @@ -1,5 +1,8 @@ package art.arcane.iris.core.nms.v26_2_R1; +import ca.spottedleaf.moonrise.patches.chunk_system.io.MoonriseRegionFileIO; +import ca.spottedleaf.moonrise.patches.chunk_system.scheduling.ChunkHolderManager; +import ca.spottedleaf.moonrise.patches.chunk_system.scheduling.NewChunkHolder; import com.mojang.brigadier.exceptions.CommandSyntaxException; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.core.nms.INMSBinding; @@ -32,6 +35,7 @@ import art.arcane.iris.util.common.scheduling.J; import it.unimi.dsi.fastutil.objects.Object2IntMap; import it.unimi.dsi.fastutil.shorts.ShortList; import net.bytebuddy.ByteBuddy; +import net.bytebuddy.agent.builder.AgentBuilder; import net.bytebuddy.asm.Advice; import net.bytebuddy.matcher.ElementMatchers; import net.minecraft.core.*; @@ -50,6 +54,7 @@ import net.minecraft.tags.TagKey; import net.minecraft.world.attribute.EnvironmentAttributes; import net.minecraft.world.entity.EntityType; import net.minecraft.world.item.component.CustomData; +import net.minecraft.world.level.Level; import net.minecraft.world.level.biome.BiomeSource; import net.minecraft.world.level.biome.Biomes; import net.minecraft.world.level.block.Block; @@ -77,7 +82,6 @@ import net.minecraft.world.level.levelgen.feature.Feature; import net.minecraft.world.level.levelgen.feature.TreeFeature; import net.minecraft.world.level.levelgen.flat.FlatLayerInfo; import net.minecraft.world.level.levelgen.flat.FlatLevelGeneratorSettings; -import net.minecraft.world.level.storage.LevelStorageSource; import org.bukkit.*; import org.bukkit.block.Biome; import org.bukkit.block.data.BlockData; @@ -875,6 +879,34 @@ public class NMSBinding implements INMSBinding { } } + @Override + public boolean saveAndUnloadChunk(World world, int x, int z) { + try { + ServerLevel level = ((CraftWorld) world).getHandle(); + ChunkHolderManager chm = level.moonrise$getChunkTaskScheduler().chunkHolderManager; + chm.processTicketUpdates(); + NewChunkHolder holder = chm.getChunkHolder(x, z); + if (holder != null) { + holder.save(false); + } + chm.processUnloads(); + return true; + } catch (Throwable e) { + IrisLogging.reportError(e); + return false; + } + } + + @Override + public void flushChunkIO(World world) { + try { + ServerLevel level = ((CraftWorld) world).getHandle(); + MoonriseRegionFileIO.flush(level); + } catch (Throwable e) { + IrisLogging.reportError(e); + } + } + @Override public boolean clearChunkBlocks(Chunk bukkitChunk) { try { @@ -1133,13 +1165,16 @@ public class NMSBinding implements INMSBinding { return true; try { IrisLogging.info("Injecting Bukkit"); - var buddy = new ByteBuddy(); - buddy.redefine(ServerLevel.class) - .visit(Advice.to(ServerLevelAdvice.class).on(ElementMatchers.isConstructor() - .and(ElementMatchers.takesArgument(0, MinecraftServer.class)) - .and(ElementMatchers.takesArgument(5, LevelStem.class)))) - .make() - .load(ServerLevel.class.getClassLoader(), Agent.installed()); + new AgentBuilder.Default() + .disableClassFormatChanges() + .with(AgentBuilder.RedefinitionStrategy.RETRANSFORMATION) + .type(ElementMatchers.is(ServerLevel.class)) + .transform((builder, typeDescription, classLoader, module, protectionDomain) -> + builder.visit(Advice.to(ServerLevelAdvice.class).on(ElementMatchers.isConstructor() + .and(ElementMatchers.takesArgument(0, MinecraftServer.class)) + .and(ElementMatchers.takesArgument(5, LevelStem.class))))) + .installOn(Agent.getInstrumentation()); + ByteBuddy buddy = new ByteBuddy(); for (Class clazz : List.of(ChunkAccess.class, ProtoChunk.class)) { buddy.redefine(clazz) .visit(Advice.to(ChunkAccessAdvice.class).on(ElementMatchers.isMethod().and(ElementMatchers.takesArguments(ShortList.class, int.class)))) @@ -1155,6 +1190,17 @@ public class NMSBinding implements INMSBinding { return false; } + @Override + public void ensureServerLevelInjection() { + if (!injected.get()) + return; + try { + Agent.getInstrumentation().retransformClasses(ServerLevel.class); + } catch (Throwable e) { + IrisLogging.error(C.RED + "Failed to re-apply ServerLevel injection"); + } + } + @Override public KMap> getBlockProperties() { KMap> states = new KMap<>(); @@ -1210,14 +1256,14 @@ public class NMSBinding implements INMSBinding { @Advice.OnMethodEnter static void enter( @Advice.Argument(0) MinecraftServer server, - @Advice.Argument(2) LevelStorageSource.LevelStorageAccess levelStorageAccess, + @Advice.Argument(4) ResourceKey dimensionKey, @Advice.Argument(value = 5, readOnly = false) LevelStem levelStem ) { - if (levelStorageAccess == null) + if (dimensionKey == null) return; try { - String levelId = levelStorageAccess.getLevelId(); + String levelId = dimensionKey.identifier().getPath(); if (levelId == null || levelId.isBlank()) { return; } 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 84b6a7f46..9ecadc364 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 @@ -21,7 +21,7 @@ package art.arcane.iris; import art.arcane.iris.engine.IrisWorldManager; import art.arcane.iris.engine.framework.EngineWorldManagerProvider; -import art.arcane.iris.core.splash.IrisSplashPackScanner; +import art.arcane.iris.core.splash.IrisSplashComposer; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.IrisWorlds; import art.arcane.iris.core.ServerConfigurator; @@ -1265,55 +1265,8 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { private void printPacks() { File packFolder = Iris.service(StudioSVC.class).getWorkspaceFolder(); - List packs = collectSplashPacks(packFolder); - if (packs.isEmpty()) - return; - Iris.info("Custom Dimensions: " + packs.size()); - for (SplashPackMetadata pack : packs) { - printPack(pack); - } - } - - static List collectSplashPacks(File packFolder) { - List scanned = IrisSplashPackScanner.collect(packFolder, Iris::reportError); - if (scanned.isEmpty()) { - return Collections.emptyList(); - } - - List packs = new ArrayList<>(scanned.size()); - for (IrisSplashPackScanner.SplashPackMetadata metadata : scanned) { - packs.add(new SplashPackMetadata(metadata.name(), metadata.version())); - } - return packs; - } - - static SplashPackMetadata readSplashPack(File pack) { - IrisSplashPackScanner.SplashPackMetadata metadata = IrisSplashPackScanner.read(pack, Iris::reportError); - if (metadata == null) { - return null; - } - return new SplashPackMetadata(metadata.name(), metadata.version()); - } - - private void printPack(SplashPackMetadata pack) { - Iris.info(" " + pack.name() + " v" + pack.version()); - } - - static final class SplashPackMetadata { - private final String name; - private final String version; - - SplashPackMetadata(String name, String version) { - this.name = name; - this.version = version; - } - - String name() { - return name; - } - - String version() { - return version; + for (String line : IrisSplashComposer.composePackLines(packFolder, Iris::reportError)) { + Iris.info(line); } } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandPregen.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandPregen.java index a68b4ff5c..38293816d 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandPregen.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandPregen.java @@ -26,6 +26,7 @@ import art.arcane.iris.util.common.director.DirectorExecutor; import art.arcane.volmlib.util.director.annotations.Director; import art.arcane.volmlib.util.director.annotations.Param; import art.arcane.iris.util.common.format.C; +import art.arcane.volmlib.util.format.Form; import art.arcane.volmlib.util.math.Position2; import org.bukkit.World; import org.bukkit.util.Vector; @@ -83,4 +84,23 @@ public class CommandPregen implements DirectorExecutor { sender().sendMessage(C.YELLOW + "No active pregeneration tasks to pause/unpause."); } } + + @Director(description = "Show the active pregeneration status") + public void status() { + PregeneratorJob.PregenProgress progress = PregeneratorJob.progressSnapshot(); + if (progress == null) { + sender().sendMessage(C.YELLOW + "No active pregeneration task."); + return; + } + + String world = progress.worldName() == null ? "?" : progress.worldName(); + sender().sendMessage(C.GREEN + "Pregen " + C.GOLD + world + C.GREEN + ": " + C.GOLD + Form.f(progress.generated()) + "/" + Form.f(progress.totalChunks()) + + C.GREEN + " (" + C.GOLD + String.format("%.1f", progress.percent()) + "%" + C.GREEN + ")" + + (progress.paused() ? C.YELLOW + " PAUSED" : "")); + sender().sendMessage(C.GREEN + "Speed: " + C.GOLD + Form.f((int) progress.chunksPerSecond()) + "/s" + C.GREEN + + " ETA: " + C.GOLD + Form.duration(progress.eta(), 2) + C.GREEN + + " Elapsed: " + C.GOLD + Form.duration(progress.elapsed(), 2) + C.GREEN + + " Method: " + C.GOLD + progress.method() + + (progress.failed() > 0 ? C.RED + " Failed: " + Form.f(progress.failed()) : "")); + } } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandWhat.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandWhat.java index b3d7c03e3..d04f6e570 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandWhat.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandWhat.java @@ -94,7 +94,7 @@ public class CommandWhat implements DirectorExecutor { @Director(description = "What region am i in?", origin = DirectorOrigin.PLAYER) public void region() { try { - Chunk chunk = world().getChunkAt(player().getLocation().getBlockZ() / 16, player().getLocation().getBlockZ() / 16); + Chunk chunk = world().getChunkAt(player().getLocation().getBlockX() >> 4, player().getLocation().getBlockZ() >> 4); IrisRegion r = EngineBukkitOps.getRegion(engine(), chunk); sender().sendMessage("IRegion: " + r.getLoadKey() + " (" + r.getName() + ")"); diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisEngineSVC.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisEngineSVC.java index 745ac1cc7..152a79aa6 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisEngineSVC.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisEngineSVC.java @@ -20,6 +20,7 @@ import art.arcane.iris.util.project.stream.utility.CachedDoubleStream2D; import art.arcane.iris.util.project.stream.utility.CachedStream2D; import art.arcane.iris.util.project.stream.utility.CachedStream3D; import art.arcane.iris.core.gui.PregeneratorJob; +import art.arcane.iris.core.pregenerator.MantleHeapPressure; import lombok.Synchronized; import org.bukkit.Bukkit; import org.bukkit.World; @@ -317,7 +318,11 @@ public class IrisEngineSVC implements IrisService { } try { - engine.getMantle().trim(activeIdleDuration(engine), activeTectonicLimit(engine)); + if (pregenTargets(engine) && MantleHeapPressure.overHighWater()) { + engine.getMantle().trim(0L, 0); + } else { + engine.getMantle().trim(activeIdleDuration(engine), activeTectonicLimit(engine)); + } } catch (Throwable e) { if (isMantleClosed(e)) { close(); @@ -349,7 +354,12 @@ public class IrisEngineSVC implements IrisService { try { long unloadStart = System.currentTimeMillis(); - int count = engine.getMantle().unloadTectonicPlate(IrisSettings.get().getPerformance().getEngineSVC().forceMulticoreWrite ? 0 : activeTectonicLimit(engine)); + boolean heapPressure = pregenTargets(engine) && MantleHeapPressure.overHighWater(); + int unloadLimit = (heapPressure || IrisSettings.get().getPerformance().getEngineSVC().forceMulticoreWrite) ? 0 : activeTectonicLimit(engine); + int count = engine.getMantle().unloadTectonicPlate(unloadLimit); + if (heapPressure && MantleHeapPressure.overPanicWater()) { + MantleHeapPressure.requestPanicReclaim(); + } if (count > 0) { IrisLogging.debug(C.GOLD + "Unloaded " + C.YELLOW + count + " TectonicPlates in " + C.RED + Form.duration(System.currentTimeMillis() - unloadStart, 2)); } @@ -385,7 +395,7 @@ public class IrisEngineSVC implements IrisService { return limit; } - return Math.max(limit, IrisSettings.get().getPregen().getMaxResidentTectonicPlates()); + return Math.max(limit, IrisSettings.get().getPregen().getEffectiveResidentTectonicPlates(engine.getHeight())); } private long activeIdleDuration(Engine engine) { @@ -433,7 +443,7 @@ public class IrisEngineSVC implements IrisService { } PregeneratorJob pregeneratorJob = PregeneratorJob.getInstance(); - boolean pregeneratorTargetsWorld = pregeneratorJob != null && pregeneratorJob.targetsWorld(world); + boolean pregeneratorTargetsWorld = pregeneratorJob != null && pregeneratorJob.targetsWorldName(world.getName()); return shouldSkipMantleReductionForMaintenance(maintenanceActive, pregeneratorTargetsWorld); } } diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisDiagnosticsTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisDiagnosticsTest.java index 0d3b768e5..1d7953523 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisDiagnosticsTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisDiagnosticsTest.java @@ -1,5 +1,6 @@ package art.arcane.iris; +import art.arcane.iris.core.splash.IrisSplashPackScanner; import org.junit.Test; import java.io.ByteArrayOutputStream; @@ -34,7 +35,7 @@ public class IrisDiagnosticsTest { } @Test - public void collectSplashPacksSkipsInternalAndInvalidFolders() throws Exception { + public void splashPackScanSkipsInternalAndInvalidFolders() throws Exception { Path root = Files.createTempDirectory("iris-splash"); try { Path validPack = root.resolve("overworld"); @@ -50,9 +51,9 @@ public class IrisDiagnosticsTest { ByteArrayOutputStream output = new ByteArrayOutputStream(); PrintStream originalErr = System.err; System.setErr(new PrintStream(output, true, StandardCharsets.UTF_8)); - List packs; + List packs; try { - packs = Iris.collectSplashPacks(root.toFile()); + packs = IrisSplashPackScanner.collect(root.toFile(), Iris::reportError); } finally { System.setErr(originalErr); } diff --git a/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricModdedLoader.java b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricModdedLoader.java index 930a0f8c1..a0a44e33e 100644 --- a/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricModdedLoader.java +++ b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricModdedLoader.java @@ -19,6 +19,7 @@ package art.arcane.iris.fabric; import art.arcane.iris.modded.ModdedLoader; +import net.fabricmc.api.EnvType; import net.fabricmc.loader.api.FabricLoader; import net.fabricmc.loader.api.ModContainer; import net.minecraft.server.MinecraftServer; @@ -52,6 +53,11 @@ public final class FabricModdedLoader implements ModdedLoader { return instance instanceof MinecraftServer server ? server : null; } + @Override + public boolean clientEnvironment() { + return FabricLoader.getInstance().getEnvironmentType() == EnvType.CLIENT; + } + @Override public Path configDir() { return FabricLoader.getInstance().getConfigDir(); diff --git a/adapters/fabric/src/main/java/art/arcane/iris/fabric/IrisFabricBootstrap.java b/adapters/fabric/src/main/java/art/arcane/iris/fabric/IrisFabricBootstrap.java index 2ebea92ad..75ff8ecdc 100644 --- a/adapters/fabric/src/main/java/art/arcane/iris/fabric/IrisFabricBootstrap.java +++ b/adapters/fabric/src/main/java/art/arcane/iris/fabric/IrisFabricBootstrap.java @@ -20,12 +20,7 @@ package art.arcane.iris.fabric; import art.arcane.iris.modded.IrisModdedChunkGenerator; import art.arcane.iris.modded.ModdedEngineBootstrap; -import art.arcane.iris.modded.ModdedIrisLog; -import art.arcane.iris.modded.ModdedParityProbe; -import art.arcane.iris.modded.ModdedWorldCheck; -import art.arcane.iris.modded.ModdedWorldEngines; import art.arcane.iris.modded.command.IrisModdedCommands; -import art.arcane.iris.modded.command.ModdedObjectUndo; import art.arcane.iris.modded.command.ModdedWandService; import com.mojang.brigadier.CommandDispatcher; import net.fabricmc.api.ModInitializer; @@ -34,8 +29,6 @@ import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; import net.fabricmc.fabric.api.event.player.AttackBlockCallback; import net.fabricmc.fabric.api.event.player.UseBlockCallback; -import net.fabricmc.loader.api.FabricLoader; -import net.fabricmc.loader.api.ModContainer; import net.minecraft.commands.CommandBuildContext; import net.minecraft.commands.CommandSourceStack; import net.minecraft.commands.Commands; @@ -54,26 +47,10 @@ import net.minecraft.world.phys.BlockHitResult; public final class IrisFabricBootstrap implements ModInitializer { @Override public void onInitialize() { - ModdedEngineBootstrap.initialize(new FabricModdedLoader()); - FabricLoader loader = FabricLoader.getInstance(); - String modVersion = loader.getModContainer("irisworldgen") - .map((ModContainer container) -> container.getMetadata().getVersion().getFriendlyString()) - .orElse("unknown"); - String minecraftVersion = loader.getModContainer("minecraft") - .map((ModContainer container) -> container.getMetadata().getVersion().getFriendlyString()) - .orElse("unknown"); - ModdedIrisLog.info("Iris " + modVersion + " bootstrapping on Minecraft " + minecraftVersion + " (Fabric)"); - - ModdedEngineBootstrap.selfTest(IrisFabricBootstrap.class.getClassLoader()); - ModdedEngineBootstrap.bind(); - Registry.register(BuiltInRegistries.CHUNK_GENERATOR, Identifier.fromNamespaceAndPath("irisworldgen", "iris"), IrisModdedChunkGenerator.CODEC); - ModdedIrisLog.info("Iris chunk generator registered as irisworldgen:iris"); - ServerLifecycleEvents.SERVER_STOPPING.register((MinecraftServer server) -> { - ModdedObjectUndo.clearAll(); - ModdedWandService.clearAll(); - ModdedWorldEngines.shutdown(); - ModdedEngineBootstrap.stop(); - }); + ModdedEngineBootstrap.bootCommon(new FabricModdedLoader(), "Fabric", + () -> Registry.register(BuiltInRegistries.CHUNK_GENERATOR, Identifier.fromNamespaceAndPath("irisworldgen", "iris"), IrisModdedChunkGenerator.CODEC)); + ServerLifecycleEvents.SERVER_STARTING.register((MinecraftServer server) -> ModdedEngineBootstrap.start(server)); + ServerLifecycleEvents.SERVER_STOPPING.register((MinecraftServer server) -> ModdedEngineBootstrap.stop()); CommandRegistrationCallback.EVENT.register((CommandDispatcher dispatcher, CommandBuildContext buildContext, Commands.CommandSelection selection) -> IrisModdedCommands.register(dispatcher)); AttackBlockCallback.EVENT.register((Player player, Level level, InteractionHand hand, BlockPos pos, Direction direction) -> ModdedWandService.attackBlock(player, level, hand, pos) ? InteractionResult.SUCCESS : InteractionResult.PASS); @@ -83,17 +60,5 @@ public final class IrisFabricBootstrap implements ModInitializer { ModdedEngineBootstrap.tick(server); ModdedWandService.serverTick(server); }); - - String parity = System.getProperty("iris.parity"); - if (parity != null) { - ModdedIrisLog.info("Iris parity probe armed: " + parity); - ModdedParityProbe.schedule(parity); - } - - String worldCheck = System.getProperty("iris.worldcheck"); - if (worldCheck != null) { - ModdedIrisLog.info("Iris world check armed"); - ModdedWorldCheck.schedule(); - } } } diff --git a/adapters/forge/src/main/java/art/arcane/iris/forge/ForgeModdedLoader.java b/adapters/forge/src/main/java/art/arcane/iris/forge/ForgeModdedLoader.java index 271b43843..445f28e34 100644 --- a/adapters/forge/src/main/java/art/arcane/iris/forge/ForgeModdedLoader.java +++ b/adapters/forge/src/main/java/art/arcane/iris/forge/ForgeModdedLoader.java @@ -21,6 +21,7 @@ package art.arcane.iris.forge; import art.arcane.iris.modded.ModdedLoader; import net.minecraft.server.MinecraftServer; import net.minecraftforge.fml.ModList; +import net.minecraftforge.fml.loading.FMLEnvironment; import net.minecraftforge.fml.loading.FMLLoader; import net.minecraftforge.fml.loading.FMLPaths; import net.minecraftforge.forgespi.language.IModFileInfo; @@ -52,6 +53,11 @@ public final class ForgeModdedLoader implements ModdedLoader { return ServerLifecycleHooks.getCurrentServer(); } + @Override + public boolean clientEnvironment() { + return FMLEnvironment.dist.isClient(); + } + @Override public Path configDir() { return FMLPaths.CONFIGDIR.get(); diff --git a/adapters/forge/src/main/java/art/arcane/iris/forge/IrisForgeBootstrap.java b/adapters/forge/src/main/java/art/arcane/iris/forge/IrisForgeBootstrap.java index 7b949d51b..b9fe969d4 100644 --- a/adapters/forge/src/main/java/art/arcane/iris/forge/IrisForgeBootstrap.java +++ b/adapters/forge/src/main/java/art/arcane/iris/forge/IrisForgeBootstrap.java @@ -21,12 +21,7 @@ package art.arcane.iris.forge; import art.arcane.iris.modded.IrisModdedChunkGenerator; import art.arcane.iris.modded.ModdedEngineBootstrap; import art.arcane.iris.modded.ModdedForcedDatapack; -import art.arcane.iris.modded.ModdedIrisLog; -import art.arcane.iris.modded.ModdedParityProbe; -import art.arcane.iris.modded.ModdedWorldCheck; -import art.arcane.iris.modded.ModdedWorldEngines; import art.arcane.iris.modded.command.IrisModdedCommands; -import art.arcane.iris.modded.command.ModdedObjectUndo; import art.arcane.iris.modded.command.ModdedWandService; import com.mojang.serialization.MapCodec; import net.minecraft.core.registries.Registries; @@ -36,13 +31,11 @@ import net.minecraftforge.event.AddPackFindersEvent; import net.minecraftforge.event.RegisterCommandsEvent; import net.minecraftforge.event.TickEvent; import net.minecraftforge.event.entity.player.PlayerInteractEvent; +import net.minecraftforge.event.server.ServerStartingEvent; import net.minecraftforge.event.server.ServerStoppingEvent; -import net.minecraftforge.fml.ModContainer; -import net.minecraftforge.fml.ModList; import net.minecraftforge.fml.common.Mod; import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext; import net.minecraftforge.fml.loading.FMLLoader; -import net.minecraftforge.fml.loading.VersionInfo; import net.minecraftforge.registries.DeferredRegister; import java.util.function.Predicate; @@ -50,27 +43,14 @@ import java.util.function.Predicate; @Mod("irisworldgen") public final class IrisForgeBootstrap { public IrisForgeBootstrap(FMLJavaModLoadingContext context) { - ModdedEngineBootstrap.initialize(new ForgeModdedLoader()); - String modVersion = ModList.getModContainerById("irisworldgen") - .map((ModContainer container) -> container.getModInfo().getVersion().toString()) - .orElse("unknown"); - VersionInfo versionInfo = FMLLoader.versionInfo(); - ModdedIrisLog.info("Iris " + modVersion + " bootstrapping on Minecraft " + versionInfo.mcVersion() + " (Forge " + versionInfo.forgeVersion() + ")"); - - ModdedEngineBootstrap.selfTest(IrisForgeBootstrap.class.getClassLoader()); - ModdedEngineBootstrap.bind(); - - DeferredRegister> chunkGenerators = DeferredRegister.create(Registries.CHUNK_GENERATOR, "irisworldgen"); - chunkGenerators.register("iris", () -> IrisModdedChunkGenerator.CODEC); - chunkGenerators.register(context.getModBusGroup()); - ModdedIrisLog.info("Iris chunk generator registered as irisworldgen:iris"); - - ServerStoppingEvent.BUS.addListener((ServerStoppingEvent event) -> { - ModdedObjectUndo.clearAll(); - ModdedWandService.clearAll(); - ModdedWorldEngines.shutdown(); - ModdedEngineBootstrap.stop(); + ModdedEngineBootstrap.bootCommon(new ForgeModdedLoader(), "Forge " + FMLLoader.versionInfo().forgeVersion(), () -> { + DeferredRegister> chunkGenerators = DeferredRegister.create(Registries.CHUNK_GENERATOR, "irisworldgen"); + chunkGenerators.register("iris", () -> IrisModdedChunkGenerator.CODEC); + chunkGenerators.register(context.getModBusGroup()); }); + + ServerStartingEvent.BUS.addListener((ServerStartingEvent event) -> ModdedEngineBootstrap.start(event.getServer())); + ServerStoppingEvent.BUS.addListener((ServerStoppingEvent event) -> ModdedEngineBootstrap.stop()); AddPackFindersEvent.BUS.addListener((AddPackFindersEvent event) -> { if (event.getPackType() == PackType.SERVER_DATA) { event.addRepositorySource(ModdedForcedDatapack.repositorySource()); @@ -85,17 +65,5 @@ public final class IrisForgeBootstrap { ModdedEngineBootstrap.tick(event.server()); ModdedWandService.serverTick(event.server()); }); - - String parity = System.getProperty("iris.parity"); - if (parity != null) { - ModdedIrisLog.info("Iris parity probe armed: " + parity); - ModdedParityProbe.schedule(parity); - } - - String worldCheck = System.getProperty("iris.worldcheck"); - if (worldCheck != null) { - ModdedIrisLog.info("Iris world check armed"); - ModdedWorldCheck.schedule(); - } } } 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 15b42518f..b90b7d4d8 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 @@ -19,6 +19,7 @@ package art.arcane.iris.modded; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.GenerationSessionException; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.PlatformBiome; import art.arcane.iris.spi.PlatformBlockState; @@ -64,6 +65,7 @@ import org.slf4j.LoggerFactory; import java.util.EnumSet; import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; @@ -76,28 +78,39 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { ).apply(instance, IrisModdedChunkGenerator::new)); private final String dimensionKey; + private final String defaultPack; + private final String defaultDimensionKey; private final ConcurrentHashMap> biomeHolders = new ConcurrentHashMap<>(); + private final Set missingBiomeWarnings = ConcurrentHashMap.newKeySet(); private final AtomicBoolean announced = new AtomicBoolean(false); private volatile Engine engine; - private volatile String activePackKey; + private volatile String activePack; + private volatile String activeDimensionKey; private volatile long seedOverride = Long.MIN_VALUE; + private volatile long lastChunkGenAt = 0L; public IrisModdedChunkGenerator(BiomeSource biomeSource, String dimensionKey) { super(biomeSource); this.dimensionKey = dimensionKey; - this.activePackKey = dimensionKey; + int colon = dimensionKey.indexOf(':'); + this.defaultPack = colon >= 0 ? dimensionKey.substring(0, colon) : dimensionKey; + this.defaultDimensionKey = colon >= 0 ? dimensionKey.substring(colon + 1) : dimensionKey; + this.activePack = defaultPack; + this.activeDimensionKey = defaultDimensionKey; } - public synchronized void repoint(String packKey, long seed) { + public synchronized void repoint(String pack, String packDimensionKey, long seed) { ServerLevel level = boundLevel(); if (level != null) { ModdedWorldEngines.evict(level); } - this.activePackKey = packKey; + this.activePack = pack; + this.activeDimensionKey = packDimensionKey; this.seedOverride = seed; this.engine = null; this.announced.set(false); this.biomeHolders.clear(); + this.missingBiomeWarnings.clear(); } public synchronized void unbindEngine() { @@ -108,14 +121,19 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { this.engine = null; this.announced.set(false); this.biomeHolders.clear(); + this.missingBiomeWarnings.clear(); } public synchronized void resetToDefault() { - repoint(dimensionKey, Long.MIN_VALUE); + repoint(defaultPack, defaultDimensionKey, Long.MIN_VALUE); } - public String activePackKey() { - return activePackKey; + public String activePack() { + return activePack; + } + + public String activeDimensionKey() { + return activeDimensionKey; } @Override @@ -149,7 +167,7 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { if (level == null) { throw new IllegalStateException("Iris generator '" + dimensionKey + "' has no bound ServerLevel yet"); } - Engine created = ModdedWorldEngines.get(level, activePackKey, seedOverride); + Engine created = ModdedWorldEngines.get(level, activePack, activeDimensionKey, seedOverride); engine = created; return created; } @@ -179,10 +197,20 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { return engine(); } + public long lastChunkGenAt() { + return lastChunkGenAt; + } + + public void onHotload() { + biomeHolders.clear(); + missingBiomeWarnings.clear(); + } + @Override public CompletableFuture fillFromNoise(Blender blender, RandomState randomState, StructureManager structureManager, ChunkAccess chunk) { Engine generationEngine = engine(); ChunkPos pos = chunk.getPos(); + lastChunkGenAt = System.currentTimeMillis(); if (announced.compareAndSet(false, true)) { LOGGER.info("Iris generating {} through IrisModdedChunkGenerator (dim={} first chunk {},{})", dimensionKey, generationEngine.getDimension().getLoadKey(), pos.x(), pos.z()); @@ -197,6 +225,13 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { Hunk biomes = Hunk.newArrayHunk(16, height, 16); try { generationEngine.generate(pos.getMinBlockX(), pos.getMinBlockZ(), blocks, biomes, false); + } catch (GenerationSessionException e) { + if (e.isExpectedTeardown()) { + LOGGER.debug("Iris chunk {},{} skipped: engine sealed for hotload/teardown", pos.x(), pos.z()); + return CompletableFuture.completedFuture(chunk); + } + LOGGER.error("Iris failed to generate chunk {},{}", pos.x(), pos.z(), e); + throw new IllegalStateException("Iris generation failed for chunk " + pos.x() + "," + pos.z(), e); } catch (Throwable e) { LOGGER.error("Iris failed to generate chunk {},{}", pos.x(), pos.z(), e); throw new IllegalStateException("Iris generation failed for chunk " + pos.x() + "," + pos.z(), e); @@ -232,7 +267,12 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { } Identifier identifier = Identifier.tryParse(key); Optional> reference = identifier == null ? Optional.empty() : registry.get(identifier); - Holder resolved = reference.>map((Holder.Reference value) -> value).orElseGet(() -> fallbackBiome(registry)); + Holder resolved = reference.>map((Holder.Reference value) -> value).orElseGet(() -> { + if (missingBiomeWarnings.size() < 256 && missingBiomeWarnings.add(key)) { + LOGGER.warn("Iris biome '{}' (pack {}) is not in the biome registry; writing minecraft:plains. Restart so the forced datapack registers the pack biomes.", key, activePack); + } + return fallbackBiome(registry); + }); Holder raced = biomeHolders.putIfAbsent(key, resolved); return raced != null ? raced : resolved; } 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 2ac79bf28..2be9308b2 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 @@ -98,11 +98,11 @@ public final class ModdedBiomeWriter implements PlatformBiomeWriter { if (owner == null) { return -1; } - org.bukkit.block.Biome derivative = owner.getVanillaDerivative(); - if (derivative == null || derivative.getKey() == null) { + String derivativeKey = owner.getVanillaDerivativeKey(); + if (derivativeKey == null) { return -1; } - return idForKey(registry, derivative.getKey().toString()); + return idForKey(registry, derivativeKey); } private IrisBiome findCustomBiomeOwner(String dimensionLoadKey, String customBiomeId) { 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 941e8483c..1b3b1cfe0 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 @@ -18,7 +18,10 @@ package art.arcane.iris.modded; +import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.IrisDimension; +import net.minecraft.core.BlockPos; import net.minecraft.core.Holder; import net.minecraft.core.Registry; import net.minecraft.core.RegistryAccess; @@ -28,7 +31,9 @@ import net.minecraft.resources.ResourceKey; import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.level.TicketType; import net.minecraft.world.entity.Relative; +import net.minecraft.world.level.ChunkPos; import net.minecraft.world.level.Level; import net.minecraft.world.level.biome.Biome; import net.minecraft.world.level.biome.BiomeManager; @@ -45,9 +50,13 @@ import net.minecraft.world.level.storage.WorldData; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.File; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executor; @@ -55,6 +64,8 @@ public final class ModdedDimensionManager { private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); private static final Object LOCK = new Object(); private static final ConcurrentHashMap HANDLES = new ConcurrentHashMap<>(); + private static final Set TYPE_FALLBACK_WARNINGS = ConcurrentHashMap.newKeySet(); + private static final TicketType TELEPORT_WARM_TICKET = new TicketType(TicketType.NO_TIMEOUT, TicketType.FLAG_LOADING); private static volatile ModdedServerAccess access; private ModdedDimensionManager() { @@ -64,6 +75,10 @@ public final class ModdedDimensionManager { access = serverAccess; } + public static void clear() { + HANDLES.clear(); + } + public static Handle handle(String dimensionId) { return HANDLES.get(dimensionId); } @@ -97,14 +112,16 @@ public final class ModdedDimensionManager { return generator.commandEngine(); } - public static Handle create(MinecraftServer server, String dimensionId, String packKey, long seed) { + public static Handle create(MinecraftServer server, String dimensionId, String pack, String packDimensionKey, long seed) { ModdedServerAccess serverAccess = requireAccess(); synchronized (LOCK) { ResourceKey key = levelKey(dimensionId); Handle existing = HANDLES.get(dimensionId); if (existing != null && serverAccess.hasLevel(server, key)) { - existing.generator().repoint(packKey, seed); - return existing; + existing.generator().repoint(pack, packDimensionKey, seed); + Handle refreshed = new Handle(dimensionId, pack, packDimensionKey, seed, existing.level(), existing.generator()); + HANDLES.put(dimensionId, refreshed); + return refreshed; } if (serverAccess.hasLevel(server, key)) { ServerLevel present = level(server, dimensionId); @@ -112,40 +129,36 @@ public final class ModdedDimensionManager { throw new IllegalStateException("Iris cannot inject dimension '" + dimensionId + "': a non-Iris level with that id is already loaded"); } LOGGER.warn("Iris dimension '{}' is already present in the running server; reusing it", dimensionId); - generator.repoint(packKey, seed); - Handle handle = new Handle(dimensionId, packKey, seed, present, generator); + generator.repoint(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, packKey, seed); + Handle handle = inject(server, serverAccess, dimensionId, key, pack, packDimensionKey, seed); HANDLES.put(dimensionId, handle); - LOGGER.info("Iris injected runtime dimension '{}' (pack={} seed={})", dimensionId, packKey, seed); + LOGGER.info("Iris injected runtime dimension '{}' (pack={} dim={} seed={})", dimensionId, pack, packDimensionKey, seed); return handle; } catch (Throwable e) { - LOGGER.error("Iris failed to inject runtime dimension '{}' (pack={} seed={})", dimensionId, packKey, seed, e); + LOGGER.error("Iris failed to inject runtime dimension '{}' (pack={} dim={} seed={})", dimensionId, pack, packDimensionKey, seed, e); throw new IllegalStateException("Iris runtime dimension injection failed for " + dimensionId, e); } } } - public static Handle createPersistent(MinecraftServer server, String dimensionId, String packKey, long seed) { - Handle handle = create(server, dimensionId, packKey, seed); - ModdedDimensionRegistryStore.put(server, new ModdedDimensionRegistryStore.PersistentDimension(dimensionId, packKey, seed)); + public static Handle createPersistent(MinecraftServer server, String dimensionId, String pack, String packDimensionKey, long seed) { + Handle handle = create(server, dimensionId, pack, packDimensionKey, seed); + ModdedDimensionRegistryStore.put(server, new ModdedDimensionRegistryStore.PersistentDimension(dimensionId, pack, packDimensionKey, seed)); return handle; } - public static boolean removePersistent(MinecraftServer server, String dimensionId) { - boolean removed = remove(server, dimensionId); + public static boolean removePersistent(MinecraftServer server, String dimensionId, boolean wipeStorage) { + boolean removed = remove(server, dimensionId, wipeStorage); ModdedDimensionRegistryStore.remove(server, dimensionId); return removed; } - public static boolean remove(MinecraftServer server, String dimensionId) { - return remove(server, dimensionId, false); - } - public static boolean remove(MinecraftServer server, String dimensionId, boolean wipeStorage) { ModdedServerAccess serverAccess = requireAccess(); synchronized (LOCK) { @@ -153,6 +166,9 @@ public final class ModdedDimensionManager { ServerLevel level = level(server, dimensionId); if (level == null) { HANDLES.remove(dimensionId); + if (wipeStorage) { + ModdedDimensionStorage.wipe(server, key); + } return false; } try { @@ -184,29 +200,78 @@ public final class ModdedDimensionManager { } int blockX = (int) Math.floor(x); int blockZ = (int) Math.floor(z); + ChunkPos chunkPos = new ChunkPos(blockX >> 4, blockZ >> 4); + if (level.getChunkSource().hasChunk(chunkPos.x(), chunkPos.z())) { + completeTeleport(player, level, x, y, z, blockX, blockZ); + return true; + } + UUID playerId = player.getUUID(); + CompletableFuture + .supplyAsync(() -> level.getChunkSource().addTicketAndLoadWithRadius(TELEPORT_WARM_TICKET, chunkPos, 1), server) + .thenCompose((CompletableFuture inner) -> inner) + .whenComplete((Object result, Throwable error) -> server.execute(() -> { + level.getChunkSource().removeTicketWithRadius(TELEPORT_WARM_TICKET, chunkPos, 1); + if (error != null) { + LOGGER.warn("Iris chunk warm for teleport into '{}' at {},{} failed: {}", dimensionId, chunkPos.x(), chunkPos.z(), error.toString()); + } + ServerPlayer target = server.getPlayerList().getPlayer(playerId); + if (target == null) { + return; + } + completeTeleport(target, level, x, y, z, blockX, blockZ); + })); + return true; + } + + private static void completeTeleport(ServerPlayer player, ServerLevel level, double x, double y, double z, int blockX, int blockZ) { double targetY = y; if (y == Double.MIN_VALUE) { targetY = level.getHeight(Heightmap.Types.MOTION_BLOCKING, blockX, blockZ); } player.teleportTo(level, x, targetY, z, Set.of(), player.getYRot(), player.getXRot(), false); - return true; } - private static Holder resolveDimensionType(RegistryAccess registryAccess) { + private static Holder resolveDimensionType(RegistryAccess registryAccess, String pack, String packDimensionKey) { Registry registry = registryAccess.lookupOrThrow(Registries.DIMENSION_TYPE); + IrisDimension dimension = loadPackDimension(pack, packDimensionKey); + if (dimension != null) { + String typeRef = ModdedForcedDatapack.dimensionTypeRef(dimension); + ResourceKey typeKey = ResourceKey.create(Registries.DIMENSION_TYPE, Identifier.parse(typeRef)); + Optional> packType = registry.get(typeKey); + if (packType.isPresent()) { + return packType.get(); + } + if (TYPE_FALLBACK_WARNINGS.add(typeRef)) { + LOGGER.warn("Iris dimension type {} (pack {} dim {}) is not registered yet; injecting with fallback heights. Restart the server so the forced datapack installs it.", typeRef, pack, packDimensionKey); + } + } ResourceKey studioPool = ResourceKey.create(Registries.DIMENSION_TYPE, Identifier.parse("irisworldgen:studio_pool")); return registry.get(studioPool) .map(reference -> (Holder) reference) .orElseGet(() -> registry.getOrThrow(BuiltinDimensionTypes.OVERWORLD)); } - private static Handle inject(MinecraftServer server, ModdedServerAccess serverAccess, String dimensionId, ResourceKey key, String packKey, long seed) { + private static IrisDimension loadPackDimension(String pack, String packDimensionKey) { + try { + File packFolder = ModdedWorldEngines.packFolder(pack); + if (!packFolder.isDirectory()) { + return null; + } + return IrisData.get(packFolder).getDimensionLoader().load(packDimensionKey); + } catch (Throwable e) { + LOGGER.warn("Iris could not load pack '{}' dimension '{}' for dimension type resolution: {}", pack, packDimensionKey, e.toString()); + return null; + } + } + + private static Handle inject(MinecraftServer server, ModdedServerAccess serverAccess, String dimensionId, ResourceKey key, String pack, String packDimensionKey, long seed) { RegistryAccess registryAccess = server.registryAccess(); - Holder dimensionType = resolveDimensionType(registryAccess); + Holder dimensionType = resolveDimensionType(registryAccess, pack, packDimensionKey); Holder plains = registryAccess.lookupOrThrow(Registries.BIOME).getOrThrow(Biomes.PLAINS); FixedBiomeSource biomeSource = new FixedBiomeSource(plains); - IrisModdedChunkGenerator generator = new IrisModdedChunkGenerator(biomeSource, packKey); - generator.repoint(packKey, seed); + String generatorRef = pack.equals(packDimensionKey) ? pack : pack + ":" + packDimensionKey; + IrisModdedChunkGenerator generator = new IrisModdedChunkGenerator(biomeSource, generatorRef); + generator.repoint(pack, packDimensionKey, seed); LevelStem stem = new LevelStem(dimensionType, generator); WorldData worldData = server.getWorldData(); @@ -231,20 +296,21 @@ public final class ModdedDimensionManager { serverAccess.putLevel(server, key, level); server.getPlayerList().addWorldborderListener(level); - return new Handle(dimensionId, packKey, seed, level, generator); + return new Handle(dimensionId, pack, packDimensionKey, seed, level, generator); } - private static void evacuate(MinecraftServer server, ServerLevel from) { + public static int evacuate(MinecraftServer server, ServerLevel from) { ServerLevel fallback = server.overworld(); if (fallback == from) { - return; + return 0; } - int spawnX = 0; - int spawnZ = 0; - int spawnY = fallback.getHeight(Heightmap.Types.MOTION_BLOCKING, spawnX, spawnZ); - for (ServerPlayer player : new ArrayList<>(from.players())) { - player.teleportTo(fallback, spawnX + 0.5D, spawnY, spawnZ + 0.5D, Set.of(), player.getYRot(), player.getXRot(), false); + BlockPos spawn = fallback.getRespawnData().pos(); + int spawnY = fallback.getHeight(Heightmap.Types.MOTION_BLOCKING, spawn.getX(), spawn.getZ()); + List players = new ArrayList<>(from.players()); + for (ServerPlayer player : players) { + player.teleportTo(fallback, spawn.getX() + 0.5D, spawnY, spawn.getZ() + 0.5D, Set.of(), player.getYRot(), player.getXRot(), false); } + return players.size(); } private static ResourceKey levelKey(String dimensionId) { @@ -260,6 +326,6 @@ public final class ModdedDimensionManager { return bound; } - public record Handle(String dimensionId, String packKey, long seed, ServerLevel level, IrisModdedChunkGenerator generator) { + public record Handle(String dimensionId, String pack, String packDimensionKey, long seed, ServerLevel level, IrisModdedChunkGenerator generator) { } } 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 541d6b534..439e4840f 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 @@ -57,12 +57,20 @@ public final class ModdedDimensionRegistryStore { for (int index = 0; index < entries.length(); index++) { JSONObject entry = entries.getJSONObject(index); String id = entry.optString("id", null); - String packKey = entry.optString("packKey", null); - if (id == null || packKey == null) { + if (id == null) { continue; } - long seed = entry.optLong("seed", 0L); - deduplicated.put(id, new PersistentDimension(id, packKey, seed)); + String pack = entry.optString("pack", null); + String dimension = entry.optString("dimension", null); + if (pack == null || dimension == null) { + LOGGER.error("Iris registry entry '{}' in {} has no pack/dimension fields; skipping it. Re-create the world with /iris world enable", id, file); + continue; + } + if (!entry.has("seed")) { + LOGGER.warn("Iris registry entry '{}' in {} has no seed; skipping it. Re-create the world with /iris world enable", id, file); + continue; + } + deduplicated.put(id, new PersistentDimension(id, pack, dimension, entry.getLong("seed"))); } return new ArrayList<>(deduplicated.values()); } catch (RuntimeException | IOException e) { @@ -98,7 +106,8 @@ public final class ModdedDimensionRegistryStore { for (PersistentDimension dimension : dimensions) { JSONObject entry = new JSONObject(); entry.put("id", dimension.id()); - entry.put("packKey", dimension.packKey()); + entry.put("pack", dimension.pack()); + entry.put("dimension", dimension.dimension()); entry.put("seed", dimension.seed()); entries.put(entry); } @@ -118,6 +127,6 @@ public final class ModdedDimensionRegistryStore { return server.getWorldPath(LevelResource.ROOT).resolve("iris").resolve(FILE_NAME); } - public record PersistentDimension(String id, String packKey, long seed) { + public record PersistentDimension(String id, String pack, String dimension, long seed) { } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java index 507a1450a..06ecbee61 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java @@ -18,6 +18,7 @@ package art.arcane.iris.modded; +import art.arcane.iris.core.gui.GuiHost; import art.arcane.iris.engine.decorator.DecoratorPlatformHooks; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.EngineWorldManager; @@ -28,8 +29,16 @@ import art.arcane.iris.engine.object.IrisObjectRotation; import art.arcane.iris.engine.object.TileData; import art.arcane.iris.modded.api.ModdedCustomContentRegistry; import art.arcane.iris.modded.command.ModdedGuiHost; +import art.arcane.iris.modded.command.ModdedObjectUndo; +import art.arcane.iris.modded.command.ModdedPregenJob; +import art.arcane.iris.modded.command.ModdedStudioCommands; +import art.arcane.iris.modded.command.ModdedWandService; +import art.arcane.iris.modded.service.ModdedChunkUpdateService; +import art.arcane.iris.modded.service.ModdedEngineMaintenanceService; import art.arcane.iris.modded.service.ModdedLogFilterService; import art.arcane.iris.modded.service.ModdedPreservationService; +import art.arcane.iris.modded.service.ModdedSettingsHotloadService; +import art.arcane.iris.modded.service.ModdedStudioHotloadService; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.IrisServices; import net.minecraft.server.MinecraftServer; @@ -51,6 +60,7 @@ public final class ModdedEngineBootstrap { private static final ModdedServiceManager SERVICE_MANAGER = new ModdedServiceManager(); private static volatile ModdedLoader loader; private static volatile ModdedPlatform platform; + private static volatile MinecraftServer currentServer; private ModdedEngineBootstrap() { } @@ -71,32 +81,76 @@ public final class ModdedEngineBootstrap { SERVICE_MANAGER.tick(server); } - public static void stop() { - ModdedPrimaryWorldRouter.clear(); - SERVICE_MANAGER.disableAll(); + public static void start(MinecraftServer server) { + currentServer = server; + bind(); + ModdedStartup.reset(); ModdedScheduler scheduler = schedulerOrNull(); if (scheduler != null) { - scheduler.shutdown(); + scheduler.reset(); } + SERVICE_MANAGER.enableAll(); } - public static void initialize(ModdedLoader moddedLoader) { + public static void stop() { + ModdedPregenJob.shutdown(); + ModdedObjectUndo.clearAll(); + ModdedWandService.clearAll(); + ModdedStudioCommands.clear(); + ModdedWorldEngines.shutdown(); + ModdedPrimaryWorldRouter.clear(); + SERVICE_MANAGER.disableAll(); + ModdedDimensionManager.clear(); + ModdedScheduler scheduler = schedulerOrNull(); + if (scheduler != null) { + scheduler.reset(); + } + ModdedStartup.reset(); + currentServer = null; + } + + public static void bootCommon(ModdedLoader moddedLoader, String loaderDescription, Runnable chunkGeneratorRegistration) { loader = moddedLoader; + ModdedIrisLog.info("Iris " + moddedLoader.modVersion() + " bootstrapping on Minecraft " + moddedLoader.minecraftVersion() + " (" + loaderDescription + ")"); + selfTest(moddedLoader.getClass().getClassLoader()); + bind(); + chunkGeneratorRegistration.run(); + ModdedIrisLog.info("Iris chunk generator registered as irisworldgen:iris"); + armParityProbe(); + armWorldCheck(); + } + + private static void armParityProbe() { + String parity = System.getProperty("iris.parity"); + if (parity == null) { + return; + } + ModdedIrisLog.info("Iris parity probe armed: " + parity); + ModdedParityProbe.schedule(parity); + } + + private static void armWorldCheck() { + if (System.getProperty("iris.worldcheck") == null) { + return; + } + ModdedIrisLog.info("Iris world check armed"); + ModdedWorldCheck.schedule(); } public static ModdedLoader loader() { ModdedLoader bound = loader; if (bound == null) { - throw new IllegalStateException("Iris modded loader is not initialized; the loader bootstrap must call ModdedEngineBootstrap.initialize first"); + throw new IllegalStateException("Iris modded loader is not initialized; the loader bootstrap must call ModdedEngineBootstrap.bootCommon first"); } return bound; } public static MinecraftServer currentServer() { - return loader().currentServer(); + MinecraftServer tracked = currentServer; + return tracked != null ? tracked : loader().currentServer(); } - public static void selfTest(ClassLoader classLoader) { + private static void selfTest(ClassLoader classLoader) { int loadedClasses = 0; for (String className : CORE_SELF_TEST_CLASSES) { try { @@ -124,6 +178,7 @@ public final class ModdedEngineBootstrap { return platform; } ModdedLoader boundLoader = loader(); + GuiHost.suppressDesktop(boundLoader.clientEnvironment()); ModdedPlatform created = new ModdedPlatform(boundLoader); IrisPlatforms.bind(created); ModdedDimensionManager.bindAccess(new ModdedServerLevels()); @@ -135,11 +190,18 @@ public final class ModdedEngineBootstrap { DecoratorPlatformHooks.bind(decoratorHooks, decoratorHooks); ModdedPreservationService preservation = SERVICE_MANAGER.register(ModdedPreservationService.class, new ModdedPreservationService()); SERVICE_MANAGER.register(ModdedLogFilterService.class, new ModdedLogFilterService()); + SERVICE_MANAGER.register(ModdedEngineMaintenanceService.class, new ModdedEngineMaintenanceService()); + SERVICE_MANAGER.register(ModdedSettingsHotloadService.class, new ModdedSettingsHotloadService()); + SERVICE_MANAGER.register(ModdedStudioHotloadService.class, new ModdedStudioHotloadService()); + SERVICE_MANAGER.register(ModdedChunkUpdateService.class, new ModdedChunkUpdateService()); IrisServices.register(PreservationRegistry.class, preservation); IrisServices.register(EngineWorldManagerProvider.class, (EngineWorldManagerProvider) (Engine engine) -> new InertWorldManager()); ModdedCustomContentRegistry.discover(); platform = created; SERVICE_MANAGER.enableAll(); + if (boundLoader.clientEnvironment()) { + ModdedStartup.prefetchDefaultPack(); + } ModdedIrisSplash.print(boundLoader); return created; } 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 98a3483b9..d94f84e75 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 @@ -121,10 +121,11 @@ public final class ModdedForcedDatapack { IDataFixer fixer = DataVersion.getLatest().get(); int packCount = 0; + KList presetIds = new KList<>(); File[] packs = packsRoot().toFile().listFiles(File::isDirectory); if (packs != null) { for (File pack : packs) { - if (installPack(pack, fixer, folders, seenBiomes)) { + if (installPack(pack, fixer, folders, seenBiomes, presetIds)) { packCount++; } } @@ -132,28 +133,113 @@ public final class ModdedForcedDatapack { writePackMeta(packDirectory); writeStudioPoolType(packDirectory); - LOGGER.info("Iris forced startup datapack regenerated: {} pack(s), {} custom biome(s) at {}", packCount, seenBiomes.size(), packDirectory); + if (!presetIds.isEmpty()) { + writeWorldPresetTag(packDirectory, presetIds); + } + LOGGER.info("Iris forced startup datapack regenerated: {} pack(s), {} world preset(s), {} custom biome(s) at {}", packCount, presetIds.size(), seenBiomes.size(), packDirectory); return packDirectory; } - private static boolean installPack(File packFolder, IDataFixer fixer, KList folders, KSet seenBiomes) { - String packKey = packFolder.getName(); - if (!new File(packFolder, "dimensions/" + packKey + ".json").isFile()) { + private static boolean installPack(File packFolder, IDataFixer fixer, KList folders, KSet seenBiomes, KList presetIds) { + String packName = packFolder.getName(); + File[] dimensionFiles = new File(packFolder, "dimensions").listFiles((File file) -> file.isFile() && file.getName().endsWith(".json")); + if (dimensionFiles == null || dimensionFiles.length == 0) { return false; } - try { - IrisData data = IrisData.get(packFolder); - IrisDimension dimension = data.getDimensionLoader().load(packKey); - if (dimension == null) { - return false; + boolean installed = false; + for (File dimensionFile : dimensionFiles) { + String dimensionKey = dimensionFile.getName().substring(0, dimensionFile.getName().length() - ".json".length()); + try { + IrisData data = IrisData.get(packFolder); + IrisDimension dimension = data.getDimensionLoader().load(dimensionKey); + if (dimension == null) { + continue; + } + dimension.installBiomes(fixer, () -> data, folders, seenBiomes); + writeDimensionType(folders, fixer, dimension); + String presetKey = dimensionKey.equals(packName) ? packName : packName + "_" + dimensionKey; + writeWorldPreset(folders, dimension, packName, dimensionKey, presetKey); + presetIds.add("irisworldgen:" + presetKey); + installed = true; + } catch (Throwable e) { + LOGGER.error("Iris failed to install forced datapack content for pack '{}' dimension '{}'", packName, dimensionKey, e); } - dimension.installBiomes(fixer, () -> data, folders, seenBiomes); - writeDimensionType(folders, fixer, dimension); - return true; - } catch (Throwable e) { - LOGGER.error("Iris failed to install forced datapack content for pack '{}'", packKey, e); - return false; } + return installed; + } + + public static String dimensionTypeRef(IrisDimension dimension) { + return fitsStudioPool(dimension) + ? "irisworldgen:" + STUDIO_POOL_TYPE_KEY + : "irisworldgen:" + dimension.getDimensionTypeKey(); + } + + private static void writeWorldPreset(KList folders, IrisDimension dimension, String packName, String dimensionKey, String presetKey) throws IOException { + String dimensionRef = dimensionKey.equals(packName) ? packName : packName + ":" + dimensionKey; + String json = worldPresetJson(dimensionRef, dimensionTypeRef(dimension)); + for (File parent : folders) { + Path output = parent.toPath().resolve(PACK_FOLDER).resolve("data").resolve("irisworldgen").resolve("worldgen").resolve("world_preset").resolve(presetKey + ".json"); + Files.createDirectories(output.getParent()); + Files.writeString(output, json, StandardCharsets.UTF_8); + } + } + + private static String worldPresetJson(String dimensionRef, String dimensionTypeRef) { + return "{\n" + + " \"dimensions\": {\n" + + " \"minecraft:overworld\": {\n" + + " \"type\": \"" + dimensionTypeRef + "\",\n" + + " \"generator\": {\n" + + " \"type\": \"irisworldgen:iris\",\n" + + " \"biome_source\": {\n" + + " \"type\": \"minecraft:fixed\",\n" + + " \"biome\": \"minecraft:plains\"\n" + + " },\n" + + " \"dimension\": \"" + dimensionRef + "\"\n" + + " }\n" + + " },\n" + + " \"minecraft:the_nether\": {\n" + + " \"type\": \"minecraft:the_nether\",\n" + + " \"generator\": {\n" + + " \"type\": \"minecraft:noise\",\n" + + " \"settings\": \"minecraft:nether\",\n" + + " \"biome_source\": {\n" + + " \"type\": \"minecraft:multi_noise\",\n" + + " \"preset\": \"minecraft:nether\"\n" + + " }\n" + + " }\n" + + " },\n" + + " \"minecraft:the_end\": {\n" + + " \"type\": \"minecraft:the_end\",\n" + + " \"generator\": {\n" + + " \"type\": \"minecraft:noise\",\n" + + " \"settings\": \"minecraft:end\",\n" + + " \"biome_source\": {\n" + + " \"type\": \"minecraft:the_end\"\n" + + " }\n" + + " }\n" + + " }\n" + + " }\n" + + "}\n"; + } + + private static void writeWorldPresetTag(Path packDirectory, KList presetIds) throws IOException { + StringBuilder values = new StringBuilder(); + for (int i = 0; i < presetIds.size(); i++) { + if (i > 0) { + values.append(",\n"); + } + values.append(" \"").append(presetIds.get(i)).append("\""); + } + String json = "{\n" + + " \"replace\": false,\n" + + " \"values\": [\n" + + values + + "\n ]\n" + + "}\n"; + Path output = packDirectory.resolve("data").resolve("minecraft").resolve("tags").resolve("worldgen").resolve("world_preset").resolve("normal.json"); + Files.createDirectories(output.getParent()); + Files.writeString(output, json, StandardCharsets.UTF_8); } private static void writeDimensionType(KList folders, IDataFixer fixer, IrisDimension dimension) throws IOException { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedIrisSplash.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedIrisSplash.java index fe0ca422a..aaef0e8c7 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedIrisSplash.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedIrisSplash.java @@ -18,17 +18,12 @@ package art.arcane.iris.modded; -import art.arcane.iris.BuildConstants; import art.arcane.iris.core.IrisSettings; -import art.arcane.iris.core.splash.IrisSplashPackScanner; -import art.arcane.iris.core.splash.IrisSplashPackScanner.SplashPackMetadata; +import art.arcane.iris.core.splash.IrisSplashComposer; import art.arcane.iris.core.splash.IrisSplashRenderer; import art.arcane.iris.spi.IrisLogging; import java.io.File; -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; -import java.util.List; public final class ModdedIrisSplash { @@ -44,44 +39,16 @@ public final class ModdedIrisSplash { private static void printPacks(ModdedLoader loader) { File packFolder = loader.configDir().resolve("irisworldgen").resolve("packs").toFile(); - List packs = IrisSplashPackScanner.collect(packFolder, IrisLogging::reportError); - if (packs.isEmpty()) { - return; - } - - IrisLogging.info("Custom Dimensions: " + packs.size()); - for (SplashPackMetadata pack : packs) { - IrisLogging.info(" " + pack.name() + " v" + pack.version()); + for (String line : IrisSplashComposer.composePackLines(packFolder, IrisLogging::reportError)) { + IrisLogging.info(line); } } private static void printLogo(ModdedLoader loader) { - String padding = " ".repeat(4); - String version = loader.modVersion(); - String releaseTrain = getReleaseTrain(version); - String startupDate = LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE); - int javaVersion = getJavaVersion(); + String serverLine = loader.platformName() + " / Minecraft " + loader.minecraftVersion(); String[] splash = IrisSplashRenderer.renderPlain(); - String[] info = new String[]{ - "", - " Iris, Dimension Engine [" + releaseTrain + " RC.1.1.6]", - " Version: " + version, - " By: Volmit Software (Arcane Arts)", - " Server: " + loader.platformName() + " / Minecraft " + loader.minecraftVersion(), - " Java: " + javaVersion + " | Date: " + startupDate, - " Commit: " + BuildConstants.COMMIT + "/" + BuildConstants.ENVIRONMENT, - "", - "", - "", - "" - }; - - StringBuilder builder = new StringBuilder("\n\n"); - for (int i = 0; i < splash.length; i++) { - builder.append(padding).append(splash[i]).append(info[i]).append('\n'); - } - - IrisLogging.info(builder.toString()); + String[] info = IrisSplashComposer.composeInfo(loader.modVersion(), serverLine, IrisSplashComposer.InfoStyle.PLAIN); + IrisLogging.info(IrisSplashComposer.compose(splash, info)); } private static boolean isLogoEnabled() { @@ -92,30 +59,4 @@ public final class ModdedIrisSplash { return true; } } - - private static int getJavaVersion() { - String version = System.getProperty("java.version"); - if (version.startsWith("1.")) { - version = version.substring(2, 3); - } else { - int dot = version.indexOf('.'); - if (dot != -1) { - version = version.substring(0, dot); - } - } - return Integer.parseInt(version); - } - - private static String getReleaseTrain(String version) { - String value = version == null ? "unknown" : version; - int suffixIndex = value.indexOf('-'); - if (suffixIndex >= 0) { - value = value.substring(0, suffixIndex); - } - String[] split = value.split("\\."); - if (split.length >= 2) { - return split[0] + "." + split[1]; - } - return value; - } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedItemTranslator.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedItemTranslator.java new file mode 100644 index 000000000..9cad4c030 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedItemTranslator.java @@ -0,0 +1,290 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 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.modded; + +import art.arcane.iris.engine.object.InventorySlotType; +import art.arcane.iris.engine.object.IrisAttributeModifier; +import art.arcane.iris.engine.object.IrisEnchantment; +import art.arcane.iris.engine.object.IrisLoot; +import art.arcane.iris.engine.object.IrisLootTable; +import art.arcane.iris.engine.object.NoiseStyle; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.collection.KMap; +import art.arcane.volmlib.util.format.Form; +import art.arcane.volmlib.util.json.JSONObject; +import art.arcane.volmlib.util.math.RNG; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import net.minecraft.core.Holder; +import net.minecraft.core.Registry; +import net.minecraft.core.component.DataComponentType; +import net.minecraft.core.component.DataComponents; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.core.registries.Registries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.TagParser; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.util.Unit; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.item.component.CustomData; +import net.minecraft.world.item.component.CustomModelData; +import net.minecraft.world.item.component.DyedItemColor; +import net.minecraft.world.item.component.ItemLore; +import net.minecraft.world.item.enchantment.Enchantment; +import net.minecraft.world.item.enchantment.ItemEnchantments; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +public final class ModdedItemTranslator { + private static final Set WARNED = ConcurrentHashMap.newKeySet(); + private static final Field TYPE_FIELD = lootField("type"); + private static final Field DYE_COLOR_FIELD = lootField("dyeColor"); + private static final String COLOR_CODES = "0123456789AaBbCcDdEeFfKkLlMmNnOoRrXx"; + + private ModdedItemTranslator() { + } + + public static KList loot(IrisLootTable table, RNG rng, InventorySlotType slot, ServerLevel level, int x, int y, int z) { + KList out = new KList<>(); + KList entries = table.getLoot(); + if (entries.isEmpty()) { + return out; + } + + int m = 0; + int c = 0; + int mx = rng.i(table.getMinPicked(), table.getMaxPicked()); + + while (m < mx && c++ < table.getMaxTries()) { + IrisLoot entry = entries.get(rng.i(entries.size())); + if (entry.getSlotTypes() != slot) { + continue; + } + ItemStack item = item(entry, table, rng, level, x, y, z); + if (item != null && !item.isEmpty()) { + out.add(item); + m++; + } + } + + return out; + } + + public static ItemStack item(IrisLoot loot, IrisLootTable table, RNG rng, ServerLevel level, int x, int y, int z) { + if (loot.getChance().aquire(() -> NoiseStyle.STATIC.create(rng)).fit(1, loot.getRarity() * table.getRarity(), x, y, z) != 1) { + return null; + } + + try { + ItemStack stack = baseStack(loot, rng); + if (stack == null || stack.isEmpty()) { + return null; + } + applyComponents(loot, stack, rng, level); + applyCustomNbt(stack, loot.getCustomNbt()); + return stack; + } catch (Throwable e) { + IrisLogging.reportError(e); + return null; + } + } + + private static ItemStack baseStack(IrisLoot loot, RNG rng) { + String raw = readString(TYPE_FIELD, loot); + if (raw == null || raw.isBlank()) { + return null; + } + + String key = raw.toLowerCase(Locale.ROOT); + Identifier id = Identifier.tryParse(key.contains(":") ? key : "minecraft:" + key); + if (id == null || !BuiltInRegistries.ITEM.containsKey(id)) { + warnOnce("item:" + key, "Iris loot: unknown item '" + raw + "'"); + return null; + } + + Item item = BuiltInRegistries.ITEM.getValue(id); + return new ItemStack(item, Math.max(1, rng.i(loot.getMinAmount(), loot.getMaxAmount()))); + } + + private static void applyComponents(IrisLoot loot, ItemStack stack, RNG rng, ServerLevel level) { + applyEnchantments(loot, stack, rng, level); + consumeAttributeDraws(loot, rng); + + if (loot.isUnbreakable()) { + stack.set(DataComponents.UNBREAKABLE, Unit.INSTANCE); + } + + if (loot.getItemFlags().isNotEmpty()) { + warnOnce("itemFlags", "Iris loot: itemFlags are not supported on modded; skipping flags"); + } + + if (loot.getCustomModel() != null) { + stack.set(DataComponents.CUSTOM_MODEL_DATA, new CustomModelData(List.of(loot.getCustomModel().floatValue()), List.of(), List.of(), List.of())); + } + + if (stack.getMaxDamage() > 0) { + int max = stack.getMaxDamage(); + int damage = (int) Math.round(Math.max(0, Math.min(max, (1D - rng.d(loot.getMinDurability(), loot.getMaxDurability())) * max))); + stack.set(DataComponents.DAMAGE, damage); + } + + if (loot.getLeatherColor() != null) { + try { + int rgb = Integer.decode(loot.getLeatherColor()) & 0xFFFFFF; + stack.set(DataComponents.DYED_COLOR, new DyedItemColor(rgb)); + } catch (NumberFormatException e) { + warnOnce("leatherColor:" + loot.getLeatherColor(), "Iris loot: invalid leatherColor '" + loot.getLeatherColor() + "'"); + } + } + + String dye = readString(DYE_COLOR_FIELD, loot); + if (dye != null) { + warnOnce("dyeColor", "Iris loot: dyeColor is not supported on modded; skipping"); + } + + if (loot.getDisplayName() != null) { + stack.set(DataComponents.CUSTOM_NAME, Component.literal(colorize(loot.getDisplayName()))); + } + + applyLore(loot, stack); + } + + private static void applyEnchantments(IrisLoot loot, ItemStack stack, RNG rng, ServerLevel level) { + if (loot.getEnchantments().isEmpty()) { + return; + } + + Registry registry = level.registryAccess().lookupOrThrow(Registries.ENCHANTMENT); + DataComponentType component = stack.is(Items.ENCHANTED_BOOK) ? DataComponents.STORED_ENCHANTMENTS : DataComponents.ENCHANTMENTS; + ItemEnchantments.Mutable mutable = new ItemEnchantments.Mutable(stack.getOrDefault(component, ItemEnchantments.EMPTY)); + boolean changed = false; + + for (IrisEnchantment enchantment : loot.getEnchantments()) { + String name = enchantment.getEnchantment(); + if (name == null || name.isBlank()) { + continue; + } + String key = name.toLowerCase(Locale.ROOT); + Identifier id = Identifier.tryParse(key.contains(":") ? key : "minecraft:" + key); + Optional> holder = id == null ? Optional.empty() : registry.get(id); + if (holder.isEmpty()) { + warnOnce("enchantment:" + key, "Iris loot: unknown enchantment '" + name + "'"); + continue; + } + if (rng.nextDouble() < enchantment.getChance()) { + mutable.set(holder.get(), enchantment.getLevel(rng)); + changed = true; + } + } + + if (changed) { + stack.set(component, mutable.toImmutable()); + } + } + + private static void consumeAttributeDraws(IrisLoot loot, RNG rng) { + if (loot.getAttributes().isEmpty()) { + return; + } + warnOnce("attributes", "Iris loot: attribute modifiers are not supported on modded; skipping"); + for (IrisAttributeModifier attribute : loot.getAttributes()) { + if (rng.nextDouble() < attribute.getChance()) { + attribute.getAmount(rng); + } + } + } + + private static void applyLore(IrisLoot loot, ItemStack stack) { + if (loot.getLore().isEmpty()) { + return; + } + + List lines = new ArrayList<>(); + for (String line : loot.getLore()) { + String colored = colorize(line); + if (colored.length() > 24) { + for (String wrapped : Form.wrapWords(colored, 24).split("\\Q\n\\E")) { + lines.add(Component.literal(wrapped.trim())); + } + } else { + lines.add(Component.literal(colored)); + } + } + stack.set(DataComponents.LORE, new ItemLore(lines)); + } + + private static void applyCustomNbt(ItemStack stack, KMap customNbt) { + if (customNbt == null || customNbt.isEmpty()) { + return; + } + try { + CompoundTag tag = TagParser.parseCompoundFully(new JSONObject(customNbt).toString()); + tag.merge(stack.getOrDefault(DataComponents.CUSTOM_DATA, CustomData.EMPTY).copyTag()); + stack.set(DataComponents.CUSTOM_DATA, CustomData.of(tag)); + } catch (CommandSyntaxException e) { + warnOnce("customNbt:" + e.getMessage(), "Iris loot: invalid customNbt: " + e.getMessage()); + } + } + + private static String colorize(String text) { + char[] chars = text.toCharArray(); + for (int i = 0; i < chars.length - 1; i++) { + if (chars[i] == '&' && COLOR_CODES.indexOf(chars[i + 1]) > -1) { + chars[i] = '§'; + chars[i + 1] = Character.toLowerCase(chars[i + 1]); + } + } + return new String(chars); + } + + private static void warnOnce(String key, String message) { + if (WARNED.add(key)) { + IrisLogging.warn(message); + } + } + + private static Field lootField(String name) { + try { + Field field = IrisLoot.class.getDeclaredField(name); + field.setAccessible(true); + return field; + } catch (NoSuchFieldException e) { + throw new IllegalStateException("IrisLoot field missing: " + name, e); + } + } + + private static String readString(Field field, IrisLoot loot) { + try { + Object value = field.get(loot); + return value == null ? null : value.toString(); + } catch (IllegalAccessException e) { + return null; + } + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedLoader.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedLoader.java index bae6ae66c..df6ae2081 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedLoader.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedLoader.java @@ -32,6 +32,8 @@ public interface ModdedLoader { MinecraftServer currentServer(); + boolean clientEnvironment(); + Path configDir(); File modJar(); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedLootApplier.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedLootApplier.java new file mode 100644 index 000000000..f8c8169e4 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedLootApplier.java @@ -0,0 +1,291 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 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.modded; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.PlacedObject; +import art.arcane.iris.engine.object.IObjectLoot; +import art.arcane.iris.engine.object.InventorySlotType; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisLootTable; +import art.arcane.iris.engine.object.IrisObjectLoot; +import art.arcane.iris.engine.object.IrisObjectPlacement; +import art.arcane.iris.engine.object.IrisObjectVanillaLoot; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.matter.Matter; +import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.Container; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.ChestBlock; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.RandomizableContainerBlockEntity; +import net.minecraft.world.level.block.state.BlockState; + +import java.util.ArrayList; +import java.util.List; + +public final class ModdedLootApplier { + private ModdedLootApplier() { + } + + private record Candidate(IrisLootTable table, Identifier vanillaId, int weight) { + } + + private record Resolution(KList tables, Identifier vanillaTable) { + boolean isEmpty() { + return tables.isEmpty() && vanillaTable == null; + } + } + + public static void apply(Engine engine, ServerLevel level, BlockPos pos, BlockState state, MantleChunk mc, RNG rng, int localX, int localZ) { + Resolution resolution = resolveTables(engine, rng, pos, state, mc); + if (resolution.isEmpty()) { + return; + } + + if (resolution.vanillaTable() != null) { + BlockEntity blockEntity = level.getBlockEntity(pos); + if (blockEntity instanceof RandomizableContainerBlockEntity randomizable) { + randomizable.setLootTable(ResourceKey.create(Registries.LOOT_TABLE, resolution.vanillaTable()), rng.nextLong()); + } else { + IrisLogging.debug("Iris loot: no randomizable container at " + pos + " for vanilla table " + resolution.vanillaTable()); + } + } + + if (resolution.tables().isEmpty()) { + return; + } + + Container container = resolveContainer(level, pos, state); + if (container == null) { + IrisLogging.debug("Iris loot: no container at " + pos); + return; + } + + KList items = new KList<>(); + for (IrisLootTable table : resolution.tables()) { + if (table == null) { + continue; + } + items.addAll(ModdedItemTranslator.loot(table, rng, InventorySlotType.STORAGE, level, localX, pos.getY(), localZ)); + } + + for (ItemStack item : items) { + addItem(container, item); + } + + scramble(container, rng); + container.setChanged(); + } + + private static Resolution resolveTables(Engine engine, RNG rng, BlockPos pos, BlockState state, MantleChunk mc) { + int rx = pos.getX(); + int rz = pos.getZ(); + int ry = pos.getY() - engine.getWorld().minHeight(); + double he = engine.getComplex().getHeightStream().get(rx, rz); + KList tables = new KList<>(); + Identifier vanillaTable = null; + + PlacedObject po = engine.getObjectPlacement(rx, ry, rz, mc); + if (po != null && po.getPlacement() != null && ModdedBlockResolution.isStorageChest(state)) { + Candidate picked = pickPlacementTable(engine, po.getPlacement(), state, rng); + if (picked != null) { + if (picked.table() != null) { + tables.add(picked.table()); + } else { + vanillaTable = picked.vanillaId(); + } + if (po.getPlacement().isOverrideGlobalLoot()) { + return new Resolution(tables, vanillaTable); + } + } + } + + IrisRegion region = engine.getComplex().getRegionStream().get(rx, rz); + IrisBiome biomeSurface = engine.getComplex().getTrueBiomeStream().get(rx, rz); + IrisBiome biomeUnder = ry < he ? engine.getCaveBiome(rx, ry, rz) : biomeSurface; + + double multiplier = 1D * engine.getDimension().getLoot().getMultiplier() * region.getLoot().getMultiplier() * biomeSurface.getLoot().getMultiplier() * biomeUnder.getLoot().getMultiplier(); + boolean fallback = tables.isEmpty() && vanillaTable == null; + engine.injectTables(tables, engine.getDimension().getLoot(), fallback); + engine.injectTables(tables, region.getLoot(), fallback); + engine.injectTables(tables, biomeSurface.getLoot(), fallback); + engine.injectTables(tables, biomeUnder.getLoot(), fallback); + + if (tables.isNotEmpty()) { + int target = (int) Math.round(tables.size() * multiplier); + + while (tables.size() < target && tables.isNotEmpty()) { + tables.add(tables.get(rng.i(tables.size() - 1))); + } + + while (tables.size() > target && tables.isNotEmpty()) { + tables.remove(rng.i(tables.size() - 1)); + } + } + + return new Resolution(tables, vanillaTable); + } + + private static Candidate pickPlacementTable(Engine engine, IrisObjectPlacement placement, BlockState state, RNG rng) { + List exact = new ArrayList<>(); + List basic = new ArrayList<>(); + List global = new ArrayList<>(); + + for (IrisObjectLoot loot : placement.getLoot()) { + if (loot == null) { + continue; + } + IrisLootTable table = engine.getData().getLootLoader().load(loot.getName()); + if (table == null) { + IrisLogging.warn("Couldn't find loot table " + loot.getName()); + continue; + } + bucket(engine, loot, new Candidate(table, null, loot.getWeight()), state, exact, basic, global); + } + + for (IrisObjectVanillaLoot loot : placement.getVanillaLoot()) { + if (loot == null) { + continue; + } + Identifier id = loot.getName() == null ? null : Identifier.tryParse(loot.getName()); + if (id == null) { + IrisLogging.warn("Couldn't parse vanilla loot table " + loot.getName()); + continue; + } + bucket(engine, loot, new Candidate(null, id, loot.getWeight()), state, exact, basic, global); + } + + List pool = !exact.isEmpty() ? exact : !basic.isEmpty() ? basic : global; + return pickWeighted(pool, rng); + } + + private static void bucket(Engine engine, IObjectLoot loot, Candidate candidate, BlockState state, List exact, List basic, List global) { + if (loot.getFilter().isEmpty()) { + global.add(candidate); + return; + } + + for (PlatformBlockState filterState : loot.getFilter(engine.getData())) { + BlockState filter = (BlockState) filterState.nativeHandle(); + if (loot.isExact() ? filter == state : filter.getBlock() == state.getBlock()) { + (loot.isExact() ? exact : basic).add(candidate); + return; + } + } + } + + private static Candidate pickWeighted(List pool, RNG rng) { + if (pool.isEmpty()) { + return null; + } + int total = 0; + for (Candidate candidate : pool) { + total += Math.max(0, candidate.weight()); + } + if (total <= 0) { + return pool.get(rng.nextInt(pool.size())); + } + int pull = rng.nextInt(total); + for (Candidate candidate : pool) { + pull -= Math.max(0, candidate.weight()); + if (pull < 0) { + return candidate; + } + } + return pool.get(pool.size() - 1); + } + + private static Container resolveContainer(ServerLevel level, BlockPos pos, BlockState state) { + if (state.getBlock() instanceof ChestBlock chestBlock) { + Container combined = ChestBlock.getContainer(chestBlock, state, level, pos, true); + if (combined != null) { + return combined; + } + } + BlockEntity blockEntity = level.getBlockEntity(pos); + return blockEntity instanceof Container container ? container : null; + } + + private static void addItem(Container container, ItemStack stack) { + if (stack == null || stack.isEmpty()) { + return; + } + for (int i = 0; i < container.getContainerSize() && !stack.isEmpty(); i++) { + ItemStack existing = container.getItem(i); + if (existing.isEmpty()) { + container.setItem(i, stack); + return; + } + if (ItemStack.isSameItemSameComponents(existing, stack) && existing.getCount() < existing.getMaxStackSize()) { + int move = Math.min(stack.getCount(), existing.getMaxStackSize() - existing.getCount()); + existing.grow(move); + stack.shrink(move); + } + } + } + + private static void scramble(Container container, RNG rng) { + int size = container.getContainerSize(); + ItemStack[] items = new ItemStack[size]; + for (int i = 0; i < size; i++) { + items[i] = container.getItem(i); + } + boolean packedFull = false; + + splitting: + for (int i = 0; i < items.length; i++) { + ItemStack is = items[i]; + + if (!is.isEmpty() && is.getCount() > 1 && !packedFull) { + for (int j = 0; j < items.length; j++) { + if (items[j].isEmpty()) { + int take = rng.nextInt(is.getCount()); + take = take == 0 ? 1 : take; + is.setCount(is.getCount() - take); + items[j] = is.copyWithCount(take); + continue splitting; + } + } + + packedFull = true; + } + } + + for (int i = items.length; i > 1; i--) { + int j = rng.nextInt(i); + ItemStack tmp = items[i - 1]; + items[i - 1] = items[j]; + items[j] = tmp; + } + + for (int i = 0; i < size; i++) { + container.setItem(i, items[i]); + } + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPackInstaller.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPackInstaller.java index 1808536dc..b1e006b29 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPackInstaller.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPackInstaller.java @@ -18,25 +18,15 @@ package art.arcane.iris.modded; +import art.arcane.iris.core.pack.PackDownloader; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.zeroturnaround.zip.ZipUtil; +import java.io.File; import java.io.IOException; -import java.io.InputStream; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.StandardCopyOption; -import java.time.Duration; -import java.util.Comparator; -import java.util.List; import java.util.function.Consumer; import java.util.regex.Pattern; -import java.util.stream.Stream; public final class ModdedPackInstaller { private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); @@ -56,87 +46,13 @@ public final class ModdedPackInstaller { return false; } - Path target = configDir.resolve("irisworldgen").resolve("packs").resolve(pack); - String url = "https://codeload.github.com/IrisDimensions/" + pack + "/zip/refs/heads/" + branch; - + File packs = configDir.resolve("irisworldgen").resolve("packs").toFile(); try { - Path work = Files.createTempDirectory("iris-pack-dl"); - Path zip = work.resolve(pack + ".zip"); - HttpClient client = HttpClient.newBuilder() - .followRedirects(HttpClient.Redirect.NORMAL) - .connectTimeout(Duration.ofSeconds(30)) - .build(); - HttpRequest request = HttpRequest.newBuilder(URI.create(url)) - .timeout(Duration.ofMinutes(5)) - .GET() - .build(); - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofInputStream()); - - if (response.statusCode() != 200) { - feedback.accept("Pack download failed: HTTP " + response.statusCode() + " from " + url); - return false; - } - - try (InputStream in = response.body()) { - Files.copy(in, zip, StandardCopyOption.REPLACE_EXISTING); - } - - Path extracted = work.resolve("extracted"); - ZipUtil.unpack(zip.toFile(), extracted.toFile()); - Path root = singleRoot(extracted); - Files.createDirectories(target.getParent()); - deleteRecursively(target); - copyRecursively(root, target); - deleteRecursively(work); - feedback.accept("Iris installed pack '" + pack + "' (branch " + branch + ") into " + target); - return true; - } catch (IOException | InterruptedException error) { + return PackDownloader.download(packs, "IrisDimensions/" + pack, branch, true, false, feedback) != null; + } catch (IOException error) { LOGGER.error("Iris pack download failed for IrisDimensions/{} ({})", pack, branch, error); feedback.accept("Pack download failed: " + error.getClass().getSimpleName() + (error.getMessage() == null ? "" : " - " + error.getMessage())); - - if (error instanceof InterruptedException) { - Thread.currentThread().interrupt(); - } return false; } } - - private static Path singleRoot(Path extracted) throws IOException { - try (Stream entries = Files.list(extracted)) { - List children = entries.toList(); - - if (children.size() == 1 && Files.isDirectory(children.get(0))) { - return children.get(0); - } - - return extracted; - } - } - - private static void copyRecursively(Path source, Path target) throws IOException { - try (Stream walk = Files.walk(source)) { - for (Path path : walk.toList()) { - Path destination = target.resolve(source.relativize(path).toString()); - - if (Files.isDirectory(path)) { - Files.createDirectories(destination); - } else { - Files.createDirectories(destination.getParent()); - Files.copy(path, destination, StandardCopyOption.REPLACE_EXISTING); - } - } - } - } - - private static void deleteRecursively(Path root) throws IOException { - if (!Files.exists(root)) { - return; - } - - try (Stream walk = Files.walk(root)) { - for (Path path : walk.sorted(Comparator.reverseOrder()).toList()) { - Files.delete(path); - } - } - } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java index 6dc71909b..252833b45 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java @@ -21,21 +21,16 @@ package art.arcane.iris.modded; import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.LogLevel; import art.arcane.iris.spi.PlatformBiomeWriter; -import art.arcane.iris.spi.PlatformCapabilities; import art.arcane.iris.spi.PlatformEntityType; -import art.arcane.iris.spi.PlatformItem; import art.arcane.iris.spi.PlatformRegistries; import art.arcane.iris.spi.PlatformScheduler; import art.arcane.iris.spi.PlatformStructureHooks; import net.minecraft.core.BlockPos; import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerLevel; -import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.EntitySpawnReason; import net.minecraft.world.entity.EntityType; -import net.minecraft.world.item.Item; -import net.minecraft.world.item.ItemStack; import java.io.File; import java.util.function.Consumer; @@ -46,7 +41,6 @@ public final class ModdedPlatform implements IrisPlatform { private final ModdedLoader loader; private final ModdedRegistries registries; private final ModdedScheduler scheduler; - private final ModdedCapabilities capabilities; private final ModdedStructureHooks structureHooks; private final ModdedBiomeWriter biomeWriter; @@ -54,7 +48,6 @@ public final class ModdedPlatform implements IrisPlatform { this.loader = loader; this.registries = new ModdedRegistries(loader::currentServer); this.scheduler = new ModdedScheduler(); - this.capabilities = new ModdedCapabilities(); this.structureHooks = new ModdedStructureHooks(loader::currentServer); this.biomeWriter = new ModdedBiomeWriter(loader::currentServer); } @@ -91,11 +84,6 @@ public final class ModdedPlatform implements IrisPlatform { return scheduler; } - @Override - public PlatformCapabilities capabilities() { - return capabilities; - } - @Override public PlatformStructureHooks structureHooks() { return structureHooks; @@ -159,20 +147,6 @@ public final class ModdedPlatform implements IrisPlatform { return entity != null; } - @Override - public boolean giveItem(Object player, String itemKey, int amount) { - if (!(player instanceof ServerPlayer serverPlayer) || itemKey == null || amount <= 0) { - return false; - } - PlatformItem resolved = registries.item(itemKey); - if (resolved == null) { - return false; - } - Item item = (Item) resolved.nativeHandle(); - ItemStack stack = new ItemStack(item, amount); - return serverPlayer.getInventory().add(stack); - } - @Override public void log(LogLevel level, String message) { ModdedIrisLog.log(level, message); @@ -194,26 +168,4 @@ public final class ModdedPlatform implements IrisPlatform { ModdedIrisLog.error("Iris reported error", error); } } - - private static final class ModdedCapabilities implements PlatformCapabilities { - @Override - public boolean customBiomes() { - return true; - } - - @Override - public boolean dataPacks() { - return true; - } - - @Override - public boolean structurePlacement() { - return true; - } - - @Override - public boolean regionizedThreading() { - return false; - } - } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedScheduler.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedScheduler.java index 597560c6c..46db35fc7 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedScheduler.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedScheduler.java @@ -114,10 +114,11 @@ public final class ModdedScheduler implements PlatformScheduler { laterGlobal(task, ticks); } - public void shutdown() { - asyncExecutor.shutdownNow(); + public void reset() { + asyncExecutor.getQueue().clear(); mainQueue.clear(); delayedQueue.clear(); + mainThread = null; } private void drain() { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedServiceManager.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedServiceManager.java index 1ca23c352..5bcd419af 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedServiceManager.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedServiceManager.java @@ -77,7 +77,6 @@ public final class ModdedServiceManager { } enabled = false; forEachReversed(this::disableService); - services.clear(); } private void enableService(ModdedService service) { 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 d636c7ee9..67bacc8c8 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 @@ -18,6 +18,10 @@ package art.arcane.iris.modded; +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.modded.command.ModdedPackCommands; import net.minecraft.server.MinecraftServer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -30,10 +34,26 @@ import java.util.concurrent.atomic.AtomicBoolean; public final class ModdedStartup { private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); private static final AtomicBoolean STARTED = new AtomicBoolean(false); + private static final Object PACK_LOCK = new Object(); private ModdedStartup() { } + public static void reset() { + STARTED.set(false); + } + + public static void prefetchDefaultPack() { + ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull(); + if (scheduler != null) { + scheduler.async(ModdedStartup::ensureDefaultPack); + return; + } + Thread thread = new Thread(ModdedStartup::ensureDefaultPack, "iris-modded-pack-prefetch"); + thread.setDaemon(true); + thread.start(); + } + public static void runOnce(MinecraftServer server) { if (server == null || !STARTED.compareAndSet(false, true)) { return; @@ -42,10 +62,44 @@ public final class ModdedStartup { ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull(); if (scheduler == null) { + validateAllPacks(); ensureDefaultPack(); return; } - scheduler.async(ModdedStartup::ensureDefaultPack); + scheduler.async(() -> { + validateAllPacks(); + ensureDefaultPack(); + }); + } + + public static void validateAllPacks() { + File packsRoot = ModdedPackCommands.packsRoot(); + File[] packDirs = packsRoot.listFiles(File::isDirectory); + if (packDirs == null || packDirs.length == 0) { + return; + } + PackValidationRegistry.clear(); + for (File packDir : packDirs) { + try { + PackValidationResult result = PackValidator.validate(packDir); + PackValidationRegistry.publish(result); + if (!result.isLoadable()) { + LOGGER.error("Iris pack '{}' FAILED validation - world/studio creation will be refused. Reasons:", result.getPackName()); + for (String reason : result.getBlockingErrors()) { + LOGGER.error(" - {}", reason); + } + } else if (!result.getWarnings().isEmpty() || !result.getRemovedUnusedFiles().isEmpty()) { + LOGGER.info("Iris pack '{}' validated ({} unused file(s) quarantined to .iris-trash/, {} warning(s)).", result.getPackName(), result.getRemovedUnusedFiles().size(), result.getWarnings().size()); + for (String warning : result.getWarnings()) { + LOGGER.warn(" [{}] {}", result.getPackName(), warning); + } + } else { + LOGGER.info("Iris pack '{}' validated.", result.getPackName()); + } + } catch (Throwable e) { + LOGGER.error("Iris pack validation failed for '{}'", packDir.getName(), e); + } + } } private static void reinjectPersistentDimensions(MinecraftServer server) { @@ -56,30 +110,32 @@ public final class ModdedStartup { int injected = 0; for (ModdedDimensionRegistryStore.PersistentDimension dimension : dimensions) { try { - ModdedDimensionManager.create(server, dimension.id(), dimension.packKey(), dimension.seed()); + ModdedDimensionManager.create(server, dimension.id(), dimension.pack(), dimension.dimension(), dimension.seed()); injected++; } catch (Throwable e) { - LOGGER.error("Iris failed to re-inject persistent dimension '{}' (pack={} seed={})", dimension.id(), dimension.packKey(), dimension.seed(), e); + LOGGER.error("Iris failed to re-inject persistent dimension '{}' (pack={} dim={} seed={})", dimension.id(), dimension.pack(), dimension.dimension(), dimension.seed(), e); } } LOGGER.info("Iris re-injected {} persistent dimension(s) at startup", injected); } - private static void ensureDefaultPack() { - ModdedModConfig config = ModdedModConfig.get(); - if (!config.autoDownloadDefaultPack()) { - return; - } - String pack = config.defaultPack(); - Path configDir = ModdedEngineBootstrap.loader().configDir(); - File packFolder = configDir.resolve("irisworldgen").resolve("packs").resolve(pack).toFile(); - if (new File(packFolder, "dimensions/" + pack + ".json").isFile()) { - return; - } - LOGGER.info("Iris default pack '{}' missing; downloading IrisDimensions/{} (master)", pack, pack); - boolean installed = ModdedPackInstaller.install(configDir, pack, "master", (String line) -> LOGGER.info("Iris: {}", line)); - if (!installed) { - LOGGER.warn("Iris default pack '{}' could not be downloaded; install it with /iris download {}", pack, pack); + public static void ensureDefaultPack() { + synchronized (PACK_LOCK) { + ModdedModConfig config = ModdedModConfig.get(); + if (!config.autoDownloadDefaultPack()) { + return; + } + String pack = config.defaultPack(); + Path configDir = ModdedEngineBootstrap.loader().configDir(); + File packFolder = new File(ModdedPackCommands.packsRoot(), pack); + if (new File(packFolder, "dimensions/" + pack + ".json").isFile()) { + return; + } + LOGGER.info("Iris default pack '{}' missing; downloading IrisDimensions/{} (master)", pack, pack); + boolean installed = ModdedPackInstaller.install(configDir, pack, "master", (String line) -> LOGGER.info("Iris: {}", line)); + if (!installed) { + LOGGER.warn("Iris default pack '{}' could not be downloaded; install it with /iris download {}", pack, pack); + } } } } 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 0ddc373d8..20c0dcc92 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 @@ -43,12 +43,12 @@ public final class ModdedWorldEngines { private ModdedWorldEngines() { } - public static Engine get(ServerLevel level, String dimensionKey, long seedOverride) { + public static Engine get(ServerLevel level, String pack, String dimensionKey, long seedOverride) { Engine existing = ENGINES.get(level); if (existing != null) { return existing; } - return ENGINES.computeIfAbsent(level, (ServerLevel l) -> create(l, dimensionKey, seedOverride)); + return ENGINES.computeIfAbsent(level, (ServerLevel l) -> create(l, pack, dimensionKey, seedOverride)); } public static Collection activeEngines() { @@ -70,15 +70,15 @@ public final class ModdedWorldEngines { } } - private static Engine create(ServerLevel level, String dimensionKey, long seedOverride) { + private static Engine create(ServerLevel level, String pack, String dimensionKey, long seedOverride) { ModdedEngineBootstrap.bind(); - File pack = resolvePack(dimensionKey); - IrisData data = IrisData.get(pack); + File packDir = resolvePack(pack, dimensionKey); + IrisData data = IrisData.get(packDir); IrisDimension dimension = data.getDimensionLoader().load(dimensionKey); if (dimension == null) { - LOGGER.error("Iris pack at {} does not contain dimension '{}' (expected dimensions/{}.json). Install a matching Iris pack and restart.", - pack.getAbsolutePath(), dimensionKey, dimensionKey); - throw new IllegalStateException("Iris dimension '" + dimensionKey + "' missing from pack " + pack.getAbsolutePath()); + LOGGER.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; @@ -94,24 +94,28 @@ public final class ModdedWorldEngines { int levelMinY = level.getMinY(); int levelMaxY = level.getMinY() + level.getHeight(); - if (dimension.getMinHeight() != levelMinY || dimension.getMaxHeight() != levelMaxY) { - LOGGER.error("Iris pack height mismatch for {}: pack generates {}..{} but the level is {}..{}. Terrain outside the level range will be clipped; ship a matching dimension_type.", + if (dimension.getMinHeight() < levelMinY || dimension.getMaxHeight() > levelMaxY) { + LOGGER.warn("Iris pack height range for {} exceeds the level: pack generates {}..{} but the level is {}..{}. Terrain outside the level range will be clipped; restart so the forced datapack registers the pack dimension type.", level.dimension().identifier(), dimension.getMinHeight(), dimension.getMaxHeight(), levelMinY, levelMaxY); } LOGGER.info("Iris engine up for {}: pack={} dim={} seed={} height={}..{}", - level.dimension().identifier(), pack.getAbsolutePath(), dimension.getLoadKey(), seed, dimension.getMinHeight(), dimension.getMaxHeight()); + level.dimension().identifier(), packDir.getAbsolutePath(), dimension.getLoadKey(), seed, dimension.getMinHeight(), dimension.getMaxHeight()); return engine; } - private static File resolvePack(String dimensionKey) { - File pack = ModdedEngineBootstrap.loader().configDir() + public static File packFolder(String pack) { + return ModdedEngineBootstrap.loader().configDir() .resolve("irisworldgen") .resolve("packs") - .resolve(dimensionKey) + .resolve(pack) .toFile(); - if (pack.isDirectory()) { - return pack; + } + + private static File resolvePack(String pack, String dimensionKey) { + File packDir = packFolder(pack); + if (packDir.isDirectory()) { + return packDir; } String parity = System.getProperty("iris.parity"); @@ -127,17 +131,17 @@ public final class ModdedWorldEngines { } File parityPack = new File(parityPath); if (parityPack.isDirectory()) { - LOGGER.warn("Iris pack missing at {}; falling back to parity pack {}", pack.getAbsolutePath(), parityPack.getAbsolutePath()); + LOGGER.warn("Iris pack missing at {}; falling back to parity pack {}", packDir.getAbsolutePath(), parityPack.getAbsolutePath()); return parityPack; } } LOGGER.error("==============================================================="); - LOGGER.error("Iris pack for dimension '{}' is not installed.", dimensionKey); - LOGGER.error("Expected a pack folder at: {}", pack.getAbsolutePath()); + LOGGER.error("Iris pack '{}' is not installed.", pack); + LOGGER.error("Expected a pack folder at: {}", packDir.getAbsolutePath()); LOGGER.error("Install an Iris pack there (the folder must contain dimensions/{}.json) and restart the server.", dimensionKey); LOGGER.error("==============================================================="); - throw new IllegalStateException("Iris pack not installed: " + pack.getAbsolutePath()); + throw new IllegalStateException("Iris pack not installed: " + packDir.getAbsolutePath()); } public static void shutdown() { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/IrisModdedAPI.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/IrisModdedAPI.java index 1142d3716..b24abcb8d 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/IrisModdedAPI.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/IrisModdedAPI.java @@ -22,17 +22,10 @@ import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.modded.IrisModdedChunkGenerator; import art.arcane.iris.modded.command.ModdedPregenJob; -import art.arcane.iris.modded.command.ModdedPregenMode; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.level.chunk.ChunkGenerator; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicInteger; - public final class IrisModdedAPI { - private static final Map WORLD_MAINTENANCE_DEPTH = new ConcurrentHashMap<>(); - private IrisModdedAPI() { } @@ -64,15 +57,15 @@ public final class IrisModdedAPI { } public static boolean pregenerate(ServerLevel level, int radiusBlocks) { - return pregenerate(level, radiusBlocks, 0, 0, ModdedPregenMode.ASYNC, false); + return pregenerate(level, radiusBlocks, 0, 0, false, true); } - public static boolean pregenerate(ServerLevel level, int radiusBlocks, int centerBlockX, int centerBlockZ, ModdedPregenMode mode, boolean cached) { + public static boolean pregenerate(ServerLevel level, int radiusBlocks, int centerBlockX, int centerBlockZ, boolean sync, boolean cached) { Engine engine = getEngine(level); if (engine == null) { return false; } - return ModdedPregenJob.start(level.getServer(), level, engine, radiusBlocks, centerBlockX, centerBlockZ, false, mode, cached); + return ModdedPregenJob.start(level.getServer(), level, engine, radiusBlocks, centerBlockX, centerBlockZ, false, sync, cached); } public static T getMantleData(ServerLevel level, int x, int y, int z, Class type) { @@ -113,32 +106,4 @@ public final class IrisModdedAPI { public static void registerCustomBlockData(String namespace, String key, String state) { ModdedCustomContentRegistry.registerCustomBlockData(namespace, key, state); } - - public static void beginWorldMaintenance(ServerLevel level) { - if (level == null) { - return; - } - WORLD_MAINTENANCE_DEPTH.computeIfAbsent(level, (ServerLevel l) -> new AtomicInteger()).incrementAndGet(); - } - - public static void endWorldMaintenance(ServerLevel level) { - if (level == null) { - return; - } - AtomicInteger counter = WORLD_MAINTENANCE_DEPTH.get(level); - if (counter == null) { - return; - } - if (counter.decrementAndGet() <= 0) { - WORLD_MAINTENANCE_DEPTH.remove(level); - } - } - - public static boolean isWorldMaintenanceActive(ServerLevel level) { - if (level == null) { - return false; - } - AtomicInteger counter = WORLD_MAINTENANCE_DEPTH.get(level); - return counter != null && counter.get() > 0; - } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java index fb1e35f34..c8aba50e6 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java @@ -18,6 +18,9 @@ package art.arcane.iris.modded.command; +import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.core.gui.GuiHost; +import art.arcane.iris.core.loader.IrisRegistrant; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.IrisStructureLocator; import art.arcane.iris.engine.framework.Locator; @@ -27,6 +30,7 @@ import art.arcane.iris.engine.object.IrisPosition; import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.modded.IrisModdedChunkGenerator; import art.arcane.iris.modded.ModdedBlockState; +import art.arcane.iris.modded.ModdedDimensionManager; import art.arcane.iris.modded.ModdedEngineBootstrap; import art.arcane.iris.modded.ModdedLoader; import art.arcane.iris.modded.ModdedPackInstaller; @@ -39,8 +43,11 @@ import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.arguments.IntegerArgumentType; import com.mojang.brigadier.arguments.LongArgumentType; import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.builder.ArgumentBuilder; import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.mojang.brigadier.builder.RequiredArgumentBuilder; import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; import com.mojang.brigadier.suggestion.SuggestionProvider; import com.mojang.brigadier.suggestion.Suggestions; import com.mojang.brigadier.suggestion.SuggestionsBuilder; @@ -48,14 +55,18 @@ import com.mojang.brigadier.tree.LiteralCommandNode; import net.minecraft.commands.CommandSourceStack; import net.minecraft.commands.Commands; import net.minecraft.commands.SharedSuggestionProvider; +import net.minecraft.commands.arguments.DimensionArgument; +import net.minecraft.commands.arguments.EntityArgument; import net.minecraft.network.chat.Component; import net.minecraft.core.BlockPos; import net.minecraft.core.particles.DustParticleOptions; import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.Identifier; import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.entity.Relative; +import net.minecraft.world.item.ItemStack; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.chunk.ChunkGenerator; import net.minecraft.world.level.levelgen.Heightmap; @@ -64,10 +75,10 @@ import net.minecraft.world.phys.HitResult; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.awt.Desktop; import java.io.File; import java.util.ArrayList; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.TreeMap; @@ -118,10 +129,29 @@ public final class IrisModdedCommands { .executes((CommandContext context) -> what(context.getSource())) .then(Commands.literal("block") .executes((CommandContext context) -> whatBlock(context.getSource()))) + .then(Commands.literal("hand") + .executes((CommandContext context) -> whatHand(context.getSource()))) .then(Commands.literal("markers") .then(Commands.argument("marker", StringArgumentType.greedyString()).suggests(MARKER_TYPES) .executes((CommandContext context) -> whatMarkers(context.getSource(), StringArgumentType.getString(context, "marker")))))); + root.then(Commands.literal("tp").requires(GATE) + .then(Commands.argument("dimension", DimensionArgument.dimension()).suggests(DIMENSION_NAMES) + .executes((CommandContext context) -> tp(context.getSource(), DimensionArgument.getDimension(context, "dimension"), null)) + .then(Commands.argument("player", EntityArgument.player()) + .executes((CommandContext context) -> tp(context.getSource(), DimensionArgument.getDimension(context, "dimension"), EntityArgument.getPlayer(context, "player")))))); + + root.then(Commands.literal("evacuate").requires(GATE) + .executes((CommandContext context) -> evacuate(context.getSource(), null)) + .then(Commands.argument("dimension", DimensionArgument.dimension()).suggests(DIMENSION_NAMES) + .executes((CommandContext context) -> evacuate(context.getSource(), DimensionArgument.getDimension(context, "dimension"))))); + + root.then(Commands.literal("debug").requires(GATE) + .executes((CommandContext context) -> debug(context.getSource()))); + + root.then(Commands.literal("reload").requires(GATE) + .executes((CommandContext context) -> reload(context.getSource()))); + root.then(gotoTree("goto")); root.then(gotoTree("find")); @@ -171,7 +201,7 @@ public final class IrisModdedCommands { private static LiteralArgumentBuilder createTree() { return Commands.literal("create").requires(GATE) .then(Commands.argument("name", StringArgumentType.word()) - .then(Commands.argument("pack", StringArgumentType.word()).suggests(PACK_NAMES) + .then(Commands.argument("pack", StringArgumentType.string()).suggests(PACK_NAMES) .executes((CommandContext context) -> ModdedWorldCommands.createWorld(context.getSource(), StringArgumentType.getString(context, "name"), StringArgumentType.getString(context, "pack"), @@ -231,27 +261,20 @@ public final class IrisModdedCommands { } private static LiteralArgumentBuilder pregenTree(String name) { + RequiredArgumentBuilder radius = Commands.argument("radius", IntegerArgumentType.integer(1, 100000)) + .executes((CommandContext context) -> pregenStart(context, false, false, false, false, false)); + attachPregenCenter(radius, false); + attachPregenFlags(radius, false, false, false, false, false); + RequiredArgumentBuilder dimension = Commands.argument("dimension", DimensionArgument.dimension()).suggests(DIMENSION_NAMES) + .executes((CommandContext context) -> pregenStart(context, true, false, false, false, false)); + attachPregenCenter(dimension, true); + attachPregenFlags(dimension, true, false, false, false, false); + radius.then(dimension); + return Commands.literal(name).requires(GATE) .executes((CommandContext context) -> ModdedCommandHelp.send(context.getSource(), name)) .then(Commands.literal("start") - .then(Commands.argument("radius", IntegerArgumentType.integer(1, 100000)) - .executes((CommandContext context) -> pregenStart(context.getSource(), IntegerArgumentType.getInteger(context, "radius"), 0, 0, false, ModdedPregenMode.ASYNC, false)) - .then(pregenStartFlag("gui", false, true, ModdedPregenMode.ASYNC, false)) - .then(pregenStartFlag("sync", false, false, ModdedPregenMode.SYNC, false)) - .then(pregenStartFlag("cached", false, false, ModdedPregenMode.SYNC, true)) - .then(Commands.argument("x", IntegerArgumentType.integer()) - .then(Commands.argument("z", IntegerArgumentType.integer()) - .executes((CommandContext context) -> pregenStart(context.getSource(), - IntegerArgumentType.getInteger(context, "radius"), - IntegerArgumentType.getInteger(context, "x"), - IntegerArgumentType.getInteger(context, "z"), false, ModdedPregenMode.ASYNC, false)) - .then(pregenStartFlag("gui", true, true, ModdedPregenMode.ASYNC, false)) - .then(pregenStartFlag("sync", true, false, ModdedPregenMode.SYNC, false)) - .then(pregenStartFlag("cached", true, false, ModdedPregenMode.SYNC, true)))) - .then(Commands.argument("dimension", StringArgumentType.greedyString()).suggests(DIMENSION_NAMES) - .executes((CommandContext context) -> pregenStart(context.getSource(), - IntegerArgumentType.getInteger(context, "radius"), 0, 0, false, ModdedPregenMode.ASYNC, false, - StringArgumentType.getString(context, "dimension")))))) + .then(radius)) .then(Commands.literal("stop") .executes((CommandContext context) -> pregenStop(context.getSource()))) .then(Commands.literal("x") @@ -264,12 +287,32 @@ public final class IrisModdedCommands { .executes((CommandContext context) -> pregenStatus(context.getSource()))); } - private static LiteralArgumentBuilder pregenStartFlag(String name, boolean withCenter, boolean gui, ModdedPregenMode mode, boolean cached) { - return Commands.literal(name).executes((CommandContext context) -> pregenStart(context.getSource(), - IntegerArgumentType.getInteger(context, "radius"), - withCenter ? IntegerArgumentType.getInteger(context, "x") : 0, - withCenter ? IntegerArgumentType.getInteger(context, "z") : 0, - gui, mode, cached)); + private static void attachPregenCenter(ArgumentBuilder node, boolean withDimension) { + RequiredArgumentBuilder z = Commands.argument("z", IntegerArgumentType.integer()) + .executes((CommandContext context) -> pregenStart(context, withDimension, true, false, false, false)); + attachPregenFlags(z, withDimension, true, false, false, false); + node.then(Commands.literal("at") + .then(Commands.argument("x", IntegerArgumentType.integer()) + .then(z))); + } + + private static void attachPregenFlags(ArgumentBuilder node, boolean withDimension, boolean withCenter, boolean gui, boolean sync, boolean nocache) { + if (!gui) { + node.then(pregenFlagNode("gui", withDimension, withCenter, true, sync, nocache)); + } + if (!sync) { + node.then(pregenFlagNode("sync", withDimension, withCenter, gui, true, nocache)); + } + if (!nocache) { + node.then(pregenFlagNode("nocache", withDimension, withCenter, gui, sync, true)); + } + } + + private static LiteralArgumentBuilder pregenFlagNode(String name, boolean withDimension, boolean withCenter, boolean gui, boolean sync, boolean nocache) { + LiteralArgumentBuilder flag = Commands.literal(name) + .executes((CommandContext context) -> pregenStart(context, withDimension, withCenter, gui, sync, nocache)); + attachPregenFlags(flag, withDimension, withCenter, gui, sync, nocache); + return flag; } private static LiteralArgumentBuilder goldenhashTree(String name) { @@ -302,21 +345,176 @@ public final class IrisModdedCommands { } private static LiteralArgumentBuilder editTree() { - String message = "/iris edit opens pack JSON files in a desktop editor through the Bukkit studio toolchain; " - + "on modded servers edit the pack files directly under config/irisworldgen/packs//."; - LiteralArgumentBuilder node = Commands.literal("edit").requires(GATE) - .executes((CommandContext context) -> { - fail(context.getSource(), message); - return 0; - }); - for (String child : new String[]{"biome", "region", "dimension"}) { - node.then(Commands.literal(child) - .executes((CommandContext context) -> { - fail(context.getSource(), message); - return 0; - })); + return Commands.literal("edit").requires(GATE) + .executes((CommandContext context) -> ModdedCommandHelp.send(context.getSource(), "edit")) + .then(Commands.literal("biome") + .executes((CommandContext context) -> editBiome(context.getSource(), null)) + .then(Commands.argument("key", StringArgumentType.greedyString()).suggests(BIOME_KEYS) + .executes((CommandContext context) -> editBiome(context.getSource(), StringArgumentType.getString(context, "key"))))) + .then(Commands.literal("region") + .executes((CommandContext context) -> editRegion(context.getSource(), null)) + .then(Commands.argument("key", StringArgumentType.greedyString()).suggests(REGION_KEYS) + .executes((CommandContext context) -> editRegion(context.getSource(), StringArgumentType.getString(context, "key"))))) + .then(Commands.literal("dimension") + .executes((CommandContext context) -> editDimension(context.getSource()))); + } + + private static int editBiome(CommandSourceStack source, String key) { + Engine engine = engineFor(source.getLevel()); + if (engine == null) { + fail(source, "This dimension is not generated by Iris."); + return 0; } - return node; + IrisBiome biome; + if (key == null || key.isBlank()) { + ServerPlayer player = source.getPlayer(); + if (player == null) { + fail(source, "Console must name a biome: /iris edit biome "); + return 0; + } + BlockPos pos = player.blockPosition(); + try { + biome = engine.getBiome(pos.getX(), pos.getY() - engine.getMinHeight(), pos.getZ()); + } catch (Throwable e) { + fail(source, "Biome lookup failed: " + e.getClass().getSimpleName()); + return 0; + } + } else { + biome = engine.getData().getBiomeLoader().load(key.trim()); + if (biome == null) { + fail(source, "Unknown biome: " + key); + return 0; + } + } + return openJson(source, biome); + } + + private static int editRegion(CommandSourceStack source, String key) { + Engine engine = engineFor(source.getLevel()); + if (engine == null) { + fail(source, "This dimension is not generated by Iris."); + return 0; + } + IrisRegion region; + if (key == null || key.isBlank()) { + ServerPlayer player = source.getPlayer(); + if (player == null) { + fail(source, "Console must name a region: /iris edit region "); + return 0; + } + BlockPos pos = player.blockPosition(); + try { + region = engine.getRegion(pos.getX(), pos.getZ()); + } catch (Throwable e) { + fail(source, "Region lookup failed: " + e.getClass().getSimpleName()); + return 0; + } + } else { + region = engine.getData().getRegionLoader().load(key.trim()); + if (region == null) { + fail(source, "Unknown region: " + key); + return 0; + } + } + return openJson(source, region); + } + + private static int editDimension(CommandSourceStack source) { + Engine engine = engineFor(source.getLevel()); + if (engine == null) { + fail(source, "This dimension is not generated by Iris."); + return 0; + } + return openJson(source, engine.getDimension()); + } + + private static int openJson(CommandSourceStack source, IrisRegistrant registrant) { + if (!GuiHost.isAvailable() || !Desktop.isDesktopSupported()) { + fail(source, "Cannot open files here: " + ModdedGuiHost.guiUnavailableReason()); + return 0; + } + if (registrant == null || registrant.getLoadFile() == null || !registrant.getLoadFile().isFile()) { + fail(source, "Cannot find the file; perhaps it was not loaded directly from a file?"); + return 0; + } + File file = registrant.getLoadFile(); + try { + Desktop.getDesktop().open(file); + } catch (Throwable e) { + LOGGER.error("Iris edit failed to open {}", file, e); + fail(source, "Could not open " + file.getName() + ": " + e.getClass().getSimpleName()); + return 0; + } + ok(source, "Opening " + registrant.getTypeName() + " " + file.getName() + " in your editor."); + return 1; + } + + private static int tp(CommandSourceStack source, ServerLevel level, ServerPlayer target) { + ServerPlayer player = target != null ? target : source.getPlayer(); + if (player == null) { + fail(source, "Console must name a player: /iris tp "); + return 0; + } + if (!(level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator)) { + fail(source, level.dimension().identifier() + " is not generated by Iris."); + return 0; + } + String dimensionId = level.dimension().identifier().toString(); + if (!ModdedDimensionManager.teleport(player, source.getServer(), dimensionId, 8.5D, Double.MIN_VALUE, 8.5D)) { + fail(source, "Teleport failed: dimension " + dimensionId + " is not loaded."); + return 0; + } + ok(source, "Teleporting " + player.getScoreboardName() + " to " + dimensionId + "..."); + return 1; + } + + private static int evacuate(CommandSourceStack source, ServerLevel target) { + MinecraftServer server = source.getServer(); + ServerLevel level = target != null ? target : source.getLevel(); + if (!(level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator)) { + fail(source, level.dimension().identifier() + " is not generated by Iris."); + return 0; + } + ServerLevel fallback = server.overworld(); + if (fallback == level) { + fail(source, "Cannot evacuate the primary world; there is nowhere to send players."); + return 0; + } + int count = ModdedDimensionManager.evacuate(server, level); + ok(source, "Evacuated " + count + " player(s) from " + level.dimension().identifier() + " to " + fallback.dimension().identifier() + "."); + return 1; + } + + private static int debug(CommandSourceStack source) { + boolean to = !IrisSettings.get().getGeneral().isDebug(); + IrisSettings.get().getGeneral().setDebug(to); + IrisSettings.get().forceSave(); + ok(source, "Set debug to: " + to); + return 1; + } + + private static int reload(CommandSourceStack source) { + if (IrisSettings.settings != null) { + IrisSettings.invalidate(); + } + IrisSettings.get(); + ok(source, "Hotloaded settings"); + return 1; + } + + private static int whatHand(CommandSourceStack source) { + ServerPlayer player = source.getPlayer(); + if (player == null) { + fail(source, "This command can only be used by players (it inspects your held item)."); + return 0; + } + ItemStack stack = player.getMainHandItem(); + if (stack.isEmpty()) { + fail(source, "Your main hand is empty."); + return 0; + } + ok(source, "Hand: " + BuiltInRegistries.ITEM.getKey(stack.getItem()) + " x" + stack.getCount()); + return 1; } private static int regen(CommandSourceStack source, int radius) { @@ -339,28 +537,23 @@ public final class IrisModdedCommands { return 1; } - private static int pregenStart(CommandSourceStack source, int radius, int centerX, int centerZ, boolean gui, ModdedPregenMode mode, boolean cached) { - return pregenStart(source, radius, centerX, centerZ, gui, mode, cached, null); - } - - private static int pregenStart(CommandSourceStack source, int radius, int centerX, int centerZ, boolean gui, ModdedPregenMode mode, boolean cached, String dimension) { - ServerLevel level; - if (dimension == null || dimension.isBlank()) { - level = source.getLevel(); - } else { - level = resolveIrisLevel(source.getServer(), dimension); - if (level == null) { - fail(source, "No loaded Iris dimension matches '" + dimension + "'. Use a bare name (irisworld) or full id (irisworldgen:irisworld); see /iris info for loaded dimensions."); - return 0; - } - } + private static int pregenStart(CommandContext context, boolean withDimension, boolean withCenter, boolean gui, boolean sync, boolean nocache) throws CommandSyntaxException { + CommandSourceStack source = context.getSource(); + int radius = IntegerArgumentType.getInteger(context, "radius"); + int centerX = withCenter ? IntegerArgumentType.getInteger(context, "x") : 0; + int centerZ = withCenter ? IntegerArgumentType.getInteger(context, "z") : 0; + ServerLevel level = withDimension ? DimensionArgument.getDimension(context, "dimension") : source.getLevel(); Engine engine = engineFor(level); if (engine == null) { - fail(source, "This dimension is not generated by Iris."); + if (withDimension) { + fail(source, level.dimension().identifier() + " is not generated by Iris; see /iris info for loaded Iris dimensions."); + } else { + fail(source, "The current dimension (" + level.dimension().identifier() + ") is not generated by Iris. Name one explicitly: /iris pregen start " + radius + " ; see /iris info for loaded Iris dimensions."); + } return 0; } boolean showGui = gui && ModdedGuiHost.isGuiLaunchable(); - if (!ModdedPregenJob.start(source.getServer(), level, engine, radius, centerX, centerZ, showGui, mode, cached)) { + if (!ModdedPregenJob.start(source.getServer(), level, engine, radius, centerX, centerZ, showGui, sync, !nocache)) { fail(source, "A pregeneration task is already running. Stop it first with /iris pregen stop."); return 0; } @@ -372,7 +565,7 @@ public final class IrisModdedCommands { } else { guiNote = " (GUI requested but unavailable: " + ModdedGuiHost.guiUnavailableReason() + ")"; } - String modeNote = mode == ModdedPregenMode.SYNC ? " Mode: sync" + (cached ? " (checkpoint cache resumable)." : ".") : ""; + String modeNote = " Mode: " + (sync ? "sync" : "async") + (nocache ? ", cache disabled." : ", resumable (checkpoint cache)."); ok(source, "Pregen started in " + level.dimension().identifier() + " of " + (radius * 2) + " by " + (radius * 2) + " blocks from " + centerX + "," + centerZ + "." + modeNote + " Progress logs to console; see /iris pregen status." + guiNote); return 1; @@ -786,14 +979,15 @@ public final class IrisModdedCommands { private static int download(CommandSourceStack source, String pack, String branch) { MinecraftServer server = source.getServer(); - ok(source, "Downloading IrisDimensions/" + pack + " (branch " + branch + ")..."); + String effectiveBranch = pack.equals("overworld") ? "master" : branch; + ok(source, "Downloading IrisDimensions/" + pack + " (branch " + effectiveBranch + ")..."); Thread thread = new Thread(() -> { - boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), pack, branch, + boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), pack, effectiveBranch, (String message) -> server.execute(() -> ok(source, message))); if (installed) { - server.execute(() -> ok(source, "Pack '" + pack + "' installed. Restart or create a new world to use it.")); + server.execute(() -> ok(source, "Pack '" + pack + "' installed. Its dimension types and custom biomes join the forced Iris datapack on the next server restart; worlds created before restarting run with fallback heights until then.")); } else { - server.execute(() -> fail(source, "Pack download failed for " + pack + "/" + branch + " (see console).")); + server.execute(() -> fail(source, "Pack download failed for " + pack + "/" + effectiveBranch + " (see console).")); } }, "Iris Pack Download"); thread.setDaemon(true); @@ -843,29 +1037,6 @@ public final class IrisModdedCommands { return count; } - private static ServerLevel resolveIrisLevel(MinecraftServer server, String raw) { - String target = raw.trim().toLowerCase(Locale.ROOT); - boolean qualified = target.indexOf(':') >= 0; - for (ServerLevel level : server.getAllLevels()) { - if (!(level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator)) { - continue; - } - String fullId = level.dimension().identifier().toString(); - if (qualified) { - if (fullId.equals(target)) { - return level; - } - continue; - } - int separator = fullId.indexOf(':'); - String path = separator >= 0 ? fullId.substring(separator + 1) : fullId; - if (path.equals(target)) { - return level; - } - } - return null; - } - private static CompletableFuture suggestBiomeKeys(CommandContext context, SuggestionsBuilder builder) { ModdedCommandFeedback.tab(context.getSource()); try { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandHelp.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandHelp.java index 801dda198..02f3a1bd5 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandHelp.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandHelp.java @@ -52,18 +52,24 @@ final class ModdedCommandHelp { SECTIONS.put("", List.of( Entry.command("version", "", "Print version information"), Entry.command("info", "[dimension]", "List loaded Iris dimensions and pack details"), - Entry.command("what", "", "Inspect the Iris biome, region, cave biome, surface and chunk at your position"), + Entry.command("what", "[block|hand|markers]", "Inspect the Iris biome, region, cave biome, surface and chunk at your position, the block you look at, your held item, or nearby markers"), Entry.group("find", "Find and teleport to Iris biomes, regions, objects, structures and points of interest", "goto"), + Entry.command("tp", " [player]", "Teleport yourself or a named player into a loaded Iris dimension"), + Entry.command("evacuate", "[dimension]", "Teleport every player out of an Iris dimension to the primary world spawn"), Entry.command("seed", "", "Print world and engine seed information"), + Entry.command("debug", "", "Toggle Iris debug logging and save settings.json"), + Entry.command("reload", "", "Reload settings.json (also hotloaded automatically every 3s)"), Entry.command("download", " [branch]", "Download a pack project", "dl"), Entry.command("metrics", "", "Print generation metrics for your current Iris dimension", "measure"), Entry.command("regen", "[radius]", "Delete and regenerate nearby chunks in place", "rg"), Entry.group("pregen", "Pregenerate an Iris dimension", "pregenerate"), Entry.command("wand", "", "Get an Iris object wand"), Entry.group("object", "Object wand, save, paste, analyze and undo tools", "o"), + Entry.group("edit", "Open pack biome, region and dimension json files in your desktop editor"), + Entry.command("create", " [seed]", "Create and inject a persistent Iris dimension; quote pack:dimensionKey to pick a specific pack dimension"), Entry.group("studio", "Pack project creation, packaging and reports", "std", "s"), Entry.group("pack", "Pack validation and maintenance", "pk"), - Entry.group("world", "Explicit Iris dimension enablement and removal", "w"), + Entry.group("world", "Runtime Iris dimension creation, removal and status", "w"), Entry.group("datapack", "World datapack install and status helpers", "datapacks", "dp"), Entry.group("structure", "Iris structure index, info and placement tools", "struct", "str"), Entry.command("goldenhash", "[radius] [threads] [capture|verify]", "Generate deterministic block hashes for parity testing", "gold") @@ -76,8 +82,13 @@ final class ModdedCommandHelp { Entry.command("poi", "", "Find a supported point of interest") )); SECTIONS.put("goto", SECTIONS.get("find")); + SECTIONS.put("edit", List.of( + Entry.command("biome", "[key]", "Open a biome json in your desktop editor; no key opens the biome at your position"), + Entry.command("region", "[key]", "Open a region json in your desktop editor; no key opens the region at your position"), + Entry.command("dimension", "", "Open the current pack's dimension json in your desktop editor") + )); SECTIONS.put("pregen", List.of( - Entry.command("start", " [x] [z]", "Start pregeneration"), + Entry.command("start", " [dimension] [at] [x] [z] [gui] [sync] [nocache]", "Start pregeneration; radius in blocks, resumable checkpoint cache on by default, center via 'at ', flags compose in any order"), Entry.command("stop", "", "Stop the active pregeneration task", "x"), Entry.command("pause", "", "Pause or resume pregeneration", "resume"), Entry.command("status", "", "Show pregeneration status") @@ -86,15 +97,15 @@ final class ModdedCommandHelp { SECTIONS.put("object", List.of( Entry.command("wand", "", "Get an Iris object wand"), Entry.command("dust", "", "Get dust that reveals object placements", "d"), - Entry.command("save", "", "Save the selected wand volume as an object"), - Entry.command("paste", "", "Paste an object at your position"), + Entry.command("save", "[overwrite] ", "Save the selected wand volume as an object"), + Entry.command("paste", "[at] [x] [y] [z] [rotate] [degrees] ", "Paste an object at your position or a given position, optionally rotated"), Entry.command("expand", "[amount]", "Expand the wand selection in your looking direction"), Entry.command("contract", "[amount]", "Contract the wand selection in your looking direction", "-"), Entry.command("shift", "[amount]", "Shift the wand selection in your looking direction"), Entry.command("position1", "[look]", "Set selection point 1", "p1"), Entry.command("position2", "[look]", "Set selection point 2", "p2"), - Entry.command("x+y", "", "Autoselect up and out"), - Entry.command("x&y", "", "Autoselect up, down and out"), + Entry.command("x+y", "", "Autoselect up and out", "xpy"), + Entry.command("x&y", "", "Autoselect up, down and out", "xay"), Entry.command("analyze", "", "Show object composition"), Entry.command("shrink", "", "Shrink an object to its minimum size"), Entry.command("undo", "[amount]", "Undo pasted objects", "u") @@ -105,10 +116,14 @@ final class ModdedCommandHelp { Entry.command("package", "[pack]", "Package a dimension into a compressed format"), Entry.command("version", "[pack]", "Print a pack version"), Entry.command("regions", "[radius]", "Calculate nearby region distribution"), - Entry.command("open", " [seed]", "Open or prepare a dimension pack studio workflow"), - Entry.command("close", "", "Explain modded studio workflow"), - Entry.command("vscode", "", "Explain editor workflow"), - Entry.command("update", "", "Explain workspace regeneration workflow"), + Entry.command("open", " [seed]", "Open a temporary studio dimension for a pack", "o"), + Entry.command("close", "", "Close the open studio dimension and discard its world", "x"), + Entry.command("tpstudio", "", "Teleport into the open studio dimension", "stp"), + Entry.command("status", "", "Show the open studio dimension and its pack"), + Entry.command("noise", "[generator] [seed]", "Open the Noise Explorer GUI on the server display", "nmap"), + Entry.command("map", "", "Open the Vision map GUI on the server display", "render"), + Entry.command("vscode", "[pack]", "Regenerate the .code-workspace for a pack and open it in your desktop editor", "vsc"), + Entry.command("update", "[pack]", "Regenerate the .code-workspace for a pack"), Entry.command("importvanilla", "", "Explain vanilla import workflow", "importv", "iv") )); SECTIONS.put("std", SECTIONS.get("studio")); @@ -120,11 +135,12 @@ final class ModdedCommandHelp { )); SECTIONS.put("pk", SECTIONS.get("pack")); SECTIONS.put("world", List.of( - Entry.command("enable", " [packDimension]", "Create an Iris dimension in world/datapacks/iris", "create"), - Entry.command("replace-overworld", " [packDimension]", "Explicitly make minecraft:overworld use an Iris pack"), - Entry.command("disable", "", "Remove an Iris dimension definition from the world datapack", "remove", "rm"), - Entry.command("list", "", "List Iris dimensions staged in the world datapack", "ls"), - Entry.command("status", "", "Show staged and currently loaded Iris dimensions") + Entry.command("enable", " [seed|random]", "Create and inject a persistent Iris dimension at runtime; downloads the pack if missing, quote pack:dimensionKey to pick a specific pack dimension", "create"), + Entry.command("replace-overworld", " [seed|random]", "Inject an Iris primary world and route players there instead of the vanilla overworld"), + Entry.command("disable", "", "Evacuate and unload an Iris dimension; world data on disk is kept for re-enabling"), + Entry.command("delete", "", "Disable an Iris dimension and wipe its chunk and mantle data from disk", "remove", "rm"), + Entry.command("list", "", "List loaded Iris dimensions", "ls"), + Entry.command("status", "", "Show loaded Iris dimensions and the configured primary world") )); SECTIONS.put("w", SECTIONS.get("world")); SECTIONS.put("datapack", List.of( diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedGoldenHash.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedGoldenHash.java index 92e001fd3..3155e60b2 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedGoldenHash.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedGoldenHash.java @@ -18,13 +18,13 @@ package art.arcane.iris.modded.command; +import art.arcane.iris.core.runtime.GoldenHashEngine; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.modded.ModdedBlockBuffer; import art.arcane.iris.modded.ModdedEngineBootstrap; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.PlatformBiome; import art.arcane.iris.spi.PlatformBlockState; -import art.arcane.iris.util.common.parallel.MultiBurst; import art.arcane.iris.util.project.hunk.Hunk; import net.minecraft.commands.CommandSourceStack; import net.minecraft.server.MinecraftServer; @@ -33,21 +33,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.File; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.HashMap; -import java.util.HexFormat; -import java.util.List; -import java.util.Map; -import java.util.TreeMap; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.Semaphore; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -59,33 +44,38 @@ public final class ModdedGoldenHash { } private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); - private static final String FORMAT = "iris-goldenhash v1"; - private static final int BIOME_STEP = 4; - private static final int MAX_REPORTED_MISMATCHES = 10; private static final AtomicBoolean ACTIVE = new AtomicBoolean(false); private final CommandSourceStack source; private final MinecraftServer server; - private final ServerLevel level; private final Engine engine; - private final int radius; - private final int threads; - private final Mode mode; - private final File goldenFile; + private final GoldenHashEngine hashEngine; private ModdedGoldenHash(CommandSourceStack source, ServerLevel level, Engine engine, int radius, int threads, Mode mode) { this.source = source; this.server = source.getServer(); - this.level = level; this.engine = engine; - this.radius = Math.max(0, radius); - this.threads = Math.max(1, threads); - this.mode = mode; + GoldenHashEngine.Mode engineMode = switch (mode) { + case AUTO -> GoldenHashEngine.Mode.AUTO; + case CAPTURE -> GoldenHashEngine.Mode.CAPTURE; + case VERIFY -> GoldenHashEngine.Mode.VERIFY; + }; File goldenDir = ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen").resolve("golden").toFile(); - goldenDir.mkdirs(); - this.goldenFile = new File(goldenDir, engine.getDimension().getLoadKey() - + "-s" + level.getSeed() - + "-c0x0-r" + this.radius + ".hashes"); + GoldenHashEngine.Request request = new GoldenHashEngine.Request( + engine.getWorld().name(), + level.getSeed(), + ModdedEngineBootstrap.loader().minecraftVersion(), + engine.getMinHeight(), + engine.getMaxHeight(), + 0, + 0, + radius, + threads, + engineMode, + true, + false, + "minecraft:plains"); + this.hashEngine = new GoldenHashEngine(engine, request, goldenDir, this::snapshot, feedback(), progress()); } public static void start(CommandSourceStack source, ServerLevel level, Engine engine, int radius, int threads, Mode mode) { @@ -94,16 +84,14 @@ public final class ModdedGoldenHash { return; } ModdedGoldenHash scan = new ModdedGoldenHash(source, level, engine, radius, threads, mode); - int chunks = (scan.radius * 2 + 1) * (scan.radius * 2 + 1); - scan.ok("GoldenHash started: " + chunks + " chunk(s) around 0,0 in buffers (world untouched), threads=" + scan.threads + " mode=" + mode); + int boundedRadius = Math.max(0, radius); + int chunks = (boundedRadius * 2 + 1) * (boundedRadius * 2 + 1); + scan.ok("GoldenHash started: " + chunks + " chunk(s) around 0,0 in buffers (world untouched), threads=" + Math.max(1, threads) + " mode=" + mode); LOGGER.info("goldenhash start: dim={} seed={} radius={} threads={} mode={} file={}", - engine.getDimension().getLoadKey(), level.getSeed(), scan.radius, scan.threads, mode, scan.goldenFile.getName()); + engine.getDimension().getLoadKey(), level.getSeed(), boundedRadius, Math.max(1, threads), mode, scan.hashEngine.getGoldenFile().getName()); Thread thread = new Thread(() -> { try { - scan.run(); - } catch (Throwable e) { - LOGGER.error("goldenhash failed", e); - scan.fail("GoldenHash failed: " + e); + scan.hashEngine.run(); } finally { ACTIVE.set(false); } @@ -112,265 +100,77 @@ public final class ModdedGoldenHash { thread.start(); } - private void run() throws Exception { - boolean exists = goldenFile.exists(); - if (mode == Mode.VERIFY && !exists) { - fail("No golden capture at " + goldenFile.getAbsolutePath() + "; run '/iris goldenhash " + radius + " " + threads + " capture' first."); - return; - } - - resetMantleFull(); - List targets = orderedTargets(0, 0, radius); - Map lines = scan(targets); - if (lines.size() != targets.size()) { - fail("GoldenHash aborted: " + (targets.size() - lines.size()) + " chunk(s) failed to generate. No golden file written."); - return; - } - - if (mode == Mode.CAPTURE || (mode == Mode.AUTO && !exists)) { - capture(lines); - } else { - verify(lines); - } - } - - private void resetMantleFull() { - try { - engine.getMantle().getMantle().saveAll(); - File folder = engine.getMantle().getMantle().getDataFolder(); - File[] files = folder.listFiles(); - if (files != null) { - for (File file : files) { - if (file.isFile()) { - file.delete(); - } - } - } - ok("Mantle reset (" + folder.getAbsolutePath() + ")"); - } catch (Throwable e) { - LOGGER.error("goldenhash mantle reset failed", e); - ok("Mantle reset failed (" + e.getClass().getSimpleName() + "); continuing with existing mantle state."); - } - } - - private Map scan(List targets) throws InterruptedException { - Map lines = new ConcurrentHashMap<>(); - Semaphore inFlight = new Semaphore(threads); - CountDownLatch done = new CountDownLatch(targets.size()); - AtomicInteger completed = new AtomicInteger(); - int total = targets.size(); - int stride = total <= 64 ? 1 : 32; - int height = engine.getMaxHeight() - engine.getMinHeight(); + private GoldenHashEngine.ChunkSnapshot snapshot(int chunkX, int chunkZ) throws Exception { + int minY = engine.getMinHeight(); + int height = engine.getMaxHeight() - minY; PlatformBlockState air = IrisPlatforms.get().registries().air(); + ModdedBlockBuffer blocks = new ModdedBlockBuffer(height, air); + Hunk biomes = Hunk.newArrayHunk(16, height, 16); + engine.generate(chunkX << 4, chunkZ << 4, blocks, biomes, false); + return new GoldenHashEngine.ChunkSnapshot() { + @Override + public int minY() { + return minY; + } - for (int[] target : targets) { - int chunkX = target[0]; - int chunkZ = target[1]; - inFlight.acquire(); - MultiBurst.burst.lazy(() -> { - try { - ModdedBlockBuffer blocks = new ModdedBlockBuffer(height, air); - Hunk biomes = Hunk.newArrayHunk(16, height, 16); - engine.generate(chunkX << 4, chunkZ << 4, blocks, biomes, false); - lines.put(chunkKey(chunkX, chunkZ), hashChunk(chunkX, chunkZ, blocks, biomes, height)); - int doneCount = completed.incrementAndGet(); - if (doneCount % stride == 0 || doneCount == total) { - ok("[" + doneCount + "/" + total + "] chunk " + chunkX + "," + chunkZ + " hashed"); - } - } catch (Throwable e) { - LOGGER.error("goldenhash chunk {},{} failed", chunkX, chunkZ, e); - fail("Chunk " + chunkX + "," + chunkZ + " FAILED: " + e.getClass().getSimpleName()); - } finally { - inFlight.release(); - done.countDown(); - } - }); - } + @Override + public int maxY() { + return minY + height; + } - done.await(); - return lines; + @Override + public PlatformBlockState block(int x, int y, int z) { + return blocks.get(x, y - minY, z); + } + + @Override + public PlatformBiome biome(int x, int y, int z) { + return biomes.get(x, y - minY, z); + } + }; } - private String hashChunk(int chunkX, int chunkZ, ModdedBlockBuffer blocks, Hunk biomes, int height) { - MessageDigest blockDigest = sha256(); - MessageDigest biomeDigest = sha256(); - Map blockCache = new HashMap<>(); - Map biomeCache = new HashMap<>(); - byte[] plains = "minecraft:plains\n".getBytes(StandardCharsets.UTF_8); + private GoldenHashEngine.Feedback feedback() { + return new GoldenHashEngine.Feedback() { + @Override + public void ok(String message) { + ModdedGoldenHash.this.ok(message); + } - for (int x = 0; x < 16; x++) { - for (int z = 0; z < 16; z++) { - for (int y = 0; y < height; y++) { - PlatformBlockState state = blocks.get(x, y, z); - byte[] bytes = blockCache.computeIfAbsent(state, (PlatformBlockState s) -> (s.key() + "\n").getBytes(StandardCharsets.UTF_8)); - blockDigest.update(bytes); + @Override + public void warn(String message) { + ModdedGoldenHash.this.ok(message); + } + + @Override + public void fail(String message) { + ModdedGoldenHash.this.fail(message); + } + }; + } + + private GoldenHashEngine.Progress progress() { + return new GoldenHashEngine.Progress() { + private final AtomicInteger hashed = new AtomicInteger(); + + @Override + public void chunkDone(int chunkX, int chunkZ, boolean ok, int done, int total) { + if (!ok) { + return; + } + int doneCount = hashed.incrementAndGet(); + int stride = total <= 64 ? 1 : 32; + if (doneCount % stride == 0 || doneCount == total) { + ModdedGoldenHash.this.ok("[" + doneCount + "/" + total + "] chunk " + chunkX + "," + chunkZ + " hashed"); } } - } - for (int x = 0; x < 16; x += BIOME_STEP) { - for (int z = 0; z < 16; z += BIOME_STEP) { - for (int y = 0; y < height; y += BIOME_STEP) { - PlatformBiome biome = biomes.get(x, y, z); - byte[] bytes = biome == null - ? plains - : biomeCache.computeIfAbsent(biome, (PlatformBiome b) -> (b.key() + "\n").getBytes(StandardCharsets.UTF_8)); - biomeDigest.update(bytes); - } + @Override + public void chunkFailed(int chunkX, int chunkZ, Throwable error) { + LOGGER.error("goldenhash chunk {},{} failed", chunkX, chunkZ, error); + ModdedGoldenHash.this.fail("Chunk " + chunkX + "," + chunkZ + " FAILED: " + error.getClass().getSimpleName()); } - } - - return chunkX + " " + chunkZ + " " - + HexFormat.of().formatHex(blockDigest.digest()) + " " - + HexFormat.of().formatHex(biomeDigest.digest()); - } - - private void capture(Map lines) throws IOException { - List body = orderedBody(lines); - String combined = combinedHash(body); - List out = new ArrayList<>(); - out.add("#" + FORMAT); - out.add("#world=" + engine.getWorld().name()); - out.add("#dim=" + engine.getDimension().getLoadKey()); - out.add("#seed=" + level.getSeed()); - out.add("#mc=" + ModdedEngineBootstrap.loader().minecraftVersion()); - out.add("#minY=" + engine.getMinHeight() + " maxY=" + engine.getMaxHeight()); - out.add("#center=0,0"); - out.add("#radius=" + radius); - out.addAll(body); - out.add("#combined=" + combined); - Files.write(goldenFile.toPath(), out, StandardCharsets.UTF_8); - - ok("Golden captured: " + body.size() + " chunks combined=" + shortHash(combined)); - ok(goldenFile.getAbsolutePath()); - LOGGER.info("goldenhash captured: {} combined={}", goldenFile.getAbsolutePath(), combined); - } - - private void verify(Map lines) throws IOException { - List existing = Files.readAllLines(goldenFile.toPath(), StandardCharsets.UTF_8); - Map meta = new HashMap<>(); - Map goldenChunks = new HashMap<>(); - for (String line : existing) { - if (line.startsWith("#")) { - int eq = line.indexOf('='); - if (eq > 0) { - meta.put(line.substring(1, eq), line.substring(eq + 1)); - } - } else if (!line.isBlank()) { - int second = line.indexOf(' ', line.indexOf(' ') + 1); - goldenChunks.put(line.substring(0, second), line); - } - } - - String expectedSeed = String.valueOf(level.getSeed()); - String expectedDim = engine.getDimension().getLoadKey(); - if (!expectedSeed.equals(meta.get("seed")) || !expectedDim.equals(meta.get("dim"))) { - fail("Golden file is for dim=" + meta.get("dim") + " seed=" + meta.get("seed") - + " but this world is dim=" + expectedDim + " seed=" + expectedSeed + ". Aborting."); - return; - } - String mc = ModdedEngineBootstrap.loader().minecraftVersion(); - if (!mc.equals(meta.get("mc"))) { - ok("Golden was captured on mc=" + meta.get("mc") + ", running mc=" + mc + ". Diffs may be version-induced."); - } - - List body = orderedBody(lines); - List mismatches = new ArrayList<>(); - for (String line : body) { - int second = line.indexOf(' ', line.indexOf(' ') + 1); - String key = line.substring(0, second); - String golden = goldenChunks.get(key); - if (!line.equals(golden)) { - mismatches.add(key + (golden == null ? " (missing in golden)" : "")); - } - } - - String combined = combinedHash(body); - if (mismatches.isEmpty()) { - ok("GOLDEN MATCH: " + body.size() + "/" + goldenChunks.size() + " chunks, combined=" + shortHash(combined)); - LOGGER.info("goldenhash MATCH: {} combined={}", goldenFile.getName(), combined); - return; - } - - fail("GOLDEN MISMATCH: " + mismatches.size() + "/" + body.size() + " chunks differ."); - for (int i = 0; i < Math.min(MAX_REPORTED_MISMATCHES, mismatches.size()); i++) { - fail(" chunk " + mismatches.get(i)); - } - if (mismatches.size() > MAX_REPORTED_MISMATCHES) { - fail(" ... and " + (mismatches.size() - MAX_REPORTED_MISMATCHES) + " more"); - } - - File current = new File(goldenFile.getParentFile(), goldenFile.getName() + ".new"); - List out = new ArrayList<>(body); - out.add("#combined=" + combined); - Files.write(current.toPath(), out, StandardCharsets.UTF_8); - ok("Current hashes written to " + current.getName()); - LOGGER.info("goldenhash MISMATCH: {}/{} -> {}", mismatches.size(), body.size(), current.getAbsolutePath()); - diagnose(mismatches.getFirst()); - } - - private void diagnose(String mismatchKey) { - try { - String[] parts = mismatchKey.trim().split(" "); - int chunkX = Integer.parseInt(parts[0]); - int chunkZ = Integer.parseInt(parts[1]); - int height = engine.getMaxHeight() - engine.getMinHeight(); - PlatformBlockState air = IrisPlatforms.get().registries().air(); - - ModdedBlockBuffer first = new ModdedBlockBuffer(height, air); - Hunk firstBiomes = Hunk.newArrayHunk(16, height, 16); - engine.generate(chunkX << 4, chunkZ << 4, first, firstBiomes, false); - ModdedBlockBuffer second = new ModdedBlockBuffer(height, air); - Hunk secondBiomes = Hunk.newArrayHunk(16, height, 16); - engine.generate(chunkX << 4, chunkZ << 4, second, secondBiomes, false); - - int diffs = 0; - for (int x = 0; x < 16 && diffs < 50; x++) { - for (int z = 0; z < 16 && diffs < 50; z++) { - for (int y = 0; y < height && diffs < 50; y++) { - if (!first.get(x, y, z).key().equals(second.get(x, y, z).key())) { - diffs++; - } - } - } - } - if (diffs == 0) { - ok("Repeat-gen STABLE for chunk " + chunkX + "," + chunkZ + " (nondeterminism is order/state-dependent, not per-call)"); - } else { - fail("Repeat-gen UNSTABLE for chunk " + chunkX + "," + chunkZ + " (" + diffs + "+ block diffs between back-to-back generations)"); - } - } catch (Throwable e) { - LOGGER.error("goldenhash diagnosis failed", e); - fail("Diagnosis failed: " + e.getMessage()); - } - } - - private static List orderedTargets(int centerX, int centerZ, int radius) { - List targets = new ArrayList<>(); - for (int dx = -radius; dx <= radius; dx++) { - for (int dz = -radius; dz <= radius; dz++) { - targets.add(new int[]{centerX + dx, centerZ + dz}); - } - } - targets.sort(Comparator.comparingInt((int[] t) -> { - int ox = t[0] - centerX; - int oz = t[1] - centerZ; - return ox * ox + oz * oz; - })); - return targets; - } - - private List orderedBody(Map lines) { - Map sorted = new TreeMap<>(lines); - return new ArrayList<>(sorted.values()); - } - - private String combinedHash(List body) { - MessageDigest digest = sha256(); - for (String line : body) { - digest.update((line + "\n").getBytes(StandardCharsets.UTF_8)); - } - return HexFormat.of().formatHex(digest.digest()); + }; } private void ok(String message) { @@ -380,20 +180,4 @@ public final class ModdedGoldenHash { private void fail(String message) { server.execute(() -> IrisModdedCommands.fail(source, message)); } - - private static String shortHash(String hex) { - return hex.substring(0, 12); - } - - private static long chunkKey(int x, int z) { - return (((long) x) << 32) ^ (z & 0xFFFFFFFFL); - } - - private static MessageDigest sha256() { - try { - return MessageDigest.getInstance("SHA-256"); - } catch (NoSuchAlgorithmException e) { - throw new IllegalStateException(e); - } - } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedGuiHost.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedGuiHost.java index 4d159ac58..1a5828cd4 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedGuiHost.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedGuiHost.java @@ -26,12 +26,14 @@ import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerLevel; import java.util.Map; +import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; public final class ModdedGuiHost implements GuiHost.Provider { private static final ModdedGuiHost INSTANCE = new ModdedGuiHost(); private final Map levels = new ConcurrentHashMap<>(); + private final Map openers = new ConcurrentHashMap<>(); private volatile Engine active; private volatile MinecraftServer server; @@ -42,10 +44,15 @@ public final class ModdedGuiHost implements GuiHost.Provider { GuiHost.set(INSTANCE); } - public static void bindContext(MinecraftServer server, ServerLevel level, Engine engine) { + public static void bindContext(MinecraftServer server, ServerLevel level, Engine engine, UUID opener) { INSTANCE.server = server; INSTANCE.active = engine; INSTANCE.levels.put(engine, level); + if (opener == null) { + INSTANCE.openers.remove(engine); + } else { + INSTANCE.openers.put(engine, opener); + } } public static boolean isGuiLaunchable() { @@ -53,6 +60,9 @@ public final class ModdedGuiHost implements GuiHost.Provider { } public static String guiUnavailableReason() { + if (GuiHost.isDesktopSuppressed()) { + return "running inside a Minecraft client (desktop GUIs disabled to avoid a client crash)"; + } if (!GuiHost.isAvailable()) { return "headless JVM (no display)"; } @@ -82,6 +92,6 @@ public final class ModdedGuiHost implements GuiHost.Provider { if (level == null || server == null) { return null; } - return new ModdedVisionOverlay(server, level, engine); + return new ModdedVisionOverlay(server, level, engine, openers.get(engine)); } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPackCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPackCommands.java index 781d35e37..7cc9a3e7f 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPackCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPackCommands.java @@ -77,7 +77,7 @@ public final class ModdedPackCommands { return root; } - static File packsRoot() { + public static File packsRoot() { return ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen").resolve("packs").toFile(); } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenJob.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenJob.java index 046c5d16d..497ea5c96 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenJob.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenJob.java @@ -18,10 +18,8 @@ package art.arcane.iris.modded.command; -import art.arcane.iris.core.gui.PregenRenderSource; -import art.arcane.iris.core.gui.PregenRenderer; -import art.arcane.iris.core.pregenerator.IrisPregenerator; -import art.arcane.iris.core.pregenerator.PregenListener; +import art.arcane.iris.core.gui.PregeneratorJob; +import art.arcane.iris.core.pregenerator.PregenPerformanceProfile; import art.arcane.iris.core.pregenerator.PregenTask; import art.arcane.iris.core.pregenerator.PregeneratorMethod; import art.arcane.iris.core.pregenerator.cache.PregenCache; @@ -35,60 +33,34 @@ import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.level.dimension.DimensionType; import net.minecraft.world.level.storage.LevelResource; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import java.awt.Color; import java.io.File; -import java.util.concurrent.atomic.AtomicReference; -public final class ModdedPregenJob implements PregenListener, PregenRenderSource { - private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); - private static final AtomicReference ACTIVE = new AtomicReference<>(); - private static final Color COLOR_GENERATING = new Color(0x66967f); - private static final Color COLOR_GENERATED = new Color(0x65c295); +public final class ModdedPregenJob { + private static volatile String dimension = "?"; - private final String dimension; - private final IrisPregenerator pregenerator; - private final Engine engine; - private final Position2 min; - private final Position2 max; - private final PregenRenderer renderer; - private volatile double chunksPerSecond; - private volatile long generated; - private volatile long totalChunks; - private volatile long eta; - private volatile long elapsed; - private volatile String method = "Modded"; + private ModdedPregenJob() { + } - private ModdedPregenJob(MinecraftServer server, ServerLevel level, Engine engine, PregenTask task, boolean gui, ModdedPregenMode mode, boolean cached) { - this.dimension = level.dimension().identifier().toString(); - this.engine = engine; - this.min = new Position2(Integer.MAX_VALUE, Integer.MAX_VALUE); - this.max = new Position2(Integer.MIN_VALUE, Integer.MIN_VALUE); - task.iterateAllChunks((int chunkX, int chunkZ) -> { - min.setX(Math.min(chunkX, min.getX())); - min.setZ(Math.min(chunkZ, min.getZ())); - max.setX(Math.max(chunkX, max.getX())); - max.setZ(Math.max(chunkZ, max.getZ())); - }); - PregeneratorMethod baseMethod = new ModdedPregenMethod(level, engine, mode); - PregeneratorMethod resolvedMethod = baseMethod; + public static boolean start(MinecraftServer server, ServerLevel level, Engine engine, int radiusBlocks, int centerBlockX, int centerBlockZ, boolean gui, boolean sync, boolean cached) { + if (PregeneratorJob.getInstance() != null) { + return false; + } + + PregenPerformanceProfile.apply(engine); + PregenTask task = PregenTask.builder() + .gui(gui) + .center(new Position2(centerBlockX, centerBlockZ)) + .radiusX(radiusBlocks) + .radiusZ(radiusBlocks) + .build(); + PregeneratorMethod method = new ModdedPregenMethod(level, engine, sync); if (cached) { - PregenCache cache = PregenCache.create(cacheDirectory(level)).sync(); - resolvedMethod = new CachedPregenMethod(baseMethod, cache); + method = new CachedPregenMethod(method, PregenCache.create(cacheDirectory(level)).sync(), task); } - this.method = mode == ModdedPregenMode.SYNC ? "Modded Sync" : "Modded"; - this.pregenerator = new IrisPregenerator(task, resolvedMethod, this); - PregenRenderer openedRenderer = null; - if (gui) { - try { - openedRenderer = PregenRenderer.open("Iris Pregen: " + dimension, this, ModdedPregenJob::pauseResume); - } catch (Throwable e) { - LOGGER.error("Iris pregen GUI failed to open for {}", dimension, e); - } - } - this.renderer = openedRenderer; + dimension = level.dimension().identifier().toString(); + new PregeneratorJob(task, method, engine); + return true; } private static File cacheDirectory(ServerLevel level) { @@ -96,119 +68,53 @@ public final class ModdedPregenJob implements PregenListener, PregenRenderSource return new File(worldFolder, "iris" + File.separator + "pregen"); } - public static boolean start(MinecraftServer server, ServerLevel level, Engine engine, int radiusBlocks, int centerBlockX, int centerBlockZ, boolean gui) { - return start(server, level, engine, radiusBlocks, centerBlockX, centerBlockZ, gui, ModdedPregenMode.ASYNC, false); - } - - public static boolean start(MinecraftServer server, ServerLevel level, Engine engine, int radiusBlocks, int centerBlockX, int centerBlockZ, boolean gui, ModdedPregenMode mode, boolean cached) { - if (ACTIVE.get() != null) { - return false; - } - PregenTask task = PregenTask.builder() - .gui(false) - .center(new Position2(centerBlockX, centerBlockZ)) - .radiusX(radiusBlocks) - .radiusZ(radiusBlocks) - .build(); - ModdedPregenJob job = new ModdedPregenJob(server, level, engine, task, gui, mode, cached); - if (!ACTIVE.compareAndSet(null, job)) { - job.closeRenderer(); - return false; - } - Thread thread = new Thread(() -> { - try { - job.pregenerator.start(); - } catch (Throwable e) { - LOGGER.error("Iris pregen failed for {}", job.dimension, e); - } finally { - job.closeRenderer(); - ACTIVE.compareAndSet(job, null); - } - }, "Iris Pregen"); - thread.setDaemon(true); - thread.start(); - return true; - } - - private void closeRenderer() { - if (renderer != null) { - renderer.close(); - } - } - - private void draw(int chunkX, int chunkZ, Color color) { - if (renderer == null || !renderer.isVisibleFrame()) { - return; - } - try { - Color resolved = color; - if (engine != null) { - resolved = engine.draw((chunkX << 4) + 8, (chunkZ << 4) + 8); - } - renderer.submit(chunkX, chunkZ, resolved); - } catch (Throwable e) { - renderer.submit(chunkX, chunkZ, color); - } - } - public static boolean stop() { - ModdedPregenJob job = ACTIVE.get(); - if (job == null) { - return false; - } - job.pregenerator.close(); - return true; + return PregeneratorJob.shutdownInstance(); + } + + public static void shutdown() { + PregeneratorJob.shutdownAndWait(10_000L); } public static Boolean pauseResume() { - ModdedPregenJob job = ACTIVE.get(); - if (job == null) { + if (PregeneratorJob.getInstance() == null) { return null; } - if (job.pregenerator.paused()) { - job.pregenerator.resume(); - return Boolean.FALSE; - } - job.pregenerator.pause(); - return Boolean.TRUE; - } - - public static String status() { - ModdedPregenJob job = ACTIVE.get(); - if (job == null) { - return null; - } - return job.statusText(); + PregeneratorJob.pauseResume(); + return PregeneratorJob.isPaused(); } public static Component statusComponent() { - ModdedPregenJob job = ACTIVE.get(); - if (job == null) { + PregeneratorJob.PregenProgress progress = PregeneratorJob.progressSnapshot(); + if (progress == null) { return null; } - double percent = job.percent(); MutableComponent status = Component.empty(); status.append(ModdedCommandFeedback.header("Iris Pregen")); status.append(Component.literal("\n")); status.append(ModdedCommandFeedback.text("Dimension ", ModdedCommandFeedback.DARK_GREEN)); - status.append(ModdedCommandFeedback.text(job.dimension, ModdedCommandFeedback.PARAMETER_ALT)); + status.append(ModdedCommandFeedback.text(dimension, ModdedCommandFeedback.PARAMETER_ALT)); status.append(ModdedCommandFeedback.text(" · Method ", ModdedCommandFeedback.DARK_GREEN)); - status.append(ModdedCommandFeedback.text(job.method, ModdedCommandFeedback.PARAMETER)); + status.append(ModdedCommandFeedback.text(progress.method(), ModdedCommandFeedback.PARAMETER)); status.append(Component.literal("\n")); - status.append(ModdedCommandFeedback.progressBar(percent, 32)); - status.append(ModdedCommandFeedback.text(" " + String.format("%.1f", percent) + "%", ModdedCommandFeedback.USAGE)); + status.append(ModdedCommandFeedback.progressBar(progress.percent(), 32)); + status.append(ModdedCommandFeedback.text(" " + String.format("%.1f", progress.percent()) + "%", ModdedCommandFeedback.USAGE)); status.append(Component.literal("\n")); status.append(ModdedCommandFeedback.text("Chunks ", ModdedCommandFeedback.DARK_GREEN)); - status.append(ModdedCommandFeedback.text(Form.f(job.generated) + "/" + Form.f(job.totalChunks), ModdedCommandFeedback.VALUE)); + status.append(ModdedCommandFeedback.text(Form.f(progress.generated()) + "/" + Form.f(progress.totalChunks()), ModdedCommandFeedback.VALUE)); status.append(ModdedCommandFeedback.text(" · Speed ", ModdedCommandFeedback.DARK_GREEN)); - status.append(ModdedCommandFeedback.text(Form.f((int) job.chunksPerSecond) + "/s", ModdedCommandFeedback.VALUE)); + status.append(ModdedCommandFeedback.text(Form.f((int) progress.chunksPerSecond()) + "/s", ModdedCommandFeedback.VALUE)); + if (progress.failed() > 0) { + status.append(ModdedCommandFeedback.text(" · Failed ", ModdedCommandFeedback.DARK_GREEN)); + status.append(ModdedCommandFeedback.text(Form.f(progress.failed()), ModdedCommandFeedback.REQUIRED)); + } status.append(Component.literal("\n")); status.append(ModdedCommandFeedback.text("ETA ", ModdedCommandFeedback.DARK_GREEN)); - status.append(ModdedCommandFeedback.text(Form.duration(job.eta, 2), ModdedCommandFeedback.VALUE)); + status.append(ModdedCommandFeedback.text(Form.duration(progress.eta(), 2), ModdedCommandFeedback.VALUE)); status.append(ModdedCommandFeedback.text(" · Elapsed ", ModdedCommandFeedback.DARK_GREEN)); - status.append(ModdedCommandFeedback.text(Form.duration(job.elapsed, 2), ModdedCommandFeedback.VALUE)); - if (job.pregenerator.paused()) { + status.append(ModdedCommandFeedback.text(Form.duration(progress.elapsed(), 2), ModdedCommandFeedback.VALUE)); + if (progress.paused()) { status.append(ModdedCommandFeedback.text(" · PAUSED", ModdedCommandFeedback.REQUIRED, true, false)); } status.append(Component.literal("\n")); @@ -219,118 +125,4 @@ public final class ModdedPregenJob implements PregenListener, PregenRenderSource status.append(ModdedCommandFeedback.footer()); return status; } - - private String statusText() { - double percent = percent(); - return "Pregen " + dimension + ": " - + Form.f(generated) + "/" + Form.f(totalChunks) - + " (" + String.format("%.1f", percent) + "%), " - + Form.f((int) chunksPerSecond) + "/s" - + ", ETA " + Form.duration(eta, 2) - + ", elapsed " + Form.duration(elapsed, 2) - + ", method " + method - + (pregenerator.paused() ? ", PAUSED" : ""); - } - - private double percent() { - long total = Math.max(1L, totalChunks); - return ((double) generated / (double) total) * 100D; - } - - @Override - public void onTick(double chunksPerSecond, double chunksPerMinute, double regionsPerMinute, double percent, long generated, long totalChunks, long chunksRemaining, long eta, long elapsed, String method, boolean cached) { - this.chunksPerSecond = chunksPerSecond; - this.generated = generated; - this.totalChunks = totalChunks; - this.eta = eta; - this.elapsed = elapsed; - this.method = method; - } - - @Override - public void onChunkGenerating(int x, int z) { - draw(x, z, COLOR_GENERATING); - } - - @Override - public void onChunkGenerated(int x, int z, boolean cached) { - draw(x, z, COLOR_GENERATED); - } - - @Override - public void onRegionGenerated(int x, int z) { - } - - @Override - public void onRegionGenerating(int x, int z) { - } - - @Override - public void onChunkCleaned(int x, int z) { - } - - @Override - public void onRegionSkipped(int x, int z) { - } - - @Override - public void onNetworkStarted(int x, int z) { - } - - @Override - public void onNetworkFailed(int x, int z) { - } - - @Override - public void onNetworkReclaim(int revert) { - } - - @Override - public void onNetworkGeneratedChunk(int x, int z) { - } - - @Override - public void onNetworkDownloaded(int x, int z) { - } - - @Override - public void onClose() { - } - - @Override - public void onSaving() { - } - - @Override - public void onChunkExistsInRegionGen(int x, int z) { - draw(x, z, COLOR_GENERATED); - } - - @Override - public Position2 min() { - return min; - } - - @Override - public Position2 max() { - return max; - } - - @Override - public String[] progress() { - double percent = percent(); - return new String[]{ - (pregenerator.paused() ? "PAUSED " : "Generating ") + Form.f(generated) + " of " + Form.f(totalChunks) - + " (" + String.format("%.1f", percent) + "%)", - "Speed: " + Form.f((int) chunksPerSecond) + " Chunks/s", - Form.duration(eta, 2) + " Remaining (" + Form.duration(elapsed, 2) + " Elapsed)", - "Dimension: " + dimension, - "Method: " + method - }; - } - - @Override - public boolean paused() { - return pregenerator.paused(); - } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenMethod.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenMethod.java index b9950315b..4ed5b1b1a 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenMethod.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenMethod.java @@ -20,6 +20,7 @@ package art.arcane.iris.modded.command; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.pregenerator.PregenListener; +import art.arcane.iris.core.pregenerator.PregenMantleBackpressure; import art.arcane.iris.core.pregenerator.PregeneratorMethod; import art.arcane.iris.engine.framework.Engine; import art.arcane.volmlib.util.mantle.runtime.Mantle; @@ -42,45 +43,76 @@ public final class ModdedPregenMethod implements PregeneratorMethod { private final ServerLevel level; private final Engine engine; - private final ModdedPregenMode mode; + private final boolean sync; private final Semaphore semaphore; private final int permits; private final int timeoutSeconds; + private final PregenMantleBackpressure backpressure; public ModdedPregenMethod(ServerLevel level, Engine engine) { - this(level, engine, ModdedPregenMode.ASYNC); + this(level, engine, false); } - public ModdedPregenMethod(ServerLevel level, Engine engine, ModdedPregenMode mode) { + public ModdedPregenMethod(ServerLevel level, Engine engine, boolean sync) { this.level = level; this.engine = engine; - this.mode = mode; + this.sync = sync; this.permits = Math.min(96, Math.max(8, Runtime.getRuntime().availableProcessors() * 2)); this.semaphore = new Semaphore(permits, true); - this.timeoutSeconds = Math.max(120, IrisSettings.get().getPregen().getChunkLoadTimeoutSeconds()); + IrisSettings.IrisSettingsPregen pregen = IrisSettings.get().getPregen(); + this.timeoutSeconds = Math.max(120, pregen.getChunkLoadTimeoutSeconds()); + this.backpressure = new PregenMantleBackpressure( + this::getMantle, + pregen.getEffectiveResidentTectonicPlates(engine.getHeight()), + pregen.getMantleBackpressureWaitMs(), + pregen.getMantleBackpressureTimeoutMs(), + () -> { + }, + () -> "dim=" + level.dimension().identifier()); } @Override public void init() { LOGGER.info("Iris modded pregen init: dim={} mode={} inFlightCap={} timeout={}s", - level.dimension().identifier(), mode, mode == ModdedPregenMode.ASYNC ? permits : 1, timeoutSeconds); + level.dimension().identifier(), sync ? "sync" : "async", sync ? 1 : permits, timeoutSeconds); } @Override public void close() { - if (mode == ModdedPregenMode.ASYNC) { - semaphore.acquireUninterruptibly(permits); + if (!sync) { + try { + semaphore.tryAcquire(permits, 5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } } - saveLevel(); + saveLevel(true); } @Override public void save() { - saveLevel(); + saveLevel(false); } - private void saveLevel() { - level.getServer().execute(() -> level.save(null, false, false)); + private void saveLevel(boolean wait) { + CompletableFuture saved = new CompletableFuture<>(); + level.getServer().execute(() -> { + try { + level.save(null, false, false); + } finally { + saved.complete(null); + } + }); + if (!wait) { + return; + } + try { + saved.get(10, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (TimeoutException | ExecutionException e) { + LOGGER.warn("Iris pregen level save did not complete in time for {}", level.dimension().identifier()); + } } @Override @@ -95,7 +127,7 @@ public final class ModdedPregenMethod implements PregeneratorMethod { @Override public boolean isAsyncChunkMode() { - return mode == ModdedPregenMode.ASYNC; + return !sync; } @Override @@ -105,7 +137,8 @@ public final class ModdedPregenMethod implements PregeneratorMethod { @Override public void generateChunk(int x, int z, PregenListener listener) { - if (mode == ModdedPregenMode.SYNC) { + backpressure.apply(); + if (sync) { generateChunkSync(x, z, listener); return; } @@ -122,6 +155,7 @@ public final class ModdedPregenMethod implements PregeneratorMethod { Object result = loadFuture.get(timeoutSeconds, TimeUnit.SECONDS); if (result instanceof ChunkResult chunkResult && !chunkResult.isSuccess()) { LOGGER.warn("Iris pregen chunk {},{} returned no chunk: {}", x, z, chunkResult.getError()); + listener.onChunkFailed(x, z); return; } listener.onChunkGenerated(x, z); @@ -131,6 +165,7 @@ public final class ModdedPregenMethod implements PregeneratorMethod { Thread.currentThread().interrupt(); } catch (TimeoutException | ExecutionException e) { LOGGER.warn("Iris pregen chunk {},{} failed: {}", x, z, e.toString()); + listener.onChunkFailed(x, z); } finally { level.getServer().execute(() -> level.getChunkSource().removeTicketWithRadius(PREGEN_TICKET, pos, 0)); } @@ -155,10 +190,12 @@ public final class ModdedPregenMethod implements PregeneratorMethod { try { if (error != null) { LOGGER.warn("Iris pregen chunk {},{} failed: {}", x, z, error.toString()); + listener.onChunkFailed(x, z); return; } if (result instanceof ChunkResult chunkResult && !chunkResult.isSuccess()) { LOGGER.warn("Iris pregen chunk {},{} returned no chunk: {}", x, z, chunkResult.getError()); + listener.onChunkFailed(x, z); return; } listener.onChunkGenerated(x, z); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenMode.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenMode.java deleted file mode 100644 index 94c3e056c..000000000 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenMode.java +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Iris is a World Generator for Minecraft Servers - * Copyright (c) 2026 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.modded.command; - -public enum ModdedPregenMode { - ASYNC, - SYNC -} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedRegen.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedRegen.java index 95e950332..1ebd3992f 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedRegen.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedRegen.java @@ -18,6 +18,7 @@ package art.arcane.iris.modded.command; +import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.mantle.EngineMantle; import art.arcane.iris.modded.IrisModdedChunkGenerator; @@ -25,6 +26,7 @@ import art.arcane.iris.modded.ModdedBlockBuffer; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.PlatformBiome; import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.common.math.ChunkSpiral; import art.arcane.iris.util.common.parallel.MultiBurst; import art.arcane.iris.util.project.hunk.Hunk; import art.arcane.volmlib.util.format.Form; @@ -54,7 +56,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.ArrayList; -import java.util.Comparator; import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Semaphore; @@ -105,9 +106,11 @@ public final class ModdedRegen { private void run() { long startedAt = M.ms(); + String worldName = engine.getWorld() == null ? null : engine.getWorld().name(); + IrisToolbelt.beginWorldMaintenance(worldName, "regen"); try { resetMantleMargin(); - List targets = orderedTargets(centerX, centerZ, radius); + List targets = ChunkSpiral.centerOut(centerX, centerZ, radius); int applied = regenerate(targets); ok("Regen finished: " + applied + "/" + targets.size() + " chunk(s) in " + Form.duration(M.ms() - startedAt, 2)); LOGGER.info("Iris regen done: {}/{} chunks in {}ms", applied, targets.size(), M.ms() - startedAt); @@ -117,6 +120,7 @@ public final class ModdedRegen { LOGGER.error("Iris regen failed", e); fail("Regen failed: " + e); } finally { + IrisToolbelt.endWorldMaintenance(worldName, "regen"); ACTIVE.set(false); } } @@ -259,21 +263,6 @@ public final class ModdedRegen { } } - private static List orderedTargets(int centerX, int centerZ, int radius) { - List targets = new ArrayList<>(); - for (int dx = -radius; dx <= radius; dx++) { - for (int dz = -radius; dz <= radius; dz++) { - targets.add(new int[]{centerX + dx, centerZ + dz}); - } - } - targets.sort(Comparator.comparingInt((int[] t) -> { - int ox = t[0] - centerX; - int oz = t[1] - centerZ; - return ox * ox + oz * oz; - })); - return targets; - } - private void ok(String message) { server.execute(() -> IrisModdedCommands.ok(source, message)); } 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 b86e7d53d..6f818ffa2 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 @@ -23,6 +23,7 @@ import art.arcane.iris.core.gui.GuiHost; import art.arcane.iris.core.gui.NoiseExplorerGUI; import art.arcane.iris.core.gui.VisionGUI; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.project.IrisProjectCopier; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBiomeGeneratorLink; @@ -41,6 +42,7 @@ import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.format.Form; import art.arcane.volmlib.util.function.Function2; import art.arcane.volmlib.util.io.IO; +import art.arcane.volmlib.util.json.JSONArray; import art.arcane.volmlib.util.json.JSONObject; import art.arcane.volmlib.util.math.M; import art.arcane.volmlib.util.math.RNG; @@ -63,13 +65,10 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.zeroturnaround.zip.ZipUtil; +import java.awt.Desktop; import java.io.File; import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardCopyOption; import java.util.ArrayList; -import java.util.Comparator; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; @@ -80,7 +79,6 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Predicate; import java.util.function.Supplier; import java.util.regex.Pattern; -import java.util.stream.Stream; public final class ModdedStudioCommands { private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); @@ -150,9 +148,9 @@ public final class ModdedStudioCommands { .executes((CommandContext context) -> tpStudio(context.getSource()))); root.then(Commands.literal("status") .executes((CommandContext context) -> status(context.getSource()))); - root.then(message("vscode", "VSCode launch and workspace generation are desktop features of the Bukkit studio toolchain; edit config/irisworldgen/packs/ directly in your editor.")); - root.then(message("vsc", "VSCode launch and workspace generation are desktop features of the Bukkit studio toolchain; edit config/irisworldgen/packs/ directly in your editor.")); - root.then(message("update", "Workspace regeneration (.code-workspace + JSON schemas) reads Bukkit registries (SchemaBuilder); run /iris studio update on a Bukkit server against this pack.")); + root.then(workspaceTree("vscode", true)); + root.then(workspaceTree("vsc", true)); + root.then(workspaceTree("update", false)); root.then(message("importvanilla", "Vanilla tree/object/structure capture generates features in throwaway Bukkit worlds via NMS; run /iris studio importvanilla on a Bukkit server against this pack, then copy the pack folder over.")); root.then(message("importv", "Vanilla tree/object/structure capture generates features in throwaway Bukkit worlds via NMS; run /iris studio importvanilla on a Bukkit server against this pack, then copy the pack folder over.")); root.then(message("iv", "Vanilla tree/object/structure capture generates features in throwaway Bukkit worlds via NMS; run /iris studio importvanilla on a Bukkit server against this pack, then copy the pack folder over.")); @@ -170,6 +168,10 @@ public final class ModdedStudioCommands { return root; } + public static void clear() { + STUDIOS.clear(); + } + private static LiteralArgumentBuilder openTree(String name) { return Commands.literal(name) .executes((CommandContext context) -> openHelp(context.getSource())) @@ -206,7 +208,8 @@ public final class ModdedStudioCommands { return 0; } if (engine != null) { - ModdedGuiHost.bindContext(source.getServer(), level, engine); + ServerPlayer player = source.getPlayer(); + ModdedGuiHost.bindContext(source.getServer(), level, engine, player == null ? null : player.getUUID()); } if (generatorKey == null || generatorKey.isBlank()) { NoiseExplorerGUI.launch(); @@ -240,13 +243,91 @@ public final class ModdedStudioCommands { IrisModdedCommands.fail(source, guiUnavailableMessage()); return 0; } - ModdedGuiHost.bindContext(source.getServer(), level, engine); + ServerPlayer player = source.getPlayer(); + ModdedGuiHost.bindContext(source.getServer(), level, engine, player == null ? null : player.getUUID()); VisionGUI.launch(engine); IrisModdedCommands.ok(source, "Opening the Vision map for " + level.dimension().identifier() + " on the server display."); return 1; } + private static LiteralArgumentBuilder workspaceTree(String name, boolean open) { + return Commands.literal(name) + .executes((CommandContext context) -> workspace(context.getSource(), null, open)) + .then(Commands.argument("pack", StringArgumentType.word()).suggests(IrisModdedCommands.PACK_NAMES) + .executes((CommandContext context) -> workspace(context.getSource(), StringArgumentType.getString(context, "pack"), open))); + } + + private static int workspace(CommandSourceStack source, String pack, boolean open) { + File folder = resolvePack(source, pack); + if (folder == null) { + return 0; + } + File workspace = new File(folder, folder.getName() + ".code-workspace"); + try { + IO.writeAll(workspace, workspaceConfig().toString(4)); + } catch (IOException e) { + LOGGER.error("Iris workspace write failed for {}", workspace, e); + IrisModdedCommands.fail(source, "Failed to write " + workspace.getAbsolutePath() + ": " + e.getMessage()); + return 0; + } + IrisModdedCommands.ok(source, "Workspace regenerated: " + workspace.getAbsolutePath() + " (JSON schemas require the Bukkit studio toolchain)."); + if (!open) { + return 1; + } + if (!GuiHost.isAvailable() || !Desktop.isDesktopSupported()) { + IrisModdedCommands.fail(source, guiUnavailableMessage()); + return 0; + } + try { + Desktop.getDesktop().open(workspace); + } catch (Throwable e) { + LOGGER.error("Iris workspace open failed for {}", workspace, e); + IrisModdedCommands.fail(source, "Could not open " + workspace.getName() + ": " + e.getClass().getSimpleName()); + return 0; + } + IrisModdedCommands.ok(source, "Opening " + workspace.getName() + " in your editor."); + return 1; + } + + private static JSONObject workspaceConfig() { + JSONObject ws = new JSONObject(); + JSONArray folders = new JSONArray(); + JSONObject folder = new JSONObject(); + folder.put("path", "."); + folders.put(folder); + ws.put("folders", folders); + JSONObject settings = new JSONObject(); + settings.put("workbench.colorTheme", "Monokai"); + settings.put("workbench.preferredDarkColorTheme", "Solarized Dark"); + settings.put("workbench.tips.enabled", false); + settings.put("workbench.tree.indent", 24); + settings.put("files.autoSave", "onFocusChange"); + JSONObject json = new JSONObject(); + json.put("editor.autoIndent", "brackets"); + json.put("editor.acceptSuggestionOnEnter", "smart"); + json.put("editor.cursorSmoothCaretAnimation", true); + json.put("editor.dragAndDrop", false); + json.put("files.trimTrailingWhitespace", true); + json.put("diffEditor.ignoreTrimWhitespace", true); + json.put("files.trimFinalNewlines", true); + json.put("editor.suggest.showKeywords", false); + json.put("editor.suggest.showSnippets", false); + json.put("editor.suggest.showWords", false); + JSONObject quick = new JSONObject(); + quick.put("strings", true); + json.put("editor.quickSuggestions", quick); + json.put("editor.suggest.insertMode", "replace"); + settings.put("[json]", json); + settings.put("json.maxItemsComputed", 30000); + settings.put("json.schemas", new JSONArray()); + ws.put("settings", settings); + return ws; + } + private static String guiUnavailableMessage() { + if (GuiHost.isDesktopSuppressed()) { + return "Iris desktop GUIs are disabled in singleplayer/client to avoid crashing the game client; use the in-game map and chat output instead."; + } if (!GuiHost.isAvailable()) { return "This server has no display (headless JVM); the Iris desktop GUIs need an AWT-capable session."; } @@ -346,7 +427,7 @@ public final class ModdedStudioCommands { private static void injectConsole(CommandSourceStack source, MinecraftServer server, String dimensionId, String pack, long seed) { ModdedDimensionManager.Handle handle; try { - handle = ModdedDimensionManager.create(server, dimensionId, pack, seed); + handle = ModdedDimensionManager.create(server, dimensionId, pack, pack, seed); } catch (Throwable e) { LOGGER.error("Iris console studio injection failed for {} ({})", dimensionId, pack, e); IrisModdedCommands.fail(source, "Studio injection failed: " + e.getClass().getSimpleName() + (e.getMessage() == null ? "" : " - " + e.getMessage())); @@ -376,7 +457,7 @@ public final class ModdedStudioCommands { } ModdedDimensionManager.Handle handle; try { - handle = ModdedDimensionManager.create(server, dimensionId, pack, seed); + handle = ModdedDimensionManager.create(server, dimensionId, pack, pack, seed); } catch (Throwable e) { LOGGER.error("Iris studio injection failed for {} ({})", dimensionId, pack, e); IrisModdedCommands.fail(source, "Studio injection failed: " + e.getClass().getSimpleName() + (e.getMessage() == null ? "" : " - " + e.getMessage())); @@ -434,7 +515,7 @@ public final class ModdedStudioCommands { for (ModdedDimensionManager.Handle handle : studios) { UUID owner = ownerOf(handle.dimensionId()); String ownerName = owner == null ? "unclaimed" : ownerName(server, owner); - IrisModdedCommands.ok(source, " " + handle.dimensionId() + ": pack '" + handle.packKey() + "' seed " + handle.seed() + " owner " + ownerName); + IrisModdedCommands.ok(source, " " + handle.dimensionId() + ": pack '" + handle.pack() + "' seed " + handle.seed() + " owner " + ownerName); } return 1; } @@ -529,7 +610,7 @@ public final class ModdedStudioCommands { return; } } - copyProject(templateFolder, target, template, name); + IrisProjectCopier.copyProject(templateFolder, target, template, name); server.execute(() -> { IrisModdedCommands.ok(source, "Created project '" + name + "' at " + target.getAbsolutePath()); IrisModdedCommands.ok(source, "Edit dimensions/" + name + ".json and the rest of the pack, then create a world with it. VSCode workspaces are generated by the Bukkit studio toolchain only."); @@ -544,39 +625,6 @@ public final class ModdedStudioCommands { return 1; } - private static void copyProject(File templateFolder, File target, String template, String name) throws IOException { - Path source = templateFolder.toPath(); - try (Stream walk = Files.walk(source)) { - for (Path path : walk.sorted(Comparator.naturalOrder()).toList()) { - String relative = source.relativize(path).toString(); - if (relative.isEmpty() || relative.equals(".git") || relative.startsWith(".git" + File.separator) || relative.endsWith(".code-workspace")) { - continue; - } - Path destination = target.toPath().resolve(relative); - if (Files.isDirectory(path)) { - Files.createDirectories(destination); - } else { - Files.createDirectories(destination.getParent()); - Files.copy(path, destination, StandardCopyOption.REPLACE_EXISTING); - } - } - } - - File oldDimension = new File(target, "dimensions/" + template + ".json"); - File newDimension = new File(target, "dimensions/" + name + ".json"); - if (oldDimension.isFile()) { - Files.copy(oldDimension.toPath(), newDimension.toPath(), StandardCopyOption.REPLACE_EXISTING); - Files.delete(oldDimension.toPath()); - } - if (newDimension.isFile()) { - JSONObject json = new JSONObject(IO.readAll(newDimension)); - if (json.has("name")) { - json.put("name", Form.capitalizeWords(name.replaceAll("\\Q-\\E", " "))); - IO.writeAll(newDimension, json.toString(4)); - } - } - } - private static int pkg(CommandSourceStack source, String pack) { File folder = resolvePack(source, pack); if (folder == null) { @@ -738,7 +786,7 @@ public final class ModdedStudioCommands { new Spiraler(diameter, diameter, (int x, int z) -> executor.queue(() -> { IrisRegion region = engine.getRegion((x << 4) + 8, (z << 4) + 8); counts.computeIfAbsent(region.getLoadKey(), (String key) -> new AtomicInteger(0)).incrementAndGet(); - })).setOffset(blockX, blockZ).drain(); + })).setOffset(blockX >> 4, blockZ >> 4).drain(); executor.complete(); burst.close(); server.execute(() -> counts.forEach((String key, AtomicInteger count) -> { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedVisionOverlay.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedVisionOverlay.java index 2cd9c2af1..1b015b8b1 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedVisionOverlay.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedVisionOverlay.java @@ -18,8 +18,10 @@ package art.arcane.iris.modded.command; +import art.arcane.iris.core.gui.GuiHost; import art.arcane.iris.core.gui.GuiMarker; import art.arcane.iris.core.gui.GuiOverlay; +import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.render.RenderType; import net.minecraft.server.MinecraftServer; @@ -30,19 +32,24 @@ import net.minecraft.world.entity.LivingEntity; import net.minecraft.world.level.levelgen.Heightmap; import net.minecraft.world.phys.Vec3; +import java.awt.Desktop; +import java.io.File; import java.util.ArrayList; import java.util.List; +import java.util.UUID; import java.util.function.Consumer; public final class ModdedVisionOverlay implements GuiOverlay { private final MinecraftServer server; private final ServerLevel level; private final Engine engine; + private final UUID opener; - public ModdedVisionOverlay(MinecraftServer server, ServerLevel level, Engine engine) { + public ModdedVisionOverlay(MinecraftServer server, ServerLevel level, Engine engine, UUID opener) { this.server = server; this.level = level; this.engine = engine; + this.opener = opener; } @Override @@ -76,11 +83,14 @@ public final class ModdedVisionOverlay implements GuiOverlay { int blockX = (int) worldX; int blockZ = (int) worldZ; server.execute(() -> { - List players = level.players(); - if (players.isEmpty()) { - return; + ServerPlayer player = opener == null ? null : server.getPlayerList().getPlayer(opener); + if (player == null) { + List players = level.players(); + if (players.isEmpty()) { + return; + } + player = players.get(0); } - ServerPlayer player = players.get(0); int surfaceY = engine.getMinHeight() + engine.getHeight(blockX, blockZ, false) + 2; int safeY = Math.max(surfaceY, level.getHeight(Heightmap.Types.MOTION_BLOCKING, blockX, blockZ) + 1); player.teleportTo(level, blockX + 0.5D, safeY, blockZ + 0.5D, java.util.Set.of(), player.getYRot(), player.getXRot(), false); @@ -89,6 +99,19 @@ public final class ModdedVisionOverlay implements GuiOverlay { @Override public String openInEditor(double worldX, double worldZ, RenderType type) { - return null; + if (!GuiHost.isAvailable() || !Desktop.isDesktopSupported()) { + return null; + } + IrisComplex complex = engine.getComplex(); + File file = switch (type) { + case BIOME, LAYER_LOAD, DECORATOR_LOAD, OBJECT_LOAD, HEIGHT -> + complex.getTrueBiomeStream().get(worldX, worldZ).openInVSCode(); + case BIOME_LAND -> complex.getLandBiomeStream().get(worldX, worldZ).openInVSCode(); + case BIOME_SEA -> complex.getSeaBiomeStream().get(worldX, worldZ).openInVSCode(); + case REGION -> complex.getRegionStream().get(worldX, worldZ).openInVSCode(); + case CAVE_LAND -> complex.getCaveBiomeStream().get(worldX, worldZ).openInVSCode(); + default -> null; + }; + return file == null ? null : file.getName(); } } 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 e1290db73..21b8ecf1b 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 @@ -19,19 +19,25 @@ package art.arcane.iris.modded.command; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.pack.PackValidationRegistry; +import art.arcane.iris.core.pack.PackValidationResult; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.modded.IrisModdedChunkGenerator; import art.arcane.iris.modded.ModdedDimensionManager; +import art.arcane.iris.modded.ModdedEngineBootstrap; import art.arcane.iris.modded.ModdedModConfig; +import art.arcane.iris.modded.ModdedPackInstaller; import art.arcane.iris.modded.ModdedPrimaryWorldRouter; import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.builder.LiteralArgumentBuilder; import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.context.ParsedCommandNode; import com.mojang.brigadier.suggestion.SuggestionProvider; import com.mojang.brigadier.suggestion.SuggestionsBuilder; import net.minecraft.commands.CommandSourceStack; import net.minecraft.commands.Commands; import net.minecraft.commands.SharedSuggestionProvider; +import net.minecraft.commands.arguments.IdentifierArgument; import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerLevel; import org.slf4j.Logger; @@ -41,12 +47,14 @@ import java.io.File; import java.util.ArrayList; import java.util.List; import java.util.Locale; +import java.util.concurrent.ThreadLocalRandom; import java.util.function.Predicate; public final class ModdedWorldCommands { private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); private static final Predicate GATE = Commands.hasPermission(Commands.LEVEL_GAMEMASTERS); private static final String DEFAULT_NAMESPACE = "irisworldgen"; + private static final long DEFAULT_SEED = 1337L; private static final SuggestionProvider LOADED_DIMENSIONS = (CommandContext context, SuggestionsBuilder builder) -> SharedSuggestionProvider.suggest(loadedIrisDimensions(context.getSource().getServer()), builder); private ModdedWorldCommands() { @@ -68,53 +76,68 @@ public final class ModdedWorldCommands { root.then(enableTree("create")); root.then(replaceOverworldTree()); - root.then(disableTree("disable")); - root.then(disableTree("remove")); - root.then(disableTree("rm")); + root.then(disableTree()); + root.then(deleteTree("delete")); + root.then(deleteTree("remove")); + root.then(deleteTree("rm")); return root; } private static LiteralArgumentBuilder enableTree(String name) { return Commands.literal(name) - .then(Commands.argument("dimension", StringArgumentType.word()) - .then(Commands.argument("pack", StringArgumentType.word()).suggests(IrisModdedCommands.PACK_NAMES) + .then(Commands.argument("dimension", IdentifierArgument.id()) + .then(Commands.argument("pack", StringArgumentType.string()).suggests(IrisModdedCommands.PACK_NAMES) .executes((CommandContext context) -> enable(context.getSource(), - StringArgumentType.getString(context, "dimension"), + dimensionArgument(context), StringArgumentType.getString(context, "pack"), - StringArgumentType.getString(context, "pack"), - 1337L)) - .then(Commands.argument("packDimension", StringArgumentType.word()) + null)) + .then(Commands.argument("seed", StringArgumentType.word()) .executes((CommandContext context) -> enable(context.getSource(), - StringArgumentType.getString(context, "dimension"), + dimensionArgument(context), StringArgumentType.getString(context, "pack"), - StringArgumentType.getString(context, "packDimension"), - 1337L))))); + StringArgumentType.getString(context, "seed")))))); } - private static LiteralArgumentBuilder disableTree(String name) { + private static LiteralArgumentBuilder disableTree() { + return Commands.literal("disable") + .then(Commands.argument("dimension", IdentifierArgument.id()).suggests(LOADED_DIMENSIONS) + .executes((CommandContext context) -> disable(context.getSource(), dimensionArgument(context), false))); + } + + private static LiteralArgumentBuilder deleteTree(String name) { return Commands.literal(name) - .then(Commands.argument("dimension", StringArgumentType.word()).suggests(LOADED_DIMENSIONS) - .executes((CommandContext context) -> disable(context.getSource(), StringArgumentType.getString(context, "dimension")))); + .then(Commands.argument("dimension", IdentifierArgument.id()).suggests(LOADED_DIMENSIONS) + .executes((CommandContext context) -> disable(context.getSource(), dimensionArgument(context), true))); } private static LiteralArgumentBuilder replaceOverworldTree() { return Commands.literal("replace-overworld") - .then(Commands.argument("pack", StringArgumentType.word()).suggests(IrisModdedCommands.PACK_NAMES) + .then(Commands.argument("pack", StringArgumentType.string()).suggests(IrisModdedCommands.PACK_NAMES) .executes((CommandContext context) -> replaceOverworld(context.getSource(), StringArgumentType.getString(context, "pack"), - StringArgumentType.getString(context, "pack"))) - .then(Commands.argument("packDimension", StringArgumentType.word()) + null)) + .then(Commands.argument("seed", StringArgumentType.word()) .executes((CommandContext context) -> replaceOverworld(context.getSource(), StringArgumentType.getString(context, "pack"), - StringArgumentType.getString(context, "packDimension"))))); + StringArgumentType.getString(context, "seed"))))); } public static int createWorld(CommandSourceStack source, String name, String pack, long seed) { - return enable(source, name, pack, pack, seed); + String[] packRef = parsePackRef(pack); + return enable(source, name, packRef[0], packRef[1], seed); } - private static int enable(CommandSourceStack source, String targetDimension, String packName, String packDimension, long seed) { + private static int enable(CommandSourceStack source, String targetDimension, String packRaw, String seedRaw) { + Long seed = parseSeed(source, seedRaw); + if (seed == null) { + return 0; + } + String[] packRef = parsePackRef(packRaw); + return enable(source, targetDimension, packRef[0], packRef[1], seed); + } + + private static int enable(CommandSourceStack source, String targetDimension, String pack, String packDimension, long seed) { MinecraftServer server = source.getServer(); String dimensionId; try { @@ -123,58 +146,146 @@ public final class ModdedWorldCommands { IrisModdedCommands.fail(source, e.getMessage()); return 0; } - if (!loadPackDimension(source, packName, packDimension)) { + if (!validPackRef(source, pack, packDimension)) { + return 0; + } + File packFolder = new File(ModdedPackCommands.packsRoot(), pack); + if (packFolder.isDirectory()) { + return enableInstalled(source, server, dimensionId, pack, packDimension, seed); + } + IrisModdedCommands.ok(source, "Pack '" + pack + "' is not installed; downloading IrisDimensions/" + pack + "..."); + Thread thread = new Thread(() -> { + boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), pack, "master", + (String line) -> server.execute(() -> IrisModdedCommands.ok(source, line))); + server.execute(() -> { + if (!installed || !packFolder.isDirectory()) { + IrisModdedCommands.fail(source, "Pack '" + pack + "' could not be downloaded; check the name or install it with /iris download " + pack + "."); + return; + } + enableInstalled(source, server, dimensionId, pack, packDimension, seed); + }); + }, "Iris World Pack Download"); + thread.setDaemon(true); + thread.start(); + return 1; + } + + private static int enableInstalled(CommandSourceStack source, MinecraftServer server, String dimensionId, String pack, String packDimension, long seed) { + if (!loadPackDimension(source, pack, packDimension)) { + return 0; + } + if (blockIfPackBroken(source, dimensionId, pack)) { return 0; } try { - ModdedDimensionManager.createPersistent(server, dimensionId, packDimension, seed); + ModdedDimensionManager.createPersistent(server, dimensionId, pack, packDimension, seed); } catch (Throwable e) { - LOGGER.error("Iris world injection failed for {} (pack={} dim={})", dimensionId, packName, packDimension, e); + LOGGER.error("Iris world injection failed for {} (pack={} dim={})", dimensionId, pack, packDimension, e); IrisModdedCommands.fail(source, "Failed to inject Iris world '" + dimensionId + "': " + e.getClass().getSimpleName() + (e.getMessage() == null ? "" : " - " + e.getMessage())); return 0; } - IrisModdedCommands.ok(source, "Created Iris world " + dimensionId + " from pack '" + packName + "' dimension '" + packDimension + "' (seed " + seed + ")."); + IrisModdedCommands.ok(source, "Created Iris world " + dimensionId + " from pack '" + pack + "' dimension '" + packDimension + "' (seed " + seed + ")."); IrisModdedCommands.ok(source, "It is live now and re-injected on every startup. Teleport in with /iris world status or a portal; no restart required."); return 1; } - private static int replaceOverworld(CommandSourceStack source, String packName, String packDimension) { + private static int replaceOverworld(CommandSourceStack source, String packRaw, String seedRaw) { MinecraftServer server = source.getServer(); + Long seed = parseSeed(source, seedRaw); + if (seed == null) { + return 0; + } + String[] packRef = parsePackRef(packRaw); + String pack = packRef[0]; + String packDimension = packRef[1]; + if (!validPackRef(source, pack, packDimension)) { + return 0; + } String dimensionId = DEFAULT_NAMESPACE + ":primary"; - if (!loadPackDimension(source, packName, packDimension)) { + if (!loadPackDimension(source, pack, packDimension)) { + return 0; + } + if (blockIfPackBroken(source, dimensionId, pack)) { return 0; } try { - ModdedDimensionManager.createPersistent(server, dimensionId, packDimension, 1337L); + ModdedDimensionManager.createPersistent(server, dimensionId, pack, packDimension, seed); } catch (Throwable e) { - LOGGER.error("Iris primary world injection failed for {} (pack={} dim={})", dimensionId, packName, packDimension, e); + LOGGER.error("Iris primary world injection failed for {} (pack={} dim={})", dimensionId, pack, packDimension, e); IrisModdedCommands.fail(source, "Failed to inject Iris primary world: " + e.getClass().getSimpleName() + (e.getMessage() == null ? "" : " - " + e.getMessage())); return 0; } ModdedModConfig.setPrimaryWorld(dimensionId); ModdedPrimaryWorldRouter.clear(); - IrisModdedCommands.ok(source, "Iris primary world set to " + dimensionId + " (pack '" + packName + "' dimension '" + packDimension + "')."); + IrisModdedCommands.ok(source, "Iris primary world set to " + dimensionId + " (pack '" + pack + "' dimension '" + packDimension + "' seed " + seed + ")."); IrisModdedCommands.ok(source, "The vanilla overworld generator cannot be hot-swapped, so this does NOT regenerate the existing overworld."); IrisModdedCommands.ok(source, "Instead, " + dimensionId + " is now the configured primary world: players in the vanilla overworld are routed there on join, and it re-injects on every startup."); return 1; } - private static boolean loadPackDimension(CommandSourceStack source, String packName, String packDimension) { - File packFolder = new File(ModdedPackCommands.packsRoot(), packName); + private static boolean blockIfPackBroken(CommandSourceStack source, String dimensionId, String pack) { + PackValidationResult validation = PackValidationRegistry.get(pack); + if (validation == null || validation.isLoadable()) { + return false; + } + IrisModdedCommands.fail(source, "Refusing to create world '" + dimensionId + "' using broken pack '" + pack + "':"); + for (String reason : validation.getBlockingErrors()) { + IrisModdedCommands.fail(source, " - " + reason); + } + IrisModdedCommands.fail(source, "Fix the pack and run /iris pack validate " + pack + " to revalidate."); + return true; + } + + private static boolean loadPackDimension(CommandSourceStack source, String pack, String packDimension) { + File packFolder = new File(ModdedPackCommands.packsRoot(), pack); if (!packFolder.isDirectory()) { - IrisModdedCommands.fail(source, "Pack '" + packName + "' was not found under " + ModdedPackCommands.packsRoot().getAbsolutePath()); + IrisModdedCommands.fail(source, "Pack '" + pack + "' was not found under " + ModdedPackCommands.packsRoot().getAbsolutePath()); return false; } IrisData data = IrisData.get(packFolder); IrisDimension dimension = data.getDimensionLoader().load(packDimension); if (dimension == null) { - IrisModdedCommands.fail(source, "Pack '" + packName + "' does not contain dimensions/" + packDimension + ".json"); + IrisModdedCommands.fail(source, "Pack '" + pack + "' does not contain dimensions/" + packDimension + ".json"); return false; } return true; } - private static int disable(CommandSourceStack source, String targetDimension) { + private static String[] parsePackRef(String raw) { + String value = raw.trim(); + int colon = value.indexOf(':'); + if (colon >= 0) { + return new String[]{value.substring(0, colon), value.substring(colon + 1)}; + } + return new String[]{value, value}; + } + + private static boolean validPackRef(CommandSourceStack source, String pack, String packDimension) { + if (pack.matches("[A-Za-z0-9_.-]+") && !pack.contains("..") + && packDimension.matches("[A-Za-z0-9_/.-]+") && !packDimension.contains("..")) { + return true; + } + IrisModdedCommands.fail(source, "Invalid pack reference '" + pack + (pack.equals(packDimension) ? "" : ":" + packDimension) + "'. Use pack or pack:dimensionKey."); + return false; + } + + private static Long parseSeed(CommandSourceStack source, String seedRaw) { + if (seedRaw == null || seedRaw.isBlank()) { + return DEFAULT_SEED; + } + String value = seedRaw.trim(); + if (value.equalsIgnoreCase("random")) { + return ThreadLocalRandom.current().nextLong(); + } + try { + return Long.parseLong(value); + } catch (NumberFormatException e) { + IrisModdedCommands.fail(source, "Invalid seed '" + seedRaw + "'. Use a number or 'random'."); + return null; + } + } + + private static int disable(CommandSourceStack source, String targetDimension, boolean wipeStorage) { MinecraftServer server = source.getServer(); String dimensionId; try { @@ -185,7 +296,7 @@ public final class ModdedWorldCommands { } boolean removed; try { - removed = ModdedDimensionManager.removePersistent(server, dimensionId); + removed = ModdedDimensionManager.removePersistent(server, dimensionId, wipeStorage); } catch (Throwable e) { LOGGER.error("Iris world removal failed for {}", dimensionId, e); IrisModdedCommands.fail(source, "Failed to remove Iris world '" + dimensionId + "': " + e.getClass().getSimpleName() + (e.getMessage() == null ? "" : " - " + e.getMessage())); @@ -196,10 +307,19 @@ public final class ModdedWorldCommands { ModdedPrimaryWorldRouter.clear(); } if (!removed) { - IrisModdedCommands.ok(source, "Iris world '" + dimensionId + "' was not loaded; cleared its persistent registry entry."); + if (wipeStorage) { + IrisModdedCommands.ok(source, "Iris world '" + dimensionId + "' was not loaded; cleared its persistent registry entry and wiped its stored chunk/mantle data."); + } else { + IrisModdedCommands.ok(source, "Iris world '" + dimensionId + "' was not loaded; cleared its persistent registry entry. World data on disk is kept."); + } return 1; } - IrisModdedCommands.ok(source, "Removed Iris world '" + dimensionId + "': evacuated, unloaded, region data deleted, and dropped from the startup registry."); + if (wipeStorage) { + IrisModdedCommands.ok(source, "Deleted Iris world '" + dimensionId + "': evacuated, unloaded, chunk/mantle data wiped, and dropped from the startup registry."); + } else { + IrisModdedCommands.ok(source, "Disabled Iris world '" + dimensionId + "': evacuated, unloaded, and dropped from the startup registry."); + IrisModdedCommands.ok(source, "World data on disk is kept; re-enable with /iris world enable " + dimensionId + " or delete it with /iris world delete " + dimensionId + "."); + } return 1; } @@ -209,7 +329,7 @@ public final class ModdedWorldCommands { for (ServerLevel level : server.getAllLevels()) { if (level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator generator) { loaded++; - IrisModdedCommands.ok(source, "Loaded Iris level: " + level.dimension().identifier() + " -> pack dimension '" + generator.activePackKey() + "'"); + IrisModdedCommands.ok(source, "Loaded Iris level: " + level.dimension().identifier() + " -> pack '" + generator.activePack() + "' dimension '" + generator.activeDimensionKey() + "'"); } } String primary = ModdedModConfig.get().primaryWorld(); @@ -244,6 +364,15 @@ public final class ModdedWorldCommands { return dimensions; } + private static String dimensionArgument(CommandContext context) { + for (ParsedCommandNode node : context.getNodes()) { + if ("dimension".equals(node.getNode().getName())) { + return node.getRange().get(context.getInput()); + } + } + return IdentifierArgument.getId(context, "dimension").toString(); + } + private static String normalizeDimensionId(String value) { if (value == null || value.isBlank()) { throw new IllegalArgumentException("Missing dimension id."); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedChunkUpdateService.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedChunkUpdateService.java new file mode 100644 index 000000000..72adf543b --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedChunkUpdateService.java @@ -0,0 +1,306 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 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.modded.service; + +import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.core.gui.PregeneratorJob; +import art.arcane.iris.engine.data.cache.Cache; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.TileData; +import art.arcane.iris.modded.IrisModdedChunkGenerator; +import art.arcane.iris.modded.ModdedBlockResolution; +import art.arcane.iris.modded.ModdedLootApplier; +import art.arcane.iris.modded.ModdedTileData; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.util.project.matter.TileWrapper; +import art.arcane.volmlib.util.mantle.flag.MantleFlag; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.math.BlockPosition; +import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.matter.Matter; +import art.arcane.volmlib.util.matter.MatterCavern; +import art.arcane.volmlib.util.matter.MatterUpdate; +import art.arcane.volmlib.util.scheduling.PrecisionStopwatch; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.NbtUtils; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; + +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; + +public final class ModdedChunkUpdateService implements ModdedTickableService { + private static final long PASS_PERIOD_MILLIS = 3_000L; + private static final int PLAYER_CHUNK_RADIUS = 1; + private static final int SILENT_FLAGS = Block.UPDATE_CLIENTS | Block.UPDATE_KNOWN_SHAPE | Block.UPDATE_SKIP_ON_PLACE; + + private final Set warmupQueue = ConcurrentHashMap.newKeySet(); + private volatile ExecutorService warmupExecutor; + private long lastPassAt; + + @Override + public void onEnable() { + if (warmupExecutor != null) { + return; + } + warmupExecutor = Executors.newSingleThreadExecutor((Runnable runnable) -> { + Thread thread = new Thread(runnable, "Iris Mantle Warmup"); + thread.setDaemon(true); + thread.setPriority(Thread.MIN_PRIORITY); + return thread; + }); + lastPassAt = 0L; + } + + @Override + public void onDisable() { + ExecutorService active = warmupExecutor; + warmupExecutor = null; + if (active != null) { + active.shutdown(); + } + warmupQueue.clear(); + } + + @Override + public void onServerTick(MinecraftServer server) { + long now = System.currentTimeMillis(); + if (now - lastPassAt < PASS_PERIOD_MILLIS) { + return; + } + lastPassAt = now; + + if (!IrisSettings.get().getWorld().isPostLoadBlockUpdates()) { + return; + } + + for (ServerLevel level : server.getAllLevels()) { + if (!(level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator generator)) { + continue; + } + Engine engine = generator.engineIfBound(); + if (engine == null || engine.isClosed() || engine.getMantle().getMantle().isClosed()) { + continue; + } + if (level.players().isEmpty() || isPregenActive(engine)) { + continue; + } + try { + updateNearPlayers(engine, level); + } catch (Throwable e) { + IrisLogging.reportError(e); + } + } + } + + private boolean isPregenActive(Engine engine) { + PregeneratorJob job = PregeneratorJob.getInstance(); + return job != null && job.targetsWorldName(engine.getWorld().name()); + } + + private void updateNearPlayers(Engine engine, ServerLevel level) { + for (ServerPlayer player : level.players()) { + int centerX = player.blockPosition().getX() >> 4; + int centerZ = player.blockPosition().getZ() >> 4; + for (int dx = -PLAYER_CHUNK_RADIUS; dx <= PLAYER_CHUNK_RADIUS; dx++) { + for (int dz = -PLAYER_CHUNK_RADIUS; dz <= PLAYER_CHUNK_RADIUS; dz++) { + updateChunk(engine, level, centerX + dx, centerZ + dz); + } + } + } + } + + private void updateChunk(Engine engine, ServerLevel level, int chunkX, int chunkZ) { + for (int x = -1; x <= 1; x++) { + for (int z = -1; z <= 1; z++) { + if (level.getChunkSource().getChunkNow(chunkX + x, chunkZ + z) == null) { + return; + } + } + } + + Mantle mantle = engine.getMantle().getMantle(); + if (!mantle.isChunkLoaded(chunkX, chunkZ)) { + warmupMantleChunk(mantle, chunkX, chunkZ); + return; + } + if (mantle.hasFlag(chunkX, chunkZ, MantleFlag.ETCHED)) { + return; + } + + MantleChunk chunk = mantle.getChunk(chunkX, chunkZ).use(); + try { + chunk.raiseFlagUnchecked(MantleFlag.ETCHED, () -> { + chunk.raiseFlagUnchecked(MantleFlag.TILE, () -> runTilePass(engine, level, chunkX, chunkZ, chunk)); + chunk.raiseFlagUnchecked(MantleFlag.UPDATE, () -> runUpdatePass(engine, level, chunkX, chunkZ, chunk)); + }); + } finally { + chunk.release(); + } + } + + private void runTilePass(Engine engine, ServerLevel level, int chunkX, int chunkZ, MantleChunk chunk) { + int minHeight = engine.getWorld().minHeight(); + int baseX = chunkX << 4; + int baseZ = chunkZ << 4; + chunk.iterate(TileWrapper.class, (Integer x, Integer yf, Integer z, TileWrapper v) -> { + int y = yf + minHeight; + if (y < level.getMinY() || y > level.getMaxY()) { + return; + } + applyTile(level, baseX + (x & 15), y, baseZ + (z & 15), v.getData()); + }); + } + + private void applyTile(ServerLevel level, int x, int y, int z, TileData tile) { + if (!(tile instanceof ModdedTileData moddedTile)) { + return; + } + String snbt = moddedTile.snbt(); + if (snbt == null || snbt.isBlank()) { + return; + } + BlockPos pos = new BlockPos(x, y, z); + BlockState state = level.getBlockState(pos); + if (!state.hasBlockEntity()) { + return; + } + try { + CompoundTag tag = NbtUtils.snbtToStructure(snbt); + BlockEntity restored = BlockEntity.loadStatic(pos, state, tag, level.registryAccess()); + if (restored == null) { + return; + } + level.setBlockEntity(restored); + restored.setChanged(); + } catch (Throwable e) { + IrisLogging.reportError(e); + } + } + + private void warmupMantleChunk(Mantle mantle, int chunkX, int chunkZ) { + ExecutorService active = warmupExecutor; + if (active == null) { + return; + } + long key = Cache.key(chunkX, chunkZ); + if (!warmupQueue.add(key)) { + return; + } + try { + active.execute(() -> { + try { + mantle.getChunk(chunkX, chunkZ); + } catch (Throwable e) { + IrisLogging.reportError(e); + } finally { + warmupQueue.remove(key); + } + }); + } catch (RejectedExecutionException rejected) { + warmupQueue.remove(key); + } + } + + private void runUpdatePass(Engine engine, ServerLevel level, int chunkX, int chunkZ, MantleChunk chunk) { + PrecisionStopwatch stopwatch = PrecisionStopwatch.start(); + int minHeight = engine.getWorld().minHeight(); + int baseX = chunkX << 4; + int baseZ = chunkZ << 4; + int[][] grid = new int[16][16]; + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + grid[x][z] = Integer.MIN_VALUE; + } + } + + RNG rng = new RNG(Cache.key(chunkX, chunkZ)); + chunk.iterate(MatterCavern.class, (Integer x, Integer yf, Integer z, MatterCavern v) -> { + int y = yf + minHeight; + if (y < level.getMinY() || y > level.getMaxY()) { + return; + } + int lx = x & 15; + int lz = z & 15; + BlockPos pos = new BlockPos(baseX + lx, y, baseZ + lz); + if (!ModdedBlockResolution.isFluid(level.getBlockState(pos))) { + return; + } + boolean exposed = ModdedBlockResolution.isAir(level.getBlockState(pos.below())) + || ModdedBlockResolution.isAir(level.getBlockState(pos.west())) + || ModdedBlockResolution.isAir(level.getBlockState(pos.east())) + || ModdedBlockResolution.isAir(level.getBlockState(pos.south())) + || ModdedBlockResolution.isAir(level.getBlockState(pos.north())); + + if (exposed) { + grid[lx][lz] = Math.max(grid[lx][lz], y); + } + }); + + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + if (grid[x][z] == Integer.MIN_VALUE) { + continue; + } + update(engine, level, x, grid[x][z], z, baseX, baseZ, chunk, rng); + } + } + + chunk.iterate(MatterUpdate.class, (Integer x, Integer yf, Integer z, MatterUpdate v) -> { + if (v != null && v.isUpdate()) { + update(engine, level, x, yf + minHeight, z, baseX, baseZ, chunk, rng); + } + }); + chunk.deleteSlices(MatterUpdate.class); + engine.getMetrics().getUpdates().put(stopwatch.getMilliseconds()); + } + + private void update(Engine engine, ServerLevel level, int x, int y, int z, int baseX, int baseZ, MantleChunk chunk, RNG rng) { + if (y < level.getMinY() || y > level.getMaxY()) { + return; + } + BlockPos pos = new BlockPos(baseX + (x & 15), y, baseZ + (z & 15)); + BlockState state = level.getBlockState(pos); + engine.blockUpdatedMetric(); + if (ModdedBlockResolution.isStorage(state)) { + if (!ModdedBlockResolution.isStorageChest(state)) { + return; + } + RNG rx = rng.nextParallelRNG(BlockPosition.toLong(x, y, z)); + try { + ModdedLootApplier.apply(engine, level, pos, state, chunk, rx, x & 15, z & 15); + } catch (Throwable e) { + IrisLogging.reportError(e); + } + } else { + level.setBlock(pos, Blocks.AIR.defaultBlockState(), SILENT_FLAGS); + level.setBlock(pos, state, Block.UPDATE_ALL); + } + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedEngineMaintenanceService.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedEngineMaintenanceService.java new file mode 100644 index 000000000..493bc4cbf --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedEngineMaintenanceService.java @@ -0,0 +1,201 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 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.modded.service; + +import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.core.gui.PregeneratorJob; +import art.arcane.iris.core.pregenerator.MantleHeapPressure; +import art.arcane.iris.core.runtime.GoldenHashEngine; +import art.arcane.iris.core.tools.IrisToolbelt; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.modded.ModdedWorldEngines; +import art.arcane.iris.spi.IrisLogging; +import net.minecraft.server.MinecraftServer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Collection; +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +public final class ModdedEngineMaintenanceService implements ModdedTickableService { + private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + private static final long TRIM_PERIOD_MILLIS = 2_000L; + private static final long SAVE_PERIOD_MILLIS = 60_000L; + + private final AtomicInteger tectonicLimit = new AtomicInteger(30); + private final Set inFlight = ConcurrentHashMap.newKeySet(); + private volatile ExecutorService service; + private long lastMaintenanceAt; + private long lastSaveAt; + + @Override + public void onEnable() { + if (service != null) { + return; + } + IrisSettings.IrisSettingsPerformance settings = IrisSettings.get().getPerformance(); + IrisSettings.IrisSettingsEngineSVC engineSettings = settings.getEngineSVC(); + ThreadFactory factory = (engineSettings.isUseVirtualThreads() + ? Thread.ofVirtual() + : Thread.ofPlatform().priority(engineSettings.getPriority())) + .name("Iris EngineSVC-", 0) + .factory(); + service = Executors.newThreadPerTaskExecutor(factory); + tectonicLimit.set(settings.getTectonicPlateSize()); + lastMaintenanceAt = 0L; + lastSaveAt = System.currentTimeMillis(); + } + + @Override + public void onDisable() { + ExecutorService active = service; + service = null; + if (active != null) { + active.shutdown(); + } + inFlight.clear(); + } + + @Override + public void onServerTick(MinecraftServer server) { + ExecutorService active = service; + if (active == null) { + return; + } + long now = System.currentTimeMillis(); + if (now - lastMaintenanceAt < TRIM_PERIOD_MILLIS) { + return; + } + lastMaintenanceAt = now; + boolean flush = now - lastSaveAt >= SAVE_PERIOD_MILLIS; + if (flush) { + lastSaveAt = now; + } + Collection engines = ModdedWorldEngines.activeEngines(); + int share = tectonicLimit.get() / Math.max(engines.size(), 1); + for (Engine engine : engines) { + if (!inFlight.add(engine)) { + continue; + } + try { + active.execute(() -> { + try { + maintain(engine, share, flush); + } finally { + inFlight.remove(engine); + } + }); + } catch (RejectedExecutionException rejected) { + inFlight.remove(engine); + } + } + } + + private void maintain(Engine engine, int share, boolean flush) { + if (engine == null || engine.isClosed() || engine.getMantle().getMantle().isClosed()) { + return; + } + if (flush) { + try { + engine.save(); + } catch (Throwable e) { + if (isMantleClosed(e)) { + return; + } + IrisLogging.reportError(e); + LOGGER.error("Iris engine save failed for {}", engine.getWorld().name(), e); + } + } + if (!shouldReduce(engine) || shouldSkipForMaintenance(engine) || GoldenHashEngine.isActive()) { + return; + } + try { + if (pregenTargets(engine) && MantleHeapPressure.overHighWater()) { + engine.getMantle().trim(0L, 0); + } else { + engine.getMantle().trim(TimeUnit.SECONDS.toMillis(IrisSettings.get().getPerformance().getMantleKeepAlive()), activeTectonicLimit(engine, share)); + } + long unloadStart = System.currentTimeMillis(); + boolean heapPressure = pregenTargets(engine) && MantleHeapPressure.overHighWater(); + int unloadLimit = (heapPressure || IrisSettings.get().getPerformance().getEngineSVC().forceMulticoreWrite) ? 0 : activeTectonicLimit(engine, share); + int count = engine.getMantle().unloadTectonicPlate(unloadLimit); + if (heapPressure && MantleHeapPressure.overPanicWater()) { + MantleHeapPressure.requestPanicReclaim(); + } + if (count > 0) { + LOGGER.debug("Iris unloaded {} tectonic plates in {}ms for {}", count, System.currentTimeMillis() - unloadStart, engine.getWorld().name()); + } + } catch (Throwable e) { + if (isMantleClosed(e)) { + return; + } + IrisLogging.reportError(e); + LOGGER.error("Iris engine maintenance failed for {}", engine.getWorld().name(), e); + } + } + + private boolean shouldReduce(Engine engine) { + if (!engine.isStudio() || IrisSettings.get().getPerformance().isTrimMantleInStudio()) { + return true; + } + return pregenTargets(engine); + } + + private boolean shouldSkipForMaintenance(Engine engine) { + if (engine.getWorld() == null || !IrisToolbelt.isWorldMaintenanceActive(engine.getWorld().name())) { + return false; + } + return !pregenTargets(engine); + } + + private boolean pregenTargets(Engine engine) { + if (engine.getWorld() == null) { + return false; + } + PregeneratorJob job = PregeneratorJob.getInstance(); + return job != null && job.targetsWorldName(engine.getWorld().name()); + } + + private int activeTectonicLimit(Engine engine, int share) { + if (!pregenTargets(engine)) { + return share; + } + return Math.max(share, IrisSettings.get().getPregen().getEffectiveResidentTectonicPlates(engine.getHeight())); + } + + private static boolean isMantleClosed(Throwable throwable) { + Throwable current = throwable; + while (current != null) { + String message = current.getMessage(); + if (message != null && message.toLowerCase(Locale.ROOT).contains("mantle is closed")) { + return true; + } + current = current.getCause(); + } + return false; + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedLogFilterService.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedLogFilterService.java index b296a9a22..9158218f0 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedLogFilterService.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedLogFilterService.java @@ -29,18 +29,30 @@ import org.apache.logging.log4j.message.Message; import java.util.List; public final class ModdedLogFilterService implements ModdedService, Filter { + private static final String VANILLA_LOGGER_PREFIX = "net.minecraft"; private static final List FILTERS = List.of( "Ignoring heightmap data for chunk", "Could not save data net.minecraft.world.entity.raid.PersistentRaid", "UUID of added entity already exists"); + private boolean installed = false; + @Override public void onEnable() { + if (installed) { + return; + } ((Logger) LogManager.getRootLogger()).addFilter(this); + installed = true; } @Override public void onDisable() { + if (!installed) { + return; + } + ((Logger) LogManager.getRootLogger()).get().removeFilter(this); + installed = false; } @Override @@ -82,76 +94,76 @@ public final class ModdedLogFilterService implements ModdedService, Filter { @Override public Result filter(LogEvent event) { - return check(event.getMessage().getFormattedMessage()); + return check(event.getLoggerName(), event.getMessage().getFormattedMessage()); } @Override public Result filter(Logger logger, Level level, Marker marker, Object msg, Throwable t) { - return check(String.valueOf(msg)); + return check(logger.getName(), String.valueOf(msg)); } @Override public Result filter(Logger logger, Level level, Marker marker, Message msg, Throwable t) { - return check(msg.getFormattedMessage()); + return check(logger.getName(), msg.getFormattedMessage()); } @Override public Result filter(Logger logger, Level level, Marker marker, String message, Object... params) { - return check(message); + return check(logger.getName(), message); } @Override public Result filter(Logger logger, Level level, Marker marker, String message, Object p0) { - return check(message); + return check(logger.getName(), message); } @Override public Result filter(Logger logger, Level level, Marker marker, String message, Object p0, Object p1) { - return check(message); + return check(logger.getName(), message); } @Override public Result filter(Logger logger, Level level, Marker marker, String message, Object p0, Object p1, Object p2) { - return check(message); + return check(logger.getName(), message); } @Override public Result filter(Logger logger, Level level, Marker marker, String message, Object p0, Object p1, Object p2, Object p3) { - return check(message); + return check(logger.getName(), message); } @Override public Result filter(Logger logger, Level level, Marker marker, String message, Object p0, Object p1, Object p2, Object p3, Object p4) { - return check(message); + return check(logger.getName(), message); } @Override public Result filter(Logger logger, Level level, Marker marker, String message, Object p0, Object p1, Object p2, Object p3, Object p4, Object p5) { - return check(message); + return check(logger.getName(), message); } @Override public Result filter(Logger logger, Level level, Marker marker, String message, Object p0, Object p1, Object p2, Object p3, Object p4, Object p5, Object p6) { - return check(message); + return check(logger.getName(), message); } @Override public Result filter(Logger logger, Level level, Marker marker, String message, Object p0, Object p1, Object p2, Object p3, Object p4, Object p5, Object p6, Object p7) { - return check(message); + return check(logger.getName(), message); } @Override public Result filter(Logger logger, Level level, Marker marker, String message, Object p0, Object p1, Object p2, Object p3, Object p4, Object p5, Object p6, Object p7, Object p8) { - return check(message); + return check(logger.getName(), message); } @Override public Result filter(Logger logger, Level level, Marker marker, String message, Object p0, Object p1, Object p2, Object p3, Object p4, Object p5, Object p6, Object p7, Object p8, Object p9) { - return check(message); + return check(logger.getName(), message); } - private Result check(String message) { - if (message == null) { + private Result check(String loggerName, String message) { + if (loggerName == null || !loggerName.startsWith(VANILLA_LOGGER_PREFIX) || message == null) { return Result.NEUTRAL; } for (String filter : FILTERS) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedSettingsHotloadService.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedSettingsHotloadService.java new file mode 100644 index 000000000..3a9416a1f --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedSettingsHotloadService.java @@ -0,0 +1,68 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 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.modded.service; + +import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.spi.IrisPlatforms; +import net.minecraft.server.MinecraftServer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; + +public final class ModdedSettingsHotloadService implements ModdedTickableService { + private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + private static final long POLL_PERIOD_MILLIS = 3_000L; + + private long lastPollAt; + private long lastModified; + + @Override + public void onEnable() { + lastPollAt = 0L; + lastModified = settingsFile().lastModified(); + } + + @Override + public void onDisable() { + } + + @Override + public void onServerTick(MinecraftServer server) { + long now = System.currentTimeMillis(); + if (now - lastPollAt < POLL_PERIOD_MILLIS) { + return; + } + lastPollAt = now; + long modified = settingsFile().lastModified(); + if (modified == lastModified) { + return; + } + if (IrisSettings.settings != null) { + IrisSettings.invalidate(); + } + IrisSettings.get(); + lastModified = settingsFile().lastModified(); + LOGGER.info("Hotloaded settings.json"); + } + + private static File settingsFile() { + return IrisPlatforms.get().dataFile("settings.json"); + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedStudioHotloadService.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedStudioHotloadService.java new file mode 100644 index 000000000..2628fd5d3 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedStudioHotloadService.java @@ -0,0 +1,184 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 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.modded.service; + +import art.arcane.iris.core.gui.PregeneratorJob; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.modded.IrisModdedChunkGenerator; +import art.arcane.iris.modded.ModdedDimensionManager; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.io.ReactiveFolder; +import art.arcane.volmlib.util.scheduling.ChronoLatch; +import net.minecraft.server.MinecraftServer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; + +public final class ModdedStudioHotloadService implements ModdedTickableService { + private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + private static final String STUDIO_DIMENSION_PREFIX = "irisworldgen:studio_"; + private static final long POLL_MILLIS = 250L; + private static final long CHECK_LATCH_MILLIS = 1_000L; + private static final long RECENT_GENERATION_HOLDOFF_MILLIS = 2_000L; + + private final ConcurrentHashMap watches = new ConcurrentHashMap<>(); + private volatile ExecutorService executor; + private long lastPollAt; + + @Override + public void onEnable() { + if (executor != null) { + return; + } + executor = Executors.newSingleThreadExecutor((Runnable task) -> { + Thread thread = new Thread(task, "Iris Studio Hotload"); + thread.setDaemon(true); + thread.setPriority(Thread.MIN_PRIORITY); + return thread; + }); + lastPollAt = 0L; + } + + @Override + public void onDisable() { + ExecutorService active = executor; + executor = null; + if (active != null) { + active.shutdownNow(); + } + watches.clear(); + } + + @Override + public void onServerTick(MinecraftServer server) { + ExecutorService active = executor; + if (active == null) { + return; + } + long now = System.currentTimeMillis(); + if (now - lastPollAt < POLL_MILLIS) { + return; + } + lastPollAt = now; + List handles = ModdedDimensionManager.handles(); + Set seen = null; + for (ModdedDimensionManager.Handle handle : handles) { + String dimensionId = handle.dimensionId(); + if (!dimensionId.startsWith(STUDIO_DIMENSION_PREFIX)) { + continue; + } + IrisModdedChunkGenerator generator = handle.generator(); + if (generator == null) { + continue; + } + Engine engine = generator.engineIfBound(); + if (engine == null || engine.isClosed()) { + continue; + } + if (seen == null) { + seen = new HashSet<>(); + } + seen.add(dimensionId); + Watch watch = watches.computeIfAbsent(dimensionId, (String key) -> new Watch()); + if (throttled(generator, engine, now)) { + continue; + } + if (!watch.latch.flip()) { + continue; + } + if (!watch.busy.compareAndSet(false, true)) { + continue; + } + try { + active.execute(() -> { + try { + poll(dimensionId, watch, generator, engine); + } finally { + watch.busy.set(false); + } + }); + } catch (RejectedExecutionException rejected) { + watch.busy.set(false); + } + } + if (!watches.isEmpty()) { + Set keep = seen == null ? Set.of() : seen; + watches.keySet().removeIf((String key) -> !keep.contains(key)); + } + } + + private boolean throttled(IrisModdedChunkGenerator generator, Engine engine, long now) { + if (now - generator.lastChunkGenAt() < RECENT_GENERATION_HOLDOFF_MILLIS) { + return true; + } + PregeneratorJob job = PregeneratorJob.getInstance(); + return job != null && job.targetsWorldName(engine.getWorld().name()); + } + + private void poll(String dimensionId, Watch watch, IrisModdedChunkGenerator generator, Engine engine) { + try { + if (watch.engine != engine) { + watch.engine = engine; + watch.folder = new ReactiveFolder( + engine.getData().getDataFolder(), + (KList created, KList changed, KList deleted) -> hotload(dimensionId, generator, engine), + new KList<>(".iob", ".json"), + new KList<>(".iris"), + new KList<>()); + return; + } + ReactiveFolder folder = watch.folder; + if (folder != null) { + folder.check(); + } + } catch (Throwable e) { + LOGGER.error("Iris studio hotload check failed for {}", dimensionId, e); + } + } + + private void hotload(String dimensionId, IrisModdedChunkGenerator generator, Engine engine) { + if (engine.isClosed()) { + return; + } + long start = System.currentTimeMillis(); + try { + engine.hotloadSilently(); + generator.onHotload(); + LOGGER.info("Iris studio hotload {} pack={} {}ms", dimensionId, engine.getDimension().getLoadKey(), System.currentTimeMillis() - start); + } catch (Throwable e) { + LOGGER.error("Iris studio hotload failed for {}", dimensionId, e); + } + } + + private static final class Watch { + private final ChronoLatch latch = new ChronoLatch(CHECK_LATCH_MILLIS, false); + private final AtomicBoolean busy = new AtomicBoolean(false); + private volatile Engine engine; + private volatile ReactiveFolder folder; + } +} diff --git a/adapters/neoforge/src/main/java/art/arcane/iris/neoforge/IrisNeoForgeBootstrap.java b/adapters/neoforge/src/main/java/art/arcane/iris/neoforge/IrisNeoForgeBootstrap.java index f26ec0c04..f2a68cebe 100644 --- a/adapters/neoforge/src/main/java/art/arcane/iris/neoforge/IrisNeoForgeBootstrap.java +++ b/adapters/neoforge/src/main/java/art/arcane/iris/neoforge/IrisNeoForgeBootstrap.java @@ -21,12 +21,7 @@ package art.arcane.iris.neoforge; import art.arcane.iris.modded.IrisModdedChunkGenerator; import art.arcane.iris.modded.ModdedEngineBootstrap; import art.arcane.iris.modded.ModdedForcedDatapack; -import art.arcane.iris.modded.ModdedIrisLog; -import art.arcane.iris.modded.ModdedParityProbe; -import art.arcane.iris.modded.ModdedWorldCheck; -import art.arcane.iris.modded.ModdedWorldEngines; import art.arcane.iris.modded.command.IrisModdedCommands; -import art.arcane.iris.modded.command.ModdedObjectUndo; import art.arcane.iris.modded.command.ModdedWandService; import com.mojang.serialization.MapCodec; import net.minecraft.core.registries.Registries; @@ -34,15 +29,13 @@ import net.minecraft.server.packs.PackType; import net.minecraft.world.InteractionResult; import net.minecraft.world.level.chunk.ChunkGenerator; import net.neoforged.bus.api.IEventBus; -import net.neoforged.fml.ModContainer; -import net.neoforged.fml.ModList; import net.neoforged.fml.common.Mod; import net.neoforged.fml.loading.FMLLoader; -import net.neoforged.fml.loading.VersionInfo; import net.neoforged.neoforge.common.NeoForge; import net.neoforged.neoforge.event.AddPackFindersEvent; import net.neoforged.neoforge.event.RegisterCommandsEvent; import net.neoforged.neoforge.event.entity.player.PlayerInteractEvent; +import net.neoforged.neoforge.event.server.ServerStartingEvent; import net.neoforged.neoforge.event.server.ServerStoppingEvent; import net.neoforged.neoforge.event.tick.ServerTickEvent; import net.neoforged.neoforge.registries.DeferredRegister; @@ -50,20 +43,11 @@ import net.neoforged.neoforge.registries.DeferredRegister; @Mod("irisworldgen") public final class IrisNeoForgeBootstrap { public IrisNeoForgeBootstrap(IEventBus modBus) { - ModdedEngineBootstrap.initialize(new NeoForgeModdedLoader()); - String modVersion = ModList.get().getModContainerById("irisworldgen") - .map((ModContainer container) -> container.getModInfo().getVersion().toString()) - .orElse("unknown"); - VersionInfo versionInfo = FMLLoader.getCurrent().getVersionInfo(); - ModdedIrisLog.info("Iris " + modVersion + " bootstrapping on Minecraft " + versionInfo.mcVersion() + " (NeoForge " + versionInfo.neoForgeVersion() + ")"); - - ModdedEngineBootstrap.selfTest(IrisNeoForgeBootstrap.class.getClassLoader()); - ModdedEngineBootstrap.bind(); - - DeferredRegister> chunkGenerators = DeferredRegister.create(Registries.CHUNK_GENERATOR, "irisworldgen"); - chunkGenerators.register("iris", () -> IrisModdedChunkGenerator.CODEC); - chunkGenerators.register(modBus); - ModdedIrisLog.info("Iris chunk generator registered as irisworldgen:iris"); + ModdedEngineBootstrap.bootCommon(new NeoForgeModdedLoader(), "NeoForge " + FMLLoader.getCurrent().getVersionInfo().neoForgeVersion(), () -> { + DeferredRegister> chunkGenerators = DeferredRegister.create(Registries.CHUNK_GENERATOR, "irisworldgen"); + chunkGenerators.register("iris", () -> IrisModdedChunkGenerator.CODEC); + chunkGenerators.register(modBus); + }); modBus.addListener((AddPackFindersEvent event) -> { if (event.getPackType() == PackType.SERVER_DATA) { @@ -71,12 +55,8 @@ public final class IrisNeoForgeBootstrap { } }); - NeoForge.EVENT_BUS.addListener((ServerStoppingEvent event) -> { - ModdedObjectUndo.clearAll(); - ModdedWandService.clearAll(); - ModdedWorldEngines.shutdown(); - ModdedEngineBootstrap.stop(); - }); + NeoForge.EVENT_BUS.addListener((ServerStartingEvent event) -> ModdedEngineBootstrap.start(event.getServer())); + NeoForge.EVENT_BUS.addListener((ServerStoppingEvent event) -> ModdedEngineBootstrap.stop()); NeoForge.EVENT_BUS.addListener((RegisterCommandsEvent event) -> IrisModdedCommands.register(event.getDispatcher())); NeoForge.EVENT_BUS.addListener((PlayerInteractEvent.LeftClickBlock event) -> { if (ModdedWandService.attackBlock(event.getEntity(), event.getLevel(), event.getHand(), event.getPos())) { @@ -93,17 +73,5 @@ public final class IrisNeoForgeBootstrap { ModdedEngineBootstrap.tick(event.getServer()); ModdedWandService.serverTick(event.getServer()); }); - - String parity = System.getProperty("iris.parity"); - if (parity != null) { - ModdedIrisLog.info("Iris parity probe armed: " + parity); - ModdedParityProbe.schedule(parity); - } - - String worldCheck = System.getProperty("iris.worldcheck"); - if (worldCheck != null) { - ModdedIrisLog.info("Iris world check armed"); - ModdedWorldCheck.schedule(); - } } } diff --git a/adapters/neoforge/src/main/java/art/arcane/iris/neoforge/NeoForgeModdedLoader.java b/adapters/neoforge/src/main/java/art/arcane/iris/neoforge/NeoForgeModdedLoader.java index 9030af82a..f4bc7f115 100644 --- a/adapters/neoforge/src/main/java/art/arcane/iris/neoforge/NeoForgeModdedLoader.java +++ b/adapters/neoforge/src/main/java/art/arcane/iris/neoforge/NeoForgeModdedLoader.java @@ -21,6 +21,7 @@ package art.arcane.iris.neoforge; import art.arcane.iris.modded.ModdedLoader; import net.minecraft.server.MinecraftServer; import net.neoforged.fml.ModList; +import net.neoforged.fml.loading.FMLEnvironment; import net.neoforged.fml.loading.FMLLoader; import net.neoforged.fml.loading.FMLPaths; import net.neoforged.neoforge.server.ServerLifecycleHooks; @@ -52,6 +53,11 @@ public final class NeoForgeModdedLoader implements ModdedLoader { return ServerLifecycleHooks.getCurrentServer(); } + @Override + public boolean clientEnvironment() { + return FMLEnvironment.getDist().isClient(); + } + @Override public Path configDir() { return FMLPaths.CONFIGDIR.get(); 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 0cfa283d1..b05b37496 100644 --- a/core/src/main/java/art/arcane/iris/core/IrisSettings.java +++ b/core/src/main/java/art/arcane/iris/core/IrisSettings.java @@ -141,6 +141,10 @@ public class IrisSettings { @Data public static class IrisSettingsPregen { + private static final int REFERENCE_WORLD_HEIGHT = 384; + private static final int MIN_RESIDENT_TECTONIC_PLATES = 16; + private static final double MANTLE_HEAP_FRACTION = 0.6D; + private static final int REFERENCE_PLATE_MEGABYTES = 48; public boolean useTicketQueue = true; public IrisRuntimeSchedulerMode runtimeSchedulerMode = IrisRuntimeSchedulerMode.AUTO; public IrisPaperLikeBackendMode paperLikeBackendMode = IrisPaperLikeBackendMode.AUTO; @@ -159,6 +163,17 @@ public class IrisSettings { return Math.max(16, maxResidentTectonicPlates); } + public int getEffectiveResidentTectonicPlates(int worldHeight) { + int baseCap = getMaxResidentTectonicPlates(); + int normalizedHeight = Math.max(1, worldHeight); + int heightScaledCap = (int) Math.round((double) baseCap * REFERENCE_WORLD_HEIGHT / (double) normalizedHeight); + long maxHeapMegabytes = getHardware.getProcessMemory(); + double plateMegabytes = (double) REFERENCE_PLATE_MEGABYTES * (double) normalizedHeight / (double) REFERENCE_WORLD_HEIGHT; + int byteBudgetCap = (int) Math.floor(MANTLE_HEAP_FRACTION * (double) maxHeapMegabytes / plateMegabytes); + int effective = Math.min(heightScaledCap, byteBudgetCap); + return Math.max(MIN_RESIDENT_TECTONIC_PLATES, Math.min(baseCap, effective)); + } + public int getMantleBackpressureWaitMs() { return Math.max(5, Math.min(mantleBackpressureWaitMs, 1_000)); } diff --git a/core/src/main/java/art/arcane/iris/core/gui/GuiHost.java b/core/src/main/java/art/arcane/iris/core/gui/GuiHost.java index 2bc08a922..74db79c48 100644 --- a/core/src/main/java/art/arcane/iris/core/gui/GuiHost.java +++ b/core/src/main/java/art/arcane/iris/core/gui/GuiHost.java @@ -25,10 +25,19 @@ import java.awt.GraphicsEnvironment; public final class GuiHost { private static volatile Provider provider = new Provider() { }; + private static volatile boolean desktopSuppressed = false; private GuiHost() { } + public static void suppressDesktop(boolean suppress) { + desktopSuppressed = suppress; + } + + public static boolean isDesktopSuppressed() { + return desktopSuppressed; + } + public interface Provider { default Engine findActiveEngine() { return null; @@ -56,6 +65,6 @@ public final class GuiHost { } public static boolean isAvailable() { - return !GraphicsEnvironment.isHeadless(); + return !desktopSuppressed && !GraphicsEnvironment.isHeadless(); } } diff --git a/core/src/main/java/art/arcane/iris/core/gui/PregenRenderer.java b/core/src/main/java/art/arcane/iris/core/gui/PregenRenderer.java index 6d3dc3317..f45eb30fa 100644 --- a/core/src/main/java/art/arcane/iris/core/gui/PregenRenderer.java +++ b/core/src/main/java/art/arcane/iris/core/gui/PregenRenderer.java @@ -85,8 +85,11 @@ public final class PregenRenderer extends JPanel implements KeyListener { } public void close() { - if (frame != null) { - frame.setVisible(false); + JFrame activeFrame = frame; + if (activeFrame != null) { + frame = null; + activeFrame.setVisible(false); + activeFrame.dispose(); } } diff --git a/core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java b/core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java index 12e9a69d0..edb094fdb 100644 --- a/core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java +++ b/core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java @@ -33,7 +33,6 @@ import art.arcane.volmlib.util.mantle.runtime.Mantle; import art.arcane.volmlib.util.math.Position2; import art.arcane.volmlib.util.scheduling.ChronoLatch; import art.arcane.iris.util.common.scheduling.J; -import org.bukkit.World; import java.awt.Color; import java.util.concurrent.ExecutorService; @@ -63,12 +62,18 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource { private final ChronoLatch cl = new ChronoLatch(TimeUnit.MINUTES.toMillis(1)); private final Engine engine; private final ExecutorService service; + private final Thread worker; private PregenRenderer renderer; private Consumer2 drawFunction; private int rgc = 0; private String[] info; private volatile double lastChunksPerSecond = 0D; private volatile long lastChunksRemaining = 0L; + private volatile long lastGenerated = 0L; + private volatile long lastTotalChunks = 0L; + private volatile long lastEta = 0L; + private volatile long lastElapsed = 0L; + private volatile String lastMethod = "Void"; public PregeneratorJob(PregenTask task, PregeneratorMethod method, Engine engine) { instance.updateAndGet(old -> { @@ -84,26 +89,32 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource { info = new String[]{"Initializing..."}; this.task = task; this.pregenerator = new IrisPregenerator(task, method, this); - max = new Position2(0, 0); + max = new Position2(Integer.MIN_VALUE, Integer.MIN_VALUE); min = new Position2(Integer.MAX_VALUE, Integer.MAX_VALUE); + service = Executors.newVirtualThreadPerTaskExecutor(); + + if (IrisSettings.get().getGui().isUseServerLaunchedGuis() && task.isGui()) { + open(); + } + + worker = new Thread(() -> { + J.sleep(1000); + computeBounds(); + this.pregenerator.start(); + }, "Iris Pregenerator"); + worker.setPriority(Thread.MIN_PRIORITY); + worker.setDaemon(true); + worker.setUncaughtExceptionHandler((thread, ex) -> IrisLogging.reportError(ex)); + worker.start(); + } + + private void computeBounds() { task.iterateAllChunks((xx, zz) -> { min.setX(Math.min(xx, min.getX())); min.setZ(Math.min(zz, min.getZ())); max.setX(Math.max(xx, max.getX())); max.setZ(Math.max(zz, max.getZ())); }); - - if (IrisSettings.get().getGui().isUseServerLaunchedGuis() && task.isGui()) { - open(); - } - - Thread t = new Thread(() -> { - J.sleep(1000); - this.pregenerator.start(); - }, "Iris Pregenerator"); - t.setPriority(Thread.MIN_PRIORITY); - t.start(); - service = Executors.newVirtualThreadPerTaskExecutor(); } public static boolean shutdownInstance() { @@ -116,6 +127,23 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource { return true; } + public static boolean shutdownAndWait(long timeoutMs) { + PregeneratorJob inst = instance.get(); + if (inst == null) { + return false; + } + + inst.pregenerator.close(); + inst.worker.interrupt(); + try { + inst.worker.join(timeoutMs); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + instance.compareAndSet(inst, null); + return true; + } + public static PregeneratorJob getInstance() { return instance.get(); } @@ -153,13 +181,36 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource { return inst == null ? -1L : Math.max(0L, inst.lastChunksRemaining); } - public boolean targetsWorld(World world) { - if (world == null || engine == null || engine.getWorld() == null) { - return false; + public record PregenProgress(double percent, long generated, long totalChunks, double chunksPerSecond, long chunksRemaining, long eta, long elapsed, String method, boolean paused, long failed, String worldName) { + } + + public static PregenProgress progressSnapshot() { + PregeneratorJob inst = instance.get(); + if (inst == null) { + return null; } - String targetName = engine.getWorld().name(); - return targetName != null && targetName.equalsIgnoreCase(world.getName()); + double percent = inst.lastTotalChunks <= 0 ? 0D : ((double) inst.lastGenerated / (double) inst.lastTotalChunks) * 100D; + return new PregenProgress( + percent, + inst.lastGenerated, + inst.lastTotalChunks, + Math.max(0D, inst.lastChunksPerSecond), + Math.max(0L, inst.lastChunksRemaining), + inst.lastEta, + inst.lastElapsed, + inst.lastMethod, + inst.paused(), + inst.pregenerator.getFailedChunks(), + inst.worldName()); + } + + public String worldName() { + if (engine == null || engine.getWorld() == null) { + return null; + } + + return engine.getWorld().name(); } public boolean targetsWorldName(String worldName) { @@ -252,6 +303,11 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource { public void onTick(double chunksPerSecond, double chunksPerMinute, double regionsPerMinute, double percent, long generated, long totalChunks, long chunksRemaining, long eta, long elapsed, String method, boolean cached) { lastChunksPerSecond = chunksPerSecond; lastChunksRemaining = chunksRemaining; + lastGenerated = generated; + lastTotalChunks = totalChunks; + lastEta = eta; + lastElapsed = elapsed; + lastMethod = method; info = new String[]{ (paused() ? "PAUSED" : (saving ? "Saving... " : "Generating")) + " " + Form.f(generated) + " of " + Form.f(totalChunks) + " (" + Form.pc(percent, 0) + " Complete)", diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitPublicBackend.java b/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitPublicBackend.java index 1be1572f3..b330ccba9 100644 --- a/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitPublicBackend.java +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitPublicBackend.java @@ -1,5 +1,6 @@ package art.arcane.iris.core.lifecycle; +import art.arcane.iris.core.nms.INMS; import org.bukkit.Bukkit; import org.bukkit.World; import org.bukkit.WorldCreator; @@ -31,6 +32,8 @@ final class BukkitPublicBackend implements WorldLifecycleBackend { WorldLifecycleStaging.stageStemGenerator(request.worldName(), request.generator()); } + INMS.get().ensureServerLevelInjection(); + try { World world = creator.createWorld(); return CompletableFuture.completedFuture(world); 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 85e2651bb..f47dd5294 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 @@ -164,6 +164,9 @@ public interface INMSBinding { throw new UnsupportedOperationException("Active NMS binding does not support runtime LevelStem creation."); } + default void ensureServerLevelInjection() { + } + int countCustomBiomes(); default boolean supportsDataPacks() { @@ -188,6 +191,13 @@ public interface INMSBinding { return false; } + default boolean saveAndUnloadChunk(World world, int x, int z) { + return false; + } + + default void flushChunkIO(World world) { + } + void injectBiomesFromMantle(Chunk e, Mantle mantle); ItemStack applyCustomNbt(ItemStack itemStack, KMap customNbt) throws IllegalArgumentException; diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackDownloader.java b/core/src/main/java/art/arcane/iris/core/pack/PackDownloader.java new file mode 100644 index 000000000..99d840fd0 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/pack/PackDownloader.java @@ -0,0 +1,160 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 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.core.pack; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.util.common.misc.WebCache; +import art.arcane.volmlib.util.io.IO; +import org.zeroturnaround.zip.ZipUtil; +import org.zeroturnaround.zip.commons.FileUtils; + +import java.io.File; +import java.io.IOException; +import java.util.UUID; +import java.util.function.Consumer; + +public final class PackDownloader { + private PackDownloader() { + } + + public static String download(File packsFolder, String repo, String branch, boolean forceOverwrite, boolean directUrl, Consumer feedback) throws IOException { + String url = directUrl ? branch : "https://codeload.github.com/" + repo + "/zip/refs/heads/" + branch; + feedback.accept("Downloading " + url + " "); //The extra space stops a bug in adventure API from repeating the last letter of the URL + File zip = WebCache.getNonCachedFile("pack-" + repo, url); + File temp = WebCache.getTemp(); + File work = new File(temp, "dl-" + UUID.randomUUID()); + + if (zip == null || !zip.exists()) { + feedback.accept("Failed to find pack at " + url); + feedback.accept("Make sure you specified the correct repo and branch!"); + feedback.accept("For example: /iris download overworld branch=stable"); + return null; + } + feedback.accept("Unpacking " + repo); + try { + ZipUtil.unpack(zip, work); + } catch (Throwable e) { + IrisLogging.reportError(e); + e.printStackTrace(); + feedback.accept( + """ + Issue when unpacking. Please check/do the following: + 1. Do you have a functioning internet connection? + 2. Did the download corrupt? + 3. Try deleting the */plugins/iris/packs folder and re-download. + 4. Download the pack from the GitHub repo: https://github.com/IrisDimensions/overworld + 5. Contact support (if all other options do not help)""" + ); + } + File dir = null; + File[] zipFiles = work.listFiles(); + + if (zipFiles == null) { + feedback.accept("No files were extracted from the zip file."); + return null; + } + + try { + dir = zipFiles.length > 1 ? work : zipFiles[0].isDirectory() ? zipFiles[0] : null; + } catch (NullPointerException e) { + IrisLogging.reportError(e); + feedback.accept("Error when finding home directory. Are there any non-text characters in the file name?"); + return null; + } + + if (dir == null) { + feedback.accept("Invalid Format. Missing root folder or too many folders!"); + return null; + } + + IrisData data = IrisData.get(dir); + String[] dimensions = data.getDimensionLoader().getPossibleKeys(); + + if (dimensions == null || dimensions.length == 0) { + feedback.accept("No dimension file found in the extracted zip file."); + feedback.accept("Check it is there on GitHub and report this to staff!"); + return null; + } + + if (dimensions.length != 1) { + feedback.accept("Dimensions folder must have 1 file in it"); + return null; + } + + IrisDimension d = data.getDimensionLoader().load(dimensions[0]); + data.close(); + + if (d == null) { + feedback.accept("Invalid dimension (folder) in dimensions folder"); + return null; + } + + String key = d.getLoadKey(); + feedback.accept("Importing " + d.getName() + " (" + key + ")"); + File packEntry = new File(packsFolder, key); + + if (forceOverwrite) { + IO.delete(packEntry); + } + + if (IrisData.loadAnyDimension(key, null) != null) { + feedback.accept("Another dimension in the packs folder is already using the key " + key + " IMPORT FAILED!"); + return null; + } + + File[] existingEntries = packEntry.listFiles(); + if (packEntry.exists() && existingEntries != null && existingEntries.length > 0) { + feedback.accept("Another pack is using the key " + key + ". IMPORT FAILED!"); + return null; + } + + FileUtils.copyDirectory(dir, packEntry); + + IrisData.getLoaded(packEntry) + .ifPresent(IrisData::hotloaded); + + feedback.accept("Successfully Aquired " + d.getName()); + validateDownloaded(packEntry, feedback); + return key; + } + + private static void validateDownloaded(File packEntry, Consumer feedback) { + try { + PackValidationResult result = PackValidator.validate(packEntry); + PackValidationRegistry.publish(result); + + if (!result.isLoadable()) { + feedback.accept("Pack '" + result.getPackName() + "' FAILED validation - world/studio creation will be refused. Reasons:"); + for (String reason : result.getBlockingErrors()) { + feedback.accept(" - " + reason); + } + } else if (!result.getWarnings().isEmpty() || !result.getRemovedUnusedFiles().isEmpty()) { + feedback.accept("Pack '" + result.getPackName() + "' validated (" + + result.getRemovedUnusedFiles().size() + " unused file(s) quarantined to .iris-trash/, " + + result.getWarnings().size() + " warning(s))."); + } else { + feedback.accept("Pack '" + result.getPackName() + "' validated."); + } + } catch (Throwable e) { + IrisLogging.reportError("Pack validation failed for '" + packEntry.getName() + "'", e); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/DeepSearchPregenerator.java b/core/src/main/java/art/arcane/iris/core/pregenerator/DeepSearchPregenerator.java deleted file mode 100644 index 6435ec0bc..000000000 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/DeepSearchPregenerator.java +++ /dev/null @@ -1,268 +0,0 @@ -package art.arcane.iris.core.pregenerator; - -import com.google.gson.Gson; -import art.arcane.iris.spi.IrisLogging; -import art.arcane.iris.core.tools.IrisToolbelt; -import art.arcane.iris.engine.framework.Engine; -import art.arcane.iris.engine.object.IrisBiome; -import art.arcane.volmlib.util.collection.KList; -import art.arcane.iris.util.common.format.C; -import art.arcane.volmlib.util.format.Form; -import art.arcane.volmlib.util.io.IO; -import art.arcane.volmlib.util.math.M; -import art.arcane.volmlib.util.math.Position2; -import art.arcane.volmlib.util.math.RollingSequence; -import art.arcane.volmlib.util.math.Spiraler; -import art.arcane.volmlib.util.scheduling.ChronoLatch; -import art.arcane.iris.util.common.scheduling.J; -import lombok.Data; -import lombok.Getter; -import org.bukkit.Bukkit; -import org.bukkit.World; -import org.bukkit.event.EventHandler; -import org.bukkit.event.Listener; -import org.bukkit.event.world.WorldUnloadEvent; - -import java.io.File; -import java.io.FileWriter; -import java.io.IOException; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; -import java.util.concurrent.locks.ReentrantLock; - -public class DeepSearchPregenerator extends Thread implements Listener { - @Getter - private static DeepSearchPregenerator instance; - private final DeepSearchJob job; - private final File destination; - private final int maxPosition; - private World world; - private final ChronoLatch latch; - private static AtomicInteger foundChunks; - private final AtomicInteger foundLast; - private final AtomicInteger foundTotalChunks; - private final AtomicLong startTime; - private final RollingSequence chunksPerSecond; - private final RollingSequence chunksPerMinute; - private final AtomicInteger chunkCachePos; - private final AtomicInteger chunkCacheSize; - private int pos; - private final AtomicInteger foundCacheLast; - private final AtomicInteger foundCache; - private LinkedHashMap chunkCache; - private KList chunkQueue; - private final ReentrantLock cacheLock; - - private static final Map jobs = new HashMap<>(); - - public DeepSearchPregenerator(DeepSearchJob job, File destination) { - this.job = job; - this.chunkCacheSize = new AtomicInteger(); // todo - this.chunkCachePos = new AtomicInteger(1000); - this.foundCacheLast = new AtomicInteger(); - this.foundCache = new AtomicInteger(); - this.cacheLock = new ReentrantLock(); - this.destination = destination; - this.chunkCache = new LinkedHashMap<>(); - this.maxPosition = new Spiraler(job.getRadiusBlocks() * 2, job.getRadiusBlocks() * 2, (x, z) -> { - }).count(); - this.world = Bukkit.getWorld(job.getWorld().getUID()); - this.chunkQueue = new KList<>(); - this.latch = new ChronoLatch(3000); - this.startTime = new AtomicLong(M.ms()); - this.chunksPerSecond = new RollingSequence(10); - this.chunksPerMinute = new RollingSequence(10); - foundChunks = new AtomicInteger(0); - this.foundLast = new AtomicInteger(0); - this.foundTotalChunks = new AtomicInteger((int) Math.ceil(Math.pow((2.0 * job.getRadiusBlocks()) / 16, 2))); - - this.pos = 0; - jobs.put(job.getWorld().getName(), job); - DeepSearchPregenerator.instance = this; - } - - @EventHandler - public void on(WorldUnloadEvent e) { - if (e.getWorld().equals(world)) { - interrupt(); - } - } - - public void run() { - while (!interrupted()) { - tick(); - } - try { - saveNow(); - } catch (IOException e) { - throw new RuntimeException(e); - } - } - - public void tick() { - DeepSearchJob job = jobs.get(world.getName()); - // chunkCache(); //todo finish this - if (latch.flip() && !job.paused) { - if (cacheLock.isLocked()) { - IrisLogging.info("DeepFinder: Caching: " + chunkCachePos.get() + " Of " + chunkCacheSize.get()); - } else { - long eta = computeETA(); - save(); - int secondGenerated = foundChunks.get() - foundLast.get(); - foundLast.set(foundChunks.get()); - secondGenerated = secondGenerated / 3; - chunksPerSecond.put(secondGenerated); - chunksPerMinute.put(secondGenerated * 60); - IrisLogging.info("DeepFinder: " + C.IRIS + world.getName() + C.RESET + " Searching: " + Form.f(foundChunks.get()) + " of " + Form.f(foundTotalChunks.get()) + " " + Form.f((int) chunksPerSecond.getAverage()) + "/s ETA: " + Form.duration((double) eta, 2)); - } - - } - if (foundChunks.get() >= foundTotalChunks.get()) { - IrisLogging.info("Completed DeepSearch!"); - interrupt(); - } - } - - private long computeETA() { - return (long) ((foundTotalChunks.get() - foundChunks.get()) / chunksPerSecond.getAverage()) * 1000; - // todo broken - } - - private final ExecutorService executorService = Executors.newSingleThreadExecutor(); - - private void queueSystem(Position2 chunk) { - if (chunkQueue.isEmpty()) { - for (int limit = 512; limit != 0; limit--) { - pos = job.getPosition() + 1; - chunkQueue.add(getChunk(pos)); - } - } else { - //MCAUtil.read(); - - } - - - } - - private void findInChunk(World world, int x, int z) throws IOException { - int xx = x * 16; - int zz = z * 16; - Engine engine = IrisToolbelt.access(world).getEngine(); - for (int i = 0; i < 16; i++) { - for (int j = 0; j < 16; j++) { - int height = engine.getHeight(xx + i, zz + j); - if (height > 300) { - File found = new File("plugins", "iris" + File.separator + "found.txt"); - found.getParentFile().mkdirs(); - IrisBiome biome = engine.getBiome(xx, engine.getHeight(), zz); - IrisLogging.info("Found at! " + xx + ", " + zz + " Biome ID: " + biome.getName()); - try (FileWriter writer = new FileWriter(found, true)) { - writer.write("Biome at: X: " + xx + " Z: " + zz + " Biome ID: " + biome.getName() + "\n"); - } - return; - } - } - } - } - - public Position2 getChunk(int position) { - int p = -1; - AtomicInteger xx = new AtomicInteger(); - AtomicInteger zz = new AtomicInteger(); - Spiraler s = new Spiraler(job.getRadiusBlocks() * 2, job.getRadiusBlocks() * 2, (x, z) -> { - xx.set(x); - zz.set(z); - }); - - while (s.hasNext() && p++ < position) { - s.next(); - } - - return new Position2(xx.get(), zz.get()); - } - - public void save() { - J.a(() -> { - try { - saveNow(); - } catch (Throwable e) { - e.printStackTrace(); - } - }); - } - - public static void setPausedDeep(World world) { - DeepSearchJob job = jobs.get(world.getName()); - if (isPausedDeep(world)){ - job.paused = false; - } else { - job.paused = true; - } - - if ( job.paused) { - IrisLogging.info(C.BLUE + "DeepSearch: " + C.IRIS + world.getName() + C.BLUE + " Paused"); - } else { - IrisLogging.info(C.BLUE + "DeepSearch: " + C.IRIS + world.getName() + C.BLUE + " Resumed"); - } - } - - public static boolean isPausedDeep(World world) { - DeepSearchJob job = jobs.get(world.getName()); - return job != null && job.isPaused(); - } - - public void shutdownInstance(World world) throws IOException { - IrisLogging.info("DeepSearch: " + C.IRIS + world.getName() + C.BLUE + " Shutting down.."); - DeepSearchJob job = jobs.get(world.getName()); - File worldDirectory = new File(Bukkit.getWorldContainer(), world.getName()); - File deepFile = new File(worldDirectory, "DeepSearch.json"); - - if (job == null) { - IrisLogging.error("No DeepSearch job found for world: " + world.getName()); - return; - } - - try { - if (!job.isPaused()) { - job.setPaused(true); - } - save(); - jobs.remove(world.getName()); - J.a(() -> { - while (deepFile.exists()) { - deepFile.delete(); - J.sleep(1000); - } - IrisLogging.info("DeepSearch: " + C.IRIS + world.getName() + C.BLUE + " File deleted and instance closed."); - }, 20); - } catch (Exception e) { - IrisLogging.error("Failed to shutdown DeepSearch for " + world.getName()); - e.printStackTrace(); - } finally { - saveNow(); - interrupt(); - } - } - - - public void saveNow() throws IOException { - IO.writeAll(this.destination, new Gson().toJson(job)); - } - - @Data - @lombok.Builder - public static class DeepSearchJob { - private World world; - @lombok.Builder.Default - private int radiusBlocks = 5000; - @lombok.Builder.Default - private int position = 0; - @lombok.Builder.Default - boolean paused = false; - } -} diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/IrisPregenerator.java b/core/src/main/java/art/arcane/iris/core/pregenerator/IrisPregenerator.java index 30a922875..56048e1d5 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/IrisPregenerator.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/IrisPregenerator.java @@ -67,6 +67,8 @@ public class IrisPregenerator { private final KSet net; private final ChronoLatch cl; private final ChronoLatch saveLatch; + private final ChronoLatch heapReclaimLatch; + private final AtomicLong failed; private final IrisPackBenchmarking benchmarking; public IrisPregenerator(PregenTask task, PregeneratorMethod generator, PregenListener listener) { @@ -74,6 +76,8 @@ public class IrisPregenerator { this.listener = listenify(listener); cl = new ChronoLatch(10000); saveLatch = new ChronoLatch(IrisSettings.get().getPregen().getSaveIntervalMs()); + heapReclaimLatch = new ChronoLatch(1000); + failed = new AtomicLong(0); generatedRegions = new KSet<>(); this.shutdown = new AtomicBoolean(false); this.paused = new AtomicBoolean(false); @@ -95,7 +99,6 @@ public class IrisPregenerator { cachedLast = new AtomicLong(0); cachedLastMinute = new AtomicLong(0); totalChunks = new AtomicLong(0); - task.iterateAllChunks((_a, _b) -> totalChunks.incrementAndGet()); startTime = new AtomicLong(M.ms()); ticker = new Looper() { @Override @@ -175,13 +178,27 @@ public class IrisPregenerator { public void start() { init(); + task.iterateAllChunks((_a, _b) -> totalChunks.incrementAndGet()); + startTime.set(M.ms()); ticker.start(); checkRegions(); PrecisionStopwatch p = PrecisionStopwatch.start(); - task.iterateRegions((x, z) -> visitRegion(x, z, true)); - task.iterateRegions((x, z) -> visitRegion(x, z, false)); - IrisLogging.info("Pregen took " + Form.duration((long) p.getMilliseconds())); - shutdown(); + try { + int[] regionBounds = task.regionBounds(); + generator.onRegionBounds(regionBounds[0], regionBounds[1], regionBounds[2], regionBounds[3]); + task.iterateRegions((x, z) -> visitRegion(x, z, true)); + task.iterateRegions((x, z) -> visitRegion(x, z, false)); + long failedCount = failed.get(); + if (failedCount > 0) { + IrisLogging.warn("Pregen finished with " + Form.f(failedCount) + " failed chunk(s); failures are not cached, rerun to fill them"); + } + IrisLogging.info("Pregen took " + Form.duration((long) p.getMilliseconds())); + } catch (Throwable e) { + IrisLogging.reportError(e); + IrisLogging.error("Pregen aborted after " + Form.duration((long) p.getMilliseconds()) + " due to " + e.getClass().getSimpleName() + ": " + e.getMessage()); + } finally { + shutdown(); + } if (benchmarking == null) { IrisLogging.info(C.IRIS + "Pregen stopped."); } else { @@ -266,12 +283,20 @@ public class IrisPregenerator { hit = true; listener.onRegionGenerating(x, z); task.iterateChunks(x, z, (xx, zz) -> { - while (paused.get() && !shutdown.get()) { + while ((paused.get() || MantleHeapPressure.overHighWater()) && !shutdown.get()) { + if (!paused.get()) { + reclaimHeapPressure(); + } J.sleep(50); } + if (shutdown.get()) { + return; + } + generator.generateChunk(xx, zz, listener); }); + generator.onRegionSubmitted(x, z); } if (hit) { @@ -280,9 +305,13 @@ public class IrisPregenerator { if (saveLatch.flip()) { listener.onSaving(); generator.save(); - Mantle mantle = getMantle(); - if (mantle != null) { - mantle.trim(0, 0); + try { + Mantle mantle = getMantle(); + if (mantle != null) { + mantle.trim(0, 0); + } + } catch (Throwable e) { + IrisLogging.reportError(e); } } @@ -291,6 +320,26 @@ public class IrisPregenerator { } } + private void reclaimHeapPressure() { + if (!heapReclaimLatch.flip()) { + return; + } + + try { + Mantle mantle = getMantle(); + if (mantle != null) { + mantle.trim(0, 0); + mantle.unloadTectonicPlate(0); + } + } catch (Throwable e) { + IrisLogging.reportError(e); + } + + if (MantleHeapPressure.overPanicWater()) { + MantleHeapPressure.requestPanicReclaim(); + } + } + private void checkRegion(int x, int z) { if (generatedRegions.contains(new Position2(x, z))) { return; @@ -299,6 +348,10 @@ public class IrisPregenerator { generator.supportsRegions(x, z, listener); } + public long getFailedChunks() { + return failed.get(); + } + public void pause() { paused.set(true); } @@ -326,6 +379,12 @@ public class IrisPregenerator { if (c) cached.addAndGet(1); } + @Override + public void onChunkFailed(int x, int z) { + failed.addAndGet(1); + listener.onChunkFailed(x, z); + } + @Override public void onRegionGenerated(int x, int z) { listener.onRegionGenerated(x, z); diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/MantleHeapPressure.java b/core/src/main/java/art/arcane/iris/core/pregenerator/MantleHeapPressure.java new file mode 100644 index 000000000..569a76c1f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/MantleHeapPressure.java @@ -0,0 +1,82 @@ +/* + * 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.core.pregenerator; + +import java.lang.management.ManagementFactory; +import java.lang.management.MemoryUsage; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; + +public final class MantleHeapPressure { + private static final double HIGH_WATER = 0.92D; + private static final double LOW_WATER = 0.82D; + private static final double PANIC_WATER = 0.96D; + private static final long PANIC_GC_INTERVAL_MS = 30_000L; + private static final AtomicBoolean engaged = new AtomicBoolean(false); + private static final AtomicLong lastPanicGcAt = new AtomicLong(0L); + + private MantleHeapPressure() { + } + + public static double usedFraction() { + MemoryUsage heap = ManagementFactory.getMemoryMXBean().getHeapMemoryUsage(); + long max = heap.getMax(); + if (max <= 0L) { + Runtime runtime = Runtime.getRuntime(); + long runtimeMax = runtime.maxMemory(); + if (runtimeMax <= 0L) { + return 0.0D; + } + long used = runtime.totalMemory() - runtime.freeMemory(); + return (double) used / (double) runtimeMax; + } + return (double) heap.getUsed() / (double) max; + } + + public static boolean overHighWater() { + double fraction = usedFraction(); + if (engaged.get()) { + if (fraction <= LOW_WATER) { + engaged.set(false); + return false; + } + return true; + } + if (fraction >= HIGH_WATER) { + engaged.set(true); + return true; + } + return false; + } + + public static boolean overPanicWater() { + return usedFraction() >= PANIC_WATER; + } + + public static void requestPanicReclaim() { + long now = System.currentTimeMillis(); + long last = lastPanicGcAt.get(); + if (now - last < PANIC_GC_INTERVAL_MS) { + return; + } + if (lastPanicGcAt.compareAndSet(last, now)) { + System.gc(); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/PregenListener.java b/core/src/main/java/art/arcane/iris/core/pregenerator/PregenListener.java index b4d30f8c9..d41c34530 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/PregenListener.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/PregenListener.java @@ -29,6 +29,9 @@ public interface PregenListener { void onChunkGenerated(int x, int z, boolean cached); + default void onChunkFailed(int x, int z) { + } + void onRegionGenerated(int x, int z); void onRegionGenerating(int x, int z); diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/PregenMantleBackpressure.java b/core/src/main/java/art/arcane/iris/core/pregenerator/PregenMantleBackpressure.java new file mode 100644 index 000000000..9f7f78f58 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/PregenMantleBackpressure.java @@ -0,0 +1,148 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 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.core.pregenerator; + +import art.arcane.iris.spi.IrisLogging; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.math.M; + +import java.util.function.Supplier; + +public final class PregenMantleBackpressure { + private final Supplier mantleSupplier; + private final int maxResidentTectonicPlates; + private final int waitMs; + private final long timeoutMs; + private final Runnable onBudgetTimeout; + private final Supplier diagnostics; + + public PregenMantleBackpressure(Supplier mantleSupplier, int maxResidentTectonicPlates, int waitMs, long timeoutMs, Runnable onBudgetTimeout, Supplier diagnostics) { + this.mantleSupplier = mantleSupplier; + this.maxResidentTectonicPlates = maxResidentTectonicPlates; + this.waitMs = waitMs; + this.timeoutMs = timeoutMs; + this.onBudgetTimeout = onBudgetTimeout; + this.diagnostics = diagnostics; + } + + public void apply() { + enforceMantleBudget(); + awaitHeapHeadroom(); + } + + public void enforceMantleBudget() { + int cap = maxResidentTectonicPlates; + if (cap <= 0) { + return; + } + + Mantle mantle = resolveMantle(); + if (mantle == null) { + return; + } + + int hardCap = cap * 2; + if (mantle.getLoadedRegionCount() <= hardCap) { + return; + } + + long waitStart = M.ms(); + long lastLog = 0L; + while (mantle.getLoadedRegionCount() > hardCap) { + int freed; + int resident; + try { + mantle.trim(0L, 0); + freed = mantle.unloadTectonicPlate(0); + resident = mantle.getLoadedRegionCount(); + } catch (Throwable e) { + IrisLogging.reportError(e); + break; + } + if (resident <= hardCap) { + break; + } + + long elapsed = M.ms() - waitStart; + if (elapsed >= timeoutMs) { + IrisLogging.warn("Pregen mantle backpressure exceeded " + timeoutMs + "ms with " + resident + + " tectonic plates resident (hard cap " + hardCap + "); proceeding to avoid deadlock. " + + "Raise pregen.maxResidentTectonicPlates if this persists. " + diagnostics.get()); + onBudgetTimeout.run(); + return; + } + + long logNow = M.ms(); + if (logNow - lastLog >= 5_000L) { + lastLog = logNow; + IrisLogging.warn("Pregen mantle backpressure: " + resident + " tectonic plates resident (hard cap " + hardCap + + "), freed " + freed + " last pass, waited " + elapsed + "ms."); + } + + try { + Thread.sleep(waitMs); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + } + + public void awaitHeapHeadroom() { + Mantle mantle = resolveMantle(); + long lastLog = 0L; + while (MantleHeapPressure.overHighWater()) { + try { + if (mantle != null && mantle.getLoadedRegionCount() > maxResidentTectonicPlates) { + mantle.trim(0L, 0); + mantle.unloadTectonicPlate(0); + } + } catch (Throwable e) { + IrisLogging.reportError(e); + } + + if (MantleHeapPressure.overPanicWater()) { + MantleHeapPressure.requestPanicReclaim(); + } + + long logNow = M.ms(); + if (logNow - lastLog >= 5_000L) { + lastLog = logNow; + IrisLogging.warn("Pregen heap pressure: pausing generation at " + + Math.round(MantleHeapPressure.usedFraction() * 100.0D) + "% heap; evicting tectonic plates and waiting for headroom" + + (mantle != null ? " (" + mantle.getLoadedRegionCount() + " plates resident)" : "") + "."); + } + + try { + Thread.sleep(waitMs); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + } + + private Mantle resolveMantle() { + try { + return mantleSupplier.get(); + } catch (Throwable ignored) { + return null; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/PregenPerformanceProfile.java b/core/src/main/java/art/arcane/iris/core/pregenerator/PregenPerformanceProfile.java new file mode 100644 index 000000000..60393424d --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/PregenPerformanceProfile.java @@ -0,0 +1,64 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 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.core.pregenerator; + +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.engine.framework.Engine; + +import java.util.concurrent.atomic.AtomicBoolean; + +public final class PregenPerformanceProfile { + private static final AtomicBoolean JVM_HINT_LOGGED = new AtomicBoolean(false); + + private PregenPerformanceProfile() { + } + + public static boolean apply() { + IrisSettings.IrisSettingsPerformance performance = IrisSettings.get().getPerformance(); + int previousNoiseCacheSize = performance.getNoiseCacheSize(); + int targetNoiseCacheSize = Math.max(previousNoiseCacheSize, 4_096); + boolean fastCacheEnabledBefore = Boolean.getBoolean("iris.cache.fast"); + boolean changed = false; + + if (targetNoiseCacheSize != previousNoiseCacheSize) { + performance.setNoiseCacheSize(targetNoiseCacheSize); + changed = true; + } + + if (!fastCacheEnabledBefore) { + System.setProperty("iris.cache.fast", "true"); + changed = true; + } + + if (JVM_HINT_LOGGED.compareAndSet(false, true) && !fastCacheEnabledBefore) { + IrisLogging.info("For startup-wide cache-fast coverage, set JVM argument: -Diris.cache.fast=true"); + } + + return changed; + } + + public static void apply(Engine engine) { + boolean changed = apply(); + if (changed && engine != null) { + engine.hotloadComplex(); + IrisLogging.info("Pregen profile applied: noiseCacheSize=" + IrisSettings.get().getPerformance().getNoiseCacheSize() + " iris.cache.fast=" + Boolean.getBoolean("iris.cache.fast")); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/PregenTask.java b/core/src/main/java/art/arcane/iris/core/pregenerator/PregenTask.java index 1727d7aa7..7894f8532 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/PregenTask.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/PregenTask.java @@ -19,7 +19,6 @@ package art.arcane.iris.core.pregenerator; import art.arcane.volmlib.util.collection.KList; -import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.math.PowerOfTwoCoordinates; import art.arcane.volmlib.util.math.Position2; import art.arcane.volmlib.util.math.Spiraled; @@ -29,12 +28,20 @@ import lombok.Data; import java.util.ArrayList; import java.util.Comparator; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; @Builder @Data public class PregenTask { - private static final KMap ORDERS = new KMap<>(); + private static final int MAX_CACHED_ORDERS = 512; + private static final LinkedHashMap ORDERS = new LinkedHashMap<>(64, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > MAX_CACHED_ORDERS; + } + }; @Builder.Default private final boolean gui = false; @@ -71,7 +78,18 @@ public class PregenTask { private static int[] orderForPull(int pullX, int pullZ) { long key = orderKey(pullX, pullZ); - return ORDERS.computeIfAbsent(key, PregenTask::computeOrder); + synchronized (ORDERS) { + int[] cached = ORDERS.get(key); + if (cached != null) { + return cached; + } + } + + int[] computed = computeOrder(key); + synchronized (ORDERS) { + ORDERS.put(key, computed); + } + return computed; } private static int[] computeOrder(long key) { @@ -119,6 +137,11 @@ public class PregenTask { })); } + public int[] regionBounds() { + Bound bound = bounds.region(); + return new int[]{bound.minX(), bound.minZ(), bound.maxX(), bound.maxZ()}; + } + @FunctionalInterface public interface InterleavedChunkConsumer { boolean on(int regionX, int regionZ, int chunkX, int chunkZ, boolean firstChunkInRegion, boolean lastChunkInRegion); diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/PregeneratorMethod.java b/core/src/main/java/art/arcane/iris/core/pregenerator/PregeneratorMethod.java index 6027060ea..e6ec6ad6f 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/PregeneratorMethod.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/PregeneratorMethod.java @@ -80,5 +80,11 @@ public interface PregeneratorMethod { */ void generateChunk(int x, int z, PregenListener listener); + default void onRegionBounds(int minRegionX, int minRegionZ, int maxRegionX, int maxRegionZ) { + } + + default void onRegionSubmitted(int regionX, int regionZ) { + } + Mantle getMantle(); } diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/cache/PregenCacheImpl.java b/core/src/main/java/art/arcane/iris/core/pregenerator/cache/PregenCacheImpl.java index 0020fa55b..b032a7bdd 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/cache/PregenCacheImpl.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/cache/PregenCacheImpl.java @@ -196,6 +196,16 @@ public class PregenCacheImpl implements PregenCache { boolean test(Region region); } + private enum CacheResult { + NOOP, + SET, + COMPLETED + } + + private interface RegionCacheOp { + CacheResult apply(Region region); + } + private static class Plate { private final int x; private final int z; @@ -216,7 +226,7 @@ public class PregenCacheImpl implements PregenCache { this.lastAccess = System.currentTimeMillis(); } - private boolean cache(int x, int z, RegionPredicate predicate) { + private boolean cache(int x, int z, RegionCacheOp op) { lastAccess = System.currentTimeMillis(); if (count == SIZE) { return false; @@ -229,7 +239,13 @@ public class PregenCacheImpl implements PregenCache { regions[index] = region; } - if (!predicate.test(region)) { + CacheResult result = op.apply(region); + if (result == CacheResult.NOOP) { + return false; + } + + dirty = true; + if (result != CacheResult.COMPLETED) { return false; } @@ -237,7 +253,6 @@ public class PregenCacheImpl implements PregenCache { if (count == SIZE) { regions = null; } - dirty = true; return true; } @@ -282,23 +297,23 @@ public class PregenCacheImpl implements PregenCache { this.words = words; } - private boolean cache() { + private CacheResult cache() { if (count == SIZE) { - return false; + return CacheResult.NOOP; } count = SIZE; words = null; - return true; + return CacheResult.COMPLETED; } - private boolean cache(int x, int z) { + private CacheResult cache(int x, int z) { if (count == SIZE) { - return false; + return CacheResult.NOOP; } long[] value = words; if (value == null) { - return false; + return CacheResult.NOOP; } int index = PowerOfTwoCoordinates.packLocal32(x, z); @@ -306,17 +321,17 @@ public class PregenCacheImpl implements PregenCache { long bit = 1L << (index & 63); boolean current = (value[wordIndex] & bit) != 0L; if (current) { - return false; + return CacheResult.NOOP; } count++; if (count == SIZE) { words = null; - return true; + return CacheResult.COMPLETED; } value[wordIndex] = value[wordIndex] | bit; - return false; + return CacheResult.SET; } private boolean isCached() { diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncOrMedievalPregenMethod.java b/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncOrMedievalPregenMethod.java index b6301fd44..82ec397fa 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncOrMedievalPregenMethod.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncOrMedievalPregenMethod.java @@ -70,6 +70,16 @@ public class AsyncOrMedievalPregenMethod implements PregeneratorMethod { method.generateChunk(x, z, listener); } + @Override + public void onRegionBounds(int minRegionX, int minRegionZ, int maxRegionX, int maxRegionZ) { + method.onRegionBounds(minRegionX, minRegionZ, maxRegionX, maxRegionZ); + } + + @Override + public void onRegionSubmitted(int regionX, int regionZ) { + method.onRegionSubmitted(regionX, regionZ); + } + @Override public Mantle getMantle() { return method.getMantle(); diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java b/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java index 1dfcb8e4b..217d460ba 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java @@ -23,9 +23,13 @@ import art.arcane.iris.core.IrisPaperLikeBackendMode; import art.arcane.iris.core.IrisRuntimeSchedulerMode; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.pregenerator.PregenListener; +import art.arcane.iris.core.pregenerator.PregenMantleBackpressure; import art.arcane.iris.core.pregenerator.PregeneratorMethod; import art.arcane.iris.core.tools.IrisToolbelt; +import art.arcane.iris.core.nms.INMS; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.platform.bukkit.BukkitPlatform; +import art.arcane.volmlib.util.collection.KSet; import art.arcane.volmlib.util.mantle.runtime.Mantle; import art.arcane.volmlib.util.math.M; import art.arcane.iris.util.common.parallel.MultiBurst; @@ -37,15 +41,16 @@ import org.bukkit.World; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; import java.util.Locale; -import java.util.Map; +import java.util.Queue; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.ExecutorService; import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; @@ -53,8 +58,6 @@ public class AsyncPregenMethod implements PregeneratorMethod { private static final AtomicInteger THREAD_COUNT = new AtomicInteger(); private static final int ADAPTIVE_TIMEOUT_STEP = 3; private static final int ADAPTIVE_RECOVERY_INTERVAL = 8; - private static final long CHUNK_CLEANUP_INTERVAL_MS = 10_000L; - private static final long CHUNK_CLEANUP_MIN_AGE_MS = 5_000L; private final World world; private final IrisRuntimeSchedulerMode runtimeSchedulerMode; private final IrisPaperLikeBackendMode paperLikeBackendMode; @@ -73,12 +76,20 @@ public class AsyncPregenMethod implements PregeneratorMethod { private final int timeoutSeconds; private final int timeoutWarnIntervalMs; private final boolean urgent; - private final Map lastUse; - private final ConcurrentLinkedQueue chunkUseQueue; + private final ConcurrentHashMap regionPending; + private final ConcurrentHashMap> regionChunks; + private final KSet drainedRegions; + private final KSet evictedRegions; + private volatile int evictionWindowRegions; + private volatile int boundsMinRegionX; + private volatile int boundsMinRegionZ; + private volatile int boundsMaxRegionX; + private volatile int boundsMaxRegionZ; private final AtomicInteger adaptiveInFlightLimit; private final int adaptiveMinInFlightLimit; private final AtomicInteger timeoutStreak = new AtomicInteger(); private final AtomicLong lastTimeoutLogAt = new AtomicLong(0L); + private final AtomicLong lastFailedReleaseLogAt = new AtomicLong(0L); private final AtomicInteger suppressedTimeoutLogs = new AtomicInteger(); private final AtomicLong lastAdaptiveLogAt = new AtomicLong(0L); private final AtomicInteger inFlight = new AtomicInteger(); @@ -86,14 +97,10 @@ public class AsyncPregenMethod implements PregeneratorMethod { private final AtomicLong completed = new AtomicLong(); private final AtomicLong failed = new AtomicLong(); private final AtomicLong lastProgressAt = new AtomicLong(M.ms()); - private final AtomicLong lastChunkCleanup = new AtomicLong(M.ms()); - private final AtomicBoolean chunkCleanupRunning = new AtomicBoolean(false); private final Object permitMonitor = new Object(); private volatile Engine metricsEngine; private volatile Mantle cachedMantle; - private final int maxResidentTectonicPlates; - private final int mantleBackpressureWaitMs; - private final long mantleBackpressureTimeoutMs; + private final PregenMantleBackpressure backpressure; public AsyncPregenMethod(World world, int unusedThreads) { if (!PaperLib.isPaper()) { @@ -140,13 +147,25 @@ public class AsyncPregenMethod implements PregeneratorMethod { this.timeoutSeconds = pregen.getChunkLoadTimeoutSeconds(); this.timeoutWarnIntervalMs = pregen.getTimeoutWarnIntervalMs(); this.urgent = false; - this.lastUse = new ConcurrentHashMap<>(); - this.chunkUseQueue = new ConcurrentLinkedQueue<>(); + this.regionPending = new ConcurrentHashMap<>(); + this.regionChunks = new ConcurrentHashMap<>(); + this.drainedRegions = new KSet<>(); + this.evictedRegions = new KSet<>(); + this.evictionWindowRegions = -1; + this.boundsMinRegionX = Integer.MIN_VALUE; + this.boundsMinRegionZ = Integer.MIN_VALUE; + this.boundsMaxRegionX = Integer.MAX_VALUE; + this.boundsMaxRegionZ = Integer.MAX_VALUE; this.adaptiveInFlightLimit = new AtomicInteger(this.threads); this.adaptiveMinInFlightLimit = Math.max(4, Math.min(16, Math.max(1, this.threads / 4))); - this.maxResidentTectonicPlates = pregen.getMaxResidentTectonicPlates(); - this.mantleBackpressureWaitMs = pregen.getMantleBackpressureWaitMs(); - this.mantleBackpressureTimeoutMs = pregen.getMantleBackpressureTimeoutMs(); + int pregenWorldHeight = world.getMaxHeight() - world.getMinHeight(); + this.backpressure = new PregenMantleBackpressure( + this::resolveMantle, + pregen.getEffectiveResidentTectonicPlates(pregenWorldHeight), + pregen.getMantleBackpressureWaitMs(), + pregen.getMantleBackpressureTimeoutMs(), + this::lowerAdaptiveInFlightLimit, + this::metricsSnapshot); } private IrisPaperLikeBackendMode resolvePaperLikeBackendMode(IrisSettings.IrisSettingsPregen pregen) { @@ -173,124 +192,242 @@ public class AsyncPregenMethod implements PregeneratorMethod { return -1; } - private void unloadAndSaveAllChunks() { - if (foliaRuntime) { - lastUse.clear(); - chunkUseQueue.clear(); + private static long rkey(int rx, int rz) { + return (((long) rx) << 32) | (rz & 0xFFFFFFFFL); + } + + private int evictionWindow() { + int cached = evictionWindowRegions; + if (cached > 0) { + return cached; + } + + Engine engine = resolveMetricsEngine(); + if (engine == null) { + return 2; + } + + try { + int radius = engine.getMantle().getRadius(); + int resolved = radius > 0 ? Math.max(1, (int) Math.ceil(radius / 32.0)) : 2; + evictionWindowRegions = resolved; + return resolved; + } catch (Throwable ignored) { + return 2; + } + } + + private void onChunkCompleted(int x, int z, Chunk chunk) { + if (chunk == null) { return; } - if (lastUse.isEmpty()) { + try { + long rk = rkey(x >> 5, z >> 5); + regionChunks.computeIfAbsent(rk, k -> new ConcurrentLinkedQueue<>()).add(chunk); + AtomicInteger pending = regionPending.get(rk); + if (pending != null && pending.decrementAndGet() == 0) { + onRegionDrained(rk); + } + } catch (Throwable e) { + IrisLogging.reportError(e); + } + } + + private void onChunkFailedToLoad(int x, int z) { + try { + long rk = rkey(x >> 5, z >> 5); + AtomicInteger pending = regionPending.get(rk); + if (pending != null && pending.decrementAndGet() == 0) { + onRegionDrained(rk); + } + } catch (Throwable e) { + IrisLogging.reportError(e); + } + + long now = M.ms(); + long last = lastFailedReleaseLogAt.get(); + if (now - last >= timeoutWarnIntervalMs && lastFailedReleaseLogAt.compareAndSet(last, now)) { + IrisLogging.warn("Released region slot for failed or timed out chunk at " + x + "," + z + ". " + metricsSnapshot()); + } + } + + @Override + public void onRegionBounds(int minRegionX, int minRegionZ, int maxRegionX, int maxRegionZ) { + boundsMinRegionX = minRegionX; + boundsMinRegionZ = minRegionZ; + boundsMaxRegionX = maxRegionX; + boundsMaxRegionZ = maxRegionZ; + } + + private boolean inBounds(int rx, int rz) { + return rx >= boundsMinRegionX && rx <= boundsMaxRegionX && rz >= boundsMinRegionZ && rz <= boundsMaxRegionZ; + } + + @Override + public void onRegionSubmitted(int regionX, int regionZ) { + try { + long rk = rkey(regionX, regionZ); + AtomicInteger pending = regionPending.get(rk); + if (pending == null || pending.decrementAndGet() == 0) { + onRegionDrained(rk); + } + } catch (Throwable e) { + IrisLogging.reportError(e); + } + } + + private void onRegionDrained(long rk) { + if (!drainedRegions.add(rk)) { + return; + } + + int w = evictionWindow(); + int rx = (int) (rk >> 32); + int rz = (int) rk; + for (int dx = -w; dx <= w; dx++) { + for (int dz = -w; dz <= w; dz++) { + int cx = rx + dx; + int cz = rz + dz; + long candidate = rkey(cx, cz); + if (evictedRegions.contains(candidate)) { + continue; + } + if (!drainedRegions.contains(candidate)) { + continue; + } + if (allNeighborsDrained(cx, cz, w)) { + evictRegion(candidate); + } + } + } + } + + private boolean allNeighborsDrained(int rx, int rz, int w) { + for (int dx = -w; dx <= w; dx++) { + for (int dz = -w; dz <= w; dz++) { + int nx = rx + dx; + int nz = rz + dz; + if (!inBounds(nx, nz)) { + continue; + } + + if (!drainedRegions.contains(rkey(nx, nz))) { + return false; + } + } + } + + return true; + } + + private void evictRegion(long c) { + if (!evictedRegions.add(c)) { + return; + } + + regionPending.remove(c); + Queue chunks = regionChunks.remove(c); + if (chunks == null || chunks.isEmpty()) { + return; + } + + try { + if (foliaRuntime) { + Chunk anchor = null; + for (Chunk chunk : chunks) { + if (chunk == null) { + continue; + } + + if (anchor == null) { + anchor = chunk; + } + + int cx = chunk.getX(); + int cz = chunk.getZ(); + if (!J.runRegion(world, cx, cz, () -> unloadChunkSafely(cx, cz))) { + unloadChunkSafely(cx, cz); + } + } + + if (anchor != null) { + int ax = anchor.getX(); + int az = anchor.getZ(); + if (!J.runRegion(world, ax, az, () -> INMS.get().flushChunkIO(world))) { + INMS.get().flushChunkIO(world); + } + } + return; + } + + J.s(() -> { + for (Chunk chunk : chunks) { + if (chunk != null) { + unloadChunkSafely(chunk.getX(), chunk.getZ()); + } + } + + INMS.get().flushChunkIO(world); + }); + } catch (Throwable e) { + IrisLogging.reportError(e); + } + } + + private void unloadChunkSafely(int cx, int cz) { + try { + world.removePluginChunkTicket(cx, cz, BukkitPlatform.plugin()); + } catch (Throwable ignored) { + } + + try { + if (!INMS.get().saveAndUnloadChunk(world, cx, cz)) { + world.unloadChunk(cx, cz, true); + } + } catch (Throwable e) { + IrisLogging.reportError(e); + } + } + + private void flushAllRemainingChunks() { + List keys = new ArrayList<>(regionChunks.keySet()); + + if (foliaRuntime) { + for (Long rk : keys) { + evictRegion(rk); + } return; } try { J.sfut(() -> { - if (world == null) { - IrisLogging.warn("World was null somehow..."); - return; + for (Long rk : keys) { + if (!evictedRegions.add(rk)) { + continue; + } + + regionPending.remove(rk); + Queue chunks = regionChunks.remove(rk); + if (chunks == null) { + continue; + } + + for (Chunk chunk : chunks) { + if (chunk != null) { + unloadChunkSafely(chunk.getX(), chunk.getZ()); + } + } } - long minTime = M.ms() - 10_000; - AtomicBoolean unloaded = new AtomicBoolean(false); - lastUse.entrySet().removeIf(i -> { - final Chunk chunk = i.getKey(); - final Long lastUseTime = i.getValue(); - if (!chunk.isLoaded() || lastUseTime == null) - return true; - if (lastUseTime < minTime) { - chunk.unload(); - unloaded.set(true); - return true; - } - return false; - }); - if (unloaded.get()) { - world.save(); - } - if (lastUse.isEmpty()) { - chunkUseQueue.clear(); - } + world.save(); + INMS.get().flushChunkIO(world); }).get(); } catch (Throwable e) { - e.printStackTrace(); + IrisLogging.reportError(e); } } - private void periodicChunkCleanup() { - long now = M.ms(); - long lastCleanup = lastChunkCleanup.get(); - if (now - lastCleanup < CHUNK_CLEANUP_INTERVAL_MS) { - return; - } - - if (!lastChunkCleanup.compareAndSet(lastCleanup, now)) { - return; - } - - if (foliaRuntime) { - int sizeBefore = lastUse.size(); - if (sizeBefore > 0) { - lastUse.clear(); - chunkUseQueue.clear(); - IrisLogging.info("Periodic chunk cleanup: cleared " + sizeBefore + " Folia chunk references"); - } - return; - } - - int sizeBefore = lastUse.size(); - if (sizeBefore == 0) { - return; - } - - if (!chunkCleanupRunning.compareAndSet(false, true)) { - return; - } - - long minTime = now - CHUNK_CLEANUP_MIN_AGE_MS; - J.a(() -> { - try { - int removedCount = cleanupQueuedChunkUses(minTime); - if (removedCount > 0) { - IrisLogging.info("Periodic chunk cleanup: removed " + removedCount + "/" + sizeBefore + " stale chunk references"); - } - } finally { - chunkCleanupRunning.set(false); - } - }); - } - - private int cleanupQueuedChunkUses(long minTime) { - int removed = 0; - while (true) { - ChunkUse chunkUse = chunkUseQueue.peek(); - if (chunkUse == null) { - return removed; - } - - Long latestUse = lastUse.get(chunkUse.chunk()); - if (latestUse != null && latestUse > chunkUse.lastUseTime()) { - chunkUseQueue.poll(); - continue; - } - - if (latestUse != null && latestUse >= minTime) { - return removed; - } - - chunkUseQueue.poll(); - if (latestUse != null && lastUse.remove(chunkUse.chunk(), latestUse)) { - removed++; - } - } - } - - private void recordChunkUse(Chunk chunk) { - long now = M.ms(); - lastUse.put(chunk, now); - chunkUseQueue.offer(new ChunkUse(chunk, now)); - } - private Chunk onChunkFutureFailure(int x, int z, Throwable throwable) { Throwable root = throwable; while (root.getCause() != null) { @@ -527,56 +664,6 @@ public class AsyncPregenMethod implements PregeneratorMethod { return resolved; } - private void enforceMantleBudget() { - int cap = maxResidentTectonicPlates; - if (cap <= 0) { - return; - } - - Mantle mantle = resolveMantle(); - if (mantle == null) { - return; - } - - int hardCap = cap * 2; - if (mantle.getLoadedRegionCount() <= hardCap) { - return; - } - - long waitStart = M.ms(); - long lastLog = 0L; - while (mantle.getLoadedRegionCount() > hardCap) { - mantle.trim(0L, 0); - int freed = mantle.unloadTectonicPlate(0); - int resident = mantle.getLoadedRegionCount(); - if (resident <= hardCap) { - break; - } - - long elapsed = M.ms() - waitStart; - if (elapsed >= mantleBackpressureTimeoutMs) { - IrisLogging.warn("Pregen mantle backpressure exceeded " + mantleBackpressureTimeoutMs + "ms with " + resident - + " tectonic plates resident (hard cap " + hardCap + "); proceeding to avoid deadlock. " - + "Raise pregen.maxResidentTectonicPlates if this persists. " + metricsSnapshot()); - return; - } - - long logNow = M.ms(); - if (logNow - lastLog >= 5_000L) { - lastLog = logNow; - IrisLogging.warn("Pregen mantle backpressure: " + resident + " tectonic plates resident (hard cap " + hardCap - + "), freed " + freed + " last pass, waited " + elapsed + "ms."); - } - - try { - Thread.sleep(mantleBackpressureWaitMs); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return; - } - } - } - @Override public void init() { IrisLogging.info("Async pregen init: world=" + world.getName() @@ -591,7 +678,6 @@ public class AsyncPregenMethod implements PregeneratorMethod { + ", recommendedCap=" + recommendedRuntimeConcurrencyCap + ", urgent=" + urgent + ", timeout=" + timeoutSeconds + "s"); - unloadAndSaveAllChunks(); if (workerPoolThreads > 0) { increaseWorkerThreads(); } @@ -610,14 +696,13 @@ public class AsyncPregenMethod implements PregeneratorMethod { @Override public void close() { semaphore.acquireUninterruptibly(threads); - unloadAndSaveAllChunks(); + flushAllRemainingChunks(); executor.shutdown(); resetWorkerThreads(); } @Override public void save() { - unloadAndSaveAllChunks(); } @Override @@ -633,8 +718,8 @@ public class AsyncPregenMethod implements PregeneratorMethod { @Override public void generateChunk(int x, int z, PregenListener listener) { listener.onChunkGenerating(x, z); - periodicChunkCleanup(); - enforceMantleBudget(); + backpressure.enforceMantleBudget(); + backpressure.awaitHeapHeadroom(); try { long waitStart = M.ms(); synchronized (permitMonitor) { @@ -659,6 +744,7 @@ public class AsyncPregenMethod implements PregeneratorMethod { return; } + regionPending.computeIfAbsent(rkey(x >> 5, z >> 5), k -> new AtomicInteger(1)).incrementAndGet(); markSubmitted(); executor.generate(x, z, listener); } @@ -833,6 +919,7 @@ public class AsyncPregenMethod implements PregeneratorMethod { .whenComplete((chunk, throwable) -> completeFoliaChunk(x, z, listener, chunk, throwable)))) { markFinished(false); semaphore.release(); + listener.onChunkFailed(x, z); IrisLogging.warn("Failed to schedule Folia region pregen task at " + x + "," + z + ". " + metricsSnapshot()); } } @@ -842,17 +929,21 @@ public class AsyncPregenMethod implements PregeneratorMethod { try { if (throwable != null) { onChunkFutureFailure(x, z, throwable); + onChunkFailedToLoad(x, z); + listener.onChunkFailed(x, z); return; } if (chunk == null) { + onChunkFailedToLoad(x, z); + listener.onChunkFailed(x, z); return; } listener.onChunkGenerated(x, z); cleanupMantleChunk(x, z); listener.onChunkCleaned(x, z); - recordChunkUse(chunk); + onChunkCompleted(x, z, chunk); success = true; } catch (Throwable e) { IrisLogging.reportError(e); @@ -877,13 +968,15 @@ public class AsyncPregenMethod implements PregeneratorMethod { .get(); if (i == null) { + onChunkFailedToLoad(x, z); + listener.onChunkFailed(x, z); return; } listener.onChunkGenerated(x, z); cleanupMantleChunk(x, z); listener.onChunkCleaned(x, z); - recordChunkUse(i); + onChunkCompleted(x, z, i); success = true; } catch (InterruptedException ignored) { Thread.currentThread().interrupt(); @@ -913,13 +1006,15 @@ public class AsyncPregenMethod implements PregeneratorMethod { boolean success = false; try { if (i == null) { + onChunkFailedToLoad(x, z); + listener.onChunkFailed(x, z); return; } listener.onChunkGenerated(x, z); cleanupMantleChunk(x, z); listener.onChunkCleaned(x, z); - recordChunkUse(i); + onChunkCompleted(x, z, i); success = true; } finally { markFinished(success); @@ -929,9 +1024,6 @@ public class AsyncPregenMethod implements PregeneratorMethod { } } - private record ChunkUse(Chunk chunk, long lastUseTime) { - } - private record ChunkAsyncMethodSelection(Method urgentMethod, Method standardMethod, String mode) { } } diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/CachedPregenMethod.java b/core/src/main/java/art/arcane/iris/core/pregenerator/methods/CachedPregenMethod.java index 834324870..6fe0f860e 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/CachedPregenMethod.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/methods/CachedPregenMethod.java @@ -1,27 +1,22 @@ package art.arcane.iris.core.pregenerator.methods; -import art.arcane.iris.spi.IrisLogging; -import art.arcane.iris.spi.IrisServices; import art.arcane.iris.core.pregenerator.PregenListener; +import art.arcane.iris.core.pregenerator.PregenTask; import art.arcane.iris.core.pregenerator.PregeneratorMethod; import art.arcane.iris.core.pregenerator.cache.PregenCache; -import art.arcane.iris.core.service.GlobalCacheSVC; import art.arcane.volmlib.util.mantle.runtime.Mantle; -import lombok.AllArgsConstructor; -@AllArgsConstructor public class CachedPregenMethod implements PregeneratorMethod { private final PregeneratorMethod method; private final PregenCache cache; + private final PregenTask task; + private volatile PregenListener wrappedSource; + private volatile PregenListener wrappedListener; - public CachedPregenMethod(PregeneratorMethod method, String worldName) { + public CachedPregenMethod(PregeneratorMethod method, PregenCache cache, PregenTask task) { this.method = method; - PregenCache cache = IrisServices.get(GlobalCacheSVC.class).get(worldName); - if (cache == null) { - IrisLogging.debug("Could not find existing cache for " + worldName + " creating fallback"); - cache = GlobalCacheSVC.createDefault(worldName); - } - this.cache = cache; + this.cache = cache.sync(); + this.task = task; } @Override @@ -31,6 +26,7 @@ public class CachedPregenMethod implements PregeneratorMethod { @Override public void close() { + cache.write(); method.close(); cache.write(); } @@ -48,6 +44,9 @@ public class CachedPregenMethod implements PregeneratorMethod { @Override public String getMethod(int x, int z) { + if (cache.isRegionCached(x, z)) { + return "Cached"; + } return method.getMethod(x, z); } @@ -55,14 +54,10 @@ public class CachedPregenMethod implements PregeneratorMethod { public void generateRegion(int x, int z, PregenListener listener) { if (cache.isRegionCached(x, z)) { listener.onRegionGenerated(x, z); - - int rX = x << 5, rZ = z << 5; - for (int cX = 0; cX < 32; cX++) { - for (int cZ = 0; cZ < 32; cZ++) { - listener.onChunkGenerated(rX + cX, rZ + cZ, true); - listener.onChunkCleaned(rX + cX, rZ + cZ); - } - } + task.iterateChunks(x, z, (cX, cZ) -> { + listener.onChunkGenerated(cX, cZ, true); + listener.onChunkCleaned(cX, cZ); + }); return; } method.generateRegion(x, z, listener); @@ -76,8 +71,113 @@ public class CachedPregenMethod implements PregeneratorMethod { listener.onChunkCleaned(x, z); return; } - method.generateChunk(x, z, listener); - cache.cacheChunk(x, z); + method.generateChunk(x, z, cachingListener(listener)); + } + + private PregenListener cachingListener(PregenListener listener) { + PregenListener source = wrappedSource; + PregenListener wrapped = wrappedListener; + if (source == listener && wrapped != null) { + return wrapped; + } + + PregenListener created = new PregenListener() { + @Override + public void onTick(double chunksPerSecond, double chunksPerMinute, double regionsPerMinute, double percent, long generated, long totalChunks, long chunksRemaining, long eta, long elapsed, String method, boolean cached) { + listener.onTick(chunksPerSecond, chunksPerMinute, regionsPerMinute, percent, generated, totalChunks, chunksRemaining, eta, elapsed, method, cached); + } + + @Override + public void onChunkGenerating(int x, int z) { + listener.onChunkGenerating(x, z); + } + + @Override + public void onChunkGenerated(int x, int z, boolean cachedChunk) { + if (!cachedChunk) { + cache.cacheChunk(x, z); + } + listener.onChunkGenerated(x, z, cachedChunk); + } + + @Override + public void onChunkFailed(int x, int z) { + listener.onChunkFailed(x, z); + } + + @Override + public void onRegionGenerated(int x, int z) { + listener.onRegionGenerated(x, z); + } + + @Override + public void onRegionGenerating(int x, int z) { + listener.onRegionGenerating(x, z); + } + + @Override + public void onChunkCleaned(int x, int z) { + listener.onChunkCleaned(x, z); + } + + @Override + public void onRegionSkipped(int x, int z) { + listener.onRegionSkipped(x, z); + } + + @Override + public void onNetworkStarted(int x, int z) { + listener.onNetworkStarted(x, z); + } + + @Override + public void onNetworkFailed(int x, int z) { + listener.onNetworkFailed(x, z); + } + + @Override + public void onNetworkReclaim(int revert) { + listener.onNetworkReclaim(revert); + } + + @Override + public void onNetworkGeneratedChunk(int x, int z) { + listener.onNetworkGeneratedChunk(x, z); + } + + @Override + public void onNetworkDownloaded(int x, int z) { + listener.onNetworkDownloaded(x, z); + } + + @Override + public void onClose() { + listener.onClose(); + } + + @Override + public void onSaving() { + listener.onSaving(); + } + + @Override + public void onChunkExistsInRegionGen(int x, int z) { + listener.onChunkExistsInRegionGen(x, z); + } + }; + wrappedSource = listener; + wrappedListener = created; + return created; + } + + @Override + public void onRegionBounds(int minRegionX, int minRegionZ, int maxRegionX, int maxRegionZ) { + method.onRegionBounds(minRegionX, minRegionZ, maxRegionX, maxRegionZ); + } + + @Override + public void onRegionSubmitted(int regionX, int regionZ) { + method.onRegionSubmitted(regionX, regionZ); } @Override diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/HybridPregenMethod.java b/core/src/main/java/art/arcane/iris/core/pregenerator/methods/HybridPregenMethod.java index 68c173302..d35ac8fa4 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/HybridPregenMethod.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/methods/HybridPregenMethod.java @@ -67,6 +67,16 @@ public class HybridPregenMethod implements PregeneratorMethod { inWorld.generateChunk(x, z, listener); } + @Override + public void onRegionBounds(int minRegionX, int minRegionZ, int maxRegionX, int maxRegionZ) { + inWorld.onRegionBounds(minRegionX, minRegionZ, maxRegionX, maxRegionZ); + } + + @Override + public void onRegionSubmitted(int regionX, int regionZ) { + inWorld.onRegionSubmitted(regionX, regionZ); + } + @Override public Mantle getMantle() { return inWorld.getMantle(); diff --git a/core/src/main/java/art/arcane/iris/core/project/IrisProjectCopier.java b/core/src/main/java/art/arcane/iris/core/project/IrisProjectCopier.java new file mode 100644 index 000000000..a59947e9f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/project/IrisProjectCopier.java @@ -0,0 +1,69 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 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.core.project; + +import art.arcane.volmlib.util.format.Form; +import art.arcane.volmlib.util.io.IO; +import art.arcane.volmlib.util.json.JSONObject; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Comparator; +import java.util.stream.Stream; + +public final class IrisProjectCopier { + private IrisProjectCopier() { + } + + public static void copyProject(File sourcePack, File targetPack, String sourceKey, String targetKey) throws IOException { + Path source = sourcePack.toPath(); + try (Stream walk = Files.walk(source)) { + for (Path path : walk.sorted(Comparator.naturalOrder()).toList()) { + String relative = source.relativize(path).toString(); + if (relative.isEmpty() || relative.equals(".git") || relative.startsWith(".git" + File.separator) || relative.endsWith(".code-workspace")) { + continue; + } + Path destination = targetPack.toPath().resolve(relative); + if (Files.isDirectory(path)) { + Files.createDirectories(destination); + } else { + Files.createDirectories(destination.getParent()); + Files.copy(path, destination, StandardCopyOption.REPLACE_EXISTING); + } + } + } + + File oldDimension = new File(targetPack, "dimensions/" + sourceKey + ".json"); + File newDimension = new File(targetPack, "dimensions/" + targetKey + ".json"); + if (oldDimension.isFile() && !oldDimension.equals(newDimension)) { + Files.copy(oldDimension.toPath(), newDimension.toPath(), StandardCopyOption.REPLACE_EXISTING); + Files.delete(oldDimension.toPath()); + } + if (newDimension.isFile()) { + JSONObject json = new JSONObject(IO.readAll(newDimension)); + if (json.has("name")) { + json.put("name", Form.capitalizeWords(targetKey.replaceAll("\\Q-\\E", " "))); + IO.writeAll(newDimension, json.toString(4)); + } + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/runtime/ChunkJobReporter.java b/core/src/main/java/art/arcane/iris/core/runtime/ChunkJobReporter.java index 25f266536..abb1ee00d 100644 --- a/core/src/main/java/art/arcane/iris/core/runtime/ChunkJobReporter.java +++ b/core/src/main/java/art/arcane/iris/core/runtime/ChunkJobReporter.java @@ -20,6 +20,7 @@ package art.arcane.iris.core.runtime; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.util.common.format.C; +import art.arcane.iris.util.common.math.ChunkSpiral; import art.arcane.iris.util.common.plugin.VolmitSender; import art.arcane.iris.util.common.scheduling.J; import art.arcane.volmlib.util.format.Form; @@ -29,8 +30,6 @@ import org.bukkit.boss.BarColor; import org.bukkit.boss.BarStyle; import org.bukkit.boss.BossBar; -import java.util.ArrayList; -import java.util.Comparator; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -61,19 +60,7 @@ public final class ChunkJobReporter { } public static List orderedTargets(int centerChunkX, int centerChunkZ, int radius) { - List targets = new ArrayList<>(); - for (int dx = -radius; dx <= radius; dx++) { - for (int dz = -radius; dz <= radius; dz++) { - targets.add(new int[]{centerChunkX + dx, centerChunkZ + dz}); - } - } - - targets.sort(Comparator.comparingInt(t -> { - int ox = t[0] - centerChunkX; - int oz = t[1] - centerChunkZ; - return ox * ox + oz * oz; - })); - return targets; + return ChunkSpiral.centerOut(centerChunkX, centerChunkZ, radius); } public void start() { diff --git a/core/src/main/java/art/arcane/iris/core/runtime/GoldenHashEngine.java b/core/src/main/java/art/arcane/iris/core/runtime/GoldenHashEngine.java new file mode 100644 index 000000000..aebc3734f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/GoldenHashEngine.java @@ -0,0 +1,484 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 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.core.runtime; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.mantle.EngineMantle; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.spi.PlatformBiome; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.common.math.ChunkSpiral; +import art.arcane.iris.util.common.parallel.MultiBurst; +import art.arcane.volmlib.util.mantle.runtime.Mantle; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Semaphore; +import java.util.concurrent.atomic.AtomicInteger; + +public final class GoldenHashEngine { + public enum Mode { + AUTO, + CAPTURE, + VERIFY + } + + public interface ChunkSnapshot { + int minY(); + + int maxY(); + + PlatformBlockState block(int x, int y, int z); + + PlatformBiome biome(int x, int y, int z); + } + + public interface ChunkSource { + ChunkSnapshot generate(int chunkX, int chunkZ) throws Exception; + } + + public interface Feedback { + void ok(String message); + + void warn(String message); + + void fail(String message); + } + + public interface Progress { + default void stage(String stage) { + } + + default void total(int total) { + } + + default void chunkDone(int chunkX, int chunkZ, boolean ok, int done, int total) { + } + + default void chunkFailed(int chunkX, int chunkZ, Throwable error) { + } + } + + public record Request(String worldName, long seed, String mcVersion, int minY, int maxY, int centerChunkX, + int centerChunkZ, int radius, int threads, Mode mode, boolean resetMantle, boolean deep, + String nullBiomeKey) { + } + + private static final AtomicInteger ACTIVE_SCANS = new AtomicInteger(0); + private static final String FORMAT = "iris-goldenhash v1"; + private static final int BIOME_STEP = 4; + private static final int MAX_REPORTED_MISMATCHES = 10; + + private final Engine engine; + private final Request request; + private final ChunkSource source; + private final Feedback feedback; + private final Progress progress; + private final int radius; + private final int threads; + private final File goldenFile; + + public GoldenHashEngine(Engine engine, Request request, File goldenDir, ChunkSource source, Feedback feedback, Progress progress) { + this.engine = engine; + this.request = request; + this.source = source; + this.feedback = feedback; + this.progress = progress; + this.radius = Math.max(0, request.radius()); + this.threads = Math.max(1, request.threads()); + goldenDir.mkdirs(); + this.goldenFile = new File(goldenDir, engine.getDimension().getLoadKey() + + "-s" + request.seed() + + "-c" + request.centerChunkX() + "x" + request.centerChunkZ() + + "-r" + this.radius + ".hashes"); + } + + public static boolean isActive() { + return ACTIVE_SCANS.get() > 0; + } + + public File getGoldenFile() { + return goldenFile; + } + + public boolean run() { + ACTIVE_SCANS.incrementAndGet(); + try { + boolean exists = goldenFile.exists(); + if (request.mode() == Mode.VERIFY && !exists) { + feedback.fail("No golden capture at " + goldenFile.getAbsolutePath() + "; run a capture first."); + return false; + } + + if (request.resetMantle()) { + progress.stage("Resetting mantle"); + resetMantleFull(); + } + + List targets = ChunkSpiral.centerOut(request.centerChunkX(), request.centerChunkZ(), radius); + progress.total(targets.size()); + progress.stage("Generating"); + Map lines = scan(targets); + + if (lines.size() != targets.size()) { + feedback.fail("GoldenHash aborted: " + (targets.size() - lines.size()) + " chunk(s) failed to generate. No golden file written."); + return false; + } + + progress.stage("Comparing"); + if (request.mode() == Mode.CAPTURE || (request.mode() == Mode.AUTO && !exists)) { + capture(lines); + return true; + } + + return verify(lines); + } catch (Throwable e) { + IrisLogging.reportError(e); + feedback.fail("GoldenHash failed: " + e); + return false; + } finally { + ACTIVE_SCANS.decrementAndGet(); + } + } + + private void resetMantleFull() { + try { + Mantle mantle = engine.getMantle().getMantle(); + mantle.saveAll(); + File folder = mantle.getDataFolder(); + File[] files = folder.listFiles(); + if (files != null) { + for (File file : files) { + if (file.isFile()) { + file.delete(); + } + } + } + feedback.ok("Mantle reset (" + folder.getAbsolutePath() + ")"); + } catch (Throwable e) { + IrisLogging.reportError(e); + feedback.warn("Mantle reset failed (" + e.getClass().getSimpleName() + "); continuing with existing mantle state."); + } + } + + private Map scan(List targets) throws InterruptedException { + Map lines = new ConcurrentHashMap<>(); + Semaphore inFlight = new Semaphore(threads); + CountDownLatch done = new CountDownLatch(targets.size()); + AtomicInteger completed = new AtomicInteger(); + int total = targets.size(); + + for (int[] target : targets) { + int chunkX = target[0]; + int chunkZ = target[1]; + + inFlight.acquire(); + MultiBurst.burst.lazy(() -> { + boolean ok = false; + try { + ChunkSnapshot snapshot = source.generate(chunkX, chunkZ); + lines.put(chunkKey(chunkX, chunkZ), hashChunk(chunkX, chunkZ, snapshot)); + if (request.deep()) { + writeDeepDump(chunkX, chunkZ, snapshot); + } + ok = true; + } catch (Throwable e) { + IrisLogging.reportError(e); + progress.chunkFailed(chunkX, chunkZ, e); + } finally { + progress.chunkDone(chunkX, chunkZ, ok, completed.incrementAndGet(), total); + inFlight.release(); + done.countDown(); + } + }); + } + + done.await(); + return lines; + } + + private String hashChunk(int chunkX, int chunkZ, ChunkSnapshot snapshot) { + MessageDigest blockDigest = sha256(); + MessageDigest biomeDigest = sha256(); + int minY = snapshot.minY(); + int maxY = snapshot.maxY(); + IdentityHashMap blockCache = new IdentityHashMap<>(); + Map biomeCache = new HashMap<>(); + byte[] nullBiome = (request.nullBiomeKey() + "\n").getBytes(StandardCharsets.UTF_8); + + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + for (int y = minY; y < maxY; y++) { + PlatformBlockState data = snapshot.block(x, y, z); + byte[] bytes = blockCache.computeIfAbsent(data, (PlatformBlockState d) -> (d.key() + "\n").getBytes(StandardCharsets.UTF_8)); + blockDigest.update(bytes); + } + } + } + + for (int x = 0; x < 16; x += BIOME_STEP) { + for (int z = 0; z < 16; z += BIOME_STEP) { + for (int y = minY; y < maxY; y += BIOME_STEP) { + PlatformBiome biome = snapshot.biome(x, y, z); + byte[] bytes = biome == null + ? nullBiome + : biomeCache.computeIfAbsent(biome, (PlatformBiome b) -> (b.key() + "\n").getBytes(StandardCharsets.UTF_8)); + biomeDigest.update(bytes); + } + } + } + + return chunkX + " " + chunkZ + " " + + HexFormat.of().formatHex(blockDigest.digest()) + " " + + HexFormat.of().formatHex(biomeDigest.digest()); + } + + private void capture(Map lines) throws IOException { + List body = orderedBody(lines); + String combined = combinedHash(body); + List out = new ArrayList<>(); + out.add("#" + FORMAT); + out.add("#world=" + request.worldName()); + out.add("#dim=" + engine.getDimension().getLoadKey()); + out.add("#seed=" + request.seed()); + out.add("#mc=" + request.mcVersion()); + out.add("#minY=" + request.minY() + " maxY=" + request.maxY()); + out.add("#center=" + request.centerChunkX() + "," + request.centerChunkZ()); + out.add("#radius=" + radius); + out.addAll(body); + out.add("#combined=" + combined); + Files.write(goldenFile.toPath(), out, StandardCharsets.UTF_8); + + feedback.ok("Golden captured: " + body.size() + " chunks combined=" + shortHash(combined)); + feedback.ok(goldenFile.getAbsolutePath()); + IrisLogging.info("goldenhash captured: " + goldenFile.getAbsolutePath() + " combined=" + combined); + } + + private boolean verify(Map lines) throws IOException { + List existing = Files.readAllLines(goldenFile.toPath(), StandardCharsets.UTF_8); + Map meta = new HashMap<>(); + Map goldenChunks = new HashMap<>(); + for (String line : existing) { + if (line.startsWith("#")) { + int eq = line.indexOf('='); + if (eq > 0) { + meta.put(line.substring(1, eq), line.substring(eq + 1)); + } + } else if (!line.isBlank()) { + int second = line.indexOf(' ', line.indexOf(' ') + 1); + goldenChunks.put(line.substring(0, second), line); + } + } + + String expectedSeed = String.valueOf(request.seed()); + String expectedDim = engine.getDimension().getLoadKey(); + if (!expectedSeed.equals(meta.get("seed")) || !expectedDim.equals(meta.get("dim"))) { + feedback.fail("Golden file is for dim=" + meta.get("dim") + " seed=" + meta.get("seed") + + " but this world is dim=" + expectedDim + " seed=" + expectedSeed + ". Aborting."); + return false; + } + if (!request.mcVersion().equals(meta.get("mc"))) { + feedback.warn("Golden was captured on mc=" + meta.get("mc") + ", running mc=" + request.mcVersion() + ". Diffs may be version-induced."); + } + + List body = orderedBody(lines); + List mismatches = new ArrayList<>(); + for (String line : body) { + int second = line.indexOf(' ', line.indexOf(' ') + 1); + String key = line.substring(0, second); + String golden = goldenChunks.get(key); + if (!line.equals(golden)) { + mismatches.add(key + (golden == null ? " (missing in golden)" : "")); + } + } + + String combined = combinedHash(body); + if (mismatches.isEmpty()) { + feedback.ok("GOLDEN MATCH: " + body.size() + "/" + goldenChunks.size() + " chunks, combined=" + shortHash(combined)); + IrisLogging.info("goldenhash MATCH: " + goldenFile.getName() + " combined=" + combined); + return true; + } + + feedback.fail("GOLDEN MISMATCH: " + mismatches.size() + "/" + body.size() + " chunks differ."); + for (int i = 0; i < Math.min(MAX_REPORTED_MISMATCHES, mismatches.size()); i++) { + feedback.fail(" chunk " + mismatches.get(i)); + } + if (mismatches.size() > MAX_REPORTED_MISMATCHES) { + feedback.fail(" ... and " + (mismatches.size() - MAX_REPORTED_MISMATCHES) + " more"); + } + + File current = new File(goldenFile.getParentFile(), goldenFile.getName() + ".new"); + List out = new ArrayList<>(body); + out.add("#combined=" + combined); + Files.write(current.toPath(), out, StandardCharsets.UTF_8); + feedback.warn("Current hashes written to " + current.getName()); + IrisLogging.info("goldenhash MISMATCH: " + mismatches.size() + "/" + body.size() + " -> " + current.getAbsolutePath()); + + progress.stage("Diagnosing"); + diagnose(mismatches.getFirst()); + return false; + } + + private void diagnose(String mismatchKey) { + try { + String[] parts = mismatchKey.trim().split(" "); + int chunkX = Integer.parseInt(parts[0]); + int chunkZ = Integer.parseInt(parts[1]); + + ChunkSnapshot first = source.generate(chunkX, chunkZ); + ChunkSnapshot second = source.generate(chunkX, chunkZ); + + int minY = first.minY(); + int maxY = first.maxY(); + List diffs = new ArrayList<>(); + for (int x = 0; x < 16 && diffs.size() < 50; x++) { + for (int z = 0; z < 16 && diffs.size() < 50; z++) { + for (int y = minY; y < maxY && diffs.size() < 50; y++) { + String a = first.block(x, y, z).key(); + String b = second.block(x, y, z).key(); + if (!a.equals(b)) { + diffs.add(x + " " + y + " " + z + " | " + a + " | " + b); + } + } + } + } + + List mantleDiffs = new ArrayList<>(); + String mantleStatus; + try { + EngineMantle engineMantle = engine.getMantle(); + int margin = Math.max(engineMantle.getRadius(), engineMantle.getRealRadius()) + 1; + for (int dx = -margin; dx <= margin; dx++) { + for (int dz = -margin; dz <= margin; dz++) { + engineMantle.getMantle().deleteChunk(chunkX + dx, chunkZ + dz); + } + } + ChunkSnapshot reset = source.generate(chunkX, chunkZ); + for (int x = 0; x < 16 && mantleDiffs.size() < 80; x++) { + for (int z = 0; z < 16 && mantleDiffs.size() < 80; z++) { + for (int y = minY; y < maxY && mantleDiffs.size() < 80; y++) { + String a = first.block(x, y, z).key(); + String b = reset.block(x, y, z).key(); + if (!a.equals(b)) { + mantleDiffs.add(x + " " + y + " " + z + " | scan: " + a + " | mantle-reset: " + b); + } + } + } + } + mantleStatus = mantleDiffs.isEmpty() ? "STABLE (mantle rebuild reproduces scan output)" : "DIVERGED (" + mantleDiffs.size() + "+ diffs - mantle build is state/order dependent)"; + } catch (Throwable t) { + mantleDiffs.clear(); + mantleStatus = "SKIPPED (" + t.getClass().getSimpleName() + ")"; + } + + List report = new ArrayList<>(); + report.add("#goldenhash diagnosis chunk=" + chunkX + "," + chunkZ); + report.add("#repeat-generation: " + (diffs.isEmpty() ? "STABLE (nondeterminism is order/state-dependent, not per-call)" : "UNSTABLE (" + diffs.size() + "+ diffs between two back-to-back generations)")); + report.addAll(diffs); + report.add("#mantle-reset regeneration: " + mantleStatus); + report.addAll(mantleDiffs); + report.add("#full non-air dump of generation 1 follows (x y z state)"); + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + for (int y = minY; y < maxY; y++) { + String state = first.block(x, y, z).key(); + if (!state.equals("minecraft:air") && !state.equals("minecraft:cave_air") && !state.equals("minecraft:void_air")) { + report.add(x + " " + y + " " + z + " " + state); + } + } + } + } + + File diag = new File(goldenFile.getParentFile(), goldenFile.getName() + ".diag-c" + chunkX + "x" + chunkZ + ".txt"); + Files.write(diag.toPath(), report, StandardCharsets.UTF_8); + String repeatPart = diffs.isEmpty() ? "Repeat-gen STABLE" : "Repeat-gen UNSTABLE (" + diffs.size() + "+ block diffs)"; + String mantlePart = "mantle-reset " + mantleStatus; + if (diffs.isEmpty() && mantleDiffs.isEmpty() && mantleStatus.startsWith("STABLE")) { + feedback.warn(repeatPart + ", " + mantlePart + " -> " + diag.getName()); + } else { + feedback.fail(repeatPart + ", " + mantlePart + " -> " + diag.getName()); + } + IrisLogging.info("goldenhash diag: chunk=" + chunkX + "," + chunkZ + " repeatStable=" + diffs.isEmpty() + " -> " + diag.getAbsolutePath()); + } catch (Throwable e) { + IrisLogging.reportError(e); + feedback.fail("Diagnosis failed: " + e.getMessage()); + } + } + + private void writeDeepDump(int chunkX, int chunkZ, ChunkSnapshot snapshot) throws IOException { + File dir = new File(goldenFile.getParentFile(), goldenFile.getName() + (goldenFile.exists() ? ".deep-verify" : ".deep")); + dir.mkdirs(); + int minY = snapshot.minY(); + int maxY = snapshot.maxY(); + List out = new ArrayList<>(); + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + for (int y = minY; y < maxY; y++) { + String state = snapshot.block(x, y, z).key(); + if (!state.equals("minecraft:air") && !state.equals("minecraft:cave_air") && !state.equals("minecraft:void_air")) { + out.add(x + " " + y + " " + z + " " + state); + } + } + } + } + Files.write(new File(dir, chunkX + "_" + chunkZ + ".txt").toPath(), out, StandardCharsets.UTF_8); + } + + private List orderedBody(Map lines) { + Map sorted = new TreeMap<>(lines); + return new ArrayList<>(sorted.values()); + } + + private String combinedHash(List body) { + MessageDigest digest = sha256(); + for (String line : body) { + digest.update((line + "\n").getBytes(StandardCharsets.UTF_8)); + } + return HexFormat.of().formatHex(digest.digest()); + } + + private static String shortHash(String hex) { + return hex.substring(0, 12); + } + + private static long chunkKey(int x, int z) { + return (((long) x) << 32) ^ (z & 0xFFFFFFFFL); + } + + private static MessageDigest sha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/runtime/GoldenHashScanner.java b/core/src/main/java/art/arcane/iris/core/runtime/GoldenHashScanner.java index 4351fdcbe..1a1ee4174 100644 --- a/core/src/main/java/art/arcane/iris/core/runtime/GoldenHashScanner.java +++ b/core/src/main/java/art/arcane/iris/core/runtime/GoldenHashScanner.java @@ -18,76 +18,47 @@ package art.arcane.iris.core.runtime; -import art.arcane.iris.spi.IrisLogging; -import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.engine.data.chunk.TerrainChunk; import art.arcane.iris.engine.framework.Engine; -import art.arcane.iris.engine.mantle.EngineMantle; +import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.PlatformBiome; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.common.format.C; -import art.arcane.iris.util.common.parallel.MultiBurst; import art.arcane.iris.util.common.plugin.VolmitSender; -import art.arcane.volmlib.util.mantle.runtime.Mantle; import org.bukkit.Bukkit; import org.bukkit.World; -import java.io.File; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.HexFormat; -import java.util.IdentityHashMap; -import java.util.List; -import java.util.Map; -import java.util.TreeMap; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.Semaphore; -import java.util.concurrent.atomic.AtomicInteger; - public final class GoldenHashScanner { - private static final AtomicInteger ACTIVE_SCANS = new AtomicInteger(0); - private static final String FORMAT = "iris-goldenhash v1"; - - public static boolean isScanActive() { - return ACTIVE_SCANS.get() > 0; - } - private static final int BIOME_STEP = 4; - private static final int MAX_REPORTED_MISMATCHES = 10; - private static final byte[] NULL_BIOME = "null\n".getBytes(StandardCharsets.UTF_8); - private final World world; private final Engine engine; private final VolmitSender sender; - private final int centerChunkX; - private final int centerChunkZ; - private final int radius; - private final boolean resetMantle; - private final int threads; - private final boolean deep; - private final File goldenFile; private final ChunkJobReporter reporter; + private final GoldenHashEngine hashEngine; public GoldenHashScanner(World world, Engine engine, VolmitSender sender, int centerChunkX, int centerChunkZ, int radius, boolean resetMantle, int threads, boolean deep) { this.world = world; this.engine = engine; this.sender = sender; - this.centerChunkX = centerChunkX; - this.centerChunkZ = centerChunkZ; - this.radius = Math.max(0, radius); - this.resetMantle = resetMantle; - this.threads = Math.max(1, threads); - this.deep = deep; - this.goldenFile = IrisPlatforms.get().dataFile("golden", engine.getDimension().getLoadKey() - + "-s" + world.getSeed() - + "-c" + centerChunkX + "x" + centerChunkZ - + "-r" + this.radius + ".hashes"); this.reporter = new ChunkJobReporter(sender, "GoldenHash", world); + GoldenHashEngine.Request request = new GoldenHashEngine.Request( + world.getName(), + world.getSeed(), + Bukkit.getBukkitVersion(), + world.getMinHeight(), + world.getMaxHeight(), + centerChunkX, + centerChunkZ, + radius, + threads, + GoldenHashEngine.Mode.AUTO, + resetMantle, + deep, + "null"); + this.hashEngine = new GoldenHashEngine(engine, request, IrisPlatforms.get().dataFolder("golden"), this::snapshot, feedback(), progress()); + } + + public static boolean isScanActive() { + return GoldenHashEngine.isActive(); } public void start() { @@ -98,334 +69,75 @@ public final class GoldenHashScanner { } private void run() { - boolean error = false; - ACTIVE_SCANS.incrementAndGet(); + boolean ok = false; try { - if (resetMantle) { - reporter.setStage("Resetting mantle"); - resetMantleFull(); - } - - List targets = ChunkJobReporter.orderedTargets(centerChunkX, centerChunkZ, radius); - reporter.setTotal(targets.size()); - reporter.setStage("Generating"); - Map lines = scan(targets); - - if (lines.size() != targets.size()) { - sender.sendMessage(C.RED + "GoldenHash aborted: " + (targets.size() - lines.size()) + " chunk(s) failed to generate. No golden file written."); - error = true; - return; - } - - reporter.setStage("Comparing"); - if (goldenFile.exists()) { - error = !verify(lines); - } else { - capture(lines); - } - } catch (Throwable e) { - IrisLogging.reportError(e); - error = true; + ok = hashEngine.run(); } finally { - ACTIVE_SCANS.decrementAndGet(); - reporter.finish(error); + reporter.finish(!ok); } } - private void resetMantleFull() { - Mantle mantle = engine.getMantle().getMantle(); - mantle.saveAll(); - File folder = mantle.getDataFolder(); - File[] files = folder.listFiles(); - if (files != null) { - for (File file : files) { - if (file.isFile()) { - file.delete(); - } - } - } - } - - private Map scan(List targets) throws InterruptedException { - Map lines = new ConcurrentHashMap<>(); - Semaphore inFlight = new Semaphore(threads); - CountDownLatch done = new CountDownLatch(targets.size()); - - for (int[] target : targets) { - int chunkX = target[0]; - int chunkZ = target[1]; - - inFlight.acquire(); - MultiBurst.burst.lazy(() -> { - boolean ok = false; - try { - TerrainChunk buffer = TerrainChunk.create(world); - engine.generate(chunkX << 4, chunkZ << 4, buffer, false); - lines.put(chunkKey(chunkX, chunkZ), hashChunk(chunkX, chunkZ, buffer)); - if (deep) { - writeDeepDump(chunkX, chunkZ, buffer); - } - ok = true; - } catch (Throwable e) { - IrisLogging.reportError(e); - } finally { - reporter.countApplied(ok); - inFlight.release(); - done.countDown(); - } - }); - } - - done.await(); - return lines; - } - - private String hashChunk(int chunkX, int chunkZ, TerrainChunk buffer) { - MessageDigest blockDigest = sha256(); - MessageDigest biomeDigest = sha256(); - int minY = buffer.getMinHeight(); - int maxY = buffer.getMaxHeight(); - IdentityHashMap blockCache = new IdentityHashMap<>(); - Map biomeCache = new HashMap<>(); - - for (int x = 0; x < 16; x++) { - for (int z = 0; z < 16; z++) { - for (int y = minY; y < maxY; y++) { - PlatformBlockState data = buffer.getBlockData(x, y, z); - byte[] bytes = blockCache.computeIfAbsent(data, (PlatformBlockState d) -> (d.key() + "\n").getBytes(StandardCharsets.UTF_8)); - blockDigest.update(bytes); - } - } - } - - for (int x = 0; x < 16; x += BIOME_STEP) { - for (int z = 0; z < 16; z += BIOME_STEP) { - for (int y = minY; y < maxY; y += BIOME_STEP) { - PlatformBiome biome = buffer.getBiome(x, y, z); - byte[] bytes = biome == null - ? NULL_BIOME - : biomeCache.computeIfAbsent(biome, (PlatformBiome b) -> (b.key() + "\n").getBytes(StandardCharsets.UTF_8)); - biomeDigest.update(bytes); - } - } - } - - return chunkX + " " + chunkZ + " " - + HexFormat.of().formatHex(blockDigest.digest()) + " " - + HexFormat.of().formatHex(biomeDigest.digest()); - } - - private void capture(Map lines) throws IOException { - List body = orderedBody(lines); - String combined = combinedHash(body); - List out = new ArrayList<>(); - out.add("#" + FORMAT); - out.add("#world=" + world.getName()); - out.add("#dim=" + engine.getDimension().getLoadKey()); - out.add("#seed=" + world.getSeed()); - out.add("#mc=" + Bukkit.getBukkitVersion()); - out.add("#minY=" + world.getMinHeight() + " maxY=" + world.getMaxHeight()); - out.add("#center=" + centerChunkX + "," + centerChunkZ); - out.add("#radius=" + radius); - out.addAll(body); - out.add("#combined=" + combined); - Files.write(goldenFile.toPath(), out, StandardCharsets.UTF_8); - - sender.sendMessage(C.GREEN + "Golden captured: " + C.GOLD + body.size() + " chunks" + C.GREEN + " combined=" + C.GOLD + shortHash(combined)); - sender.sendMessage(C.GRAY + goldenFile.getAbsolutePath()); - IrisLogging.info("goldenhash captured: " + goldenFile.getAbsolutePath() + " combined=" + combined); - } - - private boolean verify(Map lines) throws IOException { - List existing = Files.readAllLines(goldenFile.toPath(), StandardCharsets.UTF_8); - Map meta = new HashMap<>(); - Map goldenChunks = new HashMap<>(); - for (String line : existing) { - if (line.startsWith("#")) { - int eq = line.indexOf('='); - if (eq > 0) { - meta.put(line.substring(1, eq), line.substring(eq + 1)); - } - } else if (!line.isBlank()) { - int second = line.indexOf(' ', line.indexOf(' ') + 1); - goldenChunks.put(line.substring(0, second), line); - } - } - - String expectedSeed = String.valueOf(world.getSeed()); - String expectedDim = engine.getDimension().getLoadKey(); - if (!expectedSeed.equals(meta.get("seed")) || !expectedDim.equals(meta.get("dim"))) { - sender.sendMessage(C.RED + "Golden file is for dim=" + meta.get("dim") + " seed=" + meta.get("seed") - + " but this world is dim=" + expectedDim + " seed=" + expectedSeed + ". Aborting."); - return false; - } - String mc = Bukkit.getBukkitVersion(); - if (!mc.equals(meta.get("mc"))) { - sender.sendMessage(C.YELLOW + "Golden was captured on mc=" + meta.get("mc") + ", running mc=" + mc + ". Diffs may be version-induced."); - } - - List body = orderedBody(lines); - List mismatches = new ArrayList<>(); - for (String line : body) { - int second = line.indexOf(' ', line.indexOf(' ') + 1); - String key = line.substring(0, second); - String golden = goldenChunks.get(key); - if (!line.equals(golden)) { - mismatches.add(key + (golden == null ? " (missing in golden)" : "")); - } - } - - String combined = combinedHash(body); - if (mismatches.isEmpty()) { - sender.sendMessage(C.GREEN + "GOLDEN MATCH: " + C.GOLD + body.size() + "/" + goldenChunks.size() + C.GREEN - + " chunks, combined=" + C.GOLD + shortHash(combined)); - IrisLogging.info("goldenhash MATCH: " + goldenFile.getName() + " combined=" + combined); - return true; - } - - sender.sendMessage(C.RED + "GOLDEN MISMATCH: " + mismatches.size() + "/" + body.size() + " chunks differ."); - for (int i = 0; i < Math.min(MAX_REPORTED_MISMATCHES, mismatches.size()); i++) { - sender.sendMessage(C.RED + " chunk " + mismatches.get(i)); - } - if (mismatches.size() > MAX_REPORTED_MISMATCHES) { - sender.sendMessage(C.RED + " ... and " + (mismatches.size() - MAX_REPORTED_MISMATCHES) + " more"); - } - - File current = new File(goldenFile.getParentFile(), goldenFile.getName() + ".new"); - List out = new ArrayList<>(body); - out.add("#combined=" + combined); - Files.write(current.toPath(), out, StandardCharsets.UTF_8); - sender.sendMessage(C.YELLOW + "Current hashes written to " + current.getName()); - IrisLogging.info("goldenhash MISMATCH: " + mismatches.size() + "/" + body.size() + " -> " + current.getAbsolutePath()); - - reporter.setStage("Diagnosing"); - diagnose(mismatches.getFirst()); - return false; - } - - private void diagnose(String mismatchKey) { - try { - String[] parts = mismatchKey.trim().split(" "); - int chunkX = Integer.parseInt(parts[0]); - int chunkZ = Integer.parseInt(parts[1]); - - TerrainChunk first = TerrainChunk.create(world); - engine.generate(chunkX << 4, chunkZ << 4, first, false); - TerrainChunk second = TerrainChunk.create(world); - engine.generate(chunkX << 4, chunkZ << 4, second, false); - - int minY = first.getMinHeight(); - int maxY = first.getMaxHeight(); - List diffs = new ArrayList<>(); - for (int x = 0; x < 16 && diffs.size() < 50; x++) { - for (int z = 0; z < 16 && diffs.size() < 50; z++) { - for (int y = minY; y < maxY && diffs.size() < 50; y++) { - String a = first.getBlockData(x, y, z).key(); - String b = second.getBlockData(x, y, z).key(); - if (!a.equals(b)) { - diffs.add(x + " " + y + " " + z + " | " + a + " | " + b); - } - } - } + private GoldenHashEngine.ChunkSnapshot snapshot(int chunkX, int chunkZ) throws Exception { + TerrainChunk buffer = TerrainChunk.create(world); + engine.generate(chunkX << 4, chunkZ << 4, buffer, false); + return new GoldenHashEngine.ChunkSnapshot() { + @Override + public int minY() { + return buffer.getMinHeight(); } - EngineMantle engineMantle = engine.getMantle(); - int margin = Math.max(engineMantle.getRadius(), engineMantle.getRealRadius()) + 1; - for (int dx = -margin; dx <= margin; dx++) { - for (int dz = -margin; dz <= margin; dz++) { - engineMantle.getMantle().deleteChunk(chunkX + dx, chunkZ + dz); - } - } - TerrainChunk reset = TerrainChunk.create(world); - engine.generate(chunkX << 4, chunkZ << 4, reset, false); - List mantleDiffs = new ArrayList<>(); - for (int x = 0; x < 16 && mantleDiffs.size() < 80; x++) { - for (int z = 0; z < 16 && mantleDiffs.size() < 80; z++) { - for (int y = minY; y < maxY && mantleDiffs.size() < 80; y++) { - String a = first.getBlockData(x, y, z).key(); - String b = reset.getBlockData(x, y, z).key(); - if (!a.equals(b)) { - mantleDiffs.add(x + " " + y + " " + z + " | scan: " + a + " | mantle-reset: " + b); - } - } - } + @Override + public int maxY() { + return buffer.getMaxHeight(); } - List report = new ArrayList<>(); - report.add("#goldenhash diagnosis chunk=" + chunkX + "," + chunkZ); - report.add("#repeat-generation: " + (diffs.isEmpty() ? "STABLE (nondeterminism is order/state-dependent, not per-call)" : "UNSTABLE (" + diffs.size() + "+ diffs between two back-to-back generations)")); - report.addAll(diffs); - report.add("#mantle-reset regeneration: " + (mantleDiffs.isEmpty() ? "STABLE (mantle rebuild reproduces scan output)" : "DIVERGED (" + mantleDiffs.size() + "+ diffs - mantle build is state/order dependent)")); - report.addAll(mantleDiffs); - report.add("#full non-air dump of generation 1 follows (x y z state)"); - for (int x = 0; x < 16; x++) { - for (int z = 0; z < 16; z++) { - for (int y = minY; y < maxY; y++) { - String state = first.getBlockData(x, y, z).key(); - if (!state.equals("minecraft:air") && !state.equals("minecraft:cave_air") && !state.equals("minecraft:void_air")) { - report.add(x + " " + y + " " + z + " " + state); - } - } - } + @Override + public PlatformBlockState block(int x, int y, int z) { + return buffer.getBlockData(x, y, z); } - File diag = new File(goldenFile.getParentFile(), goldenFile.getName() + ".diag-c" + chunkX + "x" + chunkZ + ".txt"); - Files.write(diag.toPath(), report, StandardCharsets.UTF_8); - sender.sendMessage((diffs.isEmpty() ? C.YELLOW + "Repeat-gen STABLE" : C.RED + "Repeat-gen UNSTABLE (" + diffs.size() + "+ block diffs)") - + C.GRAY + ", " + (mantleDiffs.isEmpty() ? C.YELLOW + "mantle-reset STABLE" : C.RED + "mantle-reset DIVERGED (" + mantleDiffs.size() + "+ diffs)") - + C.GRAY + " -> " + diag.getName()); - IrisLogging.info("goldenhash diag: chunk=" + chunkX + "," + chunkZ + " repeatStable=" + diffs.isEmpty() + " -> " + diag.getAbsolutePath()); - } catch (Throwable e) { - IrisLogging.reportError(e); - sender.sendMessage(C.RED + "Diagnosis failed: " + e.getMessage()); - } - } - - private List orderedBody(Map lines) { - Map sorted = new TreeMap<>(lines); - return new ArrayList<>(sorted.values()); - } - - private String combinedHash(List body) { - MessageDigest digest = sha256(); - for (String line : body) { - digest.update((line + "\n").getBytes(StandardCharsets.UTF_8)); - } - return HexFormat.of().formatHex(digest.digest()); - } - - private void writeDeepDump(int chunkX, int chunkZ, TerrainChunk buffer) throws IOException { - File dir = new File(goldenFile.getParentFile(), goldenFile.getName() + (goldenFile.exists() ? ".deep-verify" : ".deep")); - dir.mkdirs(); - int minY = buffer.getMinHeight(); - int maxY = buffer.getMaxHeight(); - List out = new ArrayList<>(); - for (int x = 0; x < 16; x++) { - for (int z = 0; z < 16; z++) { - for (int y = minY; y < maxY; y++) { - String state = buffer.getBlockData(x, y, z).key(); - if (!state.equals("minecraft:air") && !state.equals("minecraft:cave_air") && !state.equals("minecraft:void_air")) { - out.add(x + " " + y + " " + z + " " + state); - } - } + @Override + public PlatformBiome biome(int x, int y, int z) { + return buffer.getBiome(x, y, z); } - } - Files.write(new File(dir, chunkX + "_" + chunkZ + ".txt").toPath(), out, StandardCharsets.UTF_8); + }; } - private static String shortHash(String hex) { - return hex.substring(0, 12); + private GoldenHashEngine.Feedback feedback() { + return new GoldenHashEngine.Feedback() { + @Override + public void ok(String message) { + sender.sendMessage(C.GREEN + message); + } + + @Override + public void warn(String message) { + sender.sendMessage(C.YELLOW + message); + } + + @Override + public void fail(String message) { + sender.sendMessage(C.RED + message); + } + }; } - private static long chunkKey(int x, int z) { - return (((long) x) << 32) ^ (z & 0xFFFFFFFFL); - } + private GoldenHashEngine.Progress progress() { + return new GoldenHashEngine.Progress() { + @Override + public void stage(String stage) { + reporter.setStage(stage); + } - private static MessageDigest sha256() { - try { - return MessageDigest.getInstance("SHA-256"); - } catch (NoSuchAlgorithmException e) { - throw new IllegalStateException(e); - } + @Override + public void total(int total) { + reporter.setTotal(total); + } + + @Override + public void chunkDone(int chunkX, int chunkZ, boolean ok, int done, int total) { + reporter.countApplied(ok); + } + }; } } diff --git a/core/src/main/java/art/arcane/iris/core/safeguard/Mode.java b/core/src/main/java/art/arcane/iris/core/safeguard/Mode.java index fd398c015..5ff90ec79 100644 --- a/core/src/main/java/art/arcane/iris/core/safeguard/Mode.java +++ b/core/src/main/java/art/arcane/iris/core/safeguard/Mode.java @@ -1,16 +1,13 @@ package art.arcane.iris.core.safeguard; -import art.arcane.iris.BuildConstants; import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.core.splash.IrisSplashComposer; import art.arcane.iris.core.splash.IrisSplashRenderer; import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.util.common.format.C; -import art.arcane.volmlib.util.format.Form; import org.bukkit.Bukkit; -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; import java.util.Locale; public enum Mode { @@ -54,36 +51,44 @@ public enum Mode { } public void splash() { - String padd = Form.repeat(" ", 4); - String padd2 = Form.repeat(" ", 4); String version = BukkitPlatform.plugin().getDescription().getVersion(); - String releaseTrain = getReleaseTrain(version); - String serverVersion = getServerVersion(); - String startupDate = getStartupDate(); - int javaVersion = getJavaVersion(); - String[] splash = IrisSplashRenderer.render(this::splashTone); + String[] info = IrisSplashComposer.composeInfo(version, getServerVersion(), infoStyle()); + IrisLogging.info(IrisSplashComposer.compose(splash, info)); + } - String[] info = new String[]{ - "", - padd2 + color + " Iris, " + C.AQUA + "Dimension Engine " + C.RED + "[" + releaseTrain + " RC.1.1.6]", - padd2 + C.GRAY + " Version: " + color + version, - padd2 + C.GRAY + " By: " + color + "Volmit Software (Arcane Arts)", - padd2 + C.GRAY + " Server: " + color + serverVersion, - padd2 + C.GRAY + " Java: " + color + javaVersion + C.GRAY + " | Date: " + color + startupDate, - padd2 + C.GRAY + " Commit: " + color + BuildConstants.COMMIT + C.GRAY + "/" + color + BuildConstants.ENVIRONMENT, - "", - "", - "", - "" + private IrisSplashComposer.InfoStyle infoStyle() { + return new IrisSplashComposer.InfoStyle() { + @Override + public String linePrefix() { + return " ".repeat(4); + } + + @Override + public String title(String text) { + return color + text; + } + + @Override + public String subtitle(String text) { + return C.AQUA + text; + } + + @Override + public String tag(String text) { + return C.RED + text; + } + + @Override + public String label(String text) { + return C.GRAY + text; + } + + @Override + public String value(String text) { + return color + text; + } }; - - StringBuilder builder = new StringBuilder("\n\n"); - for (int i = 0; i < splash.length; i++) { - builder.append(padd).append(splash[i]).append(info[i]).append("\n"); - } - - IrisLogging.info(builder.toString()); } private String splashTone(char glyph) { @@ -108,34 +113,4 @@ public enum Mode { } return version; } - - private int getJavaVersion() { - String version = System.getProperty("java.version"); - if (version.startsWith("1.")) { - version = version.substring(2, 3); - } else { - int dot = version.indexOf('.'); - if (dot != -1) { - version = version.substring(0, dot); - } - } - return Integer.parseInt(version); - } - - private String getStartupDate() { - return LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE); - } - - private String getReleaseTrain(String version) { - String value = version; - int suffixIndex = value.indexOf('-'); - if (suffixIndex >= 0) { - value = value.substring(0, suffixIndex); - } - String[] split = value.split("\\."); - if (split.length >= 2) { - return split[0] + "." + split[1]; - } - return value; - } } 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 655f075ad..85c0842c6 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 @@ -26,9 +26,11 @@ import art.arcane.iris.core.ServerConfigurator; import art.arcane.iris.core.lifecycle.WorldLifecycleService; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.pack.IrisPack; +import art.arcane.iris.core.pack.PackDownloader; import art.arcane.iris.core.pack.PackValidationRegistry; import art.arcane.iris.core.pack.PackValidationResult; import art.arcane.iris.core.project.IrisProject; +import art.arcane.iris.core.project.IrisProjectCopier; import art.arcane.iris.core.runtime.TransientWorldCleanupSupport; import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.engine.data.cache.AtomicCache; @@ -36,7 +38,6 @@ import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.platform.PlatformChunkGenerator; import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.exceptions.IrisException; -import art.arcane.volmlib.util.format.Form; import art.arcane.volmlib.util.io.IO; import art.arcane.volmlib.util.json.JSONException; import art.arcane.volmlib.util.json.JSONObject; @@ -52,7 +53,6 @@ import java.io.File; import java.io.IOException; import java.util.LinkedHashSet; import java.util.List; -import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.function.Consumer; @@ -259,99 +259,12 @@ public class StudioSVC implements IrisService { } public void download(VolmitSender sender, String repo, String branch, boolean forceOverwrite, boolean directUrl) throws JsonSyntaxException, IOException { - String url = directUrl ? branch : "https://codeload.github.com/" + repo + "/zip/refs/heads/" + branch; - sender.sendMessage("Downloading " + url + " "); //The extra space stops a bug in adventure API from repeating the last letter of the URL - File zip = art.arcane.iris.util.common.misc.WebCache.getNonCachedFile("pack-" + repo, url); - File temp = art.arcane.iris.util.common.misc.WebCache.getTemp(); - File work = new File(temp, "dl-" + UUID.randomUUID()); - File packs = getWorkspaceFolder(); + String key = PackDownloader.download(getWorkspaceFolder(), repo, branch, forceOverwrite, directUrl, sender::sendMessage); - if (zip == null || !zip.exists()) { - sender.sendMessage("Failed to find pack at " + url); - sender.sendMessage("Make sure you specified the correct repo and branch!"); - sender.sendMessage("For example: /iris download overworld branch=stable"); - return; - } - sender.sendMessage("Unpacking " + repo); - try { - ZipUtil.unpack(zip, work); - } catch (Throwable e) { - IrisLogging.reportError(e); - e.printStackTrace(); - sender.sendMessage( - """ - Issue when unpacking. Please check/do the following: - 1. Do you have a functioning internet connection? - 2. Did the download corrupt? - 3. Try deleting the */plugins/iris/packs folder and re-download. - 4. Download the pack from the GitHub repo: https://github.com/IrisDimensions/overworld - 5. Contact support (if all other options do not help)""" - ); - } - File dir = null; - File[] zipFiles = work.listFiles(); - - if (zipFiles == null) { - sender.sendMessage("No files were extracted from the zip file."); + if (key == null) { return; } - try { - dir = zipFiles.length > 1 ? work : zipFiles[0].isDirectory() ? zipFiles[0] : null; - } catch (NullPointerException e) { - IrisLogging.reportError(e); - sender.sendMessage("Error when finding home directory. Are there any non-text characters in the file name?"); - return; - } - - if (dir == null) { - sender.sendMessage("Invalid Format. Missing root folder or too many folders!"); - return; - } - - IrisData data = IrisData.get(dir); - String[] dimensions = data.getDimensionLoader().getPossibleKeys(); - - if (dimensions == null || dimensions.length == 0) { - sender.sendMessage("No dimension file found in the extracted zip file."); - sender.sendMessage("Check it is there on GitHub and report this to staff!"); - } else if (dimensions.length != 1) { - sender.sendMessage("Dimensions folder must have 1 file in it"); - return; - } - - IrisDimension d = data.getDimensionLoader().load(dimensions[0]); - data.close(); - - if (d == null) { - sender.sendMessage("Invalid dimension (folder) in dimensions folder"); - return; - } - - String key = d.getLoadKey(); - sender.sendMessage("Importing " + d.getName() + " (" + key + ")"); - File packEntry = new File(packs, key); - - if (forceOverwrite) { - IO.delete(packEntry); - } - - if (IrisData.loadAnyDimension(key, null) != null) { - sender.sendMessage("Another dimension in the packs folder is already using the key " + key + " IMPORT FAILED!"); - return; - } - - if (packEntry.exists() && packEntry.listFiles().length > 0) { - sender.sendMessage("Another pack is using the key " + key + ". IMPORT FAILED!"); - return; - } - - FileUtils.copyDirectory(dir, packEntry); - - IrisData.getLoaded(packEntry) - .ifPresent(IrisData::hotloaded); - - sender.sendMessage("Successfully Aquired " + d.getName()); ServerConfigurator.installDataPacks(true); } @@ -557,32 +470,7 @@ public class StudioSVC implements IrisService { } try { - FileUtils.copyDirectory(importPack, newPack, pathname -> !pathname.getAbsolutePath().contains(".git"), false); - } catch (IOException e) { - IrisLogging.reportError(e); - e.printStackTrace(); - } - - new File(importPack, existingPack + ".code-workspace").delete(); - File dimFile = new File(importPack, "dimensions/" + existingPack + ".json"); - File newDimFile = new File(newPack, "dimensions/" + newName + ".json"); - - try { - FileUtils.copyFile(dimFile, newDimFile); - } catch (IOException e) { - IrisLogging.reportError(e); - e.printStackTrace(); - } - - new File(newPack, "dimensions/" + existingPack + ".json").delete(); - - try { - JSONObject json = new JSONObject(IO.readAll(newDimFile)); - - if (json.has("name")) { - json.put("name", Form.capitalizeWords(newName.replaceAll("\\Q-\\E", " "))); - IO.writeAll(newDimFile, json.toString(4)); - } + IrisProjectCopier.copyProject(importPack, newPack, existingPack, newName); } catch (JSONException | IOException e) { IrisLogging.reportError(e); e.printStackTrace(); diff --git a/core/src/main/java/art/arcane/iris/core/splash/IrisSplashComposer.java b/core/src/main/java/art/arcane/iris/core/splash/IrisSplashComposer.java new file mode 100644 index 000000000..94c2b4471 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/splash/IrisSplashComposer.java @@ -0,0 +1,116 @@ +package art.arcane.iris.core.splash; + +import art.arcane.iris.BuildConstants; + +import java.io.File; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; + +public final class IrisSplashComposer { + public static final String RELEASE_TAG = "RC.1.1.6"; + private static final String SPLASH_PADDING = " ".repeat(4); + + private IrisSplashComposer() { + } + + public static String[] composeInfo(String version, String serverLine, InfoStyle style) { + String releaseTrain = releaseTrain(version); + String prefix = style.linePrefix(); + return new String[]{ + "", + prefix + style.title(" Iris, ") + style.subtitle("Dimension Engine ") + style.tag("[" + releaseTrain + " " + RELEASE_TAG + "]"), + prefix + style.label(" Version: ") + style.value(version), + prefix + style.label(" By: ") + style.value("Volmit Software (Arcane Arts)"), + prefix + style.label(" Server: ") + style.value(serverLine), + prefix + style.label(" Java: ") + style.value(String.valueOf(javaVersion())) + style.label(" | Date: ") + style.value(startupDate()), + prefix + style.label(" Commit: ") + style.value(BuildConstants.COMMIT) + style.label("/") + style.value(BuildConstants.ENVIRONMENT), + "", + "", + "", + "" + }; + } + + public static String compose(String[] splash, String[] info) { + StringBuilder builder = new StringBuilder("\n\n"); + for (int i = 0; i < splash.length; i++) { + builder.append(SPLASH_PADDING).append(splash[i]).append(info[i]).append('\n'); + } + return builder.toString(); + } + + public static List composePackLines(File packFolder, IrisSplashPackScanner.SplashPackErrorReporter reporter) { + List packs = IrisSplashPackScanner.collect(packFolder, reporter); + if (packs.isEmpty()) { + return List.of(); + } + + List lines = new ArrayList<>(packs.size() + 1); + lines.add("Custom Dimensions: " + packs.size()); + for (IrisSplashPackScanner.SplashPackMetadata pack : packs) { + lines.add(" " + pack.name() + " v" + pack.version()); + } + return lines; + } + + public static int javaVersion() { + String version = System.getProperty("java.version"); + if (version.startsWith("1.")) { + version = version.substring(2, 3); + } else { + int dot = version.indexOf('.'); + if (dot != -1) { + version = version.substring(0, dot); + } + } + return Integer.parseInt(version); + } + + public static String releaseTrain(String version) { + String value = version == null ? "unknown" : version; + int suffixIndex = value.indexOf('-'); + if (suffixIndex >= 0) { + value = value.substring(0, suffixIndex); + } + String[] split = value.split("\\."); + if (split.length >= 2) { + return split[0] + "." + split[1]; + } + return value; + } + + private static String startupDate() { + return LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE); + } + + public interface InfoStyle { + InfoStyle PLAIN = new InfoStyle() { + }; + + default String linePrefix() { + return ""; + } + + default String title(String text) { + return text; + } + + default String subtitle(String text) { + return text; + } + + default String tag(String text) { + return text; + } + + default String label(String text) { + return text; + } + + default String value(String text) { + return text; + } + } +} 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 21d697f9c..9c88d3ee7 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 @@ -26,11 +26,14 @@ import art.arcane.iris.core.IrisRuntimeSchedulerMode; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.gui.PregeneratorJob; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.pregenerator.PregenPerformanceProfile; import art.arcane.iris.core.pregenerator.PregenTask; import art.arcane.iris.core.pregenerator.PregeneratorMethod; +import art.arcane.iris.core.pregenerator.cache.PregenCache; import art.arcane.iris.core.project.IrisProject; import art.arcane.iris.core.pregenerator.methods.CachedPregenMethod; import art.arcane.iris.core.pregenerator.methods.HybridPregenMethod; +import art.arcane.iris.core.service.GlobalCacheSVC; import art.arcane.iris.core.service.StudioSVC; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.IrisDimension; @@ -53,7 +56,6 @@ import java.util.HashMap; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; /** @@ -66,7 +68,6 @@ public class IrisToolbelt { private static final Map worldMaintenanceDepth = new ConcurrentHashMap<>(); private static final Map worldMaintenanceMantleBypassDepth = new ConcurrentHashMap<>(); private static final Method BUKKIT_IS_STOPPING_METHOD = resolveBukkitIsStoppingMethod(); - private static final AtomicBoolean PREGEN_PROFILE_JVM_HINT_LOGGED = new AtomicBoolean(false); /** * Will find / download / search for the dimension or return null @@ -247,39 +248,24 @@ public class IrisToolbelt { IrisRuntimeSchedulerMode runtimeSchedulerMode = IrisRuntimeSchedulerMode.resolve(IrisSettings.get().getPregen()); useCachedWrapper = runtimeSchedulerMode != IrisRuntimeSchedulerMode.FOLIA; } - return new PregeneratorJob(task, useCachedWrapper ? new CachedPregenMethod(method, engine.getWorld().name()) : method, engine); + return new PregeneratorJob(task, useCachedWrapper ? new CachedPregenMethod(method, resolvePregenCache(engine.getWorld().name()), task) : method, engine); + } + + private static PregenCache resolvePregenCache(String worldName) { + PregenCache cache = IrisServices.get(GlobalCacheSVC.class).get(worldName); + if (cache == null) { + IrisLogging.debug("Could not find existing cache for " + worldName + " creating fallback"); + cache = GlobalCacheSVC.createDefault(worldName); + } + return cache; } public static boolean applyPregenPerformanceProfile() { - IrisSettings.IrisSettingsPerformance performance = IrisSettings.get().getPerformance(); - int previousNoiseCacheSize = performance.getNoiseCacheSize(); - int targetNoiseCacheSize = Math.max(previousNoiseCacheSize, 4_096); - boolean fastCacheEnabledBefore = Boolean.getBoolean("iris.cache.fast"); - boolean changed = false; - - if (targetNoiseCacheSize != previousNoiseCacheSize) { - performance.setNoiseCacheSize(targetNoiseCacheSize); - changed = true; - } - - if (!fastCacheEnabledBefore) { - System.setProperty("iris.cache.fast", "true"); - changed = true; - } - - if (PREGEN_PROFILE_JVM_HINT_LOGGED.compareAndSet(false, true) && !fastCacheEnabledBefore) { - IrisLogging.info("For startup-wide cache-fast coverage, set JVM argument: -Diris.cache.fast=true"); - } - - return changed; + return PregenPerformanceProfile.apply(); } public static void applyPregenPerformanceProfile(Engine engine) { - boolean changed = applyPregenPerformanceProfile(); - if (changed && engine != null) { - engine.hotloadComplex(); - IrisLogging.info("Pregen profile applied: noiseCacheSize=" + IrisSettings.get().getPerformance().getNoiseCacheSize() + " iris.cache.fast=" + Boolean.getBoolean("iris.cache.fast")); - } + PregenPerformanceProfile.apply(engine); } /** @@ -432,15 +418,26 @@ public class IrisToolbelt { return; } - String name = world.getName(); - int depth = worldMaintenanceDepth.computeIfAbsent(name, k -> new AtomicInteger()).incrementAndGet(); + beginWorldMaintenance(world.getName(), reason, bypassMantleStages); + } + + public static void beginWorldMaintenance(String worldName, String reason) { + beginWorldMaintenance(worldName, reason, false); + } + + public static void beginWorldMaintenance(String worldName, String reason, boolean bypassMantleStages) { + if (worldName == null) { + return; + } + + int depth = worldMaintenanceDepth.computeIfAbsent(worldName, k -> new AtomicInteger()).incrementAndGet(); if (bypassMantleStages) { - worldMaintenanceMantleBypassDepth.computeIfAbsent(name, k -> new AtomicInteger()).incrementAndGet(); + worldMaintenanceMantleBypassDepth.computeIfAbsent(worldName, k -> new AtomicInteger()).incrementAndGet(); } if (IrisSettings.get().getGeneral().isDebug()) { - IrisLogging.info("World maintenance enter: " + name + " reason=" + reason + " depth=" + depth + " bypassMantle=" + bypassMantleStages); + IrisLogging.info("World maintenance enter: " + worldName + " reason=" + reason + " depth=" + depth + " bypassMantle=" + bypassMantleStages); } else { - IrisLogging.debug("World maintenance enter: " + name + " reason=" + reason + " depth=" + depth + " bypassMantle=" + bypassMantleStages); + IrisLogging.debug("World maintenance enter: " + worldName + " reason=" + reason + " depth=" + depth + " bypassMantle=" + bypassMantleStages); } } @@ -449,50 +446,65 @@ public class IrisToolbelt { return; } - String name = world.getName(); - AtomicInteger depthCounter = worldMaintenanceDepth.get(name); + endWorldMaintenance(world.getName(), reason); + } + + public static void endWorldMaintenance(String worldName, String reason) { + if (worldName == null) { + return; + } + + AtomicInteger depthCounter = worldMaintenanceDepth.get(worldName); if (depthCounter == null) { return; } int depth = depthCounter.decrementAndGet(); if (depth <= 0) { - worldMaintenanceDepth.remove(name, depthCounter); + worldMaintenanceDepth.remove(worldName, depthCounter); depth = 0; } - AtomicInteger bypassCounter = worldMaintenanceMantleBypassDepth.get(name); + AtomicInteger bypassCounter = worldMaintenanceMantleBypassDepth.get(worldName); int bypassDepth = 0; if (bypassCounter != null) { bypassDepth = bypassCounter.decrementAndGet(); if (bypassDepth <= 0) { - worldMaintenanceMantleBypassDepth.remove(name, bypassCounter); + worldMaintenanceMantleBypassDepth.remove(worldName, bypassCounter); bypassDepth = 0; } } if (IrisSettings.get().getGeneral().isDebug()) { - IrisLogging.info("World maintenance exit: " + name + " reason=" + reason + " depth=" + depth + " bypassMantleDepth=" + bypassDepth); + IrisLogging.info("World maintenance exit: " + worldName + " reason=" + reason + " depth=" + depth + " bypassMantleDepth=" + bypassDepth); } else { - IrisLogging.debug("World maintenance exit: " + name + " reason=" + reason + " depth=" + depth + " bypassMantleDepth=" + bypassDepth); + IrisLogging.debug("World maintenance exit: " + worldName + " reason=" + reason + " depth=" + depth + " bypassMantleDepth=" + bypassDepth); } } public static boolean isWorldMaintenanceActive(World world) { - if (world == null) { + return world != null && isWorldMaintenanceActive(world.getName()); + } + + public static boolean isWorldMaintenanceActive(String worldName) { + if (worldName == null) { return false; } - AtomicInteger counter = worldMaintenanceDepth.get(world.getName()); + AtomicInteger counter = worldMaintenanceDepth.get(worldName); return counter != null && counter.get() > 0; } public static boolean isWorldMaintenanceBypassingMantleStages(World world) { - if (world == null) { + return world != null && isWorldMaintenanceBypassingMantleStages(world.getName()); + } + + public static boolean isWorldMaintenanceBypassingMantleStages(String worldName) { + if (worldName == null) { return false; } - AtomicInteger counter = worldMaintenanceMantleBypassDepth.get(world.getName()); + AtomicInteger counter = worldMaintenanceMantleBypassDepth.get(worldName); return counter != null && counter.get() > 0; } 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 5dc0cb34a..639cea776 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisEngine.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisEngine.java @@ -218,7 +218,9 @@ public class IrisEngine implements Engine { currentMode.close(); } - J.a(() -> new IrisProject(getData().getDataFolder()).updateWorkspace()); + if (getWorld().hasRealWorld()) { + J.a(() -> new IrisProject(getData().getDataFolder()).updateWorkspace()); + } } private void setupEngine() { @@ -406,11 +408,13 @@ public class IrisEngine implements Engine { getTarget().setDimension(getData().getDimensionLoader().load(getDimension().getLoadKey())); prehotload(); setupEngine(); - J.a(() -> { - synchronized (ServerConfigurator.class) { - ServerConfigurator.installDataPacks(false); - } - }); + if (getWorld().hasRealWorld()) { + J.a(() -> { + synchronized (ServerConfigurator.class) { + ServerConfigurator.installDataPacks(false); + } + }); + } } @Override @@ -608,7 +612,7 @@ public class IrisEngine implements Engine { private boolean isPregeneratorActiveForThisWorld() { PregeneratorJob pregeneratorJob = PregeneratorJob.getInstance(); - return pregeneratorJob != null && getWorld().hasRealWorld() && pregeneratorJob.targetsWorld(getWorld().realWorld()); + return pregeneratorJob != null && pregeneratorJob.targetsWorldName(getWorld().name()); } private void savePrefetchOnce() { diff --git a/core/src/main/java/art/arcane/iris/engine/IrisWorldManager.java b/core/src/main/java/art/arcane/iris/engine/IrisWorldManager.java index e5b92b36e..73f725fcb 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisWorldManager.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisWorldManager.java @@ -458,7 +458,7 @@ public class IrisWorldManager extends EngineAssignedWorldManager { return false; } - return job.targetsWorld(world); + return job.targetsWorldName(world.getName()); } private Chunk[] getLoadedChunksSnapshot(World world) { 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 b12d9d494..48e05a65a 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 @@ -105,11 +105,13 @@ public class IrisDecorantActuator extends EngineAssignedActuator= 0 && height < output.getHeight()) { + getSurfaceDecorator().decorate(i, j, realX, realZ, output, biome, height, getEngine().getHeight() - height); + } if (cave != null && cave.getDecorators().isNotEmpty()) { - for (int k = height; k > 0; k--) { + for (int k = Math.min(height, output.getHeight() - 1); k > 0; k--) { solid = PREDICATE_SOLID.test(output.get(i, k, j)); if (solid) { diff --git a/core/src/main/java/art/arcane/iris/engine/decorator/DecoratorCore.java b/core/src/main/java/art/arcane/iris/engine/decorator/DecoratorCore.java index 28f0da421..b04f449bf 100644 --- a/core/src/main/java/art/arcane/iris/engine/decorator/DecoratorCore.java +++ b/core/src/main/java/art/arcane/iris/engine/decorator/DecoratorCore.java @@ -109,7 +109,7 @@ final class DecoratorCore { String half = IrisProceduralBlocks.propertyValue(bd, "half"); if (half != null) { try { - if (!caveSkipFluid || !B.isFluid(data.get(x, height + 2, z))) { + if (height + 2 < data.getHeight() && (!caveSkipFluid || !B.isFluid(data.get(x, height + 2, z)))) { data.set(x, height + 2, z, bd.withProperty("half", topHalfValue(half))); } } catch (Throwable e) { @@ -118,7 +118,7 @@ final class DecoratorCore { bd = bd.withProperty("half", bottomHalfValue(half)); } - if (B.isAir(data.get(x, height + 1, z))) { + if (height + 1 < data.getHeight() && B.isAir(data.get(x, height + 1, z))) { data.set(x, height + 1, z, fixFacesForHunk(bd, data, x, z, realX, height + 1, realZ, mantle)); } } @@ -135,6 +135,10 @@ final class DecoratorCore { int x, int z, int realX, int height, int realZ, Hunk data, RNG rng, IrisData irisData, boolean underwater, boolean caveSkipFluid, EngineMantle mantle) { + if (height < 0 || height >= data.getHeight()) { + return; + } + PlatformBlockState bdx = data.get(x, height, z); PlatformBlockState bd = decorator.pickBlockData(rng, irisData, realX, realZ); @@ -166,7 +170,7 @@ final class DecoratorCore { String half = bd == null ? null : IrisProceduralBlocks.propertyValue(bd, "half"); if (half != null) { try { - if (!caveSkipFluid || !B.isFluid(data.get(x, height + 2, z))) { + if (height + 2 < data.getHeight() && (!caveSkipFluid || !B.isFluid(data.get(x, height + 2, z)))) { data.set(x, height + 2, z, bd.withProperty("half", topHalfValue(half))); } } catch (Throwable e) { @@ -175,7 +179,7 @@ final class DecoratorCore { bd = bd.withProperty("half", bottomHalfValue(half)); } - if (B.isAir(data.get(x, height + 1, z))) { + if (height + 1 < data.getHeight() && B.isAir(data.get(x, height + 1, z))) { data.set(x, height + 1, z, fixFacesForHunk(bd, data, x, z, realX, height + 1, realZ, mantle)); } } @@ -196,6 +200,10 @@ final class DecoratorCore { static void placeStackUp(IrisDecorator decorator, int x, int z, int realX, int realZ, int height, int max, Hunk data, RNG rng, IrisData irisData, PlaceOpts opts) { + if (height < 0 || height >= data.getHeight()) { + return; + } + int effectiveMax = max; if (opts.underwater && height < opts.fluidHeight) { effectiveMax = opts.fluidHeight; @@ -232,6 +240,10 @@ final class DecoratorCore { break; } + if (height + 1 + i >= data.getHeight()) { + break; + } + if (opts.caveSkipFluid && B.isFluid(data.get(x, height + 1 + i, z))) { break; } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/Engine.java b/core/src/main/java/art/arcane/iris/engine/framework/Engine.java index a22b44563..a01d1511e 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/Engine.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/Engine.java @@ -58,7 +58,6 @@ import art.arcane.iris.util.common.plugin.VolmitSender; import art.arcane.volmlib.util.scheduling.ChronoLatch; import art.arcane.iris.util.common.scheduling.J; import art.arcane.iris.util.project.stream.ProceduralStream; -import org.bukkit.World; import org.jetbrains.annotations.Nullable; import java.awt.Color; @@ -660,15 +659,24 @@ public interface Engine extends DataProvider, Fallible, LootProvider, BlockUpdat } default void cleanupMantleChunk(int x, int z) { - World world = getWorld().realWorld(); - if (world != null && IrisToolbelt.isWorldMaintenanceActive(world)) { - PregeneratorJob pregeneratorJob = PregeneratorJob.getInstance(); - if (pregeneratorJob == null || !pregeneratorJob.targetsWorld(world)) { - return; - } + if (shouldSkipMaintenanceCleanup()) { + return; } if (IrisSettings.get().getPerformance().isTrimMantleInStudio() || !isStudio()) { getMantle().cleanupChunk(x, z); } } + + private boolean shouldSkipMaintenanceCleanup() { + IrisWorld world = getWorld(); + if (world == null) { + return false; + } + String worldName = world.name(); + if (!IrisToolbelt.isWorldMaintenanceActive(worldName)) { + return false; + } + PregeneratorJob pregeneratorJob = PregeneratorJob.getInstance(); + return pregeneratorJob == null || !pregeneratorJob.targetsWorldName(worldName); + } } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/EngineMode.java b/core/src/main/java/art/arcane/iris/engine/framework/EngineMode.java index 51ed92a5e..831592450 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/EngineMode.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/EngineMode.java @@ -98,7 +98,7 @@ public interface EngineMode extends Staged { } PregeneratorJob pregeneratorJob = PregeneratorJob.getInstance(); - boolean pregeneratorTargetsWorld = pregeneratorJob != null && pregeneratorJob.targetsWorld(getEngine().getWorld().realWorld()); + boolean pregeneratorTargetsWorld = pregeneratorJob != null && pregeneratorJob.targetsWorldName(getEngine().getWorld().name()); return shouldDisableContextCacheForMaintenance(maintenanceActive, pregeneratorTargetsWorld); } } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/placer/WorldObjectPlacer.java b/core/src/main/java/art/arcane/iris/engine/framework/placer/WorldObjectPlacer.java index 0f782a6e6..cf7940ab7 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/placer/WorldObjectPlacer.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/placer/WorldObjectPlacer.java @@ -57,9 +57,11 @@ public class WorldObjectPlacer implements IObjectPlacer { @Override public void set(int x, int y, int z, PlatformBlockState state) { BlockData d = (BlockData) state.nativeHandle(); - Block block = world.getBlockAt(x, y + world.getMinHeight(), z); + int worldY = y + world.getMinHeight(); + if (worldY < world.getMinHeight() || worldY >= world.getMaxHeight()) return; + Block block = world.getBlockAt(x, worldY, z); - if (y <= world.getMinHeight() || block.getType() == Material.BEDROCK) return; + if (block.getType() == Material.BEDROCK) return; InventorySlotType slot = null; if (BukkitBlockResolution.isStorageChest(d)) { slot = InventorySlotType.STORAGE; @@ -128,7 +130,9 @@ public class WorldObjectPlacer implements IObjectPlacer { @Override public void setTile(int xx, int yy, int zz, TileData tile) { - tile.toBukkitTry(world.getBlockAt(xx, yy + world.getMinHeight(), zz)); + int worldY = yy + world.getMinHeight(); + if (worldY < world.getMinHeight() || worldY >= world.getMaxHeight()) return; + tile.toBukkitTry(world.getBlockAt(xx, worldY, zz)); } @Override diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisAttributeModifier.java b/core/src/main/java/art/arcane/iris/engine/object/IrisAttributeModifier.java index 68d916e71..6e8ef0a4a 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisAttributeModifier.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisAttributeModifier.java @@ -19,12 +19,14 @@ package art.arcane.iris.engine.object; import art.arcane.iris.engine.object.annotations.*; +import art.arcane.iris.spi.IrisLogging; import art.arcane.volmlib.util.math.RNG; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import lombok.experimental.Accessors; import org.bukkit.NamespacedKey; +import org.bukkit.Registry; import org.bukkit.attribute.Attributable; import org.bukkit.attribute.Attribute; import org.bukkit.attribute.AttributeModifier; @@ -33,7 +35,9 @@ import org.bukkit.inventory.EquipmentSlotGroup; import org.bukkit.inventory.meta.ItemMeta; import java.util.Locale; +import java.util.Set; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; @Snippet("attribute-modifier") @Accessors(chain = true) @@ -42,17 +46,19 @@ import java.util.UUID; @Desc("Represents an attribute modifier for an item or an entity. This allows you to create modifications to basic game attributes such as MAX_HEALTH or ARMOR_VALUE.") @Data public class IrisAttributeModifier { + private static final Set WARNED_ATTRIBUTES = ConcurrentHashMap.newKeySet(); + @Required - @Desc("The Attribute type. This type is pulled from the game attributes. Zombie & Horse attributes will not work on non-zombie/horse entities.\nUsing an attribute on an item will have affects when held, or worn. There is no way to specify further granularity as the game picks this depending on the item type.") - private Attribute attribute = null; + @Desc("The Attribute type key such as max_health or minecraft:armor. This type is pulled from the game attributes. Zombie & Horse attributes will not work on non-zombie/horse entities.\nUsing an attribute on an item will have affects when held, or worn. There is no way to specify further granularity as the game picks this depending on the item type.") + private String attribute = null; @MinNumber(2) @Required @Desc("The Attribute Name is used internally only for the game. This value should be unique to all other attributes applied to this item/entity. It is not shown in game.") private String name = ""; - @Desc("The application operation (add number is default). Add Number adds to the default value. \nAdd scalar_1 will multiply by 1 for example if the health is 20 and you multiply_scalar_1 by 0.5, the health will result in 30, not 10. Use negative values to achieve that.") - private Operation operation = Operation.ADD_NUMBER; + @Desc("The application operation: ADD_NUMBER (default), ADD_SCALAR or MULTIPLY_SCALAR_1. Add Number adds to the default value. \nAdd scalar_1 will multiply by 1 for example if the health is 20 and you multiply_scalar_1 by 0.5, the health will result in 30, not 10. Use negative values to achieve that.") + private String operation = "ADD_NUMBER"; @Desc("Minimum amount for this modifier. Iris randomly chooses an amount, this is the minimum it can choose randomly for this attribute.") private double minAmount = 1; @@ -67,13 +73,21 @@ public class IrisAttributeModifier { public void apply(RNG rng, ItemMeta meta) { if (rng.nextDouble() < getChance()) { - meta.addAttributeModifier(getAttribute(), createModifier(rng)); + Attribute resolved = resolveAttribute(); + if (resolved == null) { + return; + } + meta.addAttributeModifier(resolved, createModifier(rng)); } } public void apply(RNG rng, Attributable meta) { if (rng.nextDouble() < getChance()) { - meta.getAttribute(getAttribute()).addModifier(createModifier(rng)); + Attribute resolved = resolveAttribute(); + if (resolved == null) { + return; + } + meta.getAttribute(resolved).addModifier(createModifier(rng)); } } @@ -81,9 +95,44 @@ public class IrisAttributeModifier { return rng.d(getMinAmount(), getMaxAmount()); } + private Attribute resolveAttribute() { + if (attribute == null || attribute.isBlank()) { + return null; + } + String value = attribute.trim().toLowerCase(Locale.ROOT); + Attribute resolved; + if (value.contains(":")) { + NamespacedKey key = NamespacedKey.fromString(value); + resolved = key == null ? null : Registry.ATTRIBUTE.get(key); + } else { + resolved = Registry.ATTRIBUTE.get(NamespacedKey.minecraft(value)); + if (resolved == null && value.startsWith("generic_")) { + resolved = Registry.ATTRIBUTE.get(NamespacedKey.minecraft(value.substring("generic_".length()))); + } + if (resolved == null && value.startsWith("generic.")) { + resolved = Registry.ATTRIBUTE.get(NamespacedKey.minecraft(value.substring("generic.".length()))); + } + } + if (resolved == null && WARNED_ATTRIBUTES.add(value)) { + IrisLogging.warn("Unknown attribute '" + attribute + "' in attribute-modifier '" + name + "'"); + } + return resolved; + } + + private Operation resolveOperation() { + if (operation == null || operation.isBlank()) { + return Operation.ADD_NUMBER; + } + try { + return Operation.valueOf(operation.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + return Operation.ADD_NUMBER; + } + } + private AttributeModifier createModifier(RNG rng) { NamespacedKey key = NamespacedKey.minecraft(generateModifierKey()); - return new AttributeModifier(key, getAmount(rng), getOperation(), EquipmentSlotGroup.ANY); + return new AttributeModifier(key, getAmount(rng), resolveOperation(), EquipmentSlotGroup.ANY); } private String generateModifierKey() { diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java b/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java index 63053b016..582840ff4 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java @@ -293,6 +293,19 @@ public class IrisBiome extends IrisRegistrant implements IRare { return resolved == null ? getDerivative() : resolved; } + public String getVanillaDerivativeKey() { + String resolved = namespacedBiomeKey(vanillaDerivative); + return resolved == null ? namespacedBiomeKey(derivative) : resolved; + } + + private static String namespacedBiomeKey(String key) { + if (key == null || key.isBlank()) { + return null; + } + String trimmed = key.trim(); + return trimmed.indexOf(':') >= 0 ? trimmed : "minecraft:" + trimmed; + } + private KList getBiomeScatterResolved() { return biomeScatterResolved.aquire(() -> resolveBiomeKeys(biomeScatter)); } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisTree.java b/core/src/main/java/art/arcane/iris/engine/object/IrisTree.java index b74147f6d..b4845bbfd 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisTree.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisTree.java @@ -38,8 +38,8 @@ import org.bukkit.TreeType; public class IrisTree { @Required @Desc("The types of trees overwritten by this object") - @ArrayType(min = 1, type = TreeType.class) - private KList treeTypes; + @ArrayType(min = 1, type = String.class) + private KList treeTypes = new KList<>(); @Desc("If enabled, overrides any TreeType") private boolean anyTree = false; @@ -71,6 +71,17 @@ public class IrisTree { } private boolean matchesType(TreeType type) { - return getTreeTypes().contains(type); + if (type == null) { + return false; + } + + String name = type.name(); + for (String i : getTreeTypes()) { + if (i != null && i.equalsIgnoreCase(name)) { + return true; + } + } + + return false; } } \ No newline at end of file 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 ccb80e59e..e3a66d57d 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 @@ -503,7 +503,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun World realWorld = this.world.realWorld(); PregeneratorJob pregeneratorJob = PregeneratorJob.getInstance(); - return realWorld != null && pregeneratorJob != null && pregeneratorJob.targetsWorld(realWorld); + return realWorld != null && pregeneratorJob != null && pregeneratorJob.targetsWorldName(realWorld.getName()); } @Override diff --git a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitChunkWriteTarget.java b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitChunkWriteTarget.java deleted file mode 100644 index adad6a64b..000000000 --- a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitChunkWriteTarget.java +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Iris is a World Generator for Minecraft Bukkit Servers - * Copyright (c) 2026 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.platform.bukkit; - -import art.arcane.iris.engine.data.chunk.TerrainChunk; -import art.arcane.iris.spi.ChunkWriteTarget; -import art.arcane.iris.spi.PlatformBiome; -import art.arcane.iris.spi.PlatformBlockState; - -/** - * Bukkit adapter for the hot-path chunk write surface backed by TerrainChunk. - */ -public final class BukkitChunkWriteTarget implements ChunkWriteTarget { - private final TerrainChunk terrain; - - public BukkitChunkWriteTarget(TerrainChunk terrain) { - this.terrain = terrain; - } - - @Override - public void setBlock(int x, int y, int z, PlatformBlockState state) { - terrain.setBlock(x, y, z, state); - } - - @Override - public void setBiome(int x, int y, int z, PlatformBiome biome) { - terrain.setBiome(x, y, z, biome); - } - - @Override - public int minHeight() { - return terrain.getMinHeight(); - } - - @Override - public int maxHeight() { - return terrain.getMaxHeight(); - } -} diff --git a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitPlatform.java b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitPlatform.java index 8a863215b..6d92e234f 100644 --- a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitPlatform.java +++ b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitPlatform.java @@ -24,7 +24,6 @@ import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.LogLevel; import art.arcane.iris.spi.PlatformBiome; import art.arcane.iris.spi.PlatformBiomeWriter; -import art.arcane.iris.spi.PlatformCapabilities; import art.arcane.iris.spi.PlatformRegistries; import art.arcane.iris.spi.PlatformScheduler; import art.arcane.iris.spi.PlatformStructureHooks; @@ -32,7 +31,6 @@ import art.arcane.iris.spi.PlatformWorld; import art.arcane.iris.util.common.misc.Bindings; import art.arcane.iris.util.common.plugin.VolmitPlugin; import art.arcane.iris.util.common.plugin.VolmitSender; -import art.arcane.iris.util.common.scheduling.J; import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.math.Vector3d; import org.bukkit.Bukkit; @@ -45,7 +43,6 @@ import org.bukkit.block.Biome; import org.bukkit.block.data.BlockData; import org.bukkit.entity.Entity; import org.bukkit.entity.EntityType; -import org.bukkit.entity.Player; import org.bukkit.event.Listener; import org.bukkit.event.entity.CreatureSpawnEvent; import org.bukkit.inventory.ItemStack; @@ -90,7 +87,6 @@ public final class BukkitPlatform implements IrisPlatform { private final BukkitRegistries registries = new BukkitRegistries(); private final BukkitScheduler scheduler = new BukkitScheduler(); - private final PlatformCapabilities capabilities = new BukkitCapabilities(); private final BukkitStructureHooks structureHooks = new BukkitStructureHooks(); private final BukkitBiomeWriter biomeWriter = new BukkitBiomeWriter(); @@ -244,11 +240,6 @@ public final class BukkitPlatform implements IrisPlatform { return scheduler; } - @Override - public PlatformCapabilities capabilities() { - return capabilities; - } - @Override public PlatformStructureHooks structureHooks() { return structureHooks; @@ -311,18 +302,6 @@ public final class BukkitPlatform implements IrisPlatform { return entity != null; } - @Override - public boolean giveItem(Object player, String itemKey, int amount) { - if (!(player instanceof Player bukkitPlayer) || itemKey == null || amount <= 0) { - return false; - } - Material material = Material.matchMaterial(itemKey); - if (material == null) { - return false; - } - return bukkitPlayer.getInventory().addItem(new ItemStack(material, amount)).isEmpty(); - } - @Override public void log(LogLevel level, String message) { bridge().logSink().accept(level, message); @@ -337,26 +316,4 @@ public final class BukkitPlatform implements IrisPlatform { public void reportError(Throwable error) { bridge().errorSink().accept(error); } - - private static final class BukkitCapabilities implements PlatformCapabilities { - @Override - public boolean customBiomes() { - return INMS.get().supportsCustomBiomes(); - } - - @Override - public boolean dataPacks() { - return INMS.get().supportsDataPacks(); - } - - @Override - public boolean structurePlacement() { - return true; - } - - @Override - public boolean regionizedThreading() { - return J.isFolia(); - } - } } diff --git a/spi/src/main/java/art/arcane/iris/spi/PlatformCapabilities.java b/core/src/main/java/art/arcane/iris/util/common/math/ChunkSpiral.java similarity index 52% rename from spi/src/main/java/art/arcane/iris/spi/PlatformCapabilities.java rename to core/src/main/java/art/arcane/iris/util/common/math/ChunkSpiral.java index 550081158..a24a75d47 100644 --- a/spi/src/main/java/art/arcane/iris/spi/PlatformCapabilities.java +++ b/core/src/main/java/art/arcane/iris/util/common/math/ChunkSpiral.java @@ -16,25 +16,29 @@ * along with this program. If not, see . */ -package art.arcane.iris.spi; +package art.arcane.iris.util.common.math; -/** - * Capability flags describing which optional platform features an adapter supports; all default to unsupported. - */ -public interface PlatformCapabilities { - default boolean customBiomes() { - return false; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +public final class ChunkSpiral { + private ChunkSpiral() { } - default boolean dataPacks() { - return false; - } + public static List centerOut(int centerX, int centerZ, int radius) { + List targets = new ArrayList<>(); + for (int dx = -radius; dx <= radius; dx++) { + for (int dz = -radius; dz <= radius; dz++) { + targets.add(new int[]{centerX + dx, centerZ + dz}); + } + } - default boolean structurePlacement() { - return false; - } - - default boolean regionizedThreading() { - return false; + targets.sort(Comparator.comparingInt((int[] t) -> { + int ox = t[0] - centerX; + int oz = t[1] - centerZ; + return ox * ox + oz * oz; + })); + return targets; } } diff --git a/core/src/main/java/art/arcane/iris/util/common/scheduling/J.java b/core/src/main/java/art/arcane/iris/util/common/scheduling/J.java index 65c9d745e..ab5844e65 100644 --- a/core/src/main/java/art/arcane/iris/util/common/scheduling/J.java +++ b/core/src/main/java/art/arcane/iris/util/common/scheduling/J.java @@ -19,6 +19,7 @@ package art.arcane.iris.util.common.scheduling; import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.IrisServices; import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.engine.framework.PreservationRegistry; @@ -121,6 +122,13 @@ public class J { } public static void aBukkit(Runnable a) { + if (!BUKKIT_PRESENT) { + if (IrisPlatforms.isBound()) { + IrisPlatforms.get().scheduler().async(a); + } + return; + } + if (!isPluginEnabled()) { return; } @@ -328,6 +336,13 @@ public class J { } public static void s(Runnable r) { + if (!BUKKIT_PRESENT) { + if (IrisPlatforms.isBound()) { + IrisPlatforms.get().scheduler().global(r); + } + return; + } + if (!isPluginEnabled()) { return; } @@ -355,7 +370,7 @@ public class J { public static CompletableFuture sfut(Runnable r) { CompletableFuture f = new CompletableFuture(); - if (!isPluginEnabled()) { + if (!canSchedule()) { return null; } @@ -370,7 +385,7 @@ public class J { public static CompletableFuture sfut(Supplier r) { CompletableFuture f = new CompletableFuture<>(); - if (!isPluginEnabled()) { + if (!canSchedule()) { return null; } @@ -388,7 +403,7 @@ public class J { public static CompletableFuture sfut(Runnable r, int delay) { CompletableFuture f = new CompletableFuture(); - if (!isPluginEnabled()) { + if (!canSchedule()) { return null; } @@ -410,6 +425,13 @@ public class J { } public static void s(Runnable r, int delay) { + if (!BUKKIT_PRESENT) { + if (IrisPlatforms.isBound()) { + IrisPlatforms.get().scheduler().laterGlobal(r, delay); + } + return; + } + if (!isPluginEnabled()) { return; } @@ -451,7 +473,7 @@ public class J { } public static int sr(Runnable r, int interval) { - if (!isPluginEnabled()) { + if (!canSchedule()) { return -1; } @@ -461,13 +483,13 @@ public class J { Runnable[] loop = new Runnable[1]; loop[0] = () -> { - if (state.cancelled || !isPluginEnabled()) { + if (state.cancelled || !canSchedule()) { REPEATING_CANCELLERS.remove(taskId); return; } r.run(); - if (state.cancelled || !isPluginEnabled()) { + if (state.cancelled || !canSchedule()) { REPEATING_CANCELLERS.remove(taskId); return; } @@ -496,6 +518,17 @@ public class J { } public static void a(Runnable r, int delay) { + if (!BUKKIT_PRESENT) { + if (IrisPlatforms.isBound()) { + if (delay <= 0) { + IrisPlatforms.get().scheduler().async(r); + } else { + IrisPlatforms.get().scheduler().laterGlobal(() -> IrisPlatforms.get().scheduler().async(r), delay); + } + } + return; + } + if (!isPluginEnabled()) { return; } @@ -521,7 +554,7 @@ public class J { } public static int ar(Runnable r, int interval) { - if (!isPluginEnabled()) { + if (!canSchedule()) { return -1; } @@ -531,13 +564,13 @@ public class J { Runnable[] loop = new Runnable[1]; loop[0] = () -> { - if (state.cancelled || !isPluginEnabled()) { + if (state.cancelled || !canSchedule()) { REPEATING_CANCELLERS.remove(taskId); return; } r.run(); - if (state.cancelled || !isPluginEnabled()) { + if (state.cancelled || !canSchedule()) { REPEATING_CANCELLERS.remove(taskId); return; } @@ -582,6 +615,10 @@ public class J { return BUKKIT_PRESENT && BukkitPlatform.hasPlugin() && Bukkit.getPluginManager().isPluginEnabled(BukkitPlatform.plugin()); } + private static boolean canSchedule() { + return BUKKIT_PRESENT ? isPluginEnabled() : IrisPlatforms.isBound(); + } + private static long ticksToMilliseconds(int ticks) { return Math.max(0L, ticks) * TICK_MS; } diff --git a/core/src/main/java/art/arcane/iris/util/project/hunk/view/ChunkDataHunkHolder.java b/core/src/main/java/art/arcane/iris/util/project/hunk/view/ChunkDataHunkHolder.java index 28f0b7ccb..e6d7c57b3 100644 --- a/core/src/main/java/art/arcane/iris/util/project/hunk/view/ChunkDataHunkHolder.java +++ b/core/src/main/java/art/arcane/iris/util/project/hunk/view/ChunkDataHunkHolder.java @@ -55,6 +55,10 @@ public class ChunkDataHunkHolder extends AtomicHunk { @Override public PlatformBlockState getRaw(int x, int y, int z) { + if (y < 0 || y >= getHeight()) { + return States.AIR; + } + PlatformBlockState b = super.getRaw(x, y, z); return b != null ? b : States.AIR; diff --git a/core/src/main/java/art/arcane/iris/util/project/noise/FastNoiseDouble.java b/core/src/main/java/art/arcane/iris/util/project/noise/FastNoiseDouble.java index a6c3cb636..0a0d53c56 100644 --- a/core/src/main/java/art/arcane/iris/util/project/noise/FastNoiseDouble.java +++ b/core/src/main/java/art/arcane/iris/util/project/noise/FastNoiseDouble.java @@ -255,6 +255,14 @@ public class FastNoiseDouble { m_seed = seed; } + public double getFrequency() { + return m_frequency; + } + + public double getFractalBounding() { + return m_fractalBounding; + } + // Sets frequency for all noise types // Default: 0.01 public void setFrequency(double frequency) { diff --git a/core/src/main/java/art/arcane/iris/util/simd/NoiseKernels2D.java b/core/src/main/java/art/arcane/iris/util/simd/NoiseKernels2D.java new file mode 100644 index 000000000..b781cd784 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/util/simd/NoiseKernels2D.java @@ -0,0 +1,8 @@ +package art.arcane.iris.util.simd; + +public interface NoiseKernels2D { + String describe(); + + void simplexFractalFBM(long seed, int octaves, double frequency, double lacunarity, double gain, + double fractalBounding, double[] xs, double[] zs, double[] out, int length); +} diff --git a/core/src/main/java/art/arcane/iris/util/simd/ScalarNoiseKernels2D.java b/core/src/main/java/art/arcane/iris/util/simd/ScalarNoiseKernels2D.java new file mode 100644 index 000000000..e2b592d0b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/util/simd/ScalarNoiseKernels2D.java @@ -0,0 +1,99 @@ +package art.arcane.iris.util.simd; + +public final class ScalarNoiseKernels2D implements NoiseKernels2D { + static final double F2 = 0.5D * (Math.sqrt(3.0D) - 1.0D); + static final double G2 = (3.0D - Math.sqrt(3.0D)) / 6.0D; + static final long X_PRIME = 1619L; + static final long Y_PRIME = 31337L; + static final double[] GRAD_2D = {-1D, -1D, 1D, -1D, -1D, 1D, 1D, 1D, 0D, -1D, -1D, 0D, 0D, 1D, 1D, 0D}; + + @Override + public String describe() { + return "scalar"; + } + + static long fastFloor(double f) { + return f >= 0D ? (long) f : (long) f - 1L; + } + + static double gradCoord2D(long seed, long x, long y, double xd, double yd) { + long hash = seed; + hash ^= X_PRIME * x; + hash ^= Y_PRIME * y; + hash = hash * hash * hash * 60493L; + hash = (hash >> 13) ^ hash; + int gradientIndex = ((int) hash & 7) << 1; + return (xd * GRAD_2D[gradientIndex]) + (yd * GRAD_2D[gradientIndex + 1]); + } + + static double singleSimplex(long seed, double x, double y) { + double t = (x + y) * F2; + long i = fastFloor(x + t); + long j = fastFloor(y + t); + t = (i + j) * G2; + double x0 = x - (i - t); + double y0 = y - (j - t); + long i1; + long j1; + if (x0 > y0) { + i1 = 1L; + j1 = 0L; + } else { + i1 = 0L; + j1 = 1L; + } + double x1 = x0 - i1 + G2; + double y1 = y0 - j1 + G2; + double x2 = x0 - 1D + (2D * G2); + double y2 = y0 - 1D + (2D * G2); + double n0; + double n1; + double n2; + double a = 0.5D - x0 * x0 - y0 * y0; + if (a < 0D) { + n0 = 0D; + } else { + a *= a; + n0 = a * a * gradCoord2D(seed, i, j, x0, y0); + } + double b = 0.5D - x1 * x1 - y1 * y1; + if (b < 0D) { + n1 = 0D; + } else { + b *= b; + n1 = b * b * gradCoord2D(seed, i + i1, j + j1, x1, y1); + } + double c = 0.5D - x2 * x2 - y2 * y2; + if (c < 0D) { + n2 = 0D; + } else { + c *= c; + n2 = c * c * gradCoord2D(seed, i + 1L, j + 1L, x2, y2); + } + return 50D * (n0 + n1 + n2); + } + + static double simplexFractalFBMScalar(long seed, int octaves, double frequency, double lacunarity, double gain, + double fractalBounding, double xIn, double yIn) { + double x = xIn * frequency; + double y = yIn * frequency; + long s = seed; + double sum = singleSimplex(s, x, y); + double amp = 1D; + for (int o = 1; o < octaves; o++) { + x *= lacunarity; + y *= lacunarity; + amp *= gain; + sum += singleSimplex(++s, x, y) * amp; + } + return sum * fractalBounding; + } + + @Override + public void simplexFractalFBM(long seed, int octaves, double frequency, double lacunarity, double gain, + double fractalBounding, double[] xs, double[] zs, double[] out, int length) { + for (int k = 0; k < length; k++) { + out[k] = simplexFractalFBMScalar(seed, octaves, frequency, lacunarity, gain, fractalBounding, xs[k], zs[k]); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/util/simd/SimdSupport.java b/core/src/main/java/art/arcane/iris/util/simd/SimdSupport.java index 3a800c70c..de41f882b 100644 --- a/core/src/main/java/art/arcane/iris/util/simd/SimdSupport.java +++ b/core/src/main/java/art/arcane/iris/util/simd/SimdSupport.java @@ -54,6 +54,36 @@ public final class SimdSupport { return vector == null ? new ScalarSimdKernels() : vector; } + private static final NoiseKernels2D NOISE_KERNELS_2D = selectNoiseKernels2D(); + + public static NoiseKernels2D noiseKernels2D() { + return NOISE_KERNELS_2D; + } + + public static NoiseKernels2D createVectorNoiseKernels2D() { + if (!MODULE_PRESENT) { + return null; + } + try { + Class cls = Class.forName("art.arcane.iris.util.simd.VectorNoiseKernels2D"); + boolean profitable = (boolean) cls.getMethod("profitable").invoke(null); + if (!profitable) { + return null; + } + return (NoiseKernels2D) cls.getDeclaredConstructor().newInstance(); + } catch (Throwable e) { + return null; + } + } + + private static NoiseKernels2D selectNoiseKernels2D() { + if (!simdEnabledInSettings()) { + return new ScalarNoiseKernels2D(); + } + NoiseKernels2D vector = createVectorNoiseKernels2D(); + return vector == null ? new ScalarNoiseKernels2D() : vector; + } + private static boolean simdEnabledInSettings() { try { return IrisSettings.get().getPerformance().isSimdKernels(); diff --git a/core/src/main/java/art/arcane/iris/util/simd/VectorNoiseKernels2D.java b/core/src/main/java/art/arcane/iris/util/simd/VectorNoiseKernels2D.java new file mode 100644 index 000000000..5db19a260 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/util/simd/VectorNoiseKernels2D.java @@ -0,0 +1,129 @@ +package art.arcane.iris.util.simd; + +import jdk.incubator.vector.DoubleVector; +import jdk.incubator.vector.LongVector; +import jdk.incubator.vector.VectorMask; +import jdk.incubator.vector.VectorOperators; +import jdk.incubator.vector.VectorSpecies; + +public final class VectorNoiseKernels2D implements NoiseKernels2D { + private static final VectorSpecies DS = DoubleVector.SPECIES_PREFERRED; + private static final VectorSpecies LS = LongVector.SPECIES_PREFERRED; + private static final boolean ALIGNED = DS.length() == LS.length(); + private static final int MIN_PROFITABLE_LANES = 4; + private static final boolean PROFITABLE = ALIGNED && DS.length() >= MIN_PROFITABLE_LANES; + private static final double[] GRAD_X = {-1D, 1D, -1D, 1D, 0D, -1D, 0D, 1D}; + private static final double[] GRAD_Y = {-1D, -1D, 1D, 1D, -1D, 0D, 1D, 0D}; + private static final double F2 = ScalarNoiseKernels2D.F2; + private static final double G2 = ScalarNoiseKernels2D.G2; + private static final long X_PRIME = ScalarNoiseKernels2D.X_PRIME; + private static final long Y_PRIME = ScalarNoiseKernels2D.Y_PRIME; + private static final ThreadLocal LONG_SCRATCH = ThreadLocal.withInitial(() -> new long[LS.length()]); + + public static boolean lanesAligned() { + return ALIGNED; + } + + public static boolean profitable() { + return PROFITABLE; + } + + @Override + public String describe() { + return DS.length() + "x64 lanes, " + DS.vectorShape(); + } + + private static LongVector floorToLong(DoubleVector f) { + LongVector truncated = (LongVector) f.convertShape(VectorOperators.D2L, LS, 0); + VectorMask negative = f.compare(VectorOperators.LT, 0D).cast(LS); + return truncated.sub(1L, negative); + } + + private static DoubleVector toDouble(LongVector v) { + return (DoubleVector) v.convertShape(VectorOperators.L2D, DS, 0); + } + + private static DoubleVector gradCoord(long seed, LongVector i, LongVector j, DoubleVector xd, DoubleVector yd, + int[] idxScratch) { + LongVector hash = LongVector.broadcast(LS, seed) + .lanewise(VectorOperators.XOR, i.mul(X_PRIME)) + .lanewise(VectorOperators.XOR, j.mul(Y_PRIME)); + hash = hash.mul(hash).mul(hash).mul(60493L); + LongVector shifted = hash.lanewise(VectorOperators.ASHR, 13); + hash = shifted.lanewise(VectorOperators.XOR, hash); + LongVector idx = hash.lanewise(VectorOperators.AND, 7L); + long[] tmp = LONG_SCRATCH.get(); + idx.intoArray(tmp, 0); + for (int l = 0; l < idxScratch.length; l++) { + idxScratch[l] = (int) tmp[l]; + } + DoubleVector gx = DoubleVector.fromArray(DS, GRAD_X, 0, idxScratch, 0); + DoubleVector gy = DoubleVector.fromArray(DS, GRAD_Y, 0, idxScratch, 0); + return xd.mul(gx).add(yd.mul(gy)); + } + + private static DoubleVector corner(DoubleVector xk, DoubleVector yk, DoubleVector grad) { + DoubleVector t = DoubleVector.broadcast(DS, 0.5D).sub(xk.mul(xk)).sub(yk.mul(yk)); + VectorMask negative = t.compare(VectorOperators.LT, 0D); + DoubleVector t2 = t.mul(t); + DoubleVector t4 = t2.mul(t2); + return t4.mul(grad).blend(0D, negative); + } + + private static DoubleVector singleSimplexVector(long seed, DoubleVector x, DoubleVector y, int[] idxScratch) { + DoubleVector t = x.add(y).mul(F2); + LongVector i = floorToLong(x.add(t)); + LongVector j = floorToLong(y.add(t)); + DoubleVector skew = toDouble(i.add(j)).mul(G2); + DoubleVector x0 = x.sub(toDouble(i).sub(skew)); + DoubleVector y0 = y.sub(toDouble(j).sub(skew)); + VectorMask xGreater = x0.compare(VectorOperators.GT, y0); + VectorMask xGreaterL = xGreater.cast(LS); + LongVector i1 = LongVector.zero(LS).blend(1L, xGreaterL); + LongVector j1 = LongVector.broadcast(LS, 1L).blend(0L, xGreaterL); + DoubleVector x1 = x0.sub(toDouble(i1)).add(G2); + DoubleVector y1 = y0.sub(toDouble(j1)).add(G2); + DoubleVector x2 = x0.sub(1D).add(2D * G2); + DoubleVector y2 = y0.sub(1D).add(2D * G2); + DoubleVector n0 = corner(x0, y0, gradCoord(seed, i, j, x0, y0, idxScratch)); + DoubleVector n1 = corner(x1, y1, gradCoord(seed, i.add(i1), j.add(j1), x1, y1, idxScratch)); + DoubleVector n2 = corner(x2, y2, gradCoord(seed, i.add(1L), j.add(1L), x2, y2, idxScratch)); + return n0.add(n1).add(n2).mul(50D); + } + + @Override + public void simplexFractalFBM(long seed, int octaves, double frequency, double lacunarity, double gain, + double fractalBounding, double[] xs, double[] zs, double[] out, int length) { + if (!ALIGNED) { + tailScalar(seed, octaves, frequency, lacunarity, gain, fractalBounding, xs, zs, out, 0, length); + return; + } + int lanes = DS.length(); + int bound = DS.loopBound(length); + int[] idxScratch = new int[lanes]; + int k = 0; + for (; k < bound; k += lanes) { + DoubleVector x = DoubleVector.fromArray(DS, xs, k).mul(frequency); + DoubleVector y = DoubleVector.fromArray(DS, zs, k).mul(frequency); + long s = seed; + DoubleVector sum = singleSimplexVector(s, x, y, idxScratch); + double amp = 1D; + for (int o = 1; o < octaves; o++) { + x = x.mul(lacunarity); + y = y.mul(lacunarity); + amp *= gain; + sum = sum.add(singleSimplexVector(++s, x, y, idxScratch).mul(amp)); + } + sum.mul(fractalBounding).intoArray(out, k); + } + tailScalar(seed, octaves, frequency, lacunarity, gain, fractalBounding, xs, zs, out, k, length); + } + + private static void tailScalar(long seed, int octaves, double frequency, double lacunarity, double gain, + double fractalBounding, double[] xs, double[] zs, double[] out, int from, int length) { + for (int k = from; k < length; k++) { + out[k] = ScalarNoiseKernels2D.simplexFractalFBMScalar(seed, octaves, frequency, lacunarity, gain, + fractalBounding, xs[k], zs[k]); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/pregenerator/cache/PregenCacheImplPersistenceTest.java b/core/src/test/java/art/arcane/iris/core/pregenerator/cache/PregenCacheImplPersistenceTest.java new file mode 100644 index 000000000..02b5f22b3 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pregenerator/cache/PregenCacheImplPersistenceTest.java @@ -0,0 +1,70 @@ +package art.arcane.iris.core.pregenerator.cache; + +import org.junit.Before; +import org.junit.Test; + +import java.io.File; +import java.nio.file.Files; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class PregenCacheImplPersistenceTest { + private File directory; + + @Before + public void setUp() throws Exception { + directory = Files.createTempDirectory("iris-pregen-cache-persist").toFile(); + } + + @Test + public void partialRegionChunkPersistsAcrossReload() { + PregenCache cache = PregenCache.create(directory); + cache.cacheChunk(5, 7); + cache.write(); + + PregenCache reloaded = PregenCache.create(directory); + assertTrue(reloaded.isChunkCached(5, 7)); + assertFalse(reloaded.isChunkCached(5, 8)); + assertFalse(reloaded.isRegionCached(0, 0)); + } + + @Test + public void partialRegionNegativeCoordinatesPersistAcrossReload() { + PregenCache cache = PregenCache.create(directory); + cache.cacheChunk(-3, -1); + cache.write(); + + PregenCache reloaded = PregenCache.create(directory); + assertTrue(reloaded.isChunkCached(-3, -1)); + assertFalse(reloaded.isChunkCached(-3, -2)); + } + + @Test + public void fullRegionPersistsAcrossReload() { + PregenCache cache = PregenCache.create(directory); + cache.cacheRegion(2, 3); + cache.write(); + + PregenCache reloaded = PregenCache.create(directory); + assertTrue(reloaded.isRegionCached(2, 3)); + assertTrue(reloaded.isChunkCached((2 << 5) + 11, (3 << 5) + 19)); + assertFalse(reloaded.isRegionCached(2, 4)); + } + + @Test + public void regionCompletedChunkByChunkPersistsAsRegion() { + PregenCache cache = PregenCache.create(directory); + for (int x = 0; x < 32; x++) { + for (int z = 0; z < 32; z++) { + cache.cacheChunk(x, z); + } + } + cache.write(); + + PregenCache reloaded = PregenCache.create(directory); + assertTrue(reloaded.isRegionCached(0, 0)); + assertTrue(reloaded.isChunkCached(31, 31)); + assertFalse(reloaded.isChunkCached(32, 0)); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/pregenerator/methods/CachedPregenMethodCompletionTest.java b/core/src/test/java/art/arcane/iris/core/pregenerator/methods/CachedPregenMethodCompletionTest.java new file mode 100644 index 000000000..aaeb3eb87 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pregenerator/methods/CachedPregenMethodCompletionTest.java @@ -0,0 +1,213 @@ +package art.arcane.iris.core.pregenerator.methods; + +import art.arcane.iris.core.pregenerator.PregenListener; +import art.arcane.iris.core.pregenerator.PregenTask; +import art.arcane.iris.core.pregenerator.PregeneratorMethod; +import art.arcane.iris.core.pregenerator.cache.PregenCache; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.math.Position2; +import org.junit.Before; +import org.junit.Test; + +import java.io.File; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +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.assertTrue; + +public class CachedPregenMethodCompletionTest { + private File directory; + private PregenCache cache; + private CapturingMethod underlying; + private PregenTask task; + private CachedPregenMethod method; + private RecordingListener listener; + + @Before + public void setUp() throws Exception { + directory = Files.createTempDirectory("iris-pregen-cache-test").toFile(); + cache = PregenCache.create(directory); + underlying = new CapturingMethod(); + task = PregenTask.builder() + .center(new Position2(0, 0)) + .radiusX(256) + .radiusZ(256) + .build(); + method = new CachedPregenMethod(underlying, cache, task); + listener = new RecordingListener(); + } + + @Test + public void generateChunkDoesNotCacheOnSubmitOnlyOnCompletion() { + method.generateChunk(3, 4, listener); + + assertEquals(1, underlying.generateChunkCalls.get()); + assertFalse(cache.isChunkCached(3, 4)); + + underlying.capturedListener.get().onChunkGenerated(3, 4, false); + + assertTrue(cache.isChunkCached(3, 4)); + assertEquals(1, listener.generated.get()); + } + + @Test + public void generateChunkFailureIsNotCached() { + method.generateChunk(6, 9, listener); + underlying.capturedListener.get().onChunkFailed(6, 9); + + assertFalse(cache.isChunkCached(6, 9)); + assertEquals(0, listener.generated.get()); + assertEquals(1, listener.failed.get()); + } + + @Test + public void generateChunkSkipsUnderlyingMethodWhenCached() { + method.generateChunk(1, 2, listener); + underlying.capturedListener.get().onChunkGenerated(1, 2, false); + assertEquals(1, underlying.generateChunkCalls.get()); + + method.generateChunk(1, 2, listener); + + assertEquals(1, underlying.generateChunkCalls.get()); + assertEquals(2, listener.generated.get()); + assertEquals(1, listener.generatedCached.get()); + } + + @Test + public void generateRegionReplayRespectsTaskBounds() { + cache.cacheRegion(0, 0); + List expected = new ArrayList<>(); + task.iterateChunks(0, 0, (x, z) -> expected.add(new long[]{x, z})); + assertTrue(expected.size() < 1024); + + method.generateRegion(0, 0, listener); + + assertEquals(0, underlying.generateRegionCalls.get()); + assertEquals(expected.size(), listener.generated.get()); + assertEquals(expected.size(), listener.generatedCached.get()); + } + + private static final class CapturingMethod implements PregeneratorMethod { + private final AtomicInteger generateChunkCalls = new AtomicInteger(); + private final AtomicInteger generateRegionCalls = new AtomicInteger(); + private final AtomicReference capturedListener = new AtomicReference<>(); + + @Override + public void init() { + } + + @Override + public void close() { + } + + @Override + public void save() { + } + + @Override + public boolean supportsRegions(int x, int z, PregenListener listener) { + return false; + } + + @Override + public String getMethod(int x, int z) { + return "capture"; + } + + @Override + public void generateRegion(int x, int z, PregenListener listener) { + generateRegionCalls.incrementAndGet(); + } + + @Override + public void generateChunk(int x, int z, PregenListener listener) { + generateChunkCalls.incrementAndGet(); + capturedListener.set(listener); + } + + @Override + public Mantle getMantle() { + return null; + } + } + + private static final class RecordingListener implements PregenListener { + private final AtomicInteger generated = new AtomicInteger(); + private final AtomicInteger generatedCached = new AtomicInteger(); + private final AtomicInteger failed = new AtomicInteger(); + + @Override + public void onTick(double chunksPerSecond, double chunksPerMinute, double regionsPerMinute, double percent, long generated, long totalChunks, long chunksRemaining, long eta, long elapsed, String method, boolean cached) { + } + + @Override + public void onChunkGenerating(int x, int z) { + } + + @Override + public void onChunkGenerated(int x, int z, boolean cached) { + generated.incrementAndGet(); + if (cached) { + generatedCached.incrementAndGet(); + } + } + + @Override + public void onChunkFailed(int x, int z) { + failed.incrementAndGet(); + } + + @Override + public void onRegionGenerated(int x, int z) { + } + + @Override + public void onRegionGenerating(int x, int z) { + } + + @Override + public void onChunkCleaned(int x, int z) { + } + + @Override + public void onRegionSkipped(int x, int z) { + } + + @Override + public void onNetworkStarted(int x, int z) { + } + + @Override + public void onNetworkFailed(int x, int z) { + } + + @Override + public void onNetworkReclaim(int revert) { + } + + @Override + public void onNetworkGeneratedChunk(int x, int z) { + } + + @Override + public void onNetworkDownloaded(int x, int z) { + } + + @Override + public void onClose() { + } + + @Override + public void onSaving() { + } + + @Override + public void onChunkExistsInRegionGen(int x, int z) { + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/runtime/GoldenHashEngineTest.java b/core/src/test/java/art/arcane/iris/core/runtime/GoldenHashEngineTest.java new file mode 100644 index 000000000..f56f5476e --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/runtime/GoldenHashEngineTest.java @@ -0,0 +1,214 @@ +package art.arcane.iris.core.runtime; + +import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.spi.PlatformBiome; +import art.arcane.iris.spi.PlatformBlockState; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class GoldenHashEngineTest { + private static final int MIN_Y = 0; + private static final int MAX_Y = 8; + + private IrisSettings previousSettings; + private File goldenDir; + private Engine engine; + private PlatformBlockState stone; + private PlatformBlockState dirt; + private PlatformBiome plains; + + @Before + public void setUp() throws Exception { + previousSettings = IrisSettings.settings; + IrisSettings.settings = new IrisSettings(); + goldenDir = Files.createTempDirectory("iris-goldenhash-test").toFile(); + engine = mock(Engine.class); + IrisDimension dimension = mock(IrisDimension.class); + when(dimension.getLoadKey()).thenReturn("testdim"); + when(engine.getDimension()).thenReturn(dimension); + stone = mock(PlatformBlockState.class); + when(stone.key()).thenReturn("minecraft:stone"); + dirt = mock(PlatformBlockState.class); + when(dirt.key()).thenReturn("minecraft:dirt"); + plains = mock(PlatformBiome.class); + when(plains.key()).thenReturn("minecraft:plains"); + } + + @After + public void tearDown() { + IrisSettings.settings = previousSettings; + } + + @Test + public void capturePersistsGoldenFileWithStableFormat() throws Exception { + RecordingFeedback feedback = new RecordingFeedback(); + GoldenHashEngine hashEngine = new GoldenHashEngine(engine, request(GoldenHashEngine.Mode.AUTO), goldenDir, source(dirt, 3, 2, 1), feedback, progress()); + + assertTrue(hashEngine.run()); + File goldenFile = new File(goldenDir, "testdim-s1234-c0x0-r0.hashes"); + assertTrue(goldenFile.exists()); + + List lines = Files.readAllLines(goldenFile.toPath(), StandardCharsets.UTF_8); + assertEquals("#iris-goldenhash v1", lines.get(0)); + assertEquals("#world=testworld", lines.get(1)); + assertEquals("#dim=testdim", lines.get(2)); + assertEquals("#seed=1234", lines.get(3)); + assertEquals("#mc=test-1.0", lines.get(4)); + assertEquals("#minY=0 maxY=8", lines.get(5)); + assertEquals("#center=0,0", lines.get(6)); + assertEquals("#radius=0", lines.get(7)); + + String expectedBody = referenceLine(0, 0, 3, 2, 1); + assertEquals(expectedBody, lines.get(8)); + assertEquals("#combined=" + referenceCombined(expectedBody), lines.get(9)); + } + + @Test + public void verifyMatchesGoldenCapture() { + RecordingFeedback captureFeedback = new RecordingFeedback(); + GoldenHashEngine capture = new GoldenHashEngine(engine, request(GoldenHashEngine.Mode.AUTO), goldenDir, source(dirt, 3, 2, 1), captureFeedback, progress()); + assertTrue(capture.run()); + + RecordingFeedback verifyFeedback = new RecordingFeedback(); + GoldenHashEngine verify = new GoldenHashEngine(engine, request(GoldenHashEngine.Mode.AUTO), goldenDir, source(dirt, 3, 2, 1), verifyFeedback, progress()); + assertTrue(verify.run()); + assertTrue(verifyFeedback.ok.stream().anyMatch((String line) -> line.startsWith("GOLDEN MATCH: 1/1"))); + assertTrue(verifyFeedback.fail.isEmpty()); + } + + @Test + public void verifyFlagsMismatchAndWritesDiagnostics() { + RecordingFeedback captureFeedback = new RecordingFeedback(); + GoldenHashEngine capture = new GoldenHashEngine(engine, request(GoldenHashEngine.Mode.AUTO), goldenDir, source(dirt, 3, 2, 1), captureFeedback, progress()); + assertTrue(capture.run()); + + RecordingFeedback verifyFeedback = new RecordingFeedback(); + GoldenHashEngine verify = new GoldenHashEngine(engine, request(GoldenHashEngine.Mode.VERIFY), goldenDir, source(dirt, 7, 4, 9), verifyFeedback, progress()); + assertFalse(verify.run()); + assertTrue(verifyFeedback.fail.stream().anyMatch((String line) -> line.startsWith("GOLDEN MISMATCH: 1/1"))); + + File goldenFile = new File(goldenDir, "testdim-s1234-c0x0-r0.hashes"); + assertTrue(new File(goldenDir, goldenFile.getName() + ".new").exists()); + assertTrue(new File(goldenDir, goldenFile.getName() + ".diag-c0x0.txt").exists()); + } + + @Test + public void verifyModeWithoutCaptureFails() { + RecordingFeedback feedback = new RecordingFeedback(); + GoldenHashEngine verify = new GoldenHashEngine(engine, request(GoldenHashEngine.Mode.VERIFY), goldenDir, source(dirt, 3, 2, 1), feedback, progress()); + assertFalse(verify.run()); + assertTrue(feedback.fail.stream().anyMatch((String line) -> line.startsWith("No golden capture at "))); + } + + @Test + public void abortsWithoutWritingWhenChunkGenerationFails() { + RecordingFeedback feedback = new RecordingFeedback(); + GoldenHashEngine.ChunkSource broken = (int chunkX, int chunkZ) -> { + throw new IllegalStateException("boom"); + }; + GoldenHashEngine hashEngine = new GoldenHashEngine(engine, request(GoldenHashEngine.Mode.AUTO), goldenDir, broken, feedback, progress()); + assertFalse(hashEngine.run()); + assertFalse(new File(goldenDir, "testdim-s1234-c0x0-r0.hashes").exists()); + assertTrue(feedback.fail.stream().anyMatch((String line) -> line.startsWith("GoldenHash aborted: 1 chunk(s)"))); + } + + private GoldenHashEngine.Request request(GoldenHashEngine.Mode mode) { + return new GoldenHashEngine.Request("testworld", 1234L, "test-1.0", MIN_Y, MAX_Y, 0, 0, 0, 1, mode, false, false, "null"); + } + + private GoldenHashEngine.Progress progress() { + return new GoldenHashEngine.Progress() { + }; + } + + private GoldenHashEngine.ChunkSource source(PlatformBlockState special, int sx, int sy, int sz) { + return (int chunkX, int chunkZ) -> new GoldenHashEngine.ChunkSnapshot() { + @Override + public int minY() { + return MIN_Y; + } + + @Override + public int maxY() { + return MAX_Y; + } + + @Override + public PlatformBlockState block(int x, int y, int z) { + return x == sx && y == sy && z == sz ? special : stone; + } + + @Override + public PlatformBiome biome(int x, int y, int z) { + return plains; + } + }; + } + + private String referenceLine(int chunkX, int chunkZ, int sx, int sy, int sz) throws Exception { + MessageDigest blockDigest = MessageDigest.getInstance("SHA-256"); + MessageDigest biomeDigest = MessageDigest.getInstance("SHA-256"); + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + for (int y = MIN_Y; y < MAX_Y; y++) { + String key = x == sx && y == sy && z == sz ? "minecraft:dirt" : "minecraft:stone"; + blockDigest.update((key + "\n").getBytes(StandardCharsets.UTF_8)); + } + } + } + for (int x = 0; x < 16; x += 4) { + for (int z = 0; z < 16; z += 4) { + for (int y = MIN_Y; y < MAX_Y; y += 4) { + biomeDigest.update("minecraft:plains\n".getBytes(StandardCharsets.UTF_8)); + } + } + } + return chunkX + " " + chunkZ + " " + + HexFormat.of().formatHex(blockDigest.digest()) + " " + + HexFormat.of().formatHex(biomeDigest.digest()); + } + + private String referenceCombined(String bodyLine) throws Exception { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + digest.update((bodyLine + "\n").getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(digest.digest()); + } + + private static final class RecordingFeedback implements GoldenHashEngine.Feedback { + private final List ok = new ArrayList<>(); + private final List warn = new ArrayList<>(); + private final List fail = new ArrayList<>(); + + @Override + public void ok(String message) { + ok.add(message); + } + + @Override + public void warn(String message) { + warn.add(message); + } + + @Override + public void fail(String message) { + fail.add(message); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/util/common/math/ChunkSpiralTest.java b/core/src/test/java/art/arcane/iris/util/common/math/ChunkSpiralTest.java new file mode 100644 index 000000000..04603f69e --- /dev/null +++ b/core/src/test/java/art/arcane/iris/util/common/math/ChunkSpiralTest.java @@ -0,0 +1,55 @@ +package art.arcane.iris.util.common.math; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; + +public class ChunkSpiralTest { + @Test + public void centerOutMatchesLegacyOrdering() { + int centerX = 5; + int centerZ = -3; + int radius = 3; + List expected = legacyOrderedTargets(centerX, centerZ, radius); + List actual = ChunkSpiral.centerOut(centerX, centerZ, radius); + assertEquals(expected.size(), actual.size()); + for (int i = 0; i < expected.size(); i++) { + assertArrayEquals(expected.get(i), actual.get(i)); + } + } + + @Test + public void centerOutStartsAtCenterAndCoversSquare() { + List targets = ChunkSpiral.centerOut(10, 20, 2); + assertEquals(25, targets.size()); + assertArrayEquals(new int[]{10, 20}, targets.get(0)); + } + + @Test + public void centerOutRadiusZeroIsSingleChunk() { + List targets = ChunkSpiral.centerOut(-7, 9, 0); + assertEquals(1, targets.size()); + assertArrayEquals(new int[]{-7, 9}, targets.get(0)); + } + + private static List legacyOrderedTargets(int centerChunkX, int centerChunkZ, int radius) { + List targets = new ArrayList<>(); + for (int dx = -radius; dx <= radius; dx++) { + for (int dz = -radius; dz <= radius; dz++) { + targets.add(new int[]{centerChunkX + dx, centerChunkZ + dz}); + } + } + + targets.sort(Comparator.comparingInt((int[] t) -> { + int ox = t[0] - centerChunkX; + int oz = t[1] - centerChunkZ; + return ox * ox + oz * oz; + })); + return targets; + } +} diff --git a/core/src/test/java/art/arcane/iris/util/simd/NoiseKernels2DParityTest.java b/core/src/test/java/art/arcane/iris/util/simd/NoiseKernels2DParityTest.java new file mode 100644 index 000000000..69f16f747 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/util/simd/NoiseKernels2DParityTest.java @@ -0,0 +1,120 @@ +package art.arcane.iris.util.simd; + +import art.arcane.iris.util.project.noise.FastNoiseDouble; +import art.arcane.iris.util.project.noise.FastNoiseDouble.FractalType; +import art.arcane.iris.util.project.noise.FastNoiseDouble.NoiseType; +import art.arcane.volmlib.util.math.RNG; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class NoiseKernels2DParityTest { + private static FastNoiseDouble oracle(long seed, int octaves) { + FastNoiseDouble n = new FastNoiseDouble(seed); + n.setNoiseType(NoiseType.SimplexFractal); + n.setFractalType(FractalType.FBM); + n.setFractalOctaves(octaves); + return n; + } + + private static void assertKernelMatchesOracle(NoiseKernels2D kernel) { + RNG rng = new RNG(99L); + for (int octaves = 1; octaves <= 8; octaves++) { + FastNoiseDouble n = oracle(1337L + octaves, octaves); + int length = 256; + double[] xs = new double[length]; + double[] zs = new double[length]; + double[] expected = new double[length]; + double[] out = new double[length]; + for (int k = 0; k < length; k++) { + double x = (rng.nextDouble() - 0.5D) * 1_000_000D; + double z = (rng.nextDouble() - 0.5D) * 1_000_000D; + xs[k] = x; + zs[k] = z; + expected[k] = n.GetSimplexFractal(x, z); + } + kernel.simplexFractalFBM(1337L + octaves, octaves, n.getFrequency(), 2.0D, 0.5D, n.getFractalBounding(), xs, zs, out, length); + for (int k = 0; k < length; k++) { + assertEquals("octaves=" + octaves + " idx=" + k, expected[k], out[k], 0D); + } + } + } + + @Test + public void scalarKernelIsBitExactToOracle() { + assertKernelMatchesOracle(new ScalarNoiseKernels2D()); + } + + @Test + public void vectorKernelSingleOctaveIsBitExactToOracle() { + org.junit.Assume.assumeTrue(VectorNoiseKernels2D.lanesAligned()); + FastNoiseDouble n = oracle(202020L, 1); + RNG rng = new RNG(7L); + int length = 333; + double[] xs = new double[length]; + double[] zs = new double[length]; + double[] expected = new double[length]; + double[] out = new double[length]; + for (int k = 0; k < length; k++) { + double x = (rng.nextDouble() - 0.5D) * 800_000D; + double z = (rng.nextDouble() - 0.5D) * 800_000D; + xs[k] = x; + zs[k] = z; + expected[k] = n.GetSimplexFractal(x, z); + } + new VectorNoiseKernels2D().simplexFractalFBM(202020L, 1, n.getFrequency(), 2.0D, 0.5D, n.getFractalBounding(), xs, zs, out, length); + for (int k = 0; k < length; k++) { + assertEquals("idx=" + k, expected[k], out[k], 0D); + } + } + + @Test + public void vectorKernelAllOctavesAreBitExactToOracle() { + org.junit.Assume.assumeTrue(VectorNoiseKernels2D.lanesAligned()); + assertKernelMatchesOracle(new VectorNoiseKernels2D()); + } + + @Test + public void simdSupportReturnsBitExactNoiseKernel() { + NoiseKernels2D kernel = SimdSupport.noiseKernels2D(); + org.junit.Assert.assertNotNull(kernel); + assertKernelMatchesOracle(kernel); + } + + @Test + public void simdSupportNeverSelectsVectorBelowProfitableLanes() { + if (VectorNoiseKernels2D.profitable()) { + org.junit.Assert.assertTrue(SimdSupport.createVectorNoiseKernels2D() instanceof VectorNoiseKernels2D); + } else { + org.junit.Assert.assertTrue(SimdSupport.noiseKernels2D() instanceof ScalarNoiseKernels2D); + } + } + + @Test + public void vectorMatchesScalarAcrossEdgeCases() { + org.junit.Assume.assumeTrue(VectorNoiseKernels2D.lanesAligned()); + NoiseKernels2D scalar = new ScalarNoiseKernels2D(); + NoiseKernels2D vector = new VectorNoiseKernels2D(); + RNG rng = new RNG(31337L); + int[] lengths = {1, 3, 7, 8, 9, 255, 256, 257}; + double[] edgeCoords = {-4.0D, -3.0D, -1.0D, -0.0D, 0.0D, 1.0D, 4.0D, 1_000_000.0D, -1_000_000.0D}; + for (int octaves = 1; octaves <= 8; octaves++) { + for (int length : lengths) { + double[] xs = new double[length]; + double[] zs = new double[length]; + double[] a = new double[length]; + double[] b = new double[length]; + for (int k = 0; k < length; k++) { + boolean edge = (k % 3) == 0; + xs[k] = edge ? edgeCoords[k % edgeCoords.length] : (rng.nextDouble() - 0.5D) * 2_000_000D; + zs[k] = edge ? edgeCoords[(k + 1) % edgeCoords.length] : (rng.nextDouble() - 0.5D) * 2_000_000D; + } + scalar.simplexFractalFBM(77L, octaves, 0.01D, 2.0D, 0.5D, 0.6667D, xs, zs, a, length); + vector.simplexFractalFBM(77L, octaves, 0.01D, 2.0D, 0.5D, 0.6667D, xs, zs, b, length); + for (int k = 0; k < length; k++) { + assertEquals("oct=" + octaves + " len=" + length + " idx=" + k, a[k], b[k], 0D); + } + } + } + } +} diff --git a/docs/superpowers/2026-06-27-x86-simd-validation-handoff.md b/docs/superpowers/2026-06-27-x86-simd-validation-handoff.md new file mode 100644 index 000000000..811635eda --- /dev/null +++ b/docs/superpowers/2026-06-27-x86-simd-validation-handoff.md @@ -0,0 +1,145 @@ +# x86 SIMD Validation Handoff — Iris 2D Simplex Noise Kernel + +**Paste this whole file to the Claude Code session on the x86 PC.** It is a self-contained task. The Mac that built this is Apple Silicon (NEON, 2 lanes, no hardware gather), where the new vectorized noise kernel is a measured *loss* and is deliberately gated OFF. This machine (x86 with AVX2 = 4 lanes or AVX-512 = 8 lanes, with hardware gather) is where the kernel is expected to *win*. Your job is to verify (a) it is still **bit-exact** at 4/8 lanes and (b) **how much faster** it actually is. + +--- + +## Background (what exists) + +A determinism-safe, coordinate-parallel (structure-of-arrays) `jdk.incubator.vector` kernel for 2D FBM simplex noise was added to Iris, with a scalar fallback: + +- `core/src/main/java/art/arcane/iris/util/simd/NoiseKernels2D.java` — interface +- `core/src/main/java/art/arcane/iris/util/simd/ScalarNoiseKernels2D.java` — scalar reference (mirrors `FastNoiseDouble`) +- `core/src/main/java/art/arcane/iris/util/simd/VectorNoiseKernels2D.java` — Vector-API impl; `profitable() = lanesAligned() && DoubleVector.SPECIES_PREFERRED.length() >= 4` +- `core/src/main/java/art/arcane/iris/util/simd/SimdSupport.java` — `noiseKernels2D()` selects vector only when `profitable()`, else scalar +- `core/src/test/java/art/arcane/iris/util/simd/NoiseKernels2DParityTest.java` — 6 bit-exact (`0D`) parity tests vs `FastNoiseDouble` + +Every lane computes one coordinate's identical scalar op sequence, so results are bit-identical to scalar by construction — **but this has only ever executed at 2 lanes.** You are the first to run it at 4/8. + +Java 25 is required. The Gradle test JVM already adds `--add-modules jdk.incubator.vector` (in `core/build.gradle`). + +--- + +## Your tasks (run from the Iris project root) + +### 1. Confirm the host actually vectorizes ≥4 lanes + +Run this throwaway check (or infer from step 2's skip count): + +```bash +cat > /tmp/Lanes.java <<'EOF' +import jdk.incubator.vector.DoubleVector; +public class Lanes { + public static void main(String[] a){ + System.out.println("arch="+System.getProperty("os.arch") + +" doubleLanes="+DoubleVector.SPECIES_PREFERRED.length() + +" shape="+DoubleVector.SPECIES_PREFERRED.vectorShape()); + } +} +EOF +java --add-modules jdk.incubator.vector /tmp/Lanes.java +``` + +Expect `doubleLanes=4` (AVX2) or `8` (AVX-512). If it prints `2`, this host is NOT a wider-vector machine and the rest of the validation won't be meaningful — report that and stop. + +### 2. Bit-exactness at 4/8 lanes (the determinism gate) + +```bash +./gradlew :core:test --tests 'art.arcane.iris.util.simd.NoiseKernels2DParityTest' --rerun-tasks +``` + +PASS criteria — read `core/build/test-results/test/TEST-art.arcane.iris.util.simd.NoiseKernels2DParityTest.xml`: +- `tests="6" failures="0" errors="0" skipped="0"` +- **`skipped="0"` is critical**: the vector parity tests are guarded by `assumeTrue(VectorNoiseKernels2D.lanesAligned())`. On this host they MUST run (not skip), exercising the vector path at this host's lane width. A skip means the vector path didn't execute — investigate. +- Any single non-zero delta is a determinism failure: the JDK's AVX2/AVX-512 `D2L`/`L2D`/mask-cast/gather intrinsics would have diverged from scalar. That is a hard blocker — capture the exact failing test, octave, index, expected vs actual. + +### 3. Measure the real speedup + +The benchmark harness was removed from the Mac (it was a measurement, not shipped). Re-create it here, run it, record the number, then delete it. + +Create `core/src/test/java/art/arcane/iris/util/simd/NoiseKernels2DBenchmarkHarness.java`: + +```java +package art.arcane.iris.util.simd; + +import art.arcane.volmlib.util.math.RNG; +import org.junit.Test; + +public class NoiseKernels2DBenchmarkHarness { + @Test + public void benchmark() { + if (!Boolean.getBoolean("noise.bench")) { + return; + } + int length = 256; + int octaves = 4; + double[] xs = new double[length]; + double[] zs = new double[length]; + double[] out = new double[length]; + RNG rng = new RNG(5L); + for (int k = 0; k < length; k++) { + xs[k] = (rng.nextDouble() - 0.5D) * 1_000_000D; + zs[k] = (rng.nextDouble() - 0.5D) * 1_000_000D; + } + NoiseKernels2D scalar = new ScalarNoiseKernels2D(); + NoiseKernels2D vector = new VectorNoiseKernels2D(); + long scalarNs = time(scalar, octaves, xs, zs, out, length); + long vectorNs = time(vector, octaves, xs, zs, out, length); + System.out.println("BENCH lanes=" + VectorNoiseKernels2D.profitable() + + " doubleLanes=" + jdk.incubator.vector.DoubleVector.SPECIES_PREFERRED.length() + + " scalarNsPerCol=" + scalarNs + " vectorNsPerCol=" + vectorNs + + " speedup=" + String.format("%.2f", (double) scalarNs / (double) vectorNs)); + } + + private static long time(NoiseKernels2D kernel, int octaves, double[] xs, double[] zs, double[] out, int length) { + for (int w = 0; w < 20_000; w++) { + kernel.simplexFractalFBM(123L, octaves, 0.01D, 2.0D, 0.5D, 0.6667D, xs, zs, out, length); + } + long start = System.nanoTime(); + int iters = 200_000; + for (int it = 0; it < iters; it++) { + kernel.simplexFractalFBM(123L, octaves, 0.01D, 2.0D, 0.5D, 0.6667D, xs, zs, out, length); + } + long elapsed = System.nanoTime() - start; + return elapsed / ((long) iters * (long) length); + } +} +``` + +Add this temporary block INSIDE the existing `tasks.named('test', Test) { ... }` in `core/build.gradle` (use the Edit tool; do not disturb the existing `jvmArgs('--add-modules', 'jdk.incubator.vector')` line): + +```groovy + if (project.hasProperty('noise.bench')) { + systemProperty('noise.bench', project.property('noise.bench')) + testLogging { showStandardStreams = true } + } +``` + +Run: + +```bash +./gradlew :core:test --tests 'art.arcane.iris.util.simd.NoiseKernels2DBenchmarkHarness' -Pnoise.bench=true +``` + +Capture the `BENCH ... speedup=...` line. + +Then CLEAN UP (do not commit either): delete the harness file and remove ONLY the `noise.bench` lines you added from `core/build.gradle` (edit them out by hand — do NOT `git checkout`/`git restore` the file, there may be other uncommitted work). Confirm with `git diff HEAD -- core/build.gradle` showing no diff. + +--- + +## Report back to the Mac (this exact info) + +1. `os.arch` and `doubleLanes` (4 or 8) from step 1. +2. Step 2 parity result: the `tests/failures/errors/skipped` counts. (Must be `6/0/0/0`.) If any failure: the failing test name, octave, index, expected vs actual. +3. Step 3 `BENCH` line: `scalarNsPerCol`, `vectorNsPerCol`, `speedup`. +4. Confirmation the benchmark harness was deleted and `core/build.gradle` restored to no-diff. + +That's it — do NOT wire the kernel into world generation, do NOT commit, do NOT touch any unrelated modified files in the working tree. This is a measurement-only task. + +--- + +## What the numbers decide (context for whoever relays this) + +- **Parity 6/0/0/0 with 0 skips** → the kernel is bit-exact at this lane width; the determinism gate holds on x86. Required before it can ever be wired into generation. +- **speedup > 1** (ideally ≥1.5–2× at 4 lanes, more at 8) → confirms the kernel is worth wiring into the live noise pipeline (Phase 2: batch the composite height/biome/cave noise paths, which is the deep part). Noise is ~33% of generation CPU, so a real per-eval win translates to a meaningful pregen/gen throughput gain. +- **speedup ≤ 1 even here** → the approach doesn't pay even with hardware gather + wide lanes; do not wire it; revisit a different optimization (e.g. the ~14% NMS chunk-write cost) or GPU compute. diff --git a/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java b/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java index cf6812b46..beb786f33 100644 --- a/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java +++ b/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java @@ -23,7 +23,6 @@ import art.arcane.iris.spi.LogLevel; import art.arcane.iris.spi.PlatformBiome; import art.arcane.iris.spi.PlatformBiomeWriter; import art.arcane.iris.spi.PlatformBlockState; -import art.arcane.iris.spi.PlatformCapabilities; import art.arcane.iris.spi.PlatformEntityType; import art.arcane.iris.spi.PlatformItem; import art.arcane.iris.spi.PlatformRegistries; @@ -50,8 +49,6 @@ public final class StubPlatform implements IrisPlatform { private final StubRegistries registries = new StubRegistries(); private final StubScheduler scheduler = new StubScheduler(); - private final PlatformCapabilities capabilities = new PlatformCapabilities() { - }; private final StubStructureHooks structureHooks = new StubStructureHooks(); private final StubBiomeWriter biomeWriter = new StubBiomeWriter(); @@ -379,11 +376,6 @@ public final class StubPlatform implements IrisPlatform { return scheduler; } - @Override - public PlatformCapabilities capabilities() { - return capabilities; - } - @Override public PlatformStructureHooks structureHooks() { return structureHooks; @@ -432,11 +424,6 @@ public final class StubPlatform implements IrisPlatform { return false; } - @Override - public boolean giveItem(Object player, String itemKey, int amount) { - return false; - } - @Override public void log(LogLevel level, String message) { if (VERBOSE) { diff --git a/spi/src/main/java/art/arcane/iris/spi/ChunkWriteTarget.java b/spi/src/main/java/art/arcane/iris/spi/ChunkWriteTarget.java deleted file mode 100644 index 250148dbf..000000000 --- a/spi/src/main/java/art/arcane/iris/spi/ChunkWriteTarget.java +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Iris is a World Generator for Minecraft Bukkit Servers - * Copyright (c) 2026 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.spi; - -/** - * Hot-path chunk write surface backed by the fastest native section-write mechanism each adapter offers. - */ -public interface ChunkWriteTarget { - void setBlock(int x, int y, int z, PlatformBlockState state); - - void setBiome(int x, int y, int z, PlatformBiome biome); - - int minHeight(); - - int maxHeight(); -} diff --git a/spi/src/main/java/art/arcane/iris/spi/IrisPlatform.java b/spi/src/main/java/art/arcane/iris/spi/IrisPlatform.java index 03c7ff089..833799a3e 100644 --- a/spi/src/main/java/art/arcane/iris/spi/IrisPlatform.java +++ b/spi/src/main/java/art/arcane/iris/spi/IrisPlatform.java @@ -32,8 +32,6 @@ public interface IrisPlatform { PlatformScheduler scheduler(); - PlatformCapabilities capabilities(); - PlatformStructureHooks structureHooks(); PlatformBiomeWriter biomeWriter(); @@ -72,8 +70,6 @@ public interface IrisPlatform { boolean spawnEntity(Object world, String entityKey, double x, double y, double z); - boolean giveItem(Object player, String itemKey, int amount); - void log(LogLevel level, String message); void msg(String message);