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 committed 2026-10-06 20:51:28 -04:00
1 parent a2bcd7a302
commit 7f5ce70f49
197 files changed
+7560 -4420

No files matched your search

@@ -18,6 +18,8 @@
package art.arcane.iris.command; package art.arcane.iris.command;
import art.arcane.iris.pack.PackFingerprints;
import art.arcane.iris.Iris; import art.arcane.iris.Iris;
import art.arcane.iris.pack.validation.PackCompatReport; import art.arcane.iris.pack.validation.PackCompatReport;
import art.arcane.iris.pack.PackDirectoryResolver; 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)))); s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_NO_CLEANUP_CANDIDATES_FOUND_PACK, MessageArgument.untrusted("pack", String.valueOf(pack))));
return; 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())))); 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); reportPaths(s, result.quarantinedPaths(), BukkitRuntimeMessages.COMMAND_PACK_PATH_QUARANTINED);
return; 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)))); s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_NOTHING_RESTORE_PACK, MessageArgument.untrusted("pack", String.valueOf(pack))));
return; 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())))); 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); reportPaths(s, result.restoredPaths(), BukkitRuntimeMessages.COMMAND_PACK_PATH_RESTORED);
return; return;
@@ -363,47 +363,79 @@ public class CommandPack implements DirectorExecutor {
return persisted == null || persisted.isBlank() ? "unknown" : persisted; 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 { try {
PackValidationResult result = PackValidator.validate(packFolder); if (ticket == null) {
PackValidationRegistry.publish(result); throw new IllegalStateException("Pack content is currently being changed.");
reportResult(s, result); }
return result; 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) { } catch (Throwable e) {
Iris.reportError("Pack validation failed for '" + packFolder.getName() + "'", e); Iris.reportError("Pack validation failed for '" + packFolder.getName() + "'", e);
String detail = e.getMessage() == null || e.getMessage().isBlank() String detail = e.getMessage() == null || e.getMessage().isBlank()
? e.getClass().getSimpleName() ? e.getClass().getSimpleName()
: e.getMessage(); : e.getMessage();
PackValidationResult result = new PackValidationResult( result = new PackValidationResult(
packFolder.getName(), packFolder.getName(),
List.of("Pack validation failed with " + e.getClass().getSimpleName() + ": " + detail), List.of("Pack validation failed with " + e.getClass().getSimpleName() + ": " + detail),
List.of(), List.of(),
System.currentTimeMillis()); System.currentTimeMillis());
PackValidationRegistry.publish(result); contentFingerprint = "";
reportResult(s, result); contextFingerprint = "";
return result;
} }
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) { private void persistValidationCache(File packsRoot) {
List<File> packDirectories = PackDirectoryResolver.listVisiblePackDirectories(packsRoot); try {
List<PackValidationResult> results = new ArrayList<>(packDirectories.size()); String contextFingerprint = PackValidationCache.contextFingerprint();
for (File packDirectory : packDirectories) { PackFingerprints.PackContentSnapshot snapshot = PackFingerprints.computePackContentSnapshot(packsRoot);
PackValidationResult result = PackValidationRegistry.get(packDirectory.getName()); List<File> packDirectories = PackDirectoryResolver.listVisiblePackDirectories(packsRoot);
if (result == null) { List<PackValidationResult> results = new ArrayList<>(packDirectories.size());
// The boot cache only loads when it covers every pack, so a partial write is for (File packDirectory : packDirectories) {
// pointless - but say so instead of silently skipping the persist forever. PackValidationResult result = PackValidationRegistry.getMatching(
Iris.warn("Pack validation cache not written: \"" + packDirectory.getName() packDirectory.toPath(), snapshot.packContents().get(packDirectory.getName()));
+ "\" has no registered result. Run /iris pack validate all to repopulate it."); if (result == null) {
Iris.warn("Pack validation cache not written: \"" + packDirectory.getName()
+ "\" has no validation matching its current content. Run /iris pack validate to refresh it.");
return;
}
results.add(result);
}
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; return;
} }
results.add(result); for (int index = 0; index < packDirectories.size(); index++) {
} File packDirectory = packDirectories.get(index);
try { 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( PackValidationCache.save(
Iris.instance.getDataFile("cache", "pack-validation.json").toPath(), Iris.instance.getDataFile("cache", "pack-validation.json").toPath(),
PackValidationCache.contentFingerprint(packsRoot), snapshot.content(),
PackValidationCache.contextFingerprint(), contextFingerprint,
results); results);
} catch (IOException | RuntimeException e) { } catch (IOException | RuntimeException e) {
Iris.reportError("Could not persist refreshed pack-validation results", e); Iris.reportError("Could not persist refreshed pack-validation results", e);
@@ -18,8 +18,9 @@
package art.arcane.iris.world; package art.arcane.iris.world;
import art.arcane.iris.pack.PackFingerprints;
import art.arcane.iris.configuration.IrisSettings; import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.pack.datapack.ServerConfigurator;
import art.arcane.iris.Iris; import art.arcane.iris.Iris;
import art.arcane.iris.world.lifecycle.WorldLifecycleStaging; 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(); List<String> packNames = packDirs.stream().map(File::getName).sorted().toList();
Path cacheFile = IrisPlatforms.get().dataFile("cache", "pack-validation.json").toPath(); Path cacheFile = IrisPlatforms.get().dataFile("cache", "pack-validation.json").toPath();
ServerConfigurator.PackContentSnapshot contentSnapshot = PackFingerprints.PackContentSnapshot contentSnapshot =
new ServerConfigurator.PackContentSnapshot("", Map.of()); new PackFingerprints.PackContentSnapshot("", Map.of());
String contextFingerprint = ""; String contextFingerprint = "";
Optional<List<PackValidationResult>> cached = Optional.empty(); Optional<List<PackValidationResult>> cached = Optional.empty();
try { try {
contentSnapshot = ServerConfigurator.computePackContentSnapshot(packsRoot); contentSnapshot = PackFingerprints.computePackContentSnapshot(packsRoot);
contextFingerprint = PackValidationCache.contextFingerprint(); contextFingerprint = PackValidationCache.contextFingerprint();
if (!externalContentChanged) { if (!externalContentChanged) {
cached = PackValidationCache.load( cached = PackValidationCache.load(
@@ -274,19 +275,19 @@ public final class IrisWorldGeneratorResolver {
private static FreshValidation validateStablePacks( private static FreshValidation validateStablePacks(
File packsRoot, File packsRoot,
List<File> initialPackDirs, List<File> initialPackDirs,
ServerConfigurator.PackContentSnapshot initialSnapshot PackFingerprints.PackContentSnapshot initialSnapshot
) { ) {
List<File> packDirs = initialPackDirs; List<File> packDirs = initialPackDirs;
ServerConfigurator.PackContentSnapshot before = initialSnapshot; PackFingerprints.PackContentSnapshot before = initialSnapshot;
for (int attempt = 0; attempt < VALIDATION_STABILITY_ATTEMPTS; attempt++) { for (int attempt = 0; attempt < VALIDATION_STABILITY_ATTEMPTS; attempt++) {
List<String> packNames = packDirs.stream().map(File::getName).sorted().toList(); List<String> packNames = packDirs.stream().map(File::getName).sorted().toList();
List<PackValidationResult> results = validatePacks(packDirs); List<PackValidationResult> results = validatePacks(packDirs);
ServerConfigurator.PackContentSnapshot after; PackFingerprints.PackContentSnapshot after;
try { try {
after = ServerConfigurator.computePackContentSnapshot(packsRoot); after = PackFingerprints.computePackContentSnapshot(packsRoot);
} catch (RuntimeException exception) { } catch (RuntimeException exception) {
Iris.reportError("Could not verify Iris pack bytes after validation", 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<File> afterPackDirs = PackDirectoryResolver.listVisiblePackDirectories(packsRoot);
List<String> afterPackNames = afterPackDirs.stream().map(File::getName).sorted().toList(); List<String> afterPackNames = afterPackDirs.stream().map(File::getName).sorted().toList();
@@ -923,7 +924,7 @@ public final class IrisWorldGeneratorResolver {
private record FreshValidation( private record FreshValidation(
List<File> packDirs, List<File> packDirs,
ServerConfigurator.PackContentSnapshot contentSnapshot, PackFingerprints.PackContentSnapshot contentSnapshot,
List<PackValidationResult> results, List<PackValidationResult> results,
boolean stable 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.runtime.RuntimeInjection;
import art.arcane.iris.world.IrisCreator; import art.arcane.iris.world.IrisCreator;
import art.arcane.iris.world.IrisToolbelt; 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.Material;
import org.bukkit.block.Biome; import org.bukkit.block.Biome;
import art.arcane.volmlib.nativelib.terrain.NativeTerrainAccess; import art.arcane.volmlib.nativelib.terrain.NativeTerrainAccess;
@@ -19,6 +26,10 @@ import org.bukkit.command.CommandSender;
import org.bukkit.generator.ChunkGenerator; import org.bukkit.generator.ChunkGenerator;
import org.bukkit.plugin.java.JavaPlugin; 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.concurrent.CompletableFuture;
import java.util.List; import java.util.List;
import java.util.logging.Level; import java.util.logging.Level;
@@ -26,6 +37,7 @@ import java.util.logging.Level;
public final class NativeLifecycleFixture extends JavaPlugin implements WorldRuntimeExecution { public final class NativeLifecycleFixture extends JavaPlugin implements WorldRuntimeExecution {
private static final NamespacedKey WORLD_KEY = NamespacedKey.minecraft("native_lifecycle_probe"); 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 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 static final long WORLD_SEED = 78264193L;
private NativeWorldRuntime runtime; private NativeWorldRuntime runtime;
@@ -45,7 +57,7 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
@Override @Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) { public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (arguments.length != 1) { 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; return true;
} }
try { try {
@@ -56,6 +68,8 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
case "terrain-create" -> createTerrain(sender); case "terrain-create" -> createTerrain(sender);
case "terrain-check" -> checkTerrain(sender); case "terrain-check" -> checkTerrain(sender);
case "terrain-unload" -> unloadTerrain(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() case "status" -> sender.sendMessage("NATIVE_STATUS available=" + runtime.available()
+ " loaded=" + (Bukkit.getWorld(WORLD_KEY) != null)); + " loaded=" + (Bukkit.getWorld(WORLD_KEY) != null));
default -> throw new IllegalArgumentException("Unknown lifecycle operation"); default -> throw new IllegalArgumentException("Unknown lifecycle operation");
@@ -66,6 +80,61 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
return true; 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 { private void create(CommandSender sender) throws ReflectiveOperationException {
if (!runtime.available() || Bukkit.getWorld(WORLD_KEY) != null) { if (!runtime.available() || Bukkit.getWorld(WORLD_KEY) != null) {
throw new IllegalStateException("Native runtime unavailable or probe world already loaded"); 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); int y = world.getHighestBlockYAt(x, z);
if (y < 90 || y > 100 || world.getBlockAt(x, y, z).getType() != Material.LIME_WOOL 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, y - 3, z).getType() != Material.GREEN_CONCRETE
|| world.getBlockAt(x, world.getMinHeight(), z).getType() != Material.GREEN_CONCRETE
|| world.getBiome(x, y, z) != Biome.PLAINS) { || world.getBiome(x, y, z) != Biome.PLAINS) {
throw new IllegalStateException("Terrain column differs at " + x + "," + y + "," + z throw new IllegalStateException("Terrain column differs at " + x + "," + y + "," + z
+ ": " + world.getBlockAt(x, y, z).getType()); + ": " + world.getBlockAt(x, y, z).getType());
@@ -11,6 +11,15 @@
"block": "minecraft:lime_wool" "block": "minecraft:lime_wool"
} }
] ]
},
{
"minHeight": 384,
"maxHeight": 384,
"palette": [
{
"block": "minecraft:green_concrete"
}
]
} }
], ],
"generators": [ "generators": [
@@ -21,7 +21,7 @@
"rockPalette": { "rockPalette": {
"palette": [ "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 xx = rX + dx;
int yy = y + dy; int yy = y + dy;
int zz = rZ + dz; 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); ? 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.ModdedEntitySpawnService;
import art.arcane.iris.modded.service.ModdedLogFilterService; import art.arcane.iris.modded.service.ModdedLogFilterService;
import art.arcane.iris.modded.service.ModdedPreservationService; 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.ModdedSettingsHotloadService;
import art.arcane.iris.modded.service.ModdedStudioHotloadService; import art.arcane.iris.modded.service.ModdedStudioHotloadService;
import art.arcane.iris.modded.service.ModdedTreeFellerService; import art.arcane.iris.modded.service.ModdedTreeFellerService;
@@ -477,6 +478,7 @@ public final class ModdedEngineBootstrap {
createdServices.register(ModdedLogFilterService.class, new ModdedLogFilterService()); createdServices.register(ModdedLogFilterService.class, new ModdedLogFilterService());
createdServices.register(ModdedEngineMaintenanceService.class, new ModdedEngineMaintenanceService()); createdServices.register(ModdedEngineMaintenanceService.class, new ModdedEngineMaintenanceService());
createdServices.register(ModdedSettingsHotloadService.class, new ModdedSettingsHotloadService()); createdServices.register(ModdedSettingsHotloadService.class, new ModdedSettingsHotloadService());
createdServices.register(ModdedPackUpdateService.class, new ModdedPackUpdateService());
ModdedStudioHotloadService studioHotloadService = createdServices.register( ModdedStudioHotloadService studioHotloadService = createdServices.register(
ModdedStudioHotloadService.class, new ModdedStudioHotloadService()); ModdedStudioHotloadService.class, new ModdedStudioHotloadService());
createdServices.register(ModdedChunkUpdateService.class, new ModdedChunkUpdateService()); createdServices.register(ModdedChunkUpdateService.class, new ModdedChunkUpdateService());
@@ -27,7 +27,6 @@ import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger; 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 * 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 long SHUTDOWN_DRAIN_MILLIS = 2_000L;
private static final AtomicInteger GEN_THREAD_SEQ = new AtomicInteger(); private static final AtomicInteger GEN_THREAD_SEQ = new AtomicInteger();
private static final ChunkSystem CHUNK_SYSTEM = detectChunkSystem(); private static final ChunkSystem CHUNK_SYSTEM = detectChunkSystem();
private static final AtomicReference<ExecutorService> GEN_POOL = new AtomicReference<>(createGenPool()); private static ExecutorService generationPool = createGenPool();
private ModdedGenPool() { private ModdedGenPool() {
} }
@@ -63,39 +62,32 @@ public final class ModdedGenPool {
return CHUNK_SYSTEM.description(); return CHUNK_SYSTEM.description();
} }
static ExecutorService pool() { static synchronized ExecutorService pool() {
ExecutorService pool = GEN_POOL.get(); if (generationPool.isShutdown()) {
if (pool != null && !pool.isShutdown()) {
return pool;
}
start();
ExecutorService restarted = GEN_POOL.get();
if (restarted == null) {
throw new RejectedExecutionException("Iris gen pool is shut down"); throw new RejectedExecutionException("Iris gen pool is shut down");
} }
return restarted; return generationPool;
} }
static void start() { static synchronized void start() {
while (true) { if (!generationPool.isShutdown()) {
ExecutorService current = GEN_POOL.get(); return;
if (current != null && !current.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() { static void shutdown() {
ExecutorService pool = GEN_POOL.getAndSet(null); ExecutorService pool;
if (pool == null) { synchronized (ModdedGenPool.class) {
return; pool = generationPool;
if (pool.isTerminated()) {
return;
}
pool.shutdown();
} }
pool.shutdown();
try { try {
if (pool.awaitTermination(SHUTDOWN_DRAIN_MILLIS, TimeUnit.MILLISECONDS)) { if (pool.awaitTermination(SHUTDOWN_DRAIN_MILLIS, TimeUnit.MILLISECONDS)) {
return; return;
@@ -20,13 +20,11 @@ package art.arcane.iris.modded;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedLoader; 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.configuration.IrisSettings;
import art.arcane.iris.diagnostics.splash.IrisSplashComposer; import art.arcane.iris.diagnostics.splash.IrisSplashComposer;
import art.arcane.iris.diagnostics.splash.IrisSplashRenderer; import art.arcane.iris.diagnostics.splash.IrisSplashRenderer;
import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisLogging;
import java.io.File;
public final class ModdedIrisSplash { public final class ModdedIrisSplash {
@@ -34,18 +32,11 @@ public final class ModdedIrisSplash {
} }
public static void print(NativeModdedLoader loader) { public static void print(NativeModdedLoader loader) {
printPacks(loader);
if (isLogoEnabled()) { if (isLogoEnabled()) {
printLogo(loader); 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) { private static void printLogo(NativeModdedLoader loader) {
String serverLine = loader.platformName() + " / Minecraft " + loader.minecraftVersion(); String serverLine = loader.platformName() + " / Minecraft " + loader.minecraftVersion();
String[] splash = IrisSplashRenderer.renderPlain(); String[] splash = IrisSplashRenderer.renderPlain();
@@ -27,16 +27,15 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
public final class ModdedPrimaryWorldRouter { public final class ModdedPrimaryWorldRouter {
private static final int TICK_INTERVAL = 20; private static final int TICK_INTERVAL = 20;
private static final Set<UUID> routed = ConcurrentHashMap.newKeySet(); private static final ConcurrentMap<UUID, CompletableFuture<Boolean>> routes = new ConcurrentHashMap<>();
private static final Set<UUID> inFlight = ConcurrentHashMap.newKeySet();
private static int tickCounter = 0; private static int tickCounter = 0;
private static volatile String loginRefusal; private static volatile String loginRefusal;
private static volatile String absentPrimary; private static volatile String absentPrimary;
@@ -45,8 +44,12 @@ public final class ModdedPrimaryWorldRouter {
} }
public static void clear() { public static void clear() {
routed.clear(); routes.forEach((id, route) -> {
inFlight.clear(); if (routes.remove(id, route)) {
route.cancel(false);
}
});
tickCounter = 0;
loginRefusal = null; loginRefusal = null;
absentPrimary = null; absentPrimary = null;
} }
@@ -57,8 +60,10 @@ public final class ModdedPrimaryWorldRouter {
*/ */
public static void forget(UUID player) { public static void forget(UUID player) {
if (player != null) { if (player != null) {
routed.remove(player); CompletableFuture<Boolean> route = routes.remove(player);
inFlight.remove(player); if (route != null) {
route.cancel(false);
}
} }
} }
@@ -110,36 +115,52 @@ public final class ModdedPrimaryWorldRouter {
return; return;
} }
server.forEachPlayer(player -> { server.forEachPlayer(player -> routePlayer(server, player, overworld, target, primary));
UUID id = player.id(); }
if (routed.contains(id) || !inFlight.add(id)) {
return; private static void routePlayer(NativeModdedServer server, NativeProtocolPlayer player,
NativeWorld overworld, NativeWorld target, String primary) {
UUID id = player.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); if (!player.isInWorld(overworld)) {
routed.add(id); route.complete(true);
return; return;
} }
try { try {
CompletableFuture<Boolean> teleport = NativeWorldTeleport.teleport(player, CompletableFuture<Boolean> teleport = NativeWorldTeleport.teleport(player,
new NativeWorldTeleport.Destination(server, target, player.x(), Double.MIN_VALUE, player.z(), new NativeWorldTeleport.Destination(server, target, player.x(), Double.MIN_VALUE, player.z(),
ModdedTeleportDeadline.fromNow())); ModdedTeleportDeadline.fromNow()));
teleport.whenComplete((success, failure) -> { route.whenComplete((success, failure) -> {
inFlight.remove(id); if (!Boolean.TRUE.equals(success) && !teleport.isDone()) {
if (Boolean.TRUE.equals(success) && failure == null) { teleport.cancel(false);
routed.add(id); }
return; });
} teleport.whenComplete((success, failure) -> {
if (failure != null) { if (failure != null) {
if (route.completeExceptionally(failure)) {
ModdedIrisLog.error("Iris failed to route player {} to primary world '{}'", ModdedIrisLog.error("Iris failed to route player {} to primary world '{}'",
id, primary, failure); id, primary, failure);
} }
}); } else {
} catch (Throwable e) { route.complete(Boolean.TRUE.equals(success));
inFlight.remove(id); }
ModdedIrisLog.error("Iris failed to route player {} to primary world '{}'", id, primary, e); });
} 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.generation.runtime.Engine;
import art.arcane.iris.spi.IrisServices; import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.spi.protocol.IrisProtocol; import art.arcane.iris.spi.protocol.IrisProtocol;
import art.arcane.iris.modded.service.ModdedPackUpdateService;
import java.util.Objects; import java.util.Objects;
import java.util.UUID; import java.util.UUID;
@@ -128,6 +129,10 @@ public final class ModdedProtocolHandler {
} }
String sessionId = sessionId(player); String sessionId = sessionId(player);
ModdedStartup.warnStartupFailuresTo(player); ModdedStartup.warnStartupFailuresTo(player);
ModdedPackUpdateService packUpdates = ModdedEngineBootstrap.services().service(ModdedPackUpdateService.class);
if (packUpdates != null) {
packUpdates.notifyPlayer(player);
}
IrisSessionRegistry current = registry; IrisSessionRegistry current = registry;
ModdedProtocolTransport currentTransport = transport; ModdedProtocolTransport currentTransport = transport;
if (current == null || currentTransport == null) { if (current == null || currentTransport == null) {
@@ -18,10 +18,13 @@
package art.arcane.iris.modded; package art.arcane.iris.modded;
import art.arcane.iris.pack.PackFingerprints;
import art.arcane.iris.pack.BrokenPackException; import art.arcane.iris.pack.BrokenPackException;
import art.arcane.iris.pack.PackDirectoryResolver; import art.arcane.iris.pack.PackDirectoryResolver;
import art.arcane.iris.pack.PackDownloader; import art.arcane.iris.pack.PackDownloader;
import art.arcane.iris.pack.PackValidationRegistry; import art.arcane.iris.pack.PackValidationRegistry;
import art.arcane.iris.pack.PackValidationCache;
import art.arcane.iris.pack.PackValidationResult; import art.arcane.iris.pack.PackValidationResult;
import art.arcane.iris.pack.PackValidator; import art.arcane.iris.pack.PackValidator;
import art.arcane.iris.modded.command.ModdedPackCommands; 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 art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import java.io.File; import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
public final class ModdedStartup { public final class ModdedStartup {
private static final int COMPAT_BOOT_KEY_CAP = 3; private static final int COMPAT_BOOT_KEY_CAP = 3;
@@ -60,36 +59,9 @@ public final class ModdedStartup {
if (!PREPARED.compareAndSet(false, true)) { if (!PREPARED.compareAndSet(false, true)) {
return; return;
} }
reportLegacyPacksDirectory();
validateAllPacks(); 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: * 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 * 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) { for (File packDir : packDirs) {
try { try {
PackValidationResult result = PackValidator.validate(packDir); PackValidationResult result = validatePack(packDir);
PackValidationRegistry.publish(result);
String minecraftVersion = IrisPlatforms.isBound() ? IrisPlatforms.get().minecraftVersion() : null; String minecraftVersion = IrisPlatforms.isBound() ? IrisPlatforms.get().minecraftVersion() : null;
String compatSummary = PackValidator.compatSummary(result, minecraftVersion); String compatSummary = PackValidator.compatSummary(result, minecraftVersion);
String compatSuffix = compatSummary.isEmpty() ? "" : " " + compatSummary; String compatSuffix = compatSummary.isEmpty() ? "" : " " + compatSummary;
@@ -184,16 +155,15 @@ public final class ModdedStartup {
throw new BrokenPackException(pack, List.of( throw new BrokenPackException(pack, List.of(
"Pack folder does not exist under " + ModdedPackCommands.packsRoot().getAbsolutePath() + ".")); "Pack folder does not exist under " + ModdedPackCommands.packsRoot().getAbsolutePath() + "."));
} }
PackValidationResult cached = PackValidationRegistry.get(pack); Path packRoot = packDir.toPath();
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);
}
try { try {
PackValidationResult result = PackValidator.validate(packDir); PackValidationResult cached = PackValidationRegistry.getMatching(
PackValidationRegistry.publish(result); packRoot, PackFingerprints.computePackTreeFingerprint(packDir));
return PackValidationRegistry.requireLoadable(pack); if (cached != null) {
return PackValidationRegistry.requireLoadable(packRoot);
}
validatePack(packDir);
return PackValidationRegistry.requireLoadable(packRoot);
} catch (BrokenPackException e) { } catch (BrokenPackException e) {
throw e; throw e;
} catch (Throwable 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) { static void reinjectPersistentDimensions(NativeModdedServer server) {
List<ModdedDimensionRegistryStore.PersistentDimension> dimensions = List<ModdedDimensionRegistryStore.PersistentDimension> dimensions =
ModdedDimensionRegistryStore.loadForStartup(server); ModdedDimensionRegistryStore.loadForStartup(server);
@@ -266,23 +272,6 @@ public final class ModdedStartup {
+ GenerationRefusalNotice.summary(failure)); + 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 * 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. * 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. * the console. Tell the operators who can actually act on it when they join.
*/ */
public static void warnStartupFailuresTo(NativeProtocolPlayer player) { public static void warnStartupFailuresTo(NativeProtocolPlayer player) {
if (player == null || !player.isGameMaster()) { if (player == null || (!player.isGameMaster() && !player.isServerOwner())) {
return; return;
} }
for (String failure : DIMENSION_FAILURES) { for (String failure : DIMENSION_FAILURES) {
@@ -33,6 +33,7 @@ import art.arcane.iris.modded.IrisModdedChunkGenerator;
import art.arcane.iris.modded.ModdedDimensionManager; import art.arcane.iris.modded.ModdedDimensionManager;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldTeleport; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldTeleport;
import art.arcane.iris.modded.ModdedEngineBootstrap; import art.arcane.iris.modded.ModdedEngineBootstrap;
import art.arcane.iris.modded.service.ModdedPackUpdateService;
import art.arcane.iris.modded.ModdedForcedDatapack; import art.arcane.iris.modded.ModdedForcedDatapack;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedLoader; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedLoader;
import art.arcane.iris.modded.ModdedScheduler; import art.arcane.iris.modded.ModdedScheduler;
@@ -445,6 +446,12 @@ public final class IrisModdedCommands {
); );
String completionMessage = downloadCompletionMessage(result); String completionMessage = downloadCompletionMessage(result);
if (result != null) { if (result != null) {
if (result.changed()) {
ModdedPackUpdateService updates = ModdedEngineBootstrap.services().service(ModdedPackUpdateService.class);
if (updates != null) {
dispatchDownloadFeedback(source, updates::refresh);
}
}
if (completionMessage != null) { if (completionMessage != null) {
dispatchDownloadFeedback(source, () -> ok(source, completionMessage)); dispatchDownloadFeedback(source, () -> ok(source, completionMessage));
} }
@@ -18,6 +18,8 @@
package art.arcane.iris.modded.command; package art.arcane.iris.modded.command;
import art.arcane.iris.modded.ModdedStartup;
import art.arcane.iris.modded.ModdedIrisLog; import art.arcane.iris.modded.ModdedIrisLog;
import art.arcane.iris.pack.validation.PackCompatReport; import art.arcane.iris.pack.validation.PackCompatReport;
import art.arcane.iris.spi.IrisPlatforms; 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.PackResourceCleanup;
import art.arcane.iris.pack.PackValidationRegistry; import art.arcane.iris.pack.PackValidationRegistry;
import art.arcane.iris.pack.PackValidationResult; import art.arcane.iris.pack.PackValidationResult;
import art.arcane.iris.pack.PackValidator;
import art.arcane.iris.modded.ModdedEngineBootstrap; import art.arcane.iris.modded.ModdedEngineBootstrap;
import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.builder.LiteralArgumentBuilder; import com.mojang.brigadier.builder.LiteralArgumentBuilder;
@@ -138,8 +139,7 @@ public final class ModdedPackCommands {
int broken = 0; int broken = 0;
for (File target : targets) { for (File target : targets) {
try { try {
PackValidationResult result = PackValidator.validate(target); PackValidationResult result = ModdedStartup.validatePack(target);
PackValidationRegistry.publish(result);
if (!result.isLoadable()) { if (!result.isLoadable()) {
broken++; 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.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
public final class ModdedStudioHotloadService implements ModdedTickableService, EnginePlatformHooks { 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 POLL_MILLIS = 250L;
private static final long CHECK_LATCH_MILLIS = 1_000L; private static final long CHECK_LATCH_MILLIS = 1_000L;
private static final long RECENT_GENERATION_HOLDOFF_MILLIS = 2_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 final ConcurrentHashMap<String, Watch> watches = new ConcurrentHashMap<>();
private volatile ExecutorService executor; private volatile ExecutorService executor;
@@ -81,8 +84,12 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
@Override @Override
public void onEnable() { public void onEnable() {
if (executor != null) { ExecutorService current = executor;
return; 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) -> { executor = Executors.newSingleThreadExecutor((Runnable task) -> {
Thread thread = new Thread(task, "Iris Studio Hotload"); Thread thread = new Thread(task, "Iris Studio Hotload");
@@ -96,10 +103,10 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
@Override @Override
public void onDisable() { public void onDisable() {
ExecutorService active = executor; ExecutorService active = executor;
executor = null; if (!shutdownAndDrain(active)) {
if (active != null) { throw new IllegalStateException("Iris Studio hotload worker remained active during service shutdown");
active.shutdownNow();
} }
executor = null;
for (Watch watch : watches.values()) { for (Watch watch : watches.values()) {
watch.close(); watch.close();
} }
@@ -197,7 +204,7 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
@Override @Override
public void onServerTick(NativeModdedServer server) { public void onServerTick(NativeModdedServer server) {
ExecutorService active = executor; ExecutorService active = executor;
if (active == null) { if (active == null || active.isShutdown()) {
return; return;
} }
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
@@ -284,6 +291,30 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
&& !isPregeneratorActive(engine); && !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) { private boolean throttled(IrisModdedChunkGenerator generator, Engine engine, long now) {
if (now - generator.lastChunkGenAt() < RECENT_GENERATION_HOLDOFF_MILLIS) { if (now - generator.lastChunkGenAt() < RECENT_GENERATION_HOLDOFF_MILLIS) {
return true; return true;
@@ -39,6 +39,37 @@ public class ModdedClientPackageIsolationTest {
"art/arcane/iris/nativegen"); "art/arcane/iris/nativegen");
private static final int MINIMUM_SCANNED = 50; 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 @Test
public void serverSidePackagesNeverReferenceClientOnlyTypes() throws IOException, URISyntaxException { public void serverSidePackagesNeverReferenceClientOnlyTypes() throws IOException, URISyntaxException {
Path root = classesRoot(); 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.NativeProtocolPlayer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldTeleport; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldTeleport;
import art.arcane.volmlib.nativelib.terrain.NativeWorld; import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import net.minecraft.SharedConstants;
import net.minecraft.server.Bootstrap;
import org.junit.After; import org.junit.After;
import org.junit.BeforeClass;
import org.junit.Test; import org.junit.Test;
import org.mockito.MockedStatic; import org.mockito.MockedStatic;
import org.mockito.invocation.InvocationOnMock; import org.mockito.invocation.InvocationOnMock;
@@ -38,6 +41,12 @@ public class ModdedPrimaryWorldRouterTest {
private static final int TICK_INTERVAL = 20; private static final int TICK_INTERVAL = 20;
private static final String PRIMARY = "iris:primary"; private static final String PRIMARY = "iris:primary";
@BeforeClass
public static void bootstrap() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
}
@After @After
public void clearRouter() { public void clearRouter() {
ModdedPrimaryWorldRouter.clear(); ModdedPrimaryWorldRouter.clear();
@@ -233,6 +242,41 @@ public class ModdedPrimaryWorldRouterTest {
return new ModdedDimensionRegistryStore.PersistentDimension(PRIMARY, "overworld", "overworld", 1337L); 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) { private static void evaluate(NativeModdedServer server) {
for (int tick = 0; tick < TICK_INTERVAL; tick++) { for (int tick = 0; tick < TICK_INTERVAL; tick++) {
ModdedPrimaryWorldRouter.tick(server); ModdedPrimaryWorldRouter.tick(server);
@@ -57,6 +57,11 @@ public class ModdedStartupReinjectFailureTest {
NativeProtocolPlayer player = mock(NativeProtocolPlayer.class); NativeProtocolPlayer player = mock(NativeProtocolPlayer.class);
ModdedStartup.warnStartupFailuresTo(operator); ModdedStartup.warnStartupFailuresTo(operator);
ModdedStartup.warnStartupFailuresTo(player); 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") verify(operator).sendMessage(argThat((String message) -> message.contains("iris:moon")
&& message.contains("generation history is unusable"))); && 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.ServerLevel;
import net.minecraft.server.level.ServerPlayer; import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.level.Level; 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.BeforeClass;
import org.junit.Test; import org.junit.Test;
import org.mockito.MockedStatic; import org.mockito.MockedStatic;
@@ -98,6 +100,7 @@ public class IrisModdedCommandsTeleportTest {
when(server.getLevel(dimension)).thenReturn(serverLevel); when(server.getLevel(dimension)).thenReturn(serverLevel);
ServerPlayer serverPlayer = mock(ServerPlayer.class); ServerPlayer serverPlayer = mock(ServerPlayer.class);
when(serverPlayer.getUUID()).thenReturn(UUID.randomUUID()); when(serverPlayer.getUUID()).thenReturn(UUID.randomUUID());
when(serverPlayer.getDimensions(Pose.STANDING)).thenReturn(EntityDimensions.scalable(0.6F, 1.8F));
NativeWorld level = mock(NativeWorld.class); NativeWorld level = mock(NativeWorld.class);
when(level.nativeHandle()).thenReturn(serverLevel); 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.iris.modded.ModdedScheduler;
import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KList;
import org.junit.Test; import org.junit.Test;
import org.mockito.InOrder;
import java.lang.reflect.Field;
import java.util.List; import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue; 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 { 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 @Test
public void detectsDatapackImportsAcrossLoadedDimensions() { public void detectsDatapackImportsAcrossLoadedDimensions() {
IrisDimension empty = new IrisDimension(); IrisDimension empty = new IrisDimension();
@@ -45,4 +96,10 @@ public class ModdedStudioHotloadServiceTest {
scheduler.shutdown(); 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/bukkit/Events',
'art/arcane/volmlib/util/json/SingleCollectionTypeFactory', 'art/arcane/volmlib/util/json/SingleCollectionTypeFactory',
'art/arcane/volmlib/util/collection/StateList', '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/DownloadJob',
'art/arcane/iris/world/task/jobs/JobCollection', 'art/arcane/iris/world/task/jobs/JobCollection',
'art/arcane/iris/world/task/jobs/ParallelQueueJob', 'art/arcane/iris/world/task/jobs/ParallelQueueJob',
@@ -54,7 +54,7 @@ public final class IrisSplashPackScanner {
JsonObject json = JsonParser.parseReader(reader).getAsJsonObject(); JsonObject json = JsonParser.parseReader(reader).getAsJsonObject();
JsonElement version = json.get("version"); JsonElement version = json.get("version");
if (version == null || !version.isJsonPrimitive()) { if (version == null || !version.isJsonPrimitive()) {
return null; return new SplashPackMetadata(dimName, "unknown");
} }
return new SplashPackMetadata(dimName, version.getAsString()); 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 static final int CHILDREN_SEED_SALT = 2137;
private final transient AtomicCache<KMap<String, IrisBiomeGeneratorLink>> genCache = new AtomicCache<>(); 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>> surfaceObjectsCache = new AtomicCache<>();
private final transient AtomicCache<KList<IrisObjectPlacement>> carveObjectsCache = new AtomicCache<>(); private final transient AtomicCache<KList<IrisObjectPlacement>> carveObjectsCache = new AtomicCache<>();
private final transient AtomicCache<Color> cacheColor = 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(); return getCustomDerivitives() != null && getCustomDerivitives().isNotEmpty();
} }
public double getGenLinkMax(String loadKey, Engine engine) { public double getGenLinkMax(String loadKey) {
return IrisBiomeGenLinks.getGenLinkMax(this, loadKey, engine); return IrisBiomeGenLinks.getGenLinkMax(this, loadKey);
} }
public double getGenLinkMin(String loadKey, Engine engine) { public double getGenLinkMin(String loadKey) {
return IrisBiomeGenLinks.getGenLinkMin(this, loadKey, engine); return IrisBiomeGenLinks.getGenLinkMin(this, loadKey);
} }
public IrisBiomeGeneratorLink getGenLink(String loadKey) { public IrisBiomeGeneratorLink getGenLink(String loadKey) {
@@ -19,7 +19,6 @@
package art.arcane.iris.generation.biome; package art.arcane.iris.generation.biome;
import art.arcane.volmlib.util.cache.AtomicCache; import art.arcane.volmlib.util.cache.AtomicCache;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.collection.KMap;
/** /**
@@ -31,24 +30,14 @@ final class IrisBiomeGenLinks {
private IrisBiomeGenLinks() { private IrisBiomeGenLinks() {
} }
static double getGenLinkMax(IrisBiome biome, String loadKey, Engine engine) { static double getGenLinkMax(IrisBiome biome, String loadKey) {
if (loadKey == null || loadKey.isBlank()) { IrisBiomeGeneratorLink link = getGenLink(biome, loadKey);
return 0; 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) { static double getGenLinkMin(IrisBiome biome, String loadKey) {
if (loadKey == null || loadKey.isBlank()) { IrisBiomeGeneratorLink link = getGenLink(biome, loadKey);
return 0; return link == null ? 0 : link.getMin();
}
Integer v = minIndex(biome).get(loadKey);
return v == null ? 0 : v;
} }
static IrisBiomeGeneratorLink getGenLink(IrisBiome biome, String loadKey) { static IrisBiomeGeneratorLink getGenLink(IrisBiome biome, String loadKey) {
@@ -59,57 +48,6 @@ final class IrisBiomeGenLinks {
return linkIndex(biome).get(loadKey); 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) { private static KMap<String, IrisBiomeGeneratorLink> linkIndex(IrisBiome biome) {
AtomicCache<KMap<String, IrisBiomeGeneratorLink>> cache = biome.getGenCache(); AtomicCache<KMap<String, IrisBiomeGeneratorLink>> cache = biome.getGenCache();
KMap<String, IrisBiomeGeneratorLink> cached = cache.getIfPresent(); KMap<String, IrisBiomeGeneratorLink> cached = cache.getIfPresent();
@@ -225,7 +225,7 @@ final class IrisBiomeLayerGenerator {
int sampledDepth = Math.min(maxDepth, cycleLength); int sampledDepth = Math.min(maxDepth, cycleLength);
for (int depth = 0; depth < sampledDepth; depth++) { 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); int layerIndex = findLayer(boundaries, position);
IrisBiomePaletteLayer layer = layers.get(layerIndex); IrisBiomePaletteLayer layer = layers.get(layerIndex);
int offset = position - boundaries[layerIndex]; int offset = position - boundaries[layerIndex];
@@ -14,27 +14,31 @@ import java.util.function.Consumer;
public final class ConcurrentClockCache<V> { public final class ConcurrentClockCache<V> {
private static final int WAYS = 4; private static final int WAYS = 4;
private final AtomicReferenceArray<Entry<V>> slots; private final AtomicReferenceArray<CacheSet<V>> slots;
private final long sets; private final long sets;
private final int capacity;
public ConcurrentClockCache(int capacity) { public ConcurrentClockCache(int capacity) {
if (capacity <= 0) { if (capacity <= 0) {
throw new IllegalArgumentException("Cache capacity must be positive"); throw new IllegalArgumentException("Cache capacity must be positive");
} }
int setCount = Math.ceilDiv(capacity, WAYS); int setCount = Math.ceilDiv(capacity, WAYS);
this.capacity = Math.multiplyExact(setCount, WAYS);
sets = setCount; sets = setCount;
slots = new AtomicReferenceArray<>(Math.multiplyExact(setCount, WAYS)); slots = new AtomicReferenceArray<>(setCount);
} }
public int capacity() { public int capacity() {
return slots.length(); return capacity;
} }
public V get(long key) { public V get(long key) {
long hash = CacheKey.mix(key); CacheSet<V> snapshot = slots.getAcquire(index(CacheKey.mix(key)));
int base = base(hash); if (snapshot == null) {
return null;
}
for (int way = 0; way < WAYS; way++) { 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) { if (entry != null && entry.key == key) {
entry.touch(); entry.touch();
return entry.value; return entry.value;
@@ -46,12 +50,23 @@ public final class ConcurrentClockCache<V> {
public V putIfAbsent(long key, V value) { public V putIfAbsent(long key, V value) {
Objects.requireNonNull(value, "Cached value"); Objects.requireNonNull(value, "Cached value");
long hash = CacheKey.mix(key); long hash = CacheKey.mix(key);
int base = base(hash); int index = index(hash);
int hand = (int) (hash >>> 60) & (WAYS - 1); int hand = (int) (hash >>> 60) & (WAYS - 1);
Entry<V> created = null;
while (true) { 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; int empty = -1;
for (int way = 0; way < WAYS; way++) { for (int way = 0; way < WAYS; way++) {
Entry<V> entry = slots.getAcquire(base + way); Entry<V> entry = snapshot.get(way);
if (entry == null) { if (entry == null) {
if (empty < 0) { if (empty < 0) {
empty = way; empty = way;
@@ -61,30 +76,23 @@ public final class ConcurrentClockCache<V> {
return entry.value; return entry.value;
} }
} }
Entry<V> created = new Entry<>(key, value); int victim = empty;
if (empty >= 0) {
if (slots.compareAndSet(base + empty, null, created)) {
return value;
}
continue;
}
int victim = -1;
Entry<V> victimEntry = null;
for (int step = 0; step < WAYS * 2 && victim < 0; step++) { for (int step = 0; step < WAYS * 2 && victim < 0; step++) {
int way = (hand + step) & (WAYS - 1); int way = (hand + step) & (WAYS - 1);
Entry<V> entry = slots.getAcquire(base + way); Entry<V> entry = snapshot.get(way);
if (entry == null || !entry.referenced) { if (!entry.referenced) {
victim = way; victim = way;
victimEntry = entry;
} else { } else {
entry.referenced = false; entry.referenced = false;
} }
} }
if (victim < 0) { if (victim < 0) {
victim = hand; 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; return value;
} }
} }
@@ -92,9 +100,15 @@ public final class ConcurrentClockCache<V> {
public void forEach(Consumer<? super V> action) { public void forEach(Consumer<? super V> action) {
for (int index = 0; index < slots.length(); index++) { for (int index = 0; index < slots.length(); index++) {
Entry<V> entry = slots.getAcquire(index); CacheSet<V> snapshot = slots.getAcquire(index);
if (entry != null) { if (snapshot == null) {
action.accept(entry.value); continue;
}
for (int way = 0; way < WAYS; way++) {
Entry<V> entry = snapshot.get(way);
if (entry != null) {
action.accept(entry.value);
}
} }
} }
} }
@@ -105,14 +119,36 @@ public final class ConcurrentClockCache<V> {
} }
} }
private int base(long hash) { private int index(long hash) {
return (int) (((hash & 0xffffffffL) * sets) >>> 32) * WAYS; 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 static final class Entry<V> {
private final long key; private final long key;
private final V value; private final V value;
private boolean referenced; private volatile boolean referenced;
private Entry(long key, V value) { private Entry(long key, V value) {
this.key = key; this.key = key;
@@ -19,6 +19,8 @@
package art.arcane.iris.generation.cave; package art.arcane.iris.generation.cave;
import art.arcane.iris.pack.value.IrisRange; 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.pack.loading.IrisData;
import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.Engine;
@@ -31,12 +33,15 @@ import java.util.List;
public final class IrisCaveProfileSampler { public final class IrisCaveProfileSampler {
private static final double FLOATING_THRESHOLD_BIAS_SCALE = 0.2D; private static final double FLOATING_THRESHOLD_BIAS_SCALE = 0.2D;
private final IrisData data;
private final IrisCaveProfile profile; private final IrisCaveProfile profile;
private final CNG baseDensity; private final CNG baseDensity;
private final CNG detailDensity; private final CNG detailDensity;
private final CNG warpDensity; 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 ModuleState[] modules;
private final double inverseNormalization; private final double inverseNormalization;
private final double baseWeight; private final double baseWeight;
@@ -47,14 +52,19 @@ public final class IrisCaveProfileSampler {
private final boolean hasModules; private final boolean hasModules;
public IrisCaveProfileSampler(Engine engine, IrisCaveProfile profile) { public IrisCaveProfileSampler(Engine engine, IrisCaveProfile profile) {
this.data = engine.getData(); IrisData data = engine.getData();
this.profile = profile; this.profile = profile;
List<ModuleState> moduleStates = new ArrayList<>(); List<ModuleState> moduleStates = new ArrayList<>();
RNG baseRng = new RNG(engine.getSeedManager().getCarve()); RNG baseRng = new RNG(engine.getSeedManager().getCarve());
this.baseDensity = profile.getBaseDensityStyle().create(baseRng.nextParallelRNG(934_447), data); this.baseDensity = profile.getBaseDensityStyle().create(baseRng.nextParallelRNG(934_447), data);
this.detailDensity = profile.getDetailDensityStyle().create(baseRng.nextParallelRNG(612_991), data); this.detailDensity = profile.getDetailDensityStyle().create(baseRng.nextParallelRNG(612_991), data);
this.warpDensity = profile.getWarpStyle().create(baseRng.nextParallelRNG(770_713), 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.baseWeight = profile.getBaseWeight();
this.detailWeight = profile.getDetailWeight(); this.detailWeight = profile.getDetailWeight();
this.warpStrength = profile.getWarpStrength(); this.warpStrength = profile.getWarpStrength();
@@ -82,7 +92,8 @@ public final class IrisCaveProfileSampler {
return false; 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); threshold += floatingThresholdBias(floatingCarveThreshold);
return sampleDensity(x, y, z) <= threshold; return sampleDensity(x, y, z) <= threshold;
} }
@@ -98,14 +98,6 @@ final class DecoratorCore {
return picked; 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, static void placeSurfaceSingle(IrisDecorator decorator,
int x, int z, int realX, int height, int realZ, int x, int z, int realX, int height, int realZ,
Hunk<NativeBlockState> data, RNG rng, IrisData irisData, Hunk<NativeBlockState> data, RNG rng, IrisData irisData,
@@ -150,7 +142,7 @@ final class DecoratorCore {
} }
String half = bd == null ? null : IrisProceduralBlocks.propertyValue(bd, "half"); String half = bd == null ? null : IrisProceduralBlocks.propertyValue(bd, "half");
if (half != null) { if ("upper".equals(half) || "lower".equals(half)) {
int lowerY = height + 1; int lowerY = height + 1;
int upperY = height + 2; int upperY = height + 2;
if (!canPlaceTwoBlockPlant(data, x, z, lowerY, upperY, caveSkipFluid)) { if (!canPlaceTwoBlockPlant(data, x, z, lowerY, upperY, caveSkipFluid)) {
@@ -158,9 +150,9 @@ final class DecoratorCore {
} }
try { try {
NativeBlockState upper = bd.withProperty("half", topHalfValue(half)); NativeBlockState upper = bd.withProperty("half", "upper");
NativeBlockState lower = fixFacesForHunk( NativeBlockState lower = fixFacesForHunk(
bd.withProperty("half", bottomHalfValue(half)), bd.withProperty("half", "lower"),
data, x, z, realX, lowerY, realZ, mantle); data, x, z, realX, lowerY, realZ, mantle);
data.set(x, lowerY, z, lower); data.set(x, lowerY, z, lower);
data.set(x, upperY, z, IrisProceduralBlocks.normalizeWaterlogging(upper, data.get(x, upperY, z), true)); 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 stack = computeStack(decorator, rng, realX, realZ, irisData, effectiveMax);
int placed = 0; int placed = 0;
boolean hasSpikes = false; boolean hasSpikes = false;
NativeBlockState sampledBlock = null;
boolean sampledTop = false;
for (int i = 0; i < stack; i++) { for (int i = 0; i < stack; i++) {
int y = height + 1 + i; int y = height + 1 + i;
if (y >= data.getHeight()) { if (y >= data.getHeight()) {
@@ -239,9 +233,14 @@ final class DecoratorCore {
break; break;
} }
double threshold = stack == 1 ? 1.0 : ((double) i) / (stack - 1); double threshold = stack == 1 ? 1.0 : ((double) i) / (stack - 1);
NativeBlockState block = threshold >= decorator.getTopThreshold() boolean top = threshold >= decorator.getTopThreshold();
? decorator.pickBlockDataTop(rng, irisData, realX, realZ) if (sampledBlock == null || top != sampledTop) {
: decorator.pickBlockData(rng, irisData, realX, realZ); sampledBlock = top
? decorator.pickBlockDataTop(rng, irisData, realX, realZ)
: decorator.pickBlockData(rng, irisData, realX, realZ);
sampledTop = top;
}
NativeBlockState block = sampledBlock;
if (block == null) { if (block == null) {
break; break;
} }
@@ -282,6 +281,8 @@ final class DecoratorCore {
int stack = computeStack(decorator, rng, realX, realZ, irisData, max); int stack = computeStack(decorator, rng, realX, realZ, irisData, max);
int placed = 0; int placed = 0;
boolean hasSpikes = false; boolean hasSpikes = false;
NativeBlockState sampledBlock = null;
boolean sampledTop = false;
for (int i = 0; i < stack; i++) { for (int i = 0; i < stack; i++) {
int y = height - i; int y = height - i;
if (y < 0 || y < minHeight) { if (y < 0 || y < minHeight) {
@@ -293,9 +294,14 @@ final class DecoratorCore {
break; break;
} }
double threshold = stack == 1 ? 1.0 : ((double) i) / (stack - 1); double threshold = stack == 1 ? 1.0 : ((double) i) / (stack - 1);
NativeBlockState block = threshold >= decorator.getTopThreshold() boolean top = threshold >= decorator.getTopThreshold();
? decorator.pickBlockDataTop(rng, irisData, realX, realZ) if (sampledBlock == null || top != sampledTop) {
: decorator.pickBlockData(rng, irisData, realX, realZ); sampledBlock = top
? decorator.pickBlockDataTop(rng, irisData, realX, realZ)
: decorator.pickBlockData(rng, irisData, realX, realZ);
sampledTop = top;
}
NativeBlockState block = sampledBlock;
if (block == null) { if (block == null) {
break; break;
} }
@@ -346,7 +352,7 @@ final class DecoratorCore {
} }
String half = IrisProceduralBlocks.propertyValue(bd, "half"); String half = IrisProceduralBlocks.propertyValue(bd, "half");
if (half != null) { if ("upper".equals(half) || "lower".equals(half)) {
int lowerY = height + 1; int lowerY = height + 1;
int upperY = height + 2; int upperY = height + 2;
if (max <= 2 || !canPlaceTwoBlockPlant(data, xf, zf, lowerY, upperY, false)) { if (max <= 2 || !canPlaceTwoBlockPlant(data, xf, zf, lowerY, upperY, false)) {
@@ -354,8 +360,8 @@ final class DecoratorCore {
} }
try { try {
NativeBlockState upper = bd.withProperty("half", topHalfValue(half)); NativeBlockState upper = bd.withProperty("half", "upper");
NativeBlockState lower = bd.withProperty("half", bottomHalfValue(half)); NativeBlockState lower = bd.withProperty("half", "lower");
data.set(xf, lowerY, zf, IrisProceduralBlocks.normalizeWaterlogging(lower, data.get(xf, lowerY, zf), true)); 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)); data.set(xf, upperY, zf, IrisProceduralBlocks.normalizeWaterlogging(upper, data.get(xf, upperY, zf), true));
} catch (Throwable e) { } catch (Throwable e) {
@@ -386,15 +392,22 @@ final class DecoratorCore {
int placed = 0; int placed = 0;
boolean hasSpikes = false; boolean hasSpikes = false;
NativeBlockState sampledBlock = null;
boolean sampledTop = false;
for (int i = 0; i < stack; i++) { for (int i = 0; i < stack; i++) {
int h = height + 1 + i; int h = height + 1 + i;
if (h >= height + max || h >= data.getHeight()) { if (h >= height + max || h >= data.getHeight()) {
break; break;
} }
double threshold = stack == 1 ? 0.0 : ((double) i) / (stack - 1); double threshold = stack == 1 ? 0.0 : ((double) i) / (stack - 1);
NativeBlockState bd = threshold >= decorator.getTopThreshold() boolean top = threshold >= decorator.getTopThreshold();
? decorator.pickBlockDataTop(rng, irisData, realX, realZ) if (sampledBlock == null || top != sampledTop) {
: decorator.pickBlockData(rng, irisData, realX, realZ); sampledBlock = top
? decorator.pickBlockDataTop(rng, irisData, realX, realZ)
: decorator.pickBlockData(rng, irisData, realX, realZ);
sampledTop = top;
}
NativeBlockState bd = sampledBlock;
if (bd == null) { if (bd == null) {
break; break;
} }
@@ -193,20 +193,22 @@ public class IrisDecorator {
} }
public NativeBlockState getBlockData100(IrisBiome b, RNG rng, double x, double y, double z, IrisData data) { 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()); IrisLogging.warnOnce("decorator-empty:" + b.getName(), "Empty Block Data for " + b.getName());
return null; return null;
} }
if (getBlockData(data).size() == 1) { if (palette.size() == 1) {
return getBlockData(data).get(0); 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) { 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); return getBlockData100(b, rng, x, y, z, data);
} }
@@ -214,11 +216,11 @@ public class IrisDecorator {
double zz = z / style.getZoom(); double zz = z / style.getZoom();
if (getGenerator(rng, data).fitDouble(0D, 1D, xx, zz) <= chance) { //Exclude y from here if (getGenerator(rng, data).fitDouble(0D, 1D, xx, zz) <= chance) { //Exclude y from here
if (getBlockData(data).size() == 1) { if (topPalette.size() == 1) {
return getBlockDataTops(data).get(0); 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; return null;
@@ -146,13 +146,17 @@ public final class IrisProceduralBlocks {
} }
public static NativeBlockState normalizeWaterlogging(NativeBlockState state, NativeBlockState existing, boolean enabled) { 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; 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"))); boolean submerged = existing != null && (existing.isWater() || "true".equals(propertyValue(existing, "waterlogged")));
String value = submerged && (enabled || authored) ? "true" : "false"; 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) { public static IrisObject assemble(Map<Vector3i, NativeBlockState> blocks) {
@@ -111,12 +111,7 @@ public class IrisShoreLineDecorator extends IrisEngineDecorator {
return; return;
} }
int stack = decorator.getHeight(partRNG, realX, realZ, getData()); int stack = DecoratorCore.computeStack(decorator, partRNG, realX, realZ, getData(), max - height);
if (decorator.isScaleStack()) {
stack = (int) Math.ceil((double) (max - height) * ((double) stack / 100));
} else {
stack = Math.min(max - height, stack);
}
if (stack == 1) { if (stack == 1) {
int targetY = height + 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.HydrologyCaveAction;
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCandidate; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCandidate;
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveFluidPolicy; 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.HydrologyCaveMode;
import art.arcane.iris.generation.hydrology.cave.HydrologyCavePlannerSettings; import art.arcane.iris.generation.hydrology.cave.HydrologyCavePlannerSettings;
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveSource; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveSource;
@@ -170,11 +169,8 @@ final class CaveCandidateBuilder {
volume, volume,
1, 1,
1, 1,
1,
1,
dryHeadroom, dryHeadroom,
HydrologyCaveFluidPolicy.REJECT_EXISTING, HydrologyCaveFluidPolicy.REJECT_EXISTING,
HydrologyCaveGrottoShape.ELLIPSOID,
horizontalRadius, horizontalRadius,
maximumDepth maximumDepth
); );
@@ -114,7 +114,7 @@ final class HydrologyOutletPlanner {
return selectedOcean; return selectedOcean;
} }
if (surface) { if (surface) {
HydrologySurfaceBudgets budgets = HydrologySurfaceBudgets.sample(grid, planner.settings.surface().sources()); HydrologySurfaceBudgets budgets = grid.surfaceBudgets(planner.settings.surface().sources());
if (budgets.overridden()) { if (budgets.overridden()) {
oceanCandidates.removeIf(candidate -> !surfaceOutletHasBudget(key, grid, budgets, candidate, true)); oceanCandidates.removeIf(candidate -> !surfaceOutletHasBudget(key, grid, budgets, candidate, true));
} }
@@ -243,7 +243,7 @@ final class HydrologyOutletPlanner {
} }
sortSurfaceFallbackOutletCandidates(grid, oceanCandidates); sortSurfaceFallbackOutletCandidates(grid, oceanCandidates);
sortSurfaceFallbackOutletCandidates(grid, inlandCandidates); sortSurfaceFallbackOutletCandidates(grid, inlandCandidates);
HydrologySurfaceBudgets budgets = HydrologySurfaceBudgets.sample(grid, planner.settings.surface().sources()); HydrologySurfaceBudgets budgets = grid.surfaceBudgets(planner.settings.surface().sources());
if (budgets.overridden()) { if (budgets.overridden()) {
HydrologyTileKey key = HydrologyTileKey.fromBlock(grid.ownerMinimumX(), grid.ownerMinimumZ(), grid.ownerSize()); HydrologyTileKey key = HydrologyTileKey.fromBlock(grid.ownerMinimumX(), grid.ownerMinimumZ(), grid.ownerSize());
oceanCandidates.removeIf(candidate -> !surfaceOutletHasBudget(key, grid, budgets, candidate, true)); 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> limitSurfaceOutlets(HydrologyTileKey key, HydrologySampledGrid grid,
List<OutletCandidate> candidates, boolean coastal) { 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()) { if (!budgets.overridden()) {
return coastal ? limitOutletsByType(candidates, planner.settings.outlets().maximumCoastalPerTile()) return coastal ? limitOutletsByType(candidates, planner.settings.outlets().maximumCoastalPerTile())
: limitOutlets(candidates, planner.settings.outlets().maximumPerTile()); : limitOutlets(candidates, planner.settings.outlets().maximumPerTile());
@@ -12,6 +12,7 @@ final class HydrologySampledGrid {
private final int spacing; private final int spacing;
private final List<HydrologyGridNode> nodes; private final List<HydrologyGridNode> nodes;
private volatile HydrologyDrainageGraph drainage; private volatile HydrologyDrainageGraph drainage;
private volatile SurfaceBudgetSnapshot surfaceBudgets;
HydrologySampledGrid(int minimumX, int minimumZ, int ownerMinimumX, int ownerMinimumZ, HydrologySampledGrid(int minimumX, int minimumZ, int ownerMinimumX, int ownerMinimumZ,
int ownerSize, int width, int spacing, List<HydrologyGridNode> nodes) { int ownerSize, int width, int spacing, List<HydrologyGridNode> nodes) {
@@ -77,6 +78,21 @@ final class HydrologySampledGrid {
return nodes.get(index); 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) { HydrologyGridNode nodeAt(int gridX, int gridZ) {
if (gridX < 0 || gridZ < 0 || gridX >= width || gridZ >= width) { if (gridX < 0 || gridZ < 0 || gridX >= width || gridZ >= width) {
return null; return null;
@@ -102,4 +118,7 @@ final class HydrologySampledGrid {
return x >= ownerMinimumX && x < ownerMinimumX + ownerSize return x >= ownerMinimumX && x < ownerMinimumX + ownerSize
&& z >= ownerMinimumZ && z < ownerMinimumZ + 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); return SourceSelection.empty(surface);
} }
long sourceSalt = surface ? SURFACE_SOURCE_SALT : UNDERGROUND_SOURCE_SALT; 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<>(); ArrayList<SourceCandidate> candidates = new ArrayList<>();
boolean hasRequiredCandidate = false; boolean hasRequiredCandidate = false;
for (HydrologyGridNode node : grid.nodes()) { 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 = static final Comparator<HydrologyCaveSource> SOURCE_PRIORITY =
HydrologyCaveConflictPolicy.sourcePriority(); 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( public HydrologyCavePlanningResult validateAll(
CaveVoxelView view, CaveVoxelView view,
Collection<HydrologyCaveCandidate> candidates Collection<HydrologyCaveCandidate> candidates
@@ -698,14 +613,6 @@ public final class HydrologyCaveContainmentPlanner {
return HydrologyCaveRejection.NONE; 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( HydrologyCaveRejection rejectionForHazard(
CaveVoxel voxel, CaveVoxel voxel,
HydrologyCavePlannerSettings settings 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) { CaveVoxel voxelAt(CaveVoxelView view, CavePosition position) {
return Objects.requireNonNull(view.voxelAt(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( HydrologyCavePlan accepted(
HydrologyCaveSource source, HydrologyCaveSource source,
Map<CavePosition, HydrologyCaveAction> actions, 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 maxHorizontalRadius,
int maxDepth, int maxDepth,
int maxFloodVolume, int maxFloodVolume,
int maxThroatLength,
int throatRadius,
int grottoHorizontalRadius, int grottoHorizontalRadius,
int grottoVerticalRadius, int grottoVerticalRadius,
int dryHeadroom, int dryHeadroom,
HydrologyCaveFluidPolicy existingFluidPolicy, HydrologyCaveFluidPolicy existingFluidPolicy,
HydrologyCaveGrottoShape grottoShape,
int maxClosedComponentHorizontalRadius, int maxClosedComponentHorizontalRadius,
int maxClosedComponentDepth int maxClosedComponentDepth
) { ) {
public static final int MAXIMUM_PLANNED_MUTATIONS = 262_144; 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 { public HydrologyCavePlannerSettings {
Objects.requireNonNull(existingFluidPolicy); Objects.requireNonNull(existingFluidPolicy);
Objects.requireNonNull(grottoShape);
if (maxHorizontalRadius < 1) { if (maxHorizontalRadius < 1) {
throw new IllegalArgumentException("maxHorizontalRadius must be positive"); throw new IllegalArgumentException("maxHorizontalRadius must be positive");
} }
@@ -81,12 +26,6 @@ public record HydrologyCavePlannerSettings(
if (maxFloodVolume < 1) { if (maxFloodVolume < 1) {
throw new IllegalArgumentException("maxFloodVolume must be positive"); 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) { if (grottoHorizontalRadius < 1) {
throw new IllegalArgumentException("grottoHorizontalRadius must be positive"); 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) { private double naturalHeight(CacheStripe stripe, long packed, int blockX, int blockZ) {
synchronized (stripe) { synchronized (stripe) {
double cached = stripe.naturalHeights.getAndMoveToLast(packed); double cached = stripe.naturalHeights.getAndMoveToLast(packed);
if (!Double.isNaN(cached) || stripe.naturalHeights.containsKey(packed)) { if (!Double.isNaN(cached)) {
return cached; return cached;
} }
TerrainBasis cachedBasis = stripe.bases.getAndMoveToLast(packed); TerrainBasis cachedBasis = stripe.bases.getAndMoveToLast(packed);
@@ -392,7 +392,7 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai
} }
synchronized (stripe) { synchronized (stripe) {
double existing = stripe.naturalHeights.getAndMoveToLast(packed); double existing = stripe.naturalHeights.getAndMoveToLast(packed);
if (!Double.isNaN(existing) || stripe.naturalHeights.containsKey(packed)) { if (!Double.isNaN(existing)) {
return existing; return existing;
} }
stripe.naturalHeights.putAndMoveToLast(packed, sampled); 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.RiverFootprint;
import art.arcane.iris.generation.hydrology.IrisRiverBedProfile; import art.arcane.iris.generation.hydrology.IrisRiverBedProfile;
import art.arcane.iris.generation.hydrology.IrisRiverBlendStyle; 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.Long2IntOpenHashMap;
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
import it.unimi.dsi.fastutil.longs.LongArrayList; import it.unimi.dsi.fastutil.longs.LongArrayList;
@@ -149,8 +150,9 @@ public final class ErosionFieldCompiler {
boolean[] basin = prepared.basin(); boolean[] basin = prepared.basin();
double[] bendOffsets = prepared.bendOffsets(); double[] bendOffsets = prepared.bendOffsets();
double[][] blendWidth = prepared.blendWidth(); double[][] blendWidth = prepared.blendWidth();
Long2IntOpenHashMap nearest = geometry.nearest; Long2IntOpenHashMap indices = geometry.indices;
Long2DoubleOpenHashMap distance = geometry.distance; IntArrayList stations = geometry.stations;
DoubleArrayList distance = geometry.distance;
if (!geometry.prepared) { if (!geometry.prepared) {
for (int station = 0; station < count; station++) { for (int station = 0; station < count; station++) {
int radius = prepared.radii()[station]; int radius = prepared.radii()[station];
@@ -172,14 +174,22 @@ public final class ErosionFieldCompiler {
continue; continue;
} }
long key = RiverFootprint.pack(cellX, cellZ); 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 // 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. // there; the later station owns it so the row uses the head its own ring bounded.
if (existing < 0 if (existing < 0
|| cellDistance < distance.get(key) - EPSILON || cellDistance < existingDistance - EPSILON
|| Math.abs(cellDistance - distance.get(key)) <= EPSILON && station > existing) { || Math.abs(cellDistance - existingDistance) <= EPSILON && station > existing) {
nearest.put(key, station); if (index < 0) {
distance.put(key, cellDistance); 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 oceanStart = oceanStart(centerline, valley, terminal);
int inletStart = prepared.inletStart(); int inletStart = prepared.inletStart();
Long2ObjectOpenHashMap<SurfaceColumn> columns = new Long2ObjectOpenHashMap<>(wetOnly 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(); LongArrayList wetKeys = new LongArrayList();
LongIterator candidates = nearest.keySet().iterator(); LongIterator candidates = indices.keySet().iterator();
long[] coordinates = new long[Math.min(nearest.size(), HydrologyTerrainSampler.MAXIMUM_BATCH_SIZE)]; long[] coordinates = new long[Math.min(indices.size(), HydrologyTerrainSampler.MAXIMUM_BATCH_SIZE)];
double[] outlines = new double[coordinates.length]; double[] outlines = new double[coordinates.length];
while (candidates.hasNext()) { while (candidates.hasNext()) {
int pageSize = 0; int pageSize = 0;
while (candidates.hasNext() && pageSize < coordinates.length) { while (candidates.hasNext() && pageSize < coordinates.length) {
long key = candidates.nextLong(); long key = candidates.nextLong();
int station = nearest.get(key); int geometryIndex = indices.get(key);
double cellDistance = distance.get(key); int station = stations.getInt(geometryIndex);
double cellDistance = distance.getDouble(geometryIndex);
int cellX = RiverFootprint.unpackX(key); int cellX = RiverFootprint.unpackX(key);
int cellZ = RiverFootprint.unpackZ(key); int cellZ = RiverFootprint.unpackZ(key);
int head = valley.head()[station]; int head = valley.head()[station];
@@ -231,8 +242,9 @@ public final class ErosionFieldCompiler {
HydrologyTerrainSample[] samples = sampler.sampleBatch(coordinates, pageSize); HydrologyTerrainSample[] samples = sampler.sampleBatch(coordinates, pageSize);
for (int index = 0; index < pageSize; index++) { for (int index = 0; index < pageSize; index++) {
long key = coordinates[index]; long key = coordinates[index];
int station = nearest.get(key); int geometryIndex = indices.get(key);
double cellDistance = distance.get(key); int station = stations.getInt(geometryIndex);
double cellDistance = distance.getDouble(geometryIndex);
int cellX = RiverFootprint.unpackX(key); int cellX = RiverFootprint.unpackX(key);
int cellZ = RiverFootprint.unpackZ(key); int cellZ = RiverFootprint.unpackZ(key);
int head = valley.head()[station]; int head = valley.head()[station];
@@ -898,12 +910,13 @@ public final class ErosionFieldCompiler {
} }
private static final class RasterGeometry { private static final class RasterGeometry {
private final Long2IntOpenHashMap nearest = new Long2IntOpenHashMap(); private final Long2IntOpenHashMap indices = new Long2IntOpenHashMap();
private final Long2DoubleOpenHashMap distance = new Long2DoubleOpenHashMap(); private final IntArrayList stations = new IntArrayList();
private final DoubleArrayList distance = new DoubleArrayList();
private boolean prepared; private boolean prepared;
private RasterGeometry() { 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.spi.IrisLogging;
import art.arcane.iris.world.storage.matter.TileWrapper; import art.arcane.iris.world.storage.matter.TileWrapper;
import art.arcane.iris.world.storage.matter.PreObjectMatterCell; 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.configuration.IrisSettings;
import art.arcane.iris.integration.Identifier; import art.arcane.iris.integration.Identifier;
import art.arcane.iris.world.WorldMaintenance; import art.arcane.iris.world.WorldMaintenance;
import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.generation.runtime.Engine; 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.runtime.IrisComplex;
import art.arcane.iris.generation.terrain.Terrain3DColumn; import art.arcane.iris.generation.terrain.Terrain3DColumn;
import art.arcane.iris.world.history.TransitionGenerationPlan; 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.block.TileData;
import art.arcane.iris.generation.context.ChunkContext; import art.arcane.iris.generation.context.ChunkContext;
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCell; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCell;
import art.arcane.iris.generation.subterrain.SubterrainCell; import art.arcane.iris.generation.subterrain.SubterrainCell;
import art.arcane.iris.generation.subterrain.SubterrainRasterizer; 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.documentation.ChunkCoordinates;
import art.arcane.volmlib.util.function.Function3;
import art.arcane.volmlib.util.mantle.flag.MantleFlag; import art.arcane.volmlib.util.mantle.flag.MantleFlag;
import art.arcane.volmlib.util.mantle.runtime.Mantle; import art.arcane.volmlib.util.mantle.runtime.Mantle;
import art.arcane.volmlib.util.mantle.runtime.MantleChunk; 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.Matter;
import art.arcane.volmlib.util.matter.IrisMatter; import art.arcane.volmlib.util.matter.IrisMatter;
import art.arcane.volmlib.util.matter.MatterCavern; import art.arcane.volmlib.util.matter.MatterCavern;
import art.arcane.volmlib.util.matter.MatterSlice; import art.arcane.volmlib.util.matter.MatterSlice;
import art.arcane.volmlib.util.noise.CNG;
import art.arcane.iris.world.task.J; import art.arcane.iris.world.task.J;
import lombok.Data; import lombok.Data;
import lombok.AccessLevel; import lombok.AccessLevel;
@@ -61,9 +52,7 @@ import lombok.Setter;
import lombok.ToString; import lombok.ToString;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState; import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.iris.generation.block.B; import art.arcane.iris.generation.block.B;
import org.bukkit.util.Vector;
import java.util.HashSet;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.function.Supplier; import java.util.function.Supplier;
import it.unimi.dsi.fastutil.longs.Long2ObjectMap; 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.Map;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReferenceArray; import java.util.concurrent.atomic.AtomicReferenceArray;
import static art.arcane.iris.generation.mantle.EngineMantle.AIR; 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) { public ObjectPlacementCapture captureObjectPlacement(ObjectContinuationBundle.PlacementKey key) {
if (objectPlacementCapture.get() != null) { if (objectPlacementCapture.get() != null) {
throw new IllegalStateException("Object placement capture is already active"); throw new IllegalStateException("Object placement capture is already active");
@@ -376,6 +248,7 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable {
return; return;
} }
if (t instanceof NativeBlockState) { if (t instanceof NativeBlockState) {
clearTile(matter, x, y, z);
clearDeferredPlacement(matter, x, y, z); clearDeferredPlacement(matter, x, y, z);
} }
Class<?> sliceType = t instanceof NativeBlockState ? NativeBlockState.class : matter.getClass(t); Class<?> sliceType = t instanceof NativeBlockState ? NativeBlockState.class : matter.getClass(t);
@@ -671,69 +544,21 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable {
return carvedColumn; 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"); 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()) { if (!allowsWrite(x, z) || y < 0 || y >= mantle.getWorldHeight()) {
return false; return;
} }
MantleChunk<Matter> chunk = acquireChunk(x >> 4, z >> 4); MantleChunk<Matter> chunk = acquireChunk(x >> 4, z >> 4);
int section = y >> 4;
if (chunk == null) { if (chunk == null) {
return false; throw new IllegalStateException("Object rollback exceeded its prepared Mantle radius at "
+ x + "," + y + "," + z);
} }
synchronized (chunk) { synchronized (chunk) {
if (!chunk.exists(section)) { Matter matter = chunk.get(y >> 4);
return false; 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(); 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) { private static void clearDeferredPlacement(Matter matter, int x, int y, int z) {
MatterSlice<Identifier> slice = matter.getSlice(Identifier.class); MatterSlice<Identifier> slice = matter.getSlice(Identifier.class);
if (slice != null) { if (slice != null) {
@@ -803,6 +635,7 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable {
if (hasProtectedHydrology(matter, x, y, z) || protectsSubterrainPlacement(x, y, z)) { if (hasProtectedHydrology(matter, x, y, z) || protectsSubterrainPlacement(x, y, z)) {
return; return;
} }
clearTile(matter, x, y, z);
MatterSlice<NativeBlockState> blockSlice = matter.slice(NativeBlockState.class); MatterSlice<NativeBlockState> blockSlice = matter.slice(NativeBlockState.class);
MatterSlice<Identifier> identifierSlice = matter.slice(Identifier.class); MatterSlice<Identifier> identifierSlice = matter.slice(Identifier.class);
capturePreObjectOriginal(matter, x, y, z, NativeBlockState.class); capturePreObjectOriginal(matter, x, y, z, NativeBlockState.class);
@@ -1102,479 +935,6 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable {
return getEngineMantle().getEngine(); 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 @Override
public void close() { public void close() {
for (int index = 0; index < window.length(); index++) { for (int index = 0; index < window.length(); index++) {
@@ -1689,6 +1049,10 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable {
maximumX = Math.max(maximumX, x); maximumX = Math.max(maximumX, x);
maximumZ = Math.max(maximumZ, z); maximumZ = Math.max(maximumZ, z);
if (type == NativeBlockState.class) { 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(Identifier.class).set(x & 15, y, z & 15, null);
fragment.slice(type).set(x & 15, y, z & 15, value == null ? AIR.get() : value); fragment.slice(type).set(x & 15, y, z & 15, value == null ? AIR.get() : value);
} else { } 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.Mantle;
import art.arcane.volmlib.util.mantle.runtime.MantleChunk; import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
import art.arcane.volmlib.util.matter.Matter; import art.arcane.volmlib.util.matter.Matter;
import art.arcane.volmlib.util.matter.MatterSlice;
import java.util.List; import java.util.List;
import java.util.Map; 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) { public static void put(Matter payload, int x, int y, int z, Object value) {
if (value instanceof NativeBlockState state) { 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(); String customKey = state.deferredPlacementKey();
NativeBlockState base = state.placementBaseState(); NativeBlockState base = state.placementBaseState();
boolean custom = state.isCustom() && customKey != null && base != null; boolean custom = state.isCustom() && customKey != null && base != null;
@@ -99,12 +99,19 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer {
} }
captureOriginal(originals, mutation.key()); captureOriginal(originals, mutation.key());
if (mutation.key().type() == NativeBlockState.class) { if (mutation.key().type() == NativeBlockState.class) {
captureOriginal(originals, new DataKey( TileWrapper tile = writer.getDataIfPresent(
mutation.key().x(), mutation.key().x(), mutation.key().y(), mutation.key().z(), TileWrapper.class);
mutation.key().y(), if (tile != null) {
mutation.key().z(), originals.putIfAbsent(new DataKey(
Identifier.class 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); mutation.apply(writer);
} }
@@ -148,6 +155,7 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer {
Identifier identifier = Identifier.fromString(placementKey); Identifier identifier = Identifier.fromString(placementKey);
OverlayCell cell = writableCell(x, y, z); OverlayCell cell = writableCell(x, y, z);
cell.block = baseState; cell.block = baseState;
cell.tile = CLEARED;
cell.identifier = identifier; cell.identifier = identifier;
mutations.add(replayed != null mutations.add(replayed != null
? replayed : new CustomBlockMutation(new DataKey(x, y, z, NativeBlockState.class), state)); ? 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); OverlayCell cell = writableCell(x, y, z);
if (data instanceof NativeBlockState) { if (data instanceof NativeBlockState) {
cell.block = data; cell.block = data;
cell.tile = CLEARED;
cell.identifier = CLEARED; cell.identifier = CLEARED;
} else { } else {
cell.put(type, data); cell.put(type, data);
@@ -325,15 +334,15 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer {
return cell; return cell;
} }
private Object prerequisiteOrCleared(int x, int y, int z, Class<?> type) { private Object publishedOrCleared(int x, int y, int z, Class<?> type) {
Object value = writer.getPrerequisiteDataIfPresent(x, y, z, type); Object value = writer.getDataIfPresent(x, y, z, type);
return value == null ? CLEARED : value; return value == null ? CLEARED : value;
} }
private void captureOriginal(LinkedHashMap<DataKey, Object> originals, DataKey key) { private void captureOriginal(LinkedHashMap<DataKey, Object> originals, DataKey key) {
originals.computeIfAbsent( originals.computeIfAbsent(
key, key,
candidate -> prerequisiteOrCleared( candidate -> publishedOrCleared(
candidate.x(), candidate.x(),
candidate.y(), candidate.y(),
candidate.z(), candidate.z(),
@@ -383,27 +392,16 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer {
private void rollback(LinkedHashMap<DataKey, Object> originals, Throwable failure) { private void rollback(LinkedHashMap<DataKey, Object> originals, Throwable failure) {
ArrayList<Map.Entry<DataKey, Object>> entries = new ArrayList<>(originals.entrySet()); ArrayList<Map.Entry<DataKey, Object>> entries = new ArrayList<>(originals.entrySet());
entries.sort((first, second) -> Boolean.compare( for (int index = entries.size() - 1; index >= 0; index--) {
second.getKey().type() == NativeBlockState.class, Map.Entry<DataKey, Object> entry = entries.get(index);
first.getKey().type() == NativeBlockState.class
));
for (Map.Entry<DataKey, Object> entry : entries) {
DataKey key = entry.getKey(); DataKey key = entry.getKey();
try { try {
if (key.type() == NativeBlockState.class writer.restoreData(key.x(), key.y(), key.z(), key.type(),
&& writer.restorePrerequisiteCell(key.x(), key.y(), key.z())) { entry.getValue() == CLEARED ? null : entry.getValue());
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);
}
} catch (Throwable rollbackFailure) { } catch (Throwable rollbackFailure) {
failure.addSuppressed(rollbackFailure); if (rollbackFailure != failure) {
failure.addSuppressed(rollbackFailure);
}
} }
} }
} }
@@ -413,6 +411,7 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer {
private Object hydrology; private Object hydrology;
private Object cavern; private Object cavern;
private Object identifier; private Object identifier;
private Object tile;
private Object string; private Object string;
private Object treeMaterial; private Object treeMaterial;
private Map<Class<?>, Object> other; private Map<Class<?>, Object> other;
@@ -430,6 +429,9 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer {
if (type == Identifier.class) { if (type == Identifier.class) {
return identifier; return identifier;
} }
if (type == TileWrapper.class) {
return tile;
}
if (type == String.class) { if (type == String.class) {
return string; return string;
} }
@@ -446,6 +448,8 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer {
cavern = value; cavern = value;
} else if (type == Identifier.class) { } else if (type == Identifier.class) {
identifier = value; identifier = value;
} else if (type == TileWrapper.class) {
tile = value;
} else if (type == String.class) { } else if (type == String.class) {
string = value; string = value;
} else if (type == TreeBlockMaterial.class) { } else if (type == TreeBlockMaterial.class) {
@@ -19,9 +19,7 @@ final class StructureFoundationPlanner {
return; return;
} }
long columnKey = pack(x, z); long columnKey = pack(x, z);
if (!columns.containsKey(columnKey) || y < columns.get(columnKey)) { columns.mergeInt(columnKey, y, Math::min);
columns.put(columnKey, y);
}
} }
static int findGroundY(int foundationY, int maxDepth, int minimumY, IntPredicate solidAtY) { 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.IrisObject;
import art.arcane.iris.structure.object.IrisObjectRotation; import art.arcane.iris.structure.object.IrisObjectRotation;
import art.arcane.iris.generation.block.B; 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.iris.generation.geometry.IrisBlockVector;
import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.structure.StructureCarvingFootprint; import art.arcane.volmlib.util.structure.StructureCarvingFootprint;
@@ -36,11 +38,12 @@ final class StructurePieceColumns {
if (object == null || rotation == null || object.getBlocks() == null) { if (object == null || rotation == null || object.getBlocks() == null) {
continue; continue;
} }
for (IrisBlockVector local : object.getBlocks().keys()) { VectorMap<NativeBlockState>.Cursor cursor = object.getBlocks().cursor();
if (local == null || B.isAir(object.getBlocks().get(local))) { while (cursor.next()) {
if (B.isAir(cursor.value())) {
continue; continue;
} }
IrisBlockVector rotated = rotation.rotate(local.clone()); IrisBlockVector rotated = rotation.rotate(cursor.key());
long worldX = (long) piece.getX() + rotated.getBlockX(); long worldX = (long) piece.getX() + rotated.getBlockX();
long worldY = (long) piece.getY() + rotated.getBlockY(); long worldY = (long) piece.getY() + rotated.getBlockY();
long worldZ = (long) piece.getZ() + rotated.getBlockZ(); 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) { public static <T> T get(Mantle<Matter> mantle, int x, int y, int z, Class<T> type) {
if (!hasJournal(type)) { Objects.requireNonNull(type, "Terrain matter type");
return mantle.get(x, y, z, type); if (y < 0 || y >= mantle.getWorldHeight()) {
return null;
} }
MantleChunk<Matter> chunk = mantle.getChunk(x >> 4, z >> 4); MantleChunk<Matter> chunk = mantle.useChunk(x >> 4, z >> 4);
synchronized (chunk) { try {
PreObjectMatterCell cell = mantle.get(x, y, z, PreObjectMatterCell.class); return get(chunk, x, y, z, type);
return cell != null && cell.captures(type) ? cell.original(type) : mantle.get(x, y, z, type); } finally {
chunk.release();
} }
} }
@@ -49,6 +49,9 @@ import lombok.experimental.Accessors;
@Data @Data
@EqualsAndHashCode(callSuper = false) @EqualsAndHashCode(callSuper = false)
public class IrisExpression extends IrisRegistrant { 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) @ArrayType(type = IrisExpressionLoad.class, min = 1)
@Description("Variables to use in this expression") @Description("Variables to use in this expression")
private KList<IrisExpressionLoad> variables = new KList<>(); private KList<IrisExpressionLoad> variables = new KList<>();
@@ -66,6 +69,10 @@ public class IrisExpression extends IrisRegistrant {
@Setter(AccessLevel.NONE) @Setter(AccessLevel.NONE)
private final transient LazyBoundedCache<Long, ProceduralStream<Double>> streams = new LazyBoundedCache<>(8); 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() { private Expression expression() {
return expressionCache.aquire(() -> { return expressionCache.aquire(() -> {
Scope scope = new Scope(); // Create variable scope. This scope can hold both constants and invocation variables. 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) { public double evaluate(RNG rng, double x, double z) {
double[] g = new double[3 + getVariables().size()]; EvaluationScratch scratch = evaluationScratch.get();
int m = 0; double[] arguments = scratch.acquire(3 + getVariables().size());
for (IrisExpressionLoad i : getVariables()) { try {
g[m++] = i.getValue(rng, getLoader(), x, z); 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) { public double evaluate(RNG rng, double x, double y, double z) {
double[] g = new double[3 + getVariables().size()]; EvaluationScratch scratch = evaluationScratch.get();
int m = 0; double[] arguments = scratch.acquire(3 + getVariables().size());
for (IrisExpressionLoad i : getVariables()) { try {
g[m++] = i.getValue(rng, getLoader(), x, y, z); 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 @Override
@@ -144,4 +157,28 @@ public class IrisExpression extends IrisRegistrant {
public String getTypeName() { public String getTypeName() {
return "Expression"; 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") @SuppressWarnings("BooleanMethodIsAlwaysInverted")
public boolean isFlat() { public boolean isFlat() {
return style == null || style.equals(NoiseStyle.FLAT); return expression == null && imageMap == null && (style == null || style.equals(NoiseStyle.FLAT));
} }
public double getMaxFractureDistance() { public double getMaxFractureDistance() {
@@ -210,8 +210,8 @@ public final class DimensionTerrainContext implements DataProvider {
if (key == null || key.isBlank()) { if (key == null || key.isBlank()) {
continue; continue;
} }
maximum += biome.getGenLinkMax(key, engine); maximum += biome.getGenLinkMax(key);
minimum += biome.getGenLinkMin(key, engine); minimum += biome.getGenLinkMin(key);
} }
interpolatorBounds.put(biome, new NoiseBounds(minimum, maximum)); interpolatorBounds.put(biome, new NoiseBounds(minimum, maximum));
} }
@@ -329,8 +329,8 @@ public final class DimensionTerrainContext implements DataProvider {
if (key == null || key.isBlank()) { if (key == null || key.isBlank()) {
continue; continue;
} }
maximum += sampledBiome.getGenLinkMax(key, engine); maximum += sampledBiome.getGenLinkMax(key);
minimum += sampledBiome.getGenLinkMin(key, engine); minimum += sampledBiome.getGenLinkMin(key);
} }
return new NoiseBounds(minimum, maximum); return new NoiseBounds(minimum, maximum);
} catch (Throwable e) { } 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.M;
import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.math.RNG;
import java.util.ArrayList;
import java.util.Comparator; 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 { public final class GenerationCacheWarmer {
private GenerationCacheWarmer() { private GenerationCacheWarmer() {
@@ -43,6 +50,7 @@ public final class GenerationCacheWarmer {
IrisData data = engine.getData(); IrisData data = engine.getData();
RNG root = new RNG(engine.getSeedManager().getComponent() + 7777L); RNG root = new RNG(engine.getSeedManager().getComponent() + 7777L);
int[] counter = {0}; int[] counter = {0};
List<ProceduralWarmTask> proceduralTasks = new ArrayList<>();
KList<IrisBiome> biomes = engine.getAllBiomes(); KList<IrisBiome> biomes = engine.getAllBiomes();
biomes.sort(Comparator.comparing(IrisBiome::getLoadKey)); biomes.sort(Comparator.comparing(IrisBiome::getLoadKey));
@@ -50,7 +58,7 @@ public final class GenerationCacheWarmer {
warmPlacements(biome.getObjects(), root, counter, data, engine); warmPlacements(biome.getObjects(), root, counter, data, engine);
warmDecorators(biome.getDecorators(), engine.getSeedManager().getComponent(), counter, data); warmDecorators(biome.getDecorators(), engine.getSeedManager().getComponent(), counter, data);
warmOres(biome.getOres(), engine.getSeedManager().getTerrain(), 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); KList<IrisRegion> regions = engine.getDimension().getAllRegions(engine);
@@ -58,10 +66,11 @@ public final class GenerationCacheWarmer {
for (IrisRegion region : regions) { for (IrisRegion region : regions) {
warmPlacements(region.getObjects(), root, counter, data, engine); warmPlacements(region.getObjects(), root, counter, data, engine);
warmOres(region.getOres(), engine.getSeedManager().getTerrain(), counter, data); 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); 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"); 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) { if (procedural == null) {
return; return;
} }
for (IrisProceduralPlacement placement : procedural.getAllPlacements()) { for (IrisProceduralPlacement placement : procedural.getAllPlacements()) {
if (placement == null) { if (placement != null) {
continue; tasks.add(new ProceduralWarmTask(placement, root.nextParallelRNG(counter[0]++)));
} }
placement.getVariantObject(data, root.nextParallelRNG(counter[0]++), null);
} }
} }
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; continue;
} }
max += biome.getGenLinkMax(key, engine); max += biome.getGenLinkMax(key);
min += biome.getGenLinkMin(key, engine); min += biome.getGenLinkMin(key);
} }
return new GeneratorBounds(min, max); return new GeneratorBounds(min, max);
@@ -18,13 +18,9 @@
package art.arcane.iris.generation.runtime; package art.arcane.iris.generation.runtime;
import art.arcane.volmlib.util.data.KCache;
public interface MeteredCache { public interface MeteredCache {
long getSize(); long getSize();
KCache<?, ?> getRawCache();
long getMaxSize(); long getMaxSize();
default double getUsage() { default double getUsage() {
@@ -20,7 +20,6 @@ package art.arcane.iris.generation.runtime;
import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.pack.loading.IrisData;
import art.arcane.volmlib.util.data.KCache;
import art.arcane.volmlib.util.format.Form; import art.arcane.volmlib.util.format.Form;
import art.arcane.iris.platform.bukkit.plugin.IrisService; import art.arcane.iris.platform.bukkit.plugin.IrisService;
import art.arcane.volmlib.util.scheduling.Looper; import art.arcane.volmlib.util.scheduling.Looper;
@@ -33,7 +32,6 @@ import java.util.Objects;
import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
public class PreservationSVC implements IrisService, PreservationRegistry { public class PreservationSVC implements IrisService, PreservationRegistry {
@@ -188,10 +186,6 @@ public class PreservationSVC implements IrisService, PreservationRegistry {
caches.add(new WeakReference<>(cache)); caches.add(new WeakReference<>(cache));
} }
public List<KCache<?, ?>> caches() {
return cacheStream().map(MeteredCache::getRawCache).collect(Collectors.toList());
}
@Unmodifiable @Unmodifiable
public List<MeteredCache> getCaches() { public List<MeteredCache> getCaches() {
return cacheStream().toList(); return cacheStream().toList();
@@ -192,7 +192,8 @@ public final class IrisDimensionStackActuator extends EngineAssignedActuator<Nat
IrisDimension dimension = terrainContext.getDimension(); IrisDimension dimension = terrainContext.getDimension();
IrisData data = terrainContext.getData(); IrisData data = terrainContext.getData();
IrisBiome biome = layer.biome(); 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()); int fluidDepth = Math.max(0, layer.fluidHeight() - layer.normalTerrainHeight());
KList<NativeBlockState> surfaceBlocks = biome == null || layer.terrainColumn() != null KList<NativeBlockState> surfaceBlocks = biome == null || layer.terrainColumn() != null
? null ? null
@@ -207,6 +208,8 @@ public final class IrisDimensionStackActuator extends EngineAssignedActuator<Nat
terrainContext.getSlopeStream() terrainContext.getSlopeStream()
); );
int paletteSurfaceY = layer.normalTerrainHeight(); 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 KList<NativeBlockState> seaBlocks = biome == null || fluidDepth == 0
? null ? null
: biome.generateSeaLayers(worldX, worldZ, rng, fluidDepth, data); : biome.generateSeaLayers(worldX, worldZ, rng, fluidDepth, data);
@@ -239,13 +242,32 @@ public final class IrisDimensionStackActuator extends EngineAssignedActuator<Nat
if (sourceSurfaceY != paletteSurfaceY) { if (sourceSurfaceY != paletteSurfaceY) {
paletteSurfaceY = sourceSurfaceY; paletteSurfaceY = sourceSurfaceY;
surfaceBlocks = null; surfaceBlocks = null;
ceilingBlocks = null;
} }
if (depth == 0 && layer.surfaceBlock() != null) { if (depth == 0 && layer.surfaceBlock() != null) {
writeBlock(output, metadata, localX, y, localZ, layer.surfaceBlock()); writeBlock(output, metadata, localX, y, localZ, layer.surfaceBlock());
} else { } 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) { if (surfaceBlocks == null && biome != null) {
surfaceBlocks = biome.generateLayersWithSlope(dimension, worldX, worldZ, rng, 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, writeBlock(output, metadata, localX, y, localZ,
surfaceBlocks != null && surfaceBlocks.hasIndex(depth) surfaceBlocks != null && surfaceBlocks.hasIndex(depth)
@@ -178,15 +178,24 @@ public class IrisFloatingChildBiomeModifier extends EngineAssignedModifier<Nativ
return block == null ? fallbackSolid : block; 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); IrisBiome target = entry == null ? parent : entry.getRealBiome(parent, data);
RNG layerRng = paletteRng(rng, entry); 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()) { 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); 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) { 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> topBlocks;
private final KList<NativeBlockState> bottomBlocks; private final KList<NativeBlockState> bottomBlocks;
private final NativeBlockState fallbackSolid; 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.topBlocks = topBlocks;
this.bottomBlocks = bottomBlocks; this.bottomBlocks = bottomBlocks;
this.fallbackSolid = fallbackSolid; this.fallbackSolid = fallbackSolid;
this.surfaceY = surfaceY;
this.physicalDepth = physicalDepth;
} }
} }
@@ -251,12 +264,12 @@ public class IrisFloatingChildBiomeModifier extends EngineAssignedModifier<Nativ
IrisFloatingChildBiomes entry = sample.entryAt(k); IrisFloatingChildBiomes entry = sample.entryAt(k);
PaletteContext paletteContext = paletteContexts.get(entry); PaletteContext paletteContext = paletteContexts.get(entry);
if (paletteContext == null) { 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); paletteContexts.put(entry, paletteContext);
} }
int depth = topDepthByEntry.getOrDefault(entry, 0); int depth = topDepthByEntry.getOrDefault(entry, 0);
int bottomDepth = bottomDepths == null || bottomDepths[k] < 0 ? depth : bottomDepths[k]; 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) { if (block != null) {
output.set(xf, y, zf, block); output.set(xf, y, zf, block);
} }
@@ -315,7 +315,7 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator<NativeBloc
cut = Math.max(0, naturalSurfaceY - layerSurfaceY); cut = Math.max(0, naturalSurfaceY - layerSurfaceY);
} }
blocks = biome.generateLayers(dimension, realX, realZ, rng, 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); int deepFloor = Math.max(bedrockFloor, layerCeilingY > 0 ? layerCeilingY + 2 : 0);
@@ -436,7 +436,7 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator<NativeBloc
int depthFromFace = y - faceY; int depthFromFace = y - faceY;
if (upperBlocks == null && upperBiome != null) { if (upperBlocks == null && upperBiome != null) {
upperBlocks = upperBiome.generateLayersWithSlope(upperContext.getDimension(), upperBlocks = upperBiome.generateLayersWithSlope(upperContext.getDimension(),
realX, realZ, rng, sourceSurfaceY, sourceSurfaceY, realX, realZ, rng, sourceSurfaceY + 1 - (bedrockEnabled ? 1 : 0), sourceSurfaceY,
upperContext.getData(), upperContext.getSurfaceSlopeStream(sourceSurfaceY)); upperContext.getData(), upperContext.getSurfaceSlopeStream(sourceSurfaceY));
} }
if (upperBlocks != null && upperBlocks.hasIndex(depthFromFace)) { 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.BasicStream;
import art.arcane.volmlib.util.stream.ProceduralStream; import art.arcane.volmlib.util.stream.ProceduralStream;
import art.arcane.volmlib.util.cache.WorldCache2DDouble; 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 { public class CachedDoubleStream2D extends BasicStream<Double> implements ProceduralStream<Double>, MeteredCache, ChunkFillableStream2D, ChunkFillableDoubleStream2D {
private final ProceduralStream<Double> stream; private final ProceduralStream<Double> stream;
@@ -56,11 +55,6 @@ public class CachedDoubleStream2D extends BasicStream<Double> implements Procedu
return cache.getSize(); return cache.getSize();
} }
@Override
public KCache<?, ?> getRawCache() {
return null;
}
@Override @Override
public long getMaxSize() { public long getMaxSize() {
return cache.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.iris.generation.runtime.MeteredCache;
import art.arcane.volmlib.util.cache.ChunkCache2D; import art.arcane.volmlib.util.cache.ChunkCache2D;
import art.arcane.volmlib.util.cache.WorldCache2D; 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.BasicStream;
import art.arcane.volmlib.util.stream.ProceduralStream; import art.arcane.volmlib.util.stream.ProceduralStream;
public class CachedStream2D<T> extends BasicStream<T> implements ProceduralStream<T>, MeteredCache, ChunkFillableStream2D { 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(); return cache.getSize();
} }
@Override
public KCache<?, ?> getRawCache() {
return null;
}
@Override @Override
public long getMaxSize() { public long getMaxSize() {
return cache.getMaxSize(); return cache.getMaxSize();
@@ -64,11 +64,6 @@ public class CachedStream3D<T> extends BasicStream<T> implements ProceduralStrea
return cache.getSize(); return cache.getSize();
} }
@Override
public KCache<?, ?> getRawCache() {
return cache;
}
@Override @Override
public long getMaxSize() { public long getMaxSize() {
return cache.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.function.Function3;
import art.arcane.volmlib.util.stream.BasicStream; import art.arcane.volmlib.util.stream.BasicStream;
import art.arcane.volmlib.util.stream.ProceduralStream; import art.arcane.volmlib.util.stream.ProceduralStream;
import java.util.Objects;
public class ContextInjectingStream<T> extends BasicStream<T> { public class ContextInjectingStream<T> extends BasicStream<T> {
private final Engine engine; private final Engine engine;
private final Function3<ChunkContext, Integer, Integer, T> contextAccessor; 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) { public ContextInjectingStream(ProceduralStream<T> stream, Engine engine, Function3<ChunkContext, Integer, Integer, T> contextAccessor) {
super(stream); super(stream);
this.engine = engine; this.engine = Objects.requireNonNull(engine);
this.contextAccessor = contextAccessor; this.contextAccessor = contextAccessor;
} }
@@ -30,9 +28,9 @@ public class ContextInjectingStream<T> extends BasicStream<T> {
int blockZ = (int) z; int blockZ = (int) z;
if (chunkContext != null if (chunkContext != null
&& (engine == null || context.getEngine() == engine) && context.getEngine() == engine
&& context.getGenerationSessionId() == chunkContext.getGenerationSessionId() && context.getGenerationSessionId() == chunkContext.getGenerationSessionId()
&& (engine == null || context.getGenerationSessionId() == engine.getGenerationSessionId()) && context.getGenerationSessionId() == engine.getGenerationSessionId()
&& blockX >> 4 == chunkContext.getX() >> 4 && blockX >> 4 == chunkContext.getX() >> 4
&& blockZ >> 4 == chunkContext.getZ() >> 4) { && blockZ >> 4 == chunkContext.getZ() >> 4) {
T t = contextAccessor.apply(chunkContext, blockX & 15, blockZ & 15); T t = contextAccessor.apply(chunkContext, blockX & 15, blockZ & 15);
@@ -1,13 +1,9 @@
package art.arcane.iris.generation.stream; 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.runtime.Engine;
import art.arcane.iris.generation.noise.IrisStyledRange;
import art.arcane.iris.generation.context.ChunkContext; import art.arcane.iris.generation.context.ChunkContext;
import art.arcane.volmlib.util.function.Function3; 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.ProceduralStream;
import art.arcane.volmlib.util.stream.interpolation.Interpolated;
public final class GenerationStreams { public final class GenerationStreams {
private GenerationStreams() { private GenerationStreams() {
@@ -25,19 +21,7 @@ public final class GenerationStreams {
return new CachedStream3D<T>(name, engine, stream, maxSize); 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) { public static <T> ProceduralStream<T> contextInjecting(ProceduralStream<T> stream, Engine engine, Function3<ChunkContext, Integer, Integer, T> contextAccessor) {
return new ContextInjectingStream<>(stream, engine, 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);
}
} }
Loaded 100 of 197 files, more files were not shown because too many files have changed in this diff. Show more