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
+3 -62
View File
@@ -26,65 +26,6 @@ jobs:
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
- name: Run verification gates
run: ./gradlew :core:check :adapters:bukkit:plugin:test :spi:build :probe:deserializationProbe -PuseLocalVolmLib=false --console=plain --stacktrace
build-artifacts:
name: Build ${{ matrix.platform }}
needs: verify
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- platform: bukkit
task: buildBukkit
- platform: fabric
task: buildFabric
- platform: forge
task: buildForge
- platform: neoforge
task: buildNeoforge
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK 25
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '25'
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
- name: Build ${{ matrix.platform }} artifact
run: ./gradlew ${{ matrix.task }} -PuseLocalVolmLib=false --console=plain --stacktrace
- name: Upload ${{ matrix.platform }} jar
uses: actions/upload-artifact@v4
with:
name: iris-${{ matrix.platform }}
path: dist/*.jar
retention-days: 14
if-no-files-found: error
release-bundle:
name: Release bundle (buildAll)
if: startsWith(github.ref, 'refs/tags/v')
needs: [verify, build-artifacts]
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK 25
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '25'
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
- name: Build all platforms
run: ./gradlew buildAll --no-parallel -PuseLocalVolmLib=false --console=plain --stacktrace
- name: Upload release bundle
uses: actions/upload-artifact@v4
with:
name: iris-release-bundle
path: dist/*.jar
retention-days: 90
if-no-files-found: error
run: ./gradlew :core:check :adapters:bukkit:plugin:test :spi:build :probe:test :probe:run :probe:deserializationProbe -PuseLocalVolmLib=false --console=plain --stacktrace
- name: Run modded shared tests
run: ./gradlew -p adapters/fabric test -PuseLocalVolmLib=false --console=plain --stacktrace
+14
View File
@@ -12,6 +12,7 @@ collection/
out/
tools/simd-bench/out/
tools/simd-bench/simd-bench.jar
DataPackExamples/
@@ -25,4 +26,17 @@ adapters/*/run/
plans/
.env
.env.*
!.env.example
local.properties
*.jks
*.p12
*.pfx
*.pem
*.key
*.hprof
credentials.json
service-account*.json
docs/
+11 -4
View File
@@ -73,9 +73,16 @@ Mod (positional arguments):
/iris create myworld overworld 1337
```
`/iris pregen start` pregenerates around spawn; players with the client mod see the native HUD,
everyone else gets a boss bar (modded) or console/status output. `/iris pregen status` reports
progress on the plugin.
Pregeneration requires a radius in blocks. On the plugin, optional arguments are keyed; on modded
servers they are positional and composable:
```
/iris pregen start 352 world=myworld center=0,0 gui=false
/iris pregen start 352 irisworldgen:myworld at 0 0 sync
```
Players with the client mod see the native HUD; everyone else gets a boss bar (modded) or
console/status output. `/iris pregen status` reports progress on the plugin.
## Studio and VSCode workspace
@@ -104,7 +111,7 @@ files hotloads the changes and regenerates the schemas.
Requirements: JDK 25 (set `JAVA_HOME` to it). The Gradle wrapper handles everything else.
```
./gradlew buildAll
./gradlew buildAllToOut
```
builds every platform artifact into `dist/`:
@@ -25,6 +25,9 @@ import org.bukkit.craftbukkit.CraftWorld;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.LinkedHashSet;
import java.util.List;
@@ -40,6 +43,7 @@ public class CustomBiomeSource extends BiomeSource {
private final Registry<Biome> biomeRegistry;
private final AtomicCache<RegistryAccess> registryAccess = new AtomicCache<>();
private final KMap<String, Holder<Biome>> customBiomes;
private final Map<Biome, Holder<Biome>> vanillaSpawnBiomes;
private final Holder<Biome> fallbackBiome;
private final ConcurrentHashMap<Long, Holder<Biome>> noiseBiomeCache = new ConcurrentHashMap<>();
private final ConcurrentHashMap<Long, Holder<Biome>> structureBiomeCache = new ConcurrentHashMap<>();
@@ -51,6 +55,7 @@ public class CustomBiomeSource extends BiomeSource {
this.biomeRegistry = ((RegistryAccess) getFor(RegistryAccess.Frozen.class, ((CraftServer) Bukkit.getServer()).getHandle().getServer())).lookup(Registries.BIOME).orElse(null);
this.fallbackBiome = resolveFallbackBiome(this.biomeRegistry, this.biomeCustomRegistry);
this.customBiomes = fillCustomBiomes(this.biomeCustomRegistry, engine, this.fallbackBiome);
this.vanillaSpawnBiomes = fillVanillaSpawnBiomes(this.biomeCustomRegistry, this.biomeRegistry, engine);
}
private static List<Holder<Biome>> getAllBiomes(Registry<Biome> customRegistry, Registry<Biome> registry, Engine engine, Holder<Biome> fallback) {
@@ -163,6 +168,38 @@ public class CustomBiomeSource extends BiomeSource {
return m;
}
private Map<Biome, Holder<Biome>> fillVanillaSpawnBiomes(Registry<Biome> customRegistry, Registry<Biome> registry, Engine engine) {
IdentityHashMap<Biome, Holder<Biome>> spawnBiomes = new IdentityHashMap<>();
if (customRegistry == null || registry == null) {
return Collections.unmodifiableMap(spawnBiomes);
}
for (IrisBiome irisBiome : engine.getAllBiomes()) {
if (!irisBiome.isCustom()) {
continue;
}
Holder<Biome> vanillaHolder = NMSBinding.biomeToBiomeBase(registry, irisBiome.getVanillaDerivative());
if (vanillaHolder == null) {
continue;
}
for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) {
Holder<Biome> customHolder = resolveCustomBiomeHolder(customRegistry, engine, customBiome.getId());
if (customHolder != null) {
spawnBiomes.putIfAbsent(customHolder.value(), vanillaHolder);
}
}
}
return Collections.unmodifiableMap(spawnBiomes);
}
Holder<Biome> getVanillaSpawnBiome(Holder<Biome> biome) {
if (biome == null) {
return null;
}
return vanillaSpawnBiomes.get(biome.value());
}
private RegistryAccess registry() {
return registryAccess.aquire(() -> (RegistryAccess) getFor(RegistryAccess.Frozen.class, ((CraftServer) Bukkit.getServer()).getHandle().getServer()));
}
@@ -242,8 +279,8 @@ public class CustomBiomeSource extends BiomeSource {
}
org.bukkit.block.Biome vanillaBiome = resolution.underground
? resolution.irisBiome.getGroundBiome(resolution.rng, resolution.blockX, resolution.blockY, resolution.blockZ)
: resolution.irisBiome.getSkyBiome(resolution.rng, resolution.blockX, resolution.blockY, resolution.blockZ);
? resolution.irisBiome.getGroundBiome(resolution.rng, engine, resolution.blockX, resolution.blockY, resolution.blockZ)
: resolution.irisBiome.getSkyBiome(resolution.rng, engine, resolution.blockX, resolution.blockY, resolution.blockZ);
Holder<Biome> holder = NMSBinding.biomeToBiomeBase(biomeRegistry, vanillaBiome);
if (holder != null) {
return holder;
@@ -253,7 +290,7 @@ public class CustomBiomeSource extends BiomeSource {
}
private Holder<Biome> resolveCustomHolder(BiomeResolution resolution) {
IrisBiomeCustom customBiome = resolution.irisBiome.getCustomBiome(resolution.rng, resolution.blockX, resolution.blockY, resolution.blockZ);
IrisBiomeCustom customBiome = resolution.irisBiome.getCustomBiome(resolution.rng, engine, resolution.blockX, resolution.blockY, resolution.blockZ);
if (customBiome != null) {
Holder<Biome> holder = customBiomes.get(customBiome.getId());
if (holder != null) {
@@ -25,6 +25,7 @@ import net.minecraft.world.level.levelgen.WorldgenRandom;
import net.minecraft.world.level.levelgen.XoroshiroRandomSource;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -40,7 +41,9 @@ import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.WorldGenRegion;
import net.minecraft.util.random.Weighted;
import net.minecraft.util.random.WeightedList;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.entity.MobCategory;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
@@ -68,6 +71,7 @@ import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
public class IrisChunkGenerator extends CustomChunkGenerator {
private static final WrappedField<ChunkGenerator, BiomeSource> BIOME_SOURCE;
@@ -75,6 +79,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
private final ChunkGenerator delegate;
private final Engine engine;
private final CustomBiomeSource customBiomeSource;
private final ConcurrentHashMap<SpawnTableKey, WeightedList<MobSpawnSettings.SpawnerData>> mergedSpawnTables = new ConcurrentHashMap<>();
private volatile Set<String> reachableStructureKeysCache;
public IrisChunkGenerator(ChunkGenerator delegate, long seed, Engine engine, World world) {
@@ -225,7 +230,28 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
@Override
public WeightedList<MobSpawnSettings.SpawnerData> getMobsAt(Holder<Biome> holder, StructureManager structuremanager, MobCategory enumcreaturetype, BlockPos blockposition) {
return delegate.getMobsAt(holder, structuremanager, enumcreaturetype, blockposition);
Holder<Biome> vanillaSpawnBiome = customBiomeSource.getVanillaSpawnBiome(holder);
if (vanillaSpawnBiome == null) {
return delegate.getMobsAt(holder, structuremanager, enumcreaturetype, blockposition);
}
WeightedList<MobSpawnSettings.SpawnerData> vanillaSpawns = vanillaSpawnBiome.value().getMobSettings().getMobs(enumcreaturetype);
WeightedList<MobSpawnSettings.SpawnerData> resolvedSpawns = delegate.getMobsAt(
vanillaSpawnBiome, structuremanager, enumcreaturetype, blockposition);
if (resolvedSpawns != vanillaSpawns) {
return resolvedSpawns;
}
WeightedList<MobSpawnSettings.SpawnerData> explicitSpawns = holder.value().getMobSettings().getMobs(enumcreaturetype);
if (explicitSpawns.isEmpty()) {
return vanillaSpawns;
}
if (vanillaSpawns.isEmpty()) {
return explicitSpawns;
}
SpawnTableKey key = new SpawnTableKey(holder.value(), enumcreaturetype);
return mergedSpawnTables.computeIfAbsent(key, ignored -> mergeSpawnTables(vanillaSpawns, explicitSpawns));
}
@Override
@@ -352,6 +378,24 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
delegate.spawnOriginalMobs(regionlimitedworldaccess);
}
private static WeightedList<MobSpawnSettings.SpawnerData> mergeSpawnTables(
WeightedList<MobSpawnSettings.SpawnerData> vanillaSpawns,
WeightedList<MobSpawnSettings.SpawnerData> explicitSpawns) {
List<Weighted<MobSpawnSettings.SpawnerData>> entries = new ArrayList<>(
vanillaSpawns.unwrap().size() + explicitSpawns.unwrap().size());
Set<EntityType<?>> explicitTypes = new HashSet<>();
for (Weighted<MobSpawnSettings.SpawnerData> entry : explicitSpawns.unwrap()) {
explicitTypes.add(entry.value().type());
}
for (Weighted<MobSpawnSettings.SpawnerData> entry : vanillaSpawns.unwrap()) {
if (!explicitTypes.contains(entry.value().type())) {
entries.add(entry);
}
}
entries.addAll(explicitSpawns.unwrap());
return WeightedList.of(entries);
}
@Override
public int getSpawnHeight(LevelHeightAccessor levelheightaccessor) {
return delegate.getSpawnHeight(levelheightaccessor);
@@ -428,4 +472,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
throw new RuntimeException(e);
}
}
private record SpawnTableKey(Biome biome, MobCategory category) {
}
}
@@ -52,6 +52,7 @@ import net.minecraft.core.IdMapper;
import net.minecraft.core.RegistryAccess;
import net.minecraft.core.Registry;
import net.minecraft.core.component.DataComponents;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.core.registries.Registries;
import net.minecraft.nbt.ByteTag;
import net.minecraft.nbt.CollectionTag;
@@ -212,6 +213,16 @@ public class NMSBinding implements INMSBinding {
return !CraftBlockState.class.equals(CraftBlockStates.getBlockStateType(material));
}
@Override
public String getEntitySpawnCategory(String key) {
Identifier identifier = Identifier.parse(key);
if (!BuiltInRegistries.ENTITY_TYPE.containsKey(identifier)) {
throw new IllegalArgumentException("Unknown native entity type: " + key);
}
EntityType<?> type = BuiltInRegistries.ENTITY_TYPE.getValue(identifier);
return type.getCategory().getSerializedName().toLowerCase(Locale.ROOT);
}
@Override
public boolean hasTile(Location l) {
return ((CraftWorld) l.getWorld()).getHandle().getBlockEntity(new BlockPos(l.getBlockX(), l.getBlockY(), l.getBlockZ())) != null;
@@ -1221,7 +1221,10 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
if (!ff.exists()) {
ff.mkdirs();
service(StudioSVC.class).installIntoWorld(getSender(), dim.getLoadKey(), w.worldFolder());
dim = service(StudioSVC.class).installIntoWorld(getSender(), dim, w.worldFolder());
if (dim == null) {
throw new IllegalStateException("Failed to install dimension pack for " + id);
}
}
return new BukkitChunkGenerator(w, false, ff, dim.getLoadKey());
@@ -1243,9 +1246,8 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
for (String reason : result.getBlockingErrors()) {
Iris.error(" - " + reason);
}
} else if (!result.getWarnings().isEmpty() || !result.getRemovedUnusedFiles().isEmpty()) {
} else if (!result.getWarnings().isEmpty()) {
Iris.info("Pack '" + result.getPackName() + "' validated ("
+ result.getRemovedUnusedFiles().size() + " unused file(s) quarantined to .iris-trash/, "
+ result.getWarnings().size() + " warning(s)).");
for (String warning : result.getWarnings()) {
Iris.warn(" [" + result.getPackName() + "] " + warning);
@@ -218,7 +218,7 @@ public class CommandDeveloper implements DirectorExecutor {
Iris.service(StudioSVC.class).downloadSearch(sender(), pack.getLoadKey(), true);
}
Iris.service(StudioSVC.class).installIntoWorld(sender(), pack.getLoadKey(), folder);
Iris.service(StudioSVC.class).installIntoWorld(sender(), pack, folder);
}
@Director(description = "Test")
@@ -135,7 +135,7 @@ public class CommandIris implements DirectorExecutor {
try {
worldCreation = true;
IrisToolbelt.createWorld()
.dimension(dimension.getLoadKey())
.dimension(resolvedType)
.name(name)
.seed(seed)
.sender(sender())
@@ -199,7 +199,7 @@ public class CommandIris implements DirectorExecutor {
sender().sendMessage(C.YELLOW + "Preparing world files and bukkit.yml for next startup...");
File worldFolder = new File(Bukkit.getWorldContainer(), name);
IrisDimension installed = Iris.service(StudioSVC.class).installIntoWorld(sender(), dimension.getLoadKey(), worldFolder);
IrisDimension installed = Iris.service(StudioSVC.class).installIntoWorld(sender(), dimension, worldFolder);
if (installed == null) {
sender().sendMessage(C.RED + "Failed to stage world files for dimension \"" + dimension.getLoadKey() + "\".");
return false;
@@ -587,6 +587,7 @@ public class CommandIris implements DirectorExecutor {
IrisData data = IrisData.get(pack);
for (String key : data.getDimensionLoader().getPossibleKeys()) {
options.add(key);
options.add(pack.getName() + ":" + key);
}
} catch (Throwable ex) {
Iris.warn("Failed to read dimension keys from pack %s: %s%s",
@@ -19,6 +19,8 @@
package art.arcane.iris.core.commands;
import art.arcane.iris.Iris;
import art.arcane.iris.core.pack.PackDirectoryResolver;
import art.arcane.iris.core.pack.PackResourceCleanup;
import art.arcane.iris.core.pack.PackValidationRegistry;
import art.arcane.iris.core.pack.PackValidationResult;
import art.arcane.iris.core.pack.PackValidator;
@@ -29,6 +31,7 @@ import art.arcane.volmlib.util.director.annotations.Director;
import art.arcane.volmlib.util.director.annotations.Param;
import java.io.File;
import java.util.List;
@Director(name = "pack", aliases = {"pk"}, description = "Pack validation and maintenance")
public class CommandPack implements DirectorExecutor {
@@ -61,36 +64,115 @@ public class CommandPack implements DirectorExecutor {
return;
}
File target = new File(packsRoot, pack);
if (!target.isDirectory()) {
File target = PackDirectoryResolver.resolveExisting(packsRoot, pack);
if (target == null) {
s.sendMessage(C.RED + "Pack '" + pack + "' not found under packs/.");
return;
}
runValidate(s, target);
}
@Director(description = "Restore most recent trashed files for a pack", aliases = {"r"})
public void restore(
@Param(description = "The pack folder name to restore")
String pack
@Director(description = "Preview or apply unused-resource cleanup", aliases = {"c"})
public void cleanup(
@Param(description = "The pack folder name to clean")
String pack,
@Param(description = "preview or apply", defaultValue = "preview")
String mode
) {
VolmitSender s = sender();
if (pack == null || pack.isBlank()) {
s.sendMessage(C.RED + "You must specify a pack name.");
File packFolder = findPack(s, pack);
if (packFolder == null) {
return;
}
File packFolder = new File(Iris.instance.getDataFolder("packs"), pack);
if (!packFolder.isDirectory()) {
s.sendMessage(C.RED + "Pack '" + pack + "' not found under packs/.");
if ("apply".equalsIgnoreCase(mode)) {
PackResourceCleanup.ApplyResult result = PackResourceCleanup.apply(packFolder);
if (!result.success()) {
s.sendMessage(C.RED + result.error());
reportPaths(s, result.quarantinedPaths(), "still quarantined");
return;
}
if (!result.changed()) {
s.sendMessage(C.GREEN + "No cleanup candidates found for pack '" + pack + "'.");
return;
}
s.sendMessage(C.GREEN + "Quarantined " + result.quarantinedPaths().size()
+ " cleanup candidate(s) under " + result.quarantinePath() + ".");
reportPaths(s, result.quarantinedPaths(), "quarantined");
return;
}
int restored = PackValidator.restoreTrash(packFolder);
if (restored == 0) {
if (!"preview".equalsIgnoreCase(mode)) {
s.sendMessage(C.RED + "Cleanup mode must be preview or apply.");
return;
}
PackResourceCleanup.Preview preview = PackResourceCleanup.preview(packFolder);
if (!preview.success()) {
s.sendMessage(C.RED + preview.error());
return;
}
if (!preview.hasCandidates()) {
s.sendMessage(C.GREEN + "No cleanup candidates found for pack '" + pack + "'.");
return;
}
s.sendMessage(C.YELLOW + "Cleanup preview for pack '" + pack + "': "
+ preview.candidatePaths().size() + " candidate(s). No files were changed.");
reportPaths(s, preview.candidatePaths(), "candidate");
s.sendMessage(C.GRAY + "Run /iris pack cleanup " + pack + " mode=apply to quarantine these candidates after a fresh scan.");
}
@Director(description = "Preview or apply restoration of the latest quarantine", aliases = {"r"})
public void restore(
@Param(description = "The pack folder name to restore")
String pack,
@Param(description = "preview or apply", defaultValue = "preview")
String mode
) {
VolmitSender s = sender();
File packFolder = findPack(s, pack);
if (packFolder == null) {
return;
}
if ("apply".equalsIgnoreCase(mode)) {
PackResourceCleanup.RestoreResult result = PackResourceCleanup.restoreLatest(packFolder);
if (!result.conflicts().isEmpty()) {
s.sendMessage(C.RED + "Restore refused because " + result.conflicts().size() + " destination(s) already exist.");
reportPaths(s, result.conflicts(), "conflict");
return;
}
if (!result.success()) {
s.sendMessage(C.RED + result.error());
return;
}
if (!result.changed()) {
s.sendMessage(C.YELLOW + "Nothing to restore for pack '" + pack + "'.");
return;
}
s.sendMessage(C.GREEN + "Restored " + result.restoredPaths().size()
+ " file(s) from " + result.dumpPath() + ".");
reportPaths(s, result.restoredPaths(), "restored");
return;
}
if (!"preview".equalsIgnoreCase(mode)) {
s.sendMessage(C.RED + "Restore mode must be preview or apply.");
return;
}
PackResourceCleanup.RestorePreview preview = PackResourceCleanup.previewRestore(packFolder);
if (!preview.success()) {
s.sendMessage(C.RED + preview.error());
return;
}
if (!preview.hasFiles()) {
s.sendMessage(C.YELLOW + "Nothing to restore for pack '" + pack + "'.");
return;
}
s.sendMessage(C.GREEN + "Restored " + restored + " file(s) from the most recent trash dump for pack '" + pack + "'.");
s.sendMessage(C.GRAY + "Re-run /iris pack validate " + pack + " to re-check.");
s.sendMessage(C.YELLOW + "Restore preview for " + preview.dumpPath() + ": "
+ preview.filePaths().size() + " file(s). No files were changed.");
reportPaths(s, preview.filePaths(), "file");
if (!preview.conflicts().isEmpty()) {
s.sendMessage(C.RED + "Restore is blocked by " + preview.conflicts().size() + " existing destination(s).");
reportPaths(s, preview.conflicts(), "conflict");
return;
}
s.sendMessage(C.GRAY + "Run /iris pack restore " + pack + " mode=apply to restore after a fresh conflict check.");
}
@Director(description = "Show cached validation status for a pack", aliases = {"s"})
@@ -108,8 +190,7 @@ public class CommandPack implements DirectorExecutor {
String tag = result.isLoadable() ? (C.GREEN + "OK") : (C.RED + "BROKEN");
s.sendMessage(tag + C.RESET + " " + name
+ C.GRAY + " (blocking=" + result.getBlockingErrors().size()
+ ", warnings=" + result.getWarnings().size()
+ ", trashed=" + result.getRemovedUnusedFiles().size() + ")");
+ ", warnings=" + result.getWarnings().size() + ")");
});
return;
}
@@ -137,8 +218,7 @@ public class CommandPack implements DirectorExecutor {
private void reportResult(VolmitSender s, PackValidationResult result) {
if (result.isLoadable()) {
s.sendMessage(C.GREEN + "Pack '" + result.getPackName() + "' is loadable."
+ C.GRAY + " (warnings=" + result.getWarnings().size()
+ ", trashed=" + result.getRemovedUnusedFiles().size() + ")");
+ C.GRAY + " (warnings=" + result.getWarnings().size() + ")");
} else {
s.sendMessage(C.RED + "Pack '" + result.getPackName() + "' is BROKEN:");
for (String reason : result.getBlockingErrors()) {
@@ -152,12 +232,28 @@ public class CommandPack implements DirectorExecutor {
if (result.getWarnings().size() > wMax) {
s.sendMessage(C.GRAY + " ... and " + (result.getWarnings().size() - wMax) + " more warning(s).");
}
int tMax = Math.min(10, result.getRemovedUnusedFiles().size());
for (int i = 0; i < tMax; i++) {
s.sendMessage(C.GRAY + " ~ trashed " + result.getRemovedUnusedFiles().get(i));
}
private File findPack(VolmitSender sender, String pack) {
if (pack == null || pack.isBlank()) {
sender.sendMessage(C.RED + "You must specify a pack name.");
return null;
}
if (result.getRemovedUnusedFiles().size() > tMax) {
s.sendMessage(C.GRAY + " ... and " + (result.getRemovedUnusedFiles().size() - tMax) + " more trashed file(s).");
File packFolder = PackDirectoryResolver.resolveExisting(Iris.instance.getDataFolder("packs"), pack);
if (packFolder == null) {
sender.sendMessage(C.RED + "Pack '" + pack + "' not found under packs/.");
return null;
}
return packFolder;
}
private void reportPaths(VolmitSender sender, List<String> paths, String label) {
int max = Math.min(10, paths.size());
for (int i = 0; i < max; i++) {
sender.sendMessage(C.GRAY + " - " + label + ": " + paths.get(i));
}
if (paths.size() > max) {
sender.sendMessage(C.GRAY + " ... and " + (paths.size() - max) + " more.");
}
}
}
@@ -85,7 +85,9 @@ public class CommandPregen implements DirectorExecutor {
@Director(description = "Stop the active pregeneration task", aliases = "x")
public void stop() {
if (PregeneratorJob.shutdownInstance()) {
Iris.info( C.BLUE + "Finishing up mca region...");
String message = C.BLUE + "Pregen stop requested; finishing active work before cancellation.";
sender().sendMessage(message);
Iris.info(message);
} else {
sender().sendMessage(C.YELLOW + "No active pregeneration tasks to stop");
}
+10
View File
@@ -55,6 +55,11 @@ sourceSets {
srcDir '../modded-common/src/main/resources'
}
}
test {
java {
srcDir '../modded-common/src/test/java'
}
}
}
repositories {
@@ -102,6 +107,7 @@ configurations.create('jij') {
dependencies {
minecraft("com.mojang:minecraft:${minecraftVersion}")
implementation("net.fabricmc:fabric-loader:${fabricLoaderVersion}")
testImplementation('junit:junit:4.13.2')
List<Object> fabricApi = [
libs.fabricApi.base,
libs.fabricApi.registrySync,
@@ -199,6 +205,10 @@ tasks.named('shadowJar', ShadowJar).configure {
exclude('META-INF/*.RSA')
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
relocate('org.objectweb.asm', 'art.arcane.iris.shadow.asm')
relocate('io.sentry', 'art.arcane.iris.shadow.sentry')
relocate('com.sun.jna', 'art.arcane.iris.shadow.jna')
relocate('oshi', 'art.arcane.iris.shadow.oshi')
relocate('net.jpountz', 'art.arcane.iris.shadow.jpountz')
exclude('oshi.properties')
from(project.configurations.named('jij')) {
into('META-INF/jars')
+1
View File
@@ -227,6 +227,7 @@ tasks.named('shadowJar', ShadowJar).configure {
exclude('org/apache/commons/lang/enum/**')
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
relocate('org.objectweb.asm', 'art.arcane.iris.shadow.asm')
relocate('io.sentry', 'art.arcane.iris.shadow.sentry')
relocate('com.sun.jna', 'art.arcane.iris.shadow.jna')
relocate('oshi', 'art.arcane.iris.shadow.oshi')
relocate('net.jpountz', 'art.arcane.iris.shadow.jpountz')
@@ -0,0 +1,159 @@
/*
* 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.modded;
import java.util.ArrayDeque;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.function.LongSupplier;
final class InitialSpawnQueue {
private static final long DEFAULT_MAX_AGE_NANOS = TimeUnit.MINUTES.toNanos(5);
private final int capacity;
private final long maxAgeNanos;
private final LongSupplier nanoTime;
private final ArrayDeque<Long> queue;
private final Map<Long, Long> pending;
private final Set<Long> queued;
private boolean closed;
InitialSpawnQueue(int capacity) {
this(capacity, DEFAULT_MAX_AGE_NANOS, System::nanoTime);
}
InitialSpawnQueue(int capacity, long maxAgeNanos, LongSupplier nanoTime) {
if (capacity < 1) {
throw new IllegalArgumentException("capacity must be positive");
}
if (maxAgeNanos < 1L) {
throw new IllegalArgumentException("maxAgeNanos must be positive");
}
this.capacity = capacity;
this.maxAgeNanos = maxAgeNanos;
this.nanoTime = nanoTime;
this.queue = new ArrayDeque<>(Math.min(capacity, 256));
this.pending = new HashMap<>();
this.queued = new HashSet<>();
}
synchronized boolean offer(long key) {
if (closed || pending.containsKey(key)) {
return false;
}
if (pending.size() >= capacity) {
expire(nanoTime.getAsLong());
}
if (pending.size() >= capacity) {
return false;
}
pending.put(key, nanoTime.getAsLong());
queued.add(key);
queue.addLast(key);
return true;
}
synchronized int batchSize(int limit) {
if (closed || limit <= 0) {
return 0;
}
return Math.min(limit, queue.size());
}
synchronized Long poll() {
long now = nanoTime.getAsLong();
Long key;
while ((key = queue.pollFirst()) != null) {
queued.remove(key);
Long offeredAt = pending.get(key);
if (offeredAt == null) {
continue;
}
if (expired(offeredAt, now)) {
pending.remove(key);
continue;
}
return key;
}
return null;
}
synchronized void retry(long key) {
Long offeredAt = pending.get(key);
if (closed || offeredAt == null || expired(offeredAt, nanoTime.getAsLong())) {
pending.remove(key);
queued.remove(key);
return;
}
if (!queued.add(key)) {
return;
}
queue.addLast(key);
}
synchronized void complete(long key) {
pending.remove(key);
if (queued.remove(key)) {
queue.removeFirstOccurrence(key);
}
}
synchronized boolean isEmpty() {
return queue.isEmpty();
}
synchronized int size() {
return pending.size();
}
synchronized void clear() {
queue.clear();
pending.clear();
queued.clear();
}
synchronized void close() {
closed = true;
clear();
}
private void expire(long now) {
Set<Long> expired = new HashSet<>();
for (Map.Entry<Long, Long> entry : pending.entrySet()) {
if (expired(entry.getValue(), now)) {
expired.add(entry.getKey());
}
}
if (expired.isEmpty()) {
return;
}
for (Long key : expired) {
pending.remove(key);
queued.remove(key);
}
queue.removeIf(expired::contains);
}
private boolean expired(long offeredAt, long now) {
return now - offeredAt >= maxAgeNanos;
}
}
@@ -20,6 +20,8 @@ package art.arcane.iris.modded;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.GenerationSessionException;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisBiomeCustom;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.PlatformBiome;
import art.arcane.iris.spi.PlatformBlockState;
@@ -38,6 +40,8 @@ import net.minecraft.resources.ResourceKey;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.WorldGenRegion;
import net.minecraft.util.random.WeightedList;
import net.minecraft.world.entity.MobCategory;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.LevelHeightAccessor;
@@ -49,6 +53,7 @@ import net.minecraft.world.level.biome.BiomeManager;
import net.minecraft.world.level.biome.BiomeResolver;
import net.minecraft.world.level.biome.BiomeSource;
import net.minecraft.world.level.biome.Climate;
import net.minecraft.world.level.biome.MobSpawnSettings;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.ChunkAccess;
@@ -65,6 +70,7 @@ import org.slf4j.LoggerFactory;
import java.util.Arrays;
import java.util.EnumSet;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
@@ -136,8 +142,11 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
private final String defaultPack;
private final String defaultDimensionKey;
private final ConcurrentHashMap<String, Holder<Biome>> biomeHolders = new ConcurrentHashMap<>();
private final ConcurrentHashMap<Biome, Holder<Biome>> vanillaSpawnBiomes = new ConcurrentHashMap<>();
private final ConcurrentHashMap<SpawnTableKey, WeightedList<MobSpawnSettings.SpawnerData>> mergedSpawnTables = new ConcurrentHashMap<>();
private final Set<String> missingBiomeWarnings = ConcurrentHashMap.newKeySet();
private final AtomicBoolean announced = new AtomicBoolean(false);
private volatile boolean vanillaSpawnBiomesInitialized;
private volatile Engine engine;
private volatile String activePack;
private volatile String activeDimensionKey;
@@ -166,6 +175,7 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
this.announced.set(false);
this.biomeHolders.clear();
this.missingBiomeWarnings.clear();
resetVanillaSpawnBiomes();
}
public synchronized void unbindEngine() {
@@ -177,6 +187,7 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
this.announced.set(false);
this.biomeHolders.clear();
this.missingBiomeWarnings.clear();
resetVanillaSpawnBiomes();
}
public synchronized void resetToDefault() {
@@ -259,6 +270,82 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
public void onHotload() {
biomeHolders.clear();
missingBiomeWarnings.clear();
resetVanillaSpawnBiomes();
}
@Override
public WeightedList<MobSpawnSettings.SpawnerData> getMobsAt(
Holder<Biome> biome, StructureManager structureManager, MobCategory category, BlockPos pos) {
WeightedList<MobSpawnSettings.SpawnerData> explicitSpawns = biome.value().getMobSettings().getMobs(category);
WeightedList<MobSpawnSettings.SpawnerData> resolvedSpawns = super.getMobsAt(
biome, structureManager, category, pos);
if (resolvedSpawns != explicitSpawns) {
return resolvedSpawns;
}
Registry<Biome> registry = structureManager.registryAccess().lookupOrThrow(Registries.BIOME);
initializeVanillaSpawnBiomes(registry);
Holder<Biome> vanillaSpawnBiome = vanillaSpawnBiomes.get(biome.value());
if (vanillaSpawnBiome == null) {
return explicitSpawns;
}
WeightedList<MobSpawnSettings.SpawnerData> vanillaSpawns = vanillaSpawnBiome.value().getMobSettings().getMobs(category);
if (explicitSpawns.isEmpty()) {
return vanillaSpawns;
}
if (vanillaSpawns.isEmpty()) {
return explicitSpawns;
}
SpawnTableKey key = new SpawnTableKey(biome.value(), category);
return mergedSpawnTables.computeIfAbsent(key, ignored -> NativeSpawnTableMerger.merge(vanillaSpawns, explicitSpawns));
}
private synchronized void initializeVanillaSpawnBiomes(Registry<Biome> registry) {
if (vanillaSpawnBiomesInitialized) {
return;
}
Engine current = engineOrNull();
if (current == null) {
return;
}
String namespace = current.getDimension().getLoadKey().toLowerCase(Locale.ROOT);
for (IrisBiome irisBiome : current.getDimension().getAllBiomes(current)) {
if (irisBiome == null || !irisBiome.isCustom()) {
continue;
}
Holder<Biome> vanillaHolder = resolveBiomeHolder(registry, irisBiome.getVanillaDerivativeKey());
if (vanillaHolder == null) {
continue;
}
for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) {
Holder<Biome> customHolder = resolveBiomeHolder(registry, namespace + ":" + customBiome.getId());
if (customHolder != null) {
vanillaSpawnBiomes.putIfAbsent(customHolder.value(), vanillaHolder);
}
}
}
vanillaSpawnBiomesInitialized = true;
}
private Holder<Biome> resolveBiomeHolder(Registry<Biome> registry, String key) {
if (key == null || key.isBlank()) {
return null;
}
Identifier identifier = Identifier.tryParse(key);
if (identifier == null) {
return null;
}
Optional<Holder.Reference<Biome>> reference = registry.get(identifier);
return reference.<Holder<Biome>>map((Holder.Reference<Biome> value) -> value).orElse(null);
}
private synchronized void resetVanillaSpawnBiomes() {
vanillaSpawnBiomes.clear();
mergedSpawnTables.clear();
vanillaSpawnBiomesInitialized = false;
}
@Override
@@ -491,4 +578,7 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
public void addDebugScreenInfo(List<String> info, RandomState randomState, BlockPos pos) {
info.add("Iris dimension: " + dimensionKey);
}
private record SpawnTableKey(Biome biome, MobCategory category) {
}
}
@@ -21,6 +21,7 @@ package art.arcane.iris.modded;
import art.arcane.iris.spi.PlatformEntityType;
import net.minecraft.world.entity.EntityType;
import java.util.Locale;
import java.util.concurrent.ConcurrentHashMap;
public final class ModdedEntityType implements PlatformEntityType {
@@ -51,6 +52,11 @@ public final class ModdedEntityType implements PlatformEntityType {
return namespace;
}
@Override
public String spawnCategory() {
return type.getCategory().getSerializedName().toLowerCase(Locale.ROOT);
}
@Override
public Object nativeHandle() {
return type;
@@ -88,8 +88,8 @@ public final class ModdedStartup {
for (String reason : result.getBlockingErrors()) {
LOGGER.error(" - {}", reason);
}
} else if (!result.getWarnings().isEmpty() || !result.getRemovedUnusedFiles().isEmpty()) {
LOGGER.info("Iris pack '{}' validated ({} unused file(s) quarantined to .iris-trash/, {} warning(s)).", result.getPackName(), result.getRemovedUnusedFiles().size(), result.getWarnings().size());
} else if (!result.getWarnings().isEmpty()) {
LOGGER.info("Iris pack '{}' validated ({} warning(s)).", result.getPackName(), result.getWarnings().size());
for (String warning : result.getWarnings()) {
LOGGER.warn(" [{}] {}", result.getPackName(), warning);
}
@@ -40,49 +40,85 @@ import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
public final class ModdedWorldCheck {
private static final int EXIT_PASS = 0;
private static final int EXIT_FAILURE = 1;
private static final long SERVER_WAIT_TIMEOUT_MILLIS = 600000L;
private static final long SERVER_WAIT_INTERVAL_MILLIS = 250L;
private static final Logger LOGGER = LoggerFactory.getLogger("Iris");
private static final ProcessExit PROCESS_EXIT = Runtime.getRuntime()::exit;
private ModdedWorldCheck() {
}
public static void schedule() {
Thread thread = new Thread(ModdedWorldCheck::waitAndRun, "Iris World Check");
thread.setDaemon(true);
thread.start();
coordinatorThread(() -> waitAndRun(PROCESS_EXIT)).start();
}
private static void waitAndRun() {
static Thread coordinatorThread(Runnable coordinator) {
Thread thread = new Thread(coordinator, "Iris World Check");
thread.setDaemon(false);
return thread;
}
private static void waitAndRun(ProcessExit processExit) {
long start = System.currentTimeMillis();
MinecraftServer server = null;
while (System.currentTimeMillis() - start < 600000L) {
MinecraftServer candidate = ModdedEngineBootstrap.currentServer();
if (candidate != null && candidate.isReady()) {
server = candidate;
break;
boolean ready = false;
int exitCode = EXIT_FAILURE;
try {
while (System.currentTimeMillis() - start < SERVER_WAIT_TIMEOUT_MILLIS) {
MinecraftServer candidate = ModdedEngineBootstrap.currentServer();
if (candidate != null) {
server = candidate;
if (candidate.isReady()) {
ready = true;
break;
}
}
Thread.sleep(SERVER_WAIT_INTERVAL_MILLIS);
}
try {
Thread.sleep(250L);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
if (!ready) {
LOGGER.error("[worldcheck] server did not become ready within 10 minutes");
return;
}
}
if (server == null) {
LOGGER.error("[worldcheck] server did not become ready within 10 minutes");
return;
}
MinecraftServer serverRef = server;
AtomicBoolean pass = new AtomicBoolean(false);
try {
MinecraftServer serverRef = server;
AtomicBoolean pass = new AtomicBoolean(false);
serverRef.submit(() -> pass.set(run(serverRef))).join();
exitCode = pass.get() ? EXIT_PASS : EXIT_FAILURE;
} catch (InterruptedException e) {
LOGGER.error("[worldcheck] coordinator interrupted", e);
Thread.currentThread().interrupt();
} catch (Throwable e) {
LOGGER.error("[worldcheck] check failed", e);
} finally {
int resultCode = exitCode;
LOGGER.info("[worldcheck] shutting down dev server (result={})", resultCode == EXIT_PASS ? "PASS" : "FAIL");
MinecraftServer serverRef = server;
stopAndExit(serverRef == null ? null : () -> serverRef.halt(true), resultCode, processExit);
}
}
LOGGER.info("[worldcheck] shutting down dev server (result={})", pass.get() ? "PASS" : "FAIL");
serverRef.halt(false);
static void stopAndExit(Runnable serverStop, int requestedExitCode, ProcessExit processExit) {
int exitCode = requestedExitCode;
boolean interrupted = Thread.interrupted();
if (interrupted) {
exitCode = EXIT_FAILURE;
}
try {
if (serverStop != null) {
serverStop.run();
}
} catch (Throwable e) {
exitCode = EXIT_FAILURE;
LOGGER.error("[worldcheck] server shutdown failed", e);
} finally {
if (interrupted) {
Thread.currentThread().interrupt();
}
}
processExit.exit(exitCode);
}
private static boolean run(MinecraftServer server) {
@@ -186,4 +222,9 @@ public final class ModdedWorldCheck {
throw new IllegalStateException(e);
}
}
@FunctionalInterface
interface ProcessExit {
void exit(int status);
}
}
@@ -57,88 +57,167 @@ import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.event.player.PlayerTeleportEvent;
import java.util.HashSet;
import java.util.Queue;
import java.util.Set;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public final class ModdedWorldManager implements EngineWorldManager {
private static final ConcurrentHashMap<Engine, Queue<Long>> INITIAL_QUEUES = new ConcurrentHashMap<>();
private static final int MAX_INITIAL_QUEUE = 8192;
private static final int MAX_INITIAL_DRAIN_PER_TICK = 8;
private static final int MAX_INITIAL_RECOVERY_PER_PASS = 128;
private static final int MANTLE_WARMUP_QUEUE_CAPACITY = 256;
private static final long INITIAL_RECOVERY_INTERVAL_MS = 1_000L;
private static final long COUNT_INTERVAL_MS = 3_000L;
private static final int ENTITY_SCAN_RADIUS = 64;
private static final int PLAYER_CHUNK_RADIUS = 4;
private static final int MIN_TICK_INTERVAL_MS = 1_000;
private final Engine engine;
private final InitialSpawnQueue initialSpawnQueue;
private final Set<Long> mantleWarmups;
private final ThreadPoolExecutor mantleWarmupExecutor;
private long lastAmbientAt;
private long lastCountAt;
private long lastInitialRecoveryAt;
private volatile boolean closed;
private volatile int cachedEntityCount;
private volatile int cachedConsideredChunks;
private volatile double cachedSaturation;
public ModdedWorldManager(Engine engine) {
this.engine = engine;
INITIAL_QUEUES.putIfAbsent(engine, new ConcurrentLinkedQueue<>());
this.initialSpawnQueue = new InitialSpawnQueue(MAX_INITIAL_QUEUE);
this.mantleWarmups = ConcurrentHashMap.newKeySet();
BlockingQueue<Runnable> warmupQueue = new ArrayBlockingQueue<>(MANTLE_WARMUP_QUEUE_CAPACITY);
this.mantleWarmupExecutor = new ThreadPoolExecutor(
1,
1,
30L,
TimeUnit.SECONDS,
warmupQueue,
runnable -> {
Thread thread = new Thread(runnable, "Iris Initial Spawn Mantle Warmup");
thread.setDaemon(true);
thread.setPriority(Thread.MIN_PRIORITY);
return thread;
},
new ThreadPoolExecutor.AbortPolicy());
this.mantleWarmupExecutor.allowCoreThreadTimeOut(true);
}
public static void enqueueGenerated(Engine engine, int chunkX, int chunkZ) {
if (engine == null) {
if (engine == null || engine.isClosed()) {
return;
}
Queue<Long> queue = INITIAL_QUEUES.get(engine);
if (queue == null || queue.size() >= MAX_INITIAL_QUEUE) {
return;
EngineWorldManager worldManager = engine.getWorldManager();
if (worldManager instanceof ModdedWorldManager moddedWorldManager) {
moddedWorldManager.initialSpawnQueue.offer(pack(chunkX, chunkZ));
}
queue.add(pack(chunkX, chunkZ));
}
public void serverTick(ServerLevel level) {
if (engine.isClosed() || engine.getMantle().getMantle().isClosed()) {
if (closed || engine.isClosed() || engine.getMantle().getMantle().isClosed()) {
return;
}
if (isPregenActive()) {
return;
}
recoverLoadedInitialSpawns(level);
drainInitialSpawns(level);
ambientTick(level);
}
private void drainInitialSpawns(ServerLevel level) {
Queue<Long> queue = INITIAL_QUEUES.get(engine);
if (queue == null || queue.isEmpty()) {
return;
}
private void recoverLoadedInitialSpawns(ServerLevel level) {
if (!markerSystemEnabled() && !ambientSystemEnabled()) {
queue.clear();
return;
}
long now = System.currentTimeMillis();
if (now - lastInitialRecoveryAt < INITIAL_RECOVERY_INTERVAL_MS) {
return;
}
lastInitialRecoveryAt = now;
int budget = MAX_INITIAL_DRAIN_PER_TICK;
while (budget-- > 0) {
Long key = queue.poll();
if (key == null) {
return;
Set<Long> candidates = new HashSet<>();
for (ServerPlayer player : level.players()) {
int centerX = player.blockPosition().getX() >> 4;
int centerZ = player.blockPosition().getZ() >> 4;
for (int dx = -1; dx <= 1; dx++) {
for (int dz = -1; dz <= 1; dz++) {
candidates.add(pack(centerX + dx, centerZ + dz));
}
}
}
candidates.addAll(level.getForceLoadedChunks());
Mantle<Matter> mantle = engine.getMantle().getMantle();
int recovered = 0;
for (long key : candidates) {
int chunkX = unpackX(key);
int chunkZ = unpackZ(key);
if (level.getChunkSource().getChunkNow(chunkX, chunkZ) == null) {
continue;
}
try {
initialSpawnChunk(level, chunkX, chunkZ);
} catch (Throwable e) {
IrisLogging.reportError(e);
if (mantle.isChunkLoaded(chunkX, chunkZ) && mantle.hasFlag(chunkX, chunkZ, MantleFlag.INITIAL_SPAWNED)) {
continue;
}
if (initialSpawnQueue.offer(key) && ++recovered >= MAX_INITIAL_RECOVERY_PER_PASS) {
return;
}
}
}
private void initialSpawnChunk(ServerLevel level, int chunkX, int chunkZ) {
Mantle<Matter> mantle = engine.getMantle().getMantle();
if (!mantle.isChunkLoaded(chunkX, chunkZ) || mantle.hasFlag(chunkX, chunkZ, MantleFlag.INITIAL_SPAWNED)) {
private void drainInitialSpawns(ServerLevel level) {
if (initialSpawnQueue.isEmpty()) {
return;
}
if (!markerSystemEnabled() && !ambientSystemEnabled()) {
initialSpawnQueue.clear();
return;
}
int budget = initialSpawnQueue.batchSize(MAX_INITIAL_DRAIN_PER_TICK);
while (budget-- > 0) {
Long key = initialSpawnQueue.poll();
if (key == null) {
return;
}
int chunkX = unpackX(key);
int chunkZ = unpackZ(key);
boolean retry = false;
try {
if (level.getChunkSource().getChunkNow(chunkX, chunkZ) == null) {
retry = true;
continue;
}
if (!initialSpawnChunk(level, chunkX, chunkZ)) {
retry = true;
warmupMantleChunkAsync(key, chunkX, chunkZ);
}
} catch (Throwable e) {
IrisLogging.reportError(e);
retry = true;
} finally {
if (retry) {
initialSpawnQueue.retry(key);
} else {
initialSpawnQueue.complete(key);
}
}
}
}
private boolean initialSpawnChunk(ServerLevel level, int chunkX, int chunkZ) {
Mantle<Matter> mantle = engine.getMantle().getMantle();
if (!mantle.isChunkLoaded(chunkX, chunkZ)) {
return false;
}
if (mantle.hasFlag(chunkX, chunkZ, MantleFlag.INITIAL_SPAWNED)) {
return true;
}
MantleChunk<Matter> chunk = mantle.getChunk(chunkX, chunkZ).use();
try {
@@ -153,6 +232,33 @@ public final class ModdedWorldManager implements EngineWorldManager {
} finally {
chunk.release();
}
return true;
}
private void warmupMantleChunkAsync(long key, int chunkX, int chunkZ) {
if (closed || !mantleWarmups.add(key)) {
return;
}
try {
mantleWarmupExecutor.execute(() -> warmupMantleChunk(key, chunkX, chunkZ));
} catch (RejectedExecutionException e) {
mantleWarmups.remove(key);
}
}
private void warmupMantleChunk(long key, int chunkX, int chunkZ) {
try {
Mantle<Matter> mantle = engine.getMantle().getMantle();
if (!closed && !engine.isClosed() && !mantle.isClosed() && !mantle.isChunkLoaded(chunkX, chunkZ)) {
mantle.getChunk(chunkX, chunkZ);
}
} catch (Throwable e) {
if (!closed && !engine.isClosed()) {
IrisLogging.reportError(e);
}
} finally {
mantleWarmups.remove(key);
}
}
private void ambientTick(ServerLevel level) {
@@ -575,7 +681,10 @@ public final class ModdedWorldManager implements EngineWorldManager {
@Override
public void close() {
INITIAL_QUEUES.remove(engine);
closed = true;
initialSpawnQueue.close();
mantleWarmupExecutor.shutdownNow();
mantleWarmups.clear();
}
@Override
@@ -0,0 +1,34 @@
package art.arcane.iris.modded;
import net.minecraft.util.random.Weighted;
import net.minecraft.util.random.WeightedList;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.level.biome.MobSpawnSettings;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
final class NativeSpawnTableMerger {
private NativeSpawnTableMerger() {
}
static WeightedList<MobSpawnSettings.SpawnerData> merge(
WeightedList<MobSpawnSettings.SpawnerData> vanillaSpawns,
WeightedList<MobSpawnSettings.SpawnerData> explicitSpawns) {
List<Weighted<MobSpawnSettings.SpawnerData>> entries = new ArrayList<>(
vanillaSpawns.unwrap().size() + explicitSpawns.unwrap().size());
Set<EntityType<?>> explicitTypes = new HashSet<>();
for (Weighted<MobSpawnSettings.SpawnerData> entry : explicitSpawns.unwrap()) {
explicitTypes.add(entry.value().type());
}
for (Weighted<MobSpawnSettings.SpawnerData> entry : vanillaSpawns.unwrap()) {
if (!explicitTypes.contains(entry.value().type())) {
entries.add(entry);
}
}
entries.addAll(explicitSpawns.unwrap());
return WeightedList.of(entries);
}
}
@@ -131,7 +131,8 @@ final class ModdedCommandHelp {
SECTIONS.put("s", SECTIONS.get("studio"));
SECTIONS.put("pack", List.of(
Entry.command("validate", "[pack]", "Validate a pack or every pack", "v"),
Entry.command("restore", "<pack>", "Restore the latest trashed files for a pack", "r"),
Entry.command("cleanup", "<pack> [apply]", "Preview or quarantine unused-resource candidates", "c"),
Entry.command("restore", "<pack> [apply]", "Preview or restore the latest quarantine", "r"),
Entry.command("status", "[pack]", "Show cached validation status", "s")
));
SECTIONS.put("pk", SECTIONS.get("pack"));
@@ -63,7 +63,7 @@ public final class ModdedGoldenHash {
File goldenDir = ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen").resolve("golden").toFile();
GoldenHashEngine.Request request = new GoldenHashEngine.Request(
engine.getWorld().name(),
level.getSeed(),
engine.getSeedManager().getSeed(),
ModdedEngineBootstrap.loader().minecraftVersion(),
engine.getMinHeight(),
engine.getMaxHeight(),
@@ -88,7 +88,7 @@ public final class ModdedGoldenHash {
int chunks = (boundedRadius * 2 + 1) * (boundedRadius * 2 + 1);
scan.ok("GoldenHash started: " + chunks + " chunk(s) around 0,0 in buffers (world untouched), threads=" + Math.max(1, threads) + " mode=" + mode);
LOGGER.info("goldenhash start: dim={} seed={} radius={} threads={} mode={} file={}",
engine.getDimension().getLoadKey(), level.getSeed(), boundedRadius, Math.max(1, threads), mode, scan.hashEngine.getGoldenFile().getName());
engine.getDimension().getLoadKey(), engine.getSeedManager().getSeed(), boundedRadius, Math.max(1, threads), mode, scan.hashEngine.getGoldenFile().getName());
Thread thread = new Thread(() -> {
try {
scan.hashEngine.run();
@@ -18,6 +18,8 @@
package art.arcane.iris.modded.command;
import art.arcane.iris.core.pack.PackDirectoryResolver;
import art.arcane.iris.core.pack.PackResourceCleanup;
import art.arcane.iris.core.pack.PackValidationRegistry;
import art.arcane.iris.core.pack.PackValidationResult;
import art.arcane.iris.core.pack.PackValidator;
@@ -58,12 +60,27 @@ public final class ModdedPackCommands {
.then(Commands.argument("pack", StringArgumentType.word()).suggests(IrisModdedCommands.PACK_NAMES)
.executes((CommandContext<CommandSourceStack> context) -> validate(context.getSource(), StringArgumentType.getString(context, "pack")))));
root.then(Commands.literal("cleanup")
.then(Commands.argument("pack", StringArgumentType.word()).suggests(IrisModdedCommands.PACK_NAMES)
.executes((CommandContext<CommandSourceStack> context) -> cleanup(context.getSource(), StringArgumentType.getString(context, "pack"), false))
.then(Commands.literal("apply")
.executes((CommandContext<CommandSourceStack> context) -> cleanup(context.getSource(), StringArgumentType.getString(context, "pack"), true)))));
root.then(Commands.literal("c")
.then(Commands.argument("pack", StringArgumentType.word()).suggests(IrisModdedCommands.PACK_NAMES)
.executes((CommandContext<CommandSourceStack> context) -> cleanup(context.getSource(), StringArgumentType.getString(context, "pack"), false))
.then(Commands.literal("apply")
.executes((CommandContext<CommandSourceStack> context) -> cleanup(context.getSource(), StringArgumentType.getString(context, "pack"), true)))));
root.then(Commands.literal("restore")
.then(Commands.argument("pack", StringArgumentType.word()).suggests(IrisModdedCommands.PACK_NAMES)
.executes((CommandContext<CommandSourceStack> context) -> restore(context.getSource(), StringArgumentType.getString(context, "pack")))));
.executes((CommandContext<CommandSourceStack> context) -> restore(context.getSource(), StringArgumentType.getString(context, "pack"), false))
.then(Commands.literal("apply")
.executes((CommandContext<CommandSourceStack> context) -> restore(context.getSource(), StringArgumentType.getString(context, "pack"), true)))));
root.then(Commands.literal("r")
.then(Commands.argument("pack", StringArgumentType.word()).suggests(IrisModdedCommands.PACK_NAMES)
.executes((CommandContext<CommandSourceStack> context) -> restore(context.getSource(), StringArgumentType.getString(context, "pack")))));
.executes((CommandContext<CommandSourceStack> context) -> restore(context.getSource(), StringArgumentType.getString(context, "pack"), false))
.then(Commands.literal("apply")
.executes((CommandContext<CommandSourceStack> context) -> restore(context.getSource(), StringArgumentType.getString(context, "pack"), true)))));
root.then(Commands.literal("status")
.executes((CommandContext<CommandSourceStack> context) -> status(context.getSource(), null))
@@ -99,8 +116,8 @@ public final class ModdedPackCommands {
targets.add(dir);
}
} else {
File target = new File(packsRoot, pack);
if (!target.isDirectory()) {
File target = PackDirectoryResolver.resolveExisting(packsRoot, pack);
if (target == null) {
IrisModdedCommands.fail(source, "Pack '" + pack + "' not found under " + packsRoot.getAbsolutePath());
return 0;
}
@@ -133,19 +150,45 @@ public final class ModdedPackCommands {
return 1;
}
private static int restore(CommandSourceStack source, String pack) {
File packFolder = new File(packsRoot(), pack);
if (!packFolder.isDirectory()) {
private static int cleanup(CommandSourceStack source, String pack, boolean apply) {
File packFolder = PackDirectoryResolver.resolveExisting(packsRoot(), pack);
if (packFolder == null) {
IrisModdedCommands.fail(source, "Pack '" + pack + "' not found under " + packsRoot().getAbsolutePath());
return 0;
}
int restored = PackValidator.restoreTrash(packFolder);
if (restored == 0) {
IrisModdedCommands.fail(source, "Nothing to restore for pack '" + pack + "'.");
MinecraftServer server = source.getServer();
Thread thread = new Thread(() -> {
if (apply) {
PackResourceCleanup.ApplyResult result = PackResourceCleanup.apply(packFolder);
server.execute(() -> reportCleanupApply(source, pack, result));
} else {
PackResourceCleanup.Preview result = PackResourceCleanup.preview(packFolder);
server.execute(() -> reportCleanupPreview(source, pack, result));
}
}, "Iris Pack Cleanup");
thread.setDaemon(true);
thread.start();
return 1;
}
private static int restore(CommandSourceStack source, String pack, boolean apply) {
File packFolder = PackDirectoryResolver.resolveExisting(packsRoot(), pack);
if (packFolder == null) {
IrisModdedCommands.fail(source, "Pack '" + pack + "' not found under " + packsRoot().getAbsolutePath());
return 0;
}
IrisModdedCommands.ok(source, "Restored " + restored + " file(s) from the most recent trash dump for pack '" + pack + "'.");
IrisModdedCommands.ok(source, "Re-run /iris pack validate " + pack + " to re-check.");
MinecraftServer server = source.getServer();
Thread thread = new Thread(() -> {
if (apply) {
PackResourceCleanup.RestoreResult result = PackResourceCleanup.restoreLatest(packFolder);
server.execute(() -> reportRestoreApply(source, pack, result));
} else {
PackResourceCleanup.RestorePreview result = PackResourceCleanup.previewRestore(packFolder);
server.execute(() -> reportRestorePreview(source, pack, result));
}
}, "Iris Pack Restore");
thread.setDaemon(true);
thread.start();
return 1;
}
@@ -161,8 +204,7 @@ public final class ModdedPackCommands {
String tag = result.isLoadable() ? "OK" : "BROKEN";
IrisModdedCommands.ok(source, tag + " " + entry.getKey()
+ " (blocking=" + result.getBlockingErrors().size()
+ ", warnings=" + result.getWarnings().size()
+ ", trashed=" + result.getRemovedUnusedFiles().size() + ")");
+ ", warnings=" + result.getWarnings().size() + ")");
}
return 1;
}
@@ -178,8 +220,7 @@ public final class ModdedPackCommands {
private static void report(CommandSourceStack source, PackValidationResult result) {
if (result.isLoadable()) {
IrisModdedCommands.ok(source, "Pack '" + result.getPackName() + "' is loadable."
+ " (warnings=" + result.getWarnings().size()
+ ", trashed=" + result.getRemovedUnusedFiles().size() + ")");
+ " (warnings=" + result.getWarnings().size() + ")");
} else {
IrisModdedCommands.fail(source, "Pack '" + result.getPackName() + "' is BROKEN:");
for (String reason : result.getBlockingErrors()) {
@@ -193,12 +234,84 @@ public final class ModdedPackCommands {
if (result.getWarnings().size() > warningMax) {
IrisModdedCommands.ok(source, " ... and " + (result.getWarnings().size() - warningMax) + " more warning(s).");
}
int trashMax = Math.min(10, result.getRemovedUnusedFiles().size());
for (int i = 0; i < trashMax; i++) {
IrisModdedCommands.ok(source, " ~ trashed " + result.getRemovedUnusedFiles().get(i));
}
private static void reportCleanupPreview(CommandSourceStack source, String pack, PackResourceCleanup.Preview result) {
if (!result.success()) {
IrisModdedCommands.fail(source, result.error());
return;
}
if (result.getRemovedUnusedFiles().size() > trashMax) {
IrisModdedCommands.ok(source, " ... and " + (result.getRemovedUnusedFiles().size() - trashMax) + " more trashed file(s).");
if (!result.hasCandidates()) {
IrisModdedCommands.ok(source, "No cleanup candidates found for pack '" + pack + "'.");
return;
}
IrisModdedCommands.ok(source, "Cleanup preview for pack '" + pack + "': "
+ result.candidatePaths().size() + " candidate(s). No files were changed.");
reportPaths(source, result.candidatePaths(), "candidate");
IrisModdedCommands.ok(source, "Run /iris pack cleanup " + pack + " apply to quarantine after a fresh scan.");
}
private static void reportCleanupApply(CommandSourceStack source, String pack, PackResourceCleanup.ApplyResult result) {
if (!result.success()) {
IrisModdedCommands.fail(source, result.error());
reportPaths(source, result.quarantinedPaths(), "still quarantined");
return;
}
if (!result.changed()) {
IrisModdedCommands.ok(source, "No cleanup candidates found for pack '" + pack + "'.");
return;
}
IrisModdedCommands.ok(source, "Quarantined " + result.quarantinedPaths().size()
+ " cleanup candidate(s) under " + result.quarantinePath() + ".");
reportPaths(source, result.quarantinedPaths(), "quarantined");
}
private static void reportRestorePreview(CommandSourceStack source, String pack, PackResourceCleanup.RestorePreview result) {
if (!result.success()) {
IrisModdedCommands.fail(source, result.error());
return;
}
if (!result.hasFiles()) {
IrisModdedCommands.ok(source, "Nothing to restore for pack '" + pack + "'.");
return;
}
IrisModdedCommands.ok(source, "Restore preview for " + result.dumpPath() + ": "
+ result.filePaths().size() + " file(s). No files were changed.");
reportPaths(source, result.filePaths(), "file");
if (!result.conflicts().isEmpty()) {
IrisModdedCommands.fail(source, "Restore is blocked by " + result.conflicts().size() + " existing destination(s).");
reportPaths(source, result.conflicts(), "conflict");
return;
}
IrisModdedCommands.ok(source, "Run /iris pack restore " + pack + " apply to restore after a fresh conflict check.");
}
private static void reportRestoreApply(CommandSourceStack source, String pack, PackResourceCleanup.RestoreResult result) {
if (!result.conflicts().isEmpty()) {
IrisModdedCommands.fail(source, "Restore refused because " + result.conflicts().size() + " destination(s) already exist.");
reportPaths(source, result.conflicts(), "conflict");
return;
}
if (!result.success()) {
IrisModdedCommands.fail(source, result.error());
return;
}
if (!result.changed()) {
IrisModdedCommands.ok(source, "Nothing to restore for pack '" + pack + "'.");
return;
}
IrisModdedCommands.ok(source, "Restored " + result.restoredPaths().size()
+ " file(s) from " + result.dumpPath() + ".");
reportPaths(source, result.restoredPaths(), "restored");
}
private static void reportPaths(CommandSourceStack source, List<String> paths, String label) {
int max = Math.min(10, paths.size());
for (int i = 0; i < max; i++) {
IrisModdedCommands.ok(source, " - " + label + ": " + paths.get(i));
}
if (paths.size() > max) {
IrisModdedCommands.ok(source, " ... and " + (paths.size() - max) + " more.");
}
}
}
@@ -77,7 +77,7 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
this.maxInFlight = Math.max(8, pregen.getModdedPregenInFlight());
this.minInFlight = Math.max(4, Math.min(16, maxInFlight / 4));
this.semaphore = new Semaphore(maxInFlight, true);
this.adaptiveLimit = new AtomicInteger(maxInFlight);
this.adaptiveLimit = new AtomicInteger(sync ? 1 : maxInFlight);
this.timeoutSeconds = Math.max(120, pregen.getChunkLoadTimeoutSeconds());
this.backpressure = new PregenMantleBackpressure(
this::getMantle,
@@ -179,6 +179,7 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
CompletableFuture<?> loadFuture = CompletableFuture
.supplyAsync(() -> level.getChunkSource().addTicketAndLoadWithRadius(PREGEN_TICKET, pos, 0), level.getServer())
.thenCompose((CompletableFuture<?> inner) -> inner);
markSubmitted();
try {
Object result = loadFuture.get(timeoutSeconds, TimeUnit.SECONDS);
if (result instanceof ChunkResult<?> chunkResult && !chunkResult.isSuccess()) {
@@ -196,6 +197,7 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
LOGGER.warn("Iris pregen chunk {},{} failed: {}", x, z, e.toString());
listener.onChunkFailed(x, z);
} finally {
markFinished();
level.getServer().execute(() -> level.getChunkSource().removeTicketWithRadius(PREGEN_TICKET, pos, 0));
}
}
@@ -255,6 +257,9 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
private void markFinished() {
inFlight.decrementAndGet();
if (sync) {
return;
}
synchronized (permitMonitor) {
permitMonitor.notifyAll();
}
@@ -44,6 +44,7 @@ import net.minecraft.nbt.NbtUtils;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.entity.BlockEntity;
@@ -108,17 +109,22 @@ public final class ModdedChunkUpdateService implements ModdedTickableService {
if (engine == null || engine.isClosed() || engine.getMantle().getMantle().isClosed()) {
continue;
}
if (level.players().isEmpty() || isPregenActive(engine)) {
if (!hasUpdateTargets(!level.players().isEmpty(), !level.getForceLoadedChunks().isEmpty()) || isPregenActive(engine)) {
continue;
}
try {
updateNearPlayers(engine, level);
updateForcedChunks(engine, level);
} catch (Throwable e) {
IrisLogging.reportError(e);
}
}
}
static boolean hasUpdateTargets(boolean hasPlayers, boolean hasForcedChunks) {
return hasPlayers || hasForcedChunks;
}
private boolean isPregenActive(Engine engine) {
PregeneratorJob job = PregeneratorJob.getInstance();
return job != null && job.targetsWorldName(engine.getWorld().name());
@@ -136,6 +142,12 @@ public final class ModdedChunkUpdateService implements ModdedTickableService {
}
}
private void updateForcedChunks(Engine engine, ServerLevel level) {
for (long chunkKey : level.getForceLoadedChunks()) {
updateChunk(engine, level, ChunkPos.getX(chunkKey), ChunkPos.getZ(chunkKey));
}
}
private void updateChunk(Engine engine, ServerLevel level, int chunkX, int chunkZ) {
for (int x = -1; x <= 1; x++) {
for (int z = -1; z <= 1; z++) {
@@ -0,0 +1,143 @@
package art.arcane.iris.modded;
import org.junit.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
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 InitialSpawnQueueTest {
@Test
public void rejectsDuplicatesAndEnforcesCapacityWhileInFlight() {
InitialSpawnQueue queue = new InitialSpawnQueue(2);
assertTrue(queue.offer(1L));
assertFalse(queue.offer(1L));
assertTrue(queue.offer(2L));
assertEquals(Long.valueOf(1L), queue.poll());
assertFalse(queue.offer(3L));
queue.complete(1L);
assertTrue(queue.offer(3L));
assertEquals(2, queue.size());
}
@Test
public void retryRotatesWithoutBlockingReadyEntries() {
InitialSpawnQueue queue = new InitialSpawnQueue(4);
queue.offer(1L);
queue.offer(2L);
queue.offer(3L);
int budget = queue.batchSize(8);
Long unavailable = queue.poll();
queue.retry(unavailable);
Long firstReady = queue.poll();
queue.complete(firstReady);
Long secondReady = queue.poll();
queue.complete(secondReady);
assertEquals(3, budget);
assertEquals(Long.valueOf(1L), unavailable);
assertEquals(Long.valueOf(2L), firstReady);
assertEquals(Long.valueOf(3L), secondReady);
assertEquals(1, queue.batchSize(8));
assertEquals(Long.valueOf(1L), queue.poll());
}
@Test
public void retryIsDeduplicated() {
InitialSpawnQueue queue = new InitialSpawnQueue(2);
queue.offer(7L);
Long key = queue.poll();
queue.retry(key);
queue.retry(key);
assertEquals(1, queue.batchSize(8));
assertEquals(Long.valueOf(7L), queue.poll());
assertNull(queue.poll());
}
@Test
public void closeClearsAndRejectsFurtherWork() {
InitialSpawnQueue queue = new InitialSpawnQueue(2);
queue.offer(1L);
queue.close();
assertTrue(queue.isEmpty());
assertEquals(0, queue.size());
assertFalse(queue.offer(2L));
assertEquals(0, queue.batchSize(8));
}
@Test
public void clearAllowsLaterWork() {
InitialSpawnQueue queue = new InitialSpawnQueue(2);
queue.offer(1L);
queue.clear();
assertTrue(queue.offer(2L));
assertEquals(Long.valueOf(2L), queue.poll());
}
@Test
public void expiredEntriesReleaseCapacity() {
AtomicLong now = new AtomicLong();
InitialSpawnQueue queue = new InitialSpawnQueue(2, 100L, now::get);
queue.offer(1L);
queue.offer(2L);
now.set(100L);
assertTrue(queue.offer(3L));
assertEquals(1, queue.size());
assertEquals(Long.valueOf(3L), queue.poll());
}
@Test
public void expiredInFlightEntryCannotRetry() {
AtomicLong now = new AtomicLong();
InitialSpawnQueue queue = new InitialSpawnQueue(2, 100L, now::get);
queue.offer(1L);
Long inFlight = queue.poll();
now.set(100L);
queue.retry(inFlight);
assertEquals(0, queue.size());
assertNull(queue.poll());
}
@Test
public void concurrentOffersRemainDeduplicatedAndBounded() throws Exception {
InitialSpawnQueue queue = new InitialSpawnQueue(256);
ExecutorService executor = Executors.newFixedThreadPool(8);
List<Future<?>> futures = new ArrayList<>();
for (int thread = 0; thread < 8; thread++) {
futures.add(executor.submit(() -> {
for (long key = 0L; key < 512L; key++) {
queue.offer(key);
}
}));
}
for (Future<?> future : futures) {
future.get();
}
executor.shutdown();
assertTrue(executor.awaitTermination(10L, TimeUnit.SECONDS));
assertEquals(256, queue.size());
assertEquals(256, queue.batchSize(512));
}
}
@@ -0,0 +1,47 @@
package art.arcane.iris.modded;
import net.minecraft.SharedConstants;
import net.minecraft.server.Bootstrap;
import net.minecraft.util.random.Weighted;
import net.minecraft.util.random.WeightedList;
import net.minecraft.world.entity.EntityTypes;
import net.minecraft.world.level.biome.MobSpawnSettings;
import org.junit.Test;
import org.junit.BeforeClass;
import java.util.List;
import static org.junit.Assert.assertEquals;
public class IrisModdedChunkGeneratorSpawnTest {
@BeforeClass
public static void bootstrapMinecraftRegistries() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
}
@Test
public void explicitSpawnsReplaceMatchingVanillaTypesAndPreserveOthers() {
MobSpawnSettings.SpawnerData zombie = new MobSpawnSettings.SpawnerData(EntityTypes.ZOMBIE, 1, 4);
MobSpawnSettings.SpawnerData vanillaSlime = new MobSpawnSettings.SpawnerData(EntityTypes.SLIME, 1, 1);
MobSpawnSettings.SpawnerData explicitSlime = new MobSpawnSettings.SpawnerData(EntityTypes.SLIME, 2, 5);
MobSpawnSettings.SpawnerData explicitCow = new MobSpawnSettings.SpawnerData(EntityTypes.COW, 2, 4);
WeightedList<MobSpawnSettings.SpawnerData> vanilla = WeightedList.of(List.of(
new Weighted<>(zombie, 100),
new Weighted<>(vanillaSlime, 1)));
WeightedList<MobSpawnSettings.SpawnerData> explicit = WeightedList.of(List.of(
new Weighted<>(explicitSlime, 7),
new Weighted<>(explicitCow, 3)));
WeightedList<MobSpawnSettings.SpawnerData> merged = NativeSpawnTableMerger.merge(vanilla, explicit);
List<Weighted<MobSpawnSettings.SpawnerData>> entries = merged.unwrap();
assertEquals(3, entries.size());
assertEquals(EntityTypes.ZOMBIE, entries.get(0).value().type());
assertEquals(100, entries.get(0).weight());
assertEquals(explicitSlime, entries.get(1).value());
assertEquals(7, entries.get(1).weight());
assertEquals(explicitCow, entries.get(2).value());
assertEquals(3, entries.get(2).weight());
}
}
@@ -0,0 +1,87 @@
package art.arcane.iris.modded;
import org.junit.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class ModdedWorldCheckTest {
@Test
public void coordinatorThreadIsNonDaemon() {
Thread thread = ModdedWorldCheck.coordinatorThread(() -> {
});
assertFalse(thread.isDaemon());
}
@Test
public void passStopsServerBeforeZeroExit() {
List<String> events = new ArrayList<>();
ModdedWorldCheck.stopAndExit(
() -> events.add("stop"),
0,
status -> events.add("exit:" + status)
);
assertEquals(List.of("stop", "exit:0"), events);
}
@Test
public void failureStopsServerBeforeNonzeroExit() {
List<String> events = new ArrayList<>();
ModdedWorldCheck.stopAndExit(
() -> events.add("stop"),
1,
status -> events.add("exit:" + status)
);
assertEquals(List.of("stop", "exit:1"), events);
}
@Test
public void shutdownFailureForcesNonzeroExit() {
AtomicInteger status = new AtomicInteger(-1);
ModdedWorldCheck.stopAndExit(
() -> {
throw new IllegalStateException("shutdown failed");
},
0,
status::set
);
assertEquals(1, status.get());
}
@Test
public void interruptionIsClearedForShutdownAndRestoredBeforeExit() {
AtomicBoolean interruptedDuringStop = new AtomicBoolean(true);
AtomicBoolean interruptedDuringExit = new AtomicBoolean(false);
AtomicInteger status = new AtomicInteger(-1);
Thread.currentThread().interrupt();
try {
ModdedWorldCheck.stopAndExit(
() -> interruptedDuringStop.set(Thread.currentThread().isInterrupted()),
0,
exitStatus -> {
status.set(exitStatus);
interruptedDuringExit.set(Thread.currentThread().isInterrupted());
}
);
assertFalse(interruptedDuringStop.get());
assertTrue(interruptedDuringExit.get());
assertEquals(1, status.get());
} finally {
Thread.interrupted();
}
}
}
@@ -0,0 +1,23 @@
package art.arcane.iris.modded.service;
import org.junit.Test;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class ModdedChunkUpdateServiceTest {
@Test
public void scansWhenPlayersArePresent() {
assertTrue(ModdedChunkUpdateService.hasUpdateTargets(true, false));
}
@Test
public void scansHeadlessForceLoadedChunks() {
assertTrue(ModdedChunkUpdateService.hasUpdateTargets(false, true));
}
@Test
public void skipsLevelsWithoutPlayersOrForcedChunks() {
assertFalse(ModdedChunkUpdateService.hasUpdateTargets(false, false));
}
}
+1
View File
@@ -189,6 +189,7 @@ tasks.named('shadowJar', ShadowJar).configure {
exclude('META-INF/*.RSA')
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
relocate('org.objectweb.asm', 'art.arcane.iris.shadow.asm')
relocate('io.sentry', 'art.arcane.iris.shadow.sentry')
relocate('com.sun.jna', 'art.arcane.iris.shadow.jna')
relocate('oshi', 'art.arcane.iris.shadow.oshi')
relocate('net.jpountz', 'art.arcane.iris.shadow.jpountz')
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
./gradlew buildAll --no-parallel -PuseLocalVolmLib=false "$@"
./gradlew buildAllToOut --no-parallel -PuseLocalVolmLib=false "$@"
+62 -125
View File
@@ -71,36 +71,14 @@ String bukkitArtifactName = irisArtifactName('CraftBukkit', minecraftVersion)
String fabricArtifactName = irisArtifactName('Fabric', "${minecraftVersion}+${loaderDisplayVersion(fabricLoaderVersion)}")
String forgeArtifactName = irisArtifactName('Forge', "${minecraftVersion}+${loaderDisplayVersion(forgeVersion)}")
String neoForgeArtifactName = irisArtifactName('NeoForge', "${minecraftVersion}+${loaderDisplayVersion(neoForgeVersion)}")
extensions.extraProperties.set('bukkitArtifactName', bukkitArtifactName)
extensions.extraProperties.set('fabricArtifactName', fabricArtifactName)
extensions.extraProperties.set('forgeArtifactName', forgeArtifactName)
extensions.extraProperties.set('neoForgeArtifactName', neoForgeArtifactName)
apply plugin: ApiGenerator
// ADD YOURSELF AS A NEW LINE IF YOU WANT YOUR OWN BUILD TASK GENERATED
// ======================== WINDOWS =============================
registerCustomOutputTask('Cyberpwn', 'C://Users/cyberpwn/Documents/development/server/plugins')
registerCustomOutputTask('Psycho', 'C://Dan/MinecraftDevelopment/Server/plugins')
registerCustomOutputTask('ArcaneArts', 'C://Users/arcane/Documents/development/server/plugins')
registerCustomOutputTask('Coco', 'D://mcsm/plugins')
registerCustomOutputTask('Strange', 'D://Servers/1.17 Test Server/plugins')
registerCustomOutputTask('Vatuu', 'D://Minecraft/Servers/1.19.4/plugins')
registerCustomOutputTask('CrazyDev22', 'C://Users/Julian/Desktop/server/plugins')
registerCustomOutputTask('PixelFury', 'C://Users/repix/workplace/Iris/1.21.3 - Development-Public-v3/plugins')
registerCustomOutputTask('PixelFuryDev', 'C://Users/repix/workplace/Iris/1.21 - Development-v3/plugins')
// ========================== UNIX ==============================
registerCustomOutputTaskUnix('CyberpwnLT', '/Users/danielmills/development/server/plugins')
registerCustomOutputTaskUnix(
'PsychoLT',
'/Users/brianfopiano/Developer/RemoteGit/[Minecraft Server]/consumers/plugin-consumers/dropins/plugins',
'/Users/brianfopiano/Developer/RemoteGit/[Minecraft Server]/consumers/fabric-mod-consumers/dropins/mods',
'/Users/brianfopiano/Developer/RemoteGit/[Minecraft Server]/consumers/forge-mod-consumers/dropins/mods',
'/Users/brianfopiano/Developer/RemoteGit/[Minecraft Server]/consumers/neoforge-mod-consumers/dropins/mods'
)
registerCustomOutputTaskUnix('PixelMac', '/Users/test/Desktop/mcserver/plugins')
registerCustomOutputTaskUnix('CrazyDev22LT', '/home/julian/Desktop/server/plugins')
// ==============================================================
String consumerLocation = providers.gradleProperty('location')
.getOrElse('/Users/brianfopiano/Developer/RemoteGit/[Minecraft Server]/consumers')
String bukkitConsumerPath = "${consumerLocation}/plugin-consumers/dropins/plugins"
String fabricConsumerPath = "${consumerLocation}/fabric-mod-consumers/dropins/mods"
String forgeConsumerPath = "${consumerLocation}/forge-mod-consumers/dropins/mods"
String neoForgeConsumerPath = "${consumerLocation}/neoforge-mod-consumers/dropins/mods"
def nmsBindings = [
v26_2_R1: '26.2.build.25-alpha',
@@ -272,14 +250,14 @@ tasks.named('neoforgeJar').configure {
mustRunAfter(tasks.named('forgeJar'))
}
tasks.register('buildAll') {
tasks.register('buildAllToOut') {
group = 'iris'
dependsOn('buildBukkit', 'buildFabric', 'buildForge', 'buildNeoforge', ':spi:jar')
doLast {
File dist = layout.projectDirectory.dir('dist').asFile
File[] jars = dist.listFiles((java.io.FileFilter) { File f -> f.name.endsWith('.jar') })
println('')
println('=== Iris buildAll -> dist/ ===')
println('=== Iris buildAllToOut -> dist/ ===')
if (jars != null) {
jars.sort { it.name }.each { File f ->
println(String.format(' %-60s %8d KB', f.name, (long) (f.length() / 1024)))
@@ -288,6 +266,60 @@ tasks.register('buildAll') {
}
}
tasks.register('buildAll') {
group = 'iris'
outputs.upToDateWhen { false }
dependsOn('iris', 'fabricJar', 'forgeJar', 'neoforgeJar')
doLast {
delete(
file("${bukkitConsumerPath}/Iris-Fabric.jar"),
file("${bukkitConsumerPath}/Iris-Forge.jar"),
file("${bukkitConsumerPath}/Iris-Neoforge.jar")
)
copy {
from(layout.buildDirectory.file(bukkitArtifactName))
into(bukkitConsumerPath)
rename { String ignored -> 'Iris.jar' }
}
delete(
file("${fabricConsumerPath}/Iris.jar"),
file("${fabricConsumerPath}/Iris-Forge.jar"),
file("${fabricConsumerPath}/Iris-Neoforge.jar")
)
copy {
from(layout.projectDirectory.file("adapters/fabric/build/libs/${fabricArtifactName}"))
into(fabricConsumerPath)
rename { String ignored -> 'Iris-Fabric.jar' }
}
delete(
file("${forgeConsumerPath}/Iris.jar"),
file("${forgeConsumerPath}/Iris-Fabric.jar"),
file("${forgeConsumerPath}/Iris-Neoforge.jar")
)
copy {
from(layout.projectDirectory.file("adapters/forge/build/libs/${forgeArtifactName}"))
into(forgeConsumerPath)
rename { String ignored -> 'Iris-Forge.jar' }
}
delete(
file("${neoForgeConsumerPath}/Iris.jar"),
file("${neoForgeConsumerPath}/Iris-Fabric.jar"),
file("${neoForgeConsumerPath}/Iris-Forge.jar")
)
copy {
from(layout.projectDirectory.file("adapters/neoforge/build/libs/${neoForgeArtifactName}"))
into(neoForgeConsumerPath)
rename { String ignored -> 'Iris-Neoforge.jar' }
}
println('')
println("=== Iris buildAll -> ${consumerLocation} ===")
println(" ${bukkitConsumerPath}/Iris.jar")
println(" ${fabricConsumerPath}/Iris-Fabric.jar")
println(" ${forgeConsumerPath}/Iris-Forge.jar")
println(" ${neoForgeConsumerPath}/Iris-Neoforge.jar")
}
}
tasks.register('irisDev', Copy) {
group = 'iris'
from(project(':core').layout.buildDirectory.files('libs/core-javadoc.jar', 'libs/core-sources.jar'))
@@ -409,98 +441,3 @@ if (!JavaVersion.current().isCompatibleWith(JavaVersion.VERSION_25)) {
System.err.println()
System.exit(69)
}
void registerCustomOutputTask(String name, String path) {
if (!System.getProperty('os.name').toLowerCase().contains('windows')) {
return
}
createCustomOutputTasks(name, path)
}
void registerCustomOutputTask(String name, String path, String fabricPath, String forgePath, String neoForgePath) {
if (!System.getProperty('os.name').toLowerCase().contains('windows')) {
return
}
createCustomOutputTasks(name, path, fabricPath, forgePath, neoForgePath)
}
void registerCustomOutputTaskUnix(String name, String path) {
if (System.getProperty('os.name').toLowerCase().contains('windows')) {
return
}
createCustomOutputTasks(name, path)
}
void registerCustomOutputTaskUnix(String name, String path, String fabricPath, String forgePath, String neoForgePath) {
if (System.getProperty('os.name').toLowerCase().contains('windows')) {
return
}
createCustomOutputTasks(name, path, fabricPath, forgePath, neoForgePath)
}
void createCustomOutputTasks(String name, String path) {
createBukkitOutputTask(name, path)
tasks.register("buildAll${name}") {
group = 'development'
dependsOn("build${name}")
}
}
void createCustomOutputTasks(String name, String path, String fabricPath, String forgePath, String neoForgePath) {
createBukkitOutputTask(name, path)
createPlatformOutputTask(name, fabricPath, 'Fabric', 'buildFabric', "adapters/fabric/build/libs/${String.valueOf(project.ext.fabricArtifactName)}")
createPlatformOutputTask(name, forgePath, 'Forge', 'buildForge', "adapters/forge/build/libs/${String.valueOf(project.ext.forgeArtifactName)}")
createPlatformOutputTask(name, neoForgePath, 'Neoforge', 'buildNeoforge', "adapters/neoforge/build/libs/${String.valueOf(project.ext.neoForgeArtifactName)}")
tasks.register("buildAll${name}") {
group = 'development'
dependsOn(
"build${name}",
"build${name}Fabric",
"build${name}Forge",
"build${name}Neoforge"
)
}
}
void createBukkitOutputTask(String name, String path) {
tasks.register("build${name}", Copy) {
group = 'development'
outputs.upToDateWhen { false }
dependsOn('iris')
from(layout.buildDirectory.file((String) project.ext.bukkitArtifactName))
into(file(path))
rename { String ignored -> 'Iris.jar' }
doFirst {
delete(
file("${path}/Iris-Fabric.jar"),
file("${path}/Iris-Forge.jar"),
file("${path}/Iris-Neoforge.jar")
)
}
}
}
void createPlatformOutputTask(String name, String path, String platform, String buildTask, String sourcePath) {
tasks.register("build${name}${platform}", Copy) {
group = 'development'
outputs.upToDateWhen { false }
dependsOn(buildTask)
from(layout.projectDirectory.file(sourcePath))
into(file(path))
rename { String ignored -> "Iris-${platform}.jar" }
doFirst {
delete(
file("${path}/Iris.jar"),
file("${path}/Iris-Fabric.jar"),
file("${path}/Iris-Forge.jar"),
file("${path}/Iris-Neoforge.jar")
)
}
}
}
@@ -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());
}
}
-21
View File
@@ -1,21 +0,0 @@
name: ${name}
version: ${version}
main: ${main}
load: STARTUP
authors: [ cyberpwn, NextdoorPsycho ]
website: volmit.com
description: More than a Dimension!
libraries:
- org.zeroturnaround:zt-zip:1.14
- com.googlecode.concurrentlinkedhashmap:concurrentlinkedhashmap-lru:1.4.2
- org.ow2.asm:asm:9.2
- com.google.code.gson:gson:2.8.7
- it.unimi.dsi:fastutil:8.5.4
- com.google.guava:guava:30.1.1-jre
- bsf:bsf:2.4.0
- rhino:js:1.7R2
commands:
iris:
aliases: [ ir, irs ]
api-version: ${apiversion}
hotload-dependencies: false
+5
View File
@@ -16,6 +16,7 @@ String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate')
dependencies {
implementation(project(':core'))
compileOnly(libs.spigot)
testImplementation('junit:junit:4.13.2')
compileOnly(volmLibCoordinate) {
transitive = false
}
@@ -44,6 +45,10 @@ tasks.named('run', JavaExec).configure {
dependsOn(':core:compileJava')
}
tasks.named('test').configure {
dependsOn(':core:compileJava')
}
String probePack = providers.gradleProperty('probePack')
.orElse('/Users/brianfopiano/Developer/RemoteGit/[Minecraft Server]/consumers/plugin-consumers/instances/purpur-26.2/plugins/Iris/packs/overworld')
.get()
@@ -18,15 +18,23 @@
package art.arcane.iris.probe;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Stream;
public final class ClassloadProbe {
private static final String ALLOWLIST_RESOURCE = "/classload-allowlist.tsv";
private static final String[] CRITICAL_PREFIXES = {
"art.arcane.iris.engine.",
"art.arcane.iris.util.",
@@ -34,26 +42,6 @@ public final class ClassloadProbe {
"art.arcane.iris.spi.",
};
private static final String[] EXEMPT_PREFIXES = {
"art.arcane.iris.platform.bukkit.",
"art.arcane.iris.engine.platform.",
"art.arcane.iris.util.common.plugin.",
"art.arcane.iris.util.common.inventorygui.",
"art.arcane.iris.util.common.director.",
"art.arcane.iris.util.common.data.registry.",
};
private static final String[] EXEMPT_CLASSES = {
"art.arcane.iris.util.common.misc.Bindings",
"art.arcane.iris.util.common.misc.SlimJar",
"art.arcane.iris.util.common.misc.ServerProperties",
"art.arcane.iris.util.common.data.IrisCustomData",
"art.arcane.iris.engine.IrisWorldManager",
"art.arcane.iris.engine.framework.EngineAssignedWorldManager",
"art.arcane.iris.engine.object.StudioMode",
"art.arcane.iris.engine.framework.placer.WorldObjectPlacer",
};
public static void main(String[] args) throws IOException {
art.arcane.iris.spi.IrisPlatforms.bind(new StubPlatform());
boolean bukkitPresent = true;
@@ -63,39 +51,42 @@ public final class ClassloadProbe {
bukkitPresent = false;
}
System.out.println("[probe] org.bukkit on classpath: " + bukkitPresent);
Path classesRoot = Path.of(args[0]);
List<String> names = new ArrayList<>();
try (Stream<Path> walk = Files.walk(classesRoot)) {
walk.filter(p -> p.toString().endsWith(".class"))
.forEach(p -> {
String rel = classesRoot.relativize(p).toString();
String name = rel.substring(0, rel.length() - 6).replace('/', '.');
if (name.contains("$")) {
return;
}
names.add(name);
});
if (bukkitPresent) {
System.out.println("[probe] RESULT: FAIL - org.bukkit must be absent from the classload probe runtime");
System.exit(1);
return;
}
names.sort(String::compareTo);
TreeMap<String, String> criticalFailures = new TreeMap<>();
TreeMap<String, String> otherFailures = new TreeMap<>();
TreeMap<String, Allowance> allowlist = loadAllowlist();
Path classesRoot = Path.of(args[0]);
List<String> names = scanClassNames(classesRoot);
TreeMap<String, Failure> criticalFailures = new TreeMap<>();
TreeMap<String, Failure> otherFailures = new TreeMap<>();
int loaded = 0;
int nestedTotal = 0;
int nestedLoaded = 0;
int criticalTotal = 0;
for (String name : names) {
boolean critical = matchesAny(name, CRITICAL_PREFIXES) && !matchesAny(name, EXEMPT_PREFIXES) && !exactAny(name);
boolean nested = name.contains("$");
if (nested) {
nestedTotal++;
}
boolean critical = matchesAny(name, CRITICAL_PREFIXES) && !allowlist.containsKey(name);
if (critical) {
criticalTotal++;
}
try {
Class.forName(name, true, ClassloadProbe.class.getClassLoader());
loaded++;
if (nested) {
nestedLoaded++;
}
} catch (Throwable failure) {
if (System.getenv("PROBE_TRACE") != null && name.contains(System.getenv("PROBE_TRACE"))) {
failure.printStackTrace(System.out);
}
String cause = rootCause(failure);
Failure cause = Failure.from(failure);
if (critical) {
criticalFailures.put(name, cause);
} else {
@@ -105,25 +96,56 @@ public final class ClassloadProbe {
}
System.out.println("[probe] classes scanned: " + names.size() + ", initialized OK: " + loaded);
System.out.println("[probe] nested classes scanned: " + nestedTotal + ", initialized OK: " + nestedLoaded
+ ", initialization failures: " + (nestedTotal - nestedLoaded));
System.out.println("[probe] critical set: " + criticalTotal + ", critical failures: " + criticalFailures.size());
criticalFailures.forEach((name, cause) -> System.out.println(" CRITICAL " + name + " -> " + cause));
criticalFailures.forEach((name, cause) -> System.out.println(" CRITICAL " + name + " -> " + cause.display()));
System.out.println("[probe] non-critical failures: " + otherFailures.size());
otherFailures.forEach((name, cause) -> System.out.println(" other " + name + " -> " + cause));
Review review = review(otherFailures, allowlist);
System.out.println("[probe] reviewed allowances: " + allowlist.size() + ", matched failures: " + review.matched());
otherFailures.forEach((name, cause) -> {
Allowance allowance = allowlist.get(name);
String status = allowance != null && allowance.matches(cause) ? "allowed " + allowance.category() : "UNEXPECTED";
System.out.println(" " + status + " " + name + " -> " + cause.display());
});
System.out.println("[probe] unexpected non-critical failures: " + review.unexpected().size());
System.out.println("[probe] stale allowances: " + review.staleAllowances().size());
review.staleAllowances().forEach((name, allowance) -> System.out.println(" STALE " + name + " -> " + allowance));
if (!criticalFailures.isEmpty()) {
if (!criticalFailures.isEmpty() || !review.passes()) {
System.out.println("[probe] RESULT: FAIL");
System.exit(1);
}
System.out.println("[probe] RESULT: PASS");
}
private static boolean exactAny(String name) {
for (String exempt : EXEMPT_CLASSES) {
if (name.equals(exempt)) {
return true;
static Review review(Map<String, Failure> failures, Map<String, Allowance> allowlist) {
TreeMap<String, Failure> unexpected = new TreeMap<>();
TreeMap<String, Allowance> staleAllowances = new TreeMap<>(allowlist);
int matched = 0;
for (Map.Entry<String, Failure> entry : failures.entrySet()) {
Allowance allowance = allowlist.get(entry.getKey());
staleAllowances.remove(entry.getKey());
if (allowance == null || !allowance.matches(entry.getValue())) {
unexpected.put(entry.getKey(), entry.getValue());
} else {
matched++;
}
}
return false;
return new Review(matched, unexpected, staleAllowances);
}
static List<String> scanClassNames(Path classesRoot) throws IOException {
List<String> names = new ArrayList<>();
try (Stream<Path> walk = Files.walk(classesRoot)) {
walk.filter(path -> path.toString().endsWith(".class"))
.forEach(path -> {
String relative = classesRoot.relativize(path).toString();
names.add(relative.substring(0, relative.length() - 6).replace(File.separatorChar, '.'));
});
}
names.sort(String::compareTo);
return names;
}
private static boolean matchesAny(String name, String[] prefixes) {
@@ -135,11 +157,91 @@ public final class ClassloadProbe {
return false;
}
private static String rootCause(Throwable failure) {
Throwable cause = failure;
while (cause.getCause() != null && cause.getCause() != cause) {
cause = cause.getCause();
private static TreeMap<String, Allowance> loadAllowlist() throws IOException {
InputStream resource = ClassloadProbe.class.getResourceAsStream(ALLOWLIST_RESOURCE);
if (resource == null) {
throw new IOException("Classload allowlist resource not found: " + ALLOWLIST_RESOURCE);
}
TreeMap<String, Allowance> allowlist = new TreeMap<>();
try (BufferedReader reader = new BufferedReader(new InputStreamReader(resource, StandardCharsets.UTF_8))) {
String line;
int lineNumber = 0;
while ((line = reader.readLine()) != null) {
lineNumber++;
if (line.isBlank()) {
continue;
}
String[] fields = line.split("\\t", -1);
if (fields.length != 3 || fields[0].isBlank() || fields[2].isBlank()) {
throw new IOException("Invalid classload allowlist entry at line " + lineNumber);
}
Allowance allowance;
try {
allowance = new Allowance(AllowanceCategory.valueOf(fields[1]), fields[2]);
} catch (IllegalArgumentException exception) {
throw new IOException("Invalid classload allowlist category at line " + lineNumber + ": " + fields[1], exception);
}
if (allowlist.put(fields[0], allowance) != null) {
throw new IOException("Duplicate classload allowlist class at line " + lineNumber + ": " + fields[0]);
}
}
}
return allowlist;
}
enum AllowanceCategory {
BUKKIT_API,
PAPERLIB_API,
ADVENTURE_API,
MYTHICMOBS_API,
SERVER_RUNTIME_FILE
}
record Allowance(AllowanceCategory category, String detail) {
boolean matches(Failure failure) {
return switch (category) {
case BUKKIT_API -> detail.startsWith("org.bukkit.") && failure.isMissingClassIn("org.bukkit.");
case PAPERLIB_API -> detail.startsWith("io.papermc.lib.") && failure.isMissingClassIn("io.papermc.lib.");
case ADVENTURE_API -> detail.startsWith("net.kyori.adventure.") && failure.isMissingClassIn("net.kyori.adventure.");
case MYTHICMOBS_API -> detail.startsWith("io.lumine.mythic.") && failure.isMissingClassIn("io.lumine.mythic.");
case SERVER_RUNTIME_FILE -> failure.exceptionClass().equals("java.io.FileNotFoundException")
&& failure.message().startsWith(detail);
};
}
}
record Failure(String exceptionClass, String message) {
static Failure from(Throwable failure) {
Throwable cause = failure;
while (cause.getCause() != null && cause.getCause() != cause) {
cause = cause.getCause();
}
return new Failure(cause.getClass().getName(), String.valueOf(cause.getMessage()));
}
boolean isMissingClassIn(String packagePrefix) {
if (exceptionClass.equals("java.lang.ClassNotFoundException")) {
return message.startsWith(packagePrefix);
}
String internalPrefix = packagePrefix.replace('.', '/');
if (exceptionClass.equals("java.lang.NoClassDefFoundError")) {
return message.startsWith(internalPrefix);
}
return exceptionClass.equals("java.lang.ExceptionInInitializerError")
&& message.contains("NoClassDefFoundError: " + internalPrefix);
}
String display() {
int separator = exceptionClass.lastIndexOf('.');
String simpleName = separator < 0 ? exceptionClass : exceptionClass.substring(separator + 1);
return simpleName + ": " + message;
}
}
record Review(int matched, TreeMap<String, Failure> unexpected, TreeMap<String, Allowance> staleAllowances) {
boolean passes() {
return unexpected.isEmpty() && staleAllowances.isEmpty();
}
return cause.getClass().getSimpleName() + ": " + cause.getMessage();
}
}
@@ -0,0 +1,83 @@
art.arcane.iris.core.IrisWorlds BUKKIT_API org.bukkit.command.CommandSender
art.arcane.iris.core.ServerConfigurator BUKKIT_API org.bukkit.command.CommandSender
art.arcane.iris.core.edit.BlockSignal BUKKIT_API org.bukkit.entity.Entity
art.arcane.iris.core.edit.BlockSignal$1 BUKKIT_API org.bukkit.entity.Entity
art.arcane.iris.core.edit.DustRevealer ADVENTURE_API net.kyori.adventure.text.format.TextColor
art.arcane.iris.core.events.IrisEngineEvent BUKKIT_API org.bukkit.event.Event
art.arcane.iris.core.events.IrisEngineHotloadEvent BUKKIT_API org.bukkit.event.Event
art.arcane.iris.core.events.IrisLootEvent BUKKIT_API org.bukkit.event.Event
art.arcane.iris.core.events.IrisLootEvent$1 BUKKIT_API org.bukkit.loot.LootTable
art.arcane.iris.core.events.IrisLootEvent$2 BUKKIT_API org.bukkit.inventory.InventoryHolder
art.arcane.iris.core.lifecycle.PaperLibBootstrap PAPERLIB_API io.papermc.lib.environments.Environment
art.arcane.iris.core.lifecycle.PaperLibBootstrap$ModernPaperEnvironment PAPERLIB_API io.papermc.lib.environments.PaperEnvironment
art.arcane.iris.core.link.ExternalDataProvider BUKKIT_API org.bukkit.event.Listener
art.arcane.iris.core.link.data.CraftEngineDataProvider BUKKIT_API org.bukkit.event.Listener
art.arcane.iris.core.link.data.EcoItemsDataProvider BUKKIT_API org.bukkit.event.Listener
art.arcane.iris.core.link.data.ExecutableItemsDataProvider BUKKIT_API org.bukkit.event.Listener
art.arcane.iris.core.link.data.HMCLeavesDataProvider BUKKIT_API org.bukkit.event.Listener
art.arcane.iris.core.link.data.ItemAdderDataProvider BUKKIT_API org.bukkit.event.Listener
art.arcane.iris.core.link.data.KGeneratorsDataProvider BUKKIT_API org.bukkit.event.Listener
art.arcane.iris.core.link.data.MMOItemsDataProvider BUKKIT_API org.bukkit.event.Listener
art.arcane.iris.core.link.data.MythicCrucibleDataProvider BUKKIT_API org.bukkit.event.Listener
art.arcane.iris.core.link.data.MythicMobsDataProvider BUKKIT_API org.bukkit.event.Listener
art.arcane.iris.core.link.data.MythicMobsDataProvider$IrisBiomeCondition MYTHICMOBS_API io.lumine.mythic.api.skills.conditions.ILocationCondition
art.arcane.iris.core.link.data.MythicMobsDataProvider$IrisRegionCondition MYTHICMOBS_API io.lumine.mythic.api.skills.conditions.ILocationCondition
art.arcane.iris.core.link.data.NexoDataProvider BUKKIT_API org.bukkit.event.Listener
art.arcane.iris.core.nms.INMS BUKKIT_API org.bukkit.Bukkit
art.arcane.iris.core.project.IrisProject BUKKIT_API org.bukkit.entity.Entity
art.arcane.iris.core.runtime.WorldRuntimeControlService BUKKIT_API org.bukkit.entity.Entity
art.arcane.iris.core.service.BoardSVC BUKKIT_API org.bukkit.event.Listener
art.arcane.iris.core.service.BoardSVC$PlayerBoard BUKKIT_API org.bukkit.entity.Entity
art.arcane.iris.core.service.ExternalDataSVC BUKKIT_API org.bukkit.event.Listener
art.arcane.iris.core.service.GlobalCacheSVC BUKKIT_API org.bukkit.event.Listener
art.arcane.iris.core.service.LogFilterSVC BUKKIT_API org.bukkit.event.Listener
art.arcane.iris.core.service.ObjectSVC BUKKIT_API org.bukkit.event.Listener
art.arcane.iris.core.service.ObjectStudioSaveService BUKKIT_API org.bukkit.event.Listener
art.arcane.iris.core.service.PreservationSVC BUKKIT_API org.bukkit.event.Listener
art.arcane.iris.core.service.StudioSVC BUKKIT_API org.bukkit.event.Listener
art.arcane.iris.core.service.TreeSVC BUKKIT_API org.bukkit.event.Listener
art.arcane.iris.core.tools.IrisToolbelt BUKKIT_API org.bukkit.command.CommandSender
art.arcane.iris.core.tools.IrisWorldCreator BUKKIT_API org.bukkit.generator.ChunkGenerator
art.arcane.iris.core.tools.TreePlausibilizer BUKKIT_API org.bukkit.Keyed
art.arcane.iris.engine.IrisWorldManager BUKKIT_API org.bukkit.event.Listener
art.arcane.iris.engine.framework.EngineAssignedWorldManager BUKKIT_API org.bukkit.event.Listener
art.arcane.iris.engine.framework.placer.WorldObjectPlacer BUKKIT_API org.bukkit.event.Event
art.arcane.iris.engine.object.IrisDirection$11 BUKKIT_API org.bukkit.block.BlockFace
art.arcane.iris.engine.object.IrisEffect$BukkitFx BUKKIT_API org.bukkit.entity.Entity
art.arcane.iris.engine.object.IrisEntity$BukkitOps BUKKIT_API org.bukkit.loot.LootTable
art.arcane.iris.engine.object.IrisEntity$BukkitOps$1 BUKKIT_API org.bukkit.loot.LootTable
art.arcane.iris.engine.object.IrisObjectRotation$1 BUKKIT_API org.bukkit.block.BlockFace
art.arcane.iris.engine.object.IrisObjectRotation$Faces BUKKIT_API org.bukkit.block.BlockFace
art.arcane.iris.engine.object.LegacyTileData$BannerHandler BUKKIT_API org.bukkit.Keyed
art.arcane.iris.engine.object.LegacyTileData$Handlers BUKKIT_API org.bukkit.block.BlockState
art.arcane.iris.engine.object.LegacyTileData$SignHandler BUKKIT_API org.bukkit.Keyed
art.arcane.iris.engine.object.LegacyTileData$SignTags BUKKIT_API org.bukkit.Tag
art.arcane.iris.engine.object.LegacyTileData$SignTags$1 BUKKIT_API org.bukkit.Tag
art.arcane.iris.engine.platform.BukkitChunkGenerator BUKKIT_API org.bukkit.event.Listener
art.arcane.iris.engine.platform.BukkitChunkGenerator$2 BUKKIT_API org.bukkit.HeightMap
art.arcane.iris.engine.platform.DummyBiomeProvider BUKKIT_API org.bukkit.generator.BiomeProvider
art.arcane.iris.engine.platform.DummyChunkGenerator BUKKIT_API org.bukkit.generator.ChunkGenerator
art.arcane.iris.engine.platform.EngineBukkitOps BUKKIT_API org.bukkit.entity.Entity
art.arcane.iris.engine.platform.studio.generators.ObjectStudioGenerator BUKKIT_API org.bukkit.Material
art.arcane.iris.platform.bukkit.BukkitBlockResolution BUKKIT_API org.bukkit.Material
art.arcane.iris.platform.bukkit.BukkitPlatform BUKKIT_API org.bukkit.command.CommandSender
art.arcane.iris.util.common.data.IrisCustomData BUKKIT_API org.bukkit.block.data.BlockData
art.arcane.iris.util.common.data.registry.Attributes BUKKIT_API org.bukkit.attribute.Attribute
art.arcane.iris.util.common.data.registry.Materials BUKKIT_API org.bukkit.Material
art.arcane.iris.util.common.data.registry.Particles BUKKIT_API org.bukkit.Particle
art.arcane.iris.util.common.director.handlers.VectorHandler BUKKIT_API org.bukkit.util.Vector
art.arcane.iris.util.common.format.C$DyeMaps BUKKIT_API org.bukkit.DyeColor
art.arcane.iris.util.common.inventorygui.WindowResolution BUKKIT_API org.bukkit.event.inventory.InventoryType
art.arcane.iris.util.common.misc.Bindings BUKKIT_API org.bukkit.plugin.Plugin
art.arcane.iris.util.common.misc.ServerProperties SERVER_RUNTIME_FILE server.properties
art.arcane.iris.util.common.misc.SlimJar BUKKIT_API org.bukkit.plugin.Plugin
art.arcane.iris.util.common.plugin.CommandDummy BUKKIT_API org.bukkit.command.CommandSender
art.arcane.iris.util.common.plugin.Controller BUKKIT_API org.bukkit.event.Listener
art.arcane.iris.util.common.plugin.IController BUKKIT_API org.bukkit.event.Listener
art.arcane.iris.util.common.plugin.IrisService BUKKIT_API org.bukkit.event.Listener
art.arcane.iris.util.common.plugin.RouterCommand BUKKIT_API org.bukkit.command.Command
art.arcane.iris.util.common.plugin.VolmitPlugin BUKKIT_API org.bukkit.event.Listener
art.arcane.iris.util.common.plugin.VolmitSender BUKKIT_API org.bukkit.command.CommandSender
art.arcane.iris.util.common.plugin.chunk.ChunkTickets BUKKIT_API org.bukkit.event.Listener
art.arcane.iris.util.nbt.common.mca.NBTWorld$Holder BUKKIT_API org.bukkit.Material
art.arcane.iris.util.project.sentry.ServerID$Holder BUKKIT_API org.bukkit.Bukkit
1 art.arcane.iris.core.IrisWorlds BUKKIT_API org.bukkit.command.CommandSender
2 art.arcane.iris.core.ServerConfigurator BUKKIT_API org.bukkit.command.CommandSender
3 art.arcane.iris.core.edit.BlockSignal BUKKIT_API org.bukkit.entity.Entity
4 art.arcane.iris.core.edit.BlockSignal$1 BUKKIT_API org.bukkit.entity.Entity
5 art.arcane.iris.core.edit.DustRevealer ADVENTURE_API net.kyori.adventure.text.format.TextColor
6 art.arcane.iris.core.events.IrisEngineEvent BUKKIT_API org.bukkit.event.Event
7 art.arcane.iris.core.events.IrisEngineHotloadEvent BUKKIT_API org.bukkit.event.Event
8 art.arcane.iris.core.events.IrisLootEvent BUKKIT_API org.bukkit.event.Event
9 art.arcane.iris.core.events.IrisLootEvent$1 BUKKIT_API org.bukkit.loot.LootTable
10 art.arcane.iris.core.events.IrisLootEvent$2 BUKKIT_API org.bukkit.inventory.InventoryHolder
11 art.arcane.iris.core.lifecycle.PaperLibBootstrap PAPERLIB_API io.papermc.lib.environments.Environment
12 art.arcane.iris.core.lifecycle.PaperLibBootstrap$ModernPaperEnvironment PAPERLIB_API io.papermc.lib.environments.PaperEnvironment
13 art.arcane.iris.core.link.ExternalDataProvider BUKKIT_API org.bukkit.event.Listener
14 art.arcane.iris.core.link.data.CraftEngineDataProvider BUKKIT_API org.bukkit.event.Listener
15 art.arcane.iris.core.link.data.EcoItemsDataProvider BUKKIT_API org.bukkit.event.Listener
16 art.arcane.iris.core.link.data.ExecutableItemsDataProvider BUKKIT_API org.bukkit.event.Listener
17 art.arcane.iris.core.link.data.HMCLeavesDataProvider BUKKIT_API org.bukkit.event.Listener
18 art.arcane.iris.core.link.data.ItemAdderDataProvider BUKKIT_API org.bukkit.event.Listener
19 art.arcane.iris.core.link.data.KGeneratorsDataProvider BUKKIT_API org.bukkit.event.Listener
20 art.arcane.iris.core.link.data.MMOItemsDataProvider BUKKIT_API org.bukkit.event.Listener
21 art.arcane.iris.core.link.data.MythicCrucibleDataProvider BUKKIT_API org.bukkit.event.Listener
22 art.arcane.iris.core.link.data.MythicMobsDataProvider BUKKIT_API org.bukkit.event.Listener
23 art.arcane.iris.core.link.data.MythicMobsDataProvider$IrisBiomeCondition MYTHICMOBS_API io.lumine.mythic.api.skills.conditions.ILocationCondition
24 art.arcane.iris.core.link.data.MythicMobsDataProvider$IrisRegionCondition MYTHICMOBS_API io.lumine.mythic.api.skills.conditions.ILocationCondition
25 art.arcane.iris.core.link.data.NexoDataProvider BUKKIT_API org.bukkit.event.Listener
26 art.arcane.iris.core.nms.INMS BUKKIT_API org.bukkit.Bukkit
27 art.arcane.iris.core.project.IrisProject BUKKIT_API org.bukkit.entity.Entity
28 art.arcane.iris.core.runtime.WorldRuntimeControlService BUKKIT_API org.bukkit.entity.Entity
29 art.arcane.iris.core.service.BoardSVC BUKKIT_API org.bukkit.event.Listener
30 art.arcane.iris.core.service.BoardSVC$PlayerBoard BUKKIT_API org.bukkit.entity.Entity
31 art.arcane.iris.core.service.ExternalDataSVC BUKKIT_API org.bukkit.event.Listener
32 art.arcane.iris.core.service.GlobalCacheSVC BUKKIT_API org.bukkit.event.Listener
33 art.arcane.iris.core.service.LogFilterSVC BUKKIT_API org.bukkit.event.Listener
34 art.arcane.iris.core.service.ObjectSVC BUKKIT_API org.bukkit.event.Listener
35 art.arcane.iris.core.service.ObjectStudioSaveService BUKKIT_API org.bukkit.event.Listener
36 art.arcane.iris.core.service.PreservationSVC BUKKIT_API org.bukkit.event.Listener
37 art.arcane.iris.core.service.StudioSVC BUKKIT_API org.bukkit.event.Listener
38 art.arcane.iris.core.service.TreeSVC BUKKIT_API org.bukkit.event.Listener
39 art.arcane.iris.core.tools.IrisToolbelt BUKKIT_API org.bukkit.command.CommandSender
40 art.arcane.iris.core.tools.IrisWorldCreator BUKKIT_API org.bukkit.generator.ChunkGenerator
41 art.arcane.iris.core.tools.TreePlausibilizer BUKKIT_API org.bukkit.Keyed
42 art.arcane.iris.engine.IrisWorldManager BUKKIT_API org.bukkit.event.Listener
43 art.arcane.iris.engine.framework.EngineAssignedWorldManager BUKKIT_API org.bukkit.event.Listener
44 art.arcane.iris.engine.framework.placer.WorldObjectPlacer BUKKIT_API org.bukkit.event.Event
45 art.arcane.iris.engine.object.IrisDirection$11 BUKKIT_API org.bukkit.block.BlockFace
46 art.arcane.iris.engine.object.IrisEffect$BukkitFx BUKKIT_API org.bukkit.entity.Entity
47 art.arcane.iris.engine.object.IrisEntity$BukkitOps BUKKIT_API org.bukkit.loot.LootTable
48 art.arcane.iris.engine.object.IrisEntity$BukkitOps$1 BUKKIT_API org.bukkit.loot.LootTable
49 art.arcane.iris.engine.object.IrisObjectRotation$1 BUKKIT_API org.bukkit.block.BlockFace
50 art.arcane.iris.engine.object.IrisObjectRotation$Faces BUKKIT_API org.bukkit.block.BlockFace
51 art.arcane.iris.engine.object.LegacyTileData$BannerHandler BUKKIT_API org.bukkit.Keyed
52 art.arcane.iris.engine.object.LegacyTileData$Handlers BUKKIT_API org.bukkit.block.BlockState
53 art.arcane.iris.engine.object.LegacyTileData$SignHandler BUKKIT_API org.bukkit.Keyed
54 art.arcane.iris.engine.object.LegacyTileData$SignTags BUKKIT_API org.bukkit.Tag
55 art.arcane.iris.engine.object.LegacyTileData$SignTags$1 BUKKIT_API org.bukkit.Tag
56 art.arcane.iris.engine.platform.BukkitChunkGenerator BUKKIT_API org.bukkit.event.Listener
57 art.arcane.iris.engine.platform.BukkitChunkGenerator$2 BUKKIT_API org.bukkit.HeightMap
58 art.arcane.iris.engine.platform.DummyBiomeProvider BUKKIT_API org.bukkit.generator.BiomeProvider
59 art.arcane.iris.engine.platform.DummyChunkGenerator BUKKIT_API org.bukkit.generator.ChunkGenerator
60 art.arcane.iris.engine.platform.EngineBukkitOps BUKKIT_API org.bukkit.entity.Entity
61 art.arcane.iris.engine.platform.studio.generators.ObjectStudioGenerator BUKKIT_API org.bukkit.Material
62 art.arcane.iris.platform.bukkit.BukkitBlockResolution BUKKIT_API org.bukkit.Material
63 art.arcane.iris.platform.bukkit.BukkitPlatform BUKKIT_API org.bukkit.command.CommandSender
64 art.arcane.iris.util.common.data.IrisCustomData BUKKIT_API org.bukkit.block.data.BlockData
65 art.arcane.iris.util.common.data.registry.Attributes BUKKIT_API org.bukkit.attribute.Attribute
66 art.arcane.iris.util.common.data.registry.Materials BUKKIT_API org.bukkit.Material
67 art.arcane.iris.util.common.data.registry.Particles BUKKIT_API org.bukkit.Particle
68 art.arcane.iris.util.common.director.handlers.VectorHandler BUKKIT_API org.bukkit.util.Vector
69 art.arcane.iris.util.common.format.C$DyeMaps BUKKIT_API org.bukkit.DyeColor
70 art.arcane.iris.util.common.inventorygui.WindowResolution BUKKIT_API org.bukkit.event.inventory.InventoryType
71 art.arcane.iris.util.common.misc.Bindings BUKKIT_API org.bukkit.plugin.Plugin
72 art.arcane.iris.util.common.misc.ServerProperties SERVER_RUNTIME_FILE server.properties
73 art.arcane.iris.util.common.misc.SlimJar BUKKIT_API org.bukkit.plugin.Plugin
74 art.arcane.iris.util.common.plugin.CommandDummy BUKKIT_API org.bukkit.command.CommandSender
75 art.arcane.iris.util.common.plugin.Controller BUKKIT_API org.bukkit.event.Listener
76 art.arcane.iris.util.common.plugin.IController BUKKIT_API org.bukkit.event.Listener
77 art.arcane.iris.util.common.plugin.IrisService BUKKIT_API org.bukkit.event.Listener
78 art.arcane.iris.util.common.plugin.RouterCommand BUKKIT_API org.bukkit.command.Command
79 art.arcane.iris.util.common.plugin.VolmitPlugin BUKKIT_API org.bukkit.event.Listener
80 art.arcane.iris.util.common.plugin.VolmitSender BUKKIT_API org.bukkit.command.CommandSender
81 art.arcane.iris.util.common.plugin.chunk.ChunkTickets BUKKIT_API org.bukkit.event.Listener
82 art.arcane.iris.util.nbt.common.mca.NBTWorld$Holder BUKKIT_API org.bukkit.Material
83 art.arcane.iris.util.project.sentry.ServerID$Holder BUKKIT_API org.bukkit.Bukkit
@@ -0,0 +1,204 @@
package art.arcane.iris.probe;
import org.junit.Test;
import org.junit.Rule;
import org.junit.rules.TemporaryFolder;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public final class ClassloadProbeTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void scansNamedAndGeneratedNestedClasses() throws Exception {
Path classesRoot = temporaryFolder.newFolder("classes").toPath();
Path packageRoot = Files.createDirectories(classesRoot.resolve("art/arcane/iris"));
Files.createFile(packageRoot.resolve("Outer.class"));
Files.createFile(packageRoot.resolve("Outer$Nested.class"));
Files.createFile(packageRoot.resolve("Outer$1.class"));
Files.createFile(packageRoot.resolve("ignored.txt"));
List<String> names = ClassloadProbe.scanClassNames(classesRoot);
assertEquals(List.of(
"art.arcane.iris.Outer",
"art.arcane.iris.Outer$1",
"art.arcane.iris.Outer$Nested"
), names);
}
@Test
public void scansEmptyClassDirectory() throws Exception {
Path classesRoot = temporaryFolder.newFolder("empty-classes").toPath();
assertTrue(ClassloadProbe.scanClassNames(classesRoot).isEmpty());
}
@Test
public void acceptsMatchingReviewedFailure() {
ClassloadProbe.Failure failure = new ClassloadProbe.Failure(
"java.lang.ClassNotFoundException",
"org.bukkit.event.Listener"
);
ClassloadProbe.Allowance allowance = new ClassloadProbe.Allowance(
ClassloadProbe.AllowanceCategory.BUKKIT_API,
"org.bukkit.event.Listener"
);
ClassloadProbe.Review review = ClassloadProbe.review(
Map.of("art.arcane.iris.Allowed", failure),
Map.of("art.arcane.iris.Allowed", allowance)
);
assertTrue(review.passes());
}
@Test
public void rejectsUnlistedFailure() {
ClassloadProbe.Failure failure = new ClassloadProbe.Failure(
"java.lang.ClassNotFoundException",
"org.bukkit.event.Listener"
);
ClassloadProbe.Review review = ClassloadProbe.review(
Map.of("art.arcane.iris.Unlisted", failure),
Map.of()
);
assertFalse(review.passes());
assertTrue(review.unexpected().containsKey("art.arcane.iris.Unlisted"));
}
@Test
public void acceptsDifferentMissingClassInReviewedNamespace() {
ClassloadProbe.Failure changedFailure = new ClassloadProbe.Failure(
"java.lang.ClassNotFoundException",
"org.bukkit.Material"
);
ClassloadProbe.Allowance allowance = new ClassloadProbe.Allowance(
ClassloadProbe.AllowanceCategory.BUKKIT_API,
"org.bukkit.event.Listener"
);
ClassloadProbe.Review review = ClassloadProbe.review(
Map.of("art.arcane.iris.Changed", changedFailure),
Map.of("art.arcane.iris.Changed", allowance)
);
assertTrue(review.passes());
}
@Test
public void rejectsChangedFailureNamespace() {
ClassloadProbe.Failure changedFailure = new ClassloadProbe.Failure(
"java.lang.ClassNotFoundException",
"io.papermc.lib.environments.Environment"
);
ClassloadProbe.Allowance allowance = new ClassloadProbe.Allowance(
ClassloadProbe.AllowanceCategory.BUKKIT_API,
"org.bukkit.event.Listener"
);
ClassloadProbe.Review review = ClassloadProbe.review(
Map.of("art.arcane.iris.Changed", changedFailure),
Map.of("art.arcane.iris.Changed", allowance)
);
assertFalse(review.passes());
assertTrue(review.unexpected().containsKey("art.arcane.iris.Changed"));
}
@Test
public void rejectsNonMissingClassFailure() {
ClassloadProbe.Failure changedFailure = new ClassloadProbe.Failure(
"java.lang.IllegalStateException",
"org.bukkit.event.Listener"
);
ClassloadProbe.Allowance allowance = new ClassloadProbe.Allowance(
ClassloadProbe.AllowanceCategory.BUKKIT_API,
"org.bukkit.event.Listener"
);
assertFalse(allowance.matches(changedFailure));
}
@Test
public void acceptsBukkitFailureCachedByClassInitialization() {
ClassloadProbe.Failure failure = new ClassloadProbe.Failure(
"java.lang.ExceptionInInitializerError",
"Exception java.lang.NoClassDefFoundError: org/bukkit/Material [in thread \"main\"]"
);
ClassloadProbe.Allowance allowance = new ClassloadProbe.Allowance(
ClassloadProbe.AllowanceCategory.BUKKIT_API,
"org.bukkit.Material"
);
assertTrue(allowance.matches(failure));
}
@Test
public void acceptsDifferentBukkitFailureCachedByClassInitialization() {
ClassloadProbe.Failure failure = new ClassloadProbe.Failure(
"java.lang.ExceptionInInitializerError",
"Exception java.lang.NoClassDefFoundError: org/bukkit/World [in thread \"main\"]"
);
ClassloadProbe.Allowance allowance = new ClassloadProbe.Allowance(
ClassloadProbe.AllowanceCategory.BUKKIT_API,
"org.bukkit.Material"
);
assertTrue(allowance.matches(failure));
}
@Test
public void rejectsDifferentNamespaceCachedByClassInitialization() {
ClassloadProbe.Failure failure = new ClassloadProbe.Failure(
"java.lang.ExceptionInInitializerError",
"Exception java.lang.NoClassDefFoundError: io/papermc/lib/environments/Environment [in thread \"main\"]"
);
ClassloadProbe.Allowance allowance = new ClassloadProbe.Allowance(
ClassloadProbe.AllowanceCategory.BUKKIT_API,
"org.bukkit.Material"
);
assertFalse(allowance.matches(failure));
}
@Test
public void acceptsMythicMobsApiNamespace() {
ClassloadProbe.Failure failure = new ClassloadProbe.Failure(
"java.lang.ClassNotFoundException",
"io.lumine.mythic.api.skills.conditions.ILocationCondition"
);
ClassloadProbe.Allowance allowance = new ClassloadProbe.Allowance(
ClassloadProbe.AllowanceCategory.MYTHICMOBS_API,
"io.lumine.mythic.api.skills.conditions.ILocationCondition"
);
assertTrue(allowance.matches(failure));
}
@Test
public void rejectsStaleAllowance() {
ClassloadProbe.Allowance allowance = new ClassloadProbe.Allowance(
ClassloadProbe.AllowanceCategory.BUKKIT_API,
"org.bukkit.event.Listener"
);
TreeMap<String, ClassloadProbe.Allowance> allowlist = new TreeMap<>();
allowlist.put("art.arcane.iris.Stale", allowance);
ClassloadProbe.Review review = ClassloadProbe.review(Map.of(), allowlist);
assertFalse(review.passes());
assertTrue(review.staleAllowances().containsKey("art.arcane.iris.Stale"));
}
}
@@ -26,5 +26,7 @@ public interface PlatformEntityType {
String namespace();
String spawnCategory();
Object nativeHandle();
}
+3
View File
@@ -30,6 +30,9 @@ macOS / Linux:
./run.sh
```
The run scripts compile the sources and create the ignored `simd-bench.jar` locally when it is
missing. Use `build.bat` or `./build.sh` to rebuild it explicitly.
Or invoke directly (the `--add-modules` flag is required because the Vector API
is still an incubator module):

Some files were not shown because too many files have changed in this diff Show More