Merge master into 3D terrain

- Resolve IrisTerrainNormalActuator by keeping master's primitive riverWaterSurfaceStream lookup and primarySurfaceLayerOrNull accessors while retaining the terrain column, span, layer surface and ceiling locals that write air through terrain3D gaps
- Resolve HydrologyFootprintCompiler by keeping the MaterializedSurface planned-surface record with terrain ownership beside master's package-private SurfaceRasterIndex, which retains its ownsTerrain override
- Resolve HydrologyCaveContainmentPlanner by dropping the nested RecordingCaveVoxelView, SurfaceObservations and ViewObservations types that master promoted to their own files
- Port the surface-observation ownership check into CaveSurfaceObservations so cached cave validations replay both height and ownership observations through Observation.matches
- Resolve PackExportClosureTest by keeping master's retirement of the source-text packager guard while retaining the snippet export tests, and restore the Files and Path imports those tests use
- Resolve IrisBiomeLayerSlopeSourceTest by folding the lower-ledge slope palette case into master's lockLayers parameterization
- Recapture the studio schema digest for the terrain3D biome property, its IrisTerrain3D definition, the terrain-3d snippet enumeration and the reworded upperDimensionCarving description, verified by reconstructing the previous digest from the stripped schema
This commit is contained in:
Brian Neumann-Fopiano committed 2026-09-08 13:58:00 -04:00
commit 98e51cba8d
520 files changed
+45623 -40734

No files matched your search

+6 -11
View File
@@ -29,6 +29,7 @@ import java.nio.charset.StandardCharsets
plugins {
id 'java'
id 'java-library'
id 'java-test-fixtures'
alias(libs.plugins.shadow)
alias(libs.plugins.slimjar)
}
@@ -130,6 +131,10 @@ dependencies {
slim(libs.dom4j)
slim(libs.jaxen)
testFixturesImplementation('junit:junit:4.13.2')
testFixturesImplementation('org.mockito:mockito-core:5.23.0')
testFixturesCompileOnly(libs.paper.api)
testImplementation('junit:junit:4.13.2')
testImplementation('org.mockito:mockito-core:5.23.0')
testImplementation(libs.paper.api)
@@ -232,7 +237,7 @@ tasks.named('compileJava', JavaCompile).configure {
tasks.named('test', Test).configure {
jvmArgs('--add-modules', 'jdk.incubator.vector')
dependsOn(languageSourceManifestTask)
classpath = files(layout.buildDirectory.dir('generated/language-source')) + files('src/main/resources') + classpath
classpath = files(layout.buildDirectory.dir('generated/language-source')) + files('src/main/resources') + sourceSets.main.output + classpath
}
configurations.matching { it.name.startsWith('slim') }.all { }
@@ -264,8 +269,6 @@ List<String> supersededVolmLibPackages = [
'art/arcane/volmlib/util/reflect/**',
'art/arcane/volmlib/util/documentation/**',
'art/arcane/iris/core/report/**',
'art/arcane/iris/util/common/inventorygui/**',
'art/arcane/iris/util/common/board/**',
'art/arcane/volmlib/integration/VaultEconomy*.class'
]
@@ -318,8 +321,6 @@ List<String> unusedVolmLibClasses = [
'art/arcane/volmlib/util/director/DirectorNodeBase',
'art/arcane/volmlib/util/director/DirectorParameterBase',
'art/arcane/volmlib/util/director/theme/DirectorThemes',
'art/arcane/volmlib/util/interpolation/InterpolationMethod',
'art/arcane/volmlib/util/interpolation/IrisInterpolation',
'art/arcane/volmlib/util/math/INode',
'art/arcane/volmlib/util/math/IrisMathHelper',
'art/arcane/volmlib/util/math/KochanekBartelsInterpolation',
@@ -329,11 +330,6 @@ List<String> unusedVolmLibClasses = [
'art/arcane/volmlib/util/board/BoardUpdateTask',
'art/arcane/volmlib/util/bukkit/Placeholders',
'art/arcane/volmlib/util/hunk/HunkFactory',
'art/arcane/volmlib/util/hunk/storage/ArrayHunk',
'art/arcane/volmlib/util/hunk/storage/AtomicDoubleHunk',
'art/arcane/volmlib/util/hunk/storage/AtomicIntegerHunk',
'art/arcane/volmlib/util/hunk/storage/AtomicLongHunk',
'art/arcane/volmlib/util/hunk/storage/SynchronizedArrayHunk',
'art/arcane/volmlib/util/hunk/view/BiomeForceBridge',
'art/arcane/volmlib/util/hunk/view/BiomeGridForceSupport',
'art/arcane/volmlib/util/hunk/view/BiomeGridHunkHolder',
@@ -393,7 +389,6 @@ List<String> unusedVolmLibClasses = [
'art/arcane/iris/engine/object/IPostBlockAccess',
'art/arcane/iris/engine/object/IrisPotionEffect',
'art/arcane/iris/engine/platform/DummyChunkGenerator',
'art/arcane/iris/util/common/math/VectorMath',
'art/arcane/iris/util/common/parallel/GridLock',
'art/arcane/iris/util/common/reflect/W',
'art/arcane/iris/util/slimjar/BuildConstants',
+26 -3
View File
@@ -62,11 +62,37 @@ art/arcane/iris/core/service/BoardSVC.java
art/arcane/iris/core/service/EntityRiseSVC.java
art/arcane/iris/core/service/ExternalDataSVC.java
art/arcane/iris/core/service/GlobalCacheSVC.java
art/arcane/iris/core/service/JigsawStudioAutosaveScheduler.java
art/arcane/iris/core/service/JigsawStudioCapture.java
art/arcane/iris/core/service/JigsawStudioCaptureScheduler.java
art/arcane/iris/core/service/JigsawStudioChunkWriter.java
art/arcane/iris/core/service/JigsawStudioDetailsMenu.java
art/arcane/iris/core/service/JigsawStudioDirtyTracker.java
art/arcane/iris/core/service/JigsawStudioGraphMutations.java
art/arcane/iris/core/service/JigsawStudioMainMenu.java
art/arcane/iris/core/service/JigsawStudioMarkerParser.java
art/arcane/iris/core/service/JigsawStudioMaterializer.java
art/arcane/iris/core/service/JigsawStudioMenuActions.java
art/arcane/iris/core/service/JigsawStudioMenuController.java
art/arcane/iris/core/service/JigsawStudioMenuFormat.java
art/arcane/iris/core/service/JigsawStudioPlayerContext.java
art/arcane/iris/core/service/JigsawStudioPreviewRenderer.java
art/arcane/iris/core/service/JigsawStudioProtection.java
art/arcane/iris/core/service/JigsawStudioProtectionListener.java
art/arcane/iris/core/service/JigsawStudioRegistry.java
art/arcane/iris/core/service/JigsawStudioSaveLifecycle.java
art/arcane/iris/core/service/JigsawStudioService.java
art/arcane/iris/core/service/JigsawStudioStructureMenu.java
art/arcane/iris/core/service/JigsawStudioTileWatcher.java
art/arcane/iris/core/service/JigsawStudioToolCodec.java
art/arcane/iris/core/service/JigsawStudioToolbelt.java
art/arcane/iris/core/service/JigsawStudioToolboxMenu.java
art/arcane/iris/core/service/JigsawStudioVariantEditor.java
art/arcane/iris/core/service/JigsawStudioVariantMenu.java
art/arcane/iris/core/service/JigsawStudioVariantProperties.java
art/arcane/iris/core/service/JigsawStudioVisualization.java
art/arcane/iris/core/service/JigsawStudioWorkcellEditor.java
art/arcane/iris/core/service/JigsawStudioWorkcellMenu.java
art/arcane/iris/core/service/MultiverseSVC.java
art/arcane/iris/core/service/ObjectSVC.java
art/arcane/iris/core/service/ObjectStudioSaveService.java
@@ -162,9 +188,6 @@ art/arcane/iris/util/common/director/handlers/VectorHandler.java
art/arcane/iris/util/common/director/handlers/WorldHandler.java
art/arcane/iris/util/common/director/specialhandlers/NullablePlayerHandler.java
art/arcane/iris/util/common/format/C.java
art/arcane/iris/util/common/inventorygui/UIStaticDecorator.java
art/arcane/iris/util/common/inventorygui/Window.java
art/arcane/iris/util/common/inventorygui/WindowResolution.java
art/arcane/iris/util/common/math/AxisAlignedBB.java
art/arcane/iris/util/common/math/Direction.java
art/arcane/iris/util/common/math/VectorMath.java
@@ -107,6 +107,8 @@ public class IrisSettings {
try {
IO.writeAll(s, new JSONObject(new Gson().toJson(loaded)).toString(4));
} catch (IOException e) {
IrisLogging.reportError("Could not rewrite " + s.getAbsolutePath()
+ "; settings changes made in game will not survive a restart.", e);
}
} catch (Throwable ee) {
// IrisLogging.reportError(ee); causes a self-reference & stackoverflow
@@ -306,6 +308,7 @@ public class IrisSettings {
public boolean debug = false;
public boolean dumpMantleOnError = false;
public boolean disableNMS = false;
public boolean eagerRuntimeInjection = false;
public boolean splashLogoStartup = true;
public boolean useConsoleCustomColors = true;
public boolean useCustomColorsIngame = true;
@@ -158,6 +158,16 @@ public class ServerConfigurator {
}
}
public static void resetLoadedDatapackRuntime() {
synchronized (DATAPACK_INSTALL_LOCK) {
loadedDatapackRuntimeReady = false;
loadedDatapackCompilerInputFingerprint = "";
loadedDatapackRegistryRequirements = Map.of();
loadedDatapackRuntimeGeneration++;
loadedDatapackRestartRequired = false;
}
}
public static LoadedDatapackRuntimeInvalidation invalidateLoadedDatapackRuntime() {
synchronized (DATAPACK_INSTALL_LOCK) {
boolean wasReady = loadedDatapackRuntimeReady;
@@ -0,0 +1,37 @@
/*
* 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.core.datapack;
final class CoordinatorDirectory {
String target;
String pending;
String backup;
boolean hadTarget;
String originalHash;
String desiredHash;
String originalMarkerHash;
String desiredMarkerHash;
String originalIdentity;
String desiredIdentity;
String targetRoot;
String scratchRoot;
String targetRootIdentity;
String scratchRootIdentity;
}
@@ -16,12 +16,11 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.util.project.interpolation;
package art.arcane.iris.core.datapack;
public enum InterpolationType {
LINEAR,
PARAMETRIC_2,
PARAMETRIC_4,
BEZIER,
NONE
}
final class CoordinatorEditable {
String packRoot;
String transactionId;
String claimId;
}
@@ -0,0 +1,37 @@
/*
* 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.core.datapack;
import java.util.ArrayList;
import java.util.List;
final class CoordinatorJournal {
int schemaVersion;
String transactionId;
CoordinatorOperation operation;
CoordinatorPhase phase;
String id;
String url;
String versionId;
String versionNumber;
String sha1;
boolean manifestAlreadyMatched;
List<CoordinatorDirectory> directories = new ArrayList<>();
List<CoordinatorEditable> editables = new ArrayList<>();
}
@@ -0,0 +1,25 @@
/*
* 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.core.datapack;
enum CoordinatorOperation {
INSTALL,
REMOVE
}
@@ -0,0 +1,31 @@
/*
* 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.core.datapack;
enum CoordinatorPhase {
PREPARED,
PUBLISHING,
PUBLISHED,
COMMITTED;
boolean published() {
return this == PUBLISHED || this == COMMITTED;
}
}
@@ -0,0 +1,415 @@
/*
* 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.core.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.core.datapack.ModrinthResolver.ResolvedDatapack;
import art.arcane.volmlib.util.io.IO;
import art.arcane.volmlib.util.io.ZipUtils;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.UncheckedIOException;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
final class DatapackArchive {
static final String USER_AGENT = "VolmitSoftware/Iris (datapack-ingest)";
static final int MAX_REDIRECTS = 5;
static final int MAX_ARCHIVE_ENTRIES = 100_000;
static final int MAX_CACHE_FILES = 32;
static final long MAX_DOWNLOAD_BYTES = 256L * 1024L * 1024L;
static final long MAX_EXPANDED_BYTES = 1024L * 1024L * 1024L;
static final long MAX_ENTRY_BYTES = 256L * 1024L * 1024L;
static final long MAX_CACHE_BYTES = 1024L * 1024L * 1024L;
static final Set<String> RESERVED_IDS = Set.of("iris");
private DatapackArchive() {
}
static File extractArchive(File zip, File stagingRoot, Entry entry) throws IOException {
DatapackSupport.ensureScratchDirectory(stagingRoot, "datapack staging");
File pending = new File(stagingRoot, ".pending-" + entry.id + "-" + UUID.randomUUID());
try {
if (!pending.mkdirs() && !pending.isDirectory()) {
throw new IOException("Couldn't create datapack extraction directory " + pending.getPath());
}
ZipUtils.unzipFile(zip, pending, MAX_ARCHIVE_ENTRIES, MAX_EXPANDED_BYTES, MAX_ENTRY_BYTES);
flattenIfWrapped(pending);
DatapackPackMetadata.validatePackMetadata(pending);
PackResources resources = DatapackOwnership.scanPackResources(pending);
entry.structureKeys = resources.structureKeys();
entry.templateKeys = resources.templateKeys();
DatapackOwnership.writeOwnership(pending, entry);
return pending;
} catch (UncheckedIOException e) {
IOException failure = e.getCause();
cleanupFailedExtraction(pending, failure);
throw failure;
} catch (IOException | RuntimeException e) {
cleanupFailedExtraction(pending, e);
throw e;
}
}
static void cleanupFailedExtraction(File pending, Throwable failure) {
try {
DatapackInstall.deleteInstallScratch(pending, "failed datapack extraction");
} catch (IOException cleanupFailure) {
failure.addSuppressed(cleanupFailure);
}
}
static void cleanupExtractedStaging(File extractedDir) {
try {
DatapackInstall.deleteInstallScratch(extractedDir, "extracted datapack staging");
} catch (IOException e) {
IrisLogging.warn("Preserving extracted datapack staging for restart cleanup: "
+ extractedDir.getPath() + " (" + e.getMessage() + ")");
}
}
static void flattenIfWrapped(File dir) throws IOException {
if (new File(dir, "pack.mcmeta").isFile()) {
return;
}
File[] children = dir.listFiles();
if (children == null) {
return;
}
File singleDir = null;
int dirCount = 0;
int fileCount = 0;
for (File child : children) {
if (child.isDirectory()) {
dirCount++;
singleDir = child;
} else {
fileCount++;
}
}
if (dirCount != 1 || fileCount != 0 || singleDir == null || !new File(singleDir, "pack.mcmeta").isFile()) {
return;
}
File[] inner = singleDir.listFiles();
if (inner != null) {
for (File item : inner) {
File moved = new File(dir, item.getName());
if (item.renameTo(moved)) {
continue;
}
if (item.isDirectory()) {
IO.copyDirectory(item.toPath(), moved.toPath());
} else {
Files.copy(item.toPath(), moved.toPath(), StandardCopyOption.REPLACE_EXISTING);
}
}
}
DatapackInstall.deleteInstallScratch(singleDir, "wrapped datapack extraction");
}
static DownloadResult download(String url, File dest, String etag, String lastModified) throws IOException {
URI current = DatapackStartupValidation.parseSourceUri(url);
if (current == null) {
throw new IOException("Empty datapack URL");
}
if ("file".equalsIgnoreCase(current.getScheme())) {
return copyLocalDatapack(current, dest);
}
for (int attempt = 0; attempt < MAX_REDIRECTS; attempt++) {
if (!"http".equalsIgnoreCase(current.getScheme()) && !"https".equalsIgnoreCase(current.getScheme())) {
throw new IOException("Datapack URL must use HTTP or HTTPS: " + current);
}
URL target = current.toURL();
HttpURLConnection connection = (HttpURLConnection) target.openConnection();
connection.setRequestMethod("GET");
connection.setRequestProperty("User-Agent", USER_AGENT);
if (etag != null && !etag.isBlank()) {
connection.setRequestProperty("If-None-Match", etag);
}
if (lastModified != null && !lastModified.isBlank()) {
connection.setRequestProperty("If-Modified-Since", lastModified);
}
connection.setConnectTimeout(20000);
connection.setReadTimeout(60000);
connection.setInstanceFollowRedirects(false);
try {
int code = connection.getResponseCode();
if (code == HttpURLConnection.HTTP_NOT_MODIFIED) {
String responseEtag = headerOrFallback(connection, "ETag", etag);
String responseLastModified = headerOrFallback(connection, "Last-Modified", lastModified);
return new DownloadResult(true, responseEtag, responseLastModified);
}
if (code / 100 == 3) {
String location = connection.getHeaderField("Location");
if (location == null || location.isBlank()) {
throw new IOException("Redirect without a location header from " + current);
}
try {
current = current.resolve(new URI(location));
} catch (URISyntaxException e) {
throw new IOException("Invalid redirect location from " + current + ": " + location, e);
}
continue;
}
if (code != 200) {
throw new IOException("HTTP " + code + " downloading " + current);
}
long declaredLength = connection.getContentLengthLong();
if (declaredLength > MAX_DOWNLOAD_BYTES) {
throw new IOException("Datapack download exceeds " + MAX_DOWNLOAD_BYTES + " bytes");
}
File parent = dest.getParentFile();
Path parentPath = parent == null ? Path.of(".").toAbsolutePath().normalize() : parent.toPath();
DatapackSupport.ensureScratchDirectory(parentPath.toFile(), "datapack download cache");
Path temporary = Files.createTempFile(parentPath, dest.getName() + "-", ".part");
try {
long downloaded = 0;
String responseEtag = connection.getHeaderField("ETag");
String responseLastModified = connection.getHeaderField("Last-Modified");
try (InputStream in = connection.getInputStream();
OutputStream out = Files.newOutputStream(temporary)) {
byte[] buffer = new byte[8192];
int length;
while ((length = in.read(buffer)) > 0) {
downloaded += length;
if (downloaded > MAX_DOWNLOAD_BYTES) {
throw new IOException("Datapack download exceeds " + MAX_DOWNLOAD_BYTES + " bytes");
}
out.write(buffer, 0, length);
}
}
DatapackSupport.move(temporary, dest.toPath());
return new DownloadResult(false, responseEtag, responseLastModified);
} finally {
Files.deleteIfExists(temporary);
}
} finally {
connection.disconnect();
}
}
throw new IOException("Too many redirects downloading " + url);
}
static DownloadResult copyLocalDatapack(URI source, File destination) throws IOException {
Path sourcePath = requireLocalDatapackPath(source);
Path destinationPath = destination.toPath().toAbsolutePath().normalize();
if (Files.exists(destinationPath, LinkOption.NOFOLLOW_LINKS)
&& Files.isSameFile(sourcePath, destinationPath)) {
throw new IOException("Local datapack source and cache destination are the same file: " + sourcePath);
}
Path parent = destinationPath.getParent();
if (parent == null) {
throw new IOException("Local datapack cache destination has no parent: " + destinationPath);
}
DatapackSupport.ensureScratchDirectory(parent.toFile(), "datapack download cache");
BasicFileAttributes before = Files.readAttributes(
sourcePath,
BasicFileAttributes.class,
LinkOption.NOFOLLOW_LINKS);
Path temporary = Files.createTempFile(parent, destination.getName() + "-", ".part");
try {
long copied = 0;
try (InputStream input = Files.newInputStream(
sourcePath,
StandardOpenOption.READ,
LinkOption.NOFOLLOW_LINKS);
OutputStream output = Files.newOutputStream(temporary)) {
byte[] buffer = new byte[DatapackOwnership.HASH_BUFFER_BYTES];
int length;
while ((length = input.read(buffer)) > 0) {
copied += length;
if (copied > MAX_DOWNLOAD_BYTES) {
throw new IOException("Local datapack exceeds " + MAX_DOWNLOAD_BYTES + " bytes: " + sourcePath);
}
output.write(buffer, 0, length);
}
}
BasicFileAttributes after = Files.readAttributes(
sourcePath,
BasicFileAttributes.class,
LinkOption.NOFOLLOW_LINKS);
if (!after.isRegularFile()
|| before.size() != after.size()
|| !before.lastModifiedTime().equals(after.lastModifiedTime())
|| !Objects.equals(before.fileKey(), after.fileKey())
|| copied != after.size()) {
throw new IOException("Local datapack changed while Iris was copying it: " + sourcePath);
}
DatapackSupport.move(temporary, destinationPath);
return new DownloadResult(false, null, null);
} finally {
Files.deleteIfExists(temporary);
}
}
static Path requireLocalDatapackPath(URI source) throws IOException {
if (source == null
|| !"file".equalsIgnoreCase(source.getScheme())
|| source.isOpaque()
|| source.getRawAuthority() != null
|| source.getRawQuery() != null
|| source.getRawFragment() != null) {
throw new IOException("Datapack file URL must be an absolute local file URI without authority, query, or fragment: " + source);
}
Path path;
try {
path = Path.of(source).toAbsolutePath().normalize();
} catch (IllegalArgumentException exception) {
throw new IOException("Invalid local datapack file URL: " + source, exception);
}
if (Files.isSymbolicLink(path)
|| !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Local datapack is not a regular non-symbolic-link file: " + path);
}
long size = Files.size(path);
if (size > MAX_DOWNLOAD_BYTES) {
throw new IOException("Local datapack exceeds " + MAX_DOWNLOAD_BYTES + " bytes: " + path);
}
return path;
}
static String headerOrFallback(HttpURLConnection connection, String name, String fallback) {
String value = connection.getHeaderField(name);
return value == null || value.isBlank() ? fallback : value;
}
static String sha1(File file) throws IOException {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-1");
try (InputStream in = new FileInputStream(file)) {
byte[] buffer = new byte[8192];
int length;
while ((length = in.read(buffer)) > 0) {
digest.update(buffer, 0, length);
}
}
byte[] hash = digest.digest();
StringBuilder builder = new StringBuilder(hash.length * 2);
for (byte value : hash) {
builder.append(String.format("%02x", value));
}
return builder.toString();
} catch (NoSuchAlgorithmException e) {
throw new IOException("SHA-1 algorithm unavailable", e);
}
}
static String deriveId(ResolvedDatapack resolved) {
String base = resolved.getProjectSlug();
if (base == null || base.isBlank()) {
base = resolved.getFileName();
int dot = base.lastIndexOf('.');
if (dot > 0) {
base = base.substring(0, dot);
}
}
String id = sanitizeId(base);
if (resolved.isDirect()) {
return id + "-" + ModrinthResolver.directIdentity(resolved.getDownloadUrl());
}
return id;
}
static String sanitizeId(String value) {
if (value == null) {
return "datapack";
}
String lower = value.toLowerCase(Locale.ROOT).trim();
StringBuilder builder = new StringBuilder(lower.length());
for (int i = 0; i < lower.length(); i++) {
char c = lower.charAt(i);
if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.') {
builder.append(c);
} else if (c == ' ' || c == '/' || c == '\\') {
builder.append('-');
}
}
String cleaned = builder.toString().replaceAll("-+", "-");
cleaned = cleaned.replaceAll("^[-_.]+", "").replaceAll("[-_.]+$", "");
return cleaned.isBlank() ? "datapack" : cleaned;
}
static boolean isValidManagedId(String value) {
return value != null && !value.isBlank() && value.equals(sanitizeId(value))
&& !RESERVED_IDS.contains(value);
}
static void pruneCache(File cacheDir) {
File[] files = cacheDir.listFiles(File::isFile);
if (files == null) {
return;
}
List<File> archives = new ArrayList<>();
long totalBytes = 0;
for (File file : files) {
if (file.getName().endsWith(".part")) {
IO.delete(file);
continue;
}
if (file.getName().endsWith(".zip")) {
archives.add(file);
totalBytes += Math.max(0, file.length());
}
}
archives.sort(Comparator.comparingLong(File::lastModified));
int remaining = archives.size();
for (File archive : archives) {
if (remaining <= MAX_CACHE_FILES && totalBytes <= MAX_CACHE_BYTES) {
break;
}
long size = Math.max(0, archive.length());
IO.delete(archive);
if (!archive.exists()) {
remaining--;
totalBytes -= size;
}
}
}
}
@@ -16,25 +16,26 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.util.common.inventorygui;
package art.arcane.iris.core.datapack;
import art.arcane.volmlib.util.inventorygui.WindowDecorator;
import java.io.IOException;
import java.nio.file.Path;
import art.arcane.volmlib.util.inventorygui.Element;
final class DatapackCoordinator {
private final Path transactionRoot;
final CoordinatorJournal journal;
import art.arcane.volmlib.util.data.MaterialBlock;
import org.bukkit.Material;
@SuppressWarnings("ClassCanBeRecord")
public class UIStaticDecorator implements WindowDecorator {
private final Element element;
public UIStaticDecorator(Element element) {
this.element = element == null ? new art.arcane.volmlib.util.inventorygui.UIElement("bg").setMaterial(new MaterialBlock(Material.AIR)) : element;
DatapackCoordinator(Path transactionRoot, CoordinatorJournal journal) {
this.transactionRoot = transactionRoot;
this.journal = journal;
}
@Override
public Element onDecorateBackground(art.arcane.volmlib.util.inventorygui.Window window, int position, int row) {
return element;
void phase(CoordinatorPhase phase) throws IOException {
journal.phase = phase;
DatapackTransactions.writeCoordinatorJournal(transactionRoot, journal);
}
void finish() throws IOException {
DatapackTransactions.deleteCoordinatorTransaction(transactionRoot);
}
}
@@ -0,0 +1,438 @@
/*
* 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.core.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
import art.arcane.volmlib.util.io.IO;
import java.io.File;
import java.io.IOException;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
final class DatapackCoordinatorResolution {
private DatapackCoordinatorResolution() {
}
static Set<Path> authoritativeEditableRoots(
Manifest committedManifest,
CoordinatorJournal journal
) throws IOException {
Set<Path> roots = new HashSet<>();
Entry committed = committedManifest.findById(journal.id);
if (committed != null) {
if (!Objects.equals(committed.url, journal.url)) {
throw new IOException("Datapack transaction conflicts with the committed editable pack owner");
}
addExistingPackRoots(roots, committed.importedTargets.keySet());
addExistingPackRoots(roots, committed.importAttempts.keySet());
addExistingPackRoots(roots, committed.importedBundles.keySet());
return roots;
}
if (journal.operation != CoordinatorOperation.REMOVE || !journal.phase.published()) {
throw new IOException("Datapack transaction has no committed editable pack authority");
}
for (CoordinatorEditable editable : journal.editables) {
Path packRoot = DatapackTransactions.coordinatorPath(editable.packRoot, "editable pack root");
if (Files.isSymbolicLink(packRoot) || !Files.isDirectory(packRoot, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Invalid editable pack root in datapack transaction: " + packRoot);
}
Path realPackRoot = packRoot.toRealPath();
if (!packRoot.equals(realPackRoot)) {
throw new IOException("Editable pack root changed before datapack recovery: " + packRoot);
}
roots.add(realPackRoot);
}
return roots;
}
static void addExistingPackRoots(Set<Path> roots, Set<String> paths) throws IOException {
for (String path : paths) {
try {
Path root = Path.of(path).toAbsolutePath().normalize();
if (Files.isDirectory(root)) {
roots.add(root.toRealPath());
}
} catch (RuntimeException e) {
throw new IOException("Invalid editable pack root in the datapack manifest: " + path, e);
}
}
}
static void validateCoordinatorDirectory(
CoordinatorDirectory directory,
CoordinatorOperation operation,
Set<Path> allowedTargets,
Set<Path> seenTargets,
Set<Path> seenTargetIdentities
) throws IOException {
if (directory == null || directory.target == null || directory.backup == null
|| directory.originalHash == null || directory.desiredHash == null
|| directory.originalMarkerHash == null || directory.desiredMarkerHash == null
|| directory.originalIdentity == null || directory.desiredIdentity == null
|| directory.targetRoot == null || directory.scratchRoot == null
|| directory.targetRootIdentity == null || directory.scratchRootIdentity == null) {
throw new IOException("Incomplete directory participant in datapack transaction");
}
Path target = DatapackTransactions.coordinatorPath(directory.target, "target");
if (!allowedTargets.contains(target) || !seenTargets.add(target)) {
throw new IOException("Datapack transaction target is outside its configured roots: " + target);
}
File targetFile = target.toFile();
File targetParent = targetFile.getParentFile();
Path targetRootIdentity = DatapackTransactions.coordinatorPath(directory.targetRoot, "target root");
if (!seenTargetIdentities.add(targetRootIdentity.resolve(target.getFileName()).normalize())) {
throw new IOException("Aliased datapack transaction target " + target);
}
validateRecoveryContainer(
targetParent.toPath(),
targetRootIdentity,
true,
"datapack target root"
);
DatapackInstallPlanner.verifyDirectoryContainerIdentity(
targetParent, directory.targetRoot, directory.targetRootIdentity, "datapack target root");
File scratchRoot = new File(
targetParent.getParentFile() == null ? targetParent : targetParent.getParentFile(),
operation == CoordinatorOperation.INSTALL ? ".iris-datapack-install" : ".iris-datapack-remove"
);
Path expectedScratch = scratchRoot.toPath().toAbsolutePath().normalize();
Path backup = DatapackTransactions.coordinatorPath(directory.backup, "backup");
if (!Objects.equals(backup.getParent(), expectedScratch) || Files.isSymbolicLink(backup)) {
throw new IOException("Invalid datapack transaction backup path " + backup);
}
Path pending = null;
if (operation == CoordinatorOperation.INSTALL) {
if (directory.pending == null || directory.pending.isBlank()) {
throw new IOException("Install transaction is missing its pending directory");
}
pending = DatapackTransactions.coordinatorPath(directory.pending, "pending directory");
if (!Objects.equals(pending.getParent(), expectedScratch) || Files.isSymbolicLink(pending)) {
throw new IOException("Invalid datapack transaction pending path " + pending);
}
} else if (directory.pending != null && !directory.pending.isBlank()) {
throw new IOException("Removal transaction unexpectedly contains a pending directory");
}
boolean scratchRequired = Files.exists(backup, LinkOption.NOFOLLOW_LINKS)
|| pending != null && Files.exists(pending, LinkOption.NOFOLLOW_LINKS);
validateRecoveryContainer(
expectedScratch,
DatapackTransactions.coordinatorPath(directory.scratchRoot, "scratch root"),
scratchRequired,
"datapack transaction scratch root"
);
if (scratchRequired) {
DatapackInstallPlanner.verifyDirectoryContainerIdentity(
expectedScratch.toFile(), directory.scratchRoot,
directory.scratchRootIdentity, "datapack transaction scratch root");
}
}
static void validateRecoveryContainer(
Path container,
Path expectedRealPath,
boolean required,
String purpose
) throws IOException {
Path normalized = container.toAbsolutePath().normalize();
Path normalizedExpected = expectedRealPath.toAbsolutePath().normalize();
if (!Files.exists(normalized, LinkOption.NOFOLLOW_LINKS)) {
if (required) {
throw new IOException("Missing " + purpose + " " + normalized);
}
return;
}
if (Files.isSymbolicLink(normalized)
|| !Files.isDirectory(normalized, LinkOption.NOFOLLOW_LINKS)
|| !Objects.equals(normalized.toRealPath(), normalizedExpected)) {
throw new IOException("Changed or unsafe " + purpose + " " + normalized);
}
}
static boolean coordinatorCommitDecision(Manifest manifest, CoordinatorJournal journal) throws IOException {
Entry committed = manifest.findById(journal.id);
if (journal.operation == CoordinatorOperation.REMOVE) {
if (committed == null) {
if (!journal.phase.published()) {
throw new IOException("Datapack removal manifest changed before every participant was published");
}
return true;
}
if (!Objects.equals(committed.url, journal.url) || journal.phase == CoordinatorPhase.COMMITTED) {
throw new IOException("Datapack removal journal conflicts with the committed manifest");
}
return false;
}
boolean matches = committed != null
&& Objects.equals(committed.url, journal.url)
&& Objects.equals(committed.versionId, journal.versionId)
&& Objects.equals(committed.versionNumber, journal.versionNumber)
&& Objects.equals(committed.sha1, journal.sha1);
if (journal.manifestAlreadyMatched) {
if (!matches) {
throw new IOException("Committed datapack changed while an install transaction was incomplete");
}
return journal.phase.published();
}
if (matches) {
if (!journal.phase.published()) {
throw new IOException("Datapack install manifest changed before every target was published");
}
return true;
}
if (journal.phase == CoordinatorPhase.COMMITTED) {
throw new IOException("Committed datapack install journal conflicts with the manifest");
}
return false;
}
static void resolveCoordinatorDirectories(CoordinatorJournal journal, boolean commit) throws IOException {
List<CoordinatorDirectory> directories = new ArrayList<>(journal.directories);
if (!commit) {
Collections.reverse(directories);
}
IOException failure = null;
for (CoordinatorDirectory directory : directories) {
try {
if (journal.operation == CoordinatorOperation.INSTALL) {
resolveInstallDirectory(journal, directory, commit);
} else {
resolveRemovalDirectory(directory, commit);
}
} catch (IOException | RuntimeException e) {
IOException participantFailure = e instanceof IOException ioFailure
? ioFailure : new IOException("Failed resolving datapack directory participant", e);
if (failure == null) {
failure = participantFailure;
} else {
failure.addSuppressed(participantFailure);
}
}
}
if (failure != null) {
throw failure;
}
}
static void resolveInstallDirectory(
CoordinatorJournal journal,
CoordinatorDirectory directory,
boolean commit
) throws IOException {
File target = new File(directory.target);
File pending = new File(directory.pending);
File backup = new File(directory.backup);
File targetRoot = new File(directory.targetRoot);
if (commit) {
verifyDesiredDirectory(
target, targetRoot, journal, directory.desiredHash,
directory.desiredMarkerHash, directory.desiredIdentity);
deleteOriginalBackupIfPresent(
backup, targetRoot, directory.originalHash,
directory.originalMarkerHash, directory.originalIdentity);
deleteDesiredDirectoryIfPresent(
pending, targetRoot, journal, directory.desiredHash,
directory.desiredMarkerHash, directory.desiredIdentity);
cleanupScratchParent(backup);
return;
}
if (directory.hadTarget) {
if (Files.exists(backup.toPath(), LinkOption.NOFOLLOW_LINKS)) {
DatapackInstallPlanner.verifyDirectorySnapshot(
backup, targetRoot, directory.originalHash,
directory.originalMarkerHash, directory.originalIdentity,
"datapack install backup");
deleteDesiredDirectoryIfPresent(
target, targetRoot, journal, directory.desiredHash,
directory.desiredMarkerHash, directory.desiredIdentity);
DatapackSupport.moveNew(backup.toPath(), target.toPath());
DatapackSupport.forceDirectoryIfSupported(target.getParentFile().toPath());
DatapackSupport.forceDirectoryIfSupported(backup.getParentFile().toPath());
} else {
DatapackInstallPlanner.verifyDirectorySnapshot(
target, targetRoot, directory.originalHash,
directory.originalMarkerHash, directory.originalIdentity,
"original datapack target");
}
} else {
if (Files.exists(backup.toPath(), LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Unexpected backup for newly-installed datapack " + backup.getPath());
}
deleteDesiredDirectoryIfPresent(
target, targetRoot, journal, directory.desiredHash,
directory.desiredMarkerHash, directory.desiredIdentity);
}
deleteDesiredDirectoryIfPresent(
pending, targetRoot, journal, directory.desiredHash,
directory.desiredMarkerHash, directory.desiredIdentity);
cleanupScratchParent(backup);
}
static void resolveRemovalDirectory(CoordinatorDirectory directory, boolean commit) throws IOException {
File target = new File(directory.target);
File backup = new File(directory.backup);
File targetRoot = new File(directory.targetRoot);
if (commit) {
if (Files.exists(target.toPath(), LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Removed datapack target reappeared before transaction cleanup: " + target.getPath());
}
deleteOriginalBackupIfPresent(
backup, targetRoot, directory.originalHash,
directory.originalMarkerHash, directory.originalIdentity);
cleanupScratchParent(backup);
return;
}
if (Files.exists(backup.toPath(), LinkOption.NOFOLLOW_LINKS)) {
DatapackInstallPlanner.verifyDirectorySnapshot(
backup, targetRoot, directory.originalHash,
directory.originalMarkerHash, directory.originalIdentity,
"datapack removal backup");
if (Files.exists(target.toPath(), LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Datapack removal target was concurrently recreated: " + target.getPath());
}
DatapackSupport.moveNew(backup.toPath(), target.toPath());
DatapackSupport.forceDirectoryIfSupported(target.getParentFile().toPath());
DatapackSupport.forceDirectoryIfSupported(backup.getParentFile().toPath());
} else {
DatapackInstallPlanner.verifyDirectorySnapshot(
target, targetRoot, directory.originalHash,
directory.originalMarkerHash, directory.originalIdentity,
"original datapack target");
}
cleanupScratchParent(backup);
}
static void resolveCoordinatorEditables(
CoordinatorJournal journal,
Path transactionRoot,
boolean commit
) throws IOException {
IOException failure = null;
for (CoordinatorEditable editable : journal.editables) {
try {
Path packRoot = DatapackTransactions.coordinatorPath(editable.packRoot, "editable pack root");
StructureTransactionWriter writer = new StructureTransactionWriter(packRoot);
writer.resolvePreparedRemoval(
new StructureTransactionWriter.PreparedRemovalToken(
packRoot,
UUID.fromString(editable.transactionId)
),
new StructureTransactionWriter.RecoveryOwner(
transactionRoot,
UUID.fromString(journal.transactionId),
UUID.fromString(editable.claimId)
),
commit
);
IrisData.invalidateLoadedStructureResources(packRoot.toFile());
} catch (IOException | RuntimeException e) {
IOException participantFailure = e instanceof IOException ioFailure
? ioFailure : new IOException("Failed resolving editable structure participant", e);
if (failure == null) {
failure = participantFailure;
} else {
failure.addSuppressed(participantFailure);
}
}
}
if (failure != null) {
throw failure;
}
}
static void verifyDesiredDirectory(
File directory,
File targetRoot,
CoordinatorJournal journal,
String expectedHash,
String expectedMarkerHash,
String expectedIdentity
) throws IOException {
DatapackInstallPlanner.verifyDirectorySnapshot(
directory, targetRoot, expectedHash, expectedMarkerHash,
expectedIdentity, "installed datapack target");
Ownership ownership = DatapackOwnership.readOwnership(directory);
if (!Objects.equals(ownership.id, journal.id) || !Objects.equals(ownership.url, journal.url)
|| !Objects.equals(ownership.contentHash, expectedHash)) {
throw new IOException("Installed datapack ownership does not match its transaction at " + directory.getPath());
}
}
static void deleteDesiredDirectoryIfPresent(
File directory,
File targetRoot,
CoordinatorJournal journal,
String expectedHash,
String expectedMarkerHash,
String expectedIdentity
) throws IOException {
if (!Files.exists(directory.toPath(), LinkOption.NOFOLLOW_LINKS)) {
return;
}
verifyDesiredDirectory(
directory, targetRoot, journal, expectedHash,
expectedMarkerHash, expectedIdentity);
deleteVerifiedDirectory(directory);
}
static void deleteOriginalBackupIfPresent(
File backup,
File targetRoot,
String expectedHash,
String expectedMarkerHash,
String expectedIdentity
) throws IOException {
if (!Files.exists(backup.toPath(), LinkOption.NOFOLLOW_LINKS)) {
return;
}
DatapackInstallPlanner.verifyDirectorySnapshot(
backup, targetRoot, expectedHash, expectedMarkerHash,
expectedIdentity, "datapack transaction backup");
deleteVerifiedDirectory(backup);
}
static void deleteVerifiedDirectory(File directory) throws IOException {
File parent = Objects.requireNonNull(directory.getParentFile(), "datapack transaction parent");
DatapackInstall.validateInstallTree(directory, parent, "Datapack transaction directory");
IO.delete(directory);
if (Files.exists(directory.toPath(), LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Could not delete datapack transaction directory " + directory.getPath());
}
}
static void cleanupScratchParent(File participant) {
File parent = participant.getParentFile();
if (parent != null) {
parent.delete();
}
}
}
@@ -0,0 +1,146 @@
/*
* 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.core.datapack;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.volmlib.util.collection.KList;
import java.io.File;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Stream;
final class DatapackImportSources {
static final String LOCAL_IMPORT_DIRECTORY = "imports";
private DatapackImportSources() {
}
static Set<String> mergeConfiguredImports(
Iterable<String> explicit,
Iterable<String> discovered
) {
LinkedHashSet<String> sources = new LinkedHashSet<>();
if (explicit != null) {
addImports(explicit, sources);
}
if (discovered != null) {
addImports(discovered, sources);
}
return sources.isEmpty() ? Set.of() : Set.copyOf(sources);
}
static List<String> discoverLocalDatapackImports(File directory) throws IOException {
Path root = directory.toPath().toAbsolutePath().normalize();
if (Files.notExists(root, LinkOption.NOFOLLOW_LINKS)) {
Files.createDirectories(root);
}
if (Files.isSymbolicLink(root)
|| !Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Local datapack import path is not a safe directory: " + root);
}
try (Stream<Path> stream = Files.list(root)) {
return stream
.filter(path -> !Files.isSymbolicLink(path))
.filter(path -> Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS))
.filter(DatapackImportSources::isLocalDatapackArchive)
.sorted(Comparator.comparing(
(Path path) -> path.getFileName().toString(),
String.CASE_INSENSITIVE_ORDER)
.thenComparing((Path path) -> path.getFileName().toString()))
.map(path -> path.toAbsolutePath().normalize().toUri().toASCIIString())
.toList();
}
}
static boolean isLocalDatapackArchive(Path path) {
String name = path.getFileName().toString();
return !name.startsWith(".") && name.toLowerCase(Locale.ROOT).endsWith(".zip");
}
static List<String> localDatapackImports() {
File directory = IrisPlatforms.get().dataFolder("datapacks", LOCAL_IMPORT_DIRECTORY);
try {
return discoverLocalDatapackImports(directory);
} catch (IOException exception) {
IrisLogging.reportError("Could not discover local datapack imports under "
+ directory.getPath() + ".", exception);
return List.of();
}
}
static void collectImports(IrisData data, LinkedHashSet<String> urls) {
if (data == null || data.getDimensionLoader() == null) {
return;
}
for (IrisDimension dimension : data.getDimensionLoader().loadAll(data.getDimensionLoader().getPossibleKeys())) {
if (dimension == null) {
continue;
}
KList<String> imports = dimension.getDatapackImports();
if (imports == null) {
continue;
}
addImports(imports, urls);
}
}
static void addImports(Iterable<String> imports, LinkedHashSet<String> sources) {
for (String source : imports) {
if (source != null && !source.isBlank()) {
sources.add(normalizeConfiguredSource(source));
}
}
}
static String normalizeConfiguredSource(String source) {
String normalized = source.trim();
try {
URI uri = new URI(normalized);
if ("file".equalsIgnoreCase(uri.getScheme())) {
return Path.of(uri).toAbsolutePath().normalize().toUri().toASCIIString();
}
} catch (IllegalArgumentException | URISyntaxException ignored) {
}
return normalized;
}
static Set<String> configuredImports(IrisData data) {
LinkedHashSet<String> urls = new LinkedHashSet<>();
collectImports(data, urls);
urls.addAll(localDatapackImports());
return urls;
}
static boolean hasImports(IrisData data) {
return !configuredImports(data).isEmpty();
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,331 @@
/*
* 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.core.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.io.IO;
import java.io.File;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
final class DatapackInstall {
static final int MAX_SCRATCH_DELETE_ATTEMPTS = 3;
private DatapackInstall() {
}
static InstallResult install(File stagedDir, KList<File> worldFolders, Entry entry, boolean stripOverrides) throws IOException {
File root = inferDatapackRoot(stagedDir);
DatapackScratchRecovery.recoverTransactions(root, worldFolders);
InstallExecution execution = prepareInstallExecution(
stagedDir,
worldFolders,
entry,
stripOverrides,
root
);
try {
verifyInstallExecution(execution);
} catch (IOException failure) {
rollbackInstallExecutions(List.of(execution), failure);
throw failure;
}
finishInstallExecution(execution);
return execution.result();
}
static InstallExecution prepareInstallExecution(
File stagedDir,
KList<File> worldFolders,
Entry entry,
boolean stripOverrides,
File root
) throws IOException {
return prepareInstallExecution(
stagedDir, worldFolders, entry, stripOverrides, root, null);
}
static InstallExecution prepareInstallExecution(
File stagedDir,
KList<File> worldFolders,
Entry entry,
boolean stripOverrides,
File root,
VerifiedStagingInstall verifiedStagingInstall
) throws IOException {
DatapackOwnership.validateManagedDirectory(stagedDir, entry.id);
Ownership stagedOwnership = DatapackOwnership.readOwnership(stagedDir);
String stagedHash = DatapackOwnership.directoryHash(stagedDir);
if (!ownershipMetadataMatches(stagedOwnership, entry, stagedHash)) {
throw new IOException("Iris datapack staging does not match the committed manifest entry for " + entry.id);
}
DatapackStagingGuard.validateInstallParticipants(worldFolders, verifiedStagingInstall, entry);
List<InstallPlan> plans = new ArrayList<>();
try {
for (File worldFolder : worldFolders) {
plans.add(DatapackInstallPlanner.prepareInstall(
stagedDir, worldFolder, entry, stagedHash, stripOverrides, verifiedStagingInstall));
}
if (verifiedStagingInstall != null) {
plans.add(DatapackInstallPlanner.prepareInstall(
stagedDir,
verifiedStagingInstall.stagingRoot().toFile(),
entry,
stagedHash,
false,
verifiedStagingInstall));
}
} catch (IOException | RuntimeException preparationFailure) {
for (InstallPlan plan : plans) {
try {
DatapackInstallPlanner.cleanupInstallPlan(plan, false);
} catch (IOException cleanupFailure) {
preparationFailure.addSuppressed(cleanupFailure);
}
}
if (preparationFailure instanceof UncheckedIOException unchecked) {
throw unchecked.getCause();
}
if (preparationFailure instanceof IOException ioFailure) {
throw ioFailure;
}
throw preparationFailure;
}
boolean changed = false;
List<InstallPlan> publishPlans = new ArrayList<>();
List<InstallPlan> unchangedPlans = new ArrayList<>();
try {
for (InstallPlan plan : plans) {
changed |= plan.contentChanged();
if (plan.publishRequired()) {
publishPlans.add(plan);
} else {
unchangedPlans.add(plan);
DatapackInstallPlanner.cleanupInstallPlan(plan, true);
}
}
} catch (IOException cleanupFailure) {
for (InstallPlan plan : plans) {
try {
DatapackInstallPlanner.cleanupInstallPlan(plan, false);
} catch (IOException additionalFailure) {
cleanupFailure.addSuppressed(additionalFailure);
}
}
throw cleanupFailure;
}
if (publishPlans.isEmpty()) {
return new InstallExecution(
new InstallResult(changed),
null,
stagedDir,
entry,
stagedHash,
verifiedStagingInstall == null,
publishPlans,
unchangedPlans);
}
Manifest committedManifest = DatapackManifestStore.readCommittedManifest(root);
boolean manifestAlreadyMatched = manifestEntryMatches(committedManifest.findById(entry.id), entry);
DatapackCoordinator coordinator;
try {
coordinator = DatapackTransactions.createInstallCoordinator(root, entry, publishPlans, manifestAlreadyMatched);
} catch (IOException | RuntimeException coordinatorFailure) {
for (InstallPlan plan : publishPlans) {
try {
DatapackInstallPlanner.cleanupInstallPlan(plan, false);
} catch (IOException cleanupFailure) {
coordinatorFailure.addSuppressed(cleanupFailure);
}
}
if (coordinatorFailure instanceof IOException ioFailure) {
throw ioFailure;
}
throw coordinatorFailure;
}
try {
coordinator.phase(CoordinatorPhase.PUBLISHING);
for (InstallPlan plan : publishPlans) {
DatapackInstallPlanner.publishInstallPlan(plan);
}
coordinator.phase(CoordinatorPhase.PUBLISHED);
} catch (IOException | RuntimeException publishFailure) {
try {
DatapackCoordinatorResolution.resolveCoordinatorDirectories(coordinator.journal, false);
coordinator.finish();
} catch (IOException rollbackFailure) {
publishFailure.addSuppressed(rollbackFailure);
}
if (publishFailure instanceof IOException ioFailure) {
throw ioFailure;
}
throw publishFailure;
}
return new InstallExecution(
new InstallResult(changed),
coordinator,
stagedDir,
entry,
stagedHash,
verifiedStagingInstall == null,
publishPlans,
unchangedPlans);
}
static void verifyInstallExecution(InstallExecution execution) throws IOException {
if (execution.verified()) {
return;
}
if (execution.verifyStagedSource()) {
DatapackOwnership.validateManagedDirectory(execution.stagedDir(), execution.entry().id);
Ownership stagedOwnership = DatapackOwnership.readOwnership(execution.stagedDir());
String stagedHash = DatapackOwnership.directoryHash(execution.stagedDir());
if (!Objects.equals(stagedHash, execution.stagedHash())
|| !ownershipMetadataMatches(stagedOwnership, execution.entry(), stagedHash)) {
throw new IOException("Iris datapack staging changed before installation commit for "
+ execution.entry().id);
}
}
for (InstallPlan plan : execution.publishedPlans()) {
DatapackInstallPlanner.verifyDesiredInstallSnapshot(plan.target(), plan, "published datapack target");
}
for (InstallPlan plan : execution.unchangedPlans()) {
DatapackInstallPlanner.verifyOriginalInstallSnapshot(plan.target(), plan, "unchanged datapack target");
}
execution.markVerified();
}
static void finishInstallExecution(InstallExecution execution) throws IOException {
if (!execution.verified()) {
throw new IOException("Datapack install cannot commit before final verification");
}
if (execution.coordinator() == null) {
return;
}
execution.coordinator().phase(CoordinatorPhase.COMMITTED);
DatapackCoordinatorResolution.resolveCoordinatorDirectories(execution.coordinator().journal, true);
execution.coordinator().finish();
}
static void rollbackInstallExecutions(List<InstallExecution> executions, Throwable failure) {
List<InstallExecution> reversed = new ArrayList<>(executions);
Collections.reverse(reversed);
for (InstallExecution execution : reversed) {
if (execution.coordinator() == null) {
continue;
}
try {
DatapackCoordinatorResolution.resolveCoordinatorDirectories(execution.coordinator().journal, false);
execution.coordinator().finish();
} catch (IOException rollbackFailure) {
failure.addSuppressed(rollbackFailure);
}
}
}
static File inferDatapackRoot(File stagedDir) {
File parent = stagedDir.getParentFile();
if (parent != null && "staging".equals(parent.getName()) && parent.getParentFile() != null) {
return parent.getParentFile();
}
return parent == null ? stagedDir : parent;
}
static boolean manifestEntryMatches(Entry committed, Entry desired) {
return committed != null
&& Objects.equals(committed.id, desired.id)
&& Objects.equals(committed.url, desired.url)
&& Objects.equals(committed.versionId, desired.versionId)
&& Objects.equals(committed.versionNumber, desired.versionNumber)
&& Objects.equals(committed.sha1, desired.sha1);
}
static void validateInstallTree(File directory, File storeAnchor, String purpose) throws IOException {
DatapackScratchRecovery.validateScratchTree(directory.toPath());
if (!DatapackScratchRecovery.sameScratchVolume(directory.toPath(), storeAnchor.toPath())) {
throw new IOException(purpose + " crosses a filesystem boundary at " + directory.getPath());
}
}
static File installScratchRoot(File targetFolder) {
File parent = targetFolder.getParentFile();
return new File(parent == null ? targetFolder : parent, ".iris-datapack-install");
}
static void cleanupPreparedInstall(File pending, File pendingRoot, Throwable failure) {
try {
deleteInstallScratch(pending, "prepared datapack install");
} catch (IOException cleanupFailure) {
failure.addSuppressed(cleanupFailure);
}
pendingRoot.delete();
}
static boolean ownershipMetadataMatches(Ownership ownership, Entry entry, String contentHash) {
return ownership.schemaVersion == DatapackOwnership.OWNERSHIP_SCHEMA
&& Objects.equals(ownership.id, entry.id)
&& Objects.equals(ownership.url, entry.url)
&& Objects.equals(ownership.versionId, entry.versionId)
&& Objects.equals(ownership.versionNumber, entry.versionNumber)
&& Objects.equals(ownership.sha1, entry.sha1)
&& Objects.equals(ownership.contentHash, contentHash)
&& DatapackManifestStore.copyList(ownership.structureKeys).equals(DatapackManifestStore.copyList(entry.structureKeys))
&& DatapackManifestStore.copyList(ownership.templateKeys).equals(DatapackManifestStore.copyList(entry.templateKeys));
}
static boolean ownershipSourceMatches(Ownership ownership, Entry entry) {
return ownership.schemaVersion == DatapackOwnership.OWNERSHIP_SCHEMA
&& Objects.equals(ownership.id, entry.id)
&& Objects.equals(ownership.url, entry.url);
}
static void deleteInstallScratch(File scratch, String purpose) throws IOException {
Path scratchPath = scratch.toPath();
File parent = Objects.requireNonNull(scratch.getParentFile(), "datapack scratch parent");
for (int attempt = 0; attempt < MAX_SCRATCH_DELETE_ATTEMPTS; attempt++) {
if (Files.notExists(scratchPath, LinkOption.NOFOLLOW_LINKS)) {
return;
}
if (!Files.exists(scratchPath, LinkOption.NOFOLLOW_LINKS)
|| Files.isSymbolicLink(scratchPath)
|| !Files.isDirectory(scratchPath, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Refusing to remove unsafe " + purpose + " " + scratch.getPath());
}
validateInstallTree(scratch, parent, purpose);
DatapackOwnership.removeFinderMetadata(scratch);
IO.delete(scratch);
if (Files.notExists(scratchPath, LinkOption.NOFOLLOW_LINKS)) {
return;
}
}
throw new IOException("Could not remove " + purpose + " " + scratch.getPath());
}
}
@@ -0,0 +1,394 @@
/*
* 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.core.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.volmlib.util.io.IO;
import java.io.File;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Objects;
import java.util.UUID;
final class DatapackInstallPlanner {
private DatapackInstallPlanner() {
}
static InstallPlan prepareInstall(
File stagedDir,
File worldFolder,
Entry entry,
String stagedHash,
boolean stripOverrides,
VerifiedStagingInstall verifiedStagingInstall
) throws IOException {
DatapackStagingGuard.ensureInstallTargetRoot(worldFolder);
File target = new File(worldFolder, entry.id);
InstallPlan unchanged = tryPrepareUnchangedManagedInstall(
stagedDir,
worldFolder,
target,
entry,
stagedHash,
stripOverrides,
verifiedStagingInstall);
if (unchanged != null) {
return unchanged;
}
boolean canonicalStagingInstall = verifiedStagingInstall != null
&& verifiedStagingInstall.isCanonicalInstall(worldFolder, target);
boolean legacyReplacementAuthorized = canonicalStagingInstall
&& verifiedStagingInstall.consume(stagedDir, worldFolder, target, entry, stagedHash);
VerifiedStagingInstall legacyWorldAuthorization = null;
File pendingRoot = DatapackInstall.installScratchRoot(worldFolder);
File pending = new File(pendingRoot, entry.id + "-" + UUID.randomUUID());
File backup = new File(pendingRoot, entry.id + "-backup-" + UUID.randomUUID());
try {
DatapackSupport.ensureScratchDirectory(pendingRoot, "datapack install staging");
String targetRootIdentity = DatapackSupport.realDirectoryPath(worldFolder, "datapack target root");
String scratchRootIdentity = DatapackSupport.realDirectoryPath(pendingRoot, "datapack install scratch root");
String targetRootFileIdentity = DatapackSupport.directoryIdentity(worldFolder);
String scratchRootFileIdentity = DatapackSupport.directoryIdentity(pendingRoot);
IO.copyDirectory(stagedDir.toPath(), pending.toPath());
Files.deleteIfExists(new File(pending, DatapackOwnership.OWNERSHIP_MARKER).toPath());
String copiedHash = DatapackOwnership.directoryHash(pending);
if (!Objects.equals(stagedHash, copiedHash)) {
throw new IOException("Datapack staging changed or copied incompletely while preparing " + entry.id);
}
boolean removedOverrideMarker = Files.deleteIfExists(
new File(pending, DatapackPackMetadata.OVERRIDES_STRIPPED_MARKER).toPath());
if (stripOverrides) {
DatapackPackMetadata.stripVanillaStructureOverrides(pending);
DatapackPackMetadata.writeMarker(new File(pending, DatapackPackMetadata.OVERRIDES_STRIPPED_MARKER));
}
DatapackPackMetadata.validatePackMetadata(pending);
if (!stripOverrides && !removedOverrideMarker) {
DatapackOwnership.writeOwnership(pending, entry, copiedHash);
} else {
DatapackOwnership.writeOwnership(pending, entry);
}
DatapackInstall.validateInstallTree(pending, worldFolder, "Prepared datapack install");
Ownership desiredOwnership = DatapackOwnership.readOwnership(pending);
String desiredHash = desiredOwnership.contentHash;
String desiredMarkerHash = DatapackOwnership.ownershipMarkerFingerprint(pending);
String desiredIdentity = DatapackSupport.directoryIdentity(pending);
boolean hadTarget = DatapackSupport.pathExists(target.toPath(), "datapack install target");
String originalHash = "";
String originalMarkerHash = "absent";
String originalIdentity = "";
boolean contentChanged = !hadTarget;
boolean publishRequired = !hadTarget;
if (hadTarget) {
if (Files.isSymbolicLink(target.toPath())
|| !Files.isDirectory(target.toPath(), LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Refusing to replace non-directory or symbolic-link datapack " + target.getPath());
}
DatapackInstall.validateInstallTree(target, worldFolder, "Existing datapack install");
Ownership ownership = DatapackOwnership.readOwnershipOrNull(target);
if (ownership != null) {
DatapackOwnership.removeFinderMetadata(target);
}
String currentHash = DatapackOwnership.directoryHash(target);
originalHash = currentHash;
originalMarkerHash = DatapackOwnership.ownershipMarkerFingerprint(target);
originalIdentity = DatapackSupport.directoryIdentity(target);
if (ownership == null) {
boolean differingLegacyTarget = !Objects.equals(currentHash, desiredHash);
if (differingLegacyTarget && !legacyReplacementAuthorized
&& verifiedStagingInstall != null
&& verifiedStagingInstall.authorizeLegacyWorldReplacement(
stagedDir,
worldFolder,
target,
entry,
stagedHash,
currentHash,
originalMarkerHash)) {
legacyReplacementAuthorized = true;
legacyWorldAuthorization = verifiedStagingInstall;
}
if (differingLegacyTarget && !legacyReplacementAuthorized) {
throw new IOException("Refusing to replace unmanaged datapack at " + target.getPath());
}
if (differingLegacyTarget && (originalIdentity.isEmpty()
|| targetRootFileIdentity.isEmpty()
|| scratchRootFileIdentity.isEmpty()
|| verifiedStagingInstall == null
|| !verifiedStagingInstall.hasStablePathIdentities())) {
throw new IOException("Cannot safely identify legacy datapack staging at " + target.getPath());
}
publishRequired = true;
} else {
if (!entry.id.equals(ownership.id)) {
throw new IOException("Datapack ownership mismatch at " + target.getPath());
}
publishRequired = !Objects.equals(ownership.contentHash, currentHash)
|| !DatapackInstall.ownershipMetadataMatches(ownership, entry, desiredHash);
}
contentChanged = !Objects.equals(currentHash, desiredHash);
publishRequired |= contentChanged;
if (ownership != null && !Objects.equals(ownership.contentHash, currentHash)) {
IrisLogging.warn("Repairing modified or corrupt Iris-managed datapack at " + target.getPath());
}
}
return new InstallPlan(
target,
pending,
backup,
pendingRoot,
hadTarget,
publishRequired,
contentChanged,
originalHash,
desiredHash,
originalMarkerHash,
desiredMarkerHash,
originalIdentity,
desiredIdentity,
targetRootIdentity,
scratchRootIdentity,
targetRootFileIdentity,
scratchRootFileIdentity,
entry.id,
entry.url,
legacyWorldAuthorization
);
} catch (UncheckedIOException e) {
IOException cause = e.getCause();
DatapackInstall.cleanupPreparedInstall(pending, pendingRoot, cause);
throw cause;
} catch (IOException e) {
DatapackInstall.cleanupPreparedInstall(pending, pendingRoot, e);
throw e;
} catch (RuntimeException e) {
DatapackInstall.cleanupPreparedInstall(pending, pendingRoot, e);
throw e;
}
}
static InstallPlan tryPrepareUnchangedManagedInstall(
File stagedDir,
File worldFolder,
File target,
Entry entry,
String stagedHash,
boolean stripOverrides,
VerifiedStagingInstall verifiedStagingInstall
) throws IOException {
if (verifiedStagingInstall != null
|| stripOverrides
|| DatapackSupport.pathExists(new File(stagedDir, DatapackPackMetadata.OVERRIDES_STRIPPED_MARKER).toPath(),
"staged datapack override marker")
|| !DatapackSupport.pathExists(target.toPath(), "datapack install target")) {
return null;
}
if (Files.isSymbolicLink(target.toPath())
|| !Files.isDirectory(target.toPath(), LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Refusing to replace non-directory or symbolic-link datapack " + target.getPath());
}
Ownership ownership = DatapackOwnership.readOwnershipOrNull(target);
if (ownership == null) {
return null;
}
if (!entry.id.equals(ownership.id)) {
throw new IOException("Datapack ownership mismatch at " + target.getPath());
}
if (!DatapackInstall.ownershipMetadataMatches(ownership, entry, stagedHash)) {
return null;
}
DatapackInstall.validateInstallTree(target, worldFolder, "Existing datapack install");
DatapackOwnership.removeFinderMetadata(target);
String currentHash = DatapackOwnership.directoryHash(target);
if (!Objects.equals(currentHash, stagedHash)) {
return null;
}
String markerHash = DatapackOwnership.ownershipMarkerFingerprint(target);
String identity = DatapackSupport.directoryIdentity(target);
File pendingRoot = DatapackInstall.installScratchRoot(worldFolder);
return new InstallPlan(
target,
new File(pendingRoot, entry.id + "-" + UUID.randomUUID()),
new File(pendingRoot, entry.id + "-backup-" + UUID.randomUUID()),
pendingRoot,
true,
false,
false,
currentHash,
currentHash,
markerHash,
markerHash,
identity,
identity,
DatapackSupport.realDirectoryPath(worldFolder, "datapack target root"),
"",
DatapackSupport.directoryIdentity(worldFolder),
"",
entry.id,
entry.url,
null
);
}
static void publishInstallPlan(InstallPlan plan) throws IOException {
verifyDirectoryContainerIdentity(
plan.target().getParentFile(), plan.targetRootIdentity(),
plan.targetRootFileIdentity(), "datapack target root");
verifyDirectoryContainerIdentity(
plan.pendingRoot(), plan.scratchRootIdentity(),
plan.scratchRootFileIdentity(), "datapack install scratch root");
verifyDesiredInstallSnapshot(plan.pending(), plan, "prepared datapack install");
if (plan.hadTarget()) {
verifyOriginalInstallSnapshot(plan.target(), plan, "original datapack target");
} else if (DatapackSupport.pathExists(plan.target().toPath(), "new datapack target")) {
throw new IOException("Datapack install target was concurrently created at " + plan.target().getPath());
}
if (plan.legacyWorldAuthorization() != null) {
plan.legacyWorldAuthorization().verifyLegacyWorldSnapshot();
}
try {
if (plan.hadTarget()) {
DatapackSupport.moveNew(plan.target().toPath(), plan.backup().toPath());
verifyOriginalInstallSnapshot(plan.backup(), plan, "datapack install backup");
if (DatapackSupport.pathExists(plan.target().toPath(), "moved datapack target")) {
throw new IOException("Datapack install target reappeared after backup at " + plan.target().getPath());
}
forceInstallMoveDirectories(plan);
}
DatapackSupport.moveNew(plan.pending().toPath(), plan.target().toPath());
verifyDesiredInstallSnapshot(plan.target(), plan, "installed datapack target");
forceInstallMoveDirectories(plan);
} catch (IOException publishFailure) {
if (plan.hadTarget()
&& Files.exists(plan.backup().toPath(), LinkOption.NOFOLLOW_LINKS)
&& Files.exists(plan.pending().toPath(), LinkOption.NOFOLLOW_LINKS)
&& Files.notExists(plan.target().toPath(), LinkOption.NOFOLLOW_LINKS)) {
try {
verifyOriginalInstallSnapshot(plan.backup(), plan, "datapack install backup");
DatapackSupport.moveNew(plan.backup().toPath(), plan.target().toPath());
forceInstallMoveDirectories(plan);
} catch (IOException restoreFailure) {
publishFailure.addSuppressed(restoreFailure);
}
}
throw publishFailure;
}
}
static void forceInstallMoveDirectories(InstallPlan plan) throws IOException {
DatapackSupport.forceDirectoryIfSupported(plan.target().getParentFile().toPath());
DatapackSupport.forceDirectoryIfSupported(plan.pendingRoot().toPath());
}
static void verifyDirectoryContainerIdentity(
File directory,
String expectedRealPath,
String expectedFileIdentity,
String purpose
) throws IOException {
Path normalized = directory.toPath().toAbsolutePath().normalize();
if (Files.isSymbolicLink(normalized)
|| !Files.isDirectory(normalized, LinkOption.NOFOLLOW_LINKS)
|| !Objects.equals(normalized.toRealPath(), Path.of(expectedRealPath))
|| (!expectedFileIdentity.isEmpty()
&& !Objects.equals(DatapackSupport.directoryIdentity(directory), expectedFileIdentity))) {
throw new IOException("Changed or unsafe " + purpose + " " + normalized);
}
}
static void verifyOriginalInstallSnapshot(
File directory,
InstallPlan plan,
String purpose
) throws IOException {
verifyDirectorySnapshot(
directory,
plan.target().getParentFile(),
plan.originalHash(),
plan.originalMarkerHash(),
plan.originalIdentity(),
purpose
);
}
static void verifyDesiredInstallSnapshot(
File directory,
InstallPlan plan,
String purpose
) throws IOException {
verifyDirectorySnapshot(
directory,
plan.target().getParentFile(),
plan.desiredHash(),
plan.desiredMarkerHash(),
plan.desiredIdentity(),
purpose
);
Ownership ownership = DatapackOwnership.readOwnership(directory);
if (!Objects.equals(ownership.id, plan.id())
|| !Objects.equals(ownership.url, plan.url())
|| !Objects.equals(ownership.contentHash, plan.desiredHash())) {
throw new IOException("Datapack ownership changed in " + purpose + " at " + directory.getPath());
}
}
static void verifyDirectorySnapshot(
File directory,
File storeAnchor,
String expectedHash,
String expectedMarkerHash,
String expectedIdentity,
String purpose
) throws IOException {
if (!DatapackSupport.pathExists(directory.toPath(), purpose)
|| Files.isSymbolicLink(directory.toPath())
|| !Files.isDirectory(directory.toPath(), LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Missing or unsafe " + purpose + " at " + directory.getPath());
}
DatapackInstall.validateInstallTree(directory, storeAnchor, purpose);
if (Files.exists(new File(directory, DatapackOwnership.OWNERSHIP_MARKER).toPath(), LinkOption.NOFOLLOW_LINKS)) {
DatapackOwnership.removeFinderMetadata(directory);
}
if (!expectedIdentity.isEmpty()
&& !Objects.equals(DatapackSupport.directoryIdentity(directory), expectedIdentity)) {
throw new IOException("Datapack directory identity changed in " + purpose + " at " + directory.getPath());
}
if (!Objects.equals(expectedHash, DatapackOwnership.directoryHash(directory))
|| !Objects.equals(expectedMarkerHash, DatapackOwnership.ownershipMarkerFingerprint(directory))) {
throw new IOException("Datapack content changed in " + purpose + " at " + directory.getPath());
}
}
static void cleanupInstallPlan(InstallPlan plan, boolean committed) throws IOException {
DatapackInstall.deleteInstallScratch(plan.pending(), "datapack install pending directory");
if (committed) {
DatapackInstall.deleteInstallScratch(plan.backup(), "datapack install backup");
}
if (Files.exists(plan.backup().toPath(), LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Preserving prior datapack backup after incomplete install: "
+ plan.backup().getPath());
}
plan.pendingRoot().delete();
}
}
@@ -0,0 +1,316 @@
/*
* 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.core.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import art.arcane.iris.spi.IrisLogging;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;
import java.util.TreeMap;
final class DatapackManifestStore {
static final long MAX_MANIFEST_BYTES = 16L * 1024L * 1024L;
private DatapackManifestStore() {
}
static List<String> copyList(List<String> values) {
return values == null ? List.of() : List.copyOf(values);
}
static Entry copyEntry(Entry source) {
Entry resolved = Objects.requireNonNull(source, "Datapack manifest entry must not be null");
Entry copy = new Entry();
copy.url = resolved.url;
copy.id = resolved.id;
copy.versionId = resolved.versionId;
copy.versionNumber = resolved.versionNumber;
copy.sha1 = resolved.sha1;
copy.filename = resolved.filename;
copy.etag = resolved.etag;
copy.lastModified = resolved.lastModified;
copy.installedEpoch = resolved.installedEpoch;
copy.structuresImported = resolved.structuresImported;
copy.stagingMetadata = resolved.stagingMetadata;
copy.structureKeys = new ArrayList<>(copyList(resolved.structureKeys));
copy.templateKeys = new ArrayList<>(copyList(resolved.templateKeys));
copy.installMetadata = new HashMap<>(Objects.requireNonNullElseGet(
resolved.installMetadata, Map::of));
copy.importedTargets = new HashMap<>(Objects.requireNonNullElseGet(
resolved.importedTargets, Map::of));
copy.importAttempts = new HashMap<>(Objects.requireNonNullElseGet(
resolved.importAttempts, Map::of));
copy.importedBundles = new HashMap<>();
if (resolved.importedBundles != null) {
for (Map.Entry<String, Map<String, String>> bundle : resolved.importedBundles.entrySet()) {
copy.importedBundles.put(bundle.getKey(), new HashMap<>(bundle.getValue()));
}
}
return copy;
}
static Manifest readManifest(File root) {
File file = new File(root, "manifest.json");
Manifest manifest = null;
boolean recoverFromStaging = false;
Path path = file.toPath();
if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
manifest = new Manifest();
} else if (Files.isSymbolicLink(path) || !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) {
IrisLogging.error("Unreadable datapack manifest " + file.getPath()
+ "; moving the non-regular path aside before recovering Iris-managed staging");
recoverFromStaging = quarantine(path);
} else {
try {
String json = DatapackSupport.readBoundedUtf8(path, MAX_MANIFEST_BYTES, "Datapack manifest");
manifest = DatapackSupport.GSON.fromJson(json, Manifest.class);
if (manifest == null) {
throw new IOException("Datapack manifest is empty");
}
} catch (Exception e) {
IrisLogging.reportError("Unreadable datapack manifest " + file.getPath()
+ "; moving it aside before recovering Iris-managed staging", e);
recoverFromStaging = quarantine(file.toPath());
}
}
if (manifest == null) {
manifest = new Manifest();
}
normalizeManifest(manifest);
if (recoverFromStaging) {
recoverManifestFromStaging(root, manifest);
}
return manifest;
}
static boolean quarantine(Path file) {
try {
DatapackSupport.move(file, file.resolveSibling(file.getFileName().toString() + ".corrupt-" + System.currentTimeMillis()));
return true;
} catch (IOException e) {
IrisLogging.reportError("Failed to move aside corrupt datapack manifest " + file, e);
return false;
}
}
static void normalizeManifest(Manifest manifest) {
if (manifest.entries == null) {
manifest.entries = new ArrayList<>();
return;
}
List<Entry> normalized = new ArrayList<>();
Set<String> urls = new HashSet<>();
Set<String> ids = new HashSet<>();
for (Entry entry : manifest.entries) {
if (entry == null || entry.url == null || entry.url.isBlank() || entry.id == null || entry.id.isBlank()) {
continue;
}
entry.url = entry.url.trim();
if (!DatapackArchive.isValidManagedId(entry.id)) {
IrisLogging.warn("Ignoring datapack manifest entry with invalid id '" + entry.id + "'");
continue;
}
entry.structureKeys = normalizeKeys(entry.structureKeys);
entry.templateKeys = normalizeKeys(entry.templateKeys);
entry.stagingMetadata = entry.stagingMetadata == null ? "" : entry.stagingMetadata.trim();
entry.installMetadata = normalizeImportedTargets(entry.installMetadata);
entry.importedTargets = normalizeImportedTargets(entry.importedTargets);
entry.importAttempts = normalizeImportedTargets(entry.importAttempts);
entry.importedBundles = normalizeImportedBundles(entry.importedBundles);
if (!urls.add(entry.url) || !ids.add(entry.id)) {
IrisLogging.warn("Ignoring duplicate datapack manifest entry for id '" + entry.id + "' and url " + entry.url);
continue;
}
normalized.add(entry);
}
manifest.entries = normalized;
}
static void recoverManifestFromStaging(File root, Manifest manifest) {
File staging = new File(root, "staging");
File[] directories = staging.listFiles(File::isDirectory);
if (directories == null) {
return;
}
for (File directory : directories) {
try {
Ownership ownership = DatapackOwnership.readOwnershipOrNull(directory);
if (ownership == null || !directory.getName().equals(ownership.id)
|| manifest.find(ownership.url) != null || manifest.findById(ownership.id) != null) {
continue;
}
DatapackOwnership.validateManagedDirectory(directory, ownership.id);
if (!Objects.equals(ownership.contentHash, DatapackOwnership.directoryHash(directory))) {
IrisLogging.warn("Ignoring corrupt Iris-managed datapack staging at " + directory.getPath());
continue;
}
Entry recovered = ownership.toEntry();
recovered.installedEpoch = directory.lastModified();
manifest.put(recovered);
IrisLogging.warn("Recovered Iris-managed datapack manifest entry '" + recovered.id + "' from staging.");
} catch (IOException e) {
IrisLogging.warn("Ignoring orphan datapack staging at " + directory.getPath() + ": " + e.getMessage());
}
}
}
static List<String> normalizeKeys(List<String> keys) {
TreeSet<String> normalized = new TreeSet<>();
if (keys != null) {
for (String key : keys) {
if (key != null && !key.isBlank()) {
normalized.add(key.trim().toLowerCase(Locale.ROOT));
}
}
}
return new ArrayList<>(normalized);
}
static Map<String, String> normalizeImportedTargets(Map<String, String> targets) {
Map<String, String> normalized = new HashMap<>();
if (targets == null) {
return normalized;
}
for (Map.Entry<String, String> target : targets.entrySet()) {
if (target.getKey() != null && !target.getKey().isBlank()
&& target.getValue() != null && !target.getValue().isBlank()) {
normalized.put(target.getKey(), target.getValue());
}
}
return normalized;
}
static Map<String, Map<String, String>> normalizeImportedBundles(
Map<String, Map<String, String>> targets
) {
Map<String, Map<String, String>> normalized = new HashMap<>();
if (targets == null) {
return normalized;
}
for (Map.Entry<String, Map<String, String>> target : targets.entrySet()) {
if (target.getKey() == null || target.getKey().isBlank() || target.getValue() == null) {
continue;
}
Map<String, String> bundles = new TreeMap<>();
for (Map.Entry<String, String> bundle : target.getValue().entrySet()) {
if (bundle.getKey() != null && !bundle.getKey().isBlank()
&& bundle.getValue() != null && !bundle.getValue().isBlank()) {
bundles.put(bundle.getKey(), bundle.getValue());
}
}
normalized.put(target.getKey(), bundles);
}
return normalized;
}
static void writeManifest(File root, Manifest manifest) {
try {
writeManifestChecked(root, manifest);
} catch (IOException e) {
IrisLogging.reportError("Failed to write datapack manifest "
+ new File(root, "manifest.json").toPath(), e);
}
}
static void writeManifestChecked(File root, Manifest manifest) throws IOException {
try (ManifestWrite write = prepareManifestWrite(root, manifest)) {
write.publish();
}
}
static ManifestWrite prepareManifestWrite(File root, Manifest manifest) throws IOException {
Path file = new File(root, "manifest.json").toPath();
Path parent = file.getParent();
Files.createDirectories(parent);
Path temp = Files.createTempFile(parent, "manifest", ".json.tmp");
Path rollback = null;
try {
byte[] content = DatapackSupport.GSON.toJson(manifest).getBytes(StandardCharsets.UTF_8);
if (content.length > MAX_MANIFEST_BYTES) {
throw new IOException("Datapack manifest exceeds " + MAX_MANIFEST_BYTES + " bytes");
}
Files.write(temp, content, StandardOpenOption.TRUNCATE_EXISTING);
DatapackSupport.forceFile(temp);
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(file)) {
throw new IOException("Datapack manifest is not a regular file: " + file);
}
rollback = Files.createTempFile(parent, "manifest-rollback", ".json.tmp");
Files.copy(file, rollback, StandardCopyOption.REPLACE_EXISTING);
DatapackSupport.forceFile(rollback);
}
return new ManifestWrite(temp, file, rollback);
} catch (IOException | RuntimeException e) {
try {
Files.deleteIfExists(temp);
} catch (IOException cleanupFailure) {
e.addSuppressed(cleanupFailure);
}
if (rollback != null) {
try {
Files.deleteIfExists(rollback);
} catch (IOException cleanupFailure) {
e.addSuppressed(cleanupFailure);
}
}
throw e;
}
}
static Manifest readCommittedManifest(File root) throws IOException {
Path manifestPath = new File(root, "manifest.json").toPath();
if (!Files.exists(manifestPath, LinkOption.NOFOLLOW_LINKS)) {
return new Manifest();
}
if (Files.isSymbolicLink(manifestPath)
|| !Files.isRegularFile(manifestPath, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Datapack manifest is not a regular file: " + manifestPath);
}
Manifest manifest;
try {
manifest = DatapackSupport.GSON.fromJson(
DatapackSupport.readBoundedUtf8(manifestPath, MAX_MANIFEST_BYTES, "Datapack manifest"),
Manifest.class
);
} catch (RuntimeException e) {
throw new IOException("Invalid datapack manifest " + manifestPath, e);
}
if (manifest == null) {
throw new IOException("Empty datapack manifest " + manifestPath);
}
normalizeManifest(manifest);
return manifest;
}
}
@@ -0,0 +1,379 @@
/*
* 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.core.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import art.arcane.volmlib.util.io.IO;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileStore;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.Stream;
final class DatapackOwnership {
static final String FINDER_METADATA = ".DS_Store";
static final String OWNERSHIP_MARKER = ".iris-managed.json";
static final int OWNERSHIP_SCHEMA = 1;
static final int MAX_MANAGED_PATHS = DatapackArchive.MAX_ARCHIVE_ENTRIES + 16;
static final long MAX_OWNERSHIP_BYTES = 1024L * 1024L;
static final int HASH_BUFFER_BYTES = 64 * 1024;
private DatapackOwnership() {
}
static void writeOwnership(File directory, Entry entry) throws IOException {
writeOwnership(directory, entry, directoryHash(directory));
}
static void writeOwnership(File directory, Entry entry, String contentHash) throws IOException {
if (!DatapackArchive.isValidManagedId(entry.id) || entry.url == null || entry.url.isBlank()) {
throw new IOException("Invalid Iris datapack ownership identity for " + directory.getPath());
}
if (contentHash == null || contentHash.isBlank()) {
throw new IOException("Missing Iris datapack ownership content hash for " + directory.getPath());
}
Ownership ownership = new Ownership(
OWNERSHIP_SCHEMA,
entry.id,
entry.url,
entry.versionId,
entry.versionNumber,
entry.sha1,
contentHash,
DatapackManifestStore.copyList(entry.structureKeys),
DatapackManifestStore.copyList(entry.templateKeys)
);
Path marker = new File(directory, OWNERSHIP_MARKER).toPath();
Path temporary = Files.createTempFile(directory.toPath(), OWNERSHIP_MARKER, ".tmp");
try {
Files.writeString(
temporary,
DatapackSupport.GSON.toJson(ownership),
StandardCharsets.UTF_8,
StandardOpenOption.TRUNCATE_EXISTING
);
DatapackSupport.move(temporary, marker);
} finally {
Files.deleteIfExists(temporary);
}
}
static Ownership readOwnership(File directory) throws IOException {
Ownership ownership = readOwnershipOrNull(directory);
if (ownership == null) {
throw new IOException("Missing Iris datapack ownership marker in " + directory.getPath());
}
return ownership;
}
static Ownership readOwnershipOrNull(File directory) throws IOException {
File marker = new File(directory, OWNERSHIP_MARKER);
Path markerPath = marker.toPath();
if (Files.notExists(markerPath, LinkOption.NOFOLLOW_LINKS)) {
return null;
}
if (!Files.exists(markerPath, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Cannot determine Iris datapack ownership marker state in " + directory.getPath());
}
if (Files.isSymbolicLink(markerPath)
|| !Files.isRegularFile(markerPath, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Invalid Iris datapack ownership marker in " + directory.getPath());
}
if (marker.length() > MAX_OWNERSHIP_BYTES) {
throw new IOException("Oversized Iris datapack ownership marker in " + directory.getPath());
}
try {
Ownership ownership = DatapackSupport.GSON.fromJson(DatapackSupport.readBoundedUtf8(
marker.toPath(), MAX_OWNERSHIP_BYTES, "Iris datapack ownership marker"), Ownership.class);
if (ownership == null || ownership.schemaVersion != OWNERSHIP_SCHEMA
|| !DatapackArchive.isValidManagedId(ownership.id) || ownership.url == null || ownership.url.isBlank()
|| ownership.contentHash == null || ownership.contentHash.isBlank()) {
throw new IOException("Invalid Iris datapack ownership marker in " + directory.getPath());
}
return ownership;
} catch (RuntimeException e) {
throw new IOException("Invalid Iris datapack ownership marker in " + directory.getPath(), e);
}
}
static String ownershipMarkerFingerprint(File directory) throws IOException {
Path marker = new File(directory, OWNERSHIP_MARKER).toPath();
if (Files.notExists(marker, LinkOption.NOFOLLOW_LINKS)) {
return "absent";
}
if (!Files.exists(marker, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Cannot determine Iris datapack ownership marker state in " + directory.getPath());
}
if (Files.isSymbolicLink(marker)
|| !Files.isRegularFile(marker, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Invalid Iris datapack ownership marker in " + directory.getPath());
}
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return "sha256:" + DatapackSupport.hex(digest.digest(DatapackSupport.readBoundedBytes(
marker, MAX_OWNERSHIP_BYTES, "Iris datapack ownership marker")));
} catch (NoSuchAlgorithmException e) {
throw new IOException("SHA-256 algorithm unavailable", e);
}
}
static void validateManagedDirectory(File directory, String id) throws IOException {
Path path = directory.toPath();
if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS) || !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Missing datapack directory " + directory.getPath());
}
if (Files.isSymbolicLink(path)) {
throw new IOException("Refusing symbolic-link datapack directory " + directory.getPath());
}
DatapackPackMetadata.validatePackMetadata(directory);
rejectSymbolicLinks(directory);
Ownership ownership = readOwnershipOrNull(directory);
if (ownership == null) {
throw new IOException("Datapack is not Iris-managed: " + directory.getPath());
}
if (!id.equals(ownership.id)) {
throw new IOException("Datapack ownership mismatch at " + directory.getPath());
}
removeFinderMetadata(directory);
}
static void rejectSymbolicLinks(File root) throws IOException {
try (Stream<Path> paths = Files.walk(root.toPath())) {
Path symbolicLink = paths.filter(Files::isSymbolicLink).findFirst().orElse(null);
if (symbolicLink != null) {
throw new IOException("Datapack contains a symbolic link: " + symbolicLink);
}
}
}
static boolean sameDatapackVolume(
Path root,
FileStore rootStore,
Path entry
) throws IOException {
return DatapackScratchRecovery.sameScratchVolume(root, rootStore, entry, Files.getFileStore(entry));
}
static String directoryHash(File root) throws IOException {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
Path rootPath = root.toPath().toAbsolutePath().normalize();
Path rootMarker = rootPath.resolve(OWNERSHIP_MARKER);
FileStore rootStore = Files.getFileStore(rootPath);
List<Path> entries = new ArrayList<>();
try (Stream<Path> paths = Files.walk(rootPath)) {
Iterator<Path> iterator = paths.iterator();
int pathCount = 0;
while (iterator.hasNext()) {
Path path = iterator.next();
if (path.equals(rootPath) || path.equals(rootMarker)) {
continue;
}
if (isFinderMetadata(path)) {
if (Files.isSymbolicLink(path)
|| !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Suspicious Finder metadata in datapack: " + path);
}
continue;
}
pathCount++;
if (pathCount > MAX_MANAGED_PATHS) {
throw new IOException("Datapack contains more than " + MAX_MANAGED_PATHS + " paths");
}
if (Files.isSymbolicLink(path)) {
throw new IOException("Datapack contains a symbolic link: " + path);
}
if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)
&& !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Datapack contains an unsupported filesystem entry: " + path);
}
entries.add(path);
}
}
entries.sort(Comparator.comparing(path -> rootPath.relativize(path).toString()));
byte[] buffer = new byte[HASH_BUFFER_BYTES];
long totalBytes = 0;
for (Path entry : entries) {
String relative = rootPath.relativize(entry).toString().replace(File.separatorChar, '/');
byte[] relativeBytes = relative.getBytes(StandardCharsets.UTF_8);
BasicFileAttributes attributes = Files.readAttributes(
entry,
BasicFileAttributes.class,
LinkOption.NOFOLLOW_LINKS
);
if (attributes.isSymbolicLink()
|| attributes.isOther()
|| !attributes.isDirectory() && !attributes.isRegularFile()) {
throw new IOException("Datapack entry changed while hashing: " + relative);
}
if (!DatapackScratchRecovery.sameScratchVolume(rootPath, rootStore, entry, Files.getFileStore(entry))) {
throw new IOException("Datapack entry crosses a filesystem boundary: " + entry);
}
boolean directory = attributes.isDirectory();
digest.update((byte) (directory ? 1 : 2));
DatapackSupport.updateDigestInt(digest, relativeBytes.length);
digest.update(relativeBytes);
if (!directory) {
long expectedBytes = attributes.size();
if (expectedBytes > DatapackArchive.MAX_ENTRY_BYTES) {
throw new IOException("Datapack file exceeds " + DatapackArchive.MAX_ENTRY_BYTES + " bytes: " + relative);
}
totalBytes += expectedBytes;
if (totalBytes > DatapackArchive.MAX_EXPANDED_BYTES) {
throw new IOException("Datapack contents exceed " + DatapackArchive.MAX_EXPANDED_BYTES + " bytes");
}
DatapackSupport.updateDigestLong(digest, expectedBytes);
long entryBytes = 0;
try (InputStream input = Files.newInputStream(
entry,
StandardOpenOption.READ,
LinkOption.NOFOLLOW_LINKS
)) {
int length;
while ((length = input.read(buffer)) > 0) {
entryBytes += length;
if (entryBytes > expectedBytes) {
throw new IOException("Datapack file changed while hashing: " + relative);
}
digest.update(buffer, 0, length);
}
}
if (entryBytes != expectedBytes) {
throw new IOException("Datapack file changed while hashing: " + relative);
}
}
}
return DatapackSupport.hex(digest.digest());
} catch (NoSuchAlgorithmException e) {
throw new IOException("SHA-256 algorithm unavailable", e);
}
}
static void removeFinderMetadata(File root) throws IOException {
List<Path> entries;
try (Stream<Path> paths = Files.walk(root.toPath())) {
entries = paths.limit(MAX_MANAGED_PATHS + 1L).toList();
}
if (entries.size() > MAX_MANAGED_PATHS) {
throw new IOException("Datapack contains more than " + MAX_MANAGED_PATHS + " paths");
}
for (Path path : entries) {
if (!isFinderMetadata(path)) {
continue;
}
if (Files.isSymbolicLink(path)
|| !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Suspicious Finder metadata in datapack: " + path);
}
Files.delete(path);
}
}
static boolean isFinderMetadata(Path path) {
Path fileName = path.getFileName();
return fileName != null && FINDER_METADATA.equals(fileName.toString());
}
static PackResources scanPackResources(File root) throws IOException {
TreeSet<String> structureKeys = new TreeSet<>();
TreeSet<String> structureSetKeys = new TreeSet<>();
TreeSet<String> templateKeys = new TreeSet<>();
Path dataRoot = new File(root, "data").toPath();
if (!Files.isDirectory(dataRoot, LinkOption.NOFOLLOW_LINKS)) {
return new PackResources(new ArrayList<>(), new ArrayList<>(), new ArrayList<>());
}
try (Stream<Path> paths = Files.walk(dataRoot)) {
for (Path path : paths.filter(file -> Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)).toList()) {
Path relative = dataRoot.relativize(path);
if (relative.getNameCount() < 3) {
continue;
}
String namespace = relative.getName(0).toString().toLowerCase(Locale.ROOT);
String normalized = relative.subpath(1, relative.getNameCount()).toString().replace(File.separatorChar, '/');
addResourceKey(structureKeys, namespace, normalized, "worldgen/structure/", ".json");
addResourceKey(structureKeys, namespace, normalized, "worldgen/structures/", ".json");
addResourceKey(structureSetKeys, namespace, normalized, "worldgen/structure_set/", ".json");
addResourceKey(structureSetKeys, namespace, normalized, "worldgen/structure_sets/", ".json");
addResourceKey(templateKeys, namespace, normalized, "structure/", ".nbt");
addResourceKey(templateKeys, namespace, normalized, "structures/", ".nbt");
}
}
return new PackResources(
new ArrayList<>(structureKeys),
new ArrayList<>(structureSetKeys),
new ArrayList<>(templateKeys));
}
static void addResourceKey(Set<String> keys, String namespace, String path, String prefix, String suffix) {
if (!path.startsWith(prefix) || !path.endsWith(suffix)) {
return;
}
String resourcePath = path.substring(prefix.length(), path.length() - suffix.length());
if (!resourcePath.isBlank()) {
keys.add(namespace + ":" + resourcePath);
}
}
static boolean deleteOwnedDirectory(File directory, String id) throws IOException {
Path path = directory.toPath();
if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
return false;
}
if (Files.isSymbolicLink(path) || !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Refusing non-directory or symbolic-link target " + directory.getPath());
}
Ownership ownership = readOwnership(directory);
if (!id.equals(ownership.id)) {
throw new IOException("Ownership marker belongs to '" + ownership.id + "'");
}
removeFinderMetadata(directory);
if (!Objects.equals(ownership.contentHash, directoryHash(directory))) {
throw new IOException("Refusing to delete modified or corrupt Iris-managed datapack " + directory.getPath());
}
File parent = Objects.requireNonNull(directory.getParentFile(), "managed datapack parent");
DatapackInstall.validateInstallTree(directory, parent, "Managed datapack deletion");
IO.delete(directory);
if (Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Could not delete " + directory.getPath());
}
return true;
}
}
@@ -0,0 +1,155 @@
/*
* 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.core.datapack;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisImportedStructureControl;
import art.arcane.volmlib.util.io.IO;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.util.stream.Stream;
final class DatapackPackMetadata {
static final String OVERRIDES_STRIPPED_MARKER = ".iris-overrides-stripped";
static final long MAX_METADATA_BYTES = 1024L * 1024L;
private DatapackPackMetadata() {
}
static boolean resolveStripOverrides() {
try (Stream<IrisData> stream = ServerConfigurator.allPacks()) {
return stream.anyMatch(DatapackPackMetadata::packDisablesOverrides);
}
}
static boolean packDisablesOverrides(IrisData data) {
if (data == null || data.getDimensionLoader() == null) {
return false;
}
for (IrisDimension dimension : data.getDimensionLoader().loadAll(data.getDimensionLoader().getPossibleKeys())) {
if (dimension == null) {
continue;
}
IrisImportedStructureControl control = dimension.getImportedStructures();
if (control != null && !control.isDatapackOverrides()) {
return true;
}
}
return false;
}
static void stripVanillaStructureOverrides(File datapackRoot) throws IOException {
stripVanillaStructureOverrides(datapackRoot, IO::delete);
}
static void stripVanillaStructureOverrides(
File datapackRoot,
DirectoryDeleter deleter
) throws IOException {
File minecraftData = new File(new File(datapackRoot, "data"), "minecraft");
if (!minecraftData.isDirectory()) {
return;
}
String[] relativeTrees = {
"worldgen" + File.separator + "structure_set",
"worldgen" + File.separator + "structure",
"worldgen" + File.separator + "template_pool",
"structure"
};
for (String tree : relativeTrees) {
File dir = new File(minecraftData, tree);
if (dir.exists()) {
deleter.delete(dir);
if (Files.exists(dir.toPath(), LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Could not remove vanilla structure override tree " + dir.getPath());
}
}
}
}
static void writeMarker(File marker) throws IOException {
Files.writeString(marker.toPath(), "stripped", StandardCharsets.UTF_8);
}
static void validatePackMetadata(File datapackRoot) throws IOException {
File metadata = new File(datapackRoot, "pack.mcmeta");
if (!metadata.isFile() || Files.isSymbolicLink(metadata.toPath())) {
throw new IOException("Datapack is missing a regular pack.mcmeta");
}
if (metadata.length() > MAX_METADATA_BYTES) {
throw new IOException("Datapack pack.mcmeta exceeds 1 MiB");
}
JsonElement parsed;
try {
parsed = JsonParser.parseString(DatapackSupport.readBoundedUtf8(
metadata.toPath(), MAX_METADATA_BYTES, "Datapack pack.mcmeta"));
} catch (RuntimeException e) {
throw new IOException("Datapack pack.mcmeta is not valid JSON", e);
}
if (!parsed.isJsonObject()) {
throw new IOException("Datapack pack.mcmeta root must be an object");
}
JsonObject root = parsed.getAsJsonObject();
if (!root.has("pack") || !root.get("pack").isJsonObject()) {
throw new IOException("Datapack pack.mcmeta must contain a pack object");
}
JsonObject pack = root.getAsJsonObject("pack");
boolean hasPackFormat = pack.has("pack_format") && pack.get("pack_format").isJsonPrimitive()
&& pack.getAsJsonPrimitive("pack_format").isNumber();
boolean hasRange = validFormatValue(pack.get("min_format")) && validFormatValue(pack.get("max_format"));
if (!hasPackFormat && !hasRange) {
throw new IOException("Datapack pack.mcmeta must declare numeric pack_format or valid min_format/max_format");
}
if (!pack.has("description") || pack.get("description").isJsonNull()) {
throw new IOException("Datapack pack.mcmeta must declare a description");
}
File data = new File(datapackRoot, "data");
if (data.exists() && (!data.isDirectory() || Files.isSymbolicLink(data.toPath()))) {
throw new IOException("Datapack data path is not a regular directory");
}
}
static boolean validFormatValue(JsonElement value) {
if (value == null || value.isJsonNull()) {
return false;
}
if (value.isJsonPrimitive() && value.getAsJsonPrimitive().isNumber()) {
return true;
}
if (!value.isJsonArray() || value.getAsJsonArray().size() != 2) {
return false;
}
for (JsonElement component : value.getAsJsonArray()) {
if (!component.isJsonPrimitive() || !component.getAsJsonPrimitive().isNumber()) {
return false;
}
}
return true;
}
}
@@ -0,0 +1,468 @@
/*
* 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.core.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.core.structure.authoring.StructureKey;
import art.arcane.iris.core.structure.authoring.StructureSource;
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
import art.arcane.iris.util.common.format.C;
import art.arcane.iris.util.common.plugin.VolmitSender;
import java.io.File;
import java.io.IOException;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
import java.util.TreeMap;
import java.util.UUID;
final class DatapackRemoval {
private DatapackRemoval() {
}
static RemoveOutcome removeOutcomeLocked(VolmitSender sender, String id) {
File root = IrisPlatforms.get().dataFolder("datapacks");
return removeOutcomeLocked(sender, id, root, ServerConfigurator.getDatapacksFolder());
}
static boolean removeLocked(VolmitSender sender, String id, File root, List<File> worldFolders) {
return removeOutcomeLocked(sender, id, root, worldFolders) == RemoveOutcome.REMOVED;
}
static RemoveOutcome removeOutcomeLocked(VolmitSender sender, String id, File root, List<File> worldFolders) {
String requested = id == null ? "" : id.trim().toLowerCase(Locale.ROOT);
String cleaned = DatapackArchive.sanitizeId(id);
if (requested.isBlank() || !requested.equals(cleaned) || DatapackArchive.RESERVED_IDS.contains(cleaned)) {
DatapackSupport.message(sender, C.RED + "Invalid Iris-managed datapack id '" + requested + "'. Run /iris datapack list and use the exact listed id.");
return RemoveOutcome.REJECTED;
}
try {
DatapackScratchRecovery.recoverTransactions(root, worldFolders);
} catch (IOException e) {
DatapackSupport.message(sender, C.RED + "Datapack removal blocked by incomplete transaction recovery: " + e.getMessage());
IrisLogging.reportError(e);
return RemoveOutcome.REJECTED;
}
Manifest manifest = DatapackManifestStore.readManifest(root);
Entry ownedEntry = manifest.findById(cleaned);
if (ownedEntry == null) {
DatapackSupport.message(sender, C.YELLOW + "No Iris-managed datapack named '" + cleaned + "'. Unmanaged world datapacks are never removed by Iris.");
return RemoveOutcome.REJECTED;
}
List<File> targets;
try {
targets = preflightRemovalTargets(root, worldFolders, ownedEntry);
} catch (IOException e) {
DatapackSupport.message(sender, C.RED + "Refused to remove datapack '" + cleaned + "': " + e.getMessage());
IrisLogging.reportError(e);
return RemoveOutcome.REJECTED;
}
EditableImportRemoval editableRemoval = null;
DirectoryRemoval directoryRemoval = null;
ManifestWrite manifestWrite = null;
DatapackCoordinator coordinator = null;
try {
editableRemoval = prepareEntryImportRemoval(ownedEntry, manifest.entries);
directoryRemoval = prepareOwnedDirectories(targets);
if (!manifest.removeById(cleaned)) {
throw new IOException("Datapack manifest entry disappeared during removal");
}
manifestWrite = DatapackManifestStore.prepareManifestWrite(root, manifest);
coordinator = DatapackTransactions.createRemovalCoordinator(root, ownedEntry, directoryRemoval, editableRemoval);
coordinator.phase(CoordinatorPhase.PUBLISHING);
directoryRemoval.prepare();
coordinator.phase(CoordinatorPhase.PUBLISHED);
manifestWrite.publish();
} catch (IOException | RuntimeException removalFailure) {
boolean manifestCommitted = manifestWrite != null && manifestWrite.published();
if (manifestCommitted) {
finishCommittedRemoval(cleaned, manifestWrite, directoryRemoval, editableRemoval, coordinator,
removalFailure);
DatapackSupport.message(sender, C.GREEN + "Removed datapack '" + C.WHITE + cleaned + C.GREEN
+ "'. Restart for it to stop generating, and delete its URL from the pack's datapackImports to keep it gone.");
return RemoveOutcome.REMOVED;
}
boolean restored = rollbackRemoval(manifestWrite, directoryRemoval, editableRemoval, removalFailure);
if (restored && coordinator != null) {
try {
coordinator.finish();
} catch (IOException cleanupFailure) {
removalFailure.addSuppressed(cleanupFailure);
}
}
DatapackSupport.message(sender, C.RED + "Failed to remove datapack '" + cleaned
+ "'; Iris attempted to restore every prior location: " + removalFailure.getMessage());
IrisLogging.reportError(removalFailure);
// A mutation was attempted; even a successful rollback is not provably identical
// (the fingerprint does not cover datapacks/), so stay conservatively invalidated.
return RemoveOutcome.FAILED_AFTER_MUTATION;
}
finishCommittedRemoval(cleaned, manifestWrite, directoryRemoval, editableRemoval, coordinator, null);
DatapackSupport.message(sender, C.GREEN + "Removed datapack '" + C.WHITE + cleaned + C.GREEN
+ "'. Restart for it to stop generating, and delete its URL from the pack's datapackImports to keep it gone.");
return RemoveOutcome.REMOVED;
}
static void finishCommittedRemoval(
String id,
ManifestWrite manifestWrite,
DirectoryRemoval directoryRemoval,
EditableImportRemoval editableRemoval,
DatapackCoordinator coordinator,
Throwable priorFailure
) {
if (priorFailure != null) {
IOException recoveryFailure = new IOException(
"Datapack manifest committed before publication durability was confirmed",
priorFailure
);
if (editableRemoval != null) {
try {
editableRemoval.leaveForRecovery();
} catch (IOException releaseFailure) {
recoveryFailure.addSuppressed(releaseFailure);
}
}
try {
manifestWrite.discard();
} catch (IOException cleanupFailure) {
recoveryFailure.addSuppressed(cleanupFailure);
}
IrisLogging.reportError("Datapack '" + id
+ "' was removed but transaction cleanup requires restart recovery.", recoveryFailure);
return;
}
IOException failure = null;
if (coordinator != null) {
try {
coordinator.phase(CoordinatorPhase.COMMITTED);
} catch (IOException phaseFailure) {
failure = DatapackSupport.appendIOException(failure, phaseFailure);
if (editableRemoval != null) {
try {
editableRemoval.leaveForRecovery();
} catch (IOException releaseFailure) {
failure = DatapackSupport.appendIOException(failure, releaseFailure);
}
}
try {
manifestWrite.discard();
} catch (IOException cleanupFailure) {
failure = DatapackSupport.appendIOException(failure, cleanupFailure);
}
IrisLogging.reportError("Datapack '" + id
+ "' was removed but transaction cleanup requires restart recovery.", failure);
return;
}
}
if (editableRemoval != null) {
try {
editableRemoval.markCommitted();
editableRemoval.finishCommit();
} catch (IOException | RuntimeException cleanupFailure) {
failure = DatapackSupport.appendIOException(failure, cleanupFailure);
try {
editableRemoval.leaveForRecovery();
} catch (IOException releaseFailure) {
failure = DatapackSupport.appendIOException(failure, releaseFailure);
}
}
}
if (directoryRemoval != null) {
try {
directoryRemoval.finishCommit();
} catch (IOException | RuntimeException cleanupFailure) {
failure = DatapackSupport.appendIOException(failure, cleanupFailure);
}
}
try {
manifestWrite.discard();
} catch (IOException cleanupFailure) {
failure = DatapackSupport.appendIOException(failure, cleanupFailure);
}
if (failure == null && coordinator != null) {
try {
coordinator.finish();
} catch (IOException cleanupFailure) {
failure = cleanupFailure;
}
}
if (failure != null) {
IrisLogging.reportError("Datapack '" + id
+ "' was removed but transaction cleanup requires restart recovery.", failure);
}
}
static List<File> preflightRemovalTargets(File root, List<File> worldFolders, Entry entry) throws IOException {
List<File> targets = new ArrayList<>();
Set<Path> seen = new HashSet<>();
File staging = new File(root, "staging");
DatapackSupport.verifyDirectoryContainerIfPresent(staging, "datapack staging");
File stagedDir = new File(staging, entry.id);
verifyAndCollectRemovalTarget(stagedDir, entry, targets, seen);
for (File worldFolder : worldFolders) {
DatapackSupport.verifyDirectoryContainerIfPresent(worldFolder, "world datapacks");
verifyAndCollectRemovalTarget(new File(worldFolder, entry.id), entry, targets, seen);
}
return targets;
}
static void verifyAndCollectRemovalTarget(
File target,
Entry entry,
List<File> targets,
Set<Path> seen
) throws IOException {
verifyOwnedDirectoryIfPresent(target, entry);
Path normalized = target.toPath().toAbsolutePath().normalize();
if (DatapackSupport.pathExists(normalized, "datapack removal target")) {
Path identity = normalized.toRealPath();
if (!seen.add(identity)) {
throw new IOException("Aliased datapack removal target " + normalized);
}
targets.add(target);
}
}
static EditableImportRemoval prepareEntryImportRemoval(
Entry entry,
List<Entry> manifestEntries
) throws IOException {
List<PreparedEditableImport> prepared = new ArrayList<>();
Set<String> targetIdSet = new TreeSet<>(entry.importedBundles.keySet());
targetIdSet.addAll(entry.importedTargets.keySet());
targetIdSet.addAll(entry.importAttempts.keySet());
List<String> targetIds = new ArrayList<>(targetIdSet);
targetIds.sort(String::compareTo);
try {
for (String targetId : targetIds) {
Set<String> retainedKeys = invalidateRetainedImportClaims(
entry, targetId, manifestEntries);
File dataFolder = new File(targetId);
if (!dataFolder.isDirectory()) {
continue;
}
StructureTransactionWriter writer = new StructureTransactionWriter(dataFolder.toPath());
List<StructureTransactionWriter.OwnedRemoval> removals = ownedImportRemovals(
writer,
entry,
targetId,
retainedKeys
);
StructureTransactionWriter.PreparedRemoval removal =
writer.prepareMatchingOwnedRemovals(removals);
prepared.add(new PreparedEditableImport(dataFolder, removal));
}
return new EditableImportRemoval(prepared);
} catch (IOException | RuntimeException preparationFailure) {
IOException failure = preparationFailure instanceof IOException ioFailure
? ioFailure
: new IOException("Failed preparing editable datapack import cleanup", preparationFailure);
for (int i = prepared.size() - 1; i >= 0; i--) {
try {
prepared.get(i).removal().rollback();
} catch (IOException rollbackFailure) {
failure.addSuppressed(rollbackFailure);
}
}
throw failure;
}
}
static List<StructureTransactionWriter.OwnedRemoval> ownedImportRemovals(
StructureTransactionWriter writer,
Entry removingEntry,
String targetId,
Set<String> retainedKeys
) throws IOException {
List<StructureTransactionWriter.OwnedRemoval> removals = new ArrayList<>();
Map<String, Set<String>> removingClaims = importBundleClaims(removingEntry, targetId);
for (Map.Entry<String, Set<String>> bundle : removingClaims.entrySet()) {
try {
StructureKey targetKey = StructureKey.parse(bundle.getKey());
if (retainedKeys.contains(bundle.getKey())) {
continue;
}
Optional<StructureSource> ownedSource = writer.ownedSource(targetKey);
if (ownedSource.isEmpty() || !sourceClaimsContain(bundle.getValue(), ownedSource.get())) {
continue;
}
removals.add(StructureTransactionWriter.OwnedRemoval.managedDatapack(
targetKey,
ownedSource.get().kind(),
ownedSource.get().key()
));
} catch (RuntimeException e) {
throw new IOException("Invalid editable structure import inventory entry '"
+ bundle.getKey() + "' -> '" + bundle.getValue() + "'", e);
}
}
return List.copyOf(removals);
}
static Set<String> invalidateRetainedImportClaims(
Entry removingEntry,
String targetId,
List<Entry> manifestEntries
) {
Set<String> removingKeys = importBundleClaims(removingEntry, targetId).keySet();
Set<String> retainedKeys = new TreeSet<>();
for (Entry candidate : manifestEntries) {
if (candidate == removingEntry) {
continue;
}
Set<String> candidateKeys = importBundleClaims(candidate, targetId).keySet();
boolean candidateRetained = false;
for (String candidateKey : candidateKeys) {
if (removingKeys.contains(candidateKey)) {
retainedKeys.add(candidateKey);
candidateRetained = true;
}
}
if (candidateRetained) {
candidate.importedTargets.remove(targetId);
candidate.importAttempts.remove(targetId);
candidate.structuresImported = false;
}
}
return Set.copyOf(retainedKeys);
}
static Map<String, Set<String>> importBundleClaims(Entry entry, String targetId) {
Map<String, Set<String>> claims = new TreeMap<>();
addImportBundleClaims(claims, entry.importedBundles.getOrDefault(targetId, Map.of()));
if (entry.importedBundles.containsKey(targetId) || entry.importedTargets.containsKey(targetId)) {
addImportBundleClaims(claims, DatapackStructureImports.importBundleInventory(entry));
}
return claims;
}
static void addImportBundleClaims(
Map<String, Set<String>> claims,
Map<String, String> inventory
) {
for (Map.Entry<String, String> bundle : inventory.entrySet()) {
claims.computeIfAbsent(bundle.getKey(), ignored -> new TreeSet<>()).add(bundle.getValue());
}
}
static boolean sourceClaimsContain(Set<String> claims, StructureSource source) throws IOException {
for (String claimedKey : claims) {
try {
StructureKey sourceKey = StructureKey.parse(claimedKey);
StructureSource.Kind sourceKind = sourceKey.namespace().equals("minecraft")
? StructureSource.Kind.VANILLA : StructureSource.Kind.DATAPACK;
if (source.kind() == sourceKind && source.key().equals(sourceKey)) {
return true;
}
} catch (RuntimeException e) {
throw new IOException("Invalid editable structure source key '" + claimedKey + "'", e);
}
}
return false;
}
static DirectoryRemoval prepareOwnedDirectories(List<File> targets) throws IOException {
List<DirectoryMove> planned = new ArrayList<>();
for (File target : targets) {
File parent = target.getParentFile();
File backupRoot = new File(parent.getParentFile() == null ? parent : parent.getParentFile(), ".iris-datapack-remove");
File backup = new File(backupRoot, target.getName() + "-" + UUID.randomUUID());
DatapackSupport.ensureScratchDirectory(backupRoot, "datapack removal backup");
DatapackInstall.validateInstallTree(target, parent, "Datapack removal target");
planned.add(new DirectoryMove(
target,
backup,
DatapackOwnership.directoryHash(target),
DatapackOwnership.ownershipMarkerFingerprint(target),
DatapackSupport.directoryIdentity(target),
DatapackSupport.realDirectoryPath(parent, "datapack target root"),
DatapackSupport.realDirectoryPath(backupRoot, "datapack removal scratch root"),
DatapackSupport.directoryIdentity(parent),
DatapackSupport.directoryIdentity(backupRoot)
));
}
return new DirectoryRemoval(planned);
}
static boolean rollbackRemoval(
ManifestWrite manifestWrite,
DirectoryRemoval directoryRemoval,
EditableImportRemoval editableRemoval,
Throwable removalFailure
) {
if (manifestWrite != null) {
try {
manifestWrite.discard();
} catch (IOException discardFailure) {
removalFailure.addSuppressed(discardFailure);
}
}
if (directoryRemoval != null) {
try {
directoryRemoval.rollback();
} catch (IOException rollbackFailure) {
removalFailure.addSuppressed(rollbackFailure);
}
}
if (editableRemoval != null) {
try {
editableRemoval.rollback();
} catch (IOException rollbackFailure) {
removalFailure.addSuppressed(rollbackFailure);
}
}
return removalFailure.getSuppressed().length == 0;
}
static void verifyOwnedDirectoryIfPresent(File directory, Entry entry) throws IOException {
if (!Files.exists(directory.toPath(), LinkOption.NOFOLLOW_LINKS)) {
return;
}
if (Files.isSymbolicLink(directory.toPath())
|| !Files.isDirectory(directory.toPath(), LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Refusing non-directory or symbolic-link target " + directory.getPath());
}
Ownership ownership = DatapackOwnership.readOwnership(directory);
if (!DatapackInstall.ownershipSourceMatches(ownership, entry)) {
throw new IOException("Ownership marker at " + directory.getPath() + " belongs to '" + ownership.id + "'");
}
DatapackOwnership.removeFinderMetadata(directory);
if (!Objects.equals(ownership.contentHash, DatapackOwnership.directoryHash(directory))) {
throw new IOException("Refusing to remove modified or corrupt Iris-managed datapack " + directory.getPath());
}
}
}
@@ -0,0 +1,436 @@
/*
* 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.core.datapack;
import java.io.File;
import java.io.IOException;
import java.nio.file.FileVisitResult;
import java.nio.file.FileStore;
import java.nio.file.FileSystems;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;
import java.util.TreeMap;
import java.util.UUID;
import java.util.stream.Stream;
final class DatapackScratchRecovery {
static final int WINDOWS_LEGACY_PATH_LIMIT = 247;
private DatapackScratchRecovery() {
}
static boolean recoverTransactions(File root, List<File> worldFolders) throws IOException {
boolean changed = recoverStagingScratch(new File(root, "staging"));
File transactionDirectory = new File(root, DatapackTransactions.TRANSACTION_DIRECTORY);
Path transactionPath = transactionDirectory.toPath();
if (!Files.exists(transactionPath, LinkOption.NOFOLLOW_LINKS)) {
return recoverInstallScratch(root, worldFolders) | changed;
}
DatapackSupport.verifyDirectoryContainerIfPresent(transactionDirectory, "datapack transaction");
Manifest committedManifest = DatapackManifestStore.readCommittedManifest(root);
List<Path> transactionRoots;
try (Stream<Path> paths = Files.list(transactionPath)) {
transactionRoots = paths.limit(DatapackTransactions.MAX_TRANSACTION_COUNT + 2L).sorted().toList();
}
if (transactionRoots.size() == DatapackTransactions.MAX_TRANSACTION_COUNT + 2) {
throw new IOException("Datapack transaction directory contains too many entries");
}
int transactionCount = 0;
for (Path transactionRoot : transactionRoots) {
if (isHarmlessRecoveryArtifact(transactionRoot)) {
continue;
}
transactionCount++;
if (transactionCount > DatapackTransactions.MAX_TRANSACTION_COUNT) {
throw new IOException("Datapack transaction count exceeds " + DatapackTransactions.MAX_TRANSACTION_COUNT);
}
}
for (Path transactionRoot : transactionRoots) {
if (isHarmlessRecoveryArtifact(transactionRoot)) {
changed |= Files.deleteIfExists(transactionRoot);
continue;
}
recoverTransaction(root, worldFolders, committedManifest, transactionPath, transactionRoot);
changed = true;
}
changed |= transactionDirectory.delete();
return recoverInstallScratch(root, worldFolders) | changed;
}
static boolean recoverInstallScratch(File root, List<File> worldFolders) throws IOException {
Set<Path> scratchRoots = new TreeSet<>();
scratchRoots.add(DatapackInstall.installScratchRoot(new File(root, "staging")).toPath().toAbsolutePath().normalize());
for (File worldFolder : worldFolders) {
scratchRoots.add(DatapackInstall.installScratchRoot(worldFolder).toPath().toAbsolutePath().normalize());
}
boolean changed = false;
for (Path scratchRoot : scratchRoots) {
changed |= recoverInstallScratchRoot(scratchRoot);
}
return changed;
}
static boolean recoverInstallScratchRoot(Path scratchRoot) throws IOException {
if (!Files.exists(scratchRoot, LinkOption.NOFOLLOW_LINKS)) {
return false;
}
DatapackSupport.verifyDirectoryContainerIfPresent(scratchRoot.toFile(), "datapack install scratch");
List<Path> children;
try (Stream<Path> paths = Files.list(scratchRoot)) {
children = paths.limit(DatapackOwnership.MAX_MANAGED_PATHS + 2L).sorted().toList();
}
int managedEntries = 0;
for (Path child : children) {
if (!isHarmlessRecoveryArtifact(child)) {
managedEntries++;
}
if (managedEntries > DatapackOwnership.MAX_MANAGED_PATHS) {
throw new IOException("Datapack install scratch contains too many entries");
}
}
List<StagingScratch> pending = new ArrayList<>();
List<StagingScratch> backups = new ArrayList<>();
boolean changed = false;
for (Path child : children) {
if (isHarmlessRecoveryArtifact(child)) {
changed |= Files.deleteIfExists(child);
continue;
}
StagingScratch scratch = parseInstallScratch(scratchRoot, child);
if (scratch == null) {
throw new IOException("Unexpected datapack install scratch artifact " + child);
}
if (Files.isSymbolicLink(child) || !Files.isDirectory(child, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Invalid datapack install scratch artifact " + child);
}
validateScratchTree(child);
if (scratch.kind() == StagingScratchKind.BACKUP) {
backups.add(scratch);
} else {
pending.add(scratch);
}
}
if (!backups.isEmpty()) {
throw new IOException("Preserving unjournaled datapack install backup "
+ backups.getFirst().path());
}
for (StagingScratch scratch : pending) {
DatapackInstall.deleteInstallScratch(scratch.path().toFile(), "orphan datapack install pending directory");
changed = true;
}
changed |= scratchRoot.toFile().delete();
return changed;
}
static StagingScratch parseInstallScratch(Path scratchRoot, Path child) throws IOException {
Path normalized = child.toAbsolutePath().normalize();
if (!Objects.equals(normalized.getParent(), scratchRoot)) {
throw new IOException("Datapack install scratch artifact escapes its root: " + child);
}
String name = normalized.getFileName().toString();
int uuidStart = name.length() - 36;
if (uuidStart <= 1 || name.charAt(uuidStart - 1) != '-') {
return null;
}
try {
UUID.fromString(name.substring(uuidStart));
} catch (IllegalArgumentException e) {
return null;
}
String stem = name.substring(0, uuidStart - 1);
StagingScratchKind kind = stem.endsWith("-backup")
? StagingScratchKind.BACKUP : StagingScratchKind.PENDING;
String id = kind == StagingScratchKind.BACKUP
? stem.substring(0, stem.length() - "-backup".length()) : stem;
if (id.isBlank() || !id.equals(DatapackArchive.sanitizeId(id)) || DatapackArchive.RESERVED_IDS.contains(id)) {
return null;
}
return new StagingScratch(kind, id, normalized);
}
static boolean recoverStagingScratch(File stagingDirectory) throws IOException {
Path stagingRoot = stagingDirectory.toPath().toAbsolutePath().normalize();
if (!Files.exists(stagingRoot, LinkOption.NOFOLLOW_LINKS)) {
return false;
}
DatapackSupport.verifyDirectoryContainerIfPresent(stagingDirectory, "datapack staging");
List<Path> children;
try (Stream<Path> paths = Files.list(stagingRoot)) {
children = paths.limit(DatapackOwnership.MAX_MANAGED_PATHS + 1L).sorted().toList();
}
if (children.size() > DatapackOwnership.MAX_MANAGED_PATHS) {
throw new IOException("Datapack staging contains too many entries");
}
List<StagingScratch> pending = new ArrayList<>();
Map<String, List<StagingScratch>> backups = new TreeMap<>();
for (Path child : children) {
StagingScratch scratch = parseStagingScratch(stagingRoot, child);
if (scratch == null) {
continue;
}
if (Files.isSymbolicLink(child) || !Files.isDirectory(child, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Invalid datapack staging scratch artifact " + child);
}
if (scratch.kind() == StagingScratchKind.PENDING) {
validateScratchTree(child);
pending.add(scratch);
} else {
backups.computeIfAbsent(scratch.id(), ignored -> new ArrayList<>()).add(scratch);
}
}
for (Map.Entry<String, List<StagingScratch>> entry : backups.entrySet()) {
if (entry.getValue().size() != 1) {
throw new IOException("Ambiguous datapack staging backups for '" + entry.getKey() + "'");
}
StagingScratch backup = entry.getValue().getFirst();
Ownership backupOwnership = verifyManagedScratchDirectory(backup.path().toFile(), backup.id());
Path target = stagingRoot.resolve(backup.id()).normalize();
if (!Objects.equals(target.getParent(), stagingRoot)) {
throw new IOException("Datapack staging backup target escapes its root");
}
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
Ownership targetOwnership = verifyManagedScratchDirectory(target.toFile(), backup.id());
if (!Objects.equals(backupOwnership.url, targetOwnership.url)) {
throw new IOException("Datapack staging backup source does not match its target: " + target);
}
}
}
boolean changed = false;
for (List<StagingScratch> matches : backups.values()) {
StagingScratch backup = matches.getFirst();
Path target = stagingRoot.resolve(backup.id()).normalize();
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
DatapackCoordinatorResolution.deleteVerifiedDirectory(backup.path().toFile());
} else {
DatapackSupport.moveNew(backup.path(), target);
}
changed = true;
}
for (StagingScratch scratch : pending) {
DatapackCoordinatorResolution.deleteVerifiedDirectory(scratch.path().toFile());
changed = true;
}
DatapackSupport.forceDirectoryIfSupported(stagingRoot);
return changed;
}
static StagingScratch parseStagingScratch(Path stagingRoot, Path child) throws IOException {
Path normalized = child.toAbsolutePath().normalize();
if (!Objects.equals(normalized.getParent(), stagingRoot)) {
throw new IOException("Datapack staging artifact escapes its root: " + child);
}
String name = normalized.getFileName().toString();
StagingScratchKind kind;
String prefix;
if (name.startsWith(".pending-")) {
kind = StagingScratchKind.PENDING;
prefix = ".pending-";
} else if (name.startsWith(".backup-")) {
kind = StagingScratchKind.BACKUP;
prefix = ".backup-";
} else {
return null;
}
int uuidStart = name.length() - 36;
if (uuidStart <= prefix.length() || name.charAt(uuidStart - 1) != '-') {
return null;
}
String id = name.substring(prefix.length(), uuidStart - 1);
if (!id.equals(DatapackArchive.sanitizeId(id)) || DatapackArchive.RESERVED_IDS.contains(id)) {
return null;
}
try {
UUID.fromString(name.substring(uuidStart));
} catch (IllegalArgumentException e) {
return null;
}
return new StagingScratch(kind, id, normalized);
}
static void validateScratchTree(Path root) throws IOException {
FileStore rootStore = Files.getFileStore(root);
int[] pathCount = new int[]{0};
Files.walkFileTree(root, new SimpleFileVisitor<>() {
private FileVisitResult inspect(Path entry, BasicFileAttributes attributes) throws IOException {
pathCount[0]++;
if (pathCount[0] > DatapackOwnership.MAX_MANAGED_PATHS) {
throw new IOException("Datapack scratch contains too many paths: " + root);
}
if (attributes.isSymbolicLink()
|| attributes.isOther()
|| (!attributes.isDirectory() && !attributes.isRegularFile())) {
throw new IOException("Datapack scratch contains an unsupported file: " + entry);
}
if (!DatapackOwnership.sameDatapackVolume(root, rootStore, entry)) {
throw new IOException("Datapack scratch crosses a filesystem boundary: " + entry);
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult preVisitDirectory(
Path directory,
BasicFileAttributes attributes
) throws IOException {
return inspect(directory, attributes);
}
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException {
return inspect(file, attributes);
}
@Override
public FileVisitResult visitFileFailed(Path file, IOException failure) throws IOException {
throw new IOException("Unable to inspect datapack scratch entry: " + file, failure);
}
});
}
static boolean sameScratchVolume(Path first, Path second) throws IOException {
return sameScratchVolume(first, Files.getFileStore(first), second, Files.getFileStore(second));
}
static boolean sameScratchVolume(
Path first,
FileStore firstStore,
Path second,
FileStore secondStore
) {
if (Objects.equals(firstStore, secondStore)) {
return true;
}
if (!isDefaultWindowsPath(first) || !isDefaultWindowsPath(second)) {
return false;
}
Path firstAbsolute = first.toAbsolutePath().normalize();
Path secondAbsolute = second.toAbsolutePath().normalize();
if ((firstAbsolute.toString().length() > WINDOWS_LEGACY_PATH_LIMIT)
== (secondAbsolute.toString().length() > WINDOWS_LEGACY_PATH_LIMIT)) {
return false;
}
Path firstRoot = firstAbsolute.getRoot();
Path secondRoot = secondAbsolute.getRoot();
if (firstRoot == null || secondRoot == null) {
return false;
}
return sameWindowsVolume(
firstStore, firstRoot.toString(), secondStore, secondRoot.toString());
}
static boolean sameWindowsVolume(
FileStore firstStore,
String firstRoot,
FileStore secondStore,
String secondRoot
) {
if (firstRoot == null || secondRoot == null || !firstRoot.equalsIgnoreCase(secondRoot)) {
return false;
}
try {
Object firstSerial = firstStore.getAttribute("volume:vsn");
Object secondSerial = secondStore.getAttribute("volume:vsn");
return firstSerial != null && firstSerial.equals(secondSerial);
} catch (IOException | RuntimeException exception) {
return false;
}
}
static boolean isSupportedScratchDirectory(BasicFileAttributes attributes) {
return attributes != null
&& attributes.isDirectory()
&& !attributes.isSymbolicLink()
&& !attributes.isOther();
}
static boolean isDefaultWindowsPath(Path path) {
return File.separatorChar == '\\'
&& path.getFileSystem().equals(FileSystems.getDefault());
}
static Ownership verifyManagedScratchDirectory(File directory, String id) throws IOException {
DatapackOwnership.validateManagedDirectory(directory, id);
Ownership ownership = DatapackOwnership.readOwnership(directory);
if (!Objects.equals(ownership.contentHash, DatapackOwnership.directoryHash(directory))) {
throw new IOException("Datapack staging backup is modified or corrupt: " + directory.getPath());
}
return ownership;
}
static boolean isHarmlessRecoveryArtifact(Path path) throws IOException {
if (!DatapackOwnership.isFinderMetadata(path)) {
return false;
}
if (Files.isSymbolicLink(path) || !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Suspicious datapack recovery artifact " + path);
}
return true;
}
static void recoverTransaction(
File root,
List<File> worldFolders,
Manifest committedManifest,
Path transactionDirectory,
Path transactionRoot
) throws IOException {
Path normalizedRoot = transactionRoot.toAbsolutePath().normalize();
if (!Objects.equals(normalizedRoot.getParent(), transactionDirectory.toAbsolutePath().normalize())
|| Files.isSymbolicLink(normalizedRoot)
|| !Files.isDirectory(normalizedRoot, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Invalid datapack transaction directory " + transactionRoot);
}
try {
UUID.fromString(normalizedRoot.getFileName().toString());
} catch (IllegalArgumentException e) {
throw new IOException("Invalid datapack transaction directory name " + transactionRoot, e);
}
CoordinatorJournal journal = DatapackTransactions.readCoordinatorJournal(normalizedRoot);
if (journal == null) {
DatapackTransactions.deleteCoordinatorTransaction(normalizedRoot);
return;
}
DatapackTransactions.validateCoordinatorJournal(root, worldFolders, committedManifest, normalizedRoot, journal);
boolean commit = DatapackCoordinatorResolution.coordinatorCommitDecision(committedManifest, journal);
DatapackCoordinatorResolution.resolveCoordinatorDirectories(journal, commit);
DatapackCoordinatorResolution.resolveCoordinatorEditables(journal, normalizedRoot, commit);
DatapackTransactions.deleteCoordinatorTransaction(normalizedRoot);
}
enum StagingScratchKind {
PENDING,
BACKUP
}
record StagingScratch(StagingScratchKind kind, String id, Path path) {
}
}
@@ -0,0 +1,256 @@
/*
* 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.core.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Report;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.util.common.format.C;
import art.arcane.iris.util.common.plugin.VolmitSender;
import art.arcane.volmlib.util.collection.KList;
import java.io.File;
import java.io.IOException;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
final class DatapackStagingGuard {
private DatapackStagingGuard() {
}
static VerifiedStagingInstall authorizeVerifiedStagingInstall(
File root,
File stagingRoot,
File verifiedSource,
Entry entry
) throws IOException {
Path normalizedRoot = DatapackSupport.requireDirectoryIdentity(root, "datapack storage root");
Path normalizedStagingRoot = DatapackSupport.requireDirectoryIdentity(stagingRoot, "datapack staging root");
DatapackSupport.requireNoSymbolicLinkComponents(normalizedRoot, "datapack storage root");
DatapackSupport.requireNoSymbolicLinkComponents(normalizedStagingRoot, "datapack staging root");
Path expectedStagingRoot = normalizedRoot.resolve("staging").normalize();
if (!normalizedStagingRoot.equals(expectedStagingRoot)
|| !Files.isSameFile(normalizedStagingRoot, expectedStagingRoot)) {
throw new IOException("Verified datapack staging root is not Iris's canonical staging directory");
}
Path normalizedSource = DatapackSupport.requireDirectoryIdentity(verifiedSource, "verified datapack extraction");
DatapackSupport.requireNoSymbolicLinkComponents(normalizedSource, "verified datapack extraction");
if (!Objects.equals(normalizedSource.getParent(), normalizedStagingRoot)
|| !Files.isSameFile(normalizedSource.getParent(), normalizedStagingRoot)) {
throw new IOException("Verified datapack extraction is outside Iris's canonical staging directory");
}
verifyPendingExtractionName(normalizedSource.getFileName().toString(), entry.id);
DatapackOwnership.validateManagedDirectory(verifiedSource, entry.id);
DatapackScratchRecovery.validateScratchTree(normalizedSource);
if (!DatapackScratchRecovery.sameScratchVolume(normalizedSource, normalizedStagingRoot)) {
throw new IOException("Verified datapack extraction crosses a filesystem boundary");
}
String desiredHash = DatapackOwnership.directoryHash(verifiedSource);
Ownership ownership = DatapackOwnership.readOwnership(verifiedSource);
if (!DatapackInstall.ownershipMetadataMatches(ownership, entry, desiredHash)) {
throw new IOException("Verified datapack extraction does not match the resolved archive metadata");
}
Manifest committedManifest = DatapackManifestStore.readCommittedManifest(root);
Entry committed = committedManifest.findById(entry.id);
boolean legacyReplacementAuthorized = committed != null
&& Objects.equals(committed.id, entry.id)
&& Objects.equals(committed.url, entry.url);
LegacyStagingSnapshot legacyStagingSnapshot = captureLegacyStagingSnapshot(
normalizedStagingRoot, entry.id, legacyReplacementAuthorized);
return new VerifiedStagingInstall(
normalizedRoot,
normalizedStagingRoot,
normalizedSource,
entry,
desiredHash,
committed,
legacyReplacementAuthorized,
legacyStagingSnapshot
);
}
static LegacyStagingSnapshot captureLegacyStagingSnapshot(
Path normalizedStagingRoot,
String id,
boolean legacyReplacementAuthorized
) throws IOException {
if (!legacyReplacementAuthorized) {
return null;
}
Path target = normalizedStagingRoot.resolve(id).normalize();
if (!DatapackSupport.pathExists(target, "legacy datapack staging target")) {
return null;
}
if (Files.isSymbolicLink(target)
|| !Files.isDirectory(target, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Invalid legacy datapack staging target " + target);
}
DatapackInstall.validateInstallTree(target.toFile(), normalizedStagingRoot.toFile(), "Legacy datapack staging");
String markerHash = DatapackOwnership.ownershipMarkerFingerprint(target.toFile());
if (!"absent".equals(markerHash)) {
return null;
}
return new LegacyStagingSnapshot(
target,
target.toRealPath(),
DatapackSupport.directoryIdentity(target.toFile()),
DatapackOwnership.directoryHash(target.toFile()),
markerHash
);
}
static void verifyPendingExtractionName(String name, String id) throws IOException {
String prefix = ".pending-" + id + "-";
if (!name.startsWith(prefix)) {
throw new IOException("Verified datapack extraction has an invalid staging name " + name);
}
try {
UUID.fromString(name.substring(prefix.length()));
} catch (IllegalArgumentException e) {
throw new IOException("Verified datapack extraction has an invalid staging identity " + name, e);
}
}
static void validateInstallParticipants(
List<File> worldFolders,
VerifiedStagingInstall verifiedStagingInstall,
Entry entry
) throws IOException {
List<File> roots = new ArrayList<>(worldFolders);
if (verifiedStagingInstall != null) {
roots.add(verifiedStagingInstall.stagingRoot().toFile());
}
Set<Path> normalizedTargets = new HashSet<>();
Set<Path> realRoots = new HashSet<>();
Set<Path> targetIdentities = new HashSet<>();
List<Path> existingTargets = new ArrayList<>();
for (File folder : roots) {
ensureInstallTargetRoot(folder);
Path normalizedRoot = folder.toPath().toAbsolutePath().normalize();
Path realRoot = normalizedRoot.toRealPath();
Path normalizedTarget = normalizedRoot.resolve(entry.id).normalize();
if (!realRoots.add(realRoot) || !normalizedTargets.add(normalizedTarget)) {
throw new IOException("Duplicate datapack install target " + normalizedTarget);
}
Path targetIdentity = realRoot.resolve(entry.id).normalize();
if (Files.exists(normalizedTarget, LinkOption.NOFOLLOW_LINKS)) {
if (Files.isSymbolicLink(normalizedTarget)
|| !Files.isDirectory(normalizedTarget, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Refusing invalid datapack install target " + normalizedTarget);
}
targetIdentity = normalizedTarget.toRealPath();
for (Path existingTarget : existingTargets) {
if (Files.isSameFile(normalizedTarget, existingTarget)) {
throw new IOException("Aliased datapack install target " + normalizedTarget);
}
}
existingTargets.add(normalizedTarget);
}
if (!targetIdentities.add(targetIdentity)) {
throw new IOException("Aliased datapack install target " + normalizedTarget);
}
}
if (verifiedStagingInstall != null) {
verifiedStagingInstall.verifyStagingRoot();
}
}
static void ensureInstallTargetRoot(File directory) throws IOException {
if (!Files.exists(directory.toPath(), LinkOption.NOFOLLOW_LINKS)) {
Files.createDirectories(directory.toPath());
}
DatapackSupport.requireDirectoryIdentity(directory, "datapack install root");
}
static void recordInstallResult(
VolmitSender sender,
Report report,
File stagedDir,
KList<File> worldFolders,
Entry entry,
InstallResult result,
String versionNumber
) {
DatapackStagingReapply.recordInstallMetadata(stagedDir, worldFolders, entry);
if (result.changed()) {
report.updated.add(entry.id + " (" + DatapackSupport.safe(versionNumber) + ")");
report.requiresRestart = true;
DatapackSupport.message(sender, C.GREEN + " Repaired " + C.WHITE + entry.id + C.GREEN + " " + DatapackSupport.safe(versionNumber));
return;
}
report.upToDate.add(entry.id + " (" + DatapackSupport.safe(versionNumber) + ")");
DatapackSupport.message(sender, C.GRAY + " Up to date: " + C.WHITE + entry.id + C.GRAY + " " + DatapackSupport.safe(versionNumber));
}
static boolean isUsableStaging(File stagedDir, Entry entry) {
return inspectUsableStaging(stagedDir, entry).usable();
}
static StagingInspection inspectUsableStaging(File stagedDir, Entry entry) {
try {
DatapackOwnership.validateManagedDirectory(stagedDir, entry.id);
Ownership ownership = DatapackOwnership.readOwnership(stagedDir);
String contentHash = DatapackOwnership.directoryHash(stagedDir);
if (!DatapackInstall.ownershipSourceMatches(ownership, entry)
|| !Objects.equals(ownership.versionId, entry.versionId)
|| !Objects.equals(ownership.versionNumber, entry.versionNumber)
|| !Objects.equals(ownership.sha1, entry.sha1)
|| !Objects.equals(ownership.contentHash, contentHash)) {
return new StagingInspection(false, false, false);
}
PackResources resources = DatapackOwnership.scanPackResources(stagedDir);
List<String> previousStructureKeys = DatapackManifestStore.copyList(entry.structureKeys);
List<String> previousTemplateKeys = DatapackManifestStore.copyList(entry.templateKeys);
boolean ownershipCorrected = false;
if (!DatapackManifestStore.copyList(resources.structureKeys()).equals(DatapackManifestStore.copyList(ownership.structureKeys))
|| !DatapackManifestStore.copyList(resources.templateKeys()).equals(DatapackManifestStore.copyList(ownership.templateKeys))) {
Entry corrected = DatapackManifestStore.copyEntry(entry);
corrected.structureKeys = resources.structureKeys();
corrected.templateKeys = resources.templateKeys();
DatapackOwnership.writeOwnership(stagedDir, corrected);
ownershipCorrected = true;
}
entry.structureKeys = resources.structureKeys();
entry.templateKeys = resources.templateKeys();
boolean manifestChanged = !previousStructureKeys.equals(DatapackManifestStore.copyList(entry.structureKeys))
|| !previousTemplateKeys.equals(DatapackManifestStore.copyList(entry.templateKeys));
return new StagingInspection(true, ownershipCorrected, manifestChanged);
} catch (IOException e) {
IrisLogging.warn("Ignoring unusable Iris datapack staging at " + stagedDir.getPath() + ": " + e.getMessage());
return new StagingInspection(false, false, false);
}
}
record LegacyStagingSnapshot(
Path normalizedTarget,
Path realTarget,
String targetIdentity,
String contentHash,
String markerHash
) {
}
}
@@ -0,0 +1,312 @@
/*
* 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.core.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.ReapplyOutcome;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.volmlib.util.collection.KList;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Stream;
final class DatapackStagingReapply {
private DatapackStagingReapply() {
}
static ReapplyOutcome reapplyFromStagingLocked(KList<File> worldFolders) {
File root = IrisPlatforms.get().dataFolder("datapacks");
ReapplyOutcome recovery = recoverBeforeReapplyOutcome(root, worldFolders);
if (!recovery.succeeded()) {
return reportReapplyFailure(recovery);
}
File stagingDir = IrisPlatforms.get().dataFolderNoCreate("datapacks", "staging");
ReapplyOutcome repair = reapplyStagingRootOutcome(
root,
stagingDir,
worldFolders,
DatapackPackMetadata.resolveStripOverrides());
if (!repair.succeeded()) {
return reportReapplyFailure(repair);
}
return ReapplyOutcome.success(recovery.recovered(), repair.repaired());
}
static boolean reapplyStagingRoot(
File root,
File stagingDir,
KList<File> worldFolders,
boolean stripOverrides
) {
return reportReapplyFailure(reapplyStagingRootOutcome(
root,
stagingDir,
worldFolders,
stripOverrides)).succeeded();
}
static ReapplyOutcome reapplyStagingRootOutcome(
File root,
File stagingDir,
KList<File> worldFolders,
boolean stripOverrides
) {
Manifest manifest = DatapackManifestStore.readManifest(root);
if (stagingDir == null
|| !Files.exists(stagingDir.toPath(), LinkOption.NOFOLLOW_LINKS)) {
if (manifest.entries.isEmpty()) {
return ReapplyOutcome.success(false, false);
}
File missing = stagingDir == null ? new File(root, "staging") : stagingDir;
return ReapplyOutcome.failed(new IOException(
"Managed datapack staging is missing at " + missing.getPath()));
}
if (Files.isSymbolicLink(stagingDir.toPath())
|| !Files.isDirectory(stagingDir.toPath(), LinkOption.NOFOLLOW_LINKS)) {
return ReapplyOutcome.failed(new IOException(
"Managed datapack staging is not a safe directory at " + stagingDir.getPath()));
}
return reapplyStagedDirectoriesOutcome(
root, stagingDir, worldFolders, stripOverrides, manifest);
}
static boolean reapplyStagedDirectories(
File root,
File stagingDir,
KList<File> worldFolders,
boolean stripOverrides
) {
if (Files.isSymbolicLink(stagingDir.toPath())
|| !Files.isDirectory(stagingDir.toPath(), LinkOption.NOFOLLOW_LINKS)) {
return reportReapplyFailure(ReapplyOutcome.failed(new IOException(
"Managed datapack staging is not a safe directory at "
+ stagingDir.getPath()))).succeeded();
}
return reportReapplyFailure(reapplyStagedDirectoriesOutcome(
root,
stagingDir,
worldFolders,
stripOverrides,
DatapackManifestStore.readManifest(root))).succeeded();
}
static ReapplyOutcome reapplyStagedDirectoriesOutcome(
File root,
File stagingDir,
KList<File> worldFolders,
boolean stripOverrides,
Manifest manifest
) {
File[] staged = stagingDir.listFiles(File::isDirectory);
if (staged == null) {
return ReapplyOutcome.failed(new IOException(
"Unable to enumerate managed datapack staging at " + stagingDir.getPath()));
}
boolean repaired = false;
IOException failure = null;
for (Entry entry : manifest.entries) {
File stagedDir = new File(stagingDir, entry.id);
if (isRecordedUnchangedInstall(stagedDir, worldFolders, entry, stripOverrides)) {
continue;
}
if (!DatapackStagingGuard.isUsableStaging(stagedDir, entry)) {
forgetInstallMetadata(entry);
failure = DatapackSupport.appendFailure(failure, new IOException(
"Managed datapack staging is unusable for '" + entry.id
+ "' at " + stagedDir.getPath()));
continue;
}
try {
InstallResult result = DatapackInstall.install(stagedDir, worldFolders, entry, stripOverrides);
if (result.changed()) {
repaired = true;
IrisLogging.warn("Repaired installed datapack '" + entry.id
+ "' from Iris staging before datapack compilation.");
}
recordInstallMetadata(stagedDir, worldFolders, entry);
} catch (IOException e) {
forgetInstallMetadata(entry);
failure = DatapackSupport.appendFailure(failure, e);
}
}
DatapackManifestStore.writeManifest(root, manifest);
return failure == null
? ReapplyOutcome.success(false, repaired)
: ReapplyOutcome.failed(failure);
}
static ReapplyOutcome reportReapplyFailure(ReapplyOutcome outcome) {
if (!outcome.succeeded()) {
IrisLogging.reportError(
"External datapack recovery or staging repair failed.",
outcome.failure().orElseThrow());
}
return outcome;
}
static boolean isRecordedUnchangedInstall(
File stagedDir,
KList<File> worldFolders,
Entry entry,
boolean stripOverrides
) {
if (entry.stagingMetadata == null || entry.stagingMetadata.isBlank()
|| entry.installMetadata == null || entry.installMetadata.size() != worldFolders.size()) {
return false;
}
try {
if (!isRecordedManagedDirectory(stagedDir, entry)
|| !entry.stagingMetadata.equals(metadataDigest(stagedDir))) {
return false;
}
for (File worldFolder : worldFolders) {
File target = new File(worldFolder, entry.id);
if (!isRecordedManagedDirectory(target, entry)
|| new File(target, DatapackPackMetadata.OVERRIDES_STRIPPED_MARKER).isFile() != stripOverrides) {
return false;
}
String recorded = entry.installMetadata.get(installMetadataKey(target));
if (recorded == null || !recorded.equals(metadataDigest(target))) {
return false;
}
}
return true;
} catch (IOException e) {
IrisLogging.reportError("Managed datapack '" + entry.id
+ "' requires full verification", e);
return false;
}
}
static boolean isRecordedManagedDirectory(File directory, Entry entry) throws IOException {
Path path = directory.toPath();
if (!Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(path)) {
return false;
}
if (!new File(directory, "pack.mcmeta").isFile()) {
return false;
}
Ownership ownership = DatapackOwnership.readOwnershipOrNull(directory);
return ownership != null
&& DatapackInstall.ownershipSourceMatches(ownership, entry)
&& Objects.equals(ownership.versionId, entry.versionId)
&& Objects.equals(ownership.versionNumber, entry.versionNumber)
&& Objects.equals(ownership.sha1, entry.sha1);
}
static void recordInstallMetadata(File stagedDir, KList<File> worldFolders, Entry entry) {
try {
Map<String, String> recorded = new HashMap<>();
for (File worldFolder : worldFolders) {
File target = new File(worldFolder, entry.id);
recorded.put(installMetadataKey(target), metadataDigest(target));
}
entry.stagingMetadata = metadataDigest(stagedDir);
entry.installMetadata = recorded;
} catch (IOException e) {
IrisLogging.reportError("Unable to record managed datapack metadata for '" + entry.id + "'", e);
forgetInstallMetadata(entry);
}
}
static void forgetInstallMetadata(Entry entry) {
entry.stagingMetadata = "";
entry.installMetadata = new HashMap<>();
}
static String installMetadataKey(File target) {
return target.toPath().toAbsolutePath().normalize().toString();
}
static String metadataDigest(File root) throws IOException {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
Path rootPath = root.toPath().toAbsolutePath().normalize();
List<MetadataEntry> entries = new ArrayList<>();
try (Stream<Path> paths = Files.walk(rootPath)) {
Iterator<Path> iterator = paths.iterator();
int pathCount = 0;
while (iterator.hasNext()) {
Path path = iterator.next();
if (path.equals(rootPath) || DatapackOwnership.isFinderMetadata(path)) {
continue;
}
pathCount++;
if (pathCount > DatapackOwnership.MAX_MANAGED_PATHS) {
throw new IOException("Datapack contains more than " + DatapackOwnership.MAX_MANAGED_PATHS + " paths");
}
if (Files.isSymbolicLink(path)) {
throw new IOException("Datapack contains a symbolic link: " + path);
}
String relativePath = rootPath.relativize(path).toString();
entries.add(new MetadataEntry(path, relativePath));
}
}
entries.sort(Comparator.comparing(MetadataEntry::relativePath));
for (MetadataEntry entry : entries) {
String relative = entry.relativePath().replace(File.separatorChar, '/');
byte[] relativeBytes = relative.getBytes(StandardCharsets.UTF_8);
BasicFileAttributes attributes = Files.readAttributes(
entry.path(), BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
if (!attributes.isDirectory() && !attributes.isRegularFile()) {
throw new IOException("Datapack contains an unsupported filesystem entry: " + entry.path());
}
digest.update((byte) (attributes.isDirectory() ? 1 : 2));
DatapackSupport.updateDigestInt(digest, relativeBytes.length);
digest.update(relativeBytes);
if (!attributes.isDirectory()) {
DatapackSupport.updateDigestLong(digest, attributes.size());
DatapackSupport.updateDigestLong(digest, attributes.lastModifiedTime().toMillis());
}
}
return DatapackSupport.hex(digest.digest());
} catch (NoSuchAlgorithmException e) {
throw new IOException("SHA-256 algorithm unavailable", e);
}
}
static boolean recoverBeforeReapply(File root, List<File> worldFolders) {
return reportReapplyFailure(recoverBeforeReapplyOutcome(root, worldFolders)).succeeded();
}
static ReapplyOutcome recoverBeforeReapplyOutcome(File root, List<File> worldFolders) {
try {
return ReapplyOutcome.success(DatapackScratchRecovery.recoverTransactions(root, worldFolders), false);
} catch (IOException e) {
return ReapplyOutcome.failed(e);
}
}
}
@@ -0,0 +1,385 @@
/*
* 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.core.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.core.IrisStartupValidation;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.volmlib.util.collection.KList;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
final class DatapackStartupValidation {
static final String STARTUP_VALIDATION_CACHE = "startup-validation.json";
static final int STARTUP_VALIDATION_SCHEMA = 1;
static volatile StartupValidationCache activeStartupValidation;
private DatapackStartupValidation() {
}
static StartupValidationCache cacheStartupValidation(
File root,
KList<File> worldFolders,
Path cacheFile,
String mcVersion,
int irisVersion,
boolean autoIngest,
boolean stripOverrides,
List<String> urls
) {
try {
StartupValidationCache cache = createStartupValidationCache(
mcVersion,
irisVersion,
autoIngest,
stripOverrides,
urls,
startupValidationFingerprint(root, worldFolders, urls));
writeStartupValidationCache(cacheFile, cache);
return cache;
} catch (IOException | RuntimeException exception) {
IrisLogging.warn("Could not persist external datapack startup validation: "
+ DatapackSupport.failureMessage(exception));
return null;
}
}
static void refreshStartupValidationAfterMaintenance(boolean maintenanceChanged) {
if (!maintenanceChanged) {
return;
}
StartupValidationCache validated = activeStartupValidation;
if (validated == null || !IrisStartupValidation.isReady()) {
return;
}
KList<String> configured = DatapackIngestService.collectConfiguredImports();
List<String> urls = configured.stream().sorted().toList();
boolean autoIngest = IrisSettings.get().getGeneral().autoIngestDatapacks;
boolean stripOverrides = DatapackPackMetadata.resolveStripOverrides();
String mcVersion = DatapackIngestService.serverMcVersion();
int irisVersion = IrisPlatforms.get().irisVersionNumber();
if (!startupValidationContextMatches(
validated, mcVersion, irisVersion, autoIngest, stripOverrides, urls)) {
return;
}
File root = IrisPlatforms.get().dataFolder("datapacks");
KList<File> worldFolders = ServerConfigurator.getDatapacksFolder();
Path cacheFile = new File(root, STARTUP_VALIDATION_CACHE).toPath();
DatapackTransactions.TRANSACTION_LOCK.lock();
try {
DatapackScratchRecovery.recoverTransactions(root, worldFolders);
StartupValidationCache refreshed = refreshStartupValidationCache(
validated, root, worldFolders);
writeStartupValidationCache(cacheFile, refreshed);
activeStartupValidation = refreshed;
} catch (IOException | RuntimeException exception) {
IrisLogging.warn("Could not refresh external datapack validation after startup maintenance: "
+ DatapackSupport.failureMessage(exception));
} finally {
DatapackTransactions.TRANSACTION_LOCK.unlock();
}
}
static StartupValidationCache refreshStartupValidationCache(
StartupValidationCache validated,
File root,
KList<File> worldFolders
) throws IOException {
Objects.requireNonNull(validated, "Validated external datapack startup state");
return createStartupValidationCache(
validated.minecraftVersion,
validated.irisVersion,
validated.autoIngest,
validated.stripOverrides,
validated.urls,
startupValidationFingerprint(root, worldFolders, validated.urls));
}
static StartupValidationCache createStartupValidationCache(
String mcVersion,
int irisVersion,
boolean autoIngest,
boolean stripOverrides,
List<String> urls,
String localFingerprint
) {
StartupValidationCache cache = new StartupValidationCache();
cache.schemaVersion = STARTUP_VALIDATION_SCHEMA;
cache.minecraftVersion = Objects.requireNonNullElse(mcVersion, "");
cache.irisVersion = irisVersion;
cache.autoIngest = autoIngest;
cache.stripOverrides = stripOverrides;
cache.urls = List.copyOf(urls);
cache.localFingerprint = localFingerprint;
return cache;
}
static boolean startupValidationContextMatches(
StartupValidationCache cache,
String mcVersion,
int irisVersion,
boolean autoIngest,
boolean stripOverrides,
List<String> urls
) {
return cache != null
&& cache.schemaVersion == STARTUP_VALIDATION_SCHEMA
&& Objects.equals(cache.minecraftVersion, Objects.requireNonNullElse(mcVersion, ""))
&& cache.irisVersion == irisVersion
&& cache.autoIngest == autoIngest
&& cache.stripOverrides == stripOverrides
&& Objects.equals(cache.urls, urls);
}
static boolean startupValidationCacheMatches(
StartupValidationCache cache,
String mcVersion,
int irisVersion,
boolean autoIngest,
boolean stripOverrides,
List<String> urls,
String localFingerprint
) {
return startupValidationContextMatches(
cache, mcVersion, irisVersion, autoIngest, stripOverrides, urls)
&& localFingerprint != null && !localFingerprint.isBlank()
&& Objects.equals(cache.localFingerprint, localFingerprint);
}
static StartupValidationCache readStartupValidationCache(Path cacheFile) {
if (cacheFile == null
|| Files.isSymbolicLink(cacheFile)
|| !Files.isRegularFile(cacheFile, LinkOption.NOFOLLOW_LINKS)) {
return null;
}
try {
if (Files.size(cacheFile) > DatapackPackMetadata.MAX_METADATA_BYTES) {
return null;
}
StartupValidationCache cache = DatapackSupport.GSON.fromJson(
DatapackSupport.readBoundedUtf8(cacheFile, DatapackPackMetadata.MAX_METADATA_BYTES, "External datapack startup validation"),
StartupValidationCache.class);
if (cache == null || cache.urls == null) {
return null;
}
List<String> sortedUrls = cache.urls.stream()
.filter(Objects::nonNull)
.sorted()
.toList();
if (sortedUrls.size() != cache.urls.size()
|| new HashSet<>(sortedUrls).size() != sortedUrls.size()
|| !sortedUrls.equals(cache.urls)) {
return null;
}
cache.urls = sortedUrls;
return cache;
} catch (IOException | RuntimeException exception) {
return null;
}
}
static void writeStartupValidationCache(Path cacheFile, StartupValidationCache cache) throws IOException {
Path absolute = cacheFile.toAbsolutePath().normalize();
Path parent = Objects.requireNonNull(absolute.getParent(), "External datapack startup validation parent");
Files.createDirectories(parent);
Path staged = Files.createTempFile(parent, ".startup-validation-", ".tmp");
try {
byte[] content = DatapackSupport.GSON.toJson(cache).getBytes(StandardCharsets.UTF_8);
if (content.length > DatapackPackMetadata.MAX_METADATA_BYTES) {
throw new IOException("External datapack startup validation exceeds " + DatapackPackMetadata.MAX_METADATA_BYTES + " bytes");
}
Files.write(staged, content, StandardOpenOption.TRUNCATE_EXISTING);
DatapackSupport.forceFile(staged);
try {
Files.move(staged, absolute, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException exception) {
Files.move(staged, absolute, StandardCopyOption.REPLACE_EXISTING);
}
DatapackSupport.forceDirectoryIfSupported(parent);
} finally {
Files.deleteIfExists(staged);
}
}
static String startupValidationFingerprint(File root, KList<File> worldFolders) throws IOException {
return startupValidationFingerprint(root, worldFolders, List.of());
}
static String startupValidationFingerprint(
File root,
KList<File> worldFolders,
Iterable<String> sources
) throws IOException {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
Path manifestPath = new File(root, "manifest.json").toPath();
updateFingerprintValue(digest, "manifest");
if (Files.exists(manifestPath, LinkOption.NOFOLLOW_LINKS)) {
if (Files.isSymbolicLink(manifestPath)
|| !Files.isRegularFile(manifestPath, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Invalid datapack manifest path " + manifestPath);
}
byte[] manifest = DatapackSupport.readBoundedBytes(
manifestPath, DatapackManifestStore.MAX_MANIFEST_BYTES, "Datapack manifest fingerprint");
DatapackSupport.updateDigestLong(digest, manifest.length);
digest.update(manifest);
} else if (Files.notExists(manifestPath, LinkOption.NOFOLLOW_LINKS)) {
DatapackSupport.updateDigestLong(digest, -1L);
} else {
throw new IOException("Cannot determine datapack manifest state at " + manifestPath);
}
Manifest manifest = DatapackManifestStore.readCommittedManifest(root);
updateDirectoryFingerprint(digest, "staging", new File(root, "staging"));
updateDirectoryFingerprint(digest, "transactions", new File(root, DatapackTransactions.TRANSACTION_DIRECTORY));
updateDirectoryFingerprint(
digest,
"storage-install-scratch",
DatapackInstall.installScratchRoot(new File(root, "staging")));
List<Entry> entries = new ArrayList<>(manifest.entries);
entries.sort(Comparator.comparing(entry -> entry.id));
List<File> targets = new ArrayList<>(worldFolders == null ? List.of() : worldFolders);
targets.sort(Comparator.comparing(file -> file.toPath().toAbsolutePath().normalize().toString()));
for (File worldFolder : targets) {
String worldIdentity = worldFolder.toPath().toAbsolutePath().normalize().toString();
updateFingerprintValue(digest, "world:" + worldIdentity);
updateDirectoryFingerprint(
digest,
"world-install-scratch:" + worldIdentity,
DatapackInstall.installScratchRoot(worldFolder));
for (Entry entry : entries) {
updateDirectoryFingerprint(
digest,
"world-pack:" + worldIdentity + ":" + entry.id,
new File(worldFolder, entry.id));
}
}
updateLocalSourceFingerprint(digest, sources);
return DatapackSupport.hex(digest.digest());
} catch (NoSuchAlgorithmException exception) {
throw new IOException("SHA-256 algorithm unavailable", exception);
}
}
static void updateLocalSourceFingerprint(
MessageDigest digest,
Iterable<String> sources
) throws IOException {
if (sources == null) {
return;
}
List<String> localSources = new ArrayList<>();
for (String source : sources) {
URI uri = parseSourceUri(source);
if (uri != null && "file".equalsIgnoreCase(uri.getScheme())) {
localSources.add(uri.normalize().toASCIIString());
}
}
localSources.sort(String::compareTo);
byte[] buffer = new byte[DatapackOwnership.HASH_BUFFER_BYTES];
for (String source : localSources) {
Path path = DatapackArchive.requireLocalDatapackPath(parseSourceUri(source));
updateFingerprintValue(digest, "local-source:" + source);
try (InputStream input = Files.newInputStream(
path,
StandardOpenOption.READ,
LinkOption.NOFOLLOW_LINKS)) {
long bytes = 0;
int length;
while ((length = input.read(buffer)) > 0) {
bytes += length;
if (bytes > DatapackArchive.MAX_DOWNLOAD_BYTES) {
throw new IOException("Local datapack exceeds " + DatapackArchive.MAX_DOWNLOAD_BYTES + " bytes: " + path);
}
digest.update(buffer, 0, length);
}
DatapackSupport.updateDigestLong(digest, bytes);
}
}
}
static URI parseSourceUri(String source) throws IOException {
if (source == null || source.isBlank()) {
return null;
}
try {
return new URI(source.trim());
} catch (URISyntaxException exception) {
throw new IOException("Invalid datapack URL " + source, exception);
}
}
static void updateDirectoryFingerprint(
MessageDigest digest,
String identity,
File directory
) throws IOException {
updateFingerprintValue(digest, identity);
Path path = directory.toPath();
if (Files.notExists(path, LinkOption.NOFOLLOW_LINKS)) {
updateFingerprintValue(digest, "missing");
return;
}
if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)
|| Files.isSymbolicLink(path)
|| !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Invalid external datapack validation directory " + path);
}
updateFingerprintValue(digest, DatapackOwnership.directoryHash(directory));
Path ownership = new File(directory, DatapackOwnership.OWNERSHIP_MARKER).toPath();
if (Files.isRegularFile(ownership, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(ownership)) {
byte[] marker = DatapackSupport.readBoundedBytes(
ownership, DatapackOwnership.MAX_OWNERSHIP_BYTES, "External datapack ownership fingerprint");
DatapackSupport.updateDigestLong(digest, marker.length);
digest.update(marker);
} else {
DatapackSupport.updateDigestLong(digest, -1L);
}
}
static void updateFingerprintValue(MessageDigest digest, String value) {
byte[] bytes = Objects.requireNonNullElse(value, "").getBytes(StandardCharsets.UTF_8);
DatapackSupport.updateDigestInt(digest, bytes.length);
digest.update(bytes);
}
}
@@ -0,0 +1,489 @@
/*
* 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.core.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.structure.BulkStructureImporter;
import art.arcane.iris.core.structure.StructureImporter;
import art.arcane.iris.core.structure.authoring.StructureKey;
import art.arcane.iris.core.structure.authoring.StructureSource;
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
import java.io.File;
import java.io.IOException;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
import java.util.TreeMap;
import java.util.stream.Stream;
final class DatapackStructureImports {
static final int STRUCTURE_IMPORT_FORMAT_REVISION = 3;
private DatapackStructureImports() {
}
static boolean autoImportDatapackStructures() {
DatapackTransactions.TRANSACTION_LOCK.lock();
try {
return autoImportDatapackStructuresLocked();
} finally {
DatapackTransactions.TRANSACTION_LOCK.unlock();
}
}
static boolean autoImportDatapackStructuresLocked() {
boolean autoImportEnabled = IrisSettings.get().getGeneral().autoImportDatapackStructures;
File root = IrisPlatforms.get().dataFolder("datapacks");
boolean recovered;
try {
recovered = DatapackScratchRecovery.recoverTransactions(root, ServerConfigurator.getDatapacksFolder());
} catch (IOException e) {
IrisLogging.reportError("Automatic datapack structure import blocked by incomplete transaction recovery.", e);
return false;
}
Manifest manifest = DatapackManifestStore.readManifest(root);
if (manifest.entries.isEmpty()) {
return recovered;
}
Map<String, Entry> manifestEntriesByUrl = new HashMap<>();
for (Entry entry : manifest.entries) {
if (entry.url != null) {
manifestEntriesByUrl.put(entry.url, entry);
}
}
List<IrisData> packs;
try (Stream<IrisData> stream = ServerConfigurator.allPacks()) {
packs = stream.filter(Objects::nonNull).toList();
}
if (!autoImportEnabled && !hasRemovedImportState(packs, manifest.entries)) {
return recovered;
}
Map<String, Entry> entriesByUrl = new HashMap<>();
File stagingRoot = new File(root, "staging");
boolean stagingStateChanged = false;
boolean manifestStagingMetadataChanged = false;
for (Entry entry : manifest.entries) {
if (entry.url == null) {
continue;
}
StagingInspection inspection = DatapackStagingGuard.inspectUsableStaging(new File(stagingRoot, entry.id), entry);
stagingStateChanged |= inspection.ownershipCorrected();
manifestStagingMetadataChanged |= inspection.manifestChanged();
if (inspection.usable()) {
entriesByUrl.put(entry.url, entry);
}
}
int attemptedPacks = 0;
int completedPacks = 0;
int cleanupTargets = 0;
Set<String> completedUrls = new HashSet<>();
Set<String> failedUrls = new HashSet<>();
for (IrisData data : packs) {
Set<String> configured = DatapackImportSources.configuredImports(data);
String targetId = data.getDataFolder().toPath().toAbsolutePath().normalize().toString();
for (Entry entry : manifest.entries) {
if (!configured.contains(entry.url) && hasImportState(entry, targetId)) {
cleanupTargets++;
}
}
if (!cleanupRemovedImports(data, targetId, configured, manifest.entries, manifestEntriesByUrl)) {
for (Entry entry : manifest.entries) {
if (!configured.contains(entry.url) && hasImportState(entry, targetId)) {
failedUrls.add(entry.url);
}
}
}
if (!autoImportEnabled) {
continue;
}
Set<String> pendingUrls = new HashSet<>();
for (String url : configured) {
Entry entry = entriesByUrl.get(url);
if (entry != null && importPending(entry, targetId)) {
pendingUrls.add(url);
}
}
if (pendingUrls.isEmpty()) {
continue;
}
for (String pendingUrl : pendingUrls) {
Entry entry = entriesByUrl.get(pendingUrl);
prepareImportRecoveryInventory(entry, targetId);
}
try {
DatapackManifestStore.writeManifestChecked(root, manifest);
} catch (IOException e) {
failedUrls.addAll(pendingUrls);
IrisLogging.reportError("Datapack structure import for pack '"
+ data.getDataFolder().getPath()
+ "' was blocked because its recovery inventory could not be persisted.", e);
continue;
}
Set<String> structureKeys = new TreeSet<>();
Set<String> templateKeys = new TreeSet<>();
for (Entry entry : manifest.entries) {
if (!configured.contains(entry.url)) {
continue;
}
structureKeys.addAll(DatapackManifestStore.copyList(entry.structureKeys));
templateKeys.addAll(DatapackManifestStore.copyList(entry.templateKeys));
}
attemptedPacks++;
try {
BulkStructureImporter.Report report = BulkStructureImporter.importManagedDatapackStructures(
data,
StructureImporter.Mode.OVERWRITE,
BukkitPlatform.console(),
structureKeys,
templateKeys
);
Set<String> successfulPendingUrls = new HashSet<>(pendingUrls);
Set<String> incompleteUrls = new HashSet<>();
for (String pendingUrl : pendingUrls) {
Entry entry = entriesByUrl.get(pendingUrl);
Map<String, String> desired = importBundleInventory(entry);
if (!report.successfulBundles().entrySet().containsAll(desired.entrySet())) {
successfulPendingUrls.remove(pendingUrl);
incompleteUrls.add(pendingUrl);
failedUrls.add(pendingUrl);
}
}
if (report.failed() > 0 && incompleteUrls.isEmpty()) {
successfulPendingUrls.clear();
incompleteUrls.addAll(pendingUrls);
failedUrls.addAll(pendingUrls);
}
if (!incompleteUrls.isEmpty()) {
boolean reconciled = reconcileFailedImportInventories(
root, manifest, data, targetId, incompleteUrls, entriesByUrl);
if (report.retryRequired() || !reconciled) {
IrisLogging.error("Datapack structure import for pack '%s' reported %d incomplete source(s) and remains pending because a retryable runtime failure occurred.",
data.getDataFolder().getPath(), incompleteUrls.size());
} else if (recordDeterministicImportAttempts(
root, manifest, targetId, incompleteUrls, entriesByUrl)) {
IrisLogging.warn("Datapack structure import for pack '"
+ data.getDataFolder().getPath() + "' left " + incompleteUrls.size()
+ " source(s) incomplete after deterministic validation failures. Iris will retain the partial editable imports without retrying until the datapack source, importer format, or target pack changes.");
}
}
Map<String, String> sharedBundles = desiredBundles(configured, entriesByUrl);
boolean packCompleted = false;
for (String pendingUrl : successfulPendingUrls) {
Entry entry = entriesByUrl.get(pendingUrl);
Map<String, String> desired = importBundleInventory(entry);
Map<String, String> previous = entry.importedBundles.getOrDefault(targetId, Map.of());
Map<String, String> stale = new TreeMap<>(previous);
stale.keySet().removeAll(desired.keySet());
stale.keySet().removeAll(sharedBundles.keySet());
Map<String, String> remaining = cleanupImportedBundles(data, stale);
if (!remaining.isEmpty()) {
failedUrls.add(pendingUrl);
continue;
}
entry.importedBundles.put(targetId, desired);
recordSuccessfulImport(entry, targetId);
completedUrls.add(pendingUrl);
packCompleted = true;
}
if (packCompleted) {
completedPacks++;
}
} catch (RuntimeException e) {
failedUrls.addAll(pendingUrls);
IrisLogging.reportError("Datapack structure import failed for pack '"
+ data.getDataFolder().getPath() + "'; the manifest remains pending for retry.", e);
reconcileFailedImportInventories(root, manifest, data, targetId, pendingUrls, entriesByUrl);
}
}
for (Entry entry : manifest.entries) {
if (failedUrls.contains(entry.url)) {
entry.structuresImported = false;
} else if (completedUrls.contains(entry.url)) {
entry.structuresImported = true;
}
}
if (attemptedPacks == 0 && cleanupTargets == 0) {
if (manifestStagingMetadataChanged) {
DatapackManifestStore.writeManifest(root, manifest);
}
return recovered || stagingStateChanged || manifestStagingMetadataChanged;
}
DatapackManifestStore.writeManifest(root, manifest);
if (attemptedPacks == 0) {
IrisLogging.info("Datapack editable-import cleanup reconciled " + cleanupTargets + " removed source target(s).");
return true;
}
IrisLogging.info("Datapack structure import refreshed " + completedUrls.size() + " source(s) across "
+ completedPacks + "/" + attemptedPacks
+ " pack(s). Reference the imported keys from a 'structures' placement to position them manually.");
return true;
}
static boolean hasRemovedImportState(List<IrisData> packs, List<Entry> entries) {
for (IrisData data : packs) {
Set<String> configured = DatapackImportSources.configuredImports(data);
String targetId = data.getDataFolder().toPath().toAbsolutePath().normalize().toString();
for (Entry entry : entries) {
if (!configured.contains(entry.url) && hasImportState(entry, targetId)) {
return true;
}
}
}
return false;
}
static String importRevision(Entry entry) {
return importRevision(entry, STRUCTURE_IMPORT_FORMAT_REVISION);
}
static String importRevision(Entry entry, int importerFormatRevision) {
return "v" + importerFormatRevision + ":" + DatapackSupport.safe(entry.versionId) + ":" + DatapackSupport.safe(entry.sha1);
}
static boolean importPending(Entry entry, String targetId) {
String revision = importRevision(entry);
return !revision.equals(entry.importedTargets.get(targetId))
&& !revision.equals(entry.importAttempts.get(targetId));
}
static void recordDeterministicImportAttempt(Entry entry, String targetId) {
entry.importedTargets.remove(targetId);
entry.importAttempts.put(targetId, importRevision(entry));
entry.structuresImported = false;
}
static void recordSuccessfulImport(Entry entry, String targetId) {
entry.importedTargets.put(targetId, importRevision(entry));
entry.importAttempts.remove(targetId);
}
static void prepareImportRecoveryInventory(Entry entry, String targetId) {
Map<String, String> desired = importBundleInventory(entry);
Map<String, String> recovery = new TreeMap<>(desired);
recovery.putAll(entry.importedBundles.getOrDefault(targetId, Map.of()));
entry.importedBundles.put(targetId, recovery);
entry.importedTargets.remove(targetId);
entry.importAttempts.remove(targetId);
entry.structuresImported = false;
}
static boolean reconcileFailedImportInventories(
File root,
Manifest manifest,
IrisData data,
String targetId,
Set<String> pendingUrls,
Map<String, Entry> entriesByUrl
) {
boolean reconciled = true;
for (String pendingUrl : pendingUrls) {
Entry entry = entriesByUrl.get(pendingUrl);
try {
reconcileFailedImportInventory(data, entry, targetId);
} catch (IOException | RuntimeException e) {
reconciled = false;
IrisLogging.reportError("Could not reconcile partial editable structure imports for '"
+ pendingUrl + "' in pack '" + data.getDataFolder().getPath()
+ "'; the conservative recovery inventory remains pending.", e);
}
}
try {
DatapackManifestStore.writeManifestChecked(root, manifest);
} catch (IOException e) {
reconciled = false;
IrisLogging.reportError("Could not persist reconciled partial editable structure imports for pack '"
+ data.getDataFolder().getPath() + "'; the earlier recovery inventory remains durable.", e);
}
return reconciled;
}
static boolean recordDeterministicImportAttempts(
File root,
Manifest manifest,
String targetId,
Set<String> incompleteUrls,
Map<String, Entry> entriesByUrl
) {
for (String incompleteUrl : incompleteUrls) {
recordDeterministicImportAttempt(entriesByUrl.get(incompleteUrl), targetId);
}
try {
DatapackManifestStore.writeManifestChecked(root, manifest);
return true;
} catch (IOException e) {
for (String incompleteUrl : incompleteUrls) {
entriesByUrl.get(incompleteUrl).importAttempts.remove(targetId);
}
IrisLogging.reportError("Could not persist deterministic editable structure import attempts; the sources remain pending for retry.", e);
return false;
}
}
static void reconcileFailedImportInventory(
IrisData data,
Entry entry,
String targetId
) throws IOException {
Map<String, Set<String>> claims = DatapackRemoval.importBundleClaims(entry, targetId);
Map<String, String> reconciled = new TreeMap<>();
StructureTransactionWriter writer = new StructureTransactionWriter(data.getDataFolder().toPath());
for (Map.Entry<String, Set<String>> bundle : claims.entrySet()) {
StructureKey targetKey;
try {
targetKey = StructureKey.parse(bundle.getKey());
} catch (RuntimeException e) {
throw new IOException("Invalid editable structure target key '" + bundle.getKey() + "'", e);
}
Optional<StructureSource> source = writer.ownedSource(targetKey);
if (source.isPresent() && DatapackRemoval.sourceClaimsContain(bundle.getValue(), source.get())) {
reconciled.put(bundle.getKey(), source.get().key().value());
}
}
if (reconciled.isEmpty()) {
entry.importedBundles.remove(targetId);
} else {
entry.importedBundles.put(targetId, reconciled);
}
entry.importedTargets.remove(targetId);
entry.importAttempts.remove(targetId);
entry.structuresImported = false;
}
static boolean cleanupRemovedImports(
IrisData data,
String targetId,
Set<String> configured,
List<Entry> entries,
Map<String, Entry> entriesByUrl
) {
boolean successful = true;
Map<String, String> retainedBundles = desiredBundles(configured, entriesByUrl);
for (Entry entry : entries) {
if (configured.contains(entry.url)) {
continue;
}
Map<String, String> inventory = entry.importedBundles.get(targetId);
if (inventory == null && !hasImportState(entry, targetId)) {
continue;
}
Map<String, String> resolvedInventory = inventory == null ? Map.of() : inventory;
for (Entry retainedEntry : entries) {
if (configured.contains(retainedEntry.url)) {
retainedEntry.importedTargets.remove(targetId);
retainedEntry.importAttempts.remove(targetId);
retainedEntry.structuresImported = false;
}
}
Map<String, String> removable = new TreeMap<>(resolvedInventory);
removable.keySet().removeAll(retainedBundles.keySet());
Map<String, String> remaining = cleanupImportedBundles(data, removable);
if (!remaining.isEmpty()) {
Map<String, String> retained = new TreeMap<>();
for (Map.Entry<String, String> bundle : resolvedInventory.entrySet()) {
if (retainedBundles.containsKey(bundle.getKey()) || remaining.containsKey(bundle.getKey())) {
retained.put(bundle.getKey(), bundle.getValue());
}
}
entry.importedBundles.put(targetId, retained);
entry.importedTargets.remove(targetId);
entry.importAttempts.remove(targetId);
entry.structuresImported = false;
successful = false;
continue;
}
entry.importedBundles.remove(targetId);
entry.importedTargets.remove(targetId);
entry.importAttempts.remove(targetId);
}
return successful;
}
static boolean hasImportState(Entry entry, String targetId) {
return entry.importedBundles.containsKey(targetId)
|| entry.importedTargets.containsKey(targetId)
|| entry.importAttempts.containsKey(targetId);
}
static Map<String, String> cleanupImportedBundles(IrisData data, Map<String, String> inventory) {
Map<String, String> remaining = new TreeMap<>();
StructureTransactionWriter writer = new StructureTransactionWriter(data.getDataFolder().toPath());
boolean removed = false;
for (Map.Entry<String, String> bundle : inventory.entrySet()) {
StructureKey sourceKey;
try {
sourceKey = StructureKey.parse(bundle.getValue());
StructureSource.Kind sourceKind = sourceKey.namespace().equals("minecraft")
? StructureSource.Kind.VANILLA : StructureSource.Kind.DATAPACK;
removed |= writer.removeManagedDatapackOwned(
StructureKey.parse(bundle.getKey()),
sourceKind,
sourceKey
);
} catch (IOException | RuntimeException e) {
remaining.put(bundle.getKey(), bundle.getValue());
IrisLogging.reportError("Preserving imported structure bundle '" + bundle.getKey()
+ "' in pack '" + data.getDataFolder().getPath()
+ "' because ownership-safe cleanup failed.", e);
}
}
if (removed) {
data.invalidateStructureResources();
}
return remaining;
}
static Map<String, String> desiredBundles(Set<String> configured, Map<String, Entry> entriesByUrl) {
Map<String, String> bundles = new TreeMap<>();
for (String url : configured) {
Entry entry = entriesByUrl.get(url);
if (entry != null) {
bundles.putAll(importBundleInventory(entry));
}
}
return bundles;
}
static Map<String, String> importBundleInventory(Entry entry) {
Map<String, String> bundles = new TreeMap<>();
for (String structureKey : DatapackManifestStore.copyList(entry.structureKeys)) {
bundles.put("iris:" + StructureImporter.deriveName(structureKey), structureKey);
}
for (String templateKey : DatapackManifestStore.copyList(entry.templateKeys)) {
bundles.put("iris:" + BulkStructureImporter.templateNameFor(templateKey), templateKey);
}
return bundles;
}
}
@@ -0,0 +1,265 @@
/*
* 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.core.datapack;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.util.common.io.Durability;
import art.arcane.iris.util.common.plugin.VolmitSender;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.security.MessageDigest;
final class DatapackSupport {
static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private DatapackSupport() {
}
static String failureMessage(Throwable exception) {
if (exception == null) {
return "unknown failure";
}
String message = exception.getMessage();
return message == null || message.isBlank() ? exception.getClass().getSimpleName() : message;
}
static IOException appendFailure(IOException current, IOException additional) {
if (current == null) {
return additional;
}
current.addSuppressed(additional);
return current;
}
static IOException appendIOException(IOException current, Throwable failure) {
IOException next = failure instanceof IOException ioFailure
? ioFailure : new IOException("Datapack transaction participant failed", failure);
if (current == null) {
return next;
}
current.addSuppressed(next);
return current;
}
static Path requireDirectoryIdentity(File directory, String purpose) throws IOException {
Path normalized = directory.toPath().toAbsolutePath().normalize();
BasicFileAttributes attributes = Files.readAttributes(
normalized, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
if (!DatapackScratchRecovery.isSupportedScratchDirectory(attributes)) {
throw new IOException("Invalid " + purpose + " " + normalized);
}
normalized.toRealPath();
return normalized;
}
static void requireNoSymbolicLinkComponents(Path normalized, String purpose) throws IOException {
Path current = normalized.getRoot();
for (Path component : normalized) {
current = current == null ? component : current.resolve(component);
BasicFileAttributes attributes = Files.readAttributes(
current, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
if (attributes.isSymbolicLink()) {
throw new IOException("Refusing symbolic-link component in " + purpose + " " + normalized);
}
if (attributes.isOther()) {
throw new IOException("Refusing special filesystem component in " + purpose + " " + normalized);
}
if (!DatapackScratchRecovery.isSupportedScratchDirectory(attributes)) {
throw new IOException("Refusing unsupported component in " + purpose + " " + normalized);
}
}
}
static String directoryIdentity(File directory) throws IOException {
BasicFileAttributes attributes = Files.readAttributes(
directory.toPath(), BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
if (!attributes.isDirectory() || attributes.isSymbolicLink()) {
throw new IOException("Invalid datapack directory identity at " + directory.getPath());
}
Object fileKey = attributes.fileKey();
if (fileKey != null) {
return "key:" + fileKey;
}
// Windows exposes no inode-equivalent for a directory, and returning nothing here fails
// the identity gate closed, which disables legacy staging replacement on that platform
// outright. Creation time is the one stable discriminator the filesystem still offers: a
// directory swapped in at the same path brings its own, so a swap is still caught. It is
// a weaker guarantee than an inode, so it is only ever the fallback.
return "created:" + attributes.creationTime();
}
static boolean pathExists(Path path, String purpose) throws IOException {
if (Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
return true;
}
if (Files.notExists(path, LinkOption.NOFOLLOW_LINKS)) {
return false;
}
throw new IOException("Cannot determine " + purpose + " state at " + path);
}
static String safe(String value) {
return value == null || value.isBlank() ? "?" : value;
}
static String safeFile(String value) {
return value == null || value.isBlank() ? "unknown" : value.replaceAll("[^a-zA-Z0-9._-]", "_");
}
static String hex(byte[] hash) {
StringBuilder builder = new StringBuilder(hash.length * 2);
for (byte value : hash) {
builder.append(String.format("%02x", value));
}
return builder.toString();
}
static void updateDigestInt(MessageDigest digest, int value) {
digest.update((byte) (value >>> 24));
digest.update((byte) (value >>> 16));
digest.update((byte) (value >>> 8));
digest.update((byte) value);
}
static void updateDigestLong(MessageDigest digest, 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);
}
static void message(VolmitSender sender, String text) {
if (sender != null) {
sender.sendMessage(text);
return;
}
IrisLogging.info(text);
}
static void move(Path source, Path target) throws IOException {
try {
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException e) {
Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);
}
}
static void moveNew(Path source, Path target) throws IOException {
if (pathExists(target, "move target")) {
throw new IOException("Refusing to replace concurrently-created path " + target);
}
Files.move(source, target);
}
static void ensureScratchDirectory(File directory, String purpose) throws IOException {
Path path = directory.toPath();
if (Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
if (Files.isSymbolicLink(path) || !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Refusing invalid " + purpose + " directory " + directory.getPath());
}
return;
}
Files.createDirectories(path);
if (Files.isSymbolicLink(path) || !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Could not create a safe " + purpose + " directory " + directory.getPath());
}
}
static void verifyDirectoryContainerIfPresent(File directory, String purpose) throws IOException {
Path path = directory.toPath();
if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
return;
}
if (Files.isSymbolicLink(path) || !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Refusing invalid " + purpose + " directory " + directory.getPath());
}
}
static String normalizedPath(File file) {
return file.toPath().toAbsolutePath().normalize().toString();
}
static String realDirectoryPath(File directory, String purpose) throws IOException {
Path path = directory.toPath().toAbsolutePath().normalize();
if (Files.isSymbolicLink(path) || !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Invalid " + purpose + " " + path);
}
return path.toRealPath().toString();
}
static String readBoundedUtf8(Path path, long maxBytes, String purpose) throws IOException {
return new String(readBoundedBytes(path, maxBytes, purpose), StandardCharsets.UTF_8);
}
static byte[] readBoundedBytes(Path path, long maxBytes, String purpose) throws IOException {
int readLimit = Math.toIntExact(maxBytes + 1);
byte[] content;
try (InputStream input = Files.newInputStream(
path,
StandardOpenOption.READ,
LinkOption.NOFOLLOW_LINKS
)) {
content = input.readNBytes(readLimit);
}
if (content.length > maxBytes) {
throw new IOException(purpose + " exceeds " + maxBytes + " bytes");
}
return content;
}
static void forceFile(Path file) throws IOException {
if (!Durability.enabled()) {
return;
}
try (FileChannel channel = FileChannel.open(file, StandardOpenOption.WRITE)) {
Durability.force(channel);
}
}
static void forceDirectoryIfSupported(Path directory) throws IOException {
if (!Durability.enabled()) {
return;
}
if (!Files.getFileStore(directory).supportsFileAttributeView("posix")) {
return;
}
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
Durability.force(channel);
}
}
}
@@ -0,0 +1,342 @@
/*
* 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.core.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Stream;
final class DatapackTransactions {
static final String TRANSACTION_DIRECTORY = ".iris-datapack-transactions";
static final String TRANSACTION_JOURNAL = "journal.json";
static final String TRANSACTION_JOURNAL_NEXT = "journal.next.json";
static final int TRANSACTION_SCHEMA = 2;
static final long MAX_TRANSACTION_JOURNAL_BYTES = 4L * 1024L * 1024L;
static final int MAX_TRANSACTION_COUNT = 1_024;
static final ReentrantLock TRANSACTION_LOCK = new ReentrantLock();
private DatapackTransactions() {
}
static DatapackCoordinator createInstallCoordinator(
File root,
Entry entry,
List<InstallPlan> plans,
boolean manifestAlreadyMatched
) throws IOException {
CoordinatorJournal journal = newCoordinatorJournal(
CoordinatorOperation.INSTALL,
entry,
manifestAlreadyMatched
);
for (InstallPlan plan : plans) {
if (!plan.publishRequired()) {
continue;
}
CoordinatorDirectory directory = new CoordinatorDirectory();
directory.target = DatapackSupport.normalizedPath(plan.target());
directory.pending = DatapackSupport.normalizedPath(plan.pending());
directory.backup = DatapackSupport.normalizedPath(plan.backup());
directory.hadTarget = plan.hadTarget();
directory.originalHash = plan.originalHash();
directory.desiredHash = plan.desiredHash();
directory.originalMarkerHash = plan.originalMarkerHash();
directory.desiredMarkerHash = plan.desiredMarkerHash();
directory.originalIdentity = plan.originalIdentity();
directory.desiredIdentity = plan.desiredIdentity();
directory.targetRoot = plan.targetRootIdentity();
directory.scratchRoot = plan.scratchRootIdentity();
directory.targetRootIdentity = plan.targetRootFileIdentity();
directory.scratchRootIdentity = plan.scratchRootFileIdentity();
journal.directories.add(directory);
}
return createCoordinator(root, journal);
}
static DatapackCoordinator createRemovalCoordinator(
File root,
Entry entry,
DirectoryRemoval directoryRemoval,
EditableImportRemoval editableRemoval
) throws IOException {
CoordinatorJournal journal = newCoordinatorJournal(CoordinatorOperation.REMOVE, entry, true);
for (DirectoryMove move : directoryRemoval.moves()) {
CoordinatorDirectory directory = new CoordinatorDirectory();
directory.target = DatapackSupport.normalizedPath(move.target());
directory.pending = "";
directory.backup = DatapackSupport.normalizedPath(move.backup());
directory.hadTarget = true;
directory.originalHash = move.originalHash();
directory.desiredHash = "";
directory.originalMarkerHash = move.originalMarkerHash();
directory.desiredMarkerHash = "";
directory.originalIdentity = move.originalIdentity();
directory.desiredIdentity = "";
directory.targetRoot = move.targetRootIdentity();
directory.scratchRoot = move.scratchRootIdentity();
directory.targetRootIdentity = move.targetRootFileIdentity();
directory.scratchRootIdentity = move.scratchRootFileIdentity();
journal.directories.add(directory);
}
for (StructureTransactionWriter.PreparedRemovalToken token : editableRemoval.recoveryTokens()) {
CoordinatorEditable editable = new CoordinatorEditable();
editable.packRoot = token.packRoot().toString();
editable.transactionId = token.transactionId().toString();
editable.claimId = UUID.randomUUID().toString();
journal.editables.add(editable);
}
Path transactionRoot = coordinatorTransactionRoot(root, journal);
try {
editableRemoval.claimRecoveryOwners(transactionRoot, journal);
writeCoordinatorJournal(transactionRoot, journal);
return new DatapackCoordinator(transactionRoot, journal);
} catch (IOException | RuntimeException creationFailure) {
try {
DatapackInstall.deleteInstallScratch(transactionRoot.toFile(), "incomplete datapack transaction");
} catch (IOException cleanupFailure) {
creationFailure.addSuppressed(cleanupFailure);
}
if (creationFailure instanceof IOException ioFailure) {
throw ioFailure;
}
throw creationFailure;
}
}
static CoordinatorJournal newCoordinatorJournal(
CoordinatorOperation operation,
Entry entry,
boolean manifestAlreadyMatched
) {
CoordinatorJournal journal = new CoordinatorJournal();
journal.schemaVersion = TRANSACTION_SCHEMA;
journal.transactionId = UUID.randomUUID().toString();
journal.operation = operation;
journal.phase = CoordinatorPhase.PREPARED;
journal.id = entry.id;
journal.url = entry.url;
journal.versionId = entry.versionId;
journal.versionNumber = entry.versionNumber;
journal.sha1 = entry.sha1;
journal.manifestAlreadyMatched = manifestAlreadyMatched;
return journal;
}
static DatapackCoordinator createCoordinator(File root, CoordinatorJournal journal) throws IOException {
Path transactionRoot = coordinatorTransactionRoot(root, journal);
writeCoordinatorJournal(transactionRoot, journal);
return new DatapackCoordinator(transactionRoot, journal);
}
static Path coordinatorTransactionRoot(File root, CoordinatorJournal journal) throws IOException {
File transactionDirectory = new File(root, TRANSACTION_DIRECTORY);
DatapackSupport.ensureScratchDirectory(transactionDirectory, "datapack transaction");
Path transactionRoot = new File(transactionDirectory, journal.transactionId).toPath().toAbsolutePath().normalize();
if (Files.exists(transactionRoot, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Datapack transaction id already exists: " + journal.transactionId);
}
return transactionRoot;
}
static CoordinatorJournal readCoordinatorJournal(Path transactionRoot) throws IOException {
Path committed = transactionRoot.resolve(TRANSACTION_JOURNAL);
Path next = transactionRoot.resolve(TRANSACTION_JOURNAL_NEXT);
if (Files.exists(committed, LinkOption.NOFOLLOW_LINKS)) {
return parseCoordinatorJournal(committed);
}
if (!Files.exists(next, LinkOption.NOFOLLOW_LINKS)) {
try (Stream<Path> entries = Files.list(transactionRoot)) {
if (entries.findAny().isEmpty()) {
return null;
}
}
throw new IOException("Missing datapack transaction journal " + committed);
}
try {
return parseCoordinatorJournal(next);
} catch (IOException firstWriteFailure) {
try (Stream<Path> entries = Files.list(transactionRoot)) {
List<Path> contents = entries.limit(2).toList();
if (contents.size() == 1 && Objects.equals(contents.getFirst(), next)
&& !Files.isSymbolicLink(next)) {
return null;
}
}
throw firstWriteFailure;
}
}
static CoordinatorJournal parseCoordinatorJournal(Path journalPath) throws IOException {
if (Files.isSymbolicLink(journalPath)
|| !Files.isRegularFile(journalPath, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Invalid datapack transaction journal " + journalPath);
}
try {
CoordinatorJournal journal = DatapackSupport.GSON.fromJson(
DatapackSupport.readBoundedUtf8(
journalPath,
MAX_TRANSACTION_JOURNAL_BYTES,
"Datapack transaction journal"
),
CoordinatorJournal.class
);
if (journal == null) {
throw new IOException("Empty datapack transaction journal " + journalPath);
}
return journal;
} catch (RuntimeException e) {
throw new IOException("Invalid datapack transaction journal " + journalPath, e);
}
}
static void validateCoordinatorJournal(
File root,
List<File> worldFolders,
Manifest committedManifest,
Path transactionRoot,
CoordinatorJournal journal
) throws IOException {
if (journal.schemaVersion != TRANSACTION_SCHEMA || journal.transactionId == null
|| journal.operation == null || journal.phase == null || journal.id == null
|| journal.url == null || journal.directories == null || journal.editables == null) {
throw new IOException("Incomplete datapack transaction journal at " + transactionRoot);
}
UUID transactionId;
try {
transactionId = UUID.fromString(journal.transactionId);
} catch (IllegalArgumentException e) {
throw new IOException("Invalid datapack transaction id " + journal.transactionId, e);
}
if (!transactionId.toString().equals(transactionRoot.getFileName().toString())) {
throw new IOException("Datapack transaction id does not match its directory");
}
if (!journal.id.equals(DatapackArchive.sanitizeId(journal.id)) || DatapackArchive.RESERVED_IDS.contains(journal.id)) {
throw new IOException("Invalid datapack transaction entry id " + journal.id);
}
if (journal.directories.size() > worldFolders.size() + 1 || journal.editables.size() > 10_000) {
throw new IOException("Datapack transaction journal contains too many participants");
}
if (journal.operation == CoordinatorOperation.INSTALL && !journal.editables.isEmpty()) {
throw new IOException("Datapack install transaction unexpectedly contains editable participants");
}
Set<Path> allowedTargets = new HashSet<>();
allowedTargets.add(new File(new File(root, "staging"), journal.id).toPath().toAbsolutePath().normalize());
for (File worldFolder : worldFolders) {
allowedTargets.add(new File(worldFolder, journal.id).toPath().toAbsolutePath().normalize());
}
Set<Path> seenTargets = new HashSet<>();
Set<Path> seenTargetIdentities = new HashSet<>();
for (CoordinatorDirectory directory : journal.directories) {
DatapackCoordinatorResolution.validateCoordinatorDirectory(
directory, journal.operation, allowedTargets, seenTargets, seenTargetIdentities);
}
Set<String> seenEditables = new HashSet<>();
Set<Path> allowedEditableRoots = journal.editables.isEmpty()
? Set.of() : DatapackCoordinatorResolution.authoritativeEditableRoots(committedManifest, journal);
for (CoordinatorEditable editable : journal.editables) {
if (editable == null || editable.packRoot == null || editable.transactionId == null
|| editable.claimId == null
|| !seenEditables.add(editable.packRoot)) {
throw new IOException("Invalid duplicate editable participant in datapack transaction");
}
Path packRoot = coordinatorPath(editable.packRoot, "editable pack root");
Path realPackRoot = Files.isDirectory(packRoot, LinkOption.NOFOLLOW_LINKS)
? packRoot.toRealPath() : packRoot;
if (Files.isSymbolicLink(packRoot) || !Files.isDirectory(packRoot, LinkOption.NOFOLLOW_LINKS)
|| !packRoot.equals(realPackRoot) || !allowedEditableRoots.contains(realPackRoot)) {
throw new IOException("Invalid editable pack root in datapack transaction: " + packRoot);
}
try {
UUID.fromString(editable.transactionId);
UUID.fromString(editable.claimId);
} catch (IllegalArgumentException e) {
throw new IOException("Invalid editable transaction recovery identity", e);
}
StructureTransactionWriter writer = new StructureTransactionWriter(packRoot);
boolean recoveryDataPresent = writer.verifyRecoveryOwner(
new StructureTransactionWriter.PreparedRemovalToken(
packRoot,
UUID.fromString(editable.transactionId)
),
new StructureTransactionWriter.RecoveryOwner(
transactionRoot,
UUID.fromString(journal.transactionId),
UUID.fromString(editable.claimId)
),
committedManifest.findById(journal.id) == null
&& journal.phase == CoordinatorPhase.PUBLISHED
);
if (!recoveryDataPresent && journal.phase != CoordinatorPhase.COMMITTED) {
throw new IOException("Editable structure recovery data disappeared before commit");
}
}
}
static void deleteCoordinatorTransaction(Path transactionRoot) throws IOException {
DatapackInstall.deleteInstallScratch(transactionRoot.toFile(), "completed datapack transaction");
DatapackSupport.forceDirectoryIfSupported(Objects.requireNonNull(transactionRoot.getParent(), "transaction parent"));
}
static void writeCoordinatorJournal(Path transactionRoot, CoordinatorJournal journal) throws IOException {
byte[] content = DatapackSupport.GSON.toJson(journal).getBytes(StandardCharsets.UTF_8);
if (content.length > MAX_TRANSACTION_JOURNAL_BYTES) {
throw new IOException("Datapack transaction journal exceeds " + MAX_TRANSACTION_JOURNAL_BYTES + " bytes");
}
Files.createDirectories(transactionRoot);
Path next = transactionRoot.resolve(TRANSACTION_JOURNAL_NEXT);
if (Files.isSymbolicLink(next)) {
throw new IOException("Datapack transaction journal cannot be a symbolic link: " + next);
}
Files.deleteIfExists(next);
Files.write(next, content, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
DatapackSupport.forceFile(next);
DatapackSupport.move(next, transactionRoot.resolve(TRANSACTION_JOURNAL));
DatapackSupport.forceDirectoryIfSupported(transactionRoot);
DatapackSupport.forceDirectoryIfSupported(Objects.requireNonNull(transactionRoot.getParent(), "transaction parent"));
}
static Path coordinatorPath(String value, String purpose) throws IOException {
try {
return Path.of(value).toAbsolutePath().normalize();
} catch (RuntimeException e) {
throw new IOException("Invalid datapack transaction " + purpose + " path", e);
}
}
}
@@ -0,0 +1,26 @@
/*
* 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.core.datapack;
import java.io.File;
@FunctionalInterface
interface DirectoryDeleter {
void delete(File directory);
}
@@ -0,0 +1,34 @@
/*
* 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.core.datapack;
import java.io.File;
record DirectoryMove(
File target,
File backup,
String originalHash,
String originalMarkerHash,
String originalIdentity,
String targetRootIdentity,
String scratchRootIdentity,
String targetRootFileIdentity,
String scratchRootFileIdentity
) {
}
@@ -0,0 +1,179 @@
/*
* 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.core.datapack;
import java.io.File;
import java.io.IOException;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.util.List;
final class DirectoryRemoval {
private final List<DirectoryMove> moved;
private int prepared;
private boolean closed;
DirectoryRemoval(List<DirectoryMove> moved) {
this.moved = List.copyOf(moved);
}
List<DirectoryMove> moves() {
return moved;
}
void prepare() throws IOException {
if (closed || prepared > 0) {
throw new IllegalStateException("Datapack directory removal transaction is not new");
}
try {
for (DirectoryMove directoryMove : moved) {
DatapackInstallPlanner.verifyDirectoryContainerIdentity(
directoryMove.target().getParentFile(),
directoryMove.targetRootIdentity(),
directoryMove.targetRootFileIdentity(),
"datapack removal target root"
);
DatapackInstallPlanner.verifyDirectoryContainerIdentity(
directoryMove.backup().getParentFile(),
directoryMove.scratchRootIdentity(),
directoryMove.scratchRootFileIdentity(),
"datapack removal scratch root"
);
DatapackInstallPlanner.verifyDirectorySnapshot(
directoryMove.target(),
new File(directoryMove.targetRootIdentity()),
directoryMove.originalHash(),
directoryMove.originalMarkerHash(),
directoryMove.originalIdentity(),
"datapack removal target"
);
DatapackSupport.moveNew(directoryMove.target().toPath(), directoryMove.backup().toPath());
prepared++;
DatapackSupport.forceDirectoryIfSupported(directoryMove.target().getParentFile().toPath());
DatapackSupport.forceDirectoryIfSupported(directoryMove.backup().getParentFile().toPath());
DatapackInstallPlanner.verifyDirectorySnapshot(
directoryMove.backup(),
new File(directoryMove.targetRootIdentity()),
directoryMove.originalHash(),
directoryMove.originalMarkerHash(),
directoryMove.originalIdentity(),
"datapack removal backup"
);
}
} catch (IOException removalFailure) {
try {
rollback();
} catch (IOException restoreFailure) {
removalFailure.addSuppressed(restoreFailure);
}
throw removalFailure;
}
}
void rollback() throws IOException {
if (closed) {
return;
}
IOException failure = null;
for (int i = prepared - 1; i >= 0; i--) {
DirectoryMove directoryMove = moved.get(i);
if (!Files.exists(directoryMove.backup().toPath(), LinkOption.NOFOLLOW_LINKS)) {
continue;
}
try {
DatapackInstallPlanner.verifyDirectoryContainerIdentity(
directoryMove.target().getParentFile(),
directoryMove.targetRootIdentity(),
directoryMove.targetRootFileIdentity(),
"datapack removal target root"
);
DatapackInstallPlanner.verifyDirectoryContainerIdentity(
directoryMove.backup().getParentFile(),
directoryMove.scratchRootIdentity(),
directoryMove.scratchRootFileIdentity(),
"datapack removal scratch root"
);
DatapackInstallPlanner.verifyDirectorySnapshot(
directoryMove.backup(),
new File(directoryMove.targetRootIdentity()),
directoryMove.originalHash(),
directoryMove.originalMarkerHash(),
directoryMove.originalIdentity(),
"datapack removal backup"
);
if (DatapackSupport.pathExists(directoryMove.target().toPath(), "datapack removal target")) {
throw new IOException("Datapack removal target was concurrently recreated at "
+ directoryMove.target().getPath());
}
DatapackSupport.moveNew(
directoryMove.backup().toPath(),
directoryMove.target().toPath()
);
DatapackSupport.forceDirectoryIfSupported(directoryMove.target().getParentFile().toPath());
DatapackSupport.forceDirectoryIfSupported(directoryMove.backup().getParentFile().toPath());
} catch (IOException restoreFailure) {
if (failure == null) {
failure = restoreFailure;
} else {
failure.addSuppressed(restoreFailure);
}
}
}
for (int i = 0; i < prepared; i++) {
DirectoryMove directoryMove = moved.get(i);
directoryMove.backup().getParentFile().delete();
}
closed = true;
if (failure != null) {
throw failure;
}
}
void finishCommit() throws IOException {
if (closed) {
return;
}
IOException failure = null;
for (int i = 0; i < prepared; i++) {
DirectoryMove directoryMove = moved.get(i);
try {
DatapackInstallPlanner.verifyDirectoryContainerIdentity(
directoryMove.backup().getParentFile(),
directoryMove.scratchRootIdentity(),
directoryMove.scratchRootFileIdentity(),
"datapack removal scratch root"
);
DatapackCoordinatorResolution.deleteOriginalBackupIfPresent(
directoryMove.backup(),
new File(directoryMove.targetRootIdentity()),
directoryMove.originalHash(),
directoryMove.originalMarkerHash(),
directoryMove.originalIdentity()
);
} catch (IOException cleanupFailure) {
failure = DatapackSupport.appendIOException(failure, cleanupFailure);
}
directoryMove.backup().getParentFile().delete();
}
closed = true;
if (failure != null) {
throw failure;
}
}
}
@@ -0,0 +1,23 @@
/*
* 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.core.datapack;
record DownloadResult(boolean notModified, String etag, String lastModified) {
}
@@ -0,0 +1,171 @@
/*
* 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.core.datapack;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
final class EditableImportRemoval {
private final List<PreparedEditableImport> prepared;
private boolean closed;
EditableImportRemoval(List<PreparedEditableImport> prepared) {
this.prepared = List.copyOf(prepared);
}
List<StructureTransactionWriter.PreparedRemovalToken> recoveryTokens() {
List<StructureTransactionWriter.PreparedRemovalToken> tokens = new ArrayList<>();
for (PreparedEditableImport editableImport : prepared) {
editableImport.removal().recoveryToken().ifPresent(tokens::add);
}
return List.copyOf(tokens);
}
void claimRecoveryOwners(Path transactionRoot, CoordinatorJournal journal) throws IOException {
int ownerIndex = 0;
for (PreparedEditableImport editableImport : prepared) {
Optional<StructureTransactionWriter.PreparedRemovalToken> token =
editableImport.removal().recoveryToken();
if (token.isEmpty()) {
continue;
}
if (ownerIndex >= journal.editables.size()) {
throw new IOException("Missing datapack coordinator claim for editable structure removal");
}
CoordinatorEditable editable = journal.editables.get(ownerIndex++);
StructureTransactionWriter.PreparedRemovalToken recoveryToken = token.get();
if (!Objects.equals(recoveryToken.packRoot().toString(), editable.packRoot)
|| !Objects.equals(recoveryToken.transactionId().toString(), editable.transactionId)) {
throw new IOException("Datapack coordinator editable claim order changed during preparation");
}
editableImport.removal().claimRecoveryOwner(
new StructureTransactionWriter.RecoveryOwner(
transactionRoot,
UUID.fromString(journal.transactionId),
UUID.fromString(editable.claimId)
)
);
}
if (ownerIndex != journal.editables.size()) {
throw new IOException("Unexpected datapack coordinator editable recovery claim");
}
}
void markCommitted() throws IOException {
if (closed) {
throw new IllegalStateException("Editable import removal transaction is closed");
}
for (PreparedEditableImport editableImport : prepared) {
editableImport.removal().markCommitted();
}
}
void finishCommit() throws IOException {
if (closed) {
return;
}
IOException failure = null;
for (PreparedEditableImport editableImport : prepared) {
try {
editableImport.removal().finishCommit();
} catch (IOException | RuntimeException cleanupFailure) {
IOException transactionFailure = cleanupFailure instanceof IOException ioFailure
? ioFailure
: new IOException("Failed finalizing editable import removal", cleanupFailure);
if (failure == null) {
failure = transactionFailure;
} else {
failure.addSuppressed(transactionFailure);
}
}
if (editableImport.removal().changed()) {
try {
IrisData.getLoaded(editableImport.dataFolder())
.ifPresent(IrisData::invalidateStructureResources);
} catch (RuntimeException invalidationFailure) {
IOException transactionFailure = new IOException(
"Failed invalidating editable import structure resources",
invalidationFailure
);
if (failure == null) {
failure = transactionFailure;
} else {
failure.addSuppressed(transactionFailure);
}
}
}
}
closed = true;
if (failure != null) {
throw failure;
}
}
void rollback() throws IOException {
if (closed) {
return;
}
IOException failure = null;
for (int i = prepared.size() - 1; i >= 0; i--) {
try {
prepared.get(i).removal().rollback();
} catch (IOException rollbackFailure) {
if (failure == null) {
failure = rollbackFailure;
} else {
failure.addSuppressed(rollbackFailure);
}
}
}
closed = true;
if (failure != null) {
throw failure;
}
}
void leaveForRecovery() throws IOException {
if (closed) {
return;
}
IOException failure = null;
for (PreparedEditableImport editableImport : prepared) {
try {
editableImport.removal().leaveForRecovery();
} catch (IOException releaseFailure) {
if (failure == null) {
failure = releaseFailure;
} else {
failure.addSuppressed(releaseFailure);
}
}
}
closed = true;
if (failure != null) {
throw failure;
}
}
}
@@ -0,0 +1,96 @@
/*
* 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.core.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import java.io.File;
import java.util.List;
final class InstallExecution {
private final InstallResult result;
private final DatapackCoordinator coordinator;
private final File stagedDir;
private final Entry entry;
private final String stagedHash;
private final boolean verifyStagedSource;
private final List<InstallPlan> publishedPlans;
private final List<InstallPlan> unchangedPlans;
private boolean verified;
InstallExecution(
InstallResult result,
DatapackCoordinator coordinator,
File stagedDir,
Entry entry,
String stagedHash,
boolean verifyStagedSource,
List<InstallPlan> publishedPlans,
List<InstallPlan> unchangedPlans
) {
this.result = result;
this.coordinator = coordinator;
this.stagedDir = stagedDir;
this.entry = DatapackManifestStore.copyEntry(entry);
this.stagedHash = stagedHash;
this.verifyStagedSource = verifyStagedSource;
this.publishedPlans = List.copyOf(publishedPlans);
this.unchangedPlans = List.copyOf(unchangedPlans);
}
InstallResult result() {
return result;
}
DatapackCoordinator coordinator() {
return coordinator;
}
File stagedDir() {
return stagedDir;
}
Entry entry() {
return entry;
}
String stagedHash() {
return stagedHash;
}
boolean verifyStagedSource() {
return verifyStagedSource;
}
List<InstallPlan> publishedPlans() {
return publishedPlans;
}
List<InstallPlan> unchangedPlans() {
return unchangedPlans;
}
boolean verified() {
return verified;
}
void markVerified() {
verified = true;
}
}
@@ -0,0 +1,45 @@
/*
* 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.core.datapack;
import java.io.File;
record InstallPlan(
File target,
File pending,
File backup,
File pendingRoot,
boolean hadTarget,
boolean publishRequired,
boolean contentChanged,
String originalHash,
String desiredHash,
String originalMarkerHash,
String desiredMarkerHash,
String originalIdentity,
String desiredIdentity,
String targetRootIdentity,
String scratchRootIdentity,
String targetRootFileIdentity,
String scratchRootFileIdentity,
String id,
String url,
VerifiedStagingInstall legacyWorldAuthorization
) {
}
@@ -0,0 +1,23 @@
/*
* 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.core.datapack;
record InstallResult(boolean changed) {
}
@@ -0,0 +1,61 @@
/*
* 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.core.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import java.util.ArrayList;
import java.util.List;
final class Manifest {
List<Entry> entries = new ArrayList<>();
Entry find(String url) {
for (Entry entry : entries) {
if (entry.url != null && entry.url.equals(url)) {
return entry;
}
}
return null;
}
Entry findById(String id) {
for (Entry entry : entries) {
if (entry.id != null && entry.id.equals(id)) {
return entry;
}
}
return null;
}
void put(Entry entry) {
for (int i = 0; i < entries.size(); i++) {
Entry current = entries.get(i);
if (current.url != null && current.url.equals(entry.url)) {
entries.set(i, entry);
return;
}
}
entries.add(entry);
}
boolean removeById(String id) {
return entries.removeIf(entry -> id.equals(entry.id));
}
}
@@ -0,0 +1,96 @@
/*
* 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.core.datapack;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Objects;
final class ManifestWrite implements AutoCloseable {
private final Path staged;
private final Path target;
private final Path rollback;
private boolean published;
ManifestWrite(Path staged, Path target, Path rollback) {
this.staged = staged;
this.target = target;
this.rollback = rollback;
}
void publish() throws IOException {
if (published) {
throw new IllegalStateException("Datapack manifest write was already published");
}
try {
DatapackSupport.move(staged, target);
published = true;
} catch (IOException publishFailure) {
try {
restoreOriginal();
} catch (IOException restoreFailure) {
publishFailure.addSuppressed(restoreFailure);
}
throw publishFailure;
}
DatapackSupport.forceDirectoryIfSupported(Objects.requireNonNull(target.getParent(), "manifest parent"));
}
boolean published() {
return published;
}
void discard() throws IOException {
IOException failure = null;
try {
Files.deleteIfExists(staged);
} catch (IOException cleanupFailure) {
failure = cleanupFailure;
}
if (rollback != null) {
try {
Files.deleteIfExists(rollback);
} catch (IOException cleanupFailure) {
if (failure == null) {
failure = cleanupFailure;
} else {
failure.addSuppressed(cleanupFailure);
}
}
}
if (failure != null) {
throw failure;
}
}
@Override
public void close() throws IOException {
discard();
}
private void restoreOriginal() throws IOException {
if (rollback == null) {
Files.deleteIfExists(target);
} else {
DatapackSupport.move(rollback, target);
}
DatapackSupport.forceDirectoryIfSupported(Objects.requireNonNull(target.getParent(), "manifest parent"));
}
}
@@ -0,0 +1,24 @@
/*
* 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.core.datapack;
import java.nio.file.Path;
record MetadataEntry(Path path, String relativePath) {
}
@@ -0,0 +1,69 @@
/*
* 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.core.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import java.util.List;
final class Ownership {
final int schemaVersion;
final String id;
final String url;
final String versionId;
final String versionNumber;
final String sha1;
final String contentHash;
final List<String> structureKeys;
final List<String> templateKeys;
Ownership(
int schemaVersion,
String id,
String url,
String versionId,
String versionNumber,
String sha1,
String contentHash,
List<String> structureKeys,
List<String> templateKeys
) {
this.schemaVersion = schemaVersion;
this.id = id;
this.url = url;
this.versionId = versionId;
this.versionNumber = versionNumber;
this.sha1 = sha1;
this.contentHash = contentHash;
this.structureKeys = List.copyOf(structureKeys);
this.templateKeys = List.copyOf(templateKeys);
}
Entry toEntry() {
Entry entry = new Entry();
entry.id = id;
entry.url = url;
entry.versionId = versionId;
entry.versionNumber = versionNumber;
entry.sha1 = sha1;
entry.structureKeys = DatapackManifestStore.normalizeKeys(structureKeys);
entry.templateKeys = DatapackManifestStore.normalizeKeys(templateKeys);
return entry;
}
}
@@ -0,0 +1,28 @@
/*
* 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.core.datapack;
import java.util.List;
record PackResources(
List<String> structureKeys,
List<String> structureSetKeys,
List<String> templateKeys
) {
}
@@ -0,0 +1,29 @@
/*
* 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.core.datapack;
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
import java.io.File;
record PreparedEditableImport(
File dataFolder,
StructureTransactionWriter.PreparedRemoval removal
) {
}
@@ -0,0 +1,26 @@
/*
* 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.core.datapack;
enum RemoveOutcome {
REMOVED,
REJECTED,
FAILED_AFTER_MUTATION
}
@@ -0,0 +1,27 @@
/*
* 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.core.datapack;
record StagingInspection(
boolean usable,
boolean ownershipCorrected,
boolean manifestChanged
) {
}
@@ -0,0 +1,31 @@
/*
* 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.core.datapack;
import java.util.List;
final class StartupValidationCache {
int schemaVersion;
String minecraftVersion;
int irisVersion;
boolean autoIngest;
boolean stripOverrides;
List<String> urls;
String localFingerprint;
}
@@ -0,0 +1,248 @@
/*
* 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.core.datapack;
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
import java.io.File;
import java.io.IOException;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Objects;
final class VerifiedStagingInstall {
private final Path normalizedRoot;
private final Path realRoot;
private final String rootIdentity;
private final Path normalizedStagingRoot;
private final Path realStagingRoot;
private final String stagingRootIdentity;
private final Path normalizedSource;
private final Path realSource;
private final String sourceIdentity;
private final String id;
private final String url;
private final String versionId;
private final String versionNumber;
private final String sha1;
private final String desiredHash;
private final Entry committedEntrySnapshot;
private final boolean legacyReplacementAuthorized;
private final DatapackStagingGuard.LegacyStagingSnapshot legacyStagingSnapshot;
private boolean consumed;
VerifiedStagingInstall(
Path normalizedRoot,
Path normalizedStagingRoot,
Path normalizedSource,
Entry entry,
String desiredHash,
Entry committedEntrySnapshot,
boolean legacyReplacementAuthorized,
DatapackStagingGuard.LegacyStagingSnapshot legacyStagingSnapshot
) throws IOException {
this.normalizedRoot = normalizedRoot;
this.realRoot = normalizedRoot.toRealPath();
this.rootIdentity = DatapackSupport.directoryIdentity(normalizedRoot.toFile());
this.normalizedStagingRoot = normalizedStagingRoot;
this.realStagingRoot = normalizedStagingRoot.toRealPath();
this.stagingRootIdentity = DatapackSupport.directoryIdentity(normalizedStagingRoot.toFile());
this.normalizedSource = normalizedSource;
this.realSource = normalizedSource.toRealPath();
this.sourceIdentity = DatapackSupport.directoryIdentity(normalizedSource.toFile());
this.id = entry.id;
this.url = entry.url;
this.versionId = entry.versionId;
this.versionNumber = entry.versionNumber;
this.sha1 = entry.sha1;
this.desiredHash = desiredHash;
this.committedEntrySnapshot = committedEntrySnapshot == null ? null : DatapackManifestStore.copyEntry(committedEntrySnapshot);
this.legacyReplacementAuthorized = legacyReplacementAuthorized;
this.legacyStagingSnapshot = legacyStagingSnapshot;
}
Path stagingRoot() {
return normalizedStagingRoot;
}
boolean hasStablePathIdentities() {
return !rootIdentity.isEmpty()
&& !stagingRootIdentity.isEmpty()
&& !sourceIdentity.isEmpty();
}
boolean isCanonicalInstall(File installRoot, File target) {
Path suppliedRoot = installRoot.toPath().toAbsolutePath().normalize();
Path suppliedTarget = target.toPath().toAbsolutePath().normalize();
return suppliedRoot.equals(normalizedStagingRoot)
&& suppliedTarget.equals(normalizedStagingRoot.resolve(id).normalize());
}
void verifyStagingRoot() throws IOException {
verifyPathIdentity(normalizedRoot, realRoot, rootIdentity, "datapack storage root");
verifyPathIdentity(
normalizedStagingRoot, realStagingRoot, stagingRootIdentity, "datapack staging root");
if (!Objects.equals(normalizedStagingRoot.getParent(), normalizedRoot)
|| !Files.isSameFile(normalizedStagingRoot.getParent(), normalizedRoot)) {
throw new IOException("Verified datapack staging root changed identity");
}
}
boolean consume(
File source,
File installRoot,
File target,
Entry entry,
String stagedHash
) throws IOException {
if (consumed) {
throw new IOException("Verified datapack staging authorization was already consumed");
}
consumed = true;
Path suppliedRoot = installRoot.toPath().toAbsolutePath().normalize();
Path suppliedTarget = target.toPath().toAbsolutePath().normalize();
if (!suppliedRoot.equals(normalizedStagingRoot)
|| !suppliedTarget.equals(normalizedStagingRoot.resolve(id).normalize())) {
throw new IOException("Verified datapack staging authorization was paired with a different path");
}
verifyAuthority(source, entry, stagedHash);
return legacyReplacementAuthorized;
}
boolean authorizeLegacyWorldReplacement(
File source,
File installRoot,
File target,
Entry entry,
String stagedHash,
String currentHash,
String currentMarkerHash
) throws IOException {
if (legacyStagingSnapshot == null
|| !"absent".equals(currentMarkerHash)
|| !Objects.equals(currentHash, legacyStagingSnapshot.contentHash())
|| legacyStagingSnapshot.targetIdentity().isEmpty()) {
return false;
}
Path suppliedRoot = installRoot.toPath().toAbsolutePath().normalize();
Path suppliedTarget = target.toPath().toAbsolutePath().normalize();
if (!Objects.equals(suppliedTarget.getParent(), suppliedRoot)
|| !Objects.equals(suppliedTarget.getFileName().toString(), id)
|| Files.isSameFile(suppliedRoot, normalizedStagingRoot)
|| Files.isSameFile(suppliedTarget, legacyStagingSnapshot.normalizedTarget())) {
return false;
}
verifyAuthority(source, entry, stagedHash);
verifyLegacyWorldSnapshot();
return true;
}
private void verifyAuthority(File source, Entry entry, String stagedHash) throws IOException {
verifyAuthorityState();
Path suppliedSource = source.toPath().toAbsolutePath().normalize();
if (!suppliedSource.equals(normalizedSource)) {
throw new IOException("Verified datapack staging authorization was paired with a different path");
}
if (!Objects.equals(entry.id, id)
|| !Objects.equals(entry.url, url)
|| !Objects.equals(entry.versionId, versionId)
|| !Objects.equals(entry.versionNumber, versionNumber)
|| !Objects.equals(entry.sha1, sha1)
|| !Objects.equals(stagedHash, desiredHash)) {
throw new IOException("Verified datapack staging authorization was paired with different metadata");
}
}
private void verifyAuthorityState() throws IOException {
verifyStagingRoot();
verifyPathIdentity(normalizedSource, realSource, sourceIdentity, "verified datapack extraction");
Manifest committedManifest = DatapackManifestStore.readCommittedManifest(normalizedRoot.toFile());
Entry committed = committedManifest.findById(id);
if (!manifestEntrySnapshotMatches(committed, committedEntrySnapshot)) {
throw new IOException("Committed datapack staging authority changed before installation");
}
if (committedEntrySnapshot != null && !legacyReplacementAuthorized) {
throw new IOException("Committed datapack staging authority conflicts with " + id);
}
}
private static boolean manifestEntrySnapshotMatches(Entry current, Entry expected) {
if (current == null || expected == null) {
return current == expected;
}
return Objects.equals(current.id, expected.id)
&& Objects.equals(current.url, expected.url)
&& Objects.equals(current.versionId, expected.versionId)
&& Objects.equals(current.versionNumber, expected.versionNumber)
&& Objects.equals(current.sha1, expected.sha1)
&& Objects.equals(current.filename, expected.filename)
&& Objects.equals(current.etag, expected.etag)
&& Objects.equals(current.lastModified, expected.lastModified)
&& current.installedEpoch == expected.installedEpoch
&& current.structuresImported == expected.structuresImported
&& DatapackManifestStore.copyList(current.structureKeys).equals(DatapackManifestStore.copyList(expected.structureKeys))
&& DatapackManifestStore.copyList(current.templateKeys).equals(DatapackManifestStore.copyList(expected.templateKeys))
&& Objects.equals(current.importedTargets, expected.importedTargets)
&& Objects.equals(current.importAttempts, expected.importAttempts)
&& Objects.equals(current.importedBundles, expected.importedBundles);
}
void verifyLegacyWorldSnapshot() throws IOException {
verifyAuthorityState();
if (legacyStagingSnapshot == null || legacyStagingSnapshot.targetIdentity().isEmpty()) {
throw new IOException("Missing stable canonical legacy datapack staging snapshot for " + id);
}
if (!Objects.equals(legacyStagingSnapshot.normalizedTarget().getParent(), normalizedStagingRoot)
|| !Files.isSameFile(
legacyStagingSnapshot.normalizedTarget().getParent(), normalizedStagingRoot)
|| !DatapackScratchRecovery.sameScratchVolume(
legacyStagingSnapshot.normalizedTarget(), normalizedStagingRoot)) {
throw new IOException("Changed or unsafe canonical legacy datapack staging target for " + id);
}
DatapackInstallPlanner.verifyDirectorySnapshot(
legacyStagingSnapshot.normalizedTarget().toFile(),
normalizedStagingRoot.toFile(),
legacyStagingSnapshot.contentHash(),
legacyStagingSnapshot.markerHash(),
legacyStagingSnapshot.targetIdentity(),
"canonical legacy datapack staging target"
);
if (!Objects.equals(
legacyStagingSnapshot.normalizedTarget().toRealPath(),
legacyStagingSnapshot.realTarget())) {
throw new IOException("Changed canonical legacy datapack staging target identity for " + id);
}
}
private static void verifyPathIdentity(
Path normalized,
Path expectedReal,
String expectedIdentity,
String purpose
) throws IOException {
if (Files.isSymbolicLink(normalized)
|| !Files.isDirectory(normalized, LinkOption.NOFOLLOW_LINKS)
|| !Objects.equals(normalized.toRealPath(), expectedReal)
|| (!expectedIdentity.isEmpty()
&& !Objects.equals(DatapackSupport.directoryIdentity(normalized.toFile()), expectedIdentity))) {
throw new IOException("Changed or unsafe " + purpose + " " + normalized);
}
}
}
@@ -1,5 +1,6 @@
package art.arcane.iris.core.lifecycle;
import art.arcane.iris.spi.CapabilityProbe;
import art.arcane.iris.util.common.scheduling.J;
import art.arcane.volmlib.util.scheduling.FoliaScheduler;
import org.bukkit.Bukkit;
@@ -202,33 +203,28 @@ public final class CapabilitySnapshot {
removeLevelMethod = null;
}
Method unloadWorldAsyncMethod = null;
try {
if (bukkitServer != null) {
unloadWorldAsyncMethod = CapabilityResolution.resolveMethod(bukkitServer.getClass(), "unloadWorldAsync", method -> {
Object resolvedServer = bukkitServer;
Method unloadWorldAsyncMethod = resolvedServer == null ? null : CapabilityProbe.attempt(
"server#unloadWorldAsync",
() -> CapabilityResolution.resolveMethod(resolvedServer.getClass(), "unloadWorldAsync", method -> {
Class<?>[] params = method.getParameterTypes();
return params.length == 3
&& World.class.equals(params[0])
&& boolean.class.equals(params[1])
&& "Consumer".equals(params[2].getSimpleName());
});
}
} catch (Throwable ignored) {
unloadWorldAsyncMethod = null;
}
}),
null);
Method chunkAtAsyncMethod = null;
try {
chunkAtAsyncMethod = CapabilityResolution.resolveMethod(World.class, "getChunkAtAsync", method -> {
Class<?>[] params = method.getParameterTypes();
return params.length == 3
&& int.class.equals(params[0])
&& int.class.equals(params[1])
&& boolean.class.equals(params[2]);
});
} catch (Throwable ignored) {
chunkAtAsyncMethod = null;
}
Method chunkAtAsyncMethod = CapabilityProbe.attempt(
"world#getChunkAtAsync",
() -> CapabilityResolution.resolveMethod(World.class, "getChunkAtAsync", method -> {
Class<?>[] params = method.getParameterTypes();
return params.length == 3
&& int.class.equals(params[0])
&& int.class.equals(params[1])
&& boolean.class.equals(params[2]);
}),
null);
return new CapabilitySnapshot(
serverFamily,
@@ -436,12 +432,8 @@ public final class CapabilitySnapshot {
}
private static boolean hasCanvasRuntime() {
try {
Class.forName("io.canvasmc.canvas.region.WorldRegionizer");
return true;
} catch (Throwable ignored) {
return false;
}
return CapabilityProbe.succeeds("io.canvasmc.canvas.region.WorldRegionizer",
() -> Class.forName("io.canvasmc.canvas.region.WorldRegionizer"));
}
private static boolean containsIgnoreCase(String value, String needle) {
@@ -452,14 +444,10 @@ public final class CapabilitySnapshot {
}
private static Object[] resolveWorldsProvider() throws Throwable {
try {
Class<?> worldsProviderClass = Class.forName("net.thenextlvl.worlds.api.WorldsProvider");
Class<?> levelStemClass = Class.forName("net.thenextlvl.worlds.api.generator.LevelStem");
Class<?> generatorTypeClass = Class.forName("net.thenextlvl.worlds.api.generator.GeneratorType");
Object provider = Bukkit.getServicesManager().load(worldsProviderClass);
String resolution = provider == null ? "inactive(service not registered)" : "active(service=" + provider.getClass().getName() + ")";
return new Object[]{provider, levelStemClass, generatorTypeClass, resolution};
} catch (Throwable ignored) {
Object[] direct = CapabilityProbe.attempt("net.thenextlvl.worlds.api.WorldsProvider",
CapabilitySnapshot::loadWorldsProvider, null);
if (direct != null) {
return direct;
}
Collection<Class<?>> knownServices = Bukkit.getServicesManager().getKnownServices();
@@ -490,4 +478,13 @@ public final class CapabilitySnapshot {
return new Object[]{null, null, null, "inactive(service scan found nothing)"};
}
private static Object[] loadWorldsProvider() throws ClassNotFoundException {
Class<?> worldsProviderClass = Class.forName("net.thenextlvl.worlds.api.WorldsProvider");
Class<?> levelStemClass = Class.forName("net.thenextlvl.worlds.api.generator.LevelStem");
Class<?> generatorTypeClass = Class.forName("net.thenextlvl.worlds.api.generator.GeneratorType");
Object provider = Bukkit.getServicesManager().load(worldsProviderClass);
String resolution = provider == null ? "inactive(service not registered)" : "active(service=" + provider.getClass().getName() + ")";
return new Object[]{provider, levelStemClass, generatorTypeClass, resolution};
}
}
@@ -2,6 +2,7 @@ package art.arcane.iris.core.lifecycle;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.util.common.io.Durability;
import java.io.File;
import java.io.IOException;
import java.nio.channels.FileChannel;
@@ -19,11 +20,15 @@ final class DirectoryDurability {
}
static void forceDirectoryRequired(Path directory) throws IOException {
if (!Durability.enabled()) {
return;
}
if (File.separatorChar == '\\') {
return;
}
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
channel.force(true);
Durability.force(channel);
} catch (UnsupportedOperationException failure) {
throw new IOException("Directory durability sync is unavailable for " + directory + ".", failure);
}
@@ -0,0 +1,21 @@
package art.arcane.iris.core.lifecycle;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.core.localization.IrisLanguage;
import art.arcane.iris.core.runtime.RuntimeInjection;
import art.arcane.iris.core.runtime.WorldRuntimeControlService;
import art.arcane.iris.spi.IrisServices;
public final class IrisRuntimeStatics {
private IrisRuntimeStatics() {
}
public static void reset() {
IrisServices.clear();
WorldLifecycleService.reset();
WorldRuntimeControlService.reset();
RuntimeInjection.reset();
ServerConfigurator.resetLoadedDatapackRuntime();
IrisLanguage.shutdown();
}
}
@@ -1,6 +1,7 @@
package art.arcane.iris.core.lifecycle;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.core.runtime.RuntimeInjection;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.core.tools.IrisToolbelt;
import art.arcane.iris.util.common.scheduling.J;
@@ -51,6 +52,12 @@ public final class WorldLifecycleService {
this.worldBackendByKey = new ConcurrentHashMap<>();
}
public static void reset() {
synchronized (WorldLifecycleService.class) {
instance = null;
}
}
public static WorldLifecycleService get() {
WorldLifecycleService current = instance;
if (current != null) {
@@ -76,6 +83,7 @@ public final class WorldLifecycleService {
public CompletableFuture<World> create(WorldLifecycleRequest request) {
WorldLifecycleBackend backend;
try {
RuntimeInjection.installIfDeferred();
if (request.callerKind() == WorldLifecycleCaller.FORCED_STUDIO) {
ServerConfigurator.requireWorldCreationReady(true);
} else {
@@ -6,6 +6,8 @@ import art.arcane.iris.core.nms.INMS;
import art.arcane.iris.core.nms.INMSBinding;
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.spi.CapabilityProbe;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.util.common.scheduling.J;
import art.arcane.volmlib.util.bukkit.WorldIdentity;
import art.arcane.volmlib.util.scheduling.FoliaScheduler;
@@ -412,7 +414,9 @@ final class WorldLifecycleSupport {
try {
Method closeMethod = levelStorageAccess.getClass().getMethod("close");
closeMethod.invoke(levelStorageAccess);
} catch (Throwable ignored) {
} catch (Throwable failure) {
IrisLogging.reportError("Failed to close the level storage access; the world session lock may still be held.",
unwrap(failure));
}
}
@@ -712,11 +716,8 @@ final class WorldLifecycleSupport {
if (server == null) {
return false;
}
try {
Method method = server.getClass().getMethod("isGlobalTickThread");
return Boolean.TRUE.equals(method.invoke(server));
} catch (Throwable ignored) {
return false;
}
return CapabilityProbe.attempt("server#isGlobalTickThread",
() -> Boolean.TRUE.equals(server.getClass().getMethod("isGlobalTickThread").invoke(server)),
Boolean.FALSE);
}
}
@@ -5,6 +5,7 @@ import art.arcane.iris.core.IrisWorldStorage;
import art.arcane.iris.core.WorldSlotKey;
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.WorldGeneratorSnapshot;
import art.arcane.iris.util.common.io.Durability;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
@@ -345,7 +346,7 @@ public final class WorldReplacementJournal {
while (buffer.hasRemaining()) {
channel.write(buffer);
}
channel.force(true);
Durability.force(channel);
}
forceDirectoryRequired(parent);
try {
@@ -204,8 +204,10 @@ public class MultiverseCoreLink {
try {
worldKey = WorldIdentity.parse(entry.getKey());
worldName = IrisWorldStorage.configuredWorldName(worldKey, levelName);
} catch (Throwable ignored) {
} catch (Throwable unreadable) {
// Not a world with a Bukkit startup name; Multiverse cannot be holding an entry for it.
IrisLogging.debug("Skipping Iris world registry entry " + entry.getKey()
+ " for Multiverse reconciliation: " + describe(unreadable));
continue;
}
try {
@@ -324,8 +326,8 @@ public class MultiverseCoreLink {
try {
return IrisWorlds.get().getWorlds().get(worldKey.toString());
} catch (Throwable unreadable) {
IrisLogging.debug("Could not read the Iris world registry for " + worldKey + ": "
+ describe(unreadable));
IrisLogging.reportError("Could not read the Iris world registry for " + worldKey
+ "; Multiverse keeps whatever generator it already recorded.", unreadable);
return null;
}
}
@@ -382,7 +384,8 @@ public class MultiverseCoreLink {
IrisDimension dimension = IrisWorlds.get().getDimension(worldKey.toString(), pack);
return dimension == null ? null : BukkitEnvironment.from(dimension.getEnvironment());
} catch (Throwable unreadable) {
IrisLogging.debug("Could not read the Iris dimension for " + worldKey + ": " + describe(unreadable));
IrisLogging.reportError("Could not read the Iris dimension for " + worldKey
+ "; Multiverse keeps whatever environment it already recorded.", unreadable);
return null;
}
}
@@ -471,7 +471,9 @@ public final class MultiverseGuardListener implements Listener {
for (String line : lines) {
target.sendMessage(line);
}
} catch (Throwable ignored) {
} catch (Throwable undeliverable) {
IrisLogging.debug("Falling back to plain delivery for a Multiverse guard message: "
+ describe(undeliverable));
for (String line : lines) {
ComponentMessenger.sendSection(sender, line);
}
@@ -1,5 +1,6 @@
package art.arcane.iris.core.link;
import art.arcane.iris.spi.CapabilityProbe;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.engine.data.cache.AtomicCache;
import art.arcane.volmlib.util.data.Cuboid;
@@ -63,12 +64,7 @@ public class WorldEditLink {
return true;
}
boolean present;
try {
present = detector.getAsBoolean();
} catch (Throwable ignored) {
return false;
}
boolean present = CapabilityProbe.attempt("WorldEdit", detector::getAsBoolean, Boolean.FALSE);
if (present) {
active.aquire(() -> Boolean.TRUE);
@@ -1,6 +1,7 @@
package art.arcane.iris.core.localization;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.spi.CapabilityProbe;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.volmlib.util.director.DirectorTextResolver;
@@ -212,11 +213,10 @@ public final class IrisLanguage {
if (root == null) {
return false;
}
try {
return overrideFile(root, configuredLocale()).equals(file.getAbsoluteFile());
} catch (RuntimeException ignored) {
return false;
}
File overrideRoot = root;
return CapabilityProbe.attempt("locale override path",
() -> overrideFile(overrideRoot, configuredLocale()).equals(file.getAbsoluteFile()),
Boolean.FALSE);
}
private static boolean applyReload(File root, String requestedLocale, SnapshotCapture capture) {
@@ -429,6 +429,8 @@ public final class IrisLanguage {
}
public static synchronized void shutdown() {
dataFolder = null;
PLAIN_MEMO.set(null);
PluginLanguageService current = selections;
selections = null;
if (current != null) {
@@ -35,6 +35,9 @@ import art.arcane.iris.util.common.scheduling.J;
import art.arcane.volmlib.util.scheduling.Looper;
import art.arcane.volmlib.util.scheduling.PrecisionStopwatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
@@ -43,6 +46,8 @@ import java.util.concurrent.atomic.AtomicReference;
public class IrisPregenerator {
private static final double INVALID = 9223372036854775807d;
private static final AtomicLong JOB_SEQUENCE = new AtomicLong(0L);
private static final long GATE_WAIT_MS = 50L;
private static final long RECLAIM_WAIT_MS = 150L;
private final long jobId;
private final PregenTask task;
private final PregeneratorMethod generator;
@@ -50,6 +55,8 @@ public class IrisPregenerator {
private final Looper ticker;
private final AtomicBoolean paused;
private final AtomicBoolean shutdown;
private final ReentrantLock stateLock = new ReentrantLock();
private final Condition stateChanged = stateLock.newCondition();
private final PregenRateTracker rateTracker;
private final KList<Integer> chunksPerSecondHistory;
private final AtomicLong generated;
@@ -193,6 +200,7 @@ public class IrisPregenerator {
public void close() {
shutdown.set(true);
signalState();
}
public void start() {
@@ -295,6 +303,7 @@ public class IrisPregenerator {
reclaimTectonicPlates(mantle);
}
});
shutdownStep("parallelism", PregenPerformanceProfile::restore);
shutdownStep("protocol", () -> {
IrisProtocolServer protocolServer = IrisServices.getOrNull(IrisProtocolServer.class);
if (protocolServer != null) {
@@ -337,7 +346,7 @@ public class IrisPregenerator {
}
previousLoaded = loaded;
J.sleep(150);
awaitStateChange(RECLAIM_WAIT_MS);
}
int loadedAfter = mantle.getLoadedRegionCount();
@@ -348,7 +357,7 @@ public class IrisPregenerator {
private void visitRegion(int x, int z, boolean regions) {
while (paused.get() && !shutdown.get()) {
J.sleep(50);
awaitStateChange(GATE_WAIT_MS);
}
if (shutdown.get()) {
@@ -376,7 +385,7 @@ public class IrisPregenerator {
if (!paused.get()) {
reclaimHeapPressure();
}
J.sleep(50);
awaitStateChange(GATE_WAIT_MS);
}
if (shutdown.get()) {
@@ -439,10 +448,32 @@ public class IrisPregenerator {
public void pause() {
paused.set(true);
signalState();
}
public void resume() {
paused.set(false);
signalState();
}
private void signalState() {
stateLock.lock();
try {
stateChanged.signalAll();
} finally {
stateLock.unlock();
}
}
private void awaitStateChange(long millis) {
stateLock.lock();
try {
stateChanged.await(millis, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
stateLock.unlock();
}
}
private PregenListener listenify(PregenListener listener) {
@@ -0,0 +1,136 @@
/*
* 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.core.pregenerator;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.BooleanSupplier;
import java.util.function.LongSupplier;
public final class PregenAdmissionGate {
public record Wait(long adaptiveMs, long permitMs) {
}
private final int permits;
private final long boundMs;
private final LongSupplier clock;
private final ReentrantLock lock = new ReentrantLock(true);
private final Condition changed = lock.newCondition();
private int free;
private volatile int freeSnapshot;
public PregenAdmissionGate(int permits, long boundMs, LongSupplier clock) {
if (permits < 1) {
throw new IllegalArgumentException("A pregen admission gate needs at least one permit");
}
if (boundMs < 1L) {
throw new IllegalArgumentException("A pregen admission gate needs a positive wait bound");
}
this.permits = permits;
this.boundMs = boundMs;
this.clock = clock;
this.free = permits;
this.freeSnapshot = permits;
}
public int permits() {
return permits;
}
public int availablePermits() {
return freeSnapshot;
}
public Wait admit(BooleanSupplier overAdaptiveLimit, BooleanSupplier cancelled) throws InterruptedException {
long adaptiveMs = 0L;
long permitMs = 0L;
lock.lock();
try {
while (true) {
if (cancelled.getAsBoolean()) {
return null;
}
boolean blockedByLimit = overAdaptiveLimit.getAsBoolean();
if (!blockedByLimit && free > 0) {
free--;
freeSnapshot = free;
return new Wait(adaptiveMs, permitMs);
}
long start = clock.getAsLong();
changed.await(boundMs, TimeUnit.MILLISECONDS);
long waited = Math.max(0L, clock.getAsLong() - start);
if (blockedByLimit) {
adaptiveMs += waited;
} else {
permitMs += waited;
}
}
} finally {
lock.unlock();
}
}
public void release() {
lock.lock();
try {
if (free < permits) {
free++;
freeSnapshot = free;
}
changed.signalAll();
} finally {
lock.unlock();
}
}
public void wake() {
lock.lock();
try {
changed.signalAll();
} finally {
lock.unlock();
}
}
public boolean awaitDrain(long warningInterval, TimeUnit timeUnit, Runnable onWait) {
boolean interrupted = false;
lock.lock();
try {
while (free < permits) {
boolean signalled = false;
try {
signalled = changed.await(warningInterval, timeUnit);
} catch (InterruptedException e) {
interrupted = true;
continue;
}
if (!signalled && free < permits) {
onWait.run();
}
}
} finally {
lock.unlock();
}
return interrupted;
}
}
@@ -16,27 +16,28 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.util.project.hunk;
package art.arcane.iris.core.pregenerator;
public enum HunkFace {
TOP(art.arcane.volmlib.util.hunk.HunkFace.TOP),
BOTTOM(art.arcane.volmlib.util.hunk.HunkFace.BOTTOM),
EAST(art.arcane.volmlib.util.hunk.HunkFace.EAST),
WEST(art.arcane.volmlib.util.hunk.HunkFace.WEST),
NORTH(art.arcane.volmlib.util.hunk.HunkFace.NORTH),
SOUTH(art.arcane.volmlib.util.hunk.HunkFace.SOUTH);
import art.arcane.iris.spi.IrisLogging;
private final art.arcane.volmlib.util.hunk.HunkFace shared;
HunkFace(art.arcane.volmlib.util.hunk.HunkFace shared) {
this.shared = shared;
public final class PregenDiagnostics {
private PregenDiagnostics() {
}
public art.arcane.volmlib.util.hunk.HunkFace shared() {
return shared;
public static void probeFailed(String probe, Throwable failure) {
IrisLogging.debug("Pregen probe " + describeProbe(probe) + " failed: " + describeFailure(failure));
}
public static HunkFace fromShared(art.arcane.volmlib.util.hunk.HunkFace shared) {
return HunkFace.valueOf(shared.name());
public static String describeProbe(String probe) {
return probe == null || probe.isBlank() ? "unnamed" : probe;
}
public static String describeFailure(Throwable failure) {
if (failure == null) {
return "no throwable reported";
}
String message = failure.getMessage();
return message == null ? failure.getClass().getName() : failure.getClass().getName() + ": " + message;
}
}
@@ -22,6 +22,9 @@ import art.arcane.iris.spi.IrisLogging;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
import art.arcane.volmlib.util.math.M;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.BooleanSupplier;
import java.util.function.Supplier;
@@ -33,6 +36,8 @@ public final class PregenMantleBackpressure {
private final Runnable onBudgetTimeout;
private final Supplier<String> diagnostics;
private final BooleanSupplier cancelled;
private final ReentrantLock lock = new ReentrantLock();
private final Condition progressed = lock.newCondition();
public PregenMantleBackpressure(Supplier<Mantle> mantleSupplier, int maxResidentTectonicPlates, int waitMs, long timeoutMs, Runnable onBudgetTimeout, Supplier<String> diagnostics) {
this(mantleSupplier, maxResidentTectonicPlates, waitMs, timeoutMs, onBudgetTimeout, diagnostics, () -> false);
@@ -108,10 +113,7 @@ public final class PregenMantleBackpressure {
+ "), freed " + freed + " last pass, waited " + elapsed + "ms.");
}
try {
Thread.sleep(waitMs);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
if (!awaitProgress()) {
return;
}
}
@@ -154,15 +156,34 @@ public final class PregenMantleBackpressure {
+ (mantle != null ? " (" + mantle.getLoadedRegionCount() + " plates resident)" : "") + ".");
}
try {
Thread.sleep(waitMs);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
if (!awaitProgress()) {
return;
}
}
}
public void signalProgress() {
lock.lock();
try {
progressed.signalAll();
} finally {
lock.unlock();
}
}
private boolean awaitProgress() {
lock.lock();
try {
progressed.await(waitMs, TimeUnit.MILLISECONDS);
return true;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
} finally {
lock.unlock();
}
}
private boolean isCancelled() {
if (Thread.currentThread().isInterrupted()) {
return true;
@@ -170,7 +191,8 @@ public final class PregenMantleBackpressure {
try {
return cancelled.getAsBoolean();
} catch (Throwable ignored) {
} catch (Throwable e) {
PregenDiagnostics.probeFailed("pregen cancellation state", e);
return false;
}
}
@@ -178,7 +200,8 @@ public final class PregenMantleBackpressure {
private Mantle resolveMantle() {
try {
return mantleSupplier.get();
} catch (Throwable ignored) {
} catch (Throwable e) {
PregenDiagnostics.probeFailed("mantle handle for backpressure", e);
return null;
}
}
@@ -28,8 +28,11 @@ import art.arcane.iris.util.project.stream.ProceduralStream;
import art.arcane.iris.util.project.stream.utility.CachedDoubleStream2D;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicInteger;
public final class PregenPerformanceProfile {
private static final AtomicInteger PARALLELISM_HOLDERS = new AtomicInteger();
private PregenPerformanceProfile() {
}
@@ -45,12 +48,33 @@ public final class PregenPerformanceProfile {
}
if (MultiBurst.burst.raiseParallelism(pregenBurstParallelism(Runtime.getRuntime().availableProcessors()))) {
PARALLELISM_HOLDERS.incrementAndGet();
changed = true;
}
return changed;
}
/**
* Releases one hold on the pregeneration burst parallelism. The pool returns to the parallelism it had
* before the first pregeneration raised it once the last concurrent job releases its hold.
*/
public static boolean restore() {
while (true) {
int holders = PARALLELISM_HOLDERS.get();
if (holders <= 0) {
return false;
}
if (PARALLELISM_HOLDERS.compareAndSet(holders, holders - 1)) {
return holders == 1 && MultiBurst.burst.restoreParallelism();
}
}
}
static int parallelismHolders() {
return PARALLELISM_HOLDERS.get();
}
public static void apply(Engine engine) {
apply();
if (engine != null) {
@@ -6,10 +6,6 @@ import art.arcane.volmlib.util.documentation.RegionCoordinates;
import java.io.File;
public interface PregenCache {
default boolean isThreadSafe() {
return false;
}
@ChunkCoordinates
boolean isChunkCached(int x, int z);
@@ -32,16 +28,10 @@ public interface PregenCache {
}
default PregenCache sync() {
if (isThreadSafe()) return this;
return new SynchronizedCache(this);
return this;
}
PregenCache EMPTY = new PregenCache() {
@Override
public boolean isThreadSafe() {
return true;
}
@Override
public boolean isChunkCached(int x, int z) {
return false;
@@ -26,6 +26,9 @@ import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLongArray;
import java.util.concurrent.atomic.AtomicReferenceArray;
public class PregenCacheImpl implements PregenCache {
// Daemon threads: this static pool is never shut down, and non-daemon threads here block JVM exit
@@ -36,12 +39,15 @@ public class PregenCacheImpl implements PregenCache {
return thread;
});
private static final AtomicBoolean DISPATCHER_REGISTERED = new AtomicBoolean();
private static final short SIZE = 1024;
private static final int SIZE = 1024;
private static final int RETIRED_WRITE_RETRIES = 4;
private final File directory;
private final int maxSize;
private final AtomicBoolean missingStorageReported = new AtomicBoolean();
private final Object residencyLock = new Object();
private final Long2ObjectLinkedOpenHashMap<Plate> cache = new Long2ObjectLinkedOpenHashMap<>();
private volatile Plate recent;
public PregenCacheImpl(File directory, int maxSize) {
this.directory = directory;
@@ -78,35 +84,119 @@ public class PregenCacheImpl implements PregenCache {
@Override
@ChunkCoordinates
public void cacheChunk(int x, int z) {
getPlate(PowerOfTwoCoordinates.floorDivPow2(x, 10), PowerOfTwoCoordinates.floorDivPow2(z, 10)).cache(
PowerOfTwoCoordinates.localMaskPow2(PowerOfTwoCoordinates.floorDivPow2(x, 5), PowerOfTwoCoordinates.REGION_CHUNK_BITS),
PowerOfTwoCoordinates.localMaskPow2(PowerOfTwoCoordinates.floorDivPow2(z, 5), PowerOfTwoCoordinates.REGION_CHUNK_BITS),
region -> region.cache(
PowerOfTwoCoordinates.localMaskPow2(x, PowerOfTwoCoordinates.REGION_CHUNK_BITS),
PowerOfTwoCoordinates.localMaskPow2(z, PowerOfTwoCoordinates.REGION_CHUNK_BITS)
)
);
int plateX = PowerOfTwoCoordinates.floorDivPow2(x, 10);
int plateZ = PowerOfTwoCoordinates.floorDivPow2(z, 10);
int regionX = PowerOfTwoCoordinates.localMaskPow2(PowerOfTwoCoordinates.floorDivPow2(x, 5), PowerOfTwoCoordinates.REGION_CHUNK_BITS);
int regionZ = PowerOfTwoCoordinates.localMaskPow2(PowerOfTwoCoordinates.floorDivPow2(z, 5), PowerOfTwoCoordinates.REGION_CHUNK_BITS);
int localX = PowerOfTwoCoordinates.localMaskPow2(x, PowerOfTwoCoordinates.REGION_CHUNK_BITS);
int localZ = PowerOfTwoCoordinates.localMaskPow2(z, PowerOfTwoCoordinates.REGION_CHUNK_BITS);
for (int attempt = 0; attempt < RETIRED_WRITE_RETRIES; attempt++) {
Plate plate = getPlate(plateX, plateZ);
plate.cache(regionX, regionZ, region -> region.cache(localX, localZ));
if (!plate.retired) {
return;
}
}
}
@Override
@RegionCoordinates
public void cacheRegion(int x, int z) {
getPlate(PowerOfTwoCoordinates.floorDivPow2(x, 5), PowerOfTwoCoordinates.floorDivPow2(z, 5)).cache(
PowerOfTwoCoordinates.localMaskPow2(x, PowerOfTwoCoordinates.REGION_CHUNK_BITS),
PowerOfTwoCoordinates.localMaskPow2(z, PowerOfTwoCoordinates.REGION_CHUNK_BITS),
Region::cache
);
int plateX = PowerOfTwoCoordinates.floorDivPow2(x, 5);
int plateZ = PowerOfTwoCoordinates.floorDivPow2(z, 5);
int regionX = PowerOfTwoCoordinates.localMaskPow2(x, PowerOfTwoCoordinates.REGION_CHUNK_BITS);
int regionZ = PowerOfTwoCoordinates.localMaskPow2(z, PowerOfTwoCoordinates.REGION_CHUNK_BITS);
for (int attempt = 0; attempt < RETIRED_WRITE_RETRIES; attempt++) {
Plate plate = getPlate(plateX, plateZ);
plate.cache(regionX, regionZ, Region::cache);
if (!plate.retired) {
return;
}
}
}
@Override
public void write() {
if (cache.isEmpty()) {
List<Plate> resident;
synchronized (residencyLock) {
if (cache.isEmpty()) {
return;
}
resident = new ArrayList<>(cache.values());
}
writePlates(resident);
}
@Override
public void trim(long unloadDuration) {
synchronized (residencyLock) {
if (cache.isEmpty()) {
return;
}
long threshold = System.currentTimeMillis() - unloadDuration;
List<Plate> resident = new ArrayList<>(cache.size());
Iterator<Plate> iterator = cache.values().iterator();
while (iterator.hasNext()) {
Plate plate = iterator.next();
if (plate.lastAccess < threshold) {
iterator.remove();
plate.retired = true;
}
resident.add(plate);
}
writePlates(resident);
}
}
private Plate getPlate(int x, int z) {
long key = key(x, z);
Plate memo = recent;
if (memo != null && memo.key == key && !memo.retired) {
return memo;
}
return residePlate(key, x, z);
}
private Plate residePlate(long key, int x, int z) {
Plate resolved;
synchronized (residencyLock) {
resolved = cache.getAndMoveToFirst(key);
if (resolved == null) {
resolved = readPlate(key, x, z);
cache.putAndMoveToFirst(key, resolved);
List<Plate> evicted = null;
while (cache.size() > maxSize) {
Plate removed = cache.removeLast();
removed.retired = true;
if (evicted == null) {
evicted = new ArrayList<>(2);
}
evicted.add(removed);
}
if (evicted != null) {
writePlates(evicted);
}
}
}
recent = resolved;
return resolved;
}
private void writePlates(List<Plate> plates) {
if (plates.isEmpty()) {
return;
}
List<CompletableFuture<Void>> futures = new ArrayList<>(cache.size());
for (Plate plate : cache.values()) {
if (!plate.dirty) {
List<CompletableFuture<Void>> futures = new ArrayList<>(plates.size());
for (Plate plate : plates) {
if (!plate.dirty.get()) {
continue;
}
futures.add(CompletableFuture.runAsync(() -> writePlate(plate), DISPATCHER));
@@ -117,84 +207,41 @@ public class PregenCacheImpl implements PregenCache {
}
}
@Override
public void trim(long unloadDuration) {
if (cache.isEmpty()) {
return;
}
long threshold = System.currentTimeMillis() - unloadDuration;
List<CompletableFuture<Void>> futures = new ArrayList<>(cache.size());
Iterator<Plate> iterator = cache.values().iterator();
while (iterator.hasNext()) {
Plate plate = iterator.next();
if (plate.lastAccess < threshold) {
iterator.remove();
}
futures.add(CompletableFuture.runAsync(() -> writePlate(plate), DISPATCHER));
}
for (CompletableFuture<Void> future : futures) {
future.join();
}
}
private Plate getPlate(int x, int z) {
long key = key(x, z);
Plate plate = cache.getAndMoveToFirst(key);
if (plate != null) {
return plate;
}
Plate loaded = readPlate(x, z);
cache.putAndMoveToFirst(key, loaded);
if (cache.size() > maxSize) {
List<CompletableFuture<Void>> futures = new ArrayList<>(cache.size() - maxSize);
while (cache.size() > maxSize) {
Plate evicted = cache.removeLast();
futures.add(CompletableFuture.runAsync(() -> writePlate(evicted), DISPATCHER));
}
for (CompletableFuture<Void> future : futures) {
future.join();
}
}
return loaded;
}
private Plate readPlate(int x, int z) {
private Plate readPlate(long key, int x, int z) {
File file = fileForPlate(x, z);
if (!file.exists()) {
return new Plate(x, z);
return new Plate(key, x, z);
}
try (DataInputStream input = new DataInputStream(new LZ4BlockInputStream(new FileInputStream(file)))) {
return readPlate(x, z, input);
return readPlate(key, x, z, input);
} catch (IOException e) {
IrisLogging.error("Failed to read pregen cache " + file);
IrisLogging.reportError(e);
}
return new Plate(x, z);
return new Plate(key, x, z);
}
private void writePlate(Plate plate) {
if (!plate.dirty) {
return;
}
if (!prepareCacheDirectory()) {
return;
}
synchronized (plate) {
if (!plate.dirty.compareAndSet(true, false)) {
return;
}
if (!prepareCacheDirectory()) {
plate.dirty.set(true);
return;
}
File file = null;
try {
file = fileForPlate(plate.x, plate.z);
IO.write(file, output -> new DataOutputStream(new LZ4BlockOutputStream(output)), plate::write);
plate.dirty = false;
} catch (Throwable e) {
IrisLogging.error("Failed to write pregen cache " + (file != null ? file : "c." + plate.x + "." + plate.z));
IrisLogging.reportError(e);
File file = null;
try {
file = fileForPlate(plate.x, plate.z);
IO.write(file, output -> new DataOutputStream(new LZ4BlockOutputStream(output)), plate::write);
} catch (Throwable e) {
plate.dirty.set(true);
IrisLogging.error("Failed to write pregen cache " + (file != null ? file : "c." + plate.x + "." + plate.z));
IrisLogging.reportError(e);
}
}
}
@@ -243,36 +290,48 @@ public class PregenCacheImpl implements PregenCache {
}
private static class Plate {
private final long key;
private final int x;
private final int z;
private short count;
private Region[] regions;
private boolean dirty;
private long lastAccess;
private final AtomicInteger count;
private final AtomicReferenceArray<Region> regions;
private final AtomicBoolean dirty = new AtomicBoolean();
private volatile long lastAccess;
private volatile boolean retired;
private Plate(int x, int z) {
this(x, z, (short) 0, new Region[1024]);
private Plate(long key, int x, int z) {
this(key, x, z, 0, new AtomicReferenceArray<>(SIZE));
}
private Plate(int x, int z, short count, Region[] regions) {
private Plate(long key, int x, int z, int count, AtomicReferenceArray<Region> regions) {
this.key = key;
this.x = x;
this.z = z;
this.count = count;
this.count = new AtomicInteger(count);
this.regions = regions;
this.lastAccess = System.currentTimeMillis();
}
private int completed() {
return Math.min(count.get(), SIZE);
}
private boolean cache(int x, int z, RegionCacheOp op) {
lastAccess = System.currentTimeMillis();
if (count == SIZE) {
if (completed() == SIZE) {
return false;
}
AtomicReferenceArray<Region> current = regions;
if (current == null) {
return false;
}
int index = PowerOfTwoCoordinates.packLocal32(x, z);
Region region = regions[index];
Region region = current.get(index);
if (region == null) {
region = new Region();
regions[index] = region;
Region created = new Region();
region = current.compareAndSet(index, null, created) ? created : current.get(index);
}
CacheResult result = op.apply(region);
@@ -280,25 +339,26 @@ public class PregenCacheImpl implements PregenCache {
return false;
}
dirty = true;
dirty.set(true);
if (result != CacheResult.COMPLETED) {
return false;
}
count++;
if (count == SIZE) {
regions = null;
}
return true;
return count.incrementAndGet() >= SIZE;
}
private boolean isCached(int x, int z, RegionPredicate predicate) {
lastAccess = System.currentTimeMillis();
if (count == SIZE) {
if (completed() == SIZE) {
return true;
}
Region region = regions[PowerOfTwoCoordinates.packLocal32(x, z)];
AtomicReferenceArray<Region> current = regions;
if (current == null) {
return true;
}
Region region = current.get(PowerOfTwoCoordinates.packLocal32(x, z));
if (region == null) {
return false;
}
@@ -306,12 +366,15 @@ public class PregenCacheImpl implements PregenCache {
}
private void write(DataOutput output) throws IOException {
Varint.writeSignedVarInt(count, output);
if (regions == null) {
int total = completed();
Varint.writeSignedVarInt(total, output);
AtomicReferenceArray<Region> current = regions;
if (total == SIZE || current == null) {
return;
}
for (Region region : regions) {
for (int index = 0; index < SIZE; index++) {
Region region = current.get(index);
output.writeBoolean(region == null);
if (region != null) {
region.write(output);
@@ -321,33 +384,32 @@ public class PregenCacheImpl implements PregenCache {
}
private static class Region {
private short count;
private long[] words;
private final AtomicInteger count;
private final AtomicLongArray words;
private Region() {
this((short) 0, new long[64]);
this(0, new AtomicLongArray(64));
}
private Region(short count, long[] words) {
this.count = count;
private Region(int count, AtomicLongArray words) {
this.count = new AtomicInteger(count);
this.words = words;
}
private int completed() {
return Math.min(count.get(), SIZE);
}
private CacheResult cache() {
if (count == SIZE) {
return CacheResult.NOOP;
}
count = SIZE;
words = null;
return CacheResult.COMPLETED;
return count.getAndSet(SIZE) >= SIZE ? CacheResult.NOOP : CacheResult.COMPLETED;
}
private CacheResult cache(int x, int z) {
if (count == SIZE) {
if (completed() == SIZE) {
return CacheResult.NOOP;
}
long[] value = words;
AtomicLongArray value = words;
if (value == null) {
return CacheResult.NOOP;
}
@@ -355,73 +417,79 @@ public class PregenCacheImpl implements PregenCache {
int index = PowerOfTwoCoordinates.packLocal32(x, z);
int wordIndex = index >> 6;
long bit = 1L << (index & 63);
boolean current = (value[wordIndex] & bit) != 0L;
if (current) {
return CacheResult.NOOP;
while (true) {
long current = value.get(wordIndex);
if ((current & bit) != 0L) {
return CacheResult.NOOP;
}
if (value.compareAndSet(wordIndex, current, current | bit)) {
break;
}
}
count++;
if (count == SIZE) {
words = null;
return CacheResult.COMPLETED;
}
value[wordIndex] = value[wordIndex] | bit;
return CacheResult.SET;
return count.incrementAndGet() >= SIZE ? CacheResult.COMPLETED : CacheResult.SET;
}
private boolean isCached() {
return count == SIZE;
return completed() == SIZE;
}
private boolean isCached(int x, int z) {
int index = PowerOfTwoCoordinates.packLocal32(x, z);
if (count == SIZE) {
if (completed() == SIZE) {
return true;
}
return (words[index >> 6] & (1L << (index & 63))) != 0L;
AtomicLongArray value = words;
if (value == null) {
return true;
}
int index = PowerOfTwoCoordinates.packLocal32(x, z);
return (value.get(index >> 6) & (1L << (index & 63))) != 0L;
}
private void write(DataOutput output) throws IOException {
Varint.writeSignedVarInt(count, output);
if (words == null) {
int total = completed();
Varint.writeSignedVarInt(total, output);
AtomicLongArray value = words;
if (total == SIZE || value == null) {
return;
}
for (long word : words) {
Varint.writeUnsignedVarLong(word, output);
for (int index = 0; index < value.length(); index++) {
Varint.writeUnsignedVarLong(value.get(index), output);
}
}
}
private static Plate readPlate(int x, int z, DataInput input) throws IOException {
private static Plate readPlate(long key, int x, int z, DataInput input) throws IOException {
int count = Varint.readSignedVarInt(input);
if (count == 1024) {
return new Plate(x, z, SIZE, null);
if (count == SIZE) {
return new Plate(key, x, z, SIZE, null);
}
Region[] regions = new Region[1024];
for (int i = 0; i < regions.length; i++) {
AtomicReferenceArray<Region> regions = new AtomicReferenceArray<>(SIZE);
for (int index = 0; index < SIZE; index++) {
boolean isNull = input.readBoolean();
if (!isNull) {
regions[i] = readRegion(input);
regions.set(index, readRegion(input));
}
}
return new Plate(x, z, (short) count, regions);
return new Plate(key, x, z, count, regions);
}
private static Region readRegion(DataInput input) throws IOException {
int count = Varint.readSignedVarInt(input);
if (count == 1024) {
if (count == SIZE) {
return new Region(SIZE, null);
}
long[] words = new long[64];
for (int i = 0; i < words.length; i++) {
words[i] = Varint.readUnsignedVarLong(input);
AtomicLongArray words = new AtomicLongArray(64);
for (int index = 0; index < words.length(); index++) {
words.set(index, Varint.readUnsignedVarLong(input));
}
return new Region((short) count, words);
return new Region(count, words);
}
}
@@ -1,50 +0,0 @@
package art.arcane.iris.core.pregenerator.cache;
record SynchronizedCache(PregenCache cache) implements PregenCache {
@Override
public boolean isThreadSafe() {
return true;
}
@Override
public boolean isChunkCached(int x, int z) {
synchronized (cache) {
return cache.isChunkCached(x, z);
}
}
@Override
public boolean isRegionCached(int x, int z) {
synchronized (cache) {
return cache.isRegionCached(x, z);
}
}
@Override
public void cacheChunk(int x, int z) {
synchronized (cache) {
cache.cacheChunk(x, z);
}
}
@Override
public void cacheRegion(int x, int z) {
synchronized (cache) {
cache.cacheRegion(x, z);
}
}
@Override
public void write() {
synchronized (cache) {
cache.write();
}
}
@Override
public void trim(long unloadDuration) {
synchronized (cache) {
cache.trim(unloadDuration);
}
}
}
@@ -22,6 +22,8 @@ import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.core.IrisPaperLikeBackendMode;
import art.arcane.iris.core.IrisRuntimeSchedulerMode;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.pregenerator.PregenAdmissionGate;
import art.arcane.iris.core.pregenerator.PregenDiagnostics;
import art.arcane.iris.core.pregenerator.PregenListener;
import art.arcane.iris.core.pregenerator.PregenMantleBackpressure;
import art.arcane.iris.core.pregenerator.PregeneratorMethod;
@@ -50,7 +52,6 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -67,6 +68,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
private static final int ADAPTIVE_RECOVERY_INTERVAL = 8;
private static final long CLOSE_DRAIN_WARNING_SECONDS = 60L;
private static final long FLUSH_TIMEOUT_SECONDS = 120L;
private static final long ADMISSION_WAIT_BOUND_MS = 500L;
private final World world;
private final IrisRuntimeSchedulerMode runtimeSchedulerMode;
private final IrisPaperLikeBackendMode paperLikeBackendMode;
@@ -81,7 +83,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
private final String chunkAccessMode;
private final ChunkRequestExecutor executor;
private final Executor slowRequestExecutor;
private final Semaphore semaphore;
private final PregenAdmissionGate admission;
private final int threads;
private final int slowRequestWarningSeconds;
private final int slowRequestWarnIntervalMs;
@@ -110,7 +112,6 @@ public class AsyncPregenMethod implements PregeneratorMethod {
private final AtomicLong lastProgressAt = new AtomicLong(M.ms());
private final AtomicBoolean closing = new AtomicBoolean();
private final AtomicBoolean holdsWorkerBoost = new AtomicBoolean();
private final Object permitMonitor = new Object();
private volatile Engine metricsEngine;
private volatile Mantle cachedMantle;
private final PregenMantleBackpressure backpressure;
@@ -160,7 +161,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
this.runtimeCpuThreads = detectedCpuThreads;
this.effectiveWorkerThreads = workerThreadsForCap;
this.recommendedRuntimeConcurrencyCap = configuredThreads;
this.semaphore = new Semaphore(this.threads, true);
this.admission = new PregenAdmissionGate(this.threads, ADMISSION_WAIT_BOUND_MS, M::ms);
this.slowRequestWarningSeconds = pregen.getChunkLoadTimeoutSeconds();
this.slowRequestExecutor = CompletableFuture.delayedExecutor(this.slowRequestWarningSeconds, TimeUnit.SECONDS);
this.slowRequestWarnIntervalMs = pregen.getTimeoutWarnIntervalMs();
@@ -212,7 +213,8 @@ public class AsyncPregenMethod implements PregeneratorMethod {
if (coreThreads instanceof Thread[] threadsArray) {
return threadsArray.length;
}
} catch (Throwable ignored) {
} catch (Throwable e) {
PregenDiagnostics.probeFailed("moonrise worker pool size", e);
}
return -1;
@@ -238,7 +240,8 @@ public class AsyncPregenMethod implements PregeneratorMethod {
int resolved = radius > 0 ? Math.max(1, (int) Math.ceil(radius / 32.0)) : 2;
evictionWindowRegions = resolved;
return resolved;
} catch (Throwable ignored) {
} catch (Throwable e) {
PregenDiagnostics.probeFailed("mantle radius for eviction window", e);
return 2;
}
}
@@ -431,7 +434,9 @@ public class AsyncPregenMethod implements PregeneratorMethod {
private void unloadChunkSafely(int cx, int cz) {
try {
world.removePluginChunkTicket(cx, cz, BukkitPlatform.plugin());
} catch (Throwable ignored) {
} catch (Throwable e) {
IrisLogging.reportError("Async pregen could not release the plugin chunk ticket at " + cx + "," + cz
+ " in world " + world.getName() + "; the chunk stays pinned in memory.", e);
}
try {
@@ -492,11 +497,12 @@ public class AsyncPregenMethod implements PregeneratorMethod {
}
private Chunk onChunkFutureFailure(int x, int z, Throwable throwable) {
try {
IrisLogging.warn("Failed async pregen chunk load at " + x + "," + z + ". " + metricsSnapshot());
IrisLogging.reportError("Failed async pregen chunk load at " + x + "," + z + ".", throwable);
IrisLogging.reportError(throwable);
try {
IrisLogging.warn("Async pregen state at the failed chunk " + x + "," + z + ". " + metricsSnapshot());
} catch (Throwable e) {
PregenDiagnostics.probeFailed("pregen metrics snapshot", e);
}
return null;
@@ -521,11 +527,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
} catch (Throwable e) {
IrisLogging.reportError(e);
} finally {
try {
markFinished(success);
} finally {
semaphore.release();
}
markFinished(success);
}
}
@@ -581,7 +583,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
int next = Math.max(adaptiveMinInFlightLimit, current - 1);
if (adaptiveInFlightLimit.compareAndSet(current, next)) {
logAdaptiveLimit("decrease", next);
notifyPermitWaiters();
admission.wake();
return;
}
}
@@ -597,7 +599,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
int next = nextAdaptiveInFlightLimit(current, threads);
if (adaptiveInFlightLimit.compareAndSet(current, next)) {
logAdaptiveLimit("increase", next);
notifyPermitWaiters();
admission.wake();
return;
}
}
@@ -683,7 +685,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
private String metricsSnapshot() {
long stalledFor = Math.max(0L, M.ms() - lastProgressAt.get());
return "world=" + world.getName()
+ " permits=" + semaphore.availablePermits() + "/" + threads
+ " permits=" + admission.availablePermits() + "/" + threads
+ " adaptiveLimit=" + adaptiveInFlightLimit.get()
+ " inFlight=" + inFlight.get()
+ " submitted=" + submitted.get()
@@ -710,13 +712,8 @@ public class AsyncPregenMethod implements PregeneratorMethod {
if (after < 0) {
inFlight.compareAndSet(after, 0);
}
notifyPermitWaiters();
}
private void notifyPermitWaiters() {
synchronized (permitMonitor) {
permitMonitor.notifyAll();
}
admission.release();
backpressure.signalProgress();
}
private void recordAdaptiveWait(long waitedMs) {
@@ -738,7 +735,9 @@ public class AsyncPregenMethod implements PregeneratorMethod {
if (engine != null) {
try {
engine.getMantle().cleanupChunksCoveredBy(x, z, true, listener::onChunkCleaned);
} catch (Throwable ignored) {
} catch (Throwable e) {
IrisLogging.reportError("Async pregen mantle cleanup failed at chunk " + x + "," + z
+ " in world " + world.getName() + "; tectonic plates for that chunk stay resident.", e);
}
}
}
@@ -759,7 +758,8 @@ public class AsyncPregenMethod implements PregeneratorMethod {
metricsEngine = resolvedEngine;
}
return resolvedEngine;
} catch (Throwable ignored) {
} catch (Throwable e) {
PregenDiagnostics.probeFailed("engine access for world " + world.getName(), e);
return null;
}
}
@@ -809,14 +809,12 @@ public class AsyncPregenMethod implements PregeneratorMethod {
@Override
public void close() {
closing.set(true);
notifyPermitWaiters();
admission.wake();
// A stop request interrupts the pregen worker; shield the drain and flush so chunks still hit disk.
boolean interrupted = Thread.interrupted();
try {
interrupted |= awaitDrain(
semaphore,
threads,
interrupted |= admission.awaitDrain(
CLOSE_DRAIN_WARNING_SECONDS,
TimeUnit.SECONDS,
() -> IrisLogging.warn("Async pregen is still draining outstanding chunks. " + metricsSnapshot())
@@ -834,26 +832,6 @@ public class AsyncPregenMethod implements PregeneratorMethod {
}
}
static boolean awaitDrain(
Semaphore semaphore,
int permits,
long warningInterval,
TimeUnit timeUnit,
Runnable onWait
) {
boolean interrupted = false;
while (true) {
try {
if (semaphore.tryAcquire(permits, warningInterval, timeUnit)) {
return interrupted;
}
onWait.run();
} catch (InterruptedException e) {
interrupted = true;
}
}
}
private boolean isCancelled() {
return closing.get() || Thread.currentThread().isInterrupted();
}
@@ -881,37 +859,26 @@ public class AsyncPregenMethod implements PregeneratorMethod {
return;
}
PregenAdmissionGate.Wait waited;
try {
long waitStart = M.ms();
synchronized (permitMonitor) {
while (inFlight.get() >= adaptiveInFlightLimit.get()) {
if (isCancelled()) {
return;
}
permitMonitor.wait(500L);
}
}
long adaptiveWait = Math.max(0L, M.ms() - waitStart);
if (adaptiveWait > 0L) {
recordAdaptiveWait(adaptiveWait);
}
long permitWaitStart = M.ms();
while (!semaphore.tryAcquire(1, TimeUnit.SECONDS)) {
if (isCancelled()) {
return;
}
}
long permitWait = Math.max(0L, M.ms() - permitWaitStart);
if (permitWait > 0L) {
recordPermitWait(permitWait);
}
waited = admission.admit(() -> inFlight.get() >= adaptiveInFlightLimit.get(), this::isCancelled);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
if (waited == null) {
return;
}
if (waited.adaptiveMs() > 0L) {
recordAdaptiveWait(waited.adaptiveMs());
}
if (waited.permitMs() > 0L) {
recordPermitWait(waited.permitMs());
}
regionPending.computeIfAbsent(rkey(x >> 5, z >> 5), k -> new AtomicInteger(1)).incrementAndGet();
markSubmitted();
executor.generate(x, z, listener);
@@ -1094,7 +1061,9 @@ public class AsyncPregenMethod implements PregeneratorMethod {
requestMonitoredChunkAsync(x, z)
.whenComplete((chunk, throwable) -> completeChunk(x, z, listener, chunk, throwable));
return;
} catch (Throwable ignored) {
} catch (Throwable e) {
PregenDiagnostics.probeFailed("folia direct chunk request at " + x + "," + z
+ ", falling back to a region task", e);
}
Runnable regionTask = () -> {
@@ -20,6 +20,7 @@ package art.arcane.iris.core.pregenerator.methods;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.nms.INMS;
import art.arcane.iris.core.pregenerator.PregenDiagnostics;
import art.arcane.iris.core.pregenerator.PregenListener;
import art.arcane.iris.core.pregenerator.PregeneratorMethod;
import art.arcane.iris.core.tools.IrisToolbelt;
@@ -102,7 +103,8 @@ public class MedievalPregenMethod implements PregeneratorMethod {
if (cached != null) {
cachedEngine = cached;
}
} catch (Throwable ignored) {
} catch (Throwable e) {
PregenDiagnostics.probeFailed("engine access for world " + world.getName(), e);
}
return cached;
}
@@ -0,0 +1,136 @@
/*
* Iris is a World Generator for Minecraft Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.project;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.PlatformBlockProperty;
import art.arcane.iris.spi.PlatformNumericRange;
import art.arcane.volmlib.util.json.JSONArray;
import art.arcane.volmlib.util.json.JSONObject;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
final class SchemaBlockStates {
private final SchemaBuilder builder;
SchemaBlockStates(SchemaBuilder builder) {
this.builder = builder;
}
void applyBlockStateMap(JSONObject prop, Field k, String blockType) {
prop.put("!top", true);
JSONArray any = new JSONArray();
prop.put("anyOf", any);
for (BlockStateGroup group : reconstructBlockStateGroups()) {
List<String> blocks = group.blocks();
if (blocks.isEmpty()) {
continue;
}
String raw = blocks.get(0).replace(':', '_');
String enumKey = "enum-block-state-" + raw;
String propertiesKey = "obj-block-state-" + raw;
any.put(new JSONObject()
.put("if", new JSONObject()
.put("properties", new JSONObject()
.put(blockType, new JSONObject()
.put("type", "string")
.put("$ref", definitionReference(enumKey)))))
.put("then", new JSONObject()
.put("properties", new JSONObject()
.put(k.getName(), new JSONObject()
.put("type", "object")
.put("$ref", definitionReference(propertiesKey)))))
.put("else", false));
if (!builder.definitions.containsKey(enumKey)) {
JSONArray filters = new JSONArray();
blocks.forEach(filters::put);
builder.definitions.put(enumKey, new JSONObject()
.put("type", "string")
.put("enum", filters));
}
if (!builder.definitions.containsKey(propertiesKey)) {
JSONObject props = new JSONObject();
for (PlatformBlockProperty property : group.properties()) {
props.put(property.name(), buildBlockPropertyJson(property));
}
builder.definitions.put(propertiesKey, new JSONObject()
.put("type", "object")
.put("properties", props));
}
}
}
private static String definitionReference(String key) {
return "#/definitions/" + key.replace("~", "~0").replace("/", "~1");
}
private static JSONObject buildBlockPropertyJson(PlatformBlockProperty property) {
JSONObject json = new JSONObject();
json.put("type", property.jsonType());
json.put("default", property.defaultValue());
List<Object> allowed = property.allowedValues();
if (!allowed.isEmpty()) {
json.put("enum", new JSONArray(allowed));
}
if (property.hasNumericRange()) {
PlatformNumericRange range = property.numericRange();
if ("integer".equals(property.jsonType())) {
json.put("minimum", (long) range.minimum());
json.put("maximum", (long) range.maximum());
} else {
json.put("minimum", range.minimum());
json.put("maximum", range.maximum());
}
json.put("exclusiveMinimum", range.exclusiveMinimum());
json.put("exclusiveMaximum", range.exclusiveMaximum());
}
return json;
}
private static List<BlockStateGroup> reconstructBlockStateGroups() {
List<BlockStateGroup> groups = new ArrayList<>();
List<PlatformBlockProperty> currentProperties = null;
List<String> currentBlocks = null;
for (Map.Entry<String, List<PlatformBlockProperty>> entry : IrisPlatforms.get().registries().blockStateProperties().entrySet()) {
List<PlatformBlockProperty> value = entry.getValue();
if (currentProperties != null && value == currentProperties) {
currentBlocks.add(entry.getKey());
} else {
currentBlocks = new ArrayList<>();
currentBlocks.add(entry.getKey());
currentProperties = value;
groups.add(new BlockStateGroup(currentBlocks, value));
}
}
return groups;
}
private record BlockStateGroup(List<String> blocks, List<PlatformBlockProperty> properties) {
}
}
@@ -19,74 +19,36 @@
package art.arcane.iris.core.project;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.PlatformBlockProperty;
import art.arcane.iris.spi.PlatformNumericRange;
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.core.structure.StructureSchemaKeys;
import art.arcane.iris.engine.framework.ListFunction;
import art.arcane.iris.engine.object.annotations.ArrayType;
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.RegistryListBiome;
import art.arcane.iris.engine.object.annotations.RegistryListBlockType;
import art.arcane.iris.engine.object.annotations.RegistryListEnchantment;
import art.arcane.iris.engine.object.annotations.RegistryListEntityType;
import art.arcane.iris.engine.object.annotations.RegistryListFont;
import art.arcane.iris.engine.object.annotations.RegistryListFunction;
import art.arcane.iris.engine.object.annotations.RegistryListItemType;
import art.arcane.iris.engine.object.annotations.RegistryListNativeJigsawPool;
import art.arcane.iris.engine.object.annotations.RegistryListPotionEffect;
import art.arcane.iris.engine.object.annotations.RegistryListResource;
import art.arcane.iris.engine.object.annotations.RegistryListSpecialEntity;
import art.arcane.iris.engine.object.annotations.RegistryListStructure;
import art.arcane.iris.engine.object.annotations.RegistryListVanillaStructure;
import art.arcane.iris.engine.object.annotations.RegistryListVanillaStructureSet;
import art.arcane.iris.engine.object.annotations.RegistryMapBlockState;
import art.arcane.iris.engine.object.annotations.Required;
import art.arcane.iris.engine.object.annotations.Snippet;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.collection.KMap;
import art.arcane.volmlib.util.json.JSONArray;
import art.arcane.volmlib.util.json.JSONObject;
import org.jetbrains.annotations.NotNull;
import java.awt.GraphicsEnvironment;
import java.lang.reflect.Field;
import java.lang.reflect.InaccessibleObjectException;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.function.Supplier;
public class SchemaBuilder {
private static final String SYMBOL_LIMIT__N = "*";
private static final String SYMBOL_TYPE__N = "";
private static final String MINECRAFT_NAMESPACE = "minecraft:";
/** Namespaced key or family/namespace prefix: "minecraft:village_plains", "minecraft:village", "nova_structures:". */
private static final String VANILLA_STRUCTURE_PREFIX_PATTERN = "^[a-z0-9_.-]+:[a-z0-9_./-]*$";
private static volatile JSONArray fontTypes;
private final KMap<String, JSONObject> definitions;
static final String SYMBOL_LIMIT__N = "*";
static final String SYMBOL_TYPE__N = "";
final KMap<String, JSONObject> definitions;
final KList<String> warnings;
final IrisData data;
private final Class<?> root;
private final KList<String> warnings;
private final IrisData data;
private JSONArray potionTypes;
private JSONArray enchantTypes;
private final SchemaPropertyBuilder propertyBuilder;
public SchemaBuilder(Class<?> root, IrisData data) {
this.data = data;
warnings = new KList<>();
this.definitions = new KMap<>();
this.root = root;
this.propertyBuilder = new SchemaPropertyBuilder(this);
}
public JSONObject construct() {
@@ -117,213 +79,7 @@ public class SchemaBuilder {
return schema;
}
/**
* Font families are only used for schema completion. Enumerating them touches AWT, which can fail outright on a
* headless or mod-loader JVM - a failure degrades to no completion, never to a broken schema.
*/
private static JSONArray fontTypes() {
JSONArray cached = fontTypes;
if (cached != null) {
return cached;
}
JSONArray built;
try {
built = new JSONArray(GraphicsEnvironment.getLocalGraphicsEnvironment().getAvailableFontFamilyNames());
} catch (Throwable e) {
IrisLogging.debug("Schema font family enumeration unavailable: " + e.getMessage());
built = new JSONArray();
}
fontTypes = built;
return built;
}
private JSONArray potionTypes() {
if (potionTypes == null) {
potionTypes = registryKeyForms(IrisPlatforms.get().registries().potionEffectKeys(), true);
}
return potionTypes;
}
private JSONArray enchantTypes() {
if (enchantTypes == null) {
enchantTypes = registryKeyForms(IrisPlatforms.get().registries().enchantmentKeys(), false);
}
return enchantTypes;
}
/**
* Emits the full namespaced key for every registry entry, so mod and datapack content is addressable
* unambiguously, plus the legacy short form for the vanilla namespace so existing packs stay valid.
*/
private static JSONArray registryKeyForms(List<String> keys, boolean upperCaseLegacy) {
JSONArray a = new JSONArray();
Set<String> seen = new LinkedHashSet<>();
if (keys != null) {
for (String key : keys) {
if (key == null || key.isBlank()) {
continue;
}
seen.add(key);
if (key.startsWith(MINECRAFT_NAMESPACE) || key.indexOf(':') < 0) {
String path = stripNamespace(key);
seen.add(upperCaseLegacy ? path.toUpperCase(Locale.ROOT).replace(' ', '_') : path);
}
}
}
for (String key : seen) {
a.put(key);
}
return a;
}
/**
* Biome derivatives resolve through NamespacedKey.fromString, which accepts a full key or a bare vanilla path,
* so both forms are offered.
*/
private JSONArray biomeTypes() {
return registryKeyForms(IrisPlatforms.get().registries().biomeKeys(), false);
}
private JSONArray entityTypes() {
return keysAsArray(IrisPlatforms.get().registries().entityKeys());
}
private JSONArray specialEntityTypes() {
return keysAsArray(IrisPlatforms.get().registries().specialEntityKeys());
}
private JSONArray vanillaStructures() {
return keysAsArray(IrisPlatforms.get().registries().structureKeys());
}
private JSONArray vanillaStructureSets() {
if (IrisPlatforms.get().structureHooks() == null) {
return new JSONArray();
}
List<String> keys = IrisPlatforms.get().structureHooks().structureSetKeys();
return keysAsArray(keys == null ? List.of() : keys);
}
private JSONArray nativeJigsawPools() {
return keysAsArray(templatePoolKeys());
}
private static JSONArray keysAsArray(List<String> keys) {
JSONArray a = new JSONArray();
if (keys != null) {
for (String key : keys) {
if (key != null && !key.isBlank()) {
a.put(key);
}
}
}
return a;
}
/**
* Registers a registry-backed enum definition under {@code definitionKey} and points {@code target} at it.
* <p>
* An empty key list means the registry has nothing to offer yet - the server is still booting, a modded registry
* has not been frozen, or the host simply does not expose that catalog. Emitting {@code "enum": []} in that case
* writes a schema that rejects every value the author could possibly type, turning a missing autocomplete list
* into a pack that reads as broken in the editor. The reference is omitted instead, leaving the field
* unconstrained, and the values are only computed when the definition does not exist yet.
*/
private void putRegistryEnumRef(JSONObject target, String definitionKey, Supplier<JSONArray> values) {
if (!definitions.containsKey(definitionKey)) {
JSONArray built = values.get();
if (built == null || built.length() == 0) {
IrisLogging.debug("Schema enum '" + definitionKey + "' omitted: the registry returned no keys");
return;
}
JSONObject definition = new JSONObject();
definition.put("enum", built);
definitions.put(definitionKey, definition);
}
target.put("$ref", "#/definitions/" + definitionKey);
}
/**
* {@link #putRegistryEnumRef(JSONObject, String, Supplier)} for a list-typed property. When the enum is omitted no
* {@code items} schema is written at all, which is valid and simply means "any element".
*/
private void putRegistryEnumItems(JSONObject prop, String definitionKey, Supplier<JSONArray> values) {
JSONObject items = new JSONObject();
putRegistryEnumRef(items, definitionKey, values);
if (items.has("$ref")) {
prop.put("items", items);
}
}
/**
* A registry enum that ALSO accepts family/namespace prefixes ("minecraft:village",
* "nova_structures:") — the runtime prefix-matching contract of importedStructures.disabled and
* adjustments[].match. Emitted as anyOf(enum, pattern) so autocomplete still offers registered
* keys while prefix entries validate instead of being rejected.
*/
private void putRegistryEnumOrPrefixRef(JSONObject target, String definitionKey,
String enumDefinitionKey, Supplier<JSONArray> values,
String pattern) {
if (!definitions.containsKey(definitionKey)) {
JSONArray anyOf = new JSONArray();
JSONObject enumRef = new JSONObject();
try {
putRegistryEnumRef(enumRef, enumDefinitionKey, values);
} catch (RuntimeException e) {
IrisLogging.debug("Schema enum '" + enumDefinitionKey + "' unavailable ("
+ e.getMessage() + "); emitting prefix pattern only");
}
if (enumRef.has("$ref")) {
anyOf.put(enumRef);
}
JSONObject prefix = new JSONObject();
prefix.put("type", "string");
prefix.put("pattern", pattern);
anyOf.put(prefix);
JSONObject definition = new JSONObject();
definition.put("anyOf", anyOf);
definitions.put(definitionKey, definition);
}
target.put("$ref", "#/definitions/" + definitionKey);
}
private void putRegistryEnumOrPrefixItems(JSONObject prop, String definitionKey,
String enumDefinitionKey, Supplier<JSONArray> values,
String pattern) {
JSONObject items = new JSONObject();
putRegistryEnumOrPrefixRef(items, definitionKey, enumDefinitionKey, values, pattern);
prop.put("items", items);
}
private JSONArray itemTypes() {
JSONArray a = new JSONArray();
for (String key : IrisPlatforms.get().registries().itemKeys()) {
a.put(key.startsWith(MINECRAFT_NAMESPACE) ? key.substring(MINECRAFT_NAMESPACE.length()) : key);
}
return a;
}
private JSONArray blockTypes() {
JSONArray a = new JSONArray();
for (String i : data.getBlockLoader().getPossibleKeys()) {
a.put(i);
}
for (String i : IrisPlatforms.get().registries().blockTypeKeys()) {
a.put(i);
}
return a;
}
private static String definitionReference(String key) {
return "#/definitions/" + key.replace("~", "~0").replace("/", "~1");
}
private static String stripNamespace(String key) {
int idx = key.indexOf(':');
return idx < 0 ? key : key.substring(idx + 1);
}
private JSONObject buildProperties(Class<?> c) {
JSONObject buildProperties(Class<?> c) {
JSONObject o = new JSONObject();
JSONObject properties = new JSONObject();
String desc = getDescription(c);
@@ -366,7 +122,7 @@ public class SchemaBuilder {
continue;
}
JSONObject property = buildProperty(k, c);
JSONObject property = propertyBuilder.buildProperty(k, c);
if (Boolean.TRUE == property.remove("!top")) {
extended.put(property);
@@ -381,437 +137,7 @@ public class SchemaBuilder {
}
}
private JSONObject buildProperty(Field k, Class<?> cl) {
JSONObject prop = new JSONObject();
String type = getType(k.getType());
KList<String> description = new KList<>();
prop.put("!required", k.isAnnotationPresent(Required.class));
prop.put("type", type);
String fancyType = "Unknown Type";
switch (type) {
case "boolean" -> fancyType = "Boolean";
case "integer" -> {
fancyType = "Integer";
if (k.isAnnotationPresent(MinNumber.class)) {
int min = (int) k.getDeclaredAnnotation(MinNumber.class).value();
prop.put("minimum", min);
description.add(SYMBOL_LIMIT__N + " Minimum allowed is " + min);
}
if (k.isAnnotationPresent(MaxNumber.class)) {
int max = (int) k.getDeclaredAnnotation(MaxNumber.class).value();
prop.put("maximum", max);
description.add(SYMBOL_LIMIT__N + " Maximum allowed is " + max);
}
}
case "number" -> {
fancyType = "Number";
if (k.isAnnotationPresent(MinNumber.class)) {
double min = k.getDeclaredAnnotation(MinNumber.class).value();
prop.put("minimum", min);
description.add(SYMBOL_LIMIT__N + " Minimum allowed is " + min);
}
if (k.isAnnotationPresent(MaxNumber.class)) {
double max = k.getDeclaredAnnotation(MaxNumber.class).value();
prop.put("maximum", max);
description.add(SYMBOL_LIMIT__N + " Maximum allowed is " + max);
}
}
case "string" -> {
fancyType = "Text";
if (k.isAnnotationPresent(MinNumber.class)) {
int min = (int) k.getDeclaredAnnotation(MinNumber.class).value();
prop.put("minLength", min);
description.add(SYMBOL_LIMIT__N + " Minimum Length allowed is " + min);
}
if (k.isAnnotationPresent(MaxNumber.class)) {
int max = (int) k.getDeclaredAnnotation(MaxNumber.class).value();
prop.put("maxLength", max);
description.add(SYMBOL_LIMIT__N + " Maximum Length allowed is " + max);
}
if (k.isAnnotationPresent(RegistryListResource.class)) {
RegistryListResource rr = k.getDeclaredAnnotation(RegistryListResource.class);
ResourceLoader<?> loader = data.getLoaders().get(rr.value());
if (loader != null) {
String key = "erz" + loader.getFolderName();
if (!definitions.containsKey(key)) {
JSONObject j = new JSONObject();
j.put("enum", new JSONArray(loader.getPossibleKeys()));
definitions.put(key, j);
}
fancyType = "Iris " + loader.getResourceTypeName();
prop.put("$ref", "#/definitions/" + key);
description.add(SYMBOL_TYPE__N + " Must be a valid " + loader.getFolderName() + " (use ctrl+space for auto complete!)");
} else {
IrisLogging.error("Cannot find Registry Loader for type " + rr.value() + " used in " + k.getDeclaringClass().getCanonicalName() + " in field " + k.getName());
}
} else if (k.isAnnotationPresent(RegistryListStructure.class)) {
String key = "enum-iris-structure-placement";
if (!definitions.containsKey(key)) {
JSONObject j = new JSONObject();
j.put("enum", new JSONArray(StructureSchemaKeys.collect(
Arrays.asList(data.getStructureLoader().getPossibleKeys()),
Arrays.asList(data.getJigsawPieceLoader().getPossibleKeys())).toArray(new String[0])));
definitions.put(key, j);
}
fancyType = "Structure";
prop.put("$ref", "#/definitions/" + key);
description.add(SYMBOL_TYPE__N + " Must be a valid vanilla, datapack, or imported Iris structure (use ctrl+space for auto complete!)");
} else if (k.isAnnotationPresent(RegistryListBlockType.class)) {
fancyType = "Block Type";
putRegistryEnumRef(prop, "enum-block-type", this::blockTypes);
description.add(SYMBOL_TYPE__N + " Must be a valid Block Type (use ctrl+space for auto complete!)");
} else if (k.isAnnotationPresent(RegistryListNativeJigsawPool.class)) {
fancyType = "Native Jigsaw Pool";
putRegistryEnumRef(prop, "enum-native-jigsaw-pool", this::nativeJigsawPools);
description.add(SYMBOL_TYPE__N + " Must be a registered vanilla, datapack, or modded template pool key (use ctrl+space for auto complete!)");
} else if (k.isAnnotationPresent(RegistryListVanillaStructure.class)) {
fancyType = "Vanilla Structure";
if (k.getAnnotation(RegistryListVanillaStructure.class).prefixes()) {
putRegistryEnumOrPrefixRef(prop, "enum-vanilla-structure-or-prefix",
"enum-vanilla-structure", this::vanillaStructures, VANILLA_STRUCTURE_PREFIX_PATTERN);
description.add(SYMBOL_TYPE__N + " Must be a vanilla/datapack structure key or a family/namespace prefix like 'minecraft:village' or 'nova_structures:' (use ctrl+space for auto complete!)");
} else {
putRegistryEnumRef(prop, "enum-vanilla-structure", this::vanillaStructures);
description.add(SYMBOL_TYPE__N + " Must be a valid vanilla/datapack structure key (use ctrl+space for auto complete!)");
}
} else if (k.isAnnotationPresent(RegistryListVanillaStructureSet.class)) {
fancyType = "Vanilla Structure Set";
putRegistryEnumRef(prop, "enum-vanilla-structure-set", this::vanillaStructureSets);
description.add(SYMBOL_TYPE__N + " Must be a valid vanilla/datapack structure SET key (use ctrl+space for auto complete!)");
} else if (k.isAnnotationPresent(RegistryListItemType.class)) {
fancyType = "Item Type";
putRegistryEnumRef(prop, "enum-item-type", this::itemTypes);
description.add(SYMBOL_TYPE__N + " Must be a valid Item Type (use ctrl+space for auto complete!)");
} else if (k.isAnnotationPresent(RegistryListEntityType.class)) {
fancyType = "Entity Type";
putRegistryEnumRef(prop, "enum-entity-type", this::entityTypes);
description.add(SYMBOL_TYPE__N + " Must be a valid Entity Type (use ctrl+space for auto complete!)");
} else if (k.isAnnotationPresent(RegistryListBiome.class)) {
fancyType = "Biome Type";
putRegistryEnumRef(prop, "enum-biome-type", this::biomeTypes);
description.add(SYMBOL_TYPE__N + " Must be a valid vanilla, datapack, or mod biome key (use ctrl+space for auto complete!)");
} else if (k.isAnnotationPresent(RegistryListSpecialEntity.class)) {
fancyType = "Custom Mob Type";
putRegistryEnumRef(prop, "enum-reg-specialentity", this::specialEntityTypes);
description.add(SYMBOL_TYPE__N + " Must be a valid Custom Mob Type (use ctrl+space for auto complete!)");
} else if (k.isAnnotationPresent(RegistryListFont.class)) {
fancyType = "Font Family";
putRegistryEnumRef(prop, "enum-font", SchemaBuilder::fontTypes);
description.add(SYMBOL_TYPE__N + " Must be a valid Font Family (use ctrl+space for auto complete!)");
} else if (k.isAnnotationPresent(RegistryListEnchantment.class)) {
fancyType = "Enchantment Type";
putRegistryEnumRef(prop, "enum-enchantment", this::enchantTypes);
description.add(SYMBOL_TYPE__N + " Must be a valid Enchantment Type (use ctrl+space for auto complete!)");
} else if (k.isAnnotationPresent(RegistryListPotionEffect.class)) {
fancyType = "Potion Effect Type";
putRegistryEnumRef(prop, "enum-potion-effect-type", this::potionTypes);
description.add(SYMBOL_TYPE__N + " Must be a valid Potion Effect Type (use ctrl+space for auto complete!)");
} else if (k.isAnnotationPresent(RegistryListFunction.class)) {
Class<? extends ListFunction<KList<String>>> functionClass = k.getDeclaredAnnotation(RegistryListFunction.class).value();
try {
ListFunction<KList<String>> instance = functionClass.getDeclaredConstructor().newInstance();
String key = instance.key();
fancyType = instance.fancyName();
if (!definitions.containsKey(key)) {
JSONObject j = new JSONObject();
j.put("enum", instance.apply(data));
definitions.put(key, j);
}
prop.put("$ref", "#/definitions/" + key);
description.add(SYMBOL_TYPE__N + " Must be a valid " + fancyType + " (use ctrl+space for auto complete!)");
} catch (Throwable e) {
IrisLogging.error("Could not execute apply method in " + functionClass.getName());
}
} else if (SchemaKeyedTypes.isKeyed(k.getType())) {
fancyType = addEnum(k.getType(), prop, description, SchemaKeyedTypes.values(k.getType()), Function.identity());
} else if (k.getType().isEnum()) {
fancyType = addEnum(k.getType(), prop, description, enumNames(k.getType()), Function.identity());
}
}
case "object" -> {
if (k.isAnnotationPresent(RegistryMapBlockState.class)) {
String blockType = k.getDeclaredAnnotation(RegistryMapBlockState.class).value();
fancyType = "Block State";
prop.put("!top", true);
JSONArray any = new JSONArray();
prop.put("anyOf", any);
for (BlockStateGroup group : reconstructBlockStateGroups()) {
List<String> blocks = group.blocks();
if (blocks.isEmpty()) {
continue;
}
String raw = blocks.get(0).replace(':', '_');
String enumKey = "enum-block-state-" + raw;
String propertiesKey = "obj-block-state-" + raw;
any.put(new JSONObject()
.put("if", new JSONObject()
.put("properties", new JSONObject()
.put(blockType, new JSONObject()
.put("type", "string")
.put("$ref", definitionReference(enumKey)))))
.put("then", new JSONObject()
.put("properties", new JSONObject()
.put(k.getName(), new JSONObject()
.put("type", "object")
.put("$ref", definitionReference(propertiesKey)))))
.put("else", false));
if (!definitions.containsKey(enumKey)) {
JSONArray filters = new JSONArray();
blocks.forEach(filters::put);
definitions.put(enumKey, new JSONObject()
.put("type", "string")
.put("enum", filters));
}
if (!definitions.containsKey(propertiesKey)) {
JSONObject props = new JSONObject();
for (PlatformBlockProperty property : group.properties()) {
props.put(property.name(), buildBlockPropertyJson(property));
}
definitions.put(propertiesKey, new JSONObject()
.put("type", "object")
.put("properties", props));
}
}
} else {
fancyType = k.getType().getSimpleName().replaceAll("\\QIris\\E", "") + " (Object)";
String key = "obj-" + k.getType().getCanonicalName().replaceAll("\\Q.\\E", "-").toLowerCase();
if (!definitions.containsKey(key)) {
definitions.put(key, new JSONObject());
definitions.put(key, buildProperties(k.getType()));
}
prop.put("$ref", "#/definitions/" + key);
}
}
case "array" -> {
fancyType = "List of Something...?";
ArrayType t = k.getDeclaredAnnotation(ArrayType.class);
if (t != null) {
if (t.min() > 0) {
prop.put("minItems", t.min());
if (t.min() == 1) {
description.add(SYMBOL_LIMIT__N + " At least one entry must be defined, or just remove this list.");
} else {
description.add(SYMBOL_LIMIT__N + " Requires at least " + t.min() + " entries.");
}
}
String arrayType = getType(t.type());
switch (arrayType) {
case "integer" -> fancyType = "List of Integers";
case "number" -> fancyType = "List of Numbers";
case "object" -> {
fancyType = "List of " + t.type().getSimpleName().replaceAll("\\QIris\\E", "") + "s (Objects)";
String key = "obj-" + t.type().getCanonicalName().replaceAll("\\Q.\\E", "-").toLowerCase();
if (!definitions.containsKey(key)) {
definitions.put(key, new JSONObject());
definitions.put(key, buildProperties(t.type()));
}
JSONObject items = new JSONObject();
items.put("$ref", "#/definitions/" + key);
prop.put("items", items);
}
case "string" -> {
fancyType = "List of Text";
if (k.isAnnotationPresent(RegistryListResource.class)) {
RegistryListResource rr = k.getDeclaredAnnotation(RegistryListResource.class);
ResourceLoader<?> loader = data.getLoaders().get(rr.value());
if (loader != null) {
fancyType = "List<" + loader.getResourceTypeName() + ">";
String key = "erz" + loader.getFolderName();
if (!definitions.containsKey(key)) {
JSONObject j = new JSONObject();
j.put("enum", new JSONArray(loader.getPossibleKeys()));
definitions.put(key, j);
}
JSONObject items = new JSONObject();
items.put("$ref", "#/definitions/" + key);
prop.put("items", items);
description.add(SYMBOL_TYPE__N + " Must be a valid " + loader.getResourceTypeName() + " (use ctrl+space for auto complete!)");
} else {
IrisLogging.error("Cannot find Registry Loader for type (list schema) " + rr.value() + " used in " + k.getDeclaringClass().getCanonicalName() + " in field " + k.getName());
}
} else if (k.isAnnotationPresent(RegistryListStructure.class)) {
fancyType = "List<Structure>";
String key = "enum-iris-structure-placement";
if (!definitions.containsKey(key)) {
JSONObject j = new JSONObject();
j.put("enum", new JSONArray(StructureSchemaKeys.collect(
Arrays.asList(data.getStructureLoader().getPossibleKeys()),
Arrays.asList(data.getJigsawPieceLoader().getPossibleKeys())).toArray(new String[0])));
definitions.put(key, j);
}
JSONObject items = new JSONObject();
items.put("$ref", "#/definitions/" + key);
prop.put("items", items);
description.add(SYMBOL_TYPE__N + " Must be a valid vanilla, datapack, or imported Iris structure (use ctrl+space for auto complete!)");
} else if (k.isAnnotationPresent(RegistryListNativeJigsawPool.class)) {
fancyType = "List<Native Jigsaw Pool>";
putRegistryEnumItems(prop, "enum-native-jigsaw-pool", this::nativeJigsawPools);
description.add(SYMBOL_TYPE__N + " Must be a registered vanilla, datapack, or modded template pool key (use ctrl+space for auto complete!)");
} else if (k.isAnnotationPresent(RegistryListVanillaStructure.class)) {
fancyType = "List<Vanilla Structure>";
if (k.getAnnotation(RegistryListVanillaStructure.class).prefixes()) {
putRegistryEnumOrPrefixItems(prop, "enum-vanilla-structure-or-prefix",
"enum-vanilla-structure", this::vanillaStructures, VANILLA_STRUCTURE_PREFIX_PATTERN);
description.add(SYMBOL_TYPE__N + " Must be a vanilla/datapack structure key or a family/namespace prefix like 'minecraft:village' or 'nova_structures:' (use ctrl+space for auto complete!)");
} else {
putRegistryEnumItems(prop, "enum-vanilla-structure", this::vanillaStructures);
description.add(SYMBOL_TYPE__N + " Must be a valid vanilla/datapack structure key (use ctrl+space for auto complete!)");
}
} else if (k.isAnnotationPresent(RegistryListVanillaStructureSet.class)) {
fancyType = "List<Vanilla Structure Set>";
putRegistryEnumItems(prop, "enum-vanilla-structure-set", this::vanillaStructureSets);
description.add(SYMBOL_TYPE__N + " Must be a valid vanilla/datapack structure set key (use ctrl+space for auto complete!)");
} else if (k.isAnnotationPresent(RegistryListBlockType.class)) {
fancyType = "List of Block Types";
putRegistryEnumItems(prop, "enum-block-type", this::blockTypes);
description.add(SYMBOL_TYPE__N + " Must be a valid Block Type (use ctrl+space for auto complete!)");
} else if (k.isAnnotationPresent(RegistryListItemType.class)) {
fancyType = "List of Item Types";
putRegistryEnumItems(prop, "enum-item-type", this::itemTypes);
description.add(SYMBOL_TYPE__N + " Must be a valid Item Type (use ctrl+space for auto complete!)");
} else if (k.isAnnotationPresent(RegistryListEntityType.class)) {
fancyType = "List of Entity Types";
putRegistryEnumItems(prop, "enum-entity-type", this::entityTypes);
description.add(SYMBOL_TYPE__N + " Must be a valid Entity Type (use ctrl+space for auto complete!)");
} else if (k.isAnnotationPresent(RegistryListBiome.class)) {
fancyType = "List of Biome Types";
putRegistryEnumItems(prop, "enum-biome-type", this::biomeTypes);
description.add(SYMBOL_TYPE__N + " Must be a valid vanilla, datapack, or mod biome key (use ctrl+space for auto complete!)");
} else if (k.isAnnotationPresent(RegistryListFont.class)) {
fancyType = "List of Font Families";
putRegistryEnumItems(prop, "enum-font", SchemaBuilder::fontTypes);
description.add(SYMBOL_TYPE__N + " Must be a valid Font Family (use ctrl+space for auto complete!)");
} else if (k.isAnnotationPresent(RegistryListEnchantment.class)) {
fancyType = "List of Enchantment Types";
putRegistryEnumItems(prop, "enum-enchantment", this::enchantTypes);
description.add(SYMBOL_TYPE__N + " Must be a valid Enchantment Type (use ctrl+space for auto complete!)");
} else if (k.isAnnotationPresent(RegistryListPotionEffect.class)) {
fancyType = "List of Potion Effect Types";
putRegistryEnumItems(prop, "enum-potion-effect-type", this::potionTypes);
description.add(SYMBOL_TYPE__N + " Must be a valid Potion Effect Type (use ctrl+space for auto complete!)");
} else if (k.isAnnotationPresent(RegistryListFunction.class)) {
Class<? extends ListFunction<KList<String>>> functionClass = k.getDeclaredAnnotation(RegistryListFunction.class).value();
try {
ListFunction<KList<String>> instance = functionClass.getDeclaredConstructor().newInstance();
String key = instance.key();
fancyType = instance.fancyName();
if (!definitions.containsKey(key)) {
JSONObject j = new JSONObject();
j.put("enum", instance.apply(data));
definitions.put(key, j);
}
JSONObject items = new JSONObject();
items.put("$ref", "#/definitions/" + key);
prop.put("items", items);
description.add(SYMBOL_TYPE__N + " Must be a valid " + fancyType + " (use ctrl+space for auto complete!)");
} catch (Throwable e) {
IrisLogging.error("Could not execute apply method in " + functionClass.getName());
}
} else if (SchemaKeyedTypes.isKeyed(t.type())) {
fancyType = addEnumList(prop, description, t, SchemaKeyedTypes.values(t.type()), Function.identity());
} else if (t.type().isEnum()) {
fancyType = addEnumList(prop, description, t, enumNames(t.type()), Function.identity());
}
}
}
} else {
warnings.add("Undefined array type for field " + k.getName() + " (" + k.getType().getSimpleName() + ") in class " + cl.getSimpleName());
}
}
default ->
warnings.add("Unexpected Schema Type: " + type + " for field " + k.getName() + " (" + k.getType().getSimpleName() + ") in class " + cl.getSimpleName());
}
KList<String> d = new KList<>();
d.add("<h>" + k.getName() + "</h>");
d.add(getFieldDescription(k) + "<hr></hr>");
d.add("<h>" + fancyType + "</h>");
String typeDesc = getDescription(k.getType());
boolean present = !typeDesc.isBlank();
if (present) d.add(typeDesc);
Snippet snippet = k.getType().getDeclaredAnnotation(Snippet.class);
if (snippet == null) {
ArrayType array = k.getType().getDeclaredAnnotation(ArrayType.class);
if (array != null) {
snippet = array.type().getDeclaredAnnotation(Snippet.class);
}
}
if (snippet != null) {
String sm = snippet.value();
if (present) d.add(" ");
d.add("You can instead specify \"snippet/" + sm + "/some-name.json\" to use a snippet file instead of specifying it here.");
present = false;
}
try {
k.setAccessible(true);
Object value = k.get(cl.getDeclaredConstructor().newInstance());
if (value != null) {
if (present) d.add(" ");
if (value instanceof List) {
d.add(SYMBOL_LIMIT__N + " Default Value is an empty list");
} else if (!k.getType().isPrimitive() && !(value instanceof Number) && !(value instanceof String) && !(value instanceof Enum<?>) && !SchemaKeyedTypes.isKeyed(k.getType())) {
d.add(SYMBOL_LIMIT__N + " Default Value is a default object (create this object to see default properties)");
} else {
d.add(SYMBOL_LIMIT__N + " Default Value is " + value);
}
}
} catch (Throwable ignored) {
}
description.forEach((g) -> d.add(g.trim()));
String desc = d.toString("\n")
.replace("<hr></hr>", "\n")
.replace("<h>", "")
.replace("</h>", "");
String hDesc = d.toString("<br>");
prop.put("type", type);
prop.put("description", desc);
prop.put("x-intellij-html-description", hDesc);
return buildSnippet(prop, k.getType());
}
private JSONObject buildSnippet(JSONObject prop, Class<?> type) {
JSONObject buildSnippet(JSONObject prop, Class<?> type) {
Snippet snippet = type.getDeclaredAnnotation(Snippet.class);
if (snippet == null) return prop;
@@ -850,117 +176,7 @@ public class SchemaBuilder {
return anyOf;
}
@NotNull
private <T> String addEnumList(JSONObject prop, KList<String> description, ArrayType t, T[] values, Function<T, String> function) {
JSONObject items = new JSONObject();
String s = addEnum(t.type(), items, description, values, function);
prop.put("items", items);
return "List of " + s + "s";
}
private static String[] enumNames(Class<?> enumType) {
try {
Object[] constants = enumType.getEnumConstants();
String[] names = new String[constants.length];
for (int index = 0; index < constants.length; index++) {
names[index] = ((Enum<?>) constants[index]).name();
}
return names;
} catch (LinkageError error) {
return Arrays.stream(enumType.getDeclaredFields())
.filter(Field::isEnumConstant)
.map(Field::getName)
.toArray(String[]::new);
}
}
@NotNull
private <T> String addEnum(Class<?> type, JSONObject prop, KList<String> description, T[] values, Function<T, String> function) {
JSONArray a = new JSONArray();
boolean advanced = type.isAnnotationPresent(Desc.class);
for (T gg : values) {
if (advanced) {
try {
JSONObject j = new JSONObject();
String name = function.apply(gg);
j.put("const", name);
Desc dd = type.getField(name).getAnnotation(Desc.class);
String desc = dd == null ? ("No Description for " + name) : dd.value();
j.put("description", desc);
j.put("x-intellij-html-description", desc.replace("\n", "<br>"));
a.put(j);
} catch (Throwable e) {
IrisLogging.reportError(e);
}
} else {
a.put(function.apply(gg));
}
}
String key = (advanced ? "oneof-" : "") + "enum-" + type.getCanonicalName().replaceAll("\\Q.\\E", "-").toLowerCase();
if (!definitions.containsKey(key)) {
JSONObject j = new JSONObject();
j.put(advanced ? "oneOf" : "enum", a);
definitions.put(key, j);
}
prop.put("$ref", "#/definitions/" + key);
description.add(SYMBOL_TYPE__N + " Must be a valid " + type.getSimpleName().replaceAll("\\QIris\\E", "") + " (use ctrl+space for auto complete!)");
return type.getSimpleName().replaceAll("\\QIris\\E", "");
}
private JSONObject buildBlockPropertyJson(PlatformBlockProperty property) {
JSONObject json = new JSONObject();
json.put("type", property.jsonType());
json.put("default", property.defaultValue());
List<Object> allowed = property.allowedValues();
if (!allowed.isEmpty()) {
json.put("enum", new JSONArray(allowed));
}
if (property.hasNumericRange()) {
PlatformNumericRange range = property.numericRange();
if ("integer".equals(property.jsonType())) {
json.put("minimum", (long) range.minimum());
json.put("maximum", (long) range.maximum());
} else {
json.put("minimum", range.minimum());
json.put("maximum", range.maximum());
}
json.put("exclusiveMinimum", range.exclusiveMinimum());
json.put("exclusiveMaximum", range.exclusiveMaximum());
}
return json;
}
private List<BlockStateGroup> reconstructBlockStateGroups() {
List<BlockStateGroup> groups = new ArrayList<>();
List<PlatformBlockProperty> currentProperties = null;
List<String> currentBlocks = null;
for (Map.Entry<String, List<PlatformBlockProperty>> entry : IrisPlatforms.get().registries().blockStateProperties().entrySet()) {
List<PlatformBlockProperty> value = entry.getValue();
if (currentProperties != null && value == currentProperties) {
currentBlocks.add(entry.getKey());
} else {
currentBlocks = new ArrayList<>();
currentBlocks.add(entry.getKey());
currentProperties = value;
groups.add(new BlockStateGroup(currentBlocks, value));
}
}
return groups;
}
private List<String> templatePoolKeys() {
if (IrisPlatforms.get().structureHooks() == null) {
return List.of();
}
List<String> keys = IrisPlatforms.get().structureHooks().templatePoolKeys();
return keys == null ? List.of() : keys;
}
private String getType(Class<?> c) {
String getType(Class<?> c) {
if (c.equals(int.class) || c.equals(Integer.class) || c.equals(long.class) || c.equals(Long.class)) {
return "integer";
}
@@ -992,7 +208,7 @@ public class SchemaBuilder {
return "object";
}
private String getFieldDescription(Field r) {
String getFieldDescription(Field r) {
if (r.isAnnotationPresent(Desc.class)) {
return r.getDeclaredAnnotation(Desc.class).value();
@@ -1002,7 +218,7 @@ public class SchemaBuilder {
return "No Field Description";
}
private String getDescription(Class<?> r) {
String getDescription(Class<?> r) {
if (r.isAnnotationPresent(Desc.class)) {
return r.getDeclaredAnnotation(Desc.class).value();
}
@@ -1012,7 +228,4 @@ public class SchemaBuilder {
}
return "";
}
private record BlockStateGroup(List<String> blocks, List<PlatformBlockProperty> properties) {
}
}
@@ -0,0 +1,100 @@
/*
* Iris is a World Generator for Minecraft Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.project;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.engine.object.annotations.ArrayType;
import art.arcane.iris.engine.object.annotations.Desc;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.json.JSONArray;
import art.arcane.volmlib.util.json.JSONObject;
import org.jetbrains.annotations.NotNull;
import java.lang.reflect.Field;
import java.util.Arrays;
import java.util.function.Function;
final class SchemaEnums {
private final SchemaBuilder builder;
SchemaEnums(SchemaBuilder builder) {
this.builder = builder;
}
@NotNull
<T> String addEnumList(JSONObject prop, KList<String> description, ArrayType t, T[] values, Function<T, String> function) {
JSONObject items = new JSONObject();
String s = addEnum(t.type(), items, description, values, function);
prop.put("items", items);
return "List of " + s + "s";
}
static String[] enumNames(Class<?> enumType) {
try {
Object[] constants = enumType.getEnumConstants();
String[] names = new String[constants.length];
for (int index = 0; index < constants.length; index++) {
names[index] = ((Enum<?>) constants[index]).name();
}
return names;
} catch (LinkageError error) {
return Arrays.stream(enumType.getDeclaredFields())
.filter(Field::isEnumConstant)
.map(Field::getName)
.toArray(String[]::new);
}
}
@NotNull
<T> String addEnum(Class<?> type, JSONObject prop, KList<String> description, T[] values, Function<T, String> function) {
JSONArray a = new JSONArray();
boolean advanced = type.isAnnotationPresent(Desc.class);
for (T gg : values) {
if (advanced) {
try {
JSONObject j = new JSONObject();
String name = function.apply(gg);
j.put("const", name);
Desc dd = type.getField(name).getAnnotation(Desc.class);
String desc = dd == null ? ("No Description for " + name) : dd.value();
j.put("description", desc);
j.put("x-intellij-html-description", desc.replace("\n", "<br>"));
a.put(j);
} catch (Throwable e) {
IrisLogging.reportError(e);
}
} else {
a.put(function.apply(gg));
}
}
String key = (advanced ? "oneof-" : "") + "enum-" + type.getCanonicalName().replaceAll("\\Q.\\E", "-").toLowerCase();
if (!builder.definitions.containsKey(key)) {
JSONObject j = new JSONObject();
j.put(advanced ? "oneOf" : "enum", a);
builder.definitions.put(key, j);
}
prop.put("$ref", "#/definitions/" + key);
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid " + type.getSimpleName().replaceAll("\\QIris\\E", "") + " (use ctrl+space for auto complete!)");
return type.getSimpleName().replaceAll("\\QIris\\E", "");
}
}
@@ -0,0 +1,269 @@
/*
* Iris is a World Generator for Minecraft Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.project;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.engine.object.annotations.ArrayType;
import art.arcane.iris.engine.object.annotations.MaxNumber;
import art.arcane.iris.engine.object.annotations.MinNumber;
import art.arcane.iris.engine.object.annotations.RegistryMapBlockState;
import art.arcane.iris.engine.object.annotations.Required;
import art.arcane.iris.engine.object.annotations.Snippet;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.json.JSONObject;
import java.lang.reflect.Field;
import java.util.List;
import java.util.function.Function;
final class SchemaPropertyBuilder {
private final SchemaBuilder builder;
private final SchemaEnums enums;
private final SchemaBlockStates blockStates;
private final SchemaRegistryProperties registryProperties;
SchemaPropertyBuilder(SchemaBuilder builder) {
this.builder = builder;
SchemaRegistryCatalog catalog = new SchemaRegistryCatalog(builder);
this.enums = new SchemaEnums(builder);
this.blockStates = new SchemaBlockStates(builder);
this.registryProperties = new SchemaRegistryProperties(builder, catalog);
}
JSONObject buildProperty(Field k, Class<?> cl) {
JSONObject prop = new JSONObject();
String type = builder.getType(k.getType());
KList<String> description = new KList<>();
prop.put("!required", k.isAnnotationPresent(Required.class));
prop.put("type", type);
String fancyType = switch (type) {
case "boolean" -> "Boolean";
case "integer" -> integerProperty(k, prop, description);
case "number" -> numberProperty(k, prop, description);
case "string" -> stringProperty(k, prop, description);
case "object" -> objectProperty(k, prop);
case "array" -> arrayProperty(k, cl, prop, description);
default -> unexpectedType(type, k, cl);
};
return describeProperty(k, cl, prop, type, fancyType, description);
}
private String integerProperty(Field k, JSONObject prop, KList<String> description) {
if (k.isAnnotationPresent(MinNumber.class)) {
int min = (int) k.getDeclaredAnnotation(MinNumber.class).value();
prop.put("minimum", min);
description.add(SchemaBuilder.SYMBOL_LIMIT__N + " Minimum allowed is " + min);
}
if (k.isAnnotationPresent(MaxNumber.class)) {
int max = (int) k.getDeclaredAnnotation(MaxNumber.class).value();
prop.put("maximum", max);
description.add(SchemaBuilder.SYMBOL_LIMIT__N + " Maximum allowed is " + max);
}
return "Integer";
}
private String numberProperty(Field k, JSONObject prop, KList<String> description) {
if (k.isAnnotationPresent(MinNumber.class)) {
double min = k.getDeclaredAnnotation(MinNumber.class).value();
prop.put("minimum", min);
description.add(SchemaBuilder.SYMBOL_LIMIT__N + " Minimum allowed is " + min);
}
if (k.isAnnotationPresent(MaxNumber.class)) {
double max = k.getDeclaredAnnotation(MaxNumber.class).value();
prop.put("maximum", max);
description.add(SchemaBuilder.SYMBOL_LIMIT__N + " Maximum allowed is " + max);
}
return "Number";
}
private String stringProperty(Field k, JSONObject prop, KList<String> description) {
if (k.isAnnotationPresent(MinNumber.class)) {
int min = (int) k.getDeclaredAnnotation(MinNumber.class).value();
prop.put("minLength", min);
description.add(SchemaBuilder.SYMBOL_LIMIT__N + " Minimum Length allowed is " + min);
}
if (k.isAnnotationPresent(MaxNumber.class)) {
int max = (int) k.getDeclaredAnnotation(MaxNumber.class).value();
prop.put("maxLength", max);
description.add(SchemaBuilder.SYMBOL_LIMIT__N + " Maximum Length allowed is " + max);
}
String registryType = registryProperties.stringRegistryType(k, prop, description);
if (registryType != null) {
return registryType;
}
if (SchemaKeyedTypes.isKeyed(k.getType())) {
return enums.addEnum(k.getType(), prop, description, SchemaKeyedTypes.values(k.getType()), Function.identity());
}
if (k.getType().isEnum()) {
return enums.addEnum(k.getType(), prop, description, SchemaEnums.enumNames(k.getType()), Function.identity());
}
return "Text";
}
private String objectProperty(Field k, JSONObject prop) {
if (k.isAnnotationPresent(RegistryMapBlockState.class)) {
blockStates.applyBlockStateMap(prop, k, k.getDeclaredAnnotation(RegistryMapBlockState.class).value());
return "Block State";
}
String key = "obj-" + k.getType().getCanonicalName().replaceAll("\\Q.\\E", "-").toLowerCase();
if (!builder.definitions.containsKey(key)) {
builder.definitions.put(key, new JSONObject());
builder.definitions.put(key, builder.buildProperties(k.getType()));
}
prop.put("$ref", "#/definitions/" + key);
return k.getType().getSimpleName().replaceAll("\\QIris\\E", "") + " (Object)";
}
private String arrayProperty(Field k, Class<?> cl, JSONObject prop, KList<String> description) {
ArrayType t = k.getDeclaredAnnotation(ArrayType.class);
if (t == null) {
builder.warnings.add("Undefined array type for field " + k.getName() + " (" + k.getType().getSimpleName() + ") in class " + cl.getSimpleName());
return "List of Something...?";
}
if (t.min() > 0) {
prop.put("minItems", t.min());
if (t.min() == 1) {
description.add(SchemaBuilder.SYMBOL_LIMIT__N + " At least one entry must be defined, or just remove this list.");
} else {
description.add(SchemaBuilder.SYMBOL_LIMIT__N + " Requires at least " + t.min() + " entries.");
}
}
return switch (builder.getType(t.type())) {
case "integer" -> "List of Integers";
case "number" -> "List of Numbers";
case "object" -> objectArrayProperty(t, prop);
case "string" -> stringArrayProperty(k, t, prop, description);
default -> "List of Something...?";
};
}
private String objectArrayProperty(ArrayType t, JSONObject prop) {
String key = "obj-" + t.type().getCanonicalName().replaceAll("\\Q.\\E", "-").toLowerCase();
if (!builder.definitions.containsKey(key)) {
builder.definitions.put(key, new JSONObject());
builder.definitions.put(key, builder.buildProperties(t.type()));
}
JSONObject items = new JSONObject();
items.put("$ref", "#/definitions/" + key);
prop.put("items", items);
return "List of " + t.type().getSimpleName().replaceAll("\\QIris\\E", "") + "s (Objects)";
}
private String stringArrayProperty(Field k, ArrayType t, JSONObject prop, KList<String> description) {
String registryType = registryProperties.stringArrayRegistryType(k, prop, description);
if (registryType != null) {
return registryType;
}
if (SchemaKeyedTypes.isKeyed(t.type())) {
return enums.addEnumList(prop, description, t, SchemaKeyedTypes.values(t.type()), Function.identity());
}
if (t.type().isEnum()) {
return enums.addEnumList(prop, description, t, SchemaEnums.enumNames(t.type()), Function.identity());
}
return "List of Text";
}
private String unexpectedType(String type, Field k, Class<?> cl) {
builder.warnings.add("Unexpected Schema Type: " + type + " for field " + k.getName() + " (" + k.getType().getSimpleName() + ") in class " + cl.getSimpleName());
return "Unknown Type";
}
private JSONObject describeProperty(Field k, Class<?> cl, JSONObject prop, String type, String fancyType, KList<String> description) {
KList<String> d = new KList<>();
d.add("<h>" + k.getName() + "</h>");
d.add(builder.getFieldDescription(k) + "<hr></hr>");
d.add("<h>" + fancyType + "</h>");
String typeDesc = builder.getDescription(k.getType());
boolean present = !typeDesc.isBlank();
if (present) {
d.add(typeDesc);
}
present = appendSnippetHint(k, d, present);
appendDefaultValue(k, cl, d, present);
description.forEach((g) -> d.add(g.trim()));
String desc = d.toString("\n")
.replace("<hr></hr>", "\n")
.replace("<h>", "")
.replace("</h>", "");
String hDesc = d.toString("<br>");
prop.put("type", type);
prop.put("description", desc);
prop.put("x-intellij-html-description", hDesc);
return builder.buildSnippet(prop, k.getType());
}
private boolean appendSnippetHint(Field k, KList<String> d, boolean present) {
Snippet snippet = k.getType().getDeclaredAnnotation(Snippet.class);
if (snippet == null) {
ArrayType array = k.getType().getDeclaredAnnotation(ArrayType.class);
if (array != null) {
snippet = array.type().getDeclaredAnnotation(Snippet.class);
}
}
if (snippet == null) {
return present;
}
String sm = snippet.value();
if (present) {
d.add(" ");
}
d.add("You can instead specify \"snippet/" + sm + "/some-name.json\" to use a snippet file instead of specifying it here.");
return false;
}
private void appendDefaultValue(Field k, Class<?> cl, KList<String> d, boolean present) {
Object instance;
try {
instance = cl.getDeclaredConstructor().newInstance();
} catch (ReflectiveOperationException | LinkageError e) {
return;
}
try {
k.setAccessible(true);
Object value = k.get(instance);
if (value != null) {
if (present) {
d.add(" ");
}
if (value instanceof List) {
d.add(SchemaBuilder.SYMBOL_LIMIT__N + " Default Value is an empty list");
} else if (!k.getType().isPrimitive() && !(value instanceof Number) && !(value instanceof String) && !(value instanceof Enum<?>) && !SchemaKeyedTypes.isKeyed(k.getType())) {
d.add(SchemaBuilder.SYMBOL_LIMIT__N + " Default Value is a default object (create this object to see default properties)");
} else {
d.add(SchemaBuilder.SYMBOL_LIMIT__N + " Default Value is " + value);
}
}
} catch (Throwable e) {
IrisLogging.reportError("Schema default value unavailable for "
+ cl.getCanonicalName() + "." + k.getName(), e);
}
}
}
@@ -0,0 +1,252 @@
/*
* Iris is a World Generator for Minecraft Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.project;
import art.arcane.iris.spi.CapabilityProbe;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.volmlib.util.json.JSONArray;
import art.arcane.volmlib.util.json.JSONObject;
import java.awt.GraphicsEnvironment;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.function.Supplier;
final class SchemaRegistryCatalog {
/** Namespaced key or family/namespace prefix: "minecraft:village_plains", "minecraft:village", "nova_structures:". */
static final String VANILLA_STRUCTURE_PREFIX_PATTERN = "^[a-z0-9_.-]+:[a-z0-9_./-]*$";
private static final String MINECRAFT_NAMESPACE = "minecraft:";
private static volatile JSONArray fontTypes;
private final SchemaBuilder builder;
private JSONArray potionTypes;
private JSONArray enchantTypes;
SchemaRegistryCatalog(SchemaBuilder builder) {
this.builder = builder;
}
/**
* Font families are only used for schema completion. Enumerating them touches AWT, which can fail outright on a
* headless or mod-loader JVM - a failure degrades to no completion, never to a broken schema.
*/
static JSONArray fontTypes() {
JSONArray cached = fontTypes;
if (cached != null) {
return cached;
}
JSONArray built = CapabilityProbe.attempt("java.awt font families",
() -> new JSONArray(GraphicsEnvironment.getLocalGraphicsEnvironment().getAvailableFontFamilyNames()),
new JSONArray());
fontTypes = built;
return built;
}
JSONArray potionTypes() {
if (potionTypes == null) {
potionTypes = registryKeyForms(IrisPlatforms.get().registries().potionEffectKeys(), true);
}
return potionTypes;
}
JSONArray enchantTypes() {
if (enchantTypes == null) {
enchantTypes = registryKeyForms(IrisPlatforms.get().registries().enchantmentKeys(), false);
}
return enchantTypes;
}
/**
* Emits the full namespaced key for every registry entry, so mod and datapack content is addressable
* unambiguously, plus the legacy short form for the vanilla namespace so existing packs stay valid.
*/
private static JSONArray registryKeyForms(List<String> keys, boolean upperCaseLegacy) {
JSONArray a = new JSONArray();
Set<String> seen = new LinkedHashSet<>();
if (keys != null) {
for (String key : keys) {
if (key == null || key.isBlank()) {
continue;
}
seen.add(key);
if (key.startsWith(MINECRAFT_NAMESPACE) || key.indexOf(':') < 0) {
String path = stripNamespace(key);
seen.add(upperCaseLegacy ? path.toUpperCase(Locale.ROOT).replace(' ', '_') : path);
}
}
}
for (String key : seen) {
a.put(key);
}
return a;
}
/**
* Biome derivatives resolve through NamespacedKey.fromString, which accepts a full key or a bare vanilla path,
* so both forms are offered.
*/
JSONArray biomeTypes() {
return registryKeyForms(IrisPlatforms.get().registries().biomeKeys(), false);
}
JSONArray entityTypes() {
return keysAsArray(IrisPlatforms.get().registries().entityKeys());
}
JSONArray specialEntityTypes() {
return keysAsArray(IrisPlatforms.get().registries().specialEntityKeys());
}
JSONArray vanillaStructures() {
return keysAsArray(IrisPlatforms.get().registries().structureKeys());
}
JSONArray vanillaStructureSets() {
if (IrisPlatforms.get().structureHooks() == null) {
return new JSONArray();
}
List<String> keys = IrisPlatforms.get().structureHooks().structureSetKeys();
return keysAsArray(keys == null ? List.of() : keys);
}
JSONArray nativeJigsawPools() {
return keysAsArray(templatePoolKeys());
}
private static JSONArray keysAsArray(List<String> keys) {
JSONArray a = new JSONArray();
if (keys != null) {
for (String key : keys) {
if (key != null && !key.isBlank()) {
a.put(key);
}
}
}
return a;
}
/**
* Registers a registry-backed enum definition under {@code definitionKey} and points {@code target} at it.
* <p>
* An empty key list means the registry has nothing to offer yet - the server is still booting, a modded registry
* has not been frozen, or the host simply does not expose that catalog. Emitting {@code "enum": []} in that case
* writes a schema that rejects every value the author could possibly type, turning a missing autocomplete list
* into a pack that reads as broken in the editor. The reference is omitted instead, leaving the field
* unconstrained, and the values are only computed when the definition does not exist yet.
*/
void putRegistryEnumRef(JSONObject target, String definitionKey, Supplier<JSONArray> values) {
if (!builder.definitions.containsKey(definitionKey)) {
JSONArray built = values.get();
if (built == null || built.length() == 0) {
IrisLogging.debug("Schema enum '" + definitionKey + "' omitted: the registry returned no keys");
return;
}
JSONObject definition = new JSONObject();
definition.put("enum", built);
builder.definitions.put(definitionKey, definition);
}
target.put("$ref", "#/definitions/" + definitionKey);
}
/**
* {@link #putRegistryEnumRef(JSONObject, String, Supplier)} for a list-typed property. When the enum is omitted no
* {@code items} schema is written at all, which is valid and simply means "any element".
*/
void putRegistryEnumItems(JSONObject prop, String definitionKey, Supplier<JSONArray> values) {
JSONObject items = new JSONObject();
putRegistryEnumRef(items, definitionKey, values);
if (items.has("$ref")) {
prop.put("items", items);
}
}
/**
* A registry enum that ALSO accepts family/namespace prefixes ("minecraft:village",
* "nova_structures:") — the runtime prefix-matching contract of importedStructures.disabled and
* adjustments[].match. Emitted as anyOf(enum, pattern) so autocomplete still offers registered
* keys while prefix entries validate instead of being rejected.
*/
void putRegistryEnumOrPrefixRef(JSONObject target, String definitionKey,
String enumDefinitionKey, Supplier<JSONArray> values,
String pattern) {
if (!builder.definitions.containsKey(definitionKey)) {
JSONArray anyOf = new JSONArray();
JSONObject enumRef = new JSONObject();
try {
putRegistryEnumRef(enumRef, enumDefinitionKey, values);
} catch (RuntimeException e) {
IrisLogging.reportError("Schema enum '" + enumDefinitionKey
+ "' unavailable; emitting prefix pattern only", e);
}
if (enumRef.has("$ref")) {
anyOf.put(enumRef);
}
JSONObject prefix = new JSONObject();
prefix.put("type", "string");
prefix.put("pattern", pattern);
anyOf.put(prefix);
JSONObject definition = new JSONObject();
definition.put("anyOf", anyOf);
builder.definitions.put(definitionKey, definition);
}
target.put("$ref", "#/definitions/" + definitionKey);
}
void putRegistryEnumOrPrefixItems(JSONObject prop, String definitionKey,
String enumDefinitionKey, Supplier<JSONArray> values,
String pattern) {
JSONObject items = new JSONObject();
putRegistryEnumOrPrefixRef(items, definitionKey, enumDefinitionKey, values, pattern);
prop.put("items", items);
}
JSONArray itemTypes() {
JSONArray a = new JSONArray();
for (String key : IrisPlatforms.get().registries().itemKeys()) {
a.put(key.startsWith(MINECRAFT_NAMESPACE) ? key.substring(MINECRAFT_NAMESPACE.length()) : key);
}
return a;
}
JSONArray blockTypes() {
JSONArray a = new JSONArray();
for (String i : builder.data.getBlockLoader().getPossibleKeys()) {
a.put(i);
}
for (String i : IrisPlatforms.get().registries().blockTypeKeys()) {
a.put(i);
}
return a;
}
private static String stripNamespace(String key) {
int idx = key.indexOf(':');
return idx < 0 ? key : key.substring(idx + 1);
}
private List<String> templatePoolKeys() {
if (IrisPlatforms.get().structureHooks() == null) {
return List.of();
}
List<String> keys = IrisPlatforms.get().structureHooks().templatePoolKeys();
return keys == null ? List.of() : keys;
}
}
@@ -0,0 +1,288 @@
/*
* Iris is a World Generator for Minecraft Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.project;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.core.loader.ResourceLoader;
import art.arcane.iris.core.structure.StructureSchemaKeys;
import art.arcane.iris.engine.framework.ListFunction;
import art.arcane.iris.engine.object.annotations.RegistryListBiome;
import art.arcane.iris.engine.object.annotations.RegistryListBlockType;
import art.arcane.iris.engine.object.annotations.RegistryListEnchantment;
import art.arcane.iris.engine.object.annotations.RegistryListEntityType;
import art.arcane.iris.engine.object.annotations.RegistryListFont;
import art.arcane.iris.engine.object.annotations.RegistryListFunction;
import art.arcane.iris.engine.object.annotations.RegistryListItemType;
import art.arcane.iris.engine.object.annotations.RegistryListNativeJigsawPool;
import art.arcane.iris.engine.object.annotations.RegistryListPotionEffect;
import art.arcane.iris.engine.object.annotations.RegistryListResource;
import art.arcane.iris.engine.object.annotations.RegistryListSpecialEntity;
import art.arcane.iris.engine.object.annotations.RegistryListStructure;
import art.arcane.iris.engine.object.annotations.RegistryListVanillaStructure;
import art.arcane.iris.engine.object.annotations.RegistryListVanillaStructureSet;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.json.JSONArray;
import art.arcane.volmlib.util.json.JSONObject;
import java.lang.reflect.Field;
import java.util.Arrays;
final class SchemaRegistryProperties {
private static final String IRIS_STRUCTURE_DEFINITION = "enum-iris-structure-placement";
private final SchemaBuilder builder;
private final SchemaRegistryCatalog catalog;
SchemaRegistryProperties(SchemaBuilder builder, SchemaRegistryCatalog catalog) {
this.builder = builder;
this.catalog = catalog;
}
String stringRegistryType(Field k, JSONObject prop, KList<String> description) {
if (k.isAnnotationPresent(RegistryListResource.class)) {
return resourceReference(k, prop, description);
}
if (k.isAnnotationPresent(RegistryListStructure.class)) {
putIrisStructureDefinition();
prop.put("$ref", "#/definitions/" + IRIS_STRUCTURE_DEFINITION);
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid vanilla, datapack, or imported Iris structure (use ctrl+space for auto complete!)");
return "Structure";
}
if (k.isAnnotationPresent(RegistryListBlockType.class)) {
catalog.putRegistryEnumRef(prop, "enum-block-type", catalog::blockTypes);
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid Block Type (use ctrl+space for auto complete!)");
return "Block Type";
}
if (k.isAnnotationPresent(RegistryListNativeJigsawPool.class)) {
catalog.putRegistryEnumRef(prop, "enum-native-jigsaw-pool", catalog::nativeJigsawPools);
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a registered vanilla, datapack, or modded template pool key (use ctrl+space for auto complete!)");
return "Native Jigsaw Pool";
}
if (k.isAnnotationPresent(RegistryListVanillaStructure.class)) {
if (k.getAnnotation(RegistryListVanillaStructure.class).prefixes()) {
catalog.putRegistryEnumOrPrefixRef(prop, "enum-vanilla-structure-or-prefix",
"enum-vanilla-structure", catalog::vanillaStructures, SchemaRegistryCatalog.VANILLA_STRUCTURE_PREFIX_PATTERN);
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a vanilla/datapack structure key or a family/namespace prefix like 'minecraft:village' or 'nova_structures:' (use ctrl+space for auto complete!)");
} else {
catalog.putRegistryEnumRef(prop, "enum-vanilla-structure", catalog::vanillaStructures);
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid vanilla/datapack structure key (use ctrl+space for auto complete!)");
}
return "Vanilla Structure";
}
if (k.isAnnotationPresent(RegistryListVanillaStructureSet.class)) {
catalog.putRegistryEnumRef(prop, "enum-vanilla-structure-set", catalog::vanillaStructureSets);
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid vanilla/datapack structure SET key (use ctrl+space for auto complete!)");
return "Vanilla Structure Set";
}
if (k.isAnnotationPresent(RegistryListItemType.class)) {
catalog.putRegistryEnumRef(prop, "enum-item-type", catalog::itemTypes);
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid Item Type (use ctrl+space for auto complete!)");
return "Item Type";
}
if (k.isAnnotationPresent(RegistryListEntityType.class)) {
catalog.putRegistryEnumRef(prop, "enum-entity-type", catalog::entityTypes);
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid Entity Type (use ctrl+space for auto complete!)");
return "Entity Type";
}
if (k.isAnnotationPresent(RegistryListBiome.class)) {
catalog.putRegistryEnumRef(prop, "enum-biome-type", catalog::biomeTypes);
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid vanilla, datapack, or mod biome key (use ctrl+space for auto complete!)");
return "Biome Type";
}
if (k.isAnnotationPresent(RegistryListSpecialEntity.class)) {
catalog.putRegistryEnumRef(prop, "enum-reg-specialentity", catalog::specialEntityTypes);
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid Custom Mob Type (use ctrl+space for auto complete!)");
return "Custom Mob Type";
}
if (k.isAnnotationPresent(RegistryListFont.class)) {
catalog.putRegistryEnumRef(prop, "enum-font", SchemaRegistryCatalog::fontTypes);
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid Font Family (use ctrl+space for auto complete!)");
return "Font Family";
}
if (k.isAnnotationPresent(RegistryListEnchantment.class)) {
catalog.putRegistryEnumRef(prop, "enum-enchantment", catalog::enchantTypes);
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid Enchantment Type (use ctrl+space for auto complete!)");
return "Enchantment Type";
}
if (k.isAnnotationPresent(RegistryListPotionEffect.class)) {
catalog.putRegistryEnumRef(prop, "enum-potion-effect-type", catalog::potionTypes);
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid Potion Effect Type (use ctrl+space for auto complete!)");
return "Potion Effect Type";
}
if (k.isAnnotationPresent(RegistryListFunction.class)) {
return functionReference(k, prop, description, "Text", false);
}
return null;
}
String stringArrayRegistryType(Field k, JSONObject prop, KList<String> description) {
if (k.isAnnotationPresent(RegistryListResource.class)) {
return resourceItems(k, prop, description);
}
if (k.isAnnotationPresent(RegistryListStructure.class)) {
putIrisStructureDefinition();
putItemsReference(prop, IRIS_STRUCTURE_DEFINITION);
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid vanilla, datapack, or imported Iris structure (use ctrl+space for auto complete!)");
return "List<Structure>";
}
if (k.isAnnotationPresent(RegistryListNativeJigsawPool.class)) {
catalog.putRegistryEnumItems(prop, "enum-native-jigsaw-pool", catalog::nativeJigsawPools);
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a registered vanilla, datapack, or modded template pool key (use ctrl+space for auto complete!)");
return "List<Native Jigsaw Pool>";
}
if (k.isAnnotationPresent(RegistryListVanillaStructure.class)) {
if (k.getAnnotation(RegistryListVanillaStructure.class).prefixes()) {
catalog.putRegistryEnumOrPrefixItems(prop, "enum-vanilla-structure-or-prefix",
"enum-vanilla-structure", catalog::vanillaStructures, SchemaRegistryCatalog.VANILLA_STRUCTURE_PREFIX_PATTERN);
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a vanilla/datapack structure key or a family/namespace prefix like 'minecraft:village' or 'nova_structures:' (use ctrl+space for auto complete!)");
} else {
catalog.putRegistryEnumItems(prop, "enum-vanilla-structure", catalog::vanillaStructures);
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid vanilla/datapack structure key (use ctrl+space for auto complete!)");
}
return "List<Vanilla Structure>";
}
if (k.isAnnotationPresent(RegistryListVanillaStructureSet.class)) {
catalog.putRegistryEnumItems(prop, "enum-vanilla-structure-set", catalog::vanillaStructureSets);
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid vanilla/datapack structure set key (use ctrl+space for auto complete!)");
return "List<Vanilla Structure Set>";
}
if (k.isAnnotationPresent(RegistryListBlockType.class)) {
catalog.putRegistryEnumItems(prop, "enum-block-type", catalog::blockTypes);
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid Block Type (use ctrl+space for auto complete!)");
return "List of Block Types";
}
if (k.isAnnotationPresent(RegistryListItemType.class)) {
catalog.putRegistryEnumItems(prop, "enum-item-type", catalog::itemTypes);
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid Item Type (use ctrl+space for auto complete!)");
return "List of Item Types";
}
if (k.isAnnotationPresent(RegistryListEntityType.class)) {
catalog.putRegistryEnumItems(prop, "enum-entity-type", catalog::entityTypes);
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid Entity Type (use ctrl+space for auto complete!)");
return "List of Entity Types";
}
if (k.isAnnotationPresent(RegistryListBiome.class)) {
catalog.putRegistryEnumItems(prop, "enum-biome-type", catalog::biomeTypes);
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid vanilla, datapack, or mod biome key (use ctrl+space for auto complete!)");
return "List of Biome Types";
}
if (k.isAnnotationPresent(RegistryListFont.class)) {
catalog.putRegistryEnumItems(prop, "enum-font", SchemaRegistryCatalog::fontTypes);
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid Font Family (use ctrl+space for auto complete!)");
return "List of Font Families";
}
if (k.isAnnotationPresent(RegistryListEnchantment.class)) {
catalog.putRegistryEnumItems(prop, "enum-enchantment", catalog::enchantTypes);
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid Enchantment Type (use ctrl+space for auto complete!)");
return "List of Enchantment Types";
}
if (k.isAnnotationPresent(RegistryListPotionEffect.class)) {
catalog.putRegistryEnumItems(prop, "enum-potion-effect-type", catalog::potionTypes);
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid Potion Effect Type (use ctrl+space for auto complete!)");
return "List of Potion Effect Types";
}
if (k.isAnnotationPresent(RegistryListFunction.class)) {
return functionReference(k, prop, description, "List of Text", true);
}
return null;
}
private String resourceReference(Field k, JSONObject prop, KList<String> description) {
RegistryListResource rr = k.getDeclaredAnnotation(RegistryListResource.class);
ResourceLoader<?> loader = builder.data.getLoaders().get(rr.value());
if (loader == null) {
IrisLogging.error("Cannot find Registry Loader for type " + rr.value() + " used in " + k.getDeclaringClass().getCanonicalName() + " in field " + k.getName());
return "Text";
}
String key = "erz" + loader.getFolderName();
putResourceDefinition(key, loader);
prop.put("$ref", "#/definitions/" + key);
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid " + loader.getFolderName() + " (use ctrl+space for auto complete!)");
return "Iris " + loader.getResourceTypeName();
}
private String resourceItems(Field k, JSONObject prop, KList<String> description) {
RegistryListResource rr = k.getDeclaredAnnotation(RegistryListResource.class);
ResourceLoader<?> loader = builder.data.getLoaders().get(rr.value());
if (loader == null) {
IrisLogging.error("Cannot find Registry Loader for type (list schema) " + rr.value() + " used in " + k.getDeclaringClass().getCanonicalName() + " in field " + k.getName());
return "List of Text";
}
String key = "erz" + loader.getFolderName();
putResourceDefinition(key, loader);
putItemsReference(prop, key);
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid " + loader.getResourceTypeName() + " (use ctrl+space for auto complete!)");
return "List<" + loader.getResourceTypeName() + ">";
}
private String functionReference(Field k, JSONObject prop, KList<String> description, String fallbackType, boolean asItems) {
Class<? extends ListFunction<KList<String>>> functionClass = k.getDeclaredAnnotation(RegistryListFunction.class).value();
String fancyType = fallbackType;
try {
ListFunction<KList<String>> instance = functionClass.getDeclaredConstructor().newInstance();
String key = instance.key();
fancyType = instance.fancyName();
if (!builder.definitions.containsKey(key)) {
JSONObject j = new JSONObject();
j.put("enum", instance.apply(builder.data));
builder.definitions.put(key, j);
}
if (asItems) {
putItemsReference(prop, key);
} else {
prop.put("$ref", "#/definitions/" + key);
}
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid " + fancyType + " (use ctrl+space for auto complete!)");
} catch (Throwable e) {
IrisLogging.reportError("Could not execute apply method in " + functionClass.getName(), e);
}
return fancyType;
}
private void putResourceDefinition(String key, ResourceLoader<?> loader) {
if (!builder.definitions.containsKey(key)) {
JSONObject j = new JSONObject();
j.put("enum", new JSONArray(loader.getPossibleKeys()));
builder.definitions.put(key, j);
}
}
private void putIrisStructureDefinition() {
if (!builder.definitions.containsKey(IRIS_STRUCTURE_DEFINITION)) {
JSONObject j = new JSONObject();
j.put("enum", new JSONArray(StructureSchemaKeys.collect(
Arrays.asList(builder.data.getStructureLoader().getPossibleKeys()),
Arrays.asList(builder.data.getJigsawPieceLoader().getPossibleKeys())).toArray(new String[0])));
builder.definitions.put(IRIS_STRUCTURE_DEFINITION, j);
}
}
private void putItemsReference(JSONObject prop, String key) {
JSONObject items = new JSONObject();
items.put("$ref", "#/definitions/" + key);
prop.put("items", items);
}
}
@@ -1,6 +1,7 @@
package art.arcane.iris.core.runtime;
import art.arcane.iris.core.lifecycle.CapabilitySnapshot;
import art.arcane.iris.spi.CapabilityProbe;
import io.papermc.lib.PaperLib;
import org.bukkit.Chunk;
import org.bukkit.World;
@@ -56,14 +57,12 @@ final class BukkitPublicRuntimeControlBackend implements WorldRuntimeControlBack
}
if (capabilities.chunkAtAsyncMethod() != null) {
try {
Object result = capabilities.chunkAtAsyncMethod().invoke(world, chunkX, chunkZ, generate);
if (result instanceof CompletableFuture<?>) {
@SuppressWarnings("unchecked")
CompletableFuture<Chunk> future = (CompletableFuture<Chunk>) result;
return future;
}
} catch (Throwable ignored) {
Object result = CapabilityProbe.attempt("world#getChunkAtAsync",
() -> capabilities.chunkAtAsyncMethod().invoke(world, chunkX, chunkZ, generate), null);
if (result instanceof CompletableFuture<?>) {
@SuppressWarnings("unchecked")
CompletableFuture<Chunk> future = (CompletableFuture<Chunk>) result;
return future;
}
}
@@ -1,6 +1,8 @@
package art.arcane.iris.core.runtime;
import art.arcane.iris.core.lifecycle.CapabilitySnapshot;
import art.arcane.iris.spi.CapabilityProbe;
import art.arcane.iris.spi.IrisLogging;
import io.papermc.lib.PaperLib;
import org.bukkit.Bukkit;
import org.bukkit.Chunk;
@@ -57,7 +59,9 @@ final class PaperLikeRuntimeControlBackend implements WorldRuntimeControlBackend
if (value instanceof Number number) {
return OptionalLong.of(number.longValue());
}
} catch (Throwable ignored) {
} catch (Throwable failure) {
IrisLogging.reportError("Failed to read the runtime day time of world \"" + world.getName()
+ "\" through " + strategy.description() + ".", failure);
}
return OptionalLong.empty();
@@ -107,7 +111,9 @@ final class PaperLikeRuntimeControlBackend implements WorldRuntimeControlBackend
}
strategy.syncMethod().invoke(serverHandle);
} catch (Throwable ignored) {
} catch (Throwable failure) {
IrisLogging.reportError("Failed to push the runtime day time of world \""
+ (world == null ? "unknown" : world.getName()) + "\" to its players.", failure);
}
}
@@ -161,42 +167,42 @@ final class PaperLikeRuntimeControlBackend implements WorldRuntimeControlBackend
return TimeAccessStrategy.unsupported();
}
try {
Method handleMethod = resolveZeroArgMethod(world.getClass(), "getHandle");
if (handleMethod == null) {
return TimeAccessStrategy.unsupported();
}
return CapabilityProbe.attempt("runtime world clock", () -> resolveTimeAccess(world),
TimeAccessStrategy.unsupported());
}
Object handle = handleMethod.invoke(world);
if (handle == null) {
return TimeAccessStrategy.unsupported();
}
Method readMethod = resolveZeroArgMethod(handle.getClass(), "getDayTime");
Method writeMethod = resolveLongArgMethod(handle.getClass(), "setDayTime");
if (readMethod != null && writeMethod != null) {
return TimeAccessStrategy.forHandle(handleMethod, readMethod, writeMethod, "runtime_handle#setDayTime");
}
Method levelDataMethod = resolveZeroArgMethod(handle.getClass(), "serverLevelData");
if (levelDataMethod == null) {
levelDataMethod = resolveZeroArgMethod(handle.getClass(), "getLevelData");
}
if (levelDataMethod != null) {
Object levelData = levelDataMethod.invoke(handle);
if (levelData != null) {
Method levelDataReadMethod = resolveZeroArgMethod(levelData.getClass(), "getDayTime");
Method levelDataWriteMethod = resolveLongArgMethod(levelData.getClass(), "setDayTime");
if (levelDataReadMethod != null && levelDataWriteMethod != null) {
return TimeAccessStrategy.forLevelData(handleMethod, levelDataMethod, levelDataReadMethod, levelDataWriteMethod, "world_data#setDayTime");
}
}
}
return TimeAccessStrategy.unsupported(handleMethod);
} catch (Throwable ignored) {
private TimeAccessStrategy resolveTimeAccess(World world) throws ReflectiveOperationException {
Method handleMethod = resolveZeroArgMethod(world.getClass(), "getHandle");
if (handleMethod == null) {
return TimeAccessStrategy.unsupported();
}
Object handle = handleMethod.invoke(world);
if (handle == null) {
return TimeAccessStrategy.unsupported();
}
Method readMethod = resolveZeroArgMethod(handle.getClass(), "getDayTime");
Method writeMethod = resolveLongArgMethod(handle.getClass(), "setDayTime");
if (readMethod != null && writeMethod != null) {
return TimeAccessStrategy.forHandle(handleMethod, readMethod, writeMethod, "runtime_handle#setDayTime");
}
Method levelDataMethod = resolveZeroArgMethod(handle.getClass(), "serverLevelData");
if (levelDataMethod == null) {
levelDataMethod = resolveZeroArgMethod(handle.getClass(), "getLevelData");
}
if (levelDataMethod != null) {
Object levelData = levelDataMethod.invoke(handle);
if (levelData != null) {
Method levelDataReadMethod = resolveZeroArgMethod(levelData.getClass(), "getDayTime");
Method levelDataWriteMethod = resolveLongArgMethod(levelData.getClass(), "setDayTime");
if (levelDataReadMethod != null && levelDataWriteMethod != null) {
return TimeAccessStrategy.forLevelData(handleMethod, levelDataMethod, levelDataReadMethod, levelDataWriteMethod, "world_data#setDayTime");
}
}
}
return TimeAccessStrategy.unsupported(handleMethod);
}
private static Method resolveZeroArgMethod(Class<?> type, String name) {
@@ -283,13 +289,11 @@ final class PaperLikeRuntimeControlBackend implements WorldRuntimeControlBackend
}
private static Method resolveCraftServerMethod(String name) {
try {
return CapabilityProbe.attempt("craft server#" + name, () -> {
Method method = Bukkit.getServer().getClass().getMethod(name);
method.setAccessible(true);
return method;
} catch (Throwable ignored) {
return null;
}
}, null);
}
private static Method resolveServerMethod(Method serverHandleMethod, String name) {
@@ -297,7 +301,7 @@ final class PaperLikeRuntimeControlBackend implements WorldRuntimeControlBackend
return null;
}
try {
return CapabilityProbe.attempt("minecraft server#" + name, () -> {
Object craftServer = Bukkit.getServer();
if (craftServer == null) {
return null;
@@ -311,9 +315,7 @@ final class PaperLikeRuntimeControlBackend implements WorldRuntimeControlBackend
Method method = serverHandle.getClass().getMethod(name);
method.setAccessible(true);
return method;
} catch (Throwable ignored) {
return null;
}
}, null);
}
}
}
@@ -0,0 +1,86 @@
package art.arcane.iris.core.runtime;
import art.arcane.iris.core.IrisStartupValidation;
import art.arcane.iris.core.nms.INMS;
import art.arcane.iris.core.nms.v1X.NMSBinding1X;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.util.project.agent.Agent;
public final class RuntimeInjection {
private static final Object LOCK = new Object();
private static volatile Outcome outcome;
private RuntimeInjection() {
}
public enum Failure {
NONE,
NO_BINDING,
AGENT,
INJECTION
}
public record Outcome(boolean installed, Failure failure, String lockReason) {
}
public static Outcome install() {
Outcome current = outcome;
if (current != null) {
return current;
}
synchronized (LOCK) {
if (outcome != null) {
return outcome;
}
Outcome resolved = attemptInstall();
outcome = resolved;
return resolved;
}
}
public static void installIfDeferred() {
Outcome resolved = install();
if (resolved.installed()) {
return;
}
IrisLogging.error("Iris runtime injection is unavailable: " + resolved.lockReason());
IrisStartupValidation.markRuntimeInvalid(resolved.lockReason());
}
public static boolean isInstalled() {
Outcome current = outcome;
return current != null && current.installed();
}
public static void reset() {
synchronized (LOCK) {
outcome = null;
}
}
static String agentLockReason() {
return "Iris Java agent is unavailable. Add -javaagent:" + Agent.AGENT_JAR.getPath()
+ " to the JVM arguments before -jar and restart the server.";
}
private static Outcome attemptInstall() {
if (!INMS.isBound() || INMS.get() instanceof NMSBinding1X) {
return new Outcome(false, Failure.NO_BINDING,
"Iris cannot use this server's NMS runtime. Resolve the Server Version or NMS Disabled error above and restart the server.");
}
if (!Agent.install()) {
return new Outcome(false, Failure.AGENT, agentLockReason());
}
if (!INMS.get().injectBukkit()) {
return new Outcome(false, Failure.INJECTION,
"Iris runtime injection failed. Resolve the startup errors and restart the server.");
}
return new Outcome(true, Failure.NONE, null);
}
}
@@ -1,5 +1,6 @@
package art.arcane.iris.core.runtime;
import art.arcane.iris.spi.CapabilityProbe;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.core.IrisSettings;
@@ -30,7 +31,6 @@ import org.bukkit.plugin.PluginManager;
import org.bukkit.util.BoundingBox;
import org.bukkit.util.VoxelShape;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.LinkedHashSet;
import java.util.Objects;
@@ -76,6 +76,12 @@ public final class WorldRuntimeControlService {
+ ", " + backend.describeCapabilities();
}
public static void reset() {
synchronized (WorldRuntimeControlService.class) {
instance = null;
}
}
public static WorldRuntimeControlService get() {
WorldRuntimeControlService current = instance;
if (current != null) {
@@ -192,7 +198,8 @@ public final class WorldRuntimeControlService {
backend.syncTime(world);
return true;
} catch (Throwable e) {
IrisLogging.debug("Runtime time lock skipped for world \"" + world.getName() + "\": " + e.getMessage());
IrisLogging.reportError("Failed to lock the clock of world \"" + world.getName()
+ "\" to noon; the world keeps its own time.", e);
return false;
}
}
@@ -621,21 +628,16 @@ public final class WorldRuntimeControlService {
continue;
}
try {
Field field = GameRule.class.getField(name);
Object value = field.get(null);
if (value instanceof GameRule<?> gameRule && Integer.class.equals(gameRule.getType())) {
return (GameRule<Integer>) gameRule;
}
} catch (Throwable ignored) {
Object declared = CapabilityProbe.attempt("GameRule." + name,
() -> GameRule.class.getField(name).get(null), null);
if (declared instanceof GameRule<?> gameRule && Integer.class.equals(gameRule.getType())) {
return (GameRule<Integer>) gameRule;
}
try {
GameRule<?> byName = GameRule.getByName(name);
if (byName != null && Integer.class.equals(byName.getType())) {
return (GameRule<Integer>) byName;
}
} catch (Throwable ignored) {
GameRule<?> byName = CapabilityProbe.attempt("GameRule#getByName(" + name + ")",
() -> GameRule.getByName(name), null);
if (byName != null && Integer.class.equals(byName.getType())) {
return (GameRule<Integer>) byName;
}
}
@@ -657,12 +659,10 @@ public final class WorldRuntimeControlService {
continue;
}
try {
GameRule<?> byName = GameRule.getByName(availableRule);
if (byName != null && Integer.class.equals(byName.getType())) {
return (GameRule<Integer>) byName;
}
} catch (Throwable ignored) {
GameRule<?> byName = CapabilityProbe.attempt("GameRule#getByName(" + availableRule + ")",
() -> GameRule.getByName(availableRule), null);
if (byName != null && Integer.class.equals(byName.getType())) {
return (GameRule<Integer>) byName;
}
}
@@ -681,21 +681,16 @@ public final class WorldRuntimeControlService {
continue;
}
try {
Field field = GameRule.class.getField(name);
Object value = field.get(null);
if (value instanceof GameRule<?> gameRule && Boolean.class.equals(gameRule.getType())) {
return (GameRule<Boolean>) gameRule;
}
} catch (Throwable ignored) {
Object declared = CapabilityProbe.attempt("GameRule." + name,
() -> GameRule.class.getField(name).get(null), null);
if (declared instanceof GameRule<?> gameRule && Boolean.class.equals(gameRule.getType())) {
return (GameRule<Boolean>) gameRule;
}
try {
GameRule<?> byName = GameRule.getByName(name);
if (byName != null && Boolean.class.equals(byName.getType())) {
return (GameRule<Boolean>) byName;
}
} catch (Throwable ignored) {
GameRule<?> byName = CapabilityProbe.attempt("GameRule#getByName(" + name + ")",
() -> GameRule.getByName(name), null);
if (byName != null && Boolean.class.equals(byName.getType())) {
return (GameRule<Boolean>) byName;
}
}
@@ -717,12 +712,10 @@ public final class WorldRuntimeControlService {
continue;
}
try {
GameRule<?> byName = GameRule.getByName(availableRule);
if (byName != null && Boolean.class.equals(byName.getType())) {
return (GameRule<Boolean>) byName;
}
} catch (Throwable ignored) {
GameRule<?> byName = CapabilityProbe.attempt("GameRule#getByName(" + availableRule + ")",
() -> GameRule.getByName(availableRule), null);
if (byName != null && Boolean.class.equals(byName.getType())) {
return (GameRule<Boolean>) byName;
}
}
@@ -0,0 +1,305 @@
package art.arcane.iris.core.runtime.jigsaw;
import art.arcane.iris.engine.framework.structure.PlanarJigsawWorkcellResolver;
import art.arcane.iris.engine.object.IrisJigsawMode;
import art.arcane.iris.engine.object.IrisJigsawWorkcellArchetype;
import art.arcane.iris.engine.object.IrisStructure;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
final class JigsawStudioGraphDocuments {
static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private JigsawStudioGraphDocuments() {
}
static Map<String, JigsawPlanarArchetype> expectedThemeSetSources(
IrisStructure structure,
Set<String> requestedStableIds
) throws IOException {
Map<String, JigsawPlanarArchetype> expected = new LinkedHashMap<>();
if (structure.resolvedMode() == IrisJigsawMode.SPATIAL_JIGSAW) {
if (requestedStableIds.isEmpty()) {
throw new IOException("A spatial theme set requires at least one workcell source");
}
List<String> stableIds = new ArrayList<>(requestedStableIds);
stableIds.sort(Comparator.naturalOrder());
for (String stableId : stableIds) {
if (!stableId.equals(JigsawStudioLayout.SPATIAL_WORKCELL_ID)
&& !stableId.startsWith(JigsawStudioLayout.SPATIAL_WORKCELL_ID + "/")) {
throw new IOException("Invalid spatial workcell source '" + stableId + "'");
}
expected.put(stableId, null);
}
return expected;
}
Map<IrisJigsawWorkcellArchetype, PlanarJigsawWorkcellResolver.ResolvedWorkcell> workcells;
try {
workcells = PlanarJigsawWorkcellResolver.resolve(structure);
} catch (IllegalArgumentException exception) {
throw new IOException("Planar workcell configuration is invalid: "
+ exception.getMessage(), exception);
}
for (JigsawPlanarArchetype archetype : JigsawPlanarArchetype.values()) {
PlanarJigsawWorkcellResolver.ResolvedWorkcell workcell = workcells.get(archetype.modelArchetype());
if (workcell != null && workcell.enabled()) {
expected.put(archetype.stableId(), archetype);
}
}
if (expected.isEmpty()) {
throw new IOException("A planar theme set requires at least one enabled workcell");
}
return expected;
}
static void requireExactThemeSetSources(
Map<String, String> requestedSources,
Set<String> expectedStableIds
) throws IOException {
Set<String> missing = new LinkedHashSet<>(expectedStableIds);
missing.removeAll(requestedSources.keySet());
Set<String> unexpected = new LinkedHashSet<>(requestedSources.keySet());
unexpected.removeAll(expectedStableIds);
if (!missing.isEmpty() || !unexpected.isEmpty()) {
throw new IOException("Theme-set sources must match enabled workcells exactly; missing="
+ missing + ", unexpected=" + unexpected);
}
}
static byte[] appendThemeSet(
byte[] content,
String themeKey,
String structureResource
) throws IOException {
JsonElement parsed = JsonParser.parseString(new String(content, StandardCharsets.UTF_8));
if (!parsed.isJsonObject()) {
throw new IOException("Jigsaw structure is not a JSON object: " + structureResource);
}
JsonObject structure = parsed.getAsJsonObject();
JsonArray themeSets;
if (!structure.has("themeSets")) {
themeSets = new JsonArray();
structure.add("themeSets", themeSets);
} else if (!structure.get("themeSets").isJsonArray()) {
throw new IOException("Jigsaw structure has an invalid themeSets value: " + structureResource);
} else {
themeSets = structure.getAsJsonArray("themeSets");
}
for (JsonElement element : themeSets) {
if (element.isJsonObject()
&& element.getAsJsonObject().has("key")
&& themeKey.equals(element.getAsJsonObject().get("key").getAsString())) {
throw new IOException("Jigsaw structure already declares theme '" + themeKey + "'");
}
}
JsonObject theme = new JsonObject();
theme.addProperty("key", themeKey);
theme.addProperty("weight", 1);
themeSets.add(theme);
return (GSON.toJson(structure) + "\n").getBytes(StandardCharsets.UTF_8);
}
static byte[] duplicatePieceForTheme(
byte[] content,
String targetPieceKey,
String themeKey,
String sourcePieceResource
) throws IOException {
JsonElement parsed = JsonParser.parseString(new String(content, StandardCharsets.UTF_8));
if (!parsed.isJsonObject()) {
throw new IOException("Jigsaw piece is not a JSON object: " + sourcePieceResource);
}
JsonObject piece = parsed.getAsJsonObject().deepCopy();
piece.addProperty("object", targetPieceKey);
JsonArray themes = new JsonArray();
themes.add(themeKey);
piece.add("themes", themes);
return (GSON.toJson(piece) + "\n").getBytes(StandardCharsets.UTF_8);
}
static byte[] duplicatePieceForVariant(
byte[] content,
String targetPieceKey,
String sourcePieceResource
) throws IOException {
JsonElement parsed = JsonParser.parseString(new String(content, StandardCharsets.UTF_8));
if (!parsed.isJsonObject()) {
throw new IOException("Jigsaw piece is not a JSON object: " + sourcePieceResource);
}
JsonObject piece = parsed.getAsJsonObject().deepCopy();
piece.addProperty("object", targetPieceKey);
return (GSON.toJson(piece) + "\n").getBytes(StandardCharsets.UTF_8);
}
static PoolMembershipDuplication duplicatePoolMemberships(
byte[] content,
Map<String, String> targetPieceKeysBySource,
String poolResource
) throws IOException {
JsonElement parsed = JsonParser.parseString(new String(content, StandardCharsets.UTF_8));
if (!parsed.isJsonObject()) {
throw new IOException("Jigsaw pool is not a JSON object: " + poolResource);
}
JsonObject pool = parsed.getAsJsonObject();
JsonArray pieces = pool.getAsJsonArray("pieces");
if (pieces == null) {
throw new IOException("Jigsaw pool does not declare a pieces array: " + poolResource);
}
JsonArray expanded = new JsonArray();
int duplicatedEntries = 0;
for (JsonElement element : pieces) {
expanded.add(element.deepCopy());
if (!element.isJsonObject() || !element.getAsJsonObject().has("piece")) {
continue;
}
String sourcePieceKey = element.getAsJsonObject().get("piece").getAsString();
String targetPieceKey = targetPieceKeysBySource.get(sourcePieceKey);
if (targetPieceKey == null) {
continue;
}
JsonObject duplicate = element.getAsJsonObject().deepCopy();
duplicate.addProperty("piece", targetPieceKey);
expanded.add(duplicate);
duplicatedEntries++;
}
if (duplicatedEntries == 0) {
return new PoolMembershipDuplication(content, 0);
}
pool.add("pieces", expanded);
return new PoolMembershipDuplication(
(GSON.toJson(pool) + "\n").getBytes(StandardCharsets.UTF_8),
duplicatedEntries);
}
static List<String> normalizeThemes(List<String> themes) {
Objects.requireNonNull(themes, "Jigsaw Studio piece themes");
Set<String> normalized = new LinkedHashSet<>();
for (String theme : themes) {
Objects.requireNonNull(theme, "Jigsaw Studio piece theme");
String key = theme.trim();
if (key.isEmpty() || !key.equals(theme)) {
throw new IllegalArgumentException(
"Jigsaw Studio piece themes must be non-blank and whitespace-normalized");
}
if (!normalized.add(key)) {
throw new IllegalArgumentException("Duplicate Jigsaw Studio piece theme '" + key + "'");
}
}
return List.copyOf(normalized);
}
static PoolEntryRemoval removePoolEntries(
byte[] content,
String pieceKey,
String poolResource
) throws IOException {
JsonElement parsed = JsonParser.parseString(new String(content, StandardCharsets.UTF_8));
if (!parsed.isJsonObject()) {
throw new IOException("Jigsaw pool is not a JSON object: " + poolResource);
}
JsonObject pool = parsed.getAsJsonObject();
JsonArray pieces = pool.getAsJsonArray("pieces");
if (pieces == null) {
throw new IOException("Jigsaw pool does not declare a pieces array: " + poolResource);
}
int removed = 0;
for (int index = pieces.size() - 1; index >= 0; index--) {
JsonElement entry = pieces.get(index);
if (entry.isJsonObject()
&& entry.getAsJsonObject().has("piece")
&& pieceKey.equals(entry.getAsJsonObject().get("piece").getAsString())) {
pieces.remove(index);
removed++;
}
}
if (removed == 0) {
return new PoolEntryRemoval(content, 0);
}
return new PoolEntryRemoval(
(GSON.toJson(pool) + "\n").getBytes(StandardCharsets.UTF_8),
removed);
}
static String resourceKey(String relativePath, String prefix, String suffix) {
return relativePath.substring(prefix.length(), relativePath.length() - suffix.length());
}
static byte[] addPoolEntry(
byte[] content,
String pieceKey,
int weight,
Path poolPath
) throws IOException {
JsonElement parsed = JsonParser.parseString(new String(content, StandardCharsets.UTF_8));
if (!parsed.isJsonObject()) {
throw new IOException("Jigsaw pool is not a JSON object: " + poolPath);
}
JsonObject pool = parsed.getAsJsonObject();
if (!pool.has("pieces") || !pool.get("pieces").isJsonArray()) {
throw new IOException("Jigsaw pool does not declare a pieces array: " + poolPath);
}
JsonArray pieces = pool.getAsJsonArray("pieces");
for (JsonElement element : pieces) {
if (element.isJsonObject()
&& element.getAsJsonObject().has("piece")
&& pieceKey.equals(element.getAsJsonObject().get("piece").getAsString())) {
throw new IOException("Pool already references piece '" + pieceKey + "'");
}
}
JsonObject entry = new JsonObject();
entry.addProperty("piece", pieceKey);
entry.addProperty("weight", weight);
pieces.add(entry);
return (GSON.toJson(pool) + "\n").getBytes(StandardCharsets.UTF_8);
}
static String normalizeChannel(String channel) {
String normalized = channel == null ? "" : channel.trim();
if (normalized.equalsIgnoreCase("none")) {
return "";
}
if (normalized.length() > 128) {
throw new IllegalArgumentException("Jigsaw connector channels cannot exceed 128 characters");
}
for (int index = 0; index < normalized.length(); index++) {
if (Character.isWhitespace(normalized.charAt(index))) {
throw new IllegalArgumentException("Jigsaw connector channels cannot contain whitespace");
}
}
return normalized;
}
record PoolEntryRemoval(byte[] content, int removedEntries) {
PoolEntryRemoval {
Objects.requireNonNull(content, "Jigsaw Studio pool entry removal content");
if (removedEntries < 0) {
throw new IllegalArgumentException("Removed jigsaw pool entry count cannot be negative");
}
}
}
record PoolMembershipDuplication(byte[] content, int duplicatedEntries) {
PoolMembershipDuplication {
Objects.requireNonNull(content, "Jigsaw Studio duplicated pool content");
if (duplicatedEntries < 0) {
throw new IllegalArgumentException("Duplicated pool membership count cannot be negative");
}
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,153 @@
package art.arcane.iris.core.runtime.jigsaw;
import art.arcane.iris.core.structure.authoring.StructureResourceBundle;
import art.arcane.iris.engine.framework.structure.PlanarJigsawWorkcellResolver;
import art.arcane.iris.engine.object.IrisJigsawMode;
import art.arcane.iris.engine.object.IrisJigsawPiece;
import art.arcane.iris.engine.object.IrisJigsawWorkcellArchetype;
import art.arcane.iris.engine.object.IrisStructure;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.util.Map;
final class JigsawStudioGraphInspector {
private JigsawStudioGraphInspector() {
}
static void requireAvailableVariantTarget(
JigsawStudioGraphEditor.OwnedGraph graph,
String relativePath
) throws IOException {
StructureResourceBundle.validateRelativePath(relativePath);
Path target = graph.root().resolve(relativePath).normalize();
if (!target.startsWith(graph.root())) {
throw new IOException("Variant target escapes the pack root: " + relativePath);
}
if (graph.manifest().resourceHashes().containsKey(relativePath)
|| Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Variant target already exists: " + relativePath);
}
}
static void requireAvailableThemeSetTarget(
JigsawStudioGraphEditor.OwnedGraph graph,
String relativePath
) throws IOException {
StructureResourceBundle.validateRelativePath(relativePath);
Path target = graph.root().resolve(relativePath).normalize();
if (!target.startsWith(graph.root())) {
throw new IOException("Theme-set target escapes the pack root: " + relativePath);
}
if (graph.manifest().resourceHashes().containsKey(relativePath)
|| Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Theme-set target already exists: " + relativePath);
}
}
static boolean otherOwnedPieceReferencesObject(
JigsawStudioGraphEditor.OwnedGraph graph,
String targetPieceKey,
String objectKey
) throws IOException {
for (String relativePath : graph.manifest().resourceHashes().keySet()) {
if (!relativePath.startsWith("jigsaw-pieces/") || !relativePath.endsWith(".json")) {
continue;
}
String pieceKey = JigsawStudioGraphDocuments.resourceKey(relativePath, "jigsaw-pieces/", ".json");
if (pieceKey.equals(targetPieceKey)) {
continue;
}
IrisJigsawPiece piece = JigsawStudioGraphEditor.readPiece(JigsawStudioGraphEditor.resolveOwnedResource(graph.root(), relativePath), relativePath);
if (objectKey.equals(piece.getObject())) {
return true;
}
}
return false;
}
static void requireDeletableArchetype(
JigsawStudioGraphEditor.OwnedGraph graph,
String targetPieceKey,
IrisJigsawPiece targetPiece
) throws IOException {
String structureResource = "structures/" + graph.manifest().structure().path() + ".json";
if (!graph.manifest().resourceHashes().containsKey(structureResource)) {
throw new IOException("The owned graph manifest does not include " + structureResource);
}
IrisStructure structure = JigsawStudioGraphEditor.readStructure(
JigsawStudioGraphEditor.resolveOwnedResource(graph.root(), structureResource),
structureResource);
if (structure.resolvedMode() == IrisJigsawMode.SPATIAL_JIGSAW) {
if (countOtherOwnedPieces(graph, targetPieceKey, null) == 0) {
throw new IOException("Cannot delete the final spatial jigsaw variant");
}
return;
}
IrisJigsawWorkcellArchetype archetype = IrisJigsawWorkcellArchetype.fromPiece(targetPiece);
Map<IrisJigsawWorkcellArchetype, PlanarJigsawWorkcellResolver.ResolvedWorkcell> workcells;
try {
workcells = PlanarJigsawWorkcellResolver.resolve(structure);
} catch (IllegalArgumentException exception) {
throw new IOException("Planar workcell configuration is invalid: "
+ exception.getMessage(), exception);
}
PlanarJigsawWorkcellResolver.ResolvedWorkcell workcell = workcells.get(archetype);
if (workcell != null && workcell.enabled()
&& countOtherOwnedPieces(graph, targetPieceKey, archetype) == 0) {
throw new IOException("Cannot delete the final variant for enabled planar workcell "
+ JigsawPlanarArchetype.fromModel(archetype).stableId());
}
}
static int countOtherOwnedPieces(
JigsawStudioGraphEditor.OwnedGraph graph,
String targetPieceKey,
IrisJigsawWorkcellArchetype archetype
) throws IOException {
int count = 0;
for (String relativePath : graph.manifest().resourceHashes().keySet()) {
if (!relativePath.startsWith("jigsaw-pieces/") || !relativePath.endsWith(".json")) {
continue;
}
String pieceKey = JigsawStudioGraphDocuments.resourceKey(relativePath, "jigsaw-pieces/", ".json");
if (pieceKey.equals(targetPieceKey)) {
continue;
}
if (archetype == null) {
count++;
continue;
}
IrisJigsawPiece piece = JigsawStudioGraphEditor.readPiece(JigsawStudioGraphEditor.resolveOwnedResource(graph.root(), relativePath), relativePath);
if (IrisJigsawWorkcellArchetype.fromPiece(piece) == archetype) {
count++;
}
}
return count;
}
static void requireExclusiveObjectReference(
JigsawStudioGraphEditor.OwnedGraph graph,
String activePieceKey,
String objectKey
) throws IOException {
for (String relativePath : graph.manifest().resourceHashes().keySet()) {
if (!relativePath.startsWith("jigsaw-pieces/") || !relativePath.endsWith(".json")) {
continue;
}
String pieceKey = relativePath.substring(
"jigsaw-pieces/".length(),
relativePath.length() - ".json".length());
if (pieceKey.equals(activePieceKey)) {
continue;
}
IrisJigsawPiece piece = JigsawStudioGraphEditor.readPiece(JigsawStudioGraphEditor.resolveOwnedResource(graph.root(), relativePath), relativePath);
if (objectKey.equals(piece.getObject())) {
throw new IOException("Object '" + objectKey + "' is shared by piece '" + pieceKey
+ "'; duplicate the active variant before resizing its object bounds");
}
}
}
}
@@ -0,0 +1,548 @@
package art.arcane.iris.core.runtime.jigsaw;
import art.arcane.iris.engine.object.IrisDirection;
import art.arcane.iris.engine.object.IrisJigsawConnector;
import art.arcane.iris.engine.object.IrisJigsawPiece;
import art.arcane.iris.engine.object.IrisJigsawWorkcellArchetype;
import art.arcane.iris.engine.object.IrisObject;
import art.arcane.iris.engine.object.IrisPosition;
import art.arcane.iris.engine.object.TileData;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.iris.util.common.math.IrisBlockVector;
import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
final class JigsawStudioObjectResizer {
private JigsawStudioObjectResizer() {
}
static PlanarPieceObjectResize resizePlanarPieceObject(
IrisObject source,
IrisJigsawPiece piece,
JigsawPlanarArchetype archetype,
JigsawStudioCellDimensions dimensions,
String pieceKey
) throws IOException {
IrisObject sourceObject = Objects.requireNonNull(source, "Planar Jigsaw Studio source object");
IrisJigsawPiece targetPiece = Objects.requireNonNull(piece, "Planar Jigsaw Studio piece");
JigsawPlanarArchetype targetArchetype = Objects.requireNonNull(
archetype,
"Planar Jigsaw Studio archetype");
JigsawStudioCellDimensions targetDimensions = Objects.requireNonNull(
dimensions,
"Planar Jigsaw Studio target dimensions");
String targetPieceKey = pieceKey == null || pieceKey.isBlank() ? "unknown" : pieceKey;
if (targetDimensions.width() < 3 || targetDimensions.depth() < 3) {
throw new IllegalArgumentException(
"Planar Jigsaw Studio workcell width and depth must each be at least 3 blocks");
}
if (sourceObject.getW() < 1 || sourceObject.getH() < 1 || sourceObject.getD() < 1) {
throw new IOException("Planar piece '" + targetPieceKey + "' has invalid object dimensions");
}
if (IrisJigsawWorkcellArchetype.fromPiece(targetPiece) != targetArchetype.modelArchetype()) {
throw new IOException("Planar piece '" + targetPieceKey + "' does not belong to "
+ targetArchetype.stableId());
}
int quarterTurns = targetArchetype.modelArchetype().sourceToCanonicalQuarterTurns(targetPiece);
JigsawStudioCellDimensions sourceDimensions = new JigsawStudioCellDimensions(
sourceObject.getW(),
sourceObject.getH(),
sourceObject.getD());
JigsawStudioCellDimensions canonicalDimensions = canonicalDimensions(sourceDimensions, quarterTurns);
Map<LocalPosition, PlatformBlockState> canonicalBlocks = canonicalBlocks(
sourceObject,
quarterTurns,
targetPieceKey);
Map<LocalPosition, TileData> canonicalTiles = canonicalTiles(
sourceObject,
quarterTurns,
targetPieceKey);
List<IrisJigsawConnector> connectors = targetPiece.getConnectors();
if (connectors == null) {
throw new IOException("Planar piece '" + targetPieceKey + "' has no connector list");
}
List<ConnectorResize> connectorResizes = planConnectorResizes(
connectors,
sourceDimensions,
canonicalDimensions,
targetDimensions,
quarterTurns,
targetPieceKey);
relocateConnectorPayloads(canonicalBlocks, canonicalTiles, connectorResizes, targetPieceKey);
requireContentInsideTarget(canonicalBlocks, canonicalTiles, targetDimensions, targetPieceKey);
JigsawStudioCellDimensions resizedSourceDimensions = sourceDimensions(targetDimensions, quarterTurns);
IrisObject resizedObject = rebuildSourceObject(
canonicalBlocks,
canonicalTiles,
resizedSourceDimensions,
quarterTurns,
targetPieceKey);
int relocatedConnectors = 0;
for (ConnectorResize connectorResize : connectorResizes) {
LocalPosition sourcePosition = toSource(
connectorResize.targetCanonical(),
resizedSourceDimensions,
quarterTurns);
connectorResize.connector().setPosition(sourcePosition.toIrisPosition());
if (!connectorResize.sourceCanonical().equals(connectorResize.targetCanonical())) {
relocatedConnectors++;
}
}
return new PlanarPieceObjectResize(resizedObject, relocatedConnectors);
}
private static Map<LocalPosition, PlatformBlockState> canonicalBlocks(
IrisObject source,
int quarterTurns,
String pieceKey
) throws IOException {
Map<LocalPosition, PlatformBlockState> blocks = new LinkedHashMap<>();
for (Map.Entry<IrisBlockVector, PlatformBlockState> entry : source.getBlocks()) {
LocalPosition sourcePosition = unsignedPosition(entry.getKey(), source);
requireInside(sourcePosition, source.getW(), source.getH(), source.getD(),
"stored block", pieceKey);
PlatformBlockState state = entry.getValue();
if (state == null) {
throw new IOException("Planar piece '" + pieceKey + "' contains a null stored block state");
}
LocalPosition canonicalPosition = toCanonical(
sourcePosition,
source.getW(),
source.getD(),
quarterTurns);
if (blocks.putIfAbsent(canonicalPosition, state) != null) {
throw new IOException("Planar piece '" + pieceKey
+ "' maps more than one stored block to " + canonicalPosition.describe());
}
}
return blocks;
}
private static Map<LocalPosition, TileData> canonicalTiles(
IrisObject source,
int quarterTurns,
String pieceKey
) throws IOException {
Map<LocalPosition, TileData> tiles = new LinkedHashMap<>();
for (Map.Entry<IrisBlockVector, TileData> entry : source.getStates()) {
LocalPosition sourcePosition = unsignedPosition(entry.getKey(), source);
requireInside(sourcePosition, source.getW(), source.getH(), source.getD(),
"tile data", pieceKey);
TileData tile = entry.getValue();
if (tile == null) {
throw new IOException("Planar piece '" + pieceKey + "' contains null tile data");
}
LocalPosition canonicalPosition = toCanonical(
sourcePosition,
source.getW(),
source.getD(),
quarterTurns);
if (tiles.putIfAbsent(canonicalPosition, tile.clone()) != null) {
throw new IOException("Planar piece '" + pieceKey
+ "' maps more than one tile payload to " + canonicalPosition.describe());
}
}
return tiles;
}
private static List<ConnectorResize> planConnectorResizes(
List<IrisJigsawConnector> connectors,
JigsawStudioCellDimensions sourceDimensions,
JigsawStudioCellDimensions canonicalDimensions,
JigsawStudioCellDimensions targetDimensions,
int quarterTurns,
String pieceKey
) throws IOException {
List<ConnectorResize> planned = new ArrayList<>(connectors.size());
Set<LocalPosition> sourcePositions = new LinkedHashSet<>();
Set<LocalPosition> targetPositions = new LinkedHashSet<>();
IrisPosition sourceSize = dimensionsPosition(sourceDimensions);
IrisPosition canonicalSize = dimensionsPosition(canonicalDimensions);
IrisPosition targetSize = dimensionsPosition(targetDimensions);
for (int index = 0; index < connectors.size(); index++) {
IrisJigsawConnector connector = connectors.get(index);
if (connector == null || connector.getPosition() == null || connector.getDirection() == null
|| connector.getDirection().isVertical()) {
throw new IOException("Planar piece '" + pieceKey + "' contains invalid connector " + index);
}
LocalPosition sourcePosition = LocalPosition.from(connector.getPosition());
requireInside(
sourcePosition,
sourceDimensions.width(),
sourceDimensions.height(),
sourceDimensions.depth(),
"connector " + index,
pieceKey);
if (!sourcePositions.add(sourcePosition)) {
throw new IOException("Planar piece '" + pieceKey
+ "' has multiple connectors at " + sourcePosition.describe());
}
LocalPosition sourceCanonical = toCanonical(
sourcePosition,
sourceDimensions.width(),
sourceDimensions.depth(),
quarterTurns);
IrisDirection canonicalDirection = rotateHorizontalDirection(
connector.getDirection(),
quarterTurns);
LocalPosition expectedSource = LocalPosition.from(IrisJigsawConnector.canonicalPlanarPosition(
sourceSize,
connector.getDirection()));
LocalPosition expectedCanonical = LocalPosition.from(IrisJigsawConnector.canonicalPlanarPosition(
canonicalSize,
canonicalDirection));
boolean canonicalConnector = sourcePosition.equals(expectedSource)
|| sourceCanonical.equals(expectedCanonical);
LocalPosition targetCanonical = canonicalConnector
? LocalPosition.from(IrisJigsawConnector.canonicalPlanarPosition(
targetSize,
canonicalDirection))
: sourceCanonical;
if (!inside(targetCanonical, targetDimensions)) {
throw new IOException("Planar piece '" + pieceKey + "' connector " + index + " at "
+ sourceCanonical.describe() + " would be cropped by the requested workcell bounds");
}
if (!targetPositions.add(targetCanonical)) {
throw new IOException("Planar piece '" + pieceKey
+ "' would place multiple connectors at " + targetCanonical.describe());
}
planned.add(new ConnectorResize(connector, sourceCanonical, targetCanonical));
}
return List.copyOf(planned);
}
private static void relocateConnectorPayloads(
Map<LocalPosition, PlatformBlockState> blocks,
Map<LocalPosition, TileData> tiles,
List<ConnectorResize> connectorResizes,
String pieceKey
) throws IOException {
Set<LocalPosition> relocatedSources = new LinkedHashSet<>();
for (ConnectorResize connectorResize : connectorResizes) {
if (tiles.containsKey(connectorResize.sourceCanonical())
|| tiles.containsKey(connectorResize.targetCanonical())) {
throw new IOException("Planar piece '" + pieceKey + "' has tile data at connector position "
+ connectorResize.sourceCanonical().describe()
+ "; connector tile data cannot be resized safely");
}
if (!connectorResize.sourceCanonical().equals(connectorResize.targetCanonical())) {
relocatedSources.add(connectorResize.sourceCanonical());
}
}
for (ConnectorResize connectorResize : connectorResizes) {
if (connectorResize.sourceCanonical().equals(connectorResize.targetCanonical())) {
continue;
}
if (blocks.containsKey(connectorResize.targetCanonical())
&& !relocatedSources.contains(connectorResize.targetCanonical())) {
throw new IOException("Planar piece '" + pieceKey + "' cannot relocate connector from "
+ connectorResize.sourceCanonical().describe() + " to "
+ connectorResize.targetCanonical().describe()
+ " because the destination contains a stored block");
}
}
List<BlockRelocation> payloads = new ArrayList<>(relocatedSources.size());
for (ConnectorResize connectorResize : connectorResizes) {
if (connectorResize.sourceCanonical().equals(connectorResize.targetCanonical())) {
continue;
}
boolean present = blocks.containsKey(connectorResize.sourceCanonical());
PlatformBlockState state = blocks.remove(connectorResize.sourceCanonical());
payloads.add(new BlockRelocation(connectorResize.targetCanonical(), state, present));
}
for (BlockRelocation payload : payloads) {
if (!payload.present()) {
continue;
}
if (blocks.putIfAbsent(payload.target(), payload.state()) != null) {
throw new IOException("Planar piece '" + pieceKey
+ "' has colliding connector block payloads at " + payload.target().describe());
}
}
}
private static void requireContentInsideTarget(
Map<LocalPosition, PlatformBlockState> blocks,
Map<LocalPosition, TileData> tiles,
JigsawStudioCellDimensions dimensions,
String pieceKey
) throws IOException {
for (LocalPosition position : blocks.keySet()) {
if (!inside(position, dimensions)) {
throw new IOException("Planar piece '" + pieceKey + "' has a stored block, including explicit air, at "
+ position.describe() + " that would be cropped by the requested workcell bounds");
}
}
for (LocalPosition position : tiles.keySet()) {
if (!inside(position, dimensions)) {
throw new IOException("Planar piece '" + pieceKey + "' has tile data at "
+ position.describe() + " that would be cropped by the requested workcell bounds");
}
}
}
private static IrisObject rebuildSourceObject(
Map<LocalPosition, PlatformBlockState> canonicalBlocks,
Map<LocalPosition, TileData> canonicalTiles,
JigsawStudioCellDimensions sourceDimensions,
int quarterTurns,
String pieceKey
) throws IOException {
IrisObject resized = new IrisObject(
sourceDimensions.width(),
sourceDimensions.height(),
sourceDimensions.depth());
Set<LocalPosition> sourcePositions = new LinkedHashSet<>();
for (Map.Entry<LocalPosition, PlatformBlockState> entry : canonicalBlocks.entrySet()) {
LocalPosition sourcePosition = toSource(entry.getKey(), sourceDimensions, quarterTurns);
if (!sourcePositions.add(sourcePosition)) {
throw new IOException("Planar piece '" + pieceKey
+ "' maps multiple stored blocks to " + sourcePosition.describe());
}
resized.setUnsigned(
sourcePosition.x(),
sourcePosition.y(),
sourcePosition.z(),
entry.getValue());
}
sourcePositions.clear();
for (Map.Entry<LocalPosition, TileData> entry : canonicalTiles.entrySet()) {
LocalPosition sourcePosition = toSource(entry.getKey(), sourceDimensions, quarterTurns);
if (!sourcePositions.add(sourcePosition)) {
throw new IOException("Planar piece '" + pieceKey
+ "' maps multiple tile payloads to " + sourcePosition.describe());
}
resized.setUnsignedTile(
sourcePosition.x(),
sourcePosition.y(),
sourcePosition.z(),
entry.getValue().clone());
}
return resized;
}
private static LocalPosition unsignedPosition(IrisBlockVector signed, IrisObject object) {
return new LocalPosition(
signed.getBlockX() + object.getCenter().getBlockX(),
signed.getBlockY() + object.getCenter().getBlockY(),
signed.getBlockZ() + object.getCenter().getBlockZ());
}
static JigsawStudioCellDimensions canonicalDimensions(
JigsawStudioCellDimensions source,
int quarterTurns
) {
return Math.floorMod(quarterTurns, 2) == 0
? source
: new JigsawStudioCellDimensions(source.depth(), source.height(), source.width());
}
private static JigsawStudioCellDimensions sourceDimensions(
JigsawStudioCellDimensions canonical,
int quarterTurns
) {
return Math.floorMod(quarterTurns, 2) == 0
? canonical
: new JigsawStudioCellDimensions(canonical.depth(), canonical.height(), canonical.width());
}
private static LocalPosition toCanonical(
LocalPosition source,
int sourceWidth,
int sourceDepth,
int quarterTurns
) {
return switch (Math.floorMod(quarterTurns, 4)) {
case 0 -> source;
case 1 -> new LocalPosition(sourceDepth - 1 - source.z(), source.y(), source.x());
case 2 -> new LocalPosition(
sourceWidth - 1 - source.x(),
source.y(),
sourceDepth - 1 - source.z());
case 3 -> new LocalPosition(source.z(), source.y(), sourceWidth - 1 - source.x());
default -> throw new IllegalStateException("Unreachable planar object rotation");
};
}
private static LocalPosition toSource(
LocalPosition canonical,
JigsawStudioCellDimensions sourceDimensions,
int quarterTurns
) {
return switch (Math.floorMod(quarterTurns, 4)) {
case 0 -> canonical;
case 1 -> new LocalPosition(
canonical.z(),
canonical.y(),
sourceDimensions.depth() - 1 - canonical.x());
case 2 -> new LocalPosition(
sourceDimensions.width() - 1 - canonical.x(),
canonical.y(),
sourceDimensions.depth() - 1 - canonical.z());
case 3 -> new LocalPosition(
sourceDimensions.width() - 1 - canonical.z(),
canonical.y(),
canonical.x());
default -> throw new IllegalStateException("Unreachable planar object rotation");
};
}
private static IrisDirection rotateHorizontalDirection(IrisDirection direction, int quarterTurns) {
JigsawPlanarDirection planarDirection = switch (direction) {
case NORTH_NEGATIVE_Z -> JigsawPlanarDirection.NORTH;
case EAST_POSITIVE_X -> JigsawPlanarDirection.EAST;
case SOUTH_POSITIVE_Z -> JigsawPlanarDirection.SOUTH;
case WEST_NEGATIVE_X -> JigsawPlanarDirection.WEST;
case UP_POSITIVE_Y, DOWN_NEGATIVE_Y -> throw new IllegalArgumentException(
"Planar connector direction must be horizontal");
};
return planarDirection.rotateClockwise(quarterTurns).irisDirection();
}
static IrisPosition dimensionsPosition(JigsawStudioCellDimensions dimensions) {
return new IrisPosition(dimensions.width(), dimensions.height(), dimensions.depth());
}
private static boolean inside(LocalPosition position, JigsawStudioCellDimensions dimensions) {
return inside(position, dimensions.width(), dimensions.height(), dimensions.depth());
}
private static boolean inside(LocalPosition position, int width, int height, int depth) {
return position.x() >= 0 && position.x() < width
&& position.y() >= 0 && position.y() < height
&& position.z() >= 0 && position.z() < depth;
}
private static void requireInside(
LocalPosition position,
int width,
int height,
int depth,
String content,
String pieceKey
) throws IOException {
if (!inside(position, width, height, depth)) {
throw new IOException("Planar piece '" + pieceKey + "' has " + content + " at "
+ position.describe() + " outside its object bounds");
}
}
static IrisObject resizeObject(
IrisObject source,
JigsawStudioCellDimensions dimensions,
String pieceKey
) throws IOException {
IrisObject object = Objects.requireNonNull(source, "Jigsaw Studio source object");
JigsawStudioCellDimensions target = Objects.requireNonNull(
dimensions,
"Jigsaw Studio target object dimensions");
String normalizedPiece = pieceKey == null || pieceKey.isBlank() ? "unknown" : pieceKey;
IrisObject resized = new IrisObject(target.width(), target.height(), target.depth());
for (Map.Entry<IrisBlockVector, PlatformBlockState> entry : object.getBlocks()) {
IrisBlockVector position = entry.getKey();
LocalPosition unsigned = unsignedPosition(position, object);
if (!inside(unsigned, target)) {
throw new IOException("Spatial piece '" + normalizedPiece
+ "' has a stored block, including explicit air, at " + unsigned.describe()
+ " that would be cropped by the requested variant size");
}
resized.setUnsigned(
unsigned.x(),
unsigned.y(),
unsigned.z(),
entry.getValue());
}
for (Map.Entry<IrisBlockVector, TileData> entry : object.getStates()) {
IrisBlockVector position = entry.getKey();
LocalPosition unsigned = unsignedPosition(position, object);
if (!inside(unsigned, target)) {
throw new IOException("Spatial piece '" + normalizedPiece + "' has tile data at "
+ unsigned.describe() + " that would be cropped by the requested variant size");
}
resized.setUnsignedTile(
unsigned.x(),
unsigned.y(),
unsigned.z(),
entry.getValue().clone());
}
return resized;
}
static void requireConnectorsInside(
IrisJigsawPiece piece,
JigsawStudioCellDimensions dimensions,
String pieceKey
) throws IOException {
if (piece.getConnectors() == null) {
throw new IOException("Spatial piece '" + pieceKey + "' has no connector list");
}
for (int index = 0; index < piece.getConnectors().size(); index++) {
IrisJigsawConnector connector = piece.getConnectors().get(index);
if (connector == null || connector.getPosition() == null) {
throw new IOException("Spatial piece '" + pieceKey + "' contains invalid connector " + index);
}
LocalPosition position = LocalPosition.from(connector.getPosition());
if (!inside(position, dimensions)) {
throw new IOException("Spatial piece '" + pieceKey + "' connector " + index + " at "
+ position.describe() + " would be cropped by the requested variant size");
}
}
}
static String describeDimensions(JigsawStudioCellDimensions dimensions) {
return dimensions.width() + "x" + dimensions.height() + "x" + dimensions.depth();
}
record PlanarPieceObjectResize(IrisObject object, int relocatedConnectors) {
PlanarPieceObjectResize {
Objects.requireNonNull(object, "Resized planar Jigsaw Studio object");
if (relocatedConnectors < 0) {
throw new IllegalArgumentException("Relocated connector count cannot be negative");
}
}
}
private record ConnectorResize(
IrisJigsawConnector connector,
LocalPosition sourceCanonical,
LocalPosition targetCanonical
) {
private ConnectorResize {
Objects.requireNonNull(connector, "Planar Jigsaw Studio connector");
Objects.requireNonNull(sourceCanonical, "Planar Jigsaw Studio source connector position");
Objects.requireNonNull(targetCanonical, "Planar Jigsaw Studio target connector position");
}
}
private record BlockRelocation(LocalPosition target, PlatformBlockState state, boolean present) {
private BlockRelocation {
Objects.requireNonNull(target, "Planar Jigsaw Studio connector block target");
if (present) {
Objects.requireNonNull(state, "Planar Jigsaw Studio connector block state");
}
}
}
private record LocalPosition(int x, int y, int z) {
private static LocalPosition from(IrisPosition position) {
return new LocalPosition(position.getX(), position.getY(), position.getZ());
}
private IrisPosition toIrisPosition() {
return new IrisPosition(x, y, z);
}
private String describe() {
return x + "," + y + "," + z;
}
}
}
@@ -1,10 +1,10 @@
package art.arcane.iris.core.safeguard;
import art.arcane.iris.core.IrisStartupValidation;
import art.arcane.iris.core.safeguard.task.CheckResult;
import art.arcane.iris.core.safeguard.task.Diagnostic;
import art.arcane.iris.core.safeguard.task.Task;
import art.arcane.iris.core.safeguard.task.Tasks;
import art.arcane.iris.core.safeguard.task.ValueWithDiagnostics;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.util.common.format.C;
@@ -15,7 +15,10 @@ import java.util.List;
import java.util.Map;
public final class IrisSafeguard {
private static Map<Task, ValueWithDiagnostics<Mode>> results = Collections.emptyMap();
private static final String GENERIC_LOCK_REASON =
"An Iris startup check failed. Resolve the startup errors and restart the server.";
private static Map<Task, CheckResult> results = Collections.emptyMap();
private static Map<String, String> context = Collections.emptyMap();
private static Map<String, List<String>> attachment = Collections.emptyMap();
private static Mode mode = Mode.STABLE;
@@ -27,40 +30,40 @@ public final class IrisSafeguard {
public static void execute() {
IrisStartupValidation.beginRuntimeValidation();
List<Task> tasks = Tasks.getTasks();
LinkedHashMap<Task, ValueWithDiagnostics<Mode>> resultValues = new LinkedHashMap<>(tasks.size());
LinkedHashMap<Task, CheckResult> resultValues = new LinkedHashMap<>(tasks.size());
LinkedHashMap<String, String> contextValues = new LinkedHashMap<>(tasks.size());
LinkedHashMap<String, List<String>> attachmentValues = new LinkedHashMap<>(tasks.size());
Mode currentMode = Mode.STABLE;
String lockReason = null;
int issueCount = 0;
for (Task task : tasks) {
ValueWithDiagnostics<Mode> result;
CheckResult result;
try {
result = task.run();
} catch (Throwable e) {
boolean injectionFailure = "injection".equals(task.getId());
if (injectionFailure) {
IrisStartupValidation.markRuntimeInvalid("Iris runtime injection failed. Resolve the startup errors and restart the server.");
}
IrisLogging.reportError(e);
result = new ValueWithDiagnostics<>(
injectionFailure ? Mode.UNSTABLE : Mode.WARNING,
new Diagnostic(Diagnostic.Logger.ERROR, "Error while running task " + task.getId(), e)
);
IrisLogging.reportError("Iris startup check \"" + task.getId() + "\" failed to run.", e);
Diagnostic diagnostic = new Diagnostic(Diagnostic.Logger.ERROR,
"Error while running task " + task.getId(), e);
result = task.failureMode() == Mode.UNSTABLE
? CheckResult.danger(failureLockReason(task), diagnostic)
: CheckResult.warning(diagnostic);
}
currentMode = currentMode.highest(result.getValue());
currentMode = currentMode.highest(result.mode());
if (result.mode() == Mode.UNSTABLE && lockReason == null) {
lockReason = result.lockReason();
}
resultValues.put(task, result);
contextValues.put(task.getId(), result.getValue().getId());
contextValues.put(task.getId(), result.mode().getId());
List<String> lines = new ArrayList<>();
for (Diagnostic diagnostic : result.getDiagnostics()) {
String[] split = diagnostic.toString().split("\\n");
Collections.addAll(lines, split);
for (Diagnostic diagnostic : result.diagnostics()) {
Collections.addAll(lines, diagnostic.toString().split("\\n"));
}
attachmentValues.put(task.getId(), lines);
if (result.getValue() != Mode.STABLE) {
if (result.mode() != Mode.STABLE) {
issueCount++;
}
}
@@ -70,6 +73,13 @@ public final class IrisSafeguard {
attachment = Collections.unmodifiableMap(attachmentValues);
mode = currentMode;
count = issueCount;
if (currentMode == Mode.UNSTABLE) {
IrisStartupValidation.markRuntimeInvalid(lockReason == null ? GENERIC_LOCK_REASON : lockReason);
return;
}
IrisStartupValidation.markRuntimeReady();
}
public static Mode mode() {
@@ -84,6 +94,19 @@ public final class IrisSafeguard {
return attachment;
}
public static String debugReport() {
StringBuilder builder = new StringBuilder();
builder.append("Startup safeguard: ").append(mode.getId())
.append(" (").append(count).append(" issues)");
for (Map.Entry<String, String> entry : context.entrySet()) {
builder.append('\n').append(" ").append(entry.getKey()).append(": ").append(entry.getValue());
for (String line : attachment.getOrDefault(entry.getKey(), List.of())) {
builder.append('\n').append(" ").append(line);
}
}
return builder.toString();
}
public static void printReports() {
switch (mode) {
case STABLE -> IrisLogging.info(C.BLUE + "0 Conflicts found");
@@ -91,7 +114,7 @@ public final class IrisSafeguard {
case UNSTABLE -> IrisLogging.error(C.DARK_RED + "%s Issues found", count);
}
for (ValueWithDiagnostics<Mode> value : results.values()) {
for (CheckResult value : results.values()) {
// Without the stack trace: Diagnostic.Logger splits on newlines, so a trace became one log
// record per frame at the diagnostic's own severity. Traces go through reportError.
value.log(true, false);
@@ -106,6 +129,11 @@ public final class IrisSafeguard {
}
}
private static String failureLockReason(Task task) {
String declared = task.failureLockReason();
return declared == null || declared.isBlank() ? GENERIC_LOCK_REASON : declared;
}
// A log record carries a level, so a blank record renders as an empty [WARN] line and a rule of
// dashes renders as a [SEVERE] one. Spacing belongs to a console, not to the server log.
private static void warning() {
@@ -0,0 +1,22 @@
package art.arcane.iris.core.safeguard;
import java.util.List;
public final class RuntimeLockNotice {
private RuntimeLockNotice() {
}
public static List<String> compose(String denialReason, boolean hasManagedWorldStorage) {
if (denialReason == null || denialReason.isBlank()) {
return List.of();
}
return List.of(
"Iris enabled with a locked runtime: " + denialReason.trim(),
hasManagedWorldStorage
? "Every configured Iris world is generation-locked and refuses to generate terrain"
+ " until this is resolved and the server restarts."
: "This server has no Iris world storage, so nothing is generation-locked;"
+ " world creation and player login stay refused until this is resolved.");
}
}
@@ -0,0 +1,39 @@
package art.arcane.iris.core.safeguard.task;
import art.arcane.iris.core.safeguard.Mode;
import java.util.List;
public record CheckResult(Mode mode, String lockReason, List<Diagnostic> diagnostics) {
public CheckResult {
if (mode == null) {
throw new IllegalArgumentException("A startup check result needs a mode");
}
if (mode == Mode.UNSTABLE && (lockReason == null || lockReason.isBlank())) {
throw new IllegalArgumentException("A Danger startup check result needs a lock reason");
}
diagnostics = List.copyOf(diagnostics);
}
public static CheckResult stable(Diagnostic... diagnostics) {
return new CheckResult(Mode.STABLE, null, List.of(diagnostics));
}
public static CheckResult warning(Diagnostic... diagnostics) {
return new CheckResult(Mode.WARNING, null, List.of(diagnostics));
}
public static CheckResult warning(List<Diagnostic> diagnostics) {
return new CheckResult(Mode.WARNING, null, diagnostics);
}
public static CheckResult danger(String lockReason, Diagnostic... diagnostics) {
return new CheckResult(Mode.UNSTABLE, lockReason, List.of(diagnostics));
}
public void log(boolean withException, boolean withStackTrace) {
for (Diagnostic diagnostic : diagnostics) {
diagnostic.log(withException, withStackTrace);
}
}
}
@@ -1,47 +1,47 @@
package art.arcane.iris.core.safeguard.task;
import art.arcane.iris.core.safeguard.Mode;
import art.arcane.volmlib.util.format.Form;
import java.util.Locale;
import java.util.function.Supplier;
public abstract class Task {
private final String id;
private final String name;
private final Mode failureMode;
private final String failureLockReason;
public Task(String id) {
this(id, Form.capitalizeWords(id.replace(" ", "_").toLowerCase(Locale.ROOT)));
}
public Task(String id, String name) {
protected Task(String id, Mode failureMode, String failureLockReason) {
this.id = id;
this.name = name;
this.failureMode = failureMode;
this.failureLockReason = failureLockReason;
}
public String getId() {
return id;
}
public String getName() {
return name;
public Mode failureMode() {
return failureMode;
}
public abstract ValueWithDiagnostics<Mode> run();
public String failureLockReason() {
return failureLockReason;
}
public static Task of(String id, String name, Supplier<ValueWithDiagnostics<Mode>> action) {
return new Task(id, name) {
public abstract CheckResult run();
public static Task advisory(String id, Supplier<CheckResult> action) {
return new Task(id, Mode.WARNING, null) {
@Override
public ValueWithDiagnostics<Mode> run() {
public CheckResult run() {
return action.get();
}
};
}
public static Task of(String id, Supplier<ValueWithDiagnostics<Mode>> action) {
return new Task(id) {
public static Task critical(String id, String throwLockReason, Supplier<CheckResult> action) {
return new Task(id, Mode.UNSTABLE, throwLockReason) {
@Override
public ValueWithDiagnostics<Mode> run() {
public CheckResult run() {
return action.get();
}
};
@@ -1,54 +1,65 @@
package art.arcane.iris.core.safeguard.task;
import art.arcane.iris.BuildConstants;
import art.arcane.iris.core.IrisStartupValidation;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.IrisWorlds;
import art.arcane.iris.core.runtime.RuntimeInjection;
import art.arcane.iris.core.IrisWorldStorage;
import art.arcane.iris.core.nms.INMS;
import art.arcane.iris.core.nms.v1X.NMSBinding1X;
import art.arcane.iris.core.safeguard.Mode;
import art.arcane.iris.core.splash.IrisSplashComposer;
import art.arcane.iris.spi.CapabilityProbe;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.util.common.misc.getHardware;
import art.arcane.iris.util.project.agent.Agent;
import org.bukkit.Bukkit;
import org.bukkit.Server;
import org.bukkit.plugin.Plugin;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Collectors;
public final class Tasks {
private static final Task MEMORY = Task.of("memory", () -> {
static final String NMS_LOCK_REASON =
"Iris cannot use this server's NMS runtime. Resolve the Server Version or NMS Disabled error above and restart the server.";
static final String INJECTION_LOCK_REASON =
"Iris runtime injection failed. Resolve the startup errors and restart the server.";
static final String DIMENSION_TYPE_LOCK_REASON =
"Iris dimension types were not registered. Restart the server so the registries reload.";
private static final Set<String> INCOMPATIBLE_PLUGINS = Set.of("dynmap", "stratos");
private static final Task MEMORY = Task.advisory("memory", () -> {
long mem = getHardware.getProcessMemory();
if (mem >= 3072L) {
return withDiagnostics(Mode.STABLE);
return CheckResult.stable();
}
if (mem > 2048L) {
return withDiagnostics(Mode.STABLE,
return CheckResult.stable(
Diagnostic.Logger.INFO.create("Memory Recommendation"),
Diagnostic.Logger.INFO.create("- 3GB+ process memory is recommended for Iris."),
Diagnostic.Logger.INFO.create("- Process Memory: " + mem + " MB"));
Diagnostic.Logger.INFO.create("- A 3GB+ JVM maximum heap is recommended for Iris."),
Diagnostic.Logger.INFO.create("- JVM maximum heap: " + mem + " MB"));
}
return withDiagnostics(Mode.WARNING,
return CheckResult.warning(
Diagnostic.Logger.WARN.create("Low Memory"),
Diagnostic.Logger.WARN.create("- Iris is running with 2GB or less process memory."),
Diagnostic.Logger.WARN.create("- 3GB+ process memory is recommended for Iris."),
Diagnostic.Logger.WARN.create("- Process Memory: " + mem + " MB"));
Diagnostic.Logger.WARN.create("- Iris is running with a 2GB or smaller JVM maximum heap."),
Diagnostic.Logger.WARN.create("- A 3GB+ JVM maximum heap is recommended for Iris."),
Diagnostic.Logger.WARN.create("- JVM maximum heap: " + mem + " MB"));
});
private static final Task INCOMPATIBILITIES = Task.of("incompatibilities", () -> {
Set<String> plugins = new HashSet<>(Set.of("dynmap", "Stratos"));
plugins.removeIf(name -> server().getPluginManager().getPlugin(name) == null);
private static final Task INCOMPATIBILITIES = Task.advisory("incompatibilities", () -> {
Set<String> plugins = installedIncompatiblePlugins();
if (plugins.isEmpty()) {
return withDiagnostics(Mode.STABLE);
return CheckResult.stable();
}
List<Diagnostic> diagnostics = new ArrayList<>();
@@ -58,15 +69,15 @@ public final class Tasks {
Diagnostic.Logger.ERROR.create("- The plugin Dynmap is not compatible with the server."),
Diagnostic.Logger.ERROR.create("- If you want to have a map plugin like Dynmap, consider Bluemap."));
}
if (plugins.contains("Stratos")) {
if (plugins.contains("stratos")) {
addAllDiagnostics(diagnostics,
Diagnostic.Logger.ERROR.create("Stratos"),
Diagnostic.Logger.ERROR.create("- Iris is not compatible with other worldgen plugins."));
}
return withDiagnostics(Mode.WARNING, diagnostics);
return CheckResult.warning(diagnostics);
});
private static final Task SOFTWARE = Task.of("software", () -> {
private static final Task SOFTWARE = Task.advisory("software", () -> {
Set<String> supported = Set.of("canvas", "folia", "purpur", "pufferfish", "leaf", "paper", "spigot", "bukkit");
String serverName = server().getName().toLowerCase(Locale.ROOT);
boolean supportedServer = isCanvasServer();
@@ -80,15 +91,15 @@ public final class Tasks {
}
if (supportedServer) {
return withDiagnostics(Mode.STABLE);
return CheckResult.stable();
}
return withDiagnostics(Mode.WARNING,
return CheckResult.warning(
Diagnostic.Logger.WARN.create("Unsupported Server Software"),
Diagnostic.Logger.WARN.create("- Please consider using Canvas, Folia, Leaf, Paper, or Purpur instead."));
});
private static final Task VERSION = Task.of("version", () -> {
private static final Task VERSION = Task.critical("version", NMS_LOCK_REASON, () -> {
String[] parts = BukkitPlatform.plugin().getDescription().getVersion().split("-");
String supportedVersions;
if (parts.length >= 3) {
@@ -103,84 +114,95 @@ public final class Tasks {
if (!INMS.isBound()) {
String cause = INMS.bindFailure() == null ? "Unknown NMS bind failure" : INMS.bindFailure().getMessage();
return withDiagnostics(Mode.UNSTABLE,
return CheckResult.danger(NMS_LOCK_REASON,
Diagnostic.Logger.ERROR.create("Server Version"),
Diagnostic.Logger.ERROR.create("- " + cause),
Diagnostic.Logger.ERROR.create("- Iris only supports " + supportedVersions));
}
if (!(INMS.get() instanceof NMSBinding1X)) {
return withDiagnostics(Mode.STABLE);
return CheckResult.stable();
}
return withDiagnostics(Mode.UNSTABLE,
return CheckResult.danger(NMS_LOCK_REASON,
Diagnostic.Logger.ERROR.create("NMS Disabled"),
Diagnostic.Logger.ERROR.create("- NMS support is disabled (general.disableNMS); Iris world creation is unavailable."));
});
private static final Task INJECTION = Task.of("injection", () -> {
if (!Agent.install()) {
IrisStartupValidation.markRuntimeInvalid("Iris Java agent is unavailable. Add -javaagent:"
+ Agent.AGENT_JAR.getPath() + " to the JVM arguments before -jar and restart the server.");
return withDiagnostics(Mode.UNSTABLE,
private static final Task INJECTION = Task.critical("injection", INJECTION_LOCK_REASON, () -> {
if (!IrisSettings.get().getGeneral().isEagerRuntimeInjection()) {
return CheckResult.stable(
Diagnostic.Logger.INFO.create("Runtime Injection"),
Diagnostic.Logger.INFO.create("- Deferred to the first world load. Set general.eagerRuntimeInjection to verify it during startup."));
}
RuntimeInjection.Outcome outcome = RuntimeInjection.install();
if (outcome.installed()) {
return CheckResult.stable();
}
return switch (outcome.failure()) {
case NO_BINDING -> CheckResult.danger(outcome.lockReason(),
Diagnostic.Logger.ERROR.create("Code Injection"),
Diagnostic.Logger.ERROR.create("- Runtime injection was skipped because Iris has no usable NMS binding."));
case AGENT -> CheckResult.danger(outcome.lockReason(),
Diagnostic.Logger.ERROR.create("Java Agent"),
Diagnostic.Logger.ERROR.create("- Add -javaagent:" + Agent.AGENT_JAR.getPath() + " before -jar in your startup command and restart the server."),
Diagnostic.Logger.ERROR.create("- Dynamic attachment requires -XX:+EnableDynamicAgentLoading and a host that permits JVM attachment."));
}
if (!INMS.get().injectBukkit()) {
IrisStartupValidation.markRuntimeInvalid("Iris runtime injection failed. Resolve the startup errors and restart the server.");
return withDiagnostics(Mode.UNSTABLE,
case INJECTION, NONE -> CheckResult.danger(INJECTION_LOCK_REASON,
Diagnostic.Logger.ERROR.create("Code Injection"),
Diagnostic.Logger.ERROR.create("- Failed to inject code. Please contact support"));
}
IrisStartupValidation.markRuntimeReady();
return withDiagnostics(Mode.STABLE);
};
});
private static final Task DIMENSION_TYPES = Task.of("dimensionTypes", () -> {
Set<String> keys = IrisWorlds.get().getDimensions().map(IrisDimension::getDimensionTypeKey).collect(Collectors.toSet());
if (!INMS.get().missingDimensionTypes(keys.toArray(String[]::new))) {
return withDiagnostics(Mode.STABLE);
private static final Task DIMENSION_TYPES = Task.critical("dimensionTypes", DIMENSION_TYPE_LOCK_REASON, () -> {
if (!INMS.isBound()) {
return CheckResult.danger(NMS_LOCK_REASON,
Diagnostic.Logger.ERROR.create("Dimension Types"),
Diagnostic.Logger.ERROR.create("- Dimension types could not be checked because Iris has no NMS binding."));
}
return withDiagnostics(Mode.UNSTABLE,
Set<String> keys = IrisWorlds.get().getDimensions().map(IrisDimension::getDimensionTypeKey).collect(Collectors.toSet());
if (!INMS.get().missingDimensionTypes(keys.toArray(String[]::new))) {
return CheckResult.stable();
}
return CheckResult.danger(DIMENSION_TYPE_LOCK_REASON,
Diagnostic.Logger.ERROR.create("Dimension Types"),
Diagnostic.Logger.ERROR.create("- Required Iris dimension types were not loaded."),
Diagnostic.Logger.ERROR.create("- If this still happens after a restart please contact support."));
});
private static final Task DISK_SPACE = Task.of("diskSpace", () -> {
private static final Task DISK_SPACE = Task.advisory("diskSpace", () -> {
double freeGiB = IrisWorldStorage.levelRoot().getFreeSpace() / (double) 0x4000_0000;
if (freeGiB > 3.0) {
return withDiagnostics(Mode.STABLE);
return CheckResult.stable();
}
return withDiagnostics(Mode.WARNING,
return CheckResult.warning(
Diagnostic.Logger.WARN.create("Insufficient Disk Space"),
Diagnostic.Logger.WARN.create("- 3GB of free space is required for Iris to function."));
});
private static final Task JAVA = Task.of("java", () -> {
private static final Task JAVA = Task.advisory("java", () -> {
int version = IrisSplashComposer.javaVersion();
if (version < 0) {
return withDiagnostics(Mode.WARNING,
return CheckResult.warning(
Diagnostic.Logger.WARN.create("Java Runtime"),
Diagnostic.Logger.WARN.create("- Java version could not be determined (java.version="
+ System.getProperty("java.version") + ")."));
}
if (version == 25) {
return withDiagnostics(Mode.STABLE);
return CheckResult.stable();
}
if (version > 25) {
return withDiagnostics(Mode.STABLE,
return CheckResult.stable(
Diagnostic.Logger.INFO.create("Java Runtime"),
Diagnostic.Logger.INFO.create("- Running Java " + version + ". Iris is tested primarily on Java 25."));
}
return withDiagnostics(Mode.WARNING,
return CheckResult.warning(
Diagnostic.Logger.WARN.create("Unsupported Java version"),
Diagnostic.Logger.WARN.create("- Java 25+ is recommended. Current runtime: Java " + version));
});
@@ -207,14 +229,32 @@ public final class Tasks {
return Bukkit.getServer();
}
private static Set<String> installedIncompatiblePlugins() {
Set<String> found = new LinkedHashSet<>();
Plugin[] plugins = server().getPluginManager().getPlugins();
if (plugins == null) {
return found;
}
for (Plugin plugin : plugins) {
String name = plugin == null ? null : plugin.getName();
if (name == null) {
continue;
}
String normalized = name.toLowerCase(Locale.ROOT);
if (INCOMPATIBLE_PLUGINS.contains(normalized)) {
found.add(normalized);
}
}
return found;
}
private static boolean isCanvasServer() {
ClassLoader loader = server().getClass().getClassLoader();
try {
Class.forName("io.canvasmc.canvas.region.WorldRegionizer", false, loader);
if (CapabilityProbe.succeeds("io.canvasmc.canvas.region.WorldRegionizer",
() -> Class.forName("io.canvasmc.canvas.region.WorldRegionizer", false, loader))) {
return true;
} catch (Throwable ignored) {
return server().getName().toLowerCase(Locale.ROOT).contains("canvas");
}
return server().getName().toLowerCase(Locale.ROOT).contains("canvas");
}
private static void addAllDiagnostics(List<Diagnostic> diagnostics, Diagnostic... values) {
@@ -222,13 +262,4 @@ public final class Tasks {
diagnostics.add(value);
}
}
private static ValueWithDiagnostics<Mode> withDiagnostics(Mode mode, Diagnostic... diagnostics) {
return new ValueWithDiagnostics<>(mode, diagnostics);
}
private static ValueWithDiagnostics<Mode> withDiagnostics(Mode mode, List<Diagnostic> diagnostics) {
return new ValueWithDiagnostics<>(mode, diagnostics);
}
}
@@ -1,40 +0,0 @@
package art.arcane.iris.core.safeguard.task;
import java.util.List;
public class ValueWithDiagnostics<T> {
private final T value;
private final List<Diagnostic> diagnostics;
public ValueWithDiagnostics(T value, List<Diagnostic> diagnostics) {
this.value = value;
this.diagnostics = List.copyOf(diagnostics);
}
public ValueWithDiagnostics(T value, Diagnostic... diagnostics) {
this.value = value;
this.diagnostics = List.of(diagnostics);
}
public T getValue() {
return value;
}
public List<Diagnostic> getDiagnostics() {
return diagnostics;
}
public void log() {
log(true, false);
}
public void log(boolean withException) {
log(withException, false);
}
public void log(boolean withException, boolean withStackTrace) {
for (Diagnostic diagnostic : diagnostics) {
diagnostic.log(withException, withStackTrace);
}
}
}
@@ -63,7 +63,9 @@ public class GlobalCacheSVC implements IrisService {
activeTrimmer.interrupt();
try {
activeTrimmer.join(TRIMMER_JOIN_MS);
} catch (InterruptedException ignored) {
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
IrisLogging.warn("Interrupted while waiting for the global cache trimmer to stop.");
}
if (activeTrimmer.isAlive()) {
IrisLogging.warn("Global cache trimmer did not stop within " + TRIMMER_JOIN_MS + "ms.");
@@ -0,0 +1,670 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioActivation;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBounds;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioLayout;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioSession;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioVariant;
import art.arcane.iris.core.service.JigsawStudioCapture.ChunkCaptureArea;
import art.arcane.iris.core.service.JigsawStudioCapture.ChunkSnapshot;
import art.arcane.iris.core.service.JigsawStudioCaptureScheduler.CaptureCoordinator;
import art.arcane.iris.core.service.JigsawStudioCaptureScheduler.CaptureTarget;
import art.arcane.iris.core.service.JigsawStudioCaptureScheduler.CaptureWork;
import art.arcane.iris.core.service.JigsawStudioMaterializer.VariantReloadRequest;
import art.arcane.iris.core.service.JigsawStudioSaveLifecycle.BayReadiness;
import art.arcane.iris.core.service.JigsawStudioSaveLifecycle.SaveAttempt;
import art.arcane.iris.core.service.JigsawStudioSaveLifecycle.SaveStart;
import art.arcane.iris.core.service.JigsawStudioService.ActiveStudio;
import art.arcane.iris.core.service.JigsawStudioService.CommandGraphMutationResult;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.util.common.scheduling.J;
import org.bukkit.entity.Player;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import static art.arcane.iris.core.service.JigsawStudioCapture.captureChunkIntersection;
import static art.arcane.iris.core.service.JigsawStudioCapture.chunkIntersections;
import static art.arcane.iris.core.service.JigsawStudioCaptureScheduler.resolveCaptureTarget;
import static art.arcane.iris.core.service.JigsawStudioGraphMutations.loadMappedLayout;
import static art.arcane.iris.core.service.JigsawStudioProtection.authorizeOwner;
import static art.arcane.iris.core.service.JigsawStudioService.AUTOSAVE_RETRY_TICKS;
import static art.arcane.iris.core.service.JigsawStudioService.failureMessage;
import static art.arcane.iris.core.service.JigsawStudioService.message;
import static art.arcane.iris.core.service.JigsawStudioService.writeFailure;
final class JigsawStudioAutosaveScheduler {
static final int AUTOSAVE_DEBOUNCE_TICKS = 40;
private static final List<Integer> AUTOSAVE_PERSISTENT_RETRY_DELAYS =
List.of(40, 80, 160, 320, 600);
private final JigsawStudioService service;
final Map<AutosaveKey, AutosaveTicket> autosaves = new ConcurrentHashMap<>();
JigsawStudioAutosaveScheduler(JigsawStudioService service) {
this.service = Objects.requireNonNull(service, "Jigsaw Studio service");
}
boolean undoAutosave(Player player) {
if (player == null) {
return false;
}
if (!J.isOwnedByCurrentRegion(player)) {
return J.runEntity(player, () -> undoAutosave(player));
}
ActiveStudio studio = service.studios.get(player.getWorld().getUID());
if (studio == null || !authorizeOwner(player, studio)) {
return false;
}
JigsawStudioSession session = studio.generator().getSession();
String reservationFailure = service.graphMutations.beginGraphMutation(studio, session);
if (!reservationFailure.isEmpty()) {
message(player, reservationFailure);
return false;
}
Map<String, VariantReloadRequest> activeReloads = new HashMap<>();
for (JigsawStudioBay workcell : session.layout().bays()) {
session.activeVariant(workcell.stableId()).ifPresent(variant -> activeReloads.put(
variant.pieceKey(),
new VariantReloadRequest(
workcell.stableId(),
studio.generator().renderBay(workcell))));
}
JigsawStudioActivation.Request request = studio.generator().getRequest();
message(player, "Restoring the previous Jigsaw Studio autosave iteration...");
return service.graphMutations.scheduleGraphMutation(
player,
studio,
() -> {
Path packRoot = request.source().getDataFolder().toPath();
JigsawStudioHistoryStore.UndoResult result = new JigsawStudioHistoryStore(
packRoot,
request.structureKey()).undoLatest();
if (!result.available()) {
return new CommandGraphMutationResult(
session.layout(),
"",
"",
"No earlier autosave iteration is available.");
}
if (!result.successful()) {
throw new IOException("Jigsaw Studio undo failed: "
+ writeFailure(result.writeResult()));
}
request.source().invalidateStructureResources();
JigsawStudioLayout restoredLayout = loadMappedLayout(studio);
VariantReloadRequest reload = activeReloads.get(result.pieceKey());
Optional<VariantReloadRequest> activeReload = reload == null
|| restoredLayout.get(reload.workcellId()) == null
? Optional.empty()
: Optional.of(reload);
String warning = result.warning().isEmpty()
? ""
: " History cleanup warning: " + result.warning();
return new CommandGraphMutationResult(
restoredLayout,
"",
"",
Map.of(),
activeReload,
"Restored the previous autosave iteration. "
+ result.remainingIterations() + " earlier iteration(s) remain."
+ warning);
});
}
boolean flushAutosave(Player player, String workcellId) {
if (player == null) {
return false;
}
if (!J.isOwnedByCurrentRegion(player)) {
return J.runEntity(player, () -> flushAutosave(player, workcellId));
}
ActiveStudio studio = service.studios.get(player.getWorld().getUID());
if (studio == null || !authorizeOwner(player, studio)) {
return false;
}
JigsawStudioSession session = studio.generator().getSession();
String targetId = workcellId == null || workcellId.isBlank()
? session.selectedBayId().orElse("")
: workcellId;
JigsawStudioBay bay = session.layout().get(targetId);
if (bay == null) {
message(player, "Select a Jigsaw Studio workcell before saving now.");
return false;
}
UUID requestId = studio.generator().getRequest().requestId();
service.tileWatcher.finalizeJigsawTileWatches(requestId);
if (service.tileWatcher.hasJigsawTileWatch(requestId)) {
message(player, "Wait for the open vanilla jigsaw-block editor to finish its final snapshot.");
return false;
}
SaveAttempt attempt = service.saveLifecycle.startSave(studio, studio.world(), bay, player, true);
if (attempt == SaveAttempt.STARTED) {
return true;
}
if (attempt == SaveAttempt.PERSISTENT_FAILURE) {
return false;
}
AutosaveTicket ticket = autosaves.get(new AutosaveKey(requestId, bay.stableId()));
if (ticket != null) {
expediteAutosave(
ticket,
attempt == SaveAttempt.RETRY
? AUTOSAVE_RETRY_TICKS
: AUTOSAVE_DEBOUNCE_TICKS);
}
return false;
}
void scheduleAutosave(
ActiveStudio studio,
JigsawStudioBay bay,
JigsawStudioSession.DirtyIdentity identity,
int delayTicks
) {
UUID requestId = studio.generator().getRequest().requestId();
AutosaveKey key = new AutosaveKey(requestId, bay.stableId());
AutosaveTicket ticket = new AutosaveTicket(
key,
studio,
identity,
new AtomicBoolean(false),
new AtomicBoolean(false));
autosaves.put(key, ticket);
scheduleAutosave(ticket, delayTicks);
}
void scheduleAutosave(AutosaveTicket ticket, int delayTicks) {
if (autosaves.get(ticket.key()) != ticket
|| !ticket.scheduled().compareAndSet(false, true)) {
return;
}
JigsawStudioBay bay = resolveAutosaveBay(ticket);
if (bay == null) {
ticket.scheduled().set(false);
return;
}
JigsawStudioBounds bounds = bay.bounds();
boolean scheduled = J.runRegion(
ticket.studio().world(),
bounds.originX() >> 4,
bounds.originZ() >> 4,
() -> runAutosave(ticket),
delayTicks);
if (!scheduled) {
ticket.scheduled().set(false);
if (ticket.scheduleFailureLogged().compareAndSet(false, true)) {
IrisLogging.warn("Jigsaw Studio autosave could not schedule workcell %s for request %s; Iris will keep retrying",
ticket.key().workcellId(), ticket.key().requestId());
}
scheduleAutosaveReconciliation(ticket);
}
}
private JigsawStudioBay resolveAutosaveBay(AutosaveTicket ticket) {
if (autosaves.get(ticket.key()) != ticket) {
return null;
}
JigsawStudioBay bay = resolveCurrentAutosaveBay(
ticket.studio().generator().getSession(),
ticket.key().workcellId());
if (bay != null) {
return bay;
}
if (autosaves.remove(ticket.key(), ticket)) {
reportAutosaveFailure(
ticket,
new AutosavePersistentFailure(
"workcell is absent from the current Studio layout; pending ticket retired",
null));
}
return null;
}
static JigsawStudioBay resolveCurrentAutosaveBay(
JigsawStudioSession session,
String workcellId
) {
JigsawStudioSession activeSession = Objects.requireNonNull(
session,
"Jigsaw Studio autosave session");
return activeSession.layout().get(Objects.requireNonNull(
workcellId,
"Jigsaw Studio autosave workcell ID"));
}
boolean retainCurrentAutosaveFailure(
ActiveStudio studio,
String workcellId,
AutosavePersistentFailure failure
) {
AutosaveKey key = new AutosaveKey(
studio.generator().getRequest().requestId(),
workcellId);
AutosaveTicket ticket = autosaves.get(key);
return ticket != null && retainPersistentAutosaveFailure(ticket, failure);
}
AutosaveFailureState autosaveFailureState(
ActiveStudio studio,
JigsawStudioSession.SaveIdentity saveIdentity
) {
AutosaveKey key = new AutosaveKey(
studio.generator().getRequest().requestId(),
saveIdentity.workcellId());
AutosaveTicket ticket = autosaves.get(key);
return ticket != null && matchesSaveIdentity(ticket.identity(), saveIdentity)
? ticket.failureState()
: new AutosaveFailureState();
}
private boolean retainPersistentAutosaveFailure(
AutosaveTicket ticket,
AutosavePersistentFailure failure
) {
if (autosaves.get(ticket.key()) != ticket
|| !service.isCurrentRequest(ticket.studio(), ticket.key().requestId())
|| !ticket.studio().generator().getSession().isDirtyCurrent(ticket.identity())) {
return false;
}
AutosaveFailureDecision decision = reportAutosaveFailure(ticket, failure);
expediteAutosave(ticket, decision.retryTicks());
return true;
}
private static AutosaveFailureDecision reportAutosaveFailure(
AutosaveTicket ticket,
AutosavePersistentFailure failure
) {
JigsawStudioActivation.Request request = ticket.studio().generator().getRequest();
return recordPersistentAutosaveFailure(
ticket.failureState(),
ticket.key().requestId(),
request.structureKey(),
ticket.key().workcellId(),
ticket.identity().variantKey(),
failure.detail(),
failure.cause());
}
private static void reportPersistentSaveFailure(
ActiveStudio studio,
JigsawStudioSession.SaveIdentity saveIdentity,
AutosaveFailureState failureState,
AutosavePersistentFailure failure
) {
JigsawStudioActivation.Request request = studio.generator().getRequest();
recordPersistentAutosaveFailure(
failureState,
request.requestId(),
request.structureKey(),
saveIdentity.workcellId(),
saveIdentity.variantKey(),
failure.detail(),
failure.cause());
}
static AutosaveFailureDecision recordPersistentAutosaveFailure(
AutosaveFailureState state,
UUID requestId,
String structureKey,
String workcellId,
String pieceKey,
String detail,
Throwable cause
) {
AutosaveFailureState activeState = Objects.requireNonNull(
state,
"Jigsaw Studio autosave failure state");
AutosaveFailureDecision decision = activeState.recordPersistentFailure();
if (!decision.logFailure()) {
return decision;
}
String context = autosaveFailureContext(
requestId,
structureKey,
workcellId,
pieceKey,
detail);
if (cause == null) {
IrisLogging.warn("%s", context);
} else {
IrisLogging.reportError(context, cause);
}
return decision;
}
static String autosaveFailureContext(
UUID requestId,
String structureKey,
String workcellId,
String pieceKey,
String detail
) {
return "Jigsaw Studio save failure: request="
+ Objects.requireNonNull(requestId, "Jigsaw Studio autosave request ID")
+ " structure="
+ Objects.requireNonNull(structureKey, "Jigsaw Studio autosave structure key")
+ " workcell="
+ Objects.requireNonNull(workcellId, "Jigsaw Studio autosave workcell ID")
+ " piece="
+ Objects.requireNonNull(pieceKey, "Jigsaw Studio autosave piece key")
+ " failure="
+ Objects.requireNonNull(detail, "Jigsaw Studio autosave failure detail");
}
static int persistentAutosaveRetryTicks(int failureCount) {
int index = Math.max(0, Math.min(
failureCount - 1,
AUTOSAVE_PERSISTENT_RETRY_DELAYS.size() - 1));
return AUTOSAVE_PERSISTENT_RETRY_DELAYS.get(index);
}
private void scheduleAutosaveReconciliation(AutosaveTicket ticket) {
try {
J.s(() -> {
if (autosaves.get(ticket.key()) == ticket
&& !ticket.scheduled().get()) {
scheduleAutosave(ticket, AUTOSAVE_RETRY_TICKS);
}
}, AUTOSAVE_RETRY_TICKS);
} catch (Throwable exception) {
IrisLogging.reportError(exception);
}
}
private void runAutosave(AutosaveTicket ticket) {
if (autosaves.get(ticket.key()) != ticket) {
return;
}
ticket.scheduled().set(false);
ActiveStudio studio = ticket.studio();
JigsawStudioSession session = studio.generator().getSession();
if (!service.isCurrentRequest(studio, ticket.key().requestId())) {
autosaves.remove(ticket.key(), ticket);
return;
}
JigsawStudioBay bay = resolveAutosaveBay(ticket);
if (bay == null) {
return;
}
if (!session.isDirtyCurrent(ticket.identity())) {
autosaves.remove(ticket.key(), ticket);
return;
}
SaveAttempt attempt = service.saveLifecycle.startSave(studio, studio.world(), bay, null, false);
if (attempt == SaveAttempt.STARTED || attempt == SaveAttempt.PERSISTENT_FAILURE) {
return;
}
scheduleAutosave(
ticket,
attempt == SaveAttempt.RETRY
? AUTOSAVE_RETRY_TICKS
: AUTOSAVE_DEBOUNCE_TICKS);
}
void completeAutosaveAttempt(
ActiveStudio studio,
JigsawStudioSession.SaveIdentity saveIdentity,
AutosaveFailureState failureState,
AutosavePersistentFailure persistentFailure
) {
AutosaveKey key = new AutosaveKey(
studio.generator().getRequest().requestId(),
saveIdentity.workcellId());
AutosaveTicket ticket = autosaves.get(key);
if (ticket == null || !matchesSaveIdentity(ticket.identity(), saveIdentity)) {
if (persistentFailure != null) {
reportPersistentSaveFailure(studio, saveIdentity, failureState, persistentFailure);
}
return;
}
if (resolveAutosaveBay(ticket) == null) {
return;
}
if (!studio.generator().getSession().isDirtyCurrent(ticket.identity())) {
autosaves.remove(key, ticket);
return;
}
if (persistentFailure != null) {
retainPersistentAutosaveFailure(ticket, persistentFailure);
return;
}
scheduleAutosave(ticket, AUTOSAVE_RETRY_TICKS);
}
private static boolean matchesSaveIdentity(
JigsawStudioSession.DirtyIdentity dirtyIdentity,
JigsawStudioSession.SaveIdentity saveIdentity
) {
return dirtyIdentity.sessionId().equals(saveIdentity.sessionId())
&& dirtyIdentity.workcellId().equals(saveIdentity.workcellId())
&& dirtyIdentity.variantKey().equals(saveIdentity.variantKey())
&& dirtyIdentity.loadGeneration() == saveIdentity.loadGeneration()
&& dirtyIdentity.mutationGeneration() == saveIdentity.mutationGeneration();
}
void drainAutosavesBeforeDisable() {
for (ActiveStudio studio : List.copyOf(service.studios.values())) {
try {
drainAutosavesBeforeRemoval(studio);
} catch (Throwable exception) {
IrisLogging.reportError(
"Failed to drain Jigsaw Studio autosaves in world "
+ studio.worldId() + " during shutdown.",
exception);
}
}
if (!autosaves.isEmpty()) {
IrisLogging.warn("Jigsaw Studio disabled with %d autosave operation(s) still pending after the final drain attempt",
autosaves.size());
}
}
void drainAutosavesBeforeRemoval(ActiveStudio studio) {
if (studio == null) {
return;
}
UUID requestId = studio.generator().getRequest().requestId();
if (service.saveLifecycle.discardingRequest(requestId)) {
return;
}
if (J.isFolia() || !J.isPrimaryThread()) {
expediteAutosaves(requestId);
return;
}
for (AutosaveTicket ticket : List.copyOf(autosaves.values())) {
if (requestId.equals(ticket.key().requestId())) {
drainAutosaveSynchronously(ticket);
}
}
}
private boolean drainAutosaveSynchronously(AutosaveTicket ticket) {
if (autosaves.get(ticket.key()) != ticket) {
return true;
}
ActiveStudio studio = ticket.studio();
JigsawStudioSession session = studio.generator().getSession();
if (!service.isCurrentRequest(studio, ticket.key().requestId())) {
autosaves.remove(ticket.key(), ticket);
return true;
}
JigsawStudioBay bay = resolveAutosaveBay(ticket);
if (bay == null) {
return false;
}
if (!session.isDirtyCurrent(ticket.identity())) {
autosaves.remove(ticket.key(), ticket);
return true;
}
JigsawStudioVariant activeVariant = session.activeVariant(bay.stableId()).orElse(null);
if (activeVariant == null || !activeVariant.owned()) {
return false;
}
BayReadiness readiness = studio.population(bay).readiness();
if (!readiness.ready()) {
return false;
}
CaptureTarget captureTarget;
try {
captureTarget = resolveCaptureTarget(studio, bay, activeVariant);
} catch (IOException exception) {
retainPersistentAutosaveFailure(
ticket,
new AutosavePersistentFailure(
"Jigsaw Studio cannot capture this workcell: " + failureMessage(exception),
exception));
return false;
}
JigsawStudioSession.SaveStart sessionSave = session.beginSave(bay.stableId());
if (sessionSave.status() != JigsawStudioSession.SaveStatus.STARTED) {
return false;
}
JigsawStudioSession.SaveIdentity saveIdentity = sessionSave.identity().orElseThrow();
if (service.saveLifecycle.tryBeginSave(ticket.key().requestId()) != SaveStart.STARTED) {
session.abortSave(saveIdentity);
return false;
}
List<ChunkCaptureArea> areas = chunkIntersections(captureTarget.bounds());
CaptureWork work = new CaptureWork(
studio,
studio.world(),
bay,
captureTarget,
null,
ticket.key().requestId(),
saveIdentity,
ticket.failureState());
CaptureCoordinator coordinator =
service.captureScheduler.coordinator(work, areas);
List<ChunkSnapshot> snapshots = new ArrayList<>(areas.size());
try {
for (ChunkCaptureArea area : areas) {
if (!studio.world().isChunkLoaded(area.chunkX(), area.chunkZ())) {
throw new IOException("Workcell chunk " + area.chunkX() + "," + area.chunkZ()
+ " is not loaded during the final autosave drain");
}
snapshots.add(captureChunkIntersection(
studio.world(),
captureTarget.bounds(),
captureTarget.piece(),
captureTarget.object(),
area,
captureTarget.displayRotationQuarterTurns(),
captureTarget.connectorsVisible()));
}
service.captureScheduler.assembleAndPersist(coordinator, snapshots);
} catch (Throwable exception) {
coordinator.fail("Jigsaw Studio final autosave drain failed: "
+ failureMessage(exception), exception);
}
return !session.isDirtyCurrent(ticket.identity());
}
void expediteAutosaves(UUID requestId) {
if (requestId == null) {
return;
}
for (AutosaveTicket current : List.copyOf(autosaves.values())) {
if (!requestId.equals(current.key().requestId())) {
continue;
}
expediteAutosave(current, 0);
}
}
private void expediteAutosave(AutosaveTicket current, int delayTicks) {
AutosaveTicket expedited = new AutosaveTicket(
current.key(),
current.studio(),
current.identity(),
new AtomicBoolean(false),
current.scheduleFailureLogged(),
current.failureState());
if (autosaves.replace(current.key(), current, expedited)) {
scheduleAutosave(expedited, delayTicks);
}
}
void clearAutosaves(UUID requestId) {
if (requestId != null) {
autosaves.keySet().removeIf(key -> key.requestId().equals(requestId));
}
}
record AutosaveKey(UUID requestId, String workcellId) {
AutosaveKey {
Objects.requireNonNull(requestId, "Jigsaw Studio autosave request ID");
Objects.requireNonNull(workcellId, "Jigsaw Studio autosave workcell ID");
}
}
static final class AutosaveFailureState {
private final AtomicInteger persistentFailures = new AtomicInteger();
private final AtomicBoolean failureLogged = new AtomicBoolean(false);
AutosaveFailureDecision recordPersistentFailure() {
int failureCount = persistentFailures.incrementAndGet();
return new AutosaveFailureDecision(
persistentAutosaveRetryTicks(failureCount),
failureLogged.compareAndSet(false, true));
}
}
record AutosaveFailureDecision(int retryTicks, boolean logFailure) {
}
record AutosavePersistentFailure(String detail, Throwable cause) {
AutosavePersistentFailure {
Objects.requireNonNull(detail, "Jigsaw Studio persistent autosave failure detail");
}
}
private record AutosaveTicket(
AutosaveKey key,
ActiveStudio studio,
JigsawStudioSession.DirtyIdentity identity,
AtomicBoolean scheduled,
AtomicBoolean scheduleFailureLogged,
AutosaveFailureState failureState
) {
private AutosaveTicket(
AutosaveKey key,
ActiveStudio studio,
JigsawStudioSession.DirtyIdentity identity,
AtomicBoolean scheduled,
AtomicBoolean scheduleFailureLogged
) {
this(
key,
studio,
identity,
scheduled,
scheduleFailureLogged,
new AutosaveFailureState());
}
private AutosaveTicket {
Objects.requireNonNull(key, "Jigsaw Studio autosave key");
Objects.requireNonNull(studio, "Jigsaw Studio autosave studio");
Objects.requireNonNull(identity, "Jigsaw Studio autosave dirty identity");
Objects.requireNonNull(scheduled, "Jigsaw Studio autosave schedule state");
Objects.requireNonNull(scheduleFailureLogged, "Jigsaw Studio autosave schedule warning state");
Objects.requireNonNull(failureState, "Jigsaw Studio autosave failure state");
}
}
}
@@ -0,0 +1,697 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.runtime.jigsaw.JigsawPlanarArchetype;
import art.arcane.iris.core.runtime.jigsaw.JigsawPlanarDirection;
import art.arcane.iris.core.runtime.jigsaw.JigsawPlanarTopology;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBounds;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions;
import art.arcane.iris.engine.object.IrisDirection;
import art.arcane.iris.engine.object.IrisJigsawConnector;
import art.arcane.iris.engine.object.IrisJigsawPiece;
import art.arcane.iris.engine.object.IrisObject;
import art.arcane.iris.engine.object.IrisObjectRotation;
import art.arcane.iris.engine.object.IrisPosition;
import art.arcane.iris.engine.object.JigsawJoint;
import art.arcane.iris.engine.object.TileData;
import art.arcane.iris.platform.bukkit.BukkitBlockState;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.iris.util.common.data.B;
import art.arcane.volmlib.util.collection.KMap;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import org.bukkit.block.data.type.Jigsaw;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import static art.arcane.iris.core.service.JigsawStudioChunkWriter.restoreCapturedMetadataForDisplay;
import static art.arcane.iris.core.service.JigsawStudioService.chunkKey;
import static art.arcane.iris.core.service.JigsawStudioService.failureMessage;
final class JigsawStudioCapture {
private static final Comparator<IrisJigsawConnector> NEW_CONNECTOR_SOURCE_POSITION_ORDER = Comparator
.comparingInt((IrisJigsawConnector connector) -> connector.getPosition().getX())
.thenComparingInt(connector -> connector.getPosition().getY())
.thenComparingInt(connector -> connector.getPosition().getZ());
private JigsawStudioCapture() {
}
static List<ChunkCaptureArea> chunkIntersections(JigsawStudioBounds bounds) {
JigsawStudioBounds captureBounds = Objects.requireNonNull(bounds, "Jigsaw Studio capture bounds");
List<ChunkCaptureArea> areas = new ArrayList<>();
int minimumChunkX = captureBounds.originX() >> 4;
int maximumChunkX = captureBounds.maxX() >> 4;
int minimumChunkZ = captureBounds.originZ() >> 4;
int maximumChunkZ = captureBounds.maxZ() >> 4;
for (int chunkX = minimumChunkX; chunkX <= maximumChunkX; chunkX++) {
int chunkOriginX = chunkX << 4;
int minimumX = Math.max(0, chunkOriginX - captureBounds.originX());
int maximumX = Math.min(
captureBounds.dimensions().width(),
chunkOriginX + 16 - captureBounds.originX());
for (int chunkZ = minimumChunkZ; chunkZ <= maximumChunkZ; chunkZ++) {
int chunkOriginZ = chunkZ << 4;
int minimumZ = Math.max(0, chunkOriginZ - captureBounds.originZ());
int maximumZ = Math.min(
captureBounds.dimensions().depth(),
chunkOriginZ + 16 - captureBounds.originZ());
areas.add(new ChunkCaptureArea(
chunkX,
chunkZ,
minimumX,
maximumX,
minimumZ,
maximumZ));
}
}
return List.copyOf(areas);
}
static ChunkSnapshot captureChunkIntersection(
World world,
JigsawStudioBounds bounds,
IrisJigsawPiece sourcePiece,
IrisObject sourceObject,
ChunkCaptureArea area
) throws IOException {
return captureChunkIntersection(world, bounds, sourcePiece, sourceObject, area, 0);
}
static ChunkSnapshot captureChunkIntersection(
World world,
JigsawStudioBounds bounds,
IrisJigsawPiece sourcePiece,
IrisObject sourceObject,
ChunkCaptureArea area,
int displayRotationQuarterTurns
) throws IOException {
return captureChunkIntersection(
world,
bounds,
sourcePiece,
sourceObject,
area,
displayRotationQuarterTurns,
true);
}
static ChunkSnapshot captureChunkIntersection(
World world,
JigsawStudioBounds bounds,
IrisJigsawPiece sourcePiece,
IrisObject sourceObject,
ChunkCaptureArea area,
int displayRotationQuarterTurns,
boolean connectorsVisible
) throws IOException {
World captureWorld = Objects.requireNonNull(world, "Jigsaw Studio capture world");
JigsawStudioBounds captureBounds = Objects.requireNonNull(bounds, "Jigsaw Studio capture bounds");
IrisObject captureSource = Objects.requireNonNull(sourceObject, "Jigsaw Studio source object");
ChunkCaptureArea captureArea = Objects.requireNonNull(area, "Jigsaw Studio chunk capture area");
int quarterTurns = Math.floorMod(displayRotationQuarterTurns, 4);
int sourceWidth = (quarterTurns & 1) == 0
? captureBounds.dimensions().width()
: captureBounds.dimensions().depth();
int sourceDepth = (quarterTurns & 1) == 0
? captureBounds.dimensions().depth()
: captureBounds.dimensions().width();
List<CapturedBlock> blocks = new ArrayList<>();
List<CapturedConnector> connectors = new ArrayList<>();
Map<LocalPosition, IrisJigsawConnector> hiddenConnectors = connectorsVisible
? Map.of()
: displayedSourceConnectors(sourcePiece, captureBounds.dimensions(), quarterTurns);
for (int x = captureArea.minimumX(); x < captureArea.maximumX(); x++) {
for (int y = 0; y < bounds.dimensions().height(); y++) {
for (int z = captureArea.minimumZ(); z < captureArea.maximumZ(); z++) {
Block block = captureWorld.getBlockAt(
captureBounds.originX() + x,
captureBounds.originY() + y,
captureBounds.originZ() + z);
BlockData blockData = block.getBlockData();
IrisJigsawConnector hiddenConnector = hiddenConnectors.get(new LocalPosition(x, y, z));
if (hiddenConnector != null) {
hiddenConnector.setFinalState(blockData.getAsString());
connectors.add(CapturedConnector.from(hiddenConnector));
}
if (hiddenConnector == null && blockData instanceof Jigsaw jigsaw) {
KMap<String, Object> nbt = BukkitPlatform.serializeTile(block.getLocation());
if (nbt == null) {
throw new IOException("Cannot read jigsaw marker NBT at "
+ block.getX() + "," + block.getY() + "," + block.getZ()
+ ". The active NMS binding must support tile serialization.");
}
IrisJigsawConnector connector;
try {
connector = JigsawStudioMarkerParser.parse(nbt, jigsaw.getOrientation(), x, y, z);
} catch (IllegalArgumentException exception) {
throw new IOException("Invalid jigsaw marker at "
+ block.getX() + "," + block.getY() + "," + block.getZ()
+ ": " + failureMessage(exception), exception);
}
restoreCapturedMetadataForDisplay(
connector,
sourcePiece,
captureBounds.dimensions(),
quarterTurns);
BlockData finalData;
try {
finalData = Bukkit.createBlockData(connector.getFinalState());
} catch (IllegalArgumentException exception) {
throw new IOException("Invalid final_state '" + connector.getFinalState()
+ "' at " + block.getX() + "," + block.getY() + "," + block.getZ(), exception);
}
connector.setFinalState(finalData.getAsString());
connectors.add(CapturedConnector.from(connector));
if (finalData.getMaterial() != Material.STRUCTURE_VOID) {
blocks.add(new CapturedBlock(
x,
y,
z,
BukkitBlockState.of(finalData),
null));
}
continue;
}
if (isAir(blockData.getMaterial())) {
LocalPosition sourcePosition = inversePosition(
x,
y,
z,
sourceWidth,
sourceDepth,
quarterTurns);
PlatformBlockState retainedAir = retainedSourceAir(
captureSource,
sourcePosition.x(),
sourcePosition.y(),
sourcePosition.z(),
blockData);
if (retainedAir != null) {
blocks.add(new CapturedBlock(x, y, z, retainedAir, null));
}
continue;
}
if (blockData.getMaterial() == Material.STRUCTURE_VOID) {
continue;
}
blocks.add(new CapturedBlock(
x,
y,
z,
BukkitBlockState.of(blockData),
TileData.getTileState(block, false)));
}
}
}
return new ChunkSnapshot(captureArea, blocks, connectors);
}
private static Map<LocalPosition, IrisJigsawConnector> displayedSourceConnectors(
IrisJigsawPiece sourcePiece,
JigsawStudioCellDimensions displayDimensions,
int displayRotationQuarterTurns
) throws IOException {
IrisJigsawPiece source = Objects.requireNonNull(sourcePiece, "Jigsaw Studio source piece");
if (source.getConnectors() == null) {
throw new IOException("Jigsaw Studio source piece has no connector list");
}
int quarterTurns = Math.floorMod(displayRotationQuarterTurns, 4);
int sourceWidth = (quarterTurns & 1) == 0
? displayDimensions.width()
: displayDimensions.depth();
int sourceDepth = (quarterTurns & 1) == 0
? displayDimensions.depth()
: displayDimensions.width();
IrisObjectRotation rotation = IrisObjectRotation.of(0, -90.0D * quarterTurns, 0);
Map<LocalPosition, IrisJigsawConnector> displayed = new HashMap<>(source.getConnectors().size());
for (IrisJigsawConnector sourceConnector : source.getConnectors()) {
LocalPosition sourcePosition = connectorPosition(sourceConnector, "source piece");
LocalPosition position = forwardPosition(
sourcePosition.x(),
sourcePosition.y(),
sourcePosition.z(),
sourceWidth,
sourceDepth,
quarterTurns);
IrisJigsawConnector connector = CapturedConnector.from(sourceConnector).toConnector()
.setPosition(new IrisPosition(position.x(), position.y(), position.z()))
.setDirection(rotation.rotate(sourceConnector.getDirection()))
.setTop(rotation.rotate(sourceConnector.getTop()));
if (displayed.put(position, connector) != null) {
throw new IOException("Jigsaw Studio source piece has duplicate displayed connector position "
+ connectorLocation(position));
}
}
return Map.copyOf(displayed);
}
static PlatformBlockState retainedSourceAir(
IrisObject sourceObject,
int x,
int y,
int z,
BlockData current
) {
IrisObject source = Objects.requireNonNull(sourceObject, "Jigsaw Studio source object");
BlockData currentState = Objects.requireNonNull(current, "Jigsaw Studio current block state");
if (!isAir(currentState.getMaterial())
|| x < 0
|| y < 0
|| z < 0
|| x >= source.getW()
|| y >= source.getH()
|| z >= source.getD()) {
return null;
}
PlatformBlockState original = source.getBlocks().get(source.getSigned(x, y, z));
return original != null && original.isAir() ? original : null;
}
private static boolean isAir(Material material) {
return material == Material.AIR || material == Material.CAVE_AIR || material == Material.VOID_AIR;
}
static Capture aggregateSnapshots(
JigsawStudioBounds bounds,
List<ChunkCaptureArea> expectedAreas,
List<ChunkSnapshot> snapshots
) throws IOException {
return aggregateSnapshots(bounds, expectedAreas, snapshots, 0);
}
static Capture aggregateSnapshots(
JigsawStudioBounds bounds,
List<ChunkCaptureArea> expectedAreas,
List<ChunkSnapshot> snapshots,
int displayRotationQuarterTurns
) throws IOException {
JigsawStudioBounds captureBounds = Objects.requireNonNull(bounds, "Jigsaw Studio capture bounds");
int quarterTurns = Math.floorMod(displayRotationQuarterTurns, 4);
List<ChunkCaptureArea> requiredAreas = List.copyOf(expectedAreas);
List<ChunkSnapshot> capturedSnapshots = List.copyOf(snapshots);
Map<Long, ChunkCaptureArea> requiredByChunk = new HashMap<>();
for (ChunkCaptureArea area : requiredAreas) {
if (requiredByChunk.put(chunkKey(area.chunkX(), area.chunkZ()), area) != null) {
throw new IOException("Duplicate required bay chunk " + area.chunkX() + "," + area.chunkZ());
}
}
if (capturedSnapshots.size() != requiredByChunk.size()) {
throw new IOException("Expected " + requiredByChunk.size() + " bay chunk snapshot(s), received "
+ capturedSnapshots.size());
}
Map<Long, ChunkSnapshot> capturedByChunk = new HashMap<>();
for (ChunkSnapshot snapshot : capturedSnapshots) {
ChunkCaptureArea area = snapshot.area();
long key = chunkKey(area.chunkX(), area.chunkZ());
ChunkCaptureArea required = requiredByChunk.get(key);
if (!area.equals(required)) {
throw new IOException("Unexpected bay chunk intersection for "
+ area.chunkX() + "," + area.chunkZ());
}
if (capturedByChunk.put(key, snapshot) != null) {
throw new IOException("Duplicate bay chunk snapshot " + area.chunkX() + "," + area.chunkZ());
}
}
if (!capturedByChunk.keySet().containsAll(requiredByChunk.keySet())) {
throw new IOException("One or more required bay chunk snapshots are missing");
}
List<ChunkSnapshot> ordered = new ArrayList<>(capturedSnapshots);
ordered.sort(Comparator
.comparingInt((ChunkSnapshot snapshot) -> snapshot.area().chunkX())
.thenComparingInt(snapshot -> snapshot.area().chunkZ()));
int sourceWidth = (quarterTurns & 1) == 0
? captureBounds.dimensions().width()
: captureBounds.dimensions().depth();
int sourceDepth = (quarterTurns & 1) == 0
? captureBounds.dimensions().depth()
: captureBounds.dimensions().width();
IrisObject object = new IrisObject(
sourceWidth,
captureBounds.dimensions().height(),
sourceDepth);
IrisObjectRotation inverseRotation = IrisObjectRotation.of(0, 90.0D * quarterTurns, 0);
List<IrisJigsawConnector> connectors = new ArrayList<>();
Set<LocalPosition> capturedBlocks = new HashSet<>();
Set<LocalPosition> capturedConnectors = new HashSet<>();
boolean hasBlockEntities = false;
for (ChunkSnapshot snapshot : ordered) {
ChunkCaptureArea area = snapshot.area();
for (CapturedBlock block : snapshot.blocks()) {
if (!area.contains(block.x(), block.z())
|| block.y() < 0
|| block.y() >= captureBounds.dimensions().height()) {
throw new IOException("Captured block falls outside bay chunk "
+ area.chunkX() + "," + area.chunkZ());
}
LocalPosition position = inversePosition(
block.x(), block.y(), block.z(), sourceWidth, sourceDepth, quarterTurns);
if (!capturedBlocks.add(position)) {
throw new IOException("Duplicate captured block at "
+ block.x() + "," + block.y() + "," + block.z());
}
PlatformBlockState sourceState = quarterTurns == 0
? block.state()
: inverseRotation.rotate(block.state(), 0, 0, 0);
if (sourceState == null) {
throw new IOException("Captured block state cannot be inverse-rotated at "
+ block.x() + "," + block.y() + "," + block.z());
}
object.setUnsigned(position.x(), position.y(), position.z(), sourceState);
TileData tileData = block.tileData();
if (tileData != null) {
object.setUnsignedTile(position.x(), position.y(), position.z(), tileData.clone());
hasBlockEntities = true;
}
}
for (CapturedConnector capturedConnector : snapshot.connectors()) {
if (!area.contains(capturedConnector.x(), capturedConnector.z())
|| capturedConnector.y() < 0
|| capturedConnector.y() >= captureBounds.dimensions().height()) {
throw new IOException("Captured connector falls outside bay chunk "
+ area.chunkX() + "," + area.chunkZ());
}
LocalPosition position = inversePosition(
capturedConnector.x(),
capturedConnector.y(),
capturedConnector.z(),
sourceWidth,
sourceDepth,
quarterTurns);
if (!capturedConnectors.add(position)) {
throw new IOException("Duplicate captured connector at "
+ capturedConnector.x() + "," + capturedConnector.y() + ","
+ capturedConnector.z());
}
IrisJigsawConnector connector = capturedConnector.toConnector();
if (quarterTurns != 0) {
connector.setPosition(new IrisPosition(position.x(), position.y(), position.z()));
connector.setDirection(inverseRotation.rotate(connector.getDirection()));
connector.setTop(inverseRotation.rotate(connector.getTop()));
PlatformBlockState finalState = B.getStateOrNull(connector.getFinalState(), false);
if (finalState == null) {
throw new IOException("Captured connector final state cannot be parsed at "
+ capturedConnector.x() + "," + capturedConnector.y() + ","
+ capturedConnector.z());
}
PlatformBlockState sourceFinalState = inverseRotation.rotate(finalState, 0, 0, 0);
if (sourceFinalState == null) {
throw new IOException("Captured connector final state cannot be inverse-rotated at "
+ capturedConnector.x() + "," + capturedConnector.y() + ","
+ capturedConnector.z());
}
connector.setFinalState(sourceFinalState.key());
}
connectors.add(connector);
}
}
try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
object.write(output);
return new Capture(output.toByteArray(), connectors, hasBlockEntities);
}
}
static List<IrisJigsawConnector> preserveCapturedConnectorOrder(
IrisJigsawPiece sourcePiece,
List<IrisJigsawConnector> capturedConnectors
) throws IOException {
IrisJigsawPiece source = Objects.requireNonNull(sourcePiece, "Jigsaw Studio source piece");
List<IrisJigsawConnector> sourceConnectors = source.getConnectors();
if (sourceConnectors == null) {
throw new IOException("Jigsaw Studio source piece has no connector list");
}
List<IrisJigsawConnector> captured = new ArrayList<>(Objects.requireNonNull(
capturedConnectors,
"Jigsaw Studio captured connectors"));
Map<LocalPosition, IrisJigsawConnector> capturedByPosition = new HashMap<>(captured.size());
for (IrisJigsawConnector connector : captured) {
LocalPosition position = connectorPosition(connector, "captured");
if (capturedByPosition.put(position, connector) != null) {
throw new IOException("Jigsaw Studio capture has duplicate connector position "
+ connectorLocation(position));
}
}
List<IrisJigsawConnector> ordered = new ArrayList<>(captured.size());
Set<LocalPosition> sourcePositions = new HashSet<>(sourceConnectors.size());
for (IrisJigsawConnector connector : sourceConnectors) {
LocalPosition position = connectorPosition(connector, "source piece");
if (!sourcePositions.add(position)) {
throw new IOException("Jigsaw Studio source piece has duplicate connector position "
+ connectorLocation(position));
}
IrisJigsawConnector matched = capturedByPosition.remove(position);
if (matched != null) {
ordered.add(matched);
}
}
List<IrisJigsawConnector> appended = new ArrayList<>(capturedByPosition.values());
appended.sort(NEW_CONNECTOR_SOURCE_POSITION_ORDER);
ordered.addAll(appended);
return List.copyOf(ordered);
}
private static LocalPosition connectorPosition(IrisJigsawConnector connector, String owner) throws IOException {
if (connector == null || connector.getPosition() == null) {
throw new IOException("Jigsaw Studio " + owner + " contains a connector without a position");
}
IrisPosition position = connector.getPosition();
return new LocalPosition(position.getX(), position.getY(), position.getZ());
}
private static String connectorLocation(LocalPosition position) {
return position.x() + "," + position.y() + "," + position.z();
}
static LocalPosition inversePosition(
int x,
int y,
int z,
int sourceWidth,
int sourceDepth,
int displayRotationQuarterTurns
) {
return switch (Math.floorMod(displayRotationQuarterTurns, 4)) {
case 0 -> new LocalPosition(x, y, z);
case 1 -> new LocalPosition(z, y, sourceDepth - 1 - x);
case 2 -> new LocalPosition(sourceWidth - 1 - x, y, sourceDepth - 1 - z);
case 3 -> new LocalPosition(sourceWidth - 1 - z, y, x);
default -> throw new IllegalStateException("Unreachable Jigsaw Studio inverse rotation");
};
}
private static LocalPosition forwardPosition(
int x,
int y,
int z,
int sourceWidth,
int sourceDepth,
int displayRotationQuarterTurns
) {
return switch (Math.floorMod(displayRotationQuarterTurns, 4)) {
case 0 -> new LocalPosition(x, y, z);
case 1 -> new LocalPosition(sourceDepth - 1 - z, y, x);
case 2 -> new LocalPosition(sourceWidth - 1 - x, y, sourceDepth - 1 - z);
case 3 -> new LocalPosition(z, y, sourceWidth - 1 - x);
default -> throw new IllegalStateException("Unreachable Jigsaw Studio display rotation");
};
}
static void requireWorkcellTopology(
JigsawStudioBay workcell,
List<IrisJigsawConnector> connectors,
int displayRotationQuarterTurns
) throws IOException {
JigsawPlanarArchetype expected = workcell.archetype().orElse(null);
if (expected == null) {
return;
}
int mask = 0;
for (IrisJigsawConnector connector : connectors) {
mask |= switch (connector.getDirection()) {
case NORTH_NEGATIVE_Z -> JigsawPlanarDirection.NORTH.bit();
case EAST_POSITIVE_X -> JigsawPlanarDirection.EAST.bit();
case SOUTH_POSITIVE_Z -> JigsawPlanarDirection.SOUTH.bit();
case WEST_NEGATIVE_X -> JigsawPlanarDirection.WEST.bit();
case UP_POSITIVE_Y, DOWN_NEGATIVE_Y -> throw new WorkcellTopologyException(
"Planar workcell '" + workcell.stableId()
+ "' cannot save a vertical connector. Remove it or use Reset Connector Blocks.");
};
}
JigsawPlanarTopology sourceTopology = JigsawPlanarTopology.fromMask(mask);
JigsawPlanarTopology displayedTopology = sourceTopology.rotateClockwise(displayRotationQuarterTurns);
if (displayedTopology != expected.canonicalTopology()) {
throw new WorkcellTopologyException("Workcell '" + workcell.stableId() + "' requires "
+ topologyDescription(expected.canonicalTopology())
+ ", but the edited markers form "
+ topologyDescription(displayedTopology)
+ ". Use Reset Connector Blocks to restore the saved topology, or edit the markers to match the floor glyph.");
}
}
private static String topologyDescription(JigsawPlanarTopology topology) {
int connectorCount = topology.directions().size();
return topology.name().toLowerCase(Locale.ROOT).replace('_', ' ')
+ " (" + connectorCount + " horizontal connector"
+ (connectorCount == 1 ? "" : "s") + ")";
}
static void storeConnectorFinalState(
IrisObject object,
int x,
int y,
int z,
BlockData finalData
) {
Objects.requireNonNull(object, "Jigsaw Studio captured object");
BlockData activeFinalData = Objects.requireNonNull(finalData, "Jigsaw connector final state");
if (activeFinalData.getMaterial() != Material.STRUCTURE_VOID) {
object.setUnsigned(x, y, z, BukkitBlockState.of(activeFinalData));
}
}
record ChunkCaptureArea(
int chunkX,
int chunkZ,
int minimumX,
int maximumX,
int minimumZ,
int maximumZ
) {
ChunkCaptureArea {
if (minimumX < 0 || minimumZ < 0 || maximumX <= minimumX || maximumZ <= minimumZ) {
throw new IllegalArgumentException("Jigsaw Studio chunk capture intersection is invalid");
}
}
boolean contains(int x, int z) {
return x >= minimumX && x < maximumX && z >= minimumZ && z < maximumZ;
}
}
record CapturedBlock(
int x,
int y,
int z,
PlatformBlockState state,
TileData tileData
) {
CapturedBlock {
Objects.requireNonNull(state, "Jigsaw Studio captured block state");
tileData = tileData == null ? null : tileData.clone();
}
public TileData tileData() {
return tileData == null ? null : tileData.clone();
}
}
record CapturedConnector(
int x,
int y,
int z,
IrisDirection direction,
IrisDirection top,
String pool,
String name,
String targetName,
String channel,
JigsawJoint joint,
String finalState,
int selectionPriority,
int placementPriority
) {
CapturedConnector {
Objects.requireNonNull(direction, "Jigsaw Studio captured connector direction");
Objects.requireNonNull(top, "Jigsaw Studio captured connector top");
Objects.requireNonNull(pool, "Jigsaw Studio captured connector pool");
Objects.requireNonNull(name, "Jigsaw Studio captured connector name");
Objects.requireNonNull(targetName, "Jigsaw Studio captured connector target");
channel = channel == null ? "" : channel;
Objects.requireNonNull(joint, "Jigsaw Studio captured connector joint");
Objects.requireNonNull(finalState, "Jigsaw Studio captured connector final state");
}
static CapturedConnector from(IrisJigsawConnector connector) {
IrisJigsawConnector source = Objects.requireNonNull(connector, "Jigsaw Studio captured connector");
IrisPosition position = source.getPosition();
return new CapturedConnector(
position.getX(),
position.getY(),
position.getZ(),
source.getDirection(),
source.getTop(),
source.getPool(),
source.getName(),
source.getTargetName(),
source.getChannel(),
source.getJoint(),
source.getFinalState(),
source.getSelectionPriority(),
source.getPlacementPriority());
}
IrisJigsawConnector toConnector() {
return new IrisJigsawConnector()
.setPosition(new IrisPosition(x, y, z))
.setDirection(direction)
.setTop(top)
.setPool(pool)
.setName(name)
.setTargetName(targetName)
.setChannel(channel)
.setJoint(joint)
.setFinalState(finalState)
.setSelectionPriority(selectionPriority)
.setPlacementPriority(placementPriority);
}
}
record ChunkSnapshot(
ChunkCaptureArea area,
List<CapturedBlock> blocks,
List<CapturedConnector> connectors
) {
ChunkSnapshot {
Objects.requireNonNull(area, "Jigsaw Studio captured chunk area");
blocks = List.copyOf(blocks);
connectors = List.copyOf(connectors);
}
}
record LocalPosition(int x, int y, int z) {
}
record Capture(byte[] objectContent, List<IrisJigsawConnector> connectors, boolean hasBlockEntities) {
Capture {
objectContent = Objects.requireNonNull(objectContent, "Jigsaw Studio object content").clone();
connectors = List.copyOf(connectors);
}
public byte[] objectContent() {
return objectContent.clone();
}
}
static final class WorkcellTopologyException extends IOException {
WorkcellTopologyException(String message) {
super(message);
}
}
}
@@ -0,0 +1,493 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioActivation;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBounds;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioLayout;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioSession;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioVariant;
import art.arcane.iris.core.service.JigsawStudioAutosaveScheduler.AutosaveFailureState;
import art.arcane.iris.core.service.JigsawStudioAutosaveScheduler.AutosavePersistentFailure;
import art.arcane.iris.core.service.JigsawStudioCapture.Capture;
import art.arcane.iris.core.service.JigsawStudioCapture.ChunkCaptureArea;
import art.arcane.iris.core.service.JigsawStudioCapture.ChunkSnapshot;
import art.arcane.iris.core.service.JigsawStudioCapture.WorkcellTopologyException;
import art.arcane.iris.core.service.JigsawStudioService.ActiveStudio;
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
import art.arcane.iris.core.structure.authoring.StructureWriteOptions;
import art.arcane.iris.core.structure.authoring.StructureWriteResult;
import art.arcane.iris.engine.framework.structure.StructureResourceBundleGraphCompiler;
import art.arcane.iris.engine.object.IrisJigsawConnector;
import art.arcane.iris.engine.object.IrisJigsawPiece;
import art.arcane.iris.engine.object.IrisObject;
import art.arcane.iris.engine.platform.studio.generators.JigsawStudioGenerator;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.util.common.scheduling.J;
import org.bukkit.World;
import org.bukkit.entity.Player;
import java.io.IOException;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import static art.arcane.iris.core.service.JigsawStudioCapture.aggregateSnapshots;
import static art.arcane.iris.core.service.JigsawStudioCapture.captureChunkIntersection;
import static art.arcane.iris.core.service.JigsawStudioCapture.chunkIntersections;
import static art.arcane.iris.core.service.JigsawStudioCapture.preserveCapturedConnectorOrder;
import static art.arcane.iris.core.service.JigsawStudioCapture.requireWorkcellTopology;
import static art.arcane.iris.core.service.JigsawStudioService.chunkKey;
import static art.arcane.iris.core.service.JigsawStudioService.failureMessage;
import static art.arcane.iris.core.service.JigsawStudioService.message;
import static art.arcane.iris.core.service.JigsawStudioService.playSaveSound;
import static art.arcane.iris.core.service.JigsawStudioService.writeFailure;
final class JigsawStudioCaptureScheduler {
private final JigsawStudioService service;
JigsawStudioCaptureScheduler(JigsawStudioService service) {
this.service = Objects.requireNonNull(service, "Jigsaw Studio service");
}
boolean scheduleCapture(
ActiveStudio studio,
World world,
JigsawStudioBay bay,
CaptureTarget captureTarget,
Player player,
UUID requestId,
JigsawStudioSession.SaveIdentity saveIdentity,
AutosaveFailureState autosaveFailureState
) {
List<ChunkCaptureArea> areas = chunkIntersections(captureTarget.bounds());
CaptureWork work = new CaptureWork(
studio,
world,
bay,
captureTarget,
player,
requestId,
saveIdentity,
autosaveFailureState);
CaptureCoordinator coordinator = new CaptureCoordinator(
work,
areas);
for (ChunkCaptureArea area : areas) {
if (coordinator.stopped()) {
return false;
}
boolean scheduled = J.runRegion(
world,
area.chunkX(),
area.chunkZ(),
() -> captureChunk(coordinator, area));
if (!scheduled) {
coordinator.fail("Iris could not schedule bay chunk "
+ area.chunkX() + "," + area.chunkZ() + " on its owning region.", null);
return false;
}
}
return !coordinator.stopped();
}
private void captureChunk(CaptureCoordinator coordinator, ChunkCaptureArea area) {
if (coordinator.stopped()) {
return;
}
ActiveStudio studio = coordinator.studio();
World world = coordinator.world();
if (!service.saveLifecycle.isCurrentSave(studio, coordinator.requestId(), coordinator.saveIdentity())) {
coordinator.fail("Jigsaw Studio changed before every bay chunk could be captured; save cancelled.", null);
return;
}
if (!world.isChunkLoaded(area.chunkX(), area.chunkZ())) {
coordinator.fail("Bay chunk " + area.chunkX() + "," + area.chunkZ()
+ " is not loaded. Visit the whole bay and try again.", null);
return;
}
try {
ChunkSnapshot snapshot = captureChunkIntersection(
world,
coordinator.captureTarget().bounds(),
coordinator.captureTarget().piece(),
coordinator.captureTarget().object(),
area,
coordinator.captureTarget().displayRotationQuarterTurns(),
coordinator.captureTarget().connectorsVisible());
if (!service.saveLifecycle.isCurrentSave(studio, coordinator.requestId(), coordinator.saveIdentity())) {
coordinator.fail("Jigsaw Studio changed while bay chunks were being captured; save cancelled.", null);
return;
}
coordinator.accept(snapshot);
} catch (Throwable exception) {
coordinator.fail("Jigsaw Studio capture failed in chunk "
+ area.chunkX() + "," + area.chunkZ() + ": " + failureMessage(exception), exception);
}
}
void assembleAndPersist(CaptureCoordinator coordinator, List<ChunkSnapshot> snapshots) {
if (coordinator.failed()) {
return;
}
ActiveStudio studio = coordinator.studio();
if (!service.saveLifecycle.isCurrentSave(studio, coordinator.requestId(), coordinator.saveIdentity())) {
coordinator.fail("Jigsaw Studio changed before captured chunks could be assembled; save cancelled.", null);
return;
}
try {
Capture capture = aggregateSnapshots(
coordinator.captureTarget().bounds(),
coordinator.areas(),
snapshots,
coordinator.captureTarget().displayRotationQuarterTurns());
List<IrisJigsawConnector> connectors = preserveCapturedConnectorOrder(
coordinator.captureTarget().piece(),
capture.connectors());
requireWorkcellTopology(
coordinator.bay(),
connectors,
coordinator.captureTarget().displayRotationQuarterTurns());
if (!service.saveLifecycle.isCurrentSave(studio, coordinator.requestId(), coordinator.saveIdentity())) {
coordinator.fail("Jigsaw Studio changed before the captured bay could be written; save cancelled.", null);
return;
}
message(coordinator.player(), "Captured " + connectors.size()
+ " connector(s) from " + snapshots.size()
+ " chunk(s); validating and writing the owned structure graph...");
persistCapture(coordinator, capture, connectors);
} catch (WorkcellTopologyException exception) {
coordinator.failPersistent(
"Jigsaw Studio cannot autosave this planar workcell: "
+ failureMessage(exception),
null);
} catch (Throwable exception) {
coordinator.failPersistent(
"Jigsaw Studio capture assembly failed: " + failureMessage(exception),
exception);
}
}
private void persistCapture(
CaptureCoordinator coordinator,
Capture capture,
List<IrisJigsawConnector> connectors
) {
ActiveStudio studio = coordinator.studio();
JigsawStudioBay bay = coordinator.bay();
Player player = coordinator.player();
try {
if (!service.saveLifecycle.isCurrentSave(studio, coordinator.requestId(), coordinator.saveIdentity())) {
message(player, "Jigsaw Studio changed before the captured bay could be written; save cancelled.");
return;
}
JigsawStudioActivation.Request request = studio.generator().getRequest();
IrisData source = request.source();
Path packRoot = source.getDataFolder().toPath();
JigsawStudioResourceBundleAssembler.Assembly assembly =
JigsawStudioResourceBundleAssembler.assemble(
packRoot,
request.structureKey(),
coordinator.saveIdentity().variantKey(),
capture.objectContent(),
connectors,
capture.hasBlockEntities());
StructureResourceBundleGraphCompiler.requireViable(assembly.bundle());
if (!service.saveLifecycle.isCurrentSave(studio, coordinator.requestId(), coordinator.saveIdentity())) {
message(player, "Jigsaw Studio changed during validation; save cancelled before writing.");
return;
}
JigsawStudioHistoryStore historyStore = new JigsawStudioHistoryStore(
packRoot,
request.structureKey());
JigsawStudioHistoryStore.Snapshot previous = historyStore.snapshotCurrent(
coordinator.saveIdentity().variantKey());
if (!previous.matches(assembly.bundle())) {
historyStore.append(previous);
}
StructureWriteResult result;
synchronized (service.saveLifecycleLock) {
if (!service.saveLifecycle.isCurrentSave(studio, coordinator.requestId(), coordinator.saveIdentity())) {
message(player, "Jigsaw Studio changed before the validated save entered the writer; save cancelled.");
return;
}
result = new StructureTransactionWriter(packRoot)
.write(assembly.bundle(), StructureWriteOptions.overwriteExpected(
assembly.expectedManifestHash()));
}
if (!result.successful()) {
String failure = writeFailure(result);
coordinator.recordPersistentFailure(failure, result.failure().orElse(null));
message(player, failure);
return;
}
source.invalidateStructureResources();
studio.generator().invalidateRender(bay.stableId(), coordinator.saveIdentity().variantKey());
JigsawStudioSession session = studio.generator().getSession();
boolean unchanged = session.markWorkcellSaved(coordinator.saveIdentity());
try {
service.reloadSessionLayout(studio);
} catch (IOException | RuntimeException refreshFailure) {
IrisLogging.reportError(refreshFailure);
message(player, "The graph saved, but Studio could not refresh its variant catalog. "
+ "Close and reopen this project before continuing: "
+ failureMessage(refreshFailure));
}
String cleanup = result.status() == StructureWriteResult.Status.COMMITTED_CLEANUP_REQUIRED
? " The graph committed, but transaction cleanup requires operator attention in the console."
: "";
String mutationNotice = unchanged ? "" : " Newer edits remain unsaved.";
message(player, "Saved piece '" + coordinator.saveIdentity().variantKey() + "' and object '"
+ assembly.objectKey() + "' atomically." + mutationNotice + cleanup);
playSaveSound(player);
if (unchanged) {
service.evaluator.scheduleEvaluation(studio);
}
IrisLogging.debug("Jigsaw Studio saved: structure=%s piece=%s object=%s connectors=%d status=%s",
request.structureKey(), coordinator.saveIdentity().variantKey(), assembly.objectKey(),
connectors.size(), result.status());
} catch (Throwable exception) {
String failure = "Jigsaw Studio save failed: " + failureMessage(exception);
coordinator.recordPersistentFailure(failure, exception);
message(player, failure);
} finally {
coordinator.complete();
}
}
static Set<Long> requiredChunks(
JigsawStudioBay bay,
JigsawStudioGenerator.RenderedBay rendered
) {
Set<Long> chunks = new HashSet<>();
if (!rendered.valid()) {
return chunks;
}
JigsawStudioBounds bounds = bay.bounds();
int minimumChunkX = bounds.originX() >> 4;
int maximumChunkX = bounds.maxX() >> 4;
int minimumChunkZ = bounds.originZ() >> 4;
int maximumChunkZ = bounds.maxZ() >> 4;
for (int chunkX = minimumChunkX; chunkX <= maximumChunkX; chunkX++) {
for (int chunkZ = minimumChunkZ; chunkZ <= maximumChunkZ; chunkZ++) {
chunks.add(chunkKey(chunkX, chunkZ));
}
}
return Set.copyOf(chunks);
}
static CaptureTarget resolveCaptureTarget(
ActiveStudio studio,
JigsawStudioBay bay,
JigsawStudioVariant variant
)
throws IOException {
IrisData source = studio.generator().getRequest().source();
JigsawStudioLayout currentLayout = studio.generator().getSession().layout();
JigsawStudioBay currentBay = currentLayout.get(bay.stableId());
if (currentBay == null) {
throw new IOException("workcell '" + bay.stableId() + "' is absent from the current Studio layout");
}
JigsawStudioVariant activeVariant = Objects.requireNonNull(variant, "Jigsaw Studio capture variant");
if (!currentLayout.accepts(currentBay, activeVariant)) {
throw new IOException("variant '" + activeVariant.pieceKey()
+ "' does not belong to workcell '" + currentBay.stableId() + "'");
}
IrisJigsawPiece piece = source.getJigsawPieceLoader().load(activeVariant.pieceKey(), false);
if (piece == null || piece.getObject() == null || piece.getObject().isBlank()) {
throw new IOException("piece '" + activeVariant.pieceKey() + "' is missing or has no object");
}
IrisObject object = source.getObjectLoader().load(piece.getObject(), false);
if (object == null || object.getW() < 1 || object.getH() < 1 || object.getD() < 1) {
throw new IOException("object '" + piece.getObject() + "' is missing or has invalid dimensions");
}
JigsawStudioBounds bayBounds = currentBay.bounds();
JigsawStudioCellDimensions sourceDimensions = new JigsawStudioCellDimensions(
object.getW(), object.getH(), object.getD());
JigsawStudioCellDimensions canonicalDimensions = activeVariant.canonicalDimensions(sourceDimensions);
if (canonicalDimensions.width() > bayBounds.dimensions().width()
|| canonicalDimensions.height() > bayBounds.dimensions().height()
|| canonicalDimensions.depth() > bayBounds.dimensions().depth()) {
throw new IOException("object '" + piece.getObject() + "' is "
+ canonicalDimensions.width() + "x" + canonicalDimensions.height() + "x"
+ canonicalDimensions.depth() + " in its displayed orientation"
+ " but bay '" + currentBay.stableId() + "' is only "
+ bayBounds.dimensions().width() + "x" + bayBounds.dimensions().height() + "x"
+ bayBounds.dimensions().depth());
}
return new CaptureTarget(
new JigsawStudioBounds(
bayBounds.originX(),
bayBounds.originY(),
bayBounds.originZ(),
canonicalDimensions),
piece,
object,
activeVariant.sourceToCanonicalQuarterTurns(),
studio.generator().getSession().workcellSnapshot(currentBay.stableId()).connectorsVisible());
}
CaptureCoordinator coordinator(CaptureWork work, List<ChunkCaptureArea> areas) {
return new CaptureCoordinator(work, areas);
}
final class CaptureCoordinator {
private final CaptureWork work;
private final List<ChunkCaptureArea> areas;
private final Map<Long, ChunkSnapshot> snapshots = new ConcurrentHashMap<>();
private final AtomicInteger remaining;
private final AtomicBoolean failed = new AtomicBoolean(false);
private final AtomicBoolean assemblyStarted = new AtomicBoolean(false);
private final AtomicBoolean operationFinished = new AtomicBoolean(false);
private volatile AutosavePersistentFailure persistentFailure;
CaptureCoordinator(CaptureWork work, List<ChunkCaptureArea> areas) {
this.work = Objects.requireNonNull(work, "Jigsaw Studio capture work");
this.areas = List.copyOf(areas);
this.remaining = new AtomicInteger(this.areas.size());
}
private ActiveStudio studio() {
return work.studio();
}
private World world() {
return work.world();
}
private JigsawStudioBay bay() {
return work.bay();
}
private CaptureTarget captureTarget() {
return work.captureTarget();
}
private Player player() {
return work.player();
}
private UUID requestId() {
return work.requestId();
}
private JigsawStudioSession.SaveIdentity saveIdentity() {
return work.saveIdentity();
}
private List<ChunkCaptureArea> areas() {
return areas;
}
private boolean stopped() {
return failed.get();
}
private boolean failed() {
return failed.get();
}
private void accept(ChunkSnapshot snapshot) {
if (failed.get() || assemblyStarted.get()) {
return;
}
ChunkCaptureArea area = snapshot.area();
long key = chunkKey(area.chunkX(), area.chunkZ());
if (snapshots.putIfAbsent(key, snapshot) != null) {
fail("Jigsaw Studio received duplicate snapshot for bay chunk "
+ area.chunkX() + "," + area.chunkZ() + ".", null);
return;
}
int incomplete = remaining.decrementAndGet();
if (incomplete < 0) {
fail("Jigsaw Studio received more bay chunk snapshots than expected.", null);
return;
}
if (incomplete == 0 && assemblyStarted.compareAndSet(false, true)) {
List<ChunkSnapshot> completed = List.copyOf(snapshots.values());
J.a(() -> assembleAndPersist(this, completed));
}
}
void fail(String failure, Throwable exception) {
if (!failed.compareAndSet(false, true)) {
return;
}
if (exception != null) {
IrisLogging.reportError(exception);
}
complete();
message(player(), failure);
}
private void failPersistent(String failure, Throwable exception) {
if (!failed.compareAndSet(false, true)) {
return;
}
recordPersistentFailure(failure, exception);
complete();
message(player(), failure);
}
private void recordPersistentFailure(String failure, Throwable exception) {
persistentFailure = new AutosavePersistentFailure(failure, exception);
}
private void complete() {
if (operationFinished.compareAndSet(false, true)) {
studio().generator().getSession().abortSave(saveIdentity());
service.saveLifecycle.finishSave(requestId());
service.autosaveScheduler.completeAutosaveAttempt(
studio(),
saveIdentity(),
work.autosaveFailureState(),
persistentFailure);
service.playerContext.refreshWorkcellContext(studio().worldId(), saveIdentity().workcellId());
}
}
}
record CaptureWork(
ActiveStudio studio,
World world,
JigsawStudioBay bay,
CaptureTarget captureTarget,
Player player,
UUID requestId,
JigsawStudioSession.SaveIdentity saveIdentity,
AutosaveFailureState autosaveFailureState
) {
CaptureWork {
Objects.requireNonNull(studio, "Jigsaw Studio capture studio");
Objects.requireNonNull(world, "Jigsaw Studio capture world");
Objects.requireNonNull(bay, "Jigsaw Studio capture bay");
Objects.requireNonNull(captureTarget, "Jigsaw Studio capture target");
Objects.requireNonNull(requestId, "Jigsaw Studio capture request ID");
Objects.requireNonNull(saveIdentity, "Jigsaw Studio capture save identity");
Objects.requireNonNull(autosaveFailureState, "Jigsaw Studio capture autosave failure state");
}
}
record CaptureTarget(
JigsawStudioBounds bounds,
IrisJigsawPiece piece,
IrisObject object,
int displayRotationQuarterTurns,
boolean connectorsVisible
) {
CaptureTarget {
Objects.requireNonNull(bounds, "Jigsaw Studio capture bounds");
Objects.requireNonNull(piece, "Jigsaw Studio capture piece");
Objects.requireNonNull(object, "Jigsaw Studio capture object");
displayRotationQuarterTurns = Math.floorMod(displayRotationQuarterTurns, 4);
}
}
}
@@ -0,0 +1,624 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBounds;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioMarkerKeyCodec;
import art.arcane.iris.core.service.JigsawStudioCapture.CapturedConnector;
import art.arcane.iris.core.service.JigsawStudioCapture.ChunkCaptureArea;
import art.arcane.iris.core.service.JigsawStudioCapture.LocalPosition;
import art.arcane.iris.core.service.JigsawStudioSaveLifecycle.BayReadiness;
import art.arcane.iris.core.service.JigsawStudioService.ActiveStudio;
import art.arcane.iris.engine.object.IrisJigsawConnector;
import art.arcane.iris.engine.object.IrisJigsawPiece;
import art.arcane.iris.engine.object.IrisObjectRotation;
import art.arcane.iris.engine.object.IrisPosition;
import art.arcane.iris.engine.object.TileData;
import art.arcane.iris.engine.platform.studio.generators.JigsawStudioGenerator;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.volmlib.util.collection.KMap;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import org.bukkit.block.data.type.Jigsaw;
import java.io.IOException;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import static art.arcane.iris.core.service.JigsawStudioCapture.inversePosition;
import static art.arcane.iris.core.service.JigsawStudioService.chunkKey;
import static art.arcane.iris.core.service.JigsawStudioService.failureMessage;
final class JigsawStudioChunkWriter {
private JigsawStudioChunkWriter() {
}
static void restoreConnectorChunk(
World world,
JigsawStudioBay workcell,
List<JigsawStudioGenerator.RenderedConnector> connectors,
Map<LocalPosition, JigsawStudioGenerator.RenderedBlock> renderedBlocks,
boolean connectorsVisible
) throws IOException {
JigsawStudioBounds bounds = workcell.bounds();
for (JigsawStudioGenerator.RenderedConnector connector : connectors) {
LocalPosition position = new LocalPosition(connector.x(), connector.y(), connector.z());
Block target = world.getBlockAt(
bounds.originX() + connector.x(),
bounds.originY() + connector.y(),
bounds.originZ() + connector.z());
if (connectorsVisible) {
BlockData marker;
try {
marker = Bukkit.createBlockData(
"minecraft:jigsaw[orientation=" + connector.orientation() + "]");
} catch (IllegalArgumentException exception) {
throw new IOException("Invalid saved connector orientation '"
+ connector.orientation() + "'", exception);
}
target.setBlockData(marker, false);
BukkitPlatform.deserializeTile(markerNbt(connector.connector()), target.getLocation());
continue;
}
JigsawStudioGenerator.RenderedBlock renderedBlock = renderedBlocks.get(position);
BlockData restored;
if (renderedBlock != null) {
if (renderedBlock.state().isCustom()
|| !(renderedBlock.state().nativeHandle() instanceof BlockData blockData)) {
throw new IOException("Saved connector block '" + renderedBlock.state().key()
+ "' cannot be restored directly in Bukkit Studio");
}
restored = blockData;
} else {
try {
restored = Bukkit.createBlockData(connector.connector().getFinalState());
} catch (IllegalArgumentException exception) {
throw new IOException("Invalid saved connector final state '"
+ connector.connector().getFinalState() + "'", exception);
}
}
target.setBlockData(restored, false);
if (renderedBlock != null && renderedBlock.tileData() != null) {
TileData tileData = renderedBlock.tileData();
if (!tileData.isApplicable(target.getBlockData()) || !tileData.toBukkitTry(target)) {
throw new IOException("Saved connector tile data could not be restored at "
+ target.getX() + "," + target.getY() + "," + target.getZ());
}
}
}
}
static String validateMaterialization(JigsawStudioGenerator.RenderedBay rendered) {
if (!rendered.valid()) {
return rendered.failure();
}
for (JigsawStudioGenerator.RenderedBlock block : rendered.blocks()) {
PlatformBlockState state = block.state();
if (state.isCustom()) {
return "custom block '" + state.key()
+ "' requires provider-owned placement and cannot be swapped live in Studio";
}
if (!(state.nativeHandle() instanceof BlockData)) {
return "block '" + state.key() + "' has no Bukkit block-data representation";
}
}
for (JigsawStudioGenerator.RenderedConnector connector : rendered.connectors()) {
try {
BlockData data = Bukkit.createBlockData(
"minecraft:jigsaw[orientation=" + connector.orientation() + "]");
if (!(data instanceof Jigsaw)) {
return "connector orientation '" + connector.orientation()
+ "' did not resolve to a jigsaw marker";
}
} catch (IllegalArgumentException exception) {
return "connector orientation '" + connector.orientation() + "' is invalid";
}
}
return "";
}
static void writeMaterializedChunk(
World world,
JigsawStudioBay workcell,
JigsawStudioGenerator.RenderedBay rendered,
ChunkCaptureArea area,
boolean connectorsVisible
) throws IOException {
JigsawStudioBounds bounds = workcell.bounds();
Map<LocalPosition, BlockData> expected = materializedBlockData(rendered, connectorsVisible);
BlockData air = Material.AIR.createBlockData();
for (int x = area.minimumX(); x < area.maximumX(); x++) {
for (int y = 0; y < bounds.dimensions().height(); y++) {
for (int z = area.minimumZ(); z < area.maximumZ(); z++) {
Block target = world.getBlockAt(
bounds.originX() + x,
bounds.originY() + y,
bounds.originZ() + z);
BlockData blockData = expected.getOrDefault(new LocalPosition(x, y, z), air);
target.setBlockData(blockData, false);
}
}
}
applyRenderedBayChunk(world, workcell, rendered, area.chunkX(), area.chunkZ(), connectorsVisible);
verifyMaterializedChunk(world, workcell, rendered, area, expected, connectorsVisible);
}
private static Map<LocalPosition, BlockData> materializedBlockData(
JigsawStudioGenerator.RenderedBay rendered,
boolean connectorsVisible
) throws IOException {
Map<LocalPosition, BlockData> expected = new HashMap<>();
for (JigsawStudioGenerator.RenderedBlock block : rendered.blocks()) {
if (block.state().isCustom() || !(block.state().nativeHandle() instanceof BlockData blockData)) {
throw new IOException("Rendered block '" + block.state().key()
+ "' cannot be placed directly in Bukkit Studio");
}
expected.put(new LocalPosition(block.x(), block.y(), block.z()), blockData);
}
for (JigsawStudioGenerator.RenderedConnector connector : rendered.connectors()) {
if (!connectorsVisible && expected.containsKey(
new LocalPosition(connector.x(), connector.y(), connector.z()))) {
continue;
}
BlockData marker;
try {
marker = Bukkit.createBlockData(connectorsVisible
? "minecraft:jigsaw[orientation=" + connector.orientation() + "]"
: connector.connector().getFinalState());
} catch (IllegalArgumentException exception) {
throw new IOException("Invalid rendered connector orientation '"
+ connector.orientation() + "'", exception);
}
expected.put(new LocalPosition(connector.x(), connector.y(), connector.z()), marker);
}
return expected;
}
private static void verifyMaterializedChunk(
World world,
JigsawStudioBay workcell,
JigsawStudioGenerator.RenderedBay rendered,
ChunkCaptureArea area,
Map<LocalPosition, BlockData> expected,
boolean connectorsVisible
) throws IOException {
JigsawStudioBounds bounds = workcell.bounds();
BlockData air = Material.AIR.createBlockData();
for (int x = area.minimumX(); x < area.maximumX(); x++) {
for (int y = 0; y < bounds.dimensions().height(); y++) {
for (int z = area.minimumZ(); z < area.maximumZ(); z++) {
Block target = world.getBlockAt(
bounds.originX() + x,
bounds.originY() + y,
bounds.originZ() + z);
BlockData expectedData = expected.getOrDefault(new LocalPosition(x, y, z), air);
if (!target.getBlockData().equals(expectedData)) {
throw new IOException("Workcell block did not materialize at "
+ target.getX() + "," + target.getY() + "," + target.getZ());
}
}
}
}
verifyRenderedBayChunk(world, workcell, rendered, area.chunkX(), area.chunkZ(), connectorsVisible);
}
static boolean hydrateChunk(
ActiveStudio studio,
World world,
int chunkX,
int chunkZ
) {
long key = chunkKey(chunkX, chunkZ);
boolean verificationRequired = false;
for (JigsawStudioBay bay : studio.generator().getLayout().bays()) {
BayPopulation population = studio.population(bay);
if (!population.needsApplication(key) && !population.needsVerification(key)) {
continue;
}
JigsawStudioGenerator.RenderedBay rendered = studio.generator().renderBay(bay);
if (!rendered.valid()) {
population.fail(rendered.failure());
continue;
}
try {
if (population.needsApplication(key)) {
applyRenderedBayChunk(
world,
bay,
rendered,
chunkX,
chunkZ,
studio.generator().getSession().workcellSnapshot(bay.stableId()).connectorsVisible());
population.markApplied(key);
verificationRequired = true;
} else {
verifyRenderedBayChunk(
world,
bay,
rendered,
chunkX,
chunkZ,
studio.generator().getSession().workcellSnapshot(bay.stableId()).connectorsVisible());
population.markHydrated(key);
}
} catch (Throwable exception) {
if (population.fail("chunk " + chunkX + "," + chunkZ
+ " could not hydrate: " + failureMessage(exception))) {
IrisLogging.reportError(exception);
}
}
}
return verificationRequired;
}
static void applyRenderedBayChunk(
World world,
JigsawStudioBay bay,
JigsawStudioGenerator.RenderedBay rendered,
int chunkX,
int chunkZ,
boolean connectorsVisible
) throws IOException {
JigsawStudioBounds bounds = bay.bounds();
Set<LocalPosition> connectorPositions = new HashSet<>(rendered.connectors().size());
if (connectorsVisible) {
for (JigsawStudioGenerator.RenderedConnector connector : rendered.connectors()) {
connectorPositions.add(new LocalPosition(connector.x(), connector.y(), connector.z()));
}
}
for (JigsawStudioGenerator.RenderedBlock block : rendered.blocks()) {
TileData tileData = block.tileData();
if (tileData == null
|| connectorPositions.contains(new LocalPosition(block.x(), block.y(), block.z()))) {
continue;
}
int worldX = bounds.originX() + block.x();
int worldZ = bounds.originZ() + block.z();
if ((worldX >> 4) != chunkX || (worldZ >> 4) != chunkZ) {
continue;
}
Block target = world.getBlockAt(worldX, bounds.originY() + block.y(), worldZ);
if (!tileData.isApplicable(target.getBlockData())) {
throw new IOException("source tile state does not match rendered block at "
+ target.getX() + "," + target.getY() + "," + target.getZ());
}
if (!tileData.toBukkitTry(target)) {
throw new IOException("source tile state could not hydrate at "
+ target.getX() + "," + target.getY() + "," + target.getZ());
}
}
if (!connectorsVisible) {
return;
}
for (JigsawStudioGenerator.RenderedConnector renderedConnector : rendered.connectors()) {
int worldX = bounds.originX() + renderedConnector.x();
int worldZ = bounds.originZ() + renderedConnector.z();
if ((worldX >> 4) != chunkX || (worldZ >> 4) != chunkZ) {
continue;
}
Block target = world.getBlockAt(
worldX,
bounds.originY() + renderedConnector.y(),
worldZ);
BlockData blockData = target.getBlockData();
if (!(blockData instanceof Jigsaw jigsaw)) {
throw new IOException("expected a jigsaw marker at "
+ target.getX() + "," + target.getY() + "," + target.getZ());
}
String actualOrientation = jigsaw.getOrientation().name().toLowerCase(Locale.ROOT);
if (!actualOrientation.equals(renderedConnector.orientation())) {
throw new IOException("jigsaw marker orientation at "
+ target.getX() + "," + target.getY() + "," + target.getZ()
+ " is " + actualOrientation + " instead of " + renderedConnector.orientation());
}
KMap<String, Object> expected = markerNbt(renderedConnector.connector());
BukkitPlatform.deserializeTile(expected, target.getLocation());
}
}
static void verifyRenderedBayChunk(
World world,
JigsawStudioBay bay,
JigsawStudioGenerator.RenderedBay rendered,
int chunkX,
int chunkZ,
boolean connectorsVisible
) throws IOException {
JigsawStudioBounds bounds = bay.bounds();
Set<LocalPosition> connectorPositions = new HashSet<>(rendered.connectors().size());
if (connectorsVisible) {
for (JigsawStudioGenerator.RenderedConnector connector : rendered.connectors()) {
connectorPositions.add(new LocalPosition(connector.x(), connector.y(), connector.z()));
}
}
for (JigsawStudioGenerator.RenderedBlock block : rendered.blocks()) {
TileData tileData = block.tileData();
if (tileData == null
|| connectorPositions.contains(new LocalPosition(block.x(), block.y(), block.z()))) {
continue;
}
int worldX = bounds.originX() + block.x();
int worldZ = bounds.originZ() + block.z();
if ((worldX >> 4) != chunkX || (worldZ >> 4) != chunkZ) {
continue;
}
Block target = world.getBlockAt(worldX, bounds.originY() + block.y(), worldZ);
if (!tileData.isApplicable(target.getBlockData())) {
throw new IOException("hydrated tile state no longer matches its block at "
+ target.getX() + "," + target.getY() + "," + target.getZ());
}
KMap<String, Object> hydrated = BukkitPlatform.serializeTile(target.getLocation());
if (hydrated == null
|| tileData.getProperties() != null
&& !nbtContains(tileData.getProperties(), hydrated)) {
throw new IOException("the active NMS binding did not preserve source tile NBT at "
+ target.getX() + "," + target.getY() + "," + target.getZ());
}
}
if (!connectorsVisible) {
return;
}
for (JigsawStudioGenerator.RenderedConnector renderedConnector : rendered.connectors()) {
int worldX = bounds.originX() + renderedConnector.x();
int worldZ = bounds.originZ() + renderedConnector.z();
if ((worldX >> 4) != chunkX || (worldZ >> 4) != chunkZ) {
continue;
}
Block target = world.getBlockAt(
worldX,
bounds.originY() + renderedConnector.y(),
worldZ);
BlockData blockData = target.getBlockData();
if (!(blockData instanceof Jigsaw jigsaw)
|| !jigsaw.getOrientation().name().toLowerCase(Locale.ROOT)
.equals(renderedConnector.orientation())) {
throw new IOException("jigsaw marker changed before hydration completed at "
+ target.getX() + "," + target.getY() + "," + target.getZ());
}
KMap<String, Object> expected = markerNbt(renderedConnector.connector());
KMap<String, Object> hydrated = BukkitPlatform.serializeTile(target.getLocation());
if (!markerNbtMatches(expected, hydrated)) {
throw new IOException("the active NMS binding did not preserve jigsaw marker NBT at "
+ target.getX() + "," + target.getY() + "," + target.getZ());
}
}
}
static KMap<String, Object> markerNbt(IrisJigsawConnector connector) {
IrisJigsawConnector activeConnector = Objects.requireNonNull(connector, "Jigsaw Studio connector");
KMap<String, Object> nbt = new KMap<>();
nbt.put("name", studioIdentifier(activeConnector.getName()));
nbt.put("target", studioIdentifier(activeConnector.getTargetName()));
nbt.put("pool", JigsawStudioMarkerKeyCodec.encodePool(activeConnector.getPool()));
nbt.put("final_state", activeConnector.getFinalState());
nbt.put("joint", activeConnector.getJoint().name().toLowerCase(Locale.ROOT));
nbt.put("selection_priority", activeConnector.getSelectionPriority());
nbt.put("placement_priority", activeConnector.getPlacementPriority());
return nbt;
}
private static boolean markerNbtMatches(
Map<String, Object> expected,
Map<String, Object> hydrated
) {
if (hydrated == null) {
return false;
}
for (Map.Entry<String, Object> entry : expected.entrySet()) {
Object actual = hydrated.get(entry.getKey());
if (entry.getValue() instanceof Number expectedNumber) {
if (!(actual instanceof Number actualNumber)
|| expectedNumber.longValue() != actualNumber.longValue()) {
return false;
}
} else if (!Objects.equals(entry.getValue(), actual)) {
return false;
}
}
return true;
}
private static boolean nbtContains(Object expected, Object actual) {
if (expected instanceof Number expectedNumber) {
if (!(actual instanceof Number actualNumber)) {
return false;
}
if (isIntegral(expectedNumber) && isIntegral(actualNumber)) {
return expectedNumber.longValue() == actualNumber.longValue();
}
return Double.compare(expectedNumber.doubleValue(), actualNumber.doubleValue()) == 0;
}
if (expected instanceof Map<?, ?> expectedMap) {
if (!(actual instanceof Map<?, ?> actualMap)) {
return false;
}
for (Map.Entry<?, ?> entry : expectedMap.entrySet()) {
if (!actualMap.containsKey(entry.getKey())
|| !nbtContains(entry.getValue(), actualMap.get(entry.getKey()))) {
return false;
}
}
return true;
}
if (expected instanceof List<?> expectedList) {
if (!(actual instanceof List<?> actualList) || expectedList.size() != actualList.size()) {
return false;
}
for (int index = 0; index < expectedList.size(); index++) {
if (!nbtContains(expectedList.get(index), actualList.get(index))) {
return false;
}
}
return true;
}
return Objects.equals(expected, actual);
}
private static boolean isIntegral(Number number) {
return number instanceof Byte
|| number instanceof Short
|| number instanceof Integer
|| number instanceof Long;
}
static void restoreCapturedMetadata(
IrisJigsawConnector captured,
IrisJigsawPiece sourcePiece
) {
IrisJigsawConnector activeCaptured = Objects.requireNonNull(captured, "Captured jigsaw connector");
IrisJigsawConnector original = connectorAt(sourcePiece, activeCaptured.getPosition());
if (original == null) {
return;
}
activeCaptured.setName(restoreIdentifier(activeCaptured.getName(), original.getName()));
activeCaptured.setTargetName(restoreIdentifier(
activeCaptured.getTargetName(),
original.getTargetName()));
activeCaptured.setChannel(original.getChannel() == null ? "" : original.getChannel());
}
static void restoreCapturedMetadataForDisplay(
IrisJigsawConnector captured,
IrisJigsawPiece sourcePiece,
JigsawStudioCellDimensions displayDimensions,
int displayRotationQuarterTurns
) {
if (Math.floorMod(displayRotationQuarterTurns, 4) == 0) {
restoreCapturedMetadata(captured, sourcePiece);
return;
}
int quarterTurns = Math.floorMod(displayRotationQuarterTurns, 4);
int sourceWidth = (quarterTurns & 1) == 0
? displayDimensions.width()
: displayDimensions.depth();
int sourceDepth = (quarterTurns & 1) == 0
? displayDimensions.depth()
: displayDimensions.width();
LocalPosition position = inversePosition(
captured.getPosition().getX(),
captured.getPosition().getY(),
captured.getPosition().getZ(),
sourceWidth,
sourceDepth,
quarterTurns);
IrisObjectRotation inverseRotation = IrisObjectRotation.of(0, 90.0D * quarterTurns, 0);
IrisJigsawConnector sourceOriented = CapturedConnector.from(captured).toConnector()
.setPosition(new IrisPosition(position.x(), position.y(), position.z()))
.setDirection(inverseRotation.rotate(captured.getDirection()))
.setTop(inverseRotation.rotate(captured.getTop()));
restoreCapturedMetadata(sourceOriented, sourcePiece);
captured.setName(sourceOriented.getName());
captured.setTargetName(sourceOriented.getTargetName());
captured.setChannel(sourceOriented.getChannel());
}
private static IrisJigsawConnector connectorAt(IrisJigsawPiece piece, IrisPosition position) {
if (piece == null || piece.getConnectors() == null || position == null) {
return null;
}
for (IrisJigsawConnector connector : piece.getConnectors()) {
if (connector != null && position.equals(connector.getPosition())) {
return connector;
}
}
return null;
}
private static String restoreIdentifier(String captured, String original) {
if (original != null && studioIdentifier(original).equals(captured)) {
return original;
}
return captured;
}
private static String studioIdentifier(String value) {
String normalized = Objects.requireNonNull(value, "Jigsaw Studio marker identifier").trim();
return normalized.indexOf(':') < 0 ? "iris:" + normalized : normalized;
}
static final class BayPopulation {
private final Set<Long> requiredChunks;
private final Set<Long> generatedChunks = ConcurrentHashMap.newKeySet();
private final Set<Long> appliedChunks = ConcurrentHashMap.newKeySet();
private final Set<Long> hydratedChunks = ConcurrentHashMap.newKeySet();
private volatile String failure;
BayPopulation(Set<Long> requiredChunks, String failure) {
this.requiredChunks = Set.copyOf(requiredChunks);
this.failure = failure == null ? "" : failure;
}
boolean markGenerated(long chunk) {
if (!requiredChunks.contains(chunk)) {
return false;
}
generatedChunks.add(chunk);
return !hydratedChunks.contains(chunk) && failure.isEmpty();
}
boolean needsApplication(long chunk) {
return failure.isEmpty()
&& requiredChunks.contains(chunk)
&& generatedChunks.contains(chunk)
&& !appliedChunks.contains(chunk);
}
boolean needsVerification(long chunk) {
return failure.isEmpty()
&& requiredChunks.contains(chunk)
&& generatedChunks.contains(chunk)
&& appliedChunks.contains(chunk)
&& !hydratedChunks.contains(chunk);
}
void markApplied(long chunk) {
if (requiredChunks.contains(chunk)) {
appliedChunks.add(chunk);
}
}
void markHydrated(long chunk) {
if (requiredChunks.contains(chunk)) {
hydratedChunks.add(chunk);
}
}
void markFullyReady() {
generatedChunks.addAll(requiredChunks);
appliedChunks.addAll(requiredChunks);
hydratedChunks.addAll(requiredChunks);
}
synchronized boolean fail(String reason) {
if (!failure.isEmpty()) {
return false;
}
failure = reason == null || reason.isBlank() ? "unknown hydration failure" : reason;
return true;
}
BayReadiness readiness() {
boolean ready = failure.isEmpty()
&& !requiredChunks.isEmpty()
&& generatedChunks.containsAll(requiredChunks)
&& hydratedChunks.containsAll(requiredChunks);
return new BayReadiness(
ready,
failure,
requiredChunks.size(),
generatedChunks.size(),
hydratedChunks.size());
}
}
}
@@ -0,0 +1,325 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioToolAction;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioToolPayload;
import art.arcane.volmlib.util.inventorygui.UIElement;
import art.arcane.volmlib.util.inventorygui.UIWindow;
import org.bukkit.ChatColor;
import org.bukkit.Material;
import java.util.List;
import java.util.Objects;
import static art.arcane.iris.core.service.JigsawStudioMenuController.CHANCE_STEP_PERCENTAGE_POINTS;
import static art.arcane.iris.core.service.JigsawStudioMenuController.GRID_POSITIONS;
import static art.arcane.iris.core.service.JigsawStudioMenuController.MEMBERSHIPS_PER_PAGE;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.chance;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.clampPage;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.dimensions;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.displayKey;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.element;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.evaluationElement;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.maximumPlacements;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.page;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.pageCount;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.safe;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.themes;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.yesNo;
final class JigsawStudioDetailsMenu {
private final JigsawStudioMenuController controller;
JigsawStudioDetailsMenu(JigsawStudioMenuController controller) {
this.controller = Objects.requireNonNull(controller, "Jigsaw Studio menu controller");
}
void render(
UIWindow window,
JigsawStudioMenuState state,
JigsawStudioMenuState.Workcell workcell,
JigsawStudioMenuState.Variant variant,
int requestedPage
) {
int page = clampPage(requestedPage, variant.memberships().size(), MEMBERSHIPS_PER_PAGE);
controller.purgeExpiredConfirmations(window.getViewer().getUniqueId());
window.batch(() -> {
window.clearElements();
addDetailsHeader(window, state, workcell, variant, page);
addMemberships(window, state, workcell, variant, page);
addDetailsFooter(window, state, workcell, variant, page);
});
}
private void addDetailsHeader(
UIWindow window,
JigsawStudioMenuState state,
JigsawStudioMenuState.Workcell workcell,
JigsawStudioMenuState.Variant variant,
int page
) {
UIElement back = element("back", Material.ARROW, ChatColor.YELLOW + "Back to Variants");
back.onLeftClick(clicked -> controller.refreshMain(
window.getViewer(), state.requestId(), workcell.stableId(), 0));
window.setElement(-4, 0, back);
UIElement identity = element("identity", Material.NAME_TAG, ChatColor.AQUA + safe(variant.displayName()));
identity.addLore(ChatColor.DARK_GRAY + safe(variant.pieceKey()));
identity.addLore(variant.owned()
? ChatColor.GREEN + "Owned variant"
: ChatColor.GRAY + "Read-only variant");
identity.addLore(variant.active()
? ChatColor.GREEN + "Loaded in this workcell"
: ChatColor.GRAY + "Not currently loaded");
identity.addLore(ChatColor.GRAY + "Size: " + variant.dimensions()
.map(JigsawStudioMenuFormat::dimensions)
.orElse("Object missing"));
identity.addLore(ChatColor.GRAY + "Themes: " + themes(variant.themes()));
identity.addLore(ChatColor.GRAY + "Depth: " + variant.rules().minimumDepth()
+ "-" + variant.rules().maximumDepth());
identity.addLore(ChatColor.GRAY + "Placements: " + variant.rules().minimumPlacements()
+ "-" + maximumPlacements(variant.rules().maximumPlacements()));
identity.addLore(ChatColor.GRAY + "Terminal: " + yesNo(variant.rules().terminal()));
identity.addLore(ChatColor.GRAY + "Pool entries: " + variant.memberships().size());
if (variant.owned()) {
identity.addLore(ChatColor.YELLOW + "Left-click for a rename stick");
identity.addLore(ChatColor.GRAY + "Rename that stick in an anvil, then right-click it.");
identity.addLore(ChatColor.GRAY + "Sneak-right-click the stick to reset this label.");
identity.onLeftClick(clicked -> controller.actions.giveTool(
window.getViewer(),
JigsawStudioToolPayload.variant(
JigsawStudioToolAction.RENAME_VARIANT,
state.requestId(),
workcell.stableId(),
variant.pieceKey())));
}
window.setElement(0, 0, identity);
boolean activeOwned = variant.active() && variant.owned();
boolean irisRuleEditing = activeOwned && state.irisExtended();
UIElement settings = element(
"variant-settings",
irisRuleEditing ? Material.COMPARATOR : Material.GRAY_DYE,
irisRuleEditing
? ChatColor.GOLD + "Themes & Piece Rules"
: ChatColor.GRAY + "Themes & Rules Unavailable");
settings.addLore(irisRuleEditing
? ChatColor.GRAY + "Edit this loaded variant's theme membership and placement rules."
: state.irisExtended()
? ChatColor.GRAY + "Load an owned variant before editing its rules."
: ChatColor.GRAY + "Vanilla-portable pieces cannot encode Iris theme or rule metadata.");
if (irisRuleEditing) {
settings.addLore(ChatColor.YELLOW + "Left-click to edit");
settings.onLeftClick(clicked -> controller.openVariantSettings(
window.getViewer(), state.requestId(), workcell.stableId(), variant.pieceKey(), 0));
}
window.setElement(-2, 0, settings);
boolean rotationEditable = variant.active() && variant.rotationEditable();
UIElement rotation = element(
"rotation",
rotationEditable ? Material.REPEATER : Material.BARRIER,
rotationEditable
? ChatColor.LIGHT_PURPLE + "Toggle Rotation"
: ChatColor.GRAY + "Rotation is Read-only");
rotation.addLore(ChatColor.GRAY + "Currently: " + (variant.rotatable() ? "Enabled" : "Disabled"));
if (rotationEditable) {
rotation.addLore(ChatColor.YELLOW + "Left-click to toggle");
rotation.onLeftClick(clicked -> controller.toggleRotation(
window.getViewer(), state.requestId(), workcell.stableId()));
} else if (!variant.active()) {
rotation.addLore(ChatColor.GRAY + "Load this variant before editing rotation.");
} else if (variant.owned() && variant.rotatable()) {
rotation.addLore(ChatColor.GRAY + "Vanilla-portable variants must remain rotatable.");
}
window.setElement(3, 0, rotation);
boolean sizeEditable = variant.owned() && variant.dimensions().isPresent();
UIElement size = element(
"variant-size",
sizeEditable ? Material.SCAFFOLDING : Material.BARRIER,
sizeEditable
? ChatColor.GREEN + "Edit This Variant's Size"
: ChatColor.GRAY + "Variant Size is Read-only");
size.addLore(ChatColor.GRAY + "Current: " + variant.dimensions()
.map(JigsawStudioMenuFormat::dimensions)
.orElse("Object missing"));
size.addLore(ChatColor.GRAY + "Workcell capacity: " + dimensions(workcell.capacity()));
if (sizeEditable) {
size.addLore(ChatColor.YELLOW + "Left-click for width, height, and depth controls");
size.onLeftClick(clicked -> controller.openVariantSizeSettings(
window.getViewer(), state.requestId(), workcell.stableId(), variant.pieceKey()));
if (variant.resizableToCapacity()) {
size.addLore(ChatColor.YELLOW + "Shift-left: resize this variant to capacity");
size.onShiftLeftClick(clicked -> controller.resizeVariant(
window.getViewer(),
state.requestId(),
workcell.stableId(),
variant.pieceKey(),
workcell.capacity()));
}
}
window.setElement(2, 0, size);
boolean lastVariant = workcell.variants().size() <= 1;
boolean deletionAvailable = variant.owned() && !variant.active() && !lastVariant;
boolean confirmed = deletionAvailable && controller.isDeleteConfirmed(
window.getViewer().getUniqueId(),
state.requestId(),
workcell.stableId(),
variant.pieceKey());
UIElement delete = element(
"delete-variant",
deletionAvailable ? confirmed ? Material.REDSTONE : Material.LAVA_BUCKET : Material.BARRIER,
deletionAvailable
? confirmed ? ChatColor.RED + "Confirm Delete Variant" : ChatColor.RED + "Delete Variant"
: variant.active()
? ChatColor.GRAY + "Loaded Variant Cannot Be Deleted"
: lastVariant
? ChatColor.GRAY + "Last Variant Cannot Be Deleted"
: ChatColor.GRAY + "Deletion is Read-only")
.setEnchanted(confirmed);
if (deletionAvailable) {
delete.addLore(confirmed
? ChatColor.RED + "Click again to permanently delete this owned variant."
: ChatColor.RED + "Click twice within 10 seconds to confirm.");
delete.onLeftClick(clicked -> controller.confirmOrDeleteVariant(
window.getViewer(),
state.requestId(),
workcell.stableId(),
variant.pieceKey(),
page));
} else if (variant.active()) {
delete.addLore(ChatColor.GRAY + "Load another variant in this workcell first.");
} else if (lastVariant) {
delete.addLore(ChatColor.GRAY + "Create another variant before deleting this one.");
}
window.setElement(4, 0, delete);
}
private void addMemberships(
UIWindow window,
JigsawStudioMenuState state,
JigsawStudioMenuState.Workcell workcell,
JigsawStudioMenuState.Variant variant,
int page
) {
List<JigsawStudioMenuState.Membership> memberships = page(
variant.memberships(), page, MEMBERSHIPS_PER_PAGE);
if (memberships.isEmpty()) {
UIElement empty = element("no-memberships", Material.BARRIER, ChatColor.RED + "No Pool Entries");
empty.addLore(ChatColor.GRAY + "This variant is not linked from an owned pool.");
window.setElement(0, 2, empty);
return;
}
for (int index = 0; index < memberships.size(); index++) {
JigsawStudioMenuState.Membership membership = memberships.get(index);
int position = GRID_POSITIONS[index % GRID_POSITIONS.length];
int row = 1 + index / GRID_POSITIONS.length;
boolean confirmed = variant.active() && variant.owned() && controller.isUnlinkConfirmed(
window.getViewer().getUniqueId(),
state.requestId(),
workcell.stableId(),
variant.pieceKey(),
membership);
UIElement element = element(
"membership-" + index,
confirmed ? Material.REDSTONE : Material.GOLD_NUGGET,
(confirmed ? ChatColor.RED : ChatColor.GOLD)
+ safe(displayKey(membership.poolKey()))
+ " [" + membership.entryIndex() + "]")
.setEnchanted(confirmed);
element.addLore(ChatColor.DARK_GRAY + safe(membership.poolKey()));
element.addLore(ChatColor.GRAY + "Exact entry: " + membership.entryIndex());
element.addLore(ChatColor.WHITE + "Weight: " + membership.weight());
element.addLore((state.irisExtended() ? ChatColor.WHITE : ChatColor.GRAY)
+ "Chance: " + chance(membership.chance())
+ (state.irisExtended() ? "" : " (Iris only)"));
if (variant.active() && variant.owned()) {
element.addLore(ChatColor.GREEN + "Left-click: weight +1");
element.addLore(ChatColor.YELLOW + "Right-click: weight -1");
if (state.irisExtended()) {
element.addLore(ChatColor.GREEN + "Shift-left: chance +"
+ CHANCE_STEP_PERCENTAGE_POINTS + "%");
element.addLore(ChatColor.YELLOW + "Shift-right: chance -"
+ CHANCE_STEP_PERCENTAGE_POINTS + "%");
} else {
element.addLore(ChatColor.GRAY + "Vanilla-portable pool entries always have 100% chance.");
}
element.addLore(confirmed
? ChatColor.RED + "Middle-click again to unlink"
: ChatColor.RED + "Middle-click twice to unlink");
element.onLeftClick(clicked -> controller.adjustMembershipWeight(
window.getViewer(), state.requestId(), workcell.stableId(), membership, 1));
element.onRightClick(clicked -> controller.adjustMembershipWeight(
window.getViewer(), state.requestId(), workcell.stableId(), membership, -1));
if (state.irisExtended()) {
element.onShiftLeftClick(clicked -> controller.adjustMembershipChance(
window.getViewer(),
state.requestId(),
workcell.stableId(),
membership,
CHANCE_STEP_PERCENTAGE_POINTS));
element.onShiftRightClick(clicked -> controller.adjustMembershipChance(
window.getViewer(),
state.requestId(),
workcell.stableId(),
membership,
-CHANCE_STEP_PERCENTAGE_POINTS));
}
element.onMiddleClick(clicked -> controller.confirmOrUnlink(
window.getViewer(), state.requestId(), workcell.stableId(), membership, page));
} else {
element.addLore(variant.active()
? ChatColor.GRAY + "Read-only pool entry"
: ChatColor.GRAY + "Load this variant to edit its pool entry");
}
window.setElement(position, row, element);
}
}
private void addDetailsFooter(
UIWindow window,
JigsawStudioMenuState state,
JigsawStudioMenuState.Workcell workcell,
JigsawStudioMenuState.Variant variant,
int page
) {
UIElement back = element("footer-back", Material.ARROW, ChatColor.YELLOW + "Back");
back.onLeftClick(clicked -> controller.refreshMain(
window.getViewer(), state.requestId(), workcell.stableId(), 0));
window.setElement(-4, 5, back);
int pageCount = pageCount(variant.memberships().size(), MEMBERSHIPS_PER_PAGE);
if (page > 0) {
UIElement previous = element("membership-previous", Material.ARROW, ChatColor.YELLOW + "Previous Page");
previous.onLeftClick(clicked -> controller.refreshDetails(
window.getViewer(),
state.requestId(),
workcell.stableId(),
variant.pieceKey(),
page - 1));
window.setElement(-1, 5, previous);
}
UIElement indicator = element("membership-page", Material.BOOK, ChatColor.WHITE + "Page "
+ (page + 1) + "/" + pageCount);
indicator.addLore(ChatColor.GRAY + "" + variant.memberships().size() + " exact pool entries");
window.setElement(0, 5, indicator);
if (page + 1 < pageCount) {
UIElement next = element("membership-next", Material.ARROW, ChatColor.YELLOW + "Next Page");
next.onLeftClick(clicked -> controller.refreshDetails(
window.getViewer(),
state.requestId(),
workcell.stableId(),
variant.pieceKey(),
page + 1));
window.setElement(1, 5, next);
}
window.setElement(2, 5, evaluationElement(state.evaluation()));
UIElement toolbox = element("details-toolbox", Material.STICK, ChatColor.AQUA + "Toolbox");
toolbox.onLeftClick(clicked -> controller.openToolbox(
window.getViewer(), state.requestId(), workcell.stableId(), 0));
window.setElement(4, 5, toolbox);
}
}
@@ -0,0 +1,118 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioSession;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioVariant;
import art.arcane.iris.core.service.JigsawStudioService.ActiveStudio;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.inventory.Inventory;
import java.util.List;
import java.util.Objects;
import static art.arcane.iris.core.service.JigsawStudioAutosaveScheduler.AUTOSAVE_DEBOUNCE_TICKS;
import static art.arcane.iris.core.service.JigsawStudioProtection.inventoryBlocks;
final class JigsawStudioDirtyTracker {
private final JigsawStudioService service;
JigsawStudioDirtyTracker(JigsawStudioService service) {
this.service = Objects.requireNonNull(service, "Jigsaw Studio service");
}
boolean markDirty(World world, int worldX, int worldY, int worldZ) {
if (world == null) {
return false;
}
ActiveStudio studio = service.studios.get(world.getUID());
if (studio == null) {
return false;
}
if (service.saveLifecycle.reopenRequiredRequests.contains(studio.generator().getRequest().requestId())) {
return false;
}
JigsawStudioSession session = studio.generator().getSession();
JigsawStudioBay bay = session.layout().findAt(worldX, worldY, worldZ);
if (bay == null) {
return false;
}
JigsawStudioVariant activeVariant = session.activeVariant(bay.stableId()).orElse(null);
if (activeVariant == null || !activeVariant.owned()) {
return false;
}
JigsawStudioSession.DirtyMark dirtyMark = session.markWorkcellDirty(bay.stableId());
if (dirtyMark.status() != JigsawStudioSession.DirtyStatus.MARKED) {
return false;
}
service.autosaveScheduler.scheduleAutosave(
studio,
bay,
dirtyMark.identity().orElseThrow(),
AUTOSAVE_DEBOUNCE_TICKS);
service.markEvaluationStale(studio);
if (dirtyMark.newlyDirty()) {
service.playerContext.refreshWorkcellContext(studio.worldId(), bay.stableId());
}
return true;
}
int markAllDirty(World world) {
if (world == null) {
return 0;
}
ActiveStudio studio = service.studios.get(world.getUID());
return studio == null ? 0 : markAllDirty(studio);
}
void markDirty(Block block) {
if (block != null) {
markDirty(block.getWorld(), block.getX(), block.getY(), block.getZ());
}
}
void markDirty(List<Block> blocks) {
for (Block block : blocks) {
markDirty(block);
}
}
void markDirty(Inventory inventory) {
for (Block block : inventoryBlocks(inventory)) {
markDirty(block);
}
}
void markAllDirty() {
for (ActiveStudio studio : service.studios.values()) {
markAllDirty(studio);
}
}
int markAllDirty(ActiveStudio studio) {
int changed = 0;
JigsawStudioSession session = studio.generator().getSession();
for (JigsawStudioBay bay : session.layout().bays()) {
JigsawStudioVariant activeVariant = session.activeVariant(bay.stableId()).orElse(null);
if (activeVariant == null || !activeVariant.owned()) {
continue;
}
JigsawStudioSession.DirtyMark dirtyMark = session.markWorkcellDirty(bay.stableId());
if (dirtyMark.status() == JigsawStudioSession.DirtyStatus.MARKED) {
service.autosaveScheduler.scheduleAutosave(
studio,
bay,
dirtyMark.identity().orElseThrow(),
AUTOSAVE_DEBOUNCE_TICKS);
changed++;
}
}
if (changed > 0) {
service.markEvaluationStale(studio);
for (JigsawStudioBay bay : session.layout().bays()) {
service.playerContext.refreshWorkcellContext(studio.worldId(), bay.stableId());
}
}
return changed;
}
}
@@ -0,0 +1,308 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioActivation;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioLayout;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioMode;
import art.arcane.iris.core.service.JigsawStudioService.ActiveStudio;
import art.arcane.iris.engine.framework.PlacedStructurePiece;
import art.arcane.iris.engine.framework.StructureAssembler;
import art.arcane.iris.engine.framework.structure.StructureAssemblyResult;
import art.arcane.iris.engine.framework.structure.StructureGraphCompilation;
import art.arcane.iris.engine.framework.structure.StructureGraphCompiler;
import art.arcane.iris.engine.framework.structure.StructureGraphDiagnostic;
import art.arcane.iris.engine.framework.structure.StructureGraphResolver;
import art.arcane.iris.engine.object.IrisPosition;
import art.arcane.iris.engine.object.IrisStructure;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.util.common.scheduling.J;
import art.arcane.volmlib.util.math.RNG;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import static art.arcane.iris.core.service.JigsawStudioService.failureMessage;
final class JigsawStudioEvaluator {
private static final long PREVIEW_SEED = 1337L;
private static final int SPATIAL_PREVIEW_BASE_Y = JigsawStudioLayout.FLOOR_Y + 48;
private final JigsawStudioService service;
final Map<UUID, JigsawStudioGraphEvaluation> evaluations = new ConcurrentHashMap<>();
JigsawStudioEvaluator(JigsawStudioService service) {
this.service = Objects.requireNonNull(service, "Jigsaw Studio service");
}
void scheduleInitialEvaluation(ActiveStudio studio) {
if (studio == null) {
return;
}
JigsawStudioActivation.Request request = studio.generator().getRequest();
UUID requestId = request.requestId();
if (!claimInitialEvaluation(
studio.evaluationGeneration(),
service.isCurrentRequest(studio, requestId))) {
return;
}
publishEvaluation(studio, requestId, 1L);
}
static boolean claimInitialEvaluation(AtomicLong generation, boolean currentRequest) {
return currentRequest
&& Objects.requireNonNull(generation, "Jigsaw Studio evaluation generation")
.compareAndSet(0L, 1L);
}
void scheduleEvaluation(ActiveStudio studio) {
if (studio == null) {
return;
}
JigsawStudioActivation.Request request = studio.generator().getRequest();
UUID requestId = request.requestId();
if (!service.isCurrentRequest(studio, requestId)) {
return;
}
publishEvaluation(studio, requestId, studio.evaluationGeneration().incrementAndGet());
}
private void publishEvaluation(ActiveStudio studio, UUID requestId, long generation) {
JigsawStudioGraphEvaluation previous = evaluations.get(requestId);
JigsawStudioPreviewRenderer.PreviewBounds previousBounds = previous == null
? JigsawStudioPreviewRenderer.PreviewBounds.empty()
: previous.previewBounds();
evaluations.put(requestId, new JigsawStudioGraphEvaluation(
requestId,
generation,
PREVIEW_SEED,
JigsawStudioEvaluationState.PENDING,
previous == null ? "" : previous.selectedTheme(),
previous == null ? 0 : previous.pieceCount(),
"Compiling the committed graph and assembling seed 1337",
previousBounds));
service.playerContext.refreshAllWorkcellContexts(studio);
J.a(() -> evaluateGraph(studio, requestId, generation));
}
private void evaluateGraph(ActiveStudio studio, UUID requestId, long generation) {
EvaluationComputation computation;
try {
JigsawStudioActivation.Request request = studio.generator().getRequest();
IrisStructure structure = request.source().load(
IrisStructure.class,
request.structureKey(),
false);
if (structure == null) {
throw new IOException("The active structure resource no longer exists");
}
StructureGraphCompilation compilation = StructureGraphCompiler.compile(
structure,
StructureGraphResolver.forData(request.source()));
StructureGraphDiagnostic firstError = firstDiagnostic(
compilation,
StructureGraphDiagnostic.Severity.ERROR);
if (firstError != null) {
computation = invalidEvaluation(
requestId,
generation,
firstError.code() + ": " + firstError.message());
} else {
IrisPosition origin = previewOrigin(studio.generator().getLayout(), structure);
StructureAssembler assembler = StructureAssembler.forCompilation(compilation, origin);
StructureAssemblyResult assembly = assembler.assemble(new RNG(PREVIEW_SEED));
computation = evaluationForAssembly(
requestId,
generation,
compilation,
assembly,
studio.generator().getLayout().mode());
}
} catch (Throwable exception) {
IrisLogging.reportError(exception);
computation = invalidEvaluation(requestId, generation, failureMessage(exception));
}
completeEvaluation(studio, computation);
}
private EvaluationComputation evaluationForAssembly(
UUID requestId,
long generation,
StructureGraphCompilation compilation,
StructureAssemblyResult assembly,
JigsawStudioMode mode
) throws IOException {
if (assembly.status().isFailure()) {
return invalidEvaluation(
requestId,
generation,
assembly.status().name().toLowerCase(Locale.ROOT) + ": " + assembly.detail());
}
if (!assembly.hasOutput()) {
return new EvaluationComputation(
new JigsawStudioGraphEvaluation(
requestId,
generation,
PREVIEW_SEED,
JigsawStudioEvaluationState.WARNING,
assembly.selectedTheme(),
0,
assembly.detail().isEmpty()
? "Seed 1337 intentionally generated no structure"
: assembly.detail(),
JigsawStudioPreviewRenderer.PreviewBounds.empty()),
JigsawStudioPreviewRenderer.PreviewPlan.empty());
}
List<PlacedStructurePiece> aligned = alignPreviewPieces(assembly.pieces(), mode);
JigsawStudioPreviewRenderer.PreviewPlan plan = JigsawStudioPreviewRenderer.plan(aligned);
StructureGraphDiagnostic firstWarning = firstDiagnostic(
compilation,
StructureGraphDiagnostic.Severity.WARNING);
JigsawStudioEvaluationState state = firstWarning == null
? JigsawStudioEvaluationState.VALID
: JigsawStudioEvaluationState.WARNING;
String detail = firstWarning == null
? "Seed 1337 assembled " + aligned.size() + " piece(s)"
: firstWarning.code() + ": " + firstWarning.message();
return new EvaluationComputation(
new JigsawStudioGraphEvaluation(
requestId,
generation,
PREVIEW_SEED,
state,
assembly.selectedTheme(),
aligned.size(),
detail,
plan.bounds()),
plan);
}
private void completeEvaluation(ActiveStudio studio, EvaluationComputation computation) {
JigsawStudioGraphEvaluation evaluated = computation.evaluation();
UUID requestId = evaluated.requestId();
if (!isCurrentEvaluation(studio, requestId, evaluated.generation())) {
return;
}
service.previewRenderer.render(
studio.world(),
requestId,
evaluated.generation(),
computation.plan(),
result -> {
if (!isCurrentEvaluation(studio, requestId, evaluated.generation())) {
return;
}
JigsawStudioGraphEvaluation completed = result.successful()
? evaluated
: new JigsawStudioGraphEvaluation(
requestId,
evaluated.generation(),
PREVIEW_SEED,
JigsawStudioEvaluationState.WARNING,
evaluated.selectedTheme(),
evaluated.pieceCount(),
evaluated.detail() + "; " + result.failure(),
evaluated.previewBounds());
evaluations.put(requestId, completed);
service.playerContext.refreshAllWorkcellContexts(studio);
});
}
private boolean isCurrentEvaluation(ActiveStudio studio, UUID requestId, long generation) {
if (!service.isCurrentRequest(studio, requestId)
|| studio.evaluationGeneration().get() != generation) {
return false;
}
JigsawStudioGraphEvaluation current = evaluations.get(requestId);
return current != null && current.generation() == generation;
}
private static EvaluationComputation invalidEvaluation(
UUID requestId,
long generation,
String detail
) {
return new EvaluationComputation(
new JigsawStudioGraphEvaluation(
requestId,
generation,
PREVIEW_SEED,
JigsawStudioEvaluationState.INVALID,
"",
0,
detail,
JigsawStudioPreviewRenderer.PreviewBounds.empty()),
JigsawStudioPreviewRenderer.PreviewPlan.empty());
}
private static StructureGraphDiagnostic firstDiagnostic(
StructureGraphCompilation compilation,
StructureGraphDiagnostic.Severity severity
) {
for (StructureGraphDiagnostic diagnostic : compilation.getDiagnostics()) {
if (diagnostic.severity() == severity) {
return diagnostic;
}
}
return null;
}
private static IrisPosition previewOrigin(JigsawStudioLayout layout, IrisStructure structure) {
int radius = Math.max(1, structure.getMaxSizeChunks()) * 16;
return new IrisPosition(
-radius - 32,
0,
Math.max(16, layout.extentZ() / 2));
}
static List<PlacedStructurePiece> alignPreviewPieces(
List<PlacedStructurePiece> pieces,
JigsawStudioMode mode
) {
int minimumY = Integer.MAX_VALUE;
for (PlacedStructurePiece piece : pieces) {
minimumY = Math.min(minimumY, piece.getMinY());
}
int baseY = mode == JigsawStudioMode.SPATIAL_JIGSAW
? SPATIAL_PREVIEW_BASE_Y
: JigsawStudioLayout.FLOOR_Y + 1;
int shiftY = baseY - minimumY;
List<PlacedStructurePiece> aligned = new ArrayList<>(pieces.size());
for (PlacedStructurePiece piece : pieces) {
aligned.add(new PlacedStructurePiece(
piece.getPiece(),
piece.getObject(),
piece.getX(),
piece.getY() + shiftY,
piece.getZ(),
piece.getRotation(),
piece.getMinX(),
piece.getMinY() + shiftY,
piece.getMinZ(),
piece.getMaxX(),
piece.getMaxY() + shiftY,
piece.getMaxZ()));
}
return List.copyOf(aligned);
}
static IrisStructure loadStudioStructure(ActiveStudio studio) {
JigsawStudioActivation.Request request = studio.generator().getRequest();
return request.source().load(IrisStructure.class, request.structureKey(), false);
}
private record EvaluationComputation(
JigsawStudioGraphEvaluation evaluation,
JigsawStudioPreviewRenderer.PreviewPlan plan
) {
private EvaluationComputation {
Objects.requireNonNull(evaluation, "Jigsaw Studio graph evaluation");
Objects.requireNonNull(plan, "Jigsaw Studio evaluation preview plan");
}
}
}
@@ -0,0 +1,417 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.nms.INMS;
import art.arcane.iris.core.runtime.InPlaceChunkRegenerator;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioActivation;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBounds;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioGraphMapper;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioLayout;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioSession;
import art.arcane.iris.core.service.JigsawStudioService.ActiveStudio;
import art.arcane.iris.core.service.JigsawStudioService.CommandGraphMutation;
import art.arcane.iris.core.service.JigsawStudioService.CommandGraphMutationResult;
import art.arcane.iris.engine.data.chunk.TerrainChunk;
import art.arcane.iris.engine.object.IrisStructure;
import art.arcane.iris.engine.platform.studio.generators.JigsawStudioGenerator;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.util.common.scheduling.J;
import org.bukkit.Chunk;
import org.bukkit.World;
import org.bukkit.entity.Player;
import java.io.IOException;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static art.arcane.iris.core.service.JigsawStudioChunkWriter.applyRenderedBayChunk;
import static art.arcane.iris.core.service.JigsawStudioChunkWriter.verifyRenderedBayChunk;
import static art.arcane.iris.core.service.JigsawStudioProtection.authorizeOwner;
import static art.arcane.iris.core.service.JigsawStudioService.failureMessage;
import static art.arcane.iris.core.service.JigsawStudioService.message;
final class JigsawStudioGraphMutations {
private final JigsawStudioService service;
final Set<UUID> graphMutationsInProgress = new HashSet<>();
JigsawStudioGraphMutations(JigsawStudioService service) {
this.service = Objects.requireNonNull(service, "Jigsaw Studio service");
}
String beginGraphMutation(
ActiveStudio studio,
JigsawStudioSession session
) {
UUID requestId = studio.generator().getRequest().requestId();
service.tileWatcher.finalizeJigsawTileWatches(requestId);
synchronized (service.saveLifecycleLock) {
if (service.saveLifecycle.closingRequests.contains(requestId)) {
return "This Jigsaw Studio is closing and cannot update its graph.";
}
if (service.saveLifecycle.savesInProgress.contains(requestId)) {
return "Wait for the current Jigsaw Studio save to finish.";
}
if (graphMutationsInProgress.contains(requestId)) {
return "A Jigsaw Studio graph update is already running.";
}
if (service.saveLifecycle.exportsInProgress.contains(requestId)) {
return "Wait for the current Jigsaw Studio export to finish.";
}
if (service.materializer.materializationsInProgress.contains(requestId)) {
return "Wait for the current Jigsaw Studio variant load or rollback to finish.";
}
if (session.operationInProgress()) {
return "Wait for the current workcell operation to finish.";
}
if (service.saveLifecycle.reopenRequiredRequests.contains(requestId)) {
return "Close and reopen Jigsaw Studio before making another graph change.";
}
if (service.tileWatcher.hasJigsawTileWatch(requestId)) {
return "Finish or close the open vanilla jigsaw-block editor before changing graph metadata.";
}
if (session.isDirty()) {
return "Wait for every dirty workcell to finish autosaving before changing graph metadata.";
}
graphMutationsInProgress.add(requestId);
return "";
}
}
boolean runCommandGraphMutation(
Player player,
UUID expectedRequestId,
CommandGraphMutation task
) {
Objects.requireNonNull(expectedRequestId, "Jigsaw Studio command graph request ID");
Objects.requireNonNull(task, "Jigsaw Studio command graph mutation");
if (player == null) {
return false;
}
if (!J.isOwnedByCurrentRegion(player)) {
return J.runEntity(player, () -> runCommandGraphMutation(player, expectedRequestId, task));
}
ActiveStudio studio = service.studios.get(player.getWorld().getUID());
if (studio == null
|| !studio.generator().getRequest().requestId().equals(expectedRequestId)) {
message(player, "This Jigsaw Studio session is no longer active.");
return false;
}
if (!authorizeOwner(player, studio)) {
return false;
}
String reservationFailure = beginGraphMutation(studio, studio.generator().getSession());
if (!reservationFailure.isEmpty()) {
message(player, reservationFailure);
return false;
}
return scheduleGraphMutation(player, studio, task);
}
boolean scheduleGraphMutation(
Player player,
ActiveStudio studio,
CommandGraphMutation task
) {
UUID requestId = studio.generator().getRequest().requestId();
J.a(() -> {
CommandGraphMutationResult result = null;
Throwable failure = null;
try {
result = task.run();
} catch (Throwable exception) {
failure = exception;
}
CommandGraphMutationResult completedResult = result;
Throwable completedFailure = failure;
boolean scheduled = J.runEntity(
player,
() -> completeGraphMutation(
player,
studio,
requestId,
completedResult,
completedFailure),
0,
() -> finishGraphMutation(requestId));
if (!scheduled) {
finishGraphMutation(requestId);
if (completedFailure != null) {
IrisLogging.reportError(completedFailure);
}
}
});
return true;
}
private void completeGraphMutation(
Player player,
ActiveStudio studio,
UUID requestId,
CommandGraphMutationResult result,
Throwable failure
) {
try {
if (failure != null) {
IrisLogging.reportError(failure);
message(player, "Graph update failed: " + failureMessage(failure));
finishGraphMutation(requestId);
return;
}
if (result == null) {
message(player, "Graph update completed without a result; no Studio state changed.");
finishGraphMutation(requestId);
return;
}
if (!service.isCurrentRequest(studio, requestId)) {
message(player, "The graph updated on disk after this Studio session changed; reopen it to continue.");
finishGraphMutation(requestId);
return;
}
JigsawStudioSession session = studio.generator().getSession();
JigsawStudioLayout previousLayout = session.layout();
if (result.rebindActiveVariants().isEmpty()) {
session.replaceLayout(result.layout());
} else {
session.replaceLayoutAndRebind(result.layout(), result.rebindActiveVariants());
}
for (JigsawStudioBay workcell : result.layout().bays()) {
studio.generator().invalidateRender(workcell.stableId());
}
if (layoutGeometryChanged(previousLayout, result.layout())) {
scheduleLiveRelayout(player, studio, requestId, previousLayout, result);
return;
}
finishGraphMutationSuccess(player, studio, requestId, result);
} catch (Throwable exception) {
IrisLogging.reportError(exception);
message(player, "The graph updated on disk, but Studio could not refresh: "
+ failureMessage(exception) + ". Reopen this project before editing further.");
service.saveLifecycle.reopenRequiredRequests.add(requestId);
finishGraphMutation(requestId);
}
}
private void finishGraphMutationSuccess(
Player player,
ActiveStudio studio,
UUID requestId,
CommandGraphMutationResult result
) {
for (JigsawStudioBay workcell : result.layout().bays()) {
service.playerContext.refreshWorkcellContext(studio.worldId(), workcell.stableId());
}
service.evaluator.scheduleEvaluation(studio);
message(player, result.message());
finishGraphMutation(requestId);
if (!result.activatePieceKey().isEmpty()) {
service.variantEditor.switchVariant(
player,
result.activateWorkcellId(),
result.activatePieceKey(),
false);
} else if (result.reload().isPresent()) {
service.variantEditor.reloadActiveVariant(player, studio, result.reload().orElseThrow());
}
}
private void scheduleLiveRelayout(
Player player,
ActiveStudio studio,
UUID requestId,
JigsawStudioLayout previousLayout,
CommandGraphMutationResult result
) {
Set<Long> chunks = relayoutChunks(previousLayout, result.layout());
AtomicInteger remaining = new AtomicInteger(chunks.size());
AtomicReference<String> failure = new AtomicReference<>("");
AtomicReference<Throwable> cause = new AtomicReference<>();
if (chunks.isEmpty()) {
completeLiveRelayout(player, studio, requestId, result, "", null);
return;
}
for (long chunkKey : chunks) {
int chunkX = (int) (chunkKey >> 32);
int chunkZ = (int) chunkKey;
boolean scheduled = J.runRegion(studio.world(), chunkX, chunkZ, () -> {
try {
repaintStudioChunk(studio, chunkX, chunkZ);
} catch (Throwable exception) {
failure.compareAndSet("", "chunk " + chunkX + "," + chunkZ
+ " could not regenerate: " + failureMessage(exception));
cause.compareAndSet(null, exception);
}
if (remaining.decrementAndGet() == 0) {
scheduleLiveRelayoutCompletion(
player,
studio,
requestId,
result,
failure.get(),
cause.get());
}
});
if (!scheduled) {
failure.compareAndSet("", "chunk " + chunkX + "," + chunkZ
+ " could not be scheduled on its owning region");
if (remaining.decrementAndGet() == 0) {
scheduleLiveRelayoutCompletion(
player,
studio,
requestId,
result,
failure.get(),
cause.get());
}
}
}
}
private void scheduleLiveRelayoutCompletion(
Player player,
ActiveStudio studio,
UUID requestId,
CommandGraphMutationResult result,
String failure,
Throwable cause
) {
boolean scheduled = J.runEntity(
player,
() -> completeLiveRelayout(player, studio, requestId, result, failure, cause));
if (!scheduled) {
service.saveLifecycle.reopenRequiredRequests.add(requestId);
finishGraphMutation(requestId);
if (cause != null) {
IrisLogging.reportError(cause);
}
}
}
private void completeLiveRelayout(
Player player,
ActiveStudio studio,
UUID requestId,
CommandGraphMutationResult result,
String failure,
Throwable cause
) {
if (!failure.isEmpty() || !service.isCurrentRequest(studio, requestId)) {
service.saveLifecycle.reopenRequiredRequests.add(requestId);
studio.populations().clear();
if (cause != null) {
IrisLogging.reportError(cause);
}
message(player, "The workcell size was saved, but live regeneration failed"
+ (failure.isEmpty() ? "." : ": " + failure + ".")
+ " Close and reopen this project before editing further.");
finishGraphMutation(requestId);
return;
}
studio.populations().clear();
for (JigsawStudioBay workcell : result.layout().bays()) {
JigsawStudioGenerator.RenderedBay rendered = studio.generator().renderBay(workcell);
studio.replacePopulation(
workcell,
rendered,
rendered.valid() ? "" : rendered.failure(),
rendered.valid());
}
service.saveLifecycle.reopenRequiredRequests.remove(requestId);
finishGraphMutationSuccess(player, studio, requestId, result);
studio.generator().getSession().selectedBayId().ifPresent(workcellId -> service.teleportTo(player, workcellId));
}
static boolean layoutGeometryChanged(
JigsawStudioLayout previous,
JigsawStudioLayout current
) {
if (previous.bays().size() != current.bays().size()) {
return true;
}
for (JigsawStudioBay previousBay : previous.bays()) {
JigsawStudioBay currentBay = current.get(previousBay.stableId());
if (currentBay == null || !previousBay.bounds().equals(currentBay.bounds())) {
return true;
}
}
return false;
}
static Set<Long> relayoutChunks(
JigsawStudioLayout previous,
JigsawStudioLayout current
) {
Set<Long> chunks = new HashSet<>();
addRelayoutChunks(chunks, previous);
addRelayoutChunks(chunks, current);
return Set.copyOf(chunks);
}
private static void addRelayoutChunks(Set<Long> chunks, JigsawStudioLayout layout) {
for (JigsawStudioBay workcell : layout.bays()) {
JigsawStudioBounds bounds = workcell.bounds();
int minimumChunkX = (bounds.originX() - 1) >> 4;
int maximumChunkX = (bounds.maxX() + 1) >> 4;
int minimumChunkZ = (bounds.originZ() - 1) >> 4;
int maximumChunkZ = (bounds.maxZ() + 1) >> 4;
for (int chunkX = minimumChunkX; chunkX <= maximumChunkX; chunkX++) {
for (int chunkZ = minimumChunkZ; chunkZ <= maximumChunkZ; chunkZ++) {
chunks.add(JigsawStudioService.chunkKey(chunkX, chunkZ));
}
}
}
}
private static void repaintStudioChunk(ActiveStudio studio, int chunkX, int chunkZ) throws IOException {
World world = studio.world();
Chunk chunk = world.getChunkAt(chunkX, chunkZ);
TerrainChunk generated = TerrainChunk.create(world);
studio.generator().paintChunk(generated, chunkX, chunkZ);
if (!INMS.get().applyChunkBlocks(chunk, generated)) {
InPlaceChunkRegenerator.applyBlockDiffs(
chunk,
generated.getChunkData(),
world.getMinHeight(),
world.getMaxHeight());
}
for (JigsawStudioBay workcell : studio.generator().getLayout().bays()) {
JigsawStudioGenerator.RenderedBay rendered = studio.generator().renderBay(workcell);
if (!rendered.valid()) {
continue;
}
boolean connectorsVisible = studio.generator().getSession()
.workcellSnapshot(workcell.stableId())
.connectorsVisible();
applyRenderedBayChunk(world, workcell, rendered, chunkX, chunkZ, connectorsVisible);
verifyRenderedBayChunk(world, workcell, rendered, chunkX, chunkZ, connectorsVisible);
}
world.refreshChunk(chunkX, chunkZ);
}
boolean graphMutationInProgress(UUID requestId) {
synchronized (service.saveLifecycleLock) {
return graphMutationsInProgress.contains(requestId);
}
}
private void finishGraphMutation(UUID requestId) {
synchronized (service.saveLifecycleLock) {
graphMutationsInProgress.remove(requestId);
}
}
static JigsawStudioLayout loadMappedLayout(ActiveStudio studio) throws IOException {
JigsawStudioActivation.Request request = studio.generator().getRequest();
IrisData source = request.source();
source.invalidateStructureResources();
IrisStructure structure = source.load(IrisStructure.class, request.structureKey(), false);
if (structure == null) {
throw new IOException("The structure could not be reloaded after the graph transaction");
}
return JigsawStudioGraphMapper.map(source, structure);
}
}
@@ -12,6 +12,7 @@ import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
import art.arcane.iris.core.structure.authoring.StructureWriteOptions;
import art.arcane.iris.core.structure.authoring.StructureWriteResult;
import art.arcane.iris.engine.framework.structure.StructureResourceBundleGraphCompiler;
import art.arcane.iris.util.common.io.Durability;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
@@ -303,7 +304,7 @@ final class JigsawStudioHistoryStore {
while (buffer.hasRemaining()) {
channel.write(buffer);
}
channel.force(true);
Durability.force(channel);
}
try {
Files.move(
@@ -374,6 +375,10 @@ final class JigsawStudioHistoryStore {
}
private static void forceDirectory(Path directory) throws IOException {
if (!Durability.enabled()) {
return;
}
// A directory can only be opened and fsynced on a POSIX filesystem; Windows rejects the
// open outright. Matches DirectoryDurability and DatapackIngestService, which already
// skip the barrier there.
@@ -381,7 +386,7 @@ final class JigsawStudioHistoryStore {
return;
}
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
channel.force(true);
Durability.force(channel);
}
}
@@ -0,0 +1,286 @@
package art.arcane.iris.core.service;
import art.arcane.iris.util.common.scheduling.J;
import art.arcane.volmlib.util.inventorygui.UIElement;
import art.arcane.volmlib.util.inventorygui.UIWindow;
import org.bukkit.ChatColor;
import org.bukkit.Material;
import java.util.List;
import java.util.Objects;
import static art.arcane.iris.core.service.JigsawStudioMenuController.GRID_POSITIONS;
import static art.arcane.iris.core.service.JigsawStudioMenuController.VARIANTS_PER_PAGE;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.clampPage;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.compatibilityName;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.dimensions;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.element;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.evaluationElement;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.nextThemeSetKey;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.page;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.pageCount;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.safe;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.themes;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.variantMaterial;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.workcellMaterial;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.workcellStatus;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.yesNo;
final class JigsawStudioMainMenu {
private final JigsawStudioMenuController controller;
JigsawStudioMainMenu(JigsawStudioMenuController controller) {
this.controller = Objects.requireNonNull(controller, "Jigsaw Studio menu controller");
}
void render(
UIWindow window,
JigsawStudioMenuState state,
JigsawStudioMenuState.Workcell selected,
int requestedPage
) {
int page = clampPage(requestedPage, selected.variants().size(), VARIANTS_PER_PAGE);
window.batch(() -> {
window.clearElements();
addWorkcells(window, state, selected.stableId());
addFamilyControl(window, state);
addStructureControl(window, state, selected.stableId());
addVariants(window, state, selected, page);
addMainActions(window, state, selected, page);
});
}
private void addFamilyControl(UIWindow window, JigsawStudioMenuState state) {
String nextThemeKey = nextThemeSetKey(state.themeSets());
boolean available = state.irisExtended() && state.workcells().stream()
.filter(JigsawStudioMenuState.Workcell::enabled)
.allMatch(workcell -> workcell.activeVariant() != null && workcell.activeVariant().owned());
UIElement family = element(
"duplicate-family",
available ? Material.PURPLE_DYE : Material.GRAY_DYE,
available
? ChatColor.LIGHT_PURPLE + "Duplicate All Enabled Cells as Family"
: ChatColor.GRAY + "Duplicate All Enabled Cells as Family");
family.addLore(ChatColor.GRAY + "Copies each enabled cell's loaded variant into one coherent family.");
family.addLore(ChatColor.GRAY + "New family: " + safe(nextThemeKey));
if (available) {
family.addLore(ChatColor.YELLOW + "Left-click to duplicate the complete family atomically");
family.onLeftClick(clicked -> controller.duplicateActiveFamily(
window.getViewer(), state.requestId(), nextThemeKey));
} else if (!state.irisExtended()) {
family.addLore(ChatColor.GRAY + "Coherent families require Iris compatibility.");
} else {
family.addLore(ChatColor.GRAY + "Load an owned variant in every enabled workcell first.");
}
window.setElement(3, 0, family);
}
private void addStructureControl(
UIWindow window,
JigsawStudioMenuState state,
String selectedWorkcellId
) {
UIElement structure = element(
"structure-rules",
state.irisExtended()
? state.requireCaps() ? Material.IRON_BARS : Material.TRIPWIRE_HOOK
: Material.BARRIER,
ChatColor.LIGHT_PURPLE + "Structure Rules");
structure.addLore(ChatColor.GRAY + "Mandatory caps: " + yesNo(state.requireCaps()));
structure.addLore(ChatColor.GRAY + "Theme sets: " + state.themeSets().size());
structure.addLore(ChatColor.GRAY + "Compatibility: " + compatibilityName(state.compatibilityTarget()));
structure.addLore(ChatColor.YELLOW + "Left-click for themes and rules");
structure.addLore(state.irisExtended()
? ChatColor.YELLOW + "Right-click to toggle mandatory caps"
: ChatColor.GRAY + "Mandatory caps and themes require Iris compatibility");
structure.onLeftClick(clicked -> controller.openStructureSettings(
window.getViewer(), state.requestId(), selectedWorkcellId, 0));
if (state.irisExtended()) {
structure.onRightClick(clicked -> controller.setRequireCaps(
window.getViewer(), state.requestId(), !state.requireCaps()));
}
window.setElement(4, 0, structure);
}
private void addWorkcells(UIWindow window, JigsawStudioMenuState state, String selectedWorkcellId) {
int count = state.workcells().size();
int start = count == 1 ? 0 : -(count / 2);
for (int index = 0; index < count; index++) {
JigsawStudioMenuState.Workcell workcell = state.workcells().get(index);
boolean selected = workcell.stableId().equals(selectedWorkcellId);
UIElement element = element(
"workcell-" + index,
workcellMaterial(workcell, selected),
(selected ? ChatColor.AQUA : workcell.enabled() ? ChatColor.WHITE : ChatColor.GRAY)
+ safe(workcell.displayName()))
.setEnchanted(selected);
element.addLore(workcell.enabled()
? ChatColor.GREEN + "Enabled"
: ChatColor.RED + "Disabled for assembly and export");
if (!workcell.canonicalName().equals(workcell.displayName())) {
element.addLore(ChatColor.GRAY + "Solver role: " + safe(workcell.canonicalName()));
}
element.addLore(ChatColor.GRAY + "Capacity: " + dimensions(workcell.capacity()));
element.addLore(ChatColor.GRAY + "Variants: " + workcell.variants().size());
JigsawStudioMenuState.Variant active = workcell.activeVariant();
element.addLore(ChatColor.GRAY + "Loaded: "
+ (active == null ? "None" : safe(active.displayName())));
element.addLore(workcellStatus(workcell));
element.addLore(ChatColor.GRAY + "Connector blocks: "
+ (workcell.connectorsVisible() ? "Visible" : "Hidden"));
element.addLore(ChatColor.YELLOW + "Left-click to select and teleport");
element.addLore(ChatColor.YELLOW + "Right-click for workcell settings");
if (workcell.dirty() && !workcell.saving()) {
element.addLore(ChatColor.GOLD + "Shift-left: Flush Autosave Now");
element.onShiftLeftClick(clicked -> controller.flushNow(
window.getViewer(), state.requestId(), workcell.stableId()));
}
element.onLeftClick(clicked -> controller.selectWorkcell(
window.getViewer(),
state.requestId(),
workcell.stableId()));
element.onRightClick(clicked -> J.runEntity(
window.getViewer(),
() -> controller.openWorkcellSettings(
window.getViewer(),
state.requestId(),
workcell.stableId()),
1));
window.setElement(start + index, 0, element);
}
}
private void addVariants(
UIWindow window,
JigsawStudioMenuState state,
JigsawStudioMenuState.Workcell workcell,
int page
) {
List<JigsawStudioMenuState.Variant> variants = page(
workcell.variants(), page, VARIANTS_PER_PAGE);
if (variants.isEmpty()) {
UIElement empty = element("no-variants", Material.BARRIER, ChatColor.RED + "No variants");
empty.addLore(ChatColor.GRAY + "Create a variant to begin authoring this workcell.");
window.setElement(0, 2, empty);
return;
}
for (int index = 0; index < variants.size(); index++) {
JigsawStudioMenuState.Variant variant = variants.get(index);
int position = GRID_POSITIONS[index % GRID_POSITIONS.length];
int row = 1 + index / GRID_POSITIONS.length;
UIElement element = element(
"variant-" + index,
variantMaterial(variant),
(variant.active() ? ChatColor.GREEN : ChatColor.WHITE)
+ safe(variant.displayName())
+ (variant.active() ? ChatColor.AQUA + " [Loaded]" : ""))
.setEnchanted(variant.active());
element.addLore(ChatColor.DARK_GRAY + safe(variant.pieceKey()));
element.addLore(variant.owned()
? ChatColor.GREEN + "Owned"
: ChatColor.GRAY + "Read-only");
element.addLore(ChatColor.GRAY + "Rotation: " + (variant.rotatable() ? "Enabled" : "Disabled"));
element.addLore(ChatColor.GRAY + "Size: " + variant.dimensions()
.map(JigsawStudioMenuFormat::dimensions)
.orElse("Object missing"));
element.addLore(ChatColor.GRAY + "Themes: " + themes(variant.themes()));
element.addLore(ChatColor.GRAY + "Pool entries: " + variant.memberships().size());
element.addLore(variant.active()
? ChatColor.YELLOW + "Loaded; right-click for details"
: ChatColor.YELLOW + "Left-click to load");
if (!variant.active()) {
element.addLore(ChatColor.YELLOW + "Right-click for details or deletion");
}
element.onLeftClick(clicked -> controller.switchVariant(
window.getViewer(),
state.requestId(),
workcell.stableId(),
variant.pieceKey()));
element.onRightClick(clicked -> J.runEntity(
window.getViewer(),
() -> controller.openDetails(
window.getViewer(),
state.requestId(),
workcell.stableId(),
variant.pieceKey(),
0),
1));
window.setElement(position, row, element);
}
}
private void addMainActions(
UIWindow window,
JigsawStudioMenuState state,
JigsawStudioMenuState.Workcell workcell,
int page
) {
UIElement create = element("create", Material.NETHER_STAR, ChatColor.GREEN + "New Blank Variant");
create.addLore(ChatColor.GRAY + "Create an empty owned variant with this cell's current size.");
create.onLeftClick(clicked -> controller.createVariant(
window.getViewer(), state.requestId(), workcell.stableId(), false));
window.setElement(-4, 5, create);
JigsawStudioMenuState.Variant active = workcell.activeVariant();
boolean duplicateAvailable = active != null && active.owned();
UIElement duplicate = element(
"duplicate",
duplicateAvailable ? Material.PAPER : Material.GRAY_DYE,
duplicateAvailable
? ChatColor.AQUA + "Duplicate This Cell's Variant"
: active == null
? ChatColor.GRAY + "Duplicate This Cell's Variant"
: ChatColor.GRAY + "Duplicate This Cell's Variant");
duplicate.addLore(duplicateAvailable
? ChatColor.GRAY + "Clone the loaded variant into an owned variant."
: active == null
? ChatColor.GRAY + "Load a variant before duplicating it."
: ChatColor.GRAY + "Adopt the read-only graph before duplicating it.");
if (duplicateAvailable) {
duplicate.onLeftClick(clicked -> controller.createVariant(
window.getViewer(), state.requestId(), workcell.stableId(), true));
}
window.setElement(-3, 5, duplicate);
UIElement settings = element("settings", Material.CRAFTING_TABLE, ChatColor.GOLD + "Workcell Settings");
settings.addLore(ChatColor.GRAY + "Enabled: " + yesNo(workcell.enabled()));
settings.addLore(ChatColor.GRAY + "Capacity: " + dimensions(workcell.capacity()));
settings.onLeftClick(clicked -> controller.openWorkcellSettings(
window.getViewer(), state.requestId(), workcell.stableId()));
window.setElement(-2, 5, settings);
int pageCount = pageCount(workcell.variants().size(), VARIANTS_PER_PAGE);
if (page > 0) {
UIElement previous = element("previous", Material.ARROW, ChatColor.YELLOW + "Previous Page");
previous.onLeftClick(clicked -> controller.refreshMain(
window.getViewer(), state.requestId(), workcell.stableId(), page - 1));
window.setElement(-1, 5, previous);
}
UIElement pageIndicator = element("page", Material.BOOK, ChatColor.WHITE + "Page "
+ (page + 1) + "/" + pageCount);
pageIndicator.addLore(ChatColor.GRAY + "" + workcell.variants().size() + " variants");
window.setElement(0, 5, pageIndicator);
if (page + 1 < pageCount) {
UIElement next = element("next", Material.ARROW, ChatColor.YELLOW + "Next Page");
next.onLeftClick(clicked -> controller.refreshMain(
window.getViewer(), state.requestId(), workcell.stableId(), page + 1));
window.setElement(1, 5, next);
}
window.setElement(2, 5, evaluationElement(state.evaluation()));
UIElement preview = element("preview", Material.ENDER_EYE, ChatColor.LIGHT_PURPLE + "Go to Preview");
preview.addLore(ChatColor.GRAY + "Open the deterministic assembly preview.");
preview.onLeftClick(clicked -> controller.goToPreview(window.getViewer(), state.requestId()));
window.setElement(3, 5, preview);
UIElement toolbox = element("toolbox", Material.STICK, ChatColor.AQUA + "Toolbox");
toolbox.addLore(ChatColor.GRAY + "Take named sticks bound to this Studio session.");
toolbox.onLeftClick(clicked -> controller.openToolbox(
window.getViewer(), state.requestId(), workcell.stableId(), 0));
window.setElement(4, 5, toolbox);
}
}
@@ -0,0 +1,632 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBounds;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioSession;
import art.arcane.iris.core.service.JigsawStudioCapture.ChunkCaptureArea;
import art.arcane.iris.core.service.JigsawStudioCapture.LocalPosition;
import art.arcane.iris.core.service.JigsawStudioService.ActiveStudio;
import art.arcane.iris.engine.platform.studio.generators.JigsawStudioGenerator;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.util.common.scheduling.J;
import org.bukkit.World;
import org.bukkit.entity.Player;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static art.arcane.iris.core.service.JigsawStudioCapture.chunkIntersections;
import static art.arcane.iris.core.service.JigsawStudioChunkWriter.hydrateChunk;
import static art.arcane.iris.core.service.JigsawStudioChunkWriter.restoreConnectorChunk;
import static art.arcane.iris.core.service.JigsawStudioChunkWriter.validateMaterialization;
import static art.arcane.iris.core.service.JigsawStudioChunkWriter.writeMaterializedChunk;
import static art.arcane.iris.core.service.JigsawStudioService.chunkKey;
import static art.arcane.iris.core.service.JigsawStudioService.failureMessage;
import static art.arcane.iris.core.service.JigsawStudioService.message;
final class JigsawStudioMaterializer {
private static final int HYDRATION_RETRY_TICKS = 2;
private static final int MAX_HYDRATION_ATTEMPTS = 40;
private final JigsawStudioService service;
final Set<UUID> materializationsInProgress = new HashSet<>();
JigsawStudioMaterializer(JigsawStudioService service) {
this.service = Objects.requireNonNull(service, "Jigsaw Studio service");
}
boolean scheduleMaterialization(MaterializationWork work) {
String validationFailure = validateMaterialization(work.target());
if (!validationFailure.isEmpty()) {
work.studio().generator().getSession().abortVariantSwitch(work.token());
service.playerContext.refreshWorkcellContext(work.studio().worldId(), work.workcell().stableId());
message(work.player(), "Variant '" + work.token().targetVariant().pieceKey()
+ "' cannot load: " + validationFailure);
return false;
}
String reservationFailure = beginMaterialization(work);
if (!reservationFailure.isEmpty()) {
work.studio().generator().getSession().abortVariantSwitch(work.token());
service.playerContext.refreshWorkcellContext(work.studio().worldId(), work.workcell().stableId());
message(work.player(), reservationFailure);
return false;
}
List<ChunkCaptureArea> areas = chunkIntersections(work.workcell().bounds());
MaterializationCoordinator coordinator = new MaterializationCoordinator(work, areas);
for (ChunkCaptureArea area : areas) {
boolean scheduled = J.runRegion(
work.world(),
area.chunkX(),
area.chunkZ(),
() -> materializeCandidateChunk(coordinator, area));
if (!scheduled) {
coordinator.candidateComplete(
area,
"Iris could not schedule workcell chunk "
+ area.chunkX() + "," + area.chunkZ() + " on its owning region.",
null);
} else {
coordinator.markScheduled();
}
}
return coordinator.scheduledAny();
}
private String beginMaterialization(MaterializationWork work) {
UUID requestId = work.studio().generator().getRequest().requestId();
synchronized (service.saveLifecycleLock) {
if (!isCurrentVariantSwitch(work)) {
return "The Jigsaw Studio changed before the variant load could start.";
}
if (service.saveLifecycle.closingRequests.contains(requestId)) {
return "This Jigsaw Studio is closing and cannot load another variant.";
}
if (service.saveLifecycle.savesInProgress.contains(requestId)) {
return "Wait for the current Jigsaw Studio save to finish.";
}
if (service.graphMutations.graphMutationsInProgress.contains(requestId)) {
return "Wait for the current Jigsaw Studio graph update to finish.";
}
if (service.saveLifecycle.exportsInProgress.contains(requestId)) {
return "Wait for the current Jigsaw Studio export to finish.";
}
if (service.tileWatcher.hasJigsawTileWatch(requestId)) {
return "Finish or close the open vanilla jigsaw-block editor before loading another variant.";
}
if (!materializationsInProgress.add(requestId)) {
return "Another Jigsaw Studio variant load or rollback is already running.";
}
return "";
}
}
String beginConnectorRepair(ActiveStudio studio) {
UUID requestId = studio.generator().getRequest().requestId();
service.tileWatcher.finalizeJigsawTileWatches(requestId);
synchronized (service.saveLifecycleLock) {
if (!service.isCurrentRequest(studio, requestId)) {
return "This Jigsaw Studio session is no longer active.";
}
if (service.saveLifecycle.closingRequests.contains(requestId)) {
return "This Jigsaw Studio is closing and cannot reset connector blocks.";
}
if (service.saveLifecycle.savesInProgress.contains(requestId)) {
return "Wait for the current Jigsaw Studio save to finish.";
}
if (service.graphMutations.graphMutationsInProgress.contains(requestId)) {
return "Wait for the current Jigsaw Studio graph update to finish.";
}
if (service.saveLifecycle.exportsInProgress.contains(requestId)) {
return "Wait for the current Jigsaw Studio export to finish.";
}
if (studio.generator().getSession().operationInProgress()) {
return "Wait for the current workcell operation to finish.";
}
if (service.tileWatcher.hasJigsawTileWatch(requestId)) {
return "Finish or close the open vanilla jigsaw-block editor before resetting connectors.";
}
if (!materializationsInProgress.add(requestId)) {
return "Another Jigsaw Studio variant load or repair is already running.";
}
return "";
}
}
boolean scheduleConnectorRepair(
Player player,
ActiveStudio studio,
JigsawStudioBay workcell,
JigsawStudioGenerator.RenderedBay rendered,
boolean connectorsVisible
) {
Map<Long, List<JigsawStudioGenerator.RenderedConnector>> connectorsByChunk = new HashMap<>();
JigsawStudioBounds bounds = workcell.bounds();
for (JigsawStudioGenerator.RenderedConnector connector : rendered.connectors()) {
int worldX = bounds.originX() + connector.x();
int worldZ = bounds.originZ() + connector.z();
connectorsByChunk.computeIfAbsent(
chunkKey(worldX >> 4, worldZ >> 4),
ignored -> new ArrayList<>()).add(connector);
}
AtomicInteger remaining = new AtomicInteger(connectorsByChunk.size());
AtomicReference<String> failure = new AtomicReference<>("");
AtomicReference<Throwable> cause = new AtomicReference<>();
AtomicBoolean scheduledAny = new AtomicBoolean(false);
Map<LocalPosition, JigsawStudioGenerator.RenderedBlock> renderedBlocks = new HashMap<>();
for (JigsawStudioGenerator.RenderedBlock block : rendered.blocks()) {
renderedBlocks.put(new LocalPosition(block.x(), block.y(), block.z()), block);
}
for (Map.Entry<Long, List<JigsawStudioGenerator.RenderedConnector>> entry
: connectorsByChunk.entrySet()) {
int chunkX = (int) (entry.getKey() >> 32);
int chunkZ = (int) entry.getKey().longValue();
boolean scheduled = J.runRegion(studio.world(), chunkX, chunkZ, () -> {
try {
if (!studio.world().isChunkLoaded(chunkX, chunkZ)) {
throw new IOException("connector chunk " + chunkX + "," + chunkZ
+ " is not loaded");
}
restoreConnectorChunk(
studio.world(),
workcell,
entry.getValue(),
renderedBlocks,
connectorsVisible);
} catch (Throwable exception) {
failure.compareAndSet("", failureMessage(exception));
cause.compareAndSet(null, exception);
}
if (remaining.decrementAndGet() == 0) {
completeConnectorRepair(
player,
studio,
rendered.connectors().size(),
failure.get(),
cause.get());
}
});
if (scheduled) {
scheduledAny.set(true);
} else {
failure.compareAndSet("", "connector chunk " + chunkX + "," + chunkZ
+ " could not be scheduled on its owning region");
if (remaining.decrementAndGet() == 0) {
completeConnectorRepair(
player,
studio,
rendered.connectors().size(),
failure.get(),
cause.get());
}
}
}
if (!scheduledAny.get()) {
finishMaterialization(studio.generator().getRequest().requestId());
}
return scheduledAny.get();
}
private void completeConnectorRepair(
Player player,
ActiveStudio studio,
int connectorCount,
String failure,
Throwable cause
) {
UUID requestId = studio.generator().getRequest().requestId();
boolean scheduled = J.runEntity(player, () -> {
finishMaterialization(requestId);
if (cause != null) {
IrisLogging.reportError(cause);
}
if (!failure.isEmpty()) {
message(player, "Connector reset was incomplete: " + failure + ". Retry after visiting the workcell.");
return;
}
message(player, "Restored " + connectorCount
+ " connector block(s) from the last saved iteration.");
});
if (!scheduled) {
finishMaterialization(requestId);
if (cause != null) {
IrisLogging.reportError(cause);
}
}
}
private void finishMaterialization(UUID requestId) {
synchronized (service.saveLifecycleLock) {
materializationsInProgress.remove(requestId);
}
}
private void materializeCandidateChunk(
MaterializationCoordinator coordinator,
ChunkCaptureArea area
) {
MaterializationWork work = coordinator.work();
if (!isCurrentVariantSwitch(work)) {
coordinator.candidateComplete(
area,
"Jigsaw Studio changed before the variant could finish loading.",
null);
return;
}
if (!work.world().isChunkLoaded(area.chunkX(), area.chunkZ())) {
coordinator.candidateComplete(
area,
"Workcell chunk " + area.chunkX() + "," + area.chunkZ()
+ " is not loaded. Visit the whole workcell and try again.",
null);
return;
}
try {
coordinator.markCandidateTouched(area);
writeMaterializedChunk(
work.world(),
work.workcell(),
work.target(),
area,
work.targetConnectorsVisible());
coordinator.candidateComplete(area, "", null);
} catch (Throwable exception) {
coordinator.candidateComplete(
area,
"Variant materialization failed in chunk "
+ area.chunkX() + "," + area.chunkZ() + ": " + failureMessage(exception),
exception);
}
}
private void materializeRollbackChunk(
MaterializationCoordinator coordinator,
ChunkCaptureArea area
) {
MaterializationWork work = coordinator.work();
if (!isRollbackVariantSwitch(work)) {
coordinator.rollbackComplete(
area,
"Jigsaw Studio changed before the previous variant could be restored.",
null);
return;
}
if (!work.world().isChunkLoaded(area.chunkX(), area.chunkZ())) {
coordinator.rollbackComplete(
area,
"Rollback chunk " + area.chunkX() + "," + area.chunkZ() + " is not loaded.",
null);
return;
}
try {
writeMaterializedChunk(
work.world(),
work.workcell(),
work.previous(),
area,
work.previousConnectorsVisible());
coordinator.rollbackComplete(area, "", null);
} catch (Throwable exception) {
coordinator.rollbackComplete(
area,
"Rollback failed in chunk " + area.chunkX() + "," + area.chunkZ()
+ ": " + failureMessage(exception),
exception);
}
}
private boolean isCurrentVariantSwitch(MaterializationWork work) {
return service.isCurrentRequest(
work.studio(),
work.studio().generator().getRequest().requestId())
&& work.studio().generator().getSession().isVariantSwitchCurrent(work.token());
}
private boolean isRollbackVariantSwitch(MaterializationWork work) {
return service.studios.get(work.studio().worldId()) == work.studio()
&& service.protection.materializationInProgress(work.studio());
}
void scheduleHydration(
ActiveStudio studio,
World world,
int chunkX,
int chunkZ,
int attempt
) {
if (!service.enabled || service.studios.get(world.getUID()) != studio) {
return;
}
long key = chunkKey(chunkX, chunkZ);
if (!studio.hydrationsInProgress().add(key)) {
return;
}
boolean scheduled = J.runRegion(
world,
chunkX,
chunkZ,
() -> runHydrationAttempt(studio, world, chunkX, chunkZ, attempt, key),
HYDRATION_RETRY_TICKS);
if (!scheduled) {
studio.hydrationsInProgress().remove(key);
}
}
private void runHydrationAttempt(
ActiveStudio studio,
World world,
int chunkX,
int chunkZ,
int attempt,
long key
) {
studio.hydrationsInProgress().remove(key);
if (!service.enabled || service.studios.get(world.getUID()) != studio) {
return;
}
if (!world.isChunkLoaded(chunkX, chunkZ)) {
if (attempt < MAX_HYDRATION_ATTEMPTS) {
scheduleHydration(studio, world, chunkX, chunkZ, attempt + 1);
}
return;
}
if (hydrateChunk(studio, world, chunkX, chunkZ)) {
scheduleHydration(studio, world, chunkX, chunkZ, 0);
}
}
private final class MaterializationCoordinator {
private final MaterializationWork work;
private final List<ChunkCaptureArea> areas;
private final Set<Long> candidateCompleted = new HashSet<>();
private final Set<Long> candidateTouched = new HashSet<>();
private final Set<Long> rollbackCompleted = new HashSet<>();
private int candidateRemaining;
private int rollbackRemaining;
private String candidateFailure = "";
private String rollbackFailure = "";
private boolean rollbackStarted;
private boolean finished;
private boolean leaseReleased;
private boolean scheduledAny;
private MaterializationCoordinator(
MaterializationWork work,
List<ChunkCaptureArea> areas
) {
this.work = Objects.requireNonNull(work, "Jigsaw Studio materialization work");
this.areas = List.copyOf(areas);
candidateRemaining = this.areas.size();
}
private MaterializationWork work() {
return work;
}
private synchronized void markScheduled() {
scheduledAny = true;
}
private synchronized boolean scheduledAny() {
return scheduledAny;
}
private synchronized void markCandidateTouched(ChunkCaptureArea area) {
candidateTouched.add(chunkKey(area.chunkX(), area.chunkZ()));
}
private void candidateComplete(
ChunkCaptureArea area,
String failure,
Throwable exception
) {
boolean finishSuccess = false;
boolean startRollback = false;
synchronized (this) {
if (finished || rollbackStarted
|| !candidateCompleted.add(chunkKey(area.chunkX(), area.chunkZ()))) {
return;
}
if (failure != null && !failure.isBlank() && candidateFailure.isEmpty()) {
candidateFailure = failure;
}
candidateRemaining--;
if (candidateRemaining == 0) {
if (candidateFailure.isEmpty()) {
finishSuccess = true;
} else {
rollbackStarted = true;
startRollback = true;
}
}
}
if (exception != null) {
IrisLogging.reportError(exception);
}
if (finishSuccess) {
finishSuccess();
} else if (startRollback) {
scheduleRollback();
}
}
private void finishSuccess() {
JigsawStudioSession session = work.studio().generator().getSession();
if (!isCurrentVariantSwitch(work) || !session.completeVariantSwitch(work.token())) {
beginLateRollback("Jigsaw Studio changed before the loaded variant could be activated.");
return;
}
if (work.connectorVisibilityChange()) {
session.setConnectorsVisible(work.workcell().stableId(), work.targetConnectorsVisible());
}
synchronized (this) {
if (finished) {
return;
}
finished = true;
}
try {
work.studio().generator().invalidateRender(work.workcell().stableId());
work.studio().replacePopulation(work.workcell(), work.target(), "", true);
service.playerContext.refreshWorkcellContext(work.studio().worldId(), work.workcell().stableId());
message(work.player(), work.connectorVisibilityChange()
? "Connector blocks are now "
+ (work.targetConnectorsVisible() ? "visible." : "hidden.")
: "Loaded variant '" + work.token().targetVariant().pieceKey()
+ "' into " + work.workcell().stableId() + ".");
} finally {
releaseLease();
}
}
private void beginLateRollback(String failure) {
synchronized (this) {
if (finished || rollbackStarted) {
return;
}
candidateFailure = failure;
rollbackStarted = true;
}
scheduleRollback();
}
private void scheduleRollback() {
List<ChunkCaptureArea> rollbackAreas = new ArrayList<>();
synchronized (this) {
for (ChunkCaptureArea area : areas) {
if (candidateTouched.contains(chunkKey(area.chunkX(), area.chunkZ()))) {
rollbackAreas.add(area);
}
}
rollbackRemaining = rollbackAreas.size();
if (rollbackRemaining == 0) {
finished = true;
}
}
if (rollbackAreas.isEmpty()) {
finishRollback();
return;
}
for (ChunkCaptureArea area : rollbackAreas) {
boolean scheduled = J.runRegion(
work.world(),
area.chunkX(),
area.chunkZ(),
() -> materializeRollbackChunk(this, area));
if (!scheduled) {
rollbackComplete(
area,
"Iris could not schedule rollback for workcell chunk "
+ area.chunkX() + "," + area.chunkZ() + ".",
null);
}
}
}
private void rollbackComplete(
ChunkCaptureArea area,
String failure,
Throwable exception
) {
boolean finalizeRollback = false;
synchronized (this) {
if (finished || !rollbackStarted
|| !rollbackCompleted.add(chunkKey(area.chunkX(), area.chunkZ()))) {
return;
}
if (failure != null && !failure.isBlank() && rollbackFailure.isEmpty()) {
rollbackFailure = failure;
}
rollbackRemaining--;
if (rollbackRemaining == 0) {
finished = true;
finalizeRollback = true;
}
}
if (exception != null) {
IrisLogging.reportError(exception);
}
if (finalizeRollback) {
finishRollback();
}
}
private void finishRollback() {
JigsawStudioSession session = work.studio().generator().getSession();
boolean released = session.abortVariantSwitch(work.token());
try {
work.studio().generator().invalidateRender(work.workcell().stableId());
if (rollbackFailure.isEmpty() && released) {
work.studio().replacePopulation(work.workcell(), work.previous(), "", true);
service.playerContext.refreshWorkcellContext(work.studio().worldId(), work.workcell().stableId());
message(work.player(), candidateFailure + " The previous variant was restored.");
return;
}
String failure = rollbackFailure.isEmpty()
? "The previous variant could not be restored because the Studio session changed."
: rollbackFailure;
work.studio().replacePopulation(work.workcell(), work.previous(), failure, false);
service.playerContext.refreshWorkcellContext(work.studio().worldId(), work.workcell().stableId());
message(work.player(), candidateFailure + " Automatic rollback also failed: " + failure
+ " Do not save this workcell until it is reopened.");
} finally {
releaseLease();
}
}
private void releaseLease() {
synchronized (this) {
if (leaseReleased) {
return;
}
leaseReleased = true;
}
finishMaterialization(work.studio().generator().getRequest().requestId());
}
}
record MaterializationWork(
ActiveStudio studio,
World world,
Player player,
JigsawStudioBay workcell,
JigsawStudioSession.VariantSwitchToken token,
JigsawStudioGenerator.RenderedBay previous,
JigsawStudioGenerator.RenderedBay target,
boolean previousConnectorsVisible,
boolean targetConnectorsVisible,
boolean connectorVisibilityChange
) {
MaterializationWork {
Objects.requireNonNull(studio, "Jigsaw Studio materialization studio");
Objects.requireNonNull(world, "Jigsaw Studio materialization world");
Objects.requireNonNull(player, "Jigsaw Studio materialization player");
Objects.requireNonNull(workcell, "Jigsaw Studio materialization workcell");
Objects.requireNonNull(token, "Jigsaw Studio materialization token");
Objects.requireNonNull(previous, "Jigsaw Studio previous rendered variant");
Objects.requireNonNull(target, "Jigsaw Studio target rendered variant");
}
}
public record VariantReloadRequest(
String workcellId,
JigsawStudioGenerator.RenderedBay previous
) {
public VariantReloadRequest {
workcellId = workcellId == null ? "" : workcellId.trim();
if (workcellId.isEmpty()) {
throw new IllegalArgumentException("Jigsaw Studio reload workcell ID cannot be blank");
}
Objects.requireNonNull(previous, "Jigsaw Studio previous rendered variant");
}
}
}
@@ -0,0 +1,111 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioPieceRules;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioToolPayload;
import org.bukkit.entity.Player;
import java.util.List;
import java.util.Optional;
public interface JigsawStudioMenuActions {
Optional<JigsawStudioMenuState> menuState(Player player);
boolean selectWorkcell(Player player, String workcellId);
boolean teleportToWorkcell(Player player, String workcellId);
boolean setConnectorBlocksVisible(Player player, String workcellId, boolean visible);
boolean resetConnectorBlocks(Player player, String workcellId);
boolean undoAutosave(Player player);
boolean switchVariant(Player player, String workcellId, String pieceKey, boolean discardDirty);
boolean createVariant(Player player, String workcellId, boolean duplicateActive);
boolean setWorkcellEnabled(Player player, String workcellId, boolean enabled);
boolean updateWorkcellDimensions(
Player player,
String workcellId,
JigsawStudioCellDimensions dimensions
);
boolean updateWorkcellDisplayName(Player player, String workcellId, String displayName);
boolean setRequireCaps(Player player, boolean requireCaps);
boolean duplicateActiveFamily(Player player, String themeKey);
boolean updateThemeSetWeight(Player player, String themeKey, int weight);
boolean flushAutosave(Player player, String workcellId);
boolean goToPreview(Player player);
boolean toggleVariantRotatable(Player player, String workcellId);
boolean expandVariantToCell(Player player, String workcellId);
boolean resizeVariant(
Player player,
String workcellId,
String pieceKey,
JigsawStudioCellDimensions dimensions
);
boolean updateVariantDisplayName(
Player player,
String workcellId,
String pieceKey,
String displayName
);
boolean updateVariantThemes(
Player player,
String workcellId,
String pieceKey,
List<String> themes
);
boolean updateVariantRules(
Player player,
String workcellId,
String pieceKey,
JigsawStudioPieceRules rules
);
boolean adjustVariantWeight(
Player player,
String workcellId,
String pieceKey,
String poolKey,
int entryIndex,
int delta
);
boolean adjustVariantChance(
Player player,
String workcellId,
String pieceKey,
String poolKey,
int entryIndex,
int deltaPercentagePoints
);
boolean unlinkVariantMembership(
Player player,
String workcellId,
String pieceKey,
String poolKey,
int entryIndex
);
boolean deleteVariant(Player player, String workcellId, String pieceKey);
boolean deleteProject(Player player);
boolean giveTool(Player player, JigsawStudioToolPayload payload);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,477 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCompatibilityTarget;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioPieceRules;
import art.arcane.volmlib.util.data.MaterialBlock;
import art.arcane.volmlib.util.inventorygui.UIElement;
import org.bukkit.ChatColor;
import org.bukkit.Material;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import static art.arcane.iris.core.service.JigsawStudioMenuController.PLACEMENT_RULE_SHIFT_STEP;
import static art.arcane.iris.core.service.JigsawStudioMenuController.RULE_SHIFT_STEP;
final class JigsawStudioMenuFormat {
private JigsawStudioMenuFormat() {
}
static boolean hasMembership(
JigsawStudioMenuState state,
String workcellId,
JigsawStudioMenuState.Membership expected
) {
JigsawStudioMenuState.Variant active = activeVariant(state, workcellId);
if (active == null) {
return false;
}
for (JigsawStudioMenuState.Membership membership : active.memberships()) {
if (membership.poolKey().equals(expected.poolKey())
&& membership.entryIndex() == expected.entryIndex()) {
return true;
}
}
return false;
}
static JigsawStudioMenuState.Variant activeVariant(
JigsawStudioMenuState state,
String workcellId
) {
JigsawStudioMenuState.Workcell workcell = state.workcell(workcellId);
return workcell == null ? null : workcell.activeVariant();
}
static JigsawStudioMenuState.Variant variant(
JigsawStudioMenuState.Workcell workcell,
String pieceKey
) {
if (workcell == null || pieceKey == null) {
return null;
}
for (JigsawStudioMenuState.Variant variant : workcell.variants()) {
if (variant.pieceKey().equals(pieceKey)) {
return variant;
}
}
return null;
}
static String nextThemeSetKey(List<JigsawStudioMenuState.ThemeSet> themeSets) {
List<JigsawStudioMenuState.ThemeSet> values = List.copyOf(Objects.requireNonNull(
themeSets,
"Jigsaw Studio theme sets"));
int candidate = 1;
while (candidate > 0) {
String key = "variant-" + candidate;
boolean present = false;
for (JigsawStudioMenuState.ThemeSet themeSet : values) {
if (Objects.requireNonNull(themeSet, "Jigsaw Studio theme set").key().equals(key)) {
present = true;
break;
}
}
if (!present) {
return key;
}
candidate = Math.incrementExact(candidate);
}
throw new IllegalStateException("Jigsaw Studio cannot allocate another numbered theme set");
}
static String themeSelectionPercent(
List<JigsawStudioMenuState.ThemeSet> themeSets,
JigsawStudioMenuState.ThemeSet target
) {
List<JigsawStudioMenuState.ThemeSet> activeThemeSets = Objects.requireNonNull(
themeSets,
"Jigsaw Studio theme sets");
JigsawStudioMenuState.ThemeSet activeTarget = Objects.requireNonNull(
target,
"Jigsaw Studio target theme set");
int totalWeight = 0;
for (JigsawStudioMenuState.ThemeSet themeSet : activeThemeSets) {
totalWeight = Math.addExact(totalWeight, Objects.requireNonNull(
themeSet,
"Jigsaw Studio theme set").weight());
}
if (totalWeight < 1) {
return "0.0%";
}
return String.format(
Locale.ROOT,
"%.1f%%",
activeTarget.weight() * 100.0D / totalWeight);
}
static Optional<Integer> adjustedPositiveValue(int value, int delta) {
if (value < 1 || delta == 0) {
throw new IllegalArgumentException("Jigsaw Studio positive value adjustment is invalid");
}
try {
int adjusted = Math.addExact(value, delta);
return adjusted < 1 ? Optional.empty() : Optional.of(adjusted);
} catch (ArithmeticException exception) {
return Optional.empty();
}
}
static Optional<JigsawStudioPieceRules> adjustedRules(
JigsawStudioPieceRules rules,
RuleField field,
int delta
) {
JigsawStudioPieceRules current = Objects.requireNonNull(rules, "Jigsaw Studio piece rules");
RuleField target = Objects.requireNonNull(field, "Jigsaw Studio piece rule field");
if (delta == 0) {
throw new IllegalArgumentException("Jigsaw Studio piece rule delta cannot be zero");
}
try {
int minimumDepth = current.minimumDepth();
int maximumDepth = current.maximumDepth();
int minimumPlacements = current.minimumPlacements();
int maximumPlacements = current.maximumPlacements();
switch (target) {
case MINIMUM_DEPTH -> minimumDepth = Math.addExact(minimumDepth, delta);
case MAXIMUM_DEPTH -> maximumDepth = Math.addExact(maximumDepth, delta);
case MINIMUM_PLACEMENTS -> minimumPlacements = Math.addExact(minimumPlacements, delta);
case MAXIMUM_PLACEMENTS -> {
Optional<Integer> adjustedMaximum = adjustedMaximumPlacements(maximumPlacements, delta);
if (adjustedMaximum.isEmpty()) {
return Optional.empty();
}
maximumPlacements = adjustedMaximum.get();
}
}
return Optional.of(new JigsawStudioPieceRules(
minimumDepth,
maximumDepth,
minimumPlacements,
maximumPlacements,
current.terminal()));
} catch (ArithmeticException | IllegalArgumentException exception) {
return Optional.empty();
}
}
static JigsawStudioPieceRules withTerminal(JigsawStudioPieceRules rules, boolean terminal) {
JigsawStudioPieceRules current = Objects.requireNonNull(rules, "Jigsaw Studio piece rules");
return new JigsawStudioPieceRules(
current.minimumDepth(),
current.maximumDepth(),
current.minimumPlacements(),
current.maximumPlacements(),
terminal);
}
private static Optional<Integer> adjustedMaximumPlacements(int value, int delta) {
if (value == 0) {
if (delta > 0) {
return Optional.empty();
}
int adjusted = Math.addExact(513, delta);
return adjusted < 1 ? Optional.empty() : Optional.of(adjusted);
}
int adjusted = Math.addExact(value, delta);
if (adjusted > 512) {
return Optional.of(0);
}
return adjusted < 1 ? Optional.empty() : Optional.of(adjusted);
}
static Optional<JigsawStudioCellDimensions> adjustedDimensions(
JigsawStudioCellDimensions dimensions,
DimensionAxis axis,
int delta
) {
JigsawStudioCellDimensions current = Objects.requireNonNull(
dimensions,
"Jigsaw Studio workcell dimensions");
DimensionAxis target = Objects.requireNonNull(axis, "Jigsaw Studio workcell dimension axis");
if (delta == 0) {
throw new IllegalArgumentException("Jigsaw Studio workcell dimension delta cannot be zero");
}
try {
int width = target == DimensionAxis.WIDTH
? Math.addExact(current.width(), delta)
: current.width();
int height = target == DimensionAxis.HEIGHT
? Math.addExact(current.height(), delta)
: current.height();
int depth = target == DimensionAxis.DEPTH
? Math.addExact(current.depth(), delta)
: current.depth();
return Optional.of(new JigsawStudioCellDimensions(width, height, depth));
} catch (ArithmeticException | IllegalArgumentException exception) {
return Optional.empty();
}
}
static JigsawStudioMenuState.Workcell withCapacity(
JigsawStudioMenuState.Workcell workcell,
JigsawStudioCellDimensions capacity
) {
JigsawStudioMenuState.Workcell source = Objects.requireNonNull(
workcell,
"Jigsaw Studio menu workcell");
return new JigsawStudioMenuState.Workcell(
source.stableId(),
source.canonicalName(),
source.displayName(),
Objects.requireNonNull(capacity, "Jigsaw Studio staged workcell capacity"),
source.enabled(),
source.activeVariantKey(),
source.dirty(),
source.saving(),
source.loading(),
source.connectorsVisible(),
source.variants());
}
static int pageCount(int itemCount, int pageSize) {
if (itemCount < 0 || pageSize < 1) {
throw new IllegalArgumentException("Jigsaw Studio menu page bounds are invalid");
}
return Math.max(1, (itemCount + pageSize - 1) / pageSize);
}
static int clampPage(int requestedPage, int itemCount, int pageSize) {
int maximum = pageCount(itemCount, pageSize) - 1;
return Math.max(0, Math.min(requestedPage, maximum));
}
static <T> List<T> page(List<T> items, int requestedPage, int pageSize) {
List<T> values = List.copyOf(Objects.requireNonNull(items, "Jigsaw Studio menu page items"));
int page = clampPage(requestedPage, values.size(), pageSize);
int start = page * pageSize;
int end = Math.min(values.size(), start + pageSize);
return values.subList(start, end);
}
static UIElement evaluationElement(JigsawStudioMenuState.Evaluation evaluation) {
UIElement element = element(
"evaluation",
evaluationMaterial(evaluation.state()),
evaluationColor(evaluation.state()) + evaluationName(evaluation.state()));
element.addLore(ChatColor.GRAY + "Evaluation updates automatically.");
element.addLore(ChatColor.GRAY + "Seed: " + evaluation.seed());
if (evaluation.generation() > 0L) {
element.addLore(ChatColor.GRAY + "Generation: " + evaluation.generation());
}
if (!evaluation.selectedTheme().isEmpty()) {
element.addLore(ChatColor.GRAY + "Theme: " + safe(evaluation.selectedTheme()));
}
element.addLore(ChatColor.GRAY + "Pieces: " + evaluation.pieceCount());
if (!evaluation.detail().isEmpty()) {
element.addLore(ChatColor.GRAY + safe(evaluation.detail()));
}
return element;
}
static Material evaluationMaterial(JigsawStudioEvaluationState state) {
return switch (state) {
case PENDING -> Material.CLOCK;
case VALID -> Material.EMERALD;
case WARNING -> Material.YELLOW_DYE;
case INVALID -> Material.RED_DYE;
case STALE -> Material.GRAY_DYE;
};
}
private static ChatColor evaluationColor(JigsawStudioEvaluationState state) {
return switch (state) {
case PENDING -> ChatColor.AQUA;
case VALID -> ChatColor.GREEN;
case WARNING -> ChatColor.YELLOW;
case INVALID -> ChatColor.RED;
case STALE -> ChatColor.GRAY;
};
}
private static String evaluationName(JigsawStudioEvaluationState state) {
return switch (state) {
case PENDING -> "Evaluation Pending";
case VALID -> "Ready to Assemble";
case WARNING -> "Ready with Warnings";
case INVALID -> "Assembly Blocked";
case STALE -> "Evaluation Stale";
};
}
static Material workcellMaterial(JigsawStudioMenuState.Workcell workcell, boolean selected) {
if (!workcell.enabled()) {
return Material.GRAY_WOOL;
}
if (workcell.loading() || workcell.saving()) {
return Material.YELLOW_WOOL;
}
if (workcell.dirty()) {
return Material.ORANGE_WOOL;
}
return selected ? Material.LIME_WOOL : Material.LIGHT_GRAY_WOOL;
}
static Material variantMaterial(JigsawStudioMenuState.Variant variant) {
if (variant.active()) {
return Material.JIGSAW;
}
return variant.owned() ? Material.PAPER : Material.GRAY_DYE;
}
static String workcellStatus(JigsawStudioMenuState.Workcell workcell) {
if (workcell.loading()) {
return ChatColor.YELLOW + "Loading variant";
}
if (workcell.saving()) {
return ChatColor.YELLOW + "Autosave in progress";
}
if (workcell.dirty()) {
return ChatColor.GOLD + "Autosave pending";
}
return ChatColor.GREEN + "Autosaved";
}
static String dimensions(JigsawStudioCellDimensions dimensions) {
return dimensions.width() + "x" + dimensions.height() + "x" + dimensions.depth();
}
static String themes(List<String> themes) {
return themes.isEmpty() ? "None" : safe(String.join(", ", themes));
}
static String maximumPlacements(int maximum) {
return maximum == 0 ? "Unlimited" : Integer.toString(maximum);
}
static String chance(double chance) {
return String.format(Locale.ROOT, "%.1f%%", chance * 100D);
}
static String yesNo(boolean value) {
return value ? "Yes" : "No";
}
static String compatibilityName(JigsawStudioCompatibilityTarget target) {
return switch (target) {
case IRIS_EXTENDED -> "Iris Extended";
case VANILLA_PORTABLE -> "Vanilla Portable";
};
}
static UIElement element(String id, Material material, String name) {
return new UIElement(id)
.setMaterial(new MaterialBlock(material))
.setName(name);
}
static String title(String structureKey) {
String value = "Iris Jigsaw: " + safe(displayKey(structureKey));
return value.length() > 32 ? value.substring(0, 32) : value;
}
static String displayKey(String resourceKey) {
int separator = resourceKey.lastIndexOf('/');
return separator < 0 ? resourceKey : resourceKey.substring(separator + 1);
}
static String safe(String value) {
return BoardSVC.untrustedBoardValue(value);
}
enum RuleField {
MINIMUM_DEPTH("Minimum Depth", Material.LIGHT_BLUE_DYE, RULE_SHIFT_STEP) {
@Override
int value(JigsawStudioPieceRules rules) {
return rules.minimumDepth();
}
},
MAXIMUM_DEPTH("Maximum Depth", Material.BLUE_DYE, RULE_SHIFT_STEP) {
@Override
int value(JigsawStudioPieceRules rules) {
return rules.maximumDepth();
}
},
MINIMUM_PLACEMENTS("Minimum Placements", Material.TARGET, PLACEMENT_RULE_SHIFT_STEP) {
@Override
int value(JigsawStudioPieceRules rules) {
return rules.minimumPlacements();
}
},
MAXIMUM_PLACEMENTS("Maximum Placements", Material.GREEN_DYE, PLACEMENT_RULE_SHIFT_STEP) {
@Override
int value(JigsawStudioPieceRules rules) {
return rules.maximumPlacements();
}
@Override
String displayValue(int value) {
return maximumPlacements(value);
}
};
private final String displayName;
private final Material material;
private final int shiftStep;
RuleField(String displayName, Material material, int shiftStep) {
this.displayName = displayName;
this.material = material;
this.shiftStep = shiftStep;
}
String displayName() {
return displayName;
}
Material material() {
return material;
}
int shiftStep() {
return shiftStep;
}
String displayValue(int value) {
return Integer.toString(value);
}
abstract int value(JigsawStudioPieceRules rules);
}
enum DimensionAxis {
WIDTH("Width") {
@Override
int value(JigsawStudioCellDimensions dimensions) {
return dimensions.width();
}
},
HEIGHT("Height") {
@Override
int value(JigsawStudioCellDimensions dimensions) {
return dimensions.height();
}
},
DEPTH("Depth") {
@Override
int value(JigsawStudioCellDimensions dimensions) {
return dimensions.depth();
}
};
private final String displayName;
DimensionAxis(String displayName) {
this.displayName = displayName;
}
String displayName() {
return displayName;
}
abstract int value(JigsawStudioCellDimensions dimensions);
}
}
Loaded 100 of 520 files, more files were not shown because too many files have changed in this diff. Show more