O-o .o-o. o-O

This commit is contained in:
Brian Neumann-Fopiano
2026-07-12 20:53:51 -04:00
parent cd05fd1bca
commit 4dea984d77
30 changed files with 2363 additions and 176 deletions
+3 -1
View File
@@ -1,5 +1,6 @@
String apiVersion = providers.gradleProperty('minecraftVersion').get()
def mainClass = 'art.arcane.iris.Iris'
def bootstrapperClass = 'art.arcane.iris.IrisBootstrap'
String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate')
.orElse('com.github.VolmitSoftware:VolmLib:cdf7e8bb880199734dacd386a6616bda799fcde1')
@@ -29,9 +30,10 @@ tasks.named('processResources').configure {
version : rootProject.version,
apiVersion: apiVersion,
main : mainClass,
bootstrapper: bootstrapperClass,
]
inputs.properties(pluginProperties)
filesMatching('**/plugin.yml') {
filesMatching(['**/paper-plugin.yml', '**/plugin.yml']) {
expand(pluginProperties)
}
}
@@ -0,0 +1,57 @@
package art.arcane.iris;
import art.arcane.iris.core.pack.DefaultPackBootstrapProvisioner;
import art.arcane.iris.core.pack.DefaultPackBootstrapProvisioner.ProvisionResult;
import io.papermc.paper.datapack.Datapack;
import io.papermc.paper.datapack.DatapackRegistrar;
import io.papermc.paper.datapack.DiscoveredDatapack;
import io.papermc.paper.plugin.bootstrap.BootstrapContext;
import io.papermc.paper.plugin.bootstrap.PluginBootstrap;
import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents;
import java.io.IOException;
import java.nio.file.Path;
@SuppressWarnings("UnstableApiUsage")
public final class IrisBootstrap implements PluginBootstrap {
static final String PACK_ID = "generated";
@Override
public void bootstrap(BootstrapContext context) {
ProvisionResult provisioned = provision(context);
Path datapackRoot = provisioned.datapackRoot();
context.getLogger().info("Iris startup datapack is {} at {}", provisioned.status(), datapackRoot);
context.getLifecycleManager().registerEventHandler(LifecycleEvents.DATAPACK_DISCOVERY, event -> {
try {
discoverPack(event.registrar(), datapackRoot);
} catch (IOException e) {
throw new IllegalStateException("Unable to discover the Iris startup datapack at " + datapackRoot, e);
}
});
}
static DiscoveredDatapack discoverPack(DatapackRegistrar registrar, Path datapackRoot) throws IOException {
DiscoveredDatapack datapack = registrar.discoverPack(
datapackRoot,
PACK_ID,
configurer -> configurer
.autoEnableOnServerStart(true)
.position(true, Datapack.Position.TOP)
);
if (datapack == null) {
throw new IllegalStateException("Paper did not accept the Iris startup datapack at " + datapackRoot);
}
return datapack;
}
private static ProvisionResult provision(BootstrapContext context) {
try {
return DefaultPackBootstrapProvisioner.provision(
context.getDataDirectory(),
message -> context.getLogger().info(message)
);
} catch (IOException e) {
throw new IllegalStateException("Unable to provision the Iris startup datapack", e);
}
}
}
@@ -26,6 +26,7 @@ import art.arcane.iris.core.IrisWorlds;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.core.lifecycle.WorldLifecycleService;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.pack.PackDownloader;
import art.arcane.iris.core.service.StudioSVC;
import art.arcane.iris.core.tools.IrisToolbelt;
import art.arcane.iris.engine.framework.Engine;
@@ -460,10 +461,11 @@ public class CommandIris implements DirectorExecutor {
@Param(name = "overwrite", description = "Whether or not to overwrite the pack with the downloaded one", aliases = "force", defaultValue = "false")
boolean overwrite
) {
sender().sendMessage(C.GREEN + "Downloading pack: " + pack + "/" + branch + (overwrite ? " overwriting" : ""));
if (pack.equals("overworld")) {
Iris.service(StudioSVC.class).downloadBranch(sender(), "IrisDimensions/overworld", "master", overwrite);
if (PackDownloader.isDefaultOverworld(pack)) {
sender().sendMessage(C.GREEN + "Downloading pack: " + pack + " (beta release)" + (overwrite ? " overwriting" : ""));
Iris.service(StudioSVC.class).downloadDefaultOverworld(sender(), overwrite);
} else {
sender().sendMessage(C.GREEN + "Downloading pack: " + pack + "/" + branch + (overwrite ? " overwriting" : ""));
Iris.service(StudioSVC.class).downloadSearch(sender(), "IrisDimensions/" + pack + "/" + branch, overwrite);
}
ServerConfigurator.installDataPacksIfChanged(true);
@@ -50,6 +50,8 @@ import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
@@ -64,14 +66,13 @@ public class CommandSVC implements IrisService, CommandExecutor, TabCompleter, D
@Override
public void onEnable() {
PluginCommand command = Iris.instance.getCommand(ROOT_COMMAND);
if (command == null) {
Iris.warn("Failed to find command '" + ROOT_COMMAND + "'");
return;
PluginCommand command = findBukkitCommand();
if (command != null) {
command.setExecutor(this);
command.setTabCompleter(this);
} else {
registerPaperCommand();
}
command.setExecutor(this);
command.setTabCompleter(this);
J.a(this::getDirector);
}
@@ -139,12 +140,7 @@ public class CommandSVC implements IrisService, CommandExecutor, TabCompleter, D
return List.of();
}
List<String> v = runDirectorTab(sender, alias, args);
if (sender instanceof Player player && IrisSettings.get().getGeneral().isCommandSounds()) {
player.playSound(player.getLocation(), Sound.BLOCK_AMETHYST_BLOCK_CHIME, 0.25f, RNG.r.f(0.125f, 1.95f));
}
return v;
return tabCompleteRoot(sender, alias, args);
}
@Override
@@ -153,13 +149,52 @@ public class CommandSVC implements IrisService, CommandExecutor, TabCompleter, D
return false;
}
executeRoot(sender, label, args);
return true;
}
void executeRoot(CommandSender sender, String label, String[] args) {
if (!sender.hasPermission(ROOT_PERMISSION)) {
sender.sendMessage("You lack the Permission '" + ROOT_PERMISSION + "'");
return true;
return;
}
J.aBukkit(() -> executeCommand(sender, label, args));
return true;
}
List<String> tabCompleteRoot(CommandSender sender, String alias, String[] args) {
List<String> suggestions = runDirectorTab(sender, alias, args);
if (sender instanceof Player player && IrisSettings.get().getGeneral().isCommandSounds()) {
player.playSound(player.getLocation(), Sound.BLOCK_AMETHYST_BLOCK_CHIME, 0.25f, RNG.r.f(0.125f, 1.95f));
}
return suggestions;
}
private PluginCommand findBukkitCommand() {
try {
return Iris.instance.getCommand(ROOT_COMMAND);
} catch (UnsupportedOperationException ignored) {
return null;
}
}
private void registerPaperCommand() {
try {
Class<?> registrarType = Class.forName(
"art.arcane.iris.core.service.PaperCommandRegistrar",
true,
getClass().getClassLoader()
);
Method register = registrarType.getDeclaredMethod("register", Iris.class, CommandSVC.class);
register.invoke(null, Iris.instance, this);
} catch (InvocationTargetException e) {
Throwable cause = e.getCause() == null ? e : e.getCause();
throw new IllegalStateException("Paper command registration failed", cause);
} catch (ReflectiveOperationException e) {
throw new IllegalStateException("Paper command registrar is unavailable", e);
} catch (LinkageError e) {
throw new IllegalStateException("Paper command APIs are unavailable", e);
}
}
private void executeCommand(CommandSender sender, String label, String[] args) {
@@ -0,0 +1,49 @@
package art.arcane.iris.core.service;
import art.arcane.iris.Iris;
import io.papermc.paper.command.brigadier.BasicCommand;
import io.papermc.paper.command.brigadier.CommandSourceStack;
import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents;
import org.bukkit.command.CommandSender;
import java.util.Collection;
import java.util.List;
final class PaperCommandRegistrar {
private static final String ROOT_COMMAND = "iris";
private PaperCommandRegistrar() {
}
static void register(Iris plugin, CommandSVC commandService) {
plugin.getLifecycleManager().registerEventHandler(LifecycleEvents.COMMANDS, event ->
event.registrar().register(
ROOT_COMMAND,
"Iris world generation command.",
List.of("ir", "irs"),
command(commandService)
)
);
}
static BasicCommand command(CommandSVC commandService) {
return new PaperCommand(commandService);
}
private record PaperCommand(CommandSVC commandService) implements BasicCommand {
@Override
public void execute(CommandSourceStack source, String[] args) {
commandService.executeRoot(source.getSender(), ROOT_COMMAND, args);
}
@Override
public Collection<String> suggest(CommandSourceStack source, String[] args) {
return commandService.tabCompleteRoot(source.getSender(), ROOT_COMMAND, args);
}
@Override
public boolean canUse(CommandSender sender) {
return true;
}
}
}
@@ -0,0 +1,10 @@
name: ${name}
version: ${version}
main: ${main}
bootstrapper: ${bootstrapper}
folia-supported: true
api-version: '${apiVersion}'
load: STARTUP
authors: [ cyberpwn, NextdoorPsycho, Vatuu ]
website: volmit.com
description: More than a Dimension!
@@ -0,0 +1,199 @@
package art.arcane.iris;
import io.papermc.paper.datapack.Datapack;
import io.papermc.paper.datapack.DatapackRegistrar;
import io.papermc.paper.datapack.DatapackSource;
import io.papermc.paper.datapack.DiscoveredDatapack;
import io.papermc.paper.plugin.configuration.PluginMeta;
import net.kyori.adventure.text.Component;
import org.bukkit.FeatureFlag;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.File;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Path;
import java.util.Collections;
import java.util.Map;
import java.util.Set;
import java.util.function.Consumer;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class IrisBootstrapTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void discoverPackAutoEnablesAtFixedTop() throws Exception {
File datapackDirectory = temporaryFolder.newFolder("datapack");
RecordingRegistrar registrar = new RecordingRegistrar(DiscoveryBehavior.ACCEPT);
DiscoveredDatapack discovered = IrisBootstrap.discoverPack(registrar, datapackDirectory.toPath());
assertSame(registrar.discoveredDatapack, discovered);
assertEquals(datapackDirectory.toPath(), registrar.discoveredPath);
assertEquals(IrisBootstrap.PACK_ID, registrar.discoveredId);
assertTrue(registrar.configurer.autoEnableOnServerStart);
assertTrue(registrar.configurer.fixedPosition);
assertEquals(Datapack.Position.TOP, registrar.configurer.position);
}
@Test
public void rejectedDiscoveryReportsDatapackPath() throws Exception {
File datapackDirectory = temporaryFolder.newFolder("datapack");
RecordingRegistrar registrar = new RecordingRegistrar(DiscoveryBehavior.REJECT);
IllegalStateException failure = assertThrows(
IllegalStateException.class,
() -> IrisBootstrap.discoverPack(registrar, datapackDirectory.toPath())
);
assertTrue(failure.getMessage().contains(datapackDirectory.toPath().toString()));
}
@Test
public void discoveryIoFailurePropagates() throws Exception {
File datapackDirectory = temporaryFolder.newFolder("datapack");
RecordingRegistrar registrar = new RecordingRegistrar(DiscoveryBehavior.FAIL);
IOException failure = assertThrows(
IOException.class,
() -> IrisBootstrap.discoverPack(registrar, datapackDirectory.toPath())
);
assertEquals("discovery failed", failure.getMessage());
}
private enum DiscoveryBehavior {
ACCEPT,
REJECT,
FAIL
}
private static final class RecordingConfigurer implements DatapackRegistrar.Configurer {
private boolean autoEnableOnServerStart;
private boolean fixedPosition;
private Datapack.Position position;
@Override
public DatapackRegistrar.Configurer title(Component title) {
return this;
}
@Override
public DatapackRegistrar.Configurer autoEnableOnServerStart(boolean autoEnableOnServerStart) {
this.autoEnableOnServerStart = autoEnableOnServerStart;
return this;
}
@Override
public DatapackRegistrar.Configurer position(boolean fixed, Datapack.Position position) {
this.fixedPosition = fixed;
this.position = position;
return this;
}
}
private static final class RecordingRegistrar implements DatapackRegistrar {
private final DiscoveryBehavior behavior;
private final RecordingConfigurer configurer;
private final DiscoveredDatapack discoveredDatapack;
private Path discoveredPath;
private String discoveredId;
private RecordingRegistrar(DiscoveryBehavior behavior) {
this.behavior = behavior;
this.configurer = new RecordingConfigurer();
this.discoveredDatapack = new StubDiscoveredDatapack();
}
@Override
public boolean hasPackDiscovered(String name) {
return false;
}
@Override
public DiscoveredDatapack getDiscoveredPack(String name) {
throw new UnsupportedOperationException();
}
@Override
public boolean removeDiscoveredPack(String name) {
throw new UnsupportedOperationException();
}
@Override
public Map<String, DiscoveredDatapack> getDiscoveredPacks() {
return Collections.emptyMap();
}
@Override
public DiscoveredDatapack discoverPack(URI uri, String id, Consumer<Configurer> configurer) {
throw new UnsupportedOperationException();
}
@Override
public DiscoveredDatapack discoverPack(Path path, String id, Consumer<Configurer> configurer) throws IOException {
this.discoveredPath = path;
this.discoveredId = id;
if (behavior == DiscoveryBehavior.FAIL) {
throw new IOException("discovery failed");
}
configurer.accept(this.configurer);
return behavior == DiscoveryBehavior.ACCEPT ? discoveredDatapack : null;
}
@Override
public DiscoveredDatapack discoverPack(PluginMeta pluginMeta, URI uri, String id, Consumer<Configurer> configurer) {
throw new UnsupportedOperationException();
}
@Override
public DiscoveredDatapack discoverPack(PluginMeta pluginMeta, Path path, String id, Consumer<Configurer> configurer) {
throw new UnsupportedOperationException();
}
}
private static final class StubDiscoveredDatapack implements DiscoveredDatapack {
@Override
public String getName() {
return IrisBootstrap.PACK_ID;
}
@Override
public Component getTitle() {
return Component.text(IrisBootstrap.PACK_ID);
}
@Override
public Component getDescription() {
return Component.empty();
}
@Override
public boolean isRequired() {
return true;
}
@Override
public Datapack.Compatibility getCompatibility() {
return Datapack.Compatibility.COMPATIBLE;
}
@Override
public Set<FeatureFlag> getRequiredFeatures() {
return Collections.emptySet();
}
@Override
public DatapackSource getSource() {
throw new UnsupportedOperationException();
}
}
}
@@ -0,0 +1,59 @@
package art.arcane.iris;
import org.bukkit.plugin.PluginDescriptionFile;
import org.bukkit.plugin.PluginLoadOrder;
import org.junit.Test;
import java.io.InputStream;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
public class PaperPluginMetadataTest {
@Test
public void paperMetadataDeclaresBootstrapAndFoliaSupport() throws Exception {
String metadata;
try (InputStream stream = PaperPluginMetadataTest.class.getResourceAsStream("/paper-plugin.yml")) {
assertNotNull(stream);
metadata = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
}
assertTrue(metadata.contains("bootstrapper: " + IrisBootstrap.class.getName()));
assertTrue(metadata.contains("folia-supported: true"));
assertTrue(metadata.contains("load: STARTUP"));
assertFalse(metadata.contains("commands:"));
}
@Test
public void bukkitMetadataRetainsStartupCommandAndAliases() throws Exception {
PluginDescriptionFile metadata;
try (InputStream stream = PaperPluginMetadataTest.class.getResourceAsStream("/plugin.yml")) {
assertNotNull(stream);
metadata = new PluginDescriptionFile(stream);
}
assertEquals(Iris.class.getName(), metadata.getMain());
assertEquals(PluginLoadOrder.STARTUP, metadata.getLoad());
Map<String, Map<String, Object>> commands = metadata.getCommands();
assertTrue(commands.containsKey("iris"));
assertEquals(List.of("ir", "irs"), commands.get("iris").get("aliases"));
}
@Test
public void processedResourcesContainBothMetadataFormats() throws Exception {
URL paperMetadata = PaperPluginMetadataTest.class.getResource("/paper-plugin.yml");
assertNotNull(paperMetadata);
assertEquals("file", paperMetadata.getProtocol());
Path bukkitMetadata = Path.of(paperMetadata.toURI()).resolveSibling("plugin.yml");
assertTrue(Files.isRegularFile(bukkitMetadata));
assertFalse(Files.readString(bukkitMetadata, StandardCharsets.UTF_8).contains("${"));
}
}
@@ -0,0 +1,115 @@
package art.arcane.iris.core.service;
import io.papermc.paper.command.brigadier.BasicCommand;
import io.papermc.paper.command.brigadier.CommandSourceStack;
import org.bukkit.Location;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Entity;
import org.junit.Test;
import java.lang.reflect.Proxy;
import java.util.List;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
public class PaperCommandRegistrarTest {
@Test
public void paperCommandDelegatesExecutionAndSuggestions() {
RecordingCommandService service = new RecordingCommandService();
BasicCommand command = PaperCommandRegistrar.command(service);
CommandSender sender = sender();
CommandSourceStack source = new TestCommandSourceStack(sender);
String[] arguments = {"create", "world"};
command.execute(source, arguments);
assertSame(sender, service.sender);
assertEquals("iris", service.label);
assertArrayEquals(arguments, service.arguments);
assertEquals(List.of("first", "second"), command.suggest(source, new String[]{"cr"}));
assertTrue(command.canUse(sender));
}
private static CommandSender sender() {
return (CommandSender) Proxy.newProxyInstance(
CommandSender.class.getClassLoader(),
new Class<?>[]{CommandSender.class},
(proxy, method, arguments) -> primitiveDefault(method.getReturnType())
);
}
private static Object primitiveDefault(Class<?> type) {
if (type == boolean.class) {
return false;
}
if (type == byte.class) {
return (byte) 0;
}
if (type == short.class) {
return (short) 0;
}
if (type == int.class) {
return 0;
}
if (type == long.class) {
return 0L;
}
if (type == float.class) {
return 0F;
}
if (type == double.class) {
return 0D;
}
if (type == char.class) {
return '\0';
}
return null;
}
private static final class RecordingCommandService extends CommandSVC {
private CommandSender sender;
private String label;
private String[] arguments;
@Override
void executeRoot(CommandSender sender, String label, String[] args) {
this.sender = sender;
this.label = label;
this.arguments = args;
}
@Override
List<String> tabCompleteRoot(CommandSender sender, String alias, String[] args) {
return List.of("first", "second");
}
}
private record TestCommandSourceStack(CommandSender sender) implements CommandSourceStack {
@Override
public Location getLocation() {
return null;
}
@Override
public CommandSender getSender() {
return sender;
}
@Override
public Entity getExecutor() {
return null;
}
@Override
public CommandSourceStack withLocation(Location location) {
return this;
}
@Override
public CommandSourceStack withExecutor(Entity executor) {
return this;
}
}
}
@@ -121,7 +121,7 @@ public final class ModdedForcedDatapack {
File packFolder = packDirectory.toFile();
KList<File> folders = new KList<>();
folders.add(packFolder.getParentFile());
folders.add(packFolder);
KSet<String> seenBiomes = new KSet<>();
IDataFixer fixer = DataVersion.getLatest().get();
@@ -185,8 +185,8 @@ public final class ModdedForcedDatapack {
private static void writeWorldPreset(KList<File> folders, IrisDimension dimension, String packName, String dimensionKey, String presetKey) throws IOException {
String dimensionRef = dimensionKey.equals(packName) ? packName : packName + ":" + dimensionKey;
String json = worldPresetJson(dimensionRef, dimensionTypeRef(dimension));
for (File parent : folders) {
Path output = parent.toPath().resolve(PACK_FOLDER).resolve("data").resolve("irisworldgen").resolve("worldgen").resolve("world_preset").resolve(presetKey + ".json");
for (File datapackRoot : folders) {
Path output = datapackRoot.toPath().resolve("data").resolve("irisworldgen").resolve("worldgen").resolve("world_preset").resolve(presetKey + ".json");
Files.createDirectories(output.getParent());
Files.writeString(output, json, StandardCharsets.UTF_8);
}
@@ -257,8 +257,8 @@ public final class ModdedForcedDatapack {
IrisDimensionType type = dimension.getDimensionType();
String json = type.toJson(fixer);
String typeKey = dimension.getDimensionTypeKey();
for (File parent : folders) {
Path output = parent.toPath().resolve(PACK_FOLDER).resolve("data").resolve("irisworldgen").resolve("dimension_type").resolve(typeKey + ".json");
for (File datapackRoot : folders) {
Path output = datapackRoot.toPath().resolve("data").resolve("irisworldgen").resolve("dimension_type").resolve(typeKey + ".json");
Files.createDirectories(output.getParent());
Files.writeString(output, json, StandardCharsets.UTF_8);
}
@@ -48,6 +48,9 @@ public final class ModdedPackInstaller {
File packs = configDir.resolve("irisworldgen").resolve("packs").toFile();
try {
if (PackDownloader.isDefaultOverworld(pack)) {
return PackDownloader.downloadDefaultOverworld(packs, true, feedback) != null;
}
return PackDownloader.download(packs, "IrisDimensions/" + pack, branch, true, false, feedback) != null;
} catch (IOException error) {
LOGGER.error("Iris pack download failed for IrisDimensions/{} ({})", pack, branch, error);
@@ -18,6 +18,7 @@
package art.arcane.iris.modded;
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;
@@ -131,7 +132,8 @@ public final class ModdedStartup {
if (new File(packFolder, "dimensions/" + pack + ".json").isFile()) {
return;
}
LOGGER.info("Iris default pack '{}' missing; downloading IrisDimensions/{} (master)", pack, pack);
String source = PackDownloader.isDefaultOverworld(pack) ? "beta release" : "master branch";
LOGGER.info("Iris default pack '{}' missing; downloading IrisDimensions/{} ({})", pack, pack, source);
boolean installed = ModdedPackInstaller.install(configDir, pack, "master", (String line) -> LOGGER.info("Iris: {}", line));
if (!installed) {
LOGGER.warn("Iris default pack '{}' could not be downloaded; install it with /iris download {}", pack, pack);
@@ -21,6 +21,7 @@ package art.arcane.iris.modded.command;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.gui.GuiHost;
import art.arcane.iris.core.loader.IrisRegistrant;
import art.arcane.iris.core.pack.PackDownloader;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.IrisStructureLocator;
import art.arcane.iris.engine.framework.Locator;
@@ -983,15 +984,16 @@ public final class IrisModdedCommands {
private static int download(CommandSourceStack source, String pack, String branch) {
MinecraftServer server = source.getServer();
String effectiveBranch = pack.equals("overworld") ? "master" : branch;
ok(source, "Downloading IrisDimensions/" + pack + " (branch " + effectiveBranch + ")...");
boolean defaultOverworld = PackDownloader.isDefaultOverworld(pack);
String downloadSource = defaultOverworld ? "beta release" : "branch " + branch;
ok(source, "Downloading IrisDimensions/" + pack + " (" + downloadSource + ")...");
Thread thread = new Thread(() -> {
boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), pack, effectiveBranch,
boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), pack, branch,
(String message) -> server.execute(() -> ok(source, message)));
if (installed) {
server.execute(() -> ok(source, "Pack '" + pack + "' installed. Its dimension types and custom biomes join the forced Iris datapack on the next server restart; worlds created before restarting run with fallback heights until then."));
} else {
server.execute(() -> fail(source, "Pack download failed for " + pack + "/" + effectiveBranch + " (see console)."));
server.execute(() -> fail(source, "Pack download failed for " + pack + " (" + downloadSource + "; see console)."));
}
}, "Iris Pack Download");
thread.setDaemon(true);
+3 -3
View File
@@ -87,6 +87,9 @@ dependencies {
implementation(volmLibCoordinate) {
transitive = false
}
implementation(libs.gson)
implementation(libs.lru)
implementation(libs.caffeine)
// Dynamically Loaded
slim(libs.paralithic)
@@ -104,11 +107,8 @@ dependencies {
slim(libs.oshi)
slim(libs.lz4)
slim(libs.fastutil)
slim(libs.lru)
slim(libs.zip)
slim(libs.gson)
slim(libs.asm)
slim(libs.caffeine)
slim(libs.byteBuddy.core)
slim(libs.byteBuddy.agent)
slim(libs.dom4j)
@@ -0,0 +1,219 @@
package art.arcane.iris.core;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.loader.ResourceLoader;
import art.arcane.iris.core.nms.datapack.IDataFixer;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.collection.KSet;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicIntegerArray;
import java.util.stream.Stream;
public final class IrisDatapackCompiler {
private static final int WORLD_PACK_SCAN_DEPTH = 8;
private IrisDatapackCompiler() {
}
public static List<File> collectPackRoots(Path dataDirectory, Path serverRoot) throws IOException {
LinkedHashMap<Path, File> roots = new LinkedHashMap<>();
collectInstalledPackRoots(dataDirectory.resolve("packs"), roots);
collectWorldPackRoots(serverRoot.resolve("dimensions"), roots);
return new ArrayList<>(roots.values());
}
public static CompilationResult compile(
List<File> packRoots,
KList<File> datapackRoots,
IDataFixer fixer,
int packFormat,
boolean adjustVanillaHeight
) throws IOException {
Objects.requireNonNull(packRoots, "packRoots");
Objects.requireNonNull(datapackRoots, "datapackRoots");
Objects.requireNonNull(fixer, "fixer");
if (datapackRoots.isEmpty()) {
throw new IOException("No Iris datapack output roots were provided");
}
for (File datapackRoot : datapackRoots) {
Files.createDirectories(datapackRoot.toPath());
}
IrisDimension.clearGeneratedBiomeTags(datapackRoots);
DimensionHeight height = new DimensionHeight(fixer);
Map<String, KSet<String>> biomes = new LinkedHashMap<>();
int packCount = 0;
int dimensionCount = 0;
for (File packRoot : packRoots) {
if (!hasDimensions(packRoot.toPath())) {
continue;
}
IrisData data = IrisData.openDatapackCompiler(packRoot);
try {
ResourceLoader<IrisDimension> loader = data.getDimensionLoader();
String[] possibleKeys = loader.getPossibleKeys();
if (possibleKeys == null || possibleKeys.length == 0) {
throw new IOException("Iris pack has no dimension definitions: " + packRoot);
}
int installedDimensions = 0;
for (String possibleKey : possibleKeys) {
IrisDimension dimension = loader.load(possibleKey);
if (dimension == null) {
throw new IOException("Unable to load Iris dimension '" + possibleKey + "' from " + packRoot);
}
IrisLogging.debug(" Compiling Dimension " + dimension.getLoadFile().getPath());
height.merge(dimension);
KSet<String> seenBiomes = biomes.computeIfAbsent(dimension.getLoadKey(), ignored -> new KSet<>());
dimension.installBiomes(fixer, dimension::getLoader, datapackRoots, seenBiomes);
dimension.installDimensionType(fixer, datapackRoots);
installedDimensions++;
dimensionCount++;
}
if (installedDimensions > 0) {
packCount++;
}
} finally {
data.close();
}
}
IrisDimension.writeShared(datapackRoots, height, packFormat, adjustVanillaHeight);
validateOutputs(datapackRoots);
return new CompilationResult(packCount, dimensionCount, countBiomes(biomes));
}
private static void collectInstalledPackRoots(Path packsRoot, Map<Path, File> roots) throws IOException {
if (!Files.isDirectory(packsRoot)) {
return;
}
try (Stream<Path> stream = Files.list(packsRoot)) {
List<Path> candidates = stream
.filter(Files::isDirectory)
.sorted(Comparator.comparing(Path::toString))
.toList();
for (Path candidate : candidates) {
addPackRoot(candidate, roots);
}
}
}
private static void collectWorldPackRoots(Path dimensionsRoot, Map<Path, File> roots) throws IOException {
if (!Files.isDirectory(dimensionsRoot)) {
return;
}
try (Stream<Path> stream = Files.find(
dimensionsRoot,
WORLD_PACK_SCAN_DEPTH,
(path, attributes) -> attributes.isDirectory()
&& "pack".equals(path.getFileName().toString())
&& path.getParent() != null
&& "iris".equals(path.getParent().getFileName().toString())
&& hasDimensions(path)
)) {
List<Path> candidates = stream.sorted(Comparator.comparing(Path::toString)).toList();
for (Path candidate : candidates) {
addPackRoot(candidate, roots);
}
}
}
private static void addPackRoot(Path root, Map<Path, File> roots) throws IOException {
if (!hasDimensions(root)) {
return;
}
Path normalized = root.toAbsolutePath().normalize();
Path identity = normalized.toRealPath();
roots.putIfAbsent(identity, normalized.toFile());
}
private static boolean hasDimensions(Path root) {
Path dimensions = root.resolve("dimensions");
if (!Files.isDirectory(dimensions)) {
return false;
}
try (Stream<Path> stream = Files.list(dimensions)) {
return stream.anyMatch(path -> Files.isRegularFile(path) && path.getFileName().toString().endsWith(".json"));
} catch (IOException e) {
return false;
}
}
private static void validateOutputs(Collection<File> datapackRoots) throws IOException {
for (File datapackRoot : datapackRoots) {
Path root = datapackRoot.toPath();
if (!Files.isRegularFile(root.resolve("pack.mcmeta"))) {
throw new IOException("Iris datapack metadata was not generated at " + root);
}
if (!Files.isDirectory(root.resolve("data/iris/dimension_type"))) {
throw new IOException("Iris dimension types were not generated at " + root);
}
}
}
private static int countBiomes(Map<String, KSet<String>> biomes) {
int count = 0;
for (KSet<String> values : biomes.values()) {
count += values.size();
}
return count;
}
public record CompilationResult(int packCount, int dimensionCount, int biomeCount) {
}
public static final class DimensionHeight {
private final IDataFixer fixer;
private final AtomicIntegerArray[] dimensions = new AtomicIntegerArray[3];
public DimensionHeight(IDataFixer fixer) {
this.fixer = Objects.requireNonNull(fixer, "fixer");
for (int index = 0; index < dimensions.length; index++) {
dimensions[index] = new AtomicIntegerArray(new int[]{
Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MIN_VALUE
});
}
}
public void merge(IrisDimension dimension) {
AtomicIntegerArray values = dimensions[dimension.getBaseDimension().ordinal()];
values.updateAndGet(0, current -> Math.min(current, dimension.getMinHeight()));
values.updateAndGet(1, current -> Math.max(current, dimension.getMaxHeight()));
values.updateAndGet(2, current -> Math.max(current, dimension.getLogicalHeight()));
}
public String[] jsonStrings() {
IDataFixer.Dimension[] types = IDataFixer.Dimension.values();
String[] output = new String[types.length];
for (int index = 0; index < types.length; index++) {
output[index] = jsonString(types[index]);
}
return output;
}
private String jsonString(IDataFixer.Dimension dimension) {
AtomicIntegerArray values = dimensions[dimension.ordinal()];
int minY = values.get(0);
int maxY = values.get(1);
int logicalHeight = values.get(2);
if (minY == Integer.MAX_VALUE || maxY == Integer.MIN_VALUE || logicalHeight == Integer.MIN_VALUE) {
return null;
}
return fixer.createDimension(dimension, minY, maxY - minY, logicalHeight, null).toString(4);
}
}
}
@@ -25,6 +25,8 @@ import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.nms.INMS;
import art.arcane.iris.core.nms.datapack.DataVersion;
import art.arcane.iris.core.nms.datapack.IDataFixer;
import art.arcane.iris.core.pack.DefaultPackBootstrapProvisioner;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisBiomeCustom;
import art.arcane.iris.engine.object.IrisDimension;
@@ -56,7 +58,6 @@ import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicIntegerArray;
import java.util.stream.Stream;
public class ServerConfigurator {
@@ -70,7 +71,11 @@ public class ServerConfigurator {
J.attempt(ServerConfigurator::increasePaperWatchdog);
}
installDataPacks(true);
if (DefaultPackBootstrapProvisioner.wasProvisionedThisStartup()) {
IrisLogging.info("Paper loaded the Iris datapack during bootstrap; skipping the legacy startup install.");
} else {
installDataPacks(true);
}
}
private static void increaseKeepAliveSpigot() throws IOException, InvalidConfigurationException {
@@ -108,6 +113,14 @@ public class ServerConfigurator {
return new KList<File>().qadd(new File(IrisWorldStorage.levelRoot(), "datapacks"));
}
public static KList<File> getIrisDatapackRoots() {
KList<File> roots = new KList<>();
for (File datapacksFolder : getDatapacksFolder()) {
roots.add(new File(datapacksFolder, "iris"));
}
return roots;
}
public static boolean installDataPacks(boolean fullInstall) {
IDataFixer fixer = DataVersion.getDefault();
if (fixer == null) {
@@ -128,22 +141,29 @@ public class ServerConfigurator {
} else {
IrisLogging.debug("Checking Data Packs...");
}
DimensionHeight height = new DimensionHeight(fixer);
KList<File> folders = getDatapacksFolder();
IrisDimension.clearGeneratedBiomeTags(folders);
DatapackIngestService.reapplyFromStaging(folders);
java.util.concurrent.ConcurrentMap<String, KSet<String>> biomes = new java.util.concurrent.ConcurrentHashMap<>();
KList<File> datapacksFolders = getDatapacksFolder();
DatapackIngestService.reapplyFromStaging(datapacksFolders);
List<File> packRoots;
try (Stream<IrisData> stream = allPacks()) {
stream.flatMap(height::merge)
.parallel()
.forEach(dim -> {
IrisLogging.debug(" Checking Dimension " + dim.getLoadFile().getPath());
dim.installBiomes(fixer, dim::getLoader, folders, biomes.computeIfAbsent(dim.getLoadKey(), k -> new KSet<>()));
dim.installDimensionType(fixer, folders);
});
packRoots = stream
.map(IrisData::getDataFolder)
.map(File::getAbsoluteFile)
.distinct()
.toList();
}
try {
IrisDatapackCompiler.compile(
packRoots,
getIrisDatapackRoots(),
fixer,
BukkitPlatform.dataPackFormat(),
IrisSettings.get().getGeneral().adjustVanillaHeight
);
} catch (IOException e) {
IrisLogging.reportError("Unable to compile Iris datapacks", e);
return false;
}
IrisDimension.writeShared(folders, height);
if (fullInstall) {
IrisLogging.info("Data Packs Setup!");
} else {
@@ -263,7 +283,7 @@ public class ServerConfigurator {
for (Player i : Bukkit.getOnlinePlayers()) {
if (i.isOp() || i.hasPermission("iris.all")) {
VolmitSender sender = new VolmitSender(i, art.arcane.iris.platform.bukkit.BukkitPlatform.volmitPlugin().getTag("WARNING"));
VolmitSender sender = new VolmitSender(i, BukkitPlatform.volmitPlugin().getTag("WARNING"));
sender.sendMessage("There are some Iris Packs that have custom biomes in them");
sender.sendMessage("You need to restart your server to use these packs.");
}
@@ -364,52 +384,4 @@ public class ServerConfigurator {
return key == null ? null : key.toString();
}
public static class DimensionHeight {
private final IDataFixer fixer;
private final AtomicIntegerArray[] dimensions = new AtomicIntegerArray[3];
public DimensionHeight(IDataFixer fixer) {
this.fixer = fixer;
for (int i = 0; i < 3; i++) {
dimensions[i] = new AtomicIntegerArray(new int[]{
Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MIN_VALUE
});
}
}
public Stream<IrisDimension> merge(IrisData data) {
IrisLogging.debug("Checking Pack: " + data.getDataFolder().getPath());
var loader = data.getDimensionLoader();
return loader.loadAll(loader.getPossibleKeys())
.stream()
.filter(Objects::nonNull)
.peek(this::merge);
}
public void merge(IrisDimension dimension) {
AtomicIntegerArray array = dimensions[dimension.getBaseDimension().ordinal()];
array.updateAndGet(0, min -> Math.min(min, dimension.getMinHeight()));
array.updateAndGet(1, max -> Math.max(max, dimension.getMaxHeight()));
array.updateAndGet(2, logical -> Math.max(logical, dimension.getLogicalHeight()));
}
public String[] jsonStrings() {
var dims = IDataFixer.Dimension.values();
var arr = new String[3];
for (int i = 0; i < 3; i++) {
arr[i] = jsonString(dims[i]);
}
return arr;
}
public String jsonString(IDataFixer.Dimension dimension) {
var data = dimensions[dimension.ordinal()];
int minY = data.get(0);
int maxY = data.get(1);
int logicalHeight = data.get(2);
if (minY == Integer.MAX_VALUE || maxY == Integer.MIN_VALUE || Integer.MIN_VALUE == logicalHeight)
return null;
return fixer.createDimension(dimension, minY, maxY - minY, logicalHeight, null).toString(4);
}
}
}
@@ -34,8 +34,8 @@ import java.util.HashSet;
import java.util.Set;
public class ImageResourceLoader extends ResourceLoader<IrisImage> {
public ImageResourceLoader(File root, IrisData idm, String folderName, String resourceTypeName) {
super(root, idm, folderName, resourceTypeName, IrisImage.class);
public ImageResourceLoader(File root, IrisData idm, String folderName, String resourceTypeName, Options options) {
super(root, idm, folderName, resourceTypeName, IrisImage.class, options);
loadCache = new KCache<>(this::loadRaw, IrisSettings.get().getPerformance().getObjectLoaderCacheSize());
}
@@ -80,6 +80,7 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
private static final KMap<File, IrisData> dataLoaders = new KMap<>();
private final File dataFolder;
private final int id;
private final boolean datapackCompiler;
private boolean closed = false;
private ResourceLoader<IrisBiome> biomeLoader;
private ResourceLoader<IrisLootTable> lootLoader;
@@ -106,16 +107,29 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
private Engine engine;
private IrisData(File dataFolder) {
this(dataFolder, false);
}
private IrisData(File dataFolder, boolean datapackCompiler) {
this.engine = null;
this.dataFolder = dataFolder;
this.id = RNG.r.imax();
hotloaded();
this.datapackCompiler = datapackCompiler;
if (datapackCompiler) {
hotloadedDatapackCompiler();
} else {
hotloaded();
}
}
public static IrisData get(File dataFolder) {
return dataLoaders.computeIfAbsent(dataFolder, IrisData::new);
}
public static IrisData openDatapackCompiler(File dataFolder) {
return new IrisData(dataFolder, true);
}
public static Optional<IrisData> getLoaded(File dataFolder) {
return Optional.ofNullable(dataLoaders.get(dataFolder));
}
@@ -273,7 +287,9 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
public void close() {
closed = true;
dump();
dataLoaders.remove(dataFolder);
if (dataLoaders.get(dataFolder) == this) {
dataLoaders.remove(dataFolder);
}
}
public IrisData copy() {
@@ -284,18 +300,21 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
try {
IrisRegistrant rr = registrant.getConstructor().newInstance();
ResourceLoader<T> r = null;
ResourceLoader.Options options = datapackCompiler
? ResourceLoader.Options.datapackCompiler()
: ResourceLoader.Options.runtime();
if (registrant.equals(IrisObject.class)) {
r = (ResourceLoader<T>) new ObjectResourceLoader(dataFolder, this, rr.getFolderName(),
rr.getTypeName());
rr.getTypeName(), options);
} else if (registrant.equals(IrisMatterObject.class)) {
r = (ResourceLoader<T>) new MatterObjectResourceLoader(dataFolder, this, rr.getFolderName(),
rr.getTypeName());
rr.getTypeName(), options);
} else if (registrant.equals(IrisImage.class)) {
r = (ResourceLoader<T>) new ImageResourceLoader(dataFolder, this, rr.getFolderName(),
rr.getTypeName());
rr.getTypeName(), options);
} else {
J.attempt(() -> registrant.getConstructor().newInstance().registerTypeAdapters(builder));
r = new ResourceLoader<>(dataFolder, this, rr.getFolderName(), rr.getTypeName(), registrant);
r = new ResourceLoader<>(dataFolder, this, rr.getFolderName(), rr.getTypeName(), registrant, options);
}
loaders.put(registrant, r);
@@ -349,6 +368,27 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
}
}
private void hotloadedDatapackCompiler() {
closed = false;
possibleSnippets = new KMap<>();
builder = new GsonBuilder()
.addDeserializationExclusionStrategy(this)
.addSerializationExclusionStrategy(this)
.setStrictness(Strictness.LENIENT)
.registerTypeAdapterFactory(this)
.registerTypeAdapter(MantleFlag.class, new MantleFlagAdapter())
.setPrettyPrinting();
loaders.clear();
dataFolder.mkdirs();
biomeLoader = registerLoader(IrisBiome.class);
dimensionLoader = registerLoader(IrisDimension.class);
builder.registerTypeAdapterFactory(KeyedType::createTypeAdapter);
gson = builder.create();
if (biomeLoader == null || dimensionLoader == null) {
throw new IllegalStateException("Unable to initialize Iris datapack compiler loaders for " + dataFolder);
}
}
public void dump() {
for (ResourceLoader<?> i : loaders.values()) {
i.clearCache();
@@ -33,8 +33,8 @@ import java.util.HashSet;
public class MatterObjectResourceLoader extends ResourceLoader<IrisMatterObject> {
private String[] possibleKeys;
public MatterObjectResourceLoader(File root, IrisData idm, String folderName, String resourceTypeName) {
super(root, idm, folderName, resourceTypeName, IrisMatterObject.class);
public MatterObjectResourceLoader(File root, IrisData idm, String folderName, String resourceTypeName, Options options) {
super(root, idm, folderName, resourceTypeName, IrisMatterObject.class, options);
loadCache = new KCache<>(this::loadRaw, IrisSettings.get().getPerformance().getObjectLoaderCacheSize());
}
@@ -31,8 +31,8 @@ import java.io.IOException;
import java.util.HashSet;
public class ObjectResourceLoader extends ResourceLoader<IrisObject> {
public ObjectResourceLoader(File root, IrisData idm, String folderName, String resourceTypeName) {
super(root, idm, folderName, resourceTypeName, IrisObject.class);
public ObjectResourceLoader(File root, IrisData idm, String folderName, String resourceTypeName, Options options) {
super(root, idm, folderName, resourceTypeName, IrisObject.class, options);
loadCache = new KCache<>(this::loadRaw, IrisSettings.get().getPerformance().getObjectLoaderCacheSize());
}
@@ -55,6 +55,7 @@ import java.io.IOException;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
@@ -93,8 +94,17 @@ public class ResourceLoader<T extends IrisRegistrant> implements MeteredCache {
protected IrisData manager;
protected AtomicInteger loads;
protected ChronoLatch sec;
private final Options options;
public ResourceLoader(File root, IrisData manager, String folderName, String resourceTypeName, Class<? extends T> objectClass) {
public ResourceLoader(
File root,
IrisData manager,
String folderName,
String resourceTypeName,
Class<? extends T> objectClass,
Options options
) {
this.options = Objects.requireNonNull(options, "options");
this.manager = manager;
firstAccess = new KSet<>();
folderCache = new AtomicCache<>();
@@ -105,12 +115,14 @@ public class ResourceLoader<T extends IrisRegistrant> implements MeteredCache {
this.resourceTypeName = resourceTypeName;
this.root = root;
this.folderName = folderName;
loadCache = new KCache<>(this::loadRaw, IrisSettings.get().getPerformance().getResourceLoaderCacheSize());
loadCache = new KCache<>(this::loadRaw, options.cacheSize());
IrisLogging.debug("Loader<" + C.GREEN + resourceTypeName + C.LIGHT_PURPLE + "> created in " + C.RED + "IDM/" + manager.getId() + C.LIGHT_PURPLE + " on " + C.GRAY + manager.getDataFolder().getPath());
IrisServices.get(PreservationRegistry.class).registerCache(this);
PreservationRegistry preservation = IrisServices.getOrNull(PreservationRegistry.class);
if (preservation != null && schemaBuildExecutorRegistered.compareAndSet(false, true)) {
preservation.register(schemaBuildExecutor);
if (options.registerPreservation()) {
IrisServices.get(PreservationRegistry.class).registerCache(this);
PreservationRegistry preservation = IrisServices.getOrNull(PreservationRegistry.class);
if (preservation != null && schemaBuildExecutorRegistered.compareAndSet(false, true)) {
preservation.register(schemaBuildExecutor);
}
}
}
@@ -201,10 +213,15 @@ public class ResourceLoader<T extends IrisRegistrant> implements MeteredCache {
}
if (sec.flip()) {
J.a(() -> {
Runnable summary = () -> {
IrisLogging.debug("Loaded " + C.WHITE + loads.get() + " " + resourceTypeName + (loads.get() == 1 ? "" : "s") + C.GRAY + " (" + Form.f(getLoadCache().getSize()) + " " + resourceTypeName + (loadCache.getSize() == 1 ? "" : "s") + " Loaded)");
loads.set(0);
});
};
if (options.synchronousReporting()) {
summary.run();
} else {
J.a(summary);
}
}
IrisLogging.debug("Loader<" + C.GREEN + resourceTypeName + C.LIGHT_PURPLE + "> iload " + C.YELLOW + t.getLoadKey() + C.LIGHT_PURPLE + " in " + C.GRAY + t.getLoadFile().getPath() + C.LIGHT_PURPLE + " TLT: " + C.RED + Form.duration(tlt.get(), 2));
@@ -215,7 +232,12 @@ public class ResourceLoader<T extends IrisRegistrant> implements MeteredCache {
}
public void failLoad(File path, String rawText, Throwable e) {
J.a(() -> JsonSchemaValidator.reportLoadFailure(path, rawText, resourceTypeName, e));
Runnable report = () -> JsonSchemaValidator.reportLoadFailure(path, rawText, resourceTypeName, e);
if (options.synchronousReporting()) {
report.run();
} else {
J.a(report);
}
}
private KList<File> matchAllFiles(File root, Predicate<File> f) {
@@ -577,4 +599,24 @@ public class ResourceLoader<T extends IrisRegistrant> implements MeteredCache {
public long getTotalStorage() {
return getSize();
}
public record Options(int cacheSize, boolean registerPreservation, boolean synchronousReporting) {
public Options {
if (cacheSize < 1) {
throw new IllegalArgumentException("Resource loader cache size must be positive");
}
}
public static Options runtime() {
return new Options(
IrisSettings.get().getPerformance().getResourceLoaderCacheSize(),
true,
false
);
}
public static Options datapackCompiler() {
return new Options(CACHE_SIZE, false, true);
}
}
}
@@ -0,0 +1,789 @@
package art.arcane.iris.core.pack;
import art.arcane.iris.core.IrisDatapackCompiler;
import art.arcane.iris.core.nms.datapack.DataVersion;
import art.arcane.iris.core.nms.datapack.IDataFixer;
import art.arcane.volmlib.util.collection.KList;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.time.Duration;
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.Properties;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
import java.util.stream.Stream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
public final class DefaultPackBootstrapProvisioner {
private static final URI DEFAULT_SOURCE = URI.create("https://github.com/IrisDimensions/overworld/releases/download/beta/overworld.zip");
private static final int PACK_FORMAT = 107;
private static final int MARKER_SCHEMA = 1;
private static final int MAX_ARCHIVE_ENTRIES = 100_000;
private static final long MAX_ARCHIVE_BYTES = 512L * 1024L * 1024L;
private static final long MAX_EXPANDED_BYTES = 2L * 1024L * 1024L * 1024L;
private static final AtomicBoolean PROVISIONED_THIS_STARTUP = new AtomicBoolean(false);
private DefaultPackBootstrapProvisioner() {
}
public static ProvisionResult provision(Path dataDirectory, Consumer<String> feedback) throws IOException {
Objects.requireNonNull(dataDirectory, "dataDirectory");
Objects.requireNonNull(feedback, "feedback");
Path serverRoot = Path.of("").toAbsolutePath().normalize();
Path levelRoot = resolveLevelRoot(serverRoot);
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(15))
.followRedirects(HttpClient.Redirect.ALWAYS)
.build();
ProvisionOptions options = new ProvisionOptions(
DEFAULT_SOURCE,
client,
Clock.systemUTC(),
Duration.ofMinutes(30),
Duration.ofSeconds(45),
3,
Duration.ofMillis(250),
MAX_ARCHIVE_BYTES,
levelRoot
);
return provision(dataDirectory, feedback, options);
}
public static boolean isProvisioned(Path dataDirectory) {
if (dataDirectory == null) {
return false;
}
Path bootstrapRoot = dataDirectory.toAbsolutePath().normalize().resolve("bootstrap");
Path datapackRoot = bootstrapRoot.resolve("datapack");
Path packRoot = dataDirectory.toAbsolutePath().normalize().resolve("packs/overworld");
Path markerFile = bootstrapRoot.resolve("provisioned.properties");
if (!Files.isRegularFile(markerFile) || !isPackRoot(packRoot) || !isDatapackRoot(datapackRoot)) {
return false;
}
try {
Properties marker = loadProperties(markerFile);
if (!Integer.toString(MARKER_SCHEMA).equals(marker.getProperty("schema"))) {
return false;
}
return directoryFingerprint(packRoot).equals(marker.getProperty("defaultPackFingerprint"))
&& directoryFingerprint(datapackRoot).equals(marker.getProperty("datapackFingerprint"))
&& packRootsFingerprint(IrisDatapackCompiler.collectPackRoots(
dataDirectory.toAbsolutePath().normalize(),
resolveLevelRoot(Path.of("").toAbsolutePath().normalize())
)).equals(marker.getProperty("aggregateFingerprint"));
} catch (IOException | RuntimeException exception) {
return false;
}
}
public static boolean wasProvisionedThisStartup() {
return PROVISIONED_THIS_STARTUP.get();
}
static ProvisionResult provision(Path dataDirectory, Consumer<String> feedback, ProvisionOptions options) throws IOException {
Path normalizedData = dataDirectory.toAbsolutePath().normalize();
Path packsRoot = normalizedData.resolve("packs");
Path packRoot = packsRoot.resolve("overworld");
Path bootstrapRoot = normalizedData.resolve("bootstrap");
Path datapackRoot = bootstrapRoot.resolve("datapack");
Path markerFile = bootstrapRoot.resolve("provisioned.properties");
Path cacheRoot = normalizedData.resolve("cache/bootstrap");
Files.createDirectories(packsRoot);
Files.createDirectories(bootstrapRoot);
Files.createDirectories(cacheRoot);
Properties previousMarker = Files.isRegularFile(markerFile) ? loadProperties(markerFile) : new Properties();
boolean existingPack = isPackRoot(packRoot);
boolean existingDatapack = isDatapackRoot(datapackRoot);
String currentPackFingerprint = existingPack ? directoryFingerprint(packRoot) : "";
boolean markerOwned = "true".equals(previousMarker.getProperty("managedDefault"));
boolean unchangedManagedPack = markerOwned
&& currentPackFingerprint.equals(previousMarker.getProperty("defaultPackFingerprint"));
boolean managedDefault = !existingPack || unchangedManagedPack;
if (Files.isSymbolicLink(packRoot)) {
managedDefault = false;
}
Archive archive = managedDefault
? acquireArchive(cacheRoot, previousMarker, feedback, options)
: new Archive(null, currentPackFingerprint);
boolean replacePack = !existingPack || managedDefault
&& (!archive.sha256().equals(previousMarker.getProperty("sourceSha256"))
|| !currentPackFingerprint.equals(previousMarker.getProperty("defaultPackFingerprint")));
Path stagedPack = null;
Path extractionRoot = null;
Path compileContainer = null;
Path stagedDatapack = null;
Path packBackup = null;
Path datapackBackup = null;
boolean packReplaced = false;
boolean datapackReplaced = false;
try {
if (replacePack) {
extractionRoot = cacheRoot.resolve(".extract-" + UUID.randomUUID());
Files.createDirectories(extractionRoot);
Path extractedPack = extractArchive(archive.path(), extractionRoot);
stagedPack = packsRoot.resolve(".overworld-stage-" + UUID.randomUUID());
copyDirectory(extractedPack, stagedPack);
validatePackRoot(stagedPack);
packBackup = replaceWithBackup(stagedPack, packRoot);
packReplaced = true;
}
List<File> packRoots = IrisDatapackCompiler.collectPackRoots(normalizedData, options.levelRoot());
if (packRoots.isEmpty()) {
throw new IOException("No Iris pack roots were available for bootstrap datapack compilation");
}
String aggregateFingerprint = packRootsFingerprint(packRoots);
boolean rebuildDatapack = replacePack
|| !existingDatapack
|| !aggregateFingerprint.equals(previousMarker.getProperty("aggregateFingerprint"))
|| !directoryFingerprint(datapackRoot).equals(previousMarker.getProperty("datapackFingerprint"));
if (rebuildDatapack) {
compileContainer = bootstrapRoot.resolve(".compile-" + UUID.randomUUID());
Files.createDirectories(compileContainer);
KList<File> outputFolders = new KList<File>().qadd(compileContainer.toFile());
IDataFixer fixer = DataVersion.getLatest().get();
if (fixer == null) {
throw new IOException("Latest Iris datapack fixer is unavailable during bootstrap");
}
IrisDatapackCompiler.compile(packRoots, outputFolders, fixer, PACK_FORMAT, false);
Path canonicalOutput = compileContainer;
if (!isDatapackRoot(canonicalOutput)) {
throw new IOException("Canonical Iris datapack compiler produced incomplete output at " + canonicalOutput);
}
stagedDatapack = bootstrapRoot.resolve(".datapack-stage-" + UUID.randomUUID());
move(canonicalOutput, stagedDatapack, false);
compileContainer = null;
datapackBackup = replaceWithBackup(stagedDatapack, datapackRoot);
datapackReplaced = true;
}
validatePackRoot(packRoot);
if (!isDatapackRoot(datapackRoot)) {
throw new IOException("Bootstrap datapack output is incomplete at " + datapackRoot);
}
String finalPackFingerprint = directoryFingerprint(packRoot);
String finalAggregateFingerprint = packRootsFingerprint(
IrisDatapackCompiler.collectPackRoots(normalizedData, options.levelRoot())
);
String finalDatapackFingerprint = directoryFingerprint(datapackRoot);
Properties marker = new Properties();
marker.setProperty("schema", Integer.toString(MARKER_SCHEMA));
marker.setProperty("source", options.source().toString());
marker.setProperty("sourceSha256", archive.sha256());
marker.setProperty("managedDefault", Boolean.toString(managedDefault));
marker.setProperty("defaultPackFingerprint", finalPackFingerprint);
marker.setProperty("aggregateFingerprint", finalAggregateFingerprint);
marker.setProperty("datapackFingerprint", finalDatapackFingerprint);
marker.setProperty("completedAt", Long.toString(options.clock().millis()));
storePropertiesAtomic(markerFile, marker);
PROVISIONED_THIS_STARTUP.set(true);
deleteQuietly(packBackup, feedback);
deleteQuietly(datapackBackup, feedback);
ProvisionStatus status;
if (!existingPack && !existingDatapack) {
status = ProvisionStatus.INSTALLED;
} else if (replacePack || rebuildDatapack) {
status = ProvisionStatus.UPDATED;
} else {
status = ProvisionStatus.UNCHANGED;
}
feedback.accept("Iris bootstrap pack is " + status.name().toLowerCase() + ".");
return new ProvisionResult(packRoot, datapackRoot, status);
} catch (IOException failure) {
IOException rollbackFailure = rollback(packRoot, packBackup, packReplaced, datapackRoot, datapackBackup, datapackReplaced);
if (rollbackFailure != null) {
failure.addSuppressed(rollbackFailure);
}
throw failure;
} catch (RuntimeException | LinkageError failure) {
IOException rollbackFailure = rollback(packRoot, packBackup, packReplaced, datapackRoot, datapackBackup, datapackReplaced);
if (rollbackFailure != null) {
failure.addSuppressed(rollbackFailure);
}
throw new IOException("Iris bootstrap provisioning failed", failure);
} finally {
deleteQuietly(stagedPack, feedback);
deleteQuietly(stagedDatapack, feedback);
deleteQuietly(extractionRoot, feedback);
deleteQuietly(compileContainer, feedback);
}
}
private static Archive acquireArchive(
Path cacheRoot,
Properties marker,
Consumer<String> feedback,
ProvisionOptions options
) throws IOException {
Path archivePath = cacheRoot.resolve("default-overworld.zip");
Path metadataPath = cacheRoot.resolve("default-overworld.properties");
Properties metadata = Files.isRegularFile(metadataPath) ? loadProperties(metadataPath) : new Properties();
boolean validCache = false;
if (Files.isRegularFile(archivePath)) {
try {
validCache = validateArchive(archivePath);
} catch (IOException exception) {
feedback.accept("Cached default overworld archive is invalid; downloading a replacement.");
}
}
long fetchedAt = parseLong(metadata.getProperty("fetchedAt"), 0L);
boolean fresh = validCache && options.clock().millis() - fetchedAt < options.refreshInterval().toMillis();
if (fresh) {
return new Archive(archivePath, sha256(archivePath));
}
IOException networkFailure = null;
for (int attempt = 1; attempt <= options.attempts(); attempt++) {
try {
HttpRequest.Builder request = HttpRequest.newBuilder(options.source())
.timeout(options.requestTimeout())
.header("Accept", "application/octet-stream")
.header("User-Agent", "Iris-Bootstrap")
.GET();
if (validCache) {
String etag = metadata.getProperty("etag");
String lastModified = metadata.getProperty("lastModified");
if (etag != null && !etag.isBlank()) {
request.header("If-None-Match", etag);
}
if (lastModified != null && !lastModified.isBlank()) {
request.header("If-Modified-Since", lastModified);
}
}
HttpResponse<InputStream> response = options.client().send(request.build(), HttpResponse.BodyHandlers.ofInputStream());
int status = response.statusCode();
if (status == 304 && validCache) {
close(response.body());
metadata.setProperty("fetchedAt", Long.toString(options.clock().millis()));
storePropertiesAtomic(metadataPath, metadata);
return new Archive(archivePath, sha256(archivePath));
}
if (status == 200) {
Path temporary = cacheRoot.resolve(".download-" + UUID.randomUUID() + ".zip");
try {
try (InputStream input = response.body(); OutputStream output = Files.newOutputStream(temporary)) {
copyLimited(input, output, options.maxArchiveBytes());
}
if (!validateArchive(temporary)) {
throw new IOException("Downloaded default overworld archive is invalid");
}
move(temporary, archivePath, true);
} finally {
Files.deleteIfExists(temporary);
}
Properties updated = new Properties();
response.headers().firstValue("etag").ifPresent(value -> updated.setProperty("etag", value));
response.headers().firstValue("last-modified").ifPresent(value -> updated.setProperty("lastModified", value));
updated.setProperty("fetchedAt", Long.toString(options.clock().millis()));
updated.setProperty("sha256", sha256(archivePath));
storePropertiesAtomic(metadataPath, updated);
feedback.accept("Downloaded the Iris default overworld beta archive.");
return new Archive(archivePath, updated.getProperty("sha256"));
}
close(response.body());
IOException statusFailure = new IOException("Default overworld download returned HTTP " + status);
if (!retryableStatus(status)) {
networkFailure = statusFailure;
break;
}
networkFailure = statusFailure;
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new IOException("Default overworld download was interrupted", exception);
} catch (IOException exception) {
networkFailure = exception;
}
if (attempt < options.attempts()) {
pause(options.retryDelay().multipliedBy(attempt));
}
}
if (validCache) {
feedback.accept("Default overworld download failed; using the validated cached archive.");
return new Archive(archivePath, sha256(archivePath));
}
if (isProvisionedOutputUsable(marker, cacheRoot.getParent().getParent())) {
String sourceSha = marker.getProperty("sourceSha256");
return new Archive(null, sourceSha);
}
throw networkFailure == null
? new IOException("Default overworld archive is unavailable and no valid cache exists")
: new IOException("Default overworld archive is unavailable and no valid cache exists", networkFailure);
}
private static boolean isProvisionedOutputUsable(Properties marker, Path dataDirectory) {
String sourceSha = marker.getProperty("sourceSha256");
return sourceSha != null && !sourceSha.isBlank() && isProvisioned(dataDirectory);
}
static Path resolveLevelRoot(Path serverRoot) throws IOException {
Path normalizedServerRoot = serverRoot.toAbsolutePath().normalize();
String levelName = readConfiguredLevelName(normalizedServerRoot);
Path configured = Path.of(levelName);
return configured.isAbsolute()
? configured.normalize()
: normalizedServerRoot.resolve(configured).normalize();
}
private static String readConfiguredLevelName(Path serverRoot) throws IOException {
String levelName = "world";
Path propertiesFile = serverRoot.resolve("server.properties");
if (Files.isRegularFile(propertiesFile)) {
Properties properties = loadProperties(propertiesFile);
levelName = properties.getProperty("level-name", levelName);
}
String[] arguments = ProcessHandle.current().info().arguments().orElse(new String[0]);
for (int index = 0; index < arguments.length; index++) {
String argument = arguments[index];
String following = index + 1 < arguments.length ? arguments[index + 1] : null;
String parsed = parseLevelArgument(argument, following);
if (parsed != null) {
levelName = parsed;
}
}
if (levelName.isBlank()) {
throw new IOException("Configured level name is empty");
}
return levelName;
}
private static String parseLevelArgument(String argument, String following) {
for (String key : List.of("-w", "--level-name", "--world")) {
if (argument.equals(key) && following != null && !following.isBlank()) {
return following;
}
String prefix = key + "=";
if (argument.startsWith(prefix) && argument.length() > prefix.length()) {
return argument.substring(prefix.length());
}
}
return null;
}
private static boolean validateArchive(Path archive) throws IOException {
int entries = 0;
long expanded = 0L;
boolean dimensionFound = false;
Set<String> paths = new HashSet<>();
try (InputStream input = Files.newInputStream(archive); ZipInputStream zip = new ZipInputStream(input)) {
ZipEntry entry;
while ((entry = zip.getNextEntry()) != null) {
entries++;
if (entries > MAX_ARCHIVE_ENTRIES) {
throw new IOException("Default overworld archive contains too many entries");
}
String name = normalizedZipEntry(entry.getName());
if (!paths.add(name)) {
throw new IOException("Default overworld archive contains duplicate path " + name);
}
if (name.equals("dimensions/overworld.json") || name.endsWith("/dimensions/overworld.json")) {
dimensionFound = true;
}
if (!entry.isDirectory()) {
byte[] buffer = new byte[8192];
int read;
while ((read = zip.read(buffer)) >= 0) {
expanded += read;
if (expanded > MAX_EXPANDED_BYTES) {
throw new IOException("Default overworld archive expands beyond the safety limit");
}
}
}
zip.closeEntry();
}
}
return entries > 0 && dimensionFound;
}
private static Path extractArchive(Path archive, Path extractionRoot) throws IOException {
if (archive == null) {
throw new IOException("Cached default pack archive is unavailable for required pack rebuild");
}
long expanded = 0L;
int entries = 0;
try (InputStream input = Files.newInputStream(archive); ZipInputStream zip = new ZipInputStream(input)) {
ZipEntry entry;
while ((entry = zip.getNextEntry()) != null) {
entries++;
if (entries > MAX_ARCHIVE_ENTRIES) {
throw new IOException("Default overworld archive contains too many entries");
}
String name = normalizedZipEntry(entry.getName());
Path output = extractionRoot.resolve(name).normalize();
if (!output.startsWith(extractionRoot)) {
throw new IOException("Unsafe path in default overworld archive: " + entry.getName());
}
if (entry.isDirectory()) {
Files.createDirectories(output);
} else {
Files.createDirectories(output.getParent());
try (OutputStream file = Files.newOutputStream(output)) {
byte[] buffer = new byte[8192];
int read;
while ((read = zip.read(buffer)) >= 0) {
expanded += read;
if (expanded > MAX_EXPANDED_BYTES) {
throw new IOException("Default overworld archive expands beyond the safety limit");
}
file.write(buffer, 0, read);
}
}
}
zip.closeEntry();
}
}
if (isPackRoot(extractionRoot)) {
return extractionRoot;
}
List<Path> candidates = new ArrayList<>();
try (DirectoryStream<Path> children = Files.newDirectoryStream(extractionRoot)) {
for (Path child : children) {
if (Files.isDirectory(child) && isPackRoot(child)) {
candidates.add(child);
}
}
}
if (candidates.size() != 1) {
throw new IOException("Default overworld archive has an invalid root layout");
}
return candidates.getFirst();
}
private static String normalizedZipEntry(String raw) throws IOException {
if (raw == null || raw.isBlank() || raw.indexOf('\0') >= 0 || raw.startsWith("/") || raw.startsWith("\\")) {
throw new IOException("Invalid path in default overworld archive");
}
String normalized = raw.replace('\\', '/');
Path path = Path.of(normalized).normalize();
if (path.isAbsolute() || path.startsWith("..") || normalized.matches("^[A-Za-z]:.*")) {
throw new IOException("Unsafe path in default overworld archive: " + raw);
}
return path.toString().replace('\\', '/');
}
private static boolean isPackRoot(Path path) {
return path != null && Files.isRegularFile(path.resolve("dimensions/overworld.json"));
}
private static void validatePackRoot(Path path) throws IOException {
if (!isPackRoot(path)) {
throw new IOException("Default overworld pack is missing dimensions/overworld.json at " + path);
}
}
private static boolean isDatapackRoot(Path path) {
return path != null
&& Files.isRegularFile(path.resolve("pack.mcmeta"))
&& Files.isDirectory(path.resolve("data/iris/dimension_type"));
}
private static String packRootsFingerprint(List<File> packRoots) throws IOException {
MessageDigest digest = digest();
List<Path> roots = packRoots.stream()
.map(File::toPath)
.map(path -> path.toAbsolutePath().normalize())
.distinct()
.sorted(Comparator.comparing(Path::toString))
.toList();
for (Path root : roots) {
digest.update(root.toString().getBytes(StandardCharsets.UTF_8));
digest.update(directoryFingerprint(root).getBytes(StandardCharsets.UTF_8));
}
return HexFormat.of().formatHex(digest.digest());
}
private static String directoryFingerprint(Path root) throws IOException {
if (!Files.isDirectory(root)) {
return "";
}
Path scanRoot = root.toRealPath();
MessageDigest digest = digest();
try (Stream<Path> stream = Files.walk(scanRoot)) {
List<Path> files = stream.filter(Files::isRegularFile)
.sorted(Comparator.comparing(path -> scanRoot.relativize(path).toString()))
.toList();
byte[] buffer = new byte[8192];
for (Path file : files) {
String relative = scanRoot.relativize(file).toString().replace('\\', '/');
digest.update(relative.getBytes(StandardCharsets.UTF_8));
try (InputStream input = Files.newInputStream(file)) {
int read;
while ((read = input.read(buffer)) >= 0) {
digest.update(buffer, 0, read);
}
}
}
}
return HexFormat.of().formatHex(digest.digest());
}
private static String sha256(Path file) throws IOException {
MessageDigest digest = digest();
byte[] buffer = new byte[8192];
try (InputStream input = Files.newInputStream(file)) {
int read;
while ((read = input.read(buffer)) >= 0) {
digest.update(buffer, 0, read);
}
}
return HexFormat.of().formatHex(digest.digest());
}
private static MessageDigest digest() {
try {
return MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 is unavailable", exception);
}
}
private static Path replaceWithBackup(Path staged, Path target) throws IOException {
Path backup = target.resolveSibling("." + target.getFileName() + "-backup-" + UUID.randomUUID());
if (Files.exists(target)) {
move(target, backup, false);
} else {
backup = null;
}
try {
move(staged, target, false);
return backup;
} catch (IOException failure) {
if (backup != null && Files.exists(backup)) {
move(backup, target, false);
}
throw failure;
}
}
private static IOException rollback(
Path packRoot,
Path packBackup,
boolean packReplaced,
Path datapackRoot,
Path datapackBackup,
boolean datapackReplaced
) {
IOException failure = null;
try {
restore(packRoot, packBackup, packReplaced);
} catch (IOException exception) {
failure = exception;
}
try {
restore(datapackRoot, datapackBackup, datapackReplaced);
} catch (IOException exception) {
if (failure == null) {
failure = exception;
} else {
failure.addSuppressed(exception);
}
}
return failure;
}
private static void restore(Path target, Path backup, boolean replaced) throws IOException {
if (!replaced) {
return;
}
delete(target);
if (backup != null && Files.exists(backup)) {
move(backup, target, false);
}
}
private static void copyDirectory(Path source, Path target) throws IOException {
try (Stream<Path> stream = Files.walk(source)) {
for (Path entry : stream.toList()) {
Path relative = source.relativize(entry);
Path output = target.resolve(relative);
if (Files.isDirectory(entry)) {
Files.createDirectories(output);
} else if (Files.isRegularFile(entry)) {
Files.createDirectories(output.getParent());
Files.copy(entry, output, StandardCopyOption.COPY_ATTRIBUTES);
}
}
}
}
private static void move(Path source, Path target, boolean replace) throws IOException {
if (source == null) {
return;
}
Files.createDirectories(target.getParent());
try {
if (replace) {
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} else {
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE);
}
} catch (AtomicMoveNotSupportedException exception) {
if (replace) {
Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);
} else {
Files.move(source, target);
}
}
}
private static void delete(Path path) throws IOException {
if (path == null || !Files.exists(path)) {
return;
}
try (Stream<Path> stream = Files.walk(path)) {
List<Path> entries = stream.sorted(Comparator.reverseOrder()).toList();
for (Path entry : entries) {
Files.deleteIfExists(entry);
}
}
}
private static void deleteQuietly(Path path, Consumer<String> feedback) {
try {
delete(path);
} catch (IOException exception) {
feedback.accept("Unable to clean temporary Iris bootstrap path " + path + ": " + exception.getMessage());
}
}
private static void copyLimited(InputStream input, OutputStream output, long limit) throws IOException {
byte[] buffer = new byte[8192];
long total = 0L;
int read;
while ((read = input.read(buffer)) >= 0) {
total += read;
if (total > limit) {
throw new IOException("Default overworld archive exceeds the download size limit");
}
output.write(buffer, 0, read);
}
}
private static Properties loadProperties(Path path) throws IOException {
Properties properties = new Properties();
try (InputStream input = Files.newInputStream(path)) {
properties.load(input);
}
return properties;
}
private static void storePropertiesAtomic(Path path, Properties properties) throws IOException {
Files.createDirectories(path.getParent());
Path temporary = Files.createTempFile(path.getParent(), path.getFileName().toString(), ".tmp");
try {
try (OutputStream output = Files.newOutputStream(temporary)) {
properties.store(output, null);
}
move(temporary, path, true);
} finally {
Files.deleteIfExists(temporary);
}
}
private static boolean retryableStatus(int status) {
return status == 408 || status == 425 || status == 429 || status >= 500;
}
private static void pause(Duration duration) throws IOException {
if (duration.isZero() || duration.isNegative()) {
return;
}
try {
Thread.sleep(duration.toMillis());
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new IOException("Default overworld retry wait was interrupted", exception);
}
}
private static long parseLong(String value, long fallback) {
if (value == null) {
return fallback;
}
try {
return Long.parseLong(value);
} catch (NumberFormatException exception) {
return fallback;
}
}
private static void close(InputStream input) {
try {
input.close();
} catch (IOException ignored) {
}
}
public enum ProvisionStatus {
INSTALLED,
UPDATED,
UNCHANGED
}
public record ProvisionResult(Path packRoot, Path datapackRoot, ProvisionStatus status) {
public ProvisionResult {
Objects.requireNonNull(packRoot, "packRoot");
Objects.requireNonNull(datapackRoot, "datapackRoot");
Objects.requireNonNull(status, "status");
}
}
record ProvisionOptions(
URI source,
HttpClient client,
Clock clock,
Duration refreshInterval,
Duration requestTimeout,
int attempts,
Duration retryDelay,
long maxArchiveBytes,
Path levelRoot
) {
ProvisionOptions {
Objects.requireNonNull(source, "source");
Objects.requireNonNull(client, "client");
Objects.requireNonNull(clock, "clock");
Objects.requireNonNull(refreshInterval, "refreshInterval");
Objects.requireNonNull(requestTimeout, "requestTimeout");
Objects.requireNonNull(retryDelay, "retryDelay");
Objects.requireNonNull(levelRoot, "levelRoot");
if (attempts < 1 || maxArchiveBytes < 1L) {
throw new IllegalArgumentException("Invalid bootstrap provisioning options");
}
}
}
private record Archive(Path path, String sha256) {
private Archive {
Objects.requireNonNull(sha256, "sha256");
}
}
}
@@ -33,6 +33,9 @@ import java.util.function.Consumer;
import java.util.regex.Pattern;
public final class PackDownloader {
private static final String DEFAULT_OVERWORLD_PACK = "overworld";
private static final String DEFAULT_OVERWORLD_REPOSITORY = "IrisDimensions/overworld";
private static final String DEFAULT_OVERWORLD_RELEASE_URL = "https://github.com/IrisDimensions/overworld/releases/download/beta/overworld.zip";
private static final Pattern GITHUB_REPOSITORY = Pattern.compile("[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+");
private static final Pattern GITHUB_REF = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._/-]*");
private static final Pattern COMMIT_SHA = Pattern.compile("[0-9a-fA-F]{40}");
@@ -40,6 +43,14 @@ public final class PackDownloader {
private PackDownloader() {
}
public static boolean isDefaultOverworld(String pack) {
return DEFAULT_OVERWORLD_PACK.equals(pack);
}
public static String downloadDefaultOverworld(File packsFolder, boolean forceOverwrite, Consumer<String> feedback) throws IOException {
return download(packsFolder, DEFAULT_OVERWORLD_REPOSITORY, defaultOverworldReleaseUrl(), forceOverwrite, true, feedback);
}
public static String download(File packsFolder, String repo, String ref, boolean forceOverwrite, boolean directUrl, Consumer<String> feedback) throws IOException {
String url = directUrl ? ref : resolveGithubArchiveUrl(repo, ref);
feedback.accept("Downloading " + url + " "); //The extra space stops a bug in adventure API from repeating the last letter of the URL
@@ -170,6 +181,10 @@ public final class PackDownloader {
return "https://codeload.github.com/" + repo + "/zip/" + qualifiedRef;
}
static String defaultOverworldReleaseUrl() {
return DEFAULT_OVERWORLD_RELEASE_URL;
}
private static void validateGithubRef(String qualifiedRef) {
String refPath = qualifiedRef.startsWith("refs/heads/")
? qualifiedRef.substring("refs/heads/".length())
@@ -37,6 +37,7 @@ import art.arcane.iris.core.tools.IrisToolbelt;
import art.arcane.iris.engine.data.cache.AtomicCache;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.volmlib.util.collection.KMap;
import art.arcane.volmlib.util.exceptions.IrisException;
import art.arcane.volmlib.util.io.IO;
@@ -72,9 +73,9 @@ public class StudioSVC implements IrisService {
File f = IrisPack.packsPack(pack);
if (!f.exists()) {
if (pack.equals("overworld")) {
IrisLogging.info("Downloading Default Pack " + pack + " (latest on master)");
IrisServices.get(StudioSVC.class).downloadBranch(art.arcane.iris.platform.bukkit.BukkitPlatform.console(), "IrisDimensions/overworld", "master", false);
if (PackDownloader.isDefaultOverworld(pack)) {
IrisLogging.info("Downloading Default Pack " + pack + " (beta release)");
IrisServices.get(StudioSVC.class).downloadDefaultOverworld(BukkitPlatform.console(), false);
ServerConfigurator.installDataPacksIfChanged(true);
} else {
IrisLogging.warn("Default pack '" + pack + "' is not installed. Please download it manually with /iris download " + pack);
@@ -244,13 +245,16 @@ public class StudioSVC implements IrisService {
}
}
public void downloadRelease(VolmitSender sender, String url, boolean forceOverwrite) {
public void downloadDefaultOverworld(VolmitSender sender, boolean forceOverwrite) {
try {
download(sender, "IrisDimensions", url, forceOverwrite, true);
String key = PackDownloader.downloadDefaultOverworld(getWorkspaceFolder(), forceOverwrite, sender::sendMessage);
if (key != null) {
ServerConfigurator.installDataPacks(true);
}
} catch (Throwable e) {
IrisLogging.reportError(e);
e.printStackTrace();
sender.sendMessage("Failed to download 'IrisDimensions/overworld' from " + url + ".");
sender.sendMessage("Failed to download the IrisDimensions/overworld beta release.");
}
}
@@ -18,10 +18,8 @@
package art.arcane.iris.engine.object;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.ServerConfigurator.DimensionHeight;
import art.arcane.iris.core.IrisDatapackCompiler.DimensionHeight;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.iris.core.loader.IrisRegistrant;
@@ -508,7 +506,7 @@ public class IrisDimension extends IrisRegistrant {
return landBiomeStyle;
}
public void installBiomes(IDataFixer fixer, DataProvider data, KList<File> folders, KSet<String> biomes) {
public void installBiomes(IDataFixer fixer, DataProvider data, KList<File> datapackRoots, KSet<String> biomes) throws IOException {
String namespace = getLoadKey().toLowerCase(Locale.ROOT);
for (IrisBiome irisBiome : getAllBiomes(data)) {
@@ -527,26 +525,21 @@ public class IrisDimension extends IrisRegistrant {
}
}
for (File datapacks : folders) {
File output = new File(datapacks, "iris/data/" + namespace + "/worldgen/biome/" + customBiomeId + ".json");
for (File datapackRoot : datapackRoots) {
File output = new File(datapackRoot, "data/" + namespace + "/worldgen/biome/" + customBiomeId + ".json");
IrisLogging.debug(" Installing Data Pack Biome: " + output.getPath());
output.getParentFile().mkdirs();
try {
IO.writeAll(output, json);
installBiomeTags(datapacks, namespace + ":" + customBiomeId, customBiome.getTags());
} catch (IOException e) {
IrisLogging.reportError(e);
e.printStackTrace();
}
IO.writeAll(output, json);
installBiomeTags(datapackRoot, namespace + ":" + customBiomeId, customBiome.getTags());
}
}
}
}
public static void clearGeneratedBiomeTags(KList<File> folders) {
for (File datapacks : folders) {
File dataFolder = new File(datapacks, "iris/data");
public static void clearGeneratedBiomeTags(KList<File> datapackRoots) {
for (File datapackRoot : datapackRoots) {
File dataFolder = new File(datapackRoot, "data");
File[] namespaces = dataFolder.listFiles(File::isDirectory);
if (namespaces == null) {
continue;
@@ -557,7 +550,7 @@ public class IrisDimension extends IrisRegistrant {
}
}
static void installBiomeTags(File datapacks, String biomeKey, KList<String> tags) throws IOException {
static void installBiomeTags(File datapackRoot, String biomeKey, KList<String> tags) throws IOException {
if (tags == null || tags.isEmpty()) {
return;
}
@@ -570,7 +563,7 @@ public class IrisDimension extends IrisRegistrant {
int separator = tag.indexOf(':');
String namespace = tag.substring(0, separator);
String path = tag.substring(separator + 1);
Path tagRoot = new File(datapacks, "iris/data/" + namespace + "/tags/worldgen/biome").toPath()
Path tagRoot = new File(datapackRoot, "data/" + namespace + "/tags/worldgen/biome").toPath()
.toAbsolutePath().normalize();
Path output = tagRoot.resolve(path + ".json").normalize();
if (!output.startsWith(tagRoot)) {
@@ -677,21 +670,16 @@ public class IrisDimension extends IrisRegistrant {
return upperDimension != null && !upperDimension.isEmpty() && !upperDimension.equalsIgnoreCase("none");
}
public void installDimensionType(IDataFixer fixer, KList<File> folders) {
public void installDimensionType(IDataFixer fixer, KList<File> datapackRoots) throws IOException {
IrisDimensionType type = getDimensionType();
String json = type.toJson(fixer);
String dimensionTypeKey = getDimensionTypeKey();
IrisLogging.debug(" Installing Data Pack Dimension Type: \"iris:" + dimensionTypeKey + '"');
for (File datapacks : folders) {
File output = new File(datapacks, "iris/data/iris/dimension_type/" + dimensionTypeKey + ".json");
for (File datapackRoot : datapackRoots) {
File output = new File(datapackRoot, "data/iris/dimension_type/" + dimensionTypeKey + ".json");
output.getParentFile().mkdirs();
try {
IO.writeAll(output, json);
} catch (IOException e) {
IrisLogging.reportError(e);
e.printStackTrace();
}
IO.writeAll(output, json);
}
}
@@ -710,13 +698,18 @@ public class IrisDimension extends IrisRegistrant {
}
public static void writeShared(KList<File> folders, DimensionHeight height) {
public static void writeShared(
KList<File> datapackRoots,
DimensionHeight height,
int packFormat,
boolean adjustVanillaHeight
) throws IOException {
IrisLogging.debug(" Installing Data Pack Vanilla Dimension Types");
String[] jsonStrings = height.jsonStrings();
for (File datapacks : folders) {
write(datapacks, "overworld", jsonStrings[0]);
write(datapacks, "the_nether", jsonStrings[1]);
write(datapacks, "the_end", jsonStrings[2]);
for (File datapackRoot : datapackRoots) {
write(datapackRoot, "overworld", jsonStrings[0], adjustVanillaHeight);
write(datapackRoot, "the_nether", jsonStrings[1], adjustVanillaHeight);
write(datapackRoot, "the_end", jsonStrings[2], adjustVanillaHeight);
}
String raw = """
@@ -728,32 +721,24 @@ public class IrisDimension extends IrisRegistrant {
"max_format": {}
}
}
""".replace("{}", BukkitPlatform.dataPackFormat() + "");
""".replace("{}", Integer.toString(packFormat));
for (File datapacks : folders) {
File mcm = new File(datapacks, "iris/pack.mcmeta");
try {
IO.writeAll(mcm, raw);
} catch (IOException e) {
IrisLogging.reportError(e);
e.printStackTrace();
}
for (File datapackRoot : datapackRoots) {
File mcm = new File(datapackRoot, "pack.mcmeta");
IO.writeAll(mcm, raw);
IrisLogging.debug(" Installing Data Pack MCMeta: " + mcm.getPath());
}
}
private static void write(File datapacks, String type, String json) {
if (json == null) return;
File dimTypeVanilla = new File(datapacks, "iris/data/minecraft/dimension_type/" + type + ".json");
private static void write(File datapackRoot, String type, String json, boolean adjustVanillaHeight) throws IOException {
if (json == null) {
return;
}
File dimTypeVanilla = new File(datapackRoot, "data/minecraft/dimension_type/" + type + ".json");
if (IrisSettings.get().getGeneral().adjustVanillaHeight || dimTypeVanilla.exists()) {
if (adjustVanillaHeight || dimTypeVanilla.exists()) {
dimTypeVanilla.getParentFile().mkdirs();
try {
IO.writeAll(dimTypeVanilla, json);
} catch (IOException e) {
IrisLogging.reportError(e);
e.printStackTrace();
}
IO.writeAll(dimTypeVanilla, json);
}
}
}
@@ -0,0 +1,110 @@
package art.arcane.iris.core;
import art.arcane.iris.core.nms.datapack.v1217.DataFixerV1217;
import art.arcane.volmlib.util.collection.KList;
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.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public class IrisDatapackCompilerTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void compilesInstalledAndWorldLocalPacksIntoOneDatapack() throws Exception {
Path dataDirectory = temporaryFolder.newFolder("data").toPath();
Path serverRoot = temporaryFolder.newFolder("server").toPath();
createPack(dataDirectory.resolve("packs/alpha"), "alpha", "alpha_custom");
createPack(dataDirectory.resolve("packs/beta"), "beta", "beta_custom");
createPack(serverRoot.resolve("dimensions/example/world/iris/pack"), "world_local", "world_custom");
List<File> packRoots = IrisDatapackCompiler.collectPackRoots(dataDirectory, serverRoot);
Path datapackRoot = temporaryFolder.newFolder("datapack").toPath();
IrisDatapackCompiler.CompilationResult result = IrisDatapackCompiler.compile(
packRoots,
new KList<File>().qadd(datapackRoot.toFile()),
new DataFixerV1217(),
107,
false
);
assertEquals(3, result.packCount());
assertEquals(3, result.dimensionCount());
assertEquals(3, result.biomeCount());
assertTrue(Files.isRegularFile(datapackRoot.resolve("pack.mcmeta")));
assertTrue(Files.isRegularFile(datapackRoot.resolve("data/iris/dimension_type/alpha.json")));
assertTrue(Files.isRegularFile(datapackRoot.resolve("data/iris/dimension_type/beta.json")));
assertTrue(Files.isRegularFile(datapackRoot.resolve("data/iris/dimension_type/world_local.json")));
assertTrue(Files.isRegularFile(datapackRoot.resolve("data/alpha/worldgen/biome/alpha_custom.json")));
assertTrue(Files.isRegularFile(datapackRoot.resolve("data/beta/worldgen/biome/beta_custom.json")));
assertTrue(Files.isRegularFile(datapackRoot.resolve("data/world_local/worldgen/biome/world_custom.json")));
}
@Test
public void compilesExternalPackFixtureWhenConfigured() throws Exception {
String configuredPack = System.getenv("IRIS_TEST_PACK");
Assume.assumeTrue(configuredPack != null && !configuredPack.isBlank());
Path packRoot = Path.of(configuredPack).toAbsolutePath().normalize();
Assume.assumeTrue(Files.isDirectory(packRoot.resolve("dimensions")));
Path datapackRoot = temporaryFolder.newFolder("external-datapack").toPath();
IrisDatapackCompiler.CompilationResult result = IrisDatapackCompiler.compile(
List.of(packRoot.toFile()),
new KList<File>().qadd(datapackRoot.toFile()),
new DataFixerV1217(),
107,
false
);
assertTrue(result.packCount() > 0);
assertTrue(result.dimensionCount() > 0);
assertTrue(result.biomeCount() > 0);
assertTrue(Files.isRegularFile(datapackRoot.resolve("pack.mcmeta")));
}
private static void createPack(Path root, String dimensionKey, String biomeId) throws Exception {
Files.createDirectories(root.resolve("dimensions"));
Files.createDirectories(root.resolve("biomes"));
Files.writeString(
root.resolve("dimensions").resolve(dimensionKey + ".json"),
"""
{
"name": "Test Dimension",
"environment": "NORMAL",
"logicalHeight": 256,
"dimensionHeight": {
"min": -64,
"max": 320
}
}
""",
StandardCharsets.UTF_8
);
Files.writeString(
root.resolve("biomes/test.json"),
"""
{
"name": "Test Biome",
"derivative": "minecraft:plains",
"vanillaDerivative": "minecraft:plains",
"customDerivitives": [
{
"id": "%s"
}
]
}
""".formatted(biomeId),
StandardCharsets.UTF_8
);
}
}
@@ -0,0 +1,69 @@
package art.arcane.iris.core.loader;
import art.arcane.iris.engine.framework.MeteredCache;
import art.arcane.iris.engine.framework.PreservationRegistry;
import art.arcane.iris.spi.IrisServices;
import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.File;
import java.util.concurrent.ExecutorService;
import static org.junit.Assert.assertSame;
public class IrisDataDatapackCompilerCacheTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
private IrisData runtimeData;
private IrisData compilerData;
@Before
public void registerPreservationService() {
IrisServices.register(PreservationRegistry.class, new NoOpPreservationRegistry());
}
@After
public void closeDataAndServices() {
if (compilerData != null) {
compilerData.close();
}
if (runtimeData != null) {
runtimeData.close();
}
IrisServices.clear();
}
@Test
public void closingCompilerDoesNotEvictRuntimeDataForSameDirectory() throws Exception {
File packDirectory = temporaryFolder.newFolder("pack");
runtimeData = IrisData.get(packDirectory);
compilerData = IrisData.openDatapackCompiler(packDirectory);
compilerData.close();
compilerData = null;
assertSame(runtimeData, IrisData.getLoaded(packDirectory).orElseThrow());
}
private static final class NoOpPreservationRegistry implements PreservationRegistry {
@Override
public void register(Thread thread) {
}
@Override
public void register(ExecutorService service) {
}
@Override
public void registerCache(MeteredCache cache) {
}
@Override
public void dereference() {
}
}
}
@@ -0,0 +1,389 @@
package art.arcane.iris.core.pack;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import org.junit.Test;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Properties;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class DefaultPackBootstrapProvisionerTest {
@Test
public void coldInstallUsesFreshCacheWithoutSecondRequest() throws Exception {
byte[] archive = packArchive("overworld", "bootstrap_biome");
AtomicInteger requests = new AtomicInteger();
HttpServer server = server(archive, requests);
Path root = Files.createTempDirectory("iris-bootstrap-cold");
try {
Path dataDirectory = root.resolve("plugins/Iris");
DefaultPackBootstrapProvisioner.ProvisionOptions options = options(server, root, Duration.ofHours(1));
DefaultPackBootstrapProvisioner.ProvisionResult installed = DefaultPackBootstrapProvisioner.provision(
dataDirectory,
ignored -> {
},
options
);
DefaultPackBootstrapProvisioner.ProvisionResult unchanged = DefaultPackBootstrapProvisioner.provision(
dataDirectory,
ignored -> {
},
options
);
assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.INSTALLED, installed.status());
assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.UNCHANGED, unchanged.status());
assertEquals(1, requests.get());
assertTrue(Files.isRegularFile(installed.packRoot().resolve("dimensions/overworld.json")));
assertTrue(Files.isRegularFile(installed.datapackRoot().resolve("pack.mcmeta")));
assertTrue(Files.isRegularFile(installed.datapackRoot().resolve("data/overworld/worldgen/biome/bootstrap_biome.json")));
assertTrue(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory));
assertTrue(DefaultPackBootstrapProvisioner.wasProvisionedThisStartup());
} finally {
server.stop(0);
delete(root);
}
}
@Test
public void corruptCacheRedownloadsAndRepairsArchive() throws Exception {
byte[] archive = packArchive("overworld", "bootstrap_biome");
AtomicInteger requests = new AtomicInteger();
HttpServer server = server(archive, requests);
Path root = Files.createTempDirectory("iris-bootstrap-corrupt-cache");
try {
Path dataDirectory = root.resolve("plugins/Iris");
DefaultPackBootstrapProvisioner.ProvisionOptions options = options(server, root, Duration.ofHours(1));
DefaultPackBootstrapProvisioner.provision(dataDirectory, ignored -> {
}, options);
Path cache = dataDirectory.resolve("cache/bootstrap/default-overworld.zip");
Files.writeString(cache, "corrupt", StandardCharsets.UTF_8);
DefaultPackBootstrapProvisioner.provision(dataDirectory, ignored -> {
}, options);
assertEquals(2, requests.get());
try (ZipInputStream zip = new ZipInputStream(Files.newInputStream(cache))) {
assertTrue(zip.getNextEntry() != null);
}
} finally {
server.stop(0);
delete(root);
}
}
@Test
public void traversalArchiveFailsWithoutMarkerOrEscape() throws Exception {
LinkedHashMap<String, String> files = new LinkedHashMap<>();
files.put("dimensions/overworld.json", dimensionJson("overworld"));
files.put("../escape.txt", "unsafe");
AtomicInteger requests = new AtomicInteger();
HttpServer server = server(zip(files), requests);
Path root = Files.createTempDirectory("iris-bootstrap-traversal");
try {
Path dataDirectory = root.resolve("plugins/Iris");
DefaultPackBootstrapProvisioner.ProvisionOptions options = options(server, root, Duration.ZERO);
assertThrows(IOException.class, () -> DefaultPackBootstrapProvisioner.provision(
dataDirectory,
ignored -> {
},
options
));
assertFalse(Files.exists(dataDirectory.resolve("bootstrap/provisioned.properties")));
assertFalse(Files.exists(root.resolve("escape.txt")));
} finally {
server.stop(0);
delete(root);
}
}
@Test
public void missingDimensionLayoutFailsWithoutInstallingPack() throws Exception {
AtomicInteger requests = new AtomicInteger();
HttpServer server = server(zip(Map.of("README.md", "not an Iris pack")), requests);
Path root = Files.createTempDirectory("iris-bootstrap-layout");
try {
Path dataDirectory = root.resolve("plugins/Iris");
DefaultPackBootstrapProvisioner.ProvisionOptions options = options(server, root, Duration.ZERO);
assertThrows(IOException.class, () -> DefaultPackBootstrapProvisioner.provision(
dataDirectory,
ignored -> {
},
options
));
assertFalse(Files.exists(dataDirectory.resolve("packs/overworld")));
assertFalse(Files.exists(dataDirectory.resolve("bootstrap/provisioned.properties")));
} finally {
server.stop(0);
delete(root);
}
}
@Test
public void existingSymlinkedPackIsCompiledWithoutDownloadOrReplacement() throws Exception {
AtomicInteger requests = new AtomicInteger();
HttpServer server = server(packArchive("remote", "remote_biome"), requests);
Path root = Files.createTempDirectory("iris-bootstrap-symlink");
try {
Path dataDirectory = root.resolve("plugins/Iris");
Path target = root.resolve("custom-overworld");
writePack(target, "overworld", "local_biome");
Files.createDirectories(dataDirectory.resolve("packs"));
Path link = dataDirectory.resolve("packs/overworld");
Files.createSymbolicLink(link, target);
DefaultPackBootstrapProvisioner.ProvisionOptions options = options(server, root, Duration.ZERO);
DefaultPackBootstrapProvisioner.ProvisionResult result = DefaultPackBootstrapProvisioner.provision(
dataDirectory,
ignored -> {
},
options
);
assertEquals(0, requests.get());
assertTrue(Files.isSymbolicLink(link));
assertEquals(target.toRealPath(), link.toRealPath());
assertTrue(Files.isRegularFile(result.datapackRoot().resolve("data/overworld/worldgen/biome/local_biome.json")));
Files.writeString(target.resolve("biomes/local.json"), biomeJson("changed_biome"), StandardCharsets.UTF_8);
assertFalse(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory));
DefaultPackBootstrapProvisioner.ProvisionResult updated = DefaultPackBootstrapProvisioner.provision(
dataDirectory,
ignored -> {
},
options
);
assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.UPDATED, updated.status());
assertEquals(0, requests.get());
assertTrue(Files.isSymbolicLink(link));
} finally {
server.stop(0);
delete(root);
}
}
@Test
public void additionalPackRebuildsAggregateWithoutNetwork() throws Exception {
AtomicInteger requests = new AtomicInteger();
HttpServer server = server(packArchive("overworld", "first_biome"), requests);
Path root = Files.createTempDirectory("iris-bootstrap-aggregate");
try {
Path dataDirectory = root.resolve("plugins/Iris");
DefaultPackBootstrapProvisioner.ProvisionOptions options = options(server, root, Duration.ofHours(1));
DefaultPackBootstrapProvisioner.provision(dataDirectory, ignored -> {
}, options);
writePack(dataDirectory.resolve("packs/second"), "second", "second_biome");
writePack(root.resolve("dimensions/example/world/iris/pack"), "world_local", "world_local_biome");
assertFalse(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory));
DefaultPackBootstrapProvisioner.ProvisionResult updated = DefaultPackBootstrapProvisioner.provision(
dataDirectory,
ignored -> {
},
options
);
assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.UPDATED, updated.status());
assertEquals(1, requests.get());
assertTrue(Files.isRegularFile(updated.datapackRoot().resolve("data/second/worldgen/biome/second_biome.json")));
assertTrue(Files.isRegularFile(updated.datapackRoot().resolve("data/world_local/worldgen/biome/world_local_biome.json")));
} finally {
server.stop(0);
delete(root);
}
}
@Test
public void localEditRelinquishesManagedOwnershipWithoutRemoteReplacement() throws Exception {
AtomicInteger requests = new AtomicInteger();
HttpServer server = server(packArchive("overworld", "managed_biome"), requests);
Path root = Files.createTempDirectory("iris-bootstrap-managed-edit");
try {
Path dataDirectory = root.resolve("plugins/Iris");
DefaultPackBootstrapProvisioner.ProvisionOptions initialOptions = options(server, root, Duration.ofHours(1));
DefaultPackBootstrapProvisioner.provision(dataDirectory, ignored -> {
}, initialOptions);
Path editedBiome = dataDirectory.resolve("packs/overworld/biomes/local.json");
Files.writeString(editedBiome, biomeJson("locally_edited_biome"), StandardCharsets.UTF_8);
DefaultPackBootstrapProvisioner.ProvisionOptions refreshOptions = options(server, root, Duration.ZERO);
DefaultPackBootstrapProvisioner.ProvisionResult updated = DefaultPackBootstrapProvisioner.provision(
dataDirectory,
ignored -> {
},
refreshOptions
);
assertEquals(1, requests.get());
assertTrue(Files.readString(editedBiome).contains("locally_edited_biome"));
assertTrue(Files.isRegularFile(updated.datapackRoot().resolve("data/overworld/worldgen/biome/locally_edited_biome.json")));
Properties marker = new Properties();
try (java.io.InputStream input = Files.newInputStream(dataDirectory.resolve("bootstrap/provisioned.properties"))) {
marker.load(input);
}
assertEquals("false", marker.getProperty("managedDefault"));
} finally {
server.stop(0);
delete(root);
}
}
@Test
public void failedAggregateCompilationPreservesPreviousOutputAndMarker() throws Exception {
AtomicInteger requests = new AtomicInteger();
HttpServer server = server(packArchive("overworld", "first_biome"), requests);
Path root = Files.createTempDirectory("iris-bootstrap-rollback");
try {
Path dataDirectory = root.resolve("plugins/Iris");
DefaultPackBootstrapProvisioner.ProvisionOptions options = options(server, root, Duration.ofHours(1));
DefaultPackBootstrapProvisioner.ProvisionResult first = DefaultPackBootstrapProvisioner.provision(
dataDirectory,
ignored -> {
},
options
);
byte[] marker = Files.readAllBytes(dataDirectory.resolve("bootstrap/provisioned.properties"));
byte[] metadata = Files.readAllBytes(first.datapackRoot().resolve("pack.mcmeta"));
Path invalidPack = dataDirectory.resolve("packs/invalid");
Files.createDirectories(invalidPack.resolve("dimensions"));
Files.writeString(invalidPack.resolve("dimensions/broken.json"), "{", StandardCharsets.UTF_8);
assertThrows(IOException.class, () -> DefaultPackBootstrapProvisioner.provision(
dataDirectory,
ignored -> {
},
options
));
assertTrue(java.util.Arrays.equals(marker, Files.readAllBytes(dataDirectory.resolve("bootstrap/provisioned.properties"))));
assertTrue(java.util.Arrays.equals(metadata, Files.readAllBytes(first.datapackRoot().resolve("pack.mcmeta"))));
} finally {
server.stop(0);
delete(root);
}
}
@Test
public void resolvesConfiguredLevelRootFromServerProperties() throws Exception {
Path serverRoot = Files.createTempDirectory("iris-bootstrap-level-root");
try {
Files.writeString(
serverRoot.resolve("server.properties"),
"level-name=levels/primary\n",
StandardCharsets.UTF_8
);
assertEquals(
serverRoot.resolve("levels/primary").normalize(),
DefaultPackBootstrapProvisioner.resolveLevelRoot(serverRoot)
);
} finally {
delete(serverRoot);
}
}
private static DefaultPackBootstrapProvisioner.ProvisionOptions options(
HttpServer server,
Path serverRoot,
Duration refreshInterval
) {
URI source = URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/overworld.zip");
return new DefaultPackBootstrapProvisioner.ProvisionOptions(
source,
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build(),
Clock.fixed(Instant.parse("2026-07-12T12:00:00Z"), ZoneOffset.UTC),
refreshInterval,
Duration.ofSeconds(2),
1,
Duration.ZERO,
8L * 1024L * 1024L,
serverRoot
);
}
private static HttpServer server(byte[] response, AtomicInteger requests) throws IOException {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/overworld.zip", exchange -> respond(exchange, response, requests));
server.start();
return server;
}
private static void respond(HttpExchange exchange, byte[] response, AtomicInteger requests) throws IOException {
requests.incrementAndGet();
exchange.getResponseHeaders().add("Content-Type", "application/zip");
exchange.sendResponseHeaders(200, response.length);
exchange.getResponseBody().write(response);
exchange.close();
}
private static byte[] packArchive(String dimensionKey, String biomeId) throws IOException {
LinkedHashMap<String, String> files = new LinkedHashMap<>();
files.put("dimensions/" + dimensionKey + ".json", dimensionJson(dimensionKey));
files.put("regions/local.json", "{\"name\":\"Local\",\"landBiomes\":[\"local\"]}");
files.put("biomes/local.json", biomeJson(biomeId));
return zip(files);
}
private static void writePack(Path root, String dimensionKey, String biomeId) throws IOException {
Files.createDirectories(root.resolve("dimensions"));
Files.createDirectories(root.resolve("regions"));
Files.createDirectories(root.resolve("biomes"));
Files.writeString(root.resolve("dimensions/" + dimensionKey + ".json"), dimensionJson(dimensionKey), StandardCharsets.UTF_8);
Files.writeString(root.resolve("regions/local.json"), "{\"name\":\"Local\",\"landBiomes\":[\"local\"]}", StandardCharsets.UTF_8);
Files.writeString(root.resolve("biomes/local.json"), biomeJson(biomeId), StandardCharsets.UTF_8);
}
private static String dimensionJson(String name) {
return "{\"name\":\"" + name + "\",\"regions\":[\"local\"],\"logicalHeight\":256,\"dimensionHeight\":{\"min\":-64,\"max\":320}}";
}
private static String biomeJson(String biomeId) {
return "{\"name\":\"Local\",\"derivative\":\"minecraft:plains\",\"customDerivitives\":[{\"id\":\""
+ biomeId + "\",\"category\":\"plains\"}]}";
}
private static byte[] zip(Map<String, String> files) throws IOException {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (ZipOutputStream zip = new ZipOutputStream(bytes)) {
for (Map.Entry<String, String> entry : files.entrySet()) {
zip.putNextEntry(new ZipEntry(entry.getKey()));
zip.write(entry.getValue().getBytes(StandardCharsets.UTF_8));
zip.closeEntry();
}
}
return bytes.toByteArray();
}
private static void delete(Path root) throws IOException {
if (!Files.exists(root)) {
return;
}
try (java.util.stream.Stream<Path> stream = Files.walk(root)) {
for (Path path : stream.sorted(java.util.Comparator.reverseOrder()).toList()) {
Files.deleteIfExists(path);
}
}
}
}
@@ -21,9 +21,27 @@ package art.arcane.iris.core.pack;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class PackDownloaderTest {
@Test
public void resolvesDefaultOverworldBetaRelease() {
assertEquals(
"https://github.com/IrisDimensions/overworld/releases/download/beta/overworld.zip",
PackDownloader.defaultOverworldReleaseUrl()
);
assertTrue(PackDownloader.isDefaultOverworld("overworld"));
}
@Test
public void rejectsOtherPacksAsDefaultOverworld() {
assertFalse(PackDownloader.isDefaultOverworld("theend"));
assertFalse(PackDownloader.isDefaultOverworld(""));
assertFalse(PackDownloader.isDefaultOverworld(null));
}
@Test
public void resolvesBranchReference() {
assertEquals(
@@ -41,7 +41,7 @@ public class IrisDimensionBiomeTagTest {
IrisDimension.installBiomeTags(temporaryFolder.getRoot(), "overworld:swamp", tags);
Path output = temporaryFolder.getRoot().toPath()
.resolve("iris/data/minecraft/tags/worldgen/biome/allows_surface_slime_spawns.json");
.resolve("data/minecraft/tags/worldgen/biome/allows_surface_slime_spawns.json");
JSONObject tag = new JSONObject(Files.readString(output, StandardCharsets.UTF_8));
assertEquals("overworld:swamp", tag.getJSONArray("values").getString(0));