Datapack export

- /iris datapack export compiles one pack dimension into a worldgen datapack for Minecraft 26.3 and later
- Bukkit and mod loaders take the same version, mode, namespace, output, format, objects and replace arguments
- The default overworld mode replaces the Overworld of a new world with the default world type
- Preset and dimension modes write a selectable world preset or a separate dimension
- The export runs in the background and publishes the zip or folder atomically
- Pack compiler stages turn noise, terrain, caves, subterrain, biomes, surface, decoration, objects and structures into a version-neutral IR
- Capability lowering fits every field to 26.3 density function ranges
- Disjoint min and max nodes fold to the input that always wins
- The 26.3 writer emits density functions, noise settings, biomes, features, structures, presets and dimensions
- Every export carries the Iris logo as its pack.png
- Unused objects, templates and features are pruned from the output
- The pruner keeps pack.png without reading references from its bytes
- Imported pack datapacks are bundled into the export
- Every approximation reports an export limit diagnostic
- Generation cost is counted against a node budget and reported as a warning
- Cave biomes over the wall palette limit use the closest kept palette
- Jigsaw exports share the template encoder, block states, destinations and publisher with the worldgen export
- DataVersion carries the pack format minor and world data version
- Both platforms expose vanilla data from the server classpath
- GoldenHashEngine and the export probes share one chunk spiral scan
- Headless 26.3 probes measure export cost and compare column and chunk likeness against Iris
- Real-codec tests decode the exported resources with the 26.3 registries
- Paper registry tests run in isolated registry scopes
- Command help and messages cover the export in every language file
This commit is contained in:
Brian Neumann-Fopiano committed 2026-10-30 22:59:04 -04:00
1 parent b00e28e42e
commit 1ed1044474
385 files changed
+48102 -535

No files matched your search

