This commit is contained in:
Brian Neumann-Fopiano
2026-08-11 20:35:27 -04:00
parent 82290a3090
commit 623a02025c
76 changed files with 4820 additions and 280 deletions
@@ -1,7 +1,10 @@
package art.arcane.iris.core.nms.v26_2_R1;
import art.arcane.iris.core.datapack.DatapackStructureScopeIndex;
import art.arcane.iris.engine.framework.NativeStructureFrequencyScale;
import art.arcane.iris.engine.object.IrisImportedStructureControl;
import net.minecraft.core.Holder;
import net.minecraft.core.HolderOwner;
import net.minecraft.core.Vec3i;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.chunk.ChunkGeneratorStructureState;
@@ -30,6 +33,21 @@ final class DatapackStructureStateFilter {
DatapackStructureScopeIndex scopeIndex,
Set<String> declaredSources
) {
return filter(structureSets, scopeIndex, declaredSources, new IrisImportedStructureControl());
}
static Selection filter(
List<Holder<StructureSet>> structureSets,
DatapackStructureScopeIndex scopeIndex,
Set<String> declaredSources,
IrisImportedStructureControl importedStructures
) {
if (scopeIndex.isEmpty()) {
return new Selection(
scaleFrequencyOverrides(structureSets, importedStructures),
0,
0);
}
Map<String, Holder<StructureSet>> holdersByKey = new HashMap<>();
for (Holder<StructureSet> holder : structureSets) {
String key = structureSetKey(holder);
@@ -64,7 +82,7 @@ final class DatapackStructureStateFilter {
filteredSets.add(scopedHolder);
}
return new Selection(
List.copyOf(filteredSets),
scaleFrequencyOverrides(List.copyOf(filteredSets), importedStructures),
retainedManagedSets,
excludedManagedSets);
}
@@ -138,7 +156,8 @@ final class DatapackStructureStateFilter {
scopedByIdentity.put(holder, holder);
return holder;
}
Holder<StructureSet> scoped = Holder.direct(new StructureSet(entries, placement));
Holder<StructureSet> scoped = replacementHolder(
holder, new StructureSet(entries, placement));
scopedByIdentity.put(holder, scoped);
return scoped;
} finally {
@@ -193,12 +212,138 @@ final class DatapackStructureStateFilter {
if (scopedTarget == currentZone.get().otherSet()) {
return placement;
}
return copyPlacement(placement, scopedZone);
return copyPlacement(placement, scopedZone, 1D);
}
private static List<Holder<StructureSet>> scaleFrequencyOverrides(
List<Holder<StructureSet>> structureSets,
IrisImportedStructureControl importedStructures
) {
if (!importedStructures.hasFrequencyOverrides()) {
return structureSets;
}
Map<String, Holder<StructureSet>> holdersByKey = new HashMap<>();
for (Holder<StructureSet> holder : structureSets) {
String key = structureSetKey(holder);
if (key != null) {
holdersByKey.putIfAbsent(key, holder);
}
}
Map<Holder<StructureSet>, Holder<StructureSet>> scaledByIdentity = new IdentityHashMap<>();
List<Holder<StructureSet>> scaledSets = new ArrayList<>(structureSets.size());
boolean changed = false;
for (Holder<StructureSet> holder : structureSets) {
Holder<StructureSet> scaled = scaleFrequencyHolder(
holder, importedStructures, holdersByKey, scaledByIdentity);
scaledSets.add(scaled);
changed |= scaled != holder;
}
return changed ? List.copyOf(scaledSets) : structureSets;
}
private static Holder<StructureSet> scaleFrequencyHolder(
Holder<StructureSet> holder,
IrisImportedStructureControl importedStructures,
Map<String, Holder<StructureSet>> holdersByKey,
Map<Holder<StructureSet>, Holder<StructureSet>> scaledByIdentity
) {
if (scaledByIdentity.containsKey(holder)) {
return scaledByIdentity.get(holder);
}
if (!dependsOnFrequencyOverride(holder, importedStructures, holdersByKey)) {
scaledByIdentity.put(holder, holder);
return holder;
}
ResourceKey<StructureSet> holderKey = structureSetResourceKey(holder).orElseThrow(() ->
new IllegalStateException(
"An affected native structure-set exclusion graph has an unkeyed holder"));
ReboundStructureSetHolder scaledHolder = new ReboundStructureSetHolder(holderKey);
scaledByIdentity.put(holder, scaledHolder);
StructureSet originalSet = holder.value();
StructurePlacement originalPlacement = originalSet.placement();
Optional<StructurePlacement.ExclusionZone> originalZone = exclusionZone(originalPlacement);
Optional<StructurePlacement.ExclusionZone> scaledZone = originalZone;
if (originalZone.isPresent()) {
Holder<StructureSet> target = canonicalHolder(
originalZone.get().otherSet(), holdersByKey);
Holder<StructureSet> scaledTarget = scaleFrequencyHolder(
target, importedStructures, holdersByKey, scaledByIdentity);
if (scaledTarget != originalZone.get().otherSet()) {
scaledZone = Optional.of(new StructurePlacement.ExclusionZone(
scaledTarget, originalZone.get().chunkCount()));
}
}
double multiplier = importedStructures.frequencyMultiplier(structureSetKey(holder));
StructurePlacement scaledPlacement = multiplier == 1D && scaledZone.equals(originalZone)
? originalPlacement
: copyPlacement(originalPlacement, scaledZone, multiplier);
scaledHolder.bind(new StructureSet(originalSet.structures(), scaledPlacement));
return scaledHolder;
}
private static boolean dependsOnFrequencyOverride(
Holder<StructureSet> holder,
IrisImportedStructureControl importedStructures,
Map<String, Holder<StructureSet>> holdersByKey
) {
Set<Holder<StructureSet>> visited = Collections.newSetFromMap(new IdentityHashMap<>());
Holder<StructureSet> current = holder;
while (visited.add(current)) {
if (importedStructures.frequencyMultiplier(structureSetKey(current)) != 1D) {
return true;
}
Optional<StructurePlacement.ExclusionZone> zone =
exclusionZone(current.value().placement());
if (zone.isEmpty()) {
return false;
}
current = canonicalHolder(zone.get().otherSet(), holdersByKey);
}
return false;
}
private static Holder<StructureSet> canonicalHolder(
Holder<StructureSet> holder,
Map<String, Holder<StructureSet>> holdersByKey
) {
String key = structureSetKey(holder);
return key == null ? holder : holdersByKey.getOrDefault(key, holder);
}
private static Holder<StructureSet> replacementHolder(
Holder<StructureSet> original,
StructureSet replacement
) {
Optional<ResourceKey<StructureSet>> key = structureSetResourceKey(original);
if (key.isEmpty()) {
return Holder.direct(replacement);
}
ReboundStructureSetHolder holder = new ReboundStructureSetHolder(key.get());
holder.bind(replacement);
return holder;
}
private static Optional<ResourceKey<StructureSet>> structureSetResourceKey(
Holder<StructureSet> holder
) {
Optional<ResourceKey<StructureSet>> key = holder.unwrapKey();
if (key.isPresent()) {
return key;
}
if (holder.value().placement()
instanceof ChunkGeneratorStructureState.KeyedRandomSpreadStructurePlacement keyedPlacement) {
return Optional.of(keyedPlacement.key);
}
return Optional.empty();
}
private static StructurePlacement copyPlacement(
StructurePlacement placement,
Optional<StructurePlacement.ExclusionZone> exclusionZone
Optional<StructurePlacement.ExclusionZone> exclusionZone,
double frequencyMultiplier
) {
Vec3i locateOffset = (Vec3i) declaredFieldValue(
StructurePlacement.class, placement, Vec3i.class);
@@ -212,34 +357,47 @@ final class DatapackStructureStateFilter {
int salt = (int) declaredFieldValue(
StructurePlacement.class, placement, int.class);
if (placement instanceof ChunkGeneratorStructureState.KeyedRandomSpreadStructurePlacement keyedPlacement) {
if (placement.getClass()
== ChunkGeneratorStructureState.KeyedRandomSpreadStructurePlacement.class) {
ChunkGeneratorStructureState.KeyedRandomSpreadStructurePlacement keyedPlacement =
(ChunkGeneratorStructureState.KeyedRandomSpreadStructurePlacement) placement;
NativeStructureFrequencyScale scale = NativeStructureFrequencyScale.randomSpread(
frequency, keyedPlacement.spacing(), keyedPlacement.separation(), frequencyMultiplier);
return new ChunkGeneratorStructureState.KeyedRandomSpreadStructurePlacement(
keyedPlacement.key,
locateOffset,
frequencyReductionMethod,
frequency,
scale.frequency(),
salt,
exclusionZone,
keyedPlacement.spacing(),
scale.spacing(),
keyedPlacement.separation(),
keyedPlacement.spreadType());
}
if (placement instanceof RandomSpreadStructurePlacement randomSpread) {
if (placement.getClass() == RandomSpreadStructurePlacement.class) {
RandomSpreadStructurePlacement randomSpread =
(RandomSpreadStructurePlacement) placement;
NativeStructureFrequencyScale scale = NativeStructureFrequencyScale.randomSpread(
frequency, randomSpread.spacing(), randomSpread.separation(), frequencyMultiplier);
return new RandomSpreadStructurePlacement(
locateOffset,
frequencyReductionMethod,
frequency,
scale.frequency(),
salt,
exclusionZone,
randomSpread.spacing(),
scale.spacing(),
randomSpread.separation(),
randomSpread.spreadType());
}
if (placement instanceof ConcentricRingsStructurePlacement rings) {
if (placement.getClass() == ConcentricRingsStructurePlacement.class) {
ConcentricRingsStructurePlacement rings =
(ConcentricRingsStructurePlacement) placement;
float scaledFrequency = NativeStructureFrequencyScale.probability(
frequency, frequencyMultiplier);
return new ConcentricRingsStructurePlacement(
locateOffset,
frequencyReductionMethod,
frequency,
scaledFrequency,
salt,
exclusionZone,
rings.distance(),
@@ -247,7 +405,8 @@ final class DatapackStructureStateFilter {
rings.count(),
rings.preferredBiomes());
}
throw new IllegalStateException("Unsupported structure placement with an exclusion zone: "
throw new IllegalStateException("Unsupported native structure placement in an affected "
+ "frequency-override or datapack-scope graph: "
+ placement.getClass().getName());
}
@@ -286,4 +445,15 @@ final class DatapackStructureStateFilter {
int excludedManagedSets
) {
}
private static final class ReboundStructureSetHolder extends Holder.Reference<StructureSet> {
private ReboundStructureSetHolder(ResourceKey<StructureSet> key) {
super(Type.STAND_ALONE, new HolderOwner<>() {
}, key, null);
}
private void bind(StructureSet structureSet) {
bindValue(structureSet);
}
}
}
@@ -788,7 +788,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
throw new IllegalStateException("Iris cannot generate native structures in chunk "
+ disabledChunk.x() + "," + disabledChunk.z()
+ " because structure generation is disabled outside the pack; enable native structure generation "
+ "and deny individual structures through importedStructures.disabled");
+ "and deny families through importedStructures.disabled or complete keys through importedStructures.disabledExact");
}
ChunkPos chunkPos = chunk.getPos();
SectionPos sectionPos = SectionPos.of(chunkPos, world.getMinSectionY());
@@ -6,6 +6,7 @@ import ca.spottedleaf.moonrise.patches.chunk_system.scheduling.NewChunkHolder;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import art.arcane.iris.core.datapack.DatapackStructureScopeIndex;
import art.arcane.iris.core.nms.DatapackStructureScopeResult;
import art.arcane.iris.engine.object.IrisImportedStructureControl;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.core.nms.INMSBinding;
import art.arcane.iris.core.nms.MinecraftVersion;
@@ -1221,7 +1222,8 @@ public class NMSBinding implements INMSBinding {
public DatapackStructureScopeResult scopeDatapackStructures(
World world,
DatapackStructureScopeIndex scopeIndex,
Set<String> declaredSources
Set<String> declaredSources,
IrisImportedStructureControl importedStructures
) throws NoSuchFieldException, IllegalAccessException {
ServerLevel level = ((CraftWorld) world).getHandle();
ChunkMap chunkMap = level.getChunkSource().chunkMap;
@@ -1229,7 +1231,7 @@ public class NMSBinding implements INMSBinding {
net.minecraft.world.level.chunk.ChunkGenerator generator = level.getChunkSource().getGenerator();
ChunkGeneratorStructureState scopedState = createStructureState(level, generator, currentState);
DatapackStructureStateFilter.Selection selection = DatapackStructureStateFilter.filter(
scopedState.possibleStructureSets(), scopeIndex, declaredSources);
scopedState.possibleStructureSets(), scopeIndex, declaredSources, importedStructures);
Field possibleSetsField = getField(scopedState.getClass(), List.class);
possibleSetsField.setAccessible(true);
@@ -2,6 +2,9 @@ package art.arcane.iris.core.nms.v26_2_R1;
import art.arcane.iris.core.datapack.DatapackIngestService;
import art.arcane.iris.core.datapack.DatapackStructureScopeIndex;
import art.arcane.iris.engine.object.IrisImportedStructureControl;
import art.arcane.iris.engine.object.IrisStructureSetFrequencyOverride;
import art.arcane.volmlib.util.collection.KList;
import com.mojang.datafixers.util.Either;
import net.minecraft.SharedConstants;
import net.minecraft.core.Holder;
@@ -18,6 +21,7 @@ import net.minecraft.world.level.levelgen.structure.StructureSet;
import net.minecraft.world.level.levelgen.structure.placement.RandomSpreadStructurePlacement;
import net.minecraft.world.level.levelgen.structure.placement.RandomSpreadType;
import net.minecraft.world.level.levelgen.structure.placement.StructurePlacement;
import net.minecraft.world.level.levelgen.structure.placement.StructurePlacementType;
import net.minecraft.world.level.chunk.ChunkGeneratorStructureState;
import org.junit.Test;
import org.junit.BeforeClass;
@@ -33,7 +37,9 @@ import java.util.stream.Stream;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class NMSBindingDatapackStructureScopeTest {
@@ -126,6 +132,161 @@ public class NMSBindingDatapackStructureScopeTest {
DatapackStructureStateFilter.structureSetKey(Holder.direct(structureSet)));
}
@Test
public void exactFrequencyOverridesScaleOnlyTheNamedNativeStructureSet() {
Holder<StructureSet> complexes = structureSetHolder(
"minecraft:nether_complexes",
new RandomSpreadStructurePlacement(27, 4, RandomSpreadType.LINEAR, 30084232),
structureHolder("minecraft:fortress"));
Holder<StructureSet> fossils = structureSetHolder(
"minecraft:nether_fossils",
new RandomSpreadStructurePlacement(2, 1, RandomSpreadType.LINEAR, 14357921),
structureHolder("minecraft:nether_fossil"));
KList<IrisStructureSetFrequencyOverride> overrides = new KList<>();
overrides.add(new IrisStructureSetFrequencyOverride()
.setStructureSet("minecraft:nether_complexes")
.setMultiplier(1.1D));
IrisImportedStructureControl control = new IrisImportedStructureControl()
.setFrequencyOverrides(overrides);
DatapackStructureStateFilter.Selection selection = DatapackStructureStateFilter.filter(
List.of(complexes, fossils), index(List.of(), List.of()), Set.of(), control);
RandomSpreadStructurePlacement scaledComplexes =
(RandomSpreadStructurePlacement) selection.structureSets().get(0).value().placement();
RandomSpreadStructurePlacement unchangedFossils =
(RandomSpreadStructurePlacement) selection.structureSets().get(1).value().placement();
assertEquals(26, scaledComplexes.spacing());
assertEquals(4, scaledComplexes.separation());
assertEquals(2, unchangedFossils.spacing());
assertSame(fossils, selection.structureSets().get(1));
assertEquals(27, ((RandomSpreadStructurePlacement) complexes.value().placement()).spacing());
}
@Test
public void frequencyOnlyPassLeavesUnrelatedCustomPlacementUntouched() {
Holder<StructureSet> exclusionTarget = structureSetHolder(
"example:target", structureHolder("example:target"));
Holder<StructureSet> custom = structureSetHolder(
"example:custom",
new UnsupportedPlacement(Optional.of(
new StructurePlacement.ExclusionZone(exclusionTarget, 1))),
structureHolder("example:custom"));
KList<IrisStructureSetFrequencyOverride> overrides = new KList<>();
overrides.add(new IrisStructureSetFrequencyOverride()
.setStructureSet("minecraft:nether_complexes")
.setMultiplier(1.1D));
IrisImportedStructureControl control = new IrisImportedStructureControl()
.setFrequencyOverrides(overrides);
DatapackStructureStateFilter.Selection selection = DatapackStructureStateFilter.filter(
List.of(custom), index(List.of(), List.of()), Set.of(), control);
assertSame(custom, selection.structureSets().getFirst());
}
@Test
public void frequencyOnlyPassRebindsDependentExclusionTarget() {
Holder<StructureSet> target = structureSetHolder(
"example:target",
new RandomSpreadStructurePlacement(27, 4, RandomSpreadType.LINEAR, 30084232),
structureHolder("example:target"));
RandomSpreadStructurePlacement dependentPlacement = new RandomSpreadStructurePlacement(
Vec3i.ZERO,
StructurePlacement.FrequencyReductionMethod.DEFAULT,
1.0F,
4567,
Optional.of(new StructurePlacement.ExclusionZone(target, 1)),
32,
8,
RandomSpreadType.LINEAR);
Holder<StructureSet> dependent = structureSetHolder(
"example:dependent",
dependentPlacement,
structureHolder("example:dependent"));
KList<IrisStructureSetFrequencyOverride> overrides = new KList<>();
overrides.add(new IrisStructureSetFrequencyOverride()
.setStructureSet("example:target")
.setMultiplier(1.1D));
IrisImportedStructureControl control = new IrisImportedStructureControl()
.setFrequencyOverrides(overrides);
DatapackStructureStateFilter.Selection selection = DatapackStructureStateFilter.filter(
List.of(dependent, target), index(List.of(), List.of()), Set.of(), control);
Holder<StructureSet> scaledDependent = selection.structureSets().get(0);
Holder<StructureSet> scaledTarget = selection.structureSets().get(1);
assertNotSame(dependent, scaledDependent);
assertNotSame(target, scaledTarget);
assertSame(scaledTarget, DatapackStructureStateFilter.exclusionZone(
scaledDependent.value().placement()).orElseThrow().otherSet());
assertEquals(26, ((RandomSpreadStructurePlacement)
scaledTarget.value().placement()).spacing());
}
@Test
public void frequencyOnlyPassRebindsAffectedExclusionCycle() {
MutableStructureSetHolder first = new MutableStructureSetHolder("example:first");
MutableStructureSetHolder second = new MutableStructureSetHolder("example:second");
first.bind(structureSet(
new RandomSpreadStructurePlacement(
Vec3i.ZERO,
StructurePlacement.FrequencyReductionMethod.DEFAULT,
1.0F,
101,
Optional.of(new StructurePlacement.ExclusionZone(second, 1)),
32,
8,
RandomSpreadType.LINEAR),
"example:first"));
second.bind(structureSet(
new RandomSpreadStructurePlacement(
Vec3i.ZERO,
StructurePlacement.FrequencyReductionMethod.DEFAULT,
1.0F,
102,
Optional.of(new StructurePlacement.ExclusionZone(first, 1)),
27,
4,
RandomSpreadType.LINEAR),
"example:second"));
KList<IrisStructureSetFrequencyOverride> overrides = new KList<>();
overrides.add(new IrisStructureSetFrequencyOverride()
.setStructureSet("example:second")
.setMultiplier(1.1D));
IrisImportedStructureControl control = new IrisImportedStructureControl()
.setFrequencyOverrides(overrides);
DatapackStructureStateFilter.Selection selection = DatapackStructureStateFilter.filter(
List.of(first, second), index(List.of(), List.of()), Set.of(), control);
Holder<StructureSet> scaledFirst = selection.structureSets().get(0);
Holder<StructureSet> scaledSecond = selection.structureSets().get(1);
assertSame(scaledSecond, DatapackStructureStateFilter.exclusionZone(
scaledFirst.value().placement()).orElseThrow().otherSet());
assertSame(scaledFirst, DatapackStructureStateFilter.exclusionZone(
scaledSecond.value().placement()).orElseThrow().otherSet());
assertEquals(26, ((RandomSpreadStructurePlacement)
scaledSecond.value().placement()).spacing());
}
@Test
public void affectedRandomSpreadSubclassFailsInsteadOfLosingSubtypeBehavior() {
Holder<StructureSet> custom = structureSetHolder(
"example:custom",
new CustomRandomSpreadPlacement(),
structureHolder("example:custom"));
KList<IrisStructureSetFrequencyOverride> overrides = new KList<>();
overrides.add(new IrisStructureSetFrequencyOverride()
.setStructureSet("example:custom")
.setMultiplier(1.1D));
IrisImportedStructureControl control = new IrisImportedStructureControl()
.setFrequencyOverrides(overrides);
assertThrows(IllegalStateException.class, () -> DatapackStructureStateFilter.filter(
List.of(custom), index(List.of(), List.of()), Set.of(), control));
}
@Test
public void excludedManagedSetCannotSuppressAnAllowedSetThroughExclusionZone() {
Holder<StructureSet> managedSet = structureSetHolder(
@@ -273,6 +434,13 @@ public class NMSBindingDatapackStructureScopeTest {
value);
}
private static StructureSet structureSet(StructurePlacement placement, String structureKey) {
return new StructureSet(
List.of(new StructureSet.StructureSelectionEntry(
structureHolder(structureKey), 1)),
placement);
}
private static final class KeyedHolder<T> implements Holder<T> {
private final ResourceKey<T> key;
private final T value;
@@ -352,4 +520,37 @@ public class NMSBindingDatapackStructureScopeTest {
return true;
}
}
private static final class UnsupportedPlacement extends StructurePlacement {
private UnsupportedPlacement(Optional<ExclusionZone> exclusionZone) {
super(Vec3i.ZERO, FrequencyReductionMethod.DEFAULT, 1F, 1, exclusionZone);
}
@Override
protected boolean isPlacementChunk(ChunkGeneratorStructureState state, int x, int z) {
return false;
}
@Override
public StructurePlacementType<?> type() {
return null;
}
}
private static final class MutableStructureSetHolder extends Holder.Reference<StructureSet> {
private MutableStructureSetHolder(String key) {
super(Type.STAND_ALONE, new HolderOwner<>() {
}, ResourceKey.create(Registries.STRUCTURE_SET, Identifier.parse(key)), null);
}
private void bind(StructureSet structureSet) {
bindValue(structureSet);
}
}
private static final class CustomRandomSpreadPlacement extends RandomSpreadStructurePlacement {
private CustomRandomSpreadPlacement() {
super(27, 4, RandomSpreadType.LINEAR, 30084232);
}
}
}
@@ -32,6 +32,7 @@ import art.arcane.iris.core.IrisStartupAdmissionListener;
import art.arcane.iris.core.BukkitWorldReconciler;
import art.arcane.iris.core.IrisWorldGeneratorResolver;
import art.arcane.iris.core.PendingWorldDeleteQueue;
import art.arcane.iris.core.PendingWorldReplacementManager;
import art.arcane.iris.core.SettingsHotloadWatch;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.core.datapack.DatapackIngestService;
@@ -167,6 +168,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
private final IrisWorldGeneratorResolver generatorResolver = new IrisWorldGeneratorResolver(this);
private final BukkitWorldReconciler worldReconciler = new BukkitWorldReconciler(this);
private final PendingWorldDeleteQueue pendingWorldDeletes = new PendingWorldDeleteQueue(this);
private final PendingWorldReplacementManager pendingWorldReplacements = new PendingWorldReplacementManager(this);
private volatile SettingsHotloadWatch settingsHotloadWatch;
public static VolmitSender getSender() {
@@ -595,6 +597,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
services.values().forEach(IrisService::onEnable);
services.values().forEach(this::registerListener);
addShutdownHook();
pendingWorldReplacements.processPendingStartupReplacements();
pendingWorldDeletes.processPendingStartupWorldDeletes();
WorldLifecycleService.get();
WorldRuntimeControlService.get();
@@ -604,6 +607,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
}
J.s(() -> {
pendingWorldReplacements.verifyLoadedPublishedWorlds();
J.a(this::bstats);
J.ar(() -> settingsHotloadWatch.checkConfigHotload(configHotloadEngine), 60);
J.sr(this::tickQueue, 0);
@@ -642,6 +646,10 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
return worldReconciler;
}
public PendingWorldReplacementManager pendingWorldReplacements() {
return pendingWorldReplacements;
}
private void autoStartStudio() {
if (IrisSettings.get().getStudio().isAutoStartDefaultStudio()) {
Iris.info("Starting up auto Studio!");
@@ -683,6 +691,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
IrisPlatforms.bind(new BukkitPlatform());
IrisStartupValidation.begin();
Bukkit.getPluginManager().registerEvents(new IrisStartupAdmissionListener(), this);
Bukkit.getPluginManager().registerEvents(pendingWorldReplacements, this);
enable();
BukkitGuiHost.install();
super.onEnable();
@@ -741,13 +741,19 @@ public final class BukkitWorldReconciler {
@Override
public void requireDimensionLoadable(NamespacedKey worldKey, String dimension) {
File snapshotRoot = IrisWorldStorage.packRoot(worldKey);
boolean snapshotPresent = snapshotRoot.isDirectory();
if (snapshotPresent) {
IrisWorldGeneratorResolver.requireSnapshotLoadable(snapshotRoot);
}
String worldName = IrisWorldStorage.logicalName(worldKey);
IrisDimension irisDimension = IrisWorldGeneratorResolver.loadDimension(worldName, dimension);
if (irisDimension == null) {
throw new IllegalStateException("Could not resolve the Iris dimension \"" + dimension + "\".");
}
PackValidationRegistry.requireLoadable(
irisDimension.getLoader().getDataFolder().getName());
if (!snapshotPresent) {
PackValidationRegistry.requireLoadable(irisDimension.getLoader().getDataFolder().getName());
}
}
}
}
@@ -55,6 +55,8 @@ import java.util.function.Supplier;
* Bukkit plugin entry points delegate to.
*/
public final class IrisWorldGeneratorResolver {
private static final Object SNAPSHOT_VALIDATION_LOCK = new Object();
private final VolmitPlugin plugin;
public IrisWorldGeneratorResolver(VolmitPlugin plugin) {
@@ -134,6 +136,35 @@ public final class IrisWorldGeneratorResolver {
IrisStartupValidation.markPacksReady();
}
static PackValidationResult requireSnapshotLoadable(File packRoot) {
Path normalizedRoot = packRoot.toPath().toAbsolutePath().normalize();
PackValidationResult result = PackValidationRegistry.get(normalizedRoot);
if (result == null) {
synchronized (SNAPSHOT_VALIDATION_LOCK) {
result = PackValidationRegistry.get(normalizedRoot);
if (result == null) {
try {
result = PackValidator.validate(normalizedRoot.toFile());
} catch (Throwable exception) {
Iris.reportError("Snapshot pack validation failed for '" + normalizedRoot + "'", exception);
String detail = exception.getMessage();
if (detail == null || detail.isBlank()) {
detail = exception.getClass().getSimpleName();
}
result = new PackValidationResult(
normalizedRoot.getFileName().toString(),
List.of("Pack validation failed with " + exception.getClass().getSimpleName()
+ ": " + detail),
List.of(),
System.currentTimeMillis());
}
PackValidationRegistry.publish(normalizedRoot, result);
}
}
}
return PackValidationRegistry.requireLoadable(normalizedRoot);
}
@Nullable
public static IrisDimension loadDimension(@NonNull String worldName, @NonNull String id) {
File pack = IrisWorldStorage.packRoot(IrisWorldStorage.keyFromName(worldName));
@@ -188,9 +219,17 @@ public final class IrisWorldGeneratorResolver {
if (dim == null) {
throw new RuntimeException("Can't find dimension " + id + "!");
}
String packName = dim.getLoader().getDataFolder().getName();
NamespacedKey worldKey = IrisWorldStorage.keyFromName(worldName);
File snapshotRoot = IrisWorldStorage.packRoot(worldKey);
File dimensionPackRoot = dim.getLoader().getDataFolder();
String packName = dimensionPackRoot.getName();
try {
if (snapshotRoot.toPath().toAbsolutePath().normalize()
.equals(dimensionPackRoot.toPath().toAbsolutePath().normalize())) {
requireSnapshotLoadable(snapshotRoot);
} else {
PackValidationRegistry.requireLoadable(packName);
}
} catch (BrokenPackException exception) {
Iris.error("Refusing to create world '" + worldName + "' using broken pack '" + packName + "':");
for (String reason : exception.getReasons()) {
@@ -200,7 +239,6 @@ public final class IrisWorldGeneratorResolver {
}
Iris.debug("Assuming IrisDimension: " + dim.getName());
NamespacedKey worldKey = IrisWorldStorage.keyFromName(worldName);
IrisWorld w = IrisWorld.builder()
.platformIdentity(worldKey.toString())
@@ -225,6 +263,7 @@ public final class IrisWorldGeneratorResolver {
} else {
dim = installedDimension;
}
requireSnapshotLoadable(ff);
return new BukkitChunkGenerator(w, false, ff, dim.getLoadKey());
}
@@ -0,0 +1,799 @@
package art.arcane.iris.core;
import art.arcane.iris.Iris;
import art.arcane.iris.core.ExactWorldSlotPathPolicy.SlotKind;
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration;
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.GeneratorReplacement;
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.WorldGeneratorSnapshot;
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
import art.arcane.iris.core.lifecycle.WorldReplacementFilesystem;
import art.arcane.iris.core.lifecycle.WorldReplacementFilesystem.ReplacementPaths;
import art.arcane.iris.core.pack.PackValidationRegistry;
import art.arcane.iris.core.service.StudioSVC;
import art.arcane.iris.core.tools.IrisToolbelt;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisEnvironment;
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
import art.arcane.iris.util.common.misc.ServerProperties;
import art.arcane.iris.util.common.plugin.VolmitSender;
import art.arcane.iris.util.common.scheduling.J;
import art.arcane.volmlib.util.bukkit.WorldIdentity;
import org.bukkit.NamespacedKey;
import org.bukkit.World;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.world.WorldLoadEvent;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Properties;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
public final class PendingWorldReplacementManager implements Listener {
private static final String JOURNAL_DIRECTORY = "pending-world-replacements";
private static final String JOURNAL_SUFFIX = ".properties";
private final Iris plugin;
public PendingWorldReplacementManager(Iris plugin) {
this.plugin = Objects.requireNonNull(plugin, "plugin");
}
public NamespacedKey resolveRequestedWorldKey(String requestedName) {
String requested = Objects.requireNonNull(requestedName, "requestedName").trim();
if (requested.isEmpty()) {
throw new IllegalArgumentException("World name cannot be empty.");
}
if (requested.contains("/") || requested.contains("\\") || requested.contains("..")) {
throw new IllegalArgumentException("World name must be a safe single path segment.");
}
NamespacedKey worldKey = requested.contains(":")
? NamespacedKey.fromString(requested.toLowerCase(Locale.ENGLISH))
: IrisWorldStorage.keyFromName(requested);
if (worldKey == null) {
throw new IllegalArgumentException("World identifier is invalid: " + requestedName);
}
ExactWorldSlotPathPolicy.resolve(IrisWorldStorage.levelRoot().toPath(), worldKey);
return worldKey;
}
public synchronized StagedReplacement stageReplacement(
VolmitSender sender,
NamespacedKey worldKey,
IrisDimension dimension,
long seed
) throws IOException {
VolmitSender requiredSender = Objects.requireNonNull(sender, "sender");
NamespacedKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey");
IrisDimension requiredDimension = Objects.requireNonNull(dimension, "dimension");
IrisStartupValidation.requireWorldCreationReady();
PackValidationRegistry.requireLoadable(requiredDimension.getLoader().getDataFolder().getName());
ExactWorldSlotPathPolicy.Target resolvedTarget = ExactWorldSlotPathPolicy.resolve(
IrisWorldStorage.levelRoot().toPath(),
requiredWorldKey
);
requireCompatibleEnvironment(resolvedTarget.slotKind(), requiredDimension.getEnvironment());
long effectiveSeed = resolveEffectiveSeed(resolvedTarget.slotKind(), seed);
String worldName = IrisWorldStorage.logicalName(requiredWorldKey);
LifecycleOperationCoordinator coordinator = LifecycleOperationCoordinator.get();
try (LifecycleOperationCoordinator.Lease ignored = coordinator.acquire(
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
LifecycleOperationCoordinator.OperationKind.WORLD_REPLACE,
requiredWorldKey.toString()
)) {
if (findTransaction(requiredWorldKey) != null) {
throw new IOException("A replacement is already pending for " + requiredWorldKey + ".");
}
ExactWorldSlotPathPolicy.Target target = prepareTarget(requiredWorldKey);
DatapackInstallResult datapacks = ServerConfigurator.installDataPacksIfChanged(true);
if (!datapacks.succeeded()) {
throw new IOException("Iris could not compile the dimension datapacks.");
}
UUID transactionId = UUID.randomUUID();
ReplacementPaths paths = replacementPaths(target, transactionId);
boolean targetPresent = Files.exists(paths.target(), LinkOption.NOFOLLOW_LINKS);
WorldGeneratorSnapshot originalConfiguration = BukkitWorldConfiguration.snapshot(
ServerProperties.BUKKIT_YML,
worldName
);
Transaction transaction = null;
boolean journalWritten = false;
boolean configurationApplied = false;
try {
Files.createDirectory(paths.stage());
IrisDimension installed = Iris.service(StudioSVC.class).installIntoWorld(
requiredSender,
requiredDimension,
paths.stage().toFile()
);
if (installed == null) {
throw new IOException("Iris could not stage the dimension pack.");
}
File stagedPack = paths.stage().resolve("iris/pack").toFile();
IrisWorldGeneratorResolver.requireSnapshotLoadable(stagedPack);
String packFingerprint = WorldReplacementFilesystem.fingerprintPack(stagedPack.toPath());
transaction = new Transaction(
transactionId,
requiredWorldKey,
installed.getLoadKey(),
effectiveSeed,
packFingerprint,
originalConfiguration,
targetPresent,
Phase.PREPARED
);
writeTransaction(transaction);
journalWritten = true;
GeneratorReplacement replacement = BukkitWorldConfiguration.replaceIfMatching(
ServerProperties.BUKKIT_YML,
worldName,
originalConfiguration,
installed.getLoadKey(),
effectiveSeed
);
if (!replacement.applied()) {
throw new IOException("bukkit.yml changed while the replacement was being staged.");
}
configurationApplied = true;
transaction = transaction.withPhase(Phase.ARMED);
writeTransaction(transaction);
return new StagedReplacement(
requiredWorldKey,
worldName,
installed.getLoadKey(),
effectiveSeed,
targetPresent,
datapacks.restartRequired()
);
} catch (Throwable failure) {
if (configurationApplied && transaction != null) {
try {
WorldGeneratorSnapshot replacement = replacementSnapshot(transaction);
if (!BukkitWorldConfiguration.restoreIfMatching(
ServerProperties.BUKKIT_YML,
worldName,
replacement,
originalConfiguration
)) {
failure.addSuppressed(new IOException(
"bukkit.yml changed before the failed replacement could be restored."));
}
} catch (Throwable restoreFailure) {
failure.addSuppressed(restoreFailure);
}
}
if (!configurationApplied || configurationMatches(originalConfiguration, worldName)) {
try {
WorldReplacementFilesystem.discardStage(paths);
if (journalWritten) {
deleteJournal(transactionId);
}
} catch (Throwable cleanupFailure) {
failure.addSuppressed(cleanupFailure);
}
}
if (failure instanceof IOException ioFailure) {
throw ioFailure;
}
throw new IOException("Failed to stage replacement for " + requiredWorldKey + ".", failure);
}
}
}
public synchronized void processPendingStartupReplacements() {
ArrayList<String> failures = new ArrayList<>();
List<Transaction> transactions;
try {
transactions = loadTransactions();
} catch (Throwable failure) {
Iris.reportError("Failed to read pending Iris world replacements.", failure);
IrisStartupValidation.markPacksInvalid(List.of(
"Pending Iris world replacement journal validation failed: " + detail(failure)));
return;
}
for (Transaction transaction : transactions) {
try {
processStartupTransaction(transaction);
} catch (Throwable failure) {
String message = "Pending replacement for " + transaction.worldKey()
+ " failed safely: " + detail(failure);
failures.add(message);
Iris.reportError(message, failure);
}
}
if (!failures.isEmpty()) {
IrisStartupValidation.markPacksInvalid(failures);
}
}
public synchronized void verifyLoadedPublishedWorlds() {
try {
for (Transaction transaction : loadTransactions()) {
if (transaction.phase() != Phase.PUBLISHED) {
continue;
}
WorldIdentity.resolve(transaction.worldKey()).ifPresent(world -> verifyPublishedWorld(world, transaction));
}
} catch (Throwable failure) {
Iris.reportError("Failed to inspect published Iris world replacements.", failure);
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onWorldLoad(WorldLoadEvent event) {
World world = event.getWorld();
J.s(() -> verifyPublishedWorldIfPending(world), 1);
}
private synchronized void verifyPublishedWorldIfPending(World world) {
try {
Transaction transaction = findTransaction(WorldIdentity.key(world));
if (transaction != null && transaction.phase() == Phase.PUBLISHED) {
verifyPublishedWorld(world, transaction);
}
} catch (Throwable failure) {
Iris.reportError("Failed to verify a published Iris world replacement.", failure);
}
}
private void verifyPublishedWorld(World world, Transaction transaction) {
try {
if (!transaction.worldKey().equals(WorldIdentity.key(world))) {
throw new IOException("Loaded world identity does not match the replacement journal.");
}
if (!IrisToolbelt.isIrisWorld(world)) {
throw new IOException("The replaced world did not load with an Iris generator.");
}
if (world.getSeed() != transaction.seed()) {
throw new IOException("The replaced world loaded with an unexpected seed.");
}
World.Environment expectedEnvironment = expectedEnvironment(transaction.worldKey());
if (expectedEnvironment != null && world.getEnvironment() != expectedEnvironment) {
throw new IOException("The replaced world loaded with an unexpected environment.");
}
PlatformChunkGenerator generator = IrisToolbelt.access(world);
if (generator == null || !transaction.dimension().equals(
generator.getTarget().getDimension().getLoadKey())) {
throw new IOException("The replaced world loaded an unexpected Iris dimension.");
}
ExactWorldSlotPathPolicy.Target target = ExactWorldSlotPathPolicy.resolve(
IrisWorldStorage.levelRoot().toPath(),
transaction.worldKey()
);
ReplacementPaths paths = replacementPaths(target, transaction.id());
String fingerprint = WorldReplacementFilesystem.fingerprintPack(
paths.target().resolve("iris/pack"));
if (!transaction.packFingerprint().equals(fingerprint)) {
throw new IOException("The replacement pack changed before runtime verification.");
}
WorldReplacementFilesystem.cleanupBackup(paths);
deleteJournal(transaction.id());
Iris.success("Committed Iris world replacement for " + transaction.worldKey() + ".");
} catch (Throwable failure) {
initiateRollback(transaction, failure);
}
}
private void initiateRollback(Transaction transaction, Throwable failure) {
Iris.reportError("Iris world replacement verification failed for " + transaction.worldKey()
+ "; the retained world will be restored on restart.", failure);
try {
Transaction rollback = transaction.withPhase(Phase.ROLLBACK_PENDING);
writeTransaction(rollback);
WorldGeneratorSnapshot replacement = replacementSnapshot(transaction);
WorldGeneratorSnapshot current = BukkitWorldConfiguration.snapshot(
ServerProperties.BUKKIT_YML,
transaction.worldName()
);
if (current.matchesGeneratorAndSeed(replacement)) {
if (!BukkitWorldConfiguration.restoreIfMatching(
ServerProperties.BUKKIT_YML,
transaction.worldName(),
replacement,
transaction.originalConfiguration()
)) {
throw new IOException("bukkit.yml changed during replacement rollback.");
}
} else if (!current.matchesGeneratorAndSeed(transaction.originalConfiguration())) {
throw new IOException("bukkit.yml no longer matches either side of the replacement.");
}
ServerConfigurator.restart("An Iris world replacement failed verification and will be rolled back.");
} catch (Throwable rollbackFailure) {
failure.addSuppressed(rollbackFailure);
IrisStartupValidation.markPacksInvalid(List.of(
"Iris could not arm rollback for " + transaction.worldKey() + ": " + detail(rollbackFailure)));
Iris.reportError("Failed to arm Iris world replacement rollback for "
+ transaction.worldKey() + ". Stop the server and preserve the replacement artifacts.", rollbackFailure);
}
}
private void processStartupTransaction(Transaction transaction) throws IOException {
ExactWorldSlotPathPolicy.Target target = ExactWorldSlotPathPolicy.resolve(
IrisWorldStorage.levelRoot().toPath(),
transaction.worldKey()
);
ReplacementPaths paths = replacementPaths(target, transaction.id());
WorldGeneratorSnapshot current = BukkitWorldConfiguration.snapshot(
ServerProperties.BUKKIT_YML,
transaction.worldName()
);
WorldGeneratorSnapshot replacement = replacementSnapshot(transaction);
if (transaction.phase() == Phase.ROLLBACK_PENDING) {
processRollback(transaction, paths, current, replacement);
return;
}
if (transaction.phase() == Phase.PREPARED) {
if (current.matchesGeneratorAndSeed(replacement)) {
transaction = transaction.withPhase(Phase.ARMED);
writeTransaction(transaction);
} else if (current.matchesGeneratorAndSeed(transaction.originalConfiguration())) {
WorldReplacementFilesystem.discardStage(paths);
deleteJournal(transaction.id());
Iris.warn("Cancelled incomplete Iris world replacement for " + transaction.worldKey() + ".");
return;
} else {
throw new IOException("bukkit.yml does not match the prepared replacement or its original state.");
}
}
if (transaction.phase() == Phase.ARMED) {
if (!current.matchesGeneratorAndSeed(replacement)) {
throw new IOException("bukkit.yml no longer authorizes the armed replacement.");
}
WorldReplacementFilesystem.publish(
paths,
transaction.originalTargetPresent(),
transaction.packFingerprint()
);
transaction = transaction.withPhase(Phase.PUBLISHED);
writeTransaction(transaction);
Iris.success("Published Iris world replacement for " + transaction.worldKey()
+ "; waiting for runtime verification.");
}
if (transaction.phase() == Phase.PUBLISHED) {
if (!current.matchesGeneratorAndSeed(replacement)) {
throw new IOException("bukkit.yml changed after the replacement was published.");
}
String fingerprint = WorldReplacementFilesystem.fingerprintPack(
paths.target().resolve("iris/pack"));
if (!transaction.packFingerprint().equals(fingerprint)) {
throw new IOException("Published replacement pack fingerprint does not match its journal.");
}
}
}
private void processRollback(
Transaction transaction,
ReplacementPaths paths,
WorldGeneratorSnapshot current,
WorldGeneratorSnapshot replacement
) throws IOException {
if (current.matchesGeneratorAndSeed(replacement)) {
if (!BukkitWorldConfiguration.restoreIfMatching(
ServerProperties.BUKKIT_YML,
transaction.worldName(),
replacement,
transaction.originalConfiguration()
)) {
throw new IOException("bukkit.yml changed during startup rollback.");
}
} else if (!current.matchesGeneratorAndSeed(transaction.originalConfiguration())) {
throw new IOException("bukkit.yml conflicts with the pending world rollback.");
}
WorldReplacementFilesystem.rollback(paths, transaction.originalTargetPresent());
deleteJournal(transaction.id());
Iris.success("Restored the retained world for " + transaction.worldKey() + ".");
}
private ExactWorldSlotPathPolicy.Target prepareTarget(NamespacedKey worldKey) throws IOException {
Path levelRoot = IrisWorldStorage.levelRoot().toPath();
ExactWorldSlotPathPolicy.Target target = ExactWorldSlotPathPolicy.resolve(levelRoot, worldKey);
Path dimensions = target.levelRoot().resolve("dimensions");
createDirectoryIfMissing(dimensions);
createDirectoryIfMissing(target.namespaceRoot());
return ExactWorldSlotPathPolicy.resolve(levelRoot, worldKey);
}
private static void createDirectoryIfMissing(Path directory) throws IOException {
if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) {
if (Files.isSymbolicLink(directory) || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("World storage parent is unsafe: " + directory);
}
return;
}
Files.createDirectory(directory);
}
private Transaction findTransaction(NamespacedKey worldKey) throws IOException {
for (Transaction transaction : loadTransactions()) {
if (transaction.worldKey().equals(worldKey)) {
return transaction;
}
}
return null;
}
private List<Transaction> loadTransactions() throws IOException {
Path directory = journalDirectory(false);
if (directory == null) {
return List.of();
}
ArrayList<Transaction> transactions = new ArrayList<>();
try (DirectoryStream<Path> files = Files.newDirectoryStream(directory, "*" + JOURNAL_SUFFIX)) {
for (Path file : files) {
if (Files.isSymbolicLink(file) || !Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Replacement journal entry is unsafe: " + file);
}
transactions.add(readTransaction(file));
}
}
transactions.sort(Comparator.comparing(transaction -> transaction.id().toString()));
return List.copyOf(transactions);
}
private Transaction readTransaction(Path file) throws IOException {
Properties properties = new Properties();
try (InputStream input = Files.newInputStream(file)) {
properties.load(input);
}
UUID id = UUID.fromString(required(properties, "id"));
if (!file.getFileName().toString().equals(id + JOURNAL_SUFFIX)) {
throw new IOException("Replacement journal filename does not match its transaction id.");
}
NamespacedKey worldKey = NamespacedKey.fromString(required(properties, "worldKey"));
if (worldKey == null) {
throw new IOException("Replacement journal contains an invalid world key.");
}
ExactWorldSlotPathPolicy.resolve(IrisWorldStorage.levelRoot().toPath(), worldKey);
String dimension = required(properties, "dimension");
if (!safeDimension(dimension)) {
throw new IOException("Replacement journal contains an invalid dimension key.");
}
long seed = parseLong(properties, "seed");
String packFingerprint = required(properties, "packFingerprint");
if (!packFingerprint.matches("[0-9a-f]{64}")) {
throw new IOException("Replacement journal contains an invalid pack fingerprint.");
}
WorldGeneratorSnapshot original = readSnapshot(properties, "original.");
boolean originalTargetPresent = parseBoolean(properties, "originalTargetPresent");
Phase phase;
try {
phase = Phase.valueOf(required(properties, "phase"));
} catch (IllegalArgumentException failure) {
throw new IOException("Replacement journal contains an invalid phase.", failure);
}
return new Transaction(
id,
worldKey,
dimension,
seed,
packFingerprint,
original,
originalTargetPresent,
phase
);
}
private void writeTransaction(Transaction transaction) throws IOException {
Path directory = Objects.requireNonNull(journalDirectory(true));
Path target = directory.resolve(transaction.id() + JOURNAL_SUFFIX);
Properties properties = new Properties();
properties.setProperty("id", transaction.id().toString());
properties.setProperty("worldKey", transaction.worldKey().toString());
properties.setProperty("dimension", transaction.dimension());
properties.setProperty("seed", Long.toString(transaction.seed()));
properties.setProperty("packFingerprint", transaction.packFingerprint());
properties.setProperty("originalTargetPresent", Boolean.toString(transaction.originalTargetPresent()));
properties.setProperty("phase", transaction.phase().name());
writeSnapshot(properties, "original.", transaction.originalConfiguration());
ByteArrayOutputStream output = new ByteArrayOutputStream();
properties.store(output, null);
writeAtomic(target, output.toByteArray());
}
private void deleteJournal(UUID id) throws IOException {
Path directory = journalDirectory(false);
if (directory == null) {
return;
}
Files.deleteIfExists(directory.resolve(id + JOURNAL_SUFFIX));
forceDirectory(directory);
}
private Path journalDirectory(boolean create) throws IOException {
Path directory = plugin.getDataFile(JOURNAL_DIRECTORY).toPath().toAbsolutePath().normalize();
if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) {
if (!create) {
return null;
}
Files.createDirectories(directory);
}
if (Files.isSymbolicLink(directory) || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Replacement journal storage is unsafe: " + directory);
}
return directory;
}
private static void writeAtomic(Path target, byte[] content) throws IOException {
Path parent = Objects.requireNonNull(target.getParent(), "journal parent");
Path temporary = parent.resolve("." + target.getFileName() + ".tmp-" + UUID.randomUUID());
try {
try (FileChannel channel = FileChannel.open(
temporary,
StandardOpenOption.CREATE_NEW,
StandardOpenOption.WRITE
)) {
ByteBuffer buffer = ByteBuffer.wrap(content);
while (buffer.hasRemaining()) {
channel.write(buffer);
}
channel.force(true);
}
try {
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException failure) {
Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING);
}
forceDirectory(parent);
} finally {
Files.deleteIfExists(temporary);
}
}
private static void forceDirectory(Path directory) throws IOException {
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
channel.force(true);
}
}
private static ReplacementPaths replacementPaths(
ExactWorldSlotPathPolicy.Target target,
UUID id
) {
String artifactBase = ".iris-replace-" + target.worldKey().getKey() + "-" + id;
return new ReplacementPaths(
target.worldDirectory(),
target.namespaceRoot().resolve(artifactBase + ".stage"),
target.namespaceRoot().resolve(artifactBase + ".backup")
);
}
private static WorldGeneratorSnapshot replacementSnapshot(Transaction transaction) {
return new WorldGeneratorSnapshot(
true,
true,
true,
"Iris:" + transaction.dimension(),
true,
transaction.seed()
);
}
private static boolean configurationMatches(WorldGeneratorSnapshot expected, String worldName) {
try {
return BukkitWorldConfiguration.snapshot(ServerProperties.BUKKIT_YML, worldName)
.matchesGeneratorAndSeed(expected);
} catch (IOException failure) {
return false;
}
}
private static void requireCompatibleEnvironment(SlotKind slotKind, IrisEnvironment environment) {
IrisEnvironment expected = switch (slotKind) {
case VANILLA_OVERWORLD -> IrisEnvironment.NORMAL;
case VANILLA_NETHER -> IrisEnvironment.NETHER;
case VANILLA_END -> IrisEnvironment.THE_END;
case IRIS_MANAGED -> null;
};
if (expected != null && environment != expected) {
throw new IllegalArgumentException("The " + slotKind.name().toLowerCase(Locale.ENGLISH)
+ " slot requires a pack environment of " + expected.name() + ".");
}
}
private static long resolveEffectiveSeed(SlotKind slotKind, long requestedSeed) throws IOException {
if (slotKind == SlotKind.IRIS_MANAGED) {
return requestedSeed;
}
CompletableFuture<VanillaLevelContext> contextFuture = J.sfut(() -> new VanillaLevelContext(
WorldIdentity.resolve(NamespacedKey.minecraft("overworld"))
.orElseThrow(() -> new IllegalStateException("The configured primary world is not loaded."))
.getSeed(),
Iris.instance.getServer().getAllowNether(),
Iris.instance.getServer().getAllowEnd()
));
if (contextFuture == null) {
throw new IOException("Could not schedule primary level-seed resolution.");
}
try {
VanillaLevelContext context = contextFuture.get(30L, TimeUnit.SECONDS);
if (slotKind == SlotKind.VANILLA_NETHER && !context.allowNether()) {
throw new IOException("allow-nether must be true before the vanilla Nether can be replaced.");
}
if (slotKind == SlotKind.VANILLA_END && !context.allowEnd()) {
throw new IOException("Bukkit allow-end must be true before the vanilla End can be replaced.");
}
return context.seed();
} catch (InterruptedException failure) {
Thread.currentThread().interrupt();
throw new IOException("Primary level-seed resolution was interrupted.", failure);
} catch (ExecutionException | TimeoutException failure) {
throw new IOException("Could not resolve the authoritative primary level seed.", failure);
}
}
private static World.Environment expectedEnvironment(NamespacedKey worldKey) {
if (NamespacedKey.minecraft("overworld").equals(worldKey)) {
return World.Environment.NORMAL;
}
if (NamespacedKey.minecraft("the_nether").equals(worldKey)) {
return World.Environment.NETHER;
}
if (NamespacedKey.minecraft("the_end").equals(worldKey)) {
return World.Environment.THE_END;
}
return null;
}
private static void writeSnapshot(Properties properties, String prefix, WorldGeneratorSnapshot snapshot) {
properties.setProperty(prefix + "worldsSectionPresent", Boolean.toString(snapshot.worldsSectionPresent()));
properties.setProperty(prefix + "worldSectionPresent", Boolean.toString(snapshot.worldSectionPresent()));
properties.setProperty(prefix + "generatorPresent", Boolean.toString(snapshot.generatorPresent()));
if (snapshot.generatorPresent()) {
properties.setProperty(prefix + "generator", snapshot.generator());
}
properties.setProperty(prefix + "seedPresent", Boolean.toString(snapshot.seedPresent()));
if (snapshot.seedPresent()) {
properties.setProperty(prefix + "seed", Long.toString(snapshot.seed()));
}
}
private static WorldGeneratorSnapshot readSnapshot(Properties properties, String prefix) throws IOException {
boolean worldsPresent = parseBoolean(properties, prefix + "worldsSectionPresent");
boolean worldPresent = parseBoolean(properties, prefix + "worldSectionPresent");
boolean generatorPresent = parseBoolean(properties, prefix + "generatorPresent");
String generator = generatorPresent ? required(properties, prefix + "generator") : null;
boolean seedPresent = parseBoolean(properties, prefix + "seedPresent");
Long seed = seedPresent ? parseLong(properties, prefix + "seed") : null;
try {
return new WorldGeneratorSnapshot(
worldsPresent,
worldPresent,
generatorPresent,
generator,
seedPresent,
seed
);
} catch (IllegalArgumentException failure) {
throw new IOException("Replacement journal contains an invalid configuration snapshot.", failure);
}
}
private static boolean safeDimension(String value) {
if (value.isEmpty() || value.length() > 256 || value.startsWith(".") || value.contains("..")) {
return false;
}
String[] segments = value.split("/", -1);
if (segments.length > 16) {
return false;
}
for (String segment : segments) {
if (segment.isEmpty() || !segment.matches("[A-Za-z0-9_-]+")) {
return false;
}
}
return true;
}
private static String required(Properties properties, String key) throws IOException {
String value = properties.getProperty(key);
if (value == null || value.isBlank()) {
throw new IOException("Replacement journal is missing " + key + ".");
}
return value.trim();
}
private static boolean parseBoolean(Properties properties, String key) throws IOException {
String value = required(properties, key);
if (!"true".equals(value) && !"false".equals(value)) {
throw new IOException("Replacement journal contains an invalid boolean for " + key + ".");
}
return Boolean.parseBoolean(value);
}
private static long parseLong(Properties properties, String key) throws IOException {
try {
return Long.parseLong(required(properties, key));
} catch (NumberFormatException failure) {
throw new IOException("Replacement journal contains an invalid integer for " + key + ".", failure);
}
}
private static String detail(Throwable failure) {
String message = failure.getMessage();
return message == null || message.isBlank() ? failure.getClass().getSimpleName() : message;
}
public record StagedReplacement(
NamespacedKey worldKey,
String worldName,
String dimension,
long seed,
boolean replacedExistingTarget,
boolean datapackRestartRequired
) {
public StagedReplacement {
Objects.requireNonNull(worldKey, "worldKey");
Objects.requireNonNull(worldName, "worldName");
Objects.requireNonNull(dimension, "dimension");
}
}
private record Transaction(
UUID id,
NamespacedKey worldKey,
String dimension,
long seed,
String packFingerprint,
WorldGeneratorSnapshot originalConfiguration,
boolean originalTargetPresent,
Phase phase
) {
private Transaction {
Objects.requireNonNull(id, "id");
Objects.requireNonNull(worldKey, "worldKey");
Objects.requireNonNull(dimension, "dimension");
Objects.requireNonNull(packFingerprint, "packFingerprint");
Objects.requireNonNull(originalConfiguration, "originalConfiguration");
Objects.requireNonNull(phase, "phase");
}
private String worldName() {
return IrisWorldStorage.logicalName(worldKey);
}
private Transaction withPhase(Phase nextPhase) {
return new Transaction(
id,
worldKey,
dimension,
seed,
packFingerprint,
originalConfiguration,
originalTargetPresent,
nextPhase
);
}
}
private enum Phase {
PREPARED,
ARMED,
PUBLISHED,
ROLLBACK_PENDING
}
private record VanillaLevelContext(long seed, boolean allowNether, boolean allowEnd) {
}
}
@@ -26,6 +26,7 @@ import art.arcane.iris.core.IrisStartupValidation;
import art.arcane.iris.core.DatapackInstallResult;
import art.arcane.iris.core.IrisWorldStorage;
import art.arcane.iris.core.IrisWorlds;
import art.arcane.iris.core.PendingWorldReplacementManager;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration;
import art.arcane.iris.core.lifecycle.IrisWorldRemovalService;
@@ -124,17 +125,27 @@ public class CommandIris implements DirectorExecutor {
@Param(description = "The seed to generate the world with", descriptionKey = "iris.director.commandiris.param.seed_generate_world_with", defaultValue = "1337")
long seed,
@Param(aliases = "main-world", description = "Whether or not to automatically use this world as the main world", descriptionKey = "iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world", defaultValue = "false")
boolean main
boolean main,
@Param(name = "overwrite", aliases = "force", description = "Replace the exact existing world slot on the next restart", descriptionKey = "iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart", defaultValue = "false")
boolean overwrite
) {
NamespacedKey worldKey;
try {
if (overwrite) {
worldKey = Iris.instance.pendingWorldReplacements().resolveRequestedWorldKey(name);
} else {
worldKey = IrisWorldStorage.managedKeyFromName(name);
IrisWorldStorage.requireSafeManagedDimensionRoot(worldKey);
}
} catch (IllegalArgumentException e) {
sender().sendMessage(C.RED + e.getMessage());
return;
}
String worldName = IrisWorldStorage.logicalName(worldKey);
if (overwrite && main && !NamespacedKey.minecraft("overworld").equals(worldKey)) {
sender().sendMessage(C.RED + "overwrite=true with main=true must target the configured main-world name.");
return;
}
if (worldName.equalsIgnoreCase("iris")) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_YOU_CANNOT_USE_WORLD_NAME_IRIS_CREATING_WORLDS_AS_IRIS));
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_MAY_WE_SUGGEST_NAME_IRISWORLD_INSTEAD));
@@ -147,7 +158,7 @@ public class CommandIris implements DirectorExecutor {
return;
}
if (IrisWorldStorage.dimensionRoot(worldName).exists()) {
if (!overwrite && IrisWorldStorage.dimensionRoot(worldName).exists()) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_THAT_FOLDER_ALREADY_EXISTS));
return;
}
@@ -164,6 +175,27 @@ public class CommandIris implements DirectorExecutor {
return;
}
if (overwrite) {
try {
PendingWorldReplacementManager.StagedReplacement staged = Iris.instance
.pendingWorldReplacements()
.stageReplacement(sender(), worldKey, dimension, seed);
if (staged.seed() != seed) {
sender().sendMessage(C.YELLOW + "Exact vanilla slots preserve the shared level seed; using "
+ staged.seed() + " instead of " + seed + ".");
}
sender().sendMessage(C.GREEN + "Staged Iris replacement for " + staged.worldKey()
+ ". Restart once to publish it. The current dimension is retained until Iris verifies the replacement.");
} catch (Throwable failure) {
Iris.reportError("Failed to stage Iris world replacement for " + worldKey + ".", failure);
String detail = failure.getMessage() == null || failure.getMessage().isBlank()
? failure.getClass().getSimpleName()
: failure.getMessage();
sender().sendMessage(C.RED + "Could not stage the world replacement: " + detail);
}
return;
}
if (J.isFolia()) {
if (stageFoliaWorldCreation(worldName, dimension, seed, main)) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_WORLD_STAGING_COMPLETED_RESTART_SERVER_GENERATE_LOAD, MessageArgument.untrusted("worldName", worldName)));
@@ -7,6 +7,7 @@ import art.arcane.iris.core.nms.INMS;
import art.arcane.iris.core.tools.IrisToolbelt;
import art.arcane.iris.engine.platform.BukkitChunkGenerator;
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
import art.arcane.iris.engine.object.IrisImportedStructureControl;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.util.common.plugin.IrisService;
import org.bukkit.Bukkit;
@@ -31,11 +32,11 @@ public final class DatapackStructureScopeSVC implements IrisService {
throw new IllegalStateException(
"Iris could not establish ownership for installed datapack structure sets", e);
}
if (scopeIndex.isEmpty()) {
return;
}
for (World world : Bukkit.getWorlds()) {
applyScope(world);
IrisImportedStructureControl importedStructures = importedStructures(world);
if (!scopeIndex.isEmpty() || importedStructures != null && importedStructures.hasFrequencyOverrides()) {
applyScope(world, importedStructures);
}
}
}
@@ -52,8 +53,10 @@ public final class DatapackStructureScopeSVC implements IrisService {
boolean studioEntryBootstrap = event.getWorld().getGenerator()
instanceof BukkitChunkGenerator generator
&& generator.isStudioEntryBootstrapActive();
if (shouldApplyScope(scopeIndex.isEmpty(), studioEntryBootstrap)) {
applyScope(event.getWorld());
IrisImportedStructureControl importedStructures = importedStructures(event.getWorld());
if (shouldApplyScope(scopeIndex.isEmpty(), studioEntryBootstrap,
importedStructures != null && importedStructures.hasFrequencyOverrides())) {
applyScope(event.getWorld(), importedStructures);
}
}
@@ -62,15 +65,19 @@ public final class DatapackStructureScopeSVC implements IrisService {
INMS.get().abandonStudioStructureBootstrap(event.getWorld());
}
static boolean shouldApplyScope(boolean scopeIndexEmpty, boolean studioEntryBootstrap) {
return !scopeIndexEmpty || studioEntryBootstrap;
static boolean shouldApplyScope(boolean scopeIndexEmpty, boolean studioEntryBootstrap,
boolean hasFrequencyOverrides) {
return !scopeIndexEmpty || studioEntryBootstrap || hasFrequencyOverrides;
}
private void applyScope(World world) {
private void applyScope(World world, IrisImportedStructureControl importedStructures) {
Set<String> declaredSources = declaredSources(world);
IrisImportedStructureControl activeControl = importedStructures == null
? new IrisImportedStructureControl()
: importedStructures;
try {
DatapackStructureScopeResult result = INMS.get().scopeDatapackStructures(
world, scopeIndex, declaredSources);
world, scopeIndex, declaredSources, activeControl);
IrisLogging.info("Scoped Iris-managed datapack structure sets for world '"
+ world.getName() + "': " + result.retainedManagedSets() + " retained, "
+ result.excludedManagedSets() + " excluded.");
@@ -80,6 +87,15 @@ public final class DatapackStructureScopeSVC implements IrisService {
}
}
private IrisImportedStructureControl importedStructures(World world) {
PlatformChunkGenerator generator = IrisToolbelt.access(world);
if (generator == null || generator.getTarget() == null
|| generator.getTarget().getDimension() == null) {
return null;
}
return generator.getTarget().getDimension().getImportedStructures();
}
private Set<String> declaredSources(World world) {
PlatformChunkGenerator generator = IrisToolbelt.access(world);
if (generator == null) {
@@ -0,0 +1,83 @@
package art.arcane.iris.core;
import art.arcane.iris.core.pack.BrokenPackException;
import art.arcane.iris.core.pack.PackValidationRegistry;
import art.arcane.iris.core.pack.PackValidationResult;
import org.junit.After;
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.assertNotNull;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class IrisWorldGeneratorResolverTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@After
public void clearValidationRegistry() {
PackValidationRegistry.clear();
}
@Test
public void snapshotValidationIsLazyAndExactRootScoped() throws Exception {
File packRoot = temporaryFolder.newFolder("world", "iris", "pack");
writeValidPack(packRoot.toPath());
PackValidationResult unrelatedNamedFailure = new PackValidationResult(
"pack", List.of("unrelated basename failure"), List.of(), 1L);
PackValidationRegistry.publish(unrelatedNamedFailure);
PackValidationResult result = IrisWorldGeneratorResolver.requireSnapshotLoadable(packRoot);
assertTrue(result.isLoadable());
assertEquals(result, PackValidationRegistry.get(packRoot.toPath()));
assertEquals(unrelatedNamedFailure, PackValidationRegistry.get("pack"));
}
@Test
public void invalidatedSnapshotIsValidatedAgainBeforeAuthorization() throws Exception {
File packRoot = temporaryFolder.newFolder("replace", "iris", "pack");
writeValidPack(packRoot.toPath());
assertTrue(IrisWorldGeneratorResolver.requireSnapshotLoadable(packRoot).isLoadable());
Files.writeString(
packRoot.toPath().resolve("dimensions/main.json"),
"{",
StandardCharsets.UTF_8);
PackValidationRegistry.remove(packRoot.toPath());
assertThrows(BrokenPackException.class,
() -> IrisWorldGeneratorResolver.requireSnapshotLoadable(packRoot));
PackValidationResult invalid = PackValidationRegistry.get(packRoot.toPath());
assertNotNull(invalid);
assertFalse(invalid.getBlockingErrors().toString(), invalid.isLoadable());
}
private static void writeValidPack(Path packRoot) throws Exception {
Files.createDirectories(packRoot.resolve("dimensions"));
Files.createDirectories(packRoot.resolve("regions"));
Files.createDirectories(packRoot.resolve("biomes"));
Files.writeString(
packRoot.resolve("dimensions/main.json"),
"{\"regions\":[\"region\"]}",
StandardCharsets.UTF_8);
Files.writeString(
packRoot.resolve("regions/region.json"),
"{\"landBiomes\":[\"biome\"]}",
StandardCharsets.UTF_8);
Files.writeString(
packRoot.resolve("biomes/biome.json"),
"{\"name\":\"Biome\"}",
StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,35 @@
package art.arcane.iris.core.commands;
import art.arcane.volmlib.util.director.annotations.Param;
import org.junit.Test;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.util.Arrays;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public class CommandIrisCreateOverwriteContractTest {
@Test
public void createExposesOptInRestartReplacementFlag() throws Exception {
Method command = CommandIris.class.getDeclaredMethod(
"create",
String.class,
String.class,
long.class,
boolean.class,
boolean.class
);
Parameter overwriteParameter = command.getParameters()[4];
Param overwrite = overwriteParameter.getAnnotation(Param.class);
assertEquals("overwrite", overwrite.name());
assertEquals("false", overwrite.defaultValue());
assertTrue(Arrays.asList(overwrite.aliases()).contains("force"));
assertEquals(
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart",
overwrite.descriptionKey()
);
}
}
@@ -34,8 +34,9 @@ public class DatapackStructureScopeSVCTest {
@Test
public void emptyScopeStillAppliesToJigsawStudioBootstrap() {
assertTrue(DatapackStructureScopeSVC.shouldApplyScope(true, true));
assertFalse(DatapackStructureScopeSVC.shouldApplyScope(true, false));
assertTrue(DatapackStructureScopeSVC.shouldApplyScope(false, false));
assertTrue(DatapackStructureScopeSVC.shouldApplyScope(true, true, false));
assertFalse(DatapackStructureScopeSVC.shouldApplyScope(true, false, false));
assertTrue(DatapackStructureScopeSVC.shouldApplyScope(false, false, false));
assertTrue(DatapackStructureScopeSVC.shouldApplyScope(true, false, true));
}
}
@@ -26,6 +26,7 @@ import art.arcane.iris.engine.framework.GenerationSessionException;
import art.arcane.iris.engine.framework.GenerationSessionLease;
import art.arcane.iris.engine.framework.NativeStructureStartPlan;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisImportedStructureControl;
import art.arcane.iris.nativegen.NativeStructureStartInjector;
import art.arcane.iris.nativegen.NativeStructureReferenceRepair;
import art.arcane.iris.nativegen.NativeStructureVanillaLocator;
@@ -284,8 +285,21 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
@Override
public ChunkGeneratorStructureState createState(HolderLookup<StructureSet> structureSets, RandomState randomState, long seed) {
return ChunkGeneratorStructureState.createForNormal(
ChunkGeneratorStructureState state = ChunkGeneratorStructureState.createForNormal(
randomState, seed, structureBiomeSource.forStructureState(structureSets), structureSets);
return ModdedStructureSetFrequencyOverrides.apply(state, configuredImportedStructures());
}
private IrisImportedStructureControl configuredImportedStructures() {
Engine current = engine;
if (current != null && !current.isClosed() && !current.isClosing()) {
IrisDimension dimension = current.getDimension();
if (dimension != null && dimension.getImportedStructures() != null) {
return dimension.getImportedStructures();
}
}
IrisImportedStructureControl importedStructures = configuredPack().dimension().getImportedStructures();
return importedStructures == null ? new IrisImportedStructureControl() : importedStructures;
}
@Override
@@ -444,10 +458,11 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
return;
}
String remedy = integratedEnvironment()
? "enable 'Generate Structures' for this world; Iris requires it, and individual structures "
+ "are denied through importedStructures.disabled"
? "enable 'Generate Structures' for this world; Iris requires it, then deny families through "
+ "importedStructures.disabled or complete keys through importedStructures.disabledExact"
: "set generate-structures=true in server.properties, restart the server, "
+ "and deny individual structures through importedStructures.disabled";
+ "then deny families through importedStructures.disabled or complete keys through "
+ "importedStructures.disabledExact";
throw new IllegalStateException("Iris generator '" + dimensionKey
+ "' cannot bind while generate-structures=false; " + remedy);
}
@@ -287,7 +287,8 @@ final class ModdedNativeStructureStage {
+ " because generate-structures=false disables them outside the pack. That flag is fixed when "
+ "the world is created (server.properties generate-structures, or the Generate Structures "
+ "toggle in singleplayer), so it cannot be changed for this world: create a new world with "
+ "structures enabled, and deny individual structures through importedStructures.disabled");
+ "structures enabled, then deny families through importedStructures.disabled or complete keys "
+ "through importedStructures.disabledExact");
}
ChunkPos chunkPos = chunk.getPos();
SectionPos sectionPos = SectionPos.of(chunkPos, world.getMinSectionY());
@@ -0,0 +1,285 @@
/*
* 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 art.arcane.iris.engine.framework.NativeStructureFrequencyScale;
import art.arcane.iris.engine.object.IrisImportedStructureControl;
import net.minecraft.core.Holder;
import net.minecraft.core.HolderOwner;
import net.minecraft.core.Vec3i;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.chunk.ChunkGeneratorStructureState;
import net.minecraft.world.level.levelgen.structure.StructureSet;
import net.minecraft.world.level.levelgen.structure.placement.ConcentricRingsStructurePlacement;
import net.minecraft.world.level.levelgen.structure.placement.RandomSpreadStructurePlacement;
import net.minecraft.world.level.levelgen.structure.placement.StructurePlacement;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
final class ModdedStructureSetFrequencyOverrides {
private ModdedStructureSetFrequencyOverrides() {
}
static ChunkGeneratorStructureState apply(
ChunkGeneratorStructureState state,
IrisImportedStructureControl importedStructures
) {
if (!importedStructures.hasFrequencyOverrides()) {
return state;
}
List<Holder<StructureSet>> original = state.possibleStructureSets();
List<Holder<StructureSet>> scaled = scaleSets(original, importedStructures);
if (scaled == original) {
return state;
}
Field possibleSetsField = declaredField(ChunkGeneratorStructureState.class, List.class);
try {
possibleSetsField.set(state, scaled);
return state;
} catch (IllegalAccessException error) {
throw new IllegalStateException(
"Could not apply native structure-set frequency overrides", error);
}
}
static List<Holder<StructureSet>> scaleSets(
List<Holder<StructureSet>> structureSets,
IrisImportedStructureControl importedStructures
) {
Map<String, Holder<StructureSet>> holdersByKey = new HashMap<>();
for (Holder<StructureSet> holder : structureSets) {
String key = structureSetKey(holder);
if (key != null) {
holdersByKey.putIfAbsent(key, holder);
}
}
Map<Holder<StructureSet>, Holder<StructureSet>> scaledByIdentity = new IdentityHashMap<>();
List<Holder<StructureSet>> scaledSets = new ArrayList<>(structureSets.size());
boolean changed = false;
for (Holder<StructureSet> holder : structureSets) {
Holder<StructureSet> scaled = scaleHolder(
holder, importedStructures, holdersByKey, scaledByIdentity);
scaledSets.add(scaled);
changed |= scaled != holder;
}
return changed ? List.copyOf(scaledSets) : structureSets;
}
private static Holder<StructureSet> scaleHolder(
Holder<StructureSet> holder,
IrisImportedStructureControl importedStructures,
Map<String, Holder<StructureSet>> holdersByKey,
Map<Holder<StructureSet>, Holder<StructureSet>> scaledByIdentity
) {
if (scaledByIdentity.containsKey(holder)) {
return scaledByIdentity.get(holder);
}
if (!dependsOnOverride(holder, importedStructures, holdersByKey)) {
scaledByIdentity.put(holder, holder);
return holder;
}
ResourceKey<StructureSet> holderKey = holder.unwrapKey().orElseThrow(() ->
new IllegalStateException("An affected native structure-set exclusion graph has an unkeyed holder"));
ScaledStructureSetHolder scaledHolder = new ScaledStructureSetHolder(holderKey);
scaledByIdentity.put(holder, scaledHolder);
StructureSet originalSet = holder.value();
StructurePlacement originalPlacement = originalSet.placement();
Optional<StructurePlacement.ExclusionZone> originalZone = exclusionZone(originalPlacement);
Optional<StructurePlacement.ExclusionZone> scaledZone = originalZone;
if (originalZone.isPresent()) {
Holder<StructureSet> target = canonicalHolder(
originalZone.get().otherSet(), holdersByKey);
Holder<StructureSet> scaledTarget = scaleHolder(
target, importedStructures, holdersByKey, scaledByIdentity);
if (scaledTarget != originalZone.get().otherSet()) {
scaledZone = Optional.of(new StructurePlacement.ExclusionZone(
scaledTarget, originalZone.get().chunkCount()));
}
}
double multiplier = importedStructures.frequencyMultiplier(structureSetKey(holder));
StructurePlacement scaledPlacement = multiplier == 1D && scaledZone.equals(originalZone)
? originalPlacement
: scalePlacement(originalPlacement, originalZone, scaledZone, multiplier);
scaledHolder.bind(new StructureSet(originalSet.structures(), scaledPlacement));
return scaledHolder;
}
private static boolean dependsOnOverride(
Holder<StructureSet> holder,
IrisImportedStructureControl importedStructures,
Map<String, Holder<StructureSet>> holdersByKey
) {
Set<Holder<StructureSet>> visited = Collections.newSetFromMap(new IdentityHashMap<>());
Holder<StructureSet> current = holder;
while (visited.add(current)) {
if (importedStructures.frequencyMultiplier(structureSetKey(current)) != 1D) {
return true;
}
Optional<StructurePlacement.ExclusionZone> zone =
exclusionZone(current.value().placement());
if (zone.isEmpty()) {
return false;
}
current = canonicalHolder(zone.get().otherSet(), holdersByKey);
}
return false;
}
private static Holder<StructureSet> canonicalHolder(
Holder<StructureSet> holder,
Map<String, Holder<StructureSet>> holdersByKey
) {
String key = structureSetKey(holder);
return key == null ? holder : holdersByKey.getOrDefault(key, holder);
}
private static StructurePlacement scalePlacement(
StructurePlacement placement,
Optional<StructurePlacement.ExclusionZone> originalZone,
Optional<StructurePlacement.ExclusionZone> scaledZone,
double multiplier
) {
Vec3i locateOffset = (Vec3i) declaredFieldValue(
StructurePlacement.class, placement, Vec3i.class);
StructurePlacement.FrequencyReductionMethod reductionMethod =
(StructurePlacement.FrequencyReductionMethod) declaredFieldValue(
StructurePlacement.class,
placement,
StructurePlacement.FrequencyReductionMethod.class);
float frequency = (float) declaredFieldValue(
StructurePlacement.class, placement, float.class);
int salt = (int) declaredFieldValue(
StructurePlacement.class, placement, int.class);
if (placement.getClass() == RandomSpreadStructurePlacement.class) {
RandomSpreadStructurePlacement randomSpread =
(RandomSpreadStructurePlacement) placement;
NativeStructureFrequencyScale scale = NativeStructureFrequencyScale.randomSpread(
frequency, randomSpread.spacing(), randomSpread.separation(), multiplier);
if (scale.frequency() == frequency
&& scale.spacing() == randomSpread.spacing()
&& scaledZone.equals(originalZone)) {
return placement;
}
return new RandomSpreadStructurePlacement(
locateOffset,
reductionMethod,
scale.frequency(),
salt,
scaledZone,
scale.spacing(),
randomSpread.separation(),
randomSpread.spreadType());
}
if (placement.getClass() == ConcentricRingsStructurePlacement.class) {
ConcentricRingsStructurePlacement rings =
(ConcentricRingsStructurePlacement) placement;
float scaledFrequency = NativeStructureFrequencyScale.probability(frequency, multiplier);
if (scaledFrequency == frequency && scaledZone.equals(originalZone)) {
return placement;
}
return new ConcentricRingsStructurePlacement(
locateOffset,
reductionMethod,
scaledFrequency,
salt,
scaledZone,
rings.distance(),
rings.spread(),
rings.count(),
rings.preferredBiomes());
}
throw new IllegalStateException("Unsupported native structure placement: "
+ placement.getClass().getName());
}
private static Optional<StructurePlacement.ExclusionZone> exclusionZone(
StructurePlacement placement
) {
Object value = declaredFieldValue(StructurePlacement.class, placement, Optional.class);
if (value instanceof Optional<?> optional && (optional.isEmpty()
|| optional.get() instanceof StructurePlacement.ExclusionZone)) {
@SuppressWarnings("unchecked")
Optional<StructurePlacement.ExclusionZone> resolved =
(Optional<StructurePlacement.ExclusionZone>) optional;
return resolved;
}
throw new IllegalStateException("Could not read native structure exclusion zone from "
+ placement.getClass().getName());
}
private static String structureSetKey(Holder<StructureSet> holder) {
Optional<ResourceKey<StructureSet>> key = holder.unwrapKey();
return key.map(resourceKey -> resourceKey.identifier().toString()).orElse(null);
}
private static Object declaredFieldValue(
Class<?> declaringType,
Object target,
Class<?> fieldType
) {
Field field = declaredField(declaringType, fieldType);
try {
return field.get(target);
} catch (IllegalAccessException error) {
throw new IllegalStateException("Could not read " + fieldType.getName()
+ " field on " + declaringType.getName(), error);
}
}
private static Field declaredField(Class<?> declaringType, Class<?> fieldType) {
Field match = null;
for (Field field : declaringType.getDeclaredFields()) {
if (Modifier.isStatic(field.getModifiers()) || field.getType() != fieldType) {
continue;
}
if (match != null) {
throw new IllegalStateException("Ambiguous " + fieldType.getName() + " field on "
+ declaringType.getName());
}
match = field;
}
if (match == null || !match.trySetAccessible()) {
throw new IllegalStateException("Missing accessible " + fieldType.getName()
+ " field on " + declaringType.getName());
}
return match;
}
private static final class ScaledStructureSetHolder extends Holder.Reference<StructureSet> {
private ScaledStructureSetHolder(ResourceKey<StructureSet> key) {
super(Type.STAND_ALONE, new HolderOwner<>() {
}, key, null);
}
private void bind(StructureSet structureSet) {
bindValue(structureSet);
}
}
}
@@ -0,0 +1,248 @@
package art.arcane.iris.modded;
import art.arcane.iris.engine.object.IrisImportedStructureControl;
import art.arcane.iris.engine.object.IrisStructureSetFrequencyOverride;
import art.arcane.volmlib.util.collection.KList;
import net.minecraft.SharedConstants;
import net.minecraft.core.Holder;
import net.minecraft.core.HolderOwner;
import net.minecraft.core.HolderSet;
import net.minecraft.core.Vec3i;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.Bootstrap;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.biome.BiomeSource;
import net.minecraft.world.level.biome.Climate;
import net.minecraft.world.level.chunk.ChunkGeneratorStructureState;
import net.minecraft.world.level.levelgen.RandomState;
import net.minecraft.world.level.levelgen.structure.StructureSet;
import net.minecraft.world.level.levelgen.structure.placement.RandomSpreadStructurePlacement;
import net.minecraft.world.level.levelgen.structure.placement.RandomSpreadType;
import net.minecraft.world.level.levelgen.structure.placement.StructurePlacement;
import net.minecraft.world.level.levelgen.structure.placement.StructurePlacementType;
import org.junit.BeforeClass;
import org.junit.Test;
import java.lang.reflect.Constructor;
import java.util.List;
import java.util.Optional;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
public class ModdedStructureSetFrequencyOverridesTest {
@BeforeClass
public static void bootstrapMinecraftRegistries() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
}
@Test
public void createStateReplacementScalesOnlyTheExactSetAndRetainsEntries() throws Exception {
Holder<StructureSet> complexes = structureSet(
"minecraft:nether_complexes", 27, 4, 30084232);
Holder<StructureSet> fossils = structureSet(
"minecraft:nether_fossils", 2, 1, 14357921);
ChunkGeneratorStructureState state = state(List.of(complexes, fossils));
KList<IrisStructureSetFrequencyOverride> overrides = new KList<>();
overrides.add(new IrisStructureSetFrequencyOverride()
.setStructureSet("minecraft:nether_complexes")
.setMultiplier(1.1D));
IrisImportedStructureControl control = new IrisImportedStructureControl()
.setFrequencyOverrides(overrides);
ChunkGeneratorStructureState returned =
ModdedStructureSetFrequencyOverrides.apply(state, control);
assertSame(state, returned);
List<Holder<StructureSet>> scaledSets = state.possibleStructureSets();
RandomSpreadStructurePlacement scaledComplexes =
(RandomSpreadStructurePlacement) scaledSets.get(0).value().placement();
assertEquals(26, scaledComplexes.spacing());
assertEquals(4, scaledComplexes.separation());
assertSame(complexes.value().structures(), scaledSets.get(0).value().structures());
assertSame(fossils, scaledSets.get(1));
}
@Test
public void unrelatedCustomPlacementTypeRemainsUntouched() {
Holder<StructureSet> custom = new BoundStructureSetHolder(
"example:custom",
new StructureSet(List.of(), new UnsupportedPlacement()));
KList<IrisStructureSetFrequencyOverride> overrides = new KList<>();
overrides.add(new IrisStructureSetFrequencyOverride()
.setStructureSet("minecraft:nether_complexes")
.setMultiplier(1.1D));
IrisImportedStructureControl control = new IrisImportedStructureControl()
.setFrequencyOverrides(overrides);
List<Holder<StructureSet>> scaled =
ModdedStructureSetFrequencyOverrides.scaleSets(List.of(custom), control);
assertSame(custom, scaled.getFirst());
}
@Test
public void unrelatedExclusionCycleRemainsUntouched() {
BoundStructureSetHolder first = new BoundStructureSetHolder("example:first");
BoundStructureSetHolder second = new BoundStructureSetHolder("example:second");
first.bind(new StructureSet(List.of(), placement(32, 8, 1, second)));
second.bind(new StructureSet(List.of(), placement(40, 10, 2, first)));
KList<IrisStructureSetFrequencyOverride> overrides = new KList<>();
overrides.add(new IrisStructureSetFrequencyOverride()
.setStructureSet("minecraft:nether_complexes")
.setMultiplier(1.1D));
IrisImportedStructureControl control = new IrisImportedStructureControl()
.setFrequencyOverrides(overrides);
List<Holder<StructureSet>> scaled = ModdedStructureSetFrequencyOverrides.scaleSets(
List.of(first, second), control);
assertSame(first, scaled.get(0));
assertSame(second, scaled.get(1));
}
@Test
public void affectedExclusionCycleUsesBoundScaledHolders() {
BoundStructureSetHolder first = new BoundStructureSetHolder("example:first");
BoundStructureSetHolder second = new BoundStructureSetHolder("example:second");
first.bind(new StructureSet(List.of(), placement(32, 8, 1, second)));
second.bind(new StructureSet(List.of(), placement(40, 10, 2, first)));
KList<IrisStructureSetFrequencyOverride> overrides = new KList<>();
overrides.add(new IrisStructureSetFrequencyOverride()
.setStructureSet("example:first")
.setMultiplier(1.1D));
IrisImportedStructureControl control = new IrisImportedStructureControl()
.setFrequencyOverrides(overrides);
List<Holder<StructureSet>> scaled = ModdedStructureSetFrequencyOverrides.scaleSets(
List.of(first, second), control);
assertEquals(31, ((RandomSpreadStructurePlacement)
scaled.get(0).value().placement()).spacing());
assertEquals(40, ((RandomSpreadStructurePlacement)
scaled.get(1).value().placement()).spacing());
}
@Test
public void affectedRandomSpreadSubclassFailsInsteadOfLosingSubtypeBehavior() {
Holder<StructureSet> custom = new BoundStructureSetHolder(
"example:custom",
new StructureSet(List.of(), new CustomRandomSpreadPlacement()));
KList<IrisStructureSetFrequencyOverride> overrides = new KList<>();
overrides.add(new IrisStructureSetFrequencyOverride()
.setStructureSet("example:custom")
.setMultiplier(1.1D));
IrisImportedStructureControl control = new IrisImportedStructureControl()
.setFrequencyOverrides(overrides);
assertThrows(IllegalStateException.class, () ->
ModdedStructureSetFrequencyOverrides.scaleSets(List.of(custom), control));
}
private static Holder<StructureSet> structureSet(
String key,
int spacing,
int separation,
int salt
) {
StructureSet value = new StructureSet(
List.of(),
new RandomSpreadStructurePlacement(
spacing, separation, RandomSpreadType.LINEAR, salt));
return new BoundStructureSetHolder(key, value);
}
private static RandomSpreadStructurePlacement placement(
int spacing,
int separation,
int salt,
Holder<StructureSet> exclusionTarget
) {
return new RandomSpreadStructurePlacement(
Vec3i.ZERO,
StructurePlacement.FrequencyReductionMethod.DEFAULT,
1F,
salt,
Optional.of(new StructurePlacement.ExclusionZone(exclusionTarget, 1)),
spacing,
separation,
RandomSpreadType.LINEAR);
}
private static ChunkGeneratorStructureState state(
List<Holder<StructureSet>> sets
) throws Exception {
Constructor<ChunkGeneratorStructureState> constructor =
ChunkGeneratorStructureState.class.getDeclaredConstructor(
RandomState.class,
BiomeSource.class,
long.class,
long.class,
List.class);
constructor.setAccessible(true);
return constructor.newInstance(
null,
new EmptyBiomeSource(),
1L,
1L,
sets);
}
private static final class BoundStructureSetHolder extends Holder.Reference<StructureSet> {
private BoundStructureSetHolder(String key) {
this(key, null);
}
private BoundStructureSetHolder(String key, StructureSet value) {
super(Type.STAND_ALONE, new HolderOwner<>() {
}, ResourceKey.create(Registries.STRUCTURE_SET, Identifier.parse(key)), value);
}
private void bind(StructureSet value) {
bindValue(value);
}
}
private static final class EmptyBiomeSource extends BiomeSource {
@Override
public Holder<Biome> getNoiseBiome(int x, int y, int z, Climate.Sampler sampler) {
return null;
}
@Override
protected com.mojang.serialization.MapCodec<? extends BiomeSource> codec() {
throw new UnsupportedOperationException();
}
@Override
protected java.util.stream.Stream<Holder<Biome>> collectPossibleBiomes() {
return HolderSet.<Biome>empty().stream();
}
}
private static final class UnsupportedPlacement extends StructurePlacement {
private UnsupportedPlacement() {
super(Vec3i.ZERO, FrequencyReductionMethod.DEFAULT, 1F, 1, Optional.empty());
}
@Override
protected boolean isPlacementChunk(ChunkGeneratorStructureState state, int x, int z) {
return false;
}
@Override
public StructurePlacementType<?> type() {
return null;
}
}
private static final class CustomRandomSpreadPlacement extends RandomSpreadStructurePlacement {
private CustomRandomSpreadPlacement() {
super(27, 4, RandomSpreadType.LINEAR, 30084232);
}
}
}
@@ -53,6 +53,7 @@ public class NativeStructureFailureContractTest {
assertTrue(error.getMessage().contains("overworld:overworld"));
assertTrue(error.getMessage().contains("generate-structures=false"));
assertTrue(error.getMessage().contains("importedStructures.disabled"));
assertTrue(error.getMessage().contains("importedStructures.disabledExact"));
}
@Test
@@ -0,0 +1,215 @@
package art.arcane.iris.core;
import org.bukkit.NamespacedKey;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.Objects;
import java.util.regex.Pattern;
public final class ExactWorldSlotPathPolicy {
private static final Pattern SAFE_IRIS_KEY = Pattern.compile("^[a-z0-9_-]+$");
private ExactWorldSlotPathPolicy() {
}
public static Target resolve(Path levelRoot, NamespacedKey worldKey) {
NamespacedKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey");
Path canonicalLevelRoot = canonicalLevelRoot(levelRoot);
SlotKind slotKind = classify(requiredWorldKey);
Path dimensionsRoot = canonicalLevelRoot.resolve("dimensions");
Path namespaceRoot = dimensionsRoot.resolve(requiredWorldKey.getNamespace());
Path worldDirectory = namespaceRoot.resolve(requiredWorldKey.getKey()).normalize();
if (!Objects.equals(worldDirectory.getParent(), namespaceRoot)) {
throw new Rejection(
RejectionReason.PATH_TRAVERSAL,
"World key escapes its exact dimension namespace: " + requiredWorldKey
);
}
requireDirectoryOrAbsent(dimensionsRoot, "dimension storage");
requireDirectoryOrAbsent(namespaceRoot, "dimension namespace");
requireDirectoryOrAbsent(worldDirectory, "world slot");
return new Target(requiredWorldKey, slotKind, canonicalLevelRoot, namespaceRoot, worldDirectory);
}
public static Target validate(Path levelRoot, NamespacedKey worldKey, Path candidate) {
Path requiredCandidate = Objects.requireNonNull(candidate, "candidate");
rejectTraversal(requiredCandidate, "World candidate");
Target target = resolve(levelRoot, worldKey);
Path normalizedCandidate = requiredCandidate.toAbsolutePath().normalize();
if (!normalizedCandidate.equals(target.worldDirectory())) {
throw new Rejection(
RejectionReason.PATH_MISMATCH,
"World candidate is not the exact expected dimension slot."
);
}
requireDirectoryOrAbsent(normalizedCandidate, "world slot");
return target;
}
private static Path canonicalLevelRoot(Path levelRoot) {
Path requiredLevelRoot = Objects.requireNonNull(levelRoot, "levelRoot");
rejectTraversal(requiredLevelRoot, "Level root");
Path normalizedLevelRoot = requiredLevelRoot.toAbsolutePath().normalize();
if (normalizedLevelRoot.getParent() == null) {
throw new Rejection(RejectionReason.UNSAFE_ENTRY, "A filesystem root cannot be a level root.");
}
requireDirectory(normalizedLevelRoot, "level root", true);
try {
return normalizedLevelRoot.toRealPath();
} catch (IOException exception) {
throw new Rejection(
RejectionReason.UNSAFE_ENTRY,
"Could not canonicalize the level root: " + normalizedLevelRoot,
exception
);
}
}
private static SlotKind classify(NamespacedKey worldKey) {
if ("iris".equals(worldKey.getNamespace())) {
if (!SAFE_IRIS_KEY.matcher(worldKey.getKey()).matches()) {
throw new Rejection(
RejectionReason.INVALID_IRIS_KEY,
"Iris world keys must be safe single path segments."
);
}
return SlotKind.IRIS_MANAGED;
}
if (!NamespacedKey.MINECRAFT.equals(worldKey.getNamespace())) {
throw new Rejection(
RejectionReason.FOREIGN_NAMESPACE,
"Only Iris-managed and exact vanilla dimension slots can be replaced."
);
}
return switch (worldKey.getKey()) {
case "overworld" -> SlotKind.VANILLA_OVERWORLD;
case "the_nether" -> SlotKind.VANILLA_NETHER;
case "the_end" -> SlotKind.VANILLA_END;
default -> throw new Rejection(
RejectionReason.UNSUPPORTED_MINECRAFT_SLOT,
"Only minecraft:overworld, minecraft:the_nether, and minecraft:the_end can be replaced."
);
};
}
private static void rejectTraversal(Path path, String label) {
for (Path component : path) {
if ("..".equals(component.toString())) {
throw new Rejection(
RejectionReason.PATH_TRAVERSAL,
label + " contains path traversal."
);
}
}
}
private static void requireDirectoryOrAbsent(Path path, String label) {
if (Files.isSymbolicLink(path)) {
throw new Rejection(
RejectionReason.SYMBOLIC_LINK,
"The " + label + " is a symbolic link: " + path
);
}
requireDirectory(path, label, false);
}
private static void requireDirectory(Path path, String label, boolean required) {
if (Files.isSymbolicLink(path)) {
throw new Rejection(
RejectionReason.SYMBOLIC_LINK,
"The " + label + " is a symbolic link: " + path
);
}
BasicFileAttributes attributes;
try {
attributes = Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
} catch (NoSuchFileException exception) {
if (!required) {
return;
}
throw new Rejection(
RejectionReason.MISSING_LEVEL_ROOT,
"The level root does not exist: " + path,
exception
);
} catch (IOException exception) {
throw new Rejection(
RejectionReason.UNSAFE_ENTRY,
"Could not inspect the " + label + ": " + path,
exception
);
}
if (!attributes.isDirectory()) {
throw new Rejection(
RejectionReason.UNSAFE_ENTRY,
"The " + label + " is not a directory: " + path
);
}
}
public record Target(
NamespacedKey worldKey,
SlotKind slotKind,
Path levelRoot,
Path namespaceRoot,
Path worldDirectory
) {
public Target {
Objects.requireNonNull(worldKey, "worldKey");
Objects.requireNonNull(slotKind, "slotKind");
levelRoot = Objects.requireNonNull(levelRoot, "levelRoot").toAbsolutePath().normalize();
namespaceRoot = Objects.requireNonNull(namespaceRoot, "namespaceRoot").toAbsolutePath().normalize();
worldDirectory = Objects.requireNonNull(worldDirectory, "worldDirectory").toAbsolutePath().normalize();
SlotKind expectedSlotKind = classify(worldKey);
Path expectedNamespaceRoot = levelRoot.resolve("dimensions").resolve(worldKey.getNamespace());
Path expectedWorldDirectory = expectedNamespaceRoot.resolve(worldKey.getKey());
if (slotKind != expectedSlotKind
|| !namespaceRoot.equals(expectedNamespaceRoot)
|| !worldDirectory.equals(expectedWorldDirectory)) {
throw new IllegalArgumentException("World slot paths do not form an exact dimension hierarchy.");
}
}
}
public enum SlotKind {
IRIS_MANAGED,
VANILLA_OVERWORLD,
VANILLA_NETHER,
VANILLA_END
}
public enum RejectionReason {
INVALID_IRIS_KEY,
FOREIGN_NAMESPACE,
UNSUPPORTED_MINECRAFT_SLOT,
MISSING_LEVEL_ROOT,
SYMBOLIC_LINK,
UNSAFE_ENTRY,
PATH_TRAVERSAL,
PATH_MISMATCH
}
public static final class Rejection extends IllegalArgumentException {
private final RejectionReason reason;
private Rejection(RejectionReason reason, String message) {
super(message);
this.reason = Objects.requireNonNull(reason, "reason");
}
private Rejection(RejectionReason reason, String message, Throwable cause) {
super(message, cause);
this.reason = Objects.requireNonNull(reason, "reason");
}
public RejectionReason reason() {
return reason;
}
}
}
@@ -642,7 +642,7 @@ public final class DatapackIngestService {
if (report.changed()) {
message(sender, C.YELLOW + "New datapack structures were installed. A server restart is required for them to register and generate.");
message(sender, C.GRAY + "After the restart they generate natively only in Iris dimensions that declare their source URL - no import needed. To get editable Iris copies (jigsaw pools, pieces & objects written into the pack) run /iris structure import <dimension>, or set general.autoImportDatapackStructures=true to do it on every ingest. Place any registered key directly with a 'structures' placement using nativeStructures.");
message(sender, C.GRAY + "Datapacks replace matching vanilla structure keys by default. Set 'importedStructures.datapackOverrides' to false to keep minecraft-namespaced structure definitions untouched; deny non-minecraft datapack and mod structures explicitly with importedStructures.disabled.");
message(sender, C.GRAY + "Datapacks replace matching vanilla structure keys by default. Set 'importedStructures.datapackOverrides' to false to keep minecraft-namespaced structure definitions untouched; deny non-minecraft datapack and mod structure families with importedStructures.disabled or complete keys with importedStructures.disabledExact.");
if (!restart) {
message(sender, C.GRAY + "Run with restart=true to restart now, or restart manually. After restart, run /iris structure list <dimension> to see the new keys.");
}
@@ -1491,7 +1491,8 @@ public final class DatapackIngestService {
stagedDir, worldFolders, existing, stripOverrides, cacheDir.getParentFile());
installs.add(execution);
InstallResult installResult = execution.result();
recordInstallResult(sender, report, existing, installResult, resolved.getVersionNumber());
recordInstallResult(
sender, report, stagedDir, worldFolders, existing, installResult, resolved.getVersionNumber());
return;
}
@@ -1515,7 +1516,8 @@ public final class DatapackIngestService {
installs.add(execution);
InstallResult installResult = execution.result();
manifest.put(updated);
recordInstallResult(sender, report, updated, installResult, resolved.getVersionNumber());
recordInstallResult(
sender, report, stagedDir, worldFolders, updated, installResult, resolved.getVersionNumber());
return;
}
@@ -1536,7 +1538,8 @@ public final class DatapackIngestService {
InstallResult installResult = execution.result();
writeOwnership(stagedDir, updated);
manifest.put(updated);
recordInstallResult(sender, report, updated, installResult, resolved.getVersionNumber());
recordInstallResult(
sender, report, stagedDir, worldFolders, updated, installResult, resolved.getVersionNumber());
return;
}
@@ -1565,6 +1568,7 @@ public final class DatapackIngestService {
}
installs.add(execution);
InstallResult installResult = execution.result();
recordInstallMetadata(stagedDir, worldFolders, entry);
manifest.put(entry);
report.updated.add(id + " (" + safe(resolved.getVersionNumber()) + ")");
@@ -1793,8 +1797,16 @@ public final class DatapackIngestService {
requireDirectoryIdentity(directory, "datapack install root");
}
private static void recordInstallResult(VolmitSender sender, Report report, Entry entry, InstallResult result, String versionNumber) {
forgetInstallMetadata(entry);
static void recordInstallResult(
VolmitSender sender,
Report report,
File stagedDir,
KList<File> worldFolders,
Entry entry,
InstallResult result,
String versionNumber
) {
recordInstallMetadata(stagedDir, worldFolders, entry);
if (result.changed()) {
report.updated.add(entry.id + " (" + safe(versionNumber) + ")");
report.requiresRestart = true;
@@ -12,8 +12,8 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.Objects;
import java.util.ArrayList;
import java.util.Objects;
import java.util.function.Predicate;
public final class BukkitWorldConfiguration {
@@ -55,6 +55,60 @@ public final class BukkitWorldConfiguration {
}
}
public static WorldGeneratorSnapshot snapshot(File configurationFile, String worldName) throws IOException {
Objects.requireNonNull(configurationFile, "configurationFile");
String requiredWorldName = requireWorldName(worldName);
synchronized (MUTATION_LOCK) {
return snapshot(load(configurationFile), requiredWorldName);
}
}
public static GeneratorReplacement replaceIfMatching(
File configurationFile,
String worldName,
WorldGeneratorSnapshot expected,
String dimension,
Long seed
) throws IOException {
Objects.requireNonNull(configurationFile, "configurationFile");
String requiredWorldName = requireWorldName(worldName);
WorldGeneratorSnapshot requiredExpected = Objects.requireNonNull(expected, "expected");
String requiredDimension = requireName(dimension, "Dimension");
WorldGeneratorSnapshot replacement = WorldGeneratorSnapshot.configured(requiredDimension, seed);
synchronized (MUTATION_LOCK) {
YamlConfiguration configuration = load(configurationFile);
WorldGeneratorSnapshot current = snapshot(configuration, requiredWorldName);
if (!current.matchesGeneratorAndSeed(requiredExpected)) {
return new GeneratorReplacement(false, current, replacement);
}
apply(configuration, requiredWorldName, replacement);
saveAtomic(configurationFile.toPath(), configuration);
return new GeneratorReplacement(true, current, replacement);
}
}
public static boolean restoreIfMatching(
File configurationFile,
String worldName,
WorldGeneratorSnapshot expectedCurrent,
WorldGeneratorSnapshot restoration
) throws IOException {
Objects.requireNonNull(configurationFile, "configurationFile");
String requiredWorldName = requireWorldName(worldName);
WorldGeneratorSnapshot requiredExpected = Objects.requireNonNull(expectedCurrent, "expectedCurrent");
WorldGeneratorSnapshot requiredRestoration = Objects.requireNonNull(restoration, "restoration");
synchronized (MUTATION_LOCK) {
YamlConfiguration configuration = load(configurationFile);
WorldGeneratorSnapshot current = snapshot(configuration, requiredWorldName);
if (!current.matchesGeneratorAndSeed(requiredExpected)) {
return false;
}
apply(configuration, requiredWorldName, requiredRestoration);
saveAtomic(configurationFile.toPath(), configuration);
return true;
}
}
public static boolean remove(File configurationFile, String worldName) throws IOException {
Objects.requireNonNull(configurationFile, "configurationFile");
String requiredWorldName = requireWorldName(worldName);
@@ -172,6 +226,97 @@ public final class BukkitWorldConfiguration {
}
}
private static WorldGeneratorSnapshot snapshot(
YamlConfiguration configuration,
String worldName
) throws IOException {
Object rawWorlds = configuration.get("worlds");
ConfigurationSection worlds = configuration.getConfigurationSection("worlds");
if (rawWorlds != null && worlds == null) {
throw new IOException("bukkit.yml worlds entry is not a section and was not changed.");
}
if (worlds == null) {
return WorldGeneratorSnapshot.absent();
}
Object rawWorld = worlds.get(worldName);
ConfigurationSection world = worlds.getConfigurationSection(worldName);
if (rawWorld != null && world == null) {
throw new IOException("bukkit.yml world entry \"" + worldName + "\" is not a section and was not changed.");
}
if (world == null) {
return WorldGeneratorSnapshot.absentWorld(true);
}
boolean generatorPresent = world.getKeys(false).contains("generator");
String generator = null;
if (generatorPresent) {
Object rawGenerator = world.get("generator");
if (!(rawGenerator instanceof String generatorValue)) {
throw new IOException("bukkit.yml generator for world \"" + worldName
+ "\" is not a string and was not changed.");
}
generator = generatorValue;
}
boolean seedPresent = world.getKeys(false).contains("seed");
Long seed = null;
if (seedPresent) {
Object rawSeed = world.get("seed");
if (!(rawSeed instanceof Byte
|| rawSeed instanceof Short
|| rawSeed instanceof Integer
|| rawSeed instanceof Long)) {
throw new IOException("bukkit.yml seed for world \"" + worldName
+ "\" is not an integer and was not changed.");
}
seed = ((Number) rawSeed).longValue();
}
return new WorldGeneratorSnapshot(
true,
true,
generatorPresent,
generator,
seedPresent,
seed
);
}
private static void apply(
YamlConfiguration configuration,
String worldName,
WorldGeneratorSnapshot snapshot
) throws IOException {
ConfigurationSection worlds = configuration.getConfigurationSection("worlds");
if (worlds == null) {
Object rawWorlds = configuration.get("worlds");
if (rawWorlds != null) {
throw new IOException("bukkit.yml worlds entry is not a section and was not changed.");
}
worlds = configuration.createSection("worlds");
}
ConfigurationSection world = worlds.getConfigurationSection(worldName);
if (world == null) {
Object rawWorld = worlds.get(worldName);
if (rawWorld != null) {
throw new IOException("bukkit.yml world entry \"" + worldName
+ "\" is not a section and was not changed.");
}
world = worlds.createSection(worldName);
}
world.set("generator", snapshot.generatorPresent() ? snapshot.generator() : null);
world.set("seed", snapshot.seedPresent() ? snapshot.seed() : null);
if (!snapshot.worldSectionPresent() && world.getKeys(false).isEmpty()) {
worlds.set(worldName, null);
}
if (!snapshot.worldsSectionPresent() && worlds.getKeys(false).isEmpty()) {
configuration.set("worlds", null);
}
}
private static String requireWorldName(String value) {
String worldName = requireName(value, "World name");
if (!worldName.matches("[a-z0-9_-]+")) {
@@ -191,4 +336,58 @@ public final class BukkitWorldConfiguration {
CREATED,
UNCHANGED
}
public record WorldGeneratorSnapshot(
boolean worldsSectionPresent,
boolean worldSectionPresent,
boolean generatorPresent,
String generator,
boolean seedPresent,
Long seed
) {
public WorldGeneratorSnapshot {
if (worldSectionPresent && !worldsSectionPresent) {
throw new IllegalArgumentException("A world section requires a worlds section.");
}
if (!worldSectionPresent && (generatorPresent || seedPresent)) {
throw new IllegalArgumentException("Generator and seed values require a world section.");
}
if (generatorPresent != (generator != null)) {
throw new IllegalArgumentException("Generator presence and value must agree.");
}
if (seedPresent != (seed != null)) {
throw new IllegalArgumentException("Seed presence and value must agree.");
}
}
private static WorldGeneratorSnapshot absent() {
return new WorldGeneratorSnapshot(false, false, false, null, false, null);
}
private static WorldGeneratorSnapshot absentWorld(boolean worldsSectionPresent) {
return new WorldGeneratorSnapshot(worldsSectionPresent, false, false, null, false, null);
}
private static WorldGeneratorSnapshot configured(String dimension, Long seed) {
return new WorldGeneratorSnapshot(true, true, true, "Iris:" + dimension, seed != null, seed);
}
public boolean matchesGeneratorAndSeed(WorldGeneratorSnapshot other) {
return generatorPresent == other.generatorPresent
&& Objects.equals(generator, other.generator)
&& seedPresent == other.seedPresent
&& Objects.equals(seed, other.seed);
}
}
public record GeneratorReplacement(
boolean applied,
WorldGeneratorSnapshot observed,
WorldGeneratorSnapshot replacement
) {
public GeneratorReplacement {
Objects.requireNonNull(observed, "observed");
Objects.requireNonNull(replacement, "replacement");
}
}
}
@@ -179,6 +179,7 @@ public final class LifecycleOperationCoordinator {
WORLD_LOAD,
WORLD_UNLOAD,
WORLD_REMOVE,
WORLD_REPLACE,
WORLD_PROMOTE,
STUDIO_OPEN,
STUDIO_CLOSE,
@@ -0,0 +1,305 @@
package art.arcane.iris.core.lifecycle;
import art.arcane.iris.core.SnapshotDirectoryTreeDeleter;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
import java.util.regex.Pattern;
import java.util.stream.Stream;
public final class WorldReplacementFilesystem {
private static final Pattern STAGE_NAME = Pattern.compile(
"^\\.iris-replace-[a-z0-9_-]+-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\.stage$");
private static final Pattern BACKUP_NAME = Pattern.compile(
"^\\.iris-replace-[a-z0-9_-]+-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\.backup$");
private WorldReplacementFilesystem() {
}
public static void publish(
ReplacementPaths paths,
boolean originalTargetPresent,
String expectedPackFingerprint
) throws IOException {
ReplacementPaths requiredPaths = Objects.requireNonNull(paths, "paths");
String expectedFingerprint = requireFingerprint(expectedPackFingerprint);
State state = inspect(requiredPaths);
if (state.stagePresent()) {
requireSafeTree(requiredPaths.stage(), "replacement stage");
requireFingerprint(requiredPaths.stage().resolve("iris/pack"), expectedFingerprint);
if (state.targetPresent()) {
if (state.backupPresent()) {
throw new IOException("Replacement target, stage, and backup are all present.");
}
if (!originalTargetPresent) {
throw new IOException("Replacement target appeared after an absent target was staged.");
}
move(requiredPaths.target(), requiredPaths.backup());
state = inspect(requiredPaths);
}
if (state.targetPresent()) {
throw new IOException("Replacement target is still present after backup publication.");
}
if (originalTargetPresent != state.backupPresent()) {
throw new IOException("Replacement backup state does not match the original target state.");
}
requireSafeTree(requiredPaths.stage(), "replacement stage");
requireFingerprint(requiredPaths.stage().resolve("iris/pack"), expectedFingerprint);
move(requiredPaths.stage(), requiredPaths.target());
state = inspect(requiredPaths);
}
if (!state.targetPresent() || state.stagePresent()) {
throw new IOException("Replacement publication did not produce one exact target directory.");
}
if (originalTargetPresent != state.backupPresent()) {
throw new IOException("Replacement publication lost or unexpectedly created its backup.");
}
requireSafeTree(requiredPaths.target(), "replacement target");
requireFingerprint(requiredPaths.target().resolve("iris/pack"), expectedFingerprint);
}
public static void rollback(ReplacementPaths paths, boolean originalTargetPresent) throws IOException {
prepareRollback(paths, originalTargetPresent);
discardStage(paths);
}
public static void prepareRollback(ReplacementPaths paths, boolean originalTargetPresent) throws IOException {
ReplacementPaths requiredPaths = Objects.requireNonNull(paths, "paths");
State state = inspect(requiredPaths);
if (originalTargetPresent) {
if (state.backupPresent()) {
if (state.targetPresent()) {
if (state.stagePresent()) {
throw new IOException("Rollback cannot quarantine two replacement directories.");
}
move(requiredPaths.target(), requiredPaths.stage());
}
move(requiredPaths.backup(), requiredPaths.target());
state = inspect(requiredPaths);
}
if (!state.targetPresent() || state.backupPresent()) {
throw new IOException("Rollback could not restore the original world target.");
}
} else {
if (state.backupPresent()) {
throw new IOException("An originally absent world acquired an unexpected backup.");
}
if (state.targetPresent()) {
if (state.stagePresent()) {
throw new IOException("Rollback cannot quarantine two replacement directories.");
}
move(requiredPaths.target(), requiredPaths.stage());
state = inspect(requiredPaths);
}
if (state.targetPresent()) {
throw new IOException("Rollback could not remove the replacement target.");
}
}
}
public static void discardStage(ReplacementPaths paths) throws IOException {
ReplacementPaths requiredPaths = Objects.requireNonNull(paths, "paths");
State state = inspect(requiredPaths);
if (state.backupPresent()) {
throw new IOException("Cannot discard a replacement stage after a backup was published.");
}
if (state.stagePresent()) {
SnapshotDirectoryTreeDeleter.delete(requiredPaths.stage());
}
}
public static void cleanupBackup(ReplacementPaths paths) throws IOException {
ReplacementPaths requiredPaths = Objects.requireNonNull(paths, "paths");
State state = inspect(requiredPaths);
if (!state.targetPresent() || state.stagePresent()) {
throw new IOException("Cannot clean a replacement backup before publication is complete.");
}
if (state.backupPresent()) {
SnapshotDirectoryTreeDeleter.delete(requiredPaths.backup());
}
}
public static String fingerprintPack(Path packRoot) throws IOException {
Path root = Objects.requireNonNull(packRoot, "packRoot").toAbsolutePath().normalize();
requireDirectory(root, "pack root");
MessageDigest digest = sha256();
List<Path> files;
try (Stream<Path> stream = Files.walk(root)) {
files = stream
.filter(path -> !path.equals(root))
.filter(path -> !containsMetadataSegment(root.relativize(path)))
.sorted(Comparator.comparing(path -> root.relativize(path).toString()))
.toList();
}
for (Path file : files) {
BasicFileAttributes attributes = requireSafeEntry(file);
Path relative = root.relativize(file);
update(digest, relative.toString().replace(file.getFileSystem().getSeparator(), "/"));
digest.update((byte) (attributes.isDirectory() ? 1 : 0));
if (!attributes.isRegularFile()) {
continue;
}
update(digest, Long.toString(attributes.size()));
try (InputStream input = Files.newInputStream(file)) {
byte[] buffer = new byte[8192];
int read;
while ((read = input.read(buffer)) >= 0) {
digest.update(buffer, 0, read);
}
}
}
return HexFormat.of().formatHex(digest.digest());
}
private static State inspect(ReplacementPaths paths) throws IOException {
return new State(
directoryPresent(paths.target(), "replacement target"),
directoryPresent(paths.stage(), "replacement stage"),
directoryPresent(paths.backup(), "replacement backup")
);
}
private static boolean directoryPresent(Path path, String label) throws IOException {
if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
return false;
}
requireDirectory(path, label);
return true;
}
private static void requireFingerprint(Path packRoot, String expected) throws IOException {
String actual = fingerprintPack(packRoot);
if (!actual.equals(expected)) {
throw new IOException("Staged world pack fingerprint changed before publication.");
}
}
private static String requireFingerprint(String value) {
String fingerprint = Objects.requireNonNull(value, "expectedPackFingerprint");
if (!fingerprint.matches("[0-9a-f]{64}")) {
throw new IllegalArgumentException("Pack fingerprint must be lowercase SHA-256.");
}
return fingerprint;
}
private static boolean containsMetadataSegment(Path relative) {
for (Path component : relative) {
if (".iris".equals(component.toString())) {
return true;
}
}
return false;
}
private static BasicFileAttributes requireSafeEntry(Path path) throws IOException {
BasicFileAttributes attributes = Files.readAttributes(
path,
BasicFileAttributes.class,
LinkOption.NOFOLLOW_LINKS
);
if (attributes.isSymbolicLink()) {
throw new IOException("Replacement storage contains a symbolic link: " + path);
}
if (!attributes.isDirectory() && !attributes.isRegularFile()) {
throw new IOException("Replacement storage contains an unsafe entry: " + path);
}
return attributes;
}
private static void requireDirectory(Path path, String label) throws IOException {
BasicFileAttributes attributes = requireSafeEntry(path);
if (!attributes.isDirectory()) {
throw new IOException("The " + label + " is not a directory: " + path);
}
}
private static void requireSafeTree(Path root, String label) throws IOException {
requireDirectory(root, label);
List<Path> entries;
try (Stream<Path> stream = Files.walk(root)) {
entries = stream.sorted().toList();
}
for (Path entry : entries) {
requireSafeEntry(entry);
}
}
private static void update(MessageDigest digest, String value) {
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(bytes.length).array());
digest.update(bytes);
}
private static MessageDigest sha256() {
try {
return MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 is unavailable.", exception);
}
}
private static void move(Path source, Path target) throws IOException {
try {
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException exception) {
Files.move(source, target);
}
try (FileChannel channel = FileChannel.open(source.getParent(), StandardOpenOption.READ)) {
channel.force(true);
}
}
public record ReplacementPaths(Path target, Path stage, Path backup) {
public ReplacementPaths {
target = normalize(target, "target");
stage = normalize(stage, "stage");
backup = normalize(backup, "backup");
Path parent = target.getParent();
if (parent == null || !parent.equals(stage.getParent()) || !parent.equals(backup.getParent())) {
throw new IllegalArgumentException("Replacement paths must have one exact parent.");
}
if (target.equals(stage) || target.equals(backup) || stage.equals(backup)) {
throw new IllegalArgumentException("Replacement paths must be distinct.");
}
if (!STAGE_NAME.matcher(stage.getFileName().toString()).matches()
|| !BACKUP_NAME.matcher(backup.getFileName().toString()).matches()) {
throw new IllegalArgumentException("Replacement artifact names are invalid.");
}
String stageStem = stage.getFileName().toString().replaceFirst("\\.stage$", "");
String backupStem = backup.getFileName().toString().replaceFirst("\\.backup$", "");
if (!stageStem.equals(backupStem)) {
throw new IllegalArgumentException("Replacement stage and backup do not belong to one transaction.");
}
}
private static Path normalize(Path path, String label) {
Path required = Objects.requireNonNull(path, label).toAbsolutePath();
for (Path component : required) {
if ("..".equals(component.toString())) {
throw new IllegalArgumentException("Replacement " + label + " contains path traversal.");
}
}
return required.normalize();
}
}
private record State(boolean targetPresent, boolean stagePresent, boolean backupPresent) {
}
}
@@ -290,6 +290,10 @@ public final class DirectorCommandMessages {
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world",
"Whether or not to automatically use this world as the main world"
);
public static final TextKey COMMAND_IRIS_PARAM_REPLACE_EXACT_EXISTING_WORLD_SLOT_NEXT_RESTART = TextKey.of(
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart",
"Replace the exact existing world slot on the next restart"
);
public static final TextKey COMMAND_IRIS_DIRECTOR_TELEPORT_ANOTHER_WORLD = TextKey.of(
"iris.director.commandiris.director.teleport_another_world",
"Teleport to another world"
@@ -951,6 +955,7 @@ public final class DirectorCommandMessages {
COMMAND_IRIS_PARAM_DIMENSION_PACK_CREATE_WORLD_WITH,
COMMAND_IRIS_PARAM_SEED_GENERATE_WORLD_WITH,
COMMAND_IRIS_PARAM_WHETHER_NOT_AUTOMATICALLY_USE_THIS_WORLD_AS_MAIN_WORLD,
COMMAND_IRIS_PARAM_REPLACE_EXACT_EXISTING_WORLD_SLOT_NEXT_RESTART,
COMMAND_IRIS_DIRECTOR_TELEPORT_ANOTHER_WORLD,
COMMAND_IRIS_PARAM_WORLD_TELEPORT,
COMMAND_IRIS_PARAM_PLAYER_TELEPORT,
@@ -19,6 +19,7 @@
package art.arcane.iris.core.nms;
import art.arcane.iris.core.datapack.DatapackStructureScopeIndex;
import art.arcane.iris.engine.object.IrisImportedStructureControl;
import art.arcane.iris.core.lifecycle.WorldLifecycleCaller;
import art.arcane.iris.core.lifecycle.WorldLifecycleRequest;
import art.arcane.iris.core.lifecycle.WorldLifecycleService;
@@ -231,7 +232,8 @@ public interface INMSBinding {
DatapackStructureScopeResult scopeDatapackStructures(
World world,
DatapackStructureScopeIndex scopeIndex,
Set<String> declaredSources
Set<String> declaredSources,
IrisImportedStructureControl importedStructures
) throws NoSuchFieldException, IllegalAccessException;
void completeStudioStructureBootstrap(World world) throws NoSuchFieldException, IllegalAccessException;
@@ -35,6 +35,7 @@ public class DataFixerV1217 extends DataFixerV1213 {
"sound": "minecraft:music.game"
}
},
"minecraft:visual/ambient_light_color": "#0a0a0a",
"minecraft:visual/cloud_color": "#ccffffff",
"minecraft:visual/fog_color": "#c0d8ff",
"minecraft:visual/sky_color": "#78a7ff"
@@ -49,6 +50,7 @@ public class DataFixerV1217 extends DataFixerV1213 {
"attributes": {
"minecraft:gameplay/sky_light_level": 4.0,
"minecraft:gameplay/snow_golem_melts": true,
"minecraft:visual/ambient_light_color": "#302821",
"minecraft:visual/fog_end_distance": 96.0,
"minecraft:visual/fog_start_distance": 10.0,
"minecraft:visual/sky_light_color": "#7a7aff",
@@ -81,6 +83,7 @@ public class DataFixerV1217 extends DataFixerV1213 {
"sound": "minecraft:music.end"
}
},
"minecraft:visual/ambient_light_color": "#3f473f",
"minecraft:visual/fog_color": "#181318",
"minecraft:visual/sky_color": "#000000",
"minecraft:visual/sky_light_color": "#e580ff",
@@ -93,7 +96,28 @@ public class DataFixerV1217 extends DataFixerV1213 {
@Override
public JSONObject fixCustomBiome(IrisBiomeCustom biome, JSONObject json) {
return super.fixCustomBiome(biome, json);
JSONObject fixed = super.fixCustomBiome(biome, json);
JSONObject effects = fixed.getJSONObject("effects");
JSONObject attributes = fixed.optJSONObject("attributes");
if (attributes == null) {
attributes = new JSONObject();
fixed.put("attributes", attributes);
}
moveAttribute(effects, attributes, "sky_color", "minecraft:visual/sky_color");
moveAttribute(effects, attributes, "fog_color", "minecraft:visual/fog_color");
moveAttribute(effects, attributes, "water_fog_color", "minecraft:visual/water_fog_color");
JSONObject particle = effects.optJSONObject("particle");
if (particle != null) {
JSONObject ambientParticle = new JSONObject();
ambientParticle.put("particle", particle.remove("options"));
ambientParticle.put("probability", particle.remove("probability"));
attributes.put("minecraft:visual/ambient_particles", new JSONArray().put(ambientParticle));
effects.remove("particle");
}
return fixed;
}
@Override
@@ -144,6 +168,18 @@ public class DataFixerV1217 extends DataFixerV1213 {
json.remove("effects");
JSONObject defaults = new JSONObject(DIMENSIONS.get(dimension));
merge(json, defaults);
Object ambientLight = json.opt("ambient_light");
if (ambientLight instanceof Number number && number.doubleValue() >= 1D) {
json.getJSONObject("attributes").put("minecraft:visual/ambient_light_color", "#ffffff");
}
}
private void moveAttribute(JSONObject source, JSONObject target, String sourceKey, String targetKey) {
Object value = source.remove(sourceKey);
if (value != null) {
target.put(targetKey, value);
}
}
private void merge(JSONObject base, JSONObject override) {
@@ -20,6 +20,7 @@ package art.arcane.iris.core.nms.v1X;
import art.arcane.iris.core.datapack.DatapackStructureScopeIndex;
import art.arcane.iris.core.nms.DatapackStructureScopeResult;
import art.arcane.iris.engine.object.IrisImportedStructureControl;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.core.nms.INMSBinding;
import art.arcane.iris.core.nms.container.BiomeColor;
@@ -109,7 +110,8 @@ public class NMSBinding1X implements INMSBinding {
public DatapackStructureScopeResult scopeDatapackStructures(
World world,
DatapackStructureScopeIndex scopeIndex,
Set<String> declaredSources
Set<String> declaredSources,
IrisImportedStructureControl importedStructures
) {
throw new IllegalStateException("Iris-managed datapack structure isolation requires the supported NMS binding");
}
@@ -0,0 +1,98 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.pack;
import art.arcane.volmlib.util.json.JSONObject;
import java.io.File;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
final class PackCaveProfileValidator {
private static final String CAVE_PROFILE_SNIPPET_FOLDER = "snippet/cave-profile";
private static final List<LegacyField> LEGACY_FIELDS = List.of(
new LegacyField("allowWater", "allowFluid"),
new LegacyField("waterMinDepthBelowSurface", "fluidMinDepthBelowSurface"),
new LegacyField("waterRequiresFloor", "fluidRequiresFloor")
);
private PackCaveProfileValidator() {
}
static List<String> validateLegacyFields(File packFolder) {
List<String> blockingErrors = new ArrayList<>();
if (packFolder == null || !packFolder.isDirectory()) {
return blockingErrors;
}
for (String folderName : PackValidator.STRUCTURE_HOST_FOLDERS) {
File resourceFolder = new File(packFolder, folderName);
if (!resourceFolder.isDirectory()) {
continue;
}
List<File> resourceFiles = PackValidationIo.listJsonRecursive(resourceFolder);
resourceFiles.sort(Comparator.comparing(File::getPath));
String resourceType = PackStructurePlacementValidator.structureHostType(folderName);
for (File resourceFile : resourceFiles) {
JSONObject resource = PackValidationIo.readJson(resourceFile);
if (resource == null) {
continue;
}
JSONObject caveProfile = resource.optJSONObject("caveProfile");
if (caveProfile == null) {
continue;
}
String resourceKey = PackValidationIo.deriveKey(resourceFolder, resourceFile);
validateFields(resourceType + " '" + resourceKey + "' caveProfile.", "caveProfile.", caveProfile,
blockingErrors);
}
}
File snippetFolder = new File(packFolder, CAVE_PROFILE_SNIPPET_FOLDER);
if (!snippetFolder.isDirectory()) {
return blockingErrors;
}
List<File> snippetFiles = PackValidationIo.listJsonRecursive(snippetFolder);
snippetFiles.sort(Comparator.comparing(File::getPath));
for (File snippetFile : snippetFiles) {
JSONObject caveProfile = PackValidationIo.readJson(snippetFile);
if (caveProfile == null) {
continue;
}
String snippetKey = PackValidationIo.deriveKey(snippetFolder, snippetFile);
validateFields("Cave-profile snippet '" + snippetKey + "' ", "", caveProfile, blockingErrors);
}
return blockingErrors;
}
private static void validateFields(String location, String replacementPrefix, JSONObject caveProfile,
List<String> blockingErrors) {
for (LegacyField field : LEGACY_FIELDS) {
if (caveProfile.has(field.oldName())) {
blockingErrors.add(location + field.oldName() + " was removed; use " + replacementPrefix
+ field.newName()
+ ". Cave aquifers use the dimension fluidPalette, which defaults to water.");
}
}
}
private record LegacyField(String oldName, String newName) {
}
}
@@ -105,13 +105,15 @@ final class PackDimensionValidator {
}
if (policy.has("mode")) {
blockingErrors.add("Dimension '" + dimensionKey
+ "' importedStructures.mode is not supported. Native structures are enabled by default; list explicit denials in importedStructures.disabled.");
+ "' importedStructures.mode is not supported. Native structures are enabled by default; deny families in importedStructures.disabled or complete keys in importedStructures.disabledExact.");
}
if (policy.has("enabled")) {
blockingErrors.add("Dimension '" + dimensionKey
+ "' importedStructures.enabled is not supported. Native structures are enabled by default; list explicit denials in importedStructures.disabled.");
+ "' importedStructures.enabled is not supported. Native structures are enabled by default; deny families in importedStructures.disabled or complete keys in importedStructures.disabledExact.");
}
validateStructureKeyList(dimensionKey, policy, "disabled", blockingErrors);
validateStructureKeyList(dimensionKey, policy, "disabledExact", blockingErrors);
validateFrequencyOverrides(dimensionKey, policy, blockingErrors);
JSONArray adjustments = policy.optJSONArray("adjustments");
if (adjustments == null) {
if (policy.has("adjustments")) {
@@ -138,6 +140,36 @@ final class PackDimensionValidator {
}
}
private static void validateFrequencyOverrides(String dimensionKey, JSONObject policy,
List<String> blockingErrors) {
if (!policy.has("frequencyOverrides")) {
return;
}
JSONArray overrides = policy.optJSONArray("frequencyOverrides");
if (overrides == null) {
blockingErrors.add("Dimension '" + dimensionKey
+ "' importedStructures.frequencyOverrides must be an array.");
return;
}
for (int index = 0; index < overrides.length(); index++) {
JSONObject override = overrides.optJSONObject(index);
String path = "Dimension '" + dimensionKey
+ "' importedStructures.frequencyOverrides[" + index + "]";
if (override == null) {
blockingErrors.add(path + " must be an object.");
continue;
}
Object rawKey = override.opt("structureSet");
if (!(rawKey instanceof String key)
|| key.isBlank()
|| !PackValidator.RESOURCE_KEY_PATTERN.matcher(key.trim()).matches()) {
blockingErrors.add(path + ".structureSet must be a namespaced registry key.");
}
PackJsonFieldChecks.validateOptionalDoubleRange(
path, override, "multiplier", 0.01D, 16D, blockingErrors);
}
}
private static void validateAdjustmentYBand(String dimensionKey, JSONObject adjustment, int index,
List<String> blockingErrors) {
if (!adjustment.has("yBand") || adjustment.opt("yBand") == JSONObject.NULL) {
@@ -28,7 +28,7 @@ import java.util.Optional;
import java.util.Set;
public final class PackValidationCache {
private static final int SCHEMA_VERSION = 1;
private static final int SCHEMA_VERSION = 2;
private static final long MAX_CACHE_BYTES = 16L * 1024L * 1024L;
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
@@ -18,6 +18,9 @@
package art.arcane.iris.core.pack;
import java.io.IOException;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@@ -25,6 +28,7 @@ import java.util.concurrent.ConcurrentHashMap;
public final class PackValidationRegistry {
private static final Map<String, PackValidationResult> RESULTS = new ConcurrentHashMap<>();
private static final Map<Path, PackValidationResult> ROOT_RESULTS = new ConcurrentHashMap<>();
private PackValidationRegistry() {
}
@@ -36,6 +40,13 @@ public final class PackValidationRegistry {
RESULTS.put(result.getPackName(), result);
}
public static void publish(Path packRoot, PackValidationResult result) {
if (packRoot == null || result == null) {
return;
}
ROOT_RESULTS.put(normalize(packRoot), result);
}
public static PackValidationResult get(String packName) {
if (packName == null || packName.isBlank()) {
return null;
@@ -43,6 +54,13 @@ public final class PackValidationRegistry {
return RESULTS.get(packName);
}
public static PackValidationResult get(Path packRoot) {
if (packRoot == null) {
return null;
}
return ROOT_RESULTS.get(normalize(packRoot));
}
public static PackValidationResult requireLoadable(String packName) {
if (packName == null || packName.isBlank()) {
throw new IllegalArgumentException("Pack name is required for validation");
@@ -58,11 +76,32 @@ public final class PackValidationRegistry {
return result;
}
public static PackValidationResult requireLoadable(Path packRoot) {
if (packRoot == null) {
throw new IllegalArgumentException("Pack root is required for validation");
}
Path normalizedRoot = normalize(packRoot);
PackValidationResult result = get(normalizedRoot);
if (result == null) {
throw new BrokenPackException(normalizedRoot.toString(), List.of(
"Required pack validation has not completed. World creation fails closed until validation succeeds."));
}
if (!result.isLoadable()) {
throw new BrokenPackException(normalizedRoot.toString(), result.getBlockingErrors());
}
return result;
}
public static boolean isBroken(String packName) {
PackValidationResult result = get(packName);
return result != null && !result.isLoadable();
}
public static boolean isBroken(Path packRoot) {
PackValidationResult result = get(packRoot);
return result != null && !result.isLoadable();
}
public static Map<String, PackValidationResult> snapshot() {
return Collections.unmodifiableMap(RESULTS);
}
@@ -74,7 +113,26 @@ public final class PackValidationRegistry {
RESULTS.remove(packName);
}
public static void remove(Path packRoot) {
if (packRoot == null) {
return;
}
ROOT_RESULTS.remove(normalize(packRoot));
}
public static void clear() {
RESULTS.clear();
ROOT_RESULTS.clear();
}
private static Path normalize(Path packRoot) {
Path normalizedRoot = packRoot.toAbsolutePath().normalize();
try {
return normalizedRoot.toRealPath();
} catch (NoSuchFileException exception) {
return normalizedRoot;
} catch (IOException exception) {
throw new IllegalArgumentException("Unable to resolve Iris pack root: " + normalizedRoot, exception);
}
}
}
@@ -76,6 +76,7 @@ public final class PackValidator {
}
PackDimensionValidator.validateDimensions(packFolder, dimensionFiles, blockingErrors, warnings);
blockingErrors.addAll(PackCaveProfileValidator.validateLegacyFields(packFolder));
blockingErrors.addAll(PackLootValidator.validateLootGraph(packFolder));
blockingErrors.addAll(PackObjectSurfaceValidator.validateRemovedWorldgenFields(packFolder));
blockingErrors.addAll(PackObjectSurfaceValidator.validateObjectSurfaceSupport(packFolder));
@@ -197,7 +197,11 @@ public class SchemaBuilder {
}
private JSONArray vanillaStructureSets() {
return keysAsArray(IrisPlatforms.get().structureHooks().structureSetKeys());
if (IrisPlatforms.get().structureHooks() == null) {
return new JSONArray();
}
List<String> keys = IrisPlatforms.get().structureHooks().structureSetKeys();
return keysAsArray(keys == null ? List.of() : keys);
}
private JSONArray nativeJigsawPools() {
@@ -224,6 +224,7 @@ public class StudioSVC implements IrisService {
}
publication = AtomicDirectoryPublisher.publish(stage, target);
stage = null;
validatePublishedPack(target);
IrisData installedData;
boolean activeRuntime = previousData != null && !previousData.getEngines().isEmpty();
@@ -256,6 +257,9 @@ public class StudioSVC implements IrisService {
return installedDimension;
} catch (Throwable e) {
rollbackFailedPublication(createdData, publication, e);
if (publication != null) {
invalidatePackValidation(target);
}
if (refreshedPreviousData) {
try {
previousData.hotloaded();
@@ -277,6 +281,17 @@ public class StudioSVC implements IrisService {
}
}
static void invalidatePackValidation(Path packRoot) {
PackValidationRegistry.remove(packRoot);
}
static PackValidationResult validatePublishedPack(Path packRoot) {
invalidatePackValidation(packRoot);
PackValidationResult result = PackValidator.validate(packRoot.toFile());
PackValidationRegistry.publish(packRoot, result);
return PackValidationRegistry.requireLoadable(packRoot);
}
static void rollbackFailedPublication(
IrisData createdData,
AtomicDirectoryPublisher.Publication publication,
@@ -0,0 +1,63 @@
/*
* 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.engine.framework;
public record NativeStructureFrequencyScale(float frequency, int spacing) {
public static NativeStructureFrequencyScale randomSpread(
float frequency,
int spacing,
int separation,
double multiplier
) {
if (!Float.isFinite(frequency) || frequency < 0F || frequency > 1F) {
throw new IllegalArgumentException("Native structure frequency must be between 0 and 1");
}
if (spacing <= separation) {
throw new IllegalArgumentException("Native structure spacing must exceed separation");
}
requireMultiplier(multiplier);
if (frequency == 0F || multiplier == 1D) {
return new NativeStructureFrequencyScale(frequency, spacing);
}
double requestedFrequency = frequency * multiplier;
float scaledFrequency = (float) Math.min(1D, requestedFrequency);
double remainingDensity = requestedFrequency / scaledFrequency;
int scaledSpacing = spacing;
if (remainingDensity > 1D) {
int requestedSpacing = (int) Math.round(spacing / Math.sqrt(remainingDensity));
scaledSpacing = Math.max(separation + 1, requestedSpacing);
}
return new NativeStructureFrequencyScale(scaledFrequency, scaledSpacing);
}
public static float probability(float frequency, double multiplier) {
if (!Float.isFinite(frequency) || frequency < 0F || frequency > 1F) {
throw new IllegalArgumentException("Native structure frequency must be between 0 and 1");
}
requireMultiplier(multiplier);
return (float) Math.min(1D, frequency * multiplier);
}
private static void requireMultiplier(double multiplier) {
if (!Double.isFinite(multiplier) || multiplier < 0.01D || multiplier > 16D) {
throw new IllegalArgumentException("Native structure frequency multiplier must be between 0.01 and 16");
}
}
}
@@ -24,7 +24,7 @@ import art.arcane.volmlib.util.matter.MatterSlice;
final class CaveCarveScratch {
final int[] columnMaxY = new int[256];
final int[] waterMaxY = new int[256];
final int[] fluidMaxY = new int[256];
final int[] surfaceBreakFloorY = new int[256];
final boolean[] surfaceBreakColumn = new boolean[256];
final double[] columnThreshold = new double[256];
@@ -28,11 +28,11 @@ import java.util.BitSet;
import java.util.IdentityHashMap;
import java.util.Map;
final class CaveWaterSupportPlan {
private final IdentityHashMap<MatterCavern, WaterCandidateGroup> groups = new IdentityHashMap<>();
final class CaveFluidSupportPlan {
private final IdentityHashMap<MatterCavern, FluidCandidateGroup> groups = new IdentityHashMap<>();
void add(int localX, int y, int localZ, MatterCavern water, MatterCavern air) {
WaterCandidateGroup group = groups.computeIfAbsent(water, key -> new WaterCandidateGroup(water, air));
void add(int localX, int y, int localZ, MatterCavern fluid, MatterCavern air) {
FluidCandidateGroup group = groups.computeIfAbsent(fluid, key -> new FluidCandidateGroup(fluid, air));
int columnIndex = PowerOfTwoCoordinates.packLocal16(localX, localZ);
group.positions.set((y << 8) | columnIndex);
}
@@ -43,15 +43,15 @@ final class CaveWaterSupportPlan {
return;
}
for (Map.Entry<MatterCavern, WaterCandidateGroup> entry : groups.entrySet()) {
WaterCandidateGroup group = entry.getValue();
for (Map.Entry<MatterCavern, FluidCandidateGroup> entry : groups.entrySet()) {
FluidCandidateGroup group = entry.getValue();
for (int position = group.positions.nextSetBit(0); position >= 0; position = group.positions.nextSetBit(position + 1)) {
int y = position >>> 8;
int columnIndex = position & 255;
int localX = PowerOfTwoCoordinates.unpackLocal16X(columnIndex);
int localZ = columnIndex & 15;
MatterCavern current = getCavern(chunk, localX, y, localZ);
if (current != group.water || hasCupSupport(chunk, localX, y, localZ)) {
if (current != group.fluid || hasCupSupport(chunk, localX, y, localZ)) {
continue;
}
@@ -107,13 +107,13 @@ final class CaveWaterSupportPlan {
return cavernSlice == null ? null : cavernSlice.get(localX, y & 15, localZ);
}
private static final class WaterCandidateGroup {
private final MatterCavern water;
private static final class FluidCandidateGroup {
private final MatterCavern fluid;
private final MatterCavern air;
private final BitSet positions = new BitSet();
private WaterCandidateGroup(MatterCavern water, MatterCavern air) {
this.water = water;
private FluidCandidateGroup(MatterCavern fluid, MatterCavern air) {
this.fluid = fluid;
this.air = air;
}
}
@@ -41,7 +41,7 @@ import java.util.List;
public class IrisCaveCarver3D {
private static final byte LIQUID_AIR = 0;
private static final byte LIQUID_WATER = 1;
private static final byte LIQUID_FLUID = 1;
private static final byte LIQUID_LAVA = 2;
private static final byte LIQUID_FORCED_AIR = 3;
private static final int ADAPTIVE_MIN_PLANE_COLUMNS = 16;
@@ -60,7 +60,7 @@ public class IrisCaveCarver3D {
private final CaveFieldModuleState[] modules;
private final double inverseNormalization;
private final MatterCavern carveAir;
private final MatterCavern carveWater;
private final MatterCavern carveFluid;
private final MatterCavern carveLava;
private final MatterCavern carveForcedAir;
private final double normalizationFactor;
@@ -72,9 +72,9 @@ public class IrisCaveCarver3D {
private final boolean hasWarp;
private final boolean hasModules;
private final int warpResolution;
private final boolean allowWater;
private final boolean waterRequiresFloor;
private final int waterMinDepthBelowSurface;
private final boolean allowFluid;
private final boolean fluidRequiresFloor;
private final int fluidMinDepthBelowSurface;
private final int fluidHeight;
private final int aquiferCeilingY;
private final ThreadLocal<CaveCarveScratch> scratchCache = ThreadLocal.withInitial(CaveCarveScratch::new);
@@ -84,7 +84,7 @@ public class IrisCaveCarver3D {
this.data = engine.getData();
this.profile = profile;
this.carveAir = new MatterCavern(true, "", LIQUID_AIR);
this.carveWater = new MatterCavern(true, "", LIQUID_WATER);
this.carveFluid = new MatterCavern(true, "", LIQUID_FLUID);
this.carveLava = new MatterCavern(true, "", LIQUID_LAVA);
this.carveForcedAir = new MatterCavern(true, "", LIQUID_FORCED_AIR);
List<CaveFieldModuleState> moduleStates = new ArrayList<>();
@@ -100,9 +100,9 @@ public class IrisCaveCarver3D {
this.warpStrength = profile.getWarpStrength();
this.hasWarp = this.warpStrength > 0D;
this.warpResolution = 2;
this.allowWater = profile.isAllowWater();
this.waterRequiresFloor = profile.isWaterRequiresFloor();
this.waterMinDepthBelowSurface = Math.max(0, profile.getWaterMinDepthBelowSurface());
this.allowFluid = profile.isAllowFluid();
this.fluidRequiresFloor = profile.isFluidRequiresFloor();
this.fluidMinDepthBelowSurface = Math.max(0, profile.getFluidMinDepthBelowSurface());
this.fluidHeight = engine.getDimension().getFluidHeight();
this.aquiferCeilingY = engine.getHeight() - 1;
@@ -181,10 +181,10 @@ public class IrisCaveCarver3D {
int[] precomputedSurfaceHeights,
IrisRange overrideVerticalRange
) {
CaveWaterSupportPlan waterSupportPlan = new CaveWaterSupportPlan();
CaveFluidSupportPlan fluidSupportPlan = new CaveFluidSupportPlan();
int carved = carve(writer, chunkX, chunkZ, columnWeights, minWeight, thresholdPenalty,
worldYRange, precomputedSurfaceHeights, overrideVerticalRange, waterSupportPlan);
waterSupportPlan.resolve(writer.acquireChunk(chunkX, chunkZ));
worldYRange, precomputedSurfaceHeights, overrideVerticalRange, fluidSupportPlan);
fluidSupportPlan.resolve(writer.acquireChunk(chunkX, chunkZ));
return carved;
}
@@ -198,7 +198,7 @@ public class IrisCaveCarver3D {
IrisRange worldYRange,
int[] precomputedSurfaceHeights,
IrisRange overrideVerticalRange,
CaveWaterSupportPlan waterSupportPlan
CaveFluidSupportPlan fluidSupportPlan
) {
PrecisionStopwatch applyStopwatch = PrecisionStopwatch.start();
try {
@@ -246,7 +246,7 @@ public class IrisCaveCarver3D {
int x0 = PowerOfTwoCoordinates.chunkToBlock(chunkX);
int z0 = PowerOfTwoCoordinates.chunkToBlock(chunkZ);
int[] columnMaxY = scratch.columnMaxY;
int[] waterMaxY = scratch.waterMaxY;
int[] fluidMaxY = scratch.fluidMaxY;
int[] surfaceBreakFloorY = scratch.surfaceBreakFloorY;
boolean[] surfaceBreakColumn = scratch.surfaceBreakColumn;
double[] columnThreshold = scratch.columnThreshold;
@@ -291,8 +291,8 @@ public class IrisCaveCarver3D {
: clearanceTopY;
columnMaxY[index] = columnTopY;
waterMaxY[index] = allowWater
? Math.min(fluidHeight, columnSurfaceY - waterMinDepthBelowSurface)
fluidMaxY[index] = allowFluid
? Math.min(fluidHeight, columnSurfaceY - fluidMinDepthBelowSurface)
: Integer.MIN_VALUE;
surfaceBreakFloorY[index] = Math.max(minY, columnSurfaceY - surfaceBreakDepth);
surfaceBreakColumn[index] = breakColumn;
@@ -316,14 +316,14 @@ public class IrisCaveCarver3D {
adaptiveThresholdMargin,
surfaceBreakThresholdBoost,
columnMaxY,
waterMaxY,
fluidMaxY,
surfaceBreakFloorY,
surfaceBreakColumn,
columnThreshold,
clampedWeights,
verticalEdgeFade,
matterByY,
waterRequiresFloor ? waterSupportPlan : null,
fluidRequiresFloor ? fluidSupportPlan : null,
resolvedMinWeight,
resolvedThresholdPenalty,
0D,
@@ -338,14 +338,14 @@ public class IrisCaveCarver3D {
maxY,
surfaceBreakThresholdBoost,
columnMaxY,
waterMaxY,
fluidMaxY,
surfaceBreakFloorY,
surfaceBreakColumn,
columnThreshold,
clampedWeights,
verticalEdgeFade,
matterByY,
waterRequiresFloor ? waterSupportPlan : null,
fluidRequiresFloor ? fluidSupportPlan : null,
resolvedMinWeight,
resolvedThresholdPenalty,
0D,
@@ -363,14 +363,14 @@ public class IrisCaveCarver3D {
latticeStep,
surfaceBreakThresholdBoost,
columnMaxY,
waterMaxY,
fluidMaxY,
surfaceBreakFloorY,
surfaceBreakColumn,
columnThreshold,
clampedWeights,
verticalEdgeFade,
matterByY,
waterRequiresFloor ? waterSupportPlan : null,
fluidRequiresFloor ? fluidSupportPlan : null,
resolvedMinWeight,
resolvedThresholdPenalty,
0D,
@@ -386,14 +386,14 @@ public class IrisCaveCarver3D {
sampleStep,
surfaceBreakThresholdBoost,
columnMaxY,
waterMaxY,
fluidMaxY,
surfaceBreakFloorY,
surfaceBreakColumn,
columnThreshold,
clampedWeights,
verticalEdgeFade,
matterByY,
waterRequiresFloor ? waterSupportPlan : null,
fluidRequiresFloor ? fluidSupportPlan : null,
resolvedMinWeight,
resolvedThresholdPenalty,
0D,
@@ -416,14 +416,14 @@ public class IrisCaveCarver3D {
int maxY,
double surfaceBreakThresholdBoost,
int[] columnMaxY,
int[] waterMaxY,
int[] fluidMaxY,
int[] surfaceBreakFloorY,
boolean[] surfaceBreakColumn,
double[] columnThreshold,
double[] clampedWeights,
double[] verticalEdgeFade,
MatterCavern[] matterByY,
CaveWaterSupportPlan waterSupportPlan,
CaveFluidSupportPlan fluidSupportPlan,
double minWeight,
double thresholdPenalty,
double thresholdBoost,
@@ -506,8 +506,8 @@ public class IrisCaveCarver3D {
double localThreshold = planeThresholdLimit[planeIndex] * inverseNormalization;
MatterCavern matter = resolveMatter(verticalMatter, x0 + localX, y, z0 + localZ,
columnIndex, waterMaxY, localThreshold);
writeCavern(cavernSlice, localX, y, localZ, matter, waterSupportPlan);
columnIndex, fluidMaxY, localThreshold);
writeCavern(cavernSlice, localX, y, localZ, matter, fluidSupportPlan);
carved++;
}
continue;
@@ -523,8 +523,8 @@ public class IrisCaveCarver3D {
int localZ = columnIndex & 15;
double localThreshold = planeThresholdLimit[planeIndex] * inverseNormalization;
MatterCavern matter = resolveMatter(verticalMatter, x0 + localX, y, z0 + localZ,
columnIndex, waterMaxY, localThreshold);
writeCavern(cavernSlice, localX, y, localZ, matter, waterSupportPlan);
columnIndex, fluidMaxY, localThreshold);
writeCavern(cavernSlice, localX, y, localZ, matter, fluidSupportPlan);
carved++;
}
}
@@ -543,14 +543,14 @@ public class IrisCaveCarver3D {
double adaptiveThresholdMargin,
double surfaceBreakThresholdBoost,
int[] columnMaxY,
int[] waterMaxY,
int[] fluidMaxY,
int[] surfaceBreakFloorY,
boolean[] surfaceBreakColumn,
double[] columnThreshold,
double[] clampedWeights,
double[] verticalEdgeFade,
MatterCavern[] matterByY,
CaveWaterSupportPlan waterSupportPlan,
CaveFluidSupportPlan fluidSupportPlan,
double minWeight,
double thresholdPenalty,
double thresholdBoost,
@@ -650,8 +650,8 @@ public class IrisCaveCarver3D {
double localThreshold = planeThresholdLimit[planeIndex] * inverseNormalization;
MatterCavern matter = resolveMatter(verticalMatter, x0 + localX, y, z0 + localZ,
columnIndex, waterMaxY, localThreshold);
writeCavern(cavernSlice, localX, y, localZ, matter, waterSupportPlan);
columnIndex, fluidMaxY, localThreshold);
writeCavern(cavernSlice, localX, y, localZ, matter, fluidSupportPlan);
carved++;
}
continue;
@@ -667,8 +667,8 @@ public class IrisCaveCarver3D {
int localZ = columnIndex & 15;
double localThreshold = planeThresholdLimit[planeIndex] * inverseNormalization;
MatterCavern matter = resolveMatter(verticalMatter, x0 + localX, y, z0 + localZ,
columnIndex, waterMaxY, localThreshold);
writeCavern(cavernSlice, localX, y, localZ, matter, waterSupportPlan);
columnIndex, fluidMaxY, localThreshold);
writeCavern(cavernSlice, localX, y, localZ, matter, fluidSupportPlan);
carved++;
}
}
@@ -698,14 +698,14 @@ public class IrisCaveCarver3D {
int latticeStep,
double surfaceBreakThresholdBoost,
int[] columnMaxY,
int[] waterMaxY,
int[] fluidMaxY,
int[] surfaceBreakFloorY,
boolean[] surfaceBreakColumn,
double[] columnThreshold,
double[] clampedWeights,
double[] verticalEdgeFade,
MatterCavern[] matterByY,
CaveWaterSupportPlan waterSupportPlan,
CaveFluidSupportPlan fluidSupportPlan,
double minWeight,
double thresholdPenalty,
double thresholdBoost,
@@ -814,16 +814,16 @@ public class IrisCaveCarver3D {
int worldX = x0 + localX;
int worldZ = z0 + localZ;
MatterCavern matter = resolveMatter(verticalMatter, worldX, yy, worldZ,
index, waterMaxY, localThreshold);
index, fluidMaxY, localThreshold);
if (skipExistingCarved) {
if (cavernSlice.get(localX, localY, localZ) == null) {
writeCavern(cavernSlice, localX, yy, localZ, matter, waterSupportPlan);
writeCavern(cavernSlice, localX, yy, localZ, matter, fluidSupportPlan);
carved++;
}
continue;
}
writeCavern(cavernSlice, localX, yy, localZ, matter, waterSupportPlan);
writeCavern(cavernSlice, localX, yy, localZ, matter, fluidSupportPlan);
carved++;
}
}
@@ -843,14 +843,14 @@ public class IrisCaveCarver3D {
int sampleStep,
double surfaceBreakThresholdBoost,
int[] columnMaxY,
int[] waterMaxY,
int[] fluidMaxY,
int[] surfaceBreakFloorY,
boolean[] surfaceBreakColumn,
double[] columnThreshold,
double[] clampedWeights,
double[] verticalEdgeFade,
MatterCavern[] matterByY,
CaveWaterSupportPlan waterSupportPlan,
CaveFluidSupportPlan fluidSupportPlan,
double minWeight,
double thresholdPenalty,
double thresholdBoost,
@@ -893,18 +893,18 @@ public class IrisCaveCarver3D {
for (int yy = y; yy <= carveMaxY; yy++) {
MatterCavern verticalMatter = matterByY[yy - minY];
MatterCavern matter = resolveMatter(verticalMatter, x, yy, z,
index, waterMaxY, localThreshold);
index, fluidMaxY, localThreshold);
MatterSlice<MatterCavern> cavernSlice = resolveCavernSlice(scratch, chunk, PowerOfTwoCoordinates.floorDivPow2(yy, 4));
int localY = yy & 15;
if (skipExistingCarved) {
if (cavernSlice.get(lx, localY, lz) == null) {
writeCavern(cavernSlice, lx, yy, lz, matter, waterSupportPlan);
writeCavern(cavernSlice, lx, yy, lz, matter, fluidSupportPlan);
carved++;
}
continue;
}
writeCavern(cavernSlice, lx, yy, lz, matter, waterSupportPlan);
writeCavern(cavernSlice, lx, yy, lz, matter, fluidSupportPlan);
carved++;
}
}
@@ -2171,11 +2171,11 @@ public class IrisCaveCarver3D {
}
private MatterCavern resolveMatter(MatterCavern verticalMatter, int x, int y, int z,
int columnIndex, int[] waterMaxY, double localThreshold) {
int columnIndex, int[] fluidMaxY, double localThreshold) {
if (verticalMatter != carveLava
&& y <= waterMaxY[columnIndex]
&& y <= fluidMaxY[columnIndex]
&& isAquiferCandidate(x, y, z, localThreshold)) {
return carveWater;
return carveFluid;
}
return verticalMatter;
}
@@ -2186,7 +2186,7 @@ public class IrisCaveCarver3D {
if (detailDensity.noiseFastSigned3D(x, y * 0.5D, z) <= cutoff) {
return false;
}
return !waterRequiresFloor || hasAquiferCupSupport(x, y, z, localThreshold);
return !fluidRequiresFloor || hasAquiferCupSupport(x, y, z, localThreshold);
}
private boolean hasAquiferCupSupport(int x, int y, int z, double threshold) {
@@ -2224,10 +2224,10 @@ public class IrisCaveCarver3D {
}
private void writeCavern(MatterSlice<MatterCavern> cavernSlice, int localX, int y, int localZ,
MatterCavern matter, CaveWaterSupportPlan waterSupportPlan) {
MatterCavern matter, CaveFluidSupportPlan fluidSupportPlan) {
cavernSlice.set(localX, y & 15, localZ, matter);
if (waterSupportPlan != null && matter == carveWater) {
waterSupportPlan.add(localX, y, localZ, carveWater, carveAir);
if (fluidSupportPlan != null && matter == carveFluid) {
fluidSupportPlan.add(localX, y, localZ, carveFluid, carveAir);
}
}
@@ -91,16 +91,16 @@ public class MantleCarvingComponent extends IrisMantleComponent {
PrecisionStopwatch resolveStopwatch = PrecisionStopwatch.start();
List<WeightedProfile> weightedProfiles = resolveWeightedProfiles(x, z, complex, resolverState);
getEngineMantle().getEngine().getMetrics().getCarveResolve().put(resolveStopwatch.getMilliseconds());
CaveWaterSupportPlan waterSupportPlan = new CaveWaterSupportPlan();
CaveFluidSupportPlan fluidSupportPlan = new CaveFluidSupportPlan();
for (WeightedProfile weightedProfile : weightedProfiles) {
carveProfile(weightedProfile, writer, x, z, chunkSurfaceHeights, waterSupportPlan);
carveProfile(weightedProfile, writer, x, z, chunkSurfaceHeights, fluidSupportPlan);
}
UpperDimensionContext upperCtx = getEngineMantle().getEngine().getUpperContext();
if (upperCtx != null && getDimension().isUpperDimensionCarving()) {
carveUpperTerrain(upperCtx, weightedProfiles, writer, x, z, chunkSurfaceHeights, waterSupportPlan);
carveUpperTerrain(upperCtx, weightedProfiles, writer, x, z, chunkSurfaceHeights, fluidSupportPlan);
}
waterSupportPlan.resolve(writer.acquireChunk(x, z));
fluidSupportPlan.resolve(writer.acquireChunk(x, z));
if (!weightedProfiles.isEmpty()) {
CarveOrphanSweep.sweepChunk(
@@ -122,15 +122,15 @@ public class MantleCarvingComponent extends IrisMantleComponent {
@ChunkCoordinates
private void carveProfile(WeightedProfile weightedProfile, MantleWriter writer, int cx, int cz,
int[] chunkSurfaceHeights, CaveWaterSupportPlan waterSupportPlan) {
int[] chunkSurfaceHeights, CaveFluidSupportPlan fluidSupportPlan) {
IrisCaveCarver3D carver = getCarver(weightedProfile.profile);
carver.carve(writer, cx, cz, weightedProfile.columnWeights, MIN_WEIGHT, THRESHOLD_PENALTY,
weightedProfile.worldYRange, chunkSurfaceHeights, null, waterSupportPlan);
weightedProfile.worldYRange, chunkSurfaceHeights, null, fluidSupportPlan);
}
private void carveUpperTerrain(UpperDimensionContext upperCtx, List<WeightedProfile> normalProfiles,
MantleWriter writer, int cx, int cz, int[] lowerSurfaceHeights,
CaveWaterSupportPlan waterSupportPlan) {
CaveFluidSupportPlan fluidSupportPlan) {
int chunkHeight = getEngineMantle().getEngine().getHeight();
int worldMinHeight = getEngineMantle().getEngine().getWorld().minHeight();
int gap = getDimension().getUpperDimensionGap();
@@ -178,7 +178,7 @@ public class MantleCarvingComponent extends IrisMantleComponent {
}
IrisCaveCarver3D carver = getCarver(weightedProfile.profile);
carver.carve(writer, cx, cz, weightedProfile.columnWeights, MIN_WEIGHT, THRESHOLD_PENALTY,
constrainedRange, ceilingSurfaceHeights, fullVerticalRange, waterSupportPlan);
constrainedRange, ceilingSurfaceHeights, fullVerticalRange, fluidSupportPlan);
}
}
@@ -52,6 +52,7 @@ import java.util.HashMap;
import java.util.Map;
public class IrisCarveModifier extends EngineAssignedModifier<PlatformBlockState> {
private static final byte LIQUID_FLUID = 1;
private static final ThreadLocal<CarveScratch> SCRATCH = ThreadLocal.withInitial(CarveScratch::new);
private static final int CAVE_BIOME_BLEND_RADIUS = 3;
private static final int CAVE_BIOME_BLEND_CENTER_WEIGHT = 4;
@@ -141,9 +142,9 @@ public class IrisCarveModifier extends EngineAssignedModifier<PlatformBlockState
}
if (explicitCarveIntent) {
// Only a water cavern consumes the fluid sample, and on the maintenance path that
// Only a fluid cavern consumes the fluid sample, and on the maintenance path that
// sample is a full procedural stream evaluation, so never take it per voxel.
PlatformBlockState fluid = c.isWater() ? context.getFluid().get(rx, rz) : null;
PlatformBlockState fluid = isFluidIntent(c) ? context.getFluid().get(rx, rz) : null;
output.setRaw(rx, yy, rz, resolveExplicitCarveState(c, fluid, LAVA, AIR));
} else if (usesDefaultLava(caveLavaHeight, yy)) {
output.setRaw(rx, yy, rz, LAVA);
@@ -203,7 +204,11 @@ public class IrisCarveModifier extends EngineAssignedModifier<PlatformBlockState
}
static boolean hasExplicitCarveIntent(MatterCavern cavern) {
return cavern != null && (cavern.isWater() || cavern.isLava() || cavern.getLiquid() == 3);
return cavern != null && (isFluidIntent(cavern) || cavern.isLava() || cavern.getLiquid() == 3);
}
static boolean isFluidIntent(MatterCavern cavern) {
return cavern != null && cavern.getLiquid() == LIQUID_FLUID;
}
static boolean shouldPreserveExistingFluid(MatterCavern cavern, PlatformBlockState current) {
@@ -219,7 +224,7 @@ public class IrisCarveModifier extends EngineAssignedModifier<PlatformBlockState
if (cavern == null) {
return null;
}
if (cavern.isWater()) {
if (isFluidIntent(cavern)) {
return fluid;
}
if (cavern.isLava()) {
@@ -132,16 +132,16 @@ public class IrisCaveProfile {
@Desc("Maximum random column retries while searching a valid cave object anchor in the chunk.")
private int anchorSearchAttempts = 6;
@Desc("Allow cave water placement below fluid level.")
private boolean allowWater = true;
@Desc("Allow cave fluid placement from the dimension fluid palette below fluid level.")
private boolean allowFluid = true;
@MinNumber(0)
@MaxNumber(64)
@Desc("Minimum depth below terrain surface required before cave water may be placed.")
private int waterMinDepthBelowSurface = 12;
@Desc("Minimum depth below terrain surface required before cave fluid may be placed.")
private int fluidMinDepthBelowSurface = 12;
@Desc("Require solid floor support below cave water to reduce cascading cave waterfalls.")
private boolean waterRequiresFloor = true;
@Desc("Require solid floor support below cave fluid to reduce unsupported fluid flows.")
private boolean fluidRequiresFloor = true;
@Desc("Allow cave lava placement based on lava height.")
private boolean allowLava = true;
@@ -59,7 +59,10 @@ import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
@@ -260,7 +263,7 @@ public class IrisDimension extends IrisRegistrant {
private double rockZoom = 5;
@Desc("The palette of blocks for 'stone'")
private IrisMaterialPalette rockPalette = new IrisMaterialPalette().qclear().qadd("stone");
@Desc("The palette of blocks for 'water'")
@Desc("The dimension fluid block palette used for ocean columns and cave aquifers.")
private IrisMaterialPalette fluidPalette = new IrisMaterialPalette().qclear().qadd("water");
@Desc("Prevent cartographers to generate explorer maps (Iris worlds only)\nONLY TOUCH IF YOUR SERVER CRASHES WHILE GENERATING EXPLORER MAPS")
private boolean disableExplorerMaps = false;
@@ -484,14 +487,70 @@ public class IrisDimension extends IrisRegistrant {
public KList<IrisBiome> getReachableBiomes(DataProvider g) {
KMap<String, IrisBiome> biomes = new KMap<>();
if (g == null) {
return biomes.v();
}
for (IrisRegion region : getAllRegions(g)) {
IrisData data = g.getData();
if (data == null || data.getRegionLoader() == null || data.getBiomeLoader() == null) {
return biomes.v();
}
Deque<String> pending = new ArrayDeque<>();
KList<String> regionKeys = getRegions();
if (regionKeys != null) {
for (String regionKey : regionKeys) {
IrisRegion region = data.getRegionLoader().load(regionKey);
if (region == null) {
continue;
}
for (IrisBiome biome : region.getAllBiomes(g)) {
if (biome != null) {
biomes.put(biome.getLoadKey(), biome);
addReachableBiomeKeys(pending, region.getAllBiomeIds());
}
}
KList<IrisDimensionCarvingEntry> carvingEntries = getCarving();
if (carvingEntries != null) {
for (IrisDimensionCarvingEntry entry : carvingEntries) {
if (entry != null && entry.isEnabled()) {
addReachableBiomeKey(pending, entry.getBiome());
}
}
}
Set<String> visited = new HashSet<>();
Map<String, IrisDimensionCarvingEntry> carvingEntryIndex = getCarvingEntryIndex();
while (!pending.isEmpty()) {
String biomeKey = pending.removeFirst();
if (!visited.add(biomeKey)) {
continue;
}
IrisBiome biome = data.getBiomeLoader().load(biomeKey);
if (biome == null) {
continue;
}
String loadKey = biome.getLoadKey();
if (loadKey == null || loadKey.isBlank() || biomes.containsKey(loadKey)) {
continue;
}
visited.add(loadKey);
biomes.put(loadKey, biome);
addReachableBiomeKeys(pending, biome.getChildren());
addReachableBiomeKey(pending, biome.getCarvingBiome());
KList<IrisFloatingChildBiomes> floatingChildren = biome.getFloatingChildBiomes();
if (floatingChildren == null) {
continue;
}
for (IrisFloatingChildBiomes floatingChild : floatingChildren) {
if (floatingChild != null) {
addReachableBiomeKey(pending, floatingChild.getBiome());
String carvingReference = floatingChild.getCarving();
IrisDimensionCarvingEntry carvingEntry = carvingEntryIndex.get(carvingReference);
addReachableBiomeKey(pending,
carvingEntry == null ? carvingReference : carvingEntry.getBiome());
}
}
}
@@ -499,6 +558,21 @@ public class IrisDimension extends IrisRegistrant {
return biomes.v();
}
private void addReachableBiomeKeys(Deque<String> pending, Iterable<String> biomeKeys) {
if (biomeKeys == null) {
return;
}
for (String biomeKey : biomeKeys) {
addReachableBiomeKey(pending, biomeKey);
}
}
private void addReachableBiomeKey(Deque<String> pending, String biomeKey) {
if (biomeKey != null && !biomeKey.isBlank()) {
pending.addLast(biomeKey);
}
}
public KList<IrisBiome> getAllAnyBiomes() {
KList<IrisBiome> r = new KList<>();
@@ -35,7 +35,7 @@ import java.util.Objects;
@Accessors(chain = true)
@NoArgsConstructor
@AllArgsConstructor
@Desc("Controls native vanilla, mod, and ingested datapack structure generation for this dimension (set as the dimension's 'importedStructures' field). Every registered structure generates through its native placement unless its key is explicitly listed in 'disabled' or a viable dimension-level Iris placement explicitly replaces its source. Family matching uses namespace, slash, or underscore boundaries, so 'minecraft:village' covers every village variant without matching unrelated names. Run '/iris structure list <dimension>' to dump every valid key. Only affects newly generated chunks and is separate from Iris structure placements.")
@Desc("Controls native vanilla, mod, and ingested datapack structure generation for this dimension (set as the dimension's 'importedStructures' field). Every registered structure generates through its native placement unless its key matches 'disabled', equals a key in 'disabledExact', or a viable dimension-level Iris placement explicitly replaces its source. Family matching uses namespace, slash, or underscore boundaries, while exact matching compares normalized complete keys only. Run '/iris structure list <dimension>' to dump every valid key. Only affects newly generated chunks and is separate from Iris structure placements.")
@Data
public class IrisImportedStructureControl {
@ArrayType(type = String.class, min = 1)
@@ -43,6 +43,11 @@ public class IrisImportedStructureControl {
@Desc("Structure keys to deny explicitly, e.g. 'minecraft:stronghold'. A namespace:path prefix also matches, so 'minecraft:village' disables every village variant and 'minecraft:ruined_portal' disables every ruined portal. Every key not matched here remains enabled.")
private KList<String> disabled = new KList<>();
@ArrayType(type = String.class, min = 1)
@RegistryListVanillaStructure(prefixes = false)
@Desc("Exact structure keys to deny after trimming and case normalization. Unlike 'disabled', entries never match key families, so 'minecraft:ruined_portal' does not disable 'minecraft:ruined_portal_nether'.")
private KList<String> disabledExact = new KList<>();
@MinNumber(-512)
@MaxNumber(512)
@Desc("Vertical block offset applied only to UNDERGROUND vanilla structures (the UNDERGROUND_STRUCTURES, UNDERGROUND_DECORATION, and STRONGHOLDS generation steps: strongholds, trial chambers, mineshafts, ancient cities, etc.). Surface structures (villages, outposts, etc.) are never shifted. Use a negative value to push deep structures lower when your dimension's sea/terrain level differs from vanilla's (e.g. -64 if you lowered the fluid height to 0). 0 = no shift.")
@@ -51,12 +56,18 @@ public class IrisImportedStructureControl {
@Desc("Controls whether ingested datapacks may replace minecraft-namespaced structure definitions, sets, pools, and templates. When false, those overrides are stripped from installed datapack copies so vanilla definitions stay intact. Non-minecraft structures from datapacks and mods remain governed by disabled because namespace alone cannot identify their origin. Resolved globally across loaded packs: if any dimension sets this false, minecraft-namespaced overrides are stripped from every installed datapack copy.")
private boolean datapackOverrides = true;
@ArrayType(type = IrisStructureSetFrequencyOverride.class, min = 1)
@Desc("Exact registered structure-set frequency overrides for native generation. The last entry for a normalized structure-set key wins. These do not convert native structures into Iris placements and affect only newly generated chunks.")
private KList<IrisStructureSetFrequencyOverride> frequencyOverrides = new KList<>();
@ArrayType(type = IrisVanillaStructureAdjustment.class, min = 1)
@Desc("Per-structure adjustments applied to vanilla, mod, and datapack structures that still generate natively. Vertical shifts from every matching entry stack. A matching preserveSourceY option disables Iris burial repositioning for that structure. The last matching entry with stilt settings controls foundation columns, and likewise for terrain and yBand settings. A structure suppressed by an Iris placement is unaffected.")
private KList<IrisVanillaStructureAdjustment> adjustments = new KList<>();
public boolean shouldGenerate(String key) {
return key != null && !key.isBlank() && !matches(disabled, key);
return key != null && !key.isBlank()
&& !matches(disabled, key)
&& !matchesExact(disabledExact, key);
}
public IrisNativeStructureDecision resolve(String key, boolean undergroundStep) {
@@ -86,11 +97,32 @@ public class IrisImportedStructureControl {
generationStatus(key), y, yBand, preserveSourceY, stilt, terrain);
}
public double frequencyMultiplier(String structureSetKey) {
KList<IrisStructureSetFrequencyOverride> activeOverrides = Objects.requireNonNull(
frequencyOverrides, "importedStructures.frequencyOverrides must not be null");
String normalizedKey = normalizeKey(structureSetKey);
if (normalizedKey.isEmpty()) {
return 1D;
}
double multiplier = 1D;
for (IrisStructureSetFrequencyOverride override : activeOverrides) {
if (override != null && normalizeKey(override.getStructureSet()).equals(normalizedKey)) {
multiplier = override.getMultiplier();
}
}
return multiplier;
}
public boolean hasFrequencyOverrides() {
return !Objects.requireNonNull(
frequencyOverrides, "importedStructures.frequencyOverrides must not be null").isEmpty();
}
private NativeStructureGenerationStatus generationStatus(String key) {
if (key == null || key.isBlank()) {
return NativeStructureGenerationStatus.INVALID_REGISTRY_KEY;
}
if (matches(disabled, key)) {
if (matches(disabled, key) || matchesExact(disabledExact, key)) {
return NativeStructureGenerationStatus.DISABLED_BY_PACK;
}
return NativeStructureGenerationStatus.GENERATE_NATIVE;
@@ -110,6 +142,19 @@ public class IrisImportedStructureControl {
return false;
}
private boolean matchesExact(KList<String> list, String key) {
KList<String> activeList = Objects.requireNonNull(
list, "importedStructures.disabledExact must not be null");
String normalizedKey = normalizeKey(key);
for (String entry : activeList) {
String normalizedEntry = normalizeKey(entry);
if (!normalizedEntry.isEmpty() && normalizedEntry.equals(normalizedKey)) {
return true;
}
}
return false;
}
static boolean matchesKey(String pattern, String key) {
if (pattern == null || key == null) {
return false;
@@ -129,4 +174,8 @@ public class IrisImportedStructureControl {
char boundary = normalizedKey.charAt(normalizedPattern.length());
return boundary == '/' || boundary == '_';
}
private static String normalizeKey(String key) {
return key == null ? "" : key.trim().toLowerCase(Locale.ROOT);
}
}
@@ -0,0 +1,44 @@
/*
* 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.engine.object;
import art.arcane.iris.engine.object.annotations.Desc;
import art.arcane.iris.engine.object.annotations.MaxNumber;
import art.arcane.iris.engine.object.annotations.MinNumber;
import art.arcane.iris.engine.object.annotations.RegistryListVanillaStructureSet;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
@Accessors(chain = true)
@NoArgsConstructor
@AllArgsConstructor
@Desc("An exact registered structure-set frequency multiplier. Iris retains the set's structures, weights, biome eligibility, placement algorithm, salt, exclusion zone, starts, processors, mobs, loot, and locate behavior while scaling only its native placement density.")
@Data
public class IrisStructureSetFrequencyOverride {
@RegistryListVanillaStructureSet
@Desc("Exact registered structure-set key, for example 'minecraft:nether_complexes'. Structure keys such as 'minecraft:fortress' are not valid here.")
private String structureSet = "";
@MinNumber(0.01)
@MaxNumber(16)
@Desc("Requested placement-density multiplier. Random-spread sets scale their placement frequency first and then their integer chunk spacing; integer spacing and separation constraints can make the realized increase slightly lower or higher. The default 1 leaves the registered placement unchanged.")
private double multiplier = 1D;
}
@@ -0,0 +1,232 @@
package art.arcane.iris.core;
import org.bukkit.NamespacedKey;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
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.assertThrows;
public class ExactWorldSlotPathPolicyTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void resolvesIrisAndExactVanillaSlots() throws Exception {
Path levelRoot = temporaryFolder.newFolder("world").toPath();
Path canonicalRoot = levelRoot.toRealPath();
List<SlotExpectation> expectations = List.of(
new SlotExpectation(
new NamespacedKey("iris", "underworld"),
ExactWorldSlotPathPolicy.SlotKind.IRIS_MANAGED,
"dimensions/iris/underworld"
),
new SlotExpectation(
NamespacedKey.minecraft("overworld"),
ExactWorldSlotPathPolicy.SlotKind.VANILLA_OVERWORLD,
"dimensions/minecraft/overworld"
),
new SlotExpectation(
NamespacedKey.minecraft("the_nether"),
ExactWorldSlotPathPolicy.SlotKind.VANILLA_NETHER,
"dimensions/minecraft/the_nether"
),
new SlotExpectation(
NamespacedKey.minecraft("the_end"),
ExactWorldSlotPathPolicy.SlotKind.VANILLA_END,
"dimensions/minecraft/the_end"
)
);
for (SlotExpectation expectation : expectations) {
ExactWorldSlotPathPolicy.Target target = ExactWorldSlotPathPolicy.resolve(
levelRoot,
expectation.worldKey()
);
assertEquals(expectation.worldKey(), target.worldKey());
assertEquals(expectation.slotKind(), target.slotKind());
assertEquals(canonicalRoot, target.levelRoot());
assertEquals(canonicalRoot.resolve(expectation.relativePath()), target.worldDirectory());
}
}
@Test
public void acceptsAnExistingExactDirectorySlot() throws Exception {
Path levelRoot = temporaryFolder.newFolder("existing-world").toPath();
Path worldDirectory = Files.createDirectories(levelRoot.resolve("dimensions/minecraft/the_nether"));
ExactWorldSlotPathPolicy.Target target = ExactWorldSlotPathPolicy.resolve(
levelRoot,
NamespacedKey.minecraft("the_nether")
);
assertEquals(worldDirectory.toRealPath(), target.worldDirectory());
}
@Test
public void rejectsForeignNestedAndUnsupportedKeys() throws Exception {
Path levelRoot = temporaryFolder.newFolder("key-policy").toPath();
ExactWorldSlotPathPolicy.Rejection foreign = assertThrows(
ExactWorldSlotPathPolicy.Rejection.class,
() -> ExactWorldSlotPathPolicy.resolve(levelRoot, new NamespacedKey("foreign", "world"))
);
ExactWorldSlotPathPolicy.Rejection nestedIris = assertThrows(
ExactWorldSlotPathPolicy.Rejection.class,
() -> ExactWorldSlotPathPolicy.resolve(levelRoot, new NamespacedKey("iris", "nested/world"))
);
ExactWorldSlotPathPolicy.Rejection unsupportedMinecraft = assertThrows(
ExactWorldSlotPathPolicy.Rejection.class,
() -> ExactWorldSlotPathPolicy.resolve(levelRoot, NamespacedKey.minecraft("custom"))
);
assertEquals(ExactWorldSlotPathPolicy.RejectionReason.FOREIGN_NAMESPACE, foreign.reason());
assertEquals(ExactWorldSlotPathPolicy.RejectionReason.INVALID_IRIS_KEY, nestedIris.reason());
assertEquals(
ExactWorldSlotPathPolicy.RejectionReason.UNSUPPORTED_MINECRAFT_SLOT,
unsupportedMinecraft.reason()
);
}
@Test
public void validatesOnlyTheExactExpectedCandidate() throws Exception {
Path levelRoot = temporaryFolder.newFolder("candidate-policy").toPath();
NamespacedKey worldKey = NamespacedKey.minecraft("the_nether");
Path expected = levelRoot.toRealPath().resolve("dimensions/minecraft/the_nether");
ExactWorldSlotPathPolicy.Target target = ExactWorldSlotPathPolicy.validate(
levelRoot,
worldKey,
expected
);
ExactWorldSlotPathPolicy.Rejection mismatch = assertThrows(
ExactWorldSlotPathPolicy.Rejection.class,
() -> ExactWorldSlotPathPolicy.validate(
levelRoot,
worldKey,
levelRoot.resolve("dimensions/iris/the_nether")
)
);
ExactWorldSlotPathPolicy.Rejection traversal = assertThrows(
ExactWorldSlotPathPolicy.Rejection.class,
() -> ExactWorldSlotPathPolicy.validate(
levelRoot,
worldKey,
levelRoot.resolve("dimensions/minecraft/unused/../the_nether")
)
);
assertEquals(expected, target.worldDirectory());
assertEquals(ExactWorldSlotPathPolicy.RejectionReason.PATH_MISMATCH, mismatch.reason());
assertEquals(ExactWorldSlotPathPolicy.RejectionReason.PATH_TRAVERSAL, traversal.reason());
}
@Test
public void rejectsTraversalInLevelRoot() throws Exception {
Path parent = temporaryFolder.newFolder("level-traversal").toPath();
Path levelRoot = Files.createDirectory(parent.resolve("world"));
ExactWorldSlotPathPolicy.Rejection failure = assertThrows(
ExactWorldSlotPathPolicy.Rejection.class,
() -> ExactWorldSlotPathPolicy.resolve(
levelRoot.resolve("child/.."),
new NamespacedKey("iris", "underworld")
)
);
assertEquals(ExactWorldSlotPathPolicy.RejectionReason.PATH_TRAVERSAL, failure.reason());
}
@Test
public void rejectsSymbolicLinksAtEveryManagedPathComponent() throws Exception {
Path linkedLevelTarget = temporaryFolder.newFolder("linked-level-target").toPath();
Path levelLink = temporaryFolder.getRoot().toPath().resolve("linked-level");
Files.createSymbolicLink(levelLink, linkedLevelTarget);
assertSymbolicLinkRejected(levelLink, new NamespacedKey("iris", "underworld"));
Path dimensionsLevel = temporaryFolder.newFolder("linked-dimensions").toPath();
Path externalDimensions = temporaryFolder.newFolder("external-dimensions").toPath();
Files.createSymbolicLink(dimensionsLevel.resolve("dimensions"), externalDimensions);
assertSymbolicLinkRejected(dimensionsLevel, new NamespacedKey("iris", "underworld"));
Path namespaceLevel = temporaryFolder.newFolder("linked-namespace").toPath();
Path dimensions = Files.createDirectories(namespaceLevel.resolve("dimensions"));
Path externalNamespace = temporaryFolder.newFolder("external-namespace").toPath();
Files.createSymbolicLink(dimensions.resolve("minecraft"), externalNamespace);
assertSymbolicLinkRejected(namespaceLevel, NamespacedKey.minecraft("the_nether"));
Path targetLevel = temporaryFolder.newFolder("linked-target").toPath();
Path namespace = Files.createDirectories(targetLevel.resolve("dimensions/minecraft"));
Path externalTarget = temporaryFolder.newFolder("external-target").toPath();
Files.createSymbolicLink(namespace.resolve("the_nether"), externalTarget);
assertSymbolicLinkRejected(targetLevel, NamespacedKey.minecraft("the_nether"));
}
@Test
public void rejectsNonDirectoryStorageEntries() throws Exception {
Path dimensionsLevel = temporaryFolder.newFolder("file-dimensions").toPath();
Files.writeString(dimensionsLevel.resolve("dimensions"), "not a directory");
assertUnsafeEntryRejected(dimensionsLevel, new NamespacedKey("iris", "underworld"));
Path namespaceLevel = temporaryFolder.newFolder("file-namespace").toPath();
Path dimensions = Files.createDirectories(namespaceLevel.resolve("dimensions"));
Files.writeString(dimensions.resolve("iris"), "not a directory");
assertUnsafeEntryRejected(namespaceLevel, new NamespacedKey("iris", "underworld"));
Path targetLevel = temporaryFolder.newFolder("file-target").toPath();
Path namespace = Files.createDirectories(targetLevel.resolve("dimensions/iris"));
Files.writeString(namespace.resolve("underworld"), "not a directory");
assertUnsafeEntryRejected(targetLevel, new NamespacedKey("iris", "underworld"));
}
@Test
public void rejectsMissingAndFilesystemLevelRoots() throws Exception {
Path missing = temporaryFolder.getRoot().toPath().resolve("missing");
ExactWorldSlotPathPolicy.Rejection missingFailure = assertThrows(
ExactWorldSlotPathPolicy.Rejection.class,
() -> ExactWorldSlotPathPolicy.resolve(missing, new NamespacedKey("iris", "underworld"))
);
ExactWorldSlotPathPolicy.Rejection filesystemFailure = assertThrows(
ExactWorldSlotPathPolicy.Rejection.class,
() -> ExactWorldSlotPathPolicy.resolve(
missing.toAbsolutePath().getRoot(),
new NamespacedKey("iris", "underworld")
)
);
assertEquals(ExactWorldSlotPathPolicy.RejectionReason.MISSING_LEVEL_ROOT, missingFailure.reason());
assertEquals(ExactWorldSlotPathPolicy.RejectionReason.UNSAFE_ENTRY, filesystemFailure.reason());
}
private void assertSymbolicLinkRejected(Path levelRoot, NamespacedKey worldKey) {
ExactWorldSlotPathPolicy.Rejection failure = assertThrows(
ExactWorldSlotPathPolicy.Rejection.class,
() -> ExactWorldSlotPathPolicy.resolve(levelRoot, worldKey)
);
assertEquals(ExactWorldSlotPathPolicy.RejectionReason.SYMBOLIC_LINK, failure.reason());
}
private void assertUnsafeEntryRejected(Path levelRoot, NamespacedKey worldKey) {
ExactWorldSlotPathPolicy.Rejection failure = assertThrows(
ExactWorldSlotPathPolicy.Rejection.class,
() -> ExactWorldSlotPathPolicy.resolve(levelRoot, worldKey)
);
assertEquals(ExactWorldSlotPathPolicy.RejectionReason.UNSAFE_ENTRY, failure.reason());
}
private record SlotExpectation(
NamespacedKey worldKey,
ExactWorldSlotPathPolicy.SlotKind slotKind,
String relativePath
) {
}
}
@@ -13,6 +13,7 @@ import art.arcane.iris.core.loader.IrisData;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.io.IO;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.sun.net.httpserver.HttpServer;
@@ -2927,6 +2928,37 @@ public class DatapackIngestServiceTest {
.has(fixture.target().toPath().toAbsolutePath().normalize().toString()));
}
@Test
public void successfulUnchangedIngestKeepsStartupFingerprintStableAcrossNextReapply() throws Exception {
ReapplyFixture fixture = reapplyFixture("reapply-ingest-cache");
assertTrue(DatapackIngestService.reapplyStagedDirectories(
fixture.root(), fixture.stagingRoot(), fixture.worlds(), false));
DatapackIngestService.Entry entry = new Gson().fromJson(
manifestEntry(fixture.root()), DatapackIngestService.Entry.class);
DatapackIngestService.Report report = new DatapackIngestService.Report();
DatapackIngestService.recordInstallResult(
null,
report,
fixture.staging(),
fixture.worlds(),
entry,
new DatapackIngestService.InstallResult(false),
entry.versionNumber
);
writePrettyManifest(fixture.root(), entry);
String cachedFingerprint = DatapackIngestService.startupValidationFingerprint(
fixture.root(), fixture.worlds());
assertFalse(entry.stagingMetadata.isBlank());
assertEquals(1, entry.installMetadata.size());
assertEquals(1, report.getUpToDate().size());
assertTrue(DatapackIngestService.reapplyStagedDirectories(
fixture.root(), fixture.stagingRoot(), fixture.worlds(), false));
assertEquals(cachedFingerprint, DatapackIngestService.startupValidationFingerprint(
fixture.root(), fixture.worlds()));
}
@Test
public void reapplyOutcomeDistinguishesRepairFromAnUnchangedPass() throws Exception {
ReapplyFixture fixture = reapplyFixture("reapply-outcome");
@@ -3183,6 +3215,16 @@ public class DatapackIngestServiceTest {
StandardCharsets.UTF_8);
}
private void writePrettyManifest(File root, DatapackIngestService.Entry entry) throws Exception {
Map<String, Object> manifest = new LinkedHashMap<>();
manifest.put("entries", List.of(entry));
Files.writeString(
new File(root, "manifest.json").toPath(),
new GsonBuilder().setPrettyPrinting().create().toJson(manifest),
StandardCharsets.UTF_8
);
}
private String ownershipHash(File directory) throws Exception {
String marker = Files.readString(new File(directory, ".iris-managed.json").toPath(), StandardCharsets.UTF_8);
return JsonParser.parseString(marker).getAsJsonObject().get("contentHash").getAsString();
@@ -115,4 +115,206 @@ public class BukkitWorldConfigurationTest {
1337L));
assertNull(YamlConfiguration.loadConfiguration(configuration).get("worlds.probe"));
}
@Test
public void replacementChangesOnlyGeneratorAndSeed() throws Exception {
File configuration = temporaryFolder.newFile("bukkit.yml");
YamlConfiguration initial = new YamlConfiguration();
initial.set("settings.allow-end", true);
initial.set("worlds.world_nether.generator", "VanillaNether");
initial.set("worlds.world_nether.seed", 7L);
initial.set("worlds.world_nether.environment", "NETHER");
initial.set("worlds.world_nether.keep-spawn-loaded", false);
initial.save(configuration);
BukkitWorldConfiguration.WorldGeneratorSnapshot original =
BukkitWorldConfiguration.snapshot(configuration, "world_nether");
BukkitWorldConfiguration.GeneratorReplacement replacement =
BukkitWorldConfiguration.replaceIfMatching(
configuration,
"world_nether",
original,
"underworld",
1337L
);
assertTrue(replacement.applied());
assertEquals(original, replacement.observed());
assertEquals("VanillaNether", original.generator());
assertEquals(Long.valueOf(7L), original.seed());
assertEquals("Iris:underworld", replacement.replacement().generator());
assertEquals(Long.valueOf(1337L), replacement.replacement().seed());
YamlConfiguration loaded = YamlConfiguration.loadConfiguration(configuration);
assertEquals("Iris:underworld", loaded.getString("worlds.world_nether.generator"));
assertEquals(1337L, loaded.getLong("worlds.world_nether.seed"));
assertEquals("NETHER", loaded.getString("worlds.world_nether.environment"));
assertFalse(loaded.getBoolean("worlds.world_nether.keep-spawn-loaded"));
assertTrue(loaded.getBoolean("settings.allow-end"));
}
@Test
public void staleReplacementSnapshotDoesNotWrite() throws Exception {
File configuration = temporaryFolder.newFile("bukkit.yml");
BukkitWorldConfiguration.register(configuration, "probe", "overworld", 1337L);
BukkitWorldConfiguration.WorldGeneratorSnapshot original =
BukkitWorldConfiguration.snapshot(configuration, "probe");
BukkitWorldConfiguration.GeneratorReplacement first =
BukkitWorldConfiguration.replaceIfMatching(
configuration,
"probe",
original,
"underworld",
42L
);
assertTrue(first.applied());
String beforeStaleAttempt = Files.readString(configuration.toPath());
BukkitWorldConfiguration.GeneratorReplacement stale =
BukkitWorldConfiguration.replaceIfMatching(
configuration,
"probe",
original,
"theend",
99L
);
assertFalse(stale.applied());
assertEquals(first.replacement(), stale.observed());
assertEquals(beforeStaleAttempt, Files.readString(configuration.toPath()));
}
@Test
public void restorationPreservesConcurrentUnrelatedFields() throws Exception {
File configuration = temporaryFolder.newFile("bukkit.yml");
YamlConfiguration initial = new YamlConfiguration();
initial.set("worlds.world_nether.generator", "VanillaNether");
initial.set("worlds.world_nether.seed", 7L);
initial.set("worlds.world_nether.environment", "NETHER");
initial.save(configuration);
BukkitWorldConfiguration.WorldGeneratorSnapshot original =
BukkitWorldConfiguration.snapshot(configuration, "world_nether");
BukkitWorldConfiguration.GeneratorReplacement replacement =
BukkitWorldConfiguration.replaceIfMatching(
configuration,
"world_nether",
original,
"underworld",
1337L
);
YamlConfiguration concurrent = YamlConfiguration.loadConfiguration(configuration);
concurrent.set("worlds.world_nether.environment", "CUSTOM");
concurrent.set("worlds.world_nether.extra", "preserve");
concurrent.save(configuration);
assertTrue(BukkitWorldConfiguration.restoreIfMatching(
configuration,
"world_nether",
replacement.replacement(),
original
));
YamlConfiguration restored = YamlConfiguration.loadConfiguration(configuration);
assertEquals("VanillaNether", restored.getString("worlds.world_nether.generator"));
assertEquals(7L, restored.getLong("worlds.world_nether.seed"));
assertEquals("CUSTOM", restored.getString("worlds.world_nether.environment"));
assertEquals("preserve", restored.getString("worlds.world_nether.extra"));
}
@Test
public void staleRestorationDoesNotOverwriteChangedGenerator() throws Exception {
File configuration = temporaryFolder.newFile("bukkit.yml");
BukkitWorldConfiguration.register(configuration, "probe", "overworld", 1337L);
BukkitWorldConfiguration.WorldGeneratorSnapshot original =
BukkitWorldConfiguration.snapshot(configuration, "probe");
BukkitWorldConfiguration.GeneratorReplacement replacement =
BukkitWorldConfiguration.replaceIfMatching(
configuration,
"probe",
original,
"underworld",
42L
);
YamlConfiguration concurrent = YamlConfiguration.loadConfiguration(configuration);
concurrent.set("worlds.probe.generator", "ExternalGenerator");
concurrent.save(configuration);
String beforeRestore = Files.readString(configuration.toPath());
assertFalse(BukkitWorldConfiguration.restoreIfMatching(
configuration,
"probe",
replacement.replacement(),
original
));
assertEquals(beforeRestore, Files.readString(configuration.toPath()));
}
@Test
public void restorationRemovesSectionsCreatedByReplacement() throws Exception {
File configuration = temporaryFolder.newFile("bukkit.yml");
YamlConfiguration initial = new YamlConfiguration();
initial.set("settings.allow-end", true);
initial.save(configuration);
BukkitWorldConfiguration.WorldGeneratorSnapshot original =
BukkitWorldConfiguration.snapshot(configuration, "world_nether");
BukkitWorldConfiguration.GeneratorReplacement replacement =
BukkitWorldConfiguration.replaceIfMatching(
configuration,
"world_nether",
original,
"underworld",
null
);
assertTrue(BukkitWorldConfiguration.restoreIfMatching(
configuration,
"world_nether",
replacement.replacement(),
original
));
YamlConfiguration restored = YamlConfiguration.loadConfiguration(configuration);
assertNull(restored.getConfigurationSection("worlds"));
assertTrue(restored.getBoolean("settings.allow-end"));
}
@Test
public void restorationRetainsConcurrentFieldsInNewWorldSection() throws Exception {
File configuration = temporaryFolder.newFile("bukkit.yml");
BukkitWorldConfiguration.WorldGeneratorSnapshot original =
BukkitWorldConfiguration.snapshot(configuration, "world_nether");
BukkitWorldConfiguration.GeneratorReplacement replacement =
BukkitWorldConfiguration.replaceIfMatching(
configuration,
"world_nether",
original,
"underworld",
1337L
);
YamlConfiguration concurrent = YamlConfiguration.loadConfiguration(configuration);
concurrent.set("worlds.world_nether.environment", "NETHER");
concurrent.save(configuration);
assertTrue(BukkitWorldConfiguration.restoreIfMatching(
configuration,
"world_nether",
replacement.replacement(),
original
));
YamlConfiguration restored = YamlConfiguration.loadConfiguration(configuration);
assertNull(restored.get("worlds.world_nether.generator"));
assertNull(restored.get("worlds.world_nether.seed"));
assertEquals("NETHER", restored.getString("worlds.world_nether.environment"));
}
@Test
public void snapshotRefusesMalformedGeneratorWithoutChangingFile() throws Exception {
File configuration = temporaryFolder.newFile("bukkit.yml");
String malformed = "worlds:\n probe:\n generator:\n nested: value\n seed: 7\n";
Files.writeString(configuration.toPath(), malformed);
IOException failure = assertThrows(IOException.class,
() -> BukkitWorldConfiguration.snapshot(configuration, "probe"));
assertTrue(failure.getMessage().contains("generator"));
assertEquals(malformed, Files.readString(configuration.toPath()));
}
}
@@ -0,0 +1,336 @@
package art.arcane.iris.core.lifecycle;
import org.junit.Assume;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.IOException;
import java.net.StandardProtocolFamily;
import java.net.UnixDomainSocketAddress;
import java.nio.channels.ServerSocketChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.UUID;
import java.util.stream.Stream;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class WorldReplacementFilesystemTest {
private static final UUID TRANSACTION_ID = UUID.fromString("00000000-0000-0000-0000-000000000001");
private static final UUID OTHER_TRANSACTION_ID = UUID.fromString("00000000-0000-0000-0000-000000000002");
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void publishesReplacementAndRetainsOriginalBackup() throws Exception {
WorldReplacementFilesystem.ReplacementPaths paths = paths("publish-existing", TRANSACTION_ID);
writeOriginalTarget(paths, "original");
String fingerprint = writeStage(paths, "replacement");
WorldReplacementFilesystem.publish(paths, true, fingerprint);
assertEquals("replacement", readPackContent(paths.target()));
assertEquals("original", Files.readString(paths.backup().resolve("original.txt")));
assertFalse(Files.exists(paths.stage()));
}
@Test
public void publishesReplacementWithoutCreatingBackupForAbsentTarget() throws Exception {
WorldReplacementFilesystem.ReplacementPaths paths = paths("publish-absent", TRANSACTION_ID);
String fingerprint = writeStage(paths, "replacement");
WorldReplacementFilesystem.publish(paths, false, fingerprint);
assertEquals("replacement", readPackContent(paths.target()));
assertFalse(Files.exists(paths.stage()));
assertFalse(Files.exists(paths.backup()));
}
@Test
public void retriesPublicationAfterOriginalWasMovedToBackup() throws Exception {
WorldReplacementFilesystem.ReplacementPaths paths = paths("retry-first-move", TRANSACTION_ID);
writeOriginalTarget(paths, "original");
String fingerprint = writeStage(paths, "replacement");
Files.move(paths.target(), paths.backup());
WorldReplacementFilesystem.publish(paths, true, fingerprint);
assertEquals("replacement", readPackContent(paths.target()));
assertEquals("original", Files.readString(paths.backup().resolve("original.txt")));
assertFalse(Files.exists(paths.stage()));
}
@Test
public void retriesPublicationAfterStageWasMovedToTarget() throws Exception {
WorldReplacementFilesystem.ReplacementPaths paths = paths("retry-second-move", TRANSACTION_ID);
writeOriginalTarget(paths, "original");
String fingerprint = writeStage(paths, "replacement");
Files.move(paths.target(), paths.backup());
Files.move(paths.stage(), paths.target());
WorldReplacementFilesystem.publish(paths, true, fingerprint);
assertEquals("replacement", readPackContent(paths.target()));
assertEquals("original", Files.readString(paths.backup().resolve("original.txt")));
assertFalse(Files.exists(paths.stage()));
}
@Test
public void retriesAbsentTargetPublicationAfterStageWasMoved() throws Exception {
WorldReplacementFilesystem.ReplacementPaths paths = paths("retry-absent", TRANSACTION_ID);
String fingerprint = writeStage(paths, "replacement");
Files.move(paths.stage(), paths.target());
WorldReplacementFilesystem.publish(paths, false, fingerprint);
assertEquals("replacement", readPackContent(paths.target()));
assertFalse(Files.exists(paths.stage()));
assertFalse(Files.exists(paths.backup()));
}
@Test
public void rollbackRestoresOriginalAfterCompletedPublication() throws Exception {
WorldReplacementFilesystem.ReplacementPaths paths = paths("rollback-existing", TRANSACTION_ID);
writeOriginalTarget(paths, "original");
String fingerprint = writeStage(paths, "replacement");
WorldReplacementFilesystem.publish(paths, true, fingerprint);
WorldReplacementFilesystem.rollback(paths, true);
assertEquals("original", Files.readString(paths.target().resolve("original.txt")));
assertFalse(Files.exists(paths.target().resolve("iris/pack")));
assertFalse(Files.exists(paths.stage()));
assertFalse(Files.exists(paths.backup()));
}
@Test
public void rollbackRemovesPublishedReplacementForOriginallyAbsentTarget() throws Exception {
WorldReplacementFilesystem.ReplacementPaths paths = paths("rollback-absent", TRANSACTION_ID);
String fingerprint = writeStage(paths, "replacement");
WorldReplacementFilesystem.publish(paths, false, fingerprint);
WorldReplacementFilesystem.rollback(paths, false);
assertFalse(Files.exists(paths.target()));
assertFalse(Files.exists(paths.stage()));
assertFalse(Files.exists(paths.backup()));
}
@Test
public void rollbackRestoresOriginalFromFirstMoveCrashState() throws Exception {
WorldReplacementFilesystem.ReplacementPaths paths = paths("rollback-first-move", TRANSACTION_ID);
writeOriginalTarget(paths, "original");
writeStage(paths, "replacement");
Files.move(paths.target(), paths.backup());
WorldReplacementFilesystem.rollback(paths, true);
assertEquals("original", Files.readString(paths.target().resolve("original.txt")));
assertFalse(Files.exists(paths.stage()));
assertFalse(Files.exists(paths.backup()));
}
@Test
public void rejectsPackMutationBeforePublication() throws Exception {
WorldReplacementFilesystem.ReplacementPaths paths = paths("fingerprint-mutation", TRANSACTION_ID);
String fingerprint = writeStage(paths, "original-stage");
Files.writeString(packContent(paths.stage()), "mutated-stage");
assertThrows(
IOException.class,
() -> WorldReplacementFilesystem.publish(paths, false, fingerprint)
);
assertTrue(Files.isDirectory(paths.stage()));
assertFalse(Files.exists(paths.target()));
assertFalse(Files.exists(paths.backup()));
}
@Test
public void rejectsSymlinkOutsidePackBeforePublication() throws Exception {
WorldReplacementFilesystem.ReplacementPaths paths = paths("outside-pack-link", TRANSACTION_ID);
String fingerprint = writeStage(paths, "replacement");
Path outside = temporaryFolder.newFile("outside.txt").toPath();
Path region = Files.createDirectories(paths.stage().resolve("region"));
Files.createSymbolicLink(region.resolve("linked.mca"), outside);
assertThrows(
IOException.class,
() -> WorldReplacementFilesystem.publish(paths, false, fingerprint)
);
assertTrue(Files.isDirectory(paths.stage()));
assertFalse(Files.exists(paths.target()));
}
@Test
public void rejectsSpecialEntryOutsidePackBeforePublication() throws Exception {
Path shortTemp = Path.of("/tmp");
Assume.assumeTrue(Files.isDirectory(shortTemp));
Path parent = Files.createTempDirectory(shortTemp, "iw");
String name = "u";
String stem = artifactStem(name, TRANSACTION_ID);
WorldReplacementFilesystem.ReplacementPaths paths = new WorldReplacementFilesystem.ReplacementPaths(
parent.resolve(name),
parent.resolve(stem + ".stage"),
parent.resolve(stem + ".backup")
);
try {
String fingerprint = writeStage(paths, "replacement");
Path socket = paths.stage().resolve("unsafe.sock");
try (ServerSocketChannel channel = openUnixSocket(socket)) {
assertThrows(
IOException.class,
() -> WorldReplacementFilesystem.publish(paths, false, fingerprint)
);
}
assertTrue(Files.isDirectory(paths.stage()));
assertFalse(Files.exists(paths.target()));
} finally {
deleteTestTree(parent);
}
}
@Test
public void rejectsSymlinkInsidePackAndNonDirectoryArtifacts() throws Exception {
WorldReplacementFilesystem.ReplacementPaths linkedPaths = paths("inside-pack-link", TRANSACTION_ID);
Path pack = Files.createDirectories(linkedPaths.stage().resolve("iris/pack"));
Path outside = temporaryFolder.newFile("outside-pack.txt").toPath();
Files.createSymbolicLink(pack.resolve("linked.json"), outside);
assertThrows(IOException.class, () -> WorldReplacementFilesystem.fingerprintPack(pack));
WorldReplacementFilesystem.ReplacementPaths filePaths = paths("file-stage", OTHER_TRANSACTION_ID);
Files.createFile(filePaths.stage());
assertThrows(
IOException.class,
() -> WorldReplacementFilesystem.publish(filePaths, false, "0".repeat(64))
);
}
@Test
public void rejectsMalformedAndCrossTransactionPaths() throws Exception {
Path parent = temporaryFolder.newFolder("invalid-paths").toPath();
Path target = parent.resolve("underworld");
String firstStem = artifactStem("underworld", TRANSACTION_ID);
String secondStem = artifactStem("underworld", OTHER_TRANSACTION_ID);
assertThrows(
IllegalArgumentException.class,
() -> new WorldReplacementFilesystem.ReplacementPaths(
target,
parent.resolve("invalid.stage"),
parent.resolve(firstStem + ".backup")
)
);
assertThrows(
IllegalArgumentException.class,
() -> new WorldReplacementFilesystem.ReplacementPaths(
target,
parent.resolve(firstStem + ".stage"),
parent.resolve(secondStem + ".backup")
)
);
assertThrows(
IllegalArgumentException.class,
() -> new WorldReplacementFilesystem.ReplacementPaths(
target,
parent.resolve(firstStem + ".stage"),
Files.createDirectories(parent.resolve("other")).resolve(firstStem + ".backup")
)
);
assertThrows(
IllegalArgumentException.class,
() -> new WorldReplacementFilesystem.ReplacementPaths(
parent.resolve("safe/../underworld"),
parent.resolve(firstStem + ".stage"),
parent.resolve(firstStem + ".backup")
)
);
}
@Test
public void rejectsImpossibleCombinedPublicationState() throws Exception {
WorldReplacementFilesystem.ReplacementPaths paths = paths("combined-state", TRANSACTION_ID);
writeOriginalTarget(paths, "original");
String fingerprint = writeStage(paths, "replacement");
Files.createDirectories(paths.backup());
assertThrows(
IOException.class,
() -> WorldReplacementFilesystem.publish(paths, true, fingerprint)
);
}
private WorldReplacementFilesystem.ReplacementPaths paths(String name, UUID transactionId) throws Exception {
Path parent = temporaryFolder.newFolder(name).toPath();
String stem = artifactStem(name, transactionId);
return new WorldReplacementFilesystem.ReplacementPaths(
parent.resolve(name),
parent.resolve(stem + ".stage"),
parent.resolve(stem + ".backup")
);
}
private String writeStage(WorldReplacementFilesystem.ReplacementPaths paths, String content) throws Exception {
Path contentFile = packContent(paths.stage());
Files.createDirectories(contentFile.getParent());
Files.writeString(contentFile, content);
return WorldReplacementFilesystem.fingerprintPack(paths.stage().resolve("iris/pack"));
}
private void writeOriginalTarget(WorldReplacementFilesystem.ReplacementPaths paths, String content) throws Exception {
Files.createDirectories(paths.target());
Files.writeString(paths.target().resolve("original.txt"), content);
}
private String readPackContent(Path worldDirectory) throws Exception {
return Files.readString(packContent(worldDirectory));
}
private Path packContent(Path worldDirectory) {
return worldDirectory.resolve("iris/pack/dimensions/underworld.json");
}
private String artifactStem(String name, UUID transactionId) {
return ".iris-replace-" + name + "-" + transactionId;
}
private ServerSocketChannel openUnixSocket(Path path) throws Exception {
ServerSocketChannel channel = null;
try {
channel = ServerSocketChannel.open(StandardProtocolFamily.UNIX);
channel.bind(UnixDomainSocketAddress.of(path));
return channel;
} catch (UnsupportedOperationException exception) {
if (channel != null) {
channel.close();
}
Assume.assumeNoException(exception);
throw exception;
} catch (Exception | Error failure) {
if (channel != null) {
channel.close();
}
throw failure;
}
}
private void deleteTestTree(Path root) throws Exception {
if (!Files.exists(root)) {
return;
}
try (Stream<Path> paths = Files.walk(root)) {
for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) {
Files.deleteIfExists(path);
}
}
}
}
@@ -1,6 +1,8 @@
package art.arcane.iris.core.nms.datapack.v1217;
import art.arcane.iris.engine.object.IrisBiomeCustom;
import art.arcane.iris.engine.object.IrisBiomeCustomParticle;
import art.arcane.volmlib.util.json.JSONArray;
import art.arcane.volmlib.util.json.JSONObject;
import org.junit.Test;
@@ -12,19 +14,35 @@ public class DataFixerV1217CustomBiomeTest {
private final DataFixerV1217 fixer = new DataFixerV1217();
@Test
public void keepsSpigotBiomeColorsInEffects() {
public void movesEnvironmentColorsAndParticlesIntoAttributes() {
IrisBiomeCustom biome = new IrisBiomeCustom();
biome.setId("spigot_colors");
biome.setGrassColor("#28a040");
biome.setFoliageColor("#249030");
biome.setFogColor("#330808");
biome.setSkyColor("#102030");
biome.setWaterFogColor("#405060");
biome.setAmbientParticle(new IrisBiomeCustomParticle()
.setParticle("minecraft:ash")
.setRarity(40));
JSONObject json = new JSONObject(biome.generateJson(fixer));
JSONObject effects = json.getJSONObject("effects");
JSONObject attributes = json.getJSONObject("attributes");
assertFalse(json.has("attributes"));
assertTrue(effects.has("water_color"));
assertTrue(effects.has("water_fog_color"));
assertEquals(0x28a040, effects.getInt("grass_color"));
assertEquals(0x249030, effects.getInt("foliage_color"));
assertFalse(effects.has("sky_color"));
assertFalse(effects.has("fog_color"));
assertFalse(effects.has("water_fog_color"));
assertFalse(effects.has("particle"));
assertEquals(0x330808, attributes.getInt("minecraft:visual/fog_color"));
assertEquals(0x102030, attributes.getInt("minecraft:visual/sky_color"));
assertEquals(0x405060, attributes.getInt("minecraft:visual/water_fog_color"));
JSONArray ambientParticles = attributes.getJSONArray("minecraft:visual/ambient_particles");
JSONObject ambientParticle = ambientParticles.getJSONObject(0);
assertEquals("minecraft:ash", ambientParticle.getJSONObject("particle").getString("type"));
assertEquals(0.025D, ambientParticle.getDouble("probability"), 0.000001D);
}
}
@@ -1,6 +1,7 @@
package art.arcane.iris.core.nms.datapack.v1217;
import art.arcane.iris.core.nms.datapack.IDataFixer.Dimension;
import art.arcane.iris.engine.object.IrisDimensionTypeOptions;
import art.arcane.volmlib.util.json.JSONObject;
import org.junit.Test;
@@ -16,6 +17,8 @@ public class DataFixerV1217DimensionTypeTest {
assertTrue(json.has("has_ender_dragon_fight"));
assertEquals(false, json.getBoolean("has_ender_dragon_fight"));
assertEquals("#0a0a0a", json.getJSONObject("attributes")
.getString("minecraft:visual/ambient_light_color"));
}
@Test
@@ -24,5 +27,25 @@ public class DataFixerV1217DimensionTypeTest {
assertTrue(json.has("has_ender_dragon_fight"));
assertEquals(true, json.getBoolean("has_ender_dragon_fight"));
assertEquals("#3f473f", json.getJSONObject("attributes")
.getString("minecraft:visual/ambient_light_color"));
}
@Test
public void createsNetherDimensionWithVanillaAmbientColor() {
JSONObject json = fixer.createDimension(Dimension.NETHER, -256, 768, 512, null);
assertEquals("#302821", json.getJSONObject("attributes")
.getString("minecraft:visual/ambient_light_color"));
}
@Test
public void mapsMaximumAmbientLightToWhite() {
IrisDimensionTypeOptions options = new IrisDimensionTypeOptions().ambientLight(1F);
JSONObject json = fixer.createDimension(Dimension.NETHER, -256, 768, 512, options);
assertEquals(1D, json.getDouble("ambient_light"), 0D);
assertEquals("#ffffff", json.getJSONObject("attributes")
.getString("minecraft:visual/ambient_light_color"));
}
}
@@ -0,0 +1,67 @@
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 PackCaveProfileValidatorTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void rejectsLegacyWaterFieldsAcrossEveryCaveProfileLocation() throws Exception {
File pack = temporaryFolder.newFolder("pack");
write(pack, "dimensions/main.json", "{\"caveProfile\":{\"allowWater\":false}}");
write(pack, "regions/nested/region.json", "{\"caveProfile\":{\"waterMinDepthBelowSurface\":20}}");
write(pack, "biomes/nested/biome.json", "{\"caveProfile\":{\"waterRequiresFloor\":true}}");
write(pack, "snippet/cave-profile/nested/profile.json", "{\"allowWater\":true}");
assertEquals(List.of(
"Dimension 'main' caveProfile.allowWater was removed; use caveProfile.allowFluid. Cave aquifers use the dimension fluidPalette, which defaults to water.",
"Region 'nested/region' caveProfile.waterMinDepthBelowSurface was removed; use caveProfile.fluidMinDepthBelowSurface. Cave aquifers use the dimension fluidPalette, which defaults to water.",
"Biome 'nested/biome' caveProfile.waterRequiresFloor was removed; use caveProfile.fluidRequiresFloor. Cave aquifers use the dimension fluidPalette, which defaults to water.",
"Cave-profile snippet 'nested/profile' allowWater was removed; use allowFluid. Cave aquifers use the dimension fluidPalette, which defaults to water."
), PackCaveProfileValidator.validateLegacyFields(pack));
}
@Test
public void acceptsGenericFluidFields() throws Exception {
File pack = temporaryFolder.newFolder("pack");
write(pack, "dimensions/main.json", "{\"caveProfile\":{\"allowFluid\":true,"
+ "\"fluidMinDepthBelowSurface\":20,\"fluidRequiresFloor\":true}}");
write(pack, "snippet/cave-profile/profile.json", "{\"allowFluid\":true,"
+ "\"fluidMinDepthBelowSurface\":20,\"fluidRequiresFloor\":true}");
assertTrue(PackCaveProfileValidator.validateLegacyFields(pack).isEmpty());
}
@Test
public void legacyFieldBlocksFullPackValidation() 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\",\"caveProfile\":{\"allowWater\":false}}");
PackValidationResult result = PackValidator.validate(pack);
assertFalse(result.isLoadable());
assertTrue(result.getBlockingErrors().contains(
"Biome 'biome' caveProfile.allowWater was removed; use caveProfile.allowFluid. Cave aquifers use the dimension fluidPalette, which defaults to water."));
}
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);
}
}
@@ -77,7 +77,7 @@ public class PackValidationCacheTest {
Path cache = new File(temporaryFolder.newFolder("duplicate"), "validation.json").toPath();
Files.writeString(cache, """
{
"schemaVersion": 1,
"schemaVersion": 2,
"contentFingerprint": "content",
"contextFingerprint": "context",
"results": [
@@ -91,6 +91,24 @@ public class PackValidationCacheTest {
cache, "content", "context", List.of("overworld")).isEmpty());
}
@Test
public void previousValidationSchemaIsRejected() throws Exception {
Path cache = new File(temporaryFolder.newFolder("previous-schema"), "validation.json").toPath();
Files.writeString(cache, """
{
"schemaVersion": 1,
"contentFingerprint": "content",
"contextFingerprint": "context",
"results": [
{"packName":"overworld","blockingErrors":[],"warnings":[],"validatedAtMillis":1}
]
}
""", StandardCharsets.UTF_8);
assertTrue(PackValidationCache.load(
cache, "content", "context", List.of("overworld")).isEmpty());
}
@Test
public void symbolicLinkCacheIsRejected() throws Exception {
Path directory = temporaryFolder.newFolder("symbolic-cache").toPath();
@@ -19,15 +19,25 @@
package art.arcane.iris.core.pack;
import org.junit.After;
import org.junit.Assume;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.IOException;
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.assertNull;
import static org.junit.Assert.assertTrue;
public class PackValidationRegistryTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Before
public void setUp() {
PackValidationRegistry.clear();
@@ -60,6 +70,56 @@ public class PackValidationRegistryTest {
assertEquals(result, PackValidationRegistry.requireLoadable("overworld"));
}
@Test
public void exactRootsWithTheSameBasenameRemainIndependent() throws Exception {
Path firstRoot = temporaryFolder.newFolder("first").toPath().resolve("pack");
Path secondRoot = temporaryFolder.newFolder("second").toPath().resolve("pack");
PackValidationResult loadable = new PackValidationResult(
"pack", List.of(), List.of(), 1L);
PackValidationResult broken = new PackValidationResult(
"pack", List.of("second snapshot is broken"), List.of(), 2L);
PackValidationRegistry.publish(firstRoot, loadable);
PackValidationRegistry.publish(secondRoot, broken);
assertEquals(loadable, PackValidationRegistry.requireLoadable(firstRoot));
assertEquals(broken, PackValidationRegistry.get(secondRoot));
assertTrue(PackValidationRegistry.isBroken(secondRoot));
assertNull(PackValidationRegistry.get("pack"));
assertBroken(secondRoot, "second snapshot is broken");
}
@Test
public void removingOneExactRootDoesNotEvictItsSameNamedSibling() throws Exception {
Path firstRoot = temporaryFolder.newFolder("remove-first").toPath().resolve("pack");
Path secondRoot = temporaryFolder.newFolder("keep-second").toPath().resolve("pack");
PackValidationResult first = new PackValidationResult("pack", List.of(), List.of(), 1L);
PackValidationResult second = new PackValidationResult("pack", List.of(), List.of(), 2L);
PackValidationRegistry.publish(firstRoot, first);
PackValidationRegistry.publish(secondRoot, second);
PackValidationRegistry.remove(firstRoot);
assertNull(PackValidationRegistry.get(firstRoot));
assertEquals(second, PackValidationRegistry.requireLoadable(secondRoot));
}
@Test
public void existingRootAliasesResolveToTheSameRealPath() throws Exception {
Path realRoot = temporaryFolder.newFolder("real-pack").toPath();
Path linkedRoot = realRoot.getParent().resolve("linked-pack");
try {
Files.createSymbolicLink(linkedRoot, realRoot);
} catch (IOException | UnsupportedOperationException exception) {
Assume.assumeNoException(exception);
}
PackValidationResult result = new PackValidationResult("pack", List.of(), List.of(), 1L);
PackValidationRegistry.publish(linkedRoot, result);
assertEquals(result, PackValidationRegistry.requireLoadable(realRoot));
}
private void assertBroken(String pack, String expectedReason) {
try {
PackValidationRegistry.requireLoadable(pack);
@@ -71,4 +131,16 @@ public class PackValidationRegistryTest {
}
throw new AssertionError("Expected pack validation to fail closed");
}
private void assertBroken(Path packRoot, String expectedReason) {
try {
PackValidationRegistry.requireLoadable(packRoot);
} catch (BrokenPackException e) {
assertEquals(packRoot.toAbsolutePath().normalize().toString(), e.getPackName());
assertTrue(e.getReasons().toString(), e.getReasons().stream().anyMatch(
reason -> reason.contains(expectedReason)));
return;
}
throw new AssertionError("Expected pack validation to fail closed");
}
}
@@ -15,6 +15,10 @@ public class PackValidatorImportedStructurePolicyTest {
public void denyOnlyPolicyAcceptsDisabledKeysAndAdjustments() {
JSONObject policy = new JSONObject()
.put("disabled", new JSONArray().put("minecraft:stronghold"))
.put("disabledExact", new JSONArray().put("minecraft:ruined_portal"))
.put("frequencyOverrides", new JSONArray().put(new JSONObject()
.put("structureSet", "minecraft:nether_complexes")
.put("multiplier", 1.1D)))
.put("adjustments", new JSONArray().put(new JSONObject()
.put("match", new JSONArray().put("minecraft:village"))));
List<String> errors = validate(policy);
@@ -22,6 +26,27 @@ public class PackValidatorImportedStructurePolicyTest {
assertTrue(errors.isEmpty());
}
@Test
public void malformedFrequencyOverridesAreRejected() {
JSONObject policy = new JSONObject()
.put("frequencyOverrides", new JSONArray()
.put("minecraft:nether_complexes")
.put(new JSONObject().put("structureSet", "nether_complexes"))
.put(new JSONObject()
.put("structureSet", "minecraft:ruined_portals")
.put("multiplier", 0D))
.put(new JSONObject()
.put("structureSet", "minecraft:nether_fossils")
.put("multiplier", "often")));
List<String> errors = validate(policy);
assertEquals(4, errors.size());
assertTrue(errors.stream().anyMatch(error -> error.contains("frequencyOverrides[0] must be an object")));
assertTrue(errors.stream().anyMatch(error -> error.contains("frequencyOverrides[1].structureSet")));
assertTrue(errors.stream().anyMatch(error -> error.contains("frequencyOverrides[2].multiplier must be at least")));
assertTrue(errors.stream().anyMatch(error -> error.contains("frequencyOverrides[3].multiplier must be a number")));
}
@Test
public void encaseTerrainAndYBandAdjustmentsAreAccepted() {
JSONObject policy = new JSONObject()
@@ -99,14 +124,29 @@ public class PackValidatorImportedStructurePolicyTest {
public void explicitNullsAndWrongShapesAreRejected() {
JSONObject policy = new JSONObject()
.put("disabled", JSONObject.NULL)
.put("disabledExact", new JSONObject())
.put("frequencyOverrides", JSONObject.NULL)
.put("adjustments", new JSONObject());
List<String> errors = validate(policy);
assertEquals(2, errors.size());
assertEquals(4, errors.size());
assertTrue(errors.stream().anyMatch(error -> error.contains("'disabled' must be an array")));
assertTrue(errors.stream().anyMatch(error -> error.contains("'disabledExact' must be an array")));
assertTrue(errors.stream().anyMatch(error -> error.contains("frequencyOverrides must be an array")));
assertTrue(errors.stream().anyMatch(error -> error.contains("adjustments must be an array")));
}
@Test
public void blankOrNonStringExactKeysAreRejected() {
JSONObject policy = new JSONObject()
.put("disabledExact", new JSONArray().put(" ").put(4));
List<String> errors = validate(policy);
assertEquals(2, errors.size());
assertTrue(errors.get(0).contains("'disabledExact' has a blank or non-string entry at index 0"));
assertTrue(errors.get(1).contains("'disabledExact' has a blank or non-string entry at index 1"));
}
@Test
public void explicitNullPolicyIsRejectedWhileOmissionUsesDefaults() {
List<String> missingErrors = new ArrayList<>();
@@ -1,10 +1,12 @@
package art.arcane.iris.core.project;
import art.arcane.iris.engine.object.IrisImportedStructureControl;
import art.arcane.iris.engine.object.IrisStructureSetFrequencyOverride;
import art.arcane.iris.engine.object.IrisVanillaStructureAdjustment;
import art.arcane.iris.engine.object.annotations.ArrayType;
import art.arcane.iris.engine.object.annotations.Desc;
import art.arcane.iris.engine.object.annotations.RegistryListVanillaStructure;
import art.arcane.iris.engine.object.annotations.RegistryListVanillaStructureSet;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.json.JSONArray;
import art.arcane.volmlib.util.json.JSONObject;
@@ -19,16 +21,20 @@ import static org.junit.Assert.assertTrue;
* importedStructures.disabled and adjustments[].match accept family/namespace PREFIXES
* ("minecraft:village", "nova_structures:") per IrisImportedStructureControl.matchesKey, so the
* generated editor schema must not reject them with a strict registered-key enum. Prefix-capable
* fields emit an anyOf of the registry enum plus a key/prefix pattern; exact-key fields (e.g.
* nativeStructures[].structure) keep the strict enum.
* fields emit an anyOf of the registry enum plus a key/prefix pattern. Exact-key fields, including
* importedStructures.disabledExact and nativeStructures[].structure, keep the strict enum.
*/
public class VanillaStructurePrefixSchemaTest {
@Test
public void prefixCapableFieldsDeclareThePrefixAnnotation() throws NoSuchFieldException {
assertTrue(IrisImportedStructureControl.class.getDeclaredField("disabled")
.getAnnotation(RegistryListVanillaStructure.class).prefixes());
assertFalse(IrisImportedStructureControl.class.getDeclaredField("disabledExact")
.getAnnotation(RegistryListVanillaStructure.class).prefixes());
assertTrue(IrisVanillaStructureAdjustment.class.getDeclaredField("match")
.getAnnotation(RegistryListVanillaStructure.class).prefixes());
assertTrue(IrisStructureSetFrequencyOverride.class.getDeclaredField("structureSet")
.isAnnotationPresent(RegistryListVanillaStructureSet.class));
}
@Test
@@ -2,7 +2,11 @@ package art.arcane.iris.core.service;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.pack.AtomicDirectoryPublisher;
import art.arcane.iris.core.pack.BrokenPackException;
import art.arcane.iris.core.pack.PackValidationRegistry;
import art.arcane.iris.core.pack.PackValidationResult;
import org.junit.Assume;
import org.junit.After;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
@@ -26,6 +30,11 @@ public class StudioSVCWorldPackPublishTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@After
public void clearValidationRegistry() {
PackValidationRegistry.clear();
}
@Test
public void copiesToStageAndPublishesTheCompletePack() throws IOException {
Path root = temporaryFolder.newFolder("world").toPath();
@@ -122,6 +131,24 @@ public class StudioSVCWorldPackPublishTest {
assertFalse(Files.exists(target.resolve("rejected.txt")));
}
@Test
public void finalPublishedSnapshotReplacesStalePathValidation() throws Exception {
Path packRoot = temporaryFolder.newFolder("published-snapshot", "iris", "pack").toPath();
writeValidPack(packRoot);
PackValidationResult staleFailure = new PackValidationResult(
"pack", List.of("stale failure"), List.of(), 1L);
PackValidationRegistry.publish(packRoot, staleFailure);
PackValidationResult validated = StudioSVC.validatePublishedPack(packRoot);
assertTrue(validated.isLoadable());
assertSame(validated, PackValidationRegistry.requireLoadable(packRoot));
Files.writeString(packRoot.resolve("dimensions/main.json"), "{");
assertThrows(BrokenPackException.class, () -> StudioSVC.validatePublishedPack(packRoot));
assertTrue(PackValidationRegistry.isBroken(packRoot));
}
@Test
public void createdProjectRollbackEvictsOnlyItsCachedLoaderBeforeDeletion() throws IOException {
Path root = temporaryFolder.newFolder("project-cache-rollback").toPath();
@@ -176,4 +203,13 @@ public class StudioSVCWorldPackPublishTest {
secondGate.complete("second");
assertEquals("second", second.join());
}
private static void writeValidPack(Path packRoot) throws Exception {
Files.createDirectories(packRoot.resolve("dimensions"));
Files.createDirectories(packRoot.resolve("regions"));
Files.createDirectories(packRoot.resolve("biomes"));
Files.writeString(packRoot.resolve("dimensions/main.json"), "{\"regions\":[\"region\"]}");
Files.writeString(packRoot.resolve("regions/region.json"), "{\"landBiomes\":[\"biome\"]}");
Files.writeString(packRoot.resolve("biomes/biome.json"), "{\"name\":\"Biome\"}");
}
}
@@ -0,0 +1,46 @@
package art.arcane.iris.engine.framework;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
public class NativeStructureFrequencyScaleTest {
@Test
public void netherRandomSpreadSetsResolveToNearestLegalSpacing() {
NativeStructureFrequencyScale complexes = NativeStructureFrequencyScale.randomSpread(
1F, 27, 4, 1.1D);
NativeStructureFrequencyScale portals = NativeStructureFrequencyScale.randomSpread(
1F, 40, 15, 1.1D);
NativeStructureFrequencyScale fossils = NativeStructureFrequencyScale.randomSpread(
1F, 2, 1, 1.1D);
assertEquals(26, complexes.spacing());
assertEquals(38, portals.spacing());
assertEquals(2, fossils.spacing());
assertEquals(1F, complexes.frequency(), 0F);
}
@Test
public void probabilityScalesBeforeIntegerSpacing() {
NativeStructureFrequencyScale increased = NativeStructureFrequencyScale.randomSpread(
0.5F, 32, 8, 1.5D);
NativeStructureFrequencyScale decreased = NativeStructureFrequencyScale.randomSpread(
1F, 32, 8, 0.25D);
assertEquals(32, increased.spacing());
assertEquals(0.75F, increased.frequency(), 0F);
assertEquals(32, decreased.spacing());
assertEquals(0.25F, decreased.frequency(), 0F);
}
@Test
public void invalidPlacementInputsFailClosed() {
assertThrows(IllegalArgumentException.class,
() -> NativeStructureFrequencyScale.randomSpread(1F, 8, 8, 1.1D));
assertThrows(IllegalArgumentException.class,
() -> NativeStructureFrequencyScale.randomSpread(1F, 32, 8, Double.NaN));
assertThrows(IllegalArgumentException.class,
() -> NativeStructureFrequencyScale.probability(1F, 17D));
}
}
@@ -53,12 +53,28 @@ public class NativeStructureGenerationPolicyTest {
assertFalse(decision.generate());
}
@Test
public void exactDisabledKeyDoesNotDisableSiblingVariant() {
IrisImportedStructureControl control = new IrisImportedStructureControl();
control.getDisabledExact().add("minecraft:ruined_portal");
Engine engine = engineWithControlAndRegionPlacement(control, "nova_structures:tavern_oak");
assertEquals(NativeStructureGenerationStatus.DISABLED_BY_PACK,
NativeStructureGenerationPolicy.resolve(engine, "minecraft:ruined_portal", false).status());
assertEquals(NativeStructureGenerationStatus.GENERATE_NATIVE,
NativeStructureGenerationPolicy.resolve(engine, "minecraft:ruined_portal_nether", false).status());
}
private Engine engineWithDisabledNamespaceAndRegionPlacement(String placedKey) {
IrisImportedStructureControl control = new IrisImportedStructureControl();
control.getDisabled().add("nova_structures:");
return engineWithControlAndRegionPlacement(control, placedKey);
}
private Engine engineWithControlAndRegionPlacement(IrisImportedStructureControl control, String placedKey) {
IrisData data = mock(IrisData.class);
Engine engine = mock(Engine.class);
IrisDimension dimension = mock(IrisDimension.class);
IrisImportedStructureControl control = new IrisImportedStructureControl();
control.getDisabled().add("nova_structures:");
IrisStructurePlacement placement = new IrisStructurePlacement();
placement.getNativeStructures().add(new IrisNativeStructure().setStructure(placedKey));
@@ -52,7 +52,7 @@ public class IrisCaveCarver3DNearParityTest {
private static Field surfaceBreakDensityField;
private static Field thresholdRngField;
private static Field carveAirField;
private static Field carveWaterField;
private static Field carveFluidField;
private static Field carveLavaField;
private static Field carveForcedAirField;
@@ -74,14 +74,25 @@ public class IrisCaveCarver3DNearParityTest {
thresholdRngField.setAccessible(true);
carveAirField = IrisCaveCarver3D.class.getDeclaredField("carveAir");
carveAirField.setAccessible(true);
carveWaterField = IrisCaveCarver3D.class.getDeclaredField("carveWater");
carveWaterField.setAccessible(true);
carveFluidField = IrisCaveCarver3D.class.getDeclaredField("carveFluid");
carveFluidField.setAccessible(true);
carveLavaField = IrisCaveCarver3D.class.getDeclaredField("carveLava");
carveLavaField.setAccessible(true);
carveForcedAirField = IrisCaveCarver3D.class.getDeclaredField("carveForcedAir");
carveForcedAirField.setAccessible(true);
}
@Test
public void genericFluidContractKeepsOverworldDefaults() {
IrisCaveProfile profile = new IrisCaveProfile();
IrisDimension dimension = new IrisDimension();
assertTrue(profile.isAllowFluid());
assertEquals(12, profile.getFluidMinDepthBelowSurface());
assertTrue(profile.isFluidRequiresFloor());
assertEquals("water", dimension.getFluidPalette().getPalette().get(0).getBlock());
}
@Test
public void carvedCellDistributionStableAcrossEquivalentCarvers() {
Engine engine = createEngine(128, 92);
@@ -194,12 +205,12 @@ public class IrisCaveCarver3DNearParityTest {
double[] columnWeights = fullWeights();
int[] precomputedSurfaceHeights = filledHeights(46);
IrisCaveProfile lavaProfile = createProfile(false, false).setAllowLava(true).setAllowWater(false);
IrisCaveProfile lavaProfile = createProfile(false, false).setAllowLava(true).setAllowFluid(false);
IrisCaveCarver3D lavaCarver = new IrisCaveCarver3D(engine, lavaProfile);
WriterCapture lavaCapture = createWriterCapture(48);
lavaCarver.carve(lavaCapture.writer, 0, 0, columnWeights, 0D, 0D, new IrisRange(0D, 80D), precomputedSurfaceHeights);
IrisCaveProfile forcedAirProfile = createProfile(false, false).setAllowLava(false).setAllowWater(false);
IrisCaveProfile forcedAirProfile = createProfile(false, false).setAllowLava(false).setAllowFluid(false);
IrisCaveCarver3D forcedAirCarver = new IrisCaveCarver3D(engine, forcedAirProfile);
WriterCapture forcedAirCapture = createWriterCapture(48);
forcedAirCarver.carve(forcedAirCapture.writer, 0, 0, columnWeights, 0D, 0D, new IrisRange(0D, 80D), precomputedSurfaceHeights);
@@ -211,22 +222,22 @@ public class IrisCaveCarver3DNearParityTest {
}
@Test
public void waterPrecedesForcedAirWhenLavaIsDisabled() {
public void fluidPrecedesForcedAirWhenLavaIsDisabled() {
Engine engine = createEngine(48, 46);
int[] surfaceHeights = filledHeights(46);
IrisCaveProfile wetProfile = createWaterProfile()
IrisCaveProfile wetProfile = createFluidProfile()
.setVerticalRange(new IrisRange(2D, 18D))
.setAllowLava(false)
.setWaterRequiresFloor(false);
.setFluidRequiresFloor(false);
WriterCapture wetCapture = createWriterCapture(48);
new IrisCaveCarver3D(engine, wetProfile).carve(
wetCapture.writer, 0, 0, fullWeights(), 0D, 0D, null, surfaceHeights);
IrisCaveProfile dryProfile = createWaterProfile()
IrisCaveProfile dryProfile = createFluidProfile()
.setVerticalRange(new IrisRange(2D, 18D))
.setAllowWater(false)
.setAllowFluid(false)
.setAllowLava(false)
.setWaterRequiresFloor(false);
.setFluidRequiresFloor(false);
WriterCapture dryCapture = createWriterCapture(48);
new IrisCaveCarver3D(engine, dryProfile).carve(
dryCapture.writer, 0, 0, fullWeights(), 0D, 0D, null, surfaceHeights);
@@ -239,16 +250,16 @@ public class IrisCaveCarver3DNearParityTest {
}
@Test
public void waterToggleAndMinimumDepthUseEachColumnsTerrainSurface() {
public void fluidToggleAndMinimumDepthUseEachColumnsTerrainSurface() {
Engine engine = createEngine(80, 70);
int[] surfaceHeights = splitSurfaceHeights(40, 70);
IrisCaveProfile enabledProfile = createWaterProfile().setAllowWater(true).setWaterMinDepthBelowSurface(10);
IrisCaveProfile enabledProfile = createFluidProfile().setAllowFluid(true).setFluidMinDepthBelowSurface(10);
WriterCapture enabledCapture = createWriterCapture(80);
new IrisCaveCarver3D(engine, enabledProfile).carve(
enabledCapture.writer, 0, 0, fullWeights(), 0D, 0D, null, surfaceHeights);
IrisCaveProfile disabledProfile = createWaterProfile().setAllowWater(false).setWaterMinDepthBelowSurface(10);
IrisCaveProfile disabledProfile = createFluidProfile().setAllowFluid(false).setFluidMinDepthBelowSurface(10);
WriterCapture disabledCapture = createWriterCapture(80);
new IrisCaveCarver3D(engine, disabledProfile).carve(
disabledCapture.writer, 0, 0, fullWeights(), 0D, 0D, null, surfaceHeights);
@@ -256,15 +267,15 @@ public class IrisCaveCarver3DNearParityTest {
assertEquals(enabledCapture.carvedCells, disabledCapture.carvedCells);
assertTrue(countLiquid(enabledCapture, (byte) 1) > 0);
assertEquals(0, countLiquid(disabledCapture, (byte) 1));
assertWaterRespectsSplitCutoff(enabledCapture, 30, 60);
assertFluidRespectsSplitCutoff(enabledCapture, 30, 60);
assertTrue(enabledCapture.carvedCells.contains(cellKey(0, 40, 0)));
assertTrue(enabledCapture.carvedCells.contains(cellKey(15, 70, 0)));
}
@Test
public void dimensionFluidHeightCapsWaterIntent() {
public void dimensionFluidHeightCapsFluidIntent() {
Engine engine = createEngine(80, 70);
IrisCaveProfile profile = createWaterProfile().setWaterMinDepthBelowSurface(0);
IrisCaveProfile profile = createFluidProfile().setFluidMinDepthBelowSurface(0);
WriterCapture capture = createWriterCapture(80);
new IrisCaveCarver3D(engine, profile).carve(
@@ -276,36 +287,36 @@ public class IrisCaveCarver3DNearParityTest {
}
@Test
public void floorRequiredWaterResolvesAfterTheCompleteCarveMask() {
public void floorRequiredFluidResolvesAfterTheCompleteCarveMask() {
Engine engine = createEngine(80, 70);
int[] surfaceHeights = filledHeights(70);
int chunkX = -8;
int chunkZ = -1;
IrisStyledRange cupThreshold = new IrisStyledRange(0.15D, 0.15D, new IrisGeneratorStyle(NoiseStyle.FLAT));
IrisCaveProfile supportedProfile = createWaterProfile()
IrisCaveProfile supportedProfile = createFluidProfile()
.setDensityThreshold(cupThreshold)
.setWaterMinDepthBelowSurface(0)
.setWaterRequiresFloor(true);
.setFluidMinDepthBelowSurface(0)
.setFluidRequiresFloor(true);
WriterCapture firstCapture = createWriterCapture(80);
CaveWaterSupportPlan supportPlan = new CaveWaterSupportPlan();
CaveFluidSupportPlan supportPlan = new CaveFluidSupportPlan();
new IrisCaveCarver3D(engine, supportedProfile).carve(
firstCapture.writer, chunkX, chunkZ, fullWeights(), 0D, 0D, null, surfaceHeights, null, supportPlan);
int candidateCount = countLiquid(firstCapture, (byte) 1);
supportPlan.resolve(firstCapture.writer.acquireChunk(chunkX, chunkZ));
IrisCaveProfile repeatedProfile = createWaterProfile()
IrisCaveProfile repeatedProfile = createFluidProfile()
.setDensityThreshold(cupThreshold)
.setWaterMinDepthBelowSurface(0)
.setWaterRequiresFloor(true);
.setFluidMinDepthBelowSurface(0)
.setFluidRequiresFloor(true);
WriterCapture secondCapture = createWriterCapture(80);
new IrisCaveCarver3D(engine, repeatedProfile).carve(
secondCapture.writer, chunkX, chunkZ, fullWeights(), 0D, 0D, null, surfaceHeights);
IrisCaveProfile unrestrictedProfile = createWaterProfile()
IrisCaveProfile unrestrictedProfile = createFluidProfile()
.setDensityThreshold(cupThreshold)
.setWaterMinDepthBelowSurface(0)
.setWaterRequiresFloor(false);
.setFluidMinDepthBelowSurface(0)
.setFluidRequiresFloor(false);
WriterCapture unrestrictedCapture = createWriterCapture(80);
new IrisCaveCarver3D(engine, unrestrictedProfile).carve(
unrestrictedCapture.writer, chunkX, chunkZ, fullWeights(), 0D, 0D, null, surfaceHeights);
@@ -314,23 +325,23 @@ public class IrisCaveCarver3DNearParityTest {
assertEquals(firstCapture.carvedLiquids, secondCapture.carvedLiquids);
assertTrue(countLiquid(firstCapture, (byte) 1) > 0);
assertTrue(countLiquid(firstCapture, (byte) 1) < countLiquid(unrestrictedCapture, (byte) 1));
assertWaterCellsHaveSolidSupport(firstCapture);
assertFluidCellsHaveSolidSupport(firstCapture);
}
@Test
public void finalWaterSupportRejectsUnknownNeighborChunkEdges() {
public void finalFluidSupportRejectsUnknownNeighborChunkEdges() {
WriterCapture capture = createWriterCapture(80);
MantleChunk<Matter> chunk = capture.writer.acquireChunk(0, 0);
MatterSlice<MatterCavern> slice = chunk.getOrCreate(3).slice(MatterCavern.class);
MatterCavern water = new MatterCavern(true, "", (byte) 1);
MatterCavern fluid = new MatterCavern(true, "", (byte) 1);
MatterCavern air = new MatterCavern(true, "", (byte) 0);
int y = 56;
int z = 8;
slice.set(0, y & 15, z, water);
slice.set(8, y & 15, z, water);
CaveWaterSupportPlan supportPlan = new CaveWaterSupportPlan();
supportPlan.add(0, y, z, water, air);
supportPlan.add(8, y, z, water, air);
slice.set(0, y & 15, z, fluid);
slice.set(8, y & 15, z, fluid);
CaveFluidSupportPlan supportPlan = new CaveFluidSupportPlan();
supportPlan.add(0, y, z, fluid, air);
supportPlan.add(8, y, z, fluid, air);
supportPlan.resolve(chunk);
@@ -339,29 +350,29 @@ public class IrisCaveCarver3DNearParityTest {
}
@Test
public void floorRequiredWaterSeesLaterProfileCarvePasses() {
public void floorRequiredFluidSeesLaterProfileCarvePasses() {
Engine engine = createEngine(80, 70);
int[] surfaceHeights = filledHeights(70);
WriterCapture capture = createWriterCapture(80);
CaveWaterSupportPlan waterSupportPlan = new CaveWaterSupportPlan();
IrisCaveProfile waterProfile = createWaterProfile()
CaveFluidSupportPlan fluidSupportPlan = new CaveFluidSupportPlan();
IrisCaveProfile fluidProfile = createFluidProfile()
.setDensityThreshold(new IrisStyledRange(0.15D, 0.15D, new IrisGeneratorStyle(NoiseStyle.FLAT)))
.setWaterRequiresFloor(true);
IrisCaveProfile airProfile = createWaterProfile().setAllowWater(false);
.setFluidRequiresFloor(true);
IrisCaveProfile airProfile = createFluidProfile().setAllowFluid(false);
new IrisCaveCarver3D(engine, waterProfile).carve(
new IrisCaveCarver3D(engine, fluidProfile).carve(
capture.writer, 0, 0, fullWeights(), 0D, 0D, null, surfaceHeights,
new IrisRange(20D, 64D), waterSupportPlan);
String waterCell = firstCellWithLiquid(capture, (byte) 1);
assertTrue(waterCell != null);
int waterY = coordinate(waterCell, 1);
new IrisRange(20D, 64D), fluidSupportPlan);
String fluidCell = firstCellWithLiquid(capture, (byte) 1);
assertTrue(fluidCell != null);
int fluidY = coordinate(fluidCell, 1);
new IrisCaveCarver3D(engine, airProfile).carve(
capture.writer, 0, 0, fullWeights(), 0D, 0D, null, surfaceHeights,
new IrisRange(waterY - 1D, waterY - 1D), waterSupportPlan);
new IrisRange(fluidY - 1D, fluidY - 1D), fluidSupportPlan);
assertEquals(Byte.valueOf((byte) 1), capture.carvedLiquids.get(waterCell));
waterSupportPlan.resolve(capture.writer.acquireChunk(0, 0));
assertEquals(Byte.valueOf((byte) 0), capture.carvedLiquids.get(waterCell));
assertEquals(Byte.valueOf((byte) 1), capture.carvedLiquids.get(fluidCell));
fluidSupportPlan.resolve(capture.writer.acquireChunk(0, 0));
assertEquals(Byte.valueOf((byte) 0), capture.carvedLiquids.get(fluidCell));
}
@Test
@@ -474,7 +485,7 @@ public class IrisCaveCarver3DNearParityTest {
CNG surfaceBreakDensity = (CNG) surfaceBreakDensityField.get(carver);
RNG thresholdRng = (RNG) thresholdRngField.get(carver);
MatterCavern carveAir = (MatterCavern) carveAirField.get(carver);
MatterCavern carveWater = (MatterCavern) carveWaterField.get(carver);
MatterCavern carveFluid = (MatterCavern) carveFluidField.get(carver);
MatterCavern carveLava = (MatterCavern) carveLavaField.get(carver);
MatterCavern carveForcedAir = (MatterCavern) carveForcedAirField.get(carver);
@@ -505,7 +516,7 @@ public class IrisCaveCarver3DNearParityTest {
int[] columnTopY = new int[256];
int[] surfaceBreakFloorY = new int[256];
boolean[] surfaceBreakColumn = new boolean[256];
int[] waterMaxY = new int[256];
int[] fluidMaxY = new int[256];
double[] passThreshold = new double[256];
double[] verticalEdgeFade = computeVerticalEdgeFade(profile, minY, maxY);
MatterCavern[] matterByY = computeMatterByY(engine, profile, carveAir, carveLava, carveForcedAir, minY, maxY);
@@ -528,8 +539,8 @@ public class IrisCaveCarver3DNearParityTest {
boolean breakColumn = allowSurfaceBreak && signed(surfaceBreakDensity.noiseFast2D(x, z)) >= surfaceBreakNoiseThreshold;
int resolvedTopY = breakColumn ? Math.min(maxY, Math.max(minY, columnSurfaceY)) : clearanceTopY;
columnTopY[columnIndex] = resolvedTopY;
waterMaxY[columnIndex] = profile.isAllowWater()
? Math.min(engine.getDimension().getFluidHeight(), columnSurfaceY - Math.max(0, profile.getWaterMinDepthBelowSurface()))
fluidMaxY[columnIndex] = profile.isAllowFluid()
? Math.min(engine.getDimension().getFluidHeight(), columnSurfaceY - Math.max(0, profile.getFluidMinDepthBelowSurface()))
: Integer.MIN_VALUE;
surfaceBreakFloorY[columnIndex] = Math.max(minY, columnSurfaceY - surfaceBreakDepth);
surfaceBreakColumn[columnIndex] = breakColumn;
@@ -576,9 +587,9 @@ public class IrisCaveCarver3DNearParityTest {
MatterSlice<MatterCavern> cavernSlice = sectionMatter.slice(MatterCavern.class);
MatterCavern verticalMatter = matterByY[y - minY];
boolean aquifer = verticalMatter == carveAir
&& y <= waterMaxY[columnIndex]
&& y <= fluidMaxY[columnIndex]
&& (boolean) aquiferCandidateMethod.invoke(carver, x, y, z, localThreshold);
MatterCavern matter = aquifer ? carveWater : verticalMatter;
MatterCavern matter = aquifer ? carveFluid : verticalMatter;
cavernSlice.set(localX, y & 15, localZ, matter);
carved++;
}
@@ -678,9 +689,9 @@ public class IrisCaveCarver3DNearParityTest {
profile.setSurfaceBreakNoiseThreshold(0.16D);
profile.setSurfaceBreakDepth(12);
profile.setSurfaceBreakThresholdBoost(0.17D);
profile.setAllowWater(true);
profile.setWaterMinDepthBelowSurface(8);
profile.setWaterRequiresFloor(false);
profile.setAllowFluid(true);
profile.setFluidMinDepthBelowSurface(8);
profile.setFluidRequiresFloor(false);
profile.setAllowLava(true);
if (modules) {
KList<IrisCaveFieldModule> caveModules = new KList<>();
@@ -705,7 +716,7 @@ public class IrisCaveCarver3DNearParityTest {
return profile;
}
private IrisCaveProfile createWaterProfile() {
private IrisCaveProfile createFluidProfile() {
return createProfile(false, false)
.setVerticalRange(new IrisRange(20D, 70D))
.setVerticalEdgeFade(0)
@@ -716,8 +727,8 @@ public class IrisCaveCarver3DNearParityTest {
.setAllowSurfaceBreak(true)
.setSurfaceBreakNoiseThreshold(-1D)
.setSurfaceBreakThresholdBoost(0D)
.setAllowWater(true)
.setWaterRequiresFloor(false)
.setAllowFluid(true)
.setFluidRequiresFloor(false)
.setAllowLava(true);
}
@@ -803,7 +814,7 @@ public class IrisCaveCarver3DNearParityTest {
return heights;
}
private void assertWaterCellsHaveSolidSupport(WriterCapture capture) {
private void assertFluidCellsHaveSolidSupport(WriterCapture capture) {
for (Map.Entry<String, Byte> entry : capture.carvedLiquids.entrySet()) {
if (entry.getValue() != 1) {
continue;
@@ -835,9 +846,9 @@ public class IrisCaveCarver3DNearParityTest {
}
}
private void assertWaterRespectsSplitCutoff(WriterCapture capture, int lowCutoff, int highCutoff) {
boolean lowWater = false;
boolean highWater = false;
private void assertFluidRespectsSplitCutoff(WriterCapture capture, int lowCutoff, int highCutoff) {
boolean lowFluid = false;
boolean highFluid = false;
for (Map.Entry<String, Byte> entry : capture.carvedLiquids.entrySet()) {
if (entry.getValue() != 1) {
continue;
@@ -846,14 +857,14 @@ public class IrisCaveCarver3DNearParityTest {
int y = coordinate(entry.getKey(), 1);
if (x < 8) {
assertTrue(y <= lowCutoff);
lowWater = true;
lowFluid = true;
} else {
assertTrue(y <= highCutoff);
highWater = true;
highFluid = true;
}
}
assertTrue(lowWater);
assertTrue(highWater);
assertTrue(lowFluid);
assertTrue(highFluid);
}
private void assertLiquidAtOrBelow(WriterCapture capture, byte liquid, int maxY) {
@@ -15,7 +15,7 @@ public class IrisCarveModifierFluidIntentTest {
@Test
public void explicitCavernIntentsOverrideExistingFluid() {
MatterCavern airIntent = new MatterCavern(true, "", (byte) 0);
MatterCavern waterIntent = new MatterCavern(true, "", (byte) 1);
MatterCavern fluidIntent = new MatterCavern(true, "", (byte) 1);
MatterCavern lavaIntent = new MatterCavern(true, "", (byte) 2);
MatterCavern forcedAirIntent = new MatterCavern(true, "", (byte) 3);
PlatformBlockState existingFluid = mock(PlatformBlockState.class);
@@ -26,11 +26,12 @@ public class IrisCarveModifierFluidIntentTest {
assertFalse(IrisCarveModifier.hasExplicitCarveIntent(null));
assertTrue(IrisCarveModifier.shouldPreserveExistingFluid(airIntent, existingFluid));
assertFalse(IrisCarveModifier.shouldPreserveExistingFluid(waterIntent, existingFluid));
assertTrue(IrisCarveModifier.isFluidIntent(fluidIntent));
assertFalse(IrisCarveModifier.shouldPreserveExistingFluid(fluidIntent, existingFluid));
assertFalse(IrisCarveModifier.shouldPreserveExistingFluid(lavaIntent, existingFluid));
assertFalse(IrisCarveModifier.shouldPreserveExistingFluid(forcedAirIntent, existingFluid));
assertNull(IrisCarveModifier.resolveExplicitCarveState(null, fluid, lava, air));
assertSame(fluid, IrisCarveModifier.resolveExplicitCarveState(waterIntent, fluid, lava, air));
assertSame(fluid, IrisCarveModifier.resolveExplicitCarveState(fluidIntent, fluid, lava, air));
assertSame(lava, IrisCarveModifier.resolveExplicitCarveState(lavaIntent, fluid, lava, air));
assertSame(air, IrisCarveModifier.resolveExplicitCarveState(forcedAirIntent, fluid, lava, air));
assertNull(IrisCarveModifier.resolveExplicitCarveState(airIntent, fluid, lava, air));
@@ -10,20 +10,54 @@ import java.util.stream.Collectors;
import static org.junit.Assert.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class IrisDimensionReachableBiomesTest {
@Test
@SuppressWarnings("unchecked")
public void includesOnlyBiomesReachableThroughSelectedRegions() {
public void includesExactRecursiveGenerationClosure() {
IrisDimensionCarvingEntry deepBand = new IrisDimensionCarvingEntry()
.setId("global-deep-band")
.setBiome("deep-root");
IrisDimensionCarvingEntry disabledBand = new IrisDimensionCarvingEntry()
.setId("disabled-band")
.setEnabled(false)
.setBiome("disabled-deep");
IrisDimensionCarvingEntry floatingCarvingEntry = new IrisDimensionCarvingEntry()
.setId("floating-carving-entry")
.setEnabled(false)
.setBiome("entry-floating-carve");
IrisDimension dimension = new IrisDimension().setRegions(new KList<>("reachable", "missing"));
dimension.setCarving(new KList<>(deepBand, disabledBand, floatingCarvingEntry));
IrisRegion reachable = new IrisRegion()
.setLandBiomes(new KList<>("parent", "shared"))
.setSeaBiomes(new KList<>("shared"));
IrisBiome parent = biome("parent").setChildren(new KList<>("child", "shared")).setCarvingBiome("carve");
IrisBiome parent = biome("parent")
.setChildren(new KList<>("child", "shared"))
.setCarvingBiome("carve")
.setFloatingChildBiomes(new KList<>(floating("floating-target", "direct-floating-carve")));
IrisBiome child = biome("child").setChildren(new KList<>("parent"));
IrisBiome shared = biome("shared");
IrisBiome carve = biome("carve");
IrisBiome floatingTarget = biome("floating-target")
.setChildren(new KList<>("floating-child"))
.setCarvingBiome("floating-carve");
IrisBiome floatingChild = biome("floating-child")
.setFloatingChildBiomes(new KList<>(floating("nested-floating", "floating-carving-entry")));
IrisBiome floatingCarve = biome("floating-carve");
IrisBiome directFloatingCarve = biome("direct-floating-carve");
IrisBiome entryFloatingCarve = biome("entry-floating-carve");
IrisBiome shadowedFloatingCarve = biome("floating-carving-entry");
IrisBiome nestedFloating = biome("nested-floating")
.setFloatingChildBiomes(new KList<>(floating("parent")));
IrisBiome deepRoot = biome("deep-root").setChildren(new KList<>("deep-child"));
IrisBiome deepChild = biome("deep-child").setCarvingBiome("deep-carve");
IrisBiome deepCarve = biome("deep-carve")
.setFloatingChildBiomes(new KList<>(floating("deep-floating")));
IrisBiome deepFloating = biome("deep-floating").setChildren(new KList<>("deep-root"));
IrisBiome disabledDeep = biome("disabled-deep");
IrisBiome unused = biome("unused");
IrisData data = mock(IrisData.class);
@@ -36,18 +70,77 @@ public class IrisDimensionReachableBiomesTest {
when(biomeLoader.load("child")).thenReturn(child);
when(biomeLoader.load("shared")).thenReturn(shared);
when(biomeLoader.load("carve")).thenReturn(carve);
when(biomeLoader.load("floating-target")).thenReturn(floatingTarget);
when(biomeLoader.load("floating-child")).thenReturn(floatingChild);
when(biomeLoader.load("floating-carve")).thenReturn(floatingCarve);
when(biomeLoader.load("direct-floating-carve")).thenReturn(directFloatingCarve);
when(biomeLoader.load("entry-floating-carve")).thenReturn(entryFloatingCarve);
when(biomeLoader.load("floating-carving-entry")).thenReturn(shadowedFloatingCarve);
when(biomeLoader.load("nested-floating")).thenReturn(nestedFloating);
when(biomeLoader.load("deep-root")).thenReturn(deepRoot);
when(biomeLoader.load("deep-child")).thenReturn(deepChild);
when(biomeLoader.load("deep-carve")).thenReturn(deepCarve);
when(biomeLoader.load("deep-floating")).thenReturn(deepFloating);
when(biomeLoader.load("disabled-deep")).thenReturn(disabledDeep);
when(biomeLoader.load("unused")).thenReturn(unused);
KList<IrisBiome> biomes = dimension.getReachableBiomes(() -> data);
Set<String> keys = biomes.stream().map(IrisBiome::getLoadKey).collect(Collectors.toSet());
assertEquals(Set.of("parent", "child", "shared", "carve"), keys);
assertEquals(Set.of(
"parent", "child", "shared", "carve",
"floating-target", "floating-child", "floating-carve", "direct-floating-carve",
"entry-floating-carve", "nested-floating",
"deep-root", "deep-child", "deep-carve", "deep-floating"
), keys);
assertEquals(keys.size(), biomes.size());
}
@Test(timeout = 1000L)
@SuppressWarnings("unchecked")
public void terminatesMixedDependencyCyclesWithoutReloadingBiomes() {
IrisDimension dimension = new IrisDimension().setRegions(new KList<>("reachable"));
IrisRegion reachable = new IrisRegion().setLandBiomes(new KList<>("a"));
IrisBiome a = biome("a").setChildren(new KList<>("b"));
IrisBiome b = biome("b").setCarvingBiome("c");
IrisBiome c = biome("c").setFloatingChildBiomes(new KList<>(floating("d", "a")));
IrisBiome d = biome("d")
.setChildren(new KList<>("a"))
.setFloatingChildBiomes(new KList<>(floating("b")));
IrisData data = mock(IrisData.class);
ResourceLoader<IrisRegion> regionLoader = mock(ResourceLoader.class);
ResourceLoader<IrisBiome> biomeLoader = mock(ResourceLoader.class);
when(data.getRegionLoader()).thenReturn(regionLoader);
when(data.getBiomeLoader()).thenReturn(biomeLoader);
when(regionLoader.load("reachable")).thenReturn(reachable);
when(biomeLoader.load("a")).thenReturn(a);
when(biomeLoader.load("b")).thenReturn(b);
when(biomeLoader.load("c")).thenReturn(c);
when(biomeLoader.load("d")).thenReturn(d);
KList<IrisBiome> biomes = dimension.getReachableBiomes(() -> data);
Set<String> keys = biomes.stream().map(IrisBiome::getLoadKey).collect(Collectors.toSet());
assertEquals(Set.of("a", "b", "c", "d"), keys);
assertEquals(keys.size(), biomes.size());
verify(biomeLoader, times(1)).load("a");
verify(biomeLoader, times(1)).load("b");
verify(biomeLoader, times(1)).load("c");
verify(biomeLoader, times(1)).load("d");
}
private IrisBiome biome(String loadKey) {
IrisBiome biome = new IrisBiome();
biome.setLoadKey(loadKey);
return biome;
}
private IrisFloatingChildBiomes floating(String biomeKey) {
return new IrisFloatingChildBiomes().setBiome(biomeKey);
}
private IrisFloatingChildBiomes floating(String biomeKey, String carvingKey) {
return floating(biomeKey).setCarving(carvingKey);
}
}
@@ -45,6 +45,25 @@ public class IrisImportedStructureControlTest {
assertTrue(control.shouldGenerate("minecraft:monument"));
assertEquals(NativeStructureGenerationStatus.GENERATE_NATIVE,
control.resolve("minecraft:monument", false).status());
assertEquals(1D, control.frequencyMultiplier("minecraft:villages"), 0D);
}
@Test
public void frequencyOverrideUsesExactNormalizedSetKeyAndLastEntry() {
KList<IrisStructureSetFrequencyOverride> overrides = new KList<>();
overrides.add(new IrisStructureSetFrequencyOverride()
.setStructureSet("minecraft:nether_complexes")
.setMultiplier(1.05D));
overrides.add(new IrisStructureSetFrequencyOverride()
.setStructureSet(" MINECRAFT:NETHER_COMPLEXES ")
.setMultiplier(1.1D));
IrisImportedStructureControl control = new IrisImportedStructureControl()
.setFrequencyOverrides(overrides);
assertTrue(control.hasFrequencyOverrides());
assertEquals(1.1D, control.frequencyMultiplier("minecraft:nether_complexes"), 0D);
assertEquals(1D, control.frequencyMultiplier("minecraft:nether_fossils"), 0D);
assertEquals(1D, control.frequencyMultiplier("minecraft:nether_complexes_extra"), 0D);
}
@Test
@@ -72,6 +91,30 @@ public class IrisImportedStructureControlTest {
assertTrue(control.shouldGenerate("minecraft:stronghold"));
}
@Test
public void exactBlacklistDoesNotExpandToStructureFamilies() {
IrisImportedStructureControl control = new IrisImportedStructureControl()
.setDisabledExact(keys(" MINECRAFT:RUINED_PORTAL "));
assertFalse(control.shouldGenerate("minecraft:ruined_portal"));
assertEquals(NativeStructureGenerationStatus.DISABLED_BY_PACK,
control.resolve(" MINECRAFT:RUINED_PORTAL ", false).status());
assertTrue(control.shouldGenerate("minecraft:ruined_portal_nether"));
assertEquals(NativeStructureGenerationStatus.GENERATE_NATIVE,
control.resolve("minecraft:ruined_portal_nether", false).status());
}
@Test
public void familyBlacklistRetainsPrefixMatchingBesideExactBlacklist() {
IrisImportedStructureControl control = new IrisImportedStructureControl()
.setDisabled(keys("minecraft:village"))
.setDisabledExact(keys("minecraft:ruined_portal"));
assertFalse(control.shouldGenerate("minecraft:village_plains"));
assertFalse(control.shouldGenerate("minecraft:ruined_portal"));
assertTrue(control.shouldGenerate("minecraft:ruined_portal_nether"));
}
@Test
public void datapackOverridesFalseDoesNotDisableModOrDatapackNamespaces() {
IrisImportedStructureControl control = new IrisImportedStructureControl()
@@ -305,14 +348,23 @@ public class IrisImportedStructureControlTest {
@Test
public void malformedNullPolicyListsFailWithTheirExactField() {
IrisImportedStructureControl nullDisabled = new IrisImportedStructureControl().setDisabled(null);
IrisImportedStructureControl nullDisabledExact = new IrisImportedStructureControl().setDisabledExact(null);
IrisImportedStructureControl nullAdjustments = new IrisImportedStructureControl().setAdjustments(null);
IrisImportedStructureControl nullFrequencyOverrides = new IrisImportedStructureControl()
.setFrequencyOverrides(null);
NullPointerException disabled = assertThrows(NullPointerException.class,
() -> nullDisabled.shouldGenerate("minecraft:monument"));
NullPointerException disabledExact = assertThrows(NullPointerException.class,
() -> nullDisabledExact.shouldGenerate("minecraft:monument"));
NullPointerException adjustments = assertThrows(NullPointerException.class,
() -> nullAdjustments.resolve("minecraft:monument", false));
NullPointerException frequencyOverrides = assertThrows(NullPointerException.class,
() -> nullFrequencyOverrides.frequencyMultiplier("minecraft:villages"));
assertTrue(disabled.getMessage().contains("importedStructures.disabled"));
assertTrue(disabledExact.getMessage().contains("importedStructures.disabledExact"));
assertTrue(adjustments.getMessage().contains("importedStructures.adjustments"));
assertTrue(frequencyOverrides.getMessage().contains("importedStructures.frequencyOverrides"));
}
}
+1 -1
View File
@@ -164,7 +164,7 @@ Iris replaces the chunk generator. Vanilla and mod worldgen only runs where Iris
| Vanilla / mod worldgen | Over Iris terrain | Control |
|---|---|---|
| Structures (vanilla, datapack, mod) | Yes, on by default | `importedStructures.disabled` denies individual keys |
| Structures (vanilla, datapack, mod) | Yes, on by default | `importedStructures.disabled` denies families; `disabledExact` denies one complete key |
| Placed features: ores, trees, plants, springs, geodes | Yes, **off by default** | `importedFeatures.enabled` per dimension, with per-step and per-key filters |
| Carvers (caves, canyons, mod carvers) | Never | No `NoiseGeneratorSettings` for a carver to sample; use pack `caves` / `carvings` |
| Surface builders and surface rules | Never | Iris builds surfaces from pack palettes |
+7 -2
View File
@@ -9,6 +9,7 @@ Use these as entry points; follow the linked guide before running destructive or
| Goal | Bukkit-family | Fabric / Forge / NeoForge | Success check | Detailed guide |
|---|---|---|---|---|
| Create and enter a disposable world | `/iris create tutorial type=overworld seed=1337`, then `/iris tp tutorial` | `/iris create tutorial overworld 1337`, then `/iris tp irisworldgen:tutorial` | World/dimension appears in `/iris worlds` or `/iris world status`; ordinary chunks generate | `02 - Getting Started.md` |
| Replace the vanilla Nether slot | `/iris create world_nether type=underworld seed=1337 overwrite=true`, then restart | Not available | `minecraft:the_nether` loads through Iris and the retained old Nether is removed only after verification | `06 - Worlds & Lifecycle.md` |
| Validate a pack before world creation | `/iris pack validate pack=overworld` | `/iris pack validate overworld` | No blocking validation errors | `25 - Pack Management.md` |
| Open the live authoring pack | `/iris studio open overworld seed=1337` | `/iris studio open overworld 1337` | Transient Studio world opens and a valid save hotloads | `10 - Studio & VSCode Schemas.md` |
| Create an in-game jigsaw project | `/iris jigsaw create overworld village/demo` | Not available; author on Bukkit and copy the saved pack | Owned planar, Iris-native graph is created atomically with six 15×15×15 workcells, one variant per archetype, and seed `1337`; edits then autosave | `21 - Jigsaw Structures.md` |
@@ -28,7 +29,7 @@ If a command fails before doing work, check in this order: platform syntax, perm
- Subcommands and nested groups use method names (or `@Director(name=...)`) and aliases.
- Required parameters appear as positionals; optional parameters with defaults accept `name=value` (or short aliases from `@Param`).
- Help uses Director mini-menu: required shown as `<name>`, optional with default as `[name=default]`.
- Example: `/iris create myworld type=overworld seed=42 main=false`
- Example: `/iris create myworld type=overworld seed=42 main=false overwrite=false`
- Example: `/iris pregen start 5000 world=world center=me gui=true serial=false`
- Contextual params (world, dimension, location) often resolve from the senders current world or look target when omitted.
@@ -69,7 +70,7 @@ Tree feller on mod loaders uses platform permission APIs (`irisworldgen:treefell
| (empty) / help | | Both | `[section]` (modded) | Open help; modded supports section path |
| `version` | | Both | — | Print Iris/platform/Minecraft version and engine count |
| `info` | | **Modded** (see `worlds`) | `[dimension]` | List Iris dimensions and pack details; seed only for gamemasters |
| `create` | `c` | Both | **Bukkit:** `<name> [type=default] [seed=1337] [main=false]` (`type` aliases `dimension`,`pack`). **Modded:** `<name> [pack=overworld] [seed=1337]` | Create Iris world/dimension |
| `create` | `c` | Both | **Bukkit:** `<name> [type=default] [seed=1337] [main=false] [overwrite=false]` (`type` aliases `dimension`,`pack`; `overwrite` alias `force`). **Modded:** `<name> [pack=overworld] [seed=1337]` | Create an Iris world/dimension; Bukkit `overwrite=true` stages an exact slot replacement for restart |
| `teleport` | `tp` | Both | **Bukkit:** `<world> [player=<name>]`. **Modded:** `<dimension> [player]` | Teleport self or named player into Iris world/dimension |
| `evacuate` | | Both | **Bukkit:** `<world>` (player origin). **Modded:** `[dimension]` | Move players out of Iris world to fallback/primary |
| `height` | | Both | — | Print world height (player on Bukkit) |
@@ -101,6 +102,10 @@ Tree feller on mod loaders uses platform permission APIs (`irisworldgen:treefell
---
On Bukkit, `overwrite=true` is deliberately restart-only. The name may resolve to a safe `iris:*` world or exactly the configured main, `_nether`, or `_the_end` alias; arbitrary `minecraft:*` and foreign namespaces are rejected. Iris stages and validates a fresh pack snapshot, compare-and-swaps only that world's `bukkit.yml` generator and seed, and retains the existing dimension folder as a rollback backup until the restarted world proves its Iris identity, pack, dimension, environment, and seed. Multiple distinct slots may be staged before one restart. `main=true` is valid with overwrite only when the name is the configured main-world name. Exact vanilla slots preserve the authoritative seed shared by the existing level, regardless of the supplied `seed`; this keeps Overworld/Nether/End coordinate generation aligned. Use ordinary new-main promotion when a new level seed is required.
---
## Find: `/iris find` (`goto`)
**Origin:** player (Bukkit). **Modded:** gamemaster gate.
+19 -8
View File
@@ -1,6 +1,6 @@
# 06 - Worlds & Lifecycle
Iris manages world identity, storage paths, pack installation, creation, persistence, and removal across Bukkit-family servers and the three mod loaders. Bukkit-managed Iris worlds live under the level root as `dimensions/iris/<key>/` with namespace `iris`; modded dimensions persist through `iris-dimensions.json`. Non-Studio worlds carry a frozen pack at `iris/pack`, while Studio worlds bind the live packs directory.
Iris manages world identity, storage paths, pack installation, creation, persistence, and removal across Bukkit-family servers and the three mod loaders. Bukkit-managed Iris worlds live under the level root as `dimensions/iris/<key>/` with namespace `iris`; modded dimensions persist through `iris-dimensions.json`. Non-Studio worlds carry a frozen pack at `iris/pack`, validated by its exact normalized root rather than the common `pack` folder name, while Studio worlds bind the live packs directory.
See also: `04 - Commands & Permissions.md`, `02 - Getting Started.md`, `05 - Concepts & Pack Layout.md`, `07 - Pregeneration.md`, `10 - Studio & VSCode Schemas.md`, `30 - Platform Differences.md`.
@@ -77,7 +77,7 @@ If the whole registry cannot be parsed during startup, Iris moves it to `iris-di
| Command | Effect |
|---------|--------|
| `/iris create <name> [type=default] [seed=1337] [main=false]` | Create or Folia-stage a managed world |
| `/iris create <name> [type=default] [seed=1337] [main=false] [overwrite=false]` | Create/Folia-stage a managed world, or stage an exact restart replacement |
| `/iris load <name>` / `/iris import <name>` | Load a disk Iris world via reconciler |
| `/iris unload <world>` | Evacuate → unload → close generator |
| `/iris remove <name> [delete=true]` | Unregister / delete managed world |
@@ -91,10 +91,11 @@ Full permission table: `04 - Commands & Permissions.md`.
| Param | Default | Notes |
|-------|---------|-------|
| `name` | required | Becomes `iris:<logical>`; folder must not already exist |
| `name` | required | Normally becomes `iris:<logical>`; with overwrite it may also be the exact configured main, `_nether`, or `_the_end` alias |
| `type` | `default` | Pack/dimension selector: `default``settings.generator.defaultWorldType` (`overworld`); else pack name or `pack:dimensionKey` |
| `seed` | `1337` | World seed |
| `seed` | `1337` | World seed; exact vanilla-slot overwrite preserves the existing level's shared authoritative seed instead |
| `main` | `false` | Schedule main-world promotion on JVM shutdown (Paper path) or promote during Folia staging |
| `overwrite` (`force`) | `false` | Stage a validated exact-slot replacement for the next restart; never deletes a loaded world live |
Create refuses the primary Bukkit thread. Startup datapack validation must be ready and the selected source pack must have a loadable validation result before the lifecycle lease, datapack preparation, dimension folder, pack snapshot, registration, or Bukkit/NMS create path is entered; lifecycle domain `WORLD_MUTATION` / kind `WORLD_CREATE` must then be free or create fails busy.
@@ -103,7 +104,7 @@ Create refuses the primary Bukkit thread. Startup datapack validation must be re
1. Resolve the managed key and dimension without creating the dimension root.
2. Require startup datapack readiness and a loadable validation result for the dimension's owning pack.
3. Ensure datapacks for the dimension types are installed; queue restart if types not yet loaded.
4. Copy the pack into `<world>/iris/pack` (`StudioSVC.installIntoWorld`) — atomic stage → publish; refuses primary thread.
4. Copy the pack into `<world>/iris/pack` (`StudioSVC.installIntoWorld`) — atomic stage → publish; refuses primary thread. Iris invalidates any prior result for that exact root and validates the final published tree before generator creation; failure rolls the publication back.
5. Build `WorldCreator` with Iris generator (`studio=false`).
6. Create the world through `WorldLifecycleService` / NMS async create (timeout 120s; timeout triggers server restart).
7. Register the world in `bukkit.yml` with generator `Iris` dimension key and seed; update the Multiverse link when present.
@@ -116,13 +117,21 @@ Runtime world creation is disabled on Folia. `/iris create` instead:
1. Requires startup datapack readiness and a loadable validation result for the selected pack; refusal leaves no dimension folder or registration.
2. Acquires the `WORLD_CREATE` lease.
3. Installs datapacks if changed.
4. Stages the pack into the managed dimension root via `installIntoWorld`.
4. Stages the pack into the managed dimension root via `installIntoWorld`; the final published snapshot must pass exact-root validation before registration.
5. Registers the world in `bukkit.yml` (`BukkitWorldConfiguration.register`).
6. If `main=true`, promotes main-world files immediately under lease (failure rolls back bukkit.yml + deletes staged folder).
7. Instructs the operator to restart; generation/load happens on next startup.
`WorldLifecycleStaging` holds staged generators/biome providers for the backend that consumes them at load.
## Exact world-slot replacement
`overwrite=true` uses lifecycle kind `WORLD_REPLACE` and always stages for restart on Bukkit-family servers, including Paper and Folia. It accepts safe `iris:*` keys and only the three exact vanilla slots resolved from the configured level name: `minecraft:overworld`, `minecraft:the_nether`, and `minecraft:the_end`. A vanilla slot requires a matching pack environment (`NORMAL`, `NETHER`, or `THE_END`), and Nether/End replacement requires the server's matching allow setting to be enabled; foreign namespaces, other `minecraft:*` keys, path traversal, links, and special filesystem entries fail closed. `main=true` may accompany overwrite only for the configured main-world name. Minecraft stores one authoritative seed for the existing level, so all three exact vanilla slots preserve that loaded primary-world seed and report when it differs from the command's `seed`; changing the level seed remains the ordinary new-main promotion workflow.
The transaction copies and validates a fresh frozen pack under a same-filesystem sibling stage, fingerprints it, journals the original target state and `bukkit.yml` generator/seed, then compare-and-swaps that one configuration entry. Distinct slots can be queued before one restart. During Iris `STARTUP`, before Bukkit loads worlds, each authorized transaction atomically moves the old exact dimension directory to a retained sibling backup and publishes its stage. There is no chunk merge: old region, entity, POI, and Iris data remain only in the backup, while the target starts with the staged pack snapshot.
The backup is deleted only after `WorldLoad` proves the exact namespaced identity, Iris generator, selected dimension, seed, vanilla-slot environment, and unchanged pack fingerprint. A failed runtime check journals rollback, restores the prior `bukkit.yml` generator/seed with compare-and-swap semantics, requests another restart, and restores the retained directory before that restart loads worlds. A crash between either atomic move or journal write is retried idempotently. Conflicting manual configuration, changed staged bytes, unsafe storage, or corrupt journals block Iris world admission and preserve the stage/backup for operator recovery instead of guessing or deleting.
## Studio create
Studio uses `IrisCreator.studio(true)`:
@@ -143,7 +152,7 @@ Studio uses `IrisCreator.studio(true)`:
2. `BukkitWorldReconciler.loadWorld(bukkit.yml, worldKey)`.
3. Reports success, busy, restart-required, or failure.
Load does not re-download packs; the world must already have `iris/pack` content and registration data consistent with Iris. Reconciliation checks startup readiness and the resolved pack before touching `bukkit.yml` or calling a world backend.
Load does not re-download packs; the world must already have `iris/pack` content and registration data consistent with Iris. Reconciliation checks startup readiness, then lazily validates that world's exact snapshot root before touching `bukkit.yml` or calling a world backend. Results are path-scoped, so separate worlds whose snapshot folders are both named `pack` cannot authorize or reject one another.
## Unload
@@ -188,6 +197,8 @@ When create sets `main=true` (non-Folia), a shutdown hook rewrites `server.prope
Promotion requires absent target level folder and refuses symlink world data. Folia with `main=true` performs the same publish during staging instead of deferring to shutdown.
To replace the currently configured main slot in place, name that exact main world and use `overwrite=true`; this keeps the top-level level root, shared datapacks, player data, and non-target dimensions intact. Ordinary `main=true` without overwrite remains the new-level-root promotion workflow above.
## Pack snapshot vs studio (lifecycle view)
| Operation | Pack effect |
@@ -200,4 +211,4 @@ Promotion requires absent target level folder and refuses symlink world data. Fo
## Concurrent lifecycle guards
`LifecycleOperationCoordinator` serializes domains including `WORLD_MUTATION` and `PACK_MUTATION`. Overlapping create/load/unload/remove/pack-publish returns busy to the operator. World create also refuses if the dimension root already exists or the world is already loaded.
`LifecycleOperationCoordinator` serializes domains including `WORLD_MUTATION` and `PACK_MUTATION`. Overlapping create/load/unload/remove/replace/pack-publish returns busy to the operator. Ordinary world create refuses if the dimension root already exists or the world is already loaded; exact replacement uses a separately journaled restart transaction and never relaxes removal-path protection.
+3 -3
View File
@@ -82,7 +82,7 @@ If mode construction fails, the engine logs a warning and falls back to `OVERWOR
| `dimensionHeight` | `IrisRange` | min `-64`, max `320` | World min/max Y. Iris generates internal height `max - min`, then shifts by min on output |
| `fluidHeight` | int | `63` | Required; 01024. Fluid column top in **internal** Y (0 = bottom of dimension height). World Y ≈ `fluidHeight + dimensionHeight.min` |
| `environment` | `IrisEnvironment` | `NORMAL` | `NORMAL`, `NETHER`, `THE_END`, `CUSTOM` — selects base datapack dimension template (overworld/nether/end) |
| `fullbright` | boolean | `false` | Forces maximum ambient lighting when true |
| `fullbright` | boolean | `false` | Forces maximum ambient lighting; on Minecraft 26.2 this emits a white ambient-light color as well as scalar ambient light |
| `bedrock` | boolean | `true` | Places bedrock at internal Y 0 when true |
| `caveLavaHeight` | int | `8` | Subterrain fluid layer height (0318) |
@@ -127,7 +127,7 @@ If mode construction fails, the engine logs a warning and falls back to `OVERWOR
| Field | Type | Default | Notes |
|-------|------|---------|-------|
| `rockPalette` | `IrisMaterialPalette` | stone | Subsurface “stone” fill palette |
| `fluidPalette` | `IrisMaterialPalette` | water | Fluid block palette |
| `fluidPalette` | `IrisMaterialPalette` | water | Ocean columns and `allowFluid` cave aquifers; accepts any weighted block palette |
| `rockZoom` | double | `5` | Rock palette noise zoom |
| `ores` | `IrisOreGenerator[]` | empty | Dimension-wide ore generators (surface vs underground via generator flags) |
| `deposits` | `IrisDepositGenerator[]` | empty | Global deposit blobs |
@@ -208,7 +208,7 @@ Tri-state fields use `DEFAULT` | `TRUE` | `FALSE` (follow base dimension when `D
| `skylight` | `DEFAULT` | Has skylight |
| `ceiling` | `DEFAULT` | Logical bedrock ceiling |
| `coordinateScale` | `-1` (unset) | Portal scale |
| `ambientLight` | `-1` (unset) | 01 ambient |
| `ambientLight` | `-1` (unset) | 01 ambient; a resolved value of `1` emits white ambient-light color on Minecraft 26.2 |
| `fixedTime` | `-1` sentinel | Fixed day time when set |
| `cloudHeight` | `-1` sentinel | Cloud Y or null to disable |
| `monsterSpawnBlockLightLimit` | `-1` (unset) | 015 |
+4
View File
@@ -150,6 +150,8 @@ Type: `IrisBiomeCustom` (`@Snippet("custom-biome")`). Installed via datapack com
| `grassColor` | hex string | `""` (omit if empty) | |
| `foliageColor` | hex string | `""` | |
On Minecraft 26.2, Iris publishes sky, fog, water-fog, and ambient-particle values through the biome environment-attribute registry. Water, grass, and foliage colors remain biome effects. This conversion is automatic; pack fields do not change.
Tag inheritance: effective tags = authored `tags` plus non-structure tags of the vanilla derivative. Structure tags (`has_structure/*`) are **not** inherited so native structures are not double-placed.
#### Custom spawn entry (`IrisBiomeCustomSpawn`)
@@ -202,6 +204,8 @@ A surface biome contributes all of its `structures[]` placements when it owns th
`floatingChildBiomes` builds floating terrain above columns owned by the parent biome. Each entry can reuse the parent or reference another biome for its generators, layers, derivative, decorators, and surface objects. With `mergeFloatingChildBiomes: false` (default), `pickerStyle` and `rarity` select one entry per column; with it true, every entry samples independently and islands may overlap.
Biome reachability follows configured region roots, enabled dimension-carving biomes, ordinary children and carving replacements, floating targets, and floating `carving` references recursively. Floating carving-entry ids resolve before direct biome keys, matching generation; cycles are deduplicated, and every generation-reachable biome participates in runtime spawn, placement, structure, and lookup indexes. Custom-biome datapack installation continues to scan the pack's complete authored biome set.
### Target, footprint, and altitude
| Field | Default / range | Behavior |
+22 -18
View File
@@ -1,6 +1,6 @@
# 15 - Caves & Carving
Iris carves caves itself during mantle generation via `MantleCarvingComponent` and `IrisCaveCarver3D`. Density fields from `IrisCaveProfile` decide solid vs air/water/lava. Cave biomes paint floors, ceilings, decorators, and objects inside carved space. Vanilla and mod noise carvers never run over Iris terrain.
Iris carves caves itself during mantle generation via `MantleCarvingComponent` and `IrisCaveCarver3D`. Density fields from `IrisCaveProfile` decide solid vs air, the dimension's configured fluid palette, or deep lava. Cave biomes paint floors, ceilings, decorators, and objects inside carved space. Vanilla and mod noise carvers never run over Iris terrain.
Related: `11 - Dimensions.md`, `12 - Regions.md`, `13 - Biomes.md`, `14 - Generators & Noise.md`, `16 - Surfaces, Decorators & Deposits.md`, `17 - Trees, Fungi, Coral, Crystals, Formations, Ruins.md`, `20 - Object Placement.md`, `22 - Native Structures & Datapacks.md`.
@@ -15,7 +15,7 @@ Start with a validating `OVERWORLD` pack whose surface and fluid height are alre
"enabled": true,
"verticalRange": { "min": 0, "max": 64 },
"allowSurfaceBreak": false,
"allowWater": false,
"allowFluid": false,
"allowLava": false
}
}
@@ -23,9 +23,9 @@ Start with a validating `OVERWORLD` pack whose surface and fluid height are alre
1. Record seed `1337` and surface coordinates in a Studio world before enabling the profile.
2. Add the fields above to the existing dimension JSON, validate the pack, and reopen Studio if the change is not accepted by the running engine.
3. Generate new chunks and inspect below the surface. Success is carved air within the configured vertical range, an intact surface, and no water or lava placed by the cave profile.
3. Generate new chunks and inspect below the surface. Success is carved air within the configured vertical range, an intact surface, and no fluid-palette blocks or deep lava placed by the cave profile.
4. If no caves appear, confirm dimension `mode.type` is `OVERWORLD`, `useMantle` and `carvingEnabled` are true, and the effective biome or region profile is not overriding this dimension profile. Test only fresh chunks.
5. Once the void shape is proven, add one biome key to a region's `caveBiomes`, then add cave layers and decorators. Enable water, lava, or surface breaks one setting at a time so each change remains observable.
5. Once the void shape is proven, add one biome key to a region's `caveBiomes`, then add cave layers and decorators. Enable dimension fluid, deep lava, or surface breaks one setting at a time so each change remains observable.
## Architecture (author-relevant)
@@ -33,7 +33,9 @@ Start with a validating `OVERWORLD` pack whose surface and fluid height are alre
2. Per column, Iris resolves a cave profile from biome → region → dimension (`enabled` profiles only).
3. Profiles blend across neighbors; `IrisCaveCarver3D` samples 3D density and writes carve flags into the mantle.
4. Cave biomes (region `caveBiomes`, dimension `carving` Y-band overrides, surface biome `carvingBiome`) supply materials and content for carved voxels.
5. Fluid placement inside caves follows profile water/lava rules and surface-clearance guards.
5. Aquifers sample the dimension `fluidPalette` below `fluidHeight`; deep lava remains a separate profile rule controlled by `allowLava` and `caveLavaHeight`.
Enabled dimension `carving` biome graphs are included in the dimension's recursive reachable-biome closure even when no region lists them, so their custom biome identities and spawn mappings are available wherever the Y-band selects them.
Empty pack folders such as `caves/` or `ravines/` are not separate registrant types. Carving is profile-driven JSON on dimensions/biomes/regions, not standalone cave files.
@@ -56,7 +58,7 @@ Iris does not implement Minecraft `NoiseGeneratorSettings` carver sampling. Gene
## Cave profile (`IrisCaveProfile`)
Snippet key: `cave-profile`. Appears on **dimension**, **region**, and **biome**. Resolution prefers the most specific enabled profile in the mantle path (biome/region/dimension blend).
Snippet key: `cave-profile`. Appears on **dimension**, **region**, and **biome**. Resolution prefers the most specific enabled profile in the mantle path (biome/region/dimension blend). The dimension-level `fluidPalette` supplies aquifer material and accepts any weighted block palette; its default is water. A lava `fluidPalette` therefore turns otherwise identical Overworld aquifers into lava without changing cave geometry.
| Field | Type | Default | Notes |
|-------|------|---------|-------|
@@ -88,10 +90,12 @@ Snippet key: `cave-profile`. Appears on **dimension**, **region**, and **biome**
| `defaultObjectPlaceMode` | `ObjectPlaceMode` | null | Prefer stilt modes for cave props |
| `anchorScanStep` | int 1..8 | `1` | Vertical anchor search step |
| `anchorSearchAttempts` | int 1..64 | `6` | Random column retries per chunk |
| `allowWater` | boolean | `true` | Cave water below fluid height |
| `waterMinDepthBelowSurface` | int 0..64 | `12` | Depth before cave water |
| `waterRequiresFloor` | boolean | `true` | Solid floor under water |
| `allowLava` | boolean | `true` | Cave lava by lava height rules |
| `allowFluid` | boolean | `true` | Place dimension `fluidPalette` aquifers below fluid height |
| `fluidMinDepthBelowSurface` | int 0..64 | `12` | Minimum surface burial before aquifer placement |
| `fluidRequiresFloor` | boolean | `true` | Require a supported cup beneath aquifer blocks |
| `allowLava` | boolean | `true` | Place vanilla lava at or below `caveLavaHeight` |
`allowWater`, `waterMinDepthBelowSurface`, and `waterRequiresFloor` were removed. Pack validation rejects them with their replacement names so an old dry-cave setting cannot silently fall back to the new `allowFluid: true` default.
### Density module (`IrisCaveFieldModule`)
@@ -180,7 +184,7 @@ Editable Iris jigsaws can resolve starts against the carved-space mantle instead
| `caveAnchorAttempts` | `8` | Deterministic unique X/Z columns tested inside the selected start chunk; runtime clamps to `1..64` |
| `caveAnchorScanStep` | `1` | Vertical scan increment; runtime clamps to `1..16`; values above one can skip valid one-block anchors |
| `caveMinimumClearance` | `3` | Required contiguous vertical carved run; runtime clamps to `1..64` |
| `underwater` | `false` | For cave anchors, require a dry cavern cell: ordinary cavern air must be above `caveLavaHeight`, explicit water/lava is rejected, and forced-air cavern matter remains dry below that threshold; `true` permits fluid cavern cells |
| `underwater` | `false` | For cave anchors, require a dry cavern cell: ordinary cavern air must be above `caveLavaHeight`, explicit palette-fluid/deep-lava cells are rejected, and forced-air cavern matter remains dry below that threshold; `true` permits fluid cavern cells |
Geometry and alignment are exact:
@@ -193,7 +197,7 @@ Geometry and alignment are exact:
Selection is deterministic for the world seed, placement identity, and start chunk. Iris visits at most 64 unique columns from the chunk's 256 columns, stops at the first column with matches, and chooses deterministically among every valid anchor found in that column. When no candidate passes, the placement is skipped; Iris does not fall back to a surface or height-band start.
The cave-anchor `underwater` gate reads `MatterCavern` at the actual anchor, not ocean height at the surface. A null or non-cavern cell is never an anchor. With `underwater: false`, explicit cave water/lava and ordinary cavern air at or below the dimension's `caveLavaHeight` are rejected, while forced-air cavern matter is accepted even below that threshold. With `underwater: true`, fluid cavern cells are allowed but the cell must still be carved cavern matter.
The cave-anchor `underwater` gate reads `MatterCavern` at the actual anchor, not ocean height at the surface. A null or non-cavern cell is never an anchor. With `underwater: false`, explicit palette-fluid/deep-lava cells and ordinary cavern air at or below the dimension's `caveLavaHeight` are rejected, while forced-air cavern matter is accepted even below that threshold. With `underwater: true`, fluid cavern cells are allowed but the cell must still be carved cavern matter.
The test reads one vertical `MatterCavern` column. It proves local clearance only, not that the complete assembled footprint fits the cave. `SOURCE` and `PRESERVE` can therefore leave pieces intersecting surrounding walls. Use `BORE` or `FORCE_CARVE` when the structure must make room, or inspect the complete volume in gameplay when preserving the cavern.
@@ -236,9 +240,9 @@ Dimension switch and deepdark band (`dimensions/overworld.json`):
"defaultObjectAnchor": "FLOOR",
"defaultObjectPlaceMode": "ORGANIC_STILT",
"anchorSearchAttempts": 12,
"allowWater": true,
"waterMinDepthBelowSurface": 20,
"waterRequiresFloor": true,
"allowFluid": true,
"fluidMinDepthBelowSurface": 20,
"fluidRequiresFloor": true,
"allowLava": true,
"modules": [
{
@@ -283,7 +287,7 @@ Cave biome content (`biomes/carving/amethyst.json` excerpt): floor/wall amethyst
1. Record a fixed seed and coordinates where the surface, fluid level, and bedrock are already correct.
2. Enable the dimension `caveProfile` with a narrow vertical range inside playable Y and no cave-biome decoration yet.
3. Add one tunnel or room module. Generate new chunks and verify void shape, surface clearance, water handling, and lava depth.
3. Add one tunnel or room module. Generate new chunks and verify void shape, surface clearance, fluid-palette handling, and lava depth.
4. Add modules for other shapes instead of raising `detailWeight` alone. Change one density or threshold value per comparison.
5. List one themed biome under one region's `caveBiomes`; paint its floor, ceiling, and walls before adding objects.
6. Add cave-only objects with `carvingSupport: CARVING_ONLY` and an appropriate stilt mode so props do not float.
@@ -298,7 +302,7 @@ Cave biome content (`biomes/carving/amethyst.json` excerpt): floor/wall amethyst
| Thinner tunnels | Raise threshold, lower `detailWeight`, add inverted modules |
| Fewer surface holes | Raise `surfaceBreakNoiseThreshold`, lower `surfaceBreakDepth`, or `allowSurfaceBreak: false` |
| Safer cave props | Raise `objectMinDepthBelowSurface`, set place mode + anchor |
| Dry caves | `allowWater: false` |
| No aquifers | `allowFluid: false` |
| Performance | Higher `sampleStep`, keep adaptive sampling on, simpler styles |
## Practical notes
@@ -306,4 +310,4 @@ Cave biome content (`biomes/carving/amethyst.json` excerpt): floor/wall amethyst
- Profile `enabled: false` (the Java default) produces no profile carving even if cave biomes are listed.
- Cave biome layers still need solid carve first; they do not create voids alone.
- Upper-dimension carving is optional and off in overworld.
- Pack JSON may contain unknown keys; only fields on `IrisCaveProfile` apply.
- The three removed water-specific cave-profile keys are blocking pack errors in inline dimension, region, and biome profiles and in `snippet/cave-profile` files; other unknown keys remain subject to the normal pack validation rules.
+1 -1
View File
@@ -97,7 +97,7 @@ Dimension defaults:
| Field | Default | Role |
|-------|---------|------|
| `rockPalette` | stone | Fill below biome layers |
| `fluidPalette` | water | Ocean/fluid column |
| `fluidPalette` | water | Ocean/fluid columns and `allowFluid` cave aquifers |
| `rockZoom` | `5` | Rock palette zoom |
`IrisBlockData` entries use `block` (id), optional `weight`, optional `data` blockstate map. They can also reference reusable block aliases as described below.
+1
View File
@@ -44,6 +44,7 @@ Generated by Minecraft's machinery with full native fidelity (processors, entiti
| One authored graph that must also ship as a vanilla 26.2 datapack | `VANILLA_PORTABLE` Iris jigsaw, then strict `/iris jigsaw export` |
| Move/stilt/encase vanilla structures for Iris terrain | `importedStructures.adjustments` |
| Remove vanilla villages or other families | `importedStructures.disabled` |
| Remove one native key without removing its variants | `importedStructures.disabledExact` |
| Datapack structures generating natively | `datapackImports` + ingest |
| Datapack structures only where you choose | Disable namespace + `nativeStructures` placement |
| Replace a vanilla structure with Iris-positioned native starts | Dimension placement with `nativeSuppression: REPLACE_SOURCE` |
+1 -1
View File
@@ -578,7 +578,7 @@ Run this in a purpose-named disposable pack/world and record each gate separatel
1. **Creation:** create a planar `IRIS_EXTENDED` project without optional mode, compatibility, dimensions, or seed. Confirm planar/Iris/15×15×15/1337 defaults, one structure, three pools, six pieces, six objects, one ownership manifest, tab completion of its key for `open`/`edit`/`reopen`, and no partial files after a duplicate-create rejection.
2. **Default catalog:** confirm all six workcells have one loaded owned variant, `variant-1` is the selected theme family, End is terminal, and mandatory caps are initially off.
3. **Workcell layout:** verify Blank/End Cap/Hallway then L Junction/T Junction/Cross Junction, one clear block between capacity rows and columns, light-gray floors, red canonical glyphs, sea-lantern endpoints, and no orientation/permutation gallery.
4. **Controls and context:** confirm every untouched workcell starts **Autosaved**. Walk outside and into End Cap; verify the Iris scoreboard context and `Triple-sneak for controls`, then open the menu and confirm End Cap is selected. Rename its workcell and active variant sticks in an anvil, apply them, verify the scoreboard shows the author names plus canonical role, then reset both labels.
4. **Controls and context:** confirm every untouched workcell starts **Autosaved**. Walk outside and into End Cap; verify the Iris scoreboard context and `Triple-sneak for controls`, then open the menu through the control chest, `/iris jigsaw menu`, and triple-sneak and confirm End Cap is selected each time without an inventory-view linkage error. Rename its workcell and active variant sticks in an anvil, apply them, verify the scoreboard shows the author names plus canonical role, then reset both labels.
5. **Autosave:** change a solid block, a marker field, and container contents. Immediately click **Duplicate This Cell's Variant**; confirm autosave is expedited and the duplicate runs once automatically without a wait/retry instruction. Repeat with edits in multiple enabled cells and **Duplicate All Enabled Cells as Family**. Wait for the final clean state, reopen Studio, and verify all authored changes plus both clone operations round-trip.
6. **Capacity and independent sizes:** stage Hallway capacity `16×3×3` in the open Workcell Settings menu, apply it once, and make another workcell capacity `16×8×16`; confirm no existing object byte changes and the live relayout moves only the white-concrete cages without close/reopen. In the larger workcell, resize one variant to `16×3×16` and another to `3×3×3`; confirm exact independent dimensions, live reload of the loaded variant, and unchanged siblings. Confirm cropped authored content, connector collision, and shared/read-only objects each reject the single-variant resize without writes.
7. **Disable:** disable Tee, confirm its white-concrete cage remains while the GUI and scoreboard report Disabled, and confirm seed-`1337` evaluation excludes Tee pieces. Re-enable it and confirm participation returns; test export filtering separately on the portable fixture.
+33 -6
View File
@@ -148,9 +148,11 @@ A datapack structure whose filter lists only its own biomes never generates unti
| Field | Default | Meaning |
|---|---|---|
| `disabled` | `[]` | Structure keys/prefixes to deny. |
| `disabledExact` | `[]` | Complete structure keys to deny without matching related variants. |
| `undergroundYShift` | `0` (-512..512) | Vertical offset for underground-step structures only. Surface structures never use it. |
| `datapackOverrides` | `true` | Whether ingested datapacks may replace `minecraft:`-namespaced structure content (2.5). |
| `adjustments` | `[]` | Per-structure adjustments for structures still generating natively (1.4). |
| `frequencyOverrides` | `[]` | Exact structure-set placement-density multipliers (1.4). |
| `adjustments` | `[]` | Per-structure adjustments for structures still generating natively (1.5). |
#### Prefix matching
@@ -162,15 +164,38 @@ Used by `disabled` and `adjustments[].match`. Both sides trimmed and lowercased;
`"minecraft:village"` matches village variants; `"nova_structures"` without trailing colon does **not** match the namespace.
`disabledExact` trims and lowercases each complete key, then compares for equality only. For example, `"minecraft:ruined_portal"` there disables the Overworld variant while leaving `"minecraft:ruined_portal_nether"` enabled. Use `disabled` when the whole family should be denied.
```json
{
"importedStructures": {
"disabled": ["minecraft:village", "minecraft:pillager_outpost"]
"disabled": ["minecraft:village", "minecraft:pillager_outpost"],
"disabledExact": ["minecraft:ruined_portal"]
}
}
```
### 1.4 `adjustments[]`
### 1.4 `frequencyOverrides[]`
Use this to make a registered native structure set more or less common without converting its structures to explicit Iris placements. Each entry is `{ "structureSet": "namespace:path", "multiplier": 0.01..16 }`; `structureSet` is an exact registered **structure-set key**, not a structure key, and the last normalized duplicate wins. Bukkit/Paper, Fabric, Forge, and NeoForge apply the same dimension-scoped contract to newly generated chunks.
```json
{
"importedStructures": {
"frequencyOverrides": [
{ "structureSet": "minecraft:nether_complexes", "multiplier": 1.1 },
{ "structureSet": "minecraft:ruined_portals", "multiplier": 1.1 },
{ "structureSet": "minecraft:nether_fossils", "multiplier": 1.1 }
]
}
}
```
Iris retains the registered set entries, weights, biome eligibility, placement algorithm, salt, exclusion zones, structure start/Y logic, processors, entities, mobs, loot, and native locate path. For random-spread placement, it first scales Minecraft's placement probability up to `1`, then derives the nearest integer spacing with `round(oldSpacing / sqrt(remainingMultiplier))`, never below `separation + 1`. Integer rounding means the realized increase can be slightly lower or higher: at `1.1`, Nether complexes move from spacing `27` to `26` (about `7.8%` denser), ruined portals from `40` to `38` (about `10.8%` denser), and Nether fossils remain `2/1` because no smaller legal spacing exists.
Concentric-ring sets can scale only their placement probability; a ring placement already at probability `1` cannot become denser through this field. Minecraft or modded custom placement types outside the affected override and exclusion-zone graph remain untouched. An exact override, or an exclusion dependency on an overridden set, that requires copying an unsupported placement fails world binding instead of silently leaving stale exclusion behavior. Existing chunks and existing starts are never rewritten.
### 1.5 `adjustments[]`
Each entry (`match` selects targets by the same prefix rule):
@@ -295,6 +320,8 @@ Installed datapacks are real Minecraft datapacks at `<level root>/datapacks/<id>
Ingest and recovery run synchronously in Iris's startup admission gate when `general.autoIngestDatapacks` is enabled (default true); players and every Iris world/Studio creation path remain locked until that phase is valid. A persisted manifest/configuration/content fingerprint lets an unchanged boot skip remote resolution and full revalidation, and Iris refreshes that fingerprint after its own authorized post-start import maintenance; URL, Minecraft/Iris version, override policy, external manifest edits, staging, transaction, installed content, or cache corruption still invalidates reuse and runs the full fail-closed path. Minecraft builds worldgen registries at server start, so a **newly installed or repaired** datapack requires a clean restart before admission; after it returns, keys are live only in the per-world structure state of declaring Iris dimensions.
Cache reuse is a local validation decision and does not poll remote sources; run `/iris datapack ingest` when you want an update check. Every successful ingest persists fresh staging and installed-target receipts, so unchanged bootstrap recovery leaves the manifest stable and the next startup can reuse the cached fingerprint.
Scratch validation rejects links, junction-like special files, and real cross-volume entries. On Windows/Java 25, Iris also verifies the drive root and volume serial when the JDK reports unequal `FileStore` identities only because a path crossed the legacy 247-character prefix boundary; unresolved cleanup, identity, transaction, or validation failures remain blocking and create no world artifacts.
### 2.3 Manual commands
@@ -332,7 +359,7 @@ Scratch validation rejects links, junction-like special files, and real cross-vo
}
```
**(c) Manual placement only.** Disable the datapack namespace, then place specific keys with `nativeStructures` — see **`disabled` never blocks an explicit placement** below:
**(c) Manual placement only.** Disable the datapack namespace, then place specific keys with `nativeStructures` — see **`disabled` and `disabledExact` never block an explicit placement** below:
```json
{
@@ -377,9 +404,9 @@ Placement grid fields (`distribution`, `spacing`/`separation`/`salt`, `density`,
Scoping matches Iris placements. Validation requires the structure's effective assembly span stay inside Minecraft's 128-block (8-chunk) structure reference range.
### 3.2 `disabled` never blocks an explicit placement
### 3.2 `disabled` and `disabledExact` never block an explicit placement
The placement injector generates planned starts without consulting `disabled` and bypasses the structure's own biome filter. "Disable namespace, re-place explicitly" is supported.
The placement injector generates planned starts without consulting either deny list and bypasses the structure's own biome filter. Both "disable namespace, re-place explicitly" and exact-key denial with explicit replacement are supported.
### 3.3 `nativeSuppression: REPLACE_SOURCE`
+1 -1
View File
@@ -143,7 +143,7 @@ Implementation:
1. Requires `confirm=true`.
2. Optional `StudioSVC.downloadSearch` when `fresh-download`.
3. Acquires `PACK_MUTATION` / `PACK_PUBLISH` lease.
4. `StudioSVC.replaceIntoWorld` → install into `worldFolder/iris/pack` with `replaceExisting=true` (atomic stage/publish).
4. `StudioSVC.replaceIntoWorld` → install into `worldFolder/iris/pack` with `replaceExisting=true` (atomic stage/publish), invalidate the previous exact-root validation result, and validate the final published snapshot; validation failure rolls the replacement back.
5. If an engine still holds that pack data, Iris **restarts the server** after commit (`"An active Iris world pack was replaced."`).
This is intentionally unsafe for production without backups: existing chunks keep old terrain; only future generation and pack-driven systems see new content. Prefer staging a new world when pack contracts change.
+3 -2
View File
@@ -41,10 +41,10 @@ Hotload: Bukkit file-watch engine; modded 3s poll. Same invalidate/reload/locale
| Concern | Bukkit | Modded |
|---------|--------|--------|
| Create | `/iris create` → managed world name, generator Iris, optional main-world | `/iris create` or `/iris world enable` → dimension id + pack injection |
| Create | `/iris create` → managed world name, generator Iris, optional main-world; `overwrite=true` stages exact Iris/vanilla-slot replacement for restart | `/iris create` or `/iris world enable` → dimension id + pack injection |
| Load / unload | `/iris load` (`import`), `/iris unload` | `/iris world disable` unloads; no separate load command |
| Remove / delete | `/iris remove` optional folder delete | `/iris world delete` wipes chunk/mantle data |
| Primary / main world | create `main=true` and Bukkit yml registration paths | `modded.json` primary + `routePlayersToPrimaryWorld`; `/iris world mainworld`, `replace-overworld` |
| Primary / main world | create `main=true` for a new level root, or name the configured main with `overwrite=true` for journaled in-place dimension replacement | `modded.json` primary + `routePlayersToPrimaryWorld`; `/iris world mainworld`, `replace-overworld` |
| Evacuate | `/iris evacuate <world>` | `/iris evacuate [dimension]` → primary/overworld fallback |
| Studio world | Transient studio world via StudioSVC; `/iris jigsaw` can select the Jigsaw Studio generator for one activation | Studio dimension under `irisworldgen:studio_*`; no Jigsaw Studio authoring command tree |
| Folia | Regionized schedulers; pregen `runtimeSchedulerMode` forces `FOLIA` when regionized | N/A (not Bukkit Folia) |
@@ -76,6 +76,7 @@ Jigsaw pack resources are shared runtime data, but in-game Jigsaw Studio is not
| Jigsaw Studio create/grid/marker capture/rules/export | yes | no | no | no |
| Saved planar/spatial Iris jigsaw runtime | yes | yes | yes | yes |
| Pack validate / cleanup / download | yes | yes | yes | yes |
| Exact restart replacement of configured Overworld/Nether/End slots | yes | no | no | no |
| Pregen | yes (Paper-like / Folia modes) | yes (`moddedPregenInFlight`) | yes | yes |
| Studio open/close/vscode/package | yes | yes | yes | yes |
| Object wand / paste / save / undo | yes | yes | yes | yes |
+13 -2
View File
@@ -36,6 +36,17 @@ GoldenHash details and file layout: `32 - Determinism & Goldenhash.md`.
4. Join or teleport into the world. Confirm non-empty terrain, surface biomes, and no repeating console stack traces on first chunks.
5. Gate: world is loaded as an Iris world; chunks generate without enable-time crash; console shows no fatal engine init failure.
## A.1 Exact vanilla-slot replacement (Bukkit-family)
Use a disposable server whose configured level name is `world`, with a valid `NETHER` Iris pack and a generated vanilla Nether containing a unique marker chunk. Record hashes of the old Nether `region`, `entities`, and `poi` files before staging.
1. Run `/iris create world_nether type=<nether-pack> seed=1337 overwrite=true`. Gate: the command says the replacement is staged, the loaded Nether and its files remain unchanged, `bukkit.yml` now names `Iris:<dimension>`, and one pending replacement journal plus one sibling stage exists.
2. Optionally stage the configured main name with a `NORMAL` pack and the End alias with a `THE_END` pack. Gate: each distinct slot gets its own transaction and no live dimension folder is moved.
3. Restart normally. Gate: Iris publishes before Bukkit world loading; `minecraft:the_nether` loads with the Iris generator, requested dimension and seed; its frozen `iris/pack` exists; no old `region`, `entities`, or `poi` file was merged into the target; and the marker chunk is absent.
4. Gate after `WorldLoad`: the retained sibling backup and journal disappear only after identity, environment, seed, dimension, and pack-fingerprint verification succeeds.
5. Restart again and generate fresh Nether chunks. Gate: the exact vanilla identity and Iris generator persist, ordinary Nether portals still target `minecraft:the_nether`, and no pending stage/backup/journal returns.
6. Repeat once with a deliberately changed staged pack or conflicting `bukkit.yml` value before restart. Gate: Iris refuses publication or world admission, preserves recoverable artifacts, and never guesses a target. For a post-publication verification failure, gate that Iris restores the prior configuration, requests the controlled rollback restart, and restores the retained original directory before world load.
## B. Fresh install and first world (Fabric / Forge / NeoForge)
1. Install the matching mod jar into `mods/`. Fabric requires Loader ≥ declared floor; Forge/NeoForge require their declared floors. See `01 - Installation & Platforms.md` and `30 - Platform Differences.md`.
@@ -61,7 +72,7 @@ Or a single pack: `/iris pack validate pack=<pack>` on Bukkit, `/iris pack valid
2. Review blocking errors vs warnings. Blocking errors must be fixed before treating the pack as production-ready.
3. `/iris pack status` replays the startup-published result, including a persisted result reused for unchanged content.
4. Restart without changing packs or registry context. Gate: startup logs persisted validation reuse instead of full parsing, player admission opens only after datapack and pack phases are ready, and the target remains loadable.
4. Restart without changing packs or registry context. Gate: startup logs `External datapacks match the persisted startup validation` without logging another external-datapack `Validating` or `Ingesting` pass, pack validation reuses its persisted result instead of full parsing, player admission opens only after both phases are ready, and the target remains loadable.
5. Change one pack byte and restart. Gate: the content fingerprint invalidates reuse and validation runs again; restore the pack before continuing. Cleanup/restore flows are separate and opt-in (`25 - Pack Management.md`).
## D. Bukkit datapack dimension-scope smoke
@@ -185,7 +196,7 @@ Use a disposable pack/structure key and the owning builder account. Bukkit has o
Gate: every cell has one physical white-concrete edge cage, no workcell-bound display entity exists, and focused plus nearby particle trails outline the editable bounds inside those cages. Focused connectors draw 1.75-block direction lines when particles are enabled. The Iris scoreboard replaces the general Studio context with Structure, Workcell, Variant, State, and `Triple-sneak for controls`, without orientation/mask fields. All six untouched cells initially report **Autosaved**. Enter End Cap, triple-sneak, and confirm the menu selects End Cap rather than the previously selected cell.
3. Open the same six-row controls three ways: right-click the protected chest, run `/iris jigsaw menu`, and start three sneaks within 1.5 seconds. Select Hallway and click **New Blank Variant**. Wait for its atomic graph result and load, then reopen the controls. Rename the loaded variant and Hallway workcell through their anvil inputs; confirm labels round-trip while the piece key, `straight` stable ID, and solver role stay unchanged. Load End Cap and use **Duplicate This Cell's Variant**, then load Cross Junction and duplicate it as well.
3. Open the same six-row controls three ways: right-click the protected chest, run `/iris jigsaw menu`, and start three sneaks within 1.5 seconds. Gate: each path opens without an `InventoryView` linkage error on the target Paper-family runtime. Select Hallway and click **New Blank Variant**. Wait for its atomic graph result and load, then reopen the controls. Rename the loaded variant and Hallway workcell through their anvil inputs; confirm labels round-trip while the piece key, `straight` stable ID, and solver role stay unchanged. Load End Cap and use **Duplicate This Cell's Variant**, then load Cross Junction and duplicate it as well.
Gate: the new key follows `smoke/jigsaw/variants/straight/variant-<n>` and loads into Hallway. It has the source piece's complete metadata and exact pool entries but an empty same-sized object. At the default 15×15×15, its two real markers occupy `(7,7,0)` and `(7,7,14)`, face north/south with top `UP_POSITIVE_Y`, show pool `iris:smoke/jigsaw/pieces`, use name/target `iris:planar`, `ALIGNED`, `minecraft:structure_void`, and signed priorities `0`. Mojang's UI is usable after hydration. Break one marker and click **Reset Connector Blocks** before autosave; both saved markers must return while another edited block remains unchanged. Each duplicate copies the active object's bytes, display label, and complete piece metadata. The End Cap duplicate has exact matching entries in both `smoke/jigsaw/pieces` and `smoke/jigsaw/caps`; the Cross Junction duplicate has exact matching entries in both `smoke/jigsaw/start` and `smoke/jigsaw/pieces`. An empty or unassigned workcell refuses both GUI actions and directs the operator to `/iris jigsaw piece create <poolKey> <pieceKey>` instead of choosing a fallback pool.
+3 -1
View File
@@ -282,13 +282,15 @@ Iris replaces the chunk generator. Vanilla/mod worldgen runs only if Iris runs i
| System | Over Iris? | Notes |
|---|---|---|
| Structures (vanilla, datapack, mod) | **Yes**, on by default | Vertical fit, stilts, vegetation clear. Deny: `importedStructures.disabled` |
| Structures (vanilla, datapack, mod) | **Yes**, on by default | Vertical fit, stilts, vegetation clear. Deny families with `importedStructures.disabled`, one complete key with `disabledExact`, or scale an exact structure set with `frequencyOverrides` |
| Placed features (ores, trees, plants, …) | **Yes**, **off** by default | Dimension `importedFeatures.enabled` |
| Carvers | **Never** | No noise router / aquifer for vanilla carvers |
| Mod biomes as sources | Only as derivative / scatter targets | Iris chooses biomes from the pack |
| Mob spawning (incl. mod mobs) | **Yes** | Merges pack biome table with vanilla derivative |
| Surface builders / rules | **Never** | Pack palettes |
`importedStructures.frequencyOverrides` has Bukkit parity on all three mod loaders. Entries use `{ "structureSet": "namespace:path", "multiplier": 0.01..16 }`; keys are exact registered structure-set keys, last duplicate wins, and changes affect new chunks only. Random-spread sets scale probability first and then derive the nearest legal integer spacing, while concentric rings can scale probability only. Custom placement types outside the affected override and exclusion-zone graph remain untouched; an unsupported placement that must be copied fails level binding rather than applying a partial override. Full semantics and the Nether `1.1` example are in `22 - Native Structures & Datapacks.md`.
### `importedFeatures`
Disabled by default. Absent or `enabled: false` → no feature table; terrain matches long-standing Iris-only output. Biome tags of the vanilla derivative are always inherited on custom biomes (not gated on this flag). Structure tags `#minecraft:has_structure/*` are **not** inherited.