diff --git a/core/src/main/java/art/arcane/iris/Iris.java b/core/src/main/java/art/arcane/iris/Iris.java index fd7b2c403..1bc67a408 100644 --- a/core/src/main/java/art/arcane/iris/Iris.java +++ b/core/src/main/java/art/arcane/iris/Iris.java @@ -528,7 +528,7 @@ public class Iris extends VolmitPlugin implements Listener { J.ar(this::checkConfigHotload, 60); J.sr(this::tickQueue, 0); J.s(this::setupPapi); - J.a(ServerConfigurator::configure, 20); + J.a(ServerConfigurator::configureIfDeferred, 20); autoStartStudio(); if (!J.isFolia()) { diff --git a/core/src/main/java/art/arcane/iris/core/ExternalDataPackPipeline.java b/core/src/main/java/art/arcane/iris/core/ExternalDataPackPipeline.java index 43a85a11c..0523f2972 100644 --- a/core/src/main/java/art/arcane/iris/core/ExternalDataPackPipeline.java +++ b/core/src/main/java/art/arcane/iris/core/ExternalDataPackPipeline.java @@ -3,6 +3,9 @@ package art.arcane.iris.core; import art.arcane.iris.Iris; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.nms.INMS; +import art.arcane.iris.core.nms.container.StructurePlacement; +import art.arcane.iris.core.link.Identifier; +import art.arcane.iris.engine.data.cache.AtomicCache; import art.arcane.iris.engine.object.IrisObject; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisExternalDatapackReplaceTargets; @@ -52,6 +55,7 @@ import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; +import java.util.EnumMap; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; @@ -85,12 +89,15 @@ public final class ExternalDataPackPipeline { private static final String MANAGED_WORLD_PACK_PREFIX = "iris-external-"; private static final String MANAGED_PACK_META_DESCRIPTION = "Iris managed external structure datapack assets."; private static final String IMPORT_PREFIX = "imports"; + private static final String LOCATE_MANIFEST_PATH = "cache/external-datapack-locate-manifest.json"; private static final int CONNECT_TIMEOUT_MS = 4000; private static final int READ_TIMEOUT_MS = 8000; private static final int IMPORT_PARALLELISM = Math.max(1, Math.min(8, Runtime.getRuntime().availableProcessors())); private static final int MAX_IN_FLIGHT = Math.max(2, IMPORT_PARALLELISM * 3); private static final Map BLOCK_DATA_CACHE = new ConcurrentHashMap<>(); private static final Map PACK_ENVIRONMENT_CACHE = new ConcurrentHashMap<>(); + private static final Map> RESOLVED_LOCATE_STRUCTURES_BY_ID = new ConcurrentHashMap<>(); + private static final AtomicCache> VANILLA_STRUCTURE_PLACEMENTS = new AtomicCache<>(); private static final BlockData AIR = B.getAir(); private ExternalDataPackPipeline() { @@ -104,9 +111,79 @@ public final class ExternalDataPackPipeline { return normalizeEnvironment(value); } + public static Set resolveLocateStructuresForId(String id) { + String normalizedId = normalizeLocateId(id); + if (normalizedId.isBlank()) { + return Set.of(); + } + + Set resolved = RESOLVED_LOCATE_STRUCTURES_BY_ID.get(normalizedId); + if (resolved != null && !resolved.isEmpty()) { + return Set.copyOf(resolved); + } + + Map> fromManifest = readLocateManifest(); + Set manifestSet = fromManifest.get(normalizedId); + if (manifestSet == null || manifestSet.isEmpty()) { + return Set.of(); + } + + RESOLVED_LOCATE_STRUCTURES_BY_ID.put(normalizedId, Set.copyOf(manifestSet)); + return Set.copyOf(manifestSet); + } + + public static Map> snapshotLocateStructuresById() { + if (RESOLVED_LOCATE_STRUCTURES_BY_ID.isEmpty()) { + Map> manifest = readLocateManifest(); + if (!manifest.isEmpty()) { + RESOLVED_LOCATE_STRUCTURES_BY_ID.putAll(manifest); + } + } + + ArrayList ids = new ArrayList<>(RESOLVED_LOCATE_STRUCTURES_BY_ID.keySet()); + ids.sort(String::compareTo); + LinkedHashMap> snapshot = new LinkedHashMap<>(); + for (String id : ids) { + if (id == null || id.isBlank()) { + continue; + } + + Set structures = RESOLVED_LOCATE_STRUCTURES_BY_ID.get(id); + if (structures == null || structures.isEmpty()) { + continue; + } + + ArrayList sortedStructures = new ArrayList<>(structures); + sortedStructures.sort(String::compareTo); + snapshot.put(id, Set.copyOf(sortedStructures)); + } + + return Map.copyOf(snapshot); + } + + public static Set snapshotLocateStructureKeys() { + Map> locateById = snapshotLocateStructuresById(); + LinkedHashSet structures = new LinkedHashSet<>(); + for (Set values : locateById.values()) { + if (values == null || values.isEmpty()) { + continue; + } + + for (String value : values) { + String normalized = normalizeLocateStructure(value); + if (!normalized.isBlank()) { + structures.add(normalized); + } + } + } + + return Set.copyOf(structures); + } + public static PipelineSummary processDatapacks(List requests, Map> worldDatapackFoldersByPack) { PipelineSummary summary = new PipelineSummary(); PACK_ENVIRONMENT_CACHE.clear(); + RESOLVED_LOCATE_STRUCTURES_BY_ID.clear(); Set knownWorldDatapackFolders = new LinkedHashSet<>(); if (worldDatapackFoldersByPack != null) { @@ -129,12 +206,16 @@ public final class ExternalDataPackPipeline { List normalizedRequests = normalizeRequests(requests); summary.requests = normalizedRequests.size(); if (normalizedRequests.isEmpty()) { + Iris.info("Downloading datapacks [0/0] Downloading/Done!"); + writeLocateManifest(Map.of()); summary.legacyWorldCopyRemovals += pruneManagedWorldDatapacks(knownWorldDatapackFolders, Set.of()); return summary; } List sourceInputs = new ArrayList<>(); - for (DatapackRequest request : normalizedRequests) { + LinkedHashMap> resolvedLocateStructuresById = new LinkedHashMap<>(); + for (int requestIndex = 0; requestIndex < normalizedRequests.size(); requestIndex++) { + DatapackRequest request = normalizedRequests.get(requestIndex); if (request == null) { continue; } @@ -145,7 +226,8 @@ public final class ExternalDataPackPipeline { } else { summary.optionalFailures++; } - Iris.warn("Skipped external datapack request " + request.id() + " because replaceVanilla requires explicit replacement targets."); + Iris.warn("Downloading datapacks [" + (requestIndex + 1) + "/" + normalizedRequests.size() + "] Failed! id=" + request.id() + " (replaceVanilla requires explicit replacement targets)."); + mergeResolvedLocateStructures(resolvedLocateStructuresById, request.id(), request.resolvedLocateStructures()); continue; } @@ -156,7 +238,8 @@ public final class ExternalDataPackPipeline { } else { summary.optionalFailures++; } - Iris.warn("Failed external datapack request " + request.id() + ": " + syncResult.error()); + Iris.warn("Downloading datapacks [" + (requestIndex + 1) + "/" + normalizedRequests.size() + "] Failed! id=" + request.id() + " (" + syncResult.error() + ")."); + mergeResolvedLocateStructures(resolvedLocateStructuresById, request.id(), request.resolvedLocateStructures()); continue; } @@ -165,6 +248,8 @@ public final class ExternalDataPackPipeline { } else if (syncResult.restored()) { summary.restoredRequests++; } + Iris.info("Downloading datapacks [" + (requestIndex + 1) + "/" + normalizedRequests.size() + "] Downloading/Done!"); + mergeResolvedLocateStructures(resolvedLocateStructuresById, request.id(), request.resolvedLocateStructures()); sourceInputs.add(new RequestedSourceInput(syncResult.source(), request)); } @@ -172,6 +257,8 @@ public final class ExternalDataPackPipeline { if (summary.requiredFailures == 0) { summary.legacyWorldCopyRemovals += pruneManagedWorldDatapacks(knownWorldDatapackFolders, Set.of()); } + writeLocateManifest(resolvedLocateStructuresById); + RESOLVED_LOCATE_STRUCTURES_BY_ID.putAll(resolvedLocateStructuresById); return summary; } @@ -186,7 +273,8 @@ public final class ExternalDataPackPipeline { Set activeManagedWorldDatapackNames = new HashSet<>(); ImportSummary importSummary = new ImportSummary(); - for (RequestedSourceInput sourceInput : sourceInputs) { + for (int sourceIndex = 0; sourceIndex < sourceInputs.size(); sourceIndex++) { + RequestedSourceInput sourceInput = sourceInputs.get(sourceIndex); File entry = sourceInput.source(); DatapackRequest request = sourceInput.request(); if (entry == null || !entry.exists() || request == null) { @@ -235,12 +323,14 @@ public final class ExternalDataPackPipeline { JSONObject sourceResult = convertSource(entry, sourceDescriptor, sourceRoot); newSources.put(sourceResult); addSourceToSummary(importSummary, sourceResult, false); - if (sourceResult.optInt("failed", 0) > 0) { - if (request.required()) { - summary.requiredFailures++; - } else { - summary.optionalFailures++; - } + int conversionFailed = sourceResult.optInt("failed", 0); + if (conversionFailed > 0) { + int conversionScanned = sourceResult.optInt("nbtScanned", 0); + int conversionSuccess = sourceResult.optInt("converted", 0); + Iris.warn("External datapack object import had " + conversionFailed + + " failed structure conversion(s) for id=" + request.id() + + " source=" + sourceDescriptor.sourceName() + + " (scanned=" + conversionScanned + ", converted=" + conversionSuccess + ")."); } } @@ -248,9 +338,30 @@ public final class ExternalDataPackPipeline { ProjectionResult projectionResult = projectSourceToWorldDatapacks(entry, sourceDescriptor, request, targetWorldFolders); summary.worldDatapacksInstalled += projectionResult.installedDatapacks(); summary.worldAssetsInstalled += projectionResult.installedAssets(); + mergeResolvedLocateStructures(resolvedLocateStructuresById, request.id(), projectionResult.resolvedLocateStructures()); if (projectionResult.managedName() != null && !projectionResult.managedName().isBlank() && projectionResult.installedDatapacks() > 0) { activeManagedWorldDatapackNames.add(projectionResult.managedName()); } + if (projectionResult.success()) { + Iris.verbose("External datapack projection: id=" + request.id() + + ", source=" + sourceDescriptor.sourceName() + + ", targetPack=" + request.targetPack() + + ", managedDatapack=" + projectionResult.managedName() + + ", installedDatapacks=" + projectionResult.installedDatapacks() + + ", installedAssets=" + projectionResult.installedAssets() + + ", syntheticStructureSets=" + projectionResult.syntheticStructureSets() + + ", success=true"); + } else { + Iris.warn("External datapack projection: id=" + request.id() + + ", source=" + sourceDescriptor.sourceName() + + ", targetPack=" + request.targetPack() + + ", managedDatapack=" + projectionResult.managedName() + + ", installedDatapacks=" + projectionResult.installedDatapacks() + + ", installedAssets=" + projectionResult.installedAssets() + + ", syntheticStructureSets=" + projectionResult.syntheticStructureSets() + + ", success=false" + + ", reason=" + projectionResult.error()); + } if (!projectionResult.success()) { if (request.required()) { summary.requiredFailures++; @@ -267,9 +378,166 @@ public final class ExternalDataPackPipeline { summary.legacyWorldCopyRemovals += pruneManagedWorldDatapacks(knownWorldDatapackFolders, activeManagedWorldDatapackNames); } + writeLocateManifest(resolvedLocateStructuresById); + RESOLVED_LOCATE_STRUCTURES_BY_ID.putAll(resolvedLocateStructuresById); return summary; } + private static File getLocateManifestFile() { + return Iris.instance.getDataFile(LOCATE_MANIFEST_PATH); + } + + private static String normalizeLocateId(String id) { + if (id == null) { + return ""; + } + + String normalized = id.trim().toLowerCase(Locale.ROOT); + if (normalized.isBlank()) { + return ""; + } + + normalized = normalized.replace("minecraft:worldgen/structure/", ""); + normalized = normalized.replace("worldgen/structure/", ""); + return normalized; + } + + private static String normalizeLocateStructure(String structure) { + if (structure == null || structure.isBlank()) { + return ""; + } + String normalized = normalizeResourceKey("minecraft", structure, "worldgen/structure/"); + if (normalized == null || normalized.isBlank()) { + return ""; + } + return normalized; + } + + private static void mergeResolvedLocateStructures(Map> destination, String id, Set resolvedStructures) { + if (destination == null) { + return; + } + + String normalizedId = normalizeLocateId(id); + if (normalizedId.isBlank() || resolvedStructures == null || resolvedStructures.isEmpty()) { + return; + } + + Set merged = destination.computeIfAbsent(normalizedId, key -> new LinkedHashSet<>()); + for (String structure : resolvedStructures) { + String normalizedStructure = normalizeLocateStructure(structure); + if (!normalizedStructure.isBlank()) { + merged.add(normalizedStructure); + } + } + } + + private static void writeLocateManifest(Map> resolvedLocateStructuresById) { + File output = getLocateManifestFile(); + LinkedHashMap> normalized = new LinkedHashMap<>(); + if (resolvedLocateStructuresById != null) { + for (Map.Entry> entry : resolvedLocateStructuresById.entrySet()) { + String normalizedId = normalizeLocateId(entry.getKey()); + if (normalizedId.isBlank()) { + continue; + } + + LinkedHashSet structures = new LinkedHashSet<>(); + Set values = entry.getValue(); + if (values != null) { + for (String structure : values) { + String normalizedStructure = normalizeLocateStructure(structure); + if (!normalizedStructure.isBlank()) { + structures.add(normalizedStructure); + } + } + } + + if (!structures.isEmpty()) { + normalized.put(normalizedId, Set.copyOf(structures)); + } + } + } + + JSONObject root = new JSONObject(); + root.put("generatedAt", Instant.now().toString()); + JSONObject mappings = new JSONObject(); + ArrayList ids = new ArrayList<>(normalized.keySet()); + ids.sort(String::compareTo); + for (String id : ids) { + Set structures = normalized.get(id); + if (structures == null || structures.isEmpty()) { + continue; + } + + ArrayList sortedStructures = new ArrayList<>(structures); + sortedStructures.sort(String::compareTo); + JSONArray values = new JSONArray(); + for (String structure : sortedStructures) { + values.put(structure); + } + mappings.put(id, values); + } + root.put("ids", mappings); + + try { + writeBytesToFile(root.toString(4).getBytes(StandardCharsets.UTF_8), output); + } catch (Throwable e) { + Iris.warn("Failed to write external datapack locate manifest " + output.getPath()); + Iris.reportError(e); + } + } + + private static Map> readLocateManifest() { + LinkedHashMap> mapped = new LinkedHashMap<>(); + File input = getLocateManifestFile(); + if (!input.exists() || !input.isFile()) { + return mapped; + } + + try { + JSONObject root = new JSONObject(Files.readString(input.toPath(), StandardCharsets.UTF_8)); + JSONObject ids = root.optJSONObject("ids"); + if (ids == null) { + return mapped; + } + + ArrayList keys = new ArrayList<>(ids.keySet()); + keys.sort(String::compareTo); + for (String key : keys) { + String normalizedId = normalizeLocateId(key); + if (normalizedId.isBlank()) { + continue; + } + + LinkedHashSet structures = new LinkedHashSet<>(); + JSONArray values = ids.optJSONArray(key); + if (values != null) { + for (int i = 0; i < values.length(); i++) { + Object rawValue = values.opt(i); + if (rawValue == null) { + continue; + } + + String normalizedStructure = normalizeLocateStructure(String.valueOf(rawValue)); + if (!normalizedStructure.isBlank()) { + structures.add(normalizedStructure); + } + } + } + + if (!structures.isEmpty()) { + mapped.put(normalizedId, Set.copyOf(structures)); + } + } + } catch (Throwable e) { + Iris.warn("Failed to read external datapack locate manifest " + input.getPath()); + Iris.reportError(e); + } + + return mapped; + } + private static List normalizeRequests(List requests) { Map deduplicated = new HashMap<>(); if (requests == null) { @@ -515,12 +783,25 @@ public final class ExternalDataPackPipeline { private static ProjectionResult projectSourceToWorldDatapacks(File source, SourceDescriptor sourceDescriptor, DatapackRequest request, KList worldDatapackFolders) { if (source == null || sourceDescriptor == null || request == null) { - return ProjectionResult.failure(""); + return ProjectionResult.failure("", "invalid projection inputs"); } String managedName = buildManagedWorldDatapackName(sourceDescriptor.targetPack(), sourceDescriptor.sourceKey()); if (worldDatapackFolders == null || worldDatapackFolders.isEmpty()) { - return ProjectionResult.success(managedName, 0, 0); + return ProjectionResult.success(managedName, 0, 0, Set.copyOf(request.resolvedLocateStructures()), 0); + } + + ProjectionAssetSummary projectionAssetSummary; + try { + projectionAssetSummary = buildProjectedAssets(source, sourceDescriptor, request); + } catch (Throwable e) { + Iris.warn("Failed to prepare projected external datapack assets from " + sourceDescriptor.sourceName()); + Iris.reportError(e); + return ProjectionResult.failure(managedName, e.getMessage()); + } + + if (projectionAssetSummary.assets().isEmpty()) { + return ProjectionResult.success(managedName, 0, 0, projectionAssetSummary.resolvedLocateStructures(), projectionAssetSummary.syntheticStructureSets()); } int installedDatapacks = 0; @@ -534,7 +815,7 @@ public final class ExternalDataPackPipeline { worldDatapackFolder.mkdirs(); File managedFolder = new File(worldDatapackFolder, managedName); deleteFolder(managedFolder); - int copiedAssets = copyProjectedEntries(source, managedFolder, request); + int copiedAssets = writeProjectedAssets(managedFolder, projectionAssetSummary.assets()); if (copiedAssets <= 0) { deleteFolder(managedFolder); continue; @@ -545,25 +826,150 @@ public final class ExternalDataPackPipeline { } catch (Throwable e) { Iris.warn("Failed to project external datapack source " + sourceDescriptor.sourceName() + " into " + worldDatapackFolder.getPath()); Iris.reportError(e); - return ProjectionResult.failure(managedName); + return ProjectionResult.failure(managedName, e.getMessage()); } } - return ProjectionResult.success(managedName, installedDatapacks, installedAssets); + return ProjectionResult.success(managedName, installedDatapacks, installedAssets, projectionAssetSummary.resolvedLocateStructures(), projectionAssetSummary.syntheticStructureSets()); } - private static int copyProjectedEntries(File source, File managedFolder, DatapackRequest request) throws IOException { + private static ProjectionAssetSummary buildProjectedAssets(File source, SourceDescriptor sourceDescriptor, DatapackRequest request) throws IOException { + ProjectionSelection projectionSelection = readProjectedEntries(source, request); + if (request.required() && !projectionSelection.missingSeededTargets().isEmpty()) { + throw new IOException("Required replaceVanilla projection missing seeded target(s): " + summarizeMissingSeededTargets(projectionSelection.missingSeededTargets())); + } + + List inputAssets = projectionSelection.assets(); + if (inputAssets.isEmpty()) { + return new ProjectionAssetSummary(List.of(), Set.copyOf(request.resolvedLocateStructures()), 0); + } + + String scopeNamespace = buildScopeNamespace(sourceDescriptor, request); + LinkedHashMap remappedKeys = new LinkedHashMap<>(); + for (ProjectionInputAsset inputAsset : inputAssets) { + if (inputAsset.entry().namespace().equals("minecraft") && request.alongsideMode()) { + String remappedKey = scopeNamespace + ":" + extractPathFromKey(inputAsset.entry().key()); + remappedKeys.put(inputAsset.entry().key(), remappedKey); + } + } + + LinkedHashMap remapStringValues = new LinkedHashMap<>(); + for (Map.Entry entry : remappedKeys.entrySet()) { + remapStringValues.put(entry.getKey(), entry.getValue()); + remapStringValues.put("#" + entry.getKey(), "#" + entry.getValue()); + } + + LinkedHashMap> scopedTagValues = new LinkedHashMap<>(); + LinkedHashSet resolvedLocateStructures = new LinkedHashSet<>(); + resolvedLocateStructures.addAll(request.resolvedLocateStructures()); + LinkedHashSet remappedStructureKeys = new LinkedHashSet<>(); + LinkedHashSet structureSetReferences = new LinkedHashSet<>(); + LinkedHashSet writtenPaths = new LinkedHashSet<>(); + ArrayList outputAssets = new ArrayList<>(); + + for (ProjectionInputAsset inputAsset : inputAssets) { + ProjectedEntry projectedEntry = inputAsset.entry(); + String remappedKey = remappedKeys.get(projectedEntry.key()); + ProjectedEntry effectiveEntry = remappedKey == null + ? projectedEntry + : new ProjectedEntry(projectedEntry.type(), extractNamespaceFromKey(remappedKey), remappedKey); + + String outputRelativePath = buildProjectedPath(effectiveEntry); + if (outputRelativePath == null || writtenPaths.contains(outputRelativePath)) { + continue; + } + writtenPaths.add(outputRelativePath); + + byte[] outputBytes = inputAsset.bytes(); + if (projectedEntry.type() == ProjectedEntryType.STRUCTURE + || projectedEntry.type() == ProjectedEntryType.STRUCTURE_SET + || projectedEntry.type() == ProjectedEntryType.CONFIGURED_FEATURE + || projectedEntry.type() == ProjectedEntryType.PLACED_FEATURE + || projectedEntry.type() == ProjectedEntryType.TEMPLATE_POOL + || projectedEntry.type() == ProjectedEntryType.PROCESSOR_LIST + || projectedEntry.type() == ProjectedEntryType.BIOME_HAS_STRUCTURE_TAG) { + JSONObject root = new JSONObject(new String(outputBytes, StandardCharsets.UTF_8)); + rewriteJsonValues(root, remapStringValues); + + if (projectedEntry.type() == ProjectedEntryType.STRUCTURE) { + Integer startHeightAbsolute = getPatchedStartHeightAbsolute(projectedEntry, request); + if (startHeightAbsolute == null && remappedKey != null) { + startHeightAbsolute = request.structureStartHeights().get(remappedKey); + } + + if (startHeightAbsolute != null) { + JSONObject startHeight = new JSONObject(); + startHeight.put("absolute", startHeightAbsolute); + root.put("start_height", startHeight); + } + + if (!request.forcedBiomeKeys().isEmpty()) { + String scopeTagKey = scopeNamespace + ":has_structure/" + extractPathFromKey(effectiveEntry.key()); + root.put("biomes", "#" + scopeTagKey); + KList values = scopedTagValues.computeIfAbsent(scopeTagKey, key -> new KList<>()); + values.addAll(request.forcedBiomeKeys()); + } + + remappedStructureKeys.add(effectiveEntry.key()); + resolvedLocateStructures.add(effectiveEntry.key()); + } else if (projectedEntry.type() == ProjectedEntryType.STRUCTURE_SET) { + structureSetReferences.addAll(readStructureSetReferences(root)); + } + + outputBytes = root.toString(4).getBytes(StandardCharsets.UTF_8); + } + + outputAssets.add(new ProjectionOutputAsset(outputRelativePath, outputBytes)); + } + + int syntheticStructureSets = 0; + if (request.alongsideMode()) { + SyntheticStructureSetResult syntheticResult = synthesizeMissingStructureSets( + remappedStructureKeys, + structureSetReferences, + remappedKeys, + scopeNamespace, + writtenPaths + ); + outputAssets.addAll(syntheticResult.assets()); + syntheticStructureSets += syntheticResult.count(); + } + + for (Map.Entry> scopedEntry : scopedTagValues.entrySet()) { + String tagKey = scopedEntry.getKey(); + String tagPath = "data/" + extractNamespaceFromKey(tagKey) + "/tags/worldgen/biome/" + extractPathFromKey(tagKey) + ".json"; + if (writtenPaths.contains(tagPath)) { + continue; + } + writtenPaths.add(tagPath); + + KList uniqueValues = scopedEntry.getValue().removeDuplicates().sort(); + JSONArray values = new JSONArray(); + for (String value : uniqueValues) { + values.put(value); + } + + JSONObject root = new JSONObject(); + root.put("replace", false); + root.put("values", values); + outputAssets.add(new ProjectionOutputAsset(tagPath, root.toString(4).getBytes(StandardCharsets.UTF_8))); + } + + return new ProjectionAssetSummary(outputAssets, Set.copyOf(resolvedLocateStructures), syntheticStructureSets); + } + + private static ProjectionSelection readProjectedEntries(File source, DatapackRequest request) throws IOException { if (source.isDirectory()) { - return copyProjectedDirectoryEntries(source, managedFolder, request); + return selectProjectedEntries(readProjectedDirectoryEntries(source), request); } if (isArchive(source.getName())) { - return copyProjectedArchiveEntries(source, managedFolder, request); + return selectProjectedEntries(readProjectedArchiveEntries(source), request); } - return 0; + return ProjectionSelection.empty(); } - private static int copyProjectedDirectoryEntries(File source, File managedFolder, DatapackRequest request) throws IOException { - int copied = 0; + private static List readProjectedDirectoryEntries(File source) throws IOException { + ArrayList assets = new ArrayList<>(); ArrayDeque queue = new ArrayDeque<>(); queue.add(source); while (!queue.isEmpty()) { @@ -590,24 +996,19 @@ public final class ExternalDataPackPipeline { } ProjectedEntry projectedEntry = parseProjectedEntry(normalizedRelative); - if (!shouldProjectEntry(projectedEntry, request)) { + if (projectedEntry == null) { continue; } - File output = new File(managedFolder, normalizedRelative); - File parent = output.getParentFile(); - if (parent != null) { - parent.mkdirs(); - } - copyProjectedFileEntry(child, output, request, projectedEntry); - copied++; + byte[] bytes = Files.readAllBytes(child.toPath()); + assets.add(new ProjectionInputAsset(normalizedRelative, projectedEntry, bytes)); } } - return copied; + return assets; } - private static int copyProjectedArchiveEntries(File source, File managedFolder, DatapackRequest request) throws IOException { - int copied = 0; + private static List readProjectedArchiveEntries(File source) throws IOException { + ArrayList assets = new ArrayList<>(); try (ZipFile zipFile = new ZipFile(source)) { List entries = zipFile.stream() .filter(entry -> !entry.isDirectory()) @@ -621,47 +1022,279 @@ public final class ExternalDataPackPipeline { } ProjectedEntry projectedEntry = parseProjectedEntry(normalizedRelative); - if (!shouldProjectEntry(projectedEntry, request)) { + if (projectedEntry == null) { continue; } - File output = new File(managedFolder, normalizedRelative); - File parent = output.getParentFile(); - if (parent != null) { - parent.mkdirs(); - } try (InputStream inputStream = zipFile.getInputStream(zipEntry)) { - copyProjectedStreamEntry(inputStream, output, request, projectedEntry); + byte[] bytes = inputStream.readAllBytes(); + assets.add(new ProjectionInputAsset(normalizedRelative, projectedEntry, bytes)); } - copied++; } } + return assets; + } + + private static ProjectionSelection selectProjectedEntries(List inputAssets, DatapackRequest request) { + if (inputAssets == null || inputAssets.isEmpty() || request == null) { + return ProjectionSelection.empty(); + } + + if (!request.alongsideMode() && request.replaceVanilla()) { + return selectReplaceVanillaEntries(inputAssets, request); + } + + ArrayList selected = new ArrayList<>(); + for (ProjectionInputAsset asset : inputAssets) { + if (asset == null) { + continue; + } + + if (shouldProjectEntry(asset.entry(), request)) { + selected.add(asset); + } + } + + return new ProjectionSelection(selected, Set.of()); + } + + private static ProjectionSelection selectReplaceVanillaEntries(List inputAssets, DatapackRequest request) { + EnumMap> minecraftAssets = new EnumMap<>(ProjectedEntryType.class); + EnumMap> closure = new EnumMap<>(ProjectedEntryType.class); + for (ProjectedEntryType type : ProjectedEntryType.values()) { + minecraftAssets.put(type, new LinkedHashMap<>()); + closure.put(type, new LinkedHashSet<>()); + } + + for (ProjectionInputAsset asset : inputAssets) { + if (asset == null || asset.entry() == null) { + continue; + } + + if (!"minecraft".equals(asset.entry().namespace())) { + continue; + } + + minecraftAssets.get(asset.entry().type()).put(asset.entry().key(), asset); + } + + LinkedHashSet missingSeededTargets = new LinkedHashSet<>(); + ArrayDeque queue = new ArrayDeque<>(); + enqueueSeedTargets(request.structures(), ProjectedEntryType.STRUCTURE, minecraftAssets, missingSeededTargets, queue); + enqueueSeedTargets(request.structureSets(), ProjectedEntryType.STRUCTURE_SET, minecraftAssets, missingSeededTargets, queue); + enqueueSeedTargets(request.configuredFeatures(), ProjectedEntryType.CONFIGURED_FEATURE, minecraftAssets, missingSeededTargets, queue); + enqueueSeedTargets(request.placedFeatures(), ProjectedEntryType.PLACED_FEATURE, minecraftAssets, missingSeededTargets, queue); + enqueueSeedTargets(request.templatePools(), ProjectedEntryType.TEMPLATE_POOL, minecraftAssets, missingSeededTargets, queue); + enqueueSeedTargets(request.processorLists(), ProjectedEntryType.PROCESSOR_LIST, minecraftAssets, missingSeededTargets, queue); + enqueueSeedTargets(request.biomeHasStructureTags(), ProjectedEntryType.BIOME_HAS_STRUCTURE_TAG, minecraftAssets, missingSeededTargets, queue); + + while (!queue.isEmpty()) { + ProjectedDependency current = queue.removeFirst(); + if (current == null || current.key() == null || current.key().isBlank()) { + continue; + } + + LinkedHashSet visited = closure.get(current.type()); + if (visited == null || !visited.add(current.key())) { + continue; + } + + ProjectionInputAsset currentAsset = minecraftAssets.get(current.type()).get(current.key()); + if (currentAsset == null) { + continue; + } + + if (current.type() == ProjectedEntryType.STRUCTURE_NBT) { + continue; + } + + if (!isJsonProjectedEntryType(current.type())) { + continue; + } + + try { + JSONObject root = new JSONObject(new String(currentAsset.bytes(), StandardCharsets.UTF_8)); + LinkedHashSet dependencies = new LinkedHashSet<>(); + collectProjectedDependencies(root, current.type(), dependencies); + for (ProjectedDependency dependency : dependencies) { + if (dependency == null || dependency.key() == null || dependency.key().isBlank()) { + continue; + } + if (!dependency.key().startsWith("minecraft:")) { + continue; + } + if (!minecraftAssets.get(dependency.type()).containsKey(dependency.key())) { + continue; + } + queue.addLast(dependency); + } + } catch (Throwable ignored) { + } + } + + ArrayList selected = new ArrayList<>(); + for (ProjectionInputAsset asset : inputAssets) { + if (asset == null || asset.entry() == null) { + continue; + } + + if (!"minecraft".equals(asset.entry().namespace())) { + selected.add(asset); + continue; + } + + LinkedHashSet selectedKeys = closure.get(asset.entry().type()); + if (selectedKeys != null && selectedKeys.contains(asset.entry().key())) { + selected.add(asset); + } + } + + return new ProjectionSelection(selected, Set.copyOf(missingSeededTargets)); + } + + private static void enqueueSeedTargets( + Set keys, + ProjectedEntryType type, + Map> minecraftAssets, + Set missingSeededTargets, + ArrayDeque queue + ) { + if (keys == null || keys.isEmpty()) { + return; + } + + LinkedHashMap typedAssets = minecraftAssets.get(type); + for (String key : keys) { + if (key == null || key.isBlank()) { + continue; + } + + if (typedAssets == null || !typedAssets.containsKey(key)) { + missingSeededTargets.add(type.name().toLowerCase(Locale.ROOT) + ":" + key); + continue; + } + + queue.addLast(new ProjectedDependency(type, key)); + } + } + + private static boolean isJsonProjectedEntryType(ProjectedEntryType type) { + return type == ProjectedEntryType.STRUCTURE + || type == ProjectedEntryType.STRUCTURE_SET + || type == ProjectedEntryType.CONFIGURED_FEATURE + || type == ProjectedEntryType.PLACED_FEATURE + || type == ProjectedEntryType.TEMPLATE_POOL + || type == ProjectedEntryType.PROCESSOR_LIST + || type == ProjectedEntryType.BIOME_HAS_STRUCTURE_TAG; + } + + private static void collectProjectedDependencies(Object node, ProjectedEntryType ownerType, Set dependencies) { + if (node == null) { + return; + } + + if (node instanceof JSONObject object) { + for (String key : object.keySet()) { + collectProjectedDependencies(object.get(key), ownerType, dependencies); + } + return; + } + + if (node instanceof JSONArray array) { + for (int index = 0; index < array.length(); index++) { + collectProjectedDependencies(array.get(index), ownerType, dependencies); + } + return; + } + + if (!(node instanceof String rawValue)) { + return; + } + + String value = rawValue.trim(); + if (value.isBlank()) { + return; + } + + addDependency(ownerType, dependencies, value, ProjectedEntryType.STRUCTURE, "worldgen/structure/"); + addDependency(ownerType, dependencies, value, ProjectedEntryType.STRUCTURE_SET, "worldgen/structure_set/"); + addDependency(ownerType, dependencies, value, ProjectedEntryType.CONFIGURED_FEATURE, "worldgen/configured_feature/"); + addDependency(ownerType, dependencies, value, ProjectedEntryType.PLACED_FEATURE, "worldgen/placed_feature/"); + addDependency(ownerType, dependencies, value, ProjectedEntryType.TEMPLATE_POOL, "worldgen/template_pool/"); + addDependency(ownerType, dependencies, value, ProjectedEntryType.PROCESSOR_LIST, "worldgen/processor_list/"); + addDependency(ownerType, dependencies, value, ProjectedEntryType.BIOME_HAS_STRUCTURE_TAG, + "tags/worldgen/biome/has_structure/", + "worldgen/biome/has_structure/", + "has_structure/"); + addDependency(ownerType, dependencies, value, ProjectedEntryType.STRUCTURE_NBT, "structure/", "structures/"); + } + + private static void addDependency( + ProjectedEntryType ownerType, + Set dependencies, + String value, + ProjectedEntryType dependencyType, + String... prefixes + ) { + String normalized = normalizeResourceKey("minecraft", value, prefixes); + if (normalized == null || normalized.isBlank()) { + return; + } + + if (ownerType == dependencyType && ownerType != ProjectedEntryType.TEMPLATE_POOL) { + return; + } + + dependencies.add(new ProjectedDependency(dependencyType, normalized)); + } + + private static String summarizeMissingSeededTargets(Set missingSeededTargets) { + if (missingSeededTargets == null || missingSeededTargets.isEmpty()) { + return ""; + } + + ArrayList sorted = new ArrayList<>(missingSeededTargets); + sorted.sort(String::compareTo); + if (sorted.size() <= 8) { + return String.join(", ", sorted); + } + + ArrayList limited = new ArrayList<>(sorted.subList(0, 8)); + return String.join(", ", limited) + " +" + (sorted.size() - limited.size()) + " more"; + } + + private static int writeProjectedAssets(File managedFolder, List assets) throws IOException { + if (assets == null || assets.isEmpty()) { + return 0; + } + + int copied = 0; + for (ProjectionOutputAsset asset : assets) { + if (asset == null || asset.relativePath() == null || asset.bytes() == null) { + continue; + } + File output = new File(managedFolder, asset.relativePath()); + writeBytesToFile(asset.bytes(), output); + copied++; + } return copied; } - private static void copyProjectedFileEntry(File sourceFile, File output, DatapackRequest request, ProjectedEntry projectedEntry) throws IOException { - Integer startHeightAbsolute = getPatchedStartHeightAbsolute(projectedEntry, request); - if (startHeightAbsolute == null) { - copyFile(sourceFile, output); - return; + private static void writeBytesToFile(byte[] data, File output) throws IOException { + File parent = output.getParentFile(); + if (parent != null) { + parent.mkdirs(); } - String content = Files.readString(sourceFile.toPath(), StandardCharsets.UTF_8); - String patched = applyStructureStartHeightPatch(content, startHeightAbsolute); - Files.writeString(output.toPath(), patched, StandardCharsets.UTF_8); - } - - private static void copyProjectedStreamEntry(InputStream inputStream, File output, DatapackRequest request, ProjectedEntry projectedEntry) throws IOException { - Integer startHeightAbsolute = getPatchedStartHeightAbsolute(projectedEntry, request); - if (startHeightAbsolute == null) { - writeInputStreamToFile(inputStream, output); - return; + File temp = parent == null + ? new File(output.getPath() + ".tmp-" + System.nanoTime()) + : new File(parent, output.getName() + ".tmp-" + System.nanoTime()); + Files.write(temp.toPath(), data); + try { + Files.move(temp.toPath(), output.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (IOException e) { + Files.move(temp.toPath(), output.toPath(), StandardCopyOption.REPLACE_EXISTING); } - - byte[] bytes = inputStream.readAllBytes(); - String content = new String(bytes, StandardCharsets.UTF_8); - String patched = applyStructureStartHeightPatch(content, startHeightAbsolute); - Files.writeString(output.toPath(), patched, StandardCharsets.UTF_8); } private static Integer getPatchedStartHeightAbsolute(ProjectedEntry projectedEntry, DatapackRequest request) { @@ -671,12 +1304,261 @@ public final class ExternalDataPackPipeline { return request.structureStartHeights().get(projectedEntry.key()); } - private static String applyStructureStartHeightPatch(String content, int startHeightAbsolute) { - JSONObject root = new JSONObject(content); - JSONObject startHeight = new JSONObject(); - startHeight.put("absolute", startHeightAbsolute); - root.put("start_height", startHeight); - return root.toString(4); + private static void rewriteJsonValues(Object root, Map replacements) { + if (root instanceof JSONObject object) { + for (String key : object.keySet()) { + Object value = object.get(key); + Object rewritten = rewriteJsonValue(value, replacements); + if (rewritten != value) { + object.put(key, rewritten); + } + } + return; + } + if (root instanceof JSONArray array) { + for (int i = 0; i < array.length(); i++) { + Object value = array.get(i); + Object rewritten = rewriteJsonValue(value, replacements); + if (rewritten != value) { + array.put(i, rewritten); + } + } + } + } + + private static Object rewriteJsonValue(Object value, Map replacements) { + if (value instanceof JSONObject object) { + rewriteJsonValues(object, replacements); + return object; + } + if (value instanceof JSONArray array) { + rewriteJsonValues(array, replacements); + return array; + } + if (value instanceof String stringValue) { + String replacement = replacements.get(stringValue); + if (replacement != null) { + return replacement; + } + } + return value; + } + + private static Set readStructureSetReferences(JSONObject root) { + LinkedHashSet references = new LinkedHashSet<>(); + if (root == null) { + return references; + } + + JSONArray structures = root.optJSONArray("structures"); + if (structures == null) { + return references; + } + + for (int i = 0; i < structures.length(); i++) { + JSONObject structure = structures.optJSONObject(i); + if (structure == null) { + continue; + } + String structureKey = structure.optString("structure", ""); + if (structureKey.isBlank()) { + continue; + } + String normalizedStructure = normalizeResourceKey("minecraft", structureKey, "worldgen/structure/"); + if (normalizedStructure == null || normalizedStructure.isBlank()) { + continue; + } + references.add(normalizedStructure); + } + return references; + } + + private static SyntheticStructureSetResult synthesizeMissingStructureSets( + Set remappedStructureKeys, + Set structureSetReferences, + Map remappedKeys, + String scopeNamespace, + Set writtenPaths + ) { + if (remappedStructureKeys == null || remappedStructureKeys.isEmpty()) { + return SyntheticStructureSetResult.empty(); + } + + LinkedHashMap reverseRemap = new LinkedHashMap<>(); + for (Map.Entry entry : remappedKeys.entrySet()) { + reverseRemap.put(entry.getValue(), entry.getKey()); + } + + KMap vanillaPlacements = VANILLA_STRUCTURE_PLACEMENTS.aquire(() -> INMS.get().collectStructures()); + if (vanillaPlacements == null || vanillaPlacements.isEmpty()) { + return SyntheticStructureSetResult.empty(); + } + + ArrayList assets = new ArrayList<>(); + int synthesized = 0; + for (String remappedStructure : remappedStructureKeys) { + if (structureSetReferences.contains(remappedStructure)) { + continue; + } + + String originalStructure = reverseRemap.get(remappedStructure); + if (originalStructure == null || originalStructure.isBlank()) { + continue; + } + + SyntheticPlacement syntheticPlacement = findSyntheticPlacement(vanillaPlacements, originalStructure); + if (syntheticPlacement == null) { + Iris.warn("Unable to synthesize structure set for remapped structure " + remappedStructure + " (no vanilla placement found)."); + continue; + } + + JSONObject structureSetRoot = buildSyntheticStructureSetJson(syntheticPlacement, remappedStructure); + if (structureSetRoot == null) { + Iris.warn("Unable to synthesize structure set for remapped structure " + remappedStructure + " (unsupported placement type)."); + continue; + } + + String structurePath = sanitizePath(extractPathFromKey(remappedStructure)); + if (structurePath.isBlank()) { + structurePath = "structure"; + } + String syntheticSetKey = scopeNamespace + ":generated/" + structurePath.replace('/', '_'); + String syntheticPath = buildProjectedPath(new ProjectedEntry(ProjectedEntryType.STRUCTURE_SET, scopeNamespace, syntheticSetKey)); + if (syntheticPath == null) { + continue; + } + if (writtenPaths.contains(syntheticPath)) { + syntheticSetKey = syntheticSetKey + "-" + shortHash(remappedStructure + "|" + syntheticPlacement.structureSetKey()); + syntheticPath = buildProjectedPath(new ProjectedEntry(ProjectedEntryType.STRUCTURE_SET, scopeNamespace, syntheticSetKey)); + } + if (syntheticPath == null || writtenPaths.contains(syntheticPath)) { + continue; + } + + writtenPaths.add(syntheticPath); + structureSetReferences.add(remappedStructure); + assets.add(new ProjectionOutputAsset(syntheticPath, structureSetRoot.toString(4).getBytes(StandardCharsets.UTF_8))); + synthesized++; + } + + return new SyntheticStructureSetResult(assets, synthesized); + } + + private static SyntheticPlacement findSyntheticPlacement(KMap vanillaPlacements, String structureKey) { + if (vanillaPlacements == null || vanillaPlacements.isEmpty() || structureKey == null || structureKey.isBlank()) { + return null; + } + + for (Map.Entry entry : vanillaPlacements.entrySet()) { + StructurePlacement placement = entry.getValue(); + if (placement == null || placement.structures() == null || placement.structures().isEmpty()) { + continue; + } + + for (StructurePlacement.Structure structure : placement.structures()) { + if (structure == null || structure.key() == null || structure.key().isBlank()) { + continue; + } + if (!structureKey.equalsIgnoreCase(structure.key())) { + continue; + } + int weight = structure.weight() > 0 ? structure.weight() : 1; + return new SyntheticPlacement(entry.getKey(), placement, weight); + } + } + + return null; + } + + private static JSONObject buildSyntheticStructureSetJson(SyntheticPlacement placement, String structureKey) { + if (placement == null || placement.placement() == null || structureKey == null || structureKey.isBlank()) { + return null; + } + + JSONObject root = new JSONObject(); + JSONArray structures = new JSONArray(); + JSONObject structure = new JSONObject(); + structure.put("structure", structureKey); + structure.put("weight", placement.weight()); + structures.put(structure); + root.put("structures", structures); + + StructurePlacement structurePlacement = placement.placement(); + JSONObject placementJson = new JSONObject(); + placementJson.put("salt", structurePlacement.salt()); + if (structurePlacement instanceof StructurePlacement.RandomSpread randomSpread) { + placementJson.put("type", "minecraft:random_spread"); + placementJson.put("spacing", randomSpread.spacing()); + placementJson.put("separation", randomSpread.separation()); + if (randomSpread.spreadType() != null) { + placementJson.put("spread_type", randomSpread.spreadType().name().toLowerCase(Locale.ROOT)); + } + float frequency = structurePlacement.frequency(); + if (frequency > 0F && frequency < 0.999999F) { + placementJson.put("frequency", frequency); + placementJson.put("frequency_reduction_method", "default"); + } + } else if (structurePlacement instanceof StructurePlacement.ConcentricRings concentricRings) { + placementJson.put("type", "minecraft:concentric_rings"); + placementJson.put("distance", concentricRings.distance()); + placementJson.put("spread", concentricRings.spread()); + placementJson.put("count", concentricRings.count()); + } else { + return null; + } + + root.put("placement", placementJson); + return root; + } + + private static String buildScopeNamespace(SourceDescriptor sourceDescriptor, DatapackRequest request) { + String base = shortHash(sourceDescriptor.sourceKey() + "|" + request.id() + "|" + request.scopeKey()); + return "iris_external_" + base; + } + + private static String buildProjectedPath(ProjectedEntry entry) { + if (entry == null || entry.key() == null || entry.key().isBlank()) { + return null; + } + + String namespace = extractNamespaceFromKey(entry.key()); + String path = extractPathFromKey(entry.key()); + if (namespace.isBlank() || path.isBlank()) { + return null; + } + + return switch (entry.type()) { + case STRUCTURE -> "data/" + namespace + "/worldgen/structure/" + path + ".json"; + case STRUCTURE_SET -> "data/" + namespace + "/worldgen/structure_set/" + path + ".json"; + case CONFIGURED_FEATURE -> "data/" + namespace + "/worldgen/configured_feature/" + path + ".json"; + case PLACED_FEATURE -> "data/" + namespace + "/worldgen/placed_feature/" + path + ".json"; + case TEMPLATE_POOL -> "data/" + namespace + "/worldgen/template_pool/" + path + ".json"; + case PROCESSOR_LIST -> "data/" + namespace + "/worldgen/processor_list/" + path + ".json"; + case BIOME_HAS_STRUCTURE_TAG -> "data/" + namespace + "/tags/worldgen/biome/has_structure/" + path + ".json"; + case STRUCTURE_NBT -> "data/" + namespace + "/structures/" + path + ".nbt"; + }; + } + + private static String extractNamespaceFromKey(String key) { + if (key == null || key.isBlank()) { + return ""; + } + int colon = key.indexOf(':'); + if (colon <= 0) { + return ""; + } + return sanitizePath(key.substring(0, colon)); + } + + private static String extractPathFromKey(String key) { + if (key == null || key.isBlank()) { + return ""; + } + int colon = key.indexOf(':'); + if (colon < 0 || colon + 1 >= key.length()) { + return sanitizePath(key); + } + return sanitizePath(key.substring(colon + 1)); } private static void writeInputStreamToFile(InputStream inputStream, File output) throws IOException { @@ -716,6 +1598,10 @@ public final class ExternalDataPackPipeline { return true; } + if (request.alongsideMode()) { + return true; + } + if (!request.replaceVanilla()) { return false; } @@ -867,6 +1753,22 @@ public final class ExternalDataPackPipeline { return keyNamespace + ":" + path; } + private static String normalizeBiomeKey(String value) { + if (value == null || value.isBlank()) { + return null; + } + + String cleaned = value.trim().replace('\\', '/'); + if (cleaned.startsWith("#")) { + cleaned = cleaned.substring(1); + } + if (cleaned.isBlank()) { + return null; + } + + return normalizeResourceKey("minecraft", cleaned, "worldgen/biome/"); + } + private static void collectWorldDatapackFolders(Set folders) { try { File container = Bukkit.getWorldContainer(); @@ -1903,7 +2805,11 @@ public final class ExternalDataPackPipeline { Set templatePools, Set processorLists, Set biomeHasStructureTags, - Map structureStartHeights + Map structureStartHeights, + Set forcedBiomeKeys, + String scopeKey, + boolean alongsideMode, + Set resolvedLocateStructures ) { public DatapackRequest( String id, @@ -1914,6 +2820,36 @@ public final class ExternalDataPackPipeline { boolean replaceVanilla, IrisExternalDatapackReplaceTargets replaceTargets, KList structurePatches + ) { + this( + id, + url, + targetPack, + requiredEnvironment, + required, + replaceVanilla, + replaceTargets, + structurePatches, + Set.of(), + "dimension-root", + !replaceVanilla, + Set.of() + ); + } + + public DatapackRequest( + String id, + String url, + String targetPack, + String requiredEnvironment, + boolean required, + boolean replaceVanilla, + IrisExternalDatapackReplaceTargets replaceTargets, + KList structurePatches, + Set forcedBiomeKeys, + String scopeKey, + boolean alongsideMode, + Set resolvedLocateStructures ) { this( normalizeRequestId(id, url), @@ -1932,7 +2868,11 @@ public final class ExternalDataPackPipeline { "tags/worldgen/biome/has_structure/", "worldgen/biome/has_structure/", "has_structure/"), - normalizeStructureStartHeights(structurePatches) + normalizeStructureStartHeights(structurePatches), + normalizeBiomeKeys(forcedBiomeKeys), + normalizeScopeKey(scopeKey), + alongsideMode, + normalizeLocateStructures(resolvedLocateStructures, replaceTargets == null ? null : replaceTargets.getStructures()) ); } @@ -1949,10 +2889,14 @@ public final class ExternalDataPackPipeline { processorLists = immutableSet(processorLists); biomeHasStructureTags = immutableSet(biomeHasStructureTags); structureStartHeights = immutableMap(structureStartHeights); + forcedBiomeKeys = immutableBiomeSet(forcedBiomeKeys); + scopeKey = normalizeScopeKey(scopeKey); + alongsideMode = alongsideMode || !replaceVanilla; + resolvedLocateStructures = immutableLocateSet(resolvedLocateStructures, structures); } public String getDedupeKey() { - return targetPack + "|" + url; + return targetPack + "|" + url + "|" + scopeKey + "|" + replaceVanilla + "|" + required + "|" + setFingerprint(forcedBiomeKeys); } public boolean hasReplacementTargets() { @@ -1987,7 +2931,11 @@ public final class ExternalDataPackPipeline { union(templatePools, other.templatePools), union(processorLists, other.processorLists), union(biomeHasStructureTags, other.biomeHasStructureTags), - unionStructureStartHeights(structureStartHeights, other.structureStartHeights) + unionStructureStartHeights(structureStartHeights, other.structureStartHeights), + union(forcedBiomeKeys, other.forcedBiomeKeys), + normalizeScopeKey(scopeKey), + alongsideMode || other.alongsideMode, + union(resolvedLocateStructures, other.resolvedLocateStructures) ); } @@ -2007,6 +2955,45 @@ public final class ExternalDataPackPipeline { return defaultTargetPack(); } + private static Set normalizeBiomeKeys(Set values) { + LinkedHashSet normalized = new LinkedHashSet<>(); + if (values == null) { + return normalized; + } + + for (String value : values) { + String normalizedBiome = normalizeBiomeKey(value); + if (normalizedBiome != null && !normalizedBiome.isBlank()) { + normalized.add(normalizedBiome); + } + } + + return normalized; + } + + private static Set normalizeLocateStructures(Set values, KList structureValues) { + LinkedHashSet normalized = new LinkedHashSet<>(); + if (values != null) { + for (String value : values) { + String normalizedStructure = normalizeLocateStructure(value); + if (!normalizedStructure.isBlank()) { + normalized.add(normalizedStructure); + } + } + } + + if (structureValues != null) { + for (String structure : structureValues) { + String normalizedStructure = normalizeLocateStructure(structure); + if (!normalizedStructure.isBlank()) { + normalized.add(normalizedStructure); + } + } + } + + return normalized; + } + private static Set normalizeTargets(KList values, String... prefixes) { LinkedHashSet normalized = new LinkedHashSet<>(); if (values == null) { @@ -2029,6 +3016,40 @@ public final class ExternalDataPackPipeline { return Set.copyOf(copy); } + private static Set immutableBiomeSet(Set values) { + LinkedHashSet copy = new LinkedHashSet<>(); + if (values != null) { + for (String value : values) { + String normalized = normalizeBiomeKey(value); + if (normalized != null && !normalized.isBlank()) { + copy.add(normalized); + } + } + } + return Set.copyOf(copy); + } + + private static Set immutableLocateSet(Set values, Set structureTargets) { + LinkedHashSet copy = new LinkedHashSet<>(); + if (values != null) { + for (String value : values) { + String normalized = normalizeLocateStructure(value); + if (!normalized.isBlank()) { + copy.add(normalized); + } + } + } + if (structureTargets != null) { + for (String structureTarget : structureTargets) { + String normalized = normalizeLocateStructure(structureTarget); + if (!normalized.isBlank()) { + copy.add(normalized); + } + } + } + return Set.copyOf(copy); + } + private static Map immutableMap(Map values) { LinkedHashMap copy = new LinkedHashMap<>(); if (values != null) { @@ -2085,6 +3106,23 @@ public final class ExternalDataPackPipeline { } return merged; } + + private static String normalizeScopeKey(String value) { + String normalized = sanitizePath(value).replace("/", "_"); + if (normalized.isBlank()) { + return "dimension-root"; + } + return normalized; + } + + private static String setFingerprint(Set values) { + if (values == null || values.isEmpty()) { + return "none"; + } + ArrayList sorted = new ArrayList<>(values); + sorted.sort(String::compareTo); + return shortHash(String.join(",", sorted)); + } } public static final class PipelineSummary { @@ -2223,13 +3261,102 @@ public final class ExternalDataPackPipeline { BIOME_HAS_STRUCTURE_TAG } - private record ProjectionResult(boolean success, int installedDatapacks, int installedAssets, String managedName) { - private static ProjectionResult success(String managedName, int installedDatapacks, int installedAssets) { - return new ProjectionResult(true, installedDatapacks, installedAssets, managedName); + private record ProjectedDependency(ProjectedEntryType type, String key) { + } + + private record ProjectionSelection(List assets, Set missingSeededTargets) { + private ProjectionSelection { + assets = assets == null ? List.of() : List.copyOf(assets); + missingSeededTargets = missingSeededTargets == null ? Set.of() : Set.copyOf(missingSeededTargets); } - private static ProjectionResult failure(String managedName) { - return new ProjectionResult(false, 0, 0, managedName); + private static ProjectionSelection empty() { + return new ProjectionSelection(List.of(), Set.of()); + } + } + + private record ProjectionResult( + boolean success, + int installedDatapacks, + int installedAssets, + Set resolvedLocateStructures, + int syntheticStructureSets, + String managedName, + String error + ) { + private ProjectionResult { + LinkedHashSet normalized = new LinkedHashSet<>(); + if (resolvedLocateStructures != null) { + for (String structure : resolvedLocateStructures) { + String normalizedStructure = normalizeLocateStructure(structure); + if (!normalizedStructure.isBlank()) { + normalized.add(normalizedStructure); + } + } + } + resolvedLocateStructures = Set.copyOf(normalized); + syntheticStructureSets = Math.max(0, syntheticStructureSets); + if (error == null) { + error = ""; + } + } + + private static ProjectionResult success( + String managedName, + int installedDatapacks, + int installedAssets, + Set resolvedLocateStructures, + int syntheticStructureSets + ) { + return new ProjectionResult(true, installedDatapacks, installedAssets, resolvedLocateStructures, syntheticStructureSets, managedName, ""); + } + + private static ProjectionResult failure(String managedName, String error) { + String message = error == null || error.isBlank() ? "projection failed" : error; + return new ProjectionResult(false, 0, 0, Set.of(), 0, managedName, message); + } + } + + private record ProjectionInputAsset(String relativePath, ProjectedEntry entry, byte[] bytes) { + private ProjectionInputAsset { + bytes = bytes == null ? new byte[0] : Arrays.copyOf(bytes, bytes.length); + } + } + + private record ProjectionOutputAsset(String relativePath, byte[] bytes) { + private ProjectionOutputAsset { + bytes = bytes == null ? new byte[0] : Arrays.copyOf(bytes, bytes.length); + } + } + + private record ProjectionAssetSummary(List assets, Set resolvedLocateStructures, int syntheticStructureSets) { + private ProjectionAssetSummary { + assets = assets == null ? List.of() : List.copyOf(assets); + LinkedHashSet normalized = new LinkedHashSet<>(); + if (resolvedLocateStructures != null) { + for (String structure : resolvedLocateStructures) { + String normalizedStructure = normalizeLocateStructure(structure); + if (!normalizedStructure.isBlank()) { + normalized.add(normalizedStructure); + } + } + } + resolvedLocateStructures = Set.copyOf(normalized); + syntheticStructureSets = Math.max(0, syntheticStructureSets); + } + } + + private record SyntheticPlacement(Identifier structureSetKey, StructurePlacement placement, int weight) { + } + + private record SyntheticStructureSetResult(List assets, int count) { + private SyntheticStructureSetResult { + assets = assets == null ? List.of() : List.copyOf(assets); + count = Math.max(0, count); + } + + private static SyntheticStructureSetResult empty() { + return new SyntheticStructureSetResult(List.of(), 0); } } 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 48cd7be9f..6ce8d5cd6 100644 --- a/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java +++ b/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java @@ -34,6 +34,8 @@ import art.arcane.iris.util.common.plugin.VolmitSender; import art.arcane.iris.util.common.scheduling.J; import lombok.NonNull; import org.bukkit.Bukkit; +import org.bukkit.NamespacedKey; +import org.bukkit.block.Biome; import org.bukkit.configuration.InvalidConfigurationException; import org.bukkit.configuration.file.FileConfiguration; import org.bukkit.configuration.file.YamlConfiguration; @@ -42,13 +44,22 @@ import org.jetbrains.annotations.Nullable; import java.io.File; import java.io.IOException; +import java.util.ArrayDeque; +import java.util.ArrayList; import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicIntegerArray; import java.util.stream.Stream; public class ServerConfigurator { + private static volatile boolean deferredInstallPending = false; + public static void configure() { IrisSettings.IrisSettingsAutoconfiguration s = IrisSettings.get().getAutoConfiguration(); if (s.isConfigureSpigotTimeoutTime()) { @@ -59,9 +70,26 @@ public class ServerConfigurator { J.attempt(ServerConfigurator::increasePaperWatchdog); } + if (shouldDeferInstallUntilWorldsReady()) { + deferredInstallPending = true; + return; + } + + deferredInstallPending = false; installDataPacks(true); } + public static void configureIfDeferred() { + if (!deferredInstallPending) { + return; + } + + configure(); + if (deferredInstallPending) { + J.a(ServerConfigurator::configureIfDeferred, 20); + } + } + private static void increaseKeepAliveSpigot() throws IOException, InvalidConfigurationException { File spigotConfig = new File("spigot.yml"); FileConfiguration f = new YamlConfiguration(); @@ -103,24 +131,38 @@ public class ServerConfigurator { } public static boolean installDataPacks(boolean fullInstall) { + return installDataPacks(fullInstall, true); + } + + public static boolean installDataPacks(boolean fullInstall, boolean includeExternal) { IDataFixer fixer = DataVersion.getDefault(); if (fixer == null) { DataVersion fallback = DataVersion.getLatest(); Iris.warn("Primary datapack fixer was null, forcing latest fixer: " + fallback.getVersion()); fixer = fallback.get(); } - return installDataPacks(fixer, fullInstall); + return installDataPacks(fixer, fullInstall, includeExternal); } public static boolean installDataPacks(IDataFixer fixer, boolean fullInstall) { + return installDataPacks(fixer, fullInstall, true); + } + + public static boolean installDataPacks(IDataFixer fixer, boolean fullInstall, boolean includeExternal) { if (fixer == null) { Iris.error("Unable to install datapacks, fixer is null!"); return false; } - Iris.info("Checking Data Packs..."); + if (fullInstall || includeExternal) { + Iris.info("Checking Data Packs..."); + } else { + Iris.verbose("Checking Data Packs..."); + } DimensionHeight height = new DimensionHeight(fixer); KList folders = getDatapacksFolder(); - installExternalDataPacks(folders); + if (includeExternal) { + installExternalDataPacks(folders); + } KMap> biomes = new KMap<>(); try (Stream stream = allPacks()) { @@ -133,7 +175,11 @@ public class ServerConfigurator { }); } IrisDimension.writeShared(folders, height); - Iris.info("Data Packs Setup!"); + if (fullInstall || includeExternal) { + Iris.info("Data Packs Setup!"); + } else { + Iris.verbose("Data Packs Setup!"); + } return fullInstall && verifyDataPacksPost(IrisSettings.get().getAutoConfiguration().isAutoRestartOnCustomBiomeInstall()); } @@ -147,93 +193,684 @@ public class ServerConfigurator { KMap> worldDatapackFoldersByPack = collectWorldDatapackFoldersByPack(folders); ExternalDataPackPipeline.PipelineSummary summary = ExternalDataPackPipeline.processDatapacks(requests, worldDatapackFoldersByPack); if (summary.getLegacyDownloadRemovals() > 0) { - Iris.info("Removed " + summary.getLegacyDownloadRemovals() + " legacy global datapack downloads."); + Iris.verbose("Removed " + summary.getLegacyDownloadRemovals() + " legacy global datapack downloads."); } if (summary.getLegacyWorldCopyRemovals() > 0) { - Iris.info("Removed " + summary.getLegacyWorldCopyRemovals() + " legacy managed world datapack copies."); - } - if (summary.getRequests() > 0 || summary.getImportedSources() > 0 || summary.getWorldDatapacksInstalled() > 0) { - Iris.info("External datapack sync/import/install: requests=" + summary.getRequests() - + ", synced=" + summary.getSyncedRequests() - + ", restored=" + summary.getRestoredRequests() - + ", importedSources=" + summary.getImportedSources() - + ", cachedSources=" + summary.getCachedSources() - + ", converted=" + summary.getConvertedStructures() - + ", failedConversions=" + summary.getFailedConversions() - + ", worldDatapacks=" + summary.getWorldDatapacksInstalled() - + ", worldAssets=" + summary.getWorldAssetsInstalled() - + ", optionalFailures=" + summary.getOptionalFailures() - + ", requiredFailures=" + summary.getRequiredFailures()); + Iris.verbose("Removed " + summary.getLegacyWorldCopyRemovals() + " legacy managed world datapack copies."); } + int loadedDatapackCount = Math.max(0, summary.getRequests() - summary.getOptionalFailures() - summary.getRequiredFailures()); + Iris.info("Loaded Datapacks into Iris: " + loadedDatapackCount + "!"); if (summary.getRequiredFailures() > 0) { throw new IllegalStateException("Required external datapack setup failed for " + summary.getRequiredFailures() + " request(s)."); } } + private static boolean shouldDeferInstallUntilWorldsReady() { + String forcedMainWorld = IrisSettings.get().getGeneral().forceMainWorld; + if (forcedMainWorld != null && !forcedMainWorld.isBlank()) { + return false; + } + + return Bukkit.getServer().getWorlds().isEmpty(); + } + private static KList collectExternalDatapackRequests() { KMap deduplicated = new KMap<>(); try (Stream stream = allPacks()) { - stream.forEach(data -> { - ResourceLoader loader = data.getDimensionLoader(); - if (loader == null) { - return; - } - - KList dimensions = loader.loadAll(loader.getPossibleKeys()); - for (IrisDimension dimension : dimensions) { - if (dimension == null || dimension.getExternalDatapacks() == null || dimension.getExternalDatapacks().isEmpty()) { - continue; - } - - String targetPack = sanitizePackName(dimension.getLoadKey()); - if (targetPack.isBlank()) { - targetPack = sanitizePackName(data.getDataFolder().getName()); - } - String environment = ExternalDataPackPipeline.normalizeEnvironmentValue(dimension.getEnvironment() == null ? null : dimension.getEnvironment().name()); - - for (IrisExternalDatapack externalDatapack : dimension.getExternalDatapacks()) { - if (externalDatapack == null || !externalDatapack.isEnabled()) { - continue; - } - - String url = externalDatapack.getUrl() == null ? "" : externalDatapack.getUrl().trim(); - if (url.isBlank()) { - continue; - } - - String requestId = externalDatapack.getId() == null ? "" : externalDatapack.getId().trim(); - if (requestId.isBlank()) { - requestId = url; - } - - IrisExternalDatapackReplaceTargets replaceTargets = externalDatapack.getReplaceTargets(); - ExternalDataPackPipeline.DatapackRequest request = new ExternalDataPackPipeline.DatapackRequest( - requestId, - url, - targetPack, - environment, - externalDatapack.isRequired(), - externalDatapack.isReplaceVanilla(), - replaceTargets, - externalDatapack.getStructurePatches() - ); - - String dedupeKey = request.getDedupeKey(); - ExternalDataPackPipeline.DatapackRequest existing = deduplicated.get(dedupeKey); - if (existing == null) { - deduplicated.put(dedupeKey, request); - continue; - } - - deduplicated.put(dedupeKey, existing.merge(request)); - } - } - }); + stream.forEach(data -> collectExternalDatapackRequestsForPack(data, deduplicated)); } return new KList<>(deduplicated.v()); } + private static void collectExternalDatapackRequestsForPack(IrisData data, KMap deduplicated) { + ResourceLoader loader = data.getDimensionLoader(); + if (loader == null) { + Iris.warn("Skipping external datapack request discovery for pack " + data.getDataFolder().getName() + " because dimension loader is unavailable."); + return; + } + + String[] possibleKeys = loader.getPossibleKeys(); + if (possibleKeys == null || possibleKeys.length == 0) { + File dimensionsFolder = new File(data.getDataFolder(), "dimensions"); + File[] dimensionFiles = dimensionsFolder.listFiles((dir, name) -> name != null && name.toLowerCase().endsWith(".json")); + int dimensionFileCount = dimensionFiles == null ? 0 : dimensionFiles.length; + Iris.warn("Pack " + data.getDataFolder().getName() + " has no loadable dimension keys. Dimension folder json files=" + dimensionFileCount + ". External datapacks in this pack cannot be discovered."); + return; + } + + KList dimensions = loader.loadAll(possibleKeys); + int scannedDimensions = 0; + int dimensionsWithExternalEntries = 0; + int enabledEntries = 0; + int disabledEntries = 0; + int skippedBlankUrl = 0; + int scopedRequests = 0; + int unscopedRequests = 0; + int dedupeMerges = 0; + for (IrisDimension dimension : dimensions) { + if (dimension == null) { + continue; + } + + scannedDimensions++; + KList externalDatapacks = dimension.getExternalDatapacks(); + if (externalDatapacks == null || externalDatapacks.isEmpty()) { + continue; + } + + dimensionsWithExternalEntries++; + String targetPack = sanitizePackName(dimension.getLoadKey()); + if (targetPack.isBlank()) { + targetPack = sanitizePackName(data.getDataFolder().getName()); + } + + String environment = ExternalDataPackPipeline.normalizeEnvironmentValue(dimension.getEnvironment() == null ? null : dimension.getEnvironment().name()); + LinkedHashMap definitionsById = new LinkedHashMap<>(); + for (IrisExternalDatapack externalDatapack : externalDatapacks) { + if (externalDatapack == null) { + disabledEntries++; + continue; + } + + if (!externalDatapack.isEnabled()) { + disabledEntries++; + continue; + } + + String url = externalDatapack.getUrl() == null ? "" : externalDatapack.getUrl().trim(); + if (url.isBlank()) { + skippedBlankUrl++; + continue; + } + + enabledEntries++; + String requestId = normalizeExternalDatapackId(externalDatapack.getId(), url); + IrisExternalDatapack existingDefinition = definitionsById.put(requestId, externalDatapack); + if (existingDefinition != null) { + Iris.warn("Duplicate external datapack id '" + requestId + "' in dimension " + dimension.getLoadKey() + ". Latest entry wins."); + } + } + + if (definitionsById.isEmpty()) { + continue; + } + + KMap> scopedGroups = resolveScopedBindingGroups(data, dimension, definitionsById); + for (Map.Entry entry : definitionsById.entrySet()) { + String requestId = entry.getKey(); + IrisExternalDatapack definition = entry.getValue(); + String url = definition.getUrl() == null ? "" : definition.getUrl().trim(); + if (url.isBlank()) { + continue; + } + + KList groups = scopedGroups.get(requestId); + if (groups == null || groups.isEmpty()) { + String scopeKey = buildRootScopeKey(dimension.getLoadKey(), requestId); + ExternalDataPackPipeline.DatapackRequest request = new ExternalDataPackPipeline.DatapackRequest( + requestId, + url, + targetPack, + environment, + definition.isRequired(), + definition.isReplaceVanilla(), + definition.getReplaceTargets(), + definition.getStructurePatches(), + Set.of(), + scopeKey, + !definition.isReplaceVanilla(), + Set.of() + ); + dedupeMerges += mergeDeduplicatedRequest(deduplicated, request); + unscopedRequests++; + Iris.verbose("External datapack scope resolved: id=" + requestId + + ", targetPack=" + targetPack + + ", dimension=" + dimension.getLoadKey() + + ", scope=dimension-root" + + ", forcedBiomes=0" + + ", replaceVanilla=" + definition.isReplaceVanilla() + + ", alongsideMode=" + (!definition.isReplaceVanilla()) + + ", required=" + definition.isRequired()); + continue; + } + + for (ScopedBindingGroup group : groups) { + ExternalDataPackPipeline.DatapackRequest request = new ExternalDataPackPipeline.DatapackRequest( + requestId, + url, + targetPack, + environment, + group.required(), + group.replaceVanilla(), + definition.getReplaceTargets(), + definition.getStructurePatches(), + group.forcedBiomeKeys(), + group.scopeKey(), + !group.replaceVanilla(), + Set.of() + ); + dedupeMerges += mergeDeduplicatedRequest(deduplicated, request); + scopedRequests++; + Iris.verbose("External datapack scope resolved: id=" + requestId + + ", targetPack=" + targetPack + + ", dimension=" + dimension.getLoadKey() + + ", scope=" + group.source() + + ", forcedBiomes=" + group.forcedBiomeKeys().size() + + ", replaceVanilla=" + group.replaceVanilla() + + ", alongsideMode=" + (!group.replaceVanilla()) + + ", required=" + group.required()); + } + } + } + + if (scannedDimensions == 0) { + Iris.warn("Pack " + data.getDataFolder().getName() + " did not resolve any dimensions during external datapack discovery."); + return; + } + + if (dimensionsWithExternalEntries > 0 || enabledEntries > 0 || disabledEntries > 0 || skippedBlankUrl > 0) { + Iris.verbose("External datapack discovery for pack " + data.getDataFolder().getName() + + ": dimensions=" + scannedDimensions + + ", withEntries=" + dimensionsWithExternalEntries + + ", enabled=" + enabledEntries + + ", disabled=" + disabledEntries + + ", skippedBlankUrl=" + skippedBlankUrl + + ", scopedRequests=" + scopedRequests + + ", unscopedRequests=" + unscopedRequests + + ", dedupeMerges=" + dedupeMerges); + } + } + + private static KMap> resolveScopedBindingGroups( + IrisData data, + IrisDimension dimension, + Map definitionsById + ) { + KMap> groupedRequestsById = new KMap<>(); + if (definitionsById == null || definitionsById.isEmpty()) { + return groupedRequestsById; + } + + ResourceLoader regionLoader = data.getRegionLoader(); + ResourceLoader biomeLoader = data.getBiomeLoader(); + if (regionLoader == null || biomeLoader == null) { + return groupedRequestsById; + } + + String biomeNamespace = resolveBiomeNamespace(dimension); + LinkedHashMap biomeCache = new LinkedHashMap<>(); + LinkedHashMap regions = new LinkedHashMap<>(); + KList dimensionRegions = dimension.getRegions(); + if (dimensionRegions != null) { + for (String regionKey : dimensionRegions) { + String normalizedRegion = normalizeResourceReference(regionKey); + if (normalizedRegion.isBlank()) { + continue; + } + + IrisRegion region = regionLoader.load(normalizedRegion, false); + if (region != null) { + regions.put(normalizedRegion, region); + } + } + } + + LinkedHashMap> candidatesById = new LinkedHashMap<>(); + LinkedHashSet discoveryBiomeKeys = new LinkedHashSet<>(); + for (IrisRegion region : regions.values()) { + Set expandedRegionBiomes = collectRegionBiomeKeys(region, true, biomeLoader, biomeCache); + discoveryBiomeKeys.addAll(expandedRegionBiomes); + + KList bindings = region.getExternalDatapacks(); + if (bindings == null || bindings.isEmpty()) { + continue; + } + + for (IrisExternalDatapackBinding binding : bindings) { + if (binding == null || !binding.isEnabled()) { + continue; + } + + String id = normalizeExternalDatapackId(binding.getId(), ""); + if (id.isBlank()) { + continue; + } + + IrisExternalDatapack definition = definitionsById.get(id); + if (definition == null) { + Iris.warn("Ignoring region external datapack binding id '" + id + "' in " + region.getLoadKey() + " because no matching dimension externalDatapacks entry exists."); + continue; + } + + boolean replaceVanilla = binding.getReplaceVanillaOverride() == null + ? definition.isReplaceVanilla() + : binding.getReplaceVanillaOverride(); + boolean required = binding.getRequiredOverride() == null + ? definition.isRequired() + : binding.getRequiredOverride(); + Set regionBiomeKeys = collectRegionBiomeKeys(region, binding.isIncludeChildren(), biomeLoader, biomeCache); + Set runtimeBiomeKeys = resolveRuntimeBiomeKeys(regionBiomeKeys, biomeNamespace, biomeLoader, biomeCache); + if (runtimeBiomeKeys.isEmpty()) { + continue; + } + + KList candidates = candidatesById.computeIfAbsent(id, key -> new KList<>()); + candidates.add(new ScopedBindingCandidate("region", region.getLoadKey(), 1, replaceVanilla, required, runtimeBiomeKeys)); + } + } + + for (String biomeKey : discoveryBiomeKeys) { + IrisBiome biome = loadBiomeFromCache(biomeKey, biomeLoader, biomeCache); + if (biome == null) { + continue; + } + + KList bindings = biome.getExternalDatapacks(); + if (bindings == null || bindings.isEmpty()) { + continue; + } + + for (IrisExternalDatapackBinding binding : bindings) { + if (binding == null || !binding.isEnabled()) { + continue; + } + + String id = normalizeExternalDatapackId(binding.getId(), ""); + if (id.isBlank()) { + continue; + } + + IrisExternalDatapack definition = definitionsById.get(id); + if (definition == null) { + Iris.warn("Ignoring biome external datapack binding id '" + id + "' in " + biome.getLoadKey() + " because no matching dimension externalDatapacks entry exists."); + continue; + } + + boolean replaceVanilla = binding.getReplaceVanillaOverride() == null + ? definition.isReplaceVanilla() + : binding.getReplaceVanillaOverride(); + boolean required = binding.getRequiredOverride() == null + ? definition.isRequired() + : binding.getRequiredOverride(); + Set biomeSelection = collectBiomeKeys(biome.getLoadKey(), binding.isIncludeChildren(), biomeLoader, biomeCache); + Set runtimeBiomeKeys = resolveRuntimeBiomeKeys(biomeSelection, biomeNamespace, biomeLoader, biomeCache); + if (runtimeBiomeKeys.isEmpty()) { + continue; + } + + KList candidates = candidatesById.computeIfAbsent(id, key -> new KList<>()); + candidates.add(new ScopedBindingCandidate("biome", biome.getLoadKey(), 2, replaceVanilla, required, runtimeBiomeKeys)); + } + } + + for (Map.Entry> entry : candidatesById.entrySet()) { + String id = entry.getKey(); + KList candidates = entry.getValue(); + if (candidates == null || candidates.isEmpty()) { + continue; + } + + LinkedHashMap selectedByBiome = new LinkedHashMap<>(); + for (ScopedBindingCandidate candidate : candidates) { + if (candidate == null || candidate.forcedBiomeKeys() == null || candidate.forcedBiomeKeys().isEmpty()) { + continue; + } + + ArrayList sortedBiomeKeys = new ArrayList<>(candidate.forcedBiomeKeys()); + sortedBiomeKeys.sort(String::compareTo); + for (String runtimeBiomeKey : sortedBiomeKeys) { + ScopedBindingSelection selected = selectedByBiome.get(runtimeBiomeKey); + if (selected == null) { + selectedByBiome.put(runtimeBiomeKey, new ScopedBindingSelection( + candidate.priority(), + candidate.replaceVanilla(), + candidate.required(), + candidate.sourceType(), + candidate.sourceKey() + )); + continue; + } + + if (candidate.priority() > selected.priority()) { + selectedByBiome.put(runtimeBiomeKey, new ScopedBindingSelection( + candidate.priority(), + candidate.replaceVanilla(), + candidate.required(), + candidate.sourceType(), + candidate.sourceKey() + )); + continue; + } + + if (candidate.priority() == selected.priority() + && (candidate.replaceVanilla() != selected.replaceVanilla() || candidate.required() != selected.required())) { + Iris.warn("External datapack scope conflict for id=" + id + + ", biomeKey=" + runtimeBiomeKey + + ", kept=" + selected.sourceType() + "/" + selected.sourceKey() + + ", ignored=" + candidate.sourceType() + "/" + candidate.sourceKey()); + } + } + } + + LinkedHashMap> groupedBiomes = new LinkedHashMap<>(); + LinkedHashMap groupedSelection = new LinkedHashMap<>(); + for (Map.Entry selectedEntry : selectedByBiome.entrySet()) { + String runtimeBiomeKey = selectedEntry.getKey(); + ScopedBindingSelection selection = selectedEntry.getValue(); + String groupKey = selection.replaceVanilla() + "|" + selection.required(); + groupedBiomes.computeIfAbsent(groupKey, key -> new LinkedHashSet<>()).add(runtimeBiomeKey); + groupedSelection.putIfAbsent(groupKey, selection); + } + + for (Map.Entry> groupedEntry : groupedBiomes.entrySet()) { + LinkedHashSet runtimeBiomeKeys = groupedEntry.getValue(); + if (runtimeBiomeKeys == null || runtimeBiomeKeys.isEmpty()) { + continue; + } + + ScopedBindingSelection selection = groupedSelection.get(groupedEntry.getKey()); + if (selection == null) { + continue; + } + + Set forcedBiomeKeys = Set.copyOf(runtimeBiomeKeys); + String scopeKey = buildScopedScopeKey(dimension.getLoadKey(), id, selection.sourceType(), selection.sourceKey(), forcedBiomeKeys); + String source = selection.sourceType() + ":" + selection.sourceKey(); + KList groups = groupedRequestsById.computeIfAbsent(id, key -> new KList<>()); + groups.add(new ScopedBindingGroup(selection.replaceVanilla(), selection.required(), forcedBiomeKeys, scopeKey, source)); + } + } + + return groupedRequestsById; + } + + private static Set collectRegionBiomeKeys( + IrisRegion region, + boolean includeChildren, + ResourceLoader biomeLoader, + Map biomeCache + ) { + LinkedHashSet regionBiomeKeys = new LinkedHashSet<>(); + if (region == null) { + return regionBiomeKeys; + } + + addAllResourceReferences(regionBiomeKeys, region.getLandBiomes()); + addAllResourceReferences(regionBiomeKeys, region.getSeaBiomes()); + addAllResourceReferences(regionBiomeKeys, region.getShoreBiomes()); + addAllResourceReferences(regionBiomeKeys, region.getCaveBiomes()); + if (!includeChildren) { + return regionBiomeKeys; + } + + LinkedHashSet expanded = new LinkedHashSet<>(); + for (String biomeKey : regionBiomeKeys) { + expanded.addAll(collectBiomeKeys(biomeKey, true, biomeLoader, biomeCache)); + } + return expanded; + } + + private static Set collectBiomeKeys( + String biomeKey, + boolean includeChildren, + ResourceLoader biomeLoader, + Map biomeCache + ) { + LinkedHashSet resolved = new LinkedHashSet<>(); + String normalizedBiomeKey = normalizeResourceReference(biomeKey); + if (normalizedBiomeKey.isBlank()) { + return resolved; + } + + if (!includeChildren) { + resolved.add(normalizedBiomeKey); + return resolved; + } + + ArrayDeque queue = new ArrayDeque<>(); + queue.add(normalizedBiomeKey); + while (!queue.isEmpty()) { + String next = normalizeResourceReference(queue.removeFirst()); + if (next.isBlank() || !resolved.add(next)) { + continue; + } + + IrisBiome biome = loadBiomeFromCache(next, biomeLoader, biomeCache); + if (biome == null) { + continue; + } + + addQueueResourceReferences(queue, biome.getChildren()); + } + + return resolved; + } + + private static Set resolveRuntimeBiomeKeys( + Set irisBiomeKeys, + String biomeNamespace, + ResourceLoader biomeLoader, + Map biomeCache + ) { + LinkedHashSet resolved = new LinkedHashSet<>(); + if (irisBiomeKeys == null || irisBiomeKeys.isEmpty()) { + return resolved; + } + + for (String irisBiomeKey : irisBiomeKeys) { + String normalizedBiomeKey = normalizeResourceReference(irisBiomeKey); + if (normalizedBiomeKey.isBlank()) { + continue; + } + + IrisBiome biome = loadBiomeFromCache(normalizedBiomeKey, biomeLoader, biomeCache); + if (biome == null) { + continue; + } + + if (biome.isCustom() && biome.getCustomDerivitives() != null && !biome.getCustomDerivitives().isEmpty()) { + for (IrisBiomeCustom customDerivative : biome.getCustomDerivitives()) { + if (customDerivative == null) { + continue; + } + + String customId = normalizeResourceReference(customDerivative.getId()); + if (customId.isBlank()) { + continue; + } + resolved.add((biomeNamespace + ":" + customId).toLowerCase(Locale.ROOT)); + } + continue; + } + + Biome vanillaDerivative = biome.getVanillaDerivative(); + NamespacedKey vanillaKey = vanillaDerivative == null ? null : vanillaDerivative.getKey(); + if (vanillaKey != null) { + resolved.add(vanillaKey.toString().toLowerCase(Locale.ROOT)); + } + } + + return resolved; + } + + private static String resolveBiomeNamespace(IrisDimension dimension) { + if (dimension == null) { + return "iris"; + } + + String namespace = dimension.getLoadKey() == null ? "" : dimension.getLoadKey().trim().toLowerCase(Locale.ROOT); + namespace = namespace.replaceAll("[^a-z0-9_\\-.]", "_"); + namespace = namespace.replaceAll("_+", "_"); + namespace = namespace.replaceAll("^_+", ""); + namespace = namespace.replaceAll("_+$", ""); + if (namespace.isBlank()) { + return "iris"; + } + return namespace; + } + + private static IrisBiome loadBiomeFromCache( + String biomeKey, + ResourceLoader biomeLoader, + Map biomeCache + ) { + if (biomeLoader == null) { + return null; + } + + String normalizedBiomeKey = normalizeResourceReference(biomeKey); + if (normalizedBiomeKey.isBlank()) { + return null; + } + + if (biomeCache.containsKey(normalizedBiomeKey)) { + return biomeCache.get(normalizedBiomeKey); + } + + IrisBiome biome = biomeLoader.load(normalizedBiomeKey, false); + if (biome != null) { + biomeCache.put(normalizedBiomeKey, biome); + } + return biome; + } + + private static void addAllResourceReferences(Set destination, KList references) { + if (destination == null || references == null || references.isEmpty()) { + return; + } + + for (String reference : references) { + String normalized = normalizeResourceReference(reference); + if (!normalized.isBlank()) { + destination.add(normalized); + } + } + } + + private static void addQueueResourceReferences(ArrayDeque queue, KList references) { + if (queue == null || references == null || references.isEmpty()) { + return; + } + + for (String reference : references) { + String normalized = normalizeResourceReference(reference); + if (!normalized.isBlank()) { + queue.addLast(normalized); + } + } + } + + private static String normalizeResourceReference(String reference) { + if (reference == null) { + return ""; + } + + String normalized = reference.trim().replace('\\', '/'); + normalized = normalized.replaceAll("/+", "/"); + normalized = normalized.replaceAll("^/+", ""); + normalized = normalized.replaceAll("/+$", ""); + return normalized; + } + + private static int mergeDeduplicatedRequest( + KMap deduplicated, + ExternalDataPackPipeline.DatapackRequest request + ) { + if (request == null) { + return 0; + } + + String dedupeKey = request.getDedupeKey(); + ExternalDataPackPipeline.DatapackRequest existing = deduplicated.get(dedupeKey); + if (existing == null) { + deduplicated.put(dedupeKey, request); + return 0; + } + + deduplicated.put(dedupeKey, existing.merge(request)); + return 1; + } + + private static String normalizeExternalDatapackId(String id, String fallbackUrl) { + String normalized = id == null ? "" : id.trim(); + if (!normalized.isBlank()) { + return normalized.toLowerCase(Locale.ROOT); + } + + String fallback = fallbackUrl == null ? "" : fallbackUrl.trim(); + if (fallback.isBlank()) { + return ""; + } + return fallback.toLowerCase(Locale.ROOT); + } + + private static String buildRootScopeKey(String dimensionKey, String id) { + String normalizedDimension = ExternalDataPackPipeline.sanitizePackNameValue(dimensionKey); + if (normalizedDimension.isBlank()) { + normalizedDimension = "dimension"; + } + String normalizedId = ExternalDataPackPipeline.sanitizePackNameValue(id); + if (normalizedId.isBlank()) { + normalizedId = "external"; + } + return "root-" + normalizedDimension + "-" + normalizedId; + } + + private static String buildScopedScopeKey(String dimensionKey, String id, String sourceType, String sourceKey, Set forcedBiomeKeys) { + ArrayList sortedBiomes = new ArrayList<>(); + if (forcedBiomeKeys != null) { + sortedBiomes.addAll(forcedBiomeKeys); + } + sortedBiomes.sort(String::compareTo); + String biomeFingerprint = Integer.toHexString(String.join(",", sortedBiomes).hashCode()); + String normalizedDimension = ExternalDataPackPipeline.sanitizePackNameValue(dimensionKey); + if (normalizedDimension.isBlank()) { + normalizedDimension = "dimension"; + } + String normalizedId = ExternalDataPackPipeline.sanitizePackNameValue(id); + if (normalizedId.isBlank()) { + normalizedId = "external"; + } + String normalizedSourceType = ExternalDataPackPipeline.sanitizePackNameValue(sourceType); + if (normalizedSourceType.isBlank()) { + normalizedSourceType = "scope"; + } + String normalizedSourceKey = ExternalDataPackPipeline.sanitizePackNameValue(sourceKey); + if (normalizedSourceKey.isBlank()) { + normalizedSourceKey = "entry"; + } + return normalizedDimension + "-" + normalizedId + "-" + normalizedSourceType + "-" + normalizedSourceKey + "-" + biomeFingerprint; + } + + private record ScopedBindingCandidate( + String sourceType, + String sourceKey, + int priority, + boolean replaceVanilla, + boolean required, + Set forcedBiomeKeys + ) { + } + + private record ScopedBindingSelection( + int priority, + boolean replaceVanilla, + boolean required, + String sourceType, + String sourceKey + ) { + } + + private record ScopedBindingGroup( + boolean replaceVanilla, + boolean required, + Set forcedBiomeKeys, + String scopeKey, + String source + ) { + } + private static KMap> collectWorldDatapackFoldersByPack(KList fallbackFolders) { KMap> foldersByPack = new KMap<>(); KMap mappedWorlds = IrisWorlds.get().getWorlds(); diff --git a/core/src/main/java/art/arcane/iris/core/commands/CommandDeveloper.java b/core/src/main/java/art/arcane/iris/core/commands/CommandDeveloper.java index 518ac9d25..e89b24b8c 100644 --- a/core/src/main/java/art/arcane/iris/core/commands/CommandDeveloper.java +++ b/core/src/main/java/art/arcane/iris/core/commands/CommandDeveloper.java @@ -442,7 +442,9 @@ public class CommandDeveloper implements DirectorExecutor { orchestrator.setDaemon(true); try { orchestrator.start(); - Iris.info("Delete-chunk worker dispatched on dedicated thread=" + orchestrator.getName() + " id=" + runId + "."); + if (IrisSettings.get().getGeneral().isDebug()) { + Iris.info("Delete-chunk worker dispatched on dedicated thread=" + orchestrator.getName() + " id=" + runId + "."); + } } catch (Throwable e) { ACTIVE_DELETE_CHUNK_WORLDS.remove(worldKey); sender.sendMessage(C.RED + "Failed to start delete-chunk worker thread. See console."); @@ -519,7 +521,9 @@ public class CommandDeveloper implements DirectorExecutor { watchdog.interrupt(); IrisToolbelt.endWorldMaintenance(world, "delete-chunk"); ACTIVE_DELETE_CHUNK_WORLDS.remove(worldKey); - Iris.info("Delete-chunk run closed: id=" + runId + " world=" + world.getName() + " totalMs=" + (System.currentTimeMillis() - runStart)); + if (IrisSettings.get().getGeneral().isDebug()) { + Iris.info("Delete-chunk run closed: id=" + runId + " world=" + world.getName() + " totalMs=" + (System.currentTimeMillis() - runStart)); + } } } @@ -773,7 +777,9 @@ public class CommandDeveloper implements DirectorExecutor { ) { phase.set(next); phaseSince.set(System.currentTimeMillis()); - Iris.info("Delete-chunk phase: id=" + runId + " phase=" + next + " world=" + world.getName()); + if (IrisSettings.get().getGeneral().isDebug()) { + Iris.info("Delete-chunk phase: id=" + runId + " phase=" + next + " world=" + world.getName()); + } } private String formatDeleteChunkFailedPreview(List failedChunks) { diff --git a/core/src/main/java/art/arcane/iris/core/commands/CommandIris.java b/core/src/main/java/art/arcane/iris/core/commands/CommandIris.java index fab3e7705..62adf9e04 100644 --- a/core/src/main/java/art/arcane/iris/core/commands/CommandIris.java +++ b/core/src/main/java/art/arcane/iris/core/commands/CommandIris.java @@ -19,6 +19,7 @@ package art.arcane.iris.core.commands; import art.arcane.iris.Iris; +import art.arcane.iris.core.ExternalDataPackPipeline; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.IrisWorlds; import art.arcane.iris.core.link.FoliaWorldsLink; @@ -28,8 +29,11 @@ import art.arcane.iris.core.service.StudioSVC; import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisExternalDatapack; +import art.arcane.iris.engine.object.IrisExternalDatapackReplaceTargets; +import art.arcane.iris.engine.platform.ChunkReplacementListener; +import art.arcane.iris.engine.platform.ChunkReplacementOptions; import art.arcane.iris.engine.platform.PlatformChunkGenerator; -import art.arcane.iris.util.project.matter.TileWrapper; import art.arcane.volmlib.util.collection.KList; import art.arcane.iris.util.common.director.DirectorContext; import art.arcane.volmlib.util.director.DirectorParameterHandler; @@ -38,6 +42,7 @@ import art.arcane.volmlib.util.director.DirectorOrigin; import art.arcane.volmlib.util.director.annotations.Director; import art.arcane.volmlib.util.director.annotations.Param; import art.arcane.volmlib.util.director.exceptions.DirectorParsingException; +import art.arcane.iris.util.common.director.specialhandlers.ExternalDatapackLocateHandler; import art.arcane.iris.util.common.director.specialhandlers.NullablePlayerHandler; import art.arcane.iris.util.common.format.C; import art.arcane.volmlib.util.io.IO; @@ -45,14 +50,10 @@ import art.arcane.volmlib.util.math.Position2; import art.arcane.iris.util.common.parallel.SyncExecutor; import art.arcane.iris.util.common.misc.ServerProperties; import art.arcane.iris.util.common.misc.RegenRuntime; -import art.arcane.volmlib.util.mantle.runtime.MantleChunk; import art.arcane.iris.util.common.plugin.VolmitSender; import art.arcane.iris.util.common.scheduling.J; -import art.arcane.volmlib.util.matter.Matter; import org.bukkit.Bukkit; -import org.bukkit.Chunk; import org.bukkit.World; -import org.bukkit.block.data.BlockData; import org.bukkit.boss.BarColor; import org.bukkit.boss.BarStyle; import org.bukkit.boss.BossBar; @@ -60,27 +61,26 @@ import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.entity.Player; import org.bukkit.scheduler.BukkitRunnable; -import art.arcane.volmlib.util.mantle.flag.MantleFlag; import java.io.*; import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorCompletionService; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadPoolExecutor; -import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; @@ -95,11 +95,9 @@ public class CommandIris implements DirectorExecutor { private static final long REGEN_HEARTBEAT_MS = 5000L; private static final int REGEN_MAX_ATTEMPTS = 2; private static final int REGEN_STACK_LIMIT = 20; - private static final int REGEN_STALL_DUMP_HEARTBEATS = 3; - private static final int REGEN_STALL_ABORT_HEARTBEATS = 24; - private static final long REGEN_MAX_RESET_CHUNKS = 65536L; - private static final int REGEN_RESET_PROGRESS_STEP = 128; - private static final long REGEN_RESET_DELETE_ABORT_MS = 60000L; + private static final long REGEN_STALL_DUMP_IDLE_MS = 30000L; + private static final long REGEN_STALL_ABORT_IDLE_MS = 600000L; + private static final long REGEN_STACK_DUMP_INTERVAL_MS = 10000L; private static final int REGEN_PROGRESS_BAR_WIDTH = 44; private static final long REGEN_PROGRESS_UPDATE_MS = 200L; private static final int REGEN_ACTION_PULSE_TICKS = 20; @@ -464,6 +462,205 @@ public class CommandIris implements DirectorExecutor { sender().sendMessage(C.GREEN + "Iris v" + Iris.instance.getDescription().getVersion() + " by Volmit Software"); } + @Director(description = "Locate structure targets mapped to an external datapack id in this world's dimension config", aliases = {"locateexternal", "locateext"}, origin = DirectorOrigin.PLAYER, sync = true) + public void locateExternal( + @Param(description = "External datapack id or structure id (comma separated values supported)", customHandler = ExternalDatapackLocateHandler.class) + String id, + @Param(description = "Run locate for every structure mapped to the id(s)", defaultValue = "true") + boolean all + ) { + if (id == null || id.trim().isBlank()) { + sender().sendMessage(C.RED + "You must provide an external datapack id."); + return; + } + + Engine activeEngine = engine(); + if (activeEngine == null || activeEngine.getDimension() == null) { + sender().sendMessage(C.RED + "You must be in an Iris world to use locateexternal."); + return; + } + + IrisDimension dimension = activeEngine.getDimension(); + KList externalDatapacks = dimension.getExternalDatapacks(); + if (externalDatapacks == null || externalDatapacks.isEmpty()) { + sender().sendMessage(C.RED + "This dimension has no externalDatapacks entries."); + return; + } + + LinkedHashSet requestedTokens = new LinkedHashSet<>(); + for (String token : id.split(",")) { + if (token == null) { + continue; + } + + String normalizedToken = normalizeLocateExternalToken(token); + if (!normalizedToken.isBlank()) { + requestedTokens.add(normalizedToken); + } + } + + if (requestedTokens.isEmpty()) { + sender().sendMessage(C.RED + "No valid external datapack ids or structure ids were provided."); + return; + } + + Map> fallbackById = buildExternalLocateFallbackById(externalDatapacks); + LinkedHashSet structures = new LinkedHashSet<>(); + LinkedHashSet matchedIds = new LinkedHashSet<>(); + for (String token : requestedTokens) { + Set resolvedStructures = ExternalDataPackPipeline.resolveLocateStructuresForId(token); + if (resolvedStructures.isEmpty()) { + Set fallbackStructures = fallbackById.get(token); + if (fallbackStructures != null) { + resolvedStructures = fallbackStructures; + } + } + + if (!resolvedStructures.isEmpty()) { + matchedIds.add(token); + structures.addAll(resolvedStructures); + continue; + } + + String structureToken = normalizeLocateStructureToken(token); + if (!structureToken.isBlank()) { + matchedIds.add("structure:" + structureToken); + structures.add(structureToken); + } + } + + if (structures.isEmpty()) { + sender().sendMessage(C.RED + "No external datapack entry matched value(s): " + String.join(", ", requestedTokens)); + return; + } + + VolmitSender commandSender = sender(); + Runnable dispatchTask = () -> dispatchLocateExternalCommands(commandSender, structures, matchedIds, all); + if (commandSender.isPlayer()) { + Player player = commandSender.player(); + if (player == null) { + commandSender.sendMessage(C.RED + "No active player sender was available for locateexternal."); + return; + } + + if (!J.runEntity(player, dispatchTask)) { + commandSender.sendMessage(C.RED + "Failed to schedule locate command dispatch on the player's region thread."); + } + return; + } + + J.s(dispatchTask); + } + + private void dispatchLocateExternalCommands( + VolmitSender commandSender, + Set structures, + Set matchedIds, + boolean all + ) { + org.bukkit.command.CommandSender locateSender = commandSender.isPlayer() + ? commandSender.player() + : Bukkit.getConsoleSender(); + int dispatched = 0; + for (String structure : structures) { + String command = "locate structure " + structure; + boolean accepted = Bukkit.dispatchCommand(locateSender, command); + if (!accepted) { + commandSender.sendMessage(C.RED + "Failed to dispatch: /" + command); + } else { + commandSender.sendMessage(C.GREEN + "Dispatched: /" + command); + dispatched++; + } + + if (!all) { + break; + } + } + + commandSender.sendMessage(C.GREEN + "Matched ids=" + matchedIds.size() + ", locateTargets=" + structures.size() + ", dispatched=" + dispatched + "."); + } + + private static String normalizeLocateExternalToken(String token) { + if (token == null) { + return ""; + } + + String normalized = token.trim().toLowerCase(Locale.ROOT); + if (normalized.isBlank()) { + return ""; + } + + normalized = normalized.replace("minecraft:worldgen/structure/", ""); + normalized = normalized.replace("worldgen/structure/", ""); + if (!normalized.contains(":") && normalized.contains("/")) { + return normalized; + } + + if (!normalized.contains(":")) { + return normalized; + } + + return normalized; + } + + private static Map> buildExternalLocateFallbackById(KList externalDatapacks) { + Map> mapped = new ConcurrentHashMap<>(); + if (externalDatapacks == null || externalDatapacks.isEmpty()) { + return mapped; + } + + for (IrisExternalDatapack externalDatapack : externalDatapacks) { + if (externalDatapack == null) { + continue; + } + + String url = externalDatapack.getUrl() == null ? "" : externalDatapack.getUrl().trim(); + String entryId = externalDatapack.getId() == null ? "" : externalDatapack.getId().trim(); + String normalizedEntryId = normalizeLocateExternalToken(entryId.isBlank() ? url : entryId); + if (normalizedEntryId.isBlank()) { + continue; + } + + IrisExternalDatapackReplaceTargets replaceTargets = externalDatapack.getReplaceTargets(); + if (replaceTargets == null || replaceTargets.getStructures() == null || replaceTargets.getStructures().isEmpty()) { + continue; + } + + LinkedHashSet structures = new LinkedHashSet<>(); + for (String structure : replaceTargets.getStructures()) { + String normalizedStructure = normalizeLocateStructureToken(structure); + if (!normalizedStructure.isBlank()) { + structures.add(normalizedStructure); + } + } + + if (!structures.isEmpty()) { + mapped.put(normalizedEntryId, Set.copyOf(structures)); + } + } + + return mapped; + } + + private static String normalizeLocateStructureToken(String structure) { + if (structure == null) { + return ""; + } + + String normalized = structure.trim().toLowerCase(Locale.ROOT); + if (normalized.isBlank()) { + return ""; + } + + normalized = normalized.replace("minecraft:worldgen/structure/", ""); + normalized = normalized.replace("worldgen/structure/", ""); + if (!normalized.contains(":")) { + normalized = "minecraft:" + normalized; + } + + return normalized; + } + /* /todo @Director(description = "Benchmark a pack", origin = DirectorOrigin.CONSOLE) @@ -682,7 +879,7 @@ public class CommandIris implements DirectorExecutor { VolmitSender sender = sender(); int centerX = player().getLocation().getBlockX() >> 4; int centerZ = player().getLocation().getBlockZ() >> 4; - int threadCount = resolveRegenThreadCount(parallelism); + int threadCount = resolveRegenThreadCount(parallelism, regenMode); List targets = buildRegenTargets(centerX, centerZ, radius); int chunks = targets.size(); String runId = world.getName() + "-" + System.currentTimeMillis(); @@ -693,7 +890,7 @@ public class CommandIris implements DirectorExecutor { + C.GOLD + threadCount + C.GREEN + " worker(s). " + C.GRAY + "Progress is shown on-screen."); if (regenMode == RegenMode.TERRAIN) { - Iris.warn("Regen running in terrain mode; mantle object stages are bypassed. Use mode=full to regenerate objects."); + Iris.warn("Regen running in terrain mode; mantle overlay/object replay is skipped. Use mode=full to regenerate objects."); } Iris.info("Regen run start: id=" + runId @@ -703,21 +900,24 @@ public class CommandIris implements DirectorExecutor { + " mode=" + regenMode.id() + " workers=" + threadCount + " chunks=" + chunks); - Iris.info("Regen mode config: id=" + runId - + " mode=" + regenMode.id() - + " maintenance=" + regenMode.usesMaintenance() - + " bypassMantle=" + regenMode.bypassMantleStages() - + " resetMantleChunks=" + regenMode.resetMantleChunks() - + " passes=" + regenMode.passCount() - + " overlay=" + regenMode.applyMantleOverlay() - + " diagnostics=" + regenMode.logChunkDiagnostics()); + if (IrisSettings.get().getGeneral().isDebug()) { + Iris.info("Regen mode config: id=" + runId + + " mode=" + regenMode.id() + + " maintenance=" + regenMode.usesMaintenance() + + " bypassMantle=" + regenMode.bypassMantleStages() + + " passes=" + regenMode.passCount() + + " fullMode=" + regenMode.isFullMode() + + " diagnostics=" + regenMode.logChunkDiagnostics()); + } String orchestratorName = "Iris-Regen-Orchestrator-" + runId; Thread orchestrator = new Thread(() -> runRegenOrchestrator(sender, world, targets, threadCount, regenMode, runId, display), orchestratorName); orchestrator.setDaemon(true); try { orchestrator.start(); - Iris.info("Regen worker dispatched on dedicated thread=" + orchestratorName + " id=" + runId + "."); + if (IrisSettings.get().getGeneral().isDebug()) { + Iris.info("Regen worker dispatched on dedicated thread=" + orchestratorName + " id=" + runId + "."); + } } catch (Throwable e) { sender.sendMessage(C.RED + "Failed to start regen worker thread. See console."); closeRegenDisplay(display, 0); @@ -725,12 +925,18 @@ public class CommandIris implements DirectorExecutor { } } - private int resolveRegenThreadCount(int parallelism) { - int threads = parallelism <= 0 ? Runtime.getRuntime().availableProcessors() : parallelism; - if (J.isFolia() && parallelism <= 0) { - threads = 1; + private int resolveRegenThreadCount(int parallelism, RegenMode mode) { + if (parallelism > 0) { + return Math.max(1, Math.min(8, parallelism)); } - return Math.max(1, threads); + if (J.isFolia()) { + return 1; + } + int cpus = Runtime.getRuntime().availableProcessors(); + if (mode == RegenMode.TERRAIN) { + return Math.min(Math.max(1, cpus / 2), 4); + } + return Math.min(Math.max(1, cpus / 4), 2); } private List buildRegenTargets(int centerX, int centerZ, int radius) { @@ -802,42 +1008,12 @@ public class CommandIris implements DirectorExecutor { return; } - if (mode.resetMantleChunks()) { - setRegenSetupPhase(setupPhase, setupPhaseSince, "prepare-mantle", world, runId); - updateRegenSetupDisplay(display, mode, "Preparing mantle reset", 5, 6); - int writeRadius = Math.max(0, platform.getEngine().getMantle().getRadius()); - int plannedRadius = Math.max(0, platform.getEngine().getMantle().getRealRadius()); - int resetPadding = mode.usesMaintenance() ? plannedRadius : 0; - long estimatedResetChunks = estimateRegenMantleResetChunks(targets, resetPadding); - if (estimatedResetChunks > REGEN_MAX_RESET_CHUNKS) { - int cappedPadding = capRegenMantleResetPadding(targets, resetPadding, REGEN_MAX_RESET_CHUNKS); - Iris.warn("Regen mantle reset cap applied: id=" + runId - + " desiredPadding=" + resetPadding - + " cappedPadding=" + cappedPadding - + " estimatedChunks=" + estimatedResetChunks - + " maxChunks=" + REGEN_MAX_RESET_CHUNKS); - resetPadding = cappedPadding; - } - Iris.info("Regen mantle reset planning: id=" + runId - + " writeRadius=" + writeRadius - + " plannedRadius=" + plannedRadius - + " resetPadding=" + resetPadding); - int resetChunks = resetRegenMantleChunks(platform, targets, resetPadding, runId); - Iris.info("Regen mantle reset complete: id=" + runId - + " resetChunks=" + resetChunks - + " resetPadding=" + resetPadding); - } + setRegenSetupPhase(setupPhase, setupPhaseSince, "prepare-options", world, runId); + updateRegenSetupDisplay(display, mode, "Preparing chunk replacement", 5, 6); setRegenSetupPhase(setupPhase, setupPhaseSince, "dispatch", world, runId); updateRegenSetupDisplay(display, mode, "Dispatching chunk workers", 6, 6); - RegenSummary summary = null; - for (int pass = 1; pass <= mode.passCount(); pass++) { - String passId = mode.passCount() > 1 ? runId + "-p" + pass : runId; - summary = executeRegenQueue(sender, world, platform, targets, executor, pool, regenThreads, mode, passId, pass, mode.passCount(), runStart, display); - if (summary.failedChunks() > 0) { - break; - } - } + RegenSummary summary = executeRegenQueue(sender, world, platform, targets, executor, pool, regenThreads, mode, runId, 1, 1, runStart, display); if (summary == null) { completeRegenDisplay(display, mode, true, C.RED + "Regen failed before pass execution."); @@ -856,6 +1032,9 @@ public class CommandIris implements DirectorExecutor { String failureDetail = C.RED + "Failed chunks " + C.GOLD + summary.failedChunks() + C.RED + ", retries " + C.GOLD + summary.retryCount() + C.RED + ", runtime " + C.GOLD + totalRuntime + "ms"; + if (summary.failurePhaseSummary() != null && !summary.failurePhaseSummary().isBlank() && !"none".equals(summary.failurePhaseSummary())) { + failureDetail = failureDetail + C.DARK_GRAY + " [phase " + summary.failurePhaseSummary() + "]"; + } if (!summary.failedPreview().isEmpty()) { failureDetail = failureDetail + C.DARK_GRAY + " [" + summary.failedPreview() + "]"; } @@ -916,17 +1095,20 @@ public class CommandIris implements DirectorExecutor { ConcurrentMap activeTasks = new ConcurrentHashMap<>(); ExecutorCompletionService completion = new ExecutorCompletionService<>(pool); List failedChunks = new ArrayList<>(); + Map failurePhaseCounts = new HashMap<>(); int totalChunks = targets.size(); int successChunks = 0; int failedCount = 0; int retryCount = 0; + int overlayChunks = 0; + int overlayObjectChunks = 0; + int overlayBlocks = 0; long submittedTasks = 0L; long finishedTasks = 0L; int completedChunks = 0; int inFlight = 0; - int unchangedHeartbeats = 0; - int lastCompleted = -1; + AtomicLong lastSignalMs = new AtomicLong(System.currentTimeMillis()); long lastDump = 0L; long lastProgressUiMs = 0L; lastProgressUiMs = updateRegenProgressAction( @@ -949,7 +1131,7 @@ public class CommandIris implements DirectorExecutor { while (inFlight < pool.getMaximumPoolSize() && !pending.isEmpty()) { RegenChunkTask task = pending.removeFirst(); - completion.submit(() -> runRegenChunk(task, world, platform, executor, activeTasks, mode, runId)); + completion.submit(() -> runRegenChunk(task, world, platform, executor, activeTasks, mode, runId, lastSignalMs)); inFlight++; submittedTasks++; } @@ -957,12 +1139,10 @@ public class CommandIris implements DirectorExecutor { while (completedChunks < totalChunks) { Future future = completion.poll(REGEN_HEARTBEAT_MS, TimeUnit.MILLISECONDS); if (future == null) { - if (completedChunks == lastCompleted) { - unchangedHeartbeats++; - } else { - unchangedHeartbeats = 0; - lastCompleted = completedChunks; - } + long now = System.currentTimeMillis(); + long idleMs = Math.max(0L, now - lastSignalMs.get()); + boolean stalled = idleMs >= REGEN_HEARTBEAT_MS; + String phaseSummary = summarizeActivePhases(activeTasks); Iris.warn("Regen heartbeat: id=" + runId + " completed=" + completedChunks + "/" + totalChunks @@ -976,6 +1156,8 @@ public class CommandIris implements DirectorExecutor { + " poolActive=" + pool.getActiveCount() + " poolQueue=" + pool.getQueue().size() + " poolDone=" + pool.getCompletedTaskCount() + + " idleMs=" + idleMs + + " phases=" + phaseSummary + " activeTasks=" + formatActiveTasks(activeTasks)); lastProgressUiMs = updateRegenProgressAction( sender, @@ -987,20 +1169,20 @@ public class CommandIris implements DirectorExecutor { totalChunks, inFlight, pending.size(), - unchangedHeartbeats > 0, + stalled, false, false, true, - unchangedHeartbeats > 0 ? "Waiting for active chunk to finish" : "Waiting for chunk result", + stalled ? "Waiting in phase " + phaseSummary : "Waiting for chunk result", lastProgressUiMs ); - if (unchangedHeartbeats >= REGEN_STALL_DUMP_HEARTBEATS && System.currentTimeMillis() - lastDump >= 10000L) { - lastDump = System.currentTimeMillis(); + if (idleMs >= REGEN_STALL_DUMP_IDLE_MS && now - lastDump >= REGEN_STACK_DUMP_INTERVAL_MS) { + lastDump = now; Iris.warn("Regen appears stalled; dumping worker stack traces for id=" + runId + "."); dumpRegenWorkerStacks(regenThreads, world.getName()); } - if (unchangedHeartbeats >= REGEN_STALL_ABORT_HEARTBEATS) { + if (idleMs >= REGEN_STALL_ABORT_IDLE_MS) { updateRegenProgressAction( sender, display, @@ -1015,17 +1197,18 @@ public class CommandIris implements DirectorExecutor { true, true, true, - "Stalled with no chunk completion", + "Stalled in phase " + phaseSummary, lastProgressUiMs ); - throw new IllegalStateException("Regen stalled with no progress for " - + (REGEN_STALL_ABORT_HEARTBEATS * REGEN_HEARTBEAT_MS) + throw new IllegalStateException("Regen stalled with no chunk heartbeat or completion for " + + idleMs + "ms (id=" + runId + ", mode=" + mode.id() + ", completed=" + completedChunks + "/" + totalChunks + ", inFlight=" + inFlight + ", queued=" + pending.size() + + ", phase=" + phaseSummary + ")."); } continue; @@ -1042,10 +1225,18 @@ public class CommandIris implements DirectorExecutor { inFlight--; finishedTasks++; long duration = result.finishedAtMs() - result.startedAtMs(); + lastSignalMs.set(System.currentTimeMillis()); if (result.success()) { completedChunks++; successChunks++; + if (result.overlayAppliedBlocks() > 0) { + overlayChunks++; + } + if (result.overlayObjectKeys() > 0) { + overlayObjectChunks++; + } + overlayBlocks += result.overlayAppliedBlocks(); if (result.task().attempt() > 1) { Iris.warn("Regen chunk recovered after retry: id=" + runId + " chunk=" + result.task().chunkX() + "," + result.task().chunkZ() @@ -1065,15 +1256,21 @@ public class CommandIris implements DirectorExecutor { + " chunk=" + result.task().chunkX() + "," + result.task().chunkZ() + " failedAttempt=" + result.task().attempt() + " nextAttempt=" + retryTask.attempt() + + " phase=" + result.failurePhase() + " error=" + result.errorSummary()); } else { completedChunks++; failedCount++; Position2 failed = new Position2(result.task().chunkX(), result.task().chunkZ()); failedChunks.add(failed); + String failurePhase = result.failurePhase() == null || result.failurePhase().isBlank() + ? "unknown" + : result.failurePhase(); + failurePhaseCounts.merge(failurePhase, 1, Integer::sum); Iris.warn("Regen chunk failed terminally: id=" + runId + " chunk=" + result.task().chunkX() + "," + result.task().chunkZ() + " attempts=" + result.task().attempt() + + " phase=" + failurePhase + " error=" + result.errorSummary()); if (result.error() != null) { Iris.reportError(result.error()); @@ -1082,11 +1279,12 @@ public class CommandIris implements DirectorExecutor { while (inFlight < pool.getMaximumPoolSize() && !pending.isEmpty()) { RegenChunkTask task = pending.removeFirst(); - completion.submit(() -> runRegenChunk(task, world, platform, executor, activeTasks, mode, runId)); + completion.submit(() -> runRegenChunk(task, world, platform, executor, activeTasks, mode, runId, lastSignalMs)); inFlight++; submittedTasks++; } + String phaseSummary = summarizeActivePhases(activeTasks); lastProgressUiMs = updateRegenProgressAction( sender, display, @@ -1097,22 +1295,18 @@ public class CommandIris implements DirectorExecutor { totalChunks, inFlight, pending.size(), - unchangedHeartbeats > 0, false, false, false, - "Generating chunks", + false, + phaseSummary.equals("idle") ? "Generating chunks" : "Generating chunks in " + phaseSummary, lastProgressUiMs ); } - MantleOverlaySummary overlaySummary = MantleOverlaySummary.empty(); - if (failedCount <= 0 && mode.applyMantleOverlay()) { - overlaySummary = applyRegenMantleOverlay(world, platform, targets, runId, mode.logChunkDiagnostics()); - } - long runtimeMs = System.currentTimeMillis() - runStart; String preview = formatFailedChunkPreview(failedChunks); + String failurePhaseSummary = formatFailurePhaseSummary(failurePhaseCounts); Iris.info("Regen run complete: id=" + runId + " world=" + world.getName() + " total=" + totalChunks @@ -1121,9 +1315,10 @@ public class CommandIris implements DirectorExecutor { + " retries=" + retryCount + " submittedTasks=" + submittedTasks + " finishedTasks=" + finishedTasks - + " overlayChunks=" + overlaySummary.chunksProcessed() - + " overlayObjectChunks=" + overlaySummary.chunksWithObjectKeys() - + " overlayBlocks=" + overlaySummary.blocksApplied() + + " overlayChunks=" + overlayChunks + + " overlayObjectChunks=" + overlayObjectChunks + + " overlayBlocks=" + overlayBlocks + + " failurePhases=" + failurePhaseSummary + " runtimeMs=" + runtimeMs + " failedPreview=" + preview); updateRegenProgressAction( @@ -1140,10 +1335,10 @@ public class CommandIris implements DirectorExecutor { true, failedCount > 0, true, - failedCount > 0 ? "Completed with failures" : "Pass complete", + failedCount > 0 ? "Completed with failures in " + failurePhaseSummary : "Pass complete", lastProgressUiMs ); - return new RegenSummary(totalChunks, successChunks, failedCount, retryCount, preview); + return new RegenSummary(totalChunks, successChunks, failedCount, retryCount, preview, failurePhaseSummary); } private long updateRegenProgressAction( @@ -1380,7 +1575,8 @@ public class CommandIris implements DirectorExecutor { SyncExecutor executor, ConcurrentMap activeTasks, RegenMode mode, - String runId + String runId, + AtomicLong lastSignalMs ) { String worker = Thread.currentThread().getName(); long startedAt = System.currentTimeMillis(); @@ -1390,7 +1586,34 @@ public class CommandIris implements DirectorExecutor { } catch (Throwable ignored) { } - activeTasks.put(worker, new RegenActiveTask(task.chunkX(), task.chunkZ(), task.attempt(), startedAt, loadedAtStart)); + RegenActiveTask activeTask = new RegenActiveTask(task.chunkX(), task.chunkZ(), task.attempt(), startedAt, loadedAtStart); + activeTasks.put(worker, activeTask); + AtomicReference failurePhase = new AtomicReference<>("unknown"); + AtomicInteger overlayAppliedBlocks = new AtomicInteger(); + AtomicInteger overlayObjectKeys = new AtomicInteger(); + ChunkReplacementListener listener = new ChunkReplacementListener() { + @Override + public void onPhase(String phase, int chunkX, int chunkZ, long timestampMs) { + activeTask.updatePhase(phase, timestampMs); + lastSignalMs.set(timestampMs); + } + + @Override + public void onOverlay(int chunkX, int chunkZ, int appliedBlocks, int objectKeys, long timestampMs) { + overlayAppliedBlocks.addAndGet(appliedBlocks); + overlayObjectKeys.addAndGet(objectKeys); + activeTask.updatePhase("overlay", timestampMs); + lastSignalMs.set(timestampMs); + } + + @Override + public void onFailurePhase(String phase, int chunkX, int chunkZ, Throwable error, long timestampMs) { + String classifiedPhase = classifyRegenFailurePhase(phase); + failurePhase.set(classifiedPhase); + activeTask.updatePhase(classifiedPhase, timestampMs); + lastSignalMs.set(timestampMs); + } + }; try { if (mode.logChunkDiagnostics()) { Iris.info("Regen chunk start: id=" + runId @@ -1399,9 +1622,12 @@ public class CommandIris implements DirectorExecutor { + " loadedAtStart=" + loadedAtStart + " worker=" + worker); } + ChunkReplacementOptions options = mode == RegenMode.FULL + ? ChunkReplacementOptions.full(runId, mode.logChunkDiagnostics()) + : ChunkReplacementOptions.terrain(runId, mode.logChunkDiagnostics()); RegenRuntime.setRunId(runId); try { - platform.injectChunkReplacement(world, task.chunkX(), task.chunkZ(), executor); + platform.injectChunkReplacement(world, task.chunkX(), task.chunkZ(), executor, options, listener); } finally { RegenRuntime.clear(); } @@ -1412,12 +1638,33 @@ public class CommandIris implements DirectorExecutor { + " worker=" + worker + " durationMs=" + (System.currentTimeMillis() - startedAt)); } - return RegenChunkResult.success(task, worker, startedAt, System.currentTimeMillis(), loadedAtStart); + long finishedAt = System.currentTimeMillis(); + activeTask.updateHeartbeat(finishedAt); + lastSignalMs.set(finishedAt); + return RegenChunkResult.success(task, worker, startedAt, finishedAt, loadedAtStart, overlayAppliedBlocks.get(), overlayObjectKeys.get()); } catch (Throwable e) { if (e instanceof InterruptedException) { Thread.currentThread().interrupt(); } - return RegenChunkResult.failure(task, worker, startedAt, System.currentTimeMillis(), loadedAtStart, e); + long finishedAt = System.currentTimeMillis(); + String classifiedPhase = classifyRegenFailurePhase(failurePhase.get()); + if ("unknown".equals(classifiedPhase)) { + classifiedPhase = classifyRegenFailurePhase(activeTask.phase()); + } + activeTask.updatePhase(classifiedPhase, finishedAt); + activeTask.updateHeartbeat(finishedAt); + lastSignalMs.set(finishedAt); + return RegenChunkResult.failure( + task, + worker, + startedAt, + finishedAt, + loadedAtStart, + classifiedPhase, + overlayAppliedBlocks.get(), + overlayObjectKeys.get(), + e + ); } finally { activeTasks.remove(worker); } @@ -1462,135 +1709,11 @@ public class CommandIris implements DirectorExecutor { ) { setupPhase.set(nextPhase); setupPhaseSince.set(System.currentTimeMillis()); - Iris.info("Regen setup phase: id=" + runId + " phase=" + nextPhase + " world=" + world.getName()); - } - - private RegenMantleChunkState inspectRegenMantleChunk(PlatformChunkGenerator platform, int chunkX, int chunkZ) { - MantleChunk chunk = platform.getEngine().getMantle().getMantle().getChunk(chunkX, chunkZ).use(); - try { - AtomicInteger blockDataEntries = new AtomicInteger(); - AtomicInteger stringEntries = new AtomicInteger(); - AtomicInteger objectKeyEntries = new AtomicInteger(); - AtomicInteger tileEntries = new AtomicInteger(); - - chunk.iterate(BlockData.class, (x, y, z, data) -> { - if (data != null) { - blockDataEntries.incrementAndGet(); - } - }); - chunk.iterate(String.class, (x, y, z, key) -> { - if (key == null || key.isEmpty()) { - return; - } - stringEntries.incrementAndGet(); - if (key.indexOf('@') > 0) { - objectKeyEntries.incrementAndGet(); - } - }); - chunk.iterate(TileWrapper.class, (x, y, z, tile) -> { - if (tile != null) { - tileEntries.incrementAndGet(); - } - }); - - return new RegenMantleChunkState( - chunk.isFlagged(MantleFlag.PLANNED), - chunk.isFlagged(MantleFlag.OBJECT), - chunk.isFlagged(MantleFlag.REAL), - blockDataEntries.get(), - stringEntries.get(), - objectKeyEntries.get(), - tileEntries.get() - ); - } finally { - chunk.release(); + if (IrisSettings.get().getGeneral().isDebug()) { + Iris.info("Regen setup phase: id=" + runId + " phase=" + nextPhase + " world=" + world.getName()); } } - private MantleOverlaySummary applyRegenMantleOverlay( - World world, - PlatformChunkGenerator platform, - List targets, - String runId, - boolean diagnostics - ) throws InterruptedException { - int processed = 0; - int chunksWithObjectKeys = 0; - int totalAppliedBlocks = 0; - - for (Position2 target : targets) { - int chunkX = target.getX(); - int chunkZ = target.getZ(); - CountDownLatch latch = new CountDownLatch(1); - AtomicInteger chunkApplied = new AtomicInteger(); - AtomicInteger chunkObjectKeys = new AtomicInteger(); - AtomicReference failure = new AtomicReference<>(); - - boolean scheduled = J.runRegion(world, chunkX, chunkZ, () -> { - try { - Chunk chunk = world.getChunkAt(chunkX, chunkZ); - MantleChunk mantleChunk = platform.getEngine().getMantle().getMantle().getChunk(chunkX, chunkZ).use(); - try { - mantleChunk.iterate(String.class, (x, y, z, value) -> { - if (value != null && !value.isEmpty() && value.indexOf('@') > 0) { - chunkObjectKeys.incrementAndGet(); - } - }); - - int minWorldY = world.getMinHeight(); - int maxWorldY = world.getMaxHeight(); - mantleChunk.iterate(BlockData.class, (x, y, z, blockData) -> { - if (blockData == null) { - return; - } - int worldY = y + minWorldY; - if (worldY < minWorldY || worldY >= maxWorldY) { - return; - } - chunk.getBlock(x & 15, worldY, z & 15).setBlockData(blockData, false); - chunkApplied.incrementAndGet(); - }); - } finally { - mantleChunk.release(); - } - } catch (Throwable e) { - failure.set(e); - } finally { - latch.countDown(); - } - }); - - if (!scheduled) { - throw new IllegalStateException("Failed to schedule regen mantle overlay for chunk " + chunkX + "," + chunkZ + " id=" + runId); - } - - while (!latch.await(REGEN_HEARTBEAT_MS, TimeUnit.MILLISECONDS)) { - Iris.warn("Regen overlay heartbeat: id=" + runId - + " chunk=" + chunkX + "," + chunkZ - + " appliedBlocks=" + chunkApplied.get()); - } - - Throwable error = failure.get(); - if (error != null) { - throw new IllegalStateException("Failed to apply regen mantle overlay at chunk " + chunkX + "," + chunkZ + " id=" + runId, error); - } - - processed++; - totalAppliedBlocks += chunkApplied.get(); - if (chunkObjectKeys.get() > 0) { - chunksWithObjectKeys++; - } - - if (diagnostics) { - Iris.info("Regen overlay chunk: id=" + runId - + " chunk=" + chunkX + "," + chunkZ - + " objectKeys=" + chunkObjectKeys.get() - + " appliedBlocks=" + chunkApplied.get()); - } - } - return new MantleOverlaySummary(processed, chunksWithObjectKeys, totalAppliedBlocks); - } - private static String formatFailedChunkPreview(List failedChunks) { if (failedChunks.isEmpty()) { return "[]"; @@ -1613,6 +1736,105 @@ public class CommandIris implements DirectorExecutor { return builder.toString(); } + private static String summarizeActivePhases(ConcurrentMap activeTasks) { + if (activeTasks.isEmpty()) { + return "idle"; + } + + Map counts = new HashMap<>(); + for (RegenActiveTask activeTask : activeTasks.values()) { + String phase = classifyRegenFailurePhase(activeTask.phase()); + counts.merge(phase, 1, Integer::sum); + } + if (counts.isEmpty()) { + return "idle"; + } + + List> entries = new ArrayList<>(counts.entrySet()); + entries.sort((a, b) -> { + int diff = Integer.compare(b.getValue(), a.getValue()); + if (diff != 0) { + return diff; + } + return a.getKey().compareTo(b.getKey()); + }); + + StringBuilder builder = new StringBuilder(); + int emitted = 0; + for (Map.Entry entry : entries) { + if (emitted > 0) { + builder.append(", "); + } + if (emitted >= 3) { + builder.append("..."); + break; + } + builder.append(entry.getKey()).append(" x").append(entry.getValue()); + emitted++; + } + return builder.toString(); + } + + private static String formatFailurePhaseSummary(Map failurePhaseCounts) { + if (failurePhaseCounts.isEmpty()) { + return "none"; + } + + List> entries = new ArrayList<>(failurePhaseCounts.entrySet()); + entries.sort((a, b) -> { + int diff = Integer.compare(b.getValue(), a.getValue()); + if (diff != 0) { + return diff; + } + return a.getKey().compareTo(b.getKey()); + }); + + StringBuilder builder = new StringBuilder(); + int emitted = 0; + for (Map.Entry entry : entries) { + if (emitted > 0) { + builder.append(", "); + } + if (emitted >= 5) { + builder.append("..."); + break; + } + builder.append(entry.getKey()).append("=").append(entry.getValue()); + emitted++; + } + return builder.toString(); + } + + private static String classifyRegenFailurePhase(String phase) { + if (phase == null || phase.isBlank()) { + return "unknown"; + } + + String normalized = phase.toLowerCase(Locale.ROOT); + if (normalized.contains("generate")) { + return "generate"; + } + if (normalized.contains("acquire-load-lock") || normalized.contains("reset-mantle")) { + return "generate"; + } + if (normalized.contains("apply-terrain") || normalized.contains("folia-region-run")) { + return "apply-terrain"; + } + if (normalized.contains("paperlib-async-load") || normalized.contains("folia-run-region")) { + return "apply-terrain"; + } + if (normalized.contains("overlay")) { + return "overlay"; + } + if (normalized.contains("structure")) { + return "structures"; + } + if (normalized.contains("chunk-load-callback")) { + return "chunk-load-callback"; + } + return "unknown"; + } + private static String formatActiveTasks(ConcurrentMap activeTasks) { if (activeTasks.isEmpty()) { return "{}"; @@ -1640,6 +1862,11 @@ public class CommandIris implements DirectorExecutor { .append("/") .append(now - activeTask.startedAtMs()) .append("ms") + .append(":") + .append(classifyRegenFailurePhase(activeTask.phase())) + .append("/") + .append(now - activeTask.lastHeartbeatMs()) + .append("ms") .append(activeTask.loadedAtStart() ? ":loaded" : ":cold"); count++; } @@ -1678,151 +1905,6 @@ public class CommandIris implements DirectorExecutor { } } - private int resetRegenMantleChunks( - PlatformChunkGenerator platform, - List targets, - int padding, - String runId - ) { - if (targets.isEmpty()) { - return 0; - } - - int minX = Integer.MAX_VALUE; - int maxX = Integer.MIN_VALUE; - int minZ = Integer.MAX_VALUE; - int maxZ = Integer.MIN_VALUE; - for (Position2 target : targets) { - minX = Math.min(minX, target.getX()); - maxX = Math.max(maxX, target.getX()); - minZ = Math.min(minZ, target.getZ()); - maxZ = Math.max(maxZ, target.getZ()); - } - - int fromX = minX - padding; - int toX = maxX + padding; - int fromZ = minZ - padding; - int toZ = maxZ + padding; - long total = (long) (toX - fromX + 1) * (long) (toZ - fromZ + 1); - long started = System.currentTimeMillis(); - int resetCount = 0; - art.arcane.volmlib.util.mantle.runtime.Mantle mantle = platform.getEngine().getMantle().getMantle(); - AtomicReference deleteThread = new AtomicReference<>(); - ThreadFactory deleteFactory = runnable -> { - Thread thread = new Thread(runnable, "Iris-Regen-Reset-" + runId); - thread.setDaemon(true); - deleteThread.set(thread); - return thread; - }; - ExecutorService deleteExecutor = Executors.newSingleThreadExecutor(deleteFactory); - - Iris.info("Regen mantle reset begin: id=" + runId - + " targets=" + targets.size() - + " padding=" + padding - + " bounds=" + fromX + "," + fromZ + "->" + toX + "," + toZ - + " totalChunks=" + total); - - try { - for (int x = fromX; x <= toX; x++) { - for (int z = fromZ; z <= toZ; z++) { - final int chunkX = x; - final int chunkZ = z; - Future deleteFuture = deleteExecutor.submit(() -> mantle.deleteChunk(chunkX, chunkZ)); - long waitStart = System.currentTimeMillis(); - while (true) { - try { - deleteFuture.get(REGEN_HEARTBEAT_MS, TimeUnit.MILLISECONDS); - break; - } catch (TimeoutException timeout) { - long waited = System.currentTimeMillis() - waitStart; - Iris.warn("Regen mantle reset waiting: id=" + runId - + " chunk=" + chunkX + "," + chunkZ - + " waitedMs=" + waited - + " reset=" + resetCount + "/" + total); - Thread worker = deleteThread.get(); - if (worker != null && worker.isAlive()) { - Iris.warn("Regen mantle reset worker thread=" + worker.getName() + " state=" + worker.getState()); - StackTraceElement[] trace = worker.getStackTrace(); - int limit = Math.min(trace.length, REGEN_STACK_LIMIT); - for (int i = 0; i < limit; i++) { - Iris.warn(" at " + trace[i]); - } - } - if (waited >= REGEN_RESET_DELETE_ABORT_MS) { - deleteFuture.cancel(true); - throw new IllegalStateException("Timed out deleting mantle chunk " + chunkX + "," + chunkZ - + " during regen reset id=" + runId - + " waitedMs=" + waited); - } - } catch (ExecutionException e) { - Throwable cause = e.getCause() == null ? e : e.getCause(); - throw new IllegalStateException("Failed deleting mantle chunk " + chunkX + "," + chunkZ + " during regen reset id=" + runId, cause); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IllegalStateException("Interrupted deleting mantle chunk " + chunkX + "," + chunkZ + " during regen reset id=" + runId, e); - } - } - - resetCount++; - if (resetCount % REGEN_RESET_PROGRESS_STEP == 0 || resetCount == total) { - long elapsed = System.currentTimeMillis() - started; - Iris.info("Regen mantle reset progress: id=" + runId - + " reset=" + resetCount + "/" + total - + " elapsedMs=" + elapsed - + " chunk=" + chunkX + "," + chunkZ); - } - } - } - } finally { - deleteExecutor.shutdownNow(); - } - - Iris.info("Regen mantle reset done: id=" + runId - + " targets=" + targets.size() - + " padding=" + padding - + " resetChunks=" + resetCount - + " elapsedMs=" + (System.currentTimeMillis() - started)); - return resetCount; - } - - private long estimateRegenMantleResetChunks(List targets, int padding) { - if (targets.isEmpty()) { - return 0L; - } - - int minX = Integer.MAX_VALUE; - int maxX = Integer.MIN_VALUE; - int minZ = Integer.MAX_VALUE; - int maxZ = Integer.MIN_VALUE; - for (Position2 target : targets) { - minX = Math.min(minX, target.getX()); - maxX = Math.max(maxX, target.getX()); - minZ = Math.min(minZ, target.getZ()); - maxZ = Math.max(maxZ, target.getZ()); - } - - long width = (long) (maxX - minX + 1) + (padding * 2L); - long depth = (long) (maxZ - minZ + 1) + (padding * 2L); - return Math.max(0L, width) * Math.max(0L, depth); - } - - private int capRegenMantleResetPadding(List targets, int desiredPadding, long maxChunks) { - int low = 0; - int high = Math.max(0, desiredPadding); - int best = 0; - while (low <= high) { - int mid = low + ((high - low) >>> 1); - long estimate = estimateRegenMantleResetChunks(targets, mid); - if (estimate <= maxChunks) { - best = mid; - low = mid + 1; - } else { - high = mid - 1; - } - } - return best; - } - private static final class RegenDisplay { private final VolmitSender sender; private final BossBar bossBar; @@ -1844,60 +1926,27 @@ public class CommandIris implements DirectorExecutor { } } - private record MantleOverlaySummary(int chunksProcessed, int chunksWithObjectKeys, int blocksApplied) { - private static MantleOverlaySummary empty() { - return new MantleOverlaySummary(0, 0, 0); - } - } - - private record RegenMantleChunkState( - boolean planned, - boolean objectFlag, - boolean realFlag, - int blockDataEntries, - int stringEntries, - int objectKeyEntries, - int tileEntries - ) { - private String describe() { - return "flags[planned=" + planned - + ",object=" + objectFlag - + ",real=" + realFlag - + "] slices[blockData=" + blockDataEntries - + ",strings=" + stringEntries - + ",objectKeys=" + objectKeyEntries - + ",tiles=" + tileEntries - + "]"; - } - } - private enum RegenMode { - TERRAIN("terrain", true, true, false, 1, false, false), - FULL("full", true, false, false, 2, true, true); + TERRAIN("terrain", true, false, false, false), + FULL("full", true, false, true, true); private final String id; private final boolean usesMaintenance; private final boolean bypassMantleStages; - private final boolean resetMantleChunks; - private final int passCount; - private final boolean applyMantleOverlay; + private final boolean fullMode; private final boolean logChunkDiagnostics; RegenMode( String id, boolean usesMaintenance, boolean bypassMantleStages, - boolean resetMantleChunks, - int passCount, - boolean applyMantleOverlay, + boolean fullMode, boolean logChunkDiagnostics ) { this.id = id; this.usesMaintenance = usesMaintenance; this.bypassMantleStages = bypassMantleStages; - this.resetMantleChunks = resetMantleChunks; - this.passCount = passCount; - this.applyMantleOverlay = applyMantleOverlay; + this.fullMode = fullMode; this.logChunkDiagnostics = logChunkDiagnostics; } @@ -1913,16 +1962,12 @@ public class CommandIris implements DirectorExecutor { return bypassMantleStages; } - private boolean resetMantleChunks() { - return resetMantleChunks; + private boolean isFullMode() { + return fullMode; } private int passCount() { - return passCount; - } - - private boolean applyMantleOverlay() { - return applyMantleOverlay; + return 1; } private boolean logChunkDiagnostics() { @@ -1948,7 +1993,61 @@ public class CommandIris implements DirectorExecutor { } } - private record RegenActiveTask(int chunkX, int chunkZ, int attempt, long startedAtMs, boolean loadedAtStart) { + private static final class RegenActiveTask { + private final int chunkX; + private final int chunkZ; + private final int attempt; + private final long startedAtMs; + private final boolean loadedAtStart; + private volatile String phase; + private volatile long lastHeartbeatMs; + + private RegenActiveTask(int chunkX, int chunkZ, int attempt, long startedAtMs, boolean loadedAtStart) { + this.chunkX = chunkX; + this.chunkZ = chunkZ; + this.attempt = attempt; + this.startedAtMs = startedAtMs; + this.loadedAtStart = loadedAtStart; + this.phase = "queued"; + this.lastHeartbeatMs = startedAtMs; + } + + private void updatePhase(String nextPhase, long timestampMs) { + this.phase = nextPhase == null || nextPhase.isBlank() ? "unknown" : nextPhase; + this.lastHeartbeatMs = timestampMs; + } + + private void updateHeartbeat(long timestampMs) { + this.lastHeartbeatMs = timestampMs; + } + + private int chunkX() { + return chunkX; + } + + private int chunkZ() { + return chunkZ; + } + + private int attempt() { + return attempt; + } + + private long startedAtMs() { + return startedAtMs; + } + + private boolean loadedAtStart() { + return loadedAtStart; + } + + private String phase() { + return phase; + } + + private long lastHeartbeatMs() { + return lastHeartbeatMs; + } } private record RegenChunkResult( @@ -1957,15 +2056,58 @@ public class CommandIris implements DirectorExecutor { long startedAtMs, long finishedAtMs, boolean loadedAtStart, + String failurePhase, + int overlayAppliedBlocks, + int overlayObjectKeys, boolean success, Throwable error ) { - private static RegenChunkResult success(RegenChunkTask task, String worker, long startedAtMs, long finishedAtMs, boolean loadedAtStart) { - return new RegenChunkResult(task, worker, startedAtMs, finishedAtMs, loadedAtStart, true, null); + private static RegenChunkResult success( + RegenChunkTask task, + String worker, + long startedAtMs, + long finishedAtMs, + boolean loadedAtStart, + int overlayAppliedBlocks, + int overlayObjectKeys + ) { + return new RegenChunkResult( + task, + worker, + startedAtMs, + finishedAtMs, + loadedAtStart, + "none", + overlayAppliedBlocks, + overlayObjectKeys, + true, + null + ); } - private static RegenChunkResult failure(RegenChunkTask task, String worker, long startedAtMs, long finishedAtMs, boolean loadedAtStart, Throwable error) { - return new RegenChunkResult(task, worker, startedAtMs, finishedAtMs, loadedAtStart, false, error); + private static RegenChunkResult failure( + RegenChunkTask task, + String worker, + long startedAtMs, + long finishedAtMs, + boolean loadedAtStart, + String failurePhase, + int overlayAppliedBlocks, + int overlayObjectKeys, + Throwable error + ) { + return new RegenChunkResult( + task, + worker, + startedAtMs, + finishedAtMs, + loadedAtStart, + failurePhase == null || failurePhase.isBlank() ? "unknown" : failurePhase, + overlayAppliedBlocks, + overlayObjectKeys, + false, + error + ); } private String errorSummary() { @@ -1980,7 +2122,14 @@ public class CommandIris implements DirectorExecutor { } } - private record RegenSummary(int totalChunks, int successChunks, int failedChunks, int retryCount, String failedPreview) { + private record RegenSummary( + int totalChunks, + int successChunks, + int failedChunks, + int retryCount, + String failedPreview, + String failurePhaseSummary + ) { } @Director(description = "Unload an Iris World", origin = DirectorOrigin.PLAYER, sync = true) diff --git a/core/src/main/java/art/arcane/iris/core/commands/CommandStudio.java b/core/src/main/java/art/arcane/iris/core/commands/CommandStudio.java index ae1ff0bd5..9ee64970c 100644 --- a/core/src/main/java/art/arcane/iris/core/commands/CommandStudio.java +++ b/core/src/main/java/art/arcane/iris/core/commands/CommandStudio.java @@ -30,6 +30,8 @@ import art.arcane.iris.engine.IrisNoisemapPrebakePipeline; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.SeedManager; import art.arcane.iris.engine.object.*; +import art.arcane.iris.engine.platform.ChunkReplacementListener; +import art.arcane.iris.engine.platform.ChunkReplacementOptions; import art.arcane.iris.engine.platform.PlatformChunkGenerator; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KMap; @@ -227,6 +229,7 @@ public class CommandStudio implements DirectorExecutor { sender.sendMessage(C.YELLOW + "Folia fast regen: skipping outer mantle preservation stage."); } + final String runId = "studio-regen-" + world.getName() + "-" + System.currentTimeMillis(); ParallelRadiusJob job = new ParallelRadiusJob(threadCount, service) { @Override @@ -234,7 +237,14 @@ public class CommandStudio implements DirectorExecutor { if (foliaFastRegen) { Iris.verbose("Folia fast studio regen skipping mantle delete for " + x + "," + z + "."); } - plat.injectChunkReplacement(world, x, z, executor); + plat.injectChunkReplacement( + world, + x, + z, + executor, + ChunkReplacementOptions.terrain(runId, IrisSettings.get().getGeneral().isDebug()), + ChunkReplacementListener.NO_OP + ); } @Override diff --git a/core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java b/core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java index 919a9e03a..08dbfdfd1 100644 --- a/core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java +++ b/core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java @@ -161,7 +161,9 @@ public class IrisCreator { .seed(seed) .studio(studio) .create(); - if (ServerConfigurator.installDataPacks(true)) { + boolean verifyDataPacks = !studio(); + boolean includeExternalDataPacks = !studio(); + if (ServerConfigurator.installDataPacks(verifyDataPacks, includeExternalDataPacks)) { throw new IrisException("Datapacks were missing!"); } diff --git a/core/src/main/java/art/arcane/iris/core/tools/IrisToolbelt.java b/core/src/main/java/art/arcane/iris/core/tools/IrisToolbelt.java index cb148b600..0c105ee13 100644 --- a/core/src/main/java/art/arcane/iris/core/tools/IrisToolbelt.java +++ b/core/src/main/java/art/arcane/iris/core/tools/IrisToolbelt.java @@ -433,7 +433,11 @@ public class IrisToolbelt { if (bypassMantleStages) { worldMaintenanceMantleBypassDepth.computeIfAbsent(name, k -> new AtomicInteger()).incrementAndGet(); } - Iris.info("World maintenance enter: " + name + " reason=" + reason + " depth=" + depth + " bypassMantle=" + bypassMantleStages); + if (IrisSettings.get().getGeneral().isDebug()) { + Iris.info("World maintenance enter: " + name + " reason=" + reason + " depth=" + depth + " bypassMantle=" + bypassMantleStages); + } else { + Iris.verbose("World maintenance enter: " + name + " reason=" + reason + " depth=" + depth + " bypassMantle=" + bypassMantleStages); + } } public static void endWorldMaintenance(World world, String reason) { @@ -463,7 +467,11 @@ public class IrisToolbelt { } } - Iris.info("World maintenance exit: " + name + " reason=" + reason + " depth=" + depth + " bypassMantleDepth=" + bypassDepth); + if (IrisSettings.get().getGeneral().isDebug()) { + Iris.info("World maintenance exit: " + name + " reason=" + reason + " depth=" + depth + " bypassMantleDepth=" + bypassDepth); + } else { + Iris.verbose("World maintenance exit: " + name + " reason=" + reason + " depth=" + depth + " bypassMantleDepth=" + bypassDepth); + } } public static boolean isWorldMaintenanceActive(World world) { diff --git a/core/src/main/java/art/arcane/iris/engine/IrisEngine.java b/core/src/main/java/art/arcane/iris/engine/IrisEngine.java index 56e3cba8d..ac485857e 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisEngine.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisEngine.java @@ -307,6 +307,10 @@ public class IrisEngine implements Engine { return; } + if (studio) { + return; + } + if (!noisemapPrebakeRunning.compareAndSet(false, true)) { return; } @@ -369,7 +373,7 @@ public class IrisEngine implements Engine { setupEngine(); J.a(() -> { synchronized (ServerConfigurator.class) { - ServerConfigurator.installDataPacks(false); + ServerConfigurator.installDataPacks(false, false); } }); } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/EngineMode.java b/core/src/main/java/art/arcane/iris/engine/framework/EngineMode.java index 37fdecaba..ea2f64b2a 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/EngineMode.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/EngineMode.java @@ -73,7 +73,7 @@ public interface EngineMode extends Staged { default void generate(int x, int z, Hunk blocks, Hunk biomes, boolean multicore) { boolean cacheContext = true; if (J.isFolia()) { - var world = getEngine().getWorld().realWorld(); + org.bukkit.World world = getEngine().getWorld().realWorld(); if (world != null && IrisToolbelt.isWorldMaintenanceActive(world)) { cacheContext = false; } @@ -81,7 +81,8 @@ public interface EngineMode extends Staged { ChunkContext ctx = new ChunkContext(x, z, getComplex(), cacheContext); IrisContext.getOr(getEngine()).setChunkContext(ctx); - for (EngineStage i : getStages()) { + EngineStage[] stages = getStages().toArray(new EngineStage[0]); + for (EngineStage i : stages) { i.generate(x, z, blocks, biomes, multicore, ctx); } } diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java index ade9d62e7..829a1003e 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java @@ -45,6 +45,7 @@ import art.arcane.iris.util.common.scheduling.J; import org.bukkit.util.BlockVector; import java.io.IOException; +import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; @@ -313,10 +314,11 @@ public class MantleObjectComponent extends IrisMantleComponent { continue; } int id = rng.i(0, Integer.MAX_VALUE); + IrisObjectPlacement effectivePlacement = resolveEffectivePlacement(objectPlacement, v); try { - int result = v.place(xx, -1, zz, writer, objectPlacement, rng, (b, data) -> { + int result = v.place(xx, -1, zz, writer, effectivePlacement, rng, (b, data) -> { writer.setData(b.getX(), b.getY(), b.getZ(), v.getLoadKey() + "@" + id); - if (objectPlacement.isDolphinTarget() && objectPlacement.isUnderwater() && B.isStorageChest(data)) { + if (effectivePlacement.isDolphinTarget() && effectivePlacement.isUnderwater() && B.isStorageChest(data)) { writer.setData(b.getX(), b.getY(), b.getZ(), MatterStructurePOI.BURIED_TREASURE); } }, null, getData()); @@ -417,11 +419,12 @@ public class MantleObjectComponent extends IrisMantleComponent { } int id = rng.i(0, Integer.MAX_VALUE); + IrisObjectPlacement effectivePlacement = resolveEffectivePlacement(objectPlacement, object); try { - int result = object.place(x, y, z, writer, objectPlacement, rng, (b, data) -> { + int result = object.place(x, y, z, writer, effectivePlacement, rng, (b, data) -> { writer.setData(b.getX(), b.getY(), b.getZ(), object.getLoadKey() + "@" + id); - if (objectPlacement.isDolphinTarget() && objectPlacement.isUnderwater() && B.isStorageChest(data)) { + if (effectivePlacement.isDolphinTarget() && effectivePlacement.isUnderwater() && B.isStorageChest(data)) { writer.setData(b.getX(), b.getY(), b.getZ(), MatterStructurePOI.BURIED_TREASURE); } }, null, getData()); @@ -458,6 +461,38 @@ public class MantleObjectComponent extends IrisMantleComponent { return new ObjectPlacementResult(attempts, placed, rejected, nullObjects, errors); } + private static IrisObjectPlacement resolveEffectivePlacement(IrisObjectPlacement objectPlacement, IrisObject object) { + if (objectPlacement == null || object == null) { + return objectPlacement; + } + + String loadKey = object.getLoadKey(); + if (loadKey == null || loadKey.isBlank()) { + return objectPlacement; + } + + String normalized = loadKey.toLowerCase(Locale.ROOT); + boolean imported = normalized.startsWith("imports/") + || normalized.contains("/imports/") + || normalized.contains("imports/"); + if (!imported) { + return objectPlacement; + } + + ObjectPlaceMode mode = objectPlacement.getMode(); + if (mode == ObjectPlaceMode.STILT + || mode == ObjectPlaceMode.FAST_STILT + || mode == ObjectPlaceMode.MIN_STILT + || mode == ObjectPlaceMode.FAST_MIN_STILT + || mode == ObjectPlaceMode.CENTER_STILT) { + return objectPlacement; + } + + IrisObjectPlacement effectivePlacement = objectPlacement.toPlacement(loadKey); + effectivePlacement.setMode(ObjectPlaceMode.FAST_MIN_STILT); + return effectivePlacement; + } + private int findCaveAnchorY(MantleWriter writer, RNG rng, int x, int z, IrisCaveAnchorMode anchorMode, int anchorScanStep, int objectMinDepthBelowSurface, KMap> anchorCache) { long key = Cache.key(x, z); KList anchors = anchorCache.computeIfAbsent(key, (k) -> scanCaveAnchorColumn(writer, anchorMode, anchorScanStep, objectMinDepthBelowSurface, x, z)); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java b/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java index 01e4f7aa6..9f210b75c 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java @@ -104,6 +104,9 @@ public class IrisBiome extends IrisRegistrant implements IRare { private IrisCaveProfile caveProfile = new IrisCaveProfile(); @Desc("Configuration of fluid bodies such as rivers & lakes") private IrisFluidBodies fluidBodies = new IrisFluidBodies(); + @ArrayType(type = IrisExternalDatapackBinding.class, min = 1) + @Desc("Scoped external datapack bindings for this biome") + private KList externalDatapacks = new KList<>(); @MinNumber(1) @MaxNumber(512) @Desc("The rarity of this biome (integer)") diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisEffect.java b/core/src/main/java/art/arcane/iris/engine/object/IrisEffect.java index e263d2b2b..2c697c628 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisEffect.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisEffect.java @@ -197,55 +197,56 @@ public class IrisEffect { return; } - if (sound != null) { - Location part = p.getLocation().clone().add(RNG.r.i(-soundDistance, soundDistance), RNG.r.i(-soundDistance, soundDistance), RNG.r.i(-soundDistance, soundDistance)); - - J.s(() -> p.playSound(part, getSound(), (float) volume, (float) RNG.r.d(minPitch, maxPitch))); - } - - if (particleEffect != null) { - Location part = p.getLocation().clone().add(p.getLocation().getDirection().clone().multiply(RNG.r.i(particleDistance) + particleAway)).clone().add(p.getLocation().getDirection().clone().rotateAroundY(Math.toRadians(90)).multiply(RNG.r.d(-particleDistanceWidth, particleDistanceWidth))); - - part.setY(Math.round(g.getHeight(part.getBlockX(), part.getBlockZ())) + 1); - part.add(RNG.r.d(), 0, RNG.r.d()); - int offset = p.getWorld().getMinHeight(); - if (extra != 0) { - J.s(() -> p.spawnParticle(particleEffect, part.getX(), part.getY() + offset + RNG.r.i(particleOffset), - part.getZ(), - particleCount, - randomAltX ? RNG.r.d(-particleAltX, particleAltX) : particleAltX, - randomAltY ? RNG.r.d(-particleAltY, particleAltY) : particleAltY, - randomAltZ ? RNG.r.d(-particleAltZ, particleAltZ) : particleAltZ, - extra)); - } else { - J.s(() -> p.spawnParticle(particleEffect, part.getX(), part.getY() + offset + RNG.r.i(particleOffset), part.getZ(), - particleCount, - randomAltX ? RNG.r.d(-particleAltX, particleAltX) : particleAltX, - randomAltY ? RNG.r.d(-particleAltY, particleAltY) : particleAltY, - randomAltZ ? RNG.r.d(-particleAltZ, particleAltZ) : particleAltZ)); + J.runEntity(p, () -> { + if (sound != null) { + Location part = p.getLocation().clone().add(RNG.r.i(-soundDistance, soundDistance), RNG.r.i(-soundDistance, soundDistance), RNG.r.i(-soundDistance, soundDistance)); + p.playSound(part, getSound(), (float) volume, (float) RNG.r.d(minPitch, maxPitch)); } - } - if (commandRegistry != null) { - commandRegistry.run(p); - } + if (particleEffect != null) { + Location part = p.getLocation().clone().add(p.getLocation().getDirection().clone().multiply(RNG.r.i(particleDistance) + particleAway)).clone().add(p.getLocation().getDirection().clone().rotateAroundY(Math.toRadians(90)).multiply(RNG.r.d(-particleDistanceWidth, particleDistanceWidth))); - if (potionStrength > -1) { - if (p.hasPotionEffect(getRealType())) { - PotionEffect e = p.getPotionEffect(getRealType()); - if (e.getAmplifier() > getPotionStrength()) { - return; + part.setY(Math.round(g.getHeight(part.getBlockX(), part.getBlockZ())) + 1); + part.add(RNG.r.d(), 0, RNG.r.d()); + int offset = p.getWorld().getMinHeight(); + if (extra != 0) { + p.spawnParticle(particleEffect, part.getX(), part.getY() + offset + RNG.r.i(particleOffset), + part.getZ(), + particleCount, + randomAltX ? RNG.r.d(-particleAltX, particleAltX) : particleAltX, + randomAltY ? RNG.r.d(-particleAltY, particleAltY) : particleAltY, + randomAltZ ? RNG.r.d(-particleAltZ, particleAltZ) : particleAltZ, + extra); + } else { + p.spawnParticle(particleEffect, part.getX(), part.getY() + offset + RNG.r.i(particleOffset), part.getZ(), + particleCount, + randomAltX ? RNG.r.d(-particleAltX, particleAltX) : particleAltX, + randomAltY ? RNG.r.d(-particleAltY, particleAltY) : particleAltY, + randomAltZ ? RNG.r.d(-particleAltZ, particleAltZ) : particleAltZ); + } + } + + if (commandRegistry != null) { + commandRegistry.run(p); + } + + if (potionStrength > -1) { + if (p.hasPotionEffect(getRealType())) { + PotionEffect e = p.getPotionEffect(getRealType()); + if (e != null && e.getAmplifier() > getPotionStrength()) { + return; + } + + p.removePotionEffect(getRealType()); } - J.s(() -> p.removePotionEffect(getRealType())); + p.addPotionEffect(new PotionEffect(getRealType(), + RNG.r.i(Math.min(potionTicksMax, potionTicksMin), + Math.max(potionTicksMax, potionTicksMin)), + getPotionStrength(), + true, false, false)); } - - J.s(() -> p.addPotionEffect(new PotionEffect(getRealType(), - RNG.r.i(Math.min(potionTicksMax, potionTicksMin), - Math.max(potionTicksMax, potionTicksMin)), - getPotionStrength(), - true, false, false))); - } + }); } public void apply(Entity p) { @@ -257,31 +258,32 @@ public class IrisEffect { return; } - if (sound != null) { - Location part = p.getLocation().clone().add(RNG.r.i(-soundDistance, soundDistance), RNG.r.i(-soundDistance, soundDistance), RNG.r.i(-soundDistance, soundDistance)); - - J.s(() -> p.getWorld().playSound(part, getSound(), (float) volume, (float) RNG.r.d(minPitch, maxPitch))); - } - - if (particleEffect != null) { - Location part = p.getLocation().clone().add(0, 0.25, 0).add(new Vector(1, 1, 1).multiply(RNG.r.d())).subtract(new Vector(1, 1, 1).multiply(RNG.r.d())); - part.add(RNG.r.d(), 0, RNG.r.d()); - int offset = p.getWorld().getMinHeight(); - if (extra != 0) { - J.s(() -> p.getWorld().spawnParticle(particleEffect, part.getX(), part.getY() + offset + RNG.r.i(particleOffset), - part.getZ(), - particleCount, - randomAltX ? RNG.r.d(-particleAltX, particleAltX) : particleAltX, - randomAltY ? RNG.r.d(-particleAltY, particleAltY) : particleAltY, - randomAltZ ? RNG.r.d(-particleAltZ, particleAltZ) : particleAltZ, - extra)); - } else { - J.s(() -> p.getWorld().spawnParticle(particleEffect, part.getX(), part.getY() + offset + RNG.r.i(particleOffset), part.getZ(), - particleCount, - randomAltX ? RNG.r.d(-particleAltX, particleAltX) : particleAltX, - randomAltY ? RNG.r.d(-particleAltY, particleAltY) : particleAltY, - randomAltZ ? RNG.r.d(-particleAltZ, particleAltZ) : particleAltZ)); + J.runEntity(p, () -> { + if (sound != null) { + Location part = p.getLocation().clone().add(RNG.r.i(-soundDistance, soundDistance), RNG.r.i(-soundDistance, soundDistance), RNG.r.i(-soundDistance, soundDistance)); + p.getWorld().playSound(part, getSound(), (float) volume, (float) RNG.r.d(minPitch, maxPitch)); } - } + + if (particleEffect != null) { + Location part = p.getLocation().clone().add(0, 0.25, 0).add(new Vector(1, 1, 1).multiply(RNG.r.d())).subtract(new Vector(1, 1, 1).multiply(RNG.r.d())); + part.add(RNG.r.d(), 0, RNG.r.d()); + int offset = p.getWorld().getMinHeight(); + if (extra != 0) { + p.getWorld().spawnParticle(particleEffect, part.getX(), part.getY() + offset + RNG.r.i(particleOffset), + part.getZ(), + particleCount, + randomAltX ? RNG.r.d(-particleAltX, particleAltX) : particleAltX, + randomAltY ? RNG.r.d(-particleAltY, particleAltY) : particleAltY, + randomAltZ ? RNG.r.d(-particleAltZ, particleAltZ) : particleAltZ, + extra); + } else { + p.getWorld().spawnParticle(particleEffect, part.getX(), part.getY() + offset + RNG.r.i(particleOffset), part.getZ(), + particleCount, + randomAltX ? RNG.r.d(-particleAltX, particleAltX) : particleAltX, + randomAltY ? RNG.r.d(-particleAltY, particleAltY) : particleAltY, + randomAltZ ? RNG.r.d(-particleAltZ, particleAltZ) : particleAltZ); + } + } + }); } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisExternalDatapackBinding.java b/core/src/main/java/art/arcane/iris/engine/object/IrisExternalDatapackBinding.java new file mode 100644 index 000000000..6cbb56968 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisExternalDatapackBinding.java @@ -0,0 +1,29 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Accessors(chain = true) +@Desc("Scoped binding to a dimension external datapack id") +public class IrisExternalDatapackBinding { + @Desc("Target external datapack id defined on the dimension") + private String id = ""; + + @Desc("Enable or disable this scoped binding") + private boolean enabled = true; + + @Desc("Override replaceVanilla behavior for this scoped binding (null keeps dimension default)") + private Boolean replaceVanillaOverride = null; + + @Desc("Include child biomes recursively when collecting scoped biome boundaries") + private boolean includeChildren = true; + + @Desc("Override required behavior for this scoped binding (null keeps dimension default)") + private Boolean requiredOverride = null; +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObject.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObject.java index c51fad695..fb181a267 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObject.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObject.java @@ -821,8 +821,11 @@ public class IrisObject extends IrisRegistrant { if (yv >= 0 && config.isBottom()) { y += Math.floorDiv(h, 2); - if (!config.isForcePlace()) { - bail = shouldBailForCarvingAnchor(placer, config, x, y, z); + CarvingMode carvingMode = config.getCarvingSupport(); + if (!config.isForcePlace() && !carvingMode.equals(CarvingMode.CARVING_ONLY)) { + if (shouldBailForCarvingAnchor(placer, config, x, y, z)) { + bail = true; + } } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacement.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacement.java index d2ad83891..7154439d4 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacement.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacement.java @@ -29,6 +29,7 @@ import art.arcane.iris.util.common.data.DataProvider; import art.arcane.volmlib.util.data.WeightedRandom; import art.arcane.volmlib.util.math.RNG; import art.arcane.iris.util.project.noise.CNG; +import com.google.gson.annotations.SerializedName; import lombok.AllArgsConstructor; import lombok.Data; import lombok.EqualsAndHashCode; @@ -150,6 +151,7 @@ public class IrisObjectPlacement { @Desc("List of objects to this object is forbidden to collied with") private KList forbiddenCollisions = new KList<>(); @Desc("Ignore any placement restrictions for this object") + @SerializedName(value = "forcePlace", alternate = {"force"}) private boolean forcePlace = false; private transient AtomicCache cache = new AtomicCache<>(); @@ -178,6 +180,7 @@ public class IrisObjectPlacement { p.setClamp(clamp); p.setRotation(rotation); p.setLoot(loot); + p.setForcePlace(forcePlace); return p; } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java index a1d52dbdd..3b2ea4a75 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java @@ -116,6 +116,9 @@ public class IrisRegion extends IrisRegistrant implements IRare { private IrisCaveProfile caveProfile = new IrisCaveProfile(); @Desc("Configuration of fluid bodies such as rivers & lakes") private IrisFluidBodies fluidBodies = new IrisFluidBodies(); + @ArrayType(type = IrisExternalDatapackBinding.class, min = 1) + @Desc("Scoped external datapack bindings for this region") + private KList externalDatapacks = new KList<>(); @RegistryListResource(IrisBiome.class) @Required @ArrayType(min = 1, type = String.class) diff --git a/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java b/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java index 605d7ea4a..0332a1d30 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java @@ -34,10 +34,14 @@ import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisWorld; import art.arcane.iris.engine.object.StudioMode; import art.arcane.iris.engine.platform.studio.StudioGenerator; +import art.arcane.iris.util.project.matter.TileWrapper; import art.arcane.volmlib.util.collection.KList; import art.arcane.iris.util.project.hunk.Hunk; import art.arcane.iris.util.project.hunk.view.ChunkDataHunkHolder; import art.arcane.volmlib.util.io.ReactiveFolder; +import art.arcane.volmlib.util.mantle.flag.MantleFlag; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.matter.Matter; import art.arcane.volmlib.util.scheduling.ChronoLatch; import art.arcane.iris.util.common.scheduling.J; import art.arcane.volmlib.util.scheduling.Looper; @@ -47,6 +51,7 @@ import lombok.EqualsAndHashCode; import lombok.Setter; import org.bukkit.*; import org.bukkit.block.Biome; +import org.bukkit.block.data.BlockData; import org.bukkit.entity.Entity; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; @@ -62,11 +67,13 @@ import org.jetbrains.annotations.Nullable; import java.io.File; import java.util.List; +import java.util.Objects; import java.util.Random; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.ReentrantLock; @EqualsAndHashCode(callSuper = true) @@ -205,15 +212,25 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun } @Override - public void injectChunkReplacement(World world, int x, int z, Executor syncExecutor) { + public void injectChunkReplacement( + World world, + int x, + int z, + Executor syncExecutor, + ChunkReplacementOptions options, + ChunkReplacementListener listener + ) { boolean acquired = false; - String phase = "start"; + ChunkReplacementOptions effectiveOptions = Objects.requireNonNull(options, "options"); + ChunkReplacementListener effectiveListener = Objects.requireNonNull(listener, "listener"); + AtomicReference phaseRef = new AtomicReference<>("start"); try { - phase = "acquire-load-lock"; + setChunkReplacementPhase(phaseRef, effectiveListener, "acquire-load-lock", x, z); long acquireStart = System.currentTimeMillis(); while (!loadLock.tryAcquire(5, TimeUnit.SECONDS)) { Iris.warn("Chunk replacement waiting for load lock at " + x + "," + z + " for " + (System.currentTimeMillis() - acquireStart) + "ms."); + effectiveListener.onPhase(phaseRef.get(), x, z, System.currentTimeMillis()); } acquired = true; long acquireWait = System.currentTimeMillis() - acquireStart; @@ -223,7 +240,12 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun TerrainChunk tc = TerrainChunk.create(world); this.world.bind(world); - phase = "engine-generate"; + if (effectiveOptions.isFullMode()) { + setChunkReplacementPhase(phaseRef, effectiveListener, "reset-mantle", x, z); + resetMantleChunkForFullRegen(x, z); + } + + setChunkReplacementPhase(phaseRef, effectiveListener, "generate", x, z); long generateStart = System.currentTimeMillis(); boolean useMulticore = IrisSettings.get().getGenerator().useMulticore && !J.isFolia(); AtomicBoolean generateDone = new AtomicBoolean(false); @@ -242,6 +264,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun + " for " + (System.currentTimeMillis() - generationWatchdogStart.get()) + "ms" + " thread=" + generateThread.getName() + " state=" + generateThread.getState()); + effectiveListener.onPhase(phaseRef.get(), x, z, System.currentTimeMillis()); } }); try { @@ -255,12 +278,13 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun } if (J.isFolia()) { - phase = "folia-run-region"; + setChunkReplacementPhase(phaseRef, effectiveListener, "folia-run-region", x, z); CountDownLatch latch = new CountDownLatch(1); Throwable[] failure = new Throwable[1]; long regionScheduleStart = System.currentTimeMillis(); if (!J.runRegion(world, x, z, () -> { try { + setChunkReplacementPhase(phaseRef, effectiveListener, "apply-terrain", x, z); phaseUnsafeSet("folia-region-run", x, z); Chunk c = world.getChunkAt(x, z); Iris.tickets.addTicket(c); @@ -288,7 +312,15 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun } } + if (effectiveOptions.isFullMode()) { + setChunkReplacementPhase(phaseRef, effectiveListener, "overlay", x, z); + OverlayMetrics overlayMetrics = applyMantleOverlay(c, world, x, z); + effectiveListener.onOverlay(x, z, overlayMetrics.appliedBlocks(), overlayMetrics.objectKeys(), System.currentTimeMillis()); + } + + setChunkReplacementPhase(phaseRef, effectiveListener, "structures", x, z); INMS.get().placeStructures(c); + setChunkReplacementPhase(phaseRef, effectiveListener, "chunk-load-callback", x, z); engine.getWorldManager().onChunkLoad(c, true); } finally { Iris.tickets.removeTicket(c); @@ -310,16 +342,18 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun while (!latch.await(5, TimeUnit.SECONDS)) { Iris.warn("Chunk replacement waiting on region task at " + x + "," + z + " for " + (System.currentTimeMillis() - regionWaitStart) + "ms."); + effectiveListener.onPhase(phaseRef.get(), x, z, System.currentTimeMillis()); } long regionWaitTook = System.currentTimeMillis() - regionWaitStart; if (regionWaitTook >= 5000L) { Iris.warn("Chunk replacement region task completed after " + regionWaitTook + "ms at " + x + "," + z + "."); } if (failure[0] != null) { + effectiveListener.onFailurePhase(phaseRef.get(), x, z, failure[0], System.currentTimeMillis()); throw failure[0]; } } else { - phase = "paperlib-async-load"; + setChunkReplacementPhase(phaseRef, effectiveListener, "paperlib-async-load", x, z); long loadChunkStart = System.currentTimeMillis(); Chunk c = PaperLib.getChunkAtAsync(world, x, z).get(); long loadChunkTook = System.currentTimeMillis() - loadChunkStart; @@ -327,53 +361,66 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun Iris.warn("Chunk replacement chunk load took " + loadChunkTook + "ms at " + x + "," + z + "."); } - phase = "non-folia-apply"; + setChunkReplacementPhase(phaseRef, effectiveListener, "apply-terrain", x, z); Iris.tickets.addTicket(c); - CompletableFuture.runAsync(() -> { - for (Entity ee : c.getEntities()) { - if (ee instanceof Player) { - continue; + try { + CompletableFuture.runAsync(() -> { + for (Entity ee : c.getEntities()) { + if (ee instanceof Player) { + continue; + } + + ee.remove(); } + }, syncExecutor).get(); - ee.remove(); - } - }, syncExecutor).get(); - - KList> futures = new KList<>(1 + getEngine().getHeight() >> 4); - for (int i = getEngine().getHeight() >> 4; i >= 0; i--) { - int finalI = i << 4; - futures.add(CompletableFuture.runAsync(() -> { - for (int xx = 0; xx < 16; xx++) { - for (int yy = 0; yy < 16; yy++) { - for (int zz = 0; zz < 16; zz++) { - if (yy + finalI >= engine.getHeight() || yy + finalI < 0) { - continue; + KList> futures = new KList<>(1 + getEngine().getHeight() >> 4); + for (int i = getEngine().getHeight() >> 4; i >= 0; i--) { + int finalI = i << 4; + futures.add(CompletableFuture.runAsync(() -> { + for (int xx = 0; xx < 16; xx++) { + for (int yy = 0; yy < 16; yy++) { + for (int zz = 0; zz < 16; zz++) { + if (yy + finalI >= engine.getHeight() || yy + finalI < 0) { + continue; + } + int y = yy + finalI + world.getMinHeight(); + c.getBlock(xx, y, zz).setBlockData(tc.getBlockData(xx, y, zz), false); } - int y = yy + finalI + world.getMinHeight(); - c.getBlock(xx, y, zz).setBlockData(tc.getBlockData(xx, y, zz), false); } } - } - }, syncExecutor)); + }, syncExecutor)); + } + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).get(); + if (effectiveOptions.isFullMode()) { + CompletableFuture.runAsync(() -> { + setChunkReplacementPhase(phaseRef, effectiveListener, "overlay", x, z); + OverlayMetrics overlayMetrics = applyMantleOverlay(c, world, x, z); + effectiveListener.onOverlay(x, z, overlayMetrics.appliedBlocks(), overlayMetrics.objectKeys(), System.currentTimeMillis()); + }, syncExecutor).get(); + } + CompletableFuture.runAsync(() -> { + setChunkReplacementPhase(phaseRef, effectiveListener, "structures", x, z); + INMS.get().placeStructures(c); + }, syncExecutor).get(); + CompletableFuture.runAsync(() -> { + setChunkReplacementPhase(phaseRef, effectiveListener, "chunk-load-callback", x, z); + engine.getWorldManager().onChunkLoad(c, true); + }, syncExecutor).get(); + } finally { + Iris.tickets.removeTicket(c); } - futures.add(CompletableFuture.runAsync(() -> INMS.get().placeStructures(c), syncExecutor)); - - CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) - .thenRunAsync(() -> { - Iris.tickets.removeTicket(c); - engine.getWorldManager().onChunkLoad(c, true); - }, syncExecutor) - .get(); } Iris.debug("Regenerated " + x + " " + z); } catch (Throwable e) { + effectiveListener.onFailurePhase(phaseRef.get(), x, z, e, System.currentTimeMillis()); Iris.error("======================================"); - Iris.error("Chunk replacement failed at phase=" + phase + " chunk=" + x + "," + z); + Iris.error("Chunk replacement failed at phase=" + phaseRef.get() + " chunk=" + x + "," + z); e.printStackTrace(); Iris.reportErrorChunk(x, z, e, "CHUNK"); Iris.error("======================================"); - throw new IllegalStateException("Chunk replacement failed at phase=" + phase + " chunk=" + x + "," + z, e); + throw new IllegalStateException("Chunk replacement failed at phase=" + phaseRef.get() + " chunk=" + x + "," + z, e); } finally { if (acquired) { loadLock.release(); @@ -385,6 +432,63 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun Iris.verbose("Chunk replacement phase=" + phase + " chunk=" + x + "," + z); } + private static void setChunkReplacementPhase( + AtomicReference phaseRef, + ChunkReplacementListener listener, + String phase, + int x, + int z + ) { + phaseRef.set(phase); + listener.onPhase(phase, x, z, System.currentTimeMillis()); + } + + private void resetMantleChunkForFullRegen(int chunkX, int chunkZ) { + MantleChunk mantleChunk = getEngine().getMantle().getMantle().getChunk(chunkX, chunkZ).use(); + try { + mantleChunk.deleteSlices(BlockData.class); + mantleChunk.deleteSlices(String.class); + mantleChunk.deleteSlices(TileWrapper.class); + mantleChunk.flag(MantleFlag.PLANNED, false); + mantleChunk.flag(MantleFlag.OBJECT, false); + mantleChunk.flag(MantleFlag.REAL, false); + } finally { + mantleChunk.release(); + } + } + + private OverlayMetrics applyMantleOverlay(Chunk chunk, World world, int chunkX, int chunkZ) { + int minWorldY = world.getMinHeight(); + int maxWorldY = world.getMaxHeight(); + AtomicInteger appliedBlocks = new AtomicInteger(); + AtomicInteger objectKeys = new AtomicInteger(); + MantleChunk mantleChunk = getEngine().getMantle().getMantle().getChunk(chunkX, chunkZ).use(); + try { + mantleChunk.iterate(String.class, (x, y, z, value) -> { + if (value != null && !value.isEmpty() && value.indexOf('@') > 0) { + objectKeys.incrementAndGet(); + } + }); + mantleChunk.iterate(BlockData.class, (x, y, z, blockData) -> { + if (blockData == null) { + return; + } + int worldY = y + minWorldY; + if (worldY < minWorldY || worldY >= maxWorldY) { + return; + } + chunk.getBlock(x & 15, worldY, z & 15).setBlockData(blockData, false); + appliedBlocks.incrementAndGet(); + }); + } finally { + mantleChunk.release(); + } + return new OverlayMetrics(appliedBlocks.get(), objectKeys.get()); + } + + private record OverlayMetrics(int appliedBlocks, int objectKeys) { + } + private Engine getEngine(WorldInfo world) { if (setup.get()) { return getEngine(); diff --git a/core/src/main/java/art/arcane/iris/engine/platform/ChunkReplacementListener.java b/core/src/main/java/art/arcane/iris/engine/platform/ChunkReplacementListener.java new file mode 100644 index 000000000..9d251943e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/platform/ChunkReplacementListener.java @@ -0,0 +1,33 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.platform; + +public interface ChunkReplacementListener { + ChunkReplacementListener NO_OP = new ChunkReplacementListener() { + }; + + default void onPhase(String phase, int chunkX, int chunkZ, long timestampMs) { + } + + default void onOverlay(int chunkX, int chunkZ, int appliedBlocks, int objectKeys, long timestampMs) { + } + + default void onFailurePhase(String phase, int chunkX, int chunkZ, Throwable error, long timestampMs) { + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/platform/ChunkReplacementOptions.java b/core/src/main/java/art/arcane/iris/engine/platform/ChunkReplacementOptions.java new file mode 100644 index 000000000..a23742f4b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/platform/ChunkReplacementOptions.java @@ -0,0 +1,51 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.platform; + +public final class ChunkReplacementOptions { + private final String runId; + private final boolean fullMode; + private final boolean diagnostics; + + private ChunkReplacementOptions(String runId, boolean fullMode, boolean diagnostics) { + this.runId = runId == null ? "unknown" : runId; + this.fullMode = fullMode; + this.diagnostics = diagnostics; + } + + public static ChunkReplacementOptions terrain(String runId, boolean diagnostics) { + return new ChunkReplacementOptions(runId, false, diagnostics); + } + + public static ChunkReplacementOptions full(String runId, boolean diagnostics) { + return new ChunkReplacementOptions(runId, true, diagnostics); + } + + public String runId() { + return runId; + } + + public boolean isFullMode() { + return fullMode; + } + + public boolean diagnostics() { + return diagnostics; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/platform/PlatformChunkGenerator.java b/core/src/main/java/art/arcane/iris/engine/platform/PlatformChunkGenerator.java index de91830ad..efa68be4c 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/PlatformChunkGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/PlatformChunkGenerator.java @@ -42,7 +42,14 @@ public interface PlatformChunkGenerator extends Hotloadable, DataProvider { @NotNull EngineTarget getTarget(); - void injectChunkReplacement(World world, int x, int z, Executor syncExecutor); + void injectChunkReplacement( + World world, + int x, + int z, + Executor syncExecutor, + ChunkReplacementOptions options, + ChunkReplacementListener listener + ); void close(); diff --git a/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/ExternalDatapackLocateHandler.java b/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/ExternalDatapackLocateHandler.java new file mode 100644 index 000000000..093132d3a --- /dev/null +++ b/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/ExternalDatapackLocateHandler.java @@ -0,0 +1,112 @@ +package art.arcane.iris.util.common.director.specialhandlers; + +import art.arcane.iris.core.ExternalDataPackPipeline; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.iris.util.common.director.DirectorParameterHandler; +import art.arcane.volmlib.util.director.exceptions.DirectorParsingException; + +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +public class ExternalDatapackLocateHandler implements DirectorParameterHandler { + @Override + public KList getPossibilities() { + LinkedHashSet tokens = new LinkedHashSet<>(); + Map> locateById = ExternalDataPackPipeline.snapshotLocateStructuresById(); + for (Map.Entry> entry : locateById.entrySet()) { + if (entry == null) { + continue; + } + + String id = entry.getKey(); + if (id != null && !id.isBlank()) { + tokens.add(id); + } + + Set structures = entry.getValue(); + if (structures == null || structures.isEmpty()) { + continue; + } + + for (String structure : structures) { + if (structure != null && !structure.isBlank()) { + tokens.add(structure); + } + } + } + + KList possibilities = new KList<>(); + possibilities.add(tokens); + return possibilities; + } + + @Override + public KList getPossibilities(String input) { + String rawInput = input == null ? "" : input; + String[] split = rawInput.split(",", -1); + String partial = split.length == 0 ? "" : split[split.length - 1].trim().toLowerCase(Locale.ROOT); + StringBuilder prefixBuilder = new StringBuilder(); + if (split.length > 1) { + for (int index = 0; index < split.length - 1; index++) { + String value = split[index] == null ? "" : split[index].trim(); + if (value.isBlank()) { + continue; + } + if (!prefixBuilder.isEmpty()) { + prefixBuilder.append(','); + } + prefixBuilder.append(value); + } + } + + String prefix = prefixBuilder.toString(); + LinkedHashSet completions = new LinkedHashSet<>(); + for (String possibility : getPossibilities()) { + if (possibility == null || possibility.isBlank()) { + continue; + } + String normalized = possibility.toLowerCase(Locale.ROOT); + if (!partial.isBlank() && !normalized.startsWith(partial)) { + continue; + } + + if (prefix.isBlank()) { + completions.add(possibility); + } else { + completions.add(prefix + "," + possibility); + } + } + + KList results = new KList<>(); + results.add(completions); + return results; + } + + @Override + public String toString(String value) { + return value == null ? "" : value; + } + + @Override + public String parse(String in, boolean force) throws DirectorParsingException { + if (in == null || in.trim().isBlank()) { + throw new DirectorParsingException("You must provide at least one external datapack id or structure id."); + } + + return in.trim(); + } + + @Override + public boolean supports(Class type) { + return type.equals(String.class); + } + + @Override + public String getRandomDefault() { + KList possibilities = getPossibilities(); + String random = possibilities.getRandom(); + return random == null ? "external-datapack-id" : random; + } +} diff --git a/nms/v1_21_R7/src/main/java/art/arcane/iris/core/nms/v1_21_R7/CustomBiomeSource.java b/nms/v1_21_R7/src/main/java/art/arcane/iris/core/nms/v1_21_R7/CustomBiomeSource.java index 40b5fc01b..b0d0a85cb 100644 --- a/nms/v1_21_R7/src/main/java/art/arcane/iris/core/nms/v1_21_R7/CustomBiomeSource.java +++ b/nms/v1_21_R7/src/main/java/art/arcane/iris/core/nms/v1_21_R7/CustomBiomeSource.java @@ -205,13 +205,12 @@ public class CustomBiomeSource extends BiomeSource { int blockZ = z << 2; int blockY = y << 2; int worldMinHeight = engine.getWorld().minHeight(); - int surfaceInternalY = engine.getComplex().getHeightStream().get(blockX, blockZ).intValue(); - int surfaceWorldY = surfaceInternalY + worldMinHeight; - int caveSwitchWorldY = Math.min(-8, worldMinHeight + 40); - boolean deepUnderground = blockY <= caveSwitchWorldY; - boolean belowSurface = blockY <= surfaceWorldY - 8; - boolean underground = deepUnderground && belowSurface; int internalY = blockY - worldMinHeight; + int surfaceInternalY = engine.getComplex().getHeightStream().get(blockX, blockZ).intValue(); + int caveSwitchInternalY = Math.max(-8 - worldMinHeight, 40); + boolean deepUnderground = internalY <= caveSwitchInternalY; + boolean belowSurface = internalY <= surfaceInternalY - 8; + boolean underground = deepUnderground && belowSurface; IrisBiome irisBiome = underground ? engine.getCaveBiome(blockX, internalY, blockZ) : engine.getComplex().getTrueBiomeStream().get(blockX, blockZ); diff --git a/nms/v1_21_R7/src/main/java/art/arcane/iris/core/nms/v1_21_R7/IrisChunkGenerator.java b/nms/v1_21_R7/src/main/java/art/arcane/iris/core/nms/v1_21_R7/IrisChunkGenerator.java index a98f78bac..1a4d1cc90 100644 --- a/nms/v1_21_R7/src/main/java/art/arcane/iris/core/nms/v1_21_R7/IrisChunkGenerator.java +++ b/nms/v1_21_R7/src/main/java/art/arcane/iris/core/nms/v1_21_R7/IrisChunkGenerator.java @@ -3,6 +3,7 @@ package art.arcane.iris.core.nms.v1_21_R7; import com.mojang.datafixers.util.Pair; import com.mojang.serialization.MapCodec; import art.arcane.iris.Iris; +import art.arcane.iris.core.ExternalDataPackPipeline; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.util.common.reflect.WrappedField; @@ -49,6 +50,7 @@ import java.util.function.Supplier; public class IrisChunkGenerator extends CustomChunkGenerator { private static final WrappedField BIOME_SOURCE; private static final WrappedReturningMethod SET_HEIGHT; + private static final int EXTERNAL_FOUNDATION_MAX_DEPTH = 96; private final ChunkGenerator delegate; private final Engine engine; private volatile Registry cachedStructureRegistry; @@ -199,6 +201,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator { List starts = new ArrayList<>(structureManager.startsForStructure(chunkAccess.getPos(), structure -> true)); starts.sort(Comparator.comparingInt(start -> structureOrder.getOrDefault(start.getStructure(), Integer.MAX_VALUE))); + Set externalLocateStructures = ExternalDataPackPipeline.snapshotLocateStructureKeys(); int seededStructureIndex = Integer.MIN_VALUE; for (int j = 0; j < starts.size(); j++) { @@ -210,10 +213,19 @@ public class IrisChunkGenerator extends CustomChunkGenerator { seededStructureIndex = structureIndex; } Supplier supplier = () -> structureRegistry.getResourceKey(structure).map(Object::toString).orElseGet(structure::toString); + String structureKey = supplier.get().toLowerCase(Locale.ROOT); + boolean isExternalLocateStructure = externalLocateStructures.contains(structureKey); + BitSet[] beforeSolidColumns = null; + if (isExternalLocateStructure) { + beforeSolidColumns = snapshotChunkSolidColumns(level, chunkAccess); + } try { level.setCurrentlyGenerating(supplier); start.placeInChunk(level, structureManager, this, random, getWritableArea(chunkAccess), chunkAccess.getPos()); + if (isExternalLocateStructure && beforeSolidColumns != null) { + applyExternalStructureFoundations(level, chunkAccess, beforeSolidColumns, EXTERNAL_FOUNDATION_MAX_DEPTH); + } } catch (Exception exception) { CrashReport crashReport = CrashReport.forThrowable(exception, "Feature placement"); CrashReportCategory category = crashReport.addCategory("Feature"); @@ -235,6 +247,120 @@ public class IrisChunkGenerator extends CustomChunkGenerator { return new BoundingBox(minX, minY, minZ, minX + 15, maxY, minZ + 15); } + private static BitSet[] snapshotChunkSolidColumns(WorldGenLevel level, ChunkAccess chunkAccess) { + int minY = level.getMinY(); + int maxY = level.getMaxY(); + int ySpan = maxY - minY; + if (ySpan <= 0) { + return new BitSet[0]; + } + + ChunkPos chunkPos = chunkAccess.getPos(); + int minX = chunkPos.getMinBlockX(); + int minZ = chunkPos.getMinBlockZ(); + BitSet[] columns = new BitSet[16 * 16]; + BlockPos.MutableBlockPos mutablePos = new BlockPos.MutableBlockPos(); + for (int localX = 0; localX < 16; localX++) { + for (int localZ = 0; localZ < 16; localZ++) { + int index = (localX << 4) | localZ; + BitSet solids = new BitSet(ySpan); + int worldX = minX + localX; + int worldZ = minZ + localZ; + for (int y = minY; y < maxY; y++) { + mutablePos.set(worldX, y, worldZ); + if (isFoundationSolid(level.getBlockState(mutablePos))) { + solids.set(y - minY); + } + } + columns[index] = solids; + } + } + + return columns; + } + + private static void applyExternalStructureFoundations( + WorldGenLevel level, + ChunkAccess chunkAccess, + BitSet[] beforeSolidColumns, + int maxDepth + ) { + if (beforeSolidColumns == null || beforeSolidColumns.length == 0 || maxDepth <= 0) { + return; + } + + int minY = level.getMinY(); + int maxY = level.getMaxY(); + int ySpan = maxY - minY; + if (ySpan <= 0) { + return; + } + + ChunkPos chunkPos = chunkAccess.getPos(); + int minX = chunkPos.getMinBlockX(); + int minZ = chunkPos.getMinBlockZ(); + BlockPos.MutableBlockPos mutablePos = new BlockPos.MutableBlockPos(); + for (int localX = 0; localX < 16; localX++) { + for (int localZ = 0; localZ < 16; localZ++) { + int index = (localX << 4) | localZ; + BitSet before = beforeSolidColumns[index]; + if (before == null) { + continue; + } + + int worldX = minX + localX; + int worldZ = minZ + localZ; + int lowestNewSolidY = Integer.MIN_VALUE; + for (int y = minY; y < maxY; y++) { + mutablePos.set(worldX, y, worldZ); + BlockState state = level.getBlockState(mutablePos); + if (!isFoundationSolid(state)) { + continue; + } + + if (before.get(y - minY)) { + continue; + } + + lowestNewSolidY = y; + break; + } + + if (lowestNewSolidY == Integer.MIN_VALUE) { + continue; + } + + mutablePos.set(worldX, lowestNewSolidY, worldZ); + BlockState foundationState = level.getBlockState(mutablePos); + if (!isFoundationSolid(foundationState)) { + continue; + } + + int depth = 0; + for (int y = lowestNewSolidY - 1; y >= minY && depth < maxDepth; y--) { + mutablePos.set(worldX, y, worldZ); + BlockState state = level.getBlockState(mutablePos); + if (isFoundationSolid(state)) { + break; + } + + level.setBlock(mutablePos, foundationState, 2); + depth++; + } + } + } + } + + private static boolean isFoundationSolid(BlockState state) { + if (state == null || state.isAir()) { + return false; + } + if (!state.getFluidState().isEmpty()) { + return false; + } + return Heightmap.Types.MOTION_BLOCKING_NO_LEAVES.isOpaque().test(state); + } + private Map getStructureOrder(Registry structureRegistry) { Map localOrder = cachedStructureOrder; Registry localRegistry = cachedStructureRegistry; diff --git a/nms/v1_21_R7/src/main/java/art/arcane/iris/core/nms/v1_21_R7/NMSBinding.java b/nms/v1_21_R7/src/main/java/art/arcane/iris/core/nms/v1_21_R7/NMSBinding.java index fe6cc5a46..50824c17c 100644 --- a/nms/v1_21_R7/src/main/java/art/arcane/iris/core/nms/v1_21_R7/NMSBinding.java +++ b/nms/v1_21_R7/src/main/java/art/arcane/iris/core/nms/v1_21_R7/NMSBinding.java @@ -832,7 +832,10 @@ public class NMSBinding implements INMSBinding { public void placeStructures(Chunk chunk) { var craft = ((CraftChunk) chunk); var level = craft.getCraftWorld().getHandle(); - var access = ((CraftChunk) chunk).getHandle(ChunkStatus.FULL); + var access = craft.getHandle(ChunkStatus.FEATURES); + if (access instanceof LevelChunk) { + return; + } level.getChunkSource().getGenerator().applyBiomeDecoration(level, access, level.structureManager()); }