Imagemap sanitization

This commit is contained in:
Brian Neumann-Fopiano
2026-08-24 13:44:44 -04:00
parent e20bb86f40
commit 37b01b73c4
112 changed files with 11127 additions and 670 deletions
+4 -2
View File
@@ -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)
File diff suppressed because it is too large Load Diff
@@ -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<String, String> legend(List<LegendRow> rows) {
KMap<String, String> 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<LegendRow> legendRows(IrisImageMap definition) {
List<LegendRow> rows = new ArrayList<>();
if (definition.getColors() == null) {
return rows;
}
for (Map.Entry<String, String> 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<IrisImageMapMask> masks(List<MaskRow> rows) {
KList<IrisImageMapMask> 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<MaskRow> maskRows(List<IrisImageMapMask> masks) {
List<MaskRow> 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<String> warnings(
IrisImageMap definition,
int sourceWidth,
int sourceHeight,
IrisWorldBoundary boundary
) {
List<String> 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
) {
}
}
@@ -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<String> 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<RenderResult, Void> worker;
private Consumer<String> diagnosticConsumer = ignored -> {
};
private Consumer<String> 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<String> diagnosticConsumer) {
this.diagnosticConsumer = diagnosticConsumer == null ? ignored -> {
} : diagnosticConsumer;
}
private void setHoverConsumer(Consumer<String> 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) {
}
}
@@ -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);
}
}
}
@@ -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<IrisImage> {
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<IrisImage> {
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<ImageReader> 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<IrisImage> {
}
}
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<String> m, HashSet<String> visitedDirectories) {
if (directory == null || !directory.exists()) {
return;
@@ -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<IrisObject> objectLoader;
private ResourceLoader<IrisMatterObject> matterLoader;
private ResourceLoader<IrisImage> imageLoader;
private ResourceLoader<IrisImageMap> imageMapLoader;
private ResourceLoader<IrisStructure> structureLoader;
private ResourceLoader<IrisJigsawPool> jigsawPoolLoader;
private ResourceLoader<IrisJigsawPiece> 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<Class<? extends IrisRegistrant>, ResourceLoader<? extends IrisRegistrant>> 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);
}
}
@@ -720,6 +720,12 @@ public class ResourceLoader<T extends IrisRegistrant> 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());
}
@@ -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,
@@ -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<MessageKey> 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() {
@@ -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,
@@ -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,
@@ -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<String> 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());
}
}
@@ -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<String> 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<String> 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<String> 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;
@@ -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(
@@ -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<String> blockingErrors = new LinkedHashSet<>();
Set<String> 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<String, MapResource> 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<Path> paths = Files.walk(imageMapsFolder.toPath())) {
if (paths.anyMatch(path -> Files.isRegularFile(path)
&& path.getFileName().toString().endsWith(".json"))) {
return true;
}
}
}
try (Stream<Path> paths = Files.walk(packFolder.toPath())) {
List<Path> 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<String, JsonElement> entry : object.entrySet()) {
if (containsImageMapKey(entry.getValue())) {
return true;
}
}
return false;
}
private static Map<String, MapResource> compileResources(IrisData data, Set<String> blockingErrors) {
String[] possibleKeys = data.getImageMapLoader().getPossibleKeys();
Arrays.sort(possibleKeys);
Set<String> imageKeys = new LinkedHashSet<>(Arrays.asList(data.getImageLoader().getPossibleKeys()));
Map<String, MapResource> 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<String, MapResource> resources,
PlatformRegistries registries,
Set<String> blockingErrors,
Set<String> warnings
) {
File[] sortedFiles = Arrays.copyOf(dimensionFiles, dimensionFiles.length);
Arrays.sort(sortedFiles, (File first, File second) -> first.getName().compareTo(second.getName()));
Map<String, IrisDimension> dimensions = new LinkedHashMap<>();
Set<String> 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<String, List<Map.Entry<String, IrisDimension>>> upperParents = new TreeMap<>();
for (Map.Entry<String, IrisDimension> 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<String, IrisDimension> entry : dimensions.entrySet()) {
if (!bindingReadyDimensions.contains(entry.getKey())) {
continue;
}
List<Map.Entry<String, IrisDimension>> 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<String, IrisDimension> 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<String, MapResource> resources,
PlatformRegistries registries,
Set<String> blockingErrors,
Set<String> warnings
) {
Map<String, IrisImageMapBinding> bindings = new LinkedHashMap<>();
Map<IrisImageMapApplication, String> applications = new EnumMap<>(IrisImageMapApplication.class);
List<IrisImageMapBinding> declared = dimension.getImageMaps();
Set<String> 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<String> regionKeys,
Map<String, MapResource> resources,
PlatformRegistries registries,
File packFolder,
Set<String> blockingErrors,
Set<String> 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<String, IrisImageMapBinding> bindings,
Set<String> 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<String> regionKeys,
IrisData data,
IrisImageMapApplication application,
IrisImageMap definition,
PlatformRegistries registries,
Set<String> blockingErrors
) {
if (definition.getType() != IrisImageMapType.COLOR_MAP) {
return;
}
Set<String> 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<String> regionKeys,
String biomeKey,
Set<String> blockingErrors
) {
Set<String> 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<String> 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<String> allRegionKeys(
IrisDimension dimension,
Map<String, MapResource> resources
) {
Set<String> 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<String, MapResource> resources,
Set<String> blockingErrors
) {
Map<String, Object> 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<String> 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<String> 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<String> visitedExpressions = new TreeSet<>();
Set<String> availableExpressionKeys = new TreeSet<>(Arrays.asList(
data.getExpressionLoader().getPossibleKeys()));
for (Map.Entry<String, Object> 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<String, MapResource> resources,
Set<String> availableExpressionKeys,
Set<String> visitedExpressions,
Set<String> 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<String> 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<String, MapResource> resources,
Set<String> 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<String> 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<String> blockingErrors,
Set<String> 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<String> legendTargets(IrisImageMap definition) {
Set<String> 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<String> 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<String> errors, List<String> warnings) {
}
private record MapResource(IrisImageMap definition, CompiledIrisImageMap compiled) {
}
}
@@ -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 ? "<unknown>" : packFolder.getName();
List<String> 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());
@@ -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<IrisImageMapMask> 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<IrisImageMapMask> 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<CompiledIrisImageMap> 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<IrisImageMapMask> masks
) {
IrisImageMapBinding existing = null;
for (IrisImageMapBinding candidate : dimension.getImageMaps()) {
if (candidate != null && bindingKey.equals(candidate.getKey())) {
existing = candidate;
break;
}
}
KList<IrisImageMapMask> 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<ImageReader> 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<String> 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<IrisImageMapMask> masks
) {
public ExportRequest {
masks = masks == null ? List.of() : List.copyOf(masks);
}
}
public record ExportResult(
Path imageFile,
Path imageMapFile,
Path dimensionFile,
String contentHash,
List<String> 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<StagedFile> 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) {
}
}
@@ -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));
@@ -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<JigsawStudioMenuState> 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<JigsawStudioCellDimensions> 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<JigsawStudioCellDimensions> 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<Integer> 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<JigsawStudioPieceRules> 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) {
@@ -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));
}
}
@@ -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++) {
@@ -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) {
@@ -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<IrisInterpolator, IdentityHashMap<IrisBiome, GeneratorBounds>> generatorBounds;
private Set<IrisBiome> 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<IrisRegion> 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<IrisBiome> 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<Boolean> 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)))
);
}
@@ -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<IrisBiome> biomeStream;
private final ProceduralStream<IrisRegion> regionStream;
private final ProceduralStream<PlatformBlockState> rockStream;
private final IrisImageMapRuntime imageMapRuntime;
private final boolean selfReferencing;
private UpperDimensionContext(IrisDimension dimension, IrisData data, int chunkHeight,
ProceduralStream<Double> heightStream,
ProceduralStream<IrisBiome> biomeStream,
ProceduralStream<IrisRegion> regionStream,
ProceduralStream<PlatformBlockState> 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<IrisInterpolator, Set<IrisGenerator>> 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<IrisInterpolator, IdentityHashMap<IrisBiome, NoiseBounds>> generatorBounds = new HashMap<>();
for (Map.Entry<IrisInterpolator, Set<IrisGenerator>> entry : generators.entrySet()) {
@@ -128,8 +134,11 @@ public class UpperDimensionContext implements DataProvider {
ProceduralStream<Double> regionStyleStream = upperDim.getRegionStyle()
.create(rng.nextParallelRNG(883), upperData).stream()
.zoom(upperDim.getRegionZoom());
ProceduralStream<IrisRegion> regionStream = regionStyleStream
ProceduralStream<IrisRegion> proceduralRegionStream = regionStyleStream
.selectRarity(upperData.getRegionLoader().loadAll(upperDim.getRegions()));
ProceduralStream<IrisRegion> regionStream = proceduralRegionStream
.convertAware2D((region, x, z) -> mappedRegion(imageMapRuntime, region, x, z))
.cache2D("upperImageMappedRegionStream", engine, cacheSize);
ProceduralStream<IrisBiome> 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<IrisBiome> baseBiomeStream = bridgeStream
ProceduralStream<IrisBiome> proceduralBaseBiomeStream = bridgeStream
.convertAware2D((t, x, z) -> {
ProceduralStream<IrisBiome> 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<IrisBiome> baseBiomeStream = proceduralBaseBiomeStream
.convertAware2D((biome, x, z) -> mappedBiome(imageMapRuntime, biome, x, z))
.cache2D("upperImageMappedBaseBiomeStream", engine, cacheSize);
KList<IrisShapedGeneratorStyle> overlayNoise = upperDim.getOverlayNoise();
ProceduralStream<Double> overlayStream = overlayNoise.isEmpty()
@@ -189,7 +201,7 @@ public class UpperDimensionContext implements DataProvider {
ProceduralStream<Double> 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<IrisInterpolator, Set<IrisGenerator>> 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<IrisBiome> 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<PlatformBlockState> 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<IrisBiome> allBiomes,
Map<IrisInterpolator, Set<IrisGenerator>> 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<IrisBiome> loadInferredBiomes(IrisData data, KList<String> 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<Double> heightStream,
ProceduralStream<IrisBiome> biomeStream,
ProceduralStream<IrisRegion> regionStream,
ProceduralStream<PlatformBlockState> rockStream,
IrisImageMapRuntime imageMapRuntime,
boolean selfReferencing
) {
}
}
@@ -120,6 +120,7 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator<PlatformBl
int topY = Math.min(hf, chunkHeight - 1);
PlatformBlockState fluid = fluidCache.get(xf, zf);
PlatformBlockState rock = rockCache.get(xf, zf);
PlatformBlockState mappedSurfaceBlock = complex.getImageMapRuntime().sampleSurfaceBlock(realX, realZ);
KList<IrisOreGenerator> biomeSurfaceOres = hideOres ? null : biome.getSurfaceOreGenerators();
KList<IrisOreGenerator> regionSurfaceOres = hideOres ? null : region.getSurfaceOreGenerators();
KList<IrisOreGenerator> biomeUndergroundOres = hideOres ? null : biome.getUndergroundOreGenerators();
@@ -173,6 +174,10 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator<PlatformBl
if (i <= he) {
int depth = he - i;
if (depth == 0 && mappedSurfaceBlock != null) {
h.setRaw(xf, i, zf, mappedSurfaceBlock);
continue;
}
if (blocks == null) {
blocks = biome.generateLayers(dimension, realX, realZ, localRng, he, he, data, complex);
}
@@ -210,6 +215,7 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator<PlatformBl
if (upperSurfaceY < chunkHeight - 1) {
IrisBiome upperBiome = upperContext.getUpperBiome(realX, realZ);
PlatformBlockState upperRock = upperContext.getRockBlock(realX, realZ);
PlatformBlockState upperMappedSurface = upperContext.getSurfaceBlock(realX, realZ);
int upperThickness = chunkHeight - 1 - upperSurfaceY;
KList<PlatformBlockState> upperBlocks = upperBiome != null
? upperBiome.generateLayers(upperContext.getDimension(),
@@ -222,6 +228,10 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator<PlatformBl
h.setRaw(xf, y, zf, BEDROCK);
continue;
}
if (y == upperSurfaceY && upperMappedSurface != null) {
h.setRaw(xf, y, zf, upperMappedSurface);
continue;
}
int depthFromFace = y - upperSurfaceY;
if (upperBlocks != null && upperBlocks.hasIndex(depthFromFace)) {
h.setRaw(xf, y, zf, upperBlocks.get(depthFromFace));
@@ -45,6 +45,9 @@ public interface EnginePlatformHooks {
default void validateDimensionHotload(Engine engine, IrisDimension replacement) {
}
default void applyWorldBoundary(Engine engine) {
}
default boolean isPregeneratorActive(Engine engine) {
return false;
}
@@ -0,0 +1,424 @@
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.IrisImageMapOutOfBounds;
import art.arcane.iris.engine.object.IrisImageMapSampling;
import art.arcane.iris.engine.object.IrisImageMapType;
import java.util.Objects;
public final class CompiledIrisImageMap {
private final State state;
CompiledIrisImageMap(State state) {
this.state = Objects.requireNonNull(state, "Compiled image-map state");
}
public static CompiledIrisImageMap compile(IrisImageMap definition, IrisImage image) {
return IrisImageMapCompiler.compile(definition, image);
}
public IrisImageMap getDefinition() {
return state.definition();
}
public IrisImageMapSourceMetadata getSourceMetadata() {
return state.sourceMetadata();
}
public IrisImageMapType getType() {
return state.type();
}
public int getSourceWidth() {
return state.sourceMetadata().width();
}
public int getSourceHeight() {
return state.sourceMetadata().height();
}
public String getContentHash() {
return state.contentHash();
}
public String contentHash() {
return state.contentHash();
}
public CompiledIrisImageMap withoutDecodedValues() {
return state.scalarValues() == null && state.targetIndices() == null
? this
: new CompiledIrisImageMap(state.withoutDecodedValues());
}
public int getClippedPixelCount() {
return state.clippedPixelCount();
}
public int getUnknownColorPixelCount() {
return state.unknownColorPixelCount();
}
public boolean containsWorld(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);
return validSourceCoordinate(sourceX, sourceZ)
&& sourceX >= 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;
}
}
}
@@ -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<String> 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<String> validate(IrisImageMap definition, IrisImage image) {
List<String> 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<String> 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<String> 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<String> 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<String> 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<String> diagnostics) {
KMap<String, String> 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<Integer, String> decoded = new HashMap<>();
for (Map.Entry<String, String> 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<MutableLegendEntry> entries = new ArrayList<>();
for (Map.Entry<String, String> 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<String, Integer> targetIndexes = new LinkedHashMap<>();
Map<Integer, LegendEntry> exact = new HashMap<>();
List<LegendEntry> 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<String, Integer> 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<String> 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<String, String> configuredColors = definition.getColors();
List<Map.Entry<String, String>> 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<String, String> 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<String, String> configuredColors = definition.getColors();
KMap<String, String> 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<LegendEntry> entries,
Map<Integer, LegendEntry> 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());
}
}
}
@@ -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<Layer> layers;
public IrisImageMapMaskSampler(List<Layer> 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<CompiledIrisImageMap> compiled,
List<IrisImageMapMask> definitions
) {
if (compiled.size() != definitions.size()) {
throw new IllegalArgumentException("Compiled image-map masks and definitions must have equal sizes");
}
List<Layer> 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);
}
}
}
@@ -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<String, RuntimeBinding> bindings;
private final Map<IrisImageMapApplication, RuntimeBinding> applications;
private final Set<IrisRegion> mappedRegions;
private final Set<IrisBiome> mappedBiomes;
private IrisImageMapRuntime(
int minimumWorldHeight,
Map<String, RuntimeBinding> bindings,
Map<IrisImageMapApplication, RuntimeBinding> applications,
Set<IrisRegion> mappedRegions,
Set<IrisBiome> 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<String, RuntimeBinding> bindings = new LinkedHashMap<>();
Map<IrisImageMapApplication, RuntimeBinding> applications = new EnumMap<>(IrisImageMapApplication.class);
Set<IrisRegion> mappedRegions = new LinkedHashSet<>();
Set<IrisBiome> 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<IrisRegion> getMappedRegions() {
return mappedRegions;
}
public Set<IrisBiome> 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<IrisRegion> mappedRegions,
Set<IrisBiome> 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<String, IrisRegion> regions = new LinkedHashMap<>();
Map<String, IrisBiome> biomes = new LinkedHashMap<>();
Map<String, PlatformBlockState> blocks = new LinkedHashMap<>();
Set<String> 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<IrisImageMapMask> 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<String, RuntimeBinding> bindings
) {
if (binding.maskDefinitions.isEmpty()) {
return IrisImageMapMaskSampler.empty();
}
List<IrisImageMapMaskSampler.Layer> 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<String> legendTargets(IrisImageMap definition) {
Set<String> 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<InferredType> 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<String, IrisRegion> regions;
private final Map<String, IrisBiome> biomes;
private final Map<String, PlatformBlockState> blocks;
private final List<IrisImageMapMask> maskDefinitions;
private IrisImageMapMaskSampler masks = IrisImageMapMaskSampler.empty();
private RuntimeBinding(
String key,
IrisImageMapApplication application,
CompiledIrisImageMap compiled,
Map<String, IrisRegion> regions,
Map<String, IrisBiome> biomes,
Map<String, PlatformBlockState> blocks,
List<IrisImageMapMask> 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);
}
}
}
@@ -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
) {
}
@@ -0,0 +1,35 @@
package art.arcane.iris.engine.image;
import java.util.List;
public final class IrisImageMapValidationException extends IllegalArgumentException {
private final List<String> diagnostics;
public IrisImageMapValidationException(String diagnostic) {
this(List.of(diagnostic));
}
public IrisImageMapValidationException(List<String> 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<String> getDiagnostics() {
return diagnostics;
}
private static String message(List<String> diagnostics) {
if (diagnostics == null || diagnostics.isEmpty()) {
return "Image-map validation failed";
}
return String.join("; ", diagnostics);
}
}
@@ -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<IrisImageMapBinding> 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<IrisRegion> getAllRegions(DataProvider g) {
KList<IrisRegion> r = new KList<>();
KMap<String, IrisRegion> 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<IrisRegion> getAllAnyRegions() {
@@ -496,15 +520,23 @@ public class IrisDimension extends IrisRegistrant {
if (riversEnabled && riverNetwork.getBiomes() != null) {
addReachableBiomeKeys(pending, riverNetwork.getBiomes().getAllBiomeIds());
}
KList<String> 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<String> imageMapTargets(IrisImageMap map) {
Set<String> 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<String> pending, Iterable<String> biomeKeys) {
if (biomeKeys == null) {
return;
@@ -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) {
@@ -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
@@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
@@ -0,0 +1,9 @@
package art.arcane.iris.engine.object;
public enum IrisImageColorMode {
GRAYSCALE,
RGB,
RGBA,
INDEXED,
UNSUPPORTED
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<IrisImage> imageCache = new AtomicCache<IrisImage>();
@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<String, String> 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";
}
}
@@ -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
}
@@ -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
}
@@ -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<IrisImageMapMask> masks = new KList<>();
}
@@ -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;
}
@@ -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
}
@@ -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;
}
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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);
@@ -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()))));
}
});
}
@@ -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<VolmitSender> 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;
@@ -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);
}
}
}
@@ -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<InputStream> 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;
}
}
}
@@ -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("<NOMINI>")) {
s.sendMessage(C.translateAlternateColorCodes('&', getTag() + message.replaceAll("\\Q<NOMINI>\\E", "")));
ComponentMessenger.sendSection(
s,
C.translateAlternateColorCodes('&', getTag() + message.replace("<NOMINI>", "")));
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("<NOMINI>")) {
s.sendMessage(message.replaceAll("\\Q<NOMINI>\\E", ""));
ComponentMessenger.sendSection(s, message.replace("<NOMINI>", ""));
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
@@ -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
+70 -2
View File
@@ -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."
}
}
+70 -2
View File
@@ -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."
}
}
+70 -2
View File
@@ -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."
}
}
+70 -2
View File
@@ -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 darrêt et naccepte 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."
}
}
+70 -2
View File
@@ -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."
}
}
+70 -2
View File
@@ -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."
}
}
+70 -2
View File
@@ -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."
}
}
+70 -2
View File
@@ -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."
}
}
+70 -2
View File
@@ -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."
}
}
+70 -2
View File
@@ -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."
}
}
+70 -2
View File
@@ -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."
}
}
+70 -2
View File
@@ -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."
}
}
+70 -2
View File
@@ -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."
}
}
+70 -2
View File
@@ -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."
}
}
+70 -2
View File
@@ -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."
}
}
+70 -2
View File
@@ -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."
}
}
+70 -2
View File
@@ -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."
}
}
@@ -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");
@@ -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<IrisImageMap> 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();
}
}
@@ -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<String, String> 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<String> 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<String> 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<ImageMapStudioModel.MaskRow> rows = List.of(
ImageMapStudioModel.maskRow("land", "multiply", false, "0", "1"),
ImageMapStudioModel.maskRow("roads", IrisImageMapMaskOperation.SUBTRACT, true, 0.5D, 0.25D)
);
List<IrisImageMapMask> 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));
}
}
@@ -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)));
}
}
@@ -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));
}
}
@@ -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");
@@ -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)"));
}
}
@@ -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<String> 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");
}
}
@@ -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<String> absentErrors = validate("{}");
List<String> 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<String> 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<String> validate(String json) {
List<String> errors = new ArrayList<>();
PackDimensionValidator.validateWorldBoundary("main", new JSONObject(json), errors);
return errors;
}
}
@@ -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();
}
}
@@ -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<Class<? extends IrisRegistrant>, ResourceLoader<? extends IrisRegistrant>> loaders = new KMap<>();
when(data.getLoaders()).thenReturn(loaders);
when(data.getPossibleSnippets(anyString())).thenReturn(new KList<>());
return data;
}
}
@@ -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());
}
}
@@ -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));
}
}
@@ -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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> firstColors = new KMap<>();
firstColors.put("#FF0000", "red");
firstColors.put("#0000FF", "blue");
KMap<String, String> 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<String, String> 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;
}
}
}
@@ -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"));
}
}
@@ -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) {
}
}
@@ -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());
}
}
@@ -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",
@@ -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<WebCache.TransferProgress> progress, long transferredBytes,
long contentLength) {
long previousBytes = -1L;
@@ -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<String> 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<String> messages = new ArrayList<>();
VolmitSender sender = new VolmitSender(recordingSender(messages));
sender.sendMessage("<NOMINI><red>literal");
assertEquals(1, messages.size());
assertEquals("<red>literal", plain(messages.getFirst()));
}
@Test
public void nonPlayerFallbackReceivesPlainTextWithoutSectionSymbols() {
List<String> 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<String> 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<String> 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;
}
}
@@ -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
);
}
}