diff --git a/adapters/bukkit/plugin/build.gradle b/adapters/bukkit/plugin/build.gradle index a66a60578..beedc1592 100644 --- a/adapters/bukkit/plugin/build.gradle +++ b/adapters/bukkit/plugin/build.gradle @@ -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) } } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/IrisBootstrap.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/IrisBootstrap.java new file mode 100644 index 000000000..101c8892a --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/IrisBootstrap.java @@ -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); + } + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandIris.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandIris.java index 76caf4eda..8dce173b3 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandIris.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandIris.java @@ -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); diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/CommandSVC.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/CommandSVC.java index 23ddd58a0..e03b7e1ba 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/CommandSVC.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/CommandSVC.java @@ -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 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 tabCompleteRoot(CommandSender sender, String alias, String[] args) { + List 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) { diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/PaperCommandRegistrar.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/PaperCommandRegistrar.java new file mode 100644 index 000000000..467cccf33 --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/PaperCommandRegistrar.java @@ -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 suggest(CommandSourceStack source, String[] args) { + return commandService.tabCompleteRoot(source.getSender(), ROOT_COMMAND, args); + } + + @Override + public boolean canUse(CommandSender sender) { + return true; + } + } +} diff --git a/adapters/bukkit/plugin/src/main/resources/paper-plugin.yml b/adapters/bukkit/plugin/src/main/resources/paper-plugin.yml new file mode 100644 index 000000000..9ef7c54ec --- /dev/null +++ b/adapters/bukkit/plugin/src/main/resources/paper-plugin.yml @@ -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! diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisBootstrapTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisBootstrapTest.java new file mode 100644 index 000000000..a2a23972a --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisBootstrapTest.java @@ -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 getDiscoveredPacks() { + return Collections.emptyMap(); + } + + @Override + public DiscoveredDatapack discoverPack(URI uri, String id, Consumer configurer) { + throw new UnsupportedOperationException(); + } + + @Override + public DiscoveredDatapack discoverPack(Path path, String id, Consumer 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) { + throw new UnsupportedOperationException(); + } + + @Override + public DiscoveredDatapack discoverPack(PluginMeta pluginMeta, Path path, String id, Consumer 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 getRequiredFeatures() { + return Collections.emptySet(); + } + + @Override + public DatapackSource getSource() { + throw new UnsupportedOperationException(); + } + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/PaperPluginMetadataTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/PaperPluginMetadataTest.java new file mode 100644 index 000000000..2f587e497 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/PaperPluginMetadataTest.java @@ -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> 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("${")); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/PaperCommandRegistrarTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/PaperCommandRegistrarTest.java new file mode 100644 index 000000000..6e9ec341a --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/PaperCommandRegistrarTest.java @@ -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 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; + } + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java index c6aac54a4..f6e0506a1 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java @@ -121,7 +121,7 @@ public final class ModdedForcedDatapack { File packFolder = packDirectory.toFile(); KList folders = new KList<>(); - folders.add(packFolder.getParentFile()); + folders.add(packFolder); KSet seenBiomes = new KSet<>(); IDataFixer fixer = DataVersion.getLatest().get(); @@ -185,8 +185,8 @@ public final class ModdedForcedDatapack { private static void writeWorldPreset(KList 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); } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPackInstaller.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPackInstaller.java index b1e006b29..4af4b34e0 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPackInstaller.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPackInstaller.java @@ -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); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java index 7d7b2d433..3cc82d382 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java @@ -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); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java index 0220f4ea2..b10c7dba9 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java @@ -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); diff --git a/core/build.gradle b/core/build.gradle index eb4df3688..d7f7a21e5 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -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) diff --git a/core/src/main/java/art/arcane/iris/core/IrisDatapackCompiler.java b/core/src/main/java/art/arcane/iris/core/IrisDatapackCompiler.java new file mode 100644 index 000000000..8ec6a3d5f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/IrisDatapackCompiler.java @@ -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 collectPackRoots(Path dataDirectory, Path serverRoot) throws IOException { + LinkedHashMap roots = new LinkedHashMap<>(); + collectInstalledPackRoots(dataDirectory.resolve("packs"), roots); + collectWorldPackRoots(serverRoot.resolve("dimensions"), roots); + return new ArrayList<>(roots.values()); + } + + public static CompilationResult compile( + List packRoots, + KList 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> 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 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 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 roots) throws IOException { + if (!Files.isDirectory(packsRoot)) { + return; + } + try (Stream stream = Files.list(packsRoot)) { + List 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 roots) throws IOException { + if (!Files.isDirectory(dimensionsRoot)) { + return; + } + try (Stream 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 candidates = stream.sorted(Comparator.comparing(Path::toString)).toList(); + for (Path candidate : candidates) { + addPackRoot(candidate, roots); + } + } + } + + private static void addPackRoot(Path root, Map 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 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 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> biomes) { + int count = 0; + for (KSet 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); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java b/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java index 282b41c85..9f4e9f24f 100644 --- a/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java +++ b/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java @@ -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().qadd(new File(IrisWorldStorage.levelRoot(), "datapacks")); } + public static KList getIrisDatapackRoots() { + KList 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 folders = getDatapacksFolder(); - IrisDimension.clearGeneratedBiomeTags(folders); - DatapackIngestService.reapplyFromStaging(folders); - java.util.concurrent.ConcurrentMap> biomes = new java.util.concurrent.ConcurrentHashMap<>(); - + KList datapacksFolders = getDatapacksFolder(); + DatapackIngestService.reapplyFromStaging(datapacksFolders); + List packRoots; try (Stream 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 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); - } - } } diff --git a/core/src/main/java/art/arcane/iris/core/loader/ImageResourceLoader.java b/core/src/main/java/art/arcane/iris/core/loader/ImageResourceLoader.java index 4c9abff96..a148d96d7 100644 --- a/core/src/main/java/art/arcane/iris/core/loader/ImageResourceLoader.java +++ b/core/src/main/java/art/arcane/iris/core/loader/ImageResourceLoader.java @@ -34,8 +34,8 @@ import java.util.HashSet; import java.util.Set; public class ImageResourceLoader extends ResourceLoader { - 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()); } diff --git a/core/src/main/java/art/arcane/iris/core/loader/IrisData.java b/core/src/main/java/art/arcane/iris/core/loader/IrisData.java index 707701b68..d50bce1c2 100644 --- a/core/src/main/java/art/arcane/iris/core/loader/IrisData.java +++ b/core/src/main/java/art/arcane/iris/core/loader/IrisData.java @@ -80,6 +80,7 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { private static final KMap dataLoaders = new KMap<>(); private final File dataFolder; private final int id; + private final boolean datapackCompiler; private boolean closed = false; private ResourceLoader biomeLoader; private ResourceLoader 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 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 r = null; + ResourceLoader.Options options = datapackCompiler + ? ResourceLoader.Options.datapackCompiler() + : ResourceLoader.Options.runtime(); if (registrant.equals(IrisObject.class)) { r = (ResourceLoader) new ObjectResourceLoader(dataFolder, this, rr.getFolderName(), - rr.getTypeName()); + rr.getTypeName(), options); } else if (registrant.equals(IrisMatterObject.class)) { r = (ResourceLoader) new MatterObjectResourceLoader(dataFolder, this, rr.getFolderName(), - rr.getTypeName()); + rr.getTypeName(), options); } else if (registrant.equals(IrisImage.class)) { r = (ResourceLoader) 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(); diff --git a/core/src/main/java/art/arcane/iris/core/loader/MatterObjectResourceLoader.java b/core/src/main/java/art/arcane/iris/core/loader/MatterObjectResourceLoader.java index 4b08371c2..fc79c41ff 100644 --- a/core/src/main/java/art/arcane/iris/core/loader/MatterObjectResourceLoader.java +++ b/core/src/main/java/art/arcane/iris/core/loader/MatterObjectResourceLoader.java @@ -33,8 +33,8 @@ import java.util.HashSet; public class MatterObjectResourceLoader extends ResourceLoader { 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()); } diff --git a/core/src/main/java/art/arcane/iris/core/loader/ObjectResourceLoader.java b/core/src/main/java/art/arcane/iris/core/loader/ObjectResourceLoader.java index 20a94ae40..bda8a42d4 100644 --- a/core/src/main/java/art/arcane/iris/core/loader/ObjectResourceLoader.java +++ b/core/src/main/java/art/arcane/iris/core/loader/ObjectResourceLoader.java @@ -31,8 +31,8 @@ import java.io.IOException; import java.util.HashSet; public class ObjectResourceLoader extends ResourceLoader { - 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()); } diff --git a/core/src/main/java/art/arcane/iris/core/loader/ResourceLoader.java b/core/src/main/java/art/arcane/iris/core/loader/ResourceLoader.java index 7b8185c03..7753d3bed 100644 --- a/core/src/main/java/art/arcane/iris/core/loader/ResourceLoader.java +++ b/core/src/main/java/art/arcane/iris/core/loader/ResourceLoader.java @@ -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 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 objectClass) { + public ResourceLoader( + File root, + IrisData manager, + String folderName, + String resourceTypeName, + Class 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 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 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 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 matchAllFiles(File root, Predicate f) { @@ -577,4 +599,24 @@ public class ResourceLoader 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); + } + } } diff --git a/core/src/main/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisioner.java b/core/src/main/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisioner.java new file mode 100644 index 000000000..af7cd0402 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisioner.java @@ -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 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 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 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 outputFolders = new KList().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 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 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 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 candidates = new ArrayList<>(); + try (DirectoryStream 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 packRoots) throws IOException { + MessageDigest digest = digest(); + List 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 stream = Files.walk(scanRoot)) { + List 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 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 stream = Files.walk(path)) { + List entries = stream.sorted(Comparator.reverseOrder()).toList(); + for (Path entry : entries) { + Files.deleteIfExists(entry); + } + } + } + + private static void deleteQuietly(Path path, Consumer 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"); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackDownloader.java b/core/src/main/java/art/arcane/iris/core/pack/PackDownloader.java index f6d249659..68a090c4a 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackDownloader.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackDownloader.java @@ -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 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 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()) diff --git a/core/src/main/java/art/arcane/iris/core/service/StudioSVC.java b/core/src/main/java/art/arcane/iris/core/service/StudioSVC.java index f87d4d326..ba1e9ca22 100644 --- a/core/src/main/java/art/arcane/iris/core/service/StudioSVC.java +++ b/core/src/main/java/art/arcane/iris/core/service/StudioSVC.java @@ -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."); } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java b/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java index 01f06479d..d0042dc20 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java @@ -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 folders, KSet biomes) { + public void installBiomes(IDataFixer fixer, DataProvider data, KList datapackRoots, KSet 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 folders) { - for (File datapacks : folders) { - File dataFolder = new File(datapacks, "iris/data"); + public static void clearGeneratedBiomeTags(KList 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 tags) throws IOException { + static void installBiomeTags(File datapackRoot, String biomeKey, KList 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 folders) { + public void installDimensionType(IDataFixer fixer, KList 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 folders, DimensionHeight height) { + public static void writeShared( + KList 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); } } } diff --git a/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerTest.java b/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerTest.java new file mode 100644 index 000000000..ec207dabe --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerTest.java @@ -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 packRoots = IrisDatapackCompiler.collectPackRoots(dataDirectory, serverRoot); + Path datapackRoot = temporaryFolder.newFolder("datapack").toPath(); + IrisDatapackCompiler.CompilationResult result = IrisDatapackCompiler.compile( + packRoots, + new KList().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().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 + ); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/loader/IrisDataDatapackCompilerCacheTest.java b/core/src/test/java/art/arcane/iris/core/loader/IrisDataDatapackCompilerCacheTest.java new file mode 100644 index 000000000..f07966444 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/loader/IrisDataDatapackCompilerCacheTest.java @@ -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() { + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisionerTest.java b/core/src/test/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisionerTest.java new file mode 100644 index 000000000..18f5d99ad --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisionerTest.java @@ -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 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 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 files) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(bytes)) { + for (Map.Entry 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 stream = Files.walk(root)) { + for (Path path : stream.sorted(java.util.Comparator.reverseOrder()).toList()) { + Files.deleteIfExists(path); + } + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackDownloaderTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackDownloaderTest.java index c4bd8a9f4..79ee99280 100644 --- a/core/src/test/java/art/arcane/iris/core/pack/PackDownloaderTest.java +++ b/core/src/test/java/art/arcane/iris/core/pack/PackDownloaderTest.java @@ -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( diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionBiomeTagTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionBiomeTagTest.java index 31d93c6db..b12712c1b 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionBiomeTagTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionBiomeTagTest.java @@ -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));