mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-11 19:14:11 +00:00
Merged biome retention
- Saved biome behavior is integrated with current Iris changes
This commit is contained in:
+4
@@ -333,6 +333,10 @@ public class CustomBiomeSource extends BiomeSource {
|
||||
return Collections.unmodifiableMap(spawnBiomes);
|
||||
}
|
||||
|
||||
Holder<Biome> getRetainedVanillaSpawnBiome(String derivativeKey) {
|
||||
return resolveBiomeHolder(biomeRegistry, derivativeKey);
|
||||
}
|
||||
|
||||
Holder<Biome> getVanillaSpawnBiome(Holder<Biome> biome) {
|
||||
if (biome == null) {
|
||||
return null;
|
||||
|
||||
+17
-5
@@ -18,6 +18,7 @@ import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy;
|
||||
import art.arcane.iris.engine.IrisEngine;
|
||||
import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter;
|
||||
import art.arcane.iris.engine.history.NativeTerrainReceipt;
|
||||
import art.arcane.iris.engine.history.NativeBiomeSpawnSelection;
|
||||
import art.arcane.iris.engine.history.SavedTerrainChunk;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
@@ -929,11 +930,17 @@ public class IrisChunkGenerator extends CustomChunkGenerator implements LongPred
|
||||
|
||||
@Override
|
||||
public WeightedList<MobSpawnSettings.SpawnerData> getMobsAt(Holder<Biome> holder, StructureManager structuremanager, MobCategory enumcreaturetype, BlockPos blockposition) {
|
||||
NativeBiomeSpawnSelection selection = NativeBiomeSpawnSelection.at(
|
||||
engine, blockposition.getX(), blockposition.getY(), blockposition.getZ(),
|
||||
holder.unwrapKey().map(key -> key.identifier().toString()).orElse(""));
|
||||
if (selection.mode() == NativeBiomeSpawnSelection.Mode.LOADING) {
|
||||
return WeightedList.of(List.of());
|
||||
}
|
||||
try (GenerationHistoryRuntimeRouter.CoordinateScope route = openHistoryCoordinateScope(
|
||||
blockposition.getX(), blockposition.getZ(), "bukkit_nms_mob_spawns");
|
||||
GenerationSessionLease lease = requireGenerationLease("bukkit_nms_mob_spawns");
|
||||
IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) {
|
||||
return getMobsAtWithActiveRuntime(holder, structuremanager, enumcreaturetype, blockposition);
|
||||
return getMobsAtWithActiveRuntime(holder, structuremanager, enumcreaturetype, blockposition, selection);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -941,9 +948,14 @@ public class IrisChunkGenerator extends CustomChunkGenerator implements LongPred
|
||||
Holder<Biome> holder,
|
||||
StructureManager structuremanager,
|
||||
MobCategory enumcreaturetype,
|
||||
BlockPos blockposition
|
||||
BlockPos blockposition,
|
||||
NativeBiomeSpawnSelection selection
|
||||
) {
|
||||
Holder<Biome> vanillaSpawnBiome = customBiomeSource.getVanillaSpawnBiome(holder);
|
||||
Holder<Biome> vanillaSpawnBiome = switch (selection.mode()) {
|
||||
case RETAINED -> customBiomeSource.getRetainedVanillaSpawnBiome(selection.derivativeKey());
|
||||
case CURRENT -> customBiomeSource.getVanillaSpawnBiome(holder);
|
||||
case NONE, LOADING -> null;
|
||||
};
|
||||
if (vanillaSpawnBiome == null) {
|
||||
return delegate.getMobsAt(holder, structuremanager, enumcreaturetype, blockposition);
|
||||
}
|
||||
@@ -964,7 +976,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator implements LongPred
|
||||
}
|
||||
|
||||
int spawnRuntimeId = engine.getCacheID();
|
||||
SpawnTableKey key = new SpawnTableKey(spawnRuntimeId, holder.value(), enumcreaturetype);
|
||||
SpawnTableKey key = new SpawnTableKey(spawnRuntimeId, holder.value(), vanillaSpawnBiome.value(), enumcreaturetype);
|
||||
return mergedSpawnTables.computeIfAbsent(key, ignored -> mergeSpawnTables(vanillaSpawns, explicitSpawns));
|
||||
}
|
||||
|
||||
@@ -1611,7 +1623,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator implements LongPred
|
||||
}
|
||||
}
|
||||
|
||||
private record SpawnTableKey(int runtimeId, Biome biome, MobCategory category) {
|
||||
private record SpawnTableKey(int runtimeId, Biome biome, Biome vanillaBiome, MobCategory category) {
|
||||
}
|
||||
|
||||
private record ReachableStructureCache(IrisDimension dimension, int runtimeId, Set<String> keys) {
|
||||
|
||||
+11
-7
@@ -600,7 +600,8 @@ public final class IrisWorldGeneratorResolver {
|
||||
throw new IllegalStateException("Immutable Iris generation pack at " + packRoot
|
||||
+ " does not contain dimension " + dimensionKey + ".");
|
||||
}
|
||||
GenerationEpoch.DimensionContract actualContract = captureDimensionContract(data, dimension);
|
||||
GenerationEpoch.DimensionContract actualContract = GenerationEpochContractFactory.createForEpoch(
|
||||
dimension, dimensionTypeKey(data, dimension), epoch);
|
||||
if (!epoch.dimensionContract().equals(actualContract)) {
|
||||
throw new IllegalStateException("Immutable Iris generation pack dimension contract changed at "
|
||||
+ packRoot + ".");
|
||||
@@ -612,7 +613,15 @@ public final class IrisWorldGeneratorResolver {
|
||||
IrisData data,
|
||||
IrisDimension dimension
|
||||
) {
|
||||
String dimensionTypeKey = IrisPlatforms.get()
|
||||
return GenerationEpochContractFactory.create(
|
||||
dimension,
|
||||
dimension.getLoadKey(),
|
||||
dimensionTypeKey(data, dimension)
|
||||
);
|
||||
}
|
||||
|
||||
private static String dimensionTypeKey(IrisData data, IrisDimension dimension) {
|
||||
return IrisPlatforms.get()
|
||||
.registries()
|
||||
.generationRegistry()
|
||||
.dimensionTypeResourceKey(
|
||||
@@ -620,11 +629,6 @@ public final class IrisWorldGeneratorResolver {
|
||||
dimension.getLoadKey(),
|
||||
dimension.getDimensionTypeKey()
|
||||
);
|
||||
return GenerationEpochContractFactory.create(
|
||||
dimension,
|
||||
dimension.getLoadKey(),
|
||||
dimensionTypeKey
|
||||
);
|
||||
}
|
||||
|
||||
private static File requireActivePack(GenerationHistory history) {
|
||||
|
||||
+28
-5
@@ -31,6 +31,7 @@ import art.arcane.iris.engine.framework.GenerationSessionLease;
|
||||
import art.arcane.iris.engine.platform.EngineBukkitOps;
|
||||
import art.arcane.iris.util.project.context.IrisContext;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.history.SavedBiomeUnavailableException;
|
||||
import art.arcane.iris.engine.object.IrisRegion;
|
||||
import art.arcane.iris.util.common.director.DirectorExecutor;
|
||||
import art.arcane.iris.util.common.plugin.VolmitSender;
|
||||
@@ -82,21 +83,36 @@ public class CommandWhat implements DirectorExecutor {
|
||||
|
||||
@Director(description = "What biome am i in?", descriptionKey = "iris.director.commandwhat.director.what_biome_am_i", origin = DirectorOrigin.PLAYER)
|
||||
public void biome() {
|
||||
VolmitSender commandSender = sender();
|
||||
Player player = player();
|
||||
onPlayerThread(player, () -> describeBiome(commandSender, player));
|
||||
}
|
||||
|
||||
private void describeBiome(VolmitSender commandSender, Player player) {
|
||||
Location location = player.getLocation();
|
||||
try {
|
||||
IrisBiome b = engine().getBiome(player().getLocation().getBlockX(), player().getLocation().getBlockY() - player().getWorld().getMinHeight(), player().getLocation().getBlockZ());
|
||||
Engine engine = IrisToolbelt.access(location.getWorld()).getEngine();
|
||||
IrisBiome b = engine.getBiome(location.getBlockX(), location.getBlockY() - location.getWorld().getMinHeight(), location.getBlockZ());
|
||||
Biome derivative = b.getDerivative();
|
||||
NamespacedKey derivativeKey = resolveBiomeKey(derivative);
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_WHAT_IBIOME, MessageArgument.untrusted("value", b.getLoadKey()), MessageArgument.untrusted("value2", derivativeKey == null ? IrisLanguage.plain(RuntimeUiMessages.STATUS_UNREGISTERED) : derivativeKey.getKey())));
|
||||
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_WHAT_IBIOME, MessageArgument.untrusted("value", b.getLoadKey()), MessageArgument.untrusted("value2", derivativeKey == null ? IrisLanguage.plain(RuntimeUiMessages.STATUS_UNREGISTERED) : derivativeKey.getKey())));
|
||||
|
||||
} catch (SavedBiomeUnavailableException e) {
|
||||
if (e.getCause() != null) {
|
||||
Iris.reportError(e);
|
||||
}
|
||||
commandSender.sendMessage(e.isLoading()
|
||||
? "Saved biome data is loading. Run the command again shortly."
|
||||
: e.getMessage());
|
||||
} catch (Throwable e) {
|
||||
Iris.reportError(e);
|
||||
Biome biome = player().getLocation().getBlock().getBiome();
|
||||
Biome biome = location.getBlock().getBiome();
|
||||
NamespacedKey key = resolveBiomeKey(biome);
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_WHAT_NON_IRIS_BIOME, MessageArgument.untrusted("value", key == null ? IrisLanguage.plain(RuntimeUiMessages.STATUS_UNREGISTERED) : key)));
|
||||
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_WHAT_NON_IRIS_BIOME, MessageArgument.untrusted("value", key == null ? IrisLanguage.plain(RuntimeUiMessages.STATUS_UNREGISTERED) : key)));
|
||||
|
||||
if (key == null || key.getKey().equals("custom")) {
|
||||
try {
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_WHAT_DATA_PACK_BIOME_ID, MessageArgument.untrusted("value", INMS.get().getTrueBiomeBaseKey(player().getLocation())), MessageArgument.untrusted("value2", INMS.get().getTrueBiomeBaseId(INMS.get().getTrueBiomeBase(player().getLocation())))));
|
||||
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_WHAT_DATA_PACK_BIOME_ID, MessageArgument.untrusted("value", INMS.get().getTrueBiomeBaseKey(location)), MessageArgument.untrusted("value2", INMS.get().getTrueBiomeBaseId(INMS.get().getTrueBiomeBase(location)))));
|
||||
} catch (Throwable ee) {
|
||||
Iris.reportError(ee);
|
||||
}
|
||||
@@ -118,6 +134,13 @@ public class CommandWhat implements DirectorExecutor {
|
||||
IrisRegion r = EngineBukkitOps.getRegion(engine, chunk);
|
||||
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_WHAT_IREGION, MessageArgument.untrusted("value", r.getLoadKey()), MessageArgument.untrusted("value2", r.getName())));
|
||||
|
||||
} catch (SavedBiomeUnavailableException e) {
|
||||
if (e.getCause() != null) {
|
||||
Iris.reportError(e);
|
||||
}
|
||||
commandSender.sendMessage(e.isLoading()
|
||||
? "Saved biome data is loading. Run the command again shortly."
|
||||
: e.getMessage());
|
||||
} catch (Throwable e) {
|
||||
Iris.reportError(e);
|
||||
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_WHAT_IRIS_WORLDS_ONLY));
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package art.arcane.iris.core.commands;
|
||||
|
||||
import art.arcane.iris.core.localization.BukkitCommandMessagesExtended;
|
||||
import art.arcane.iris.core.localization.IrisLanguage;
|
||||
import art.arcane.iris.core.localization.RuntimeUiMessages;
|
||||
import art.arcane.iris.core.tools.IrisToolbelt;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
|
||||
import art.arcane.iris.util.common.director.DirectorContext;
|
||||
import art.arcane.iris.util.common.plugin.VolmitSender;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import art.arcane.volmlib.util.localization.MessageArgument;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class CommandWhatBiomeTest {
|
||||
@Test
|
||||
public void scheduledBiomeDescriptionUsesThePlayersWorldAfterCommandContextClears() {
|
||||
VolmitSender sender = mock(VolmitSender.class);
|
||||
Player player = mock(Player.class);
|
||||
World world = mock(World.class);
|
||||
Engine engine = mock(Engine.class);
|
||||
PlatformChunkGenerator generator = mock(PlatformChunkGenerator.class);
|
||||
IrisBiome biome = mock(IrisBiome.class);
|
||||
AtomicReference<Runnable> scheduled = new AtomicReference<>();
|
||||
when(sender.player()).thenReturn(player);
|
||||
when(sender.isPlayer()).thenReturn(true);
|
||||
when(player.getWorld()).thenReturn(world);
|
||||
when(player.getLocation()).thenReturn(new Location(world, 19, 70, -3));
|
||||
when(world.getMinHeight()).thenReturn(-64);
|
||||
when(generator.getEngine()).thenReturn(engine);
|
||||
when(engine.getBiome(19, 134, -3)).thenReturn(biome);
|
||||
when(biome.getLoadKey()).thenReturn("retained-flat");
|
||||
try (MockedStatic<J> scheduler = mockStatic(J.class);
|
||||
MockedStatic<IrisToolbelt> toolbelt = mockStatic(IrisToolbelt.class);
|
||||
MockedStatic<IrisLanguage> language = mockStatic(IrisLanguage.class)) {
|
||||
scheduler.when(() -> J.runEntity(eq(player), any(Runnable.class))).thenAnswer(invocation -> {
|
||||
scheduled.set(invocation.getArgument(1));
|
||||
return true;
|
||||
});
|
||||
toolbelt.when(() -> IrisToolbelt.access(world)).thenReturn(generator);
|
||||
language.when(() -> IrisLanguage.plain(RuntimeUiMessages.STATUS_UNREGISTERED)).thenReturn("unregistered");
|
||||
language.when(() -> IrisLanguage.text(eq(BukkitCommandMessagesExtended.COMMAND_WHAT_IBIOME),
|
||||
any(MessageArgument.class), any(MessageArgument.class))).thenReturn("Iris biome: retained-flat");
|
||||
DirectorContext.touch(sender);
|
||||
try {
|
||||
new CommandWhat().biome();
|
||||
} finally {
|
||||
DirectorContext.remove();
|
||||
}
|
||||
assertNull(DirectorContext.get());
|
||||
assertNotNull(scheduled.get());
|
||||
verifyNoInteractions(engine);
|
||||
|
||||
scheduled.get().run();
|
||||
|
||||
verify(engine).getBiome(19, 134, -3);
|
||||
verify(sender).sendMessage("Iris biome: retained-flat");
|
||||
verify(world, never()).getBlockAt(anyInt(), anyInt(), anyInt());
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-2
@@ -20,6 +20,7 @@ package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.iris.nativegen.NativeTransitionColumn;
|
||||
import art.arcane.iris.engine.history.TerrainBoundarySignature;
|
||||
import art.arcane.iris.engine.history.NativeBiomeSpawnSelection;
|
||||
import java.util.Optional;
|
||||
import art.arcane.iris.nativegen.NativeGenerationWriteGuard;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
@@ -920,6 +921,11 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
||||
public WeightedList<MobSpawnSettings.SpawnerData> getMobsAt(
|
||||
Holder<Biome> biome, StructureManager structureManager, MobCategory category, BlockPos pos) {
|
||||
Engine current = engine();
|
||||
NativeBiomeSpawnSelection selection = NativeBiomeSpawnSelection.at(current, pos.getX(), pos.getY(), pos.getZ(),
|
||||
biome.unwrapKey().map(key -> key.identifier().toString()).orElse(""));
|
||||
if (selection.mode() == NativeBiomeSpawnSelection.Mode.LOADING) {
|
||||
return WeightedList.of(List.of());
|
||||
}
|
||||
try (GenerationHistoryRuntimeRouter.CoordinateScope historyScope = openHistoryCoordinateScope(
|
||||
current, pos.getX(), pos.getZ(), "modded_mob_spawn_table");
|
||||
GenerationSessionLease lease = requireGenerationLease(current, "modded_mob_spawn_table");
|
||||
@@ -933,8 +939,15 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
||||
}
|
||||
|
||||
Registry<Biome> registry = structureManager.registryAccess().lookupOrThrow(Registries.BIOME);
|
||||
spawnTables.initializeVanillaSpawnBiomes(registry);
|
||||
Holder<Biome> vanillaSpawnBiome = spawnTables.vanillaSpawnBiome(biome.value());
|
||||
Holder<Biome> vanillaSpawnBiome;
|
||||
if (selection.mode() == NativeBiomeSpawnSelection.Mode.RETAINED) {
|
||||
vanillaSpawnBiome = spawnTables.resolveBiomeHolder(registry, selection.derivativeKey());
|
||||
} else if (selection.mode() == NativeBiomeSpawnSelection.Mode.CURRENT) {
|
||||
spawnTables.initializeVanillaSpawnBiomes(registry);
|
||||
vanillaSpawnBiome = spawnTables.vanillaSpawnBiome(biome.value());
|
||||
} else {
|
||||
vanillaSpawnBiome = null;
|
||||
}
|
||||
if (vanillaSpawnBiome == null) {
|
||||
return explicitSpawns;
|
||||
}
|
||||
@@ -951,6 +964,7 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
||||
return spawnTables.mergedSpawnTable(
|
||||
current,
|
||||
biome.value(),
|
||||
vanillaSpawnBiome.value(),
|
||||
category,
|
||||
vanillaSpawns,
|
||||
explicitSpawns
|
||||
|
||||
+25
-35
@@ -20,13 +20,13 @@ package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.iris.core.IrisSettings;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.BiomeEnvironment;
|
||||
import art.arcane.iris.engine.history.SavedBiomeUnavailableException;
|
||||
import art.arcane.iris.engine.framework.EngineAssignedComponent;
|
||||
import art.arcane.iris.engine.framework.EngineEffects;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisCommand;
|
||||
import art.arcane.iris.engine.object.IrisCommandRegistry;
|
||||
import art.arcane.iris.engine.object.IrisEffect;
|
||||
import art.arcane.iris.engine.object.IrisRegion;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
import net.minecraft.core.Holder;
|
||||
@@ -36,6 +36,7 @@ import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.network.protocol.game.ClientboundSoundPacket;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.sounds.SoundEvent;
|
||||
import net.minecraft.sounds.SoundSource;
|
||||
@@ -193,18 +194,21 @@ public final class ModdedEngineEffects extends EngineAssignedComponent implement
|
||||
return;
|
||||
}
|
||||
|
||||
samplePlayer(state);
|
||||
try {
|
||||
samplePlayer(state);
|
||||
} catch (SavedBiomeUnavailableException unavailable) {
|
||||
state.environment = null;
|
||||
state.sampled(false);
|
||||
return;
|
||||
}
|
||||
if (!IrisSettings.get().getWorld().isEffectSystem()) {
|
||||
return;
|
||||
}
|
||||
|
||||
IrisRegion region = state.region();
|
||||
if (region != null) {
|
||||
applyEffects(level, player, region.getEffects());
|
||||
}
|
||||
IrisBiome biome = state.biome();
|
||||
if (biome != null) {
|
||||
applyEffects(level, player, biome.getEffects());
|
||||
BiomeEnvironment environment = state.environment;
|
||||
try (BiomeEnvironment.Scope ignored = getEngine().openBiomeEnvironmentScope(environment)) {
|
||||
applyEffects(level, player, environment.region().getEffects());
|
||||
applyEffects(level, player, environment.biome().getEffects());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,14 +223,14 @@ public final class ModdedEngineEffects extends EngineAssignedComponent implement
|
||||
return;
|
||||
}
|
||||
|
||||
state.sampled(true);
|
||||
state.lastPosition(player.getX(), player.getY(), player.getZ());
|
||||
state.lastSample(now);
|
||||
int blockX = floor(player.getX());
|
||||
int blockY = floor(player.getY()) - getEngine().getWorld().minHeight();
|
||||
int blockZ = floor(player.getZ());
|
||||
state.biome(getEngine().getBiome(blockX, blockY, blockZ));
|
||||
state.region(getEngine().getRegion(blockX, blockY, blockZ));
|
||||
BiomeEnvironment environment = getEngine().getBiomeEnvironment(blockX, blockY, blockZ);
|
||||
state.environment = environment;
|
||||
state.sampled(true);
|
||||
state.lastPosition(player.getX(), player.getY(), player.getZ());
|
||||
state.lastSample(now);
|
||||
}
|
||||
|
||||
private void applyEffects(ServerLevel level, ServerPlayer player, Iterable<IrisEffect> effects) {
|
||||
@@ -299,9 +303,12 @@ public final class ModdedEngineEffects extends EngineAssignedComponent implement
|
||||
double sideways = RNG.r.d(-effect.getParticleDistanceWidth(), effect.getParticleDistanceWidth());
|
||||
double surfaceX = player.getX() + direction.x * forward + direction.z * sideways;
|
||||
double surfaceZ = player.getZ() + direction.z * forward - direction.x * sideways;
|
||||
int surfaceY = getEngine().getHeight(floor(surfaceX), floor(surfaceZ));
|
||||
if (level.getChunkSource().getChunkNow(floor(surfaceX) >> 4, floor(surfaceZ) >> 4) == null) {
|
||||
return;
|
||||
}
|
||||
int surfaceY = level.getHeight(Heightmap.Types.OCEAN_FLOOR, floor(surfaceX), floor(surfaceZ));
|
||||
double x = surfaceX + RNG.r.d();
|
||||
double y = surfaceY + 1.0D + level.getMinY() + RNG.r.i(effect.getParticleOffset());
|
||||
double y = surfaceY + RNG.r.i(effect.getParticleOffset());
|
||||
double z = surfaceZ + RNG.r.d();
|
||||
double altX = randomized(effect.getParticleAltX(), effect.isRandomAltX());
|
||||
double altY = randomized(effect.getParticleAltY(), effect.isRandomAltY());
|
||||
@@ -421,8 +428,7 @@ public final class ModdedEngineEffects extends EngineAssignedComponent implement
|
||||
|
||||
private static final class PlayerState {
|
||||
private ServerPlayer player;
|
||||
private IrisBiome biome;
|
||||
private IrisRegion region;
|
||||
private BiomeEnvironment environment;
|
||||
private double lastX;
|
||||
private double lastY;
|
||||
private double lastZ;
|
||||
@@ -446,22 +452,6 @@ public final class ModdedEngineEffects extends EngineAssignedComponent implement
|
||||
this.player = player;
|
||||
}
|
||||
|
||||
private IrisBiome biome() {
|
||||
return biome;
|
||||
}
|
||||
|
||||
private void biome(IrisBiome biome) {
|
||||
this.biome = biome;
|
||||
}
|
||||
|
||||
private IrisRegion region() {
|
||||
return region;
|
||||
}
|
||||
|
||||
private void region(IrisRegion region) {
|
||||
this.region = region;
|
||||
}
|
||||
|
||||
private double lastX() {
|
||||
return lastX;
|
||||
}
|
||||
|
||||
+6
-5
@@ -175,7 +175,7 @@ final class ModdedGenerationHistoryStorage {
|
||||
GenerationEpoch epoch = history.activeEpoch();
|
||||
Path packRoot = normalize(history.activePackRoot());
|
||||
validatePack(packRoot, epoch.packFingerprint());
|
||||
validateDimension(packRoot, epoch.dimensionContract());
|
||||
validateDimension(packRoot, epoch);
|
||||
return new ActivePack(
|
||||
packRoot,
|
||||
epoch.dimensionContract().dimensionKey(),
|
||||
@@ -194,8 +194,9 @@ final class ModdedGenerationHistoryStorage {
|
||||
|
||||
private static void validateDimension(
|
||||
Path packRoot,
|
||||
GenerationEpoch.DimensionContract recordedContract
|
||||
GenerationEpoch epoch
|
||||
) throws IOException {
|
||||
GenerationEpoch.DimensionContract recordedContract = epoch.dimensionContract();
|
||||
IrisData data = IrisData.openDatapackCompiler(packRoot.toFile());
|
||||
try {
|
||||
IrisDimension dimension = data.getDimensionLoader().load(recordedContract.dimensionKey(), false);
|
||||
@@ -203,10 +204,10 @@ final class ModdedGenerationHistoryStorage {
|
||||
throw new IOException("Immutable generation pack does not contain dimension '"
|
||||
+ recordedContract.dimensionKey() + "'.");
|
||||
}
|
||||
GenerationEpoch.DimensionContract actualContract = GenerationEpochContractFactory.create(
|
||||
GenerationEpoch.DimensionContract actualContract = GenerationEpochContractFactory.createForEpoch(
|
||||
dimension,
|
||||
dimension.getLoadKey(),
|
||||
recordedContract.dimensionTypeKey()
|
||||
recordedContract.dimensionTypeKey(),
|
||||
epoch
|
||||
);
|
||||
if (!recordedContract.equals(actualContract)) {
|
||||
throw new IOException("Immutable generation pack no longer matches its recorded dimension contract.");
|
||||
|
||||
+4
-3
@@ -137,10 +137,11 @@ final class ModdedSpawnTableMerger {
|
||||
WeightedList<MobSpawnSettings.SpawnerData> mergedSpawnTable(
|
||||
Engine engine,
|
||||
Biome biome,
|
||||
Biome vanillaBiome,
|
||||
MobCategory category,
|
||||
WeightedList<MobSpawnSettings.SpawnerData> vanillaSpawns,
|
||||
WeightedList<MobSpawnSettings.SpawnerData> explicitSpawns) {
|
||||
SpawnTableKey key = new SpawnTableKey(engine.getCacheID(), biome, category);
|
||||
SpawnTableKey key = new SpawnTableKey(engine.getCacheID(), biome, vanillaBiome, category);
|
||||
return mergedSpawnTables.computeIfAbsent(
|
||||
key,
|
||||
ignored -> NativeSpawnTableMerger.merge(vanillaSpawns, explicitSpawns)
|
||||
@@ -153,7 +154,7 @@ final class ModdedSpawnTableMerger {
|
||||
initializedRuntimeIdentities.remove(runtimeIdentity);
|
||||
}
|
||||
|
||||
private Holder<Biome> resolveBiomeHolder(Registry<Biome> registry, String key) {
|
||||
Holder<Biome> resolveBiomeHolder(Registry<Biome> registry, String key) {
|
||||
if (key == null || key.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
@@ -176,6 +177,6 @@ final class ModdedSpawnTableMerger {
|
||||
private record SpawnBiomeKey(int runtimeIdentity, Biome biome) {
|
||||
}
|
||||
|
||||
private record SpawnTableKey(int runtimeIdentity, Biome biome, MobCategory category) {
|
||||
private record SpawnTableKey(int runtimeIdentity, Biome biome, Biome vanillaBiome, MobCategory category) {
|
||||
}
|
||||
}
|
||||
|
||||
+88
-38
@@ -22,6 +22,8 @@ import art.arcane.iris.core.IrisSettings;
|
||||
import art.arcane.iris.core.gui.PregeneratorJob;
|
||||
import art.arcane.iris.engine.IrisComplex;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.BiomeEnvironment;
|
||||
import art.arcane.iris.engine.history.SavedBiomeUnavailableException;
|
||||
import art.arcane.iris.engine.framework.EngineLifecycleTasks;
|
||||
import art.arcane.iris.engine.framework.EngineWorldManager;
|
||||
import art.arcane.iris.engine.framework.LootResolver;
|
||||
@@ -32,7 +34,6 @@ import art.arcane.iris.engine.object.IrisEntitySpawn;
|
||||
import art.arcane.iris.engine.object.IrisMarker;
|
||||
import art.arcane.iris.engine.object.IrisPosition;
|
||||
import art.arcane.iris.engine.object.IrisRange;
|
||||
import art.arcane.iris.engine.object.IrisRegion;
|
||||
import art.arcane.iris.engine.object.IrisSpawnGroup;
|
||||
import art.arcane.iris.engine.object.IrisSpawner;
|
||||
import art.arcane.iris.engine.object.IrisSurface;
|
||||
@@ -53,12 +54,16 @@ import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.entity.MobCategory;
|
||||
import net.minecraft.world.level.NaturalSpawner;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
@@ -152,7 +157,7 @@ public final class ModdedWorldManager implements EngineWorldManager {
|
||||
}
|
||||
|
||||
private void recoverLoadedInitialSpawns(ServerLevel level) {
|
||||
if (!markerSystemEnabled()) {
|
||||
if (!markerSystemEnabled() && !ambientSystemEnabled()) {
|
||||
return;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
@@ -194,7 +199,7 @@ public final class ModdedWorldManager implements EngineWorldManager {
|
||||
if (initialSpawnQueue.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (!markerSystemEnabled()) {
|
||||
if (!markerSystemEnabled() && !ambientSystemEnabled()) {
|
||||
initialSpawnQueue.clear();
|
||||
return;
|
||||
}
|
||||
@@ -217,6 +222,8 @@ public final class ModdedWorldManager implements EngineWorldManager {
|
||||
retry = true;
|
||||
warmupMantleChunkAsync(key, chunkX, chunkZ);
|
||||
}
|
||||
} catch (SavedBiomeUnavailableException unavailable) {
|
||||
retry = unavailable.isLoading();
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError(e);
|
||||
retry = true;
|
||||
@@ -230,7 +237,7 @@ public final class ModdedWorldManager implements EngineWorldManager {
|
||||
}
|
||||
}
|
||||
|
||||
private boolean initialSpawnChunk(ServerLevel level, int chunkX, int chunkZ) {
|
||||
boolean initialSpawnChunk(ServerLevel level, int chunkX, int chunkZ) {
|
||||
if (!ModdedEntitySpawner.chunksSafe(level, chunkX, chunkZ)) {
|
||||
return false;
|
||||
}
|
||||
@@ -244,9 +251,15 @@ public final class ModdedWorldManager implements EngineWorldManager {
|
||||
|
||||
MantleChunk<Matter> chunk = mantle.getChunk(chunkX, chunkZ).use();
|
||||
try {
|
||||
List<PreparedMarkerSpawn> markers = markerSystemEnabled()
|
||||
? prepareMarkerSpawns(level, chunkX, chunkZ, chunk) : List.of();
|
||||
Optional<BiomeEnvironment> environment = ambientSystemEnabled()
|
||||
? resolveSurfaceEnvironment(chunkX, chunkZ) : Optional.empty();
|
||||
chunk.raiseFlagUnchecked(INITIAL_SPAWN_COMPLETION_FLAG, () -> {
|
||||
spawnMarkerSpawners(level, chunkX, chunkZ, chunk, true);
|
||||
scheduleInitialFollowUp(level, chunkX, chunkZ);
|
||||
spawnPreparedMarkers(level, markers, true);
|
||||
if (environment.isPresent()) {
|
||||
scheduleInitialFollowUp(level, chunkX, chunkZ, environment.get());
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
chunk.release();
|
||||
@@ -254,7 +267,7 @@ public final class ModdedWorldManager implements EngineWorldManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
private void scheduleInitialFollowUp(ServerLevel level, int chunkX, int chunkZ) {
|
||||
private void scheduleInitialFollowUp(ServerLevel level, int chunkX, int chunkZ, BiomeEnvironment environment) {
|
||||
ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull();
|
||||
if (scheduler == null) {
|
||||
IrisLogging.error("Iris could not schedule the initial entity-spawn follow-up because the modded scheduler is unavailable.");
|
||||
@@ -264,11 +277,11 @@ public final class ModdedWorldManager implements EngineWorldManager {
|
||||
() -> EngineLifecycleTasks.run(
|
||||
engine,
|
||||
"modded_world_manager_initial_spawn_followup",
|
||||
() -> runInitialFollowUp(level, chunkX, chunkZ)),
|
||||
() -> runInitialFollowUp(level, chunkX, chunkZ, environment)),
|
||||
RNG.r.i(5, 200));
|
||||
}
|
||||
|
||||
private void runInitialFollowUp(ServerLevel level, int chunkX, int chunkZ) {
|
||||
private void runInitialFollowUp(ServerLevel level, int chunkX, int chunkZ, BiomeEnvironment environment) {
|
||||
Mantle<Matter> mantle = engine.getMantle().getMantle();
|
||||
if (closed || engine.isClosed() || mantle.isClosed() || !isEntitySpawningEnabledForCurrentWorld()) {
|
||||
return;
|
||||
@@ -280,16 +293,10 @@ public final class ModdedWorldManager implements EngineWorldManager {
|
||||
return;
|
||||
}
|
||||
|
||||
MantleChunk<Matter> chunk = mantle.getChunk(chunkX, chunkZ).use();
|
||||
try {
|
||||
if (markerSystemEnabled()) {
|
||||
spawnMarkerSpawners(level, chunkX, chunkZ, chunk, false);
|
||||
if (ambientSystemEnabled()) {
|
||||
try (BiomeEnvironment.Scope ignored = engine.openBiomeEnvironmentScope(environment)) {
|
||||
spawnAmbient(level, chunkX, chunkZ, true, environment);
|
||||
}
|
||||
if (ambientSystemEnabled()) {
|
||||
spawnAmbient(level, chunkX, chunkZ, true);
|
||||
}
|
||||
} finally {
|
||||
chunk.release();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,6 +367,8 @@ public final class ModdedWorldManager implements EngineWorldManager {
|
||||
long key = ambientChunkSample[RNG.r.nextInt(sampled)];
|
||||
try {
|
||||
ambientSpawnChunk(level, unpackX(key), unpackZ(key));
|
||||
} catch (SavedBiomeUnavailableException unavailable) {
|
||||
continue;
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError(e);
|
||||
}
|
||||
@@ -378,7 +387,7 @@ public final class ModdedWorldManager implements EngineWorldManager {
|
||||
MantleChunk<Matter> chunk = mantle.getChunk(chunkX, chunkZ).use();
|
||||
try {
|
||||
if (markerSystemEnabled()) {
|
||||
spawnMarkerSpawners(level, chunkX, chunkZ, chunk, false);
|
||||
spawnPreparedMarkers(level, prepareMarkerSpawns(level, chunkX, chunkZ, chunk), false);
|
||||
}
|
||||
if (ambientSystemEnabled()) {
|
||||
spawnAmbient(level, chunkX, chunkZ, false);
|
||||
@@ -388,29 +397,38 @@ public final class ModdedWorldManager implements EngineWorldManager {
|
||||
}
|
||||
}
|
||||
|
||||
private void spawnMarkerSpawners(ServerLevel level, int chunkX, int chunkZ, MantleChunk<Matter> chunk, boolean initial) {
|
||||
private List<PreparedMarkerSpawn> prepareMarkerSpawns(ServerLevel level, int chunkX, int chunkZ, MantleChunk<Matter> chunk) {
|
||||
int minHeight = engine.getWorld().minHeight();
|
||||
KList<IrisPosition> obstructed = new KList<>();
|
||||
List<PreparedMarkerSpawn> prepared = new ArrayList<>();
|
||||
chunk.iterate(MatterMarker.class, (Integer x, Integer yf, Integer z, MatterMarker marker) -> {
|
||||
String tag = marker.getTag();
|
||||
if (tag.equals("cave_floor") || tag.equals("cave_ceiling")) {
|
||||
return;
|
||||
}
|
||||
IrisMarker resolved = engine.getData().getMarkerLoader().load(tag);
|
||||
if (resolved == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
int worldX = (chunkX << 4) + (x & 15);
|
||||
int worldZ = (chunkZ << 4) + (z & 15);
|
||||
int worldY = yf + minHeight;
|
||||
BiomeEnvironment environment;
|
||||
try {
|
||||
environment = engine.getBiomeEnvironment(worldX, yf, worldZ);
|
||||
} catch (SavedBiomeUnavailableException unavailable) {
|
||||
if (unavailable.isLoading()) {
|
||||
throw unavailable;
|
||||
}
|
||||
return;
|
||||
}
|
||||
IrisMarker resolved = environment.data().getMarkerLoader().load(tag);
|
||||
if (resolved == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (resolved.isEmptyAbove() && aboveObstructed(level, worldX, worldY, worldZ)) {
|
||||
obstructed.add(new IrisPosition(worldX, yf, worldZ));
|
||||
return;
|
||||
}
|
||||
|
||||
KList<IrisSpawner> spawners = resolveMarkerSpawners(resolved);
|
||||
KList<IrisSpawner> spawners = resolveMarkerSpawners(resolved, environment);
|
||||
if (spawners.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
@@ -418,18 +436,19 @@ public final class ModdedWorldManager implements EngineWorldManager {
|
||||
if (chosen == null) {
|
||||
return;
|
||||
}
|
||||
spawnFromSpawner(level, new IrisPosition(worldX, worldY, worldZ), chosen, initial);
|
||||
prepared.add(new PreparedMarkerSpawn(new IrisPosition(worldX, worldY, worldZ), chosen, environment));
|
||||
});
|
||||
Mantle<Matter> mantle = engine.getMantle().getMantle();
|
||||
for (IrisPosition position : obstructed) {
|
||||
mantle.remove(position.getX(), position.getY(), position.getZ(), MatterMarker.class);
|
||||
}
|
||||
return List.copyOf(prepared);
|
||||
}
|
||||
|
||||
private KList<IrisSpawner> resolveMarkerSpawners(IrisMarker marker) {
|
||||
private KList<IrisSpawner> resolveMarkerSpawners(IrisMarker marker, BiomeEnvironment environment) {
|
||||
KList<IrisSpawner> spawners = new KList<>();
|
||||
for (String key : marker.getSpawners()) {
|
||||
IrisSpawner spawner = engine.getData().getSpawnerLoader().load(key);
|
||||
IrisSpawner spawner = environment.data().getSpawnerLoader().load(key);
|
||||
if (spawner == null) {
|
||||
IrisLogging.error("Cannot load spawner: " + key + " for marker on " + engine.getName());
|
||||
continue;
|
||||
@@ -440,6 +459,14 @@ public final class ModdedWorldManager implements EngineWorldManager {
|
||||
return spawners;
|
||||
}
|
||||
|
||||
private void spawnPreparedMarkers(ServerLevel level, List<PreparedMarkerSpawn> markers, boolean initial) {
|
||||
for (PreparedMarkerSpawn marker : markers) {
|
||||
try (BiomeEnvironment.Scope ignored = engine.openBiomeEnvironmentScope(marker.environment())) {
|
||||
spawnFromSpawner(level, marker.position(), marker.spawner(), initial);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void spawnFromSpawner(ServerLevel level, IrisPosition position, IrisSpawner spawner, boolean initial) {
|
||||
KList<IrisEntitySpawn> spawns = initial ? spawner.getInitialSpawns() : spawner.getSpawns();
|
||||
if (spawns.isEmpty()) {
|
||||
@@ -465,22 +492,41 @@ public final class ModdedWorldManager implements EngineWorldManager {
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<BiomeEnvironment> resolveSurfaceEnvironment(int chunkX, int chunkZ) {
|
||||
try {
|
||||
return Optional.of(engine.getSurfaceBiomeEnvironment((chunkX << 4) + 8, (chunkZ << 4) + 8));
|
||||
} catch (SavedBiomeUnavailableException unavailable) {
|
||||
if (unavailable.isLoading()) {
|
||||
throw unavailable;
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private void spawnAmbient(ServerLevel level, int chunkX, int chunkZ, boolean initial) {
|
||||
Optional<BiomeEnvironment> resolved = resolveSurfaceEnvironment(chunkX, chunkZ);
|
||||
if (resolved.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
BiomeEnvironment environment = resolved.get();
|
||||
try (BiomeEnvironment.Scope ignored = engine.openBiomeEnvironmentScope(environment)) {
|
||||
spawnAmbient(level, chunkX, chunkZ, initial, environment);
|
||||
}
|
||||
}
|
||||
|
||||
private void spawnAmbient(ServerLevel level, int chunkX, int chunkZ, boolean initial, BiomeEnvironment environment) {
|
||||
IrisComplex complex = engine.getComplex();
|
||||
if (complex == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
int blockX = chunkX << 4;
|
||||
int blockZ = chunkZ << 4;
|
||||
IrisBiome biome = engine.getSurfaceBiome(blockX, blockZ);
|
||||
IrisRegion region = engine.getRegion(blockX, blockZ);
|
||||
IrisBiome biome = environment.biome();
|
||||
int chunkMobs = countChunkLivingEntities(level, chunkX, chunkZ);
|
||||
|
||||
KList<IrisEntitySpawn> pool = new KList<>();
|
||||
collectSpawns(pool, engine.getData().getSpawnerLoader().loadAll(engine.getDimension().getEntitySpawners()), biome, chunkX, chunkZ, chunkMobs, initial);
|
||||
collectSpawns(pool, engine.getData().getSpawnerLoader().loadAll(region.getEntitySpawners()), null, chunkX, chunkZ, chunkMobs, initial);
|
||||
collectSpawns(pool, engine.getData().getSpawnerLoader().loadAll(biome.getEntitySpawners()), null, chunkX, chunkZ, chunkMobs, initial);
|
||||
collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.dimension().getEntitySpawners()), biome, chunkX, chunkZ, chunkMobs, initial);
|
||||
collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.region().getEntitySpawners()), null, chunkX, chunkZ, chunkMobs, initial);
|
||||
collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(biome.getEntitySpawners()), null, chunkX, chunkZ, chunkMobs, initial);
|
||||
if (pool.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
@@ -556,8 +602,8 @@ public final class ModdedWorldManager implements EngineWorldManager {
|
||||
} else {
|
||||
worldX = (chunkX << 4) + RNG.r.i(16);
|
||||
worldZ = (chunkZ << 4) + RNG.r.i(16);
|
||||
int surfaceY = level.getMinY() + engine.getHeight(worldX, worldZ, false);
|
||||
int solidY = level.getMinY() + engine.getHeight(worldX, worldZ, true);
|
||||
int surfaceY = level.getHeight(Heightmap.Types.WORLD_SURFACE, worldX, worldZ) - 1;
|
||||
int solidY = level.getHeight(Heightmap.Types.OCEAN_FLOOR, worldX, worldZ) - 1;
|
||||
worldY = group == IrisSpawnGroup.NORMAL
|
||||
? surfaceY + 1
|
||||
: RNG.r.i(solidY + 1, surfaceY);
|
||||
@@ -864,4 +910,8 @@ public final class ModdedWorldManager implements EngineWorldManager {
|
||||
}
|
||||
return failure;
|
||||
}
|
||||
|
||||
private record PreparedMarkerSpawn(IrisPosition position, IrisSpawner spawner, BiomeEnvironment environment) {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+15
@@ -24,6 +24,7 @@ import art.arcane.iris.core.localization.IrisMessages;
|
||||
import art.arcane.iris.modded.localization.ModdedCommandMessages;
|
||||
import art.arcane.iris.core.localization.RuntimeUiMessages;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.history.SavedBiomeUnavailableException;
|
||||
import art.arcane.iris.engine.framework.GenerationSessionException;
|
||||
import art.arcane.iris.engine.framework.GenerationSessionLease;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
@@ -194,6 +195,13 @@ public final class ModdedWhatCommands {
|
||||
MessageArgument.untrusted("biome", nativeBiome.key()),
|
||||
MessageArgument.trusted("id", nativeBiome.id())));
|
||||
return 1;
|
||||
} catch (SavedBiomeUnavailableException error) {
|
||||
if (error.getCause() != null) {
|
||||
ModdedIrisLog.error("Iris saved biome lookup failed in {}",
|
||||
source.getLevel().dimension().identifier(), error);
|
||||
}
|
||||
IrisModdedCommands.fail(source, error.getMessage());
|
||||
return 0;
|
||||
} catch (Throwable error) {
|
||||
logLookupFailure(source, "biome", error,
|
||||
ModdedCommandMessages.IRIS_MODDED_COMMANDS_BIOME_LOOKUP_FAILED_2);
|
||||
@@ -223,6 +231,13 @@ public final class ModdedWhatCommands {
|
||||
MessageArgument.untrusted("region", region.getLoadKey()),
|
||||
MessageArgument.untrusted("name", region.getName())));
|
||||
return 1;
|
||||
} catch (SavedBiomeUnavailableException error) {
|
||||
if (error.getCause() != null) {
|
||||
ModdedIrisLog.error("Iris saved biome lookup failed in {}",
|
||||
source.getLevel().dimension().identifier(), error);
|
||||
}
|
||||
IrisModdedCommands.fail(source, error.getMessage());
|
||||
return 0;
|
||||
} catch (Throwable error) {
|
||||
logLookupFailure(source, "region", error,
|
||||
ModdedCommandMessages.IRIS_MODDED_COMMANDS_REGION_LOOKUP_FAILED_2);
|
||||
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.iris.core.IrisSettings;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.engine.framework.BiomeEnvironment;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.history.SavedBiomeUnavailableException;
|
||||
import art.arcane.iris.engine.mantle.EngineMantle;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisRegion;
|
||||
import art.arcane.iris.engine.object.IrisWorld;
|
||||
import art.arcane.volmlib.util.mantle.runtime.Mantle;
|
||||
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
|
||||
import art.arcane.volmlib.util.matter.Matter;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import org.junit.Test;
|
||||
import org.junit.BeforeClass;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.iris.util.common.data.B;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class ModdedSavedBiomeConsumersTest {
|
||||
@BeforeClass
|
||||
public static void initializeRuntimeTypes() throws Exception {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
PlatformBlockState air = mock(PlatformBlockState.class);
|
||||
try (MockedStatic<B> blocks = mockStatic(B.class)) {
|
||||
blocks.when(() -> B.getState("AIR")).thenReturn(air);
|
||||
Class.forName(EngineMantle.class.getName());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void loadingDefinitionsLeaveInitialFlagUnclaimed() {
|
||||
Engine engine = mock(Engine.class);
|
||||
EngineMantle engineMantle = mock(EngineMantle.class);
|
||||
Mantle<Matter> mantle = mock(Mantle.class);
|
||||
MantleChunk<Matter> chunk = mock(MantleChunk.class);
|
||||
ServerLevel level = mock(ServerLevel.class);
|
||||
when(engine.getMantle()).thenReturn(engineMantle);
|
||||
when(engine.getWorld()).thenReturn(mock(IrisWorld.class));
|
||||
when(engineMantle.getMantle()).thenReturn(mantle);
|
||||
when(mantle.isChunkLoaded(2, -1)).thenReturn(true);
|
||||
when(mantle.getChunk(2, -1)).thenReturn(chunk);
|
||||
when(chunk.use()).thenReturn(chunk);
|
||||
BiomeEnvironment environment = environment();
|
||||
when(engine.getSurfaceBiomeEnvironment(40, -8))
|
||||
.thenThrow(new SavedBiomeUnavailableException("Loading", true))
|
||||
.thenReturn(environment);
|
||||
IrisSettings settings = new IrisSettings();
|
||||
settings.getWorld().setAmbientEntitySpawningSystem(true);
|
||||
settings.getWorld().setMarkerEntitySpawningSystem(false);
|
||||
ModdedWorldManager manager = new ModdedWorldManager(engine);
|
||||
|
||||
try (MockedStatic<IrisSettings> configured = mockStatic(IrisSettings.class);
|
||||
MockedStatic<ModdedEntitySpawner> spawns = mockStatic(ModdedEntitySpawner.class)) {
|
||||
configured.when(IrisSettings::get).thenReturn(settings);
|
||||
spawns.when(() -> ModdedEntitySpawner.chunksSafe(level, 2, -1)).thenReturn(true);
|
||||
assertThrows(SavedBiomeUnavailableException.class, () -> manager.initialSpawnChunk(level, 2, -1));
|
||||
verify(chunk, never()).raiseFlagUnchecked(eq(ModdedWorldManager.INITIAL_SPAWN_COMPLETION_FLAG), any());
|
||||
assertTrue(manager.initialSpawnChunk(level, 2, -1));
|
||||
verify(chunk).raiseFlagUnchecked(eq(ModdedWorldManager.INITIAL_SPAWN_COMPLETION_FLAG), any());
|
||||
} finally {
|
||||
manager.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void playerEffectsRetryLoadingWithoutMovement() throws Exception {
|
||||
Engine engine = mock(Engine.class);
|
||||
IrisWorld world = mock(IrisWorld.class);
|
||||
ServerLevel level = mock(ServerLevel.class);
|
||||
ServerPlayer player = mock(ServerPlayer.class);
|
||||
when(engine.getWorld()).thenReturn(world);
|
||||
when(world.minHeight()).thenReturn(-64);
|
||||
when(player.isAlive()).thenReturn(true);
|
||||
when(player.level()).thenReturn(level);
|
||||
BiomeEnvironment environment = environment();
|
||||
when(engine.getBiomeEnvironment(0, 64, 0))
|
||||
.thenThrow(new SavedBiomeUnavailableException("Loading", true))
|
||||
.thenReturn(environment);
|
||||
IrisSettings settings = new IrisSettings();
|
||||
settings.getWorld().setEffectSystem(false);
|
||||
Class<?> stateType = Class.forName(ModdedEngineEffects.class.getName() + "$PlayerState");
|
||||
Constructor<?> constructor = stateType.getDeclaredConstructor(ServerPlayer.class);
|
||||
constructor.setAccessible(true);
|
||||
Object state = constructor.newInstance(player);
|
||||
Method tick = ModdedEngineEffects.class.getDeclaredMethod("tickPlayer", ServerLevel.class, stateType);
|
||||
tick.setAccessible(true);
|
||||
Field selected = stateType.getDeclaredField("environment");
|
||||
selected.setAccessible(true);
|
||||
|
||||
try (MockedStatic<IrisSettings> configured = mockStatic(IrisSettings.class)) {
|
||||
configured.when(IrisSettings::get).thenReturn(settings);
|
||||
ModdedEngineEffects effects = new ModdedEngineEffects(engine);
|
||||
tick.invoke(effects, level, state);
|
||||
assertNull(selected.get(state));
|
||||
tick.invoke(effects, level, state);
|
||||
assertSame(environment, selected.get(state));
|
||||
verify(engine, times(2)).getBiomeEnvironment(0, 64, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private static BiomeEnvironment environment() {
|
||||
return new BiomeEnvironment(3L, mock(IrisBiome.class), mock(IrisRegion.class),
|
||||
mock(IrisDimension.class), mock(IrisData.class));
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import art.arcane.iris.engine.EngineBackgroundTasks.BackgroundTaskDrain;
|
||||
import art.arcane.iris.engine.EngineRuntimeBuilder.RuntimeAssembly;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.BiomeEnvironment;
|
||||
import art.arcane.iris.engine.framework.EngineEffects;
|
||||
import art.arcane.iris.engine.framework.EngineMetrics;
|
||||
import art.arcane.iris.engine.framework.EngineMode;
|
||||
@@ -130,6 +131,9 @@ public class IrisEngine implements Engine {
|
||||
final ThreadLocal<RuntimeAssembly> runtimeAssembly = new ThreadLocal<>();
|
||||
@Getter(AccessLevel.NONE)
|
||||
@Setter(AccessLevel.NONE)
|
||||
final ThreadLocal<BiomeEnvironmentBinding> biomeEnvironmentScopes = new ThreadLocal<>();
|
||||
@Getter(AccessLevel.NONE)
|
||||
@Setter(AccessLevel.NONE)
|
||||
final GenerationRuntimeScopeState generationRuntimeScopes = new GenerationRuntimeScopeState();
|
||||
@Getter(AccessLevel.NONE)
|
||||
@Setter(AccessLevel.NONE)
|
||||
@@ -543,18 +547,112 @@ public class IrisEngine implements Engine {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public BiomeEnvironment getBiomeEnvironment(int x, int y, int z) {
|
||||
Optional<BiomeEnvironment> saved = resolveSavedBiomeEnvironment(x, y, z, false);
|
||||
if (saved.isPresent()) {
|
||||
return saved.get();
|
||||
}
|
||||
try (GenerationHistoryRuntimeRouter.CoordinateScope ignored =
|
||||
openGenerationHistoryCoordinateScopeUnchecked(x, z, "resolve a biome environment")) {
|
||||
return Engine.super.getBiomeEnvironment(x, y, z);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public BiomeEnvironment getSurfaceBiomeEnvironment(int x, int z) {
|
||||
Optional<BiomeEnvironment> saved = resolveSavedBiomeEnvironment(x, 0, z, true);
|
||||
if (saved.isPresent()) {
|
||||
return saved.get();
|
||||
}
|
||||
try (GenerationHistoryRuntimeRouter.CoordinateScope ignored =
|
||||
openGenerationHistoryCoordinateScopeUnchecked(x, z, "resolve a surface biome environment")) {
|
||||
return Engine.super.getSurfaceBiomeEnvironment(x, z);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public BiomeEnvironment.Scope openBiomeEnvironmentScope(BiomeEnvironment environment) {
|
||||
BiomeEnvironmentBinding binding = new BiomeEnvironmentBinding(
|
||||
Objects.requireNonNull(environment, "environment"), biomeEnvironmentScopes.get());
|
||||
biomeEnvironmentScopes.set(binding);
|
||||
return binding;
|
||||
}
|
||||
|
||||
private Optional<BiomeEnvironment> resolveSavedBiomeEnvironment(int x, int y, int z, boolean surface) {
|
||||
if (!usesSavedBiomeEnvironment()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return generationHistoryRuntimeRouter.biomes().resolve(x, y + getWorld().minHeight(), z, surface);
|
||||
}
|
||||
|
||||
private boolean usesSavedBiomeEnvironment() {
|
||||
if (hasGenerationRuntimeScope() || generationHistoryRuntimeRouter == null) {
|
||||
return false;
|
||||
}
|
||||
IrisContext context = IrisContext.get();
|
||||
return context == null || context.getEngine() != this || context.getChunkContext() == null;
|
||||
}
|
||||
|
||||
@BlockCoordinates
|
||||
@Override
|
||||
public IrisBiome getBiome(int x, int y, int z) {
|
||||
Optional<BiomeEnvironment> saved = resolveSavedBiomeEnvironment(x, y, z, false);
|
||||
if (saved.isPresent()) {
|
||||
return saved.get().biome();
|
||||
}
|
||||
try (GenerationHistoryRuntimeRouter.CoordinateScope ignored =
|
||||
openGenerationHistoryCoordinateScopeUnchecked(x, z, "resolve a biome")) {
|
||||
return Engine.super.getBiome(x, y, z);
|
||||
}
|
||||
}
|
||||
|
||||
@BlockCoordinates
|
||||
@Override
|
||||
public IrisBiome getBiomeOrMantle(int x, int y, int z) {
|
||||
Optional<BiomeEnvironment> saved = resolveSavedBiomeEnvironment(x, y, z, false);
|
||||
if (saved.isPresent()) {
|
||||
return saved.get().biome();
|
||||
}
|
||||
try (GenerationHistoryRuntimeRouter.CoordinateScope ignored =
|
||||
openGenerationHistoryCoordinateScopeUnchecked(x, z, "resolve a biome or mantle biome")) {
|
||||
return Engine.super.getBiomeOrMantle(x, y, z);
|
||||
}
|
||||
}
|
||||
|
||||
@BlockCoordinates
|
||||
@Override
|
||||
public IrisRegion getRegion(int x, int z) {
|
||||
Optional<BiomeEnvironment> saved = resolveSavedBiomeEnvironment(x, 0, z, true);
|
||||
if (saved.isPresent()) {
|
||||
return saved.get().region();
|
||||
}
|
||||
try (GenerationHistoryRuntimeRouter.CoordinateScope ignored =
|
||||
openGenerationHistoryCoordinateScopeUnchecked(x, z, "resolve a region")) {
|
||||
return Engine.super.getRegion(x, z);
|
||||
}
|
||||
}
|
||||
|
||||
@BlockCoordinates
|
||||
@Override
|
||||
public IrisRegion getRegion(int x, int y, int z) {
|
||||
Optional<BiomeEnvironment> saved = resolveSavedBiomeEnvironment(x, y, z, false);
|
||||
if (saved.isPresent()) {
|
||||
return saved.get().region();
|
||||
}
|
||||
try (GenerationHistoryRuntimeRouter.CoordinateScope ignored =
|
||||
openGenerationHistoryCoordinateScopeUnchecked(x, z, "resolve a vertical region")) {
|
||||
return Engine.super.getRegion(x, y, z);
|
||||
}
|
||||
}
|
||||
|
||||
@BlockCoordinates
|
||||
@Override
|
||||
public IrisBiome getCaveOrMantleBiome(int x, int y, int z) {
|
||||
Optional<BiomeEnvironment> saved = resolveSavedBiomeEnvironment(x, y, z, false);
|
||||
if (saved.isPresent()) {
|
||||
return saved.get().biome();
|
||||
}
|
||||
try (GenerationHistoryRuntimeRouter.CoordinateScope ignored =
|
||||
openGenerationHistoryCoordinateScopeUnchecked(x, z, "resolve a cave or mantle biome")) {
|
||||
return Engine.super.getCaveOrMantleBiome(x, y, z);
|
||||
@@ -564,6 +662,12 @@ public class IrisEngine implements Engine {
|
||||
@BlockCoordinates
|
||||
@Override
|
||||
public IrisBiome getCaveBiome(int x, int z) {
|
||||
if (usesSavedBiomeEnvironment()) {
|
||||
Optional<BiomeEnvironment> saved = generationHistoryRuntimeRouter.biomes().resolveCaveBase(x, z);
|
||||
if (saved.isPresent()) {
|
||||
return saved.get().biome();
|
||||
}
|
||||
}
|
||||
if (usesScopedNaturalTerrain()) {
|
||||
return Engine.super.getCaveBiome(x, z);
|
||||
}
|
||||
@@ -587,6 +691,10 @@ public class IrisEngine implements Engine {
|
||||
int z,
|
||||
IrisDimensionCarvingResolver.State state
|
||||
) {
|
||||
Optional<BiomeEnvironment> saved = resolveSavedBiomeEnvironment(x, y, z, false);
|
||||
if (saved.isPresent()) {
|
||||
return saved.get().biome();
|
||||
}
|
||||
if (usesScopedNaturalTerrain()) {
|
||||
return Engine.super.getCaveBiome(x, y, z, state);
|
||||
}
|
||||
@@ -599,6 +707,10 @@ public class IrisEngine implements Engine {
|
||||
@BlockCoordinates
|
||||
@Override
|
||||
public IrisBiome getSurfaceBiome(int x, int z) {
|
||||
Optional<BiomeEnvironment> saved = resolveSavedBiomeEnvironment(x, 0, z, true);
|
||||
if (saved.isPresent()) {
|
||||
return saved.get().biome();
|
||||
}
|
||||
if (usesScopedNaturalTerrain()) {
|
||||
return Engine.super.getSurfaceBiome(x, z);
|
||||
}
|
||||
@@ -1183,6 +1295,10 @@ public class IrisEngine implements Engine {
|
||||
|
||||
@Override
|
||||
public IrisData getData() {
|
||||
BiomeEnvironmentBinding environment = biomeEnvironmentScopes.get();
|
||||
if (environment != null && !hasGenerationRuntimeScope()) {
|
||||
return environment.environment.data();
|
||||
}
|
||||
RuntimeAssembly assembly = runtimeAssembly.get();
|
||||
if (assembly != null) {
|
||||
return assembly.target.getData();
|
||||
@@ -1193,6 +1309,10 @@ public class IrisEngine implements Engine {
|
||||
|
||||
@Override
|
||||
public IrisDimension getDimension() {
|
||||
BiomeEnvironmentBinding environment = biomeEnvironmentScopes.get();
|
||||
if (environment != null && !hasGenerationRuntimeScope()) {
|
||||
return environment.environment.dimension();
|
||||
}
|
||||
RuntimeAssembly assembly = runtimeAssembly.get();
|
||||
if (assembly != null) {
|
||||
return assembly.target.getDimension();
|
||||
@@ -1608,4 +1728,36 @@ public class IrisEngine implements Engine {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private final class BiomeEnvironmentBinding implements BiomeEnvironment.Scope {
|
||||
private final BiomeEnvironment environment;
|
||||
private final BiomeEnvironmentBinding previous;
|
||||
private final Thread owner = Thread.currentThread();
|
||||
private boolean closed;
|
||||
|
||||
private BiomeEnvironmentBinding(BiomeEnvironment environment, BiomeEnvironmentBinding previous) {
|
||||
this.environment = environment;
|
||||
this.previous = previous;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (Thread.currentThread() != owner) {
|
||||
throw new IllegalStateException("Biome environment scope must close on its owning thread.");
|
||||
}
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
if (biomeEnvironmentScopes.get() != this) {
|
||||
throw new IllegalStateException("Biome environment scopes must close in reverse order.");
|
||||
}
|
||||
if (previous == null) {
|
||||
biomeEnvironmentScopes.remove();
|
||||
} else {
|
||||
biomeEnvironmentScopes.set(previous);
|
||||
}
|
||||
closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@
|
||||
|
||||
package art.arcane.iris.engine;
|
||||
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.engine.framework.BiomeEnvironment;
|
||||
import art.arcane.iris.engine.history.SavedBiomeUnavailableException;
|
||||
|
||||
import art.arcane.iris.engine.data.cache.Cache;
|
||||
import art.arcane.iris.engine.object.IrisMarker;
|
||||
import art.arcane.iris.engine.object.IrisPosition;
|
||||
@@ -28,13 +32,16 @@ import art.arcane.iris.util.common.scheduling.J;
|
||||
import art.arcane.volmlib.util.collection.KMap;
|
||||
import art.arcane.volmlib.util.collection.KSet;
|
||||
import art.arcane.volmlib.util.matter.MatterMarker;
|
||||
import art.arcane.volmlib.util.mantle.flag.MantleFlag;
|
||||
import org.bukkit.Chunk;
|
||||
import org.bukkit.World;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Resolves the mantle spawn markers of a chunk into spawners. On Folia the mantle read runs
|
||||
@@ -49,80 +56,10 @@ final class MarkerSpawnScanner {
|
||||
this.manager = manager;
|
||||
}
|
||||
|
||||
Map<IrisPosition, KSet<IrisSpawner>> getSpawnersFromMarkers(Chunk c) {
|
||||
Map<IrisPosition, KSet<IrisSpawner>> p = new KMap<>();
|
||||
Set<IrisPosition> b = new KSet<>();
|
||||
|
||||
if (J.isFolia()) {
|
||||
if (!manager.getMantle().isChunkLoaded(c.getX(), c.getZ())) {
|
||||
manager.chunkMaintenance.warmupMantleChunkAsync(c.getX(), c.getZ());
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
manager.getMantle().iterateChunk(c.getX(), c.getZ(), MatterMarker.class, (x, y, z, t) -> {
|
||||
if (t.getTag().equals("cave_floor") || t.getTag().equals("cave_ceiling")) {
|
||||
return;
|
||||
}
|
||||
|
||||
IrisMarker mark = manager.getData().getMarkerLoader().load(t.getTag());
|
||||
if (mark == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
IrisPosition pos = new IrisPosition((c.getX() << 4) + x, y, (c.getZ() << 4) + z);
|
||||
|
||||
if (isMarkerObstructed(c, pos, mark.isEmptyAbove())) {
|
||||
b.add(pos);
|
||||
return;
|
||||
}
|
||||
|
||||
for (String i : mark.getSpawners()) {
|
||||
IrisSpawner m = manager.getData().getSpawnerLoader().load(i);
|
||||
if (m == null) {
|
||||
IrisLogging.error("Cannot load spawner: " + i + " for marker on " + manager.getName());
|
||||
continue;
|
||||
}
|
||||
if (m.isCompatExcluded()) {
|
||||
continue;
|
||||
}
|
||||
m.setReferenceMarker(mark);
|
||||
|
||||
// This is so fucking incorrect its a joke
|
||||
//noinspection ConstantConditions
|
||||
if (m != null) {
|
||||
p.computeIfAbsent(pos, (k) -> new KSet<>()).add(m);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (IrisPosition i : b) {
|
||||
manager.getEngine().getMantle().getMantle().remove(i.getX(), i.getY(), i.getZ(), MatterMarker.class);
|
||||
}
|
||||
|
||||
return p;
|
||||
}
|
||||
|
||||
void forEachMarkerSpawner(Chunk c, BiConsumer<IrisPosition, KSet<IrisSpawner>> consumer) {
|
||||
if (c == null || consumer == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!J.isFolia()) {
|
||||
int minY = manager.getEngine().getWorld().minHeight();
|
||||
getSpawnersFromMarkers(c).forEach((relative, spawners) -> {
|
||||
if (spawners.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
consumer.accept(new IrisPosition(relative.getX(), relative.getY() + minY, relative.getZ()), spawners);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
int chunkX = c.getX();
|
||||
int chunkZ = c.getZ();
|
||||
World world = c.getWorld();
|
||||
void scanMarkerSpawners(Chunk chunk, boolean initialOnly, Consumer<List<PreparedMarkerSpawn>> consumer) {
|
||||
int chunkX = chunk.getX();
|
||||
int chunkZ = chunk.getZ();
|
||||
World world = chunk.getWorld();
|
||||
long key = Cache.key(chunkX, chunkZ);
|
||||
if (!markerScanQueue.add(key)) {
|
||||
return;
|
||||
@@ -130,57 +67,82 @@ final class MarkerSpawnScanner {
|
||||
|
||||
J.a(manager.managedTask("bukkit_world_manager_marker_scan", () -> {
|
||||
try {
|
||||
Map<IrisPosition, MarkerSpawnData> markerData = collectMarkerSpawnData(chunkX, chunkZ);
|
||||
if (markerData.isEmpty()) {
|
||||
if (initialOnly && manager.getMantle().hasFlag(chunkX, chunkZ, MantleFlag.INITIAL_SPAWNED_MARKER)) {
|
||||
markerScanQueue.remove(key);
|
||||
return;
|
||||
}
|
||||
|
||||
J.runRegion(world, chunkX, chunkZ, manager.managedTask("bukkit_world_manager_marker_scan_region", () -> {
|
||||
if (!world.isChunkLoaded(chunkX, chunkZ) || !Chunks.isSafe(world, chunkX, chunkZ)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Chunk chunk = world.getChunkAt(chunkX, chunkZ);
|
||||
int minY = manager.getEngine().getWorld().minHeight();
|
||||
markerData.forEach((relative, data) -> {
|
||||
if (data.spawners.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMarkerObstructed(chunk, relative, data.requiresEmptyAbove)) {
|
||||
removeMarkerAsync(relative);
|
||||
return;
|
||||
}
|
||||
|
||||
consumer.accept(new IrisPosition(relative.getX(), relative.getY() + minY, relative.getZ()), data.spawners);
|
||||
});
|
||||
}));
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError(e);
|
||||
} finally {
|
||||
Map<IrisPosition, MarkerSpawnData> markerData = collectMarkerSpawnData(chunkX, chunkZ);
|
||||
boolean accepted = J.runRegion(world, chunkX, chunkZ,
|
||||
manager.managedTask("bukkit_world_manager_marker_scan_region", () -> {
|
||||
try {
|
||||
if (!world.isChunkLoaded(chunkX, chunkZ) || !Chunks.isSafe(world, chunkX, chunkZ)) {
|
||||
return;
|
||||
}
|
||||
Chunk loaded = world.getChunkAt(chunkX, chunkZ);
|
||||
int minimumY = manager.getEngine().getWorld().minHeight();
|
||||
List<PreparedMarkerSpawn> prepared = new ArrayList<>(markerData.size());
|
||||
for (Map.Entry<IrisPosition, MarkerSpawnData> entry : markerData.entrySet()) {
|
||||
IrisPosition position = entry.getKey();
|
||||
MarkerSpawnData data = entry.getValue();
|
||||
if (data.spawners.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (isMarkerObstructed(loaded, position, data.requiresEmptyAbove)) {
|
||||
removeMarkerAsync(position);
|
||||
continue;
|
||||
}
|
||||
prepared.add(new PreparedMarkerSpawn(new IrisPosition(position.getX(),
|
||||
position.getY() + minimumY, position.getZ()), data.spawners, data.environment));
|
||||
}
|
||||
consumer.accept(List.copyOf(prepared));
|
||||
} finally {
|
||||
markerScanQueue.remove(key);
|
||||
}
|
||||
}, () -> markerScanQueue.remove(key)));
|
||||
if (!accepted) {
|
||||
markerScanQueue.remove(key);
|
||||
}
|
||||
} catch (SavedBiomeUnavailableException e) {
|
||||
markerScanQueue.remove(key);
|
||||
} catch (Throwable e) {
|
||||
markerScanQueue.remove(key);
|
||||
IrisLogging.reportError(e);
|
||||
}
|
||||
}, () -> markerScanQueue.remove(key)));
|
||||
}
|
||||
|
||||
private Map<IrisPosition, MarkerSpawnData> collectMarkerSpawnData(int chunkX, int chunkZ) {
|
||||
if (manager.getEngine() instanceof IrisEngine engine) {
|
||||
engine.getGenerationHistoryRuntimeRouter().ifPresent(router -> router.biomes().prepareChunk(chunkX, chunkZ));
|
||||
}
|
||||
Map<IrisPosition, MarkerSpawnData> markerData = new KMap<>();
|
||||
manager.getMantle().iterateChunk(chunkX, chunkZ, MatterMarker.class, (x, y, z, t) -> {
|
||||
if (t.getTag().equals("cave_floor") || t.getTag().equals("cave_ceiling")) {
|
||||
return;
|
||||
}
|
||||
|
||||
IrisMarker mark = manager.getData().getMarkerLoader().load(t.getTag());
|
||||
BiomeEnvironment environment;
|
||||
try {
|
||||
environment = manager.getEngine().getBiomeEnvironment((chunkX << 4) + x, y,
|
||||
(chunkZ << 4) + z);
|
||||
} catch (SavedBiomeUnavailableException e) {
|
||||
if (e.isLoading()) {
|
||||
throw e;
|
||||
}
|
||||
return;
|
||||
}
|
||||
IrisData definitions = environment.data();
|
||||
IrisMarker mark = definitions.getMarkerLoader().load(t.getTag());
|
||||
if (mark == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
IrisPosition position = new IrisPosition((chunkX << 4) + x, y, (chunkZ << 4) + z);
|
||||
MarkerSpawnData data = markerData.computeIfAbsent(position, k -> new MarkerSpawnData());
|
||||
MarkerSpawnData data = markerData.computeIfAbsent(position, k -> new MarkerSpawnData(environment));
|
||||
data.requiresEmptyAbove = data.requiresEmptyAbove || mark.isEmptyAbove();
|
||||
|
||||
for (String i : mark.getSpawners()) {
|
||||
IrisSpawner spawner = manager.getData().getSpawnerLoader().load(i);
|
||||
IrisSpawner spawner = definitions.getSpawnerLoader().load(i);
|
||||
if (spawner == null) {
|
||||
IrisLogging.error("Cannot load spawner: " + i + " for marker on " + manager.getName());
|
||||
continue;
|
||||
@@ -223,8 +185,16 @@ final class MarkerSpawnScanner {
|
||||
}));
|
||||
}
|
||||
|
||||
record PreparedMarkerSpawn(IrisPosition position, KSet<IrisSpawner> spawners, BiomeEnvironment environment) {
|
||||
}
|
||||
|
||||
private static final class MarkerSpawnData {
|
||||
private final KSet<IrisSpawner> spawners = new KSet<>();
|
||||
private final BiomeEnvironment environment;
|
||||
private boolean requiresEmptyAbove;
|
||||
|
||||
private MarkerSpawnData(BiomeEnvironment environment) {
|
||||
this.environment = environment;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.EngineMode;
|
||||
import art.arcane.iris.engine.history.BoundaryColumnGeometry;
|
||||
import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter;
|
||||
import art.arcane.iris.engine.history.FloatingBiomeOverlay;
|
||||
import art.arcane.iris.engine.history.SavedTerrainChunk;
|
||||
import art.arcane.iris.engine.history.TerrainBoundarySignature;
|
||||
import art.arcane.iris.engine.history.TransitionGenerationPlan;
|
||||
@@ -29,10 +30,10 @@ public final class ResolvedTerrainProvider {
|
||||
private static final int MAXIMUM_HEIGHT_CHUNKS = 4_096;
|
||||
|
||||
private final Engine engine;
|
||||
private final LinkedHashMap<Long, SavedTerrainChunk> completed = new LinkedHashMap<>(64, 0.75F, true);
|
||||
private final ConcurrentHashMap<Long, CompletableFuture<SavedTerrainChunk>> pending = new ConcurrentHashMap<>();
|
||||
private final LinkedHashMap<Long, SavedTerrainChunk> rawCompleted = new LinkedHashMap<>(64, 0.75F, true);
|
||||
private final ConcurrentHashMap<Long, CompletableFuture<SavedTerrainChunk>> rawPending = new ConcurrentHashMap<>();
|
||||
private final LinkedHashMap<Long, ResolvedTerrain> completed = new LinkedHashMap<>(64, 0.75F, true);
|
||||
private final ConcurrentHashMap<Long, CompletableFuture<ResolvedTerrain>> pending = new ConcurrentHashMap<>();
|
||||
private final LinkedHashMap<Long, ResolvedTerrain> rawCompleted = new LinkedHashMap<>(64, 0.75F, true);
|
||||
private final ConcurrentHashMap<Long, CompletableFuture<ResolvedTerrain>> rawPending = new ConcurrentHashMap<>();
|
||||
private final LinkedHashMap<Long, int[]> heights = new LinkedHashMap<>(64, 0.75F, true);
|
||||
|
||||
public ResolvedTerrainProvider(Engine engine) {
|
||||
@@ -40,7 +41,7 @@ public final class ResolvedTerrainProvider {
|
||||
}
|
||||
|
||||
public TerrainBoundarySignature column(int blockX, int blockZ) {
|
||||
return resolve(blockX >> 4, blockZ >> 4).column(blockX, blockZ);
|
||||
return resolve(blockX >> 4, blockZ >> 4).terrain().column(blockX, blockZ);
|
||||
}
|
||||
|
||||
public int height(int blockX, int blockZ, boolean ignoreFluid) {
|
||||
@@ -51,7 +52,7 @@ public final class ResolvedTerrainProvider {
|
||||
return cached[(ignoreFluid ? 256 : 0) + (blockX & 15) * 16 + (blockZ & 15)];
|
||||
}
|
||||
}
|
||||
TerrainBoundarySignature column = resolve(blockX >> 4, blockZ >> 4).column(blockX, blockZ);
|
||||
TerrainBoundarySignature column = resolve(blockX >> 4, blockZ >> 4).terrain().column(blockX, blockZ);
|
||||
return ignoreFluid ? column.oceanFloorHeight() : column.surfaceHeight();
|
||||
}
|
||||
|
||||
@@ -60,7 +61,9 @@ public final class ResolvedTerrainProvider {
|
||||
SavedTerrainChunk terrain;
|
||||
if (context.getComplex().getTransitionGenerationPlan() != null
|
||||
&& context.getComplex().getTransitionGenerationPlan().hasTransitionAtChunk(x >> 4, z >> 4)) {
|
||||
terrain = resolve(x >> 4, z >> 4);
|
||||
ResolvedTerrain resolved = resolve(x >> 4, z >> 4);
|
||||
terrain = resolved.terrain();
|
||||
context.setFloatingBiomes(resolved.floatingBiomes());
|
||||
copy(terrain, blocks, biomes, context);
|
||||
} else {
|
||||
mode.generateTerrain(x, z, blocks, biomes, multicore, context);
|
||||
@@ -70,6 +73,7 @@ public final class ResolvedTerrainProvider {
|
||||
GenerationHistoryRuntimeRouter router = irisEngine.getGenerationHistoryRuntimeRouter().orElse(null);
|
||||
if (router != null) {
|
||||
router.recordNaturalTerrain(terrain.boundaryOnly());
|
||||
router.recordFloatingBiomes(x >> 4, z >> 4, context.getFloatingBiomes());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,36 +90,36 @@ public final class ResolvedTerrainProvider {
|
||||
}
|
||||
}
|
||||
|
||||
private SavedTerrainChunk resolve(int chunkX, int chunkZ) {
|
||||
private ResolvedTerrain resolve(int chunkX, int chunkZ) {
|
||||
return resolve(chunkX, chunkZ, false);
|
||||
}
|
||||
|
||||
private SavedTerrainChunk resolve(int chunkX, int chunkZ, boolean raw) {
|
||||
LinkedHashMap<Long, SavedTerrainChunk> cache = raw ? rawCompleted : completed;
|
||||
ConcurrentHashMap<Long, CompletableFuture<SavedTerrainChunk>> loads = raw ? rawPending : pending;
|
||||
private ResolvedTerrain resolve(int chunkX, int chunkZ, boolean raw) {
|
||||
LinkedHashMap<Long, ResolvedTerrain> cache = raw ? rawCompleted : completed;
|
||||
ConcurrentHashMap<Long, CompletableFuture<ResolvedTerrain>> loads = raw ? rawPending : pending;
|
||||
long key = chunkKey(chunkX, chunkZ);
|
||||
synchronized (cache) {
|
||||
SavedTerrainChunk cached = cache.get(key);
|
||||
ResolvedTerrain cached = cache.get(key);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
CompletableFuture<SavedTerrainChunk> result = new CompletableFuture<>();
|
||||
CompletableFuture<SavedTerrainChunk> existing = loads.putIfAbsent(key, result);
|
||||
CompletableFuture<ResolvedTerrain> result = new CompletableFuture<>();
|
||||
CompletableFuture<ResolvedTerrain> existing = loads.putIfAbsent(key, result);
|
||||
if (existing != null) {
|
||||
return existing.join();
|
||||
}
|
||||
try {
|
||||
synchronized (cache) {
|
||||
SavedTerrainChunk published = cache.get(key);
|
||||
ResolvedTerrain published = cache.get(key);
|
||||
if (published != null) {
|
||||
result.complete(published);
|
||||
return published;
|
||||
}
|
||||
}
|
||||
SavedTerrainChunk terrain = raw ? compute(chunkX, chunkZ) : contain(chunkX, chunkZ);
|
||||
ResolvedTerrain terrain = raw ? compute(chunkX, chunkZ) : contain(chunkX, chunkZ);
|
||||
if (!raw) {
|
||||
cacheHeights(key, terrain);
|
||||
cacheHeights(key, terrain.terrain());
|
||||
}
|
||||
synchronized (cache) {
|
||||
cache.put(key, terrain);
|
||||
@@ -156,14 +160,15 @@ public final class ResolvedTerrainProvider {
|
||||
}
|
||||
}
|
||||
|
||||
private SavedTerrainChunk contain(int chunkX, int chunkZ) {
|
||||
SavedTerrainChunk raw = resolve(chunkX, chunkZ, true);
|
||||
private ResolvedTerrain contain(int chunkX, int chunkZ) {
|
||||
ResolvedTerrain raw = resolve(chunkX, chunkZ, true);
|
||||
TransitionGenerationPlan plan = engine.getComplex().getTransitionGenerationPlan();
|
||||
if (plan == null || !plan.hasTransitionAtChunk(chunkX, chunkZ)) {
|
||||
return raw;
|
||||
}
|
||||
try {
|
||||
return TransitionFluidContainment.contain(raw, plan, (x, z) -> rawColumn(plan, raw, x, z));
|
||||
return new ResolvedTerrain(TransitionFluidContainment.contain(raw.terrain(), plan,
|
||||
(x, z) -> rawColumn(plan, raw.terrain(), x, z)), raw.floatingBiomes());
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException("Unable to contain transition fluids at " + chunkX + "," + chunkZ, failure);
|
||||
}
|
||||
@@ -181,10 +186,10 @@ public final class ResolvedTerrainProvider {
|
||||
return geometry;
|
||||
}
|
||||
return (x >> 4) == current.chunkX() && (z >> 4) == current.chunkZ()
|
||||
? current.column(x, z).geometry() : resolve(x >> 4, z >> 4, true).column(x, z).geometry();
|
||||
? current.column(x, z).geometry() : resolve(x >> 4, z >> 4, true).terrain().column(x, z).geometry();
|
||||
}
|
||||
|
||||
private SavedTerrainChunk compute(int chunkX, int chunkZ) {
|
||||
private ResolvedTerrain compute(int chunkX, int chunkZ) {
|
||||
int x = Math.multiplyExact(chunkX, 16);
|
||||
int z = Math.multiplyExact(chunkZ, 16);
|
||||
try (GenerationHistoryRuntimeRouter.CoordinateScope runtimeScope = engine instanceof IrisEngine irisEngine
|
||||
@@ -198,7 +203,7 @@ public final class ResolvedTerrainProvider {
|
||||
Hunk<PlatformBiome> biomes = Hunk.newArrayHunk(16, engine.getHeight(), 16);
|
||||
try (IrisContext.Scope ignored = IrisContext.open(engine, sessionId, context)) {
|
||||
engine.getMode().generateTerrain(x, z, blocks, biomes, false, context);
|
||||
return capture(x, z, blocks, biomes, context, false);
|
||||
return new ResolvedTerrain(capture(x, z, blocks, biomes, context, false), context.getFloatingBiomes());
|
||||
}
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException("Unable to scope natural terrain at " + chunkX + "," + chunkZ, failure);
|
||||
@@ -207,6 +212,10 @@ public final class ResolvedTerrainProvider {
|
||||
|
||||
private SavedTerrainChunk capture(int x, int z, Hunk<PlatformBlockState> blocks,
|
||||
Hunk<PlatformBiome> biomes, ChunkContext context, boolean boundaryOnly) {
|
||||
FloatingBiomeOverlay floating = context.getFloatingBiomes();
|
||||
if (floating != null) {
|
||||
floating.retainHighestSurfaces((localX, localZ) -> highestSolid(blocks, localX, localZ));
|
||||
}
|
||||
try {
|
||||
return IrisTransitionGeometryActuator.capture(x, z, blocks, biomes, engine.getMinHeight(), context, boundaryOnly);
|
||||
} catch (IOException failure) {
|
||||
@@ -214,6 +223,16 @@ public final class ResolvedTerrainProvider {
|
||||
}
|
||||
}
|
||||
|
||||
private static int highestSolid(Hunk<PlatformBlockState> blocks, int localX, int localZ) {
|
||||
for (int y = blocks.getHeight() - 1; y >= 0; y--) {
|
||||
PlatformBlockState state = blocks.getRaw(localX, y, localZ);
|
||||
if (state != null && !state.isAir() && !state.isFluid()) {
|
||||
return y;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static void copy(SavedTerrainChunk terrain, Hunk<PlatformBlockState> blocks,
|
||||
Hunk<PlatformBiome> biomes, ChunkContext context) {
|
||||
PlatformRegistries registries = IrisPlatforms.get().registries();
|
||||
@@ -237,4 +256,8 @@ public final class ResolvedTerrainProvider {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record ResolvedTerrain(SavedTerrainChunk terrain, FloatingBiomeOverlay floatingBiomes) {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -20,18 +20,15 @@ package art.arcane.iris.engine;
|
||||
|
||||
import art.arcane.iris.core.IrisSettings;
|
||||
import art.arcane.iris.engine.data.cache.Cache;
|
||||
import art.arcane.iris.engine.object.IrisSpawner;
|
||||
import art.arcane.iris.engine.platform.EngineBukkitOps;
|
||||
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.util.common.plugin.Chunks;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.mantle.flag.MantleFlag;
|
||||
import art.arcane.volmlib.util.mantle.runtime.Mantle;
|
||||
import art.arcane.volmlib.util.math.PowerOfTwoCoordinates;
|
||||
import art.arcane.volmlib.util.math.Position2;
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
import art.arcane.volmlib.util.matter.Matter;
|
||||
import org.bukkit.Chunk;
|
||||
import org.bukkit.World;
|
||||
@@ -280,37 +277,9 @@ final class WorldChunkMaintenance {
|
||||
EngineBukkitOps.updateChunk(manager.getEngine(), chunk);
|
||||
}
|
||||
|
||||
if (!manager.entitySpawner.isEntitySpawningEnabledForCurrentWorld()) {
|
||||
return;
|
||||
if (manager.entitySpawner.isEntitySpawningEnabledForCurrentWorld()) {
|
||||
manager.entitySpawner.spawnInitially(chunk);
|
||||
}
|
||||
|
||||
if (!IrisSettings.get().getWorld().isMarkerEntitySpawningSystem()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!J.isFolia() && !manager.getMantle().isChunkLoaded(chunkX, chunkZ)) {
|
||||
warmupMantleChunkAsync(chunkX, chunkZ);
|
||||
return;
|
||||
}
|
||||
|
||||
raiseInitialSpawnMarkerFlag(world, chunkX, chunkZ, () -> {
|
||||
int delay = RNG.r.i(5, 200);
|
||||
J.runRegion(world, chunkX, chunkZ, manager.managedTask("bukkit_world_manager_initial_spawn_followup", () -> {
|
||||
if (!world.isChunkLoaded(chunkX, chunkZ)) {
|
||||
return;
|
||||
}
|
||||
manager.entitySpawner.spawnIn(world.getChunkAt(chunkX, chunkZ), true);
|
||||
}), delay);
|
||||
|
||||
Chunk markerChunk = world.getChunkAt(chunkX, chunkZ);
|
||||
manager.markerScanner.forEachMarkerSpawner(markerChunk, (block, spawners) -> {
|
||||
IrisSpawner s = new KList<>(spawners).getRandom();
|
||||
if (s == null) {
|
||||
return;
|
||||
}
|
||||
manager.entitySpawner.spawn(block, s, true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void raiseInitialSpawnMarkerFlag(World world, int chunkX, int chunkZ, Runnable onFirstRaise) {
|
||||
|
||||
@@ -22,11 +22,12 @@ import art.arcane.iris.core.IrisSettings;
|
||||
import art.arcane.iris.core.gui.PregeneratorJob;
|
||||
import art.arcane.iris.core.tools.IrisToolbelt;
|
||||
import art.arcane.iris.engine.object.IRare;
|
||||
import art.arcane.iris.engine.framework.BiomeEnvironment;
|
||||
import art.arcane.iris.engine.history.SavedBiomeUnavailableException;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisEntitySpawn;
|
||||
import art.arcane.iris.engine.object.IrisPosition;
|
||||
import art.arcane.iris.engine.object.IrisSpawner;
|
||||
import art.arcane.iris.engine.platform.EngineBukkitOps;
|
||||
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.util.common.plugin.Chunks;
|
||||
@@ -44,6 +45,8 @@ import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import art.arcane.iris.engine.MarkerSpawnScanner.PreparedMarkerSpawn;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
@@ -266,6 +269,10 @@ final class WorldEntitySpawner {
|
||||
}
|
||||
|
||||
void spawnIn(Chunk c, boolean initial) {
|
||||
if (initial) {
|
||||
spawnInitially(c);
|
||||
return;
|
||||
}
|
||||
if (manager.getEngine().isClosed()) {
|
||||
return;
|
||||
}
|
||||
@@ -280,17 +287,11 @@ final class WorldEntitySpawner {
|
||||
}
|
||||
|
||||
if (IrisSettings.get().getWorld().isMarkerEntitySpawningSystem()) {
|
||||
manager.markerScanner.forEachMarkerSpawner(c, (block, spawners) -> {
|
||||
IrisSpawner s = new KList<>(spawners).getRandom();
|
||||
if (s == null) {
|
||||
return;
|
||||
manager.markerScanner.scanMarkerSpawners(c, false, markers -> {
|
||||
for (PreparedMarkerSpawn marker : markers) {
|
||||
spawnPreparedMarker(marker, false);
|
||||
}
|
||||
|
||||
spawn(block, s, false);
|
||||
J.runRegion(c.getWorld(), c.getX(), c.getZ(), manager.managedTask(
|
||||
"bukkit_world_manager_marker_spawn_followup",
|
||||
() -> manager.chunkMaintenance.raiseInitialSpawnMarkerFlag(c.getWorld(), c.getX(), c.getZ(),
|
||||
() -> spawn(block, s, true))));
|
||||
prepareInitialSpawn(c, markers);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -298,26 +299,91 @@ final class WorldEntitySpawner {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
BiomeEnvironment environment = manager.getEngine().getSurfaceBiomeEnvironment(
|
||||
(c.getX() << 4) + 8, (c.getZ() << 4) + 8);
|
||||
try (BiomeEnvironment.Scope ignored = manager.getEngine().openBiomeEnvironmentScope(environment)) {
|
||||
spawnAmbient(c, initial, environment);
|
||||
}
|
||||
} catch (SavedBiomeUnavailableException e) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void spawnInitially(Chunk chunk) {
|
||||
if (manager.getEngine().isClosed() || !isEntitySpawningEnabledForCurrentWorld()) {
|
||||
return;
|
||||
}
|
||||
if (IrisSettings.get().getWorld().isMarkerEntitySpawningSystem()) {
|
||||
manager.markerScanner.scanMarkerSpawners(chunk, true, markers -> prepareInitialSpawn(chunk, markers));
|
||||
} else if (IrisSettings.get().getWorld().isAmbientEntitySpawningSystem()) {
|
||||
prepareInitialSpawn(chunk, List.of());
|
||||
}
|
||||
}
|
||||
|
||||
void prepareInitialSpawn(Chunk chunk, List<PreparedMarkerSpawn> markers) {
|
||||
Optional<BiomeEnvironment> environment = Optional.empty();
|
||||
if (IrisSettings.get().getWorld().isAmbientEntitySpawningSystem()) {
|
||||
try {
|
||||
environment = Optional.of(manager.getEngine().getSurfaceBiomeEnvironment(
|
||||
(chunk.getX() << 4) + 8, (chunk.getZ() << 4) + 8));
|
||||
} catch (SavedBiomeUnavailableException e) {
|
||||
if (e.isLoading()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
Optional<BiomeEnvironment> preparedEnvironment = environment;
|
||||
manager.chunkMaintenance.raiseInitialSpawnMarkerFlag(chunk.getWorld(), chunk.getX(), chunk.getZ(), () -> {
|
||||
for (PreparedMarkerSpawn marker : markers) {
|
||||
spawnPreparedMarker(marker, true);
|
||||
}
|
||||
if (preparedEnvironment.isPresent()) {
|
||||
J.runRegion(chunk.getWorld(), chunk.getX(), chunk.getZ(),
|
||||
manager.managedTask("bukkit_world_manager_initial_spawn_followup", () -> {
|
||||
if (!chunk.getWorld().isChunkLoaded(chunk.getX(), chunk.getZ())) {
|
||||
return;
|
||||
}
|
||||
BiomeEnvironment selected = preparedEnvironment.get();
|
||||
try (BiomeEnvironment.Scope ignored = manager.getEngine().openBiomeEnvironmentScope(selected)) {
|
||||
spawnAmbient(chunk, true, selected);
|
||||
}
|
||||
}), RNG.r.i(5, 200));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void spawnPreparedMarker(PreparedMarkerSpawn marker, boolean initial) {
|
||||
IrisSpawner spawner = new KList<>(marker.spawners()).getRandom();
|
||||
if (spawner == null || manager.getEngine().isClosed()) {
|
||||
return;
|
||||
}
|
||||
try (BiomeEnvironment.Scope ignored = manager.getEngine().openBiomeEnvironmentScope(marker.environment())) {
|
||||
spawnMarker(marker.position(), spawner, initial);
|
||||
}
|
||||
}
|
||||
|
||||
private void spawnAmbient(Chunk c, boolean initial, BiomeEnvironment environment) {
|
||||
//@builder
|
||||
// Excluded spawners cannot spawn anything on this Minecraft version, so they never enter the selection pool.
|
||||
Predicate<IrisSpawner> filter = i -> !i.isCompatExcluded() && i.canSpawn(manager.getEngine(), c.getX(), c.getZ());
|
||||
ChunkCounter counter = new ChunkCounter(c.getEntities());
|
||||
|
||||
IrisBiome biome = EngineBukkitOps.getSurfaceBiome(manager.getEngine(), c);
|
||||
IrisEntitySpawn v = spawnRandomly(Stream.concat(manager.getData().getSpawnerLoader()
|
||||
.loadAll(manager.getDimension().getEntitySpawners())
|
||||
IrisBiome biome = environment.biome();
|
||||
IrisEntitySpawn v = spawnRandomly(Stream.concat(environment.data().getSpawnerLoader()
|
||||
.loadAll(environment.dimension().getEntitySpawners())
|
||||
.shuffleCopy(RNG.r)
|
||||
.stream()
|
||||
.filter(filter)
|
||||
.filter((i) -> i.isValid(biome)),
|
||||
Stream.concat(manager.getData()
|
||||
Stream.concat(environment.data()
|
||||
.getSpawnerLoader()
|
||||
.loadAll(manager.getEngine().getRegion(PowerOfTwoCoordinates.chunkToBlock(c.getX()), PowerOfTwoCoordinates.chunkToBlock(c.getZ())).getEntitySpawners())
|
||||
.loadAll(environment.region().getEntitySpawners())
|
||||
.shuffleCopy(RNG.r)
|
||||
.stream()
|
||||
.filter(filter),
|
||||
manager.getData().getSpawnerLoader()
|
||||
.loadAll(manager.getEngine().getSurfaceBiome(PowerOfTwoCoordinates.chunkToBlock(c.getX()), PowerOfTwoCoordinates.chunkToBlock(c.getZ())).getEntitySpawners())
|
||||
environment.data().getSpawnerLoader()
|
||||
.loadAll(environment.biome().getEntitySpawners())
|
||||
.shuffleCopy(RNG.r)
|
||||
.stream()
|
||||
.filter(filter)))
|
||||
@@ -353,15 +419,7 @@ final class WorldEntitySpawner {
|
||||
}
|
||||
}
|
||||
|
||||
void spawn(IrisPosition block, IrisSpawner spawner, boolean initial) {
|
||||
if (manager.getEngine().isClosed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (spawner == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
private void spawnMarker(IrisPosition block, IrisSpawner spawner, boolean initial) {
|
||||
KList<IrisEntitySpawn> s = initial ? spawner.getInitialSpawns() : spawner.getSpawns();
|
||||
if (s.isEmpty()) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package art.arcane.iris.engine.framework;
|
||||
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisRegion;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public record BiomeEnvironment(long activationId, IrisBiome biome, IrisRegion region,
|
||||
IrisDimension dimension, IrisData data) {
|
||||
public BiomeEnvironment {
|
||||
if (activationId < 0) {
|
||||
throw new IllegalArgumentException("Biome activation cannot be negative.");
|
||||
}
|
||||
Objects.requireNonNull(biome, "biome");
|
||||
Objects.requireNonNull(region, "region");
|
||||
Objects.requireNonNull(dimension, "dimension");
|
||||
Objects.requireNonNull(data, "data");
|
||||
}
|
||||
|
||||
public interface Scope extends AutoCloseable {
|
||||
@Override
|
||||
void close();
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,19 @@ import java.util.stream.Collectors;
|
||||
public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, Hotloadable {
|
||||
IrisComplex getComplex();
|
||||
|
||||
default BiomeEnvironment getBiomeEnvironment(int x, int y, int z) {
|
||||
return new BiomeEnvironment(0L, getBiome(x, y, z), getRegion(x, y, z), getDimension(), getData());
|
||||
}
|
||||
|
||||
default BiomeEnvironment getSurfaceBiomeEnvironment(int x, int z) {
|
||||
return new BiomeEnvironment(0L, getSurfaceBiome(x, z), getRegion(x, z), getDimension(), getData());
|
||||
}
|
||||
|
||||
default BiomeEnvironment.Scope openBiomeEnvironmentScope(BiomeEnvironment environment) {
|
||||
return () -> {};
|
||||
}
|
||||
|
||||
|
||||
default @Nullable UpperDimensionContext getUpperContext() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import art.arcane.iris.core.IrisSettings;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisEffect;
|
||||
import art.arcane.iris.engine.object.IrisRegion;
|
||||
import art.arcane.iris.engine.platform.EngineBukkitOps;
|
||||
import art.arcane.iris.engine.history.SavedBiomeUnavailableException;
|
||||
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
@@ -35,6 +35,7 @@ import org.bukkit.entity.Player;
|
||||
public class EnginePlayer {
|
||||
private final Engine engine;
|
||||
private final Player player;
|
||||
private BiomeEnvironment environment;
|
||||
private IrisBiome biome;
|
||||
private IrisRegion region;
|
||||
private Location lastLocation;
|
||||
@@ -56,6 +57,12 @@ public class EnginePlayer {
|
||||
return;
|
||||
}
|
||||
|
||||
try (BiomeEnvironment.Scope ignored = engine.openBiomeEnvironmentScope(environment)) {
|
||||
applyEffects();
|
||||
}
|
||||
}
|
||||
|
||||
private void applyEffects() {
|
||||
if (region != null) {
|
||||
for (IrisEffect effect : region.getEffects()) {
|
||||
try {
|
||||
@@ -92,15 +99,22 @@ public class EnginePlayer {
|
||||
boolean sampled = lastLocation != null;
|
||||
double distanceSquared = sampled ? current.distanceSquared(lastLocation) : 0D;
|
||||
if (needsSample(sampled, ticksSinceLastSample(), distanceSquared)) {
|
||||
BiomeEnvironment sampledEnvironment = engine.getBiomeEnvironment(current.getBlockX(),
|
||||
current.getBlockY() - engine.getWorld().minHeight(), current.getBlockZ());
|
||||
environment = sampledEnvironment;
|
||||
biome = sampledEnvironment.biome();
|
||||
region = sampledEnvironment.region();
|
||||
lastLocation = current;
|
||||
lastSample = M.ms();
|
||||
biome = EngineBukkitOps.getBiome(engine, current);
|
||||
region = EngineBukkitOps.getRegion(engine, current);
|
||||
}
|
||||
return false;
|
||||
} catch (SavedBiomeUnavailableException e) {
|
||||
environment = null;
|
||||
biome = null;
|
||||
region = null;
|
||||
lastLocation = null;
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError(e);
|
||||
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.function.IntBinaryOperator;
|
||||
|
||||
public final class FloatingBiomeOverlay {
|
||||
private final int height;
|
||||
private final int quartHeight;
|
||||
private final Identity[] volume;
|
||||
private final Surface[] surfaces = new Surface[256];
|
||||
|
||||
public FloatingBiomeOverlay(int height) {
|
||||
if (height <= 0 || height > SavedBiomeChunk.MAXIMUM_HEIGHT) {
|
||||
throw new IllegalArgumentException("Invalid floating biome height: " + height);
|
||||
}
|
||||
this.height = height;
|
||||
quartHeight = (height + 3) / 4;
|
||||
volume = new Identity[16 * quartHeight];
|
||||
}
|
||||
|
||||
public void record(int localX, int y, int localZ, Identity identity) {
|
||||
requirePosition(localX, y, localZ);
|
||||
Objects.requireNonNull(identity, "identity");
|
||||
if ((localX & 3) == 0 && (localZ & 3) == 0 && (y & 3) == 0) {
|
||||
volume[index(localX, y, localZ)] = identity;
|
||||
}
|
||||
int column = localX * 16 + localZ;
|
||||
Surface previous = surfaces[column];
|
||||
if (previous == null || y >= previous.y()) {
|
||||
surfaces[column] = new Surface(y, identity);
|
||||
}
|
||||
}
|
||||
|
||||
public Identity volumeAt(int localX, int y, int localZ) {
|
||||
requirePosition(localX, y, localZ);
|
||||
return volume[index(localX, y, localZ)];
|
||||
}
|
||||
|
||||
public void retainHighestSurfaces(IntBinaryOperator highestSolid) {
|
||||
for (int localX = 0; localX < 16; localX++) {
|
||||
for (int localZ = 0; localZ < 16; localZ++) {
|
||||
int column = localX * 16 + localZ;
|
||||
Surface surface = surfaces[column];
|
||||
if (surface != null && highestSolid.applyAsInt(localX, localZ) != surface.y()) {
|
||||
surfaces[column] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Identity surfaceAt(int localX, int localZ) {
|
||||
requirePosition(localX, 0, localZ);
|
||||
Surface surface = surfaces[localX * 16 + localZ];
|
||||
return surface == null ? null : surface.identity();
|
||||
}
|
||||
|
||||
public int surfaceYAt(int localX, int localZ) {
|
||||
requirePosition(localX, 0, localZ);
|
||||
Surface surface = surfaces[localX * 16 + localZ];
|
||||
return surface == null ? -1 : surface.y();
|
||||
}
|
||||
|
||||
public int height() {
|
||||
return height;
|
||||
}
|
||||
|
||||
private int index(int localX, int y, int localZ) {
|
||||
return ((localX >> 2) * 4 + (localZ >> 2)) * quartHeight + (y >> 2);
|
||||
}
|
||||
|
||||
private void requirePosition(int localX, int y, int localZ) {
|
||||
if (localX < 0 || localX >= 16 || localZ < 0 || localZ >= 16 || y < 0 || y >= height) {
|
||||
throw new IllegalArgumentException("Floating biome position is outside its generation chunk.");
|
||||
}
|
||||
}
|
||||
|
||||
public record Identity(String biomeKey, String regionKey) {
|
||||
public Identity {
|
||||
Objects.requireNonNull(biomeKey, "biomeKey");
|
||||
Objects.requireNonNull(regionKey, "regionKey");
|
||||
}
|
||||
}
|
||||
|
||||
private record Surface(int y, Identity identity) {
|
||||
}
|
||||
}
|
||||
+25
-3
@@ -51,6 +51,27 @@ public final class GenerationEpochContractFactory {
|
||||
String dimensionKey,
|
||||
String dimensionTypeKey,
|
||||
int dimensionTypeFingerprintSchema
|
||||
) {
|
||||
return createContract(dimension, dimensionKey, dimensionTypeKey,
|
||||
dimensionTypeFingerprintSchema, GenerationPackFingerprint.CURRENT_VERSION);
|
||||
}
|
||||
|
||||
public static GenerationEpoch.DimensionContract createForEpoch(
|
||||
IrisDimension dimension,
|
||||
String dimensionTypeKey,
|
||||
GenerationEpoch epoch
|
||||
) {
|
||||
GenerationEpoch recorded = Objects.requireNonNull(epoch, "epoch");
|
||||
return createContract(dimension, dimension.getLoadKey(), dimensionTypeKey,
|
||||
recorded.dimensionContract().dimensionTypeFingerprintSchema(), recorded.packFingerprintVersion());
|
||||
}
|
||||
|
||||
private static GenerationEpoch.DimensionContract createContract(
|
||||
IrisDimension dimension,
|
||||
String dimensionKey,
|
||||
String dimensionTypeKey,
|
||||
int dimensionTypeFingerprintSchema,
|
||||
int packFingerprintVersion
|
||||
) {
|
||||
requireSupportedDimensionTypeFingerprintSchema(dimensionTypeFingerprintSchema);
|
||||
IrisDimension requiredDimension = Objects.requireNonNull(dimension, "dimension");
|
||||
@@ -83,7 +104,7 @@ public final class GenerationEpochContractFactory {
|
||||
upperDimensionKey,
|
||||
upperTerrainEnabled ? requiredDimension.getUpperDimensionGap() : 0,
|
||||
upperTerrainEnabled
|
||||
? requireUpperTerrainPackFingerprint(requiredDimension, upperDimensionKey)
|
||||
? requireUpperTerrainPackFingerprint(requiredDimension, upperDimensionKey, packFingerprintVersion)
|
||||
: NO_UPPER_TERRAIN_FINGERPRINT,
|
||||
dimensionTypeFingerprintSchema,
|
||||
fingerprintDimensionType(dimensionType, dimensionTypeFingerprintSchema)
|
||||
@@ -151,7 +172,8 @@ public final class GenerationEpochContractFactory {
|
||||
|
||||
private static String requireUpperTerrainPackFingerprint(
|
||||
IrisDimension dimension,
|
||||
String upperDimensionKey
|
||||
String upperDimensionKey,
|
||||
int packFingerprintVersion
|
||||
) {
|
||||
IrisData data = Objects.requireNonNull(
|
||||
dimension.getLoader(),
|
||||
@@ -167,7 +189,7 @@ public final class GenerationEpochContractFactory {
|
||||
try {
|
||||
return GenerationPackFingerprint.compute(
|
||||
data.getDataFolder().toPath(),
|
||||
GenerationPackFingerprint.CURRENT_VERSION
|
||||
packFingerprintVersion
|
||||
);
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException("Unable to fingerprint the immutable upper terrain pack.", failure);
|
||||
|
||||
@@ -36,6 +36,7 @@ public final class GenerationHistory {
|
||||
private final GenerationBoundaryStore boundaries;
|
||||
private final TerrainBoundarySignatureStore terrainSignatures;
|
||||
private final GenerationSemanticIndex semantics;
|
||||
private final SavedBiomeStore savedBiomes;
|
||||
private final GenerationKernelRegistry kernels;
|
||||
private final GenerationAdmission admission;
|
||||
private final Map<Long, GenerationBoundary> boundaryCache;
|
||||
@@ -56,12 +57,17 @@ public final class GenerationHistory {
|
||||
this.boundaries = new GenerationBoundaryStore(paths.dimensionRoot());
|
||||
this.terrainSignatures = new TerrainBoundarySignatureStore(paths.dimensionRoot());
|
||||
this.semantics = GenerationSemanticIndex.loadRequired(paths.dimensionRoot());
|
||||
this.savedBiomes = SavedBiomeStore.open(paths.dimensionRoot());
|
||||
this.admission = new GenerationAdmission(paths.dimensionRoot());
|
||||
this.boundaryCache = boundedCache(MAXIMUM_CACHED_BOUNDARIES);
|
||||
this.terrainSignatureCache = boundedCache(MAXIMUM_CACHED_TERRAIN_SIGNATURES);
|
||||
validateReferencedState();
|
||||
}
|
||||
|
||||
public SavedBiomeStore savedBiomes() {
|
||||
return savedBiomes;
|
||||
}
|
||||
|
||||
public static GenerationHistory create(
|
||||
Path dimensionRoot,
|
||||
Path packSource,
|
||||
@@ -507,7 +513,6 @@ public final class GenerationHistory {
|
||||
promoteSavedBoundary();
|
||||
}
|
||||
}
|
||||
packs.releaseArchivedPacks(store.manifest());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -530,6 +535,7 @@ public final class GenerationHistory {
|
||||
return SavedTerrainChunk.hasTerrain(SavedTerrainChunk.readStatus(paths.dimensionRoot(), chunkX, chunkZ));
|
||||
});
|
||||
semantics.discardUnstoredClaims(inventory, selected);
|
||||
savedBiomes.discardUnstoredClaims(inventory, selected);
|
||||
ownership.discardUnstoredClaims(inventory, selected);
|
||||
return inventory;
|
||||
}
|
||||
|
||||
+29
-18
@@ -23,6 +23,7 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable {
|
||||
|
||||
private final IrisEngine engine;
|
||||
private final GenerationHistory history;
|
||||
private final SavedBiomeRuntime biomes;
|
||||
private final GenerationBoundarySignatureSampler signatureSampler;
|
||||
private final ActivationRuntimeFactory runtimeFactory;
|
||||
private final LinkedHashMap<Long, RuntimeCacheEntry> bindings;
|
||||
@@ -54,6 +55,7 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable {
|
||||
this.inactive = stateLock.newCondition();
|
||||
this.operationDepth = ThreadLocal.withInitial(() -> 0);
|
||||
this.scopedRoute = new ThreadLocal<>();
|
||||
this.biomes = new SavedBiomeRuntime(engine, history);
|
||||
|
||||
GenerationActivation active = history.activeActivation();
|
||||
GenerationEpoch epoch = requireEpoch(active);
|
||||
@@ -81,6 +83,10 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
public SavedBiomeRuntime biomes() {
|
||||
return biomes;
|
||||
}
|
||||
|
||||
public static GenerationHistoryRuntimeRouter attach(
|
||||
IrisEngine engine,
|
||||
GenerationHistory history,
|
||||
@@ -297,6 +303,14 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable {
|
||||
route.naturalTerrain = captured;
|
||||
}
|
||||
|
||||
public void recordFloatingBiomes(int chunkX, int chunkZ, FloatingBiomeOverlay overlay) {
|
||||
RuntimeRoute route = scopedRoute.get();
|
||||
if (route == null || route.chunkX() != chunkX || route.chunkZ() != chunkZ) {
|
||||
throw new IllegalStateException("Floating biomes must be captured inside their chunk generation route.");
|
||||
}
|
||||
route.floatingBiomes = overlay;
|
||||
}
|
||||
|
||||
public void preloadActiveRuntimes() throws IOException {
|
||||
enterOperation();
|
||||
try {
|
||||
@@ -387,6 +401,11 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable {
|
||||
} catch (Throwable detachFailure) {
|
||||
failure = detachFailure;
|
||||
}
|
||||
try {
|
||||
biomes.close();
|
||||
} catch (Throwable biomeFailure) {
|
||||
failure = appendFailure(failure, biomeFailure);
|
||||
}
|
||||
try {
|
||||
retireBindings(retired);
|
||||
} catch (Throwable retirementFailure) {
|
||||
@@ -620,18 +639,7 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable {
|
||||
if (failure != null) {
|
||||
throw new IllegalStateException("Failed to retire cached generation runtimes.", failure);
|
||||
}
|
||||
if (!retired.isEmpty()) {
|
||||
stateLock.lock();
|
||||
try {
|
||||
if (bindings.size() == 1 && retiringBindings.isEmpty()) {
|
||||
new GenerationPackRepository(history.paths().dimensionRoot()).releaseArchivedPacks(history.manifest());
|
||||
}
|
||||
} catch (IOException cleanupFailure) {
|
||||
throw new IllegalStateException("Unable to release archived generation packs after runtime retirement.", cleanupFailure);
|
||||
} finally {
|
||||
stateLock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private void requireBindingContract(
|
||||
@@ -897,7 +905,7 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable {
|
||||
+ activation.activationId() + " pack " + expectedPack + ".");
|
||||
}
|
||||
binding.target().getData().bindGenerationRegistryContract(epoch.registryContract());
|
||||
requireDimensionContract(binding.target().getDimension(), epoch.dimensionContract(), actualPack);
|
||||
requireDimensionContract(binding.target().getDimension(), epoch, actualPack);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -917,7 +925,7 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable {
|
||||
throw new IOException("Immutable generation pack does not contain dimension '"
|
||||
+ epoch.dimensionContract().dimensionKey() + "': " + packRoot);
|
||||
}
|
||||
requireDimensionContract(dimension, epoch.dimensionContract(), packRoot);
|
||||
requireDimensionContract(dimension, epoch, packRoot);
|
||||
TransitionGenerationPlan plan = activation.isInitial()
|
||||
? null
|
||||
: history.transitionPlan(activation.activationId());
|
||||
@@ -947,15 +955,16 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable {
|
||||
|
||||
private static void requireDimensionContract(
|
||||
IrisDimension dimension,
|
||||
GenerationEpoch.DimensionContract recorded,
|
||||
GenerationEpoch epoch,
|
||||
Path packRoot
|
||||
) throws IOException {
|
||||
GenerationEpoch.DimensionContract recorded = epoch.dimensionContract();
|
||||
GenerationEpoch.DimensionContract loaded;
|
||||
try {
|
||||
loaded = GenerationEpochContractFactory.create(
|
||||
loaded = GenerationEpochContractFactory.createForEpoch(
|
||||
dimension,
|
||||
dimension.getLoadKey(),
|
||||
recorded.dimensionTypeKey()
|
||||
recorded.dimensionTypeKey(),
|
||||
epoch
|
||||
);
|
||||
} catch (RuntimeException failure) {
|
||||
throw new IOException("Unable to validate immutable generation dimension at " + packRoot + ".", failure);
|
||||
@@ -976,6 +985,7 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable {
|
||||
private final GenerationTransitionGate.Participation participation;
|
||||
private int activeScopes;
|
||||
private volatile SavedTerrainChunk naturalTerrain;
|
||||
private volatile FloatingBiomeOverlay floatingBiomes;
|
||||
private boolean closed;
|
||||
|
||||
private RuntimeRoute(
|
||||
@@ -1044,6 +1054,7 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable {
|
||||
stage,
|
||||
caveSpace
|
||||
);
|
||||
router.biomes.capture(SavedBiomeCapture.capture(router.engine, stage, router.biomes, floatingBiomes));
|
||||
return router.history.claimGeneratedSemantics(stage, semantics);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class GenerationPackFingerprint {
|
||||
public static final int CURRENT_VERSION = 1;
|
||||
public static final int CURRENT_VERSION = 2;
|
||||
private static final int BUFFER_BYTES = 64 * 1_024;
|
||||
|
||||
private GenerationPackFingerprint() {
|
||||
@@ -31,23 +31,31 @@ public final class GenerationPackFingerprint {
|
||||
|
||||
public static String compute(Path packRoot, int version) throws IOException {
|
||||
requireSupported(version);
|
||||
return computeVersionOne(packRoot);
|
||||
return switch (version) {
|
||||
case 1 -> computeVersionOne(packRoot);
|
||||
case 2 -> computeTree(packRoot, true);
|
||||
default -> throw new IOException("Unsupported Iris generation pack fingerprint version " + version + ".");
|
||||
};
|
||||
}
|
||||
|
||||
public static void requireSupported(int version) throws IOException {
|
||||
if (version != 1) {
|
||||
if (version != 1 && version != 2) {
|
||||
throw new IOException("Unsupported Iris generation pack fingerprint version " + version + ".");
|
||||
}
|
||||
}
|
||||
|
||||
private static String computeVersionOne(Path packRoot) throws IOException {
|
||||
return computeTree(packRoot, false);
|
||||
}
|
||||
|
||||
private static String computeTree(Path packRoot, boolean ignoreFinderMetadata) throws IOException {
|
||||
Path root = Objects.requireNonNull(packRoot, "packRoot").toAbsolutePath().normalize();
|
||||
if (!Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(root)) {
|
||||
throw new IOException("Generation pack fingerprint root is missing or unsafe: " + root);
|
||||
}
|
||||
Path realRoot = root.toRealPath(LinkOption.NOFOLLOW_LINKS);
|
||||
List<FingerprintEntry> entries = new ArrayList<>();
|
||||
collectTree(realRoot, entries);
|
||||
collectTree(realRoot, entries, ignoreFinderMetadata);
|
||||
entries.sort(Comparator.comparing(FingerprintEntry::relativePath));
|
||||
MessageDigest digest = sha256();
|
||||
byte[] buffer = new byte[BUFFER_BYTES];
|
||||
@@ -75,7 +83,11 @@ public final class GenerationPackFingerprint {
|
||||
return HexFormat.of().formatHex(digest.digest());
|
||||
}
|
||||
|
||||
private static void collectTree(Path root, List<FingerprintEntry> entries) throws IOException {
|
||||
private static void collectTree(
|
||||
Path root,
|
||||
List<FingerprintEntry> entries,
|
||||
boolean ignoreFinderMetadata
|
||||
) throws IOException {
|
||||
Files.walkFileTree(root, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public FileVisitResult preVisitDirectory(
|
||||
@@ -98,7 +110,8 @@ public final class GenerationPackFingerprint {
|
||||
Path relativePath = root.relativize(file);
|
||||
String fileName = file.getFileName().toString();
|
||||
if ((relativePath.getNameCount() == 1 && PackDirectoryResolver.isHiddenName(fileName))
|
||||
|| fileName.endsWith(".code-workspace")) {
|
||||
|| fileName.endsWith(".code-workspace")
|
||||
|| (ignoreFinderMetadata && fileName.equals(".DS_Store"))) {
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
if (attributes.isSymbolicLink() || Files.isSymbolicLink(file)) {
|
||||
|
||||
@@ -96,29 +96,6 @@ public final class GenerationPackRepository {
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void releaseArchivedPacks(GenerationManifest manifest) throws IOException {
|
||||
GenerationManifest retained = Objects.requireNonNull(manifest, "manifest");
|
||||
String activeEpochId = retained.activeEpoch().epochId();
|
||||
String pendingEpochId = retained.pendingEpoch().map(GenerationEpoch::epochId).orElse(null);
|
||||
for (GenerationEpoch epoch : retained.epochs()) {
|
||||
String epochId = epoch.epochId();
|
||||
if (epochId.equals(activeEpochId) || epochId.equals(pendingEpochId)) {
|
||||
continue;
|
||||
}
|
||||
try (GenerationPublicationLock ignored = GenerationPublicationLock.acquire(
|
||||
epochsRoot, ".epoch-" + epochId + ".lock")) {
|
||||
validateExistingAncestors(epochId);
|
||||
Path pack = packRoot(epochId);
|
||||
if (!Files.exists(pack, LinkOption.NOFOLLOW_LINKS)) {
|
||||
continue;
|
||||
}
|
||||
requireSafeDirectory(pack, "Generation epoch pack is unsafe");
|
||||
AtomicDirectoryPublisher.deleteTree(pack);
|
||||
forceDirectory(epochRoot(epochId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Path requireExactPack(
|
||||
String epochId,
|
||||
String packFingerprint,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.engine.IrisEngine;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisBiomeCustom;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
public record NativeBiomeSpawnSelection(Mode mode, String derivativeKey) {
|
||||
private static final NativeBiomeSpawnSelection CURRENT = new NativeBiomeSpawnSelection(Mode.CURRENT, "");
|
||||
private static final NativeBiomeSpawnSelection NONE = new NativeBiomeSpawnSelection(Mode.NONE, "");
|
||||
private static final NativeBiomeSpawnSelection LOADING = new NativeBiomeSpawnSelection(Mode.LOADING, "");
|
||||
|
||||
public NativeBiomeSpawnSelection {
|
||||
Objects.requireNonNull(mode, "mode");
|
||||
Objects.requireNonNull(derivativeKey, "derivativeKey");
|
||||
if (mode == Mode.RETAINED) {
|
||||
derivativeKey = ChunkGenerationSemantics.requireResourceKey(derivativeKey);
|
||||
} else if (!derivativeKey.isEmpty()) {
|
||||
throw new IllegalArgumentException("Only retained native spawn selections have a derivative key");
|
||||
}
|
||||
}
|
||||
|
||||
public static NativeBiomeSpawnSelection at(Engine engine, int blockX, int worldY, int blockZ, String physicalBiomeKey) {
|
||||
if (!(engine instanceof IrisEngine irisEngine) || irisEngine.hasGenerationRuntimeScope()) {
|
||||
return CURRENT;
|
||||
}
|
||||
GenerationHistoryRuntimeRouter router = irisEngine.getGenerationHistoryRuntimeRouter().orElse(null);
|
||||
if (router == null) {
|
||||
return CURRENT;
|
||||
}
|
||||
try {
|
||||
return router.biomes().nativeSpawnSelection(blockX, worldY, blockZ, physicalBiomeKey);
|
||||
} catch (SavedBiomeUnavailableException unavailable) {
|
||||
return unavailable.isLoading() ? LOADING : NONE;
|
||||
}
|
||||
}
|
||||
|
||||
static Map<String, String> retainedDerivatives(IrisData data) {
|
||||
Map<String, String> derivatives = new LinkedHashMap<>();
|
||||
for (String key : data.getDimensionLoader().getPossibleKeys()) {
|
||||
IrisDimension dimension = data.getDimensionLoader().load(key);
|
||||
if (dimension == null) {
|
||||
continue;
|
||||
}
|
||||
for (IrisBiome biome : dimension.getReachableBiomes(() -> data)) {
|
||||
if (biome == null || !biome.isCustom()) {
|
||||
continue;
|
||||
}
|
||||
for (IrisBiomeCustom custom : biome.getCustomDerivitives()) {
|
||||
derivatives.putIfAbsent(data.customBiomeResourceKey(dimension, custom), biome.getVanillaDerivativeKey());
|
||||
}
|
||||
}
|
||||
}
|
||||
return Map.copyOf(derivatives);
|
||||
}
|
||||
|
||||
public enum Mode {
|
||||
CURRENT,
|
||||
RETAINED,
|
||||
NONE,
|
||||
LOADING
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisRegion;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
public final class SavedBiomeCapture {
|
||||
private SavedBiomeCapture() {
|
||||
}
|
||||
|
||||
public static SavedBiomeChunk capture(Engine engine, GenerationHistory.GenerationStage stage, SavedBiomeRuntime saved, FloatingBiomeOverlay floating) throws IOException {
|
||||
int minimumY = engine.getMinHeight();
|
||||
int height = engine.getHeight();
|
||||
if (floating != null && floating.height() != height) {
|
||||
throw new IllegalArgumentException("Floating biome overlay has a different generation height.");
|
||||
}
|
||||
int startX = Math.multiplyExact(stage.chunkX(), 16);
|
||||
int startZ = Math.multiplyExact(stage.chunkZ(), 16);
|
||||
long activationId = stage.activation().activationId();
|
||||
SavedBiomeChunk.Builder result = SavedBiomeChunk.builder(new SavedBiomeChunk.Header(
|
||||
stage.chunkX(), stage.chunkZ(), activationId, minimumY, height));
|
||||
TransitionGenerationPlan transition = engine.getComplex().getTransitionGenerationPlan();
|
||||
Map<Long, Optional<SavedBiomeChunk>> historical = new HashMap<>();
|
||||
Map<Integer, List<SavedBiomeChunk.Span>> vertical = new HashMap<>(16);
|
||||
for (int localX = 0; localX < 16; localX++) {
|
||||
for (int localZ = 0; localZ < 16; localZ++) {
|
||||
int blockX = startX + localX;
|
||||
int blockZ = startZ + localZ;
|
||||
IrisRegion region = engine.getRegion(blockX, blockZ);
|
||||
IrisBiome surface = engine.getSurfaceBiome(blockX, blockZ);
|
||||
int surfaceY = minimumY + engine.getHeight(blockX, blockZ);
|
||||
SavedBiomeChunk.Cell surfaceCell = cell(activationId, surface, region);
|
||||
if (floating != null) {
|
||||
FloatingBiomeOverlay.Identity floatingSurface = floating.surfaceAt(localX, localZ);
|
||||
if (floatingSurface != null) {
|
||||
surfaceCell = overlay(surfaceCell, floatingSurface);
|
||||
surfaceY = minimumY + floating.surfaceYAt(localX, localZ);
|
||||
}
|
||||
}
|
||||
if (transition != null) {
|
||||
surfaceCell = historicalCell(new Sample(blockX, surfaceY, blockZ, true), surfaceCell, transition, saved, historical);
|
||||
}
|
||||
int quartX = localX & ~3;
|
||||
int quartZ = localZ & ~3;
|
||||
int quartKey = quartX * 16 + quartZ;
|
||||
List<SavedBiomeChunk.Span> spans = vertical.get(quartKey);
|
||||
if (spans == null) {
|
||||
spans = captureVertical(new Column(engine, activationId, startX + quartX, startZ + quartZ), transition, saved, historical, floating);
|
||||
vertical.put(quartKey, spans);
|
||||
}
|
||||
SavedBiomeChunk.Cell caveBase = cell(activationId, engine.getCaveBiome(blockX, blockZ), region);
|
||||
result.column(localX, localZ, new SavedBiomeChunk.Column(surfaceCell, caveBase, spans));
|
||||
}
|
||||
}
|
||||
return result.build();
|
||||
}
|
||||
|
||||
private static List<SavedBiomeChunk.Span> captureVertical(Column column, TransitionGenerationPlan transition,
|
||||
SavedBiomeRuntime saved, Map<Long, Optional<SavedBiomeChunk>> historical, FloatingBiomeOverlay floating) throws IOException {
|
||||
Engine engine = column.engine();
|
||||
int minimumY = engine.getMinHeight();
|
||||
int maximumY = minimumY + engine.getHeight();
|
||||
List<SavedBiomeChunk.Span> spans = new ArrayList<>();
|
||||
SavedBiomeChunk.Cell previous = null;
|
||||
int startY = minimumY;
|
||||
for (int worldY = minimumY; worldY < maximumY; worldY += 4) {
|
||||
IrisBiome biome = engine.getBiomeOrMantle(column.blockX(), worldY - minimumY, column.blockZ());
|
||||
IrisRegion region = engine.getRegion(column.blockX(), worldY - minimumY, column.blockZ());
|
||||
SavedBiomeChunk.Cell current = cell(column.activationId(), biome, region);
|
||||
if (floating != null) {
|
||||
current = overlay(current, floating.volumeAt(column.blockX() & 15, worldY - minimumY, column.blockZ() & 15));
|
||||
}
|
||||
if (transition != null) {
|
||||
current = historicalCell(new Sample(column.blockX(), worldY, column.blockZ(), false), current, transition, saved, historical);
|
||||
}
|
||||
if (previous != null && !previous.equals(current)) {
|
||||
spans.add(new SavedBiomeChunk.Span(startY, worldY, previous));
|
||||
startY = worldY;
|
||||
}
|
||||
previous = current;
|
||||
}
|
||||
spans.add(new SavedBiomeChunk.Span(startY, maximumY, Objects.requireNonNull(previous, "biome")));
|
||||
return List.copyOf(spans);
|
||||
}
|
||||
|
||||
private static SavedBiomeChunk.Cell historicalCell(Sample sample, SavedBiomeChunk.Cell current,
|
||||
TransitionGenerationPlan transition, SavedBiomeRuntime saved,
|
||||
Map<Long, Optional<SavedBiomeChunk>> historical) throws IOException {
|
||||
TransitionGenerationPlan.TerrainSample terrain = transition.terrainSampleAt(sample.x(), sample.z());
|
||||
if (transition.historicalPhysicalBiomeKeyAt(sample.x(), sample.y(), sample.z(), terrain).isEmpty()) {
|
||||
return current;
|
||||
}
|
||||
TerrainBoundarySignature signature = Objects.requireNonNull(terrain.nearestSignature(), "historical biome boundary");
|
||||
int chunkX = Math.floorDiv(signature.blockX(), 16);
|
||||
int chunkZ = Math.floorDiv(signature.blockZ(), 16);
|
||||
long key = ((long) chunkX << 32) | (chunkZ & 0xFFFFFFFFL);
|
||||
Optional<SavedBiomeChunk> recorded = historical.get(key);
|
||||
if (recorded == null) {
|
||||
try {
|
||||
recorded = saved.snapshot(chunkX, chunkZ);
|
||||
} catch (SavedBiomeUnavailableException unavailable) {
|
||||
recorded = Optional.empty();
|
||||
}
|
||||
historical.put(key, recorded);
|
||||
}
|
||||
if (recorded.isEmpty()) {
|
||||
return SavedBiomeChunk.Cell.unresolved(saved.activationAt(chunkX, chunkZ));
|
||||
}
|
||||
SavedBiomeChunk source = recorded.get();
|
||||
int localX = Math.floorMod(signature.blockX(), 16);
|
||||
int localZ = Math.floorMod(signature.blockZ(), 16);
|
||||
return sample.surface() ? source.surfaceAt(localX, localZ)
|
||||
: source.biomeAt(localX, Math.max(source.header().minimumY(),
|
||||
Math.min(sample.y(), source.header().maximumYExclusive() - 1)), localZ);
|
||||
}
|
||||
|
||||
private static SavedBiomeChunk.Cell overlay(SavedBiomeChunk.Cell current, FloatingBiomeOverlay.Identity floating) {
|
||||
return floating == null ? current
|
||||
: new SavedBiomeChunk.Cell(current.activationId(), floating.biomeKey(), floating.regionKey());
|
||||
}
|
||||
|
||||
private static SavedBiomeChunk.Cell cell(long activationId, IrisBiome biome, IrisRegion region) {
|
||||
return new SavedBiomeChunk.Cell(activationId, Objects.requireNonNull(biome, "biome").getLoadKey(),
|
||||
Objects.requireNonNull(region, "region").getLoadKey());
|
||||
}
|
||||
|
||||
private record Column(Engine engine, long activationId, int blockX, int blockZ) {
|
||||
}
|
||||
|
||||
private record Sample(int x, int y, int z, boolean surface) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class SavedBiomeChunk {
|
||||
public static final int MAXIMUM_HEIGHT = 65_536;
|
||||
public static final long MAXIMUM_ESTIMATED_BYTES = 64L * 1024L * 1024L;
|
||||
private static final int COLUMN_COUNT = 256;
|
||||
|
||||
private final Header header;
|
||||
private final Column[] columns;
|
||||
private final long estimatedBytes;
|
||||
|
||||
private SavedBiomeChunk(Header header, Column[] columns) {
|
||||
this.header = header;
|
||||
this.columns = columns.clone();
|
||||
long bytes = 128L + COLUMN_COUNT * 16L;
|
||||
for (Column column : this.columns) {
|
||||
Objects.requireNonNull(column, "Every saved biome column is required");
|
||||
int expectedY = header.minimumY();
|
||||
for (Span span : column.vertical()) {
|
||||
if (span.minimumY() != expectedY) {
|
||||
throw new IllegalArgumentException("Saved biome spans must cover the column without gaps or overlaps");
|
||||
}
|
||||
expectedY = span.maximumYExclusive();
|
||||
bytes = checkedSize(bytes + cellBytes(span.cell()));
|
||||
}
|
||||
if (expectedY != header.maximumYExclusive()) {
|
||||
throw new IllegalArgumentException("Saved biome spans must cover the entire world height");
|
||||
}
|
||||
bytes = checkedSize(bytes + cellBytes(column.surface()) + cellBytes(column.caveBase()));
|
||||
}
|
||||
estimatedBytes = bytes;
|
||||
}
|
||||
|
||||
public static Builder builder(Header header) {
|
||||
return new Builder(Objects.requireNonNull(header, "header"));
|
||||
}
|
||||
|
||||
public Header header() {
|
||||
return header;
|
||||
}
|
||||
|
||||
public int chunkX() {
|
||||
return header.chunkX();
|
||||
}
|
||||
|
||||
public int chunkZ() {
|
||||
return header.chunkZ();
|
||||
}
|
||||
|
||||
public long activationId() {
|
||||
return header.activationId();
|
||||
}
|
||||
|
||||
public Column column(int localX, int localZ) {
|
||||
return columns[columnIndex(localX, localZ)];
|
||||
}
|
||||
|
||||
public Cell surfaceAt(int localX, int localZ) {
|
||||
return column(localX, localZ).surface();
|
||||
}
|
||||
|
||||
public Cell caveBaseAt(int localX, int localZ) {
|
||||
return column(localX, localZ).caveBase();
|
||||
}
|
||||
|
||||
public Cell biomeAt(int localX, int worldY, int localZ) {
|
||||
if (worldY < header.minimumY() || worldY >= header.maximumYExclusive()) {
|
||||
throw new IndexOutOfBoundsException("Biome height is outside the saved world height");
|
||||
}
|
||||
List<Span> spans = column(localX, localZ).vertical();
|
||||
int low = 0;
|
||||
int high = spans.size() - 1;
|
||||
while (low < high) {
|
||||
int middle = (low + high) >>> 1;
|
||||
if (worldY >= spans.get(middle).maximumYExclusive()) {
|
||||
low = middle + 1;
|
||||
} else {
|
||||
high = middle;
|
||||
}
|
||||
}
|
||||
return spans.get(low).cell();
|
||||
}
|
||||
|
||||
long estimatedBytes() {
|
||||
return estimatedBytes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object compared) {
|
||||
return this == compared || compared instanceof SavedBiomeChunk other
|
||||
&& header.equals(other.header) && Arrays.equals(columns, other.columns);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return 31 * header.hashCode() + Arrays.hashCode(columns);
|
||||
}
|
||||
|
||||
private static long checkedSize(long bytes) {
|
||||
if (bytes > MAXIMUM_ESTIMATED_BYTES) {
|
||||
throw new IllegalArgumentException("Saved biome chunk exceeds its decoded footprint limit");
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static long cellBytes(Cell cell) {
|
||||
return cell.isResolved()
|
||||
? 160L + cell.biomeKey().length() * 2L + cell.regionKey().length() * 2L
|
||||
: 160L;
|
||||
}
|
||||
|
||||
private static int columnIndex(int localX, int localZ) {
|
||||
if (localX < 0 || localX >= 16 || localZ < 0 || localZ >= 16) {
|
||||
throw new IndexOutOfBoundsException("Saved biome local coordinates must be between 0 and 15");
|
||||
}
|
||||
return localZ * 16 + localX;
|
||||
}
|
||||
|
||||
public record Header(int chunkX, int chunkZ, long activationId, int minimumY, int height) {
|
||||
public Header {
|
||||
if (activationId <= 0L || height <= 0 || height > MAXIMUM_HEIGHT
|
||||
|| (long) minimumY + height > Integer.MAX_VALUE) {
|
||||
throw new IllegalArgumentException("Invalid saved biome chunk header");
|
||||
}
|
||||
}
|
||||
|
||||
public int maximumYExclusive() {
|
||||
return minimumY + height;
|
||||
}
|
||||
}
|
||||
|
||||
public enum Resolution {
|
||||
RESOLVED,
|
||||
UNRESOLVED
|
||||
}
|
||||
|
||||
public record Cell(long activationId, String biomeKey, String regionKey, Resolution resolution) {
|
||||
public Cell {
|
||||
if (activationId <= 0L) {
|
||||
throw new IllegalArgumentException("Saved biome activation must be positive");
|
||||
}
|
||||
Objects.requireNonNull(resolution, "resolution");
|
||||
if (resolution == Resolution.RESOLVED) {
|
||||
if (biomeKey == null || regionKey == null) {
|
||||
throw new IllegalArgumentException("Resolved saved biomes require both resource keys");
|
||||
}
|
||||
biomeKey = ChunkGenerationSemantics.requireResourceKey(biomeKey);
|
||||
regionKey = ChunkGenerationSemantics.requireResourceKey(regionKey);
|
||||
} else if (biomeKey != null || regionKey != null) {
|
||||
throw new IllegalArgumentException("Unresolved saved biomes must not contain resource keys");
|
||||
}
|
||||
}
|
||||
|
||||
public Cell(long activationId, String biomeKey, String regionKey) {
|
||||
this(activationId, biomeKey, regionKey, Resolution.RESOLVED);
|
||||
}
|
||||
|
||||
public static Cell unresolved(long activationId) {
|
||||
return new Cell(activationId, null, null, Resolution.UNRESOLVED);
|
||||
}
|
||||
|
||||
public boolean isResolved() {
|
||||
return resolution == Resolution.RESOLVED;
|
||||
}
|
||||
}
|
||||
|
||||
public record Span(int minimumY, int maximumYExclusive, Cell cell) {
|
||||
public Span {
|
||||
if (minimumY >= maximumYExclusive) {
|
||||
throw new IllegalArgumentException("Saved biome spans must have positive height");
|
||||
}
|
||||
Objects.requireNonNull(cell, "cell");
|
||||
}
|
||||
}
|
||||
|
||||
public record Column(Cell surface, Cell caveBase, List<Span> vertical) {
|
||||
public Column {
|
||||
Objects.requireNonNull(surface, "surface");
|
||||
Objects.requireNonNull(caveBase, "caveBase");
|
||||
Objects.requireNonNull(vertical, "vertical");
|
||||
if (vertical.isEmpty() || vertical.size() > MAXIMUM_HEIGHT) {
|
||||
throw new IllegalArgumentException("Invalid saved biome column span count");
|
||||
}
|
||||
ArrayList<Span> compact = new ArrayList<>(vertical.size());
|
||||
for (Span span : vertical) {
|
||||
Objects.requireNonNull(span, "span");
|
||||
if (!compact.isEmpty()) {
|
||||
Span previous = compact.getLast();
|
||||
if (previous.maximumYExclusive() != span.minimumY()) {
|
||||
throw new IllegalArgumentException("Saved biome spans must be contiguous");
|
||||
}
|
||||
if (previous.cell().equals(span.cell())) {
|
||||
compact.set(compact.size() - 1,
|
||||
new Span(previous.minimumY(), span.maximumYExclusive(), span.cell()));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
compact.add(span);
|
||||
}
|
||||
vertical = List.copyOf(compact);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Builder {
|
||||
private final Header header;
|
||||
private final Column[] columns = new Column[COLUMN_COUNT];
|
||||
|
||||
private Builder(Header header) {
|
||||
this.header = header;
|
||||
}
|
||||
|
||||
public Builder column(int localX, int localZ, Column column) {
|
||||
columns[columnIndex(localX, localZ)] = Objects.requireNonNull(column, "column");
|
||||
return this;
|
||||
}
|
||||
|
||||
public SavedBiomeChunk build() {
|
||||
return new SavedBiomeChunk(header, columns);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisRegion;
|
||||
import art.arcane.iris.engine.object.IrisRiverPolicy;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
public final class SavedBiomeRecovery {
|
||||
private SavedBiomeRecovery() {
|
||||
}
|
||||
|
||||
public static Optional<SavedBiomeChunk> recover(Input input) throws IOException {
|
||||
Objects.requireNonNull(input, "input");
|
||||
Optional<ChunkGenerationSemantics> recorded = input.history().semantics(input.chunkX(), input.chunkZ());
|
||||
if (recorded.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
ChunkGenerationSemantics semantics = recorded.get();
|
||||
if (!semantics.sealed() || semantics.surfaceBiomeKeys().size() != 1
|
||||
|| semantics.regionKeys().size() != 1) {
|
||||
return Optional.empty();
|
||||
}
|
||||
GenerationActivation activation = input.history().resolveActivation(input.chunkX(), input.chunkZ());
|
||||
if (activation.activationId() != semantics.activationId()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String biomeKey = semantics.surfaceBiomeKeys().iterator().next();
|
||||
String regionKey = semantics.regionKeys().iterator().next();
|
||||
if (!onlyKey(semantics.caveBiomeKeys(), biomeKey)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
IrisDimension dimension = input.dimension();
|
||||
GenerationEpoch.DimensionContract contract = input.history().resolveEpoch(input.chunkX(), input.chunkZ())
|
||||
.dimensionContract();
|
||||
if (!contract.dimensionKey().equals(dimension.getLoadKey())
|
||||
|| contract.minHeight() != dimension.getMinHeight()
|
||||
|| contract.maxHeight() != dimension.getMaxHeight()
|
||||
|| contract.upperTerrainEnabled() || dimension.hasUpperDimension() || dimension.hasDimensionStack()
|
||||
|| !dimension.getCarving().isEmpty()
|
||||
|| !optionalKey(dimension.getFocus(), biomeKey)
|
||||
|| !optionalKey(dimension.getFocusRegion(), regionKey)
|
||||
|| !singleChoice(dimension.getRegions(), regionKey)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
IrisRegion region = input.data().getRegionLoader().load(regionKey);
|
||||
IrisBiome biome = input.data().getBiomeLoader().load(biomeKey);
|
||||
if (region == null || biome == null
|
||||
|| !regionKey.equals(region.getLoadKey()) || !biomeKey.equals(biome.getLoadKey())
|
||||
|| !singleChoice(region.getLandBiomes(), biomeKey)
|
||||
|| !singleChoice(region.getSeaBiomes(), biomeKey)
|
||||
|| !singleChoice(region.getShoreBiomes(), biomeKey)
|
||||
|| !singleChoice(region.getCaveBiomes(), biomeKey)
|
||||
|| !biome.getChildren().isEmpty() || !biome.getFloatingChildBiomes().isEmpty()
|
||||
|| !optionalKey(biome.getCarvingBiome(), biomeKey)
|
||||
|| !sameRiverBiomes(dimension.getRiverPolicy(), biomeKey)
|
||||
|| !sameRiverBiomes(region.getRiverPolicy(), biomeKey)
|
||||
|| !sameRiverBiomes(biome.getRiverPolicy(), biomeKey)
|
||||
|| hasHistoricalColumns(input, activation)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
SavedBiomeChunk.Header header = new SavedBiomeChunk.Header(input.chunkX(), input.chunkZ(),
|
||||
activation.activationId(), contract.minHeight(), contract.height());
|
||||
SavedBiomeChunk.Cell cell = new SavedBiomeChunk.Cell(activation.activationId(), biomeKey, regionKey);
|
||||
SavedBiomeChunk.Column column = new SavedBiomeChunk.Column(cell, cell,
|
||||
List.of(new SavedBiomeChunk.Span(header.minimumY(), header.maximumYExclusive(), cell)));
|
||||
SavedBiomeChunk.Builder builder = SavedBiomeChunk.builder(header);
|
||||
for (int localZ = 0; localZ < 16; localZ++) {
|
||||
for (int localX = 0; localX < 16; localX++) {
|
||||
builder.column(localX, localZ, column);
|
||||
}
|
||||
}
|
||||
return Optional.of(builder.build());
|
||||
}
|
||||
|
||||
private static boolean hasHistoricalColumns(Input input, GenerationActivation activation) throws IOException {
|
||||
if (activation.isInitial()) {
|
||||
return false;
|
||||
}
|
||||
TransitionGenerationPlan transition = input.history().transitionPlan(activation.activationId());
|
||||
int minimumX = Math.multiplyExact(input.chunkX(), 16);
|
||||
int minimumZ = Math.multiplyExact(input.chunkZ(), 16);
|
||||
for (int localZ = 0; localZ < 16; localZ++) {
|
||||
for (int localX = 0; localX < 16; localX++) {
|
||||
if (transition.newEpochWeightAt(Math.addExact(minimumX, localX),
|
||||
Math.addExact(minimumZ, localZ)) != 1D) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean singleChoice(Collection<String> keys, String expected) {
|
||||
return keys != null && !keys.isEmpty() && onlyKey(keys, expected);
|
||||
}
|
||||
|
||||
private static boolean onlyKey(Collection<String> keys, String expected) {
|
||||
for (String key : keys) {
|
||||
if (!expected.equals(key)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean optionalKey(String key, String expected) {
|
||||
return key != null && (key.isEmpty() || expected.equals(key));
|
||||
}
|
||||
|
||||
private static boolean sameRiverBiomes(IrisRiverPolicy policy, String biomeKey) {
|
||||
return policy == null || onlyKey(policy.getAllBiomeIds(), biomeKey);
|
||||
}
|
||||
|
||||
public record Input(GenerationHistory history, int chunkX, int chunkZ, IrisData data, IrisDimension dimension) {
|
||||
public Input {
|
||||
Objects.requireNonNull(history, "history");
|
||||
Objects.requireNonNull(data, "data");
|
||||
Objects.requireNonNull(dimension, "dimension");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.engine.IrisEngine;
|
||||
import art.arcane.iris.engine.framework.BiomeEnvironment;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisRegion;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public final class SavedBiomeRuntime implements AutoCloseable {
|
||||
private static final int MAXIMUM_QUERIES = 128;
|
||||
private static final int MAXIMUM_PENDING_QUERIES = 256;
|
||||
private static final long MAXIMUM_QUERY_BYTES = SavedBiomeChunk.MAXIMUM_ESTIMATED_BYTES;
|
||||
|
||||
private final IrisEngine engine;
|
||||
private final GenerationHistory history;
|
||||
private final SavedBiomeStore store;
|
||||
private final Map<String, Definitions> definitions = new ConcurrentHashMap<>();
|
||||
private final LinkedHashMap<Long, PreparedQuery> queries = new LinkedHashMap<>(32, 0.75F, true);
|
||||
private final Map<Long, PendingQuery> pending = new HashMap<>();
|
||||
private long queryBytes;
|
||||
private final ExecutorService reads = Executors.newFixedThreadPool(2, task -> {
|
||||
Thread thread = new Thread(task, "Iris Saved Biomes");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
private volatile boolean closed;
|
||||
|
||||
SavedBiomeRuntime(IrisEngine engine, GenerationHistory history) throws IOException {
|
||||
this.engine = Objects.requireNonNull(engine, "engine");
|
||||
this.history = Objects.requireNonNull(history, "history");
|
||||
store = history.savedBiomes();
|
||||
}
|
||||
|
||||
public Optional<BiomeEnvironment> resolve(int blockX, int worldY, int blockZ, boolean surface) {
|
||||
return resolve(blockX, worldY, blockZ, surface ? QueryKind.SURFACE : QueryKind.VOLUME);
|
||||
}
|
||||
|
||||
public NativeBiomeSpawnSelection nativeSpawnSelection(int blockX, int worldY, int blockZ, String physicalBiomeKey) {
|
||||
PreparedQuery prepared = query(Math.floorDiv(blockX, 16), Math.floorDiv(blockZ, 16));
|
||||
if (prepared.failure() != null) {
|
||||
throw prepared.failure();
|
||||
}
|
||||
if (prepared.chunk().isEmpty()) {
|
||||
return new NativeBiomeSpawnSelection(NativeBiomeSpawnSelection.Mode.CURRENT, "");
|
||||
}
|
||||
SavedBiomeChunk chunk = prepared.chunk().get();
|
||||
SavedBiomeChunk.Cell cell = chunk.biomeAt(Math.floorMod(blockX, 16),
|
||||
Math.max(chunk.header().minimumY(), Math.min(worldY, chunk.header().maximumYExclusive() - 1)),
|
||||
Math.floorMod(blockZ, 16));
|
||||
GenerationActivation activation = history.manifest().activation(cell.activationId()).orElseThrow(() ->
|
||||
new SavedBiomeUnavailableException("Historical biome generation is missing: " + cell.activationId(), false));
|
||||
Definitions source = definitions.get(activation.epochId());
|
||||
String derivative = source == null ? null : source.nativeDerivatives().get(physicalBiomeKey);
|
||||
return derivative == null
|
||||
? new NativeBiomeSpawnSelection(NativeBiomeSpawnSelection.Mode.NONE, "")
|
||||
: new NativeBiomeSpawnSelection(NativeBiomeSpawnSelection.Mode.RETAINED, derivative);
|
||||
}
|
||||
|
||||
public void prepareChunk(int chunkX, int chunkZ) {
|
||||
PreparedQuery prepared = query(chunkX, chunkZ);
|
||||
if (prepared.failure() != null) {
|
||||
throw prepared.failure();
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<BiomeEnvironment> resolveCaveBase(int blockX, int blockZ) {
|
||||
return resolve(blockX, 0, blockZ, QueryKind.CAVE_BASE);
|
||||
}
|
||||
|
||||
private Optional<BiomeEnvironment> resolve(int blockX, int worldY, int blockZ, QueryKind kind) {
|
||||
int chunkX = Math.floorDiv(blockX, 16);
|
||||
int chunkZ = Math.floorDiv(blockZ, 16);
|
||||
PreparedQuery query = query(chunkX, chunkZ);
|
||||
if (query.failure() != null) {
|
||||
throw query.failure();
|
||||
}
|
||||
if (query.chunk().isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
SavedBiomeChunk chunk = query.chunk().get();
|
||||
int localX = Math.floorMod(blockX, 16);
|
||||
int localZ = Math.floorMod(blockZ, 16);
|
||||
SavedBiomeChunk.Cell cell = switch (kind) {
|
||||
case SURFACE -> chunk.surfaceAt(localX, localZ);
|
||||
case CAVE_BASE -> chunk.caveBaseAt(localX, localZ);
|
||||
case VOLUME -> chunk.biomeAt(localX, Math.max(chunk.header().minimumY(),
|
||||
Math.min(worldY, chunk.header().maximumYExclusive() - 1)), localZ);
|
||||
};
|
||||
if (!cell.isResolved()) {
|
||||
throw new SavedBiomeUnavailableException("This position has no recorded Iris biome identity for generation "
|
||||
+ cell.activationId() + ". The saved terrain and Minecraft biome remain available, but Iris biome rules cannot run.", false);
|
||||
}
|
||||
SavedBiomeUnavailableException unavailable = query.cellFailures().get(cell);
|
||||
if (unavailable != null) {
|
||||
throw unavailable;
|
||||
}
|
||||
BiomeEnvironment environment = query.environments().get(cell);
|
||||
if (environment == null) {
|
||||
throw new SavedBiomeUnavailableException("Historical biome definitions are unavailable for generation " + cell.activationId() + ".", false);
|
||||
}
|
||||
return Optional.of(environment);
|
||||
}
|
||||
|
||||
public Optional<SavedBiomeChunk> snapshot(int chunkX, int chunkZ) throws IOException {
|
||||
requireOpen();
|
||||
Optional<SavedBiomeChunk> saved = store.get(chunkX, chunkZ);
|
||||
if (saved.isPresent()) {
|
||||
return saved;
|
||||
}
|
||||
if (history.isActiveUnowned(chunkX, chunkZ) && history.semantics(chunkX, chunkZ).isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
long activationId = history.resolveActivation(chunkX, chunkZ).activationId();
|
||||
Definitions source = definitions(activationId);
|
||||
Optional<SavedBiomeChunk> recovered = SavedBiomeRecovery.recover(new SavedBiomeRecovery.Input(
|
||||
history, chunkX, chunkZ, source.data(), source.dimension()));
|
||||
if (recovered.isPresent()) {
|
||||
store.claimAndPersist(recovered.get());
|
||||
return recovered;
|
||||
}
|
||||
throw new SavedBiomeUnavailableException("This chunk has no exact saved Iris biome assignment for generation "
|
||||
+ activationId + ". Its terrain is preserved; historical biome rules cannot be recovered safely.", false);
|
||||
}
|
||||
|
||||
public long activationAt(int chunkX, int chunkZ) {
|
||||
return history.resolveActivation(chunkX, chunkZ).activationId();
|
||||
}
|
||||
|
||||
public void capture(SavedBiomeChunk chunk) throws IOException {
|
||||
requireOpen();
|
||||
store.claimAndPersist(chunk);
|
||||
synchronized (queries) {
|
||||
long key = key(chunk.chunkX(), chunk.chunkZ());
|
||||
PreparedQuery removed = queries.remove(key);
|
||||
if (removed != null) {
|
||||
queryBytes -= removed.estimatedBytes();
|
||||
}
|
||||
PendingQuery loading = pending.get(key);
|
||||
if (loading != null) {
|
||||
loading.invalidated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void close() {
|
||||
synchronized (queries) {
|
||||
closed = true;
|
||||
reads.shutdown();
|
||||
}
|
||||
boolean interrupted = false;
|
||||
Throwable failure = null;
|
||||
try {
|
||||
while (true) {
|
||||
try {
|
||||
if (reads.awaitTermination(1L, TimeUnit.DAYS)) {
|
||||
break;
|
||||
}
|
||||
} catch (InterruptedException interruption) {
|
||||
interrupted = true;
|
||||
}
|
||||
}
|
||||
synchronized (queries) {
|
||||
queries.clear();
|
||||
pending.clear();
|
||||
queryBytes = 0L;
|
||||
}
|
||||
for (Definitions source : definitions.values()) {
|
||||
try {
|
||||
try {
|
||||
source.data().unregisterEngine(engine);
|
||||
} finally {
|
||||
source.data().close();
|
||||
}
|
||||
} catch (Throwable closeFailure) {
|
||||
if (failure == null) {
|
||||
failure = closeFailure;
|
||||
} else {
|
||||
failure.addSuppressed(closeFailure);
|
||||
}
|
||||
}
|
||||
}
|
||||
definitions.clear();
|
||||
if (failure != null) {
|
||||
throw new IllegalStateException("Unable to close saved biome definitions", failure);
|
||||
}
|
||||
} finally {
|
||||
if (interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int cachedQueryCount() {
|
||||
synchronized (queries) {
|
||||
return queries.size();
|
||||
}
|
||||
}
|
||||
|
||||
long cachedQueryBytes() {
|
||||
synchronized (queries) {
|
||||
return queryBytes;
|
||||
}
|
||||
}
|
||||
|
||||
int pendingQueryCount() {
|
||||
synchronized (queries) {
|
||||
return pending.size();
|
||||
}
|
||||
}
|
||||
|
||||
private PreparedQuery query(int chunkX, int chunkZ) {
|
||||
long key = key(chunkX, chunkZ);
|
||||
synchronized (queries) {
|
||||
requireOpen();
|
||||
PreparedQuery cached = queries.get(key);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
if (!pending.containsKey(key) && pending.size() < MAXIMUM_PENDING_QUERIES) {
|
||||
PendingQuery loading = new PendingQuery();
|
||||
pending.put(key, loading);
|
||||
reads.execute(() -> readQuery(chunkX, chunkZ, loading));
|
||||
}
|
||||
}
|
||||
throw new SavedBiomeUnavailableException("Saved biome information is loading at chunk "
|
||||
+ chunkX + "," + chunkZ + ". Try again shortly.", true);
|
||||
}
|
||||
|
||||
private void readQuery(int chunkX, int chunkZ, PendingQuery loading) {
|
||||
long key = key(chunkX, chunkZ);
|
||||
PreparedQuery prepared = null;
|
||||
try {
|
||||
if (!closed) {
|
||||
Optional<SavedBiomeChunk> saved = snapshot(chunkX, chunkZ);
|
||||
prepared = saved.isEmpty()
|
||||
? new PreparedQuery(Optional.empty(), Map.of(), Map.of(), null, 256L)
|
||||
: prepareDefinitions(saved.get());
|
||||
}
|
||||
} catch (SavedBiomeUnavailableException unavailable) {
|
||||
if (!closed) {
|
||||
IrisLogging.warnOnce("saved-biome:" + history.paths().dimensionRoot() + ":" + unavailable.getMessage(),
|
||||
unavailable.getMessage());
|
||||
}
|
||||
prepared = new PreparedQuery(Optional.empty(), Map.of(), Map.of(), unavailable, 512L);
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
if (!closed && IrisLogging.warnOnce("saved-biome:" + history.paths().dimensionRoot() + ":" + failure.getMessage(),
|
||||
"Failed to read saved biome information for chunk " + chunkX + "," + chunkZ + ".")) {
|
||||
IrisLogging.reportError(failure);
|
||||
}
|
||||
prepared = new PreparedQuery(Optional.empty(), Map.of(), Map.of(), new SavedBiomeUnavailableException(
|
||||
"Unable to read the saved biome at chunk " + chunkX + "," + chunkZ + ".", failure), 512L);
|
||||
} finally {
|
||||
synchronized (queries) {
|
||||
pending.remove(key, loading);
|
||||
if (!closed && !loading.invalidated && prepared != null) {
|
||||
cacheQuery(key, prepared);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void cacheQuery(long key, PreparedQuery prepared) {
|
||||
PreparedQuery previous = queries.put(key, prepared);
|
||||
if (previous != null) {
|
||||
queryBytes -= previous.estimatedBytes();
|
||||
}
|
||||
queryBytes += prepared.estimatedBytes();
|
||||
Iterator<Map.Entry<Long, PreparedQuery>> entries = queries.entrySet().iterator();
|
||||
while (queries.size() > MAXIMUM_QUERIES || queryBytes > MAXIMUM_QUERY_BYTES) {
|
||||
queryBytes -= entries.next().getValue().estimatedBytes();
|
||||
entries.remove();
|
||||
}
|
||||
}
|
||||
|
||||
private PreparedQuery prepareDefinitions(SavedBiomeChunk chunk) throws IOException {
|
||||
if (chunk.estimatedBytes() + 256L > MAXIMUM_QUERY_BYTES) {
|
||||
throw new SavedBiomeUnavailableException("Saved biome definitions exceed the query memory limit.", false);
|
||||
}
|
||||
Map<SavedBiomeChunk.Cell, BiomeEnvironment> environments = new HashMap<>();
|
||||
Map<SavedBiomeChunk.Cell, SavedBiomeUnavailableException> failures = new HashMap<>();
|
||||
Set<Long> nativeOwners = new HashSet<>();
|
||||
for (int x = 0; x < 16; x++) {
|
||||
for (int z = 0; z < 16; z++) {
|
||||
SavedBiomeChunk.Column column = chunk.column(x, z);
|
||||
prepareEnvironment(chunk, column.surface(), environments, failures);
|
||||
prepareEnvironment(chunk, column.caveBase(), environments, failures);
|
||||
for (SavedBiomeChunk.Span span : column.vertical()) {
|
||||
if (nativeOwners.add(span.cell().activationId())) {
|
||||
prepareNativeDefinitions(span.cell().activationId());
|
||||
}
|
||||
prepareEnvironment(chunk, span.cell(), environments, failures);
|
||||
}
|
||||
}
|
||||
}
|
||||
return new PreparedQuery(Optional.of(chunk), Map.copyOf(environments), Map.copyOf(failures), null,
|
||||
chunk.estimatedBytes() + environments.size() * 256L + failures.size() * 512L + 256L);
|
||||
}
|
||||
|
||||
private void prepareNativeDefinitions(long activationId) throws IOException {
|
||||
try {
|
||||
definitions(activationId);
|
||||
} catch (SavedBiomeUnavailableException unavailable) {
|
||||
IrisLogging.warnOnce("saved-biome:" + history.paths().dimensionRoot() + ":" + unavailable.getMessage(),
|
||||
unavailable.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void prepareEnvironment(SavedBiomeChunk chunk, SavedBiomeChunk.Cell cell,
|
||||
Map<SavedBiomeChunk.Cell, BiomeEnvironment> environments,
|
||||
Map<SavedBiomeChunk.Cell, SavedBiomeUnavailableException> failures) throws IOException {
|
||||
if (!cell.isResolved() || environments.containsKey(cell) || failures.containsKey(cell)) {
|
||||
return;
|
||||
}
|
||||
if (chunk.estimatedBytes() + environments.size() * 256L + (failures.size() + 1L) * 512L + 256L > MAXIMUM_QUERY_BYTES) {
|
||||
throw new SavedBiomeUnavailableException("Saved biome definitions exceed the query memory limit.", false);
|
||||
}
|
||||
try {
|
||||
environments.put(cell, definitions(cell.activationId()).environment(cell));
|
||||
} catch (SavedBiomeUnavailableException unavailable) {
|
||||
failures.put(cell, unavailable);
|
||||
IrisLogging.warnOnce("saved-biome:" + history.paths().dimensionRoot() + ":" + unavailable.getMessage(),
|
||||
unavailable.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private Definitions definitions(long activationId) throws IOException {
|
||||
GenerationActivation activation = history.manifest().activation(activationId).orElseThrow(() ->
|
||||
new IOException("Historical biome generation is missing: " + activationId));
|
||||
String epochId = activation.epochId();
|
||||
Definitions cached = definitions.get(epochId);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
synchronized (definitions) {
|
||||
cached = definitions.get(epochId);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
GenerationEpoch epoch = history.manifest().epoch(activation.epochId()).orElseThrow(() ->
|
||||
new IOException("Historical biome epoch is missing: " + activation.epochId()));
|
||||
Path pack = history.paths().packRoot(epoch.epochId());
|
||||
if (!Files.exists(pack, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new SavedBiomeUnavailableException("The saved pack definitions for generation " + activationId
|
||||
+ " are missing. Its terrain remains saved, but its Iris biome rules cannot run.", false);
|
||||
}
|
||||
IrisData data = IrisData.openRuntime(history.packRoot(activationId).toFile());
|
||||
try {
|
||||
data.bindGenerationRegistryContract(epoch.registryContract());
|
||||
data.registerEngine(engine);
|
||||
IrisDimension dimension = data.getDimensionLoader().load(epoch.dimensionContract().dimensionKey());
|
||||
if (dimension == null) {
|
||||
throw new IOException("Historical dimension is no longer supported: " + epoch.dimensionContract().dimensionKey());
|
||||
}
|
||||
Definitions loaded = new Definitions(data, dimension, NativeBiomeSpawnSelection.retainedDerivatives(data));
|
||||
definitions.put(epochId, loaded);
|
||||
return loaded;
|
||||
} catch (Throwable failure) {
|
||||
data.unregisterEngine(engine);
|
||||
data.close();
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void requireOpen() {
|
||||
if (closed) {
|
||||
throw new IllegalStateException("Saved biome runtime is closed.");
|
||||
}
|
||||
}
|
||||
|
||||
private static long key(int chunkX, int chunkZ) {
|
||||
return ((long) chunkX << 32) | (chunkZ & 0xFFFFFFFFL);
|
||||
}
|
||||
|
||||
private record PreparedQuery(Optional<SavedBiomeChunk> chunk,
|
||||
Map<SavedBiomeChunk.Cell, BiomeEnvironment> environments,
|
||||
Map<SavedBiomeChunk.Cell, SavedBiomeUnavailableException> cellFailures,
|
||||
SavedBiomeUnavailableException failure, long estimatedBytes) {
|
||||
}
|
||||
|
||||
private static final class PendingQuery {
|
||||
private boolean invalidated;
|
||||
}
|
||||
|
||||
private enum QueryKind {
|
||||
SURFACE, CAVE_BASE, VOLUME
|
||||
}
|
||||
|
||||
private record Definitions(IrisData data, IrisDimension dimension, Map<String, String> nativeDerivatives) {
|
||||
private BiomeEnvironment environment(SavedBiomeChunk.Cell cell) {
|
||||
IrisBiome biome = data.getBiomeLoader().load(cell.biomeKey());
|
||||
IrisRegion region = data.getRegionLoader().load(cell.regionKey());
|
||||
if (biome == null || region == null) {
|
||||
throw new SavedBiomeUnavailableException("Historical biome " + cell.biomeKey() + " in region "
|
||||
+ cell.regionKey() + " is no longer supported for generation " + cell.activationId()
|
||||
+ ". Its terrain remains saved, but its Iris biome rules cannot run.", false);
|
||||
}
|
||||
return new BiomeEnvironment(cell.activationId(), biome, region, dimension, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,670 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.DataInputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.File;
|
||||
import java.io.RandomAccessFile;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
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.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.zip.CRC32;
|
||||
import java.util.zip.Deflater;
|
||||
import java.util.zip.DeflaterOutputStream;
|
||||
import java.util.zip.InflaterInputStream;
|
||||
|
||||
public final class SavedBiomeStore {
|
||||
private static final Pattern REGION_FILE = Pattern.compile("r\\.(-?[0-9]+)\\.(-?[0-9]+)\\.ibio");
|
||||
private static final int MAGIC = 0x4942494F;
|
||||
private static final int FORMAT_VERSION = 1;
|
||||
private static final int HEADER_BYTES = 20;
|
||||
private static final int MAXIMUM_PALETTE_CELLS = 65_536;
|
||||
private static final int MAXIMUM_RECORD_BYTES = 16 * 1024 * 1024;
|
||||
private static final int MAXIMUM_DECODED_BYTES = 64 * 1024 * 1024;
|
||||
private static final long MAXIMUM_REGION_BYTES = 1024L * 1024L * 1024L;
|
||||
private static final int MAXIMUM_CACHED_REGIONS = 64;
|
||||
private static final int MAXIMUM_CACHED_CHUNKS = 128;
|
||||
private static final long MAXIMUM_CACHED_BYTES = 64L * 1024L * 1024L;
|
||||
|
||||
private final Path dimensionRoot;
|
||||
private final Path directory;
|
||||
private final Object[] regionLocks = new Object[64];
|
||||
private final LinkedHashMap<Long, RegionIndex> regions = new LinkedHashMap<>(64, 0.75F, true);
|
||||
private final LinkedHashMap<Long, Optional<SavedBiomeChunk>> chunks = new LinkedHashMap<>(128, 0.75F, true);
|
||||
private long cachedBytes;
|
||||
|
||||
private SavedBiomeStore(Path dimensionRoot) throws IOException {
|
||||
this.dimensionRoot = Objects.requireNonNull(dimensionRoot, "dimensionRoot").toAbsolutePath().normalize();
|
||||
directory = this.dimensionRoot.resolve("iris/generation/biomes");
|
||||
requireSafeAncestors();
|
||||
for (int index = 0; index < regionLocks.length; index++) {
|
||||
regionLocks[index] = new Object();
|
||||
}
|
||||
}
|
||||
|
||||
public static SavedBiomeStore open(Path dimensionRoot) throws IOException {
|
||||
return new SavedBiomeStore(dimensionRoot);
|
||||
}
|
||||
|
||||
public Path storageDirectory() {
|
||||
return directory;
|
||||
}
|
||||
|
||||
public Optional<SavedBiomeChunk> get(int chunkX, int chunkZ) throws IOException {
|
||||
long chunkKey = key(chunkX, chunkZ);
|
||||
synchronized (chunks) {
|
||||
Optional<SavedBiomeChunk> cached = chunks.get(chunkKey);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
synchronized (regionLock(chunkX, chunkZ)) {
|
||||
synchronized (chunks) {
|
||||
Optional<SavedBiomeChunk> cached = chunks.get(chunkKey);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
RegionIndex region = region(chunkX, chunkZ);
|
||||
int slot = slot(chunkX, chunkZ);
|
||||
Optional<SavedBiomeChunk> result = region.offsets[slot] == 0L
|
||||
? Optional.empty() : Optional.of(readRecord(region, slot));
|
||||
cacheChunk(chunkKey, result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<SavedBiomeChunk> cached(int chunkX, int chunkZ) {
|
||||
synchronized (chunks) {
|
||||
return chunks.getOrDefault(key(chunkX, chunkZ), Optional.empty());
|
||||
}
|
||||
}
|
||||
|
||||
public boolean claimAndPersist(SavedBiomeChunk chunk) throws IOException {
|
||||
SavedBiomeChunk required = Objects.requireNonNull(chunk, "chunk");
|
||||
synchronized (regionLock(required.chunkX(), required.chunkZ())) {
|
||||
Optional<SavedBiomeChunk> existing = get(required.chunkX(), required.chunkZ());
|
||||
if (existing.isPresent()) {
|
||||
if (!existing.get().equals(required)) {
|
||||
throw new IOException("Conflicting saved biome claim at chunk " + required.chunkX() + ", " + required.chunkZ());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
byte[] body = encode(required);
|
||||
RegionIndex region = region(required.chunkX(), required.chunkZ());
|
||||
ensureRegionFile(region);
|
||||
long offset;
|
||||
try (RandomAccessFile output = new RandomAccessFile(region.path.toFile(), "rw")) {
|
||||
offset = output.length();
|
||||
if (offset + body.length + 8L > MAXIMUM_REGION_BYTES) {
|
||||
throw new IOException("Saved biome region exceeds its storage limit: " + region.path);
|
||||
}
|
||||
try {
|
||||
output.seek(offset);
|
||||
output.writeInt(body.length);
|
||||
output.write(body);
|
||||
output.writeInt(checksum(body));
|
||||
output.getChannel().force(true);
|
||||
} catch (IOException failure) {
|
||||
try {
|
||||
output.setLength(offset);
|
||||
output.getChannel().force(true);
|
||||
} catch (IOException rollback) {
|
||||
failure.addSuppressed(rollback);
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
int slot = slot(required.chunkX(), required.chunkZ());
|
||||
region.offsets[slot] = offset;
|
||||
region.lengths[slot] = body.length;
|
||||
cacheChunk(key(required.chunkX(), required.chunkZ()), Optional.of(required));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public int discardUnstoredClaims(WorldChunkInventory inventory, Set<Long> selectedActivations) throws IOException {
|
||||
Objects.requireNonNull(inventory, "inventory");
|
||||
Set<Long> selected = Set.copyOf(selectedActivations);
|
||||
for (long activationId : selected) {
|
||||
if (activationId <= 0L) {
|
||||
throw new IllegalArgumentException("Saved biome activation must be positive");
|
||||
}
|
||||
}
|
||||
requireSafeAncestors();
|
||||
if (selected.isEmpty() || !Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return 0;
|
||||
}
|
||||
int removed = 0;
|
||||
try (DirectoryStream<Path> files = Files.newDirectoryStream(directory, "r.*.*.ibio")) {
|
||||
for (Path path : files) {
|
||||
Matcher matcher = REGION_FILE.matcher(path.getFileName().toString());
|
||||
if (!matcher.matches()) {
|
||||
continue;
|
||||
}
|
||||
int regionX = parseRegionCoordinate(matcher.group(1));
|
||||
int regionZ = parseRegionCoordinate(matcher.group(2));
|
||||
synchronized (regionLock(regionX << 5, regionZ << 5)) {
|
||||
RegionIndex region = region(regionX << 5, regionZ << 5);
|
||||
boolean[] discarded = new boolean[1024];
|
||||
int count = 0;
|
||||
for (int slot = 0; slot < 1024; slot++) {
|
||||
int chunkX = (regionX << 5) + (slot & 31);
|
||||
int chunkZ = (regionZ << 5) + (slot >> 5);
|
||||
if (region.offsets[slot] != 0L && !inventory.contains(chunkX, chunkZ)
|
||||
&& selected.contains(readRecord(region, slot).activationId())) {
|
||||
discarded[slot] = true;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
if (count != 0) {
|
||||
rewriteRegion(region, discarded);
|
||||
removed = Math.addExact(removed, count);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
int cachedRegionCount() {
|
||||
synchronized (regions) {
|
||||
return regions.size();
|
||||
}
|
||||
}
|
||||
|
||||
int cachedChunkCount() {
|
||||
synchronized (chunks) {
|
||||
return chunks.size();
|
||||
}
|
||||
}
|
||||
|
||||
long cachedByteCount() {
|
||||
synchronized (chunks) {
|
||||
return cachedBytes;
|
||||
}
|
||||
}
|
||||
|
||||
private RegionIndex region(int chunkX, int chunkZ) throws IOException {
|
||||
int regionX = chunkX >> 5;
|
||||
int regionZ = chunkZ >> 5;
|
||||
long regionKey = key(regionX, regionZ);
|
||||
synchronized (regions) {
|
||||
RegionIndex cached = regions.get(regionKey);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
requireSafeAncestors();
|
||||
RegionIndex loaded = new RegionIndex(directory.resolve("r." + regionX + "." + regionZ + ".ibio"), regionX, regionZ);
|
||||
if (Files.exists(loaded.path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
loadIndex(loaded);
|
||||
}
|
||||
synchronized (regions) {
|
||||
regions.put(regionKey, loaded);
|
||||
while (regions.size() > MAXIMUM_CACHED_REGIONS) {
|
||||
regions.remove(regions.keySet().iterator().next());
|
||||
}
|
||||
}
|
||||
return loaded;
|
||||
}
|
||||
|
||||
private void loadIndex(RegionIndex region) throws IOException {
|
||||
if (!Files.isRegularFile(region.path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Saved biome region is not a regular file: " + region.path);
|
||||
}
|
||||
try (RandomAccessFile input = new RandomAccessFile(region.path.toFile(), "rw")) {
|
||||
long fileLength = input.length();
|
||||
if (fileLength < HEADER_BYTES || fileLength > MAXIMUM_REGION_BYTES) {
|
||||
throw new IOException("Invalid saved biome region size: " + region.path);
|
||||
}
|
||||
byte[] header = new byte[HEADER_BYTES];
|
||||
input.readFully(header);
|
||||
ByteBuffer fields = ByteBuffer.wrap(header);
|
||||
if (fields.getInt() != MAGIC || fields.getInt() != FORMAT_VERSION
|
||||
|| fields.getInt() != region.regionX || fields.getInt() != region.regionZ
|
||||
|| fields.getInt() != checksum(header, HEADER_BYTES - 4)) {
|
||||
throw new IOException("Invalid saved biome region header: " + region.path);
|
||||
}
|
||||
byte[] buffer = new byte[8192];
|
||||
int records = 0;
|
||||
while (input.getFilePointer() < fileLength) {
|
||||
long offset = input.getFilePointer();
|
||||
if (fileLength - offset < Integer.BYTES) {
|
||||
truncateTail(input, offset);
|
||||
break;
|
||||
}
|
||||
int length = input.readInt();
|
||||
if (length < 12 || length > MAXIMUM_RECORD_BYTES) {
|
||||
throw new IOException("Invalid saved biome record size: " + region.path);
|
||||
}
|
||||
if (fileLength - input.getFilePointer() < length + 4L) {
|
||||
truncateTail(input, offset);
|
||||
break;
|
||||
}
|
||||
CRC32 crc = new CRC32();
|
||||
input.readFully(buffer, 0, 8);
|
||||
crc.update(buffer, 0, 8);
|
||||
ByteBuffer coordinates = ByteBuffer.wrap(buffer, 0, 8);
|
||||
int chunkX = coordinates.getInt();
|
||||
int chunkZ = coordinates.getInt();
|
||||
for (int remaining = length - 8; remaining > 0;) {
|
||||
int count = Math.min(remaining, buffer.length);
|
||||
input.readFully(buffer, 0, count);
|
||||
crc.update(buffer, 0, count);
|
||||
remaining -= count;
|
||||
}
|
||||
if (input.readInt() != (int) crc.getValue() || chunkX >> 5 != region.regionX || chunkZ >> 5 != region.regionZ) {
|
||||
throw new IOException("Invalid saved biome record checksum or coordinates: " + region.path);
|
||||
}
|
||||
int slot = slot(chunkX, chunkZ);
|
||||
if (++records > 1024 || region.offsets[slot] != 0L) {
|
||||
throw new IOException("Duplicate saved biome chunk record: " + region.path);
|
||||
}
|
||||
region.offsets[slot] = offset;
|
||||
region.lengths[slot] = length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private SavedBiomeChunk readRecord(RegionIndex region, int slot) throws IOException {
|
||||
byte[] encoded = new byte[region.lengths[slot]];
|
||||
try (RandomAccessFile input = new RandomAccessFile(region.path.toFile(), "r")) {
|
||||
input.seek(region.offsets[slot]);
|
||||
if (input.readInt() != encoded.length) {
|
||||
throw new IOException("Saved biome record length changed: " + region.path);
|
||||
}
|
||||
input.readFully(encoded);
|
||||
if (input.readInt() != checksum(encoded)) {
|
||||
throw new IOException("Saved biome record checksum mismatch: " + region.path);
|
||||
}
|
||||
}
|
||||
SavedBiomeChunk result = decode(encoded);
|
||||
if (result.chunkX() >> 5 != region.regionX || result.chunkZ() >> 5 != region.regionZ
|
||||
|| slot(result.chunkX(), result.chunkZ()) != slot) {
|
||||
throw new IOException("Saved biome record coordinates do not match its index: " + region.path);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void ensureRegionFile(RegionIndex region) throws IOException {
|
||||
requireSafeAncestors();
|
||||
Files.createDirectories(directory);
|
||||
if (Files.exists(region.path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (!Files.isRegularFile(region.path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Saved biome region is not a regular file: " + region.path);
|
||||
}
|
||||
return;
|
||||
}
|
||||
ByteBuffer header = ByteBuffer.allocate(HEADER_BYTES);
|
||||
header.putInt(MAGIC).putInt(FORMAT_VERSION).putInt(region.regionX).putInt(region.regionZ);
|
||||
header.putInt(checksum(header.array(), HEADER_BYTES - 4));
|
||||
Path temporary = Files.createTempFile(directory, ".biomes-", ".tmp");
|
||||
try {
|
||||
try (FileChannel output = FileChannel.open(temporary, StandardOpenOption.WRITE)) {
|
||||
header.flip();
|
||||
while (header.hasRemaining()) {
|
||||
output.write(header);
|
||||
}
|
||||
output.force(true);
|
||||
}
|
||||
Files.move(temporary, region.path, StandardCopyOption.ATOMIC_MOVE);
|
||||
forceDirectory(directory);
|
||||
} finally {
|
||||
Files.deleteIfExists(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
private static void forceDirectory(Path path) throws IOException {
|
||||
if (File.separatorChar == '\\') {
|
||||
return;
|
||||
}
|
||||
try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) {
|
||||
channel.force(true);
|
||||
} catch (UnsupportedOperationException failure) {
|
||||
throw new IOException("Saved biome directory cannot be durability-synced: " + path, failure);
|
||||
}
|
||||
}
|
||||
|
||||
private void rewriteRegion(RegionIndex region, boolean[] discarded) throws IOException {
|
||||
Path temporary = Files.createTempFile(directory, ".biomes-", ".tmp");
|
||||
try {
|
||||
try (FileChannel input = FileChannel.open(region.path, StandardOpenOption.READ);
|
||||
FileChannel output = FileChannel.open(temporary, StandardOpenOption.WRITE)) {
|
||||
transfer(input, output, 0L, HEADER_BYTES);
|
||||
for (int slot = 0; slot < 1024; slot++) {
|
||||
if (region.offsets[slot] != 0L && !discarded[slot]) {
|
||||
transfer(input, output, region.offsets[slot], region.lengths[slot] + 8L);
|
||||
}
|
||||
}
|
||||
output.force(true);
|
||||
}
|
||||
Files.move(temporary, region.path, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
synchronized (regions) {
|
||||
regions.remove(key(region.regionX, region.regionZ));
|
||||
}
|
||||
synchronized (chunks) {
|
||||
for (int slot = 0; slot < 1024; slot++) {
|
||||
long chunkKey = key((region.regionX << 5) + (slot & 31), (region.regionZ << 5) + (slot >> 5));
|
||||
Optional<SavedBiomeChunk> removed = chunks.remove(chunkKey);
|
||||
if (removed != null && removed.isPresent()) {
|
||||
cachedBytes -= removed.get().estimatedBytes();
|
||||
}
|
||||
}
|
||||
}
|
||||
forceDirectory(directory);
|
||||
} finally {
|
||||
Files.deleteIfExists(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
private static void transfer(FileChannel input, FileChannel output, long offset, long length) throws IOException {
|
||||
while (length > 0L) {
|
||||
long count = input.transferTo(offset, length, output);
|
||||
if (count <= 0L) {
|
||||
throw new IOException("Saved biome region ended during rewrite");
|
||||
}
|
||||
offset += count;
|
||||
length -= count;
|
||||
}
|
||||
}
|
||||
|
||||
private static int parseRegionCoordinate(String coordinate) throws IOException {
|
||||
try {
|
||||
int parsed = Integer.parseInt(coordinate);
|
||||
if (parsed < Integer.MIN_VALUE >> 5 || parsed > Integer.MAX_VALUE >> 5) {
|
||||
throw new IOException("Saved biome region coordinate is out of range");
|
||||
}
|
||||
return parsed;
|
||||
} catch (NumberFormatException failure) {
|
||||
throw new IOException("Invalid saved biome region coordinate", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private void requireSafeAncestors() throws IOException {
|
||||
for (Path path = directory; path != null && path.startsWith(dimensionRoot); path = path.getParent()) {
|
||||
if (Files.isSymbolicLink(path)) {
|
||||
throw new IOException("Saved biome storage must not use symbolic links: " + path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void cacheChunk(long key, Optional<SavedBiomeChunk> value) {
|
||||
synchronized (chunks) {
|
||||
Optional<SavedBiomeChunk> previous = chunks.remove(key);
|
||||
if (previous != null && previous.isPresent()) {
|
||||
cachedBytes -= previous.get().estimatedBytes();
|
||||
}
|
||||
long weight = value.map(SavedBiomeChunk::estimatedBytes).orElse(0L);
|
||||
if (weight > MAXIMUM_CACHED_BYTES) {
|
||||
return;
|
||||
}
|
||||
chunks.put(key, value);
|
||||
cachedBytes += weight;
|
||||
Iterator<Map.Entry<Long, Optional<SavedBiomeChunk>>> entries = chunks.entrySet().iterator();
|
||||
while (chunks.size() > MAXIMUM_CACHED_CHUNKS || cachedBytes > MAXIMUM_CACHED_BYTES) {
|
||||
Optional<SavedBiomeChunk> removed = entries.next().getValue();
|
||||
cachedBytes -= removed.map(SavedBiomeChunk::estimatedBytes).orElse(0L);
|
||||
entries.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Object regionLock(int chunkX, int chunkZ) {
|
||||
long key = key(chunkX >> 5, chunkZ >> 5);
|
||||
return regionLocks[(int) (key ^ (key >>> 32)) & (regionLocks.length - 1)];
|
||||
}
|
||||
|
||||
private static long key(int x, int z) {
|
||||
return (long) x << 32 | z & 0xffffffffL;
|
||||
}
|
||||
|
||||
private static int slot(int chunkX, int chunkZ) {
|
||||
return (chunkZ & 31) * 32 + (chunkX & 31);
|
||||
}
|
||||
|
||||
private static void truncateTail(RandomAccessFile file, long length) throws IOException {
|
||||
file.setLength(length);
|
||||
file.getChannel().force(true);
|
||||
}
|
||||
|
||||
private static int checksum(byte[] bytes) {
|
||||
return checksum(bytes, bytes.length);
|
||||
}
|
||||
|
||||
private static int checksum(byte[] bytes, int length) {
|
||||
CRC32 checksum = new CRC32();
|
||||
checksum.update(bytes, 0, length);
|
||||
return (int) checksum.getValue();
|
||||
}
|
||||
|
||||
private static byte[] encode(SavedBiomeChunk chunk) throws IOException {
|
||||
LinkedHashMap<SavedBiomeChunk.Cell, Integer> palette = new LinkedHashMap<>();
|
||||
LinkedHashMap<SavedBiomeChunk.Column, Integer> columns = new LinkedHashMap<>();
|
||||
for (int z = 0; z < 16; z++) {
|
||||
for (int x = 0; x < 16; x++) {
|
||||
SavedBiomeChunk.Column column = chunk.column(x, z);
|
||||
columns.computeIfAbsent(column, ignored -> columns.size());
|
||||
palette.computeIfAbsent(column.surface(), ignored -> palette.size());
|
||||
palette.computeIfAbsent(column.caveBase(), ignored -> palette.size());
|
||||
for (SavedBiomeChunk.Span span : column.vertical()) {
|
||||
palette.computeIfAbsent(span.cell(), ignored -> palette.size());
|
||||
}
|
||||
}
|
||||
}
|
||||
if (palette.size() > MAXIMUM_PALETTE_CELLS) {
|
||||
throw new IOException("Saved biome palette exceeds its cell limit");
|
||||
}
|
||||
ByteArrayOutputStream raw = new ByteArrayOutputStream();
|
||||
try (DataOutputStream output = new DataOutputStream(raw)) {
|
||||
output.writeLong(chunk.activationId());
|
||||
output.writeInt(chunk.header().minimumY());
|
||||
output.writeInt(chunk.header().height());
|
||||
output.writeInt(palette.size());
|
||||
for (SavedBiomeChunk.Cell cell : palette.keySet()) {
|
||||
output.writeLong(cell.activationId());
|
||||
output.writeByte(cell.isResolved() ? 0 : 1);
|
||||
if (cell.isResolved()) {
|
||||
output.writeUTF(cell.biomeKey());
|
||||
output.writeUTF(cell.regionKey());
|
||||
}
|
||||
}
|
||||
output.writeInt(columns.size());
|
||||
for (SavedBiomeChunk.Column column : columns.keySet()) {
|
||||
writeUnsigned(output, palette.get(column.surface()));
|
||||
writeUnsigned(output, palette.get(column.caveBase()));
|
||||
writeUnsigned(output, column.vertical().size());
|
||||
for (SavedBiomeChunk.Span span : column.vertical()) {
|
||||
writeUnsigned(output, span.maximumYExclusive() - chunk.header().minimumY());
|
||||
writeUnsigned(output, palette.get(span.cell()));
|
||||
}
|
||||
}
|
||||
for (int z = 0; z < 16; z++) {
|
||||
for (int x = 0; x < 16; x++) {
|
||||
output.writeByte(columns.get(chunk.column(x, z)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (raw.size() > MAXIMUM_DECODED_BYTES) {
|
||||
throw new IOException("Saved biome chunk exceeds its decoded size limit");
|
||||
}
|
||||
ByteArrayOutputStream encoded = new ByteArrayOutputStream();
|
||||
DataOutputStream output = new DataOutputStream(encoded);
|
||||
output.writeInt(chunk.chunkX());
|
||||
output.writeInt(chunk.chunkZ());
|
||||
output.writeInt(raw.size());
|
||||
Deflater deflater = new Deflater(Deflater.BEST_SPEED);
|
||||
try (DeflaterOutputStream compressed = new DeflaterOutputStream(output, deflater)) {
|
||||
raw.writeTo(compressed);
|
||||
} finally {
|
||||
deflater.end();
|
||||
}
|
||||
if (encoded.size() > MAXIMUM_RECORD_BYTES) {
|
||||
throw new IOException("Saved biome chunk exceeds its encoded size limit");
|
||||
}
|
||||
return encoded.toByteArray();
|
||||
}
|
||||
|
||||
private static SavedBiomeChunk decode(byte[] encoded) throws IOException {
|
||||
try (DataInputStream header = new DataInputStream(new ByteArrayInputStream(encoded))) {
|
||||
int chunkX = header.readInt();
|
||||
int chunkZ = header.readInt();
|
||||
int decodedLength = header.readInt();
|
||||
if (decodedLength < 24 || decodedLength > MAXIMUM_DECODED_BYTES) {
|
||||
throw new IOException("Invalid saved biome decoded size");
|
||||
}
|
||||
byte[] raw;
|
||||
try (InflaterInputStream compressed = new InflaterInputStream(header)) {
|
||||
raw = compressed.readNBytes(decodedLength);
|
||||
if (raw.length != decodedLength || compressed.read() != -1) {
|
||||
throw new IOException("Saved biome compressed length mismatch");
|
||||
}
|
||||
}
|
||||
try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(raw))) {
|
||||
SavedBiomeChunk.Header metadata = new SavedBiomeChunk.Header(chunkX, chunkZ, input.readLong(), input.readInt(), input.readInt());
|
||||
DecodeBudget budget = new DecodeBudget();
|
||||
int paletteSize = input.readInt();
|
||||
if (paletteSize <= 0 || paletteSize > MAXIMUM_PALETTE_CELLS || paletteSize > input.available() / 9) {
|
||||
throw new IOException("Invalid saved biome palette size");
|
||||
}
|
||||
budget.reserve(paletteSize * 64L);
|
||||
ArrayList<SavedBiomeChunk.Cell> palette = new ArrayList<>(paletteSize);
|
||||
for (int index = 0; index < paletteSize; index++) {
|
||||
long activationId = input.readLong();
|
||||
int resolution = input.readUnsignedByte();
|
||||
SavedBiomeChunk.Cell cell = switch (resolution) {
|
||||
case 0 -> new SavedBiomeChunk.Cell(activationId, readKey(input, budget), readKey(input, budget));
|
||||
case 1 -> SavedBiomeChunk.Cell.unresolved(activationId);
|
||||
default -> throw new IOException("Invalid saved biome resolution state");
|
||||
};
|
||||
palette.add(cell);
|
||||
}
|
||||
int columnCount = input.readInt();
|
||||
if (columnCount <= 0 || columnCount > 256) {
|
||||
throw new IOException("Invalid saved biome column palette size");
|
||||
}
|
||||
budget.reserve(columnCount * 128L);
|
||||
ArrayList<SavedBiomeChunk.Column> columns = new ArrayList<>(columnCount);
|
||||
for (int index = 0; index < columnCount; index++) {
|
||||
SavedBiomeChunk.Cell surface = paletteCell(palette, readUnsigned(input));
|
||||
SavedBiomeChunk.Cell caveBase = paletteCell(palette, readUnsigned(input));
|
||||
int count = readUnsigned(input);
|
||||
if (count <= 0 || count > metadata.height() || count > input.available() / 2) {
|
||||
throw new IOException("Invalid saved biome span count");
|
||||
}
|
||||
budget.reserve(count * 80L);
|
||||
ArrayList<SavedBiomeChunk.Span> spans = new ArrayList<>(count);
|
||||
int start = metadata.minimumY();
|
||||
for (int spanIndex = 0; spanIndex < count; spanIndex++) {
|
||||
int end = Math.addExact(metadata.minimumY(), readUnsigned(input));
|
||||
spans.add(new SavedBiomeChunk.Span(start, end, paletteCell(palette, readUnsigned(input))));
|
||||
start = end;
|
||||
}
|
||||
columns.add(new SavedBiomeChunk.Column(surface, caveBase, spans));
|
||||
}
|
||||
SavedBiomeChunk.Builder builder = SavedBiomeChunk.builder(metadata);
|
||||
for (int z = 0; z < 16; z++) {
|
||||
for (int x = 0; x < 16; x++) {
|
||||
int column = input.readUnsignedByte();
|
||||
if (column >= columns.size()) {
|
||||
throw new IOException("Invalid saved biome column index");
|
||||
}
|
||||
builder.column(x, z, columns.get(column));
|
||||
}
|
||||
}
|
||||
if (input.read() != -1) {
|
||||
throw new IOException("Unexpected saved biome payload bytes");
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
} catch (IllegalArgumentException | ArithmeticException failure) {
|
||||
throw new IOException("Invalid saved biome chunk payload", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static String readKey(DataInputStream input, DecodeBudget budget) throws IOException {
|
||||
int length = input.readUnsignedShort();
|
||||
if (length > ChunkGenerationSemantics.MAX_KEY_BYTES * 3 || length > input.available()) {
|
||||
throw new IOException("Invalid saved biome resource key size");
|
||||
}
|
||||
budget.reserve(64L + length * 4L);
|
||||
byte[] encoded = new byte[length + 2];
|
||||
ByteBuffer.wrap(encoded).putShort((short) length);
|
||||
input.readFully(encoded, 2, length);
|
||||
try (DataInputStream key = new DataInputStream(new ByteArrayInputStream(encoded))) {
|
||||
return key.readUTF();
|
||||
}
|
||||
}
|
||||
|
||||
private static SavedBiomeChunk.Cell paletteCell(List<SavedBiomeChunk.Cell> palette, int index) throws IOException {
|
||||
if (index < 0 || index >= palette.size()) {
|
||||
throw new IOException("Invalid saved biome palette index");
|
||||
}
|
||||
return palette.get(index);
|
||||
}
|
||||
|
||||
private static void writeUnsigned(DataOutputStream output, int value) throws IOException {
|
||||
while ((value & ~0x7f) != 0) {
|
||||
output.writeByte((value & 0x7f) | 0x80);
|
||||
value >>>= 7;
|
||||
}
|
||||
output.writeByte(value);
|
||||
}
|
||||
|
||||
private static int readUnsigned(DataInputStream input) throws IOException {
|
||||
int result = 0;
|
||||
for (int shift = 0; shift < 35; shift += 7) {
|
||||
int value = input.readUnsignedByte();
|
||||
if (shift == 28 && (value & 0xf8) != 0) {
|
||||
throw new IOException("Invalid saved biome variable integer");
|
||||
}
|
||||
result |= (value & 0x7f) << shift;
|
||||
if ((value & 0x80) == 0) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
throw new IOException("Invalid saved biome variable integer");
|
||||
}
|
||||
|
||||
private static final class DecodeBudget {
|
||||
private long remaining = SavedBiomeChunk.MAXIMUM_ESTIMATED_BYTES - 8192L;
|
||||
|
||||
private void reserve(long bytes) throws IOException {
|
||||
if (bytes < 0L || bytes > remaining) {
|
||||
throw new IOException("Saved biome chunk exceeds its decoded footprint limit");
|
||||
}
|
||||
remaining -= bytes;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RegionIndex {
|
||||
private final Path path;
|
||||
private final int regionX;
|
||||
private final int regionZ;
|
||||
private final long[] offsets = new long[1024];
|
||||
private final int[] lengths = new int[1024];
|
||||
|
||||
private RegionIndex(Path path, int regionX, int regionZ) {
|
||||
this.path = path;
|
||||
this.regionX = regionX;
|
||||
this.regionZ = regionZ;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
public final class SavedBiomeUnavailableException extends IllegalStateException {
|
||||
private final boolean loading;
|
||||
|
||||
public SavedBiomeUnavailableException(String message, boolean loading) {
|
||||
super(message);
|
||||
this.loading = loading;
|
||||
}
|
||||
|
||||
public SavedBiomeUnavailableException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
loading = false;
|
||||
}
|
||||
|
||||
public boolean isLoading() {
|
||||
return loading;
|
||||
}
|
||||
}
|
||||
+12
-4
@@ -50,6 +50,7 @@ import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.iris.spi.PlatformBiome;
|
||||
import art.arcane.iris.engine.history.TransitionGenerationPlan;
|
||||
import art.arcane.iris.engine.history.FloatingBiomeOverlay;
|
||||
import java.util.Optional;
|
||||
|
||||
import java.util.IdentityHashMap;
|
||||
@@ -395,7 +396,7 @@ public class IrisFloatingChildBiomeModifier extends EngineAssignedModifier<Platf
|
||||
ChunkContext context
|
||||
) {
|
||||
try {
|
||||
IdentityHashMap<IrisFloatingChildBiomes, PlatformBiome> matterByEntry = new IdentityHashMap<>();
|
||||
IdentityHashMap<IrisFloatingChildBiomes, FloatingBiome> matterByEntry = new IdentityHashMap<>();
|
||||
for (int k = 0; k <= sample.topIdx; k++) {
|
||||
if (!sample.solidMask[k]) {
|
||||
continue;
|
||||
@@ -408,16 +409,18 @@ public class IrisFloatingChildBiomeModifier extends EngineAssignedModifier<Platf
|
||||
continue;
|
||||
}
|
||||
IrisFloatingChildBiomes entry = sample.entryAt(k);
|
||||
PlatformBiome matter = matterByEntry.get(entry);
|
||||
FloatingBiome matter = matterByEntry.get(entry);
|
||||
if (matter == null) {
|
||||
IrisBiome target = entry == null ? parent : entry.getRealBiome(parent, data);
|
||||
if (target == null) {
|
||||
continue;
|
||||
}
|
||||
matter = createSkyBiome(target, wx, wz);
|
||||
matter = new FloatingBiome(createSkyBiome(target, wx, wz),
|
||||
new FloatingBiomeOverlay.Identity(target.getLoadKey(),
|
||||
context.getRegion().get(wx & 15, wz & 15).getLoadKey()));
|
||||
matterByEntry.put(entry, matter);
|
||||
}
|
||||
PlatformBiome selected = matter;
|
||||
PlatformBiome selected = matter.physical();
|
||||
TransitionGenerationPlan transition = context.getComplex().getTransitionGenerationPlan();
|
||||
if (transition != null) {
|
||||
Optional<String> historical = transition.historicalPhysicalBiomeKeyAt(wx, y + getEngine().getMinHeight(), wz);
|
||||
@@ -426,6 +429,7 @@ public class IrisFloatingChildBiomeModifier extends EngineAssignedModifier<Platf
|
||||
}
|
||||
}
|
||||
context.setNaturalBiome(wx & 15, y, wz & 15, selected);
|
||||
context.floatingBiomes(chunkHeight).record(wx & 15, y, wz & 15, matter.identity());
|
||||
if (!context.isSpeculativeTerrain()) {
|
||||
getEngine().getMantle().getMantle().set(wx, y, wz, BiomeInjectMatter.get(selected.key()));
|
||||
}
|
||||
@@ -451,4 +455,8 @@ public class IrisFloatingChildBiomeModifier extends EngineAssignedModifier<Platf
|
||||
|
||||
return IrisPlatforms.get().registries().biome(target.getSkyBiomeKey(rng, getEngine(), wx, 0, wz));
|
||||
}
|
||||
|
||||
private record FloatingBiome(PlatformBiome physical, FloatingBiomeOverlay.Identity identity) {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.experimental.Accessors;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.HeightMap;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Particle;
|
||||
import org.bukkit.Sound;
|
||||
@@ -242,24 +244,7 @@ public class IrisEffect {
|
||||
if (particleType != null) {
|
||||
Location part = p.getLocation().clone().add(p.getLocation().getDirection().clone().multiply(RNG.r.i(particleDistance) + particleAway)).clone().add(p.getLocation().getDirection().clone().rotateAroundY(Math.toRadians(90)).multiply(RNG.r.d(-particleDistanceWidth, particleDistanceWidth)));
|
||||
|
||||
part.setY(Math.round(g.getHeight(part.getBlockX(), part.getBlockZ())) + 1);
|
||||
part.add(RNG.r.d(), 0, RNG.r.d());
|
||||
int offset = p.getWorld().getMinHeight();
|
||||
if (extra != 0) {
|
||||
p.spawnParticle(particleType, part.getX(), part.getY() + offset + RNG.r.i(particleOffset),
|
||||
part.getZ(),
|
||||
particleCount,
|
||||
randomAltX ? RNG.r.d(-particleAltX, particleAltX) : particleAltX,
|
||||
randomAltY ? RNG.r.d(-particleAltY, particleAltY) : particleAltY,
|
||||
randomAltZ ? RNG.r.d(-particleAltZ, particleAltZ) : particleAltZ,
|
||||
extra);
|
||||
} else {
|
||||
p.spawnParticle(particleType, part.getX(), part.getY() + offset + RNG.r.i(particleOffset), part.getZ(),
|
||||
particleCount,
|
||||
randomAltX ? RNG.r.d(-particleAltX, particleAltX) : particleAltX,
|
||||
randomAltY ? RNG.r.d(-particleAltY, particleAltY) : particleAltY,
|
||||
randomAltZ ? RNG.r.d(-particleAltZ, particleAltZ) : particleAltZ);
|
||||
}
|
||||
applyParticles(p, particleType, part);
|
||||
}
|
||||
|
||||
if (commandRegistry != null) {
|
||||
@@ -285,6 +270,41 @@ public class IrisEffect {
|
||||
});
|
||||
}
|
||||
|
||||
void applyParticles(Player player, Particle particle, Location location) {
|
||||
World world = location.getWorld();
|
||||
if (J.isOwnedByCurrentRegion(world, location.getBlockX() >> 4, location.getBlockZ() >> 4)) {
|
||||
sampleParticleSurface(player, particle, location);
|
||||
} else {
|
||||
J.runAt(location, () -> sampleParticleSurface(player, particle, location));
|
||||
}
|
||||
}
|
||||
|
||||
private void sampleParticleSurface(Player player, Particle particle, Location location) {
|
||||
World world = location.getWorld();
|
||||
if (!world.isChunkLoaded(location.getBlockX() >> 4, location.getBlockZ() >> 4)) {
|
||||
return;
|
||||
}
|
||||
location.setY(world.getHighestBlockYAt(location.getBlockX(), location.getBlockZ(), HeightMap.OCEAN_FLOOR) + 1);
|
||||
location.add(RNG.r.d(), 0, RNG.r.d());
|
||||
BukkitFx.run(player, () -> emitParticles(player, particle, location));
|
||||
}
|
||||
|
||||
private void emitParticles(Player player, Particle particle, Location location) {
|
||||
if (player.getWorld() != location.getWorld()) {
|
||||
return;
|
||||
}
|
||||
double xOffset = randomAltX ? RNG.r.d(-particleAltX, particleAltX) : particleAltX;
|
||||
double yOffset = randomAltY ? RNG.r.d(-particleAltY, particleAltY) : particleAltY;
|
||||
double zOffset = randomAltZ ? RNG.r.d(-particleAltZ, particleAltZ) : particleAltZ;
|
||||
if (extra != 0) {
|
||||
player.spawnParticle(particle, location.getX(), location.getY() + RNG.r.i(particleOffset),
|
||||
location.getZ(), particleCount, xOffset, yOffset, zOffset, extra);
|
||||
} else {
|
||||
player.spawnParticle(particle, location.getX(), location.getY() + RNG.r.i(particleOffset),
|
||||
location.getZ(), particleCount, xOffset, yOffset, zOffset);
|
||||
}
|
||||
}
|
||||
|
||||
public void apply(Entity p) {
|
||||
if (!shouldApplyNow()) {
|
||||
return;
|
||||
|
||||
@@ -23,6 +23,7 @@ import art.arcane.iris.spi.IrisServices;
|
||||
import art.arcane.iris.core.IrisSettings;
|
||||
import art.arcane.iris.core.link.Identifier;
|
||||
import art.arcane.iris.core.loader.IrisRegistrant;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.service.EntityRiseSVC;
|
||||
import art.arcane.iris.core.service.ExternalDataSVC;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
@@ -507,6 +508,7 @@ public class IrisEntity extends IrisRegistrant {
|
||||
}
|
||||
|
||||
private static void bindLoot(IrisEntity entity, Engine gen, Lootable l, Location finalAt, RNG rng) {
|
||||
IrisData definitions = gen.getData();
|
||||
l.setLootTable(new LootTable() {
|
||||
@Override
|
||||
public NamespacedKey getKey() {
|
||||
@@ -518,7 +520,7 @@ public class IrisEntity extends IrisRegistrant {
|
||||
KList<ItemStack> items = new KList<>();
|
||||
|
||||
for (String fi : entity.getLoot().getTables()) {
|
||||
IrisLootTable i = gen.getData().getLootLoader().load(fi);
|
||||
IrisLootTable i = definitions.getLootLoader().load(fi);
|
||||
items.addAll(i.getLoot(gen.isStudio(), gen.getSeedManager().getLoot(), InventorySlotType.STORAGE, finalAt.getWorld(), finalAt.getBlockX(), finalAt.getBlockY(), finalAt.getBlockZ()));
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.experimental.Accessors;
|
||||
import org.bukkit.Chunk;
|
||||
import org.bukkit.HeightMap;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.World;
|
||||
@@ -80,9 +81,9 @@ public class IrisEntitySpawn implements IRare {
|
||||
for (int id = 0; id < spawns; id++) {
|
||||
int x = (c.getX() * 16) + rng.i(16);
|
||||
int z = (c.getZ() * 16) + rng.i(16);
|
||||
World world = BukkitWorldBinding.tryBind(gen.getWorld()) ? BukkitWorldBinding.world(gen.getWorld()) : null;
|
||||
int h = gen.getHeight(x, z, true) + (world == null ? -64 : world.getMinHeight());
|
||||
int hf = gen.getHeight(x, z, false) + (world == null ? -64 : world.getMinHeight());
|
||||
World world = c.getWorld();
|
||||
int h = world.getHighestBlockYAt(x, z, HeightMap.OCEAN_FLOOR);
|
||||
int hf = world.getHighestBlockYAt(x, z, HeightMap.WORLD_SURFACE);
|
||||
Location l = switch (getReferenceSpawner().getGroup()) {
|
||||
case NORMAL -> new Location(c.getWorld(), x, hf + 1, z);
|
||||
case CAVE -> findCaveSpawnLocation(gen, c, rng);
|
||||
|
||||
@@ -8,6 +8,7 @@ import art.arcane.iris.engine.framework.EngineMetrics;
|
||||
import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime;
|
||||
import art.arcane.iris.engine.object.FloatingIslandBoundarySampler;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.history.FloatingBiomeOverlay;
|
||||
import art.arcane.iris.engine.object.IrisRegion;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.iris.spi.PlatformBiome;
|
||||
@@ -22,6 +23,7 @@ public class ChunkContext {
|
||||
private boolean naturalTerrain = true;
|
||||
private boolean speculativeTerrain;
|
||||
private Hunk<PlatformBiome> terrainBiomes;
|
||||
private FloatingBiomeOverlay floatingBiomes;
|
||||
private final int x;
|
||||
private final int z;
|
||||
private final IrisComplex complex;
|
||||
@@ -141,6 +143,21 @@ public class ChunkContext {
|
||||
this.terrainBiomes = terrainBiomes;
|
||||
}
|
||||
|
||||
public FloatingBiomeOverlay getFloatingBiomes() {
|
||||
return floatingBiomes;
|
||||
}
|
||||
|
||||
public void setFloatingBiomes(FloatingBiomeOverlay floatingBiomes) {
|
||||
this.floatingBiomes = floatingBiomes;
|
||||
}
|
||||
|
||||
public FloatingBiomeOverlay floatingBiomes(int height) {
|
||||
if (floatingBiomes == null) {
|
||||
floatingBiomes = new FloatingBiomeOverlay(height);
|
||||
}
|
||||
return floatingBiomes;
|
||||
}
|
||||
|
||||
public void setNaturalBiome(int localX, int y, int localZ, PlatformBiome biome) {
|
||||
if (terrainBiomes != null) {
|
||||
terrainBiomes.setRaw(localX, y, localZ, biome);
|
||||
|
||||
+15
-1
@@ -64,12 +64,26 @@ public class WorldReplacementBootstrapTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsNestedMetadataAddedAfterImmutableEpochPublication() throws Exception {
|
||||
public void acceptsFinderMetadataAddedAfterImmutableEpochPublication() throws Exception {
|
||||
Transaction transaction = stagedTransaction(Phase.ARMED, true, "original");
|
||||
configureReplacement(transaction);
|
||||
Path dimensions = activePackRoot(paths(transaction).stage()).resolve("dimensions");
|
||||
Files.writeString(dimensions.resolve(".DS_Store"), "Finder metadata");
|
||||
|
||||
WorldReplacementBootstrap.ReconcileResult result = reconcile();
|
||||
|
||||
assertEquals(1, result.published());
|
||||
assertEquals("replacement", replacementContent(target.worldDirectory()));
|
||||
assertEquals(Phase.PUBLISHED, loadSingle().phase());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsNestedResourceAddedAfterImmutableEpochPublication() throws Exception {
|
||||
Transaction transaction = stagedTransaction(Phase.ARMED, true, "original");
|
||||
configureReplacement(transaction);
|
||||
Path dimensions = activePackRoot(paths(transaction).stage()).resolve("dimensions");
|
||||
Files.writeString(dimensions.resolve(".hidden-resource"), "changed resource");
|
||||
|
||||
assertThrows(IOException.class, this::reconcile);
|
||||
|
||||
assertTrue(Files.isDirectory(paths(transaction).stage()));
|
||||
|
||||
@@ -3,6 +3,12 @@ package art.arcane.iris.engine;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.engine.GenerationRuntime.BiomeMaxes;
|
||||
import art.arcane.iris.engine.framework.EngineEffects;
|
||||
import art.arcane.iris.engine.framework.BiomeEnvironment;
|
||||
import art.arcane.iris.engine.history.SavedBiomeRuntime;
|
||||
import art.arcane.iris.engine.history.SavedBiomeUnavailableException;
|
||||
import art.arcane.iris.engine.object.IrisRegion;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import art.arcane.iris.engine.framework.EngineMode;
|
||||
import art.arcane.iris.engine.framework.EngineStage;
|
||||
import art.arcane.iris.engine.framework.EngineTarget;
|
||||
@@ -59,6 +65,7 @@ import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class IrisEngineGenerationRuntimeScopeTest {
|
||||
@@ -86,6 +93,7 @@ public class IrisEngineGenerationRuntimeScopeTest {
|
||||
IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class));
|
||||
IrisEngine.GenerationRuntimeBinding binding = detachedBinding(engine, selected.runtime);
|
||||
GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class);
|
||||
when(router.biomes()).thenReturn(mock(SavedBiomeRuntime.class));
|
||||
setField(engine, "generationHistoryRuntimeRouter", router);
|
||||
doReturn(null).when(engine).getDimensionStackContext();
|
||||
doReturn(false).when(engine).answersFromNaturalTerrain(anyInt(), anyInt());
|
||||
@@ -128,6 +136,7 @@ public class IrisEngineGenerationRuntimeScopeTest {
|
||||
RuntimeFixture selected = runtime(2, 2D, 2D, 2D);
|
||||
IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class));
|
||||
GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class);
|
||||
when(router.biomes()).thenReturn(mock(SavedBiomeRuntime.class));
|
||||
setField(engine, "generationHistoryRuntimeRouter", router);
|
||||
ProceduralStream<IrisBiome> caves = mock(ProceduralStream.class);
|
||||
when(active.complex.getCaveBiomeStream()).thenReturn(caves);
|
||||
@@ -149,6 +158,82 @@ public class IrisEngineGenerationRuntimeScopeTest {
|
||||
verify(router, times(3)).openCoordinateScope(19, -3);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void unboundContentContextSkipsSavedBiomesAndSelectsTheCoordinateRuntime() throws Exception {
|
||||
RuntimeFixture active = runtime(1, 1D, 1D, 1D);
|
||||
RuntimeFixture selected = runtime(2, 2D, 2D, 2D);
|
||||
IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class));
|
||||
IrisEngine.GenerationRuntimeBinding binding = detachedBinding(engine, selected.runtime);
|
||||
GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class);
|
||||
SavedBiomeRuntime saved = mock(SavedBiomeRuntime.class);
|
||||
when(router.biomes()).thenReturn(saved);
|
||||
setField(engine, "generationHistoryRuntimeRouter", router);
|
||||
doReturn(null).when(engine).getDimensionStackContext();
|
||||
doReturn(false).when(engine).answersFromNaturalTerrain(anyInt(), anyInt());
|
||||
ChunkContext context = mock(ChunkContext.class);
|
||||
when(context.getComplex()).thenReturn(active.complex);
|
||||
when(context.isNaturalTerrain()).thenReturn(false);
|
||||
IrisBiome cave = mock(IrisBiome.class);
|
||||
IrisBiome surface = mock(IrisBiome.class);
|
||||
IrisRegion region = mock(IrisRegion.class);
|
||||
ProceduralStream<IrisBiome> caves = mock(ProceduralStream.class);
|
||||
ProceduralStream<IrisBiome> surfaces = mock(ProceduralStream.class);
|
||||
ProceduralStream<IrisRegion> regions = mock(ProceduralStream.class);
|
||||
when(selected.complex.getCaveBiomeStream()).thenReturn(caves);
|
||||
when(selected.complex.getTrueBiomeStream()).thenReturn(surfaces);
|
||||
when(selected.complex.getRegionStream()).thenReturn(regions);
|
||||
when(caves.get(19D, -3D)).thenReturn(cave);
|
||||
when(surfaces.get(19D, -3D)).thenReturn(surface);
|
||||
when(regions.get(19D, -3D)).thenReturn(region);
|
||||
when(router.openCoordinateScope(19, -3)).thenAnswer(invocation -> {
|
||||
assertSame(active.complex, engine.getComplex());
|
||||
IrisEngine.GenerationRuntimeScope runtimeScope = engine.openGenerationRuntimeScope(binding);
|
||||
GenerationHistoryRuntimeRouter.CoordinateScope coordinateScope = mock(GenerationHistoryRuntimeRouter.CoordinateScope.class);
|
||||
doAnswer(close -> {
|
||||
runtimeScope.close();
|
||||
return null;
|
||||
}).when(coordinateScope).close();
|
||||
return coordinateScope;
|
||||
});
|
||||
|
||||
try (IrisContext.Scope ignored = IrisContext.open(engine, 73L, context)) {
|
||||
assertSame(cave, engine.getCaveBiome(19, -3));
|
||||
assertSame(surface, engine.getSurfaceBiome(19, -3));
|
||||
assertSame(region, engine.getRegion(19, -3));
|
||||
assertSame(region, engine.getRegion(19, 20, -3));
|
||||
assertSame(active.complex, engine.getComplex());
|
||||
}
|
||||
verify(router, times(4)).openCoordinateScope(19, -3);
|
||||
verifyNoInteractions(saved);
|
||||
assertNull(IrisContext.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void gameplayContextWithoutAChunkStillReadsSavedBiomes() throws Exception {
|
||||
RuntimeFixture active = runtime(1, 1D, 1D, 1D);
|
||||
IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class));
|
||||
when(active.target.getWorld().minHeight()).thenReturn(-64);
|
||||
GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class);
|
||||
SavedBiomeRuntime saved = mock(SavedBiomeRuntime.class);
|
||||
when(router.biomes()).thenReturn(saved);
|
||||
setField(engine, "generationHistoryRuntimeRouter", router);
|
||||
BiomeEnvironment environment = new BiomeEnvironment(4L, mock(IrisBiome.class),
|
||||
mock(IrisRegion.class), mock(IrisDimension.class), mock(IrisData.class));
|
||||
when(saved.resolve(19, -64, -3, true)).thenReturn(Optional.of(environment));
|
||||
when(saved.resolveCaveBase(19, -3)).thenReturn(Optional.of(environment));
|
||||
|
||||
try (IrisContext.Scope ignored = IrisContext.open(engine, 0L, null)) {
|
||||
assertSame(environment.biome(), engine.getCaveBiome(19, -3));
|
||||
assertSame(environment.biome(), engine.getSurfaceBiome(19, -3));
|
||||
assertSame(environment.region(), engine.getRegion(19, -3));
|
||||
}
|
||||
verify(saved).resolveCaveBase(19, -3);
|
||||
verify(saved, times(2)).resolve(19, -64, -3, true);
|
||||
verify(router, never()).openCoordinateScope(anyInt(), anyInt());
|
||||
assertNull(IrisContext.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void scopedGettersResolveDetachedGenerationStateAndWorldServicesStayActive() throws Exception {
|
||||
RuntimeFixture active = runtime(1, 1D, 2D, 3D);
|
||||
@@ -426,6 +511,7 @@ public class IrisEngineGenerationRuntimeScopeTest {
|
||||
RuntimeFixture active = runtime(1, 1D, 1D, 1D);
|
||||
IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class));
|
||||
GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class);
|
||||
when(router.biomes()).thenReturn(mock(SavedBiomeRuntime.class));
|
||||
when(router.engine()).thenReturn(engine);
|
||||
engine.attachGenerationHistoryRuntimeRouter(router);
|
||||
|
||||
@@ -441,6 +527,7 @@ public class IrisEngineGenerationRuntimeScopeTest {
|
||||
RuntimeFixture active = runtime(1, 1D, 1D, 1D);
|
||||
IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class));
|
||||
GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class);
|
||||
when(router.biomes()).thenReturn(mock(SavedBiomeRuntime.class));
|
||||
when(router.engine()).thenReturn(engine);
|
||||
engine.attachGenerationHistoryRuntimeRouter(router);
|
||||
|
||||
@@ -457,6 +544,7 @@ public class IrisEngineGenerationRuntimeScopeTest {
|
||||
IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class));
|
||||
assertNull(engine.openGenerationHistoryCoordinateScope(0, 0));
|
||||
GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class);
|
||||
when(router.biomes()).thenReturn(mock(SavedBiomeRuntime.class));
|
||||
when(router.engine()).thenReturn(engine);
|
||||
engine.attachGenerationHistoryRuntimeRouter(router);
|
||||
engine.detachGenerationHistoryRuntimeRouter(router);
|
||||
@@ -540,6 +628,83 @@ public class IrisEngineGenerationRuntimeScopeTest {
|
||||
verify(detached.data).close();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void savedBiomesAnswerWithoutOpeningGenerationRoutes() throws Exception {
|
||||
RuntimeFixture active = runtime(1, 1D, 1D, 1D);
|
||||
IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class));
|
||||
when(active.target.getWorld().minHeight()).thenReturn(-64);
|
||||
GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class);
|
||||
SavedBiomeRuntime saved = mock(SavedBiomeRuntime.class);
|
||||
when(router.biomes()).thenReturn(saved);
|
||||
setField(engine, "generationHistoryRuntimeRouter", router);
|
||||
BiomeEnvironment environment = new BiomeEnvironment(4L, mock(IrisBiome.class),
|
||||
mock(IrisRegion.class), mock(IrisDimension.class), mock(IrisData.class));
|
||||
when(saved.resolve(19, -44, -3, false)).thenReturn(Optional.of(environment));
|
||||
when(saved.resolve(19, -64, -3, true)).thenReturn(Optional.of(environment));
|
||||
when(saved.resolveCaveBase(19, -3)).thenReturn(Optional.of(environment));
|
||||
|
||||
assertSame(environment, engine.getBiomeEnvironment(19, 20, -3));
|
||||
assertSame(environment, engine.getSurfaceBiomeEnvironment(19, -3));
|
||||
assertSame(environment.biome(), engine.getBiome(19, 20, -3));
|
||||
assertSame(environment.biome(), engine.getBiomeOrMantle(19, 20, -3));
|
||||
assertSame(environment.biome(), engine.getCaveOrMantleBiome(19, 20, -3));
|
||||
assertSame(environment.biome(), engine.getCaveBiome(19, 20, -3));
|
||||
assertSame(environment.biome(), engine.getCaveBiome(19, -3));
|
||||
assertSame(environment.biome(), engine.getSurfaceBiome(19, -3));
|
||||
assertSame(environment.region(), engine.getRegion(19, -3));
|
||||
assertSame(environment.region(), engine.getRegion(19, 20, -3));
|
||||
verify(router, never()).openCoordinateScope(anyInt(), anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unavailableSavedBiomesNeverUseTheActiveGenerator() throws Exception {
|
||||
RuntimeFixture active = runtime(1, 1D, 1D, 1D);
|
||||
IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class));
|
||||
GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class);
|
||||
SavedBiomeRuntime saved = mock(SavedBiomeRuntime.class);
|
||||
when(router.biomes()).thenReturn(saved);
|
||||
setField(engine, "generationHistoryRuntimeRouter", router);
|
||||
SavedBiomeUnavailableException failure = new SavedBiomeUnavailableException("Missing historical biome", false);
|
||||
when(saved.resolve(19, 20, -3, false)).thenThrow(failure);
|
||||
|
||||
assertSame(failure, assertThrows(SavedBiomeUnavailableException.class,
|
||||
() -> engine.getBiome(19, 20, -3)));
|
||||
verify(router, never()).openCoordinateScope(anyInt(), anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void definitionScopesRestoreAndLeaveGenerationRuntimeUnchanged() throws Exception {
|
||||
RuntimeFixture active = runtime(1, 1D, 1D, 1D);
|
||||
RuntimeFixture selected = runtime(2, 2D, 2D, 2D);
|
||||
IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class));
|
||||
BiomeEnvironment first = new BiomeEnvironment(4L, mock(IrisBiome.class),
|
||||
mock(IrisRegion.class), mock(IrisDimension.class), mock(IrisData.class));
|
||||
BiomeEnvironment second = new BiomeEnvironment(5L, mock(IrisBiome.class),
|
||||
mock(IrisRegion.class), mock(IrisDimension.class), mock(IrisData.class));
|
||||
|
||||
try (BiomeEnvironment.Scope outer = engine.openBiomeEnvironmentScope(first)) {
|
||||
assertSame(first.data(), engine.getData());
|
||||
assertSame(first.dimension(), engine.getDimension());
|
||||
assertSame(active.complex, engine.getComplex());
|
||||
try (BiomeEnvironment.Scope inner = engine.openBiomeEnvironmentScope(second)) {
|
||||
assertSame(second.data(), engine.getData());
|
||||
assertThrows(IllegalStateException.class, outer::close);
|
||||
CompletableFuture.runAsync(() -> assertThrows(IllegalStateException.class, inner::close))
|
||||
.get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
assertSame(first.data(), engine.getData());
|
||||
try (IrisEngine.GenerationRuntimeScope ignored = engine.openGenerationRuntimeScope(
|
||||
detachedBinding(engine, selected.runtime))) {
|
||||
assertSame(selected.data, engine.getData());
|
||||
assertSame(selected.dimension, engine.getDimension());
|
||||
assertSame(selected.complex, engine.getComplex());
|
||||
}
|
||||
assertSame(first.data(), engine.getData());
|
||||
}
|
||||
assertSame(active.data, engine.getData());
|
||||
assertSame(active.dimension, engine.getDimension());
|
||||
}
|
||||
|
||||
private static IrisEngine engine(
|
||||
GenerationRuntime generationRuntime,
|
||||
EngineEffects effects,
|
||||
@@ -549,6 +714,7 @@ public class IrisEngineGenerationRuntimeScopeTest {
|
||||
setField(engine, "lifecycleLock", new Object());
|
||||
setField(engine, "generationHistoryRuntimeRouterLock", new Object());
|
||||
setField(engine, "runtimeAssembly", new ThreadLocal<EngineRuntimeBuilder.RuntimeAssembly>());
|
||||
setField(engine, "biomeEnvironmentScopes", new ThreadLocal<Object>());
|
||||
setField(engine, "generationRuntimeScopes", new GenerationRuntimeScopeState());
|
||||
Set<GenerationRuntime> detached = Collections.synchronizedSet(
|
||||
Collections.newSetFromMap(new IdentityHashMap<GenerationRuntime, Boolean>()));
|
||||
|
||||
@@ -4,6 +4,7 @@ import art.arcane.iris.engine.framework.EngineMode;
|
||||
import art.arcane.iris.engine.framework.EnginePlatformHooks;
|
||||
import art.arcane.iris.engine.framework.EngineStage;
|
||||
import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter;
|
||||
import art.arcane.iris.engine.history.FloatingBiomeOverlay;
|
||||
import art.arcane.iris.engine.history.BoundaryColumnGeometry;
|
||||
import art.arcane.iris.engine.history.TerrainBoundarySignatureStore;
|
||||
import art.arcane.iris.engine.history.TransitionGenerationPlan;
|
||||
@@ -12,6 +13,9 @@ import art.arcane.iris.engine.history.TerrainBoundarySignature;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisRegion;
|
||||
import art.arcane.iris.spi.PlatformBiome;
|
||||
import art.arcane.iris.spi.IrisPlatform;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.spi.PlatformRegistries;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.iris.util.project.context.ChunkContext;
|
||||
import art.arcane.iris.util.project.context.IrisContext;
|
||||
@@ -19,6 +23,7 @@ import art.arcane.iris.util.project.hunk.Hunk;
|
||||
import art.arcane.iris.util.project.stream.ProceduralStream;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Field;
|
||||
@@ -46,12 +51,42 @@ import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.doCallRealMethod;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public final class ResolvedTerrainProviderTest {
|
||||
@Test
|
||||
public void cachedSpeculativeFloatingIdentityIsPublishedOnlyByActualGeneration() throws Exception {
|
||||
Fixture fixture = new Fixture();
|
||||
fixture.floatingIdentity = new FloatingBiomeOverlay.Identity("floating-child", "region");
|
||||
TransitionGenerationPlan plan = mock(TransitionGenerationPlan.class);
|
||||
when(plan.hasTransitionAtChunk(0, 0)).thenReturn(true);
|
||||
when(plan.newEpochWeightAt(anyInt(), anyInt())).thenReturn(1D);
|
||||
when(fixture.complex.getTransitionGenerationPlan()).thenReturn(plan);
|
||||
fixture.provider.column(0, 0);
|
||||
verify(fixture.router, never()).recordFloatingBiomes(anyInt(), anyInt(), any());
|
||||
IrisPlatform platform = mock(IrisPlatform.class);
|
||||
PlatformRegistries registries = mock(PlatformRegistries.class);
|
||||
when(platform.registries()).thenReturn(registries);
|
||||
when(registries.blockOrNull("minecraft:stone")).thenReturn(fixture.stone);
|
||||
when(registries.blockOrNull("minecraft:air")).thenReturn(fixture.air);
|
||||
when(registries.biome("minecraft:plains")).thenReturn(fixture.biome);
|
||||
ChunkContext context = new ChunkContext(0, 0, fixture.complex, false, ChunkContext.PrefillPlan.NONE, null);
|
||||
try (MockedStatic<IrisPlatforms> platforms = mockStatic(IrisPlatforms.class);
|
||||
IrisContext.Scope ignored = IrisContext.open(fixture.engine, 5L, context)) {
|
||||
platforms.when(IrisPlatforms::get).thenReturn(platform);
|
||||
fixture.provider.generate(fixture.mode, 0, 0, Hunk.newArrayHunk(16, 16, 16),
|
||||
Hunk.newArrayHunk(16, 16, 16), false, context);
|
||||
}
|
||||
assertEquals(Integer.valueOf(1), fixture.computations.get("0,0"));
|
||||
assertEquals(fixture.floatingIdentity, context.getFloatingBiomes().volumeAt(0, 0, 0));
|
||||
assertEquals(fixture.floatingIdentity, context.getFloatingBiomes().surfaceAt(0, 0));
|
||||
verify(fixture.router).recordFloatingBiomes(0, 0, context.getFloatingBiomes());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void columnRequestsAreDeterministicAcrossOrderAndNegativeCoordinates() {
|
||||
Fixture first = new Fixture();
|
||||
@@ -352,6 +387,7 @@ public final class ResolvedTerrainProviderTest {
|
||||
private final ResolvedTerrainProvider provider;
|
||||
private long lastSessionId;
|
||||
private EngineStage terrainOverride;
|
||||
private FloatingBiomeOverlay.Identity floatingIdentity;
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Fixture() {
|
||||
@@ -411,6 +447,10 @@ public final class ResolvedTerrainProviderTest {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (floatingIdentity != null) {
|
||||
context.floatingBiomes(16).record(0, 0, 0, floatingIdentity);
|
||||
context.floatingBiomes(16).record(0, 2, 0, floatingIdentity);
|
||||
}
|
||||
}
|
||||
|
||||
private static PlatformBlockState block(String key, boolean air) {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package art.arcane.iris.engine;
|
||||
|
||||
import art.arcane.iris.core.IrisSettings;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.engine.MarkerSpawnScanner.PreparedMarkerSpawn;
|
||||
import art.arcane.iris.engine.framework.BiomeEnvironment;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.history.SavedBiomeUnavailableException;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisEntitySpawn;
|
||||
import art.arcane.iris.engine.object.IrisPosition;
|
||||
import art.arcane.iris.engine.object.IrisRegion;
|
||||
import art.arcane.iris.engine.object.IrisSpawner;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.collection.KSet;
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
import org.bukkit.Chunk;
|
||||
import org.bukkit.World;
|
||||
import org.junit.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.List;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class WorldEntitySpawnerBiomeTest {
|
||||
@Test
|
||||
public void loadingSurfaceDefinitionsDoNotConsumeInitialSpawnFlag() throws Exception {
|
||||
Engine engine = mock(Engine.class);
|
||||
IrisWorldManager manager = manager(engine);
|
||||
WorldChunkMaintenance maintenance = manager.chunkMaintenance;
|
||||
World world = mock(World.class);
|
||||
Chunk chunk = mock(Chunk.class);
|
||||
when(chunk.getWorld()).thenReturn(world);
|
||||
when(chunk.getX()).thenReturn(2);
|
||||
when(chunk.getZ()).thenReturn(-1);
|
||||
BiomeEnvironment environment = environment();
|
||||
when(engine.getSurfaceBiomeEnvironment(40, -8))
|
||||
.thenThrow(new SavedBiomeUnavailableException("Loading", true))
|
||||
.thenReturn(environment);
|
||||
IrisSettings settings = new IrisSettings();
|
||||
settings.getWorld().setAmbientEntitySpawningSystem(true);
|
||||
|
||||
try (MockedStatic<IrisSettings> configured = mockStatic(IrisSettings.class)) {
|
||||
configured.when(IrisSettings::get).thenReturn(settings);
|
||||
WorldEntitySpawner spawner = new WorldEntitySpawner(manager);
|
||||
spawner.prepareInitialSpawn(chunk, List.of());
|
||||
verify(maintenance, never()).raiseInitialSpawnMarkerFlag(any(), anyInt(), anyInt(), any());
|
||||
spawner.prepareInitialSpawn(chunk, List.of());
|
||||
verify(maintenance).raiseInitialSpawnMarkerFlag(eq(world), eq(2), eq(-1), any());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void preparedInitialMarkersKeepDefinitionsAfterSavedQueryEviction() throws Exception {
|
||||
Engine engine = mock(Engine.class);
|
||||
IrisWorldManager manager = manager(engine);
|
||||
World world = mock(World.class);
|
||||
Chunk chunk = mock(Chunk.class);
|
||||
when(chunk.getWorld()).thenReturn(world);
|
||||
BiomeEnvironment environment = environment();
|
||||
BiomeEnvironment.Scope scope = mock(BiomeEnvironment.Scope.class);
|
||||
when(engine.openBiomeEnvironmentScope(environment)).thenReturn(scope);
|
||||
IrisSpawner definition = mock(IrisSpawner.class);
|
||||
IrisEntitySpawn entity = mock(IrisEntitySpawn.class);
|
||||
IrisPosition position = new IrisPosition(3, 90, 4);
|
||||
when(definition.getInitialSpawns()).thenReturn(new KList<>(entity));
|
||||
when(definition.canSpawn(engine, 0, 0)).thenReturn(true);
|
||||
when(entity.getRarity()).thenReturn(1);
|
||||
when(entity.getReferenceSpawner()).thenReturn(definition);
|
||||
when(entity.spawn(eq(engine), eq(position), any(RNG.class))).thenReturn(1);
|
||||
KSet<IrisSpawner> definitions = new KSet<>();
|
||||
definitions.add(definition);
|
||||
PreparedMarkerSpawn marker = new PreparedMarkerSpawn(position, definitions, environment);
|
||||
IrisSettings settings = new IrisSettings();
|
||||
settings.getWorld().setAmbientEntitySpawningSystem(false);
|
||||
|
||||
try (MockedStatic<IrisSettings> configured = mockStatic(IrisSettings.class)) {
|
||||
configured.when(IrisSettings::get).thenReturn(settings);
|
||||
new WorldEntitySpawner(manager).prepareInitialSpawn(chunk, List.of(marker));
|
||||
ArgumentCaptor<Runnable> callback = ArgumentCaptor.forClass(Runnable.class);
|
||||
verify(manager.chunkMaintenance).raiseInitialSpawnMarkerFlag(eq(world), eq(0), eq(0), callback.capture());
|
||||
doThrow(new SavedBiomeUnavailableException("Evicted", true)).when(engine)
|
||||
.getBiomeEnvironment(anyInt(), anyInt(), anyInt());
|
||||
callback.getValue().run();
|
||||
verify(entity).spawn(eq(engine), eq(position), any(RNG.class));
|
||||
verify(engine).openBiomeEnvironmentScope(environment);
|
||||
verify(scope).close();
|
||||
verify(engine, never()).getBiomeEnvironment(anyInt(), anyInt(), anyInt());
|
||||
}
|
||||
}
|
||||
|
||||
private static IrisWorldManager manager(Engine engine) throws Exception {
|
||||
IrisWorldManager manager = mock(IrisWorldManager.class);
|
||||
when(manager.getEngine()).thenReturn(engine);
|
||||
Field field = IrisWorldManager.class.getDeclaredField("chunkMaintenance");
|
||||
field.setAccessible(true);
|
||||
field.set(manager, mock(WorldChunkMaintenance.class));
|
||||
return manager;
|
||||
}
|
||||
|
||||
private static BiomeEnvironment environment() {
|
||||
return new BiomeEnvironment(3L, mock(IrisBiome.class), mock(IrisRegion.class),
|
||||
mock(IrisDimension.class), mock(IrisData.class));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,25 @@
|
||||
package art.arcane.iris.engine.framework;
|
||||
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.engine.history.SavedBiomeUnavailableException;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisRegion;
|
||||
import art.arcane.iris.engine.object.IrisWorld;
|
||||
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
@@ -13,4 +32,35 @@ public class EnginePlayerTest {
|
||||
assertFalse(EnginePlayer.needsSample(true, 55L, 82D));
|
||||
assertTrue(EnginePlayer.needsSample(true, 56L, 82D));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void loadingSavedBiomesRetryWithoutMovementAndKeepOneDefinitionContext() {
|
||||
Engine engine = mock(Engine.class);
|
||||
IrisWorld irisWorld = mock(IrisWorld.class);
|
||||
World world = mock(World.class);
|
||||
Player player = mock(Player.class);
|
||||
when(engine.getWorld()).thenReturn(irisWorld);
|
||||
when(irisWorld.minHeight()).thenReturn(-64);
|
||||
when(player.getLocation()).thenReturn(new Location(world, 19, 80, -3));
|
||||
BiomeEnvironment environment = new BiomeEnvironment(4L, mock(IrisBiome.class),
|
||||
mock(IrisRegion.class), mock(IrisDimension.class), mock(IrisData.class));
|
||||
when(engine.getBiomeEnvironment(19, 144, -3))
|
||||
.thenThrow(new SavedBiomeUnavailableException("Loading", true))
|
||||
.thenReturn(environment);
|
||||
|
||||
try (MockedStatic<BukkitWorldBinding> binding = mockStatic(BukkitWorldBinding.class)) {
|
||||
binding.when(() -> BukkitWorldBinding.world(irisWorld)).thenReturn(world);
|
||||
EnginePlayer sampled = new EnginePlayer(engine, player);
|
||||
assertTrue(sampled.sample());
|
||||
assertNull(sampled.getLastLocation());
|
||||
assertNull(sampled.getEnvironment());
|
||||
assertFalse(sampled.sample());
|
||||
assertSame(environment, sampled.getEnvironment());
|
||||
assertSame(environment.biome(), sampled.getBiome());
|
||||
assertSame(environment.region(), sampled.getRegion());
|
||||
assertFalse(sampled.sample());
|
||||
verify(engine, times(2)).getBiomeEnvironment(19, 144, -3);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import art.arcane.iris.core.IrisDatapackCompiler;
|
||||
import art.arcane.iris.core.pack.AtomicDirectoryPublisher;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.nms.datapack.v1217.DataFixerV1217;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
@@ -47,7 +48,8 @@ public class ArchivedRegistryCompilerTest {
|
||||
String oldEpoch = history.activeEpoch().epochId();
|
||||
history.stageUpdate(secondPack, second.fingerprint(), second.dimension(), second.registry(), 256);
|
||||
history.promotePending(List.of());
|
||||
new GenerationPackRepository(world).releaseArchivedPacks(history.manifest());
|
||||
assertTrue(Files.isDirectory(history.paths().packRoot(oldEpoch)));
|
||||
AtomicDirectoryPublisher.deleteTree(history.paths().packRoot(oldEpoch));
|
||||
assertFalse(Files.exists(history.paths().packRoot(oldEpoch)));
|
||||
List<File> packs = List.of(history.activePackRoot().toFile());
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package art.arcane.iris.engine.history;
|
||||
import art.arcane.iris.core.nms.container.BlockPos;
|
||||
import art.arcane.iris.core.nms.container.Pair;
|
||||
import art.arcane.iris.engine.IrisEngine;
|
||||
import art.arcane.iris.core.pack.AtomicDirectoryPublisher;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
@@ -50,6 +51,8 @@ public final class ArchivedSemanticQueryTest {
|
||||
updated.stageUpdate(newPack, fingerprint(newPack), dimension, GenerationRegistryContract.empty(), 32);
|
||||
updated.prepareCurrentGenerator(32);
|
||||
GenerationHistory archived = GenerationHistory.open(world);
|
||||
assertTrue(Files.exists(archived.paths().packRoot(oldEpoch)));
|
||||
AtomicDirectoryPublisher.deleteTree(archived.paths().packRoot(oldEpoch));
|
||||
assertFalse(Files.exists(archived.paths().packRoot(oldEpoch)));
|
||||
IrisEngine engine = engine(archived);
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
|
||||
public class FloatingBiomeOverlayTest {
|
||||
@Test
|
||||
public void volumeFollowsTheQuartAnchorAndLastWriterWhileSurfaceUsesTheHighestWrite() {
|
||||
FloatingBiomeOverlay overlay = new FloatingBiomeOverlay(16);
|
||||
FloatingBiomeOverlay.Identity first = new FloatingBiomeOverlay.Identity("first", "region");
|
||||
FloatingBiomeOverlay.Identity second = new FloatingBiomeOverlay.Identity("second", "region");
|
||||
overlay.record(0, 8, 0, first);
|
||||
overlay.record(0, 9, 0, second);
|
||||
overlay.record(1, 8, 1, second);
|
||||
assertEquals(first, overlay.volumeAt(3, 11, 3));
|
||||
overlay.record(0, 8, 0, second);
|
||||
assertEquals(second, overlay.volumeAt(3, 11, 3));
|
||||
assertNull(overlay.volumeAt(4, 8, 0));
|
||||
overlay.retainHighestSurfaces((x, z) -> x == 0 ? 9 : 12);
|
||||
assertEquals(second, overlay.surfaceAt(0, 0));
|
||||
assertEquals(9, overlay.surfaceYAt(0, 0));
|
||||
assertNull(overlay.surfaceAt(1, 1));
|
||||
assertEquals(-1, overlay.surfaceYAt(1, 1));
|
||||
assertThrows(IllegalArgumentException.class, () -> overlay.record(16, 8, 0, first));
|
||||
assertThrows(IllegalArgumentException.class, () -> overlay.volumeAt(0, 16, 0));
|
||||
}
|
||||
}
|
||||
+28
-1
@@ -79,6 +79,33 @@ public class GenerationEpochContractFactoryTest {
|
||||
assertEquals(164, contract.internalFluidHeight());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void recordedEpochKeepsItsUpperTerrainFingerprintVersion() throws Exception {
|
||||
File pack = temporary.newFolder("recorded-upper-pack");
|
||||
Files.createDirectories(pack.toPath().resolve("objects"));
|
||||
Files.writeString(pack.toPath().resolve("objects/.DS_Store"), "recorded metadata");
|
||||
IrisData data = mock(IrisData.class);
|
||||
IrisDimension dimension = dimension(IrisEnvironment.NORMAL).setUpperDimension("overworld");
|
||||
dimension.setLoader(data);
|
||||
when(data.getDataFolder()).thenReturn(pack);
|
||||
GenerationEpoch.DimensionContract current = GenerationEpochContractFactory.create(
|
||||
dimension, "overworld", "iris:overworld");
|
||||
GenerationEpoch epoch = mock(GenerationEpoch.class);
|
||||
when(epoch.dimensionContract()).thenReturn(current);
|
||||
when(epoch.packFingerprintVersion()).thenReturn(1);
|
||||
|
||||
GenerationEpoch.DimensionContract recorded = GenerationEpochContractFactory.createForEpoch(
|
||||
dimension, "iris:overworld", epoch);
|
||||
|
||||
assertEquals(GenerationPackFingerprint.compute(pack.toPath(), 1), recorded.upperTerrainPackFingerprint());
|
||||
assertNotEquals(current.upperTerrainPackFingerprint(), recorded.upperTerrainPackFingerprint());
|
||||
when(epoch.dimensionContract()).thenReturn(recorded);
|
||||
assertEquals(recorded, GenerationEpochContractFactory.createForEpoch(dimension, "iris:overworld", epoch));
|
||||
Files.writeString(pack.toPath().resolve("objects/.DS_Store"), "changed metadata");
|
||||
assertNotEquals(recorded, GenerationEpochContractFactory.createForEpoch(dimension, "iris:overworld", epoch));
|
||||
assertEquals(current, GenerationEpochContractFactory.create(dimension, "overworld", "iris:overworld"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void upperTerrainMustBeLocalAndPackIdentityDoesNotChangeItsLayout() throws Exception {
|
||||
File pack = temporary.newFolder("upper-pack");
|
||||
@@ -127,7 +154,7 @@ public class GenerationEpochContractFactoryTest {
|
||||
dimension,
|
||||
"overworld",
|
||||
"iris:overworld"
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+23
-2
@@ -1,7 +1,10 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import art.arcane.iris.core.pack.AtomicDirectoryPublisher;
|
||||
import art.arcane.iris.engine.IrisEngine;
|
||||
import art.arcane.iris.engine.IrisComplex;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisRegion;
|
||||
import art.arcane.iris.engine.framework.GenerationSessionManager;
|
||||
import art.arcane.iris.engine.mantle.EngineMantle;
|
||||
import art.arcane.iris.util.common.data.B;
|
||||
@@ -40,6 +43,7 @@ import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
@@ -177,10 +181,14 @@ public final class GenerationHistoryRuntimeRouterTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void historicalRoutesWorkAfterArchivedPacksAreReleased() throws Exception {
|
||||
public void historicalTerrainRoutesWorkWhenFrozenPackFixturesAreMissing() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("router-archived-world").toPath();
|
||||
GenerationHistory history = createThreeActivationHistory(world, "router-archived");
|
||||
new GenerationPackRepository(world).releaseArchivedPacks(history.manifest());
|
||||
for (long activationId : List.of(1L, 2L)) {
|
||||
Path archivedPack = history.paths().packRoot(history.manifest().activation(activationId).orElseThrow().epochId());
|
||||
assertTrue(Files.isDirectory(archivedPack));
|
||||
AtomicDirectoryPublisher.deleteTree(archivedPack);
|
||||
}
|
||||
assertFalse(Files.exists(history.paths().packRoot(history.manifest().activation(1L).orElseThrow().epochId())));
|
||||
IrisEngine engine = mock(IrisEngine.class);
|
||||
FakeRuntimeFactory runtimes = new FakeRuntimeFactory();
|
||||
@@ -568,6 +576,18 @@ public final class GenerationHistoryRuntimeRouterTest {
|
||||
GenerationHistory history = createHistory(world, pack);
|
||||
IrisEngine engine = mock(IrisEngine.class);
|
||||
IrisComplex complex = mock(IrisComplex.class);
|
||||
IrisBiome biome = new IrisBiome();
|
||||
biome.setLoadKey("alpha");
|
||||
IrisRegion region = new IrisRegion();
|
||||
region.setLoadKey("alpha");
|
||||
when(engine.getMinHeight()).thenReturn(-64);
|
||||
when(engine.getHeight()).thenReturn(384);
|
||||
when(engine.getSurfaceBiome(anyInt(), anyInt())).thenReturn(biome);
|
||||
when(engine.getCaveBiome(anyInt(), anyInt())).thenReturn(biome);
|
||||
when(engine.getBiome(anyInt(), anyInt(), anyInt())).thenReturn(biome);
|
||||
when(engine.getBiomeOrMantle(anyInt(), anyInt(), anyInt())).thenReturn(biome);
|
||||
when(engine.getRegion(anyInt(), anyInt())).thenReturn(region);
|
||||
when(engine.getRegion(anyInt(), anyInt(), anyInt())).thenReturn(region);
|
||||
EngineMantle engineMantle = mock(EngineMantle.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
Mantle<Matter> mantle = mock(Mantle.class);
|
||||
@@ -596,6 +616,7 @@ public final class GenerationHistoryRuntimeRouterTest {
|
||||
ChunkGenerationSemantics semantics = history.semantics(4, -3).orElseThrow();
|
||||
assertTrue(semantics.sealed());
|
||||
assertEquals(1L, semantics.activationId());
|
||||
assertEquals("alpha", history.savedBiomes().get(4, -3).orElseThrow().biomeAt(0, 0, 0).biomeKey());
|
||||
router.close();
|
||||
}
|
||||
|
||||
|
||||
@@ -669,7 +669,7 @@ public final class GenerationHistoryTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void coldRecoveryKeepsStoredClaimsAndHistoricalMetadataButReleasesOldPacks() throws Exception {
|
||||
public void coldRecoveryKeepsStoredClaimsHistoricalMetadataAndFrozenPacks() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("cold-recovery-world").toPath();
|
||||
Path packA = createPack("cold-recovery-a", "alpha");
|
||||
Path packB = createPack("cold-recovery-b", "beta");
|
||||
@@ -695,7 +695,9 @@ public final class GenerationHistoryTest {
|
||||
assertTrue(recovered.semantics(0, 0).isPresent());
|
||||
assertTrue(recovered.semantics(1, 0).isPresent());
|
||||
assertTrue(recovered.semantics(2, 0).isEmpty());
|
||||
assertFalse(Files.exists(recovered.paths().packRoot(oldEpoch)));
|
||||
assertTrue(Files.isDirectory(recovered.paths().packRoot(oldEpoch)));
|
||||
assertEquals(GenerationPackFingerprint.compute(packA, GenerationPackFingerprint.CURRENT_VERSION),
|
||||
GenerationPackFingerprint.compute(recovered.paths().packRoot(oldEpoch), GenerationPackFingerprint.CURRENT_VERSION));
|
||||
assertTrue(Files.isDirectory(recovered.activePackRoot()));
|
||||
assertTrue(Files.isDirectory(packA));
|
||||
GenerationHistory reopened = GenerationHistory.open(world);
|
||||
|
||||
+38
-2
@@ -30,10 +30,46 @@ public class GenerationPackFingerprintTest {
|
||||
|
||||
String fingerprint = GenerationPackFingerprint.compute(
|
||||
pack,
|
||||
GenerationPackFingerprint.CURRENT_VERSION
|
||||
1
|
||||
);
|
||||
|
||||
assertEquals("ad7d39c2b95bd3001248778911ce6bbd6b4cbdcece28145838c2b923a0bf73b3", fingerprint);
|
||||
Files.writeString(pack.resolve("objects/.DS_Store"), "finder");
|
||||
assertEquals("b36c630e300a9939fabe3fbd02f546f2a76b31d2ebccbf9106a13fb7d8b8eb98",
|
||||
GenerationPackFingerprint.compute(pack, 1));
|
||||
assertEquals(fingerprint, GenerationPackFingerprint.compute(pack, 2));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void versionTwoIgnoresFinderMetadataAtEveryDepth() throws Exception {
|
||||
Path source = temporaryFolder.newFolder("metadata-source").toPath();
|
||||
Files.createDirectories(source.resolve("objects/trees"));
|
||||
Files.writeString(source.resolve("objects/trees/oak.json"), "{}");
|
||||
Files.writeString(source.resolve("objects/.DS_Store"), "source metadata");
|
||||
Path target = temporaryFolder.getRoot().toPath().resolve("metadata-copy");
|
||||
GenerationPackRepository.copyPackTree(source, target);
|
||||
String expected = GenerationPackFingerprint.compute(source, 2);
|
||||
|
||||
Files.writeString(target.resolve(".DS_Store"), "root metadata");
|
||||
Files.writeString(target.resolve("objects/.DS_Store"), "destination metadata");
|
||||
Files.writeString(target.resolve("objects/trees/.DS_Store"), "nested metadata");
|
||||
|
||||
assertEquals(expected, GenerationPackFingerprint.compute(target, 2));
|
||||
assertNotEquals(GenerationPackFingerprint.compute(source, 1),
|
||||
GenerationPackFingerprint.compute(target, 1));
|
||||
Files.writeString(target.resolve("objects/trees/oak.json"), "{\"changed\":true}");
|
||||
assertNotEquals(expected, GenerationPackFingerprint.compute(target, 2));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void versionTwoStillIncludesOtherNestedHiddenResources() throws Exception {
|
||||
Path pack = temporaryFolder.newFolder("nested-hidden-pack").toPath();
|
||||
Files.createDirectories(pack.resolve("objects"));
|
||||
Files.writeString(pack.resolve("objects/.hidden"), "first");
|
||||
String first = GenerationPackFingerprint.compute(pack, 2);
|
||||
Files.writeString(pack.resolve("objects/.hidden"), "second");
|
||||
|
||||
assertNotEquals(first, GenerationPackFingerprint.compute(pack, 2));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -51,7 +87,7 @@ public class GenerationPackFingerprintTest {
|
||||
@Test
|
||||
public void unsupportedVersionsAndSymbolicLinksFailClosed() throws Exception {
|
||||
Path pack = temporaryFolder.newFolder("unsafe-pack").toPath();
|
||||
assertThrows(IOException.class, () -> GenerationPackFingerprint.compute(pack, 2));
|
||||
assertThrows(IOException.class, () -> GenerationPackFingerprint.compute(pack, 3));
|
||||
|
||||
Path target = temporaryFolder.newFile("outside.json").toPath();
|
||||
try {
|
||||
|
||||
+54
-8
@@ -4,6 +4,7 @@ import org.junit.Assume;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
@@ -12,11 +13,13 @@ import java.util.concurrent.CyclicBarrier;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
|
||||
public class GenerationPackRepositoryTest {
|
||||
@Rule
|
||||
@@ -69,6 +72,50 @@ public class GenerationPackRepositoryTest {
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void finderMetadataCreatedDuringPublicationDoesNotChangeVersionTwoIdentity() throws Exception {
|
||||
Path dimensionRoot = temporaryFolder.newFolder("finder-world").toPath();
|
||||
Path source = createPack("finder-source", "{}");
|
||||
String fingerprint = fingerprint(source);
|
||||
GenerationPackRepository repository = new GenerationPackRepository(dimensionRoot);
|
||||
String epochId = digest('b');
|
||||
AtomicBoolean injected = new AtomicBoolean();
|
||||
|
||||
try (MockedStatic<GenerationPackFingerprint> ignored = mockStatic(GenerationPackFingerprint.class, invocation -> {
|
||||
if (invocation.getMethod().getName().equals("compute")) {
|
||||
Path pack = invocation.getArgument(0);
|
||||
if (pack.getFileName().toString().startsWith(".pack-")) {
|
||||
Files.writeString(pack.resolve(".DS_Store"), "root metadata");
|
||||
Files.writeString(pack.resolve("dimensions/.DS_Store"), "nested metadata");
|
||||
injected.set(true);
|
||||
}
|
||||
}
|
||||
return invocation.callRealMethod();
|
||||
})) {
|
||||
Path published = repository.publish(epochId, fingerprint, 2, source);
|
||||
assertTrue(injected.get());
|
||||
assertTrue(Files.isRegularFile(published.resolve("dimensions/.DS_Store")));
|
||||
assertEquals(published, repository.requireExactPack(epochId, fingerprint, 2));
|
||||
Files.writeString(published.resolve("dimensions/main.json"), "{\"changed\":true}");
|
||||
assertThrows(IOException.class, () -> repository.requireExactPack(epochId, fingerprint, 2));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void versionOnePublicationsKeepTheirOriginalMetadataContract() throws Exception {
|
||||
Path dimensionRoot = temporaryFolder.newFolder("version-one-world").toPath();
|
||||
Path source = createPack("version-one-source", "{}");
|
||||
Files.writeString(source.resolve("dimensions/.DS_Store"), "original metadata");
|
||||
String fingerprint = GenerationPackFingerprint.compute(source, 1);
|
||||
GenerationPackRepository repository = new GenerationPackRepository(dimensionRoot);
|
||||
String epochId = digest('a');
|
||||
|
||||
Path published = repository.publish(epochId, fingerprint, 1, source);
|
||||
assertEquals(published, repository.requireExactPack(epochId, fingerprint, 1));
|
||||
Files.writeString(published.resolve("dimensions/.DS_Store"), "changed metadata");
|
||||
assertThrows(IOException.class, () -> repository.requireExactPack(epochId, fingerprint, 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsNestedSymbolicLinks() throws Exception {
|
||||
Path dimensionRoot = temporaryFolder.newFolder("linked-world").toPath();
|
||||
@@ -149,7 +196,7 @@ public class GenerationPackRepositoryTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void releasesOnlyArchivedPacksAndCanRepublishTheirSnapshot() throws Exception {
|
||||
public void publicationAndActivationRetainEveryFrozenPack() throws Exception {
|
||||
Path dimensionRoot = temporaryFolder.newFolder("retention-world").toPath();
|
||||
Path firstSource = createPack("retention-first", "{\"version\":1}");
|
||||
Path secondSource = createPack("retention-second", "{\"version\":2}");
|
||||
@@ -167,18 +214,17 @@ public class GenerationPackRepositoryTest {
|
||||
repository.publish(pending.epochId(), pending.packFingerprint(), pending.packFingerprintVersion(), pendingSource);
|
||||
store.preparePendingActivation(pending, 256);
|
||||
|
||||
repository.releaseArchivedPacks(store.manifest());
|
||||
repository.releaseArchivedPacks(store.manifest());
|
||||
|
||||
assertFalse(Files.exists(repository.packRoot(first.epochId())));
|
||||
assertTrue(Files.isDirectory(repository.packRoot(first.epochId())));
|
||||
assertEquals(first.packFingerprint(), fingerprint(repository.packRoot(first.epochId())));
|
||||
assertTrue(Files.isRegularFile(repository.epochRoot(first.epochId()).resolve("epoch.json")));
|
||||
assertTrue(Files.isDirectory(repository.packRoot(second.epochId())));
|
||||
assertTrue(Files.isDirectory(repository.packRoot(pending.epochId())));
|
||||
assertTrue(Files.isRegularFile(firstSource.resolve("dimensions/main.json")));
|
||||
assertEquals(store.manifest(), GenerationHistoryStore.open(repository.generationRoot()).manifest());
|
||||
Path republished = repository.publish(first.epochId(), first.packFingerprint(), first.packFingerprintVersion(), firstSource);
|
||||
assertEquals(Files.readString(firstSource.resolve("dimensions/main.json")),
|
||||
Files.readString(republished.resolve("dimensions/main.json")));
|
||||
Files.writeString(firstSource.resolve("dimensions/main.json"), "{\"version\":99}");
|
||||
assertEquals("{\"version\":1}", Files.readString(repository.packRoot(first.epochId()).resolve("dimensions/main.json")));
|
||||
assertEquals(repository.packRoot(first.epochId()), repository.requireExactPack(
|
||||
first.epochId(), first.packFingerprint(), first.packFingerprintVersion()));
|
||||
}
|
||||
|
||||
private static GenerationEpoch epoch(String fingerprint) {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.engine.IrisEngine;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisBiomeCustom;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.util.common.data.DataProvider;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class NativeBiomeSpawnSelectionTest {
|
||||
@Test
|
||||
public void savedSelectionUsesTheActualPhysicalHolderAndOwningRuntime() {
|
||||
IrisEngine engine = mock(IrisEngine.class);
|
||||
GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class);
|
||||
SavedBiomeRuntime saved = mock(SavedBiomeRuntime.class);
|
||||
when(engine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router));
|
||||
when(router.biomes()).thenReturn(saved);
|
||||
NativeBiomeSpawnSelection retained = new NativeBiomeSpawnSelection(
|
||||
NativeBiomeSpawnSelection.Mode.RETAINED, "minecraft:plains");
|
||||
when(saved.nativeSpawnSelection(-17, 62, 31, "iris:old-physical")).thenReturn(retained);
|
||||
|
||||
assertEquals(retained, NativeBiomeSpawnSelection.at(engine, -17, 62, 31, "iris:old-physical"));
|
||||
verify(saved).nativeSpawnSelection(-17, 62, 31, "iris:old-physical");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void generationScopesUseCurrentPredictionWithoutReadingSavedBiomes() {
|
||||
IrisEngine engine = mock(IrisEngine.class);
|
||||
when(engine.hasGenerationRuntimeScope()).thenReturn(true);
|
||||
|
||||
assertEquals(NativeBiomeSpawnSelection.Mode.CURRENT,
|
||||
NativeBiomeSpawnSelection.at(engine, 0, 64, 0, "iris:new-physical").mode());
|
||||
verify(engine, never()).getGenerationHistoryRuntimeRouter();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void loadingSkipsTheAttemptAndUnsupportedNeverUsesCurrentInheritance() {
|
||||
IrisEngine engine = mock(IrisEngine.class);
|
||||
GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class);
|
||||
SavedBiomeRuntime saved = mock(SavedBiomeRuntime.class);
|
||||
when(engine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router));
|
||||
when(router.biomes()).thenReturn(saved);
|
||||
when(saved.nativeSpawnSelection(0, 64, 0, "iris:old"))
|
||||
.thenThrow(new SavedBiomeUnavailableException("loading", true))
|
||||
.thenThrow(new SavedBiomeUnavailableException("unsupported", false));
|
||||
|
||||
assertEquals(NativeBiomeSpawnSelection.Mode.LOADING,
|
||||
NativeBiomeSpawnSelection.at(engine, 0, 64, 0, "iris:old").mode());
|
||||
assertEquals(NativeBiomeSpawnSelection.Mode.NONE,
|
||||
NativeBiomeSpawnSelection.at(engine, 0, 64, 0, "iris:old").mode());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void retainedPhysicalMappingsAreIndependentOfLaterParentDerivativeChanges() {
|
||||
IrisData previous = retainedData("iris:same-physical", "minecraft:plains");
|
||||
IrisData current = retainedData("iris:same-physical", "minecraft:desert");
|
||||
Map<String, String> oldMappings = NativeBiomeSpawnSelection.retainedDerivatives(previous);
|
||||
Map<String, String> newMappings = NativeBiomeSpawnSelection.retainedDerivatives(current);
|
||||
|
||||
assertEquals("minecraft:plains", oldMappings.get("iris:same-physical"));
|
||||
assertEquals("minecraft:desert", newMappings.get("iris:same-physical"));
|
||||
assertEquals(null, oldMappings.get("iris:unrelated-physical"));
|
||||
assertTrue(oldMappings.keySet().contains("iris:same-physical"));
|
||||
}
|
||||
|
||||
private static IrisData retainedData(String physicalKey, String vanillaDerivative) {
|
||||
IrisData data = mock(IrisData.class, RETURNS_DEEP_STUBS);
|
||||
IrisDimension dimension = mock(IrisDimension.class);
|
||||
IrisBiome biome = mock(IrisBiome.class);
|
||||
IrisBiomeCustom custom = new IrisBiomeCustom();
|
||||
custom.setId("custom");
|
||||
when(data.getDimensionLoader().getPossibleKeys()).thenReturn(new String[]{"main"});
|
||||
when(data.getDimensionLoader().load("main")).thenReturn(dimension);
|
||||
when(dimension.getReachableBiomes(any(DataProvider.class))).thenReturn(new KList<IrisBiome>().qadd(biome));
|
||||
when(biome.isCustom()).thenReturn(true);
|
||||
when(biome.getCustomDerivitives()).thenReturn(new KList<IrisBiomeCustom>().qadd(custom));
|
||||
when(biome.getVanillaDerivativeKey()).thenReturn(vanillaDerivative);
|
||||
when(data.customBiomeResourceKey(dimension, custom)).thenReturn(physicalKey);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisRegion;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class SavedBiomeCaptureTest {
|
||||
@Test
|
||||
public void retainsFloatingChildIdentityWhenItSharesTheHostDerivative() throws Exception {
|
||||
Engine engine = mock(Engine.class, RETURNS_DEEP_STUBS);
|
||||
GenerationHistory.GenerationStage stage = mock(GenerationHistory.GenerationStage.class);
|
||||
SavedBiomeRuntime historical = mock(SavedBiomeRuntime.class);
|
||||
IrisBiome host = biome("host");
|
||||
IrisBiome child = biome("floating-child");
|
||||
host.setDerivative("minecraft:plains");
|
||||
child.setDerivative("minecraft:plains");
|
||||
IrisRegion region = region("host-region");
|
||||
when(stage.activation()).thenReturn(GenerationActivation.initial("a".repeat(64), 1L));
|
||||
when(engine.getMinHeight()).thenReturn(-64);
|
||||
when(engine.getHeight()).thenReturn(16);
|
||||
when(engine.getHeight(anyInt(), anyInt())).thenReturn(2);
|
||||
when(engine.getComplex().getTransitionGenerationPlan()).thenReturn(null);
|
||||
when(engine.getRegion(anyInt(), anyInt())).thenReturn(region);
|
||||
when(engine.getRegion(anyInt(), anyInt(), anyInt())).thenReturn(region);
|
||||
when(engine.getSurfaceBiome(anyInt(), anyInt())).thenReturn(host);
|
||||
when(engine.getCaveBiome(anyInt(), anyInt())).thenReturn(host);
|
||||
when(engine.getBiomeOrMantle(anyInt(), anyInt(), anyInt())).thenReturn(host);
|
||||
FloatingBiomeOverlay floating = new FloatingBiomeOverlay(16);
|
||||
FloatingBiomeOverlay.Identity childIdentity = new FloatingBiomeOverlay.Identity(child.getLoadKey(), region.getLoadKey());
|
||||
floating.record(0, 8, 0, childIdentity);
|
||||
floating.record(0, 9, 0, childIdentity);
|
||||
floating.record(4, 8, 0, childIdentity);
|
||||
floating.retainHighestSurfaces((x, z) -> x == 0 ? 9 : 12);
|
||||
|
||||
SavedBiomeChunk chunk = SavedBiomeCapture.capture(engine, stage, historical, floating);
|
||||
|
||||
assertEquals(host.getVanillaDerivativeKey(), child.getVanillaDerivativeKey());
|
||||
assertEquals("floating-child", chunk.surfaceAt(0, 0).biomeKey());
|
||||
assertEquals("host", chunk.surfaceAt(4, 0).biomeKey());
|
||||
assertEquals("floating-child", chunk.biomeAt(0, -56, 0).biomeKey());
|
||||
assertEquals("floating-child", chunk.biomeAt(3, -53, 3).biomeKey());
|
||||
assertEquals("host", chunk.biomeAt(0, -52, 0).biomeKey());
|
||||
assertEquals("host", chunk.caveBaseAt(0, 0).biomeKey());
|
||||
assertEquals(1L, chunk.biomeAt(0, -56, 0).activationId());
|
||||
verifyNoInteractions(historical);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void recordsExactSurfaceColumnsAndIndependentCaveAndVerticalIdentities() throws Exception {
|
||||
Engine engine = mock(Engine.class, RETURNS_DEEP_STUBS);
|
||||
GenerationHistory.GenerationStage stage = mock(GenerationHistory.GenerationStage.class);
|
||||
SavedBiomeRuntime historical = mock(SavedBiomeRuntime.class);
|
||||
IrisBiome left = biome("left");
|
||||
IrisBiome right = biome("right");
|
||||
IrisBiome cave = biome("base-cave");
|
||||
IrisBiome lower = biome("lower-volume");
|
||||
IrisBiome upper = biome("upper-volume");
|
||||
IrisRegion surfaceRegion = region("surface-region");
|
||||
IrisRegion caveRegion = region("volume-region");
|
||||
when(stage.chunkX()).thenReturn(-1);
|
||||
when(stage.chunkZ()).thenReturn(2);
|
||||
when(stage.activation()).thenReturn(GenerationActivation.initial("a".repeat(64), 1L));
|
||||
when(engine.getMinHeight()).thenReturn(-64);
|
||||
when(engine.getHeight()).thenReturn(16);
|
||||
when(engine.getComplex().getTransitionGenerationPlan()).thenReturn(null);
|
||||
when(engine.getRegion(anyInt(), anyInt())).thenReturn(surfaceRegion);
|
||||
when(engine.getRegion(anyInt(), anyInt(), anyInt())).thenReturn(caveRegion);
|
||||
when(engine.getSurfaceBiome(anyInt(), anyInt())).thenAnswer(call -> (int) call.getArgument(0) == -15 ? right : left);
|
||||
when(engine.getCaveBiome(anyInt(), anyInt())).thenReturn(cave);
|
||||
when(engine.getBiomeOrMantle(anyInt(), anyInt(), anyInt())).thenAnswer(call -> (int) call.getArgument(1) < 8 ? lower : upper);
|
||||
|
||||
SavedBiomeChunk chunk = SavedBiomeCapture.capture(engine, stage, historical, null);
|
||||
|
||||
assertEquals(new SavedBiomeChunk.Header(-1, 2, 1L, -64, 16), chunk.header());
|
||||
assertEquals("left", chunk.surfaceAt(0, 0).biomeKey());
|
||||
assertEquals("right", chunk.surfaceAt(1, 0).biomeKey());
|
||||
assertEquals("surface-region", chunk.surfaceAt(1, 0).regionKey());
|
||||
assertEquals("base-cave", chunk.caveBaseAt(1, 0).biomeKey());
|
||||
assertEquals("lower-volume", chunk.biomeAt(1, -57, 0).biomeKey());
|
||||
assertEquals("upper-volume", chunk.biomeAt(1, -56, 0).biomeKey());
|
||||
assertEquals("volume-region", chunk.biomeAt(1, -56, 0).regionKey());
|
||||
assertEquals(2, chunk.column(0, 0).vertical().size());
|
||||
assertEquals(chunk.column(0, 0).vertical(), chunk.column(3, 3).vertical());
|
||||
verifyNoInteractions(historical);
|
||||
}
|
||||
|
||||
private static IrisBiome biome(String key) {
|
||||
IrisBiome biome = new IrisBiome();
|
||||
biome.setLoadKey(key);
|
||||
return biome;
|
||||
}
|
||||
|
||||
private static IrisRegion region(String key) {
|
||||
IrisRegion region = new IrisRegion();
|
||||
region.setLoadKey(key);
|
||||
return region;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisDimensionCarvingEntry;
|
||||
import art.arcane.iris.engine.object.IrisDimensionStack;
|
||||
import art.arcane.iris.engine.object.IrisFloatingChildBiomes;
|
||||
import art.arcane.iris.engine.object.IrisRegion;
|
||||
import art.arcane.iris.engine.object.IrisRiverPolicy;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class SavedBiomeRecoveryTest {
|
||||
private static final String BIOME = "transition-demo";
|
||||
private static final String REGION = "transition-demo";
|
||||
private static final int CHUNK_X = -2;
|
||||
private static final int CHUNK_Z = 3;
|
||||
|
||||
private final GenerationHistory history = mock(GenerationHistory.class);
|
||||
private final IrisData data = mock(IrisData.class, RETURNS_DEEP_STUBS);
|
||||
private final IrisDimension dimension = new IrisDimension();
|
||||
private final IrisRegion region = new IrisRegion();
|
||||
private final IrisBiome biome = new IrisBiome();
|
||||
private final SavedBiomeRecovery.Input input =
|
||||
new SavedBiomeRecovery.Input(history, CHUNK_X, CHUNK_Z, data, dimension);
|
||||
|
||||
@Before
|
||||
public void prepareSingletonPack() {
|
||||
dimension.setLoadKey("overworld");
|
||||
dimension.setRegions(new KList<String>().qadd(REGION));
|
||||
region.setLoadKey(REGION);
|
||||
region.setLandBiomes(new KList<String>().qadd(BIOME));
|
||||
region.setSeaBiomes(new KList<String>().qadd(BIOME));
|
||||
region.setShoreBiomes(new KList<String>().qadd(BIOME));
|
||||
region.setCaveBiomes(new KList<String>().qadd(BIOME));
|
||||
biome.setLoadKey(BIOME);
|
||||
when(data.getRegionLoader().load(REGION)).thenReturn(region);
|
||||
when(data.getBiomeLoader().load(BIOME)).thenReturn(biome);
|
||||
when(history.resolveActivation(CHUNK_X, CHUNK_Z))
|
||||
.thenReturn(GenerationActivation.initial("a".repeat(64), 1L));
|
||||
GenerationEpoch epoch = mock(GenerationEpoch.class);
|
||||
GenerationEpoch.DimensionContract contract = mock(GenerationEpoch.DimensionContract.class);
|
||||
when(contract.dimensionKey()).thenReturn("overworld");
|
||||
when(contract.minHeight()).thenReturn(-64);
|
||||
when(contract.maxHeight()).thenReturn(320);
|
||||
when(contract.height()).thenReturn(384);
|
||||
when(epoch.dimensionContract()).thenReturn(contract);
|
||||
when(history.resolveEpoch(CHUNK_X, CHUNK_Z)).thenReturn(epoch);
|
||||
record(semantics(1L).seal().build());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void recoversAllColumnsAndEntireHeightFromTheOwningActivation() throws IOException {
|
||||
SavedBiomeChunk recovered = SavedBiomeRecovery.recover(input).orElseThrow();
|
||||
SavedBiomeChunk.Cell expected = new SavedBiomeChunk.Cell(1L, BIOME, REGION);
|
||||
assertEquals(new SavedBiomeChunk.Header(CHUNK_X, CHUNK_Z, 1L, -64, 384), recovered.header());
|
||||
for (int z = 0; z < 16; z++) {
|
||||
for (int x = 0; x < 16; x++) {
|
||||
assertEquals(expected, recovered.surfaceAt(x, z));
|
||||
assertEquals(expected, recovered.caveBaseAt(x, z));
|
||||
assertEquals(expected, recovered.biomeAt(x, -64, z));
|
||||
assertEquals(expected, recovered.biomeAt(x, 319, z));
|
||||
assertEquals(1, recovered.column(x, z).vertical().size());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsMissingOrUnsealedRecordedFacts() throws IOException {
|
||||
when(history.semantics(CHUNK_X, CHUNK_Z)).thenReturn(Optional.empty());
|
||||
assertTrue(SavedBiomeRecovery.recover(input).isEmpty());
|
||||
record(semantics(1L).build());
|
||||
assertTrue(SavedBiomeRecovery.recover(input).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsMultipleSurfaceOrRegionIdentities() throws IOException {
|
||||
record(semantics(1L).addSurfaceBiome("other").seal().build());
|
||||
assertTrue(SavedBiomeRecovery.recover(input).isEmpty());
|
||||
record(semantics(1L).addRegion("other").seal().build());
|
||||
assertTrue(SavedBiomeRecovery.recover(input).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsInconsistentOwnershipAndDifferentCaveIdentity() throws IOException {
|
||||
record(semantics(2L).seal().build());
|
||||
assertTrue(SavedBiomeRecovery.recover(input).isEmpty());
|
||||
record(semantics(1L).addCaveBiome("other").seal().build());
|
||||
assertTrue(SavedBiomeRecovery.recover(input).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void acceptsRecordedCavesWithTheSameIdentity() throws IOException {
|
||||
record(semantics(1L).addCaveBiome(BIOME).seal().build());
|
||||
assertTrue(SavedBiomeRecovery.recover(input).isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsMissingDefinitions() throws IOException {
|
||||
when(data.getBiomeLoader().load(BIOME)).thenReturn(null);
|
||||
assertTrue(SavedBiomeRecovery.recover(input).isEmpty());
|
||||
when(data.getBiomeLoader().load(BIOME)).thenReturn(biome);
|
||||
when(data.getRegionLoader().load(REGION)).thenReturn(null);
|
||||
assertTrue(SavedBiomeRecovery.recover(input).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsPoolsWithUnrecordedChoicesOrNoVerticalChoice() throws IOException {
|
||||
region.getCaveBiomes().add("other");
|
||||
assertTrue(SavedBiomeRecovery.recover(input).isEmpty());
|
||||
region.getCaveBiomes().clear();
|
||||
assertTrue(SavedBiomeRecovery.recover(input).isEmpty());
|
||||
region.getCaveBiomes().add(BIOME);
|
||||
region.getSeaBiomes().add("other");
|
||||
assertTrue(SavedBiomeRecovery.recover(input).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsChildrenAndFloatingBiomes() throws IOException {
|
||||
biome.getChildren().add("other");
|
||||
assertTrue(SavedBiomeRecovery.recover(input).isEmpty());
|
||||
biome.getChildren().clear();
|
||||
biome.getFloatingChildBiomes().add(new IrisFloatingChildBiomes());
|
||||
assertTrue(SavedBiomeRecovery.recover(input).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsStackedUpperAndCarvingOverrides() throws IOException {
|
||||
dimension.setDimensionStack(new IrisDimensionStack());
|
||||
assertTrue(SavedBiomeRecovery.recover(input).isEmpty());
|
||||
dimension.setDimensionStack(null);
|
||||
dimension.setUpperDimension("upper");
|
||||
assertTrue(SavedBiomeRecovery.recover(input).isEmpty());
|
||||
dimension.setUpperDimension("none");
|
||||
dimension.getCarving().add(new IrisDimensionCarvingEntry());
|
||||
assertTrue(SavedBiomeRecovery.recover(input).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsDifferingRiverOverridesAtEachScope() throws IOException {
|
||||
IrisRiverPolicy policy = new IrisRiverPolicy().setFloodedCaveBiomes(new KList<String>().qadd("other"));
|
||||
dimension.setRiverPolicy(policy);
|
||||
assertTrue(SavedBiomeRecovery.recover(input).isEmpty());
|
||||
dimension.setRiverPolicy(null);
|
||||
region.setRiverPolicy(policy);
|
||||
assertTrue(SavedBiomeRecovery.recover(input).isEmpty());
|
||||
region.setRiverPolicy(null);
|
||||
biome.setRiverPolicy(policy);
|
||||
assertTrue(SavedBiomeRecovery.recover(input).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsDifferentDimensionAndFocusSelectors() throws IOException {
|
||||
dimension.setLoadKey("other");
|
||||
assertTrue(SavedBiomeRecovery.recover(input).isEmpty());
|
||||
dimension.setLoadKey("overworld");
|
||||
dimension.setFocus("other");
|
||||
assertTrue(SavedBiomeRecovery.recover(input).isEmpty());
|
||||
dimension.setFocus("");
|
||||
dimension.getRegions().add("other");
|
||||
assertTrue(SavedBiomeRecovery.recover(input).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsOneBlendedColumnEvenWhenAllOtherColumnsAreNew() throws IOException {
|
||||
TransitionGenerationPlan transition = nextActivation();
|
||||
when(transition.newEpochWeightAt(-17, 63)).thenReturn(0.999D);
|
||||
assertTrue(SavedBiomeRecovery.recover(input).isEmpty());
|
||||
verify(transition).newEpochWeightAt(-17, 63);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void retainsLaterActivationIdentityForUnblendedChunks() throws IOException {
|
||||
nextActivation();
|
||||
SavedBiomeChunk recovered = SavedBiomeRecovery.recover(input).orElseThrow();
|
||||
assertEquals(2L, recovered.activationId());
|
||||
assertEquals(2L, recovered.biomeAt(15, 319, 15).activationId());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void propagatesUnreadableTransitionEvidence() throws IOException {
|
||||
nextActivation();
|
||||
when(history.transitionPlan(2L)).thenThrow(new IOException("Unreadable frozen transition"));
|
||||
assertThrows(IOException.class, () -> SavedBiomeRecovery.recover(input));
|
||||
}
|
||||
|
||||
private TransitionGenerationPlan nextActivation() throws IOException {
|
||||
when(history.resolveActivation(CHUNK_X, CHUNK_Z))
|
||||
.thenReturn(GenerationActivation.next(2L, "b".repeat(64), 1L, 2L, 64));
|
||||
record(semantics(2L).seal().build());
|
||||
TransitionGenerationPlan transition = mock(TransitionGenerationPlan.class);
|
||||
when(transition.newEpochWeightAt(anyInt(), anyInt())).thenReturn(1D);
|
||||
when(history.transitionPlan(2L)).thenReturn(transition);
|
||||
return transition;
|
||||
}
|
||||
|
||||
private void record(ChunkGenerationSemantics semantics) {
|
||||
when(history.semantics(CHUNK_X, CHUNK_Z)).thenReturn(Optional.of(semantics));
|
||||
}
|
||||
|
||||
private static ChunkGenerationSemantics.Builder semantics(long activationId) {
|
||||
return ChunkGenerationSemantics.builder(CHUNK_X, CHUNK_Z, activationId)
|
||||
.addSurfaceBiome(BIOME).addRegion(REGION);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import art.arcane.iris.core.IrisSettings;
|
||||
import art.arcane.iris.engine.IrisEngine;
|
||||
import art.arcane.iris.engine.framework.BiomeEnvironment;
|
||||
import art.arcane.iris.engine.framework.PreservationRegistry;
|
||||
import art.arcane.iris.spi.IrisServices;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
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.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class SavedBiomeRuntimeTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
private final IrisEngine engine = mock(IrisEngine.class);
|
||||
private final GenerationHistory history = mock(GenerationHistory.class);
|
||||
private final SavedBiomeStore store = mock(SavedBiomeStore.class);
|
||||
private IrisSettings previousSettings;
|
||||
|
||||
@Before
|
||||
public void prepareServices() throws Exception {
|
||||
previousSettings = IrisSettings.settings;
|
||||
IrisSettings.settings = new IrisSettings();
|
||||
IrisServices.register(PreservationRegistry.class, mock(PreservationRegistry.class));
|
||||
when(history.savedBiomes()).thenReturn(store);
|
||||
when(history.paths()).thenReturn(GenerationHistoryPaths.forDimension(temporaryFolder.newFolder().toPath()));
|
||||
String epochId = "a".repeat(64);
|
||||
GenerationManifest manifest = mock(GenerationManifest.class);
|
||||
GenerationEpoch epoch = mock(GenerationEpoch.class);
|
||||
when(history.manifest()).thenReturn(manifest);
|
||||
when(manifest.activation(1L)).thenReturn(Optional.of(GenerationActivation.initial(epochId, 1L)));
|
||||
when(manifest.activation(2L)).thenReturn(Optional.of(GenerationActivation.next(2L, epochId, 1L, 2L, 64)));
|
||||
when(manifest.epoch(epochId)).thenReturn(Optional.of(epoch));
|
||||
when(epoch.epochId()).thenReturn(epochId);
|
||||
}
|
||||
|
||||
@After
|
||||
public void restoreServices() {
|
||||
IrisServices.clear();
|
||||
IrisSettings.settings = previousSettings;
|
||||
}
|
||||
|
||||
@Test
|
||||
public void everyCallerReceivesLoadingWithoutWaitingForDisk() throws Exception {
|
||||
CountDownLatch entered = new CountDownLatch(1);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
when(store.get(0, 0)).thenAnswer(invocation -> {
|
||||
entered.countDown();
|
||||
assertTrue(release.await(5L, TimeUnit.SECONDS));
|
||||
return Optional.of(unresolvedChunk(1));
|
||||
});
|
||||
SavedBiomeRuntime runtime = new SavedBiomeRuntime(engine, history);
|
||||
try (ExecutorService caller = Executors.newSingleThreadExecutor()) {
|
||||
Future<SavedBiomeUnavailableException> response = caller.submit(() -> assertThrows(
|
||||
SavedBiomeUnavailableException.class, () -> runtime.resolve(0, 0, 0, true)));
|
||||
assertTrue(entered.await(5L, TimeUnit.SECONDS));
|
||||
assertTrue(response.get(1L, TimeUnit.SECONDS).isLoading());
|
||||
} finally {
|
||||
release.countDown();
|
||||
runtime.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void chunkReadinessDoesNotRequireEveryCellToHaveAResolvedIdentity() throws Exception {
|
||||
when(store.get(0, 0)).thenReturn(Optional.of(unresolvedChunk(1)));
|
||||
try (SavedBiomeRuntime runtime = new SavedBiomeRuntime(engine, history)) {
|
||||
assertTrue(assertThrows(SavedBiomeUnavailableException.class,
|
||||
() -> runtime.prepareChunk(0, 0)).isLoading());
|
||||
awaitIdle(runtime);
|
||||
runtime.prepareChunk(0, 0);
|
||||
SavedBiomeUnavailableException unavailable = assertThrows(SavedBiomeUnavailableException.class,
|
||||
() -> runtime.resolve(0, 0, 0, true));
|
||||
assertEquals(false, unavailable.isLoading());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void closeWaitsForInvalidatedWorkersAndRestoresInterruptStatus() throws Exception {
|
||||
CountDownLatch entered = new CountDownLatch(1);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
SavedBiomeChunk chunk = unresolvedChunk(1);
|
||||
when(store.get(0, 0)).thenAnswer(invocation -> {
|
||||
entered.countDown();
|
||||
assertTrue(release.await(5L, TimeUnit.SECONDS));
|
||||
return Optional.of(chunk);
|
||||
});
|
||||
SavedBiomeRuntime runtime = new SavedBiomeRuntime(engine, history);
|
||||
assertTrue(assertThrows(SavedBiomeUnavailableException.class,
|
||||
() -> runtime.resolve(0, 0, 0, true)).isLoading());
|
||||
assertTrue(entered.await(5L, TimeUnit.SECONDS));
|
||||
runtime.capture(chunk);
|
||||
assertEquals(1, runtime.pendingQueryCount());
|
||||
try (ExecutorService closer = Executors.newSingleThreadExecutor()) {
|
||||
Future<Boolean> closed = closer.submit(() -> {
|
||||
Thread.currentThread().interrupt();
|
||||
runtime.close();
|
||||
return Thread.currentThread().isInterrupted();
|
||||
});
|
||||
try {
|
||||
assertThrows(TimeoutException.class, () -> closed.get(100L, TimeUnit.MILLISECONDS));
|
||||
} finally {
|
||||
release.countDown();
|
||||
}
|
||||
assertTrue(closed.get(5L, TimeUnit.SECONDS));
|
||||
assertEquals(0, runtime.pendingQueryCount());
|
||||
assertEquals(0, runtime.cachedQueryCount());
|
||||
} finally {
|
||||
release.countDown();
|
||||
runtime.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void completedSnapshotCacheRespectsItsByteBudgetAndNegativeCacheIsBounded() throws Exception {
|
||||
when(store.get(anyInt(), anyInt())).thenReturn(Optional.of(unresolvedChunk(16)));
|
||||
try (SavedBiomeRuntime runtime = new SavedBiomeRuntime(engine, history)) {
|
||||
for (int chunkX = 0; chunkX < 110; chunkX++) {
|
||||
int blockX = chunkX * 16;
|
||||
assertTrue(assertThrows(SavedBiomeUnavailableException.class,
|
||||
() -> runtime.resolve(blockX, 0, 0, true)).isLoading());
|
||||
awaitIdle(runtime);
|
||||
assertTrue(runtime.cachedQueryBytes() <= SavedBiomeChunk.MAXIMUM_ESTIMATED_BYTES);
|
||||
}
|
||||
assertTrue(runtime.cachedQueryCount() < 110);
|
||||
}
|
||||
when(store.get(anyInt(), anyInt())).thenReturn(Optional.empty());
|
||||
when(history.isActiveUnowned(anyInt(), anyInt())).thenReturn(true);
|
||||
when(history.semantics(anyInt(), anyInt())).thenReturn(Optional.empty());
|
||||
try (SavedBiomeRuntime runtime = new SavedBiomeRuntime(engine, history)) {
|
||||
for (int chunkX = 0; chunkX < 150; chunkX++) {
|
||||
int blockX = chunkX * 16;
|
||||
assertTrue(assertThrows(SavedBiomeUnavailableException.class,
|
||||
() -> runtime.resolve(blockX, 0, 0, true)).isLoading());
|
||||
awaitIdle(runtime);
|
||||
assertEquals(Optional.empty(), runtime.resolve(blockX, 0, 0, true));
|
||||
assertTrue(runtime.cachedQueryCount() <= 128);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void preparedEnvironmentSurvivesLoaderEvictionWithoutReadingPackFilesAgain() throws Exception {
|
||||
String epochId = "a".repeat(64);
|
||||
Path pack = history.paths().packRoot(epochId);
|
||||
Files.createDirectories(pack.resolve("dimensions"));
|
||||
Files.createDirectories(pack.resolve("biomes"));
|
||||
Files.createDirectories(pack.resolve("regions"));
|
||||
Files.writeString(pack.resolve("dimensions/main.json"), "{}");
|
||||
Files.writeString(pack.resolve("biomes/forest.json"), "{}");
|
||||
Files.writeString(pack.resolve("regions/main.json"), "{}");
|
||||
GenerationManifest manifest = mock(GenerationManifest.class);
|
||||
GenerationEpoch epoch = mock(GenerationEpoch.class);
|
||||
GenerationEpoch.DimensionContract contract = mock(GenerationEpoch.DimensionContract.class);
|
||||
when(history.manifest()).thenReturn(manifest);
|
||||
when(history.packRoot(1L)).thenReturn(pack);
|
||||
when(history.packRoot(2L)).thenReturn(pack);
|
||||
when(manifest.activation(1L)).thenReturn(Optional.of(GenerationActivation.initial(epochId, 1L)));
|
||||
when(manifest.activation(2L)).thenReturn(Optional.of(GenerationActivation.next(2L, epochId, 1L, 2L, 64)));
|
||||
when(manifest.epoch(epochId)).thenReturn(Optional.of(epoch));
|
||||
when(epoch.epochId()).thenReturn(epochId);
|
||||
when(epoch.registryContract()).thenReturn(GenerationRegistryContract.empty());
|
||||
when(epoch.dimensionContract()).thenReturn(contract);
|
||||
when(contract.dimensionKey()).thenReturn("main");
|
||||
SavedBiomeChunk.Cell cell = new SavedBiomeChunk.Cell(1L, "forest", "main");
|
||||
SavedBiomeChunk.Cell repeatedActivation = new SavedBiomeChunk.Cell(2L, "forest", "main");
|
||||
SavedBiomeChunk.Cell removed = new SavedBiomeChunk.Cell(2L, "removed", "main");
|
||||
SavedBiomeChunk.Builder builder = SavedBiomeChunk.builder(new SavedBiomeChunk.Header(0, 0, 2L, -64, 384));
|
||||
for (int z = 0; z < 16; z++) {
|
||||
for (int x = 0; x < 16; x++) {
|
||||
SavedBiomeChunk.Cell selected = x < 8 ? cell : x < 12 ? repeatedActivation : removed;
|
||||
builder.column(x, z, new SavedBiomeChunk.Column(selected, cell,
|
||||
List.of(new SavedBiomeChunk.Span(-64, 320, selected))));
|
||||
}
|
||||
}
|
||||
when(store.get(0, 0)).thenReturn(Optional.of(builder.build()));
|
||||
try (SavedBiomeRuntime runtime = new SavedBiomeRuntime(engine, history)) {
|
||||
assertTrue(assertThrows(SavedBiomeUnavailableException.class,
|
||||
() -> runtime.resolve(0, 0, 0, true)).isLoading());
|
||||
awaitIdle(runtime);
|
||||
runtime.prepareChunk(0, 0);
|
||||
BiomeEnvironment environment = runtime.resolve(0, 0, 0, true).orElseThrow();
|
||||
BiomeEnvironment repeated = runtime.resolve(8, 0, 0, true).orElseThrow();
|
||||
assertEquals(2L, repeated.activationId());
|
||||
assertSame(environment.data(), repeated.data());
|
||||
SavedBiomeUnavailableException missing = assertThrows(SavedBiomeUnavailableException.class,
|
||||
() -> runtime.resolve(12, 0, 0, true));
|
||||
assertEquals(false, missing.isLoading());
|
||||
assertTrue(missing.getMessage().contains("removed"));
|
||||
assertSame(environment, runtime.resolveCaveBase(12, 0).orElseThrow());
|
||||
environment.data().getBiomeLoader().unload("forest");
|
||||
environment.data().getRegionLoader().unload("main");
|
||||
Files.delete(pack.resolve("biomes/forest.json"));
|
||||
Files.delete(pack.resolve("regions/main.json"));
|
||||
assertSame(environment, runtime.resolve(0, 0, 0, true).orElseThrow());
|
||||
assertEquals("forest", environment.biome().getLoadKey());
|
||||
}
|
||||
}
|
||||
|
||||
private static void awaitIdle(SavedBiomeRuntime runtime) throws InterruptedException {
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5L);
|
||||
while (runtime.pendingQueryCount() != 0 && System.nanoTime() < deadline) {
|
||||
Thread.sleep(1L);
|
||||
}
|
||||
assertEquals(0, runtime.pendingQueryCount());
|
||||
}
|
||||
|
||||
private static SavedBiomeChunk unresolvedChunk(int runCount) {
|
||||
SavedBiomeChunk.Cell first = SavedBiomeChunk.Cell.unresolved(1L);
|
||||
SavedBiomeChunk.Cell second = SavedBiomeChunk.Cell.unresolved(2L);
|
||||
ArrayList<SavedBiomeChunk.Span> spans = new ArrayList<>(runCount);
|
||||
for (int index = 0; index < runCount; index++) {
|
||||
int start = -64 + index * 384 / runCount;
|
||||
int end = -64 + (index + 1) * 384 / runCount;
|
||||
spans.add(new SavedBiomeChunk.Span(start, end, (index & 1) == 0 ? first : second));
|
||||
}
|
||||
return chunk(new SavedBiomeChunk.Column(first, second, spans));
|
||||
}
|
||||
|
||||
private static SavedBiomeChunk chunk(SavedBiomeChunk.Column column) {
|
||||
SavedBiomeChunk.Builder builder = SavedBiomeChunk.builder(new SavedBiomeChunk.Header(0, 0, 2L, -64, 384));
|
||||
for (int z = 0; z < 16; z++) {
|
||||
for (int x = 0; x < 16; x++) {
|
||||
builder.column(x, z, column);
|
||||
}
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.zip.CRC32;
|
||||
import java.util.zip.DeflaterOutputStream;
|
||||
|
||||
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 SavedBiomeStoreTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void preservesEveryColumnHeightRegionAndOwningActivationAcrossReload() throws Exception {
|
||||
Path root = temporaryFolder.newFolder().toPath();
|
||||
SavedBiomeChunk original = chunk(-33, 31, 8L);
|
||||
SavedBiomeStore writer = SavedBiomeStore.open(root);
|
||||
assertTrue(writer.claimAndPersist(original));
|
||||
assertFalse(writer.claimAndPersist(original));
|
||||
SavedBiomeChunk loaded = SavedBiomeStore.open(root).get(-33, 31).orElseThrow();
|
||||
assertEquals(original, loaded);
|
||||
for (int z = 0; z < 16; z++) {
|
||||
for (int x = 0; x < 16; x++) {
|
||||
assertEquals(original.surfaceAt(x, z), loaded.surfaceAt(x, z));
|
||||
assertEquals(original.caveBaseAt(x, z), loaded.caveBaseAt(x, z));
|
||||
for (int y = -64; y < 320; y++) {
|
||||
assertEquals(original.biomeAt(x, y, z), loaded.biomeAt(x, y, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals(7L, loaded.biomeAt(0, -64, 0).activationId());
|
||||
assertEquals("cave/base-0", loaded.caveBaseAt(0, 0).biomeKey());
|
||||
assertEquals(8L, loaded.caveBaseAt(0, 0).activationId());
|
||||
assertFalse(loaded.caveBaseAt(0, 0).equals(loaded.biomeAt(0, -64, 0)));
|
||||
assertEquals("region/0", loaded.surfaceAt(0, 0).regionKey());
|
||||
assertEquals("region/1", loaded.surfaceAt(0, 1).regionKey());
|
||||
assertThrows(IndexOutOfBoundsException.class, () -> loaded.biomeAt(0, -65, 0));
|
||||
assertThrows(IndexOutOfBoundsException.class, () -> loaded.biomeAt(0, 320, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void preservesUnresolvedHistoricalCellsAlongsideKnownBiomeAssignments() throws Exception {
|
||||
Path root = temporaryFolder.newFolder().toPath();
|
||||
SavedBiomeChunk.Cell unresolved = SavedBiomeChunk.Cell.unresolved(1L);
|
||||
SavedBiomeChunk.Cell resolved = new SavedBiomeChunk.Cell(2L, "biome/current", "region/current");
|
||||
SavedBiomeChunk.Builder builder = SavedBiomeChunk.builder(new SavedBiomeChunk.Header(-1, -1, 2L, -64, 384));
|
||||
List<SavedBiomeChunk.Span> spans = List.of(new SavedBiomeChunk.Span(-64, 16, unresolved),
|
||||
new SavedBiomeChunk.Span(16, 320, resolved));
|
||||
for (int z = 0; z < 16; z++) {
|
||||
for (int x = 0; x < 16; x++) {
|
||||
builder.column(x, z, new SavedBiomeChunk.Column(x < 8 ? unresolved : resolved, unresolved, spans));
|
||||
}
|
||||
}
|
||||
SavedBiomeChunk original = builder.build();
|
||||
SavedBiomeStore.open(root).claimAndPersist(original);
|
||||
SavedBiomeChunk loaded = SavedBiomeStore.open(root).get(-1, -1).orElseThrow();
|
||||
assertEquals(original, loaded);
|
||||
assertFalse(loaded.surfaceAt(0, 0).isResolved());
|
||||
assertEquals(unresolved, loaded.caveBaseAt(15, 15));
|
||||
assertEquals(1L, loaded.surfaceAt(0, 0).activationId());
|
||||
assertEquals(SavedBiomeChunk.Resolution.UNRESOLVED, loaded.biomeAt(15, -64, 15).resolution());
|
||||
assertEquals(null, loaded.biomeAt(15, -64, 15).biomeKey());
|
||||
assertEquals(null, loaded.biomeAt(15, -64, 15).regionKey());
|
||||
assertTrue(loaded.surfaceAt(8, 0).isResolved());
|
||||
assertEquals(resolved, loaded.biomeAt(0, 16, 0));
|
||||
assertThrows(IllegalArgumentException.class, () -> SavedBiomeChunk.Cell.unresolved(0L));
|
||||
assertThrows(IllegalArgumentException.class, () -> new SavedBiomeChunk.Cell(1L, "biome/known", null));
|
||||
assertThrows(IllegalArgumentException.class, () -> new SavedBiomeChunk.Cell(1L, null, "region/known"));
|
||||
assertThrows(IllegalArgumentException.class, () -> new SavedBiomeChunk.Cell(1L, "biome/known", null,
|
||||
SavedBiomeChunk.Resolution.UNRESOLVED));
|
||||
assertThrows(IllegalArgumentException.class, () -> new SavedBiomeChunk.Cell(1L, null, "region/known",
|
||||
SavedBiomeChunk.Resolution.UNRESOLVED));
|
||||
assertThrows(IllegalArgumentException.class, () -> new SavedBiomeChunk.Cell(1L, null, null,
|
||||
SavedBiomeChunk.Resolution.RESOLVED));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsConflictingClaimsWithoutReplacingSavedContent() throws Exception {
|
||||
Path root = temporaryFolder.newFolder().toPath();
|
||||
SavedBiomeStore store = SavedBiomeStore.open(root);
|
||||
SavedBiomeChunk original = chunk(1, 2, 3L);
|
||||
store.claimAndPersist(original);
|
||||
byte[] bytes = Files.readAllBytes(regionPath(root, 0, 0));
|
||||
assertThrows(IOException.class, () -> store.claimAndPersist(chunk(1, 2, 4L)));
|
||||
assertTrue(Arrays.equals(bytes, Files.readAllBytes(regionPath(root, 0, 0))));
|
||||
assertEquals(Optional.of(original), SavedBiomeStore.open(root).get(1, 2));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void missingSnapshotsLeaveExistingHistoryUntouchedAndCachedReadsAvoidDisk() throws Exception {
|
||||
Path root = temporaryFolder.newFolder().toPath();
|
||||
Files.createDirectories(root.resolve("iris/generation/semantics"));
|
||||
Path semantics = root.resolve("iris/generation/semantics/existing.isem");
|
||||
Files.writeString(semantics, "unchanged format six data");
|
||||
SavedBiomeStore store = SavedBiomeStore.open(root);
|
||||
assertTrue(store.get(0, 0).isEmpty());
|
||||
assertFalse(Files.exists(store.storageDirectory()));
|
||||
SavedBiomeChunk chunk = chunk(0, 0, 3L);
|
||||
store.claimAndPersist(chunk);
|
||||
Files.delete(regionPath(root, 0, 0));
|
||||
assertEquals(Optional.of(chunk), store.get(0, 0));
|
||||
assertEquals(Optional.of(chunk), store.cached(0, 0));
|
||||
assertEquals("unchanged format six data", Files.readString(semantics));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void prunesOnlyUnstoredSelectedClaimsAndAllowsReplacementAfterCacheInvalidation() throws Exception {
|
||||
Path root = temporaryFolder.newFolder().toPath();
|
||||
SavedBiomeStore store = SavedBiomeStore.open(root);
|
||||
SavedBiomeChunk stored = chunk(0, 0, 3L);
|
||||
SavedBiomeChunk orphan = chunk(1, 0, 3L);
|
||||
SavedBiomeChunk previous = chunk(2, 0, 2L);
|
||||
SavedBiomeChunk unrelatedRegion = chunk(32, 0, 2L);
|
||||
store.claimAndPersist(stored);
|
||||
store.claimAndPersist(orphan);
|
||||
store.claimAndPersist(previous);
|
||||
store.claimAndPersist(unrelatedRegion);
|
||||
byte[] untouched = Files.readAllBytes(regionPath(root, 1, 0));
|
||||
WorldChunkInventory inventory = WorldChunkInventory.ofPackedChunks(ChunkGenerationOwnership.packChunk(0, 0));
|
||||
|
||||
assertEquals(1, store.discardUnstoredClaims(inventory, Set.of(3L)));
|
||||
|
||||
assertTrue(store.cached(1, 0).isEmpty());
|
||||
assertTrue(store.get(1, 0).isEmpty());
|
||||
assertEquals(Optional.of(stored), store.get(0, 0));
|
||||
assertEquals(Optional.of(previous), store.get(2, 0));
|
||||
assertEquals(Optional.of(unrelatedRegion), store.get(32, 0));
|
||||
assertTrue(Arrays.equals(untouched, Files.readAllBytes(regionPath(root, 1, 0))));
|
||||
SavedBiomeChunk replacement = chunk(1, 0, 4L);
|
||||
assertTrue(store.claimAndPersist(replacement));
|
||||
assertEquals(0, store.discardUnstoredClaims(inventory, Set.of(3L)));
|
||||
SavedBiomeStore loaded = SavedBiomeStore.open(root);
|
||||
assertEquals(Optional.of(replacement), loaded.get(1, 0));
|
||||
assertEquals(Optional.of(stored), loaded.get(0, 0));
|
||||
assertEquals(Optional.of(previous), loaded.get(2, 0));
|
||||
assertEquals(1, loaded.discardUnstoredClaims(WorldChunkInventory.empty(), Set.of(4L)));
|
||||
assertTrue(SavedBiomeStore.open(root).get(1, 0).isEmpty());
|
||||
assertThrows(IllegalArgumentException.class, () -> loaded.discardUnstoredClaims(inventory, Set.of(0L)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void tornLastAppendTruncatesOnlyIncompleteClaimAndAllowsNewAppend() throws Exception {
|
||||
Path root = temporaryFolder.newFolder().toPath();
|
||||
SavedBiomeStore store = SavedBiomeStore.open(root);
|
||||
SavedBiomeChunk first = chunk(0, 0, 3L);
|
||||
store.claimAndPersist(first);
|
||||
Path path = regionPath(root, 0, 0);
|
||||
long firstLength = Files.size(path);
|
||||
store.claimAndPersist(chunk(1, 0, 3L));
|
||||
byte[] bytes = Files.readAllBytes(path);
|
||||
Files.write(path, Arrays.copyOf(bytes, bytes.length - 3));
|
||||
SavedBiomeStore loaded = SavedBiomeStore.open(root);
|
||||
assertEquals(Optional.of(first), loaded.get(0, 0));
|
||||
assertEquals(firstLength, Files.size(path));
|
||||
assertTrue(loaded.get(1, 0).isEmpty());
|
||||
assertTrue(loaded.claimAndPersist(chunk(2, 0, 3L)));
|
||||
assertTrue(SavedBiomeStore.open(root).get(2, 0).isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsChecksumCorruptionAndDuplicateCompleteRecords() throws Exception {
|
||||
Path root = temporaryFolder.newFolder().toPath();
|
||||
SavedBiomeStore store = SavedBiomeStore.open(root);
|
||||
store.claimAndPersist(chunk(0, 0, 3L));
|
||||
Path path = regionPath(root, 0, 0);
|
||||
byte[] original = Files.readAllBytes(path);
|
||||
byte[] corrupt = original.clone();
|
||||
corrupt[corrupt.length - 1] ^= 1;
|
||||
Files.write(path, corrupt);
|
||||
assertThrows(IOException.class, () -> SavedBiomeStore.open(root).get(0, 0));
|
||||
Files.write(path, original);
|
||||
Files.write(path, Arrays.copyOfRange(original, 20, original.length), StandardOpenOption.APPEND);
|
||||
assertThrows(IOException.class, () -> SavedBiomeStore.open(root).get(0, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsUnknownFormatEvenWithValidHeaderChecksum() throws Exception {
|
||||
Path root = temporaryFolder.newFolder().toPath();
|
||||
SavedBiomeStore.open(root).claimAndPersist(chunk(0, 0, 3L));
|
||||
Path path = regionPath(root, 0, 0);
|
||||
byte[] bytes = Files.readAllBytes(path);
|
||||
ByteBuffer.wrap(bytes).putInt(4, 2);
|
||||
CRC32 crc = new CRC32();
|
||||
crc.update(bytes, 0, 16);
|
||||
ByteBuffer.wrap(bytes).putInt(16, (int) crc.getValue());
|
||||
Files.write(path, bytes);
|
||||
assertThrows(IOException.class, () -> SavedBiomeStore.open(root).get(0, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsOversizedDecodedHeadersPalettesRunsAndStringsBeforeAllocation() throws Exception {
|
||||
Path root = temporaryFolder.newFolder().toPath();
|
||||
SavedBiomeStore.open(root).claimAndPersist(chunk(0, 0, 3L));
|
||||
Path path = regionPath(root, 0, 0);
|
||||
rewritePayload(path, new byte[24], Integer.MAX_VALUE);
|
||||
assertThrows(IOException.class, () -> SavedBiomeStore.open(root).get(0, 0));
|
||||
for (int mode = 0; mode < 3; mode++) {
|
||||
ByteArrayOutputStream raw = new ByteArrayOutputStream();
|
||||
try (DataOutputStream output = new DataOutputStream(raw)) {
|
||||
output.writeLong(3L);
|
||||
output.writeInt(-64);
|
||||
output.writeInt(384);
|
||||
output.writeInt(mode == 0 ? Integer.MAX_VALUE : 1);
|
||||
if (mode == 0) {
|
||||
output.writeInt(0);
|
||||
} else {
|
||||
output.writeLong(3L);
|
||||
output.writeByte(mode == 1 ? 1 : 0);
|
||||
if (mode == 1) {
|
||||
output.writeInt(1);
|
||||
output.writeByte(0);
|
||||
output.writeByte(0);
|
||||
output.write(new byte[]{(byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, 0x07});
|
||||
} else {
|
||||
output.writeShort(65_535);
|
||||
}
|
||||
}
|
||||
}
|
||||
rewritePayload(path, raw.toByteArray(), raw.size());
|
||||
assertThrows(IOException.class, () -> SavedBiomeStore.open(root).get(0, 0));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void modelRejectsDenseColumnsBeyondItsDecodedFootprintLimit() {
|
||||
SavedBiomeChunk.Cell first = new SavedBiomeChunk.Cell(3L, "biome/first", "region/main");
|
||||
SavedBiomeChunk.Cell second = new SavedBiomeChunk.Cell(3L, "biome/second", "region/main");
|
||||
ArrayList<SavedBiomeChunk.Span> spans = new ArrayList<>(4096);
|
||||
for (int y = 0; y < 4096; y++) {
|
||||
spans.add(new SavedBiomeChunk.Span(y, y + 1, (y & 1) == 0 ? first : second));
|
||||
}
|
||||
SavedBiomeChunk.Column column = new SavedBiomeChunk.Column(first, second, spans);
|
||||
SavedBiomeChunk.Builder builder = SavedBiomeChunk.builder(new SavedBiomeChunk.Header(0, 0, 3L, 0, 4096));
|
||||
for (int z = 0; z < 16; z++) {
|
||||
for (int x = 0; x < 16; x++) {
|
||||
builder.column(x, z, column);
|
||||
}
|
||||
}
|
||||
IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, builder::build);
|
||||
assertTrue(failure.getMessage().contains("footprint limit"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void boundedCachesReloadEvictedRegionsAndChunks() throws Exception {
|
||||
Path root = temporaryFolder.newFolder().toPath();
|
||||
SavedBiomeStore store = SavedBiomeStore.open(root);
|
||||
for (int index = 0; index < 140; index++) {
|
||||
store.claimAndPersist(chunk(index * 32, -32, 3L));
|
||||
assertTrue(store.cachedChunkCount() <= 128);
|
||||
assertTrue(store.cachedRegionCount() <= 64);
|
||||
assertTrue(store.cachedByteCount() <= 64L * 1024L * 1024L);
|
||||
}
|
||||
assertTrue(store.cached(0, -32).isEmpty());
|
||||
assertEquals(Optional.of(chunk(0, -32, 3L)), store.get(0, -32));
|
||||
assertEquals(Optional.of(chunk(139 * 32, -32, 3L)), SavedBiomeStore.open(root).get(139 * 32, -32));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void compressesRepeatedVerticalColumnsAndKeepsDistinctExactSurfaceColumns() throws Exception {
|
||||
Path root = temporaryFolder.newFolder().toPath();
|
||||
SavedBiomeChunk.Header header = new SavedBiomeChunk.Header(0, 0, 5L, -64, 384);
|
||||
SavedBiomeChunk.Cell cave = new SavedBiomeChunk.Cell(5L, "biome/cave", "region/main");
|
||||
List<SavedBiomeChunk.Span> vertical = List.of(new SavedBiomeChunk.Span(-64, 320, cave));
|
||||
SavedBiomeChunk.Builder builder = SavedBiomeChunk.builder(header);
|
||||
for (int z = 0; z < 16; z++) {
|
||||
for (int x = 0; x < 16; x++) {
|
||||
SavedBiomeChunk.Cell surface = new SavedBiomeChunk.Cell(5L, "biome/surface-" + x, "region/" + z);
|
||||
builder.column(x, z, new SavedBiomeChunk.Column(surface, cave, vertical));
|
||||
}
|
||||
}
|
||||
SavedBiomeChunk original = builder.build();
|
||||
SavedBiomeStore.open(root).claimAndPersist(original);
|
||||
assertTrue(Files.size(regionPath(root, 0, 0)) < 4096);
|
||||
assertEquals(Optional.of(original), SavedBiomeStore.open(root).get(0, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void concurrentlyPublishesSeparateRegionsAndSingleImmutableClaims() throws Exception {
|
||||
Path root = temporaryFolder.newFolder().toPath();
|
||||
SavedBiomeStore store = SavedBiomeStore.open(root);
|
||||
try (ExecutorService workers = Executors.newFixedThreadPool(4)) {
|
||||
ArrayList<Future<Boolean>> futures = new ArrayList<>();
|
||||
for (int index = 0; index < 12; index++) {
|
||||
int chunkX = (index % 6) * 32;
|
||||
futures.add(workers.submit(() -> store.claimAndPersist(chunk(chunkX, 0, 3L))));
|
||||
}
|
||||
int added = 0;
|
||||
for (Future<Boolean> future : futures) {
|
||||
if (future.get()) {
|
||||
added++;
|
||||
}
|
||||
}
|
||||
assertEquals(6, added);
|
||||
}
|
||||
SavedBiomeStore loaded = SavedBiomeStore.open(root);
|
||||
for (int index = 0; index < 6; index++) {
|
||||
assertEquals(Optional.of(chunk(index * 32, 0, 3L)), loaded.get(index * 32, 0));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void modelCopiesInputsCompactsRunsAndRejectsGapsMissingColumnsAndInvalidOwners() {
|
||||
SavedBiomeChunk.Cell cell = new SavedBiomeChunk.Cell(3L, "biome/main", "region/main");
|
||||
ArrayList<SavedBiomeChunk.Span> spans = new ArrayList<>(List.of(
|
||||
new SavedBiomeChunk.Span(-64, 0, cell), new SavedBiomeChunk.Span(0, 320, cell)));
|
||||
SavedBiomeChunk.Column column = new SavedBiomeChunk.Column(cell, cell, spans);
|
||||
spans.clear();
|
||||
assertEquals(List.of(new SavedBiomeChunk.Span(-64, 320, cell)), column.vertical());
|
||||
assertThrows(UnsupportedOperationException.class, () -> column.vertical().clear());
|
||||
SavedBiomeChunk.Builder builder = SavedBiomeChunk.builder(new SavedBiomeChunk.Header(0, 0, 3L, -64, 384));
|
||||
assertThrows(NullPointerException.class, builder::build);
|
||||
assertThrows(IllegalArgumentException.class, () -> new SavedBiomeChunk.Cell(0L, "biome/main", "region/main"));
|
||||
assertThrows(IllegalArgumentException.class, () -> new SavedBiomeChunk.Header(0, 0, 3L, Integer.MAX_VALUE, 1));
|
||||
assertThrows(IllegalArgumentException.class, () -> new SavedBiomeChunk.Column(cell, cell, List.of(
|
||||
new SavedBiomeChunk.Span(-64, 0, cell), new SavedBiomeChunk.Span(1, 320, cell))));
|
||||
for (int z = 0; z < 16; z++) {
|
||||
for (int x = 0; x < 16; x++) {
|
||||
builder.column(x, z, column);
|
||||
}
|
||||
}
|
||||
SavedBiomeChunk saved = builder.build();
|
||||
SavedBiomeChunk.Column shorter = new SavedBiomeChunk.Column(cell, cell, List.of(new SavedBiomeChunk.Span(-64, 319, cell)));
|
||||
builder.column(0, 0, shorter);
|
||||
assertEquals(column, saved.column(0, 0));
|
||||
assertThrows(IllegalArgumentException.class, builder::build);
|
||||
}
|
||||
|
||||
private static void rewritePayload(Path path, byte[] raw, int declaredSize) throws IOException {
|
||||
byte[] header = Arrays.copyOf(Files.readAllBytes(path), 20);
|
||||
ByteArrayOutputStream body = new ByteArrayOutputStream();
|
||||
DataOutputStream coordinates = new DataOutputStream(body);
|
||||
coordinates.writeInt(0);
|
||||
coordinates.writeInt(0);
|
||||
coordinates.writeInt(declaredSize);
|
||||
try (DeflaterOutputStream compressed = new DeflaterOutputStream(coordinates)) {
|
||||
compressed.write(raw);
|
||||
}
|
||||
byte[] encoded = body.toByteArray();
|
||||
CRC32 crc = new CRC32();
|
||||
crc.update(encoded);
|
||||
ByteArrayOutputStream file = new ByteArrayOutputStream();
|
||||
try (DataOutputStream output = new DataOutputStream(file)) {
|
||||
output.write(header);
|
||||
output.writeInt(encoded.length);
|
||||
output.write(encoded);
|
||||
output.writeInt((int) crc.getValue());
|
||||
}
|
||||
Files.write(path, file.toByteArray());
|
||||
}
|
||||
|
||||
private static Path regionPath(Path root, int regionX, int regionZ) {
|
||||
return root.resolve("iris/generation/biomes/r." + regionX + "." + regionZ + ".ibio");
|
||||
}
|
||||
|
||||
private static SavedBiomeChunk chunk(int chunkX, int chunkZ, long activation) {
|
||||
SavedBiomeChunk.Builder builder = SavedBiomeChunk.builder(new SavedBiomeChunk.Header(chunkX, chunkZ, activation, -64, 384));
|
||||
for (int z = 0; z < 16; z++) {
|
||||
for (int x = 0; x < 16; x++) {
|
||||
SavedBiomeChunk.Cell cave = new SavedBiomeChunk.Cell(activation - 1L, "cave/" + (x / 4), "region/" + (z / 4));
|
||||
SavedBiomeChunk.Cell surface = new SavedBiomeChunk.Cell(activation, "surface/" + x, "region/" + z);
|
||||
builder.column(x, z, new SavedBiomeChunk.Column(surface,
|
||||
new SavedBiomeChunk.Cell(activation, "cave/base-" + z, "region/" + z), List.of(
|
||||
new SavedBiomeChunk.Span(-64, 16 + z, cave), new SavedBiomeChunk.Span(16 + z, 320, surface))));
|
||||
}
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package art.arcane.iris.engine.object;
|
||||
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import org.bukkit.HeightMap;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Particle;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyDouble;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class IrisEffectSurfaceTest {
|
||||
@Test
|
||||
public void samplesLiveHeightOnTargetRegionThenEmitsOnPlayerThread() {
|
||||
World world = mock(World.class);
|
||||
Player player = mock(Player.class);
|
||||
Location location = new Location(world, -17, 100, 32);
|
||||
when(player.getWorld()).thenReturn(world);
|
||||
when(world.isChunkLoaded(-2, 2)).thenReturn(true);
|
||||
when(world.getHighestBlockYAt(-17, 32, HeightMap.OCEAN_FLOOR)).thenReturn(23);
|
||||
AtomicReference<Runnable> regionTask = new AtomicReference<>();
|
||||
AtomicReference<Runnable> playerTask = new AtomicReference<>();
|
||||
|
||||
try (MockedStatic<J> scheduler = mockStatic(J.class)) {
|
||||
scheduler.when(() -> J.runAt(eq(location), any(Runnable.class))).thenAnswer(call -> {
|
||||
regionTask.set(call.getArgument(1));
|
||||
return true;
|
||||
});
|
||||
scheduler.when(() -> J.runEntity(eq(player), any(Runnable.class))).thenAnswer(call -> {
|
||||
playerTask.set(call.getArgument(1));
|
||||
return true;
|
||||
});
|
||||
|
||||
new IrisEffect().applyParticles(player, Particle.FLAME, location);
|
||||
assertNotNull(regionTask.get());
|
||||
assertNull(playerTask.get());
|
||||
verify(world, never()).getHighestBlockYAt(anyInt(), anyInt(), any(HeightMap.class));
|
||||
regionTask.get().run();
|
||||
assertEquals(24D, location.getY(), 0D);
|
||||
assertNotNull(playerTask.get());
|
||||
playerTask.get().run();
|
||||
verify(player).spawnParticle(eq(Particle.FLAME), anyDouble(), eq(24D), anyDouble(),
|
||||
anyInt(), anyDouble(), anyDouble(), anyDouble());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unloadedParticleColumnsDoNotLoadTerrainOrEmit() {
|
||||
World world = mock(World.class);
|
||||
Player player = mock(Player.class);
|
||||
Location location = new Location(world, 0, 100, 0);
|
||||
try (MockedStatic<J> scheduler = mockStatic(J.class)) {
|
||||
scheduler.when(() -> J.isOwnedByCurrentRegion(world, 0, 0)).thenReturn(true);
|
||||
new IrisEffect().applyParticles(player, Particle.FLAME, location);
|
||||
verify(world, never()).getHighestBlockYAt(anyInt(), anyInt(), any(HeightMap.class));
|
||||
scheduler.verify(() -> J.runEntity(eq(player), any(Runnable.class)), never());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user