@@ -18,21 +18,41 @@
package art.arcane.iris.command;
import art.arcane.iris.pack.datapack.DatapackIngestService;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.iris.Iris;
import art.arcane.iris.command.handlers.DimensionHandler;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.localization.BukkitCommandMessages;
import art.arcane.iris.localization.C;
import art.arcane.iris.localization.DirectorCommandMessages;
import art.arcane.iris.localization.IrisLanguage;
import art.arcane.iris.pack.datapack.DataVersion;
import art.arcane.iris.pack.datapack.DatapackIngestService;
import art.arcane.iris.pack.datapack.export.DatapackExportDestinations;
import art.arcane.iris.pack.datapack.export.WorldgenExportMode;
import art.arcane.iris.pack.datapack.export.WorldgenExportProgress;
import art.arcane.iris.pack.datapack.export.WorldgenExportRequest;
import art.arcane.iris.pack.datapack.export.WorldgenExportResult;
import art.arcane.iris.pack.datapack.export.WorldgenExportService;
import art.arcane.iris.pack.datapack.export.WorldgenExportServices;
import art.arcane.iris.pack.datapack.export.WorldgenExportTargets;
import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic;
import art.arcane.iris.platform.bukkit.plugin.VolmitSender;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.world.task.J;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.director.annotations.Director;
import art.arcane.volmlib.util.director.annotations.Param;
import art.arcane.volmlib.util.localization.LanguageAudience;
import art.arcane.volmlib.util.localization.MessageArgument;
import art.arcane.volmlib.util.localization.TextKey;
import java.util.List;
import java.util.UUID;
import art.arcane.iris.localization.IrisLanguage;
import art.arcane.iris.localization.BukkitCommandMessages;
import art.arcane.volmlib.util.localization.MessageArgument;
@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 {
private static final int RESOURCE_REPORTS = 4;
@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(
@Param(description = "Restart the server when new datapacks are installed (required for new structures to register and generate)", descriptionKey = "iris.director.commanddatapack.param.restart_server_when_new_datapacks_are_installed_required_new_structures_register_generate", defaultValue = "false")
@@ -71,4 +91,128 @@ public class CommandDatapack implements DirectorExecutor {
) {
DatapackIngestService.remove(sender(), id);
}
@Director(description = "Export a dimension as a vanilla worldgen datapack", descriptionKey = "iris.director.commanddatapack.director.export_dimension_vanilla_worldgen_datapack")
public void export(
@Param(name = "dimension", description = "The dimension to export", descriptionKey = "iris.director.commanddatapack.param.export_dimension", customHandler = DimensionHandler.class)
IrisDimension dimension,
@Param(name = "version", description = "Target Minecraft version, 26.3 or later (blank uses the latest)", descriptionKey = "iris.director.commanddatapack.param.export_version", defaultValue = "")
String version,
@Param(name = "mode", description = "Output mode: preset, dimension, or overworld", descriptionKey = "iris.director.commanddatapack.param.export_mode", defaultValue = WorldgenExportRequest.DEFAULT_MODE)
String mode,
@Param(name = "namespace", description = "Namespace for the exported resources", descriptionKey = "iris.director.commanddatapack.param.export_namespace", defaultValue = WorldgenExportRequest.DEFAULT_NAMESPACE)
String namespace,
@Param(name = "output", description = "Output name in the datapack exports folder (blank uses the dimension and version)", descriptionKey = "iris.director.commanddatapack.param.export_output", defaultValue = "")
String output,
@Param(name = "format", description = "zip or directory", descriptionKey = "iris.director.commanddatapack.param.export_format", defaultValue = WorldgenExportRequest.DEFAULT_FORMAT)
String format,
@Param(name = "objects", description = "Include objects as structure templates", descriptionKey = "iris.director.commanddatapack.param.export_objects", defaultValue = "true")
boolean objects,
@Param(name = "replace", description = "Replace an existing export with the same name", descriptionKey = "iris.director.commanddatapack.param.export_replace", defaultValue = "false")
boolean replace
) {
VolmitSender sender = sender();
WorldgenExportRequest request = WorldgenExportRequest.builder()
.pack(dimension.getLoader().getDataFolder().toPath())
.dimension(dimension.getLoadKey())
.version(version)
.mode(mode)
.namespace(namespace)
.output(output)
.format(format)
.objects(objects)
.replace(replace)
.exportRoot(IrisPlatforms.get().dataFolder("exports", "datapacks").toPath())
.build();
DataVersion target;
try {
target = validateExport(request);
} catch (IllegalArgumentException invalid) {
sender.sendMessage(C.RED + IrisLanguage.text(DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_INVALID, MessageArgument.untrusted("reason", invalid.getMessage())));
return;
}
WorldgenExportService service = WorldgenExportServices.get();
sender.sendMessage(C.GRAY + IrisLanguage.text(DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_STARTED, MessageArgument.untrusted("dimension", request.dimension()), MessageArgument.untrusted("version", target.getVersion())));
UUID audience = LanguageAudience.current();
ExportFeedback feedback = new ExportFeedback(sender);
J.a(() -> LanguageAudience.run(audience, () -> runExport(service, request, feedback)));
}
static DataVersion validateExport(WorldgenExportRequest request) {
DataVersion target = WorldgenExportTargets.resolve(request.version());
WorldgenExportMode.parse(request.mode());
DatapackExportDestinations.parseFormat(request.format());
return target;
}
private static void runExport(WorldgenExportService service, WorldgenExportRequest request, ExportFeedback feedback) {
WorldgenExportResult result;
try {
result = service.export(request, feedback);
} catch (Throwable failure) {
Iris.reportError("Datapack export failed for '" + request.dimension() + "'.", failure);
feedback.send(C.RED, IrisLanguage.text(DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_FAILED, MessageArgument.untrusted("reason", IrisLanguage.errorDetail(failure))));
return;
}
reportExport(feedback, result);
}
private static void reportExport(ExportFeedback feedback, WorldgenExportResult result) {
for (ExportDiagnostic diagnostic : result.diagnostics()) {
feedback.send(diagnosticColor(diagnostic.severity()), IrisLanguage.text(DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_DIAGNOSTIC, MessageArgument.untrusted("key", diagnosticLabel(diagnostic)), MessageArgument.untrusted("message", diagnostic.message())));
}
if (result.isSuccess()) {
feedback.send(C.GREEN, IrisLanguage.text(DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_EXPORTED, MessageArgument.untrusted("path", result.output())));
return;
}
TextKey outcome = result.status() == WorldgenExportResult.Status.REJECTED
? DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_REJECTED
: DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_FAILED;
feedback.send(C.RED, IrisLanguage.text(outcome, MessageArgument.untrusted("reason", result.message())));
}
private static C diagnosticColor(ExportDiagnostic.Severity severity) {
return switch (severity) {
case ERROR -> C.RED;
case WARNING -> C.YELLOW;
case INFO -> C.GRAY;
};
}
private static String diagnosticLabel(ExportDiagnostic diagnostic) {
return diagnostic.resource().isEmpty()
? diagnostic.limit().name()
: diagnostic.limit().name() + " " + diagnostic.resource();
}
private static final class ExportFeedback implements WorldgenExportProgress {
private final VolmitSender sender;
private ExportFeedback(VolmitSender sender) {
this.sender = sender;
}
@Override
public void stage(String stage) {
send(C.GRAY, IrisLanguage.text(DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_STAGE, MessageArgument.untrusted("stage", stage)));
}
@Override
public void resources(int done, int total) {
int stride = Math.max(1, total / RESOURCE_REPORTS);
if (done != total && done % stride != 0) {
return;
}
send(C.GRAY, IrisLanguage.text(DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_RESOURCES, MessageArgument.untrusted("done", done), MessageArgument.untrusted("total", total)));
}
private void send(C color, String message) {
String line = color + message;
if (sender.isPlayer()) {
J.runEntity(sender.player(), () -> sender.sendMessage(line));
return;
}
sender.sendMessage(line);
}
}
}
@@ -21,7 +21,8 @@ import art.arcane.iris.structure.StructureImporter;
import art.arcane.iris.structure.VillageImporter;
import art.arcane.iris.structure.authoring.StructureKey;
import art.arcane.iris.structure.authoring.StructureWriteResult;
import art.arcane.iris.structure.export.VanillaJigsawExportFormat;
import art.arcane.iris.pack.datapack.export.DatapackExportDestinations;
import art.arcane.iris.pack.datapack.export.DatapackExportFormat;
import art.arcane.iris.structure.export.VanillaJigsawExportRequest;
import art.arcane.iris.structure.export.VanillaJigsawExportSettings;
import art.arcane.iris.structure.export.VanillaJigsawExportSource;
@@ -420,9 +421,9 @@ public class CommandJigsaw implements DirectorExecutor {
sendError("Wait for the pending autosave or discard the edits before exporting the on-disk graph.");
return;
}
VanillaJigsawExportFormat exportFormat;
DatapackExportFormat exportFormat;
try {
exportFormat = CommandJigsawExports.parseFormat(format);
exportFormat = DatapackExportDestinations.parseFormat(format);
} catch (IllegalArgumentException exception) {
sendError(exception.getMessage());
return;
@@ -431,7 +432,7 @@ public class CommandJigsaw implements DirectorExecutor {
.toPath().toAbsolutePath().normalize();
Path destination;
try {
destination = CommandJigsawExports.resolveExportDestination(exportRoot, output, exportFormat);
destination = DatapackExportDestinations.resolve(exportRoot, output, exportFormat);
} catch (IllegalArgumentException exception) {
sendError(exception.getMessage());
return;
@@ -4,7 +4,7 @@ import art.arcane.iris.Iris;
import art.arcane.iris.studio.jigsaw.JigsawStudioService;
import art.arcane.iris.structure.export.VanillaJigsawDatapackExporter;
import art.arcane.iris.structure.export.VanillaJigsawExportDiagnostic;
import art.arcane.iris.structure.export.VanillaJigsawExportFormat;
import art.arcane.iris.pack.datapack.export.DatapackExportDestinations;
import art.arcane.iris.structure.export.VanillaJigsawExportRequest;
import art.arcane.iris.structure.export.VanillaJigsawExportResult;
import art.arcane.iris.localization.C;
@@ -13,18 +13,14 @@ import art.arcane.iris.world.task.J;
import org.bukkit.entity.Player;
import java.nio.file.Path;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.regex.Pattern;
final class CommandJigsawExports {
private static final Pattern EXPORT_OUTPUT_NAME = Pattern.compile("[a-zA-Z0-9_-][a-zA-Z0-9._-]{0,127}");
private static final Set<UUID> EXPORTING_PLAYERS = ConcurrentHashMap.newKeySet();
private static final Set<Path> EXPORT_DESTINATIONS = ConcurrentHashMap.newKeySet();
private CommandJigsawExports() {
}
@@ -45,11 +41,11 @@ final class CommandJigsawExports {
static boolean beginExport(UUID playerId, Path destination) {
Objects.requireNonNull(playerId);
Path normalizedDestination = Objects.requireNonNull(destination).toAbsolutePath().normalize();
Objects.requireNonNull(destination);
if (!EXPORTING_PLAYERS.add(playerId)) {
return false;
}
if (EXPORT_DESTINATIONS.add(normalizedDestination)) {
if (DatapackExportDestinations.claim(destination)) {
return true;
}
EXPORTING_PLAYERS.remove(playerId);
@@ -58,7 +54,7 @@ final class CommandJigsawExports {
static void finishExport(UUID playerId, Path destination) {
EXPORTING_PLAYERS.remove(Objects.requireNonNull(playerId));
EXPORT_DESTINATIONS.remove(Objects.requireNonNull(destination).toAbsolutePath().normalize());
DatapackExportDestinations.release(destination);
}
static void runExport(ExportOperation operation) {
@@ -102,35 +98,6 @@ final class CommandJigsawExports {
+ result.status() + ": " + result.output());
}
static VanillaJigsawExportFormat parseFormat(String value) {
return switch (value.trim().toLowerCase(Locale.ROOT)) {
case "zip" -> VanillaJigsawExportFormat.ZIP;
case "directory", "dir", "folder" -> VanillaJigsawExportFormat.DIRECTORY;
default -> throw new IllegalArgumentException("Export format must be directory or zip.");
};
}
static Path resolveExportDestination(
Path exportRoot,
String output,
VanillaJigsawExportFormat format
) {
Path root = Objects.requireNonNull(exportRoot, "Jigsaw export root").toAbsolutePath().normalize();
String requested = Objects.requireNonNull(output, "Jigsaw export output name");
String name = requested.trim();
if (!name.equals(requested) || !EXPORT_OUTPUT_NAME.matcher(name).matches()) {
throw new IllegalArgumentException(
"Export output must be one file name using letters, numbers, '.', '_', or '-'.");
}
String fileName = format == VanillaJigsawExportFormat.ZIP && !name.endsWith(".zip")
? name + ".zip" : name;
Path destination = root.resolve(fileName).normalize();
if (destination.equals(root) || !root.equals(destination.getParent())) {
throw new IllegalArgumentException("Export output must be one artifact inside the Studio exports folder.");
}
return destination;
}
record ExportOperation(
Player player,
VolmitSender sender,
@@ -0,0 +1,104 @@
package art.arcane.iris.command;
import art.arcane.iris.command.handlers.DimensionHandler;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.localization.IrisLanguage;
import art.arcane.iris.pack.datapack.DataVersion;
import art.arcane.iris.pack.datapack.export.WorldgenExportRequest;
import art.arcane.volmlib.util.director.annotations.Director;
import art.arcane.volmlib.util.director.annotations.Param;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
public class CommandDatapackExportContractTest {
private static final List<String> NAMES = List.of(
"dimension", "version", "mode", "namespace", "output", "format", "objects", "replace");
private static final List<String> DEFAULTS = List.of(
Param.NO_DEFAULT, "", "overworld", "iris_export", "", "zip", "true", "false");
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void exportParametersAreNamedInOrder() throws Exception {
List<String> names = new ArrayList<>();
for (Parameter parameter : exportMethod().getParameters()) {
names.add(parameter.getAnnotation(Param.class).name());
}
assertEquals(NAMES, names);
}
@Test
public void dimensionIsPositionalAndEveryOtherParameterIsKeyedWithItsDefault() throws Exception {
Parameter[] parameters = exportMethod().getParameters();
List<String> defaults = new ArrayList<>();
for (Parameter parameter : parameters) {
defaults.add(parameter.getAnnotation(Param.class).defaultValue());
}
assertEquals(DEFAULTS, defaults);
assertEquals(DimensionHandler.class, parameters[0].getAnnotation(Param.class).customHandler());
}
@Test
public void exportCommandAndParametersUseRegisteredDescriptions() throws Exception {
Method export = exportMethod();
Director director = export.getAnnotation(Director.class);
assertNotNull(director);
assertEquals(0L, Arrays.stream(director.aliases()).filter((String alias) -> !alias.isBlank()).count());
assertNotNull(director.descriptionKey(), IrisLanguage.catalog().key(director.descriptionKey()));
for (Parameter parameter : export.getParameters()) {
String key = parameter.getAnnotation(Param.class).descriptionKey();
assertNotNull(key, IrisLanguage.catalog().key(key));
}
}
@Test
public void validRequestResolvesItsTarget() {
assertSame(DataVersion.V26_3, CommandDatapack.validateExport(request().build()));
assertSame(DataVersion.V26_3, CommandDatapack.validateExport(request().version("26.3").build()));
}
@Test
public void unsupportedArgumentsAreRejectedWithSharedMessages() {
assertEquals("Unsupported datapack export version '1.21.1'. Supported: 26.3",
reason(request().version("1.21.1").build()));
assertEquals("Unsupported datapack export version '26.2'. Supported: 26.3",
reason(request().version("26.2").build()));
assertEquals("Unknown export mode 'nether'. Expected: preset, dimension, overworld",
reason(request().mode("nether").build()));
assertEquals("Export format must be directory or zip.", reason(request().format("rar").build()));
}
private WorldgenExportRequest.Builder request() {
Path root = temporaryFolder.getRoot().toPath();
return WorldgenExportRequest.builder()
.pack(root.resolve("packs").resolve("overworld"))
.dimension("overworld")
.exportRoot(root.resolve("exports"));
}
private static String reason(WorldgenExportRequest request) {
return assertThrows(IllegalArgumentException.class, () -> CommandDatapack.validateExport(request)).getMessage();
}
private static Method exportMethod() throws NoSuchMethodException {
return CommandDatapack.class.getDeclaredMethod("export", IrisDimension.class, String.class, String.class,
String.class, String.class, String.class, boolean.class, boolean.class);
}
}
@@ -17,7 +17,6 @@ import art.arcane.iris.structure.authoring.StructureOwnershipManifest;
import art.arcane.iris.structure.authoring.StructureSource;
import art.arcane.iris.structure.authoring.StructureTransactionWriter;
import art.arcane.iris.structure.conversion.IrisStructureAdoptionInputKind;
import art.arcane.iris.structure.export.VanillaJigsawExportFormat;
import art.arcane.iris.world.IrisCreator;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.pack.value.IrisDirection;
@@ -413,24 +412,6 @@ public class CommandJigsawContractTest {
}
}
@Test
public void exportOutputIsOneSafeChildArtifact() {
Path root = Path.of("build", "jigsaw-exports").toAbsolutePath().normalize();
assertEquals(root.resolve("village.zip"), CommandJigsawExports.resolveExportDestination(
root, "village", VanillaJigsawExportFormat.ZIP));
assertEquals(root.resolve("village"), CommandJigsawExports.resolveExportDestination(
root, "village", VanillaJigsawExportFormat.DIRECTORY));
assertThrows(IllegalArgumentException.class, () -> CommandJigsawExports.resolveExportDestination(
root, "", VanillaJigsawExportFormat.DIRECTORY));
assertThrows(IllegalArgumentException.class, () -> CommandJigsawExports.resolveExportDestination(
root, ".", VanillaJigsawExportFormat.DIRECTORY));
assertThrows(IllegalArgumentException.class, () -> CommandJigsawExports.resolveExportDestination(
root, "../all-exports", VanillaJigsawExportFormat.DIRECTORY));
assertThrows(IllegalArgumentException.class, () -> CommandJigsawExports.resolveExportDestination(
root, "nested/export", VanillaJigsawExportFormat.DIRECTORY));
}
@Test
public void exportLeaseRejectsDuplicatePlayerAndDestination() {
UUID firstPlayer = UUID.randomUUID();