This commit is contained in:
Brian Neumann-Fopiano
2026-07-10 04:05:24 -04:00
parent 6111b5670a
commit 402bcb04fe
112 changed files with 5610 additions and 733 deletions
@@ -160,6 +160,7 @@ public class ServerConfigurator {
}
DimensionHeight height = new DimensionHeight(fixer);
KList<File> folders = getDatapacksFolder();
IrisDimension.clearGeneratedBiomeTags(folders);
DatapackIngestService.reapplyFromStaging(folders);
java.util.concurrent.ConcurrentMap<String, KSet<String>> biomes = new java.util.concurrent.ConcurrentHashMap<>();
@@ -205,7 +205,9 @@ public interface INMSBinding {
void inject(long seed, Engine engine, World world) throws NoSuchFieldException, IllegalAccessException;
Vector3d getBoundingbox(org.bukkit.entity.EntityType entity);
String getEntitySpawnCategory(String key);
Entity spawnEntity(Location location, EntityType type, CreatureSpawnEvent.SpawnReason reason);
Color getBiomeColor(Location location, BiomeColor type);
@@ -12,6 +12,7 @@ public class DataFixerV1217 extends DataFixerV1213 {
Dimension.OVERWORLD, """
{
"ambient_light": 0.0,
"default_clock": "minecraft:overworld",
"has_ender_dragon_fight": false,
"attributes": {
"minecraft:audio/ambient_sounds": {
@@ -43,6 +44,7 @@ public class DataFixerV1217 extends DataFixerV1213 {
Dimension.NETHER, """
{
"ambient_light": 0.1,
"has_fixed_time": true,
"has_ender_dragon_fight": false,
"attributes": {
"minecraft:gameplay/sky_light_level": 4.0,
@@ -59,6 +61,8 @@ public class DataFixerV1217 extends DataFixerV1213 {
Dimension.END, """
{
"ambient_light": 0.25,
"default_clock": "minecraft:the_end",
"has_fixed_time": true,
"has_ender_dragon_fight": true,
"attributes": {
"minecraft:audio/ambient_sounds": {
@@ -248,6 +248,11 @@ public class NMSBinding1X implements INMSBinding {
return null;
}
@Override
public String getEntitySpawnCategory(String key) {
return null;
}
@Override
public MCAPaletteAccess createPalette() {
IrisLogging.error("Cannot use the global data palette! Iris is incapable of using MCA generation on this version of minecraft!");
@@ -0,0 +1,26 @@
package art.arcane.iris.core.pack;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
public final class PackDirectoryResolver {
private PackDirectoryResolver() {
}
public static File resolveExisting(File packsRoot, String packName) {
if (packsRoot == null || packName == null || packName.isBlank()) {
return null;
}
Path root = packsRoot.toPath().toAbsolutePath().normalize();
Path candidate = root.resolve(packName).normalize();
if (!root.equals(candidate.getParent())) {
return null;
}
if (Files.isSymbolicLink(candidate) || !Files.isDirectory(candidate, LinkOption.NOFOLLOW_LINKS)) {
return null;
}
return candidate.toFile();
}
}
@@ -30,13 +30,18 @@ import java.io.File;
import java.io.IOException;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.regex.Pattern;
public final class PackDownloader {
private static final Pattern GITHUB_REPOSITORY = Pattern.compile("[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+");
private static final Pattern GITHUB_REF = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._/-]*");
private static final Pattern COMMIT_SHA = Pattern.compile("[0-9a-fA-F]{40}");
private PackDownloader() {
}
public static String download(File packsFolder, String repo, String branch, boolean forceOverwrite, boolean directUrl, Consumer<String> feedback) throws IOException {
String url = directUrl ? branch : "https://codeload.github.com/" + repo + "/zip/refs/heads/" + branch;
public static String download(File packsFolder, String repo, String ref, boolean forceOverwrite, boolean directUrl, Consumer<String> feedback) throws IOException {
String url = directUrl ? ref : resolveGithubArchiveUrl(repo, ref);
feedback.accept("Downloading " + url + " "); //The extra space stops a bug in adventure API from repeating the last letter of the URL
File zip = WebCache.getNonCachedFile("pack-" + repo, url);
File temp = WebCache.getTemp();
@@ -136,6 +141,57 @@ public final class PackDownloader {
return key;
}
static String resolveGithubArchiveUrl(String repo, String ref) {
if (repo == null || !GITHUB_REPOSITORY.matcher(repo).matches()) {
throw new IllegalArgumentException("Invalid GitHub repository '" + repo + "'");
}
String[] repositoryParts = repo.split("/", -1);
if (repositoryParts[0].equals(".") || repositoryParts[0].equals("..")
|| repositoryParts[1].equals(".") || repositoryParts[1].equals("..")) {
throw new IllegalArgumentException("Invalid GitHub repository '" + repo + "'");
}
if (ref == null || ref.isBlank()) {
throw new IllegalArgumentException("GitHub reference cannot be empty");
}
if (COMMIT_SHA.matcher(ref).matches()) {
return "https://github.com/" + repo + "/archive/" + ref + ".zip";
}
if (ref.startsWith("refs/") && !ref.startsWith("refs/heads/") && !ref.startsWith("refs/tags/")) {
throw new IllegalArgumentException("Unsupported GitHub reference '" + ref + "'");
}
String qualifiedRef;
if (ref.startsWith("refs/heads/") || ref.startsWith("refs/tags/")) {
qualifiedRef = ref;
} else {
qualifiedRef = "refs/heads/" + ref;
}
validateGithubRef(qualifiedRef);
return "https://codeload.github.com/" + repo + "/zip/" + qualifiedRef;
}
private static void validateGithubRef(String qualifiedRef) {
String refPath = qualifiedRef.startsWith("refs/heads/")
? qualifiedRef.substring("refs/heads/".length())
: qualifiedRef.substring("refs/tags/".length());
if (!GITHUB_REF.matcher(refPath).matches()
|| refPath.contains("..")
|| refPath.contains("//")
|| refPath.contains("@{")
|| refPath.endsWith("/")
|| refPath.endsWith(".")
|| refPath.endsWith(".lock")) {
throw new IllegalArgumentException("Invalid GitHub reference '" + qualifiedRef + "'");
}
String[] segments = refPath.split("/");
for (String segment : segments) {
if (segment.startsWith(".") || segment.endsWith(".lock")) {
throw new IllegalArgumentException("Invalid GitHub reference '" + qualifiedRef + "'");
}
}
}
private static void validateDownloaded(File packEntry, Consumer<String> feedback) {
try {
PackValidationResult result = PackValidator.validate(packEntry);
@@ -146,9 +202,8 @@ public final class PackDownloader {
for (String reason : result.getBlockingErrors()) {
feedback.accept(" - " + reason);
}
} else if (!result.getWarnings().isEmpty() || !result.getRemovedUnusedFiles().isEmpty()) {
} else if (!result.getWarnings().isEmpty()) {
feedback.accept("Pack '" + result.getPackName() + "' validated ("
+ result.getRemovedUnusedFiles().size() + " unused file(s) quarantined to .iris-trash/, "
+ result.getWarnings().size() + " warning(s)).");
} else {
feedback.accept("Pack '" + result.getPackName() + "' validated.");
@@ -0,0 +1,620 @@
package art.arcane.iris.core.pack;
import art.arcane.iris.spi.IrisLogging;
import java.io.File;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Stream;
public final class PackResourceCleanup {
private static final String TRASH_ROOT = ".iris-trash";
private static final List<String> MANAGED_RESOURCE_FOLDERS = List.of(
"biomes",
"regions",
"entities",
"spawners",
"loot",
"generators",
"expressions",
"markers",
"blocks",
"mods"
);
private static final List<String> CORPUS_EXCLUSIONS = List.of(
TRASH_ROOT,
"datapack-imports",
"externaldatapacks",
"internaldatapacks",
"datapacks",
"cache",
"objects",
".iris"
);
private static final DateTimeFormatter TRASH_STAMP = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss-SSS");
private static final Map<Path, Object> PACK_LOCKS = new ConcurrentHashMap<>();
private PackResourceCleanup() {
}
public static Preview preview(File packFolder) {
Path lockPath = lockPath(packFolder);
if (lockPath == null) {
return new Preview(List.of(), "Pack folder is required.");
}
synchronized (packLock(lockPath)) {
try {
Path packRoot = requirePackRoot(packFolder);
CleanupScan scan = scanCleanup(packRoot);
return new Preview(scan.paths(), null);
} catch (IOException | RuntimeException e) {
return new Preview(List.of(), errorMessage("Unable to scan pack resources", e));
}
}
}
public static ApplyResult apply(File packFolder) {
Path lockPath = lockPath(packFolder);
if (lockPath == null) {
return new ApplyResult(null, List.of(), "Pack folder is required.");
}
synchronized (packLock(lockPath)) {
try {
Path packRoot = requirePackRoot(packFolder);
CleanupScan scan = scanCleanup(packRoot);
if (scan.paths().isEmpty()) {
return new ApplyResult(null, List.of(), null);
}
return quarantine(packRoot, scan.paths());
} catch (IOException | RuntimeException e) {
return new ApplyResult(null, List.of(), errorMessage("Unable to quarantine pack resources", e));
}
}
}
public static RestorePreview previewRestore(File packFolder) {
Path lockPath = lockPath(packFolder);
if (lockPath == null) {
return new RestorePreview(null, List.of(), List.of(), "Pack folder is required.");
}
synchronized (packLock(lockPath)) {
try {
Path packRoot = requirePackRoot(packFolder);
RestoreScan scan = scanRestore(packRoot);
return new RestorePreview(scan.dumpPath(), scan.paths(), scan.conflicts(), null);
} catch (IOException | RuntimeException e) {
return new RestorePreview(null, List.of(), List.of(), errorMessage("Unable to scan quarantined resources", e));
}
}
}
public static RestoreResult restoreLatest(File packFolder) {
Path lockPath = lockPath(packFolder);
if (lockPath == null) {
return new RestoreResult(null, List.of(), List.of(), "Pack folder is required.");
}
synchronized (packLock(lockPath)) {
try {
Path packRoot = requirePackRoot(packFolder);
RestoreScan scan = scanRestore(packRoot);
if (scan.dump() == null) {
return new RestoreResult(null, List.of(), List.of(), null);
}
if (!scan.conflicts().isEmpty()) {
return new RestoreResult(scan.dumpPath(), List.of(), scan.conflicts(), null);
}
return restore(packRoot, scan);
} catch (IOException | RuntimeException e) {
return new RestoreResult(null, List.of(), List.of(), errorMessage("Unable to restore quarantined resources", e));
}
}
}
private static ApplyResult quarantine(Path packRoot, List<String> paths) throws IOException {
Path dump = createUniqueDump(packRoot);
List<Transfer> moved = new ArrayList<>(paths.size());
try {
for (String path : paths) {
Path source = resolveContained(packRoot, path);
requireRegularFile(source, "Cleanup candidate");
Path destination = resolveContained(dump, path);
if (Files.exists(destination, LinkOption.NOFOLLOW_LINKS)) {
throw new FileAlreadyExistsException(destination.toString());
}
Files.createDirectories(destination.getParent());
Files.move(source, destination);
moved.add(new Transfer(path, source, destination));
}
return new ApplyResult(relativePath(packRoot, dump), paths, null);
} catch (IOException | RuntimeException e) {
List<String> remaining = rollbackMoves(moved);
deleteEmptyDirectories(dump);
deleteIfEmpty(dump.getParent());
String rollbackState = remaining.isEmpty()
? "Quarantine failed and was rolled back"
: "Quarantine failed and rollback left " + remaining.size() + " file(s) quarantined";
IrisLogging.reportError(rollbackState + " for pack " + packRoot.getFileName(), e);
return new ApplyResult(relativePath(packRoot, dump), remaining, errorMessage(rollbackState, e));
}
}
private static RestoreResult restore(Path packRoot, RestoreScan scan) throws IOException {
List<Path> copiedDestinations = new ArrayList<>(scan.transfers().size());
List<Path> createdDirectories = new ArrayList<>();
try {
for (Transfer transfer : scan.transfers()) {
createDirectories(packRoot, transfer.destination().getParent(), createdDirectories);
Files.copy(transfer.source(), transfer.destination(), StandardCopyOption.COPY_ATTRIBUTES);
copiedDestinations.add(transfer.destination());
}
} catch (IOException | RuntimeException e) {
List<String> rollbackRemainders = rollbackCopies(packRoot, copiedDestinations, createdDirectories);
String rollbackState = rollbackRemainders.isEmpty()
? "Restore copy failed and was rolled back"
: "Restore copy failed and rollback left " + rollbackRemainders.size() + " destination(s)";
IrisLogging.reportError(rollbackState + " for pack " + packRoot.getFileName(), e);
return new RestoreResult(scan.dumpPath(), rollbackRemainders, List.of(), errorMessage(rollbackState, e));
}
List<Transfer> removedSources = new ArrayList<>(scan.transfers().size());
for (Transfer transfer : scan.transfers()) {
try {
Files.delete(transfer.source());
removedSources.add(transfer);
} catch (IOException | RuntimeException e) {
return rollbackSourceRemoval(packRoot, scan, copiedDestinations, createdDirectories, removedSources, e);
}
}
deleteEmptyDirectories(scan.dump());
deleteIfEmpty(scan.dump().getParent());
return new RestoreResult(scan.dumpPath(), scan.paths(), List.of(), null);
}
private static CleanupScan scanCleanup(Path packRoot) throws IOException {
List<Path> corpusFiles = listTree(packRoot);
StringBuilder corpus = new StringBuilder(1 << 16);
for (Path path : corpusFiles) {
if (!isScannableJson(packRoot, path)) {
continue;
}
requireRegularFile(path, "Corpus file");
corpus.append(Files.readString(path, StandardCharsets.UTF_8)).append('\n');
}
if (corpus.isEmpty()) {
return new CleanupScan(List.of());
}
String corpusText = corpus.toString();
List<String> candidates = new ArrayList<>();
for (String folderName : MANAGED_RESOURCE_FOLDERS) {
Path resourceFolder = resolveContained(packRoot, folderName);
if (!Files.exists(resourceFolder, LinkOption.NOFOLLOW_LINKS)) {
continue;
}
requireDirectory(resourceFolder, "Managed resource folder");
for (Path resource : listTree(resourceFolder)) {
if (!isJsonFile(resource)) {
continue;
}
requireRegularFile(resource, "Managed resource");
String key = stripJsonExtension(relativePath(resourceFolder, resource));
if (!key.isBlank() && !isReferenced(corpusText, key)) {
candidates.add(relativePath(packRoot, resource));
}
}
}
candidates.sort(String::compareTo);
return new CleanupScan(List.copyOf(candidates));
}
private static RestoreScan scanRestore(Path packRoot) throws IOException {
Path trashRoot = resolveContained(packRoot, TRASH_ROOT);
if (!Files.exists(trashRoot, LinkOption.NOFOLLOW_LINKS)) {
return RestoreScan.empty();
}
requireDirectory(trashRoot, "Quarantine root");
List<Path> dumps;
try (Stream<Path> stream = Files.list(trashRoot)) {
dumps = stream.sorted(Comparator.comparing(path -> path.getFileName().toString())).toList();
} catch (UncheckedIOException e) {
throw e.getCause();
}
List<Path> directories = new ArrayList<>();
for (Path dump : dumps) {
if (Files.isSymbolicLink(dump)) {
throw new IOException("Quarantine entry is a symbolic link: " + dump);
}
if (Files.isDirectory(dump, LinkOption.NOFOLLOW_LINKS)) {
directories.add(dump);
}
}
if (directories.isEmpty()) {
return RestoreScan.empty();
}
Path latest = directories.get(directories.size() - 1);
String dumpPath = relativePath(packRoot, latest);
List<Transfer> transfers = new ArrayList<>();
List<String> conflicts = new ArrayList<>();
for (Path source : listTree(latest)) {
if (Files.isDirectory(source, LinkOption.NOFOLLOW_LINKS)) {
continue;
}
requireRegularFile(source, "Quarantined resource");
String relative = relativePath(latest, source);
Path destination = resolveContained(packRoot, relative);
if (destination.startsWith(trashRoot)) {
throw new IOException("Quarantined resource resolves inside the quarantine root: " + relative);
}
if (hasDestinationConflict(packRoot, destination)) {
conflicts.add(relative);
}
transfers.add(new Transfer(relative, source, destination));
}
transfers.sort(Comparator.comparing(Transfer::path));
conflicts.sort(String::compareTo);
List<String> paths = transfers.stream().map(Transfer::path).toList();
return new RestoreScan(latest, dumpPath, List.copyOf(transfers), List.copyOf(paths), List.copyOf(conflicts));
}
private static List<Path> listTree(Path root) throws IOException {
requireDirectory(root, "Scan root");
try (Stream<Path> stream = Files.walk(root)) {
List<Path> paths = stream.sorted(Comparator.comparing(path -> relativePath(root, path))).toList();
for (Path path : paths) {
if (!path.equals(root) && Files.isSymbolicLink(path)) {
throw new IOException("Symbolic links are not supported during pack cleanup: " + path);
}
}
return paths;
} catch (UncheckedIOException e) {
throw e.getCause();
}
}
private static Path createUniqueDump(Path packRoot) throws IOException {
Path trashRoot = resolveContained(packRoot, TRASH_ROOT);
if (Files.exists(trashRoot, LinkOption.NOFOLLOW_LINKS)) {
requireDirectory(trashRoot, "Quarantine root");
} else {
Files.createDirectory(trashRoot);
}
String baseName = LocalDateTime.now().format(TRASH_STAMP);
for (int attempt = 0; attempt < 10_000; attempt++) {
String name = attempt == 0 ? baseName : baseName + '-' + String.format(Locale.ROOT, "%04d", attempt);
Path dump = resolveContained(trashRoot, name);
try {
return Files.createDirectory(dump);
} catch (FileAlreadyExistsException ignored) {
}
}
throw new IOException("Unable to allocate a unique quarantine directory.");
}
private static List<String> rollbackMoves(List<Transfer> moved) {
for (int i = moved.size() - 1; i >= 0; i--) {
Transfer transfer = moved.get(i);
try {
if (!Files.exists(transfer.source(), LinkOption.NOFOLLOW_LINKS)
&& Files.exists(transfer.destination(), LinkOption.NOFOLLOW_LINKS)) {
Files.move(transfer.destination(), transfer.source());
}
} catch (IOException | RuntimeException e) {
IrisLogging.reportError("Failed to roll back quarantined resource '" + transfer.path() + "'", e);
}
}
List<String> remaining = new ArrayList<>();
for (Transfer transfer : moved) {
if (Files.exists(transfer.destination(), LinkOption.NOFOLLOW_LINKS)) {
remaining.add(transfer.path());
}
}
remaining.sort(String::compareTo);
return List.copyOf(remaining);
}
private static RestoreResult rollbackSourceRemoval(Path packRoot,
RestoreScan scan,
List<Path> copiedDestinations,
List<Path> createdDirectories,
List<Transfer> removedSources,
Throwable failure) {
List<String> sourceRollbackFailures = new ArrayList<>();
for (int i = removedSources.size() - 1; i >= 0; i--) {
Transfer transfer = removedSources.get(i);
try {
Files.copy(transfer.destination(), transfer.source(), StandardCopyOption.COPY_ATTRIBUTES);
} catch (IOException | RuntimeException e) {
sourceRollbackFailures.add(transfer.path());
IrisLogging.reportError("Failed to restore quarantined source '" + transfer.path() + "' during rollback", e);
}
}
List<Path> removableDestinations = new ArrayList<>();
for (Path destination : copiedDestinations) {
String path = relativePath(packRoot, destination);
Path source = scan.dump().resolve(path).normalize();
if (Files.exists(source, LinkOption.NOFOLLOW_LINKS)) {
removableDestinations.add(destination);
}
}
List<String> destinationRemainders = rollbackCopies(packRoot, removableDestinations, createdDirectories);
List<String> remainders = new ArrayList<>(sourceRollbackFailures.size() + destinationRemainders.size());
remainders.addAll(sourceRollbackFailures);
for (String destinationRemainder : destinationRemainders) {
if (!remainders.contains(destinationRemainder)) {
remainders.add(destinationRemainder);
}
}
remainders.sort(String::compareTo);
String rollbackState = remainders.isEmpty()
? "Restore source removal failed and was rolled back"
: "Restore source removal failed and rollback left " + remainders.size() + " destination(s)";
IrisLogging.reportError(rollbackState + " for pack " + packRoot.getFileName(), failure);
return new RestoreResult(scan.dumpPath(), remainders, List.of(), errorMessage(rollbackState, failure));
}
private static List<String> rollbackCopies(Path packRoot,
List<Path> destinations,
List<Path> createdDirectories) {
for (int i = destinations.size() - 1; i >= 0; i--) {
try {
Files.deleteIfExists(destinations.get(i));
} catch (IOException | RuntimeException e) {
IrisLogging.reportError("Failed to roll back restored resource '" + relativePath(packRoot, destinations.get(i)) + "'", e);
}
}
for (int i = createdDirectories.size() - 1; i >= 0; i--) {
deleteIfEmpty(createdDirectories.get(i));
}
List<String> remaining = new ArrayList<>();
for (Path destination : destinations) {
if (Files.exists(destination, LinkOption.NOFOLLOW_LINKS)) {
remaining.add(relativePath(packRoot, destination));
}
}
remaining.sort(String::compareTo);
return List.copyOf(remaining);
}
private static void createDirectories(Path packRoot, Path target, List<Path> createdDirectories) throws IOException {
Path contained = requireContained(packRoot, target);
List<Path> missing = new ArrayList<>();
Path current = contained;
while (!current.equals(packRoot) && !Files.exists(current, LinkOption.NOFOLLOW_LINKS)) {
missing.add(current);
current = current.getParent();
}
if (!Files.isDirectory(current, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(current)) {
throw new IOException("Restore parent is not a safe directory: " + current);
}
for (int i = missing.size() - 1; i >= 0; i--) {
Path directory = missing.get(i);
Files.createDirectory(directory);
createdDirectories.add(directory);
}
}
private static boolean hasDestinationConflict(Path packRoot, Path destination) throws IOException {
if (Files.exists(destination, LinkOption.NOFOLLOW_LINKS)) {
return true;
}
Path parent = destination.getParent();
while (parent != null && !parent.equals(packRoot)) {
if (Files.exists(parent, LinkOption.NOFOLLOW_LINKS)
&& (!Files.isDirectory(parent, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(parent))) {
return true;
}
parent = parent.getParent();
}
return parent == null;
}
private static void deleteEmptyDirectories(Path root) {
if (root == null || !Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) {
return;
}
try (Stream<Path> stream = Files.walk(root)) {
List<Path> directories = stream.filter(path -> Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS))
.sorted(Comparator.reverseOrder())
.toList();
for (Path directory : directories) {
deleteIfEmpty(directory);
}
} catch (IOException | RuntimeException ignored) {
}
}
private static void deleteIfEmpty(Path directory) {
if (directory == null || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
return;
}
try (Stream<Path> stream = Files.list(directory)) {
if (stream.findAny().isEmpty()) {
Files.deleteIfExists(directory);
}
} catch (IOException | RuntimeException ignored) {
}
}
private static boolean isScannableJson(Path packRoot, Path path) {
if (!isJsonFile(path)) {
return false;
}
Path relative = packRoot.relativize(path);
for (Path segment : relative) {
if (CORPUS_EXCLUSIONS.contains(segment.toString())) {
return false;
}
}
return true;
}
private static boolean isJsonFile(Path path) {
return path.getFileName() != null && path.getFileName().toString().endsWith(".json")
&& Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS);
}
private static boolean isReferenced(String corpus, String key) {
if (corpus.contains("\"" + key + "\"")) {
return true;
}
int slash = key.indexOf('/');
if (slash <= 0) {
return false;
}
String tail = key.substring(slash + 1);
return !tail.isBlank() && corpus.contains("\"" + tail + "\"");
}
private static String stripJsonExtension(String path) {
return path.substring(0, path.length() - ".json".length());
}
private static Path requirePackRoot(File packFolder) throws IOException {
if (packFolder == null) {
throw new IOException("Pack folder is required.");
}
Path root = packFolder.toPath().toAbsolutePath().normalize();
requireDirectory(root, "Pack folder");
return root;
}
private static void requireDirectory(Path path, String label) throws IOException {
if (Files.isSymbolicLink(path) || !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException(label + " is not a safe directory: " + path);
}
}
private static void requireRegularFile(Path path, String label) throws IOException {
if (Files.isSymbolicLink(path) || !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException(label + " is not a safe regular file: " + path);
}
}
private static Path resolveContained(Path root, String relative) throws IOException {
return requireContained(root, root.resolve(relative).normalize());
}
private static Path requireContained(Path root, Path path) throws IOException {
Path normalizedRoot = root.toAbsolutePath().normalize();
Path normalizedPath = path.toAbsolutePath().normalize();
if (!normalizedPath.startsWith(normalizedRoot)) {
throw new IOException("Path escapes pack boundary: " + path);
}
return normalizedPath;
}
private static String relativePath(Path root, Path path) {
return root.relativize(path).toString().replace(File.separatorChar, '/');
}
private static Path lockPath(File packFolder) {
return packFolder == null ? null : packFolder.toPath().toAbsolutePath().normalize();
}
private static Object packLock(Path path) {
return PACK_LOCKS.computeIfAbsent(path, ignored -> new Object());
}
private static String errorMessage(String prefix, Throwable error) {
String message = error.getMessage();
return prefix + (message == null || message.isBlank() ? "." : ": " + message);
}
public record Preview(List<String> candidatePaths, String error) {
public Preview {
candidatePaths = candidatePaths == null ? List.of() : List.copyOf(candidatePaths);
}
public boolean success() {
return error == null;
}
public boolean hasCandidates() {
return !candidatePaths.isEmpty();
}
}
public record ApplyResult(String quarantinePath, List<String> quarantinedPaths, String error) {
public ApplyResult {
quarantinedPaths = quarantinedPaths == null ? List.of() : List.copyOf(quarantinedPaths);
}
public boolean success() {
return error == null;
}
public boolean changed() {
return !quarantinedPaths.isEmpty();
}
}
public record RestorePreview(String dumpPath, List<String> filePaths, List<String> conflicts, String error) {
public RestorePreview {
filePaths = filePaths == null ? List.of() : List.copyOf(filePaths);
conflicts = conflicts == null ? List.of() : List.copyOf(conflicts);
}
public boolean success() {
return error == null;
}
public boolean hasFiles() {
return !filePaths.isEmpty();
}
public boolean canRestore() {
return success() && hasFiles() && conflicts.isEmpty();
}
public boolean hasConflicts() {
return !conflicts.isEmpty();
}
}
public record RestoreResult(String dumpPath, List<String> restoredPaths, List<String> conflicts, String error) {
public RestoreResult {
restoredPaths = restoredPaths == null ? List.of() : List.copyOf(restoredPaths);
conflicts = conflicts == null ? List.of() : List.copyOf(conflicts);
}
public boolean success() {
return error == null && conflicts.isEmpty();
}
public boolean changed() {
return !restoredPaths.isEmpty();
}
public boolean hasConflicts() {
return !conflicts.isEmpty();
}
}
private record CleanupScan(List<String> paths) {
}
private record RestoreScan(Path dump, String dumpPath, List<Transfer> transfers, List<String> paths,
List<String> conflicts) {
private static RestoreScan empty() {
return new RestoreScan(null, null, List.of(), List.of(), List.of());
}
}
private record Transfer(String path, Path source, Path destination) {
}
}
@@ -26,18 +26,15 @@ public final class PackValidationResult {
private final String packName;
private final List<String> blockingErrors;
private final List<String> warnings;
private final List<String> removedUnusedFiles;
private final long validatedAtMillis;
public PackValidationResult(String packName,
List<String> blockingErrors,
List<String> warnings,
List<String> removedUnusedFiles,
long validatedAtMillis) {
this.packName = packName;
this.blockingErrors = blockingErrors == null ? new ArrayList<>() : new ArrayList<>(blockingErrors);
this.warnings = warnings == null ? new ArrayList<>() : new ArrayList<>(warnings);
this.removedUnusedFiles = removedUnusedFiles == null ? new ArrayList<>() : new ArrayList<>(removedUnusedFiles);
this.validatedAtMillis = validatedAtMillis;
}
@@ -57,10 +54,6 @@ public final class PackValidationResult {
return Collections.unmodifiableList(warnings);
}
public List<String> getRemovedUnusedFiles() {
return Collections.unmodifiableList(removedUnusedFiles);
}
public long getValidatedAtMillis() {
return validatedAtMillis;
}
@@ -18,8 +18,10 @@
package art.arcane.iris.core.pack;
import art.arcane.iris.engine.object.IrisBiomeCustomSpawnType;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.PlatformEntityType;
import art.arcane.iris.spi.PlatformRegistries;
import art.arcane.volmlib.util.json.JSONArray;
import art.arcane.volmlib.util.json.JSONObject;
@@ -28,17 +30,16 @@ import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.regex.Pattern;
import java.util.stream.Stream;
public final class PackValidator {
@@ -50,19 +51,9 @@ public final class PackValidator {
private static final String CACHE_FOLDER = "cache";
private static final String OBJECTS_FOLDER = "objects";
private static final String DIMENSIONS_FOLDER = "dimensions";
private static final List<String> MANAGED_RESOURCE_FOLDERS = List.of(
"biomes",
"regions",
"entities",
"spawners",
"loot",
"generators",
"expressions",
"markers",
"blocks",
"mods"
);
private static final DateTimeFormatter TRASH_STAMP = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss");
private static final List<String> STRUCTURE_HOST_FOLDERS = List.of(DIMENSIONS_FOLDER, "regions", "biomes");
private static final List<String> UNSUPPORTED_STRUCTURE_TRANSFORM_FIELDS = List.of("rotation", "translate", "scale");
private static final Pattern RESOURCE_KEY_PATTERN = Pattern.compile("[a-z0-9_.-]+:[a-z0-9/._-]+");
private PackValidator() {
}
@@ -71,39 +62,376 @@ public final class PackValidator {
String packName = packFolder == null ? "<unknown>" : packFolder.getName();
List<String> blockingErrors = new ArrayList<>();
List<String> warnings = new ArrayList<>();
List<String> removedUnusedFiles = new ArrayList<>();
long validatedAt = System.currentTimeMillis();
if (packFolder == null || !packFolder.isDirectory()) {
blockingErrors.add("Pack folder does not exist or is not a directory.");
return new PackValidationResult(packName, blockingErrors, warnings, removedUnusedFiles, validatedAt);
return new PackValidationResult(packName, blockingErrors, warnings, validatedAt);
}
File dimensionsFolder = new File(packFolder, DIMENSIONS_FOLDER);
if (!dimensionsFolder.isDirectory()) {
blockingErrors.add("Missing dimensions/ folder.");
return new PackValidationResult(packName, blockingErrors, warnings, removedUnusedFiles, validatedAt);
return new PackValidationResult(packName, blockingErrors, warnings, validatedAt);
}
File[] dimensionFiles = dimensionsFolder.listFiles(f -> f.isFile() && f.getName().endsWith(".json"));
if (dimensionFiles == null || dimensionFiles.length == 0) {
blockingErrors.add("No dimension JSON files under dimensions/.");
return new PackValidationResult(packName, blockingErrors, warnings, removedUnusedFiles, validatedAt);
return new PackValidationResult(packName, blockingErrors, warnings, validatedAt);
}
validateDimensions(packFolder, dimensionFiles, blockingErrors, warnings);
try {
String packTextCorpus = buildPackTextCorpus(packFolder);
runUnusedResourceGc(packFolder, packTextCorpus, removedUnusedFiles, warnings);
} catch (Throwable e) {
IrisLogging.reportError("PackValidator GC pass failed for pack '" + packName + "'", e);
warnings.add("Unused-resource GC pass failed: " + e.getMessage());
}
blockingErrors.addAll(validateUnsupportedStructureTransforms(packFolder));
blockingErrors.addAll(validateSpawnerEntityReferences(
new File(packFolder, "spawners"), new File(packFolder, "entities")));
blockingErrors.addAll(validateCustomBiomeSpawns(
new File(packFolder, "biomes"), PackValidator::resolveEntitySpawnCategory));
runContentKeyValidation(packFolder, warnings);
return new PackValidationResult(packName, blockingErrors, warnings, removedUnusedFiles, validatedAt);
return new PackValidationResult(packName, blockingErrors, warnings, validatedAt);
}
static List<String> validateUnsupportedStructureTransforms(File packFolder) {
List<String> blockingErrors = new ArrayList<>();
if (packFolder == null || !packFolder.isDirectory()) {
return blockingErrors;
}
for (String folderName : STRUCTURE_HOST_FOLDERS) {
File resourceFolder = new File(packFolder, folderName);
if (!resourceFolder.isDirectory()) {
continue;
}
List<File> resourceFiles = listJsonRecursive(resourceFolder);
resourceFiles.sort(Comparator.comparing(File::getPath));
String resourceType = structureHostType(folderName);
for (File resourceFile : resourceFiles) {
JSONObject resource;
try {
resource = new JSONObject(Files.readString(resourceFile.toPath(), StandardCharsets.UTF_8));
} catch (Throwable ignored) {
continue;
}
JSONArray placements = resource.optJSONArray("structures");
if (placements == null) {
continue;
}
String resourceKey = deriveKey(resourceFolder, resourceFile);
for (int placementIndex = 0; placementIndex < placements.length(); placementIndex++) {
JSONObject placement = placements.optJSONObject(placementIndex);
if (placement == null) {
continue;
}
for (String field : UNSUPPORTED_STRUCTURE_TRANSFORM_FIELDS) {
if (placement.has(field)) {
blockingErrors.add(resourceType + " '" + resourceKey + "' structures[" + placementIndex
+ "] declares unsupported field '" + field
+ "'. Structure placement transforms are not supported; remove the field.");
}
}
}
}
}
return blockingErrors;
}
private static String structureHostType(String folderName) {
return switch (folderName) {
case "dimensions" -> "Dimension";
case "regions" -> "Region";
case "biomes" -> "Biome";
default -> "Resource";
};
}
static List<String> validateSpawnerEntityReferences(File spawnersFolder, File entitiesFolder) {
List<String> blockingErrors = new ArrayList<>();
if (spawnersFolder == null || !spawnersFolder.isDirectory()) {
return blockingErrors;
}
Path entityRoot = entitiesFolder.toPath().toAbsolutePath().normalize();
Set<Path> validEntityFiles = new HashSet<>();
Map<Path, String> invalidEntityFiles = new HashMap<>();
List<File> spawnerFiles = listJsonRecursive(spawnersFolder);
spawnerFiles.sort(Comparator.comparing(File::getPath));
for (File spawnerFile : spawnerFiles) {
String spawnerKey = deriveKey(spawnersFolder, spawnerFile);
JSONObject spawner;
try {
spawner = new JSONObject(Files.readString(spawnerFile.toPath(), StandardCharsets.UTF_8));
} catch (Throwable e) {
blockingErrors.add("Spawner '" + spawnerKey + "' has invalid JSON: " + e.getMessage());
continue;
}
validateSpawnerSpawnEntries(spawnerKey, spawner, "spawns", entityRoot,
validEntityFiles, invalidEntityFiles, blockingErrors);
validateSpawnerSpawnEntries(spawnerKey, spawner, "initialSpawns", entityRoot,
validEntityFiles, invalidEntityFiles, blockingErrors);
}
return blockingErrors;
}
private static void validateSpawnerSpawnEntries(String spawnerKey,
JSONObject spawner,
String field,
Path entityRoot,
Set<Path> validEntityFiles,
Map<Path, String> invalidEntityFiles,
List<String> blockingErrors) {
if (!spawner.has(field)) {
return;
}
JSONArray entries = spawner.optJSONArray(field);
if (entries == null) {
blockingErrors.add("Spawner '" + spawnerKey + "' " + field + " must be an array.");
return;
}
for (int index = 0; index < entries.length(); index++) {
JSONObject entry = entries.optJSONObject(index);
if (entry == null) {
blockingErrors.add("Spawner '" + spawnerKey + "' " + field
+ " has a non-object entry at index " + index + ".");
continue;
}
if (!entry.has("entity") || entry.isNull("entity")) {
blockingErrors.add("Spawner '" + spawnerKey + "' " + field
+ " has an entry without an entity reference at index " + index + ".");
continue;
}
Object rawEntity = entry.get("entity");
if (!(rawEntity instanceof String entityKey)) {
blockingErrors.add("Spawner '" + spawnerKey + "' " + field
+ " entity reference at index " + index + " must be a string.");
continue;
}
if (entityKey.isBlank()) {
blockingErrors.add("Spawner '" + spawnerKey + "' " + field
+ " has a blank entity reference at index " + index + ".");
continue;
}
Path entityFile;
try {
if (entityKey.indexOf('\\') >= 0) {
throw new IllegalArgumentException("backslash path separators are not portable");
}
entityFile = entityRoot.resolve(entityKey + ".json").normalize();
} catch (RuntimeException e) {
blockingErrors.add("Spawner '" + spawnerKey + "' " + field + " entry at index " + index
+ " has invalid entity reference '" + entityKey + "': " + e.getMessage());
continue;
}
if (!entityFile.startsWith(entityRoot)) {
blockingErrors.add("Spawner '" + spawnerKey + "' " + field + " entry at index " + index
+ " has unsafe entity reference '" + entityKey + "'.");
continue;
}
if (!Files.isRegularFile(entityFile)) {
blockingErrors.add("Spawner '" + spawnerKey + "' " + field + " entry at index " + index
+ " references missing entity '" + entityKey + "'.");
continue;
}
String invalidJson = invalidEntityFiles.get(entityFile);
if (invalidJson != null) {
blockingErrors.add("Spawner '" + spawnerKey + "' " + field + " entry at index " + index
+ " references malformed entity '" + entityKey + "': " + invalidJson);
continue;
}
if (validEntityFiles.contains(entityFile)) {
continue;
}
try {
new JSONObject(Files.readString(entityFile, StandardCharsets.UTF_8));
validEntityFiles.add(entityFile);
} catch (Throwable e) {
String message = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
invalidEntityFiles.put(entityFile, message);
blockingErrors.add("Spawner '" + spawnerKey + "' " + field + " entry at index " + index
+ " references malformed entity '" + entityKey + "': " + message);
}
}
}
static List<String> validateCustomBiomeSpawns(File biomesFolder, Function<String, SpawnCategoryResolution> categoryResolver) {
List<String> blockingErrors = new ArrayList<>();
if (biomesFolder == null || !biomesFolder.isDirectory()) {
return blockingErrors;
}
List<File> biomeFiles = listJsonRecursive(biomesFolder);
biomeFiles.sort(Comparator.comparing(File::getPath));
for (File biomeFile : biomeFiles) {
String biomeKey = deriveKey(biomesFolder, biomeFile);
JSONObject biome;
try {
biome = new JSONObject(Files.readString(biomeFile.toPath(), StandardCharsets.UTF_8));
} catch (Throwable e) {
blockingErrors.add("Biome '" + biomeKey + "' has invalid JSON: " + e.getMessage());
continue;
}
JSONArray derivatives = biome.optJSONArray("customDerivitives");
if (derivatives == null) {
if (biome.has("customDerivitives") && !biome.isNull("customDerivitives")) {
blockingErrors.add("Biome '" + biomeKey + "' customDerivitives must be an array.");
}
continue;
}
for (int derivativeIndex = 0; derivativeIndex < derivatives.length(); derivativeIndex++) {
JSONObject derivative = derivatives.optJSONObject(derivativeIndex);
if (derivative == null) {
blockingErrors.add("Biome '" + biomeKey + "' has a non-object custom derivative at index " + derivativeIndex + ".");
continue;
}
validateCustomBiomeDerivativeTags(biomeKey, derivative, derivativeIndex, blockingErrors);
validateCustomBiomeDerivativeSpawns(
biomeKey, derivative, derivativeIndex, categoryResolver, blockingErrors);
}
}
return blockingErrors;
}
private static void validateCustomBiomeDerivativeTags(String biomeKey,
JSONObject derivative,
int derivativeIndex,
List<String> blockingErrors) {
if (!derivative.has("tags") || derivative.isNull("tags")) {
return;
}
String derivativeId = derivative.optString("id", "#" + derivativeIndex);
JSONArray tags = derivative.optJSONArray("tags");
if (tags == null) {
blockingErrors.add("Biome '" + biomeKey + "' custom derivative '" + derivativeId
+ "' tags must be an array.");
return;
}
for (int tagIndex = 0; tagIndex < tags.length(); tagIndex++) {
Object rawTag = tags.opt(tagIndex);
if (!(rawTag instanceof String tag)) {
blockingErrors.add("Biome '" + biomeKey + "' custom derivative '" + derivativeId
+ "' has a non-string tag at index " + tagIndex + ".");
continue;
}
String normalized = tag.trim().toLowerCase(Locale.ROOT);
if (normalized.indexOf(':') < 0) {
normalized = "minecraft:" + normalized;
}
if (!isSafeResourceKey(normalized)) {
blockingErrors.add("Biome '" + biomeKey + "' custom derivative '" + derivativeId
+ "' has invalid tag '" + tag + "'.");
}
}
}
private static boolean isSafeResourceKey(String key) {
if (!RESOURCE_KEY_PATTERN.matcher(key).matches()) {
return false;
}
int separator = key.indexOf(':');
String[] segments = key.substring(separator + 1).split("/");
for (String segment : segments) {
if (segment.equals("..")) {
return false;
}
}
return true;
}
private static void validateCustomBiomeDerivativeSpawns(String biomeKey,
JSONObject derivative,
int derivativeIndex,
Function<String, SpawnCategoryResolution> categoryResolver,
List<String> blockingErrors) {
JSONArray spawns = derivative.optJSONArray("spawns");
if (spawns == null) {
if (derivative.has("spawns") && !derivative.isNull("spawns")) {
String derivativeId = derivative.optString("id", "#" + derivativeIndex);
blockingErrors.add("Biome '" + biomeKey + "' custom derivative '" + derivativeId
+ "' spawns must be an array.");
}
return;
}
String derivativeId = derivative.optString("id", "#" + derivativeIndex);
for (int spawnIndex = 0; spawnIndex < spawns.length(); spawnIndex++) {
JSONObject spawn = spawns.optJSONObject(spawnIndex);
if (spawn == null) {
blockingErrors.add("Biome '" + biomeKey + "' custom derivative '" + derivativeId
+ "' has a non-object spawn at index " + spawnIndex + ".");
continue;
}
String type = spawn.optString("type", "").trim().toLowerCase(Locale.ROOT);
if (type.isEmpty()) {
blockingErrors.add("Biome '" + biomeKey + "' custom derivative '" + derivativeId
+ "' has a spawn without an entity type at index " + spawnIndex + ".");
continue;
}
String typeKey = type.indexOf(':') >= 0 ? type : "minecraft:" + type;
SpawnCategoryResolution resolution;
try {
resolution = categoryResolver == null ? null : categoryResolver.apply(typeKey);
} catch (Throwable e) {
IrisLogging.reportError("PackValidator failed to resolve spawn category for '" + typeKey + "'", e);
blockingErrors.add("Biome '" + biomeKey + "' custom derivative '" + derivativeId
+ "' spawn category lookup failed for '" + typeKey + "': " + e.getMessage());
continue;
}
if (resolution != null && !resolution.entityKnown()) {
blockingErrors.add("Biome '" + biomeKey + "' custom derivative '" + derivativeId
+ "' spawn references unknown entity type '" + typeKey + "'.");
continue;
}
String expectedGroup = resolution == null ? null : resolution.category();
String group = spawn.optString("group", "").trim();
if (group.isEmpty()) {
if (expectedGroup != null && !expectedGroup.isBlank()
&& !IrisBiomeCustomSpawnType.MISC.name().equalsIgnoreCase(expectedGroup)) {
blockingErrors.add("Biome '" + biomeKey + "' custom derivative '" + derivativeId
+ "' spawn '" + typeKey + "' must declare group '"
+ expectedGroup.toUpperCase(Locale.ROOT) + "'.");
}
continue;
}
IrisBiomeCustomSpawnType configuredGroup;
try {
configuredGroup = IrisBiomeCustomSpawnType.valueOf(group);
} catch (IllegalArgumentException e) {
blockingErrors.add("Biome '" + biomeKey + "' custom derivative '" + derivativeId
+ "' spawn '" + typeKey + "' declares unknown group '" + group + "'.");
continue;
}
if (expectedGroup != null && !expectedGroup.isBlank()
&& !configuredGroup.name().equalsIgnoreCase(expectedGroup)) {
blockingErrors.add("Biome '" + biomeKey + "' custom derivative '" + derivativeId
+ "' spawn '" + typeKey + "' declares group '" + configuredGroup.name()
+ "' but the live entity registry requires '" + expectedGroup.toUpperCase(Locale.ROOT) + "'.");
}
}
}
private static SpawnCategoryResolution resolveEntitySpawnCategory(String typeKey) {
if (!IrisPlatforms.isBound()) {
return null;
}
PlatformRegistries registries = IrisPlatforms.get().registries();
if (registries == null) {
return null;
}
PlatformEntityType entityType = registries.entity(typeKey);
return entityType == null
? SpawnCategoryResolution.unknown()
: SpawnCategoryResolution.known(entityType.spawnCategory());
}
private static void runContentKeyValidation(File packFolder, List<String> warnings) {
@@ -221,6 +549,16 @@ public final class PackValidator {
private record ReferencedContentKeys(Set<String> blocks, Set<String> items, Set<String> entities) {
}
record SpawnCategoryResolution(boolean entityKnown, String category) {
static SpawnCategoryResolution unknown() {
return new SpawnCategoryResolution(false, null);
}
static SpawnCategoryResolution known(String category) {
return new SpawnCategoryResolution(true, category);
}
}
private static void validateDimensions(File packFolder, File[] dimensionFiles, List<String> blockingErrors, List<String> warnings) {
File regionsFolder = new File(packFolder, "regions");
File biomesFolder = new File(packFolder, "biomes");
@@ -299,24 +637,6 @@ public final class PackValidator {
return resolved;
}
private static String buildPackTextCorpus(File packFolder) {
StringBuilder sb = new StringBuilder(1 << 16);
try (Stream<Path> stream = Files.walk(packFolder.toPath())) {
stream.filter(Files::isRegularFile)
.filter(PackValidator::isScannableJsonPath)
.forEach(p -> {
try {
sb.append(Files.readString(p, StandardCharsets.UTF_8));
sb.append('\n');
} catch (Throwable ignored) {
}
});
} catch (Throwable e) {
IrisLogging.reportError("PackValidator failed to walk pack folder for corpus scan", e);
}
return sb.toString();
}
private static boolean isScannableJsonPath(Path path) {
String name = path.getFileName().toString();
if (!name.endsWith(".json")) {
@@ -350,66 +670,6 @@ public final class PackValidator {
return true;
}
private static void runUnusedResourceGc(File packFolder, String corpus, List<String> removedUnusedFiles, List<String> warnings) {
if (corpus == null || corpus.isEmpty()) {
return;
}
File trashRoot = new File(packFolder, TRASH_ROOT + File.separator + LocalDateTime.now().format(TRASH_STAMP));
Set<File> scheduledForTrash = new LinkedHashSet<>();
for (String folderName : MANAGED_RESOURCE_FOLDERS) {
File resourceFolder = new File(packFolder, folderName);
if (!resourceFolder.isDirectory()) {
continue;
}
List<File> files = listJsonRecursive(resourceFolder);
for (File resourceFile : files) {
String key = deriveKey(resourceFolder, resourceFile);
if (key == null || key.isBlank()) {
continue;
}
if (isReferenced(corpus, key)) {
continue;
}
scheduledForTrash.add(resourceFile);
}
}
if (scheduledForTrash.isEmpty()) {
return;
}
for (File file : scheduledForTrash) {
try {
Path src = file.toPath();
Path relative = packFolder.toPath().relativize(src);
Path dest = trashRoot.toPath().resolve(relative);
Files.createDirectories(dest.getParent());
Files.move(src, dest, StandardCopyOption.REPLACE_EXISTING);
removedUnusedFiles.add(relative.toString().replace(File.separatorChar, '/'));
} catch (Throwable e) {
IrisLogging.reportError("PackValidator failed to move unused file " + file.getPath() + " to trash", e);
warnings.add("Failed to quarantine unused file " + file.getName() + ": " + e.getMessage());
}
}
}
private static boolean isReferenced(String corpus, String key) {
String needleQuoted = "\"" + key + "\"";
if (corpus.contains(needleQuoted)) {
return true;
}
int slash = key.indexOf('/');
if (slash > 0) {
String tail = key.substring(slash + 1);
if (!tail.isBlank() && corpus.contains("\"" + tail + "\"")) {
return true;
}
}
return false;
}
private static List<File> listJsonRecursive(File root) {
List<File> out = new ArrayList<>();
try (Stream<Path> stream = Files.walk(root.toPath())) {
@@ -435,49 +695,6 @@ public final class PackValidator {
return dot <= 0 ? name : name.substring(0, dot);
}
public static int restoreTrash(File packFolder) {
if (packFolder == null || !packFolder.isDirectory()) {
return 0;
}
File trashRoot = new File(packFolder, TRASH_ROOT);
if (!trashRoot.isDirectory()) {
return 0;
}
File[] dumps = trashRoot.listFiles(File::isDirectory);
if (dumps == null || dumps.length == 0) {
return 0;
}
Arrays.sort(dumps, Comparator.comparing(File::getName));
File latestDump = dumps[dumps.length - 1];
int restored = 0;
try (Stream<Path> stream = Files.walk(latestDump.toPath())) {
List<Path> files = stream.filter(Files::isRegularFile).toList();
for (Path src : files) {
Path relative = latestDump.toPath().relativize(src);
Path dest = packFolder.toPath().resolve(relative);
Files.createDirectories(dest.getParent());
Files.move(src, dest, StandardCopyOption.REPLACE_EXISTING);
restored++;
}
} catch (Throwable e) {
IrisLogging.reportError("PackValidator failed to restore trash for pack " + packFolder.getName(), e);
}
deleteFolderQuiet(latestDump);
return restored;
}
private static void deleteFolderQuiet(File folder) {
if (folder == null || !folder.exists()) {
return;
}
try (Stream<Path> stream = Files.walk(folder.toPath())) {
stream.sorted(Comparator.reverseOrder())
.map(Path::toFile)
.forEach(File::delete);
} catch (Throwable ignored) {
}
}
public static Set<String> listReferencedKeysFromCorpus(String corpus) {
Set<String> keys = new HashSet<>();
if (corpus == null) {
@@ -26,7 +26,6 @@ import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.tools.IrisPackBenchmarking;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.collection.KSet;
import art.arcane.iris.util.common.format.C;
import art.arcane.volmlib.util.format.Form;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
import art.arcane.volmlib.util.math.M;
@@ -217,16 +216,21 @@ public class IrisPregenerator {
} finally {
shutdown();
}
if (completed) {
if (completed && allVisitsComplete()) {
logSuccessfulCompletion(p);
}
if (benchmarking == null) {
IrisLogging.info(C.IRIS + "Pregen stopped.");
} else {
logIncompleteCompletion(p);
}
if (benchmarking != null) {
benchmarking.finishedBenchmark(chunksPerSecondHistory);
}
}
private boolean allVisitsComplete() {
long total = totalChunks.get();
return total > 0 && generated.get() + failed.get() >= total;
}
private void logSuccessfulCompletion(PrecisionStopwatch stopwatch) {
long failedCount = failed.get();
if (failedCount > 0) {
@@ -238,6 +242,19 @@ public class IrisPregenerator {
+ " duration=" + Form.duration((long) stopwatch.getMilliseconds()));
}
private void logIncompleteCompletion(PrecisionStopwatch stopwatch) {
long generatedCount = generated.get();
long failedCount = failed.get();
long total = totalChunks.get();
long remaining = Math.max(0L, total - generatedCount - failedCount);
String status = shutdown.get() ? "Pregen cancelled" : "Pregen stopped before completion";
IrisLogging.info(status + ": generated=" + Form.f(generatedCount)
+ " total=" + Form.f(total)
+ " failed=" + Form.f(failedCount)
+ " remaining=" + Form.f(remaining)
+ " duration=" + Form.duration((long) stopwatch.getMilliseconds()));
}
private void checkRegions() {
task.iterateRegions(this::checkRegion);
}
@@ -646,7 +646,7 @@ public class IrisProject {
biomes.forEach((i) -> i.getGenerators().forEach((j) -> generators.add(j.getCachedGenerator(() -> dm))));
biomes.forEach((r) -> r.getLoot().getTables().forEach((i) -> loot.add(dm.getLootLoader().load(i))));
biomes.forEach((r) -> r.getEntitySpawners().forEach((sp) -> spawners.add(dm.getSpawnerLoader().load(sp))));
spawners.forEach((i) -> i.getSpawns().forEach((j) -> entities.add(dm.getEntityLoader().load(j.getEntity()))));
collectSpawnerEntityKeys(spawners).forEach((i) -> entities.add(dm.getEntityLoader().load(i)));
KMap<String, String> renameObjects = new KMap<>();
String a;
StringBuilder b = new StringBuilder();
@@ -769,6 +769,15 @@ public class IrisProject {
return null;
}
static KSet<String> collectSpawnerEntityKeys(KSet<IrisSpawner> spawners) {
KSet<String> entityKeys = new KSet<>();
for (IrisSpawner spawner : spawners) {
spawner.getSpawns().forEach((spawn) -> entityKeys.add(spawn.getEntity()));
spawner.getInitialSpawns().forEach((spawn) -> entityKeys.add(spawn.getEntity()));
}
return entityKeys;
}
public void compile(VolmitSender sender) {
IrisData data = IrisData.get(getPath());
KList<Job> jobs = new KList<>();
@@ -130,8 +130,17 @@ public class StudioSVC implements IrisService {
queueStudioWorldDeletionOnStartup(worldNamesToDelete);
}
public IrisDimension installIntoWorld(VolmitSender sender, String type, File folder) {
return installInto(sender, type, new File(folder, "iris/pack"));
public IrisDimension installIntoWorld(VolmitSender sender, IrisDimension dimension, File folder) {
File target = new File(folder, "iris/pack");
File source = dimension.getLoader().getDataFolder();
sender.sendMessage("Installing Package: " + source.getName() + ":" + dimension.getLoadKey());
try {
FileUtils.copyDirectory(source, target);
} catch (IOException e) {
IrisLogging.reportError(e);
return null;
}
return IrisData.get(target).getDimensionLoader().load(dimension.getLoadKey());
}
public IrisDimension installInto(VolmitSender sender, String type, File folder) {
@@ -166,7 +166,11 @@ public class IrisCreator {
reportStudioProgress(0.16D, "prepare_world_pack");
if (!studio() || benchmark) {
IrisServices.get(StudioSVC.class).installIntoWorld(sender, d.getLoadKey(), new File(Bukkit.getWorldContainer(), name()));
d = IrisServices.get(StudioSVC.class).installIntoWorld(sender, d, new File(Bukkit.getWorldContainer(), name()));
if (d == null) {
throw new IrisException("Failed to install dimension pack for " + dimension());
}
dimension = d.getLoadKey();
}
if (studio()) {
IrisRuntimeSchedulerMode runtimeSchedulerMode = IrisRuntimeSchedulerMode.resolve(IrisSettings.get().getPregen());
@@ -178,7 +182,7 @@ public class IrisCreator {
AtomicDouble pp = new AtomicDouble(0);
AtomicBoolean done = new AtomicBoolean(false);
WorldCreator wc = new IrisWorldCreator()
.dimension(dimension)
.dimension(d)
.name(name)
.seed(seed)
.studio(studio)
@@ -81,23 +81,23 @@ public class IrisToolbelt {
return null;
}
String requested = dimension.trim();
if (requested.isEmpty()) {
PackReference reference = parsePackReference(dimension);
if (reference == null) {
return null;
}
File packsFolder = IrisPlatforms.get().dataFolder("packs");
File pack = new File(packsFolder, requested);
File pack = new File(packsFolder, reference.pack());
if (!pack.exists()) {
File found = findCaseInsensitivePack(packsFolder, requested);
File found = findCaseInsensitivePack(packsFolder, reference.pack());
if (found != null) {
pack = found;
}
}
if (!pack.exists()) {
IrisServices.get(StudioSVC.class).downloadSearch(new VolmitSender(Bukkit.getConsoleSender(), BukkitPlatform.volmitPlugin().getTag()), requested, false);
File found = findCaseInsensitivePack(packsFolder, requested);
IrisServices.get(StudioSVC.class).downloadSearch(new VolmitSender(Bukkit.getConsoleSender(), BukkitPlatform.volmitPlugin().getTag()), reference.pack(), false);
File found = findCaseInsensitivePack(packsFolder, reference.pack());
if (found != null) {
pack = found;
}
@@ -108,13 +108,16 @@ public class IrisToolbelt {
}
IrisData data = IrisData.get(pack);
IrisDimension resolved = data.getDimensionLoader().load(requested, false);
IrisDimension resolved = data.getDimensionLoader().load(reference.dimension(), false);
if (resolved != null) {
return resolved;
}
if (reference.explicitDimension()) {
return null;
}
String packName = pack.getName();
if (!packName.equals(requested)) {
if (!packName.equals(reference.pack())) {
resolved = data.getDimensionLoader().load(packName, false);
if (resolved != null) {
return resolved;
@@ -122,7 +125,7 @@ public class IrisToolbelt {
}
for (String key : data.getDimensionLoader().getPossibleKeys()) {
if (!key.equalsIgnoreCase(requested) && !key.equalsIgnoreCase(packName)) {
if (!key.equalsIgnoreCase(reference.pack()) && !key.equalsIgnoreCase(packName)) {
continue;
}
@@ -135,6 +138,26 @@ public class IrisToolbelt {
return null;
}
static PackReference parsePackReference(String value) {
if (value == null) {
return null;
}
String requested = value.trim();
if (requested.isEmpty()) {
return null;
}
int separator = requested.indexOf(':');
if (separator < 0) {
return new PackReference(requested, requested, false);
}
String pack = requested.substring(0, separator).trim();
String dimension = requested.substring(separator + 1).trim();
if (pack.isEmpty() || dimension.isEmpty()) {
return null;
}
return new PackReference(pack, dimension, true);
}
private static File findCaseInsensitivePack(File packsFolder, String requested) {
File[] children = packsFolder.listFiles();
if (children == null) {
@@ -503,4 +526,7 @@ public class IrisToolbelt {
public static boolean removeWorld(World world) throws IOException {
return IrisCreator.removeFromBukkitYml(world.getName());
}
record PackReference(String pack, String dimension, boolean explicitDimension) {
}
}
@@ -33,6 +33,7 @@ public class IrisWorldCreator {
private String name;
private boolean studio = false;
private String dimensionName = null;
private IrisDimension dimension;
private long seed = 1337;
public IrisWorldCreator() {
@@ -41,6 +42,13 @@ public class IrisWorldCreator {
public IrisWorldCreator dimension(String loadKey) {
this.dimensionName = loadKey;
this.dimension = null;
return this;
}
public IrisWorldCreator dimension(IrisDimension dimension) {
this.dimension = dimension;
this.dimensionName = dimension.getLoadKey();
return this;
}
@@ -65,7 +73,7 @@ public class IrisWorldCreator {
}
public WorldCreator create() {
IrisDimension dim = IrisData.loadAnyDimension(dimensionName, null);
IrisDimension dim = dimension == null ? IrisData.loadAnyDimension(dimensionName, null) : dimension;
IrisWorld w = IrisWorld.builder()
.name(name)
@@ -87,7 +95,7 @@ public class IrisWorldCreator {
}
private World.Environment findEnvironment() {
IrisDimension dim = IrisData.loadAnyDimension(dimensionName, null);
IrisDimension dim = dimension == null ? IrisData.loadAnyDimension(dimensionName, null) : dimension;
if (dim == null || dim.getEnvironment() == null) {
return World.Environment.NORMAL;
} else {
@@ -42,8 +42,10 @@ import art.arcane.iris.util.project.interpolation.IrisInterpolation.NoiseBounds;
import art.arcane.iris.util.project.noise.CNG;
import art.arcane.iris.util.project.stream.ProceduralStream;
import art.arcane.iris.util.project.stream.interpolation.Interpolated;
import lombok.AccessLevel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.ToString;
import java.io.File;
@@ -56,13 +58,14 @@ import java.util.Set;
import java.util.UUID;
@Data
@EqualsAndHashCode(exclude = "data")
@ToString(exclude = "data")
@EqualsAndHashCode(exclude = {"data", "gridBoundsCache"})
@ToString(exclude = {"data", "gridBoundsCache"})
public class IrisComplex implements DataProvider {
private static final NoiseBounds ZERO_NOISE_BOUNDS = new NoiseBounds(0D, 0D);
private static final int GRID_BOUNDS_CACHE_SIZE = 8192;
private static final int HEIGHT_BOUNDS_GRID = 4;
private static final ThreadLocal<GridBoundsCache> GRID_BOUNDS_CACHE = ThreadLocal.withInitial(GridBoundsCache::new);
@Getter(AccessLevel.NONE)
private final transient ThreadLocal<GridBoundsCache> gridBoundsCache = ThreadLocal.withInitial(GridBoundsCache::new);
private RNG rng;
private double fluidHeight;
private IrisData data;
@@ -362,7 +365,7 @@ public class IrisComplex implements DataProvider {
double fx = (x - gx) / grid;
double fz = (z - gz) / grid;
GridBoundsCache cache = GRID_BOUNDS_CACHE.get();
GridBoundsCache cache = gridBoundsCache.get();
long b00 = cornerBounds(cache, engine, interpolator, interpolatorIndex, generators, gx, gz);
long b10 = cornerBounds(cache, engine, interpolator, interpolatorIndex, generators, gx + grid, gz);
long b01 = cornerBounds(cache, engine, interpolator, interpolatorIndex, generators, gx, gz + grid);
@@ -67,6 +67,8 @@ import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.event.player.PlayerTeleportEvent;
import org.bukkit.inventory.ItemStack;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -78,6 +80,8 @@ import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.BiConsumer;
import java.util.function.Predicate;
import java.util.stream.Collectors;
@@ -86,6 +90,8 @@ import java.util.stream.Stream;
@EqualsAndHashCode(callSuper = true)
@Data
public class IrisWorldManager extends EngineAssignedWorldManager {
private static final int MAX_FORCED_CHUNK_UPDATES = 128;
private final Looper looper;
private final int id;
private final KList<Runnable> updateQueue = new KList<>();
@@ -100,11 +106,16 @@ public class IrisWorldManager extends EngineAssignedWorldManager {
private final Set<Long> markerFlagQueue = ConcurrentHashMap.newKeySet();
private final Set<Long> discoveredFlagQueue = ConcurrentHashMap.newKeySet();
private final Set<Long> markerScanQueue = ConcurrentHashMap.newKeySet();
private final Set<Long> chunkUpdateQueue = ConcurrentHashMap.newKeySet();
private final AtomicBoolean chunkUpdateScanScheduled = new AtomicBoolean();
private final AtomicBoolean chunkDiscoveryScanScheduled = new AtomicBoolean();
private int entityCount = 0;
private int actuallySpawned = 0;
private int cooldown = 0;
private List<Entity> precount = new KList<>();
private int forcedChunkUpdateCursor = 0;
private volatile boolean playersPresent = false;
private KSet<Position2> injectBiomes = new KSet<>();
private volatile int loadedChunkCount = 0;
public IrisWorldManager() {
super(null);
@@ -126,7 +137,7 @@ public class IrisWorldManager extends EngineAssignedWorldManager {
cl = new ChronoLatch(3000);
clw = new ChronoLatch(1000, true);
cleanupService = Executors.newSingleThreadScheduledExecutor(runnable -> {
var thread = new Thread(runnable, "Iris Mantle Cleanup " + getTarget().getWorld().name());
Thread thread = new Thread(runnable, "Iris Mantle Cleanup " + getTarget().getWorld().name());
thread.setPriority(Thread.MIN_PRIORITY);
return thread;
});
@@ -139,18 +150,18 @@ public class IrisWorldManager extends EngineAssignedWorldManager {
}
if (!getEngine().getWorld().hasRealWorld() && clw.flip()) {
getEngine().getWorld().tryGetRealWorld();
J.runGlobal(() -> getEngine().getWorld().tryGetRealWorld());
}
if (getEngine().getWorld().hasRealWorld()) {
if (getEngine().getWorld().getPlayers().isEmpty()) {
return 5000;
}
if (chunkUpdater.flip()) {
updateChunks();
}
if (!playersPresent) {
return 5000;
}
if (chunkDiscovery.flip()) {
discoverChunks();
}
@@ -163,19 +174,6 @@ public class IrisWorldManager extends EngineAssignedWorldManager {
return 3000;
}
if (precount != null) {
entityCount = 0;
for (Entity i : precount) {
if (i instanceof LivingEntity) {
if (!i.isDead()) {
entityCount++;
}
}
}
precount = null;
}
onAsyncTick();
}
@@ -202,23 +200,46 @@ public class IrisWorldManager extends EngineAssignedWorldManager {
return;
}
for (Player player : getEngine().getWorld().getPlayers()) {
if (player == null || !player.isOnline()) {
continue;
}
if (!chunkDiscoveryScanScheduled.compareAndSet(false, true)) {
return;
}
J.runEntity(player, () -> {
int centerX = PowerOfTwoCoordinates.blockToChunkFloor(player.getLocation().getBlockX());
int centerZ = PowerOfTwoCoordinates.blockToChunkFloor(player.getLocation().getBlockZ());
int radius = 1;
for (int x = -radius; x <= radius; x++) {
for (int z = -radius; z <= radius; z++) {
int chunkX = centerX + x;
int chunkZ = centerZ + z;
raiseDiscoveredChunkFlag(world, chunkX, chunkZ);
}
boolean scheduled = J.runGlobal(() -> {
try {
if (getEngine().isClosed() || !world.equals(getEngine().getWorld().realWorld())) {
return;
}
});
for (Player player : world.getPlayers()) {
if (player == null) {
continue;
}
J.runEntity(player, () -> {
if (!player.isOnline() || !world.equals(player.getWorld())) {
return;
}
int centerX = PowerOfTwoCoordinates.blockToChunkFloor(player.getLocation().getBlockX());
int centerZ = PowerOfTwoCoordinates.blockToChunkFloor(player.getLocation().getBlockZ());
int radius = 1;
for (int x = -radius; x <= radius; x++) {
for (int z = -radius; z <= radius; z++) {
int chunkX = centerX + x;
int chunkZ = centerZ + z;
raiseDiscoveredChunkFlag(world, chunkX, chunkZ);
}
}
});
}
} catch (Throwable e) {
IrisLogging.reportError(e);
} finally {
chunkDiscoveryScanScheduled.set(false);
}
});
if (!scheduled) {
chunkDiscoveryScanScheduled.set(false);
}
}
@@ -261,24 +282,98 @@ public class IrisWorldManager extends EngineAssignedWorldManager {
return;
}
for (Player player : getEngine().getWorld().getPlayers()) {
if (player == null || !player.isOnline()) {
continue;
if (!chunkUpdateScanScheduled.compareAndSet(false, true)) {
return;
}
boolean scheduled = J.runGlobal(() -> updateChunksOnGlobal(world));
if (!scheduled) {
chunkUpdateScanScheduled.set(false);
}
}
private void updateChunksOnGlobal(World world) {
try {
if (getEngine().isClosed() || !world.equals(getEngine().getWorld().realWorld())) {
return;
}
J.runEntity(player, () -> {
int centerX = PowerOfTwoCoordinates.blockToChunkFloor(player.getLocation().getBlockX());
int centerZ = PowerOfTwoCoordinates.blockToChunkFloor(player.getLocation().getBlockZ());
int radius = 1;
List<Player> players = new ArrayList<>(world.getPlayers());
playersPresent = !players.isEmpty();
loadedChunkCount = world.getLoadedChunks().length;
for (Player player : players) {
if (player == null) {
continue;
}
for (int x = -radius; x <= radius; x++) {
for (int z = -radius; z <= radius; z++) {
int targetX = centerX + x;
int targetZ = centerZ + z;
J.runRegion(world, targetX, targetZ, () -> updateChunkRegion(world, targetX, targetZ));
}
J.runEntity(player, () -> schedulePlayerChunkUpdates(world, player));
}
scheduleForcedChunkUpdates(world);
} catch (Throwable e) {
IrisLogging.reportError(e);
} finally {
chunkUpdateScanScheduled.set(false);
}
}
private void schedulePlayerChunkUpdates(World world, Player player) {
if (!player.isOnline() || !world.equals(player.getWorld())) {
return;
}
int centerX = PowerOfTwoCoordinates.blockToChunkFloor(player.getLocation().getBlockX());
int centerZ = PowerOfTwoCoordinates.blockToChunkFloor(player.getLocation().getBlockZ());
int radius = 1;
for (int x = -radius; x <= radius; x++) {
for (int z = -radius; z <= radius; z++) {
scheduleChunkUpdate(world, centerX + x, centerZ + z);
}
}
}
private void scheduleForcedChunkUpdates(World world) {
List<Position2> forcedChunks = new ArrayList<>();
for (Chunk chunk : world.getForceLoadedChunks()) {
forcedChunks.add(new Position2(chunk.getX(), chunk.getZ()));
}
forcedChunks.sort(Comparator.comparingInt(Position2::getX).thenComparingInt(Position2::getZ));
int forcedChunkCount = forcedChunks.size();
if (forcedChunkCount == 0) {
forcedChunkUpdateCursor = 0;
return;
}
int updateCount = Math.min(forcedChunkCount, MAX_FORCED_CHUNK_UPDATES);
int start = Math.floorMod(forcedChunkUpdateCursor, forcedChunkCount);
for (int i = 0; i < updateCount; i++) {
Position2 chunk = forcedChunks.get((start + i) % forcedChunkCount);
scheduleChunkUpdate(world, chunk.getX(), chunk.getZ());
}
forcedChunkUpdateCursor = (start + updateCount) % forcedChunkCount;
}
private void scheduleChunkUpdate(World world, int chunkX, int chunkZ) {
long key = Cache.key(chunkX, chunkZ);
if (!chunkUpdateQueue.add(key)) {
return;
}
try {
boolean scheduled = J.runRegion(world, chunkX, chunkZ, () -> {
try {
updateChunkRegion(world, chunkX, chunkZ);
} finally {
chunkUpdateQueue.remove(key);
}
});
if (!scheduled) {
chunkUpdateQueue.remove(key);
}
} catch (Throwable e) {
chunkUpdateQueue.remove(key);
IrisLogging.reportError(e);
}
}
@@ -407,6 +502,36 @@ public class IrisWorldManager extends EngineAssignedWorldManager {
return false;
}
if (cl.flip()) {
try {
World realWorld = getEngine().getWorld().realWorld();
if (realWorld == null) {
entityCount = 0;
} else if (J.isFolia()) {
entityCount = getFoliaLivingEntityCount(realWorld);
} else {
CompletableFuture<Integer> future = new CompletableFuture<>();
boolean scheduled = J.runGlobal(() -> {
try {
int count = 0;
for (Entity entity : realWorld.getEntities()) {
if (entity instanceof LivingEntity && !entity.isDead()) {
count++;
}
}
future.complete(count);
} catch (Throwable ex) {
future.completeExceptionally(ex);
}
});
entityCount = scheduled ? future.get(2, TimeUnit.SECONDS) : 0;
}
} catch (Throwable e) {
IrisLogging.reportError(e);
close();
}
}
double epx = getEntitySaturation();
if (epx > IrisSettings.get().getWorld().getTargetSpawnEntitiesPerChunk()) {
IrisLogging.debug("Can't spawn. The entity per chunk ratio is at " + Form.pc(epx, 2) + " > 100% (total entities " + entityCount + ")");
@@ -414,43 +539,20 @@ public class IrisWorldManager extends EngineAssignedWorldManager {
return false;
}
if (cl.flip()) {
try {
World realWorld = getEngine().getWorld().realWorld();
if (realWorld == null) {
precount = new KList<>();
} else if (J.isFolia()) {
precount = getFoliaEntitySnapshot(realWorld);
} else {
CompletableFuture<List<Entity>> future = new CompletableFuture<>();
J.s(() -> {
try {
future.complete(realWorld.getEntities());
} catch (Throwable ex) {
future.completeExceptionally(ex);
}
});
precount = future.get(2, TimeUnit.SECONDS);
}
} catch (Throwable e) {
close();
}
}
int spawnBuffer = RNG.r.i(2, 12);
World world = getEngine().getWorld().realWorld();
if (world == null) {
return false;
}
Chunk[] cc = getLoadedChunksSnapshot(world);
Position2[] cc = getLoadedChunkPositionsSnapshot(world);
while (spawnBuffer-- > 0) {
if (cc.length == 0) {
IrisLogging.debug("Can't spawn. No chunks!");
return false;
}
Chunk c = cc[RNG.r.nextInt(cc.length)];
Position2 c = cc[RNG.r.nextInt(cc.length)];
spawnChunkSafely(world, c.getX(), c.getZ(), false);
}
@@ -475,48 +577,76 @@ public class IrisWorldManager extends EngineAssignedWorldManager {
return job.targetsWorldName(world.getName());
}
private Chunk[] getLoadedChunksSnapshot(World world) {
private Position2[] getLoadedChunkPositionsSnapshot(World world) {
if (world == null) {
return new Chunk[0];
return new Position2[0];
}
CompletableFuture<Chunk[]> future = new CompletableFuture<>();
J.s(() -> {
CompletableFuture<Position2[]> future = new CompletableFuture<>();
boolean scheduled = J.runGlobal(() -> {
try {
future.complete(world.getLoadedChunks());
Chunk[] chunks = world.getLoadedChunks();
Position2[] positions = new Position2[chunks.length];
for (int i = 0; i < chunks.length; i++) {
positions[i] = new Position2(chunks[i].getX(), chunks[i].getZ());
}
loadedChunkCount = positions.length;
future.complete(positions);
} catch (Throwable e) {
future.completeExceptionally(e);
}
});
if (!scheduled) {
return new Position2[0];
}
try {
return future.get(2, TimeUnit.SECONDS);
} catch (Throwable e) {
IrisLogging.reportError(e);
return new Chunk[0];
return new Position2[0];
}
}
private List<Entity> getFoliaEntitySnapshot(World world) {
Map<String, Entity> snapshot = new ConcurrentHashMap<>();
List<Player> players = getEngine().getWorld().getPlayers();
if (players == null || players.isEmpty()) {
return new KList<>();
private int getFoliaLivingEntityCount(World world) {
CompletableFuture<List<Player>> playerFuture = new CompletableFuture<>();
boolean scheduled = J.runGlobal(() -> {
try {
playerFuture.complete(new ArrayList<>(world.getPlayers()));
} catch (Throwable e) {
playerFuture.completeExceptionally(e);
}
});
if (!scheduled) {
return 0;
}
List<Player> players;
try {
players = playerFuture.get(2, TimeUnit.SECONDS);
} catch (Throwable e) {
IrisLogging.reportError(e);
return 0;
}
Map<String, Entity> candidates = new ConcurrentHashMap<>();
CountDownLatch latch = new CountDownLatch(players.size());
for (Player player : players) {
if (player == null || !player.isOnline() || !world.equals(player.getWorld())) {
if (player == null) {
latch.countDown();
continue;
}
if (!J.runEntity(player, () -> {
try {
snapshot.put(player.getUniqueId().toString(), player);
if (!player.isOnline() || !world.equals(player.getWorld())) {
return;
}
candidates.put(player.getUniqueId().toString(), player);
for (Entity nearby : player.getNearbyEntities(64, 64, 64)) {
if (nearby != null && world.equals(nearby.getWorld())) {
snapshot.put(nearby.getUniqueId().toString(), nearby);
if (nearby != null) {
candidates.put(nearby.getUniqueId().toString(), nearby);
}
}
} finally {
@@ -533,9 +663,28 @@ public class IrisWorldManager extends EngineAssignedWorldManager {
Thread.currentThread().interrupt();
}
KList<Entity> entities = new KList<>();
entities.addAll(snapshot.values());
return entities;
AtomicInteger count = new AtomicInteger();
CountDownLatch entityLatch = new CountDownLatch(candidates.size());
for (Entity entity : candidates.values()) {
if (!J.runEntity(entity, () -> {
try {
if (entity instanceof LivingEntity && world.equals(entity.getWorld()) && !entity.isDead()) {
count.incrementAndGet();
}
} finally {
entityLatch.countDown();
}
})) {
entityLatch.countDown();
}
}
try {
entityLatch.await(2, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return count.get();
}
private void spawnChunkSafely(World world, int chunkX, int chunkZ, boolean initial) {
@@ -1039,7 +1188,7 @@ public class IrisWorldManager extends EngineAssignedWorldManager {
@Override
public int getChunkCount() {
return getEngine().getWorld().realWorld().getLoadedChunks().length;
return loadedChunkCount;
}
@Override
@@ -1048,7 +1197,7 @@ public class IrisWorldManager extends EngineAssignedWorldManager {
return 1;
}
return (double) entityCount / (getEngine().getWorld().realWorld().getLoadedChunks().length + 1) * 1.28;
return (double) entityCount / (loadedChunkCount + 1) * 1.28;
}
@Data
@@ -55,12 +55,12 @@ public class IrisBiomeActuator extends EngineAssignedActuator<PlatformBiome> {
PlatformBiome biome;
if (ib.isCustom()) {
IrisBiomeCustom custom = ib.getCustomBiome(rng, x, 0, z);
IrisBiomeCustom custom = ib.getCustomBiome(rng, getEngine(), x + xf, 0, z + zf);
String key = getDimension().getLoadKey() + ":" + custom.getId();
biome = IrisPlatforms.get().registries().biome(key);
matter = BiomeInjectMatter.get(IrisPlatforms.get().biomeWriter().biomeIdFor(key));
} else {
String skyKey = ib.getSkyBiomeKey(rng, x, 0, z);
String skyKey = ib.getSkyBiomeKey(rng, getEngine(), x + xf, 0, z + zf);
biome = IrisPlatforms.get().registries().biome(skyKey);
matter = BiomeInjectMatter.get(IrisPlatforms.get().biomeWriter().biomeIdFor(skyKey));
}
@@ -42,9 +42,9 @@ public class IrisDecorantActuator extends EngineAssignedActuator<PlatformBlockSt
private static final Predicate<PlatformBlockState> PREDICATE_SOLID = (s) -> s != null && !B.isAirOrFluid(s);
private final RNG rng;
@Getter
private final EngineDecorator surfaceDecorator;
private final IrisSurfaceDecorator surfaceDecorator;
@Getter
private final EngineDecorator ceilingDecorator;
private final IrisCeilingDecorator ceilingDecorator;
@Getter
private final EngineDecorator seaSurfaceDecorator;
@Getter
@@ -41,7 +41,13 @@ public class IrisCeilingDecorator extends IrisEngineDecorator {
@Override
public void decorate(int x, int z, int realX, int realX1, int realX_1, int realZ, int realZ1, int realZ_1,
Hunk<PlatformBlockState> data, IrisBiome biome, int height, int max) {
boolean caveSkipFluid = biome.getInferredType() == InferredType.CAVE;
decorate(x, z, realX, realX1, realX_1, realZ, realZ1, realZ_1, data, biome, biome.getInferredType(), height, max);
}
@BlockCoordinates
public void decorate(int x, int z, int realX, int realX1, int realX_1, int realZ, int realZ1, int realZ_1,
Hunk<PlatformBlockState> data, IrisBiome biome, InferredType inferredType, int height, int max) {
boolean caveSkipFluid = IrisSurfaceDecorator.skipsFluid(inferredType);
RNG rng = getRNG(realX, realZ);
IrisDecorator decorator = DecoratorCore.pickDecorator(biome, getPart(), partRNG, rng, getData(), realX, realZ);
@@ -52,13 +52,19 @@ public class IrisSurfaceDecorator extends IrisEngineDecorator {
@Override
public void decorate(int x, int z, int realX, int realX1, int realX_1, int realZ, int realZ1, int realZ_1,
Hunk<PlatformBlockState> data, IrisBiome biome, int height, int max) {
decorate(x, z, realX, realX1, realX_1, realZ, realZ1, realZ_1, data, biome, biome.getInferredType(), height, max);
}
@BlockCoordinates
public void decorate(int x, int z, int realX, int realX1, int realX_1, int realZ, int realZ1, int realZ_1,
Hunk<PlatformBlockState> data, IrisBiome biome, InferredType inferredType, int height, int max) {
int fluidHeight = getDimension().getFluidHeight();
if (biome.getInferredType().equals(InferredType.SHORE) && height < fluidHeight) {
if (inferredType == InferredType.SHORE && height < fluidHeight) {
return;
}
boolean underwater = height < fluidHeight && biome.getInferredType() != InferredType.CAVE;
boolean caveSkipFluid = biome.getInferredType() == InferredType.CAVE;
boolean underwater = isUnderwater(inferredType, height, fluidHeight);
boolean caveSkipFluid = skipsFluid(inferredType);
RNG rng = getRNG(realX, realZ);
IrisDecorator decorator = DecoratorCore.pickDecorator(biome, getPart(), partRNG, rng, getData(), realX, realZ);
@@ -79,4 +85,12 @@ public class IrisSurfaceDecorator extends IrisEngineDecorator {
DecoratorCore.placeSurfaceSingle(decorator, x, z, realX, height, realZ,
data, rng, getData(), underwater, caveSkipFluid, getEngine().getMantle());
}
static boolean isUnderwater(InferredType inferredType, int height, int fluidHeight) {
return height < fluidHeight && inferredType != InferredType.CAVE;
}
static boolean skipsFluid(InferredType inferredType) {
return inferredType == InferredType.CAVE;
}
}
@@ -41,9 +41,11 @@ import art.arcane.iris.engine.object.IrisEngineData;
import art.arcane.iris.engine.object.IrisLootMode;
import art.arcane.iris.engine.object.IrisLootReference;
import art.arcane.iris.engine.object.IrisLootTable;
import art.arcane.iris.engine.object.IrisObject;
import art.arcane.iris.engine.object.IrisObjectPlacement;
import art.arcane.iris.engine.object.IrisPosition;
import art.arcane.iris.engine.object.IrisRegion;
import art.arcane.iris.engine.object.IrisStructure;
import art.arcane.iris.engine.object.IrisWorld;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.PlatformBiome;
@@ -577,15 +579,24 @@ public interface Engine extends DataProvider, Fallible, LootProvider, BlockUpdat
return null;
}
String[] v = objectAt.split("\\Q@\\E");
String object = v[0];
if (object.isEmpty() || object.equals("null")) {
StructurePlacementMarker.Decoded marker = StructurePlacementMarker.decode(objectAt);
if (marker == null) {
return null;
}
String object = marker.objectKey();
if (object.startsWith("procedural/")) {
return null;
}
int id = Integer.parseInt(v[1]);
int id = marker.placementId();
if (marker.structureAware()) {
IrisObject placedObject = getData().getObjectLoader().load(object);
IrisStructure structure = IrisData.loadAnyStructure(marker.structureKey(), getData());
IrisObjectPlacement placement = placedObject == null || structure == null
? null
: structure.createLootPlacement(object);
return new PlacedObject(placement, placedObject, id, x, z);
}
IrisRegion region = getRegion(x, z);
@@ -0,0 +1,95 @@
package art.arcane.iris.engine.framework;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Objects;
public final class StructurePlacementMarker {
private static final String FAMILY_PREFIX = "@iris-structure:";
private static final String VERSION_PREFIX = FAMILY_PREFIX + "v1:";
private StructurePlacementMarker() {
}
public static String encodeStructure(String objectKey, int placementId, String structureKey) {
String normalizedObjectKey = requireKey(objectKey, "objectKey");
String normalizedStructureKey = requireKey(structureKey, "structureKey");
return VERSION_PREFIX
+ encodeKey(normalizedObjectKey)
+ ":" + placementId
+ ":" + encodeKey(normalizedStructureKey);
}
public static Decoded decode(String marker) {
if (marker == null || marker.isBlank()) {
return null;
}
if (marker.startsWith(FAMILY_PREFIX)) {
return decodeStructure(marker);
}
return decodeLegacy(marker);
}
private static Decoded decodeStructure(String marker) {
if (!marker.startsWith(VERSION_PREFIX)) {
return null;
}
String[] fields = marker.split(":", -1);
if (fields.length != 5 || !fields[0].equals("@iris-structure") || !fields[1].equals("v1")) {
return null;
}
try {
String objectKey = decodeCanonicalKey(fields[2]);
int placementId = Integer.parseInt(fields[3]);
String structureKey = decodeCanonicalKey(fields[4]);
if (objectKey.isBlank() || structureKey.isBlank()) {
return null;
}
return new Decoded(objectKey, placementId, structureKey);
} catch (IllegalArgumentException exception) {
return null;
}
}
private static Decoded decodeLegacy(String marker) {
int separator = marker.indexOf('@');
if (separator <= 0 || separator != marker.lastIndexOf('@') || separator == marker.length() - 1) {
return null;
}
String objectKey = marker.substring(0, separator);
if (objectKey.isEmpty() || objectKey.equals("null")) {
return null;
}
try {
return new Decoded(objectKey, Integer.parseInt(marker.substring(separator + 1)), null);
} catch (NumberFormatException exception) {
return null;
}
}
private static String requireKey(String key, String name) {
String required = Objects.requireNonNull(key, name);
if (required.isBlank()) {
throw new IllegalArgumentException(name + " must not be blank");
}
return required;
}
private static String encodeKey(String key) {
return Base64.getUrlEncoder().withoutPadding().encodeToString(key.getBytes(StandardCharsets.UTF_8));
}
private static String decodeCanonicalKey(String key) {
String decoded = new String(Base64.getUrlDecoder().decode(key), StandardCharsets.UTF_8);
if (!encodeKey(decoded).equals(key)) {
throw new IllegalArgumentException("Non-canonical marker key");
}
return decoded;
}
public record Decoded(String objectKey, int placementId, String structureKey) {
public boolean structureAware() {
return structureKey != null;
}
}
}
@@ -46,7 +46,6 @@ public class IrisCaveCarver3D {
private static final int ADAPTIVE_DEEP_SURFACE_MARGIN = 12;
private static final double ADAPTIVE_LOCAL_RANGE_SCALE = 0.125D;
private static final double ADAPTIVE_DEEP_MARGIN_BOOST = 0.015D;
private static final ThreadLocal<Scratch> SCRATCH = ThreadLocal.withInitial(Scratch::new);
private final Engine engine;
private final IrisData data;
@@ -70,6 +69,7 @@ public class IrisCaveCarver3D {
private final boolean hasWarp;
private final boolean hasModules;
private final int warpResolution;
private final ThreadLocal<Scratch> scratchCache = ThreadLocal.withInitial(Scratch::new);
public IrisCaveCarver3D(Engine engine, IrisCaveProfile profile) {
this.engine = engine;
@@ -112,7 +112,7 @@ public class IrisCaveCarver3D {
}
public int carve(MantleWriter writer, int chunkX, int chunkZ) {
Scratch scratch = SCRATCH.get();
Scratch scratch = scratchCache.get();
if (!scratch.fullWeightsInitialized) {
Arrays.fill(scratch.fullWeights, 1D);
scratch.fullWeightsInitialized = true;
@@ -169,7 +169,7 @@ public class IrisCaveCarver3D {
) {
PrecisionStopwatch applyStopwatch = PrecisionStopwatch.start();
try {
Scratch scratch = SCRATCH.get();
Scratch scratch = scratchCache.get();
if (columnWeights == null || columnWeights.length < 256) {
if (!scratch.fullWeightsInitialized) {
Arrays.fill(scratch.fullWeights, 1D);
@@ -364,7 +364,7 @@ public class IrisCaveCarver3D {
boolean skipExistingCarved
) {
int carved = 0;
Scratch scratch = SCRATCH.get();
Scratch scratch = scratchCache.get();
double[] passThreshold = scratch.passThreshold;
int[] activeColumnIndices = scratch.activeColumnIndices;
int[] activeColumnTopY = scratch.activeColumnTopY;
@@ -483,7 +483,7 @@ public class IrisCaveCarver3D {
boolean skipExistingCarved
) {
int carved = 0;
Scratch scratch = SCRATCH.get();
Scratch scratch = scratchCache.get();
double[] passThreshold = scratch.passThreshold;
int[] activeColumnIndices = scratch.activeColumnIndices;
int[] activeColumnTopY = scratch.activeColumnTopY;
@@ -639,7 +639,7 @@ public class IrisCaveCarver3D {
boolean skipExistingCarved
) {
int carved = 0;
Scratch scratch = SCRATCH.get();
Scratch scratch = scratchCache.get();
double[] passThreshold = scratch.passThreshold;
int[] tileIndices = scratch.tileIndices;
int[] tileLocalX = scratch.tileLocalX;
@@ -778,7 +778,7 @@ public class IrisCaveCarver3D {
boolean skipExistingCarved
) {
int carved = 0;
Scratch scratch = SCRATCH.get();
Scratch scratch = scratchCache.get();
for (int lx = 0; lx < 16; lx++) {
int x = x0 + lx;
@@ -926,7 +926,7 @@ public class IrisCaveCarver3D {
}
private void classifyDensityPlaneNoWarpModules(int x0, int z0, int y, int[] planeColumnIndices, double[] planeThresholdLimit, int planeCount, boolean[] planeCarve) {
Scratch scratch = SCRATCH.get();
Scratch scratch = scratchCache.get();
int activeModuleCount = prepareActiveModules(scratch, y);
if (activeModuleCount == 0) {
classifyDensityPlaneNoWarpNoModules(x0, z0, y, planeColumnIndices, planeThresholdLimit, planeCount, planeCarve);
@@ -964,7 +964,7 @@ public class IrisCaveCarver3D {
}
private void classifyDensityPlaneWarpModules(int x0, int z0, int y, int[] planeColumnIndices, double[] planeThresholdLimit, int planeCount, boolean[] planeCarve) {
Scratch scratch = SCRATCH.get();
Scratch scratch = scratchCache.get();
int activeModuleCount = prepareActiveModules(scratch, y);
if (activeModuleCount == 0) {
classifyDensityPlaneWarpOnly(x0, z0, y, planeColumnIndices, planeThresholdLimit, planeCount, planeCarve);
@@ -1003,7 +1003,7 @@ public class IrisCaveCarver3D {
int adaptiveSampleStep,
double adaptiveThresholdMargin
) {
Scratch scratch = SCRATCH.get();
Scratch scratch = scratchCache.get();
double[] adaptivePlaneDensity = scratch.adaptivePlaneDensity;
int axisCells = (16 + adaptiveSampleStep - 1) / adaptiveSampleStep;
int axisSamples = axisCells + 1;
@@ -1046,7 +1046,7 @@ public class IrisCaveCarver3D {
int adaptiveSampleStep,
double adaptiveThresholdMargin
) {
Scratch scratch = SCRATCH.get();
Scratch scratch = scratchCache.get();
int activeModuleCount = prepareActiveModules(scratch, y);
if (activeModuleCount == 0) {
classifyDensityPlaneAdaptiveNoWarpNoModules(x0, z0, y, planeColumnIndices, planeThresholdLimit, planeCount, planeCarve, adaptiveSampleStep, adaptiveThresholdMargin);
@@ -1102,7 +1102,7 @@ public class IrisCaveCarver3D {
int adaptiveSampleStep,
double adaptiveThresholdMargin
) {
Scratch scratch = SCRATCH.get();
Scratch scratch = scratchCache.get();
double[] adaptivePlaneDensity = scratch.adaptivePlaneDensity;
int axisCells = (16 + adaptiveSampleStep - 1) / adaptiveSampleStep;
int axisSamples = axisCells + 1;
@@ -1145,7 +1145,7 @@ public class IrisCaveCarver3D {
int adaptiveSampleStep,
double adaptiveThresholdMargin
) {
Scratch scratch = SCRATCH.get();
Scratch scratch = scratchCache.get();
int activeModuleCount = prepareActiveModules(scratch, y);
if (activeModuleCount == 0) {
classifyDensityPlaneAdaptiveWarpOnly(x0, z0, y, planeColumnIndices, planeThresholdLimit, planeCount, planeCarve, adaptiveSampleStep, adaptiveThresholdMargin);
@@ -1211,7 +1211,7 @@ public class IrisCaveCarver3D {
int axisCells,
int axisSamples
) {
Scratch scratch = SCRATCH.get();
Scratch scratch = scratchCache.get();
double[] adaptivePlanePrediction = scratch.adaptivePlanePrediction;
double[] adaptivePlaneAmbiguity = scratch.adaptivePlaneAmbiguity;
prepareAdaptivePlaneColumns(
@@ -1271,7 +1271,7 @@ public class IrisCaveCarver3D {
double[] remainingMin,
double[] remainingMax
) {
Scratch scratch = SCRATCH.get();
Scratch scratch = scratchCache.get();
double[] adaptivePlanePrediction = scratch.adaptivePlanePrediction;
double[] adaptivePlaneAmbiguity = scratch.adaptivePlaneAmbiguity;
prepareAdaptivePlaneColumns(
@@ -1348,7 +1348,7 @@ public class IrisCaveCarver3D {
int axisCells,
int axisSamples
) {
Scratch scratch = SCRATCH.get();
Scratch scratch = scratchCache.get();
double[] adaptivePlanePrediction = scratch.adaptivePlanePrediction;
double[] adaptivePlaneAmbiguity = scratch.adaptivePlaneAmbiguity;
prepareAdaptivePlaneColumns(
@@ -1408,7 +1408,7 @@ public class IrisCaveCarver3D {
double[] remainingMin,
double[] remainingMax
) {
Scratch scratch = SCRATCH.get();
Scratch scratch = scratchCache.get();
double[] adaptivePlanePrediction = scratch.adaptivePlanePrediction;
double[] adaptivePlaneAmbiguity = scratch.adaptivePlaneAmbiguity;
prepareAdaptivePlaneColumns(
@@ -1485,7 +1485,7 @@ public class IrisCaveCarver3D {
double[] remainingMin,
double[] remainingMax
) {
Scratch scratch = SCRATCH.get();
Scratch scratch = scratchCache.get();
double[] adaptivePlanePrediction = scratch.adaptivePlanePrediction;
double[] adaptivePlaneAmbiguity = scratch.adaptivePlaneAmbiguity;
prepareAdaptivePlaneColumns(
@@ -1604,7 +1604,7 @@ public class IrisCaveCarver3D {
}
private boolean classifyDensityPointWarpOnly(int x, int y, int z, double thresholdLimit) {
Scratch scratch = SCRATCH.get();
Scratch scratch = scratchCache.get();
int sx = snapWarp(x);
int sy = snapWarp(y);
int sz = snapWarp(z);
@@ -1640,7 +1640,7 @@ public class IrisCaveCarver3D {
return classifyDensityPointWarpOnly(x, y, z, thresholdLimit);
}
Scratch scratch = SCRATCH.get();
Scratch scratch = scratchCache.get();
int sx = snapWarp(x);
int sy = snapWarp(y);
int sz = snapWarp(z);
@@ -1740,7 +1740,7 @@ public class IrisCaveCarver3D {
return true;
}
Scratch scratch = SCRATCH.get();
Scratch scratch = scratchCache.get();
int sx = snapWarp(x);
int sy = snapWarp(y);
int sz = snapWarp(z);
@@ -1778,7 +1778,7 @@ public class IrisCaveCarver3D {
int[] adaptivePlaneSampleBounds,
int axisCells
) {
Scratch scratch = SCRATCH.get();
Scratch scratch = scratchCache.get();
prepareAdaptiveGeometry(scratch, adaptiveSampleStep, axisCells, axisCells + 1);
int[] adaptiveCellX = scratch.adaptiveCellX;
int[] adaptiveCellZ = scratch.adaptiveCellZ;
@@ -1822,7 +1822,7 @@ public class IrisCaveCarver3D {
double[] adaptivePlanePrediction,
double[] adaptivePlaneAmbiguity
) {
Scratch scratch = SCRATCH.get();
Scratch scratch = scratchCache.get();
prepareAdaptiveGeometry(scratch, adaptiveSampleStep, axisCells, axisSamples);
int[] adaptiveCellZ = scratch.adaptiveCellZ;
int[] adaptiveRow0 = scratch.adaptiveRow0;
@@ -1882,7 +1882,7 @@ public class IrisCaveCarver3D {
}
private double sampleDensityNoWarpModules(int x, int y, int z) {
Scratch scratch = SCRATCH.get();
Scratch scratch = scratchCache.get();
int activeModuleCount = prepareActiveModules(scratch, y);
if (activeModuleCount == 0) {
return sampleDensityNoWarpNoModules(x, y, z);
@@ -1905,7 +1905,7 @@ public class IrisCaveCarver3D {
}
private double sampleDensityWarpOnly(int x, int y, int z) {
Scratch scratch = SCRATCH.get();
Scratch scratch = scratchCache.get();
int sx = snapWarp(x);
int sy = snapWarp(y);
int sz = snapWarp(z);
@@ -1921,7 +1921,7 @@ public class IrisCaveCarver3D {
}
private double sampleDensityWarpModules(int x, int y, int z) {
Scratch scratch = SCRATCH.get();
Scratch scratch = scratchCache.get();
int activeModuleCount = prepareActiveModules(scratch, y);
if (activeModuleCount == 0) {
return sampleDensityWarpOnly(x, y, z);
@@ -23,6 +23,7 @@ import art.arcane.iris.engine.IrisComplex;
import art.arcane.iris.engine.data.cache.Cache;
import art.arcane.iris.engine.framework.PlacedStructurePiece;
import art.arcane.iris.engine.framework.StructureAssembler;
import art.arcane.iris.engine.framework.StructurePlacementMarker;
import art.arcane.iris.engine.framework.StructurePlacementGrid;
import art.arcane.iris.engine.mantle.ComponentFlag;
import art.arcane.iris.engine.mantle.EngineMantle;
@@ -37,6 +38,7 @@ import art.arcane.iris.engine.object.IrisRegion;
import art.arcane.iris.engine.object.IrisStructure;
import art.arcane.iris.engine.object.IrisStructurePlacement;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.iris.util.project.noise.CNG;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.iris.util.project.context.ChunkContext;
@@ -331,10 +333,10 @@ public class IrisStructureComponent extends IrisMantleComponent {
private void placeObject(MantleWriter writer, IrisStructure structure, PlacedStructurePiece p, ObjectPlaceMode mode, int y, RNG rng) {
IrisObject object = p.getObject();
IrisObjectPlacement config = new IrisObjectPlacement();
String objectKey = object.getLoadKey();
IrisObjectPlacement config = structure.createLootPlacement(objectKey);
config.setMode(mode);
config.setRotation(p.getRotation());
config.getPlace().add(object.getLoadKey());
if (!structure.getEdit().isEmpty()) {
config.setEdit(structure.getEdit());
}
@@ -342,7 +344,35 @@ public class IrisStructureComponent extends IrisMantleComponent {
config.setForcePlace(true);
}
int placeY = (y == -1) ? -1 : y - getEngineMantle().getEngine().getMinHeight();
object.place(p.getX(), placeY, p.getZ(), writer, config, rng, null, null, getData());
String marker = structurePlacementMarker(structure, p, objectKey);
object.place(p.getX(), placeY, p.getZ(), writer, config, rng, (position, state) -> {
if (marker != null && shouldWriteStructureMarker(state)) {
writer.setData(position.getX(), position.getY(), position.getZ(), marker);
}
}, null, getData());
}
static boolean shouldWriteStructureMarker(PlatformBlockState state) {
return state != null && state.isStorageChest();
}
static int structurePlacementId(String structureKey, String objectKey, int x, int y, int z) {
int hash = 17;
hash = 31 * hash + structureKey.hashCode();
hash = 31 * hash + objectKey.hashCode();
hash = 31 * hash + x;
hash = 31 * hash + y;
hash = 31 * hash + z;
return hash & Integer.MAX_VALUE;
}
private static String structurePlacementMarker(IrisStructure structure, PlacedStructurePiece piece, String objectKey) {
String structureKey = structure.getLoadKey();
if (structureKey == null || structureKey.isBlank() || objectKey == null || objectKey.isBlank()) {
return null;
}
int placementId = structurePlacementId(structureKey, objectKey, piece.getX(), piece.getY(), piece.getZ());
return StructurePlacementMarker.encodeStructure(objectKey, placementId, structureKey);
}
@Override
@@ -464,7 +464,7 @@ public class MantleCarvingComponent extends IrisMantleComponent {
}
protected int computeRadius() {
return 0;
return 1;
}
private int[] prepareChunkSurfaceHeights(int chunkX, int chunkZ, ChunkContext context, int[] scratch) {
@@ -168,7 +168,6 @@ public class IrisCarveModifier extends EngineAssignedModifier<PlatformBlockState
: resolveCustomBiome(customBiomeCache, customBiome);
if (biome != null) {
biome.setInferredType(InferredType.CAVE);
PlatformBlockState data = biome.getWall().get(rng, worldX, yy, worldZ, getData());
int columnIndex = PowerOfTwoCoordinates.packLocal16(rx, rz);
@@ -461,7 +460,6 @@ public class IrisCarveModifier extends EngineAssignedModifier<PlatformBlockState
return;
}
biome.setInferredType(InferredType.CAVE);
KList<PlatformBlockState> floorLayers = biome.generateLayers(getDimension(), worldX, worldZ, rng, 3, zoneFloor, getData(), getComplex());
for (int i = 0; i < zoneFloor - 1; i++) {
@@ -550,7 +548,6 @@ public class IrisCarveModifier extends EngineAssignedModifier<PlatformBlockState
return;
}
biome.setInferredType(InferredType.CAVE);
KList<PlatformBlockState> blocks = biome.generateLayers(getDimension(), xx, zz, rng, 3, zone.floor, getData(), getComplex());
@@ -600,9 +597,9 @@ public class IrisCarveModifier extends EngineAssignedModifier<PlatformBlockState
for (IrisDecorator decorator : biome.getDecorators()) {
if (decorator.getPartOf().equals(IrisDecorationPart.NONE) && zone.getFloor() > 0 && B.isSolid(output.getRaw(rx, zone.getFloor() - 1, rz))) {
decorant.getSurfaceDecorator().decorate(rx, rz, xx, xx, xx, zz, zz, zz, output, biome, zone.getFloor() - 1, zone.airThickness());
decorant.getSurfaceDecorator().decorate(rx, rz, xx, xx, xx, zz, zz, zz, output, biome, InferredType.CAVE, zone.getFloor() - 1, zone.airThickness());
} else if (decorator.getPartOf().equals(IrisDecorationPart.CEILING) && zone.getCeiling() + 1 < maxY && B.isSolid(output.getRaw(rx, zone.getCeiling() + 1, rz))) {
decorant.getCeilingDecorator().decorate(rx, rz, xx, xx, xx, zz, zz, zz, output, biome, zone.getCeiling(), zone.airThickness());
decorant.getCeilingDecorator().decorate(rx, rz, xx, xx, xx, zz, zz, zz, output, biome, InferredType.CAVE, zone.getCeiling(), zone.airThickness());
}
}
}
@@ -394,10 +394,10 @@ public class IrisFloatingChildBiomeModifier extends EngineAssignedModifier<Platf
private MatterBiomeInject createSkyBiomeMatter(IrisBiome target, int wx, int wz) {
if (target.isCustom()) {
IrisBiomeCustom custom = target.getCustomBiome(rng, wx, 0, wz);
IrisBiomeCustom custom = target.getCustomBiome(rng, getEngine(), wx, 0, wz);
return BiomeInjectMatter.get(IrisPlatforms.get().biomeWriter().biomeIdFor(getDimension().getLoadKey() + ":" + custom.getId()));
}
return BiomeInjectMatter.get(IrisPlatforms.get().biomeWriter().biomeIdFor(target.getSkyBiomeKey(rng, wx, 0, wz)));
return BiomeInjectMatter.get(IrisPlatforms.get().biomeWriter().biomeIdFor(target.getSkyBiomeKey(rng, getEngine(), wx, 0, wz)));
}
}
@@ -44,9 +44,14 @@ import art.arcane.volmlib.util.json.JSONObject;
import art.arcane.volmlib.util.math.RNG;
import art.arcane.iris.util.project.noise.CNG;
import art.arcane.iris.util.common.plugin.VolmitSender;
import art.arcane.iris.util.project.context.IrisContext;
import com.googlecode.concurrentlinkedhashmap.ConcurrentLinkedHashMap;
import lombok.AccessLevel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.experimental.Accessors;
import art.arcane.iris.spi.PlatformBlockState;
import org.bukkit.NamespacedKey;
@@ -61,6 +66,8 @@ import java.util.EnumMap;
@Data
@EqualsAndHashCode(callSuper = false)
public class IrisBiome extends IrisRegistrant implements IRare {
private static final int BIOME_GENERATOR_CACHE_SIZE = 8;
private static final class States {
private static final PlatformBlockState BARRIER = B.getState("BARRIER");
}
@@ -76,7 +83,13 @@ public class IrisBiome extends IrisRegistrant implements IRare {
private final transient AtomicCache<Color> cacheColorLayerLoad = new AtomicCache<>();
private final transient AtomicCache<Color> cacheColorDepositLoad = new AtomicCache<>();
private final transient AtomicCache<CNG> childrenCell = new AtomicCache<>();
private final transient AtomicCache<CNG> biomeGenerator = new AtomicCache<>();
@Getter(AccessLevel.NONE)
private final transient ConcurrentLinkedHashMap<Long, CNG> biomeGenerators = new ConcurrentLinkedHashMap.Builder<Long, CNG>()
.maximumWeightedCapacity(BIOME_GENERATOR_CACHE_SIZE)
.build();
@Getter(AccessLevel.NONE)
@Setter(AccessLevel.NONE)
private transient volatile SeededBiomeGenerator recentBiomeGenerator;
private final transient AtomicCache<Integer> maxHeight = new AtomicCache<>();
private final transient AtomicCache<Integer> maxWithObjectHeight = new AtomicCache<>();
private final transient AtomicCache<IrisBiome> realCarveBiome = new AtomicCache<>();
@@ -469,8 +482,39 @@ public class IrisBiome extends IrisRegistrant implements IRare {
}
public CNG getBiomeGenerator(RNG random) {
return biomeGenerator.aquire(() ->
biomeStyle.create(random.nextParallelRNG(213949 + 228888 + getRarity() + getName().length()), getLoader()));
IrisData loader = getLoader();
Engine engine = resolveBiomeGeneratorEngine(loader);
return getBiomeGenerator(random, engine);
}
public CNG getBiomeGenerator(RNG random, Engine engine) {
IrisData loader = getLoader();
long seed = engine == null ? random.getSeed() : engine.getSeedManager().getBiome();
SeededBiomeGenerator recent = recentBiomeGenerator;
if (recent != null && recent.seed == seed) {
return recent.generator;
}
CNG generator = biomeGenerators.computeIfAbsent(seed, ignored -> createBiomeGenerator(seed, loader));
recentBiomeGenerator = new SeededBiomeGenerator(seed, generator);
return generator;
}
private Engine resolveBiomeGeneratorEngine(IrisData loader) {
IrisContext context = IrisContext.get();
if (context != null) {
Engine contextEngine = context.getEngine();
if (!contextEngine.isClosed() && (loader == null || contextEngine.getData() == loader)) {
return contextEngine;
}
}
return loader == null ? null : loader.getEngine();
}
private CNG createBiomeGenerator(long seed, IrisData loader) {
int signature = 213949 + 228888 + getRarity() + getName().length();
return biomeStyle.createNoCache(new RNG(seed).nextParallelRNG(signature), loader);
}
public CNG getChildrenGenerator(RNG random, int sig, double scale) {
@@ -752,23 +796,31 @@ public class IrisBiome extends IrisRegistrant implements IRare {
}
public Biome getSkyBiome(RNG rng, double x, double y, double z) {
return getSkyBiome(rng, resolveBiomeGeneratorEngine(getLoader()), x, y, z);
}
public Biome getSkyBiome(RNG rng, Engine engine, double x, double y, double z) {
if (biomeSkyScatter.size() == 1) {
return getBiomeSkyScatterResolved().get(0);
}
if (biomeSkyScatter.isEmpty()) {
return getGroundBiome(rng, x, y, z);
return getGroundBiome(rng, engine, x, y, z);
}
return getBiomeSkyScatterResolved().get(getBiomeGenerator(rng).fit(0, biomeSkyScatter.size() - 1, x, y, z));
return getBiomeSkyScatterResolved().get(getBiomeGenerator(rng, engine).fit(0, biomeSkyScatter.size() - 1, x, y, z));
}
public IrisBiomeCustom getCustomBiome(RNG rng, double x, double y, double z) {
return getCustomBiome(rng, resolveBiomeGeneratorEngine(getLoader()), x, y, z);
}
public IrisBiomeCustom getCustomBiome(RNG rng, Engine engine, double x, double y, double z) {
if (customDerivitives.size() == 1) {
return customDerivitives.get(0);
}
return customDerivitives.get(getBiomeGenerator(rng).fit(0, customDerivitives.size() - 1, x, y, z));
return customDerivitives.get(getBiomeGenerator(rng, engine).fit(0, customDerivitives.size() - 1, x, y, z));
}
public KList<IrisBiome> getRealChildren(DataProvider g) {
@@ -801,6 +853,10 @@ public class IrisBiome extends IrisRegistrant implements IRare {
//TODO: Test
public Biome getGroundBiome(RNG rng, double x, double y, double z) {
return getGroundBiome(rng, resolveBiomeGeneratorEngine(getLoader()), x, y, z);
}
public Biome getGroundBiome(RNG rng, Engine engine, double x, double y, double z) {
if (biomeScatter.isEmpty()) {
return getDerivative();
}
@@ -809,22 +865,30 @@ public class IrisBiome extends IrisRegistrant implements IRare {
return getBiomeScatterResolved().get(0);
}
return getBiomeGenerator(rng).fit(getBiomeScatterResolved(), x, y, z);
return getBiomeGenerator(rng, engine).fit(getBiomeScatterResolved(), x, y, z);
}
public String getSkyBiomeKey(RNG rng, double x, double y, double z) {
return getSkyBiomeKey(rng, resolveBiomeGeneratorEngine(getLoader()), x, y, z);
}
public String getSkyBiomeKey(RNG rng, Engine engine, double x, double y, double z) {
if (biomeSkyScatter.size() == 1) {
return namespacedBiomeKey(biomeSkyScatter.get(0));
}
if (biomeSkyScatter.isEmpty()) {
return getGroundBiomeKey(rng, x, y, z);
return getGroundBiomeKey(rng, engine, x, y, z);
}
return namespacedBiomeKey(biomeSkyScatter.get(getBiomeGenerator(rng).fit(0, biomeSkyScatter.size() - 1, x, y, z)));
return namespacedBiomeKey(biomeSkyScatter.get(getBiomeGenerator(rng, engine).fit(0, biomeSkyScatter.size() - 1, x, y, z)));
}
public String getGroundBiomeKey(RNG rng, double x, double y, double z) {
return getGroundBiomeKey(rng, resolveBiomeGeneratorEngine(getLoader()), x, y, z);
}
public String getGroundBiomeKey(RNG rng, Engine engine, double x, double y, double z) {
if (biomeScatter.isEmpty()) {
return namespacedBiomeKey(derivative);
}
@@ -833,7 +897,7 @@ public class IrisBiome extends IrisRegistrant implements IRare {
return namespacedBiomeKey(biomeScatter.get(0));
}
return namespacedBiomeKey(biomeScatter.get(getBiomeGenerator(rng).fit(0, biomeScatter.size() - 1, x, y, z)));
return namespacedBiomeKey(biomeScatter.get(getBiomeGenerator(rng, engine).fit(0, biomeScatter.size() - 1, x, y, z)));
}
public PlatformBlockState getSurfaceBlock(int x, int z, RNG rng, IrisData idm) {
@@ -931,4 +995,14 @@ public class IrisBiome extends IrisRegistrant implements IRare {
public void scanForErrors(JSONObject p, VolmitSender sender) {
}
private static final class SeededBiomeGenerator {
private final long seed;
private final CNG generator;
private SeededBiomeGenerator(long seed, CNG generator) {
this.seed = seed;
this.generator = generator;
}
}
}
@@ -65,6 +65,10 @@ public class IrisBiomeCustom {
@Desc("The biome's mob spawns")
private KList<IrisBiomeCustomSpawn> spawns = new KList<>();
@ArrayType(min = 1, type = String.class)
@Desc("Biome tags to opt this custom biome into, such as minecraft:allows_surface_slime_spawns")
private KList<String> tags = new KList<>();
@Desc("The biome's downfall type")
private IrisBiomeCustomPrecipType downfallType = IrisBiomeCustomPrecipType.rain;
@@ -32,6 +32,8 @@ import lombok.experimental.Accessors;
import org.bukkit.NamespacedKey;
import org.bukkit.entity.EntityType;
import java.util.Locale;
@Snippet("custom-biome-spawn")
@Accessors(chain = true)
@NoArgsConstructor
@@ -45,11 +47,12 @@ public class IrisBiomeCustomSpawn {
private String type = "minecraft:cow";
public EntityType getType() {
if (type == null) {
String typeKey = getTypeKey();
if (typeKey == null) {
return null;
}
return typeResolved.aquire(() -> {
NamespacedKey namespacedKey = NamespacedKey.fromString(type);
NamespacedKey namespacedKey = NamespacedKey.fromString(typeKey);
return namespacedKey == null ? null : RegistryUtil.lookup(EntityType.class).get(namespacedKey);
});
}
@@ -58,7 +61,8 @@ public class IrisBiomeCustomSpawn {
if (type == null || type.isBlank()) {
return null;
}
return type.contains(":") ? type : "minecraft:" + type;
String normalized = type.trim().toLowerCase(Locale.ROOT);
return normalized.contains(":") ? normalized : "minecraft:" + normalized;
}
@MinNumber(1)
@@ -31,6 +31,9 @@ public enum IrisBiomeCustomSpawnType {
@Desc("Eg bats")
AMBIENT,
@Desc("Axolotls")
AXOLOTLS,
@Desc("Odd spawn group but ok")
UNDERGROUND_WATER_CREATURE,
@@ -40,6 +40,7 @@ import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.collection.KSet;
import art.arcane.iris.util.common.data.DataProvider;
import art.arcane.volmlib.util.io.IO;
import art.arcane.volmlib.util.json.JSONArray;
import art.arcane.volmlib.util.json.JSONObject;
import art.arcane.volmlib.util.mantle.flag.MantleFlag;
import art.arcane.volmlib.util.math.Position2;
@@ -56,9 +57,17 @@ import org.bukkit.block.Biome;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.regex.Pattern;
@Accessors(chain = true)
@NoArgsConstructor
@@ -67,6 +76,8 @@ import java.util.Map;
@EqualsAndHashCode(callSuper = false, doNotUseGetters = true)
@ToString(doNotUseGetters = true)
public class IrisDimension extends IrisRegistrant {
private static final Pattern RESOURCE_KEY_PATTERN = Pattern.compile("[a-z0-9_.-]+:[a-z0-9/._-]+");
private final transient AtomicCache<Position2> parallaxSize = new AtomicCache<>();
private final transient AtomicCache<CNG> rockLayerGenerator = new AtomicCache<>();
private final transient AtomicCache<CNG> fluidLayerGenerator = new AtomicCache<>();
@@ -523,6 +534,7 @@ public class IrisDimension extends IrisRegistrant {
output.getParentFile().mkdirs();
try {
IO.writeAll(output, json);
installBiomeTags(datapacks, namespace + ":" + customBiomeId, customBiome.getTags());
} catch (IOException e) {
IrisLogging.reportError(e);
e.printStackTrace();
@@ -532,6 +544,97 @@ public class IrisDimension extends IrisRegistrant {
}
}
public static void clearGeneratedBiomeTags(KList<File> folders) {
for (File datapacks : folders) {
File dataFolder = new File(datapacks, "iris/data");
File[] namespaces = dataFolder.listFiles(File::isDirectory);
if (namespaces == null) {
continue;
}
for (File namespace : namespaces) {
IO.delete(new File(namespace, "tags/worldgen/biome"));
}
}
}
static void installBiomeTags(File datapacks, String biomeKey, KList<String> tags) throws IOException {
if (tags == null || tags.isEmpty()) {
return;
}
for (String rawTag : tags) {
String tag = normalizeResourceKey(rawTag);
if (tag == null) {
IrisLogging.error("Invalid custom biome tag '" + rawTag + "' for " + biomeKey);
continue;
}
int separator = tag.indexOf(':');
String namespace = tag.substring(0, separator);
String path = tag.substring(separator + 1);
Path tagRoot = new File(datapacks, "iris/data/" + namespace + "/tags/worldgen/biome").toPath()
.toAbsolutePath().normalize();
Path output = tagRoot.resolve(path + ".json").normalize();
if (!output.startsWith(tagRoot)) {
IrisLogging.error("Unsafe custom biome tag '" + rawTag + "' for " + biomeKey);
continue;
}
writeBiomeTag(output, biomeKey);
}
}
private static String normalizeResourceKey(String key) {
if (key == null || key.isBlank()) {
return null;
}
String normalized = key.trim().toLowerCase(Locale.ROOT);
if (normalized.indexOf(':') < 0) {
normalized = "minecraft:" + normalized;
}
return RESOURCE_KEY_PATTERN.matcher(normalized).matches() ? normalized : null;
}
static void writeBiomeTag(Path output, String biomeKey) throws IOException {
synchronized (IrisDimension.class) {
Set<String> values = new TreeSet<>();
if (Files.isRegularFile(output)) {
JSONObject existing = new JSONObject(Files.readString(output, StandardCharsets.UTF_8));
JSONArray existingValues = existing.optJSONArray("values");
if (existingValues != null) {
for (int index = 0; index < existingValues.length(); index++) {
String value = existingValues.optString(index, null);
if (value != null && !value.isBlank()) {
values.add(value);
}
}
}
}
values.add(biomeKey);
JSONArray outputValues = new JSONArray();
for (String value : values) {
outputValues.put(value);
}
JSONObject tag = new JSONObject();
tag.put("replace", false);
tag.put("values", outputValues);
writeAtomic(output, tag.toString(4));
}
}
private static void writeAtomic(Path output, String content) throws IOException {
Files.createDirectories(output.getParent());
Path temporary = Files.createTempFile(output.getParent(), output.getFileName().toString(), ".tmp");
try {
Files.writeString(temporary, content, StandardCharsets.UTF_8);
try {
Files.move(temporary, output, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException e) {
Files.move(temporary, output, StandardCopyOption.REPLACE_EXISTING);
}
} finally {
Files.deleteIfExists(temporary);
}
}
public Dimension getBaseDimension() {
return switch (environment) {
case NETHER -> Dimension.NETHER;
@@ -71,6 +71,21 @@ public class IrisStructure extends IrisRegistrant {
@Desc("If this structure was generated by importing a vanilla or datapack structure, this is that structure's key (provenance). Empty for hand-authored structures.")
private String vanillaSource = "";
public IrisObjectPlacement createLootPlacement(String objectKey) {
IrisObjectPlacement placement = new IrisObjectPlacement();
placement.getPlace().add(objectKey);
placement.setOverrideGlobalLoot(false);
if (loot == null) {
return placement;
}
for (String lootTable : loot) {
if (lootTable != null && !lootTable.isBlank()) {
placement.getLoot().add(new IrisObjectLoot().setName(lootTable).setWeight(1));
}
}
return placement;
}
@Override
public String getFolderName() {
return "structures";
@@ -72,15 +72,6 @@ public class IrisStructurePlacement {
@Desc("CONCENTRIC_RINGS only: how many placements share each ring before moving outward.")
private int ringSpread = 3;
@Desc("rotation applied to the placed structure.")
private IrisObjectRotation rotation = new IrisObjectRotation();
@Desc("translation applied to the placed structure.")
private IrisObjectTranslate translate = new IrisObjectTranslate();
@Desc("scale applied to the placed structure.")
private IrisObjectScale scale = new IrisObjectScale();
@Desc("When underground=false this is the minimum surface Y the placement is allowed at (a gate); when underground=true this is the lower bound of the Y band the structure is placed within.")
private int minHeight = -2032;
@@ -18,6 +18,7 @@
package art.arcane.iris.platform.bukkit;
import art.arcane.iris.core.nms.INMS;
import art.arcane.iris.spi.PlatformEntityType;
import org.bukkit.entity.EntityType;
@@ -32,12 +33,14 @@ public final class BukkitEntityType implements PlatformEntityType {
private final EntityType type;
private final String key;
private final String namespace;
private final String spawnCategory;
private BukkitEntityType(EntityType type, String key) {
this.type = type;
this.key = key;
int colon = key.indexOf(':');
this.namespace = colon >= 0 ? key.substring(0, colon) : "minecraft";
this.spawnCategory = INMS.get().getEntitySpawnCategory(key);
}
public static BukkitEntityType of(EntityType type) {
@@ -55,6 +58,11 @@ public final class BukkitEntityType implements PlatformEntityType {
return namespace;
}
@Override
public String spawnCategory() {
return spawnCategory;
}
@Override
public Object nativeHandle() {
return type;
@@ -281,6 +281,41 @@ public class J {
return true;
}
public static boolean runGlobal(Runnable runnable) {
if (runnable == null) {
return false;
}
if (!BUKKIT_PRESENT) {
if (!IrisPlatforms.isBound()) {
return false;
}
IrisPlatforms.get().scheduler().global(runnable);
return true;
}
if (!isPluginEnabled()) {
return false;
}
if (isFolia()) {
return FoliaScheduler.runGlobal(BukkitPlatform.plugin(), runnable);
}
if (Bukkit.isPrimaryThread()) {
runnable.run();
return true;
}
try {
Bukkit.getScheduler().runTask(BukkitPlatform.plugin(), runnable);
return true;
} catch (UnsupportedOperationException e) {
FoliaScheduler.forceFoliaThreading(Bukkit.getServer());
return FoliaScheduler.runGlobal(BukkitPlatform.plugin(), runnable);
}
}
public static boolean runRegion(World world, int chunkX, int chunkZ, Runnable runnable, int delayTicks) {
if (world == null || runnable == null) {
return false;
@@ -0,0 +1,42 @@
package art.arcane.iris.core.nms.datapack.v1217;
import art.arcane.iris.core.nms.datapack.IDataFixer.Dimension;
import art.arcane.volmlib.util.json.JSONObject;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class DataFixerV1217ClockTest {
@Test
public void overworldProvidesTheVanillaDefaultClock() {
JSONObject json = fixed(Dimension.OVERWORLD);
assertEquals("minecraft:overworld", json.getString("default_clock"));
assertFalse(json.optBoolean("has_fixed_time", false));
}
@Test
public void endProvidesItsFixedVanillaClock() {
JSONObject json = fixed(Dimension.END);
assertEquals("minecraft:the_end", json.getString("default_clock"));
assertTrue(json.getBoolean("has_fixed_time"));
}
@Test
public void netherRemainsFixedWithoutADefaultClock() {
JSONObject json = fixed(Dimension.NETHER);
assertFalse(json.has("default_clock"));
assertTrue(json.getBoolean("has_fixed_time"));
}
private JSONObject fixed(Dimension dimension) {
DataFixerV1217 fixer = new DataFixerV1217();
JSONObject json = fixer.resolve(dimension, null);
fixer.fixDimension(dimension, json);
return json;
}
}
@@ -0,0 +1,58 @@
package art.arcane.iris.core.pack;
import org.junit.Assume;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
public class PackDirectoryResolverTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void resolvesOnlyExistingDirectChildren() throws Exception {
File packs = temporaryFolder.newFolder("packs");
File overworld = new File(packs, "overworld");
Files.createDirectory(overworld.toPath());
assertEquals(overworld.getAbsoluteFile(), PackDirectoryResolver.resolveExisting(packs, "overworld"));
assertEquals(overworld.getAbsoluteFile(), PackDirectoryResolver.resolveExisting(packs, "./overworld"));
assertNull(PackDirectoryResolver.resolveExisting(packs, "missing"));
assertNull(PackDirectoryResolver.resolveExisting(packs, ""));
}
@Test
public void rejectsTraversalAbsoluteAndNestedPaths() throws Exception {
File packs = temporaryFolder.newFolder("pack-root");
File outside = temporaryFolder.newFolder("outside");
File nested = new File(packs, "nested/pack");
Files.createDirectories(nested.toPath());
assertNull(PackDirectoryResolver.resolveExisting(packs, "../outside"));
assertNull(PackDirectoryResolver.resolveExisting(packs, outside.getAbsolutePath()));
assertNull(PackDirectoryResolver.resolveExisting(packs, "nested/pack"));
assertNull(PackDirectoryResolver.resolveExisting(packs, "."));
}
@Test
public void rejectsSymbolicLinkChildren() throws Exception {
File packs = temporaryFolder.newFolder("symlink-root");
File outside = temporaryFolder.newFolder("symlink-target");
Path link = new File(packs, "linked").toPath();
try {
Files.createSymbolicLink(link, outside.toPath());
} catch (IOException | UnsupportedOperationException | SecurityException e) {
Assume.assumeNoException(e);
}
assertNull(PackDirectoryResolver.resolveExisting(packs, "linked"));
}
}
@@ -0,0 +1,67 @@
/*
* Iris is a World Generator for Minecraft Servers
* Copyright (c) 2026 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 <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.pack;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
public class PackDownloaderTest {
@Test
public void resolvesBranchReference() {
assertEquals(
"https://codeload.github.com/IrisDimensions/overworld/zip/refs/heads/feature/release",
PackDownloader.resolveGithubArchiveUrl("IrisDimensions/overworld", "feature/release")
);
}
@Test
public void resolvesQualifiedHeadReference() {
assertEquals(
"https://codeload.github.com/IrisDimensions/overworld/zip/refs/heads/master",
PackDownloader.resolveGithubArchiveUrl("IrisDimensions/overworld", "refs/heads/master")
);
}
@Test
public void resolvesTagReference() {
assertEquals(
"https://codeload.github.com/IrisDimensions/overworld/zip/refs/tags/v4.0.0",
PackDownloader.resolveGithubArchiveUrl("IrisDimensions/overworld", "refs/tags/v4.0.0")
);
}
@Test
public void resolvesCommitReference() {
assertEquals(
"https://github.com/IrisDimensions/overworld/archive/8e32852ee6ecd039fae27a36f701f57cdc02e83f.zip",
PackDownloader.resolveGithubArchiveUrl("IrisDimensions/overworld", "8e32852ee6ecd039fae27a36f701f57cdc02e83f")
);
}
@Test
public void rejectsUnsafeRepositoryAndReference() {
assertThrows(IllegalArgumentException.class, () -> PackDownloader.resolveGithubArchiveUrl("IrisDimensions/overworld?raw=1", "master"));
assertThrows(IllegalArgumentException.class, () -> PackDownloader.resolveGithubArchiveUrl("../overworld", "master"));
assertThrows(IllegalArgumentException.class, () -> PackDownloader.resolveGithubArchiveUrl("IrisDimensions/overworld", "refs/heads/../master"));
assertThrows(IllegalArgumentException.class, () -> PackDownloader.resolveGithubArchiveUrl("IrisDimensions/overworld", "refs/pull/123/head"));
assertThrows(IllegalArgumentException.class, () -> PackDownloader.resolveGithubArchiveUrl("IrisDimensions/overworld", ""));
}
}
@@ -0,0 +1,250 @@
package art.arcane.iris.core.pack;
import org.junit.Rule;
import org.junit.Test;
import org.junit.Assume;
import org.junit.rules.TemporaryFolder;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermission;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
public class PackResourceCleanupTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void previewIsReadOnlyAndReturnsSortedCandidates() throws Exception {
File pack = createPack("preview");
write(pack, "dimensions/main.json", "{\"biome\":\"nested/used\"}");
write(pack, "biomes/z-last.json", "{}");
write(pack, "biomes/a-first.json", "{}");
write(pack, "biomes/nested/used.json", "{}");
PackResourceCleanup.Preview preview = PackResourceCleanup.preview(pack);
assertTrue(preview.success());
assertTrue(preview.hasCandidates());
assertEquals(List.of("biomes/a-first.json", "biomes/z-last.json"), preview.candidatePaths());
assertFalse(new File(pack, ".iris-trash").exists());
assertTrue(new File(pack, "biomes/a-first.json").isFile());
}
@Test
public void applyRunsFreshScanBeforeMutating() throws Exception {
File pack = createPack("fresh-scan");
write(pack, "dimensions/main.json", "{}");
write(pack, "biomes/kept.json", "{}");
PackResourceCleanup.Preview preview = PackResourceCleanup.preview(pack);
assertEquals(List.of("biomes/kept.json"), preview.candidatePaths());
write(pack, "dimensions/main.json", "{\"biome\":\"kept\"}");
PackResourceCleanup.ApplyResult result = PackResourceCleanup.apply(pack);
assertTrue(result.success());
assertFalse(result.changed());
assertNull(result.quarantinePath());
assertTrue(new File(pack, "biomes/kept.json").isFile());
assertFalse(new File(pack, ".iris-trash").exists());
}
@Test
public void applyCreatesUniqueDumpsAndPreservesRelativePaths() throws Exception {
File pack = createPack("unique-dumps");
write(pack, "dimensions/main.json", "{}");
write(pack, "biomes/nested/first.json", "{\"value\":1}");
PackResourceCleanup.ApplyResult first = PackResourceCleanup.apply(pack);
assertTrue(first.success());
assertEquals(List.of("biomes/nested/first.json"), first.quarantinedPaths());
assertTrue(new File(pack, first.quarantinePath() + "/biomes/nested/first.json").isFile());
write(pack, "biomes/nested/second.json", "{\"value\":2}");
PackResourceCleanup.ApplyResult second = PackResourceCleanup.apply(pack);
assertTrue(second.success());
assertNotEquals(first.quarantinePath(), second.quarantinePath());
assertTrue(new File(pack, first.quarantinePath() + "/biomes/nested/first.json").isFile());
assertTrue(new File(pack, second.quarantinePath() + "/biomes/nested/second.json").isFile());
}
@Test
public void restorePreviewReportsLatestDumpAndAllConflictsWithoutMutation() throws Exception {
File pack = createPack("restore-conflict");
write(pack, "dimensions/main.json", "{}");
write(pack, "biomes/first.json", "{\"value\":1}");
PackResourceCleanup.ApplyResult first = PackResourceCleanup.apply(pack);
write(pack, "biomes/second.json", "{\"value\":2}");
PackResourceCleanup.ApplyResult second = PackResourceCleanup.apply(pack);
write(pack, "biomes/second.json", "{\"replacement\":true}");
PackResourceCleanup.RestorePreview preview = PackResourceCleanup.previewRestore(pack);
assertTrue(preview.success());
assertEquals(second.quarantinePath(), preview.dumpPath());
assertEquals(List.of("biomes/second.json"), preview.filePaths());
assertEquals(List.of("biomes/second.json"), preview.conflicts());
assertTrue(preview.hasConflicts());
assertFalse(preview.canRestore());
assertTrue(new File(pack, second.quarantinePath() + "/biomes/second.json").isFile());
assertTrue(new File(pack, first.quarantinePath() + "/biomes/first.json").isFile());
PackResourceCleanup.RestoreResult result = PackResourceCleanup.restoreLatest(pack);
assertFalse(result.success());
assertEquals(List.of("biomes/second.json"), result.conflicts());
assertTrue(new File(pack, second.quarantinePath() + "/biomes/second.json").isFile());
assertEquals("{\"replacement\":true}", read(pack, "biomes/second.json"));
}
@Test
public void restoreLatestRestoresOnlyNewestDump() throws Exception {
File pack = createPack("restore-latest");
write(pack, "dimensions/main.json", "{}");
write(pack, "biomes/first.json", "{\"value\":1}");
PackResourceCleanup.ApplyResult first = PackResourceCleanup.apply(pack);
write(pack, "biomes/second.json", "{\"value\":2}");
PackResourceCleanup.ApplyResult second = PackResourceCleanup.apply(pack);
PackResourceCleanup.RestoreResult result = PackResourceCleanup.restoreLatest(pack);
assertTrue(result.success());
assertTrue(result.changed());
assertEquals(second.quarantinePath(), result.dumpPath());
assertEquals(List.of("biomes/second.json"), result.restoredPaths());
assertEquals("{\"value\":2}", read(pack, "biomes/second.json"));
assertFalse(new File(pack, "biomes/first.json").exists());
assertTrue(new File(pack, first.quarantinePath() + "/biomes/first.json").isFile());
assertFalse(new File(pack, second.quarantinePath()).exists());
}
@Test
public void restoreCopyFailureRollsBackCreatedDestinations() throws Exception {
File pack = createPack("restore-rollback");
write(pack, "dimensions/main.json", "{}");
write(pack, "biomes/a-readable.json", "{\"value\":1}");
write(pack, "biomes/z-unreadable.json", "{\"value\":2}");
PackResourceCleanup.ApplyResult applied = PackResourceCleanup.apply(pack);
Path unreadable = new File(pack, applied.quarantinePath() + "/biomes/z-unreadable.json").toPath();
Assume.assumeTrue(Files.getFileStore(unreadable).supportsFileAttributeView("posix"));
Set<PosixFilePermission> originalPermissions = Files.getPosixFilePermissions(unreadable);
Files.setPosixFilePermissions(unreadable, Set.of(PosixFilePermission.OWNER_WRITE));
try {
PackResourceCleanup.RestoreResult result = PackResourceCleanup.restoreLatest(pack);
assertFalse(result.success());
assertTrue(result.error().contains("rolled back"));
assertFalse(new File(pack, "biomes/a-readable.json").exists());
assertFalse(new File(pack, "biomes/z-unreadable.json").exists());
assertTrue(new File(pack, applied.quarantinePath() + "/biomes/a-readable.json").isFile());
assertTrue(Files.exists(unreadable));
} finally {
Files.setPosixFilePermissions(unreadable, originalPermissions);
}
}
@Test
public void restoreSourceRemovalFailureRollsBackDestinationsAndPreservesDump() throws Exception {
File pack = createPack("restore-delete-rollback");
write(pack, "dimensions/main.json", "{}");
write(pack, "biomes/a.json", "{\"value\":1}");
write(pack, "biomes/b.json", "{\"value\":2}");
PackResourceCleanup.ApplyResult applied = PackResourceCleanup.apply(pack);
Path quarantineBiomes = new File(pack, applied.quarantinePath() + "/biomes").toPath();
Assume.assumeTrue(Files.getFileStore(quarantineBiomes).supportsFileAttributeView("posix"));
Set<PosixFilePermission> originalPermissions = Files.getPosixFilePermissions(quarantineBiomes);
Files.setPosixFilePermissions(quarantineBiomes, Set.of(
PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_EXECUTE));
try {
PackResourceCleanup.RestoreResult result = PackResourceCleanup.restoreLatest(pack);
assertFalse(result.success());
assertTrue(result.error().contains("source removal failed"));
assertFalse(new File(pack, "biomes/a.json").exists());
assertFalse(new File(pack, "biomes/b.json").exists());
assertTrue(new File(pack, applied.quarantinePath() + "/biomes/a.json").isFile());
assertTrue(new File(pack, applied.quarantinePath() + "/biomes/b.json").isFile());
} finally {
Files.setPosixFilePermissions(quarantineBiomes, originalPermissions);
}
}
@Test
public void cleanupFailsClosedWhenManagedContentContainsSymbolicLink() throws Exception {
File pack = createPack("symlink");
write(pack, "dimensions/main.json", "{}");
File outside = temporaryFolder.newFile("outside.json");
Path link = new File(pack, "biomes/linked.json").toPath();
Files.createDirectories(link.getParent());
try {
Files.createSymbolicLink(link, outside.toPath());
} catch (IOException | UnsupportedOperationException | SecurityException e) {
Assume.assumeNoException(e);
}
PackResourceCleanup.ApplyResult result = PackResourceCleanup.apply(pack);
assertFalse(result.success());
assertFalse(result.changed());
assertTrue(Files.isSymbolicLink(link));
assertFalse(new File(pack, ".iris-trash").exists());
}
@Test
public void concurrentAppliesSerializePerPack() throws Exception {
File pack = createPack("concurrent");
write(pack, "dimensions/main.json", "{}");
write(pack, "biomes/unused.json", "{}");
CountDownLatch start = new CountDownLatch(1);
ExecutorService executor = Executors.newFixedThreadPool(2);
try {
Future<PackResourceCleanup.ApplyResult> first = executor.submit(() -> {
start.await();
return PackResourceCleanup.apply(pack);
});
Future<PackResourceCleanup.ApplyResult> second = executor.submit(() -> {
start.await();
return PackResourceCleanup.apply(pack);
});
start.countDown();
List<PackResourceCleanup.ApplyResult> results = List.of(first.get(), second.get());
assertTrue(results.stream().allMatch(PackResourceCleanup.ApplyResult::success));
assertEquals(1L, results.stream().filter(PackResourceCleanup.ApplyResult::changed).count());
assertFalse(new File(pack, "biomes/unused.json").exists());
} finally {
executor.shutdownNow();
}
}
private File createPack(String name) throws Exception {
return temporaryFolder.newFolder(name);
}
private void write(File pack, String relativePath, String content) throws Exception {
Path path = new File(pack, relativePath).toPath();
Files.createDirectories(path.getParent());
Files.writeString(path, content, StandardCharsets.UTF_8);
}
private String read(File pack, String relativePath) throws Exception {
return Files.readString(new File(pack, relativePath).toPath(), StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,179 @@
package art.arcane.iris.core.pack;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public class PackValidatorCustomBiomeSpawnTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void acceptsExplicitMatchingSpawnGroup() throws Exception {
File biomes = createBiomes("{\"customDerivitives\":[{\"id\":\"swamp\",\"spawns\":[{\"type\":\"minecraft:slime\",\"group\":\"MONSTER\"}]}]}");
List<String> errors = PackValidator.validateCustomBiomeSpawns(
biomes, key -> PackValidator.SpawnCategoryResolution.known("monster"));
assertTrue(errors.isEmpty());
}
@Test
public void acceptsBiomeWithoutCustomSpawns() throws Exception {
File biomes = createBiomes("{\"name\":\"Plains\"}");
List<String> errors = PackValidator.validateCustomBiomeSpawns(
biomes, key -> PackValidator.SpawnCategoryResolution.known("monster"));
assertTrue(errors.isEmpty());
}
@Test
public void rejectsMissingSpawnGroup() throws Exception {
File biomes = createBiomes("{\"customDerivitives\":[{\"id\":\"swamp\",\"spawns\":[{\"type\":\"minecraft:slime\"}]}]}");
List<String> errors = PackValidator.validateCustomBiomeSpawns(
biomes, key -> PackValidator.SpawnCategoryResolution.known("monster"));
assertEquals(1, errors.size());
assertTrue(errors.get(0).contains("must declare group 'MONSTER'"));
}
@Test
public void acceptsImplicitMiscSpawnGroup() throws Exception {
File biomes = createBiomes("{\"customDerivitives\":[{\"id\":\"effects\",\"spawns\":[{\"type\":\"minecraft:armor_stand\"}]}]}");
List<String> errors = PackValidator.validateCustomBiomeSpawns(
biomes, key -> PackValidator.SpawnCategoryResolution.known("misc"));
assertTrue(errors.isEmpty());
}
@Test
public void rejectsSpawnGroupThatDisagreesWithRegistry() throws Exception {
File biomes = createBiomes("{\"customDerivitives\":[{\"id\":\"swamp\",\"spawns\":[{\"type\":\"minecraft:slime\",\"group\":\"MISC\"}]}]}");
List<String> errors = PackValidator.validateCustomBiomeSpawns(
biomes, key -> PackValidator.SpawnCategoryResolution.known("monster"));
assertEquals(1, errors.size());
assertTrue(errors.get(0).contains("live entity registry requires 'MONSTER'"));
}
@Test
public void acceptsAxolotlSpawnCategory() throws Exception {
File biomes = createBiomes("{\"customDerivitives\":[{\"id\":\"cave\",\"spawns\":[{\"type\":\"minecraft:axolotl\",\"group\":\"AXOLOTLS\"}]}]}");
List<String> errors = PackValidator.validateCustomBiomeSpawns(
biomes, key -> PackValidator.SpawnCategoryResolution.known("axolotls"));
assertTrue(errors.isEmpty());
}
@Test
public void rejectsUnknownSpawnEntity() throws Exception {
File biomes = createBiomes("{\"customDerivitives\":[{\"id\":\"swamp\",\"spawns\":[{\"type\":\"missing:entity\",\"group\":\"MISC\"}]}]}");
List<String> errors = PackValidator.validateCustomBiomeSpawns(
biomes, key -> PackValidator.SpawnCategoryResolution.unknown());
assertEquals(1, errors.size());
assertTrue(errors.get(0).contains("unknown entity type 'missing:entity'"));
}
@Test
public void rejectsCaseNormalizedGroupThatRuntimeWouldNotParse() throws Exception {
File biomes = createBiomes("{\"customDerivitives\":[{\"id\":\"swamp\",\"spawns\":[{\"type\":\"minecraft:slime\",\"group\":\"monster\"}]}]}");
List<String> errors = PackValidator.validateCustomBiomeSpawns(
biomes, key -> PackValidator.SpawnCategoryResolution.known("monster"));
assertEquals(1, errors.size());
assertTrue(errors.get(0).contains("unknown group 'monster'"));
}
@Test
public void rejectsWrongCustomDerivativeContainerType() throws Exception {
File biomes = createBiomes("{\"customDerivitives\":{}}");
List<String> errors = PackValidator.validateCustomBiomeSpawns(biomes, null);
assertEquals(1, errors.size());
assertTrue(errors.get(0).contains("customDerivitives must be an array"));
}
@Test
public void rejectsWrongSpawnContainerType() throws Exception {
File biomes = createBiomes("{\"customDerivitives\":[{\"id\":\"swamp\",\"spawns\":{}}]}");
List<String> errors = PackValidator.validateCustomBiomeSpawns(biomes, null);
assertEquals(1, errors.size());
assertTrue(errors.get(0).contains("spawns must be an array"));
}
@Test
public void acceptsNullCustomDerivativeContainerAsAbsent() throws Exception {
File biomes = createBiomes("{\"customDerivitives\":null}");
List<String> errors = PackValidator.validateCustomBiomeSpawns(biomes, null);
assertTrue(errors.isEmpty());
}
@Test
public void acceptsNullSpawnContainerAsAbsent() throws Exception {
File biomes = createBiomes("{\"customDerivitives\":[{\"id\":\"swamp\",\"spawns\":null}]}");
List<String> errors = PackValidator.validateCustomBiomeSpawns(biomes, null);
assertTrue(errors.isEmpty());
}
@Test
public void acceptsNamespacedCustomBiomeTags() throws Exception {
File biomes = createBiomes("{\"customDerivitives\":[{\"id\":\"swamp\",\"tags\":[\"minecraft:allows_surface_slime_spawns\"]}]}");
List<String> errors = PackValidator.validateCustomBiomeSpawns(biomes, null);
assertTrue(errors.isEmpty());
}
@Test
public void rejectsUnsafeCustomBiomeTags() throws Exception {
File biomes = createBiomes("{\"customDerivitives\":[{\"id\":\"swamp\",\"tags\":[\"minecraft:../outside\"]}]}");
List<String> errors = PackValidator.validateCustomBiomeSpawns(biomes, null);
assertEquals(1, errors.size());
assertTrue(errors.get(0).contains("invalid tag"));
}
@Test
public void convertsSpawnCategoryResolverFailureIntoBlockingError() throws Exception {
File biomes = createBiomes("{\"customDerivitives\":[{\"id\":\"swamp\",\"spawns\":[{\"type\":\"minecraft:slime\",\"group\":\"MONSTER\"}]}]}");
List<String> errors = PackValidator.validateCustomBiomeSpawns(biomes, key -> {
throw new IllegalStateException("registry unavailable");
});
assertEquals(1, errors.size());
assertTrue(errors.get(0).contains("spawn category lookup failed"));
assertTrue(errors.get(0).contains("registry unavailable"));
}
private File createBiomes(String json) throws Exception {
File biomes = temporaryFolder.newFolder("biomes");
File biome = new File(biomes, "test.json");
Files.writeString(biome.toPath(), json, StandardCharsets.UTF_8);
return biomes;
}
}
@@ -0,0 +1,59 @@
package art.arcane.iris.core.pack;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.stream.Stream;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class PackValidatorReadOnlyTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void validationDoesNotMoveOrRewritePackFiles() throws Exception {
File pack = temporaryFolder.newFolder("pack");
write(pack, "dimensions/main.json", "{\"regions\":[\"region\"]}");
write(pack, "regions/region.json", "{\"landBiomes\":[\"biome\"]}");
write(pack, "biomes/biome.json", "{\"name\":\"Biome\"}");
write(pack, "generators/unused.json", "{\"name\":\"Unused\"}");
Map<String, byte[]> before = snapshot(pack.toPath());
PackValidationResult result = PackValidator.validate(pack);
assertTrue(result.isLoadable());
assertEquals(before.keySet(), snapshot(pack.toPath()).keySet());
Map<String, byte[]> after = snapshot(pack.toPath());
for (Map.Entry<String, byte[]> entry : before.entrySet()) {
assertArrayEquals(entry.getValue(), after.get(entry.getKey()));
}
assertFalse(new File(pack, ".iris-trash").exists());
}
private void write(File root, String relative, String content) throws Exception {
Path path = root.toPath().resolve(relative);
Files.createDirectories(path.getParent());
Files.writeString(path, content, StandardCharsets.UTF_8);
}
private Map<String, byte[]> snapshot(Path root) throws Exception {
Map<String, byte[]> files = new LinkedHashMap<>();
try (Stream<Path> stream = Files.walk(root)) {
for (Path path : stream.filter(Files::isRegularFile).sorted().toList()) {
files.put(root.relativize(path).toString(), Files.readAllBytes(path));
}
}
return files;
}
}
@@ -0,0 +1,122 @@
package art.arcane.iris.core.pack;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public class PackValidatorSpawnerEntityTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void acceptsNestedEntitiesReferencedByBothSpawnLists() throws Exception {
File pack = temporaryFolder.newFolder("pack");
write(pack, "spawners/frozen/passive.json", """
{
"spawns": [{"entity": "standard/passive/cod"}],
"initialSpawns": [{"entity": "standard/passive/camel"}]
}
""");
write(pack, "entities/standard/passive/cod.json", "{\"type\":\"COD\",\"surface\":\"WATER\"}");
write(pack, "entities/standard/passive/camel.json", "{\"type\":\"CAMEL\"}");
List<String> errors = validate(pack);
assertTrue(errors.isEmpty());
}
@Test
public void rejectsMalformedContainersAndEntries() throws Exception {
File pack = temporaryFolder.newFolder("pack");
write(pack, "spawners/broken.json", """
{
"spawns": null,
"initialSpawns": [7, {}, {"entity": 9}, {"entity": " "}]
}
""");
List<String> errors = validate(pack);
assertEquals(5, errors.size());
assertTrue(errors.stream().anyMatch(error -> error.contains("spawns must be an array")));
assertTrue(errors.stream().anyMatch(error -> error.contains("non-object entry")));
assertTrue(errors.stream().anyMatch(error -> error.contains("without an entity reference")));
assertTrue(errors.stream().anyMatch(error -> error.contains("must be a string")));
assertTrue(errors.stream().anyMatch(error -> error.contains("blank entity reference")));
}
@Test
public void rejectsMissingAndMalformedReferencedEntities() throws Exception {
File pack = temporaryFolder.newFolder("pack");
write(pack, "spawners/broken.json", """
{
"spawns": [
{"entity": "standard/hostile/missing"},
{"entity": "standard/hostile/broken"}
]
}
""");
write(pack, "entities/standard/hostile/broken.json", "not-json");
List<String> errors = validate(pack);
assertEquals(2, errors.size());
assertTrue(errors.stream().anyMatch(error -> error.contains("references missing entity 'standard/hostile/missing'")));
assertTrue(errors.stream().anyMatch(error -> error.contains("references malformed entity 'standard/hostile/broken'")));
}
@Test
public void rejectsMalformedSpawnerJson() throws Exception {
File pack = temporaryFolder.newFolder("pack");
write(pack, "spawners/broken.json", "not-json");
List<String> errors = validate(pack);
assertEquals(1, errors.size());
assertTrue(errors.get(0).contains("Spawner 'broken' has invalid JSON"));
}
@Test
public void rejectsEntityReferenceThatEscapesPack() throws Exception {
File pack = temporaryFolder.newFolder("pack");
write(pack, "spawners/broken.json", "{\"spawns\":[{\"entity\":\"../outside\"}]}");
write(pack, "outside.json", "{\"type\":\"COW\"}");
List<String> errors = validate(pack);
assertEquals(1, errors.size());
assertTrue(errors.get(0).contains("unsafe entity reference '../outside'"));
}
@Test
public void ignoresMalformedEntityThatNoSpawnerReferences() throws Exception {
File pack = temporaryFolder.newFolder("pack");
write(pack, "spawners/passive.json", "{\"spawns\":[{\"entity\":\"standard/passive/cow\"}]}");
write(pack, "entities/standard/passive/cow.json", "{\"type\":\"COW\"}");
write(pack, "entities/unique/unused.json", "not-json");
List<String> errors = validate(pack);
assertTrue(errors.isEmpty());
}
private List<String> validate(File pack) {
return PackValidator.validateSpawnerEntityReferences(
new File(pack, "spawners"), new File(pack, "entities"));
}
private void write(File root, String relative, String content) throws Exception {
Path path = root.toPath().resolve(relative);
Files.createDirectories(path.getParent());
Files.writeString(path, content, StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,64 @@
package art.arcane.iris.core.pack;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class PackValidatorStructureTransformTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void reportsUnsupportedTransformsAcrossEveryStructureHost() throws Exception {
File pack = temporaryFolder.newFolder("pack");
write(pack, "dimensions/main.json", "{\"structures\":[{\"structures\":[\"test\"],\"rotation\":{}}]}");
write(pack, "regions/nested/region.json", "{\"structures\":[{\"structures\":[\"test\"],\"translate\":{}}]}");
write(pack, "biomes/nested/biome.json", "{\"structures\":[{\"structures\":[\"test\"],\"scale\":{}}]}");
List<String> errors = PackValidator.validateUnsupportedStructureTransforms(pack);
assertEquals(List.of(
"Dimension 'main' structures[0] declares unsupported field 'rotation'. Structure placement transforms are not supported; remove the field.",
"Region 'nested/region' structures[0] declares unsupported field 'translate'. Structure placement transforms are not supported; remove the field.",
"Biome 'nested/biome' structures[0] declares unsupported field 'scale'. Structure placement transforms are not supported; remove the field."
), errors);
}
@Test
public void ignoresTransformsOutsideStructurePlacementArrays() throws Exception {
File pack = temporaryFolder.newFolder("pack");
write(pack, "biomes/biome.json", "{\"objects\":[{\"rotation\":{},\"translate\":{},\"scale\":{}}],\"structures\":[{\"structures\":[\"test\"]}]}");
assertTrue(PackValidator.validateUnsupportedStructureTransforms(pack).isEmpty());
}
@Test
public void unsupportedTransformBlocksFullPackValidation() throws Exception {
File pack = temporaryFolder.newFolder("pack");
write(pack, "dimensions/main.json", "{\"regions\":[\"region\"],\"structures\":[{\"structures\":[\"test\"],\"rotation\":null}]}");
write(pack, "regions/region.json", "{\"landBiomes\":[\"biome\"]}");
write(pack, "biomes/biome.json", "{\"name\":\"Biome\"}");
PackValidationResult result = PackValidator.validate(pack);
assertFalse(result.isLoadable());
assertTrue(result.getBlockingErrors().contains(
"Dimension 'main' structures[0] declares unsupported field 'rotation'. Structure placement transforms are not supported; remove the field."));
}
private void write(File root, String relative, String content) throws Exception {
Path path = root.toPath().resolve(relative);
Files.createDirectories(path.getParent());
Files.writeString(path, content, StandardCharsets.UTF_8);
}
}
@@ -10,8 +10,10 @@ import java.io.PrintStream;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class IrisPregeneratorInitTest {
@@ -53,6 +55,35 @@ public class IrisPregeneratorInitTest {
assertTrue(output.contains("Pregen finished: generated=8 total=9 failed=1"));
}
@Test
public void cancellationSummaryDoesNotReportPartialRunAsFinished() {
IrisSettings previousSettings = IrisSettings.settings;
PrintStream previousOut = System.out;
IrisSettings.settings = new IrisSettings();
ByteArrayOutputStream output = new ByteArrayOutputStream();
System.setOut(new PrintStream(output, true, StandardCharsets.UTF_8));
AtomicReference<IrisPregenerator> pregeneratorReference = new AtomicReference<>();
CancelAfterFirstChunkMethod method = new CancelAfterFirstChunkMethod(pregeneratorReference);
PregenTask task = PregenTask.builder()
.center(new Position2(0, 0))
.radiusX(1)
.radiusZ(1)
.build();
try {
IrisPregenerator pregenerator = new IrisPregenerator(task, method, new NoOpPregenListener());
pregeneratorReference.set(pregenerator);
pregenerator.start();
String summary = output.toString(StandardCharsets.UTF_8);
assertTrue(summary.contains("Pregen cancelled: generated=1 total=9 failed=0 remaining=8"));
assertFalse(summary.contains("Pregen finished:"));
} finally {
System.setOut(previousOut);
IrisSettings.settings = previousSettings;
}
}
private String runCompletionOnClose(boolean failLast) {
IrisSettings previousSettings = IrisSettings.settings;
PrintStream previousOut = System.out;
@@ -173,6 +204,55 @@ public class IrisPregeneratorInitTest {
}
}
private static final class CancelAfterFirstChunkMethod implements PregeneratorMethod {
private final AtomicReference<IrisPregenerator> pregeneratorReference;
private boolean cancelled;
private CancelAfterFirstChunkMethod(AtomicReference<IrisPregenerator> pregeneratorReference) {
this.pregeneratorReference = pregeneratorReference;
}
@Override
public void init() {
}
@Override
public void close() {
}
@Override
public void save() {
}
@Override
public boolean supportsRegions(int x, int z, PregenListener listener) {
return false;
}
@Override
public String getMethod(int x, int z) {
return "test";
}
@Override
public void generateRegion(int x, int z, PregenListener listener) {
}
@Override
public void generateChunk(int x, int z, PregenListener listener) {
listener.onChunkGenerated(x, z);
if (!cancelled) {
cancelled = true;
pregeneratorReference.get().close();
}
}
@Override
public Mantle getMantle() {
return null;
}
}
private static final class NoOpPregenListener implements PregenListener {
@Override
public void onTick(double chunksPerSecond, double chunksPerMinute, double regionsPerMinute, double percent, long generated, long totalChunks, long chunksRemaining, long eta, long elapsed, String method, boolean cached) {
@@ -0,0 +1,33 @@
package art.arcane.iris.core.project;
import art.arcane.iris.engine.object.IrisEntitySpawn;
import art.arcane.iris.engine.object.IrisSpawner;
import art.arcane.volmlib.util.collection.KSet;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public class IrisProjectEntityDependencyTest {
@Test
public void collectsInitialOnlyEntityAlongsideNormalSpawnEntityForExport() {
IrisSpawner spawner = new IrisSpawner();
spawner.getSpawns().add(new IrisEntitySpawn().setEntity("standard/passive/cow"));
spawner.getInitialSpawns().add(new IrisEntitySpawn().setEntity("beta/sentinel"));
KSet<IrisSpawner> spawners = new KSet<>();
spawners.add(spawner);
KSet<String> entityKeys = IrisProject.collectSpawnerEntityKeys(spawners);
assertEquals(2, entityKeys.size());
assertTrue(entityKeys.contains("standard/passive/cow"));
assertTrue(entityKeys.contains("beta/sentinel"));
}
@Test
public void returnsNoEntityDependenciesForEmptySpawnerSet() {
KSet<String> entityKeys = IrisProject.collectSpawnerEntityKeys(new KSet<>());
assertTrue(entityKeys.isEmpty());
}
}
@@ -19,6 +19,10 @@
package art.arcane.iris.core.project;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.loader.ResourceLoader;
import art.arcane.iris.engine.object.IrisJigsawPiece;
import art.arcane.iris.engine.object.IrisStructure;
import art.arcane.iris.engine.object.IrisStructurePlacement;
import art.arcane.iris.engine.object.annotations.Desc;
import art.arcane.iris.engine.object.annotations.MaxNumber;
import art.arcane.iris.engine.object.annotations.MinNumber;
@@ -50,6 +54,10 @@ import java.util.List;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class SchemaBuilderParityTest {
private static final List<String> POTION_KEYS = List.of("minecraft:speed", "minecraft:slow_falling", "sniffer_mod:mega_boost");
@@ -108,6 +116,26 @@ public class SchemaBuilderParityTest {
assertEquals(List.of("ALPHA", "BETA"), enumValues(schema.getJSONObject("definitions"), flavorDefinitionKey()));
}
@Test
public void structurePlacementSchemaOmitsUnsupportedTransforms() {
IrisData data = mock(IrisData.class);
ResourceLoader<IrisStructure> structureLoader = mock(ResourceLoader.class);
ResourceLoader<IrisJigsawPiece> pieceLoader = mock(ResourceLoader.class);
when(data.getStructureLoader()).thenReturn(structureLoader);
when(data.getJigsawPieceLoader()).thenReturn(pieceLoader);
when(structureLoader.getPossibleKeys()).thenReturn(new String[0]);
when(pieceLoader.getPossibleKeys()).thenReturn(new String[0]);
JSONObject properties = new SchemaBuilder(IrisStructurePlacement.class, data)
.construct().getJSONObject("properties");
assertTrue(properties.has("structures"));
assertTrue(properties.has("distribution"));
assertFalse(properties.has("rotation"));
assertFalse(properties.has("translate"));
assertFalse(properties.has("scale"));
}
private static String flavorDefinitionKey() {
return "enum-" + Flavor.class.getCanonicalName().replaceAll("\\Q.\\E", "-").toLowerCase();
}
@@ -0,0 +1,37 @@
package art.arcane.iris.core.tools;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
public class IrisToolbeltPackReferenceTest {
@Test
public void plainPackUsesMatchingDimensionKey() {
IrisToolbelt.PackReference reference = IrisToolbelt.parsePackReference("overworld");
assertEquals("overworld", reference.pack());
assertEquals("overworld", reference.dimension());
assertFalse(reference.explicitDimension());
}
@Test
public void explicitDimensionKeepsPackAndDimensionSeparate() {
IrisToolbelt.PackReference reference = IrisToolbelt.parsePackReference(" custom_pack : dimensions/sky ");
assertEquals("custom_pack", reference.pack());
assertEquals("dimensions/sky", reference.dimension());
assertTrue(reference.explicitDimension());
}
@Test
public void malformedReferencesAreRejected() {
assertNull(IrisToolbelt.parsePackReference(null));
assertNull(IrisToolbelt.parsePackReference(""));
assertNull(IrisToolbelt.parsePackReference(":"));
assertNull(IrisToolbelt.parsePackReference("pack:"));
assertNull(IrisToolbelt.parsePackReference(":dimension"));
}
}
@@ -0,0 +1,126 @@
package art.arcane.iris.engine;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.object.IrisGenerator;
import art.arcane.iris.engine.object.IrisInterpolator;
import art.arcane.iris.util.project.interpolation.IrisInterpolation.NoiseBounds;
import art.arcane.iris.util.project.interpolation.IrisInterpolation.NoiseBoundsProvider;
import org.junit.Test;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotEquals;
import static org.mockito.Answers.CALLS_REAL_METHODS;
import static org.mockito.Mockito.mock;
public class IrisComplexGridBoundsCacheTest {
@Test
public void gridBoundsCacheIsIsolatedPerComplex() throws Exception {
IrisComplex first = createComplex();
IrisComplex second = createComplex();
Method cornerBounds = cornerBoundsMethod();
long firstPacked = invokeCornerBounds(first, cornerBounds, new CountingInterpolator(1.25D, 2.5D), 64, -32);
long secondPacked = invokeCornerBounds(second, cornerBounds, new CountingInterpolator(10.25D, 20.5D), 64, -32);
assertNotEquals(firstPacked, secondPacked);
assertEquals(1.25F, unpackLow(firstPacked), 0D);
assertEquals(2.5F, unpackHigh(firstPacked), 0D);
assertEquals(10.25F, unpackLow(secondPacked), 0D);
assertEquals(20.5F, unpackHigh(secondPacked), 0D);
}
@Test
public void gridBoundsCacheReusesCornersWithinSameComplex() throws Exception {
IrisComplex complex = createComplex();
Method cornerBounds = cornerBoundsMethod();
CountingInterpolator interpolator = new CountingInterpolator(3.5D, 7.25D);
long firstPacked = invokeCornerBounds(complex, cornerBounds, interpolator, 128, 96);
long secondPacked = invokeCornerBounds(complex, cornerBounds, interpolator, 128, 96);
assertEquals(firstPacked, secondPacked);
assertEquals(1, interpolator.getInvocations());
}
private IrisComplex createComplex() throws Exception {
IrisComplex complex = mock(IrisComplex.class, CALLS_REAL_METHODS);
Field generatorBounds = IrisComplex.class.getDeclaredField("generatorBounds");
generatorBounds.setAccessible(true);
generatorBounds.set(complex, new HashMap<>());
Class<?> cacheClass = Class.forName("art.arcane.iris.engine.IrisComplex$GridBoundsCache");
Constructor<?> cacheConstructor = cacheClass.getDeclaredConstructor();
cacheConstructor.setAccessible(true);
ThreadLocal<Object> cache = ThreadLocal.withInitial(() -> newCache(cacheConstructor));
Field gridBoundsCache = IrisComplex.class.getDeclaredField("gridBoundsCache");
gridBoundsCache.setAccessible(true);
gridBoundsCache.set(complex, cache);
return complex;
}
private Object newCache(Constructor<?> constructor) {
try {
return constructor.newInstance();
} catch (ReflectiveOperationException e) {
throw new IllegalStateException(e);
}
}
private Method cornerBoundsMethod() throws Exception {
Class<?> cacheClass = Class.forName("art.arcane.iris.engine.IrisComplex$GridBoundsCache");
Method method = IrisComplex.class.getDeclaredMethod(
"cornerBounds",
cacheClass,
Engine.class,
IrisInterpolator.class,
int.class,
Set.class,
int.class,
int.class
);
method.setAccessible(true);
return method;
}
private long invokeCornerBounds(IrisComplex complex, Method method, IrisInterpolator interpolator, int x, int z) throws Exception {
Field gridBoundsCache = IrisComplex.class.getDeclaredField("gridBoundsCache");
gridBoundsCache.setAccessible(true);
ThreadLocal<?> cache = (ThreadLocal<?>) gridBoundsCache.get(complex);
return (long) method.invoke(complex, cache.get(), null, interpolator, 0, Set.<IrisGenerator>of(), x, z);
}
private float unpackLow(long packed) {
return Float.intBitsToFloat((int) (packed >>> 32));
}
private float unpackHigh(long packed) {
return Float.intBitsToFloat((int) packed);
}
private static final class CountingInterpolator extends IrisInterpolator {
private final NoiseBounds bounds;
private final AtomicInteger invocations = new AtomicInteger();
private CountingInterpolator(double low, double high) {
bounds = new NoiseBounds(low, high);
}
@Override
public NoiseBounds interpolateBounds(double x, double z, NoiseBoundsProvider provider) {
invocations.incrementAndGet();
return bounds;
}
private int getInvocations() {
return invocations.get();
}
}
}
@@ -0,0 +1,101 @@
package art.arcane.iris.engine.actuator;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.EngineMetrics;
import art.arcane.iris.engine.framework.SeedManager;
import art.arcane.iris.engine.mantle.EngineMantle;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.spi.IrisPlatform;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.iris.spi.PlatformBiome;
import art.arcane.iris.spi.PlatformBiomeWriter;
import art.arcane.iris.spi.PlatformRegistries;
import art.arcane.iris.util.project.context.ChunkContext;
import art.arcane.iris.util.project.context.ChunkedDataCache;
import art.arcane.iris.util.project.hunk.Hunk;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
import art.arcane.volmlib.util.math.RNG;
import art.arcane.volmlib.util.matter.Matter;
import org.junit.After;
import org.junit.Test;
import java.util.ArrayList;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyDouble;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class IrisBiomeActuatorCoordinateTest {
@After
public void unbindPlatform() {
IrisPlatforms.unbind();
}
@Test
@SuppressWarnings("unchecked")
public void derivativeScatterUsesEachWorldColumnCoordinate() {
bindPlatform();
Engine engine = engine();
IrisBiomeActuator actuator = new IrisBiomeActuator(engine);
Hunk<PlatformBiome> output = mock(Hunk.class);
when(output.getWidth()).thenReturn(2);
when(output.getDepth()).thenReturn(2);
when(output.getHeight()).thenReturn(1);
List<String> samples = new ArrayList<>();
IrisBiome biome = mock(IrisBiome.class);
when(biome.getSkyBiomeKey(any(RNG.class), same(engine), anyDouble(), anyDouble(), anyDouble()))
.thenAnswer(invocation -> {
double worldX = invocation.getArgument(2);
double worldZ = invocation.getArgument(4);
samples.add((int) worldX + "," + (int) worldZ);
return "minecraft:plains";
});
ChunkedDataCache<IrisBiome> biomeCache = mock(ChunkedDataCache.class);
when(biomeCache.get(anyInt(), anyInt())).thenReturn(biome);
ChunkContext context = mock(ChunkContext.class);
when(context.getBiome()).thenReturn(biomeCache);
actuator.onActuate(100, -200, output, false, context);
assertEquals(List.of("100,-200", "100,-199", "101,-200", "101,-199"), samples);
}
@SuppressWarnings("unchecked")
private Engine engine() {
Mantle<Matter> mantle = mock(Mantle.class);
EngineMantle engineMantle = mock(EngineMantle.class);
when(engineMantle.getMantle()).thenReturn(mantle);
SeedManager seedManager = mock(SeedManager.class);
when(seedManager.getBiome()).thenReturn(1337L);
Engine engine = mock(Engine.class);
when(engine.getCacheID()).thenReturn(1);
when(engine.getSeedManager()).thenReturn(seedManager);
when(engine.getMantle()).thenReturn(engineMantle);
when(engine.getMetrics()).thenReturn(new EngineMetrics(16));
return engine;
}
private void bindPlatform() {
IrisPlatforms.unbind();
PlatformBiome biome = mock(PlatformBiome.class);
PlatformBlockState block = mock(PlatformBlockState.class);
PlatformRegistries registries = mock(PlatformRegistries.class);
when(registries.biome(anyString())).thenReturn(biome);
when(registries.block(anyString())).thenReturn(block);
PlatformBiomeWriter biomeWriter = mock(PlatformBiomeWriter.class);
when(biomeWriter.biomeIdFor(anyString())).thenReturn(1);
IrisPlatform platform = mock(IrisPlatform.class);
when(platform.registries()).thenReturn(registries);
when(platform.biomeWriter()).thenReturn(biomeWriter);
IrisPlatforms.bind(platform);
}
}
@@ -0,0 +1,70 @@
package art.arcane.iris.engine.decorator;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.SeedManager;
import art.arcane.iris.engine.object.InferredType;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisDecorationPart;
import art.arcane.iris.engine.object.IrisDecorator;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.iris.util.project.hunk.Hunk;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyDouble;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
public class IrisDecoratorCaveContextTest {
@Test
@SuppressWarnings("unchecked")
public void explicitCaveContextSkipsFluidWithoutMutatingBiome() {
Engine engine = mock(Engine.class);
SeedManager seedManager = mock(SeedManager.class);
doReturn(seedManager).when(engine).getSeedManager();
doReturn(mock(IrisData.class)).when(engine).getData();
IrisDimension dimension = mock(IrisDimension.class);
doReturn(63).when(dimension).getFluidHeight();
doReturn(dimension).when(engine).getDimension();
IrisDecorator decorator = mock(IrisDecorator.class);
doReturn(true).when(decorator).passesChanceGate(any(), anyDouble(), anyDouble(), any());
doReturn(true).when(decorator).isStacking();
doReturn(true).when(decorator).isForcePlace();
doReturn(1).when(decorator).getHeight(any(), anyDouble(), anyDouble(), any());
IrisBiome biome = mock(IrisBiome.class);
doReturn(InferredType.LAND).when(biome).getInferredType();
doReturn(new IrisDecorator[]{decorator}).when(biome).getDecoratorBucket(IrisDecorationPart.NONE);
doReturn(new IrisDecorator[]{decorator}).when(biome).getDecoratorBucket(IrisDecorationPart.CEILING);
PlatformBlockState fluid = mock(PlatformBlockState.class);
doReturn(true).when(fluid).isFluid();
Hunk<PlatformBlockState> output = mock(Hunk.class);
doReturn(128).when(output).getHeight();
doReturn(fluid).when(output).get(0, 10, 0);
IrisSurfaceDecorator surfaceDecorator = new IrisSurfaceDecorator(engine);
surfaceDecorator.decorate(0, 0, 0, 0, 0, 0, 0, 0, output, biome, InferredType.CAVE, 10, 10);
DecoratorCore.PlaceOpts surfaceOptions = DecoratorCore.SCRATCH_OPTS.get();
assertTrue(surfaceOptions.caveSkipFluid);
assertFalse(surfaceOptions.underwater);
IrisCeilingDecorator ceilingDecorator = new IrisCeilingDecorator(engine);
ceilingDecorator.decorate(0, 0, 0, 0, 0, 0, 0, 0, output, biome, InferredType.CAVE, 10, 10);
DecoratorCore.PlaceOpts ceilingOptions = DecoratorCore.SCRATCH_OPTS.get();
assertTrue(ceilingOptions.caveSkipFluid);
assertEquals(InferredType.LAND, biome.getInferredType());
}
@Test
public void nullInferenceRemainsSafeForNormalSurfaceContext() {
assertTrue(IrisSurfaceDecorator.isUnderwater(null, 10, 63));
assertFalse(IrisSurfaceDecorator.skipsFluid(null));
}
}
@@ -0,0 +1,47 @@
package art.arcane.iris.engine.framework;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
public class StructurePlacementMarkerTest {
@Test
public void structureMarkerRoundTripsDelimiterSafeKeys() {
String objectKey = "pieces/room@east:alpha/樹";
String structureKey = "structures/village@night:beta/城";
String encoded = StructurePlacementMarker.encodeStructure(objectKey, 918273, structureKey);
StructurePlacementMarker.Decoded decoded = StructurePlacementMarker.decode(encoded);
assertTrue(encoded.startsWith("@iris-structure:v1:"));
assertEquals(objectKey, decoded.objectKey());
assertEquals(918273, decoded.placementId());
assertEquals(structureKey, decoded.structureKey());
assertTrue(decoded.structureAware());
}
@Test
public void legacyMarkerDecodesWithoutChangingFields() {
StructurePlacementMarker.Decoded decoded = StructurePlacementMarker.decode("objects/oak_tree@42");
assertEquals("objects/oak_tree", decoded.objectKey());
assertEquals(42, decoded.placementId());
assertNull(decoded.structureKey());
assertFalse(decoded.structureAware());
}
@Test
public void malformedAndUnknownMarkersFailSafely() {
assertNull(StructurePlacementMarker.decode(null));
assertNull(StructurePlacementMarker.decode(""));
assertNull(StructurePlacementMarker.decode("objects/oak_tree"));
assertNull(StructurePlacementMarker.decode("objects/oak_tree@not-an-id"));
assertNull(StructurePlacementMarker.decode("objects/oak_tree@42@extra"));
assertNull(StructurePlacementMarker.decode("@iris-structure:v2:b2JqZWN0:42:c3RydWN0dXJl"));
assertNull(StructurePlacementMarker.decode("@iris-structure:v1:not%base64:42:c3RydWN0dXJl"));
assertNull(StructurePlacementMarker.decode("@iris-structure:v1:b2JqZWN0:42:"));
}
}
@@ -37,9 +37,11 @@ import java.util.HashSet;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
import java.util.logging.Logger;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
@@ -117,6 +119,43 @@ public class IrisCaveCarver3DNearParityTest {
assertEquals(firstCapture.carvedLiquids, secondCapture.carvedLiquids);
}
@Test
public void warpCacheDoesNotCrossContaminateCarverInstancesOnSameThread() throws Exception {
Engine engine = createEngine(128, 92);
IrisCaveProfile firstProfile = createProfile(true, false);
firstProfile.setWarpStyle(new IrisGeneratorStyle(NoiseStyle.SIMPLEX).zoomed(0.12D));
IrisCaveProfile secondProfile = createProfile(true, false);
secondProfile.setWarpStyle(new IrisGeneratorStyle(NoiseStyle.CELLULAR).zoomed(0.12D));
IrisCaveProfile controlProfile = createProfile(true, false);
controlProfile.setWarpStyle(new IrisGeneratorStyle(NoiseStyle.CELLULAR).zoomed(0.12D));
IrisCaveCarver3D firstCarver = new IrisCaveCarver3D(engine, firstProfile);
IrisCaveCarver3D secondCarver = new IrisCaveCarver3D(engine, secondProfile);
IrisCaveCarver3D controlCarver = new IrisCaveCarver3D(engine, controlProfile);
int x = 48;
int y = 64;
int z = -352;
double firstDensity = sampleDensity(firstCarver, x, y, z);
double secondDensity = sampleDensity(secondCarver, x, y, z);
AtomicReference<Double> controlDensity = new AtomicReference<>();
AtomicReference<Throwable> controlFailure = new AtomicReference<>();
Thread controlThread = new Thread(() -> {
try {
controlDensity.set(sampleDensity(controlCarver, x, y, z));
} catch (Throwable throwable) {
controlFailure.set(throwable);
}
}, "Iris cave warp cache control");
controlThread.start();
controlThread.join();
if (controlFailure.get() != null) {
throw new AssertionError(controlFailure.get());
}
assertNotEquals(firstDensity, controlDensity.get(), 0D);
assertEquals(controlDensity.get(), secondDensity, 0D);
}
@Test
public void exactPathCarvesChunkEdgesAndRespectsWorldHeightClipping() {
Engine engine = createEngine(48, 46);
@@ -277,6 +316,10 @@ public class IrisCaveCarver3DNearParityTest {
return elapsed;
}
private double sampleDensity(IrisCaveCarver3D carver, int x, int y, int z) throws Exception {
return (double) sampleDensityMethod.invoke(carver, x, y, z);
}
private long runNaiveOnce(IrisCaveCarver3D carver, int chunkX, int chunkZ, double[] columnWeights, IrisRange worldYRange, int[] precomputedSurfaceHeights, int worldHeight) throws Exception {
WriterCapture capture = createWriterCapture(worldHeight);
long start = System.nanoTime();
@@ -0,0 +1,35 @@
package art.arcane.iris.engine.mantle.components;
import art.arcane.iris.spi.PlatformBlockState;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class IrisStructureComponentMarkerTest {
@Test
public void markerFilterAcceptsOnlyStorageContainers() {
PlatformBlockState storage = mock(PlatformBlockState.class);
PlatformBlockState solid = mock(PlatformBlockState.class);
when(storage.isStorageChest()).thenReturn(true);
when(solid.isStorageChest()).thenReturn(false);
assertTrue(IrisStructureComponent.shouldWriteStructureMarker(storage));
assertFalse(IrisStructureComponent.shouldWriteStructureMarker(solid));
assertFalse(IrisStructureComponent.shouldWriteStructureMarker(null));
}
@Test
public void placementIdIsStableAndCoordinateSensitive() {
int first = IrisStructureComponent.structurePlacementId("structures/village", "pieces/house", 20, -14, 35);
int repeated = IrisStructureComponent.structurePlacementId("structures/village", "pieces/house", 20, -14, 35);
int moved = IrisStructureComponent.structurePlacementId("structures/village", "pieces/house", 21, -14, 35);
assertEquals(first, repeated);
assertNotEquals(first, moved);
}
}
@@ -0,0 +1,135 @@
package art.arcane.iris.engine.mantle.components;
import art.arcane.iris.core.nms.container.Pair;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.mantle.MantleComponent;
import art.arcane.iris.engine.mantle.EngineMantle;
import art.arcane.iris.engine.mantle.MatterGenerator;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.spi.IrisPlatform;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.iris.spi.PlatformRegistries;
import art.arcane.iris.util.project.context.ChunkContext;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
import art.arcane.volmlib.util.matter.Matter;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import java.util.ArrayList;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.when;
public class MantleCarvingComponentBoundaryRadiusTest {
@Before
public void bindPlatform() {
IrisPlatforms.unbind();
PlatformBlockState block = mock(PlatformBlockState.class);
PlatformRegistries registries = mock(PlatformRegistries.class);
when(registries.block(anyString())).thenReturn(block);
IrisPlatform platform = mock(IrisPlatform.class);
when(platform.registries()).thenReturn(registries);
IrisPlatforms.bind(platform);
}
@After
public void unbindPlatform() {
IrisPlatforms.unbind();
}
@Test
public void boundaryWallResolutionSchedulesAdjacentCarvingChunks() {
EngineMantle engineMantle = mock(EngineMantle.class);
MantleCarvingComponent component = new MantleCarvingComponent(engineMantle);
assertEquals(1, component.getRadius());
assertEquals(1, Math.ceilDiv(component.getRadius(), 16));
}
@Test
@SuppressWarnings("unchecked")
public void matterGenerationRunsCarvingForAllAdjacentChunks() {
IrisDimension dimension = mock(IrisDimension.class);
when(dimension.isUseMantle()).thenReturn(true);
Mantle<Matter> mantle = mock(Mantle.class);
MantleChunk<Matter> chunk = mock(MantleChunk.class);
when(mantle.getChunk(anyInt(), anyInt())).thenReturn(chunk);
when(chunk.use()).thenReturn(chunk);
doAnswer(invocation -> {
Runnable task = invocation.getArgument(1);
task.run();
return null;
}).when(chunk).raiseFlagSuspend(any(), any(Runnable.class));
EngineMantle engineMantle = mock(EngineMantle.class);
Engine engine = mock(Engine.class);
when(engine.getDimension()).thenReturn(dimension);
when(engine.getMantle()).thenReturn(engineMantle);
when(engineMantle.getEngine()).thenReturn(engine);
List<String> generatedChunks = new ArrayList<>();
MantleCarvingComponent component = spy(new MantleCarvingComponent(engineMantle));
doAnswer(invocation -> {
int chunkX = invocation.getArgument(1);
int chunkZ = invocation.getArgument(2);
generatedChunks.add(chunkX + "," + chunkZ);
return null;
}).when(component).generateLayer(any(), anyInt(), anyInt(), any());
TestMatterGenerator generator = new TestMatterGenerator(engine, mantle, component);
generator.generateMatter(4, -7, false, mock(ChunkContext.class));
assertEquals(List.of(
"3,-8", "3,-7", "3,-6",
"4,-8", "4,-7", "4,-6",
"5,-8", "5,-7", "5,-6"
), generatedChunks);
}
private static final class TestMatterGenerator implements MatterGenerator {
private final Engine engine;
private final Mantle<Matter> mantle;
private final List<Pair<List<MantleComponent>, Integer>> components;
private TestMatterGenerator(Engine engine, Mantle<Matter> mantle, MantleComponent component) {
this.engine = engine;
this.mantle = mantle;
this.components = List.of(new Pair<>(List.of(component), 1));
}
@Override
public Engine getEngine() {
return engine;
}
@Override
public Mantle<Matter> getMantle() {
return mantle;
}
@Override
public int getRadius() {
return 1;
}
@Override
public int getRealRadius() {
return 0;
}
@Override
public List<Pair<List<MantleComponent>, Integer>> getComponents() {
return components;
}
}
}
@@ -0,0 +1,124 @@
package art.arcane.iris.engine.modifier;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.IrisComplex;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.object.InferredType;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisDimensionCarvingResolver;
import art.arcane.iris.engine.object.IrisRegion;
import art.arcane.iris.engine.object.IrisWorld;
import art.arcane.iris.spi.IrisPlatform;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.iris.spi.PlatformRegistries;
import art.arcane.iris.util.project.hunk.Hunk;
import art.arcane.iris.util.project.stream.ProceduralStream;
import art.arcane.volmlib.util.matter.MatterCavern;
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Test;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertSame;
import static org.mockito.ArgumentMatchers.anyDouble;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
public class IrisCarveModifierInferenceIsolationTest {
@BeforeClass
public static void bindPlatform() {
IrisPlatforms.unbind();
PlatformBlockState block = mock(PlatformBlockState.class);
PlatformRegistries registries = mock(PlatformRegistries.class);
doReturn(block).when(registries).block(anyString());
IrisPlatform platform = mock(IrisPlatform.class);
doReturn(registries).when(platform).registries();
IrisPlatforms.bind(platform);
}
@AfterClass
public static void unbindPlatform() {
IrisPlatforms.unbind();
}
@Test
@SuppressWarnings("unchecked")
public void cavePaintingDoesNotMutateSharedBiomeInference() throws Exception {
IrisBiome biome = new IrisBiome().setInferredType(InferredType.LAND);
biome.getLayers().clear();
biome.getCaveCeilingLayers().clear();
Engine engine = mock(Engine.class);
doReturn(mock(IrisData.class)).when(engine).getData();
doReturn(mock(IrisDimension.class)).when(engine).getDimension();
doReturn(mock(IrisComplex.class)).when(engine).getComplex();
doReturn(IrisWorld.builder().minHeight(-256).maxHeight(512).build()).when(engine).getWorld();
IrisCarveModifier modifier = mock(IrisCarveModifier.class, CALLS_REAL_METHODS);
doReturn(engine).when(modifier).getEngine();
Map<String, IrisBiome> customBiomes = new HashMap<>();
customBiomes.put("shared", biome);
Method paintBoundaryZone = IrisCarveModifier.class.getDeclaredMethod(
"paintBoundaryZone",
Hunk.class,
MatterCavern.class,
int.class,
int.class,
int.class,
int.class,
int.class,
int.class,
IrisDimensionCarvingResolver.State.class,
Long2ObjectOpenHashMap.class,
Map.class
);
paintBoundaryZone.setAccessible(true);
paintBoundaryZone.invoke(
modifier,
mock(Hunk.class),
new MatterCavern(true, "shared", (byte) 0),
0,
0,
0,
0,
1,
1,
new IrisDimensionCarvingResolver.State(),
new Long2ObjectOpenHashMap<IrisBiome>(),
customBiomes
);
IrisBiome fallback = new IrisBiome();
ProceduralStream<IrisBiome> landStream = mock(ProceduralStream.class);
doReturn(fallback).when(landStream).get(anyDouble(), anyDouble());
IrisComplex complex = mock(IrisComplex.class, CALLS_REAL_METHODS);
Field landBiomeStream = IrisComplex.class.getDeclaredField("landBiomeStream");
landBiomeStream.setAccessible(true);
landBiomeStream.set(complex, landStream);
IrisRegion region = mock(IrisRegion.class);
doReturn(0D).when(region).getShoreHeight(anyDouble(), anyDouble());
Method fixBiomeType = IrisComplex.class.getDeclaredMethod(
"fixBiomeType",
Double.class,
IrisBiome.class,
IrisRegion.class,
Double.class,
Double.class,
double.class
);
fixBiomeType.setAccessible(true);
IrisBiome resolved = (IrisBiome) fixBiomeType.invoke(complex, 10D, biome, region, 0D, 0D, 0D);
assertSame(biome, resolved);
assertEquals(InferredType.LAND, biome.getInferredType());
}
}
@@ -0,0 +1,29 @@
package art.arcane.iris.engine.object;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
public class IrisBiomeCustomSpawnTest {
@Test
public void normalizesNamespacedEntityKey() {
IrisBiomeCustomSpawn spawn = new IrisBiomeCustomSpawn().setType(" Minecraft:Slime ");
assertEquals("minecraft:slime", spawn.getTypeKey());
}
@Test
public void prefixesBareEntityKey() {
IrisBiomeCustomSpawn spawn = new IrisBiomeCustomSpawn().setType("Slime");
assertEquals("minecraft:slime", spawn.getTypeKey());
}
@Test
public void returnsNullForBlankEntityKey() {
IrisBiomeCustomSpawn spawn = new IrisBiomeCustomSpawn().setType(" ");
assertNull(spawn.getTypeKey());
}
}
@@ -0,0 +1,139 @@
package art.arcane.iris.engine.object;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.SeedManager;
import art.arcane.iris.util.project.noise.CNG;
import art.arcane.volmlib.util.math.RNG;
import org.junit.Test;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertSame;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class IrisBiomeGeneratorSeedIsolationTest {
private static final double SAMPLE_X = 128D;
private static final double SAMPLE_Z = -64D;
@Test
public void engineSeedMakesPaperStyleFirstCallOrderIrrelevant() {
IrisData data = dataWithActiveEngine(new AtomicReference<>(engine(1337L)));
IrisBiome paperFirst = biome(data);
IrisBiome moddedFirst = biome(data);
CNG paperGenerator = paperFirst.getBiomeGenerator(new RNG(8457289L));
CNG moddedGenerator = moddedFirst.getBiomeGenerator(new RNG(-991245L));
assertEquals(paperGenerator.noise(SAMPLE_X, SAMPLE_Z), moddedGenerator.noise(SAMPLE_X, SAMPLE_Z), 0D);
}
@Test
public void distinctEnginesKeepDistinctBiomeGenerators() {
Engine firstEngine = engine(1337L);
Engine secondEngine = engine(7331L);
IrisBiome biome = biome(dataWithActiveEngine(new AtomicReference<>(secondEngine)));
CNG first = biome.getBiomeGenerator(new RNG(1L), firstEngine);
CNG second = biome.getBiomeGenerator(new RNG(1L), secondEngine);
assertNotSame(first, second);
assertNotEquals(first.noise(SAMPLE_X, SAMPLE_Z), second.noise(SAMPLE_X, SAMPLE_Z), 0D);
}
@Test
public void sameSeedEnginesSharingBiomeReuseBiomeGenerator() {
Engine firstEngine = engine(1337L);
Engine secondEngine = engine(1337L);
IrisBiome biome = biome(dataWithActiveEngine(new AtomicReference<>(secondEngine)));
CNG first = biome.getBiomeGenerator(new RNG(11L), firstEngine);
CNG second = biome.getBiomeGenerator(new RNG(99L), secondEngine);
assertSame(first, second);
}
@Test
public void engineLessToolingUsesSuppliedRngSeed() {
IrisBiome biome = biome(null);
CNG first = biome.getBiomeGenerator(new RNG(11L));
CNG sameSeed = biome.getBiomeGenerator(new RNG(11L));
CNG second = biome.getBiomeGenerator(new RNG(99L));
assertSame(first, sameSeed);
assertNotSame(first, second);
assertNotEquals(first.noise(SAMPLE_X, SAMPLE_Z), second.noise(SAMPLE_X, SAMPLE_Z), 0D);
}
@Test
public void concurrentExactEnginesIgnoreLoaderEngine() throws Exception {
Engine firstEngine = engine(1337L);
Engine secondEngine = engine(7331L);
IrisBiome biome = biome(dataWithActiveEngine(new AtomicReference<>(secondEngine)));
CountDownLatch start = new CountDownLatch(1);
ExecutorService executor = Executors.newFixedThreadPool(2);
try {
Future<CNG> firstFuture = executor.submit(() -> repeatedlyResolve(biome, firstEngine, start));
Future<CNG> secondFuture = executor.submit(() -> repeatedlyResolve(biome, secondEngine, start));
start.countDown();
CNG first = firstFuture.get();
CNG second = secondFuture.get();
assertNotSame(first, second);
assertSame(first, biome.getBiomeGenerator(new RNG(99L), firstEngine));
assertSame(second, biome.getBiomeGenerator(new RNG(99L), secondEngine));
} finally {
executor.shutdownNow();
}
}
@Test
public void biomeGeneratorCacheEvictsOldSeeds() {
IrisBiome biome = biome(null);
CNG first = biome.getBiomeGenerator(new RNG(0L));
for (long seed = 1L; seed <= 8L; seed++) {
biome.getBiomeGenerator(new RNG(seed));
}
assertNotSame(first, biome.getBiomeGenerator(new RNG(0L)));
}
private IrisBiome biome(IrisData data) {
IrisBiome biome = new IrisBiome().setName("Scatter Test");
biome.setLoader(data);
return biome;
}
private IrisData dataWithActiveEngine(AtomicReference<Engine> activeEngine) {
IrisData data = mock(IrisData.class);
when(data.getEngine()).thenAnswer(ignored -> activeEngine.get());
return data;
}
private Engine engine(long biomeSeed) {
SeedManager seedManager = mock(SeedManager.class);
when(seedManager.getBiome()).thenReturn(biomeSeed);
Engine engine = mock(Engine.class);
when(engine.getSeedManager()).thenReturn(seedManager);
return engine;
}
private CNG repeatedlyResolve(IrisBiome biome, Engine engine, CountDownLatch start) throws InterruptedException {
start.await();
CNG generator = biome.getBiomeGenerator(new RNG(1L), engine);
for (int index = 0; index < 100; index++) {
assertSame(generator, biome.getBiomeGenerator(new RNG(index + 2L), engine));
}
return generator;
}
}
@@ -0,0 +1,49 @@
package art.arcane.iris.engine.object;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.json.JSONArray;
import art.arcane.volmlib.util.json.JSONObject;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
public class IrisDimensionBiomeTagTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void biomeTagWritesAreSortedAndDeduplicated() throws Exception {
Path output = temporaryFolder.getRoot().toPath().resolve("allows_surface_slime_spawns.json");
IrisDimension.writeBiomeTag(output, "overworld:swamp_b");
IrisDimension.writeBiomeTag(output, "overworld:swamp_a");
IrisDimension.writeBiomeTag(output, "overworld:swamp_b");
JSONObject tag = new JSONObject(Files.readString(output, StandardCharsets.UTF_8));
JSONArray values = tag.getJSONArray("values");
assertFalse(tag.getBoolean("replace"));
assertEquals(2, values.length());
assertEquals("overworld:swamp_a", values.getString(0));
assertEquals("overworld:swamp_b", values.getString(1));
}
@Test
public void customBiomeTagUsesTheMinecraftBiomeTagPath() throws Exception {
KList<String> tags = new KList<>();
tags.add("minecraft:allows_surface_slime_spawns");
IrisDimension.installBiomeTags(temporaryFolder.getRoot(), "overworld:swamp", tags);
Path output = temporaryFolder.getRoot().toPath()
.resolve("iris/data/minecraft/tags/worldgen/biome/allows_surface_slime_spawns.json");
JSONObject tag = new JSONObject(Files.readString(output, StandardCharsets.UTF_8));
assertEquals("overworld:swamp", tag.getJSONArray("values").getString(0));
}
}
@@ -0,0 +1,38 @@
package art.arcane.iris.engine.object;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class IrisStructureLootPlacementTest {
@Test
public void createsPiecePlacementWithAuthoredLootOrderAndDefaultWeights() {
IrisStructure structure = new IrisStructure();
structure.getLoot().add("village_common");
structure.getLoot().add("village_rare");
IrisObjectPlacement placement = structure.createLootPlacement("pieces/village_house");
assertEquals(1, placement.getPlace().size());
assertEquals("pieces/village_house", placement.getPlace().get(0));
assertEquals(2, placement.getLoot().size());
assertEquals("village_common", placement.getLoot().get(0).getName());
assertEquals(1, placement.getLoot().get(0).getWeight());
assertTrue(placement.getLoot().get(0).getFilter().isEmpty());
assertEquals("village_rare", placement.getLoot().get(1).getName());
assertEquals(1, placement.getLoot().get(1).getWeight());
assertTrue(placement.getLoot().get(1).getFilter().isEmpty());
assertFalse(placement.isOverrideGlobalLoot());
}
@Test
public void createsEmptyLootPlacementWhenStructureHasNoLoot() {
IrisObjectPlacement placement = new IrisStructure().createLootPlacement("pieces/empty_house");
assertEquals("pieces/empty_house", placement.getPlace().get(0));
assertTrue(placement.getLoot().isEmpty());
assertFalse(placement.isOverrideGlobalLoot());
}
}