From 37b01b73c4cad11dd6b475f2d6650a504c008da9 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Mon, 24 Aug 2026 13:44:44 -0400 Subject: [PATCH] Imagemap sanitization --- .../src/main/java/art/arcane/iris/Iris.java | 52 +- .../iris/core/commands/CommandObject.java | 2 +- .../iris/core/commands/CommandStudio.java | 23 + .../runtime/BukkitEnginePlatformHooks.java | 71 ++ .../arcane/iris/core/service/CommandSVC.java | 5 +- .../iris/IrisDiagnosticLogLevelTest.java | 4 +- .../core/runtime/BukkitWorldBoundaryTest.java | 44 + .../iris/modded/IrisModdedChunkGenerator.java | 1 + .../iris/modded/ModdedWorldEngines.java | 27 +- .../modded/command/ModdedStudioCommands.java | 51 +- .../service/ModdedStudioHotloadService.java | 78 ++ .../service/ModdedWorldBoundaryTest.java | 49 + build.gradle | 1 + .../src/main/java/BukkitArtifactVerifier.java | 1 - core/build.gradle | 6 +- .../iris/core/gui/ImageMapStudioGUI.java | 1121 +++++++++++++++++ .../iris/core/gui/ImageMapStudioModel.java | 411 ++++++ .../core/gui/ImageMapStudioPreviewPanel.java | 632 ++++++++++ .../core/link/MultiverseGuardListener.java | 3 +- .../iris/core/loader/ImageResourceLoader.java | 77 +- .../art/arcane/iris/core/loader/IrisData.java | 17 +- .../iris/core/loader/ResourceLoader.java | 6 + .../BukkitCommandMessagesExtended.java | 5 + .../core/localization/DesktopUiMessages.java | 85 +- .../localization/ModdedCommandMessages.java | 10 + .../localization/PackDownloadMessages.java | 5 - .../core/pack/ImageMapPackageClosure.java | 48 + .../core/pack/PackDimensionValidator.java | 81 ++ .../arcane/iris/core/pack/PackDownloader.java | 26 +- .../iris/core/pack/PackImageMapValidator.java | 907 +++++++++++++ .../arcane/iris/core/pack/PackValidator.java | 8 + .../core/project/ImageMapStudioExporter.java | 537 ++++++++ .../core/project/IrisPackageCompiler.java | 15 +- .../service/JigsawStudioMenuController.java | 49 +- .../core/service/JigsawStudioService.java | 3 +- .../core/service/ObjectStudioSaveService.java | 19 +- .../arcane/iris/engine/EngineHotloader.java | 1 + .../art/arcane/iris/engine/IrisComplex.java | 56 +- .../iris/engine/UpperDimensionContext.java | 158 ++- .../actuator/IrisTerrainNormalActuator.java | 10 + .../engine/framework/EnginePlatformHooks.java | 3 + .../engine/image/CompiledIrisImageMap.java | 424 +++++++ .../engine/image/IrisImageMapCompiler.java | 825 ++++++++++++ .../engine/image/IrisImageMapMaskSampler.java | 113 ++ .../engine/image/IrisImageMapRuntime.java | 386 ++++++ .../image/IrisImageMapSourceMetadata.java | 18 + .../IrisImageMapValidationException.java | 35 + .../iris/engine/object/IrisDimension.java | 83 +- .../engine/object/IrisGeneratorStyle.java | 16 +- .../arcane/iris/engine/object/IrisImage.java | 161 ++- .../iris/engine/object/IrisImageChannel.java | 51 - .../engine/object/IrisImageColorMode.java | 9 + .../iris/engine/object/IrisImageMap.java | 171 +-- .../iris/engine/object/IrisImageMapAlpha.java | 11 + .../object/IrisImageMapApplication.java | 13 + .../engine/object/IrisImageMapBinding.java | 29 + .../iris/engine/object/IrisImageMapMask.java | 33 + .../object/IrisImageMapMaskOperation.java | 12 + .../engine/object/IrisImageMapOrigin.java | 20 + .../object/IrisImageMapOutOfBounds.java | 12 + .../engine/object/IrisImageMapRotation.java | 11 + .../engine/object/IrisImageMapSampling.java | 10 + .../iris/engine/object/IrisImageMapType.java | 13 + .../object/IrisImageMapUnknownColor.java | 10 + .../iris/engine/object/IrisWorldBoundary.java | 91 ++ .../object/IrisWorldBoundaryCenter.java | 29 + .../engine/platform/BukkitChunkGenerator.java | 1 + .../iris/engine/platform/EngineBukkitOps.java | 9 +- .../iris/platform/bukkit/BukkitPlatform.java | 14 - .../iris/util/common/misc/Bindings.java | 24 - .../iris/util/common/misc/WebCache.java | 365 ++++-- .../iris/util/common/plugin/VolmitSender.java | 150 +-- .../iris/util/project/noise/ImageNoise.java | 38 +- core/src/main/resources/languages/de_DE.json | 72 +- core/src/main/resources/languages/es_ES.json | 72 +- core/src/main/resources/languages/fi_FI.json | 72 +- core/src/main/resources/languages/fr_FR.json | 72 +- core/src/main/resources/languages/he_IL.json | 72 +- core/src/main/resources/languages/it_IT.json | 72 +- core/src/main/resources/languages/ja-JP.json | 72 +- core/src/main/resources/languages/ko_KR.json | 72 +- core/src/main/resources/languages/lt_LT.json | 72 +- core/src/main/resources/languages/nl_NL.json | 72 +- core/src/main/resources/languages/pl_PL.json | 72 +- core/src/main/resources/languages/pt_PT.json | 72 +- core/src/main/resources/languages/ru_RU.json | 72 +- core/src/main/resources/languages/tr_TR.json | 72 +- core/src/main/resources/languages/vi_VI.json | 72 +- core/src/main/resources/languages/zh_CN.json | 72 +- core/src/main/resources/languages/zh_TW.json | 72 +- .../core/DiagnosticSeverityPolicyTest.java | 1 - .../iris/core/gui/ImageMapStudioGUITest.java | 30 + .../core/gui/ImageMapStudioModelTest.java | 168 +++ .../gui/ImageMapStudioPreviewPanelTest.java | 146 +++ .../core/loader/ImageResourceLoaderTest.java | 17 + .../iris/core/pack/PackDownloaderTest.java | 48 + .../iris/core/pack/PackExportClosureTest.java | 8 + .../core/pack/PackImageMapValidatorTest.java | 515 ++++++++ .../pack/PackValidatorWorldBoundaryTest.java | 52 + .../project/ImageMapStudioExporterTest.java | 265 ++++ .../core/project/IrisImageMapSchemaTest.java | 64 + .../project/IrisWorldBoundarySchemaTest.java | 42 + .../UpperDimensionImageMapRuntimeTest.java | 59 + .../image/IrisImageMapCompilerTest.java | 570 +++++++++ .../image/IrisImageMapMaskSamplerTest.java | 122 ++ .../engine/image/IrisImageMapRuntimeTest.java | 163 +++ .../engine/object/IrisWorldBoundaryTest.java | 49 + .../purity/PackTypeBukkitPurityGateTest.java | 1 + .../iris/util/common/misc/WebCacheTest.java | 145 ++- .../VolmitSenderComponentDeliveryTest.java | 123 ++ .../util/project/noise/ImageNoiseTest.java | 86 ++ gradle/libs.versions.toml | 4 +- 112 files changed, 11127 insertions(+), 670 deletions(-) create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/runtime/BukkitWorldBoundaryTest.java create mode 100644 adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedWorldBoundaryTest.java create mode 100644 core/src/main/java/art/arcane/iris/core/gui/ImageMapStudioGUI.java create mode 100644 core/src/main/java/art/arcane/iris/core/gui/ImageMapStudioModel.java create mode 100644 core/src/main/java/art/arcane/iris/core/gui/ImageMapStudioPreviewPanel.java create mode 100644 core/src/main/java/art/arcane/iris/core/pack/ImageMapPackageClosure.java create mode 100644 core/src/main/java/art/arcane/iris/core/pack/PackImageMapValidator.java create mode 100644 core/src/main/java/art/arcane/iris/core/project/ImageMapStudioExporter.java create mode 100644 core/src/main/java/art/arcane/iris/engine/image/CompiledIrisImageMap.java create mode 100644 core/src/main/java/art/arcane/iris/engine/image/IrisImageMapCompiler.java create mode 100644 core/src/main/java/art/arcane/iris/engine/image/IrisImageMapMaskSampler.java create mode 100644 core/src/main/java/art/arcane/iris/engine/image/IrisImageMapRuntime.java create mode 100644 core/src/main/java/art/arcane/iris/engine/image/IrisImageMapSourceMetadata.java create mode 100644 core/src/main/java/art/arcane/iris/engine/image/IrisImageMapValidationException.java delete mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisImageChannel.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisImageColorMode.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisImageMapAlpha.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisImageMapApplication.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisImageMapBinding.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisImageMapMask.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisImageMapMaskOperation.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisImageMapOrigin.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisImageMapOutOfBounds.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisImageMapRotation.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisImageMapSampling.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisImageMapType.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisImageMapUnknownColor.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisWorldBoundary.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisWorldBoundaryCenter.java create mode 100644 core/src/test/java/art/arcane/iris/core/gui/ImageMapStudioGUITest.java create mode 100644 core/src/test/java/art/arcane/iris/core/gui/ImageMapStudioModelTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/gui/ImageMapStudioPreviewPanelTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/loader/ImageResourceLoaderTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/pack/PackImageMapValidatorTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/pack/PackValidatorWorldBoundaryTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/project/ImageMapStudioExporterTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/project/IrisImageMapSchemaTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/project/IrisWorldBoundarySchemaTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/UpperDimensionImageMapRuntimeTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/image/IrisImageMapCompilerTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/image/IrisImageMapMaskSamplerTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/image/IrisImageMapRuntimeTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IrisWorldBoundaryTest.java create mode 100644 core/src/test/java/art/arcane/iris/util/common/plugin/VolmitSenderComponentDeliveryTest.java create mode 100644 core/src/test/java/art/arcane/iris/util/project/noise/ImageNoiseTest.java diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java index b2f1749c1..53a960339 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java @@ -102,6 +102,9 @@ import art.arcane.volmlib.util.io.IO; import art.arcane.volmlib.util.io.InstanceState; import art.arcane.volmlib.util.math.M; import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.plugin.ComponentLog; +import art.arcane.volmlib.util.plugin.ComponentMessenger; +import art.arcane.volmlib.util.plugin.ComponentText; import art.arcane.iris.util.common.misc.Bindings; import art.arcane.iris.util.common.misc.ServerProperties; import art.arcane.iris.util.common.misc.SlimJar; @@ -147,6 +150,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.logging.Level; +import java.util.logging.Logger; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -161,7 +165,6 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { } public static Iris instance; - public static Bindings.Adventure audiences; public static MultiverseCoreLink linkMultiverseCore; public static IrisCompat compat; public static ChunkTickets tickets; @@ -256,7 +259,15 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { getSender().sendMessage(string); } catch (Throwable e) { try { - instance.getLogger().info(instance.getTag() + IrisLogging.clean(string)); + Iris plugin = instance; + String tag = plugin == null ? "" : plugin.getTag(); + ComponentLog.logMarkup( + plugin, + Logger.getLogger("Iris"), + "[Iris] ", + Level.INFO, + tag + string, + null); } catch (Throwable inner) { System.err.println("[Iris] Failed to emit log message: " + inner.getMessage()); inner.printStackTrace(System.err); @@ -550,20 +561,13 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { private static void diagnostic(Level level, String message) { String line = IrisLogging.clean(message); - Iris plugin = instance; - if (plugin != null) { - try { - plugin.getLogger().log(level, line); - return; - } catch (Throwable unavailable) { - // Paper runs the bootstrap before a plugin logger exists; the streams below are all there is. - } - } - if (level.intValue() >= Level.WARNING.intValue()) { - System.err.println("[Iris] " + line); - return; - } - System.out.println("[Iris] " + line); + ComponentLog.log( + instance, + Logger.getLogger("Iris"), + "[Iris] ", + level, + ComponentText.literal(line), + null); } /** @@ -595,7 +599,6 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { PaperLibBootstrap.install(); SimdSupport.install(); services = new KMap<>(); - setupAudience(); BukkitPlatform.hostHud(new HudActionBar(this), new HudBossBarLane()); Bindings.setupSentry(); // Explicit, ordered service list: the previous reflective jar scan gave hash-ordered @@ -794,17 +797,6 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { } } - private void setupAudience() { - try { - audiences = new Bindings.Adventure(this); - BukkitPlatform.hostAudiences(audiences); - } catch (Throwable e) { - IrisSettings.get().getGeneral().setUseConsoleCustomColors(false); - IrisSettings.get().getGeneral().setUseCustomColorsIngame(false); - Iris.reportError("Failed to set up Adventure; custom colors are disabled.", e); - } - } - public void onEnable() { IrisPlatforms.bind(new BukkitPlatform()); IrisStartupValidation.begin(); @@ -1215,7 +1207,9 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { } public void imsg(CommandSender s, String msg) { - s.sendMessage(C.IRIS + "[" + C.DARK_GRAY + "Iris" + C.IRIS + "]" + C.GRAY + ": " + msg); + ComponentMessenger.sendSection( + s, + C.IRIS + "[" + C.DARK_GRAY + "Iris" + C.IRIS + "]" + C.GRAY + ": " + msg); } @Nullable diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandObject.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandObject.java index d1a57aab5..d1388a5e5 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandObject.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandObject.java @@ -398,7 +398,7 @@ public class CommandObject implements DirectorExecutor { MessageArgument.trusted("value2", targets.size()) )); - org.bukkit.command.CommandSender s = sender(); + VolmitSender s = sender(); List queued = targets; J.a(() -> TreePlausibilizeBatch.run(queued, dryRun, reach, nearest, (TreePlausibilizeBatch.Output output) -> s.sendMessage(output.headline() diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStudio.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStudio.java index 0c01d33dc..bcb9d0ce6 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStudio.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStudio.java @@ -21,6 +21,7 @@ package art.arcane.iris.core.commands; import art.arcane.iris.Iris; import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.core.gui.ImageMapStudioGUI; import art.arcane.iris.core.gui.NoiseExplorerGUI; import art.arcane.iris.core.gui.VisionGUI; import art.arcane.iris.core.loader.IrisData; @@ -263,6 +264,28 @@ public class CommandStudio implements DirectorExecutor { NoiseExplorerGUI.launchGeneratorKey(generatorKey, generator, seed); } + @Director(description = "Open the image-map studio (External GUI)", descriptionKey = "iris.director.commandstudio.director.open_image_map_studio_external_gui", aliases = {"imap"}) + public void imagemap( + @Param(name = "world", description = "The Iris world whose active pack receives image-map exports", descriptionKey = "iris.director.commandstudio.param.world_open_image_map_studio", contextual = true, contextualOverride = true) + World world + ) { + if (noGUI()) { + return; + } + if (!ImageMapStudioGUI.isAvailable()) { + sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_YOU_MUST_HAVE_SERVER_LAUNCHED_GUIS_ENABLED_SETTINGS)); + return; + } + if (!IrisToolbelt.isIrisWorld(world)) { + sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_YOU_NEED_BE_SPECIFY_IRIS_GENERATED_WORLD)); + return; + } + + Engine activeEngine = IrisToolbelt.access(world).getEngine(); + ImageMapStudioGUI.launch(activeEngine); + sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_IMAGE_MAP_STUDIO)); + } + @Director(description = "Show loot if a chest were right here", descriptionKey = "iris.director.commandstudio.director.show_loot_if_chest_were_right_here", origin = DirectorOrigin.PLAYER, sync = true) public void loot( @Param(description = "Fast insertion of items in virtual inventory (may cause performance drop)", descriptionKey = "iris.director.commandstudio.param.fast_insertion_items_virtual_inventory_may_cause_performance_drop", defaultValue = "false") diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/runtime/BukkitEnginePlatformHooks.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/runtime/BukkitEnginePlatformHooks.java index 9f03179f5..f6fda8937 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/runtime/BukkitEnginePlatformHooks.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/runtime/BukkitEnginePlatformHooks.java @@ -36,11 +36,14 @@ import art.arcane.iris.engine.framework.NativeStructureVolume; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisDimensionRuntimeContract; import art.arcane.iris.engine.object.IrisWorld; +import art.arcane.iris.engine.object.IrisWorldBoundary; import art.arcane.iris.platform.bukkit.BukkitWorldBinding; +import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.util.common.scheduling.J; import art.arcane.volmlib.util.collection.KList; import org.bukkit.World; +import org.bukkit.WorldBorder; public final class BukkitEnginePlatformHooks implements EnginePlatformHooks { @Override @@ -80,6 +83,35 @@ public final class BukkitEnginePlatformHooks implements EnginePlatformHooks { "iris"); } + @Override + public void applyWorldBoundary(Engine engine) { + IrisDimension expectedDimension = engine.getDimension(); + IrisWorldBoundary configuredBoundary = expectedDimension.getWorldBoundary(); + if (configuredBoundary == null) { + return; + } + IrisWorld expectedIrisWorld = engine.getWorld(); + World expectedWorld = BukkitWorldBinding.world(expectedIrisWorld); + if (expectedWorld == null) { + return; + } + IrisWorldBoundary boundary; + try { + boundary = IrisWorldBoundary.snapshot(configuredBoundary); + } catch (Throwable error) { + IrisLogging.error("Invalid Iris world boundary for " + expectedWorld.getName() + "."); + IrisLogging.reportError(error); + throw propagateBoundaryFailure("Invalid Iris world boundary for '" + expectedWorld.getName() + "'.", error); + } + if (!J.runGlobal(() -> applyWorldBoundary(engine, expectedDimension, expectedIrisWorld, expectedWorld, boundary))) { + IllegalStateException failure = new IllegalStateException( + "Bukkit global scheduler rejected world-boundary application for '" + expectedWorld.getName() + "'."); + IrisLogging.error(failure.getMessage()); + IrisLogging.reportError(failure); + throw failure; + } + } + @Override public boolean isPregeneratorActive(Engine engine) { IrisWorld world = engine.getWorld(); @@ -134,4 +166,43 @@ public final class BukkitEnginePlatformHooks implements EnginePlatformHooks { World world = BukkitWorldBinding.world(engine.getWorld()); return world != null && IrisToolbelt.isWorldMaintenanceBypassingMantleStages(world); } + + static void applyWorldBoundary(WorldBorder worldBorder, IrisWorldBoundary boundary) { + if (boundary == null) { + return; + } + worldBorder.setCenter(boundary.getCenter().getX(), boundary.getCenter().getZ()); + worldBorder.setSize(boundary.getSize()); + worldBorder.setWarningDistance(boundary.getWarningDistance()); + worldBorder.setDamageBuffer(boundary.getDamageBuffer()); + worldBorder.setDamageAmount(boundary.getDamageAmount()); + } + + private static void applyWorldBoundary(Engine engine, IrisDimension expectedDimension, + IrisWorld expectedIrisWorld, World expectedWorld, + IrisWorldBoundary boundary) { + if (engine.isClosed() || engine.isClosing() || engine.getDimension() != expectedDimension + || engine.getWorld() != expectedIrisWorld + || BukkitWorldBinding.world(expectedIrisWorld) != expectedWorld) { + return; + } + try { + applyWorldBoundary(expectedWorld.getWorldBorder(), boundary); + } catch (Throwable error) { + IrisLogging.error("Failed to apply Iris world boundary to " + expectedWorld.getName() + "."); + IrisLogging.reportError(error); + throw propagateBoundaryFailure("Failed to apply Iris world boundary to '" + + expectedWorld.getName() + "'.", error); + } + } + + private static RuntimeException propagateBoundaryFailure(String message, Throwable error) { + if (error instanceof Error fatal) { + throw fatal; + } + if (error instanceof RuntimeException runtimeException) { + return runtimeException; + } + return new IllegalStateException(message, error); + } } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/CommandSVC.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/CommandSVC.java index 11c03e553..50d40b23b 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/CommandSVC.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/CommandSVC.java @@ -62,6 +62,7 @@ import java.util.Optional; import art.arcane.iris.core.localization.IrisLanguage; import art.arcane.iris.core.localization.IrisMessages; import art.arcane.volmlib.util.localization.MessageArgument; +import art.arcane.volmlib.util.plugin.ComponentMessenger; public class CommandSVC implements IrisService, CommandExecutor, TabCompleter, DirectorInvocationHook { private static final String ROOT_COMMAND = "iris"; private static final String ROOT_PERMISSION = "iris.all"; @@ -169,7 +170,7 @@ public class CommandSVC implements IrisService, CommandExecutor, TabCompleter, D public void executeRoot(CommandSender sender, String label, String[] args) { if (!sender.hasPermission(ROOT_PERMISSION)) { - sender.sendMessage(IrisLanguage.text( + ComponentMessenger.sendSection(sender, IrisLanguage.text( IrisMessages.COMMAND_PERMISSION_DENIED, MessageArgument.trusted("permission", ROOT_PERMISSION) )); @@ -325,7 +326,7 @@ public class CommandSVC implements IrisService, CommandExecutor, TabCompleter, D @Override public void sendMessage(String message) { if (message != null && !message.trim().isEmpty()) { - sender.sendMessage(message); + ComponentMessenger.sendLiteral(sender, message); } } } diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisDiagnosticLogLevelTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisDiagnosticLogLevelTest.java index bcb0649c4..0ecb40052 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisDiagnosticLogLevelTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisDiagnosticLogLevelTest.java @@ -65,7 +65,9 @@ public class IrisDiagnosticLogLevelTest { assertTrue(bridge, bridge.contains("diagnosticLevel(target)")); assertTrue(bridge, bridge.contains("diagnostic(diagnostic, message)")); - assertTrue(source.contains("plugin.getLogger().log(level, line)")); + String diagnostic = method(source, "private static void diagnostic(Level level, String message)"); + assertTrue(diagnostic, diagnostic.contains("ComponentLog.log(")); + assertTrue(diagnostic, diagnostic.contains("ComponentText.literal(line)")); } private static String method(String source, String signature) { diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/runtime/BukkitWorldBoundaryTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/runtime/BukkitWorldBoundaryTest.java new file mode 100644 index 000000000..2703250fd --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/runtime/BukkitWorldBoundaryTest.java @@ -0,0 +1,44 @@ +package art.arcane.iris.core.runtime; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisWorldBoundary; +import art.arcane.iris.engine.object.IrisWorldBoundaryCenter; +import org.bukkit.WorldBorder; +import org.junit.Test; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class BukkitWorldBoundaryTest { + @Test + public void omittedBoundaryDoesNotTouchTheNativeWorld() { + Engine engine = mock(Engine.class); + when(engine.getDimension()).thenReturn(new IrisDimension()); + + new BukkitEnginePlatformHooks().applyWorldBoundary(engine); + + verify(engine, never()).getWorld(); + } + + @Test + public void mapsEveryBoundaryValueToBukkit() { + WorldBorder worldBorder = mock(WorldBorder.class); + IrisWorldBoundary boundary = new IrisWorldBoundary() + .setCenter(new IrisWorldBoundaryCenter(12.5D, -7.25D)) + .setSize(4096D) + .setWarningDistance(24) + .setDamageBuffer(6.5D) + .setDamageAmount(0.4D); + + BukkitEnginePlatformHooks.applyWorldBoundary(worldBorder, boundary); + + verify(worldBorder).setCenter(12.5D, -7.25D); + verify(worldBorder).setSize(4096D); + verify(worldBorder).setWarningDistance(24); + verify(worldBorder).setDamageBuffer(6.5D); + verify(worldBorder).setDamageAmount(0.4D); + } +} 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 7bfd790d8..1f9a15586 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 @@ -184,6 +184,7 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { Engine replacement = ModdedWorldEngines.prepareReplacement(level, pack, packDimensionKey, seed); try { ModdedWorldEngines.installReplacement(level, replacement); + replacement.getPlatformHooks().applyWorldBoundary(replacement); } catch (Throwable error) { try { ModdedWorldEngines.closeUnregistered(replacement); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java index e35c80da0..b8dc735d4 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java @@ -50,7 +50,8 @@ public final class ModdedWorldEngines { if (existing != null) { return existing; } - return ENGINES.computeIfAbsent(level, (ServerLevel l) -> create(l, pack, dimensionKey, seedOverride)); + return ENGINES.computeIfAbsent(level, + (ServerLevel l) -> createAndApplyWorldBoundary(l, pack, dimensionKey, seedOverride)); } public static Collection activeEngines() { @@ -98,9 +99,33 @@ public final class ModdedWorldEngines { static void closeUnregistered(Engine engine) { close(engine); + ENGINES.entrySet().removeIf((Map.Entry entry) -> entry.getValue() == engine); ModdedGuiHost.unbind(engine); } + private static Engine createAndApplyWorldBoundary(ServerLevel level, String pack, + String dimensionKey, long seedOverride) { + Engine engine = create(level, pack, dimensionKey, seedOverride); + try { + engine.getPlatformHooks().applyWorldBoundary(engine); + return engine; + } catch (Throwable failure) { + try { + close(engine); + } catch (Throwable cleanupError) { + failure.addSuppressed(cleanupError); + } + if (failure instanceof Error fatal) { + throw fatal; + } + if (failure instanceof RuntimeException runtimeException) { + throw runtimeException; + } + throw new IllegalStateException("Failed to apply Iris world boundary for '" + + level.dimension().identifier() + "'.", failure); + } + } + private static Engine create(ServerLevel level, String pack, String dimensionKey, long seedOverride) { ModdedEngineBootstrap.bind(); File packDir = resolvePack(pack, dimensionKey); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java index 2936a5e17..81536d7fa 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java @@ -21,10 +21,14 @@ package art.arcane.iris.modded.command; import art.arcane.iris.modded.ModdedIrisLog; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.gui.GuiHost; +import art.arcane.iris.core.gui.ImageMapStudioGUI; import art.arcane.iris.core.gui.NoiseExplorerGUI; import art.arcane.iris.core.gui.VisionGUI; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.pack.StructurePackageClosure; +import art.arcane.iris.core.pack.ImageMapPackageClosure; +import art.arcane.iris.core.pack.PackValidationResult; +import art.arcane.iris.core.pack.PackValidator; import art.arcane.iris.core.project.IrisProjectCopier; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.IrisBiome; @@ -151,6 +155,8 @@ public final class ModdedStudioCommands { root.then(noiseTree("nmap")); root.then(mapTree("map")); root.then(mapTree("render")); + root.then(imageMapTree("imagemap")); + root.then(imageMapTree("imap")); 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.")); @@ -217,6 +223,11 @@ public final class ModdedStudioCommands { .executes((CommandContext context) -> map(context.getSource())); } + private static LiteralArgumentBuilder imageMapTree(String name) { + return Commands.literal(name) + .executes((CommandContext context) -> imageMap(context.getSource())); + } + private static int noise(CommandSourceStack source, String generatorKey, long seed) { ServerLevel level = source.getLevel(); Engine engine = IrisModdedCommands.engineFor(level); @@ -266,6 +277,29 @@ public final class ModdedStudioCommands { return 1; } + private static int imageMap(CommandSourceStack source) { + ServerLevel level = source.getLevel(); + Engine engine = IrisModdedCommands.engineFor(level); + if (engine == null) { + IrisModdedCommands.fail(source, IrisLanguage.plain( + ModdedCommandMessages.MODDED_STUDIO_COMMANDS_IMAGE_MAP_REQUIRES_IRIS_DIMENSION + )); + return 0; + } + if (!ImageMapStudioGUI.isAvailable()) { + IrisModdedCommands.fail(source, guiUnavailableMessage()); + return 0; + } + ServerPlayer player = source.getPlayer(); + ModdedGuiHost.bindContext(source.getServer(), level, engine, player == null ? null : player.getUUID()); + ImageMapStudioGUI.launch(engine); + IrisModdedCommands.ok(source, IrisLanguage.plain( + ModdedCommandMessages.MODDED_STUDIO_COMMANDS_OPENING_IMAGE_MAP_STUDIO_ON_SERVER_DISPLAY, + MessageArgument.untrusted("value", level.dimension().identifier()) + )); + return 1; + } + private static LiteralArgumentBuilder workspaceTree(String name, boolean open) { return Commands.literal(name) .executes((CommandContext context) -> workspace(context.getSource(), null, open)) @@ -640,6 +674,11 @@ public final class ModdedStudioCommands { } private static File compilePackage(File packFolder, String dimKey) throws IOException { + PackValidationResult validation = PackValidator.validateForPackaging(packFolder); + if (!validation.isLoadable()) { + throw new IOException("Pack validation failed before packaging: " + + String.join("; ", validation.getBlockingErrors())); + } IrisData dm = IrisData.get(packFolder); IrisDimension dimension = dm.getDimensionLoader().load(dimKey); if (dimension == null) { @@ -660,7 +699,16 @@ public final class ModdedStudioCommands { LinkedHashSet markerKeys = new LinkedHashSet<>(); LinkedHashSet structureKeys = new LinkedHashSet<>(); - regionKeys.addAll(dimension.getRegions()); + dimension.getAllRegions(() -> dm).forEach((IrisRegion region) -> { + if (region != null && region.getLoadKey() != null) { + regionKeys.add(region.getLoadKey()); + } + }); + dimension.getReachableBiomes(() -> dm).forEach((IrisBiome biome) -> { + if (biome != null && biome.getLoadKey() != null) { + biomeKeys.add(biome.getLoadKey()); + } + }); lootKeys.addAll(dimension.getLoot().getTables()); spawnerKeys.addAll(dimension.getEntitySpawners()); collectStructureKeys(structureKeys, dimension.getStructures()); @@ -722,6 +770,7 @@ public final class ModdedStudioCommands { throw new IOException("Structure package closure is invalid: " + String.join("; ", structureClosure.errors())); } hashes.append(structureClosure.writeTo(folder, true)); + hashes.append(ImageMapPackageClosure.writeAll(dm, folder, true)); for (String objectKey : objectKeys) { try { File objectFile = dm.getObjectLoader().findFile(objectKey); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedStudioHotloadService.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedStudioHotloadService.java index e652033a4..3170eb2fd 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedStudioHotloadService.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedStudioHotloadService.java @@ -28,6 +28,7 @@ import art.arcane.iris.engine.framework.NativeStructureVolume; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisDimensionRuntimeContract; import art.arcane.iris.engine.object.IrisWorld; +import art.arcane.iris.engine.object.IrisWorldBoundary; import art.arcane.iris.modded.IrisModdedChunkGenerator; import art.arcane.iris.modded.ModdedDimensionManager; import art.arcane.iris.modded.ModdedForcedDatapack; @@ -38,6 +39,8 @@ import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.io.ReactiveFolder; import art.arcane.volmlib.util.scheduling.ChronoLatch; import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.border.WorldBorder; import java.io.File; import java.util.HashSet; @@ -120,6 +123,42 @@ public final class ModdedStudioHotloadService implements ModdedTickableService, "irisworldgen"); } + @Override + public void applyWorldBoundary(Engine engine) { + IrisDimension expectedDimension = engine.getDimension(); + IrisWorldBoundary configuredBoundary = expectedDimension.getWorldBoundary(); + if (configuredBoundary == null) { + return; + } + IrisWorld expectedIrisWorld = engine.getWorld(); + if (expectedIrisWorld == null || !expectedIrisWorld.hasPlatformWorld() + || !(expectedIrisWorld.platformWorld().nativeHandle() instanceof ServerLevel expectedLevel)) { + return; + } + IrisWorldBoundary boundary; + try { + boundary = IrisWorldBoundary.snapshot(configuredBoundary); + } catch (Throwable error) { + ModdedIrisLog.error("Invalid Iris world boundary for " + + expectedLevel.dimension().identifier() + ".", error); + throw propagateBoundaryFailure("Invalid Iris world boundary for '" + + expectedLevel.dimension().identifier() + "'.", error); + } + MinecraftServer server = expectedLevel.getServer(); + if (server.isSameThread()) { + applyWorldBoundary(engine, expectedDimension, expectedIrisWorld, expectedLevel, boundary); + return; + } + try { + server.execute(() -> applyWorldBoundary(engine, expectedDimension, expectedIrisWorld, expectedLevel, boundary)); + } catch (Throwable error) { + ModdedIrisLog.error("Failed to schedule Iris world-boundary application for " + + expectedLevel.dimension().identifier() + ".", error); + throw propagateBoundaryFailure("Failed to schedule Iris world-boundary application for '" + + expectedLevel.dimension().identifier() + "'.", error); + } + } + @Override public void onServerTick(MinecraftServer server) { ExecutorService active = executor; @@ -268,6 +307,45 @@ public final class ModdedStudioHotloadService implements ModdedTickableService, return false; } + static void applyWorldBoundary(WorldBorder worldBorder, IrisWorldBoundary boundary) { + if (boundary == null) { + return; + } + worldBorder.setCenter(boundary.getCenter().getX(), boundary.getCenter().getZ()); + worldBorder.setSize(boundary.getSize()); + worldBorder.setWarningBlocks(boundary.getWarningDistance()); + worldBorder.setSafeZone(boundary.getDamageBuffer()); + worldBorder.setDamagePerBlock(boundary.getDamageAmount()); + } + + private static void applyWorldBoundary(Engine engine, IrisDimension expectedDimension, + IrisWorld expectedIrisWorld, ServerLevel expectedLevel, + IrisWorldBoundary boundary) { + if (engine.isClosed() || engine.isClosing() || engine.getDimension() != expectedDimension + || engine.getWorld() != expectedIrisWorld + || expectedIrisWorld.platformWorld().nativeHandle() != expectedLevel) { + return; + } + try { + applyWorldBoundary(expectedLevel.getWorldBorder(), boundary); + } catch (Throwable error) { + ModdedIrisLog.error("Failed to apply Iris world boundary to " + + expectedLevel.dimension().identifier() + ".", error); + throw propagateBoundaryFailure("Failed to apply Iris world boundary to '" + + expectedLevel.dimension().identifier() + "'.", error); + } + } + + private static RuntimeException propagateBoundaryFailure(String message, Throwable error) { + if (error instanceof Error fatal) { + throw fatal; + } + if (error instanceof RuntimeException runtimeException) { + return runtimeException; + } + return new IllegalStateException(message, error); + } + private void writeWorkspace(Engine engine, String operation) { try { IrisData data = engine.getData(); diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedWorldBoundaryTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedWorldBoundaryTest.java new file mode 100644 index 000000000..9421d1171 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedWorldBoundaryTest.java @@ -0,0 +1,49 @@ +package art.arcane.iris.modded.service; + +import art.arcane.iris.engine.object.IrisWorldBoundary; +import art.arcane.iris.engine.object.IrisWorldBoundaryCenter; +import net.minecraft.world.level.border.WorldBorder; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class ModdedWorldBoundaryTest { + @Test + public void omittedBoundaryDoesNotTouchTheNativeLevel() { + WorldBorder worldBorder = new WorldBorder(); + worldBorder.setCenter(3.5D, -4.5D); + worldBorder.setSize(1024D); + worldBorder.setWarningBlocks(9); + worldBorder.setSafeZone(2.5D); + worldBorder.setDamagePerBlock(0.75D); + + ModdedStudioHotloadService.applyWorldBoundary(worldBorder, null); + + assertEquals(3.5D, worldBorder.getCenterX(), 0D); + assertEquals(-4.5D, worldBorder.getCenterZ(), 0D); + assertEquals(1024D, worldBorder.getSize(), 0D); + assertEquals(9, worldBorder.getWarningBlocks()); + assertEquals(2.5D, worldBorder.getSafeZone(), 0D); + assertEquals(0.75D, worldBorder.getDamagePerBlock(), 0D); + } + + @Test + public void mapsEveryBoundaryValueToMinecraft() { + WorldBorder worldBorder = new WorldBorder(); + IrisWorldBoundary boundary = new IrisWorldBoundary() + .setCenter(new IrisWorldBoundaryCenter(12.5D, -7.25D)) + .setSize(4096D) + .setWarningDistance(24) + .setDamageBuffer(6.5D) + .setDamageAmount(0.4D); + + ModdedStudioHotloadService.applyWorldBoundary(worldBorder, boundary); + + assertEquals(12.5D, worldBorder.getCenterX(), 0D); + assertEquals(-7.25D, worldBorder.getCenterZ(), 0D); + assertEquals(4096D, worldBorder.getSize(), 0D); + assertEquals(24, worldBorder.getWarningBlocks()); + assertEquals(6.5D, worldBorder.getSafeZone(), 0D); + assertEquals(0.4D, worldBorder.getDamagePerBlock(), 0D); + } +} diff --git a/build.gradle b/build.gradle index 9e2e3faed..ba4835b98 100644 --- a/build.gradle +++ b/build.gradle @@ -537,6 +537,7 @@ List requiredBukkitArtifactEntries = [ 'art/arcane/iris/core/lifecycle/WorldLifecycleStaging.class', 'art/arcane/iris/util/simd/VectorSimdKernels.class', 'art/arcane/iris/util/simd/VectorNoiseKernels2D.class', + 'art/arcane/iris/util/paralithic/functions/Function.class', 'art/arcane/iris/util/project/agent/Agent.class', 'art/arcane/iris/util/common/misc/getHardware.class', 'art/arcane/iris/util/caffeine/cache/Caffeine.class', diff --git a/buildSrc/src/main/java/BukkitArtifactVerifier.java b/buildSrc/src/main/java/BukkitArtifactVerifier.java index bede3b3a6..635859b90 100644 --- a/buildSrc/src/main/java/BukkitArtifactVerifier.java +++ b/buildSrc/src/main/java/BukkitArtifactVerifier.java @@ -28,7 +28,6 @@ public final class BukkitArtifactVerifier { // Relocation targets for the libraries slimjar downloads and relocates at runtime. Compiled // references to them are correct and the classes are correctly absent from the jar. private static final List RUNTIME_DOWNLOADED_PREFIXES = List.of( - "art/arcane/iris/util/paralithic/", "art/arcane/iris/util/paper/", "art/arcane/iris/util/kyori/", "art/arcane/iris/util/metrics/", diff --git a/core/build.gradle b/core/build.gradle index 855a3b7d9..a234f4775 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -89,13 +89,14 @@ dependencies { implementation(libs.gson) implementation(libs.lru) implementation(libs.caffeine) + implementation(libs.paralithic) // Dynamically Loaded - slim(libs.paralithic) slim(libs.paperlib) slim(libs.adventure.api) slim(libs.adventure.minimessage) - slim(libs.adventure.platform) + slim(libs.adventure.serializer.legacy) + slim(libs.adventure.serializer.plain) slim(libs.bstats) slim(libs.sentry) @@ -310,6 +311,7 @@ tasks.named('shadowJar', com.github.jengelman.gradle.plugins.shadow.tasks.Shadow // surfaces are only referenced from :adapters:bukkit:plugin, which is merged into the artifact // after this task runs. Both would be stripped as unreachable. Excluding them from minimization // leaves it reaching ~25KB of Gson internals, which is not worth a mode that fails silently. + relocate('com.dfsek.paralithic', "${lib}.paralithic") relocate('io.github.slimjar', "${lib}.slimjar") exclude('modules/loader-agent.isolated-jar') exclude(supersededVolmLibPackages) diff --git a/core/src/main/java/art/arcane/iris/core/gui/ImageMapStudioGUI.java b/core/src/main/java/art/arcane/iris/core/gui/ImageMapStudioGUI.java new file mode 100644 index 000000000..48c3d40f8 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/gui/ImageMapStudioGUI.java @@ -0,0 +1,1121 @@ +package art.arcane.iris.core.gui; + +import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.localization.DesktopUiMessages; +import art.arcane.iris.core.localization.IrisLanguage; +import art.arcane.iris.core.project.ImageMapStudioExporter; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisImageMap; +import art.arcane.iris.engine.object.IrisImageMapAlpha; +import art.arcane.iris.engine.object.IrisImageMapApplication; +import art.arcane.iris.engine.object.IrisImageMapBinding; +import art.arcane.iris.engine.object.IrisImageMapMask; +import art.arcane.iris.engine.object.IrisImageMapMaskOperation; +import art.arcane.iris.engine.object.IrisImageMapOrigin; +import art.arcane.iris.engine.object.IrisImageMapOutOfBounds; +import art.arcane.iris.engine.object.IrisImageMapRotation; +import art.arcane.iris.engine.object.IrisImageMapSampling; +import art.arcane.iris.engine.object.IrisImageMapType; +import art.arcane.iris.engine.object.IrisImageMapUnknownColor; +import art.arcane.iris.engine.object.IrisWorldBoundary; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.volmlib.util.collection.KMap; +import art.arcane.volmlib.util.localization.TextKey; + +import javax.swing.BorderFactory; +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.DefaultComboBoxModel; +import javax.swing.DefaultCellEditor; +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JComboBox; +import javax.swing.JFileChooser; +import javax.swing.JFrame; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JSplitPane; +import javax.swing.JTable; +import javax.swing.JTextArea; +import javax.swing.JTextField; +import javax.swing.ListSelectionModel; +import javax.swing.SwingWorker; +import javax.swing.filechooser.FileNameExtensionFilter; +import javax.swing.table.DefaultTableModel; +import java.awt.BorderLayout; +import java.awt.CardLayout; +import java.awt.Color; +import java.awt.Component; +import java.awt.Dimension; +import java.awt.EventQueue; +import java.awt.FlowLayout; +import java.awt.Font; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.awt.event.WindowAdapter; +import java.awt.event.WindowEvent; +import java.io.File; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.function.DoubleBinaryOperator; + +public final class ImageMapStudioGUI { + private static final Color BACKGROUND = new Color(12, 15, 22); + private static final Color SIDEBAR_BACKGROUND = new Color(17, 20, 29); + private static final Color PANEL_BACKGROUND = new Color(23, 27, 38); + private static final Color FIELD_BACKGROUND = new Color(29, 34, 47); + private static final Color PRIMARY_TEXT = new Color(230, 234, 242); + private static final Color SECONDARY_TEXT = new Color(151, 160, 178); + private static final Color ACCENT = new Color(99, 161, 255); + private static final Color BORDER = new Color(48, 55, 72); + private static final Color ERROR = new Color(255, 116, 116); + private static final Color SUCCESS = new Color(91, 204, 139); + private static final Font BODY_FONT = new Font(Font.SANS_SERIF, Font.PLAIN, 12); + private static final Font LABEL_FONT = new Font(Font.SANS_SERIF, Font.BOLD, 11); + + private final Engine engine; + private final File packFolder; + private final String dimensionKey; + private final JFrame frame; + private final ImageMapStudioPreviewPanel previewPanel = new ImageMapStudioPreviewPanel(); + private final JComboBox presetPicker = new JComboBox<>(); + private final JTextField bindingKeyField = field("image-map"); + private final JTextField mapKeyField = field("image-map"); + private final JTextField imageKeyField = field("image-map"); + private final JComboBox typePicker = combo(IrisImageMapType.values()); + private final JComboBox applicationPicker = combo(IrisImageMapApplication.values()); + private final JTextField blocksPerPixelField = field("1"); + private final JTextField originXField = field("0"); + private final JTextField originZField = field("0"); + private final JTextField sourceOriginXField = field("0"); + private final JTextField sourceOriginZField = field("0"); + private final JComboBox rotationPicker = combo(IrisImageMapRotation.values()); + private final JCheckBox mirrorXCheck = check(); + private final JCheckBox mirrorZCheck = check(); + private final JComboBox samplingPicker = combo(IrisImageMapSampling.values()); + private final JComboBox outOfBoundsPicker = combo(IrisImageMapOutOfBounds.values()); + private final JTextField fallbackValueField = field("0"); + private final JTextField fallbackTargetField = field(""); + private final JComboBox alphaPicker = combo(IrisImageMapAlpha.values()); + private final JTextField minimumHeightField = field("-64"); + private final JTextField maximumHeightField = field("320"); + private final JTextField verticalOffsetField = field("0"); + private final JCheckBox clampCheck = check(); + private final JCheckBox heightInvertedCheck = check(); + private final JTextField heightCurveExponentField = field("1"); + private final JTextField heightSmoothingRadiusField = field("0"); + private final JCheckBox maskInvertedCheck = check(); + private final JTextField maskCurveExponentField = field("1"); + private final JTextField maskSmoothingRadiusField = field("0"); + private final JTextField thresholdField = field("0.5"); + private final JTextField falloffField = field("0"); + private final JTextField toleranceField = field("0"); + private final JComboBox unknownColorPicker = combo(IrisImageMapUnknownColor.values()); + private final DefaultTableModel legendModel = new DefaultTableModel(new Object[]{"#RRGGBB", "Target"}, 0); + private final JTable legendTable = new JTable(legendModel); + private final DefaultTableModel composedMaskModel = new DefaultTableModel( + new Object[]{"Binding", "Operation", "Invert", "Threshold", "Falloff"}, 0 + ) { + @Override + public Class getColumnClass(int columnIndex) { + return columnIndex == 2 ? Boolean.class : Object.class; + } + }; + private final JTable composedMaskTable = new JTable(composedMaskModel); + private final JPanel typeCards = new JPanel(new CardLayout()); + private final JTextArea metadataArea = area(3); + private final JTextArea diagnosticsArea = area(5); + private final JLabel statusLabel = new JLabel(); + private final JCheckBox chunksOverlay = selectedCheck(); + private final JCheckBox regionsOverlay = selectedCheck(); + private final JCheckBox boundaryOverlay = selectedCheck(); + private final JCheckBox coverageOverlay = selectedCheck(); + private Path sourcePath; + private IrisWorldBoundary boundary; + private boolean controlsUpdating; + + private ImageMapStudioGUI(Engine engine) { + this.engine = Objects.requireNonNull(engine, "Image Map Studio engine"); + this.packFolder = engine.getData().getDataFolder(); + this.dimensionKey = engine.getDimension().getLoadKey(); + this.boundary = engine.getDimension().getWorldBoundary() == null + ? null + : IrisWorldBoundary.snapshot(engine.getDimension().getWorldBoundary()); + this.frame = new JFrame(text(DesktopUiMessages.IMAGEMAP_TITLE)); + configureFrame(); + configureControls(); + refreshPresets(); + } + + public static boolean isAvailable() { + return GuiHost.isAvailable() && IrisSettings.get().getGui().isUseServerLaunchedGuis(); + } + + public static void launch(Engine engine) { + EventQueue.invokeLater(() -> new ImageMapStudioGUI(engine).show()); + } + + static void reloadActiveEngine(Engine engine) { + engine.getData().getImageMapLoader().clearCache(); + engine.hotloadSilently(); + } + + private void configureFrame() { + GuiHost.prepareFrame(frame); + frame.getContentPane().setBackground(BACKGROUND); + frame.setLayout(new BorderLayout()); + frame.add(buildToolbar(), BorderLayout.NORTH); + frame.add(buildWorkspace(), BorderLayout.CENTER); + frame.add(buildStatusBar(), BorderLayout.SOUTH); + frame.setSize(1540, 920); + frame.setMinimumSize(new Dimension(1040, 680)); + frame.setLocationRelativeTo(null); + frame.addWindowListener(new WindowAdapter() { + @Override + public void windowClosed(WindowEvent event) { + previewPanel.close(); + } + + @Override + public void windowClosing(WindowEvent event) { + previewPanel.close(); + } + }); + } + + private void configureControls() { + clampCheck.setSelected(true); + typePicker.setSelectedItem(IrisImageMapType.GRAYSCALE_HEIGHT); + applicationPicker.setSelectedItem(IrisImageMapApplication.TERRAIN_HEIGHT); + samplingPicker.setSelectedItem(IrisImageMapSampling.NEAREST); + outOfBoundsPicker.setSelectedItem(IrisImageMapOutOfBounds.FALLBACK); + alphaPicker.setSelectedItem(IrisImageMapAlpha.IGNORE); + unknownColorPicker.setSelectedItem(IrisImageMapUnknownColor.ERROR); + typePicker.addActionListener(event -> updateTypeControls()); + for (JCheckBox overlay : List.of(chunksOverlay, regionsOverlay, boundaryOverlay, coverageOverlay)) { + overlay.addActionListener(event -> updateOverlays()); + } + previewPanel.setDiagnosticConsumer(this::appendDiagnostic); + configureComposedMaskTable(); + updateTypeControls(); + updateOverlays(); + } + + private void show() { + frame.setVisible(true); + EventQueue.invokeLater(() -> { + String selected = (String) presetPicker.getSelectedItem(); + if (selected != null && !selected.isBlank()) { + loadPreset(selected); + } + }); + } + + private JPanel buildToolbar() { + JPanel toolbar = new JPanel(new BorderLayout()); + toolbar.setBackground(SIDEBAR_BACKGROUND); + toolbar.setBorder(BorderFactory.createMatteBorder(0, 0, 1, 0, BORDER)); + JPanel leading = new JPanel(new FlowLayout(FlowLayout.LEFT, 7, 6)); + leading.setOpaque(false); + JLabel presetLabel = label(DesktopUiMessages.IMAGEMAP_PRESET); + leading.add(presetLabel); + presetPicker.setPrototypeDisplayValue("terrain/example-long-preset"); + styleCombo(presetPicker); + leading.add(presetPicker); + JButton load = button(DesktopUiMessages.IMAGEMAP_LOAD); + load.addActionListener(event -> { + String selected = (String) presetPicker.getSelectedItem(); + if (selected != null) { + loadPreset(selected); + } + }); + leading.add(load); + JButton importButton = button(DesktopUiMessages.IMAGEMAP_IMPORT_PNG); + importButton.addActionListener(event -> chooseSource(false)); + leading.add(importButton); + JButton replaceButton = button(DesktopUiMessages.IMAGEMAP_REPLACE_PNG); + replaceButton.addActionListener(event -> chooseSource(true)); + leading.add(replaceButton); + toolbar.add(leading, BorderLayout.WEST); + + JPanel trailing = new JPanel(new FlowLayout(FlowLayout.RIGHT, 7, 6)); + trailing.setOpaque(false); + JButton preview = button(DesktopUiMessages.IMAGEMAP_PREVIEW); + preview.addActionListener(event -> preview()); + trailing.add(preview); + JButton export = button(DesktopUiMessages.IMAGEMAP_EXPORT); + export.setForeground(SUCCESS); + export.addActionListener(event -> export()); + trailing.add(export); + toolbar.add(trailing, BorderLayout.EAST); + return toolbar; + } + + private Component buildWorkspace() { + JScrollPane editorScroll = new JScrollPane(buildEditor()); + editorScroll.setBorder(BorderFactory.createEmptyBorder()); + editorScroll.getVerticalScrollBar().setUnitIncrement(18); + editorScroll.setPreferredSize(new Dimension(470, 760)); + JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, editorScroll, previewPanel); + split.setBorder(BorderFactory.createEmptyBorder()); + split.setDividerSize(5); + split.setResizeWeight(0D); + split.setDividerLocation(470); + return split; + } + + private JPanel buildEditor() { + JPanel editor = new JPanel(); + editor.setLayout(new BoxLayout(editor, BoxLayout.Y_AXIS)); + editor.setBackground(SIDEBAR_BACKGROUND); + editor.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10)); + editor.add(section(DesktopUiMessages.IMAGEMAP_METADATA, metadataPanel())); + editor.add(Box.createVerticalStrut(9)); + editor.add(section(DesktopUiMessages.IMAGEMAP_RESOURCE, resourcePanel())); + editor.add(Box.createVerticalStrut(9)); + editor.add(section(DesktopUiMessages.IMAGEMAP_COORDINATES, coordinatePanel())); + editor.add(Box.createVerticalStrut(9)); + typeCards.add(heightPanel(), "HEIGHT"); + typeCards.add(colorPanel(), "COLOR"); + typeCards.add(maskPanel(), "MASK"); + typeCards.setBackground(SIDEBAR_BACKGROUND); + editor.add(typeCards); + editor.add(Box.createVerticalStrut(9)); + editor.add(section(DesktopUiMessages.IMAGEMAP_COMPOSED_MASKS, composedMaskPanel())); + editor.add(Box.createVerticalStrut(9)); + editor.add(section(DesktopUiMessages.IMAGEMAP_OVERLAYS, overlayPanel())); + editor.add(Box.createVerticalStrut(9)); + editor.add(section(DesktopUiMessages.IMAGEMAP_DIAGNOSTICS, diagnosticsPanel())); + return editor; + } + + private JPanel metadataPanel() { + metadataArea.setText(text(DesktopUiMessages.IMAGEMAP_NO_SOURCE)); + return single(metadataArea); + } + + private JPanel resourcePanel() { + JPanel panel = form(); + addRow(panel, 0, DesktopUiMessages.IMAGEMAP_BINDING_KEY, bindingKeyField); + addRow(panel, 1, DesktopUiMessages.IMAGEMAP_MAP_KEY, mapKeyField); + addRow(panel, 2, DesktopUiMessages.IMAGEMAP_IMAGE_KEY, imageKeyField); + addRow(panel, 3, DesktopUiMessages.IMAGEMAP_TYPE, typePicker); + addRow(panel, 4, DesktopUiMessages.IMAGEMAP_APPLICATION, applicationPicker); + return panel; + } + + private JPanel coordinatePanel() { + JPanel panel = form(); + addRow(panel, 0, DesktopUiMessages.IMAGEMAP_BLOCKS_PER_PIXEL, blocksPerPixelField); + addRow(panel, 1, DesktopUiMessages.IMAGEMAP_ORIGIN_X, originXField); + addRow(panel, 2, DesktopUiMessages.IMAGEMAP_ORIGIN_Z, originZField); + addRow(panel, 3, DesktopUiMessages.IMAGEMAP_SOURCE_ORIGIN_X, sourceOriginXField); + addRow(panel, 4, DesktopUiMessages.IMAGEMAP_SOURCE_ORIGIN_Z, sourceOriginZField); + addRow(panel, 5, DesktopUiMessages.IMAGEMAP_ROTATION, rotationPicker); + addRow(panel, 6, DesktopUiMessages.IMAGEMAP_MIRROR_X, mirrorXCheck); + addRow(panel, 7, DesktopUiMessages.IMAGEMAP_MIRROR_Z, mirrorZCheck); + addRow(panel, 8, DesktopUiMessages.IMAGEMAP_SAMPLING, samplingPicker); + addRow(panel, 9, DesktopUiMessages.IMAGEMAP_OUT_OF_BOUNDS, outOfBoundsPicker); + addRow(panel, 10, DesktopUiMessages.IMAGEMAP_ALPHA, alphaPicker); + addRow(panel, 11, DesktopUiMessages.IMAGEMAP_FALLBACK_VALUE, fallbackValueField); + addRow(panel, 12, DesktopUiMessages.IMAGEMAP_FALLBACK_TARGET, fallbackTargetField); + return panel; + } + + private JPanel heightPanel() { + JPanel panel = form(); + addRow(panel, 0, DesktopUiMessages.IMAGEMAP_MINIMUM_HEIGHT, minimumHeightField); + addRow(panel, 1, DesktopUiMessages.IMAGEMAP_MAXIMUM_HEIGHT, maximumHeightField); + addRow(panel, 2, DesktopUiMessages.IMAGEMAP_VERTICAL_OFFSET, verticalOffsetField); + addRow(panel, 3, DesktopUiMessages.IMAGEMAP_CLAMP, clampCheck); + addRow(panel, 4, DesktopUiMessages.IMAGEMAP_INVERTED, heightInvertedCheck); + addRow(panel, 5, DesktopUiMessages.IMAGEMAP_CURVE_EXPONENT, heightCurveExponentField); + addRow(panel, 6, DesktopUiMessages.IMAGEMAP_SMOOTHING_RADIUS, heightSmoothingRadiusField); + return section(DesktopUiMessages.IMAGEMAP_HEIGHT, panel); + } + + private JPanel maskPanel() { + JPanel panel = form(); + addRow(panel, 0, DesktopUiMessages.IMAGEMAP_THRESHOLD, thresholdField); + addRow(panel, 1, DesktopUiMessages.IMAGEMAP_FALLOFF, falloffField); + addRow(panel, 2, DesktopUiMessages.IMAGEMAP_INVERTED, maskInvertedCheck); + addRow(panel, 3, DesktopUiMessages.IMAGEMAP_CURVE_EXPONENT, maskCurveExponentField); + addRow(panel, 4, DesktopUiMessages.IMAGEMAP_SMOOTHING_RADIUS, maskSmoothingRadiusField); + return section(DesktopUiMessages.IMAGEMAP_MASK, panel); + } + + private JPanel colorPanel() { + JPanel panel = new JPanel(new BorderLayout(0, 7)); + panel.setOpaque(false); + JPanel settings = form(); + addRow(settings, 0, DesktopUiMessages.IMAGEMAP_COLOR_TOLERANCE, toleranceField); + addRow(settings, 1, DesktopUiMessages.IMAGEMAP_UNKNOWN_COLOR, unknownColorPicker); + panel.add(settings, BorderLayout.NORTH); + configureLegendTable(); + JScrollPane scroll = new JScrollPane(legendTable); + scroll.setPreferredSize(new Dimension(390, 160)); + scroll.setBorder(BorderFactory.createLineBorder(BORDER)); + panel.add(scroll, BorderLayout.CENTER); + JPanel actions = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 0)); + actions.setOpaque(false); + JButton add = button(DesktopUiMessages.IMAGEMAP_ADD_COLOR); + add.addActionListener(event -> legendModel.addRow(new Object[]{"#000000", "iris:"})); + actions.add(add); + JButton remove = button(DesktopUiMessages.IMAGEMAP_REMOVE_COLOR); + remove.addActionListener(event -> removeLegendRows()); + actions.add(remove); + panel.add(actions, BorderLayout.SOUTH); + return section(DesktopUiMessages.IMAGEMAP_COLOR_MAP, panel); + } + + private JPanel overlayPanel() { + JPanel panel = new JPanel(new FlowLayout(FlowLayout.LEFT, 9, 2)); + panel.setOpaque(false); + chunksOverlay.setText(text(DesktopUiMessages.IMAGEMAP_CHUNKS)); + regionsOverlay.setText(text(DesktopUiMessages.IMAGEMAP_REGIONS)); + boundaryOverlay.setText(text(DesktopUiMessages.IMAGEMAP_BOUNDARY)); + coverageOverlay.setText(text(DesktopUiMessages.IMAGEMAP_COVERAGE)); + panel.add(chunksOverlay); + panel.add(regionsOverlay); + panel.add(boundaryOverlay); + panel.add(coverageOverlay); + return panel; + } + + private JPanel composedMaskPanel() { + JPanel panel = new JPanel(new BorderLayout(0, 7)); + panel.setOpaque(false); + JScrollPane scroll = new JScrollPane(composedMaskTable); + scroll.setPreferredSize(new Dimension(390, 120)); + scroll.setBorder(BorderFactory.createLineBorder(BORDER)); + panel.add(scroll, BorderLayout.CENTER); + JPanel actions = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 0)); + actions.setOpaque(false); + JButton add = button(DesktopUiMessages.IMAGEMAP_ADD_MASK); + add.addActionListener(event -> composedMaskModel.addRow(new Object[]{ + "", IrisImageMapMaskOperation.MULTIPLY, false, "0", "0" + })); + actions.add(add); + JButton remove = button(DesktopUiMessages.IMAGEMAP_REMOVE_COLOR); + remove.addActionListener(event -> removeRows(composedMaskTable, composedMaskModel)); + actions.add(remove); + panel.add(actions, BorderLayout.SOUTH); + return panel; + } + + private JPanel diagnosticsPanel() { + diagnosticsArea.setText(text(DesktopUiMessages.IMAGEMAP_READY)); + return single(diagnosticsArea); + } + + private JPanel buildStatusBar() { + JPanel panel = new JPanel(new BorderLayout()); + panel.setBackground(PANEL_BACKGROUND); + panel.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createMatteBorder(1, 0, 0, 0, BORDER), + BorderFactory.createEmptyBorder(5, 9, 5, 9) + )); + statusLabel.setForeground(SECONDARY_TEXT); + statusLabel.setFont(BODY_FONT); + statusLabel.setText(packFolder.getName() + " | " + dimensionKey + " | " + + text(DesktopUiMessages.IMAGEMAP_READY)); + panel.add(statusLabel, BorderLayout.WEST); + return panel; + } + + private void refreshPresets() { + String[] keys = engine.getData().getImageMapLoader().getPossibleKeys(); + Arrays.sort(keys); + presetPicker.setModel(new DefaultComboBoxModel<>(keys)); + } + + private void loadPreset(String key) { + setStatus(DesktopUiMessages.IMAGEMAP_LOADING); + new SwingWorker() { + @Override + protected PresetState doInBackground() throws Exception { + IrisData data = IrisData.openRuntime(packFolder); + try { + IrisImageMap loaded = data.getImageMapLoader().load(key); + if (loaded == null) { + throw new IllegalArgumentException("Image-map preset '" + key + "' could not be loaded"); + } + IrisImageMap definition = data.getGson().fromJson(data.getGson().toJson(loaded), IrisImageMap.class); + IrisDimension dimension = data.getDimensionLoader().load(dimensionKey); + String bindingKey = key; + IrisImageMapApplication application = suggestedApplication(definition.getType()); + List masks = List.of(); + if (dimension != null) { + for (IrisImageMapBinding binding : dimension.getImageMaps()) { + if (binding != null && key.equals(binding.getMap())) { + bindingKey = binding.getKey(); + application = binding.getApplication(); + masks = binding.getMasks() == null + ? List.of() + : List.copyOf(binding.getMasks()); + break; + } + } + } + File image = data.getImageLoader().findFile(definition.getSource()); + if (image == null || !image.isFile()) { + throw new IllegalArgumentException("Preset source PNG is missing: " + definition.getSource()); + } + IrisWorldBoundary loadedBoundary = dimension == null || dimension.getWorldBoundary() == null + ? null + : IrisWorldBoundary.snapshot(dimension.getWorldBoundary()); + return new PresetState( + definition, image.toPath(), key, bindingKey, application, masks, loadedBoundary + ); + } finally { + data.close(); + } + } + + @Override + protected void done() { + try { + PresetState state = get(); + boundary = state.boundary(); + sourcePath = state.source(); + controlsUpdating = true; + applyDefinition(state.definition()); + mapKeyField.setText(state.mapKey()); + bindingKeyField.setText(state.bindingKey()); + applicationPicker.setSelectedItem(state.application()); + applyMasks(state.masks()); + controlsUpdating = false; + preview(); + } catch (Exception exception) { + controlsUpdating = false; + showFailure(text(DesktopUiMessages.IMAGEMAP_LOAD_FAILED), exception); + } + } + }.execute(); + } + + private void chooseSource(boolean replacement) { + JFileChooser chooser = new JFileChooser(sourcePath == null ? packFolder : sourcePath.toFile().getParentFile()); + chooser.setDialogTitle(text(replacement + ? DesktopUiMessages.IMAGEMAP_REPLACE_PNG + : DesktopUiMessages.IMAGEMAP_IMPORT_PNG)); + chooser.setFileFilter(new FileNameExtensionFilter("PNG", "png")); + if (chooser.showOpenDialog(frame) != JFileChooser.APPROVE_OPTION) { + return; + } + sourcePath = chooser.getSelectedFile().toPath().toAbsolutePath().normalize(); + if (!replacement) { + String key = ImageMapStudioModel.safeKey(chooser.getSelectedFile().getName()); + bindingKeyField.setText(key); + mapKeyField.setText(key); + imageKeyField.setText(key); + } + inspectSourceAndPreview(); + } + + private void inspectSourceAndPreview() { + Path inspectedPath = sourcePath; + setStatus(DesktopUiMessages.IMAGEMAP_PREVIEWING); + new SwingWorker() { + @Override + protected ImageMapStudioExporter.SourceInspection doInBackground() throws Exception { + return ImageMapStudioExporter.inspectSource(inspectedPath); + } + + @Override + protected void done() { + try { + ImageMapStudioExporter.SourceInspection inspection = get(); + ImageMapStudioModel.SourceMetadata metadata = ImageMapStudioModel.inspect( + inspectedPath, + inspection.source(), + inspection.format(), + inspection.colorProfile(), + inspection.minimumAlpha(), + inspection.maximumAlpha() + ); + metadataArea.setText(metadata.path() + "\n" + metadata.summary()); + previewPanel.setSource(inspection.source()); + preview(); + } catch (Exception exception) { + showFailure(text(DesktopUiMessages.IMAGEMAP_PREVIEW_FAILED), exception); + } + } + }.execute(); + } + + private void preview() { + IrisImageMap definition; + List currentMasks; + try { + definition = readDefinition(); + requireSource(); + currentMasks = readMasks(); + validateMaskApplication(currentMasks); + } catch (RuntimeException exception) { + showFailure(text(DesktopUiMessages.IMAGEMAP_PREVIEW_FAILED), exception); + return; + } + setStatus(DesktopUiMessages.IMAGEMAP_PREVIEWING); + diagnosticsArea.setText(""); + Path currentSource = sourcePath; + IrisImageMapApplication currentApplication = selected(applicationPicker, "application"); + DoubleBinaryOperator proceduralHeightSampler = null; + if (currentApplication == IrisImageMapApplication.TERRAIN_HEIGHT + && !engine.isClosed() + && engine.getComplex() != null) { + proceduralHeightSampler = (worldX, worldZ) -> engine.getComplex() + .sampleProceduralTerrainHeight(engine, worldX, worldZ); + } + DoubleBinaryOperator currentProceduralHeightSampler = proceduralHeightSampler; + new SwingWorker() { + @Override + protected ImageMapStudioExporter.PreviewResult doInBackground() throws Exception { + return ImageMapStudioExporter.preview( + currentSource, definition, packFolder, dimensionKey, currentMasks + ); + } + + @Override + protected void done() { + try { + ImageMapStudioExporter.PreviewResult result = get(); + ImageMapStudioModel.SourceMetadata metadata = ImageMapStudioModel.inspect( + currentSource, + result.source(), + result.compiled().getSourceMetadata().format(), + result.colorProfile(), + result.compiled().getSourceMetadata().minimumAlpha(), + result.compiled().getSourceMetadata().maximumAlpha() + ); + metadataArea.setText(metadata.path() + "\n" + metadata.summary()); + previewPanel.setPreview( + result.source(), result.compiled(), result.maskSampler(), boundary, + currentApplication, engine.getMinHeight(), currentProceduralHeightSampler + ); + List warnings = ImageMapStudioModel.warnings( + definition, metadata.width(), metadata.height(), boundary + ); + warnings = new ArrayList<>(warnings); + if (result.compiled().getClippedPixelCount() > 0) { + warnings.add(result.compiled().getClippedPixelCount() + + " source height pixel(s) are clipped to the configured vertical range."); + } + if (result.compiled().getUnknownColorPixelCount() > 0) { + warnings.add(result.compiled().getUnknownColorPixelCount() + + " source color pixel(s) are absent from the legend and use the unknown-color policy."); + } + diagnosticsArea.setText(warnings.isEmpty() + ? text(DesktopUiMessages.IMAGEMAP_PREVIEW_VALID) + : String.join("\n", warnings)); + diagnosticsArea.setForeground(warnings.isEmpty() ? SUCCESS : PRIMARY_TEXT); + setStatus(DesktopUiMessages.IMAGEMAP_READY); + } catch (Exception exception) { + showFailure(text(DesktopUiMessages.IMAGEMAP_PREVIEW_FAILED), exception); + } + } + }.execute(); + } + + private void export() { + IrisImageMap definition; + List masks; + try { + definition = readDefinition(); + requireSource(); + masks = readMasks(); + validateMaskApplication(masks); + } catch (RuntimeException exception) { + showFailure(text(DesktopUiMessages.IMAGEMAP_EXPORT_FAILED), exception); + return; + } + String bindingKey = bindingKeyField.getText().trim(); + String mapKey = mapKeyField.getText().trim(); + String imageKey = imageKeyField.getText().trim(); + IrisImageMapApplication application = selected(applicationPicker, "application"); + ImageMapStudioExporter.ExportRequest request = new ImageMapStudioExporter.ExportRequest( + packFolder, + dimensionKey, + bindingKey, + application, + mapKey, + imageKey, + definition, + sourcePath, + masks + ); + setStatus(DesktopUiMessages.IMAGEMAP_EXPORTING); + new SwingWorker() { + @Override + protected ImageMapStudioExporter.ExportResult doInBackground() throws Exception { + ImageMapStudioExporter.ExportResult result = ImageMapStudioExporter.export(request); + reloadActiveEngine(engine); + return result; + } + + @Override + protected void done() { + try { + ImageMapStudioExporter.ExportResult result = get(); + StringBuilder message = new StringBuilder(text(DesktopUiMessages.IMAGEMAP_EXPORTED)); + message.append('\n').append(result.imageMapFile()); + message.append('\n').append(result.imageFile()); + message.append('\n').append(result.dimensionFile()); + for (String warning : result.warnings()) { + message.append('\n').append(warning); + } + diagnosticsArea.setForeground(SUCCESS); + diagnosticsArea.setText(message.toString()); + setStatus(DesktopUiMessages.IMAGEMAP_EXPORTED); + refreshPresets(); + presetPicker.setSelectedItem(mapKey); + } catch (Exception exception) { + showFailure(text(DesktopUiMessages.IMAGEMAP_EXPORT_FAILED), exception); + } + } + }.execute(); + } + + private IrisImageMap readDefinition() { + IrisImageMapType type = selected(typePicker, "type"); + IrisImageMap defaults = new IrisImageMap(); + boolean heightType = type == IrisImageMapType.GRAYSCALE_HEIGHT || type == IrisImageMapType.RGB_HEIGHT; + boolean maskType = type == IrisImageMapType.BINARY_MASK + || type == IrisImageMapType.GRAYSCALE_MASK + || type == IrisImageMapType.ALPHA_MASK; + boolean colorType = type == IrisImageMapType.COLOR_MAP; + IrisImageMap definition = new IrisImageMap() + .setSource(requiredText(imageKeyField, "Image key")) + .setType(type) + .setBlocksPerPixel(number(blocksPerPixelField, "Blocks per pixel")) + .setOrigin(new IrisImageMapOrigin( + number(originXField, "Origin X"), + number(originZField, "Origin Z") + )) + .setSourceOrigin(new IrisImageMapOrigin( + number(sourceOriginXField, "Source origin X"), + number(sourceOriginZField, "Source origin Z") + )) + .setRotation(selected(rotationPicker, "rotation")) + .setMirrorX(mirrorXCheck.isSelected()) + .setMirrorZ(mirrorZCheck.isSelected()) + .setSampling(selected(samplingPicker, "sampling")) + .setOutOfBounds(selected(outOfBoundsPicker, "out-of-bounds policy")) + .setFallbackValue(number(fallbackValueField, "Fallback value")) + .setFallbackTarget(fallbackTargetField.getText().trim()) + .setAlpha(selected(alphaPicker, "alpha policy")) + .setMinimumHeight(heightType + ? number(minimumHeightField, "Minimum height") : defaults.getMinimumHeight()) + .setMaximumHeight(heightType + ? number(maximumHeightField, "Maximum height") : defaults.getMaximumHeight()) + .setVerticalOffset(heightType + ? number(verticalOffsetField, "Vertical offset") : defaults.getVerticalOffset()) + .setClamp(heightType ? clampCheck.isSelected() : defaults.isClamp()) + .setInverted(heightType + ? heightInvertedCheck.isSelected() + : maskType && maskInvertedCheck.isSelected()) + .setCurveExponent(heightType + ? number(heightCurveExponentField, "Curve exponent") + : maskType + ? number(maskCurveExponentField, "Curve exponent") + : defaults.getCurveExponent()) + .setSmoothingRadius(heightType + ? integer(heightSmoothingRadiusField, "Smoothing radius") + : maskType + ? integer(maskSmoothingRadiusField, "Smoothing radius") + : defaults.getSmoothingRadius()) + .setThreshold(type == IrisImageMapType.BINARY_MASK + ? number(thresholdField, "Threshold") : defaults.getThreshold()) + .setFalloff(type == IrisImageMapType.BINARY_MASK + ? number(falloffField, "Falloff") : defaults.getFalloff()) + .setColorTolerance(colorType + ? number(toleranceField, "Color tolerance") : defaults.getColorTolerance()) + .setUnknownColor(colorType + ? selected(unknownColorPicker, "unknown-color policy") + : defaults.getUnknownColor()); + if (colorType) { + List rows = new ArrayList<>(); + for (int row = 0; row < legendModel.getRowCount(); row++) { + rows.add(new ImageMapStudioModel.LegendRow( + String.valueOf(legendModel.getValueAt(row, 0)), + String.valueOf(legendModel.getValueAt(row, 1)) + )); + } + KMap colors = ImageMapStudioModel.legend(rows); + definition.setColors(colors); + } + requiredText(bindingKeyField, "Binding key"); + requiredText(mapKeyField, "Image-map key"); + return ImageMapStudioModel.normalizeTypeSettings(definition); + } + + private void applyDefinition(IrisImageMap definition) { + imageKeyField.setText(definition.getSource()); + typePicker.setSelectedItem(definition.getType()); + blocksPerPixelField.setText(Double.toString(definition.getBlocksPerPixel())); + originXField.setText(Double.toString(definition.getOrigin().getX())); + originZField.setText(Double.toString(definition.getOrigin().getZ())); + sourceOriginXField.setText(Double.toString(definition.getSourceOrigin().getX())); + sourceOriginZField.setText(Double.toString(definition.getSourceOrigin().getZ())); + rotationPicker.setSelectedItem(definition.getRotation()); + mirrorXCheck.setSelected(definition.isMirrorX()); + mirrorZCheck.setSelected(definition.isMirrorZ()); + samplingPicker.setSelectedItem(definition.getSampling()); + outOfBoundsPicker.setSelectedItem(definition.getOutOfBounds()); + fallbackValueField.setText(Double.toString(definition.getFallbackValue())); + fallbackTargetField.setText(definition.getFallbackTarget()); + alphaPicker.setSelectedItem(definition.getAlpha()); + minimumHeightField.setText(Double.toString(definition.getMinimumHeight())); + maximumHeightField.setText(Double.toString(definition.getMaximumHeight())); + verticalOffsetField.setText(Double.toString(definition.getVerticalOffset())); + clampCheck.setSelected(definition.isClamp()); + heightInvertedCheck.setSelected(definition.isInverted()); + heightCurveExponentField.setText(Double.toString(definition.getCurveExponent())); + heightSmoothingRadiusField.setText(Integer.toString(definition.getSmoothingRadius())); + maskInvertedCheck.setSelected(definition.isInverted()); + maskCurveExponentField.setText(Double.toString(definition.getCurveExponent())); + maskSmoothingRadiusField.setText(Integer.toString(definition.getSmoothingRadius())); + thresholdField.setText(Double.toString(definition.getThreshold())); + falloffField.setText(Double.toString(definition.getFalloff())); + toleranceField.setText(Double.toString(definition.getColorTolerance())); + unknownColorPicker.setSelectedItem(definition.getUnknownColor()); + legendModel.setRowCount(0); + for (ImageMapStudioModel.LegendRow row : ImageMapStudioModel.legendRows(definition)) { + legendModel.addRow(new Object[]{row.color(), row.target()}); + } + updateTypeControls(); + } + + private void updateTypeControls() { + IrisImageMapType type = (IrisImageMapType) typePicker.getSelectedItem(); + if (type == null) { + return; + } + CardLayout layout = (CardLayout) typeCards.getLayout(); + layout.show(typeCards, typeCard(type)); + thresholdField.setEnabled(type == IrisImageMapType.BINARY_MASK); + falloffField.setEnabled(type == IrisImageMapType.BINARY_MASK); + if (controlsUpdating) { + return; + } + if (type == IrisImageMapType.COLOR_MAP || type == IrisImageMapType.BINARY_MASK) { + samplingPicker.setSelectedItem(IrisImageMapSampling.NEAREST); + } + if (type == IrisImageMapType.ALPHA_MASK) { + alphaPicker.setSelectedItem(IrisImageMapAlpha.IGNORE); + } + applicationPicker.setSelectedItem(suggestedApplication(type)); + } + + private static IrisImageMapApplication suggestedApplication(IrisImageMapType type) { + if (type == null) { + return IrisImageMapApplication.CUSTOM; + } + return switch (type) { + case GRAYSCALE_HEIGHT, RGB_HEIGHT -> IrisImageMapApplication.TERRAIN_HEIGHT; + case COLOR_MAP -> IrisImageMapApplication.BIOME; + case BINARY_MASK, GRAYSCALE_MASK, ALPHA_MASK -> IrisImageMapApplication.MASK; + }; + } + + private static String typeCard(IrisImageMapType type) { + return switch (type) { + case GRAYSCALE_HEIGHT, RGB_HEIGHT -> "HEIGHT"; + case COLOR_MAP -> "COLOR"; + case BINARY_MASK, GRAYSCALE_MASK, ALPHA_MASK -> "MASK"; + }; + } + + private void updateOverlays() { + previewPanel.setOverlays( + chunksOverlay.isSelected(), + regionsOverlay.isSelected(), + boundaryOverlay.isSelected(), + coverageOverlay.isSelected() + ); + } + + private void configureLegendTable() { + legendTable.setBackground(FIELD_BACKGROUND); + legendTable.setForeground(PRIMARY_TEXT); + legendTable.setGridColor(BORDER); + legendTable.setSelectionBackground(new Color(42, 55, 78)); + legendTable.setSelectionForeground(PRIMARY_TEXT); + legendTable.setFont(BODY_FONT); + legendTable.setRowHeight(23); + legendTable.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION); + legendTable.getTableHeader().setBackground(PANEL_BACKGROUND); + legendTable.getTableHeader().setForeground(PRIMARY_TEXT); + } + + private void configureComposedMaskTable() { + composedMaskTable.setBackground(FIELD_BACKGROUND); + composedMaskTable.setForeground(PRIMARY_TEXT); + composedMaskTable.setGridColor(BORDER); + composedMaskTable.setSelectionBackground(new Color(42, 55, 78)); + composedMaskTable.setSelectionForeground(PRIMARY_TEXT); + composedMaskTable.setFont(BODY_FONT); + composedMaskTable.setRowHeight(23); + composedMaskTable.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION); + composedMaskTable.getTableHeader().setBackground(PANEL_BACKGROUND); + composedMaskTable.getTableHeader().setForeground(PRIMARY_TEXT); + composedMaskTable.getColumnModel().getColumn(1).setCellEditor( + new DefaultCellEditor(new JComboBox<>(IrisImageMapMaskOperation.values())) + ); + } + + private void removeLegendRows() { + removeRows(legendTable, legendModel); + } + + private static void removeRows(JTable table, DefaultTableModel model) { + int[] selected = table.getSelectedRows(); + for (int index = selected.length - 1; index >= 0; index--) { + model.removeRow(selected[index]); + } + } + + private List readMasks() { + List rows = new ArrayList<>(); + for (int row = 0; row < composedMaskModel.getRowCount(); row++) { + rows.add(ImageMapStudioModel.maskRow( + String.valueOf(composedMaskModel.getValueAt(row, 0)), + composedMaskModel.getValueAt(row, 1), + Boolean.TRUE.equals(composedMaskModel.getValueAt(row, 2)), + composedMaskModel.getValueAt(row, 3), + composedMaskModel.getValueAt(row, 4) + )); + } + return ImageMapStudioModel.masks(rows); + } + + private void applyMasks(List masks) { + composedMaskModel.setRowCount(0); + for (ImageMapStudioModel.MaskRow row : ImageMapStudioModel.maskRows(masks)) { + composedMaskModel.addRow(new Object[]{ + row.map(), row.operation(), row.inverted(), row.threshold(), row.falloff() + }); + } + } + + private void validateMaskApplication(List masks) { + if (selected(applicationPicker, "application") == IrisImageMapApplication.MASK && !masks.isEmpty()) { + throw new IllegalArgumentException("A MASK binding cannot reference composed masks"); + } + } + + private void appendDiagnostic(String diagnostic) { + if (diagnostic == null || diagnostic.isBlank()) { + return; + } + String current = diagnosticsArea.getText(); + diagnosticsArea.setForeground(ERROR); + diagnosticsArea.setText(current.isBlank() ? diagnostic : current + "\n" + diagnostic); + } + + private void showFailure(String prefix, Exception exception) { + Throwable cause = exception; + while (cause.getCause() != null && cause != cause.getCause()) { + cause = cause.getCause(); + } + if (!(cause instanceof IllegalArgumentException)) { + IrisLogging.reportError(cause); + } + diagnosticsArea.setForeground(ERROR); + diagnosticsArea.setText(prefix + ": " + String.valueOf(cause.getMessage())); + statusLabel.setText(prefix); + } + + private void setStatus(TextKey key) { + statusLabel.setText(packFolder.getName() + " | " + dimensionKey + " | " + text(key)); + } + + private void requireSource() { + if (sourcePath == null) { + throw new IllegalArgumentException(text(DesktopUiMessages.IMAGEMAP_NO_SOURCE)); + } + } + + private static JPanel form() { + JPanel panel = new JPanel(new GridBagLayout()); + panel.setOpaque(false); + return panel; + } + + private static void addRow(JPanel panel, int row, TextKey key, Component component) { + GridBagConstraints labelConstraints = new GridBagConstraints(); + labelConstraints.gridx = 0; + labelConstraints.gridy = row; + labelConstraints.anchor = GridBagConstraints.WEST; + labelConstraints.insets = new Insets(3, 0, 3, 8); + JLabel label = label(key); + panel.add(label, labelConstraints); + + GridBagConstraints componentConstraints = new GridBagConstraints(); + componentConstraints.gridx = 1; + componentConstraints.gridy = row; + componentConstraints.weightx = 1D; + componentConstraints.fill = GridBagConstraints.HORIZONTAL; + componentConstraints.insets = new Insets(3, 0, 3, 0); + panel.add(component, componentConstraints); + } + + private static JPanel section(TextKey title, Component content) { + JPanel panel = new JPanel(new BorderLayout()); + panel.setAlignmentX(Component.LEFT_ALIGNMENT); + panel.setBackground(PANEL_BACKGROUND); + panel.setBorder(BorderFactory.createLineBorder(BORDER)); + JLabel heading = new JLabel(text(title)); + heading.setForeground(PRIMARY_TEXT); + heading.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 12)); + heading.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createMatteBorder(0, 0, 1, 0, BORDER), + BorderFactory.createEmptyBorder(7, 9, 7, 9) + )); + panel.add(heading, BorderLayout.NORTH); + JPanel body = new JPanel(new BorderLayout()); + body.setOpaque(false); + body.setBorder(BorderFactory.createEmptyBorder(8, 9, 9, 9)); + body.add(content, BorderLayout.CENTER); + panel.add(body, BorderLayout.CENTER); + panel.setMaximumSize(new Dimension(Integer.MAX_VALUE, panel.getPreferredSize().height + 300)); + return panel; + } + + private static JPanel single(Component component) { + JPanel panel = new JPanel(new BorderLayout()); + panel.setOpaque(false); + panel.add(component, BorderLayout.CENTER); + return panel; + } + + private static JLabel label(TextKey key) { + JLabel label = new JLabel(text(key)); + label.setForeground(SECONDARY_TEXT); + label.setFont(LABEL_FONT); + return label; + } + + private static JButton button(TextKey key) { + JButton button = new JButton(text(key)); + button.setBackground(FIELD_BACKGROUND); + button.setForeground(PRIMARY_TEXT); + button.setFocusPainted(false); + button.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createLineBorder(BORDER), + BorderFactory.createEmptyBorder(5, 9, 5, 9) + )); + return button; + } + + private static JTextField field(String value) { + JTextField field = new JTextField(value); + field.setBackground(FIELD_BACKGROUND); + field.setForeground(PRIMARY_TEXT); + field.setCaretColor(PRIMARY_TEXT); + field.setFont(BODY_FONT); + field.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createLineBorder(BORDER), + BorderFactory.createEmptyBorder(5, 7, 5, 7) + )); + return field; + } + + private static JTextArea area(int rows) { + JTextArea area = new JTextArea(rows, 20); + area.setEditable(false); + area.setLineWrap(true); + area.setWrapStyleWord(true); + area.setBackground(FIELD_BACKGROUND); + area.setForeground(PRIMARY_TEXT); + area.setFont(BODY_FONT); + area.setBorder(BorderFactory.createEmptyBorder(6, 7, 6, 7)); + return area; + } + + private static JCheckBox check() { + JCheckBox check = new JCheckBox(); + check.setOpaque(false); + check.setForeground(PRIMARY_TEXT); + check.setFocusPainted(false); + return check; + } + + private static JCheckBox selectedCheck() { + JCheckBox check = check(); + check.setSelected(true); + return check; + } + + private static JComboBox combo(T[] values) { + JComboBox combo = new JComboBox<>(values); + styleCombo(combo); + return combo; + } + + private static void styleCombo(JComboBox combo) { + combo.setBackground(FIELD_BACKGROUND); + combo.setForeground(PRIMARY_TEXT); + combo.setFont(BODY_FONT); + combo.setFocusable(false); + } + + private static double number(JTextField field, String name) { + double value; + try { + value = Double.parseDouble(field.getText().trim()); + } catch (NumberFormatException exception) { + throw new IllegalArgumentException(name + " must be a number", exception); + } + if (!Double.isFinite(value)) { + throw new IllegalArgumentException(name + " must be finite"); + } + return value; + } + + private static int integer(JTextField field, String name) { + try { + return Integer.parseInt(field.getText().trim()); + } catch (NumberFormatException exception) { + throw new IllegalArgumentException(name + " must be an integer", exception); + } + } + + private static String requiredText(JTextField field, String name) { + String value = field.getText().trim(); + if (value.isEmpty()) { + throw new IllegalArgumentException(name + " must not be blank"); + } + return value; + } + + private static T selected(JComboBox combo, String name) { + int index = combo.getSelectedIndex(); + if (index < 0) { + throw new IllegalArgumentException("A " + name + " is required"); + } + return combo.getItemAt(index); + } + + private static String text(TextKey key) { + return IrisLanguage.plain(key); + } + + private record PresetState( + IrisImageMap definition, + Path source, + String mapKey, + String bindingKey, + IrisImageMapApplication application, + List masks, + IrisWorldBoundary boundary + ) { + } +} diff --git a/core/src/main/java/art/arcane/iris/core/gui/ImageMapStudioModel.java b/core/src/main/java/art/arcane/iris/core/gui/ImageMapStudioModel.java new file mode 100644 index 000000000..27f704db3 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/gui/ImageMapStudioModel.java @@ -0,0 +1,411 @@ +package art.arcane.iris.core.gui; + +import art.arcane.iris.engine.object.IrisImage; +import art.arcane.iris.engine.object.IrisImageMap; +import art.arcane.iris.engine.object.IrisImageMapOrigin; +import art.arcane.iris.engine.object.IrisImageMapMask; +import art.arcane.iris.engine.object.IrisImageMapMaskOperation; +import art.arcane.iris.engine.object.IrisImageMapRotation; +import art.arcane.iris.engine.object.IrisImageMapType; +import art.arcane.iris.engine.object.IrisImageMapUnknownColor; +import art.arcane.iris.engine.object.IrisWorldBoundary; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.collection.KMap; + +import java.awt.geom.Line2D; +import java.awt.geom.Point2D; +import java.awt.image.BufferedImage; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Pattern; + +public final class ImageMapStudioModel { + private static final Pattern COLOR = Pattern.compile("#[0-9a-fA-F]{6}"); + private static final Pattern KEY_CHARACTER = Pattern.compile("[^a-z0-9/._-]"); + + private ImageMapStudioModel() { + } + + public static SourceMetadata inspect(Path path, BufferedImage image, String format) { + return inspect(path, image, format, "not inspected", 1D, 1D); + } + + public static SourceMetadata inspect( + Path path, + BufferedImage image, + String format, + String colorProfile, + double minimumAlpha, + double maximumAlpha + ) { + IrisImage irisImage = new IrisImage(image, format); + return new SourceMetadata( + path.toAbsolutePath().normalize(), + irisImage.getFormat(), + irisImage.getWidth(), + irisImage.getHeight(), + (long) irisImage.getWidth() * irisImage.getHeight(), + irisImage.getColorMode().name(), + irisImage.getColorComponentCount(), + irisImage.getChannelCount(), + irisImage.getBitDepth(), + irisImage.hasAlpha(), + minimumAlpha, + maximumAlpha, + colorProfile + ); + } + + public static KMap legend(List rows) { + KMap colors = new KMap<>(); + for (LegendRow row : rows) { + String color = row.color() == null ? "" : row.color().trim().toUpperCase(Locale.ROOT); + String target = row.target() == null ? "" : row.target().trim(); + if (color.isEmpty() && target.isEmpty()) { + continue; + } + if (!COLOR.matcher(color).matches()) { + throw new IllegalArgumentException("Legend color must use #RRGGBB syntax: " + color); + } + if (target.isEmpty()) { + throw new IllegalArgumentException("Legend target must not be blank for " + color); + } + String previous = colors.put(color, target); + if (previous != null) { + throw new IllegalArgumentException("Legend contains duplicate color " + color); + } + } + return colors; + } + + public static List legendRows(IrisImageMap definition) { + List rows = new ArrayList<>(); + if (definition.getColors() == null) { + return rows; + } + for (Map.Entry entry : definition.getColors().entrySet()) { + rows.add(new LegendRow(entry.getKey().toUpperCase(Locale.ROOT), entry.getValue())); + } + rows.sort(Comparator.comparing(LegendRow::color)); + return rows; + } + + public static IrisImageMap normalizeTypeSettings(IrisImageMap definition) { + IrisImageMapType type = definition.getType(); + if (type == null) { + return definition; + } + IrisImageMap defaults = new IrisImageMap(); + switch (type) { + case GRAYSCALE_HEIGHT, RGB_HEIGHT -> definition + .setThreshold(defaults.getThreshold()) + .setFalloff(defaults.getFalloff()) + .setColorTolerance(defaults.getColorTolerance()) + .setUnknownColor(IrisImageMapUnknownColor.ERROR) + .setColors(new KMap<>()); + case COLOR_MAP -> definition + .setMinimumHeight(defaults.getMinimumHeight()) + .setMaximumHeight(defaults.getMaximumHeight()) + .setVerticalOffset(defaults.getVerticalOffset()) + .setClamp(defaults.isClamp()) + .setInverted(defaults.isInverted()) + .setCurveExponent(defaults.getCurveExponent()) + .setSmoothingRadius(defaults.getSmoothingRadius()) + .setThreshold(defaults.getThreshold()) + .setFalloff(defaults.getFalloff()); + case BINARY_MASK, GRAYSCALE_MASK, ALPHA_MASK -> { + definition + .setMinimumHeight(defaults.getMinimumHeight()) + .setMaximumHeight(defaults.getMaximumHeight()) + .setVerticalOffset(defaults.getVerticalOffset()) + .setClamp(defaults.isClamp()) + .setColorTolerance(defaults.getColorTolerance()) + .setUnknownColor(IrisImageMapUnknownColor.ERROR) + .setColors(new KMap<>()); + if (type != IrisImageMapType.BINARY_MASK) { + definition + .setThreshold(defaults.getThreshold()) + .setFalloff(defaults.getFalloff()); + } + } + } + return definition; + } + + public static KList masks(List rows) { + KList masks = new KList<>(); + for (MaskRow row : rows) { + masks.add(new IrisImageMapMask() + .setMap(row.map()) + .setOperation(row.operation()) + .setInverted(row.inverted()) + .setThreshold(row.threshold()) + .setFalloff(row.falloff())); + } + return masks; + } + + public static MaskRow maskRow( + String mapValue, + Object operationValue, + boolean inverted, + Object thresholdValue, + Object falloffValue + ) { + String map = mapValue == null ? "" : mapValue.trim(); + if (map.isBlank()) { + throw new IllegalArgumentException("Composed mask binding key must not be blank"); + } + IrisImageMapMaskOperation operation; + try { + operation = IrisImageMapMaskOperation.valueOf( + String.valueOf(operationValue).trim().toUpperCase(Locale.ROOT) + ); + } catch (IllegalArgumentException exception) { + throw new IllegalArgumentException("Unknown mask operation for '" + map + "'", exception); + } + double threshold = unitValue(thresholdValue, "Mask threshold for '" + map + "'"); + double falloff = unitValue(falloffValue, "Mask falloff for '" + map + "'"); + return new MaskRow(map, operation, inverted, threshold, falloff); + } + + public static List maskRows(List masks) { + List rows = new ArrayList<>(); + if (masks == null) { + return rows; + } + for (IrisImageMapMask mask : masks) { + if (mask != null) { + rows.add(new MaskRow( + mask.getMap(), + mask.getOperation(), + mask.isInverted(), + mask.getThreshold(), + mask.getFalloff() + )); + } + } + return rows; + } + + public static String safeKey(String fileName) { + String normalized = fileName == null ? "image-map" : fileName.trim().toLowerCase(Locale.ROOT); + if (normalized.endsWith(".png")) { + normalized = normalized.substring(0, normalized.length() - 4); + } + normalized = KEY_CHARACTER.matcher(normalized).replaceAll("-"); + while (normalized.contains("--")) { + normalized = normalized.replace("--", "-"); + } + normalized = normalized.replaceAll("^[./_-]+|[./_-]+$", ""); + return normalized.isBlank() ? "image-map" : normalized; + } + + public static List warnings( + IrisImageMap definition, + int sourceWidth, + int sourceHeight, + IrisWorldBoundary boundary + ) { + List warnings = new ArrayList<>(); + double blocksPerPixel = definition.getBlocksPerPixel(); + if (Double.isFinite(blocksPerPixel)) { + if (blocksPerPixel < 1D) { + warnings.add("Sub-block source scale will discard detail during block generation."); + } else if (blocksPerPixel > 512D) { + warnings.add("Each source pixel spans more than one Minecraft region (512 blocks)."); + } + } + long worldWidth = Math.round(sourceWidth * blocksPerPixel); + long worldHeight = Math.round(sourceHeight * blocksPerPixel); + if (worldWidth < 16L || worldHeight < 16L) { + warnings.add("The transformed source covers less than one complete chunk on at least one axis."); + } + if (boundary != null) { + Point2D.Double[] polygon = sourceWorldCorners(definition, sourceWidth, sourceHeight); + Point2D.Double[] borderCorners = new Point2D.Double[]{ + new Point2D.Double(boundary.minimumX(), boundary.minimumZ()), + new Point2D.Double(boundary.maximumX(), boundary.minimumZ()), + new Point2D.Double(boundary.maximumX(), boundary.maximumZ()), + new Point2D.Double(boundary.minimumX(), boundary.maximumZ()) + }; + for (Point2D.Double corner : borderCorners) { + if (!contains(polygon, corner)) { + warnings.add("The configured world boundary is not fully covered by the source image."); + break; + } + } + double minimumX = Double.POSITIVE_INFINITY; + double maximumX = Double.NEGATIVE_INFINITY; + double minimumZ = Double.POSITIVE_INFINITY; + double maximumZ = Double.NEGATIVE_INFINITY; + boolean outsideBoundary = false; + for (Point2D.Double corner : polygon) { + minimumX = Math.min(minimumX, corner.x); + maximumX = Math.max(maximumX, corner.x); + minimumZ = Math.min(minimumZ, corner.y); + maximumZ = Math.max(maximumZ, corner.y); + if (corner.x < boundary.minimumX() || corner.x > boundary.maximumX() + || corner.y < boundary.minimumZ() || corner.y > boundary.maximumZ()) { + outsideBoundary = true; + } + } + if (outsideBoundary) { + warnings.add("The source image extends outside the world boundary; mapped content there is not playable."); + } + double footprintWidth = maximumX - minimumX; + double footprintHeight = maximumZ - minimumZ; + if (footprintWidth > boundary.getSize() * 2D || footprintHeight > boundary.getSize() * 2D) { + warnings.add("The transformed source is substantially larger than the playable boundary."); + } else if (footprintWidth < boundary.getSize() * 0.5D + || footprintHeight < boundary.getSize() * 0.5D) { + warnings.add("The transformed source is substantially smaller than the playable boundary."); + } + } + return List.copyOf(warnings); + } + + public static Point2D.Double[] sourceWorldCorners(IrisImageMap definition, int width, int height) { + return new Point2D.Double[]{ + sourceToWorld(definition, 0D, 0D), + sourceToWorld(definition, width, 0D), + sourceToWorld(definition, width, height), + sourceToWorld(definition, 0D, height) + }; + } + + public static Point2D.Double sourceToWorld(IrisImageMap definition, double sourceX, double sourceZ) { + IrisImageMapOrigin origin = definition.getOrigin(); + IrisImageMapOrigin sourceOrigin = definition.getSourceOrigin(); + double x = sourceX - sourceOrigin.getX(); + double z = sourceZ - sourceOrigin.getZ(); + if (definition.isMirrorX()) { + x = -x; + } + if (definition.isMirrorZ()) { + z = -z; + } + Point2D.Double rotated = rotateForward(x, z, definition.getRotation()); + return new Point2D.Double( + origin.getX() + (rotated.x * definition.getBlocksPerPixel()), + origin.getZ() + (rotated.y * definition.getBlocksPerPixel()) + ); + } + + public static int targetColor(String target) { + if (target == null) { + return 0xFF252936; + } + int hash = target.hashCode(); + int red = 72 + Math.floorMod(hash, 152); + int green = 72 + Math.floorMod(hash >>> 8, 152); + int blue = 72 + Math.floorMod(hash >>> 16, 152); + return 0xFF000000 | (red << 16) | (green << 8) | blue; + } + + public static int heightColor(double height, double minimum, double maximum) { + double range = maximum - minimum; + double normalized = range == 0D ? 0.5D : Math.max(0D, Math.min(1D, (height - minimum) / range)); + double red; + double green; + double blue; + if (normalized < 0.5D) { + double factor = normalized * 2D; + red = 38D + (45D * factor); + green = 77D + (111D * factor); + blue = 132D - (42D * factor); + } else { + double factor = (normalized - 0.5D) * 2D; + red = 83D + (169D * factor); + green = 188D + (58D * factor); + blue = 90D + (150D * factor); + } + return 0xFF000000 | ((int) red << 16) | ((int) green << 8) | (int) blue; + } + + private static Point2D.Double rotateForward(double x, double z, IrisImageMapRotation rotation) { + return switch (rotation) { + case DEG_0 -> new Point2D.Double(x, z); + case DEG_90 -> new Point2D.Double(-z, x); + case DEG_180 -> new Point2D.Double(-x, -z); + case DEG_270 -> new Point2D.Double(z, -x); + }; + } + + private static boolean contains(Point2D.Double[] polygon, Point2D.Double point) { + boolean inside = false; + int previous = polygon.length - 1; + for (int index = 0; index < polygon.length; index++) { + Point2D.Double current = polygon[index]; + Point2D.Double before = polygon[previous]; + if (Line2D.ptSegDist(before.x, before.y, current.x, current.y, point.x, point.y) <= 0.0000001D) { + return true; + } + boolean crosses = (current.y > point.y) != (before.y > point.y); + if (crosses) { + double intersection = ((before.x - current.x) * (point.y - current.y) + / (before.y - current.y)) + current.x; + if (point.x < intersection) { + inside = !inside; + } + } + previous = index; + } + return inside; + } + + private static double unitValue(Object value, String name) { + double number; + try { + number = Double.parseDouble(String.valueOf(value).trim()); + } catch (NumberFormatException exception) { + throw new IllegalArgumentException(name + " must be a number", exception); + } + if (!Double.isFinite(number) || number < 0D || number > 1D) { + throw new IllegalArgumentException(name + " must be finite and within 0..1"); + } + return number; + } + + public record SourceMetadata( + Path path, + String format, + int width, + int height, + long pixels, + String colorMode, + int colorComponents, + int channels, + int bitDepth, + boolean alpha, + double minimumAlpha, + double maximumAlpha, + String colorProfile + ) { + public String summary() { + String alphaSummary = alpha + ? String.format(Locale.ROOT, "yes (%.1f%%..%.1f%%)", minimumAlpha * 100D, maximumAlpha * 100D) + : "no"; + return format.toUpperCase(Locale.ROOT) + " | " + width + " × " + height + + " | " + pixels + " pixels | " + colorMode + + " | " + bitDepth + "-bit | " + channels + " channel(s) | alpha " + + alphaSummary + " | profile " + colorProfile; + } + } + + public record LegendRow(String color, String target) { + } + + public record MaskRow( + String map, + IrisImageMapMaskOperation operation, + boolean inverted, + double threshold, + double falloff + ) { + } +} diff --git a/core/src/main/java/art/arcane/iris/core/gui/ImageMapStudioPreviewPanel.java b/core/src/main/java/art/arcane/iris/core/gui/ImageMapStudioPreviewPanel.java new file mode 100644 index 000000000..78cd18b58 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/gui/ImageMapStudioPreviewPanel.java @@ -0,0 +1,632 @@ +package art.arcane.iris.core.gui; + +import art.arcane.iris.core.localization.DesktopUiMessages; +import art.arcane.iris.core.localization.IrisLanguage; +import art.arcane.iris.engine.image.CompiledIrisImageMap; +import art.arcane.iris.engine.image.IrisImageMapRuntime; +import art.arcane.iris.engine.image.IrisImageMapMaskSampler; +import art.arcane.iris.engine.object.IrisImageMap; +import art.arcane.iris.engine.object.IrisImageMapApplication; +import art.arcane.iris.engine.object.IrisImageMapType; +import art.arcane.iris.engine.object.IrisWorldBoundary; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.volmlib.util.localization.MessageArgument; + +import javax.swing.BorderFactory; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JSplitPane; +import javax.swing.SwingWorker; +import java.awt.BasicStroke; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.Point; +import java.awt.Polygon; +import java.awt.RenderingHints; +import java.awt.event.ComponentAdapter; +import java.awt.event.ComponentEvent; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.event.MouseWheelEvent; +import java.awt.geom.Point2D; +import java.awt.image.BufferedImage; +import java.util.concurrent.CancellationException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Consumer; +import java.util.function.DoubleBinaryOperator; + +final class ImageMapStudioPreviewPanel extends JPanel implements AutoCloseable { + private static final Color BACKGROUND = new Color(12, 15, 22); + private static final Color PANEL_BACKGROUND = new Color(20, 24, 33); + private static final Color BORDER = new Color(48, 55, 72); + private static final Color TEXT = new Color(230, 234, 242); + private static final Color SECONDARY = new Color(151, 160, 178); + + private final SourceCanvas sourceCanvas = new SourceCanvas(); + private final WorldCanvas worldCanvas = new WorldCanvas(); + private final JLabel statusLabel = new JLabel(" "); + + ImageMapStudioPreviewPanel() { + super(new BorderLayout()); + setBackground(BACKGROUND); + JPanel source = wrap(IrisLanguage.plain(DesktopUiMessages.IMAGEMAP_SOURCE), sourceCanvas); + JPanel interpreted = wrap(IrisLanguage.plain(DesktopUiMessages.IMAGEMAP_INTERPRETED), worldCanvas); + JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, source, interpreted); + split.setBorder(BorderFactory.createEmptyBorder()); + split.setDividerSize(5); + split.setResizeWeight(0.42D); + split.setBackground(BACKGROUND); + add(split, BorderLayout.CENTER); + statusLabel.setForeground(SECONDARY); + statusLabel.setBackground(PANEL_BACKGROUND); + statusLabel.setOpaque(true); + statusLabel.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createMatteBorder(1, 0, 0, 0, BORDER), + BorderFactory.createEmptyBorder(5, 9, 5, 9) + )); + add(statusLabel, BorderLayout.SOUTH); + worldCanvas.setHoverConsumer(statusLabel::setText); + } + + void setPreview( + BufferedImage source, + CompiledIrisImageMap compiled, + IrisImageMapMaskSampler maskSampler, + IrisWorldBoundary boundary, + IrisImageMapApplication application, + int minimumWorldHeight, + DoubleBinaryOperator proceduralHeightSampler + ) { + sourceCanvas.setSource(source); + worldCanvas.setPreview( + compiled, maskSampler, boundary, application, minimumWorldHeight, proceduralHeightSampler + ); + } + + void setSource(BufferedImage source) { + sourceCanvas.setSource(source); + worldCanvas.clear(); + } + + void setDiagnosticConsumer(Consumer diagnosticConsumer) { + worldCanvas.setDiagnosticConsumer(diagnosticConsumer); + } + + void setOverlays(boolean chunks, boolean regions, boolean boundary, boolean coverage) { + worldCanvas.setOverlays(chunks, regions, boundary, coverage); + } + + BufferedImage renderInterpretedSnapshot(int width, int height) { + return worldCanvas.renderSnapshot(width, height).image(); + } + + BufferedImage renderSourceSnapshot(int width, int height) { + sourceCanvas.setSize(width, height); + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + Graphics2D graphics = image.createGraphics(); + try { + sourceCanvas.paint(graphics); + } finally { + graphics.dispose(); + } + return image; + } + + @Override + public void close() { + worldCanvas.close(); + } + + private static JPanel wrap(String title, JPanel canvas) { + JPanel panel = new JPanel(new BorderLayout()); + panel.setBackground(PANEL_BACKGROUND); + panel.setBorder(BorderFactory.createLineBorder(BORDER)); + JLabel label = new JLabel(title); + label.setForeground(TEXT); + label.setBackground(PANEL_BACKGROUND); + label.setOpaque(true); + label.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createMatteBorder(0, 0, 1, 0, BORDER), + BorderFactory.createEmptyBorder(6, 9, 6, 9) + )); + panel.add(label, BorderLayout.NORTH); + panel.add(canvas, BorderLayout.CENTER); + return panel; + } + + private static final class SourceCanvas extends JPanel { + private BufferedImage source; + + private SourceCanvas() { + setBackground(BACKGROUND); + setPreferredSize(new Dimension(420, 520)); + } + + private void setSource(BufferedImage source) { + this.source = source; + repaint(); + } + + @Override + protected void paintComponent(Graphics graphics) { + super.paintComponent(graphics); + if (source == null) { + drawEmpty(graphics, IrisLanguage.plain(DesktopUiMessages.IMAGEMAP_NO_SOURCE)); + return; + } + Graphics2D canvas = (Graphics2D) graphics.create(); + try { + canvas.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR); + int padding = 16; + double scale = Math.min( + (getWidth() - (padding * 2D)) / source.getWidth(), + (getHeight() - (padding * 2D)) / source.getHeight() + ); + scale = Math.max(0.0001D, scale); + int width = Math.max(1, (int) Math.round(source.getWidth() * scale)); + int height = Math.max(1, (int) Math.round(source.getHeight() * scale)); + int x = (getWidth() - width) / 2; + int y = (getHeight() - height) / 2; + canvas.drawImage(source, x, y, width, height, null); + canvas.setColor(new Color(255, 255, 255, 64)); + canvas.drawRect(x, y, width - 1, height - 1); + if (scale >= 8D && source.getWidth() <= 256 && source.getHeight() <= 256) { + canvas.setColor(new Color(255, 255, 255, 25)); + for (int sourceX = 1; sourceX < source.getWidth(); sourceX++) { + int lineX = x + (int) Math.round(sourceX * scale); + canvas.drawLine(lineX, y, lineX, y + height); + } + for (int sourceZ = 1; sourceZ < source.getHeight(); sourceZ++) { + int lineY = y + (int) Math.round(sourceZ * scale); + canvas.drawLine(x, lineY, x + width, lineY); + } + } + } finally { + canvas.dispose(); + } + } + } + + private static final class WorldCanvas extends JPanel implements AutoCloseable { + private final AtomicLong revision = new AtomicLong(); + private CompiledIrisImageMap compiled; + private IrisImageMapMaskSampler maskSampler = IrisImageMapMaskSampler.empty(); + private IrisWorldBoundary boundary; + private IrisImageMapApplication application = IrisImageMapApplication.CUSTOM; + private int minimumWorldHeight; + private DoubleBinaryOperator proceduralHeightSampler; + private BufferedImage rendered; + private SwingWorker worker; + private Consumer diagnosticConsumer = ignored -> { + }; + private Consumer hoverConsumer = ignored -> { + }; + private double centerX; + private double centerZ; + private double blocksPerScreenPixel = 1D; + private boolean showChunks = true; + private boolean showRegions = true; + private boolean showBoundary = true; + private boolean showCoverage = true; + private Point dragOrigin; + private double dragCenterX; + private double dragCenterZ; + + private WorldCanvas() { + setBackground(BACKGROUND); + setPreferredSize(new Dimension(620, 520)); + MouseAdapter mouse = new MouseAdapter() { + @Override + public void mousePressed(MouseEvent event) { + dragOrigin = event.getPoint(); + dragCenterX = centerX; + dragCenterZ = centerZ; + } + + @Override + public void mouseDragged(MouseEvent event) { + if (dragOrigin == null) { + return; + } + centerX = dragCenterX - ((event.getX() - dragOrigin.x) * blocksPerScreenPixel); + centerZ = dragCenterZ - ((event.getY() - dragOrigin.y) * blocksPerScreenPixel); + requestRender(); + } + + @Override + public void mouseReleased(MouseEvent event) { + dragOrigin = null; + } + + @Override + public void mouseMoved(MouseEvent event) { + publishHover(event.getX(), event.getY()); + } + + @Override + public void mouseWheelMoved(MouseWheelEvent event) { + double beforeX = screenToWorldX(event.getX()); + double beforeZ = screenToWorldZ(event.getY()); + double factor = Math.pow(1.12D, event.getPreciseWheelRotation()); + blocksPerScreenPixel = Math.max(0.01D, Math.min(1_000_000D, blocksPerScreenPixel * factor)); + centerX += beforeX - screenToWorldX(event.getX()); + centerZ += beforeZ - screenToWorldZ(event.getY()); + requestRender(); + } + }; + addMouseListener(mouse); + addMouseMotionListener(mouse); + addMouseWheelListener(mouse); + addComponentListener(new ComponentAdapter() { + @Override + public void componentResized(ComponentEvent event) { + requestRender(); + } + }); + } + + private void setPreview( + CompiledIrisImageMap compiled, + IrisImageMapMaskSampler maskSampler, + IrisWorldBoundary boundary, + IrisImageMapApplication application, + int minimumWorldHeight, + DoubleBinaryOperator proceduralHeightSampler + ) { + this.compiled = compiled; + this.maskSampler = maskSampler == null ? IrisImageMapMaskSampler.empty() : maskSampler; + this.boundary = boundary; + this.application = application == null ? IrisImageMapApplication.CUSTOM : application; + this.minimumWorldHeight = minimumWorldHeight; + this.proceduralHeightSampler = proceduralHeightSampler; + IrisImageMap definition = compiled.getDefinition(); + centerX = definition.getOrigin().getX(); + centerZ = definition.getOrigin().getZ(); + fitSource(); + requestRender(); + } + + private void clear() { + revision.incrementAndGet(); + if (worker != null) { + worker.cancel(true); + worker = null; + } + compiled = null; + rendered = null; + repaint(); + } + + private void setDiagnosticConsumer(Consumer diagnosticConsumer) { + this.diagnosticConsumer = diagnosticConsumer == null ? ignored -> { + } : diagnosticConsumer; + } + + private void setHoverConsumer(Consumer hoverConsumer) { + this.hoverConsumer = hoverConsumer == null ? ignored -> { + } : hoverConsumer; + } + + private void setOverlays(boolean chunks, boolean regions, boolean boundary, boolean coverage) { + showChunks = chunks; + showRegions = regions; + showBoundary = boundary; + showCoverage = coverage; + requestRender(); + } + + private void fitSource() { + if (compiled == null) { + return; + } + Point2D.Double[] corners = ImageMapStudioModel.sourceWorldCorners( + compiled.getDefinition(), compiled.getSourceWidth(), compiled.getSourceHeight() + ); + double minimumX = Double.POSITIVE_INFINITY; + double maximumX = Double.NEGATIVE_INFINITY; + double minimumZ = Double.POSITIVE_INFINITY; + double maximumZ = Double.NEGATIVE_INFINITY; + for (Point2D.Double corner : corners) { + minimumX = Math.min(minimumX, corner.x); + maximumX = Math.max(maximumX, corner.x); + minimumZ = Math.min(minimumZ, corner.y); + maximumZ = Math.max(maximumZ, corner.y); + } + centerX = (minimumX + maximumX) / 2D; + centerZ = (minimumZ + maximumZ) / 2D; + int width = Math.max(320, getWidth()); + int height = Math.max(320, getHeight()); + blocksPerScreenPixel = Math.max( + (maximumX - minimumX) / (width * 0.82D), + (maximumZ - minimumZ) / (height * 0.82D) + ); + blocksPerScreenPixel = Math.max(0.01D, blocksPerScreenPixel); + } + + private void requestRender() { + if (compiled == null || getWidth() <= 0 || getHeight() <= 0) { + repaint(); + return; + } + long currentRevision = revision.incrementAndGet(); + if (worker != null) { + worker.cancel(true); + } + int width = Math.max(1, getWidth()); + int height = Math.max(1, getHeight()); + worker = new SwingWorker<>() { + @Override + protected RenderResult doInBackground() { + return renderSnapshot(width, height); + } + + @Override + protected void done() { + if (isCancelled() || currentRevision != revision.get()) { + return; + } + try { + RenderResult result = get(); + rendered = result.image(); + if (result.errors() > 0) { + diagnosticConsumer.accept(result.errors() + " preview sample(s) failed; invalid pixels are magenta."); + } + repaint(); + } catch (CancellationException ignored) { + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + } catch (ExecutionException exception) { + IrisLogging.reportError(exception.getCause()); + diagnosticConsumer.accept("Preview render failed: " + exception.getCause().getMessage()); + } + } + }; + worker.execute(); + } + + private RenderResult renderSnapshot(int width, int height) { + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + if (compiled == null) { + return new RenderResult(image, 0); + } + int errors = 0; + IrisImageMapType type = compiled.getType(); + IrisImageMap definition = compiled.getDefinition(); + for (int screenZ = 0; screenZ < height; screenZ++) { + if (Thread.currentThread().isInterrupted()) { + break; + } + double worldZ = centerZ + ((screenZ - (height / 2D)) * blocksPerScreenPixel); + for (int screenX = 0; screenX < width; screenX++) { + double worldX = centerX + ((screenX - (width / 2D)) * blocksPerScreenPixel); + int color; + try { + double maskWeight = maskSampler.sample(worldX, worldZ); + color = switch (type) { + case GRAYSCALE_HEIGHT, RGB_HEIGHT -> ImageMapStudioModel.heightColor( + interpretedHeight(worldX, worldZ, maskWeight), + definition.getMinimumHeight(), + definition.getMaximumHeight() + ); + case COLOR_MAP -> categoricalApplication() + && !IrisImageMapRuntime.selectCategorical(maskWeight) + ? BACKGROUND.getRGB() + : ImageMapStudioModel.targetColor(compiled.sampleTarget(worldX, worldZ)); + case BINARY_MASK, GRAYSCALE_MASK, ALPHA_MASK -> grayscale( + compiled.sampleNormalized(worldX, worldZ) + ); + }; + } catch (RuntimeException exception) { + color = 0xFFFF3DA8; + errors++; + } + if (showCoverage && !compiled.containsWorld(worldX, worldZ) + && ((screenX + screenZ) & 7) < 3) { + color = blend(color, 0xFF090B10, 0.48D); + } + image.setRGB(screenX, screenZ, color); + } + } + return new RenderResult(image, errors); + } + + @Override + protected void paintComponent(Graphics graphics) { + super.paintComponent(graphics); + if (compiled == null) { + drawEmpty(graphics, IrisLanguage.plain(DesktopUiMessages.IMAGEMAP_NO_PREVIEW)); + return; + } + Graphics2D canvas = (Graphics2D) graphics.create(); + try { + canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + if (rendered != null) { + canvas.drawImage(rendered, 0, 0, getWidth(), getHeight(), null); + } + drawWorldGrid(canvas, 16D, new Color(255, 255, 255, 28), showChunks); + drawWorldGrid(canvas, 512D, new Color(99, 161, 255, 96), showRegions); + if (showCoverage) { + drawCoverage(canvas); + } + if (showBoundary && boundary != null) { + drawBoundary(canvas); + } + drawOrigin(canvas); + } finally { + canvas.dispose(); + } + } + + private void drawWorldGrid(Graphics2D canvas, double spacing, Color color, boolean enabled) { + if (!enabled || spacing / blocksPerScreenPixel < 7D) { + return; + } + canvas.setColor(color); + double minimumX = screenToWorldX(0); + double maximumX = screenToWorldX(getWidth()); + double minimumZ = screenToWorldZ(0); + double maximumZ = screenToWorldZ(getHeight()); + double firstX = Math.floor(minimumX / spacing) * spacing; + double firstZ = Math.floor(minimumZ / spacing) * spacing; + for (double worldX = firstX; worldX <= maximumX; worldX += spacing) { + int screenX = worldToScreenX(worldX); + canvas.drawLine(screenX, 0, screenX, getHeight()); + } + for (double worldZ = firstZ; worldZ <= maximumZ; worldZ += spacing) { + int screenZ = worldToScreenZ(worldZ); + canvas.drawLine(0, screenZ, getWidth(), screenZ); + } + } + + private void drawCoverage(Graphics2D canvas) { + Point2D.Double[] corners = ImageMapStudioModel.sourceWorldCorners( + compiled.getDefinition(), compiled.getSourceWidth(), compiled.getSourceHeight() + ); + Polygon polygon = new Polygon(); + for (Point2D.Double corner : corners) { + polygon.addPoint(worldToScreenX(corner.x), worldToScreenZ(corner.y)); + } + canvas.setStroke(new BasicStroke(2F)); + canvas.setColor(new Color(99, 161, 255, 205)); + canvas.drawPolygon(polygon); + } + + private void drawBoundary(Graphics2D canvas) { + int minimumX = worldToScreenX(boundary.minimumX()); + int maximumX = worldToScreenX(boundary.maximumX()); + int minimumZ = worldToScreenZ(boundary.minimumZ()); + int maximumZ = worldToScreenZ(boundary.maximumZ()); + int x = Math.min(minimumX, maximumX); + int z = Math.min(minimumZ, maximumZ); + int width = Math.abs(maximumX - minimumX); + int height = Math.abs(maximumZ - minimumZ); + canvas.setColor(new Color(255, 188, 82, 220)); + canvas.setStroke(new BasicStroke(2F)); + canvas.drawRect(x, z, width, height); + } + + private void drawOrigin(Graphics2D canvas) { + IrisImageMap definition = compiled.getDefinition(); + int x = worldToScreenX(definition.getOrigin().getX()); + int z = worldToScreenZ(definition.getOrigin().getZ()); + canvas.setColor(new Color(255, 255, 255, 190)); + canvas.drawLine(x - 7, z, x + 7, z); + canvas.drawLine(x, z - 7, x, z + 7); + } + + private void publishHover(int screenX, int screenZ) { + if (compiled == null) { + return; + } + double worldX = screenToWorldX(screenX); + double worldZ = screenToWorldZ(screenZ); + String value; + try { + double maskWeight = maskSampler.sample(worldX, worldZ); + value = switch (compiled.getType()) { + case GRAYSCALE_HEIGHT, RGB_HEIGHT -> String.format( + "Y %.3f", interpretedHeight(worldX, worldZ, maskWeight) + ); + case COLOR_MAP -> categoricalApplication() + && !IrisImageMapRuntime.selectCategorical(maskWeight) + ? "procedural" + : String.valueOf(compiled.sampleTarget(worldX, worldZ)); + case BINARY_MASK, GRAYSCALE_MASK, ALPHA_MASK -> String.format( + "%.4f", compiled.sampleNormalized(worldX, worldZ) + ); + }; + if (!maskSampler.isEmpty()) { + value += String.format(" | mask %.4f", maskWeight); + } + } catch (RuntimeException exception) { + value = exception.getMessage(); + } + hoverConsumer.accept(IrisLanguage.plain( + DesktopUiMessages.IMAGEMAP_PREVIEW_STATUS, + MessageArgument.untrusted("x", String.format("%.2f", worldX)), + MessageArgument.untrusted("z", String.format("%.2f", worldZ)), + MessageArgument.untrusted("value", value), + MessageArgument.untrusted("scale", String.format("%.3f", blocksPerScreenPixel)) + )); + } + + private double interpretedHeight(double worldX, double worldZ, double maskWeight) { + double mappedHeight = compiled.sampleHeight(worldX, worldZ); + if (application != IrisImageMapApplication.TERRAIN_HEIGHT || maskSampler.isEmpty()) { + return mappedHeight; + } + IrisImageMap definition = compiled.getDefinition(); + double proceduralLocalHeight = proceduralHeightSampler == null + ? definition.getMinimumHeight() - minimumWorldHeight + : proceduralHeightSampler.applyAsDouble(worldX, worldZ); + double mappedLocalHeight = mappedHeight - minimumWorldHeight; + return minimumWorldHeight + IrisImageMapRuntime.blendTerrainHeight( + mappedLocalHeight, proceduralLocalHeight, maskWeight + ); + } + + private boolean categoricalApplication() { + return application == IrisImageMapApplication.BIOME + || application == IrisImageMapApplication.REGION + || application == IrisImageMapApplication.SURFACE_BLOCK; + } + + private int worldToScreenX(double worldX) { + return (int) Math.round((getWidth() / 2D) + ((worldX - centerX) / blocksPerScreenPixel)); + } + + private int worldToScreenZ(double worldZ) { + return (int) Math.round((getHeight() / 2D) + ((worldZ - centerZ) / blocksPerScreenPixel)); + } + + private double screenToWorldX(double screenX) { + return centerX + ((screenX - (getWidth() / 2D)) * blocksPerScreenPixel); + } + + private double screenToWorldZ(double screenZ) { + return centerZ + ((screenZ - (getHeight() / 2D)) * blocksPerScreenPixel); + } + + @Override + public void close() { + revision.incrementAndGet(); + if (worker != null) { + worker.cancel(true); + } + } + } + + private static void drawEmpty(Graphics graphics, String text) { + Graphics2D canvas = (Graphics2D) graphics.create(); + try { + canvas.setColor(SECONDARY); + canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + int width = canvas.getFontMetrics().stringWidth(text); + int x = Math.max(8, (graphics.getClipBounds() == null ? 0 : graphics.getClipBounds().width - width) / 2); + int y = Math.max(20, graphics.getClipBounds() == null ? 20 : graphics.getClipBounds().height / 2); + canvas.drawString(text, x, y); + } finally { + canvas.dispose(); + } + } + + private static int grayscale(double value) { + int level = (int) Math.round(Math.max(0D, Math.min(1D, value)) * 255D); + return 0xFF000000 | (level << 16) | (level << 8) | level; + } + + private static int blend(int first, int second, double secondWeight) { + double firstWeight = 1D - secondWeight; + int red = (int) ((((first >>> 16) & 0xFF) * firstWeight) + (((second >>> 16) & 0xFF) * secondWeight)); + int green = (int) ((((first >>> 8) & 0xFF) * firstWeight) + (((second >>> 8) & 0xFF) * secondWeight)); + int blue = (int) (((first & 0xFF) * firstWeight) + ((second & 0xFF) * secondWeight)); + return 0xFF000000 | (red << 16) | (green << 8) | blue; + } + + private record RenderResult(BufferedImage image, int errors) { + } +} diff --git a/core/src/main/java/art/arcane/iris/core/link/MultiverseGuardListener.java b/core/src/main/java/art/arcane/iris/core/link/MultiverseGuardListener.java index a77a1851f..66db137f8 100644 --- a/core/src/main/java/art/arcane/iris/core/link/MultiverseGuardListener.java +++ b/core/src/main/java/art/arcane/iris/core/link/MultiverseGuardListener.java @@ -25,6 +25,7 @@ import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisServices; import art.arcane.iris.util.common.plugin.VolmitSender; import art.arcane.iris.util.common.scheduling.J; +import art.arcane.volmlib.util.plugin.ComponentMessenger; import org.bukkit.Bukkit; import org.bukkit.World; import org.bukkit.command.CommandSender; @@ -472,7 +473,7 @@ public final class MultiverseGuardListener implements Listener { } } catch (Throwable ignored) { for (String line : lines) { - sender.sendMessage(line); + ComponentMessenger.sendSection(sender, line); } } } diff --git a/core/src/main/java/art/arcane/iris/core/loader/ImageResourceLoader.java b/core/src/main/java/art/arcane/iris/core/loader/ImageResourceLoader.java index b6c9d630e..7e287f683 100644 --- a/core/src/main/java/art/arcane/iris/core/loader/ImageResourceLoader.java +++ b/core/src/main/java/art/arcane/iris/core/loader/ImageResourceLoader.java @@ -20,6 +20,7 @@ package art.arcane.iris.core.loader; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.engine.image.IrisImageMapCompiler; import art.arcane.iris.engine.object.IrisImage; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KSet; @@ -27,16 +28,23 @@ import art.arcane.volmlib.util.data.KCache; import art.arcane.volmlib.util.scheduling.PrecisionStopwatch; import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +import javax.imageio.stream.ImageInputStream; import java.awt.image.BufferedImage; import java.io.File; import java.io.IOException; import java.util.HashSet; +import java.util.Iterator; +import java.util.Locale; import java.util.Set; public class ImageResourceLoader extends ResourceLoader { public ImageResourceLoader(File root, IrisData idm, String folderName, String resourceTypeName, Options options) { super(root, idm, folderName, resourceTypeName, IrisImage.class, options); - loadCache = new KCache<>(this::loadRaw, IrisSettings.get().getPerformance().getObjectLoaderCacheSize()); + int cacheSize = options.registerPreservation() + ? IrisSettings.get().getPerformance().getObjectLoaderCacheSize() + : options.cacheSize(); + loadCache = new KCache<>(this::loadRaw, cacheSize); } public boolean supportsSchemas() { @@ -54,20 +62,51 @@ public class ImageResourceLoader extends ResourceLoader { protected IrisImage loadFile(File j, String name) { try { PrecisionStopwatch p = PrecisionStopwatch.start(); - BufferedImage bu = ImageIO.read(j); - - if (bu == null) { - IrisLogging.warn("Couldn't read " + resourceTypeName + " file: " + j.getPath() + " (unsupported or corrupt image)"); - return null; + try (ImageInputStream input = ImageIO.createImageInputStream(j)) { + if (input == null) { + IrisLogging.warn("Couldn't read " + resourceTypeName + " file: " + j.getPath() + + " (unsupported or corrupt image)"); + return null; + } + Iterator readers = ImageIO.getImageReaders(input); + if (!readers.hasNext()) { + IrisLogging.warn("Couldn't read " + resourceTypeName + " file: " + j.getPath() + + " (unsupported or corrupt image)"); + return null; + } + ImageReader reader = readers.next(); + try { + reader.setInput(input, true, true); + String format = reader.getFormatName().toLowerCase(Locale.ROOT); + if (!"png".equals(format)) { + IrisLogging.warn("Couldn't read " + resourceTypeName + " file: " + j.getPath() + + " (expected PNG, got " + format + ")"); + return null; + } + int width = reader.getWidth(0); + int height = reader.getHeight(0); + if (!supportedDimensions(width, height)) { + IrisLogging.warn("Couldn't read " + resourceTypeName + " file: " + j.getPath() + + " (dimensions " + width + "x" + height + " exceed the supported image-map limits)"); + return null; + } + BufferedImage image = reader.read(0); + if (image == null) { + IrisLogging.warn("Couldn't read " + resourceTypeName + " file: " + j.getPath() + + " (unsupported or corrupt image)"); + return null; + } + IrisImage loaded = new IrisImage(image, format); + loaded.setLoadFile(j); + loaded.setLoader(manager); + loaded.setLoadKey(name); + logLoad(j, loaded); + tlt.addAndGet(p.getMilliseconds()); + return loaded; + } finally { + reader.dispose(); + } } - - IrisImage img = new IrisImage(bu); - img.setLoadFile(j); - img.setLoader(manager); - img.setLoadKey(name); - logLoad(j, img); - tlt.addAndGet(p.getMilliseconds()); - return img; } catch (Throwable e) { IrisLogging.reportError(e); IrisLogging.warn("Couldn't read " + resourceTypeName + " file: " + j.getPath() + ": " + e.getMessage()); @@ -75,6 +114,16 @@ public class ImageResourceLoader extends ResourceLoader { } } + static boolean supportedDimensions(int width, int height) { + if (width < IrisImageMapCompiler.MINIMUM_DIMENSION + || width > IrisImageMapCompiler.MAXIMUM_DIMENSION + || height < IrisImageMapCompiler.MINIMUM_DIMENSION + || height > IrisImageMapCompiler.MAXIMUM_DIMENSION) { + return false; + } + return (long) width * height <= IrisImageMapCompiler.MAXIMUM_PIXELS; + } + void getPNGFiles(File directory, String prefix, Set m, HashSet visitedDirectories) { if (directory == null || !directory.exists()) { return; diff --git a/core/src/main/java/art/arcane/iris/core/loader/IrisData.java b/core/src/main/java/art/arcane/iris/core/loader/IrisData.java index 4c5dfba9d..2a1ae8409 100644 --- a/core/src/main/java/art/arcane/iris/core/loader/IrisData.java +++ b/core/src/main/java/art/arcane/iris/core/loader/IrisData.java @@ -46,6 +46,7 @@ import art.arcane.iris.engine.object.IrisExpression; import art.arcane.iris.engine.object.IrisObjectScale; import art.arcane.iris.engine.object.IrisGenerator; import art.arcane.iris.engine.object.IrisImage; +import art.arcane.iris.engine.object.IrisImageMap; import art.arcane.iris.engine.object.IrisJigsawPiece; import art.arcane.iris.engine.object.IrisJigsawPool; import art.arcane.iris.engine.object.IrisLootTable; @@ -119,6 +120,7 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { private ResourceLoader objectLoader; private ResourceLoader matterLoader; private ResourceLoader imageLoader; + private ResourceLoader imageMapLoader; private ResourceLoader structureLoader; private ResourceLoader jigsawPoolLoader; private ResourceLoader jigsawPieceLoader; @@ -241,6 +243,10 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { return loadAny(IrisImage.class, key, nearest); } + public static IrisImageMap loadAnyImageMap(String key, @Nullable IrisData nearest) { + return loadAny(IrisImageMap.class, key, nearest); + } + public static IrisDimension loadAnyDimension(String key, @Nullable IrisData nearest) { return loadAny(IrisDimension.class, key, nearest); } @@ -484,6 +490,7 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { this.expressionLoader = registerLoader(IrisExpression.class, replacement); this.objectLoader = registerLoader(IrisObject.class, replacement); this.imageLoader = registerLoader(IrisImage.class, replacement); + this.imageMapLoader = registerLoader(IrisImageMap.class, replacement); this.matterLoader = registerLoader(IrisMatterObject.class, replacement); this.structureLoader = registerLoader(IrisStructure.class, replacement); this.jigsawPoolLoader = registerLoader(IrisJigsawPool.class, replacement); @@ -510,13 +517,19 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { .setPrettyPrinting(); KMap, ResourceLoader> replacement = new KMap<>(); dataFolder.mkdirs(); - recoverStructureTransactions(); biomeLoader = registerLoader(IrisBiome.class, replacement); + regionLoader = registerLoader(IrisRegion.class, replacement); dimensionLoader = registerLoader(IrisDimension.class, replacement); + generatorLoader = registerLoader(IrisGenerator.class, replacement); + expressionLoader = registerLoader(IrisExpression.class, replacement); + imageLoader = registerLoader(IrisImage.class, replacement); + imageMapLoader = registerLoader(IrisImageMap.class, replacement); builder.registerTypeAdapterFactory(KeyedType::createTypeAdapter); gson = builder.create(); loaders = replacement; - if (biomeLoader == null || dimensionLoader == null) { + if (biomeLoader == null || regionLoader == null || dimensionLoader == null + || generatorLoader == null || expressionLoader == null + || imageLoader == null || imageMapLoader == null) { throw new IllegalStateException("Unable to initialize Iris datapack compiler loaders for " + dataFolder); } } diff --git a/core/src/main/java/art/arcane/iris/core/loader/ResourceLoader.java b/core/src/main/java/art/arcane/iris/core/loader/ResourceLoader.java index dabd48102..7bc7248a6 100644 --- a/core/src/main/java/art/arcane/iris/core/loader/ResourceLoader.java +++ b/core/src/main/java/art/arcane/iris/core/loader/ResourceLoader.java @@ -720,6 +720,12 @@ public class ResourceLoader implements MeteredCache { folderCache.reset(); } + public void unload(String name) { + if (name != null && !name.isBlank()) { + loadCache.invalidate(name); + } + } + public File fileFor(T b) { return resolveFile(b.getLoadKey(), ".json", getFolders()); } diff --git a/core/src/main/java/art/arcane/iris/core/localization/BukkitCommandMessagesExtended.java b/core/src/main/java/art/arcane/iris/core/localization/BukkitCommandMessagesExtended.java index dd61ac09f..ec664ae77 100644 --- a/core/src/main/java/art/arcane/iris/core/localization/BukkitCommandMessagesExtended.java +++ b/core/src/main/java/art/arcane/iris/core/localization/BukkitCommandMessagesExtended.java @@ -612,6 +612,10 @@ public final class BukkitCommandMessagesExtended { "iris.bukkit.commandstudio.opening_noise_explorer", C.GREEN + "Opening Noise Explorer!" ); + public static final TextKey COMMAND_STUDIO_OPENING_IMAGE_MAP_STUDIO = TextKey.of( + "iris.bukkit.commandstudio.opening_image_map_studio", + C.GREEN + "Opening the Image Map Studio." + ); public static final TextKey COMMAND_STUDIO_CANNOT_ADD_ITEMS_VIRTUAL_INVENTORY_BECAUSE = TextKey.of( "iris.bukkit.commandstudio.cannot_add_items_virtual_inventory_because", C.RED + "Cannot add items to virtual inventory because of: " + "{value}" @@ -952,6 +956,7 @@ public final class BukkitCommandMessagesExtended { COMMAND_STUDIO_OPENING_VSCODE_PACK, COMMAND_STUDIO_PACK_HAS_VERSION, COMMAND_STUDIO_OPENING_NOISE_EXPLORER, + COMMAND_STUDIO_OPENING_IMAGE_MAP_STUDIO, COMMAND_STUDIO_CANNOT_ADD_ITEMS_VIRTUAL_INVENTORY_BECAUSE, COMMAND_STUDIO_OPENING_INVENTORY_NOW, COMMAND_STUDIO_ONLY_WORKS_IRIS_WORLD, diff --git a/core/src/main/java/art/arcane/iris/core/localization/DesktopUiMessages.java b/core/src/main/java/art/arcane/iris/core/localization/DesktopUiMessages.java index 273f0fd3b..545928c7e 100644 --- a/core/src/main/java/art/arcane/iris/core/localization/DesktopUiMessages.java +++ b/core/src/main/java/art/arcane/iris/core/localization/DesktopUiMessages.java @@ -90,6 +90,72 @@ public final class DesktopUiMessages { public static final TextKey PREGEN_TIME = TextKey.of("iris.desktop.pregen.time", "{remaining} remaining ({elapsed} elapsed)"); public static final TextKey PREGEN_METHOD = TextKey.of("iris.desktop.pregen.method", "Generation method: {method}"); public static final TextKey PREGEN_MEMORY = TextKey.of("iris.desktop.pregen.memory", "Memory: {used} ({usage}) Pressure: {pressure}/s"); + public static final TextKey IMAGEMAP_TITLE = TextKey.of("iris.desktop.imagemap.title", "Image Map Studio"); + public static final TextKey IMAGEMAP_PRESET = TextKey.of("iris.desktop.imagemap.preset", "Preset:"); + public static final TextKey IMAGEMAP_LOAD = TextKey.of("iris.desktop.imagemap.load", "Load"); + public static final TextKey IMAGEMAP_IMPORT_PNG = TextKey.of("iris.desktop.imagemap.import_png", "Import PNG"); + public static final TextKey IMAGEMAP_REPLACE_PNG = TextKey.of("iris.desktop.imagemap.replace_png", "Replace PNG"); + public static final TextKey IMAGEMAP_PREVIEW = TextKey.of("iris.desktop.imagemap.preview", "Preview"); + public static final TextKey IMAGEMAP_EXPORT = TextKey.of("iris.desktop.imagemap.export", "Export to active pack"); + public static final TextKey IMAGEMAP_METADATA = TextKey.of("iris.desktop.imagemap.metadata", "Source metadata"); + public static final TextKey IMAGEMAP_RESOURCE = TextKey.of("iris.desktop.imagemap.resource", "Resource binding"); + public static final TextKey IMAGEMAP_COORDINATES = TextKey.of("iris.desktop.imagemap.coordinates", "Coordinates and sampling"); + public static final TextKey IMAGEMAP_BINDING_KEY = TextKey.of("iris.desktop.imagemap.binding_key", "Binding key"); + public static final TextKey IMAGEMAP_MAP_KEY = TextKey.of("iris.desktop.imagemap.map_key", "Map key"); + public static final TextKey IMAGEMAP_IMAGE_KEY = TextKey.of("iris.desktop.imagemap.image_key", "Image key"); + public static final TextKey IMAGEMAP_TYPE = TextKey.of("iris.desktop.imagemap.type", "Type"); + public static final TextKey IMAGEMAP_APPLICATION = TextKey.of("iris.desktop.imagemap.application", "Application"); + public static final TextKey IMAGEMAP_BLOCKS_PER_PIXEL = TextKey.of("iris.desktop.imagemap.blocks_per_pixel", "Blocks per pixel"); + public static final TextKey IMAGEMAP_ORIGIN_X = TextKey.of("iris.desktop.imagemap.origin_x", "World origin X"); + public static final TextKey IMAGEMAP_ORIGIN_Z = TextKey.of("iris.desktop.imagemap.origin_z", "World origin Z"); + public static final TextKey IMAGEMAP_SOURCE_ORIGIN_X = TextKey.of("iris.desktop.imagemap.source_origin_x", "Source origin X"); + public static final TextKey IMAGEMAP_SOURCE_ORIGIN_Z = TextKey.of("iris.desktop.imagemap.source_origin_z", "Source origin Z"); + public static final TextKey IMAGEMAP_ROTATION = TextKey.of("iris.desktop.imagemap.rotation", "Rotation"); + public static final TextKey IMAGEMAP_MIRROR_X = TextKey.of("iris.desktop.imagemap.mirror_x", "Mirror X"); + public static final TextKey IMAGEMAP_MIRROR_Z = TextKey.of("iris.desktop.imagemap.mirror_z", "Mirror Z"); + public static final TextKey IMAGEMAP_SAMPLING = TextKey.of("iris.desktop.imagemap.sampling", "Sampling"); + public static final TextKey IMAGEMAP_OUT_OF_BOUNDS = TextKey.of("iris.desktop.imagemap.out_of_bounds", "Out of bounds"); + public static final TextKey IMAGEMAP_ALPHA = TextKey.of("iris.desktop.imagemap.alpha", "Alpha policy"); + public static final TextKey IMAGEMAP_FALLBACK_VALUE = TextKey.of("iris.desktop.imagemap.fallback_value", "Fallback value"); + public static final TextKey IMAGEMAP_FALLBACK_TARGET = TextKey.of("iris.desktop.imagemap.fallback_target", "Fallback target"); + public static final TextKey IMAGEMAP_MINIMUM_HEIGHT = TextKey.of("iris.desktop.imagemap.minimum_height", "Minimum height"); + public static final TextKey IMAGEMAP_MAXIMUM_HEIGHT = TextKey.of("iris.desktop.imagemap.maximum_height", "Maximum height"); + public static final TextKey IMAGEMAP_VERTICAL_OFFSET = TextKey.of("iris.desktop.imagemap.vertical_offset", "Vertical offset"); + public static final TextKey IMAGEMAP_CLAMP = TextKey.of("iris.desktop.imagemap.clamp", "Clamp height"); + public static final TextKey IMAGEMAP_INVERTED = TextKey.of("iris.desktop.imagemap.inverted", "Invert values"); + public static final TextKey IMAGEMAP_CURVE_EXPONENT = TextKey.of("iris.desktop.imagemap.curve_exponent", "Curve exponent"); + public static final TextKey IMAGEMAP_SMOOTHING_RADIUS = TextKey.of("iris.desktop.imagemap.smoothing_radius", "Smoothing radius"); + public static final TextKey IMAGEMAP_THRESHOLD = TextKey.of("iris.desktop.imagemap.threshold", "Threshold"); + public static final TextKey IMAGEMAP_FALLOFF = TextKey.of("iris.desktop.imagemap.falloff", "Falloff"); + public static final TextKey IMAGEMAP_COLOR_TOLERANCE = TextKey.of("iris.desktop.imagemap.color_tolerance", "Raw sRGB tolerance"); + public static final TextKey IMAGEMAP_UNKNOWN_COLOR = TextKey.of("iris.desktop.imagemap.unknown_color", "Unknown color"); + public static final TextKey IMAGEMAP_ADD_COLOR = TextKey.of("iris.desktop.imagemap.add_color", "Add legend color"); + public static final TextKey IMAGEMAP_REMOVE_COLOR = TextKey.of("iris.desktop.imagemap.remove_color", "Remove selected"); + public static final TextKey IMAGEMAP_COMPOSED_MASKS = TextKey.of("iris.desktop.imagemap.composed_masks", "Composed masks"); + public static final TextKey IMAGEMAP_ADD_MASK = TextKey.of("iris.desktop.imagemap.add_mask", "Add mask binding"); + public static final TextKey IMAGEMAP_HEIGHT = TextKey.of("iris.desktop.imagemap.height", "Height interpretation"); + public static final TextKey IMAGEMAP_MASK = TextKey.of("iris.desktop.imagemap.mask", "Mask interpretation"); + public static final TextKey IMAGEMAP_COLOR_MAP = TextKey.of("iris.desktop.imagemap.color_map", "Color legend"); + public static final TextKey IMAGEMAP_OVERLAYS = TextKey.of("iris.desktop.imagemap.overlays", "World overlays"); + public static final TextKey IMAGEMAP_CHUNKS = TextKey.of("iris.desktop.imagemap.chunks", "16-block chunks"); + public static final TextKey IMAGEMAP_REGIONS = TextKey.of("iris.desktop.imagemap.regions", "512-block regions"); + public static final TextKey IMAGEMAP_BOUNDARY = TextKey.of("iris.desktop.imagemap.boundary", "World boundary"); + public static final TextKey IMAGEMAP_COVERAGE = TextKey.of("iris.desktop.imagemap.coverage", "Source coverage"); + public static final TextKey IMAGEMAP_DIAGNOSTICS = TextKey.of("iris.desktop.imagemap.diagnostics", "Diagnostics"); + public static final TextKey IMAGEMAP_READY = TextKey.of("iris.desktop.imagemap.ready", "Ready"); + public static final TextKey IMAGEMAP_NO_SOURCE = TextKey.of("iris.desktop.imagemap.no_source", "Import a PNG source to begin."); + public static final TextKey IMAGEMAP_LOADING = TextKey.of("iris.desktop.imagemap.loading", "Loading preset..."); + public static final TextKey IMAGEMAP_LOAD_FAILED = TextKey.of("iris.desktop.imagemap.load_failed", "Preset load failed"); + public static final TextKey IMAGEMAP_PREVIEWING = TextKey.of("iris.desktop.imagemap.previewing", "Compiling preview..."); + public static final TextKey IMAGEMAP_PREVIEW_VALID = TextKey.of("iris.desktop.imagemap.preview_valid", "Runtime compiler validation passed."); + public static final TextKey IMAGEMAP_PREVIEW_FAILED = TextKey.of("iris.desktop.imagemap.preview_failed", "Preview failed"); + public static final TextKey IMAGEMAP_EXPORTING = TextKey.of("iris.desktop.imagemap.exporting", "Validating and exporting..."); + public static final TextKey IMAGEMAP_EXPORTED = TextKey.of("iris.desktop.imagemap.exported", "Exported image map, PNG, and dimension binding atomically."); + public static final TextKey IMAGEMAP_EXPORT_FAILED = TextKey.of("iris.desktop.imagemap.export_failed", "Export failed"); + public static final TextKey IMAGEMAP_SOURCE = TextKey.of("iris.desktop.imagemap.source", "Source pixels"); + public static final TextKey IMAGEMAP_INTERPRETED = TextKey.of("iris.desktop.imagemap.interpreted", "Runtime interpretation"); + public static final TextKey IMAGEMAP_NO_PREVIEW = TextKey.of("iris.desktop.imagemap.no_preview", "Compile a valid preview to inspect world output."); + public static final TextKey IMAGEMAP_PREVIEW_STATUS = TextKey.of("iris.desktop.imagemap.preview_status", "X {x} Z {z} | {value} | {scale} blocks/pixel"); private static final List KEYS = List.of( VISION_TITLE, VISION_VIEW, VISION_GRID, VISION_FOLLOW, @@ -112,7 +178,24 @@ public final class DesktopUiMessages { NOISE_CATEGORY_UTILITY, NOISE_CATEGORY_OTHER, PREGEN_INITIALIZING, PREGEN_TITLE, PREGEN_METHOD_PENDING, PREGEN_PAUSED, PREGEN_RESUME_HINT, PREGEN_PAUSE_HINT, PREGEN_PROGRESS_PAUSED, PREGEN_PROGRESS_SAVING, PREGEN_PROGRESS_GENERATING, PREGEN_SPEED, - PREGEN_SPEED_CACHED, PREGEN_TIME, PREGEN_METHOD, PREGEN_MEMORY + PREGEN_SPEED_CACHED, PREGEN_TIME, PREGEN_METHOD, PREGEN_MEMORY, + IMAGEMAP_TITLE, IMAGEMAP_PRESET, IMAGEMAP_LOAD, IMAGEMAP_IMPORT_PNG, + IMAGEMAP_REPLACE_PNG, IMAGEMAP_PREVIEW, IMAGEMAP_EXPORT, IMAGEMAP_METADATA, + IMAGEMAP_RESOURCE, IMAGEMAP_COORDINATES, IMAGEMAP_BINDING_KEY, IMAGEMAP_MAP_KEY, + IMAGEMAP_IMAGE_KEY, IMAGEMAP_TYPE, IMAGEMAP_APPLICATION, IMAGEMAP_BLOCKS_PER_PIXEL, + IMAGEMAP_ORIGIN_X, IMAGEMAP_ORIGIN_Z, IMAGEMAP_SOURCE_ORIGIN_X, IMAGEMAP_SOURCE_ORIGIN_Z, + IMAGEMAP_ROTATION, IMAGEMAP_MIRROR_X, IMAGEMAP_MIRROR_Z, IMAGEMAP_SAMPLING, + IMAGEMAP_OUT_OF_BOUNDS, IMAGEMAP_ALPHA, IMAGEMAP_FALLBACK_VALUE, IMAGEMAP_FALLBACK_TARGET, + IMAGEMAP_MINIMUM_HEIGHT, IMAGEMAP_MAXIMUM_HEIGHT, IMAGEMAP_VERTICAL_OFFSET, IMAGEMAP_CLAMP, + IMAGEMAP_INVERTED, IMAGEMAP_CURVE_EXPONENT, IMAGEMAP_SMOOTHING_RADIUS, IMAGEMAP_THRESHOLD, + IMAGEMAP_FALLOFF, IMAGEMAP_COLOR_TOLERANCE, IMAGEMAP_UNKNOWN_COLOR, IMAGEMAP_ADD_COLOR, + IMAGEMAP_REMOVE_COLOR, IMAGEMAP_COMPOSED_MASKS, IMAGEMAP_ADD_MASK, + IMAGEMAP_HEIGHT, IMAGEMAP_MASK, IMAGEMAP_COLOR_MAP, + IMAGEMAP_OVERLAYS, IMAGEMAP_CHUNKS, IMAGEMAP_REGIONS, IMAGEMAP_BOUNDARY, + IMAGEMAP_COVERAGE, IMAGEMAP_DIAGNOSTICS, IMAGEMAP_READY, IMAGEMAP_NO_SOURCE, + IMAGEMAP_LOADING, IMAGEMAP_LOAD_FAILED, IMAGEMAP_PREVIEWING, IMAGEMAP_PREVIEW_VALID, + IMAGEMAP_PREVIEW_FAILED, IMAGEMAP_EXPORTING, IMAGEMAP_EXPORTED, IMAGEMAP_EXPORT_FAILED, + IMAGEMAP_SOURCE, IMAGEMAP_INTERPRETED, IMAGEMAP_NO_PREVIEW, IMAGEMAP_PREVIEW_STATUS ); private DesktopUiMessages() { diff --git a/core/src/main/java/art/arcane/iris/core/localization/ModdedCommandMessages.java b/core/src/main/java/art/arcane/iris/core/localization/ModdedCommandMessages.java index 79554d09a..7d59a718a 100644 --- a/core/src/main/java/art/arcane/iris/core/localization/ModdedCommandMessages.java +++ b/core/src/main/java/art/arcane/iris/core/localization/ModdedCommandMessages.java @@ -907,6 +907,14 @@ public final class ModdedCommandMessages { "iris.modded.moddedstudiocommands.opening_vision_map_on_server_display", "Opening the Vision map for " + "{value}" + " on the server display." ); + public static final TextKey MODDED_STUDIO_COMMANDS_IMAGE_MAP_REQUIRES_IRIS_DIMENSION = TextKey.of( + "iris.modded.moddedstudiocommands.image_map_requires_iris_dimension", + "Stand in an active Iris or Studio dimension before opening the Image Map Studio." + ); + public static final TextKey MODDED_STUDIO_COMMANDS_OPENING_IMAGE_MAP_STUDIO_ON_SERVER_DISPLAY = TextKey.of( + "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display", + "Opening the Image Map Studio for " + "{value}" + " on the server display." + ); public static final TextKey MODDED_STUDIO_COMMANDS_FAILED_WRITE_WORKSPACE = TextKey.of( "iris.modded.moddedstudiocommands.failed_write_workspace", "Failed to write workspace for " + "{value}" + ": " + "{value2}" @@ -1462,6 +1470,8 @@ public final class ModdedCommandMessages { MODDED_STUDIO_COMMANDS_OPENING_NOISE_EXPLORER_GENERATOR_SEED, MODDED_STUDIO_COMMANDS_THIS_DIMENSION_IS_NOT_GENERATED_BY_IRIS_STAND_IRIS_STUDIO, MODDED_STUDIO_COMMANDS_OPENING_VISION_MAP_ON_SERVER_DISPLAY, + MODDED_STUDIO_COMMANDS_IMAGE_MAP_REQUIRES_IRIS_DIMENSION, + MODDED_STUDIO_COMMANDS_OPENING_IMAGE_MAP_STUDIO_ON_SERVER_DISPLAY, MODDED_STUDIO_COMMANDS_FAILED_WRITE_WORKSPACE, MODDED_STUDIO_COMMANDS_WORKSPACE_REGENERATED_WITH_JSON_SCHEMAS_AUTOCOMPLETE, MODDED_STUDIO_COMMANDS_COULD_NOT_OPEN, diff --git a/core/src/main/java/art/arcane/iris/core/localization/PackDownloadMessages.java b/core/src/main/java/art/arcane/iris/core/localization/PackDownloadMessages.java index ab6d985a3..ed8a92383 100644 --- a/core/src/main/java/art/arcane/iris/core/localization/PackDownloadMessages.java +++ b/core/src/main/java/art/arcane/iris/core/localization/PackDownloadMessages.java @@ -104,10 +104,6 @@ public final class PackDownloadMessages { "iris.runtime.pack_download.downloading", "Downloading {url}" ); - public static final TextKey FAILED_TO_FIND = TextKey.of( - "iris.runtime.pack_download.failed_to_find", - "Failed to find pack at {url}" - ); public static final TextKey UNPACKING = TextKey.of( "iris.runtime.pack_download.unpacking", "Unpacking {repository}" @@ -213,7 +209,6 @@ public final class PackDownloadMessages { INVALID_BUILT_IN, SHUTTING_DOWN, DOWNLOADING, - FAILED_TO_FIND, UNPACKING, UNPACK_FAILED, NO_EXTRACTED_FILES, diff --git a/core/src/main/java/art/arcane/iris/core/pack/ImageMapPackageClosure.java b/core/src/main/java/art/arcane/iris/core/pack/ImageMapPackageClosure.java new file mode 100644 index 000000000..069daa70f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/pack/ImageMapPackageClosure.java @@ -0,0 +1,48 @@ +package art.arcane.iris.core.pack; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.object.IrisImageMap; +import art.arcane.volmlib.util.io.IO; +import art.arcane.volmlib.util.json.JSONObject; + +import java.io.File; +import java.io.IOException; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Set; + +public final class ImageMapPackageClosure { + private ImageMapPackageClosure() { + } + + public static String writeAll(IrisData data, File targetRoot, boolean minify) throws IOException { + String[] possibleKeys = data.getImageMapLoader().getPossibleKeys(); + Arrays.sort(possibleKeys); + Set imageKeys = new LinkedHashSet<>(); + StringBuilder hashes = new StringBuilder(); + for (String mapKey : possibleKeys) { + IrisImageMap map = data.getImageMapLoader().load(mapKey); + File mapFile = data.getImageMapLoader().findFile(mapKey); + if (map == null || mapFile == null || !mapFile.isFile()) { + throw new IOException("Image-map resource '" + mapKey + "' could not be packaged"); + } + String json = new JSONObject(IO.readAll(mapFile)).toString(minify ? 0 : 4); + IO.writeAll(new File(targetRoot, "image-maps/" + mapKey + ".json"), json); + hashes.append(IO.hash(json)); + String source = map.getSource(); + if (source == null || source.isBlank()) { + throw new IOException("Image-map resource '" + mapKey + "' has no source"); + } + imageKeys.add(source); + } + for (String imageKey : imageKeys) { + File imageFile = data.getImageLoader().findFile(imageKey); + if (imageFile == null || !imageFile.isFile()) { + throw new IOException("Image-map source '" + imageKey + "' could not be packaged"); + } + IO.copyFile(imageFile, new File(targetRoot, "images/" + imageKey + ".png")); + hashes.append(IO.hash(imageFile)); + } + return IO.hash(hashes.toString()); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackDimensionValidator.java b/core/src/main/java/art/arcane/iris/core/pack/PackDimensionValidator.java index 6066363b3..765842b95 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackDimensionValidator.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackDimensionValidator.java @@ -19,6 +19,7 @@ package art.arcane.iris.core.pack; import art.arcane.iris.engine.object.IrisDimensionType; +import art.arcane.iris.engine.object.IrisWorldBoundary; import art.arcane.volmlib.util.json.JSONArray; import art.arcane.volmlib.util.json.JSONObject; @@ -47,6 +48,7 @@ final class PackDimensionValidator { validateImportedStructurePolicy(dimensionKey, dimJson, blockingErrors, warnings); validateDimensionHeights(packFolder, dimensionKey, dimJson, blockingErrors); + validateWorldBoundary(dimensionKey, dimJson, blockingErrors); JSONArray regionsArray = dimJson.optJSONArray("regions"); if (regionsArray == null || regionsArray.length() == 0) { @@ -279,6 +281,85 @@ final class PackDimensionValidator { } } + static void validateWorldBoundary(String dimensionKey, JSONObject dimension, List blockingErrors) { + if (!dimension.has("worldBoundary")) { + return; + } + if (dimension.isNull("worldBoundary")) { + blockingErrors.add("Dimension '" + dimensionKey + "' worldBoundary must be an object."); + return; + } + JSONObject boundary = dimension.optJSONObject("worldBoundary"); + if (boundary == null) { + blockingErrors.add("Dimension '" + dimensionKey + "' worldBoundary must be an object."); + return; + } + + String context = "Dimension '" + dimensionKey + "' worldBoundary"; + validateFiniteNumber(boundary, "size", 1D, IrisWorldBoundary.MAXIMUM_SIZE, context, blockingErrors); + validateInteger(boundary, "warningDistance", 0, Integer.MAX_VALUE, context, blockingErrors); + validateFiniteNumber(boundary, "damageBuffer", 0D, Double.MAX_VALUE, context, blockingErrors); + validateFiniteNumber(boundary, "damageAmount", 0D, Double.MAX_VALUE, context, blockingErrors); + + if (!boundary.has("center")) { + return; + } + if (boundary.isNull("center")) { + blockingErrors.add(context + ".center must be an object."); + return; + } + JSONObject center = boundary.optJSONObject("center"); + if (center == null) { + blockingErrors.add(context + ".center must be an object."); + return; + } + validateFiniteNumber(center, "x", -IrisWorldBoundary.MAXIMUM_CENTER, + IrisWorldBoundary.MAXIMUM_CENTER, context + ".center", blockingErrors); + validateFiniteNumber(center, "z", -IrisWorldBoundary.MAXIMUM_CENTER, + IrisWorldBoundary.MAXIMUM_CENTER, context + ".center", blockingErrors); + } + + private static void validateFiniteNumber(JSONObject owner, String field, double minimum, double maximum, + String context, List blockingErrors) { + if (!owner.has(field)) { + return; + } + Object raw = owner.opt(field); + if (!(raw instanceof Number number)) { + blockingErrors.add(context + "." + field + " must be a number."); + return; + } + double value = number.doubleValue(); + if (!Double.isFinite(value) || value < minimum || value > maximum) { + blockingErrors.add(context + "." + field + " must be finite and between " + + decimalLabel(minimum) + " and " + decimalLabel(maximum) + "."); + } + } + + private static void validateInteger(JSONObject owner, String field, int minimum, int maximum, + String context, List blockingErrors) { + if (!owner.has(field)) { + return; + } + Object raw = owner.opt(field); + if (!(raw instanceof Number number)) { + blockingErrors.add(context + "." + field + " must be an integer."); + return; + } + double value = number.doubleValue(); + if (!Double.isFinite(value) || value != Math.rint(value) || value < minimum || value > maximum) { + blockingErrors.add(context + "." + field + " must be an integer between " + + minimum + " and " + maximum + "."); + } + } + + private static String decimalLabel(double value) { + if (Math.abs(value) <= Long.MAX_VALUE && value == Math.rint(value)) { + return Long.toString((long) value); + } + return Double.toString(value); + } + private static JSONObject resolveDimensionHeight(File packFolder, JSONObject dimJson) { if (!dimJson.has("dimensionHeight") || dimJson.isNull("dimensionHeight")) { return null; diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackDownloader.java b/core/src/main/java/art/arcane/iris/core/pack/PackDownloader.java index 18e574f50..72092c3a1 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackDownloader.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackDownloader.java @@ -358,23 +358,25 @@ public final class PackDownloader { PackDownloadMessages.DOWNLOADING, MessageArgument.untrusted("url", source) ) + " "); - File zip = WebCache.getNonCachedFile( - "pack-archive", - url, - ARCHIVE_LIMITS.maxArchiveBytes(), - transfer -> sendProgress(progressListener, DownloadProgress.transfer(transfer)) - ); + File zip; + try { + zip = WebCache.getNonCachedFile( + "pack-archive", + url, + ARCHIVE_LIMITS.maxArchiveBytes(), + transfer -> sendProgress(progressListener, DownloadProgress.transfer(transfer)) + ); + } catch (InterruptedIOException exception) { + cancellation.checkpoint(); + throw exception; + } cancellation.checkpoint(); File temp = WebCache.getTemp(); File work = new File(temp, "dl-" + UUID.randomUUID()); try { - if (zip == null || !zip.exists()) { - sendFeedback(feedback, IrisLanguage.plain( - PackDownloadMessages.FAILED_TO_FIND, - MessageArgument.untrusted("url", source) - )); - return null; + if (!zip.exists()) { + throw new IOException("Downloaded pack archive is missing before unpacking."); } sendProgress(progressListener, DownloadProgress.phase(DownloadPhase.UNPACKING)); sendFeedback(feedback, IrisLanguage.plain( diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackImageMapValidator.java b/core/src/main/java/art/arcane/iris/core/pack/PackImageMapValidator.java new file mode 100644 index 000000000..086c231e6 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/pack/PackImageMapValidator.java @@ -0,0 +1,907 @@ +package art.arcane.iris.core.pack; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.image.CompiledIrisImageMap; +import art.arcane.iris.engine.image.IrisImageMapCompiler; +import art.arcane.iris.engine.image.IrisImageMapValidationException; +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.IrisExpression; +import art.arcane.iris.engine.object.IrisGenerator; +import art.arcane.iris.engine.object.IrisImage; +import art.arcane.iris.engine.object.IrisImageMap; +import art.arcane.iris.engine.object.IrisImageMapApplication; +import art.arcane.iris.engine.object.IrisImageMapBinding; +import art.arcane.iris.engine.object.IrisImageMapMask; +import art.arcane.iris.engine.object.IrisImageMapOutOfBounds; +import art.arcane.iris.engine.object.IrisImageMapType; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.object.IrisRange; +import art.arcane.iris.engine.object.IrisWorldBoundary; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.volmlib.util.json.JSONArray; +import art.arcane.volmlib.util.json.JSONObject; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonParser; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.EnumMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.stream.Stream; + +final class PackImageMapValidator { + private static final byte[] PNG_SIGNATURE = { + (byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A + }; + + private PackImageMapValidator() { + } + + static Validation validate(File packFolder, File[] dimensionFiles, boolean validateLiveRegistries) { + Set blockingErrors = new LinkedHashSet<>(); + Set warnings = new LinkedHashSet<>(); + try { + if (!requiresValidation(packFolder)) { + return new Validation(List.of(), List.of()); + } + } catch (IOException error) { + IrisLogging.reportError("Image-map feature detection failed for '" + packFolder.getName() + "'.", error); + blockingErrors.add("Image-map validation could not inspect pack '" + packFolder.getName() + + "': " + failureMessage(error) + "."); + return new Validation(List.copyOf(blockingErrors), List.of()); + } + IrisData data = null; + try { + data = IrisData.openDatapackCompiler(packFolder); + Map resources = compileResources(data, blockingErrors); + PlatformRegistries registries = liveRegistries(validateLiveRegistries); + validateDimensions(packFolder, dimensionFiles, data, resources, registries, blockingErrors, warnings); + } catch (Throwable error) { + IrisLogging.reportError("Image-map pack validation failed for '" + packFolder.getName() + "'.", error); + blockingErrors.add("Image-map validation could not inspect pack '" + packFolder.getName() + + "': " + failureMessage(error) + "."); + } finally { + if (data != null) { + try { + data.close(); + } catch (Throwable error) { + IrisLogging.reportError("Failed to close detached image-map validation data for '" + + packFolder.getName() + "'.", error); + blockingErrors.add("Image-map validation could not release detached pack data: " + + failureMessage(error) + "."); + } + } + } + return new Validation(List.copyOf(blockingErrors), List.copyOf(warnings)); + } + + private static boolean requiresValidation(File packFolder) throws IOException { + File imageMapsFolder = new File(packFolder, "image-maps"); + if (imageMapsFolder.isDirectory()) { + try (Stream paths = Files.walk(imageMapsFolder.toPath())) { + if (paths.anyMatch(path -> Files.isRegularFile(path) + && path.getFileName().toString().endsWith(".json"))) { + return true; + } + } + } + try (Stream paths = Files.walk(packFolder.toPath())) { + List jsonFiles = paths + .filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().endsWith(".json")) + .toList(); + for (Path jsonFile : jsonFiles) { + try { + if (containsImageMapKey(JsonParser.parseString(Files.readString(jsonFile)))) { + return true; + } + } catch (JsonParseException error) { + return true; + } + } + } + return false; + } + + private static boolean containsImageMapKey(JsonElement element) { + if (element == null || element.isJsonNull() || element.isJsonPrimitive()) { + return false; + } + if (element.isJsonArray()) { + for (JsonElement child : element.getAsJsonArray()) { + if (containsImageMapKey(child)) { + return true; + } + } + return false; + } + JsonObject object = element.getAsJsonObject(); + if (object.has("imageMap") || object.has("imageMaps")) { + return true; + } + for (Map.Entry entry : object.entrySet()) { + if (containsImageMapKey(entry.getValue())) { + return true; + } + } + return false; + } + + private static Map compileResources(IrisData data, Set blockingErrors) { + String[] possibleKeys = data.getImageMapLoader().getPossibleKeys(); + Arrays.sort(possibleKeys); + Set imageKeys = new LinkedHashSet<>(Arrays.asList(data.getImageLoader().getPossibleKeys())); + Map resources = new LinkedHashMap<>(); + for (String mapKey : possibleKeys) { + IrisImageMap definition = data.getImageMapLoader().load(mapKey, false); + if (definition == null) { + blockingErrors.add("Image-map resource '" + mapKey + "' is invalid and could not be parsed."); + resources.put(mapKey, new MapResource(null, null)); + continue; + } + String source = normalized(definition.getSource()); + if (source == null) { + blockingErrors.add("Image-map resource '" + mapKey + "' source must not be blank."); + resources.put(mapKey, new MapResource(definition, null)); + continue; + } + if (!imageKeys.contains(source)) { + blockingErrors.add("Image-map resource '" + mapKey + "' references missing PNG source '" + + source + "'."); + resources.put(mapKey, new MapResource(definition, null)); + continue; + } + File sourceFile = data.getImageLoader().findFile(source); + if (sourceFile == null || !sourceFile.isFile()) { + blockingErrors.add("Image-map resource '" + mapKey + "' references missing PNG source '" + + source + "'."); + resources.put(mapKey, new MapResource(definition, null)); + continue; + } + try { + if (!hasPngSignature(sourceFile)) { + blockingErrors.add("Image-map resource '" + mapKey + "' source '" + source + + "' is not a PNG file."); + } + } catch (IOException error) { + blockingErrors.add("Image-map resource '" + mapKey + "' source '" + source + + "' could not be read: " + failureMessage(error) + "."); + } + IrisImage image = data.getImageLoader().load(source, false); + if (image == null) { + blockingErrors.add("Image-map resource '" + mapKey + "' source '" + source + + "' is corrupt or unsupported."); + resources.put(mapKey, new MapResource(definition, null)); + continue; + } + CompiledIrisImageMap compiled = null; + try { + compiled = IrisImageMapCompiler.compile(definition, image).withoutDecodedValues(); + } catch (IrisImageMapValidationException validation) { + for (String diagnostic : validation.getDiagnostics()) { + blockingErrors.add("Image-map resource '" + mapKey + "': " + diagnostic + "."); + } + } finally { + data.getImageLoader().unload(source); + } + resources.put(mapKey, new MapResource(definition, compiled)); + } + return resources; + } + + private static void validateDimensions( + File packFolder, + File[] dimensionFiles, + IrisData data, + Map resources, + PlatformRegistries registries, + Set blockingErrors, + Set warnings + ) { + File[] sortedFiles = Arrays.copyOf(dimensionFiles, dimensionFiles.length); + Arrays.sort(sortedFiles, (File first, File second) -> first.getName().compareTo(second.getName())); + Map dimensions = new LinkedHashMap<>(); + Set bindingReadyDimensions = new LinkedHashSet<>(); + for (File dimensionFile : sortedFiles) { + String dimensionKey = PackValidationIo.stripExtension(dimensionFile.getName()); + JSONObject raw = PackValidationIo.readJson(dimensionFile); + if (raw == null) { + continue; + } + IrisDimension dimension = data.getDimensionLoader().load(dimensionKey, false); + if (dimension == null) { + continue; + } + dimensions.put(dimensionKey, dimension); + if (raw.has("imageMaps")) { + JSONArray rawBindings = raw.optJSONArray("imageMaps"); + if (rawBindings == null) { + blockingErrors.add("Dimension '" + dimensionKey + "' imageMaps must be an array."); + } else if (dimension.getImageMaps() == null) { + blockingErrors.add("Dimension '" + dimensionKey + + "' imageMaps is invalid and could not be parsed."); + } else { + bindingReadyDimensions.add(dimensionKey); + } + } else { + bindingReadyDimensions.add(dimensionKey); + } + validateGeneratorStyleReferences(dimensionKey, dimension, data, resources, blockingErrors); + } + Map>> upperParents = new TreeMap<>(); + for (Map.Entry entry : dimensions.entrySet()) { + String upperKey = normalized(entry.getValue().getUpperDimension()); + if (upperKey == null || upperKey.equalsIgnoreCase("none")) { + continue; + } + upperParents.computeIfAbsent(upperKey, ignored -> new ArrayList<>()).add(entry); + } + for (Map.Entry entry : dimensions.entrySet()) { + if (!bindingReadyDimensions.contains(entry.getKey())) { + continue; + } + List> parents = upperParents.get(entry.getKey()); + if (parents == null || parents.isEmpty()) { + validateBindings(packFolder, "Dimension '" + entry.getKey() + "'", entry.getValue(), + entry.getValue().getWorldBoundary(), data, resources, registries, blockingErrors, warnings); + continue; + } + if (entry.getValue().getWorldBoundary() != null) { + validateBindings(packFolder, "Dimension '" + entry.getKey() + "'", entry.getValue(), + entry.getValue().getWorldBoundary(), data, resources, registries, blockingErrors, warnings); + } + parents.sort(Map.Entry.comparingByKey()); + for (Map.Entry parent : parents) { + validateBindings(packFolder, + "Dimension '" + parent.getKey() + "' upper dimension '" + entry.getKey() + "'", + entry.getValue(), parent.getValue().getWorldBoundary(), data, resources, registries, + blockingErrors, warnings); + } + } + } + + private static void validateBindings( + File packFolder, + String dimensionContext, + IrisDimension dimension, + IrisWorldBoundary enforcedBoundary, + IrisData data, + Map resources, + PlatformRegistries registries, + Set blockingErrors, + Set warnings + ) { + Map bindings = new LinkedHashMap<>(); + Map applications = new EnumMap<>(IrisImageMapApplication.class); + List declared = dimension.getImageMaps(); + Set regionKeys = allRegionKeys(dimension, resources); + for (int index = 0; index < declared.size(); index++) { + IrisImageMapBinding binding = declared.get(index); + String indexContext = dimensionContext + " imageMaps[" + index + "]"; + if (binding == null) { + blockingErrors.add(indexContext + " must be an object."); + continue; + } + String key = normalized(binding.getKey()); + if (key == null) { + blockingErrors.add(indexContext + ".key must not be blank."); + } else if (bindings.putIfAbsent(key, binding) != null) { + blockingErrors.add(dimensionContext + " declares duplicate image-map key '" + + key + "'."); + } + IrisImageMapApplication application = binding.getApplication(); + if (application == null) { + blockingErrors.add(indexContext + ".application is required."); + } else if (application != IrisImageMapApplication.MASK + && application != IrisImageMapApplication.CUSTOM) { + String duplicate = applications.putIfAbsent(application, key == null ? indexContext : key); + if (duplicate != null) { + blockingErrors.add(dimensionContext + " declares more than one " + + application + " image-map binding."); + } + } + validateMapReference(dimensionContext, indexContext, binding, key, dimension, enforcedBoundary, data, + regionKeys, resources, registries, packFolder, blockingErrors, warnings); + } + for (int index = 0; index < declared.size(); index++) { + IrisImageMapBinding binding = declared.get(index); + if (binding != null) { + validateMasks(dimensionContext, index, binding, bindings, blockingErrors); + } + } + } + + private static void validateMapReference( + String dimensionContext, + String indexContext, + IrisImageMapBinding binding, + String bindingKey, + IrisDimension dimension, + IrisWorldBoundary enforcedBoundary, + IrisData data, + Set regionKeys, + Map resources, + PlatformRegistries registries, + File packFolder, + Set blockingErrors, + Set warnings + ) { + String mapKey = normalized(binding.getMap()); + if (mapKey == null) { + blockingErrors.add(indexContext + ".map must not be blank."); + return; + } + MapResource resource = resources.get(mapKey); + if (resource == null) { + blockingErrors.add(indexContext + " references missing image-map resource '" + mapKey + "'."); + return; + } + IrisImageMap definition = resource.definition(); + IrisImageMapApplication application = binding.getApplication(); + if (definition == null || application == null) { + return; + } + IrisImageMapType type = definition.getType(); + String context = dimensionContext + " image-map '" + + (bindingKey == null ? mapKey : bindingKey) + "'"; + if (type != null && !compatible(application, type)) { + blockingErrors.add(context + " type " + type + " is incompatible with " + application + "."); + } + if (application == IrisImageMapApplication.MASK + && binding.getMasks() != null && !binding.getMasks().isEmpty()) { + blockingErrors.add(context + " is a MASK binding and cannot reference additional masks."); + } + validateLegendTargets(packFolder, context, regionKeys, data, application, definition, + registries, blockingErrors); + if (application == IrisImageMapApplication.TERRAIN_HEIGHT) { + validateTerrainHeightRange(context, dimension, definition, blockingErrors); + } + if (resource.compiled() != null && isGenerationApplication(application)) { + validateBoundaryCoverage(context, enforcedBoundary, definition, + resource.compiled(), blockingErrors, warnings); + } + } + + private static void validateMasks( + String dimensionContext, + int bindingIndex, + IrisImageMapBinding binding, + Map bindings, + Set blockingErrors + ) { + if (binding.getMasks() == null) { + return; + } + String bindingKey = normalized(binding.getKey()); + String context = bindingKey == null + ? dimensionContext + " imageMaps[" + bindingIndex + "]" + : dimensionContext + " image-map '" + bindingKey + "'"; + for (int maskIndex = 0; maskIndex < binding.getMasks().size(); maskIndex++) { + IrisImageMapMask mask = binding.getMasks().get(maskIndex); + String maskContext = context + " masks[" + maskIndex + "]"; + if (mask == null) { + blockingErrors.add(maskContext + " must be an object."); + continue; + } + String maskKey = normalized(mask.getMap()); + if (maskKey == null) { + blockingErrors.add(maskContext + ".map must not be blank."); + } else { + IrisImageMapBinding referenced = bindings.get(maskKey); + if (referenced == null) { + blockingErrors.add(maskContext + " references missing MASK binding '" + maskKey + "'."); + } else if (referenced.getApplication() != IrisImageMapApplication.MASK) { + blockingErrors.add(maskContext + " references '" + maskKey + "', which is not a MASK binding."); + } + } + if (mask.getOperation() == null) { + blockingErrors.add(maskContext + ".operation is required."); + } + if (!unitRange(mask.getThreshold())) { + blockingErrors.add(maskContext + ".threshold must be finite and within 0..1."); + } + if (!unitRange(mask.getFalloff())) { + blockingErrors.add(maskContext + ".falloff must be finite and within 0..1."); + } + } + } + + private static void validateLegendTargets( + File packFolder, + String context, + Set regionKeys, + IrisData data, + IrisImageMapApplication application, + IrisImageMap definition, + PlatformRegistries registries, + Set blockingErrors + ) { + if (definition.getType() != IrisImageMapType.COLOR_MAP) { + return; + } + Set targets = legendTargets(definition); + if (application == IrisImageMapApplication.BIOME) { + for (String target : targets) { + String local = localIrisTarget(context, "biome", target, blockingErrors); + if (local == null) { + continue; + } + File biomeFile = resourceFile(packFolder, "biomes", local); + IrisBiome biome = data.getBiomeLoader().load(local, false); + if (biomeFile == null || !biomeFile.isFile() || biome == null) { + blockingErrors.add(context + " references missing or invalid biome target '" + target + "'."); + continue; + } + validateBiomeRole(packFolder, context, regionKeys, local, blockingErrors); + } + } else if (application == IrisImageMapApplication.REGION) { + for (String target : targets) { + String local = localIrisTarget(context, "region", target, blockingErrors); + if (local == null) { + continue; + } + File regionFile = resourceFile(packFolder, "regions", local); + if (regionFile == null || !regionFile.isFile() || PackValidationIo.readJson(regionFile) == null) { + blockingErrors.add(context + " references missing or invalid region target '" + target + "'."); + } + } + } else if (application == IrisImageMapApplication.SURFACE_BLOCK && registries != null) { + for (String target : targets) { + try { + PlatformBlockState block = registries.blockOrNull(target, false); + if (block == null) { + blockingErrors.add(context + " references unknown surface block target '" + target + "'."); + } + } catch (Throwable error) { + IrisLogging.reportError("Could not validate image-map surface block target '" + target + "'.", error); + blockingErrors.add(context + " could not validate surface block target '" + target + + "': " + failureMessage(error) + "."); + } + } + for (String issue : ContentKeyValidator.validateBlockStateProperties(registries, targets)) { + blockingErrors.add(context + " surface block target is invalid: " + issue + "."); + } + } + } + + private static void validateBiomeRole( + File packFolder, + String context, + Set regionKeys, + String biomeKey, + Set blockingErrors + ) { + Set roles = new LinkedHashSet<>(); + for (String regionKey : regionKeys) { + File regionFile = resourceFile(packFolder, "regions", regionKey); + JSONObject region = regionFile == null ? null : PackValidationIo.readJson(regionFile); + if (region == null) { + continue; + } + collectRole(region, "landBiomes", "LAND", biomeKey, roles); + collectRole(region, "seaBiomes", "SEA", biomeKey, roles); + collectRole(region, "shoreBiomes", "SHORE", biomeKey, roles); + } + if (roles.size() != 1) { + blockingErrors.add(context + " biome target '" + biomeKey + + "' must occur in exactly one landBiomes, seaBiomes, or shoreBiomes role; found " + + roles + "."); + } + } + + private static void collectRole( + JSONObject region, + String field, + String role, + String biomeKey, + Set roles + ) { + JSONArray values = region.optJSONArray(field); + if (values == null) { + return; + } + for (int index = 0; index < values.length(); index++) { + if (biomeKey.equals(values.optString(index, null))) { + roles.add(role); + return; + } + } + } + + private static Set allRegionKeys( + IrisDimension dimension, + Map resources + ) { + Set regionKeys = new TreeSet<>(); + if (dimension.getRegions() != null) { + for (String regionKey : dimension.getRegions()) { + String normalized = normalized(regionKey); + if (normalized != null) { + regionKeys.add(normalized); + } + } + } + for (IrisImageMapBinding binding : dimension.getImageMaps()) { + if (binding == null || binding.getApplication() != IrisImageMapApplication.REGION) { + continue; + } + MapResource resource = resources.get(normalized(binding.getMap())); + if (resource == null || resource.definition() == null) { + continue; + } + for (String target : legendTargets(resource.definition())) { + String local = localIrisTargetOrNull(target); + if (local != null) { + regionKeys.add(local); + } + } + } + return regionKeys; + } + + private static void validateGeneratorStyleReferences( + String dimensionKey, + IrisDimension dimension, + IrisData data, + Map resources, + Set blockingErrors + ) { + Map roots = new TreeMap<>(); + roots.put("Dimension '" + dimensionKey + "'", dimension); + for (IrisRegion region : dimension.getAllRegions(() -> data)) { + if (region != null && normalized(region.getLoadKey()) != null) { + roots.put("Region '" + region.getLoadKey() + "' reachable from dimension '" + dimensionKey + "'", region); + } + } + Set generatorKeys = new TreeSet<>(); + for (IrisBiome biome : dimension.getReachableBiomes(() -> data)) { + if (biome == null || normalized(biome.getLoadKey()) == null) { + continue; + } + roots.put("Biome '" + biome.getLoadKey() + "' reachable from dimension '" + dimensionKey + "'", biome); + if (biome.getGenerators() == null) { + continue; + } + for (IrisBiomeGeneratorLink link : biome.getGenerators()) { + if (link == null) { + continue; + } + String generatorKey = normalized(link.getGenerator()); + generatorKeys.add(generatorKey == null ? "default" : generatorKey); + } + } + Set availableGeneratorKeys = new TreeSet<>(Arrays.asList( + data.getGeneratorLoader().getPossibleKeys())); + for (String generatorKey : generatorKeys) { + if (!availableGeneratorKeys.contains(generatorKey)) { + continue; + } + IrisGenerator generator = data.getGeneratorLoader().load(generatorKey, false); + if (generator != null) { + roots.put("Generator '" + generatorKey + "' reachable from dimension '" + dimensionKey + "'", generator); + } + } + Set visitedExpressions = new TreeSet<>(); + Set availableExpressionKeys = new TreeSet<>(Arrays.asList( + data.getExpressionLoader().getPossibleKeys())); + for (Map.Entry entry : roots.entrySet()) { + JSONObject root = new JSONObject(data.getGson().toJson(entry.getValue())); + scanGeneratorStyles(entry.getKey(), root, data, resources, availableExpressionKeys, + visitedExpressions, blockingErrors); + } + } + + private static void scanGeneratorStyles( + String path, + Object value, + IrisData data, + Map resources, + Set availableExpressionKeys, + Set visitedExpressions, + Set blockingErrors + ) { + if (value instanceof JSONObject object) { + if (isGeneratorStyleObject(object)) { + validateGeneratorStyleReference(path, object, resources, blockingErrors); + String expressionKey = normalized(object.optString("expression", null)); + if (expressionKey != null && availableExpressionKeys.contains(expressionKey) + && visitedExpressions.add(expressionKey)) { + IrisExpression expression = data.getExpressionLoader().load(expressionKey, false); + if (expression != null) { + JSONObject expressionRoot = new JSONObject(data.getGson().toJson(expression)); + scanGeneratorStyles("Expression '" + expressionKey + "' reachable from " + path, + expressionRoot, data, resources, availableExpressionKeys, + visitedExpressions, blockingErrors); + } + } + } + Set keys = new TreeSet<>(object.keySet()); + for (String key : keys) { + scanGeneratorStyles(path + "." + key, object.opt(key), data, resources, + availableExpressionKeys, visitedExpressions, blockingErrors); + } + return; + } + if (value instanceof JSONArray array) { + for (int index = 0; index < array.length(); index++) { + scanGeneratorStyles(path + "[" + index + "]", array.opt(index), data, resources, + availableExpressionKeys, visitedExpressions, blockingErrors); + } + } + } + + private static boolean isGeneratorStyleObject(JSONObject object) { + return object.has("cellularFrequency") + && object.has("cellularZoom") + && object.has("zoom") + && object.has("cacheSize"); + } + + private static void validateGeneratorStyleReference( + String context, + JSONObject style, + Map resources, + Set blockingErrors + ) { + if (!style.has("imageMap")) { + return; + } + String mapKey = normalized(style.optString("imageMap", null)); + if (mapKey == null) { + blockingErrors.add(context + " generator style imageMap must not be blank."); + return; + } + MapResource resource = resources.get(mapKey); + if (resource == null) { + blockingErrors.add(context + " generator style references missing image-map resource '" + mapKey + "'."); + return; + } + IrisImageMap definition = resource.definition(); + if (definition == null || definition.getType() == null) { + return; + } + if (definition.getType() == IrisImageMapType.COLOR_MAP) { + blockingErrors.add(context + " generator style image-map '" + mapKey + + "' must use a scalar map type, not COLOR_MAP."); + } + if (definition.getOutOfBounds() == IrisImageMapOutOfBounds.ERROR) { + blockingErrors.add(context + " generator style image-map '" + mapKey + + "' cannot use outOfBounds=ERROR because its transformed sampling domain cannot be proven finite;" + + " use FALLBACK, CLAMP, REPEAT, or MIRROR."); + } + } + + private static void validateTerrainHeightRange( + String context, + IrisDimension dimension, + IrisImageMap definition, + Set blockingErrors + ) { + if (definition.getType() != IrisImageMapType.GRAYSCALE_HEIGHT + && definition.getType() != IrisImageMapType.RGB_HEIGHT) { + return; + } + IrisRange dimensionHeight = dimension.getDimensionHeight(); + double minimumHeight = definition.getMinimumHeight(); + double maximumHeight = definition.getMaximumHeight(); + double verticalOffset = definition.getVerticalOffset(); + if (dimensionHeight == null + || !Double.isFinite(minimumHeight) + || !Double.isFinite(maximumHeight) + || !Double.isFinite(verticalOffset) + || maximumHeight < minimumHeight) { + return; + } + double effectiveMinimum = minimumHeight + verticalOffset; + double effectiveMaximum = maximumHeight + verticalOffset; + if (definition.isClamp()) { + effectiveMinimum = Math.max(minimumHeight, Math.min(maximumHeight, effectiveMinimum)); + effectiveMaximum = Math.max(minimumHeight, Math.min(maximumHeight, effectiveMaximum)); + } + if (effectiveMinimum < dimensionHeight.getMin() || effectiveMaximum > dimensionHeight.getMax()) { + blockingErrors.add(context + " produces world Y " + effectiveMinimum + ".." + effectiveMaximum + + " after verticalOffset and clamp; the owning dimension allows " + + dimensionHeight.getMin() + ".." + dimensionHeight.getMax() + "."); + } + } + + private static void validateBoundaryCoverage( + String context, + IrisWorldBoundary configuredBoundary, + IrisImageMap definition, + CompiledIrisImageMap compiled, + Set blockingErrors, + Set warnings + ) { + boolean requiresCoverage = definition.getOutOfBounds() == IrisImageMapOutOfBounds.ERROR; + if (configuredBoundary == null) { + if (requiresCoverage) { + blockingErrors.add(context + + " uses outOfBounds=ERROR and requires a configured worldBoundary."); + } + return; + } + IrisWorldBoundary boundary; + try { + boundary = IrisWorldBoundary.snapshot(configuredBoundary); + } catch (IllegalArgumentException ignored) { + return; + } + double insetX = boundaryInset(boundary.minimumX(), boundary.maximumX()); + double insetZ = boundaryInset(boundary.minimumZ(), boundary.maximumZ()); + double minimumX = boundary.minimumX() + insetX; + double maximumX = boundary.maximumX() - insetX; + double minimumZ = boundary.minimumZ() + insetZ; + double maximumZ = boundary.maximumZ() - insetZ; + boolean covers = compiled.containsWorldForSampling(minimumX, minimumZ) + && compiled.containsWorldForSampling(minimumX, maximumZ) + && compiled.containsWorldForSampling(maximumX, minimumZ) + && compiled.containsWorldForSampling(maximumX, maximumZ); + if (!covers) { + String issue = context + " source footprint does not cover the configured worldBoundary."; + if (requiresCoverage) { + blockingErrors.add(issue); + } else { + warnings.add(issue); + } + } + } + + private static Set legendTargets(IrisImageMap definition) { + Set targets = new TreeSet<>(); + if (definition.getColors() != null) { + for (String target : definition.getColors().values()) { + String normalized = normalized(target); + if (normalized != null) { + targets.add(normalized); + } + } + } + String fallback = normalized(definition.getFallbackTarget()); + if (fallback != null) { + targets.add(fallback); + } + return targets; + } + + private static String localIrisTarget( + String context, + String targetType, + String target, + Set blockingErrors + ) { + int separator = target.indexOf(':'); + String local = target; + if (separator >= 0) { + if (!target.startsWith("iris:") || separator == target.length() - 1) { + blockingErrors.add(context + " " + targetType + + " target must be a bare pack key or use the iris: namespace, got '" + target + "'."); + return null; + } + local = target.substring(separator + 1); + } + if (local.isBlank() || local.startsWith("/") || local.contains("\\")) { + blockingErrors.add(context + " " + targetType + " target '" + target + "' is not a valid pack key."); + return null; + } + for (String segment : local.split("/")) { + if (segment.isBlank() || segment.equals(".") || segment.equals("..")) { + blockingErrors.add(context + " " + targetType + " target '" + target + "' is not a valid pack key."); + return null; + } + } + return local; + } + + private static String localIrisTargetOrNull(String target) { + int separator = target.indexOf(':'); + if (separator < 0) { + return target; + } + if (!target.startsWith("iris:") || separator == target.length() - 1) { + return null; + } + return target.substring(separator + 1); + } + + private static File resourceFile(File packFolder, String folder, String key) { + String normalizedKey = normalized(key); + if (normalizedKey == null || normalizedKey.startsWith("/") || normalizedKey.contains("\\")) { + return null; + } + File root = new File(packFolder, folder); + File target = new File(root, normalizedKey + ".json"); + try { + if (!target.getCanonicalFile().toPath().startsWith(root.getCanonicalFile().toPath())) { + return null; + } + } catch (IOException error) { + return null; + } + return target; + } + + private static PlatformRegistries liveRegistries(boolean validateLiveRegistries) { + if (!validateLiveRegistries || !IrisPlatforms.isBound()) { + return null; + } + try { + PlatformRegistries registries = IrisPlatforms.get().registries(); + if (registries == null || registries.blockKeys() == null || registries.blockKeys().isEmpty()) { + return null; + } + return registries; + } catch (Throwable error) { + IrisLogging.reportError("Could not read live block registries for image-map validation.", error); + return null; + } + } + + private static boolean compatible(IrisImageMapApplication application, IrisImageMapType type) { + return switch (application) { + case TERRAIN_HEIGHT -> type == IrisImageMapType.GRAYSCALE_HEIGHT + || type == IrisImageMapType.RGB_HEIGHT; + case BIOME, REGION, SURFACE_BLOCK -> type == IrisImageMapType.COLOR_MAP; + case MASK -> type == IrisImageMapType.BINARY_MASK + || type == IrisImageMapType.GRAYSCALE_MASK + || type == IrisImageMapType.ALPHA_MASK; + case CUSTOM -> true; + }; + } + + private static boolean isGenerationApplication(IrisImageMapApplication application) { + return application != IrisImageMapApplication.CUSTOM; + } + + private static double boundaryInset(double minimum, double maximum) { + return Math.max(Math.ulp(minimum), Math.ulp(maximum)) * 8D; + } + + private static boolean hasPngSignature(File file) throws IOException { + byte[] actual; + try (InputStream input = Files.newInputStream(file.toPath())) { + actual = input.readNBytes(PNG_SIGNATURE.length); + } + return Arrays.equals(PNG_SIGNATURE, actual); + } + + private static boolean unitRange(double value) { + return Double.isFinite(value) && value >= 0D && value <= 1D; + } + + private static String normalized(String value) { + if (value == null || value.isBlank()) { + return null; + } + return value.trim(); + } + + private static String failureMessage(Throwable error) { + String message = error.getMessage(); + return message == null || message.isBlank() ? error.getClass().getSimpleName() : message; + } + + record Validation(List errors, List warnings) { + } + + private record MapResource(IrisImageMap definition, CompiledIrisImageMap compiled) { + } +} diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java b/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java index fec3e2746..c06873cc7 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java @@ -52,6 +52,10 @@ public final class PackValidator { return validate(packFolder, false); } + public static PackValidationResult validateForPackaging(File packFolder) { + return validate(packFolder, false); + } + private static PackValidationResult validate(File packFolder, boolean validateLiveRegistries) { String packName = packFolder == null ? "" : packFolder.getName(); List blockingErrors = new ArrayList<>(); @@ -76,6 +80,10 @@ public final class PackValidator { } PackDimensionValidator.validateDimensions(packFolder, dimensionFiles, blockingErrors, warnings); + PackImageMapValidator.Validation imageMaps = PackImageMapValidator.validate( + packFolder, dimensionFiles, validateLiveRegistries); + addDistinct(blockingErrors, imageMaps.errors()); + addDistinct(warnings, imageMaps.warnings()); PackRiverValidator.Validation riverValidation = PackRiverValidator.validate(packFolder, dimensionFiles); addDistinct(blockingErrors, riverValidation.errors()); addDistinct(warnings, riverValidation.warnings()); diff --git a/core/src/main/java/art/arcane/iris/core/project/ImageMapStudioExporter.java b/core/src/main/java/art/arcane/iris/core/project/ImageMapStudioExporter.java new file mode 100644 index 000000000..bd358e47f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/project/ImageMapStudioExporter.java @@ -0,0 +1,537 @@ +package art.arcane.iris.core.project; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.pack.PackValidationResult; +import art.arcane.iris.core.pack.PackValidator; +import art.arcane.iris.engine.image.CompiledIrisImageMap; +import art.arcane.iris.engine.image.IrisImageMapCompiler; +import art.arcane.iris.engine.image.IrisImageMapRuntime; +import art.arcane.iris.engine.image.IrisImageMapMaskSampler; +import art.arcane.iris.engine.image.IrisImageMapValidationException; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisImage; +import art.arcane.iris.engine.object.IrisImageMap; +import art.arcane.iris.engine.object.IrisImageMapApplication; +import art.arcane.iris.engine.object.IrisImageMapBinding; +import art.arcane.iris.engine.object.IrisImageMapMask; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.json.JSONObject; +import com.google.gson.Gson; + +import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +import javax.imageio.metadata.IIOMetadata; +import javax.imageio.stream.ImageInputStream; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.regex.Pattern; + +import org.w3c.dom.NamedNodeMap; +import org.w3c.dom.Node; + +public final class ImageMapStudioExporter { + private static final Pattern RESOURCE_KEY = Pattern.compile("[a-z0-9][a-z0-9/._-]*"); + + private ImageMapStudioExporter() { + } + + public static ExportResult export(ExportRequest request) throws IOException { + Objects.requireNonNull(request, "Image-map Studio export request"); + Path pack = requirePack(request.packFolder()); + String dimensionKey = requireKey(request.dimensionKey(), "Dimension key"); + String bindingKey = requireKey(request.bindingKey(), "Binding key"); + String imageMapKey = requireKey(request.imageMapKey(), "Image-map key"); + String imageKey = requireKey(request.imageKey(), "Image key"); + IrisImageMapApplication application = Objects.requireNonNull( + request.application(), "Image-map application" + ); + IrisImageMap definition = Objects.requireNonNull(request.definition(), "Image-map definition"); + Path source = Objects.requireNonNull(request.sourcePng(), "Source PNG").toAbsolutePath().normalize(); + if (!Files.isRegularFile(source) || Files.isSymbolicLink(source)) { + throw new IOException("Source PNG is missing or unsafe: " + source); + } + + DecodedSource decoded = decodePng(source); + definition.setSource(imageKey); + CompiledIrisImageMap compiled = CompiledIrisImageMap.compile( + definition, new IrisImage(decoded.image(), decoded.format()) + ); + + Path dimensionTarget = safeTarget(pack, "dimensions", dimensionKey + ".json"); + Path mapTarget = safeTarget(pack, "image-maps", imageMapKey + ".json"); + Path imageTarget = safeTarget(pack, "images", imageKey + ".png"); + IrisDimension dimension; + String mapJson; + String dimensionJson; + IrisData data = IrisData.openDatapackCompiler(pack.toFile()); + try { + dimension = data.getDimensionLoader().load(dimensionKey); + if (dimension == null) { + throw new IOException("Dimension resource '" + dimensionKey + "' could not be loaded from " + pack); + } + IrisDimension updated = data.getGson().fromJson(data.getGson().toJson(dimension), IrisDimension.class); + bind(updated, bindingKey, imageMapKey, application, request.masks()); + Gson gson = new Gson(); + mapJson = new JSONObject(gson.toJson(definition)).toString(4); + dimensionJson = new JSONObject(gson.toJson(updated)).toString(4); + } finally { + data.close(); + } + + AtomicFileSet files = new AtomicFileSet(); + try { + files.stageCopy(source, imageTarget); + files.stageText(mapJson, mapTarget); + files.stageText(dimensionJson, dimensionTarget); + files.publish(); + + PackValidationResult validation = validatePublished(pack, dimensionKey); + if (!validation.isLoadable()) { + throw new IrisImageMapValidationException(validation.getBlockingErrors()); + } + files.commit(); + return new ExportResult( + imageTarget, + mapTarget, + dimensionTarget, + compiled.getContentHash(), + validation.getWarnings() + ); + } finally { + files.close(); + } + } + + public static PreviewResult preview(Path sourcePng, IrisImageMap definition) throws IOException { + return preview(sourcePng, definition, null, null, List.of()); + } + + public static PreviewResult preview( + Path sourcePng, + IrisImageMap definition, + File packFolder, + String dimensionKey, + List masks + ) throws IOException { + Path source = Objects.requireNonNull(sourcePng, "Source PNG").toAbsolutePath().normalize(); + DecodedSource decoded = decodePng(source); + IrisImage image = new IrisImage(decoded.image(), decoded.format()); + CompiledIrisImageMap compiled = CompiledIrisImageMap.compile(definition, image); + IrisImageMapMaskSampler maskSampler = compilePreviewMasks(packFolder, dimensionKey, masks); + return new PreviewResult(decoded.image(), compiled, decoded.colorProfile(), maskSampler); + } + + public static SourceInspection inspectSource(Path sourcePng) throws IOException { + Path source = Objects.requireNonNull(sourcePng, "Source PNG").toAbsolutePath().normalize(); + DecodedSource decoded = decodePng(source); + IrisImage image = new IrisImage(decoded.image(), decoded.format()); + double minimumAlpha = 1D; + double maximumAlpha = image.hasAlpha() ? 0D : 1D; + if (image.hasAlpha()) { + for (int sourceZ = 0; sourceZ < image.getHeight(); sourceZ++) { + for (int sourceX = 0; sourceX < image.getWidth(); sourceX++) { + double alpha = image.getAlphaNormalized(sourceX, sourceZ); + minimumAlpha = Math.min(minimumAlpha, alpha); + maximumAlpha = Math.max(maximumAlpha, alpha); + } + } + } + return new SourceInspection( + decoded.image(), decoded.format(), decoded.colorProfile(), minimumAlpha, maximumAlpha + ); + } + + private static IrisImageMapMaskSampler compilePreviewMasks( + File packFolder, + String dimensionKey, + List masks + ) throws IOException { + if (masks == null || masks.isEmpty()) { + return IrisImageMapMaskSampler.empty(); + } + Path pack = requirePack(packFolder); + String dimensionResource = requireKey(dimensionKey, "Dimension key"); + IrisData data = IrisData.openDatapackCompiler(pack.toFile()); + try { + IrisDimension dimension = data.getDimensionLoader().load(dimensionResource); + if (dimension == null) { + throw new IOException("Dimension resource '" + dimensionResource + "' could not be loaded"); + } + List compiledMasks = new ArrayList<>(masks.size()); + for (IrisImageMapMask mask : masks) { + IrisImageMapBinding binding = findBinding(dimension, mask.getMap()); + if (binding == null || binding.getApplication() != IrisImageMapApplication.MASK) { + throw new IrisImageMapValidationException( + "Composed mask '" + mask.getMap() + "' does not reference a MASK binding" + ); + } + IrisImageMap maskDefinition = data.getImageMapLoader().load(binding.getMap()); + if (maskDefinition == null) { + throw new IrisImageMapValidationException( + "Composed mask '" + mask.getMap() + "' references missing image-map '" + + binding.getMap() + "'" + ); + } + IrisImage maskImage = data.getImageLoader().load(maskDefinition.getSource()); + if (maskImage == null) { + throw new IrisImageMapValidationException( + "Composed mask '" + mask.getMap() + "' references missing PNG '" + + maskDefinition.getSource() + "'" + ); + } + try { + compiledMasks.add(CompiledIrisImageMap.compile(maskDefinition, maskImage)); + } finally { + data.getImageLoader().unload(maskDefinition.getSource()); + } + } + return IrisImageMapMaskSampler.of(compiledMasks, masks); + } finally { + data.close(); + } + } + + private static IrisImageMapBinding findBinding(IrisDimension dimension, String key) { + if (key == null) { + return null; + } + for (IrisImageMapBinding binding : dimension.getImageMaps()) { + if (binding != null && key.equals(binding.getKey())) { + return binding; + } + } + return null; + } + + private static PackValidationResult validatePublished(Path pack, String dimensionKey) throws IOException { + IrisData validationData = IrisData.openDatapackCompiler(pack.toFile()); + try { + IrisDimension published = validationData.getDimensionLoader().load(dimensionKey); + if (published == null) { + throw new IOException("Published dimension '" + dimensionKey + "' could not be reloaded"); + } + IrisImageMapRuntime.compile(validationData, published, published.getMinHeight()); + } finally { + validationData.close(); + } + return PackValidator.validate(pack.toFile()); + } + + private static void bind( + IrisDimension dimension, + String bindingKey, + String imageMapKey, + IrisImageMapApplication application, + List masks + ) { + IrisImageMapBinding existing = null; + for (IrisImageMapBinding candidate : dimension.getImageMaps()) { + if (candidate != null && bindingKey.equals(candidate.getKey())) { + existing = candidate; + break; + } + } + KList configuredMasks = new KList<>(); + configuredMasks.addAll(masks); + if (existing == null) { + dimension.getImageMaps().add(new IrisImageMapBinding() + .setKey(bindingKey) + .setMap(imageMapKey) + .setApplication(application) + .setMasks(configuredMasks)); + return; + } + existing.setMap(imageMapKey); + existing.setApplication(application); + existing.setMasks(configuredMasks); + } + + private static DecodedSource decodePng(Path source) throws IOException { + if (!Files.isRegularFile(source)) { + throw new IOException("Image source is not a file: " + source); + } + try (ImageInputStream input = ImageIO.createImageInputStream(source.toFile())) { + if (input == null) { + throw new IOException("Unable to open image source " + source); + } + Iterator readers = ImageIO.getImageReaders(input); + if (!readers.hasNext()) { + throw new IOException("Unsupported or corrupt image source " + source); + } + ImageReader reader = readers.next(); + try { + String format = reader.getFormatName().toLowerCase(); + if (!"png".equals(format)) { + throw new IOException("Image-map data must be PNG, got " + format); + } + reader.setInput(input, true, false); + validateDimensions(reader.getWidth(0), reader.getHeight(0)); + String colorProfile = colorProfile(reader); + BufferedImage image = reader.read(0); + if (image == null) { + throw new IOException("PNG decoder returned no image for " + source); + } + return new DecodedSource(image, format, colorProfile); + } finally { + reader.dispose(); + } + } + } + + private static void validateDimensions(int width, int height) { + List diagnostics = new ArrayList<>(); + if (width < IrisImageMapCompiler.MINIMUM_DIMENSION || width > IrisImageMapCompiler.MAXIMUM_DIMENSION) { + diagnostics.add("Image width must be " + IrisImageMapCompiler.MINIMUM_DIMENSION + ".." + + IrisImageMapCompiler.MAXIMUM_DIMENSION + ", got " + width); + } + if (height < IrisImageMapCompiler.MINIMUM_DIMENSION || height > IrisImageMapCompiler.MAXIMUM_DIMENSION) { + diagnostics.add("Image height must be " + IrisImageMapCompiler.MINIMUM_DIMENSION + ".." + + IrisImageMapCompiler.MAXIMUM_DIMENSION + ", got " + height); + } + long pixels = (long) width * height; + if (pixels > IrisImageMapCompiler.MAXIMUM_PIXELS) { + diagnostics.add("Image contains " + pixels + " pixels; maximum is " + + IrisImageMapCompiler.MAXIMUM_PIXELS); + } + if (!diagnostics.isEmpty()) { + throw new IrisImageMapValidationException(diagnostics); + } + } + + private static Path requirePack(File folder) throws IOException { + if (folder == null) { + throw new IOException("Pack folder is required"); + } + Path pack = folder.toPath().toAbsolutePath().normalize(); + if (!Files.isDirectory(pack) || Files.isSymbolicLink(pack)) { + throw new IOException("Pack folder is missing or unsafe: " + pack); + } + return pack; + } + + private static String colorProfile(ImageReader reader) { + try { + IIOMetadata metadata = reader.getImageMetadata(0); + Node root = metadata.getAsTree("javax_imageio_png_1.0"); + Node embedded = findNode(root, "iCCP"); + if (embedded != null) { + return "embedded ICC: " + attribute(embedded, "profileName", "unnamed"); + } + Node standard = findNode(root, "sRGB"); + if (standard != null) { + return "sRGB: " + attribute(standard, "renderingIntent", "intent unspecified"); + } + Node gamma = findNode(root, "gAMA"); + if (gamma != null) { + return "gamma: " + attribute(gamma, "value", "unspecified"); + } + return "none declared"; + } catch (IOException | RuntimeException exception) { + return "metadata unavailable"; + } + } + + private static Node findNode(Node node, String name) { + if (node == null) { + return null; + } + if (name.equals(node.getNodeName())) { + return node; + } + for (Node child = node.getFirstChild(); child != null; child = child.getNextSibling()) { + Node found = findNode(child, name); + if (found != null) { + return found; + } + } + return null; + } + + private static String attribute(Node node, String name, String fallback) { + NamedNodeMap attributes = node.getAttributes(); + Node value = attributes == null ? null : attributes.getNamedItem(name); + return value == null || value.getNodeValue().isBlank() ? fallback : value.getNodeValue(); + } + + private static String requireKey(String value, String name) { + if (value == null || !RESOURCE_KEY.matcher(value).matches() || value.contains("..")) { + throw new IllegalArgumentException(name + " must match " + RESOURCE_KEY.pattern() + " without '..'"); + } + return value; + } + + private static Path safeTarget(Path pack, String folder, String relativeName) throws IOException { + Path target = pack.resolve(folder).resolve(relativeName).normalize(); + if (!target.startsWith(pack) || target.equals(pack)) { + throw new IOException("Export target escapes the pack: " + target); + } + if (Files.isSymbolicLink(target)) { + throw new IOException("Export target must not be a symbolic link: " + target); + } + return target; + } + + public record ExportRequest( + File packFolder, + String dimensionKey, + String bindingKey, + IrisImageMapApplication application, + String imageMapKey, + String imageKey, + IrisImageMap definition, + Path sourcePng, + List masks + ) { + public ExportRequest { + masks = masks == null ? List.of() : List.copyOf(masks); + } + } + + public record ExportResult( + Path imageFile, + Path imageMapFile, + Path dimensionFile, + String contentHash, + List warnings + ) { + public ExportResult { + warnings = List.copyOf(warnings); + } + } + + public record PreviewResult( + BufferedImage source, + CompiledIrisImageMap compiled, + String colorProfile, + IrisImageMapMaskSampler maskSampler + ) { + } + + public record SourceInspection( + BufferedImage source, + String format, + String colorProfile, + double minimumAlpha, + double maximumAlpha + ) { + } + + private record DecodedSource(BufferedImage image, String format, String colorProfile) { + } + + private static final class AtomicFileSet implements AutoCloseable { + private final List files = new ArrayList<>(); + private boolean published; + private boolean committed; + + private void stageCopy(Path source, Path target) throws IOException { + Files.createDirectories(target.getParent()); + Path staged = Files.createTempFile(target.getParent(), "." + target.getFileName(), ".studio"); + Files.copy(source, staged, StandardCopyOption.REPLACE_EXISTING); + files.add(new StagedFile(staged, target, null, false)); + } + + private void stageText(String content, Path target) throws IOException { + Files.createDirectories(target.getParent()); + Path staged = Files.createTempFile(target.getParent(), "." + target.getFileName(), ".studio"); + Files.writeString(staged, content, StandardCharsets.UTF_8); + files.add(new StagedFile(staged, target, null, false)); + } + + private void publish() throws IOException { + published = true; + for (int index = 0; index < files.size(); index++) { + StagedFile file = files.get(index); + Path backup = null; + if (Files.exists(file.target())) { + backup = file.target().resolveSibling("." + file.target().getFileName() + + ".backup-" + UUID.randomUUID()); + move(file.target(), backup, false); + } + files.set(index, new StagedFile(file.staged(), file.target(), backup, true)); + move(file.staged(), file.target(), true); + } + } + + private void commit() throws IOException { + committed = true; + for (StagedFile file : files) { + if (file.backup() != null) { + Files.deleteIfExists(file.backup()); + } + } + } + + @Override + public void close() throws IOException { + IOException failure = null; + if (published && !committed) { + for (int index = files.size() - 1; index >= 0; index--) { + StagedFile file = files.get(index); + if (!file.published()) { + continue; + } + try { + Files.deleteIfExists(file.target()); + if (file.backup() != null && Files.exists(file.backup())) { + move(file.backup(), file.target(), true); + } + } catch (IOException rollbackFailure) { + if (failure == null) { + failure = rollbackFailure; + } else { + failure.addSuppressed(rollbackFailure); + } + } + } + } + for (StagedFile file : files) { + try { + Files.deleteIfExists(file.staged()); + if (committed && file.backup() != null) { + Files.deleteIfExists(file.backup()); + } + } catch (IOException cleanupFailure) { + if (failure == null) { + failure = cleanupFailure; + } else { + failure.addSuppressed(cleanupFailure); + } + } + } + if (failure != null) { + throw failure; + } + } + + private static void move(Path source, Path target, boolean replace) throws IOException { + try { + if (replace) { + Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } else { + Files.move(source, target, StandardCopyOption.ATOMIC_MOVE); + } + } catch (AtomicMoveNotSupportedException exception) { + if (replace) { + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); + } else { + Files.move(source, target); + } + } + } + } + + private record StagedFile(Path staged, Path target, Path backup, boolean published) { + } +} diff --git a/core/src/main/java/art/arcane/iris/core/project/IrisPackageCompiler.java b/core/src/main/java/art/arcane/iris/core/project/IrisPackageCompiler.java index b6513060a..464c04049 100644 --- a/core/src/main/java/art/arcane/iris/core/project/IrisPackageCompiler.java +++ b/core/src/main/java/art/arcane/iris/core/project/IrisPackageCompiler.java @@ -22,6 +22,9 @@ import com.google.gson.Gson; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.localization.BukkitRuntimeMessages; import art.arcane.iris.core.localization.IrisLanguage; +import art.arcane.iris.core.pack.ImageMapPackageClosure; +import art.arcane.iris.core.pack.PackValidationResult; +import art.arcane.iris.core.pack.PackValidator; import art.arcane.iris.core.pack.StructurePackageClosure; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBlockData; @@ -65,6 +68,14 @@ public class IrisPackageCompiler { } public File compilePackage(VolmitSender sender, boolean obfuscate, boolean minify) { + PackValidationResult validation = PackValidator.validateForPackaging(project.getPath()); + if (!validation.isLoadable()) { + IllegalStateException error = new IllegalStateException("Pack validation failed before packaging: " + + String.join("; ", validation.getBlockingErrors())); + IrisLogging.reportError("Package validation failed for '" + project.getName() + "'.", error); + sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.IRIS_PROJECT_FAILED)); + return null; + } // Detached loader on purpose: obfuscation rewrites the placement 'place' lists on the // loaded resources, which must never leak into the shared cached pack state — a second // export against a mutated cache produced archives with an empty objects/ directory. @@ -108,9 +119,10 @@ public class IrisPackageCompiler { addIfPresent(blocks, dm.getBlockLoader().load(i)); } - dimension.getRegions().forEach((i) -> addIfPresent(regions, dm.getRegionLoader().load(i))); + dimension.getAllRegions(() -> dm).forEach((region) -> addIfPresent(regions, region)); dimension.getLoot().getTables().forEach((i) -> addIfPresent(loot, dm.getLootLoader().load(i))); regions.forEach((i) -> biomes.addAll(i.getAllBiomes(() -> dm))); + dimension.getReachableBiomes(() -> dm).forEach((biome) -> addIfPresent(biomes, biome)); regions.forEach((r) -> r.getLoot().getTables().forEach((i) -> addIfPresent(loot, dm.getLootLoader().load(i)))); regions.forEach((r) -> r.getEntitySpawners().forEach((sp) -> addIfPresent(spawners, dm.getSpawnerLoader().load(sp)))); dimension.getEntitySpawners().forEach((sp) -> addIfPresent(spawners, dm.getSpawnerLoader().load(sp))); @@ -209,6 +221,7 @@ public class IrisPackageCompiler { throw new IOException("Structure package closure is invalid: " + String.join("; ", structureClosure.errors())); } b.append(structureClosure.writeTo(folder, minify)); + b.append(ImageMapPackageClosure.writeAll(dm, folder, minify)); a = new JSONObject(new Gson().toJson(dimension)).toString(minify ? 0 : 4); IO.writeAll(new File(folder, "dimensions/" + dimension.getLoadKey() + ".json"), a); b.append(IO.hash(a)); diff --git a/core/src/main/java/art/arcane/iris/core/service/JigsawStudioMenuController.java b/core/src/main/java/art/arcane/iris/core/service/JigsawStudioMenuController.java index 2cbf2e6f2..f1028b0ef 100644 --- a/core/src/main/java/art/arcane/iris/core/service/JigsawStudioMenuController.java +++ b/core/src/main/java/art/arcane/iris/core/service/JigsawStudioMenuController.java @@ -12,6 +12,7 @@ import art.arcane.volmlib.util.inventorygui.UIElement; import art.arcane.volmlib.util.inventorygui.UIPaneDecorator; import art.arcane.volmlib.util.inventorygui.UIWindow; import art.arcane.volmlib.util.inventorygui.WindowResolution; +import art.arcane.volmlib.util.plugin.ComponentMessenger; import org.bukkit.ChatColor; import org.bukkit.Material; import org.bukkit.entity.Player; @@ -64,13 +65,13 @@ public final class JigsawStudioMenuController { Optional available = actions.menuState(player); if (available.isEmpty()) { close(player); - player.sendMessage(ChatColor.RED + "Iris Jigsaw Studio is not active in this world."); + send(player, ChatColor.RED + "Iris Jigsaw Studio is not active in this world."); return false; } JigsawStudioMenuState state = available.get(); if (state.workcells().isEmpty()) { close(player); - player.sendMessage(ChatColor.RED + "This Jigsaw Studio has no workcells."); + send(player, ChatColor.RED + "This Jigsaw Studio has no workcells."); return false; } @@ -160,7 +161,7 @@ public final class JigsawStudioMenuController { return false; } if (!current.get().irisExtended()) { - player.sendMessage(ChatColor.YELLOW + send(player, ChatColor.YELLOW + "Vanilla-portable pieces cannot encode Iris theme or piece-rule metadata."); close(player); return false; @@ -1573,7 +1574,7 @@ public final class JigsawStudioMenuController { return; } if (variant.active()) { - player.sendMessage(ChatColor.YELLOW + "That variant is already loaded. Right-click it for details."); + send(player, ChatColor.YELLOW + "That variant is already loaded. Right-click it for details."); return; } if (actions.switchVariant(player, workcellId, pieceKey, false)) { @@ -1655,11 +1656,11 @@ public final class JigsawStudioMenuController { return; } if (!variant.active() || !variant.owned()) { - player.sendMessage(ChatColor.YELLOW + "Load an owned variant before editing its rules."); + send(player, ChatColor.YELLOW + "Load an owned variant before editing its rules."); return; } if (!current.get().irisExtended()) { - player.sendMessage(ChatColor.YELLOW + send(player, ChatColor.YELLOW + "Vanilla-portable pieces cannot encode Iris theme or piece-rule metadata."); return; } @@ -1753,7 +1754,7 @@ public final class JigsawStudioMenuController { UUID playerId = player.getUniqueId(); PendingWorkcellResize existing = pendingWorkcellResize(playerId, requestId, workcellId); if (existing != null && existing.applying()) { - player.sendMessage(ChatColor.YELLOW + "That cell size is already being applied."); + send(player, ChatColor.YELLOW + "That cell size is already being applied."); return; } JigsawStudioCellDimensions base = existing == null @@ -1762,7 +1763,7 @@ public final class JigsawStudioMenuController { Optional adjusted = adjustedDimensions( base, axis, delta); if (adjusted.isEmpty()) { - player.sendMessage(ChatColor.RED + "That workcell size is outside Iris limits."); + send(player, ChatColor.RED + "That workcell size is outside Iris limits."); return; } PendingWorkcellResize pending = new PendingWorkcellResize( @@ -1864,7 +1865,7 @@ public final class JigsawStudioMenuController { if (window != null) { renderWorkcellSettings(window, current.orElseThrow(), withCapacity(workcell, retry.dimensions())); } - player.sendMessage(ChatColor.YELLOW + send(player, ChatColor.YELLOW + "Cell resizing is still pending; use Apply Cell Size to retry after the current operation settles."); } @@ -1927,7 +1928,7 @@ public final class JigsawStudioMenuController { Optional adjusted = adjustedDimensions( variant.dimensions().orElseThrow(), axis, delta); if (adjusted.isEmpty()) { - player.sendMessage(ChatColor.RED + "That variant size is outside Iris limits."); + send(player, ChatColor.RED + "That variant size is outside Iris limits."); return; } resizeVariant(player, requestId, workcellId, pieceKey, adjusted.get()); @@ -1953,7 +1954,7 @@ public final class JigsawStudioMenuController { if (dimensions.width() > workcell.capacity().width() || dimensions.height() > workcell.capacity().height() || dimensions.depth() > workcell.capacity().depth()) { - player.sendMessage(ChatColor.RED + "Increase this workcell's capacity before making the variant larger."); + send(player, ChatColor.RED + "Increase this workcell's capacity before making the variant larger."); return; } if (actions.resizeVariant(player, workcellId, pieceKey, dimensions)) { @@ -1967,11 +1968,11 @@ public final class JigsawStudioMenuController { return; } if (!current.get().irisExtended()) { - player.sendMessage(ChatColor.YELLOW + "Mandatory caps require Iris compatibility."); + send(player, ChatColor.YELLOW + "Mandatory caps require Iris compatibility."); return; } if (current.get().requireCaps() == requireCaps) { - player.sendMessage(ChatColor.YELLOW + "Mandatory caps are already " + send(player, ChatColor.YELLOW + "Mandatory caps are already " + (requireCaps ? "enabled." : "disabled.")); return; } @@ -1987,7 +1988,7 @@ public final class JigsawStudioMenuController { return; } if (!current.get().irisExtended()) { - player.sendMessage(ChatColor.YELLOW + "Theme sets require Iris compatibility."); + send(player, ChatColor.YELLOW + "Theme sets require Iris compatibility."); return; } if (!nextThemeSetKey(current.get().themeSets()).equals(themeKey)) { @@ -2011,7 +2012,7 @@ public final class JigsawStudioMenuController { return; } if (!current.get().irisExtended()) { - player.sendMessage(ChatColor.YELLOW + "Theme weights require Iris compatibility."); + send(player, ChatColor.YELLOW + "Theme weights require Iris compatibility."); return; } JigsawStudioMenuState.ThemeSet themeSet = current.get().themeSet(expected.key()); @@ -2021,7 +2022,7 @@ public final class JigsawStudioMenuController { } Optional weight = adjustedPositiveValue(themeSet.weight(), delta); if (weight.isEmpty()) { - player.sendMessage(ChatColor.RED + "Theme weights must remain positive."); + send(player, ChatColor.RED + "Theme weights must remain positive."); return; } if (actions.updateThemeSetWeight(player, themeSet.key(), weight.get())) { @@ -2041,7 +2042,7 @@ public final class JigsawStudioMenuController { return; } if (!workcell.dirty()) { - player.sendMessage(ChatColor.YELLOW + "This workcell has no pending changes to flush."); + send(player, ChatColor.YELLOW + "This workcell has no pending changes to flush."); return; } if (actions.flushAutosave(player, workcellId)) { @@ -2085,7 +2086,7 @@ public final class JigsawStudioMenuController { } Optional rules = adjustedRules(variant.rules(), field, delta); if (rules.isEmpty()) { - player.sendMessage(ChatColor.RED + "That piece rule value is outside Iris limits."); + send(player, ChatColor.RED + "That piece rule value is outside Iris limits."); return; } updateVariantRules( @@ -2108,7 +2109,7 @@ public final class JigsawStudioMenuController { return; } if (!current.get().irisExtended()) { - player.sendMessage(ChatColor.YELLOW + "Piece rules require Iris compatibility."); + send(player, ChatColor.YELLOW + "Piece rules require Iris compatibility."); return; } JigsawStudioMenuState.Variant variant = activeVariant(current.get(), workcellId); @@ -2134,7 +2135,7 @@ public final class JigsawStudioMenuController { return; } if (!current.get().irisExtended()) { - player.sendMessage(ChatColor.YELLOW + "Theme membership requires Iris compatibility."); + send(player, ChatColor.YELLOW + "Theme membership requires Iris compatibility."); return; } JigsawStudioMenuState.Variant variant = activeVariant(current.get(), workcell.stableId()); @@ -2201,7 +2202,7 @@ public final class JigsawStudioMenuController { return; } if (!current.get().irisExtended()) { - player.sendMessage(ChatColor.YELLOW + "Per-entry chance requires Iris compatibility."); + send(player, ChatColor.YELLOW + "Per-entry chance requires Iris compatibility."); return; } JigsawStudioMenuState.Variant active = activeVariant(current.get(), workcellId); @@ -2449,7 +2450,11 @@ public final class JigsawStudioMenuController { private void stale(Player player) { closeAfterAction(player); - player.sendMessage(ChatColor.RED + "This Jigsaw Studio menu is stale. Open the control chest again."); + send(player, ChatColor.RED + "This Jigsaw Studio menu is stale. Open the control chest again."); + } + + private static void send(Player player, String message) { + ComponentMessenger.sendSection(player, message); } private void closeAfterAction(Player player) { diff --git a/core/src/main/java/art/arcane/iris/core/service/JigsawStudioService.java b/core/src/main/java/art/arcane/iris/core/service/JigsawStudioService.java index c4f6ef504..ea1c84397 100644 --- a/core/src/main/java/art/arcane/iris/core/service/JigsawStudioService.java +++ b/core/src/main/java/art/arcane/iris/core/service/JigsawStudioService.java @@ -63,6 +63,7 @@ import art.arcane.iris.util.common.scheduling.J; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.plugin.ComponentMessenger; import org.bukkit.Bukkit; import org.bukkit.ChatColor; import org.bukkit.Chunk; @@ -7803,7 +7804,7 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC private static void message(Player player, String text) { if (player != null) { - J.runEntity(player, () -> player.sendMessage("[Iris Jigsaw Studio] " + text)); + J.runEntity(player, () -> ComponentMessenger.sendLiteral(player, "[Iris Jigsaw Studio] " + text)); } } diff --git a/core/src/main/java/art/arcane/iris/core/service/ObjectStudioSaveService.java b/core/src/main/java/art/arcane/iris/core/service/ObjectStudioSaveService.java index 7470939b0..e51dc1e32 100644 --- a/core/src/main/java/art/arcane/iris/core/service/ObjectStudioSaveService.java +++ b/core/src/main/java/art/arcane/iris/core/service/ObjectStudioSaveService.java @@ -53,6 +53,7 @@ import java.util.concurrent.ConcurrentHashMap; import art.arcane.iris.core.localization.BukkitRuntimeMessages; import art.arcane.iris.core.localization.IrisLanguage; import art.arcane.volmlib.util.localization.MessageArgument; +import art.arcane.volmlib.util.plugin.ComponentMessenger; public class ObjectStudioSaveService implements IrisService { private static ObjectStudioSaveService INSTANCE; @@ -142,11 +143,11 @@ public class ObjectStudioSaveService implements IrisService { Player player = event.getPlayer(); GridCell cell = findCellNear(studio, clicked.getX(), clicked.getZ()); if (cell == null) { - player.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.OBJECT_STUDIO_SAVE_SERVICE_OBJECT_STUDIO_NO_CELL_UNDER_CLICK_X_Z, MessageArgument.untrusted("x", String.valueOf(clicked.getX())), MessageArgument.untrusted("z", String.valueOf(clicked.getZ())))); + send(player, IrisLanguage.text(BukkitRuntimeMessages.OBJECT_STUDIO_SAVE_SERVICE_OBJECT_STUDIO_NO_CELL_UNDER_CLICK_X_Z, MessageArgument.untrusted("x", String.valueOf(clicked.getX())), MessageArgument.untrusted("z", String.valueOf(clicked.getZ())))); return; } - player.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.OBJECT_STUDIO_SAVE_SERVICE_OBJECT_STUDIO_SAVING_X_X, MessageArgument.untrusted("pack", String.valueOf(cell.pack())), MessageArgument.untrusted("key", String.valueOf(cell.key())), MessageArgument.untrusted("w", String.valueOf(cell.w())), MessageArgument.untrusted("h", String.valueOf(cell.h())), MessageArgument.untrusted("d", String.valueOf(cell.d())))); + send(player, IrisLanguage.text(BukkitRuntimeMessages.OBJECT_STUDIO_SAVE_SERVICE_OBJECT_STUDIO_SAVING_X_X, MessageArgument.untrusted("pack", String.valueOf(cell.pack())), MessageArgument.untrusted("key", String.valueOf(cell.key())), MessageArgument.untrusted("w", String.valueOf(cell.w())), MessageArgument.untrusted("h", String.valueOf(cell.h())), MessageArgument.untrusted("d", String.valueOf(cell.d())))); IrisLogging.debug("Object Studio save triggered by %s for %s/%s", player.getName(), cell.pack(), cell.key()); J.runRegion(world, cell.chunkMinX(), cell.chunkMinZ(), () -> { try { @@ -236,7 +237,7 @@ public class ObjectStudioSaveService implements IrisService { Long prior = studio.hashes.get(hashKey); if (prior != null && prior == hash) { if (notify != null) { - notify.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.OBJECT_STUDIO_SAVE_SERVICE_OBJECT_STUDIO_NO_CHANGES, MessageArgument.untrusted("pack", String.valueOf(cell.pack())), MessageArgument.untrusted("key", String.valueOf(cell.key())))); + send(notify, IrisLanguage.text(BukkitRuntimeMessages.OBJECT_STUDIO_SAVE_SERVICE_OBJECT_STUDIO_NO_CHANGES, MessageArgument.untrusted("pack", String.valueOf(cell.pack())), MessageArgument.untrusted("key", String.valueOf(cell.key())))); } return; } @@ -244,7 +245,7 @@ public class ObjectStudioSaveService implements IrisService { if (!anyBlock && prior == null) { studio.hashes.put(hashKey, hash); if (notify != null) { - notify.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.OBJECT_STUDIO_SAVE_SERVICE_OBJECT_STUDIO_EMPTY_CELL_NOTHING_WRITE, MessageArgument.untrusted("pack", String.valueOf(cell.pack())), MessageArgument.untrusted("key", String.valueOf(cell.key())))); + send(notify, IrisLanguage.text(BukkitRuntimeMessages.OBJECT_STUDIO_SAVE_SERVICE_OBJECT_STUDIO_EMPTY_CELL_NOTHING_WRITE, MessageArgument.untrusted("pack", String.valueOf(cell.pack())), MessageArgument.untrusted("key", String.valueOf(cell.key())))); } return; } @@ -254,7 +255,7 @@ public class ObjectStudioSaveService implements IrisService { File targetFile = objectFileFor(studio, cell); if (targetFile == null) { if (notify != null) { - notify.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.OBJECT_STUDIO_SAVE_SERVICE_OBJECT_STUDIO_NO_TARGET_FILE, MessageArgument.untrusted("pack", String.valueOf(cell.pack())), MessageArgument.untrusted("key", String.valueOf(cell.key())))); + send(notify, IrisLanguage.text(BukkitRuntimeMessages.OBJECT_STUDIO_SAVE_SERVICE_OBJECT_STUDIO_NO_TARGET_FILE, MessageArgument.untrusted("pack", String.valueOf(cell.pack())), MessageArgument.untrusted("key", String.valueOf(cell.key())))); } return; } @@ -269,17 +270,21 @@ public class ObjectStudioSaveService implements IrisService { IrisLogging.debug("Object Studio saved: %s/%s (%dx%dx%d)", cell.pack(), cell.key(), cell.w(), cell.h(), cell.d()); if (notify != null) { - J.runEntity(notify, () -> notify.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.OBJECT_STUDIO_SAVE_SERVICE_OBJECT_STUDIO_SAVED, MessageArgument.untrusted("pack", String.valueOf(cell.pack())), MessageArgument.untrusted("key", String.valueOf(cell.key()))))); + J.runEntity(notify, () -> send(notify, IrisLanguage.text(BukkitRuntimeMessages.OBJECT_STUDIO_SAVE_SERVICE_OBJECT_STUDIO_SAVED, MessageArgument.untrusted("pack", String.valueOf(cell.pack())), MessageArgument.untrusted("key", String.valueOf(cell.key()))))); } } catch (Throwable e) { IrisLogging.reportError(e); if (notify != null) { - J.runEntity(notify, () -> notify.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.OBJECT_STUDIO_SAVE_SERVICE_OBJECT_STUDIO_SAVE_FAILED, MessageArgument.untrusted("pack", String.valueOf(cell.pack())), MessageArgument.untrusted("key", String.valueOf(cell.key())), MessageArgument.untrusted("error", String.valueOf(e.getMessage()))))); + J.runEntity(notify, () -> send(notify, IrisLanguage.text(BukkitRuntimeMessages.OBJECT_STUDIO_SAVE_SERVICE_OBJECT_STUDIO_SAVE_FAILED, MessageArgument.untrusted("pack", String.valueOf(cell.pack())), MessageArgument.untrusted("key", String.valueOf(cell.key())), MessageArgument.untrusted("error", String.valueOf(e.getMessage()))))); } } }); } + private static void send(Player player, String message) { + ComponentMessenger.sendSection(player, message); + } + private boolean allChunksLoaded(World world, GridCell cell) { for (int cx = cell.chunkMinX(); cx <= cell.chunkMaxX(); cx++) { for (int cz = cell.chunkMinZ(); cz <= cell.chunkMaxZ(); cz++) { diff --git a/core/src/main/java/art/arcane/iris/engine/EngineHotloader.java b/core/src/main/java/art/arcane/iris/engine/EngineHotloader.java index 71850a6e9..763325b0f 100644 --- a/core/src/main/java/art/arcane/iris/engine/EngineHotloader.java +++ b/core/src/main/java/art/arcane/iris/engine/EngineHotloader.java @@ -139,6 +139,7 @@ final class EngineHotloader { throw new IllegalStateException("Iris background task admission closed before workspace refresh."); } } + engine.getPlatformHooks().applyWorldBoundary(engine); broadcastStudioHotload(false, ""); } catch (Throwable e) { if (!published) { diff --git a/core/src/main/java/art/arcane/iris/engine/IrisComplex.java b/core/src/main/java/art/arcane/iris/engine/IrisComplex.java index 6b2b85a05..3cdc74ab6 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisComplex.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisComplex.java @@ -22,6 +22,7 @@ import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.data.cache.Cache; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.image.IrisImageMapRuntime; import art.arcane.iris.engine.object.InferredType; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisDecorationPart; @@ -70,8 +71,8 @@ import java.util.UUID; import java.util.concurrent.atomic.AtomicLong; @Data -@EqualsAndHashCode(exclude = {"data", "gridBoundsCache", "frozenInterpolators", "frozenGenerators", "riverRuntime"}) -@ToString(exclude = {"data", "gridBoundsCache", "frozenInterpolators", "frozenGenerators", "riverRuntime"}) +@EqualsAndHashCode(exclude = {"data", "gridBoundsCache", "frozenInterpolators", "frozenGenerators", "riverRuntime", "imageMapRuntime"}) +@ToString(exclude = {"data", "gridBoundsCache", "frozenInterpolators", "frozenGenerators", "riverRuntime", "imageMapRuntime"}) public class IrisComplex implements DataProvider { private static final NoiseBounds ZERO_NOISE_BOUNDS = new NoiseBounds(0D, 0D); private static final AtomicLong lastBoundsFailureLog = new AtomicLong(0L); @@ -136,6 +137,7 @@ public class IrisComplex implements DataProvider { private Map> generatorBounds; private Set generatorBiomes; private IrisRiverRuntime riverRuntime; + private transient IrisImageMapRuntime imageMapRuntime; // Copy-on-write: reads happen per column on every burst thread; the synchronizedMap // monitor was taken on every HIT. Writes are once per biome and bounded, so a fresh map // per insert is cheap. Identity keying is load-bearing (IrisBiome is mutable/value-hashed). @@ -152,6 +154,7 @@ public class IrisComplex implements DataProvider { UUID focusUUID = UUID.nameUUIDFromBytes("focus".getBytes()); this.rng = new RNG(engine.getSeedManager().getComplex()); this.data = engine.getData(); + imageMapRuntime = IrisImageMapRuntime.compile(engine); double height = engine.getMaxHeight(); fluidHeight = engine.getDimension().getFluidHeight(); generators = new HashMap<>(); @@ -178,6 +181,11 @@ public class IrisComplex implements DataProvider { prepareInferredBiomes(region); region.getNaturalBiomes(this).forEach(this::registerGenerators); }); + for (IrisRegion region : imageMapRuntime.getMappedRegions()) { + prepareInferredBiomes(region); + region.getNaturalBiomes(this).forEach(this::registerGenerators); + } + imageMapRuntime.getMappedBiomes().forEach(this::registerGenerators); } int interpolatorCount = generators.size(); frozenInterpolators = new IrisInterpolator[interpolatorCount]; @@ -208,12 +216,18 @@ public class IrisComplex implements DataProvider { regionStyleStream = engine.getDimension().getRegionStyle().create(rng.nextParallelRNG(883), getData()).stream() .zoom(engine.getDimension().getRegionZoom()); regionIdentityStream = regionStyleStream.fit(Integer.MIN_VALUE, Integer.MAX_VALUE); - regionStream = focusRegion != null ? + ProceduralStream proceduralRegionStream = focusRegion != null ? ProceduralStream.of((x, z) -> focusRegion, Interpolated.of(a -> 0D, a -> focusRegion)) : regionStyleStream .selectRarity(data.getRegionLoader().loadAll(engine.getDimension().getRegions())) .cache2D("regionStream", engine, cacheSize); + regionStream = focusRegion != null ? proceduralRegionStream : proceduralRegionStream + .convertAware2D((region, x, z) -> { + IrisRegion mapped = imageMapRuntime.sampleRegion(x, z); + return mapped == null ? region : mapped; + }) + .cache2D("imageMappedRegionStream", engine, cacheSize); regionIDStream = regionIdentityStream.convertCached((i) -> new UUID(Double.doubleToLongBits(i), String.valueOf(i * 38445).hashCode() * 3245556666L)); caveBiomeStream = regionStream.contextInjecting(engine, (c, x, z) -> c.getRegion().get(x, z)) @@ -259,22 +273,33 @@ public class IrisComplex implements DataProvider { .bake().scale(1D / engine.getDimension().getContinentZoom()).bake().stream() .convert((v) -> v >= engine.getDimension().getLandChance() ? InferredType.SEA : InferredType.LAND) .cache2D("bridgeStream", engine, cacheSize); - baseBiomeStream = focusBiome != null ? ProceduralStream.of((x, z) -> focusBiome, + ProceduralStream proceduralBaseBiomeStream = focusBiome != null ? ProceduralStream.of((x, z) -> focusBiome, Interpolated.of(a -> 0D, a -> focusBiome)) : bridgeStream.convertAware2D((t, x, z) -> inferredStreams.get(t).get(x, z)) .convertAware2D(this::implode) .cache2D("baseBiomeStream", engine, cacheSize); + baseBiomeStream = focusBiome != null ? proceduralBaseBiomeStream : proceduralBaseBiomeStream + .convertAware2D((biome, x, z) -> { + IrisBiome mapped = imageMapRuntime.sampleBiome(x, z); + return mapped == null ? biome : mapped; + }) + .cache2D("imageMappedBaseBiomeStream", engine, cacheSize); naturalHeightStream = ProceduralStream.of((x, z) -> { IrisBiome b = focusBiome != null ? focusBiome : baseBiomeStream.get(x, z); - return getHeight(engine, b, x, z, engine.getSeedManager().getHeight()); + double proceduralHeight = getHeight(engine, b, x, z, engine.getSeedManager().getHeight()); + return imageMapRuntime.sampleTerrainHeight(x, z, proceduralHeight); }, Interpolated.DOUBLE).cache2DDouble("naturalHeightStream", engine, cacheSize); naturalSlopeStream = naturalHeightStream.slope(3) .cache2DDouble("naturalSlopeStream", engine, cacheSize); naturalTrueBiomeStream = focusBiome != null ? ProceduralStream.of((x, y) -> focusBiome, Interpolated.of(a -> 0D, b -> focusBiome)) .cache2D("naturalTrueBiomeStream-focus", engine, cacheSize) : naturalHeightStream - .convertAware2D((h, x, z) -> - fixBiomeType(h, baseBiomeStream.get(x, z), regionStream.get(x, z), x, z, fluidHeight)) + .convertAware2D((h, x, z) -> { + IrisBiome mapped = imageMapRuntime.sampleBiome(x, z); + return mapped == null + ? fixBiomeType(h, baseBiomeStream.get(x, z), regionStream.get(x, z), x, z, fluidHeight) + : mapped; + }) .cache2D("naturalTrueBiomeStream", engine, cacheSize); if (engine.getDimension().getRivers() != null && engine.getDimension().getRivers().isEnabled()) { ProceduralStream naturalOceanStream = createNaturalOceanStream( @@ -469,6 +494,13 @@ public class IrisComplex implements DataProvider { return null; } + public double sampleProceduralTerrainHeight(Engine engine, double worldX, double worldZ) { + if (engine == null) { + throw new IllegalArgumentException("Engine is required to sample procedural terrain height"); + } + return getHeight(engine, null, worldX, worldZ, engine.getSeedManager().getHeight()); + } + private IrisRegion findRegion(IrisBiome focus, Engine engine) { for (IrisRegion i : engine.getDimension().getAllRegions(engine)) { if (i.getAllBiomeIds().contains(focus.getLoadKey())) { @@ -506,6 +538,10 @@ public class IrisComplex implements DataProvider { } private IrisBiome resolveRiverSurfaceBiome(IrisRiverSurfaceSample sample, double x, double z) { + IrisBiome mapped = imageMapRuntime.sampleBiome(x, z); + if (mapped != null && (riverRuntime == null || !sample.river().present())) { + return mapped; + } if (riverRuntime != null) { if (sample.subterranean()) { return fixBiomeType( @@ -734,9 +770,11 @@ public class IrisComplex implements DataProvider { minimum += Math.min(style.getMin(), style.getMax()); maximum += Math.max(style.getMin(), style.getMax()); } + minimum = imageMapRuntime.sampleTerrainHeight(x, z, minimum); + maximum = imageMapRuntime.sampleTerrainHeight(x, z, maximum); return new NoiseBounds( - Math.max(0D, Math.min(engine.getHeight(), minimum)), - Math.max(0D, Math.min(engine.getHeight(), maximum)) + Math.max(0D, Math.min(engine.getHeight(), Math.min(minimum, maximum))), + Math.max(0D, Math.min(engine.getHeight(), Math.max(minimum, maximum))) ); } diff --git a/core/src/main/java/art/arcane/iris/engine/UpperDimensionContext.java b/core/src/main/java/art/arcane/iris/engine/UpperDimensionContext.java index 0b6297747..c05446024 100644 --- a/core/src/main/java/art/arcane/iris/engine/UpperDimensionContext.java +++ b/core/src/main/java/art/arcane/iris/engine/UpperDimensionContext.java @@ -1,7 +1,9 @@ package art.arcane.iris.engine; +import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.image.IrisImageMapRuntime; import art.arcane.iris.engine.object.InferredType; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisDimension; @@ -35,22 +37,19 @@ public class UpperDimensionContext implements DataProvider { private final ProceduralStream biomeStream; private final ProceduralStream regionStream; private final ProceduralStream rockStream; + private final IrisImageMapRuntime imageMapRuntime; private final boolean selfReferencing; - private UpperDimensionContext(IrisDimension dimension, IrisData data, int chunkHeight, - ProceduralStream heightStream, - ProceduralStream biomeStream, - ProceduralStream regionStream, - ProceduralStream rockStream, - boolean selfReferencing) { - this.dimension = dimension; - this.data = data; - this.chunkHeight = chunkHeight; - this.heightStream = heightStream; - this.biomeStream = biomeStream; - this.regionStream = regionStream; - this.rockStream = rockStream; - this.selfReferencing = selfReferencing; + private UpperDimensionContext(ContextState state) { + this.dimension = state.dimension(); + this.data = state.data(); + this.chunkHeight = state.chunkHeight(); + this.heightStream = state.heightStream(); + this.biomeStream = state.biomeStream(); + this.regionStream = state.regionStream(); + this.rockStream = state.rockStream(); + this.imageMapRuntime = state.imageMapRuntime(); + this.selfReferencing = state.selfReferencing(); } public static UpperDimensionContext create(Engine engine, IrisDimension upperDim) { @@ -64,7 +63,7 @@ public class UpperDimensionContext implements DataProvider { private static UpperDimensionContext createSelfReferencing(Engine engine, int chunkHeight) { IrisComplex complex = engine.getComplex(); - return new UpperDimensionContext( + return new UpperDimensionContext(new ContextState( engine.getDimension(), engine.getData(), chunkHeight, @@ -72,8 +71,9 @@ public class UpperDimensionContext implements DataProvider { complex.getNaturalTrueBiomeStream(), complex.getRegionStream(), complex.getRockStream(), + complex.getImageMapRuntime(), true - ); + )); } private static UpperDimensionContext createCrossReferencing(Engine engine, IrisDimension upperDim, int chunkHeight) { @@ -82,9 +82,15 @@ public class UpperDimensionContext implements DataProvider { resolvedData = engine.getData(); } IrisData upperData = resolvedData; + IrisImageMapRuntime imageMapRuntime = IrisImageMapRuntime.compile( + upperData, + upperDim, + engine.getMinHeight() + ); long seedOffset = upperDim.getLoadKey().hashCode(); RNG rng = new RNG(engine.getSeedManager().getComplex() ^ seedOffset); double fluidHeight = upperDim.getFluidHeight(); + int cacheSize = IrisSettings.get().getPerformance().getNoiseCacheSize(); DataProvider dataProvider = () -> upperData; Map> generators = new HashMap<>(); @@ -94,17 +100,17 @@ public class UpperDimensionContext implements DataProvider { upperDim.getRegions().forEach(regionKey -> { IrisRegion region = upperData.getRegionLoader().load(regionKey); if (region != null) { - region.getNaturalBiomes(dataProvider).forEach(biome -> { - allBiomes.add(biome); - biome.getGenerators().forEach(link -> { - IrisGenerator gen = link.getCachedGenerator(dataProvider); - if (gen != null) { - generators.computeIfAbsent(gen.getInterpolator(), k -> new HashSet<>()).add(gen); - } - }); - }); + region.getNaturalBiomes(dataProvider).forEach(biome -> registerBiomeGenerators( + biome, dataProvider, allBiomes, generators)); } }); + for (IrisRegion mappedRegion : imageMapRuntime.getMappedRegions()) { + mappedRegion.getNaturalBiomes(dataProvider).forEach(biome -> registerBiomeGenerators( + biome, dataProvider, allBiomes, generators)); + } + for (IrisBiome mappedBiome : imageMapRuntime.getMappedBiomes()) { + registerBiomeGenerators(mappedBiome, dataProvider, allBiomes, generators); + } Map> generatorBounds = new HashMap<>(); for (Map.Entry> entry : generators.entrySet()) { @@ -128,8 +134,11 @@ public class UpperDimensionContext implements DataProvider { ProceduralStream regionStyleStream = upperDim.getRegionStyle() .create(rng.nextParallelRNG(883), upperData).stream() .zoom(upperDim.getRegionZoom()); - ProceduralStream regionStream = regionStyleStream + ProceduralStream proceduralRegionStream = regionStyleStream .selectRarity(upperData.getRegionLoader().loadAll(upperDim.getRegions())); + ProceduralStream regionStream = proceduralRegionStream + .convertAware2D((region, x, z) -> mappedRegion(imageMapRuntime, region, x, z)) + .cache2D("upperImageMappedRegionStream", engine, cacheSize); ProceduralStream landBiomeStream = regionStream .convert(r -> upperDim.getLandBiomeStyle() @@ -165,13 +174,16 @@ public class UpperDimensionContext implements DataProvider { .bake().scale(1D / upperDim.getContinentZoom()).bake().stream() .convert(v -> v >= upperDim.getLandChance() ? InferredType.SEA : InferredType.LAND); - ProceduralStream baseBiomeStream = bridgeStream + ProceduralStream proceduralBaseBiomeStream = bridgeStream .convertAware2D((t, x, z) -> { ProceduralStream stream = inferredStreams.get(t); return stream != null ? stream.get(x, z) : inferredStreams.get(InferredType.LAND).get(x, z); }) .convertAware2D((biome, x, z) -> implode( biome, x, z, rng, dataProvider, childSelectionPlans, 3)); + ProceduralStream baseBiomeStream = proceduralBaseBiomeStream + .convertAware2D((biome, x, z) -> mappedBiome(imageMapRuntime, biome, x, z)) + .cache2D("upperImageMappedBaseBiomeStream", engine, cacheSize); KList overlayNoise = upperDim.getOverlayNoise(); ProceduralStream overlayStream = overlayNoise.isEmpty() @@ -189,7 +201,7 @@ public class UpperDimensionContext implements DataProvider { ProceduralStream heightStream = ProceduralStream.of((x, z) -> { IrisBiome b = baseBiomeStream.get(x, z); if (b == null) { - return fluidHeight; + return mappedTerrainHeight(imageMapRuntime, fluidHeight, x, z); } double interpolatedHeight = 0; for (Map.Entry> entry : generators.entrySet()) { @@ -233,10 +245,18 @@ public class UpperDimensionContext implements DataProvider { } interpolatedHeight += d / gens.size(); } - return Math.max(Math.min(interpolatedHeight + fluidHeight + overlayStream.get(x, z), chunkHeight), 0); - }, Interpolated.DOUBLE); + double proceduralHeight = Math.max( + Math.min(interpolatedHeight + fluidHeight + overlayStream.get(x, z), chunkHeight), + 0D + ); + return mappedTerrainHeight(imageMapRuntime, proceduralHeight, x, z); + }, Interpolated.DOUBLE).cache2DDouble("upperImageMappedHeightStream", engine, cacheSize); ProceduralStream finalBiomeStream = heightStream.convertAware2D((height, x, z) -> { + IrisBiome mappedBiome = imageMapRuntime.sampleBiome(x, z); + if (mappedBiome != null) { + return mappedBiome; + } IrisBiome baseBiome = baseBiomeStream.get(x, z); IrisBiome resolved = IrisComplex.resolveSurfaceBiome( height, @@ -251,13 +271,13 @@ public class UpperDimensionContext implements DataProvider { return resolved == baseBiome ? baseBiome : implode(resolved, x, z, rng, dataProvider, childSelectionPlans, 3); - }); + }).cache2D("upperImageMappedFinalBiomeStream", engine, cacheSize); ProceduralStream rockStream = upperDim.getRockPalette() .getLayerGenerator(rng.nextParallelRNG(45), upperData).stream() .select(upperDim.getRockPalette().getBlockData(upperData)); - return new UpperDimensionContext( + return new UpperDimensionContext(new ContextState( upperDim, upperData, chunkHeight, @@ -265,8 +285,63 @@ public class UpperDimensionContext implements DataProvider { finalBiomeStream, regionStream, rockStream, + imageMapRuntime, false - ); + )); + } + + private static void registerBiomeGenerators( + IrisBiome biome, + DataProvider dataProvider, + Set allBiomes, + Map> generators + ) { + allBiomes.add(biome); + biome.getGenerators().forEach(link -> { + IrisGenerator generator = link.getCachedGenerator(dataProvider); + if (generator != null) { + generators.computeIfAbsent(generator.getInterpolator(), key -> new HashSet<>()).add(generator); + } + }); + } + + static IrisRegion mappedRegion( + IrisImageMapRuntime imageMapRuntime, + IrisRegion proceduralRegion, + double worldX, + double worldZ + ) { + IrisRegion mappedRegion = imageMapRuntime.sampleRegion(worldX, worldZ); + return mappedRegion == null ? proceduralRegion : mappedRegion; + } + + static double mappedTerrainHeight( + IrisImageMapRuntime imageMapRuntime, + double proceduralHeight, + double worldX, + double worldZ + ) { + return imageMapRuntime.sampleTerrainHeight(worldX, worldZ, proceduralHeight); + } + + static IrisBiome mappedBiome( + IrisImageMapRuntime imageMapRuntime, + IrisBiome proceduralBiome, + double worldX, + double worldZ + ) { + IrisBiome mappedBiome = imageMapRuntime.sampleBiome(worldX, worldZ); + return mappedBiome == null ? proceduralBiome : mappedBiome; + } + + static PlatformBlockState mappedSurfaceBlock( + IrisImageMapRuntime imageMapRuntime, + PlatformBlockState proceduralBlock, + double worldX, + double worldZ + ) { + PlatformBlockState mappedBlock = imageMapRuntime.sampleSurfaceBlock(worldX, worldZ); + return mappedBlock == null ? proceduralBlock : mappedBlock; } private static KList loadInferredBiomes(IrisData data, KList keys, InferredType type) { @@ -340,6 +415,10 @@ public class UpperDimensionContext implements DataProvider { return rockStream.get((double) x, (double) z); } + public PlatformBlockState getSurfaceBlock(int x, int z) { + return imageMapRuntime.sampleSurfaceBlock(x, z); + } + public IrisDimension getDimension() { return dimension; } @@ -352,4 +431,17 @@ public class UpperDimensionContext implements DataProvider { public boolean isSelfReferencing() { return selfReferencing; } + + private record ContextState( + IrisDimension dimension, + IrisData data, + int chunkHeight, + ProceduralStream heightStream, + ProceduralStream biomeStream, + ProceduralStream regionStream, + ProceduralStream rockStream, + IrisImageMapRuntime imageMapRuntime, + boolean selfReferencing + ) { + } } diff --git a/core/src/main/java/art/arcane/iris/engine/actuator/IrisTerrainNormalActuator.java b/core/src/main/java/art/arcane/iris/engine/actuator/IrisTerrainNormalActuator.java index 9652399a0..658f768a4 100644 --- a/core/src/main/java/art/arcane/iris/engine/actuator/IrisTerrainNormalActuator.java +++ b/core/src/main/java/art/arcane/iris/engine/actuator/IrisTerrainNormalActuator.java @@ -120,6 +120,7 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator biomeSurfaceOres = hideOres ? null : biome.getSurfaceOreGenerators(); KList regionSurfaceOres = hideOres ? null : region.getSurfaceOreGenerators(); KList biomeUndergroundOres = hideOres ? null : biome.getUndergroundOreGenerators(); @@ -173,6 +174,10 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator upperBlocks = upperBiome != null ? upperBiome.generateLayers(upperContext.getDimension(), @@ -222,6 +228,10 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator= 0D + && sourceZ >= 0D + && sourceX < state.sourceMetadata().width() + && sourceZ < state.sourceMetadata().height(); + } + + public boolean containsWorldForSampling(double worldX, double worldZ) { + if (!Double.isFinite(worldX) || !Double.isFinite(worldZ)) { + return false; + } + double sourceX = state.sourceTransform().sourceX(worldX, worldZ); + double sourceZ = state.sourceTransform().sourceZ(worldX, worldZ); + if (!validSourceCoordinate(sourceX, sourceZ)) { + return false; + } + long baseX = floorToLong(sourceX); + long baseZ = floorToLong(sourceZ); + return switch (state.sampling()) { + case NEAREST -> sourceIndexInBounds(baseX, baseZ); + case BILINEAR -> bilinearKernelInBounds(sourceX, sourceZ, baseX, baseZ); + case BICUBIC -> bicubicKernelInBounds(sourceX, sourceZ, baseX, baseZ); + }; + } + + public double sampleNormalized(double worldX, double worldZ) { + requireScalarType(); + requireWorldCoordinate(worldX, worldZ); + double sourceX = state.sourceTransform().sourceX(worldX, worldZ); + double sourceZ = state.sourceTransform().sourceZ(worldX, worldZ); + requireSourceCoordinate(sourceX, sourceZ); + double sampled = switch (state.sampling()) { + case NEAREST -> scalarAt(floorToLong(sourceX), floorToLong(sourceZ)); + case BILINEAR -> bilinear(sourceX, sourceZ); + case BICUBIC -> bicubic(sourceX, sourceZ); + }; + return clamp01(sampled); + } + + public double sampleHeight(double worldX, double worldZ) { + if (!isHeightType()) { + throw new IrisImageMapValidationException("Image map type " + state.type() + " does not produce terrain heights"); + } + double normalized = sampleNormalized(worldX, worldZ); + double height = state.minimumHeight() + + (normalized * (state.maximumHeight() - state.minimumHeight())) + + state.verticalOffset(); + if (!state.clampHeight()) { + return height; + } + return Math.max(state.minimumHeight(), Math.min(state.maximumHeight(), height)); + } + + public String sampleTarget(double worldX, double worldZ) { + if (state.type() != IrisImageMapType.COLOR_MAP) { + throw new IrisImageMapValidationException("Image map type " + state.type() + " does not produce legend targets"); + } + if (state.targetIndices() == null) { + throw new IrisImageMapValidationException("Compiled image-map view does not retain decoded target data"); + } + requireWorldCoordinate(worldX, worldZ); + double transformedX = state.sourceTransform().sourceX(worldX, worldZ); + double transformedZ = state.sourceTransform().sourceZ(worldX, worldZ); + requireSourceCoordinate(transformedX, transformedZ); + long sourceX = floorToLong(transformedX); + long sourceZ = floorToLong(transformedZ); + int resolvedX = resolveIndex(sourceX, state.sourceMetadata().width()); + int resolvedZ = resolveIndex(sourceZ, state.sourceMetadata().height()); + if (resolvedX < 0 || resolvedZ < 0) { + return state.fallbackTarget(); + } + int targetIndex = state.targetIndices().get((resolvedZ * state.sourceMetadata().width()) + resolvedX); + return targetIndex < 0 ? null : state.targets()[targetIndex]; + } + + private void requireWorldCoordinate(double worldX, double worldZ) { + if (!Double.isFinite(worldX) || !Double.isFinite(worldZ)) { + throw new IrisImageMapValidationException("World coordinates must be finite, got " + worldX + "," + worldZ); + } + } + + private void requireSourceCoordinate(double sourceX, double sourceZ) { + if (!validSourceCoordinate(sourceX, sourceZ)) { + throw new IrisImageMapValidationException( + "World coordinates transform outside the supported source-coordinate range: " + + sourceX + "," + sourceZ + ); + } + } + + private boolean bilinearKernelInBounds(double sourceX, double sourceZ, long baseX, long baseZ) { + long maximumX = sourceX == baseX ? baseX : baseX + 1L; + long maximumZ = sourceZ == baseZ ? baseZ : baseZ + 1L; + return baseX >= 0L + && baseZ >= 0L + && maximumX < state.sourceMetadata().width() + && maximumZ < state.sourceMetadata().height(); + } + + private boolean bicubicKernelInBounds(double sourceX, double sourceZ, long baseX, long baseZ) { + if (sourceX == baseX && sourceZ == baseZ) { + return sourceIndexInBounds(baseX, baseZ); + } + return baseX >= 1L + && baseZ >= 1L + && baseX + 2L < state.sourceMetadata().width() + && baseZ + 2L < state.sourceMetadata().height(); + } + + private boolean sourceIndexInBounds(long sourceX, long sourceZ) { + return sourceX >= 0L + && sourceZ >= 0L + && sourceX < state.sourceMetadata().width() + && sourceZ < state.sourceMetadata().height(); + } + + private double bilinear(double sourceX, double sourceZ) { + long x0 = floorToLong(sourceX); + long z0 = floorToLong(sourceZ); + double fractionX = sourceX - x0; + double fractionZ = sourceZ - z0; + if (fractionX == 0D && fractionZ == 0D) { + return scalarAt(x0, z0); + } + if (fractionZ == 0D) { + return lerp(scalarAt(x0, z0), scalarAt(x0 + 1L, z0), fractionX); + } + if (fractionX == 0D) { + return lerp(scalarAt(x0, z0), scalarAt(x0, z0 + 1L), fractionZ); + } + double top = lerp(scalarAt(x0, z0), scalarAt(x0 + 1L, z0), fractionX); + double bottom = lerp(scalarAt(x0, z0 + 1L), scalarAt(x0 + 1L, z0 + 1L), fractionX); + return lerp(top, bottom, fractionZ); + } + + private double bicubic(double sourceX, double sourceZ) { + long baseX = floorToLong(sourceX); + long baseZ = floorToLong(sourceZ); + double fractionX = sourceX - baseX; + double fractionZ = sourceZ - baseZ; + if (fractionX == 0D && fractionZ == 0D) { + return scalarAt(baseX, baseZ); + } + double row0 = cubicRow(baseX, baseZ - 1L, fractionX); + double row1 = cubicRow(baseX, baseZ, fractionX); + double row2 = cubicRow(baseX, baseZ + 1L, fractionX); + double row3 = cubicRow(baseX, baseZ + 2L, fractionX); + return cubic(row0, row1, row2, row3, fractionZ); + } + + private double cubicRow(long baseX, long sourceZ, double fractionX) { + return cubic( + scalarAt(baseX - 1L, sourceZ), + scalarAt(baseX, sourceZ), + scalarAt(baseX + 1L, sourceZ), + scalarAt(baseX + 2L, sourceZ), + fractionX + ); + } + + private double scalarAt(long sourceX, long sourceZ) { + int resolvedX = resolveIndex(sourceX, state.sourceMetadata().width()); + int resolvedZ = resolveIndex(sourceZ, state.sourceMetadata().height()); + if (resolvedX < 0 || resolvedZ < 0) { + return state.fallbackValue(); + } + return state.scalarValues().get((resolvedZ * state.sourceMetadata().width()) + resolvedX); + } + + private int resolveIndex(long coordinate, int length) { + if (coordinate >= 0L && coordinate < length) { + return (int) coordinate; + } + IrisImageMapOutOfBounds outOfBounds = state.outOfBounds(); + return switch (outOfBounds) { + case FALLBACK -> -1; + case CLAMP -> coordinate < 0L ? 0 : length - 1; + case REPEAT -> (int) Math.floorMod(coordinate, (long) length); + case MIRROR -> mirrorIndex(coordinate, length); + case ERROR -> throw new IrisImageMapValidationException( + "Source coordinate " + coordinate + " is outside 0.." + (length - 1) + ); + }; + } + + private int mirrorIndex(long coordinate, int length) { + if (length == 1) { + return 0; + } + long period = (long) length * 2L; + long mirrored = Math.floorMod(coordinate, period); + if (mirrored >= length) { + mirrored = period - 1L - mirrored; + } + return (int) mirrored; + } + + private boolean isHeightType() { + return state.type() == IrisImageMapType.GRAYSCALE_HEIGHT + || state.type() == IrisImageMapType.RGB_HEIGHT; + } + + private void requireScalarType() { + if (state.scalarValues() == null) { + if (isHeightType() + || state.type() == IrisImageMapType.BINARY_MASK + || state.type() == IrisImageMapType.GRAYSCALE_MASK + || state.type() == IrisImageMapType.ALPHA_MASK) { + throw new IrisImageMapValidationException("Compiled image-map view does not retain decoded scalar data"); + } + throw new IrisImageMapValidationException("Image map type " + state.type() + " does not produce normalized scalar data"); + } + } + + private static long floorToLong(double value) { + return (long) Math.floor(value); + } + + private static boolean validSourceCoordinate(double sourceX, double sourceZ) { + double safeMinimum = -9_000_000_000_000_000_000D; + double safeMaximum = 9_000_000_000_000_000_000D; + return Double.isFinite(sourceX) + && Double.isFinite(sourceZ) + && sourceX >= safeMinimum + && sourceX <= safeMaximum + && sourceZ >= safeMinimum + && sourceZ <= safeMaximum; + } + + private static double lerp(double a, double b, double factor) { + return a + ((b - a) * factor); + } + + private static double cubic(double p0, double p1, double p2, double p3, double factor) { + double squared = factor * factor; + double cubed = squared * factor; + return 0.5D * ((2D * p1) + + ((-p0 + p2) * factor) + + (((2D * p0) - (5D * p1) + (4D * p2) - p3) * squared) + + ((-p0 + (3D * p1) - (3D * p2) + p3) * cubed)); + } + + private static double clamp01(double value) { + return Math.max(0D, Math.min(1D, value)); + } + + static record State( + IrisImageMap definition, + IrisImageMapSourceMetadata sourceMetadata, + IrisImageMapType type, + FloatTiles scalarValues, + IntTiles targetIndices, + String[] targets, + SourceTransform sourceTransform, + IrisImageMapSampling sampling, + IrisImageMapOutOfBounds outOfBounds, + double fallbackValue, + String fallbackTarget, + double minimumHeight, + double maximumHeight, + double verticalOffset, + boolean clampHeight, + int clippedPixelCount, + int unknownColorPixelCount, + String contentHash + ) { + State withoutDecodedValues() { + return new State( + definition, + sourceMetadata, + type, + null, + null, + null, + sourceTransform, + sampling, + outOfBounds, + fallbackValue, + fallbackTarget, + minimumHeight, + maximumHeight, + verticalOffset, + clampHeight, + clippedPixelCount, + unknownColorPixelCount, + contentHash + ); + } + } + + static record SourceTransform( + double sourceXFromWorldX, + double sourceXFromWorldZ, + double sourceXOffset, + double sourceZFromWorldX, + double sourceZFromWorldZ, + double sourceZOffset + ) { + double sourceX(double worldX, double worldZ) { + return (sourceXFromWorldX * worldX) + (sourceXFromWorldZ * worldZ) + sourceXOffset; + } + + double sourceZ(double worldX, double worldZ) { + return (sourceZFromWorldX * worldX) + (sourceZFromWorldZ * worldZ) + sourceZOffset; + } + } + + static final class FloatTiles { + private static final int TILE_SHIFT = 20; + private static final int TILE_SIZE = 1 << TILE_SHIFT; + private static final int TILE_MASK = TILE_SIZE - 1; + private final float[][] tiles; + + FloatTiles(int size) { + int tileCount = (size + TILE_SIZE - 1) >>> TILE_SHIFT; + tiles = new float[tileCount][]; + for (int tile = 0; tile < tileCount; tile++) { + int remaining = size - (tile << TILE_SHIFT); + tiles[tile] = new float[Math.min(TILE_SIZE, remaining)]; + } + } + + float get(int index) { + return tiles[index >>> TILE_SHIFT][index & TILE_MASK]; + } + + void set(int index, float value) { + tiles[index >>> TILE_SHIFT][index & TILE_MASK] = value; + } + } + + static final class IntTiles { + private static final int TILE_SHIFT = 20; + private static final int TILE_SIZE = 1 << TILE_SHIFT; + private static final int TILE_MASK = TILE_SIZE - 1; + private final int[][] tiles; + + IntTiles(int size) { + int tileCount = (size + TILE_SIZE - 1) >>> TILE_SHIFT; + tiles = new int[tileCount][]; + for (int tile = 0; tile < tileCount; tile++) { + int remaining = size - (tile << TILE_SHIFT); + tiles[tile] = new int[Math.min(TILE_SIZE, remaining)]; + } + } + + int get(int index) { + return tiles[index >>> TILE_SHIFT][index & TILE_MASK]; + } + + void set(int index, int value) { + tiles[index >>> TILE_SHIFT][index & TILE_MASK] = value; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/image/IrisImageMapCompiler.java b/core/src/main/java/art/arcane/iris/engine/image/IrisImageMapCompiler.java new file mode 100644 index 000000000..a6ae02b07 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/image/IrisImageMapCompiler.java @@ -0,0 +1,825 @@ +package art.arcane.iris.engine.image; + +import art.arcane.iris.engine.object.IrisImage; +import art.arcane.iris.engine.object.IrisImageColorMode; +import art.arcane.iris.engine.object.IrisImageMap; +import art.arcane.iris.engine.object.IrisImageMapAlpha; +import art.arcane.iris.engine.object.IrisImageMapOrigin; +import art.arcane.iris.engine.object.IrisImageMapOutOfBounds; +import art.arcane.iris.engine.object.IrisImageMapSampling; +import art.arcane.iris.engine.object.IrisImageMapType; +import art.arcane.iris.engine.object.IrisImageMapUnknownColor; +import art.arcane.volmlib.util.collection.KMap; + +import java.nio.charset.StandardCharsets; +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.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Pattern; + +public final class IrisImageMapCompiler { + public static final int MINIMUM_DIMENSION = 1; + public static final int MAXIMUM_DIMENSION = 16_384; + public static final long MAXIMUM_PIXELS = 16_777_216L; + + private static final Pattern COLOR_PATTERN = Pattern.compile("#[0-9a-fA-F]{6}"); + + private IrisImageMapCompiler() { + } + + public static CompiledIrisImageMap compile(IrisImageMap definition, IrisImage image) { + if (definition == null) { + throw new IrisImageMapValidationException("Image-map definition is required"); + } + if (image == null) { + throw new IrisImageMapValidationException("Decoded image source is required"); + } + + List diagnostics = validate(definition, image); + if (!diagnostics.isEmpty()) { + throw new IrisImageMapValidationException(diagnostics); + } + + try { + SourceDigest sourceDigest = hashSource(image); + IrisImageMapSourceMetadata metadata = new IrisImageMapSourceMetadata( + image.getFormat(), + image.getColorMode(), + image.getWidth(), + image.getHeight(), + image.getColorComponentCount(), + image.getChannelCount(), + image.getBitDepth(), + image.hasAlpha(), + sourceDigest.minimumAlpha(), + sourceDigest.maximumAlpha(), + sourceDigest.hash() + ); + IrisImageMap snapshot = snapshot(definition); + CompiledIrisImageMap.FloatTiles scalarValues = null; + CompiledIrisImageMap.IntTiles targetIndices = null; + String[] targets = null; + int clippedPixelCount = 0; + int unknownColorPixelCount = 0; + + if (definition.getType() == IrisImageMapType.COLOR_MAP) { + CompiledTargets compiledTargets = compileTargets(definition, image); + targetIndices = compiledTargets.indices(); + targets = compiledTargets.targets(); + unknownColorPixelCount = compiledTargets.unknownColorPixelCount(); + } else { + scalarValues = compileScalars(definition, image); + if (definition.getSmoothingRadius() > 0) { + smooth(scalarValues, image.getWidth(), image.getHeight(), definition.getSmoothingRadius()); + } + clippedPixelCount = countClippedPixels( + definition, scalarValues, Math.multiplyExact(image.getWidth(), image.getHeight()) + ); + } + + String contentHash = hashContent(snapshot, metadata); + String fallbackTarget = definition.getFallbackTarget() == null || definition.getFallbackTarget().isBlank() + ? null + : definition.getFallbackTarget(); + CompiledIrisImageMap.SourceTransform sourceTransform = compileSourceTransform(definition); + CompiledIrisImageMap.State state = new CompiledIrisImageMap.State( + snapshot, + metadata, + definition.getType(), + scalarValues, + targetIndices, + targets, + sourceTransform, + definition.getSampling(), + definition.getOutOfBounds(), + definition.getFallbackValue(), + fallbackTarget, + definition.getMinimumHeight(), + definition.getMaximumHeight(), + definition.getVerticalOffset(), + definition.isClamp(), + clippedPixelCount, + unknownColorPixelCount, + contentHash + ); + return new CompiledIrisImageMap(state); + } catch (IrisImageMapValidationException exception) { + throw exception; + } catch (RuntimeException exception) { + throw new IrisImageMapValidationException( + "Failed to compile image map '" + definition.getSource() + "': " + exception.getMessage(), + exception + ); + } + } + + private static List validate(IrisImageMap definition, IrisImage image) { + List diagnostics = new ArrayList<>(); + if (definition.getSource() == null || definition.getSource().isBlank()) { + diagnostics.add("Image-map source must not be blank"); + } + validateSource(image, diagnostics); + validateCoordinates(definition, diagnostics); + validateScalarSettings(definition, diagnostics); + validateType(definition, image, diagnostics); + validateLegend(definition, diagnostics); + return diagnostics; + } + + private static void validateSource(IrisImage image, List diagnostics) { + if (!"png".equals(image.getFormat())) { + diagnostics.add("Image format must be PNG, got " + image.getFormat()); + } + int width = image.getWidth(); + int height = image.getHeight(); + if (width < MINIMUM_DIMENSION || width > MAXIMUM_DIMENSION) { + diagnostics.add("Image width must be " + MINIMUM_DIMENSION + ".." + MAXIMUM_DIMENSION + ", got " + width); + } + if (height < MINIMUM_DIMENSION || height > MAXIMUM_DIMENSION) { + diagnostics.add("Image height must be " + MINIMUM_DIMENSION + ".." + MAXIMUM_DIMENSION + ", got " + height); + } + long pixels = (long) width * height; + if (pixels > MAXIMUM_PIXELS) { + diagnostics.add("Image contains " + pixels + " pixels; maximum is " + MAXIMUM_PIXELS); + } + if (image.getColorMode() == IrisImageColorMode.INDEXED) { + diagnostics.add("Indexed PNG images are unsupported; convert the source to grayscale, RGB, or RGBA"); + } else if (image.getColorMode() == IrisImageColorMode.UNSUPPORTED) { + diagnostics.add("Unsupported PNG color mode with " + image.getColorComponentCount() + + " color components and " + image.getChannelCount() + " channels"); + } + } + + private static void validateCoordinates(IrisImageMap definition, List diagnostics) { + if (!finiteAtLeast(definition.getBlocksPerPixel(), IrisImageMap.MINIMUM_SCALE)) { + diagnostics.add("blocksPerPixel must be finite and at least " + IrisImageMap.MINIMUM_SCALE); + } + IrisImageMapOrigin origin = definition.getOrigin(); + if (origin == null || !Double.isFinite(origin.getX()) || !Double.isFinite(origin.getZ())) { + diagnostics.add("origin X and Z must be finite"); + } + IrisImageMapOrigin sourceOrigin = definition.getSourceOrigin(); + if (sourceOrigin == null || !Double.isFinite(sourceOrigin.getX()) || !Double.isFinite(sourceOrigin.getZ())) { + diagnostics.add("sourceOrigin X and Z must be finite"); + } + if (definition.getRotation() == null) { + diagnostics.add("rotation is required"); + } + if (definition.getSampling() == null) { + diagnostics.add("sampling is required"); + } + if (definition.getOutOfBounds() == null) { + diagnostics.add("outOfBounds is required"); + } + } + + private static void validateScalarSettings(IrisImageMap definition, List diagnostics) { + if (!range(definition.getFallbackValue(), 0D, 1D)) { + diagnostics.add("fallbackValue must be finite and within 0..1"); + } + if (!Double.isFinite(definition.getMinimumHeight()) || !Double.isFinite(definition.getMaximumHeight())) { + diagnostics.add("minimumHeight and maximumHeight must be finite"); + } else if (definition.getMaximumHeight() < definition.getMinimumHeight()) { + diagnostics.add("maximumHeight must be greater than or equal to minimumHeight"); + } + if (!Double.isFinite(definition.getVerticalOffset())) { + diagnostics.add("verticalOffset must be finite"); + } + if (!finiteAtLeast(definition.getCurveExponent(), IrisImageMap.MINIMUM_SCALE)) { + diagnostics.add("curveExponent must be finite and at least " + IrisImageMap.MINIMUM_SCALE); + } + if (definition.getSmoothingRadius() < 0 || definition.getSmoothingRadius() > 32) { + diagnostics.add("smoothingRadius must be within 0..32"); + } + if (!range(definition.getThreshold(), 0D, 1D)) { + diagnostics.add("threshold must be finite and within 0..1"); + } + if (!range(definition.getFalloff(), 0D, 1D)) { + diagnostics.add("falloff must be finite and within 0..1"); + } + if (!range(definition.getColorTolerance(), 0D, IrisImageMap.MAXIMUM_COLOR_TOLERANCE)) { + diagnostics.add("colorTolerance must be finite and within 0.." + + IrisImageMap.MAXIMUM_COLOR_TOLERANCE); + } + if (definition.getAlpha() == null) { + diagnostics.add("alpha policy is required"); + } + if (definition.getUnknownColor() == null) { + diagnostics.add("unknownColor policy is required"); + } + } + + private static void validateType(IrisImageMap definition, IrisImage image, List diagnostics) { + IrisImageMapType type = definition.getType(); + if (type == null) { + diagnostics.add("Image-map type is required"); + return; + } + + IrisImageColorMode colorMode = image.getColorMode(); + int bitDepth = image.getBitDepth(); + switch (type) { + case GRAYSCALE_HEIGHT, BINARY_MASK, GRAYSCALE_MASK -> { + if (colorMode != IrisImageColorMode.GRAYSCALE) { + diagnostics.add(type + " requires a grayscale PNG, got " + colorMode); + } + if (bitDepth != 8 && bitDepth != 16) { + diagnostics.add(type + " requires 8-bit or 16-bit grayscale samples, got " + bitDepth + "-bit"); + } + } + case RGB_HEIGHT, COLOR_MAP -> { + if (colorMode != IrisImageColorMode.RGB && colorMode != IrisImageColorMode.RGBA) { + diagnostics.add(type + " requires an RGB or RGBA PNG, got " + colorMode); + } + if (bitDepth != 8) { + diagnostics.add(type + " requires canonical 8-bit RGB channels, got " + bitDepth + "-bit"); + } + } + case ALPHA_MASK -> { + if (colorMode != IrisImageColorMode.RGBA || !image.hasAlpha()) { + diagnostics.add("ALPHA_MASK requires an 8-bit RGBA PNG"); + } + if (bitDepth != 8) { + diagnostics.add("ALPHA_MASK requires an 8-bit alpha channel, got " + bitDepth + "-bit color channels"); + } + if (definition.getAlpha() != null && definition.getAlpha() != IrisImageMapAlpha.IGNORE) { + diagnostics.add("ALPHA_MASK requires alpha=IGNORE because alpha is the map data"); + } + } + } + + if ((type == IrisImageMapType.COLOR_MAP || type == IrisImageMapType.BINARY_MASK) + && definition.getSampling() != null + && definition.getSampling() != IrisImageMapSampling.NEAREST) { + diagnostics.add(type + " requires NEAREST sampling"); + } + if (type == IrisImageMapType.COLOR_MAP && definition.getSmoothingRadius() != 0) { + diagnostics.add("COLOR_MAP does not support numeric smoothing"); + } + } + + private static void validateLegend(IrisImageMap definition, List diagnostics) { + KMap colors = definition.getColors(); + if (definition.getType() != IrisImageMapType.COLOR_MAP) { + if (colors != null && !colors.isEmpty()) { + diagnostics.add("colors may only be set for COLOR_MAP definitions"); + } + if (definition.getColorTolerance() != 0D) { + diagnostics.add("colorTolerance may only be set for COLOR_MAP definitions"); + } + return; + } + if (colors == null || colors.isEmpty()) { + diagnostics.add("COLOR_MAP requires at least one #RRGGBB legend entry"); + return; + } + + Map decoded = new HashMap<>(); + for (Map.Entry entry : colors.entrySet()) { + String encoded = entry.getKey(); + String target = entry.getValue(); + if (encoded == null || !COLOR_PATTERN.matcher(encoded).matches()) { + diagnostics.add("Color legend key must use exact #RRGGBB syntax, got " + encoded); + continue; + } + if (target == null || target.isBlank()) { + diagnostics.add("Color legend target for " + encoded + " must not be blank"); + } + int rgb = Integer.parseInt(encoded.substring(1), 16); + String previous = decoded.putIfAbsent(rgb, encoded); + if (previous != null) { + diagnostics.add("Color legend duplicates raw color " + previous + " as " + encoded); + } + } + + boolean fallbackRequired = definition.getOutOfBounds() == IrisImageMapOutOfBounds.FALLBACK + || definition.getUnknownColor() == IrisImageMapUnknownColor.FALLBACK + || definition.getAlpha() == IrisImageMapAlpha.TRANSPARENT_IS_FALLBACK + || definition.getAlpha() == IrisImageMapAlpha.MASK; + if (fallbackRequired && (definition.getFallbackTarget() == null || definition.getFallbackTarget().isBlank())) { + diagnostics.add("fallbackTarget is required by the configured color-map fallback policy"); + } + } + + private static CompiledIrisImageMap.FloatTiles compileScalars(IrisImageMap definition, IrisImage image) { + int width = image.getWidth(); + int height = image.getHeight(); + CompiledIrisImageMap.FloatTiles values = new CompiledIrisImageMap.FloatTiles( + Math.multiplyExact(width, height) + ); + for (int sourceZ = 0; sourceZ < height; sourceZ++) { + for (int sourceX = 0; sourceX < width; sourceX++) { + double alpha = image.getAlphaNormalized(sourceX, sourceZ); + validateAlphaPixel(definition, sourceX, sourceZ, alpha); + double value; + if (definition.getAlpha() == IrisImageMapAlpha.TRANSPARENT_IS_FALLBACK && alpha == 0D) { + value = definition.getFallbackValue(); + } else { + value = rawScalar(definition.getType(), image, sourceX, sourceZ); + if (definition.isInverted()) { + value = 1D - value; + } + value = Math.pow(clamp01(value), definition.getCurveExponent()); + if (definition.getType() == IrisImageMapType.BINARY_MASK) { + value = threshold(value, definition.getThreshold(), definition.getFalloff()); + } + if (definition.getAlpha() == IrisImageMapAlpha.MASK) { + value *= alpha; + } + } + values.set((sourceZ * width) + sourceX, (float) clamp01(value)); + } + } + return values; + } + + private static double rawScalar(IrisImageMapType type, IrisImage image, int sourceX, int sourceZ) { + return switch (type) { + case GRAYSCALE_HEIGHT, BINARY_MASK, GRAYSCALE_MASK -> image.getBandNormalized(sourceX, sourceZ, 0); + case RGB_HEIGHT -> { + int red = image.getBandSample(sourceX, sourceZ, 0); + int green = image.getBandSample(sourceX, sourceZ, 1); + int blue = image.getBandSample(sourceX, sourceZ, 2); + int encoded = (red << 16) | (green << 8) | blue; + yield encoded / 16_777_215D; + } + case ALPHA_MASK -> image.getAlphaNormalized(sourceX, sourceZ); + case COLOR_MAP -> throw new IrisImageMapValidationException("COLOR_MAP does not produce scalar pixels"); + }; + } + + private static CompiledTargets compileTargets(IrisImageMap definition, IrisImage image) { + Legend legend = compileLegend(definition); + int width = image.getWidth(); + int height = image.getHeight(); + CompiledIrisImageMap.IntTiles indices = new CompiledIrisImageMap.IntTiles( + Math.multiplyExact(width, height) + ); + int unknownColorPixelCount = 0; + for (int sourceZ = 0; sourceZ < height; sourceZ++) { + for (int sourceX = 0; sourceX < width; sourceX++) { + double alpha = image.getAlphaNormalized(sourceX, sourceZ); + validateAlphaPixel(definition, sourceX, sourceZ, alpha); + int targetIndex; + if (definition.getAlpha() == IrisImageMapAlpha.TRANSPARENT_IS_FALLBACK && alpha == 0D) { + targetIndex = legend.fallbackIndex(); + } else if (definition.getAlpha() == IrisImageMapAlpha.MASK && alpha < 1D) { + if (alpha == 0D) { + targetIndex = legend.fallbackIndex(); + } else { + throw new IrisImageMapValidationException( + "COLOR_MAP alpha=MASK requires binary alpha; pixel " + sourceX + "," + sourceZ + + " has alpha " + alpha + ); + } + } else { + int rgb = image.getRawRgb8(sourceX, sourceZ); + TargetMatch match = matchTarget(definition, legend, rgb, sourceX, sourceZ); + targetIndex = match.targetIndex(); + if (match.unknown()) { + unknownColorPixelCount++; + } + } + indices.set((sourceZ * width) + sourceX, targetIndex); + } + } + return new CompiledTargets(indices, legend.targets(), unknownColorPixelCount); + } + + private static Legend compileLegend(IrisImageMap definition) { + List entries = new ArrayList<>(); + for (Map.Entry entry : definition.getColors().entrySet()) { + int rgb = Integer.parseInt(entry.getKey().substring(1), 16); + entries.add(new MutableLegendEntry(rgb, entry.getKey().toUpperCase(), entry.getValue())); + } + entries.sort(Comparator.comparingInt(MutableLegendEntry::rgb).thenComparing(MutableLegendEntry::target)); + + LinkedHashMap targetIndexes = new LinkedHashMap<>(); + Map exact = new HashMap<>(); + List compiled = new ArrayList<>(); + for (MutableLegendEntry entry : entries) { + int targetIndex = targetIndexes.computeIfAbsent(entry.target(), ignored -> targetIndexes.size()); + LegendEntry compiledEntry = new LegendEntry(entry.rgb(), entry.encoded(), entry.target(), targetIndex); + exact.put(entry.rgb(), compiledEntry); + compiled.add(compiledEntry); + } + + int fallbackIndex = -1; + if (definition.getFallbackTarget() != null && !definition.getFallbackTarget().isBlank()) { + fallbackIndex = targetIndexes.computeIfAbsent( + definition.getFallbackTarget(), + ignored -> targetIndexes.size() + ); + } + String[] targets = new String[targetIndexes.size()]; + for (Map.Entry entry : targetIndexes.entrySet()) { + targets[entry.getValue()] = entry.getKey(); + } + return new Legend(List.copyOf(compiled), Map.copyOf(exact), targets, fallbackIndex); + } + + private static TargetMatch matchTarget( + IrisImageMap definition, + Legend legend, + int rgb, + int sourceX, + int sourceZ + ) { + LegendEntry exact = legend.exact().get(rgb); + if (exact != null) { + return new TargetMatch(exact.targetIndex(), false); + } + + double toleranceSquared = definition.getColorTolerance() * definition.getColorTolerance(); + LegendEntry matched = null; + List ambiguous = new ArrayList<>(); + if (toleranceSquared > 0D) { + for (LegendEntry candidate : legend.entries()) { + if (colorDistanceSquared(rgb, candidate.rgb()) <= toleranceSquared) { + if (matched == null) { + matched = candidate; + } + ambiguous.add(candidate.encoded()); + } + } + } + if (ambiguous.size() > 1) { + throw new IrisImageMapValidationException( + "Color " + encodeColor(rgb) + " at " + sourceX + "," + sourceZ + + " ambiguously matches " + String.join(", ", ambiguous) + ); + } + if (matched != null) { + return new TargetMatch(matched.targetIndex(), false); + } + + int targetIndex = switch (definition.getUnknownColor()) { + case ERROR -> throw new IrisImageMapValidationException( + "Color " + encodeColor(rgb) + " at " + sourceX + "," + sourceZ + " is absent from the legend" + ); + case FALLBACK -> legend.fallbackIndex(); + case IGNORE -> -1; + }; + return new TargetMatch(targetIndex, true); + } + + private static int countClippedPixels( + IrisImageMap definition, + CompiledIrisImageMap.FloatTiles values, + int size + ) { + if (!definition.isClamp() + || (definition.getType() != IrisImageMapType.GRAYSCALE_HEIGHT + && definition.getType() != IrisImageMapType.RGB_HEIGHT)) { + return 0; + } + double minimum = definition.getMinimumHeight(); + double maximum = definition.getMaximumHeight(); + double range = maximum - minimum; + double offset = definition.getVerticalOffset(); + int clipped = 0; + for (int index = 0; index < size; index++) { + double height = minimum + (values.get(index) * range) + offset; + if (height < minimum || height > maximum) { + clipped++; + } + } + return clipped; + } + + private static void validateAlphaPixel(IrisImageMap definition, int sourceX, int sourceZ, double alpha) { + if (definition.getAlpha() == IrisImageMapAlpha.ERROR && alpha < 1D) { + throw new IrisImageMapValidationException( + "Transparent pixel at " + sourceX + "," + sourceZ + " violates alpha=ERROR" + ); + } + } + + private static double threshold(double value, double threshold, double falloff) { + if (falloff == 0D) { + return value >= threshold ? 1D : 0D; + } + return clamp01((value - threshold) / falloff); + } + + private static void smooth(CompiledIrisImageMap.FloatTiles values, int width, int height, int radius) { + float[] line = new float[Math.max(width, height)]; + int diameter = (radius * 2) + 1; + for (int sourceZ = 0; sourceZ < height; sourceZ++) { + int rowOffset = sourceZ * width; + for (int sourceX = 0; sourceX < width; sourceX++) { + line[sourceX] = values.get(rowOffset + sourceX); + } + double sum = 0D; + for (int offset = -radius; offset <= radius; offset++) { + int sourceX = Math.max(0, Math.min(width - 1, offset)); + sum += line[sourceX]; + } + for (int sourceX = 0; sourceX < width; sourceX++) { + values.set(rowOffset + sourceX, (float) (sum / diameter)); + int leavingX = Math.max(0, Math.min(width - 1, sourceX - radius)); + int enteringX = Math.max(0, Math.min(width - 1, sourceX + radius + 1)); + sum += line[enteringX] - line[leavingX]; + } + } + + for (int sourceX = 0; sourceX < width; sourceX++) { + for (int sourceZ = 0; sourceZ < height; sourceZ++) { + line[sourceZ] = values.get((sourceZ * width) + sourceX); + } + double sum = 0D; + for (int offset = -radius; offset <= radius; offset++) { + int sourceZ = Math.max(0, Math.min(height - 1, offset)); + sum += line[sourceZ]; + } + for (int sourceZ = 0; sourceZ < height; sourceZ++) { + values.set((sourceZ * width) + sourceX, (float) (sum / diameter)); + int leavingZ = Math.max(0, Math.min(height - 1, sourceZ - radius)); + int enteringZ = Math.max(0, Math.min(height - 1, sourceZ + radius + 1)); + sum += line[enteringZ] - line[leavingZ]; + } + } + } + + private static SourceDigest hashSource(IrisImage image) { + HashAccumulator hash = new HashAccumulator(); + hash.addString(image.getFormat()); + hash.addString(image.getColorMode().name()); + hash.addInt(image.getWidth()); + hash.addInt(image.getHeight()); + hash.addInt(image.getColorComponentCount()); + hash.addInt(image.getChannelCount()); + hash.addInt(image.getBitDepth()); + hash.addBoolean(image.hasAlpha()); + double minimumAlpha = 1D; + double maximumAlpha = image.hasAlpha() ? 0D : 1D; + for (int sourceZ = 0; sourceZ < image.getHeight(); sourceZ++) { + for (int sourceX = 0; sourceX < image.getWidth(); sourceX++) { + double alpha = image.getAlphaNormalized(sourceX, sourceZ); + minimumAlpha = Math.min(minimumAlpha, alpha); + maximumAlpha = Math.max(maximumAlpha, alpha); + for (int band = 0; band < image.getChannelCount(); band++) { + hash.addInt(image.getBandSample(sourceX, sourceZ, band)); + } + } + } + return new SourceDigest(hash.finish(), minimumAlpha, maximumAlpha); + } + + private static String hashContent(IrisImageMap definition, IrisImageMapSourceMetadata metadata) { + HashAccumulator hash = new HashAccumulator(); + hash.addString(metadata.decodedContentHash()); + hash.addString(definition.getSource()); + hash.addString(definition.getType().name()); + hash.addDouble(definition.getBlocksPerPixel()); + hash.addDouble(definition.getOrigin().getX()); + hash.addDouble(definition.getOrigin().getZ()); + hash.addDouble(definition.getSourceOrigin().getX()); + hash.addDouble(definition.getSourceOrigin().getZ()); + hash.addString(definition.getRotation().name()); + hash.addBoolean(definition.isMirrorX()); + hash.addBoolean(definition.isMirrorZ()); + hash.addString(definition.getSampling().name()); + hash.addString(definition.getOutOfBounds().name()); + hash.addDouble(definition.getFallbackValue()); + hash.addString(definition.getFallbackTarget()); + hash.addString(definition.getAlpha().name()); + hash.addDouble(definition.getMinimumHeight()); + hash.addDouble(definition.getMaximumHeight()); + hash.addDouble(definition.getVerticalOffset()); + hash.addBoolean(definition.isClamp()); + hash.addBoolean(definition.isInverted()); + hash.addDouble(definition.getCurveExponent()); + hash.addInt(definition.getSmoothingRadius()); + hash.addDouble(definition.getThreshold()); + hash.addDouble(definition.getFalloff()); + hash.addDouble(definition.getColorTolerance()); + hash.addString(definition.getUnknownColor().name()); + + KMap configuredColors = definition.getColors(); + List> colors = configuredColors == null + ? new ArrayList<>() + : new ArrayList<>(configuredColors.entrySet()); + colors.sort(Map.Entry.comparingByKey(String.CASE_INSENSITIVE_ORDER)); + hash.addInt(colors.size()); + for (Map.Entry entry : colors) { + hash.addString(entry.getKey().toUpperCase()); + hash.addString(entry.getValue()); + } + return hash.finish(); + } + + private static CompiledIrisImageMap.SourceTransform compileSourceTransform(IrisImageMap definition) { + double sourceXFromWorldX; + double sourceXFromWorldZ; + double sourceZFromWorldX; + double sourceZFromWorldZ; + switch (definition.getRotation()) { + case DEG_0 -> { + sourceXFromWorldX = 1D; + sourceXFromWorldZ = 0D; + sourceZFromWorldX = 0D; + sourceZFromWorldZ = 1D; + } + case DEG_90 -> { + sourceXFromWorldX = 0D; + sourceXFromWorldZ = 1D; + sourceZFromWorldX = -1D; + sourceZFromWorldZ = 0D; + } + case DEG_180 -> { + sourceXFromWorldX = -1D; + sourceXFromWorldZ = 0D; + sourceZFromWorldX = 0D; + sourceZFromWorldZ = -1D; + } + case DEG_270 -> { + sourceXFromWorldX = 0D; + sourceXFromWorldZ = -1D; + sourceZFromWorldX = 1D; + sourceZFromWorldZ = 0D; + } + default -> throw new IrisImageMapValidationException( + "Unsupported image-map rotation " + definition.getRotation() + ); + } + if (definition.isMirrorX()) { + sourceXFromWorldX = -sourceXFromWorldX; + sourceXFromWorldZ = -sourceXFromWorldZ; + } + if (definition.isMirrorZ()) { + sourceZFromWorldX = -sourceZFromWorldX; + sourceZFromWorldZ = -sourceZFromWorldZ; + } + double inverseScale = 1D / definition.getBlocksPerPixel(); + sourceXFromWorldX *= inverseScale; + sourceXFromWorldZ *= inverseScale; + sourceZFromWorldX *= inverseScale; + sourceZFromWorldZ *= inverseScale; + double sourceXOffset = definition.getSourceOrigin().getX() + - (sourceXFromWorldX * definition.getOrigin().getX()) + - (sourceXFromWorldZ * definition.getOrigin().getZ()); + double sourceZOffset = definition.getSourceOrigin().getZ() + - (sourceZFromWorldX * definition.getOrigin().getX()) + - (sourceZFromWorldZ * definition.getOrigin().getZ()); + if (!Double.isFinite(sourceXFromWorldX) + || !Double.isFinite(sourceXFromWorldZ) + || !Double.isFinite(sourceXOffset) + || !Double.isFinite(sourceZFromWorldX) + || !Double.isFinite(sourceZFromWorldZ) + || !Double.isFinite(sourceZOffset)) { + throw new IrisImageMapValidationException("Image-map coordinate transform must remain finite"); + } + return new CompiledIrisImageMap.SourceTransform( + sourceXFromWorldX, + sourceXFromWorldZ, + sourceXOffset, + sourceZFromWorldX, + sourceZFromWorldZ, + sourceZOffset + ); + } + + private static IrisImageMap snapshot(IrisImageMap definition) { + KMap configuredColors = definition.getColors(); + KMap colors = configuredColors == null ? new KMap<>() : new KMap<>(configuredColors); + return new IrisImageMap() + .setSource(definition.getSource()) + .setType(definition.getType()) + .setBlocksPerPixel(definition.getBlocksPerPixel()) + .setOrigin(new IrisImageMapOrigin(definition.getOrigin().getX(), definition.getOrigin().getZ())) + .setSourceOrigin(new IrisImageMapOrigin( + definition.getSourceOrigin().getX(), + definition.getSourceOrigin().getZ() + )) + .setRotation(definition.getRotation()) + .setMirrorX(definition.isMirrorX()) + .setMirrorZ(definition.isMirrorZ()) + .setSampling(definition.getSampling()) + .setOutOfBounds(definition.getOutOfBounds()) + .setFallbackValue(definition.getFallbackValue()) + .setFallbackTarget(definition.getFallbackTarget()) + .setAlpha(definition.getAlpha()) + .setMinimumHeight(definition.getMinimumHeight()) + .setMaximumHeight(definition.getMaximumHeight()) + .setVerticalOffset(definition.getVerticalOffset()) + .setClamp(definition.isClamp()) + .setInverted(definition.isInverted()) + .setCurveExponent(definition.getCurveExponent()) + .setSmoothingRadius(definition.getSmoothingRadius()) + .setThreshold(definition.getThreshold()) + .setFalloff(definition.getFalloff()) + .setColorTolerance(definition.getColorTolerance()) + .setUnknownColor(definition.getUnknownColor()) + .setColors(colors); + } + + private static double colorDistanceSquared(int first, int second) { + int red = ((first >>> 16) & 0xFF) - ((second >>> 16) & 0xFF); + int green = ((first >>> 8) & 0xFF) - ((second >>> 8) & 0xFF); + int blue = (first & 0xFF) - (second & 0xFF); + return (red * red) + (green * green) + (blue * blue); + } + + private static String encodeColor(int rgb) { + return String.format(Locale.ROOT, "#%06X", rgb & 0xFFFFFF); + } + + private static boolean finiteAtLeast(double value, double minimum) { + return Double.isFinite(value) && value >= minimum; + } + + private static boolean range(double value, double minimum, double maximum) { + return Double.isFinite(value) && value >= minimum && value <= maximum; + } + + private static double clamp01(double value) { + return Math.max(0D, Math.min(1D, value)); + } + + private record MutableLegendEntry(int rgb, String encoded, String target) { + } + + private record LegendEntry(int rgb, String encoded, String target, int targetIndex) { + } + + private record Legend( + List entries, + Map exact, + String[] targets, + int fallbackIndex + ) { + } + + private record CompiledTargets( + CompiledIrisImageMap.IntTiles indices, + String[] targets, + int unknownColorPixelCount + ) { + } + + private record TargetMatch(int targetIndex, boolean unknown) { + } + + private record SourceDigest(String hash, double minimumAlpha, double maximumAlpha) { + } + + private static final class HashAccumulator { + private final MessageDigest digest; + + private HashAccumulator() { + try { + digest = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable", exception); + } + } + + private void addBoolean(boolean value) { + digest.update((byte) (value ? 1 : 0)); + } + + private void addInt(int value) { + digest.update((byte) (value >>> 24)); + digest.update((byte) (value >>> 16)); + digest.update((byte) (value >>> 8)); + digest.update((byte) value); + } + + private void addLong(long value) { + digest.update((byte) (value >>> 56)); + digest.update((byte) (value >>> 48)); + digest.update((byte) (value >>> 40)); + digest.update((byte) (value >>> 32)); + digest.update((byte) (value >>> 24)); + digest.update((byte) (value >>> 16)); + digest.update((byte) (value >>> 8)); + digest.update((byte) value); + } + + private void addDouble(double value) { + addLong(Double.doubleToLongBits(value)); + } + + private void addString(String value) { + if (value == null) { + addInt(-1); + return; + } + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + addInt(bytes.length); + digest.update(bytes); + } + + private String finish() { + return HexFormat.of().formatHex(digest.digest()); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/image/IrisImageMapMaskSampler.java b/core/src/main/java/art/arcane/iris/engine/image/IrisImageMapMaskSampler.java new file mode 100644 index 000000000..7fa899106 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/image/IrisImageMapMaskSampler.java @@ -0,0 +1,113 @@ +package art.arcane.iris.engine.image; + +import art.arcane.iris.engine.object.IrisImageMapMask; +import art.arcane.iris.engine.object.IrisImageMapMaskOperation; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public final class IrisImageMapMaskSampler { + private static final IrisImageMapMaskSampler EMPTY = new IrisImageMapMaskSampler(List.of()); + + private final List layers; + + public IrisImageMapMaskSampler(List layers) { + this.layers = List.copyOf(Objects.requireNonNull(layers, "Image-map mask layers")); + } + + public static IrisImageMapMaskSampler empty() { + return EMPTY; + } + + public static Layer layer(CompiledIrisImageMap compiled, IrisImageMapMask definition) { + Objects.requireNonNull(definition, "Image-map mask definition"); + if (definition.getOperation() == null) { + throw new IrisImageMapValidationException("Image-map mask operation is required"); + } + if (!unitRange(definition.getThreshold()) || !unitRange(definition.getFalloff())) { + throw new IrisImageMapValidationException("Image-map mask threshold and falloff must be within 0..1"); + } + return new Layer( + Objects.requireNonNull(compiled, "Compiled image-map mask"), + definition.getOperation(), + definition.isInverted(), + definition.getThreshold(), + definition.getFalloff() + ); + } + + public static IrisImageMapMaskSampler of( + List compiled, + List definitions + ) { + if (compiled.size() != definitions.size()) { + throw new IllegalArgumentException("Compiled image-map masks and definitions must have equal sizes"); + } + List layers = new ArrayList<>(compiled.size()); + for (int index = 0; index < compiled.size(); index++) { + layers.add(layer(compiled.get(index), definitions.get(index))); + } + return layers.isEmpty() ? empty() : new IrisImageMapMaskSampler(layers); + } + + public boolean isEmpty() { + return layers.isEmpty(); + } + + public double sample(double worldX, double worldZ) { + double weight = 1D; + for (Layer layer : layers) { + double value = layer.sample(worldX, worldZ); + weight = switch (layer.operation()) { + case MULTIPLY -> weight * value; + case MINIMUM -> Math.min(weight, value); + case MAXIMUM -> Math.max(weight, value); + case ADD -> weight + value; + case SUBTRACT -> weight - value; + }; + weight = clamp01(weight); + } + return weight; + } + + private static boolean unitRange(double value) { + return Double.isFinite(value) && value >= 0D && value <= 1D; + } + + private static double clamp01(double value) { + return Math.max(0D, Math.min(1D, value)); + } + + public record Layer( + CompiledIrisImageMap compiled, + IrisImageMapMaskOperation operation, + boolean inverted, + double threshold, + double falloff + ) { + public Layer { + Objects.requireNonNull(compiled, "Compiled image-map mask"); + Objects.requireNonNull(operation, "Image-map mask operation"); + if (!unitRange(threshold) || !unitRange(falloff)) { + throw new IrisImageMapValidationException( + "Image-map mask threshold and falloff must be within 0..1" + ); + } + } + + private double sample(double worldX, double worldZ) { + double value = compiled.sampleNormalized(worldX, worldZ); + if (inverted) { + value = 1D - value; + } + if (threshold == 0D && falloff == 0D) { + return value; + } + if (falloff == 0D) { + return value >= threshold ? 1D : 0D; + } + return clamp01((value - threshold) / falloff); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/image/IrisImageMapRuntime.java b/core/src/main/java/art/arcane/iris/engine/image/IrisImageMapRuntime.java new file mode 100644 index 000000000..9eb98cc15 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/image/IrisImageMapRuntime.java @@ -0,0 +1,386 @@ +package art.arcane.iris.engine.image; + +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.IrisDimension; +import art.arcane.iris.engine.object.IrisImage; +import art.arcane.iris.engine.object.IrisImageMap; +import art.arcane.iris.engine.object.IrisImageMapApplication; +import art.arcane.iris.engine.object.IrisImageMapBinding; +import art.arcane.iris.engine.object.IrisImageMapMask; +import art.arcane.iris.engine.object.IrisImageMapType; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.object.InferredType; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.common.data.B; + +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +public final class IrisImageMapRuntime { + private final int minimumWorldHeight; + private final Map bindings; + private final Map applications; + private final Set mappedRegions; + private final Set mappedBiomes; + + private IrisImageMapRuntime( + int minimumWorldHeight, + Map bindings, + Map applications, + Set mappedRegions, + Set mappedBiomes + ) { + this.minimumWorldHeight = minimumWorldHeight; + this.bindings = Map.copyOf(bindings); + this.applications = Map.copyOf(applications); + this.mappedRegions = Set.copyOf(mappedRegions); + this.mappedBiomes = Set.copyOf(mappedBiomes); + } + + public static IrisImageMapRuntime compile(Engine engine) { + Objects.requireNonNull(engine, "Image-map runtime requires an engine"); + return compile(engine.getData(), engine.getDimension(), engine.getMinHeight()); + } + + public static IrisImageMapRuntime compile(IrisData data, IrisDimension dimension, int minimumWorldHeight) { + Objects.requireNonNull(data, "Image-map runtime requires Iris data"); + Objects.requireNonNull(dimension, "Image-map runtime requires a dimension"); + Map bindings = new LinkedHashMap<>(); + Map applications = new EnumMap<>(IrisImageMapApplication.class); + Set mappedRegions = new LinkedHashSet<>(); + Set mappedBiomes = new LinkedHashSet<>(); + + for (IrisImageMapBinding binding : dimension.getImageMaps()) { + RuntimeBinding runtimeBinding = compileBinding(binding, data, dimension, mappedRegions, mappedBiomes); + RuntimeBinding duplicate = bindings.putIfAbsent(runtimeBinding.key, runtimeBinding); + if (duplicate != null) { + throw validation("Duplicate dimension image-map key '" + runtimeBinding.key + "'"); + } + IrisImageMapApplication application = runtimeBinding.application; + if (application != IrisImageMapApplication.MASK && application != IrisImageMapApplication.CUSTOM) { + RuntimeBinding applicationDuplicate = applications.putIfAbsent(application, runtimeBinding); + if (applicationDuplicate != null) { + throw validation("Dimension declares more than one " + application + " image-map binding"); + } + } + } + + for (RuntimeBinding binding : bindings.values()) { + binding.masks = resolveMasks(binding, bindings); + } + + return new IrisImageMapRuntime( + minimumWorldHeight, bindings, applications, mappedRegions, mappedBiomes + ); + } + + public boolean has(IrisImageMapApplication application) { + return applications.containsKey(application); + } + + public Set getMappedRegions() { + return mappedRegions; + } + + public Set getMappedBiomes() { + return mappedBiomes; + } + + public double sampleTerrainHeight(double worldX, double worldZ, double proceduralLocalHeight) { + RuntimeBinding binding = applications.get(IrisImageMapApplication.TERRAIN_HEIGHT); + if (binding == null) { + return proceduralLocalHeight; + } + double mappedLocalHeight = binding.compiled.sampleHeight(worldX, worldZ) - minimumWorldHeight; + double weight = binding.maskWeight(worldX, worldZ); + return blendTerrainHeight(mappedLocalHeight, proceduralLocalHeight, weight); + } + + public IrisRegion sampleRegion(double worldX, double worldZ) { + RuntimeBinding binding = applications.get(IrisImageMapApplication.REGION); + if (binding == null || !selectCategorical(binding.maskWeight(worldX, worldZ))) { + return null; + } + String target = binding.compiled.sampleTarget(worldX, worldZ); + return target == null ? null : binding.regions.get(target); + } + + public IrisBiome sampleBiome(double worldX, double worldZ) { + RuntimeBinding binding = applications.get(IrisImageMapApplication.BIOME); + if (binding == null || !selectCategorical(binding.maskWeight(worldX, worldZ))) { + return null; + } + String target = binding.compiled.sampleTarget(worldX, worldZ); + return target == null ? null : binding.biomes.get(target); + } + + public PlatformBlockState sampleSurfaceBlock(double worldX, double worldZ) { + RuntimeBinding binding = applications.get(IrisImageMapApplication.SURFACE_BLOCK); + if (binding == null || !selectCategorical(binding.maskWeight(worldX, worldZ))) { + return null; + } + String target = binding.compiled.sampleTarget(worldX, worldZ); + return target == null ? null : binding.blocks.get(target); + } + + public CompiledIrisImageMap getCompiled(String key) { + RuntimeBinding binding = bindings.get(key); + return binding == null ? null : binding.compiled; + } + + public static double blendTerrainHeight(double mappedLocalHeight, double proceduralLocalHeight, double weight) { + return proceduralLocalHeight + ((mappedLocalHeight - proceduralLocalHeight) * weight); + } + + public static boolean selectCategorical(double weight) { + return weight >= 0.5D; + } + + private static RuntimeBinding compileBinding( + IrisImageMapBinding binding, + IrisData data, + IrisDimension dimension, + Set mappedRegions, + Set mappedBiomes + ) { + if (binding == null) { + throw validation("Dimension imageMaps contains a null binding"); + } + String key = requireText(binding.getKey(), "Dimension image-map key"); + String mapKey = requireText(binding.getMap(), "Dimension image-map resource for '" + key + "'"); + IrisImageMapApplication application = binding.getApplication(); + if (application == null) { + throw validation("Dimension image-map '" + key + "' requires an application"); + } + IrisImageMap definition = data.getImageMapLoader().load(mapKey); + if (definition == null) { + throw validation("Dimension image-map '" + key + "' references missing image-map resource '" + mapKey + "'"); + } + String source = requireText(definition.getSource(), "Image-map resource '" + mapKey + "' source"); + IrisImage image = data.getImageLoader().load(source); + if (image == null) { + throw validation("Image-map resource '" + mapKey + "' references missing or invalid PNG '" + source + "'"); + } + CompiledIrisImageMap compiled; + try { + validateApplication(key, application, definition.getType(), binding.getMasks()); + compiled = CompiledIrisImageMap.compile(definition, image); + } finally { + data.getImageLoader().unload(source); + } + Map regions = new LinkedHashMap<>(); + Map biomes = new LinkedHashMap<>(); + Map blocks = new LinkedHashMap<>(); + Set targets = legendTargets(definition); + + if (application == IrisImageMapApplication.REGION) { + for (String target : targets) { + IrisRegion region = data.getRegionLoader().load(localResourceKey(target)); + if (region == null) { + throw validation("Image-map '" + key + "' references missing region target '" + target + "'"); + } + regions.put(target, region); + mappedRegions.add(region); + } + } else if (application == IrisImageMapApplication.BIOME) { + for (String target : targets) { + String localTarget = localResourceKey(target); + IrisBiome biome = data.getBiomeLoader().load(localTarget); + if (biome == null) { + throw validation("Image-map '" + key + "' references missing biome target '" + target + "'"); + } + IrisBiome typedBiome = biome.withInferredType(resolveBiomeType(localTarget, dimension, data)); + biomes.put(target, typedBiome); + mappedBiomes.add(typedBiome); + } + } else if (application == IrisImageMapApplication.SURFACE_BLOCK) { + for (String target : targets) { + PlatformBlockState block = B.getStateOrNull(target, false); + if (block == null) { + throw validation("Image-map '" + key + "' references unknown surface block target '" + target + "'"); + } + blocks.put(target, block); + } + } + + return new RuntimeBinding( + key, + application, + compiled, + Map.copyOf(regions), + Map.copyOf(biomes), + Map.copyOf(blocks), + binding.getMasks() == null ? List.of() : List.copyOf(binding.getMasks()) + ); + } + + private static void validateApplication( + String key, + IrisImageMapApplication application, + IrisImageMapType type, + List masks + ) { + if (type == null) { + throw validation("Image-map '" + key + "' requires a type"); + } + boolean valid = switch (application) { + case TERRAIN_HEIGHT -> type == IrisImageMapType.GRAYSCALE_HEIGHT + || type == IrisImageMapType.RGB_HEIGHT; + case BIOME, REGION, SURFACE_BLOCK -> type == IrisImageMapType.COLOR_MAP; + case MASK -> isMaskType(type); + case CUSTOM -> true; + }; + if (!valid) { + throw validation("Image-map '" + key + "' type " + type + " is incompatible with " + application); + } + if (application == IrisImageMapApplication.MASK && masks != null && !masks.isEmpty()) { + throw validation("MASK image-map '" + key + "' cannot reference additional masks"); + } + } + + private static IrisImageMapMaskSampler resolveMasks( + RuntimeBinding binding, + Map bindings + ) { + if (binding.maskDefinitions.isEmpty()) { + return IrisImageMapMaskSampler.empty(); + } + List masks = new ArrayList<>(binding.maskDefinitions.size()); + for (IrisImageMapMask mask : binding.maskDefinitions) { + if (mask == null) { + throw validation("Image-map '" + binding.key + "' contains a null mask reference"); + } + String maskKey = requireText(mask.getMap(), "Mask reference on image-map '" + binding.key + "'"); + RuntimeBinding maskBinding = bindings.get(maskKey); + if (maskBinding == null) { + throw validation("Image-map '" + binding.key + "' references missing MASK binding '" + maskKey + "'"); + } + if (maskBinding.application != IrisImageMapApplication.MASK) { + throw validation("Image-map '" + binding.key + "' references '" + maskKey + "', which is not a MASK binding"); + } + if (mask.getOperation() == null) { + throw validation("Mask '" + maskKey + "' on image-map '" + binding.key + "' requires an operation"); + } + if (!range(mask.getThreshold()) || !range(mask.getFalloff())) { + throw validation("Mask '" + maskKey + "' threshold and falloff must be finite values within 0..1"); + } + masks.add(IrisImageMapMaskSampler.layer(maskBinding.compiled, mask)); + } + return new IrisImageMapMaskSampler(masks); + } + + private static Set legendTargets(IrisImageMap definition) { + Set targets = new LinkedHashSet<>(); + if (definition.getColors() != null) { + for (String target : definition.getColors().values()) { + if (target != null && !target.isBlank()) { + targets.add(target); + } + } + } + String fallback = definition.getFallbackTarget(); + if (fallback != null && !fallback.isBlank()) { + targets.add(fallback); + } + return targets; + } + + private static InferredType resolveBiomeType(String biomeKey, IrisDimension dimension, IrisData data) { + Set types = new LinkedHashSet<>(); + for (IrisRegion region : dimension.getAllRegions(() -> data)) { + if (region == null) { + continue; + } + if (region.getLandBiomes().contains(biomeKey)) { + types.add(InferredType.LAND); + } + if (region.getSeaBiomes().contains(biomeKey)) { + types.add(InferredType.SEA); + } + if (region.getShoreBiomes().contains(biomeKey)) { + types.add(InferredType.SHORE); + } + } + if (types.size() != 1) { + throw validation("Image-mapped biome '" + biomeKey + + "' must occur in exactly one landBiomes, seaBiomes, or shoreBiomes role across the dimension; found " + + types); + } + return types.iterator().next(); + } + + private static String localResourceKey(String target) { + String normalized = requireText(target, "Image-map Iris resource target"); + int separator = normalized.indexOf(':'); + if (separator < 0) { + return normalized; + } + if (!normalized.startsWith("iris:") || separator == normalized.length() - 1) { + throw validation("Iris biome and region targets must be bare pack keys or use the iris: namespace, got '" + + target + "'"); + } + return normalized.substring(separator + 1); + } + + private static String requireText(String value, String name) { + if (value == null || value.isBlank()) { + throw validation(name + " must not be blank"); + } + return value.trim(); + } + + private static boolean range(double value) { + return Double.isFinite(value) && value >= 0D && value <= 1D; + } + + private static boolean isMaskType(IrisImageMapType type) { + return type == IrisImageMapType.BINARY_MASK + || type == IrisImageMapType.GRAYSCALE_MASK + || type == IrisImageMapType.ALPHA_MASK; + } + + private static IrisImageMapValidationException validation(String message) { + return new IrisImageMapValidationException(message); + } + + private static final class RuntimeBinding { + private final String key; + private final IrisImageMapApplication application; + private final CompiledIrisImageMap compiled; + private final Map regions; + private final Map biomes; + private final Map blocks; + private final List maskDefinitions; + private IrisImageMapMaskSampler masks = IrisImageMapMaskSampler.empty(); + + private RuntimeBinding( + String key, + IrisImageMapApplication application, + CompiledIrisImageMap compiled, + Map regions, + Map biomes, + Map blocks, + List maskDefinitions + ) { + this.key = key; + this.application = application; + this.compiled = compiled; + this.regions = regions; + this.biomes = biomes; + this.blocks = blocks; + this.maskDefinitions = maskDefinitions; + } + + private double maskWeight(double worldX, double worldZ) { + return masks.sample(worldX, worldZ); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/image/IrisImageMapSourceMetadata.java b/core/src/main/java/art/arcane/iris/engine/image/IrisImageMapSourceMetadata.java new file mode 100644 index 000000000..2f64e2a02 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/image/IrisImageMapSourceMetadata.java @@ -0,0 +1,18 @@ +package art.arcane.iris.engine.image; + +import art.arcane.iris.engine.object.IrisImageColorMode; + +public record IrisImageMapSourceMetadata( + String format, + IrisImageColorMode colorMode, + int width, + int height, + int colorComponents, + int channels, + int bitDepth, + boolean alpha, + double minimumAlpha, + double maximumAlpha, + String decodedContentHash +) { +} diff --git a/core/src/main/java/art/arcane/iris/engine/image/IrisImageMapValidationException.java b/core/src/main/java/art/arcane/iris/engine/image/IrisImageMapValidationException.java new file mode 100644 index 000000000..9c267b4a9 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/image/IrisImageMapValidationException.java @@ -0,0 +1,35 @@ +package art.arcane.iris.engine.image; + +import java.util.List; + +public final class IrisImageMapValidationException extends IllegalArgumentException { + private final List diagnostics; + + public IrisImageMapValidationException(String diagnostic) { + this(List.of(diagnostic)); + } + + public IrisImageMapValidationException(List diagnostics) { + super(message(diagnostics)); + if (diagnostics == null || diagnostics.isEmpty()) { + throw new IllegalArgumentException("Image-map validation requires at least one diagnostic"); + } + this.diagnostics = List.copyOf(diagnostics); + } + + public IrisImageMapValidationException(String diagnostic, Throwable cause) { + super(diagnostic, cause); + this.diagnostics = List.of(diagnostic); + } + + public List getDiagnostics() { + return diagnostics; + } + + private static String message(List diagnostics) { + if (diagnostics == null || diagnostics.isEmpty()) { + return "Image-map validation failed"; + } + return String.join("; ", diagnostics); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java b/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java index fec26549e..6433ee185 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java @@ -63,6 +63,7 @@ import java.util.ArrayDeque; import java.util.Deque; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.Locale; import java.util.Map; import java.util.Set; @@ -188,6 +189,11 @@ public class IrisDimension extends IrisRegistrant { private IrisRange dimensionHeight = new IrisRange(-64, 320); @Desc("Define options for this dimension") private IrisDimensionTypeOptions dimensionOptions = new IrisDimensionTypeOptions(); + @Desc("Configure and enforce the native Minecraft world border. When omitted, Iris leaves the native border unchanged.") + private IrisWorldBoundary worldBoundary = null; + @ArrayType(type = IrisImageMapBinding.class) + @Desc("Bind reusable image-map resources to typed world-generation inputs") + private KList imageMaps = new KList<>(); @Desc("When true, sets the dimension's ambient light to maximum, making all blocks fully lit regardless of light level.") private boolean fullbright = false; @RegistryListResource(IrisDimension.class) @@ -441,21 +447,39 @@ public class IrisDimension extends IrisRegistrant { } public KList getAllRegions(DataProvider g) { - KList r = new KList<>(); + KMap regions = new KMap<>(); if (g == null) { - return r; + return regions.v(); } IrisData data = g.getData(); if (data == null || data.getRegionLoader() == null) { - return r; + return regions.v(); } - for (String i : getRegions()) { - r.add(data.getRegionLoader().load(i)); + for (String key : getRegions()) { + IrisRegion region = data.getRegionLoader().load(key); + if (region != null) { + regions.put(key, region); + } } - - return r; + for (IrisImageMapBinding binding : getImageMaps()) { + if (binding == null || binding.getApplication() != IrisImageMapApplication.REGION) { + continue; + } + IrisImageMap map = data.getImageMapLoader().load(binding.getMap()); + if (map == null || map.getColors() == null) { + continue; + } + for (String target : imageMapTargets(map)) { + String key = localImageMapTarget(target); + IrisRegion region = data.getRegionLoader().load(key); + if (region != null) { + regions.put(key, region); + } + } + } + return regions.v(); } public KList getAllAnyRegions() { @@ -496,15 +520,23 @@ public class IrisDimension extends IrisRegistrant { if (riversEnabled && riverNetwork.getBiomes() != null) { addReachableBiomeKeys(pending, riverNetwork.getBiomes().getAllBiomeIds()); } - KList regionKeys = getRegions(); - if (regionKeys != null) { - for (String regionKey : regionKeys) { - IrisRegion region = data.getRegionLoader().load(regionKey); - if (region == null) { - continue; - } - addReachableBiomeKeys(pending, - riversEnabled ? region.getAllBiomeIds() : region.getNaturalBiomeIds()); + for (IrisRegion region : getAllRegions(g)) { + if (region == null) { + continue; + } + addReachableBiomeKeys(pending, + riversEnabled ? region.getAllBiomeIds() : region.getNaturalBiomeIds()); + } + for (IrisImageMapBinding binding : getImageMaps()) { + if (binding == null || binding.getApplication() != IrisImageMapApplication.BIOME) { + continue; + } + IrisImageMap map = data.getImageMapLoader().load(binding.getMap()); + if (map == null) { + continue; + } + for (String target : imageMapTargets(map)) { + addReachableBiomeKey(pending, localImageMapTarget(target)); } } @@ -561,6 +593,25 @@ public class IrisDimension extends IrisRegistrant { return biomes.v(); } + private Set imageMapTargets(IrisImageMap map) { + Set targets = new LinkedHashSet<>(); + if (map.getColors() != null) { + for (String target : map.getColors().values()) { + if (target != null && !target.isBlank()) { + targets.add(target); + } + } + } + if (map.getFallbackTarget() != null && !map.getFallbackTarget().isBlank()) { + targets.add(map.getFallbackTarget()); + } + return targets; + } + + private String localImageMapTarget(String target) { + return target.startsWith("iris:") ? target.substring("iris:".length()) : target; + } + private void addReachableBiomeKeys(Deque pending, Iterable biomeKeys) { if (biomeKeys == null) { return; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisGeneratorStyle.java b/core/src/main/java/art/arcane/iris/engine/object/IrisGeneratorStyle.java index 6da231aff..7f878a37e 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisGeneratorStyle.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisGeneratorStyle.java @@ -71,7 +71,8 @@ public class IrisGeneratorStyle { @RegistryListResource(IrisExpression.class) private String expression = null; @Desc("Use an Image map instead of a generated value") - private IrisImageMap imageMap = null; + @RegistryListResource(IrisImageMap.class) + private String imageMap = null; @MinNumber(0.00001) @Desc("The Output multiplier. Only used if parent is fracture.") private double multiplier = 1; @@ -109,13 +110,7 @@ public class IrisGeneratorStyle { return 0; } - return Objects.hash(imageMap.getImage(), - imageMap.getCoordinateScale(), - imageMap.getInterpolationMethod(), - imageMap.getChannel(), - imageMap.isInverted(), - imageMap.isTiled(), - imageMap.isCentered()); + return imageMap.hashCode(); } private int hash() { @@ -178,8 +173,9 @@ public class IrisGeneratorStyle { e.getLoadFile() == null ? 0L : e.getLoadFile().lastModified())); } } else if (getImageMap() != null) { - cng = new CNG(rng, new ImageNoise(data, getImageMap()), 1D, 1).bake(); - sourceStamp = Integer.toUnsignedLong(imageMapHash()); + ImageNoise imageNoise = new ImageNoise(data, getImageMap()); + cng = new CNG(rng, imageNoise, 1D, 1).bake(); + sourceStamp = Integer.toUnsignedLong(imageNoise.getContentHash().hashCode()); } if (cng == null) { diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisImage.java b/core/src/main/java/art/arcane/iris/engine/object/IrisImage.java index c16790273..c4c76829c 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisImage.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisImage.java @@ -20,19 +20,31 @@ package art.arcane.iris.engine.object; import art.arcane.iris.core.loader.IrisRegistrant; -import java.awt.Color; +import java.awt.image.ColorModel; import java.awt.image.BufferedImage; +import java.awt.image.IndexColorModel; +import java.awt.image.Raster; import java.util.Objects; -public class IrisImage extends IrisRegistrant { +public final class IrisImage extends IrisRegistrant { private final BufferedImage image; + private final Raster raster; + private final ColorModel colorModel; + private final String format; public IrisImage() { - this(new BufferedImage(4, 4, BufferedImage.TYPE_INT_RGB)); + this(new BufferedImage(4, 4, BufferedImage.TYPE_INT_RGB), "png"); } public IrisImage(BufferedImage image) { + this(image, "png"); + } + + public IrisImage(BufferedImage image, String format) { this.image = Objects.requireNonNull(image, "IrisImage requires a decoded image (the source file was unreadable or not an image)"); + this.raster = image.getRaster(); + this.colorModel = image.getColorModel(); + this.format = Objects.requireNonNull(format, "Image format").toLowerCase(); } public int getWidth() { @@ -43,63 +55,104 @@ public class IrisImage extends IrisRegistrant { return image.getHeight(); } - public int getRawValue(int x, int z) { - if (x >= getWidth() || z >= getHeight() || x < 0 || z < 0) { - return 0; - } + public String getFormat() { + return format; + } + public IrisImageColorMode getColorMode() { + if (colorModel instanceof IndexColorModel) { + return IrisImageColorMode.INDEXED; + } + int colorComponents = colorModel.getNumColorComponents(); + if (colorComponents == 1) { + return colorModel.hasAlpha() ? IrisImageColorMode.UNSUPPORTED : IrisImageColorMode.GRAYSCALE; + } + if (colorComponents == 3) { + return colorModel.hasAlpha() ? IrisImageColorMode.RGBA : IrisImageColorMode.RGB; + } + return IrisImageColorMode.UNSUPPORTED; + } + + public int getColorComponentCount() { + return colorModel.getNumColorComponents(); + } + + public int getChannelCount() { + return raster.getNumBands(); + } + + public int getBitDepth() { + int[] sampleSizes = raster.getSampleModel().getSampleSize(); + int maximum = 0; + int colorBands = Math.min(getColorComponentCount(), sampleSizes.length); + for (int index = 0; index < colorBands; index++) { + maximum = Math.max(maximum, sampleSizes[index]); + } + return maximum; + } + + public boolean hasAlpha() { + return colorModel.hasAlpha(); + } + + public double getBandNormalized(int x, int z, int band) { + requireCoordinate(x, z); + if (band < 0 || band >= raster.getNumBands()) { + throw new IllegalArgumentException("Image band " + band + " is outside 0.." + (raster.getNumBands() - 1)); + } + int bits = raster.getSampleModel().getSampleSize(band); + long maximum = (1L << bits) - 1L; + return raster.getSample(x, z, band) / (double) maximum; + } + + public int getBandSample(int x, int z, int band) { + requireCoordinate(x, z); + if (band < 0 || band >= raster.getNumBands()) { + throw new IllegalArgumentException("Image band " + band + " is outside 0.." + (raster.getNumBands() - 1)); + } + return raster.getSample(x, z, band); + } + + public double getAlphaNormalized(int x, int z) { + requireCoordinate(x, z); + if (!hasAlpha()) { + return 1D; + } + int alphaBand = raster.getNumBands() - 1; + return getBandNormalized(x, z, alphaBand); + } + + public int getRawRgb8(int x, int z) { + requireCoordinate(x, z); + IrisImageColorMode mode = getColorMode(); + if (mode != IrisImageColorMode.RGB && mode != IrisImageColorMode.RGBA) { + throw new IllegalStateException("Raw RGB sampling requires an RGB or RGBA image, not " + mode); + } + int red = scaleTo8Bit(x, z, 0); + int green = scaleTo8Bit(x, z, 1); + int blue = scaleTo8Bit(x, z, 2); + return red << 16 | green << 8 | blue; + } + + public int getPreviewArgb(int x, int z) { + requireCoordinate(x, z); return image.getRGB(x, z); } - public double getValue(IrisImageChannel channel, int x, int z) { - int color = getRawValue(x, z); - - switch (channel) { - case RED -> { - return ((color >> 16) & 0xFF) / 255D; - } - case GREEN -> { - return ((color >> 8) & 0xFF) / 255D; - } - case BLUE -> { - return ((color) & 0xFF) / 255D; - } - case SATURATION -> { - return Color.RGBtoHSB((color >> 16) & 0xFF, (color >> 8) & 0xFF, (color) & 0xFF, null)[1]; - } - case HUE -> { - return Color.RGBtoHSB((color >> 16) & 0xFF, (color >> 8) & 0xFF, (color) & 0xFF, null)[0]; - } - case BRIGHTNESS -> { - return Color.RGBtoHSB((color >> 16) & 0xFF, (color >> 8) & 0xFF, (color) & 0xFF, null)[2]; - } - case COMPOSITE_ADD_RGB -> { - return ((((color >> 16) & 0xFF) / 255D) + (((color >> 8) & 0xFF) / 255D) + (((color) & 0xFF) / 255D)) / 3D; - } - case COMPOSITE_MUL_RGB -> { - return (((color >> 16) & 0xFF) / 255D) * (((color >> 8) & 0xFF) / 255D) * (((color) & 0xFF) / 255D); - } - case COMPOSITE_MAX_RGB -> { - return Math.max(Math.max((((color >> 16) & 0xFF) / 255D), (((color >> 8) & 0xFF) / 255D)), (((color) & 0xFF) / 255D)); - } - case COMPOSITE_ADD_HSB -> { - float[] hsb = Color.RGBtoHSB((color >> 16) & 0xFF, (color >> 8) & 0xFF, (color) & 0xFF, null); - return (hsb[0] + hsb[1] + hsb[2]) / 3D; - } - case COMPOSITE_MUL_HSB -> { - float[] hsb = Color.RGBtoHSB((color >> 16) & 0xFF, (color >> 8) & 0xFF, (color) & 0xFF, null); - return hsb[0] * hsb[1] * hsb[2]; - } - case COMPOSITE_MAX_HSB -> { - float[] hsb = Color.RGBtoHSB((color >> 16) & 0xFF, (color >> 8) & 0xFF, (color) & 0xFF, null); - return Math.max(hsb[0], Math.max(hsb[1], hsb[2])); - } - case RAW -> { - return color; - } + private int scaleTo8Bit(int x, int z, int band) { + int bits = raster.getSampleModel().getSampleSize(band); + int sample = raster.getSample(x, z, band); + if (bits == 8) { + return sample; } + long maximum = (1L << bits) - 1L; + return (int) Math.round(sample * 255D / maximum); + } - return color; + private void requireCoordinate(int x, int z) { + if (x < 0 || z < 0 || x >= getWidth() || z >= getHeight()) { + throw new IndexOutOfBoundsException("Image coordinate " + x + "," + z + " is outside " + getWidth() + "x" + getHeight()); + } } @Override diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisImageChannel.java b/core/src/main/java/art/arcane/iris/engine/object/IrisImageChannel.java deleted file mode 100644 index dd385bfea..000000000 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisImageChannel.java +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Iris is a World Generator for Minecraft Bukkit Servers - * Copyright (c) 2022 Arcane Arts (Volmit Software) - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package art.arcane.iris.engine.object; - -import art.arcane.iris.engine.object.annotations.Desc; - -@Desc("Determines a derived channel of an image to read") -public enum IrisImageChannel { - @Desc("The red channel of the image") - RED, - @Desc("Thge green channel of the image") - GREEN, - @Desc("The blue channel of the image") - BLUE, - @Desc("The saturation as a channel of the image") - SATURATION, - @Desc("The hue as a channel of the image") - HUE, - @Desc("The brightness as a channel of the image") - BRIGHTNESS, - @Desc("The composite of RGB as a channel of the image. Takes the average channel value (adding)") - COMPOSITE_ADD_RGB, - @Desc("The composite of RGB as a channel of the image. Multiplies the channels") - COMPOSITE_MUL_RGB, - @Desc("The composite of RGB as a channel of the image. Picks the highest channel") - COMPOSITE_MAX_RGB, - @Desc("The composite of HSB as a channel of the image Takes the average channel value (adding)") - COMPOSITE_ADD_HSB, - @Desc("The composite of HSB as a channel of the image Multiplies the channels") - COMPOSITE_MUL_HSB, - @Desc("The composite of HSB as a channel of the image Picks the highest channel") - COMPOSITE_MAX_HSB, - @Desc("The raw value as a channel (probably doesnt look very good)") - RAW -} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisImageColorMode.java b/core/src/main/java/art/arcane/iris/engine/object/IrisImageColorMode.java new file mode 100644 index 000000000..f588e8fb5 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisImageColorMode.java @@ -0,0 +1,9 @@ +package art.arcane.iris.engine.object; + +public enum IrisImageColorMode { + GRAYSCALE, + RGB, + RGBA, + INDEXED, + UNSUPPORTED +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisImageMap.java b/core/src/main/java/art/arcane/iris/engine/object/IrisImageMap.java index 8638a1fb5..2c84311e0 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisImageMap.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisImageMap.java @@ -1,105 +1,120 @@ -/* - * Iris is a World Generator for Minecraft Bukkit Servers - * Copyright (c) 2022 Arcane Arts (Volmit Software) - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - package art.arcane.iris.engine.object; -import art.arcane.iris.core.loader.IrisData; -import art.arcane.iris.engine.data.cache.AtomicCache; +import art.arcane.iris.core.loader.IrisRegistrant; import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; import art.arcane.iris.engine.object.annotations.MinNumber; import art.arcane.iris.engine.object.annotations.RegistryListResource; -import art.arcane.iris.engine.object.annotations.Snippet; -import art.arcane.iris.spi.IrisLogging; -import art.arcane.iris.util.project.interpolation.InterpolationMethod; -import art.arcane.iris.util.project.interpolation.IrisInterpolation; -import lombok.AllArgsConstructor; +import art.arcane.volmlib.util.collection.KMap; import lombok.Data; +import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import lombok.experimental.Accessors; -@Snippet("image-map") @Accessors(chain = true) @NoArgsConstructor -@AllArgsConstructor -@Desc("Represents an image map") +@Desc("A reusable typed image-map definition") @Data -public class IrisImageMap { +@EqualsAndHashCode(callSuper = false) +public class IrisImageMap extends IrisRegistrant { + public static final double MINIMUM_SCALE = 0.000001D; + public static final double MAXIMUM_COLOR_TOLERANCE = 441.672956D; + @RegistryListResource(IrisImage.class) - @Desc("Define the png image to read in this noise map") - private String image = ""; + @Desc("PNG source under images/, without the file extension") + private String source = ""; - @MinNumber(1) - @Desc("The amount of distance a single pixel is when reading this map, reading x=13, would still read pixel 0 if the scale is 32. You can zoom this externally through noise styles for zooming out.") - private double coordinateScale = 32; + @Desc("How source pixels are decoded") + private IrisImageMapType type = IrisImageMapType.GRAYSCALE_HEIGHT; - @Desc("The interpolation method if the coordinateScale is greater than 1. This blends the image into noise. For nearest neighbor, use NONE.") - private InterpolationMethod interpolationMethod = InterpolationMethod.BILINEAR_STARCAST_6; + @MinNumber(MINIMUM_SCALE) + @Desc("Minecraft blocks represented by one source pixel") + private double blocksPerPixel = 1D; - @Desc("The channel of the image to read from. This basically converts image data into a number betwen 0 to 1 per pixel using a certain 'channel/filter'") - private IrisImageChannel channel = IrisImageChannel.COMPOSITE_ADD_HSB; + @Desc("Minecraft X/Z coordinate that maps to sourceOrigin") + private IrisImageMapOrigin origin = new IrisImageMapOrigin(); - @Desc("Invert the channel input") + @Desc("Source pixel X/Y coordinate placed at origin") + private IrisImageMapOrigin sourceOrigin = new IrisImageMapOrigin(); + + @Desc("Clockwise quarter-turn rotation around sourceOrigin") + private IrisImageMapRotation rotation = IrisImageMapRotation.DEG_0; + + @Desc("Mirror image X around sourceOrigin before rotation") + private boolean mirrorX = false; + + @Desc("Mirror image Y around sourceOrigin before rotation") + private boolean mirrorZ = false; + + @Desc("Numeric sampling filter; exact color and binary maps require NEAREST") + private IrisImageMapSampling sampling = IrisImageMapSampling.NEAREST; + + @Desc("Behavior outside the source rectangle") + private IrisImageMapOutOfBounds outOfBounds = IrisImageMapOutOfBounds.FALLBACK; + + @MinNumber(0) + @MaxNumber(1) + @Desc("Normalized scalar used by FALLBACK coordinates") + private double fallbackValue = 0D; + + @Desc("Legend target used by FALLBACK or unknown color-map pixels") + private String fallbackTarget = ""; + + @Desc("How alpha affects decoded data") + private IrisImageMapAlpha alpha = IrisImageMapAlpha.IGNORE; + + @Desc("Minimum absolute world Y for height maps") + private double minimumHeight = -64D; + + @Desc("Maximum absolute world Y for height maps") + private double maximumHeight = 320D; + + @Desc("Vertical block offset applied after height decoding") + private double verticalOffset = 0D; + + @Desc("Clamp decoded height to minimumHeight and maximumHeight after offset") + private boolean clamp = true; + + @Desc("Invert decoded scalar values before curve evaluation") private boolean inverted = false; - @Desc("Tile the image coordinates") - private boolean tiled = false; + @MinNumber(MINIMUM_SCALE) + @Desc("Power curve applied after optional inversion; 1 is linear") + private double curveExponent = 1D; - @Desc("Center 0,0 to the center of the image instead of the top left.") - private boolean centered = true; + @MinNumber(0) + @MaxNumber(32) + @Desc("Load-time box smoothing radius in source pixels") + private int smoothingRadius = 0; - private transient AtomicCache imageCache = new AtomicCache(); + @MinNumber(0) + @MaxNumber(1) + @Desc("Binary mask threshold") + private double threshold = 0.5D; - public double getNoise(IrisData data, int x, int z) { - IrisImage i = imageCache.aquire(() -> data.getImageLoader().load(image)); - if (i == null) { - // Reached per sample for as long as the pack points at an image that will not load, so the - // pack key is what the operator needs and one statement of it is enough. - IrisLogging.warnOnce("image-map:" + image, "No image %s in the pack; that noise samples as zero.", image); - return 0; - } + @MinNumber(0) + @MaxNumber(1) + @Desc("Mask transition width above threshold") + private double falloff = 0D; - return IrisInterpolation.getNoise(interpolationMethod, x, z, coordinateScale, (xx, zz) -> rawNoise(i, xx, zz)); + @MinNumber(0) + @MaxNumber(MAXIMUM_COLOR_TOLERANCE) + @Desc("Euclidean raw sRGB distance accepted by tolerant color matching; zero is exact") + private double colorTolerance = 0D; + + @Desc("How colors absent from the legend are handled") + private IrisImageMapUnknownColor unknownColor = IrisImageMapUnknownColor.ERROR; + + @Desc("Exact #RRGGBB colors mapped to Iris resource or Minecraft block keys") + private KMap colors = new KMap<>(); + + @Override + public String getFolderName() { + return "image-maps"; } - private double rawNoise(IrisImage i, double x, double z) { - x /= coordinateScale; - z /= coordinateScale; - - // X and Z are now scaled to the image - - // Add half the image width & height if centered - if (isCentered()) { - x += i.getWidth() / 2D; - z += i.getHeight() / 2D; - } - - // If tiled modulo over width and height - if (isTiled()) { - x = x % i.getWidth(); - x = x < 0 ? x + i.getWidth() : x; // Fix java's negative modulo shit - z = z % i.getHeight(); - z = z < 0 ? z + i.getHeight() : z; // Fix java's negative modulo shit - } - - // Retrieve value from image - double v = i.getValue(getChannel(), (int) x, (int) z); - - // Return value, or 1 - value if inverted (value is in double set [0, 1] so this will return [0, 1]) - return isInverted() ? 1D - v : v; + @Override + public String getTypeName() { + return "Image Map"; } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisImageMapAlpha.java b/core/src/main/java/art/arcane/iris/engine/object/IrisImageMapAlpha.java new file mode 100644 index 000000000..d1956e241 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisImageMapAlpha.java @@ -0,0 +1,11 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; + +@Desc("How alpha participates in decoded data") +public enum IrisImageMapAlpha { + IGNORE, + MASK, + TRANSPARENT_IS_FALLBACK, + ERROR +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisImageMapApplication.java b/core/src/main/java/art/arcane/iris/engine/object/IrisImageMapApplication.java new file mode 100644 index 000000000..149a1c650 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisImageMapApplication.java @@ -0,0 +1,13 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; + +@Desc("The generation input controlled by an image map") +public enum IrisImageMapApplication { + TERRAIN_HEIGHT, + BIOME, + REGION, + SURFACE_BLOCK, + MASK, + CUSTOM +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisImageMapBinding.java b/core/src/main/java/art/arcane/iris/engine/object/IrisImageMapBinding.java new file mode 100644 index 000000000..d9156c710 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisImageMapBinding.java @@ -0,0 +1,29 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.ArrayType; +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.RegistryListResource; +import art.arcane.volmlib.util.collection.KList; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("Binds a reusable image map to one dimension generation role") +@Data +public class IrisImageMapBinding { + @Desc("Unique name used by Studio previews and custom map lookups") + private String key = ""; + + @RegistryListResource(IrisImageMap.class) + @Desc("Image-map resource key under image-maps/") + private String map = ""; + + @Desc("Generation input controlled by this binding") + private IrisImageMapApplication application = IrisImageMapApplication.CUSTOM; + + @ArrayType(type = IrisImageMapMask.class) + @Desc("Named mask maps composed in declaration order") + private KList masks = new KList<>(); +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisImageMapMask.java b/core/src/main/java/art/arcane/iris/engine/object/IrisImageMapMask.java new file mode 100644 index 000000000..1e2be7877 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisImageMapMask.java @@ -0,0 +1,33 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("A composable reference to a named MASK image map") +@Data +public class IrisImageMapMask { + @Desc("The key of a named imageMaps entry whose application is MASK") + private String map = ""; + + @Desc("How this mask combines with masks before it") + private IrisImageMapMaskOperation operation = IrisImageMapMaskOperation.MULTIPLY; + + @Desc("Invert this mask before combining it") + private boolean inverted = false; + + @MinNumber(0) + @MaxNumber(1) + @Desc("Values below this threshold become zero") + private double threshold = 0D; + + @MinNumber(0) + @MaxNumber(1) + @Desc("Soft transition width above threshold; zero is a hard edge") + private double falloff = 0D; +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisImageMapMaskOperation.java b/core/src/main/java/art/arcane/iris/engine/object/IrisImageMapMaskOperation.java new file mode 100644 index 000000000..265f7426e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisImageMapMaskOperation.java @@ -0,0 +1,12 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; + +@Desc("How a referenced mask combines with masks before it") +public enum IrisImageMapMaskOperation { + MULTIPLY, + MINIMUM, + MAXIMUM, + ADD, + SUBTRACT +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisImageMapOrigin.java b/core/src/main/java/art/arcane/iris/engine/object/IrisImageMapOrigin.java new file mode 100644 index 000000000..28dfcd3b1 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisImageMapOrigin.java @@ -0,0 +1,20 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("A two-dimensional image-map coordinate") +@Data +public class IrisImageMapOrigin { + private double x = 0D; + private double z = 0D; + + public IrisImageMapOrigin(double x, double z) { + this.x = x; + this.z = z; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisImageMapOutOfBounds.java b/core/src/main/java/art/arcane/iris/engine/object/IrisImageMapOutOfBounds.java new file mode 100644 index 000000000..d42c5a5d5 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisImageMapOutOfBounds.java @@ -0,0 +1,12 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; + +@Desc("How coordinates outside an image are resolved") +public enum IrisImageMapOutOfBounds { + FALLBACK, + CLAMP, + REPEAT, + MIRROR, + ERROR +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisImageMapRotation.java b/core/src/main/java/art/arcane/iris/engine/object/IrisImageMapRotation.java new file mode 100644 index 000000000..473b83ff2 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisImageMapRotation.java @@ -0,0 +1,11 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; + +@Desc("Clockwise image-map rotation around sourceOrigin") +public enum IrisImageMapRotation { + DEG_0, + DEG_90, + DEG_180, + DEG_270 +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisImageMapSampling.java b/core/src/main/java/art/arcane/iris/engine/object/IrisImageMapSampling.java new file mode 100644 index 000000000..952253d8a --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisImageMapSampling.java @@ -0,0 +1,10 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; + +@Desc("The filter used between source pixels") +public enum IrisImageMapSampling { + NEAREST, + BILINEAR, + BICUBIC +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisImageMapType.java b/core/src/main/java/art/arcane/iris/engine/object/IrisImageMapType.java new file mode 100644 index 000000000..6e885cc1a --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisImageMapType.java @@ -0,0 +1,13 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; + +@Desc("How Iris decodes each source pixel") +public enum IrisImageMapType { + GRAYSCALE_HEIGHT, + RGB_HEIGHT, + COLOR_MAP, + BINARY_MASK, + GRAYSCALE_MASK, + ALPHA_MASK +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisImageMapUnknownColor.java b/core/src/main/java/art/arcane/iris/engine/object/IrisImageMapUnknownColor.java new file mode 100644 index 000000000..e18e11e90 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisImageMapUnknownColor.java @@ -0,0 +1,10 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; + +@Desc("How a color absent from a color-map legend is handled") +public enum IrisImageMapUnknownColor { + ERROR, + FALLBACK, + IGNORE +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisWorldBoundary.java b/core/src/main/java/art/arcane/iris/engine/object/IrisWorldBoundary.java new file mode 100644 index 000000000..57129644f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisWorldBoundary.java @@ -0,0 +1,91 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +import java.util.Objects; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("The native Minecraft world border applied to worlds using this dimension") +@Data +public class IrisWorldBoundary { + public static final double DEFAULT_DAMAGE_AMOUNT = 0.2D; + public static final double DEFAULT_DAMAGE_BUFFER = 5D; + public static final double MAXIMUM_CENTER = 29_999_984D; + public static final double MAXIMUM_SIZE = 59_999_968D; + + @Desc("The center of the boundary") + private IrisWorldBoundaryCenter center = new IrisWorldBoundaryCenter(); + + @MinNumber(1) + @MaxNumber(59_999_968) + @Desc("The full border diameter in blocks, matching Minecraft world-border terminology") + private double size = 16_384D; + + @MinNumber(0) + @MaxNumber(Integer.MAX_VALUE) + @Desc("Distance from the border at which the client warning begins") + private int warningDistance = 16; + + @MinNumber(0) + @Desc("Safe distance outside the border before damage begins") + private double damageBuffer = DEFAULT_DAMAGE_BUFFER; + + @MinNumber(0) + @Desc("Damage per block beyond damageBuffer") + private double damageAmount = DEFAULT_DAMAGE_AMOUNT; + + public static IrisWorldBoundary snapshot(IrisWorldBoundary configured) { + IrisWorldBoundary source = Objects.requireNonNull(configured, "Configured worldBoundary"); + source.validate(); + return new IrisWorldBoundary() + .setCenter(new IrisWorldBoundaryCenter(source.getCenter().getX(), source.getCenter().getZ())) + .setSize(source.getSize()) + .setWarningDistance(source.getWarningDistance()) + .setDamageBuffer(source.getDamageBuffer()) + .setDamageAmount(source.getDamageAmount()); + } + + public void validate() { + if (center == null) { + throw new IllegalArgumentException("worldBoundary.center is required"); + } + if (!Double.isFinite(center.getX()) || Math.abs(center.getX()) > MAXIMUM_CENTER + || !Double.isFinite(center.getZ()) || Math.abs(center.getZ()) > MAXIMUM_CENTER) { + throw new IllegalArgumentException("worldBoundary.center must be finite and within +/-" + MAXIMUM_CENTER); + } + if (!Double.isFinite(size) || size < 1D || size > MAXIMUM_SIZE) { + throw new IllegalArgumentException("worldBoundary.size must be between 1 and " + MAXIMUM_SIZE); + } + if (warningDistance < 0) { + throw new IllegalArgumentException("worldBoundary.warningDistance cannot be negative"); + } + if (!Double.isFinite(damageBuffer) || damageBuffer < 0D) { + throw new IllegalArgumentException("worldBoundary.damageBuffer must be finite and non-negative"); + } + if (!Double.isFinite(damageAmount) || damageAmount < 0D) { + throw new IllegalArgumentException("worldBoundary.damageAmount must be finite and non-negative"); + } + } + + public double minimumX() { + return center.getX() - size / 2D; + } + + public double maximumX() { + return center.getX() + size / 2D; + } + + public double minimumZ() { + return center.getZ() - size / 2D; + } + + public double maximumZ() { + return center.getZ() + size / 2D; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisWorldBoundaryCenter.java b/core/src/main/java/art/arcane/iris/engine/object/IrisWorldBoundaryCenter.java new file mode 100644 index 000000000..a6d7d8d56 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisWorldBoundaryCenter.java @@ -0,0 +1,29 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("The world-border center in block coordinates") +@Data +public class IrisWorldBoundaryCenter { + @MinNumber(-29_999_984) + @MaxNumber(29_999_984) + @Desc("The world-border center X coordinate") + private double x = 0D; + + @MinNumber(-29_999_984) + @MaxNumber(29_999_984) + @Desc("The world-border center Z coordinate") + private double z = 0D; + + public IrisWorldBoundaryCenter(double x, double z) { + this.x = x; + this.z = z; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java b/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java index 2fc4bb545..e2364209f 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java @@ -191,6 +191,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun return false; } INMS.get().inject(world.getSeed(), engine, world); + engine.getPlatformHooks().applyWorldBoundary(engine); IrisLogging.debug("Injected Iris Biome Source into " + world.getName()); if (!studio) { J.s(() -> updateSpawnLocation(world), 1); diff --git a/core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java b/core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java index 29c19d064..51a5b035a 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java @@ -52,6 +52,7 @@ import art.arcane.volmlib.util.mantle.runtime.MantleChunk; import art.arcane.volmlib.util.math.M; import art.arcane.volmlib.util.math.Position2; import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.plugin.ComponentMessenger; import art.arcane.volmlib.util.matter.Matter; import art.arcane.volmlib.util.matter.MatterCavern; import art.arcane.volmlib.util.matter.MatterUpdate; @@ -551,7 +552,7 @@ public final class EngineBukkitOps { public static void gotoRegion(Engine engine, IrisRegion r, Player player, boolean teleport) { if (!engine.getDimension().getRegions().contains(r.getLoadKey())) { - player.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.ENGINE_BUKKIT_OPS_IS_NOT_DEFINED_DIMENSION, MessageArgument.untrusted("name", String.valueOf(r.getName())))); + ComponentMessenger.sendSection(player, IrisLanguage.text(BukkitRuntimeMessages.ENGINE_BUKKIT_OPS_IS_NOT_DEFINED_DIMENSION, MessageArgument.untrusted("name", String.valueOf(r.getName())))); return; } @@ -569,14 +570,14 @@ public final class EngineBukkitOps { private static void find(Engine engine, Locator locator, Player player, boolean teleport, String message) { find(engine, locator, player, 120_000, location -> { if (location == null) { - player.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.ENGINE_BUKKIT_OPS_COULD_NOT_FIND_WITHIN_SEARCH_RANGE, MessageArgument.untrusted("message", String.valueOf(message)))); + ComponentMessenger.sendSection(player, IrisLanguage.text(BukkitRuntimeMessages.ENGINE_BUKKIT_OPS_COULD_NOT_FIND_WITHIN_SEARCH_RANGE, MessageArgument.untrusted("message", String.valueOf(message)))); return; } if (teleport) { J.runEntity(player, () -> teleportAsyncSafely(player, location)); - player.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.ENGINE_BUKKIT_OPS_TELEPORTING, MessageArgument.untrusted("message", String.valueOf(message)))); + ComponentMessenger.sendSection(player, IrisLanguage.text(BukkitRuntimeMessages.ENGINE_BUKKIT_OPS_TELEPORTING, MessageArgument.untrusted("message", String.valueOf(message)))); } else { - player.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.ENGINE_BUKKIT_OPS_AT, MessageArgument.untrusted("message", String.valueOf(message)), MessageArgument.untrusted("blockX", String.valueOf(location.getBlockX())), MessageArgument.untrusted("blockY", String.valueOf(location.getBlockY())), MessageArgument.untrusted("blockZ", String.valueOf(location.getBlockZ())))); + ComponentMessenger.sendSection(player, IrisLanguage.text(BukkitRuntimeMessages.ENGINE_BUKKIT_OPS_AT, MessageArgument.untrusted("message", String.valueOf(message)), MessageArgument.untrusted("blockX", String.valueOf(location.getBlockX())), MessageArgument.untrusted("blockY", String.valueOf(location.getBlockY())), MessageArgument.untrusted("blockZ", String.valueOf(location.getBlockZ())))); } }); } diff --git a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitPlatform.java b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitPlatform.java index ca9587a30..2201f5f3f 100644 --- a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitPlatform.java +++ b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitPlatform.java @@ -30,7 +30,6 @@ import art.arcane.iris.spi.PlatformRegistries; import art.arcane.iris.spi.PlatformScheduler; import art.arcane.iris.spi.PlatformStructureHooks; import art.arcane.iris.spi.PlatformWorld; -import art.arcane.iris.util.common.misc.Bindings; import art.arcane.iris.util.common.plugin.VolmitPlugin; import art.arcane.iris.util.common.plugin.VolmitSender; import art.arcane.volmlib.util.collection.KMap; @@ -65,7 +64,6 @@ import java.util.function.Supplier; */ public final class BukkitPlatform implements IrisPlatform { private static volatile Plugin PLUGIN; - private static volatile Bindings.Adventure AUDIENCES; private static volatile HudActionBar HUD_BAR; private static volatile HudBossBarLane HUD_LANES; private static volatile Supplier CONSOLE; @@ -124,18 +122,6 @@ public final class BukkitPlatform implements IrisPlatform { throw new IllegalStateException("Hosted Iris plugin is not a VolmitPlugin"); } - public static void hostAudiences(Bindings.Adventure adventure) { - AUDIENCES = adventure; - } - - public static Bindings.Adventure audiences() { - Bindings.Adventure adventure = AUDIENCES; - if (adventure == null) { - throw new IllegalStateException("No Iris adventure audiences are hosted"); - } - return adventure; - } - public static void hostHud(HudActionBar hudBar, HudBossBarLane hudLanes) { HUD_BAR = hudBar; HUD_LANES = hudLanes; diff --git a/core/src/main/java/art/arcane/iris/util/common/misc/Bindings.java b/core/src/main/java/art/arcane/iris/util/common/misc/Bindings.java index 7c5c376f4..7cda961be 100644 --- a/core/src/main/java/art/arcane/iris/util/common/misc/Bindings.java +++ b/core/src/main/java/art/arcane/iris/util/common/misc/Bindings.java @@ -14,21 +14,14 @@ import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.engine.platform.PlatformChunkGenerator; import art.arcane.iris.util.project.context.IrisContext; import art.arcane.volmlib.util.json.JSONException; -import art.arcane.volmlib.util.reflect.ShadeFix; import art.arcane.iris.util.common.plugin.VolmitPlugin; import art.arcane.iris.util.common.scheduling.J; import io.sentry.Sentry; -import net.kyori.adventure.audience.Audience; -import net.kyori.adventure.platform.bukkit.BukkitAudiences; -import net.kyori.adventure.text.serializer.ComponentSerializer; import org.bstats.bukkit.Metrics; import org.bstats.charts.DrilldownPie; import org.bstats.charts.SingleLineChart; import org.bukkit.Bukkit; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; -import org.bukkit.plugin.Plugin; import java.util.HashMap; import java.util.Map; @@ -123,21 +116,4 @@ public class Bindings { plugin.postShutdown(metrics::shutdown); }); } - - public static class Adventure { - private final BukkitAudiences audiences; - - public Adventure(Plugin plugin) { - ShadeFix.fix(ComponentSerializer.class); - this.audiences = BukkitAudiences.create(plugin); - } - - public Audience player(Player player) { - return audiences.player(player); - } - - public Audience sender(CommandSender sender) { - return audiences.sender(sender); - } - } } diff --git a/core/src/main/java/art/arcane/iris/util/common/misc/WebCache.java b/core/src/main/java/art/arcane/iris/util/common/misc/WebCache.java index 91e7013b4..e5bdb142b 100644 --- a/core/src/main/java/art/arcane/iris/util/common/misc/WebCache.java +++ b/core/src/main/java/art/arcane/iris/util/common/misc/WebCache.java @@ -27,10 +27,10 @@ import java.io.IOException; import java.io.InputStream; import java.io.InterruptedIOException; import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.SocketTimeoutException; import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; +import java.net.URLConnection; import java.nio.charset.StandardCharsets; import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.Files; @@ -38,6 +38,7 @@ import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; import java.time.Duration; +import java.util.Objects; import java.util.concurrent.TimeUnit; /** @@ -48,14 +49,16 @@ import java.util.concurrent.TimeUnit; */ public final class WebCache { private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10L); - private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(120L); + private static final DownloadPolicy DOWNLOAD_POLICY = new DownloadPolicy( + Duration.ofSeconds(10L), + 3, + Duration.ofSeconds(1L) + ); private static final int BUFFER_SIZE = 8192; private static final long PROGRESS_INTERVAL_NANOS = Duration.ofMillis(250L).toNanos(); private static final TransferProgressListener NO_TRANSFER_PROGRESS = progress -> { }; - private static volatile HttpClient client; - private WebCache() { } @@ -89,24 +92,31 @@ public final class WebCache { } } - public static File getNonCachedFile(String name, String url) { + public static File getNonCachedFile(String name, String url) throws IOException { return getNonCachedFile(name, url, Long.MAX_VALUE); } - public static File getNonCachedFile(String name, String url, long maxBytes) { + public static File getNonCachedFile(String name, String url, long maxBytes) throws IOException { return getNonCachedFile(name, url, maxBytes, NO_TRANSFER_PROGRESS); } - public static File getNonCachedFile(String name, String url, TransferProgressListener progressListener) { + public static File getNonCachedFile(String name, String url, + TransferProgressListener progressListener) throws IOException { return getNonCachedFile(name, url, Long.MAX_VALUE, progressListener); } public static File getNonCachedFile(String name, String url, long maxBytes, - TransferProgressListener progressListener) { + TransferProgressListener progressListener) throws IOException { + return getNonCachedFile(name, url, maxBytes, DOWNLOAD_POLICY, progressListener); + } + + static File getNonCachedFile(String name, String url, long maxBytes, DownloadPolicy policy, + TransferProgressListener progressListener) throws IOException { String h = IO.hash(name + "*" + url); File f = IrisPlatforms.get().dataFile("cache", h.substring(0, 2), h.substring(3, 5), h); IrisLogging.debug("Download " + name); - return download(name, url, f, maxBytes, progressListener) ? f : null; + download(name, url, f, maxBytes, policy, progressListener); + return f; } private static boolean download(String name, String url, File target) { @@ -119,103 +129,79 @@ public final class WebCache { private static boolean download(String name, String url, File target, long maxBytes, TransferProgressListener progressListener) { + try { + download(name, url, target, maxBytes, DOWNLOAD_POLICY, progressListener); + return true; + } catch (InterruptedIOException exception) { + if (Thread.currentThread().isInterrupted()) { + IrisLogging.debug("Download interrupted for " + name); + } else { + IrisLogging.reportError(exception); + } + return false; + } catch (IOException exception) { + IrisLogging.reportError(exception); + return false; + } + } + + private static void download(String name, String url, File target, long maxBytes, DownloadPolicy policy, + TransferProgressListener progressListener) throws IOException { if (maxBytes < 1L) { throw new IllegalArgumentException("Download size limit must be positive."); } + DownloadPolicy downloadPolicy = Objects.requireNonNull(policy, "policy"); TransferProgressListener progress = progressListener == null ? NO_TRANSFER_PROGRESS : progressListener; - HttpRequest request = HttpRequest.newBuilder(URI.create(url)) - .timeout(REQUEST_TIMEOUT) - .GET() - .build(); + DownloadFailure lastFailure = null; + for (int attempt = 1; attempt <= downloadPolicy.attempts(); attempt++) { + checkInterrupted(); + try { + downloadAttempt(name, url, target, maxBytes, downloadPolicy.readTimeout(), progress); + return; + } catch (DownloadFailure failure) { + lastFailure = failure; + if (!failure.retryable() || attempt == downloadPolicy.attempts()) { + if (failure.retryable() && attempt > 1) { + throw new DownloadFailure( + failure.getMessage() + " Download failed after " + attempt + " attempts.", + false, + failure + ); + } + throw failure; + } + awaitRetry(downloadPolicy.retryDelay().multipliedBy(attempt)); + } + } + throw Objects.requireNonNull(lastFailure, "lastFailure"); + } + + private static void downloadAttempt(String name, String url, File target, long maxBytes, Duration readTimeout, + TransferProgressListener progress) throws IOException { Path staged = null; try { checkInterrupted(); - HttpResponse response = client() - .send(request, HttpResponse.BodyHandlers.ofInputStream()); - if (response.statusCode() / 100 != 2) { - response.body().close(); - IrisLogging.reportError(new IOException("HTTP " + response.statusCode() - + " downloading " + name)); - return false; - } - long declaredBytes = response.headers().firstValueAsLong("Content-Length").orElse(-1L); - if (declaredBytes > maxBytes) { - response.body().close(); - throw new IOException("Download exceeds the size limit for " + name + "."); - } Path destination = target.toPath().toAbsolutePath().normalize(); Path parent = destination.getParent(); if (parent == null) { - response.body().close(); - throw new IOException("Download target has no parent: " + destination); + throw new DownloadFailure("Download target has no parent: " + destination, false); } - Files.createDirectories(parent); - staged = Files.createTempFile(parent, ".download-", ".tmp"); - long startedNanos = System.nanoTime(); - long lastProgressNanos = startedNanos; - sendProgress(progress, new TransferProgress(0L, declaredBytes, 0L, false)); - long downloadedBytes = 0L; - try (InputStream in = response.body(); - OutputStream out = Files.newOutputStream(staged, StandardOpenOption.WRITE)) { - byte[] buffer = new byte[BUFFER_SIZE]; - int read; - while (true) { - checkInterrupted(); - read = in.read(buffer); - if (read == -1) { - break; - } - checkInterrupted(); - if (read > maxBytes - downloadedBytes) { - throw new IOException("Download exceeds the size limit for " + name + "."); - } - out.write(buffer, 0, read); - downloadedBytes += read; - long currentNanos = System.nanoTime(); - if (currentNanos - lastProgressNanos >= PROGRESS_INTERVAL_NANOS) { - sendProgress(progress, new TransferProgress( - downloadedBytes, - declaredBytes, - elapsedMillis(startedNanos, currentNanos), - false - )); - lastProgressNanos = currentNanos; - } - } - out.flush(); + try { + Files.createDirectories(parent); + staged = Files.createTempFile(parent, ".download-", ".tmp"); + } catch (IOException exception) { + throw new DownloadFailure("Unable to stage download " + name + ".", false, exception); } - sendProgress(progress, new TransferProgress( - downloadedBytes, - declaredBytes, - elapsedMillis(startedNanos), - true - )); + transfer(name, url, staged, maxBytes, readTimeout, progress); checkInterrupted(); try { Files.move(staged, destination, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); } catch (AtomicMoveNotSupportedException unsupported) { Files.move(staged, destination, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException exception) { + throw new DownloadFailure("Unable to publish download " + name + ".", false, exception); } staged = null; - return true; - } catch (InterruptedIOException e) { - if (Thread.currentThread().isInterrupted()) { - IrisLogging.debug("Download interrupted for " + name); - } else { - IrisLogging.reportError(e); - } - return false; - } catch (IOException e) { - if (Thread.currentThread().isInterrupted()) { - IrisLogging.debug("Download interrupted for " + name); - return false; - } - IrisLogging.reportError(e); - return false; - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - IrisLogging.debug("Download interrupted for " + name); - return false; } finally { if (staged != null) { try { @@ -227,6 +213,165 @@ public final class WebCache { } } + private static void transfer(String name, String url, Path staged, long maxBytes, Duration readTimeout, + TransferProgressListener progress) throws IOException { + HttpURLConnection connection = null; + Thread interruptionWatchdog = null; + boolean responseStarted = false; + try { + URLConnection opened = URI.create(url).toURL().openConnection(); + if (!(opened instanceof HttpURLConnection httpConnection)) { + throw new DownloadFailure("Download URL is not HTTP or HTTPS.", false); + } + connection = httpConnection; + connection.setConnectTimeout(timeoutMillis(CONNECT_TIMEOUT)); + connection.setReadTimeout(timeoutMillis(readTimeout)); + connection.setInstanceFollowRedirects(true); + connection.setRequestMethod("GET"); + interruptionWatchdog = startInterruptionWatchdog(connection); + int statusCode = connection.getResponseCode(); + if (statusCode / 100 != 2) { + closeErrorResponse(connection); + throw new DownloadFailure( + "HTTP " + statusCode + " while downloading " + name + ".", + isRetryableStatus(statusCode) + ); + } + responseStarted = true; + long declaredBytes = connection.getContentLengthLong(); + if (declaredBytes > maxBytes) { + throw new DownloadFailure("Download exceeds the size limit for " + name + ".", false); + } + streamResponse(name, connection, staged, maxBytes, declaredBytes, progress); + } catch (DownloadFailure exception) { + throw exception; + } catch (InterruptedIOException exception) { + if (Thread.currentThread().isInterrupted()) { + throw exception; + } + String message = responseStarted && exception instanceof SocketTimeoutException + ? "Download of " + name + " stalled for " + readTimeout.toSeconds() + + " seconds without receiving data." + : "Connection timed out or was interrupted while downloading " + name + "."; + throw new DownloadFailure(message, true, exception); + } catch (IOException exception) { + if (Thread.currentThread().isInterrupted()) { + InterruptedIOException interrupted = new InterruptedIOException("Download interrupted."); + interrupted.initCause(exception); + throw interrupted; + } + throw new DownloadFailure("Network failure while downloading " + name + ": " + + errorDetail(exception), true, exception); + } catch (IllegalArgumentException exception) { + throw new DownloadFailure("Invalid download URL for " + name + ".", false, exception); + } finally { + if (interruptionWatchdog != null) { + interruptionWatchdog.interrupt(); + } + if (connection != null) { + connection.disconnect(); + } + } + } + + private static void streamResponse(String name, HttpURLConnection connection, Path staged, long maxBytes, + long declaredBytes, TransferProgressListener progress) throws IOException { + long startedNanos = System.nanoTime(); + long lastProgressNanos = startedNanos; + sendProgress(progress, new TransferProgress(0L, declaredBytes, 0L, false)); + long downloadedBytes = 0L; + try (InputStream in = connection.getInputStream(); + OutputStream out = Files.newOutputStream(staged, StandardOpenOption.WRITE)) { + byte[] buffer = new byte[BUFFER_SIZE]; + int read; + while (true) { + checkInterrupted(); + read = in.read(buffer); + if (read == -1) { + break; + } + checkInterrupted(); + if (read > maxBytes - downloadedBytes) { + throw new DownloadFailure("Download exceeds the size limit for " + name + ".", false); + } + out.write(buffer, 0, read); + downloadedBytes += read; + long currentNanos = System.nanoTime(); + if (currentNanos - lastProgressNanos >= PROGRESS_INTERVAL_NANOS) { + sendProgress(progress, new TransferProgress( + downloadedBytes, + declaredBytes, + elapsedMillis(startedNanos, currentNanos), + false + )); + lastProgressNanos = currentNanos; + } + } + out.flush(); + } + if (declaredBytes >= 0L && downloadedBytes != declaredBytes) { + throw new DownloadFailure( + "Download of " + name + " ended after " + downloadedBytes + " of " + declaredBytes + " bytes.", + true + ); + } + sendProgress(progress, new TransferProgress( + downloadedBytes, + declaredBytes, + elapsedMillis(startedNanos), + true + )); + } + + private static void awaitRetry(Duration delay) throws InterruptedIOException { + try { + TimeUnit.MILLISECONDS.sleep(delay.toMillis()); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + InterruptedIOException interrupted = new InterruptedIOException("Download retry interrupted."); + interrupted.initCause(exception); + throw interrupted; + } + } + + private static Thread startInterruptionWatchdog(HttpURLConnection connection) { + Thread worker = Thread.currentThread(); + Thread watchdog = new Thread(() -> { + while (!worker.isInterrupted()) { + try { + Thread.sleep(100L); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + return; + } + } + connection.disconnect(); + }, "Iris Web Download Watchdog"); + watchdog.setDaemon(true); + watchdog.start(); + return watchdog; + } + + private static boolean isRetryableStatus(int statusCode) { + return statusCode == 408 || statusCode == 425 || statusCode == 429 || statusCode >= 500; + } + + private static int timeoutMillis(Duration timeout) { + return Math.toIntExact(Math.min(Integer.MAX_VALUE, timeout.toMillis())); + } + + private static void closeErrorResponse(HttpURLConnection connection) throws IOException { + InputStream response = connection.getErrorStream(); + if (response != null) { + response.close(); + } + } + + private static String errorDetail(IOException exception) { + String message = exception.getMessage(); + return message == null || message.isBlank() ? exception.getClass().getSimpleName() : message; + } + private static void checkInterrupted() throws InterruptedIOException { if (Thread.currentThread().isInterrupted()) { throw new InterruptedIOException("Download interrupted."); @@ -249,27 +394,39 @@ public final class WebCache { } } - private static HttpClient client() { - HttpClient current = client; - if (current != null) { - return current; - } - synchronized (WebCache.class) { - if (client == null) { - client = HttpClient.newBuilder() - .connectTimeout(CONNECT_TIMEOUT) - .followRedirects(HttpClient.Redirect.NORMAL) - .build(); - } - return client; - } + public record TransferProgress(long transferredBytes, long contentLength, long elapsedMillis, boolean complete) { } - public record TransferProgress(long transferredBytes, long contentLength, long elapsedMillis, boolean complete) { + record DownloadPolicy(Duration readTimeout, int attempts, Duration retryDelay) { + DownloadPolicy { + Objects.requireNonNull(readTimeout, "readTimeout"); + Objects.requireNonNull(retryDelay, "retryDelay"); + if (readTimeout.isZero() || readTimeout.isNegative() || attempts < 1 || retryDelay.isNegative()) { + throw new IllegalArgumentException("Download policy values are invalid."); + } + } } @FunctionalInterface public interface TransferProgressListener { void onProgress(TransferProgress progress); } + + private static final class DownloadFailure extends IOException { + private final boolean retryable; + + private DownloadFailure(String message, boolean retryable) { + super(message); + this.retryable = retryable; + } + + private DownloadFailure(String message, boolean retryable, Throwable cause) { + super(message, cause); + this.retryable = retryable; + } + + private boolean retryable() { + return retryable; + } + } } diff --git a/core/src/main/java/art/arcane/iris/util/common/plugin/VolmitSender.java b/core/src/main/java/art/arcane/iris/util/common/plugin/VolmitSender.java index fb46fed72..6a2678351 100644 --- a/core/src/main/java/art/arcane/iris/util/common/plugin/VolmitSender.java +++ b/core/src/main/java/art/arcane/iris/util/common/plugin/VolmitSender.java @@ -18,13 +18,14 @@ package art.arcane.iris.util.common.plugin; -import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.core.IrisSettings; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.hud.HudPriority; import art.arcane.volmlib.util.hud.HudSegment; import art.arcane.volmlib.util.hud.HudSlot; +import art.arcane.volmlib.util.plugin.ComponentMessenger; +import art.arcane.volmlib.util.plugin.ComponentText; import art.arcane.iris.util.common.format.C; import art.arcane.volmlib.util.format.Form; import art.arcane.volmlib.util.math.M; @@ -32,10 +33,6 @@ import lombok.Getter; import lombok.Setter; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.minimessage.MiniMessage; -import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer; -import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; -import net.md_5.bungee.api.ChatMessageType; -import net.md_5.bungee.api.chat.TextComponent; import org.bukkit.Server; import org.bukkit.Sound; import org.bukkit.command.CommandSender; @@ -217,7 +214,7 @@ public class VolmitSender implements CommandSender { } public void hr() { - s.sendMessage("========================================================"); + ComponentMessenger.sendLiteral(s, "========================================================"); } public void sendProgress(double percent, String thing) { @@ -228,21 +225,22 @@ public class VolmitSender implements CommandSender { public void sendAction(String action) { try { - deliverAction(LegacyComponentSerializer.legacySection().serialize(createNoPrefixComponent(action))); + deliverAction(createNoPrefixComponent(action)); } catch (Throwable ignored) { } } public void sendActionNoProcessing(String action) { try { - deliverAction(LegacyComponentSerializer.legacySection().serialize(createNoPrefixComponentNoProcessing(action))); + deliverAction(createNoPrefixComponentNoProcessing(action)); } catch (Throwable ignored) { } } - private void deliverAction(String legacy) { + private void deliverAction(ComponentText message) { Player player = player(); if (BukkitPlatform.hasHud()) { + String legacy = message.legacy(); if (legacy.isBlank()) { BukkitPlatform.hudBar().clear(player, "iris:action"); } else { @@ -250,54 +248,56 @@ public class VolmitSender implements CommandSender { } return; } - player.spigot().sendMessage(ChatMessageType.ACTION_BAR, TextComponent.fromLegacyText(legacy)); + ComponentMessenger.sendActionBar(player, message); } - private Component createNoPrefixComponent(String message) { + private ComponentText createNoPrefixComponent(String message) { if (!IrisSettings.get().getGeneral().canUseCustomColors(this)) { String t = C.translateAlternateColorCodes('&', MiniMessage.miniMessage().stripTags(message)); - return MiniMessage.miniMessage().deserialize(C.mini(t)); + return ComponentText.markup(C.mini(t)); } String t = C.translateAlternateColorCodes('&', message); String a = C.aura(t, IrisSettings.get().getGeneral().getSpinh(), IrisSettings.get().getGeneral().getSpins(), IrisSettings.get().getGeneral().getSpinb(), 0.36); - return MiniMessage.miniMessage().deserialize(a); + return ComponentText.markup(a); } - private Component createNoPrefixComponentNoProcessing(String message) { - return MiniMessage.builder().postProcessor(c -> c).build().deserialize(C.mini(message)); + private ComponentText createNoPrefixComponentNoProcessing(String message) { + return ComponentText.markup(C.mini(message)); } - private Component createComponent(String message) { + private ComponentText createComponent(String message) { if (!IrisSettings.get().getGeneral().canUseCustomColors(this)) { String t = C.translateAlternateColorCodes('&', MiniMessage.miniMessage().stripTags(getTag() + message)); - return MiniMessage.miniMessage().deserialize(C.mini(t)); + return ComponentText.markup(C.mini(t)); } String t = C.translateAlternateColorCodes('&', getTag() + message); String a = C.aura(t, IrisSettings.get().getGeneral().getSpinh(), IrisSettings.get().getGeneral().getSpins(), IrisSettings.get().getGeneral().getSpinb()); - return MiniMessage.miniMessage().deserialize(a); + return ComponentText.markup(a); } - private Component createComponentRaw(String message) { + private ComponentText createComponentRaw(String message) { if (!IrisSettings.get().getGeneral().canUseCustomColors(this)) { String t = C.translateAlternateColorCodes('&', MiniMessage.miniMessage().stripTags(getTag() + message)); - return MiniMessage.miniMessage().deserialize(C.mini(t)); + return ComponentText.markup(C.mini(t)); } String t = C.translateAlternateColorCodes('&', getTag() + message); - return MiniMessage.miniMessage().deserialize(C.mini(t)); + return ComponentText.markup(C.mini(t)); } @Override public void sendMessage(String message) { if ((!IrisSettings.get().getGeneral().isUseCustomColorsIngame() && s instanceof Player) || !IrisSettings.get().getGeneral().isUseConsoleCustomColors()) { - s.sendMessage(C.translateAlternateColorCodes('&', getTag() + message)); + ComponentMessenger.sendSection(s, C.translateAlternateColorCodes('&', getTag() + message)); return; } if (message.contains("")) { - s.sendMessage(C.translateAlternateColorCodes('&', getTag() + message.replaceAll("\\Q\\E", ""))); + ComponentMessenger.sendSection( + s, + C.translateAlternateColorCodes('&', getTag() + message.replace("", ""))); return; } @@ -305,17 +305,17 @@ public class VolmitSender implements CommandSender { } public void sendMessageBasic(String message) { - s.sendMessage(C.translateAlternateColorCodes('&', getTag() + message)); + ComponentMessenger.sendSection(s, C.translateAlternateColorCodes('&', getTag() + message)); } public void sendMessageRaw(String message) { if ((!IrisSettings.get().getGeneral().isUseCustomColorsIngame() && s instanceof Player) || !IrisSettings.get().getGeneral().isUseConsoleCustomColors()) { - s.sendMessage(C.translateAlternateColorCodes('&', message)); + ComponentMessenger.sendSection(s, C.translateAlternateColorCodes('&', message)); return; } if (message.contains("")) { - s.sendMessage(message.replaceAll("\\Q\\E", "")); + ComponentMessenger.sendSection(s, message.replace("", "")); return; } @@ -323,103 +323,11 @@ public class VolmitSender implements CommandSender { } public void sendComponent(Component component) { - deliver(component); + deliver(ComponentText.component(component)); } - private void deliver(Component component) { - if (sendNative(s, component)) { - return; - } - s.sendMessage(LegacyComponentSerializer.legacySection().serialize(component)); - } - - private static volatile boolean nativeProbed = false; - private static Object nativeGson; - private static java.lang.reflect.Method nativeDeserialize; - private static java.lang.reflect.Method nativeSendMessage; - private static final java.util.concurrent.atomic.AtomicBoolean SEND_LOGGED = new java.util.concurrent.atomic.AtomicBoolean(false); - - private static void adventureDebug(String message) { - try { - java.io.File f = IrisPlatforms.get().dataFile("adventure-debug.txt"); - java.nio.file.Files.writeString(f.toPath(), message + "\n", java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.APPEND); - } catch (Throwable ignored) { - } - } - - private static String nativeAdventure(String suffix) { - return new String(new char[]{'n', 'e', 't', '.', 'k', 'y', 'o', 'r', 'i', '.', 'a', 'd', 'v', 'e', 'n', 't', 'u', 'r', 'e', '.', 't', 'e', 'x', 't', '.'}) + suffix; - } - - private static void probeNative() { - String step = "start"; - try { - String[] candidates = {nativeAdventure("serializer.gson.GsonComponentSerializer"), nativeAdventure("serializer.json.JSONComponentSerializer")}; - String[] accessors = {"gson", "json"}; - Class serializer = null; - for (int i = 0; i < candidates.length; i++) { - try { - step = "forName " + candidates[i]; - Class c = Class.forName(candidates[i]); - step = "accessor " + accessors[i] + " on " + candidates[i]; - nativeGson = c.getMethod(accessors[i]).invoke(null); - serializer = c; - break; - } catch (Throwable ignore) { - } - } - if (serializer == null) { - throw new ClassNotFoundException("no native adventure json serializer exposed"); - } - step = "find deserialize on " + serializer.getName(); - java.lang.reflect.Method exact = null; - java.lang.reflect.Method loose = null; - for (java.lang.reflect.Method m : serializer.getMethods()) { - if (m.getName().equals("deserialize") && m.getParameterCount() == 1) { - Class p = m.getParameterTypes()[0]; - if (p == String.class) { - exact = m; - break; - } - if (p.isAssignableFrom(String.class)) { - loose = m; - } - } - } - nativeDeserialize = exact != null ? exact : loose; - if (nativeDeserialize == null) { - throw new NoSuchMethodException("deserialize(String-compatible) not found"); - } - step = "forName Component"; - Class componentType = Class.forName(nativeAdventure("Component")); - step = "getMethod CommandSender.sendMessage(Component)"; - nativeSendMessage = CommandSender.class.getMethod("sendMessage", componentType); - } catch (Throwable e) { - nativeSendMessage = null; - adventureDebug("PROBE FAIL at [" + step + "]: " + e.getClass().getName() + ": " + e.getMessage()); - } - nativeProbed = true; - } - - private static boolean sendNative(CommandSender target, Component component) { - if (!nativeProbed) { - probeNative(); - } - if (nativeSendMessage == null) { - return false; - } - try { - String json = GsonComponentSerializer.gson().serialize(component); - Object nativeComponent = nativeDeserialize.invoke(nativeGson, json); - nativeSendMessage.invoke(target, nativeComponent); - return true; - } catch (Throwable e) { - if (SEND_LOGGED.compareAndSet(false, true)) { - Throwable cause = e.getCause() != null ? e.getCause() : e; - adventureDebug("SEND FAIL: " + cause.getClass().getName() + ": " + cause.getMessage()); - } - return false; - } + private void deliver(ComponentText message) { + ComponentMessenger.send(s, message); } @Override diff --git a/core/src/main/java/art/arcane/iris/util/project/noise/ImageNoise.java b/core/src/main/java/art/arcane/iris/util/project/noise/ImageNoise.java index 31a66571e..a57d42af1 100644 --- a/core/src/main/java/art/arcane/iris/util/project/noise/ImageNoise.java +++ b/core/src/main/java/art/arcane/iris/util/project/noise/ImageNoise.java @@ -19,15 +19,41 @@ package art.arcane.iris.util.project.noise; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.image.CompiledIrisImageMap; +import art.arcane.iris.engine.image.IrisImageMapValidationException; +import art.arcane.iris.engine.object.IrisImage; import art.arcane.iris.engine.object.IrisImageMap; +import art.arcane.iris.engine.object.IrisImageMapType; public class ImageNoise implements NoiseGenerator { - private final IrisImageMap expression; - private final IrisData data; + private final CompiledIrisImageMap compiled; - public ImageNoise(IrisData data, IrisImageMap expression) { - this.data = data; - this.expression = expression; + public ImageNoise(IrisData data, String imageMapKey) { + IrisImageMap definition = data.getImageMapLoader().load(imageMapKey); + if (definition == null) { + throw new IrisImageMapValidationException("Missing image-map resource '" + imageMapKey + "'"); + } + if (definition.getType() == IrisImageMapType.COLOR_MAP) { + throw new IrisImageMapValidationException( + "Generator-style image-map resource '" + imageMapKey + "' must produce normalized scalar data" + ); + } + IrisImage image = data.getImageLoader().load(definition.getSource()); + if (image == null) { + throw new IrisImageMapValidationException( + "Image-map resource '" + imageMapKey + "' references missing or invalid PNG '" + + definition.getSource() + "'" + ); + } + try { + compiled = CompiledIrisImageMap.compile(definition, image); + } finally { + data.getImageLoader().unload(definition.getSource()); + } + } + + public String getContentHash() { + return compiled.getContentHash(); } @Override @@ -37,7 +63,7 @@ public class ImageNoise implements NoiseGenerator { @Override public double noise(double x, double z) { - return expression.getNoise(data, (int) x, (int) z); + return compiled.sampleNormalized(x, z); } @Override diff --git a/core/src/main/resources/languages/de_DE.json b/core/src/main/resources/languages/de_DE.json index c857e0930..297203907 100644 --- a/core/src/main/resources/languages/de_DE.json +++ b/core/src/main/resources/languages/de_DE.json @@ -1283,7 +1283,6 @@ "iris.runtime.pack_download.invalid_built_in": "§cIris bietet integrierte Downloads nur für 'overworld' und 'underworld' an.", "iris.runtime.pack_download.shutting_down": "§eIris wird heruntergefahren und nimmt keine Pack-Downloads mehr an.", "iris.runtime.pack_download.downloading": "{url} wird heruntergeladen", - "iris.runtime.pack_download.failed_to_find": "Unter {url} wurde kein Pack gefunden", "iris.runtime.pack_download.unpacking": "{repository} wird entpackt", "iris.runtime.pack_download.unpack_failed": [ "Beim Entpacken ist ein Problem aufgetreten. Bitte prüfe Folgendes:", @@ -1482,6 +1481,75 @@ "iris.runtime.what.flag.ore": "ore", "iris.runtime.what.flag.block_entity": "block entity", "iris.runtime.world.height_range": "World height: {minY} to {maxY}", - "iris.runtime.world.height_total": "Total height: {height}" + "iris.runtime.world.height_total": "Total height: {height}", + "iris.desktop.imagemap.title": "Image Map Studio", + "iris.desktop.imagemap.preset": "Preset:", + "iris.desktop.imagemap.load": "Load", + "iris.desktop.imagemap.import_png": "Import PNG", + "iris.desktop.imagemap.replace_png": "Replace PNG", + "iris.desktop.imagemap.preview": "Preview", + "iris.desktop.imagemap.export": "Export to active pack", + "iris.desktop.imagemap.metadata": "Source metadata", + "iris.desktop.imagemap.resource": "Resource binding", + "iris.desktop.imagemap.coordinates": "Coordinates and sampling", + "iris.desktop.imagemap.binding_key": "Binding key", + "iris.desktop.imagemap.map_key": "Map key", + "iris.desktop.imagemap.image_key": "Image key", + "iris.desktop.imagemap.type": "Type", + "iris.desktop.imagemap.application": "Application", + "iris.desktop.imagemap.blocks_per_pixel": "Blocks per pixel", + "iris.desktop.imagemap.origin_x": "World origin X", + "iris.desktop.imagemap.origin_z": "World origin Z", + "iris.desktop.imagemap.source_origin_x": "Source origin X", + "iris.desktop.imagemap.source_origin_z": "Source origin Z", + "iris.desktop.imagemap.rotation": "Rotation", + "iris.desktop.imagemap.mirror_x": "Mirror X", + "iris.desktop.imagemap.mirror_z": "Mirror Z", + "iris.desktop.imagemap.sampling": "Sampling", + "iris.desktop.imagemap.out_of_bounds": "Out of bounds", + "iris.desktop.imagemap.alpha": "Alpha policy", + "iris.desktop.imagemap.fallback_value": "Fallback value", + "iris.desktop.imagemap.fallback_target": "Fallback target", + "iris.desktop.imagemap.minimum_height": "Minimum height", + "iris.desktop.imagemap.maximum_height": "Maximum height", + "iris.desktop.imagemap.vertical_offset": "Vertical offset", + "iris.desktop.imagemap.clamp": "Clamp height", + "iris.desktop.imagemap.inverted": "Invert values", + "iris.desktop.imagemap.curve_exponent": "Curve exponent", + "iris.desktop.imagemap.smoothing_radius": "Smoothing radius", + "iris.desktop.imagemap.threshold": "Threshold", + "iris.desktop.imagemap.falloff": "Falloff", + "iris.desktop.imagemap.color_tolerance": "Raw sRGB tolerance", + "iris.desktop.imagemap.unknown_color": "Unknown color", + "iris.desktop.imagemap.add_color": "Add legend color", + "iris.desktop.imagemap.remove_color": "Remove selected", + "iris.desktop.imagemap.composed_masks": "Composed masks", + "iris.desktop.imagemap.add_mask": "Add mask binding", + "iris.desktop.imagemap.height": "Height interpretation", + "iris.desktop.imagemap.mask": "Mask interpretation", + "iris.desktop.imagemap.color_map": "Color legend", + "iris.desktop.imagemap.overlays": "World overlays", + "iris.desktop.imagemap.chunks": "16-block chunks", + "iris.desktop.imagemap.regions": "512-block regions", + "iris.desktop.imagemap.boundary": "World boundary", + "iris.desktop.imagemap.coverage": "Source coverage", + "iris.desktop.imagemap.diagnostics": "Diagnostics", + "iris.desktop.imagemap.ready": "Ready", + "iris.desktop.imagemap.no_source": "Import a PNG source to begin.", + "iris.desktop.imagemap.loading": "Loading preset...", + "iris.desktop.imagemap.load_failed": "Preset load failed", + "iris.desktop.imagemap.previewing": "Compiling preview...", + "iris.desktop.imagemap.preview_valid": "Runtime compiler validation passed.", + "iris.desktop.imagemap.preview_failed": "Preview failed", + "iris.desktop.imagemap.exporting": "Validating and exporting...", + "iris.desktop.imagemap.exported": "Exported image map, PNG, and dimension binding atomically.", + "iris.desktop.imagemap.export_failed": "Export failed", + "iris.desktop.imagemap.source": "Source pixels", + "iris.desktop.imagemap.interpreted": "Runtime interpretation", + "iris.desktop.imagemap.no_preview": "Compile a valid preview to inspect world output.", + "iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel", + "iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.", + "iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.", + "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display." } } diff --git a/core/src/main/resources/languages/es_ES.json b/core/src/main/resources/languages/es_ES.json index 55ae43c94..5b98d090d 100644 --- a/core/src/main/resources/languages/es_ES.json +++ b/core/src/main/resources/languages/es_ES.json @@ -1283,7 +1283,6 @@ "iris.runtime.pack_download.invalid_built_in": "§cIris solo ofrece descargas integradas para 'overworld' y 'underworld'.", "iris.runtime.pack_download.shutting_down": "§eIris se está cerrando y no acepta descargas de packs.", "iris.runtime.pack_download.downloading": "Descargando {url}", - "iris.runtime.pack_download.failed_to_find": "No se encontró el pack en {url}", "iris.runtime.pack_download.unpacking": "Descomprimiendo {repository}", "iris.runtime.pack_download.unpack_failed": [ "Se produjo un problema al descomprimir. Comprueba lo siguiente:", @@ -1482,6 +1481,75 @@ "iris.runtime.what.flag.ore": "ore", "iris.runtime.what.flag.block_entity": "block entity", "iris.runtime.world.height_range": "World height: {minY} to {maxY}", - "iris.runtime.world.height_total": "Total height: {height}" + "iris.runtime.world.height_total": "Total height: {height}", + "iris.desktop.imagemap.title": "Image Map Studio", + "iris.desktop.imagemap.preset": "Preset:", + "iris.desktop.imagemap.load": "Load", + "iris.desktop.imagemap.import_png": "Import PNG", + "iris.desktop.imagemap.replace_png": "Replace PNG", + "iris.desktop.imagemap.preview": "Preview", + "iris.desktop.imagemap.export": "Export to active pack", + "iris.desktop.imagemap.metadata": "Source metadata", + "iris.desktop.imagemap.resource": "Resource binding", + "iris.desktop.imagemap.coordinates": "Coordinates and sampling", + "iris.desktop.imagemap.binding_key": "Binding key", + "iris.desktop.imagemap.map_key": "Map key", + "iris.desktop.imagemap.image_key": "Image key", + "iris.desktop.imagemap.type": "Type", + "iris.desktop.imagemap.application": "Application", + "iris.desktop.imagemap.blocks_per_pixel": "Blocks per pixel", + "iris.desktop.imagemap.origin_x": "World origin X", + "iris.desktop.imagemap.origin_z": "World origin Z", + "iris.desktop.imagemap.source_origin_x": "Source origin X", + "iris.desktop.imagemap.source_origin_z": "Source origin Z", + "iris.desktop.imagemap.rotation": "Rotation", + "iris.desktop.imagemap.mirror_x": "Mirror X", + "iris.desktop.imagemap.mirror_z": "Mirror Z", + "iris.desktop.imagemap.sampling": "Sampling", + "iris.desktop.imagemap.out_of_bounds": "Out of bounds", + "iris.desktop.imagemap.alpha": "Alpha policy", + "iris.desktop.imagemap.fallback_value": "Fallback value", + "iris.desktop.imagemap.fallback_target": "Fallback target", + "iris.desktop.imagemap.minimum_height": "Minimum height", + "iris.desktop.imagemap.maximum_height": "Maximum height", + "iris.desktop.imagemap.vertical_offset": "Vertical offset", + "iris.desktop.imagemap.clamp": "Clamp height", + "iris.desktop.imagemap.inverted": "Invert values", + "iris.desktop.imagemap.curve_exponent": "Curve exponent", + "iris.desktop.imagemap.smoothing_radius": "Smoothing radius", + "iris.desktop.imagemap.threshold": "Threshold", + "iris.desktop.imagemap.falloff": "Falloff", + "iris.desktop.imagemap.color_tolerance": "Raw sRGB tolerance", + "iris.desktop.imagemap.unknown_color": "Unknown color", + "iris.desktop.imagemap.add_color": "Add legend color", + "iris.desktop.imagemap.remove_color": "Remove selected", + "iris.desktop.imagemap.composed_masks": "Composed masks", + "iris.desktop.imagemap.add_mask": "Add mask binding", + "iris.desktop.imagemap.height": "Height interpretation", + "iris.desktop.imagemap.mask": "Mask interpretation", + "iris.desktop.imagemap.color_map": "Color legend", + "iris.desktop.imagemap.overlays": "World overlays", + "iris.desktop.imagemap.chunks": "16-block chunks", + "iris.desktop.imagemap.regions": "512-block regions", + "iris.desktop.imagemap.boundary": "World boundary", + "iris.desktop.imagemap.coverage": "Source coverage", + "iris.desktop.imagemap.diagnostics": "Diagnostics", + "iris.desktop.imagemap.ready": "Ready", + "iris.desktop.imagemap.no_source": "Import a PNG source to begin.", + "iris.desktop.imagemap.loading": "Loading preset...", + "iris.desktop.imagemap.load_failed": "Preset load failed", + "iris.desktop.imagemap.previewing": "Compiling preview...", + "iris.desktop.imagemap.preview_valid": "Runtime compiler validation passed.", + "iris.desktop.imagemap.preview_failed": "Preview failed", + "iris.desktop.imagemap.exporting": "Validating and exporting...", + "iris.desktop.imagemap.exported": "Exported image map, PNG, and dimension binding atomically.", + "iris.desktop.imagemap.export_failed": "Export failed", + "iris.desktop.imagemap.source": "Source pixels", + "iris.desktop.imagemap.interpreted": "Runtime interpretation", + "iris.desktop.imagemap.no_preview": "Compile a valid preview to inspect world output.", + "iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel", + "iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.", + "iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.", + "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display." } } diff --git a/core/src/main/resources/languages/fi_FI.json b/core/src/main/resources/languages/fi_FI.json index dc66d8267..9f3f0c947 100644 --- a/core/src/main/resources/languages/fi_FI.json +++ b/core/src/main/resources/languages/fi_FI.json @@ -1283,7 +1283,6 @@ "iris.runtime.pack_download.invalid_built_in": "§cIriksen sisäiset lataukset ovat saatavilla vain paketeille 'overworld' ja 'underworld'.", "iris.runtime.pack_download.shutting_down": "§eIristä sammutetaan, eikä se ota vastaan pakettien latauksia.", "iris.runtime.pack_download.downloading": "Noudetaan {url}", - "iris.runtime.pack_download.failed_to_find": "Pakkausta ei löytynyt {url}", "iris.runtime.pack_download.unpacking": "Purkaminen {repository}", "iris.runtime.pack_download.unpack_failed": [ "Purkamisen yhteydessä. Tarkista/ tee seuraava:", @@ -1482,6 +1481,75 @@ "iris.runtime.what.flag.ore": "ore", "iris.runtime.what.flag.block_entity": "block entity", "iris.runtime.world.height_range": "World height: {minY} to {maxY}", - "iris.runtime.world.height_total": "Total height: {height}" + "iris.runtime.world.height_total": "Total height: {height}", + "iris.desktop.imagemap.title": "Image Map Studio", + "iris.desktop.imagemap.preset": "Preset:", + "iris.desktop.imagemap.load": "Load", + "iris.desktop.imagemap.import_png": "Import PNG", + "iris.desktop.imagemap.replace_png": "Replace PNG", + "iris.desktop.imagemap.preview": "Preview", + "iris.desktop.imagemap.export": "Export to active pack", + "iris.desktop.imagemap.metadata": "Source metadata", + "iris.desktop.imagemap.resource": "Resource binding", + "iris.desktop.imagemap.coordinates": "Coordinates and sampling", + "iris.desktop.imagemap.binding_key": "Binding key", + "iris.desktop.imagemap.map_key": "Map key", + "iris.desktop.imagemap.image_key": "Image key", + "iris.desktop.imagemap.type": "Type", + "iris.desktop.imagemap.application": "Application", + "iris.desktop.imagemap.blocks_per_pixel": "Blocks per pixel", + "iris.desktop.imagemap.origin_x": "World origin X", + "iris.desktop.imagemap.origin_z": "World origin Z", + "iris.desktop.imagemap.source_origin_x": "Source origin X", + "iris.desktop.imagemap.source_origin_z": "Source origin Z", + "iris.desktop.imagemap.rotation": "Rotation", + "iris.desktop.imagemap.mirror_x": "Mirror X", + "iris.desktop.imagemap.mirror_z": "Mirror Z", + "iris.desktop.imagemap.sampling": "Sampling", + "iris.desktop.imagemap.out_of_bounds": "Out of bounds", + "iris.desktop.imagemap.alpha": "Alpha policy", + "iris.desktop.imagemap.fallback_value": "Fallback value", + "iris.desktop.imagemap.fallback_target": "Fallback target", + "iris.desktop.imagemap.minimum_height": "Minimum height", + "iris.desktop.imagemap.maximum_height": "Maximum height", + "iris.desktop.imagemap.vertical_offset": "Vertical offset", + "iris.desktop.imagemap.clamp": "Clamp height", + "iris.desktop.imagemap.inverted": "Invert values", + "iris.desktop.imagemap.curve_exponent": "Curve exponent", + "iris.desktop.imagemap.smoothing_radius": "Smoothing radius", + "iris.desktop.imagemap.threshold": "Threshold", + "iris.desktop.imagemap.falloff": "Falloff", + "iris.desktop.imagemap.color_tolerance": "Raw sRGB tolerance", + "iris.desktop.imagemap.unknown_color": "Unknown color", + "iris.desktop.imagemap.add_color": "Add legend color", + "iris.desktop.imagemap.remove_color": "Remove selected", + "iris.desktop.imagemap.composed_masks": "Composed masks", + "iris.desktop.imagemap.add_mask": "Add mask binding", + "iris.desktop.imagemap.height": "Height interpretation", + "iris.desktop.imagemap.mask": "Mask interpretation", + "iris.desktop.imagemap.color_map": "Color legend", + "iris.desktop.imagemap.overlays": "World overlays", + "iris.desktop.imagemap.chunks": "16-block chunks", + "iris.desktop.imagemap.regions": "512-block regions", + "iris.desktop.imagemap.boundary": "World boundary", + "iris.desktop.imagemap.coverage": "Source coverage", + "iris.desktop.imagemap.diagnostics": "Diagnostics", + "iris.desktop.imagemap.ready": "Ready", + "iris.desktop.imagemap.no_source": "Import a PNG source to begin.", + "iris.desktop.imagemap.loading": "Loading preset...", + "iris.desktop.imagemap.load_failed": "Preset load failed", + "iris.desktop.imagemap.previewing": "Compiling preview...", + "iris.desktop.imagemap.preview_valid": "Runtime compiler validation passed.", + "iris.desktop.imagemap.preview_failed": "Preview failed", + "iris.desktop.imagemap.exporting": "Validating and exporting...", + "iris.desktop.imagemap.exported": "Exported image map, PNG, and dimension binding atomically.", + "iris.desktop.imagemap.export_failed": "Export failed", + "iris.desktop.imagemap.source": "Source pixels", + "iris.desktop.imagemap.interpreted": "Runtime interpretation", + "iris.desktop.imagemap.no_preview": "Compile a valid preview to inspect world output.", + "iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel", + "iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.", + "iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.", + "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display." } } diff --git a/core/src/main/resources/languages/fr_FR.json b/core/src/main/resources/languages/fr_FR.json index 7fe3509e1..6bbaa1be8 100644 --- a/core/src/main/resources/languages/fr_FR.json +++ b/core/src/main/resources/languages/fr_FR.json @@ -1283,7 +1283,6 @@ "iris.runtime.pack_download.invalid_built_in": "§cIris ne propose de téléchargement intégré que pour 'overworld' et 'underworld'.", "iris.runtime.pack_download.shutting_down": "§eIris est en cours d’arrêt et n’accepte plus de téléchargements de packs.", "iris.runtime.pack_download.downloading": "Téléchargement de {url}", - "iris.runtime.pack_download.failed_to_find": "Pack introuvable dans {url}", "iris.runtime.pack_download.unpacking": "Décompression de {repository}", "iris.runtime.pack_download.unpack_failed": [ "Un problème est survenu pendant la décompression. Vérifiez les points suivants :", @@ -1482,6 +1481,75 @@ "iris.runtime.what.flag.ore": "ore", "iris.runtime.what.flag.block_entity": "block entity", "iris.runtime.world.height_range": "World height: {minY} to {maxY}", - "iris.runtime.world.height_total": "Total height: {height}" + "iris.runtime.world.height_total": "Total height: {height}", + "iris.desktop.imagemap.title": "Image Map Studio", + "iris.desktop.imagemap.preset": "Preset:", + "iris.desktop.imagemap.load": "Load", + "iris.desktop.imagemap.import_png": "Import PNG", + "iris.desktop.imagemap.replace_png": "Replace PNG", + "iris.desktop.imagemap.preview": "Preview", + "iris.desktop.imagemap.export": "Export to active pack", + "iris.desktop.imagemap.metadata": "Source metadata", + "iris.desktop.imagemap.resource": "Resource binding", + "iris.desktop.imagemap.coordinates": "Coordinates and sampling", + "iris.desktop.imagemap.binding_key": "Binding key", + "iris.desktop.imagemap.map_key": "Map key", + "iris.desktop.imagemap.image_key": "Image key", + "iris.desktop.imagemap.type": "Type", + "iris.desktop.imagemap.application": "Application", + "iris.desktop.imagemap.blocks_per_pixel": "Blocks per pixel", + "iris.desktop.imagemap.origin_x": "World origin X", + "iris.desktop.imagemap.origin_z": "World origin Z", + "iris.desktop.imagemap.source_origin_x": "Source origin X", + "iris.desktop.imagemap.source_origin_z": "Source origin Z", + "iris.desktop.imagemap.rotation": "Rotation", + "iris.desktop.imagemap.mirror_x": "Mirror X", + "iris.desktop.imagemap.mirror_z": "Mirror Z", + "iris.desktop.imagemap.sampling": "Sampling", + "iris.desktop.imagemap.out_of_bounds": "Out of bounds", + "iris.desktop.imagemap.alpha": "Alpha policy", + "iris.desktop.imagemap.fallback_value": "Fallback value", + "iris.desktop.imagemap.fallback_target": "Fallback target", + "iris.desktop.imagemap.minimum_height": "Minimum height", + "iris.desktop.imagemap.maximum_height": "Maximum height", + "iris.desktop.imagemap.vertical_offset": "Vertical offset", + "iris.desktop.imagemap.clamp": "Clamp height", + "iris.desktop.imagemap.inverted": "Invert values", + "iris.desktop.imagemap.curve_exponent": "Curve exponent", + "iris.desktop.imagemap.smoothing_radius": "Smoothing radius", + "iris.desktop.imagemap.threshold": "Threshold", + "iris.desktop.imagemap.falloff": "Falloff", + "iris.desktop.imagemap.color_tolerance": "Raw sRGB tolerance", + "iris.desktop.imagemap.unknown_color": "Unknown color", + "iris.desktop.imagemap.add_color": "Add legend color", + "iris.desktop.imagemap.remove_color": "Remove selected", + "iris.desktop.imagemap.composed_masks": "Composed masks", + "iris.desktop.imagemap.add_mask": "Add mask binding", + "iris.desktop.imagemap.height": "Height interpretation", + "iris.desktop.imagemap.mask": "Mask interpretation", + "iris.desktop.imagemap.color_map": "Color legend", + "iris.desktop.imagemap.overlays": "World overlays", + "iris.desktop.imagemap.chunks": "16-block chunks", + "iris.desktop.imagemap.regions": "512-block regions", + "iris.desktop.imagemap.boundary": "World boundary", + "iris.desktop.imagemap.coverage": "Source coverage", + "iris.desktop.imagemap.diagnostics": "Diagnostics", + "iris.desktop.imagemap.ready": "Ready", + "iris.desktop.imagemap.no_source": "Import a PNG source to begin.", + "iris.desktop.imagemap.loading": "Loading preset...", + "iris.desktop.imagemap.load_failed": "Preset load failed", + "iris.desktop.imagemap.previewing": "Compiling preview...", + "iris.desktop.imagemap.preview_valid": "Runtime compiler validation passed.", + "iris.desktop.imagemap.preview_failed": "Preview failed", + "iris.desktop.imagemap.exporting": "Validating and exporting...", + "iris.desktop.imagemap.exported": "Exported image map, PNG, and dimension binding atomically.", + "iris.desktop.imagemap.export_failed": "Export failed", + "iris.desktop.imagemap.source": "Source pixels", + "iris.desktop.imagemap.interpreted": "Runtime interpretation", + "iris.desktop.imagemap.no_preview": "Compile a valid preview to inspect world output.", + "iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel", + "iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.", + "iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.", + "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display." } } diff --git a/core/src/main/resources/languages/he_IL.json b/core/src/main/resources/languages/he_IL.json index adc07e9bc..0c4bc61a4 100644 --- a/core/src/main/resources/languages/he_IL.json +++ b/core/src/main/resources/languages/he_IL.json @@ -1283,7 +1283,6 @@ "iris.runtime.pack_download.invalid_built_in": "§cIris מספק הורדות מובנות רק עבור 'overworld' ו-'underworld'.", "iris.runtime.pack_download.shutting_down": "§eIris נכבה כעת ואינו מקבל הורדות של חבילות.", "iris.runtime.pack_download.downloading": "הורדה {url}", - "iris.runtime.pack_download.failed_to_find": "נכשל למצוא חבילות {url}", "iris.runtime.pack_download.unpacking": "חבילות חילוץ {repository}", "iris.runtime.pack_download.unpack_failed": [ "בעיה כאשר לא לארוז. בדוק/עשה את הפעולות הבאות:", @@ -1482,6 +1481,75 @@ "iris.runtime.what.flag.ore": "ore", "iris.runtime.what.flag.block_entity": "block entity", "iris.runtime.world.height_range": "World height: {minY} to {maxY}", - "iris.runtime.world.height_total": "Total height: {height}" + "iris.runtime.world.height_total": "Total height: {height}", + "iris.desktop.imagemap.title": "Image Map Studio", + "iris.desktop.imagemap.preset": "Preset:", + "iris.desktop.imagemap.load": "Load", + "iris.desktop.imagemap.import_png": "Import PNG", + "iris.desktop.imagemap.replace_png": "Replace PNG", + "iris.desktop.imagemap.preview": "Preview", + "iris.desktop.imagemap.export": "Export to active pack", + "iris.desktop.imagemap.metadata": "Source metadata", + "iris.desktop.imagemap.resource": "Resource binding", + "iris.desktop.imagemap.coordinates": "Coordinates and sampling", + "iris.desktop.imagemap.binding_key": "Binding key", + "iris.desktop.imagemap.map_key": "Map key", + "iris.desktop.imagemap.image_key": "Image key", + "iris.desktop.imagemap.type": "Type", + "iris.desktop.imagemap.application": "Application", + "iris.desktop.imagemap.blocks_per_pixel": "Blocks per pixel", + "iris.desktop.imagemap.origin_x": "World origin X", + "iris.desktop.imagemap.origin_z": "World origin Z", + "iris.desktop.imagemap.source_origin_x": "Source origin X", + "iris.desktop.imagemap.source_origin_z": "Source origin Z", + "iris.desktop.imagemap.rotation": "Rotation", + "iris.desktop.imagemap.mirror_x": "Mirror X", + "iris.desktop.imagemap.mirror_z": "Mirror Z", + "iris.desktop.imagemap.sampling": "Sampling", + "iris.desktop.imagemap.out_of_bounds": "Out of bounds", + "iris.desktop.imagemap.alpha": "Alpha policy", + "iris.desktop.imagemap.fallback_value": "Fallback value", + "iris.desktop.imagemap.fallback_target": "Fallback target", + "iris.desktop.imagemap.minimum_height": "Minimum height", + "iris.desktop.imagemap.maximum_height": "Maximum height", + "iris.desktop.imagemap.vertical_offset": "Vertical offset", + "iris.desktop.imagemap.clamp": "Clamp height", + "iris.desktop.imagemap.inverted": "Invert values", + "iris.desktop.imagemap.curve_exponent": "Curve exponent", + "iris.desktop.imagemap.smoothing_radius": "Smoothing radius", + "iris.desktop.imagemap.threshold": "Threshold", + "iris.desktop.imagemap.falloff": "Falloff", + "iris.desktop.imagemap.color_tolerance": "Raw sRGB tolerance", + "iris.desktop.imagemap.unknown_color": "Unknown color", + "iris.desktop.imagemap.add_color": "Add legend color", + "iris.desktop.imagemap.remove_color": "Remove selected", + "iris.desktop.imagemap.composed_masks": "Composed masks", + "iris.desktop.imagemap.add_mask": "Add mask binding", + "iris.desktop.imagemap.height": "Height interpretation", + "iris.desktop.imagemap.mask": "Mask interpretation", + "iris.desktop.imagemap.color_map": "Color legend", + "iris.desktop.imagemap.overlays": "World overlays", + "iris.desktop.imagemap.chunks": "16-block chunks", + "iris.desktop.imagemap.regions": "512-block regions", + "iris.desktop.imagemap.boundary": "World boundary", + "iris.desktop.imagemap.coverage": "Source coverage", + "iris.desktop.imagemap.diagnostics": "Diagnostics", + "iris.desktop.imagemap.ready": "Ready", + "iris.desktop.imagemap.no_source": "Import a PNG source to begin.", + "iris.desktop.imagemap.loading": "Loading preset...", + "iris.desktop.imagemap.load_failed": "Preset load failed", + "iris.desktop.imagemap.previewing": "Compiling preview...", + "iris.desktop.imagemap.preview_valid": "Runtime compiler validation passed.", + "iris.desktop.imagemap.preview_failed": "Preview failed", + "iris.desktop.imagemap.exporting": "Validating and exporting...", + "iris.desktop.imagemap.exported": "Exported image map, PNG, and dimension binding atomically.", + "iris.desktop.imagemap.export_failed": "Export failed", + "iris.desktop.imagemap.source": "Source pixels", + "iris.desktop.imagemap.interpreted": "Runtime interpretation", + "iris.desktop.imagemap.no_preview": "Compile a valid preview to inspect world output.", + "iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel", + "iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.", + "iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.", + "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display." } } diff --git a/core/src/main/resources/languages/it_IT.json b/core/src/main/resources/languages/it_IT.json index d7e26295e..80e9730b4 100644 --- a/core/src/main/resources/languages/it_IT.json +++ b/core/src/main/resources/languages/it_IT.json @@ -1283,7 +1283,6 @@ "iris.runtime.pack_download.invalid_built_in": "§cIris offre download integrati solo per 'overworld' e 'underworld'.", "iris.runtime.pack_download.shutting_down": "§eIris si sta arrestando e non accetta download di pack.", "iris.runtime.pack_download.downloading": "Scaricamento {url}", - "iris.runtime.pack_download.failed_to_find": "Impossibile trovare il Pack in {url}", "iris.runtime.pack_download.unpacking": "Disimballaggio {repository}", "iris.runtime.pack_download.unpack_failed": [ "Si è verificato un problema durante l'estrazione. Controlla quanto segue:", @@ -1482,6 +1481,75 @@ "iris.runtime.what.flag.ore": "ore", "iris.runtime.what.flag.block_entity": "block entity", "iris.runtime.world.height_range": "World height: {minY} to {maxY}", - "iris.runtime.world.height_total": "Total height: {height}" + "iris.runtime.world.height_total": "Total height: {height}", + "iris.desktop.imagemap.title": "Image Map Studio", + "iris.desktop.imagemap.preset": "Preset:", + "iris.desktop.imagemap.load": "Load", + "iris.desktop.imagemap.import_png": "Import PNG", + "iris.desktop.imagemap.replace_png": "Replace PNG", + "iris.desktop.imagemap.preview": "Preview", + "iris.desktop.imagemap.export": "Export to active pack", + "iris.desktop.imagemap.metadata": "Source metadata", + "iris.desktop.imagemap.resource": "Resource binding", + "iris.desktop.imagemap.coordinates": "Coordinates and sampling", + "iris.desktop.imagemap.binding_key": "Binding key", + "iris.desktop.imagemap.map_key": "Map key", + "iris.desktop.imagemap.image_key": "Image key", + "iris.desktop.imagemap.type": "Type", + "iris.desktop.imagemap.application": "Application", + "iris.desktop.imagemap.blocks_per_pixel": "Blocks per pixel", + "iris.desktop.imagemap.origin_x": "World origin X", + "iris.desktop.imagemap.origin_z": "World origin Z", + "iris.desktop.imagemap.source_origin_x": "Source origin X", + "iris.desktop.imagemap.source_origin_z": "Source origin Z", + "iris.desktop.imagemap.rotation": "Rotation", + "iris.desktop.imagemap.mirror_x": "Mirror X", + "iris.desktop.imagemap.mirror_z": "Mirror Z", + "iris.desktop.imagemap.sampling": "Sampling", + "iris.desktop.imagemap.out_of_bounds": "Out of bounds", + "iris.desktop.imagemap.alpha": "Alpha policy", + "iris.desktop.imagemap.fallback_value": "Fallback value", + "iris.desktop.imagemap.fallback_target": "Fallback target", + "iris.desktop.imagemap.minimum_height": "Minimum height", + "iris.desktop.imagemap.maximum_height": "Maximum height", + "iris.desktop.imagemap.vertical_offset": "Vertical offset", + "iris.desktop.imagemap.clamp": "Clamp height", + "iris.desktop.imagemap.inverted": "Invert values", + "iris.desktop.imagemap.curve_exponent": "Curve exponent", + "iris.desktop.imagemap.smoothing_radius": "Smoothing radius", + "iris.desktop.imagemap.threshold": "Threshold", + "iris.desktop.imagemap.falloff": "Falloff", + "iris.desktop.imagemap.color_tolerance": "Raw sRGB tolerance", + "iris.desktop.imagemap.unknown_color": "Unknown color", + "iris.desktop.imagemap.add_color": "Add legend color", + "iris.desktop.imagemap.remove_color": "Remove selected", + "iris.desktop.imagemap.composed_masks": "Composed masks", + "iris.desktop.imagemap.add_mask": "Add mask binding", + "iris.desktop.imagemap.height": "Height interpretation", + "iris.desktop.imagemap.mask": "Mask interpretation", + "iris.desktop.imagemap.color_map": "Color legend", + "iris.desktop.imagemap.overlays": "World overlays", + "iris.desktop.imagemap.chunks": "16-block chunks", + "iris.desktop.imagemap.regions": "512-block regions", + "iris.desktop.imagemap.boundary": "World boundary", + "iris.desktop.imagemap.coverage": "Source coverage", + "iris.desktop.imagemap.diagnostics": "Diagnostics", + "iris.desktop.imagemap.ready": "Ready", + "iris.desktop.imagemap.no_source": "Import a PNG source to begin.", + "iris.desktop.imagemap.loading": "Loading preset...", + "iris.desktop.imagemap.load_failed": "Preset load failed", + "iris.desktop.imagemap.previewing": "Compiling preview...", + "iris.desktop.imagemap.preview_valid": "Runtime compiler validation passed.", + "iris.desktop.imagemap.preview_failed": "Preview failed", + "iris.desktop.imagemap.exporting": "Validating and exporting...", + "iris.desktop.imagemap.exported": "Exported image map, PNG, and dimension binding atomically.", + "iris.desktop.imagemap.export_failed": "Export failed", + "iris.desktop.imagemap.source": "Source pixels", + "iris.desktop.imagemap.interpreted": "Runtime interpretation", + "iris.desktop.imagemap.no_preview": "Compile a valid preview to inspect world output.", + "iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel", + "iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.", + "iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.", + "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display." } } diff --git a/core/src/main/resources/languages/ja-JP.json b/core/src/main/resources/languages/ja-JP.json index aefcdfee6..10e7d9d22 100644 --- a/core/src/main/resources/languages/ja-JP.json +++ b/core/src/main/resources/languages/ja-JP.json @@ -1283,7 +1283,6 @@ "iris.runtime.pack_download.invalid_built_in": "§cIris の組み込みダウンロードは 'overworld' と 'underworld' のみです。", "iris.runtime.pack_download.shutting_down": "§eIris はシャットダウン中のため、パックのダウンロードを受け付けていません。", "iris.runtime.pack_download.downloading": "{url} をダウンロードしています", - "iris.runtime.pack_download.failed_to_find": "{url} にパックが見つかりませんでした", "iris.runtime.pack_download.unpacking": "{repository} を展開しています", "iris.runtime.pack_download.unpack_failed": [ "展開中に問題が発生しました。次の項目を確認してください:", @@ -1482,6 +1481,75 @@ "iris.runtime.what.flag.ore": "ore", "iris.runtime.what.flag.block_entity": "block entity", "iris.runtime.world.height_range": "World height: {minY} to {maxY}", - "iris.runtime.world.height_total": "Total height: {height}" + "iris.runtime.world.height_total": "Total height: {height}", + "iris.desktop.imagemap.title": "Image Map Studio", + "iris.desktop.imagemap.preset": "Preset:", + "iris.desktop.imagemap.load": "Load", + "iris.desktop.imagemap.import_png": "Import PNG", + "iris.desktop.imagemap.replace_png": "Replace PNG", + "iris.desktop.imagemap.preview": "Preview", + "iris.desktop.imagemap.export": "Export to active pack", + "iris.desktop.imagemap.metadata": "Source metadata", + "iris.desktop.imagemap.resource": "Resource binding", + "iris.desktop.imagemap.coordinates": "Coordinates and sampling", + "iris.desktop.imagemap.binding_key": "Binding key", + "iris.desktop.imagemap.map_key": "Map key", + "iris.desktop.imagemap.image_key": "Image key", + "iris.desktop.imagemap.type": "Type", + "iris.desktop.imagemap.application": "Application", + "iris.desktop.imagemap.blocks_per_pixel": "Blocks per pixel", + "iris.desktop.imagemap.origin_x": "World origin X", + "iris.desktop.imagemap.origin_z": "World origin Z", + "iris.desktop.imagemap.source_origin_x": "Source origin X", + "iris.desktop.imagemap.source_origin_z": "Source origin Z", + "iris.desktop.imagemap.rotation": "Rotation", + "iris.desktop.imagemap.mirror_x": "Mirror X", + "iris.desktop.imagemap.mirror_z": "Mirror Z", + "iris.desktop.imagemap.sampling": "Sampling", + "iris.desktop.imagemap.out_of_bounds": "Out of bounds", + "iris.desktop.imagemap.alpha": "Alpha policy", + "iris.desktop.imagemap.fallback_value": "Fallback value", + "iris.desktop.imagemap.fallback_target": "Fallback target", + "iris.desktop.imagemap.minimum_height": "Minimum height", + "iris.desktop.imagemap.maximum_height": "Maximum height", + "iris.desktop.imagemap.vertical_offset": "Vertical offset", + "iris.desktop.imagemap.clamp": "Clamp height", + "iris.desktop.imagemap.inverted": "Invert values", + "iris.desktop.imagemap.curve_exponent": "Curve exponent", + "iris.desktop.imagemap.smoothing_radius": "Smoothing radius", + "iris.desktop.imagemap.threshold": "Threshold", + "iris.desktop.imagemap.falloff": "Falloff", + "iris.desktop.imagemap.color_tolerance": "Raw sRGB tolerance", + "iris.desktop.imagemap.unknown_color": "Unknown color", + "iris.desktop.imagemap.add_color": "Add legend color", + "iris.desktop.imagemap.remove_color": "Remove selected", + "iris.desktop.imagemap.composed_masks": "Composed masks", + "iris.desktop.imagemap.add_mask": "Add mask binding", + "iris.desktop.imagemap.height": "Height interpretation", + "iris.desktop.imagemap.mask": "Mask interpretation", + "iris.desktop.imagemap.color_map": "Color legend", + "iris.desktop.imagemap.overlays": "World overlays", + "iris.desktop.imagemap.chunks": "16-block chunks", + "iris.desktop.imagemap.regions": "512-block regions", + "iris.desktop.imagemap.boundary": "World boundary", + "iris.desktop.imagemap.coverage": "Source coverage", + "iris.desktop.imagemap.diagnostics": "Diagnostics", + "iris.desktop.imagemap.ready": "Ready", + "iris.desktop.imagemap.no_source": "Import a PNG source to begin.", + "iris.desktop.imagemap.loading": "Loading preset...", + "iris.desktop.imagemap.load_failed": "Preset load failed", + "iris.desktop.imagemap.previewing": "Compiling preview...", + "iris.desktop.imagemap.preview_valid": "Runtime compiler validation passed.", + "iris.desktop.imagemap.preview_failed": "Preview failed", + "iris.desktop.imagemap.exporting": "Validating and exporting...", + "iris.desktop.imagemap.exported": "Exported image map, PNG, and dimension binding atomically.", + "iris.desktop.imagemap.export_failed": "Export failed", + "iris.desktop.imagemap.source": "Source pixels", + "iris.desktop.imagemap.interpreted": "Runtime interpretation", + "iris.desktop.imagemap.no_preview": "Compile a valid preview to inspect world output.", + "iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel", + "iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.", + "iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.", + "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display." } } diff --git a/core/src/main/resources/languages/ko_KR.json b/core/src/main/resources/languages/ko_KR.json index 567af8bab..086f3c05e 100644 --- a/core/src/main/resources/languages/ko_KR.json +++ b/core/src/main/resources/languages/ko_KR.json @@ -1283,7 +1283,6 @@ "iris.runtime.pack_download.invalid_built_in": "§cIris는 'overworld'와 'underworld'에 대해서만 기본 제공 다운로드를 지원합니다.", "iris.runtime.pack_download.shutting_down": "§eIris가 종료 중이므로 팩 다운로드를 받을 수 없습니다.", "iris.runtime.pack_download.downloading": "다운로드 {url}", - "iris.runtime.pack_download.failed_to_find": "팩을 찾기 위해 실패 {url}", "iris.runtime.pack_download.unpacking": "옵션 정보 {repository}", "iris.runtime.pack_download.unpack_failed": [ "압축 해제 중 문제가 발생했습니다. 다음 항목을 확인하세요:", @@ -1482,6 +1481,75 @@ "iris.runtime.what.flag.ore": "ore", "iris.runtime.what.flag.block_entity": "block entity", "iris.runtime.world.height_range": "World height: {minY} to {maxY}", - "iris.runtime.world.height_total": "Total height: {height}" + "iris.runtime.world.height_total": "Total height: {height}", + "iris.desktop.imagemap.title": "Image Map Studio", + "iris.desktop.imagemap.preset": "Preset:", + "iris.desktop.imagemap.load": "Load", + "iris.desktop.imagemap.import_png": "Import PNG", + "iris.desktop.imagemap.replace_png": "Replace PNG", + "iris.desktop.imagemap.preview": "Preview", + "iris.desktop.imagemap.export": "Export to active pack", + "iris.desktop.imagemap.metadata": "Source metadata", + "iris.desktop.imagemap.resource": "Resource binding", + "iris.desktop.imagemap.coordinates": "Coordinates and sampling", + "iris.desktop.imagemap.binding_key": "Binding key", + "iris.desktop.imagemap.map_key": "Map key", + "iris.desktop.imagemap.image_key": "Image key", + "iris.desktop.imagemap.type": "Type", + "iris.desktop.imagemap.application": "Application", + "iris.desktop.imagemap.blocks_per_pixel": "Blocks per pixel", + "iris.desktop.imagemap.origin_x": "World origin X", + "iris.desktop.imagemap.origin_z": "World origin Z", + "iris.desktop.imagemap.source_origin_x": "Source origin X", + "iris.desktop.imagemap.source_origin_z": "Source origin Z", + "iris.desktop.imagemap.rotation": "Rotation", + "iris.desktop.imagemap.mirror_x": "Mirror X", + "iris.desktop.imagemap.mirror_z": "Mirror Z", + "iris.desktop.imagemap.sampling": "Sampling", + "iris.desktop.imagemap.out_of_bounds": "Out of bounds", + "iris.desktop.imagemap.alpha": "Alpha policy", + "iris.desktop.imagemap.fallback_value": "Fallback value", + "iris.desktop.imagemap.fallback_target": "Fallback target", + "iris.desktop.imagemap.minimum_height": "Minimum height", + "iris.desktop.imagemap.maximum_height": "Maximum height", + "iris.desktop.imagemap.vertical_offset": "Vertical offset", + "iris.desktop.imagemap.clamp": "Clamp height", + "iris.desktop.imagemap.inverted": "Invert values", + "iris.desktop.imagemap.curve_exponent": "Curve exponent", + "iris.desktop.imagemap.smoothing_radius": "Smoothing radius", + "iris.desktop.imagemap.threshold": "Threshold", + "iris.desktop.imagemap.falloff": "Falloff", + "iris.desktop.imagemap.color_tolerance": "Raw sRGB tolerance", + "iris.desktop.imagemap.unknown_color": "Unknown color", + "iris.desktop.imagemap.add_color": "Add legend color", + "iris.desktop.imagemap.remove_color": "Remove selected", + "iris.desktop.imagemap.composed_masks": "Composed masks", + "iris.desktop.imagemap.add_mask": "Add mask binding", + "iris.desktop.imagemap.height": "Height interpretation", + "iris.desktop.imagemap.mask": "Mask interpretation", + "iris.desktop.imagemap.color_map": "Color legend", + "iris.desktop.imagemap.overlays": "World overlays", + "iris.desktop.imagemap.chunks": "16-block chunks", + "iris.desktop.imagemap.regions": "512-block regions", + "iris.desktop.imagemap.boundary": "World boundary", + "iris.desktop.imagemap.coverage": "Source coverage", + "iris.desktop.imagemap.diagnostics": "Diagnostics", + "iris.desktop.imagemap.ready": "Ready", + "iris.desktop.imagemap.no_source": "Import a PNG source to begin.", + "iris.desktop.imagemap.loading": "Loading preset...", + "iris.desktop.imagemap.load_failed": "Preset load failed", + "iris.desktop.imagemap.previewing": "Compiling preview...", + "iris.desktop.imagemap.preview_valid": "Runtime compiler validation passed.", + "iris.desktop.imagemap.preview_failed": "Preview failed", + "iris.desktop.imagemap.exporting": "Validating and exporting...", + "iris.desktop.imagemap.exported": "Exported image map, PNG, and dimension binding atomically.", + "iris.desktop.imagemap.export_failed": "Export failed", + "iris.desktop.imagemap.source": "Source pixels", + "iris.desktop.imagemap.interpreted": "Runtime interpretation", + "iris.desktop.imagemap.no_preview": "Compile a valid preview to inspect world output.", + "iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel", + "iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.", + "iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.", + "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display." } } diff --git a/core/src/main/resources/languages/lt_LT.json b/core/src/main/resources/languages/lt_LT.json index 6bf4b5cb5..4ed1ba76f 100644 --- a/core/src/main/resources/languages/lt_LT.json +++ b/core/src/main/resources/languages/lt_LT.json @@ -1283,7 +1283,6 @@ "iris.runtime.pack_download.invalid_built_in": "§cIris integruotai leidžia atsisiųsti tik 'overworld' ir 'underworld'.", "iris.runtime.pack_download.shutting_down": "§eIris išjungiamas ir nebepriima paketų atsisiuntimų.", "iris.runtime.pack_download.downloading": "Atsiunčiama {url}", - "iris.runtime.pack_download.failed_to_find": "Nepavyko rasti pakuotės {url}", "iris.runtime.pack_download.unpacking": "Išpakavimas {repository}", "iris.runtime.pack_download.unpack_failed": [ "Išpakuojama. Prašome patikrinti / atlikti šiuos veiksmus:", @@ -1482,6 +1481,75 @@ "iris.runtime.what.flag.ore": "ore", "iris.runtime.what.flag.block_entity": "block entity", "iris.runtime.world.height_range": "World height: {minY} to {maxY}", - "iris.runtime.world.height_total": "Total height: {height}" + "iris.runtime.world.height_total": "Total height: {height}", + "iris.desktop.imagemap.title": "Image Map Studio", + "iris.desktop.imagemap.preset": "Preset:", + "iris.desktop.imagemap.load": "Load", + "iris.desktop.imagemap.import_png": "Import PNG", + "iris.desktop.imagemap.replace_png": "Replace PNG", + "iris.desktop.imagemap.preview": "Preview", + "iris.desktop.imagemap.export": "Export to active pack", + "iris.desktop.imagemap.metadata": "Source metadata", + "iris.desktop.imagemap.resource": "Resource binding", + "iris.desktop.imagemap.coordinates": "Coordinates and sampling", + "iris.desktop.imagemap.binding_key": "Binding key", + "iris.desktop.imagemap.map_key": "Map key", + "iris.desktop.imagemap.image_key": "Image key", + "iris.desktop.imagemap.type": "Type", + "iris.desktop.imagemap.application": "Application", + "iris.desktop.imagemap.blocks_per_pixel": "Blocks per pixel", + "iris.desktop.imagemap.origin_x": "World origin X", + "iris.desktop.imagemap.origin_z": "World origin Z", + "iris.desktop.imagemap.source_origin_x": "Source origin X", + "iris.desktop.imagemap.source_origin_z": "Source origin Z", + "iris.desktop.imagemap.rotation": "Rotation", + "iris.desktop.imagemap.mirror_x": "Mirror X", + "iris.desktop.imagemap.mirror_z": "Mirror Z", + "iris.desktop.imagemap.sampling": "Sampling", + "iris.desktop.imagemap.out_of_bounds": "Out of bounds", + "iris.desktop.imagemap.alpha": "Alpha policy", + "iris.desktop.imagemap.fallback_value": "Fallback value", + "iris.desktop.imagemap.fallback_target": "Fallback target", + "iris.desktop.imagemap.minimum_height": "Minimum height", + "iris.desktop.imagemap.maximum_height": "Maximum height", + "iris.desktop.imagemap.vertical_offset": "Vertical offset", + "iris.desktop.imagemap.clamp": "Clamp height", + "iris.desktop.imagemap.inverted": "Invert values", + "iris.desktop.imagemap.curve_exponent": "Curve exponent", + "iris.desktop.imagemap.smoothing_radius": "Smoothing radius", + "iris.desktop.imagemap.threshold": "Threshold", + "iris.desktop.imagemap.falloff": "Falloff", + "iris.desktop.imagemap.color_tolerance": "Raw sRGB tolerance", + "iris.desktop.imagemap.unknown_color": "Unknown color", + "iris.desktop.imagemap.add_color": "Add legend color", + "iris.desktop.imagemap.remove_color": "Remove selected", + "iris.desktop.imagemap.composed_masks": "Composed masks", + "iris.desktop.imagemap.add_mask": "Add mask binding", + "iris.desktop.imagemap.height": "Height interpretation", + "iris.desktop.imagemap.mask": "Mask interpretation", + "iris.desktop.imagemap.color_map": "Color legend", + "iris.desktop.imagemap.overlays": "World overlays", + "iris.desktop.imagemap.chunks": "16-block chunks", + "iris.desktop.imagemap.regions": "512-block regions", + "iris.desktop.imagemap.boundary": "World boundary", + "iris.desktop.imagemap.coverage": "Source coverage", + "iris.desktop.imagemap.diagnostics": "Diagnostics", + "iris.desktop.imagemap.ready": "Ready", + "iris.desktop.imagemap.no_source": "Import a PNG source to begin.", + "iris.desktop.imagemap.loading": "Loading preset...", + "iris.desktop.imagemap.load_failed": "Preset load failed", + "iris.desktop.imagemap.previewing": "Compiling preview...", + "iris.desktop.imagemap.preview_valid": "Runtime compiler validation passed.", + "iris.desktop.imagemap.preview_failed": "Preview failed", + "iris.desktop.imagemap.exporting": "Validating and exporting...", + "iris.desktop.imagemap.exported": "Exported image map, PNG, and dimension binding atomically.", + "iris.desktop.imagemap.export_failed": "Export failed", + "iris.desktop.imagemap.source": "Source pixels", + "iris.desktop.imagemap.interpreted": "Runtime interpretation", + "iris.desktop.imagemap.no_preview": "Compile a valid preview to inspect world output.", + "iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel", + "iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.", + "iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.", + "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display." } } diff --git a/core/src/main/resources/languages/nl_NL.json b/core/src/main/resources/languages/nl_NL.json index 4b9ba84de..b889f0919 100644 --- a/core/src/main/resources/languages/nl_NL.json +++ b/core/src/main/resources/languages/nl_NL.json @@ -1283,7 +1283,6 @@ "iris.runtime.pack_download.invalid_built_in": "§cIris biedt alleen ingebouwde downloads voor 'overworld' en 'underworld'.", "iris.runtime.pack_download.shutting_down": "§eIris wordt afgesloten en accepteert geen pakketdownloads.", "iris.runtime.pack_download.downloading": "Downloaden {url}", - "iris.runtime.pack_download.failed_to_find": "Kon pakket niet vinden op {url}", "iris.runtime.pack_download.unpacking": "Uitpakken {repository}", "iris.runtime.pack_download.unpack_failed": [ "Uitpakken. Controleer/doe het volgende:", @@ -1482,6 +1481,75 @@ "iris.runtime.what.flag.ore": "ore", "iris.runtime.what.flag.block_entity": "block entity", "iris.runtime.world.height_range": "World height: {minY} to {maxY}", - "iris.runtime.world.height_total": "Total height: {height}" + "iris.runtime.world.height_total": "Total height: {height}", + "iris.desktop.imagemap.title": "Image Map Studio", + "iris.desktop.imagemap.preset": "Preset:", + "iris.desktop.imagemap.load": "Load", + "iris.desktop.imagemap.import_png": "Import PNG", + "iris.desktop.imagemap.replace_png": "Replace PNG", + "iris.desktop.imagemap.preview": "Preview", + "iris.desktop.imagemap.export": "Export to active pack", + "iris.desktop.imagemap.metadata": "Source metadata", + "iris.desktop.imagemap.resource": "Resource binding", + "iris.desktop.imagemap.coordinates": "Coordinates and sampling", + "iris.desktop.imagemap.binding_key": "Binding key", + "iris.desktop.imagemap.map_key": "Map key", + "iris.desktop.imagemap.image_key": "Image key", + "iris.desktop.imagemap.type": "Type", + "iris.desktop.imagemap.application": "Application", + "iris.desktop.imagemap.blocks_per_pixel": "Blocks per pixel", + "iris.desktop.imagemap.origin_x": "World origin X", + "iris.desktop.imagemap.origin_z": "World origin Z", + "iris.desktop.imagemap.source_origin_x": "Source origin X", + "iris.desktop.imagemap.source_origin_z": "Source origin Z", + "iris.desktop.imagemap.rotation": "Rotation", + "iris.desktop.imagemap.mirror_x": "Mirror X", + "iris.desktop.imagemap.mirror_z": "Mirror Z", + "iris.desktop.imagemap.sampling": "Sampling", + "iris.desktop.imagemap.out_of_bounds": "Out of bounds", + "iris.desktop.imagemap.alpha": "Alpha policy", + "iris.desktop.imagemap.fallback_value": "Fallback value", + "iris.desktop.imagemap.fallback_target": "Fallback target", + "iris.desktop.imagemap.minimum_height": "Minimum height", + "iris.desktop.imagemap.maximum_height": "Maximum height", + "iris.desktop.imagemap.vertical_offset": "Vertical offset", + "iris.desktop.imagemap.clamp": "Clamp height", + "iris.desktop.imagemap.inverted": "Invert values", + "iris.desktop.imagemap.curve_exponent": "Curve exponent", + "iris.desktop.imagemap.smoothing_radius": "Smoothing radius", + "iris.desktop.imagemap.threshold": "Threshold", + "iris.desktop.imagemap.falloff": "Falloff", + "iris.desktop.imagemap.color_tolerance": "Raw sRGB tolerance", + "iris.desktop.imagemap.unknown_color": "Unknown color", + "iris.desktop.imagemap.add_color": "Add legend color", + "iris.desktop.imagemap.remove_color": "Remove selected", + "iris.desktop.imagemap.composed_masks": "Composed masks", + "iris.desktop.imagemap.add_mask": "Add mask binding", + "iris.desktop.imagemap.height": "Height interpretation", + "iris.desktop.imagemap.mask": "Mask interpretation", + "iris.desktop.imagemap.color_map": "Color legend", + "iris.desktop.imagemap.overlays": "World overlays", + "iris.desktop.imagemap.chunks": "16-block chunks", + "iris.desktop.imagemap.regions": "512-block regions", + "iris.desktop.imagemap.boundary": "World boundary", + "iris.desktop.imagemap.coverage": "Source coverage", + "iris.desktop.imagemap.diagnostics": "Diagnostics", + "iris.desktop.imagemap.ready": "Ready", + "iris.desktop.imagemap.no_source": "Import a PNG source to begin.", + "iris.desktop.imagemap.loading": "Loading preset...", + "iris.desktop.imagemap.load_failed": "Preset load failed", + "iris.desktop.imagemap.previewing": "Compiling preview...", + "iris.desktop.imagemap.preview_valid": "Runtime compiler validation passed.", + "iris.desktop.imagemap.preview_failed": "Preview failed", + "iris.desktop.imagemap.exporting": "Validating and exporting...", + "iris.desktop.imagemap.exported": "Exported image map, PNG, and dimension binding atomically.", + "iris.desktop.imagemap.export_failed": "Export failed", + "iris.desktop.imagemap.source": "Source pixels", + "iris.desktop.imagemap.interpreted": "Runtime interpretation", + "iris.desktop.imagemap.no_preview": "Compile a valid preview to inspect world output.", + "iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel", + "iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.", + "iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.", + "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display." } } diff --git a/core/src/main/resources/languages/pl_PL.json b/core/src/main/resources/languages/pl_PL.json index 4741366ac..75ddd814a 100644 --- a/core/src/main/resources/languages/pl_PL.json +++ b/core/src/main/resources/languages/pl_PL.json @@ -1283,7 +1283,6 @@ "iris.runtime.pack_download.invalid_built_in": "§cIris udostępnia wbudowane pobieranie tylko dla 'overworld' i 'underworld'.", "iris.runtime.pack_download.shutting_down": "§eTrwa wyłączanie Iris; pobieranie pakietów nie jest już przyjmowane.", "iris.runtime.pack_download.downloading": "Pobieranie {url}", - "iris.runtime.pack_download.failed_to_find": "Nie udało się znaleźć pakietu w {url}", "iris.runtime.pack_download.unpacking": "Rozpakowanie {repository}", "iris.runtime.pack_download.unpack_failed": [ "Wydanie podczas rozpakowywania. Proszę sprawdzić / wykonać następujące czynności:", @@ -1482,6 +1481,75 @@ "iris.runtime.what.flag.ore": "ore", "iris.runtime.what.flag.block_entity": "block entity", "iris.runtime.world.height_range": "World height: {minY} to {maxY}", - "iris.runtime.world.height_total": "Total height: {height}" + "iris.runtime.world.height_total": "Total height: {height}", + "iris.desktop.imagemap.title": "Image Map Studio", + "iris.desktop.imagemap.preset": "Preset:", + "iris.desktop.imagemap.load": "Load", + "iris.desktop.imagemap.import_png": "Import PNG", + "iris.desktop.imagemap.replace_png": "Replace PNG", + "iris.desktop.imagemap.preview": "Preview", + "iris.desktop.imagemap.export": "Export to active pack", + "iris.desktop.imagemap.metadata": "Source metadata", + "iris.desktop.imagemap.resource": "Resource binding", + "iris.desktop.imagemap.coordinates": "Coordinates and sampling", + "iris.desktop.imagemap.binding_key": "Binding key", + "iris.desktop.imagemap.map_key": "Map key", + "iris.desktop.imagemap.image_key": "Image key", + "iris.desktop.imagemap.type": "Type", + "iris.desktop.imagemap.application": "Application", + "iris.desktop.imagemap.blocks_per_pixel": "Blocks per pixel", + "iris.desktop.imagemap.origin_x": "World origin X", + "iris.desktop.imagemap.origin_z": "World origin Z", + "iris.desktop.imagemap.source_origin_x": "Source origin X", + "iris.desktop.imagemap.source_origin_z": "Source origin Z", + "iris.desktop.imagemap.rotation": "Rotation", + "iris.desktop.imagemap.mirror_x": "Mirror X", + "iris.desktop.imagemap.mirror_z": "Mirror Z", + "iris.desktop.imagemap.sampling": "Sampling", + "iris.desktop.imagemap.out_of_bounds": "Out of bounds", + "iris.desktop.imagemap.alpha": "Alpha policy", + "iris.desktop.imagemap.fallback_value": "Fallback value", + "iris.desktop.imagemap.fallback_target": "Fallback target", + "iris.desktop.imagemap.minimum_height": "Minimum height", + "iris.desktop.imagemap.maximum_height": "Maximum height", + "iris.desktop.imagemap.vertical_offset": "Vertical offset", + "iris.desktop.imagemap.clamp": "Clamp height", + "iris.desktop.imagemap.inverted": "Invert values", + "iris.desktop.imagemap.curve_exponent": "Curve exponent", + "iris.desktop.imagemap.smoothing_radius": "Smoothing radius", + "iris.desktop.imagemap.threshold": "Threshold", + "iris.desktop.imagemap.falloff": "Falloff", + "iris.desktop.imagemap.color_tolerance": "Raw sRGB tolerance", + "iris.desktop.imagemap.unknown_color": "Unknown color", + "iris.desktop.imagemap.add_color": "Add legend color", + "iris.desktop.imagemap.remove_color": "Remove selected", + "iris.desktop.imagemap.composed_masks": "Composed masks", + "iris.desktop.imagemap.add_mask": "Add mask binding", + "iris.desktop.imagemap.height": "Height interpretation", + "iris.desktop.imagemap.mask": "Mask interpretation", + "iris.desktop.imagemap.color_map": "Color legend", + "iris.desktop.imagemap.overlays": "World overlays", + "iris.desktop.imagemap.chunks": "16-block chunks", + "iris.desktop.imagemap.regions": "512-block regions", + "iris.desktop.imagemap.boundary": "World boundary", + "iris.desktop.imagemap.coverage": "Source coverage", + "iris.desktop.imagemap.diagnostics": "Diagnostics", + "iris.desktop.imagemap.ready": "Ready", + "iris.desktop.imagemap.no_source": "Import a PNG source to begin.", + "iris.desktop.imagemap.loading": "Loading preset...", + "iris.desktop.imagemap.load_failed": "Preset load failed", + "iris.desktop.imagemap.previewing": "Compiling preview...", + "iris.desktop.imagemap.preview_valid": "Runtime compiler validation passed.", + "iris.desktop.imagemap.preview_failed": "Preview failed", + "iris.desktop.imagemap.exporting": "Validating and exporting...", + "iris.desktop.imagemap.exported": "Exported image map, PNG, and dimension binding atomically.", + "iris.desktop.imagemap.export_failed": "Export failed", + "iris.desktop.imagemap.source": "Source pixels", + "iris.desktop.imagemap.interpreted": "Runtime interpretation", + "iris.desktop.imagemap.no_preview": "Compile a valid preview to inspect world output.", + "iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel", + "iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.", + "iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.", + "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display." } } diff --git a/core/src/main/resources/languages/pt_PT.json b/core/src/main/resources/languages/pt_PT.json index a2e4dcbe4..4cc9466c6 100644 --- a/core/src/main/resources/languages/pt_PT.json +++ b/core/src/main/resources/languages/pt_PT.json @@ -1283,7 +1283,6 @@ "iris.runtime.pack_download.invalid_built_in": "§cIris apenas disponibiliza transferências integradas para 'overworld' e 'underworld'.", "iris.runtime.pack_download.shutting_down": "§eIris está a encerrar e não aceita transferências de packs.", "iris.runtime.pack_download.downloading": "Baixando {url}", - "iris.runtime.pack_download.failed_to_find": "Não foi possível encontrar o pacote em {url}", "iris.runtime.pack_download.unpacking": "Desembalar {repository}", "iris.runtime.pack_download.unpack_failed": [ "Problema ao desfazer as malas. Verifique/faça o seguinte:", @@ -1482,6 +1481,75 @@ "iris.runtime.what.flag.ore": "ore", "iris.runtime.what.flag.block_entity": "block entity", "iris.runtime.world.height_range": "World height: {minY} to {maxY}", - "iris.runtime.world.height_total": "Total height: {height}" + "iris.runtime.world.height_total": "Total height: {height}", + "iris.desktop.imagemap.title": "Image Map Studio", + "iris.desktop.imagemap.preset": "Preset:", + "iris.desktop.imagemap.load": "Load", + "iris.desktop.imagemap.import_png": "Import PNG", + "iris.desktop.imagemap.replace_png": "Replace PNG", + "iris.desktop.imagemap.preview": "Preview", + "iris.desktop.imagemap.export": "Export to active pack", + "iris.desktop.imagemap.metadata": "Source metadata", + "iris.desktop.imagemap.resource": "Resource binding", + "iris.desktop.imagemap.coordinates": "Coordinates and sampling", + "iris.desktop.imagemap.binding_key": "Binding key", + "iris.desktop.imagemap.map_key": "Map key", + "iris.desktop.imagemap.image_key": "Image key", + "iris.desktop.imagemap.type": "Type", + "iris.desktop.imagemap.application": "Application", + "iris.desktop.imagemap.blocks_per_pixel": "Blocks per pixel", + "iris.desktop.imagemap.origin_x": "World origin X", + "iris.desktop.imagemap.origin_z": "World origin Z", + "iris.desktop.imagemap.source_origin_x": "Source origin X", + "iris.desktop.imagemap.source_origin_z": "Source origin Z", + "iris.desktop.imagemap.rotation": "Rotation", + "iris.desktop.imagemap.mirror_x": "Mirror X", + "iris.desktop.imagemap.mirror_z": "Mirror Z", + "iris.desktop.imagemap.sampling": "Sampling", + "iris.desktop.imagemap.out_of_bounds": "Out of bounds", + "iris.desktop.imagemap.alpha": "Alpha policy", + "iris.desktop.imagemap.fallback_value": "Fallback value", + "iris.desktop.imagemap.fallback_target": "Fallback target", + "iris.desktop.imagemap.minimum_height": "Minimum height", + "iris.desktop.imagemap.maximum_height": "Maximum height", + "iris.desktop.imagemap.vertical_offset": "Vertical offset", + "iris.desktop.imagemap.clamp": "Clamp height", + "iris.desktop.imagemap.inverted": "Invert values", + "iris.desktop.imagemap.curve_exponent": "Curve exponent", + "iris.desktop.imagemap.smoothing_radius": "Smoothing radius", + "iris.desktop.imagemap.threshold": "Threshold", + "iris.desktop.imagemap.falloff": "Falloff", + "iris.desktop.imagemap.color_tolerance": "Raw sRGB tolerance", + "iris.desktop.imagemap.unknown_color": "Unknown color", + "iris.desktop.imagemap.add_color": "Add legend color", + "iris.desktop.imagemap.remove_color": "Remove selected", + "iris.desktop.imagemap.composed_masks": "Composed masks", + "iris.desktop.imagemap.add_mask": "Add mask binding", + "iris.desktop.imagemap.height": "Height interpretation", + "iris.desktop.imagemap.mask": "Mask interpretation", + "iris.desktop.imagemap.color_map": "Color legend", + "iris.desktop.imagemap.overlays": "World overlays", + "iris.desktop.imagemap.chunks": "16-block chunks", + "iris.desktop.imagemap.regions": "512-block regions", + "iris.desktop.imagemap.boundary": "World boundary", + "iris.desktop.imagemap.coverage": "Source coverage", + "iris.desktop.imagemap.diagnostics": "Diagnostics", + "iris.desktop.imagemap.ready": "Ready", + "iris.desktop.imagemap.no_source": "Import a PNG source to begin.", + "iris.desktop.imagemap.loading": "Loading preset...", + "iris.desktop.imagemap.load_failed": "Preset load failed", + "iris.desktop.imagemap.previewing": "Compiling preview...", + "iris.desktop.imagemap.preview_valid": "Runtime compiler validation passed.", + "iris.desktop.imagemap.preview_failed": "Preview failed", + "iris.desktop.imagemap.exporting": "Validating and exporting...", + "iris.desktop.imagemap.exported": "Exported image map, PNG, and dimension binding atomically.", + "iris.desktop.imagemap.export_failed": "Export failed", + "iris.desktop.imagemap.source": "Source pixels", + "iris.desktop.imagemap.interpreted": "Runtime interpretation", + "iris.desktop.imagemap.no_preview": "Compile a valid preview to inspect world output.", + "iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel", + "iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.", + "iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.", + "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display." } } diff --git a/core/src/main/resources/languages/ru_RU.json b/core/src/main/resources/languages/ru_RU.json index c4616235f..8e8932d74 100644 --- a/core/src/main/resources/languages/ru_RU.json +++ b/core/src/main/resources/languages/ru_RU.json @@ -1283,7 +1283,6 @@ "iris.runtime.pack_download.invalid_built_in": "§cВстроенная загрузка Iris доступна только для 'overworld' и 'underworld'.", "iris.runtime.pack_download.shutting_down": "§eIris завершает работу и не принимает загрузки пакетов.", "iris.runtime.pack_download.downloading": "Скачать {url}", - "iris.runtime.pack_download.failed_to_find": "Не удалось найти стаю {url}", "iris.runtime.pack_download.unpacking": "распаковка {repository}", "iris.runtime.pack_download.unpack_failed": [ "Проблемы при распаковке. Пожалуйста, проверьте / сделайте следующее:", @@ -1482,6 +1481,75 @@ "iris.runtime.what.flag.ore": "ore", "iris.runtime.what.flag.block_entity": "block entity", "iris.runtime.world.height_range": "World height: {minY} to {maxY}", - "iris.runtime.world.height_total": "Total height: {height}" + "iris.runtime.world.height_total": "Total height: {height}", + "iris.desktop.imagemap.title": "Image Map Studio", + "iris.desktop.imagemap.preset": "Preset:", + "iris.desktop.imagemap.load": "Load", + "iris.desktop.imagemap.import_png": "Import PNG", + "iris.desktop.imagemap.replace_png": "Replace PNG", + "iris.desktop.imagemap.preview": "Preview", + "iris.desktop.imagemap.export": "Export to active pack", + "iris.desktop.imagemap.metadata": "Source metadata", + "iris.desktop.imagemap.resource": "Resource binding", + "iris.desktop.imagemap.coordinates": "Coordinates and sampling", + "iris.desktop.imagemap.binding_key": "Binding key", + "iris.desktop.imagemap.map_key": "Map key", + "iris.desktop.imagemap.image_key": "Image key", + "iris.desktop.imagemap.type": "Type", + "iris.desktop.imagemap.application": "Application", + "iris.desktop.imagemap.blocks_per_pixel": "Blocks per pixel", + "iris.desktop.imagemap.origin_x": "World origin X", + "iris.desktop.imagemap.origin_z": "World origin Z", + "iris.desktop.imagemap.source_origin_x": "Source origin X", + "iris.desktop.imagemap.source_origin_z": "Source origin Z", + "iris.desktop.imagemap.rotation": "Rotation", + "iris.desktop.imagemap.mirror_x": "Mirror X", + "iris.desktop.imagemap.mirror_z": "Mirror Z", + "iris.desktop.imagemap.sampling": "Sampling", + "iris.desktop.imagemap.out_of_bounds": "Out of bounds", + "iris.desktop.imagemap.alpha": "Alpha policy", + "iris.desktop.imagemap.fallback_value": "Fallback value", + "iris.desktop.imagemap.fallback_target": "Fallback target", + "iris.desktop.imagemap.minimum_height": "Minimum height", + "iris.desktop.imagemap.maximum_height": "Maximum height", + "iris.desktop.imagemap.vertical_offset": "Vertical offset", + "iris.desktop.imagemap.clamp": "Clamp height", + "iris.desktop.imagemap.inverted": "Invert values", + "iris.desktop.imagemap.curve_exponent": "Curve exponent", + "iris.desktop.imagemap.smoothing_radius": "Smoothing radius", + "iris.desktop.imagemap.threshold": "Threshold", + "iris.desktop.imagemap.falloff": "Falloff", + "iris.desktop.imagemap.color_tolerance": "Raw sRGB tolerance", + "iris.desktop.imagemap.unknown_color": "Unknown color", + "iris.desktop.imagemap.add_color": "Add legend color", + "iris.desktop.imagemap.remove_color": "Remove selected", + "iris.desktop.imagemap.composed_masks": "Composed masks", + "iris.desktop.imagemap.add_mask": "Add mask binding", + "iris.desktop.imagemap.height": "Height interpretation", + "iris.desktop.imagemap.mask": "Mask interpretation", + "iris.desktop.imagemap.color_map": "Color legend", + "iris.desktop.imagemap.overlays": "World overlays", + "iris.desktop.imagemap.chunks": "16-block chunks", + "iris.desktop.imagemap.regions": "512-block regions", + "iris.desktop.imagemap.boundary": "World boundary", + "iris.desktop.imagemap.coverage": "Source coverage", + "iris.desktop.imagemap.diagnostics": "Diagnostics", + "iris.desktop.imagemap.ready": "Ready", + "iris.desktop.imagemap.no_source": "Import a PNG source to begin.", + "iris.desktop.imagemap.loading": "Loading preset...", + "iris.desktop.imagemap.load_failed": "Preset load failed", + "iris.desktop.imagemap.previewing": "Compiling preview...", + "iris.desktop.imagemap.preview_valid": "Runtime compiler validation passed.", + "iris.desktop.imagemap.preview_failed": "Preview failed", + "iris.desktop.imagemap.exporting": "Validating and exporting...", + "iris.desktop.imagemap.exported": "Exported image map, PNG, and dimension binding atomically.", + "iris.desktop.imagemap.export_failed": "Export failed", + "iris.desktop.imagemap.source": "Source pixels", + "iris.desktop.imagemap.interpreted": "Runtime interpretation", + "iris.desktop.imagemap.no_preview": "Compile a valid preview to inspect world output.", + "iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel", + "iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.", + "iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.", + "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display." } } diff --git a/core/src/main/resources/languages/tr_TR.json b/core/src/main/resources/languages/tr_TR.json index 43aab055b..debb5802a 100644 --- a/core/src/main/resources/languages/tr_TR.json +++ b/core/src/main/resources/languages/tr_TR.json @@ -1283,7 +1283,6 @@ "iris.runtime.pack_download.invalid_built_in": "§cIris yalnızca 'overworld' ve 'underworld' için yerleşik indirmeler sunar.", "iris.runtime.pack_download.shutting_down": "§eIris kapanıyor ve paket indirmelerini kabul etmiyor.", "iris.runtime.pack_download.downloading": "Downloading indir {url}", - "iris.runtime.pack_download.failed_to_find": "Paket bulmak için başarısız oldu {url}", "iris.runtime.pack_download.unpacking": "Unpackinging {repository}", "iris.runtime.pack_download.unpack_failed": [ "Paket açılırken sorun oluştu. Lütfen şunları kontrol edin/yapın:", @@ -1482,6 +1481,75 @@ "iris.runtime.what.flag.ore": "ore", "iris.runtime.what.flag.block_entity": "block entity", "iris.runtime.world.height_range": "World height: {minY} to {maxY}", - "iris.runtime.world.height_total": "Total height: {height}" + "iris.runtime.world.height_total": "Total height: {height}", + "iris.desktop.imagemap.title": "Image Map Studio", + "iris.desktop.imagemap.preset": "Preset:", + "iris.desktop.imagemap.load": "Load", + "iris.desktop.imagemap.import_png": "Import PNG", + "iris.desktop.imagemap.replace_png": "Replace PNG", + "iris.desktop.imagemap.preview": "Preview", + "iris.desktop.imagemap.export": "Export to active pack", + "iris.desktop.imagemap.metadata": "Source metadata", + "iris.desktop.imagemap.resource": "Resource binding", + "iris.desktop.imagemap.coordinates": "Coordinates and sampling", + "iris.desktop.imagemap.binding_key": "Binding key", + "iris.desktop.imagemap.map_key": "Map key", + "iris.desktop.imagemap.image_key": "Image key", + "iris.desktop.imagemap.type": "Type", + "iris.desktop.imagemap.application": "Application", + "iris.desktop.imagemap.blocks_per_pixel": "Blocks per pixel", + "iris.desktop.imagemap.origin_x": "World origin X", + "iris.desktop.imagemap.origin_z": "World origin Z", + "iris.desktop.imagemap.source_origin_x": "Source origin X", + "iris.desktop.imagemap.source_origin_z": "Source origin Z", + "iris.desktop.imagemap.rotation": "Rotation", + "iris.desktop.imagemap.mirror_x": "Mirror X", + "iris.desktop.imagemap.mirror_z": "Mirror Z", + "iris.desktop.imagemap.sampling": "Sampling", + "iris.desktop.imagemap.out_of_bounds": "Out of bounds", + "iris.desktop.imagemap.alpha": "Alpha policy", + "iris.desktop.imagemap.fallback_value": "Fallback value", + "iris.desktop.imagemap.fallback_target": "Fallback target", + "iris.desktop.imagemap.minimum_height": "Minimum height", + "iris.desktop.imagemap.maximum_height": "Maximum height", + "iris.desktop.imagemap.vertical_offset": "Vertical offset", + "iris.desktop.imagemap.clamp": "Clamp height", + "iris.desktop.imagemap.inverted": "Invert values", + "iris.desktop.imagemap.curve_exponent": "Curve exponent", + "iris.desktop.imagemap.smoothing_radius": "Smoothing radius", + "iris.desktop.imagemap.threshold": "Threshold", + "iris.desktop.imagemap.falloff": "Falloff", + "iris.desktop.imagemap.color_tolerance": "Raw sRGB tolerance", + "iris.desktop.imagemap.unknown_color": "Unknown color", + "iris.desktop.imagemap.add_color": "Add legend color", + "iris.desktop.imagemap.remove_color": "Remove selected", + "iris.desktop.imagemap.composed_masks": "Composed masks", + "iris.desktop.imagemap.add_mask": "Add mask binding", + "iris.desktop.imagemap.height": "Height interpretation", + "iris.desktop.imagemap.mask": "Mask interpretation", + "iris.desktop.imagemap.color_map": "Color legend", + "iris.desktop.imagemap.overlays": "World overlays", + "iris.desktop.imagemap.chunks": "16-block chunks", + "iris.desktop.imagemap.regions": "512-block regions", + "iris.desktop.imagemap.boundary": "World boundary", + "iris.desktop.imagemap.coverage": "Source coverage", + "iris.desktop.imagemap.diagnostics": "Diagnostics", + "iris.desktop.imagemap.ready": "Ready", + "iris.desktop.imagemap.no_source": "Import a PNG source to begin.", + "iris.desktop.imagemap.loading": "Loading preset...", + "iris.desktop.imagemap.load_failed": "Preset load failed", + "iris.desktop.imagemap.previewing": "Compiling preview...", + "iris.desktop.imagemap.preview_valid": "Runtime compiler validation passed.", + "iris.desktop.imagemap.preview_failed": "Preview failed", + "iris.desktop.imagemap.exporting": "Validating and exporting...", + "iris.desktop.imagemap.exported": "Exported image map, PNG, and dimension binding atomically.", + "iris.desktop.imagemap.export_failed": "Export failed", + "iris.desktop.imagemap.source": "Source pixels", + "iris.desktop.imagemap.interpreted": "Runtime interpretation", + "iris.desktop.imagemap.no_preview": "Compile a valid preview to inspect world output.", + "iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel", + "iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.", + "iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.", + "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display." } } diff --git a/core/src/main/resources/languages/vi_VI.json b/core/src/main/resources/languages/vi_VI.json index ddfad5032..73a65069e 100644 --- a/core/src/main/resources/languages/vi_VI.json +++ b/core/src/main/resources/languages/vi_VI.json @@ -1283,7 +1283,6 @@ "iris.runtime.pack_download.invalid_built_in": "§cIris chỉ cung cấp bản tải xuống tích hợp cho 'overworld' và 'underworld'.", "iris.runtime.pack_download.shutting_down": "§eIris đang tắt và không nhận yêu cầu tải gói.", "iris.runtime.pack_download.downloading": "Đang tải về {url}", - "iris.runtime.pack_download.failed_to_find": "Không tìm thấy gói tại {url}", "iris.runtime.pack_download.unpacking": "Đang mở gói {repository}", "iris.runtime.pack_download.unpack_failed": [ "Vấn đề là khi dỡ đồ ra. Vui lòng kiểm tra/ làm những điều sau:", @@ -1482,6 +1481,75 @@ "iris.runtime.what.flag.ore": "ore", "iris.runtime.what.flag.block_entity": "block entity", "iris.runtime.world.height_range": "World height: {minY} to {maxY}", - "iris.runtime.world.height_total": "Total height: {height}" + "iris.runtime.world.height_total": "Total height: {height}", + "iris.desktop.imagemap.title": "Image Map Studio", + "iris.desktop.imagemap.preset": "Preset:", + "iris.desktop.imagemap.load": "Load", + "iris.desktop.imagemap.import_png": "Import PNG", + "iris.desktop.imagemap.replace_png": "Replace PNG", + "iris.desktop.imagemap.preview": "Preview", + "iris.desktop.imagemap.export": "Export to active pack", + "iris.desktop.imagemap.metadata": "Source metadata", + "iris.desktop.imagemap.resource": "Resource binding", + "iris.desktop.imagemap.coordinates": "Coordinates and sampling", + "iris.desktop.imagemap.binding_key": "Binding key", + "iris.desktop.imagemap.map_key": "Map key", + "iris.desktop.imagemap.image_key": "Image key", + "iris.desktop.imagemap.type": "Type", + "iris.desktop.imagemap.application": "Application", + "iris.desktop.imagemap.blocks_per_pixel": "Blocks per pixel", + "iris.desktop.imagemap.origin_x": "World origin X", + "iris.desktop.imagemap.origin_z": "World origin Z", + "iris.desktop.imagemap.source_origin_x": "Source origin X", + "iris.desktop.imagemap.source_origin_z": "Source origin Z", + "iris.desktop.imagemap.rotation": "Rotation", + "iris.desktop.imagemap.mirror_x": "Mirror X", + "iris.desktop.imagemap.mirror_z": "Mirror Z", + "iris.desktop.imagemap.sampling": "Sampling", + "iris.desktop.imagemap.out_of_bounds": "Out of bounds", + "iris.desktop.imagemap.alpha": "Alpha policy", + "iris.desktop.imagemap.fallback_value": "Fallback value", + "iris.desktop.imagemap.fallback_target": "Fallback target", + "iris.desktop.imagemap.minimum_height": "Minimum height", + "iris.desktop.imagemap.maximum_height": "Maximum height", + "iris.desktop.imagemap.vertical_offset": "Vertical offset", + "iris.desktop.imagemap.clamp": "Clamp height", + "iris.desktop.imagemap.inverted": "Invert values", + "iris.desktop.imagemap.curve_exponent": "Curve exponent", + "iris.desktop.imagemap.smoothing_radius": "Smoothing radius", + "iris.desktop.imagemap.threshold": "Threshold", + "iris.desktop.imagemap.falloff": "Falloff", + "iris.desktop.imagemap.color_tolerance": "Raw sRGB tolerance", + "iris.desktop.imagemap.unknown_color": "Unknown color", + "iris.desktop.imagemap.add_color": "Add legend color", + "iris.desktop.imagemap.remove_color": "Remove selected", + "iris.desktop.imagemap.composed_masks": "Composed masks", + "iris.desktop.imagemap.add_mask": "Add mask binding", + "iris.desktop.imagemap.height": "Height interpretation", + "iris.desktop.imagemap.mask": "Mask interpretation", + "iris.desktop.imagemap.color_map": "Color legend", + "iris.desktop.imagemap.overlays": "World overlays", + "iris.desktop.imagemap.chunks": "16-block chunks", + "iris.desktop.imagemap.regions": "512-block regions", + "iris.desktop.imagemap.boundary": "World boundary", + "iris.desktop.imagemap.coverage": "Source coverage", + "iris.desktop.imagemap.diagnostics": "Diagnostics", + "iris.desktop.imagemap.ready": "Ready", + "iris.desktop.imagemap.no_source": "Import a PNG source to begin.", + "iris.desktop.imagemap.loading": "Loading preset...", + "iris.desktop.imagemap.load_failed": "Preset load failed", + "iris.desktop.imagemap.previewing": "Compiling preview...", + "iris.desktop.imagemap.preview_valid": "Runtime compiler validation passed.", + "iris.desktop.imagemap.preview_failed": "Preview failed", + "iris.desktop.imagemap.exporting": "Validating and exporting...", + "iris.desktop.imagemap.exported": "Exported image map, PNG, and dimension binding atomically.", + "iris.desktop.imagemap.export_failed": "Export failed", + "iris.desktop.imagemap.source": "Source pixels", + "iris.desktop.imagemap.interpreted": "Runtime interpretation", + "iris.desktop.imagemap.no_preview": "Compile a valid preview to inspect world output.", + "iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel", + "iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.", + "iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.", + "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display." } } diff --git a/core/src/main/resources/languages/zh_CN.json b/core/src/main/resources/languages/zh_CN.json index 4f5dd9a06..983026bab 100644 --- a/core/src/main/resources/languages/zh_CN.json +++ b/core/src/main/resources/languages/zh_CN.json @@ -1283,7 +1283,6 @@ "iris.runtime.pack_download.invalid_built_in": "§cIris 仅为 'overworld' 和 'underworld' 提供内置下载。", "iris.runtime.pack_download.shutting_down": "§eIris 正在关闭,不再接受资源包下载。", "iris.runtime.pack_download.downloading": "下载 {url}", - "iris.runtime.pack_download.failed_to_find": "找到包失败 {url}", "iris.runtime.pack_download.unpacking": "正在解压 {repository}", "iris.runtime.pack_download.unpack_failed": [ "解包时发布. 请检查/做如下:", @@ -1482,6 +1481,75 @@ "iris.runtime.what.flag.ore": "ore", "iris.runtime.what.flag.block_entity": "block entity", "iris.runtime.world.height_range": "World height: {minY} to {maxY}", - "iris.runtime.world.height_total": "Total height: {height}" + "iris.runtime.world.height_total": "Total height: {height}", + "iris.desktop.imagemap.title": "Image Map Studio", + "iris.desktop.imagemap.preset": "Preset:", + "iris.desktop.imagemap.load": "Load", + "iris.desktop.imagemap.import_png": "Import PNG", + "iris.desktop.imagemap.replace_png": "Replace PNG", + "iris.desktop.imagemap.preview": "Preview", + "iris.desktop.imagemap.export": "Export to active pack", + "iris.desktop.imagemap.metadata": "Source metadata", + "iris.desktop.imagemap.resource": "Resource binding", + "iris.desktop.imagemap.coordinates": "Coordinates and sampling", + "iris.desktop.imagemap.binding_key": "Binding key", + "iris.desktop.imagemap.map_key": "Map key", + "iris.desktop.imagemap.image_key": "Image key", + "iris.desktop.imagemap.type": "Type", + "iris.desktop.imagemap.application": "Application", + "iris.desktop.imagemap.blocks_per_pixel": "Blocks per pixel", + "iris.desktop.imagemap.origin_x": "World origin X", + "iris.desktop.imagemap.origin_z": "World origin Z", + "iris.desktop.imagemap.source_origin_x": "Source origin X", + "iris.desktop.imagemap.source_origin_z": "Source origin Z", + "iris.desktop.imagemap.rotation": "Rotation", + "iris.desktop.imagemap.mirror_x": "Mirror X", + "iris.desktop.imagemap.mirror_z": "Mirror Z", + "iris.desktop.imagemap.sampling": "Sampling", + "iris.desktop.imagemap.out_of_bounds": "Out of bounds", + "iris.desktop.imagemap.alpha": "Alpha policy", + "iris.desktop.imagemap.fallback_value": "Fallback value", + "iris.desktop.imagemap.fallback_target": "Fallback target", + "iris.desktop.imagemap.minimum_height": "Minimum height", + "iris.desktop.imagemap.maximum_height": "Maximum height", + "iris.desktop.imagemap.vertical_offset": "Vertical offset", + "iris.desktop.imagemap.clamp": "Clamp height", + "iris.desktop.imagemap.inverted": "Invert values", + "iris.desktop.imagemap.curve_exponent": "Curve exponent", + "iris.desktop.imagemap.smoothing_radius": "Smoothing radius", + "iris.desktop.imagemap.threshold": "Threshold", + "iris.desktop.imagemap.falloff": "Falloff", + "iris.desktop.imagemap.color_tolerance": "Raw sRGB tolerance", + "iris.desktop.imagemap.unknown_color": "Unknown color", + "iris.desktop.imagemap.add_color": "Add legend color", + "iris.desktop.imagemap.remove_color": "Remove selected", + "iris.desktop.imagemap.composed_masks": "Composed masks", + "iris.desktop.imagemap.add_mask": "Add mask binding", + "iris.desktop.imagemap.height": "Height interpretation", + "iris.desktop.imagemap.mask": "Mask interpretation", + "iris.desktop.imagemap.color_map": "Color legend", + "iris.desktop.imagemap.overlays": "World overlays", + "iris.desktop.imagemap.chunks": "16-block chunks", + "iris.desktop.imagemap.regions": "512-block regions", + "iris.desktop.imagemap.boundary": "World boundary", + "iris.desktop.imagemap.coverage": "Source coverage", + "iris.desktop.imagemap.diagnostics": "Diagnostics", + "iris.desktop.imagemap.ready": "Ready", + "iris.desktop.imagemap.no_source": "Import a PNG source to begin.", + "iris.desktop.imagemap.loading": "Loading preset...", + "iris.desktop.imagemap.load_failed": "Preset load failed", + "iris.desktop.imagemap.previewing": "Compiling preview...", + "iris.desktop.imagemap.preview_valid": "Runtime compiler validation passed.", + "iris.desktop.imagemap.preview_failed": "Preview failed", + "iris.desktop.imagemap.exporting": "Validating and exporting...", + "iris.desktop.imagemap.exported": "Exported image map, PNG, and dimension binding atomically.", + "iris.desktop.imagemap.export_failed": "Export failed", + "iris.desktop.imagemap.source": "Source pixels", + "iris.desktop.imagemap.interpreted": "Runtime interpretation", + "iris.desktop.imagemap.no_preview": "Compile a valid preview to inspect world output.", + "iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel", + "iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.", + "iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.", + "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display." } } diff --git a/core/src/main/resources/languages/zh_TW.json b/core/src/main/resources/languages/zh_TW.json index b8d00257c..7f0e835ed 100644 --- a/core/src/main/resources/languages/zh_TW.json +++ b/core/src/main/resources/languages/zh_TW.json @@ -1283,7 +1283,6 @@ "iris.runtime.pack_download.invalid_built_in": "§cIris 僅為 'overworld' 和 'underworld' 提供內建下載。", "iris.runtime.pack_download.shutting_down": "§eIris 正在關閉,不再接受資源包下載。", "iris.runtime.pack_download.downloading": "下載 {url}", - "iris.runtime.pack_download.failed_to_find": "找到包失敗 {url}", "iris.runtime.pack_download.unpacking": "正在解壓縮 {repository}", "iris.runtime.pack_download.unpack_failed": [ "解包時釋出. 請檢查/做如下:", @@ -1482,6 +1481,75 @@ "iris.runtime.what.flag.ore": "ore", "iris.runtime.what.flag.block_entity": "block entity", "iris.runtime.world.height_range": "World height: {minY} to {maxY}", - "iris.runtime.world.height_total": "Total height: {height}" + "iris.runtime.world.height_total": "Total height: {height}", + "iris.desktop.imagemap.title": "Image Map Studio", + "iris.desktop.imagemap.preset": "Preset:", + "iris.desktop.imagemap.load": "Load", + "iris.desktop.imagemap.import_png": "Import PNG", + "iris.desktop.imagemap.replace_png": "Replace PNG", + "iris.desktop.imagemap.preview": "Preview", + "iris.desktop.imagemap.export": "Export to active pack", + "iris.desktop.imagemap.metadata": "Source metadata", + "iris.desktop.imagemap.resource": "Resource binding", + "iris.desktop.imagemap.coordinates": "Coordinates and sampling", + "iris.desktop.imagemap.binding_key": "Binding key", + "iris.desktop.imagemap.map_key": "Map key", + "iris.desktop.imagemap.image_key": "Image key", + "iris.desktop.imagemap.type": "Type", + "iris.desktop.imagemap.application": "Application", + "iris.desktop.imagemap.blocks_per_pixel": "Blocks per pixel", + "iris.desktop.imagemap.origin_x": "World origin X", + "iris.desktop.imagemap.origin_z": "World origin Z", + "iris.desktop.imagemap.source_origin_x": "Source origin X", + "iris.desktop.imagemap.source_origin_z": "Source origin Z", + "iris.desktop.imagemap.rotation": "Rotation", + "iris.desktop.imagemap.mirror_x": "Mirror X", + "iris.desktop.imagemap.mirror_z": "Mirror Z", + "iris.desktop.imagemap.sampling": "Sampling", + "iris.desktop.imagemap.out_of_bounds": "Out of bounds", + "iris.desktop.imagemap.alpha": "Alpha policy", + "iris.desktop.imagemap.fallback_value": "Fallback value", + "iris.desktop.imagemap.fallback_target": "Fallback target", + "iris.desktop.imagemap.minimum_height": "Minimum height", + "iris.desktop.imagemap.maximum_height": "Maximum height", + "iris.desktop.imagemap.vertical_offset": "Vertical offset", + "iris.desktop.imagemap.clamp": "Clamp height", + "iris.desktop.imagemap.inverted": "Invert values", + "iris.desktop.imagemap.curve_exponent": "Curve exponent", + "iris.desktop.imagemap.smoothing_radius": "Smoothing radius", + "iris.desktop.imagemap.threshold": "Threshold", + "iris.desktop.imagemap.falloff": "Falloff", + "iris.desktop.imagemap.color_tolerance": "Raw sRGB tolerance", + "iris.desktop.imagemap.unknown_color": "Unknown color", + "iris.desktop.imagemap.add_color": "Add legend color", + "iris.desktop.imagemap.remove_color": "Remove selected", + "iris.desktop.imagemap.composed_masks": "Composed masks", + "iris.desktop.imagemap.add_mask": "Add mask binding", + "iris.desktop.imagemap.height": "Height interpretation", + "iris.desktop.imagemap.mask": "Mask interpretation", + "iris.desktop.imagemap.color_map": "Color legend", + "iris.desktop.imagemap.overlays": "World overlays", + "iris.desktop.imagemap.chunks": "16-block chunks", + "iris.desktop.imagemap.regions": "512-block regions", + "iris.desktop.imagemap.boundary": "World boundary", + "iris.desktop.imagemap.coverage": "Source coverage", + "iris.desktop.imagemap.diagnostics": "Diagnostics", + "iris.desktop.imagemap.ready": "Ready", + "iris.desktop.imagemap.no_source": "Import a PNG source to begin.", + "iris.desktop.imagemap.loading": "Loading preset...", + "iris.desktop.imagemap.load_failed": "Preset load failed", + "iris.desktop.imagemap.previewing": "Compiling preview...", + "iris.desktop.imagemap.preview_valid": "Runtime compiler validation passed.", + "iris.desktop.imagemap.preview_failed": "Preview failed", + "iris.desktop.imagemap.exporting": "Validating and exporting...", + "iris.desktop.imagemap.exported": "Exported image map, PNG, and dimension binding atomically.", + "iris.desktop.imagemap.export_failed": "Export failed", + "iris.desktop.imagemap.source": "Source pixels", + "iris.desktop.imagemap.interpreted": "Runtime interpretation", + "iris.desktop.imagemap.no_preview": "Compile a valid preview to inspect world output.", + "iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel", + "iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.", + "iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.", + "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display." } } diff --git a/core/src/test/java/art/arcane/iris/core/DiagnosticSeverityPolicyTest.java b/core/src/test/java/art/arcane/iris/core/DiagnosticSeverityPolicyTest.java index be8bb65e6..143d5f018 100644 --- a/core/src/test/java/art/arcane/iris/core/DiagnosticSeverityPolicyTest.java +++ b/core/src/test/java/art/arcane/iris/core/DiagnosticSeverityPolicyTest.java @@ -27,7 +27,6 @@ public class DiagnosticSeverityPolicyTest { assertLoggedWith("util/project/hunk/Hunk.java", "OUT OF BOUNDS ", "debug"); assertLoggedWith("engine/mantle/MantleWriter.java", "No set? ", "debug"); assertLoggedWith("engine/mantle/MantleWriter.java", "Mantle Writer Accessed chunk out of bounds", "debug"); - assertLoggedWith("engine/object/IrisImageMap.java", "No image ", "warnOnce"); assertLoggedWith("engine/object/IrisDecorator.java", "Empty Block Data for ", "warnOnce"); assertLoggedWith("engine/object/IrisCompat.java", "Can't find block data for ", "warnOnce"); assertLoggedWith("engine/data/cache/AtomicCache.java", "Atomic cache supplier failed: %s: %s", "warnOnce"); diff --git a/core/src/test/java/art/arcane/iris/core/gui/ImageMapStudioGUITest.java b/core/src/test/java/art/arcane/iris/core/gui/ImageMapStudioGUITest.java new file mode 100644 index 000000000..ad6da8c33 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/gui/ImageMapStudioGUITest.java @@ -0,0 +1,30 @@ +package art.arcane.iris.core.gui; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.IrisImageMap; +import org.junit.Test; +import org.mockito.InOrder; + +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class ImageMapStudioGUITest { + @Test + public void invalidatesPresetKeysBeforeHotloadingTheActiveEngine() { + Engine engine = mock(Engine.class); + IrisData data = mock(IrisData.class); + @SuppressWarnings("unchecked") + ResourceLoader loader = mock(ResourceLoader.class); + when(engine.getData()).thenReturn(data); + when(data.getImageMapLoader()).thenReturn(loader); + + ImageMapStudioGUI.reloadActiveEngine(engine); + + InOrder order = inOrder(loader, engine); + order.verify(loader).clearCache(); + order.verify(engine).hotloadSilently(); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/gui/ImageMapStudioModelTest.java b/core/src/test/java/art/arcane/iris/core/gui/ImageMapStudioModelTest.java new file mode 100644 index 000000000..8afb3fa78 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/gui/ImageMapStudioModelTest.java @@ -0,0 +1,168 @@ +package art.arcane.iris.core.gui; + +import art.arcane.iris.engine.object.IrisImageMap; +import art.arcane.iris.engine.object.IrisImageMapOrigin; +import art.arcane.iris.engine.object.IrisImageMapMask; +import art.arcane.iris.engine.object.IrisImageMapMaskOperation; +import art.arcane.iris.engine.object.IrisImageMapRotation; +import art.arcane.iris.engine.object.IrisImageMapType; +import art.arcane.iris.engine.object.IrisWorldBoundary; +import art.arcane.iris.engine.object.IrisWorldBoundaryCenter; +import art.arcane.volmlib.util.collection.KMap; +import org.junit.Test; + +import java.awt.geom.Point2D; +import java.awt.image.BufferedImage; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class ImageMapStudioModelTest { + private static final double EPSILON = 0.000001D; + + @Test + public void inspectsCanonicalSixteenBitGrayscaleMetadata() { + BufferedImage image = new BufferedImage(3, 2, BufferedImage.TYPE_USHORT_GRAY); + + ImageMapStudioModel.SourceMetadata metadata = ImageMapStudioModel.inspect( + Path.of("source.png"), image, "png" + ); + + assertEquals(3, metadata.width()); + assertEquals(2, metadata.height()); + assertEquals(6L, metadata.pixels()); + assertEquals("png", metadata.format()); + assertEquals("GRAYSCALE", metadata.colorMode()); + assertEquals(16, metadata.bitDepth()); + assertFalse(metadata.alpha()); + assertTrue(metadata.summary().contains("PNG")); + assertTrue(metadata.summary().contains("profile not inspected")); + } + + @Test + public void normalizesResourceKeysAndRejectsAmbiguousLegendRows() { + assertEquals("terrain/my-map", ImageMapStudioModel.safeKey(" Terrain/My Map.PNG ")); + assertEquals("image-map", ImageMapStudioModel.safeKey("..")); + + KMap legend = ImageMapStudioModel.legend(List.of( + new ImageMapStudioModel.LegendRow("#00aaFF", "minecraft:water") + )); + assertEquals("minecraft:water", legend.get("#00AAFF")); + assertThrows(IllegalArgumentException.class, () -> ImageMapStudioModel.legend(List.of( + new ImageMapStudioModel.LegendRow("#000000", "minecraft:stone"), + new ImageMapStudioModel.LegendRow("#000000", "minecraft:deepslate") + ))); + assertThrows(IllegalArgumentException.class, () -> ImageMapStudioModel.legend(List.of( + new ImageMapStudioModel.LegendRow("000000", "minecraft:stone") + ))); + } + + @Test + public void mapsSourceCoordinatesThroughMirrorRotationScaleAndOrigins() { + IrisImageMap definition = new IrisImageMap() + .setBlocksPerPixel(2D) + .setOrigin(new IrisImageMapOrigin(10D, 20D)) + .setSourceOrigin(new IrisImageMapOrigin(1D, 1D)) + .setMirrorX(true) + .setRotation(IrisImageMapRotation.DEG_90); + + Point2D.Double world = ImageMapStudioModel.sourceToWorld(definition, 2D, 1D); + + assertEquals(10D, world.x, EPSILON); + assertEquals(18D, world.y, EPSILON); + } + + @Test + public void reportsScaleAndBoundaryCoverageWarnings() { + IrisImageMap definition = new IrisImageMap() + .setBlocksPerPixel(0.5D) + .setOrigin(new IrisImageMapOrigin()) + .setSourceOrigin(new IrisImageMapOrigin()); + IrisWorldBoundary boundary = new IrisWorldBoundary() + .setCenter(new IrisWorldBoundaryCenter(2D, 2D)) + .setSize(20D); + + List warnings = ImageMapStudioModel.warnings(definition, 4, 4, boundary); + + assertTrue(warnings.stream().anyMatch(value -> value.contains("Sub-block"))); + assertTrue(warnings.stream().anyMatch(value -> value.contains("complete chunk"))); + assertTrue(warnings.stream().anyMatch(value -> value.contains("not fully covered"))); + assertTrue(warnings.stream().anyMatch(value -> value.contains("substantially smaller"))); + } + + @Test + public void acceptsAnExactSourceAndWorldBoundaryFit() { + IrisImageMap definition = new IrisImageMap() + .setBlocksPerPixel(1D) + .setOrigin(new IrisImageMapOrigin()) + .setSourceOrigin(new IrisImageMapOrigin()); + IrisWorldBoundary boundary = new IrisWorldBoundary() + .setCenter(new IrisWorldBoundaryCenter(8D, 8D)) + .setSize(16D); + + List warnings = ImageMapStudioModel.warnings(definition, 16, 16, boundary); + + assertTrue(warnings.isEmpty()); + } + + @Test + public void clearsSettingsThatAreInvalidForTheSelectedType() { + IrisImageMap height = new IrisImageMap() + .setType(IrisImageMapType.GRAYSCALE_HEIGHT) + .setColorTolerance(12D) + .setColors(new KMap<>(Map.of("#000000", "iris:test"))); + IrisImageMap color = new IrisImageMap() + .setType(IrisImageMapType.COLOR_MAP) + .setInverted(true) + .setCurveExponent(2D) + .setSmoothingRadius(4) + .setColors(new KMap<>(Map.of("#000000", "iris:test"))); + IrisImageMap mask = new IrisImageMap() + .setType(IrisImageMapType.GRAYSCALE_MASK) + .setMinimumHeight(12D) + .setMaximumHeight(42D) + .setThreshold(0.7D) + .setFalloff(0.2D) + .setColors(new KMap<>(Map.of("#000000", "iris:test"))); + + ImageMapStudioModel.normalizeTypeSettings(height); + ImageMapStudioModel.normalizeTypeSettings(color); + ImageMapStudioModel.normalizeTypeSettings(mask); + + assertEquals(0D, height.getColorTolerance(), 0D); + assertTrue(height.getColors().isEmpty()); + assertFalse(color.isInverted()); + assertEquals(1D, color.getCurveExponent(), 0D); + assertEquals(0, color.getSmoothingRadius()); + assertEquals("iris:test", color.getColors().get("#000000")); + assertEquals(-64D, mask.getMinimumHeight(), 0D); + assertEquals(320D, mask.getMaximumHeight(), 0D); + assertEquals(0.5D, mask.getThreshold(), 0D); + assertEquals(0D, mask.getFalloff(), 0D); + assertTrue(mask.getColors().isEmpty()); + } + + @Test + public void parsesOrderedComposedMaskRowsWithUnitRangeValidation() { + List rows = List.of( + ImageMapStudioModel.maskRow("land", "multiply", false, "0", "1"), + ImageMapStudioModel.maskRow("roads", IrisImageMapMaskOperation.SUBTRACT, true, 0.5D, 0.25D) + ); + + List masks = ImageMapStudioModel.masks(rows); + + assertEquals(2, masks.size()); + assertEquals("land", masks.get(0).getMap()); + assertEquals(IrisImageMapMaskOperation.MULTIPLY, masks.get(0).getOperation()); + assertEquals(IrisImageMapMaskOperation.SUBTRACT, masks.get(1).getOperation()); + assertTrue(masks.get(1).isInverted()); + assertEquals("roads", ImageMapStudioModel.maskRows(masks).get(1).map()); + assertThrows(IllegalArgumentException.class, + () -> ImageMapStudioModel.maskRow("bad", "ADD", false, 1.1D, 0D)); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/gui/ImageMapStudioPreviewPanelTest.java b/core/src/test/java/art/arcane/iris/core/gui/ImageMapStudioPreviewPanelTest.java new file mode 100644 index 000000000..43337353f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/gui/ImageMapStudioPreviewPanelTest.java @@ -0,0 +1,146 @@ +package art.arcane.iris.core.gui; + +import art.arcane.iris.engine.image.CompiledIrisImageMap; +import art.arcane.iris.engine.image.IrisImageMapRuntime; +import art.arcane.iris.engine.image.IrisImageMapMaskSampler; +import art.arcane.iris.engine.object.IrisImage; +import art.arcane.iris.engine.object.IrisImageMap; +import art.arcane.iris.engine.object.IrisImageMapApplication; +import art.arcane.iris.engine.object.IrisImageMapMask; +import art.arcane.iris.engine.object.IrisImageMapMaskOperation; +import art.arcane.iris.engine.object.IrisImageMapOutOfBounds; +import art.arcane.iris.engine.object.IrisImageMapType; +import art.arcane.volmlib.util.collection.KMap; +import org.junit.Test; + +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; + +public class ImageMapStudioPreviewPanelTest { + @Test + public void rendersAnInspectedSourceWithoutACompiledSemanticType() { + BufferedImage source = new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB); + source.setRGB(0, 0, 0xFFFF0000); + ImageMapStudioPreviewPanel panel = new ImageMapStudioPreviewPanel(); + try { + panel.setSource(source); + + BufferedImage rendered = panel.renderSourceSnapshot(64, 64); + + assertEquals(0xFFFF0000, rendered.getRGB(32, 32)); + } finally { + panel.close(); + } + } + + @Test + public void rendersRuntimeInterpretedHeightDataWithoutAFrame() { + BufferedImage source = new BufferedImage(2, 1, BufferedImage.TYPE_BYTE_GRAY); + source.getRaster().setSample(0, 0, 0, 0); + source.getRaster().setSample(1, 0, 0, 255); + IrisImageMap definition = new IrisImageMap() + .setSource("test") + .setType(IrisImageMapType.GRAYSCALE_HEIGHT) + .setOutOfBounds(IrisImageMapOutOfBounds.CLAMP) + .setMinimumHeight(0D) + .setMaximumHeight(255D); + CompiledIrisImageMap compiled = CompiledIrisImageMap.compile( + definition, new IrisImage(source, "png") + ); + ImageMapStudioPreviewPanel panel = new ImageMapStudioPreviewPanel(); + try { + panel.setPreview( + source, compiled, IrisImageMapMaskSampler.empty(), null, + IrisImageMapApplication.TERRAIN_HEIGHT, -64, (worldX, worldZ) -> 0D + ); + + BufferedImage interpreted = panel.renderInterpretedSnapshot(64, 32); + + assertNotEquals(interpreted.getRGB(4, 16), interpreted.getRGB(59, 16)); + } finally { + panel.close(); + } + } + + @Test + public void blendsMaskedHeightAgainstTheProceduralRuntimeBaseline() { + BufferedImage source = new BufferedImage(1, 1, BufferedImage.TYPE_BYTE_GRAY); + source.getRaster().setSample(0, 0, 0, 255); + IrisImageMap definition = new IrisImageMap() + .setSource("height") + .setType(IrisImageMapType.GRAYSCALE_HEIGHT) + .setOutOfBounds(IrisImageMapOutOfBounds.CLAMP) + .setMinimumHeight(0D) + .setMaximumHeight(100D); + CompiledIrisImageMap compiled = CompiledIrisImageMap.compile(definition, new IrisImage(source, "png")); + IrisImageMapMaskSampler maskSampler = maskSampler(128); + ImageMapStudioPreviewPanel panel = new ImageMapStudioPreviewPanel(); + try { + panel.setPreview( + source, compiled, maskSampler, null, + IrisImageMapApplication.TERRAIN_HEIGHT, -64, (worldX, worldZ) -> 20D + ); + + BufferedImage interpreted = panel.renderInterpretedSnapshot(64, 32); + double weight = 128D / 255D; + double expectedHeight = -64D + IrisImageMapRuntime.blendTerrainHeight(164D, 20D, weight); + + assertEquals( + ImageMapStudioModel.heightColor(expectedHeight, 0D, 100D), + interpreted.getRGB(32, 16) + ); + } finally { + panel.close(); + } + } + + @Test + public void usesTheRuntimeCategoricalMaskCutoff() { + BufferedImage source = new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB); + source.setRGB(0, 0, 0xFFFF0000); + IrisImageMap definition = new IrisImageMap() + .setSource("biome") + .setType(IrisImageMapType.COLOR_MAP) + .setOutOfBounds(IrisImageMapOutOfBounds.CLAMP) + .setColors(new KMap<>(Map.of("#FF0000", "iris:test"))); + CompiledIrisImageMap compiled = CompiledIrisImageMap.compile(definition, new IrisImage(source, "png")); + ImageMapStudioPreviewPanel below = new ImageMapStudioPreviewPanel(); + ImageMapStudioPreviewPanel above = new ImageMapStudioPreviewPanel(); + try { + below.setPreview( + source, compiled, maskSampler(127), null, + IrisImageMapApplication.BIOME, -64, null + ); + above.setPreview( + source, compiled, maskSampler(128), null, + IrisImageMapApplication.BIOME, -64, null + ); + + assertEquals(new Color(12, 15, 22).getRGB(), below.renderInterpretedSnapshot(64, 32).getRGB(32, 16)); + assertEquals( + ImageMapStudioModel.targetColor("iris:test"), + above.renderInterpretedSnapshot(64, 32).getRGB(32, 16) + ); + } finally { + below.close(); + above.close(); + } + } + + private static IrisImageMapMaskSampler maskSampler(int sample) { + BufferedImage source = new BufferedImage(1, 1, BufferedImage.TYPE_BYTE_GRAY); + source.getRaster().setSample(0, 0, 0, sample); + IrisImageMap definition = new IrisImageMap() + .setSource("mask") + .setType(IrisImageMapType.GRAYSCALE_MASK) + .setOutOfBounds(IrisImageMapOutOfBounds.CLAMP); + CompiledIrisImageMap compiled = CompiledIrisImageMap.compile(definition, new IrisImage(source, "png")); + IrisImageMapMask mask = new IrisImageMapMask().setOperation(IrisImageMapMaskOperation.MULTIPLY); + return new IrisImageMapMaskSampler(List.of(IrisImageMapMaskSampler.layer(compiled, mask))); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/loader/ImageResourceLoaderTest.java b/core/src/test/java/art/arcane/iris/core/loader/ImageResourceLoaderTest.java new file mode 100644 index 000000000..24ccc8543 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/loader/ImageResourceLoaderTest.java @@ -0,0 +1,17 @@ +package art.arcane.iris.core.loader; + +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class ImageResourceLoaderTest { + @Test + public void preflightsDimensionsBeforeImageDecode() { + assertTrue(ImageResourceLoader.supportedDimensions(1, 1)); + assertTrue(ImageResourceLoader.supportedDimensions(16_384, 1_024)); + assertFalse(ImageResourceLoader.supportedDimensions(16_384, 1_025)); + assertFalse(ImageResourceLoader.supportedDimensions(16_385, 1)); + assertFalse(ImageResourceLoader.supportedDimensions(0, 1)); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackDownloaderTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackDownloaderTest.java index 620d3b6ca..594e61883 100644 --- a/core/src/test/java/art/arcane/iris/core/pack/PackDownloaderTest.java +++ b/core/src/test/java/art/arcane/iris/core/pack/PackDownloaderTest.java @@ -407,6 +407,54 @@ public class PackDownloaderTest { } } + @Test + public void networkFailureReportsItsCauseAndReopensAdmission() throws Exception { + File packsFolder = temp.newFolder("failed-download-packs"); + byte[] followupArchive = packArchive("failed-followup.zip", "failed_followup"); + AtomicInteger failedRequests = new AtomicInteger(); + AtomicInteger followupRequests = new AtomicInteger(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/missing.zip", exchange -> { + failedRequests.incrementAndGet(); + exchange.sendResponseHeaders(404, -1L); + exchange.close(); + }); + server.createContext("/followup.zip", exchange -> { + followupRequests.incrementAndGet(); + exchange.sendResponseHeaders(200, followupArchive.length); + exchange.getResponseBody().write(followupArchive); + exchange.close(); + }); + server.start(); + try { + String baseUrl = "http://127.0.0.1:" + server.getAddress().getPort(); + + IOException failure = assertThrows(IOException.class, () -> PackDownloader.downloadUrl( + packsFolder, + baseUrl + "/missing.zip", + false, + ignored -> { + } + )); + + assertTrue(failure.getMessage().contains("HTTP 404")); + assertEquals(1, failedRequests.get()); + PackDownloader.PackInstallResult followup = PackDownloader.downloadUrl( + packsFolder, + baseUrl + "/followup.zip", + false, + ignored -> { + } + ); + assertNotNull(followup); + assertEquals("failed_followup", followup.key()); + assertEquals(1, followupRequests.get()); + assertEquals(0, PackDownloader.downloadLockCount()); + } finally { + server.stop(0); + } + } + @Test public void builtInPackPresenceRequiresItsPrimaryDimension() throws Exception { File packsFolder = temp.newFolder("managed-presence"); diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackExportClosureTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackExportClosureTest.java index 031795e0c..3f03aec0a 100644 --- a/core/src/test/java/art/arcane/iris/core/pack/PackExportClosureTest.java +++ b/core/src/test/java/art/arcane/iris/core/pack/PackExportClosureTest.java @@ -62,6 +62,11 @@ public class PackExportClosureTest { assertTrue("Bukkit packager must export region objects alongside biome objects", bukkit.contains("regions.forEach((r) -> allPlacements.addAll(r.getObjects()))")); assertTrue("Bukkit packager must export entity loot tables", bukkit.contains("getLoot().getTables()")); + int bukkitValidation = bukkit.indexOf("PackValidator.validateForPackaging(project.getPath())"); + assertTrue("Bukkit packager must validate before opening or mutating package state", + bukkitValidation >= 0 + && bukkitValidation < bukkit.indexOf("IrisData.openRuntime(project.getPath())") + && bukkitValidation < bukkit.indexOf("IO.delete(folder)")); String modded = Files.readString(Path.of( "../adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java")).replace("\r\n", "\n"); @@ -69,5 +74,8 @@ public class PackExportClosureTest { assertTrue("modded packager must write markers/", modded.contains("\"markers\"")); assertTrue("modded packager must include initial spawns", modded.contains("getInitialSpawns")); assertTrue("modded packager must export region objects", modded.contains("region.getObjects()")); + int moddedValidation = modded.indexOf("PackValidator.validateForPackaging(packFolder)"); + assertTrue("modded packager must validate before mutating package state", + moddedValidation >= 0 && moddedValidation < modded.indexOf("IO.delete(folder)")); } } diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackImageMapValidatorTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackImageMapValidatorTest.java new file mode 100644 index 000000000..8c0a871b9 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pack/PackImageMapValidatorTest.java @@ -0,0 +1,515 @@ +package art.arcane.iris.core.pack; + +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformRegistries; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class PackImageMapValidatorTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + private IrisPlatform previousPlatform; + + @Before + public void isolatePlatform() { + previousPlatform = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; + IrisPlatforms.unbind(); + } + + @After + public void restorePlatform() { + IrisPlatforms.unbind(); + if (previousPlatform != null) { + IrisPlatforms.bind(previousPlatform); + } + } + + @Test + public void acceptsCompiledTypedBindingsThroughPackValidator() throws Exception { + File pack = createPack("valid", """ + { + "regions": ["region"], + "worldBoundary": {"center": {"x": 0, "z": 0}, "size": 16}, + "imageMaps": [ + { + "key": "terrain", + "map": "terrain", + "application": "TERRAIN_HEIGHT", + "masks": [{"map": "weight", "operation": "MULTIPLY"}] + }, + {"key": "weight", "map": "weight", "application": "MASK"} + ] + } + """); + writeGray(pack, "terrain", 16, 16); + writeGray(pack, "weight", 16, 16); + write(pack, "image-maps/terrain.json", """ + { + "source": "terrain", + "type": "GRAYSCALE_HEIGHT", + "origin": {"x": -8, "z": -8}, + "outOfBounds": "ERROR" + } + """); + write(pack, "image-maps/weight.json", """ + { + "source": "weight", + "type": "GRAYSCALE_MASK", + "origin": {"x": -8, "z": -8}, + "outOfBounds": "CLAMP" + } + """); + + PackValidationResult result = PackValidator.validateForDatapackBootstrap(pack); + + assertTrue(result.getBlockingErrors().toString(), result.isLoadable()); + assertFalse(result.getWarnings().toString(), contains(result.getWarnings(), "image-map")); + } + + @Test + public void enforcesErrorCoverageAndWarnsForRecoverablePolicies() throws Exception { + File noBoundary = createPack("no-boundary", dimensionWithTerrain(null)); + writeGray(noBoundary, "terrain", 2, 2); + write(noBoundary, "image-maps/terrain.json", heightMap("ERROR")); + + PackImageMapValidator.Validation missing = validate(noBoundary, false); + + assertTrue(missing.errors().toString(), contains(missing.errors(), + "outOfBounds=ERROR and requires a configured worldBoundary")); + + File uncoveredError = createPack("uncovered-error", dimensionWithTerrain(16)); + writeGray(uncoveredError, "terrain", 2, 2); + write(uncoveredError, "image-maps/terrain.json", heightMap("ERROR")); + + PackImageMapValidator.Validation errorCoverage = validate(uncoveredError, false); + + assertTrue(errorCoverage.errors().toString(), contains(errorCoverage.errors(), + "source footprint does not cover the configured worldBoundary")); + + File uncoveredClamp = createPack("uncovered-clamp", dimensionWithTerrain(16)); + writeGray(uncoveredClamp, "terrain", 2, 2); + write(uncoveredClamp, "image-maps/terrain.json", heightMap("CLAMP")); + + PackImageMapValidator.Validation clampCoverage = validate(uncoveredClamp, false); + + assertTrue(clampCoverage.errors().toString(), clampCoverage.errors().isEmpty()); + assertTrue(clampCoverage.warnings().toString(), contains(clampCoverage.warnings(), + "source footprint does not cover the configured worldBoundary")); + } + + @Test + public void reportsBindingReferenceAndApplicationFailuresDeterministically() throws Exception { + File pack = createPack("bindings", """ + { + "regions": ["region"], + "imageMaps": [ + { + "key": "duplicate", + "map": "height", + "application": "TERRAIN_HEIGHT", + "masks": [ + {"map": "duplicate", "operation": "MULTIPLY"}, + {"map": "missing-mask", "operation": "MULTIPLY"} + ] + }, + {"key": "duplicate", "map": "height-two", "application": "TERRAIN_HEIGHT"}, + {"key": "biome", "map": "height", "application": "BIOME"}, + {"key": "missing-map", "map": "absent", "application": "CUSTOM"}, + { + "key": "mask", + "map": "mask", + "application": "MASK", + "masks": [{"map": "mask", "operation": "MULTIPLY"}] + } + ] + } + """); + writeGray(pack, "height", 2, 2); + writeGray(pack, "height-two", 2, 2); + writeGray(pack, "mask", 2, 2); + write(pack, "image-maps/height.json", heightMap("CLAMP")); + write(pack, "image-maps/height-two.json", """ + {"source": "height-two", "type": "GRAYSCALE_HEIGHT", "outOfBounds": "CLAMP"} + """); + write(pack, "image-maps/mask.json", """ + {"source": "mask", "type": "GRAYSCALE_MASK", "outOfBounds": "CLAMP"} + """); + + PackImageMapValidator.Validation first = validate(pack, false); + PackImageMapValidator.Validation second = validate(pack, false); + + assertEquals(first, second); + assertTrue(first.errors().toString(), contains(first.errors(), "duplicate image-map key 'duplicate'")); + assertTrue(first.errors().toString(), contains(first.errors(), + "more than one TERRAIN_HEIGHT image-map binding")); + assertTrue(first.errors().toString(), contains(first.errors(), "incompatible with BIOME")); + assertTrue(first.errors().toString(), contains(first.errors(), + "references missing image-map resource 'absent'")); + assertTrue(first.errors().toString(), contains(first.errors(), "which is not a MASK binding")); + assertTrue(first.errors().toString(), contains(first.errors(), + "references missing MASK binding 'missing-mask'")); + assertTrue(first.errors().toString(), contains(first.errors(), + "is a MASK binding and cannot reference additional masks")); + } + + @Test + public void validatesEveryFirstClassResourceBeforeItIsBound() throws Exception { + File pack = createPack("resources", "{\"regions\":[\"region\"]}"); + writeGray(pack, "grayscale", 2, 2); + writeRgb(pack, "not-png", 1, 1, "jpeg"); + Path corrupt = pack.toPath().resolve("images/corrupt.png"); + Files.createDirectories(corrupt.getParent()); + Files.write(corrupt, new byte[]{ + (byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x01, 0x02 + }); + write(pack, "image-maps/wrong-type.json", """ + {"source": "grayscale", "type": "RGB_HEIGHT", "outOfBounds": "CLAMP"} + """); + write(pack, "image-maps/wrong-format.json", """ + {"source": "not-png", "type": "RGB_HEIGHT", "outOfBounds": "CLAMP"} + """); + write(pack, "image-maps/missing-source.json", """ + {"source": "absent", "type": "GRAYSCALE_HEIGHT", "outOfBounds": "CLAMP"} + """); + write(pack, "image-maps/corrupt-source.json", """ + {"source": "corrupt", "type": "GRAYSCALE_HEIGHT", "outOfBounds": "CLAMP"} + """); + + PackImageMapValidator.Validation validation = validate(pack, false); + + assertTrue(validation.errors().toString(), contains(validation.errors(), + "RGB_HEIGHT requires an RGB or RGBA PNG")); + assertTrue(validation.errors().toString(), contains(validation.errors(), "is not a PNG file")); + assertTrue(validation.errors().toString(), contains(validation.errors(), + "references missing PNG source 'absent'")); + assertTrue(validation.errors().toString(), contains(validation.errors(), "is corrupt or unsupported")); + } + + @Test + public void validatesLegendFilesMappedRegionRolesAndLiveSurfaceBlocks() throws Exception { + File pack = createPack("legends", """ + { + "regions": ["region"], + "imageMaps": [ + {"key": "biome", "map": "biome-map", "application": "BIOME"}, + {"key": "region", "map": "region-map", "application": "REGION"}, + {"key": "surface", "map": "surface-map", "application": "SURFACE_BLOCK"} + ] + } + """); + writeRgb(pack, "biome", 1, 1, "png"); + writeRgb(pack, "region-map", 1, 1, "png"); + writeRgb(pack, "surface", 1, 1, "png"); + write(pack, "biomes/mapped-biome.json", "{\"name\":\"Mapped\"}"); + write(pack, "regions/mapped-region.json", "{\"landBiomes\":[\"mapped-biome\"]}"); + write(pack, "image-maps/biome-map.json", colorMap("biome", "mapped-biome")); + write(pack, "image-maps/region-map.json", colorMap("region-map", "mapped-region")); + write(pack, "image-maps/surface-map.json", colorMap("surface", "minecraft:not_real")); + PlatformRegistries registries = mock(PlatformRegistries.class); + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.registries()).thenReturn(registries); + when(registries.blockKeys()).thenReturn(List.of("minecraft:stone")); + when(registries.blockOrNull("minecraft:not_real", false)).thenReturn(null); + when(registries.blockStateProperties()).thenReturn(Map.of()); + IrisPlatforms.bind(platform); + + PackImageMapValidator.Validation validation = validate(pack, true); + + assertFalse(validation.errors().toString(), contains(validation.errors(), + "biome target 'mapped-biome' must occur")); + assertTrue(validation.errors().toString(), contains(validation.errors(), + "unknown surface block target 'minecraft:not_real'")); + } + + @Test + public void validatesReachableGeneratorStyleMapReferences() throws Exception { + File missingPack = createPack("generator-style-missing", "{\"regions\":[\"region\"]}"); + write(missingPack, "biomes/biome.json", """ + {"name":"Biome","generators":[{"generator":"mapped","min":0,"max":1}]} + """); + write(missingPack, "generators/mapped.json", """ + {"composite":[{"style":{"imageMap":"absent"}}]} + """); + + PackImageMapValidator.Validation missing = validate(missingPack, false); + + assertTrue(missing.errors().toString(), contains(missing.errors(), + "generator style references missing image-map resource 'absent'")); + + File colorPack = createPack("generator-style-color", """ + {"regions":["region"],"regionStyle":{"imageMap":"colors"}} + """); + writeRgb(colorPack, "colors", 1, 1, "png"); + write(colorPack, "image-maps/colors.json", colorMap("colors", "biome")); + + PackImageMapValidator.Validation color = validate(colorPack, false); + + assertTrue(color.errors().toString(), contains(color.errors(), + "must use a scalar map type, not COLOR_MAP")); + + File errorPack = createPack("generator-style-error", """ + {"regions":["region"],"regionStyle":{"imageMap":"height"}} + """); + writeGray(errorPack, "height", 4, 4); + write(errorPack, "image-maps/height.json", """ + {"source":"height","type":"GRAYSCALE_HEIGHT","outOfBounds":"ERROR"} + """); + + PackImageMapValidator.Validation error = validate(errorPack, false); + + assertTrue(error.errors().toString(), contains(error.errors(), + "cannot use outOfBounds=ERROR because its transformed sampling domain cannot be proven finite")); + } + + @Test + public void validatesUpperDimensionCoverageAgainstParentBoundary() throws Exception { + File pack = createPack("upper-parent-boundary", """ + { + "regions": ["region"], + "worldBoundary": {"center": {"x": 0, "z": 0}, "size": 16}, + "upperDimension": "upper" + } + """); + write(pack, "dimensions/upper.json", """ + { + "regions": ["region"], + "worldBoundary": {"center": {"x": 0, "z": 0}, "size": 2}, + "imageMaps": [ + {"key": "terrain", "map": "upper-terrain", "application": "TERRAIN_HEIGHT"} + ] + } + """); + writeGray(pack, "upper-terrain", 4, 4); + write(pack, "image-maps/upper-terrain.json", """ + { + "source": "upper-terrain", + "type": "GRAYSCALE_HEIGHT", + "origin": {"x": -2, "z": -2}, + "outOfBounds": "ERROR" + } + """); + + PackImageMapValidator.Validation validation = validate(pack, false); + + assertTrue(validation.errors().toString(), contains(validation.errors(), + "Dimension 'main' upper dimension 'upper' image-map 'terrain' source footprint does not cover")); + assertFalse(validation.errors().toString(), contains(validation.errors(), + "Dimension 'upper' image-map 'terrain' source footprint does not cover")); + } + + @Test + public void acceptsUpperDimensionWithoutStandaloneBoundaryWhenParentIsCovered() throws Exception { + File pack = createPack("upper-parent-only-boundary", """ + { + "regions": ["region"], + "worldBoundary": {"center": {"x": 0, "z": 0}, "size": 4}, + "upperDimension": "upper" + } + """); + write(pack, "dimensions/upper.json", """ + { + "regions": ["region"], + "imageMaps": [ + {"key": "terrain", "map": "upper-terrain", "application": "TERRAIN_HEIGHT"} + ] + } + """); + writeGray(pack, "upper-terrain", 4, 4); + write(pack, "image-maps/upper-terrain.json", """ + { + "source": "upper-terrain", + "type": "GRAYSCALE_HEIGHT", + "origin": {"x": -2, "z": -2}, + "outOfBounds": "ERROR" + } + """); + + PackImageMapValidator.Validation validation = validate(pack, false); + + assertFalse(validation.errors().toString(), contains(validation.errors(), + "requires a configured worldBoundary")); + assertFalse(validation.errors().toString(), contains(validation.errors(), + "source footprint does not cover")); + } + + @Test + public void validatesDeclaredUpperBoundaryAlongsideParentBoundary() throws Exception { + File pack = createPack("upper-own-and-parent-boundary", """ + { + "regions": ["region"], + "worldBoundary": {"center": {"x": 0, "z": 0}, "size": 4}, + "upperDimension": "upper" + } + """); + write(pack, "dimensions/upper.json", """ + { + "regions": ["region"], + "worldBoundary": {"center": {"x": 0, "z": 0}, "size": 16}, + "imageMaps": [ + {"key": "terrain", "map": "upper-terrain", "application": "TERRAIN_HEIGHT"} + ] + } + """); + writeGray(pack, "upper-terrain", 4, 4); + write(pack, "image-maps/upper-terrain.json", """ + { + "source": "upper-terrain", + "type": "GRAYSCALE_HEIGHT", + "origin": {"x": -2, "z": -2}, + "outOfBounds": "ERROR" + } + """); + + PackImageMapValidator.Validation validation = validate(pack, false); + + assertTrue(validation.errors().toString(), contains(validation.errors(), + "Dimension 'upper' image-map 'terrain' source footprint does not cover")); + assertFalse(validation.errors().toString(), contains(validation.errors(), + "Dimension 'main' upper dimension 'upper' image-map 'terrain' source footprint does not cover")); + } + + @Test + public void enforcesTerrainHeightRangeAfterOffsetAndClamp() throws Exception { + File unclamped = createPack("height-unclamped", """ + { + "regions": ["region"], + "dimensionHeight": {"min": 0, "max": 64}, + "imageMaps": [ + {"key": "terrain", "map": "terrain", "application": "TERRAIN_HEIGHT"} + ] + } + """); + writeGray(unclamped, "terrain", 2, 2); + write(unclamped, "image-maps/terrain.json", """ + { + "source": "terrain", + "type": "GRAYSCALE_HEIGHT", + "minimumHeight": 0, + "maximumHeight": 64, + "verticalOffset": 10, + "clamp": false, + "outOfBounds": "CLAMP" + } + """); + + PackImageMapValidator.Validation outside = validate(unclamped, false); + + assertTrue(outside.errors().toString(), contains(outside.errors(), + "produces world Y 10.0..74.0 after verticalOffset and clamp")); + + File clamped = createPack("height-clamped", """ + { + "regions": ["region"], + "dimensionHeight": {"min": 0, "max": 64}, + "imageMaps": [ + {"key": "terrain", "map": "terrain", "application": "TERRAIN_HEIGHT"} + ] + } + """); + writeGray(clamped, "terrain", 2, 2); + write(clamped, "image-maps/terrain.json", """ + { + "source": "terrain", + "type": "GRAYSCALE_HEIGHT", + "minimumHeight": 0, + "maximumHeight": 64, + "verticalOffset": 10, + "clamp": true, + "outOfBounds": "CLAMP" + } + """); + + PackImageMapValidator.Validation inside = validate(clamped, false); + + assertFalse(inside.errors().toString(), contains(inside.errors(), "produces world Y")); + } + + private PackImageMapValidator.Validation validate(File pack, boolean liveRegistries) { + File dimensions = new File(pack, "dimensions"); + File[] dimensionFiles = dimensions.listFiles((File file) -> file.isFile() + && file.getName().endsWith(".json")); + assertTrue(dimensionFiles != null && dimensionFiles.length > 0); + return PackImageMapValidator.validate(pack, dimensionFiles, liveRegistries); + } + + private File createPack(String name, String dimension) throws Exception { + File pack = temporaryFolder.newFolder(name); + write(pack, "dimensions/main.json", dimension); + write(pack, "regions/region.json", "{\"landBiomes\":[\"biome\"]}"); + write(pack, "biomes/biome.json", "{\"name\":\"Biome\"}"); + return pack; + } + + private String dimensionWithTerrain(Integer boundarySize) { + String boundary = boundarySize == null + ? "" + : ",\"worldBoundary\":{\"center\":{\"x\":0,\"z\":0},\"size\":" + boundarySize + "}"; + return "{\"regions\":[\"region\"]" + boundary + + ",\"imageMaps\":[{\"key\":\"terrain\",\"map\":\"terrain\"," + + "\"application\":\"TERRAIN_HEIGHT\"}]}"; + } + + private String heightMap(String outOfBounds) { + return "{\"source\":\"terrain\",\"type\":\"GRAYSCALE_HEIGHT\"," + + "\"outOfBounds\":\"" + outOfBounds + "\"}"; + } + + private String colorMap(String source, String target) { + return "{\"source\":\"" + source + "\",\"type\":\"COLOR_MAP\"," + + "\"outOfBounds\":\"CLAMP\",\"unknownColor\":\"IGNORE\"," + + "\"colors\":{\"#FF0000\":\"" + target + "\"}}"; + } + + private boolean contains(List messages, String fragment) { + return messages.stream().anyMatch((String message) -> message.contains(fragment)); + } + + private void write(File root, String relative, String content) throws Exception { + Path path = root.toPath().resolve(relative); + Files.createDirectories(path.getParent()); + Files.writeString(path, content, StandardCharsets.UTF_8); + } + + private void writeGray(File pack, String key, int width, int height) throws Exception { + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_BYTE_GRAY); + File file = imageFile(pack, key); + assertTrue(ImageIO.write(image, "png", file)); + } + + private void writeRgb(File pack, String key, int width, int height, String format) throws Exception { + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + for (int z = 0; z < height; z++) { + for (int x = 0; x < width; x++) { + image.setRGB(x, z, 0xFFFF0000); + } + } + File file = imageFile(pack, key); + assertTrue(ImageIO.write(image, format, file)); + } + + private File imageFile(File pack, String key) throws Exception { + File images = new File(pack, "images"); + Files.createDirectories(images.toPath()); + return new File(images, key + ".png"); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackValidatorWorldBoundaryTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackValidatorWorldBoundaryTest.java new file mode 100644 index 000000000..8c4542292 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pack/PackValidatorWorldBoundaryTest.java @@ -0,0 +1,52 @@ +package art.arcane.iris.core.pack; + +import art.arcane.volmlib.util.json.JSONObject; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class PackValidatorWorldBoundaryTest { + @Test + public void acceptsAbsentAndValidBoundaries() { + List absentErrors = validate("{}"); + List configuredErrors = validate("{\"worldBoundary\":{\"center\":{\"x\":12.5,\"z\":-8}," + + "\"size\":16384,\"warningDistance\":16,\"damageBuffer\":5,\"damageAmount\":0.2}}"); + + assertTrue(absentErrors.toString(), absentErrors.isEmpty()); + assertTrue(configuredErrors.toString(), configuredErrors.isEmpty()); + } + + @Test + public void rejectsMalformedBoundaryObjects() { + assertEquals(List.of("Dimension 'main' worldBoundary must be an object."), + validate("{\"worldBoundary\":null}")); + assertEquals(List.of("Dimension 'main' worldBoundary must be an object."), + validate("{\"worldBoundary\":42}")); + assertEquals(List.of("Dimension 'main' worldBoundary.center must be an object."), + validate("{\"worldBoundary\":{\"center\":null}}")); + } + + @Test + public void rejectsValuesOutsideNativeLimits() { + List errors = validate("{\"worldBoundary\":{\"center\":{\"x\":29999985,\"z\":-29999985}," + + "\"size\":59999969,\"warningDistance\":1.5,\"damageBuffer\":-1,\"damageAmount\":-0.1}}"); + + assertEquals(6, errors.size()); + assertTrue(errors.toString(), errors.stream().anyMatch((String error) -> error.contains("worldBoundary.size"))); + assertTrue(errors.toString(), errors.stream().anyMatch((String error) -> error.contains("worldBoundary.warningDistance"))); + assertTrue(errors.toString(), errors.stream().anyMatch((String error) -> error.contains("worldBoundary.damageBuffer"))); + assertTrue(errors.toString(), errors.stream().anyMatch((String error) -> error.contains("worldBoundary.damageAmount"))); + assertTrue(errors.toString(), errors.stream().anyMatch((String error) -> error.contains("worldBoundary.center.x"))); + assertTrue(errors.toString(), errors.stream().anyMatch((String error) -> error.contains("worldBoundary.center.z"))); + } + + private static List validate(String json) { + List errors = new ArrayList<>(); + PackDimensionValidator.validateWorldBoundary("main", new JSONObject(json), errors); + return errors; + } +} diff --git a/core/src/test/java/art/arcane/iris/core/project/ImageMapStudioExporterTest.java b/core/src/test/java/art/arcane/iris/core/project/ImageMapStudioExporterTest.java new file mode 100644 index 000000000..32166bb60 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/project/ImageMapStudioExporterTest.java @@ -0,0 +1,265 @@ +package art.arcane.iris.core.project; + +import art.arcane.iris.engine.image.IrisImageMapValidationException; +import art.arcane.iris.engine.object.IrisImage; +import art.arcane.iris.engine.object.IrisImageMap; +import art.arcane.iris.engine.object.IrisImageMapApplication; +import art.arcane.iris.engine.object.IrisImageMapOutOfBounds; +import art.arcane.iris.engine.object.IrisImageMapSampling; +import art.arcane.iris.engine.object.IrisImageMapType; +import art.arcane.volmlib.util.collection.KMap; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.List; +import java.util.Map; +import java.util.zip.CRC32; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertThrows; + +public class ImageMapStudioExporterTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void previewUsesTheRuntimeCompiler() throws Exception { + File source = temporaryFolder.newFile("height.png"); + BufferedImage image = new BufferedImage(2, 1, BufferedImage.TYPE_BYTE_GRAY); + image.getRaster().setSample(0, 0, 0, 0); + image.getRaster().setSample(1, 0, 0, 255); + ImageIO.write(image, "png", source); + IrisImageMap definition = new IrisImageMap() + .setSource("height") + .setType(IrisImageMapType.GRAYSCALE_HEIGHT) + .setMinimumHeight(-64D) + .setMaximumHeight(320D); + + ImageMapStudioExporter.PreviewResult preview = ImageMapStudioExporter.preview( + source.toPath(), definition + ); + + assertEquals(2, preview.compiled().getSourceWidth()); + assertEquals(-64D, preview.compiled().sampleHeight(0D, 0D), 0D); + assertEquals(320D, preview.compiled().sampleHeight(1D, 0D), 0D); + assertFalse(preview.colorProfile().isBlank()); + } + + @Test + public void inspectsSourceMetadataWithoutACompatibleSemanticType() throws Exception { + File source = temporaryFolder.newFile("inspect-rgb.png"); + BufferedImage image = new BufferedImage(2, 3, BufferedImage.TYPE_INT_ARGB); + image.setRGB(0, 0, 0x40FF0000); + image.setRGB(1, 2, 0xFFFF0000); + ImageIO.write(image, "png", source); + + ImageMapStudioExporter.SourceInspection inspection = ImageMapStudioExporter.inspectSource(source.toPath()); + IrisImage inspected = new IrisImage(inspection.source(), inspection.format()); + + assertEquals(2, inspected.getWidth()); + assertEquals(3, inspected.getHeight()); + assertTrue(inspected.hasAlpha()); + assertTrue(inspection.minimumAlpha() < 1D); + assertEquals(1D, inspection.maximumAlpha(), 0D); + } + + @Test + public void readsEmbeddedPngColorProfileMetadata() throws Exception { + File source = temporaryFolder.newFile("profile.png"); + BufferedImage image = new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB); + ImageIO.write(image, "png", source); + byte[] original = Files.readAllBytes(source.toPath()); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + output.write(original, 0, 33); + output.write(pngChunk("sRGB", new byte[]{0})); + output.write(original, 33, original.length - 33); + Files.write(source.toPath(), output.toByteArray()); + + ImageMapStudioExporter.SourceInspection inspection = ImageMapStudioExporter.inspectSource(source.toPath()); + + assertTrue(inspection.colorProfile().startsWith("sRGB:")); + } + + @Test + public void rejectsOversizedPngDimensionsBeforePixelDecode() throws Exception { + File source = temporaryFolder.newFile("oversized.png"); + writeHeaderOnlyPng(source, 16_385, 1); + + IrisImageMapValidationException failure = assertThrows( + IrisImageMapValidationException.class, + () -> ImageMapStudioExporter.inspectSource(source.toPath()) + ); + + assertTrue(failure.getMessage().contains("Image width must be 1..16384")); + } + + @Test + public void rejectsLossyInputsBeforeProjectMutation() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + File source = temporaryFolder.newFile("height.jpg"); + BufferedImage image = new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB); + ImageIO.write(image, "jpg", source); + ImageMapStudioExporter.ExportRequest request = new ImageMapStudioExporter.ExportRequest( + pack, + "overworld", + "terrain", + IrisImageMapApplication.TERRAIN_HEIGHT, + "terrain", + "terrain", + new IrisImageMap().setType(IrisImageMapType.RGB_HEIGHT), + source.toPath(), + List.of() + ); + + assertThrows(IOException.class, () -> ImageMapStudioExporter.export(request)); + assertFalse(new File(pack, "images").exists()); + assertFalse(new File(pack, "image-maps").exists()); + } + + @Test + public void rejectsIncompatibleDefinitionsBeforeProjectMutation() throws Exception { + File pack = temporaryFolder.newFolder("invalid-pack"); + File source = temporaryFolder.newFile("map.png"); + BufferedImage image = new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB); + ImageIO.write(image, "png", source); + IrisImageMap definition = new IrisImageMap() + .setType(IrisImageMapType.COLOR_MAP) + .setSampling(IrisImageMapSampling.BILINEAR); + ImageMapStudioExporter.ExportRequest request = new ImageMapStudioExporter.ExportRequest( + pack, + "overworld", + "biomes", + IrisImageMapApplication.BIOME, + "biomes", + "biomes", + definition, + source.toPath(), + List.of() + ); + + assertThrows(IrisImageMapValidationException.class, () -> ImageMapStudioExporter.export(request)); + assertFalse(new File(pack, "images").exists()); + assertFalse(new File(pack, "image-maps").exists()); + } + + @Test + public void exportsAndReloadsAValidatedRuntimeBinding() throws Exception { + File pack = minimalPack("export-pack"); + File source = temporaryFolder.newFile("export-height.png"); + BufferedImage image = new BufferedImage(2, 1, BufferedImage.TYPE_BYTE_GRAY); + image.getRaster().setSample(1, 0, 0, 255); + ImageIO.write(image, "png", source); + IrisImageMap definition = new IrisImageMap() + .setType(IrisImageMapType.GRAYSCALE_HEIGHT) + .setOutOfBounds(IrisImageMapOutOfBounds.CLAMP); + ImageMapStudioExporter.ExportRequest request = new ImageMapStudioExporter.ExportRequest( + pack, + "main", + "terrain", + IrisImageMapApplication.TERRAIN_HEIGHT, + "terrain", + "terrain", + definition, + source.toPath(), + List.of() + ); + + ImageMapStudioExporter.ExportResult result = ImageMapStudioExporter.export(request); + + assertTrue(Files.isRegularFile(result.imageFile())); + assertTrue(Files.isRegularFile(result.imageMapFile())); + assertTrue(Files.readString(result.dimensionFile()).contains("\"key\": \"terrain\"")); + assertEquals(64, result.contentHash().length()); + } + + @Test + public void restoresEveryExistingTargetWhenPublishedRuntimeValidationFails() throws Exception { + File pack = minimalPack("rollback-pack"); + File source = temporaryFolder.newFile("rollback-color.png"); + BufferedImage image = new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB); + image.setRGB(0, 0, 0xFFFF0000); + ImageIO.write(image, "png", source); + File imageTarget = write(pack, "images/biomes.png", "old-image"); + File mapTarget = write(pack, "image-maps/biomes.json", "old-map"); + String originalDimension = Files.readString(new File(pack, "dimensions/main.json").toPath()); + IrisImageMap definition = new IrisImageMap() + .setType(IrisImageMapType.COLOR_MAP) + .setColors(new KMap<>(Map.of("#FF0000", "iris:missing"))); + ImageMapStudioExporter.ExportRequest request = new ImageMapStudioExporter.ExportRequest( + pack, + "main", + "biomes", + IrisImageMapApplication.BIOME, + "biomes", + "biomes", + definition, + source.toPath(), + List.of() + ); + + assertThrows(RuntimeException.class, () -> ImageMapStudioExporter.export(request)); + + assertEquals("old-image", Files.readString(imageTarget.toPath())); + assertEquals("old-map", Files.readString(mapTarget.toPath())); + assertEquals(originalDimension, Files.readString(new File(pack, "dimensions/main.json").toPath())); + } + + private File minimalPack(String name) throws Exception { + File pack = temporaryFolder.newFolder(name); + write(pack, "dimensions/main.json", "{\"regions\":[\"region\"]}"); + write(pack, "regions/region.json", "{\"landBiomes\":[\"biome\"]}"); + write(pack, "biomes/biome.json", "{\"name\":\"Biome\"}"); + return pack; + } + + private File write(File root, String relative, String content) throws Exception { + File target = new File(root, relative); + Files.createDirectories(target.toPath().getParent()); + Files.writeString(target.toPath(), content, StandardCharsets.UTF_8); + return target; + } + + private void writeHeaderOnlyPng(File target, int width, int height) throws Exception { + ByteArrayOutputStream headerBytes = new ByteArrayOutputStream(); + try (DataOutputStream header = new DataOutputStream(headerBytes)) { + header.writeInt(width); + header.writeInt(height); + header.writeByte(8); + header.writeByte(2); + header.writeByte(0); + header.writeByte(0); + header.writeByte(0); + } + ByteArrayOutputStream png = new ByteArrayOutputStream(); + png.write(new byte[]{(byte) 137, 80, 78, 71, 13, 10, 26, 10}); + png.write(pngChunk("IHDR", headerBytes.toByteArray())); + png.write(pngChunk("IEND", new byte[0])); + Files.write(target.toPath(), png.toByteArray()); + } + + private byte[] pngChunk(String type, byte[] data) throws Exception { + byte[] typeBytes = type.getBytes(StandardCharsets.US_ASCII); + CRC32 checksum = new CRC32(); + checksum.update(typeBytes); + checksum.update(data); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (DataOutputStream output = new DataOutputStream(bytes)) { + output.writeInt(data.length); + output.write(typeBytes); + output.write(data); + output.writeInt((int) checksum.getValue()); + } + return bytes.toByteArray(); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/project/IrisImageMapSchemaTest.java b/core/src/test/java/art/arcane/iris/core/project/IrisImageMapSchemaTest.java new file mode 100644 index 000000000..b4191e43c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/project/IrisImageMapSchemaTest.java @@ -0,0 +1,64 @@ +package art.arcane.iris.core.project; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.IrisRegistrant; +import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisGeneratorStyle; +import art.arcane.iris.engine.object.IrisImageMap; +import art.arcane.iris.engine.object.IrisImageMapBinding; +import art.arcane.iris.engine.object.annotations.ArrayType; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.collection.KMap; +import art.arcane.volmlib.util.json.JSONObject; +import org.junit.Test; + +import java.lang.reflect.Field; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisImageMapSchemaTest { + @Test + public void dimensionAndGeneratorExposeResourceReferences() throws Exception { + Field bindings = IrisDimension.class.getDeclaredField("imageMaps"); + Field generatorMap = IrisGeneratorStyle.class.getDeclaredField("imageMap"); + + assertEquals(IrisImageMapBinding.class, bindings.getAnnotation(ArrayType.class).type()); + assertEquals(String.class, generatorMap.getType()); + assertEquals("image-maps", new IrisImageMap().getFolderName()); + } + + @Test + public void schemasExposeTypedMapAndBindingObjects() { + IrisData data = schemaData(); + JSONObject bindingSchema = new SchemaBuilder(IrisImageMapBinding.class, data).construct(); + JSONObject mapSchema = new SchemaBuilder(IrisImageMap.class, data).construct(); + + assertTrue(bindingSchema.getJSONObject("properties").has("application")); + assertTrue(bindingSchema.getJSONObject("properties").has("map")); + assertTrue(mapSchema.getJSONObject("properties").has("type")); + assertTrue(mapSchema.getJSONObject("properties").has("source")); + assertTrue(mapSchema.getJSONObject("properties").has("colors")); + assertTrue(mapSchema.getJSONObject("properties").has("blocksPerPixel")); + JSONObject properties = mapSchema.getJSONObject("properties"); + assertEquals(IrisImageMap.MINIMUM_SCALE, + properties.getJSONObject("blocksPerPixel").getDouble("minimum"), 0D); + assertEquals(IrisImageMap.MINIMUM_SCALE, + properties.getJSONObject("curveExponent").getDouble("minimum"), 0D); + assertEquals(IrisImageMap.MAXIMUM_COLOR_TOLERANCE, + properties.getJSONObject("colorTolerance").getDouble("maximum"), 0D); + } + + @SuppressWarnings("unchecked") + private static IrisData schemaData() { + IrisData data = mock(IrisData.class); + KMap, ResourceLoader> loaders = new KMap<>(); + when(data.getLoaders()).thenReturn(loaders); + when(data.getPossibleSnippets(anyString())).thenReturn(new KList<>()); + return data; + } +} diff --git a/core/src/test/java/art/arcane/iris/core/project/IrisWorldBoundarySchemaTest.java b/core/src/test/java/art/arcane/iris/core/project/IrisWorldBoundarySchemaTest.java new file mode 100644 index 000000000..e18773e9b --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/project/IrisWorldBoundarySchemaTest.java @@ -0,0 +1,42 @@ +package art.arcane.iris.core.project; + +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisWorldBoundary; +import art.arcane.iris.engine.object.IrisWorldBoundaryCenter; +import art.arcane.volmlib.util.json.JSONObject; +import org.junit.Test; + +import java.lang.reflect.Field; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +public class IrisWorldBoundarySchemaTest { + @Test + public void dimensionExposesOptionalTypedBoundary() throws Exception { + Field boundary = IrisDimension.class.getDeclaredField("worldBoundary"); + + assertEquals(IrisWorldBoundary.class, boundary.getType()); + assertNull(new IrisDimension().getWorldBoundary()); + } + + @Test + public void schemaExposesNativeBoundaryLimits() { + JSONObject schema = new SchemaBuilder(IrisWorldBoundary.class, null).construct(); + JSONObject definitions = schema.getJSONObject("definitions"); + JSONObject properties = schema.getJSONObject("properties"); + JSONObject centerReference = properties.getJSONObject("center"); + String centerKey = centerReference.getString("$ref").substring("#/definitions/".length()); + JSONObject center = definitions.getJSONObject(centerKey).getJSONObject("properties"); + + assertEquals("number", properties.getJSONObject("size").getString("type")); + assertEquals(1D, properties.getJSONObject("size").getDouble("minimum"), 0D); + assertEquals(IrisWorldBoundary.MAXIMUM_SIZE, + properties.getJSONObject("size").getDouble("maximum"), 0D); + assertEquals(Integer.MAX_VALUE, properties.getJSONObject("warningDistance").getInt("maximum")); + assertEquals(-IrisWorldBoundary.MAXIMUM_CENTER, center.getJSONObject("x").getDouble("minimum"), 0D); + assertEquals(IrisWorldBoundary.MAXIMUM_CENTER, center.getJSONObject("z").getDouble("maximum"), 0D); + assertEquals(IrisWorldBoundaryCenter.class, + new IrisWorldBoundary().getCenter().getClass()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/UpperDimensionImageMapRuntimeTest.java b/core/src/test/java/art/arcane/iris/engine/UpperDimensionImageMapRuntimeTest.java new file mode 100644 index 000000000..cd8cd6536 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/UpperDimensionImageMapRuntimeTest.java @@ -0,0 +1,59 @@ +package art.arcane.iris.engine; + +import art.arcane.iris.engine.image.IrisImageMapRuntime; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.spi.PlatformBlockState; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class UpperDimensionImageMapRuntimeTest { + @Test + public void mappedRegionAndBiomeOverrideProceduralSelections() { + IrisImageMapRuntime runtime = mock(IrisImageMapRuntime.class); + IrisRegion proceduralRegion = new IrisRegion(); + IrisRegion mappedRegion = new IrisRegion(); + IrisBiome proceduralBiome = new IrisBiome(); + IrisBiome mappedBiome = new IrisBiome(); + when(runtime.sampleRegion(12D, -7D)).thenReturn(mappedRegion); + when(runtime.sampleBiome(12D, -7D)).thenReturn(mappedBiome); + + assertSame(mappedRegion, UpperDimensionContext.mappedRegion( + runtime, proceduralRegion, 12D, -7D)); + assertSame(mappedBiome, UpperDimensionContext.mappedBiome( + runtime, proceduralBiome, 12D, -7D)); + } + + @Test + public void absentMappingsPreserveProceduralSelections() { + IrisImageMapRuntime runtime = mock(IrisImageMapRuntime.class); + IrisRegion proceduralRegion = new IrisRegion(); + IrisBiome proceduralBiome = new IrisBiome(); + PlatformBlockState proceduralBlock = mock(PlatformBlockState.class); + + assertSame(proceduralRegion, UpperDimensionContext.mappedRegion( + runtime, proceduralRegion, -2D, 4D)); + assertSame(proceduralBiome, UpperDimensionContext.mappedBiome( + runtime, proceduralBiome, -2D, 4D)); + assertSame(proceduralBlock, UpperDimensionContext.mappedSurfaceBlock( + runtime, proceduralBlock, -2D, 4D)); + } + + @Test + public void mappedHeightAndSurfaceBlockAreAuthoritative() { + IrisImageMapRuntime runtime = mock(IrisImageMapRuntime.class); + PlatformBlockState proceduralBlock = mock(PlatformBlockState.class); + PlatformBlockState mappedBlock = mock(PlatformBlockState.class); + when(runtime.sampleTerrainHeight(3D, 9D, 80D)).thenReturn(144D); + when(runtime.sampleSurfaceBlock(3D, 9D)).thenReturn(mappedBlock); + + assertEquals(144D, UpperDimensionContext.mappedTerrainHeight( + runtime, 80D, 3D, 9D), 0D); + assertSame(mappedBlock, UpperDimensionContext.mappedSurfaceBlock( + runtime, proceduralBlock, 3D, 9D)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/image/IrisImageMapCompilerTest.java b/core/src/test/java/art/arcane/iris/engine/image/IrisImageMapCompilerTest.java new file mode 100644 index 000000000..4c00f27c3 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/image/IrisImageMapCompilerTest.java @@ -0,0 +1,570 @@ +package art.arcane.iris.engine.image; + +import art.arcane.iris.engine.object.IrisImage; +import art.arcane.iris.engine.object.IrisImageMap; +import art.arcane.iris.engine.object.IrisImageMapAlpha; +import art.arcane.iris.engine.object.IrisImageMapOrigin; +import art.arcane.iris.engine.object.IrisImageMapOutOfBounds; +import art.arcane.iris.engine.object.IrisImageMapRotation; +import art.arcane.iris.engine.object.IrisImageMapSampling; +import art.arcane.iris.engine.object.IrisImageMapType; +import art.arcane.iris.engine.object.IrisImageMapUnknownColor; +import art.arcane.volmlib.util.collection.KMap; +import org.junit.Test; + +import java.awt.image.BufferedImage; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class IrisImageMapCompilerTest { + private static final double EPSILON = 0.000001D; + + @Test + public void decodesEightAndSixteenBitGrayscaleEndpoints() { + CompiledIrisImageMap eightBit = compile( + scalarDefinition(IrisImageMapType.GRAYSCALE_HEIGHT), + grayscale8(new int[][]{{0, 255}}) + ); + CompiledIrisImageMap sixteenBit = compile( + scalarDefinition(IrisImageMapType.GRAYSCALE_HEIGHT), + grayscale16(new int[][]{{0, 32768, 65535}}) + ); + + assertEquals(0D, eightBit.sampleNormalized(0D, 0D), EPSILON); + assertEquals(1D, eightBit.sampleNormalized(1D, 0D), EPSILON); + assertEquals(0D, sixteenBit.sampleNormalized(0D, 0D), EPSILON); + assertEquals(32768D / 65535D, sixteenBit.sampleNormalized(1D, 0D), EPSILON); + assertEquals(1D, sixteenBit.sampleNormalized(2D, 0D), EPSILON); + } + + @Test + public void decodesCanonicalRgbHeightAndRawChannels() { + BufferedImage image = rgb(new int[][]{{0x000000, 0x123456, 0xFFFFFF}}); + CompiledIrisImageMap compiled = compile(scalarDefinition(IrisImageMapType.RGB_HEIGHT), image); + + assertEquals(0D, compiled.sampleNormalized(0D, 0D), EPSILON); + assertEquals(0x123456 / 16_777_215D, compiled.sampleNormalized(1D, 0D), EPSILON); + assertEquals(1D, compiled.sampleNormalized(2D, 0D), EPSILON); + assertEquals(0x12, new IrisImage(image).getBandSample(1, 0, 0)); + assertEquals(0x34, new IrisImage(image).getBandSample(1, 0, 1)); + assertEquals(0x56, new IrisImage(image).getBandSample(1, 0, 2)); + } + + @Test + public void freezesDecodedValuesAndHashAtCompileTime() { + BufferedImage image = grayscale8(new int[][]{{64}}); + CompiledIrisImageMap compiled = compile( + scalarDefinition(IrisImageMapType.GRAYSCALE_HEIGHT), + image + ); + double initialValue = compiled.sampleNormalized(0D, 0D); + String initialHash = compiled.contentHash(); + + image.getRaster().setSample(0, 0, 0, 255); + + assertEquals(initialValue, compiled.sampleNormalized(0D, 0D), 0D); + assertEquals(initialHash, compiled.contentHash()); + CompiledIrisImageMap changed = compile( + scalarDefinition(IrisImageMapType.GRAYSCALE_HEIGHT), + image + ); + assertNotEquals(initialHash, changed.contentHash()); + } + + @Test + public void mapsOriginsScaleNegativeCoordinatesAndContainmentWithFloorSemantics() { + IrisImageMap definition = scalarDefinition(IrisImageMapType.GRAYSCALE_HEIGHT) + .setBlocksPerPixel(2D) + .setOrigin(new IrisImageMapOrigin(10D, 20D)) + .setSourceOrigin(new IrisImageMapOrigin(1D, 0D)) + .setFallbackValue(0.75D); + CompiledIrisImageMap compiled = compile(definition, grayscale8(new int[][]{{0, 128, 255}})); + + assertEquals(0D, compiled.sampleNormalized(8D, 20D), EPSILON); + assertEquals(128D / 255D, compiled.sampleNormalized(10D, 20D), EPSILON); + assertEquals(1D, compiled.sampleNormalized(12D, 20D), EPSILON); + assertEquals(0.75D, compiled.sampleNormalized(7.999D, 20D), EPSILON); + assertTrue(compiled.containsWorld(8D, 20D)); + assertFalse(compiled.containsWorld(7.999D, 20D)); + assertFalse(compiled.containsWorld(Double.POSITIVE_INFINITY, 20D)); + } + + @Test + public void mapsEveryQuarterTurnAndMirrorsAroundSourceOrigin() { + BufferedImage image = grayscale8(new int[][]{ + {0, 0, 0}, + {0, 0, 255}, + {0, 0, 0} + }); + IrisImageMap definition = scalarDefinition(IrisImageMapType.GRAYSCALE_HEIGHT) + .setSourceOrigin(new IrisImageMapOrigin(1D, 1D)); + + assertEquals(1D, compile(definition.setRotation(IrisImageMapRotation.DEG_0), image) + .sampleNormalized(1D, 0D), EPSILON); + assertEquals(1D, compile(definition.setRotation(IrisImageMapRotation.DEG_90), image) + .sampleNormalized(0D, 1D), EPSILON); + assertEquals(1D, compile(definition.setRotation(IrisImageMapRotation.DEG_180), image) + .sampleNormalized(-1D, 0D), EPSILON); + assertEquals(1D, compile(definition.setRotation(IrisImageMapRotation.DEG_270), image) + .sampleNormalized(0D, -1D), EPSILON); + assertEquals(1D, compile(definition + .setRotation(IrisImageMapRotation.DEG_90) + .setMirrorX(true), image) + .sampleNormalized(0D, -1D), EPSILON); + + BufferedImage zImage = grayscale8(new int[][]{ + {0, 0, 0}, + {0, 0, 0}, + {0, 255, 0} + }); + assertEquals(1D, compile(definition + .setRotation(IrisImageMapRotation.DEG_0) + .setMirrorX(false) + .setMirrorZ(true), zImage) + .sampleNormalized(0D, -1D), EPSILON); + } + + @Test + public void appliesEveryOutOfBoundsMode() { + BufferedImage image = grayscale8(new int[][]{{0, 255}}); + IrisImageMap definition = scalarDefinition(IrisImageMapType.GRAYSCALE_HEIGHT).setFallbackValue(0.25D); + + assertEquals(0.25D, compile(definition.setOutOfBounds(IrisImageMapOutOfBounds.FALLBACK), image) + .sampleNormalized(-1D, 0D), EPSILON); + assertEquals(0D, compile(definition.setOutOfBounds(IrisImageMapOutOfBounds.CLAMP), image) + .sampleNormalized(-1D, 0D), EPSILON); + assertEquals(1D, compile(definition.setOutOfBounds(IrisImageMapOutOfBounds.REPEAT), image) + .sampleNormalized(-1D, 0D), EPSILON); + assertEquals(1D, compile(definition.setOutOfBounds(IrisImageMapOutOfBounds.MIRROR), image) + .sampleNormalized(-2D, 0D), EPSILON); + assertEquals(1D, compile(definition, image).sampleNormalized(2D, 0D), EPSILON); + CompiledIrisImageMap error = compile(definition.setOutOfBounds(IrisImageMapOutOfBounds.ERROR), image); + assertThrows(IrisImageMapValidationException.class, () -> error.sampleNormalized(-1D, 0D)); + } + + @Test + public void samplesBilinearAndBicubicNumericMaps() { + IrisImageMap bilinearDefinition = scalarDefinition(IrisImageMapType.GRAYSCALE_HEIGHT) + .setSampling(IrisImageMapSampling.BILINEAR) + .setOutOfBounds(IrisImageMapOutOfBounds.CLAMP); + CompiledIrisImageMap bilinear = compile(bilinearDefinition, grayscale8(new int[][]{ + {0, 255}, + {255, 0} + })); + assertEquals(0.5D, bilinear.sampleNormalized(0.5D, 0.5D), EPSILON); + + IrisImageMap bicubicDefinition = scalarDefinition(IrisImageMapType.GRAYSCALE_HEIGHT) + .setSampling(IrisImageMapSampling.BICUBIC) + .setOutOfBounds(IrisImageMapOutOfBounds.CLAMP); + CompiledIrisImageMap bicubic = compile( + bicubicDefinition, + grayscale8(new int[][]{{0, 85, 170, 255}}) + ); + assertEquals(0.5D, bicubic.sampleNormalized(1.5D, 0D), EPSILON); + } + + @Test + public void reportsExactErrorSamplingKernelCoverage() { + IrisImageMap bilinearDefinition = scalarDefinition(IrisImageMapType.GRAYSCALE_HEIGHT) + .setSampling(IrisImageMapSampling.BILINEAR) + .setOutOfBounds(IrisImageMapOutOfBounds.ERROR); + CompiledIrisImageMap bilinear = compile(bilinearDefinition, grayscale8(new int[][]{ + {0, 255}, + {255, 0} + })); + + assertTrue(bilinear.containsWorldForSampling(0.5D, 0.5D)); + assertTrue(bilinear.containsWorldForSampling(1D, 1D)); + assertFalse(bilinear.containsWorldForSampling(1.5D, 0.5D)); + assertThrows(IrisImageMapValidationException.class, () -> bilinear.sampleNormalized(1.5D, 0.5D)); + + IrisImageMap bicubicDefinition = scalarDefinition(IrisImageMapType.GRAYSCALE_HEIGHT) + .setSampling(IrisImageMapSampling.BICUBIC) + .setOutOfBounds(IrisImageMapOutOfBounds.ERROR); + CompiledIrisImageMap bicubic = compile(bicubicDefinition, grayscale8(new int[][]{ + {0, 0, 0, 0, 0}, + {0, 0, 0, 0, 0}, + {0, 0, 255, 0, 0}, + {0, 0, 0, 0, 0}, + {0, 0, 0, 0, 0} + })); + + assertTrue(bicubic.containsWorldForSampling(0D, 0D)); + assertTrue(bicubic.containsWorldForSampling(1.5D, 1.5D)); + assertFalse(bicubic.containsWorldForSampling(0.5D, 1.5D)); + assertFalse(bicubic.containsWorldForSampling(3.5D, 1.5D)); + assertThrows(IrisImageMapValidationException.class, () -> bicubic.sampleNormalized(0.5D, 1.5D)); + } + + @Test + public void lightweightValidationViewRetainsCoverageWithoutDecodedValues() { + IrisImageMap definition = scalarDefinition(IrisImageMapType.GRAYSCALE_HEIGHT) + .setSampling(IrisImageMapSampling.BILINEAR) + .setOutOfBounds(IrisImageMapOutOfBounds.ERROR); + CompiledIrisImageMap compiled = compile(definition, grayscale8(new int[][]{ + {0, 255}, + {255, 0} + })); + CompiledIrisImageMap validationView = compiled.withoutDecodedValues(); + + assertEquals(compiled.contentHash(), validationView.contentHash()); + assertEquals(compiled.getSourceMetadata(), validationView.getSourceMetadata()); + assertEquals(compiled.containsWorldForSampling(0.5D, 0.5D), + validationView.containsWorldForSampling(0.5D, 0.5D)); + assertEquals(compiled.containsWorldForSampling(1.5D, 0.5D), + validationView.containsWorldForSampling(1.5D, 0.5D)); + assertThrows(IrisImageMapValidationException.class, () -> validationView.sampleNormalized(0D, 0D)); + } + + @Test + public void appliesLoadTimeSmoothingCurveAndHeightClamp() { + IrisImageMap smoothedDefinition = scalarDefinition(IrisImageMapType.GRAYSCALE_HEIGHT) + .setSmoothingRadius(1) + .setOutOfBounds(IrisImageMapOutOfBounds.CLAMP); + CompiledIrisImageMap smoothed = compile(smoothedDefinition, grayscale8(new int[][]{{0, 255, 0}})); + assertEquals(1D / 3D, smoothed.sampleNormalized(0D, 0D), EPSILON); + assertEquals(1D / 3D, smoothed.sampleNormalized(1D, 0D), EPSILON); + assertEquals(1D / 3D, smoothed.sampleNormalized(2D, 0D), EPSILON); + + IrisImageMap curvedDefinition = scalarDefinition(IrisImageMapType.GRAYSCALE_HEIGHT) + .setCurveExponent(2D) + .setMinimumHeight(10D) + .setMaximumHeight(20D) + .setVerticalOffset(5D) + .setClamp(true); + CompiledIrisImageMap curved = compile(curvedDefinition, grayscale8(new int[][]{{128, 255}})); + double curvedValue = Math.pow(128D / 255D, 2D); + assertEquals(curvedValue, curved.sampleNormalized(0D, 0D), EPSILON); + assertEquals(15D + (curvedValue * 10D), curved.sampleHeight(0D, 0D), EPSILON); + assertEquals(20D, curved.sampleHeight(1D, 0D), EPSILON); + assertEquals(1, curved.getClippedPixelCount()); + } + + @Test + public void decodesContinuousAndBinaryMasks() { + BufferedImage image = grayscale8(new int[][]{{64, 128, 192}}); + IrisImageMap continuousDefinition = scalarDefinition(IrisImageMapType.GRAYSCALE_MASK) + .setInverted(true); + CompiledIrisImageMap continuous = compile(continuousDefinition, image); + assertEquals(1D - (64D / 255D), continuous.sampleNormalized(0D, 0D), EPSILON); + assertThrows(IrisImageMapValidationException.class, () -> continuous.sampleHeight(0D, 0D)); + + IrisImageMap binaryDefinition = scalarDefinition(IrisImageMapType.BINARY_MASK) + .setThreshold(0.25D) + .setFalloff(0.5D); + CompiledIrisImageMap binary = compile(binaryDefinition, image); + assertEquals(((64D / 255D) - 0.25D) / 0.5D, binary.sampleNormalized(0D, 0D), EPSILON); + assertEquals(((128D / 255D) - 0.25D) / 0.5D, binary.sampleNormalized(1D, 0D), EPSILON); + assertEquals(1D, binary.sampleNormalized(2D, 0D), EPSILON); + } + + @Test + public void appliesNumericAlphaPoliciesAndAlphaMasks() { + BufferedImage image = rgba(new int[][]{{0xFFFFFFFF, 0x80FFFFFF, 0x00FFFFFF}}); + IrisImageMap definition = scalarDefinition(IrisImageMapType.RGB_HEIGHT); + + assertEquals(1D, compile(definition.setAlpha(IrisImageMapAlpha.IGNORE), image) + .sampleNormalized(1D, 0D), EPSILON); + assertEquals(128D / 255D, compile(definition.setAlpha(IrisImageMapAlpha.MASK), image) + .sampleNormalized(1D, 0D), EPSILON); + assertEquals(0.25D, compile(definition + .setAlpha(IrisImageMapAlpha.TRANSPARENT_IS_FALLBACK) + .setFallbackValue(0.25D), image) + .sampleNormalized(2D, 0D), EPSILON); + assertThrows( + IrisImageMapValidationException.class, + () -> compile(definition.setAlpha(IrisImageMapAlpha.ERROR), image) + ); + + IrisImageMap alphaDefinition = scalarDefinition(IrisImageMapType.ALPHA_MASK) + .setAlpha(IrisImageMapAlpha.IGNORE); + CompiledIrisImageMap alpha = compile(alphaDefinition, image); + assertEquals(1D, alpha.sampleNormalized(0D, 0D), EPSILON); + assertEquals(128D / 255D, alpha.sampleNormalized(1D, 0D), EPSILON); + assertEquals(0D, alpha.sampleNormalized(2D, 0D), EPSILON); + } + + @Test + public void resolvesExactAndTolerantRawSrgbLegends() { + KMap colors = new KMap<>(); + colors.put("#FF0000", "red"); + colors.put("#0000FF", "blue"); + IrisImageMap definition = colorDefinition(colors) + .setColorTolerance(2D) + .setUnknownColor(IrisImageMapUnknownColor.IGNORE); + CompiledIrisImageMap compiled = compile( + definition, + rgb(new int[][]{{0xFF0000, 0xFE0100, 0x0000FF, 0x00FF00}}) + ); + + assertEquals("red", compiled.sampleTarget(0D, 0D)); + assertEquals("red", compiled.sampleTarget(1D, 0D)); + assertEquals("blue", compiled.sampleTarget(2D, 0D)); + assertNull(compiled.sampleTarget(3D, 0D)); + assertEquals(1, compiled.getUnknownColorPixelCount()); + } + + @Test + public void rejectsAmbiguousTolerantLegendMatchesButExactWins() { + KMap colors = new KMap<>(); + colors.put("#FF0000", "high"); + colors.put("#FD0000", "low"); + IrisImageMap definition = colorDefinition(colors).setColorTolerance(1D); + + IrisImageMapValidationException ambiguity = assertThrows( + IrisImageMapValidationException.class, + () -> compile(definition, rgb(new int[][]{{0xFE0000}})) + ); + assertTrue(ambiguity.getMessage().contains("ambiguously matches")); + assertEquals("high", compile(definition, rgb(new int[][]{{0xFF0000}})).sampleTarget(0D, 0D)); + } + + @Test + public void appliesColorAlphaFallbackAndRejectsPartialMaskAlpha() { + KMap colors = new KMap<>(); + colors.put("#FF0000", "red"); + IrisImageMap definition = colorDefinition(colors) + .setAlpha(IrisImageMapAlpha.MASK) + .setFallbackTarget("fallback"); + + CompiledIrisImageMap binary = compile(definition, rgba(new int[][]{{0xFFFF0000, 0x00FF0000}})); + assertEquals("red", binary.sampleTarget(0D, 0D)); + assertEquals("fallback", binary.sampleTarget(1D, 0D)); + assertThrows( + IrisImageMapValidationException.class, + () -> compile(definition, rgba(new int[][]{{0x80FF0000}})) + ); + } + + @Test + public void freezesCompiledLegendTargets() { + KMap colors = new KMap<>(); + colors.put("#FF0000", "red"); + IrisImageMap definition = colorDefinition(colors); + BufferedImage image = rgb(new int[][]{{0xFF0000}}); + CompiledIrisImageMap compiled = compile(definition, image); + + colors.put("#FF0000", "changed"); + image.setRGB(0, 0, 0xFF0000FF); + + assertEquals("red", compiled.sampleTarget(0D, 0D)); + } + + @Test + public void validatesFormatModeBitDepthAndDimensionsWithTypedDiagnostics() { + IrisImageMap grayscaleDefinition = scalarDefinition(IrisImageMapType.GRAYSCALE_HEIGHT); + IrisImageMapValidationException format = assertThrows( + IrisImageMapValidationException.class, + () -> CompiledIrisImageMap.compile( + grayscaleDefinition, + new IrisImage(grayscale8(new int[][]{{0}}), "jpeg") + ) + ); + assertTrue(format.getDiagnostics().get(0).contains("PNG")); + + IrisImageMapValidationException mode = assertThrows( + IrisImageMapValidationException.class, + () -> compile(grayscaleDefinition, rgb(new int[][]{{0}})) + ); + assertTrue(mode.getMessage().contains("grayscale PNG")); + + BufferedImage indexed = new BufferedImage(1, 1, BufferedImage.TYPE_BYTE_INDEXED); + IrisImageMapValidationException indexedMode = assertThrows( + IrisImageMapValidationException.class, + () -> compile(grayscaleDefinition, indexed) + ); + assertTrue(indexedMode.getMessage().contains("Indexed PNG")); + + BufferedImage lowBitRgb = new BufferedImage(1, 1, BufferedImage.TYPE_USHORT_565_RGB); + IrisImageMapValidationException bitDepth = assertThrows( + IrisImageMapValidationException.class, + () -> compile(scalarDefinition(IrisImageMapType.RGB_HEIGHT), lowBitRgb) + ); + assertTrue(bitDepth.getMessage().contains("8-bit RGB")); + + BufferedImage tooWide = new BufferedImage(16_385, 1, BufferedImage.TYPE_BYTE_GRAY); + IrisImageMapValidationException dimensions = assertThrows( + IrisImageMapValidationException.class, + () -> compile(grayscaleDefinition, tooWide) + ); + assertTrue(dimensions.getMessage().contains("16385")); + + BufferedImage tooManyPixels = new ReportedSizeImage(4_097, 4_097); + IrisImageMapValidationException pixels = assertThrows( + IrisImageMapValidationException.class, + () -> compile(grayscaleDefinition, tooManyPixels) + ); + assertTrue(pixels.getMessage().contains("pixels")); + } + + @Test + public void enforcesSchemaNumericBoundsAtRuntime() { + IrisImageMap tooSmallScale = scalarDefinition(IrisImageMapType.GRAYSCALE_HEIGHT) + .setBlocksPerPixel(IrisImageMap.MINIMUM_SCALE / 2D); + IrisImageMapValidationException scale = assertThrows( + IrisImageMapValidationException.class, + () -> compile(tooSmallScale, grayscale8(new int[][]{{0}})) + ); + assertTrue(scale.getMessage().contains("blocksPerPixel must be finite and at least")); + + IrisImageMap minimums = scalarDefinition(IrisImageMapType.GRAYSCALE_HEIGHT) + .setBlocksPerPixel(IrisImageMap.MINIMUM_SCALE) + .setCurveExponent(IrisImageMap.MINIMUM_SCALE); + assertEquals(0D, compile(minimums, grayscale8(new int[][]{{0}})) + .sampleNormalized(0D, 0D), EPSILON); + + IrisImageMap tooSmallCurve = scalarDefinition(IrisImageMapType.GRAYSCALE_HEIGHT) + .setCurveExponent(IrisImageMap.MINIMUM_SCALE / 2D); + IrisImageMapValidationException curve = assertThrows( + IrisImageMapValidationException.class, + () -> compile(tooSmallCurve, grayscale8(new int[][]{{0}})) + ); + assertTrue(curve.getMessage().contains("curveExponent must be finite and at least")); + + KMap colors = new KMap<>(); + colors.put("#FF0000", "red"); + IrisImageMap maximumTolerance = colorDefinition(colors) + .setColorTolerance(IrisImageMap.MAXIMUM_COLOR_TOLERANCE); + assertEquals("red", compile(maximumTolerance, rgb(new int[][]{{0xFF0000}})) + .sampleTarget(0D, 0D)); + + IrisImageMap excessiveTolerance = colorDefinition(colors) + .setColorTolerance(IrisImageMap.MAXIMUM_COLOR_TOLERANCE + 0.000001D); + IrisImageMapValidationException tolerance = assertThrows( + IrisImageMapValidationException.class, + () -> compile(excessiveTolerance, rgb(new int[][]{{0xFF0000}})) + ); + assertTrue(tolerance.getMessage().contains("colorTolerance must be finite and within")); + } + + @Test + public void hashesDecodedContentAndCanonicalConfigurationDeterministically() { + KMap firstColors = new KMap<>(); + firstColors.put("#FF0000", "red"); + firstColors.put("#0000FF", "blue"); + KMap secondColors = new KMap<>(); + secondColors.put("#0000FF", "blue"); + secondColors.put("#FF0000", "red"); + BufferedImage image = rgb(new int[][]{{0xFF0000}}); + + CompiledIrisImageMap first = compile(colorDefinition(firstColors), image); + CompiledIrisImageMap second = compile(colorDefinition(secondColors), image); + assertEquals(first.contentHash(), second.contentHash()); + assertEquals(64, first.contentHash().length()); + assertEquals(64, first.getSourceMetadata().decodedContentHash().length()); + assertEquals(1, first.getSourceWidth()); + assertEquals(1, first.getSourceHeight()); + assertEquals(IrisImageMapType.COLOR_MAP, first.getType()); + + IrisImageMap changed = colorDefinition(firstColors).setBlocksPerPixel(2D); + assertNotEquals(first.contentHash(), compile(changed, image).contentHash()); + } + + @Test + public void storesLargeRectangularMapsAcrossBoundedTileEdges() { + BufferedImage image = new BufferedImage(1_025, 1_024, BufferedImage.TYPE_BYTE_GRAY); + image.getRaster().setSample(1, 1_023, 0, 255); + + CompiledIrisImageMap compiled = compile( + scalarDefinition(IrisImageMapType.GRAYSCALE_HEIGHT), image + ); + + assertEquals(1_025, compiled.getSourceWidth()); + assertEquals(1_024, compiled.getSourceHeight()); + assertEquals(0D, compiled.sampleNormalized(0D, 1_023D), 0D); + assertEquals(1D, compiled.sampleNormalized(1D, 1_023D), 0D); + } + + private static CompiledIrisImageMap compile(IrisImageMap definition, BufferedImage image) { + return CompiledIrisImageMap.compile(definition, new IrisImage(image, "png")); + } + + private static IrisImageMap scalarDefinition(IrisImageMapType type) { + return new IrisImageMap() + .setSource("test") + .setType(type) + .setOutOfBounds(IrisImageMapOutOfBounds.FALLBACK); + } + + private static IrisImageMap colorDefinition(KMap colors) { + return new IrisImageMap() + .setSource("test") + .setType(IrisImageMapType.COLOR_MAP) + .setColors(colors) + .setOutOfBounds(IrisImageMapOutOfBounds.CLAMP); + } + + private static BufferedImage grayscale8(int[][] values) { + int height = values.length; + int width = values[0].length; + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_BYTE_GRAY); + for (int z = 0; z < height; z++) { + for (int x = 0; x < width; x++) { + image.getRaster().setSample(x, z, 0, values[z][x]); + } + } + return image; + } + + private static BufferedImage grayscale16(int[][] values) { + int height = values.length; + int width = values[0].length; + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_USHORT_GRAY); + for (int z = 0; z < height; z++) { + for (int x = 0; x < width; x++) { + image.getRaster().setSample(x, z, 0, values[z][x]); + } + } + return image; + } + + private static BufferedImage rgb(int[][] values) { + int height = values.length; + int width = values[0].length; + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + for (int z = 0; z < height; z++) { + for (int x = 0; x < width; x++) { + image.setRGB(x, z, 0xFF000000 | values[z][x]); + } + } + return image; + } + + private static BufferedImage rgba(int[][] values) { + int height = values.length; + int width = values[0].length; + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + for (int z = 0; z < height; z++) { + for (int x = 0; x < width; x++) { + image.setRGB(x, z, values[z][x]); + } + } + return image; + } + + private static final class ReportedSizeImage extends BufferedImage { + private final int reportedWidth; + private final int reportedHeight; + + private ReportedSizeImage(int reportedWidth, int reportedHeight) { + super(1, 1, BufferedImage.TYPE_BYTE_GRAY); + this.reportedWidth = reportedWidth; + this.reportedHeight = reportedHeight; + } + + @Override + public int getWidth() { + return reportedWidth; + } + + @Override + public int getHeight() { + return reportedHeight; + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/image/IrisImageMapMaskSamplerTest.java b/core/src/test/java/art/arcane/iris/engine/image/IrisImageMapMaskSamplerTest.java new file mode 100644 index 000000000..d955edf25 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/image/IrisImageMapMaskSamplerTest.java @@ -0,0 +1,122 @@ +package art.arcane.iris.engine.image; + +import art.arcane.iris.engine.object.IrisImage; +import art.arcane.iris.engine.object.IrisImageMap; +import art.arcane.iris.engine.object.IrisImageMapMask; +import art.arcane.iris.engine.object.IrisImageMapMaskOperation; +import art.arcane.iris.engine.object.IrisImageMapOutOfBounds; +import art.arcane.iris.engine.object.IrisImageMapType; +import org.junit.Test; + +import java.awt.image.BufferedImage; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +public class IrisImageMapMaskSamplerTest { + private static final double EPSILON = 0.000001D; + + @Test + public void appliesEveryOperationInDeclaredOrder() { + CompiledIrisImageMap half = compile(128); + CompiledIrisImageMap quarter = compile(64); + + assertEquals(128D / 255D, sampler(layer(half, IrisImageMapMaskOperation.MULTIPLY)).sample(0D, 0D), EPSILON); + assertEquals(128D / 255D, sampler(layer(half, IrisImageMapMaskOperation.MINIMUM)).sample(0D, 0D), EPSILON); + assertEquals(128D / 255D, sampler( + layer(half, IrisImageMapMaskOperation.SUBTRACT), + layer(half, IrisImageMapMaskOperation.MAXIMUM) + ).sample(0D, 0D), EPSILON); + assertEquals(191D / 255D, sampler( + layer(half, IrisImageMapMaskOperation.SUBTRACT), + layer(quarter, IrisImageMapMaskOperation.ADD) + ).sample(0D, 0D), EPSILON); + assertEquals(127D / 255D, sampler(layer(half, IrisImageMapMaskOperation.SUBTRACT)).sample(0D, 0D), EPSILON); + + double subtractThenMultiply = sampler( + layer(quarter, IrisImageMapMaskOperation.SUBTRACT), + layer(half, IrisImageMapMaskOperation.MULTIPLY) + ).sample(0D, 0D); + double multiplyThenSubtract = sampler( + layer(half, IrisImageMapMaskOperation.MULTIPLY), + layer(quarter, IrisImageMapMaskOperation.SUBTRACT) + ).sample(0D, 0D); + assertEquals((1D - (64D / 255D)) * (128D / 255D), subtractThenMultiply, EPSILON); + assertEquals((128D - 64D) / 255D, multiplyThenSubtract, EPSILON); + } + + @Test + public void appliesInversionThresholdFalloffAndClamping() { + CompiledIrisImageMap quarter = compile(64); + CompiledIrisImageMap threeQuarters = compile(192); + + assertEquals(191D / 255D, sampler(layer( + quarter, IrisImageMapMaskOperation.MULTIPLY, true, 0D, 0D + )).sample(0D, 0D), EPSILON); + assertEquals(0D, sampler(layer( + quarter, IrisImageMapMaskOperation.MULTIPLY, false, 0.5D, 0D + )).sample(0D, 0D), EPSILON); + assertEquals(1D, sampler(layer( + threeQuarters, IrisImageMapMaskOperation.MULTIPLY, false, 0.5D, 0D + )).sample(0D, 0D), EPSILON); + assertEquals(((128D / 255D) - 0.25D) / 0.5D, sampler(layer( + compile(128), IrisImageMapMaskOperation.MULTIPLY, false, 0.25D, 0.5D + )).sample(0D, 0D), EPSILON); + assertEquals(1D, sampler(layer( + threeQuarters, IrisImageMapMaskOperation.ADD + )).sample(0D, 0D), EPSILON); + assertEquals(0D, sampler(layer( + threeQuarters, IrisImageMapMaskOperation.SUBTRACT + ), layer( + threeQuarters, IrisImageMapMaskOperation.SUBTRACT + )).sample(0D, 0D), EPSILON); + } + + @Test + public void rejectsInvalidLayerDefinitionsAndMismatchedLists() { + CompiledIrisImageMap compiled = compile(128); + IrisImageMapMask invalid = new IrisImageMapMask().setMap("mask").setThreshold(Double.NaN); + + assertThrows(IrisImageMapValidationException.class, + () -> IrisImageMapMaskSampler.layer(compiled, invalid)); + assertThrows(IllegalArgumentException.class, + () -> IrisImageMapMaskSampler.of(List.of(compiled), List.of())); + } + + private static IrisImageMapMaskSampler sampler(IrisImageMapMaskSampler.Layer... layers) { + return new IrisImageMapMaskSampler(List.of(layers)); + } + + private static IrisImageMapMaskSampler.Layer layer( + CompiledIrisImageMap compiled, + IrisImageMapMaskOperation operation + ) { + return layer(compiled, operation, false, 0D, 0D); + } + + private static IrisImageMapMaskSampler.Layer layer( + CompiledIrisImageMap compiled, + IrisImageMapMaskOperation operation, + boolean inverted, + double threshold, + double falloff + ) { + return IrisImageMapMaskSampler.layer(compiled, new IrisImageMapMask() + .setMap("mask") + .setOperation(operation) + .setInverted(inverted) + .setThreshold(threshold) + .setFalloff(falloff)); + } + + private static CompiledIrisImageMap compile(int value) { + BufferedImage image = new BufferedImage(1, 1, BufferedImage.TYPE_BYTE_GRAY); + image.getRaster().setSample(0, 0, 0, value); + IrisImageMap definition = new IrisImageMap() + .setSource("mask") + .setType(IrisImageMapType.GRAYSCALE_MASK) + .setOutOfBounds(IrisImageMapOutOfBounds.CLAMP); + return CompiledIrisImageMap.compile(definition, new IrisImage(image, "png")); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/image/IrisImageMapRuntimeTest.java b/core/src/test/java/art/arcane/iris/engine/image/IrisImageMapRuntimeTest.java new file mode 100644 index 000000000..58c8ea2f5 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/image/IrisImageMapRuntimeTest.java @@ -0,0 +1,163 @@ +package art.arcane.iris.engine.image; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisImageMap; +import art.arcane.iris.engine.object.IrisImageMapApplication; +import art.arcane.iris.engine.object.IrisImageMapBinding; +import art.arcane.iris.engine.object.IrisImageMapMask; +import art.arcane.iris.engine.object.IrisImageMapMaskOperation; +import art.arcane.iris.engine.object.IrisImageMapOutOfBounds; +import art.arcane.iris.engine.object.IrisImageMapType; +import art.arcane.volmlib.util.collection.KList; +import com.google.gson.Gson; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +public class IrisImageMapRuntimeTest { + private static final double EPSILON = 0.0001D; + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void compilesHeightBindingsAndKeepsReloadSamplingDeterministic() throws Exception { + File pack = temporaryFolder.newFolder("height-pack"); + writeMap(pack, "terrain", "terrain", grayscale(0, 255), new IrisImageMap() + .setSource("terrain") + .setType(IrisImageMapType.GRAYSCALE_HEIGHT) + .setMinimumHeight(-64D) + .setMaximumHeight(320D) + .setOutOfBounds(IrisImageMapOutOfBounds.CLAMP)); + IrisDimension dimension = new IrisDimension(); + dimension.getImageMaps().add(new IrisImageMapBinding() + .setKey("terrain") + .setMap("terrain") + .setApplication(IrisImageMapApplication.TERRAIN_HEIGHT)); + + Sample first = sample(pack, dimension); + Sample second = sample(pack, dimension); + + assertEquals(0D, first.minimum(), EPSILON); + assertEquals(384D, first.maximum(), EPSILON); + assertEquals(first.minimum(), second.minimum(), 0D); + assertEquals(first.maximum(), second.maximum(), 0D); + assertEquals(first.hash(), second.hash()); + } + + @Test + public void composesNamedMasksInDeclarationOrder() throws Exception { + File pack = temporaryFolder.newFolder("mask-pack"); + writeMap(pack, "terrain", "terrain", grayscale(255), new IrisImageMap() + .setSource("terrain") + .setType(IrisImageMapType.GRAYSCALE_HEIGHT) + .setMinimumHeight(-64D) + .setMaximumHeight(320D)); + writeMap(pack, "weight", "weight", grayscale(128), new IrisImageMap() + .setSource("weight") + .setType(IrisImageMapType.GRAYSCALE_MASK)); + IrisImageMapMask mask = new IrisImageMapMask() + .setMap("weight") + .setOperation(IrisImageMapMaskOperation.MULTIPLY) + .setThreshold(0D) + .setFalloff(1D); + IrisDimension dimension = new IrisDimension(); + dimension.setImageMaps(new KList<>()); + dimension.getImageMaps().add(new IrisImageMapBinding() + .setKey("terrain") + .setMap("terrain") + .setApplication(IrisImageMapApplication.TERRAIN_HEIGHT) + .setMasks(new KList<>(mask))); + dimension.getImageMaps().add(new IrisImageMapBinding() + .setKey("weight") + .setMap("weight") + .setApplication(IrisImageMapApplication.MASK)); + + IrisData data = IrisData.openDatapackCompiler(pack); + try { + IrisImageMapRuntime runtime = IrisImageMapRuntime.compile(data, dimension, -64); + double weight = 128D / 255D; + double expected = 10D + ((384D - 10D) * weight); + assertEquals(expected, runtime.sampleTerrainHeight(0D, 0D, 10D), EPSILON); + } finally { + data.close(); + } + } + + @Test + public void unloadsDecodedImageWhenApplicationValidationFails() throws Exception { + File pack = temporaryFolder.newFolder("invalid-application-pack"); + writeMap(pack, "terrain", "terrain", grayscale(255), new IrisImageMap() + .setSource("terrain") + .setType(IrisImageMapType.GRAYSCALE_HEIGHT)); + IrisDimension dimension = new IrisDimension(); + dimension.getImageMaps().add(new IrisImageMapBinding() + .setKey("terrain") + .setMap("terrain") + .setApplication(IrisImageMapApplication.BIOME)); + + IrisData data = IrisData.openDatapackCompiler(pack); + try { + assertThrows(IrisImageMapValidationException.class, + () -> IrisImageMapRuntime.compile(data, dimension, -64)); + assertEquals(0L, data.getImageLoader().getSize()); + } finally { + data.close(); + } + } + + private Sample sample(File pack, IrisDimension dimension) { + IrisData data = IrisData.openDatapackCompiler(pack); + try { + IrisImageMapRuntime runtime = IrisImageMapRuntime.compile(data, dimension, -64); + CompiledIrisImageMap compiled = runtime.getCompiled("terrain"); + return new Sample( + runtime.sampleTerrainHeight(0D, 0D, 25D), + runtime.sampleTerrainHeight(1D, 0D, 25D), + compiled.getContentHash() + ); + } finally { + data.close(); + } + } + + private void writeMap( + File pack, + String mapKey, + String imageKey, + BufferedImage image, + IrisImageMap map + ) throws Exception { + File images = new File(pack, "images"); + File maps = new File(pack, "image-maps"); + Files.createDirectories(images.toPath()); + Files.createDirectories(maps.toPath()); + ImageIO.write(image, "png", new File(images, imageKey + ".png")); + Files.writeString( + new File(maps, mapKey + ".json").toPath(), + new Gson().toJson(map), + StandardCharsets.UTF_8 + ); + } + + private BufferedImage grayscale(int... samples) { + BufferedImage image = new BufferedImage(samples.length, 1, BufferedImage.TYPE_BYTE_GRAY); + for (int x = 0; x < samples.length; x++) { + image.getRaster().setSample(x, 0, 0, samples[x]); + } + return image; + } + + private record Sample(double minimum, double maximum, String hash) { + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisWorldBoundaryTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisWorldBoundaryTest.java new file mode 100644 index 000000000..39abe9b62 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisWorldBoundaryTest.java @@ -0,0 +1,49 @@ +package art.arcane.iris.engine.object; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertThrows; + +public class IrisWorldBoundaryTest { + @Test + public void absentConfigurationHasNoSyntheticBoundarySnapshot() { + assertThrows(NullPointerException.class, () -> IrisWorldBoundary.snapshot(null)); + } + + @Test + public void configuredBoundarySnapshotsAllValues() { + IrisWorldBoundary configured = new IrisWorldBoundary() + .setCenter(new IrisWorldBoundaryCenter(128.5D, -64.25D)) + .setSize(16_384D) + .setWarningDistance(16) + .setDamageBuffer(7.5D) + .setDamageAmount(0.75D); + + IrisWorldBoundary snapshot = IrisWorldBoundary.snapshot(configured); + configured.getCenter().setX(512D); + + assertNotSame(configured, snapshot); + assertNotSame(configured.getCenter(), snapshot.getCenter()); + assertEquals(128.5D, snapshot.getCenter().getX(), 0D); + assertEquals(-64.25D, snapshot.getCenter().getZ(), 0D); + assertEquals(16_384D, snapshot.getSize(), 0D); + assertEquals(16, snapshot.getWarningDistance()); + assertEquals(7.5D, snapshot.getDamageBuffer(), 0D); + assertEquals(0.75D, snapshot.getDamageAmount(), 0D); + } + + @Test + public void rejectsValuesOutsideNativeLimits() { + assertThrows(IllegalArgumentException.class, + () -> new IrisWorldBoundary().setSize(IrisWorldBoundary.MAXIMUM_SIZE + 1D).validate()); + assertThrows(IllegalArgumentException.class, + () -> new IrisWorldBoundary().setWarningDistance(-1).validate()); + assertThrows(IllegalArgumentException.class, + () -> new IrisWorldBoundary().setCenter(new IrisWorldBoundaryCenter( + IrisWorldBoundary.MAXIMUM_CENTER + 1D, 0D)).validate()); + assertThrows(IllegalArgumentException.class, + () -> new IrisWorldBoundary().setDamageAmount(Double.NaN).validate()); + } +} diff --git a/core/src/test/java/art/arcane/iris/purity/PackTypeBukkitPurityGateTest.java b/core/src/test/java/art/arcane/iris/purity/PackTypeBukkitPurityGateTest.java index 0fe4ba6e4..c577d0a32 100644 --- a/core/src/test/java/art/arcane/iris/purity/PackTypeBukkitPurityGateTest.java +++ b/core/src/test/java/art/arcane/iris/purity/PackTypeBukkitPurityGateTest.java @@ -96,6 +96,7 @@ public class PackTypeBukkitPurityGateTest { "art.arcane.iris.engine.object.IrisExpression", "art.arcane.iris.engine.object.IrisObject", "art.arcane.iris.engine.object.IrisImage", + "art.arcane.iris.engine.object.IrisImageMap", "art.arcane.iris.engine.object.matter.IrisMatterObject", "art.arcane.iris.engine.object.IrisStructure", "art.arcane.iris.engine.object.IrisJigsawPool", diff --git a/core/src/test/java/art/arcane/iris/util/common/misc/WebCacheTest.java b/core/src/test/java/art/arcane/iris/util/common/misc/WebCacheTest.java index 5e209823f..ab243ae9f 100644 --- a/core/src/test/java/art/arcane/iris/util/common/misc/WebCacheTest.java +++ b/core/src/test/java/art/arcane/iris/util/common/misc/WebCacheTest.java @@ -17,14 +17,17 @@ import java.io.IOException; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.time.Duration; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; @@ -71,9 +74,12 @@ public class WebCacheTest { Files.createDirectories(existing.toPath().getParent()); Files.writeString(existing.toPath(), "previous", StandardCharsets.UTF_8); - File downloaded = WebCache.getNonCachedFile(name, url, 8L); + IOException failure = assertThrows( + IOException.class, + () -> WebCache.getNonCachedFile(name, url, 8L) + ); - assertNull(downloaded); + assertTrue(failure.getMessage().contains("size limit")); assertEquals("previous", Files.readString(existing.toPath(), StandardCharsets.UTF_8)); } finally { server.stop(0); @@ -91,9 +97,12 @@ public class WebCacheTest { Files.createDirectories(existing.toPath().getParent()); Files.writeString(existing.toPath(), "previous", StandardCharsets.UTF_8); - File downloaded = WebCache.getNonCachedFile(name, url, 8L); + IOException failure = assertThrows( + IOException.class, + () -> WebCache.getNonCachedFile(name, url, 8L) + ); - assertNull(downloaded); + assertTrue(failure.getMessage().contains("size limit")); assertEquals("previous", Files.readString(existing.toPath(), StandardCharsets.UTF_8)); } finally { server.stop(0); @@ -192,6 +201,127 @@ public class WebCacheTest { } } + @Test + public void stalledResponseTimesOutAndPreservesThePreviousCacheEntry() throws Exception { + byte[] body = "stalled-response".getBytes(StandardCharsets.UTF_8); + CountDownLatch prefixSent = new CountDownLatch(1); + CountDownLatch releaseResponse = new CountDownLatch(1); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/pack", exchange -> { + try { + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body, 0, 4); + exchange.getResponseBody().flush(); + prefixSent.countDown(); + releaseResponse.await(5L, TimeUnit.SECONDS); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + } finally { + exchange.close(); + } + }); + server.start(); + try { + String name = "stalled-pack"; + String url = url(server); + File existing = cachedFile(name, url); + Files.createDirectories(existing.toPath().getParent()); + Files.writeString(existing.toPath(), "previous", StandardCharsets.UTF_8); + WebCache.DownloadPolicy policy = new WebCache.DownloadPolicy( + Duration.ofMillis(100L), + 1, + Duration.ZERO + ); + + IOException failure = assertThrows( + IOException.class, + () -> WebCache.getNonCachedFile(name, url, body.length, policy, ignored -> { + }) + ); + + assertTrue(prefixSent.await(1L, TimeUnit.SECONDS)); + assertTrue(failure.getMessage().contains("stalled")); + assertEquals("previous", Files.readString(existing.toPath(), StandardCharsets.UTF_8)); + assertNoIncompleteDownloads(existing.getParentFile()); + } finally { + releaseResponse.countDown(); + server.stop(0); + } + } + + @Test + public void transientServerFailuresRetryAndPublishTheCompleteResponse() throws Exception { + byte[] body = "retried-response".getBytes(StandardCharsets.UTF_8); + AtomicInteger requests = new AtomicInteger(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/pack", exchange -> { + if (requests.incrementAndGet() < 3) { + exchange.sendResponseHeaders(503, -1L); + exchange.close(); + return; + } + respond(exchange, body, true); + }); + server.start(); + try { + WebCache.DownloadPolicy policy = new WebCache.DownloadPolicy( + Duration.ofSeconds(1L), + 3, + Duration.ZERO + ); + + File downloaded = WebCache.getNonCachedFile( + "retried-pack", + url(server), + body.length, + policy, + ignored -> { + } + ); + + assertEquals(3, requests.get()); + assertEquals("retried-response", Files.readString(downloaded.toPath(), StandardCharsets.UTF_8)); + } finally { + server.stop(0); + } + } + + @Test + public void permanentClientFailureDoesNotRetry() throws Exception { + AtomicInteger requests = new AtomicInteger(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/pack", exchange -> { + requests.incrementAndGet(); + exchange.sendResponseHeaders(404, -1L); + exchange.close(); + }); + server.start(); + try { + WebCache.DownloadPolicy policy = new WebCache.DownloadPolicy( + Duration.ofSeconds(1L), + 3, + Duration.ZERO + ); + + IOException failure = assertThrows( + IOException.class, + () -> WebCache.getNonCachedFile( + "missing-pack", + url(server), + 64L, + policy, + ignored -> { + } + ) + ); + + assertTrue(failure.getMessage().contains("HTTP 404")); + assertEquals(1, requests.get()); + } finally { + server.stop(0); + } + } + private HttpServer server(byte[] body, boolean declareLength) throws IOException { HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); server.createContext("/pack", exchange -> respond(exchange, body, declareLength)); @@ -214,6 +344,11 @@ public class WebCacheTest { return IrisPlatforms.get().dataFile("cache", hash.substring(0, 2), hash.substring(3, 5), hash); } + private void assertNoIncompleteDownloads(File folder) { + File[] incomplete = folder.listFiles((File parent, String name) -> name.startsWith(".download-")); + assertTrue(incomplete == null || incomplete.length == 0); + } + private void assertProgressEndsAt(List progress, long transferredBytes, long contentLength) { long previousBytes = -1L; diff --git a/core/src/test/java/art/arcane/iris/util/common/plugin/VolmitSenderComponentDeliveryTest.java b/core/src/test/java/art/arcane/iris/util/common/plugin/VolmitSenderComponentDeliveryTest.java new file mode 100644 index 000000000..20f1a84fa --- /dev/null +++ b/core/src/test/java/art/arcane/iris/util/common/plugin/VolmitSenderComponentDeliveryTest.java @@ -0,0 +1,123 @@ +package art.arcane.iris.util.common.plugin; + +import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.util.common.format.C; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.MiniMessage; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.bukkit.command.CommandSender; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + +public class VolmitSenderComponentDeliveryTest { + private IrisSettings previousSettings; + + @Before + public void installSettings() { + previousSettings = IrisSettings.settings; + IrisSettings settings = new IrisSettings(); + settings.getGeneral().setSpinh(0); + settings.getGeneral().setSpins(0); + settings.getGeneral().setSpinb(0); + IrisSettings.settings = settings; + } + + @After + public void restoreSettings() { + IrisSettings.settings = previousSettings; + } + + @Test + public void sectionColorsReachTheRichSenderWithoutLiteralControlCodes() { + List messages = new ArrayList<>(); + VolmitSender sender = new VolmitSender(recordingSender(messages)); + + sender.sendMessage(C.RED + "Broken"); + + assertEquals(1, messages.size()); + assertFalse(messages.getFirst().contains("\u00a7")); + assertEquals("Broken", plain(messages.getFirst())); + } + + @Test + public void noMiniKeepsMiniMessageTagsLiteral() { + List messages = new ArrayList<>(); + VolmitSender sender = new VolmitSender(recordingSender(messages)); + + sender.sendMessage("literal"); + + assertEquals(1, messages.size()); + assertEquals("literal", plain(messages.getFirst())); + } + + @Test + public void nonPlayerFallbackReceivesPlainTextWithoutSectionSymbols() { + List messages = new ArrayList<>(); + VolmitSender sender = new VolmitSender(plainFallbackSender(messages)); + + sender.sendMessage(C.RED + "Broken"); + + assertEquals(1, messages.size()); + assertEquals("Broken", messages.getFirst()); + assertFalse(messages.getFirst().contains("\u00a7")); + } + + private static String plain(String miniMessage) { + Component component = MiniMessage.miniMessage().deserialize(miniMessage); + return PlainTextComponentSerializer.plainText().serialize(component); + } + + private static CommandSender recordingSender(List messages) { + return (CommandSender) Proxy.newProxyInstance( + VolmitSenderComponentDeliveryTest.class.getClassLoader(), + new Class[]{CommandSender.class}, + (proxy, method, arguments) -> { + if (method.getName().equals("sendRichMessage") && arguments != null && arguments.length > 0) { + messages.add(String.valueOf(arguments[0])); + return null; + } + if (method.getName().equals("sendMessage") && arguments != null && arguments.length > 0) { + messages.add(String.valueOf(arguments[0])); + return null; + } + return defaultValue(method.getReturnType()); + }); + } + + private static CommandSender plainFallbackSender(List messages) { + return (CommandSender) Proxy.newProxyInstance( + VolmitSenderComponentDeliveryTest.class.getClassLoader(), + new Class[]{CommandSender.class}, + (proxy, method, arguments) -> { + if (method.getName().equals("sendRichMessage")) { + throw new UnsupportedOperationException("rich delivery unavailable"); + } + if (method.getName().equals("sendMessage") && arguments != null && arguments.length > 0) { + messages.add(String.valueOf(arguments[0])); + return null; + } + return defaultValue(method.getReturnType()); + }); + } + + private static Object defaultValue(Class returnType) { + if (returnType == boolean.class) { + return Boolean.FALSE; + } + if (returnType == int.class) { + return Integer.valueOf(0); + } + if (returnType == long.class) { + return Long.valueOf(0L); + } + return null; + } +} diff --git a/core/src/test/java/art/arcane/iris/util/project/noise/ImageNoiseTest.java b/core/src/test/java/art/arcane/iris/util/project/noise/ImageNoiseTest.java new file mode 100644 index 000000000..aaf67ff31 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/util/project/noise/ImageNoiseTest.java @@ -0,0 +1,86 @@ +package art.arcane.iris.util.project.noise; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.image.IrisImageMapValidationException; +import art.arcane.iris.engine.object.IrisImageMap; +import art.arcane.iris.engine.object.IrisImageMapOutOfBounds; +import art.arcane.iris.engine.object.IrisImageMapType; +import com.google.gson.Gson; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +public class ImageNoiseTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void samplesScalarImageMapsWithoutRetainingDecodedSources() throws Exception { + File pack = temporaryFolder.newFolder("scalar-pack"); + BufferedImage image = new BufferedImage(2, 1, BufferedImage.TYPE_BYTE_GRAY); + image.getRaster().setSample(1, 0, 0, 255); + writeImage(pack, "height", image); + writeMap(pack, "height", new IrisImageMap() + .setSource("height") + .setType(IrisImageMapType.GRAYSCALE_HEIGHT) + .setOutOfBounds(IrisImageMapOutOfBounds.CLAMP)); + + IrisData data = IrisData.openDatapackCompiler(pack); + try { + ImageNoise noise = new ImageNoise(data, "height"); + + assertEquals(0D, noise.noise(0D, 0D), 0D); + assertEquals(1D, noise.noise(1D, 0D), 0D); + assertEquals(0L, data.getImageLoader().getSize()); + } finally { + data.close(); + } + } + + @Test + public void rejectsColorMapsBeforeLoadingTheirSource() throws Exception { + File pack = temporaryFolder.newFolder("color-pack"); + writeMap(pack, "colors", new IrisImageMap() + .setSource("missing") + .setType(IrisImageMapType.COLOR_MAP)); + + IrisData data = IrisData.openDatapackCompiler(pack); + try { + IrisImageMapValidationException failure = assertThrows( + IrisImageMapValidationException.class, + () -> new ImageNoise(data, "colors") + ); + + assertEquals("Generator-style image-map resource 'colors' must produce normalized scalar data", + failure.getMessage()); + assertEquals(0L, data.getImageLoader().getSize()); + } finally { + data.close(); + } + } + + private void writeImage(File pack, String key, BufferedImage image) throws Exception { + File folder = new File(pack, "images"); + Files.createDirectories(folder.toPath()); + ImageIO.write(image, "png", new File(folder, key + ".png")); + } + + private void writeMap(File pack, String key, IrisImageMap definition) throws Exception { + File folder = new File(pack, "image-maps"); + Files.createDirectories(folder.toPath()); + Files.writeString( + new File(folder, key + ".json").toPath(), + new Gson().toJson(definition), + StandardCharsets.UTF_8 + ); + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5196c979c..8ccf6dbff 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,7 +15,6 @@ spigot = "26.2-R0.1-SNAPSHOT" # https://hub.spigotmc.org/nexus/repository/snapsh paper-api = "26.1.2.build.74-stable" log4j = "2.26.1" # https://central.sonatype.com/artifact/org.apache.logging.log4j/log4j-api adventure-api = "4.26.1" # https://github.com/KyoriPowered/adventure -adventure-platform = "4.4.1" # https://github.com/KyoriPowered/adventure-platform annotations = "26.1.0" # https://central.sonatype.com/artifact/org.jetbrains/annotations paralithic = "0.8.1" # https://github.com/PolyhedralDev/Paralithic/ @@ -75,7 +74,8 @@ annotations = { module = "org.jetbrains:annotations", version.ref = "annotations # Dynamically Loaded adventure-api = { module = "net.kyori:adventure-api", version.ref = "adventure-api" } adventure-minimessage = { module = "net.kyori:adventure-text-minimessage", version.ref = "adventure-api" } -adventure-platform = { module = "net.kyori:adventure-platform-bukkit", version.ref = "adventure-platform" } +adventure-serializer-legacy = { module = "net.kyori:adventure-text-serializer-legacy", version.ref = "adventure-api" } +adventure-serializer-plain = { module = "net.kyori:adventure-text-serializer-plain", version.ref = "adventure-api" } paralithic = { module = "com.dfsek:paralithic", version.ref = "paralithic" } paperlib = { module = "io.papermc:paperlib", version.ref = "paperlib" }