diff --git a/adapters/fabric/build.gradle b/adapters/fabric/build.gradle index 42ed74232..42f61703b 100644 --- a/adapters/fabric/build.gradle +++ b/adapters/fabric/build.gradle @@ -90,13 +90,25 @@ configurations.named('bundle').configure { configurations.compileClasspath.extendsFrom(configurations.devBundle) configurations.runtimeClasspath.extendsFrom(configurations.devBundle) +configurations.create('jij') { + transitive = true +} + dependencies { minecraft("com.mojang:minecraft:${minecraftVersion}") implementation("net.fabricmc:fabric-loader:${fabricLoaderVersion}") + jij('net.fabricmc.fabric-api:fabric-api-base:2.0.3+ece063234c') + jij('net.fabricmc.fabric-api:fabric-registry-sync-v0:7.1.0+2fa62b4e4c') + jij('net.fabricmc.fabric-api:fabric-resource-loader-v1:2.0.10+7c44c7324c') + jij('net.fabricmc.fabric-api:fabric-lifecycle-events-v1:4.1.0+6d50a0854c') + jij('net.fabricmc.fabric-api:fabric-command-api-v2:3.0.5+e2bdee784c') + jij('net.fabricmc.fabric-api:fabric-events-interaction-v0:5.2.2+07b380be4c') implementation('net.fabricmc.fabric-api:fabric-api-base:2.0.3+ece063234c') implementation('net.fabricmc.fabric-api:fabric-registry-sync-v0:7.1.0+2fa62b4e4c') implementation('net.fabricmc.fabric-api:fabric-resource-loader-v1:2.0.10+7c44c7324c') implementation('net.fabricmc.fabric-api:fabric-lifecycle-events-v1:4.1.0+6d50a0854c') + implementation('net.fabricmc.fabric-api:fabric-command-api-v2:3.0.5+e2bdee784c') + implementation('net.fabricmc.fabric-api:fabric-events-interaction-v0:5.2.2+07b380be4c') compileOnly('org.slf4j:slf4j-api:2.0.17') compileOnly(libs.spigot) { transitive = false @@ -174,6 +186,12 @@ tasks.named('shadowJar', ShadowJar).configure { exclude('META-INF/*.DSA') exclude('META-INF/*.RSA') duplicatesStrategy = DuplicatesStrategy.EXCLUDE + relocate('org.objectweb.asm', 'art.arcane.iris.shadow.asm') + exclude('oshi.properties') + from(project.configurations.named('jij')) { + into('META-INF/jars') + rename { String fileName -> fileName.replaceAll(/-[0-9][^-]*\.jar$/, '.jar') } + } } tasks.named('assemble').configure { diff --git a/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricModdedLoader.java b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricModdedLoader.java index 13fb2a285..930a0f8c1 100644 --- a/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricModdedLoader.java +++ b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricModdedLoader.java @@ -39,6 +39,13 @@ public final class FabricModdedLoader implements ModdedLoader { .orElse("unknown"); } + @Override + public String modVersion() { + return FabricLoader.getInstance().getModContainer("irisworldgen") + .map((ModContainer container) -> container.getMetadata().getVersion().getFriendlyString()) + .orElse("unknown"); + } + @Override public MinecraftServer currentServer() { Object instance = FabricLoader.getInstance().getGameInstance(); 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 79c28c7a7..293e36ae4 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 @@ -23,14 +23,32 @@ import art.arcane.iris.modded.ModdedEngineBootstrap; import art.arcane.iris.modded.ModdedParityProbe; import art.arcane.iris.modded.ModdedWorldCheck; import art.arcane.iris.modded.ModdedWorldEngines; +import art.arcane.iris.modded.command.IrisModdedCommands; +import art.arcane.iris.modded.command.ModdedObjectUndo; +import art.arcane.iris.modded.command.ModdedWandService; +import com.mojang.brigadier.CommandDispatcher; import net.fabricmc.api.ModInitializer; +import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; +import net.fabricmc.fabric.api.event.player.AttackBlockCallback; +import net.fabricmc.fabric.api.event.player.UseBlockCallback; import net.fabricmc.loader.api.FabricLoader; import net.fabricmc.loader.api.ModContainer; +import net.minecraft.commands.CommandBuildContext; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; import net.minecraft.core.Registry; import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.resources.Identifier; import net.minecraft.server.MinecraftServer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.BlockHitResult; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -40,6 +58,7 @@ public final class IrisFabricBootstrap implements ModInitializer { @Override public void onInitialize() { ModdedEngineBootstrap.initialize(new FabricModdedLoader()); + art.arcane.iris.modded.ModdedPackInstaller.ensureDefaultPack(ModdedEngineBootstrap.loader().configDir()); FabricLoader loader = FabricLoader.getInstance(); String modVersion = loader.getModContainer("irisworldgen") .map((ModContainer container) -> container.getMetadata().getVersion().getFriendlyString()) @@ -53,7 +72,17 @@ public final class IrisFabricBootstrap implements ModInitializer { ModdedEngineBootstrap.bind(); Registry.register(BuiltInRegistries.CHUNK_GENERATOR, Identifier.fromNamespaceAndPath("irisworldgen", "iris"), IrisModdedChunkGenerator.CODEC); LOGGER.info("Iris chunk generator registered as irisworldgen:iris"); - ServerLifecycleEvents.SERVER_STOPPING.register((MinecraftServer server) -> ModdedWorldEngines.shutdown()); + ServerLifecycleEvents.SERVER_STOPPING.register((MinecraftServer server) -> { + ModdedObjectUndo.clearAll(); + ModdedWandService.clearAll(); + ModdedWorldEngines.shutdown(); + }); + 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)); String parity = System.getProperty("iris.parity"); if (parity != null) { diff --git a/adapters/fabric/src/main/resources/fabric.mod.json b/adapters/fabric/src/main/resources/fabric.mod.json index 4738cf87c..3c272e961 100644 --- a/adapters/fabric/src/main/resources/fabric.mod.json +++ b/adapters/fabric/src/main/resources/fabric.mod.json @@ -13,11 +13,17 @@ "entrypoints": { "main": ["art.arcane.iris.fabric.IrisFabricBootstrap"] }, + "jars": [ + { "file": "META-INF/jars/fabric-api-base.jar" }, + { "file": "META-INF/jars/fabric-registry-sync-v0.jar" }, + { "file": "META-INF/jars/fabric-resource-loader-v1.jar" }, + { "file": "META-INF/jars/fabric-lifecycle-events-v1.jar" }, + { "file": "META-INF/jars/fabric-networking-api-v1.jar" }, + { "file": "META-INF/jars/fabric-command-api-v2.jar" }, + { "file": "META-INF/jars/fabric-events-interaction-v0.jar" } + ], "depends": { "fabricloader": ">=0.19.0", - "fabric-registry-sync-v0": "*", - "fabric-resource-loader-v1": "*", - "fabric-lifecycle-events-v1": "*", "minecraft": "~${minecraftVersion}", "java": ">=25" } diff --git a/adapters/forge/build.gradle b/adapters/forge/build.gradle index bcc25d8aa..afa31030a 100644 --- a/adapters/forge/build.gradle +++ b/adapters/forge/build.gradle @@ -208,6 +208,8 @@ tasks.named('shadowJar', ShadowJar).configure { exclude('META-INF/*.RSA') exclude('org/apache/commons/lang/enum/**') duplicatesStrategy = DuplicatesStrategy.EXCLUDE + relocate('org.objectweb.asm', 'art.arcane.iris.shadow.asm') + exclude('oshi.properties') } tasks.named('assemble').configure { diff --git a/adapters/forge/src/main/java/art/arcane/iris/forge/ForgeModdedLoader.java b/adapters/forge/src/main/java/art/arcane/iris/forge/ForgeModdedLoader.java index f17279e69..271b43843 100644 --- a/adapters/forge/src/main/java/art/arcane/iris/forge/ForgeModdedLoader.java +++ b/adapters/forge/src/main/java/art/arcane/iris/forge/ForgeModdedLoader.java @@ -40,6 +40,13 @@ public final class ForgeModdedLoader implements ModdedLoader { return FMLLoader.versionInfo().mcVersion(); } + @Override + public String modVersion() { + return ModList.getModContainerById("irisworldgen") + .map((net.minecraftforge.fml.ModContainer container) -> container.getModInfo().getVersion().toString()) + .orElse("unknown"); + } + @Override public MinecraftServer currentServer() { return ServerLifecycleHooks.getCurrentServer(); 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 ce29c3957..963647b53 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 @@ -23,9 +23,15 @@ import art.arcane.iris.modded.ModdedEngineBootstrap; import art.arcane.iris.modded.ModdedParityProbe; import art.arcane.iris.modded.ModdedWorldCheck; import art.arcane.iris.modded.ModdedWorldEngines; +import art.arcane.iris.modded.command.IrisModdedCommands; +import art.arcane.iris.modded.command.ModdedObjectUndo; +import art.arcane.iris.modded.command.ModdedWandService; import com.mojang.serialization.MapCodec; import net.minecraft.core.registries.Registries; import net.minecraft.world.level.chunk.ChunkGenerator; +import net.minecraftforge.event.RegisterCommandsEvent; +import net.minecraftforge.event.TickEvent; +import net.minecraftforge.event.entity.player.PlayerInteractEvent; import net.minecraftforge.event.server.ServerStoppingEvent; import net.minecraftforge.fml.ModContainer; import net.minecraftforge.fml.ModList; @@ -34,6 +40,8 @@ import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext; import net.minecraftforge.fml.loading.FMLLoader; import net.minecraftforge.fml.loading.VersionInfo; import net.minecraftforge.registries.DeferredRegister; + +import java.util.function.Predicate; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -43,6 +51,7 @@ public final class IrisForgeBootstrap { public IrisForgeBootstrap(FMLJavaModLoadingContext context) { ModdedEngineBootstrap.initialize(new ForgeModdedLoader()); + art.arcane.iris.modded.ModdedPackInstaller.ensureDefaultPack(ModdedEngineBootstrap.loader().configDir()); String modVersion = ModList.getModContainerById("irisworldgen") .map((ModContainer container) -> container.getModInfo().getVersion().toString()) .orElse("unknown"); @@ -57,7 +66,17 @@ public final class IrisForgeBootstrap { chunkGenerators.register(context.getModBusGroup()); LOGGER.info("Iris chunk generator registered as irisworldgen:iris"); - ServerStoppingEvent.BUS.addListener((ServerStoppingEvent event) -> ModdedWorldEngines.shutdown()); + ServerStoppingEvent.BUS.addListener((ServerStoppingEvent event) -> { + ModdedObjectUndo.clearAll(); + ModdedWandService.clearAll(); + ModdedWorldEngines.shutdown(); + }); + 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())); String parity = System.getProperty("iris.parity"); if (parity != null) { 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 c6e6da0c4..e88bc42e3 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 @@ -134,6 +134,18 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { } } + public String dimensionKey() { + return dimensionKey; + } + + public Engine engineIfBound() { + return engine; + } + + public Engine commandEngine() { + return engine(); + } + @Override public CompletableFuture fillFromNoise(Blender blender, RandomState randomState, StructureManager structureManager, ChunkAccess chunk) { Engine generationEngine = engine(); @@ -192,6 +204,13 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { return raced != null ? raced : resolved; } + public BiomeResolver regenBiomeResolver(Registry registry, Hunk biomes, ChunkPos pos) { + Engine current = engine(); + int dimMinY = current.getMinHeight(); + int height = current.getMaxHeight() - dimMinY; + return new HunkBiomeResolver(this, biomes, registry, pos, dimMinY, height); + } + private void writeBlocks(ChunkAccess chunk, ModdedBlockBuffer blocks, int dimMinY, int height) { int chunkMinY = chunk.getMinY(); int chunkMaxY = chunkMinY + chunk.getHeight(); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedLoader.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedLoader.java index 109b9a0bb..bae6ae66c 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedLoader.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedLoader.java @@ -28,6 +28,8 @@ public interface ModdedLoader { String minecraftVersion(); + String modVersion(); + MinecraftServer currentServer(); Path configDir(); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPackInstaller.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPackInstaller.java new file mode 100644 index 000000000..a8ae8e2d1 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPackInstaller.java @@ -0,0 +1,159 @@ +/* + * 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 org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.zeroturnaround.zip.ZipUtil; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Duration; +import java.util.Comparator; +import java.util.List; +import java.util.function.Consumer; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +public final class ModdedPackInstaller { + private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + private static final String DEFAULT_PACK = "overworld"; + private static final String DEFAULT_BRANCH = "master"; + private static final Pattern PACK_NAME = Pattern.compile("[a-z0-9_-]+"); + private static final Pattern BRANCH_NAME = Pattern.compile("[A-Za-z0-9._-]+"); + + private ModdedPackInstaller() { + } + + public static void ensureDefaultPack(Path configDir) { + Path target = configDir.resolve("irisworldgen").resolve("packs").resolve(DEFAULT_PACK); + + if (Files.isDirectory(target.resolve("dimensions"))) { + return; + } + + LOGGER.info("Iris default pack missing; downloading IrisDimensions/{} (latest on {})", DEFAULT_PACK, DEFAULT_BRANCH); + boolean installed = install(configDir, DEFAULT_PACK, DEFAULT_BRANCH, LOGGER::info); + + if (!installed) { + LOGGER.error("Iris failed to download the default '{}' pack; install it manually at {} (source: https://github.com/IrisDimensions/overworld)", DEFAULT_PACK, target); + } + } + + public static boolean install(Path configDir, String pack, String branch, Consumer feedback) { + if (pack == null || !PACK_NAME.matcher(pack).matches()) { + feedback.accept("Invalid pack name '" + pack + "' (allowed: a-z, 0-9, _ and -)"); + return false; + } + if (branch == null || !BRANCH_NAME.matcher(branch).matches()) { + feedback.accept("Invalid branch name '" + branch + "' (allowed: letters, digits, . _ and -)"); + return false; + } + + Path target = configDir.resolve("irisworldgen").resolve("packs").resolve(pack); + String url = "https://codeload.github.com/IrisDimensions/" + pack + "/zip/refs/heads/" + branch; + + try { + Path work = Files.createTempDirectory("iris-pack-dl"); + Path zip = work.resolve(pack + ".zip"); + HttpClient client = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .connectTimeout(Duration.ofSeconds(30)) + .build(); + HttpRequest request = HttpRequest.newBuilder(URI.create(url)) + .timeout(Duration.ofMinutes(5)) + .GET() + .build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofInputStream()); + + if (response.statusCode() != 200) { + feedback.accept("Pack download failed: HTTP " + response.statusCode() + " from " + url); + return false; + } + + try (InputStream in = response.body()) { + Files.copy(in, zip, StandardCopyOption.REPLACE_EXISTING); + } + + Path extracted = work.resolve("extracted"); + ZipUtil.unpack(zip.toFile(), extracted.toFile()); + Path root = singleRoot(extracted); + Files.createDirectories(target.getParent()); + deleteRecursively(target); + copyRecursively(root, target); + deleteRecursively(work); + feedback.accept("Iris installed pack '" + pack + "' (branch " + branch + ") into " + target); + return true; + } catch (IOException | InterruptedException error) { + LOGGER.error("Iris pack download failed for IrisDimensions/{} ({})", pack, branch, error); + feedback.accept("Pack download failed: " + error.getClass().getSimpleName() + (error.getMessage() == null ? "" : " - " + error.getMessage())); + + if (error instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + return false; + } + } + + private static Path singleRoot(Path extracted) throws IOException { + try (Stream entries = Files.list(extracted)) { + List children = entries.toList(); + + if (children.size() == 1 && Files.isDirectory(children.get(0))) { + return children.get(0); + } + + return extracted; + } + } + + private static void copyRecursively(Path source, Path target) throws IOException { + try (Stream walk = Files.walk(source)) { + for (Path path : walk.toList()) { + Path destination = target.resolve(source.relativize(path).toString()); + + if (Files.isDirectory(path)) { + Files.createDirectories(destination); + } else { + Files.createDirectories(destination.getParent()); + Files.copy(path, destination, StandardCopyOption.REPLACE_EXISTING); + } + } + } + } + + private static void deleteRecursively(Path root) throws IOException { + if (!Files.exists(root)) { + return; + } + + try (Stream walk = Files.walk(root)) { + for (Path path : walk.sorted(Comparator.reverseOrder()).toList()) { + Files.delete(path); + } + } + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java new file mode 100644 index 000000000..37770436f --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java @@ -0,0 +1,698 @@ +/* + * 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.engine.framework.Engine; +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.IrisRegion; +import art.arcane.iris.modded.IrisModdedChunkGenerator; +import art.arcane.iris.modded.ModdedEngineBootstrap; +import art.arcane.iris.modded.ModdedLoader; +import art.arcane.iris.modded.ModdedPackInstaller; +import art.arcane.volmlib.util.collection.KMap; +import art.arcane.volmlib.util.math.Position2; +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.arguments.IntegerArgumentType; +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; +import net.minecraft.commands.SharedSuggestionProvider; +import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.network.chat.Component; +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.block.state.BlockState; +import net.minecraft.world.level.chunk.ChunkGenerator; +import net.minecraft.world.level.levelgen.Heightmap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.function.Predicate; + +public final class IrisModdedCommands { + private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + private static final Predicate GATE = Commands.hasPermission(Commands.LEVEL_GAMEMASTERS); + private static final long LOCATE_TIMEOUT_MS = 120000L; + + private static final SuggestionProvider BIOME_KEYS = (CommandContext context, SuggestionsBuilder builder) -> suggestBiomeKeys(context, builder); + private static final SuggestionProvider REGION_KEYS = (CommandContext context, SuggestionsBuilder builder) -> suggestRegionKeys(context, builder); + 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); + static final SuggestionProvider PACK_NAMES = (CommandContext context, SuggestionsBuilder builder) -> suggestPackNames(builder); + private static final SuggestionProvider DIMENSION_NAMES = (CommandContext context, SuggestionsBuilder builder) -> suggestDimensionNames(context, builder); + + private IrisModdedCommands() { + } + + public static void register(CommandDispatcher dispatcher) { + LiteralArgumentBuilder root = Commands.literal("iris"); + + root.then(Commands.literal("version") + .executes((CommandContext context) -> version(context.getSource()))); + + root.then(Commands.literal("info").requires(GATE) + .executes((CommandContext context) -> info(context.getSource(), null)) + .then(Commands.argument("dimension", StringArgumentType.greedyString()).suggests(DIMENSION_NAMES) + .executes((CommandContext context) -> info(context.getSource(), StringArgumentType.getString(context, "dimension"))))); + + root.then(Commands.literal("what").requires(GATE) + .executes((CommandContext context) -> what(context.getSource()))); + + root.then(gotoTree("goto")); + root.then(gotoTree("find")); + + root.then(Commands.literal("seed").requires(GATE) + .executes((CommandContext context) -> seed(context.getSource()))); + + root.then(goldenhashTree()); + + root.then(Commands.literal("download").requires(GATE) + .then(Commands.argument("pack", StringArgumentType.word()).suggests(PACK_NAMES) + .executes((CommandContext context) -> download(context.getSource(), StringArgumentType.getString(context, "pack"), "master")) + .then(Commands.argument("branch", StringArgumentType.word()) + .executes((CommandContext context) -> download(context.getSource(), StringArgumentType.getString(context, "pack"), StringArgumentType.getString(context, "branch")))))); + + root.then(Commands.literal("metrics").requires(GATE) + .executes((CommandContext context) -> metrics(context.getSource()))); + + root.then(Commands.literal("regen").requires(GATE) + .executes((CommandContext context) -> regen(context.getSource(), 0)) + .then(Commands.argument("radius", IntegerArgumentType.integer(0, 64)) + .executes((CommandContext context) -> regen(context.getSource(), IntegerArgumentType.getInteger(context, "radius"))))); + + root.then(pregenTree()); + + root.then(Commands.literal("wand").requires(GATE) + .executes((CommandContext context) -> ModdedObjectCommands.giveWand(context.getSource()))); + root.then(ModdedObjectCommands.tree()); + root.then(editTree()); + + root.then(ModdedStudioCommands.tree()); + root.then(ModdedPackCommands.tree()); + root.then(ModdedDatapackCommands.tree()); + root.then(ModdedStructureCommands.tree()); + + dispatcher.register(root); + LOGGER.info("Iris /iris command tree registered"); + } + + private static LiteralArgumentBuilder gotoTree(String name) { + return Commands.literal(name).requires(GATE) + .then(Commands.literal("biome") + .then(Commands.argument("key", StringArgumentType.greedyString()).suggests(BIOME_KEYS) + .executes((CommandContext context) -> gotoBiome(context.getSource(), StringArgumentType.getString(context, "key"))))) + .then(Commands.literal("region") + .then(Commands.argument("key", StringArgumentType.greedyString()).suggests(REGION_KEYS) + .executes((CommandContext context) -> gotoRegion(context.getSource(), StringArgumentType.getString(context, "key"))))) + .then(Commands.literal("object") + .then(Commands.argument("key", StringArgumentType.greedyString()).suggests(OBJECT_KEYS) + .executes((CommandContext context) -> gotoObject(context.getSource(), StringArgumentType.getString(context, "key"))))) + .then(Commands.literal("structure") + .then(Commands.argument("key", StringArgumentType.greedyString()).suggests(STRUCTURE_KEYS) + .executes((CommandContext context) -> gotoStructure(context.getSource(), StringArgumentType.getString(context, "key"))))) + .then(Commands.literal("poi") + .then(Commands.argument("type", StringArgumentType.greedyString()).suggests(POI_TYPES) + .executes((CommandContext context) -> gotoPoi(context.getSource(), StringArgumentType.getString(context, "type"))))); + } + + private static LiteralArgumentBuilder pregenTree() { + return Commands.literal("pregen").requires(GATE) + .then(Commands.literal("start") + .then(Commands.argument("radius", IntegerArgumentType.integer(1, 100000)) + .executes((CommandContext context) -> pregenStart(context.getSource(), IntegerArgumentType.getInteger(context, "radius"), 0, 0)) + .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"))))))) + .then(Commands.literal("stop") + .executes((CommandContext context) -> pregenStop(context.getSource()))) + .then(Commands.literal("pause") + .executes((CommandContext context) -> pregenPause(context.getSource()))) + .then(Commands.literal("status") + .executes((CommandContext context) -> pregenStatus(context.getSource()))); + } + + private static LiteralArgumentBuilder goldenhashTree() { + LiteralArgumentBuilder radiusAndThreads = Commands.literal("goldenhash").requires(GATE) + .executes((CommandContext context) -> goldenhash(context.getSource(), 8, 8, ModdedGoldenHash.Mode.AUTO)); + attachModes(radiusAndThreads, (CommandContext context) -> 8, (CommandContext context) -> 8); + + com.mojang.brigadier.builder.RequiredArgumentBuilder radius = Commands.argument("radius", IntegerArgumentType.integer(0, 256)) + .executes((CommandContext context) -> goldenhash(context.getSource(), IntegerArgumentType.getInteger(context, "radius"), 8, ModdedGoldenHash.Mode.AUTO)); + attachModes(radius, (CommandContext context) -> IntegerArgumentType.getInteger(context, "radius"), (CommandContext context) -> 8); + + com.mojang.brigadier.builder.RequiredArgumentBuilder threads = Commands.argument("threads", IntegerArgumentType.integer(1, 64)) + .executes((CommandContext context) -> goldenhash(context.getSource(), IntegerArgumentType.getInteger(context, "radius"), IntegerArgumentType.getInteger(context, "threads"), ModdedGoldenHash.Mode.AUTO)); + attachModes(threads, (CommandContext context) -> IntegerArgumentType.getInteger(context, "radius"), (CommandContext context) -> IntegerArgumentType.getInteger(context, "threads")); + + radius.then(threads); + radiusAndThreads.then(radius); + return radiusAndThreads; + } + + private interface IntExtractor { + int extract(CommandContext context); + } + + private static void attachModes(com.mojang.brigadier.builder.ArgumentBuilder node, IntExtractor radius, IntExtractor threads) { + node.then(Commands.literal("capture") + .executes((CommandContext context) -> goldenhash(context.getSource(), radius.extract(context), threads.extract(context), ModdedGoldenHash.Mode.CAPTURE))); + node.then(Commands.literal("verify") + .executes((CommandContext context) -> goldenhash(context.getSource(), radius.extract(context), threads.extract(context), ModdedGoldenHash.Mode.VERIFY))); + } + + private static LiteralArgumentBuilder editTree() { + String message = "/iris edit opens pack JSON files in a desktop editor through the Bukkit studio toolchain; " + + "on modded servers edit the pack files directly under config/irisworldgen/packs//."; + LiteralArgumentBuilder node = Commands.literal("edit").requires(GATE) + .executes((CommandContext context) -> { + fail(context.getSource(), message); + return 0; + }); + for (String child : new String[]{"biome", "region", "dimension"}) { + node.then(Commands.literal(child) + .executes((CommandContext context) -> { + fail(context.getSource(), message); + return 0; + })); + } + return node; + } + + private static int regen(CommandSourceStack source, int radius) { + ServerPlayer player = source.getPlayer(); + if (player == null) { + fail(source, "This command can only be used by players."); + return 0; + } + ServerLevel level = source.getLevel(); + if (!(level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator irisGenerator)) { + fail(source, "This dimension is not generated by Iris."); + return 0; + } + Engine engine = engineFor(level); + if (engine == null) { + fail(source, "This dimension is not generated by Iris."); + return 0; + } + ModdedRegen.start(source, level, irisGenerator, engine, player, radius); + return 1; + } + + private static int pregenStart(CommandSourceStack source, int radius, int centerX, int centerZ) { + 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)) { + fail(source, "A pregeneration task is already running. Stop it first with /iris pregen stop."); + return 0; + } + 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."); + return 1; + } + + private static int pregenStop(CommandSourceStack source) { + if (ModdedPregenJob.stop()) { + ok(source, "Stopping pregeneration; finishing up the current region..."); + return 1; + } + fail(source, "No active pregeneration task to stop."); + return 0; + } + + private static int pregenPause(CommandSourceStack source) { + Boolean paused = ModdedPregenJob.pauseResume(); + if (paused == null) { + fail(source, "No active pregeneration task to pause/resume."); + return 0; + } + ok(source, "Pregeneration is now " + (paused.booleanValue() ? "paused" : "running") + "."); + return 1; + } + + private static int pregenStatus(CommandSourceStack source) { + String status = ModdedPregenJob.status(); + if (status == null) { + fail(source, "No active pregeneration task."); + return 0; + } + ok(source, status); + return 1; + } + + private static int version(CommandSourceStack source) { + ModdedLoader loader = ModdedEngineBootstrap.loader(); + int engines = engineCount(source.getServer()); + ok(source, "Iris " + loader.modVersion() + " by Volmit Software on " + loader.platformName() + + " (Minecraft " + loader.minecraftVersion() + "), " + engines + " Iris dimension(s)"); + return 1; + } + + private static int info(CommandSourceStack source, String filter) { + MinecraftServer server = source.getServer(); + List lines = new ArrayList<>(); + int total = 0; + int iris = 0; + for (ServerLevel level : server.getAllLevels()) { + total++; + ChunkGenerator generator = level.getChunkSource().getGenerator(); + if (!(generator instanceof IrisModdedChunkGenerator irisGenerator)) { + continue; + } + iris++; + String dimensionId = level.dimension().identifier().toString(); + if (filter != null && !dimensionId.contains(filter) && !irisGenerator.dimensionKey().contains(filter)) { + continue; + } + Engine engine = irisGenerator.engineIfBound(); + if (engine == null) { + lines.add(dimensionId + ": pack=" + irisGenerator.dimensionKey() + " (engine not started yet)"); + continue; + } + lines.add(dimensionId + ": pack=" + engine.getDimension().getLoadKey() + + " seed=" + level.getSeed() + + " height=" + engine.getMinHeight() + ".." + engine.getMaxHeight() + + " generated=" + engine.getGenerated() + + " data=" + engine.getData().getDataFolder().getAbsolutePath()); + } + ok(source, "Loaded dimensions: " + total + " (" + iris + " Iris)"); + if (lines.isEmpty()) { + ok(source, filter == null ? "No Iris dimensions are loaded." : "No Iris dimension matches '" + filter + "'."); + return 0; + } + for (String line : lines) { + ok(source, line); + } + return 1; + } + + private static int what(CommandSourceStack source) { + ServerPlayer player = source.getPlayer(); + if (player == null) { + fail(source, "This command can only be used by players."); + return 0; + } + ServerLevel level = source.getLevel(); + Engine engine = engineFor(level); + if (engine == null) { + fail(source, "This dimension is not generated by Iris."); + return 0; + } + BlockPos pos = player.blockPosition(); + int relativeY = pos.getY() - engine.getMinHeight(); + try { + IrisBiome biome = engine.getBiome(pos.getX(), relativeY, pos.getZ()); + ok(source, "Biome: " + biome.getLoadKey() + " (" + biome.getName() + ")"); + } catch (Throwable e) { + fail(source, "Biome lookup failed: " + e.getClass().getSimpleName()); + } + try { + IrisRegion region = engine.getRegion(pos.getX(), pos.getZ()); + ok(source, "Region: " + region.getLoadKey() + " (" + region.getName() + ")"); + } catch (Throwable e) { + fail(source, "Region lookup failed: " + e.getClass().getSimpleName()); + } + try { + IrisBiome cave = engine.getCaveBiome(pos.getX(), relativeY, pos.getZ()); + ok(source, "Cave biome: " + (cave == null ? "none" : cave.getLoadKey())); + } catch (Throwable e) { + fail(source, "Cave biome lookup failed: " + e.getClass().getSimpleName()); + } + int surfaceY = level.getHeight(Heightmap.Types.WORLD_SURFACE, pos.getX(), pos.getZ()); + BlockState surface = level.getBlockState(new BlockPos(pos.getX(), surfaceY - 1, pos.getZ())); + ok(source, "Surface block: " + BuiltInRegistries.BLOCK.getKey(surface.getBlock()) + " (y=" + (surfaceY - 1) + ")"); + ok(source, "Position: " + pos.getX() + " " + pos.getY() + " " + pos.getZ() + " (chunk " + (pos.getX() >> 4) + "," + (pos.getZ() >> 4) + ")"); + return 1; + } + + private static int gotoBiome(CommandSourceStack source, String key) { + ServerPlayer player = source.getPlayer(); + if (player == null) { + fail(source, "This command can only be used by players."); + return 0; + } + ServerLevel level = source.getLevel(); + Engine engine = engineFor(level); + if (engine == null) { + fail(source, "This dimension is not generated by Iris."); + return 0; + } + IrisBiome biome = engine.getData().getBiomeLoader().load(key.trim()); + if (biome == null) { + fail(source, "Unknown biome: " + key); + return 0; + } + locate(source, level, engine, player, Locator.surfaceBiome(biome.getLoadKey()), "biome " + biome.getLoadKey()); + return 1; + } + + private static int gotoRegion(CommandSourceStack source, String key) { + ServerPlayer player = source.getPlayer(); + if (player == null) { + fail(source, "This command can only be used by players."); + return 0; + } + ServerLevel level = source.getLevel(); + Engine engine = engineFor(level); + if (engine == null) { + fail(source, "This dimension is not generated by Iris."); + return 0; + } + IrisRegion region = engine.getData().getRegionLoader().load(key.trim()); + if (region == null) { + fail(source, "Unknown region: " + key); + return 0; + } + if (!engine.getDimension().getRegions().contains(region.getLoadKey())) { + fail(source, region.getLoadKey() + " is not defined in the dimension!"); + return 0; + } + locate(source, level, engine, player, Locator.region(region.getLoadKey()), "region " + region.getLoadKey()); + return 1; + } + + private static int gotoObject(CommandSourceStack source, String keyRaw) { + ServerLevel level = source.getLevel(); + Engine engine = engineFor(level); + if (engine == null) { + fail(source, "This dimension is not generated by Iris."); + return 0; + } + String key = keyRaw.trim(); + if (!engine.hasObjectPlacement(key)) { + fail(source, key + " is not configured in any region/biome object placements (" + + engine.getData().getObjectLoader().getPossibleKeys().length + " object keys loaded)."); + return 0; + } + ServerPlayer player = source.getPlayer(); + if (player == null) { + fail(source, "This command can only be used by players. (object key '" + key + "' resolved against " + + engine.getData().getObjectLoader().getPossibleKeys().length + " loaded object keys)"); + return 0; + } + locate(source, level, engine, player, Locator.object(key), "object " + key); + return 1; + } + + private static int gotoStructure(CommandSourceStack source, String keyRaw) { + ServerLevel level = source.getLevel(); + Engine engine = engineFor(level); + if (engine == null) { + fail(source, "This dimension is not generated by Iris."); + return 0; + } + String key = keyRaw.trim(); + Set placed = IrisStructureLocator.placedKeys(engine); + if (!IrisStructureLocator.isPlaced(engine, key)) { + fail(source, "Structure " + key + " is not placed by this pack. Placed keys (" + placed.size() + "): " + + String.join(", ", placed.stream().limit(8).toList()) + (placed.size() > 8 ? ", ..." : "")); + return 0; + } + ServerPlayer player = source.getPlayer(); + if (player == null) { + fail(source, "This command can only be used by players. (structure key '" + key + "' resolved; " + + placed.size() + " placed structure keys loaded)"); + return 0; + } + MinecraftServer server = source.getServer(); + int blockX = player.blockPosition().getX(); + int blockZ = player.blockPosition().getZ(); + ok(source, "Searching for structure " + key + "..."); + Thread thread = new Thread(() -> { + try { + int[] at = IrisStructureLocator.locate(engine, key, blockX, blockZ, 1024); + if (at == null) { + server.execute(() -> fail(source, "Could not find structure " + key + " within 1024 chunks.")); + return; + } + int targetX = at[0] + 8; + int targetY = at[1] + 2; + int targetZ = at[2] + 8; + server.execute(() -> { + player.teleportTo(level, targetX + 0.5D, targetY, targetZ + 0.5D, Set.of(), player.getYRot(), player.getXRot(), false); + ok(source, "Teleported to structure " + key + " at " + targetX + " " + targetY + " " + targetZ); + }); + } catch (Throwable e) { + LOGGER.error("Iris structure locate failed for {}", key, e); + server.execute(() -> fail(source, "Search failed: " + e.getClass().getSimpleName())); + } + }, "Iris Structure Locator"); + thread.setDaemon(true); + thread.start(); + return 1; + } + + private static int gotoPoi(CommandSourceStack source, String typeRaw) { + ServerLevel level = source.getLevel(); + Engine engine = engineFor(level); + if (engine == null) { + fail(source, "This dimension is not generated by Iris."); + return 0; + } + String type = typeRaw.trim(); + ServerPlayer player = source.getPlayer(); + if (player == null) { + fail(source, "This command can only be used by players. (POI type '" + type + "' accepted)"); + return 0; + } + locate(source, level, engine, player, Locator.poi(type), "POI " + type); + return 1; + } + + private static void locate(CommandSourceStack source, ServerLevel level, Engine engine, ServerPlayer player, Locator locator, String label) { + MinecraftServer server = source.getServer(); + int chunkX = player.blockPosition().getX() >> 4; + int chunkZ = player.blockPosition().getZ() >> 4; + ok(source, "Searching for " + label + "..."); + Thread thread = new Thread(() -> { + try { + Position2 at = locator.find(engine, new Position2(chunkX, chunkZ), LOCATE_TIMEOUT_MS, (Integer checks) -> { + }).get(); + if (at == null) { + server.execute(() -> fail(source, "Could not find " + label + " within the search timeout.")); + return; + } + int blockX = (at.getX() << 4) + 8; + int blockZ = (at.getZ() << 4) + 8; + int blockY = engine.getMinHeight() + engine.getHeight(blockX, blockZ, false) + 2; + server.execute(() -> { + player.teleportTo(level, blockX + 0.5D, blockY, blockZ + 0.5D, Set.of(), player.getYRot(), player.getXRot(), false); + ok(source, "Teleported to " + label + " at " + blockX + " " + blockY + " " + blockZ); + }); + } catch (WrongEngineBroException e) { + server.execute(() -> fail(source, "The engine for this world has been closed; rejoin the dimension and try again.")); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (ExecutionException e) { + LOGGER.error("Iris locate failed for {}", label, e); + server.execute(() -> fail(source, "Search failed: " + e.getCause())); + } + }, "Iris Locator"); + thread.setDaemon(true); + thread.start(); + } + + private static int seed(CommandSourceStack source) { + ServerLevel level = source.getLevel(); + Engine engine = engineFor(level); + if (engine == null) { + fail(source, "This dimension is not generated by Iris."); + return 0; + } + ok(source, "World seed: " + level.getSeed()); + ok(source, "Engine seed: " + engine.getSeedManager().getSeed() + " (mixed: " + engine.getSeedManager().getFullMixedSeed() + ")"); + return 1; + } + + private static int goldenhash(CommandSourceStack source, int radius, int threads, ModdedGoldenHash.Mode mode) { + ServerLevel level = source.getLevel(); + Engine engine = engineFor(level); + if (engine == null) { + fail(source, "This dimension is not generated by Iris."); + return 0; + } + ModdedGoldenHash.start(source, level, engine, radius, threads, mode); + return 1; + } + + private static int download(CommandSourceStack source, String pack, String branch) { + MinecraftServer server = source.getServer(); + ok(source, "Downloading IrisDimensions/" + pack + " (branch " + branch + ")..."); + Thread thread = new Thread(() -> { + boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), pack, branch, + (String message) -> server.execute(() -> ok(source, message))); + if (installed) { + server.execute(() -> ok(source, "Pack '" + pack + "' installed. Restart or create a new world to use it.")); + } else { + server.execute(() -> fail(source, "Pack download failed for " + pack + "/" + branch + " (see console).")); + } + }, "Iris Pack Download"); + thread.setDaemon(true); + thread.start(); + return 1; + } + + private static int metrics(CommandSourceStack source) { + ServerLevel level = source.getLevel(); + Engine engine = engineFor(level); + if (engine == null) { + fail(source, "This dimension is not generated by Iris."); + return 0; + } + ok(source, "Generated: " + engine.getGenerated() + " chunk(s), " + String.format("%.1f", engine.getGeneratedPerSecond()) + "/s"); + KMap pulled = engine.getMetrics().pull(); + Map sorted = new TreeMap<>(pulled); + for (Map.Entry entry : sorted.entrySet()) { + if (entry.getValue() == null || entry.getValue() <= 0D) { + continue; + } + ok(source, " " + entry.getKey() + ": " + String.format("%.2f", entry.getValue()) + "ms"); + } + return 1; + } + + static Engine engineFor(ServerLevel level) { + ChunkGenerator generator = level.getChunkSource().getGenerator(); + if (generator instanceof IrisModdedChunkGenerator irisGenerator) { + try { + return irisGenerator.commandEngine(); + } catch (Throwable e) { + LOGGER.error("Iris engine lookup failed for {}", level.dimension().identifier(), e); + return null; + } + } + return null; + } + + private static int engineCount(MinecraftServer server) { + int count = 0; + for (ServerLevel level : server.getAllLevels()) { + if (level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator) { + count++; + } + } + return count; + } + + private static CompletableFuture suggestBiomeKeys(CommandContext context, SuggestionsBuilder builder) { + try { + Engine engine = engineFor(context.getSource().getLevel()); + if (engine != null) { + return SharedSuggestionProvider.suggest(engine.getData().getBiomeLoader().getPossibleKeys(), builder); + } + } catch (Throwable ignored) { + } + return builder.buildFuture(); + } + + private static CompletableFuture suggestRegionKeys(CommandContext context, SuggestionsBuilder builder) { + try { + Engine engine = engineFor(context.getSource().getLevel()); + if (engine != null) { + return SharedSuggestionProvider.suggest(engine.getDimension().getRegions(), builder); + } + } catch (Throwable ignored) { + } + return builder.buildFuture(); + } + + private static CompletableFuture suggestObjectKeys(CommandContext context, SuggestionsBuilder builder) { + try { + Engine engine = engineFor(context.getSource().getLevel()); + if (engine != null) { + return SharedSuggestionProvider.suggest(engine.getData().getObjectLoader().getPossibleKeys(), builder); + } + } catch (Throwable ignored) { + } + return builder.buildFuture(); + } + + private static CompletableFuture suggestStructureKeys(CommandContext context, SuggestionsBuilder builder) { + try { + Engine engine = engineFor(context.getSource().getLevel()); + if (engine != null) { + return SharedSuggestionProvider.suggest(IrisStructureLocator.placedKeys(engine), builder); + } + } catch (Throwable ignored) { + } + return builder.buildFuture(); + } + + private static CompletableFuture suggestPackNames(SuggestionsBuilder builder) { + List names = new ArrayList<>(); + names.add("overworld"); + try { + File packs = ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen").resolve("packs").toFile(); + File[] children = packs.listFiles(); + if (children != null) { + for (File child : children) { + if (child.isDirectory() && !names.contains(child.getName())) { + names.add(child.getName()); + } + } + } + } catch (Throwable ignored) { + } + return SharedSuggestionProvider.suggest(names, builder); + } + + private static CompletableFuture suggestDimensionNames(CommandContext context, SuggestionsBuilder builder) { + List names = new ArrayList<>(); + for (ServerLevel level : context.getSource().getServer().getAllLevels()) { + if (level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator) { + names.add(level.dimension().identifier().toString()); + } + } + return SharedSuggestionProvider.suggest(names, builder); + } + + static void ok(CommandSourceStack source, String message) { + source.sendSuccess(() -> Component.literal(message), false); + } + + static void fail(CommandSourceStack source, String message) { + source.sendFailure(Component.literal(message)); + } +} 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 new file mode 100644 index 000000000..b865cf55f --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDatapackCommands.java @@ -0,0 +1,261 @@ +/* + * 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.framework.Engine; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.modded.IrisModdedChunkGenerator; +import art.arcane.volmlib.util.collection.KList; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.mojang.brigadier.context.CommandContext; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +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.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +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 = "iris"; + + private ModdedDatapackCommands() { + } + + public static LiteralArgumentBuilder tree() { + LiteralArgumentBuilder root = Commands.literal("datapack").requires(GATE); + + root.then(Commands.literal("status") + .executes((CommandContext context) -> status(context.getSource()))); + + root.then(Commands.literal("install") + .executes((CommandContext context) -> install(context.getSource()))); + + root.then(Commands.literal("list") + .executes((CommandContext context) -> list(context.getSource()))); + + root.then(message("ingest", "Modrinth datapack ingest requires the Bukkit plugin: its post-restart structure import into editable Iris resources uses Bukkit registries, and Iris modded dimensions do not run vanilla structure placement, so ingested structure datapacks would not generate. Drop datapacks into world/datapacks manually for non-structure content.")); + + root.then(message("remove", "Datapack removal manages the Bukkit ingest manifest. On modded servers delete the datapack folder from world/datapacks and restart.")); + + return root; + } + + private static LiteralArgumentBuilder message(String name, String text) { + return Commands.literal(name) + .executes((CommandContext context) -> { + IrisModdedCommands.fail(context.getSource(), text); + return 0; + }) + .then(Commands.argument("args", StringArgumentType.greedyString()) + .executes((CommandContext context) -> { + IrisModdedCommands.fail(context.getSource(), text); + return 0; + })); + } + + private static File worldDatapacksFolder(MinecraftServer server) { + return server.getWorldPath(LevelResource.DATAPACK_DIR).toFile(); + } + + private static File overrideFile(MinecraftServer server, String dimensionKey) { + return new File(worldDatapacksFolder(server), WORLD_PACK_NAME + "/data/irisworldgen/dimension_type/" + IrisDimension.sanitizeDimensionTypeKeyValue(dimensionKey) + ".json"); + } + + private static int status(CommandSourceStack source) { + MinecraftServer server = source.getServer(); + int irisLevels = 0; + int mismatches = 0; + for (ServerLevel level : server.getAllLevels()) { + if (!(level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator irisGenerator)) { + continue; + } + irisLevels++; + String dimensionId = level.dimension().identifier().toString(); + String typeKey = level.dimensionTypeRegistration().unwrapKey() + .map((net.minecraft.resources.ResourceKey key) -> key.identifier().toString()) + .orElse("inline"); + DimensionType active = level.dimensionType(); + int activeMin = active.minY(); + int activeMax = active.minY() + active.height(); + + Engine engine = IrisModdedCommands.engineFor(level); + if (engine == null || engine.getDimension() == null) { + IrisModdedCommands.ok(source, dimensionId + ": type=" + typeKey + " active=" + activeMin + ".." + activeMax + + " logical=" + active.logicalHeight() + " (pack=" + irisGenerator.dimensionKey() + ", engine not started; pack heights unknown)"); + continue; + } + IrisDimension dimension = engine.getDimension(); + int packMin = dimension.getMinHeight(); + int packMax = dimension.getMaxHeight(); + int packLogical = dimension.getLogicalHeight(); + boolean matches = packMin == activeMin && packMax == activeMax && packLogical == active.logicalHeight(); + File override = overrideFile(server, irisGenerator.dimensionKey()); + IrisModdedCommands.ok(source, dimensionId + ": type=" + typeKey + + " active=" + activeMin + ".." + activeMax + " logical=" + active.logicalHeight() + + " | pack '" + dimension.getLoadKey() + "' wants " + packMin + ".." + packMax + " logical=" + packLogical + + " | " + (matches ? "MATCH" : "MISMATCH") + + (override.isFile() ? " (world datapack override installed)" : "")); + if (!matches) { + mismatches++; + IrisModdedCommands.fail(source, " WARNING: the active dimension type does not match the pack. Terrain outside " + + activeMin + ".." + activeMax + " will be clipped. Run /iris datapack install and restart the server."); + } + } + if (irisLevels == 0) { + IrisModdedCommands.fail(source, "No Iris dimensions are loaded."); + return 0; + } + if (mismatches == 0) { + IrisModdedCommands.ok(source, "All " + irisLevels + " Iris dimension(s) match their pack height ranges."); + } + return 1; + } + + private static int install(CommandSourceStack source) { + MinecraftServer server = source.getServer(); + List written = new ArrayList<>(); + for (ServerLevel level : server.getAllLevels()) { + if (!(level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator irisGenerator)) { + continue; + } + Engine engine = IrisModdedCommands.engineFor(level); + if (engine == null || engine.getDimension() == null) { + IrisModdedCommands.fail(source, level.dimension().identifier() + ": engine not started; cannot derive its dimension type yet."); + continue; + } + IrisDimension dimension = engine.getDimension(); + String json; + try { + json = dimension.getDimensionType().toJson(DataVersion.getLatest().get()); + } catch (Throwable e) { + LOGGER.error("Iris dimension type generation failed for {}", dimension.getLoadKey(), e); + IrisModdedCommands.fail(source, level.dimension().identifier() + ": dimension type generation failed: " + e.getMessage()); + continue; + } + File output = overrideFile(server, irisGenerator.dimensionKey()); + try { + output.getParentFile().mkdirs(); + Files.writeString(output.toPath(), json, StandardCharsets.UTF_8); + written.add(output.getPath()); + } catch (IOException e) { + LOGGER.error("Iris dimension type write failed for {}", output, e); + IrisModdedCommands.fail(source, "Failed to write " + output + ": " + e.getMessage()); + } + } + if (written.isEmpty()) { + IrisModdedCommands.fail(source, "No Iris dimensions with running engines found; nothing was installed."); + return 0; + } + + File mcmeta = new File(worldDatapacksFolder(server), WORLD_PACK_NAME + "/pack.mcmeta"); + int packFormat = DataVersion.getLatest().getPackFormat(); + String meta = "{\n" + + " \"pack\": {\n" + + " \"description\": \"Iris dimension types derived from the installed Iris packs.\",\n" + + " \"pack_format\": " + packFormat + ",\n" + + " \"min_format\": " + packFormat + ",\n" + + " \"max_format\": " + packFormat + "\n" + + " }\n" + + "}\n"; + try { + mcmeta.getParentFile().mkdirs(); + Files.writeString(mcmeta.toPath(), meta, StandardCharsets.UTF_8); + written.add(mcmeta.getPath()); + } catch (IOException e) { + LOGGER.error("Iris pack.mcmeta write failed for {}", mcmeta, e); + IrisModdedCommands.fail(source, "Failed to write " + mcmeta + ": " + e.getMessage()); + return 0; + } + + for (String path : written) { + IrisModdedCommands.ok(source, "Wrote " + path); + } + IrisModdedCommands.ok(source, "World datapack '" + WORLD_PACK_NAME + "' installed. Restart the server for the dimension types to apply (world datapacks override mod-provided data)."); + return 1; + } + + private static int list(CommandSourceStack source) { + MinecraftServer server = source.getServer(); + LinkedHashSet configured = new LinkedHashSet<>(); + File packsRoot = ModdedPackCommands.packsRoot(); + File[] packs = packsRoot.isDirectory() ? packsRoot.listFiles(File::isDirectory) : null; + if (packs != null) { + for (File pack : packs) { + if (!new File(pack, "dimensions").isDirectory()) { + continue; + } + try { + IrisData data = IrisData.get(pack); + for (IrisDimension dimension : data.getDimensionLoader().loadAll(data.getDimensionLoader().getPossibleKeys())) { + if (dimension == null || dimension.getDatapackImports() == null) { + continue; + } + for (String url : dimension.getDatapackImports()) { + if (url != null && !url.isBlank()) { + configured.add(url.trim()); + } + } + } + } catch (Throwable e) { + LOGGER.error("Iris datapack import scan failed for pack {}", pack.getName(), e); + } + } + } + + IrisModdedCommands.ok(source, "Configured datapack imports: " + configured.size()); + for (String url : configured) { + IrisModdedCommands.ok(source, " - " + url); + } + if (!configured.isEmpty()) { + IrisModdedCommands.ok(source, "Modrinth ingest is Bukkit-only; install these manually into world/datapacks if needed."); + } + + File datapacks = worldDatapacksFolder(server); + File[] installed = datapacks.isDirectory() ? datapacks.listFiles(File::isDirectory) : null; + KList names = new KList<>(); + if (installed != null) { + for (File folder : installed) { + if (new File(folder, "pack.mcmeta").isFile()) { + names.add(folder.getName()); + } + } + } + IrisModdedCommands.ok(source, "Installed world datapacks: " + names.size()); + for (String name : names) { + IrisModdedCommands.ok(source, " - " + name); + } + return 1; + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDustRevealer.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDustRevealer.java new file mode 100644 index 000000000..cd3852807 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDustRevealer.java @@ -0,0 +1,183 @@ +/* + * 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.engine.framework.Engine; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.modded.ModdedBlockState; +import net.minecraft.core.BlockPos; +import net.minecraft.core.particles.DustParticleOptions; +import net.minecraft.network.chat.Component; +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.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Supplier; + +public final class ModdedDustRevealer { + private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + private static final int MAX_HITS = 2048; + private static final DustParticleOptions REVEAL_DUST = new DustParticleOptions(0xFFD24A, 1.2F); + + private ModdedDustRevealer() { + } + + public static void reveal(ServerPlayer player, ServerLevel level, BlockPos pos) { + Engine engine = IrisModdedCommands.engineFor(level); + if (engine == null) { + player.sendSystemMessage(Component.literal("This dimension is not generated by Iris.")); + return; + } + describe(player, level, engine, pos); + + int relativeY = pos.getY() - engine.getMinHeight(); + String key = safe(() -> engine.getObjectPlacementKey(pos.getX(), relativeY, pos.getZ())); + if (key == null) { + return; + } + player.sendSystemMessage(Component.literal("Found object " + key)); + MinecraftServer server = level.getServer(); + BlockPos origin = pos.immutable(); + Thread thread = new Thread(() -> { + List hits = collect(engine, level, origin, key); + server.execute(() -> { + for (BlockPos hit : hits) { + level.sendParticles(player, REVEAL_DUST, true, true, + hit.getX() + 0.5D, hit.getY() + 0.5D, hit.getZ() + 0.5D, + 3, 0.25D, 0.25D, 0.25D, 0.0D); + } + player.sendSystemMessage(Component.literal("Revealed " + hits.size() + " block(s) of " + key + + (hits.size() >= MAX_HITS ? " (capped)" : ""))); + }); + }, "Iris Dust Revealer"); + thread.setDaemon(true); + thread.start(); + } + + private static List collect(Engine engine, ServerLevel level, BlockPos origin, String key) { + List hits = new ArrayList<>(); + Set visited = new HashSet<>(); + Deque frontier = new ArrayDeque<>(); + frontier.add(origin); + visited.add(origin); + int minY = level.getMinY(); + int maxY = minY + level.getHeight(); + try { + while (!frontier.isEmpty() && hits.size() < MAX_HITS) { + BlockPos current = frontier.poll(); + hits.add(current); + for (int dx = -1; dx <= 1; dx++) { + for (int dy = -1; dy <= 1; dy++) { + for (int dz = -1; dz <= 1; dz++) { + if (dx == 0 && dy == 0 && dz == 0) { + continue; + } + BlockPos next = current.offset(dx, dy, dz); + if (next.getY() < minY || next.getY() >= maxY || visited.contains(next)) { + continue; + } + visited.add(next); + String nextKey = engine.getObjectPlacementKey(next.getX(), next.getY() - engine.getMinHeight(), next.getZ()); + if (key.equals(nextKey)) { + frontier.add(next); + } + } + } + } + } + } catch (Throwable e) { + LOGGER.error("Iris dust reveal BFS failed for {}", key, e); + } + return hits; + } + + private static void describe(ServerPlayer player, ServerLevel level, Engine engine, BlockPos pos) { + int x = pos.getX(); + int y = pos.getY(); + int z = pos.getZ(); + int minHeight = engine.getMinHeight(); + int relativeY = y - minHeight; + int surfaceRelative = safeInt(() -> engine.getHeight(x, z, true)); + int surfaceY = surfaceRelative + minHeight; + int offset = y - surfaceY; + + String objectKey = safe(() -> engine.getObjectPlacementKey(x, relativeY, z)); + IrisBiome surfaceBiome = safe(() -> engine.getSurfaceBiome(x, z)); + IrisBiome biomeHere = safe(() -> engine.getBiome(x, relativeY, z)); + IrisBiome caveBiome = safe(() -> engine.getCaveOrMantleBiome(x, relativeY, z)); + IrisRegion region = safe(() -> engine.getRegion(x, z)); + + List lines = new ArrayList<>(); + lines.add("--- Iris Dust @ " + x + ", " + y + ", " + z + " ---"); + lines.add("Block: " + ModdedBlockState.serialize(level.getBlockState(pos))); + if (offset > 0) { + lines.add("Position: +" + offset + " ABOVE surface (surface Y=" + surfaceY + ")"); + } else if (offset < 0) { + lines.add("Position: " + (-offset) + " below surface (surface Y=" + surfaceY + ")"); + } else { + lines.add("Position: at surface (Y=" + surfaceY + ")"); + } + lines.add("Object @block: " + (objectKey == null ? "none" : objectKey)); + if (surfaceBiome != null) { + lines.add("Surface biome: " + surfaceBiome.getLoadKey()); + } + if (biomeHere != null && (surfaceBiome == null || !biomeHere.getLoadKey().equals(surfaceBiome.getLoadKey()))) { + lines.add("Biome @Y: " + biomeHere.getLoadKey()); + } + if (caveBiome != null && (surfaceBiome == null || !caveBiome.getLoadKey().equals(surfaceBiome.getLoadKey()))) { + lines.add("Cave/Mantle biome: " + caveBiome.getLoadKey()); + } + if (region != null) { + lines.add("Region: " + region.getLoadKey() + " (" + region.getName() + ")"); + } + Set objects = safe(() -> engine.getObjectsAt(x >> 4, z >> 4)); + if (objects != null && !objects.isEmpty()) { + lines.add("Objects in chunk: " + objects); + } + for (String line : lines) { + player.sendSystemMessage(Component.literal(line)); + } + } + + private static T safe(Supplier supplier) { + try { + return supplier.get(); + } catch (Throwable e) { + return null; + } + } + + private static int safeInt(Supplier supplier) { + try { + Integer value = supplier.get(); + return value == null ? 0 : value; + } catch (Throwable e) { + return 0; + } + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedGoldenHash.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedGoldenHash.java new file mode 100644 index 000000000..833793191 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedGoldenHash.java @@ -0,0 +1,400 @@ +/* + * 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.engine.framework.Engine; +import art.arcane.iris.modded.ModdedBlockBuffer; +import art.arcane.iris.modded.ModdedEngineBootstrap; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformBiome; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.common.parallel.MultiBurst; +import art.arcane.iris.util.project.hunk.Hunk; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.network.chat.Component; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Semaphore; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +public final class ModdedGoldenHash { + public enum Mode { + AUTO, + CAPTURE, + VERIFY + } + + private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + private static final String FORMAT = "iris-goldenhash v1"; + private static final int BIOME_STEP = 4; + private static final int MAX_REPORTED_MISMATCHES = 10; + private static final AtomicBoolean ACTIVE = new AtomicBoolean(false); + + private final CommandSourceStack source; + private final MinecraftServer server; + private final ServerLevel level; + private final Engine engine; + private final int radius; + private final int threads; + private final Mode mode; + private final File goldenFile; + + private ModdedGoldenHash(CommandSourceStack source, ServerLevel level, Engine engine, int radius, int threads, Mode mode) { + this.source = source; + this.server = source.getServer(); + this.level = level; + this.engine = engine; + this.radius = Math.max(0, radius); + this.threads = Math.max(1, threads); + this.mode = mode; + File goldenDir = ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen").resolve("golden").toFile(); + goldenDir.mkdirs(); + this.goldenFile = new File(goldenDir, engine.getDimension().getLoadKey() + + "-s" + level.getSeed() + + "-c0x0-r" + this.radius + ".hashes"); + } + + public static void start(CommandSourceStack source, ServerLevel level, Engine engine, int radius, int threads, Mode mode) { + if (!ACTIVE.compareAndSet(false, true)) { + source.sendFailure(Component.literal("A goldenhash scan is already running.")); + return; + } + ModdedGoldenHash scan = new ModdedGoldenHash(source, level, engine, radius, threads, mode); + int chunks = (scan.radius * 2 + 1) * (scan.radius * 2 + 1); + scan.ok("GoldenHash started: " + chunks + " chunk(s) around 0,0 in buffers (world untouched), threads=" + scan.threads + " mode=" + mode); + LOGGER.info("goldenhash start: dim={} seed={} radius={} threads={} mode={} file={}", + engine.getDimension().getLoadKey(), level.getSeed(), scan.radius, scan.threads, mode, scan.goldenFile.getName()); + Thread thread = new Thread(() -> { + try { + scan.run(); + } catch (Throwable e) { + LOGGER.error("goldenhash failed", e); + scan.fail("GoldenHash failed: " + e); + } finally { + ACTIVE.set(false); + } + }, "Iris GoldenHash"); + thread.setDaemon(true); + thread.start(); + } + + private void run() throws Exception { + boolean exists = goldenFile.exists(); + if (mode == Mode.VERIFY && !exists) { + fail("No golden capture at " + goldenFile.getAbsolutePath() + "; run '/iris goldenhash " + radius + " " + threads + " capture' first."); + return; + } + + resetMantleFull(); + List targets = orderedTargets(0, 0, radius); + Map lines = scan(targets); + if (lines.size() != targets.size()) { + fail("GoldenHash aborted: " + (targets.size() - lines.size()) + " chunk(s) failed to generate. No golden file written."); + return; + } + + if (mode == Mode.CAPTURE || (mode == Mode.AUTO && !exists)) { + capture(lines); + } else { + verify(lines); + } + } + + private void resetMantleFull() { + try { + engine.getMantle().getMantle().saveAll(); + File folder = engine.getMantle().getMantle().getDataFolder(); + File[] files = folder.listFiles(); + if (files != null) { + for (File file : files) { + if (file.isFile()) { + file.delete(); + } + } + } + ok("Mantle reset (" + folder.getAbsolutePath() + ")"); + } catch (Throwable e) { + LOGGER.error("goldenhash mantle reset failed", e); + ok("Mantle reset failed (" + e.getClass().getSimpleName() + "); continuing with existing mantle state."); + } + } + + private Map scan(List targets) throws InterruptedException { + Map lines = new ConcurrentHashMap<>(); + Semaphore inFlight = new Semaphore(threads); + CountDownLatch done = new CountDownLatch(targets.size()); + AtomicInteger completed = new AtomicInteger(); + int total = targets.size(); + int stride = total <= 64 ? 1 : 32; + int height = engine.getMaxHeight() - engine.getMinHeight(); + PlatformBlockState air = IrisPlatforms.get().registries().air(); + + for (int[] target : targets) { + int chunkX = target[0]; + int chunkZ = target[1]; + inFlight.acquire(); + MultiBurst.burst.lazy(() -> { + try { + ModdedBlockBuffer blocks = new ModdedBlockBuffer(height, air); + Hunk biomes = Hunk.newArrayHunk(16, height, 16); + engine.generate(chunkX << 4, chunkZ << 4, blocks, biomes, false); + lines.put(chunkKey(chunkX, chunkZ), hashChunk(chunkX, chunkZ, blocks, biomes, height)); + int doneCount = completed.incrementAndGet(); + if (doneCount % stride == 0 || doneCount == total) { + ok("[" + doneCount + "/" + total + "] chunk " + chunkX + "," + chunkZ + " hashed"); + } + } catch (Throwable e) { + LOGGER.error("goldenhash chunk {},{} failed", chunkX, chunkZ, e); + fail("Chunk " + chunkX + "," + chunkZ + " FAILED: " + e.getClass().getSimpleName()); + } finally { + inFlight.release(); + done.countDown(); + } + }); + } + + done.await(); + return lines; + } + + private String hashChunk(int chunkX, int chunkZ, ModdedBlockBuffer blocks, Hunk biomes, int height) { + MessageDigest blockDigest = sha256(); + MessageDigest biomeDigest = sha256(); + Map blockCache = new HashMap<>(); + Map biomeCache = new HashMap<>(); + byte[] plains = "minecraft:plains\n".getBytes(StandardCharsets.UTF_8); + + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + for (int y = 0; y < height; y++) { + PlatformBlockState state = blocks.get(x, y, z); + byte[] bytes = blockCache.computeIfAbsent(state, (PlatformBlockState s) -> (s.key() + "\n").getBytes(StandardCharsets.UTF_8)); + blockDigest.update(bytes); + } + } + } + + for (int x = 0; x < 16; x += BIOME_STEP) { + for (int z = 0; z < 16; z += BIOME_STEP) { + for (int y = 0; y < height; y += BIOME_STEP) { + PlatformBiome biome = biomes.get(x, y, z); + byte[] bytes = biome == null + ? plains + : biomeCache.computeIfAbsent(biome, (PlatformBiome b) -> (b.key() + "\n").getBytes(StandardCharsets.UTF_8)); + biomeDigest.update(bytes); + } + } + } + + return chunkX + " " + chunkZ + " " + + HexFormat.of().formatHex(blockDigest.digest()) + " " + + HexFormat.of().formatHex(biomeDigest.digest()); + } + + private void capture(Map lines) throws IOException { + List body = orderedBody(lines); + String combined = combinedHash(body); + List out = new ArrayList<>(); + out.add("#" + FORMAT); + out.add("#world=" + engine.getWorld().name()); + out.add("#dim=" + engine.getDimension().getLoadKey()); + out.add("#seed=" + level.getSeed()); + out.add("#mc=" + ModdedEngineBootstrap.loader().minecraftVersion()); + out.add("#minY=" + engine.getMinHeight() + " maxY=" + engine.getMaxHeight()); + out.add("#center=0,0"); + out.add("#radius=" + radius); + out.addAll(body); + out.add("#combined=" + combined); + Files.write(goldenFile.toPath(), out, StandardCharsets.UTF_8); + + ok("Golden captured: " + body.size() + " chunks combined=" + shortHash(combined)); + ok(goldenFile.getAbsolutePath()); + LOGGER.info("goldenhash captured: {} combined={}", goldenFile.getAbsolutePath(), combined); + } + + private void verify(Map lines) throws IOException { + List existing = Files.readAllLines(goldenFile.toPath(), StandardCharsets.UTF_8); + Map meta = new HashMap<>(); + Map goldenChunks = new HashMap<>(); + for (String line : existing) { + if (line.startsWith("#")) { + int eq = line.indexOf('='); + if (eq > 0) { + meta.put(line.substring(1, eq), line.substring(eq + 1)); + } + } else if (!line.isBlank()) { + int second = line.indexOf(' ', line.indexOf(' ') + 1); + goldenChunks.put(line.substring(0, second), line); + } + } + + String expectedSeed = String.valueOf(level.getSeed()); + String expectedDim = engine.getDimension().getLoadKey(); + if (!expectedSeed.equals(meta.get("seed")) || !expectedDim.equals(meta.get("dim"))) { + fail("Golden file is for dim=" + meta.get("dim") + " seed=" + meta.get("seed") + + " but this world is dim=" + expectedDim + " seed=" + expectedSeed + ". Aborting."); + return; + } + String mc = ModdedEngineBootstrap.loader().minecraftVersion(); + if (!mc.equals(meta.get("mc"))) { + ok("Golden was captured on mc=" + meta.get("mc") + ", running mc=" + mc + ". Diffs may be version-induced."); + } + + List body = orderedBody(lines); + List mismatches = new ArrayList<>(); + for (String line : body) { + int second = line.indexOf(' ', line.indexOf(' ') + 1); + String key = line.substring(0, second); + String golden = goldenChunks.get(key); + if (!line.equals(golden)) { + mismatches.add(key + (golden == null ? " (missing in golden)" : "")); + } + } + + String combined = combinedHash(body); + if (mismatches.isEmpty()) { + ok("GOLDEN MATCH: " + body.size() + "/" + goldenChunks.size() + " chunks, combined=" + shortHash(combined)); + LOGGER.info("goldenhash MATCH: {} combined={}", goldenFile.getName(), combined); + return; + } + + fail("GOLDEN MISMATCH: " + mismatches.size() + "/" + body.size() + " chunks differ."); + for (int i = 0; i < Math.min(MAX_REPORTED_MISMATCHES, mismatches.size()); i++) { + fail(" chunk " + mismatches.get(i)); + } + if (mismatches.size() > MAX_REPORTED_MISMATCHES) { + fail(" ... and " + (mismatches.size() - MAX_REPORTED_MISMATCHES) + " more"); + } + + File current = new File(goldenFile.getParentFile(), goldenFile.getName() + ".new"); + List out = new ArrayList<>(body); + out.add("#combined=" + combined); + Files.write(current.toPath(), out, StandardCharsets.UTF_8); + ok("Current hashes written to " + current.getName()); + LOGGER.info("goldenhash MISMATCH: {}/{} -> {}", mismatches.size(), body.size(), current.getAbsolutePath()); + diagnose(mismatches.getFirst()); + } + + private void diagnose(String mismatchKey) { + try { + String[] parts = mismatchKey.trim().split(" "); + int chunkX = Integer.parseInt(parts[0]); + int chunkZ = Integer.parseInt(parts[1]); + int height = engine.getMaxHeight() - engine.getMinHeight(); + PlatformBlockState air = IrisPlatforms.get().registries().air(); + + ModdedBlockBuffer first = new ModdedBlockBuffer(height, air); + Hunk firstBiomes = Hunk.newArrayHunk(16, height, 16); + engine.generate(chunkX << 4, chunkZ << 4, first, firstBiomes, false); + ModdedBlockBuffer second = new ModdedBlockBuffer(height, air); + Hunk secondBiomes = Hunk.newArrayHunk(16, height, 16); + engine.generate(chunkX << 4, chunkZ << 4, second, secondBiomes, false); + + int diffs = 0; + for (int x = 0; x < 16 && diffs < 50; x++) { + for (int z = 0; z < 16 && diffs < 50; z++) { + for (int y = 0; y < height && diffs < 50; y++) { + if (!first.get(x, y, z).key().equals(second.get(x, y, z).key())) { + diffs++; + } + } + } + } + if (diffs == 0) { + ok("Repeat-gen STABLE for chunk " + chunkX + "," + chunkZ + " (nondeterminism is order/state-dependent, not per-call)"); + } else { + fail("Repeat-gen UNSTABLE for chunk " + chunkX + "," + chunkZ + " (" + diffs + "+ block diffs between back-to-back generations)"); + } + } catch (Throwable e) { + LOGGER.error("goldenhash diagnosis failed", e); + fail("Diagnosis failed: " + e.getMessage()); + } + } + + private static List orderedTargets(int centerX, int centerZ, int radius) { + List targets = new ArrayList<>(); + for (int dx = -radius; dx <= radius; dx++) { + for (int dz = -radius; dz <= radius; dz++) { + targets.add(new int[]{centerX + dx, centerZ + dz}); + } + } + targets.sort(Comparator.comparingInt((int[] t) -> { + int ox = t[0] - centerX; + int oz = t[1] - centerZ; + return ox * ox + oz * oz; + })); + return targets; + } + + private List orderedBody(Map lines) { + Map sorted = new TreeMap<>(lines); + return new ArrayList<>(sorted.values()); + } + + private String combinedHash(List body) { + MessageDigest digest = sha256(); + for (String line : body) { + digest.update((line + "\n").getBytes(StandardCharsets.UTF_8)); + } + return HexFormat.of().formatHex(digest.digest()); + } + + private void ok(String message) { + server.execute(() -> source.sendSuccess(() -> Component.literal(message), false)); + } + + private void fail(String message) { + server.execute(() -> source.sendFailure(Component.literal(message))); + } + + private static String shortHash(String hex) { + return hex.substring(0, 12); + } + + private static long chunkKey(int x, int z) { + return (((long) x) << 32) ^ (z & 0xFFFFFFFFL); + } + + private static MessageDigest sha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedObjectCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedObjectCommands.java new file mode 100644 index 000000000..7c094e7c4 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedObjectCommands.java @@ -0,0 +1,610 @@ +/* + * 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.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.modded.ModdedBlockState; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.volmlib.util.math.RNG; +import com.mojang.brigadier.arguments.IntegerArgumentType; +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.commands.SharedSuggestionProvider; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +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.state.BlockState; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.HitResult; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.function.Predicate; + +public final class ModdedObjectCommands { + private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + private static final Predicate GATE = Commands.hasPermission(Commands.LEVEL_GAMEMASTERS); + private static final long MAX_SAVE_VOLUME = 500000L; + private static final long MAX_AUTOSELECT_VOLUME = 100000L; + private static final double TARGET_RANGE = 256.0D; + + private static final SuggestionProvider OBJECT_KEYS = (CommandContext context, SuggestionsBuilder builder) -> { + try { + Engine engine = IrisModdedCommands.engineFor(context.getSource().getLevel()); + if (engine != null) { + return SharedSuggestionProvider.suggest(engine.getData().getObjectLoader().getPossibleKeys(), builder); + } + } catch (Throwable ignored) { + } + return builder.buildFuture(); + }; + + private static final SuggestionProvider ROTATIONS = (CommandContext context, SuggestionsBuilder builder) -> + SharedSuggestionProvider.suggest(List.of("0", "90", "180", "270"), builder); + + private ModdedObjectCommands() { + } + + private enum ResizeOp { + EXPAND, + CONTRACT, + SHIFT + } + + public static LiteralArgumentBuilder tree() { + ModdedObjectUndo.init(); + LiteralArgumentBuilder root = Commands.literal("object").requires(GATE); + + root.then(Commands.literal("wand") + .executes((CommandContext context) -> giveWand(context.getSource()))); + root.then(Commands.literal("dust") + .executes((CommandContext context) -> giveDust(context.getSource()))); + + root.then(Commands.literal("save") + .then(Commands.literal("overwrite") + .then(Commands.argument("name", StringArgumentType.greedyString()) + .executes((CommandContext context) -> save(context.getSource(), StringArgumentType.getString(context, "name"), true)))) + .then(Commands.argument("name", StringArgumentType.greedyString()) + .executes((CommandContext context) -> save(context.getSource(), StringArgumentType.getString(context, "name"), false)))); + + root.then(pasteTree()); + + root.then(resizeTree("expand", ResizeOp.EXPAND)); + root.then(resizeTree("contract", ResizeOp.CONTRACT)); + root.then(resizeTree("shift", ResizeOp.SHIFT)); + + root.then(Commands.literal("xpy") + .executes((CommandContext context) -> autoSelect(context.getSource(), false))); + root.then(Commands.literal("x+y") + .executes((CommandContext context) -> autoSelect(context.getSource(), false))); + root.then(Commands.literal("xay") + .executes((CommandContext context) -> autoSelect(context.getSource(), true))); + root.then(Commands.literal("x&y") + .executes((CommandContext context) -> autoSelect(context.getSource(), true))); + + root.then(positionTree("position1", true)); + root.then(positionTree("position2", false)); + + root.then(Commands.literal("analyze") + .then(Commands.argument("key", StringArgumentType.greedyString()).suggests(OBJECT_KEYS) + .executes((CommandContext context) -> analyze(context.getSource(), StringArgumentType.getString(context, "key"))))); + + root.then(Commands.literal("shrink") + .then(Commands.argument("key", StringArgumentType.greedyString()).suggests(OBJECT_KEYS) + .executes((CommandContext context) -> shrink(context.getSource(), StringArgumentType.getString(context, "key"))))); + + root.then(Commands.literal("undo") + .executes((CommandContext context) -> undo(context.getSource(), 1)) + .then(Commands.argument("amount", IntegerArgumentType.integer(1, 32)) + .executes((CommandContext context) -> undo(context.getSource(), IntegerArgumentType.getInteger(context, "amount"))))); + + root.then(bukkitOnly("we", "WorldEdit selection import requires the Bukkit plugin with WorldEdit installed.")); + root.then(bukkitOnly("studio", "The object studio world requires the Bukkit studio toolchain; it is not available on modded servers.")); + root.then(bukkitOnly("convert", "Schematic conversion (.schem -> .iob) requires the Bukkit plugin.")); + root.then(bukkitOnly("plausibilize", "Tree plausibilization requires the Bukkit plugin (its block-data tooling is not ported to modded yet).")); + + return root; + } + + private static LiteralArgumentBuilder pasteTree() { + LiteralArgumentBuilder paste = Commands.literal("paste"); + paste.then(Commands.argument("key", StringArgumentType.greedyString()).suggests(OBJECT_KEYS) + .executes((CommandContext context) -> paste(context.getSource(), StringArgumentType.getString(context, "key"), 0, null))); + paste.then(Commands.literal("rotate") + .then(Commands.argument("degrees", IntegerArgumentType.integer(-270, 270)).suggests(ROTATIONS) + .then(Commands.argument("key", StringArgumentType.greedyString()).suggests(OBJECT_KEYS) + .executes((CommandContext context) -> paste(context.getSource(), StringArgumentType.getString(context, "key"), + IntegerArgumentType.getInteger(context, "degrees"), null))))); + paste.then(Commands.literal("at") + .then(Commands.argument("x", IntegerArgumentType.integer()) + .then(Commands.argument("y", IntegerArgumentType.integer()) + .then(Commands.argument("z", IntegerArgumentType.integer()) + .then(Commands.literal("rotate") + .then(Commands.argument("degrees", IntegerArgumentType.integer(-270, 270)).suggests(ROTATIONS) + .then(Commands.argument("key", StringArgumentType.greedyString()).suggests(OBJECT_KEYS) + .executes((CommandContext context) -> paste(context.getSource(), StringArgumentType.getString(context, "key"), + IntegerArgumentType.getInteger(context, "degrees"), + new BlockPos(IntegerArgumentType.getInteger(context, "x"), IntegerArgumentType.getInteger(context, "y"), IntegerArgumentType.getInteger(context, "z"))))))) + .then(Commands.argument("key", StringArgumentType.greedyString()).suggests(OBJECT_KEYS) + .executes((CommandContext context) -> paste(context.getSource(), StringArgumentType.getString(context, "key"), 0, + new BlockPos(IntegerArgumentType.getInteger(context, "x"), IntegerArgumentType.getInteger(context, "y"), IntegerArgumentType.getInteger(context, "z"))))))))); + return paste; + } + + private static LiteralArgumentBuilder resizeTree(String name, ResizeOp op) { + return Commands.literal(name) + .executes((CommandContext context) -> resize(context.getSource(), 1, op)) + .then(Commands.argument("amount", IntegerArgumentType.integer(1, 256)) + .executes((CommandContext context) -> resize(context.getSource(), IntegerArgumentType.getInteger(context, "amount"), op))); + } + + private static LiteralArgumentBuilder positionTree(String name, boolean first) { + return Commands.literal(name) + .executes((CommandContext context) -> position(context.getSource(), first, false)) + .then(Commands.literal("look") + .executes((CommandContext context) -> position(context.getSource(), first, true))); + } + + private static LiteralArgumentBuilder bukkitOnly(String name, String message) { + return Commands.literal(name) + .executes((CommandContext context) -> { + IrisModdedCommands.fail(context.getSource(), message); + return 0; + }); + } + + public static int giveWand(CommandSourceStack source) { + ServerPlayer player = source.getPlayer(); + if (player == null) { + IrisModdedCommands.fail(source, "This command can only be used by players. (the wand is a physical item)"); + return 0; + } + if (!player.getInventory().add(ModdedWandService.createWand())) { + IrisModdedCommands.fail(source, "Your inventory is full."); + return 0; + } + IrisModdedCommands.ok(source, "Poof! Good luck building! (left click = corner 1, right click = corner 2)"); + return 1; + } + + private static int giveDust(CommandSourceStack source) { + ServerPlayer player = source.getPlayer(); + if (player == null) { + IrisModdedCommands.fail(source, "This command can only be used by players. (the dust is a physical item)"); + return 0; + } + if (!player.getInventory().add(ModdedWandService.createDust())) { + IrisModdedCommands.fail(source, "Your inventory is full."); + return 0; + } + IrisModdedCommands.ok(source, "Right click a block to reveal the object it belongs to."); + return 1; + } + + private static int save(CommandSourceStack source, String nameRaw, boolean overwrite) { + ServerPlayer player = source.getPlayer(); + if (player == null) { + IrisModdedCommands.fail(source, "This command can only be used by players. (saving captures your wand selection)"); + return 0; + } + ServerLevel level = player.level(); + Engine engine = IrisModdedCommands.engineFor(level); + if (engine == null) { + IrisModdedCommands.fail(source, "This dimension is not generated by Iris; objects save into the dimension's pack."); + return 0; + } + if (!ModdedWandService.isHoldingWand(player)) { + IrisModdedCommands.fail(source, "Hold your Iris wand. (/iris wand)"); + return 0; + } + ModdedWandService.Selection selection = ModdedWandService.selection(player); + if (selection == null) { + IrisModdedCommands.fail(source, "No area selected. Left/right click blocks with the wand first."); + return 0; + } + String name = nameRaw.trim().replace('\\', '/'); + if (name.isEmpty() || name.contains("..")) { + IrisModdedCommands.fail(source, "Invalid object name: " + nameRaw); + return 0; + } + BlockPos min = selection.min(); + BlockPos max = selection.max(); + int w = max.getX() - min.getX() + 1; + int h = max.getY() - min.getY() + 1; + int d = max.getZ() - min.getZ() + 1; + long volume = (long) w * h * d; + if (volume > MAX_SAVE_VOLUME) { + IrisModdedCommands.fail(source, "Selection too large: " + volume + " blocks (max " + MAX_SAVE_VOLUME + ")."); + return 0; + } + File file = new File(engine.getData().getDataFolder(), "objects" + File.separator + name.replace('/', File.separatorChar) + ".iob"); + if (file.exists() && !overwrite) { + IrisModdedCommands.fail(source, "File already exists. Use /iris object save overwrite " + name); + return 0; + } + int[] tilesSkipped = {0}; + IrisObject object = capture(level, min, max, w, h, d, tilesSkipped); + File parent = file.getParentFile(); + if (parent != null) { + parent.mkdirs(); + } + try { + object.write(file); + } catch (IOException e) { + LOGGER.error("Iris object save failed for {}", file.getAbsolutePath(), e); + 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)" : ""; + 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()); + return 1; + } + + private static IrisObject capture(ServerLevel level, BlockPos min, BlockPos max, int w, int h, int d, int[] tilesSkipped) { + IrisObject object = new IrisObject(w, h, d); + BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos(); + for (int x = min.getX(); x <= max.getX(); x++) { + for (int y = min.getY(); y <= max.getY(); y++) { + for (int z = min.getZ(); z <= max.getZ(); z++) { + BlockState state = level.getBlockState(cursor.set(x, y, z)); + if (state.is(Blocks.AIR)) { + continue; + } + if (state.hasBlockEntity()) { + tilesSkipped[0]++; + } + object.setUnsigned(x - min.getX(), y - min.getY(), z - min.getZ(), ModdedBlockState.of(state, null)); + } + } + } + return object; + } + + private static int paste(CommandSourceStack source, String keyRaw, int rotation, BlockPos at) { + ServerLevel level = source.getLevel(); + Engine engine = IrisModdedCommands.engineFor(level); + String key = keyRaw.trim(); + IrisObject object = null; + try { + object = IrisData.loadAnyObject(key, engine == null ? null : engine.getData()); + } catch (Throwable e) { + LOGGER.error("Iris object load failed for {}", key, e); + } + if (object == null || object.getBlocks().size() == 0) { + IrisModdedCommands.fail(source, "Unknown or empty object: " + key); + return 0; + } + + ServerPlayer player = source.getPlayer(); + BlockPos target = at; + if (target == null) { + if (player == null) { + IrisModdedCommands.fail(source, "Console must specify coordinates: /iris object paste at [rotate ] " + key); + return 0; + } + HitResult hit = player.pick(TARGET_RANGE, 1.0F, false); + if (hit.getType() != HitResult.Type.BLOCK || !(hit instanceof BlockHitResult blockHit)) { + IrisModdedCommands.fail(source, "You are not looking at a block within " + (int) TARGET_RANGE + " blocks."); + return 0; + } + target = blockHit.getBlockPos().above(); + } + + IrisObjectPlacement placement = new IrisObjectPlacement(); + placement.setRotation(IrisObjectRotation.of(0, rotation, 0)); + ModdedObjectPlacer placer = new ModdedObjectPlacer(level); + try { + object.place(target.getX(), target.getY() + object.getCenter().getY(), target.getZ(), placer, placement, new RNG(), null); + } catch (Throwable e) { + LOGGER.error("Iris paste failed for {}", key, e); + ModdedObjectUndo.record(player == null ? ModdedObjectUndo.CONSOLE : player.getUUID(), level, placer.undoSnapshot()); + IrisModdedCommands.fail(source, "Paste failed: " + e.getClass().getSimpleName() + " (partial changes recorded for undo)"); + return 0; + } + 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" : ""; + 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()); + return placer.writes() > 0 ? 1 : 0; + } + + private static int resize(CommandSourceStack source, int amount, ResizeOp op) { + ServerPlayer player = source.getPlayer(); + if (player == null) { + IrisModdedCommands.fail(source, "This command can only be used by players."); + return 0; + } + if (!ModdedWandService.isHoldingWand(player)) { + IrisModdedCommands.fail(source, "Hold your Iris wand. (/iris wand)"); + return 0; + } + ModdedWandService.Selection selection = ModdedWandService.selection(player); + if (selection == null) { + IrisModdedCommands.fail(source, "No area selected."); + return 0; + } + Direction direction = Direction.getApproximateNearest(player.getLookAngle()); + int[] mins = {selection.min().getX(), selection.min().getY(), selection.min().getZ()}; + int[] maxs = {selection.max().getX(), selection.max().getY(), selection.max().getZ()}; + int axis = switch (direction.getAxis()) { + case X -> 0; + case Y -> 1; + case Z -> 2; + }; + int step = direction.getAxisDirection().getStep(); + switch (op) { + case EXPAND -> { + if (step > 0) { + maxs[axis] += amount; + } else { + mins[axis] -= amount; + } + } + case CONTRACT -> { + if (step > 0) { + maxs[axis] = Math.max(mins[axis], maxs[axis] - amount); + } else { + mins[axis] = Math.min(maxs[axis], mins[axis] + amount); + } + } + case SHIFT -> { + mins[axis] += step * amount; + maxs[axis] += step * amount; + } + } + BlockPos first = new BlockPos(mins[0], mins[1], mins[2]); + BlockPos second = new BlockPos(maxs[0], maxs[1], maxs[2]); + ModdedWandService.setSelection(player, first, second); + IrisModdedCommands.ok(source, op.name().toLowerCase(Locale.ROOT) + " " + amount + " " + direction.getName() + + ": " + describe(first, second)); + return 1; + } + + private static int position(CommandSourceStack source, boolean first, boolean look) { + ServerPlayer player = source.getPlayer(); + if (player == null) { + IrisModdedCommands.fail(source, "This command can only be used by players."); + return 0; + } + if (!ModdedWandService.isHoldingWand(player)) { + IrisModdedCommands.fail(source, "Ready your wand. (/iris wand)"); + return 0; + } + BlockPos pos; + if (look) { + HitResult hit = player.pick(TARGET_RANGE, 1.0F, false); + if (hit.getType() != HitResult.Type.BLOCK || !(hit instanceof BlockHitResult blockHit)) { + IrisModdedCommands.fail(source, "You are not looking at a block."); + return 0; + } + pos = blockHit.getBlockPos(); + } else { + pos = player.blockPosition().below(); + } + ModdedWandService.Selection selection = ModdedWandService.selection(player); + BlockPos other = selection == null ? null : (first ? selection.second() : selection.first()); + BlockPos fallback = other == null ? pos : other; + if (first) { + ModdedWandService.setSelection(player, pos, fallback); + } else { + ModdedWandService.setSelection(player, fallback, pos); + } + IrisModdedCommands.ok(source, "Position " + (first ? 1 : 2) + " set to " + pos.getX() + " " + pos.getY() + " " + pos.getZ()); + return 1; + } + + private static int autoSelect(CommandSourceStack source, boolean down) { + ServerPlayer player = source.getPlayer(); + if (player == null) { + IrisModdedCommands.fail(source, "This command can only be used by players."); + return 0; + } + if (!ModdedWandService.isHoldingWand(player)) { + IrisModdedCommands.fail(source, "Hold your wand!"); + return 0; + } + ModdedWandService.Selection selection = ModdedWandService.selection(player); + if (selection == null) { + IrisModdedCommands.fail(source, "No area selected."); + return 0; + } + ServerLevel level = player.level(); + BlockPos min = selection.min(); + BlockPos max = selection.max(); + long volume = (long) (max.getX() - min.getX() + 1) * (max.getY() - min.getY() + 1) * (max.getZ() - min.getZ() + 1); + if (volume > MAX_AUTOSELECT_VOLUME) { + IrisModdedCommands.fail(source, "Selection too large for auto-select: " + volume + " blocks (max " + MAX_AUTOSELECT_VOLUME + ")."); + return 0; + } + int levelMinY = level.getMinY(); + int levelMaxY = levelMinY + level.getHeight() - 1; + + int topMinY = min.getY(); + int topMaxY = max.getY(); + while (topMaxY < levelMaxY && !boxOnlyAir(level, min.getX(), topMinY, min.getZ(), max.getX(), topMaxY, max.getZ())) { + topMinY++; + topMaxY++; + } + topMaxY--; + + int bottomY = min.getY(); + if (down) { + int lowMinY = min.getY(); + int lowMaxY = max.getY(); + while (lowMinY > levelMinY && !boxOnlyAir(level, min.getX(), lowMinY, min.getZ(), max.getX(), lowMaxY, max.getZ())) { + lowMinY--; + lowMaxY--; + } + bottomY = lowMinY + 1; + } + + int minX = min.getX(); + int maxX = max.getX(); + int minZ = min.getZ(); + int maxZ = max.getZ(); + while (minX < maxX && boxOnlyAir(level, minX, bottomY, minZ, minX, topMaxY, maxZ)) { + minX++; + } + while (maxX > minX && boxOnlyAir(level, maxX, bottomY, minZ, maxX, topMaxY, maxZ)) { + maxX--; + } + while (minZ < maxZ && boxOnlyAir(level, minX, bottomY, minZ, maxX, topMaxY, minZ)) { + minZ++; + } + while (maxZ > minZ && boxOnlyAir(level, minX, bottomY, maxZ, maxX, topMaxY, maxZ)) { + maxZ--; + } + + BlockPos first = new BlockPos(minX, bottomY, minZ); + BlockPos second = new BlockPos(maxX, topMaxY, maxZ); + ModdedWandService.setSelection(player, first, second); + IrisModdedCommands.ok(source, "Auto-select complete: " + describe(first, second)); + return 1; + } + + private static boolean boxOnlyAir(ServerLevel level, int minX, int minY, int minZ, int maxX, int maxY, int maxZ) { + BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos(); + for (int x = minX; x <= maxX; x++) { + for (int y = minY; y <= maxY; y++) { + for (int z = minZ; z <= maxZ; z++) { + if (!level.getBlockState(cursor.set(x, y, z)).isAir()) { + return false; + } + } + } + } + return true; + } + + private static int analyze(CommandSourceStack source, String keyRaw) { + ServerLevel level = source.getLevel(); + Engine engine = IrisModdedCommands.engineFor(level); + String key = keyRaw.trim(); + IrisObject object = null; + try { + object = IrisData.loadAnyObject(key, engine == null ? null : engine.getData()); + } catch (Throwable e) { + LOGGER.error("Iris object load failed for {}", key, e); + } + if (object == null) { + IrisModdedCommands.fail(source, "Unknown object: " + key); + return 0; + } + IrisModdedCommands.ok(source, "Object Size: " + object.getW() + " * " + object.getH() + " * " + object.getD()); + IrisModdedCommands.ok(source, "Blocks Used: " + object.getBlocks().size()); + Map counts = new HashMap<>(); + Iterator values = object.getBlocks().values(); + while (values.hasNext()) { + PlatformBlockState state = values.next(); + counts.merge(state.key(), 1, Integer::sum); + } + List> sorted = new ArrayList<>(counts.entrySet()); + sorted.sort(Comparator.comparingInt((Map.Entry entry) -> entry.getValue()).reversed()); + IrisModdedCommands.ok(source, "== Blocks in object =="); + int shown = 0; + for (Map.Entry entry : sorted) { + IrisModdedCommands.ok(source, " - " + entry.getKey() + " * " + entry.getValue()); + shown++; + if (shown >= 10) { + int remaining = sorted.size() - shown; + if (remaining > 0) { + IrisModdedCommands.ok(source, " + " + remaining + " other block state(s)"); + } + break; + } + } + return 1; + } + + private static int shrink(CommandSourceStack source, String keyRaw) { + ServerLevel level = source.getLevel(); + Engine engine = IrisModdedCommands.engineFor(level); + String key = keyRaw.trim(); + IrisObject object = null; + try { + object = IrisData.loadAnyObject(key, engine == null ? null : engine.getData()); + } catch (Throwable e) { + LOGGER.error("Iris object load failed for {}", key, e); + } + if (object == null) { + IrisModdedCommands.fail(source, "Unknown object: " + key); + return 0; + } + IrisModdedCommands.ok(source, "Current Object Size: " + object.getW() + " * " + object.getH() + " * " + object.getD()); + object.shrinkwrap(); + IrisModdedCommands.ok(source, "New Object Size: " + object.getW() + " * " + object.getH() + " * " + object.getD()); + File file = object.getLoadFile(); + if (file == null) { + IrisModdedCommands.fail(source, "Object has no load file; cannot persist the shrink."); + return 0; + } + try { + object.write(file); + } catch (IOException e) { + LOGGER.error("Iris object shrink save failed for {}", file.getAbsolutePath(), e); + IrisModdedCommands.fail(source, "Failed to save object " + file.getName() + ": " + e.getMessage()); + return 0; + } + return 1; + } + + private static int undo(CommandSourceStack source, int amount) { + ServerPlayer player = source.getPlayer(); + UUID owner = player == null ? ModdedObjectUndo.CONSOLE : player.getUUID(); + int available = ModdedObjectUndo.size(owner); + if (available == 0) { + IrisModdedCommands.fail(source, "Nothing to undo."); + return 0; + } + int reverted = ModdedObjectUndo.undo(owner, Math.min(amount, available)); + IrisModdedCommands.ok(source, "Reverted " + reverted + " paste(s)!"); + return 1; + } + + private static String describe(BlockPos first, BlockPos second) { + return "(" + first.getX() + "," + first.getY() + "," + first.getZ() + ") -> (" + + second.getX() + "," + second.getY() + "," + second.getZ() + ")"; + } +} 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 new file mode 100644 index 000000000..514b8fbd9 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedObjectPlacer.java @@ -0,0 +1,147 @@ +/* + * 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.engine.framework.Engine; +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.spi.PlatformBlockState; +import net.minecraft.core.BlockPos; +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.state.BlockState; +import net.minecraft.world.level.levelgen.Heightmap; + +import java.util.HashMap; +import java.util.Map; + +final class ModdedObjectPlacer implements IObjectPlacer { + private final ServerLevel level; + private final Map undo = new HashMap<>(); + private int writes = 0; + private int nonAirWrites = 0; + private int skippedTiles = 0; + + ModdedObjectPlacer(ServerLevel level) { + this.level = level; + } + + Map undoSnapshot() { + return undo; + } + + int writes() { + return writes; + } + + int nonAirWrites() { + return nonAirWrites; + } + + int skippedTiles() { + return skippedTiles; + } + + @Override + public int getHighest(int x, int z, IrisData data) { + return level.getHeight(Heightmap.Types.MOTION_BLOCKING, x, z) - 1; + } + + @Override + public int getHighest(int x, int z, IrisData data, boolean ignoreFluid) { + return level.getHeight(ignoreFluid ? Heightmap.Types.OCEAN_FLOOR : Heightmap.Types.MOTION_BLOCKING, x, z) - 1; + } + + @Override + public void set(int x, int y, int z, PlatformBlockState s) { + if (y <= level.getMinY() || y >= level.getMinY() + level.getHeight()) { + return; + } + BlockPos pos = new BlockPos(x, y, z); + BlockState current = level.getBlockState(pos); + if (current.is(Blocks.BEDROCK)) { + return; + } + BlockState target = (BlockState) s.nativeHandle(); + undo.putIfAbsent(pos, current); + level.setBlock(pos, target, Block.UPDATE_CLIENTS | Block.UPDATE_KNOWN_SHAPE); + writes++; + if (!target.isAir()) { + nonAirWrites++; + } + } + + @Override + public PlatformBlockState get(int x, int y, int z) { + return ModdedBlockState.of(level.getBlockState(new BlockPos(x, y, z)), null); + } + + @Override + public boolean isPreventingDecay() { + return false; + } + + @Override + public boolean isCarved(int x, int y, int z) { + return false; + } + + @Override + public boolean isSolid(int x, int y, int z) { + return ModdedBlockResolution.isSolid(level.getBlockState(new BlockPos(x, y, z))); + } + + @Override + public boolean isUnderwater(int x, int z) { + return false; + } + + @Override + public int getFluidHeight() { + return 63; + } + + @Override + public boolean isDebugSmartBore() { + return false; + } + + @Override + public void setTile(int xx, int yy, int zz, TileData tile) { + skippedTiles++; + } + + @Override + public void setData(int xx, int yy, int zz, T data) { + } + + @Override + public T getData(int xx, int yy, int zz, Class t) { + return null; + } + + @Override + public Engine getEngine() { + return null; + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedObjectUndo.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedObjectUndo.java new file mode 100644 index 000000000..123fb4f49 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedObjectUndo.java @@ -0,0 +1,105 @@ +/* + * 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 net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.state.BlockState; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; + +public final class ModdedObjectUndo { + private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + private static final int MAX_ENTRIES_PER_OWNER = 32; + private static final ConcurrentHashMap> UNDOS = new ConcurrentHashMap<>(); + private static final AtomicBoolean INITIALIZED = new AtomicBoolean(false); + public static final UUID CONSOLE = new UUID(0L, 0L); + + private ModdedObjectUndo() { + } + + private record Entry(ServerLevel level, Map blocks) { + } + + public static void init() { + if (INITIALIZED.compareAndSet(false, true)) { + LOGGER.info("Iris object undo service ready (bounded to {} paste(s) per player)", MAX_ENTRIES_PER_OWNER); + } + } + + public static void record(UUID owner, ServerLevel level, Map oldBlocks) { + if (oldBlocks == null || oldBlocks.isEmpty()) { + return; + } + Deque queue = UNDOS.computeIfAbsent(owner, (UUID key) -> new ArrayDeque<>()); + synchronized (queue) { + queue.addLast(new Entry(level, oldBlocks)); + while (queue.size() > MAX_ENTRIES_PER_OWNER) { + queue.pollFirst(); + } + } + } + + public static int size(UUID owner) { + Deque queue = UNDOS.get(owner); + if (queue == null) { + return 0; + } + synchronized (queue) { + return queue.size(); + } + } + + public static int undo(UUID owner, int amount) { + Deque queue = UNDOS.get(owner); + if (queue == null) { + return 0; + } + int reverted = 0; + while (reverted < amount) { + Entry entry; + synchronized (queue) { + entry = queue.pollLast(); + } + if (entry == null) { + break; + } + int writes = 0; + for (Map.Entry block : entry.blocks().entrySet()) { + entry.level().setBlock(block.getKey(), block.getValue(), Block.UPDATE_CLIENTS | Block.UPDATE_KNOWN_SHAPE); + writes++; + } + LOGGER.info("Iris object undo: reverted {} block(s) in {}", writes, entry.level().dimension().identifier()); + reverted++; + } + return reverted; + } + + public static void clearAll() { + UNDOS.clear(); + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPackCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPackCommands.java new file mode 100644 index 000000000..dcd3973d0 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPackCommands.java @@ -0,0 +1,191 @@ +/* + * 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.pack.PackValidationRegistry; +import art.arcane.iris.core.pack.PackValidationResult; +import art.arcane.iris.core.pack.PackValidator; +import art.arcane.iris.modded.ModdedEngineBootstrap; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.mojang.brigadier.context.CommandContext; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.server.MinecraftServer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Predicate; + +public final class ModdedPackCommands { + private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + private static final Predicate GATE = Commands.hasPermission(Commands.LEVEL_GAMEMASTERS); + + private ModdedPackCommands() { + } + + public static LiteralArgumentBuilder tree() { + LiteralArgumentBuilder root = Commands.literal("pack").requires(GATE); + + root.then(Commands.literal("validate") + .executes((CommandContext context) -> validate(context.getSource(), null)) + .then(Commands.argument("pack", StringArgumentType.word()).suggests(IrisModdedCommands.PACK_NAMES) + .executes((CommandContext context) -> validate(context.getSource(), StringArgumentType.getString(context, "pack"))))); + + root.then(Commands.literal("restore") + .then(Commands.argument("pack", StringArgumentType.word()).suggests(IrisModdedCommands.PACK_NAMES) + .executes((CommandContext context) -> restore(context.getSource(), StringArgumentType.getString(context, "pack"))))); + + root.then(Commands.literal("status") + .executes((CommandContext context) -> status(context.getSource(), null)) + .then(Commands.argument("pack", StringArgumentType.word()).suggests(IrisModdedCommands.PACK_NAMES) + .executes((CommandContext context) -> status(context.getSource(), StringArgumentType.getString(context, "pack"))))); + + return root; + } + + static File packsRoot() { + return ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen").resolve("packs").toFile(); + } + + private static int validate(CommandSourceStack source, String pack) { + File packsRoot = packsRoot(); + if (!packsRoot.isDirectory()) { + IrisModdedCommands.fail(source, "Packs folder not found: " + packsRoot.getAbsolutePath()); + return 0; + } + + List targets = new ArrayList<>(); + if (pack == null || pack.isBlank()) { + File[] dirs = packsRoot.listFiles(File::isDirectory); + if (dirs == null || dirs.length == 0) { + IrisModdedCommands.fail(source, "No packs to validate under " + packsRoot.getAbsolutePath()); + return 0; + } + for (File dir : dirs) { + targets.add(dir); + } + } else { + File target = new File(packsRoot, pack); + if (!target.isDirectory()) { + IrisModdedCommands.fail(source, "Pack '" + pack + "' not found under " + packsRoot.getAbsolutePath()); + return 0; + } + targets.add(target); + } + + MinecraftServer server = source.getServer(); + IrisModdedCommands.ok(source, "Validating " + targets.size() + " pack(s)..."); + Thread thread = new Thread(() -> { + int broken = 0; + for (File target : targets) { + try { + PackValidationResult result = PackValidator.validate(target); + PackValidationRegistry.publish(result); + if (!result.isLoadable()) { + broken++; + } + server.execute(() -> report(source, result)); + } catch (Throwable e) { + LOGGER.error("Iris pack validation failed for {}", target.getName(), e); + server.execute(() -> IrisModdedCommands.fail(source, "Validation of '" + target.getName() + "' failed: " + e.getMessage())); + broken++; + } + } + int brokenTotal = broken; + server.execute(() -> IrisModdedCommands.ok(source, "Validation complete. Broken packs: " + brokenTotal + "/" + targets.size())); + }, "Iris Pack Validator"); + thread.setDaemon(true); + thread.start(); + return 1; + } + + private static int restore(CommandSourceStack source, String pack) { + File packFolder = new File(packsRoot(), pack); + if (!packFolder.isDirectory()) { + IrisModdedCommands.fail(source, "Pack '" + pack + "' not found under " + packsRoot().getAbsolutePath()); + return 0; + } + int restored = PackValidator.restoreTrash(packFolder); + if (restored == 0) { + IrisModdedCommands.fail(source, "Nothing to restore for pack '" + pack + "'."); + return 0; + } + IrisModdedCommands.ok(source, "Restored " + restored + " file(s) from the most recent trash dump for pack '" + pack + "'."); + IrisModdedCommands.ok(source, "Re-run /iris pack validate " + pack + " to re-check."); + return 1; + } + + private static int status(CommandSourceStack source, String pack) { + if (pack == null || pack.isBlank()) { + Map snapshot = PackValidationRegistry.snapshot(); + if (snapshot.isEmpty()) { + IrisModdedCommands.fail(source, "No validation results recorded. Run /iris pack validate first."); + return 0; + } + for (Map.Entry entry : snapshot.entrySet()) { + PackValidationResult result = entry.getValue(); + String tag = result.isLoadable() ? "OK" : "BROKEN"; + IrisModdedCommands.ok(source, tag + " " + entry.getKey() + + " (blocking=" + result.getBlockingErrors().size() + + ", warnings=" + result.getWarnings().size() + + ", trashed=" + result.getRemovedUnusedFiles().size() + ")"); + } + return 1; + } + PackValidationResult result = PackValidationRegistry.get(pack); + if (result == null) { + IrisModdedCommands.fail(source, "No validation result for '" + pack + "'. Run /iris pack validate " + pack + "."); + return 0; + } + report(source, result); + return 1; + } + + private static void report(CommandSourceStack source, PackValidationResult result) { + if (result.isLoadable()) { + IrisModdedCommands.ok(source, "Pack '" + result.getPackName() + "' is loadable." + + " (warnings=" + result.getWarnings().size() + + ", trashed=" + result.getRemovedUnusedFiles().size() + ")"); + } else { + IrisModdedCommands.fail(source, "Pack '" + result.getPackName() + "' is BROKEN:"); + for (String reason : result.getBlockingErrors()) { + IrisModdedCommands.fail(source, " - " + reason); + } + } + int warningMax = Math.min(10, result.getWarnings().size()); + for (int i = 0; i < warningMax; i++) { + IrisModdedCommands.ok(source, " ! " + result.getWarnings().get(i)); + } + if (result.getWarnings().size() > warningMax) { + IrisModdedCommands.ok(source, " ... and " + (result.getWarnings().size() - warningMax) + " more warning(s)."); + } + int trashMax = Math.min(10, result.getRemovedUnusedFiles().size()); + for (int i = 0; i < trashMax; i++) { + IrisModdedCommands.ok(source, " ~ trashed " + result.getRemovedUnusedFiles().get(i)); + } + if (result.getRemovedUnusedFiles().size() > trashMax) { + IrisModdedCommands.ok(source, " ... and " + (result.getRemovedUnusedFiles().size() - trashMax) + " more trashed file(s)."); + } + } +} 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 new file mode 100644 index 000000000..0c4f5d5bf --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenJob.java @@ -0,0 +1,183 @@ +/* + * 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.pregenerator.IrisPregenerator; +import art.arcane.iris.core.pregenerator.PregenListener; +import art.arcane.iris.core.pregenerator.PregenTask; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.volmlib.util.format.Form; +import art.arcane.volmlib.util.math.Position2; +import net.minecraft.server.level.ServerLevel; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.atomic.AtomicReference; + +public final class ModdedPregenJob implements PregenListener { + private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + private static final AtomicReference ACTIVE = new AtomicReference<>(); + + private final String dimension; + private final IrisPregenerator pregenerator; + private volatile double chunksPerSecond; + private volatile long generated; + private volatile long totalChunks; + private volatile long eta; + private volatile long elapsed; + private volatile String method = "Modded"; + + private ModdedPregenJob(ServerLevel level, Engine engine, PregenTask task) { + this.dimension = level.dimension().identifier().toString(); + this.pregenerator = new IrisPregenerator(task, new ModdedPregenMethod(level, engine), this); + } + + public static boolean start(ServerLevel level, Engine engine, int radiusBlocks, int centerBlockX, int centerBlockZ) { + if (ACTIVE.get() != null) { + return false; + } + PregenTask task = PregenTask.builder() + .gui(false) + .center(new Position2(centerBlockX, centerBlockZ)) + .radiusX(radiusBlocks) + .radiusZ(radiusBlocks) + .build(); + ModdedPregenJob job = new ModdedPregenJob(level, engine, task); + if (!ACTIVE.compareAndSet(null, job)) { + return false; + } + Thread thread = new Thread(() -> { + try { + job.pregenerator.start(); + } catch (Throwable e) { + LOGGER.error("Iris pregen failed for {}", job.dimension, e); + } finally { + ACTIVE.compareAndSet(job, null); + } + }, "Iris Pregen"); + thread.setDaemon(true); + thread.start(); + return true; + } + + public static boolean stop() { + ModdedPregenJob job = ACTIVE.get(); + if (job == null) { + return false; + } + job.pregenerator.close(); + return true; + } + + public static Boolean pauseResume() { + ModdedPregenJob job = ACTIVE.get(); + if (job == null) { + return null; + } + if (job.pregenerator.paused()) { + job.pregenerator.resume(); + return Boolean.FALSE; + } + job.pregenerator.pause(); + return Boolean.TRUE; + } + + public static String status() { + ModdedPregenJob job = ACTIVE.get(); + if (job == null) { + return null; + } + long total = Math.max(1L, job.totalChunks); + double percent = ((double) job.generated / (double) total) * 100D; + return "Pregen " + job.dimension + ": " + + Form.f(job.generated) + "/" + Form.f(job.totalChunks) + + " (" + String.format("%.1f", percent) + "%), " + + Form.f((int) job.chunksPerSecond) + "/s" + + ", ETA " + Form.duration(job.eta, 2) + + ", elapsed " + Form.duration(job.elapsed, 2) + + ", method " + job.method + + (job.pregenerator.paused() ? ", PAUSED" : ""); + } + + @Override + public void onTick(double chunksPerSecond, double chunksPerMinute, double regionsPerMinute, double percent, long generated, long totalChunks, long chunksRemaining, long eta, long elapsed, String method, boolean cached) { + this.chunksPerSecond = chunksPerSecond; + this.generated = generated; + this.totalChunks = totalChunks; + this.eta = eta; + this.elapsed = elapsed; + this.method = method; + } + + @Override + public void onChunkGenerating(int x, int z) { + } + + @Override + public void onChunkGenerated(int x, int z, boolean cached) { + } + + @Override + public void onRegionGenerated(int x, int z) { + } + + @Override + public void onRegionGenerating(int x, int z) { + } + + @Override + public void onChunkCleaned(int x, int z) { + } + + @Override + public void onRegionSkipped(int x, int z) { + } + + @Override + public void onNetworkStarted(int x, int z) { + } + + @Override + public void onNetworkFailed(int x, int z) { + } + + @Override + public void onNetworkReclaim(int revert) { + } + + @Override + public void onNetworkGeneratedChunk(int x, int z) { + } + + @Override + public void onNetworkDownloaded(int x, int z) { + } + + @Override + public void onClose() { + } + + @Override + public void onSaving() { + } + + @Override + public void onChunkExistsInRegionGen(int x, int z) { + } +} diff --git a/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 new file mode 100644 index 000000000..601677b99 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenMethod.java @@ -0,0 +1,142 @@ +/* + * 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.pregenerator.PregenListener; +import art.arcane.iris.core.pregenerator.PregeneratorMethod; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import net.minecraft.server.level.ChunkResult; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.TicketType; +import net.minecraft.world.level.ChunkPos; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; + +public final class ModdedPregenMethod implements PregeneratorMethod { + private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + private static final TicketType PREGEN_TICKET = new TicketType(TicketType.NO_TIMEOUT, TicketType.FLAG_LOADING | TicketType.FLAG_KEEP_DIMENSION_ACTIVE); + + private final ServerLevel level; + private final Engine engine; + private final Semaphore semaphore; + private final int permits; + private final int timeoutSeconds; + + public ModdedPregenMethod(ServerLevel level, Engine engine) { + this.level = level; + this.engine = engine; + 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()); + } + + @Override + public void init() { + LOGGER.info("Iris modded pregen init: dim={} inFlightCap={} timeout={}s", + level.dimension().identifier(), permits, timeoutSeconds); + } + + @Override + public void close() { + semaphore.acquireUninterruptibly(permits); + saveLevel(); + } + + @Override + public void save() { + saveLevel(); + } + + private void saveLevel() { + level.getServer().execute(() -> level.save(null, false, false)); + } + + @Override + public boolean supportsRegions(int x, int z, PregenListener listener) { + return false; + } + + @Override + public String getMethod(int x, int z) { + return "Modded"; + } + + @Override + public boolean isAsyncChunkMode() { + return true; + } + + @Override + public void generateRegion(int x, int z, PregenListener listener) { + throw new UnsupportedOperationException(); + } + + @Override + public void generateChunk(int x, int z, PregenListener listener) { + listener.onChunkGenerating(x, z); + try { + semaphore.acquire(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + + ChunkPos pos = new ChunkPos(x, z); + CompletableFuture loadFuture = CompletableFuture + .supplyAsync(() -> level.getChunkSource().addTicketAndLoadWithRadius(PREGEN_TICKET, pos, 0), level.getServer()) + .thenCompose((CompletableFuture inner) -> inner); + + loadFuture.orTimeout(timeoutSeconds, TimeUnit.SECONDS).whenComplete((Object result, Throwable error) -> { + level.getServer().execute(() -> level.getChunkSource().removeTicketWithRadius(PREGEN_TICKET, pos, 0)); + try { + if (error != null) { + LOGGER.warn("Iris pregen chunk {},{} failed: {}", x, z, error.toString()); + return; + } + 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); + } finally { + semaphore.release(); + } + }); + } + + private void cleanupMantleChunk(int x, int z) { + try { + engine.getMantle().forceCleanupChunk(x, z); + } catch (Throwable ignored) { + } + } + + @Override + public Mantle getMantle() { + return engine.getMantle().getMantle(); + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedRegen.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedRegen.java new file mode 100644 index 000000000..f1de40d19 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedRegen.java @@ -0,0 +1,285 @@ +/* + * 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.engine.framework.Engine; +import art.arcane.iris.engine.mantle.EngineMantle; +import art.arcane.iris.modded.IrisModdedChunkGenerator; +import art.arcane.iris.modded.ModdedBlockBuffer; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformBiome; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.common.parallel.MultiBurst; +import art.arcane.iris.util.project.hunk.Hunk; +import art.arcane.volmlib.util.format.Form; +import art.arcane.volmlib.util.math.M; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Registry; +import net.minecraft.core.SectionPos; +import net.minecraft.core.registries.Registries; +import net.minecraft.network.chat.Component; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.level.ThreadedLevelLightEngine; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.EntityBlock; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.LevelChunk; +import net.minecraft.world.level.chunk.LevelChunkSection; +import net.minecraft.world.level.chunk.status.ChunkStatus; +import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.phys.AABB; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Semaphore; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +public final class ModdedRegen { + private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + private static final int APPLY_AHEAD = 8; + private static final AtomicBoolean ACTIVE = new AtomicBoolean(false); + + private final CommandSourceStack source; + private final MinecraftServer server; + private final ServerLevel level; + private final IrisModdedChunkGenerator generator; + private final Engine engine; + private final int centerX; + private final int centerZ; + private final int radius; + + private ModdedRegen(CommandSourceStack source, ServerLevel level, IrisModdedChunkGenerator generator, Engine engine, int centerX, int centerZ, int radius) { + this.source = source; + this.server = source.getServer(); + this.level = level; + this.generator = generator; + this.engine = engine; + this.centerX = centerX; + this.centerZ = centerZ; + this.radius = Math.max(0, radius); + } + + public static void start(CommandSourceStack source, ServerLevel level, IrisModdedChunkGenerator generator, Engine engine, ServerPlayer player, int radius) { + if (!ACTIVE.compareAndSet(false, true)) { + source.sendFailure(Component.literal("A regen is already running.")); + return; + } + int centerX = player.blockPosition().getX() >> 4; + int centerZ = player.blockPosition().getZ() >> 4; + ModdedRegen job = new ModdedRegen(source, level, generator, engine, centerX, centerZ, radius); + int chunks = (job.radius * 2 + 1) * (job.radius * 2 + 1); + job.ok("Regen started: " + chunks + " chunk(s) around " + centerX + "," + centerZ + ". Deleting and regenerating in place."); + LOGGER.info("Iris regen start: dim={} center={},{} radius={} chunks={}", + level.dimension().identifier(), centerX, centerZ, job.radius, chunks); + Thread thread = new Thread(job::run, "Iris Regenerate"); + thread.setDaemon(true); + thread.start(); + } + + private void run() { + long startedAt = M.ms(); + try { + resetMantleMargin(); + List targets = orderedTargets(centerX, centerZ, radius); + int applied = regenerate(targets); + ok("Regen finished: " + applied + "/" + targets.size() + " chunk(s) in " + Form.duration(M.ms() - startedAt, 2)); + LOGGER.info("Iris regen done: {}/{} chunks in {}ms", applied, targets.size(), M.ms() - startedAt); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (Throwable e) { + LOGGER.error("Iris regen failed", e); + fail("Regen failed: " + e); + } finally { + ACTIVE.set(false); + } + } + + private void resetMantleMargin() { + EngineMantle engineMantle = engine.getMantle(); + int margin = radius + Math.max(engineMantle.getRadius(), engineMantle.getRealRadius()) + 1; + for (int dx = -margin; dx <= margin; dx++) { + for (int dz = -margin; dz <= margin; dz++) { + engineMantle.getMantle().deleteChunk(centerX + dx, centerZ + dz); + } + } + } + + private int regenerate(List targets) throws InterruptedException { + Semaphore inFlight = new Semaphore(APPLY_AHEAD); + CountDownLatch allApplied = new CountDownLatch(targets.size()); + AtomicInteger completed = new AtomicInteger(); + AtomicInteger applied = new AtomicInteger(); + int total = targets.size(); + int stride = total <= 64 ? 1 : 32; + int height = engine.getMaxHeight() - engine.getMinHeight(); + PlatformBlockState air = IrisPlatforms.get().registries().air(); + + for (int[] target : targets) { + int chunkX = target[0]; + int chunkZ = target[1]; + inFlight.acquire(); + MultiBurst.burst.lazy(() -> { + long chunkStart = M.ms(); + ModdedBlockBuffer blocks = new ModdedBlockBuffer(height, air); + Hunk biomes = Hunk.newArrayHunk(16, height, 16); + try { + engine.generate(chunkX << 4, chunkZ << 4, blocks, biomes, false); + } catch (Throwable e) { + LOGGER.error("Iris regen chunk {},{} generation failed", chunkX, chunkZ, e); + fail("Chunk " + chunkX + "," + chunkZ + " generation FAILED: " + e.getClass().getSimpleName()); + completed.incrementAndGet(); + inFlight.release(); + allApplied.countDown(); + return; + } + server.execute(() -> { + boolean success = false; + try { + apply(chunkX, chunkZ, blocks, biomes); + success = true; + applied.incrementAndGet(); + } catch (Throwable e) { + LOGGER.error("Iris regen chunk {},{} apply failed", chunkX, chunkZ, e); + fail("Chunk " + chunkX + "," + chunkZ + " apply FAILED: " + e.getClass().getSimpleName()); + } finally { + int done = completed.incrementAndGet(); + if (success && (done % stride == 0 || done == total)) { + ok("Regen [" + done + "/" + total + "] chunk " + chunkX + "," + chunkZ + " in " + (M.ms() - chunkStart) + "ms"); + } + inFlight.release(); + allApplied.countDown(); + } + }); + }); + } + + allApplied.await(); + return applied.get(); + } + + private void apply(int chunkX, int chunkZ, ModdedBlockBuffer blocks, Hunk biomes) { + LevelChunk chunk = level.getChunk(chunkX, chunkZ); + ChunkPos pos = chunk.getPos(); + discardEntities(pos); + for (BlockPos blockEntityPos : new ArrayList<>(chunk.getBlockEntities().keySet())) { + chunk.removeBlockEntity(blockEntityPos); + } + + int dimMinY = engine.getMinHeight(); + int height = engine.getMaxHeight() - dimMinY; + int baseX = pos.getMinBlockX(); + int baseZ = pos.getMinBlockZ(); + BlockState airState = Blocks.AIR.defaultBlockState(); + ThreadedLevelLightEngine lightEngine = (ThreadedLevelLightEngine) level.getChunkSource().getLightEngine(); + List lightChecks = new ArrayList<>(); + + for (int i = 0; i < chunk.getSectionsCount(); i++) { + LevelChunkSection section = chunk.getSection(i); + int sectionBaseY = chunk.getSectionYFromSectionIndex(i) << 4; + section.acquire(); + try { + for (int y = 0; y < 16; y++) { + int worldY = sectionBaseY + y; + int bufferY = worldY - dimMinY; + boolean inRange = bufferY >= 0 && bufferY < height; + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + BlockState target = airState; + if (inRange && !blocks.isAir(x, bufferY, z)) { + target = (BlockState) blocks.getRaw(x, bufferY, z).nativeHandle(); + } + BlockState previous = section.setBlockState(x, y, z, target, false); + if (previous != target && (previous.getLightEmission() != target.getLightEmission() + || previous.getLightDampening() != target.getLightDampening() + || previous.propagatesSkylightDown() != target.propagatesSkylightDown())) { + lightChecks.add(new BlockPos(baseX + x, worldY, baseZ + z)); + } + if (target.hasBlockEntity() && target.getBlock() instanceof EntityBlock entityBlock) { + BlockEntity entity = entityBlock.newBlockEntity(new BlockPos(baseX + x, worldY, baseZ + z), target); + if (entity != null) { + chunk.setBlockEntity(entity); + } + } + } + } + } + } finally { + section.release(); + } + lightEngine.updateSectionStatus(SectionPos.of(pos, chunk.getSectionYFromSectionIndex(i)), section.hasOnlyAir()); + } + + Heightmap.primeHeightmaps(chunk, ChunkStatus.FULL.heightmapsAfter()); + Registry biomeRegistry = level.registryAccess().lookupOrThrow(Registries.BIOME); + chunk.fillBiomesFromNoise(generator.regenBiomeResolver(biomeRegistry, biomes, pos), level.getChunkSource().randomState().sampler()); + chunk.markUnsaved(); + + for (BlockPos check : lightChecks) { + lightEngine.checkBlock(check); + } + + for (ServerPlayer tracking : level.getChunkSource().chunkMap.getPlayers(pos, false)) { + tracking.connection.chunkSender.dropChunk(tracking, pos); + tracking.connection.chunkSender.markChunkPendingToSend(chunk); + } + } + + private void discardEntities(ChunkPos pos) { + AABB box = new AABB(pos.getMinBlockX(), level.getMinY(), pos.getMinBlockZ(), + pos.getMaxBlockX() + 1, level.getMaxY() + 1, pos.getMaxBlockZ() + 1); + for (Entity entity : level.getEntities((Entity) null, box, (Entity e) -> !(e instanceof ServerPlayer))) { + entity.discard(); + } + } + + private static List orderedTargets(int centerX, int centerZ, int radius) { + List targets = new ArrayList<>(); + for (int dx = -radius; dx <= radius; dx++) { + for (int dz = -radius; dz <= radius; dz++) { + targets.add(new int[]{centerX + dx, centerZ + dz}); + } + } + targets.sort(Comparator.comparingInt((int[] t) -> { + int ox = t[0] - centerX; + int oz = t[1] - centerZ; + return ox * ox + oz * oz; + })); + return targets; + } + + private void ok(String message) { + server.execute(() -> source.sendSuccess(() -> Component.literal(message), false)); + } + + private void fail(String message) { + server.execute(() -> source.sendFailure(Component.literal(message))); + } +} 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 new file mode 100644 index 000000000..eb7835b03 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStructureCommands.java @@ -0,0 +1,206 @@ +/* + * 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.structure.StructureIndexService; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.PlacedStructurePiece; +import art.arcane.iris.engine.framework.StructureAssembler; +import art.arcane.iris.engine.object.IrisObjectPlacement; +import art.arcane.iris.engine.object.IrisStructure; +import art.arcane.iris.engine.object.ObjectPlaceMode; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +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; +import net.minecraft.commands.SharedSuggestionProvider; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.function.Predicate; + +public final class ModdedStructureCommands { + private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + private static final Predicate GATE = Commands.hasPermission(Commands.LEVEL_GAMEMASTERS); + + private static final SuggestionProvider IRIS_STRUCTURE_KEYS = (CommandContext context, SuggestionsBuilder builder) -> suggestIrisStructureKeys(context, builder); + + private ModdedStructureCommands() { + } + + public static LiteralArgumentBuilder tree() { + LiteralArgumentBuilder root = Commands.literal("structure").requires(GATE); + + root.then(Commands.literal("list") + .executes((CommandContext context) -> list(context.getSource()))); + + root.then(Commands.literal("info") + .then(Commands.argument("key", StringArgumentType.greedyString()).suggests(IRIS_STRUCTURE_KEYS) + .executes((CommandContext context) -> info(context.getSource(), StringArgumentType.getString(context, "key"))))); + + root.then(Commands.literal("place") + .then(Commands.argument("key", StringArgumentType.greedyString()).suggests(IRIS_STRUCTURE_KEYS) + .executes((CommandContext context) -> place(context.getSource(), StringArgumentType.getString(context, "key"))))); + + root.then(message("import", "Structure import rebuilds vanilla & datapack structures as editable Iris resources through Bukkit/NMS template managers; run /iris structure import on a Bukkit server against this pack, then copy the pack folder over.")); + root.then(message("capture", "Structure capture generates each structure in a throwaway Bukkit scratch world to read its blocks; it requires the Bukkit plugin (v26 NMS binding).")); + root.then(message("verify", "Vanilla structure locate is meaningless here: Iris modded dimensions do not run vanilla structure placement (Iris places structures itself). Use /iris goto structure to locate Iris-placed structures.")); + + return root; + } + + private static LiteralArgumentBuilder message(String name, String text) { + return Commands.literal(name) + .executes((CommandContext context) -> { + IrisModdedCommands.fail(context.getSource(), text); + return 0; + }) + .then(Commands.argument("args", StringArgumentType.greedyString()) + .executes((CommandContext context) -> { + IrisModdedCommands.fail(context.getSource(), text); + return 0; + })); + } + + private static IrisData dataFor(CommandSourceStack source) { + Engine engine = IrisModdedCommands.engineFor(source.getLevel()); + if (engine == null) { + IrisModdedCommands.fail(source, "This dimension is not generated by Iris. Run this from an Iris dimension so the pack can be resolved."); + return null; + } + return engine.getData(); + } + + private static int list(CommandSourceStack source) { + IrisData data = dataFor(source); + if (data == null) { + return 0; + } + File file = StructureIndexService.write(data); + IrisModdedCommands.ok(source, "Wrote structure index: " + file.getPath()); + return 1; + } + + private static int info(CommandSourceStack source, String keyRaw) { + IrisData data = dataFor(source); + if (data == null) { + return 0; + } + String key = keyRaw.trim(); + IrisStructure structure = IrisData.loadAnyStructure(key, data); + if (structure == null) { + IrisModdedCommands.fail(source, "No iris structure '" + key + "' in this pack"); + return 0; + } + StructureAssembler assembler = new StructureAssembler(data, structure, 0, 64, 0); + KList pieces = assembler.assemble(new RNG(1234)); + if (pieces == null || pieces.isEmpty()) { + IrisModdedCommands.fail(source, "Structure '" + key + "' assembled 0 pieces (check startPool '" + structure.getStartPool() + "')"); + return 0; + } + int minX = Integer.MAX_VALUE; + int minZ = Integer.MAX_VALUE; + int maxX = Integer.MIN_VALUE; + int maxZ = Integer.MIN_VALUE; + for (PlacedStructurePiece piece : pieces) { + minX = Math.min(minX, piece.getMinX()); + minZ = Math.min(minZ, piece.getMinZ()); + maxX = Math.max(maxX, piece.getMaxX()); + maxZ = Math.max(maxZ, piece.getMaxZ()); + } + IrisModdedCommands.ok(source, "Structure '" + key + "': " + pieces.size() + " pieces, footprint " + + (maxX - minX + 1) + "x" + (maxZ - minZ + 1) + " blocks (sample seed 1234)"); + return 1; + } + + private static int place(CommandSourceStack source, String keyRaw) { + ServerPlayer player = source.getPlayer(); + if (player == null) { + IrisModdedCommands.fail(source, "This command can only be used by players (the structure is assembled at your position)."); + return 0; + } + ServerLevel level = source.getLevel(); + IrisData data = dataFor(source); + if (data == null) { + return 0; + } + String key = keyRaw.trim(); + IrisStructure structure = IrisData.loadAnyStructure(key, data); + if (structure == null) { + IrisModdedCommands.fail(source, "No iris structure '" + key + "' in this pack"); + return 0; + } + int originX = player.blockPosition().getX(); + int originY = player.blockPosition().getY(); + int originZ = player.blockPosition().getZ(); + StructureAssembler assembler = new StructureAssembler(data, structure, originX, originY, originZ); + RNG rng = new RNG((long) originX * 341873128712L + originZ); + KList pieces = assembler.assemble(rng); + if (pieces == null || pieces.isEmpty()) { + IrisModdedCommands.fail(source, "Structure '" + key + "' assembled 0 pieces"); + return 0; + } + ModdedObjectPlacer placer = new ModdedObjectPlacer(level); + UUID owner = player.getUUID(); + try { + for (PlacedStructurePiece piece : pieces) { + IrisObjectPlacement config = new IrisObjectPlacement(); + config.setMode(ObjectPlaceMode.STRUCTURE_PIECE); + config.setRotation(piece.getRotation()); + config.getPlace().add(piece.getObject().getLoadKey()); + if (!structure.getEdit().isEmpty()) { + config.setEdit(structure.getEdit()); + } + piece.getObject().place(piece.getX(), piece.getY(), piece.getZ(), placer, config, rng, null, null, data); + } + } catch (Throwable e) { + LOGGER.error("Iris structure place failed for {}", key, e); + ModdedObjectUndo.record(owner, level, placer.undoSnapshot()); + IrisModdedCommands.fail(source, "Place failed: " + e.getClass().getSimpleName() + " (partial changes recorded for undo)"); + return 0; + } + ModdedObjectUndo.record(owner, level, placer.undoSnapshot()); + String tileNote = placer.skippedTiles() > 0 ? ", " + placer.skippedTiles() + " tile state(s) skipped" : ""; + IrisModdedCommands.ok(source, "Placed '" + key + "' (" + pieces.size() + " pieces, " + placer.writes() + " write(s)" + tileNote + ") at your location. /iris object undo to revert."); + return 1; + } + + private static CompletableFuture suggestIrisStructureKeys(CommandContext context, SuggestionsBuilder builder) { + try { + Engine engine = IrisModdedCommands.engineFor(context.getSource().getLevel()); + if (engine != null && engine.getData().getStructureLoader() != null) { + return SharedSuggestionProvider.suggest(engine.getData().getStructureLoader().getPossibleKeys(), builder); + } + } catch (Throwable ignored) { + } + return builder.buildFuture(); + } +} 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 new file mode 100644 index 000000000..e01061a89 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java @@ -0,0 +1,412 @@ +/* + * 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.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.IrisObjectPlacement; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.object.IrisSpawner; +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.io.IO; +import art.arcane.volmlib.util.json.JSONObject; +import art.arcane.volmlib.util.math.M; +import art.arcane.volmlib.util.math.Spiraler; +import com.mojang.brigadier.arguments.IntegerArgumentType; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.mojang.brigadier.context.CommandContext; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.zeroturnaround.zip.ZipUtil; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Predicate; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +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 String DEFAULT_TEMPLATE = "example"; + + private ModdedStudioCommands() { + } + + public static LiteralArgumentBuilder tree() { + LiteralArgumentBuilder root = Commands.literal("studio").requires(GATE); + + root.then(Commands.literal("create") + .then(Commands.argument("name", StringArgumentType.word()) + .executes((CommandContext context) -> create(context.getSource(), StringArgumentType.getString(context, "name"), DEFAULT_TEMPLATE)) + .then(Commands.argument("template", StringArgumentType.word()).suggests(IrisModdedCommands.PACK_NAMES) + .executes((CommandContext context) -> create(context.getSource(), StringArgumentType.getString(context, "name"), StringArgumentType.getString(context, "template")))))); + + root.then(Commands.literal("package") + .executes((CommandContext context) -> pkg(context.getSource(), null)) + .then(Commands.argument("pack", StringArgumentType.word()).suggests(IrisModdedCommands.PACK_NAMES) + .executes((CommandContext context) -> pkg(context.getSource(), StringArgumentType.getString(context, "pack"))))); + + root.then(Commands.literal("version") + .executes((CommandContext context) -> version(context.getSource(), null)) + .then(Commands.argument("pack", StringArgumentType.word()).suggests(IrisModdedCommands.PACK_NAMES) + .executes((CommandContext context) -> version(context.getSource(), StringArgumentType.getString(context, "pack"))))); + + root.then(Commands.literal("regions") + .executes((CommandContext context) -> regions(context.getSource(), 500)) + .then(Commands.argument("radius", IntegerArgumentType.integer(8, 1000)) + .executes((CommandContext context) -> regions(context.getSource(), IntegerArgumentType.getInteger(context, "radius"))))); + + root.then(message("open", "Studio worlds are temporary Bukkit worlds opened by the Bukkit studio toolchain; modded servers cannot open ad-hoc runtime dimensions. Edit the pack under config/irisworldgen/packs/ and create a fresh world (or /iris regen) to see changes.")); + root.then(message("close", "There are no studio worlds on modded servers (/iris studio open is Bukkit-only), so there is nothing to close.")); + root.then(message("tpstudio", "There are no studio worlds on modded servers to teleport to; /iris studio open is Bukkit-only.")); + 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("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("noise", "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("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.")); + root.then(message("objects", "The chunk object report reads Bukkit chunk data and is not ported to modded servers.")); + + return root; + } + + private static LiteralArgumentBuilder message(String name, String text) { + return Commands.literal(name) + .executes((CommandContext context) -> { + IrisModdedCommands.fail(context.getSource(), text); + return 0; + }) + .then(Commands.argument("args", StringArgumentType.greedyString()) + .executes((CommandContext context) -> { + IrisModdedCommands.fail(context.getSource(), text); + return 0; + })); + } + + private static File resolvePack(CommandSourceStack source, String pack) { + String name = pack; + if (name == null || name.isBlank()) { + Engine engine = IrisModdedCommands.engineFor(source.getLevel()); + if (engine == null || engine.getDimension() == null) { + IrisModdedCommands.fail(source, "This dimension is not generated by Iris; specify a pack name explicitly."); + return null; + } + name = engine.getDimension().getLoadKey(); + } + File folder = new File(ModdedPackCommands.packsRoot(), name); + if (!folder.isDirectory() || !new File(folder, "dimensions").isDirectory()) { + IrisModdedCommands.fail(source, "Pack '" + name + "' not found under " + ModdedPackCommands.packsRoot().getAbsolutePath()); + return null; + } + return folder; + } + + private static int version(CommandSourceStack source, String pack) { + File folder = resolvePack(source, pack); + if (folder == null) { + 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"); + return 0; + } + IrisModdedCommands.ok(source, "The \"" + dimension.getName() + "\" pack has version: " + dimension.getVersion()); + return 1; + } + + private static int create(CommandSourceStack source, String nameRaw, String template) { + String name = nameRaw.toLowerCase(Locale.ROOT); + if (!PROJECT_NAME.matcher(name).matches()) { + IrisModdedCommands.fail(source, "Invalid project name '" + nameRaw + "' (allowed: a-z, 0-9, _ and -)"); + return 0; + } + File packsRoot = ModdedPackCommands.packsRoot(); + File target = new File(packsRoot, name); + if (target.exists()) { + IrisModdedCommands.fail(source, "Pack '" + name + "' already exists at " + target.getAbsolutePath()); + return 0; + } + MinecraftServer server = source.getServer(); + IrisModdedCommands.ok(source, "Creating project '" + name + "' from template '" + template + "'..."); + Thread thread = new Thread(() -> { + try { + File templateFolder = new File(packsRoot, template); + if (!new File(templateFolder, "dimensions/" + template + ".json").isFile()) { + server.execute(() -> IrisModdedCommands.ok(source, "Template '" + template + "' is not installed; downloading IrisDimensions/" + template + "...")); + boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), template, "master", + (String line) -> server.execute(() -> IrisModdedCommands.ok(source, line))); + if (!installed || !new File(templateFolder, "dimensions/" + template + ".json").isFile()) { + server.execute(() -> IrisModdedCommands.fail(source, "Template '" + template + "' could not be downloaded; install a pack with dimensions/" + template + ".json first.")); + return; + } + } + copyProject(templateFolder, target, template, name); + server.execute(() -> { + IrisModdedCommands.ok(source, "Created project '" + name + "' at " + target.getAbsolutePath()); + IrisModdedCommands.ok(source, "Edit dimensions/" + name + ".json and the rest of the pack, then create a world with it. VSCode workspaces are generated by the Bukkit studio toolchain only."); + }); + } catch (Throwable e) { + LOGGER.error("Iris studio create failed for {}", name, e); + server.execute(() -> IrisModdedCommands.fail(source, "Project creation failed: " + e.getClass().getSimpleName() + (e.getMessage() == null ? "" : " - " + e.getMessage()))); + } + }, "Iris Studio Create"); + thread.setDaemon(true); + thread.start(); + return 1; + } + + private static void copyProject(File templateFolder, File target, String template, String name) throws IOException { + Path source = templateFolder.toPath(); + try (Stream walk = Files.walk(source)) { + for (Path path : walk.sorted(Comparator.naturalOrder()).toList()) { + String relative = source.relativize(path).toString(); + if (relative.isEmpty() || relative.equals(".git") || relative.startsWith(".git" + File.separator) || relative.endsWith(".code-workspace")) { + continue; + } + Path destination = target.toPath().resolve(relative); + if (Files.isDirectory(path)) { + Files.createDirectories(destination); + } else { + Files.createDirectories(destination.getParent()); + Files.copy(path, destination, StandardCopyOption.REPLACE_EXISTING); + } + } + } + + File oldDimension = new File(target, "dimensions/" + template + ".json"); + File newDimension = new File(target, "dimensions/" + name + ".json"); + if (oldDimension.isFile()) { + Files.copy(oldDimension.toPath(), newDimension.toPath(), StandardCopyOption.REPLACE_EXISTING); + Files.delete(oldDimension.toPath()); + } + if (newDimension.isFile()) { + JSONObject json = new JSONObject(IO.readAll(newDimension)); + if (json.has("name")) { + json.put("name", Form.capitalizeWords(name.replaceAll("\\Q-\\E", " "))); + IO.writeAll(newDimension, json.toString(4)); + } + } + } + + private static int pkg(CommandSourceStack source, String pack) { + File folder = resolvePack(source, pack); + if (folder == null) { + return 0; + } + MinecraftServer server = source.getServer(); + String dimKey = folder.getName(); + IrisModdedCommands.ok(source, "Packaging dimension '" + dimKey + "'..."); + Thread thread = new Thread(() -> { + try { + File result = compilePackage(folder, dimKey); + server.execute(() -> IrisModdedCommands.ok(source, "Package compiled: " + result.getAbsolutePath())); + } catch (Throwable e) { + LOGGER.error("Iris package failed for {}", dimKey, e); + server.execute(() -> IrisModdedCommands.fail(source, "Packaging failed: " + e.getClass().getSimpleName() + (e.getMessage() == null ? "" : " - " + e.getMessage()))); + } + }, "Iris Studio Package"); + thread.setDaemon(true); + thread.start(); + return 1; + } + + private static File compilePackage(File packFolder, String dimKey) throws IOException { + IrisData dm = IrisData.get(packFolder); + IrisDimension dimension = dm.getDimensionLoader().load(dimKey); + if (dimension == null) { + throw new IOException("Pack '" + dimKey + "' has no dimensions/" + dimKey + ".json"); + } + File exports = ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen").resolve("exports").toFile(); + File folder = new File(exports, dimension.getLoadKey()); + IO.delete(folder); + folder.mkdirs(); + + LinkedHashSet regionKeys = new LinkedHashSet<>(); + LinkedHashSet biomeKeys = new LinkedHashSet<>(); + LinkedHashSet entityKeys = new LinkedHashSet<>(); + LinkedHashSet spawnerKeys = new LinkedHashSet<>(); + LinkedHashSet generatorKeys = new LinkedHashSet<>(); + LinkedHashSet lootKeys = new LinkedHashSet<>(); + LinkedHashSet objectKeys = new LinkedHashSet<>(); + + regionKeys.addAll(dimension.getRegions()); + lootKeys.addAll(dimension.getLoot().getTables()); + spawnerKeys.addAll(dimension.getEntitySpawners()); + + for (String regionKey : regionKeys) { + IrisRegion region = dm.getRegionLoader().load(regionKey); + if (region == null) { + continue; + } + region.getAllBiomes(() -> dm).forEach((IrisBiome biome) -> { + if (biome != null && biome.getLoadKey() != null) { + biomeKeys.add(biome.getLoadKey()); + } + }); + lootKeys.addAll(region.getLoot().getTables()); + spawnerKeys.addAll(region.getEntitySpawners()); + } + for (String biomeKey : biomeKeys) { + IrisBiome biome = dm.getBiomeLoader().load(biomeKey); + if (biome == null) { + continue; + } + biome.getGenerators().forEach((IrisBiomeGeneratorLink link) -> generatorKeys.add(link.getGenerator())); + lootKeys.addAll(biome.getLoot().getTables()); + spawnerKeys.addAll(biome.getEntitySpawners()); + for (IrisObjectPlacement placement : biome.getObjects()) { + objectKeys.addAll(placement.getPlace()); + } + } + for (String spawnerKey : spawnerKeys) { + IrisSpawner spawner = dm.getSpawnerLoader().load(spawnerKey); + if (spawner == null) { + continue; + } + spawner.getSpawns().forEach((IrisEntitySpawn spawn) -> entityKeys.add(spawn.getEntity())); + } + + StringBuilder hashes = new StringBuilder(); + for (String objectKey : objectKeys) { + try { + File objectFile = dm.getObjectLoader().findFile(objectKey); + IO.copyFile(objectFile, new File(folder, "objects/" + objectKey + ".iob")); + hashes.append(IO.hash(objectFile)); + } catch (Throwable e) { + LOGGER.error("Iris package failed to copy object {}", objectKey, e); + } + } + + hashes.append(copyJson(folder, "dimensions", dimension.getLoadKey(), dm.getDimensionLoader().findFile(dimension.getLoadKey()))); + for (String key : generatorKeys) { + hashes.append(copyJson(folder, "generators", key, dm.getGeneratorLoader().findFile(key))); + } + for (String key : regionKeys) { + hashes.append(copyJson(folder, "regions", key, dm.getRegionLoader().findFile(key))); + } + for (String key : dm.getBlockLoader().getPossibleKeys()) { + hashes.append(copyJson(folder, "blocks", key, dm.getBlockLoader().findFile(key))); + } + for (String key : biomeKeys) { + hashes.append(copyJson(folder, "biomes", key, dm.getBiomeLoader().findFile(key))); + } + for (String key : entityKeys) { + hashes.append(copyJson(folder, "entities", key, dm.getEntityLoader().findFile(key))); + } + for (String key : lootKeys) { + hashes.append(copyJson(folder, "loot", key, dm.getLootLoader().findFile(key))); + } + + JSONObject meta = new JSONObject(); + meta.put("hash", IO.hash(hashes.toString())); + meta.put("time", M.ms()); + meta.put("version", dimension.getVersion()); + IO.writeAll(new File(folder, "package.json"), meta.toString(0)); + + File output = new File(exports, dimension.getLoadKey() + ".iris"); + ZipUtil.pack(folder, output, 9); + IO.delete(folder); + return output; + } + + private static String copyJson(File folder, String category, String key, File file) { + if (file == null || !file.isFile()) { + return ""; + } + try { + String json = new JSONObject(IO.readAll(file)).toString(0); + IO.writeAll(new File(folder, category + "/" + key + ".json"), json); + return IO.hash(json); + } catch (Throwable e) { + LOGGER.error("Iris package failed to write {}/{}", category, key, e); + return ""; + } + } + + private static int regions(CommandSourceStack source, int radius) { + ServerPlayer player = source.getPlayer(); + if (player == null) { + IrisModdedCommands.fail(source, "This command can only be used by players (sampling is centered on your position)."); + return 0; + } + Engine engine = IrisModdedCommands.engineFor(source.getLevel()); + if (engine == null) { + IrisModdedCommands.fail(source, "This dimension is not generated by Iris."); + return 0; + } + MinecraftServer server = source.getServer(); + int blockX = player.blockPosition().getX(); + int blockZ = player.blockPosition().getZ(); + IrisModdedCommands.ok(source, "Sampling region distribution in " + (radius * 2) + "x" + (radius * 2) + " chunks around you..."); + Thread thread = new Thread(() -> { + try { + int diameter = radius * 2; + int totalTasks = diameter * diameter; + KMap counts = new KMap<>(); + engine.getDimension().getRegions().forEach((String key) -> counts.put(key, new AtomicInteger(0))); + MultiBurst burst = new MultiBurst("Region Sampler"); + BurstExecutor executor = burst.burst(totalTasks); + new Spiraler(diameter, diameter, (int x, int z) -> executor.queue(() -> { + IrisRegion region = engine.getRegion((x << 4) + 8, (z << 4) + 8); + counts.computeIfAbsent(region.getLoadKey(), (String key) -> new AtomicInteger(0)).incrementAndGet(); + })).setOffset(blockX, blockZ).drain(); + executor.complete(); + burst.close(); + server.execute(() -> counts.forEach((String key, AtomicInteger count) -> { + IrisRegion region = engine.getData().getRegionLoader().load(key); + String rarity = region == null ? "?" : String.valueOf(region.getRarity()); + IrisModdedCommands.ok(source, key + ": rarity=" + rarity + " / " + Form.f((double) count.get() / totalTasks * 100, 2) + "%"); + })); + } catch (Throwable e) { + LOGGER.error("Iris region sampling failed", e); + server.execute(() -> IrisModdedCommands.fail(source, "Region sampling failed: " + e.getClass().getSimpleName())); + } + }, "Iris Region Sampler"); + thread.setDaemon(true); + thread.start(); + return 1; + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWandService.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWandService.java new file mode 100644 index 000000000..2df151f4f --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWandService.java @@ -0,0 +1,268 @@ +/* + * 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 net.minecraft.ChatFormatting; +import net.minecraft.core.BlockPos; +import net.minecraft.core.component.DataComponents; +import net.minecraft.core.particles.DustParticleOptions; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.chat.Component; +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.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.util.Unit; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.item.component.CustomData; +import net.minecraft.world.item.component.ItemLore; +import net.minecraft.world.level.Level; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.awt.Color; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ThreadLocalRandom; + +public final class ModdedWandService { + private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + private static final ConcurrentHashMap SELECTIONS = new ConcurrentHashMap<>(); + private static final String WAND_TAG = "iris_wand"; + private static final String DUST_TAG = "iris_dust"; + private static final int DRAW_INTERVAL_TICKS = 5; + private static final double DRAW_STEP = 0.5D; + private static final double DRAW_KEEP_CHANCE = 0.4D; + private static final int DRAW_POINT_CAP = 400; + private static final double DRAW_DISTANCE_SQUARED = 96.0D * 96.0D; + private static int tickCounter = 0; + + private ModdedWandService() { + } + + public record Selection(ResourceKey dimension, BlockPos first, BlockPos second) { + public boolean complete() { + return first != null && second != null; + } + + public BlockPos min() { + return new BlockPos(Math.min(first.getX(), second.getX()), Math.min(first.getY(), second.getY()), Math.min(first.getZ(), second.getZ())); + } + + public BlockPos max() { + return new BlockPos(Math.max(first.getX(), second.getX()), Math.max(first.getY(), second.getY()), Math.max(first.getZ(), second.getZ())); + } + } + + public static ItemStack createWand() { + ItemStack stack = new ItemStack(Items.BLAZE_ROD); + stack.set(DataComponents.CUSTOM_NAME, Component.literal("Wand of Iris").withStyle(ChatFormatting.BOLD, ChatFormatting.GOLD)); + stack.set(DataComponents.LORE, new ItemLore(List.of( + Component.literal("Left click a block to set the first corner"), + Component.literal("Right click a block to set the second corner")))); + stack.set(DataComponents.UNBREAKABLE, Unit.INSTANCE); + stack.set(DataComponents.ENCHANTMENT_GLINT_OVERRIDE, Boolean.TRUE); + stack.set(DataComponents.CUSTOM_DATA, CustomData.of(flagTag(WAND_TAG))); + return stack; + } + + public static ItemStack createDust() { + ItemStack stack = new ItemStack(Items.GLOWSTONE_DUST); + stack.set(DataComponents.CUSTOM_NAME, Component.literal("Dust of Revealing").withStyle(ChatFormatting.BOLD, ChatFormatting.YELLOW)); + stack.set(DataComponents.LORE, new ItemLore(List.of( + Component.literal("Right click on a block to reveal it's placement structure!")))); + stack.set(DataComponents.UNBREAKABLE, Unit.INSTANCE); + stack.set(DataComponents.ENCHANTMENT_GLINT_OVERRIDE, Boolean.TRUE); + stack.set(DataComponents.CUSTOM_DATA, CustomData.of(flagTag(DUST_TAG))); + return stack; + } + + private static CompoundTag flagTag(String key) { + CompoundTag tag = new CompoundTag(); + tag.putBoolean(key, true); + return tag; + } + + public static boolean isWand(ItemStack stack) { + return hasFlag(stack, WAND_TAG); + } + + public static boolean isDust(ItemStack stack) { + return hasFlag(stack, DUST_TAG); + } + + private static boolean hasFlag(ItemStack stack, String key) { + if (stack == null || stack.isEmpty()) { + return false; + } + CustomData data = stack.get(DataComponents.CUSTOM_DATA); + return data != null && data.copyTag().getBooleanOr(key, false); + } + + public static boolean isHoldingWand(ServerPlayer player) { + return isWand(player.getMainHandItem()); + } + + public static boolean attackBlock(Player player, Level level, InteractionHand hand, BlockPos pos) { + if (hand != InteractionHand.MAIN_HAND || level.isClientSide() || !(player instanceof ServerPlayer serverPlayer) || !(level instanceof ServerLevel serverLevel)) { + return false; + } + if (isWand(serverPlayer.getMainHandItem())) { + setCorner(serverPlayer, serverLevel, pos, true); + return true; + } + return false; + } + + public static boolean useBlock(Player player, Level level, InteractionHand hand, BlockPos pos) { + if (hand != InteractionHand.MAIN_HAND || level.isClientSide() || !(player instanceof ServerPlayer serverPlayer) || !(level instanceof ServerLevel serverLevel)) { + return false; + } + ItemStack held = serverPlayer.getMainHandItem(); + if (isWand(held)) { + setCorner(serverPlayer, serverLevel, pos, false); + return true; + } + if (isDust(held)) { + serverLevel.playSound(null, pos, SoundEvents.AMETHYST_BLOCK_CHIME, SoundSource.PLAYERS, 2.0F, 1.97F); + ModdedDustRevealer.reveal(serverPlayer, serverLevel, pos); + return true; + } + return false; + } + + private static void setCorner(ServerPlayer player, ServerLevel level, BlockPos pos, boolean first) { + ResourceKey dimension = level.dimension(); + BlockPos corner = pos.immutable(); + SELECTIONS.compute(player.getUUID(), (UUID uuid, Selection existing) -> { + BlockPos other = existing != null && existing.dimension().equals(dimension) ? (first ? existing.second() : existing.first()) : null; + return first ? new Selection(dimension, corner, other) : new Selection(dimension, other, corner); + }); + level.playSound(null, pos, SoundEvents.END_PORTAL_FRAME_FILL, SoundSource.PLAYERS, 1.0F, first ? 0.67F : 1.17F); + player.sendOverlayMessage(Component.literal("Position " + (first ? 1 : 2) + " set to " + corner.getX() + ", " + corner.getY() + ", " + corner.getZ())); + } + + public static Selection selection(ServerPlayer player) { + Selection selection = SELECTIONS.get(player.getUUID()); + if (selection == null || !selection.complete() || !selection.dimension().equals(player.level().dimension())) { + return null; + } + return selection; + } + + public static void setSelection(ServerPlayer player, BlockPos first, BlockPos second) { + SELECTIONS.put(player.getUUID(), new Selection(player.level().dimension(), first.immutable(), second.immutable())); + } + + public static void clearAll() { + SELECTIONS.clear(); + } + + public static void serverTick(MinecraftServer server) { + tickCounter++; + if (tickCounter % DRAW_INTERVAL_TICKS != 0) { + return; + } + try { + for (ServerPlayer player : server.getPlayerList().getPlayers()) { + if (!isHoldingWand(player)) { + continue; + } + Selection selection = selection(player); + if (selection == null) { + continue; + } + draw(player.level(), player, selection); + } + } catch (Throwable e) { + LOGGER.error("Iris wand selection draw failed", e); + } + } + + private static void draw(ServerLevel level, ServerPlayer player, Selection selection) { + BlockPos min = selection.min(); + BlockPos max = selection.max(); + double lowX = min.getX(); + double lowY = min.getY(); + double lowZ = min.getZ(); + double highX = max.getX() + 1; + double highY = max.getY() + 1; + double highZ = max.getZ() + 1; + double[][] edges = { + {lowX, lowY, lowZ, highX, lowY, lowZ}, + {lowX, lowY, lowZ, lowX, highY, lowZ}, + {lowX, lowY, lowZ, lowX, lowY, highZ}, + {highX, lowY, lowZ, highX, highY, lowZ}, + {highX, lowY, lowZ, highX, lowY, highZ}, + {lowX, highY, lowZ, highX, highY, lowZ}, + {lowX, highY, lowZ, lowX, highY, highZ}, + {lowX, lowY, highZ, highX, lowY, highZ}, + {lowX, lowY, highZ, lowX, highY, highZ}, + {highX, highY, lowZ, highX, highY, highZ}, + {lowX, highY, highZ, highX, highY, highZ}, + {highX, lowY, highZ, highX, highY, highZ} + }; + + ThreadLocalRandom random = ThreadLocalRandom.current(); + double px = player.getX(); + double py = player.getY(); + double pz = player.getZ(); + int sent = 0; + for (double[] edge : edges) { + double dx = edge[3] - edge[0]; + double dy = edge[4] - edge[1]; + double dz = edge[5] - edge[2]; + double length = Math.sqrt(dx * dx + dy * dy + dz * dz); + if (length <= 0) { + continue; + } + double ux = dx / length; + double uy = dy / length; + double uz = dz / length; + for (double d = 0; d <= length; d += DRAW_STEP) { + if (random.nextDouble() > DRAW_KEEP_CHANCE) { + continue; + } + double x = edge[0] + ux * d; + double y = edge[1] + uy * d; + double z = edge[2] + uz * d; + double distX = x - px; + double distY = y - py; + double distZ = z - pz; + if (distX * distX + distY * distY + distZ * distZ > DRAW_DISTANCE_SQUARED) { + continue; + } + float hue = (float) (0.5F + (Math.sin((x + y + z + (player.tickCount / 2.0F)) / 20.0F) / 2.0D)); + Color color = Color.getHSBColor(hue, 1.0F, 1.0F); + DustParticleOptions options = new DustParticleOptions(color.getRGB() & 0xFFFFFF, 0.9F); + level.sendParticles(player, options, true, true, x, y, z, 1, 0.0D, 0.0D, 0.0D, 0.0D); + sent++; + if (sent >= DRAW_POINT_CAP) { + return; + } + } + } + } +} diff --git a/adapters/neoforge/build.gradle b/adapters/neoforge/build.gradle index 20f0bdb49..6db8e0a92 100644 --- a/adapters/neoforge/build.gradle +++ b/adapters/neoforge/build.gradle @@ -177,6 +177,8 @@ tasks.named('shadowJar', ShadowJar).configure { exclude('META-INF/*.DSA') exclude('META-INF/*.RSA') duplicatesStrategy = DuplicatesStrategy.EXCLUDE + relocate('org.objectweb.asm', 'art.arcane.iris.shadow.asm') + exclude('oshi.properties') } 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 293fa6447..cef45f1d5 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 @@ -23,8 +23,12 @@ import art.arcane.iris.modded.ModdedEngineBootstrap; import art.arcane.iris.modded.ModdedParityProbe; import art.arcane.iris.modded.ModdedWorldCheck; import art.arcane.iris.modded.ModdedWorldEngines; +import art.arcane.iris.modded.command.IrisModdedCommands; +import art.arcane.iris.modded.command.ModdedObjectUndo; +import art.arcane.iris.modded.command.ModdedWandService; import com.mojang.serialization.MapCodec; import net.minecraft.core.registries.Registries; +import net.minecraft.world.InteractionResult; import net.minecraft.world.level.chunk.ChunkGenerator; import net.neoforged.bus.api.IEventBus; import net.neoforged.fml.ModContainer; @@ -33,7 +37,10 @@ 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.RegisterCommandsEvent; +import net.neoforged.neoforge.event.entity.player.PlayerInteractEvent; import net.neoforged.neoforge.event.server.ServerStoppingEvent; +import net.neoforged.neoforge.event.tick.ServerTickEvent; import net.neoforged.neoforge.registries.DeferredRegister; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -44,6 +51,7 @@ public final class IrisNeoForgeBootstrap { public IrisNeoForgeBootstrap(IEventBus modBus) { ModdedEngineBootstrap.initialize(new NeoForgeModdedLoader()); + art.arcane.iris.modded.ModdedPackInstaller.ensureDefaultPack(ModdedEngineBootstrap.loader().configDir()); String modVersion = ModList.get().getModContainerById("irisworldgen") .map((ModContainer container) -> container.getModInfo().getVersion().toString()) .orElse("unknown"); @@ -58,7 +66,24 @@ public final class IrisNeoForgeBootstrap { chunkGenerators.register(modBus); LOGGER.info("Iris chunk generator registered as irisworldgen:iris"); - NeoForge.EVENT_BUS.addListener((ServerStoppingEvent event) -> ModdedWorldEngines.shutdown()); + NeoForge.EVENT_BUS.addListener((ServerStoppingEvent event) -> { + ModdedObjectUndo.clearAll(); + ModdedWandService.clearAll(); + ModdedWorldEngines.shutdown(); + }); + NeoForge.EVENT_BUS.addListener((RegisterCommandsEvent event) -> IrisModdedCommands.register(event.getDispatcher())); + NeoForge.EVENT_BUS.addListener((PlayerInteractEvent.LeftClickBlock event) -> { + if (ModdedWandService.attackBlock(event.getEntity(), event.getLevel(), event.getHand(), event.getPos())) { + event.setCanceled(true); + } + }); + NeoForge.EVENT_BUS.addListener((PlayerInteractEvent.RightClickBlock event) -> { + if (ModdedWandService.useBlock(event.getEntity(), event.getLevel(), event.getHand(), event.getPos())) { + event.setCanceled(true); + event.setCancellationResult(InteractionResult.SUCCESS); + } + }); + NeoForge.EVENT_BUS.addListener((ServerTickEvent.Post event) -> ModdedWandService.serverTick(event.getServer())); String parity = System.getProperty("iris.parity"); if (parity != null) { diff --git a/adapters/neoforge/src/main/java/art/arcane/iris/neoforge/NeoForgeModdedLoader.java b/adapters/neoforge/src/main/java/art/arcane/iris/neoforge/NeoForgeModdedLoader.java index 486ffcc3f..9030af82a 100644 --- a/adapters/neoforge/src/main/java/art/arcane/iris/neoforge/NeoForgeModdedLoader.java +++ b/adapters/neoforge/src/main/java/art/arcane/iris/neoforge/NeoForgeModdedLoader.java @@ -40,6 +40,13 @@ public final class NeoForgeModdedLoader implements ModdedLoader { return FMLLoader.getCurrent().getVersionInfo().mcVersion(); } + @Override + public String modVersion() { + return ModList.get().getModContainerById("irisworldgen") + .map((net.neoforged.fml.ModContainer container) -> container.getModInfo().getVersion().toString()) + .orElse("unknown"); + } + @Override public MinecraftServer currentServer() { return ServerLifecycleHooks.getCurrentServer(); diff --git a/core/src/main/java/art/arcane/iris/core/structure/StructureIndexService.java b/core/src/main/java/art/arcane/iris/core/structure/StructureIndexService.java index f68d02812..c94b77337 100644 --- a/core/src/main/java/art/arcane/iris/core/structure/StructureIndexService.java +++ b/core/src/main/java/art/arcane/iris/core/structure/StructureIndexService.java @@ -19,10 +19,9 @@ package art.arcane.iris.core.structure; import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.core.loader.IrisData; -import art.arcane.iris.core.nms.INMS; import art.arcane.iris.util.project.matter.IrisMatterSupport; -import art.arcane.volmlib.util.collection.KList; import com.google.gson.GsonBuilder; import java.io.File; @@ -64,8 +63,8 @@ public final class StructureIndexService { } public static File write(IrisData data) { - KList structures = INMS.get().getStructureKeys(); - KList sets = INMS.get().getStructureSetKeys(); + List structures = IrisPlatforms.get().structureHooks().structureKeys(); + List sets = IrisPlatforms.get().structureHooks().structureSetKeys(); List vanilla = new ArrayList<>(); List datapack = new ArrayList<>(); diff --git a/core/src/main/java/art/arcane/iris/engine/framework/IrisStructureLocator.java b/core/src/main/java/art/arcane/iris/engine/framework/IrisStructureLocator.java index 8c18d28f4..499fde4b2 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/IrisStructureLocator.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/IrisStructureLocator.java @@ -27,14 +27,13 @@ import art.arcane.iris.engine.object.IrisStructurePlacement; import art.arcane.iris.engine.object.StructureDistribution; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.math.RNG; +import com.github.benmanes.caffeine.cache.Caffeine; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashSet; import java.util.List; -import java.util.Map; import java.util.Set; -import java.util.WeakHashMap; /** * Finds where IRIS_PLACED structures generate. A structure key matches either the iris @@ -51,7 +50,7 @@ import java.util.WeakHashMap; * exhaustive per-chunk ring scan. */ public final class IrisStructureLocator { - private static final Map INDEX_CACHE = Collections.synchronizedMap(new WeakHashMap<>()); + private static final com.github.benmanes.caffeine.cache.Cache INDEX_CACHE = Caffeine.newBuilder().weakKeys().build(); private IrisStructureLocator() { } @@ -280,13 +279,7 @@ public final class IrisStructureLocator { private static PlacementIndex index(Engine engine) { IrisData data = engine.getData(); - PlacementIndex cached = INDEX_CACHE.get(data); - if (cached != null) { - return cached; - } - PlacementIndex built = build(engine, data); - INDEX_CACHE.put(data, built); - return built; + return INDEX_CACHE.get(data, (IrisData keyData) -> build(engine, keyData)); } private static PlacementIndex build(Engine engine, IrisData data) {