diff --git a/adapters/bukkit/nms/v26_1_R1/src/main/java/art/arcane/iris/core/nms/v26_1_R1/NMSBinding.java b/adapters/bukkit/nms/v26_1_R1/src/main/java/art/arcane/iris/core/nms/v26_1_R1/NMSBinding.java index 6186c0462..5e0f8122d 100644 --- a/adapters/bukkit/nms/v26_1_R1/src/main/java/art/arcane/iris/core/nms/v26_1_R1/NMSBinding.java +++ b/adapters/bukkit/nms/v26_1_R1/src/main/java/art/arcane/iris/core/nms/v26_1_R1/NMSBinding.java @@ -859,88 +859,6 @@ public class NMSBinding implements INMSBinding { } } - @Override - public boolean writeChunkNbtDirect(NBTWorld nbtWorld, int chunkX, int chunkZ, Hunk blocks, Hunk biomes) { - try { - art.arcane.iris.util.nbt.common.mca.MCAFile mca = nbtWorld.getMCA(chunkX >> 5, chunkZ >> 5); - if (mca == null) { - return false; - } - - art.arcane.iris.util.nbt.common.mca.Chunk chunk = art.arcane.iris.util.nbt.common.mca.Chunk.newChunk(); - art.arcane.iris.util.nbt.common.mca.Chunk.injectIrisData(chunk); - - int blockHeight = blocks.getHeight(); - java.util.IdentityHashMap encodeCache = new java.util.IdentityHashMap<>(64); - for (int y = 0; y < blockHeight; y++) { - for (int z = 0; z < 16; z++) { - for (int x = 0; x < 16; x++) { - PlatformBlockState platformState = blocks.getRaw(x, y, z); - if (platformState == null) { - continue; - } - Object nativeHandle = platformState.nativeHandle(); - if (!(nativeHandle instanceof BlockData blockData)) { - continue; - } - if (blockData instanceof IrisCustomData customData) { - blockData = customData.getBase(); - } - if (blockData.getMaterial().isAir()) { - continue; - } - CompoundTag nbtState = encodeCache.get(blockData); - if (nbtState == null) { - nbtState = NBTWorld.getCompound(blockData); - if (nbtState == null) { - continue; - } - encodeCache.put(blockData, nbtState); - } - chunk.setBlockStateAt(x, y, z, nbtState, false); - } - } - } - - int biomeHeight = biomes.getHeight(); - for (int by = 0; by < biomeHeight; by += 4) { - int quartY = by >> 2; - for (int bz = 0; bz < 16; bz += 4) { - int quartZ = bz >> 2; - for (int bx = 0; bx < 16; bx += 4) { - int quartX = bx >> 2; - PlatformBiome platformBiome = biomes.getRaw(bx, by, bz); - if (platformBiome == null) { - continue; - } - String biomeKey = platformBiome.key(); - if (biomeKey == null || biomeKey.isEmpty()) { - continue; - } - int biomeId = getBiomeBaseIdForKey(biomeKey); - if (biomeId < 0) { - continue; - } - chunk.setBiomeAt(quartX, quartY, quartZ, biomeId); - } - } - } - - chunk.cleanupPalettesAndBlockStates(); - - synchronized (mca) { - mca.setChunk(chunkX & 31, chunkZ & 31, chunk); - } - - return true; - } catch (Throwable e) { - IrisLogging.warn("writeChunkNbtDirect failed at " + chunkX + "," + chunkZ + ": " + e.getClass().getSimpleName() + ": " + e.getMessage()); - e.printStackTrace(System.err); - IrisLogging.reportError(e); - return false; - } - } - @Override public boolean forceEvictChunk(World world, int chunkX, int chunkZ) { try { 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 9ea235e61..84b6a7f46 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 @@ -39,6 +39,7 @@ import art.arcane.iris.core.pack.BrokenPackException; import art.arcane.iris.core.pack.PackValidationRegistry; import art.arcane.iris.core.pack.PackValidationResult; import art.arcane.iris.core.pack.PackValidator; +import art.arcane.iris.core.gui.BukkitGuiHost; import art.arcane.iris.core.gui.PregeneratorJob; import art.arcane.iris.core.service.EditSVC; import art.arcane.iris.core.service.PreservationSVC; @@ -947,6 +948,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { public void onEnable() { IrisPlatforms.bind(new BukkitPlatform()); enable(); + BukkitGuiHost.install(); super.onEnable(); Bukkit.getPluginManager().registerEvents(this, this); } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStudio.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStudio.java index aa10d26c4..9e6a3a6bc 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStudio.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStudio.java @@ -323,7 +323,7 @@ public class CommandStudio implements DirectorExecutor { return; } - VisionGUI.launch(IrisToolbelt.access(world).getEngine(), 0); + VisionGUI.launch(IrisToolbelt.access(world).getEngine()); sender().sendMessage(C.GREEN + "Opening map!"); } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/gui/BukkitGuiHost.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/gui/BukkitGuiHost.java new file mode 100644 index 000000000..8eb3b800a --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/gui/BukkitGuiHost.java @@ -0,0 +1,91 @@ +/* + * 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.gui; + +import art.arcane.iris.Iris; +import art.arcane.iris.core.events.IrisEngineHotloadEvent; +import art.arcane.iris.core.tools.IrisToolbelt; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.platform.PlatformChunkGenerator; +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; + +import java.util.ArrayList; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public final class BukkitGuiHost implements GuiHost.Provider { + private final Map hotloadHooks = new ConcurrentHashMap<>(); + + public static void install() { + GuiHost.set(new BukkitGuiHost()); + } + + @Override + public Engine findActiveEngine() { + try { + for (World world : new ArrayList<>(Bukkit.getWorlds())) { + try { + PlatformChunkGenerator access = IrisToolbelt.access(world); + if (access != null && access.getEngine() != null && !access.getEngine().isClosed()) { + return access.getEngine(); + } + } catch (Throwable ignored) { + } + } + } catch (Throwable ignored) { + } + return null; + } + + @Override + public void registerHotloadHook(Runnable onHotload) { + Listener listener = new HotloadListener(onHotload); + hotloadHooks.put(onHotload, listener); + Iris.instance.registerListener(listener); + } + + @Override + public void unregisterHotloadHook(Runnable onHotload) { + Listener listener = hotloadHooks.remove(onHotload); + if (listener != null) { + Iris.instance.unregisterListener(listener); + } + } + + @Override + public GuiOverlay overlayFor(Engine engine) { + return engine == null ? null : new BukkitVisionOverlay(engine); + } + + private static final class HotloadListener implements Listener { + private final Runnable onHotload; + + private HotloadListener(Runnable onHotload) { + this.onHotload = onHotload; + } + + @EventHandler + public void on(IrisEngineHotloadEvent event) { + onHotload.run(); + } + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/gui/BukkitVisionOverlay.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/gui/BukkitVisionOverlay.java new file mode 100644 index 000000000..f53fd46b9 --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/gui/BukkitVisionOverlay.java @@ -0,0 +1,111 @@ +/* + * 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.gui; + +import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.render.RenderType; +import art.arcane.iris.engine.object.IrisWorld; +import art.arcane.iris.util.common.scheduling.J; +import art.arcane.volmlib.util.format.Form; +import org.bukkit.Location; +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Player; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.function.Consumer; + +import static art.arcane.iris.util.common.data.registry.Attributes.MAX_HEALTH; + +public final class BukkitVisionOverlay implements GuiOverlay { + private final Engine engine; + + public BukkitVisionOverlay(Engine engine) { + this.engine = engine; + } + + @Override + public List players() { + IrisWorld world = engine.getWorld(); + List markers = new ArrayList<>(); + for (Player player : world.getPlayers()) { + markers.add(GuiMarker.player(player.getName(), player.getLocation().getX(), player.getLocation().getZ())); + } + return markers; + } + + @Override + public void requestEntities(Consumer> sink) { + J.s(() -> { + IrisWorld world = engine.getWorld(); + List markers = new ArrayList<>(); + for (LivingEntity entity : world.getEntitiesByClass(LivingEntity.class)) { + if (entity instanceof Player) { + continue; + } + String label = Form.capitalizeWords(entity.getType().name().toLowerCase(Locale.ROOT).replaceAll("\\Q_\\E", " ")); + double maxHealth = 0; + try { + maxHealth = entity.getAttribute(MAX_HEALTH).getValue(); + } catch (Throwable ignored) { + } + markers.add(GuiMarker.entity(label, entity.getLocation().getX(), entity.getLocation().getY(), entity.getLocation().getZ(), + entity.getHealth(), maxHealth)); + } + sink.accept(markers); + }); + } + + @Override + public void teleport(double worldX, double worldZ) { + IrisWorld world = engine.getWorld(); + if (!world.hasRealWorld()) { + return; + } + J.s(() -> { + List players = world.getPlayers(); + if (players.isEmpty()) { + return; + } + Player player = players.get(0); + int xx = (int) worldX; + int zz = (int) worldZ; + int yy = player.getWorld().getHighestBlockYAt(xx, zz) + 1; + player.teleport(new Location(player.getWorld(), xx, yy, zz)); + }); + } + + @Override + public String openInEditor(double worldX, double worldZ, RenderType type) { + 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/fabric/build.gradle b/adapters/fabric/build.gradle index f5618e644..972e48db6 100644 --- a/adapters/fabric/build.gradle +++ b/adapters/fabric/build.gradle @@ -147,6 +147,7 @@ tasks.named('compileJava', JavaCompile).configure { } loom { + accessWidenerPath = file('src/main/resources/irisworldgen.accesswidener') runs { server { String parity = providers.gradleProperty('irisParity').getOrNull() diff --git a/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricForcedDatapackSources.java b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricForcedDatapackSources.java new file mode 100644 index 000000000..6b911cc0f --- /dev/null +++ b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricForcedDatapackSources.java @@ -0,0 +1,48 @@ +/* + * 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.fabric; + +import art.arcane.iris.modded.ModdedForcedDatapack; +import net.minecraft.server.packs.repository.PackRepository; +import net.minecraft.server.packs.repository.RepositorySource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.LinkedHashSet; +import java.util.Set; + +public final class FabricForcedDatapackSources { + private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + + private FabricForcedDatapackSources() { + } + + public static void attach(PackRepository repository) { + if (repository == null) { + return; + } + try { + Set merged = new LinkedHashSet<>(repository.sources); + merged.add(ModdedForcedDatapack.repositorySource()); + repository.sources = merged; + } catch (Throwable e) { + LOGGER.error("Iris failed to attach the forced startup datapack source to the server data pack repository", e); + } + } +} 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 918f1a4a8..2ebea92ad 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 @@ -72,13 +72,17 @@ public final class IrisFabricBootstrap implements ModInitializer { ModdedObjectUndo.clearAll(); ModdedWandService.clearAll(); ModdedWorldEngines.shutdown(); + 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); UseBlockCallback.EVENT.register((Player player, Level level, InteractionHand hand, BlockHitResult hit) -> ModdedWandService.useBlock(player, level, hand, hit.getBlockPos()) ? InteractionResult.SUCCESS : InteractionResult.PASS); - ServerTickEvents.END_SERVER_TICK.register((MinecraftServer server) -> ModdedWandService.serverTick(server)); + ServerTickEvents.END_SERVER_TICK.register((MinecraftServer server) -> { + ModdedEngineBootstrap.tick(server); + ModdedWandService.serverTick(server); + }); String parity = System.getProperty("iris.parity"); if (parity != null) { diff --git a/adapters/fabric/src/main/java/art/arcane/iris/fabric/mixin/ServerPacksSourceMixin.java b/adapters/fabric/src/main/java/art/arcane/iris/fabric/mixin/ServerPacksSourceMixin.java new file mode 100644 index 000000000..2cf85eb8d --- /dev/null +++ b/adapters/fabric/src/main/java/art/arcane/iris/fabric/mixin/ServerPacksSourceMixin.java @@ -0,0 +1,36 @@ +/* + * 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.fabric.mixin; + +import art.arcane.iris.fabric.FabricForcedDatapackSources; +import net.minecraft.server.packs.repository.PackRepository; +import net.minecraft.server.packs.repository.ServerPacksSource; +import net.minecraft.world.level.storage.LevelStorageSource; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(ServerPacksSource.class) +public class ServerPacksSourceMixin { + @Inject(method = "createPackRepository(Lnet/minecraft/world/level/storage/LevelStorageSource$LevelStorageAccess;)Lnet/minecraft/server/packs/repository/PackRepository;", at = @At("RETURN")) + private static void iris$addForcedDatapackSource(LevelStorageSource.LevelStorageAccess storage, CallbackInfoReturnable info) { + FabricForcedDatapackSources.attach(info.getReturnValue()); + } +} diff --git a/adapters/fabric/src/main/resources/fabric.mod.json b/adapters/fabric/src/main/resources/fabric.mod.json index 8e4f739d2..48c3f1883 100644 --- a/adapters/fabric/src/main/resources/fabric.mod.json +++ b/adapters/fabric/src/main/resources/fabric.mod.json @@ -10,6 +10,8 @@ }, "license": "GPL-3.0", "environment": "*", + "accessWidener": "irisworldgen.accesswidener", + "mixins": ["irisworldgen.mixins.json"], "entrypoints": { "main": ["art.arcane.iris.fabric.IrisFabricBootstrap"] }, diff --git a/adapters/fabric/src/main/resources/irisworldgen.accesswidener b/adapters/fabric/src/main/resources/irisworldgen.accesswidener new file mode 100644 index 000000000..573ad0649 --- /dev/null +++ b/adapters/fabric/src/main/resources/irisworldgen.accesswidener @@ -0,0 +1,6 @@ +accessWidener v2 official +accessible field net/minecraft/server/MinecraftServer levels Ljava/util/Map; +accessible field net/minecraft/server/MinecraftServer executor Ljava/util/concurrent/Executor; +accessible field net/minecraft/server/MinecraftServer storageSource Lnet/minecraft/world/level/storage/LevelStorageSource$LevelStorageAccess; +accessible field net/minecraft/server/packs/repository/PackRepository sources Ljava/util/Set; +mutable field net/minecraft/server/packs/repository/PackRepository sources Ljava/util/Set; diff --git a/adapters/fabric/src/main/resources/irisworldgen.mixins.json b/adapters/fabric/src/main/resources/irisworldgen.mixins.json new file mode 100644 index 000000000..14a174a8b --- /dev/null +++ b/adapters/fabric/src/main/resources/irisworldgen.mixins.json @@ -0,0 +1,12 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "art.arcane.iris.fabric.mixin", + "compatibilityLevel": "JAVA_25", + "mixins": [ + "ServerPacksSourceMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/adapters/forge/build.gradle b/adapters/forge/build.gradle index 56767749d..bdb28a787 100644 --- a/adapters/forge/build.gradle +++ b/adapters/forge/build.gradle @@ -170,6 +170,7 @@ dependencies { } minecraft { + accessTransformer.from(file('src/main/resources/META-INF/accesstransformer.cfg')) runs { register('server') { workingDir = layout.projectDirectory.dir('run') @@ -214,6 +215,7 @@ tasks.named('shadowJar', ShadowJar).configure { } archiveFileName.set(irisArtifactName('Forge', "${minecraftVersion}+${loaderDisplayVersion(forgeVersion)}")) configurations = [project.configurations.named('bundle').get()] + from(sourceSets.main.output) mergeServiceFiles() exclude('META-INF/maven/**') exclude('META-INF/proguard/**') @@ -223,7 +225,16 @@ tasks.named('shadowJar', ShadowJar).configure { exclude('org/apache/commons/lang/enum/**') duplicatesStrategy = DuplicatesStrategy.EXCLUDE relocate('org.objectweb.asm', 'art.arcane.iris.shadow.asm') + relocate('com.sun.jna', 'art.arcane.iris.shadow.jna') + relocate('oshi', 'art.arcane.iris.shadow.oshi') + relocate('net.jpountz', 'art.arcane.iris.shadow.jpountz') exclude('oshi.properties') + exclude('org/jspecify/**') + exclude('com/google/errorprone/**') + exclude('com/google/j2objc/**') + exclude('org/checkerframework/**') + exclude('org/jetbrains/annotations/**') + exclude('org/intellij/lang/**') } tasks.named('assemble').configure { 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 9a6430df1..7b949d51b 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 @@ -20,6 +20,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; @@ -29,7 +30,9 @@ 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; +import net.minecraft.server.packs.PackType; import net.minecraft.world.level.chunk.ChunkGenerator; +import net.minecraftforge.event.AddPackFindersEvent; import net.minecraftforge.event.RegisterCommandsEvent; import net.minecraftforge.event.TickEvent; import net.minecraftforge.event.entity.player.PlayerInteractEvent; @@ -66,13 +69,22 @@ public final class IrisForgeBootstrap { ModdedObjectUndo.clearAll(); ModdedWandService.clearAll(); ModdedWorldEngines.shutdown(); + ModdedEngineBootstrap.stop(); + }); + AddPackFindersEvent.BUS.addListener((AddPackFindersEvent event) -> { + if (event.getPackType() == PackType.SERVER_DATA) { + event.addRepositorySource(ModdedForcedDatapack.repositorySource()); + } }); RegisterCommandsEvent.BUS.addListener((RegisterCommandsEvent event) -> IrisModdedCommands.register(event.getDispatcher())); PlayerInteractEvent.LeftClickBlock.BUS.addListener((Predicate) (PlayerInteractEvent.LeftClickBlock event) -> ModdedWandService.attackBlock(event.getEntity(), event.getLevel(), event.getHand(), event.getPos())); PlayerInteractEvent.RightClickBlock.BUS.addListener((Predicate) (PlayerInteractEvent.RightClickBlock event) -> ModdedWandService.useBlock(event.getEntity(), event.getLevel(), event.getHand(), event.getPos())); - TickEvent.ServerTickEvent.Post.BUS.addListener((TickEvent.ServerTickEvent.Post event) -> ModdedWandService.serverTick(event.server())); + TickEvent.ServerTickEvent.Post.BUS.addListener((TickEvent.ServerTickEvent.Post event) -> { + ModdedEngineBootstrap.tick(event.server()); + ModdedWandService.serverTick(event.server()); + }); String parity = System.getProperty("iris.parity"); if (parity != null) { diff --git a/adapters/forge/src/main/resources/META-INF/accesstransformer.cfg b/adapters/forge/src/main/resources/META-INF/accesstransformer.cfg new file mode 100644 index 000000000..5a2faeeb3 --- /dev/null +++ b/adapters/forge/src/main/resources/META-INF/accesstransformer.cfg @@ -0,0 +1,3 @@ +public net.minecraft.server.MinecraftServer levels +public net.minecraft.server.MinecraftServer executor +public net.minecraft.server.MinecraftServer storageSource 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 e52748406..15b42518f 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 @@ -79,10 +79,43 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { private final ConcurrentHashMap> biomeHolders = new ConcurrentHashMap<>(); private final AtomicBoolean announced = new AtomicBoolean(false); private volatile Engine engine; + private volatile String activePackKey; + private volatile long seedOverride = Long.MIN_VALUE; public IrisModdedChunkGenerator(BiomeSource biomeSource, String dimensionKey) { super(biomeSource); this.dimensionKey = dimensionKey; + this.activePackKey = dimensionKey; + } + + public synchronized void repoint(String packKey, long seed) { + ServerLevel level = boundLevel(); + if (level != null) { + ModdedWorldEngines.evict(level); + } + this.activePackKey = packKey; + this.seedOverride = seed; + this.engine = null; + this.announced.set(false); + this.biomeHolders.clear(); + } + + public synchronized void unbindEngine() { + ServerLevel level = boundLevel(); + if (level != null) { + ModdedWorldEngines.evict(level); + } + this.engine = null; + this.announced.set(false); + this.biomeHolders.clear(); + } + + public synchronized void resetToDefault() { + repoint(dimensionKey, Long.MIN_VALUE); + } + + public String activePackKey() { + return activePackKey; } @Override @@ -116,7 +149,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, dimensionKey); + Engine created = ModdedWorldEngines.get(level, activePackKey, seedOverride); engine = created; return created; } 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 b18926ef1..2ac79bf28 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 @@ -18,6 +18,9 @@ package art.arcane.iris.modded; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisBiomeCustom; import art.arcane.iris.spi.PlatformBiome; import art.arcane.iris.spi.PlatformBiomeWriter; import net.minecraft.core.Registry; @@ -25,12 +28,17 @@ import net.minecraft.core.registries.Registries; import net.minecraft.resources.Identifier; import net.minecraft.server.MinecraftServer; import net.minecraft.world.level.biome.Biome; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.util.ArrayList; import java.util.List; import java.util.function.Supplier; public final class ModdedBiomeWriter implements PlatformBiomeWriter { + private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + private static final String VANILLA_FALLBACK_KEY = "minecraft:plains"; + private final Supplier server; public ModdedBiomeWriter(Supplier server) { @@ -40,12 +48,18 @@ public final class ModdedBiomeWriter implements PlatformBiomeWriter { @Override public int biomeIdFor(String key) { Registry registry = biomeRegistry(); - Identifier identifier = Identifier.tryParse(key); - if (registry == null || identifier == null) { + if (registry == null) { return 0; } - Biome biome = registry.getValue(identifier); - return biome == null ? 0 : registry.getId(biome); + int direct = idForKey(registry, key); + if (direct >= 0) { + return direct; + } + int derivative = idForDerivative(registry, key); + if (derivative >= 0) { + return derivative; + } + return fallbackId(registry); } @Override @@ -64,6 +78,60 @@ public final class ModdedBiomeWriter implements PlatformBiomeWriter { return biomes; } + private int idForKey(Registry registry, String key) { + Identifier identifier = Identifier.tryParse(key); + if (identifier == null) { + return -1; + } + Biome biome = registry.getValue(identifier); + return biome == null ? -1 : registry.getId(biome); + } + + private int idForDerivative(Registry registry, String key) { + int colon = key.indexOf(':'); + if (colon <= 0 || colon >= key.length() - 1) { + return -1; + } + String dimensionLoadKey = key.substring(0, colon); + String customBiomeId = key.substring(colon + 1); + IrisBiome owner = findCustomBiomeOwner(dimensionLoadKey, customBiomeId); + if (owner == null) { + return -1; + } + org.bukkit.block.Biome derivative = owner.getVanillaDerivative(); + if (derivative == null || derivative.getKey() == null) { + return -1; + } + return idForKey(registry, derivative.getKey().toString()); + } + + private IrisBiome findCustomBiomeOwner(String dimensionLoadKey, String customBiomeId) { + for (Engine engine : ModdedWorldEngines.activeEngines()) { + if (engine == null || engine.isClosed()) { + continue; + } + if (!dimensionLoadKey.equalsIgnoreCase(engine.getDimension().getLoadKey())) { + continue; + } + for (IrisBiome biome : engine.getDimension().getAllBiomes(engine)) { + if (!biome.isCustom()) { + continue; + } + for (IrisBiomeCustom custom : biome.getCustomDerivitives()) { + if (customBiomeId.equals(custom.getId())) { + return biome; + } + } + } + } + return null; + } + + private int fallbackId(Registry registry) { + int plains = idForKey(registry, VANILLA_FALLBACK_KEY); + return plains >= 0 ? plains : 0; + } + private Registry biomeRegistry() { MinecraftServer instance = server.get(); if (instance == null) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockResolution.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockResolution.java index 466ff1968..56823efad 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockResolution.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockResolution.java @@ -19,6 +19,7 @@ package art.arcane.iris.modded; import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.modded.api.ModdedCustomContentRegistry; import art.arcane.iris.spi.IrisLogging; import com.mojang.brigadier.StringReader; import com.mojang.brigadier.exceptions.CommandSyntaxException; @@ -217,6 +218,10 @@ public final class ModdedBlockResolution { Parsed bdx = parseBlockData(bd, warn); if (bdx == null) { + BlockState provided = ModdedCustomContentRegistry.resolveBlock(bd); + if (provided != null) { + return new Parsed(provided, null); + } if (warn && shouldWarn()) { IrisLogging.warn("Unknown Block Data '" + bd + "'"); } 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 new file mode 100644 index 000000000..941e8483c --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionManager.java @@ -0,0 +1,265 @@ +/* + * 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 net.minecraft.core.Holder; +import net.minecraft.core.Registry; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +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.level.Level; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.biome.BiomeManager; +import net.minecraft.world.level.biome.Biomes; +import net.minecraft.world.level.biome.FixedBiomeSource; +import net.minecraft.world.level.dimension.BuiltinDimensionTypes; +import net.minecraft.world.level.dimension.DimensionType; +import net.minecraft.world.level.dimension.LevelStem; +import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.level.storage.DerivedLevelData; +import net.minecraft.world.level.storage.LevelStorageSource; +import net.minecraft.world.level.storage.ServerLevelData; +import net.minecraft.world.level.storage.WorldData; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; + +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 volatile ModdedServerAccess access; + + private ModdedDimensionManager() { + } + + public static void bindAccess(ModdedServerAccess serverAccess) { + access = serverAccess; + } + + public static Handle handle(String dimensionId) { + return HANDLES.get(dimensionId); + } + + public static List handles() { + return new ArrayList<>(HANDLES.values()); + } + + public static ServerLevel level(MinecraftServer server, String dimensionId) { + Handle handle = HANDLES.get(dimensionId); + if (handle != null && handle.level().getServer() == server) { + return handle.level(); + } + ResourceKey key = levelKey(dimensionId); + for (ServerLevel level : server.getAllLevels()) { + if (level.dimension().equals(key)) { + return level; + } + } + return null; + } + + public static Engine engine(MinecraftServer server, String dimensionId) { + ServerLevel level = level(server, dimensionId); + if (level == null) { + return null; + } + if (!(level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator generator)) { + return null; + } + return generator.commandEngine(); + } + + public static Handle create(MinecraftServer server, String dimensionId, String packKey, 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; + } + if (serverAccess.hasLevel(server, key)) { + ServerLevel present = level(server, dimensionId); + if (present == null || !(present.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator generator)) { + 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); + HANDLES.put(dimensionId, handle); + return handle; + } + + try { + Handle handle = inject(server, serverAccess, dimensionId, key, packKey, seed); + HANDLES.put(dimensionId, handle); + LOGGER.info("Iris injected runtime dimension '{}' (pack={} seed={})", dimensionId, packKey, seed); + return handle; + } catch (Throwable e) { + LOGGER.error("Iris failed to inject runtime dimension '{}' (pack={} seed={})", dimensionId, packKey, 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)); + return handle; + } + + public static boolean removePersistent(MinecraftServer server, String dimensionId) { + boolean removed = remove(server, dimensionId); + 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) { + ResourceKey key = levelKey(dimensionId); + ServerLevel level = level(server, dimensionId); + if (level == null) { + HANDLES.remove(dimensionId); + return false; + } + try { + evacuate(server, level); + if (level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator generator) { + generator.unbindEngine(); + } + ModdedWorldEngines.evict(level); + level.save(null, true, false); + serverAccess.removeLevel(server, key); + level.close(); + HANDLES.remove(dimensionId); + if (wipeStorage) { + ModdedDimensionStorage.wipe(server, key); + } + LOGGER.info("Iris removed runtime dimension '{}'", dimensionId); + return true; + } catch (Throwable e) { + LOGGER.error("Iris failed to remove runtime dimension '{}'", dimensionId, e); + throw new IllegalStateException("Iris runtime dimension removal failed for " + dimensionId, e); + } + } + } + + public static boolean teleport(ServerPlayer player, MinecraftServer server, String dimensionId, double x, double y, double z) { + ServerLevel level = level(server, dimensionId); + if (level == null) { + return false; + } + int blockX = (int) Math.floor(x); + int blockZ = (int) Math.floor(z); + 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) { + Registry registry = registryAccess.lookupOrThrow(Registries.DIMENSION_TYPE); + 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) { + RegistryAccess registryAccess = server.registryAccess(); + Holder dimensionType = resolveDimensionType(registryAccess); + Holder plains = registryAccess.lookupOrThrow(Registries.BIOME).getOrThrow(Biomes.PLAINS); + FixedBiomeSource biomeSource = new FixedBiomeSource(plains); + IrisModdedChunkGenerator generator = new IrisModdedChunkGenerator(biomeSource, packKey); + generator.repoint(packKey, seed); + LevelStem stem = new LevelStem(dimensionType, generator); + + WorldData worldData = server.getWorldData(); + ServerLevelData overworldData = worldData.overworldData(); + DerivedLevelData derivedLevelData = new DerivedLevelData(worldData, overworldData); + + Executor executor = serverAccess.levelExecutor(server); + LevelStorageSource.LevelStorageAccess storage = serverAccess.levelStorage(server); + long obfuscatedSeed = BiomeManager.obfuscateSeed(seed); + + ServerLevel level = new ServerLevel( + server, + executor, + storage, + derivedLevelData, + key, + stem, + false, + obfuscatedSeed, + List.of(), + false); + + serverAccess.putLevel(server, key, level); + server.getPlayerList().addWorldborderListener(level); + return new Handle(dimensionId, packKey, seed, level, generator); + } + + private static void evacuate(MinecraftServer server, ServerLevel from) { + ServerLevel fallback = server.overworld(); + if (fallback == from) { + return; + } + 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); + } + } + + private static ResourceKey levelKey(String dimensionId) { + Identifier identifier = Identifier.parse(dimensionId); + return ResourceKey.create(Registries.DIMENSION, identifier); + } + + private static ModdedServerAccess requireAccess() { + ModdedServerAccess bound = access; + if (bound == null) { + throw new IllegalStateException("Iris modded server access is not bound; the loader bootstrap must bind ModdedServerAccess before runtime dimension injection"); + } + return bound; + } + + public record Handle(String dimensionId, String packKey, 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 new file mode 100644 index 000000000..541d6b534 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionRegistryStore.java @@ -0,0 +1,123 @@ +/* + * 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.volmlib.util.json.JSONArray; +import art.arcane.volmlib.util.json.JSONObject; +import net.minecraft.server.MinecraftServer; +import net.minecraft.world.level.storage.LevelResource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class ModdedDimensionRegistryStore { + private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + private static final String FILE_NAME = "iris-dimensions.json"; + + private ModdedDimensionRegistryStore() { + } + + public static List load(MinecraftServer server) { + Path file = storeFile(server); + if (!Files.isRegularFile(file)) { + return new ArrayList<>(); + } + try { + JSONObject root = new JSONObject(Files.readString(file, StandardCharsets.UTF_8)); + JSONArray entries = root.optJSONArray("dimensions"); + if (entries == null) { + return new ArrayList<>(); + } + Map deduplicated = new LinkedHashMap<>(); + 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) { + continue; + } + long seed = entry.optLong("seed", 0L); + deduplicated.put(id, new PersistentDimension(id, packKey, seed)); + } + return new ArrayList<>(deduplicated.values()); + } catch (RuntimeException | IOException e) { + LOGGER.error("Iris persistent dimension registry at {} is invalid; ignoring it", file, e); + return new ArrayList<>(); + } + } + + public static synchronized void put(MinecraftServer server, PersistentDimension dimension) { + Map current = index(load(server)); + current.put(dimension.id(), dimension); + write(server, new ArrayList<>(current.values())); + } + + public static synchronized void remove(MinecraftServer server, String id) { + Map current = index(load(server)); + if (current.remove(id) != null) { + write(server, new ArrayList<>(current.values())); + } + } + + private static Map index(List dimensions) { + Map map = new LinkedHashMap<>(); + for (PersistentDimension dimension : dimensions) { + map.put(dimension.id(), dimension); + } + return map; + } + + private static void write(MinecraftServer server, List dimensions) { + Path file = storeFile(server); + JSONArray entries = new JSONArray(); + for (PersistentDimension dimension : dimensions) { + JSONObject entry = new JSONObject(); + entry.put("id", dimension.id()); + entry.put("packKey", dimension.packKey()); + entry.put("seed", dimension.seed()); + entries.put(entry); + } + JSONObject root = new JSONObject(); + root.put("dimensions", entries); + try { + Files.createDirectories(file.getParent()); + Path temp = file.resolveSibling(FILE_NAME + ".tmp"); + Files.writeString(temp, root.toString(2), StandardCharsets.UTF_8); + Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (IOException e) { + LOGGER.error("Iris failed to write persistent dimension registry at {}", file, e); + } + } + + private static Path storeFile(MinecraftServer server) { + return server.getWorldPath(LevelResource.ROOT).resolve("iris").resolve(FILE_NAME); + } + + public record PersistentDimension(String id, String packKey, long seed) { + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionStorage.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionStorage.java new file mode 100644 index 000000000..ebfd75f57 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionStorage.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; + +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.MinecraftServer; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.dimension.DimensionType; +import net.minecraft.world.level.storage.LevelResource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Stream; + +public final class ModdedDimensionStorage { + private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + private static final List CHUNK_DATA_FOLDERS = List.of("region", "entities", "poi", "mantle"); + + private ModdedDimensionStorage() { + } + + public static File storageFolder(MinecraftServer server, ResourceKey dimension) { + return DimensionType.getStorageFolder(dimension, server.getWorldPath(LevelResource.ROOT)).toFile(); + } + + public static void wipe(MinecraftServer server, ResourceKey dimension) { + File storageFolder = storageFolder(server, dimension); + for (String folder : CHUNK_DATA_FOLDERS) { + deleteRecursively(new File(storageFolder, folder).toPath()); + } + LOGGER.info("Iris wiped dimension storage at {}", storageFolder.getAbsolutePath()); + } + + private static void deleteRecursively(Path root) { + if (!Files.exists(root)) { + return; + } + try (Stream walk = Files.walk(root)) { + for (Path path : walk.sorted(Comparator.reverseOrder()).toList()) { + Files.deleteIfExists(path); + } + } catch (IOException e) { + LOGGER.error("Iris failed to wipe dimension storage at {}", root, e); + } + } +} 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 97727e69d..507a1450a 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 @@ -22,11 +22,14 @@ import art.arcane.iris.engine.decorator.DecoratorPlatformHooks; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.EngineWorldManager; import art.arcane.iris.engine.framework.EngineWorldManagerProvider; -import art.arcane.iris.engine.framework.MeteredCache; import art.arcane.iris.engine.framework.PreservationRegistry; import art.arcane.iris.engine.object.BlockDataMergeSupport; 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.service.ModdedLogFilterService; +import art.arcane.iris.modded.service.ModdedPreservationService; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.IrisServices; import net.minecraft.server.MinecraftServer; @@ -45,12 +48,38 @@ public final class ModdedEngineBootstrap { "art.arcane.iris.core.loader.IrisData" }; private static final Object LOCK = new Object(); + private static final ModdedServiceManager SERVICE_MANAGER = new ModdedServiceManager(); private static volatile ModdedLoader loader; private static volatile ModdedPlatform platform; private ModdedEngineBootstrap() { } + public static ModdedServiceManager services() { + return SERVICE_MANAGER; + } + + public static ModdedScheduler schedulerOrNull() { + ModdedPlatform bound = platform; + return bound == null ? null : bound.moddedScheduler(); + } + + public static void tick(MinecraftServer server) { + ModdedScheduler.tick(server); + ModdedStartup.runOnce(server); + ModdedPrimaryWorldRouter.tick(server); + SERVICE_MANAGER.tick(server); + } + + public static void stop() { + ModdedPrimaryWorldRouter.clear(); + SERVICE_MANAGER.disableAll(); + ModdedScheduler scheduler = schedulerOrNull(); + if (scheduler != null) { + scheduler.shutdown(); + } + } + public static void initialize(ModdedLoader moddedLoader) { loader = moddedLoader; } @@ -97,33 +126,25 @@ public final class ModdedEngineBootstrap { ModdedLoader boundLoader = loader(); ModdedPlatform created = new ModdedPlatform(boundLoader); IrisPlatforms.bind(created); + ModdedDimensionManager.bindAccess(new ModdedServerLevels()); IrisObjectRotation.bindFallbackRotator(new ModdedStateRotator()); BlockDataMergeSupport.bindFallbackMerger(new ModdedStateMerger()); TileData.bindFallbackReader(new ModdedTileReader(boundLoader::currentServer)); + ModdedGuiHost.install(); ModdedDecoratorHooks decoratorHooks = new ModdedDecoratorHooks(); DecoratorPlatformHooks.bind(decoratorHooks, decoratorHooks); - IrisServices.register(PreservationRegistry.class, new InertPreservation()); + ModdedPreservationService preservation = SERVICE_MANAGER.register(ModdedPreservationService.class, new ModdedPreservationService()); + SERVICE_MANAGER.register(ModdedLogFilterService.class, new ModdedLogFilterService()); + IrisServices.register(PreservationRegistry.class, preservation); IrisServices.register(EngineWorldManagerProvider.class, (EngineWorldManagerProvider) (Engine engine) -> new InertWorldManager()); + ModdedCustomContentRegistry.discover(); platform = created; + SERVICE_MANAGER.enableAll(); ModdedIrisSplash.print(boundLoader); return created; } } - private static final class InertPreservation implements PreservationRegistry { - @Override - public void register(Thread thread) { - } - - @Override - public void registerCache(MeteredCache cache) { - } - - @Override - public void dereference() { - } - } - private static final class InertWorldManager implements EngineWorldManager { @Override public void close() { 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 new file mode 100644 index 000000000..98a3483b9 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java @@ -0,0 +1,221 @@ +/* + * 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.core.loader.IrisData; +import art.arcane.iris.core.nms.datapack.DataVersion; +import art.arcane.iris.core.nms.datapack.IDataFixer; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisDimensionType; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.collection.KSet; +import net.minecraft.network.chat.Component; +import net.minecraft.server.packs.PackLocationInfo; +import net.minecraft.server.packs.PackSelectionConfig; +import net.minecraft.server.packs.PackType; +import net.minecraft.server.packs.PathPackResources; +import net.minecraft.server.packs.repository.Pack; +import net.minecraft.server.packs.repository.PackSource; +import net.minecraft.server.packs.repository.RepositorySource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.stream.Stream; + +public final class ModdedForcedDatapack { + private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + private static final String PACK_ID = "iris_worldgen"; + private static final String PACK_FOLDER = "iris"; + private static final String STUDIO_POOL_TYPE_KEY = "studio_pool"; + private static final String STUDIO_POOL_TYPE_RESOURCE = "/data/irisworldgen/dimension_type/overworld.json"; + private static final int STUDIO_POOL_MIN_Y = -256; + private static final int STUDIO_POOL_MAX_Y = 512; + private static final Object LOCK = new Object(); + + private ModdedForcedDatapack() { + } + + public static RepositorySource repositorySource() { + return (Consumer consumer) -> { + Pack pack = buildPack(); + if (pack != null) { + consumer.accept(pack); + } + }; + } + + public static Path datapackRoot() { + return ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen").resolve("generated").resolve("datapack"); + } + + private static Path packDirectory() { + return datapackRoot().resolve(PACK_FOLDER); + } + + private static Pack buildPack() { + Path directory = generate(); + if (directory == null) { + return null; + } + PackLocationInfo location = new PackLocationInfo( + PACK_ID, + Component.literal("Iris World Generation"), + PackSource.BUILT_IN, + Optional.empty()); + PackSelectionConfig selection = new PackSelectionConfig(true, Pack.Position.TOP, true); + PathPackResources.PathResourcesSupplier supplier = new PathPackResources.PathResourcesSupplier(directory); + Pack pack = Pack.readMetaAndCreate(location, supplier, PackType.SERVER_DATA, selection); + if (pack == null) { + LOGGER.error("Iris forced datapack at {} produced no readable pack metadata", directory); + } + return pack; + } + + private static Path generate() { + synchronized (LOCK) { + try { + return write(); + } catch (IOException e) { + LOGGER.error("Iris failed to generate the forced startup datapack", e); + return null; + } + } + } + + private static Path write() throws IOException { + Path packDirectory = packDirectory(); + clean(packDirectory); + Files.createDirectories(packDirectory); + + File packFolder = packDirectory.toFile(); + KList folders = new KList<>(); + folders.add(packFolder.getParentFile()); + KSet seenBiomes = new KSet<>(); + IDataFixer fixer = DataVersion.getLatest().get(); + + int packCount = 0; + File[] packs = packsRoot().toFile().listFiles(File::isDirectory); + if (packs != null) { + for (File pack : packs) { + if (installPack(pack, fixer, folders, seenBiomes)) { + packCount++; + } + } + } + + writePackMeta(packDirectory); + writeStudioPoolType(packDirectory); + LOGGER.info("Iris forced startup datapack regenerated: {} pack(s), {} custom biome(s) at {}", packCount, 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()) { + return false; + } + try { + IrisData data = IrisData.get(packFolder); + IrisDimension dimension = data.getDimensionLoader().load(packKey); + if (dimension == null) { + return false; + } + 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; + } + } + + private static void writeDimensionType(KList folders, IDataFixer fixer, IrisDimension dimension) throws IOException { + if (fitsStudioPool(dimension)) { + return; + } + IrisDimensionType type = dimension.getDimensionType(); + String json = type.toJson(fixer); + String typeKey = dimension.getDimensionTypeKey(); + for (File parent : folders) { + Path output = parent.toPath().resolve(PACK_FOLDER).resolve("data").resolve("irisworldgen").resolve("dimension_type").resolve(typeKey + ".json"); + Files.createDirectories(output.getParent()); + Files.writeString(output, json, StandardCharsets.UTF_8); + } + } + + private static boolean fitsStudioPool(IrisDimension dimension) { + return dimension.getMinHeight() >= STUDIO_POOL_MIN_Y && dimension.getMaxHeight() <= STUDIO_POOL_MAX_Y; + } + + private static void writeStudioPoolType(Path packDirectory) throws IOException { + Path output = packDirectory.resolve("data").resolve("irisworldgen").resolve("dimension_type").resolve(STUDIO_POOL_TYPE_KEY + ".json"); + Files.createDirectories(output.getParent()); + Files.writeString(output, readStudioPoolType(), StandardCharsets.UTF_8); + } + + private static String readStudioPoolType() throws IOException { + try (InputStream stream = ModdedForcedDatapack.class.getResourceAsStream(STUDIO_POOL_TYPE_RESOURCE)) { + if (stream == null) { + throw new IOException("Bundled studio pool dimension type resource is missing: " + STUDIO_POOL_TYPE_RESOURCE); + } + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + } + + private static void writePackMeta(Path packDirectory) throws IOException { + int packFormat = DataVersion.getLatest().getPackFormat(); + String json = "{\n" + + " \"pack\": {\n" + + " \"description\": \"Iris world generation biomes and dimension types for installed packs.\",\n" + + " \"pack_format\": " + packFormat + ",\n" + + " \"min_format\": " + packFormat + ",\n" + + " \"max_format\": " + packFormat + "\n" + + " }\n" + + "}\n"; + Files.writeString(packDirectory.resolve("pack.mcmeta"), json, StandardCharsets.UTF_8); + } + + private static void clean(Path packDirectory) throws IOException { + if (!Files.exists(packDirectory)) { + return; + } + List entries = new ArrayList<>(); + try (Stream walk = Files.walk(packDirectory)) { + walk.sorted(Comparator.comparingInt(Path::getNameCount).reversed()).forEach(entries::add); + } + for (Path entry : entries) { + Files.deleteIfExists(entry); + } + } + + private static Path packsRoot() { + return ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen").resolve("packs"); + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedModConfig.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedModConfig.java new file mode 100644 index 000000000..5d02e65f5 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedModConfig.java @@ -0,0 +1,123 @@ +/* + * 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.volmlib.util.json.JSONObject; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public final class ModdedModConfig { + private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + private static final Object LOCK = new Object(); + private static volatile ModdedModConfig instance; + + private final String defaultPack; + private final boolean autoDownloadDefaultPack; + private final String primaryWorld; + private final boolean routePlayersToPrimaryWorld; + + private ModdedModConfig(String defaultPack, boolean autoDownloadDefaultPack, String primaryWorld, boolean routePlayersToPrimaryWorld) { + this.defaultPack = defaultPack; + this.autoDownloadDefaultPack = autoDownloadDefaultPack; + this.primaryWorld = primaryWorld == null ? "" : primaryWorld.trim(); + this.routePlayersToPrimaryWorld = routePlayersToPrimaryWorld; + } + + public static ModdedModConfig get() { + ModdedModConfig bound = instance; + if (bound != null) { + return bound; + } + synchronized (LOCK) { + if (instance != null) { + return instance; + } + instance = load(); + return instance; + } + } + + public static void setPrimaryWorld(String dimensionId) { + synchronized (LOCK) { + ModdedModConfig current = get(); + ModdedModConfig updated = new ModdedModConfig(current.defaultPack, current.autoDownloadDefaultPack, dimensionId, current.routePlayersToPrimaryWorld); + instance = updated; + write(configFile(), updated); + } + } + + public String defaultPack() { + return defaultPack; + } + + public boolean autoDownloadDefaultPack() { + return autoDownloadDefaultPack; + } + + public String primaryWorld() { + return primaryWorld; + } + + public boolean routePlayersToPrimaryWorld() { + return routePlayersToPrimaryWorld; + } + + private static Path configFile() { + return ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen").resolve("modded.json"); + } + + private static ModdedModConfig load() { + Path file = configFile(); + ModdedModConfig defaults = new ModdedModConfig("overworld", true, "", true); + if (!Files.isRegularFile(file)) { + write(file, defaults); + return defaults; + } + try { + JSONObject json = new JSONObject(Files.readString(file, StandardCharsets.UTF_8)); + return new ModdedModConfig( + json.optString("defaultPack", defaults.defaultPack), + json.optBoolean("autoDownloadDefaultPack", defaults.autoDownloadDefaultPack), + json.optString("primaryWorld", defaults.primaryWorld), + json.optBoolean("routePlayersToPrimaryWorld", defaults.routePlayersToPrimaryWorld)); + } catch (RuntimeException | IOException e) { + LOGGER.error("Iris modded config at {} is invalid; using defaults", file, e); + return defaults; + } + } + + private static void write(Path file, ModdedModConfig config) { + JSONObject json = new JSONObject(); + json.put("defaultPack", config.defaultPack); + json.put("autoDownloadDefaultPack", config.autoDownloadDefaultPack); + json.put("primaryWorld", config.primaryWorld); + json.put("routePlayersToPrimaryWorld", config.routePlayersToPrimaryWorld); + try { + Files.createDirectories(file.getParent()); + Files.writeString(file, json.toString(4), StandardCharsets.UTF_8); + } catch (IOException e) { + LOGGER.error("Iris failed to write modded config at {}", file, e); + } + } +} 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 521c39614..6dc71909b 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 @@ -22,10 +22,20 @@ 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; @@ -57,6 +67,10 @@ public final class ModdedPlatform implements IrisPlatform { return loader.currentServer(); } + public ModdedScheduler moddedScheduler() { + return scheduler; + } + @Override public String platformName() { return loader.platformName(); @@ -130,6 +144,35 @@ public final class ModdedPlatform implements IrisPlatform { public void dispatchConsoleCommand(String command) { } + @Override + public boolean spawnEntity(Object world, String entityKey, double x, double y, double z) { + if (!(world instanceof ServerLevel level) || entityKey == null) { + return false; + } + PlatformEntityType resolved = registries.entity(entityKey); + if (resolved == null) { + return false; + } + EntityType type = (EntityType) resolved.nativeHandle(); + BlockPos pos = BlockPos.containing(x, y, z); + Entity entity = type.spawn(level, pos, EntitySpawnReason.COMMAND); + 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); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPrimaryWorldRouter.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPrimaryWorldRouter.java new file mode 100644 index 000000000..6f0ff028d --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPrimaryWorldRouter.java @@ -0,0 +1,93 @@ +/* + * 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 net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +public final class ModdedPrimaryWorldRouter { + private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + private static final int TICK_INTERVAL = 20; + + private static final Set routed = ConcurrentHashMap.newKeySet(); + private static int tickCounter = 0; + + private ModdedPrimaryWorldRouter() { + } + + public static void clear() { + routed.clear(); + } + + public static void tick(MinecraftServer server) { + if (server == null) { + return; + } + tickCounter++; + if (tickCounter < TICK_INTERVAL) { + return; + } + tickCounter = 0; + + ModdedModConfig config = ModdedModConfig.get(); + if (!config.routePlayersToPrimaryWorld()) { + return; + } + String primary = config.primaryWorld(); + if (primary.isBlank()) { + return; + } + + ServerLevel target = ModdedDimensionManager.level(server, primary); + if (target == null) { + return; + } + ServerLevel overworld = server.overworld(); + if (target == overworld) { + return; + } + + List players = new ArrayList<>(server.getPlayerList().getPlayers()); + for (ServerPlayer player : players) { + UUID id = player.getUUID(); + if (routed.contains(id)) { + continue; + } + if (player.level() != overworld) { + routed.add(id); + continue; + } + try { + ModdedDimensionManager.teleport(player, server, primary, player.getX(), Double.MIN_VALUE, player.getZ()); + routed.add(id); + } catch (Throwable e) { + LOGGER.error("Iris failed to route player {} to primary world '{}'", id, primary, e); + } + } + } +} 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 be564079e..597560c6c 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 @@ -20,28 +20,170 @@ package art.arcane.iris.modded; import art.arcane.iris.spi.PlatformScheduler; import art.arcane.iris.spi.PlatformWorld; +import net.minecraft.server.MinecraftServer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; public final class ModdedScheduler implements PlatformScheduler { + private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + private static final int ASYNC_CORE_THREADS = 2; + private static final int ASYNC_MAX_THREADS = Math.max(4, Runtime.getRuntime().availableProcessors()); + private static final int ASYNC_QUEUE_CAPACITY = 4096; + private static final long ASYNC_KEEP_ALIVE_SECONDS = 30L; + + private static volatile Thread mainThread; + + private final ThreadPoolExecutor asyncExecutor; + private final ConcurrentLinkedQueue mainQueue = new ConcurrentLinkedQueue<>(); + private final ConcurrentLinkedQueue delayedQueue = new ConcurrentLinkedQueue<>(); + + public ModdedScheduler() { + BlockingQueue workQueue = new ArrayBlockingQueue<>(ASYNC_QUEUE_CAPACITY); + this.asyncExecutor = new ThreadPoolExecutor( + ASYNC_CORE_THREADS, + ASYNC_MAX_THREADS, + ASYNC_KEEP_ALIVE_SECONDS, + TimeUnit.SECONDS, + workQueue, + new AsyncThreadFactory(), + new ThreadPoolExecutor.CallerRunsPolicy()); + this.asyncExecutor.allowCoreThreadTimeOut(true); + } + + public static void tick(MinecraftServer server) { + if (server == null) { + return; + } + mainThread = server.getRunningThread(); + ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull(); + if (scheduler == null) { + return; + } + scheduler.drain(); + } + @Override public void global(Runnable task) { - task.run(); + if (task == null) { + return; + } + if (onMainThread()) { + runGuarded(task); + return; + } + mainQueue.add(task); } @Override public void region(PlatformWorld world, int chunkX, int chunkZ, Runnable task) { - task.run(); + global(task); } @Override public void async(Runnable task) { - task.run(); + if (task == null) { + return; + } + asyncExecutor.execute(() -> runGuarded(task)); } @Override public void laterGlobal(Runnable task, int ticks) { + if (task == null) { + return; + } + if (ticks <= 0) { + global(task); + return; + } + delayedQueue.add(new DelayedTask(task, ticks)); } @Override public void laterRegion(PlatformWorld world, int chunkX, int chunkZ, Runnable task, int ticks) { + laterGlobal(task, ticks); + } + + public void shutdown() { + asyncExecutor.shutdownNow(); + mainQueue.clear(); + delayedQueue.clear(); + } + + private void drain() { + promoteDelayed(); + Runnable task; + while ((task = mainQueue.poll()) != null) { + runGuarded(task); + } + } + + private void promoteDelayed() { + if (delayedQueue.isEmpty()) { + return; + } + List retained = new ArrayList<>(); + DelayedTask delayed; + while ((delayed = delayedQueue.poll()) != null) { + if (delayed.tick()) { + mainQueue.add(delayed.task()); + } else { + retained.add(delayed); + } + } + delayedQueue.addAll(retained); + } + + private boolean onMainThread() { + Thread main = mainThread; + return main != null && Thread.currentThread() == main; + } + + private void runGuarded(Runnable task) { + try { + task.run(); + } catch (Throwable error) { + LOGGER.error("Iris scheduled task failed", error); + } + } + + private static final class DelayedTask { + private final Runnable task; + private int remaining; + + private DelayedTask(Runnable task, int remaining) { + this.task = task; + this.remaining = remaining; + } + + private boolean tick() { + remaining--; + return remaining <= 0; + } + + private Runnable task() { + return task; + } + } + + private static final class AsyncThreadFactory implements ThreadFactory { + private final AtomicInteger counter = new AtomicInteger(1); + + @Override + public Thread newThread(Runnable runnable) { + Thread thread = new Thread(runnable, "iris-modded-async-" + counter.getAndIncrement()); + thread.setDaemon(true); + return thread; + } } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedServerAccess.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedServerAccess.java new file mode 100644 index 000000000..1e4ad72ca --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedServerAccess.java @@ -0,0 +1,39 @@ +/* + * 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 net.minecraft.resources.ResourceKey; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.storage.LevelStorageSource; + +import java.util.concurrent.Executor; + +public interface ModdedServerAccess { + Executor levelExecutor(MinecraftServer server); + + LevelStorageSource.LevelStorageAccess levelStorage(MinecraftServer server); + + ServerLevel putLevel(MinecraftServer server, ResourceKey key, ServerLevel level); + + ServerLevel removeLevel(MinecraftServer server, ResourceKey key); + + boolean hasLevel(MinecraftServer server, ResourceKey key); +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedServerLevels.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedServerLevels.java new file mode 100644 index 000000000..be70ebd74 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedServerLevels.java @@ -0,0 +1,54 @@ +/* + * 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 net.minecraft.resources.ResourceKey; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.storage.LevelStorageSource; + +import java.util.concurrent.Executor; + +public final class ModdedServerLevels implements ModdedServerAccess { + @Override + public Executor levelExecutor(MinecraftServer server) { + return server.executor; + } + + @Override + public LevelStorageSource.LevelStorageAccess levelStorage(MinecraftServer server) { + return server.storageSource; + } + + @Override + public ServerLevel putLevel(MinecraftServer server, ResourceKey key, ServerLevel level) { + return server.levels.put(key, level); + } + + @Override + public ServerLevel removeLevel(MinecraftServer server, ResourceKey key) { + return server.levels.remove(key); + } + + @Override + public boolean hasLevel(MinecraftServer server, ResourceKey key) { + return server.levels.containsKey(key); + } +} 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 new file mode 100644 index 000000000..1ca23c352 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedServiceManager.java @@ -0,0 +1,113 @@ +/* + * 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.modded.service.ModdedService; +import art.arcane.iris.modded.service.ModdedTickableService; +import net.minecraft.server.MinecraftServer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.Consumer; + +public final class ModdedServiceManager { + private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + + private final Map, ModdedService> services = new LinkedHashMap<>(); + private boolean enabled = false; + + public synchronized T register(Class type, T service) { + if (services.containsKey(type)) { + return type.cast(services.get(type)); + } + services.put(type, service); + if (enabled) { + enableService(service); + } + return service; + } + + public synchronized T service(Class type) { + ModdedService service = services.get(type); + return service == null ? null : type.cast(service); + } + + public synchronized void enableAll() { + if (enabled) { + return; + } + enabled = true; + for (ModdedService service : services.values()) { + enableService(service); + } + } + + public synchronized void tick(MinecraftServer server) { + if (!enabled) { + return; + } + for (ModdedService service : services.values()) { + if (service instanceof ModdedTickableService tickable) { + tickService(tickable, server); + } + } + } + + public synchronized void disableAll() { + if (!enabled) { + return; + } + enabled = false; + forEachReversed(this::disableService); + services.clear(); + } + + private void enableService(ModdedService service) { + try { + service.onEnable(); + } catch (Throwable error) { + LOGGER.error("Iris service onEnable failed for {}", service.getClass().getName(), error); + } + } + + private void disableService(ModdedService service) { + try { + service.onDisable(); + } catch (Throwable error) { + LOGGER.error("Iris service onDisable failed for {}", service.getClass().getName(), error); + } + } + + private void tickService(ModdedTickableService service, MinecraftServer server) { + try { + service.onServerTick(server); + } catch (Throwable error) { + LOGGER.error("Iris service tick failed for {}", service.getClass().getName(), error); + } + } + + private void forEachReversed(Consumer action) { + ModdedService[] ordered = services.values().toArray(new ModdedService[0]); + for (int i = ordered.length - 1; i >= 0; i--) { + action.accept(ordered[i]); + } + } +} 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 new file mode 100644 index 000000000..d636c7ee9 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java @@ -0,0 +1,85 @@ +/* + * 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 net.minecraft.server.MinecraftServer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.nio.file.Path; +import java.util.List; +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 ModdedStartup() { + } + + public static void runOnce(MinecraftServer server) { + if (server == null || !STARTED.compareAndSet(false, true)) { + return; + } + reinjectPersistentDimensions(server); + + ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull(); + if (scheduler == null) { + ensureDefaultPack(); + return; + } + scheduler.async(ModdedStartup::ensureDefaultPack); + } + + private static void reinjectPersistentDimensions(MinecraftServer server) { + List dimensions = ModdedDimensionRegistryStore.load(server); + if (dimensions.isEmpty()) { + return; + } + int injected = 0; + for (ModdedDimensionRegistryStore.PersistentDimension dimension : dimensions) { + try { + ModdedDimensionManager.create(server, dimension.id(), dimension.packKey(), 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.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); + } + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedTileData.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedTileData.java index 7545c5aba..c8caa84fb 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedTileData.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedTileData.java @@ -20,11 +20,18 @@ package art.arcane.iris.modded; import art.arcane.iris.engine.object.TileData; import art.arcane.volmlib.util.collection.KMap; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.Strictness; +import java.io.ByteArrayOutputStream; import java.io.DataOutputStream; import java.io.IOException; public final class ModdedTileData extends TileData { + public static final String NBT_PROPERTY = "nbt"; + private static final Gson GSON = new GsonBuilder().disableHtmlEscaping().setStrictness(Strictness.LENIENT).create(); + private final byte[] raw; private final KMap tileProperties; @@ -34,6 +41,22 @@ public final class ModdedTileData extends TileData { this.tileProperties = tileProperties == null ? new KMap<>() : tileProperties; } + public static ModdedTileData capture(String blockKey, String snbt) throws IOException { + KMap properties = new KMap<>(); + properties.put(NBT_PROPERTY, snbt); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (DataOutputStream out = new DataOutputStream(bytes)) { + out.writeUTF(blockKey); + out.writeUTF(GSON.toJson(properties)); + } + return new ModdedTileData(bytes.toByteArray(), properties); + } + + public String snbt() { + Object value = tileProperties.get(NBT_PROPERTY); + return value == null ? null : value.toString(); + } + @Override public KMap getProperties() { return tileProperties; 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 991b115a1..0ddc373d8 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 @@ -31,6 +31,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.File; +import java.util.ArrayList; +import java.util.Collection; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -41,15 +43,34 @@ public final class ModdedWorldEngines { private ModdedWorldEngines() { } - public static Engine get(ServerLevel level, String dimensionKey) { + public static Engine get(ServerLevel level, String dimensionKey, long seedOverride) { Engine existing = ENGINES.get(level); if (existing != null) { return existing; } - return ENGINES.computeIfAbsent(level, (ServerLevel l) -> create(l, dimensionKey)); + return ENGINES.computeIfAbsent(level, (ServerLevel l) -> create(l, dimensionKey, seedOverride)); } - private static Engine create(ServerLevel level, String dimensionKey) { + public static Collection activeEngines() { + return new ArrayList<>(ENGINES.values()); + } + + public static void evict(ServerLevel level) { + Engine removed = ENGINES.remove(level); + if (removed == null) { + return; + } + try { + if (!removed.isClosed()) { + removed.close(); + } + LOGGER.info("Iris engine evicted for {}", level.dimension().identifier()); + } catch (Throwable e) { + LOGGER.error("Iris engine evict close failed for {}", level.dimension().identifier(), e); + } + } + + private static Engine create(ServerLevel level, String dimensionKey, long seedOverride) { ModdedEngineBootstrap.bind(); File pack = resolvePack(dimensionKey); IrisData data = IrisData.get(pack); @@ -60,7 +81,7 @@ public final class ModdedWorldEngines { throw new IllegalStateException("Iris dimension '" + dimensionKey + "' missing from pack " + pack.getAbsolutePath()); } - long seed = level.getSeed(); + long seed = seedOverride == Long.MIN_VALUE ? level.getSeed() : seedOverride; File worldFolder = DimensionType.getStorageFolder(level.dimension(), level.getServer().getWorldPath(LevelResource.ROOT)).toFile(); IrisWorld world = IrisWorld.builder() .name(level.dimension().identifier().toString().replace(':', '_')) 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 new file mode 100644 index 000000000..1142d3716 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/IrisModdedAPI.java @@ -0,0 +1,144 @@ +/* + * 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.api; + +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() { + } + + public static boolean isIrisLevel(ServerLevel level) { + if (level == null) { + return false; + } + return level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator; + } + + public static boolean isStudioLevel(ServerLevel level) { + Engine engine = getEngine(level); + return engine != null && engine.isStudio(); + } + + public static Engine getEngine(ServerLevel level) { + if (level == null) { + return null; + } + ChunkGenerator generator = level.getChunkSource().getGenerator(); + if (!(generator instanceof IrisModdedChunkGenerator irisGenerator)) { + return null; + } + try { + return irisGenerator.commandEngine(); + } catch (Throwable error) { + return null; + } + } + + public static boolean pregenerate(ServerLevel level, int radiusBlocks) { + return pregenerate(level, radiusBlocks, 0, 0, ModdedPregenMode.ASYNC, false); + } + + public static boolean pregenerate(ServerLevel level, int radiusBlocks, int centerBlockX, int centerBlockZ, ModdedPregenMode mode, boolean cached) { + Engine engine = getEngine(level); + if (engine == null) { + return false; + } + return ModdedPregenJob.start(level.getServer(), level, engine, radiusBlocks, centerBlockX, centerBlockZ, false, mode, cached); + } + + public static T getMantleData(ServerLevel level, int x, int y, int z, Class type) { + Engine engine = getEngine(level); + if (engine == null) { + return null; + } + return engine.getMantle().getMantle().get(x, y - engine.getMinHeight(), z, type); + } + + public static void setMantleData(ServerLevel level, int x, int y, int z, T data) { + Engine engine = getEngine(level); + if (engine == null || data == null) { + return; + } + engine.getMantle().getMantle().set(x, y - engine.getMinHeight(), z, data); + } + + public static void deleteMantleData(ServerLevel level, int x, int y, int z, Class type) { + Engine engine = getEngine(level); + if (engine == null) { + return; + } + engine.getMantle().getMantle().remove(x, y - engine.getMinHeight(), z, type); + } + + public static void retainMantleDataForSlice(Class sliceType) { + if (sliceType == null) { + return; + } + IrisToolbelt.retainMantleDataForSlice(sliceType.getCanonicalName()); + } + + public static void registerProvider(ModdedDataProvider provider) { + ModdedCustomContentRegistry.register(provider); + } + + 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/api/ModdedCustomContentRegistry.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/ModdedCustomContentRegistry.java new file mode 100644 index 000000000..57043683c --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/ModdedCustomContentRegistry.java @@ -0,0 +1,182 @@ +/* + * 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.api; + +import art.arcane.iris.modded.ModdedBlockResolution; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.block.state.BlockState; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.ServiceLoader; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; + +public final class ModdedCustomContentRegistry { + private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + private static final List PROVIDERS = new CopyOnWriteArrayList<>(); + private static final Map CUSTOM_BLOCKS = new ConcurrentHashMap<>(); + private static volatile boolean scanned = false; + + private ModdedCustomContentRegistry() { + } + + public static void registerCustomBlockData(String namespace, String key, String state) { + if (namespace == null || key == null || state == null) { + return; + } + Identifier identifier = Identifier.tryParse(namespace + ":" + key); + if (identifier == null) { + LOGGER.warn("Iris custom block data registration rejected invalid id {}:{}", namespace, key); + return; + } + BlockState parsed; + try { + parsed = ModdedBlockResolution.strictParse(state).handle(); + } catch (Throwable error) { + LOGGER.error("Iris custom block data '{}:{}' has unparseable state '{}'", namespace, key, state, error); + return; + } + CUSTOM_BLOCKS.put(identifier.toString(), parsed); + LOGGER.info("Iris registered custom block data {}:{} -> {}", namespace, key, state); + } + + public static void register(ModdedDataProvider provider) { + if (provider == null) { + return; + } + for (ModdedDataProvider existing : PROVIDERS) { + if (existing.modId().equals(provider.modId())) { + LOGGER.warn("Iris custom content provider for '{}' already registered; ignoring duplicate", provider.modId()); + return; + } + } + PROVIDERS.add(provider); + try { + provider.init(); + } catch (Throwable error) { + LOGGER.error("Iris custom content provider '{}' failed to initialize", provider.modId(), error); + } + LOGGER.info("Iris registered custom content provider '{}'", provider.modId()); + } + + public static void discover() { + if (scanned) { + return; + } + scanned = true; + ServiceLoader loader = ServiceLoader.load(ModdedDataProvider.class, ModdedCustomContentRegistry.class.getClassLoader()); + for (ModdedDataProvider provider : loader) { + register(provider); + } + } + + public static boolean hasProviders() { + return !PROVIDERS.isEmpty() || !CUSTOM_BLOCKS.isEmpty(); + } + + public static BlockState resolveBlock(String key) { + if (key == null || (PROVIDERS.isEmpty() && CUSTOM_BLOCKS.isEmpty())) { + return null; + } + Identifier base = parseIdentifier(key); + if (base == null) { + return null; + } + BlockState custom = CUSTOM_BLOCKS.get(base.toString()); + if (custom != null) { + return custom; + } + if (PROVIDERS.isEmpty()) { + return null; + } + Map state = parseState(key); + for (ModdedDataProvider provider : PROVIDERS) { + if (!provider.isReady() || !provider.isValidProvider(base, ModdedDataType.BLOCK)) { + continue; + } + try { + BlockState resolved = provider.getBlockData(base, state); + if (resolved != null) { + return resolved; + } + } catch (Throwable error) { + LOGGER.error("Iris custom content provider '{}' failed resolving block {}", provider.modId(), key, error); + } + } + return null; + } + + public static Entity spawnMob(ServerLevel level, double x, double y, double z, String key) { + if (PROVIDERS.isEmpty() || level == null || key == null) { + return null; + } + Identifier base = parseIdentifier(key); + if (base == null) { + return null; + } + for (ModdedDataProvider provider : PROVIDERS) { + if (!provider.isReady() || !provider.isValidProvider(base, ModdedDataType.ENTITY)) { + continue; + } + try { + Entity entity = provider.spawnMob(level, x, y, z, base); + if (entity != null) { + return entity; + } + } catch (Throwable error) { + LOGGER.error("Iris custom content provider '{}' failed spawning mob {}", provider.modId(), key, error); + } + } + return null; + } + + private static Identifier parseIdentifier(String key) { + String trimmed = key.trim(); + int bracket = trimmed.indexOf('['); + String head = bracket < 0 ? trimmed : trimmed.substring(0, bracket); + return Identifier.tryParse(head); + } + + private static Map parseState(String key) { + Map state = new LinkedHashMap<>(); + int open = key.indexOf('['); + int close = key.indexOf(']'); + if (open < 0 || close < open) { + return state; + } + String body = key.substring(open + 1, close); + if (body.isEmpty()) { + return state; + } + for (String pair : body.split(",")) { + int eq = pair.indexOf('='); + if (eq <= 0) { + continue; + } + state.put(pair.substring(0, eq).trim(), pair.substring(eq + 1).trim()); + } + return state; + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/ModdedDataProvider.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/ModdedDataProvider.java new file mode 100644 index 000000000..cdfc7e8de --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/ModdedDataProvider.java @@ -0,0 +1,50 @@ +/* + * 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.api; + +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.block.state.BlockState; + +import java.util.Collection; +import java.util.Map; + +public interface ModdedDataProvider { + String modId(); + + default boolean isReady() { + return true; + } + + Collection getTypes(ModdedDataType type); + + boolean isValidProvider(Identifier id, ModdedDataType type); + + default BlockState getBlockData(Identifier blockId, Map state) { + return null; + } + + default Entity spawnMob(ServerLevel level, double x, double y, double z, Identifier entityId) { + return null; + } + + default void init() { + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/ModdedDataType.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/ModdedDataType.java new file mode 100644 index 000000000..1c0e75baf --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/ModdedDataType.java @@ -0,0 +1,25 @@ +/* + * 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.api; + +public enum ModdedDataType { + BLOCK, + ITEM, + ENTITY +} 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 9b6a0bd5f..6cb97aee5 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 @@ -23,16 +23,21 @@ import art.arcane.iris.engine.framework.IrisStructureLocator; import art.arcane.iris.engine.framework.Locator; import art.arcane.iris.engine.framework.WrongEngineBroException; import art.arcane.iris.engine.object.IrisBiome; +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.ModdedEngineBootstrap; import art.arcane.iris.modded.ModdedLoader; import art.arcane.iris.modded.ModdedPackInstaller; import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.spi.PlatformBlockState; import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.math.Position2; +import art.arcane.volmlib.util.matter.MatterMarker; 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.LiteralArgumentBuilder; import com.mojang.brigadier.context.CommandContext; @@ -45,6 +50,7 @@ import net.minecraft.commands.Commands; import net.minecraft.commands.SharedSuggestionProvider; 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.server.MinecraftServer; import net.minecraft.server.level.ServerLevel; @@ -53,6 +59,8 @@ import net.minecraft.world.entity.Relative; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.chunk.ChunkGenerator; import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.HitResult; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -76,6 +84,8 @@ public final class IrisModdedCommands { private static final SuggestionProvider OBJECT_KEYS = (CommandContext context, SuggestionsBuilder builder) -> suggestObjectKeys(context, builder); private static final SuggestionProvider STRUCTURE_KEYS = (CommandContext context, SuggestionsBuilder builder) -> suggestStructureKeys(context, builder); private static final SuggestionProvider POI_TYPES = (CommandContext context, SuggestionsBuilder builder) -> SharedSuggestionProvider.suggest(List.of("buried_treasure"), builder); + private static final SuggestionProvider MARKER_TYPES = (CommandContext context, SuggestionsBuilder builder) -> SharedSuggestionProvider.suggest(List.of("cave_floor", "cave_ceiling", "object"), builder); + private static final DustParticleOptions MARKER_DUST = new DustParticleOptions(0x5A8CFF, 1.2F); static final SuggestionProvider PACK_NAMES = (CommandContext context, SuggestionsBuilder builder) -> suggestPackNames(context, builder); private static final SuggestionProvider DIMENSION_NAMES = (CommandContext context, SuggestionsBuilder builder) -> suggestDimensionNames(context, builder); @@ -104,7 +114,12 @@ public final class IrisModdedCommands { .executes((CommandContext context) -> info(context.getSource(), StringArgumentType.getString(context, "dimension"))))); root.then(Commands.literal("what").requires(GATE) - .executes((CommandContext context) -> what(context.getSource()))); + .executes((CommandContext context) -> what(context.getSource())) + .then(Commands.literal("block") + .executes((CommandContext context) -> whatBlock(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(gotoTree("goto")); root.then(gotoTree("find")); @@ -133,6 +148,8 @@ public final class IrisModdedCommands { root.then(ModdedObjectCommands.tree("o")); root.then(editTree()); + root.then(createTree()); + root.then(ModdedStudioCommands.tree("studio")); root.then(ModdedStudioCommands.tree("std")); root.then(ModdedStudioCommands.tree("s")); @@ -150,6 +167,21 @@ public final class IrisModdedCommands { return root; } + 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) + .executes((CommandContext context) -> ModdedWorldCommands.createWorld(context.getSource(), + StringArgumentType.getString(context, "name"), + StringArgumentType.getString(context, "pack"), + 1337L)) + .then(Commands.argument("seed", LongArgumentType.longArg()) + .executes((CommandContext context) -> ModdedWorldCommands.createWorld(context.getSource(), + StringArgumentType.getString(context, "name"), + StringArgumentType.getString(context, "pack"), + LongArgumentType.getLong(context, "seed")))))); + } + private static LiteralArgumentBuilder helpTree() { return Commands.literal("help") .executes((CommandContext context) -> ModdedCommandHelp.send(context.getSource(), "")) @@ -202,13 +234,19 @@ public final class IrisModdedCommands { .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)) + .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"))))))) + 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.literal("stop") .executes((CommandContext context) -> pregenStop(context.getSource()))) .then(Commands.literal("x") @@ -221,6 +259,14 @@ 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 LiteralArgumentBuilder goldenhashTree(String name) { LiteralArgumentBuilder radiusAndThreads = Commands.literal(name).requires(GATE) .executes((CommandContext context) -> goldenhash(context.getSource(), 8, 8, ModdedGoldenHash.Mode.AUTO)); @@ -288,19 +334,29 @@ public final class IrisModdedCommands { return 1; } - private static int pregenStart(CommandSourceStack source, int radius, int centerX, int centerZ) { + private static int pregenStart(CommandSourceStack source, int radius, int centerX, int centerZ, boolean gui, ModdedPregenMode mode, boolean cached) { ServerLevel level = source.getLevel(); Engine engine = engineFor(level); if (engine == null) { fail(source, "This dimension is not generated by Iris."); return 0; } - if (!ModdedPregenJob.start(level, engine, radius, centerX, centerZ)) { + boolean showGui = gui && ModdedGuiHost.isGuiLaunchable(); + if (!ModdedPregenJob.start(source.getServer(), level, engine, radius, centerX, centerZ, showGui, mode, cached)) { fail(source, "A pregeneration task is already running. Stop it first with /iris pregen stop."); return 0; } + String guiNote; + if (!gui) { + guiNote = ""; + } else if (showGui) { + guiNote = " A progress map window is opening on the server display."; + } else { + guiNote = " (GUI requested but unavailable: " + ModdedGuiHost.guiUnavailableReason() + ")"; + } + String modeNote = mode == ModdedPregenMode.SYNC ? " Mode: sync" + (cached ? " (checkpoint cache resumable)." : ".") : ""; ok(source, "Pregen started in " + level.dimension().identifier() + " of " + (radius * 2) + " by " + (radius * 2) - + " blocks from " + centerX + "," + centerZ + ". Progress logs to console; see /iris pregen status."); + + " blocks from " + centerX + "," + centerZ + "." + modeNote + " Progress logs to console; see /iris pregen status." + guiNote); return 1; } @@ -418,6 +474,108 @@ public final class IrisModdedCommands { return 1; } + private static int whatBlock(CommandSourceStack source) { + ServerPlayer player = source.getPlayer(); + if (player == null) { + fail(source, "This command can only be used by players (it inspects the block you are looking at)."); + return 0; + } + HitResult hit = player.pick(128.0D, 1.0F, false); + if (hit.getType() != HitResult.Type.BLOCK || !(hit instanceof BlockHitResult blockHit)) { + fail(source, "Look at a block, not the sky."); + return 0; + } + ServerLevel level = source.getLevel(); + BlockPos pos = blockHit.getBlockPos(); + BlockState state = level.getBlockState(pos); + PlatformBlockState platform = ModdedBlockState.of(state, null); + ok(source, "Block: " + platform.key() + " (y=" + pos.getY() + ")"); + List flags = new ArrayList<>(); + if (platform.isSolid()) { + flags.add("solid"); + } + if (platform.isFluid()) { + flags.add("fluid"); + } + if (platform.isWater()) { + flags.add("water"); + } + if (platform.isWaterLogged()) { + flags.add("waterlogged"); + } + if (platform.isStorage()) { + flags.add("storage (loot capable)"); + } + if (platform.isLit()) { + flags.add("lit"); + } + if (platform.isFoliage()) { + flags.add("foliage"); + } + if (platform.isFoliagePlantable()) { + flags.add("plantable foliage"); + } + if (platform.isDecorant()) { + flags.add("decorant"); + } + if (platform.isOre()) { + flags.add("ore"); + } + if (platform.hasTileEntity()) { + flags.add("tile entity"); + } + ok(source, flags.isEmpty() ? "Properties: (none)" : "Properties: " + String.join(", ", flags)); + return 1; + } + + private static int whatMarkers(CommandSourceStack source, String markerRaw) { + ServerPlayer player = source.getPlayer(); + if (player == null) { + fail(source, "This command can only be used by players (markers render as particles around you)."); + return 0; + } + ServerLevel level = source.getLevel(); + Engine engine = engineFor(level); + if (engine == null) { + fail(source, "This dimension is not generated by Iris."); + return 0; + } + String marker = markerRaw.trim(); + BlockPos origin = player.blockPosition(); + int chunkX = origin.getX() >> 4; + int chunkZ = origin.getZ() >> 4; + MinecraftServer server = source.getServer(); + ok(source, "Scanning for '" + marker + "' markers around you..."); + Thread thread = new Thread(() -> { + List hits = new ArrayList<>(); + MatterMarker matterMarker = new MatterMarker(marker); + try { + for (int cx = chunkX - 4; cx <= chunkX + 4; cx++) { + for (int cz = chunkZ - 4; cz <= chunkZ + 4; cz++) { + for (IrisPosition position : engine.getMantle().findMarkers(cx, cz, matterMarker)) { + hits.add(new int[]{position.getX(), position.getY(), position.getZ()}); + } + } + } + } catch (Throwable e) { + LOGGER.error("Iris marker scan failed for {}", marker, e); + server.execute(() -> fail(source, "Marker scan failed: " + e.getClass().getSimpleName())); + return; + } + server.execute(() -> { + for (int[] hit : hits) { + level.sendParticles(player, MARKER_DUST, true, true, + hit[0] + 0.5D, hit[1] + 1.0D, hit[2] + 0.5D, + 3, 0.2D, 0.2D, 0.2D, 0.0D); + } + ok(source, "Found " + hits.size() + " nearby marker(s) (" + marker + ")"); + }); + }, "Iris Marker Scan"); + thread.setDaemon(true); + thread.start(); + return 1; + } + private static int gotoBiome(CommandSourceStack source, String key) { ServerPlayer player = source.getPlayer(); if (player == null) { 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 ad720ba14..801dda198 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 @@ -19,6 +19,7 @@ package art.arcane.iris.modded.command; import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; import net.minecraft.network.chat.ClickEvent; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.HoverEvent; @@ -161,6 +162,9 @@ final class ModdedCommandHelp { ModdedCommandFeedback.clear(source); sendHeader(source, normalized); + if (!Commands.hasPermission(Commands.LEVEL_GAMEMASTERS).test(source)) { + ModdedCommandFeedback.send(source, opNotice()); + } if (!normalized.isEmpty()) { ModdedCommandFeedback.send(source, backButton(normalized)); } @@ -293,6 +297,17 @@ final class ModdedCommandHelp { return hover; } + private static MutableComponent opNotice() { + MutableComponent notice = Component.empty(); + notice.append(text("âš  ", REQUIRED)); + notice.append(text("Iris commands need operator permission (level 2). ", REQUIRED_TEXT)); + notice.append(text("Run ", DESCRIPTION)); + notice.append(text("/op ", PARAMETER_ALT)); + notice.append(text(" from the console (or enable cheats in singleplayer); ", DESCRIPTION)); + notice.append(text("until then these commands will not run or tab-complete.", USAGE)); + return notice; + } + private static MutableComponent footer() { return ModdedCommandFeedback.footer(); } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDatapackCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDatapackCommands.java index 1e15e6b34..2d170aba5 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDatapackCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDatapackCommands.java @@ -48,7 +48,7 @@ import java.util.function.Predicate; public final class ModdedDatapackCommands { private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); private static final Predicate GATE = Commands.hasPermission(Commands.LEVEL_GAMEMASTERS); - private static final String WORLD_PACK_NAME = ModdedWorldDatapackWriter.WORLD_PACK_NAME; + private static final String WORLD_PACK_NAME = "iris"; private ModdedDatapackCommands() { } 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 new file mode 100644 index 000000000..4d159ac58 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedGuiHost.java @@ -0,0 +1,87 @@ +/* + * 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; + +import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.core.gui.GuiHost; +import art.arcane.iris.core.gui.GuiOverlay; +import art.arcane.iris.engine.framework.Engine; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; + +import java.util.Map; +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 volatile Engine active; + private volatile MinecraftServer server; + + private ModdedGuiHost() { + } + + public static void install() { + GuiHost.set(INSTANCE); + } + + public static void bindContext(MinecraftServer server, ServerLevel level, Engine engine) { + INSTANCE.server = server; + INSTANCE.active = engine; + INSTANCE.levels.put(engine, level); + } + + public static boolean isGuiLaunchable() { + return GuiHost.isAvailable() && IrisSettings.get().getGui().isUseServerLaunchedGuis(); + } + + public static String guiUnavailableReason() { + if (!GuiHost.isAvailable()) { + return "headless JVM (no display)"; + } + return "gui.useServerLaunchedGuis=false in Iris settings"; + } + + @Override + public Engine findActiveEngine() { + Engine current = active; + if (current != null && !current.isClosed()) { + return current; + } + for (Map.Entry entry : levels.entrySet()) { + if (!entry.getKey().isClosed()) { + return entry.getKey(); + } + } + return null; + } + + @Override + public GuiOverlay overlayFor(Engine engine) { + if (engine == null) { + return null; + } + ServerLevel level = levels.get(engine); + if (level == null || server == null) { + return null; + } + return new ModdedVisionOverlay(server, level, engine); + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedObjectCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedObjectCommands.java index c5ec8dade..ddecbc417 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedObjectCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedObjectCommands.java @@ -23,7 +23,9 @@ import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.IrisObject; import art.arcane.iris.engine.object.IrisObjectPlacement; import art.arcane.iris.engine.object.IrisObjectRotation; +import art.arcane.iris.engine.object.TileData; import art.arcane.iris.modded.ModdedBlockState; +import art.arcane.iris.modded.ModdedTileData; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.volmlib.util.math.RNG; import com.mojang.brigadier.arguments.IntegerArgumentType; @@ -37,9 +39,14 @@ import net.minecraft.commands.Commands; import net.minecraft.commands.SharedSuggestionProvider; import net.minecraft.core.BlockPos; import net.minecraft.core.Direction; +import net.minecraft.core.HolderLookup; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.NbtUtils; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.phys.BlockHitResult; import net.minecraft.world.phys.HitResult; @@ -272,7 +279,8 @@ public final class ModdedObjectCommands { return 0; } int[] tilesSkipped = {0}; - IrisObject object = capture(level, min, max, w, h, d, tilesSkipped); + int[] tilesSaved = {0}; + IrisObject object = capture(level, min, max, w, h, d, tilesSkipped, tilesSaved); File parent = file.getParentFile(); if (parent != null) { parent.mkdirs(); @@ -284,15 +292,25 @@ public final class ModdedObjectCommands { IrisModdedCommands.fail(source, "Failed to save object: " + e.getMessage()); return 0; } - String tileNote = tilesSkipped[0] > 0 ? " (" + tilesSkipped[0] + " tile state(s) not captured; tile capture is Bukkit-only for now)" : ""; + StringBuilder tileNote = new StringBuilder(); + if (tilesSaved[0] > 0) { + tileNote.append(" (").append(tilesSaved[0]).append(" tile entity state(s) captured"); + if (tilesSkipped[0] > 0) { + tileNote.append(", ").append(tilesSkipped[0]).append(" failed"); + } + tileNote.append(")"); + } else if (tilesSkipped[0] > 0) { + tileNote.append(" (").append(tilesSkipped[0]).append(" tile state(s) could not be captured)"); + } IrisModdedCommands.ok(source, "Saved " + engine.getData().getDataFolder().getName() + "/objects/" + name + ".iob: " + w + "x" + h + "x" + d + ", " + object.getBlocks().size() + " block(s)" + tileNote); - LOGGER.info("Iris object save: {} {}x{}x{} blocks={} tilesSkipped={} -> {}", name, w, h, d, object.getBlocks().size(), tilesSkipped[0], file.getAbsolutePath()); + LOGGER.info("Iris object save: {} {}x{}x{} blocks={} tilesSaved={} tilesSkipped={} -> {}", name, w, h, d, object.getBlocks().size(), tilesSaved[0], tilesSkipped[0], file.getAbsolutePath()); return 1; } - private static IrisObject capture(ServerLevel level, BlockPos min, BlockPos max, int w, int h, int d, int[] tilesSkipped) { + private static IrisObject capture(ServerLevel level, BlockPos min, BlockPos max, int w, int h, int d, int[] tilesSkipped, int[] tilesSaved) { IrisObject object = new IrisObject(w, h, d); + HolderLookup.Provider provider = level.registryAccess(); BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos(); for (int x = min.getX(); x <= max.getX(); x++) { for (int y = min.getY(); y <= max.getY(); y++) { @@ -301,16 +319,41 @@ public final class ModdedObjectCommands { if (state.is(Blocks.AIR)) { continue; } + int ox = x - min.getX(); + int oy = y - min.getY(); + int oz = z - min.getZ(); + object.setUnsigned(ox, oy, oz, ModdedBlockState.of(state, null)); if (state.hasBlockEntity()) { - tilesSkipped[0]++; + TileData tile = captureTile(level, provider, cursor.immutable(), state); + if (tile != null) { + object.setUnsignedTile(ox, oy, oz, tile); + tilesSaved[0]++; + } else { + tilesSkipped[0]++; + } } - object.setUnsigned(x - min.getX(), y - min.getY(), z - min.getZ(), ModdedBlockState.of(state, null)); } } } return object; } + private static TileData captureTile(ServerLevel level, HolderLookup.Provider provider, BlockPos pos, BlockState state) { + BlockEntity blockEntity = level.getBlockEntity(pos); + if (blockEntity == null) { + return null; + } + try { + CompoundTag tag = blockEntity.saveWithFullMetadata(provider); + String snbt = NbtUtils.structureToSnbt(tag); + String blockKey = BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString(); + return ModdedTileData.capture(blockKey, snbt); + } catch (Throwable e) { + LOGGER.error("Iris tile capture failed at {} {} {}", pos.getX(), pos.getY(), pos.getZ(), e); + return null; + } + } + private static int paste(CommandSourceStack source, String keyRaw, int rotation, BlockPos at) { ServerLevel level = source.getLevel(); Engine engine = IrisModdedCommands.engineFor(level); @@ -354,11 +397,11 @@ public final class ModdedObjectCommands { } UUID owner = player == null ? ModdedObjectUndo.CONSOLE : player.getUUID(); ModdedObjectUndo.record(owner, level, placer.undoSnapshot()); - String tileNote = placer.skippedTiles() > 0 ? ", " + placer.skippedTiles() + " tile state(s) skipped" : ""; + String tileNote = tileNote(placer); IrisModdedCommands.ok(source, "Placed " + key + " at " + target.getX() + " " + target.getY() + " " + target.getZ() + " rot=" + rotation + " (" + placer.writes() + " write(s), " + placer.nonAirWrites() + " non-air" + tileNote + ")"); - LOGGER.info("Iris paste: {} at {},{},{} rot={} writes={} nonAir={} tilesSkipped={}", - key, target.getX(), target.getY(), target.getZ(), rotation, placer.writes(), placer.nonAirWrites(), placer.skippedTiles()); + LOGGER.info("Iris paste: {} at {},{},{} rot={} writes={} nonAir={} tilesRestored={} tilesSkipped={}", + key, target.getX(), target.getY(), target.getZ(), rotation, placer.writes(), placer.nonAirWrites(), placer.restoredTiles(), placer.skippedTiles()); return placer.writes() > 0 ? 1 : 0; } @@ -619,4 +662,15 @@ public final class ModdedObjectCommands { return "(" + first.getX() + "," + first.getY() + "," + first.getZ() + ") -> (" + second.getX() + "," + second.getY() + "," + second.getZ() + ")"; } + + static String tileNote(ModdedObjectPlacer placer) { + StringBuilder note = new StringBuilder(); + if (placer.restoredTiles() > 0) { + note.append(", ").append(placer.restoredTiles()).append(" tile entity state(s) restored"); + } + if (placer.skippedTiles() > 0) { + note.append(", ").append(placer.skippedTiles()).append(" tile state(s) skipped"); + } + return note.toString(); + } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedObjectPlacer.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedObjectPlacer.java index 514b8fbd9..0e48c0f1a 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedObjectPlacer.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedObjectPlacer.java @@ -24,23 +24,32 @@ import art.arcane.iris.engine.object.IObjectPlacer; import art.arcane.iris.engine.object.TileData; import art.arcane.iris.modded.ModdedBlockResolution; import art.arcane.iris.modded.ModdedBlockState; +import art.arcane.iris.modded.ModdedTileData; import art.arcane.iris.spi.PlatformBlockState; import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.NbtUtils; import net.minecraft.server.level.ServerLevel; 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 net.minecraft.world.level.levelgen.Heightmap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.util.HashMap; import java.util.Map; final class ModdedObjectPlacer implements IObjectPlacer { + private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + private final ServerLevel level; private final Map undo = new HashMap<>(); private int writes = 0; private int nonAirWrites = 0; private int skippedTiles = 0; + private int restoredTiles = 0; ModdedObjectPlacer(ServerLevel level) { this.level = level; @@ -62,6 +71,10 @@ final class ModdedObjectPlacer implements IObjectPlacer { return skippedTiles; } + int restoredTiles() { + return restoredTiles; + } + @Override public int getHighest(int x, int z, IrisData data) { return level.getHeight(Heightmap.Types.MOTION_BLOCKING, x, z) - 1; @@ -128,7 +141,35 @@ final class ModdedObjectPlacer implements IObjectPlacer { @Override public void setTile(int xx, int yy, int zz, TileData tile) { - skippedTiles++; + if (!(tile instanceof ModdedTileData moddedTile)) { + skippedTiles++; + return; + } + String snbt = moddedTile.snbt(); + if (snbt == null || snbt.isBlank()) { + skippedTiles++; + return; + } + BlockPos pos = new BlockPos(xx, yy, zz); + BlockState state = level.getBlockState(pos); + if (!state.hasBlockEntity()) { + skippedTiles++; + return; + } + try { + CompoundTag tag = NbtUtils.snbtToStructure(snbt); + BlockEntity restored = BlockEntity.loadStatic(pos, state, tag, level.registryAccess()); + if (restored == null) { + skippedTiles++; + return; + } + level.setBlockEntity(restored); + restored.setChanged(); + restoredTiles++; + } catch (Throwable e) { + LOGGER.error("Iris tile restore failed at {} {} {}", xx, yy, zz, e); + skippedTiles++; + } } @Override 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 96faf9b21..046c5d16d 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,26 +18,42 @@ 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.pregenerator.PregenTask; +import art.arcane.iris.core.pregenerator.PregeneratorMethod; +import art.arcane.iris.core.pregenerator.cache.PregenCache; +import art.arcane.iris.core.pregenerator.methods.CachedPregenMethod; import art.arcane.iris.engine.framework.Engine; import art.arcane.volmlib.util.format.Form; import art.arcane.volmlib.util.math.Position2; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.MutableComponent; +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 { +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); 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; @@ -45,12 +61,46 @@ public final class ModdedPregenJob implements PregenListener { private volatile long elapsed; private volatile String method = "Modded"; - private ModdedPregenJob(ServerLevel level, Engine engine, PregenTask task) { + private ModdedPregenJob(MinecraftServer server, ServerLevel level, Engine engine, PregenTask task, boolean gui, ModdedPregenMode mode, boolean cached) { this.dimension = level.dimension().identifier().toString(); - this.pregenerator = new IrisPregenerator(task, new ModdedPregenMethod(level, engine), this); + 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; + if (cached) { + PregenCache cache = PregenCache.create(cacheDirectory(level)).sync(); + resolvedMethod = new CachedPregenMethod(baseMethod, cache); + } + 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; } - public static boolean start(ServerLevel level, Engine engine, int radiusBlocks, int centerBlockX, int centerBlockZ) { + private static File cacheDirectory(ServerLevel level) { + File worldFolder = DimensionType.getStorageFolder(level.dimension(), level.getServer().getWorldPath(LevelResource.ROOT)).toFile(); + 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; } @@ -60,8 +110,9 @@ public final class ModdedPregenJob implements PregenListener { .radiusX(radiusBlocks) .radiusZ(radiusBlocks) .build(); - ModdedPregenJob job = new ModdedPregenJob(level, engine, task); + ModdedPregenJob job = new ModdedPregenJob(server, level, engine, task, gui, mode, cached); if (!ACTIVE.compareAndSet(null, job)) { + job.closeRenderer(); return false; } Thread thread = new Thread(() -> { @@ -70,6 +121,7 @@ public final class ModdedPregenJob implements PregenListener { } catch (Throwable e) { LOGGER.error("Iris pregen failed for {}", job.dimension, e); } finally { + job.closeRenderer(); ACTIVE.compareAndSet(job, null); } }, "Iris Pregen"); @@ -78,6 +130,27 @@ public final class ModdedPregenJob implements PregenListener { 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) { @@ -176,10 +249,12 @@ public final class ModdedPregenJob implements PregenListener { @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 @@ -228,5 +303,34 @@ public final class ModdedPregenJob implements PregenListener { @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 601677b99..b9950315b 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 @@ -31,8 +31,10 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; public final class ModdedPregenMethod implements PregeneratorMethod { private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); @@ -40,13 +42,19 @@ public final class ModdedPregenMethod implements PregeneratorMethod { private final ServerLevel level; private final Engine engine; + private final ModdedPregenMode mode; private final Semaphore semaphore; private final int permits; private final int timeoutSeconds; public ModdedPregenMethod(ServerLevel level, Engine engine) { + this(level, engine, ModdedPregenMode.ASYNC); + } + + public ModdedPregenMethod(ServerLevel level, Engine engine, ModdedPregenMode mode) { this.level = level; this.engine = engine; + this.mode = mode; 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()); @@ -54,13 +62,15 @@ public final class ModdedPregenMethod implements PregeneratorMethod { @Override public void init() { - LOGGER.info("Iris modded pregen init: dim={} inFlightCap={} timeout={}s", - level.dimension().identifier(), permits, timeoutSeconds); + LOGGER.info("Iris modded pregen init: dim={} mode={} inFlightCap={} timeout={}s", + level.dimension().identifier(), mode, mode == ModdedPregenMode.ASYNC ? permits : 1, timeoutSeconds); } @Override public void close() { - semaphore.acquireUninterruptibly(permits); + if (mode == ModdedPregenMode.ASYNC) { + semaphore.acquireUninterruptibly(permits); + } saveLevel(); } @@ -85,7 +95,7 @@ public final class ModdedPregenMethod implements PregeneratorMethod { @Override public boolean isAsyncChunkMode() { - return true; + return mode == ModdedPregenMode.ASYNC; } @Override @@ -95,6 +105,38 @@ public final class ModdedPregenMethod implements PregeneratorMethod { @Override public void generateChunk(int x, int z, PregenListener listener) { + if (mode == ModdedPregenMode.SYNC) { + generateChunkSync(x, z, listener); + return; + } + generateChunkAsync(x, z, listener); + } + + private void generateChunkSync(int x, int z, PregenListener listener) { + listener.onChunkGenerating(x, z); + ChunkPos pos = new ChunkPos(x, z); + CompletableFuture loadFuture = CompletableFuture + .supplyAsync(() -> level.getChunkSource().addTicketAndLoadWithRadius(PREGEN_TICKET, pos, 0), level.getServer()) + .thenCompose((CompletableFuture inner) -> inner); + try { + 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()); + return; + } + listener.onChunkGenerated(x, z); + cleanupMantleChunk(x, z); + listener.onChunkCleaned(x, z); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (TimeoutException | ExecutionException e) { + LOGGER.warn("Iris pregen chunk {},{} failed: {}", x, z, e.toString()); + } finally { + level.getServer().execute(() -> level.getChunkSource().removeTicketWithRadius(PREGEN_TICKET, pos, 0)); + } + } + + private void generateChunkAsync(int x, int z, PregenListener listener) { listener.onChunkGenerating(x, z); try { semaphore.acquire(); 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 new file mode 100644 index 000000000..94c3e056c --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenMode.java @@ -0,0 +1,24 @@ +/* + * 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/ModdedStructureCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStructureCommands.java index 6d70c554b..edc9a76cd 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStructureCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStructureCommands.java @@ -201,7 +201,7 @@ public final class ModdedStructureCommands { return 0; } ModdedObjectUndo.record(owner, level, placer.undoSnapshot()); - String tileNote = placer.skippedTiles() > 0 ? ", " + placer.skippedTiles() + " tile state(s) skipped" : ""; + String tileNote = ModdedObjectCommands.tileNote(placer); IrisModdedCommands.ok(source, "Placed '" + key + "' (" + pieces.size() + " pieces, " + placer.writes() + " write(s)" + tileNote + ") at your location. /iris object undo to revert."); return 1; } 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 f40c08d01..8a9d7afa4 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 @@ -18,36 +18,47 @@ package art.arcane.iris.modded.command; +import art.arcane.iris.core.IrisSettings; +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.engine.framework.Engine; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBiomeGeneratorLink; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisEntitySpawn; +import art.arcane.iris.engine.object.IrisGenerator; import art.arcane.iris.engine.object.IrisObjectPlacement; import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.object.IrisSpawner; +import art.arcane.iris.modded.ModdedDimensionManager; import art.arcane.iris.modded.ModdedEngineBootstrap; import art.arcane.iris.modded.ModdedPackInstaller; import art.arcane.iris.util.common.parallel.BurstExecutor; import art.arcane.iris.util.common.parallel.MultiBurst; 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.JSONObject; import art.arcane.volmlib.util.math.M; +import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.math.Spiraler; import com.mojang.brigadier.arguments.IntegerArgumentType; import com.mojang.brigadier.arguments.LongArgumentType; import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.builder.LiteralArgumentBuilder; import com.mojang.brigadier.context.CommandContext; +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.network.chat.Component; -import net.minecraft.network.chat.MutableComponent; +import net.minecraft.commands.SharedSuggestionProvider; import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Relative; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.zeroturnaround.zip.ZipUtil; @@ -57,11 +68,17 @@ 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; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; 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; @@ -69,7 +86,22 @@ public final class ModdedStudioCommands { private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); private static final Predicate GATE = Commands.hasPermission(Commands.LEVEL_GAMEMASTERS); private static final Pattern PROJECT_NAME = Pattern.compile("[a-z0-9_-]+"); + private static final Pattern STUDIO_ID_SANITIZER = Pattern.compile("[^a-z0-9_-]"); + private static final String STUDIO_NAMESPACE = "irisworldgen"; + private static final String STUDIO_PREFIX = "studio_"; private static final String DEFAULT_TEMPLATE = "example"; + private static final Map STUDIOS = new ConcurrentHashMap<>(); + private static final SuggestionProvider GENERATOR_KEYS = (CommandContext context, SuggestionsBuilder builder) -> { + ModdedCommandFeedback.tab(context.getSource()); + try { + Engine engine = IrisModdedCommands.engineFor(context.getSource().getLevel()); + if (engine != null) { + return SharedSuggestionProvider.suggest(engine.getData().getGeneratorLoader().getPossibleKeys(), builder); + } + } catch (Throwable ignored) { + } + return builder.buildFuture(); + }; private ModdedStudioCommands() { } @@ -107,20 +139,26 @@ public final class ModdedStudioCommands { root.then(openTree("open")); root.then(openTree("o")); - root.then(message("close", "There are no studio worlds on modded servers (/iris studio open prepares the pack workflow instead), so there is nothing to close.")); - root.then(message("x", "There are no studio worlds on modded servers (/iris studio open prepares the pack workflow instead), so there is nothing to close.")); - root.then(message("tpstudio", "There are no temporary Bukkit studio worlds on modded servers to teleport to; use /iris studio open to prepare the pack workflow instead.")); - root.then(message("stp", "There are no temporary Bukkit studio worlds on modded servers to teleport to; use /iris studio open to prepare the pack workflow instead.")); + root.then(Commands.literal("close") + .executes((CommandContext context) -> close(context.getSource()))); + root.then(Commands.literal("x") + .executes((CommandContext context) -> close(context.getSource()))); + root.then(Commands.literal("tpstudio") + .executes((CommandContext context) -> tpStudio(context.getSource()))); + root.then(Commands.literal("stp") + .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(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.")); - root.then(message("noise", "The noise explorer is a desktop GUI launched from the Bukkit plugin.")); - root.then(message("nmap", "The noise explorer is a desktop GUI launched from the Bukkit plugin.")); - root.then(message("map", "The world map renderer is a desktop GUI launched from the Bukkit plugin.")); - root.then(message("render", "The world map renderer is a desktop GUI launched from the Bukkit plugin.")); + root.then(noiseTree("noise")); + root.then(noiseTree("nmap")); + root.then(mapTree("map")); + root.then(mapTree("render")); root.then(message("loot", "Loot simulation opens a Bukkit chest inventory GUI; it is not available on modded servers.")); root.then(message("profile", "Pack performance profiling is part of the Bukkit studio toolchain and is not ported to modded servers.")); root.then(message("spawn", "Iris entity spawning uses the Bukkit entity pipeline and is not ported to modded servers.")); @@ -145,6 +183,75 @@ public final class ModdedStudioCommands { return 0; } + private static LiteralArgumentBuilder noiseTree(String name) { + return Commands.literal(name) + .executes((CommandContext context) -> noise(context.getSource(), null, 12345L)) + .then(Commands.argument("generator", StringArgumentType.word()).suggests(GENERATOR_KEYS) + .executes((CommandContext context) -> noise(context.getSource(), StringArgumentType.getString(context, "generator"), 12345L)) + .then(Commands.argument("seed", LongArgumentType.longArg()) + .executes((CommandContext context) -> noise(context.getSource(), StringArgumentType.getString(context, "generator"), LongArgumentType.getLong(context, "seed"))))); + } + + private static LiteralArgumentBuilder mapTree(String name) { + return Commands.literal(name) + .executes((CommandContext context) -> map(context.getSource())); + } + + private static int noise(CommandSourceStack source, String generatorKey, long seed) { + ServerLevel level = source.getLevel(); + Engine engine = IrisModdedCommands.engineFor(level); + if (!GuiHost.isAvailable() || !IrisSettings.get().getGui().isUseServerLaunchedGuis()) { + IrisModdedCommands.fail(source, guiUnavailableMessage()); + return 0; + } + if (engine != null) { + ModdedGuiHost.bindContext(source.getServer(), level, engine); + } + if (generatorKey == null || generatorKey.isBlank()) { + NoiseExplorerGUI.launch(); + IrisModdedCommands.ok(source, "Opening the Noise Explorer on the server display."); + return 1; + } + if (engine == null) { + IrisModdedCommands.fail(source, "This dimension is not generated by Iris; run /iris studio noise from an Iris dimension to resolve generators, or omit the generator name."); + return 0; + } + IrisGenerator generator = engine.getData().getGeneratorLoader().load(generatorKey.trim()); + if (generator == null) { + IrisModdedCommands.fail(source, "Unknown generator '" + generatorKey + "' in pack " + engine.getDimension().getLoadKey() + "."); + return 0; + } + long mixedSeed = new RNG(seed).nextParallelRNG(3245).lmax(); + Supplier> supplier = () -> (Double x, Double z) -> generator.getHeight(x, z, mixedSeed); + NoiseExplorerGUI.launch(supplier, generatorKey.trim()); + IrisModdedCommands.ok(source, "Opening the Noise Explorer for generator '" + generatorKey.trim() + "' (seed " + seed + ")."); + return 1; + } + + private static int map(CommandSourceStack source) { + ServerLevel level = source.getLevel(); + Engine engine = IrisModdedCommands.engineFor(level); + if (engine == null) { + IrisModdedCommands.fail(source, "This dimension is not generated by Iris; stand in an Iris (or studio) dimension and run /iris studio map."); + return 0; + } + if (!GuiHost.isAvailable() || !IrisSettings.get().getGui().isUseServerLaunchedGuis()) { + IrisModdedCommands.fail(source, guiUnavailableMessage()); + return 0; + } + ModdedGuiHost.bindContext(source.getServer(), level, engine); + VisionGUI.launch(engine); + IrisModdedCommands.ok(source, "Opening the Vision map for " + level.dimension().identifier() + " on the server display."); + return 1; + } + + private static String guiUnavailableMessage() { + if (!GuiHost.isAvailable()) { + return "This server has no display (headless JVM); the Iris desktop GUIs need an AWT-capable session."; + } + return "Server-launched GUIs are disabled (gui.useServerLaunchedGuis=false in Iris settings)."; + } + private static LiteralArgumentBuilder message(String name, String text) { return Commands.literal(name) .executes((CommandContext context) -> { @@ -176,50 +283,177 @@ public final class ModdedStudioCommands { return folder; } + private static String studioDimensionId(ServerPlayer player) { + String base = STUDIO_ID_SANITIZER.matcher(player.getScoreboardName().toLowerCase(Locale.ROOT)).replaceAll("_"); + if (base.isBlank()) { + base = player.getUUID().toString().replace("-", ""); + } + return STUDIO_NAMESPACE + ":" + STUDIO_PREFIX + base; + } + private static int open(CommandSourceStack source, String pack, long seed) { - File folder = resolvePack(source, pack); - if (folder == null) { + ServerPlayer player = source.getPlayer(); + if (player == null) { + IrisModdedCommands.fail(source, "This command can only be used by players (a studio teleports you into the dimension)."); return 0; } - - IrisData data = IrisData.get(folder); - IrisDimension dimension = data.getDimensionLoader().load(folder.getName()); - if (dimension == null) { - IrisModdedCommands.fail(source, "Pack '" + folder.getName() + "' has no dimensions/" + folder.getName() + ".json"); + if (pack == null || pack.isBlank()) { + IrisModdedCommands.fail(source, "Provide a dimension pack: /iris studio open [seed]"); return 0; } - - IrisModdedCommands.ok(source, studioOpenComponent(dimension, folder, seed)); + MinecraftServer server = source.getServer(); + UUID owner = player.getUUID(); + String dimensionId = studioDimensionId(player); + IrisModdedCommands.ok(source, "Opening studio for '" + pack + "' (seed " + seed + ")..."); + Thread thread = new Thread(() -> openAsync(source, server, owner, dimensionId, pack, seed), "Iris Studio Open"); + thread.setDaemon(true); + thread.start(); return 1; } - private static MutableComponent studioOpenComponent(IrisDimension dimension, File folder, long seed) { - String dimensionKey = dimension.getLoadKey() == null ? folder.getName() : dimension.getLoadKey(); - MutableComponent message = Component.empty(); - message.append(ModdedCommandFeedback.header("Iris Studio")); - message.append(Component.literal("\n")); - message.append(ModdedCommandFeedback.text("Opening studio for the \"", ModdedCommandFeedback.DARK_GREEN)); - message.append(ModdedCommandFeedback.text(dimension.getName(), ModdedCommandFeedback.PARAMETER_ALT)); - message.append(ModdedCommandFeedback.text("\" pack", ModdedCommandFeedback.DARK_GREEN)); - message.append(ModdedCommandFeedback.text(" (seed: " + seed + ")", ModdedCommandFeedback.VALUE)); - message.append(Component.literal("\n")); - message.append(ModdedCommandFeedback.text("Pack ", ModdedCommandFeedback.DARK_GREEN)); - message.append(ModdedCommandFeedback.text(dimensionKey, ModdedCommandFeedback.PARAMETER)); - message.append(ModdedCommandFeedback.text(" is ready at ", ModdedCommandFeedback.DESCRIPTION)); - message.append(ModdedCommandFeedback.text(folder.getAbsolutePath(), ModdedCommandFeedback.VALUE)); - message.append(Component.literal("\n")); - message.append(ModdedCommandFeedback.text("Modded servers cannot create Bukkit's temporary studio world at runtime; edit this pack directly, then use the matching world/datapack workflow or ", ModdedCommandFeedback.DESCRIPTION)); - message.append(ModdedCommandFeedback.button("/iris regen", "/iris regen", "Regenerate nearby chunks after editing this pack", false)); - message.append(ModdedCommandFeedback.text(" in an Iris dimension.", ModdedCommandFeedback.DESCRIPTION)); - message.append(Component.literal("\n")); - message.append(ModdedCommandFeedback.button("Validate", "/iris pack validate " + dimensionKey, "Validate this pack before loading it", true)); - message.append(ModdedCommandFeedback.text(" ", ModdedCommandFeedback.OPTIONAL)); - message.append(ModdedCommandFeedback.button("World Help", "/iris world", "Open Iris world command help", true)); - message.append(ModdedCommandFeedback.text(" ", ModdedCommandFeedback.OPTIONAL)); - message.append(ModdedCommandFeedback.button("Package", "/iris studio package " + dimensionKey, "Package this dimension", false)); - message.append(Component.literal("\n")); - message.append(ModdedCommandFeedback.footer()); - return message; + private static void openAsync(CommandSourceStack source, MinecraftServer server, UUID owner, String dimensionId, String pack, long seed) { + try { + File packFolder = new File(ModdedPackCommands.packsRoot(), pack); + if (!new File(packFolder, "dimensions/" + pack + ".json").isFile()) { + server.execute(() -> IrisModdedCommands.ok(source, "Pack '" + pack + "' missing; downloading IrisDimensions/" + pack + "...")); + boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), pack, "master", + (String line) -> server.execute(() -> IrisModdedCommands.ok(source, line))); + if (!installed || !new File(packFolder, "dimensions/" + pack + ".json").isFile()) { + server.execute(() -> IrisModdedCommands.fail(source, "Pack '" + pack + "' could not be downloaded; check the name and try /iris download " + pack + ".")); + return; + } + } + IrisData data = IrisData.get(packFolder); + IrisDimension dimension = data.getDimensionLoader().load(pack); + if (dimension == null) { + server.execute(() -> IrisModdedCommands.fail(source, "Pack '" + pack + "' has no dimensions/" + pack + ".json")); + return; + } + server.execute(() -> injectAndTeleport(source, server, owner, dimensionId, pack, seed)); + } catch (Throwable e) { + LOGGER.error("Iris studio open failed for {}", pack, e); + server.execute(() -> IrisModdedCommands.fail(source, "Studio open failed: " + e.getClass().getSimpleName() + (e.getMessage() == null ? "" : " - " + e.getMessage()))); + } + } + + private static void injectAndTeleport(CommandSourceStack source, MinecraftServer server, UUID owner, String dimensionId, String pack, long seed) { + ServerPlayer player = server.getPlayerList().getPlayer(owner); + if (player == null) { + return; + } + ModdedDimensionManager.Handle handle; + try { + handle = ModdedDimensionManager.create(server, dimensionId, 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())); + return; + } + STUDIOS.put(owner, dimensionId); + ServerLevel studio = handle.level(); + int surface = studio.getMaxY(); + try { + Engine engine = IrisModdedCommands.engineFor(studio); + if (engine != null) { + surface = engine.getMinHeight() + engine.getHeight(8, 8, false) + 2; + } + } catch (Throwable e) { + LOGGER.error("Iris studio surface probe failed for {}", dimensionId, e); + } + player.teleportTo(studio, 8.5D, surface, 8.5D, java.util.Set.of(), player.getYRot(), player.getXRot(), false); + IrisModdedCommands.ok(source, "Studio open: " + dimensionId + " now runs '" + pack + "' seed " + seed + ". Use /iris studio close when done."); + } + + private static int close(CommandSourceStack source) { + ServerPlayer player = source.getPlayer(); + if (player == null) { + IrisModdedCommands.fail(source, "This command can only be used by players."); + return 0; + } + MinecraftServer server = source.getServer(); + UUID owner = player.getUUID(); + String dimensionId = STUDIOS.remove(owner); + if (dimensionId == null) { + IrisModdedCommands.fail(source, "You do not have an open studio. Use /iris studio open first."); + return 0; + } + try { + ModdedDimensionManager.remove(server, dimensionId, true); + } catch (Throwable e) { + LOGGER.error("Iris studio close failed for {}", dimensionId, e); + IrisModdedCommands.fail(source, "Studio close failed: " + e.getClass().getSimpleName() + (e.getMessage() == null ? "" : " - " + e.getMessage())); + return 0; + } + IrisModdedCommands.ok(source, "Studio closed: " + dimensionId + " was evacuated, unloaded and its region data deleted."); + return 1; + } + + private static int status(CommandSourceStack source) { + MinecraftServer server = source.getServer(); + List handles = ModdedDimensionManager.handles(); + List studios = new ArrayList<>(); + for (ModdedDimensionManager.Handle handle : handles) { + if (handle.dimensionId().startsWith(STUDIO_NAMESPACE + ":" + STUDIO_PREFIX) && ModdedDimensionManager.level(server, handle.dimensionId()) != null) { + studios.add(handle); + } + } + if (studios.isEmpty()) { + IrisModdedCommands.ok(source, "No studio dimensions are currently open."); + return 1; + } + IrisModdedCommands.ok(source, "Active studio dimension(s): " + studios.size()); + 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); + } + return 1; + } + + private static UUID ownerOf(String dimensionId) { + for (Map.Entry entry : STUDIOS.entrySet()) { + if (entry.getValue().equals(dimensionId)) { + return entry.getKey(); + } + } + return null; + } + + private static String ownerName(MinecraftServer server, UUID owner) { + ServerPlayer player = server.getPlayerList().getPlayer(owner); + return player == null ? owner.toString() : player.getScoreboardName(); + } + + private static int tpStudio(CommandSourceStack source) { + ServerPlayer player = source.getPlayer(); + if (player == null) { + IrisModdedCommands.fail(source, "This command can only be used by players."); + return 0; + } + MinecraftServer server = source.getServer(); + String dimensionId = STUDIOS.get(player.getUUID()); + if (dimensionId == null) { + IrisModdedCommands.fail(source, "You do not have an open studio. Use /iris studio open first."); + return 0; + } + ServerLevel studio = ModdedDimensionManager.level(server, dimensionId); + if (studio == null) { + STUDIOS.remove(player.getUUID()); + IrisModdedCommands.fail(source, "Your studio dimension is no longer loaded. Use /iris studio open again."); + return 0; + } + int surface = studio.getMaxY(); + try { + Engine engine = IrisModdedCommands.engineFor(studio); + if (engine != null) { + surface = engine.getMinHeight() + engine.getHeight(8, 8, false) + 2; + } + } catch (Throwable e) { + LOGGER.error("Iris tpstudio surface probe failed", e); + } + player.teleportTo(studio, 8.5D, surface, 8.5D, java.util.Set.of(), player.getYRot(), player.getXRot(), false); + IrisModdedCommands.ok(source, "Teleported to your studio (" + dimensionId + ")."); + return 1; } private static int version(CommandSourceStack source, String pack) { 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 new file mode 100644 index 000000000..2cd9c2af1 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedVisionOverlay.java @@ -0,0 +1,94 @@ +/* + * 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; + +import art.arcane.iris.core.gui.GuiMarker; +import art.arcane.iris.core.gui.GuiOverlay; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.render.RenderType; +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.LivingEntity; +import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.phys.Vec3; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +public final class ModdedVisionOverlay implements GuiOverlay { + private final MinecraftServer server; + private final ServerLevel level; + private final Engine engine; + + public ModdedVisionOverlay(MinecraftServer server, ServerLevel level, Engine engine) { + this.server = server; + this.level = level; + this.engine = engine; + } + + @Override + public List players() { + List markers = new ArrayList<>(); + for (ServerPlayer player : level.players()) { + Vec3 position = player.position(); + markers.add(GuiMarker.player(player.getScoreboardName(), position.x(), position.z())); + } + return markers; + } + + @Override + public void requestEntities(Consumer> sink) { + server.execute(() -> { + List markers = new ArrayList<>(); + for (Entity entity : level.getAllEntities()) { + if (entity instanceof ServerPlayer || !(entity instanceof LivingEntity living)) { + continue; + } + Vec3 position = living.position(); + markers.add(GuiMarker.entity(living.getType().toShortString(), position.x(), position.y(), position.z(), + living.getHealth(), living.getMaxHealth())); + } + sink.accept(markers); + }); + } + + @Override + public void teleport(double worldX, double worldZ) { + int blockX = (int) worldX; + int blockZ = (int) worldZ; + server.execute(() -> { + List players = level.players(); + if (players.isEmpty()) { + return; + } + 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); + }); + } + + @Override + public String openInEditor(double worldX, double worldZ, RenderType type) { + return null; + } +} 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 3c8231ce5..e1290db73 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 @@ -18,12 +18,16 @@ package art.arcane.iris.modded.command; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.modded.IrisModdedChunkGenerator; +import art.arcane.iris.modded.ModdedDimensionManager; +import art.arcane.iris.modded.ModdedModConfig; +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.suggestion.SuggestionProvider; -import com.mojang.brigadier.suggestion.Suggestions; import com.mojang.brigadier.suggestion.SuggestionsBuilder; import net.minecraft.commands.CommandSourceStack; import net.minecraft.commands.Commands; @@ -34,15 +38,16 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.File; -import java.io.IOException; +import java.util.ArrayList; import java.util.List; -import java.util.concurrent.CompletableFuture; +import java.util.Locale; 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 SuggestionProvider ENABLED_DIMENSIONS = (CommandContext context, SuggestionsBuilder builder) -> suggestEnabledDimensions(context, builder); + private static final String DEFAULT_NAMESPACE = "irisworldgen"; + private static final SuggestionProvider LOADED_DIMENSIONS = (CommandContext context, SuggestionsBuilder builder) -> SharedSuggestionProvider.suggest(loadedIrisDimensions(context.getSource().getServer()), builder); private ModdedWorldCommands() { } @@ -63,15 +68,9 @@ public final class ModdedWorldCommands { root.then(enableTree("create")); root.then(replaceOverworldTree()); - root.then(Commands.literal("disable") - .then(Commands.argument("dimension", StringArgumentType.word()).suggests(ENABLED_DIMENSIONS) - .executes((CommandContext context) -> disable(context.getSource(), StringArgumentType.getString(context, "dimension"))))); - root.then(Commands.literal("remove") - .then(Commands.argument("dimension", StringArgumentType.word()).suggests(ENABLED_DIMENSIONS) - .executes((CommandContext context) -> disable(context.getSource(), StringArgumentType.getString(context, "dimension"))))); - root.then(Commands.literal("rm") - .then(Commands.argument("dimension", StringArgumentType.word()).suggests(ENABLED_DIMENSIONS) - .executes((CommandContext context) -> disable(context.getSource(), StringArgumentType.getString(context, "dimension"))))); + root.then(disableTree("disable")); + root.then(disableTree("remove")); + root.then(disableTree("rm")); return root; } @@ -83,105 +82,183 @@ public final class ModdedWorldCommands { .executes((CommandContext context) -> enable(context.getSource(), StringArgumentType.getString(context, "dimension"), StringArgumentType.getString(context, "pack"), - StringArgumentType.getString(context, "pack"))) + StringArgumentType.getString(context, "pack"), + 1337L)) .then(Commands.argument("packDimension", StringArgumentType.word()) .executes((CommandContext context) -> enable(context.getSource(), StringArgumentType.getString(context, "dimension"), StringArgumentType.getString(context, "pack"), - StringArgumentType.getString(context, "packDimension")))))); + StringArgumentType.getString(context, "packDimension"), + 1337L))))); + } + + private static LiteralArgumentBuilder disableTree(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")))); } private static LiteralArgumentBuilder replaceOverworldTree() { return Commands.literal("replace-overworld") .then(Commands.argument("pack", StringArgumentType.word()).suggests(IrisModdedCommands.PACK_NAMES) - .executes((CommandContext context) -> enable(context.getSource(), - "minecraft:overworld", + .executes((CommandContext context) -> replaceOverworld(context.getSource(), StringArgumentType.getString(context, "pack"), StringArgumentType.getString(context, "pack"))) .then(Commands.argument("packDimension", StringArgumentType.word()) - .executes((CommandContext context) -> enable(context.getSource(), - "minecraft:overworld", + .executes((CommandContext context) -> replaceOverworld(context.getSource(), StringArgumentType.getString(context, "pack"), StringArgumentType.getString(context, "packDimension"))))); } - private static int enable(CommandSourceStack source, String targetDimension, String packName, String packDimension) { + public static int createWorld(CommandSourceStack source, String name, String pack, long seed) { + return enable(source, name, pack, pack, seed); + } + + private static int enable(CommandSourceStack source, String targetDimension, String packName, String packDimension, long seed) { + MinecraftServer server = source.getServer(); + String dimensionId; try { - ModdedWorldDatapackWriter.WriteResult result = ModdedWorldDatapackWriter.enable(source.getServer(), targetDimension, packName, packDimension); - IrisModdedCommands.ok(source, "Enabled Iris world " + result.targetDimension() + " using pack '" + result.packName() + "' dimension '" + result.packDimension() + "'."); - IrisModdedCommands.ok(source, "Wrote " + result.dimensionFile().getPath()); - IrisModdedCommands.ok(source, "Wrote " + result.typeFile().getPath()); - IrisModdedCommands.ok(source, "Wrote " + result.packMetaFile().getPath()); - IrisModdedCommands.ok(source, "Restart the server, then use /execute in " + result.targetDimension() + " run tp 0 100 0 or a portal/modded dimension tool."); - if ("minecraft:overworld".equals(result.targetDimension())) { - IrisModdedCommands.ok(source, "minecraft:overworld replacement was explicitly requested."); - } - return 1; - } catch (IOException e) { - LOGGER.error("Iris world datapack write failed for {}", targetDimension, e); - IrisModdedCommands.fail(source, "Failed to write Iris world datapack: " + e.getMessage()); - return 0; + dimensionId = normalizeDimensionId(targetDimension); } catch (IllegalArgumentException e) { IrisModdedCommands.fail(source, e.getMessage()); return 0; } + if (!loadPackDimension(source, packName, packDimension)) { + return 0; + } + try { + ModdedDimensionManager.createPersistent(server, dimensionId, packDimension, seed); + } catch (Throwable e) { + LOGGER.error("Iris world injection failed for {} (pack={} dim={})", dimensionId, packName, 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, "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) { + MinecraftServer server = source.getServer(); + String dimensionId = DEFAULT_NAMESPACE + ":primary"; + if (!loadPackDimension(source, packName, packDimension)) { + return 0; + } + try { + ModdedDimensionManager.createPersistent(server, dimensionId, packDimension, 1337L); + } catch (Throwable e) { + LOGGER.error("Iris primary world injection failed for {} (pack={} dim={})", dimensionId, packName, 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, "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); + if (!packFolder.isDirectory()) { + IrisModdedCommands.fail(source, "Pack '" + packName + "' 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"); + return false; + } + return true; } private static int disable(CommandSourceStack source, String targetDimension) { + MinecraftServer server = source.getServer(); + String dimensionId; try { - File removed = ModdedWorldDatapackWriter.disable(source.getServer(), targetDimension); - IrisModdedCommands.ok(source, "Removed " + removed.getPath()); - IrisModdedCommands.ok(source, "Restart the server for the dimension removal to apply."); - return 1; - } catch (IOException e) { - LOGGER.error("Iris world datapack removal failed for {}", targetDimension, e); - IrisModdedCommands.fail(source, "Failed to remove Iris world datapack entry: " + e.getMessage()); - return 0; + dimensionId = normalizeDimensionId(targetDimension); } catch (IllegalArgumentException e) { IrisModdedCommands.fail(source, e.getMessage()); return 0; } + boolean removed; + try { + removed = ModdedDimensionManager.removePersistent(server, dimensionId); + } 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())); + return 0; + } + if (dimensionId.equals(ModdedModConfig.get().primaryWorld())) { + ModdedModConfig.setPrimaryWorld(""); + ModdedPrimaryWorldRouter.clear(); + } + if (!removed) { + IrisModdedCommands.ok(source, "Iris world '" + dimensionId + "' was not loaded; cleared its persistent registry entry."); + return 1; + } + IrisModdedCommands.ok(source, "Removed Iris world '" + dimensionId + "': evacuated, unloaded, region data deleted, and dropped from the startup registry."); + return 1; } private static int status(CommandSourceStack source) { - list(source); - int loaded = 0; MinecraftServer server = source.getServer(); + int loaded = 0; 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.dimensionKey() + "'"); + IrisModdedCommands.ok(source, "Loaded Iris level: " + level.dimension().identifier() + " -> pack dimension '" + generator.activePackKey() + "'"); } } + String primary = ModdedModConfig.get().primaryWorld(); + if (!primary.isBlank()) { + IrisModdedCommands.ok(source, "Primary world: " + primary + (ModdedModConfig.get().routePlayersToPrimaryWorld() ? " (players routed there)" : " (routing disabled)")); + } if (loaded == 0) { - IrisModdedCommands.fail(source, "No Iris dimensions are currently loaded. Enabled dimensions require a server restart before Minecraft loads them."); + IrisModdedCommands.fail(source, "No Iris dimensions are currently loaded. Create one with /iris world create ."); } return loaded > 0 ? 1 : 0; } private static int list(CommandSourceStack source) { - try { - List dimensions = ModdedWorldDatapackWriter.enabledDimensions(source.getServer()); - IrisModdedCommands.ok(source, "Enabled Iris world datapack dimensions: " + dimensions.size()); - for (String dimension : dimensions) { - IrisModdedCommands.ok(source, " - " + dimension); - } - if (dimensions.isEmpty()) { - IrisModdedCommands.ok(source, "Use /iris world enable to create one without replacing the main world."); - } - return 1; - } catch (IOException e) { - LOGGER.error("Iris world datapack listing failed", e); - IrisModdedCommands.fail(source, "Failed to list Iris world datapack dimensions: " + e.getMessage()); - return 0; + List dimensions = loadedIrisDimensions(source.getServer()); + IrisModdedCommands.ok(source, "Loaded Iris dimensions: " + dimensions.size()); + for (String dimension : dimensions) { + IrisModdedCommands.ok(source, " - " + dimension); } + if (dimensions.isEmpty()) { + IrisModdedCommands.ok(source, "Use /iris world create to inject one without restarting."); + } + return 1; } - private static CompletableFuture suggestEnabledDimensions(CommandContext context, SuggestionsBuilder builder) { - try { - return SharedSuggestionProvider.suggest(ModdedWorldDatapackWriter.enabledDimensions(context.getSource().getServer()), builder); - } catch (IOException e) { - return Suggestions.empty(); + private static List loadedIrisDimensions(MinecraftServer server) { + List dimensions = new ArrayList<>(); + for (ServerLevel level : server.getAllLevels()) { + if (level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator) { + dimensions.add(level.dimension().identifier().toString()); + } } + return dimensions; + } + + private static String normalizeDimensionId(String value) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("Missing dimension id."); + } + String normalized = value.trim().toLowerCase(Locale.ROOT); + String namespace = DEFAULT_NAMESPACE; + String path = normalized; + int colon = normalized.indexOf(':'); + if (colon >= 0) { + namespace = normalized.substring(0, colon); + path = normalized.substring(colon + 1); + } + if (!namespace.matches("[a-z0-9_.-]+") || !path.matches("[a-z0-9_./-]+") || path.startsWith("/") || path.endsWith("/") || path.contains("..")) { + throw new IllegalArgumentException("Invalid dimension id '" + value + "'. Use name or namespace:path."); + } + return namespace + ":" + path; } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWorldDatapackWriter.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWorldDatapackWriter.java deleted file mode 100644 index c37795d2b..000000000 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWorldDatapackWriter.java +++ /dev/null @@ -1,217 +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; - -import art.arcane.iris.core.loader.IrisData; -import art.arcane.iris.core.nms.datapack.DataVersion; -import art.arcane.iris.engine.object.IrisDimension; -import net.minecraft.server.MinecraftServer; -import net.minecraft.world.level.storage.LevelResource; - -import java.io.File; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Locale; -import java.util.stream.Stream; - -final class ModdedWorldDatapackWriter { - static final String WORLD_PACK_NAME = "iris"; - private static final String DEFAULT_NAMESPACE = "irisworldgen"; - private static final String TYPE_NAMESPACE = "irisworldgen"; - - private ModdedWorldDatapackWriter() { - } - - static WriteResult enable(MinecraftServer server, String targetDimension, String packName, String packDimension) throws IOException { - ResourceTarget target = ResourceTarget.parse(targetDimension); - String cleanPackName = cleanPackSegment(packName, "pack"); - String cleanPackDimension = cleanPackSegment(packDimension, "pack dimension"); - File packFolder = new File(ModdedPackCommands.packsRoot(), cleanPackName); - if (!packFolder.isDirectory()) { - throw new IllegalArgumentException("Pack '" + cleanPackName + "' was not found under " + ModdedPackCommands.packsRoot().getAbsolutePath()); - } - - IrisData data = IrisData.get(packFolder); - IrisDimension dimension = data.getDimensionLoader().load(cleanPackDimension); - if (dimension == null) { - throw new IllegalArgumentException("Pack '" + cleanPackName + "' does not contain dimensions/" + cleanPackDimension + ".json"); - } - - String typeKey = IrisDimension.sanitizeDimensionTypeKeyValue(cleanPackDimension); - File typeFile = dimensionTypeFile(server, typeKey); - File dimensionFile = dimensionFile(server, target); - File mcmeta = packMetaFile(server); - - writeParented(typeFile, dimension.getDimensionType().toJson(DataVersion.getLatest().get())); - writeParented(dimensionFile, dimensionJson(cleanPackDimension, typeKey)); - writePackMeta(mcmeta); - - return new WriteResult(target.id(), cleanPackName, cleanPackDimension, dimensionFile, typeFile, mcmeta); - } - - static File disable(MinecraftServer server, String targetDimension) throws IOException { - ResourceTarget target = ResourceTarget.parse(targetDimension); - File dimensionFile = dimensionFile(server, target); - if (!dimensionFile.isFile()) { - throw new IllegalArgumentException("No Iris world datapack dimension exists for " + target.id()); - } - Files.delete(dimensionFile.toPath()); - return dimensionFile; - } - - static List enabledDimensions(MinecraftServer server) throws IOException { - File dataRoot = new File(irisPackFolder(server), "data"); - if (!dataRoot.isDirectory()) { - return List.of(); - } - - List dimensions = new ArrayList<>(); - File[] namespaces = dataRoot.listFiles(File::isDirectory); - if (namespaces == null) { - return List.of(); - } - - for (File namespace : namespaces) { - File dimensionRoot = new File(namespace, "dimension"); - if (!dimensionRoot.isDirectory()) { - continue; - } - collectDimensionFiles(namespace.getName(), dimensionRoot, dimensions); - } - Collections.sort(dimensions); - return dimensions; - } - - static File packMetaFile(MinecraftServer server) { - return new File(irisPackFolder(server), "pack.mcmeta"); - } - - private static void collectDimensionFiles(String namespace, File dimensionRoot, List dimensions) throws IOException { - Path root = dimensionRoot.toPath(); - try (Stream stream = Files.walk(root)) { - stream.filter(Files::isRegularFile) - .filter((Path path) -> path.getFileName().toString().endsWith(".json")) - .forEach((Path path) -> { - String relative = root.relativize(path).toString().replace(File.separatorChar, '/'); - String idPath = relative.substring(0, relative.length() - ".json".length()); - dimensions.add(namespace + ":" + idPath); - }); - } - } - - private static File worldDatapacksFolder(MinecraftServer server) { - return server.getWorldPath(LevelResource.DATAPACK_DIR).toFile(); - } - - private static File irisPackFolder(MinecraftServer server) { - return new File(worldDatapacksFolder(server), WORLD_PACK_NAME); - } - - private static File dimensionTypeFile(MinecraftServer server, String typeKey) { - return new File(irisPackFolder(server), "data/" + TYPE_NAMESPACE + "/dimension_type/" + typeKey + ".json"); - } - - private static File dimensionFile(MinecraftServer server, ResourceTarget target) { - return new File(new File(irisPackFolder(server), "data/" + target.namespace() + "/dimension"), target.path() + ".json"); - } - - private static void writePackMeta(File output) throws IOException { - int packFormat = DataVersion.getLatest().getPackFormat(); - String json = "{\n" - + " \"pack\": {\n" - + " \"description\": \"Iris world and dimension type definitions.\",\n" - + " \"pack_format\": " + packFormat + ",\n" - + " \"min_format\": " + packFormat + ",\n" - + " \"max_format\": " + packFormat + "\n" - + " }\n" - + "}\n"; - writeParented(output, json); - } - - private static void writeParented(File output, String text) throws IOException { - File parent = output.getParentFile(); - if (parent != null) { - parent.mkdirs(); - } - Files.writeString(output.toPath(), text, StandardCharsets.UTF_8); - } - - private static String dimensionJson(String packDimension, String typeKey) { - return "{\n" - + " \"type\": \"" + TYPE_NAMESPACE + ":" + typeKey + "\",\n" - + " \"generator\": {\n" - + " \"type\": \"irisworldgen:iris\",\n" - + " \"dimension\": \"" + escape(packDimension) + "\",\n" - + " \"biome_source\": {\n" - + " \"type\": \"minecraft:fixed\",\n" - + " \"biome\": \"minecraft:plains\"\n" - + " }\n" - + " }\n" - + "}\n"; - } - - private static String cleanPackSegment(String value, String label) { - if (value == null || value.isBlank()) { - throw new IllegalArgumentException("Missing " + label + "."); - } - String clean = value.trim(); - if (clean.contains("/") || clean.contains("..") || clean.contains("\\") || clean.contains(":")) { - throw new IllegalArgumentException("Invalid " + label + " '" + value + "'."); - } - return clean; - } - - private static String escape(String value) { - return value.replace("\\", "\\\\").replace("\"", "\\\""); - } - - record WriteResult(String targetDimension, String packName, String packDimension, File dimensionFile, File typeFile, File packMetaFile) { - } - - private record ResourceTarget(String namespace, String path) { - static ResourceTarget parse(String value) { - if (value == null || value.isBlank()) { - throw new IllegalArgumentException("Missing dimension id."); - } - - String normalized = value.trim().toLowerCase(Locale.ROOT); - String namespace = DEFAULT_NAMESPACE; - String path = normalized; - int colon = normalized.indexOf(':'); - if (colon >= 0) { - namespace = normalized.substring(0, colon); - path = normalized.substring(colon + 1); - } - - if (!namespace.matches("[a-z0-9_.-]+") || !path.matches("[a-z0-9_./-]+") || path.startsWith("/") || path.endsWith("/") || path.contains("..")) { - throw new IllegalArgumentException("Invalid dimension id '" + value + "'. Use name or namespace:path."); - } - return new ResourceTarget(namespace, path); - } - - String id() { - return namespace + ":" + path; - } - } -} 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 new file mode 100644 index 000000000..b296a9a22 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedLogFilterService.java @@ -0,0 +1,164 @@ +/* + * 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 org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Marker; +import org.apache.logging.log4j.core.Filter; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.Logger; +import org.apache.logging.log4j.message.Message; + +import java.util.List; + +public final class ModdedLogFilterService implements ModdedService, Filter { + 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"); + + @Override + public void onEnable() { + ((Logger) LogManager.getRootLogger()).addFilter(this); + } + + @Override + public void onDisable() { + } + + @Override + public void initialize() { + } + + @Override + public void start() { + } + + @Override + public void stop() { + } + + @Override + public boolean isStarted() { + return true; + } + + @Override + public boolean isStopped() { + return false; + } + + @Override + public State getState() { + return State.STARTED; + } + + @Override + public Filter.Result getOnMatch() { + return Result.NEUTRAL; + } + + @Override + public Filter.Result getOnMismatch() { + return Result.NEUTRAL; + } + + @Override + public Result filter(LogEvent event) { + return check(event.getMessage().getFormattedMessage()); + } + + @Override + public Result filter(Logger logger, Level level, Marker marker, Object msg, Throwable t) { + return check(String.valueOf(msg)); + } + + @Override + public Result filter(Logger logger, Level level, Marker marker, Message msg, Throwable t) { + return check(msg.getFormattedMessage()); + } + + @Override + public Result filter(Logger logger, Level level, Marker marker, String message, Object... params) { + return check(message); + } + + @Override + public Result filter(Logger logger, Level level, Marker marker, String message, Object p0) { + return check(message); + } + + @Override + public Result filter(Logger logger, Level level, Marker marker, String message, Object p0, Object p1) { + return check(message); + } + + @Override + public Result filter(Logger logger, Level level, Marker marker, String message, Object p0, Object p1, Object p2) { + return check(message); + } + + @Override + public Result filter(Logger logger, Level level, Marker marker, String message, Object p0, Object p1, Object p2, Object p3) { + return check(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); + } + + @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); + } + + @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); + } + + @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); + } + + @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); + } + + @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); + } + + private Result check(String message) { + if (message == null) { + return Result.NEUTRAL; + } + for (String filter : FILTERS) { + if (message.contains(filter)) { + return Result.DENY; + } + } + return Result.NEUTRAL; + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedPreservationService.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedPreservationService.java new file mode 100644 index 000000000..fc1226339 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedPreservationService.java @@ -0,0 +1,122 @@ +/* + * 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.engine.framework.MeteredCache; +import art.arcane.iris.engine.framework.PreservationRegistry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.lang.ref.WeakReference; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.atomic.AtomicBoolean; + +public final class ModdedPreservationService implements ModdedService, PreservationRegistry { + private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + private static final long DEREFERENCE_INTERVAL_MILLIS = 60000L; + + private final List threads = new CopyOnWriteArrayList<>(); + private final List services = new CopyOnWriteArrayList<>(); + private final List> caches = new CopyOnWriteArrayList<>(); + private final AtomicBoolean running = new AtomicBoolean(false); + private Thread dereferencer; + + @Override + public void register(Thread thread) { + threads.add(thread); + } + + public void register(ExecutorService service) { + services.add(service); + } + + @Override + public void registerCache(MeteredCache cache) { + caches.add(new WeakReference<>(cache)); + } + + @Override + public void dereference() { + threads.removeIf((Thread thread) -> !thread.isAlive()); + services.removeIf(ExecutorService::isShutdown); + caches.removeIf((WeakReference ref) -> { + MeteredCache cache = ref.get(); + return cache == null || cache.isClosed(); + }); + } + + @Override + public void onEnable() { + if (!running.compareAndSet(false, true)) { + return; + } + dereferencer = new Thread(this::loop, "iris-modded-preservation"); + dereferencer.setDaemon(true); + dereferencer.start(); + } + + @Override + public void onDisable() { + if (!running.compareAndSet(true, false)) { + return; + } + if (dereferencer != null) { + dereferencer.interrupt(); + dereferencer = null; + } + dereference(); + shutdownTrackedResources(); + } + + private void loop() { + while (running.get()) { + try { + Thread.sleep(DEREFERENCE_INTERVAL_MILLIS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return; + } + dereference(); + } + } + + private void shutdownTrackedResources() { + for (Thread thread : threads) { + if (!thread.isAlive()) { + continue; + } + try { + thread.interrupt(); + LOGGER.info("Iris preservation interrupted thread {}", thread.getName()); + } catch (Throwable error) { + LOGGER.error("Iris preservation failed to interrupt thread {}", thread.getName(), error); + } + } + for (ExecutorService service : services) { + try { + service.shutdownNow(); + LOGGER.info("Iris preservation shut down executor {}", service); + } catch (Throwable error) { + LOGGER.error("Iris preservation failed to shut down executor {}", service, error); + } + } + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedService.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedService.java new file mode 100644 index 000000000..06b92d5d1 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedService.java @@ -0,0 +1,25 @@ +/* + * 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; + +public interface ModdedService { + void onEnable(); + + void onDisable(); +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedTickableService.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedTickableService.java new file mode 100644 index 000000000..b086e4fbd --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedTickableService.java @@ -0,0 +1,25 @@ +/* + * 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 net.minecraft.server.MinecraftServer; + +public interface ModdedTickableService extends ModdedService { + void onServerTick(MinecraftServer server); +} diff --git a/adapters/neoforge/build.gradle b/adapters/neoforge/build.gradle index 9137c89e2..d27467dca 100644 --- a/adapters/neoforge/build.gradle +++ b/adapters/neoforge/build.gradle @@ -126,6 +126,8 @@ dependencies { neoForge { version = neoForgeVersion + accessTransformers.from('src/main/resources/META-INF/accesstransformer.cfg') + runs { server { server() @@ -176,6 +178,7 @@ tasks.named('shadowJar', ShadowJar).configure { } archiveFileName.set(irisArtifactName('NeoForge', "${minecraftVersion}+${neoForgeVersion}")) configurations = [project.configurations.named('bundle').get()] + from(sourceSets.main.output) mergeServiceFiles() exclude('META-INF/maven/**') exclude('META-INF/proguard/**') @@ -184,7 +187,16 @@ tasks.named('shadowJar', ShadowJar).configure { exclude('META-INF/*.RSA') duplicatesStrategy = DuplicatesStrategy.EXCLUDE relocate('org.objectweb.asm', 'art.arcane.iris.shadow.asm') + relocate('com.sun.jna', 'art.arcane.iris.shadow.jna') + relocate('oshi', 'art.arcane.iris.shadow.oshi') + relocate('net.jpountz', 'art.arcane.iris.shadow.jpountz') exclude('oshi.properties') + exclude('org/jspecify/**') + exclude('com/google/errorprone/**') + exclude('com/google/j2objc/**') + exclude('org/checkerframework/**') + exclude('org/jetbrains/annotations/**') + exclude('org/intellij/lang/**') } tasks.named('assemble').configure { 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 391361423..f26ec0c04 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 @@ -20,6 +20,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; @@ -29,6 +30,7 @@ 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; +import net.minecraft.server.packs.PackType; import net.minecraft.world.InteractionResult; import net.minecraft.world.level.chunk.ChunkGenerator; import net.neoforged.bus.api.IEventBus; @@ -38,6 +40,7 @@ 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.ServerStoppingEvent; @@ -62,10 +65,17 @@ public final class IrisNeoForgeBootstrap { chunkGenerators.register(modBus); ModdedIrisLog.info("Iris chunk generator registered as irisworldgen:iris"); + modBus.addListener((AddPackFindersEvent event) -> { + if (event.getPackType() == PackType.SERVER_DATA) { + event.addRepositorySource(ModdedForcedDatapack.repositorySource()); + } + }); + NeoForge.EVENT_BUS.addListener((ServerStoppingEvent event) -> { ModdedObjectUndo.clearAll(); ModdedWandService.clearAll(); ModdedWorldEngines.shutdown(); + ModdedEngineBootstrap.stop(); }); NeoForge.EVENT_BUS.addListener((RegisterCommandsEvent event) -> IrisModdedCommands.register(event.getDispatcher())); NeoForge.EVENT_BUS.addListener((PlayerInteractEvent.LeftClickBlock event) -> { @@ -79,7 +89,10 @@ public final class IrisNeoForgeBootstrap { event.setCancellationResult(InteractionResult.SUCCESS); } }); - NeoForge.EVENT_BUS.addListener((ServerTickEvent.Post event) -> ModdedWandService.serverTick(event.getServer())); + NeoForge.EVENT_BUS.addListener((ServerTickEvent.Post event) -> { + ModdedEngineBootstrap.tick(event.getServer()); + ModdedWandService.serverTick(event.getServer()); + }); String parity = System.getProperty("iris.parity"); if (parity != null) { diff --git a/adapters/neoforge/src/main/resources/META-INF/accesstransformer.cfg b/adapters/neoforge/src/main/resources/META-INF/accesstransformer.cfg new file mode 100644 index 000000000..5a2faeeb3 --- /dev/null +++ b/adapters/neoforge/src/main/resources/META-INF/accesstransformer.cfg @@ -0,0 +1,3 @@ +public net.minecraft.server.MinecraftServer levels +public net.minecraft.server.MinecraftServer executor +public net.minecraft.server.MinecraftServer storageSource 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 e069dc32a..0cfa283d1 100644 --- a/core/src/main/java/art/arcane/iris/core/IrisSettings.java +++ b/core/src/main/java/art/arcane/iris/core/IrisSettings.java @@ -150,11 +150,6 @@ public class IrisSettings { public int maxResidentTectonicPlates = 96; public int mantleBackpressureWaitMs = 25; public int mantleBackpressureTimeoutMs = 60_000; - public boolean enableSpigotDirectPregen = false; - - public boolean isEnableSpigotDirectPregen() { - return enableSpigotDirectPregen; - } public int getChunkLoadTimeoutSeconds() { return Math.max(5, Math.min(chunkLoadTimeoutSeconds, 120)); 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 new file mode 100644 index 000000000..2bc08a922 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/gui/GuiHost.java @@ -0,0 +1,61 @@ +/* + * 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.gui; + +import art.arcane.iris.engine.framework.Engine; + +import java.awt.GraphicsEnvironment; + +public final class GuiHost { + private static volatile Provider provider = new Provider() { + }; + + private GuiHost() { + } + + public interface Provider { + default Engine findActiveEngine() { + return null; + } + + default void registerHotloadHook(Runnable onHotload) { + } + + default void unregisterHotloadHook(Runnable onHotload) { + } + + default GuiOverlay overlayFor(Engine engine) { + return null; + } + } + + public static void set(Provider boundProvider) { + if (boundProvider != null) { + provider = boundProvider; + } + } + + public static Provider get() { + return provider; + } + + public static boolean isAvailable() { + return !GraphicsEnvironment.isHeadless(); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/gui/GuiMarker.java b/core/src/main/java/art/arcane/iris/core/gui/GuiMarker.java new file mode 100644 index 000000000..58173f942 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/gui/GuiMarker.java @@ -0,0 +1,29 @@ +/* + * 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.gui; + +public record GuiMarker(String label, double worldX, double worldY, double worldZ, double health, double maxHealth) { + public static GuiMarker player(String name, double worldX, double worldZ) { + return new GuiMarker(name, worldX, 0, worldZ, 0, 0); + } + + public static GuiMarker entity(String label, double worldX, double worldY, double worldZ, double health, double maxHealth) { + return new GuiMarker(label, worldX, worldY, worldZ, health, maxHealth); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/gui/GuiOverlay.java b/core/src/main/java/art/arcane/iris/core/gui/GuiOverlay.java new file mode 100644 index 000000000..b329e0272 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/gui/GuiOverlay.java @@ -0,0 +1,34 @@ +/* + * 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.gui; + +import art.arcane.iris.engine.framework.render.RenderType; + +import java.util.List; +import java.util.function.Consumer; + +public interface GuiOverlay { + List players(); + + void requestEntities(Consumer> sink); + + void teleport(double worldX, double worldZ); + + String openInEditor(double worldX, double worldZ, RenderType type); +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/gui/NoiseExplorerGUI.java b/core/src/main/java/art/arcane/iris/core/gui/NoiseExplorerGUI.java similarity index 93% rename from adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/gui/NoiseExplorerGUI.java rename to core/src/main/java/art/arcane/iris/core/gui/NoiseExplorerGUI.java index 2878e870a..9d4ddca64 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/gui/NoiseExplorerGUI.java +++ b/core/src/main/java/art/arcane/iris/core/gui/NoiseExplorerGUI.java @@ -1,6 +1,6 @@ /* - * Iris is a World Generator for Minecraft Bukkit Servers - * Copyright (c) 2022 Arcane Arts (Volmit Software) + * 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 @@ -18,15 +18,11 @@ package art.arcane.iris.core.gui; -import art.arcane.iris.Iris; import art.arcane.iris.core.IrisSettings; -import art.arcane.iris.core.events.IrisEngineHotloadEvent; import art.arcane.iris.core.loader.IrisData; -import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.IrisGenerator; import art.arcane.iris.engine.object.NoiseStyle; -import art.arcane.iris.engine.platform.PlatformChunkGenerator; import art.arcane.volmlib.util.function.Function2; import art.arcane.volmlib.util.math.M; import art.arcane.volmlib.util.math.RNG; @@ -36,23 +32,44 @@ import art.arcane.iris.util.common.parallel.BurstExecutor; import art.arcane.iris.util.common.parallel.MultiBurst; import art.arcane.iris.util.common.scheduling.J; import art.arcane.volmlib.util.scheduling.PrecisionStopwatch; -import org.bukkit.Bukkit; -import org.bukkit.World; -import org.bukkit.event.EventHandler; -import org.bukkit.event.Listener; -import javax.swing.*; +import javax.swing.BorderFactory; +import javax.swing.DefaultListCellRenderer; +import javax.swing.DefaultListModel; +import javax.swing.JFrame; +import javax.swing.JList; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTextField; +import javax.swing.ListSelectionModel; import javax.swing.event.DocumentEvent; import javax.swing.event.DocumentListener; -import java.awt.*; -import java.awt.event.*; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Component; +import java.awt.Dimension; +import java.awt.EventQueue; +import java.awt.Font; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.Point; +import java.awt.RenderingHints; +import java.awt.event.MouseEvent; +import java.awt.event.MouseMotionListener; +import java.awt.event.MouseWheelEvent; +import java.awt.event.MouseWheelListener; +import java.awt.event.WindowAdapter; +import java.awt.event.WindowEvent; import java.awt.image.BufferedImage; import java.awt.image.DataBufferInt; -import java.util.*; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.function.Supplier; -public class NoiseExplorerGUI extends JPanel implements MouseWheelListener, Listener { +public class NoiseExplorerGUI extends JPanel implements MouseWheelListener { private static final long serialVersionUID = 2094606939770332040L; private static final Color BG = new Color(24, 24, 28); @@ -88,6 +105,7 @@ public class NoiseExplorerGUI extends JPanel implements MouseWheelListener, List private final RollingSequence fpsHistory = new RollingSequence(60); private final boolean colorMode = IrisSettings.get().getGui().colorMode; private final MultiBurst gx = MultiBurst.burst; + private final Runnable hotloadHook = this::refreshGenerator; private double scale = 1; private double animScale = 10; private double ox = 0; @@ -107,7 +125,7 @@ public class NoiseExplorerGUI extends JPanel implements MouseWheelListener, List private String currentName = "STATIC"; public NoiseExplorerGUI() { - Iris.instance.registerListener(this); + GuiHost.get().registerHotloadHook(hotloadHook); setBackground(BG); addMouseWheelListener(this); addMouseMotionListener(new MouseMotionListener() { @@ -130,7 +148,7 @@ public class NoiseExplorerGUI extends JPanel implements MouseWheelListener, List } public static void launch() { - Engine engine = findActiveEngine(); + Engine engine = GuiHost.get().findActiveEngine(); EventQueue.invokeLater(() -> { NoiseExplorerGUI nv = new NoiseExplorerGUI(); buildFrame("Noise Explorer", nv, engine, null, null); @@ -138,7 +156,7 @@ public class NoiseExplorerGUI extends JPanel implements MouseWheelListener, List } public static void launch(Supplier> gen, String genName) { - Engine engine = findActiveEngine(); + Engine engine = GuiHost.get().findActiveEngine(); EventQueue.invokeLater(() -> { NoiseExplorerGUI nv = new NoiseExplorerGUI(); nv.loader = gen; @@ -148,20 +166,6 @@ public class NoiseExplorerGUI extends JPanel implements MouseWheelListener, List }); } - private static Engine findActiveEngine() { - try { - for (World w : new ArrayList<>(Bukkit.getWorlds())) { - try { - PlatformChunkGenerator access = IrisToolbelt.access(w); - if (access != null && access.getEngine() != null && !access.getEngine().isClosed()) { - return access.getEngine(); - } - } catch (Throwable ignored) {} - } - } catch (Throwable ignored) {} - return null; - } - private static JFrame buildFrame(String title, NoiseExplorerGUI nv, Engine engine, Supplier> customGen, String customName) { JFrame frame = new JFrame(title); @@ -180,7 +184,7 @@ public class NoiseExplorerGUI extends JPanel implements MouseWheelListener, List frame.addWindowListener(new WindowAdapter() { @Override public void windowClosing(WindowEvent e) { - Iris.instance.unregisterListener(nv); + GuiHost.get().unregisterHotloadHook(nv.hotloadHook); } }); return frame; @@ -364,8 +368,7 @@ public class NoiseExplorerGUI extends JPanel implements MouseWheelListener, List return Character.toUpperCase(lower.charAt(0)) + lower.substring(1); } - @EventHandler - public void on(IrisEngineHotloadEvent e) { + private void refreshGenerator() { if (generator != null && loader != null) { generator = loader.get(); } diff --git a/core/src/main/java/art/arcane/iris/core/gui/PregenRenderSource.java b/core/src/main/java/art/arcane/iris/core/gui/PregenRenderSource.java new file mode 100644 index 000000000..638b16373 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/gui/PregenRenderSource.java @@ -0,0 +1,31 @@ +/* + * 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.gui; + +import art.arcane.volmlib.util.math.Position2; + +public interface PregenRenderSource { + Position2 min(); + + Position2 max(); + + String[] progress(); + + boolean paused(); +} 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 new file mode 100644 index 000000000..6d3dc3317 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/gui/PregenRenderer.java @@ -0,0 +1,158 @@ +/* + * 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.gui; + +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.core.IrisSettings; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.function.Consumer2; +import art.arcane.volmlib.util.math.M; +import art.arcane.volmlib.util.math.Position2; +import art.arcane.iris.util.common.scheduling.J; + +import javax.swing.JFrame; +import javax.swing.JPanel; +import java.awt.Color; +import java.awt.Font; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.event.KeyEvent; +import java.awt.event.KeyListener; +import java.awt.image.BufferedImage; +import java.util.concurrent.locks.ReentrantLock; + +public final class PregenRenderer extends JPanel implements KeyListener { + private static final long serialVersionUID = 2094606939770332040L; + + private final KList order = new KList<>(); + private final ReentrantLock lock = new ReentrantLock(); + private final int res = 512; + private final BufferedImage image = new BufferedImage(res, res, BufferedImage.TYPE_INT_RGB); + private final PregenRenderSource source; + private final Runnable onPause; + private Graphics2D bg; + private JFrame frame; + + private PregenRenderer(PregenRenderSource source, Runnable onPause) { + this.source = source; + this.onPause = onPause; + } + + public static PregenRenderer open(String title, PregenRenderSource source, Runnable onPause) { + PregenRenderer renderer = new PregenRenderer(source, onPause); + JFrame frame = new JFrame(title); + renderer.frame = frame; + frame.addKeyListener(renderer); + frame.add(renderer); + frame.setSize(1000, 1000); + frame.setVisible(true); + return renderer; + } + + public Consumer2 drawFunction() { + return (Position2 c, Color color) -> { + lock.lock(); + try { + order.add(() -> draw(c, color, bg)); + } finally { + lock.unlock(); + } + }; + } + + public void submit(int x, int z, Color color) { + drawFunction().accept(new Position2(x, z), color); + } + + public boolean isVisibleFrame() { + return frame != null && frame.isVisible(); + } + + public void close() { + if (frame != null) { + frame.setVisible(false); + } + } + + @Override + public void paint(Graphics gx) { + Graphics2D g = (Graphics2D) gx; + bg = (Graphics2D) image.getGraphics(); + lock.lock(); + try { + while (order.isNotEmpty()) { + try { + order.pop().run(); + } catch (Throwable e) { + IrisLogging.reportError(e); + } + } + } finally { + lock.unlock(); + } + + g.drawImage(image, 0, 0, getParent().getWidth(), getParent().getHeight(), (img, infoflags, x, y, width, height) -> true); + g.setColor(Color.WHITE); + g.setFont(new Font("Hevetica", Font.BOLD, 13)); + String[] prog = source.progress(); + int h = g.getFontMetrics().getHeight() + 5; + int hh = 20; + + if (source.paused()) { + g.drawString("PAUSED", 20, hh += h); + g.drawString("Press P to Resume", 20, hh += h); + } else { + for (String i : prog) { + g.drawString(i, 20, hh += h); + } + g.drawString("Press P to Pause", 20, hh += h); + } + + J.sleep(IrisSettings.get().getGui().isMaximumPregenGuiFPS() ? 4 : 250); + repaint(); + } + + private void draw(Position2 p, Color c, Graphics2D bg) { + double pw = M.lerpInverse(source.min().getX(), source.max().getX(), p.getX()); + double ph = M.lerpInverse(source.min().getZ(), source.max().getZ(), p.getZ()); + double pwa = M.lerpInverse(source.min().getX(), source.max().getX(), p.getX() + 1); + double pha = M.lerpInverse(source.min().getZ(), source.max().getZ(), p.getZ() + 1); + int x = (int) M.lerp(0, res, pw); + int z = (int) M.lerp(0, res, ph); + int xa = (int) M.lerp(0, res, pwa); + int za = (int) M.lerp(0, res, pha); + bg.setColor(c); + bg.fillRect(x, z, xa - x, za - z); + } + + @Override + public void keyTyped(KeyEvent e) { + } + + @Override + public void keyPressed(KeyEvent e) { + } + + @Override + public void keyReleased(KeyEvent e) { + if (e.getKeyCode() == KeyEvent.VK_P && onPause != null) { + onPause.run(); + } + } +} 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 fee81f313..12e9a69d0 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 @@ -30,25 +30,19 @@ import art.arcane.volmlib.util.format.Form; import art.arcane.volmlib.util.format.MemoryMonitor; import art.arcane.volmlib.util.function.Consumer2; import art.arcane.volmlib.util.mantle.runtime.Mantle; -import art.arcane.volmlib.util.math.M; 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 javax.swing.*; -import java.awt.*; -import java.awt.event.KeyEvent; -import java.awt.event.KeyListener; -import java.awt.image.BufferedImage; +import java.awt.Color; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; -import java.util.concurrent.locks.ReentrantLock; import java.util.function.Consumer; -public class PregeneratorJob implements PregenListener { +public class PregeneratorJob implements PregenListener, PregenRenderSource { private static final Color COLOR_EXISTS = parseColor("#4d7d5b"); private static final Color COLOR_BLACK = parseColor("#4d7d5b"); private static final Color COLOR_MANTLE = parseColor("#3c2773"); @@ -69,8 +63,8 @@ public class PregeneratorJob implements PregenListener { private final ChronoLatch cl = new ChronoLatch(TimeUnit.MINUTES.toMillis(1)); private final Engine engine; private final ExecutorService service; - private JFrame frame; private PregenRenderer renderer; + private Consumer2 drawFunction; private int rgc = 0; private String[] info; private volatile double lastChunksPerSecond = 0D; @@ -212,8 +206,8 @@ public class PregeneratorJob implements PregenListener { public void draw(int x, int z, Color color) { try { - if (renderer != null && frame != null && frame.isVisible()) { - renderer.func.accept(new Position2(x, z), color); + if (renderer != null && drawFunction != null && renderer.isVisibleFrame()) { + drawFunction.accept(new Position2(x, z), color); } } catch (Throwable ignored) { IrisLogging.error("Failed to draw pregen"); @@ -232,11 +226,11 @@ public class PregeneratorJob implements PregenListener { J.a(() -> { try { monitor.close(); - if (frame == null) { + if (renderer == null) { return; } J.sleep(3000); - frame.setVisible(false); + renderer.close(); } catch (Throwable ignored) { IrisLogging.error("Error closing pregen gui"); } @@ -246,20 +240,8 @@ public class PregeneratorJob implements PregenListener { public void open() { J.a(() -> { try { - frame = new JFrame("Pregen View"); - renderer = new PregenRenderer(); - frame.addKeyListener(renderer); - renderer.l = new ReentrantLock(); - renderer.frame = frame; - renderer.job = this; - renderer.func = (c, b) -> { - renderer.l.lock(); - renderer.order.add(() -> renderer.draw(c, b, renderer.bg)); - renderer.l.unlock(); - }; - frame.add(renderer); - frame.setSize(1000, 1000); - frame.setVisible(true); + renderer = PregenRenderer.open("Pregen View", this, PregeneratorJob::pauseResume); + drawFunction = renderer.drawFunction(); } catch (Throwable ignored) { IrisLogging.error("Error opening pregen gui"); } @@ -292,7 +274,7 @@ public class PregeneratorJob implements PregenListener { @Override public void onChunkGenerated(int x, int z, boolean cached) { - if (renderer == null || frame == null || !frame.isVisible()) return; + if (renderer == null || !renderer.isVisibleFrame()) return; service.submit(() -> { if (engine != null) { draw(x, z, engine.draw((x << 4) + 8, (z << 4) + 8)); @@ -378,112 +360,23 @@ public class PregeneratorJob implements PregenListener { draw(x, z, COLOR_EXISTS); } - private Position2 getMax() { + @Override + public Position2 max() { return max; } - private Position2 getMin() { + @Override + public Position2 min() { return min; } - private boolean paused() { + @Override + public boolean paused() { return pregenerator.paused(); } - private String[] getProgress() { + @Override + public String[] progress() { return info; } - - public static class PregenRenderer extends JPanel implements KeyListener { - private static final long serialVersionUID = 2094606939770332040L; - private final KList order = new KList<>(); - private final int res = 512; - private final BufferedImage image = new BufferedImage(res, res, BufferedImage.TYPE_INT_RGB); - Graphics2D bg; - private PregeneratorJob job; - private ReentrantLock l; - private Consumer2 func; - private JFrame frame; - - public PregenRenderer() { - - } - - public void paint(int x, int z, Color c) { - func.accept(new Position2(x, z), c); - } - - @Override - public void paint(Graphics gx) { - Graphics2D g = (Graphics2D) gx; - bg = (Graphics2D) image.getGraphics(); - l.lock(); - - while (order.isNotEmpty()) { - try { - order.pop().run(); - } catch (Throwable e) { - IrisLogging.reportError(e); - - } - } - - l.unlock(); - g.drawImage(image, 0, 0, getParent().getWidth(), getParent().getHeight(), (img, infoflags, x, y, width, height) -> true); - g.setColor(Color.WHITE); - g.setFont(new Font("Hevetica", Font.BOLD, 13)); - String[] prog = job.getProgress(); - int h = g.getFontMetrics().getHeight() + 5; - int hh = 20; - - if (job.paused()) { - g.drawString("PAUSED", 20, hh += h); - - g.drawString("Press P to Resume", 20, hh += h); - } else { - for (String i : prog) { - g.drawString(i, 20, hh += h); - } - - g.drawString("Press P to Pause", 20, hh += h); - } - - J.sleep(IrisSettings.get().getGui().isMaximumPregenGuiFPS() ? 4 : 250); - repaint(); - } - - private void draw(Position2 p, Color c, Graphics2D bg) { - double pw = M.lerpInverse(job.getMin().getX(), job.getMax().getX(), p.getX()); - double ph = M.lerpInverse(job.getMin().getZ(), job.getMax().getZ(), p.getZ()); - double pwa = M.lerpInverse(job.getMin().getX(), job.getMax().getX(), p.getX() + 1); - double pha = M.lerpInverse(job.getMin().getZ(), job.getMax().getZ(), p.getZ() + 1); - int x = (int) M.lerp(0, res, pw); - int z = (int) M.lerp(0, res, ph); - int xa = (int) M.lerp(0, res, pwa); - int za = (int) M.lerp(0, res, pha); - bg.setColor(c); - bg.fillRect(x, z, xa - x, za - z); - } - - @Override - public void keyTyped(KeyEvent e) { - - } - - @Override - public void keyPressed(KeyEvent e) { - - } - - @Override - public void keyReleased(KeyEvent e) { - if (e.getKeyCode() == KeyEvent.VK_P) { - PregeneratorJob.pauseResume(); - } - } - - public void close() { - frame.setVisible(false); - } - } } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/gui/VisionGUI.java b/core/src/main/java/art/arcane/iris/core/gui/VisionGUI.java similarity index 86% rename from adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/gui/VisionGUI.java rename to core/src/main/java/art/arcane/iris/core/gui/VisionGUI.java index 4ba1d7596..e83229ba9 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/gui/VisionGUI.java +++ b/core/src/main/java/art/arcane/iris/core/gui/VisionGUI.java @@ -1,6 +1,6 @@ /* - * Iris is a World Generator for Minecraft Bukkit Servers - * Copyright (c) 2022 Arcane Arts (Volmit Software) + * 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 @@ -18,15 +18,11 @@ package art.arcane.iris.core.gui; -import art.arcane.iris.Iris; import art.arcane.iris.engine.framework.render.IrisRenderer; import art.arcane.iris.engine.framework.render.RenderType; -import art.arcane.iris.core.tools.IrisToolbelt; -import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisRegion; -import art.arcane.iris.engine.object.IrisWorld; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.collection.KSet; @@ -38,25 +34,43 @@ import art.arcane.volmlib.util.scheduling.ChronoLatch; import art.arcane.iris.util.common.scheduling.J; import art.arcane.volmlib.util.scheduling.O; import art.arcane.volmlib.util.scheduling.PrecisionStopwatch; -import org.bukkit.Location; -import org.bukkit.entity.LivingEntity; -import org.bukkit.entity.Player; -import javax.swing.*; +import javax.swing.BorderFactory; +import javax.swing.ButtonGroup; +import javax.swing.JFrame; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JSeparator; +import javax.swing.JToggleButton; +import javax.swing.SwingConstants; import javax.swing.event.MouseInputListener; -import java.awt.*; -import java.awt.event.*; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.EventQueue; +import java.awt.FlowLayout; +import java.awt.Font; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.Point; +import java.awt.RenderingHints; +import java.awt.event.KeyEvent; +import java.awt.event.KeyListener; +import java.awt.event.MouseEvent; +import java.awt.event.MouseMotionListener; +import java.awt.event.MouseWheelEvent; +import java.awt.event.MouseWheelListener; +import java.awt.event.WindowAdapter; +import java.awt.event.WindowEvent; import java.awt.geom.RoundRectangle2D; import java.awt.image.BufferedImage; -import java.io.File; import java.util.LinkedHashMap; +import java.util.List; import java.util.Locale; import java.util.Map; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; -import static art.arcane.iris.util.common.data.registry.Attributes.MAX_HEALTH; - public class VisionGUI extends JPanel implements MouseWheelListener, KeyListener, MouseMotionListener, MouseInputListener { private static final long serialVersionUID = 2094606939770332040L; @@ -65,9 +79,7 @@ public class VisionGUI extends JPanel implements MouseWheelListener, KeyListener private static final Color CARD_BORDER = new Color(60, 60, 75, 180); private static final Color TEXT_PRIMARY = new Color(220, 220, 230); private static final Color TEXT_SECONDARY = new Color(140, 140, 155); - private static final Color TEXT_DIM = new Color(90, 90, 105); private static final Color ACCENT = new Color(90, 140, 255); - private static final Color ACCENT_DIM = new Color(60, 100, 200, 100); private static final Color PLAYER_COLOR = new Color(80, 200, 120); private static final Color MOB_COLOR = new Color(220, 80, 80); private static final Color STATUS_BG = new Color(24, 24, 30, 240); @@ -81,7 +93,7 @@ public class VisionGUI extends JPanel implements MouseWheelListener, KeyListener private static final int CARD_PAD = 12; private static final int STATUS_HEIGHT = 26; - private final KList lastEntities = new KList<>(); + private final KList lastEntities = new KList<>(); private final KMap notifications = new KMap<>(); private final ChronoLatch centities = new ChronoLatch(1000); private final RollingSequence rs = new RollingSequence(512); @@ -95,7 +107,6 @@ public class VisionGUI extends JPanel implements MouseWheelListener, KeyListener private boolean help = true; private boolean helpIgnored = false; private boolean shift = false; - private Player player = null; private boolean debug = false; private boolean control = false; private boolean eco = false; @@ -103,8 +114,9 @@ public class VisionGUI extends JPanel implements MouseWheelListener, KeyListener private boolean follow = false; private boolean alt = false; private boolean grid = false; + private GuiMarker followMarker = null; private IrisRenderer renderer; - private IrisWorld world; + private GuiOverlay overlay; private double velocity = 0; private int lowq = 12; private double scale = 128; @@ -128,10 +140,7 @@ public class VisionGUI extends JPanel implements MouseWheelListener, KeyListener Thread t = new Thread(r); t.setName("Iris HD Renderer " + tid); t.setPriority(Thread.MIN_PRIORITY); - t.setUncaughtExceptionHandler((et, ex) -> { - Iris.info("Exception encountered in " + et.getName()); - ex.printStackTrace(); - }); + t.setUncaughtExceptionHandler((et, ex) -> ex.printStackTrace()); return t; }); @@ -140,10 +149,7 @@ public class VisionGUI extends JPanel implements MouseWheelListener, KeyListener Thread t = new Thread(r); t.setName("Iris Renderer " + tid); t.setPriority(Thread.NORM_PRIORITY); - t.setUncaughtExceptionHandler((et, ex) -> { - Iris.info("Exception encountered in " + et.getName()); - ex.printStackTrace(); - }); + t.setUncaughtExceptionHandler((et, ex) -> ex.printStackTrace()); return t; }); @@ -170,11 +176,15 @@ public class VisionGUI extends JPanel implements MouseWheelListener, KeyListener }); } - private static void createAndShowGUI(Engine r, int s, IrisWorld world) { + public static void launch(Engine g) { + J.a(() -> createAndShowGUI(g)); + } + + private static void createAndShowGUI(Engine r) { JFrame frame = new JFrame("Iris Vision"); VisionGUI nv = new VisionGUI(frame); - nv.world = world; nv.engine = r; + nv.overlay = GuiHost.get().overlayFor(r); nv.renderer = new IrisRenderer(r); frame.getContentPane().setBackground(BG); frame.setLayout(new BorderLayout()); @@ -261,18 +271,17 @@ public class VisionGUI extends JPanel implements MouseWheelListener, KeyListener return sep; } - public static void launch(Engine g, int i) { - J.a(() -> createAndShowGUI(g, i, g.getWorld())); - } - public boolean updateEngine() { if (engine.isClosed()) { - if (world.hasRealWorld()) { - try { - engine = IrisToolbelt.access(world.realWorld()).getEngine(); - return !engine.isClosed(); - } catch (Throwable ignored) { + try { + Engine reacquired = GuiHost.get().findActiveEngine(); + if (reacquired != null && !reacquired.isClosed()) { + engine = reacquired; + overlay = GuiHost.get().overlayFor(reacquired); + renderer = new IrisRenderer(reacquired); + return true; } + } catch (Throwable ignored) { } } return false; @@ -378,8 +387,8 @@ public class VisionGUI extends JPanel implements MouseWheelListener, KeyListener void toggleFollow() { follow = !follow; - if (player != null && follow) { - notify("Following " + player.getName()); + if (followMarker != null && follow) { + notify("Following " + followMarker.label()); } else if (follow) { notify("No player in world"); follow = false; @@ -478,28 +487,26 @@ public class VisionGUI extends JPanel implements MouseWheelListener, KeyListener @Override public void paint(Graphics gx) { - if (engine.isClosed()) { + if (engine.isClosed() && !updateEngine()) { EventQueue.invokeLater(() -> { try { setVisible(false); } catch (Throwable ignored) { } }); return; } - if (updateEngine()) { - dump(); - } - velocity = Math.abs(ox - oxp) * 0.36 + Math.abs(oz - ozp) * 0.36; oxp = lerp(oxp, ox, 0.36); ozp = lerp(ozp, oz, 0.36); hx = lerp(hx, lx, 0.36); hz = lerp(hz, lz, 0.36); - if (centities.flip()) { - J.s(() -> { + if (centities.flip() && overlay != null) { + overlay.requestEntities((List next) -> { synchronized (lastEntities) { lastEntities.clear(); - lastEntities.addAll(world.getEntitiesByClass(LivingEntity.class)); + if (next != null) { + lastEntities.addAll(next); + } } }); } @@ -588,8 +595,8 @@ public class VisionGUI extends JPanel implements MouseWheelListener, KeyListener } private void handleFollow() { - if (follow && player != null) { - animateTo(player.getLocation().getX(), player.getLocation().getZ()); + if (follow && followMarker != null) { + animateTo(followMarker.worldX(), followMarker.worldZ()); } } @@ -635,28 +642,30 @@ public class VisionGUI extends JPanel implements MouseWheelListener, KeyListener g.drawString(right, w - rw - 8, y + 17); g.setColor(ACCENT); - int modeW = g.getFontMetrics().stringWidth(" " + modeName(currentType)); g.fillRect(0, y + 1, 3, STATUS_HEIGHT - 1); } private void renderEntities(Graphics2D g) { - Player b = null; + GuiMarker firstPlayer = null; - for (Player i : world.getPlayers()) { - b = i; - renderPlayerMarker(g, i.getLocation().getX(), i.getLocation().getZ(), i.getName()); + List players = overlay == null ? List.of() : overlay.players(); + if (players != null) { + for (GuiMarker i : players) { + firstPlayer = i; + renderPlayerMarker(g, i.worldX(), i.worldZ(), i.label()); + } } synchronized (lastEntities) { double dist = Double.MAX_VALUE; - LivingEntity nearest = null; + GuiMarker nearest = null; - for (LivingEntity i : lastEntities) { - if (i instanceof Player) continue; - renderMobMarker(g, i.getLocation().getX(), i.getLocation().getZ()); + for (GuiMarker i : lastEntities) { + renderMobMarker(g, i.worldX(), i.worldZ()); if (shift) { - double d = i.getLocation().distanceSquared( - new Location(i.getWorld(), getWorldX(hx), i.getLocation().getY(), getWorldZ(hz))); + double dx = i.worldX() - getWorldX(hx); + double dz = i.worldZ() - getWorldZ(hz); + double d = dx * dx + dz * dz; if (d < dist) { dist = d; nearest = i; @@ -665,22 +674,24 @@ public class VisionGUI extends JPanel implements MouseWheelListener, KeyListener } if (nearest != null && shift) { - double sx = getScreenX(nearest.getLocation().getX()); - double sz = getScreenZ(nearest.getLocation().getZ()); + double sx = getScreenX(nearest.worldX()); + double sz = getScreenZ(nearest.worldZ()); g.setColor(MOB_COLOR); g.fillOval((int) sx - 6, (int) sz - 6, 12, 12); g.setColor(new Color(220, 80, 80, 60)); g.fillOval((int) sx - 10, (int) sz - 10, 20, 20); KList k = new KList<>(); - k.add(Form.capitalizeWords(nearest.getType().name().toLowerCase(Locale.ROOT).replaceAll("\\Q_\\E", " "))); - k.add("Pos: " + nearest.getLocation().getBlockX() + ", " + nearest.getLocation().getBlockY() + ", " + nearest.getLocation().getBlockZ()); - k.add("HP: " + Form.f(nearest.getHealth(), 1) + " / " + Form.f(nearest.getAttribute(MAX_HEALTH).getValue(), 1)); + k.add(nearest.label()); + k.add("Pos: " + (int) nearest.worldX() + ", " + (int) nearest.worldY() + ", " + (int) nearest.worldZ()); + if (nearest.maxHealth() > 0) { + k.add("HP: " + Form.f(nearest.health(), 1) + " / " + Form.f(nearest.maxHealth(), 1)); + } drawCard(w - CARD_PAD, CARD_PAD, 1, 0, g, k); } } - player = b; + followMarker = firstPlayer; } private void renderPlayerMarker(Graphics2D g, double x, double z, String name) { @@ -884,32 +895,23 @@ public class VisionGUI extends JPanel implements MouseWheelListener, KeyListener @Override public void mouseExited(MouseEvent e) { } private void open() { - IrisComplex complex = engine.getComplex(); - File r = null; - switch (currentType) { - case BIOME, LAYER_LOAD, DECORATOR_LOAD, OBJECT_LOAD, HEIGHT -> - r = complex.getTrueBiomeStream().get(getWorldX(hx), getWorldZ(hz)).openInVSCode(); - case BIOME_LAND -> r = complex.getLandBiomeStream().get(getWorldX(hx), getWorldZ(hz)).openInVSCode(); - case BIOME_SEA -> r = complex.getSeaBiomeStream().get(getWorldX(hx), getWorldZ(hz)).openInVSCode(); - case REGION -> r = complex.getRegionStream().get(getWorldX(hx), getWorldZ(hz)).openInVSCode(); - case CAVE_LAND -> r = complex.getCaveBiomeStream().get(getWorldX(hx), getWorldZ(hz)).openInVSCode(); + if (overlay == null) { + return; } - if (r != null) { - notify("Opened " + r.getName()); + String opened = overlay.openInEditor(getWorldX(hx), getWorldZ(hz), currentType); + if (opened != null) { + notify("Opened " + opened); } } private void teleport() { - J.s(() -> { - if (player != null) { - int xx = (int) getWorldX(hx); - int zz = (int) getWorldZ(hz); - int yy = player.getWorld().getHighestBlockYAt(xx, zz) + 1; - player.teleport(new Location(player.getWorld(), xx, yy, zz)); - notify("Teleported to " + xx + ", " + yy + ", " + zz); - } else { - notify("No player in world"); - } - }); + if (overlay == null) { + notify("No player in world"); + return; + } + int xx = (int) getWorldX(hx); + int zz = (int) getWorldZ(hz); + overlay.teleport(xx, zz); + notify("Teleporting to " + xx + ", " + zz); } } 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 84b05e282..7c5992a03 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 @@ -27,9 +27,7 @@ import art.arcane.iris.core.nms.datapack.DataVersion; import art.arcane.iris.engine.data.chunk.TerrainChunk; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.platform.PlatformChunkGenerator; -import art.arcane.iris.spi.PlatformBiome; import art.arcane.iris.spi.PlatformBlockState; -import art.arcane.iris.util.nbt.common.mca.NBTWorld; import art.arcane.iris.util.project.hunk.Hunk; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KMap; @@ -182,10 +180,6 @@ public interface INMSBinding { return false; } - default boolean writeChunkNbtDirect(NBTWorld nbtWorld, int chunkX, int chunkZ, Hunk blocks, Hunk biomes) { - return false; - } - default boolean forceEvictChunk(World world, int chunkX, int chunkZ) { return false; } diff --git a/core/src/main/java/art/arcane/iris/core/nms/datapack/v1206/DataFixerV1206.java b/core/src/main/java/art/arcane/iris/core/nms/datapack/v1206/DataFixerV1206.java index 640a1afff..bbd2e6ce2 100644 --- a/core/src/main/java/art/arcane/iris/core/nms/datapack/v1206/DataFixerV1206.java +++ b/core/src/main/java/art/arcane/iris/core/nms/datapack/v1206/DataFixerV1206.java @@ -5,11 +5,10 @@ import art.arcane.iris.core.nms.datapack.v1192.DataFixerV1192; import art.arcane.iris.engine.object.IrisBiomeCustom; import art.arcane.iris.engine.object.IrisBiomeCustomSpawn; import art.arcane.iris.engine.object.IrisBiomeCustomSpawnType; +import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.json.JSONArray; import art.arcane.volmlib.util.json.JSONObject; -import org.bukkit.NamespacedKey; -import org.bukkit.entity.EntityType; import java.util.Locale; @@ -23,7 +22,7 @@ public class DataFixerV1206 extends DataFixerV1192 { json.remove("creature_spawn_probability"); } - var spawns = biome.getSpawns(); + KList spawns = biome.getSpawns(); if (spawns != null && spawns.isNotEmpty()) { JSONObject spawners = new JSONObject(); KMap groups = new KMap<>(); @@ -32,20 +31,15 @@ public class DataFixerV1206 extends DataFixerV1192 { if (i == null) { continue; } - EntityType type = i.getType(); - if (type == null) { + String key = i.getTypeKey(); + if (key == null) { IrisLogging.warn("Skipping custom biome spawn with null entity type in biome " + biome.getId()); continue; } IrisBiomeCustomSpawnType group = i.getGroup() == null ? IrisBiomeCustomSpawnType.MISC : i.getGroup(); JSONArray g = groups.computeIfAbsent(group, (k) -> new JSONArray()); JSONObject o = new JSONObject(); - NamespacedKey key = type.getKey(); - if (key == null) { - IrisLogging.warn("Skipping custom biome spawn with unresolved entity key in biome " + biome.getId()); - continue; - } - o.put("type", key.toString()); + o.put("type", key); o.put("weight", i.getWeight()); o.put("minCount", i.getMinCount()); o.put("maxCount", i.getMaxCount()); 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 347664496..b6301fd44 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 @@ -18,13 +18,10 @@ package art.arcane.iris.core.pregenerator.methods; -import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.pregenerator.PregenListener; import art.arcane.iris.core.pregenerator.PregeneratorMethod; -import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.volmlib.util.mantle.runtime.Mantle; import io.papermc.lib.PaperLib; -import org.bukkit.Bukkit; import org.bukkit.World; public class AsyncOrMedievalPregenMethod implements PregeneratorMethod { @@ -33,36 +30,11 @@ public class AsyncOrMedievalPregenMethod implements PregeneratorMethod { public AsyncOrMedievalPregenMethod(World world, int threads) { if (PaperLib.isPaper()) { method = new AsyncPregenMethod(world, threads); - } else if (shouldUseSpigotDirect(world)) { - method = new SpigotDirectPregenMethod(world, threads); } else { method = new MedievalPregenMethod(world); } } - private static boolean shouldUseSpigotDirect(World world) { - if (!IrisSettings.get().getPregen().isEnableSpigotDirectPregen()) { - return false; - } - if (IrisToolbelt.isIrisStudioWorld(world)) { - return false; - } - if (!world.isAutoSave()) { - return false; - } - try { - String name = Bukkit.getServer().getName(); - if (name != null) { - String lower = name.toLowerCase(); - if (lower.contains("spigot") || lower.contains("craftbukkit")) { - return true; - } - } - } catch (Throwable ignored) { - } - return false; - } - @Override public void init() { method.init(); diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/SpigotDirectPregenMethod.java b/core/src/main/java/art/arcane/iris/core/pregenerator/methods/SpigotDirectPregenMethod.java deleted file mode 100644 index 2d0073f50..000000000 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/SpigotDirectPregenMethod.java +++ /dev/null @@ -1,586 +0,0 @@ -/* - * 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.methods; - -import art.arcane.iris.core.IrisSettings; -import art.arcane.iris.core.nms.INMS; -import art.arcane.iris.core.pregenerator.PregenListener; -import art.arcane.iris.core.pregenerator.PregeneratorMethod; -import art.arcane.iris.core.tools.IrisToolbelt; -import art.arcane.iris.engine.framework.Engine; -import art.arcane.iris.spi.IrisLogging; -import art.arcane.iris.spi.PlatformBiome; -import art.arcane.iris.spi.PlatformBlockState; -import art.arcane.iris.spi.IrisPlatforms; -import art.arcane.iris.util.common.parallel.MultiBurst; -import art.arcane.iris.util.common.scheduling.J; -import art.arcane.iris.util.nbt.common.mca.NBTWorld; -import art.arcane.iris.util.project.hunk.Hunk; -import art.arcane.iris.util.project.hunk.storage.AtomicHunk; -import art.arcane.volmlib.util.mantle.runtime.Mantle; -import art.arcane.volmlib.util.math.M; -import org.bukkit.Chunk; -import org.bukkit.World; - -import java.util.HashSet; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -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; - -public class SpigotDirectPregenMethod implements PregeneratorMethod { - private static final int ADAPTIVE_TIMEOUT_STEP = 3; - private static final int ADAPTIVE_RECOVERY_INTERVAL = 8; - private static final long PERMIT_WAIT_NOTIFY_MS = 500L; - - private final World world; - private final int threads; - private final int timeoutSeconds; - private final int timeoutWarnIntervalMs; - private final int maxResidentTectonicPlates; - private final int mantleBackpressureWaitMs; - private final int mantleBackpressureTimeoutMs; - - private final Semaphore semaphore; - private final AtomicInteger adaptiveInFlightLimit; - private final int adaptiveMinInFlightLimit; - private final AtomicInteger timeoutStreak = new AtomicInteger(); - private final AtomicLong lastTimeoutLogAt = new AtomicLong(0L); - private final AtomicInteger suppressedTimeoutLogs = new AtomicInteger(); - private final AtomicLong lastAdaptiveLogAt = new AtomicLong(0L); - private final AtomicInteger inFlight = new AtomicInteger(); - private final AtomicLong submitted = new AtomicLong(); - private final AtomicLong completed = new AtomicLong(); - private final AtomicLong failed = new AtomicLong(); - private final AtomicLong lastProgressAt = new AtomicLong(M.ms()); - private final Object permitMonitor = new Object(); - - private final Set liveLoadedChunkKeys; - private final ConcurrentHashMap chunksWrittenDirect; - private final AtomicBoolean writePathDisabled; - private volatile Engine cachedEngine; - private volatile Mantle cachedMantle; - private volatile NBTWorld nbtWorld; - private volatile MedievalPregenMethod fallback; - - public SpigotDirectPregenMethod(World world, int threads) { - this.world = world; - int configured = Math.max(1, threads); - this.threads = Math.max(8, Math.min(32, configured)); - this.semaphore = new Semaphore(this.threads, true); - this.adaptiveInFlightLimit = new AtomicInteger(this.threads); - this.adaptiveMinInFlightLimit = Math.max(4, Math.min(16, Math.max(1, this.threads / 4))); - - IrisSettings.IrisSettingsPregen pregen = IrisSettings.get().getPregen(); - this.timeoutSeconds = pregen.getChunkLoadTimeoutSeconds(); - this.timeoutWarnIntervalMs = pregen.getTimeoutWarnIntervalMs(); - this.maxResidentTectonicPlates = pregen.getMaxResidentTectonicPlates(); - this.mantleBackpressureWaitMs = pregen.getMantleBackpressureWaitMs(); - this.mantleBackpressureTimeoutMs = pregen.getMantleBackpressureTimeoutMs(); - - this.liveLoadedChunkKeys = ConcurrentHashMap.newKeySet(); - this.chunksWrittenDirect = new ConcurrentHashMap<>(); - this.writePathDisabled = new AtomicBoolean(false); - } - - @Override - public void init() { - try { - this.nbtWorld = new NBTWorld(world.getWorldFolder()); - } catch (Throwable e) { - IrisLogging.error("SpigotDirect pregen could not open NBTWorld for " + world.getName() + "; disabling direct write path."); - IrisLogging.reportError(e); - writePathDisabled.set(true); - } - - snapshotLoadedChunks(); - - IrisLogging.info("SpigotDirect pregen init: world=" + world.getName() - + ", threads=" + threads - + ", adaptiveLimit=" + adaptiveInFlightLimit.get() - + ", initialLoadedChunks=" + liveLoadedChunkKeys.size() - + ", maxResidentTectonicPlates=" + maxResidentTectonicPlates - + ", timeout=" + timeoutSeconds + "s"); - } - - @Override - public void close() { - semaphore.acquireUninterruptibly(threads); - if (nbtWorld != null) { - try { - nbtWorld.flushNow(); - nbtWorld.close(); - } catch (Throwable e) { - IrisLogging.reportError(e); - } - } - evictWrittenChunksFromServer(); - if (fallback != null) { - try { - fallback.close(); - } catch (Throwable e) { - IrisLogging.reportError(e); - } - } - } - - @Override - public void save() { - if (nbtWorld != null) { - try { - nbtWorld.save(); - } catch (Throwable e) { - IrisLogging.reportError(e); - } - } - if (fallback != null) { - try { - fallback.save(); - } catch (Throwable e) { - IrisLogging.reportError(e); - } - } - } - - @Override - public boolean supportsRegions(int x, int z, PregenListener listener) { - return false; - } - - @Override - public void generateRegion(int x, int z, PregenListener listener) { - throw new UnsupportedOperationException(); - } - - @Override - public String getMethod(int x, int z) { - return "SpigotDirect"; - } - - @Override - public Mantle getMantle() { - if (IrisToolbelt.isIrisWorld(world)) { - return IrisToolbelt.access(world).getEngine().getMantle().getMantle(); - } - return null; - } - - @Override - public void generateChunk(int x, int z, PregenListener listener) { - listener.onChunkGenerating(x, z); - enforceMantleBudget(); - - long key = chunkKey(x, z); - if (writePathDisabled.get() || nbtWorld == null || liveLoadedChunkKeys.contains(key) || world.isChunkLoaded(x, z)) { - ensureFallback().generateChunk(x, z, listener); - return; - } - - Engine engine = resolveEngine(); - if (engine == null || !engine.getDimension().isUseMantle()) { - ensureFallback().generateChunk(x, z, listener); - return; - } - - try { - synchronized (permitMonitor) { - while (inFlight.get() >= adaptiveInFlightLimit.get()) { - permitMonitor.wait(PERMIT_WAIT_NOTIFY_MS); - } - } - while (!semaphore.tryAcquire(1, TimeUnit.SECONDS)) { - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return; - } - - markSubmitted(); - MultiBurst.burst.lazy(() -> runDirect(engine, x, z, listener)); - } - - private void runDirect(Engine engine, int x, int z, PregenListener listener) { - boolean success = false; - try { - int worldMinY = world.getMinHeight(); - int worldMaxY = world.getMaxHeight(); - int height = worldMaxY - worldMinY; - - PlatformBlockState air = IrisPlatforms.get().registries().air(); - Hunk blocks = new AirDefaultAtomicHunk(16, height, 16, air); - Hunk biomes = Hunk.newSynchronizedArrayHunk(16, height, 16); - - try { - engine.generate(x << 4, z << 4, blocks, biomes, false); - } catch (Throwable e) { - handleFailure(x, z, e); - return; - } - - boolean wrote; - try { - wrote = INMS.get().writeChunkNbtDirect(nbtWorld, x, z, blocks, biomes); - } catch (Throwable e) { - handleFailure(x, z, e); - return; - } - - if (!wrote) { - if (writePathDisabled.compareAndSet(false, true)) { - IrisLogging.warn("SpigotDirect NBT write returned false at chunk " + x + "," + z - + "; disabling direct path. Subsequent chunks will route through MedievalPregenMethod on the iterator thread."); - } - listener.onChunkGenerated(x, z); - listener.onChunkCleaned(x, z); - success = true; - return; - } - - chunksWrittenDirect.put(chunkKey(x, z), Boolean.TRUE); - - try { - cleanupMantleChunk(x, z); - } catch (Throwable ignored) { - } - - listener.onChunkGenerated(x, z); - listener.onChunkCleaned(x, z); - success = true; - } catch (Throwable e) { - handleFailure(x, z, e); - } finally { - markFinished(success); - semaphore.release(); - } - } - - private void handleFailure(int x, int z, Throwable error) { - IrisLogging.warn("SpigotDirect pregen failure at chunk " + x + "," + z + ". " + metricsSnapshot()); - IrisLogging.reportError(error); - if (error != null) { - error.printStackTrace(System.err); - } - if (writePathDisabled.compareAndSet(false, true)) { - IrisLogging.warn("SpigotDirect: disabling direct write path after first failure. Subsequent chunks route through MedievalPregenMethod on the iterator thread."); - } - onTimeout(x, z); - } - - private MedievalPregenMethod ensureFallback() { - MedievalPregenMethod existing = fallback; - if (existing != null) { - return existing; - } - synchronized (this) { - existing = fallback; - if (existing == null) { - existing = new MedievalPregenMethod(world); - existing.init(); - fallback = existing; - } - } - return existing; - } - - private void snapshotLoadedChunks() { - try { - Set loaded = J.sfut(() -> { - Set keys = new HashSet<>(); - if (world == null) { - return keys; - } - for (Chunk loadedChunk : world.getLoadedChunks()) { - keys.add(chunkKey(loadedChunk.getX(), loadedChunk.getZ())); - } - return keys; - }).get(); - if (loaded != null) { - liveLoadedChunkKeys.addAll(loaded); - } - } catch (Throwable e) { - IrisLogging.reportError(e); - } - } - - private void evictWrittenChunksFromServer() { - if (chunksWrittenDirect.isEmpty()) { - return; - } - try { - J.sfut(() -> { - int evicted = 0; - for (Long key : chunksWrittenDirect.keySet()) { - int x = keyX(key); - int z = keyZ(key); - if (!world.isChunkLoaded(x, z)) { - continue; - } - if (INMS.get().forceEvictChunk(world, x, z)) { - evicted++; - } - } - if (evicted > 0) { - IrisLogging.info("SpigotDirect: force-evicted " + evicted + " chunks loaded mid-pregen so the server reloads them from disk."); - } - }).get(); - } catch (Throwable e) { - IrisLogging.reportError(e); - } - } - - private static long chunkKey(int x, int z) { - return (((long) x) << 32) | (z & 0xffffffffL); - } - - private static int keyX(long key) { - return (int) (key >> 32); - } - - private static int keyZ(long key) { - return (int) (key & 0xffffffffL); - } - - private void onTimeout(int x, int z) { - int streak = timeoutStreak.incrementAndGet(); - if (streak % ADAPTIVE_TIMEOUT_STEP == 0) { - lowerAdaptiveInFlightLimit(); - } - - long now = M.ms(); - long last = lastTimeoutLogAt.get(); - if (now - last < timeoutWarnIntervalMs || !lastTimeoutLogAt.compareAndSet(last, now)) { - suppressedTimeoutLogs.incrementAndGet(); - return; - } - - int suppressed = suppressedTimeoutLogs.getAndSet(0); - String suppressedText = suppressed <= 0 ? "" : " suppressed=" + suppressed; - IrisLogging.warn("SpigotDirect pregen failure cluster at " + x + "," + z - + " adaptiveLimit=" + adaptiveInFlightLimit.get() - + suppressedText + " " + metricsSnapshot()); - } - - private void onSuccess() { - int streak = timeoutStreak.get(); - if (streak > 0) { - int newStreak = Math.max(0, streak - 2); - timeoutStreak.compareAndSet(streak, newStreak); - if (newStreak > 0) { - return; - } - } - - if ((completed.get() & (ADAPTIVE_RECOVERY_INTERVAL - 1)) == 0L) { - raiseAdaptiveInFlightLimit(); - } - } - - private void lowerAdaptiveInFlightLimit() { - while (true) { - int current = adaptiveInFlightLimit.get(); - if (current <= adaptiveMinInFlightLimit) { - return; - } - int next = Math.max(adaptiveMinInFlightLimit, current - 1); - if (adaptiveInFlightLimit.compareAndSet(current, next)) { - logAdaptiveLimit("decrease", next); - notifyPermitWaiters(); - return; - } - } - } - - private void raiseAdaptiveInFlightLimit() { - while (true) { - int current = adaptiveInFlightLimit.get(); - if (current >= threads) { - return; - } - int deficit = threads - current; - int step = deficit > (threads / 2) ? Math.max(2, threads / 8) : 1; - int next = Math.min(threads, current + step); - if (adaptiveInFlightLimit.compareAndSet(current, next)) { - logAdaptiveLimit("increase", next); - notifyPermitWaiters(); - return; - } - } - } - - private void logAdaptiveLimit(String mode, int value) { - long now = M.ms(); - long last = lastAdaptiveLogAt.get(); - if (now - last < 5000L) { - return; - } - if (lastAdaptiveLogAt.compareAndSet(last, now)) { - IrisLogging.info("SpigotDirect pregen adaptive limit " + mode + " -> " + value + " (" + metricsSnapshot() + ")"); - } - } - - private String metricsSnapshot() { - long stalledFor = Math.max(0L, M.ms() - lastProgressAt.get()); - return "world=" + world.getName() - + " permits=" + semaphore.availablePermits() + "/" + threads - + " adaptiveLimit=" + adaptiveInFlightLimit.get() - + " inFlight=" + inFlight.get() - + " submitted=" + submitted.get() - + " completed=" + completed.get() - + " failed=" + failed.get() - + " stalledForMs=" + stalledFor; - } - - private void markSubmitted() { - submitted.incrementAndGet(); - inFlight.incrementAndGet(); - } - - private void markFinished(boolean success) { - if (success) { - completed.incrementAndGet(); - onSuccess(); - } else { - failed.incrementAndGet(); - } - - lastProgressAt.set(M.ms()); - int after = inFlight.decrementAndGet(); - if (after < 0) { - inFlight.compareAndSet(after, 0); - } - notifyPermitWaiters(); - } - - private void notifyPermitWaiters() { - synchronized (permitMonitor) { - permitMonitor.notifyAll(); - } - } - - private void cleanupMantleChunk(int x, int z) { - Engine engine = resolveEngine(); - if (engine != null) { - try { - engine.getMantle().forceCleanupChunk(x, z); - } catch (Throwable ignored) { - } - } - } - - private Engine resolveEngine() { - Engine cached = cachedEngine; - if (cached != null) { - return cached; - } - - if (!IrisToolbelt.isIrisWorld(world)) { - return null; - } - - try { - Engine resolved = IrisToolbelt.access(world).getEngine(); - if (resolved != null) { - cachedEngine = resolved; - } - return resolved; - } catch (Throwable ignored) { - return null; - } - } - - private Mantle resolveMantle() { - Mantle cached = cachedMantle; - if (cached != null) { - return cached; - } - Mantle resolved = getMantle(); - if (resolved != null) { - cachedMantle = resolved; - } - 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("SpigotDirect 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("SpigotDirect 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; - } - } - } - - private static final class AirDefaultAtomicHunk extends AtomicHunk { - private final PlatformBlockState airDefault; - - AirDefaultAtomicHunk(int w, int h, int d, PlatformBlockState airDefault) { - super(w, h, d); - this.airDefault = airDefault; - } - - @Override - public PlatformBlockState getRaw(int x, int y, int z) { - PlatformBlockState v = super.getRaw(x, y, z); - return v != null ? v : airDefault; - } - } -} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustom.java b/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustom.java index 6456c7396..9a3bac1f0 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustom.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustom.java @@ -29,8 +29,6 @@ import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import lombok.experimental.Accessors; -import org.bukkit.NamespacedKey; -import org.bukkit.entity.EntityType; import java.awt.*; import java.util.Locale; @@ -147,20 +145,15 @@ public class IrisBiomeCustom { if (i == null) { continue; } - EntityType type = i.getType(); - if (type == null) { + String key = i.getTypeKey(); + if (key == null) { IrisLogging.warn("Skipping custom biome spawn with null entity type in biome " + getId()); continue; } IrisBiomeCustomSpawnType group = i.getGroup() == null ? IrisBiomeCustomSpawnType.MISC : i.getGroup(); JSONArray g = groups.computeIfAbsent(group, (k) -> new JSONArray()); JSONObject o = new JSONObject(); - NamespacedKey key = type.getKey(); - if (key == null) { - IrisLogging.warn("Skipping custom biome spawn with unresolved entity key in biome " + getId()); - continue; - } - o.put("type", key.toString()); + o.put("type", key); o.put("weight", i.getWeight()); o.put("minCount", i.getMinCount()); o.put("maxCount", i.getMaxCount()); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustomSpawn.java b/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustomSpawn.java index 70e6aea0d..0f5e05fbc 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustomSpawn.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustomSpawn.java @@ -50,6 +50,13 @@ public class IrisBiomeCustomSpawn { }); } + public String getTypeKey() { + if (type == null || type.isBlank()) { + return null; + } + return type.contains(":") ? type : "minecraft:" + type; + } + @MinNumber(1) @Desc("The min to spawn") private int minCount = 2; 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 206d01d23..8a863215b 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 @@ -38,11 +38,14 @@ import art.arcane.volmlib.util.math.Vector3d; import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.Registry; import org.bukkit.World; 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; @@ -291,6 +294,35 @@ public final class BukkitPlatform implements IrisPlatform { Bukkit.getServer().dispatchCommand(Bukkit.getConsoleSender(), command); } + @Override + public boolean spawnEntity(Object world, String entityKey, double x, double y, double z) { + if (!(world instanceof World bukkitWorld) || entityKey == null) { + return false; + } + NamespacedKey namespacedKey = NamespacedKey.fromString(entityKey); + if (namespacedKey == null) { + return false; + } + EntityType type = Registry.ENTITY_TYPE.get(namespacedKey); + if (type == null) { + return false; + } + Entity entity = spawnEntity(new Location(bukkitWorld, x, y, z), type, CreatureSpawnEvent.SpawnReason.COMMAND); + 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); diff --git a/core/src/test/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethodConcurrencyCapTest.java b/core/src/test/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethodConcurrencyCapTest.java index 8dcca2a0b..5cb36e21d 100644 --- a/core/src/test/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethodConcurrencyCapTest.java +++ b/core/src/test/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethodConcurrencyCapTest.java @@ -8,8 +8,8 @@ public class AsyncPregenMethodConcurrencyCapTest { @Test public void paperLikeRecommendedCapTracksWorkerThreads() { assertEquals(16, AsyncPregenMethod.computePaperLikeRecommendedCap(1)); - assertEquals(16, AsyncPregenMethod.computePaperLikeRecommendedCap(4)); - assertEquals(48, AsyncPregenMethod.computePaperLikeRecommendedCap(12)); + assertEquals(32, AsyncPregenMethod.computePaperLikeRecommendedCap(4)); + assertEquals(96, AsyncPregenMethod.computePaperLikeRecommendedCap(12)); assertEquals(128, AsyncPregenMethod.computePaperLikeRecommendedCap(80)); assertEquals(128, AsyncPregenMethod.computePaperLikeRecommendedCap(128)); } @@ -17,8 +17,8 @@ public class AsyncPregenMethodConcurrencyCapTest { @Test public void foliaRecommendedCapTracksWorkerThreads() { assertEquals(64, AsyncPregenMethod.computeFoliaRecommendedCap(1)); - assertEquals(64, AsyncPregenMethod.computeFoliaRecommendedCap(12)); - assertEquals(80, AsyncPregenMethod.computeFoliaRecommendedCap(20)); + assertEquals(96, AsyncPregenMethod.computeFoliaRecommendedCap(12)); + assertEquals(160, AsyncPregenMethod.computeFoliaRecommendedCap(20)); assertEquals(192, AsyncPregenMethod.computeFoliaRecommendedCap(80)); } 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 7fa213c2b..cf6812b46 100644 --- a/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java +++ b/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java @@ -427,6 +427,16 @@ public final class StubPlatform implements IrisPlatform { public void dispatchConsoleCommand(String command) { } + @Override + public boolean spawnEntity(Object world, String entityKey, double x, double y, double z) { + 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/IrisPlatform.java b/spi/src/main/java/art/arcane/iris/spi/IrisPlatform.java index 06aa6ad8f..03c7ff089 100644 --- a/spi/src/main/java/art/arcane/iris/spi/IrisPlatform.java +++ b/spi/src/main/java/art/arcane/iris/spi/IrisPlatform.java @@ -70,6 +70,10 @@ public interface IrisPlatform { void dispatchConsoleCommand(String command); + 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);