Improve generation and lifecycle safety

- Reduce terrain, noise, hydrology, and decorator sampling overhead.
- Reduce object transaction allocations while preserving rollback behavior.
- Optimize structure placement and cave anchor sampling.
- Parallelize Studio cache warmup and protect world entry and hotload recovery.
- Validate pack fingerprints and publish pack replacements atomically.
- Notify modded operators about outdated packs without synchronizing pack data.
- Harden world creation, loading, evacuation, and teleport failure recovery.
- Coalesce queued region flushes while preserving chunk save ordering.
- Remove obsolete mantle, pack, and cave planning code.
- Add regression tests and reusable gameplay fixtures.
This commit is contained in:
Brian Neumann-Fopiano
2026-10-06 20:51:28 -04:00
parent a2bcd7a302
commit 7f5ce70f49
197 changed files with 7560 additions and 4420 deletions
@@ -18,6 +18,8 @@
package art.arcane.iris.command;
import art.arcane.iris.pack.PackFingerprints;
import art.arcane.iris.Iris;
import art.arcane.iris.pack.validation.PackCompatReport;
import art.arcane.iris.pack.PackDirectoryResolver;
@@ -174,7 +176,6 @@ public class CommandPack implements DirectorExecutor {
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_NO_CLEANUP_CANDIDATES_FOUND_PACK, MessageArgument.untrusted("pack", String.valueOf(pack))));
return;
}
PackValidationRegistry.remove(packFolder.getName());
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_QUARANTINED_CLEANUP_CANDIDATE_S_UNDER, MessageArgument.untrusted("size", String.valueOf(result.quarantinedPaths().size())), MessageArgument.untrusted("quarantinePath", String.valueOf(result.quarantinePath()))));
reportPaths(s, result.quarantinedPaths(), BukkitRuntimeMessages.COMMAND_PACK_PATH_QUARANTINED);
return;
@@ -230,7 +231,6 @@ public class CommandPack implements DirectorExecutor {
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_NOTHING_RESTORE_PACK, MessageArgument.untrusted("pack", String.valueOf(pack))));
return;
}
PackValidationRegistry.remove(packFolder.getName());
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_RESTORED_FILE_S_FROM, MessageArgument.untrusted("size", String.valueOf(result.restoredPaths().size())), MessageArgument.untrusted("dumpPath", String.valueOf(result.dumpPath()))));
reportPaths(s, result.restoredPaths(), BukkitRuntimeMessages.COMMAND_PACK_PATH_RESTORED);
return;
@@ -363,47 +363,79 @@ public class CommandPack implements DirectorExecutor {
return persisted == null || persisted.isBlank() ? "unknown" : persisted;
}
private PackValidationResult runValidate(VolmitSender s, File packFolder) {
PackValidationResult runValidate(VolmitSender s, File packFolder) {
PackValidationRegistry.ValidationTicket ticket =
PackValidationRegistry.tryBeginValidation(packFolder.toPath());
PackValidationResult result;
String contentFingerprint = "";
String contextFingerprint = "";
try {
PackValidationResult result = PackValidator.validate(packFolder);
PackValidationRegistry.publish(result);
reportResult(s, result);
return result;
if (ticket == null) {
throw new IllegalStateException("Pack content is currently being changed.");
}
contentFingerprint = PackFingerprints.computePackTreeFingerprint(packFolder);
contextFingerprint = PackValidationCache.contextFingerprint();
result = PackValidator.validate(packFolder);
if (!contentFingerprint.equals(PackFingerprints.computePackTreeFingerprint(packFolder))
|| !contextFingerprint.equals(PackValidationCache.contextFingerprint())) {
throw new IllegalStateException("Pack content or validation context changed during validation.");
}
} catch (Throwable e) {
Iris.reportError("Pack validation failed for '" + packFolder.getName() + "'", e);
String detail = e.getMessage() == null || e.getMessage().isBlank()
? e.getClass().getSimpleName()
: e.getMessage();
PackValidationResult result = new PackValidationResult(
result = new PackValidationResult(
packFolder.getName(),
List.of("Pack validation failed with " + e.getClass().getSimpleName() + ": " + detail),
List.of(),
System.currentTimeMillis());
contentFingerprint = "";
contextFingerprint = "";
}
if (PackValidationRegistry.publishIfCurrent(ticket, result, contentFingerprint, contextFingerprint)) {
PackValidationRegistry.publish(result);
} else {
result = new PackValidationResult(packFolder.getName(),
List.of("Pack content changed during validation."), List.of(), System.currentTimeMillis());
}
reportResult(s, result);
return result;
}
}
private void persistValidationCache(File packsRoot) {
try {
String contextFingerprint = PackValidationCache.contextFingerprint();
PackFingerprints.PackContentSnapshot snapshot = PackFingerprints.computePackContentSnapshot(packsRoot);
List<File> packDirectories = PackDirectoryResolver.listVisiblePackDirectories(packsRoot);
List<PackValidationResult> results = new ArrayList<>(packDirectories.size());
for (File packDirectory : packDirectories) {
PackValidationResult result = PackValidationRegistry.get(packDirectory.getName());
PackValidationResult result = PackValidationRegistry.getMatching(
packDirectory.toPath(), snapshot.packContents().get(packDirectory.getName()));
if (result == null) {
// The boot cache only loads when it covers every pack, so a partial write is
// pointless - but say so instead of silently skipping the persist forever.
Iris.warn("Pack validation cache not written: \"" + packDirectory.getName()
+ "\" has no registered result. Run /iris pack validate all to repopulate it.");
+ "\" has no validation matching its current content. Run /iris pack validate to refresh it.");
return;
}
results.add(result);
}
try {
if (!snapshot.content().equals(PackFingerprints.computePackContentSnapshot(packsRoot).content())
|| !contextFingerprint.equals(PackValidationCache.contextFingerprint())) {
Iris.warn("Pack validation cache not written because pack content or validation context changed.");
return;
}
for (int index = 0; index < packDirectories.size(); index++) {
File packDirectory = packDirectories.get(index);
if (PackValidationRegistry.getMatching(packDirectory.toPath(),
snapshot.packContents().get(packDirectory.getName())) != results.get(index)) {
Iris.warn("Pack validation cache not written because validation changed during persistence.");
return;
}
}
PackValidationCache.save(
Iris.instance.getDataFile("cache", "pack-validation.json").toPath(),
PackValidationCache.contentFingerprint(packsRoot),
PackValidationCache.contextFingerprint(),
snapshot.content(),
contextFingerprint,
results);
} catch (IOException | RuntimeException e) {
Iris.reportError("Could not persist refreshed pack-validation results", e);
@@ -18,8 +18,9 @@
package art.arcane.iris.world;
import art.arcane.iris.pack.PackFingerprints;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.pack.datapack.ServerConfigurator;
import art.arcane.iris.Iris;
import art.arcane.iris.world.lifecycle.WorldLifecycleStaging;
@@ -184,12 +185,12 @@ public final class IrisWorldGeneratorResolver {
}
List<String> packNames = packDirs.stream().map(File::getName).sorted().toList();
Path cacheFile = IrisPlatforms.get().dataFile("cache", "pack-validation.json").toPath();
ServerConfigurator.PackContentSnapshot contentSnapshot =
new ServerConfigurator.PackContentSnapshot("", Map.of());
PackFingerprints.PackContentSnapshot contentSnapshot =
new PackFingerprints.PackContentSnapshot("", Map.of());
String contextFingerprint = "";
Optional<List<PackValidationResult>> cached = Optional.empty();
try {
contentSnapshot = ServerConfigurator.computePackContentSnapshot(packsRoot);
contentSnapshot = PackFingerprints.computePackContentSnapshot(packsRoot);
contextFingerprint = PackValidationCache.contextFingerprint();
if (!externalContentChanged) {
cached = PackValidationCache.load(
@@ -274,19 +275,19 @@ public final class IrisWorldGeneratorResolver {
private static FreshValidation validateStablePacks(
File packsRoot,
List<File> initialPackDirs,
ServerConfigurator.PackContentSnapshot initialSnapshot
PackFingerprints.PackContentSnapshot initialSnapshot
) {
List<File> packDirs = initialPackDirs;
ServerConfigurator.PackContentSnapshot before = initialSnapshot;
PackFingerprints.PackContentSnapshot before = initialSnapshot;
for (int attempt = 0; attempt < VALIDATION_STABILITY_ATTEMPTS; attempt++) {
List<String> packNames = packDirs.stream().map(File::getName).sorted().toList();
List<PackValidationResult> results = validatePacks(packDirs);
ServerConfigurator.PackContentSnapshot after;
PackFingerprints.PackContentSnapshot after;
try {
after = ServerConfigurator.computePackContentSnapshot(packsRoot);
after = PackFingerprints.computePackContentSnapshot(packsRoot);
} catch (RuntimeException exception) {
Iris.reportError("Could not verify Iris pack bytes after validation", exception);
after = new ServerConfigurator.PackContentSnapshot("", Map.of());
after = new PackFingerprints.PackContentSnapshot("", Map.of());
}
List<File> afterPackDirs = PackDirectoryResolver.listVisiblePackDirectories(packsRoot);
List<String> afterPackNames = afterPackDirs.stream().map(File::getName).sorted().toList();
@@ -923,7 +924,7 @@ public final class IrisWorldGeneratorResolver {
private record FreshValidation(
List<File> packDirs,
ServerConfigurator.PackContentSnapshot contentSnapshot,
PackFingerprints.PackContentSnapshot contentSnapshot,
List<PackValidationResult> results,
boolean stable
) {
@@ -0,0 +1,47 @@
{
"name": "Flat",
"derivative": "minecraft:plains",
"vanillaDerivative": "minecraft:plains",
"layers": [
{
"minHeight": 1,
"maxHeight": 1,
"palette": [
{
"block": "minecraft:lime_wool"
}
]
},
{
"minHeight": 384,
"maxHeight": 384,
"palette": [
{
"block": "minecraft:green_concrete"
}
]
}
],
"generators": [
{
"generator": "flat",
"min": 96,
"max": 96
}
],
"decorators": [
{
"palette": [
{
"block": "minecraft:oak_stairs",
"data": {
"half": "bottom",
"facing": "north"
}
}
],
"chance": 1,
"forcePlace": true
}
]
}
@@ -0,0 +1,28 @@
{
"name": "Decorator Fixture",
"version": 1,
"mode": {
"type": "OVERWORLD"
},
"regions": [
"flat"
],
"focusRegion": "flat",
"focus": "flat",
"landChance": 1,
"environment": "NORMAL",
"dimensionHeight": {
"min": -64,
"max": 320
},
"logicalHeight": 384,
"fluidHeight": 0,
"bedrock": false,
"rockPalette": {
"palette": [
{
"block": "minecraft:red_concrete"
}
]
}
}
@@ -0,0 +1,15 @@
{
"interpolator": {
"function": "NONE",
"horizontalScale": 1
},
"seed": 310,
"composite": [
{
"seed": 310,
"style": {
"style": "FLAT"
}
}
]
}
@@ -0,0 +1,12 @@
{
"name": "Flat",
"landBiomes": [
"flat"
],
"seaBiomes": [
"flat"
],
"shoreBiomes": [
"flat"
]
}
@@ -0,0 +1,40 @@
import { readFile } from 'node:fs/promises'
import path from 'node:path'
export default {
name: 'iris-decorators',
description: 'Verify authored stair decorations occupy one block and retain their half state.',
async run(context) {
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
context.expect(/^isolated=true\s*$/m.test(source), 'Decorator generation requires an isolated instance')
await context.step('create a world with authored stair decorations', async () => {
const response = await context.command('/iris create name=decorator_probe type=decorator-fixture seed=78264193',
/Successfully created your world|Failed to create/, 180000)
context.expect(response.includes('Successfully created your world'), response)
})
await context.step('client sees single bottom-half stair decorations', async () => {
let checked = 0
await context.waitUntil(() => {
checked = 0
const center = context.bot.entity.position.floored()
for (let x = -6; x <= 6; x++) {
for (let z = -6; z <= 6; z++) {
for (let y = -4; y <= 1; y++) {
const position = center.offset(x, y, z)
if (context.bot.blockAt(position)?.name !== 'lime_wool') continue
const decoration = context.bot.blockAt(position.offset(0, 1, 0))
const above = context.bot.blockAt(position.offset(0, 2, 0))
if (!decoration || !above) return false
if (decoration.name !== 'oak_stairs' || decoration.getProperties().half !== 'bottom'
|| above.name !== 'air') return false
checked++
}
}
}
return checked >= 100
}, { label: 'At least 100 authored stair columns with empty space above', timeoutMs: 60000, intervalMs: 250 })
context.expect(checked >= 100, 'Client verified generated decorator columns', { checked })
context.report.decoratorColumns = checked
})
}
}
@@ -4,6 +4,13 @@ import art.arcane.volmlib.nativelib.NativeAdapters;
import art.arcane.iris.world.runtime.RuntimeInjection;
import art.arcane.iris.world.IrisCreator;
import art.arcane.iris.world.IrisToolbelt;
import art.arcane.iris.world.IrisWorldStorage;
import art.arcane.iris.world.WorldCreatorCompat;
import art.arcane.iris.world.lifecycle.LifecycleOperationCoordinator;
import art.arcane.iris.platform.bootstrap.ServerProperties;
import org.bukkit.Location;
import org.bukkit.entity.Player;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.Material;
import org.bukkit.block.Biome;
import art.arcane.volmlib.nativelib.terrain.NativeTerrainAccess;
@@ -19,6 +26,10 @@ import org.bukkit.command.CommandSender;
import org.bukkit.generator.ChunkGenerator;
import org.bukkit.plugin.java.JavaPlugin;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.util.concurrent.CompletableFuture;
import java.util.List;
import java.util.logging.Level;
@@ -26,6 +37,7 @@ import java.util.logging.Level;
public final class NativeLifecycleFixture extends JavaPlugin implements WorldRuntimeExecution {
private static final NamespacedKey WORLD_KEY = NamespacedKey.minecraft("native_lifecycle_probe");
private static final NamespacedKey TERRAIN_KEY = new NamespacedKey("iris", "native_terrain_probe");
private static final NamespacedKey COMMAND_KEY = new NamespacedKey("iris", "lifecycle_probe");
private static final long WORLD_SEED = 78264193L;
private NativeWorldRuntime runtime;
@@ -45,7 +57,7 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (arguments.length != 1) {
sender.sendMessage("Use /irislifecycle create|unload|status|structures|terrain-create|terrain-check|terrain-unload");
sender.sendMessage("Use /irislifecycle create|unload|status|structures|terrain-create|terrain-check|terrain-unload|lifecycle-status|lifecycle-entry");
return true;
}
try {
@@ -56,6 +68,8 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
case "terrain-create" -> createTerrain(sender);
case "terrain-check" -> checkTerrain(sender);
case "terrain-unload" -> unloadTerrain(sender);
case "lifecycle-status" -> lifecycleStatus(sender);
case "lifecycle-entry" -> lifecycleEntry(sender);
case "status" -> sender.sendMessage("NATIVE_STATUS available=" + runtime.available()
+ " loaded=" + (Bukkit.getWorld(WORLD_KEY) != null));
default -> throw new IllegalArgumentException("Unknown lifecycle operation");
@@ -66,6 +80,61 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
return true;
}
private void lifecycleStatus(CommandSender sender) throws Exception {
World world = Bukkit.getWorld(COMMAND_KEY);
Path storage = WorldCreatorCompat.persistentLevelRoot(COMMAND_KEY).toPath();
Path levelRoot = IrisWorldStorage.levelRoot().toPath();
Path registry = levelRoot.resolve("iris/worlds.json");
boolean registered = Files.exists(registry)
&& Files.readString(registry).contains("\"" + COMMAND_KEY + "\"");
String startupName = IrisWorldStorage.configuredWorldName(COMMAND_KEY, levelRoot.getFileName().toString());
YamlConfiguration configuration = YamlConfiguration.loadConfiguration(ServerProperties.BUKKIT_YML);
boolean configured = configuration.getConfigurationSection("worlds") != null
&& configuration.getConfigurationSection("worlds").contains(startupName);
File quarantineRoot = levelRoot.resolve("dimensions/iris").toFile();
File[] quarantines = quarantineRoot.listFiles((directory, name) -> name.startsWith(".iris-delete-"));
int quarantineCount = quarantines == null ? 0 : quarantines.length;
Path queueFile = Bukkit.getPluginManager().getPlugin("Iris").getDataFolder().toPath()
.resolve("pending-world-deletes.txt");
List<String> queue = Files.exists(queueFile) ? Files.readAllLines(queueFile) : List.of();
boolean queued = quarantineCount > 0;
if (quarantines != null) {
for (File quarantine : quarantines) {
queued &= queue.contains(quarantine.getName());
}
}
boolean disk = Files.exists(storage, LinkOption.NOFOLLOW_LINKS);
String cleanup = disk ? "present" : quarantineCount == 0 ? "deleted" : queued ? "queued" : "unsafe";
boolean idle = true;
LifecycleOperationCoordinator coordinator = LifecycleOperationCoordinator.get();
for (LifecycleOperationCoordinator.Domain domain : LifecycleOperationCoordinator.Domain.values()) {
idle &= coordinator.active(domain).isEmpty();
}
sender.sendMessage("IRIS_LIFECYCLE loaded=" + (world != null) + " registered=" + registered
+ " configured=" + configured + " disk=" + disk + " quarantines=" + quarantineCount
+ " cleanup=" + cleanup + " idle=" + idle);
}
private void lifecycleEntry(CommandSender sender) {
if (!(sender instanceof Player player)) {
throw new IllegalArgumentException("Lifecycle entry requires a player");
}
World world = Bukkit.getWorld(COMMAND_KEY);
if (world == null || player.getWorld() != world || world.getSeed() != WORLD_SEED
|| !IrisToolbelt.isIrisWorld(world)) {
throw new IllegalStateException("Player did not enter the requested seeded Iris world");
}
Location feet = player.getLocation();
Material floor = world.getBlockAt(feet.getBlockX(), feet.getBlockY() - 1, feet.getBlockZ()).getType();
if (floor != Material.LIME_WOOL || !floor.isSolid()
|| !world.getBlockAt(feet.getBlockX(), feet.getBlockY(), feet.getBlockZ()).isPassable()
|| !world.getBlockAt(feet.getBlockX(), feet.getBlockY() + 1, feet.getBlockZ()).isPassable()) {
throw new IllegalStateException("Lifecycle player entry lacks a safe fixture floor");
}
sender.sendMessage("IRIS_LIFECYCLE_ENTRY world=" + COMMAND_KEY + " seed=" + world.getSeed()
+ " floor=lime_wool safe=true");
}
private void create(CommandSender sender) throws ReflectiveOperationException {
if (!runtime.available() || Bukkit.getWorld(WORLD_KEY) != null) {
throw new IllegalStateException("Native runtime unavailable or probe world already loaded");
@@ -124,6 +193,7 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
int y = world.getHighestBlockYAt(x, z);
if (y < 90 || y > 100 || world.getBlockAt(x, y, z).getType() != Material.LIME_WOOL
|| world.getBlockAt(x, y - 3, z).getType() != Material.GREEN_CONCRETE
|| world.getBlockAt(x, world.getMinHeight(), z).getType() != Material.GREEN_CONCRETE
|| world.getBiome(x, y, z) != Biome.PLAINS) {
throw new IllegalStateException("Terrain column differs at " + x + "," + y + "," + z
+ ": " + world.getBlockAt(x, y, z).getType());
@@ -11,6 +11,15 @@
"block": "minecraft:lime_wool"
}
]
},
{
"minHeight": 384,
"maxHeight": 384,
"palette": [
{
"block": "minecraft:green_concrete"
}
]
}
],
"generators": [
@@ -21,7 +21,7 @@
"rockPalette": {
"palette": [
{
"block": "minecraft:green_concrete"
"block": "minecraft:red_concrete"
}
]
}
@@ -0,0 +1,32 @@
{
"name": "Flat",
"derivative": "minecraft:plains",
"vanillaDerivative": "minecraft:plains",
"layers": [
{
"minHeight": 1,
"maxHeight": 1,
"palette": [
{
"block": "minecraft:lime_wool"
}
]
},
{
"minHeight": 384,
"maxHeight": 384,
"palette": [
{
"block": "minecraft:green_concrete"
}
]
}
],
"generators": [
{
"generator": "flat",
"min": 64,
"max": 96
}
]
}
@@ -0,0 +1,28 @@
{
"name": "Noise Sources",
"version": 1,
"mode": {
"type": "OVERWORLD"
},
"regions": [
"flat"
],
"focusRegion": "flat",
"focus": "flat",
"landChance": 1,
"environment": "NORMAL",
"dimensionHeight": {
"min": -64,
"max": 320
},
"logicalHeight": 384,
"fluidHeight": 0,
"bedrock": false,
"rockPalette": {
"palette": [
{
"block": "minecraft:red_concrete"
}
]
}
}
@@ -0,0 +1,3 @@
{
"expression": "0.5 + sin(x * 0.15) * 0.25 + cos(y * 0.13) * 0.25"
}
@@ -0,0 +1,22 @@
{
"interpolator": {
"function": "NONE",
"horizontalScale": 1
},
"seed": 310,
"composite": [
{
"seed": 310,
"style": {
"expression": "terrain"
}
},
{
"seed": 311,
"style": {
"style": "CLOVER_HERMITE",
"zoom": 0.1
}
}
]
}
@@ -0,0 +1,12 @@
{
"name": "Flat",
"landBiomes": [
"flat"
],
"seaBiomes": [
"flat"
],
"shoreBiomes": [
"flat"
]
}
@@ -0,0 +1,48 @@
import { readFile } from 'node:fs/promises'
import path from 'node:path'
export default {
name: 'iris-noise',
description: 'Verify expression and interpolated noise produce varied, bounded terrain visible to a player.',
async run(context) {
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
context.expect(/^isolated=true\s*$/m.test(source), 'Noise generation requires an isolated instance')
await context.step('create a world with expression and Clover noise', async () => {
const response = await context.command('/iris create name=noise_probe type=noise-fixture seed=78264193',
/Successfully created your world|Failed to create/, 180000)
context.expect(response.includes('Successfully created your world'), response)
})
await context.step('client observes bounded terrain with varying heights', async () => {
let columns = 0
let minimum = Infinity
let maximum = -Infinity
await context.waitUntil(() => {
columns = 0
minimum = Infinity
maximum = -Infinity
const center = context.bot.entity.position.floored()
for (let x = -8; x <= 8; x++) {
for (let z = -8; z <= 8; z++) {
let surface = null
for (let y = 60; y <= 100; y++) {
const position = center.offset(x, y - center.y, z)
const block = context.bot.blockAt(position)
if (!block) return false
if (block.name === 'lime_wool') {
if (surface !== null || context.bot.blockAt(position.offset(0, 1, 0))?.name !== 'air') return false
surface = y
}
}
if (surface === null || surface < 64 || surface > 96) return false
columns++
minimum = Math.min(minimum, surface)
maximum = Math.max(maximum, surface)
}
}
return columns === 289 && maximum - minimum >= 3
}, { label: '289 noise columns with bounded, varied surfaces', timeoutMs: 60000, intervalMs: 250 })
context.expect(columns === 289 && maximum - minimum >= 3, 'Client verified noise terrain', { columns, minimum, maximum })
context.report.noiseTerrain = { columns, minimum, maximum }
})
}
}
@@ -0,0 +1,45 @@
{
"name": "Flat",
"derivative": "minecraft:plains",
"vanillaDerivative": "minecraft:plains",
"layers": [
{
"minHeight": 1,
"maxHeight": 1,
"palette": [
{
"block": "minecraft:lime_wool"
}
]
},
{
"minHeight": 384,
"maxHeight": 384,
"palette": [
{
"block": "minecraft:stone"
}
]
}
],
"generators": [
{
"generator": "flat",
"min": 64,
"max": 64
}
],
"objects": [
{
"place": [
"object"
],
"chance": 1,
"density": 4,
"mode": "CENTER_HEIGHT",
"rotation": {
"enabled": false
}
}
]
}
@@ -0,0 +1,66 @@
{
"name": "Structure Fixture",
"version": 1,
"mode": {
"type": "OVERWORLD"
},
"regions": [
"flat"
],
"focusRegion": "flat",
"focus": "flat",
"landChance": 1,
"environment": "NORMAL",
"dimensionHeight": {
"min": -64,
"max": 320
},
"logicalHeight": 384,
"fluidHeight": 0,
"bedrock": false,
"rockPalette": {
"palette": [
{
"block": "minecraft:stone"
}
]
},
"structures": [
{
"structures": [
"surface"
],
"placementId": "surface",
"distribution": "DENSITY",
"density": 1,
"anchor": "SURFACE",
"terrain": {
"mode": "PRESERVE"
},
"stilt": {
"maxDepth": 16,
"palette": {
"palette": [
{
"block": "minecraft:diamond_block"
}
]
}
}
},
{
"structures": [
"buried"
],
"placementId": "buried",
"distribution": "DENSITY",
"density": 1,
"anchor": "HEIGHT_BAND",
"minHeight": 24,
"maxHeight": 24,
"terrain": {
"mode": "BORE"
}
}
]
}
@@ -0,0 +1,15 @@
{
"interpolator": {
"function": "NONE",
"horizontalScale": 1
},
"seed": 310,
"composite": [
{
"seed": 310,
"style": {
"style": "FLAT"
}
}
]
}
@@ -0,0 +1,8 @@
{
"object": "buried",
"connectors": [],
"rotatable": false,
"rules": {
"terminal": true
}
}
@@ -0,0 +1,8 @@
{
"object": "surface",
"connectors": [],
"rotatable": false,
"rules": {
"terminal": true
}
}
@@ -0,0 +1,8 @@
{
"pieces": [
{
"piece": "buried",
"weight": 1
}
]
}
@@ -0,0 +1,8 @@
{
"pieces": [
{
"piece": "surface",
"weight": 1
}
]
}
@@ -0,0 +1,12 @@
{
"name": "Flat",
"landBiomes": [
"flat"
],
"seaBiomes": [
"flat"
],
"shoreBiomes": [
"flat"
]
}
@@ -0,0 +1,6 @@
{
"startPool": "buried",
"maxDepth": 1,
"maxSizeChunks": 1,
"placeMode": "STRUCTURE_PIECE"
}
@@ -0,0 +1,6 @@
{
"startPool": "surface",
"maxDepth": 1,
"maxSizeChunks": 1,
"placeMode": "STRUCTURE_PIECE"
}
@@ -0,0 +1,52 @@
import { readFile } from 'node:fs/promises'
import { createHash } from 'node:crypto'
import path from 'node:path'
export default {
name: 'iris-structures',
description: 'Verify surface foundations, buried rooms, and waterlogged object blocks in generated chunks.',
async run(context) {
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
context.expect(/^isolated=true\s*$/m.test(source), 'Structure generation requires an isolated instance')
const world = context.options.command ?? 'structure_probe'
context.expect(/^[a-z0-9_]+$/.test(world), 'Fixture world name must be a simple identifier')
await context.step('create the structure fixture world', async () => {
const response = await context.command(`/iris create name=${world} type=structure-fixture seed=78264193`,
/World ready|Failed to create/, 180000)
context.expect(response.includes('World ready'), response)
})
await context.step('observe structures and objects above and below terrain', async () => {
await context.waitUntil(() => {
if (Math.abs(context.bot.entity.position.x) > 32 || Math.abs(context.bot.entity.position.z) > 32) return false
for (let x = -16; x < 32; x += 16) {
for (let z = -16; z < 32; z += 16) {
for (let y = 16; y <= 80; y += 16) {
if (!context.bot.blockAt(context.bot.entity.position.clone().set(x, y, z))) return false
}
}
}
return true
}, { label: 'Complete structure fixture chunks', timeoutMs: 60000, intervalMs: 100 })
const digest = createHash('sha256')
const counts = { emerald_block: 0, diamond_block: 0, blue_concrete: 0, gold_block: 0, oak_slab: 0 }
for (let x = -16; x < 32; x++) {
for (let z = -16; z < 32; z++) {
for (let y = 16; y <= 80; y++) {
const block = context.bot.blockAt(context.bot.entity.position.clone().set(x, y, z))
context.expect(block !== null, 'Every sampled structure block must be loaded')
digest.update(`${block.stateId}\n`)
if (Object.hasOwn(counts, block.name)) counts[block.name]++
if (block.name === 'emerald_block') context.expect(y > 64, 'Surface platform must be above terrain')
if (block.name === 'blue_concrete') context.expect(y < 40, 'Buried room must remain underground')
if (block.name === 'oak_slab') context.expect(block.getProperties().waterlogged === false,
'Object slabs placed on dry ground must clear waterlogging')
}
}
}
for (const [block, count] of Object.entries(counts)) {
context.expect(count > 0, `Generated fixture must contain ${block}`, { count })
}
context.report.structures = { counts, hash: digest.digest('hex') }
})
}
}
@@ -0,0 +1,74 @@
import { readFile, stat } from 'node:fs/promises'
import { createHash } from 'node:crypto'
import path from 'node:path'
export default {
name: 'iris-studio-open',
description: 'Measure repeated Studio entry and verify visible terrain, spectator mode, and world cleanup.',
async run(context) {
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
context.expect(/^isolated=true\s*$/m.test(source), 'Studio entry requires an isolated instance')
const match = /^([a-z0-9_-]+):([1-4])$/.exec(context.options.command ?? 'overworld:2')
context.expect(match, 'Expected pack:cycles with one to four cycles')
context.report.studioOpen = []
for (let cycle = 0; cycle < Number(match[2]); cycle++) {
await context.step(`open and close Studio ${cycle + 1}`, async () => {
const before = new Set((await context.observe()).worlds.map(world => world.id))
const start = Date.now()
let world
let center
await context.command(`/iris studio open ${match[1]} seed=1337`)
await context.waitUntil(async () => {
const observation = await context.observe()
const player = observation.players.find(entry => entry.username === context.bot.username)
world = observation.worlds.find(entry => entry.id === player?.world)
if (!world || before.has(world.id) || !/iris-[0-9a-f-]{36}$/.test(world.name)) return false
center = context.bot.entity.position.floored()
return world.loadedChunks > 0 && context.bot.blockAt(center) !== null && context.bot.game.gameMode === 'spectator'
}, { label: 'Studio arrival with terrain and spectator mode', timeoutMs: 180000, intervalMs: 100 })
const arrivalMs = Date.now() - start
context.expect(Number.isInteger(context.bot.game.minY) && Number.isInteger(context.bot.game.height),
'Studio dimension must report its vertical range')
await context.waitUntil(() => {
for (let x = -1; x <= 1; x++) {
for (let z = -1; z <= 1; z++) {
for (let y = context.bot.game.minY; y < context.bot.game.minY + context.bot.game.height; y += 16) {
if (context.bot.blockAt(center.offset(x, y - center.y, z)) === null) return false
}
}
}
return true
}, { label: 'Complete neighboring Studio columns', timeoutMs: 60000, intervalMs: 100 })
const digest = createHash('sha256')
let solid = 0
for (let x = -1; x <= 1; x++) {
for (let z = -1; z <= 1; z++) {
for (let y = context.bot.game.minY; y < context.bot.game.minY + context.bot.game.height; y++) {
const block = context.bot.blockAt(center.offset(x, y - center.y, z))
context.expect(block !== null, 'Studio entry column must be available')
digest.update(`${block.name}\n`)
if (!['air', 'cave_air', 'void_air', 'water', 'lava'].includes(block.name)) solid++
}
}
}
context.expect(solid > 0, 'Studio entry must contain solid terrain')
context.report.studioOpen.push({ cycle, arrivalMs, world: world.name, x: center.x, z: center.z,
terrainHash: digest.digest('hex'), solid })
const closed = await context.command('/iris studio close', /^Studio closed\.$|Studio close failed:/, 180000)
context.expect(closed === 'Studio closed.', closed)
await context.waitUntil(async () => !(await context.observe()).worlds.some(entry => entry.id === world.id),
{ label: 'Studio world unload', timeoutMs: 90000, intervalMs: 100 })
const transientName = /iris-[0-9a-f-]{36}$/.exec(world.name)[0]
const properties = await readFile(path.join(context.server.directory, 'server.properties'), 'utf8')
const level = /^level-name=(.+)$/m.exec(properties)?.[1].trim() ?? 'world'
const directory = path.join(context.server.directory, level, 'dimensions', 'iris', transientName)
await context.waitUntil(async () => {
try { await stat(directory); return false } catch (error) {
if (error.code === 'ENOENT') return true
throw error
}
}, { label: 'Studio directory cleanup', timeoutMs: 90000, intervalMs: 100 })
})
}
}
}
@@ -0,0 +1,98 @@
import { readFile } from 'node:fs/promises'
import path from 'node:path'
const worldName = 'lifecycle_probe'
const worldKey = `iris:${worldName}`
const storageName = `world_iris_${worldName}`
export default {
name: 'iris-world-lifecycle',
description: 'Create, enter, evacuate, reload, and remove an Iris world through the player commands.',
async run(context) {
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
context.expect(/^isolated=true\s*$/m.test(source), 'World lifecycle requires an isolated instance')
const wait = (predicate, label, timeoutMs = 180000) => context.waitUntil(predicate, {
label, timeoutMs, intervalMs: 250
})
const playerWorld = async () => {
const observation = await context.observe()
const player = observation.players.find(entry => entry.username === context.bot.username)
return observation.worlds.find(entry => entry.id === player?.world)?.name
}
const status = async () => {
const response = await context.command('/irislifecycle lifecycle-status', /IRIS_LIFECYCLE |NATIVE_FAILED/, 10000)
context.expect(!response.includes('NATIVE_FAILED'), response)
return response
}
const settledStatus = async expected => {
const response = await status()
return response.includes(expected) && response.includes('idle=true')
}
const assertEvacuated = async label => {
let destination
await wait(async () => {
destination = await playerWorld()
return Boolean(destination && destination !== storageName)
}, label, 30000)
context.expect(destination && destination !== storageName, label, { destination })
}
const assertSafeEntry = async () => {
await wait(async () => (await playerWorld()) === storageName, 'Player entered the lifecycle world')
await wait(() => {
const feet = context.bot.entity.position.floored()
const floor = context.bot.blockAt(feet.offset(0, -1, 0))
const body = context.bot.blockAt(feet)
const head = context.bot.blockAt(feet.offset(0, 1, 0))
return context.bot.entity.onGround && floor?.name === 'lime_wool'
&& body?.boundingBox === 'empty' && head?.boundingBox === 'empty'
}, 'Client sees solid terrain below unobstructed player entry', 30000)
const response = await context.command('/irislifecycle lifecycle-entry', /IRIS_LIFECYCLE_ENTRY|NATIVE_FAILED/, 10000)
context.expect(response.includes(`IRIS_LIFECYCLE_ENTRY world=${worldKey} seed=78264193 floor=lime_wool safe=true`), response)
}
await context.step('confirm disposable world is absent', async () => {
const response = await status()
context.expect(response.includes('loaded=false registered=false configured=false disk=false quarantines=0 cleanup=deleted idle=true'), response)
await context.command('/minecraft:gamemode survival @s')
await wait(() => context.bot.game.gameMode === 'survival', 'Survival mode', 10000)
})
await context.step('create through Iris and automatically enter safe terrain', async () => {
await context.command(`/iris create name=${worldName} type=native-terrain seed=78264193`)
await wait(async () => await settledStatus('loaded=true registered=true configured=true disk=true'), 'Created world registration and storage')
await assertSafeEntry()
})
await context.step('unload while the player is inside and verify evacuation', async () => {
context.expect((await playerWorld()) === storageName, 'Player must be inside the world before unload')
await context.command(`/iris unloadWorld ${worldKey}`)
await wait(async () => await settledStatus('loaded=false registered=true configured=true disk=true'), 'World unloaded with storage preserved')
await assertEvacuated('Unload evacuated the player to a loaded world')
})
await context.step('reload the saved world and enter it again', async () => {
await context.command(`/iris loadWorld ${worldName}`)
await wait(async () => await settledStatus('loaded=true registered=true configured=true disk=true'), 'World reloaded')
await context.command(`/iris tp ${worldKey}`)
await assertSafeEntry()
})
await context.step('remove the occupied world and verify disk cleanup', async () => {
await context.command(`/iris remove world=${worldName} delete=true`)
let finalStatus
await wait(async () => {
finalStatus = await status()
return finalStatus.includes('loaded=false registered=false configured=false disk=false')
&& /cleanup=(deleted|queued)\b/.test(finalStatus) && finalStatus.includes('idle=true')
}, 'Removal detached registrations and deleted or queued quarantine')
await assertEvacuated('Removal evacuated the player to a loaded world')
context.report.worldLifecycle = { world: worldKey, cleanup: /cleanup=(deleted|queued)\b/.exec(finalStatus)[1] }
})
if (process.env.IRIS_QA_PACK_URL) {
await context.step('download and overwrite the fixture pack through Iris', async () => {
const response = await context.command(
`/iris download link=${process.env.IRIS_QA_PACK_URL} overwrite=true`,
/Iris pack 'native-terrain' installed|Iris pack download failed\./,
120000
)
context.expect(response.includes("Iris pack 'native-terrain' installed"), response)
})
}
}
}
@@ -0,0 +1,126 @@
package art.arcane.iris.command;
import art.arcane.iris.pack.PackFingerprints;
import art.arcane.iris.Iris;
import art.arcane.iris.pack.PackValidationRegistry;
import art.arcane.iris.pack.PackValidationCache;
import art.arcane.iris.pack.PackValidationResult;
import art.arcane.iris.pack.PackValidator;
import art.arcane.iris.platform.bukkit.plugin.VolmitSender;
import org.junit.After;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.mockito.MockedStatic;
import java.io.File;
import java.nio.file.Files;
import java.util.List;
import java.lang.reflect.Method;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
public class CommandPackValidationPublicationTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@After
public void clearValidation() {
PackValidationRegistry.clear();
}
@Test
public void changedCachedPackCannotBePersistedAsFreshValidation() throws Exception {
File packs = temporaryFolder.newFolder("packs");
File root = new File(packs, "terrain");
Files.createDirectories(root.toPath());
File content = new File(root, "dimension.json");
Files.writeString(content.toPath(), "original");
File cacheFile = new File(temporaryFolder.getRoot(), "validation-cache.json");
Iris previousPlugin = Iris.instance;
Iris plugin = mock(Iris.class);
Iris.instance = plugin;
when(plugin.getDataFile("cache", "pack-validation.json")).thenReturn(cacheFile);
try (MockedStatic<Iris> iris = mockStatic(Iris.class);
MockedStatic<PackValidationCache> cache = mockStatic(PackValidationCache.class, CALLS_REAL_METHODS)) {
cache.when(PackValidationCache::contextFingerprint).thenReturn("context");
PackValidationResult valid = new PackValidationResult(root.getName(), List.of(), List.of(), 1L);
PackValidationRegistry.publish(valid);
PackValidationRegistry.publish(root.toPath(), valid,
PackFingerprints.computePackTreeFingerprint(root), "context");
Files.writeString(content.toPath(), "changed");
Method persist = CommandPack.class.getDeclaredMethod("persistValidationCache", File.class);
persist.setAccessible(true);
persist.invoke(new CommandPack(), packs);
assertFalse(cacheFile.exists());
Files.writeString(content.toPath(), "original");
persist.invoke(new CommandPack(), packs);
assertTrue(cacheFile.exists());
assertTrue(PackValidationCache.load(cacheFile.toPath(),
PackFingerprints.computePackContentSnapshot(packs).content(), "context",
List.of(root.getName())).orElseThrow().getFirst().isLoadable());
} finally {
Iris.instance = previousPlugin;
}
}
@Test
public void commandPublishesCanonicalRootValidation() throws Exception {
File root = temporaryFolder.newFolder("terrain");
PackValidationResult valid = new PackValidationResult(root.getName(), List.of(), List.of(), 1L);
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class)) {
validator.when(() -> PackValidator.validate(root)).thenReturn(valid);
assertSame(valid, new CommandPack().runValidate(mock(VolmitSender.class), root));
assertSame(valid, PackValidationRegistry.get(root.toPath()));
assertSame(valid, PackValidationRegistry.get(root.getName()));
}
}
@Test
public void mutationDuringValidationCannotRepublishStaleResult() throws Exception {
File root = temporaryFolder.newFolder("terrain");
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class);
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
validator.when(() -> PackValidator.validate(root)).thenAnswer(invocation -> {
try (PackValidationRegistry.RootMutation mutation = PackValidationRegistry.beginRootMutation(root.toPath())) {
return new PackValidationResult(root.getName(), List.of(), List.of(), 1L);
}
});
assertFalse(new CommandPack().runValidate(mock(VolmitSender.class), root).isLoadable());
assertNull(PackValidationRegistry.get(root.toPath()));
assertNull(PackValidationRegistry.get(root.getName()));
}
}
@Test
public void externalEditDuringValidationPublishesBlockedResult() throws Exception {
File root = temporaryFolder.newFolder("terrain");
File content = new File(root, "dimension.json");
Files.writeString(content.toPath(), "original");
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class);
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
validator.when(() -> PackValidator.validate(root)).thenAnswer(invocation -> {
Files.writeString(content.toPath(), "changed");
return new PackValidationResult(root.getName(), List.of(), List.of(), 1L);
});
assertFalse(new CommandPack().runValidate(mock(VolmitSender.class), root).isLoadable());
assertFalse(PackValidationRegistry.get(root.toPath()).isLoadable());
}
}
}
@@ -50,7 +50,7 @@ public final class ModdedDecoratorHooks implements DecoratorPlatformHooks.FaceFi
int xx = rX + dx;
int yy = y + dy;
int zz = rZ + dz;
return xx < 0 || xx > 15 || zz < 0 || zz > 15 || yy < 0 || yy > hunk.getHeight()
return xx < 0 || xx > 15 || zz < 0 || zz > 15 || yy < 0 || yy >= hunk.getHeight()
? null : hunk.get(xx, yy, zz);
}
}
@@ -70,6 +70,7 @@ import art.arcane.iris.modded.service.ModdedEngineMaintenanceService;
import art.arcane.iris.modded.service.ModdedEntitySpawnService;
import art.arcane.iris.modded.service.ModdedLogFilterService;
import art.arcane.iris.modded.service.ModdedPreservationService;
import art.arcane.iris.modded.service.ModdedPackUpdateService;
import art.arcane.iris.modded.service.ModdedSettingsHotloadService;
import art.arcane.iris.modded.service.ModdedStudioHotloadService;
import art.arcane.iris.modded.service.ModdedTreeFellerService;
@@ -477,6 +478,7 @@ public final class ModdedEngineBootstrap {
createdServices.register(ModdedLogFilterService.class, new ModdedLogFilterService());
createdServices.register(ModdedEngineMaintenanceService.class, new ModdedEngineMaintenanceService());
createdServices.register(ModdedSettingsHotloadService.class, new ModdedSettingsHotloadService());
createdServices.register(ModdedPackUpdateService.class, new ModdedPackUpdateService());
ModdedStudioHotloadService studioHotloadService = createdServices.register(
ModdedStudioHotloadService.class, new ModdedStudioHotloadService());
createdServices.register(ModdedChunkUpdateService.class, new ModdedChunkUpdateService());
@@ -27,7 +27,6 @@ import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
/**
* Owns the Iris generation pool and the single decision of whether this loader already runs chunk
@@ -43,7 +42,7 @@ public final class ModdedGenPool {
private static final long SHUTDOWN_DRAIN_MILLIS = 2_000L;
private static final AtomicInteger GEN_THREAD_SEQ = new AtomicInteger();
private static final ChunkSystem CHUNK_SYSTEM = detectChunkSystem();
private static final AtomicReference<ExecutorService> GEN_POOL = new AtomicReference<>(createGenPool());
private static ExecutorService generationPool = createGenPool();
private ModdedGenPool() {
}
@@ -63,39 +62,32 @@ public final class ModdedGenPool {
return CHUNK_SYSTEM.description();
}
static ExecutorService pool() {
ExecutorService pool = GEN_POOL.get();
if (pool != null && !pool.isShutdown()) {
return pool;
}
start();
ExecutorService restarted = GEN_POOL.get();
if (restarted == null) {
static synchronized ExecutorService pool() {
if (generationPool.isShutdown()) {
throw new RejectedExecutionException("Iris gen pool is shut down");
}
return restarted;
return generationPool;
}
static void start() {
while (true) {
ExecutorService current = GEN_POOL.get();
if (current != null && !current.isShutdown()) {
static synchronized void start() {
if (!generationPool.isShutdown()) {
return;
}
ExecutorService created = createGenPool();
if (GEN_POOL.compareAndSet(current, created)) {
return;
}
created.shutdownNow();
if (!generationPool.isTerminated()) {
throw new IllegalStateException("Iris gen pool cannot restart while prior workers are still active");
}
generationPool = createGenPool();
}
static void shutdown() {
ExecutorService pool = GEN_POOL.getAndSet(null);
if (pool == null) {
ExecutorService pool;
synchronized (ModdedGenPool.class) {
pool = generationPool;
if (pool.isTerminated()) {
return;
}
pool.shutdown();
}
try {
if (pool.awaitTermination(SHUTDOWN_DRAIN_MILLIS, TimeUnit.MILLISECONDS)) {
return;
@@ -20,13 +20,11 @@ package art.arcane.iris.modded;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedLoader;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.diagnostics.splash.IrisSplashComposer;
import art.arcane.iris.diagnostics.splash.IrisSplashRenderer;
import art.arcane.iris.spi.IrisLogging;
import java.io.File;
public final class ModdedIrisSplash {
@@ -34,18 +32,11 @@ public final class ModdedIrisSplash {
}
public static void print(NativeModdedLoader loader) {
printPacks(loader);
if (isLogoEnabled()) {
printLogo(loader);
}
}
private static void printPacks(NativeModdedLoader loader) {
File packFolder = IrisPlatforms.get().packsFolderNoCreate();
IrisSplashComposer.composePackLines(packFolder, IrisLogging::reportError)
.thenAccept(lines -> lines.forEach(IrisLogging::info));
}
private static void printLogo(NativeModdedLoader loader) {
String serverLine = loader.platformName() + " / Minecraft " + loader.minecraftVersion();
String[] splash = IrisSplashRenderer.renderPlain();
@@ -27,16 +27,15 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
public final class ModdedPrimaryWorldRouter {
private static final int TICK_INTERVAL = 20;
private static final Set<UUID> routed = ConcurrentHashMap.newKeySet();
private static final Set<UUID> inFlight = ConcurrentHashMap.newKeySet();
private static final ConcurrentMap<UUID, CompletableFuture<Boolean>> routes = new ConcurrentHashMap<>();
private static int tickCounter = 0;
private static volatile String loginRefusal;
private static volatile String absentPrimary;
@@ -45,8 +44,12 @@ public final class ModdedPrimaryWorldRouter {
}
public static void clear() {
routed.clear();
inFlight.clear();
routes.forEach((id, route) -> {
if (routes.remove(id, route)) {
route.cancel(false);
}
});
tickCounter = 0;
loginRefusal = null;
absentPrimary = null;
}
@@ -57,8 +60,10 @@ public final class ModdedPrimaryWorldRouter {
*/
public static void forget(UUID player) {
if (player != null) {
routed.remove(player);
inFlight.remove(player);
CompletableFuture<Boolean> route = routes.remove(player);
if (route != null) {
route.cancel(false);
}
}
}
@@ -110,36 +115,52 @@ public final class ModdedPrimaryWorldRouter {
return;
}
server.forEachPlayer(player -> {
server.forEachPlayer(player -> routePlayer(server, player, overworld, target, primary));
}
private static void routePlayer(NativeModdedServer server, NativeProtocolPlayer player,
NativeWorld overworld, NativeWorld target, String primary) {
UUID id = player.id();
if (routed.contains(id) || !inFlight.add(id)) {
if (routes.containsKey(id)) {
return;
}
CompletableFuture<Boolean> route = new CompletableFuture<>();
if (routes.putIfAbsent(id, route) != null) {
return;
}
route.whenComplete((success, failure) -> {
if (!Boolean.TRUE.equals(success)) {
routes.remove(id, route);
}
});
if (!player.isInWorld(overworld)) {
inFlight.remove(id);
routed.add(id);
route.complete(true);
return;
}
try {
CompletableFuture<Boolean> teleport = NativeWorldTeleport.teleport(player,
new NativeWorldTeleport.Destination(server, target, player.x(), Double.MIN_VALUE, player.z(),
ModdedTeleportDeadline.fromNow()));
teleport.whenComplete((success, failure) -> {
inFlight.remove(id);
if (Boolean.TRUE.equals(success) && failure == null) {
routed.add(id);
return;
route.whenComplete((success, failure) -> {
if (!Boolean.TRUE.equals(success) && !teleport.isDone()) {
teleport.cancel(false);
}
});
teleport.whenComplete((success, failure) -> {
if (failure != null) {
if (route.completeExceptionally(failure)) {
ModdedIrisLog.error("Iris failed to route player {} to primary world '{}'",
id, primary, failure);
}
});
} catch (Throwable e) {
inFlight.remove(id);
ModdedIrisLog.error("Iris failed to route player {} to primary world '{}'", id, primary, e);
} else {
route.complete(Boolean.TRUE.equals(success));
}
});
} catch (Throwable failure) {
if (route.completeExceptionally(failure)) {
ModdedIrisLog.error("Iris failed to route player {} to primary world '{}'", id, primary, failure);
}
}
}
/**
@@ -31,6 +31,7 @@ import art.arcane.iris.platform.protocol.IrisVisionRequestService;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.spi.protocol.IrisProtocol;
import art.arcane.iris.modded.service.ModdedPackUpdateService;
import java.util.Objects;
import java.util.UUID;
@@ -128,6 +129,10 @@ public final class ModdedProtocolHandler {
}
String sessionId = sessionId(player);
ModdedStartup.warnStartupFailuresTo(player);
ModdedPackUpdateService packUpdates = ModdedEngineBootstrap.services().service(ModdedPackUpdateService.class);
if (packUpdates != null) {
packUpdates.notifyPlayer(player);
}
IrisSessionRegistry current = registry;
ModdedProtocolTransport currentTransport = transport;
if (current == null || currentTransport == null) {
@@ -18,10 +18,13 @@
package art.arcane.iris.modded;
import art.arcane.iris.pack.PackFingerprints;
import art.arcane.iris.pack.BrokenPackException;
import art.arcane.iris.pack.PackDirectoryResolver;
import art.arcane.iris.pack.PackDownloader;
import art.arcane.iris.pack.PackValidationRegistry;
import art.arcane.iris.pack.PackValidationCache;
import art.arcane.iris.pack.PackValidationResult;
import art.arcane.iris.pack.PackValidator;
import art.arcane.iris.modded.command.ModdedPackCommands;
@@ -31,15 +34,11 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
public final class ModdedStartup {
private static final int COMPAT_BOOT_KEY_CAP = 3;
@@ -60,36 +59,9 @@ public final class ModdedStartup {
if (!PREPARED.compareAndSet(false, true)) {
return;
}
reportLegacyPacksDirectory();
validateAllPacks();
}
/**
* Older builds mkdir'd (and stale guidance sometimes populated) config/iris/packs, but modded
* packs live under config/irisworldgen/packs. Never auto-move user content: warn loudly when
* the legacy directory holds packs, and quietly remove it when it is empty.
*/
private static void reportLegacyPacksDirectory() {
try {
File legacy = ModdedEngineBootstrap.loader().configDir().resolve("iris").resolve("packs").toFile();
if (!legacy.isDirectory()) {
return;
}
if (!PackDirectoryResolver.listVisiblePackDirectories(legacy).isEmpty()) {
File real = art.arcane.iris.spi.IrisPlatforms.get().packsFolderNoCreate();
ModdedIrisLog.warn("Iris found packs under the legacy directory {} - modded packs load from {} only. Move them there.",
legacy.getAbsolutePath(), real.getAbsolutePath());
return;
}
String[] entries = legacy.list();
if (entries == null || entries.length == 0) {
legacy.delete();
}
} catch (Throwable e) {
ModdedIrisLog.debug("Iris legacy packs directory check failed", e);
}
}
/**
* Boot trigger for the forced datapack. Runs on its own daemon thread rather than the Iris scheduler:
* ModdedEngineBootstrap.start clears the async queue at SERVER_STARTING, which would silently drop this
@@ -140,8 +112,7 @@ public final class ModdedStartup {
}
for (File packDir : packDirs) {
try {
PackValidationResult result = PackValidator.validate(packDir);
PackValidationRegistry.publish(result);
PackValidationResult result = validatePack(packDir);
String minecraftVersion = IrisPlatforms.isBound() ? IrisPlatforms.get().minecraftVersion() : null;
String compatSummary = PackValidator.compatSummary(result, minecraftVersion);
String compatSuffix = compatSummary.isEmpty() ? "" : " " + compatSummary;
@@ -184,16 +155,15 @@ public final class ModdedStartup {
throw new BrokenPackException(pack, List.of(
"Pack folder does not exist under " + ModdedPackCommands.packsRoot().getAbsolutePath() + "."));
}
PackValidationResult cached = PackValidationRegistry.get(pack);
if (cached != null && cached.getValidatedAtMillis() >= newestModificationMillis(packDir.toPath())) {
// prepareForStartup already validated this pack and nothing in it changed since; re-validating per
// persistent dimension at boot costs a full pack parse each time.
return PackValidationRegistry.requireLoadable(pack);
}
Path packRoot = packDir.toPath();
try {
PackValidationResult result = PackValidator.validate(packDir);
PackValidationRegistry.publish(result);
return PackValidationRegistry.requireLoadable(pack);
PackValidationResult cached = PackValidationRegistry.getMatching(
packRoot, PackFingerprints.computePackTreeFingerprint(packDir));
if (cached != null) {
return PackValidationRegistry.requireLoadable(packRoot);
}
validatePack(packDir);
return PackValidationRegistry.requireLoadable(packRoot);
} catch (BrokenPackException e) {
throw e;
} catch (Throwable e) {
@@ -212,6 +182,42 @@ public final class ModdedStartup {
}
}
public static PackValidationResult validatePack(File packDir) {
Path packRoot = packDir.toPath();
PackValidationRegistry.ValidationTicket ticket = PackValidationRegistry.tryBeginValidation(packRoot);
if (ticket == null) {
throw new BrokenPackException(packDir.getName(), List.of("Pack content is currently being changed."));
}
String contentFingerprint = PackFingerprints.computePackTreeFingerprint(packDir);
String contextFingerprint = PackValidationCache.contextFingerprint();
PackValidationResult result;
try {
result = PackValidator.validate(packDir);
} catch (Throwable failure) {
ModdedIrisLog.error("Iris pack validation failed for '{}'", packDir.getName(), failure);
String detail = failure.getMessage();
if (detail == null || detail.isBlank()) {
detail = failure.getClass().getSimpleName();
}
result = new PackValidationResult(
packDir.getName(),
List.of("Pack validation failed with " + failure.getClass().getSimpleName() + ": " + detail),
List.of(),
System.currentTimeMillis());
}
if (!contentFingerprint.equals(PackFingerprints.computePackTreeFingerprint(packDir))
|| !contextFingerprint.equals(PackValidationCache.contextFingerprint())) {
PackValidationRegistry.remove(packRoot);
PackValidationRegistry.remove(packDir.getName());
throw new BrokenPackException(packDir.getName(), List.of("Pack content or validation context changed during validation."));
}
if (!PackValidationRegistry.publishIfCurrent(ticket, result, contentFingerprint, contextFingerprint)) {
throw new BrokenPackException(packDir.getName(), List.of("Pack content changed during validation."));
}
PackValidationRegistry.publish(result);
return result;
}
static void reinjectPersistentDimensions(NativeModdedServer server) {
List<ModdedDimensionRegistryStore.PersistentDimension> dimensions =
ModdedDimensionRegistryStore.loadForStartup(server);
@@ -266,23 +272,6 @@ public final class ModdedStartup {
+ GenerationRefusalNotice.summary(failure));
}
private static long newestModificationMillis(Path root) {
long newest = 0L;
try (Stream<Path> walk = Files.walk(root)) {
for (Path path : (Iterable<Path>) walk::iterator) {
BasicFileAttributes attributes = Files.readAttributes(path, BasicFileAttributes.class);
long modified = attributes.lastModifiedTime().toMillis();
if (modified > newest) {
newest = modified;
}
}
} catch (IOException | RuntimeException unreadable) {
ModdedIrisLog.debug("Iris could not stat {} for validation reuse; revalidating", root, unreadable);
return Long.MAX_VALUE;
}
return newest;
}
/**
* True once the first tick with a player list has re-injected the persistent dimensions. Before that a runtime
* dimension that is not loaded yet is still on its way.
@@ -296,7 +285,7 @@ public final class ModdedStartup {
* the console. Tell the operators who can actually act on it when they join.
*/
public static void warnStartupFailuresTo(NativeProtocolPlayer player) {
if (player == null || !player.isGameMaster()) {
if (player == null || (!player.isGameMaster() && !player.isServerOwner())) {
return;
}
for (String failure : DIMENSION_FAILURES) {
@@ -33,6 +33,7 @@ import art.arcane.iris.modded.IrisModdedChunkGenerator;
import art.arcane.iris.modded.ModdedDimensionManager;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldTeleport;
import art.arcane.iris.modded.ModdedEngineBootstrap;
import art.arcane.iris.modded.service.ModdedPackUpdateService;
import art.arcane.iris.modded.ModdedForcedDatapack;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedLoader;
import art.arcane.iris.modded.ModdedScheduler;
@@ -445,6 +446,12 @@ public final class IrisModdedCommands {
);
String completionMessage = downloadCompletionMessage(result);
if (result != null) {
if (result.changed()) {
ModdedPackUpdateService updates = ModdedEngineBootstrap.services().service(ModdedPackUpdateService.class);
if (updates != null) {
dispatchDownloadFeedback(source, updates::refresh);
}
}
if (completionMessage != null) {
dispatchDownloadFeedback(source, () -> ok(source, completionMessage));
}
@@ -18,6 +18,8 @@
package art.arcane.iris.modded.command;
import art.arcane.iris.modded.ModdedStartup;
import art.arcane.iris.modded.ModdedIrisLog;
import art.arcane.iris.pack.validation.PackCompatReport;
import art.arcane.iris.spi.IrisPlatforms;
@@ -25,7 +27,6 @@ import art.arcane.iris.pack.PackDirectoryResolver;
import art.arcane.iris.pack.PackResourceCleanup;
import art.arcane.iris.pack.PackValidationRegistry;
import art.arcane.iris.pack.PackValidationResult;
import art.arcane.iris.pack.PackValidator;
import art.arcane.iris.modded.ModdedEngineBootstrap;
import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
@@ -138,8 +139,7 @@ public final class ModdedPackCommands {
int broken = 0;
for (File target : targets) {
try {
PackValidationResult result = PackValidator.validate(target);
PackValidationRegistry.publish(result);
PackValidationResult result = ModdedStartup.validatePack(target);
if (!result.isLoadable()) {
broken++;
}
@@ -0,0 +1,137 @@
package art.arcane.iris.modded.service;
import art.arcane.iris.diagnostics.splash.IrisSplashPackScanner;
import art.arcane.iris.diagnostics.splash.IrisSplashPackScanner.SplashPackMetadata;
import art.arcane.iris.pack.BuiltInPackUpdates;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import java.io.File;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.function.Function;
public final class ModdedPackUpdateService implements ModdedTickableService {
private final Function<File, CompletableFuture<PackCheck>> checker;
private final Set<UUID> notified = new HashSet<>();
private CompletableFuture<PackCheck> pending;
private List<String> notices = List.of();
private File packsFolder;
private boolean enabled;
private boolean started;
public ModdedPackUpdateService() {
this(ModdedPackUpdateService::checkPacks);
}
ModdedPackUpdateService(Function<File, CompletableFuture<PackCheck>> checker) {
this.checker = checker;
}
@Override
public void onEnable() {
enabled = true;
}
@Override
public void onDisable() {
enabled = false;
if (pending != null) {
pending.cancel(true);
pending = null;
}
started = false;
notices = List.of();
notified.clear();
packsFolder = null;
}
@Override
public void onServerTick(NativeModdedServer server) {
if (!enabled || server == null || !server.hasPlayerList()) {
return;
}
if (!started) {
started = true;
packsFolder = IrisPlatforms.get().packsFolderNoCreate().getAbsoluteFile();
pending = checker.apply(packsFolder);
}
if (pending == null || !pending.isDone()) {
return;
}
CompletableFuture<PackCheck> completed = pending;
pending = null;
try {
publish(completed.join());
server.forEachPlayer(this::notifyPlayer);
} catch (CompletionException error) {
IrisLogging.reportError("Iris pack update check failed", error);
}
}
public void refresh() {
if (!enabled) {
return;
}
if (pending != null) {
pending.cancel(true);
pending = null;
}
started = false;
notices = List.of();
notified.clear();
}
public void notifyPlayer(NativeProtocolPlayer player) {
if (!enabled || notices.isEmpty() || player == null || !player.connected()
|| (!player.isGameMaster() && !player.isServerOwner()) || !notified.add(player.id())) {
return;
}
for (String notice : notices) {
player.sendMessage(notice);
}
}
private static CompletableFuture<PackCheck> checkPacks(File folder) {
return CompletableFuture.supplyAsync(() -> IrisSplashPackScanner.collect(folder, IrisLogging::reportError))
.thenCompose(packs -> BuiltInPackUpdates.check(packs.stream().map(SplashPackMetadata::name).toList())
.thenApply(updates -> new PackCheck(packs, updates)));
}
private void publish(PackCheck result) {
List<String> updates = new ArrayList<>();
IrisLogging.info("Iris packs: " + packsFolder);
for (SplashPackMetadata pack : result.packs()) {
BuiltInPackUpdates.Update update = result.updates().get(pack.name());
String version = pack.version().matches("[0-9]+") ? "v" + pack.version() : "version unknown";
IrisLogging.info(" " + pack.name() + " " + version
+ (update == null ? "" : update.suffix(pack.version())));
if (update != null && update.newerThan(pack.version())) {
updates.add("Iris " + pack.name() + " release metadata is outdated: installed v" + pack.version()
+ ", latest built-in release v" + update.version() + ".");
updates.add("Back up and review custom edits in " + new File(packsFolder, pack.name())
+ "; close Studio, then update with /iris download pack=" + pack.name() + " overwrite=true.");
updates.add("Existing worlds retain their recorded production pack; this updates the authoring pack for new worlds.");
}
}
notices = List.copyOf(updates);
for (String notice : notices) {
IrisLogging.warn(notice);
}
}
record PackCheck(List<SplashPackMetadata> packs, Map<String, BuiltInPackUpdates.Update> updates) {
PackCheck {
packs = List.copyOf(packs);
updates = Map.copyOf(updates);
}
}
}
@@ -57,6 +57,7 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
public final class ModdedStudioHotloadService implements ModdedTickableService, EnginePlatformHooks {
@@ -64,6 +65,8 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
private static final long POLL_MILLIS = 250L;
private static final long CHECK_LATCH_MILLIS = 1_000L;
private static final long RECENT_GENERATION_HOLDOFF_MILLIS = 2_000L;
private static final long SHUTDOWN_TIMEOUT_SECONDS = 30L;
private static final long INTERRUPT_DRAIN_TIMEOUT_SECONDS = 5L;
private final ConcurrentHashMap<String, Watch> watches = new ConcurrentHashMap<>();
private volatile ExecutorService executor;
@@ -81,9 +84,13 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
@Override
public void onEnable() {
if (executor != null) {
ExecutorService current = executor;
if (current != null && !current.isTerminated()) {
if (!current.isShutdown()) {
return;
}
throw new IllegalStateException("Iris Studio hotload cannot restart while prior workers are still active");
}
executor = Executors.newSingleThreadExecutor((Runnable task) -> {
Thread thread = new Thread(task, "Iris Studio Hotload");
thread.setDaemon(true);
@@ -96,10 +103,10 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
@Override
public void onDisable() {
ExecutorService active = executor;
executor = null;
if (active != null) {
active.shutdownNow();
if (!shutdownAndDrain(active)) {
throw new IllegalStateException("Iris Studio hotload worker remained active during service shutdown");
}
executor = null;
for (Watch watch : watches.values()) {
watch.close();
}
@@ -197,7 +204,7 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
@Override
public void onServerTick(NativeModdedServer server) {
ExecutorService active = executor;
if (active == null) {
if (active == null || active.isShutdown()) {
return;
}
long now = System.currentTimeMillis();
@@ -284,6 +291,30 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
&& !isPregeneratorActive(engine);
}
private static boolean shutdownAndDrain(ExecutorService active) {
if (active == null) {
return true;
}
active.shutdown();
try {
if (active.awaitTermination(SHUTDOWN_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
return true;
}
active.shutdownNow();
if (active.awaitTermination(INTERRUPT_DRAIN_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
return true;
}
ModdedIrisLog.error("Iris Studio hotload worker did not stop after shutdownNow",
new IllegalStateException("Iris Studio hotload worker is still active"));
return false;
} catch (InterruptedException exception) {
active.shutdownNow();
Thread.currentThread().interrupt();
ModdedIrisLog.error("Interrupted while draining Iris Studio hotload", exception);
return false;
}
}
private boolean throttled(IrisModdedChunkGenerator generator, Engine engine, long now) {
if (now - generator.lastChunkGenAt() < RECENT_GENERATION_HOLDOFF_MILLIS) {
return true;
@@ -39,6 +39,37 @@ public class ModdedClientPackageIsolationTest {
"art/arcane/iris/nativegen");
private static final int MINIMUM_SCANNED = 50;
@Test
public void clientPresentationCannotAccessPackOrWorldPersistence() throws IOException, URISyntaxException {
Path root = classesRoot();
Path directory = root.resolve(CLIENT_IRIS);
List<String> forbidden = List.of(
"art/arcane/iris/pack/",
"art/arcane/iris/world/storage/",
"art/arcane/iris/world/history/",
"art/arcane/iris/generation/runtime/",
"art/arcane/iris/configuration/",
"java/nio/file/",
"java/nio/channels/FileChannel",
"java/io/File",
"java/io/RandomAccessFile");
List<String> violations = new ArrayList<>();
int scanned = 0;
try (Stream<Path> walk = Files.walk(directory)) {
for (Path classFile : walk.filter(ModdedClientPackageIsolationTest::isClassFile).toList()) {
scanned++;
String bytecode = readAsLatin1(classFile);
for (String dependency : forbidden) {
if (bytecode.contains(dependency)) {
violations.add(root.relativize(classFile) + " -> " + dependency);
}
}
}
}
assertTrue("client presentation classes were not scanned", scanned >= 20);
assertEquals("client presentation can access persistent generation data: " + violations, List.of(), violations);
}
@Test
public void serverSidePackagesNeverReferenceClientOnlyTypes() throws IOException, URISyntaxException {
Path root = classesRoot();
@@ -0,0 +1,44 @@
package art.arcane.iris.modded;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeBlockFaces;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.volmlib.util.hunk.Hunk;
import org.junit.Test;
import org.mockito.MockedStatic;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
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.verify;
import static org.mockito.Mockito.when;
public class ModdedDecoratorHooksTest {
@Test
public void faceFixingExcludesTheUpperBoundaryWhileKeepingTheLastRowAccessible() {
NativeBlockState state = mock(NativeBlockState.class);
NativeBlockState lastRow = mock(NativeBlockState.class);
Hunk<NativeBlockState> hunk = mock(Hunk.class);
when(hunk.getHeight()).thenReturn(4);
when(hunk.get(1, 3, 2)).thenReturn(lastRow);
when(hunk.get(1, 4, 2)).thenThrow(new IndexOutOfBoundsException("upper boundary"));
try (MockedStatic<NativeBlockFaces> faces = mockStatic(NativeBlockFaces.class)) {
faces.when(() -> NativeBlockFaces.fixFaces(eq(state), any(NativeBlockFaces.Neighbors.class)))
.thenAnswer(invocation -> {
NativeBlockFaces.Neighbors neighbors = invocation.getArgument(1);
assertNull(neighbors.secondary(0, 1, 0));
assertSame(lastRow, neighbors.secondary(0, 0, 0));
return state;
});
assertSame(state, new ModdedDecoratorHooks().fixFaces(state, hunk, 1, 2, 99, 3, 108, null));
faces.verify(() -> NativeBlockFaces.fixFaces(eq(state), any(NativeBlockFaces.Neighbors.class)));
}
verify(hunk, never()).get(1, 4, 2);
verify(hunk).get(1, 3, 2);
}
}
@@ -0,0 +1,78 @@
package art.arcane.iris.modded;
import org.junit.Test;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
public class ModdedGenPoolTest {
@Test
public void executorLookupDoesNotRestartStoppedPool() throws Exception {
ModdedGenPool.start();
ExecutorService original = ModdedGenPool.pool();
ModdedGenPool.shutdown();
try {
ModdedGenPool.pool();
fail("Stopped generation pool must reject executor lookup");
} catch (RejectedExecutionException expected) {
assertTrue(original.isShutdown());
}
ModdedGenPool.start();
try {
assertNotSame(original, ModdedGenPool.pool());
} finally {
ModdedGenPool.shutdown();
}
}
@Test
public void restartWaitsForPreviousWorkersToTerminate() throws Exception {
ModdedGenPool.start();
ExecutorService original = ModdedGenPool.pool();
CountDownLatch entered = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
original.execute(() -> {
entered.countDown();
boolean released = false;
while (!released) {
try {
release.await();
released = true;
} catch (InterruptedException ignored) {
Thread.interrupted();
}
}
});
assertTrue(entered.await(5L, TimeUnit.SECONDS));
try {
Thread.currentThread().interrupt();
ModdedGenPool.shutdown();
assertTrue(Thread.interrupted());
assertFalse(original.isTerminated());
try {
ModdedGenPool.start();
fail("Generation pool must retain workers until they terminate");
} catch (IllegalStateException expected) {
assertFalse(original.isTerminated());
}
} finally {
Thread.interrupted();
release.countDown();
assertTrue(original.awaitTermination(5L, TimeUnit.SECONDS));
ModdedGenPool.start();
assertNotSame(original, ModdedGenPool.pool());
ExecutorService restarted = ModdedGenPool.pool();
ModdedGenPool.start();
assertSame(restarted, ModdedGenPool.pool());
ModdedGenPool.shutdown();
}
}
}
@@ -0,0 +1,143 @@
package art.arcane.iris.modded;
import art.arcane.iris.modded.command.ModdedPackCommands;
import art.arcane.iris.pack.BrokenPackException;
import art.arcane.iris.pack.PackValidationRegistry;
import art.arcane.iris.pack.PackValidationResult;
import art.arcane.iris.pack.PackValidator;
import org.junit.After;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.mockito.MockedStatic;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.attribute.FileTime;
import java.util.List;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.fail;
import static org.mockito.Mockito.mockStatic;
public class ModdedPackValidationPublicationTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@After
public void clearValidation() {
PackValidationRegistry.clear();
}
@Test
public void validationPublishesTheCanonicalRootAndPackName() throws Exception {
File root = temporaryFolder.newFolder("terrain");
File equivalent = new File(root, ".");
PackValidationResult result = loadable(root);
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class)) {
validator.when(() -> PackValidator.validate(equivalent)).thenReturn(result);
assertSame(result, ModdedStartup.validatePack(equivalent));
assertSame(result, PackValidationRegistry.requireLoadable(root.toPath()));
assertSame(result, PackValidationRegistry.requireLoadable(root.getName()));
}
}
@Test
public void rootInvalidationCannotReusePackNameValidation() throws Exception {
File root = temporaryFolder.newFolder("terrain");
PackValidationRegistry.publish(loadable(root));
PackValidationResult replacement = loadable(root);
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class);
MockedStatic<ModdedPackCommands> commands = mockStatic(ModdedPackCommands.class)) {
commands.when(ModdedPackCommands::packsRoot).thenReturn(root.getParentFile());
validator.when(() -> PackValidator.validate(root)).thenReturn(replacement);
assertSame(replacement, ModdedStartup.requirePackForWorldCreation(root.getName()));
validator.verify(() -> PackValidator.validate(root));
assertSame(replacement, PackValidationRegistry.requireLoadable(root.toPath()));
}
}
@Test
public void changedContentWithPreservedTimestampCannotReuseValidation() throws Exception {
File root = temporaryFolder.newFolder("terrain");
File content = new File(root, "dimension.json");
Files.writeString(content.toPath(), "valid");
PackValidationResult original = loadable(root);
PackValidationResult broken = new PackValidationResult(root.getName(), List.of("invalid"), List.of(),
System.currentTimeMillis());
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class);
MockedStatic<ModdedPackCommands> commands = mockStatic(ModdedPackCommands.class)) {
commands.when(ModdedPackCommands::packsRoot).thenReturn(root.getParentFile());
validator.when(() -> PackValidator.validate(root)).thenReturn(original, broken);
ModdedStartup.validatePack(root);
Files.writeString(content.toPath(), "wrong");
Files.setLastModifiedTime(content.toPath(), FileTime.fromMillis(1L));
Files.setLastModifiedTime(root.toPath(), FileTime.fromMillis(1L));
assertThrows(BrokenPackException.class,
() -> ModdedStartup.requirePackForWorldCreation(root.getName()));
}
}
@Test
public void externalEditDuringValidationCannotPublishMixedSnapshot() throws Exception {
File root = temporaryFolder.newFolder("terrain");
File content = new File(root, "dimension.json");
Files.writeString(content.toPath(), "original");
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class)) {
validator.when(() -> PackValidator.validate(root)).thenAnswer(invocation -> {
Files.writeString(content.toPath(), "changed");
return loadable(root);
});
assertThrows(BrokenPackException.class, () -> ModdedStartup.validatePack(root));
assertNull(PackValidationRegistry.get(root.toPath()));
}
}
@Test
public void mutationDuringValidationPreventsStalePublication() throws Exception {
File root = temporaryFolder.newFolder("terrain");
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class)) {
validator.when(() -> PackValidator.validate(root)).thenAnswer(invocation -> {
try (PackValidationRegistry.RootMutation mutation = PackValidationRegistry.beginRootMutation(root.toPath())) {
return loadable(root);
}
});
try {
ModdedStartup.validatePack(root);
fail("Changed pack must reject stale validation");
} catch (BrokenPackException expected) {
assertNull(PackValidationRegistry.get(root.toPath()));
assertNull(PackValidationRegistry.get(root.getName()));
}
}
}
@Test
public void validationFailurePublishesBlockedRoot() throws Exception {
File root = temporaryFolder.newFolder("terrain");
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class);
MockedStatic<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) {
validator.when(() -> PackValidator.validate(root)).thenThrow(new IllegalStateException("Invalid terrain"));
PackValidationResult result = ModdedStartup.validatePack(root);
assertFalse(result.isLoadable());
assertSame(result, PackValidationRegistry.get(root.toPath()));
assertSame(result, PackValidationRegistry.get(root.getName()));
}
}
private static PackValidationResult loadable(File root) {
return new PackValidationResult(root.getName(), List.of(), List.of(), System.currentTimeMillis());
}
}
@@ -4,7 +4,10 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldTeleport;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import net.minecraft.SharedConstants;
import net.minecraft.server.Bootstrap;
import org.junit.After;
import org.junit.BeforeClass;
import org.junit.Test;
import org.mockito.MockedStatic;
import org.mockito.invocation.InvocationOnMock;
@@ -38,6 +41,12 @@ public class ModdedPrimaryWorldRouterTest {
private static final int TICK_INTERVAL = 20;
private static final String PRIMARY = "iris:primary";
@BeforeClass
public static void bootstrap() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
}
@After
public void clearRouter() {
ModdedPrimaryWorldRouter.clear();
@@ -233,6 +242,41 @@ public class ModdedPrimaryWorldRouterTest {
return new ModdedDimensionRegistryStore.PersistentDimension(PRIMARY, "overworld", "overworld", 1337L);
}
@Test
public void disconnectCancelsOldRoutingWithoutAffectingReconnectedPlayer() {
NativeModdedServer server = mock(NativeModdedServer.class);
NativeWorld overworld = mock(NativeWorld.class);
NativeWorld primary = mock(NativeWorld.class);
when(overworld.nativeHandle()).thenReturn(new Object());
when(primary.nativeHandle()).thenReturn(new Object());
when(server.overworld()).thenReturn(overworld);
NativeProtocolPlayer player = mock(NativeProtocolPlayer.class);
UUID id = UUID.randomUUID();
when(player.id()).thenReturn(id);
when(player.isInWorld(overworld)).thenReturn(true);
onlinePlayers(server, player);
CompletableFuture<Boolean> oldRoute = new CompletableFuture<>();
CompletableFuture<Boolean> newRoute = new CompletableFuture<>();
ModdedModConfig config = config(true, PRIMARY);
try (MockedStatic<ModdedModConfig> configs = mockStatic(ModdedModConfig.class);
MockedStatic<ModdedDimensionManager> manager = mockStatic(ModdedDimensionManager.class);
MockedStatic<NativeWorldTeleport> teleport = mockStatic(NativeWorldTeleport.class)) {
configs.when(ModdedModConfig::get).thenReturn(config);
manager.when(() -> ModdedDimensionManager.level(server, PRIMARY)).thenReturn(primary);
teleport.when(() -> NativeWorldTeleport.teleport(any(), any())).thenReturn(oldRoute, newRoute);
evaluate(server);
ModdedPrimaryWorldRouter.forget(id);
assertTrue(oldRoute.isCancelled());
evaluate(server);
oldRoute.complete(true);
evaluate(server);
teleport.verify(() -> NativeWorldTeleport.teleport(any(), any()), times(2));
assertFalse(newRoute.isDone());
ModdedPrimaryWorldRouter.clear();
assertTrue(newRoute.isCancelled());
}
}
private static void evaluate(NativeModdedServer server) {
for (int tick = 0; tick < TICK_INTERVAL; tick++) {
ModdedPrimaryWorldRouter.tick(server);
@@ -57,6 +57,11 @@ public class ModdedStartupReinjectFailureTest {
NativeProtocolPlayer player = mock(NativeProtocolPlayer.class);
ModdedStartup.warnStartupFailuresTo(operator);
ModdedStartup.warnStartupFailuresTo(player);
NativeProtocolPlayer owner = mock(NativeProtocolPlayer.class);
when(owner.isServerOwner()).thenReturn(true);
ModdedStartup.warnStartupFailuresTo(owner);
verify(owner).sendMessage(argThat((String message) -> message.contains("iris:moon")
&& message.contains("generation history is unusable")));
verify(operator).sendMessage(argThat((String message) -> message.contains("iris:moon")
&& message.contains("generation history is unusable")));
@@ -21,6 +21,8 @@ import net.minecraft.server.level.ServerChunkCache;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.level.Level;
import net.minecraft.world.entity.EntityDimensions;
import net.minecraft.world.entity.Pose;
import org.junit.BeforeClass;
import org.junit.Test;
import org.mockito.MockedStatic;
@@ -98,6 +100,7 @@ public class IrisModdedCommandsTeleportTest {
when(server.getLevel(dimension)).thenReturn(serverLevel);
ServerPlayer serverPlayer = mock(ServerPlayer.class);
when(serverPlayer.getUUID()).thenReturn(UUID.randomUUID());
when(serverPlayer.getDimensions(Pose.STANDING)).thenReturn(EntityDimensions.scalable(0.6F, 1.8F));
NativeWorld level = mock(NativeWorld.class);
when(level.nativeHandle()).thenReturn(serverLevel);
@@ -0,0 +1,209 @@
package art.arcane.iris.modded.service;
import art.arcane.iris.diagnostics.splash.IrisSplashPackScanner.SplashPackMetadata;
import art.arcane.iris.pack.BuiltInPackUpdates;
import art.arcane.iris.spi.IrisPlatform;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import net.minecraft.SharedConstants;
import net.minecraft.server.Bootstrap;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.permissions.LevelBasedPermissionSet;
import org.junit.BeforeClass;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.io.File;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.Mockito.doAnswer;
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 ModdedPackUpdateServiceTest {
@BeforeClass
public static void bootstrap() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
}
@Test
public void pendingCheckNotifiesOnlineAndLaterOperatorsOnceOnServerTick() {
CompletableFuture<ModdedPackUpdateService.PackCheck> pending = new CompletableFuture<>();
ModdedPackUpdateService service = new ModdedPackUpdateService(folder -> pending);
NativeProtocolPlayer online = player(true, false);
NativeModdedServer server = server(online);
File root = new File("config/irisworldgen/packs").getAbsoluteFile();
try (MockedStatic<IrisPlatforms> platforms = platform(root)) {
service.onEnable();
service.onServerTick(server);
service.notifyPlayer(online);
verify(online, never()).sendMessage(anyString());
pending.complete(outdated());
verify(online, never()).sendMessage(anyString());
service.onServerTick(server);
service.onServerTick(server);
service.notifyPlayer(online);
verify(online, times(1)).sendMessage(contains("installed v1, latest built-in release v2"));
verify(online, times(1)).sendMessage(contains(new File(root, "overworld").toString()));
verify(online, times(1)).sendMessage(contains("/iris download pack=overworld overwrite=true"));
verify(online, never()).sendMessage(contains("plugins/"));
NativeProtocolPlayer later = player(true, false);
service.notifyPlayer(later);
verify(later).sendMessage(contains("release metadata is outdated"));
service.onDisable();
}
}
@Test
public void integratedOwnerWithoutCheatsReceivesNoticeAndRemoteOrdinaryPlayersDoNot() {
NativeProtocolPlayer owner = player(false, true);
NativeProtocolPlayer remote = player(false, false);
ModdedPackUpdateService service = new ModdedPackUpdateService(folder -> CompletableFuture.completedFuture(outdated()));
try (MockedStatic<IrisPlatforms> platforms = platform(new File("config/irisworldgen/packs"))) {
service.onEnable();
service.onServerTick(server(owner));
verify(owner).sendMessage(contains("release metadata is outdated"));
service.notifyPlayer(remote);
verify(remote, never()).sendMessage(anyString());
service.onDisable();
}
}
@Test
public void disableCancelsCheckAndRestartDoesNotPublishOldNotices() {
CompletableFuture<ModdedPackUpdateService.PackCheck> first = new CompletableFuture<>();
CompletableFuture<ModdedPackUpdateService.PackCheck> second = new CompletableFuture<>();
AtomicInteger checks = new AtomicInteger();
ModdedPackUpdateService service = new ModdedPackUpdateService(folder -> checks.getAndIncrement() == 0 ? first : second);
NativeProtocolPlayer operator = player(true, false);
NativeModdedServer server = server(operator);
try (MockedStatic<IrisPlatforms> platforms = platform(new File("config/irisworldgen/packs"))) {
service.onEnable();
service.onServerTick(server);
service.onDisable();
assertTrue(first.isCancelled());
assertFalse(first.complete(outdated()));
service.onEnable();
service.onServerTick(server);
second.complete(new ModdedPackUpdateService.PackCheck(List.of(), Map.of()));
service.onServerTick(server);
verify(operator, never()).sendMessage(anyString());
assertEquals(2, checks.get());
service.onDisable();
}
}
@Test
public void currentCustomUnknownAndUnavailableVersionsNeverBecomeOutdated() {
ModdedPackUpdateService.PackCheck result = new ModdedPackUpdateService.PackCheck(List.of(
new SplashPackMetadata("overworld", "unknown"),
new SplashPackMetadata("underworld", "3"),
new SplashPackMetadata("custom", "1")), Map.of(
"overworld", new BuiltInPackUpdates.Update("2"),
"underworld", new BuiltInPackUpdates.Update("2")));
ModdedPackUpdateService service = new ModdedPackUpdateService(folder -> CompletableFuture.completedFuture(result));
NativeProtocolPlayer operator = player(true, false);
try (MockedStatic<IrisPlatforms> platforms = platform(new File("config/irisworldgen/packs"))) {
service.onEnable();
service.onServerTick(server(operator));
verify(operator, never()).sendMessage(anyString());
service.onDisable();
}
}
@Test
public void successfulAuthoringUpdateClearsOldNoticesAndRescansInstalledVersion() {
CompletableFuture<ModdedPackUpdateService.PackCheck> current = new CompletableFuture<>();
AtomicInteger checks = new AtomicInteger();
ModdedPackUpdateService service = new ModdedPackUpdateService(folder -> checks.getAndIncrement() == 0
? CompletableFuture.completedFuture(outdated()) : current);
NativeProtocolPlayer operator = player(true, false);
NativeModdedServer server = server(operator);
try (MockedStatic<IrisPlatforms> platforms = platform(new File("config/irisworldgen/packs"))) {
service.onEnable();
service.onServerTick(server);
verify(operator).sendMessage(contains("release metadata is outdated"));
service.refresh();
NativeProtocolPlayer later = player(true, false);
service.notifyPlayer(later);
verify(later, never()).sendMessage(anyString());
service.onServerTick(server);
current.complete(new ModdedPackUpdateService.PackCheck(List.of(new SplashPackMetadata("overworld", "2")),
Map.of("overworld", new BuiltInPackUpdates.Update("2"))));
service.onServerTick(server);
service.notifyPlayer(later);
verify(later, never()).sendMessage(anyString());
assertEquals(2, checks.get());
service.onDisable();
}
}
@Test
public void nativeOwnerCheckDoesNotGrantGameMasterPermissions() {
ServerPlayer handle = mock(ServerPlayer.class);
ServerLevel level = mock(ServerLevel.class);
MinecraftServer server = mock(MinecraftServer.class);
when(handle.level()).thenReturn(level);
when(level.getServer()).thenReturn(server);
when(handle.permissions()).thenReturn(LevelBasedPermissionSet.ALL);
NativeProtocolPlayer player = NativeProtocolPlayer.fromHandle(handle);
when(server.isSingleplayerOwner(handle.nameAndId())).thenReturn(true);
assertTrue(player.isServerOwner());
assertFalse(player.isGameMaster());
when(server.isSingleplayerOwner(handle.nameAndId())).thenReturn(false);
assertFalse(player.isServerOwner());
assertFalse(player.isGameMaster());
}
private static ModdedPackUpdateService.PackCheck outdated() {
return new ModdedPackUpdateService.PackCheck(List.of(new SplashPackMetadata("overworld", "1")),
Map.of("overworld", new BuiltInPackUpdates.Update("2")));
}
private static MockedStatic<IrisPlatforms> platform(File root) {
IrisPlatform platform = mock(IrisPlatform.class);
when(platform.packsFolderNoCreate()).thenReturn(root);
MockedStatic<IrisPlatforms> platforms = mockStatic(IrisPlatforms.class);
platforms.when(IrisPlatforms::get).thenReturn(platform);
return platforms;
}
private static NativeProtocolPlayer player(boolean operator, boolean owner) {
NativeProtocolPlayer player = mock(NativeProtocolPlayer.class);
when(player.id()).thenReturn(UUID.randomUUID());
when(player.connected()).thenReturn(true);
when(player.isGameMaster()).thenReturn(operator);
when(player.isServerOwner()).thenReturn(owner);
return player;
}
@SuppressWarnings("unchecked")
private static NativeModdedServer server(NativeProtocolPlayer player) {
NativeModdedServer server = mock(NativeModdedServer.class);
when(server.hasPlayerList()).thenReturn(true);
doAnswer(invocation -> {
invocation.getArgument(0, Consumer.class).accept(player);
return null;
}).when(server).forEachPlayer(any());
return server;
}
}
@@ -4,14 +4,65 @@ import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.modded.ModdedScheduler;
import art.arcane.volmlib.util.collection.KList;
import org.junit.Test;
import org.mockito.InOrder;
import java.lang.reflect.Field;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class ModdedStudioHotloadServiceTest {
@Test
public void shutdownDrainsWorkerBeforeAllowingRestart() throws Exception {
ModdedStudioHotloadService service = new ModdedStudioHotloadService();
ExecutorService executor = mock(ExecutorService.class);
setExecutor(service, executor);
when(executor.awaitTermination(30L, TimeUnit.SECONDS)).thenReturn(false);
when(executor.awaitTermination(5L, TimeUnit.SECONDS)).thenReturn(true);
service.onDisable();
InOrder order = inOrder(executor);
order.verify(executor).shutdown();
order.verify(executor).awaitTermination(30L, TimeUnit.SECONDS);
order.verify(executor).shutdownNow();
order.verify(executor).awaitTermination(5L, TimeUnit.SECONDS);
service.onEnable();
service.onDisable();
}
@Test
public void undrainedWorkerPreventsRestart() throws Exception {
ModdedStudioHotloadService service = new ModdedStudioHotloadService();
ExecutorService executor = mock(ExecutorService.class);
setExecutor(service, executor);
when(executor.isShutdown()).thenReturn(true);
try {
service.onDisable();
fail("Hotload shutdown must report a worker that did not terminate");
} catch (IllegalStateException expected) {
verify(executor).shutdownNow();
}
try {
service.onEnable();
fail("Hotload must not start another worker before the previous worker terminates");
} catch (IllegalStateException expected) {
assertFalse(executor.isTerminated());
}
when(executor.isTerminated()).thenReturn(true);
service.onEnable();
service.onDisable();
}
@Test
public void detectsDatapackImportsAcrossLoadedDimensions() {
IrisDimension empty = new IrisDimension();
@@ -45,4 +96,10 @@ public class ModdedStudioHotloadServiceTest {
scheduler.shutdown();
}
}
private static void setExecutor(ModdedStudioHotloadService service, ExecutorService executor) throws Exception {
Field field = ModdedStudioHotloadService.class.getDeclaredField("executor");
field.setAccessible(true);
field.set(service, executor);
}
}
@@ -0,0 +1,142 @@
package art.arcane.volmlib.nativelib.minecraft26_2.modded;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import net.minecraft.SharedConstants;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.Bootstrap;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerChunkCache;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.EntityDimensions;
import net.minecraft.world.entity.Pose;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.AABB;
import org.junit.BeforeClass;
import org.junit.Test;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class NativeTeleportSafetyTest {
@BeforeClass
public static void bootstrap() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
}
@Test
public void rejectsTallAndHazardousSupportsAndAcceptsSolidFloor() {
ServerPlayer player = player();
ServerLevel level = level();
for (BlockState support : new BlockState[]{Blocks.OAK_FENCE.defaultBlockState(),
Blocks.MAGMA_BLOCK.defaultBlockState(), Blocks.CACTUS.defaultBlockState()}) {
setSupport(level, support);
assertFalse(NativeWorldTeleport.isSafeStandingPosition(level, player, 8.5D, 4, 8.5D));
}
setSupport(level, Blocks.STONE.defaultBlockState());
assertTrue(NativeWorldTeleport.isSafeStandingPosition(level, player, 8.5D, 4, 8.5D));
}
@Test
public void fractionalPositionUsesWholePlayerBodyAndRequiresNeighborChunk() {
ServerPlayer player = player();
ServerLevel level = level();
setSupport(level, Blocks.STONE.defaultBlockState());
when(level.noCollision(any(), any(AABB.class))).thenAnswer(invocation -> {
AABB body = invocation.getArgument(1);
return body.maxX <= 16D;
});
assertTrue(NativeWorldTeleport.isSafeStandingPosition(level, player, 15.5D, 4, 8.5D));
assertFalse(NativeWorldTeleport.isSafeStandingPosition(level, player, 15.9D, 4, 8.5D));
when(level.noCollision(any(), any(AABB.class))).thenReturn(true);
when(level.getChunkSource().hasChunk(1, 0)).thenReturn(false);
assertFalse(NativeWorldTeleport.isSafeStandingPosition(level, player, 15.9D, 4, 8.5D));
EntityDimensions dimensions = player.getDimensions(Pose.STANDING);
assertEquals(0, NativeWorldTeleport.warmRadius(dimensions.makeBoundingBox(15.5D, 4D, 8.5D), new ChunkPos(0, 0)));
assertEquals(1, NativeWorldTeleport.warmRadius(dimensions.makeBoundingBox(15.9D, 4D, 8.5D), new ChunkPos(0, 0)));
assertEquals(1, NativeWorldTeleport.warmRadius(dimensions.makeBoundingBox(-16.1D, 4D, -8.5D), new ChunkPos(-2, -1)));
}
@Test
public void scaledStandingBodyStaysInsideActualWorldHeight() {
ServerLevel level = level();
setSupport(level, Blocks.STONE.defaultBlockState());
ServerPlayer player = player();
when(player.getDimensions(Pose.STANDING)).thenReturn(EntityDimensions.scalable(0.6F, 13F));
assertFalse(NativeWorldTeleport.isSafeStandingPosition(level, player, 8.5D, 4, 8.5D));
when(player.getDimensions(Pose.STANDING)).thenReturn(EntityDimensions.scalable(0.6F, 12F));
assertTrue(NativeWorldTeleport.isSafeStandingPosition(level, player, 8.5D, 4, 8.5D));
assertFalse(NativeWorldTeleport.isSafeStandingPosition(level, player, 8.5D, 0, 8.5D));
}
@Test
public void reconnectDuringChunkWarmupDoesNotTeleportReplacementSession() {
MinecraftServer server = mock(MinecraftServer.class, RETURNS_DEEP_STUBS);
when(server.isSameThread()).thenReturn(true);
ServerLevel level = level();
ResourceKey<Level> key = ResourceKey.create(Registries.DIMENSION, Identifier.parse("iris:test"));
when(level.getServer()).thenReturn(server);
when(level.dimension()).thenReturn(key);
when(server.getLevel(key)).thenReturn(level);
ServerChunkCache chunks = level.getChunkSource();
when(chunks.hasChunk(anyInt(), anyInt())).thenReturn(false);
CompletableFuture<Void> loaded = new CompletableFuture<>();
doReturn(loaded).when(chunks).addTicketAndLoadWithRadius(any(), any(), anyInt());
ServerPlayer original = player();
UUID id = UUID.randomUUID();
when(original.getUUID()).thenReturn(id);
when(server.getPlayerList().getPlayer(id)).thenReturn(original);
NativeWorld world = mock(NativeWorld.class);
when(world.nativeHandle()).thenReturn(level);
CompletableFuture<Boolean> result = NativeWorldTeleport.teleport(NativeProtocolPlayer.fromHandle(original),
new NativeWorldTeleport.Destination(NativeModdedServer.fromHandle(server), world, 8.5D, 4D, 8.5D, 0L));
assertFalse(result.isDone());
ServerPlayer replacement = player();
when(server.getPlayerList().getPlayer(id)).thenReturn(replacement);
loaded.complete(null);
assertFalse(result.join());
verify(chunks).removeTicketWithRadius(any(), any(), anyInt());
}
private ServerPlayer player() {
ServerPlayer player = mock(ServerPlayer.class);
when(player.getDimensions(Pose.STANDING)).thenReturn(EntityDimensions.scalable(0.6F, 1.8F));
return player;
}
private ServerLevel level() {
ServerLevel level = mock(ServerLevel.class);
ServerChunkCache chunks = mock(ServerChunkCache.class);
when(level.getChunkSource()).thenReturn(chunks);
when(level.getMinY()).thenReturn(0);
when(level.getMaxY()).thenReturn(16);
when(chunks.hasChunk(anyInt(), anyInt())).thenReturn(true);
when(level.noCollision(any(), any(AABB.class))).thenReturn(true);
return level;
}
private void setSupport(ServerLevel level, BlockState support) {
when(level.getBlockState(any(BlockPos.class))).thenAnswer(invocation -> {
BlockPos position = invocation.getArgument(0);
return position.getY() == 3 ? support : Blocks.AIR.defaultBlockState();
});
}
}
-2
View File
@@ -379,8 +379,6 @@ List<String> unusedVolmLibClasses = [
'art/arcane/volmlib/util/bukkit/Events',
'art/arcane/volmlib/util/json/SingleCollectionTypeFactory',
'art/arcane/volmlib/util/collection/StateList',
'art/arcane/iris/pack/IrisPack',
'art/arcane/iris/pack/IrisPackRepository',
'art/arcane/iris/world/task/jobs/DownloadJob',
'art/arcane/iris/world/task/jobs/JobCollection',
'art/arcane/iris/world/task/jobs/ParallelQueueJob',
@@ -54,7 +54,7 @@ public final class IrisSplashPackScanner {
JsonObject json = JsonParser.parseReader(reader).getAsJsonObject();
JsonElement version = json.get("version");
if (version == null || !version.isJsonPrimitive()) {
return null;
return new SplashPackMetadata(dimName, "unknown");
}
return new SplashPackMetadata(dimName, version.getAsString());
@@ -96,8 +96,6 @@ public class IrisBiome extends IrisRegistrant implements Rarity {
private static final int CHILDREN_SEED_SALT = 2137;
private final transient AtomicCache<KMap<String, IrisBiomeGeneratorLink>> genCache = new AtomicCache<>();
private final transient AtomicCache<KMap<String, Integer>> genCacheMax = new AtomicCache<>();
private final transient AtomicCache<KMap<String, Integer>> genCacheMin = new AtomicCache<>();
private final transient AtomicCache<KList<IrisObjectPlacement>> surfaceObjectsCache = new AtomicCache<>();
private final transient AtomicCache<KList<IrisObjectPlacement>> carveObjectsCache = new AtomicCache<>();
private final transient AtomicCache<Color> cacheColor = new AtomicCache<>();
@@ -415,12 +413,12 @@ public class IrisBiome extends IrisRegistrant implements Rarity {
return getCustomDerivitives() != null && getCustomDerivitives().isNotEmpty();
}
public double getGenLinkMax(String loadKey, Engine engine) {
return IrisBiomeGenLinks.getGenLinkMax(this, loadKey, engine);
public double getGenLinkMax(String loadKey) {
return IrisBiomeGenLinks.getGenLinkMax(this, loadKey);
}
public double getGenLinkMin(String loadKey, Engine engine) {
return IrisBiomeGenLinks.getGenLinkMin(this, loadKey, engine);
public double getGenLinkMin(String loadKey) {
return IrisBiomeGenLinks.getGenLinkMin(this, loadKey);
}
public IrisBiomeGeneratorLink getGenLink(String loadKey) {
@@ -19,7 +19,6 @@
package art.arcane.iris.generation.biome;
import art.arcane.volmlib.util.cache.AtomicCache;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.volmlib.util.collection.KMap;
/**
@@ -31,24 +30,14 @@ final class IrisBiomeGenLinks {
private IrisBiomeGenLinks() {
}
static double getGenLinkMax(IrisBiome biome, String loadKey, Engine engine) {
if (loadKey == null || loadKey.isBlank()) {
return 0;
static double getGenLinkMax(IrisBiome biome, String loadKey) {
IrisBiomeGeneratorLink link = getGenLink(biome, loadKey);
return link == null ? 0 : link.getMax();
}
Integer v = maxIndex(biome).get(loadKey);
return v == null ? 0 : v;
}
static double getGenLinkMin(IrisBiome biome, String loadKey, Engine engine) {
if (loadKey == null || loadKey.isBlank()) {
return 0;
}
Integer v = minIndex(biome).get(loadKey);
return v == null ? 0 : v;
static double getGenLinkMin(IrisBiome biome, String loadKey) {
IrisBiomeGeneratorLink link = getGenLink(biome, loadKey);
return link == null ? 0 : link.getMin();
}
static IrisBiomeGeneratorLink getGenLink(IrisBiome biome, String loadKey) {
@@ -59,57 +48,6 @@ final class IrisBiomeGenLinks {
return linkIndex(biome).get(loadKey);
}
private static KMap<String, Integer> maxIndex(IrisBiome biome) {
AtomicCache<KMap<String, Integer>> cache = biome.getGenCacheMax();
KMap<String, Integer> cached = cache.getIfPresent();
if (cached != null) {
return cached;
}
return cache.aquire(() ->
{
KMap<String, Integer> l = new KMap<>();
for (IrisBiomeGeneratorLink i : biome.getGenerators()) {
String generatorKey = i.getGenerator();
if (generatorKey == null || generatorKey.isBlank()) {
continue;
}
l.put(generatorKey, i.getMax());
}
return l;
});
}
private static KMap<String, Integer> minIndex(IrisBiome biome) {
AtomicCache<KMap<String, Integer>> cache = biome.getGenCacheMin();
KMap<String, Integer> cached = cache.getIfPresent();
if (cached != null) {
return cached;
}
return cache.aquire(() ->
{
KMap<String, Integer> l = new KMap<>();
for (IrisBiomeGeneratorLink i : biome.getGenerators()) {
String generatorKey = i.getGenerator();
if (generatorKey == null || generatorKey.isBlank()) {
continue;
}
l.put(generatorKey, i.getMin());
}
return l;
});
}
private static KMap<String, IrisBiomeGeneratorLink> linkIndex(IrisBiome biome) {
AtomicCache<KMap<String, IrisBiomeGeneratorLink>> cache = biome.getGenCache();
KMap<String, IrisBiomeGeneratorLink> cached = cache.getIfPresent();
@@ -225,7 +225,7 @@ final class IrisBiomeLayerGenerator {
int sampledDepth = Math.min(maxDepth, cycleLength);
for (int depth = 0; depth < sampledDepth; depth++) {
int position = Math.floorMod(512L - height - depth, cycleLength);
int position = Math.floorMod(512L - height + depth, cycleLength);
int layerIndex = findLayer(boundaries, position);
IrisBiomePaletteLayer layer = layers.get(layerIndex);
int offset = position - boundaries[layerIndex];
@@ -14,27 +14,31 @@ import java.util.function.Consumer;
public final class ConcurrentClockCache<V> {
private static final int WAYS = 4;
private final AtomicReferenceArray<Entry<V>> slots;
private final AtomicReferenceArray<CacheSet<V>> slots;
private final long sets;
private final int capacity;
public ConcurrentClockCache(int capacity) {
if (capacity <= 0) {
throw new IllegalArgumentException("Cache capacity must be positive");
}
int setCount = Math.ceilDiv(capacity, WAYS);
this.capacity = Math.multiplyExact(setCount, WAYS);
sets = setCount;
slots = new AtomicReferenceArray<>(Math.multiplyExact(setCount, WAYS));
slots = new AtomicReferenceArray<>(setCount);
}
public int capacity() {
return slots.length();
return capacity;
}
public V get(long key) {
long hash = CacheKey.mix(key);
int base = base(hash);
CacheSet<V> snapshot = slots.getAcquire(index(CacheKey.mix(key)));
if (snapshot == null) {
return null;
}
for (int way = 0; way < WAYS; way++) {
Entry<V> entry = slots.getAcquire(base + way);
Entry<V> entry = snapshot.get(way);
if (entry != null && entry.key == key) {
entry.touch();
return entry.value;
@@ -46,12 +50,23 @@ public final class ConcurrentClockCache<V> {
public V putIfAbsent(long key, V value) {
Objects.requireNonNull(value, "Cached value");
long hash = CacheKey.mix(key);
int base = base(hash);
int index = index(hash);
int hand = (int) (hash >>> 60) & (WAYS - 1);
Entry<V> created = null;
while (true) {
CacheSet<V> snapshot = slots.getAcquire(index);
if (snapshot == null) {
if (created == null) {
created = new Entry<>(key, value);
}
if (slots.compareAndSet(index, null, new CacheSet<>(created, null, null, null))) {
return value;
}
continue;
}
int empty = -1;
for (int way = 0; way < WAYS; way++) {
Entry<V> entry = slots.getAcquire(base + way);
Entry<V> entry = snapshot.get(way);
if (entry == null) {
if (empty < 0) {
empty = way;
@@ -61,30 +76,23 @@ public final class ConcurrentClockCache<V> {
return entry.value;
}
}
Entry<V> created = new Entry<>(key, value);
if (empty >= 0) {
if (slots.compareAndSet(base + empty, null, created)) {
return value;
}
continue;
}
int victim = -1;
Entry<V> victimEntry = null;
int victim = empty;
for (int step = 0; step < WAYS * 2 && victim < 0; step++) {
int way = (hand + step) & (WAYS - 1);
Entry<V> entry = slots.getAcquire(base + way);
if (entry == null || !entry.referenced) {
Entry<V> entry = snapshot.get(way);
if (!entry.referenced) {
victim = way;
victimEntry = entry;
} else {
entry.referenced = false;
}
}
if (victim < 0) {
victim = hand;
victimEntry = slots.getAcquire(base + victim);
}
if (slots.compareAndSet(base + victim, victimEntry, created)) {
if (created == null) {
created = new Entry<>(key, value);
}
if (slots.compareAndSet(index, snapshot, snapshot.with(victim, created))) {
return value;
}
}
@@ -92,12 +100,18 @@ public final class ConcurrentClockCache<V> {
public void forEach(Consumer<? super V> action) {
for (int index = 0; index < slots.length(); index++) {
Entry<V> entry = slots.getAcquire(index);
CacheSet<V> snapshot = slots.getAcquire(index);
if (snapshot == null) {
continue;
}
for (int way = 0; way < WAYS; way++) {
Entry<V> entry = snapshot.get(way);
if (entry != null) {
action.accept(entry.value);
}
}
}
}
public void clear() {
for (int index = 0; index < slots.length(); index++) {
@@ -105,14 +119,36 @@ public final class ConcurrentClockCache<V> {
}
}
private int base(long hash) {
return (int) (((hash & 0xffffffffL) * sets) >>> 32) * WAYS;
private int index(long hash) {
return (int) (((hash & 0xffffffffL) * sets) >>> 32);
}
private record CacheSet<V>(Entry<V> first, Entry<V> second, Entry<V> third, Entry<V> fourth) {
private Entry<V> get(int way) {
return switch (way) {
case 0 -> first;
case 1 -> second;
case 2 -> third;
case 3 -> fourth;
default -> throw new IndexOutOfBoundsException(way);
};
}
private CacheSet<V> with(int way, Entry<V> entry) {
return switch (way) {
case 0 -> new CacheSet<>(entry, second, third, fourth);
case 1 -> new CacheSet<>(first, entry, third, fourth);
case 2 -> new CacheSet<>(first, second, entry, fourth);
case 3 -> new CacheSet<>(first, second, third, entry);
default -> throw new IndexOutOfBoundsException(way);
};
}
}
private static final class Entry<V> {
private final long key;
private final V value;
private boolean referenced;
private volatile boolean referenced;
private Entry(long key, V value) {
this.key = key;
@@ -19,6 +19,8 @@
package art.arcane.iris.generation.cave;
import art.arcane.iris.pack.value.IrisRange;
import art.arcane.iris.generation.noise.IrisStyledRange;
import art.arcane.volmlib.util.math.M;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.generation.runtime.Engine;
@@ -31,12 +33,15 @@ import java.util.List;
public final class IrisCaveProfileSampler {
private static final double FLOATING_THRESHOLD_BIAS_SCALE = 0.2D;
private final IrisData data;
private final IrisCaveProfile profile;
private final CNG baseDensity;
private final CNG detailDensity;
private final CNG warpDensity;
private final RNG thresholdRng;
private final CNG thresholdDensity;
private final double thresholdMin;
private final double thresholdMax;
private final double constantThreshold;
private final boolean thresholdHasNoise;
private final ModuleState[] modules;
private final double inverseNormalization;
private final double baseWeight;
@@ -47,14 +52,19 @@ public final class IrisCaveProfileSampler {
private final boolean hasModules;
public IrisCaveProfileSampler(Engine engine, IrisCaveProfile profile) {
this.data = engine.getData();
IrisData data = engine.getData();
this.profile = profile;
List<ModuleState> moduleStates = new ArrayList<>();
RNG baseRng = new RNG(engine.getSeedManager().getCarve());
this.baseDensity = profile.getBaseDensityStyle().create(baseRng.nextParallelRNG(934_447), data);
this.detailDensity = profile.getDetailDensityStyle().create(baseRng.nextParallelRNG(612_991), data);
this.warpDensity = profile.getWarpStyle().create(baseRng.nextParallelRNG(770_713), data);
this.thresholdRng = baseRng.nextParallelRNG(489_112);
IrisStyledRange threshold = profile.getDensityThreshold();
thresholdMin = threshold.getMin();
thresholdMax = threshold.getMax();
thresholdHasNoise = thresholdMin != thresholdMax && !threshold.getStyle().isFlat();
thresholdDensity = thresholdHasNoise ? threshold.getStyle().create(baseRng.nextParallelRNG(489_112), data) : null;
constantThreshold = thresholdMin == thresholdMax ? thresholdMin : M.lerp(thresholdMin, thresholdMax, 0.5D);
this.baseWeight = profile.getBaseWeight();
this.detailWeight = profile.getDetailWeight();
this.warpStrength = profile.getWarpStrength();
@@ -82,7 +92,8 @@ public final class IrisCaveProfileSampler {
return false;
}
double threshold = profile.getDensityThreshold().get(thresholdRng, x, z, data) - profile.getThresholdBias();
double threshold = (thresholdHasNoise ? thresholdDensity.fitDouble(thresholdMin, thresholdMax, x, z)
: constantThreshold) - profile.getThresholdBias();
threshold += floatingThresholdBias(floatingCarveThreshold);
return sampleDensity(x, y, z) <= threshold;
}
@@ -98,14 +98,6 @@ final class DecoratorCore {
return picked;
}
private static String topHalfValue(String half) {
return half.equals("upper") || half.equals("lower") ? "upper" : "top";
}
private static String bottomHalfValue(String half) {
return half.equals("upper") || half.equals("lower") ? "lower" : "bottom";
}
static void placeSurfaceSingle(IrisDecorator decorator,
int x, int z, int realX, int height, int realZ,
Hunk<NativeBlockState> data, RNG rng, IrisData irisData,
@@ -150,7 +142,7 @@ final class DecoratorCore {
}
String half = bd == null ? null : IrisProceduralBlocks.propertyValue(bd, "half");
if (half != null) {
if ("upper".equals(half) || "lower".equals(half)) {
int lowerY = height + 1;
int upperY = height + 2;
if (!canPlaceTwoBlockPlant(data, x, z, lowerY, upperY, caveSkipFluid)) {
@@ -158,9 +150,9 @@ final class DecoratorCore {
}
try {
NativeBlockState upper = bd.withProperty("half", topHalfValue(half));
NativeBlockState upper = bd.withProperty("half", "upper");
NativeBlockState lower = fixFacesForHunk(
bd.withProperty("half", bottomHalfValue(half)),
bd.withProperty("half", "lower"),
data, x, z, realX, lowerY, realZ, mantle);
data.set(x, lowerY, z, lower);
data.set(x, upperY, z, IrisProceduralBlocks.normalizeWaterlogging(upper, data.get(x, upperY, z), true));
@@ -228,6 +220,8 @@ final class DecoratorCore {
int stack = computeStack(decorator, rng, realX, realZ, irisData, effectiveMax);
int placed = 0;
boolean hasSpikes = false;
NativeBlockState sampledBlock = null;
boolean sampledTop = false;
for (int i = 0; i < stack; i++) {
int y = height + 1 + i;
if (y >= data.getHeight()) {
@@ -239,9 +233,14 @@ final class DecoratorCore {
break;
}
double threshold = stack == 1 ? 1.0 : ((double) i) / (stack - 1);
NativeBlockState block = threshold >= decorator.getTopThreshold()
boolean top = threshold >= decorator.getTopThreshold();
if (sampledBlock == null || top != sampledTop) {
sampledBlock = top
? decorator.pickBlockDataTop(rng, irisData, realX, realZ)
: decorator.pickBlockData(rng, irisData, realX, realZ);
sampledTop = top;
}
NativeBlockState block = sampledBlock;
if (block == null) {
break;
}
@@ -282,6 +281,8 @@ final class DecoratorCore {
int stack = computeStack(decorator, rng, realX, realZ, irisData, max);
int placed = 0;
boolean hasSpikes = false;
NativeBlockState sampledBlock = null;
boolean sampledTop = false;
for (int i = 0; i < stack; i++) {
int y = height - i;
if (y < 0 || y < minHeight) {
@@ -293,9 +294,14 @@ final class DecoratorCore {
break;
}
double threshold = stack == 1 ? 1.0 : ((double) i) / (stack - 1);
NativeBlockState block = threshold >= decorator.getTopThreshold()
boolean top = threshold >= decorator.getTopThreshold();
if (sampledBlock == null || top != sampledTop) {
sampledBlock = top
? decorator.pickBlockDataTop(rng, irisData, realX, realZ)
: decorator.pickBlockData(rng, irisData, realX, realZ);
sampledTop = top;
}
NativeBlockState block = sampledBlock;
if (block == null) {
break;
}
@@ -346,7 +352,7 @@ final class DecoratorCore {
}
String half = IrisProceduralBlocks.propertyValue(bd, "half");
if (half != null) {
if ("upper".equals(half) || "lower".equals(half)) {
int lowerY = height + 1;
int upperY = height + 2;
if (max <= 2 || !canPlaceTwoBlockPlant(data, xf, zf, lowerY, upperY, false)) {
@@ -354,8 +360,8 @@ final class DecoratorCore {
}
try {
NativeBlockState upper = bd.withProperty("half", topHalfValue(half));
NativeBlockState lower = bd.withProperty("half", bottomHalfValue(half));
NativeBlockState upper = bd.withProperty("half", "upper");
NativeBlockState lower = bd.withProperty("half", "lower");
data.set(xf, lowerY, zf, IrisProceduralBlocks.normalizeWaterlogging(lower, data.get(xf, lowerY, zf), true));
data.set(xf, upperY, zf, IrisProceduralBlocks.normalizeWaterlogging(upper, data.get(xf, upperY, zf), true));
} catch (Throwable e) {
@@ -386,15 +392,22 @@ final class DecoratorCore {
int placed = 0;
boolean hasSpikes = false;
NativeBlockState sampledBlock = null;
boolean sampledTop = false;
for (int i = 0; i < stack; i++) {
int h = height + 1 + i;
if (h >= height + max || h >= data.getHeight()) {
break;
}
double threshold = stack == 1 ? 0.0 : ((double) i) / (stack - 1);
NativeBlockState bd = threshold >= decorator.getTopThreshold()
boolean top = threshold >= decorator.getTopThreshold();
if (sampledBlock == null || top != sampledTop) {
sampledBlock = top
? decorator.pickBlockDataTop(rng, irisData, realX, realZ)
: decorator.pickBlockData(rng, irisData, realX, realZ);
sampledTop = top;
}
NativeBlockState bd = sampledBlock;
if (bd == null) {
break;
}
@@ -193,20 +193,22 @@ public class IrisDecorator {
}
public NativeBlockState getBlockData100(IrisBiome b, RNG rng, double x, double y, double z, IrisData data) {
if (getBlockData(data).isEmpty()) {
KList<NativeBlockState> palette = getBlockData(data);
if (palette.isEmpty()) {
IrisLogging.warnOnce("decorator-empty:" + b.getName(), "Empty Block Data for " + b.getName());
return null;
}
if (getBlockData(data).size() == 1) {
return getBlockData(data).get(0);
if (palette.size() == 1) {
return palette.get(0);
}
return getVarianceGenerator(rng, data).fit(getBlockData(data), z, y, x); //X and Z must be switched
return getVarianceGenerator(rng, data).fit(palette, z, y, x); //X and Z must be switched
}
public NativeBlockState getBlockDataForTop(IrisBiome b, RNG rng, double x, double y, double z, IrisData data) {
if (getBlockDataTops(data).isEmpty()) {
KList<NativeBlockState> topPalette = getBlockDataTops(data);
if (topPalette.isEmpty()) {
return getBlockData100(b, rng, x, y, z, data);
}
@@ -214,11 +216,11 @@ public class IrisDecorator {
double zz = z / style.getZoom();
if (getGenerator(rng, data).fitDouble(0D, 1D, xx, zz) <= chance) { //Exclude y from here
if (getBlockData(data).size() == 1) {
return getBlockDataTops(data).get(0);
if (topPalette.size() == 1) {
return topPalette.get(0);
}
return getVarianceGenerator(rng, data).fit(getBlockDataTops(data), z, y, x); //X and Z must be switched
return getVarianceGenerator(rng, data).fit(topPalette, z, y, x); //X and Z must be switched
}
return null;
@@ -146,13 +146,17 @@ public final class IrisProceduralBlocks {
}
public static NativeBlockState normalizeWaterlogging(NativeBlockState state, NativeBlockState existing, boolean enabled) {
if (state == null || !hasProperty(state, "waterlogged")) {
if (state == null) {
return null;
}
String current = propertyValue(state, "waterlogged");
if (current == null) {
return state;
}
boolean authored = "true".equals(propertyValue(state, "waterlogged"));
boolean authored = "true".equals(current);
boolean submerged = existing != null && (existing.isWater() || "true".equals(propertyValue(existing, "waterlogged")));
String value = submerged && (enabled || authored) ? "true" : "false";
return value.equals(propertyValue(state, "waterlogged")) ? state : state.withProperty("waterlogged", value);
return value.equals(current) ? state : state.withProperty("waterlogged", value);
}
public static IrisObject assemble(Map<Vector3i, NativeBlockState> blocks) {
@@ -111,12 +111,7 @@ public class IrisShoreLineDecorator extends IrisEngineDecorator {
return;
}
int stack = decorator.getHeight(partRNG, realX, realZ, getData());
if (decorator.isScaleStack()) {
stack = (int) Math.ceil((double) (max - height) * ((double) stack / 100));
} else {
stack = Math.min(max - height, stack);
}
int stack = DecoratorCore.computeStack(decorator, partRNG, realX, realZ, getData(), max - height);
if (stack == 1) {
int targetY = height + 1;
@@ -5,7 +5,6 @@ import art.arcane.iris.generation.hydrology.cave.CavePositionIndex;
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveAction;
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCandidate;
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveFluidPolicy;
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveGrottoShape;
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveMode;
import art.arcane.iris.generation.hydrology.cave.HydrologyCavePlannerSettings;
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveSource;
@@ -170,11 +169,8 @@ final class CaveCandidateBuilder {
volume,
1,
1,
1,
1,
dryHeadroom,
HydrologyCaveFluidPolicy.REJECT_EXISTING,
HydrologyCaveGrottoShape.ELLIPSOID,
horizontalRadius,
maximumDepth
);
@@ -114,7 +114,7 @@ final class HydrologyOutletPlanner {
return selectedOcean;
}
if (surface) {
HydrologySurfaceBudgets budgets = HydrologySurfaceBudgets.sample(grid, planner.settings.surface().sources());
HydrologySurfaceBudgets budgets = grid.surfaceBudgets(planner.settings.surface().sources());
if (budgets.overridden()) {
oceanCandidates.removeIf(candidate -> !surfaceOutletHasBudget(key, grid, budgets, candidate, true));
}
@@ -243,7 +243,7 @@ final class HydrologyOutletPlanner {
}
sortSurfaceFallbackOutletCandidates(grid, oceanCandidates);
sortSurfaceFallbackOutletCandidates(grid, inlandCandidates);
HydrologySurfaceBudgets budgets = HydrologySurfaceBudgets.sample(grid, planner.settings.surface().sources());
HydrologySurfaceBudgets budgets = grid.surfaceBudgets(planner.settings.surface().sources());
if (budgets.overridden()) {
HydrologyTileKey key = HydrologyTileKey.fromBlock(grid.ownerMinimumX(), grid.ownerMinimumZ(), grid.ownerSize());
oceanCandidates.removeIf(candidate -> !surfaceOutletHasBudget(key, grid, budgets, candidate, true));
@@ -279,7 +279,7 @@ final class HydrologyOutletPlanner {
List<OutletCandidate> limitSurfaceOutlets(HydrologyTileKey key, HydrologySampledGrid grid,
List<OutletCandidate> candidates, boolean coastal) {
HydrologySurfaceBudgets budgets = HydrologySurfaceBudgets.sample(grid, planner.settings.surface().sources());
HydrologySurfaceBudgets budgets = grid.surfaceBudgets(planner.settings.surface().sources());
if (!budgets.overridden()) {
return coastal ? limitOutletsByType(candidates, planner.settings.outlets().maximumCoastalPerTile())
: limitOutlets(candidates, planner.settings.outlets().maximumPerTile());
@@ -12,6 +12,7 @@ final class HydrologySampledGrid {
private final int spacing;
private final List<HydrologyGridNode> nodes;
private volatile HydrologyDrainageGraph drainage;
private volatile SurfaceBudgetSnapshot surfaceBudgets;
HydrologySampledGrid(int minimumX, int minimumZ, int ownerMinimumX, int ownerMinimumZ,
int ownerSize, int width, int spacing, List<HydrologyGridNode> nodes) {
@@ -77,6 +78,21 @@ final class HydrologySampledGrid {
return nodes.get(index);
}
HydrologySurfaceBudgets surfaceBudgets(HydrologyPlannerSettings.Source sources) {
SurfaceBudgetSnapshot current = surfaceBudgets;
if (current != null && current.sources().equals(sources)) {
return current.budgets();
}
synchronized (this) {
current = surfaceBudgets;
if (current == null || !current.sources().equals(sources)) {
current = new SurfaceBudgetSnapshot(sources, HydrologySurfaceBudgets.sample(this, sources));
surfaceBudgets = current;
}
return current.budgets();
}
}
HydrologyGridNode nodeAt(int gridX, int gridZ) {
if (gridX < 0 || gridZ < 0 || gridX >= width || gridZ >= width) {
return null;
@@ -102,4 +118,7 @@ final class HydrologySampledGrid {
return x >= ownerMinimumX && x < ownerMinimumX + ownerSize
&& z >= ownerMinimumZ && z < ownerMinimumZ + ownerSize;
}
private record SurfaceBudgetSnapshot(HydrologyPlannerSettings.Source sources, HydrologySurfaceBudgets budgets) {
}
}
@@ -219,7 +219,7 @@ final class HydrologySourcePlanner {
return SourceSelection.empty(surface);
}
long sourceSalt = surface ? SURFACE_SOURCE_SALT : UNDERGROUND_SOURCE_SALT;
HydrologySurfaceBudgets surfaceBudgets = surface ? HydrologySurfaceBudgets.sample(grid, sourceSettings) : null;
HydrologySurfaceBudgets surfaceBudgets = surface ? grid.surfaceBudgets(sourceSettings) : null;
ArrayList<SourceCandidate> candidates = new ArrayList<>();
boolean hasRequiredCandidate = false;
for (HydrologyGridNode node : grid.nodes()) {
@@ -1,18 +0,0 @@
package art.arcane.iris.generation.hydrology.cave;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.Set;
record CaveBoundaryResult(Set<CavePosition> sealGuards, HydrologyCaveRejection rejection) {
static CaveBoundaryResult accepted(Set<CavePosition> sealGuards) {
return new CaveBoundaryResult(
Collections.unmodifiableSet(new LinkedHashSet<>(sealGuards)),
HydrologyCaveRejection.NONE
);
}
static CaveBoundaryResult rejected(HydrologyCaveRejection rejection) {
return new CaveBoundaryResult(Set.of(), rejection);
}
}
@@ -1,18 +0,0 @@
package art.arcane.iris.generation.hydrology.cave;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.Set;
record CaveComponentResult(Set<CavePosition> positions, HydrologyCaveRejection rejection) {
static CaveComponentResult accepted(Set<CavePosition> positions) {
return new CaveComponentResult(
Collections.unmodifiableSet(new LinkedHashSet<>(positions)),
HydrologyCaveRejection.NONE
);
}
static CaveComponentResult rejected(HydrologyCaveRejection rejection) {
return new CaveComponentResult(Set.of(), rejection);
}
}
@@ -1,18 +0,0 @@
package art.arcane.iris.generation.hydrology.cave;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.Set;
record CaveGrottoResult(Set<CavePosition> positions, HydrologyCaveRejection rejection) {
static CaveGrottoResult accepted(Set<CavePosition> positions) {
return new CaveGrottoResult(
Collections.unmodifiableSet(new LinkedHashSet<>(positions)),
HydrologyCaveRejection.NONE
);
}
static CaveGrottoResult rejected(HydrologyCaveRejection rejection) {
return new CaveGrottoResult(Set.of(), rejection);
}
}
@@ -1,13 +0,0 @@
package art.arcane.iris.generation.hydrology.cave;
import java.util.List;
record CavePathResult(List<CavePosition> positions, HydrologyCaveRejection rejection) {
static CavePathResult accepted(List<CavePosition> positions) {
return new CavePathResult(List.copyOf(positions), HydrologyCaveRejection.NONE);
}
static CavePathResult rejected(HydrologyCaveRejection rejection) {
return new CavePathResult(List.of(), rejection);
}
}
@@ -1,809 +0,0 @@
package art.arcane.iris.generation.hydrology.cave;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Queue;
import java.util.Set;
final class HydrologyCaveChamberPlanner {
private final HydrologyCaveContainmentPlanner planner;
HydrologyCaveChamberPlanner(HydrologyCaveContainmentPlanner planner) {
this.planner = planner;
}
HydrologyCavePlan planGrottoOrClosedComponent(
CaveVoxelView view,
HydrologyCaveSource source,
HydrologyCavePlannerSettings settings,
List<CavePosition> throat
) {
CaveVoxel targetVoxel = planner.voxelAt(view, source.target());
if (planner.isFluidReachable(targetVoxel, settings)) {
return planClosedComponent(view, source, settings, throat);
}
if (targetVoxel == CaveVoxel.LAVA) {
return planner.rejected(source, HydrologyCaveRejection.LAVA_CONTACT);
}
if (targetVoxel == CaveVoxel.INCOMPATIBLE_FLUID) {
return planner.rejected(source, HydrologyCaveRejection.INCOMPATIBLE_FLUID);
}
return planGeneratedGrotto(view, source, settings, throat);
}
HydrologyCavePlan planWaterfallPool(
CaveVoxelView view,
HydrologyCaveSource source,
HydrologyCavePlannerSettings settings,
List<CavePosition> throat
) {
if (!view.isOpenToSurface(source.target())) {
return planGrottoOrClosedComponent(view, source, settings, throat);
}
HydrologyCaveRejection dryThroatRejection = validateDryThroatContacts(view, source, throat);
if (dryThroatRejection != HydrologyCaveRejection.NONE) {
return planner.rejected(source, dryThroatRejection);
}
HydrologyCaveRejection shaftRejection = validateWaterfallShaft(view, source, settings, throat);
if (shaftRejection != HydrologyCaveRejection.NONE) {
return planner.rejected(source, shaftRejection);
}
CaveVoxel targetVoxel = planner.voxelAt(view, source.target());
if (!planner.isFluidReachable(targetVoxel, settings)) {
return planner.rejected(source, planner.rejectionForTarget(targetVoxel, settings));
}
Map<CavePosition, HydrologyCaveAction> actions = new LinkedHashMap<>();
addThroatActions(actions, throat, source);
addSealGuards(view, source, actions);
return planner.accepted(view, source, actions);
}
HydrologyCavePlan planClosedComponent(
CaveVoxelView view,
HydrologyCaveSource source,
HydrologyCavePlannerSettings settings,
List<CavePosition> throat
) {
HydrologyCaveRejection dryThroatRejection = validateDryThroatContacts(view, source, throat);
if (dryThroatRejection != HydrologyCaveRejection.NONE) {
return planner.rejected(source, dryThroatRejection);
}
HydrologyCaveRejection waterfallRejection = validateWaterfallShaft(view, source, settings, throat);
if (waterfallRejection != HydrologyCaveRejection.NONE) {
return planner.rejected(source, waterfallRejection);
}
CaveVoxel targetVoxel = planner.voxelAt(view, source.target());
if (!planner.isFluidReachable(targetVoxel, settings)) {
return planner.rejected(source, planner.rejectionForTarget(targetVoxel, settings));
}
CaveComponentResult component = resolveClosedComponent(view, source, settings, throat);
if (component.rejection() != HydrologyCaveRejection.NONE) {
return planner.rejected(source, component.rejection());
}
Map<CavePosition, HydrologyCaveAction> actions = new LinkedHashMap<>();
addThroatActions(actions, throat, source);
for (CavePosition position : component.positions()) {
actions.put(position, HydrologyCaveAction.WET_SOURCE);
}
addSealGuards(view, source, actions);
return planner.accepted(view, source, actions);
}
HydrologyCaveRejection validateDryThroatContacts(
CaveVoxelView view,
HydrologyCaveSource source,
List<CavePosition> throat
) {
Set<CavePosition> throatPositions = Set.copyOf(throat);
for (CavePosition position : throat) {
if (position.y() <= source.waterHeadY()) {
continue;
}
for (CavePosition direction : HydrologyCaveContainmentPlanner.DIRECTIONS) {
CavePosition neighbor = position.offset(direction.x(), direction.y(), direction.z());
if (throatPositions.contains(neighbor) || isInletOpening(source, neighbor)) {
continue;
}
if (!view.isInWorld(neighbor)) {
return HydrologyCaveRejection.WORLD_BOUNDARY;
}
CaveVoxel voxel = planner.voxelAt(view, neighbor);
if (voxel == CaveVoxel.LAVA) {
return HydrologyCaveRejection.LAVA_CONTACT;
}
if (voxel == CaveVoxel.COMPATIBLE_FLUID) {
return HydrologyCaveRejection.EXISTING_FLUID;
}
if (voxel == CaveVoxel.INCOMPATIBLE_FLUID) {
return HydrologyCaveRejection.INCOMPATIBLE_FLUID;
}
}
}
return HydrologyCaveRejection.NONE;
}
HydrologyCaveRejection validateWaterfallShaft(
CaveVoxelView view,
HydrologyCaveSource source,
HydrologyCavePlannerSettings settings,
List<CavePosition> throat
) {
if (source.mode() != HydrologyCaveMode.WATERFALL_POOL) {
return HydrologyCaveRejection.NONE;
}
Set<CavePosition> throatPositions = Set.copyOf(throat);
for (CavePosition position : throat) {
if (position.y() <= source.waterHeadY()) {
continue;
}
for (CavePosition direction : HydrologyCaveContainmentPlanner.DIRECTIONS) {
CavePosition neighbor = position.offset(direction.x(), direction.y(), direction.z());
if (throatPositions.contains(neighbor) || isInletOpening(source, neighbor)) {
continue;
}
if (!view.isInWorld(neighbor)) {
return HydrologyCaveRejection.WORLD_BOUNDARY;
}
HydrologyCaveRejection boundsRejection = planner.validateBounds(source, settings, neighbor);
if (boundsRejection != HydrologyCaveRejection.NONE) {
return boundsRejection;
}
CaveVoxel voxel = planner.voxelAt(view, neighbor);
HydrologyCaveRejection hazard = planner.rejectionForHazard(voxel, settings);
if (hazard != HydrologyCaveRejection.NONE) {
return hazard;
}
if (voxel != CaveVoxel.SOLID) {
return HydrologyCaveRejection.WATERFALL_SHAFT_OPEN;
}
}
}
return HydrologyCaveRejection.NONE;
}
HydrologyCavePlan planGeneratedGrotto(
CaveVoxelView view,
HydrologyCaveSource source,
HydrologyCavePlannerSettings settings,
List<CavePosition> throat
) {
CaveGrottoResult grotto = buildGrotto(source, settings);
if (grotto.rejection() != HydrologyCaveRejection.NONE) {
return planner.rejected(source, grotto.rejection());
}
Set<CavePosition> chamber = grotto.positions();
Set<CavePosition> carve = new LinkedHashSet<>(chamber.size() + throat.size());
carve.addAll(chamber);
carve.addAll(throat);
HydrologyCaveRejection carveRejection = validateGeneratedCarve(view, source, settings, carve);
if (carveRejection != HydrologyCaveRejection.NONE) {
return planner.rejected(source, carveRejection);
}
CaveBoundaryResult boundary = validateGeneratedBoundary(view, source, settings, carve);
if (boundary.rejection() != HydrologyCaveRejection.NONE) {
return planner.rejected(source, boundary.rejection());
}
Map<CavePosition, HydrologyCaveAction> actions = new LinkedHashMap<>();
addChamberActions(actions, chamber, source.waterHeadY());
addThroatActions(actions, throat, source);
for (CavePosition position : boundary.sealGuards()) {
actions.put(position, HydrologyCaveAction.SEAL_GUARD);
}
return planner.accepted(view, source, actions);
}
HydrologyCavePlan planDeepPool(
CaveVoxelView view,
HydrologyCaveSource source,
HydrologyCavePlannerSettings settings,
List<CavePosition> throat
) {
CaveGrottoResult grotto = buildGrotto(source, settings);
if (grotto.rejection() != HydrologyCaveRejection.NONE) {
return planner.rejected(source, grotto.rejection());
}
Set<CavePosition> chamber = grotto.positions();
Set<CavePosition> carve = new LinkedHashSet<>(chamber.size() + throat.size());
carve.addAll(chamber);
carve.addAll(throat);
HydrologyCaveRejection carveRejection = validateDeepPoolCarve(view, source, settings, carve);
if (carveRejection != HydrologyCaveRejection.NONE) {
return planner.rejected(source, carveRejection);
}
CaveBoundaryResult boundary = validateDeepPoolBoundary(view, source, settings, carve);
if (boundary.rejection() != HydrologyCaveRejection.NONE) {
return planner.rejected(source, boundary.rejection());
}
Map<CavePosition, HydrologyCaveAction> actions = new LinkedHashMap<>();
addChamberActions(actions, chamber, source.waterHeadY());
addThroatActions(actions, throat, source);
for (CavePosition position : boundary.sealGuards()) {
actions.put(position, HydrologyCaveAction.SEAL_GUARD);
}
return planner.accepted(view, source, actions);
}
HydrologyCaveRejection validateDeepPoolCarve(
CaveVoxelView view,
HydrologyCaveSource source,
HydrologyCavePlannerSettings settings,
Set<CavePosition> carve
) {
for (CavePosition position : carve) {
if (!view.isInWorld(position)) {
return HydrologyCaveRejection.WORLD_BOUNDARY;
}
HydrologyCaveRejection boundsRejection = planner.validateBounds(source, settings, position);
if (boundsRejection != HydrologyCaveRejection.NONE) {
return boundsRejection;
}
CaveVoxel voxel = planner.voxelAt(view, position);
HydrologyCaveRejection hazard = planner.rejectionForHazard(voxel, settings);
if (hazard != HydrologyCaveRejection.NONE) {
return hazard;
}
if (voxel == CaveVoxel.SOLID) {
continue;
}
if (position.y() > source.waterHeadY()
&& voxel == CaveVoxel.CAVE_AIR
&& !view.isOpenToSurface(position)) {
continue;
}
return HydrologyCaveRejection.GROTTO_INTERSECTION;
}
return HydrologyCaveRejection.NONE;
}
CaveBoundaryResult validateDeepPoolBoundary(
CaveVoxelView view,
HydrologyCaveSource source,
HydrologyCavePlannerSettings settings,
Set<CavePosition> carve
) {
Set<CavePosition> guards = new LinkedHashSet<>();
for (CavePosition position : carve) {
for (CavePosition direction : HydrologyCaveContainmentPlanner.DIRECTIONS) {
CavePosition neighbor = position.offset(direction.x(), direction.y(), direction.z());
if (carve.contains(neighbor)) {
continue;
}
if (!view.isInWorld(neighbor)) {
return CaveBoundaryResult.rejected(HydrologyCaveRejection.WORLD_BOUNDARY);
}
HydrologyCaveRejection boundsRejection = planner.validateBounds(source, settings, neighbor);
if (boundsRejection != HydrologyCaveRejection.NONE) {
return CaveBoundaryResult.rejected(boundsRejection);
}
CaveVoxel voxel = planner.voxelAt(view, neighbor);
HydrologyCaveRejection hazard = planner.rejectionForHazard(voxel, settings);
if (hazard != HydrologyCaveRejection.NONE) {
return CaveBoundaryResult.rejected(hazard);
}
if (voxel == CaveVoxel.SOLID) {
guards.add(neighbor);
continue;
}
if (neighbor.y() > source.waterHeadY()
&& voxel == CaveVoxel.CAVE_AIR
&& !view.isOpenToSurface(neighbor)) {
continue;
}
return CaveBoundaryResult.rejected(HydrologyCaveRejection.GROTTO_SHELL_OPEN);
}
}
return CaveBoundaryResult.accepted(guards);
}
CaveComponentResult resolveClosedComponent(
CaveVoxelView view,
HydrologyCaveSource source,
HydrologyCavePlannerSettings settings,
List<CavePosition> throat
) {
Queue<CavePosition> queue = new ArrayDeque<>();
Set<CavePosition> queued = new LinkedHashSet<>();
int visitedCount = 0;
queue.add(source.target());
queued.add(source.target());
HydrologyCaveRejection seedRejection = addThroatContacts(view, source, settings, throat, queue, queued);
if (seedRejection != HydrologyCaveRejection.NONE) {
return CaveComponentResult.rejected(seedRejection);
}
while (!queue.isEmpty()) {
CavePosition position = queue.remove();
HydrologyCaveRejection positionRejection = validateReachablePosition(view, source, settings, position);
if (positionRejection != HydrologyCaveRejection.NONE) {
return CaveComponentResult.rejected(positionRejection);
}
if (++visitedCount > settings.maxFloodVolume()) {
return CaveComponentResult.rejected(HydrologyCaveRejection.VOLUME_LIMIT);
}
for (CavePosition direction : HydrologyCaveContainmentPlanner.DIRECTIONS) {
CavePosition neighbor = position.offset(direction.x(), direction.y(), direction.z());
HydrologyCaveRejection neighborRejection = inspectReachableNeighbor(
view,
source,
settings,
neighbor,
queue,
queued
);
if (neighborRejection != HydrologyCaveRejection.NONE) {
return CaveComponentResult.rejected(neighborRejection);
}
}
}
return CaveComponentResult.accepted(queued);
}
HydrologyCaveRejection addThroatContacts(
CaveVoxelView view,
HydrologyCaveSource source,
HydrologyCavePlannerSettings settings,
List<CavePosition> throat,
Queue<CavePosition> queue,
Set<CavePosition> queued
) {
for (CavePosition position : throat) {
if (position.y() > source.waterHeadY()) {
continue;
}
for (CavePosition direction : HydrologyCaveContainmentPlanner.DIRECTIONS) {
CavePosition neighbor = position.offset(direction.x(), direction.y(), direction.z());
HydrologyCaveRejection rejection = inspectReachableNeighbor(
view,
source,
settings,
neighbor,
queue,
queued
);
if (rejection != HydrologyCaveRejection.NONE) {
return rejection;
}
}
}
return HydrologyCaveRejection.NONE;
}
HydrologyCaveRejection inspectReachableNeighbor(
CaveVoxelView view,
HydrologyCaveSource source,
HydrologyCavePlannerSettings settings,
CavePosition position,
Queue<CavePosition> queue,
Set<CavePosition> queued
) {
if (position.y() > source.waterHeadY()) {
return inspectAboveHeadNeighbor(view, source, position);
}
if (!view.isInWorld(position)) {
return HydrologyCaveRejection.WORLD_BOUNDARY;
}
CaveVoxel voxel = planner.voxelAt(view, position);
HydrologyCaveRejection hazard = planner.rejectionForHazard(voxel, settings);
if (hazard != HydrologyCaveRejection.NONE) {
return hazard;
}
if (!planner.isFluidReachable(voxel, settings)) {
return HydrologyCaveRejection.NONE;
}
HydrologyCaveRejection boundsRejection = planner.validateBounds(source, settings, position);
if (boundsRejection != HydrologyCaveRejection.NONE) {
return boundsRejection;
}
if (queued.add(position)) {
queue.add(position);
}
return HydrologyCaveRejection.NONE;
}
HydrologyCaveRejection inspectAboveHeadNeighbor(
CaveVoxelView view,
HydrologyCaveSource source,
CavePosition position
) {
if (isInletOpening(source, position) || !view.isInWorld(position)) {
return HydrologyCaveRejection.NONE;
}
CaveVoxel voxel = planner.voxelAt(view, position);
return switch (voxel) {
case LAVA -> HydrologyCaveRejection.LAVA_CONTACT;
case COMPATIBLE_FLUID -> HydrologyCaveRejection.EXISTING_FLUID;
case INCOMPATIBLE_FLUID -> HydrologyCaveRejection.INCOMPATIBLE_FLUID;
default -> HydrologyCaveRejection.NONE;
};
}
HydrologyCaveRejection validateReachablePosition(
CaveVoxelView view,
HydrologyCaveSource source,
HydrologyCavePlannerSettings settings,
CavePosition position
) {
if (!view.isInWorld(position)) {
return HydrologyCaveRejection.WORLD_BOUNDARY;
}
HydrologyCaveRejection boundsRejection = planner.validateBounds(source, settings, position);
if (boundsRejection != HydrologyCaveRejection.NONE) {
return boundsRejection;
}
if (view.isOpenToSurface(position)) {
return HydrologyCaveRejection.OPEN_SURFACE;
}
CaveVoxel voxel = planner.voxelAt(view, position);
HydrologyCaveRejection hazard = planner.rejectionForHazard(voxel, settings);
if (hazard != HydrologyCaveRejection.NONE) {
return hazard;
}
return planner.isFluidReachable(voxel, settings)
? HydrologyCaveRejection.NONE
: HydrologyCaveRejection.NO_CAVE_TARGET;
}
CavePathResult buildThroat(
CaveVoxelView view,
HydrologyCaveSource source,
HydrologyCavePlannerSettings settings
) {
CavePosition entry = source.entry();
CavePosition target = source.target();
int deltaX = target.x() - entry.x();
int deltaY = target.y() - entry.y();
int deltaZ = target.z() - entry.z();
int movesX = Math.abs(deltaX);
int movesY = Math.abs(deltaY);
int movesZ = Math.abs(deltaZ);
int length = movesX + movesY + movesZ;
if (length > settings.maxThroatLength()) {
return CavePathResult.rejected(HydrologyCaveRejection.THROAT_LIMIT);
}
int stepX = Integer.signum(deltaX);
int stepY = Integer.signum(deltaY);
int stepZ = Integer.signum(deltaZ);
int usedX = 0;
int usedY = 0;
int usedZ = 0;
CavePosition current = entry;
List<CavePosition> positions = new ArrayList<>(length + 1);
while (true) {
HydrologyCaveRejection positionRejection = validateThroatPosition(view, source, settings, current);
if (positionRejection != HydrologyCaveRejection.NONE) {
return CavePathResult.rejected(positionRejection);
}
positions.add(current);
if (current.equals(target)) {
return expandThroat(view, source, settings, positions);
}
int axis = selectNextAxis(source.sourceId(), movesX, movesY, movesZ, usedX, usedY, usedZ);
if (axis == 0) {
current = current.offset(stepX, 0, 0);
usedX++;
} else if (axis == 1) {
current = current.offset(0, stepY, 0);
usedY++;
} else {
current = current.offset(0, 0, stepZ);
usedZ++;
}
}
}
CavePathResult expandThroat(
CaveVoxelView view,
HydrologyCaveSource source,
HydrologyCavePlannerSettings settings,
List<CavePosition> centerline
) {
int radius = settings.throatRadius();
int extent = radius - 1;
int radiusSquared = radius * radius;
Set<CavePosition> expanded = new LinkedHashSet<>();
for (CavePosition center : centerline) {
for (int dx = -extent; dx <= extent; dx++) {
for (int dy = -extent; dy <= extent; dy++) {
for (int dz = -extent; dz <= extent; dz++) {
if ((dx * dx) + (dy * dy) + (dz * dz) >= radiusSquared) {
continue;
}
CavePosition position = center.offset(dx, dy, dz);
if (position.y() > source.entry().y()) {
continue;
}
HydrologyCaveRejection rejection = validateThroatPosition(view, source, settings, position);
if (rejection != HydrologyCaveRejection.NONE) {
return CavePathResult.rejected(rejection);
}
expanded.add(position);
if (expanded.size() > settings.maxFloodVolume()) {
return CavePathResult.rejected(HydrologyCaveRejection.VOLUME_LIMIT);
}
}
}
}
}
return CavePathResult.accepted(List.copyOf(expanded));
}
int selectNextAxis(
long sourceId,
int movesX,
int movesY,
int movesZ,
int usedX,
int usedY,
int usedZ
) {
double scoreX = nextAxisScore(movesX, usedX);
double scoreY = nextAxisScore(movesY, usedY);
double scoreZ = nextAxisScore(movesZ, usedZ);
double minimum = Math.min(scoreX, Math.min(scoreY, scoreZ));
int tieOffset = Math.floorMod(sourceId, 3);
for (int offset = 0; offset < 3; offset++) {
int axis = (tieOffset + offset) % 3;
double score = axis == 0 ? scoreX : axis == 1 ? scoreY : scoreZ;
if (score == minimum) {
return axis;
}
}
throw new IllegalStateException("No remaining throat axis");
}
double nextAxisScore(int moves, int used) {
if (used >= moves) {
return Double.POSITIVE_INFINITY;
}
return ((2D * used) + 1D) / moves;
}
HydrologyCaveRejection validateThroatPosition(
CaveVoxelView view,
HydrologyCaveSource source,
HydrologyCavePlannerSettings settings,
CavePosition position
) {
if (!view.isInWorld(position)) {
return HydrologyCaveRejection.WORLD_BOUNDARY;
}
HydrologyCaveRejection boundsRejection = planner.validateBounds(source, settings, position);
if (boundsRejection != HydrologyCaveRejection.NONE) {
return boundsRejection;
}
return planner.rejectionForHazard(planner.voxelAt(view, position), settings);
}
CaveGrottoResult buildGrotto(HydrologyCaveSource source, HydrologyCavePlannerSettings settings) {
int horizontalRadius = settings.grottoHorizontalRadius();
int verticalRadius = settings.grottoVerticalRadius();
Set<CavePosition> candidates = new LinkedHashSet<>();
for (int dx = -horizontalRadius; dx <= horizontalRadius; dx++) {
for (int dy = -verticalRadius; dy <= verticalRadius; dy++) {
for (int dz = -horizontalRadius; dz <= horizontalRadius; dz++) {
if (settings.grottoShape().contains(source, settings, dx, dy, dz)) {
candidates.add(source.target().offset(dx, dy, dz));
if (candidates.size() > settings.maxFloodVolume()) {
return CaveGrottoResult.rejected(HydrologyCaveRejection.VOLUME_LIMIT);
}
}
}
}
}
candidates.add(source.target());
for (int offset = 1; offset <= settings.dryHeadroom(); offset++) {
CavePosition headroom = new CavePosition(
source.target().x(), source.waterHeadY() + offset, source.target().z());
if (Math.abs(headroom.y() - source.target().y()) > verticalRadius) {
return CaveGrottoResult.rejected(HydrologyCaveRejection.DRY_HEADROOM_LIMIT);
}
candidates.add(headroom);
if (candidates.size() > settings.maxFloodVolume()) {
return CaveGrottoResult.rejected(HydrologyCaveRejection.VOLUME_LIMIT);
}
}
return CaveGrottoResult.accepted(connectedGrotto(source.target(), candidates));
}
Set<CavePosition> connectedGrotto(CavePosition target, Set<CavePosition> candidates) {
Queue<CavePosition> queue = new ArrayDeque<>();
Set<CavePosition> connected = new LinkedHashSet<>();
queue.add(target);
connected.add(target);
while (!queue.isEmpty()) {
CavePosition position = queue.remove();
for (CavePosition direction : HydrologyCaveContainmentPlanner.DIRECTIONS) {
CavePosition neighbor = position.offset(direction.x(), direction.y(), direction.z());
if (candidates.contains(neighbor) && connected.add(neighbor)) {
queue.add(neighbor);
}
}
}
return connected;
}
HydrologyCaveRejection validateGeneratedCarve(
CaveVoxelView view,
HydrologyCaveSource source,
HydrologyCavePlannerSettings settings,
Set<CavePosition> carve
) {
for (CavePosition position : carve) {
if (!view.isInWorld(position)) {
return HydrologyCaveRejection.WORLD_BOUNDARY;
}
HydrologyCaveRejection boundsRejection = planner.validateBounds(source, settings, position);
if (boundsRejection != HydrologyCaveRejection.NONE) {
return boundsRejection;
}
CaveVoxel voxel = planner.voxelAt(view, position);
if (isGeneratedInletCarve(view, source, settings, position, voxel)) {
continue;
}
HydrologyCaveRejection hazard = planner.rejectionForHazard(voxel, settings);
if (hazard != HydrologyCaveRejection.NONE) {
return hazard;
}
if (voxel != CaveVoxel.SOLID) {
return HydrologyCaveRejection.GROTTO_INTERSECTION;
}
}
return HydrologyCaveRejection.NONE;
}
CaveBoundaryResult validateGeneratedBoundary(
CaveVoxelView view,
HydrologyCaveSource source,
HydrologyCavePlannerSettings settings,
Set<CavePosition> carve
) {
Set<CavePosition> guards = new LinkedHashSet<>();
for (CavePosition position : carve) {
for (CavePosition direction : HydrologyCaveContainmentPlanner.DIRECTIONS) {
CavePosition neighbor = position.offset(direction.x(), direction.y(), direction.z());
if (carve.contains(neighbor)) {
continue;
}
if (isInletOpening(source, neighbor)
|| isGeneratedInletOpening(view, source, settings, neighbor)) {
continue;
}
if (!view.isInWorld(neighbor)) {
return CaveBoundaryResult.rejected(HydrologyCaveRejection.WORLD_BOUNDARY);
}
HydrologyCaveRejection boundsRejection = planner.validateBounds(source, settings, neighbor);
if (boundsRejection != HydrologyCaveRejection.NONE) {
return CaveBoundaryResult.rejected(boundsRejection);
}
CaveVoxel voxel = planner.voxelAt(view, neighbor);
HydrologyCaveRejection hazard = planner.rejectionForHazard(voxel, settings);
if (hazard != HydrologyCaveRejection.NONE) {
return CaveBoundaryResult.rejected(hazard);
}
if (voxel != CaveVoxel.SOLID) {
return CaveBoundaryResult.rejected(HydrologyCaveRejection.GROTTO_SHELL_OPEN);
}
guards.add(neighbor);
}
}
return CaveBoundaryResult.accepted(guards);
}
void addChamberActions(
Map<CavePosition, HydrologyCaveAction> actions,
Collection<CavePosition> positions,
int waterHeadY
) {
for (CavePosition position : positions) {
HydrologyCaveAction action = position.y() <= waterHeadY
? HydrologyCaveAction.WET_SOURCE
: HydrologyCaveAction.DRY_AIR;
actions.put(position, action);
}
}
void addThroatActions(
Map<CavePosition, HydrologyCaveAction> actions,
Collection<CavePosition> throat,
HydrologyCaveSource source
) {
for (CavePosition position : throat) {
HydrologyCaveAction action;
if (position.y() <= source.waterHeadY()) {
action = HydrologyCaveAction.WET_SOURCE;
} else if (source.mode() == HydrologyCaveMode.WATERFALL_POOL
|| source.mode() == HydrologyCaveMode.GENERATED_GROTTO) {
action = HydrologyCaveAction.FALLING_FLUID;
} else {
action = HydrologyCaveAction.DRY_AIR;
}
actions.put(position, action);
}
}
void addSealGuards(
CaveVoxelView view,
HydrologyCaveSource source,
Map<CavePosition, HydrologyCaveAction> actions
) {
Set<CavePosition> guards = new LinkedHashSet<>();
for (CavePosition position : List.copyOf(actions.keySet())) {
for (CavePosition direction : HydrologyCaveContainmentPlanner.DIRECTIONS) {
CavePosition neighbor = position.offset(direction.x(), direction.y(), direction.z());
if (actions.containsKey(neighbor)
|| isInletOpening(source, neighbor)
|| !view.isInWorld(neighbor)) {
continue;
}
if (planner.voxelAt(view, neighbor) == CaveVoxel.SOLID) {
guards.add(neighbor);
}
}
}
for (CavePosition guard : guards) {
actions.put(guard, HydrologyCaveAction.SEAL_GUARD);
}
}
boolean isInletOpening(HydrologyCaveSource source, CavePosition position) {
return position.equals(source.entry().offset(0, 1, 0));
}
boolean isGeneratedInletCarve(
CaveVoxelView view,
HydrologyCaveSource source,
HydrologyCavePlannerSettings settings,
CavePosition position,
CaveVoxel voxel
) {
if (voxel != CaveVoxel.CAVE_AIR && voxel != CaveVoxel.COMPATIBLE_FLUID) {
return false;
}
int extent = Math.max(0, settings.throatRadius() - 1);
long deltaX = (long) position.x() - source.entry().x();
long deltaZ = (long) position.z() - source.entry().z();
return position.y() >= source.entry().y() - extent
&& position.y() <= source.entry().y()
&& deltaX * deltaX + deltaZ * deltaZ <= (long) extent * extent
&& view.isOpenToSurface(position);
}
boolean isGeneratedInletOpening(
CaveVoxelView view,
HydrologyCaveSource source,
HydrologyCavePlannerSettings settings,
CavePosition position
) {
int radius = Math.max(1, settings.throatRadius());
long deltaX = (long) position.x() - source.entry().x();
long deltaZ = (long) position.z() - source.entry().z();
return position.y() >= source.entry().y()
&& position.y() <= source.entry().y() + 1
&& deltaX * deltaX + deltaZ * deltaZ < (long) radius * radius
&& view.isOpenToSurface(position);
}
}
@@ -29,91 +29,6 @@ public final class HydrologyCaveContainmentPlanner {
static final Comparator<HydrologyCaveSource> SOURCE_PRIORITY =
HydrologyCaveConflictPolicy.sourcePriority();
final HydrologyCaveChamberPlanner chambers;
public HydrologyCaveContainmentPlanner() {
this.chambers = new HydrologyCaveChamberPlanner(this);
}
public HydrologyCavePlan plan(
CaveVoxelView view,
HydrologyCaveSource source,
HydrologyCavePlannerSettings settings
) {
Objects.requireNonNull(view);
Objects.requireNonNull(source);
Objects.requireNonNull(settings);
HydrologyCaveRejection sourceRejection = validateSource(source);
if (sourceRejection != HydrologyCaveRejection.NONE) {
return rejected(source, sourceRejection);
}
CavePathResult throat = chambers.buildThroat(view, source, settings);
if (throat.rejection() != HydrologyCaveRejection.NONE) {
return rejected(source, throat.rejection());
}
return switch (source.mode()) {
case CLOSED_COMPONENT -> chambers.planClosedComponent(view, source, settings, throat.positions());
case GENERATED_GROTTO -> chambers.planGeneratedGrotto(view, source, settings, throat.positions());
case GROTTO_OR_CLOSED_COMPONENT -> chambers.planGrottoOrClosedComponent(
view,
source,
settings,
throat.positions()
);
case WATERFALL_POOL -> chambers.planWaterfallPool(
view,
source,
settings,
throat.positions()
);
case DEEP_POOL -> chambers.planDeepPool(view, source, settings, throat.positions());
};
}
public HydrologyCavePlanningResult planAll(
CaveVoxelView view,
Collection<HydrologyCaveSource> sources,
HydrologyCavePlannerSettings settings
) {
Objects.requireNonNull(view);
Objects.requireNonNull(sources);
Objects.requireNonNull(settings);
List<HydrologyCaveSource> orderedSources = new ArrayList<>(sources);
orderedSources.sort(SOURCE_PRIORITY);
List<HydrologyCavePlan> plans = new ArrayList<>(orderedSources.size());
Map<CavePosition, HydrologyCaveAction> combinedActions = new LinkedHashMap<>();
Map<CavePosition, HydrologyCaveSource> claimedBy = new HashMap<>();
Map<CavePosition, CaveVoxelPrecondition> combinedPreconditions = new LinkedHashMap<>();
for (HydrologyCaveSource source : orderedSources) {
HydrologyCavePlan candidate = plan(view, source, settings);
if (!candidate.accepted()) {
plans.add(candidate);
continue;
}
OptionalLong winnerSourceId = findWinningSourceId(
candidate.actions().keySet(),
claimedBy
);
if (winnerSourceId.isPresent()) {
plans.add(rejectedOverlap(source, winnerSourceId.getAsLong()));
} else {
plans.add(candidate);
combinedActions.putAll(candidate.actions());
combinedPreconditions.putAll(candidate.baselinePreconditions());
}
for (CavePosition position : candidate.actions().keySet()) {
claimedBy.putIfAbsent(position, source);
}
}
return new HydrologyCavePlanningResult(plans, combinedActions, combinedPreconditions);
}
public HydrologyCavePlanningResult validateAll(
CaveVoxelView view,
Collection<HydrologyCaveCandidate> candidates
@@ -698,14 +613,6 @@ public final class HydrologyCaveContainmentPlanner {
return HydrologyCaveRejection.NONE;
}
HydrologyCaveRejection rejectionForTarget(
CaveVoxel voxel,
HydrologyCavePlannerSettings settings
) {
HydrologyCaveRejection hazard = rejectionForHazard(voxel, settings);
return hazard == HydrologyCaveRejection.NONE ? HydrologyCaveRejection.NO_CAVE_TARGET : hazard;
}
HydrologyCaveRejection rejectionForHazard(
CaveVoxel voxel,
HydrologyCavePlannerSettings settings
@@ -722,50 +629,10 @@ public final class HydrologyCaveContainmentPlanner {
};
}
boolean isFluidReachable(CaveVoxel voxel, HydrologyCavePlannerSettings settings) {
return voxel == CaveVoxel.CAVE_AIR
|| (voxel == CaveVoxel.COMPATIBLE_FLUID
&& settings.existingFluidPolicy() != HydrologyCaveFluidPolicy.REJECT_EXISTING)
|| (voxel == CaveVoxel.INCOMPATIBLE_FLUID
&& settings.existingFluidPolicy() == HydrologyCaveFluidPolicy.REPLACE_CONTAINED);
}
CaveVoxel voxelAt(CaveVoxelView view, CavePosition position) {
return Objects.requireNonNull(view.voxelAt(position));
}
OptionalLong findWinningSourceId(
Set<CavePosition> positions,
Map<CavePosition, HydrologyCaveSource> claimedBy
) {
HydrologyCaveSource winner = null;
for (CavePosition position : positions) {
HydrologyCaveSource contender = claimedBy.get(position);
if (contender == null) {
continue;
}
if (winner == null || SOURCE_PRIORITY.compare(contender, winner) < 0) {
winner = contender;
}
}
return winner == null ? OptionalLong.empty() : OptionalLong.of(winner.sourceId());
}
HydrologyCavePlan accepted(
CaveVoxelView view,
HydrologyCaveSource source,
Map<CavePosition, HydrologyCaveAction> actions
) {
Map<CavePosition, CaveVoxelPrecondition> preconditions = new LinkedHashMap<>(actions.size());
for (CavePosition position : actions.keySet()) {
preconditions.put(
position,
new CaveVoxelPrecondition(voxelAt(view, position), view.isOpenToSurface(position))
);
}
return accepted(source, actions, preconditions);
}
HydrologyCavePlan accepted(
HydrologyCaveSource source,
Map<CavePosition, HydrologyCaveAction> actions,
@@ -1,21 +0,0 @@
package art.arcane.iris.generation.hydrology.cave;
@FunctionalInterface
public interface HydrologyCaveGrottoShape {
HydrologyCaveGrottoShape ELLIPSOID = (source, settings, dx, dy, dz) -> {
double horizontalRadius = settings.grottoHorizontalRadius();
double verticalRadius = settings.grottoVerticalRadius();
double normalized = ((double) dx * dx / (horizontalRadius * horizontalRadius))
+ ((double) dy * dy / (verticalRadius * verticalRadius))
+ ((double) dz * dz / (horizontalRadius * horizontalRadius));
return normalized <= 1D;
};
boolean contains(
HydrologyCaveSource source,
HydrologyCavePlannerSettings settings,
int offsetX,
int offsetY,
int offsetZ
);
}
@@ -6,72 +6,17 @@ public record HydrologyCavePlannerSettings(
int maxHorizontalRadius,
int maxDepth,
int maxFloodVolume,
int maxThroatLength,
int throatRadius,
int grottoHorizontalRadius,
int grottoVerticalRadius,
int dryHeadroom,
HydrologyCaveFluidPolicy existingFluidPolicy,
HydrologyCaveGrottoShape grottoShape,
int maxClosedComponentHorizontalRadius,
int maxClosedComponentDepth
) {
public static final int MAXIMUM_PLANNED_MUTATIONS = 262_144;
public HydrologyCavePlannerSettings(
int maxHorizontalRadius,
int maxDepth,
int maxFloodVolume,
int maxThroatLength,
int throatRadius,
int grottoHorizontalRadius,
int grottoVerticalRadius,
int dryHeadroom,
HydrologyCaveFluidPolicy existingFluidPolicy,
HydrologyCaveGrottoShape grottoShape
) {
this(
maxHorizontalRadius,
maxDepth,
maxFloodVolume,
maxThroatLength,
throatRadius,
grottoHorizontalRadius,
grottoVerticalRadius,
dryHeadroom,
existingFluidPolicy,
grottoShape,
maxHorizontalRadius,
maxDepth
);
}
public HydrologyCavePlannerSettings(
int maxHorizontalRadius,
int maxDepth,
int maxFloodVolume,
int maxThroatLength,
int grottoHorizontalRadius,
int grottoVerticalRadius,
HydrologyCaveFluidPolicy existingFluidPolicy
) {
this(
maxHorizontalRadius,
maxDepth,
maxFloodVolume,
maxThroatLength,
1,
grottoHorizontalRadius,
grottoVerticalRadius,
0,
existingFluidPolicy,
HydrologyCaveGrottoShape.ELLIPSOID
);
}
public HydrologyCavePlannerSettings {
Objects.requireNonNull(existingFluidPolicy);
Objects.requireNonNull(grottoShape);
if (maxHorizontalRadius < 1) {
throw new IllegalArgumentException("maxHorizontalRadius must be positive");
}
@@ -81,12 +26,6 @@ public record HydrologyCavePlannerSettings(
if (maxFloodVolume < 1) {
throw new IllegalArgumentException("maxFloodVolume must be positive");
}
if (maxThroatLength < 1) {
throw new IllegalArgumentException("maxThroatLength must be positive");
}
if (throatRadius < 1) {
throw new IllegalArgumentException("throatRadius must be positive");
}
if (grottoHorizontalRadius < 1) {
throw new IllegalArgumentException("grottoHorizontalRadius must be positive");
}
@@ -376,7 +376,7 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai
private double naturalHeight(CacheStripe stripe, long packed, int blockX, int blockZ) {
synchronized (stripe) {
double cached = stripe.naturalHeights.getAndMoveToLast(packed);
if (!Double.isNaN(cached) || stripe.naturalHeights.containsKey(packed)) {
if (!Double.isNaN(cached)) {
return cached;
}
TerrainBasis cachedBasis = stripe.bases.getAndMoveToLast(packed);
@@ -392,7 +392,7 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai
}
synchronized (stripe) {
double existing = stripe.naturalHeights.getAndMoveToLast(packed);
if (!Double.isNaN(existing) || stripe.naturalHeights.containsKey(packed)) {
if (!Double.isNaN(existing)) {
return existing;
}
stripe.naturalHeights.putAndMoveToLast(packed, sampled);
@@ -9,7 +9,8 @@ import art.arcane.iris.generation.hydrology.HydrologyTerrainSampler;
import art.arcane.iris.generation.hydrology.RiverFootprint;
import art.arcane.iris.generation.hydrology.IrisRiverBedProfile;
import art.arcane.iris.generation.hydrology.IrisRiverBlendStyle;
import it.unimi.dsi.fastutil.longs.Long2DoubleOpenHashMap;
import it.unimi.dsi.fastutil.doubles.DoubleArrayList;
import it.unimi.dsi.fastutil.ints.IntArrayList;
import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap;
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
import it.unimi.dsi.fastutil.longs.LongArrayList;
@@ -149,8 +150,9 @@ public final class ErosionFieldCompiler {
boolean[] basin = prepared.basin();
double[] bendOffsets = prepared.bendOffsets();
double[][] blendWidth = prepared.blendWidth();
Long2IntOpenHashMap nearest = geometry.nearest;
Long2DoubleOpenHashMap distance = geometry.distance;
Long2IntOpenHashMap indices = geometry.indices;
IntArrayList stations = geometry.stations;
DoubleArrayList distance = geometry.distance;
if (!geometry.prepared) {
for (int station = 0; station < count; station++) {
int radius = prepared.radii()[station];
@@ -172,14 +174,22 @@ public final class ErosionFieldCompiler {
continue;
}
long key = RiverFootprint.pack(cellX, cellZ);
int existing = nearest.get(key);
int index = indices.get(key);
int existing = index < 0 ? -1 : stations.getInt(index);
double existingDistance = index < 0 ? 0D : distance.getDouble(index);
// A cell on a station's cross-section row ties between the two segments meeting
// there; the later station owns it so the row uses the head its own ring bounded.
if (existing < 0
|| cellDistance < distance.get(key) - EPSILON
|| Math.abs(cellDistance - distance.get(key)) <= EPSILON && station > existing) {
nearest.put(key, station);
distance.put(key, cellDistance);
|| cellDistance < existingDistance - EPSILON
|| Math.abs(cellDistance - existingDistance) <= EPSILON && station > existing) {
if (index < 0) {
indices.put(key, stations.size());
stations.add(station);
distance.add(cellDistance);
} else {
stations.set(index, station);
distance.set(index, cellDistance);
}
}
}
}
@@ -189,17 +199,18 @@ public final class ErosionFieldCompiler {
int oceanStart = oceanStart(centerline, valley, terminal);
int inletStart = prepared.inletStart();
Long2ObjectOpenHashMap<SurfaceColumn> columns = new Long2ObjectOpenHashMap<>(wetOnly
? Math.min(nearest.size(), Math.max(16, count * 8)) : nearest.size());
? Math.min(indices.size(), Math.max(16, count * 8)) : indices.size());
LongArrayList wetKeys = new LongArrayList();
LongIterator candidates = nearest.keySet().iterator();
long[] coordinates = new long[Math.min(nearest.size(), HydrologyTerrainSampler.MAXIMUM_BATCH_SIZE)];
LongIterator candidates = indices.keySet().iterator();
long[] coordinates = new long[Math.min(indices.size(), HydrologyTerrainSampler.MAXIMUM_BATCH_SIZE)];
double[] outlines = new double[coordinates.length];
while (candidates.hasNext()) {
int pageSize = 0;
while (candidates.hasNext() && pageSize < coordinates.length) {
long key = candidates.nextLong();
int station = nearest.get(key);
double cellDistance = distance.get(key);
int geometryIndex = indices.get(key);
int station = stations.getInt(geometryIndex);
double cellDistance = distance.getDouble(geometryIndex);
int cellX = RiverFootprint.unpackX(key);
int cellZ = RiverFootprint.unpackZ(key);
int head = valley.head()[station];
@@ -231,8 +242,9 @@ public final class ErosionFieldCompiler {
HydrologyTerrainSample[] samples = sampler.sampleBatch(coordinates, pageSize);
for (int index = 0; index < pageSize; index++) {
long key = coordinates[index];
int station = nearest.get(key);
double cellDistance = distance.get(key);
int geometryIndex = indices.get(key);
int station = stations.getInt(geometryIndex);
double cellDistance = distance.getDouble(geometryIndex);
int cellX = RiverFootprint.unpackX(key);
int cellZ = RiverFootprint.unpackZ(key);
int head = valley.head()[station];
@@ -898,12 +910,13 @@ public final class ErosionFieldCompiler {
}
private static final class RasterGeometry {
private final Long2IntOpenHashMap nearest = new Long2IntOpenHashMap();
private final Long2DoubleOpenHashMap distance = new Long2DoubleOpenHashMap();
private final Long2IntOpenHashMap indices = new Long2IntOpenHashMap();
private final IntArrayList stations = new IntArrayList();
private final DoubleArrayList distance = new DoubleArrayList();
private boolean prepared;
private RasterGeometry() {
nearest.defaultReturnValue(-1);
indices.defaultReturnValue(-1);
}
}
@@ -22,36 +22,27 @@ package art.arcane.iris.generation.mantle;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.world.storage.matter.TileWrapper;
import art.arcane.iris.world.storage.matter.PreObjectMatterCell;
import com.google.common.collect.ImmutableList;
import it.unimi.dsi.fastutil.longs.LongOpenHashSet;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.integration.Identifier;
import art.arcane.iris.world.WorldMaintenance;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.GenerationFailures;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.terrain.Terrain3DColumn;
import art.arcane.iris.world.history.TransitionGenerationPlan;
import art.arcane.iris.generation.noise.IrisGeneratorStyle;
import art.arcane.iris.pack.value.IrisPosition;
import art.arcane.iris.generation.block.TileData;
import art.arcane.iris.generation.context.ChunkContext;
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCell;
import art.arcane.iris.generation.subterrain.SubterrainCell;
import art.arcane.iris.generation.subterrain.SubterrainRasterizer;
import art.arcane.volmlib.util.collection.KSet;
import art.arcane.volmlib.util.documentation.ChunkCoordinates;
import art.arcane.volmlib.util.function.Function3;
import art.arcane.volmlib.util.mantle.flag.MantleFlag;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
import art.arcane.volmlib.util.math.RNG;
import art.arcane.volmlib.util.matter.Matter;
import art.arcane.volmlib.util.matter.IrisMatter;
import art.arcane.volmlib.util.matter.MatterCavern;
import art.arcane.volmlib.util.matter.MatterSlice;
import art.arcane.volmlib.util.noise.CNG;
import art.arcane.iris.world.task.J;
import lombok.Data;
import lombok.AccessLevel;
@@ -61,9 +52,7 @@ import lombok.Setter;
import lombok.ToString;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.iris.generation.block.B;
import org.bukkit.util.Vector;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.function.Supplier;
import it.unimi.dsi.fastutil.longs.Long2ObjectMap;
@@ -71,7 +60,6 @@ import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
import java.util.Map;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReferenceArray;
import static art.arcane.iris.generation.mantle.EngineMantle.AIR;
@@ -215,122 +203,6 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable {
}
}
static Set<IrisPosition> getBallooned(Set<IrisPosition> vset, double radius) {
Set<IrisPosition> returnset = new HashSet<>();
int ceilrad = (int) Math.ceil(radius);
double r2 = Math.pow(radius, 2);
for (IrisPosition v : vset) {
int tipx = v.getX();
int tipy = v.getY();
int tipz = v.getZ();
for (int loopx = tipx - ceilrad; loopx <= tipx + ceilrad; loopx++) {
for (int loopy = tipy - ceilrad; loopy <= tipy + ceilrad; loopy++) {
for (int loopz = tipz - ceilrad; loopz <= tipz + ceilrad; loopz++) {
if (hypot(loopx - tipx, loopy - tipy, loopz - tipz) <= r2) {
returnset.add(new IrisPosition(loopx, loopy, loopz));
}
}
}
}
}
return returnset;
}
static Set<IrisPosition> getHollowed(Set<IrisPosition> vset) {
PositionLookup lookup = PositionLookup.of(vset);
Set<IrisPosition> returnset = new KSet<>();
for (IrisPosition v : vset) {
int x = v.getX();
int y = v.getY();
int z = v.getZ();
if (!(lookup.contains(x + 1, y, z)
&& lookup.contains(x - 1, y, z)
&& lookup.contains(x, y + 1, z)
&& lookup.contains(x, y - 1, z)
&& lookup.contains(x, y, z + 1)
&& lookup.contains(x, y, z - 1))) {
returnset.add(v);
}
}
return returnset;
}
private static double hypot(double x, double y, double z) {
return Math.pow(x, 2) + Math.pow(y, 2) + Math.pow(z, 2);
}
record PositionLookup(LongOpenHashSet keys, Set<IrisPosition> positions, int originX, int originY, int originZ) {
private static final int AXIS_BITS = 21;
private static final long AXIS_LIMIT = 1L << AXIS_BITS;
static PositionLookup of(Set<IrisPosition> positions) {
if (positions.isEmpty()) {
return new PositionLookup(new LongOpenHashSet(), positions, 0, 0, 0);
}
int minX = Integer.MAX_VALUE;
int minY = Integer.MAX_VALUE;
int minZ = Integer.MAX_VALUE;
int maxX = Integer.MIN_VALUE;
int maxY = Integer.MIN_VALUE;
int maxZ = Integer.MIN_VALUE;
for (IrisPosition position : positions) {
minX = Math.min(minX, position.getX());
minY = Math.min(minY, position.getY());
minZ = Math.min(minZ, position.getZ());
maxX = Math.max(maxX, position.getX());
maxY = Math.max(maxY, position.getY());
maxZ = Math.max(maxZ, position.getZ());
}
if ((long) maxX - minX >= AXIS_LIMIT - 2L
|| (long) maxY - minY >= AXIS_LIMIT - 2L
|| (long) maxZ - minZ >= AXIS_LIMIT - 2L) {
return new PositionLookup(null, positions, 0, 0, 0);
}
LongOpenHashSet keys = new LongOpenHashSet(positions.size());
for (IrisPosition position : positions) {
keys.add(key(position.getX() - minX, position.getY() - minY, position.getZ() - minZ));
}
return new PositionLookup(keys, positions, minX, minY, minZ);
}
boolean contains(int x, int y, int z) {
if (keys == null) {
return positions.contains(new IrisPosition(x, y, z));
}
long dx = (long) x - originX;
long dy = (long) y - originY;
long dz = (long) z - originZ;
if ((dx | dy | dz) < 0L || dx >= AXIS_LIMIT || dy >= AXIS_LIMIT || dz >= AXIS_LIMIT) {
return false;
}
return keys.contains(key(dx, dy, dz));
}
private static long key(long x, long y, long z) {
return (x << (AXIS_BITS * 2)) | (y << AXIS_BITS) | z;
}
}
private static double lengthSq(double x, double y, double z) {
return (x * x) + (y * y) + (z * z);
}
private static double lengthSq(double x, double z) {
return (x * x) + (z * z);
}
public <T> void setDataWarped(int x, int y, int z, T t, RNG rng, IrisData data, IrisGeneratorStyle style) {
setData((int) Math.round(style.warp(rng, data, x, x, y, -z)),
(int) Math.round(style.warp(rng, data, y, z, -x, y)),
(int) Math.round(style.warp(rng, data, z, -y, z, x)), t);
}
public ObjectPlacementCapture captureObjectPlacement(ObjectContinuationBundle.PlacementKey key) {
if (objectPlacementCapture.get() != null) {
throw new IllegalStateException("Object placement capture is already active");
@@ -376,6 +248,7 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable {
return;
}
if (t instanceof NativeBlockState) {
clearTile(matter, x, y, z);
clearDeferredPlacement(matter, x, y, z);
}
Class<?> sliceType = t instanceof NativeBlockState ? NativeBlockState.class : matter.getClass(t);
@@ -671,69 +544,21 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable {
return carvedColumn;
}
public boolean restorePrerequisiteData(int x, int y, int z, Class<?> type) {
void restoreData(int x, int y, int z, Class<?> type, Object value) {
Objects.requireNonNull(type, "type");
if (!allowsWrite(x, z)
|| y < 0
|| y >= mantle.getWorldHeight()
|| !isPreObjectType(type)) {
return false;
}
MantleChunk<Matter> chunk = acquireChunk(x >> 4, z >> 4);
int section = y >> 4;
if (chunk == null) {
return false;
}
synchronized (chunk) {
if (!chunk.exists(section)) {
return false;
}
Matter matter = chunk.get(section);
PreObjectMatterCell cell = preObjectCell(matter, x, y, z);
if (cell == null || !cell.captures(type)) {
return false;
}
restoreRaw(matter, x, y, z, type, cell.original(type));
recordObjectValue(x, y, z, type, cell.original(type));
return true;
}
}
public boolean restorePrerequisiteCell(int x, int y, int z) {
if (!allowsWrite(x, z) || y < 0 || y >= mantle.getWorldHeight()) {
return false;
return;
}
MantleChunk<Matter> chunk = acquireChunk(x >> 4, z >> 4);
int section = y >> 4;
if (chunk == null) {
return false;
throw new IllegalStateException("Object rollback exceeded its prepared Mantle radius at "
+ x + "," + y + "," + z);
}
synchronized (chunk) {
if (!chunk.exists(section)) {
return false;
Matter matter = chunk.get(y >> 4);
if (matter != null) {
restoreRaw(matter, x, y, z, type, value);
}
Matter matter = chunk.get(section);
PreObjectMatterCell cell = preObjectCell(matter, x, y, z);
if (cell == null) {
return false;
}
if (cell.blockCaptured()) {
restoreRaw(matter, x, y, z, NativeBlockState.class, cell.block());
recordObjectValue(x, y, z, NativeBlockState.class, cell.block());
}
if (cell.stringCaptured()) {
restoreRaw(matter, x, y, z, String.class, cell.string());
recordObjectValue(x, y, z, String.class, cell.string());
}
if (cell.cavernCaptured()) {
restoreRaw(matter, x, y, z, MatterCavern.class, cell.cavern());
recordObjectValue(x, y, z, MatterCavern.class, cell.cavern());
}
return true;
}
}
@@ -778,6 +603,13 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable {
return hydrology != null && hydrology.protectsPlacement();
}
private static void clearTile(Matter matter, int x, int y, int z) {
MatterSlice<TileWrapper> slice = matter.getSlice(TileWrapper.class);
if (slice != null) {
slice.set(x & 15, y & 15, z & 15, null);
}
}
private static void clearDeferredPlacement(Matter matter, int x, int y, int z) {
MatterSlice<Identifier> slice = matter.getSlice(Identifier.class);
if (slice != null) {
@@ -803,6 +635,7 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable {
if (hasProtectedHydrology(matter, x, y, z) || protectsSubterrainPlacement(x, y, z)) {
return;
}
clearTile(matter, x, y, z);
MatterSlice<NativeBlockState> blockSlice = matter.slice(NativeBlockState.class);
MatterSlice<Identifier> identifierSlice = matter.slice(Identifier.class);
capturePreObjectOriginal(matter, x, y, z, NativeBlockState.class);
@@ -1102,479 +935,6 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable {
return getEngineMantle().getEngine();
}
/**
* Set a sphere into the mantle
*
* @param cx the center x
* @param cy the center y
* @param cz the center z
* @param radius the radius of this sphere
* @param fill should it be filled? or just the outer shell?
* @param data the data to set
* @param <T> the type of data to apply to the mantle
*/
public <T> void setSphere(int cx, int cy, int cz, double radius, boolean fill, T data) {
setElipsoid(cx, cy, cz, radius, radius, radius, fill, data);
}
public <T> void setElipsoid(int cx, int cy, int cz, double rx, double ry, double rz, boolean fill, T data) {
setElipsoidFunction(cx, cy, cz, rx, ry, rz, fill, (a, b, c) -> data);
}
public <T> void setElipsoidWarped(int cx, int cy, int cz, double rx, double ry, double rz, boolean fill, T data, RNG rng, IrisData idata, IrisGeneratorStyle style) {
setElipsoidFunctionWarped(cx, cy, cz, rx, ry, rz, fill, (a, b, c) -> data, rng, idata, style);
}
/**
* Set an elipsoid into the mantle
*
* @param cx the center x
* @param cy the center y
* @param cz the center z
* @param rx the x radius
* @param ry the y radius
* @param rz the z radius
* @param fill should it be filled or just the outer shell?
* @param data the data to set
* @param <T> the type of data to apply to the mantle
*/
public <T> void setElipsoidFunction(int cx, int cy, int cz, double rx, double ry, double rz, boolean fill, Function3<Integer, Integer, Integer, T> data) {
rx += 0.5;
ry += 0.5;
rz += 0.5;
final double invRadiusX = 1 / rx;
final double invRadiusY = 1 / ry;
final double invRadiusZ = 1 / rz;
final int ceilRadiusX = (int) Math.ceil(rx);
final int ceilRadiusY = (int) Math.ceil(ry);
final int ceilRadiusZ = (int) Math.ceil(rz);
double nextXn = 0;
forX:
for (int x = 0; x <= ceilRadiusX; ++x) {
final double xn = nextXn;
nextXn = (x + 1) * invRadiusX;
double nextYn = 0;
forY:
for (int y = 0; y <= ceilRadiusY; ++y) {
final double yn = nextYn;
nextYn = (y + 1) * invRadiusY;
double nextZn = 0;
for (int z = 0; z <= ceilRadiusZ; ++z) {
final double zn = nextZn;
nextZn = (z + 1) * invRadiusZ;
double distanceSq = lengthSq(xn, yn, zn);
if (distanceSq > 1) {
if (z == 0) {
if (y == 0) {
break forX;
}
break forY;
}
break;
}
if (!fill) {
if (lengthSq(nextXn, yn, zn) <= 1 && lengthSq(xn, nextYn, zn) <= 1 && lengthSq(xn, yn, nextZn) <= 1) {
continue;
}
}
setData(x + cx, y + cy, z + cz, data.apply(x + cx, y + cy, z + cz));
setData(-x + cx, y + cy, z + cz, data.apply(-x + cx, y + cy, z + cz));
setData(x + cx, -y + cy, z + cz, data.apply(x + cx, -y + cy, z + cz));
setData(x + cx, y + cy, -z + cz, data.apply(x + cx, y + cy, -z + cz));
setData(-x + cx, y + cy, -z + cz, data.apply(-x + cx, y + cy, -z + cz));
setData(-x + cx, -y + cy, z + cz, data.apply(-x + cx, -y + cy, z + cz));
setData(x + cx, -y + cy, -z + cz, data.apply(x + cx, -y + cy, -z + cz));
setData(-x + cx, y + cy, -z + cz, data.apply(-x + cx, y + cy, -z + cz));
setData(-x + cx, -y + cy, -z + cz, data.apply(-x + cx, -y + cy, -z + cz));
}
}
}
}
public <T> void setElipsoidFunctionWarped(int cx, int cy, int cz, double rx, double ry, double rz, boolean fill, Function3<Integer, Integer, Integer, T> data, RNG rng, IrisData idata, IrisGeneratorStyle style) {
rx += 0.5;
ry += 0.5;
rz += 0.5;
final double invRadiusX = 1 / rx;
final double invRadiusY = 1 / ry;
final double invRadiusZ = 1 / rz;
final int ceilRadiusX = (int) Math.ceil(rx);
final int ceilRadiusY = (int) Math.ceil(ry);
final int ceilRadiusZ = (int) Math.ceil(rz);
double nextXn = 0;
forX:
for (int x = 0; x <= ceilRadiusX; ++x) {
final double xn = nextXn;
nextXn = (x + 1) * invRadiusX;
double nextYn = 0;
forY:
for (int y = 0; y <= ceilRadiusY; ++y) {
final double yn = nextYn;
nextYn = (y + 1) * invRadiusY;
double nextZn = 0;
for (int z = 0; z <= ceilRadiusZ; ++z) {
final double zn = nextZn;
nextZn = (z + 1) * invRadiusZ;
double distanceSq = lengthSq(xn, yn, zn);
if (distanceSq > 1) {
if (z == 0) {
if (y == 0) {
break forX;
}
break forY;
}
break;
}
if (!fill) {
if (lengthSq(nextXn, yn, zn) <= 1 && lengthSq(xn, nextYn, zn) <= 1 && lengthSq(xn, yn, nextZn) <= 1) {
continue;
}
}
setDataWarped(x + cx, y + cy, z + cz, data.apply(x + cx, y + cy, z + cz), rng, idata, style);
setDataWarped(-x + cx, y + cy, z + cz, data.apply(-x + cx, y + cy, z + cz), rng, idata, style);
setDataWarped(x + cx, -y + cy, z + cz, data.apply(x + cx, -y + cy, z + cz), rng, idata, style);
setDataWarped(x + cx, y + cy, -z + cz, data.apply(x + cx, y + cy, -z + cz), rng, idata, style);
setDataWarped(-x + cx, y + cy, -z + cz, data.apply(-x + cx, y + cy, -z + cz), rng, idata, style);
setDataWarped(-x + cx, -y + cy, z + cz, data.apply(-x + cx, -y + cy, z + cz), rng, idata, style);
setDataWarped(x + cx, -y + cy, -z + cz, data.apply(x + cx, -y + cy, -z + cz), rng, idata, style);
setDataWarped(-x + cx, y + cy, -z + cz, data.apply(-x + cx, y + cy, -z + cz), rng, idata, style);
setDataWarped(-x + cx, -y + cy, -z + cz, data.apply(-x + cx, -y + cy, -z + cz), rng, idata, style);
}
}
}
}
/**
* Set a cuboid of data in the mantle
*
* @param x1 the min x
* @param y1 the min y
* @param z1 the min z
* @param x2 the max x
* @param y2 the max y
* @param z2 the max z
* @param data the data to set
* @param <T> the type of data to apply to the mantle
*/
public <T> void setCuboid(int x1, int y1, int z1, int x2, int y2, int z2, T data) {
int j, k;
for (int i = x1; i <= x2; i++) {
for (j = x1; j <= x2; j++) {
for (k = x1; k <= x2; k++) {
setData(i, j, k, data);
}
}
}
}
/**
* Set a pyramid of data in the mantle
*
* @param cx the center x
* @param cy the base y
* @param cz the center z
* @param data the data to set
* @param size the size of the pyramid (width of base & height)
* @param filled should it be filled or hollow
* @param <T> the type of data to apply to the mantle
*/
@SuppressWarnings("ConstantConditions")
public <T> void setPyramid(int cx, int cy, int cz, T data, int size, boolean filled) {
int height = size;
for (int y = 0; y <= height; ++y) {
size--;
for (int x = 0; x <= size; ++x) {
for (int z = 0; z <= size; ++z) {
if ((filled && z <= size && x <= size) || z == size || x == size) {
setData(x + cx, y + cy, z + cz, data);
setData(-x + cx, y + cy, z + cz, data);
setData(x + cx, y + cy, -z + cz, data);
setData(-x + cx, y + cy, -z + cz, data);
}
}
}
}
}
/**
* Set a 3d line
*
* @param a the first point
* @param b the second point
* @param radius the radius
* @param filled hollow or filled?
* @param data the data
* @param <T> the type of data to apply to the mantle
*/
public <T> void setLine(IrisPosition a, IrisPosition b, double radius, boolean filled, T data) {
setLine(ImmutableList.of(a, b), radius, filled, data);
}
public <T> void setLine(List<IrisPosition> vectors, double radius, boolean filled, T data) {
setLineConsumer(vectors, radius, filled, (_x, _y, _z) -> data);
}
/**
* Set lines for points
*
* @param vectors the points
* @param radius the radius
* @param filled hollow or filled?
* @param data the data to set
* @param <T> the type of data to apply to the mantle
*/
public <T> void setLineConsumer(List<IrisPosition> vectors, double radius, boolean filled, Function3<Integer, Integer, Integer, T> data) {
Set<IrisPosition> vset = cleanup(vectors);
vset = getBallooned(vset, radius);
if (!filled) {
vset = getHollowed(vset);
}
setConsumer(vset, data);
}
/**
* Set lines for points
*
* @param vectors the points
* @param radius the radius
* @param filled hollow or filled?
* @param data the data to set
* @param <T> the type of data to apply to the mantle
*/
public <T> void setNoiseMasked(List<IrisPosition> vectors, double radius, double threshold, CNG shape, Set<IrisPosition> masks, boolean filled, Function3<Integer, Integer, Integer, T> data) {
Set<IrisPosition> vset = cleanup(vectors);
vset = masks == null ? getBallooned(vset, radius) : getMasked(vset, masks, radius);
vset.removeIf(p -> shape.noise(p.getX(), p.getY(), p.getZ()) < threshold);
if (!filled) {
vset = getHollowed(vset);
}
setConsumer(vset, data);
}
static Set<IrisPosition> getMasked(Set<IrisPosition> vectors, Set<IrisPosition> masks, double radius) {
Set<IrisPosition> vset = new KSet<>();
int ceil = (int) Math.ceil(radius);
double r2 = Math.pow(radius, 2);
PositionLookup maskLookup = PositionLookup.of(masks);
for (IrisPosition v : vectors) {
int tipX = v.getX();
int tipY = v.getY();
int tipZ = v.getZ();
for (int x = -ceil; x <= ceil; x++) {
for (int y = -ceil; y <= ceil; y++) {
for (int z = -ceil; z <= ceil; z++) {
if (hypot(x, y, z) > r2 || !maskLookup.contains(x, y, z))
continue;
vset.add(new IrisPosition(tipX + x, tipY + y, tipZ + z));
}
}
}
}
return vset;
}
private static Set<IrisPosition> cleanup(List<IrisPosition> vectors) {
Set<IrisPosition> vset = new KSet<>();
for (int i = 0; vectors.size() != 0 && i < vectors.size() - 1; i++) {
IrisPosition pos1 = vectors.get(i);
IrisPosition pos2 = vectors.get(i + 1);
int x1 = pos1.getX();
int y1 = pos1.getY();
int z1 = pos1.getZ();
int x2 = pos2.getX();
int y2 = pos2.getY();
int z2 = pos2.getZ();
int tipx = x1;
int tipy = y1;
int tipz = z1;
int dx = Math.abs(x2 - x1);
int dy = Math.abs(y2 - y1);
int dz = Math.abs(z2 - z1);
if (dx + dy + dz == 0) {
vset.add(new IrisPosition(tipx, tipy, tipz));
continue;
}
int dMax = Math.max(Math.max(dx, dy), dz);
if (dMax == dx) {
for (int domstep = 0; domstep <= dx; domstep++) {
tipx = x1 + domstep * (x2 - x1 > 0 ? 1 : -1);
tipy = (int) Math.round(y1 + domstep * ((double) dy) / ((double) dx) * (y2 - y1 > 0 ? 1 : -1));
tipz = (int) Math.round(z1 + domstep * ((double) dz) / ((double) dx) * (z2 - z1 > 0 ? 1 : -1));
vset.add(new IrisPosition(tipx, tipy, tipz));
}
} else if (dMax == dy) {
for (int domstep = 0; domstep <= dy; domstep++) {
tipy = y1 + domstep * (y2 - y1 > 0 ? 1 : -1);
tipx = (int) Math.round(x1 + domstep * ((double) dx) / ((double) dy) * (x2 - x1 > 0 ? 1 : -1));
tipz = (int) Math.round(z1 + domstep * ((double) dz) / ((double) dy) * (z2 - z1 > 0 ? 1 : -1));
vset.add(new IrisPosition(tipx, tipy, tipz));
}
} else /* if (dMax == dz) */ {
for (int domstep = 0; domstep <= dz; domstep++) {
tipz = z1 + domstep * (z2 - z1 > 0 ? 1 : -1);
tipy = (int) Math.round(y1 + domstep * ((double) dy) / ((double) dz) * (y2 - y1 > 0 ? 1 : -1));
tipx = (int) Math.round(x1 + domstep * ((double) dx) / ((double) dz) * (x2 - x1 > 0 ? 1 : -1));
vset.add(new IrisPosition(tipx, tipy, tipz));
}
}
}
return vset;
}
/**
* Set a cylinder in the mantle
*
* @param cx the center x
* @param cy the base y
* @param cz the center z
* @param data the data to set
* @param radius the radius
* @param height the height of the cyl
* @param filled filled or not
*/
public <T> void setCylinder(int cx, int cy, int cz, T data, double radius, int height, boolean filled) {
setCylinder(cx, cy, cz, data, radius, radius, height, filled);
}
/**
* Set a cylinder in the mantle
*
* @param cx the center x
* @param cy the base y
* @param cz the center z
* @param data the data to set
* @param radiusX the x radius
* @param radiusZ the z radius
* @param height the height of this cyl
* @param filled filled or hollow?
*/
public <T> void setCylinder(int cx, int cy, int cz, T data, double radiusX, double radiusZ, int height, boolean filled) {
int affected = 0;
radiusX += 0.5;
radiusZ += 0.5;
if (height == 0) {
return;
} else if (height < 0) {
height = -height;
cy = cy - height;
}
if (cy < 0) {
cy = 0;
} else if (cy + height - 1 > getMantle().getWorldHeight()) {
height = getMantle().getWorldHeight() - cy + 1;
}
final double invRadiusX = 1 / radiusX;
final double invRadiusZ = 1 / radiusZ;
final int ceilRadiusX = (int) Math.ceil(radiusX);
final int ceilRadiusZ = (int) Math.ceil(radiusZ);
double nextXn = 0;
forX:
for (int x = 0; x <= ceilRadiusX; ++x) {
final double xn = nextXn;
nextXn = (x + 1) * invRadiusX;
double nextZn = 0;
for (int z = 0; z <= ceilRadiusZ; ++z) {
final double zn = nextZn;
nextZn = (z + 1) * invRadiusZ;
double distanceSq = lengthSq(xn, zn);
if (distanceSq > 1) {
if (z == 0) {
break forX;
}
break;
}
if (!filled) {
if (lengthSq(nextXn, zn) <= 1 && lengthSq(xn, nextZn) <= 1) {
continue;
}
}
for (int y = 0; y < height; ++y) {
setData(cx + x, cy + y, cz + z, data);
setData(cx + -x, cy + y, cz + z, data);
setData(cx + x, cy + y, cz + -z, data);
setData(cx + -x, cy + y, cz + -z, data);
}
}
}
}
public <T> void set(IrisPosition pos, T data) {
try {
setData(pos.getX(), pos.getY(), pos.getZ(), data);
} catch (Throwable e) {
GenerationFailures.rethrowEngineFailure(e);
// Reached per position while an object writes past the edge of the writer window, which is
// how a large object is clipped. Nothing here is actionable from outside the engine.
IrisLogging.debug("No set? " + data.toString() + " for " + pos.toString());
}
}
public <T> void set(List<IrisPosition> positions, T data) {
for (IrisPosition i : positions) {
set(i, data);
}
}
public <T> void set(Set<IrisPosition> positions, T data) {
for (IrisPosition i : positions) {
set(i, data);
}
}
public <T> void setConsumer(Set<IrisPosition> positions, Function3<Integer, Integer, Integer, T> data) {
for (IrisPosition i : positions) {
set(i, data.apply(i.getX(), i.getY(), i.getZ()));
}
}
public boolean isWithin(Vector pos) {
return isWithin(pos.getBlockX(), pos.getBlockY(), pos.getBlockZ());
}
public boolean isWithin(int x, int y, int z) {
int cx = x >> 4;
int cz = z >> 4;
if (y < 0 || y >= mantle.getWorldHeight()) {
return false;
}
return cx >= this.x - radius && cx <= this.x + radius
&& cz >= this.z - radius && cz <= this.z + radius;
}
@Override
public void close() {
for (int index = 0; index < window.length(); index++) {
@@ -1689,6 +1049,10 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable {
maximumX = Math.max(maximumX, x);
maximumZ = Math.max(maximumZ, z);
if (type == NativeBlockState.class) {
MatterSlice<TileWrapper> tiles = fragment.getSlice(TileWrapper.class);
if (tiles != null) {
tiles.set(x & 15, y, z & 15, null);
}
fragment.slice(Identifier.class).set(x & 15, y, z & 15, null);
fragment.slice(type).set(x & 15, y, z & 15, value == null ? AIR.get() : value);
} else {
@@ -8,6 +8,7 @@ import art.arcane.volmlib.util.mantle.flag.MantleFlag;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
import art.arcane.volmlib.util.matter.Matter;
import art.arcane.volmlib.util.matter.MatterSlice;
import java.util.List;
import java.util.Map;
@@ -51,6 +52,10 @@ public final class ObjectContinuationPersistence {
public static void put(Matter payload, int x, int y, int z, Object value) {
if (value instanceof NativeBlockState state) {
MatterSlice<TileWrapper> tiles = payload.getSlice(TileWrapper.class);
if (tiles != null) {
tiles.set(x, y, z, null);
}
String customKey = state.deferredPlacementKey();
NativeBlockState base = state.placementBaseState();
boolean custom = state.isCustom() && customKey != null && base != null;
@@ -99,12 +99,19 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer {
}
captureOriginal(originals, mutation.key());
if (mutation.key().type() == NativeBlockState.class) {
captureOriginal(originals, new DataKey(
mutation.key().x(),
mutation.key().y(),
mutation.key().z(),
Identifier.class
));
TileWrapper tile = writer.getDataIfPresent(
mutation.key().x(), mutation.key().y(), mutation.key().z(), TileWrapper.class);
if (tile != null) {
originals.putIfAbsent(new DataKey(
mutation.key().x(), mutation.key().y(), mutation.key().z(), TileWrapper.class), tile);
}
Identifier identifier = writer.getDataIfPresent(
mutation.key().x(), mutation.key().y(), mutation.key().z(), Identifier.class);
if (identifier != null || mutation instanceof CustomBlockMutation) {
originals.putIfAbsent(new DataKey(
mutation.key().x(), mutation.key().y(), mutation.key().z(), Identifier.class),
identifier == null ? CLEARED : identifier);
}
}
mutation.apply(writer);
}
@@ -148,6 +155,7 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer {
Identifier identifier = Identifier.fromString(placementKey);
OverlayCell cell = writableCell(x, y, z);
cell.block = baseState;
cell.tile = CLEARED;
cell.identifier = identifier;
mutations.add(replayed != null
? replayed : new CustomBlockMutation(new DataKey(x, y, z, NativeBlockState.class), state));
@@ -243,6 +251,7 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer {
OverlayCell cell = writableCell(x, y, z);
if (data instanceof NativeBlockState) {
cell.block = data;
cell.tile = CLEARED;
cell.identifier = CLEARED;
} else {
cell.put(type, data);
@@ -325,15 +334,15 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer {
return cell;
}
private Object prerequisiteOrCleared(int x, int y, int z, Class<?> type) {
Object value = writer.getPrerequisiteDataIfPresent(x, y, z, type);
private Object publishedOrCleared(int x, int y, int z, Class<?> type) {
Object value = writer.getDataIfPresent(x, y, z, type);
return value == null ? CLEARED : value;
}
private void captureOriginal(LinkedHashMap<DataKey, Object> originals, DataKey key) {
originals.computeIfAbsent(
key,
candidate -> prerequisiteOrCleared(
candidate -> publishedOrCleared(
candidate.x(),
candidate.y(),
candidate.z(),
@@ -383,36 +392,26 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer {
private void rollback(LinkedHashMap<DataKey, Object> originals, Throwable failure) {
ArrayList<Map.Entry<DataKey, Object>> entries = new ArrayList<>(originals.entrySet());
entries.sort((first, second) -> Boolean.compare(
second.getKey().type() == NativeBlockState.class,
first.getKey().type() == NativeBlockState.class
));
for (Map.Entry<DataKey, Object> entry : entries) {
for (int index = entries.size() - 1; index >= 0; index--) {
Map.Entry<DataKey, Object> entry = entries.get(index);
DataKey key = entry.getKey();
try {
if (key.type() == NativeBlockState.class
&& writer.restorePrerequisiteCell(key.x(), key.y(), key.z())) {
continue;
}
if (writer.restorePrerequisiteData(key.x(), key.y(), key.z(), key.type())) {
continue;
}
writer.clearData(key.x(), key.y(), key.z(), key.type());
Object original = entry.getValue();
if (original != CLEARED) {
writer.setData(key.x(), key.y(), key.z(), original);
}
writer.restoreData(key.x(), key.y(), key.z(), key.type(),
entry.getValue() == CLEARED ? null : entry.getValue());
} catch (Throwable rollbackFailure) {
if (rollbackFailure != failure) {
failure.addSuppressed(rollbackFailure);
}
}
}
}
private static final class OverlayCell {
private Object block;
private Object hydrology;
private Object cavern;
private Object identifier;
private Object tile;
private Object string;
private Object treeMaterial;
private Map<Class<?>, Object> other;
@@ -430,6 +429,9 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer {
if (type == Identifier.class) {
return identifier;
}
if (type == TileWrapper.class) {
return tile;
}
if (type == String.class) {
return string;
}
@@ -446,6 +448,8 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer {
cavern = value;
} else if (type == Identifier.class) {
identifier = value;
} else if (type == TileWrapper.class) {
tile = value;
} else if (type == String.class) {
string = value;
} else if (type == TreeBlockMaterial.class) {
@@ -19,9 +19,7 @@ final class StructureFoundationPlanner {
return;
}
long columnKey = pack(x, z);
if (!columns.containsKey(columnKey) || y < columns.get(columnKey)) {
columns.put(columnKey, y);
}
columns.mergeInt(columnKey, y, Math::min);
}
static int findGroundY(int foundationY, int maxDepth, int minimumY, IntPredicate solidAtY) {
@@ -4,6 +4,8 @@ import art.arcane.iris.structure.placement.PlacedStructurePiece;
import art.arcane.iris.structure.object.IrisObject;
import art.arcane.iris.structure.object.IrisObjectRotation;
import art.arcane.iris.generation.block.B;
import art.arcane.iris.generation.block.VectorMap;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.iris.generation.geometry.IrisBlockVector;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.structure.StructureCarvingFootprint;
@@ -36,11 +38,12 @@ final class StructurePieceColumns {
if (object == null || rotation == null || object.getBlocks() == null) {
continue;
}
for (IrisBlockVector local : object.getBlocks().keys()) {
if (local == null || B.isAir(object.getBlocks().get(local))) {
VectorMap<NativeBlockState>.Cursor cursor = object.getBlocks().cursor();
while (cursor.next()) {
if (B.isAir(cursor.value())) {
continue;
}
IrisBlockVector rotated = rotation.rotate(local.clone());
IrisBlockVector rotated = rotation.rotate(cursor.key());
long worldX = (long) piece.getX() + rotated.getBlockX();
long worldY = (long) piece.getY() + rotated.getBlockY();
long worldZ = (long) piece.getZ() + rotated.getBlockZ();
@@ -22,13 +22,15 @@ public final class TerrainMatterView {
}
public static <T> T get(Mantle<Matter> mantle, int x, int y, int z, Class<T> type) {
if (!hasJournal(type)) {
return mantle.get(x, y, z, type);
Objects.requireNonNull(type, "Terrain matter type");
if (y < 0 || y >= mantle.getWorldHeight()) {
return null;
}
MantleChunk<Matter> chunk = mantle.getChunk(x >> 4, z >> 4);
synchronized (chunk) {
PreObjectMatterCell cell = mantle.get(x, y, z, PreObjectMatterCell.class);
return cell != null && cell.captures(type) ? cell.original(type) : mantle.get(x, y, z, type);
MantleChunk<Matter> chunk = mantle.useChunk(x >> 4, z >> 4);
try {
return get(chunk, x, y, z, type);
} finally {
chunk.release();
}
}
@@ -49,6 +49,9 @@ import lombok.experimental.Accessors;
@Data
@EqualsAndHashCode(callSuper = false)
public class IrisExpression extends IrisRegistrant {
private static final int MAX_RETAINED_EVALUATION_DEPTH = 8;
private static final int MAX_RETAINED_ARGUMENTS = 256;
@ArrayType(type = IrisExpressionLoad.class, min = 1)
@Description("Variables to use in this expression")
private KList<IrisExpressionLoad> variables = new KList<>();
@@ -66,6 +69,10 @@ public class IrisExpression extends IrisRegistrant {
@Setter(AccessLevel.NONE)
private final transient LazyBoundedCache<Long, ProceduralStream<Double>> streams = new LazyBoundedCache<>(8);
@Getter(AccessLevel.NONE)
@Setter(AccessLevel.NONE)
private final transient ThreadLocal<EvaluationScratch> evaluationScratch = ThreadLocal.withInitial(EvaluationScratch::new);
private Expression expression() {
return expressionCache.aquire(() -> {
Scope scope = new Scope(); // Create variable scope. This scope can hold both constants and invocation variables.
@@ -108,31 +115,37 @@ public class IrisExpression extends IrisRegistrant {
}
public double evaluate(RNG rng, double x, double z) {
double[] g = new double[3 + getVariables().size()];
int m = 0;
for (IrisExpressionLoad i : getVariables()) {
g[m++] = i.getValue(rng, getLoader(), x, z);
EvaluationScratch scratch = evaluationScratch.get();
double[] arguments = scratch.acquire(3 + getVariables().size());
try {
int index = 0;
for (IrisExpressionLoad variable : getVariables()) {
arguments[index++] = variable.getValue(rng, getLoader(), x, z);
}
arguments[index++] = x;
arguments[index++] = z;
arguments[index] = -1;
return expression().evaluate(new FunctionContext(rng), arguments);
} finally {
scratch.release();
}
g[m++] = x;
g[m++] = z;
g[m] = -1;
return expression().evaluate(new FunctionContext(rng), g);
}
public double evaluate(RNG rng, double x, double y, double z) {
double[] g = new double[3 + getVariables().size()];
int m = 0;
for (IrisExpressionLoad i : getVariables()) {
g[m++] = i.getValue(rng, getLoader(), x, y, z);
EvaluationScratch scratch = evaluationScratch.get();
double[] arguments = scratch.acquire(3 + getVariables().size());
try {
int index = 0;
for (IrisExpressionLoad variable : getVariables()) {
arguments[index++] = variable.getValue(rng, getLoader(), x, y, z);
}
arguments[index++] = x;
arguments[index++] = y;
arguments[index] = z;
return expression().evaluate(new FunctionContext(rng), arguments);
} finally {
scratch.release();
}
g[m++] = x;
g[m++] = y;
g[m] = z;
return expression().evaluate(new FunctionContext(rng), g);
}
@Override
@@ -144,4 +157,28 @@ public class IrisExpression extends IrisRegistrant {
public String getTypeName() {
return "Expression";
}
private static final class EvaluationScratch {
private final double[][] retained = new double[MAX_RETAINED_EVALUATION_DEPTH][];
private int depth;
private double[] acquire(int length) {
double[] arguments;
if (depth >= retained.length || length > MAX_RETAINED_ARGUMENTS) {
arguments = new double[length];
} else {
arguments = retained[depth];
if (arguments == null || arguments.length != length) {
arguments = new double[length];
retained[depth] = arguments;
}
}
depth++;
return arguments;
}
private void release() {
depth--;
}
}
}
@@ -258,7 +258,7 @@ public class IrisGeneratorStyle {
@SuppressWarnings("BooleanMethodIsAlwaysInverted")
public boolean isFlat() {
return style == null || style.equals(NoiseStyle.FLAT);
return expression == null && imageMap == null && (style == null || style.equals(NoiseStyle.FLAT));
}
public double getMaxFractureDistance() {
@@ -210,8 +210,8 @@ public final class DimensionTerrainContext implements DataProvider {
if (key == null || key.isBlank()) {
continue;
}
maximum += biome.getGenLinkMax(key, engine);
minimum += biome.getGenLinkMin(key, engine);
maximum += biome.getGenLinkMax(key);
minimum += biome.getGenLinkMin(key);
}
interpolatorBounds.put(biome, new NoiseBounds(minimum, maximum));
}
@@ -329,8 +329,8 @@ public final class DimensionTerrainContext implements DataProvider {
if (key == null || key.isBlank()) {
continue;
}
maximum += sampledBiome.getGenLinkMax(key, engine);
minimum += sampledBiome.getGenLinkMin(key, engine);
maximum += sampledBiome.getGenLinkMax(key);
minimum += sampledBiome.getGenLinkMin(key);
}
return new NoiseBounds(minimum, maximum);
} catch (Throwable e) {
@@ -32,7 +32,14 @@ import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.math.M;
import art.arcane.volmlib.util.math.RNG;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public final class GenerationCacheWarmer {
private GenerationCacheWarmer() {
@@ -43,6 +50,7 @@ public final class GenerationCacheWarmer {
IrisData data = engine.getData();
RNG root = new RNG(engine.getSeedManager().getComponent() + 7777L);
int[] counter = {0};
List<ProceduralWarmTask> proceduralTasks = new ArrayList<>();
KList<IrisBiome> biomes = engine.getAllBiomes();
biomes.sort(Comparator.comparing(IrisBiome::getLoadKey));
@@ -50,7 +58,7 @@ public final class GenerationCacheWarmer {
warmPlacements(biome.getObjects(), root, counter, data, engine);
warmDecorators(biome.getDecorators(), engine.getSeedManager().getComponent(), counter, data);
warmOres(biome.getOres(), engine.getSeedManager().getTerrain(), counter, data);
warmProcedural(biome.getProceduralObjects(), root, counter, data);
collectProcedural(biome.getProceduralObjects(), root, counter, proceduralTasks);
}
KList<IrisRegion> regions = engine.getDimension().getAllRegions(engine);
@@ -58,10 +66,11 @@ public final class GenerationCacheWarmer {
for (IrisRegion region : regions) {
warmPlacements(region.getObjects(), root, counter, data, engine);
warmOres(region.getOres(), engine.getSeedManager().getTerrain(), counter, data);
warmProcedural(region.getProceduralObjects(), root, counter, data);
collectProcedural(region.getProceduralObjects(), root, counter, proceduralTasks);
}
warmOres(engine.getDimension().getOres(), engine.getSeedManager().getTerrain(), counter, data);
warmProcedural(proceduralTasks, data);
IrisLogging.debug("[IrisEngine timing] cache warm " + counter[0] + " configs=" + (M.ms() - start) + "ms");
}
@@ -110,15 +119,93 @@ public final class GenerationCacheWarmer {
}
}
private static void warmProcedural(IrisProceduralObjects procedural, RNG root, int[] counter, IrisData data) {
private static void collectProcedural(IrisProceduralObjects procedural, RNG root, int[] counter,
List<ProceduralWarmTask> tasks) {
if (procedural == null) {
return;
}
for (IrisProceduralPlacement placement : procedural.getAllPlacements()) {
if (placement == null) {
continue;
}
placement.getVariantObject(data, root.nextParallelRNG(counter[0]++), null);
if (placement != null) {
tasks.add(new ProceduralWarmTask(placement, root.nextParallelRNG(counter[0]++)));
}
}
}
static void warmProcedural(List<ProceduralWarmTask> tasks, IrisData data) {
if (tasks.isEmpty()) {
return;
}
IdentityHashMap<IrisProceduralPlacement, List<ProceduralWarmTask>> identities = new IdentityHashMap<>();
List<List<ProceduralWarmTask>> groups = new ArrayList<>();
for (ProceduralWarmTask task : tasks) {
List<ProceduralWarmTask> group = identities.get(task.placement());
if (group == null) {
group = new ArrayList<>();
identities.put(task.placement(), group);
groups.add(group);
}
group.add(task);
}
int workers = Math.min(groups.size(), Math.min(4, Runtime.getRuntime().availableProcessors()));
if (workers == 1) {
for (ProceduralWarmTask task : tasks) {
task.placement().getVariantObject(data, task.rng(), null);
}
return;
}
Throwable failure = null;
boolean interrupted = false;
try (ExecutorService executor = Executors.newFixedThreadPool(workers, runnable -> {
Thread thread = new Thread(runnable, "Iris-Procedural-Warm");
thread.setDaemon(true);
thread.setPriority(Thread.MIN_PRIORITY);
return thread;
})) {
List<Future<?>> pending = new ArrayList<>(groups.size());
for (List<ProceduralWarmTask> group : groups) {
pending.add(executor.submit(() -> warmGroup(group, data)));
}
for (Future<?> future : pending) {
boolean complete = false;
while (!complete) {
try {
future.get();
complete = true;
} catch (InterruptedException exception) {
interrupted = true;
} catch (ExecutionException exception) {
Throwable cause = exception.getCause();
if (failure == null) {
failure = cause;
} else if (failure != cause) {
failure.addSuppressed(cause);
}
complete = true;
}
}
}
} finally {
if (interrupted) {
Thread.currentThread().interrupt();
}
}
if (failure instanceof Error error) {
throw error;
}
if (failure instanceof RuntimeException exception) {
throw exception;
}
if (failure != null) {
throw new IllegalStateException("Procedural generation cache warming failed.", failure);
}
}
private static void warmGroup(List<ProceduralWarmTask> group, IrisData data) {
for (ProceduralWarmTask task : group) {
task.placement().getVariantObject(data, task.rng(), null);
}
}
record ProceduralWarmTask(IrisProceduralPlacement placement, RNG rng) {
}
}
@@ -1955,8 +1955,8 @@ public class IrisComplex implements DataProvider {
continue;
}
max += biome.getGenLinkMax(key, engine);
min += biome.getGenLinkMin(key, engine);
max += biome.getGenLinkMax(key);
min += biome.getGenLinkMin(key);
}
return new GeneratorBounds(min, max);
@@ -18,13 +18,9 @@
package art.arcane.iris.generation.runtime;
import art.arcane.volmlib.util.data.KCache;
public interface MeteredCache {
long getSize();
KCache<?, ?> getRawCache();
long getMaxSize();
default double getUsage() {
@@ -20,7 +20,6 @@ package art.arcane.iris.generation.runtime;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.volmlib.util.data.KCache;
import art.arcane.volmlib.util.format.Form;
import art.arcane.iris.platform.bukkit.plugin.IrisService;
import art.arcane.volmlib.util.scheduling.Looper;
@@ -33,7 +32,6 @@ import java.util.Objects;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class PreservationSVC implements IrisService, PreservationRegistry {
@@ -188,10 +186,6 @@ public class PreservationSVC implements IrisService, PreservationRegistry {
caches.add(new WeakReference<>(cache));
}
public List<KCache<?, ?>> caches() {
return cacheStream().map(MeteredCache::getRawCache).collect(Collectors.toList());
}
@Unmodifiable
public List<MeteredCache> getCaches() {
return cacheStream().toList();
@@ -192,7 +192,8 @@ public final class IrisDimensionStackActuator extends EngineAssignedActuator<Nat
IrisDimension dimension = terrainContext.getDimension();
IrisData data = terrainContext.getData();
IrisBiome biome = layer.biome();
int surfaceDepth = Math.max(0, layer.normalTerrainHeight());
int bedrockDepth = dimension.isBedrock() ? 1 : 0;
int surfaceDepth = Math.max(0, layer.normalTerrainHeight() + 1 - bedrockDepth);
int fluidDepth = Math.max(0, layer.fluidHeight() - layer.normalTerrainHeight());
KList<NativeBlockState> surfaceBlocks = biome == null || layer.terrainColumn() != null
? null
@@ -207,6 +208,8 @@ public final class IrisDimensionStackActuator extends EngineAssignedActuator<Nat
terrainContext.getSlopeStream()
);
int paletteSurfaceY = layer.normalTerrainHeight();
KList<NativeBlockState> ceilingBlocks = null;
int terrainSpan = layer.terrainColumn() == null ? -1 : layer.terrainColumn().spanCount() - 1;
KList<NativeBlockState> seaBlocks = biome == null || fluidDepth == 0
? null
: biome.generateSeaLayers(worldX, worldZ, rng, fluidDepth, data);
@@ -239,13 +242,32 @@ public final class IrisDimensionStackActuator extends EngineAssignedActuator<Nat
if (sourceSurfaceY != paletteSurfaceY) {
paletteSurfaceY = sourceSurfaceY;
surfaceBlocks = null;
ceilingBlocks = null;
}
if (depth == 0 && layer.surfaceBlock() != null) {
writeBlock(output, metadata, localX, y, localZ, layer.surfaceBlock());
} else {
if (terrainSpan >= 0) {
while (terrainSpan >= 0 && sourceY < layer.terrainColumn().ceiling(terrainSpan)) {
terrainSpan--;
}
int ceilingY = layer.terrainColumn().ceiling(terrainSpan);
int ceilingDepth = sourceY - ceilingY;
if (terrainSpan > 0 && depth >= 2 && ceilingDepth < 2) {
if (ceilingBlocks == null && biome != null) {
ceilingBlocks = biome.generateCeilingLayers(dimension, worldX, worldZ, rng,
2, ceilingY, data, null);
}
writeBlock(output, metadata, localX, y, localZ,
ceilingBlocks != null && ceilingBlocks.hasIndex(ceilingDepth)
? ceilingBlocks.get(ceilingDepth) : layer.rockBlock());
continue;
}
}
if (surfaceBlocks == null && biome != null) {
surfaceBlocks = biome.generateLayersWithSlope(dimension, worldX, worldZ, rng,
sourceSurfaceY, sourceSurfaceY, data, terrainContext.getSurfaceSlopeStream(sourceSurfaceY));
sourceSurfaceY + 1 - bedrockDepth, sourceSurfaceY, data,
terrainContext.getSurfaceSlopeStream(sourceSurfaceY));
}
writeBlock(output, metadata, localX, y, localZ,
surfaceBlocks != null && surfaceBlocks.hasIndex(depth)
@@ -178,15 +178,24 @@ public class IrisFloatingChildBiomeModifier extends EngineAssignedModifier<Nativ
return block == null ? fallbackSolid : block;
}
private PaletteContext createPaletteContext(IrisBiome parent, IrisFloatingChildBiomes entry, IrisDimension dimension, int wx, int wz, int paletteDepth, IrisData data, IrisComplex complex) {
private PaletteContext createPaletteContext(IrisBiome parent, IrisFloatingChildBiomes entry, IrisDimension dimension, int wx, int wz, int paletteDepth, int surfaceY, int baseY, IrisData data, IrisComplex complex) {
IrisBiome target = entry == null ? parent : entry.getRealBiome(parent, data);
RNG layerRng = paletteRng(rng, entry);
KList<NativeBlockState> topBlocks = target == null ? null : target.generateLayers(dimension, wx, wz, layerRng, paletteDepth, paletteDepth, data, complex);
boolean physicalDepth = usesPhysicalDepth(target, entry);
int topPaletteDepth = physicalDepth ? Math.max(paletteDepth, surfaceY - baseY + 1) : paletteDepth;
KList<NativeBlockState> topBlocks = target == null ? null : target.generateLayers(dimension, wx, wz, layerRng, topPaletteDepth, surfaceY, data, complex);
if (topBlocks == null || topBlocks.isEmpty()) {
topBlocks = parent.generateLayers(dimension, wx, wz, layerRng, paletteDepth, paletteDepth, data, complex);
physicalDepth = usesPhysicalDepth(parent, entry);
topPaletteDepth = physicalDepth ? Math.max(paletteDepth, surfaceY - baseY + 1) : paletteDepth;
topBlocks = parent.generateLayers(dimension, wx, wz, layerRng, topPaletteDepth, surfaceY, data, complex);
}
KList<NativeBlockState> bottomBlocks = generateBottomPaletteLayers(entry, dimension, wx, wz, layerRng, paletteDepth, data, complex);
return new PaletteContext(topBlocks, bottomBlocks, B.getState("minecraft:stone"));
return new PaletteContext(topBlocks, bottomBlocks, B.getState("minecraft:stone"), surfaceY, physicalDepth);
}
private static boolean usesPhysicalDepth(IrisBiome biome, IrisFloatingChildBiomes entry) {
return biome != null && biome.isLockLayers() && (entry == null || entry.getBottomPaletteMode() == null
|| entry.getBottomPaletteMode() == FloatingBottomPaletteMode.DEPTH);
}
static RNG paletteRng(RNG worldRng, IrisFloatingChildBiomes entry) {
@@ -198,11 +207,15 @@ public class IrisFloatingChildBiomeModifier extends EngineAssignedModifier<Nativ
private final KList<NativeBlockState> topBlocks;
private final KList<NativeBlockState> bottomBlocks;
private final NativeBlockState fallbackSolid;
private final int surfaceY;
private final boolean physicalDepth;
private PaletteContext(KList<NativeBlockState> topBlocks, KList<NativeBlockState> bottomBlocks, NativeBlockState fallbackSolid) {
private PaletteContext(KList<NativeBlockState> topBlocks, KList<NativeBlockState> bottomBlocks, NativeBlockState fallbackSolid, int surfaceY, boolean physicalDepth) {
this.topBlocks = topBlocks;
this.bottomBlocks = bottomBlocks;
this.fallbackSolid = fallbackSolid;
this.surfaceY = surfaceY;
this.physicalDepth = physicalDepth;
}
}
@@ -251,12 +264,12 @@ public class IrisFloatingChildBiomeModifier extends EngineAssignedModifier<Nativ
IrisFloatingChildBiomes entry = sample.entryAt(k);
PaletteContext paletteContext = paletteContexts.get(entry);
if (paletteContext == null) {
paletteContext = createPaletteContext(parent, entry, dimension, wx, wz, paletteDepth, data, complex);
paletteContext = createPaletteContext(parent, entry, dimension, wx, wz, paletteDepth, y, sample.islandBaseY, data, complex);
paletteContexts.put(entry, paletteContext);
}
int depth = topDepthByEntry.getOrDefault(entry, 0);
int bottomDepth = bottomDepths == null || bottomDepths[k] < 0 ? depth : bottomDepths[k];
NativeBlockState block = selectPaletteBlock(entry, paletteContext.topBlocks, paletteContext.bottomBlocks, depth, bottomDepth, paletteContext.fallbackSolid);
NativeBlockState block = selectPaletteBlock(entry, paletteContext.topBlocks, paletteContext.bottomBlocks, paletteContext.physicalDepth ? paletteContext.surfaceY - y : depth, bottomDepth, paletteContext.fallbackSolid);
if (block != null) {
output.set(xf, y, zf, block);
}
@@ -315,7 +315,7 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator<NativeBloc
cut = Math.max(0, naturalSurfaceY - layerSurfaceY);
}
blocks = biome.generateLayers(dimension, realX, realZ, rng,
layerSurfaceY + cut, layerSurfaceY + cut, data, complex);
layerSurfaceY + cut + 1 - bedrockFloor, layerSurfaceY + cut, data, complex);
}
int deepFloor = Math.max(bedrockFloor, layerCeilingY > 0 ? layerCeilingY + 2 : 0);
@@ -436,7 +436,7 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator<NativeBloc
int depthFromFace = y - faceY;
if (upperBlocks == null && upperBiome != null) {
upperBlocks = upperBiome.generateLayersWithSlope(upperContext.getDimension(),
realX, realZ, rng, sourceSurfaceY, sourceSurfaceY,
realX, realZ, rng, sourceSurfaceY + 1 - (bedrockEnabled ? 1 : 0), sourceSurfaceY,
upperContext.getData(), upperContext.getSurfaceSlopeStream(sourceSurfaceY));
}
if (upperBlocks != null && upperBlocks.hasIndex(depthFromFace)) {
@@ -7,7 +7,6 @@ import art.arcane.iris.generation.runtime.MeteredCache;
import art.arcane.volmlib.util.stream.BasicStream;
import art.arcane.volmlib.util.stream.ProceduralStream;
import art.arcane.volmlib.util.cache.WorldCache2DDouble;
import art.arcane.volmlib.util.data.KCache;
public class CachedDoubleStream2D extends BasicStream<Double> implements ProceduralStream<Double>, MeteredCache, ChunkFillableStream2D, ChunkFillableDoubleStream2D {
private final ProceduralStream<Double> stream;
@@ -56,11 +55,6 @@ public class CachedDoubleStream2D extends BasicStream<Double> implements Procedu
return cache.getSize();
}
@Override
public KCache<?, ?> getRawCache() {
return null;
}
@Override
public long getMaxSize() {
return cache.getMaxSize();
@@ -24,7 +24,6 @@ import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.MeteredCache;
import art.arcane.volmlib.util.cache.ChunkCache2D;
import art.arcane.volmlib.util.cache.WorldCache2D;
import art.arcane.volmlib.util.data.KCache;
import art.arcane.volmlib.util.stream.BasicStream;
import art.arcane.volmlib.util.stream.ProceduralStream;
public class CachedStream2D<T> extends BasicStream<T> implements ProceduralStream<T>, MeteredCache, ChunkFillableStream2D {
@@ -70,11 +69,6 @@ public class CachedStream2D<T> extends BasicStream<T> implements ProceduralStrea
return cache.getSize();
}
@Override
public KCache<?, ?> getRawCache() {
return null;
}
@Override
public long getMaxSize() {
return cache.getMaxSize();
@@ -64,11 +64,6 @@ public class CachedStream3D<T> extends BasicStream<T> implements ProceduralStrea
return cache.getSize();
}
@Override
public KCache<?, ?> getRawCache() {
return cache;
}
@Override
public long getMaxSize() {
return cache.getMaxSize();
@@ -6,17 +6,15 @@ import art.arcane.iris.generation.runtime.Engine;
import art.arcane.volmlib.util.function.Function3;
import art.arcane.volmlib.util.stream.BasicStream;
import art.arcane.volmlib.util.stream.ProceduralStream;
import java.util.Objects;
public class ContextInjectingStream<T> extends BasicStream<T> {
private final Engine engine;
private final Function3<ChunkContext, Integer, Integer, T> contextAccessor;
public ContextInjectingStream(ProceduralStream<T> stream, Function3<ChunkContext, Integer, Integer, T> contextAccessor) {
this(stream, null, contextAccessor);
}
public ContextInjectingStream(ProceduralStream<T> stream, Engine engine, Function3<ChunkContext, Integer, Integer, T> contextAccessor) {
super(stream);
this.engine = engine;
this.engine = Objects.requireNonNull(engine);
this.contextAccessor = contextAccessor;
}
@@ -30,9 +28,9 @@ public class ContextInjectingStream<T> extends BasicStream<T> {
int blockZ = (int) z;
if (chunkContext != null
&& (engine == null || context.getEngine() == engine)
&& context.getEngine() == engine
&& context.getGenerationSessionId() == chunkContext.getGenerationSessionId()
&& (engine == null || context.getGenerationSessionId() == engine.getGenerationSessionId())
&& context.getGenerationSessionId() == engine.getGenerationSessionId()
&& blockX >> 4 == chunkContext.getX() >> 4
&& blockZ >> 4 == chunkContext.getZ() >> 4) {
T t = contextAccessor.apply(chunkContext, blockX & 15, blockZ & 15);
@@ -1,13 +1,9 @@
package art.arcane.iris.generation.stream;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.noise.IrisStyledRange;
import art.arcane.iris.generation.context.ChunkContext;
import art.arcane.volmlib.util.function.Function3;
import art.arcane.volmlib.util.math.RNG;
import art.arcane.volmlib.util.stream.ProceduralStream;
import art.arcane.volmlib.util.stream.interpolation.Interpolated;
public final class GenerationStreams {
private GenerationStreams() {
@@ -25,19 +21,7 @@ public final class GenerationStreams {
return new CachedStream3D<T>(name, engine, stream, maxSize);
}
public static <T> ProceduralStream<T> contextInjecting(ProceduralStream<T> stream, Function3<ChunkContext, Integer, Integer, T> contextAccessor) {
//return stream;
return new ContextInjectingStream<>(stream, contextAccessor);
}
public static <T> ProceduralStream<T> contextInjecting(ProceduralStream<T> stream, Engine engine, Function3<ChunkContext, Integer, Integer, T> contextAccessor) {
return new ContextInjectingStream<>(stream, engine, contextAccessor);
}
public static <T> ProceduralStream<Double> style(ProceduralStream<T> stream, RNG rng, IrisStyledRange range, IrisData data) {
return ProceduralStream.of((x, z) -> {
double d = stream.getDouble(x, z);
return range.get(rng, d, -d, data);
}, Interpolated.DOUBLE);
}
}

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