This commit is contained in:
Brian Neumann-Fopiano
2026-08-11 14:54:53 -04:00
parent dfae45663a
commit 82290a3090
60 changed files with 2901 additions and 716 deletions
@@ -1209,6 +1209,11 @@ public class NMSBinding implements INMSBinding {
worldGenContext.structureManager(), worldGenContext.lightEngine(), worldGenContext.mainThreadExecutor(), worldGenContext.unsavedListener());
worldGenContextField.set(chunkMap, newContext);
net.minecraft.world.level.chunk.ChunkGenerator activeGenerator = level.getChunkSource().getGenerator();
if (activeGenerator != irisGenerator) {
throw new IllegalStateException("Iris generator injection did not become the active Paper chunk generator; active="
+ activeGenerator.getClass().getName());
}
retargetStructureCheck(level, irisGenerator);
}
@@ -213,6 +213,27 @@ public class NMSBindingDatapackStructureScopeTest {
assertFalse(method.contains("retained.fullState()"));
}
@Test
public void injectionVerifiesTheCanonicalPaperGeneratorBeforeStructureRetargeting() throws IOException {
Path chunkGeneratorSource = Path.of(System.getProperty("iris.nmsChunkGeneratorSource"));
String source = Files.readString(chunkGeneratorSource.resolveSibling("NMSBinding.java"));
int methodStart = source.indexOf("public void inject(long seed, Engine engine, World world)");
int methodEnd = source.indexOf("\n @Override\n public DatapackStructureScopeResult", methodStart);
assertTrue(methodStart >= 0);
assertTrue(methodEnd > methodStart);
String method = source.substring(methodStart, methodEnd);
int publication = method.indexOf("worldGenContextField.set(chunkMap, newContext);");
int canonicalRead = method.indexOf("level.getChunkSource().getGenerator()", publication);
int identityGate = method.indexOf("activeGenerator != irisGenerator", canonicalRead);
int structureRetarget = method.indexOf("retargetStructureCheck(level, irisGenerator)", identityGate);
assertTrue(publication >= 0);
assertTrue(canonicalRead > publication);
assertTrue(identityGate > canonicalRead);
assertTrue(structureRetarget > identityGate);
}
private static DatapackStructureScopeIndex index(
List<String> structureKeys,
List<String> structureSetKeys
+2
View File
@@ -15,6 +15,7 @@ dependencies {
}
compileOnly(libs.paper.api)
testImplementation('junit:junit:4.13.2')
testImplementation('org.mockito:mockito-core:5.23.0')
testImplementation(libs.paper.api)
testImplementation(libs.bstats)
testImplementation(libs.sentry)
@@ -66,5 +67,6 @@ tasks.named('test').configure {
systemProperty('iris.terrainSvcSource', file('src/main/java/art/arcane/iris/core/service/IrisTerrainSVC.java').absolutePath)
systemProperty('iris.apiEventSvcSource', file('src/main/java/art/arcane/iris/core/service/IrisApiEventSVC.java').absolutePath)
systemProperty('iris.worldInfoFactorySource', file('src/main/java/art/arcane/iris/core/service/terrain/IrisWorldInfoFactory.java').absolutePath)
systemProperty('iris.startupSource', file('src/main/java/art/arcane/iris/Iris.java').absolutePath)
systemProperty('iris.readmeSource', rootProject.file('README.md').absolutePath)
}
@@ -27,12 +27,15 @@ import art.arcane.iris.engine.framework.EnginePlatformHooks;
import art.arcane.iris.engine.framework.EngineWorldManagerProvider;
import art.arcane.iris.core.splash.IrisSplashComposer;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.IrisStartupValidation;
import art.arcane.iris.core.IrisStartupAdmissionListener;
import art.arcane.iris.core.BukkitWorldReconciler;
import art.arcane.iris.core.IrisWorldGeneratorResolver;
import art.arcane.iris.core.PendingWorldDeleteQueue;
import art.arcane.iris.core.SettingsHotloadWatch;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.core.datapack.DatapackIngestService;
import art.arcane.iris.core.datapack.DatapackIngestService.StartupValidationOutcome;
import art.arcane.iris.core.lifecycle.PaperLibBootstrap;
import art.arcane.iris.core.lifecycle.WorldLifecycleService;
import art.arcane.iris.core.runtime.BukkitEnginePlatformHooks;
@@ -558,7 +561,10 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
IrisServices.register(IrisCompat.class, compat);
ServerConfigurator.configure();
IrisToolbelt.applyPregenPerformanceProfile();
generatorResolver.validateAllPacks();
StartupValidationOutcome datapackValidation = DatapackIngestService.validateOnStartup();
if (datapackValidation == StartupValidationOutcome.READY) {
generatorResolver.validateAllPacks();
}
IrisSafeguard.execute();
getSender().setTag(getTag());
splash();
@@ -593,7 +599,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
WorldLifecycleService.get();
WorldRuntimeControlService.get();
if (J.isFolia()) {
if (J.isFolia() && IrisStartupValidation.isReady()) {
J.s(() -> worldReconciler.checkForBukkitWorlds(s -> true), 1);
}
@@ -602,10 +608,11 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
J.ar(() -> settingsHotloadWatch.checkConfigHotload(configHotloadEngine), 60);
J.sr(this::tickQueue, 0);
J.s(this::setupPapi);
J.a(DatapackIngestService::autoIngestOnStartup, 60);
autoStartStudio();
if (!J.isFolia()) {
if (IrisStartupValidation.isReady()) {
J.a(DatapackIngestService::runPostStartupTasks, 60);
autoStartStudio();
}
if (!J.isFolia() && IrisStartupValidation.isReady()) {
worldReconciler.checkForBukkitWorlds(s -> true);
}
IrisToolbelt.retainMantleDataForSlice(String.class.getCanonicalName());
@@ -674,6 +681,8 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
public void onEnable() {
IrisPlatforms.bind(new BukkitPlatform());
IrisStartupValidation.begin();
Bukkit.getPluginManager().registerEvents(new IrisStartupAdmissionListener(), this);
enable();
BukkitGuiHost.install();
super.onEnable();
@@ -22,6 +22,7 @@ import art.arcane.iris.Iris;
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration;
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
import art.arcane.iris.core.nms.INMS;
import art.arcane.iris.core.pack.PackValidationRegistry;
import art.arcane.iris.core.tools.IrisToolbelt;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.platform.bukkit.BukkitEnvironment;
@@ -204,6 +205,14 @@ public final class BukkitWorldReconciler {
LifecycleOperationCoordinator.Lease lease
) {
try {
IrisStartupValidation.requireWorldCreationReady();
backend.requireDimensionLoadable(worldKey, dimension);
} catch (Throwable failure) {
lease.close();
return CompletableFuture.completedFuture(LoadResult.dimensionFailure(worldKey, failure));
}
BukkitWorldConfiguration.Registration registration;
String worldName = IrisWorldStorage.logicalName(worldKey);
try {
@@ -424,6 +433,8 @@ public final class BukkitWorldReconciler {
boolean isIrisWorld(World world);
DimensionResolution resolveDimension(NamespacedKey worldKey);
void requireDimensionLoadable(NamespacedKey worldKey, String dimension);
}
public enum ReconciliationStatus {
@@ -727,5 +738,16 @@ public final class BukkitWorldReconciler {
"Multiple dimension definitions were found without an exact registered dimension: "
+ String.join(", ", dimensions)));
}
@Override
public void requireDimensionLoadable(NamespacedKey worldKey, String dimension) {
String worldName = IrisWorldStorage.logicalName(worldKey);
IrisDimension irisDimension = IrisWorldGeneratorResolver.loadDimension(worldName, dimension);
if (irisDimension == null) {
throw new IllegalStateException("Could not resolve the Iris dimension \"" + dimension + "\".");
}
PackValidationRegistry.requireLoadable(
irisDimension.getLoader().getDataFolder().getName());
}
}
}
@@ -0,0 +1,16 @@
package art.arcane.iris.core;
import net.kyori.adventure.text.Component;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.AsyncPlayerPreLoginEvent;
public final class IrisStartupAdmissionListener implements Listener {
@EventHandler(priority = EventPriority.LOWEST)
public void onAsyncPlayerPreLogin(AsyncPlayerPreLoginEvent event) {
IrisStartupValidation.denialReason().ifPresent(reason -> event.disallow(
AsyncPlayerPreLoginEvent.Result.KICK_OTHER,
Component.text(reason + " Check the server console, correct the reported Iris state, and restart.")));
}
}
@@ -24,6 +24,7 @@ import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.pack.BrokenPackException;
import art.arcane.iris.core.pack.PackDownloader;
import art.arcane.iris.core.pack.PackDirectoryResolver;
import art.arcane.iris.core.pack.PackValidationCache;
import art.arcane.iris.core.pack.PackValidationRegistry;
import art.arcane.iris.core.pack.PackValidationResult;
import art.arcane.iris.core.pack.PackValidator;
@@ -42,7 +43,11 @@ import org.bukkit.generator.ChunkGenerator;
import org.jetbrains.annotations.Nullable;
import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.function.Supplier;
/**
@@ -60,31 +65,73 @@ public final class IrisWorldGeneratorResolver {
File packsRoot = plugin.getDataFolder("packs");
List<File> packDirs = PackDirectoryResolver.listVisiblePackDirectories(packsRoot);
PackValidationRegistry.clear();
if (packDirs.isEmpty()) {
return;
List<String> packNames = packDirs.stream().map(File::getName).sorted().toList();
Path cacheFile = IrisPlatforms.get().dataFile("cache", "pack-validation.json").toPath();
String contentFingerprint = "";
String contextFingerprint = "";
Optional<List<PackValidationResult>> cached = Optional.empty();
try {
contentFingerprint = PackValidationCache.contentFingerprint(packsRoot);
contextFingerprint = PackValidationCache.contextFingerprint();
cached = PackValidationCache.load(
cacheFile,
contentFingerprint,
contextFingerprint,
packNames);
} catch (RuntimeException exception) {
Iris.reportError("Could not evaluate the persisted pack-validation cache", exception);
}
for (File packDir : packDirs) {
try {
PackValidationResult result = PackValidator.validate(packDir);
PackValidationRegistry.publish(result);
if (!result.isLoadable()) {
Iris.error("Pack '" + result.getPackName() + "' FAILED validation - world/studio creation will be refused. Reasons:");
for (String reason : result.getBlockingErrors()) {
Iris.error(" - " + reason);
List<PackValidationResult> results;
if (cached.isPresent()) {
results = cached.get();
Iris.info("Reused persisted validation for " + results.size()
+ " unchanged Iris pack(s); full pack parsing was skipped.");
} else {
results = new ArrayList<>(packDirs.size());
for (File packDir : packDirs) {
try {
results.add(PackValidator.validate(packDir));
} catch (Throwable exception) {
Iris.reportError("Pack validation failed for '" + packDir.getName() + "'", exception);
String detail = exception.getMessage();
if (detail == null || detail.isBlank()) {
detail = exception.getClass().getSimpleName();
}
} else if (!result.getWarnings().isEmpty()) {
Iris.info("Pack '" + result.getPackName() + "' validated ("
+ result.getWarnings().size() + " warning(s)).");
for (String warning : result.getWarnings()) {
Iris.warn(" [" + result.getPackName() + "] " + warning);
}
} else {
Iris.success("Pack '" + result.getPackName() + "' validated.");
results.add(new PackValidationResult(
packDir.getName(),
List.of("Pack validation failed with " + exception.getClass().getSimpleName()
+ ": " + detail),
List.of(),
System.currentTimeMillis()));
}
} catch (Throwable e) {
Iris.reportError("Pack validation failed for '" + packDir.getName() + "'", e);
}
try {
PackValidationCache.save(cacheFile, contentFingerprint, contextFingerprint, results);
} catch (IOException exception) {
Iris.reportError("Could not persist Iris pack-validation results", exception);
}
}
for (PackValidationResult result : results) {
PackValidationRegistry.publish(result);
if (!result.isLoadable()) {
Iris.error("Pack '" + result.getPackName()
+ "' FAILED validation - world and Studio creation with this pack will be refused. Reasons:");
for (String reason : result.getBlockingErrors()) {
Iris.error(" - " + reason);
}
} else if (!result.getWarnings().isEmpty()) {
Iris.info("Pack '" + result.getPackName() + "' validated ("
+ result.getWarnings().size() + " warning(s)).");
for (String warning : result.getWarnings()) {
Iris.warn(" [" + result.getPackName() + "] " + warning);
}
} else if (cached.isEmpty()) {
Iris.success("Pack '" + result.getPackName() + "' validated.");
}
}
IrisStartupValidation.markPacksReady();
}
@Nullable
@@ -127,6 +174,7 @@ public final class IrisWorldGeneratorResolver {
}
public ChunkGenerator resolveDefaultWorldGenerator(String worldName, String id) {
IrisStartupValidation.requireWorldCreationReady();
ChunkGenerator stagedGenerator = WorldLifecycleStaging.consumeGenerator(worldName);
if (stagedGenerator != null) {
Iris.debug("Using staged runtime generator for " + worldName);
@@ -136,19 +184,20 @@ public final class IrisWorldGeneratorResolver {
if (id == null || id.isEmpty()) id = IrisSettings.get().getGenerator().getDefaultWorldType();
Iris.debug("Generator ID: " + id + " requested by bukkit/plugin");
PackValidationResult validation = PackValidationRegistry.get(id);
if (validation != null && !validation.isLoadable()) {
Iris.error("Refusing to create world '" + worldName + "' using broken pack '" + id + "':");
for (String reason : validation.getBlockingErrors()) {
Iris.error(" - " + reason);
}
throw new BrokenPackException(id, validation.getBlockingErrors());
}
IrisDimension dim = loadDimension(worldName, id);
if (dim == null) {
throw new RuntimeException("Can't find dimension " + id + "!");
}
String packName = dim.getLoader().getDataFolder().getName();
try {
PackValidationRegistry.requireLoadable(packName);
} catch (BrokenPackException exception) {
Iris.error("Refusing to create world '" + worldName + "' using broken pack '" + packName + "':");
for (String reason : exception.getReasons()) {
Iris.error(" - " + reason);
}
throw exception;
}
Iris.debug("Assuming IrisDimension: " + dim.getName());
NamespacedKey worldKey = IrisWorldStorage.keyFromName(worldName);
@@ -22,6 +22,7 @@ import art.arcane.iris.Iris;
import art.arcane.iris.core.BukkitWorldReconciler;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.IrisStartupValidation;
import art.arcane.iris.core.DatapackInstallResult;
import art.arcane.iris.core.IrisWorldStorage;
import art.arcane.iris.core.IrisWorlds;
@@ -34,6 +35,7 @@ import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.pack.AtomicDirectoryPublisher;
import art.arcane.iris.core.pack.PackDownloader;
import art.arcane.iris.core.pack.PackDirectoryResolver;
import art.arcane.iris.core.pack.PackValidationRegistry;
import art.arcane.iris.core.service.StudioSVC;
import art.arcane.iris.core.tools.IrisToolbelt;
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
@@ -394,6 +396,14 @@ public class CommandIris implements DirectorExecutor {
}
private boolean stageFoliaWorldCreation(String name, IrisDimension dimension, long seed, boolean main) {
try {
IrisStartupValidation.requireWorldCreationReady();
PackValidationRegistry.requireLoadable(
dimension.getLoader().getDataFolder().getName());
} catch (RuntimeException exception) {
sender().sendMessage(C.RED + exception.getMessage());
return false;
}
NamespacedKey worldKey = IrisWorldStorage.managedKeyFromName(name);
LifecycleOperationCoordinator.Lease worldLease = null;
File worldFolder = IrisWorldStorage.requireSafeManagedDimensionRoot(worldKey);
@@ -21,6 +21,7 @@ package art.arcane.iris.core.commands;
import art.arcane.iris.Iris;
import art.arcane.iris.core.pack.PackDirectoryResolver;
import art.arcane.iris.core.pack.PackResourceCleanup;
import art.arcane.iris.core.pack.PackValidationCache;
import art.arcane.iris.core.pack.PackValidationRegistry;
import art.arcane.iris.core.pack.PackValidationResult;
import art.arcane.iris.core.pack.PackValidator;
@@ -31,6 +32,8 @@ import art.arcane.volmlib.util.director.annotations.Param;
import art.arcane.volmlib.util.localization.TextKey;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import art.arcane.iris.core.localization.IrisLanguage;
@@ -64,6 +67,7 @@ public class CommandPack implements DirectorExecutor {
broken++;
}
}
persistValidationCache(packsRoot);
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_VALIDATION_COMPLETE_BROKEN_PACKS, MessageArgument.untrusted("broken", String.valueOf(broken)), MessageArgument.untrusted("value", String.valueOf(dirs.size()))));
return;
}
@@ -74,6 +78,7 @@ public class CommandPack implements DirectorExecutor {
return;
}
runValidate(s, target);
persistValidationCache(packsRoot);
}
@Director(description = "Preview or apply unused-resource cleanup", descriptionKey = "iris.director.commandpack.director.preview_apply_unused_resource_cleanup", aliases = {"c"})
@@ -102,6 +107,7 @@ public class CommandPack implements DirectorExecutor {
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_NO_CLEANUP_CANDIDATES_FOUND_PACK, MessageArgument.untrusted("pack", String.valueOf(pack))));
return;
}
PackValidationRegistry.remove(packFolder.getName());
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_QUARANTINED_CLEANUP_CANDIDATE_S_UNDER, MessageArgument.untrusted("size", String.valueOf(result.quarantinedPaths().size())), MessageArgument.untrusted("quarantinePath", String.valueOf(result.quarantinePath()))));
reportPaths(s, result.quarantinedPaths(), BukkitRuntimeMessages.COMMAND_PACK_PATH_QUARANTINED);
return;
@@ -157,6 +163,7 @@ public class CommandPack implements DirectorExecutor {
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_NOTHING_RESTORE_PACK, MessageArgument.untrusted("pack", String.valueOf(pack))));
return;
}
PackValidationRegistry.remove(packFolder.getName());
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_RESTORED_FILE_S_FROM, MessageArgument.untrusted("size", String.valueOf(result.restoredPaths().size())), MessageArgument.untrusted("dumpPath", String.valueOf(result.dumpPath()))));
reportPaths(s, result.restoredPaths(), BukkitRuntimeMessages.COMMAND_PACK_PATH_RESTORED);
return;
@@ -227,8 +234,38 @@ public class CommandPack implements DirectorExecutor {
return result;
} catch (Throwable e) {
Iris.reportError("Pack validation failed for '" + packFolder.getName() + "'", e);
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_VALIDATION_FAILED, MessageArgument.untrusted("name", String.valueOf(packFolder.getName())), MessageArgument.untrusted("error", String.valueOf(e.getMessage()))));
return null;
String detail = e.getMessage() == null || e.getMessage().isBlank()
? e.getClass().getSimpleName()
: e.getMessage();
PackValidationResult result = new PackValidationResult(
packFolder.getName(),
List.of("Pack validation failed with " + e.getClass().getSimpleName() + ": " + detail),
List.of(),
System.currentTimeMillis());
PackValidationRegistry.publish(result);
reportResult(s, result);
return result;
}
}
private void persistValidationCache(File packsRoot) {
List<File> packDirectories = PackDirectoryResolver.listVisiblePackDirectories(packsRoot);
List<PackValidationResult> results = new ArrayList<>(packDirectories.size());
for (File packDirectory : packDirectories) {
PackValidationResult result = PackValidationRegistry.get(packDirectory.getName());
if (result == null) {
return;
}
results.add(result);
}
try {
PackValidationCache.save(
Iris.instance.getDataFile("cache", "pack-validation.json").toPath(),
PackValidationCache.contentFingerprint(packsRoot),
PackValidationCache.contextFingerprint(),
results);
} catch (IOException | RuntimeException e) {
Iris.reportError("Could not persist refreshed pack-validation results", e);
}
}
@@ -5,6 +5,7 @@ import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
import org.bukkit.NamespacedKey;
import org.bukkit.World;
import org.bukkit.configuration.file.YamlConfiguration;
import org.junit.After;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
@@ -29,6 +30,11 @@ public class BukkitWorldReconcilerTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@After
public void disableStartupValidation() {
IrisStartupValidation.disable();
}
@Test
public void loadLeaseCoversRegistrationThroughExactWorldCompletion() throws Exception {
File configuration = temporaryFolder.newFile("bukkit.yml");
@@ -221,6 +227,38 @@ public class BukkitWorldReconcilerTest {
assertEquals(0L, configuration.length());
}
@Test
public void pendingStartupValidationRefusesWorldLoadBeforeRegistration() throws Exception {
File configuration = temporaryFolder.newFile("startup-pending.yml");
FakeBackend backend = new FakeBackend();
BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator());
IrisStartupValidation.begin();
BukkitWorldReconciler.LoadResult result = reconciler
.loadWorld(configuration, backend.worldKey.toString())
.join();
assertEquals(BukkitWorldReconciler.ReconciliationStatus.DIMENSION_UNRESOLVED, result.status());
assertEquals(0, backend.createCount.get());
assertEquals(0L, configuration.length());
}
@Test
public void invalidDimensionRefusesWorldLoadBeforeRegistration() throws Exception {
File configuration = temporaryFolder.newFile("invalid-dimension.yml");
FakeBackend backend = new FakeBackend();
backend.validationFailure = new IllegalStateException("pack is invalid");
BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator());
BukkitWorldReconciler.LoadResult result = reconciler
.loadWorld(configuration, backend.worldKey.toString())
.join();
assertEquals(BukkitWorldReconciler.ReconciliationStatus.DIMENSION_UNRESOLVED, result.status());
assertEquals(0, backend.createCount.get());
assertEquals(0L, configuration.length());
}
@Test
public void terminalCreateTimeoutWinsOverLateWorldCompletion() {
NamespacedKey worldKey = new NamespacedKey("iris", "probe");
@@ -317,6 +355,7 @@ public class BukkitWorldReconcilerTest {
private boolean irisWorld;
private Long configuredSeed;
private BukkitWorldReconciler.DimensionResolution dimensionResolution;
private RuntimeException validationFailure;
private FakeBackend() {
worldKey = new NamespacedKey("iris", "probe");
@@ -326,6 +365,7 @@ public class BukkitWorldReconcilerTest {
irisWorld = true;
configuredSeed = null;
dimensionResolution = BukkitWorldReconciler.DimensionResolution.resolved("overworld");
validationFailure = null;
}
@Override
@@ -358,5 +398,12 @@ public class BukkitWorldReconcilerTest {
public BukkitWorldReconciler.DimensionResolution resolveDimension(NamespacedKey requestedWorldKey) {
return dimensionResolution;
}
@Override
public void requireDimensionLoadable(NamespacedKey requestedWorldKey, String dimension) {
if (validationFailure != null) {
throw validationFailure;
}
}
}
}
@@ -0,0 +1,78 @@
package art.arcane.iris.core;
import com.destroystokyo.paper.profile.PlayerProfile;
import org.bukkit.event.player.AsyncPlayerPreLoginEvent;
import org.junit.After;
import org.junit.Test;
import java.net.InetAddress;
import java.util.UUID;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.mockito.Mockito.mock;
public class IrisStartupAdmissionListenerTest {
private final IrisStartupAdmissionListener listener = new IrisStartupAdmissionListener();
@After
public void disableValidation() {
IrisStartupValidation.disable();
}
@Test
public void pendingStartupValidationDeniesLogin() throws Exception {
IrisStartupValidation.begin();
AsyncPlayerPreLoginEvent event = event();
listener.onAsyncPlayerPreLogin(event);
assertEquals(AsyncPlayerPreLoginEvent.Result.KICK_OTHER, event.getLoginResult());
assertNotNull(event.kickMessage());
}
@Test
public void invalidDatapacksDenyLogin() throws Exception {
IrisStartupValidation.begin();
IrisStartupValidation.markDatapacksInvalid("datapack failure");
IrisStartupValidation.markPacksReady();
AsyncPlayerPreLoginEvent event = event();
listener.onAsyncPlayerPreLogin(event);
assertEquals(AsyncPlayerPreLoginEvent.Result.KICK_OTHER, event.getLoginResult());
}
@Test
public void restartRequiredDeniesLogin() throws Exception {
IrisStartupValidation.begin();
IrisStartupValidation.requireRestart("restart required");
IrisStartupValidation.markPacksReady();
AsyncPlayerPreLoginEvent event = event();
listener.onAsyncPlayerPreLogin(event);
assertEquals(AsyncPlayerPreLoginEvent.Result.KICK_OTHER, event.getLoginResult());
}
@Test
public void readyValidationAllowsLogin() throws Exception {
IrisStartupValidation.begin();
IrisStartupValidation.markDatapacksReady();
IrisStartupValidation.markPacksReady();
AsyncPlayerPreLoginEvent event = event();
listener.onAsyncPlayerPreLogin(event);
assertEquals(AsyncPlayerPreLoginEvent.Result.ALLOWED, event.getLoginResult());
}
private AsyncPlayerPreLoginEvent event() throws Exception {
return new AsyncPlayerPreLoginEvent(
"ValidationTest",
InetAddress.getLoopbackAddress(),
UUID.randomUUID(),
false,
mock(PlayerProfile.class));
}
}
@@ -0,0 +1,49 @@
package art.arcane.iris.core;
import org.junit.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.Assert.assertTrue;
public class IrisStartupOrderingTest {
@Test
public void admissionGateIsRegisteredBeforeStartupValidationBegins() throws Exception {
String source = Files.readString(Path.of(System.getProperty("iris.startupSource")));
String onEnable = section(source, "public void onEnable()", "public void onDisable()");
assertOrdered(onEnable,
"IrisStartupValidation.begin();",
"registerEvents(new IrisStartupAdmissionListener(), this);",
"enable();");
}
@Test
public void externalDatapacksValidateBeforeDimensionPacks() throws Exception {
String source = Files.readString(Path.of(System.getProperty("iris.startupSource")));
String enable = section(source, "private void enable()", "public void addShutdownHook()");
assertOrdered(enable,
"DatapackIngestService.validateOnStartup();",
"generatorResolver.validateAllPacks();");
}
private static String section(String source, String startMarker, String endMarker) {
int start = source.indexOf(startMarker);
int end = source.indexOf(endMarker, start);
assertTrue("Missing source section starting with " + startMarker, start >= 0);
assertTrue("Missing source section ending with " + endMarker, end > start);
return source.substring(start, end);
}
private static void assertOrdered(String source, String... markers) {
int previous = -1;
for (String marker : markers) {
int current = source.indexOf(marker);
assertTrue("Missing source marker " + marker, current >= 0);
assertTrue("Source marker is out of order: " + marker, current > previous);
previous = current;
}
}
}
+24 -24
View File
@@ -1,12 +1,12 @@
[16:35:58] [Test worker/WARN]: Iris cannot resolve the biome id for 'minecraft:plains' before the Minecraft server is available; using biome id 0
[16:35:58] [Test worker/WARN]: Iris cannot enumerate the biome registry before the Minecraft server is available; returning no biomes
[16:35:58] [Test worker/INFO]: [STDERR]: [Iris/WARN] Can't find block data for minecraft:definitely_not_a_real_block
[16:35:58] [Test worker/INFO]: [STDERR]: [Iris/WARN] Can't find block data for minecraft:minecraft:definitely_not_a_real_block
[16:35:58] [Test worker/INFO]: [STDERR]: [Iris/WARN] Block 'minecraft:oak_log' rejected state 'not_a_property=x'; using its default state
[16:35:58] [Test worker/INFO]: Iris registered custom content provider 'iris_deferred_test'
[16:35:58] [Test worker/WARN]: Iris persistent dimension registry entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial11660810095261932969/iris-dimensions.json is invalid (entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial11660810095261932969/iris-dimensions.json has no dimension); kept verbatim: {"id":"iris:broken","pack":"overworld"}
[16:35:58] [Test worker/ERROR]: Iris persistent dimension registry at /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot5812784634372180772/iris-dimensions.json is corrupt; quarantining it and continuing boot
java.lang.IllegalStateException: Iris persistent dimension registry at /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot5812784634372180772/iris-dimensions.json could not be read; refusing to discard persistent worlds
[23:16:06] [Test worker/WARN]: Iris cannot resolve the biome id for 'minecraft:plains' before the Minecraft server is available; using biome id 0
[23:16:06] [Test worker/WARN]: Iris cannot enumerate the biome registry before the Minecraft server is available; returning no biomes
[23:16:06] [Test worker/INFO]: [STDERR]: [Iris/WARN] Can't find block data for minecraft:definitely_not_a_real_block
[23:16:06] [Test worker/INFO]: [STDERR]: [Iris/WARN] Can't find block data for minecraft:minecraft:definitely_not_a_real_block
[23:16:06] [Test worker/INFO]: [STDERR]: [Iris/WARN] Block 'minecraft:oak_log' rejected state 'not_a_property=x'; using its default state
[23:16:06] [Test worker/INFO]: Iris registered custom content provider 'iris_deferred_test'
[23:16:06] [Test worker/WARN]: Iris persistent dimension registry entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial3600839487069132870/iris-dimensions.json is invalid (entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial3600839487069132870/iris-dimensions.json has no dimension); kept verbatim: {"id":"iris:broken","pack":"overworld"}
[23:16:06] [Test worker/ERROR]: Iris persistent dimension registry at /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot13638847434645426607/iris-dimensions.json is corrupt; quarantining it and continuing boot
java.lang.IllegalStateException: Iris persistent dimension registry at /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot13638847434645426607/iris-dimensions.json could not be read; refusing to discard persistent worlds
at art.arcane.iris.modded.ModdedDimensionRegistryStore.contents(ModdedDimensionRegistryStore.java:150)
at art.arcane.iris.modded.ModdedDimensionRegistryStore.load(ModdedDimensionRegistryStore.java:56)
at art.arcane.iris.modded.ModdedDimensionRegistryStore.loadForStartup(ModdedDimensionRegistryStore.java:69)
@@ -63,9 +63,9 @@ Caused by: art.arcane.volmlib.util.json.JSONException: A JSONObject text must en
at art.arcane.volmlib.util.json.JSONObject.<init>(JSONObject.java:260)
at art.arcane.iris.modded.ModdedDimensionRegistryStore.contents(ModdedDimensionRegistryStore.java:117)
... 45 more
[16:35:58] [Test worker/ERROR]: Iris lost 1 persistent dimension(s) from the corrupt registry: iris:lost
[16:35:58] [Test worker/ERROR]: Iris moved the corrupt persistent dimension registry to /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot5812784634372180772/iris-dimensions.json.broken-1786394158359
[16:35:58] [Test worker/ERROR]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService
[23:16:06] [Test worker/ERROR]: Iris lost 1 persistent dimension(s) from the corrupt registry: iris:lost
[23:16:06] [Test worker/ERROR]: Iris moved the corrupt persistent dimension registry to /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot13638847434645426607/iris-dimensions.json.broken-1786418166450
[23:16:06] [Test worker/ERROR]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService
java.lang.RuntimeException: second disable failed
at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:55)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
@@ -110,7 +110,7 @@ java.lang.RuntimeException: second disable failed
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)
[16:35:58] [Test worker/ERROR]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$FirstService
[23:16:06] [Test worker/ERROR]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$FirstService
java.lang.RuntimeException: first disable failed
at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:54)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
@@ -155,7 +155,7 @@ java.lang.RuntimeException: first disable failed
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)
[16:35:58] [Test worker/ERROR]: Iris disabled all services with 2 failure(s)
[23:16:06] [Test worker/ERROR]: Iris disabled all services with 2 failure(s)
java.lang.RuntimeException: second disable failed
at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:55)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
@@ -203,7 +203,7 @@ java.lang.RuntimeException: second disable failed
Suppressed: java.lang.RuntimeException: first disable failed
at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:54)
... 42 more
[16:35:58] [Test worker/ERROR]: Iris service rollback failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService
[23:16:06] [Test worker/ERROR]: Iris service rollback failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService
java.lang.RuntimeException: cleanup failed
at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
@@ -248,7 +248,7 @@ java.lang.RuntimeException: cleanup failed
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)
[16:35:58] [Test worker/ERROR]: Iris service onEnable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService
[23:16:06] [Test worker/ERROR]: Iris service onEnable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService
java.lang.RuntimeException: enable failed
at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:31)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
@@ -296,7 +296,7 @@ java.lang.RuntimeException: enable failed
Suppressed: java.lang.RuntimeException: cleanup failed
at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32)
... 42 more
[16:35:58] [Test worker/ERROR]: [worldcheck] server stop request failed
[23:16:06] [Test worker/ERROR]: [worldcheck] server stop request failed
java.lang.IllegalStateException: stop request failed
at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$stopRequestFailureForcesNonzeroResult$1(ModdedWorldCheckTest.java:238)
at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:144)
@@ -343,7 +343,7 @@ java.lang.IllegalStateException: stop request failed
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)
[16:35:58] [Test worker/ERROR]: [worldcheck] waiting for server shutdown failed
[23:16:06] [Test worker/ERROR]: [worldcheck] waiting for server shutdown failed
java.lang.IllegalStateException: shutdown wait failed
at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$shutdownWaitFailureForcesNonzeroExit$0(ModdedWorldCheckTest.java:264)
at art.arcane.iris.modded.ModdedWorldCheck.awaitStopAndExit(ModdedWorldCheck.java:159)
@@ -390,7 +390,7 @@ java.lang.IllegalStateException: shutdown wait failed
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)
[16:35:58] [Test worker/ERROR]: [worldcheck] check failed
[23:16:06] [Test worker/ERROR]: [worldcheck] check failed
java.lang.IllegalStateException: check failed
at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$thrownCheckStillRequestsStop$0(ModdedWorldCheckTest.java:224)
at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:139)
@@ -437,8 +437,8 @@ java.lang.IllegalStateException: check failed
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)
[16:35:58] [Test worker/INFO]: Iris registered custom content provider 'iris_discovery_success'
[16:35:58] [Test worker/WARN]: Iris custom content provider discovery failed at provider 'iris_discovery_failure' (art.arcane.iris.modded.api.ModdedCustomContentRegistryTest$TestProvider)
[23:16:06] [Test worker/INFO]: Iris registered custom content provider 'iris_discovery_success'
[23:16:06] [Test worker/WARN]: Iris custom content provider discovery failed at provider 'iris_discovery_failure' (art.arcane.iris.modded.api.ModdedCustomContentRegistryTest$TestProvider)
java.lang.RuntimeException: provider init failed
at art.arcane.iris.modded.api.ModdedCustomContentRegistryTest.failedDiscoveryPublishesNothingAndPreservesTheCause(ModdedCustomContentRegistryTest.java:39)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
@@ -483,6 +483,6 @@ java.lang.RuntimeException: provider init failed
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)
[16:35:58] [Test worker/INFO]: Iris object undo service ready (bounded to 32 paste(s) per player)
[16:35:58] [Test worker/INFO]: [STDOUT]: [Iris/INFO] Iris /iris command tree registered
[16:35:58] [Test worker/INFO]: [STDOUT]: [Iris/INFO] Iris /iris command tree registered
[23:16:06] [Test worker/INFO]: Iris object undo service ready (bounded to 32 paste(s) per player)
[23:16:06] [Test worker/INFO]: [STDOUT]: [Iris/INFO] Iris /iris command tree registered
[23:16:06] [Test worker/INFO]: [STDOUT]: [Iris/INFO] Iris /iris command tree registered
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+22 -22
View File
@@ -1,12 +1,12 @@
[10Aug2026 16:36:16.711] [Test worker/DEBUG] [io.netty.util.internal.logging.InternalLoggerFactory/]: Using SLF4J as the default logging framework
[10Aug2026 16:36:16.713] [Test worker/DEBUG] [io.netty.util.ResourceLeakDetector/]: -Dio.netty.leakDetection.level: simple
[10Aug2026 16:36:16.713] [Test worker/DEBUG] [io.netty.util.ResourceLeakDetector/]: -Dio.netty.leakDetection.targetRecords: 4
[10Aug2026 16:36:18.446] [Test worker/WARN] [Iris/]: Iris cannot resolve the biome id for 'minecraft:plains' before the Minecraft server is available; using biome id 0
[10Aug2026 16:36:18.447] [Test worker/WARN] [Iris/]: Iris cannot enumerate the biome registry before the Minecraft server is available; returning no biomes
[10Aug2026 16:36:18.481] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_deferred_test'
[10Aug2026 16:36:18.487] [Test worker/WARN] [Iris/]: Iris persistent dimension registry entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial10729038317795245166/iris-dimensions.json is invalid (entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial10729038317795245166/iris-dimensions.json has no dimension); kept verbatim: {"id":"iris:broken","pack":"overworld"}
[10Aug2026 16:36:18.500] [Test worker/ERROR] [Iris/]: Iris persistent dimension registry at /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot15013382090322814704/iris-dimensions.json is corrupt; quarantining it and continuing boot
java.lang.IllegalStateException: Iris persistent dimension registry at /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot15013382090322814704/iris-dimensions.json could not be read; refusing to discard persistent worlds
[10Aug2026 23:16:24.848] [Test worker/DEBUG] [io.netty.util.internal.logging.InternalLoggerFactory/]: Using SLF4J as the default logging framework
[10Aug2026 23:16:24.850] [Test worker/DEBUG] [io.netty.util.ResourceLeakDetector/]: -Dio.netty.leakDetection.level: simple
[10Aug2026 23:16:24.850] [Test worker/DEBUG] [io.netty.util.ResourceLeakDetector/]: -Dio.netty.leakDetection.targetRecords: 4
[10Aug2026 23:16:26.445] [Test worker/WARN] [Iris/]: Iris cannot resolve the biome id for 'minecraft:plains' before the Minecraft server is available; using biome id 0
[10Aug2026 23:16:26.446] [Test worker/WARN] [Iris/]: Iris cannot enumerate the biome registry before the Minecraft server is available; returning no biomes
[10Aug2026 23:16:26.477] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_deferred_test'
[10Aug2026 23:16:26.483] [Test worker/WARN] [Iris/]: Iris persistent dimension registry entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial11065974536391059626/iris-dimensions.json is invalid (entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial11065974536391059626/iris-dimensions.json has no dimension); kept verbatim: {"id":"iris:broken","pack":"overworld"}
[10Aug2026 23:16:26.496] [Test worker/ERROR] [Iris/]: Iris persistent dimension registry at /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot1665584090438343711/iris-dimensions.json is corrupt; quarantining it and continuing boot
java.lang.IllegalStateException: Iris persistent dimension registry at /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot1665584090438343711/iris-dimensions.json could not be read; refusing to discard persistent worlds
at art.arcane.iris.modded.ModdedDimensionRegistryStore.contents(ModdedDimensionRegistryStore.java:150) ~[main/:?]
at art.arcane.iris.modded.ModdedDimensionRegistryStore.load(ModdedDimensionRegistryStore.java:56) ~[main/:?]
at art.arcane.iris.modded.ModdedDimensionRegistryStore.loadForStartup(ModdedDimensionRegistryStore.java:69) ~[main/:?]
@@ -63,9 +63,9 @@ Caused by: art.arcane.volmlib.util.json.JSONException: A JSONObject text must en
at art.arcane.volmlib.util.json.JSONObject.<init>(JSONObject.java:260) ~[shared-local-SNAPSHOT.jar:?]
at art.arcane.iris.modded.ModdedDimensionRegistryStore.contents(ModdedDimensionRegistryStore.java:117) ~[main/:?]
... 45 more
[10Aug2026 16:36:18.506] [Test worker/ERROR] [Iris/]: Iris lost 1 persistent dimension(s) from the corrupt registry: iris:lost
[10Aug2026 16:36:18.506] [Test worker/ERROR] [Iris/]: Iris moved the corrupt persistent dimension registry to /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot15013382090322814704/iris-dimensions.json.broken-1786394178506
[10Aug2026 16:36:18.552] [Test worker/ERROR] [Iris/]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService
[10Aug2026 23:16:26.501] [Test worker/ERROR] [Iris/]: Iris lost 1 persistent dimension(s) from the corrupt registry: iris:lost
[10Aug2026 23:16:26.502] [Test worker/ERROR] [Iris/]: Iris moved the corrupt persistent dimension registry to /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot1665584090438343711/iris-dimensions.json.broken-1786418186502
[10Aug2026 23:16:26.543] [Test worker/ERROR] [Iris/]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService
java.lang.RuntimeException: second disable failed
at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:55) ~[test/:?]
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?]
@@ -110,7 +110,7 @@ java.lang.RuntimeException: second disable failed
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?]
[10Aug2026 16:36:18.555] [Test worker/ERROR] [Iris/]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$FirstService
[10Aug2026 23:16:26.546] [Test worker/ERROR] [Iris/]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$FirstService
java.lang.RuntimeException: first disable failed
at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:54) ~[test/:?]
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?]
@@ -155,7 +155,7 @@ java.lang.RuntimeException: first disable failed
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?]
[10Aug2026 16:36:18.557] [Test worker/ERROR] [Iris/]: Iris disabled all services with 2 failure(s)
[10Aug2026 23:16:26.548] [Test worker/ERROR] [Iris/]: Iris disabled all services with 2 failure(s)
java.lang.RuntimeException: second disable failed
at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:55) ~[test/:?]
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?]
@@ -203,7 +203,7 @@ java.lang.RuntimeException: second disable failed
Suppressed: java.lang.RuntimeException: first disable failed
at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:54) ~[test/:?]
... 42 more
[10Aug2026 16:36:18.560] [Test worker/ERROR] [Iris/]: Iris service rollback failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService
[10Aug2026 23:16:26.551] [Test worker/ERROR] [Iris/]: Iris service rollback failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService
java.lang.RuntimeException: cleanup failed
at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32) ~[test/:?]
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?]
@@ -248,7 +248,7 @@ java.lang.RuntimeException: cleanup failed
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?]
[10Aug2026 16:36:18.562] [Test worker/ERROR] [Iris/]: Iris service onEnable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService
[10Aug2026 23:16:26.553] [Test worker/ERROR] [Iris/]: Iris service onEnable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService
java.lang.RuntimeException: enable failed
at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:31) ~[test/:?]
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?]
@@ -296,7 +296,7 @@ java.lang.RuntimeException: enable failed
Suppressed: java.lang.RuntimeException: cleanup failed
at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32) ~[test/:?]
... 42 more
[10Aug2026 16:36:18.596] [Test worker/ERROR] [Iris/]: [worldcheck] server stop request failed
[10Aug2026 23:16:26.588] [Test worker/ERROR] [Iris/]: [worldcheck] server stop request failed
java.lang.IllegalStateException: stop request failed
at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$stopRequestFailureForcesNonzeroResult$1(ModdedWorldCheckTest.java:238) ~[test/:?]
at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:144) ~[main/:?]
@@ -343,7 +343,7 @@ java.lang.IllegalStateException: stop request failed
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?]
[10Aug2026 16:36:18.598] [Test worker/ERROR] [Iris/]: [worldcheck] waiting for server shutdown failed
[10Aug2026 23:16:26.590] [Test worker/ERROR] [Iris/]: [worldcheck] waiting for server shutdown failed
java.lang.IllegalStateException: shutdown wait failed
at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$shutdownWaitFailureForcesNonzeroExit$0(ModdedWorldCheckTest.java:264) ~[test/:?]
at art.arcane.iris.modded.ModdedWorldCheck.awaitStopAndExit(ModdedWorldCheck.java:159) ~[main/:?]
@@ -390,7 +390,7 @@ java.lang.IllegalStateException: shutdown wait failed
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?]
[10Aug2026 16:36:18.602] [Test worker/ERROR] [Iris/]: [worldcheck] check failed
[10Aug2026 23:16:26.593] [Test worker/ERROR] [Iris/]: [worldcheck] check failed
java.lang.IllegalStateException: check failed
at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$thrownCheckStillRequestsStop$0(ModdedWorldCheckTest.java:224) ~[test/:?]
at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:139) ~[main/:?]
@@ -437,8 +437,8 @@ java.lang.IllegalStateException: check failed
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?]
[10Aug2026 16:36:18.613] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_discovery_success'
[10Aug2026 16:36:18.613] [Test worker/WARN] [Iris/]: Iris custom content provider discovery failed at provider 'iris_discovery_failure' (art.arcane.iris.modded.api.ModdedCustomContentRegistryTest$TestProvider)
[10Aug2026 23:16:26.603] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_discovery_success'
[10Aug2026 23:16:26.604] [Test worker/WARN] [Iris/]: Iris custom content provider discovery failed at provider 'iris_discovery_failure' (art.arcane.iris.modded.api.ModdedCustomContentRegistryTest$TestProvider)
java.lang.RuntimeException: provider init failed
at art.arcane.iris.modded.api.ModdedCustomContentRegistryTest.failedDiscoveryPublishesNothingAndPreservesTheCause(ModdedCustomContentRegistryTest.java:39) ~[test/:?]
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?]
@@ -483,4 +483,4 @@ java.lang.RuntimeException: provider init failed
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?]
[10Aug2026 16:36:18.627] [Test worker/INFO] [Iris/]: Iris object undo service ready (bounded to 32 paste(s) per player)
[10Aug2026 23:16:26.616] [Test worker/INFO] [Iris/]: Iris object undo service ready (bounded to 32 paste(s) per player)
+19 -19
View File
@@ -1,9 +1,9 @@
[10Aug2026 16:36:18.446] [Test worker/WARN] [Iris/]: Iris cannot resolve the biome id for 'minecraft:plains' before the Minecraft server is available; using biome id 0
[10Aug2026 16:36:18.447] [Test worker/WARN] [Iris/]: Iris cannot enumerate the biome registry before the Minecraft server is available; returning no biomes
[10Aug2026 16:36:18.481] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_deferred_test'
[10Aug2026 16:36:18.487] [Test worker/WARN] [Iris/]: Iris persistent dimension registry entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial10729038317795245166/iris-dimensions.json is invalid (entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial10729038317795245166/iris-dimensions.json has no dimension); kept verbatim: {"id":"iris:broken","pack":"overworld"}
[10Aug2026 16:36:18.500] [Test worker/ERROR] [Iris/]: Iris persistent dimension registry at /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot15013382090322814704/iris-dimensions.json is corrupt; quarantining it and continuing boot
java.lang.IllegalStateException: Iris persistent dimension registry at /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot15013382090322814704/iris-dimensions.json could not be read; refusing to discard persistent worlds
[10Aug2026 23:16:26.445] [Test worker/WARN] [Iris/]: Iris cannot resolve the biome id for 'minecraft:plains' before the Minecraft server is available; using biome id 0
[10Aug2026 23:16:26.446] [Test worker/WARN] [Iris/]: Iris cannot enumerate the biome registry before the Minecraft server is available; returning no biomes
[10Aug2026 23:16:26.477] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_deferred_test'
[10Aug2026 23:16:26.483] [Test worker/WARN] [Iris/]: Iris persistent dimension registry entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial11065974536391059626/iris-dimensions.json is invalid (entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial11065974536391059626/iris-dimensions.json has no dimension); kept verbatim: {"id":"iris:broken","pack":"overworld"}
[10Aug2026 23:16:26.496] [Test worker/ERROR] [Iris/]: Iris persistent dimension registry at /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot1665584090438343711/iris-dimensions.json is corrupt; quarantining it and continuing boot
java.lang.IllegalStateException: Iris persistent dimension registry at /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot1665584090438343711/iris-dimensions.json could not be read; refusing to discard persistent worlds
at art.arcane.iris.modded.ModdedDimensionRegistryStore.contents(ModdedDimensionRegistryStore.java:150) ~[main/:?]
at art.arcane.iris.modded.ModdedDimensionRegistryStore.load(ModdedDimensionRegistryStore.java:56) ~[main/:?]
at art.arcane.iris.modded.ModdedDimensionRegistryStore.loadForStartup(ModdedDimensionRegistryStore.java:69) ~[main/:?]
@@ -60,9 +60,9 @@ Caused by: art.arcane.volmlib.util.json.JSONException: A JSONObject text must en
at art.arcane.volmlib.util.json.JSONObject.<init>(JSONObject.java:260) ~[shared-local-SNAPSHOT.jar:?]
at art.arcane.iris.modded.ModdedDimensionRegistryStore.contents(ModdedDimensionRegistryStore.java:117) ~[main/:?]
... 45 more
[10Aug2026 16:36:18.506] [Test worker/ERROR] [Iris/]: Iris lost 1 persistent dimension(s) from the corrupt registry: iris:lost
[10Aug2026 16:36:18.506] [Test worker/ERROR] [Iris/]: Iris moved the corrupt persistent dimension registry to /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot15013382090322814704/iris-dimensions.json.broken-1786394178506
[10Aug2026 16:36:18.552] [Test worker/ERROR] [Iris/]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService
[10Aug2026 23:16:26.501] [Test worker/ERROR] [Iris/]: Iris lost 1 persistent dimension(s) from the corrupt registry: iris:lost
[10Aug2026 23:16:26.502] [Test worker/ERROR] [Iris/]: Iris moved the corrupt persistent dimension registry to /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot1665584090438343711/iris-dimensions.json.broken-1786418186502
[10Aug2026 23:16:26.543] [Test worker/ERROR] [Iris/]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService
java.lang.RuntimeException: second disable failed
at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:55) ~[test/:?]
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?]
@@ -107,7 +107,7 @@ java.lang.RuntimeException: second disable failed
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?]
[10Aug2026 16:36:18.555] [Test worker/ERROR] [Iris/]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$FirstService
[10Aug2026 23:16:26.546] [Test worker/ERROR] [Iris/]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$FirstService
java.lang.RuntimeException: first disable failed
at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:54) ~[test/:?]
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?]
@@ -152,7 +152,7 @@ java.lang.RuntimeException: first disable failed
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?]
[10Aug2026 16:36:18.557] [Test worker/ERROR] [Iris/]: Iris disabled all services with 2 failure(s)
[10Aug2026 23:16:26.548] [Test worker/ERROR] [Iris/]: Iris disabled all services with 2 failure(s)
java.lang.RuntimeException: second disable failed
at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:55) ~[test/:?]
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?]
@@ -200,7 +200,7 @@ java.lang.RuntimeException: second disable failed
Suppressed: java.lang.RuntimeException: first disable failed
at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:54) ~[test/:?]
... 42 more
[10Aug2026 16:36:18.560] [Test worker/ERROR] [Iris/]: Iris service rollback failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService
[10Aug2026 23:16:26.551] [Test worker/ERROR] [Iris/]: Iris service rollback failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService
java.lang.RuntimeException: cleanup failed
at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32) ~[test/:?]
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?]
@@ -245,7 +245,7 @@ java.lang.RuntimeException: cleanup failed
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?]
[10Aug2026 16:36:18.562] [Test worker/ERROR] [Iris/]: Iris service onEnable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService
[10Aug2026 23:16:26.553] [Test worker/ERROR] [Iris/]: Iris service onEnable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService
java.lang.RuntimeException: enable failed
at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:31) ~[test/:?]
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?]
@@ -293,7 +293,7 @@ java.lang.RuntimeException: enable failed
Suppressed: java.lang.RuntimeException: cleanup failed
at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32) ~[test/:?]
... 42 more
[10Aug2026 16:36:18.596] [Test worker/ERROR] [Iris/]: [worldcheck] server stop request failed
[10Aug2026 23:16:26.588] [Test worker/ERROR] [Iris/]: [worldcheck] server stop request failed
java.lang.IllegalStateException: stop request failed
at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$stopRequestFailureForcesNonzeroResult$1(ModdedWorldCheckTest.java:238) ~[test/:?]
at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:144) ~[main/:?]
@@ -340,7 +340,7 @@ java.lang.IllegalStateException: stop request failed
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?]
[10Aug2026 16:36:18.598] [Test worker/ERROR] [Iris/]: [worldcheck] waiting for server shutdown failed
[10Aug2026 23:16:26.590] [Test worker/ERROR] [Iris/]: [worldcheck] waiting for server shutdown failed
java.lang.IllegalStateException: shutdown wait failed
at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$shutdownWaitFailureForcesNonzeroExit$0(ModdedWorldCheckTest.java:264) ~[test/:?]
at art.arcane.iris.modded.ModdedWorldCheck.awaitStopAndExit(ModdedWorldCheck.java:159) ~[main/:?]
@@ -387,7 +387,7 @@ java.lang.IllegalStateException: shutdown wait failed
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?]
[10Aug2026 16:36:18.602] [Test worker/ERROR] [Iris/]: [worldcheck] check failed
[10Aug2026 23:16:26.593] [Test worker/ERROR] [Iris/]: [worldcheck] check failed
java.lang.IllegalStateException: check failed
at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$thrownCheckStillRequestsStop$0(ModdedWorldCheckTest.java:224) ~[test/:?]
at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:139) ~[main/:?]
@@ -434,8 +434,8 @@ java.lang.IllegalStateException: check failed
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?]
[10Aug2026 16:36:18.613] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_discovery_success'
[10Aug2026 16:36:18.613] [Test worker/WARN] [Iris/]: Iris custom content provider discovery failed at provider 'iris_discovery_failure' (art.arcane.iris.modded.api.ModdedCustomContentRegistryTest$TestProvider)
[10Aug2026 23:16:26.603] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_discovery_success'
[10Aug2026 23:16:26.604] [Test worker/WARN] [Iris/]: Iris custom content provider discovery failed at provider 'iris_discovery_failure' (art.arcane.iris.modded.api.ModdedCustomContentRegistryTest$TestProvider)
java.lang.RuntimeException: provider init failed
at art.arcane.iris.modded.api.ModdedCustomContentRegistryTest.failedDiscoveryPublishesNothingAndPreservesTheCause(ModdedCustomContentRegistryTest.java:39) ~[test/:?]
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?]
@@ -480,4 +480,4 @@ java.lang.RuntimeException: provider init failed
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?]
[10Aug2026 16:36:18.627] [Test worker/INFO] [Iris/]: Iris object undo service ready (bounded to 32 paste(s) per player)
[10Aug2026 23:16:26.616] [Test worker/INFO] [Iris/]: Iris object undo service ready (bounded to 32 paste(s) per player)
+1 -1
View File
@@ -62,7 +62,7 @@ art/arcane/iris/core/service/ExternalDataSVC.java
art/arcane/iris/core/service/GlobalCacheSVC.java
art/arcane/iris/core/service/JigsawStudioMarkerParser.java
art/arcane/iris/core/service/JigsawStudioMenuController.java
art/arcane/iris/core/service/JigsawStudioDisabledWorkcellRenderer.java
art/arcane/iris/core/service/JigsawStudioBoundsRenderer.java
art/arcane/iris/core/service/JigsawStudioPreviewRenderer.java
art/arcane/iris/core/service/JigsawStudioService.java
art/arcane/iris/core/service/JigsawStudioToolCodec.java
@@ -0,0 +1,154 @@
package art.arcane.iris.core;
import java.util.List;
import java.util.Optional;
public final class IrisStartupValidation {
private static volatile Snapshot snapshot = Snapshot.disabled();
private IrisStartupValidation() {
}
public static synchronized void begin() {
snapshot = new Snapshot(true, ValidationState.PENDING, ValidationState.PENDING, List.of(), List.of());
}
public static synchronized void disable() {
snapshot = Snapshot.disabled();
}
public static synchronized void beginDatapackValidation() {
if (!snapshot.enforced() || snapshot.datapacks() == ValidationState.RESTART_REQUIRED) {
return;
}
snapshot = new Snapshot(true, ValidationState.PENDING, snapshot.packs(), List.of(), snapshot.packFailures());
}
public static synchronized void markDatapacksReady() {
if (!snapshot.enforced() || snapshot.datapacks() == ValidationState.RESTART_REQUIRED) {
return;
}
snapshot = new Snapshot(true, ValidationState.READY, snapshot.packs(), List.of(), snapshot.packFailures());
}
public static synchronized void markDatapacksInvalid(String failure) {
if (!snapshot.enforced()) {
return;
}
snapshot = new Snapshot(
true,
ValidationState.INVALID,
snapshot.packs(),
List.of(normalizeFailure(failure, "External datapack validation failed.")),
snapshot.packFailures());
}
public static synchronized void requireRestart(String reason) {
if (!snapshot.enforced()) {
return;
}
snapshot = new Snapshot(
true,
ValidationState.RESTART_REQUIRED,
snapshot.packs(),
List.of(normalizeFailure(reason, "A restart is required to load validated datapacks.")),
snapshot.packFailures());
}
public static synchronized void markPacksReady() {
if (!snapshot.enforced()) {
return;
}
snapshot = new Snapshot(true, snapshot.datapacks(), ValidationState.READY, snapshot.datapackFailures(), List.of());
}
public static synchronized void markPacksInvalid(List<String> failures) {
if (!snapshot.enforced()) {
return;
}
List<String> normalized = failures == null || failures.isEmpty()
? List.of("Iris dimension-pack validation failed.")
: failures.stream()
.map(failure -> normalizeFailure(failure, "Iris dimension-pack validation failed."))
.toList();
snapshot = new Snapshot(true, snapshot.datapacks(), ValidationState.INVALID,
snapshot.datapackFailures(), normalized);
}
public static boolean isReady() {
return isReady(snapshot);
}
public static Optional<String> denialReason() {
Snapshot current = snapshot;
if (!current.enforced() || isReady(current)) {
return Optional.empty();
}
if (current.datapacks() == ValidationState.RESTART_REQUIRED) {
return Optional.of(firstFailure(current.datapackFailures(),
"Iris installed validated datapacks and requires a restart before the server is safe."));
}
if (current.datapacks() == ValidationState.INVALID) {
return Optional.of(firstFailure(current.datapackFailures(),
"Iris external datapack validation failed."));
}
if (current.datapacks() == ValidationState.PENDING) {
return Optional.of("Iris is still validating external datapacks.");
}
if (current.packs() == ValidationState.INVALID) {
return Optional.of(firstFailure(current.packFailures(),
"Iris dimension-pack validation failed."));
}
return Optional.of("Iris is still validating dimension packs.");
}
public static void requireWorldCreationReady() {
Optional<String> denial = denialReason();
if (denial.isPresent()) {
throw new IllegalStateException("Iris world creation is locked: " + denial.get());
}
}
static Snapshot snapshot() {
return snapshot;
}
private static String normalizeFailure(String failure, String fallback) {
return failure == null || failure.isBlank() ? fallback : failure.trim();
}
private static String firstFailure(List<String> failures, String fallback) {
return failures == null || failures.isEmpty() ? fallback : failures.getFirst();
}
private static boolean isReady(Snapshot current) {
return !current.enforced()
|| current.datapacks() == ValidationState.READY
&& current.packs() == ValidationState.READY;
}
enum ValidationState {
PENDING,
READY,
INVALID,
RESTART_REQUIRED,
DISABLED
}
record Snapshot(
boolean enforced,
ValidationState datapacks,
ValidationState packs,
List<String> datapackFailures,
List<String> packFailures
) {
Snapshot {
datapackFailures = List.copyOf(datapackFailures);
packFailures = List.copyOf(packFailures);
}
private static Snapshot disabled() {
return new Snapshot(false, ValidationState.DISABLED, ValidationState.DISABLED, List.of(), List.of());
}
}
}
@@ -161,6 +161,8 @@ public class ServerConfigurator {
invalidateLoadedDatapackRuntime();
loadedDatapackRestartRequired = true;
}
IrisStartupValidation.requireRestart(
"Iris datapack changes require a restart before player admission or world creation.");
}
public static void restoreLoadedDatapackRuntimeIfUnchanged(
@@ -21,6 +21,7 @@ package art.arcane.iris.core.datapack;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.core.IrisStartupValidation;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.core.datapack.ModrinthResolver.ResolvedDatapack;
@@ -62,6 +63,7 @@ import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
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;
@@ -97,8 +99,10 @@ public final class DatapackIngestService {
private static final String TRANSACTION_DIRECTORY = ".iris-datapack-transactions";
private static final String TRANSACTION_JOURNAL = "journal.json";
private static final String TRANSACTION_JOURNAL_NEXT = "journal.next.json";
private static final String STARTUP_VALIDATION_CACHE = "startup-validation.json";
private static final int OWNERSHIP_SCHEMA = 1;
private static final int TRANSACTION_SCHEMA = 2;
private static final int STARTUP_VALIDATION_SCHEMA = 1;
private static final int STRUCTURE_IMPORT_FORMAT_REVISION = 3;
private static final int MAX_REDIRECTS = 5;
private static final int MAX_ARCHIVE_ENTRIES = 100_000;
@@ -113,9 +117,12 @@ public final class DatapackIngestService {
private static final long MAX_METADATA_BYTES = 1024L * 1024L;
private static final long MAX_OWNERSHIP_BYTES = 1024L * 1024L;
private static final int MAX_TRANSACTION_COUNT = 1_024;
private static final int MAX_SCRATCH_DELETE_ATTEMPTS = 3;
private static final int WINDOWS_LEGACY_PATH_LIMIT = 247;
private static final Set<String> RESERVED_IDS = Set.of("iris");
private static final ReentrantLock TRANSACTION_LOCK = new ReentrantLock();
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private static volatile StartupValidationCache activeStartupValidation;
private DatapackIngestService() {
}
@@ -125,19 +132,367 @@ public final class DatapackIngestService {
}
public static void autoIngestOnStartup() {
boolean restarting = false;
if (IrisSettings.get().getGeneral().autoIngestDatapacks) {
KList<String> urls = collectConfiguredImports();
if (!urls.isEmpty()) {
IrisLogging.info("Auto-ingesting " + urls.size() + " external datapack import(s) from pack datapackImports...");
Report report = ingest(null, urls, true);
restarting = report.changed();
StartupValidationOutcome outcome = validateOnStartup();
if (outcome == StartupValidationOutcome.READY) {
runPostStartupTasks();
}
}
public static StartupValidationOutcome validateOnStartup() {
activeStartupValidation = null;
IrisStartupValidation.beginDatapackValidation();
KList<String> configured = collectConfiguredImports();
List<String> urls = configured.stream().sorted().toList();
boolean autoIngest = IrisSettings.get().getGeneral().autoIngestDatapacks;
boolean stripOverrides = resolveStripOverrides();
String mcVersion = serverMcVersion();
int irisVersion = IrisPlatforms.get().irisVersionNumber();
File root = IrisPlatforms.get().dataFolder("datapacks");
KList<File> worldFolders = ServerConfigurator.getDatapacksFolder();
Path cacheFile = new File(root, STARTUP_VALIDATION_CACHE).toPath();
try {
String localFingerprint = startupValidationFingerprint(root, worldFolders);
StartupValidationCache cached = readStartupValidationCache(cacheFile);
if (startupValidationCacheMatches(
cached,
mcVersion,
irisVersion,
autoIngest,
stripOverrides,
urls,
localFingerprint)) {
activeStartupValidation = cached;
IrisLogging.info("External datapacks match the persisted startup validation; remote resolution and full revalidation were skipped.");
IrisStartupValidation.markDatapacksReady();
return StartupValidationOutcome.READY;
}
} catch (IOException | RuntimeException exception) {
IrisLogging.warn("Persisted external datapack validation could not be reused: "
+ failureMessage(exception));
}
if (!restarting) {
refreshWorkspaces();
autoImportDatapackStructures();
if (autoIngest && !configured.isEmpty()) {
IrisLogging.info("Validating " + configured.size()
+ " configured external datapack import(s) before player admission...");
Report report = ingest(null, configured, true);
if (!report.getFailed().isEmpty()) {
String failure = report.getFailed().getFirst();
IrisStartupValidation.markDatapacksInvalid(failure);
return StartupValidationOutcome.FAILED;
}
StartupValidationOutcome outcome = report.changed()
? StartupValidationOutcome.RESTART_REQUIRED
: StartupValidationOutcome.READY;
activeStartupValidation = cacheStartupValidation(root, worldFolders, cacheFile, mcVersion, irisVersion,
autoIngest, stripOverrides, urls);
if (outcome == StartupValidationOutcome.RESTART_REQUIRED) {
IrisStartupValidation.requireRestart(
"Iris installed updated external datapacks; restart must complete before player admission or world creation.");
} else {
IrisStartupValidation.markDatapacksReady();
}
return outcome;
}
ReapplyOutcome reapply = reapplyFromStaging(worldFolders);
if (!reapply.succeeded()) {
String failure = reapply.failure()
.map(DatapackIngestService::failureMessage)
.orElse("External datapack recovery failed.");
IrisStartupValidation.markDatapacksInvalid(failure);
return StartupValidationOutcome.FAILED;
}
activeStartupValidation = cacheStartupValidation(root, worldFolders, cacheFile, mcVersion, irisVersion,
autoIngest, stripOverrides, urls);
if (reapply.changed()) {
IrisStartupValidation.requireRestart(
"Iris repaired external datapack files; restart must complete before player admission or world creation.");
return StartupValidationOutcome.RESTART_REQUIRED;
}
IrisStartupValidation.markDatapacksReady();
return StartupValidationOutcome.READY;
}
public static void runPostStartupTasks() {
refreshWorkspaces();
autoImportDatapackStructures();
refreshStartupValidationAfterMaintenance();
}
private 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));
writeStartupValidationCache(cacheFile, cache);
return cache;
} catch (IOException | RuntimeException exception) {
IrisLogging.warn("Could not persist external datapack startup validation: "
+ failureMessage(exception));
return null;
}
}
private static void refreshStartupValidationAfterMaintenance() {
StartupValidationCache validated = activeStartupValidation;
if (validated == null || !IrisStartupValidation.isReady()) {
return;
}
KList<String> configured = collectConfiguredImports();
List<String> urls = configured.stream().sorted().toList();
boolean autoIngest = IrisSettings.get().getGeneral().autoIngestDatapacks;
boolean stripOverrides = resolveStripOverrides();
String mcVersion = 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();
TRANSACTION_LOCK.lock();
try {
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: "
+ failureMessage(exception));
} finally {
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));
}
private 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) > MAX_METADATA_BYTES) {
return null;
}
StartupValidationCache cache = GSON.fromJson(
readBoundedUtf8(cacheFile, 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;
}
}
private 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 = GSON.toJson(cache).getBytes(StandardCharsets.UTF_8);
if (content.length > MAX_METADATA_BYTES) {
throw new IOException("External datapack startup validation exceeds " + MAX_METADATA_BYTES + " bytes");
}
Files.write(staged, content, StandardOpenOption.TRUNCATE_EXISTING);
forceFile(staged);
try {
Files.move(staged, absolute, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException exception) {
Files.move(staged, absolute, StandardCopyOption.REPLACE_EXISTING);
}
forceDirectoryIfSupported(parent);
} finally {
Files.deleteIfExists(staged);
}
}
static String startupValidationFingerprint(File root, KList<File> worldFolders) 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 = readBoundedBytes(
manifestPath, MAX_MANIFEST_BYTES, "Datapack manifest fingerprint");
updateDigestLong(digest, manifest.length);
digest.update(manifest);
} else if (Files.notExists(manifestPath, LinkOption.NOFOLLOW_LINKS)) {
updateDigestLong(digest, -1L);
} else {
throw new IOException("Cannot determine datapack manifest state at " + manifestPath);
}
Manifest manifest = readCommittedManifest(root);
updateDirectoryFingerprint(digest, "staging", new File(root, "staging"));
updateDirectoryFingerprint(digest, "transactions", new File(root, TRANSACTION_DIRECTORY));
updateDirectoryFingerprint(
digest,
"storage-install-scratch",
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,
installScratchRoot(worldFolder));
for (Entry entry : entries) {
updateDirectoryFingerprint(
digest,
"world-pack:" + worldIdentity + ":" + entry.id,
new File(worldFolder, entry.id));
}
}
return hex(digest.digest());
} catch (NoSuchAlgorithmException exception) {
throw new IOException("SHA-256 algorithm unavailable", exception);
}
}
private 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, directoryHash(directory));
Path ownership = new File(directory, OWNERSHIP_MARKER).toPath();
if (Files.isRegularFile(ownership, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(ownership)) {
byte[] marker = readBoundedBytes(
ownership, MAX_OWNERSHIP_BYTES, "External datapack ownership fingerprint");
updateDigestLong(digest, marker.length);
digest.update(marker);
} else {
updateDigestLong(digest, -1L);
}
}
private static void updateFingerprintValue(MessageDigest digest, String value) {
byte[] bytes = Objects.requireNonNullElse(value, "").getBytes(StandardCharsets.UTF_8);
updateDigestInt(digest, bytes.length);
digest.update(bytes);
}
private static String failureMessage(Throwable exception) {
if (exception == null) {
return "unknown failure";
}
String message = exception.getMessage();
return message == null || message.isBlank() ? exception.getClass().getSimpleName() : message;
}
public static void refreshWorkspaces() {
@@ -158,6 +513,7 @@ public final class DatapackIngestService {
}
public static Report ingest(VolmitSender sender, KList<String> urls, boolean restart) {
IrisStartupValidation.beginDatapackValidation();
ServerConfigurator.LoadedDatapackRuntimeInvalidation invalidation =
ServerConfigurator.invalidateLoadedDatapackRuntime();
Report report;
@@ -176,6 +532,11 @@ public final class DatapackIngestService {
ServerConfigurator.requireDatapackRestart();
}
}
if (!report.getFailed().isEmpty()) {
IrisStartupValidation.markDatapacksInvalid(report.getFailed().getFirst());
} else if (!report.changed()) {
IrisStartupValidation.markDatapacksReady();
}
return report;
}
@@ -304,6 +665,10 @@ public final class DatapackIngestService {
ServerConfigurator.restoreLoadedDatapackRuntimeIfUnchanged(invalidation);
} else if (outcome.changed()) {
ServerConfigurator.requireDatapackRestart();
} else {
IrisStartupValidation.markDatapacksInvalid(outcome.failure()
.map(DatapackIngestService::failureMessage)
.orElse("External datapack recovery failed."));
}
return outcome;
}
@@ -1279,7 +1644,7 @@ public final class DatapackIngestService {
verifyPendingExtractionName(normalizedSource.getFileName().toString(), entry.id);
validateManagedDirectory(verifiedSource, entry.id);
validateScratchTree(normalizedSource);
if (!Objects.equals(Files.getFileStore(normalizedSource), Files.getFileStore(normalizedStagingRoot))) {
if (!sameScratchVolume(normalizedSource, normalizedStagingRoot)) {
throw new IOException("Verified datapack extraction crosses a filesystem boundary");
}
String desiredHash = directoryHash(verifiedSource);
@@ -1338,8 +1703,9 @@ public final class DatapackIngestService {
private static Path requireDirectoryIdentity(File directory, String purpose) throws IOException {
Path normalized = directory.toPath().toAbsolutePath().normalize();
if (Files.isSymbolicLink(normalized)
|| !Files.isDirectory(normalized, LinkOption.NOFOLLOW_LINKS)) {
BasicFileAttributes attributes = Files.readAttributes(
normalized, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
if (!isSupportedScratchDirectory(attributes)) {
throw new IOException("Invalid " + purpose + " " + normalized);
}
normalized.toRealPath();
@@ -1350,9 +1716,17 @@ public final class DatapackIngestService {
Path current = normalized.getRoot();
for (Path component : normalized) {
current = current == null ? component : current.resolve(component);
if (Files.isSymbolicLink(current)) {
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 (!isSupportedScratchDirectory(attributes)) {
throw new IOException("Refusing unsupported component in " + purpose + " " + normalized);
}
}
}
@@ -1677,7 +2051,7 @@ public final class DatapackIngestService {
private static void validateInstallTree(File directory, File storeAnchor, String purpose) throws IOException {
validateScratchTree(directory.toPath());
if (!Objects.equals(Files.getFileStore(directory.toPath()), Files.getFileStore(storeAnchor.toPath()))) {
if (!sameScratchVolume(directory.toPath(), storeAnchor.toPath())) {
throw new IOException(purpose + " crosses a filesystem boundary at " + directory.getPath());
}
}
@@ -1990,21 +2364,26 @@ public final class DatapackIngestService {
plan.pendingRoot.delete();
}
private static void deleteInstallScratch(File scratch, String purpose) throws IOException {
if (Files.notExists(scratch.toPath(), LinkOption.NOFOLLOW_LINKS)) {
return;
}
if (!Files.exists(scratch.toPath(), LinkOption.NOFOLLOW_LINKS)
|| Files.isSymbolicLink(scratch.toPath())
|| !Files.isDirectory(scratch.toPath(), LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Refusing to remove unsafe " + purpose + " " + scratch.getPath());
}
static void deleteInstallScratch(File scratch, String purpose) throws IOException {
Path scratchPath = scratch.toPath();
File parent = Objects.requireNonNull(scratch.getParentFile(), "datapack scratch parent");
validateInstallTree(scratch, parent, purpose);
IO.delete(scratch);
if (Files.exists(scratch.toPath(), LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Could not remove " + purpose + " " + scratch.getPath());
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);
removeFinderMetadata(scratch);
IO.delete(scratch);
if (Files.notExists(scratchPath, LinkOption.NOFOLLOW_LINKS)) {
return;
}
}
throw new IOException("Could not remove " + purpose + " " + scratch.getPath());
}
private static boolean resolveStripOverrides() {
@@ -2271,6 +2650,7 @@ public final class DatapackIngestService {
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();
@@ -2315,9 +2695,14 @@ public final class DatapackIngestService {
BasicFileAttributes.class,
LinkOption.NOFOLLOW_LINKS
);
if (!attributes.isDirectory() && !attributes.isRegularFile()) {
if (attributes.isSymbolicLink()
|| attributes.isOther()
|| !attributes.isDirectory() && !attributes.isRegularFile()) {
throw new IOException("Datapack entry changed while hashing: " + relative);
}
if (!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));
updateDigestInt(digest, relativeBytes.length);
@@ -3809,16 +4194,78 @@ public final class DatapackIngestService {
BasicFileAttributes attributes = Files.readAttributes(
entry, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
if (attributes.isSymbolicLink()
|| attributes.isOther()
|| (!attributes.isDirectory() && !attributes.isRegularFile())) {
throw new IOException("Datapack scratch contains an unsupported file: " + entry);
}
if (!Objects.equals(rootStore, Files.getFileStore(entry))) {
if (!sameScratchVolume(root, rootStore, entry, Files.getFileStore(entry))) {
throw new IOException("Datapack scratch crosses a filesystem boundary: " + entry);
}
}
}
}
private 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();
}
private static boolean isDefaultWindowsPath(Path path) {
return File.separatorChar == '\\'
&& path.getFileSystem().equals(FileSystems.getDefault());
}
private static Ownership verifyManagedScratchDirectory(File directory, String id) throws IOException {
validateManagedDirectory(directory, id);
Ownership ownership = readOwnership(directory);
@@ -4752,9 +5199,8 @@ public final class DatapackIngestService {
if (!Objects.equals(legacyStagingSnapshot.normalizedTarget().getParent(), normalizedStagingRoot)
|| !Files.isSameFile(
legacyStagingSnapshot.normalizedTarget().getParent(), normalizedStagingRoot)
|| !Objects.equals(
Files.getFileStore(legacyStagingSnapshot.normalizedTarget()),
Files.getFileStore(normalizedStagingRoot))) {
|| !sameScratchVolume(
legacyStagingSnapshot.normalizedTarget(), normalizedStagingRoot)) {
throw new IOException("Changed or unsafe canonical legacy datapack staging target for " + id);
}
verifyDirectorySnapshot(
@@ -4831,6 +5277,22 @@ public final class DatapackIngestService {
public Map<String, Map<String, String>> importedBundles = new HashMap<>();
}
static final class StartupValidationCache {
int schemaVersion;
String minecraftVersion;
int irisVersion;
boolean autoIngest;
boolean stripOverrides;
List<String> urls;
String localFingerprint;
}
public enum StartupValidationOutcome {
READY,
RESTART_REQUIRED,
FAILED
}
private static final class Manifest {
private List<Entry> entries = new ArrayList<>();
@@ -1,5 +1,6 @@
package art.arcane.iris.core.lifecycle;
import art.arcane.iris.core.IrisStartupValidation;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.core.tools.IrisToolbelt;
@@ -76,6 +77,7 @@ public final class WorldLifecycleService {
public CompletableFuture<World> create(WorldLifecycleRequest request) {
WorldLifecycleBackend backend;
try {
IrisStartupValidation.requireWorldCreationReady();
backend = selectCreateBackend(request);
} catch (Throwable e) {
IrisLogging.reportError("WorldLifecycle create backend selection failed for world=\"" + request.worldName()
@@ -0,0 +1,201 @@
package art.arcane.iris.core.pack;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.spi.IrisPlatform;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.PlatformRegistries;
import art.arcane.iris.spi.PlatformStructureHooks;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
public final class PackValidationCache {
private static final int SCHEMA_VERSION = 1;
private static final long MAX_CACHE_BYTES = 16L * 1024L * 1024L;
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private PackValidationCache() {
}
public static String contentFingerprint(File packsRoot) {
return ServerConfigurator.computePackFingerprint(packsRoot);
}
public static String contextFingerprint() {
if (!IrisPlatforms.isBound()) {
return "";
}
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
IrisPlatform platform = IrisPlatforms.get();
update(digest, platform.platformName());
update(digest, platform.minecraftVersion());
update(digest, Integer.toString(platform.irisVersionNumber()));
update(digest, Boolean.toString(ContentKeyValidator.strictContent()));
PlatformRegistries registries = Objects.requireNonNull(
platform.registries(), "Pack validation platform registries");
updateSorted(digest, registries.blockKeys());
updateSorted(digest, registries.biomeKeys());
updateSorted(digest, registries.itemKeys());
updateSorted(digest, registries.entityKeys());
PlatformStructureHooks hooks = Objects.requireNonNull(
platform.structureHooks(), "Pack validation structure hooks");
updateSorted(digest, hooks.structureKeys());
updateSorted(digest, hooks.jigsawStructureKeys());
updateSorted(digest, hooks.templatePoolKeys());
updateSorted(digest, hooks.structureSetKeys());
updateSorted(digest, hooks.objectFeatureKeys());
return HexFormat.of().formatHex(digest.digest());
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 is unavailable", exception);
}
}
public static Optional<List<PackValidationResult>> load(
Path cacheFile,
String contentFingerprint,
String contextFingerprint,
List<String> expectedPackNames
) {
if (cacheFile == null
|| contentFingerprint == null || contentFingerprint.isBlank()
|| contextFingerprint == null || contextFingerprint.isBlank()
|| !Files.isRegularFile(cacheFile, LinkOption.NOFOLLOW_LINKS)
|| Files.isSymbolicLink(cacheFile)) {
return Optional.empty();
}
try {
if (Files.size(cacheFile) > MAX_CACHE_BYTES) {
return Optional.empty();
}
CacheState state = GSON.fromJson(Files.readString(cacheFile, StandardCharsets.UTF_8), CacheState.class);
if (state == null
|| state.schemaVersion != SCHEMA_VERSION
|| !contentFingerprint.equals(state.contentFingerprint)
|| !contextFingerprint.equals(state.contextFingerprint)
|| state.results == null) {
return Optional.empty();
}
Set<String> expected = new HashSet<>(expectedPackNames == null ? List.of() : expectedPackNames);
List<PackValidationResult> results = new ArrayList<>(state.results.size());
Set<String> actual = new HashSet<>();
for (CachedResult cached : state.results) {
if (cached == null || cached.packName == null || cached.packName.isBlank()
|| cached.blockingErrors == null || cached.warnings == null
|| !actual.add(cached.packName)) {
return Optional.empty();
}
results.add(new PackValidationResult(
cached.packName,
cached.blockingErrors,
cached.warnings,
cached.validatedAtMillis));
}
if (!expected.equals(actual)) {
return Optional.empty();
}
results.sort(Comparator.comparing(PackValidationResult::getPackName));
return Optional.of(List.copyOf(results));
} catch (IOException | RuntimeException exception) {
return Optional.empty();
}
}
public static void save(
Path cacheFile,
String contentFingerprint,
String contextFingerprint,
List<PackValidationResult> results
) throws IOException {
if (cacheFile == null || contentFingerprint == null || contentFingerprint.isBlank()
|| contextFingerprint == null || contextFingerprint.isBlank()) {
return;
}
List<PackValidationResult> sorted = new ArrayList<>(results == null ? List.of() : results);
sorted.sort(Comparator.comparing(PackValidationResult::getPackName));
CacheState state = new CacheState();
state.schemaVersion = SCHEMA_VERSION;
state.contentFingerprint = contentFingerprint;
state.contextFingerprint = contextFingerprint;
state.results = new ArrayList<>(sorted.size());
for (PackValidationResult result : sorted) {
CachedResult cached = new CachedResult();
cached.packName = result.getPackName();
cached.blockingErrors = List.copyOf(result.getBlockingErrors());
cached.warnings = List.copyOf(result.getWarnings());
cached.validatedAtMillis = result.getValidatedAtMillis();
state.results.add(cached);
}
Path absolute = cacheFile.toAbsolutePath().normalize();
Path parent = Objects.requireNonNull(absolute.getParent(), "Pack validation cache parent");
Files.createDirectories(parent);
Path staged = Files.createTempFile(parent, ".pack-validation-", ".tmp");
try {
byte[] content = GSON.toJson(state).getBytes(StandardCharsets.UTF_8);
if (content.length > MAX_CACHE_BYTES) {
throw new IOException("Pack validation cache exceeds " + MAX_CACHE_BYTES + " bytes");
}
Files.write(staged, content);
try {
Files.move(staged, absolute, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException exception) {
Files.move(staged, absolute, StandardCopyOption.REPLACE_EXISTING);
}
} finally {
Files.deleteIfExists(staged);
}
}
private static void updateSorted(MessageDigest digest, List<String> values) {
List<String> sorted = new ArrayList<>(Objects.requireNonNull(values, "Pack validation registry keys"));
sorted.sort(String::compareTo);
update(digest, Integer.toString(sorted.size()));
for (String value : sorted) {
update(digest, value);
}
}
private static void update(MessageDigest digest, String value) {
byte[] bytes = Objects.requireNonNullElse(value, "").getBytes(StandardCharsets.UTF_8);
digest.update((byte) (bytes.length >>> 24));
digest.update((byte) (bytes.length >>> 16));
digest.update((byte) (bytes.length >>> 8));
digest.update((byte) bytes.length);
digest.update(bytes);
}
private static final class CacheState {
private int schemaVersion;
private String contentFingerprint;
private String contextFingerprint;
private List<CachedResult> results;
}
private static final class CachedResult {
private String packName;
private List<String> blockingErrors;
private List<String> warnings;
private long validatedAtMillis;
}
}
@@ -218,9 +218,7 @@ public final class StudioOpenCoordinator {
if (request.openKind().openWorkspace() && request.project() != null) {
new IrisCodeWorkspace(request.project()).openVSCode(request.sender());
}
if (request.onDone() != null) {
request.onDone().accept(world);
}
runOpenFinalizer(request.onDone(), world);
t = logStudioPhase(request, "finalize_open", t, openStart);
IrisLogging.info("Studio open: " + world.getName() + " ready in "
@@ -272,6 +270,20 @@ public final class StudioOpenCoordinator {
return now;
}
private void runOpenFinalizer(Consumer<World> finalizer, World world)
throws InterruptedException, ExecutionException, TimeoutException {
if (finalizer == null) {
return;
}
if (J.isPrimaryThread()) {
finalizer.accept(world);
return;
}
CompletableFuture<Void> completion = J.sfut(() -> finalizer.accept(world));
completion.get(STUDIO_STRUCTURE_ACTIVATION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
}
private long elapsedMillis(long startedAtNanos) {
return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAtNanos);
}
@@ -157,7 +157,9 @@ public final class JigsawStudioGraphEditor {
IrisStructure structure = readStructure(
resolveOwnedResource(graph.root(), structureResource),
structureResource);
Map<String, JigsawPlanarArchetype> expectedSources = expectedThemeSetSources(structure);
Map<String, JigsawPlanarArchetype> expectedSources = expectedThemeSetSources(
structure,
requestedSources.keySet());
requireExactThemeSetSources(requestedSources, expectedSources.keySet());
Map<String, byte[]> resources = readOwnedResources(graph);
@@ -200,7 +202,9 @@ public final class JigsawStudioGraphEditor {
+ "' is not owned by this jigsaw project");
}
String variantFolder = expectedArchetype == null
? expectedSources.size() == 1
? "spatial"
: "spatial/" + sourcePieceKey
: expectedArchetype.name().toLowerCase(Locale.ROOT);
String targetPieceKey = graph.manifest().structure().path()
+ "/variants/" + variantFolder + "/" + themeKey;
@@ -686,11 +690,23 @@ public final class JigsawStudioGraphEditor {
}
private static Map<String, JigsawPlanarArchetype> expectedThemeSetSources(
IrisStructure structure
IrisStructure structure,
Set<String> requestedStableIds
) throws IOException {
Map<String, JigsawPlanarArchetype> expected = new LinkedHashMap<>();
if (structure.resolvedMode() == IrisJigsawMode.SPATIAL_JIGSAW) {
expected.put(JigsawStudioLayout.SPATIAL_WORKCELL_ID, null);
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;
@@ -27,6 +27,7 @@ public final class JigsawStudioLayout {
private final JigsawStudioVariantCatalog variantCatalog;
private final List<JigsawStudioBay> bays;
private final Map<String, JigsawStudioBay> byStableId;
private final Map<String, String> spatialVariantByBay;
private JigsawStudioLayout(
JigsawStudioMode mode,
@@ -34,7 +35,8 @@ public final class JigsawStudioLayout {
int columns,
int gap,
JigsawStudioVariantCatalog variantCatalog,
List<JigsawStudioBay> bays
List<JigsawStudioBay> bays,
Map<String, String> spatialVariantByBay
) {
this.mode = mode;
this.cellDimensions = cellDimensions;
@@ -50,6 +52,7 @@ public final class JigsawStudioLayout {
}
}
this.byStableId = Collections.unmodifiableMap(index);
this.spatialVariantByBay = Collections.unmodifiableMap(new LinkedHashMap<>(spatialVariantByBay));
}
public static JigsawStudioLayout create(
@@ -87,20 +90,41 @@ public final class JigsawStudioLayout {
validateCatalogMode(JigsawStudioMode.SPATIAL_JIGSAW, catalog);
String resolvedDisplayName = displayName == null ? "" : displayName.trim();
List<JigsawStudioBay> workcells = new ArrayList<>();
workcells.add(new JigsawStudioBay(
SPATIAL_WORKCELL_ID,
JigsawStudioBayKind.SPATIAL_WORKCELL,
Optional.empty(),
resolvedDisplayName,
new JigsawStudioBounds(FIRST_ORIGIN, FLOOR_Y + 1, FIRST_ORIGIN, dimensions)));
List<JigsawStudioVariant> variants = catalog.spatialVariants();
List<JigsawStudioBay> workcells = new ArrayList<>(Math.max(1, variants.size()));
Map<String, String> variantsByWorkcell = new LinkedHashMap<>();
if (variants.isEmpty()) {
workcells.add(new JigsawStudioBay(
SPATIAL_WORKCELL_ID,
JigsawStudioBayKind.SPATIAL_WORKCELL,
Optional.empty(),
resolvedDisplayName,
new JigsawStudioBounds(FIRST_ORIGIN, FLOOR_Y + 1, FIRST_ORIGIN, dimensions)));
} else {
int originX = FIRST_ORIGIN;
for (int index = 0; index < variants.size(); index++) {
JigsawStudioVariant variant = variants.get(index);
String stableId = index == 0
? SPATIAL_WORKCELL_ID
: SPATIAL_WORKCELL_ID + "/" + variant.pieceKey();
workcells.add(new JigsawStudioBay(
stableId,
JigsawStudioBayKind.SPATIAL_WORKCELL,
Optional.empty(),
variant.resolvedDisplayName(),
new JigsawStudioBounds(originX, FLOOR_Y + 1, FIRST_ORIGIN, dimensions)));
variantsByWorkcell.put(stableId, variant.pieceKey());
originX = Math.addExact(originX, Math.addExact(dimensions.width(), PLANAR_GAP));
}
}
return new JigsawStudioLayout(
JigsawStudioMode.SPATIAL_JIGSAW,
dimensions,
1,
workcells.size(),
PLANAR_GAP,
catalog,
workcells);
workcells,
variantsByWorkcell);
}
public static JigsawStudioLayout createPlanar(
@@ -143,7 +167,8 @@ public final class JigsawStudioLayout {
PLANAR_COLUMNS,
PLANAR_GAP,
catalog,
workcells);
workcells,
Map.of());
}
public JigsawStudioMode mode() {
@@ -186,7 +211,11 @@ public final class JigsawStudioLayout {
public List<JigsawStudioVariant> variants(JigsawStudioBay workcell) {
JigsawStudioBay activeWorkcell = requireWorkcell(workcell);
if (activeWorkcell.kind() == JigsawStudioBayKind.SPATIAL_WORKCELL) {
return variantCatalog.spatialVariants();
String pieceKey = spatialVariantByBay.get(activeWorkcell.stableId());
if (pieceKey == null) {
return variantCatalog.spatialVariants();
}
return variantCatalog.find(pieceKey).map(List::of).orElseGet(List::of);
}
return variantCatalog.variants(activeWorkcell.archetype().orElseThrow());
}
@@ -203,11 +232,28 @@ public final class JigsawStudioLayout {
return false;
}
if (activeWorkcell.kind() == JigsawStudioBayKind.SPATIAL_WORKCELL) {
return activeVariant.mode() == JigsawStudioMode.SPATIAL_JIGSAW;
return activeVariant.mode() == JigsawStudioMode.SPATIAL_JIGSAW
&& variants(activeWorkcell).contains(activeVariant);
}
return activeVariant.archetype().filter(activeWorkcell.archetype().orElseThrow()::equals).isPresent();
}
public Optional<JigsawStudioBay> workcellForVariant(String pieceKey) {
Optional<JigsawStudioVariant> variant = variantCatalog.find(pieceKey);
if (variant.isEmpty()) {
return Optional.empty();
}
if (mode == JigsawStudioMode.PLANAR_JIGSAW) {
return variant.get().archetype().map(archetype -> get(archetype.stableId()));
}
for (Map.Entry<String, String> entry : spatialVariantByBay.entrySet()) {
if (entry.getValue().equals(variant.get().pieceKey())) {
return Optional.ofNullable(get(entry.getKey()));
}
}
return Optional.ofNullable(get(SPATIAL_WORKCELL_ID));
}
public JigsawStudioControlPosition controlPosition() {
return CONTROL_POSITION;
}
@@ -30,11 +30,19 @@ import com.google.gson.GsonBuilder;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
public final class JigsawStudioProjectCreator {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private static final List<IrisDirection> SPATIAL_CONNECTOR_ORDER = List.of(
IrisDirection.NORTH_NEGATIVE_Z,
IrisDirection.SOUTH_POSITIVE_Z,
IrisDirection.EAST_POSITIVE_X,
IrisDirection.WEST_NEGATIVE_X,
IrisDirection.UP_POSITIVE_Y,
IrisDirection.DOWN_NEGATIVE_Y);
private JigsawStudioProjectCreator() {
}
@@ -73,7 +81,7 @@ public final class JigsawStudioProjectCreator {
if (options.mode() == JigsawStudioMode.PLANAR_JIGSAW) {
addPlanarDefaults(bundle, structure, pool, options);
} else {
addSpatialDefault(bundle, pool, options);
addSpatialDefaults(bundle, pool, options);
}
bundle.textResource("jigsaw-pools/" + resourceKey + "/start.json", GSON.toJson(pool) + "\n");
bundle.textResource("structures/" + resourceKey + ".json", GSON.toJson(structure) + "\n");
@@ -156,23 +164,82 @@ public final class JigsawStudioProjectCreator {
bundle.textResource("jigsaw-pools/" + capPoolKey + ".json", GSON.toJson(capPool) + "\n");
}
private static void addSpatialDefault(
private static void addSpatialDefaults(
StructureResourceBundle.Builder bundle,
IrisJigsawPool pool,
Options options
) throws IOException {
String key = options.structureKey() + "/start";
JigsawStudioCellDimensions dimensions = options.cellDimensions();
IrisJigsawPiece piece = new IrisJigsawPiece().setObject(key).setRotatable(true);
if (options.compatibilityTarget() == JigsawStudioCompatibilityTarget.IRIS_EXTENDED) {
piece.getThemes().add("variant-1");
String piecePoolKey = options.structureKey() + "/pieces";
IrisJigsawPool piecePool = new IrisJigsawPool();
for (int connectorCount = 0; connectorCount <= SPATIAL_CONNECTOR_ORDER.size(); connectorCount++) {
String key = options.structureKey() + "/"
+ (connectorCount == 0 ? "start" : "connectors-" + connectorCount);
IrisJigsawPiece piece = spatialPiece(
key,
piecePoolKey,
dimensions,
connectorCount);
if (options.compatibilityTarget() == JigsawStudioCompatibilityTarget.IRIS_EXTENDED) {
piece.getThemes().add("variant-1");
}
pool.getPieces().add(new IrisJigsawPieceEntry(key, 1));
if (connectorCount > 0) {
piecePool.getPieces().add(new IrisJigsawPieceEntry(key, 1));
}
bundle.resource("objects/" + key + ".iob", serialize(new IrisObject(
dimensions.width(),
dimensions.height(),
dimensions.depth())));
bundle.textResource("jigsaw-pieces/" + key + ".json", GSON.toJson(piece) + "\n");
}
pool.getPieces().add(new IrisJigsawPieceEntry(key, 1));
bundle.resource("objects/" + key + ".iob", serialize(new IrisObject(
dimensions.width(),
dimensions.height(),
dimensions.depth())));
bundle.textResource("jigsaw-pieces/" + key + ".json", GSON.toJson(piece) + "\n");
piecePool.getPieces().add(new IrisJigsawPieceEntry().setEmpty(true));
bundle.textResource("jigsaw-pools/" + piecePoolKey + ".json", GSON.toJson(piecePool) + "\n");
}
private static IrisJigsawPiece spatialPiece(
String objectKey,
String poolKey,
JigsawStudioCellDimensions dimensions,
int connectorCount
) {
IrisJigsawPiece piece = new IrisJigsawPiece()
.setDisplayName(connectorCount + (connectorCount == 1 ? " Connector" : " Connectors"))
.setObject(objectKey)
.setRotatable(true)
.setRules(new IrisJigsawPieceRules().setMaximumPlacements(16));
for (int index = 0; index < connectorCount; index++) {
IrisDirection direction = SPATIAL_CONNECTOR_ORDER.get(index);
piece.getConnectors().add(new IrisJigsawConnector()
.setPosition(spatialConnectorPosition(dimensions, direction))
.setDirection(direction)
.setTop(direction.isVertical()
? IrisDirection.NORTH_NEGATIVE_Z
: IrisDirection.UP_POSITIVE_Y)
.setPool(poolKey)
.setName("iris:spatial")
.setTargetName("iris:spatial")
.setJoint(JigsawJoint.ROLLABLE)
.setFinalState("minecraft:structure_void"));
}
return piece;
}
private static IrisPosition spatialConnectorPosition(
JigsawStudioCellDimensions dimensions,
IrisDirection direction
) {
int centerX = dimensions.width() / 2;
int centerY = dimensions.height() / 2;
int centerZ = dimensions.depth() / 2;
return switch (direction) {
case NORTH_NEGATIVE_Z -> new IrisPosition(centerX, centerY, 0);
case SOUTH_POSITIVE_Z -> new IrisPosition(centerX, centerY, dimensions.depth() - 1);
case EAST_POSITIVE_X -> new IrisPosition(dimensions.width() - 1, centerY, centerZ);
case WEST_NEGATIVE_X -> new IrisPosition(0, centerY, centerZ);
case UP_POSITIVE_Y -> new IrisPosition(centerX, dimensions.height() - 1, centerZ);
case DOWN_NEGATIVE_Y -> new IrisPosition(centerX, 0, centerZ);
};
}
private static IrisJigsawPiece planarPiece(
@@ -1,272 +0,0 @@
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.JigsawStudioLayout;
import art.arcane.iris.util.common.scheduling.J;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.entity.BlockDisplay;
import org.bukkit.entity.Display;
import org.bukkit.util.Transformation;
import org.joml.Quaternionf;
import org.joml.Vector3f;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
public final class JigsawStudioDisabledWorkcellRenderer {
private static final String ENTITY_TAG = "iris_jigsaw_disabled_workcell";
private final Map<UUID, RequestDisplays> requests = new HashMap<>();
public void reconcile(World world, UUID requestId, JigsawStudioLayout layout) {
World activeWorld = Objects.requireNonNull(world, "Jigsaw Studio display world");
UUID activeRequestId = Objects.requireNonNull(requestId, "Jigsaw Studio display request ID");
Map<String, Descriptor> desired = descriptors(Objects.requireNonNull(
layout,
"Jigsaw Studio display layout"));
List<BlockDisplay> removals = new ArrayList<>();
long generation;
synchronized (this) {
RequestDisplays state = requests.computeIfAbsent(
activeRequestId,
ignored -> new RequestDisplays(activeWorld.getUID()));
if (!state.worldId.equals(activeWorld.getUID())) {
removals.addAll(state.entities.values());
state = new RequestDisplays(activeWorld.getUID());
requests.put(activeRequestId, state);
}
generation = Math.incrementExact(state.generation);
state.generation = generation;
state.desired.clear();
state.desired.putAll(desired);
for (Map.Entry<String, BlockDisplay> entry : new ArrayList<>(state.entities.entrySet())) {
Descriptor descriptor = desired.get(entry.getKey());
if (descriptor == null || !descriptor.equals(state.rendered.get(entry.getKey()))) {
state.entities.remove(entry.getKey());
state.rendered.remove(entry.getKey());
removals.add(entry.getValue());
}
}
}
remove(removals);
for (Descriptor descriptor : desired.values()) {
scheduleSpawn(activeWorld, activeRequestId, generation, descriptor);
}
}
public void unloadChunk(UUID requestId, int chunkX, int chunkZ) {
if (requestId == null) {
return;
}
List<BlockDisplay> removals;
synchronized (this) {
RequestDisplays state = requests.get(requestId);
if (state == null) {
return;
}
removals = detachChunkDisplays(state.entities, state.rendered, chunkX, chunkZ);
}
remove(removals);
}
public void removeRequest(UUID requestId) {
if (requestId == null) {
return;
}
RequestDisplays removed;
synchronized (this) {
removed = requests.remove(requestId);
}
if (removed != null) {
remove(new ArrayList<>(removed.entities.values()));
}
}
public void removeAll() {
List<BlockDisplay> removals = new ArrayList<>();
synchronized (this) {
for (RequestDisplays state : requests.values()) {
removals.addAll(state.entities.values());
}
requests.clear();
}
remove(removals);
}
static Map<String, Descriptor> descriptors(JigsawStudioLayout layout) {
Map<String, Descriptor> descriptors = new LinkedHashMap<>();
for (JigsawStudioBay bay : layout.bays()) {
if (bay.enabled()) {
continue;
}
JigsawStudioBounds bounds = bay.bounds();
descriptors.put(bay.stableId(), new Descriptor(
bay.stableId(),
bounds.originX(),
bounds.originY(),
bounds.originZ(),
bounds.dimensions().width(),
bounds.dimensions().height(),
bounds.dimensions().depth()));
}
return Map.copyOf(descriptors);
}
synchronized int activeDisplayCount(UUID requestId) {
RequestDisplays state = requests.get(requestId);
return state == null ? 0 : state.entities.size();
}
static List<BlockDisplay> detachChunkDisplays(
Map<String, BlockDisplay> entities,
Map<String, Descriptor> rendered,
int chunkX,
int chunkZ
) {
List<BlockDisplay> removals = new ArrayList<>();
for (Map.Entry<String, BlockDisplay> entry : new ArrayList<>(entities.entrySet())) {
Descriptor descriptor = rendered.get(entry.getKey());
if (descriptor == null
|| descriptor.originX() >> 4 != chunkX
|| descriptor.originZ() >> 4 != chunkZ) {
continue;
}
entities.remove(entry.getKey());
rendered.remove(entry.getKey());
removals.add(entry.getValue());
}
return List.copyOf(removals);
}
private void scheduleSpawn(
World world,
UUID requestId,
long generation,
Descriptor descriptor
) {
synchronized (this) {
RequestDisplays state = requests.get(requestId);
if (state == null
|| state.generation != generation
|| state.entities.containsKey(descriptor.workcellId())
|| !descriptor.equals(state.desired.get(descriptor.workcellId()))) {
return;
}
}
J.runRegion(
world,
descriptor.originX() >> 4,
descriptor.originZ() >> 4,
() -> spawn(world, requestId, generation, descriptor));
}
private void spawn(
World world,
UUID requestId,
long generation,
Descriptor descriptor
) {
if (!world.isChunkLoaded(descriptor.originX() >> 4, descriptor.originZ() >> 4)) {
return;
}
synchronized (this) {
RequestDisplays state = requests.get(requestId);
if (state == null
|| state.generation != generation
|| state.entities.containsKey(descriptor.workcellId())
|| !descriptor.equals(state.desired.get(descriptor.workcellId()))) {
return;
}
}
BlockDisplay display = world.spawn(
new Location(world, descriptor.originX(), descriptor.originY(), descriptor.originZ()),
BlockDisplay.class,
entity -> configure(entity, descriptor));
boolean retained;
synchronized (this) {
RequestDisplays state = requests.get(requestId);
retained = state != null
&& state.generation == generation
&& !state.entities.containsKey(descriptor.workcellId())
&& descriptor.equals(state.desired.get(descriptor.workcellId()));
if (retained) {
state.entities.put(descriptor.workcellId(), display);
state.rendered.put(descriptor.workcellId(), descriptor);
}
}
if (!retained) {
remove(display);
}
}
private static void configure(BlockDisplay display, Descriptor descriptor) {
display.setBlock(Material.RED_STAINED_GLASS.createBlockData());
display.setTransformation(new Transformation(
new Vector3f(),
new Quaternionf(),
new Vector3f(descriptor.width(), descriptor.height(), descriptor.depth()),
new Quaternionf()));
display.setBrightness(new Display.Brightness(15, 15));
display.setDisplayWidth(Math.max(descriptor.width(), descriptor.depth()));
display.setDisplayHeight(descriptor.height());
display.setViewRange(128.0F);
display.setShadowRadius(0.0F);
display.setShadowStrength(0.0F);
display.setInterpolationDuration(0);
display.setTeleportDuration(0);
display.setPersistent(false);
display.setInvulnerable(true);
display.setGravity(false);
display.setSilent(true);
display.addScoreboardTag(ENTITY_TAG);
}
private static void remove(List<BlockDisplay> displays) {
for (BlockDisplay display : displays) {
remove(display);
}
}
private static void remove(BlockDisplay display) {
if (display != null) {
J.runEntity(display, display::remove);
}
}
record Descriptor(
String workcellId,
int originX,
int originY,
int originZ,
int width,
int height,
int depth
) {
Descriptor {
workcellId = Objects.requireNonNull(workcellId, "Jigsaw Studio display workcell ID");
if (width < 1 || height < 1 || depth < 1) {
throw new IllegalArgumentException("Jigsaw Studio display dimensions must be positive");
}
}
}
private static final class RequestDisplays {
private final UUID worldId;
private final Map<String, Descriptor> desired = new HashMap<>();
private final Map<String, Descriptor> rendered = new HashMap<>();
private final Map<String, BlockDisplay> entities = new HashMap<>();
private long generation;
private RequestDisplays(UUID worldId) {
this.worldId = worldId;
}
}
}
@@ -36,6 +36,8 @@ public final class JigsawStudioMenuController {
static final int PLACEMENT_RULE_SHIFT_STEP = 16;
private static final long DESTRUCTIVE_CONFIRM_NANOS = 10_000_000_000L;
private static final int WORKCELL_RESIZE_REFRESH_TICKS = 2;
private static final int WORKCELL_RESIZE_REFRESH_ATTEMPTS = 200;
private static final int[] GRID_POSITIONS = {-3, -2, -1, 0, 1, 2, 3};
private final JavaPlugin plugin;
@@ -44,6 +46,7 @@ public final class JigsawStudioMenuController {
private final Map<UUID, PendingUnlink> pendingUnlinks = new ConcurrentHashMap<>();
private final Map<UUID, PendingDelete> pendingDeletes = new ConcurrentHashMap<>();
private final Map<UUID, PendingProjectDelete> pendingProjectDeletes = new ConcurrentHashMap<>();
private final Map<UUID, PendingWorkcellResize> pendingWorkcellResizes = new ConcurrentHashMap<>();
public JigsawStudioMenuController(JavaPlugin plugin, Actions actions) {
this.plugin = Objects.requireNonNull(plugin, "Jigsaw Studio menu plugin");
@@ -100,11 +103,13 @@ public final class JigsawStudioMenuController {
pendingUnlinks.remove(playerId);
pendingDeletes.remove(playerId);
pendingProjectDeletes.remove(playerId);
pendingWorkcellResizes.remove(playerId);
});
windows.put(playerId, window);
pendingUnlinks.remove(playerId);
pendingDeletes.remove(playerId);
pendingProjectDeletes.remove(playerId);
pendingWorkcellResizes.remove(playerId);
renderMain(window, state, selected, 0);
window.open();
return true;
@@ -242,6 +247,7 @@ public final class JigsawStudioMenuController {
pendingUnlinks.clear();
pendingDeletes.clear();
pendingProjectDeletes.clear();
pendingWorkcellResizes.clear();
for (UIWindow window : activeWindows) {
Player player = window.getViewer();
if (J.isOwnedByCurrentRegion(player)) {
@@ -508,26 +514,38 @@ public final class JigsawStudioMenuController {
JigsawStudioMenuState state,
JigsawStudioMenuState.Workcell workcell
) {
PendingWorkcellResize pendingResize = pendingWorkcellResize(
window.getViewer().getUniqueId(),
state.requestId(),
workcell.stableId());
JigsawStudioMenuState.Workcell renderedWorkcell = pendingResize == null
? workcell
: withCapacity(workcell, pendingResize.dimensions());
window.batch(() -> {
window.clearElements();
UIElement back = element("settings-back", Material.ARROW, ChatColor.YELLOW + "Back to Variants");
back.onLeftClick(clicked -> refreshMain(
window.getViewer(), state.requestId(), workcell.stableId(), 0));
back.onLeftClick(clicked -> leaveWorkcellSettings(
window.getViewer(), state.requestId(), renderedWorkcell.stableId()));
window.setElement(-4, 0, back);
UIElement identity = element(
"settings-identity",
workcell.enabled() ? Material.LIME_WOOL : Material.GRAY_WOOL,
ChatColor.AQUA + safe(workcell.displayName()));
identity.addLore(ChatColor.DARK_GRAY + safe(workcell.stableId()));
identity.addLore(workcell.enabled()
renderedWorkcell.enabled() ? Material.LIME_WOOL : Material.GRAY_WOOL,
ChatColor.AQUA + safe(renderedWorkcell.displayName()));
identity.addLore(ChatColor.DARK_GRAY + safe(renderedWorkcell.stableId()));
identity.addLore(renderedWorkcell.enabled()
? ChatColor.GREEN + "Enabled"
: ChatColor.RED + "Disabled for assembly and export");
if (!workcell.canonicalName().equals(workcell.displayName())) {
identity.addLore(ChatColor.GRAY + "Solver role: " + safe(workcell.canonicalName()));
if (!renderedWorkcell.canonicalName().equals(renderedWorkcell.displayName())) {
identity.addLore(ChatColor.GRAY + "Solver role: " + safe(renderedWorkcell.canonicalName()));
}
identity.addLore(ChatColor.GRAY + "Capacity: " + dimensions(renderedWorkcell.capacity()));
if (pendingResize != null) {
identity.addLore(pendingResize.applying()
? ChatColor.YELLOW + "Applying one live relayout"
: ChatColor.GOLD + "Pending; apply when all dimensions are ready");
}
identity.addLore(ChatColor.GRAY + "Capacity: " + dimensions(workcell.capacity()));
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.");
@@ -536,25 +554,25 @@ public final class JigsawStudioMenuController {
JigsawStudioToolPayload.workcell(
JigsawStudioToolAction.RENAME_WORKCELL,
state.requestId(),
workcell.stableId())));
renderedWorkcell.stableId())));
window.setElement(0, 0, identity);
window.setElement(4, 0, evaluationElement(state.evaluation()));
if (state.mode() == JigsawStudioMode.PLANAR_JIGSAW) {
UIElement enabled = element(
"settings-enabled",
workcell.enabled() ? Material.LEVER : Material.REDSTONE_TORCH,
workcell.enabled()
renderedWorkcell.enabled() ? Material.LEVER : Material.REDSTONE_TORCH,
renderedWorkcell.enabled()
? ChatColor.GREEN + "Workcell Enabled"
: ChatColor.RED + "Workcell Disabled");
enabled.addLore(ChatColor.GRAY + "Disabled workcells remain editable and keep their size.");
enabled.addLore(ChatColor.YELLOW + "Left-click to "
+ (workcell.enabled() ? "disable" : "enable"));
+ (renderedWorkcell.enabled() ? "disable" : "enable"));
enabled.onLeftClick(clicked -> setWorkcellEnabled(
window.getViewer(),
state.requestId(),
workcell.stableId(),
!workcell.enabled()));
renderedWorkcell.stableId(),
!renderedWorkcell.enabled()));
window.setElement(0, 1, enabled);
} else {
UIElement spatial = element(
@@ -568,18 +586,18 @@ public final class JigsawStudioMenuController {
UIElement connectors = element(
"settings-connectors",
workcell.connectorsVisible() ? Material.JIGSAW : Material.STRUCTURE_VOID,
workcell.connectorsVisible()
renderedWorkcell.connectorsVisible() ? Material.JIGSAW : Material.STRUCTURE_VOID,
renderedWorkcell.connectorsVisible()
? ChatColor.GREEN + "Connector Blocks Visible"
: ChatColor.YELLOW + "Connector Blocks Hidden");
connectors.addLore(ChatColor.GRAY + "Hidden connectors retain their metadata and final block state.");
connectors.addLore(ChatColor.YELLOW + "Left-click to "
+ (workcell.connectorsVisible() ? "hide" : "show") + " connector blocks");
+ (renderedWorkcell.connectorsVisible() ? "hide" : "show") + " connector blocks");
connectors.onLeftClick(clicked -> toggleConnectorBlocks(
window.getViewer(),
state.requestId(),
workcell.stableId(),
!workcell.connectorsVisible()));
renderedWorkcell.stableId(),
!renderedWorkcell.connectorsVisible()));
window.setElement(2, 1, connectors);
UIElement resetConnectors = element(
@@ -591,31 +609,57 @@ public final class JigsawStudioMenuController {
resetConnectors.onLeftClick(clicked -> resetConnectorBlocks(
window.getViewer(),
state.requestId(),
workcell.stableId()));
renderedWorkcell.stableId()));
window.setElement(4, 1, resetConnectors);
window.setElement(-2, 2, axisElement(
window,
state,
workcell,
renderedWorkcell,
DimensionAxis.WIDTH,
Material.IRON_INGOT));
window.setElement(0, 2, axisElement(
window,
state,
workcell,
renderedWorkcell,
DimensionAxis.HEIGHT,
Material.GOLD_INGOT));
window.setElement(2, 2, axisElement(
window,
state,
workcell,
renderedWorkcell,
DimensionAxis.DEPTH,
Material.COPPER_INGOT));
if (pendingResize != null) {
UIElement apply = element(
"settings-apply-capacity",
pendingResize.applying() ? Material.CLOCK : Material.EMERALD_BLOCK,
pendingResize.applying()
? ChatColor.YELLOW + "Applying Cell Size"
: ChatColor.GREEN + "Apply Cell Size");
apply.addLore(ChatColor.WHITE + dimensions(pendingResize.dimensions()));
apply.addLore(ChatColor.GRAY + "Regenerates the layout once after all size edits.");
if (!pendingResize.applying()) {
apply.onLeftClick(clicked -> applyWorkcellResize(
window.getViewer(), state.requestId(), renderedWorkcell.stableId()));
}
window.setElement(0, 3, apply);
if (!pendingResize.applying()) {
UIElement discard = element(
"settings-discard-capacity",
Material.BARRIER,
ChatColor.RED + "Discard Size Changes");
discard.onLeftClick(clicked -> discardWorkcellResize(
window.getViewer(), state.requestId(), renderedWorkcell.stableId()));
window.setElement(2, 3, discard);
}
}
UIElement footerBack = element("settings-footer-back", Material.ARROW, ChatColor.YELLOW + "Back");
footerBack.onLeftClick(clicked -> refreshMain(
window.getViewer(), state.requestId(), workcell.stableId(), 0));
footerBack.onLeftClick(clicked -> leaveWorkcellSettings(
window.getViewer(), state.requestId(), renderedWorkcell.stableId()));
window.setElement(-4, 5, footerBack);
UIElement undo = element(
@@ -629,7 +673,7 @@ public final class JigsawStudioMenuController {
state.requestId()));
window.setElement(0, 5, undo);
if (workcell.dirty() && !workcell.saving()) {
if (renderedWorkcell.dirty() && !renderedWorkcell.saving()) {
UIElement saveNow = element(
"save-now",
Material.EMERALD,
@@ -637,7 +681,7 @@ public final class JigsawStudioMenuController {
saveNow.addLore(ChatColor.GRAY + "Autosave is automatic.");
saveNow.addLore(ChatColor.GRAY + "Use this only to flush pending work or recover immediately.");
saveNow.onLeftClick(clicked -> flushNow(
window.getViewer(), state.requestId(), workcell.stableId()));
window.getViewer(), state.requestId(), renderedWorkcell.stableId()));
window.setElement(2, 5, saveNow);
}
@@ -650,7 +694,7 @@ public final class JigsawStudioMenuController {
UIElement toolbox = element("settings-toolbox", Material.STICK, ChatColor.AQUA + "Toolbox");
toolbox.onLeftClick(clicked -> openToolbox(
window.getViewer(), state.requestId(), workcell.stableId(), 0));
window.getViewer(), state.requestId(), renderedWorkcell.stableId(), 0));
window.setElement(4, 5, toolbox);
});
}
@@ -802,6 +846,7 @@ public final class JigsawStudioMenuController {
if (themeEditingAvailable) {
createTheme.addLore(ChatColor.GRAY + "Creates one new owned variant for every enabled workcell.");
createTheme.addLore(ChatColor.GRAY + "All created variants join " + safe(nextThemeKey) + ".");
createTheme.addLore(ChatColor.GRAY + "One family is selected for the complete assembly.");
createTheme.addLore(ChatColor.YELLOW + "Left-click to create the complete set");
createTheme.onLeftClick(clicked -> duplicateActiveFamily(
window.getViewer(), state.requestId(), nextThemeKey));
@@ -884,6 +929,8 @@ public final class JigsawStudioMenuController {
state.irisExtended() ? Material.PURPLE_DYE : Material.GRAY_DYE,
(state.irisExtended() ? ChatColor.LIGHT_PURPLE : ChatColor.GRAY) + safe(themeSet.key()));
element.addLore(ChatColor.WHITE + "Selection weight: " + themeSet.weight());
element.addLore(ChatColor.WHITE + "Whole-assembly chance: "
+ themeSelectionPercent(state.themeSets(), themeSet));
if (state.irisExtended()) {
element.addLore(ChatColor.GREEN + "Left-click: weight +1");
element.addLore(ChatColor.YELLOW + "Right-click: weight -1");
@@ -919,7 +966,7 @@ public final class JigsawStudioMenuController {
element.addLore(ChatColor.YELLOW + "Right-click: -1");
element.addLore(ChatColor.GREEN + "Shift-left: +8");
element.addLore(ChatColor.YELLOW + "Shift-right: -8");
element.addLore(ChatColor.GRAY + "The Studio layout regenerates after resizing.");
element.addLore(ChatColor.GRAY + "Changes stay in this menu until Apply Cell Size.");
element.onLeftClick(clicked -> resizeWorkcell(
window.getViewer(), state.requestId(), workcell.stableId(), axis, 1));
element.onRightClick(clicked -> resizeWorkcell(
@@ -1396,6 +1443,7 @@ public final class JigsawStudioMenuController {
element.addLore(member
? ChatColor.GREEN + "This variant belongs to the theme."
: ChatColor.GRAY + "This variant does not belong to the theme.");
element.addLore(ChatColor.GRAY + "Only variants in the selected family are eligible.");
element.addLore(ChatColor.YELLOW + "Left-click to toggle membership");
element.onLeftClick(clicked -> toggleVariantTheme(
window.getViewer(), state.requestId(), workcell, variant, themeSet.key()));
@@ -1702,17 +1750,162 @@ public final class JigsawStudioMenuController {
stale(player);
return;
}
UUID playerId = player.getUniqueId();
PendingWorkcellResize existing = pendingWorkcellResize(playerId, requestId, workcellId);
if (existing != null && existing.applying()) {
player.sendMessage(ChatColor.YELLOW + "That cell size is already being applied.");
return;
}
JigsawStudioCellDimensions base = existing == null
? workcell.capacity()
: existing.dimensions();
Optional<JigsawStudioCellDimensions> adjusted = adjustedDimensions(
workcell.capacity(), axis, delta);
base, axis, delta);
if (adjusted.isEmpty()) {
player.sendMessage(ChatColor.RED + "That workcell size is outside Iris limits.");
return;
}
if (actions.updateWorkcellDimensions(player, workcellId, adjusted.get())) {
closeAfterAction(player);
PendingWorkcellResize pending = new PendingWorkcellResize(
requestId,
workcellId,
adjusted.get(),
false);
pendingWorkcellResizes.put(playerId, pending);
UIWindow window = windows.get(playerId);
if (window != null) {
renderWorkcellSettings(window, current.get(), withCapacity(workcell, pending.dimensions()));
}
}
private void applyWorkcellResize(Player player, UUID requestId, String workcellId) {
Optional<JigsawStudioMenuState> current = matchingState(player, requestId, true);
if (current.isEmpty()) {
return;
}
UUID playerId = player.getUniqueId();
PendingWorkcellResize pending = pendingWorkcellResize(playerId, requestId, workcellId);
JigsawStudioMenuState.Workcell workcell = current.get().workcell(workcellId);
if (pending == null || pending.applying() || workcell == null) {
return;
}
if (workcell.capacity().equals(pending.dimensions())) {
pendingWorkcellResizes.remove(playerId, pending);
refreshWorkcellSettings(player, requestId, workcellId);
return;
}
PendingWorkcellResize applying = new PendingWorkcellResize(
requestId,
workcellId,
pending.dimensions(),
true);
pendingWorkcellResizes.put(playerId, applying);
UIWindow window = windows.get(playerId);
if (window != null) {
renderWorkcellSettings(window, current.get(), withCapacity(workcell, applying.dimensions()));
}
if (!actions.updateWorkcellDimensions(player, workcellId, applying.dimensions())) {
pendingWorkcellResizes.replace(playerId, applying, pending);
refreshWorkcellSettings(player, requestId, workcellId);
return;
}
scheduleWorkcellResizeRefresh(player, applying, WORKCELL_RESIZE_REFRESH_ATTEMPTS);
}
private void scheduleWorkcellResizeRefresh(
Player player,
PendingWorkcellResize pending,
int attemptsRemaining
) {
boolean scheduled = J.runEntity(
player,
() -> refreshAppliedWorkcellResize(player, pending, attemptsRemaining),
WORKCELL_RESIZE_REFRESH_TICKS);
if (!scheduled) {
pendingWorkcellResizes.remove(player.getUniqueId(), pending);
}
}
private void refreshAppliedWorkcellResize(
Player player,
PendingWorkcellResize pending,
int attemptsRemaining
) {
UUID playerId = player.getUniqueId();
if (!pending.equals(pendingWorkcellResizes.get(playerId))) {
return;
}
Optional<JigsawStudioMenuState> current = matchingState(player, pending.requestId(), false);
JigsawStudioMenuState.Workcell workcell = current
.map(state -> state.workcell(pending.workcellId()))
.orElse(null);
if (workcell == null) {
pendingWorkcellResizes.remove(playerId, pending);
return;
}
if (workcell.capacity().equals(pending.dimensions())) {
pendingWorkcellResizes.remove(playerId, pending);
UIWindow window = windows.get(playerId);
if (window != null) {
renderWorkcellSettings(window, current.orElseThrow(), workcell);
}
return;
}
if (attemptsRemaining > 0) {
scheduleWorkcellResizeRefresh(player, pending, attemptsRemaining - 1);
return;
}
PendingWorkcellResize retry = new PendingWorkcellResize(
pending.requestId(),
pending.workcellId(),
pending.dimensions(),
false);
pendingWorkcellResizes.replace(playerId, pending, retry);
UIWindow window = windows.get(playerId);
if (window != null) {
renderWorkcellSettings(window, current.orElseThrow(), withCapacity(workcell, retry.dimensions()));
}
player.sendMessage(ChatColor.YELLOW
+ "Cell resizing is still pending; use Apply Cell Size to retry after the current operation settles.");
}
private void discardWorkcellResize(Player player, UUID requestId, String workcellId) {
PendingWorkcellResize pending = pendingWorkcellResize(player.getUniqueId(), requestId, workcellId);
if (pending != null && !pending.applying()) {
pendingWorkcellResizes.remove(player.getUniqueId(), pending);
}
refreshWorkcellSettings(player, requestId, workcellId);
}
private void leaveWorkcellSettings(Player player, UUID requestId, String workcellId) {
PendingWorkcellResize pending = pendingWorkcellResize(player.getUniqueId(), requestId, workcellId);
if (pending != null && !pending.applying()) {
pendingWorkcellResizes.remove(player.getUniqueId(), pending);
}
refreshMain(player, requestId, workcellId, 0);
}
private void refreshWorkcellSettings(Player player, UUID requestId, String workcellId) {
Optional<JigsawStudioMenuState> current = matchingState(player, requestId, true);
UIWindow window = windows.get(player.getUniqueId());
JigsawStudioMenuState.Workcell workcell = current
.map(state -> state.workcell(workcellId))
.orElse(null);
if (window == null || workcell == null) {
return;
}
renderWorkcellSettings(window, current.orElseThrow(), workcell);
}
private PendingWorkcellResize pendingWorkcellResize(UUID playerId, UUID requestId, String workcellId) {
PendingWorkcellResize pending = pendingWorkcellResizes.get(playerId);
if (pending == null
|| !pending.requestId().equals(requestId)
|| !pending.workcellId().equals(workcellId)) {
return null;
}
return pending;
}
private void resizeVariantAxis(
Player player,
UUID requestId,
@@ -2400,6 +2593,31 @@ public final class JigsawStudioMenuController {
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");
@@ -2503,6 +2721,27 @@ public final class JigsawStudioMenuController {
}
}
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 List<ToolboxTool> toolboxTools(
JigsawStudioMenuState state,
JigsawStudioMenuState.Workcell workcell
@@ -3138,4 +3377,17 @@ public final class JigsawStudioMenuController {
private record PendingProjectDelete(UUID requestId, long expiresAtNanos) {
}
private record PendingWorkcellResize(
UUID requestId,
String workcellId,
JigsawStudioCellDimensions dimensions,
boolean applying
) {
private PendingWorkcellResize {
requestId = Objects.requireNonNull(requestId, "Jigsaw Studio resize request ID");
workcellId = Objects.requireNonNull(workcellId, "Jigsaw Studio resize workcell ID");
dimensions = Objects.requireNonNull(dimensions, "Jigsaw Studio resize dimensions");
}
}
}
@@ -67,6 +67,7 @@ import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.Chunk;
import org.bukkit.Color;
import org.bukkit.GameRules;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Particle;
@@ -100,6 +101,7 @@ import org.bukkit.event.block.BrewingStartEvent;
import org.bukkit.event.block.CrafterCraftEvent;
import org.bukkit.event.entity.EntityChangeBlockEvent;
import org.bukkit.event.entity.EntityExplodeEvent;
import org.bukkit.event.entity.CreatureSpawnEvent;
import org.bukkit.event.inventory.BrewEvent;
import org.bukkit.event.inventory.BrewingStandFuelEvent;
import org.bukkit.event.inventory.FurnaceBurnEvent;
@@ -123,7 +125,6 @@ import org.bukkit.event.player.PlayerTeleportEvent;
import org.bukkit.event.player.PlayerToggleSneakEvent;
import org.bukkit.event.server.ServerCommandEvent;
import org.bukkit.event.world.ChunkLoadEvent;
import org.bukkit.event.world.ChunkUnloadEvent;
import org.bukkit.event.world.StructureGrowEvent;
import org.bukkit.event.world.WorldUnloadEvent;
import org.bukkit.inventory.EquipmentSlot;
@@ -161,6 +162,7 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
private static final List<Integer> AUTOSAVE_PERSISTENT_RETRY_DELAYS =
List.of(40, 80, 160, 320, 600);
private static final long PREVIEW_SEED = 1337L;
private static final int SPATIAL_PREVIEW_BASE_Y = JigsawStudioLayout.FLOOR_Y + 48;
private static final JigsawStudioPieceRules DEFAULT_PIECE_RULES =
new JigsawStudioPieceRules(0, 30, 0, 0, false);
private static final long TOOL_CONFIRM_NANOS = 10_000_000_000L;
@@ -207,8 +209,6 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
private final Map<UUID, JigsawStudioGraphEvaluation> evaluations = new ConcurrentHashMap<>();
private final JigsawStudioTripleSneakTracker tripleSneakTracker = new JigsawStudioTripleSneakTracker();
private final JigsawStudioToolCodec toolCodec = new JigsawStudioToolCodec();
private final JigsawStudioDisabledWorkcellRenderer disabledWorkcellRenderer =
new JigsawStudioDisabledWorkcellRenderer();
private final JigsawStudioPreviewRenderer previewRenderer = new JigsawStudioPreviewRenderer();
private final Object saveLifecycleLock = new Object();
private final Set<UUID> savesInProgress = new HashSet<>();
@@ -258,7 +258,6 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
jigsawTileWatches.clear();
toolConfirmations.clear();
tripleSneakTracker.clearAll();
disabledWorkcellRenderer.removeAll();
evaluations.clear();
previewRenderer.removeAll();
reopenRequiredRequests.clear();
@@ -329,16 +328,11 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
unregisterRetries.remove(displacedRequestId);
unregisterDrainWarnings.remove(displacedRequestId);
tripleSneakTracker.clearRequest(displacedRequestId);
disabledWorkcellRenderer.removeRequest(displacedRequestId);
evaluations.remove(displacedRequestId);
previewRenderer.removeRequest(displacedRequestId);
}
IrisLogging.info("Jigsaw Studio authoring registered: world=%s structure=%s bays=%d",
world.getName(), activeGenerator.getSession().structureKey(), activeGenerator.getLayout().bays().size());
disabledWorkcellRenderer.reconcile(
world,
activeGenerator.getRequest().requestId(),
activeGenerator.getLayout());
scheduleInitialEvaluation(next);
scheduleOnlinePlayers(world.getUID());
}
@@ -351,6 +345,7 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
if (studio == null || !requestId.equals(studio.generator().getRequest().requestId())) {
return;
}
disableNaturalStudioSpawning(world);
scheduleInitialEvaluation(studio);
}
@@ -429,7 +424,6 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
unregisterDrainWarnings.remove(request.requestId());
toolConfirmations.entrySet().removeIf(entry -> entry.getValue().payload().requestId().equals(request.requestId()));
tripleSneakTracker.clearRequest(request.requestId());
disabledWorkcellRenderer.removeRequest(request.requestId());
evaluations.remove(request.requestId());
previewRenderer.forgetRequest(request.requestId());
JigsawStudioActivation.deactivate(request.packKey(), request.requestId());
@@ -1309,9 +1303,22 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
sourcePieceKey,
pieceKey);
}
JigsawStudioLayout updatedLayout = loadMappedLayout(studio);
String targetWorkcellId = updatedLayout.workcellForVariant(pieceKey)
.map(JigsawStudioBay::stableId)
.orElseThrow(() -> new IOException(
"Created variant '" + pieceKey + "' has no Studio workcell"));
if (updatedLayout.mode() == JigsawStudioMode.SPATIAL_JIGSAW) {
return new CommandGraphMutationResult(
updatedLayout,
"",
"",
(duplicateActive ? "Duplicated" : "Created") + " variant '" + pieceKey
+ "' in " + targetWorkcellId + ".");
}
return new CommandGraphMutationResult(
loadMappedLayout(studio),
workcell.stableId(),
updatedLayout,
targetWorkcellId,
pieceKey,
(duplicateActive ? "Duplicated" : "Created") + " variant '" + pieceKey + "'.");
});
@@ -1548,11 +1555,15 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
throw new IOException("Variant-family transaction failed with "
+ creation.writeResult().status());
}
JigsawStudioLayout updatedLayout = loadMappedLayout(studio);
Map<String, String> rebinds = updatedLayout.mode() == JigsawStudioMode.SPATIAL_JIGSAW
? Map.of()
: creation.pieceKeysByWorkcell();
return new CommandGraphMutationResult(
loadMappedLayout(studio),
updatedLayout,
"",
"",
creation.pieceKeysByWorkcell(),
rebinds,
Optional.empty(),
"Duplicated every enabled workcell as coherent family '" + themeKey + "' with "
+ creation.pieceKeysByWorkcell().size() + " variant(s).");
@@ -2201,7 +2212,8 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
String activePieceKey = session.activeVariant(workcellId)
.map(JigsawStudioVariant::pieceKey)
.orElse("");
if (activePieceKey.equals(pieceKey)) {
if (session.layout().mode() == JigsawStudioMode.PLANAR_JIGSAW
&& activePieceKey.equals(pieceKey)) {
message(player, "Load another variant in this workcell before deleting '" + pieceKey + "'.");
return false;
}
@@ -2575,7 +2587,12 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
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);
computation = evaluationForAssembly(
requestId,
generation,
compilation,
assembly,
studio.generator().getLayout().mode());
}
} catch (Throwable exception) {
IrisLogging.reportError(exception);
@@ -2588,7 +2605,8 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
UUID requestId,
long generation,
StructureGraphCompilation compilation,
StructureAssemblyResult assembly
StructureAssemblyResult assembly,
JigsawStudioMode mode
) throws IOException {
if (assembly.status().isFailure()) {
return invalidEvaluation(
@@ -2611,7 +2629,7 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
JigsawStudioPreviewRenderer.PreviewBounds.empty()),
JigsawStudioPreviewRenderer.PreviewPlan.empty());
}
List<PlacedStructurePiece> aligned = alignPreviewPieces(assembly.pieces());
List<PlacedStructurePiece> aligned = alignPreviewPieces(assembly.pieces(), mode);
JigsawStudioPreviewRenderer.PreviewPlan plan = JigsawStudioPreviewRenderer.plan(aligned);
StructureGraphDiagnostic firstWarning = firstDiagnostic(
compilation,
@@ -2713,12 +2731,18 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
Math.max(16, layout.extentZ() / 2));
}
private static List<PlacedStructurePiece> alignPreviewPieces(List<PlacedStructurePiece> pieces) {
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 shiftY = JigsawStudioLayout.FLOOR_Y + 1 - minimumY;
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(
@@ -3534,7 +3558,6 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
UUID requestId,
CommandGraphMutationResult result
) {
disabledWorkcellRenderer.reconcile(studio.world(), requestId, result.layout());
for (JigsawStudioBay workcell : result.layout().bays()) {
refreshWorkcellContext(studio.worldId(), workcell.stableId());
}
@@ -3801,28 +3824,6 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
ActiveStudio studio = studios.get(event.getWorld().getUID());
if (studio != null) {
markChunkAvailable(studio, event.getChunk().getX(), event.getChunk().getZ());
for (JigsawStudioBay bay : studio.generator().getLayout().bays()) {
if (!bay.enabled()
&& bay.bounds().originX() >> 4 == event.getChunk().getX()
&& bay.bounds().originZ() >> 4 == event.getChunk().getZ()) {
disabledWorkcellRenderer.reconcile(
studio.world(),
studio.generator().getRequest().requestId(),
studio.generator().getLayout());
break;
}
}
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onChunkUnload(ChunkUnloadEvent event) {
ActiveStudio studio = studios.get(event.getWorld().getUID());
if (studio != null) {
disabledWorkcellRenderer.unloadChunk(
studio.generator().getRequest().requestId(),
event.getChunk().getX(),
event.getChunk().getZ());
}
}
@@ -3922,6 +3923,14 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
reconcilePlayerContext(event.getPlayer(), event.getRespawnLocation());
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onNaturalCreatureSpawn(CreatureSpawnEvent event) {
if (studios.containsKey(event.getLocation().getWorld().getUID())
&& isNaturalStudioSpawn(event.getSpawnReason())) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onUnauthorizedBlockPlace(BlockPlaceEvent event) {
if (isUnauthorizedStudioEdit(event.getPlayer(), event.getBlockPlaced())) {
@@ -5947,6 +5956,11 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
+ " 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),
@@ -6025,6 +6039,7 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
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) {
scheduleEvaluation(studio);
}
@@ -6518,21 +6533,29 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
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 IOException(
"Planar workcells cannot save vertical connectors");
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 IOException("Workcell '" + workcell.stableId() + "' requires "
+ expected.canonicalTopology().name().toLowerCase(Locale.ROOT)
+ " connector orientation, but the edited markers form "
+ displayedTopology.name().toLowerCase(Locale.ROOT)
+ ". Keep the workcell's red floor glyph orientation; Iris rotates the saved variant automatically.");
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,
@@ -7039,10 +7062,6 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
private void reloadSessionLayout(ActiveStudio studio) throws IOException {
JigsawStudioLayout layout = loadMappedLayout(studio);
studio.generator().getSession().replaceLayout(layout);
disabledWorkcellRenderer.reconcile(
studio.world(),
studio.generator().getRequest().requestId(),
layout);
for (JigsawStudioBay workcell : layout.bays()) {
studio.generator().invalidateRender(workcell.stableId());
}
@@ -7080,7 +7099,8 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
JigsawStudioSession session = studio.generator().getSession();
JigsawStudioBay workcell = session.layout().findAt(
target.getBlockX(), target.getBlockY(), target.getBlockZ());
selectEnteredWorkcell(session, workcell, ownerMatches(player, studio));
boolean owner = ownerMatches(player, studio);
selectEnteredWorkcell(session, workcell, owner);
String workcellId = workcell == null ? "" : workcell.stableId();
playerWorkcells.put(player.getUniqueId(), new PlayerWorkcellContext(
studio.worldId(),
@@ -7115,6 +7135,14 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
return owner && workcell != null && session.selectBay(workcell.stableId());
}
static boolean isNaturalStudioSpawn(CreatureSpawnEvent.SpawnReason reason) {
return reason == CreatureSpawnEvent.SpawnReason.NATURAL;
}
static void disableNaturalStudioSpawning(World world) {
Objects.requireNonNull(world, "world").setGameRule(GameRules.SPAWN_MOBS, false);
}
public void refreshWorkcellContext(UUID worldId, String workcellId) {
if (worldId == null || workcellId == null) {
return;
@@ -7708,9 +7736,7 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
if (!variant.pieceKey().equalsIgnoreCase(key)) {
continue;
}
return variant.archetype()
.map(archetype -> layout.get(archetype.stableId()))
.orElse(layout.get(JigsawStudioLayout.SPATIAL_WORKCELL_ID));
return layout.workcellForVariant(variant.pieceKey()).orElse(null);
}
return null;
}
@@ -7738,6 +7764,16 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
}
}
static void playSaveSound(Player player) {
if (player != null) {
J.runEntity(player, () -> player.playSound(
player.getLocation(),
"minecraft:block.note_block.bell",
0.65F,
1.65F));
}
}
private static void report(Player player, boolean enabled, String text) {
if (enabled) {
message(player, text);
@@ -8722,6 +8758,12 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
}
}
static final class WorkcellTopologyException extends IOException {
WorkcellTopologyException(String message) {
super(message);
}
}
private static final class ParticleBudget {
private int remaining;
@@ -22,6 +22,7 @@ import com.google.gson.JsonSyntaxException;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.IrisStartupValidation;
import art.arcane.iris.core.IrisWorldStorage;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.core.DatapackInstallResult;
@@ -33,6 +34,7 @@ import art.arcane.iris.core.pack.PackDirectoryResolver;
import art.arcane.iris.core.pack.PackDownloader;
import art.arcane.iris.core.pack.PackValidationRegistry;
import art.arcane.iris.core.pack.PackValidationResult;
import art.arcane.iris.core.pack.PackValidator;
import art.arcane.iris.core.project.IrisProject;
import art.arcane.iris.core.project.IrisPackageCompiler;
import art.arcane.iris.core.project.IrisCodeWorkspace;
@@ -69,6 +71,7 @@ import java.nio.file.StandardCopyOption;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Optional;
import java.util.Locale;
import java.util.Objects;
import java.util.UUID;
@@ -471,12 +474,24 @@ public class StudioSVC implements IrisService {
}
private static boolean blockIfPackBroken(VolmitSender sender, String dimm) {
PackValidationResult validation = PackValidationRegistry.get(dimm);
if (validation == null || validation.isLoadable()) {
Optional<String> startupDenial = IrisStartupValidation.denialReason();
if (startupDenial.isPresent()) {
sender.sendMessage(startupDenial.get());
return true;
}
IrisDimension dimension = IrisToolbelt.getDimension(dimm);
String packName = dimension == null || dimension.getLoader() == null
? dimm
: dimension.getLoader().getDataFolder().getName();
PackValidationResult validation = PackValidationRegistry.get(packName);
if (validation != null && validation.isLoadable()) {
return false;
}
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_CANNOT_OPEN_STUDIO_PACK_HAS_BLOCKING_ERRORS, MessageArgument.untrusted("dimm", String.valueOf(dimm))));
for (String reason : validation.getBlockingErrors()) {
List<String> failures = validation == null
? List.of("Required pack validation has not completed. Studio creation fails closed until validation succeeds.")
: validation.getBlockingErrors();
for (String reason : failures) {
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_MESSAGE, MessageArgument.untrusted("reason", String.valueOf(reason))));
}
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_FIX_PACK_RUN_IRIS_PACK_VALIDATE_REVALIDATE, MessageArgument.untrusted("dimm", String.valueOf(dimm))));
@@ -829,6 +844,11 @@ public class StudioSVC implements IrisService {
}
private void createProject(VolmitSender sender, String requestedName, String requestedTemplate, File selectedTemplatePack) {
Optional<String> startupDenial = IrisStartupValidation.denialReason();
if (startupDenial.isPresent()) {
sender.sendMessage("Studio project creation refused: " + startupDenial.get());
return;
}
String normalizedName;
String templateName;
File workspace;
@@ -881,12 +901,24 @@ public class StudioSVC implements IrisService {
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_MISSING_IMPORTED_DIMENSION_FILE));
return;
}
PackValidationRegistry.requireLoadable(importPack.getName());
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_IMPORTING_INTO_NEW_PROJECT, MessageArgument.untrusted("downloadable", String.valueOf(templateName)), MessageArgument.untrusted("s", String.valueOf(projectName))));
createFrom(importPack, templateName, projectName);
}
projectPublished = true;
PackValidationResult createdValidation = PackValidator.validate(newPack);
PackValidationRegistry.publish(createdValidation);
if (!createdValidation.isLoadable()) {
rollbackCreatedProject(sender, newPack,
"Studio project validation failed; the new project was rolled back.");
for (String reason : createdValidation.getBlockingErrors()) {
sender.sendMessage(reason);
}
return;
}
DatapackInstallResult installResult = ServerConfigurator.installDataPacksIfChanged(true);
CreationOutcome installOutcome = switch (installResult.status()) {
case FAILED -> CreationOutcome.FAILED;
@@ -26,6 +26,7 @@ import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.core.link.MultiverseCoreLink;
import art.arcane.iris.core.IrisRuntimeSchedulerMode;
import art.arcane.iris.core.IrisStartupValidation;
import art.arcane.iris.core.DatapackInstallResult;
import art.arcane.iris.core.IrisWorldStorage;
import art.arcane.iris.core.IrisWorlds;
@@ -41,6 +42,7 @@ import art.arcane.iris.core.localization.RuntimeProgressMessages;
import art.arcane.iris.core.nms.INMS;
import art.arcane.iris.core.pregenerator.PregenTask;
import art.arcane.iris.core.pack.AtomicDirectoryPublisher;
import art.arcane.iris.core.pack.PackValidationRegistry;
import art.arcane.iris.core.runtime.WorldDeletionQueue;
import art.arcane.iris.core.service.StudioSVC;
import art.arcane.iris.engine.object.IrisDimension;
@@ -172,6 +174,9 @@ public class IrisCreator {
if (resolvedDimension == null) {
throw new IrisException("Dimension cannot be found for id " + dimension());
}
IrisStartupValidation.requireWorldCreationReady();
PackValidationRegistry.requireLoadable(
resolvedDimension.getLoader().getDataFolder().getName());
worldLease = coordinator.acquire(
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
LifecycleOperationCoordinator.OperationKind.WORLD_CREATE,
@@ -73,7 +73,7 @@ public final class JigsawStudioGenerator extends EnginedStudioGenerator {
session,
B.getState("minecraft:smooth_stone"),
B.getState("minecraft:polished_deepslate"),
B.getState("minecraft:smooth_quartz"),
B.getState("minecraft:white_concrete"),
B.getState("minecraft:light_gray_wool"),
B.getState("minecraft:red_wool"),
B.getState("minecraft:sea_lantern"),
@@ -289,41 +289,56 @@ public final class JigsawStudioGenerator extends EnginedStudioGenerator {
int chunkWorldZ
) {
JigsawStudioBounds bounds = workcell.bounds();
int minX = bounds.originX() - 1;
int maxX = bounds.maxX() + 1;
int minZ = bounds.originZ() - 1;
int maxZ = bounds.maxZ() + 1;
int minimumX = bounds.originX() - 1;
int maximumX = bounds.maxX() + 1;
int minimumZ = bounds.originZ() - 1;
int maximumZ = bounds.maxZ() + 1;
int bottomY = bounds.originY();
int topY = bounds.maxY() + 1;
paintRectangle(terrainChunk, minX, maxX, bottomY, minZ, maxZ, frame, chunkWorldX, chunkWorldZ);
paintRectangle(terrainChunk, minX, maxX, topY, minZ, maxZ, frame, chunkWorldX, chunkWorldZ);
paintRectangle(
terrainChunk,
minimumX,
maximumX,
bottomY,
minimumZ,
maximumZ,
chunkWorldX,
chunkWorldZ);
paintRectangle(
terrainChunk,
minimumX,
maximumX,
topY,
minimumZ,
maximumZ,
chunkWorldX,
chunkWorldZ);
for (int y = bottomY + 1; y < topY; y++) {
setWorldBlock(terrainChunk, minX, y, minZ, frame, chunkWorldX, chunkWorldZ);
setWorldBlock(terrainChunk, maxX, y, minZ, frame, chunkWorldX, chunkWorldZ);
setWorldBlock(terrainChunk, minX, y, maxZ, frame, chunkWorldX, chunkWorldZ);
setWorldBlock(terrainChunk, maxX, y, maxZ, frame, chunkWorldX, chunkWorldZ);
setWorldBlock(terrainChunk, minimumX, y, minimumZ, frame, chunkWorldX, chunkWorldZ);
setWorldBlock(terrainChunk, maximumX, y, minimumZ, frame, chunkWorldX, chunkWorldZ);
setWorldBlock(terrainChunk, minimumX, y, maximumZ, frame, chunkWorldX, chunkWorldZ);
setWorldBlock(terrainChunk, maximumX, y, maximumZ, frame, chunkWorldX, chunkWorldZ);
}
}
private void paintRectangle(
TerrainChunk terrainChunk,
int minX,
int maxX,
int minimumX,
int maximumX,
int y,
int minZ,
int maxZ,
PlatformBlockState block,
int minimumZ,
int maximumZ,
int chunkWorldX,
int chunkWorldZ
) {
for (int x = minX; x <= maxX; x++) {
setWorldBlock(terrainChunk, x, y, minZ, block, chunkWorldX, chunkWorldZ);
setWorldBlock(terrainChunk, x, y, maxZ, block, chunkWorldX, chunkWorldZ);
for (int x = minimumX; x <= maximumX; x++) {
setWorldBlock(terrainChunk, x, y, minimumZ, frame, chunkWorldX, chunkWorldZ);
setWorldBlock(terrainChunk, x, y, maximumZ, frame, chunkWorldX, chunkWorldZ);
}
for (int z = minZ + 1; z < maxZ; z++) {
setWorldBlock(terrainChunk, minX, y, z, block, chunkWorldX, chunkWorldZ);
setWorldBlock(terrainChunk, maxX, y, z, block, chunkWorldX, chunkWorldZ);
for (int z = minimumZ + 1; z < maximumZ; z++) {
setWorldBlock(terrainChunk, minimumX, y, z, frame, chunkWorldX, chunkWorldZ);
setWorldBlock(terrainChunk, maximumX, y, z, frame, chunkWorldX, chunkWorldZ);
}
}
@@ -0,0 +1,97 @@
package art.arcane.iris.core;
import org.junit.After;
import org.junit.Test;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
public class IrisStartupValidationTest {
@After
public void disableValidation() {
IrisStartupValidation.disable();
}
@Test
public void disabledValidationAllowsCreation() {
IrisStartupValidation.disable();
assertTrue(IrisStartupValidation.isReady());
assertTrue(IrisStartupValidation.denialReason().isEmpty());
IrisStartupValidation.requireWorldCreationReady();
}
@Test
public void pendingValidationDeniesCreation() {
IrisStartupValidation.begin();
assertFalse(IrisStartupValidation.isReady());
assertTrue(IrisStartupValidation.denialReason().orElseThrow().contains("external datapacks"));
try {
IrisStartupValidation.requireWorldCreationReady();
fail("Expected pending startup validation to lock world creation");
} catch (IllegalStateException expected) {
assertTrue(expected.getMessage().contains("world creation is locked"));
}
}
@Test
public void bothValidationPhasesMustComplete() {
IrisStartupValidation.begin();
IrisStartupValidation.markDatapacksReady();
assertFalse(IrisStartupValidation.isReady());
assertTrue(IrisStartupValidation.denialReason().orElseThrow().contains("dimension packs"));
IrisStartupValidation.markPacksReady();
assertTrue(IrisStartupValidation.isReady());
assertTrue(IrisStartupValidation.denialReason().isEmpty());
}
@Test
public void invalidDatapacksExposeTheFailure() {
IrisStartupValidation.begin();
IrisStartupValidation.markDatapacksInvalid("broken managed datapack");
IrisStartupValidation.markPacksReady();
assertEquals("broken managed datapack", IrisStartupValidation.denialReason().orElseThrow());
}
@Test
public void restartRequirementCannotBeDowngradedToReady() {
IrisStartupValidation.begin();
IrisStartupValidation.requireRestart("restart required for registry load");
IrisStartupValidation.markDatapacksReady();
IrisStartupValidation.markPacksReady();
assertFalse(IrisStartupValidation.isReady());
assertEquals("restart required for registry load", IrisStartupValidation.denialReason().orElseThrow());
}
@Test
public void repeatedValidationCannotClearRestartRequirement() {
IrisStartupValidation.begin();
IrisStartupValidation.requireRestart("restart boundary");
IrisStartupValidation.beginDatapackValidation();
IrisStartupValidation.markDatapacksReady();
IrisStartupValidation.markPacksReady();
assertFalse(IrisStartupValidation.isReady());
assertEquals("restart boundary", IrisStartupValidation.denialReason().orElseThrow());
}
@Test
public void packValidationInfrastructureFailureDeniesCreation() {
IrisStartupValidation.begin();
IrisStartupValidation.markDatapacksReady();
IrisStartupValidation.markPacksInvalid(List.of("pack registry unavailable"));
assertEquals("pack registry unavailable", IrisStartupValidation.denialReason().orElseThrow());
}
}
@@ -29,9 +29,11 @@ import java.net.StandardProtocolFamily;
import java.net.UnixDomainSocketAddress;
import java.nio.channels.ServerSocketChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileStore;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.FileTime;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
@@ -75,6 +77,174 @@ public class DatapackIngestServiceTest {
assertEquals("1.21.4", DatapackIngestService.serverMcVersion(server));
}
@Test
public void windowsLongPathAliasAcceptsTheSameVolumeSerialAndRoot() throws Exception {
FileStore shortPathStore = mock(FileStore.class);
FileStore longPathStore = mock(FileStore.class);
when(shortPathStore.getAttribute("volume:vsn")).thenReturn(41234L);
when(longPathStore.getAttribute("volume:vsn")).thenReturn(41234L);
assertTrue(DatapackIngestService.sameWindowsVolume(
shortPathStore, "C:\\", longPathStore, "c:\\"));
}
@Test
public void windowsLongPathAliasRejectsDifferentRootsAndVolumeSerials() throws Exception {
FileStore firstStore = mock(FileStore.class);
FileStore secondStore = mock(FileStore.class);
when(firstStore.getAttribute("volume:vsn")).thenReturn(1L);
when(secondStore.getAttribute("volume:vsn")).thenReturn(2L);
assertFalse(DatapackIngestService.sameWindowsVolume(
firstStore, "C:\\", secondStore, "C:\\"));
when(secondStore.getAttribute("volume:vsn")).thenReturn(1L);
assertFalse(DatapackIngestService.sameWindowsVolume(
firstStore, "C:\\", secondStore, "D:\\"));
}
@Test
public void scratchDirectoryRejectsJunctionLikeOtherAttributes() {
BasicFileAttributes attributes = mock(BasicFileAttributes.class);
when(attributes.isDirectory()).thenReturn(true);
when(attributes.isOther()).thenReturn(true);
assertFalse(DatapackIngestService.isSupportedScratchDirectory(attributes));
when(attributes.isOther()).thenReturn(false);
assertTrue(DatapackIngestService.isSupportedScratchDirectory(attributes));
}
@Test
public void startupValidationCacheRequiresEveryInputAndLocalFingerprint() {
DatapackIngestService.StartupValidationCache cache = new DatapackIngestService.StartupValidationCache();
cache.schemaVersion = 1;
cache.minecraftVersion = "26.2";
cache.irisVersion = 4000;
cache.autoIngest = true;
cache.stripOverrides = false;
cache.urls = List.of("https://modrinth.com/datapack/example");
cache.localFingerprint = "fingerprint";
assertTrue(DatapackIngestService.startupValidationCacheMatches(
cache,
"26.2",
4000,
true,
false,
List.of("https://modrinth.com/datapack/example"),
"fingerprint"));
assertFalse(DatapackIngestService.startupValidationCacheMatches(
cache,
"26.3",
4000,
true,
false,
cache.urls,
"fingerprint"));
assertFalse(DatapackIngestService.startupValidationCacheMatches(
cache,
"26.2",
4000,
true,
true,
cache.urls,
"fingerprint"));
assertFalse(DatapackIngestService.startupValidationCacheMatches(
cache,
"26.2",
4000,
true,
false,
List.of("https://modrinth.com/datapack/changed"),
"fingerprint"));
assertFalse(DatapackIngestService.startupValidationCacheMatches(
cache,
"26.2",
4000,
true,
false,
cache.urls,
"changed"));
}
@Test
public void startupValidationFingerprintChangesWithStagedContent() throws Exception {
File root = temporaryFolder.newFolder("startup-validation-fingerprint");
File staged = new File(root, "staging/managed");
assertTrue(staged.mkdirs());
Path content = new File(staged, "value.txt").toPath();
Files.writeString(content, "alpha", StandardCharsets.UTF_8);
KList<File> worldFolders = new KList<>();
String before = DatapackIngestService.startupValidationFingerprint(root, worldFolders);
Files.writeString(content, "bravo", StandardCharsets.UTF_8);
String after = DatapackIngestService.startupValidationFingerprint(root, worldFolders);
assertFalse(before.equals(after));
}
@Test
public void authorizedStartupMaintenanceRefreshesOnlyTheLocalFingerprint() throws Exception {
File root = temporaryFolder.newFolder("startup-validation-maintenance");
File staging = new File(root, "staging/managed");
assertTrue(staging.mkdirs());
Path content = new File(staging, "value.txt").toPath();
Files.writeString(content, "before", StandardCharsets.UTF_8);
KList<File> worldFolders = new KList<>();
DatapackIngestService.StartupValidationCache validated = new DatapackIngestService.StartupValidationCache();
validated.schemaVersion = 1;
validated.minecraftVersion = "26.2";
validated.irisVersion = 4000;
validated.autoIngest = true;
validated.stripOverrides = false;
validated.urls = List.of("https://modrinth.com/datapack/example");
validated.localFingerprint = DatapackIngestService.startupValidationFingerprint(root, worldFolders);
Files.writeString(content, "after", StandardCharsets.UTF_8);
assertFalse(DatapackIngestService.startupValidationCacheMatches(
validated,
"26.2",
4000,
true,
false,
validated.urls,
DatapackIngestService.startupValidationFingerprint(root, worldFolders)));
DatapackIngestService.StartupValidationCache refreshed =
DatapackIngestService.refreshStartupValidationCache(validated, root, worldFolders);
assertTrue(DatapackIngestService.startupValidationCacheMatches(
refreshed,
"26.2",
4000,
true,
false,
validated.urls,
DatapackIngestService.startupValidationFingerprint(root, worldFolders)));
assertEquals(validated.urls, refreshed.urls);
}
@Test
public void startupMaintenanceDoesNotAuthorizeChangedValidationInputs() {
DatapackIngestService.StartupValidationCache validated = new DatapackIngestService.StartupValidationCache();
validated.schemaVersion = 1;
validated.minecraftVersion = "26.2";
validated.irisVersion = 4000;
validated.autoIngest = true;
validated.stripOverrides = false;
validated.urls = List.of("https://modrinth.com/datapack/example");
assertTrue(DatapackIngestService.startupValidationContextMatches(
validated, "26.2", 4000, true, false, validated.urls));
assertFalse(DatapackIngestService.startupValidationContextMatches(
validated, "26.2", 4000, true, false,
List.of("https://modrinth.com/datapack/changed")));
assertFalse(DatapackIngestService.startupValidationContextMatches(
validated, "26.2", 4000, true, true, validated.urls));
}
@Test
public void packMetadataMustContainAValidPackContract() throws Exception {
File valid = temporaryFolder.newFolder("valid");
@@ -1974,6 +2144,38 @@ public class DatapackIngestServiceTest {
assertFalse(scratch.exists());
}
@Test
public void installScratchDeletionRetriesATransientDirectoryFailure() throws Exception {
File root = temporaryFolder.newFolder("transient-install-scratch-delete-root");
DeleteAttemptFile scratch = new DeleteAttemptFile(
new File(root, "managed-" + UUID.randomUUID()).getPath(), 2);
assertTrue(scratch.mkdir());
Files.writeString(new File(scratch, ".DS_Store").toPath(), "finder", StandardCharsets.UTF_8);
DatapackIngestService.deleteInstallScratch(scratch, "test datapack install scratch");
assertFalse(scratch.exists());
assertEquals(2, scratch.deleteAttempts());
}
@Test
public void installScratchDeletionStillFailsAfterBoundedRetries() throws Exception {
File root = temporaryFolder.newFolder("persistent-install-scratch-delete-root");
DeleteAttemptFile scratch = new DeleteAttemptFile(
new File(root, "managed-" + UUID.randomUUID()).getPath(), Integer.MAX_VALUE);
assertTrue(scratch.mkdir());
try {
DatapackIngestService.deleteInstallScratch(scratch, "test datapack install scratch");
fail("Expected persistent scratch deletion failure");
} catch (IOException expected) {
assertTrue(expected.getMessage().contains("Could not remove test datapack install scratch"));
}
assertTrue(scratch.exists());
assertEquals(3, scratch.deleteAttempts());
}
@Test
public void recoveryRejectsFinderMetadataDirectoryInInstallScratch() throws Exception {
File root = temporaryFolder.newFolder("orphan-install-finder-directory-root");
@@ -3173,4 +3375,26 @@ public class DatapackIngestServiceTest {
DatapackIngestService.VerifiedStagingInstall authorization
) {
}
private static final class DeleteAttemptFile extends File {
private static final long serialVersionUID = 1L;
private final int successfulAttempt;
private int deleteAttempts;
private DeleteAttemptFile(String pathname, int successfulAttempt) {
super(pathname);
this.successfulAttempt = successfulAttempt;
}
@Override
public boolean delete() {
deleteAttempts++;
return deleteAttempts >= successfulAttempt && super.delete();
}
private int deleteAttempts() {
return deleteAttempts;
}
}
}
@@ -1,12 +1,24 @@
package art.arcane.iris.core.lifecycle;
import art.arcane.iris.core.IrisStartupValidation;
import org.bukkit.NamespacedKey;
import org.bukkit.World;
import org.junit.After;
import org.junit.Test;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
public class WorldLifecycleSelectionTest {
@After
public void disableStartupValidation() {
IrisStartupValidation.disable();
}
@Test
public void studioSelectsPaperLikeBackendOnPaper() {
WorldLifecycleService service = new WorldLifecycleService(CapabilitySnapshot.forTesting(ServerFamily.PAPER, false, false, true));
@@ -86,4 +98,67 @@ public class WorldLifecycleSelectionTest {
service.rememberBackend(NamespacedKey.minecraft("studio"), "paper_like_runtime");
assertEquals("paper_like_runtime", service.selectUnloadBackend("minecraft:studio").backendName());
}
@Test
public void pendingStartupValidationStopsCreateBeforeBackendSelection() {
CountingBackend selected = new CountingBackend("selected", true);
CountingBackend inactive = new CountingBackend("inactive", false);
WorldLifecycleService service = new WorldLifecycleService(
CapabilitySnapshot.forTesting(ServerFamily.PAPER, false, false, true),
selected,
inactive,
inactive);
WorldLifecycleRequest request = new WorldLifecycleRequest(
"blocked",
NamespacedKey.minecraft("blocked"),
World.Environment.NORMAL,
null,
null,
null,
true,
false,
1337L,
false,
false,
WorldLifecycleCaller.CREATE);
IrisStartupValidation.begin();
CompletionException failure = assertThrows(CompletionException.class, () -> service.create(request).join());
assertEquals(IllegalStateException.class, failure.getCause().getClass());
assertEquals(0, selected.createCount.get());
}
private static final class CountingBackend implements WorldLifecycleBackend {
private final String name;
private final boolean supported;
private final AtomicInteger createCount;
private CountingBackend(String name, boolean supported) {
this.name = name;
this.supported = supported;
this.createCount = new AtomicInteger();
}
@Override
public boolean supports(WorldLifecycleRequest request, CapabilitySnapshot capabilities) {
return supported;
}
@Override
public CompletableFuture<World> create(WorldLifecycleRequest request) {
createCount.incrementAndGet();
return CompletableFuture.completedFuture(null);
}
@Override
public CompletableFuture<Boolean> unloadAsync(World world, boolean save) {
return CompletableFuture.completedFuture(false);
}
@Override
public String backendName() {
return name;
}
}
}
@@ -0,0 +1,146 @@
package art.arcane.iris.core.pack;
import org.junit.Rule;
import org.junit.Test;
import org.junit.Assume;
import org.junit.rules.TemporaryFolder;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.FileTime;
import java.util.List;
import java.util.Optional;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
public class PackValidationCacheTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void cacheRoundTripsSuccessfulAndFailedResultsInStableOrder() throws Exception {
Path cache = new File(temporaryFolder.newFolder("round-trip"), "validation.json").toPath();
PackValidationResult valid = new PackValidationResult(
"z-valid", List.of(), List.of("warning"), 20L);
PackValidationResult invalid = new PackValidationResult(
"a-invalid", List.of("broken reference"), List.of(), 10L);
PackValidationCache.save(cache, "content", "context", List.of(valid, invalid));
List<PackValidationResult> loaded = PackValidationCache.load(
cache,
"content",
"context",
List.of("z-valid", "a-invalid")).orElseThrow();
assertEquals(List.of("a-invalid", "z-valid"), loaded.stream()
.map(PackValidationResult::getPackName)
.toList());
assertFalse(loaded.getFirst().isLoadable());
assertEquals(List.of("broken reference"), loaded.getFirst().getBlockingErrors());
assertTrue(loaded.getLast().isLoadable());
assertEquals(List.of("warning"), loaded.getLast().getWarnings());
}
@Test
public void cacheRejectsContentContextAndPackSetChanges() throws Exception {
Path cache = new File(temporaryFolder.newFolder("mismatch"), "validation.json").toPath();
PackValidationCache.save(cache, "content", "context", List.of(
new PackValidationResult("overworld", List.of(), List.of(), 1L)));
assertTrue(PackValidationCache.load(
cache, "changed", "context", List.of("overworld")).isEmpty());
assertTrue(PackValidationCache.load(
cache, "content", "changed", List.of("overworld")).isEmpty());
assertTrue(PackValidationCache.load(
cache, "content", "context", List.of("other")).isEmpty());
}
@Test
public void corruptCacheIsIgnored() throws Exception {
Path cache = new File(temporaryFolder.newFolder("corrupt"), "validation.json").toPath();
Files.writeString(cache, "not-json", StandardCharsets.UTF_8);
Optional<List<PackValidationResult>> loaded = PackValidationCache.load(
cache, "content", "context", List.of());
assertTrue(loaded.isEmpty());
}
@Test
public void duplicateResultsAreRejected() throws Exception {
Path cache = new File(temporaryFolder.newFolder("duplicate"), "validation.json").toPath();
Files.writeString(cache, """
{
"schemaVersion": 1,
"contentFingerprint": "content",
"contextFingerprint": "context",
"results": [
{"packName":"overworld","blockingErrors":[],"warnings":[],"validatedAtMillis":1},
{"packName":"overworld","blockingErrors":[],"warnings":[],"validatedAtMillis":2}
]
}
""", StandardCharsets.UTF_8);
assertTrue(PackValidationCache.load(
cache, "content", "context", List.of("overworld")).isEmpty());
}
@Test
public void symbolicLinkCacheIsRejected() throws Exception {
Path directory = temporaryFolder.newFolder("symbolic-cache").toPath();
Path target = directory.resolve("target.json");
Files.writeString(target, "{}", StandardCharsets.UTF_8);
Path link = directory.resolve("validation.json");
try {
Files.createSymbolicLink(link, target.getFileName());
} catch (Exception unavailable) {
Assume.assumeNoException(unavailable);
}
assertTrue(PackValidationCache.load(
link, "content", "context", List.of()).isEmpty());
}
@Test
public void contentFingerprintChangesWhenSizeAndTimestampAreRestored() throws Exception {
File packsRoot = temporaryFolder.newFolder("content-fingerprint");
File pack = new File(packsRoot, "overworld");
assertTrue(pack.mkdirs());
Path dimension = new File(pack, "dimensions/overworld.json").toPath();
Files.createDirectories(dimension.getParent());
Files.writeString(dimension, "alpha", StandardCharsets.UTF_8);
FileTime originalTime = Files.getLastModifiedTime(dimension);
String before = PackValidationCache.contentFingerprint(packsRoot);
Files.writeString(dimension, "bravo", StandardCharsets.UTF_8);
Files.setLastModifiedTime(dimension, originalTime);
String after = PackValidationCache.contentFingerprint(packsRoot);
assertNotEquals(before, after);
}
@Test
public void cachedFailureRemainsFailClosedWhenPublished() throws Exception {
Path cache = new File(temporaryFolder.newFolder("failed-result"), "validation.json").toPath();
PackValidationCache.save(cache, "content", "context", List.of(
new PackValidationResult("overworld", List.of("missing structure"), List.of(), 1L)));
PackValidationResult loaded = PackValidationCache.load(
cache, "content", "context", List.of("overworld")).orElseThrow().getFirst();
PackValidationRegistry.clear();
PackValidationRegistry.publish(loaded);
try {
PackValidationRegistry.requireLoadable("overworld");
fail("Expected a persisted failed validation to remain blocking");
} catch (BrokenPackException expected) {
assertEquals(List.of("missing structure"), expected.getReasons());
} finally {
PackValidationRegistry.clear();
}
}
}
@@ -131,4 +131,24 @@ public class StudioOpenCoordinatorOpenKindTest {
assertTrue(method.contains("CompletableFuture<Void> abandonment = J.sfut("));
assertTrue(method.contains("abandonment.get(STUDIO_STRUCTURE_ACTIVATION_TIMEOUT_SECONDS"));
}
@Test
public void openFinalizerReturnsToTheServerThreadBeforeCompletion() throws Exception {
String source = Files.readString(Path.of(
"src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java"));
int finalizerCall = source.indexOf("runOpenFinalizer(request.onDone(), world);");
int futureCompletion = source.indexOf(
"future.complete(new StudioOpenResult(world, safeEntry))", finalizerCall);
int methodStart = source.indexOf(
"private void runOpenFinalizer(Consumer<World> finalizer, World world)");
int methodEnd = source.indexOf("private long elapsedMillis", methodStart);
String method = source.substring(methodStart, methodEnd);
assertTrue(finalizerCall >= 0);
assertTrue(futureCompletion > finalizerCall);
assertTrue(method.contains("if (J.isPrimaryThread())"));
assertTrue(method.contains("J.sfut(() -> finalizer.accept(world))"));
assertTrue(method.contains(
"completion.get(STUDIO_STRUCTURE_ACTIVATION_TIMEOUT_SECONDS, TimeUnit.SECONDS)"));
}
}
@@ -96,20 +96,31 @@ public class JigsawStudioLayoutTest {
}
@Test
public void spatialLayoutHasOneActiveWorkcell() {
public void spatialVariantsReceiveDedicatedAdjacentWorkcells() {
JigsawStudioVariant hall = spatialVariant("stronghold/hall");
JigsawStudioVariant stairs = spatialVariant("stronghold/stairs");
JigsawStudioVariant tower = spatialVariant("stronghold/tower");
JigsawStudioLayout layout = JigsawStudioLayout.create(
JigsawStudioMode.SPATIAL_JIGSAW,
CELL,
new JigsawStudioVariantCatalog(List.of(hall)));
new JigsawStudioVariantCatalog(List.of(hall, stairs, tower)));
assertEquals(1, layout.bays().size());
assertEquals(3, layout.bays().size());
JigsawStudioBay workcell = layout.bays().getFirst();
JigsawStudioBay second = layout.bays().get(1);
JigsawStudioBay third = layout.bays().get(2);
assertEquals(JigsawStudioLayout.SPATIAL_WORKCELL_ID, workcell.stableId());
assertEquals(JigsawStudioBayKind.SPATIAL_WORKCELL, workcell.kind());
assertTrue(workcell.archetype().isEmpty());
assertTrue(workcell.topology().isEmpty());
assertSame(hall, layout.defaultVariant(workcell).orElseThrow());
assertSame(stairs, layout.defaultVariant(second).orElseThrow());
assertSame(tower, layout.defaultVariant(third).orElseThrow());
assertEquals(1, second.bounds().originX() - workcell.bounds().maxX() - 1);
assertEquals(1, third.bounds().originX() - second.bounds().maxX() - 1);
assertTrue(layout.accepts(second, stairs));
assertFalse(layout.accepts(second, hall));
assertSame(second, layout.workcellForVariant(stairs.pieceKey()).orElseThrow());
assertNull(layout.findAt(-1, JigsawStudioLayout.FLOOR_Y + 1, -1));
}
@@ -448,7 +448,7 @@ public class JigsawStudioPersistenceEditorsTest {
}
@Test
public void createsSpatialThemeSetFromTheSingleSpatialWorkcellSource() throws Exception {
public void createsSpatialThemeSetFromTheSelectedSpatialWorkcellSource() throws Exception {
Path packRoot = temporaryFolder.newFolder("spatial-theme").toPath();
JigsawStudioProjectCreator.Options options = new JigsawStudioProjectCreator.Options(
"spatial/theme",
@@ -468,7 +468,39 @@ public class JigsawStudioPersistenceEditorsTest {
"spatial/theme/variants/spatial/variant-2",
creation.pieceKeysByWorkcell().get(JigsawStudioLayout.SPATIAL_WORKCELL_ID));
JsonObject pool = readJson(packRoot.resolve("jigsaw-pools/spatial/theme/start.json"));
assertEquals(2, pool.getAsJsonArray("pieces").size());
assertEquals(8, pool.getAsJsonArray("pieces").size());
}
@Test
public void createsSpatialThemeSetAcrossEveryDedicatedSpatialWorkcell() throws Exception {
Path packRoot = temporaryFolder.newFolder("spatial-theme-row").toPath();
JigsawStudioProjectCreator.Options options = new JigsawStudioProjectCreator.Options(
"spatial/row",
JigsawStudioMode.SPATIAL_JIGSAW,
JigsawStudioCompatibilityTarget.IRIS_EXTENDED,
new JigsawStudioCellDimensions(15, 15, 15));
assertTrue(JigsawStudioProjectCreator.create(packRoot, options).successful());
Map<String, String> sources = new LinkedHashMap<>();
sources.put(JigsawStudioLayout.SPATIAL_WORKCELL_ID, "spatial/row/start");
for (int connectorCount = 1; connectorCount <= 6; connectorCount++) {
String pieceKey = "spatial/row/connectors-" + connectorCount;
sources.put(JigsawStudioLayout.SPATIAL_WORKCELL_ID + "/" + pieceKey, pieceKey);
}
JigsawStudioGraphEditor.VariantFamilyCreation creation = JigsawStudioGraphEditor.duplicateActiveFamily(
packRoot,
"spatial/row",
sources,
"variant-2");
assertTrue(creation.writeResult().successful());
assertEquals(7, creation.pieceKeysByWorkcell().size());
for (String targetPieceKey : creation.pieceKeysByWorkcell().values()) {
assertTrue(Files.isRegularFile(packRoot.resolve("jigsaw-pieces/" + targetPieceKey + ".json")));
assertTrue(Files.isRegularFile(packRoot.resolve("objects/" + targetPieceKey + ".iob")));
}
JsonObject pool = readJson(packRoot.resolve("jigsaw-pools/spatial/row/start.json"));
assertEquals(14, pool.getAsJsonArray("pieces").size());
}
@Test
@@ -206,6 +206,64 @@ public class JigsawStudioProjectCreatorTest {
}
}
@Test
public void spatialProjectSeedsZeroThroughSixConnectorVariants() throws Exception {
Path temporaryDirectory = temporaryFolder.getRoot().toPath();
JigsawStudioProjectCreator.Options options = new JigsawStudioProjectCreator.Options(
"stronghold/gallery",
JigsawStudioMode.SPATIAL_JIGSAW,
JigsawStudioCompatibilityTarget.IRIS_EXTENDED,
new JigsawStudioCellDimensions(15, 15, 15));
StructureWriteResult result = JigsawStudioProjectCreator.create(temporaryDirectory, options);
assertTrue(result.successful());
JsonObject startPool = JsonParser.parseString(Files.readString(
temporaryDirectory.resolve("jigsaw-pools/stronghold/gallery/start.json"),
StandardCharsets.UTF_8)).getAsJsonObject();
JsonObject piecePool = JsonParser.parseString(Files.readString(
temporaryDirectory.resolve("jigsaw-pools/stronghold/gallery/pieces.json"),
StandardCharsets.UTF_8)).getAsJsonObject();
assertEquals(7, startPool.getAsJsonArray("pieces").size());
assertEquals(7, piecePool.getAsJsonArray("pieces").size());
assertEquals("stronghold/gallery/connectors-1", piecePool.getAsJsonArray("pieces").get(0)
.getAsJsonObject().get("piece").getAsString());
assertTrue(piecePool.getAsJsonArray("pieces").get(6).getAsJsonObject()
.get("empty").getAsBoolean());
for (int connectorCount = 0; connectorCount <= 6; connectorCount++) {
String pieceName = connectorCount == 0 ? "start" : "connectors-" + connectorCount;
JsonObject piece = JsonParser.parseString(Files.readString(
temporaryDirectory.resolve("jigsaw-pieces/stronghold/gallery/"
+ pieceName + ".json"),
StandardCharsets.UTF_8)).getAsJsonObject();
assertEquals(connectorCount, piece.getAsJsonArray("connectors").size());
assertEquals(connectorCount + (connectorCount == 1 ? " Connector" : " Connectors"),
piece.get("displayName").getAsString());
assertEquals(16, piece.getAsJsonObject("rules").get("maximumPlacements").getAsInt());
assertEquals(new IrisBlockVector(15, 15, 15), IrisObject.sampleSize(
temporaryDirectory.resolve("objects/stronghold/gallery/"
+ pieceName + ".iob").toFile()));
}
JsonObject six = JsonParser.parseString(Files.readString(
temporaryDirectory.resolve("jigsaw-pieces/stronghold/gallery/connectors-6.json"),
StandardCharsets.UTF_8)).getAsJsonObject();
assertEquals("NORTH_NEGATIVE_Z", six.getAsJsonArray("connectors").get(0)
.getAsJsonObject().get("direction").getAsString());
assertEquals("DOWN_NEGATIVE_Y", six.getAsJsonArray("connectors").get(5)
.getAsJsonObject().get("direction").getAsString());
assertEquals(0, six.getAsJsonArray("connectors").get(5)
.getAsJsonObject().getAsJsonObject("position").get("y").getAsInt());
StructureGraphCompilation compilation = StructureResourceBundleGraphCompiler.compile(
JigsawStudioProjectCreator.bundle(options)).getFirst();
StructureAssemblyResult preview = StructureAssembler.forCompilation(
compilation,
new IrisPosition(0, 0, 0)).assemble(new RNG(1337L));
assertEquals(preview.detail(), StructureAssemblyStatus.COMPLETE, preview.status());
assertTrue(preview.pieces().size() > 1);
assertTrue(preview.pieces().size() <= 96);
}
@Test
public void updatesOwnedPoolThroughWholeGraphTransaction() throws Exception {
Path temporaryDirectory = temporaryFolder.getRoot().toPath();
@@ -337,10 +395,10 @@ public class JigsawStudioProjectCreatorTest {
JsonObject pool = JsonParser.parseString(Files.readString(
temporaryDirectory.resolve("jigsaw-pools/stronghold/test/start.json"),
StandardCharsets.UTF_8)).getAsJsonObject();
assertEquals(2, pool.getAsJsonArray("pieces").size());
assertEquals("stronghold/test/hall", pool.getAsJsonArray("pieces").get(1)
assertEquals(8, pool.getAsJsonArray("pieces").size());
assertEquals("stronghold/test/hall", pool.getAsJsonArray("pieces").get(7)
.getAsJsonObject().get("piece").getAsString());
assertEquals(3, pool.getAsJsonArray("pieces").get(1)
assertEquals(3, pool.getAsJsonArray("pieces").get(7)
.getAsJsonObject().get("weight").getAsInt());
StructureWriteResult resized = JigsawStudioStructureEditor.updateCellSize(
@@ -360,10 +418,10 @@ public class JigsawStudioProjectCreatorTest {
new JigsawStudioCellDimensions(16, 12, 18)).writeResult().successful());
IrisBlockVector hallSize = IrisObject.sampleSize(
temporaryDirectory.resolve("objects/stronghold/test/hall.iob").toFile());
IrisBlockVector startSize = IrisObject.sampleSize(
IrisBlockVector starterSize = IrisObject.sampleSize(
temporaryDirectory.resolve("objects/stronghold/test/start.iob").toFile());
assertEquals(new IrisBlockVector(16, 12, 18), hallSize);
assertEquals(new IrisBlockVector(12, 10, 14), startSize);
assertEquals(new IrisBlockVector(12, 10, 14), starterSize);
StructureWriteResult limited = JigsawStudioStructureEditor.updateLimits(
temporaryDirectory,
@@ -1,89 +0,0 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.runtime.jigsaw.JigsawPlanarArchetype;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioLayout;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioVariantCatalog;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioWorkcellSpec;
import org.bukkit.entity.BlockDisplay;
import org.junit.Test;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
public class JigsawStudioDisabledWorkcellRendererTest {
@Test
public void disabledWorkcellProducesOneFullVolumeDescriptor() {
JigsawStudioLayout layout = layoutWithDisabled(JigsawPlanarArchetype.TEE);
Map<String, JigsawStudioDisabledWorkcellRenderer.Descriptor> descriptors =
JigsawStudioDisabledWorkcellRenderer.descriptors(layout);
assertEquals(1, descriptors.size());
JigsawStudioDisabledWorkcellRenderer.Descriptor descriptor =
descriptors.get(JigsawPlanarArchetype.TEE.stableId());
assertEquals(11, descriptor.width());
assertEquals(5, descriptor.height());
assertEquals(7, descriptor.depth());
assertEquals(JigsawStudioLayout.FLOOR_Y + 1, descriptor.originY());
}
@Test
public void enabledWorkcellsNeverProduceRedGlassDescriptors() {
JigsawStudioLayout layout = layoutWithDisabled(null);
Map<String, JigsawStudioDisabledWorkcellRenderer.Descriptor> descriptors =
JigsawStudioDisabledWorkcellRenderer.descriptors(layout);
assertTrue(descriptors.isEmpty());
assertFalse(layout.bays().isEmpty());
}
@Test
public void chunkUnloadDetachesOnlyDisplaysWhoseOriginsBelongToThatChunk() {
BlockDisplay target = mock(BlockDisplay.class);
BlockDisplay retained = mock(BlockDisplay.class);
JigsawStudioDisabledWorkcellRenderer.Descriptor targetDescriptor =
new JigsawStudioDisabledWorkcellRenderer.Descriptor("workcell/tee", 31, 65, -1, 3, 3, 3);
JigsawStudioDisabledWorkcellRenderer.Descriptor retainedDescriptor =
new JigsawStudioDisabledWorkcellRenderer.Descriptor("workcell/cross", 32, 65, -1, 3, 3, 3);
Map<String, BlockDisplay> entities = new HashMap<>(Map.of(
targetDescriptor.workcellId(), target,
retainedDescriptor.workcellId(), retained));
Map<String, JigsawStudioDisabledWorkcellRenderer.Descriptor> rendered = new HashMap<>(Map.of(
targetDescriptor.workcellId(), targetDescriptor,
retainedDescriptor.workcellId(), retainedDescriptor));
List<BlockDisplay> removals = JigsawStudioDisabledWorkcellRenderer.detachChunkDisplays(
entities, rendered, 1, -1);
assertEquals(List.of(target), removals);
assertFalse(entities.containsKey(targetDescriptor.workcellId()));
assertFalse(rendered.containsKey(targetDescriptor.workcellId()));
assertEquals(retained, entities.get(retainedDescriptor.workcellId()));
assertEquals(retainedDescriptor, rendered.get(retainedDescriptor.workcellId()));
verifyNoInteractions(target, retained);
}
private static JigsawStudioLayout layoutWithDisabled(JigsawPlanarArchetype disabled) {
List<JigsawStudioWorkcellSpec> specs = new ArrayList<>();
for (JigsawPlanarArchetype archetype : JigsawPlanarArchetype.values()) {
JigsawStudioCellDimensions dimensions = archetype == JigsawPlanarArchetype.TEE
? new JigsawStudioCellDimensions(11, 5, 7)
: new JigsawStudioCellDimensions(3, 3, 3);
specs.add(new JigsawStudioWorkcellSpec(archetype, "", dimensions, archetype != disabled));
}
return JigsawStudioLayout.createPlanar(
new JigsawStudioCellDimensions(3, 3, 3),
specs,
JigsawStudioVariantCatalog.empty());
}
}
@@ -16,12 +16,16 @@ import art.arcane.iris.engine.platform.studio.generators.JigsawStudioGenerator;
import art.arcane.iris.util.common.scheduling.J;
import art.arcane.volmlib.util.collection.KMap;
import org.bukkit.World;
import org.bukkit.event.entity.CreatureSpawnEvent;
import org.junit.After;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -70,6 +74,33 @@ public class JigsawStudioLifecycleTest {
assertFalse(JigsawStudioActivation.tryBeginOpen(OTHER_OWNER));
}
@Test
public void studioRejectsNaturalCreatureSpawns() {
assertTrue(JigsawStudioService.isNaturalStudioSpawn(
CreatureSpawnEvent.SpawnReason.NATURAL));
assertFalse(JigsawStudioService.isNaturalStudioSpawn(
CreatureSpawnEvent.SpawnReason.CUSTOM));
assertFalse(JigsawStudioService.isNaturalStudioSpawn(
CreatureSpawnEvent.SpawnReason.SPAWNER));
}
@Test
public void committedStudioActivationDisablesNaturalMobSpawning() throws IOException {
String source = Files.readString(Path.of(
"src/main/java/art/arcane/iris/core/service/JigsawStudioService.java"));
int registerStart = source.indexOf("public void register(");
int commitStart = source.indexOf("public void activationCommitted(", registerStart);
int commitEnd = source.indexOf("public void markChunkGenerated(", commitStart);
int helperStart = source.indexOf("static void disableNaturalStudioSpawning(", commitEnd);
String register = source.substring(registerStart, commitStart);
String commit = source.substring(commitStart, commitEnd);
String helper = source.substring(helperStart);
assertFalse(register.contains("disableNaturalStudioSpawning(world)"));
assertTrue(commit.contains("disableNaturalStudioSpawning(world)"));
assertTrue(helper.contains("setGameRule(GameRules.SPAWN_MOBS, false)"));
}
@Test
public void closeAuthorizationChecksOwnerDirtyStateAndSaveBarrierAtomically() {
assertTrue(JigsawStudioActivation.tryBeginOpen(OWNER));
@@ -91,10 +122,10 @@ public class JigsawStudioLifecycleTest {
service.tryBeginClose(request.requestId(), OWNER, false));
assertTrue(service.closeProtectionFailure(request.requestId()).contains("autosave"));
JigsawStudioSession.VariantSwitchToken switchToken = session.beginVariantSwitch(
JigsawStudioLayout.SPATIAL_WORKCELL_ID,
"stronghold/tower",
true).token().orElseThrow();
String towerWorkcellId = session.layout().workcellForVariant("stronghold/tower")
.orElseThrow().stableId();
JigsawStudioSession.VariantSwitchToken switchToken = session.beginVariantReload(
towerWorkcellId).token().orElseThrow();
assertEquals(
JigsawStudioService.CloseStart.OPERATION_IN_PROGRESS,
service.tryBeginClose(request.requestId(), OWNER, true));
@@ -19,6 +19,7 @@ import org.junit.Test;
import org.mockito.MockedStatic;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@@ -30,9 +31,11 @@ import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@@ -239,6 +242,87 @@ public class JigsawStudioMenuControllerTest {
0));
}
@Test
public void stagesMultipleCellSizeEditsAndAppliesOneRelayoutWithoutClosing() throws Exception {
UUID playerId = UUID.fromString("55555555-5555-5555-5555-555555555555");
Player player = mock(Player.class);
JigsawStudioMenuController.Actions actions = mock(JigsawStudioMenuController.Actions.class);
JigsawStudioMenuState.Variant active = variant("pieces/corner", true, List.of(), List.of());
JigsawStudioMenuState.Workcell workcell = workcell(active);
JigsawStudioMenuState state = state(JigsawStudioMenuState.Evaluation.pending(), workcell);
JigsawStudioMenuController controller = new JigsawStudioMenuController(
mock(JavaPlugin.class),
actions);
Method resize = JigsawStudioMenuController.class.getDeclaredMethod(
"resizeWorkcell",
Player.class,
UUID.class,
String.class,
JigsawStudioMenuController.DimensionAxis.class,
int.class);
Method apply = JigsawStudioMenuController.class.getDeclaredMethod(
"applyWorkcellResize",
Player.class,
UUID.class,
String.class);
resize.setAccessible(true);
apply.setAccessible(true);
when(player.getUniqueId()).thenReturn(playerId);
when(actions.menuState(player)).thenReturn(Optional.of(state));
when(actions.updateWorkcellDimensions(
player,
workcell.stableId(),
new JigsawStudioCellDimensions(25, 20, 18))).thenReturn(true);
resize.invoke(
controller,
player,
REQUEST_ID,
workcell.stableId(),
JigsawStudioMenuController.DimensionAxis.WIDTH,
1);
resize.invoke(
controller,
player,
REQUEST_ID,
workcell.stableId(),
JigsawStudioMenuController.DimensionAxis.HEIGHT,
8);
verify(actions, never()).updateWorkcellDimensions(
player,
workcell.stableId(),
new JigsawStudioCellDimensions(25, 20, 18));
try (MockedStatic<J> scheduling = mockStatic(J.class)) {
scheduling.when(() -> J.runEntity(
same(player),
any(Runnable.class),
eq(2))).thenReturn(true);
apply.invoke(controller, player, REQUEST_ID, workcell.stableId());
verify(actions).updateWorkcellDimensions(
player,
workcell.stableId(),
new JigsawStudioCellDimensions(25, 20, 18));
}
}
@Test
public void stagedCapacityPreservesEveryOtherWorkcellProperty() {
JigsawStudioMenuState.Variant active = variant("pieces/corner", true, List.of(), List.of());
JigsawStudioMenuState.Workcell source = workcell(active);
JigsawStudioCellDimensions capacity = new JigsawStudioCellDimensions(31, 19, 27);
JigsawStudioMenuState.Workcell staged = JigsawStudioMenuController.withCapacity(source, capacity);
assertEquals(capacity, staged.capacity());
assertEquals(source.stableId(), staged.stableId());
assertEquals(source.enabled(), staged.enabled());
assertEquals(source.activeVariantKey(), staged.activeVariantKey());
assertEquals(source.variants(), staged.variants());
}
@Test
public void allocatesNumberedThemeSetsAndAdjustsPositiveWeights() {
List<JigsawStudioMenuState.ThemeSet> themeSets = List.of(
@@ -254,6 +338,20 @@ public class JigsawStudioMenuControllerTest {
() -> JigsawStudioMenuController.adjustedPositiveValue(4, 0));
}
@Test
public void themeWeightsExposeWholeAssemblySelectionChance() {
List<JigsawStudioMenuState.ThemeSet> themes = List.of(
new JigsawStudioMenuState.ThemeSet("variant-1", 3),
new JigsawStudioMenuState.ThemeSet("variant-2", 1));
assertEquals("75.0%", JigsawStudioMenuController.themeSelectionPercent(
themes,
themes.getFirst()));
assertEquals("25.0%", JigsawStudioMenuController.themeSelectionPercent(
themes,
themes.getLast()));
}
@Test
public void editsPieceRuleFieldsWithinRuntimeBounds() {
assertEquals(
@@ -1,5 +1,7 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioLayout;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioMode;
import art.arcane.iris.engine.framework.PlacedStructurePiece;
import art.arcane.iris.engine.object.IrisJigsawPiece;
import art.arcane.iris.engine.object.IrisObject;
@@ -68,6 +70,28 @@ public class JigsawStudioPreviewRendererTest {
assertTrue(plan.bounds().isEmpty());
}
@Test
public void spatialPreviewFloatsAboveThePlanarEditingFloor() {
PlacedStructurePiece source = piece(
new IrisObject(3, 3, 3),
10,
10,
10,
IrisObjectRotation.of(0, 0, 0));
PlacedStructurePiece planar = JigsawStudioService.alignPreviewPieces(
List.of(source),
JigsawStudioMode.PLANAR_JIGSAW).getFirst();
PlacedStructurePiece spatial = JigsawStudioService.alignPreviewPieces(
List.of(source),
JigsawStudioMode.SPATIAL_JIGSAW).getFirst();
assertEquals(JigsawStudioLayout.FLOOR_Y + 1, planar.getMinY());
assertEquals(JigsawStudioLayout.FLOOR_Y + 48, spatial.getMinY());
assertEquals(planar.getMinX(), spatial.getMinX());
assertEquals(planar.getMinZ(), spatial.getMinZ());
}
@Test
public void uncertainPositionIsReappliedWhenSuccessivePlansMatch() {
JigsawStudioPreviewRenderer.BlockPosition position =
@@ -63,7 +63,7 @@ public class JigsawStudioResourceBundleAssemblerTest {
List.of(connector),
false);
assertEquals(4, assembly.bundle().resources().size());
assertEquals(17, assembly.bundle().resources().size());
assertEquals("fort/start", assembly.objectKey());
assertEquals(1, assembly.piece().getConnectors().size());
StructureWriteResult result = new StructureTransactionWriter(packRoot)
@@ -41,6 +41,7 @@ import art.arcane.iris.util.common.scheduling.J;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.collection.KMap;
import org.bukkit.Chunk;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
@@ -103,6 +104,7 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
@@ -112,6 +114,24 @@ import static org.mockito.Mockito.when;
public class JigsawStudioServiceCaptureTest {
@Test
public void successfulStudioSavePlaysOneOwnerLocalBell() {
Player player = mock(Player.class);
Location location = mock(Location.class);
when(player.getLocation()).thenReturn(location);
try (MockedStatic<J> scheduling = mockStatic(J.class)) {
scheduling.when(() -> J.runEntity(same(player), any(Runnable.class))).thenAnswer(invocation -> {
invocation.getArgument(1, Runnable.class).run();
return true;
});
JigsawStudioService.playSaveSound(player);
verify(player).playSound(location, "minecraft:block.note_block.bell", 0.65F, 1.65F);
}
}
@Test
public void hiddenConnectorResetRestoresOnlyItsSavedOrdinaryBlock() throws Exception {
World world = mock(World.class);
@@ -741,10 +761,23 @@ public class JigsawStudioServiceCaptureTest {
layout.get("workcell/end"),
List.of(eastSource),
3);
assertThrows(IOException.class, () -> JigsawStudioService.requireWorkcellTopology(
layout.get("workcell/end"),
List.of(southDisplayed),
0));
JigsawStudioService.WorkcellTopologyException wrongDirection = assertThrows(
JigsawStudioService.WorkcellTopologyException.class,
() -> JigsawStudioService.requireWorkcellTopology(
layout.get("workcell/end"),
List.of(southDisplayed),
0));
assertTrue(wrongDirection.getMessage().contains("south end (1 horizontal connector)"));
assertTrue(wrongDirection.getMessage().contains("Reset Connector Blocks"));
JigsawStudioService.WorkcellTopologyException missingTee = assertThrows(
JigsawStudioService.WorkcellTopologyException.class,
() -> JigsawStudioService.requireWorkcellTopology(
layout.get("workcell/tee"),
List.of(),
0));
assertTrue(missingTee.getMessage().contains("north east west tee (3 horizontal connectors)"));
assertTrue(missingTee.getMessage().contains("blank (0 horizontal connectors)"));
}
@Test
@@ -1233,6 +1266,9 @@ public class JigsawStudioServiceCaptureTest {
when(world.getUID()).thenReturn(worldId);
JigsawStudioGenerator generator = mock(JigsawStudioGenerator.class);
when(generator.getLayout()).thenReturn(layout);
JigsawStudioActivation.Request request = mock(JigsawStudioActivation.Request.class);
when(request.requestId()).thenReturn(UUID.randomUUID());
when(generator.getRequest()).thenReturn(request);
when(generator.renderBay(any(JigsawStudioBay.class)))
.thenReturn(JigsawStudioGenerator.RenderedBay.empty(dimensions));
Class<?> studioType = Class.forName(JigsawStudioService.class.getName() + "$ActiveStudio");
@@ -212,6 +212,32 @@ public class JigsawStudioGeneratorTest {
fixture.generator(), control.worldX(), control.worldY(), control.worldZ()));
}
@Test
public void paintsEveryWorkcellAsAWhiteConcreteEdgeCuboid() {
GeneratorFixture fixture = fixture(
JigsawStudioMode.PLANAR_JIGSAW,
new JigsawStudioCellDimensions(5, 4, 3),
JigsawStudioVariantCatalog.empty());
JigsawStudioBay workcell = fixture.layout().get("workcell/blank");
int minimumX = workcell.bounds().originX() - 1;
int maximumX = workcell.bounds().maxX() + 1;
int minimumZ = workcell.bounds().originZ() - 1;
int maximumZ = workcell.bounds().maxZ() + 1;
int bottomY = workcell.bounds().originY();
int topY = workcell.bounds().maxY() + 1;
assertSame(fixture.frame(), stateAt(fixture.generator(), minimumX, bottomY, minimumZ));
assertSame(fixture.frame(), stateAt(fixture.generator(), maximumX, bottomY, maximumZ));
assertSame(fixture.frame(), stateAt(fixture.generator(), minimumX, topY, maximumZ));
assertSame(fixture.frame(), stateAt(fixture.generator(), maximumX, topY, minimumZ));
assertSame(fixture.frame(), stateAt(fixture.generator(), minimumX, bottomY + 1, minimumZ));
assertFalse(fixture.frame() == stateAt(
fixture.generator(),
workcell.bounds().originX(),
bottomY + 1,
workcell.bounds().originZ()));
}
@Test
public void glyphMathHandlesAllArchetypesAndSmallEvenAndOddCells() {
int[] sizes = {1, 2, 3, 4, 7, 16};
@@ -521,6 +547,7 @@ public class JigsawStudioGeneratorTest {
return new GeneratorFixture(
generator,
layout,
frame,
topologyBase,
topologyPath,
connectorCap,
@@ -559,6 +586,7 @@ public class JigsawStudioGeneratorTest {
private record GeneratorFixture(
JigsawStudioGenerator generator,
JigsawStudioLayout layout,
PlatformBlockState frame,
PlatformBlockState topologyBase,
PlatformBlockState topologyPath,
PlatformBlockState connectorCap,
+2
View File
@@ -49,6 +49,8 @@ Validate the plugin install from the server console:
The first command must report the running Iris, platform, and Minecraft versions. The second must resolve the downloaded pack and finish without blocking validation errors. Then complete the disposable-world workflow in `02 - Getting Started.md`; a command response alone does not prove that the generator can create chunks.
Iris denies player login until managed external datapacks and installed dimension packs complete startup validation. Unchanged validated datapacks and packs reuse their persisted content/context results; a failed external datapack state keeps login and all Iris world creation locked, while an install or repair that changes registry inputs requires the clean restart Iris reports. A dimension pack with blocking errors remains unavailable to world and Studio creation without preventing healthy validated packs from being used.
Command root: `/iris` (aliases `/ir`, `/irs`). Explicit permission in the descriptor: `iris.treefeller` (default op). Command access uses the Director permission model rooted at `iris.all` (see `04 - Commands & Permissions.md`).
Soft dependencies (optional, not bundled): PlaceholderAPI, CraftEngine, Nexo, ItemsAdder, SCore, ExecutableItems, MythicLib, MMOItems, eco, EcoItems, MythicMobs, MythicCrucible, KGenerators, WorldEdit. Multiverse-Core is ordered after Iris so Multiverse sees Iris generators after Iris is up.
+1 -1
View File
@@ -93,7 +93,7 @@ Static helper `IrisSettings.getThreadCount(int c)`: for `c` in `{-1,-2,-4}` retu
| `useConsoleCustomColors` | boolean | `true` | Custom colors for console senders |
| `useCustomColorsIngame` | boolean | `true` | Custom colors for player senders |
| `adjustVanillaHeight` | boolean | `false` | Adjust vanilla height handling |
| `autoIngestDatapacks` | boolean | `true` | Auto-ingest configured external datapacks; managed structures remain scoped to declaring Iris dimensions |
| `autoIngestDatapacks` | boolean | `true` | Validate and ingest configured external datapacks during the startup admission gate; unchanged committed content reuses its persisted result without another remote/full validation, and managed structures remain scoped to declaring Iris dimensions |
| `autoImportDatapackStructures` | boolean | `false` | Opt-in bulk write of every registered datapack structure as editable Iris resources; prefer `/iris structure import <dimension>` |
| `strictContentKeys` | boolean | `false` | Unresolved pack content keys and bad block-state properties become blocking pack errors; system property `-Diris.strictContent` overrides when set |
| `spinh` | int | `-20` | Splash / spin color H |
+4 -4
View File
@@ -228,11 +228,11 @@ See `10 - Studio & VSCode Schemas.md`.
| `menu` | — | Open the six-row controls also opened by the generated chest or three sneaks within 1.5 seconds |
| `select` | — | Select the workcell containing the player |
| `goto` | `<workcell>` | Select and teleport above a stable workcell ID; alias `teleport` |
| `particles` | `<visible>` | Toggle player-local bounds and connector particles |
| `particles` | `<visible>` | Toggle player-local workcell-bound, connector, and temporary assembly-preview particle trails |
| `save` | `[bay=selected]` | Flush the selected dirty workcell's automatic capture now; normal block and container updates already autosave |
| `connector channel` | `<channel\|none>` | Look at a saved marker in the active owned workcell within 8 blocks and set/clear its Iris-only channel at the inverse-mapped source position |
| `bounds` | `<width> <height> <depth>` | Set the selected workcell capacity without resizing any variant object; every existing variant must fit, and the compact Studio layout regenerates in place; aliases `cell`, `resize` |
| `workcell capacity` | `<width> <height> <depth>` | Explicit nested form of `bounds`; planar capacities are per canonical archetype and spatial capacity is the single project envelope |
| `workcell capacity` | `<width> <height> <depth>` | Explicit nested form of `bounds`; planar capacities are per canonical archetype and spatial capacity is the shared envelope for its one-row variant cells |
| `workcell label` | `<displayName>` | Set the selected planar or spatial workcell's author label; quote spaces; solver identity remains canonical |
| `workcell label-reset` | — | Reset the selected workcell to its canonical solver label; alias `reset-label` |
| `pool create` | `<poolKey> [fallbackPoolKey=none]` | Atomically create an empty owned pool, optionally using an existing owned direct fallback |
@@ -254,7 +254,7 @@ See `10 - Studio & VSCode Schemas.md`.
| `export` | `[namespace=iris] [output=jigsaw-export] [format=zip] [replace=false]` | Start a background strict Minecraft 26.2 vanilla datapack export as one direct artifact under the Studio packs `exports/` folder |
| `delete` | `[confirm=false]` | With `confirm=true`, scan reverse references, close Studio, and hash-pinned-delete the complete owned project; alias `remove` |
There is no Jigsaw Studio undo command, adoption rollback command, or mod-loader authoring command. **Undo Last Autosave** in Workcell Settings restores the newest of five previous saved graph iterations retained in one `.iris/jigsaw-history/key-<sha256>.json` file. **Reset Connector Blocks** restores the selected workcell's saved connector blocks without replacing its other edited blocks. Planar Studio always has six independently capacitated/enabled canonical workcells, spatial Studio one, and every variant retains its own exact dimensions and optional display label. Workcell and variant rename tools are renamed in an anvil, right-clicked to apply, and sneak-right-clicked to reset. A catalog may contain at most 512 variants. The seed-`1337` assembly is evaluated automatically and rendered as a permanent protected block preview; `preview assemble` is the separate temporary arbitrary-seed particle diagnostic. See `21 - Jigsaw Structures.md` for GUI/toolbox controls, themes/chance/rules/caps, markers, ownership, placement, export, and recovery.
There is no Jigsaw Studio undo command, adoption rollback command, or mod-loader authoring command. **Undo Last Autosave** in Workcell Settings restores the newest of five previous saved graph iterations retained in one `.iris/jigsaw-history/key-<sha256>.json` file. **Reset Connector Blocks** restores the selected workcell's saved connector blocks without replacing its other edited blocks. Planar Studio always has six independently capacitated/enabled canonical workcells. Spatial Studio places every variant in a dedicated one-row cell; a new project seeds seven 15×15×15 variants with 0 through 6 cumulative face-center connectors. All cells retain one clear block of separation and use physical white-concrete edge cages with player-local particle trails inside them; Jigsaw Studio spawns no display entities for workcell bounds. Every variant retains its own exact dimensions and optional display label. Workcell and variant rename tools are renamed in an anvil, right-clicked to apply, and sneak-right-clicked to reset. A catalog may contain at most 512 variants. Natural creature spawning is disabled in the transient Studio world. The seed-`1337` assembly is evaluated automatically and rendered as a permanent protected block preview; spatial previews form an elevated connected assembly, while `preview assemble` remains the separate temporary arbitrary-seed particle diagnostic. See `21 - Jigsaw Structures.md` for GUI/toolbox controls, whole-assembly theme chances, independent pool-entry chances, rules/caps, markers, ownership, placement, export, and recovery.
Bukkit has one global Studio project/world and the Jigsaw session belongs to one owning player. Only that owner can control, load, or mutate it; entering a workcell makes that physical cell the owner's next menu selection. Non-owner edits are cancelled and non-owner commands use a strict informational/communication allowlist. Block and inventory changes in loaded owned workcells autosave after a 40-tick quiet period. Duplicate-one and duplicate-family actions queue once behind pending autosave, expedite it, and continue automatically against the same request and source variants. The chest and live preview are protected; schema-1 or otherwise stale toolbox sticks are rejected. A later mutation after capture starts remains dirty for another capture. Plugins that bypass covered events must call `JigsawStudioService.markDirty(...)` or `markAllDirty(...)`.
@@ -267,7 +267,7 @@ Bukkit has one global Studio project/world and the Jigsaw session belongs to one
| `validate` | `v` | **Bukkit:** `[pack=<key>]`. **Modded:** `[pack]`; empty = all | Validate pack(s) and publish results |
| `cleanup` | `c` | **Bukkit:** `<pack> [mode=preview]`. **Modded:** `<pack> [apply]` | Preview/quarantine unused resources |
| `restore` | `r` | same pattern | Preview/restore latest quarantine |
| `status` | `s` | **Bukkit:** `[pack=<key>]`. **Modded:** `[pack]` | Cached validation status |
| `status` | `s` | **Bukkit:** `[pack=<key>]`. **Modded:** `[pack]` | Startup-published validation status, including persisted unchanged results |
See `25 - Pack Management.md`.
+17 -14
View File
@@ -35,6 +35,7 @@ This workflow passes when the dimension is re-injected after restart and generat
| Symptom | Meaning | Recovery |
|---|---|---|
| Command reports busy | Another `WORLD_MUTATION` or `PACK_MUTATION` lease owns the lifecycle coordinator | Let that operation finish; do not retry concurrent create/remove/update commands |
| Login or create reports startup validation pending/failed/restart-required | External datapacks or dimension-pack validation has not reached a safe state | Fix the first logged failure or complete the requested restart; do not create folders or add `bukkit.yml` entries manually |
| Folia create succeeds but teleport cannot find the world | Creation staged files and registration only | Restart, then load/teleport as instructed by the staging result |
| Bukkit load reports missing or inconsistent data | Managed dimension root, registration, or `iris/pack` snapshot is incomplete | Keep the directory, restore from backup, and reconcile registration before retrying; load never redownloads the snapshot |
| Unload reaches its terminal timeout | World, generator, or scheduler work did not settle within 150 seconds | Allow the requested restart; do not force-delete the live directory |
@@ -95,29 +96,30 @@ Full permission table: `04 - Commands & Permissions.md`.
| `seed` | `1337` | World seed |
| `main` | `false` | Schedule main-world promotion on JVM shutdown (Paper path) or promote during Folia staging |
Create refuses the primary Bukkit thread. Lifecycle domain `WORLD_MUTATION` / kind `WORLD_CREATE` must be free or create fails busy.
Create refuses the primary Bukkit thread. Startup datapack validation must be ready and the selected source pack must have a loadable validation result before the lifecycle lease, datapack preparation, dimension folder, pack snapshot, registration, or Bukkit/NMS create path is entered; lifecycle domain `WORLD_MUTATION` / kind `WORLD_CREATE` must then be free or create fails busy.
## Production create flow (non-Folia)
1. Resolve managed key and empty dimension root.
2. Resolve dimension via `IrisToolbelt.getDimension` (may download pack if missing).
1. Resolve the managed key and dimension without creating the dimension root.
2. Require startup datapack readiness and a loadable validation result for the dimension's owning pack.
3. Ensure datapacks for the dimension types are installed; queue restart if types not yet loaded.
4. Copy pack into `<world>/iris/pack` (`StudioSVC.installIntoWorld`) — atomic stage → publish; refuses primary thread.
4. Copy the pack into `<world>/iris/pack` (`StudioSVC.installIntoWorld`) — atomic stage → publish; refuses primary thread.
5. Build `WorldCreator` with Iris generator (`studio=false`).
6. Create world through `WorldLifecycleService` / NMS async create (timeout 120s; timeout triggers server restart).
7. Register world in `bukkit.yml` with generator `Iris` dimension key and seed; Multiverse link update when present.
8. Optional creation-time pregen if a `PregenTask` was attached by the creator API.
6. Create the world through `WorldLifecycleService` / NMS async create (timeout 120s; timeout triggers server restart).
7. Register the world in `bukkit.yml` with generator `Iris` dimension key and seed; update the Multiverse link when present.
8. Run optional creation-time pregen if a `PregenTask` was attached by the creator API.
## Folia staging
Runtime world creation is disabled on Folia. `/iris create` instead:
1. Acquires `WORLD_CREATE` lease.
2. Installs datapacks if changed.
3. Stages pack into the managed dimension root via `installIntoWorld`.
4. Registers the world in `bukkit.yml` (`BukkitWorldConfiguration.register`).
5. If `main=true`, promotes main-world files immediately under lease (failure rolls back bukkit.yml + deletes staged folder).
6. Instructs operator to restart; generation/load happens on next startup.
1. Requires startup datapack readiness and a loadable validation result for the selected pack; refusal leaves no dimension folder or registration.
2. Acquires the `WORLD_CREATE` lease.
3. Installs datapacks if changed.
4. Stages the pack into the managed dimension root via `installIntoWorld`.
5. Registers the world in `bukkit.yml` (`BukkitWorldConfiguration.register`).
6. If `main=true`, promotes main-world files immediately under lease (failure rolls back bukkit.yml + deletes staged folder).
7. Instructs the operator to restart; generation/load happens on next startup.
`WorldLifecycleStaging` holds staged generators/biome providers for the backend that consumes them at load.
@@ -125,6 +127,7 @@ Runtime world creation is disabled on Folia. `/iris create` instead:
Studio uses `IrisCreator.studio(true)`:
- Startup datapack validation and the selected pack's validation must be loadable before a Studio project/world folder, snapshot, generator, or Bukkit world is created. Missing validation fails closed.
- Does **not** copy the pack into the world folder (except benchmark).
- Engine data folder is the live pack path; hotloader starts after engine setup.
- Biome Buffet prepares a changed focus before opening the chunk generation session. Its exclusive fair-stage admission downgrades directly to the retained chunk permit, so no other transition can enter between the focus hotload and that chunk.
@@ -140,7 +143,7 @@ Studio uses `IrisCreator.studio(true)`:
2. `BukkitWorldReconciler.loadWorld(bukkit.yml, worldKey)`.
3. Reports success, busy, restart-required, or failure.
Load does not re-download packs; the world must already have `iris/pack` content and registration data consistent with Iris.
Load does not re-download packs; the world must already have `iris/pack` content and registration data consistent with Iris. Reconciliation checks startup readiness and the resolved pack before touching `bukkit.yml` or calling a world backend.
## Unload
+9 -9
View File
@@ -34,7 +34,7 @@ The loop passes when the editor binds the generated schema, hotload succeeds, pa
| Symptom | Meaning | Recovery |
|---|---|---|
| `open` reports blocking validation errors | Pack graph cannot safely build an engine | Run the platform's `pack validate` form, fix the first blocking error, and retry; do not bypass validation |
| `open` reports startup validation pending, missing, failed, restart-required, or blocking pack errors | Datapacks or the pack graph cannot safely build an engine | Complete the requested restart or run the platform's `pack validate` form, fix the first blocking error, and retry; do not bypass validation |
| Save reports hotload failure | New data/runtime build failed and the previous runtime may remain active | Fix the first console error and save again before making unrelated edits |
| Height, logical height, or dimension type change is rejected | Change violates `IrisDimensionRuntimeContract` | Close Studio and reopen; on modded, restart when regenerated dimension-type datapacks require registry reload |
| Valid change is invisible | Existing chunks are already materialized or the edited resource is unreachable | Move to new chunks and trace the active dimension graph; use focus/buffet modes for isolation |
@@ -66,9 +66,9 @@ Root: `/iris studio` (aliases `std`, `s`). Implemented by `CommandStudio` + `Stu
| Subcommand | Aliases | What it does |
|------------|---------|--------------|
| `open <dimension> [seed=1337]` | `o` | Close any open studio, open pack as studio world. Blocks if pack validation has blocking errors. |
| `open <dimension> [seed=1337]` | `o` | Close any open studio, open pack as studio world. Blocks unless startup datapack validation is ready and the selected pack has a loadable validation result. |
| `close` | `x` | Close the active studio project/world. |
| `create [name=studio] [template=<dimension>]` | `+` | Create a new pack under `packs/<name>`. Optional template is another pack dimension key; without template, writes the starter skeleton (see below). |
| `create [name=studio] [template=<dimension>]` | `+` | Create a new pack under `packs/<name>` only after startup validation is ready. An optional template must itself be validated and loadable; without a template, writes the starter skeleton (see below). |
| `vscode [dimension=default]` | `vsc` | Open the packs VSCode workspace (generates it if missing). |
| `update [dimension=default]` | | Rewrite `<pack>/<name>.code-workspace` and regenerate `.iris/schema/*` mappings. |
| `version [dimension=default]` | | Print dimension `version` field. |
@@ -88,9 +88,9 @@ Permissions and the full `/iris` tree: see `04 - Commands & Permissions.md`.
## Jigsaw Studio (Bukkit)
`/iris jigsaw` opens one selected structure graph through the transient Studio lifecycle, but chooses `JigsawStudioGenerator` for that activation without persisting a special dimension mode. The owner enters in creative. Planar Studio has six rotation-independent workcells in a compact three-column by two-row layout: Blank, End Cap, Hallway, L Junction, T Junction, and Cross Junction. Spatial Studio has one workcell. There is no orientation, permutation, piece, or derived-rotation gallery.
`/iris jigsaw` opens one selected structure graph through the transient Studio lifecycle, but chooses `JigsawStudioGenerator` for that activation without persisting a special dimension mode. The owner enters in creative, `spawn_mobs` is disabled, and natural creature-spawn events are cancelled. Planar Studio has six rotation-independent workcells in a compact three-column by two-row layout: Blank, End Cap, Hallway, L Junction, T Junction, and Cross Junction. Spatial Studio places every variant in its own dedicated cell in one horizontal row. New spatial projects seed seven 15×15×15 cells labeled 0 through 6 connectors, with one additional north, south, east, west, up, then down face-center connector in each successive cell. All seven pieces belong to the start pool; the 1-through-6 connector pieces also belong to the generated pieces pool with an explicit empty terminator and a 16-placement default per piece. Their seed-`1337` preview is a connected blob raised 48 blocks above the editing floor without making the intentionally connectorless piece an unreachable child. There is no orientation, permutation, or derived-rotation gallery.
Each planar floor is light-gray wool with a red canonical topology glyph and sea-lantern caps at its face-center connector positions. Every workcell has an independent width, height, depth, enabled state, and optional author label. Those dimensions are capacity only: changing one never rewrites a variant object, and the complete change is rejected if any existing variant would no longer fit. Each owned variant has its own exact width, height, depth, and optional label, so one End Cap can be a `16×3×3` longhouse while another End Cap in the same workcell remains `3×3×3`. Per-variant growth or lossless shrink preserves in-bounds canonical content and moves canonical connector payloads and sockets to the new face centers; cropped stored content, connector collisions, or shared/read-only objects reject the transaction. Capacity changes regenerate and rehydrate the compact layout in place, while resizing the loaded variant reloads that cell in place. A disabled planar workcell remains editable but is excluded from assembly and vanilla export; a full-volume red stained-glass display marks it and is recreated when its origin chunk reloads. Existing planar variants are rotated into the archetype's canonical display orientation and inverse-rotated during capture, while their piece resources, dimensions, labels, and pool entries remain distinct.
Each planar floor is light-gray wool with a red canonical topology glyph and sea-lantern caps at its face-center connector positions. Every workcell has an independent width, height, depth, enabled state, and optional author label. Those dimensions are capacity only: changing one never rewrites a variant object, and the complete change is rejected if any existing variant would no longer fit. Each owned variant has its own exact width, height, depth, and optional label, so one End Cap can be a `16×3×3` longhouse while another End Cap in the same workcell remains `3×3×3`. Per-variant growth or lossless shrink preserves in-bounds canonical content and moves canonical connector payloads and sockets to the new face centers; cropped stored content, connector collisions, or shared/read-only objects reject the transaction. Workcell Settings stages width, height, and depth clicks in the open menu and performs one live regeneration only after **Apply Cell Size**; resizing the loaded variant still reloads that cell in place. Every enabled or disabled cell uses a physical white-concrete edge cage with particle trails drawn inside its editable bounds, and no workcell-bound display entity is spawned. Existing planar variants are rotated into the archetype's canonical display orientation and inverse-rotated during capture, while their piece resources, dimensions, labels, and pool entries remain distinct.
Create a default planar, Iris-native graph with:
@@ -112,13 +112,13 @@ Each committed graph is compiled and assembled automatically with seed `1337`. T
Structure themes select one weighted family before assembly. **Duplicate All Enabled Cells as Family** allocates the next `variant-<n>` theme by default, clones the currently loaded owned variant from every enabled workcell with its exact object size and label, duplicates their pool memberships, assigns the new pieces to that family, and atomically loads the new family across those workcells. Individual loaded owned variants can join one or more declared themes; an empty theme list makes a piece available to every selected theme. Pool membership `chance` is an independent `0..1` eligibility gate applied before its positive relative weight. Piece rules constrain minimum/maximum depth, minimum/maximum placements, and terminal status. With mandatory caps enabled, an unresolved open connector must use its direct fallback to place a compatible terminal piece; failure rejects that assembly. Themes, chance gates, piece rules, and mandatory caps are Iris-only and block `VANILLA_PORTABLE` compilation or export when used.
Connector blocks are hidden per workcell by default and can be shown from **Workcell Settings**. **Reset Connector Blocks** rewrites every saved connector coordinate in the selected workcell from the active on-disk variant while leaving every other edited block unchanged; it restores jigsaw orientation and NBT while visible, or the exact final block and tile NBT while hidden. If an autosave already committed a deleted connector, use **Undo Last Autosave** first. Hidden capture retains each connector's pool, identity, orientation, priorities, channel, and authored order while the ordinary block and tile NBT at that coordinate become its exact final state; visible mode exposes Mojang's jigsaw UI for pool, name, target, joint, final state, and both signed priorities. `/iris jigsaw connector channel <channel|none>` changes the saved Iris-only channel for the exact targeted visible connector. Aqua particles mark the occupied workcell, dark gray marks nearby valid bounds, red marks invalid bounds or incomplete connector identity, lime marks a valid connector without a channel, and a channel receives a deterministic color. The Iris scoreboard switches automatically to Jigsaw context and shows the structure, author workcell label, canonical solver role when the label differs, loaded variant label, state, and `Triple-sneak for controls`; `/iris studio scoreboard` retains its session-only toggle behavior.
Connector blocks are hidden per workcell by default and can be shown from **Workcell Settings**. **Reset Connector Blocks** rewrites every saved connector coordinate in the selected workcell from the active on-disk variant while leaving every other edited block unchanged; it restores jigsaw orientation and NBT while visible, or the exact final block and tile NBT while hidden. If an autosave already committed a deleted connector, use **Undo Last Autosave** first. Hidden capture retains each connector's pool, identity, orientation, priorities, channel, and authored order while the ordinary block and tile NBT at that coordinate become its exact final state; visible mode exposes Mojang's jigsaw UI for pool, name, target, joint, final state, and both signed priorities. `/iris jigsaw connector channel <channel|none>` changes the saved Iris-only channel for the exact targeted visible connector. Particle trails outline nearby and focused editable bounds inside the white-concrete cages; connector diagnostics remain red for incomplete identity, lime for a valid connector without a channel, and deterministic by channel otherwise. The Iris scoreboard switches automatically to Jigsaw context and shows the structure, author workcell label, canonical solver role when the label differs, loaded variant label, state, and `Triple-sneak for controls`; `/iris studio scoreboard` retains its session-only toggle behavior. Every successful atomic workcell save plays one owner-local bell.
Bukkit has one global Studio project/world and one owning Jigsaw session. Only that owner can control or mutate it. Non-owner edits are cancelled, non-owner commands use a strict informational/communication allowlist, and the control chest plus live preview are protected. Autosave, variant switching, graph changes, opening, closing, and deletion share operation barriers. Close waits for clean state unless `discard=true`; discard is only for deliberately losing pending work.
Dimensions are capped at 128 blocks on X/Z, 192 on Y, and 2,097,152 blocks in total; planar variant and capacity width/depth must each be at least 3. A workcell capacity change persists only structure metadata, verifies every variant fits, leaves all object bytes unchanged, and atomically regenerates the affected live cages, objects, connector view, and block-entity hydration before editing resumes. `variant resize` and the **Variant Size** screen change only the selected owned object; lossless growth and shrink preserve in-bounds canonical content, reject cropped explicit air/blocks/tiles/connectors, and relocate planar canonical sockets. The loaded variant reloads in place; inactive variants remain untouched. **Resize to Capacity** or `/iris jigsaw piece expand` is a convenience for setting one selected object exactly to its current capacity. On Folia, each intersecting object chunk is read on its owning region and one graph write begins only after the full snapshot validates.
Dimensions are capped at 128 blocks on X/Z, 192 on Y, and 2,097,152 blocks in total; planar variant and capacity width/depth must each be at least 3. A workcell capacity change persists only structure metadata, verifies every variant fits, leaves all object bytes unchanged, and atomically regenerates the affected white-concrete cage, objects, connector view, and block-entity hydration before editing resumes. Workcell Settings batches all three staged axes into that one transaction; **Discard Size Changes** cancels the menu-local values without writing. `variant resize` and the **Variant Size** screen change only the selected owned object; lossless growth and shrink preserve in-bounds canonical content, reject cropped explicit air/blocks/tiles/connectors, and relocate planar canonical sockets. The loaded variant reloads in place; inactive planar variants remain untouched, while a spatial variant's dedicated cell moves with the live row. **Resize to Capacity** or `/iris jigsaw piece expand` is a convenience for setting one selected object exactly to its current capacity. On Folia, each intersecting object chunk is read on its owning region and one graph write begins only after the full snapshot validates.
Deleting a variant is limited to an owned, inactive variant when another variant remains in that workcell. Project deletion first verifies ownership hashes and scans the pack for external JSON or ownership-manifest references; any reverse reference blocks deletion. A clear result closes Studio and removes the complete owned resource set through a hash-pinned transaction. If the post-close delete fails, the project files remain on disk for recovery.
Deleting a planar variant is limited to an owned, inactive variant when another variant remains in that workcell. A spatial variant is always active in its dedicated cell, so deletion removes that cell when at least one other spatial variant remains. Project deletion first verifies ownership hashes and scans the pack for external JSON or ownership-manifest references; any reverse reference blocks deletion. A clear result closes Studio and removes the complete owned resource set through a hash-pinned transaction. If the post-close delete fails, the project files remain on disk for recovery.
This command tree is Bukkit-only. Saved `PLANAR_JIGSAW` and `SPATIAL_JIGSAW` pack resources run in the shared core on Fabric, Forge, and NeoForge, and strict `VANILLA_PORTABLE` graphs can be exported as Minecraft 26.2 datapacks. The complete workflow, commands, marker rules, portability blockers, and recovery steps are in `21 - Jigsaw Structures.md`.
@@ -168,7 +168,7 @@ With a template: `/iris studio create name=mypack template=overworld` copies tha
Both ordinary and Jigsaw Studio reuse Iris's startup-loaded datapack runtime only while its pinned compiler-input fingerprint still matches every live `dimensions`, `biomes`, and `snippet` JSON input, the compiler build, and the vanilla-height policy. A changed input, unavailable registry, failed startup recovery, or changed/failed external datapack ingest or removal invalidates reuse and falls back to recovery, compilation, publication, and the existing restart gate; a verified no-change ingest or recovery check restores the prior pin. Object, structure, jigsaw, pool, and ownership edits do not affect generated dimension types or custom biomes and therefore do not force that fallback.
Ordinary Studio still resolves and teleports through its standard safe entry, may launch the pack workspace, prepares the complete mantle radius, and preserves native structures for generation previews. On Paper 26.2, WorldInit publishes the filtered native-structure placement state once but leaves it uninitialized while native starts, locates, and object-collision volume queries are gated; after the exact FULL entry-chunk request and retention ticket settle and the standard safe-entry teleport step succeeds when applicable, the global scheduler claims that exact level, chunk map, generator, and state, starts its placement initialization, registers the exact concentric-ring futures, enables collision-volume queries, and then lowers the structure gate. The ring searches finish in the background rather than delaying entry or extending Studio ready time, while normal close, full hotload, and complex hotload wait up to 120 seconds for their exact aggregate before mutating or sealing the engine; a synchronous partial-start failure permanently rejects those transitions for that engine because a complete drain cannot be proven.
Ordinary Studio still resolves and teleports through its standard safe entry, may launch the pack workspace, prepares the complete mantle radius, and preserves native structures for generation previews. On Paper 26.2, WorldInit publishes the filtered native-structure placement state once but leaves it uninitialized while native starts, locates, and object-collision volume queries are gated; injection verifies that Paper's canonical chunk-generator getter owns the new Iris generator before native structure state is published. After the exact FULL entry-chunk request and retention ticket settle and the standard safe-entry teleport step succeeds when applicable, the global scheduler claims that exact level, chunk map, generator, and state, starts its placement initialization, registers the exact concentric-ring futures, enables collision-volume queries, and then lowers the structure gate. The final Studio callback also returns to the server scheduler before applying game rules or committing a Jigsaw session. The ring searches finish in the background rather than delaying entry or extending Studio ready time, while normal close, full hotload, and complex hotload wait up to 120 seconds for their exact aggregate before mutating or sealing the engine; a synchronous partial-start failure permanently rejects those transitions for that engine because a complete drain cannot be proven.
Jigsaw Studio publishes an initialized empty native-structure state even when no managed datapack scope exists, never retains or activates the filtered full state, and keeps starts, references, locates, and native collision-volume queries disabled. Its dedicated open kind also skips the standard-entry teleport, workspace launch, procedural generation-cache warm, complete mantle-radius preparation, and ordinary pack-file hotloader before sending the owner once through the selected workcell destination. Jigsaw graph transactions directly invalidate, reload, evaluate, and rematerialize their owned resources; close and reopen Jigsaw Studio to apply unrelated external pack edits.
+18 -16
View File
@@ -30,7 +30,7 @@ Prerequisites: a Bukkit-family Iris server, a writable pack under the Iris `pack
The start pool selects Cross Junction at weight `1`; the pieces pool contains End Cap, Hallway, L Junction, T Junction, and Cross Junction; its direct fallback is the caps pool; and the caps pool contains End Cap plus an empty termination entry. Their resource keys remain `end`, `straight`, `corner`, `tee`, and `cross`. Every default piece is rotatable and has weight/chance `1` where it is a pool member. In the default Iris-compatible project, every piece belongs to theme `variant-1`, End Cap is terminal, mandatory caps are off, and unresolved optional branches fail the assembly. A vanilla-compatible project omits both Iris theme and terminal-rule metadata and terminates only the unresolved optional branch. Studio opens with the player in creative above Blank, and all six workcells have their default variant loaded.
Jigsaw Studio does not run the ordinary Studio entry teleport or open a VSCode workspace before entering the workcell. It reuses the startup-loaded datapack runtime only while the pinned compiler-input fingerprint still matches all dimension, biome, and snippet JSON plus the compiler build and height policy. Jigsaw's structure, pool, piece, object, and ownership writes do not alter those generated registries; a relevant input edit, unavailable registry, or changed/failed external datapack ingest or removal invalidates reuse and falls back to the normal recovery and installation check, while a verified no-change check restores the prior pin. Its dedicated synthetic generator skips the procedural generation-cache warm, complete mantle-radius preparation, and native structure-start generation, Paper requests the one entry chunk urgently and asynchronously, and the owner is teleported once through the Jigsaw destination path after that chunk is retained and ready.
Jigsaw Studio does not run the ordinary Studio entry teleport or open a VSCode workspace before entering the workcell. It reuses the startup-loaded datapack runtime only while the pinned compiler-input fingerprint still matches all dimension, biome, and snippet JSON plus the compiler build and height policy. Jigsaw's structure, pool, piece, object, and ownership writes do not alter those generated registries; a relevant input edit, unavailable registry, or changed/failed external datapack ingest or removal invalidates reuse and falls back to the normal recovery and installation check, while a verified no-change check restores the prior pin. Its dedicated synthetic generator skips the procedural generation-cache warm, complete mantle-radius preparation, and native structure-start generation, Paper requests the one entry chunk urgently and asynchronously, and the owner is teleported once through the Jigsaw destination path after that chunk is retained and ready. The transient world sets `spawn_mobs=false` and independently cancels natural creature-spawn events; explicitly summoned test entities remain available.
2. Inspect the compact Studio and its context displays:
@@ -41,13 +41,13 @@ Prerequisites: a Bukkit-family Iris server, a writable pack under the Iris `pack
Planar Studio has exactly six rotation-independent workcells in a three-column by two-row grid: Blank, End Cap, and Hallway on the first row; L Junction, T Junction, and Cross Junction on the second. Their stable IDs remain `blank`, `end`, `straight`, `corner`, `tee`, and `cross`. Neighboring capacity columns and rows retain at least one clear block even when their sizes differ. Each floor is light-gray wool, the canonical connector path is red wool, and every canonical face-center socket is capped with a sea lantern. There is no orientation, permutation, authored-piece, or derived-rotation gallery.
Aqua particles outline the workcell containing the player, dark gray outlines nearby valid workcells, and red identifies an invalid workcell. A focused connector is a 1.75-block direction line: lime for complete metadata with no Iris channel, red for incomplete identity metadata, or a deterministic channel color. `/iris jigsaw particles <true|false>` is player-local. The existing Iris scoreboard switches automatically to Jigsaw context and reports the structure, workcell, variant, and Loading, Saving, Disabled, Read-only, Invalid, Unsaved, or Saved state. `/iris studio scoreboard` toggles that sidebar for the current login.
Every workcell is enclosed by a physical white-concrete edge cage one block outside its editable capacity. Player-local particle trails outline focused and nearby editable bounds inside those cages and show each focused connector's 1.75-block direction line: lime for complete metadata with no Iris channel, red for incomplete identity metadata, or a deterministic channel color. Jigsaw Studio spawns no display entities for workcell bounds. `/iris jigsaw particles <true|false>` controls the workcell, connector, and temporary assembly diagnostics. The existing Iris scoreboard switches automatically to Jigsaw context and reports the structure, workcell, variant, and Loading, Saving, Disabled, Read-only, Invalid, Unsaved, or Saved state. `/iris studio scoreboard` toggles that sidebar for the current login.
3. Right-click the generated control chest with the main hand, run `/iris jigsaw menu`, or start three sneaks within 1.5 seconds. The six-row GUI shows the six workcells and pages only the variants belonging to the selected rotational archetype. Entering a workcell selects it for the owner's next menu open; left-clicking a workcell selects it, closes the menu, and teleports the owner to its horizontal center. Left-click **Hallway**, then click **New Blank Variant**. Iris clones the active owned piece's complete metadata and every exact pool-entry membership into a service-named owned piece, creates an empty object with the source object's dimensions, closes the GUI while the graph transaction runs, and loads the new variant into Hallway. Reopen the menu after the completion message.
New keys are deterministic, such as `village/demo/variants/straight/variant-1`. **Rename This Variant** and **Rename This Workcell** use an anvil text input; labels are author-facing only and do not change piece keys, stable workcell IDs, or solver archetypes. **Duplicate This Cell's Variant** clones the same complete piece metadata and every exact membership while copying the source object's bytes and its author-facing label. An End Cap clone therefore keeps both its pieces- and caps-pool entries, and a Cross Junction clone keeps both its start- and pieces-pool entries, including each entry's weight, chance, and other fields. Neither action guesses a first or lexicographically sorted owned pool. Both require an active owned variant with at least one owned membership; for an empty or unassigned workcell, use `/iris jigsaw piece create <poolKey> <pieceKey>` to select the pool explicitly. A non-owned variant cannot be duplicated or mutated.
4. Enter Hallway and build only inside its aqua particle bounds. The workcell displays the active object's real blocks with connector blocks hidden by default. **Workcell Settings** toggles real `minecraft:jigsaw` overlays when marker editing is needed. If a shown connector is broken, **Reset Connector Blocks** restores all saved connector coordinates without touching other edited blocks. An existing planar piece authored in another direction is rotated automatically into canonical orientation; block states, connectors, positions, and final states rotate with it. Capture applies the inverse rotation so the source resources stay coherent.
4. Enter Hallway and build inside its white-concrete cage and particle bounds. The workcell displays the active object's real blocks with connector blocks hidden by default. **Workcell Settings** toggles real `minecraft:jigsaw` overlays when marker editing is needed. If a shown connector is broken, **Reset Connector Blocks** restores all saved connector coordinates without touching other edited blocks. An existing planar piece authored in another direction is rotated automatically into canonical orientation; block states, connectors, positions, and final states rotate with it. Capture applies the inverse rotation so the source resources stay coherent.
5. Configure each `minecraft:jigsaw` marker through Mojang's block UI. For a newly generated Hallway variant, the north and south markers already use:
@@ -72,9 +72,9 @@ Prerequisites: a Bukkit-family Iris server, a writable pack under the Iris `pack
With connector blocks visible, autosave preserves the authored connector order for every marker that remains at the same source-local position, including markers whose metadata or orientation changed there. Removed markers disappear; new or moved markers append in deterministic X/Y/Z order. With connector blocks hidden, connector identity and order remain fixed while the exact ordinary block state and tile NBT placed at that coordinate are captured into the object and become that connector's final state. Duplicate source or captured positions reject the save.
`status` reports whether an autosave is pending. `/iris jigsaw save` and the GUI's **Flush Autosave Now** action request an immediate flush; if the final marker snapshot, another operation, or scheduler availability prevents capture from starting, the same pending ticket is retained and retried. Each changed autosave retains the previous complete owned closure in one per-project history file; content blobs are deduplicated and only the newest five iterations remain. **Undo Last Autosave** restores and removes the newest retained iteration through the atomic writer, so it can be clicked repeatedly to rewind up to five saves. Persistent validation or atomic-writer failures leave that mutation dirty, emit one console report with request, structure, workcell, and piece context, and retry after 2, 4, 8, 16, then at most every 30 seconds. A later edit resets that failure state; a manual flush attempts immediately without discarding it. Pending tickets resolve their workcell by stable ID after every committed graph reload, so one workcell save cannot strand sibling autosaves on replaced layout objects. Neither manual action is required in the normal loop. Fresh untouched workcells report **Autosaved**, not pending. Capture reads the active owned variant across its exact displayed dimensions, converts jigsaw blocks into connector metadata, writes each connector's `final_state` into the object cell, replaces only the piece JSON connector array so omitted defaults and extension fields remain intact, compiles the complete owned graph, then commits the JSON, `.iob`, and manifest together. The Jigsaw service directly invalidates, reloads, evaluates, and rematerializes these graph resources without running ordinary Studio's full-engine pack hotloader. If an object crosses chunks, Iris snapshots every intersection on that chunk's owning region and begins the write only after the complete capture validates. A failed or incomplete capture writes nothing.
`status` reports whether an autosave is pending. `/iris jigsaw save` and the GUI's **Flush Autosave Now** action request an immediate flush; if the final marker snapshot, another operation, or scheduler availability prevents capture from starting, the same pending ticket is retained and retried. Every successful atomic workcell save plays one short bell for the owning player. Each changed autosave retains the previous complete owned closure in one per-project history file; content blobs are deduplicated and only the newest five iterations remain. **Undo Last Autosave** restores and removes the newest retained iteration through the atomic writer, so it can be clicked repeatedly to rewind up to five saves. Persistent validation or atomic-writer failures leave that mutation dirty, emit one console report with request, structure, workcell, and piece context, and retry after 2, 4, 8, 16, then at most every 30 seconds. A planar connector-topology mismatch is expected authoring validation: it names the required and edited shape without a stack trace and directs the author to **Reset Connector Blocks**. A later edit resets that failure state; a manual flush attempts immediately without discarding it. Pending tickets resolve their workcell by stable ID after every committed graph reload, so one workcell save cannot strand sibling autosaves on replaced layout objects. Neither manual action is required in the normal loop. Fresh untouched workcells report **Autosaved**, not pending. Capture reads the active owned variant across its exact displayed dimensions, converts jigsaw blocks into connector metadata, writes each connector's `final_state` into the object cell, replaces only the piece JSON connector array so omitted defaults and extension fields remain intact, compiles the complete owned graph, then commits the JSON, `.iob`, and manifest together. The Jigsaw service directly invalidates, reloads, evaluates, and rematerializes these graph resources without running ordinary Studio's full-engine pack hotloader. If an object crosses chunks, Iris snapshots every intersection on that chunk's owning region and begins the write only after the complete capture validates. A failed or incomplete capture writes nothing.
7. Inspect the session-persistent preview. Every committed mutation triggers a background compile and seed-`1337` assembly. The menu reports `PENDING`, `VALID`, `WARNING`, `INVALID`, or `STALE`, plus selected theme, piece count, and the current diagnostic. Iris renders the assembled blocks on the negative-X side of the workcells, keeps them until replacement or Studio close, and updates that read-only area after each later commit. Click **Go to Preview** or run `/iris jigsaw preview goto` to teleport above it. The preview bounds are protected from players, fluids, pistons, explosions, growth, fire, entities, and redstone. The renderer accepts at most 250,000 explicit blocks; a larger assembly becomes `INVALID` with the render-limit diagnostic and is not rendered.
7. Inspect the session-persistent preview. Every committed mutation triggers a background compile and seed-`1337` assembly. The menu reports `PENDING`, `VALID`, `WARNING`, `INVALID`, or `STALE`, plus selected theme, piece count, and the current diagnostic. Iris renders the assembled blocks on the negative-X side of the workcells, keeps them until replacement or Studio close, and updates that read-only area after each later commit. Planar previews sit on the editing floor; spatial previews are lifted 48 blocks above it so their connected three-dimensional piece blob remains visually separate from the one-row editor. Click **Go to Preview** or run `/iris jigsaw preview goto` to teleport above it. The preview bounds are protected from players, fluids, pistons, explosions, growth, fire, entities, and redstone. The renderer accepts at most 250,000 explicit blocks; a larger assembly becomes `INVALID` with the render-limit diagnostic and is not rendered.
For an additional arbitrary-seed diagnostic, use:
@@ -88,14 +88,14 @@ Prerequisites: a Bukkit-family Iris server, a writable pack under the Iris `pack
- Each exact pool membership has a positive relative weight and an independent `0%..100%` eligibility chance. GUI chance adjustments use five-percentage-point steps. Chance is tested before weighted selection.
- **Themes & Piece Rules** sets a loaded owned variant's theme membership, allowed depth `0..30`, required/maximum placement count `0..512` (`0` maximum means unbounded), and terminal role.
- **Duplicate All Enabled Cells as Family** allocates the next `variant-<n>` family, clones the currently loaded owned variant from every enabled workcell, duplicates their pool memberships, and atomically loads and assigns all clones to that one new theme. The operation either commits and rebinds the complete family or changes nothing. The structure selects one theme per assembly by positive theme weight, so `variant-1` pieces do not mix with `variant-2` pieces unless a piece belongs to both or has an empty theme list.
- **Duplicate All Enabled Cells as Family** allocates the next `variant-<n>` family, clones the currently loaded owned variant from every enabled workcell, duplicates their pool memberships, and atomically loads and assigns all clones to that one new theme. The operation either commits and rebinds the complete family or changes nothing. The structure selects exactly one theme per assembly by positive theme weight, so an assembly can be entirely `variant-1` or entirely `variant-2`; pieces do not mix unless a piece belongs to both or has an empty theme list. **Structure Themes & Caps** shows each family's current whole-assembly percentage and adjusts its relative weight. Pool membership chance remains independent and is rolled per candidate after family selection.
- **Mandatory Caps** requires every unresolved connector pool to use its direct fallback and place a compatible piece marked terminal. The default End piece is terminal and the default pieces pool points directly to the caps pool, so a new Iris-compatible project can enable this rule without first editing End.
Piece themes, non-default chance, piece rules, and mandatory caps are Iris-only metadata. A graph using them is not `VANILLA_PORTABLE`.
9. Resize or disable workcells as needed. Open **Workcell Settings** and adjust capacity width, height, or depth by 1 or 8. Iris regenerates moved cages and their active variants in place, keeps one clear block between capacity rows and columns, rehydrates tile data, and moves the owner with the selected workcell; reopening is only the recovery path if that live regeneration fails. Every planar workcell persists its own capacity; changing it never rewrites a variant object. A capacity cannot shrink below any variant already assigned to that cell. Open **Variant Size** to give the selected owned variant its own exact width, height, and depth within that capacity. Growth adds air; a safe shrink preserves in-bounds blocks and moves canonical connector payloads and sockets to the new face centers, while cropping or collision rejects the transaction without writes. **Resize This Variant to Capacity** is the one-click exact-size shortcut. A loaded resized variant reloads in place after commit; sibling variants keep their independent dimensions and bytes.
9. Resize or disable workcells as needed. Open **Workcell Settings** and stage capacity width, height, or depth by 1 or 8 without closing the menu. Click **Apply Cell Size** after all three values are ready; Iris then performs one live regeneration of moved white-concrete cages and active variants, keeps one clear block between capacity rows and columns or spatial cells, rehydrates tile data, and moves the owner with the selected workcell. **Discard Size Changes** cancels the staged menu values without writing. Reopening is only the recovery path if live regeneration fails. Every planar workcell persists its own capacity; changing it never rewrites a variant object. A capacity cannot shrink below any variant already assigned to that cell. Open **Variant Size** to give the selected owned variant its own exact width, height, and depth within that capacity. Growth adds air; a safe shrink preserves in-bounds blocks and moves canonical connector payloads and sockets to the new face centers, while cropping or collision rejects the transaction without writes. **Resize This Variant to Capacity** is the one-click exact-size shortcut. A loaded resized variant reloads in place after commit; sibling variants keep their independent dimensions and bytes.
Disabling a planar workcell removes all pieces of that archetype from assembly and vanilla export but preserves its size and variants for later editing. A red stained-glass block display fills the disabled bounds; Iris removes its tracked display when the origin chunk unloads and recreates it after that chunk loads again. Re-enable the workcell from the same settings page to restore participation.
Disabling a planar workcell removes all pieces of that archetype from assembly and vanilla export but preserves its size and variants for later editing. Its translucent cuboid changes from light blue to red; Iris removes the tracked display when the origin chunk unloads and recreates it after that chunk loads again. Re-enable the workcell from the same settings page to restore participation.
10. Use the **Toolbox** page when repeated actions should be available without reopening the chest. Clicking an entry gives a named stick bound to the current Studio request and its workcell, variant, pool entry, or action. Right-click to use it. Resize, themes, and rules sticks open the relevant GUI context; other sticks run their exact bound action. A stick from a closed or replaced Studio is rejected. Destructive sticks require two right-clicks within 10 seconds.
@@ -167,7 +167,9 @@ Spatial projects use the same lifecycle without planar cell constraints:
/iris jigsaw create overworld stronghold/demo mode=spatial width=32 height=24 depth=32
```
Spatial Studio has one `workcell/spatial` cell and no topology glyph. Right-click the control chest or triple-sneak to load the start variant, create another service-named variant, or duplicate the active owned variant. Build the room inside that one workcell, place Mojang jigsaw blocks at doorways, stairs, shafts, floors, or ceilings, and configure their name, target, pool, joint, final state, and priorities. Spatial connectors may use all 12 front/top orientations supported by the jigsaw block. Studio sizes the shared capacity to contain every reachable object and the horizontal footprint of its cardinal rotations, but automatic capture and per-variant resize preserve each variant's independent exact dimensions. Use **Resize to Capacity** or `/iris jigsaw piece expand` when only the selected object should become the full workcell size. Spatial workcell and variant labels are author metadata only; `cellSize` and labels do not constrain runtime assembly.
New spatial projects begin with seven owned 15×15×15 variants displayed left-to-right in one horizontal row: **0 Connectors**, then **1 Connector** through **6 Connectors**. The connector sequence is cumulative north, south, east, west, up, and down, so each adjacent cell adds exactly one face-center socket. The first cell uses `workcell/spatial`; later cells use `workcell/spatial/<piece-key>`. Every cell is one clear block from the next, and adding, deleting, or resizing variants regenerates the live row without reopening Studio. The start pool contains all seven variants. The generated pieces pool contains variants 1 through 6 plus an explicit empty terminator; the connectorless editing piece is excluded because it cannot be reached as a child. Each generated piece defaults to at most 16 placements, so seed `1337` renders a bounded connected blob in the elevated spatial preview instead of one isolated piece.
Right-click the control chest or triple-sneak to select a cell, create another service-named variant, or duplicate the active owned variant. Build inside that variant's dedicated cell and configure its doorway, stair, shaft, floor, or ceiling connectors as needed. Connector blocks are hidden by default, so ordinary blocks and block-entity data remain directly editable at the socket; **Workcell Settings** can show or reset the saved jigsaw blocks. Spatial connectors may use all 12 front/top orientations supported by the jigsaw block. Studio sizes the shared capacity to contain every reachable object and the horizontal footprint of its cardinal rotations, but automatic capture and per-variant resize preserve each variant's independent exact dimensions. Use **Resize to Capacity** or `/iris jigsaw piece expand` when only the selected object should become the full workcell size. Spatial workcell and variant labels are author metadata only; `cellSize` and labels do not constrain runtime assembly.
Use `ROLLABLE` when candidate top direction should not constrain the join and `ALIGNED` when it must match the source top after rotation. Iris still tries only cardinal Y rotations. A piece with `rotatable: false` is tried only at its authored rotation. The control-chest details view toggles this property; Studio does not render separate rotation cells. Vanilla-portable variants must remain rotatable, so their GUI toggle is disabled once rotation is enabled.
@@ -183,7 +185,7 @@ Create additional owned pools before targeting them from new spatial markers or
## Studio workcells and canonical planar display
The surrounding platform uses a four-block checker pattern, and smooth-quartz cages plus particles identify each workcell's editable volume. The first workcell origin is `(16, 65, 16)`; every workcell's bounds begin at Y 65, one block above its floor, and that origin is the displayed object's lowest unsigned corner. Planar projects use six cells in this exact three-by-two order. Each column uses the widest workcell in that column, each row uses the deepest workcell in that row, and adjacent column and row envelopes retain one clear block. A smaller workcell can have additional open space beside it because its row and column remain aligned to the largest workcell in that envelope:
The surrounding platform uses a four-block checker pattern. Every complete workcell capacity is surrounded by a physical white-concrete edge cage one block outside the editable volume; enabled and disabled planar cells use the same material, while their participation state remains visible in the GUI and scoreboard. Player-local particle trails outline focused and nearby editable bounds inside those cages, connector direction lines, the permanent live-preview bounds, and the explicit temporary arbitrary-seed diagnostic. No workcell-bound display entity is created. The first workcell origin is `(16, 65, 16)`; every workcell's bounds begin at Y 65, one block above its floor, and that origin is the displayed object's lowest unsigned corner. Planar projects use six cells in this exact three-by-two order. Each column uses the widest workcell in that column, each row uses the deepest workcell in that row, and adjacent column and row envelopes retain one clear block. A smaller workcell can have additional open space beside it because its row and column remain aligned to the largest workcell in that envelope:
| Row | Workcell | Stable ID | Canonical open sides |
|---|---|---|---|
@@ -194,7 +196,7 @@ The surrounding platform uses a four-block checker pattern, and smooth-quartz ca
| 2 | T Junction | `workcell/tee` | north, east, and west |
| 2 | Cross Junction | `workcell/cross` | north, east, south, and west |
Every planar footprint at Y 64 is light-gray wool. A one-block-wide red-wool glyph runs from its center toward each canonical side, and the endpoint on that workcell face is a sea lantern. The Blank workcell has no red path or connector cap. A disabled workcell retains this floor but gains one non-persistent red stained-glass block display scaled across its full editable volume; it remains selectable and editable but contributes no pieces to assembly or export. The renderer detaches the display when its origin chunk unloads and recreates it when that chunk loads again. Spatial Studio uses the single ID `workcell/spatial` and has no topology glyph or enable toggle.
Every planar footprint at Y 64 is light-gray wool. A one-block-wide red-wool glyph runs from its center toward each canonical side, and the endpoint on that workcell face is a sea lantern. The Blank workcell has no red path or connector cap. A disabled workcell retains this floor while its existing translucent cuboid turns red; it remains selectable and editable but contributes no pieces to assembly or export. Spatial Studio lays every variant out as a dedicated cell in one row, has no topology glyph or enable toggle, and retains `workcell/spatial` for its first cell.
The GUI groups every planar piece by rotational topology kind. For example, west, east, south, and north end pieces are variants of the one End Cap workcell; east-west and north-south pieces are variants of Hallway. When a variant is loaded, its source orientation is rotated clockwise into the archetype's canonical display, including directional block states, connector orientation and position, and connector final state. Capture applies the inverse rotation before writing the original piece and object resources. Pool memberships, weights, dimensions, labels, and the separate underlying piece resources are not merged by this display compaction.
@@ -266,11 +268,11 @@ The create/open `<key>` is the root structure's internal lowercase resource path
| `menu` | Open the same workcell/variant/rules/toolbox GUI as the generated control chest or triple-sneak gesture |
| `select` | Select the workcell containing the player |
| `goto <workcell>` | Select and teleport above a stable workcell ID; alias `teleport` |
| `particles <visible>` | Toggle player-local bounds and connector particles |
| `particles <visible>` | Toggle player-local workcell-bound, connector, live-preview, and temporary assembly-preview particle trails |
| `save [bay=selected]` | Flush automatic capture now for one dirty ready workcell; ordinary block and container changes already schedule this operation |
| `connector channel <channel\|none>` | Look at a saved marker in the active owned workcell within 8 blocks and set/clear its Iris-only channel at the inverse-mapped source position; reopen to refresh the workcell and particles |
| `bounds <width> <height> <depth>` | Set the selected workcell capacity without rewriting any variant object; all variants must fit, and the live aligned layout regenerates and rehydrates without close/reopen; aliases `cell`, `resize` |
| `workcell capacity <width> <height> <depth>` | Explicit nested form of `bounds`; planar capacity belongs to one canonical archetype and spatial capacity is the single project envelope |
| `workcell capacity <width> <height> <depth>` | Explicit nested form of `bounds`; planar capacity belongs to one canonical archetype and spatial capacity is the shared envelope for its one-row variant cells |
| `workcell label <displayName>` | Set the selected planar or spatial workcell's author-facing label; quote spaces; canonical solver identity remains unchanged |
| `workcell label-reset` | Reset the selected workcell to its canonical solver label; alias `reset-label` |
| `pool create <poolKey> [fallbackPoolKey=none]` | Create a new empty owned pool; a non-`none` fallback must already be owned by this project |
@@ -578,12 +580,12 @@ Run this in a purpose-named disposable pack/world and record each gate separatel
3. **Workcell layout:** verify Blank/End Cap/Hallway then L Junction/T Junction/Cross Junction, one clear block between capacity rows and columns, light-gray floors, red canonical glyphs, sea-lantern endpoints, and no orientation/permutation gallery.
4. **Controls and context:** confirm every untouched workcell starts **Autosaved**. Walk outside and into End Cap; verify the Iris scoreboard context and `Triple-sneak for controls`, then open the menu and confirm End Cap is selected. Rename its workcell and active variant sticks in an anvil, apply them, verify the scoreboard shows the author names plus canonical role, then reset both labels.
5. **Autosave:** change a solid block, a marker field, and container contents. Immediately click **Duplicate This Cell's Variant**; confirm autosave is expedited and the duplicate runs once automatically without a wait/retry instruction. Repeat with edits in multiple enabled cells and **Duplicate All Enabled Cells as Family**. Wait for the final clean state, reopen Studio, and verify all authored changes plus both clone operations round-trip.
6. **Capacity and independent sizes:** make Hallway capacity `16×3×3` and another workcell capacity `16×8×16`; confirm no existing object byte changes and the live relayout moves only the cages without close/reopen. In the larger workcell, resize one variant to `16×3×16` and another to `3×3×3`; confirm exact independent dimensions, live reload of the loaded variant, and unchanged siblings. Confirm cropped authored content, connector collision, and shared/read-only objects each reject the single-variant resize without writes.
7. **Disable:** disable Tee, confirm a full red stained-glass display fills that workcell, and confirm seed-`1337` evaluation excludes Tee pieces. Re-enable it and confirm participation returns; test export filtering separately on the portable fixture.
6. **Capacity and independent sizes:** stage Hallway capacity `16×3×3` in the open Workcell Settings menu, apply it once, and make another workcell capacity `16×8×16`; confirm no existing object byte changes and the live relayout moves only the white-concrete cages without close/reopen. In the larger workcell, resize one variant to `16×3×16` and another to `3×3×3`; confirm exact independent dimensions, live reload of the loaded variant, and unchanged siblings. Confirm cropped authored content, connector collision, and shared/read-only objects each reject the single-variant resize without writes.
7. **Disable:** disable Tee, confirm its white-concrete cage remains while the GUI and scoreboard report Disabled, and confirm seed-`1337` evaluation excludes Tee pieces. Re-enable it and confirm participation returns; test export filtering separately on the portable fixture.
8. **Dynamic preview:** confirm evaluation moves through pending/stale to valid or an understood warning, reports theme/piece count, and renders the same protected block assembly on the negative-X side after reopen. Reach it through both **Go to Preview** and `/iris jigsaw preview goto`; verify edits, fluids, pistons, explosions, growth, fire, entities, and redstone cannot alter it.
9. **Variants and rules:** create a blank variant and duplicate one active variant; adjust one exact weight and chance; create `variant-2` through the all-enabled family action and confirm one exact-size clone per enabled workcell, duplicated memberships, and atomic active-family rebind. Change theme membership, depth/count rules, terminal status, and mandatory caps. Confirm only selected resources change and invalid rules fail atomically.
10. **Toolbox:** take schema-`2` named sticks for selection, capacity, per-variant size, labels, duplicate-one/family, preview, Flush Autosave, themes/rules, membership changes, caps, and deletion. Confirm bindings target the named context, active/valid icons are jigsaw/emerald, lime dye only labels theme membership, destructive tools require two uses, and schema-`1` or replaced-Studio tools are rejected.
11. **Deletion:** delete one owned inactive variant only after another remains. Add an external placement/reference and confirm project deletion is blocked; remove it, confirm deletion, and verify the complete owned closure plus manifest are removed.
11. **Deletion:** delete one owned inactive planar variant only after another remains; a spatial variant removes its dedicated active cell as long as another spatial variant remains. Add an external placement/reference and confirm project deletion is blocked; remove it, confirm deletion, and verify the complete owned closure plus manifest are removed.
12. **Ownership protection:** have a second player attempt a direct edit, chest use, `/setblock`, `/fill`, and WorldEdit-style mutation. Confirm each is denied across the active Studio world and the owner remains able to edit.
13. **Adoption:** apply an exclusive unowned graph in place without changing resource bytes; require a clone for a shared graph; reject a stale plan without writes; and clone a managed datapack import without changing the managed source.
14. **Registered conversion:** convert one registered jigsaw to an unused target, review fidelity warnings/provenance, and open the owned target. A non-jigsaw source and occupied target must fail without overwrite.
+3 -1
View File
@@ -293,7 +293,9 @@ Checksum-verified when Modrinth publishes a hash; size-capped.
Installed datapacks are real Minecraft datapacks at `<level root>/datapacks/<id>/`, each with `.iris-managed.json`. Unmanaged datapacks are never touched; id `iris` is reserved. Cache/staging/manifest under `plugins/Iris/datapacks/`.
Ingest runs shortly after plugin enable when `general.autoIngestDatapacks` is enabled (default true). Minecraft builds worldgen registries at server start, so a **newly installed** datapack is not registered on the boot that installed it — auto-ingest **restarts the server** when anything changed. After that restart, keys are live only in the per-world structure state of declaring Iris dimensions. A repair path reinstalls staged datapacks that went missing without re-downloading.
Ingest and recovery run synchronously in Iris's startup admission gate when `general.autoIngestDatapacks` is enabled (default true); players and every Iris world/Studio creation path remain locked until that phase is valid. A persisted manifest/configuration/content fingerprint lets an unchanged boot skip remote resolution and full revalidation, and Iris refreshes that fingerprint after its own authorized post-start import maintenance; URL, Minecraft/Iris version, override policy, external manifest edits, staging, transaction, installed content, or cache corruption still invalidates reuse and runs the full fail-closed path. Minecraft builds worldgen registries at server start, so a **newly installed or repaired** datapack requires a clean restart before admission; after it returns, keys are live only in the per-world structure state of declaring Iris dimensions.
Scratch validation rejects links, junction-like special files, and real cross-volume entries. On Windows/Java 25, Iris also verifies the drive root and volume serial when the JDK reports unequal `FileStore` identities only because a path crossed the legacy 247-character prefix boundary; unresolved cleanup, identity, transaction, or validation failures remain blocking and create no world artifacts.
### 2.3 Manual commands
+3 -1
View File
@@ -60,7 +60,9 @@ Default overworld repository constant: `IrisDimensions/overworld`.
| Command | Behavior |
|---------|----------|
| Bukkit: `/iris pack validate [pack=<key>]`; modded: `/iris pack validate [pack]` | Validate one pack or all visible packs; publish into `PackValidationRegistry` |
| Bukkit: `/iris pack status [pack=<key>]`; modded: `/iris pack status [pack]` | Show cached registry results (run validate first) |
| Bukkit: `/iris pack status [pack=<key>]`; modded: `/iris pack status [pack]` | Show the startup-published registry result, including a reused persisted result; run validate to refresh after edits |
Bukkit persists successful and failed startup validation results and reuses them only when the exact visible pack set, pack-content fingerprint, validator schema, strict-content mode, platform/Minecraft/Iris context, and relevant live registries still match. Cached failures remain blocking; changed bytes, context, registry keys, missing/extra packs, malformed cache state, or a manual validation refresh prevents stale success from authorizing world or Studio creation.
### Checks performed (`PackValidator`)
+9 -7
View File
@@ -60,8 +60,9 @@ GoldenHash details and file layout: `32 - Determinism & Goldenhash.md`.
Or a single pack: `/iris pack validate pack=<pack>` on Bukkit, `/iris pack validate <pack>` on modded.
2. Review blocking errors vs warnings. Blocking errors must be fixed before treating the pack as production-ready.
3. Optional: `/iris pack status` replays the last recorded validation result for the session.
4. Gate: target pack is loadable; no unexpected blocking errors on the shipping default pack. Cleanup/restore flows are separate and opt-in (`25 - Pack Management.md`).
3. `/iris pack status` replays the startup-published result, including a persisted result reused for unchanged content.
4. Restart without changing packs or registry context. Gate: startup logs persisted validation reuse instead of full parsing, player admission opens only after datapack and pack phases are ready, and the target remains loadable.
5. Change one pack byte and restart. Gate: the content fingerprint invalidates reuse and validation runs again; restore the pack before continuing. Cleanup/restore flows are separate and opt-in (`25 - Pack Management.md`).
## D. Bukkit datapack dimension-scope smoke
@@ -71,6 +72,7 @@ Use a disposable server with one managed datapack source, a vanilla world, one I
2. Generate new chunks in all three worlds; do not use existing chunks as proof because scope changes do not rewrite them.
3. Gate: locate and natural generation retain the managed structure in the declaring Iris world, while the vanilla and nondeclaring Iris worlds neither locate nor generate it.
4. Restart without removing the installed datapack and repeat locate plus new-chunk generation. Gate: the same per-world result remains and no ownership or structure-state failure appears during world initialization.
5. Break one required dimension/native-structure reference, restart, and attempt create/load for that pack. Gate: validation is blocking before admission completes for the pack, create/load reports the failure before datapack preparation, and no dimension folder, pack snapshot, `bukkit.yml` registration, registry entry, or loaded world appears. Restore and revalidate the pack before the next smoke.
## E. Pregeneration control smoke
@@ -169,7 +171,7 @@ Use a disposable pack/structure key and the owning builder account. Bukkit has o
/iris jigsaw status
```
Gate: the add-only transaction owns one structure, three pools, six pieces, six objects, and one manifest before Studio opens. The player enters creative above Blank. `status` reports `PLANAR_JIGSAW`, `IRIS_EXTENDED`, six workcells, 15×15×15 for the selected workcell, six variants, no pending autosave, and the seed-`1337` evaluation. The key tab-completes for `open`, `edit`, and `reopen`; the GUI and owned resources show one loaded variant per archetype, theme `variant-1`, terminal End, and mandatory caps off.
Gate: the add-only transaction owns one structure, three pools, six pieces, six objects, and one manifest before Studio opens. On Paper, require native-structure scope to report success and reject any active-generator ownership or asynchronous game-rule event error. The player enters creative above Blank. `status` reports `PLANAR_JIGSAW`, `IRIS_EXTENDED`, six workcells, 15×15×15 for the selected workcell, six variants, no pending autosave, and the seed-`1337` evaluation. The key tab-completes for `open`, `edit`, and `reopen`; the GUI and owned resources show one loaded variant per archetype, theme `variant-1`, terminal End, and mandatory caps off.
2. Inspect the exact Blank, End Cap, Hallway, L Junction, T Junction, Cross Junction layout. Floors are light-gray wool, topology paths are red wool, and canonical endpoints are sea lanterns. There are no orientation, permutation, piece, or derived-rotation cells. Toggle player-local particles:
@@ -181,7 +183,7 @@ Use a disposable pack/structure key and the owning builder account. Bukkit has o
/iris jigsaw particles true
```
Gate: the occupied valid cell is aqua, nearby valid bounds are dark gray, and an invalid cell is red. Focused connectors draw 1.75-block direction lines. The Iris scoreboard replaces the general Studio context with Structure, Workcell, Variant, State, and `Triple-sneak for controls`, without orientation/mask fields. All six untouched cells initially report **Autosaved**. Enter End Cap, triple-sneak, and confirm the menu selects End Cap rather than the previously selected cell.
Gate: every cell has one physical white-concrete edge cage, no workcell-bound display entity exists, and focused plus nearby particle trails outline the editable bounds inside those cages. Focused connectors draw 1.75-block direction lines when particles are enabled. The Iris scoreboard replaces the general Studio context with Structure, Workcell, Variant, State, and `Triple-sneak for controls`, without orientation/mask fields. All six untouched cells initially report **Autosaved**. Enter End Cap, triple-sneak, and confirm the menu selects End Cap rather than the previously selected cell.
3. Open the same six-row controls three ways: right-click the protected chest, run `/iris jigsaw menu`, and start three sneaks within 1.5 seconds. Select Hallway and click **New Blank Variant**. Wait for its atomic graph result and load, then reopen the controls. Rename the loaded variant and Hallway workcell through their anvil inputs; confirm labels round-trip while the piece key, `straight` stable ID, and solver role stay unchanged. Load End Cap and use **Duplicate This Cell's Variant**, then load Cross Junction and duplicate it as well.
@@ -193,17 +195,17 @@ Use a disposable pack/structure key and the owning builder account. Bukkit has o
/iris jigsaw status
```
Gates: the command and close attempt request a final owning-region marker snapshot; close waits behind marker finalization and autosave instead of losing the last UI change. State moves through dirty/saving to clean automatically; the inventory and machine changes also mark it dirty; one complete multi-resource commit occurs; and no partial resource appears. Make six distinct saved block edits, then click **Undo Last Autosave** five times. Confirm each prior block state and manifest hash returns in reverse order, the sixth-oldest state is no longer available, one `.iris/jigsaw-history/key-<sha256>.json` file held the stack, and no transaction debris remains. Make another edit while capture is pending, immediately click **Duplicate This Cell's Variant**, and confirm Iris expedites autosave then performs that one duplicate exactly once without a wait/retry instruction. Repeat with dirty edits in multiple enabled cells and **Duplicate All Enabled Cells as Family**. Invoke **Flush Autosave Now** while capture cannot start and confirm the same ticket remains pending, retries, and eventually becomes clean. Close/reopen, load the variant, and confirm block, marker NBT, inventory, explicit-air final state when used, and `structure_void` absence round-trip. **Flush Autosave Now** and `/iris jigsaw save` are not required.
Gates: the command and close attempt request a final owning-region marker snapshot; close waits behind marker finalization and autosave instead of losing the last UI change. State moves through dirty/saving to clean automatically; the inventory and machine changes also mark it dirty; one complete multi-resource commit occurs, one owner-local bell sounds, and no partial resource appears. Make six distinct saved block edits, then click **Undo Last Autosave** five times. Confirm each prior block state and manifest hash returns in reverse order, the sixth-oldest state is no longer available, one `.iris/jigsaw-history/key-<sha256>.json` file held the stack, and no transaction debris remains. Make another edit while capture is pending, immediately click **Duplicate This Cell's Variant**, and confirm Iris expedites autosave then performs that one duplicate exactly once without a wait/retry instruction. Repeat with dirty edits in multiple enabled cells and **Duplicate All Enabled Cells as Family**. Invoke **Flush Autosave Now** while capture cannot start and confirm the same ticket remains pending, retries, and eventually becomes clean. Close/reopen, load the variant, and confirm block, marker NBT, inventory, explicit-air final state when used, and `structure_void` absence round-trip. **Flush Autosave Now** and `/iris jigsaw save` are not required.
On Paper, repeat one dirty edit immediately before plugin disable and confirm the synchronous final drain persists it. On Folia, verify an enabled-world unload or unregister remains deferred and retries until autosave finishes. Record the forced-disable boundary separately: once Folia has disabled the plugin it rejects new region tasks, so a new final cross-region capture cannot be guaranteed. Close Studio or wait for `status` to report no pending autosave before reload or server shutdown.
5. Change Hallway's capacity to 16×3×3 from **Workcell Settings** or `/iris jigsaw bounds 16 3 3`. Gate: only structure capacity metadata changes; every Hallway variant keeps its object bytes and exact dimensions. A capacity shrink below any assigned variant is rejected atomically. Resize one loaded Hallway variant to 16×3×3 from **Variant Size** or `/iris jigsaw variant resize 16 3 3`; confirm only that object changes and reloads in place, its canonical connector payloads and sockets move to `(8,1,0)` and `(8,1,2)`, and sibling Hallway variants keep their prior dimensions and bytes. Resize a second Hallway variant to 3×3×3 after raising capacity if required, proving variants in one cell can differ. Before one shrink, persist a block outside the target; confirm the resize is rejected without an owned-file change, then remove it and retry. Also confirm a shared or read-only object is rejected. **Resize This Variant to Capacity** affects only the selected variant.
5. Stage Hallway's capacity as 16×3×3 from **Workcell Settings**, verify the menu remains open during every axis click, then click **Apply Cell Size** once; also test `/iris jigsaw bounds 16 3 3`. Gate: only structure capacity metadata changes; one live relayout runs after Apply, and every Hallway variant keeps its object bytes and exact dimensions. A capacity shrink below any assigned variant is rejected atomically. Resize one loaded Hallway variant to 16×3×3 from **Variant Size** or `/iris jigsaw variant resize 16 3 3`; confirm only that object changes and reloads in place, its canonical connector payloads and sockets move to `(8,1,0)` and `(8,1,2)`, and sibling Hallway variants keep their prior dimensions and bytes. Resize a second Hallway variant to 3×3×3 after raising capacity if required, proving variants in one cell can differ. Before one shrink, persist a block outside the target; confirm the resize is rejected without an owned-file change, then remove it and retry. Also confirm a shared or read-only object is rejected. **Resize This Variant to Capacity** affects only the selected variant.
6. Open the loaded variant's details. Change one exact pool entry's weight and chance, use **Duplicate This Cell's Variant**, toggle rotation, and use the two-click unlink confirmation. Gate: only that entry changes; chance moves in five-percentage-point steps; the duplicate has a new key, copied label, and independent object; and every stale callback is rejected by request ID.
7. Use **Duplicate All Enabled Cells as Family** to create `variant-2`. Gate: one owned clone is created from the active variant of every enabled workcell, matching pool memberships, labels, and independent object dimensions are duplicated, every clone is atomically loaded and assigned to `variant-2`, and a failure leaves both files and all live bindings unchanged. Seed `1337` selects one complete weighted theme without mixing families. Change a loaded piece's depth/count/terminal rules, theme membership, theme weight, and mandatory caps. Invalid combinations must fail atomically and appear in the automatic evaluation without a manual validation command.
8. Disable Tee. Gate: a red stained-glass display fills its full bounds, the workcell remains editable, and Tee pieces disappear from assembly. Unload and reload the display's origin chunk and confirm the full-volume red display returns once without a stale duplicate. The permanent seed-`1337` preview on the negative-X side updates in place and is protected from players, fluids, pistons, explosions, growth, fire, entities, and redstone; the GUI, scoreboard, or `status` shows its selected theme and piece count. Reach it through both **Go to Preview** and `/iris jigsaw preview goto`. Re-enable Tee and confirm participation returns.
8. Disable Tee. Gate: its white-concrete cage remains, the GUI and scoreboard report Disabled, the workcell remains editable, and Tee pieces disappear from assembly. The permanent seed-`1337` preview on the negative-X side updates in place and is protected from players, fluids, pistons, explosions, growth, fire, entities, and redstone; the GUI, scoreboard, or `status` shows its selected theme and piece count. Reach it through both **Go to Preview** and `/iris jigsaw preview goto`. Re-enable Tee and confirm participation returns.
9. Open **Toolbox** and take schema-`2` named sticks, including selection, capacity, per-variant size, variant/workcell rename, duplicate-one/family, preview, membership, rules/themes, caps, variant deletion, and project deletion. Gate: right-click uses the exact bound context, rename sticks open an anvil and sneak-right-click resets the label, other context sticks open the matching GUI, destructive tools require a second use within 10 seconds, and schema-`1` or replaced-Studio sticks are rejected. Confirm the active variant uses a jigsaw icon, a valid evaluation uses emerald, minimum placements does not use dye, and lime dye appears only as an explicitly labeled theme-membership boolean.