Datapack imports from Modrinth, HTTP(S) and local file ZIPs

Work in progress parked from the working tree so the hydrology merge
could land; amend or squash as needed.
This commit is contained in:
Brian Neumann-Fopiano
2026-09-02 03:00:10 -04:00
parent 15bc52e9b5
commit 333b766d2d
12 changed files with 378 additions and 50 deletions
+3 -1
View File
@@ -67,7 +67,9 @@ the server. First boot performs no pack download. Run `/iris download pack=overw
each download to finish before starting another. The current built-in packs declare no external each download to finish before starting another. The current built-in packs declare no external
datapack imports. Restart after download so Minecraft loads their dimension types and biomes. datapack imports. Restart after download so Minecraft loads their dimension types and biomes.
Custom packs that declare `datapackImports` must complete their external-datapack installation and Custom packs that declare `datapackImports` must complete their external-datapack installation and
registry restart first. Plain Spigot supports ordinary managed `/iris create`, but not registry restart first. Bukkit accepts Modrinth, direct HTTP(S), and absolute local `file:` ZIP URLs;
ZIPs under `plugins/Iris/datapacks/imports/` are discovered for every Iris dimension. Plain Spigot
supports ordinary managed `/iris create`, but not
the early-bootstrap `/iris replace` path for canonical Overworld, Nether, or End slots. the early-bootstrap `/iris replace` path for canonical Overworld, Nether, or End slots.
**Mod (Fabric/Forge/NeoForge):** drop the mod jar into `mods/` and start the server. The jar is **Mod (Fabric/Forge/NeoForge):** drop the mod jar into `mods/` and start the server. The jar is
@@ -32,7 +32,7 @@ import java.util.List;
import art.arcane.iris.core.localization.IrisLanguage; import art.arcane.iris.core.localization.IrisLanguage;
import art.arcane.iris.core.localization.BukkitCommandMessages; import art.arcane.iris.core.localization.BukkitCommandMessages;
import art.arcane.volmlib.util.localization.MessageArgument; import art.arcane.volmlib.util.localization.MessageArgument;
@Director(name = "datapack", aliases = {"datapacks", "dp"}, description = "Download & manage external datapack imports (Modrinth)", descriptionKey = "iris.director.commanddatapack.director.download_manage_external_datapack_imports_modrinth") @Director(name = "datapack", aliases = {"datapacks", "dp"}, description = "Download & manage external datapack imports", descriptionKey = "iris.director.commanddatapack.director.download_manage_external_datapack_imports_modrinth")
public class CommandDatapack implements DirectorExecutor { public class CommandDatapack implements DirectorExecutor {
@Director(description = "Download/update every datapack listed in a pack dimension's 'datapackImports' and install it into the world so its structures register like vanilla", descriptionKey = "iris.director.commanddatapack.director.download_update_every_datapack_listed_pack_dimension_s_datapackimports_install_it_into", aliases = {"pull"}) @Director(description = "Download/update every datapack listed in a pack dimension's 'datapackImports' and install it into the world so its structures register like vanilla", descriptionKey = "iris.director.commanddatapack.director.download_update_every_datapack_listed_pack_dimension_s_datapackimports_install_it_into", aliases = {"pull"})
public void ingest( public void ingest(
@@ -334,7 +334,7 @@ public class CommandFind implements DirectorExecutor {
} }
Set<String> configuredImportUrls = normalizedImportUrls( Set<String> configuredImportUrls = normalizedImportUrls(
engine.getDimension().getDatapackImports()); DatapackIngestService.configuredImports(engine.getDimension()));
return new StructureExclusionReport( return new StructureExclusionReport(
List.copyOf(exclusions), Set.copyOf(registeredKeys.keySet()), List.copyOf(exclusions), Set.copyOf(registeredKeys.keySet()),
configuredImportUrls, registeredExcluded, configuredUnregistered, configuredImportUrls, registeredExcluded, configuredUnregistered,
@@ -116,6 +116,6 @@ public final class DatapackStructureScopeSVC implements IrisService {
return Set.of(); return Set.of();
} }
return scopeIndex.declaredSources( return scopeIndex.declaredSources(
generator.getTarget().getDimension().getDatapackImports()); DatapackIngestService.configuredImports(generator.getTarget().getDimension()));
} }
} }
@@ -205,7 +205,7 @@ public class IrisStructureLocateCommandContractTest {
assertTrue(method.contains("non-generating structure candidate(s) and their reasons to the server console")); assertTrue(method.contains("non-generating structure candidate(s) and their reasons to the server console"));
assertTrue(method.contains("no chunks were searched")); assertTrue(method.contains("no chunks were searched"));
assertTrue(method.indexOf("DatapackIngestService.installed()") > method.indexOf("J.a(() ->")); assertTrue(method.indexOf("DatapackIngestService.installed()") > method.indexOf("J.a(() ->"));
assertTrue(source.contains("engine.getDimension().getDatapackImports()")); assertTrue(source.contains("DatapackIngestService.configuredImports(engine.getDimension())"));
assertTrue(source.contains("snapshot.configuredImportUrls().contains(importUrl)")); assertTrue(source.contains("snapshot.configuredImportUrls().contains(importUrl)"));
assertTrue(source.contains("engine.getData().getStructureLoader().getPossibleKeys()")); assertTrue(source.contains("engine.getData().getStructureLoader().getPossibleKeys()"));
assertFalse(method.contains("commandSender.sendMessage(exclusion")); assertFalse(method.contains("commandSender.sendMessage(exclusion"));
@@ -102,6 +102,7 @@ public final class DatapackIngestService {
private static final String TRANSACTION_JOURNAL = "journal.json"; private static final String TRANSACTION_JOURNAL = "journal.json";
private static final String TRANSACTION_JOURNAL_NEXT = "journal.next.json"; private static final String TRANSACTION_JOURNAL_NEXT = "journal.next.json";
private static final String STARTUP_VALIDATION_CACHE = "startup-validation.json"; private static final String STARTUP_VALIDATION_CACHE = "startup-validation.json";
private static final String LOCAL_IMPORT_DIRECTORY = "imports";
private static final int OWNERSHIP_SCHEMA = 1; private static final int OWNERSHIP_SCHEMA = 1;
private static final int TRANSACTION_SCHEMA = 2; private static final int TRANSACTION_SCHEMA = 2;
private static final int STARTUP_VALIDATION_SCHEMA = 1; private static final int STARTUP_VALIDATION_SCHEMA = 1;
@@ -158,7 +159,7 @@ public final class DatapackIngestService {
StartupValidationCache cached = readStartupValidationCache(cacheFile); StartupValidationCache cached = readStartupValidationCache(cacheFile);
if (startupValidationContextMatches( if (startupValidationContextMatches(
cached, mcVersion, irisVersion, autoIngest, stripOverrides, urls)) { cached, mcVersion, irisVersion, autoIngest, stripOverrides, urls)) {
String localFingerprint = startupValidationFingerprint(root, worldFolders); String localFingerprint = startupValidationFingerprint(root, worldFolders, urls);
if (startupValidationCacheMatches( if (startupValidationCacheMatches(
cached, cached,
mcVersion, mcVersion,
@@ -243,7 +244,7 @@ public final class DatapackIngestService {
autoIngest, autoIngest,
stripOverrides, stripOverrides,
urls, urls,
startupValidationFingerprint(root, worldFolders)); startupValidationFingerprint(root, worldFolders, urls));
writeStartupValidationCache(cacheFile, cache); writeStartupValidationCache(cacheFile, cache);
return cache; return cache;
} catch (IOException | RuntimeException exception) { } catch (IOException | RuntimeException exception) {
@@ -301,7 +302,7 @@ public final class DatapackIngestService {
validated.autoIngest, validated.autoIngest,
validated.stripOverrides, validated.stripOverrides,
validated.urls, validated.urls,
startupValidationFingerprint(root, worldFolders)); startupValidationFingerprint(root, worldFolders, validated.urls));
} }
private static StartupValidationCache createStartupValidationCache( private static StartupValidationCache createStartupValidationCache(
@@ -411,6 +412,14 @@ public final class DatapackIngestService {
} }
static String startupValidationFingerprint(File root, KList<File> worldFolders) throws IOException { static String startupValidationFingerprint(File root, KList<File> worldFolders) throws IOException {
return startupValidationFingerprint(root, worldFolders, List.of());
}
static String startupValidationFingerprint(
File root,
KList<File> worldFolders,
Iterable<String> sources
) throws IOException {
try { try {
MessageDigest digest = MessageDigest.getInstance("SHA-256"); MessageDigest digest = MessageDigest.getInstance("SHA-256");
Path manifestPath = new File(root, "manifest.json").toPath(); Path manifestPath = new File(root, "manifest.json").toPath();
@@ -456,12 +465,61 @@ public final class DatapackIngestService {
new File(worldFolder, entry.id)); new File(worldFolder, entry.id));
} }
} }
updateLocalSourceFingerprint(digest, sources);
return hex(digest.digest()); return hex(digest.digest());
} catch (NoSuchAlgorithmException exception) { } catch (NoSuchAlgorithmException exception) {
throw new IOException("SHA-256 algorithm unavailable", exception); throw new IOException("SHA-256 algorithm unavailable", exception);
} }
} }
private static void updateLocalSourceFingerprint(
MessageDigest digest,
Iterable<String> sources
) throws IOException {
if (sources == null) {
return;
}
List<String> localSources = new ArrayList<>();
for (String source : sources) {
URI uri = parseSourceUri(source);
if (uri != null && "file".equalsIgnoreCase(uri.getScheme())) {
localSources.add(uri.normalize().toASCIIString());
}
}
localSources.sort(String::compareTo);
byte[] buffer = new byte[HASH_BUFFER_BYTES];
for (String source : localSources) {
Path path = requireLocalDatapackPath(parseSourceUri(source));
updateFingerprintValue(digest, "local-source:" + source);
try (InputStream input = Files.newInputStream(
path,
StandardOpenOption.READ,
LinkOption.NOFOLLOW_LINKS)) {
long bytes = 0;
int length;
while ((length = input.read(buffer)) > 0) {
bytes += length;
if (bytes > MAX_DOWNLOAD_BYTES) {
throw new IOException("Local datapack exceeds " + MAX_DOWNLOAD_BYTES + " bytes: " + path);
}
digest.update(buffer, 0, length);
}
updateDigestLong(digest, bytes);
}
}
}
private static URI parseSourceUri(String source) throws IOException {
if (source == null || source.isBlank()) {
return null;
}
try {
return new URI(source.trim());
} catch (URISyntaxException exception) {
throw new IOException("Invalid datapack URL " + source, exception);
}
}
private static void updateDirectoryFingerprint( private static void updateDirectoryFingerprint(
MessageDigest digest, MessageDigest digest,
String identity, String identity,
@@ -574,7 +632,7 @@ public final class DatapackIngestService {
private static Report ingestLocked(VolmitSender sender, KList<String> urls, boolean restart) { private static Report ingestLocked(VolmitSender sender, KList<String> urls, boolean restart) {
Report report = new Report(); Report report = new Report();
if (urls == null || urls.isEmpty()) { if (urls == null || urls.isEmpty()) {
message(sender, C.YELLOW + "No datapackImports configured in any loaded pack. Add Modrinth URLs to a dimension's 'datapackImports' list, then run /iris datapack ingest."); message(sender, C.YELLOW + "No external datapacks found. Add an HTTP(S) or file URL to a dimension's 'datapackImports', or place a ZIP in Iris/datapacks/imports, then run /iris datapack ingest.");
return report; return report;
} }
@@ -676,7 +734,7 @@ public final class DatapackIngestService {
if (report.changed()) { if (report.changed()) {
message(sender, C.YELLOW + "New datapack structures were installed. A server restart is required for them to register and generate."); message(sender, C.YELLOW + "New datapack structures were installed. A server restart is required for them to register and generate.");
message(sender, C.GRAY + "After the restart they generate natively only in Iris dimensions that declare their source URL - no import needed. To get editable Iris copies (jigsaw pools, pieces & objects written into the pack) run /iris structure import <dimension>, or set general.autoImportDatapackStructures=true to do it on every ingest. Place any registered key directly with a 'structures' placement using nativeStructures."); message(sender, C.GRAY + "After the restart they generate natively in Iris dimensions that declare their source URL; ZIPs from Iris/datapacks/imports are enabled for every Iris dimension. To get editable Iris copies (jigsaw pools, pieces & objects written into the pack) run /iris structure import <dimension>, or set general.autoImportDatapackStructures=true to do it on every ingest. Place any registered key directly with a 'structures' placement using nativeStructures.");
message(sender, C.GRAY + "Datapacks replace matching vanilla structure keys by default. Set 'importedStructures.datapackOverrides' to false to keep minecraft-namespaced structure definitions untouched; deny non-minecraft datapack and mod structure families with importedStructures.disabled or complete keys with importedStructures.disabledExact."); message(sender, C.GRAY + "Datapacks replace matching vanilla structure keys by default. Set 'importedStructures.datapackOverrides' to false to keep minecraft-namespaced structure definitions untouched; deny non-minecraft datapack and mod structure families with importedStructures.disabled or complete keys with importedStructures.disabledExact.");
if (!restart) { if (!restart) {
message(sender, C.GRAY + "Run with restart=true to restart now, or restart manually. After restart, run /iris structure list <dimension> to see the new keys."); message(sender, C.GRAY + "Run with restart=true to restart now, or restart manually. After restart, run /iris structure list <dimension> to see the new keys.");
@@ -1434,15 +1492,74 @@ public final class DatapackIngestService {
} }
public static KList<String> collectConfiguredImports() { public static KList<String> collectConfiguredImports() {
LinkedHashSet<String> urls = new LinkedHashSet<>(); LinkedHashSet<String> sources = new LinkedHashSet<>();
try (Stream<IrisData> stream = ServerConfigurator.allPacks()) { try (Stream<IrisData> stream = ServerConfigurator.allPacks()) {
stream.forEach(data -> collectImports(data, urls)); stream.forEach(data -> collectImports(data, sources));
} }
sources.addAll(localDatapackImports());
KList<String> result = new KList<>(); KList<String> result = new KList<>();
result.addAll(urls); result.addAll(sources);
return result; return result;
} }
public static Set<String> configuredImports(IrisDimension dimension) {
Iterable<String> explicit = dimension == null ? List.of() : dimension.getDatapackImports();
return mergeConfiguredImports(explicit, localDatapackImports());
}
static Set<String> mergeConfiguredImports(
Iterable<String> explicit,
Iterable<String> discovered
) {
LinkedHashSet<String> sources = new LinkedHashSet<>();
if (explicit != null) {
addImports(explicit, sources);
}
if (discovered != null) {
addImports(discovered, sources);
}
return sources.isEmpty() ? Set.of() : Set.copyOf(sources);
}
static List<String> discoverLocalDatapackImports(File directory) throws IOException {
Path root = directory.toPath().toAbsolutePath().normalize();
if (Files.notExists(root, LinkOption.NOFOLLOW_LINKS)) {
Files.createDirectories(root);
}
if (Files.isSymbolicLink(root)
|| !Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Local datapack import path is not a safe directory: " + root);
}
try (Stream<Path> stream = Files.list(root)) {
return stream
.filter(path -> !Files.isSymbolicLink(path))
.filter(path -> Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS))
.filter(DatapackIngestService::isLocalDatapackArchive)
.sorted(Comparator.comparing(
(Path path) -> path.getFileName().toString(),
String.CASE_INSENSITIVE_ORDER)
.thenComparing((Path path) -> path.getFileName().toString()))
.map(path -> path.toAbsolutePath().normalize().toUri().toASCIIString())
.toList();
}
}
private static boolean isLocalDatapackArchive(Path path) {
String name = path.getFileName().toString();
return !name.startsWith(".") && name.toLowerCase(Locale.ROOT).endsWith(".zip");
}
private static List<String> localDatapackImports() {
File directory = IrisPlatforms.get().dataFolder("datapacks", LOCAL_IMPORT_DIRECTORY);
try {
return discoverLocalDatapackImports(directory);
} catch (IOException exception) {
IrisLogging.reportError("Could not discover local datapack imports under "
+ directory.getPath() + ".", exception);
return List.of();
}
}
public static List<Entry> installed() { public static List<Entry> installed() {
TRANSACTION_LOCK.lock(); TRANSACTION_LOCK.lock();
try { try {
@@ -1883,34 +2000,39 @@ public final class DatapackIngestService {
if (imports == null) { if (imports == null) {
continue; continue;
} }
for (String url : imports) { addImports(imports, urls);
if (url != null && !url.isBlank()) { }
urls.add(url.trim()); }
}
private static void addImports(Iterable<String> imports, LinkedHashSet<String> sources) {
for (String source : imports) {
if (source != null && !source.isBlank()) {
sources.add(normalizeConfiguredSource(source));
} }
} }
} }
static String normalizeConfiguredSource(String source) {
String normalized = source.trim();
try {
URI uri = new URI(normalized);
if ("file".equalsIgnoreCase(uri.getScheme())) {
return Path.of(uri).toAbsolutePath().normalize().toUri().toASCIIString();
}
} catch (IllegalArgumentException | URISyntaxException ignored) {
}
return normalized;
}
private static Set<String> configuredImports(IrisData data) { private static Set<String> configuredImports(IrisData data) {
LinkedHashSet<String> urls = new LinkedHashSet<>(); LinkedHashSet<String> urls = new LinkedHashSet<>();
collectImports(data, urls); collectImports(data, urls);
urls.addAll(localDatapackImports());
return urls; return urls;
} }
private static boolean hasImports(IrisData data) { private static boolean hasImports(IrisData data) {
if (data.getDimensionLoader() == null) { return !configuredImports(data).isEmpty();
return false;
}
for (IrisDimension dimension : data.getDimensionLoader().loadAll(data.getDimensionLoader().getPossibleKeys())) {
if (dimension == null) {
continue;
}
KList<String> imports = dimension.getDatapackImports();
if (imports != null && !imports.isEmpty()) {
return true;
}
}
return false;
} }
static InstallResult install(File stagedDir, KList<File> worldFolders, Entry entry, boolean stripOverrides) throws IOException { static InstallResult install(File stagedDir, KList<File> worldFolders, Entry entry, boolean stripOverrides) throws IOException {
@@ -3532,11 +3654,12 @@ public final class DatapackIngestService {
} }
static DownloadResult download(String url, File dest, String etag, String lastModified) throws IOException { static DownloadResult download(String url, File dest, String etag, String lastModified) throws IOException {
URI current; URI current = parseSourceUri(url);
try { if (current == null) {
current = new URI(url); throw new IOException("Empty datapack URL");
} catch (URISyntaxException e) { }
throw new IOException("Invalid datapack URL " + url, e); if ("file".equalsIgnoreCase(current.getScheme())) {
return copyLocalDatapack(current, dest);
} }
for (int attempt = 0; attempt < MAX_REDIRECTS; attempt++) { for (int attempt = 0; attempt < MAX_REDIRECTS; attempt++) {
if (!"http".equalsIgnoreCase(current.getScheme()) && !"https".equalsIgnoreCase(current.getScheme())) { if (!"http".equalsIgnoreCase(current.getScheme()) && !"https".equalsIgnoreCase(current.getScheme())) {
@@ -3614,6 +3737,84 @@ public final class DatapackIngestService {
throw new IOException("Too many redirects downloading " + url); throw new IOException("Too many redirects downloading " + url);
} }
private static DownloadResult copyLocalDatapack(URI source, File destination) throws IOException {
Path sourcePath = requireLocalDatapackPath(source);
Path destinationPath = destination.toPath().toAbsolutePath().normalize();
if (Files.exists(destinationPath, LinkOption.NOFOLLOW_LINKS)
&& Files.isSameFile(sourcePath, destinationPath)) {
throw new IOException("Local datapack source and cache destination are the same file: " + sourcePath);
}
Path parent = destinationPath.getParent();
if (parent == null) {
throw new IOException("Local datapack cache destination has no parent: " + destinationPath);
}
ensureScratchDirectory(parent.toFile(), "datapack download cache");
BasicFileAttributes before = Files.readAttributes(
sourcePath,
BasicFileAttributes.class,
LinkOption.NOFOLLOW_LINKS);
Path temporary = Files.createTempFile(parent, destination.getName() + "-", ".part");
try {
long copied = 0;
try (InputStream input = Files.newInputStream(
sourcePath,
StandardOpenOption.READ,
LinkOption.NOFOLLOW_LINKS);
OutputStream output = Files.newOutputStream(temporary)) {
byte[] buffer = new byte[HASH_BUFFER_BYTES];
int length;
while ((length = input.read(buffer)) > 0) {
copied += length;
if (copied > MAX_DOWNLOAD_BYTES) {
throw new IOException("Local datapack exceeds " + MAX_DOWNLOAD_BYTES + " bytes: " + sourcePath);
}
output.write(buffer, 0, length);
}
}
BasicFileAttributes after = Files.readAttributes(
sourcePath,
BasicFileAttributes.class,
LinkOption.NOFOLLOW_LINKS);
if (!after.isRegularFile()
|| before.size() != after.size()
|| !before.lastModifiedTime().equals(after.lastModifiedTime())
|| !Objects.equals(before.fileKey(), after.fileKey())
|| copied != after.size()) {
throw new IOException("Local datapack changed while Iris was copying it: " + sourcePath);
}
move(temporary, destinationPath);
return new DownloadResult(false, null, null);
} finally {
Files.deleteIfExists(temporary);
}
}
private static Path requireLocalDatapackPath(URI source) throws IOException {
if (source == null
|| !"file".equalsIgnoreCase(source.getScheme())
|| source.isOpaque()
|| source.getRawAuthority() != null
|| source.getRawQuery() != null
|| source.getRawFragment() != null) {
throw new IOException("Datapack file URL must be an absolute local file URI without authority, query, or fragment: " + source);
}
Path path;
try {
path = Path.of(source).toAbsolutePath().normalize();
} catch (IllegalArgumentException exception) {
throw new IOException("Invalid local datapack file URL: " + source, exception);
}
if (Files.isSymbolicLink(path)
|| !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Local datapack is not a regular non-symbolic-link file: " + path);
}
long size = Files.size(path);
if (size > MAX_DOWNLOAD_BYTES) {
throw new IOException("Local datapack exceeds " + MAX_DOWNLOAD_BYTES + " bytes: " + path);
}
return path;
}
private static String headerOrFallback(HttpURLConnection connection, String name, String fallback) { private static String headerOrFallback(HttpURLConnection connection, String name, String fallback) {
String value = connection.getHeaderField(name); String value = connection.getHeaderField(name);
return value == null || value.isBlank() ? fallback : value; return value == null || value.isBlank() ? fallback : value;
@@ -32,6 +32,7 @@ import java.net.URI;
import java.net.URISyntaxException; import java.net.URISyntaxException;
import java.net.URL; import java.net.URL;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.util.ArrayList; import java.util.ArrayList;
@@ -228,22 +229,33 @@ public final class ModrinthResolver {
} }
private static ResolvedDatapack directResolve(String url) { private static ResolvedDatapack directResolve(String url) {
String filename = url; String filename = directFilename(url);
int slash = filename.lastIndexOf('/');
if (slash >= 0) {
filename = filename.substring(slash + 1);
}
int query = filename.indexOf('?');
if (query >= 0) {
filename = filename.substring(0, query);
}
if (filename.isBlank()) {
filename = "datapack.zip";
}
String identity = directIdentity(url); String identity = directIdentity(url);
return new ResolvedDatapack(url, filename, null, "direct-" + identity, "direct", null, true); return new ResolvedDatapack(url, filename, null, "direct-" + identity, "direct", null, true);
} }
private static String directFilename(String url) {
try {
URI uri = new URI(url);
if ("file".equalsIgnoreCase(uri.getScheme())) {
Path fileName = Path.of(uri).getFileName();
return fileName == null || fileName.toString().isBlank()
? "datapack.zip"
: fileName.toString();
}
String path = uri.getPath();
if (path != null && !path.isBlank()) {
int slash = path.lastIndexOf('/');
String fileName = slash >= 0 ? path.substring(slash + 1) : path;
if (!fileName.isBlank()) {
return fileName;
}
}
} catch (IllegalArgumentException | URISyntaxException ignored) {
}
return "datapack.zip";
}
static String directIdentity(String url) { static String directIdentity(String url) {
String normalized = url; String normalized = url;
try { try {
@@ -29,7 +29,7 @@ public final class BukkitCommandMessages {
); );
public static final TextKey COMMAND_DATAPACK_ADD_MODRINTH_URLS_DIMENSION_S_DATAPACKIMPORTS_LIST_THEN_RUN_IRIS = TextKey.of( public static final TextKey COMMAND_DATAPACK_ADD_MODRINTH_URLS_DIMENSION_S_DATAPACKIMPORTS_LIST_THEN_RUN_IRIS = TextKey.of(
"iris.bukkit.commanddatapack.add_modrinth_urls_dimension_s_datapackimports_list_then_run_iris", "iris.bukkit.commanddatapack.add_modrinth_urls_dimension_s_datapackimports_list_then_run_iris",
C.YELLOW + "Add Modrinth URLs to a dimension's 'datapackImports' list, then run /iris datapack ingest." C.YELLOW + "Add source URLs to a dimension's 'datapackImports' list or use the local import folder, then run /iris datapack ingest."
); );
public static final TextKey COMMAND_DEVELOPER_GENHASH_STARTED_CHUNKS = TextKey.of( public static final TextKey COMMAND_DEVELOPER_GENHASH_STARTED_CHUNKS = TextKey.of(
"iris.bukkit.commanddeveloper.genhash_started_chunks", "iris.bukkit.commanddeveloper.genhash_started_chunks",
@@ -8,7 +8,7 @@ import java.util.List;
public final class DirectorCommandMessages { public final class DirectorCommandMessages {
public static final TextKey COMMAND_DATAPACK_DIRECTOR_DOWNLOAD_MANAGE_EXTERNAL_DATAPACK_IMPORTS_MODRINTH = TextKey.of( public static final TextKey COMMAND_DATAPACK_DIRECTOR_DOWNLOAD_MANAGE_EXTERNAL_DATAPACK_IMPORTS_MODRINTH = TextKey.of(
"iris.director.commanddatapack.director.download_manage_external_datapack_imports_modrinth", "iris.director.commanddatapack.director.download_manage_external_datapack_imports_modrinth",
"Download & manage external datapack imports (Modrinth)" "Download & manage external datapack imports"
); );
public static final TextKey COMMAND_DATAPACK_DIRECTOR_DOWNLOAD_UPDATE_EVERY_DATAPACK_LISTED_PACK_DIMENSION_S_DATAPACKIMPORTS_INSTALL_IT_INTO = TextKey.of( public static final TextKey COMMAND_DATAPACK_DIRECTOR_DOWNLOAD_UPDATE_EVERY_DATAPACK_LISTED_PACK_DIMENSION_S_DATAPACKIMPORTS_INSTALL_IT_INTO = TextKey.of(
"iris.director.commanddatapack.director.download_update_every_datapack_listed_pack_dimension_s_datapackimports_install_it_into", "iris.director.commanddatapack.director.download_update_every_datapack_listed_pack_dimension_s_datapackimports_install_it_into",
@@ -276,7 +276,7 @@ public class IrisDimension extends IrisRegistrant {
@Desc("Controls native vanilla, mod, and ingested datapack PLACED FEATURE generation (ores, trees, plants, springs, geodes) for this dimension. Disabled by default: leaving this out generates exactly the terrain Iris always has. Set 'enabled' true to run the vanilla decoration feature pass over Iris terrain. Carvers are never imported.") @Desc("Controls native vanilla, mod, and ingested datapack PLACED FEATURE generation (ores, trees, plants, springs, geodes) for this dimension. Disabled by default: leaving this out generates exactly the terrain Iris always has. Set 'enabled' true to run the vanilla decoration feature pass over Iris terrain. Carvers are never imported.")
private IrisImportedFeatureControl importedFeatures = new IrisImportedFeatureControl(); private IrisImportedFeatureControl importedFeatures = new IrisImportedFeatureControl();
@ArrayType(type = String.class, min = 1) @ArrayType(type = String.class, min = 1)
@Desc("External datapack sources owned by this dimension. Bukkit registers their resources server-wide, but Iris excludes their structure sets and definitions from generation and locate state in vanilla worlds and Iris dimensions that do not declare the same source. Editable auto-import is limited to resources supplied by the URLs declared by each Iris pack. Any registered datapack structure can be placed directly through nativeStructures without conversion. Replacing native generation requires a dimension-level structure placement with nativeSuppression set to REPLACE_SOURCE; provenance alone never disables native structures.") @Desc("External HTTP(S) or file URL datapack sources owned by this dimension. Bukkit registers their resources server-wide, but Iris excludes their structure sets and definitions from generation and locate state in vanilla worlds and Iris dimensions that do not declare the same source. Local ZIPs under Iris/datapacks/imports are discovered automatically and enabled for every Iris dimension. Editable auto-import is limited to resources supplied by configured or discovered sources. Any registered datapack structure can be placed directly through nativeStructures without conversion. Replacing native generation requires a dimension-level structure placement with nativeSuppression set to REPLACE_SOURCE; provenance alone never disables native structures.")
private KList<String> datapackImports = new KList<>(); private KList<String> datapackImports = new KList<>();
@MinNumber(0) @MinNumber(0)
@MaxNumber(318) @MaxNumber(318)
@@ -357,6 +357,97 @@ public class DatapackIngestServiceTest {
} }
} }
@Test
public void downloadCopiesLocalFileUrl() throws Exception {
Path source = temporaryFolder.newFile("local source.zip").toPath();
Files.writeString(source, "local-archive", StandardCharsets.UTF_8);
File destination = new File(temporaryFolder.newFolder("local-download"), "pack.zip");
DatapackIngestService.DownloadResult result = DatapackIngestService.download(
source.toUri().toASCIIString(),
destination,
null,
null
);
assertFalse(result.notModified());
assertEquals("local-archive", Files.readString(destination.toPath(), StandardCharsets.UTF_8));
}
@Test
public void failedLocalDownloadPreservesExistingDestination() throws Exception {
Path missing = temporaryFolder.getRoot().toPath().resolve("missing.zip");
File destination = new File(temporaryFolder.newFolder("local-download-existing"), "pack.zip");
Files.writeString(destination.toPath(), "existing", StandardCharsets.UTF_8);
try {
DatapackIngestService.download(
missing.toUri().toASCIIString(),
destination,
null,
null
);
fail("Expected missing local source rejection");
} catch (IOException expected) {
assertTrue(expected.getMessage().contains("regular non-symbolic-link file"));
}
assertEquals("existing", Files.readString(destination.toPath(), StandardCharsets.UTF_8));
}
@Test
public void localDropFolderDiscoversOnlyTopLevelZipFilesInStableOrder() throws Exception {
File imports = temporaryFolder.newFolder("local-imports");
Path first = new File(imports, "Alpha Pack.ZIP").toPath();
Path second = new File(imports, "bravo.zip").toPath();
Files.writeString(first, "alpha", StandardCharsets.UTF_8);
Files.writeString(second, "bravo", StandardCharsets.UTF_8);
Files.writeString(new File(imports, "ignored.txt").toPath(), "ignored", StandardCharsets.UTF_8);
Path nested = new File(imports, "nested/hidden.zip").toPath();
Files.createDirectories(nested.getParent());
Files.writeString(nested, "nested", StandardCharsets.UTF_8);
List<String> discovered = DatapackIngestService.discoverLocalDatapackImports(imports);
assertEquals(List.of(
first.toAbsolutePath().normalize().toUri().toASCIIString(),
second.toAbsolutePath().normalize().toUri().toASCIIString()
), discovered);
}
@Test
public void configuredFileSourceNormalizesToTheDiscoveredArchiveUri() throws Exception {
Path source = temporaryFolder.newFile("normalized-local.zip").toPath();
String discovered = source.toAbsolutePath().normalize().toUri().toASCIIString();
String alternate = discovered.replace("file:///", "file:/");
assertEquals(discovered, DatapackIngestService.normalizeConfiguredSource(alternate));
assertEquals(
Set.of("https://example.test/dimension.zip", discovered),
DatapackIngestService.mergeConfiguredImports(
List.of("https://example.test/dimension.zip", alternate),
List.of(discovered)));
}
@Test
public void startupValidationFingerprintChangesWithLocalSourceBytes() throws Exception {
File root = temporaryFolder.newFolder("startup-local-source-fingerprint");
Path source = temporaryFolder.newFile("startup-local.zip").toPath();
KList<File> worldFolders = new KList<>();
String sourceUrl = source.toUri().toASCIIString();
Files.writeString(source, "alpha", StandardCharsets.UTF_8);
String before = DatapackIngestService.startupValidationFingerprint(
root, worldFolders, List.of(sourceUrl));
FileTime originalTime = Files.getLastModifiedTime(source);
Files.writeString(source, "bravo", StandardCharsets.UTF_8);
Files.setLastModifiedTime(source, originalTime);
String after = DatapackIngestService.startupValidationFingerprint(
root, worldFolders, List.of(sourceUrl));
assertFalse(before.equals(after));
}
@Test @Test
public void removalRequiresMatchingIrisOwnership() throws Exception { public void removalRequiresMatchingIrisOwnership() throws Exception {
File unmanaged = datapackDirectory("unmanaged"); File unmanaged = datapackDirectory("unmanaged");
@@ -8,6 +8,9 @@ import org.junit.Test;
import java.io.IOException; import java.io.IOException;
import java.io.Reader; import java.io.Reader;
import java.io.StringReader; import java.io.StringReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays; import java.util.Arrays;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
@@ -104,6 +107,25 @@ public class ModrinthResolverTest {
assertFalse(resolved.getVersionId().isBlank()); assertFalse(resolved.getVersionId().isBlank());
} }
@Test
public void fileUrlResolvesAsDirectArchiveWithDecodedFilename() throws Exception {
Path directory = Files.createTempDirectory("iris-local-resolver");
Path archive = directory.resolve("local pack.zip");
try {
Files.writeString(archive, "archive", StandardCharsets.UTF_8);
ModrinthResolver.ResolvedDatapack resolved = ModrinthResolver.resolve(
archive.toUri().toASCIIString(), "26.2");
assertTrue(resolved.isDirect());
assertEquals("local pack.zip", resolved.getFileName());
assertFalse(resolved.getVersionId().isBlank());
} finally {
Files.deleteIfExists(archive);
Files.deleteIfExists(directory);
}
}
@Test @Test
public void boundedApiReaderAcceptsTheExactCharacterLimit() throws Exception { public void boundedApiReaderAcceptsTheExactCharacterLimit() throws Exception {
String response = "1234\n5678"; String response = "1234\n5678";