Compare commits

...
Author SHA1 Message Date
Brian Neumann-Fopiano b357eee181 Fixed runtime configuration
- Timeout overrides retain original values for restoration
- Timeout restoration preserves operator changes and symbolic links
- Tests cover timeout persistence and failed configuration writes
- Runtime targets use separate Gradle caches and shared module outputs
- Modded build inputs include test fixtures and runtime cache settings
2026-10-18 18:15:46 -04:00
Brian Neumann-Fopiano 64f7a7a9ac Merged generation fixes
- Procedural trees keep compact terrain-supported roots
- Canopies preserve trunks across chunk generation and replay
- CraftEngine content follows native terrain replacement and regeneration
- Regression fixtures cover tree geometry and custom content cleanup
2026-10-18 18:04:40 -04:00
Brian Neumann-Fopiano a6aeceff86 Grounded procedural trees
- Compact connected roots extend bounded gaps into supporting terrain
- Unsupported cliff placements leave no partial trees
- Root and canopy roles follow object scaling and rotation
- Canopies preserve solid trunk cells across chunk replay and saved continuations
- Branch wood replaces leaf clusters within each tree
- Regression tests cover slopes and overlapping tree placements
- Gameplay checks compare grounded spindle geometry across both packs
2026-10-18 18:00:40 -04:00
Brian Neumann-Fopiano 51c626c410 Merged custom content fixes
- Deferred placement guards and generated content ownership share the current engine
- Native terrain writes invalidate stale custom content across platforms
2026-10-18 17:59:20 -04:00
Brian Neumann-Fopiano 34417b2f5b Tracked generated content ownership
- Generated furniture keeps ownership tokens for targeted removal
- Native writes invalidate pending custom content and tile records
- Regeneration and chunk updates remove invalidated content on owning threads
- Provider callbacks return ownership tokens across server platforms
- Regression fixtures cover terrain replacement and restart cleanup
2026-10-18 17:58:30 -04:00
Brian Neumann-Fopiano ed5483d75a Merged stale placement guard
- Combine the carrier check with native write invalidation
2026-10-16 19:37:15 -04:00
Brian Neumann-Fopiano ff537f4abc Cleared records under native writes
- Added DeferredPlacementRecords to gate tracking on Identifier and TileWrapper slices
- Cleared deferred custom and tile records at positions native structure placement changed
- Implemented block change tracking in the Bukkit and modded structure policies
- Covered invalidation through the custom block materialization pass
- Covered modded wrapper change tracking and protected cells
- Passed disabled change sets in existing wrapper tests
2026-10-16 19:36:35 -04:00
Brian Neumann-Fopiano dbc9d95372 Skip stale deferred placements
- Skip deferred custom block records whose live block no longer matches the provider carrier type
- Leave air-carrier furniture and unresolvable carriers to the provider unchanged
- Resolve each carrier once per chunk pass and log skipped records once per chunk at debug level
- Skip tile records quietly when the live block no longer matches the tile material
- Apply the same carrier check to the modded custom block pass
- Add Bukkit, CraftEngine and modded regression tests for flattened, replaced, intact and furniture records
2026-10-16 19:32:01 -04:00
98 changed files with 5637 additions and 339 deletions
@@ -3,6 +3,7 @@ package art.arcane.iris.platform.bukkit.nms.v26_2_R1;
import art.arcane.volmlib.nativelib.v26_2_R1.terrain.NativeStructureWorldgenAccess;
import art.arcane.iris.structure.placement.IrisStructureTerrain;
import art.arcane.volmlib.nativelib.terrain.NativeBlockChangeSet;
import art.arcane.volmlib.util.structure.StructureTerrainMode;
import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeStructurePostProcessor;
import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeStructureTerrainIntegrator;
@@ -230,8 +231,9 @@ public class NativeStructureWorldgenAccessTest {
recording.world(), generationCenter,
(x, z) -> SURFACE_FIRST_FREE_Y,
(x, z) -> FLOOR_FIRST_FREE_Y,
true,
position -> false);
true, true,
position -> false,
NativeBlockChangeSet.disabled());
int x = generationCenter.getMiddleBlockX();
int z = generationCenter.getMiddleBlockZ();
@@ -248,8 +250,9 @@ public class NativeStructureWorldgenAccessTest {
recording.world(), generationCenter(),
(x, z) -> SURFACE_FIRST_FREE_Y,
(x, z) -> FLOOR_FIRST_FREE_Y,
false,
position::equals);
false, true,
position::equals,
NativeBlockChangeSet.disabled());
assertSame(Blocks.DIRT, access.getBlockState(position).getBlock());
assertFalse(access.ensureCanWrite(position));
@@ -319,8 +322,9 @@ public class NativeStructureWorldgenAccessTest {
recording.world(), generationCenter(),
(x, z) -> SURFACE_FIRST_FREE_Y,
(x, z) -> FLOOR_FIRST_FREE_Y,
false,
position -> false);
false, true,
position -> false,
NativeBlockChangeSet.disabled());
}
private static ChunkPos generationCenter() {
@@ -3,6 +3,7 @@ package art.arcane.iris.platform.bukkit.nms.v26_3_R1;
import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeStructureWorldgenAccess;
import art.arcane.iris.structure.placement.IrisStructureTerrain;
import art.arcane.volmlib.nativelib.terrain.NativeBlockChangeSet;
import art.arcane.volmlib.util.structure.StructureTerrainMode;
import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeStructurePostProcessor;
import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeStructureTerrainIntegrator;
@@ -230,8 +231,9 @@ public class NativeStructureWorldgenAccessTest {
recording.world(), generationCenter,
(x, z) -> SURFACE_FIRST_FREE_Y,
(x, z) -> FLOOR_FIRST_FREE_Y,
true,
position -> false);
true, true,
position -> false,
NativeBlockChangeSet.disabled());
int x = generationCenter.getMiddleBlockX();
int z = generationCenter.getMiddleBlockZ();
@@ -248,8 +250,9 @@ public class NativeStructureWorldgenAccessTest {
recording.world(), generationCenter(),
(x, z) -> SURFACE_FIRST_FREE_Y,
(x, z) -> FLOOR_FIRST_FREE_Y,
false,
position::equals);
false, true,
position::equals,
NativeBlockChangeSet.disabled());
assertSame(Blocks.DIRT, access.getBlockState(position).getBlock());
assertFalse(access.ensureCanWrite(position));
@@ -319,8 +322,9 @@ public class NativeStructureWorldgenAccessTest {
recording.world(), generationCenter(),
(x, z) -> SURFACE_FIRST_FREE_Y,
(x, z) -> FLOOR_FIRST_FREE_Y,
false,
position -> false);
false, true,
position -> false,
NativeBlockChangeSet.disabled());
}
private static ChunkPos generationCenter() {
@@ -19,6 +19,10 @@
package art.arcane.iris.world.runtime;
import art.arcane.iris.api.world.IrisWorldPhase;
import art.arcane.iris.generation.mantle.CustomBlockOwnership;
import art.arcane.iris.integration.ExternalDataSVC;
import art.arcane.iris.spi.IrisServices;
import art.arcane.volmlib.nativelib.terrain.NativeBlockPoint;
import art.arcane.iris.pack.datapack.ServerConfigurator;
import art.arcane.iris.pack.datapack.DatapackIngestService;
import art.arcane.iris.world.event.IrisEngineHotloadEvent;
@@ -51,6 +55,13 @@ import org.bukkit.World;
import org.bukkit.WorldBorder;
public final class BukkitEnginePlatformHooks implements EnginePlatformHooks {
@Override
public boolean removeCustomBlock(Engine engine, NativeBlockPoint position, CustomBlockOwnership.Entry entry) {
ExternalDataSVC service = IrisServices.getOrNull(ExternalDataSVC.class);
return service != null && service.removeGenerated(
BukkitWorldBinding.world(engine.getWorld()).getBlockAt(position.x(), position.y(), position.z()), entry);
}
@Override
public CompletableFuture<Void> flushSavedTerrainCapture(Engine engine) {
return INMS.get().flushSavedTerrainCapture(BukkitWorldBinding.world(engine.getWorld()));
@@ -0,0 +1,46 @@
{
"name": "Flat",
"derivative": "minecraft:snowy_plains",
"vanillaDerivative": "minecraft:snowy_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": "craftengine:default/flower_basket",
"data": {
"variant": "ground"
}
}
],
"chance": 1,
"forcePlace": true
}
]
}
@@ -0,0 +1,39 @@
{
"name": "CraftEngine Imported Feature 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"
}
]
},
"importedStructures": {
"disabled": [
"minecraft:"
]
},
"importedFeatures": {
"enabled": true,
"steps": [
"TOP_LAYER_MODIFICATION"
]
}
}
@@ -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,35 @@
plugins {
id 'java'
}
version = '1.0.0'
repositories {
mavenCentral()
maven { url = 'https://repo.papermc.io/repository/maven-public/' }
}
dependencies {
compileOnly files(providers.gradleProperty('irisJar').get())
compileOnly files(providers.gradleProperty('craftEngineJar').get())
compileOnly 'io.papermc.paper:paper-api:26.1.2.build.74-stable'
}
java {
toolchain.languageVersion = JavaLanguageVersion.of(25)
}
sourceSets {
main {
java.srcDirs = ['src']
resources.srcDirs = ['resources']
}
}
tasks.withType(JavaCompile).configureEach {
options.compilerArgs += '-parameters'
}
tasks.named('jar') {
archiveBaseName = 'IrisCraftEngineNativeFixture'
}
@@ -0,0 +1,12 @@
name: IrisCraftEngineNativeFixture
version: 1.0.0
main: art.arcane.iris.fixture.CraftEngineNativeFixture
api-version: '26.1.2'
folia-supported: true
depend: [Iris, CraftEngine]
commands:
iriscenative:
permission: iriscenative.run
permissions:
iriscenative.run:
default: op
@@ -0,0 +1 @@
rootProject.name = 'iris-craftengine-native-fixture'
@@ -0,0 +1,385 @@
package art.arcane.iris.fixture;
import art.arcane.iris.generation.block.IrisCustomData;
import art.arcane.iris.generation.block.TileData;
import art.arcane.iris.generation.mantle.DeferredPlacementRecords;
import art.arcane.iris.generation.mantle.CustomBlockOwnership;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.integration.ExternalDataSVC;
import art.arcane.iris.integration.Identifier;
import art.arcane.iris.platform.generation.EngineBukkitOps;
import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.world.IrisCreator;
import art.arcane.iris.world.IrisToolbelt;
import art.arcane.iris.world.storage.matter.TileWrapper;
import art.arcane.iris.world.task.J;
import art.arcane.volmlib.nativelib.terrain.NativeBlockChangeSet;
import art.arcane.volmlib.util.mantle.flag.MantleFlag;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
import art.arcane.volmlib.util.matter.Matter;
import net.momirealms.craftengine.bukkit.api.CraftEngineBlocks;
import net.momirealms.craftengine.bukkit.api.CraftEngineFurniture;
import net.momirealms.craftengine.core.util.Key;
import org.bukkit.Bukkit;
import org.bukkit.Chunk;
import org.bukkit.GameMode;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.bukkit.plugin.java.JavaPlugin;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.logging.Level;
public final class CraftEngineNativeFixture extends JavaPlugin {
private static final NamespacedKey WORLD_KEY = new NamespacedKey("iris", "ce_native_probe");
private static final NamespacedKey FEATURE_WORLD_KEY = new NamespacedKey("iris", "ce_native_feature_probe");
private static final int TEST_Y = 180;
private static final int TEST_Z = 8;
private static final String BLOCK_ID = "craftengine:default/topaz_ore";
private static final String FURNITURE_ID = "craftengine:default/flower_basket[variant=ground]";
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (!(sender instanceof Player player) || arguments.length != 1) {
sender.sendMessage("CE_FAILED Use /iriscenative prepare|pending|neighbor|loaded|features|restart from a player");
return true;
}
if (arguments[0].equals("prepare")) {
player.setGameMode(GameMode.CREATIVE);
prepare(player);
return true;
}
if (arguments[0].equals("features")) {
importedFeatures(player);
return true;
}
World world = Bukkit.getWorld(WORLD_KEY);
if (world == null) {
fail(player, new IllegalStateException("Fixture world has not been created"));
return true;
}
if (arguments[0].equals("restart")) {
loadChunks().thenCompose(ignored -> J.runRegionFuture(world, 0, 0,
() -> later(player, world, () -> restarted(player, world))))
.whenComplete((ignored, failure) -> {
if (failure != null) {
fail(player, failure);
}
});
return true;
}
J.runRegionFuture(world, 0, 0, () -> {
switch (arguments[0]) {
case "pending" -> pending(player, world);
case "neighbor" -> neighbor(player, world);
case "loaded" -> invalidateLoaded(player, world, IrisToolbelt.access(world).getEngine());
default -> throw new IllegalArgumentException("Unknown fixture operation");
}
}).whenComplete((ignored, failure) -> {
if (failure != null) {
fail(player, failure);
}
});
return true;
}
private void prepare(Player player) {
CompletableFuture.runAsync(() -> {
if (Bukkit.getWorld(WORLD_KEY) == null) {
try {
new IrisCreator().name(WORLD_KEY.toString()).dimension("ce-native-fixture")
.seed(78264193L).create();
} catch (Exception failure) {
throw new IllegalStateException("Could not create CraftEngine fixture world", failure);
}
}
}).thenCompose(ignored -> loadChunks()).thenCompose(ignored -> {
World world = Bukkit.getWorld(WORLD_KEY);
return J.runRegionFuture(world, 0, 0, () -> preparePlatform(world));
}).thenAccept(ignored -> {
World world = Bukkit.getWorld(WORLD_KEY);
if (!J.runEntity(player, () -> player.teleportAsync(new Location(world, 8.5, TEST_Y + 3, 8.5))
.whenComplete((teleported, failure) -> {
if (failure != null || !Boolean.TRUE.equals(teleported)) {
fail(player, failure == null ? new IllegalStateException("Fixture teleport failed") : failure);
return;
}
player.sendMessage("CE_READY world=" + WORLD_KEY + " folia=" + J.isFolia());
}))) {
throw new IllegalStateException("Could not schedule fixture teleport");
}
}).exceptionally(failure -> {
fail(player, failure);
return null;
});
}
private void importedFeatures(Player player) {
CompletableFuture.runAsync(() -> {
if (Bukkit.getWorld(FEATURE_WORLD_KEY) == null) {
try {
new IrisCreator().name(FEATURE_WORLD_KEY.toString()).dimension("ce-native-feature-fixture")
.seed(78264193L).create();
} catch (Exception failure) {
throw new IllegalStateException("Could not create imported feature fixture world", failure);
}
}
}).thenCompose(ignored -> {
World world = Bukkit.getWorld(FEATURE_WORLD_KEY);
return world.getChunkAtAsync(0, 0, true);
}).thenCompose(chunk -> J.runRegionFuture(chunk.getWorld(), 0, 0, () -> {
World world = chunk.getWorld();
Engine engine = IrisToolbelt.access(world).getEngine();
int snow = 0;
int pending = 0;
for (int x = 0; x < 16; x++) {
for (int z = 0; z < 16; z++) {
if (world.getBlockAt(x, 97, z).getType() == Material.SNOW) {
snow++;
}
if (engine.getMantle().getMantle().get(x, 97 - engine.getMinHeight(), z, Identifier.class) != null) {
pending++;
}
}
}
require(snow == 256, "Imported snow feature did not cover all 256 test columns: " + snow);
require(pending == 0, "Imported feature left pending furniture records: " + pending);
for (Entity entity : chunk.getEntities()) {
require(!CraftEngineFurniture.isFurniture(entity), "Imported snow feature left generated furniture");
}
if (!J.runEntity(player, () -> player.teleportAsync(new Location(world, 8.5, 101, 8.5))
.whenComplete((teleported, failure) -> {
if (failure != null || !Boolean.TRUE.equals(teleported)) {
fail(player, failure == null ? new IllegalStateException("Feature world teleport failed") : failure);
return;
}
player.sendMessage("CE_FEATURES snow=256 pending=0 furniture=0");
}))) {
throw new IllegalStateException("Could not schedule feature world teleport");
}
})).exceptionally(failure -> {
fail(player, failure);
return null;
});
}
private CompletableFuture<Void> loadChunks() {
World world = Bukkit.getWorld(WORLD_KEY);
require(world != null && IrisToolbelt.isIrisWorld(world), "Fixture must use a real Iris world");
List<CompletableFuture<Void>> chunks = new ArrayList<>(15);
for (int x = -1; x <= 3; x++) {
for (int z = -1; z <= 1; z++) {
chunks.add(world.getChunkAtAsync(x, z, true).thenCompose(chunk ->
J.runRegionFuture(world, chunk.getX(), chunk.getZ(), () -> chunk.addPluginChunkTicket(this))));
}
}
return CompletableFuture.allOf(chunks.toArray(CompletableFuture[]::new));
}
private void preparePlatform(World world) {
require(CraftEngineBlocks.byId(Key.of("default:topaz_ore")) != null, "CraftEngine default block is missing");
require(CraftEngineFurniture.byId(Key.of("default:flower_basket")) != null, "CraftEngine furniture is missing");
for (int chunkX = 0; chunkX <= 2; chunkX++) {
for (Entity entity : world.getChunkAt(chunkX, 0).getEntities()) {
if (CraftEngineFurniture.isFurniture(entity)) {
CraftEngineFurniture.remove(entity, false, false);
}
}
}
for (int x = 0; x <= 47; x++) {
for (int z = 2; z <= 14; z++) {
world.getBlockAt(x, TEST_Y - 1, z).setType(Material.STONE, false);
for (int y = TEST_Y; y <= TEST_Y + 5; y++) {
world.getBlockAt(x, y, z).setType(Material.AIR, false);
}
}
}
}
private void pending(Player player, World world) {
Engine engine = IrisToolbelt.access(world).getEngine();
Mantle<Matter> mantle = engine.getMantle().getMantle();
queue(engine, world, 2, BLOCK_ID);
queue(engine, world, 4, BLOCK_ID);
queue(engine, world, 6, BLOCK_ID);
queue(engine, world, 8, BLOCK_ID);
queue(engine, world, 10, FURNITURE_ID);
queue(engine, world, 12, FURNITURE_ID);
Block chest = world.getBlockAt(14, TEST_Y, TEST_Z);
chest.setType(Material.CHEST, false);
mantle.set(14, TEST_Y - engine.getMinHeight(), TEST_Z, new TileWrapper(TileData.getTileState(chest, false)));
world.getBlockAt(4, TEST_Y, TEST_Z).setType(Material.AIR, false);
world.getBlockAt(6, TEST_Y, TEST_Z).setType(Material.COBBLESTONE, false);
NativeBlockChangeSet changes = DeferredPlacementRecords.track(engine, bounds(world));
changes.add(8, TEST_Y, TEST_Z);
changes.add(12, TEST_Y - 1, TEST_Z);
changes.add(12, TEST_Y, TEST_Z);
changes.add(14, TEST_Y, TEST_Z);
world.getBlockAt(8, TEST_Y, TEST_Z).setType(Material.AIR, false);
world.getBlockAt(12, TEST_Y - 1, TEST_Z).setType(Material.AIR, false);
chest.setType(Material.COBBLESTONE, false);
DeferredPlacementRecords.invalidate(engine, changes);
require(record(engine, 8, Identifier.class) == null, "Native cut retained a pending custom record");
require(record(engine, 12, Identifier.class) == null, "Flatten column retained pending furniture");
require(record(engine, 14, TileWrapper.class) == null, "Native wall retained pending chest data");
require(record(engine, 2, Identifier.class) != null, "Native writes invalidated an untouched custom record");
replay(engine, world, 0);
CustomBlockOwnership ownership = record(engine, 10, CustomBlockOwnership.class);
require(record(engine, 2, Identifier.class) == null, "Custom materialization did not consume pending records");
require(CraftEngineBlocks.isCustomBlock(world.getBlockAt(2, TEST_Y, TEST_Z)), "Intact custom block was not placed");
require(world.getBlockAt(4, TEST_Y, TEST_Z).getType().isAir(), "Cut custom block was resurrected");
require(world.getBlockAt(6, TEST_Y, TEST_Z).getType() == Material.COBBLESTONE, "Custom block replaced native wall");
require(world.getBlockAt(8, TEST_Y, TEST_Z).getType().isAir(), "Invalidated custom block was resurrected");
require(chest.getType() == Material.COBBLESTONE, "Chest data replaced native wall");
later(player, world, () -> {
int intactFurniture = furnitureAt(world, 10);
require(intactFurniture == 1, "Intact furniture count is " + intactFurniture
+ "; pending=" + record(engine, 10, Identifier.class)
+ "; ownership=" + ownership
+ "; chunkEntities=" + world.getChunkAt(0, 0).getEntities().length);
require(furnitureAt(world, 12) == 0, "Flattened furniture was resurrected");
player.sendMessage("CE_PENDING intact=1 air=1 wall=1 invalidated=1 flatten=1 chest=1 furniture=1");
});
}
private void neighbor(Player player, World world) {
Engine engine = IrisToolbelt.access(world).getEngine();
queue(engine, world, 18, FURNITURE_ID);
queue(engine, world, 20, FURNITURE_ID);
queue(engine, world, 22, FURNITURE_ID);
queue(engine, world, 42, FURNITURE_ID);
replay(engine, world, 1);
replay(engine, world, 2);
world.getBlockAt(40, TEST_Y, TEST_Z).setType(Material.DIAMOND_BLOCK, false);
later(player, world, () -> {
require(furnitureAt(world, 18) == 1 && furnitureAt(world, 20) == 1
&& furnitureAt(world, 22) == 1, "Neighbor furniture did not materialize");
require(furnitureAt(world, 42) == 1 && record(engine, 42, CustomBlockOwnership.class) != null,
"Regeneration target furniture did not materialize with saved ownership");
if (!J.runEntity(player, () -> player.teleportAsync(new Location(world, 40.5, TEST_Y + 3, TEST_Z + 0.5))
.whenComplete((teleported, failure) -> {
if (failure != null || !Boolean.TRUE.equals(teleported)) {
fail(player, failure == null ? new IllegalStateException("Regeneration target teleport failed") : failure);
return;
}
player.sendMessage("CE_NEIGHBOR_READY chunk=2,0 furniture=3 target=1");
}))) {
throw new IllegalStateException("Could not schedule regeneration target teleport");
}
});
}
private void invalidateLoaded(Player player, World world, Engine engine) {
require(world.getBlockAt(40, TEST_Y, TEST_Z).getType().isAir(), "Adjacent target chunk did not regenerate");
require(furnitureAt(world, 18) == 1 && furnitureAt(world, 20) == 1
&& furnitureAt(world, 22) == 1, "Neighbor furniture did not materialize");
NativeBlockChangeSet changes = DeferredPlacementRecords.track(engine, bounds(world));
changes.add(18, TEST_Y - 1, TEST_Z);
changes.add(18, TEST_Y, TEST_Z);
changes.add(22, TEST_Y, TEST_Z);
world.getBlockAt(18, TEST_Y - 1, TEST_Z).setType(Material.AIR, false);
world.getBlockAt(18, TEST_Y, TEST_Z).setType(Material.COBBLESTONE, false);
DeferredPlacementRecords.invalidate(engine, changes);
require(CraftEngineFurniture.place(new Location(world, 22.5, TEST_Y, TEST_Z + 0.5),
Key.of("default:flower_basket"), "ground", false) != null,
"Could not place replacement furniture before scheduled cleanup");
later(player, world, () -> {
require(furnitureAt(world, 42) == 0, "Regeneration retained target chunk furniture");
require(furnitureAt(world, 18) == 0, "Native write left materialized neighbor furniture");
require(furnitureAt(world, 20) == 1, "Native write removed untouched neighbor furniture");
require(furnitureAt(world, 22) == 1, "Native cleanup removed replacement furniture or retained the old one");
require(world.getBlockAt(18, TEST_Y, TEST_Z).getType() == Material.COBBLESTONE,
"Furniture cleanup replaced the native wall");
player.sendMessage("CE_LOADED removed=1 untouched=1 replacement=1 wall=1 target=1");
});
}
private void restarted(Player player, World world) {
require(furnitureAt(world, 20) == 1, "Generated neighbor furniture did not survive restart");
require(furnitureAt(world, 10) == 1, "Untouched furniture did not survive restart");
Engine engine = IrisToolbelt.access(world).getEngine();
NativeBlockChangeSet changes = DeferredPlacementRecords.track(engine, bounds(world));
changes.add(20, TEST_Y, TEST_Z);
DeferredPlacementRecords.invalidate(engine, changes);
later(player, world, () -> {
require(furnitureAt(world, 20) == 0, "Persisted furniture ownership was not invalidated after restart");
require(furnitureAt(world, 10) == 1, "Restart cleanup removed untouched furniture");
player.sendMessage("CE_RESTART removed=1 untouched=1");
});
}
private void queue(Engine engine, World world, int x, String identifier) {
ExternalDataSVC service = IrisServices.get(ExternalDataSVC.class);
BlockData data = service.getBlockData(Identifier.fromString(identifier)).orElseThrow();
require(data instanceof IrisCustomData, "Iris did not resolve real CraftEngine content: " + identifier);
IrisCustomData custom = (IrisCustomData) data;
world.getBlockAt(x, TEST_Y, TEST_Z).setBlockData(custom.getBase(), false);
engine.getMantle().getMantle().set(x, TEST_Y - engine.getMinHeight(), TEST_Z, custom.getCustom());
}
private <T> T record(Engine engine, int x, Class<T> type) {
return engine.getMantle().getMantle().get(x, TEST_Y - engine.getMinHeight(), TEST_Z, type);
}
private void replay(Engine engine, World world, int chunkX) {
Mantle<Matter> mantle = engine.getMantle().getMantle();
for (int x = chunkX - 1; x <= chunkX + 1; x++) {
for (int z = -1; z <= 1; z++) {
require(world.isChunkLoaded(x, z), "Replay neighbor is not loaded: " + x + "," + z);
}
}
require(mantle.isLoaded(world.getChunkAt(chunkX, 0)), "Replay mantle chunk is not loaded: " + chunkX);
mantle.flag(chunkX, 0, MantleFlag.ETCHED, false);
mantle.flag(chunkX, 0, MantleFlag.TILE, false);
mantle.flag(chunkX, 0, MantleFlag.CUSTOM, false);
EngineBukkitOps.updateChunk(engine, world.getChunkAt(chunkX, 0), mantle);
}
private int furnitureAt(World world, int x) {
int count = 0;
for (Entity entity : world.getChunkAt(x >> 4, TEST_Z >> 4).getEntities()) {
Location location = entity.getLocation();
if (location.getBlockX() == x && location.getBlockY() == TEST_Y && location.getBlockZ() == TEST_Z
&& CraftEngineFurniture.getLoadedFurnitureByMetaEntity(entity) != null) {
count++;
}
}
return count;
}
private void later(Player player, World world, Runnable assertion) {
Bukkit.getRegionScheduler().runDelayed(this, new Location(world, 8, TEST_Y, TEST_Z), task -> {
try {
assertion.run();
} catch (Throwable failure) {
fail(player, failure);
}
}, 20);
}
private NativeBlockChangeSet.Bounds bounds(World world) {
return new NativeBlockChangeSet.Bounds(-1, -1, 3, 1, world.getMinHeight(), world.getMaxHeight() - world.getMinHeight());
}
private void require(boolean condition, String message) {
if (!condition) {
throw new IllegalStateException(message);
}
}
private void fail(CommandSender sender, Throwable failure) {
getLogger().log(Level.SEVERE, "CraftEngine native placement assertion failed", failure);
sender.sendMessage("CE_FAILED " + failure.getMessage());
}
}
@@ -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": 96,
"max": 96
}
]
}
@@ -0,0 +1,33 @@
{
"name": "CraftEngine Native 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"
}
]
},
"importedStructures": {
"disabled": [
"minecraft:"
]
}
}
@@ -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,15 @@
import { readFile } from 'node:fs/promises'
import path from 'node:path'
export default {
name: 'iris-craftengine-native-restart',
description: 'Verify persisted generated furniture ownership after a full server restart.',
async run(context) {
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
context.expect(/^isolated=true\s*$/m.test(source), 'CraftEngine regression requires an isolated instance')
await context.step('native writes remove persisted ownership after restart', async () => {
const response = await context.command('/iriscenative restart', /CE_RESTART|CE_FAILED/, 60000)
context.expect(response.includes('CE_RESTART removed=1 untouched=1'), response)
})
}
}
@@ -0,0 +1,49 @@
import { readFile } from 'node:fs/promises'
import path from 'node:path'
export default {
name: 'iris-craftengine-native',
description: 'Verify deferred native invalidation and loaded neighbor cleanup with real Iris and CraftEngine.',
async run(context) {
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
context.expect(/^isolated=true\s*$/m.test(source), 'CraftEngine regression requires an isolated instance')
await context.step('create and enter a real Iris world with CraftEngine content', async () => {
const response = await context.command('/iriscenative prepare', /CE_READY|CE_FAILED/, 180000)
context.expect(response.includes('CE_READY'), response)
})
await context.step('native writes remove pending block, chest, and flatten furniture records', async () => {
const response = await context.command('/iriscenative pending', /CE_PENDING|CE_FAILED/, 60000)
context.expect(response.includes('CE_PENDING intact=1 air=1 wall=1 invalidated=1 flatten=1 chest=1 furniture=1'), response)
await context.waitUntil(() => {
const origin = context.bot.entity.position.floored()
const cut = context.bot.blockAt(origin.set(4, 180, 8))
const wall = context.bot.blockAt(origin.set(6, 180, 8))
const invalidated = context.bot.blockAt(origin.set(8, 180, 8))
const chest = context.bot.blockAt(origin.set(14, 180, 8))
return cut?.name === 'air' && wall?.name === 'cobblestone'
&& invalidated?.name === 'air' && chest?.name === 'cobblestone'
}, { label: 'Client observes retained native air and walls', timeoutMs: 30000, intervalMs: 100 })
})
await context.step('regeneration removes target furniture and preserves neighbor ownership', async () => {
const ready = await context.command('/iriscenative neighbor', /CE_NEIGHBOR_READY|CE_FAILED/, 30000)
context.expect(ready.includes('CE_NEIGHBOR_READY chunk=2,0 furniture=3 target=1'), ready)
const regenerated = await context.command('/iris developer regen radius=0', /Regen complete:|Regen failed:/, 90000)
context.expect(regenerated.includes('Regen complete:'), regenerated)
const response = await context.command('/iriscenative loaded', /CE_LOADED|CE_FAILED/, 30000)
context.expect(response.includes('CE_LOADED removed=1 untouched=1 replacement=1 wall=1 target=1'), response)
})
await context.step('imported snow features replace generated furniture records', async () => {
const response = await context.command('/iriscenative features', /CE_FEATURES|CE_FAILED/, 180000)
context.expect(response.includes('CE_FEATURES snow=256 pending=0 furniture=0'), response)
await context.waitUntil(() => {
const position = context.bot.entity.position.floored()
for (let x = 0; x < 16; x++) {
for (let z = 0; z < 16; z++) {
if (context.bot.blockAt(position.set(x, 97, z))?.name !== 'snow') return false
}
}
return true
}, { label: 'Client observes 256 imported snow layers', timeoutMs: 30000, intervalMs: 100 })
})
}
}
@@ -0,0 +1,143 @@
import { readFile, realpath } from 'node:fs/promises'
import path from 'node:path'
const nonGround = new Set(['air', 'cave_air', 'void_air', 'water', 'lava', 'nether_wart_block', 'warped_wart_block'])
const chunkOffsets = [-56, -40, -24, -8, 8, 24, 40, 56]
function isWood(block) {
return block !== null && /_(wood|hyphae|log|stem)$/.test(block.name)
}
function isGround(block) {
return block !== null && block.boundingBox === 'block' && !nonGround.has(block.name)
&& !block.name.endsWith('_leaves') && !isWood(block)
}
function positionKey(position) {
return `${position.x},${position.y},${position.z}`
}
function groundSpread(bot, base) {
const heights = []
for (const x of [-3, 0, 3]) {
for (const z of [-3, 0, 3]) {
for (let y = base.y + 6; y >= base.y - 8; y--) {
if (!isGround(bot.blockAt(base.offset(x, y - base.y, z)))) continue
heights.push(y)
break
}
}
}
return heights.length === 9 ? Math.max(...heights) - Math.min(...heights) : null
}
function inspectTrees(context) {
const bot = context.bot
const center = bot.entity.position.floored()
const woodIds = Object.values(bot.registry.blocksByName).filter(isWood).map(block => block.id)
const positions = bot.findBlocks({ matching: woodIds, point: center, maxDistance: 64, count: 20000 })
const columns = new Map()
for (const position of positions) {
const key = `${position.x},${position.z}`
if (!columns.has(key)) columns.set(key, [])
columns.get(key).push(position)
}
const trees = []
for (const column of columns.values()) {
column.sort((first, second) => first.y - second.y)
const base = column[0]
const height = column[column.length - 1].y - base.y + 1
if (column.length < 8 || height < 8 || column.length / height < 0.65) continue
if (column.some(position => position.distanceTo(center) >= 60)) continue
if (![0, 1, 2, 3].every(y => isWood(bot.blockAt(base.offset(0, y, 0))))) continue
const upperGaps = []
for (let y = base.y; y < base.y + height; y++) {
const block = bot.blockAt(base.offset(0, y - base.y, 0))
if (block === null || !isWood(block)) upperGaps.push({ y, block: block?.name ?? null })
}
const grounded = isGround(bot.blockAt(base.offset(0, -1, 0)))
const spread = groundSpread(bot, base)
trees.push({ base: { x: base.x, y: base.y, z: base.z }, woodBlocks: column.length,
height, grounded, terrainSpread: spread, upperGaps })
}
return trees
}
export default {
name: 'iris-tree-grounding',
description: 'Verify grounded spindle bases, continuous overlapping trunks, and paired pack geometry.',
async run(context) {
const directory = await realpath(context.server.directory)
const source = await readFile(path.join(directory, '.server-source'), 'utf8')
context.expect(/^isolated=true\s*$/m.test(source), 'Tree generation requires an isolated instance')
const packs = await realpath(path.join(directory, 'plugins', 'Iris', 'packs'))
context.expect(packs.startsWith(`${directory}${path.sep}`), 'Tree tests require local pack copies')
context.report.treeWorlds = []
context.report.treeGrounding = []
context.report.treeContinuity = []
for (const pack of ['overworld', 'underworld']) {
const dimension = JSON.parse(await readFile(path.join(packs, pack, 'dimensions', `${pack}.json`), 'utf8'))
context.expect(dimension.focus === 'estranged/spindlewood', 'Pack must focus on spindlewood', { pack })
await context.step(`create ${pack} spindle world`, async () => {
const world = `tree_${pack}_${Date.now().toString(36)}`
const response = await context.command(`/iris create name=${world} type=${pack} seed=78264193`,
/Successfully created your world|Failed to create/, 240000)
context.expect(response.includes('Successfully created your world'), response)
context.report.treeWorlds.push({ pack, name: world, seed: 78264193, biome: dimension.focus })
if (context.bot.game.gameMode !== 'spectator') {
await context.command('/minecraft:gamemode spectator', /spectator/i, 30000)
}
await context.waitUntil(() => context.bot.game.gameMode === 'spectator',
{ label: 'Spectator mode for terrain inspection', timeoutMs: 30000, intervalMs: 100 })
})
await context.step(`${pack} trees connect to flat and sloped terrain`, async () => {
const origin = context.bot.entity.position.floored()
const inspected = new Map()
for (const offset of [0, 80, 160]) {
await context.command(`/minecraft:tp @s ${origin.x + offset + 0.5} ${origin.y + 12} ${origin.z + 0.5}`,
/Teleported/i, 30000)
await context.waitUntil(() => {
const center = context.bot.entity.position.floored()
if (Math.abs(center.x - origin.x - offset) > 1) return false
return chunkOffsets.every(x => chunkOffsets.every(z => context.bot.blockAt(center.offset(x, 0, z)) !== null))
}, { label: 'Complete terrain neighborhood', timeoutMs: 90000, intervalMs: 250 })
const trees = inspectTrees(context)
for (const tree of trees) {
context.expect(tree.grounded, 'Generated spindle trunk base has no terrain connection', { pack, ...tree })
inspected.set(positionKey(tree.base), tree)
}
if (inspected.size >= 8 && [...inspected.values()].some(tree => tree.terrainSpread >= 2)) break
}
const trees = [...inspected.values()]
const sloped = trees.filter(tree => tree.terrainSpread >= 2)
context.expect(trees.length >= 8, 'Expected at least eight generated spindle trunk bases', { pack, trees })
context.expect(sloped.length > 0, 'Expected a grounded tree on uneven terrain', { pack, trees })
context.report.treeGrounding.push({ pack, trees: trees.length, sloped: sloped.length, samples: trees })
})
await context.step(`${pack} overlapping canopy preserves the seeded main trunk`, async () => {
await context.command('/minecraft:tp @s 0.5 88 0.5', /Teleported/i, 30000)
await context.waitUntil(() => {
const center = context.bot.entity.position.floored()
return Math.abs(center.x) < 2 && Math.abs(center.z) < 2
&& context.bot.blockAt(center.offset(-7 - center.x, 69 - center.y, 6 - center.z)) !== null
}, { label: 'Seeded trunk neighborhood', timeoutMs: 90000, intervalMs: 250 })
const center = context.bot.entity.position.floored()
const blocks = []
for (let y = 69; y <= 83; y++) {
const block = context.bot.blockAt(center.offset(-7 - center.x, y - center.y, 6 - center.z))
context.expect(isWood(block), 'Overlapping canopy interrupted the seeded main trunk',
{ pack, x: -7, y, z: 6, block: block?.name ?? null })
blocks.push({ y, block: block.name })
}
context.report.treeContinuity.push({ pack, x: -7, z: 6, blocks })
})
}
await context.step('paired packs retain matching sampled tree geometry', async () => {
const geometry = context.report.treeGrounding.map(result => result.samples.map(tree => ({
base: tree.base, woodBlocks: tree.woodBlocks, height: tree.height, terrainSpread: tree.terrainSpread
})).sort((first, second) => positionKey(first.base).localeCompare(positionKey(second.base))))
context.expect(JSON.stringify(geometry[0]) === JSON.stringify(geometry[1]),
'Paired spindle packs produced different sampled geometry', { geometry })
})
}
}
+2
View File
@@ -33,6 +33,8 @@ plugins {
rootProject.name = 'iris-fabric'
apply from: new File(settingsDir, '../../gradle/runtime-cache.settings.gradle').canonicalFile
dependencyResolutionManagement {
versionCatalogs {
libs {
+2
View File
@@ -33,6 +33,8 @@ plugins {
rootProject.name = 'iris-forge'
apply from: new File(settingsDir, '../../gradle/runtime-cache.settings.gradle').canonicalFile
dependencyResolutionManagement {
versionCatalogs {
libs {
@@ -57,6 +57,8 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedChunkGenera
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeGeneratorContext;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
import art.arcane.volmlib.util.matter.Matter;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedStructureStage;
import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
@@ -829,12 +831,12 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
importedFeatures.invalidate();
}
public void updateRegeneratedChunk(int chunkX, int chunkZ) {
public void updateRegeneratedChunk(Mantle<Matter> mantle, int chunkX, int chunkZ) {
ModdedChunkUpdateService service = ModdedEngineBootstrap.services().service(ModdedChunkUpdateService.class);
if (service == null) {
throw new IllegalStateException("Iris chunk update service is unavailable during regeneration");
}
service.updateRegeneratedChunk(engine(), boundLevel(), chunkX, chunkZ);
service.updateRegeneratedChunk(engine(), boundLevel(), mantle, chunkX, chunkZ);
}
public NativeBiomeResolver regenBiomeResolver() {
@@ -2,6 +2,7 @@ package art.arcane.iris.modded;
import art.arcane.volmlib.nativelib.terrain.structure.StructureStagePolicy;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.mantle.DeferredPlacementRecords;
import art.arcane.iris.generation.terrain.IrisMaterialPalette;
import art.arcane.iris.structure.nativegen.GenerationWritePolicy;
import art.arcane.iris.structure.nativegen.IrisStructurePolicy;
@@ -13,6 +14,7 @@ import art.arcane.iris.structure.nativegen.StructureLocateSearch;
import art.arcane.iris.structure.placement.IrisStructureLocator;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedStructureStage;
import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeStructureVolumeSource;
import art.arcane.volmlib.nativelib.terrain.NativeBlockChangeSet;
import art.arcane.volmlib.nativelib.terrain.NativeBlockPositionPredicate;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.volmlib.nativelib.terrain.structure.StructureLocateSearchAccess;
@@ -107,6 +109,16 @@ final class ModdedNativeStructurePolicy implements NativeModdedStructureStage.Po
context.getDimensionStackContext(), context.getMinHeight());
}
@Override
public NativeBlockChangeSet trackBlockChanges(Engine context, NativeBlockChangeSet.Bounds bounds) {
return DeferredPlacementRecords.track(context, bounds);
}
@Override
public void invalidateBlockChanges(Engine context, NativeBlockChangeSet changes) {
DeferredPlacementRecords.invalidate(context, changes);
}
@Override
public IntBinaryOperator surfaceHeight(Engine context) {
return (x, z) -> Engine.hostHeight(context, x, z, true) + context.getMinHeight();
@@ -19,6 +19,8 @@
package art.arcane.iris.modded.api;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.mantle.CustomBlockOwnership;
import art.arcane.iris.integration.Identifier;
import art.arcane.iris.modded.ModdedIrisLog;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedBlockState;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeBlockPlacement;
@@ -297,11 +299,11 @@ public final class ModdedCustomContentRegistry {
* consulted for that position. An unparseable key or no matching provider is logged and skipped. Called on the
* server thread with the chunk loaded.
*/
public static void processBlockPlacement(Engine engine, String key, Supplier<NativeBlockPlacement> placement) {
public static CustomBlockOwnership.Entry processBlockPlacement(Engine engine, String key, Supplier<NativeBlockPlacement> placement) {
String base = parseIdentifier(key);
if (base == null) {
ModdedIrisLog.warn("Iris deferred custom block placement rejected invalid id {}", key);
return;
return null;
}
Map<String, String> state = parseState(key);
for (ModdedDataProvider provider : PROVIDERS) {
@@ -309,14 +311,33 @@ public final class ModdedCustomContentRegistry {
continue;
}
try {
provider.processBlockPlacement(new ModdedBlockPlacementContext(
String token = provider.processBlockPlacement(new ModdedBlockPlacementContext(
engine, placement.get(), base, state));
int properties = key.indexOf('[');
String qualifiedKey = base.replace(':', '/') + (properties < 0 ? "" : key.substring(properties));
return token == null ? null : new CustomBlockOwnership.Entry(
new Identifier(provider.modId(), qualifiedKey), token, false);
} catch (Throwable error) {
ModdedIrisLog.error("Iris custom content provider '{}' failed post-placement for {}", provider.modId(), key, error);
}
return;
return null;
}
ModdedIrisLog.warn("Iris deferred custom block placement has no provider for {}", key);
return null;
}
public static boolean removeGenerated(Engine engine, CustomBlockOwnership.Entry entry,
Supplier<NativeBlockPlacement> placement) {
for (ModdedDataProvider provider : PROVIDERS) {
if (provider.modId().equals(entry.identifier().namespace()) && provider.isReady()) {
String storedKey = entry.identifier().key();
int separator = storedKey.indexOf('/');
String key = storedKey.substring(0, separator) + ":" + storedKey.substring(separator + 1);
return provider.removeGenerated(new ModdedBlockPlacementContext(
engine, placement.get(), parseIdentifier(key), parseState(key)), entry.token());
}
}
return false;
}
/**
@@ -95,7 +95,12 @@ public interface ModdedDataProvider {
* Called on the server thread, once per deferred position, by the first provider that claims the identifier -
* later providers are not consulted for that position. Only fires for states returned as deferred.
*/
default void processBlockPlacement(ModdedBlockPlacementContext context) {
default String processBlockPlacement(ModdedBlockPlacementContext context) {
return null;
}
default boolean removeGenerated(ModdedBlockPlacementContext context, String token) {
return false;
}
/**
@@ -22,6 +22,7 @@ import art.arcane.iris.modded.ModdedIrisLog;
import art.arcane.iris.world.WorldMaintenance;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.mantle.EngineMantle;
import art.arcane.iris.generation.mantle.DeferredPlacementRecords;
import art.arcane.iris.modded.IrisModdedChunkGenerator;
import art.arcane.iris.modded.ModdedBlockBuffer;
import art.arcane.iris.spi.IrisPlatforms;
@@ -36,6 +37,9 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeCommandSource;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeChunkRegeneration;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
import art.arcane.volmlib.util.matter.Matter;
import java.util.List;
import java.util.concurrent.CountDownLatch;
@@ -110,10 +114,12 @@ public final class ModdedRegen {
private void resetMantleMargin() {
EngineMantle engineMantle = engine.getMantle();
Mantle<Matter> mantle = engineMantle.getMantle();
int margin = radius + Math.max(engineMantle.getRadius(), engineMantle.getRealRadius()) + 1;
for (int dx = -margin; dx <= margin; dx++) {
for (int dz = -margin; dz <= margin; dz++) {
engineMantle.getMantle().deleteChunk(centerX + dx, centerZ + dz);
DeferredPlacementRecords.resetMantleChunk(mantle, centerX + dx, centerZ + dz,
Math.abs(dx) <= radius && Math.abs(dz) <= radius);
}
}
}
@@ -199,10 +205,21 @@ public final class ModdedRegen {
}
private void apply(int chunkX, int chunkZ, ModdedBlockBuffer blocks, Hunk<NativeBiome> biomes) {
Mantle<Matter> mantle = engine.getMantle().getMantle();
regeneration.apply(new NativeChunkRegeneration.Replacement(chunkX, chunkZ,
engine.getMinHeight(), engine.getMaxHeight() - engine.getMinHeight(),
blocks::rawOrNull, generator.regenBiomeResolver()),
() -> generator.updateRegeneratedChunk(chunkX, chunkZ));
() -> completeReplacement(mantle, chunkX, chunkZ));
}
private void completeReplacement(Mantle<Matter> mantle, int chunkX, int chunkZ) {
MantleChunk<Matter> records = mantle.useChunk(chunkX, chunkZ);
try {
DeferredPlacementRecords.invalidateCustomBlockOwnership(records);
} finally {
records.release();
}
generator.updateRegeneratedChunk(mantle, chunkX, chunkZ);
}
private void ok(String message) {
@@ -28,13 +28,17 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeBlockProperties;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.studio.view.PregeneratorJob;
import art.arcane.iris.integration.Identifier;
import art.arcane.iris.generation.mantle.CustomBlockOwnership;
import art.arcane.iris.generation.mantle.DeferredPlacementRecords;
import art.arcane.iris.generation.cache.Cache;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.block.TileData;
import art.arcane.iris.generation.decoration.IrisProceduralBlocks;
import art.arcane.iris.modded.IrisModdedChunkGenerator;
import art.arcane.iris.modded.ModdedBlockResolution;
import art.arcane.iris.modded.ModdedLootApplier;
import art.arcane.iris.modded.ModdedTileData;
import art.arcane.iris.modded.api.ModdedBlockData;
import art.arcane.iris.modded.api.ModdedCustomContentRegistry;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeBlockPlacement;
import art.arcane.iris.spi.IrisLogging;
@@ -50,6 +54,8 @@ import art.arcane.volmlib.util.scheduling.PrecisionStopwatch;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import it.unimi.dsi.fastutil.ints.IntArrayList;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
@@ -162,8 +168,8 @@ public final class ModdedChunkUpdateService implements ModdedTickableService {
updatePassChunk(engine, level, (int) chunkKey, (int) (chunkKey >> 32)));
}
public void updateRegeneratedChunk(Engine engine, NativeWorld level, int chunkX, int chunkZ) {
updateChunk(engine, level, chunkX, chunkZ, false);
public void updateRegeneratedChunk(Engine engine, NativeWorld level, Mantle<Matter> mantle, int chunkX, int chunkZ) {
updateChunk(engine, level, mantle, chunkX, chunkZ, false);
}
/**
@@ -172,7 +178,7 @@ public final class ModdedChunkUpdateService implements ModdedTickableService {
*/
private void updatePassChunk(Engine engine, NativeWorld level, int chunkX, int chunkZ) {
try {
updateChunk(engine, level, chunkX, chunkZ, true);
updateChunk(engine, level, engine.getMantle().getMantle(), chunkX, chunkZ, true);
} catch (Throwable failure) {
if (engine.isClosed() || engine.getMantle().getMantle().isClosed()) {
throw failure;
@@ -184,7 +190,8 @@ public final class ModdedChunkUpdateService implements ModdedTickableService {
}
}
private void updateChunk(Engine engine, NativeWorld level, int chunkX, int chunkZ, boolean requireNeighbors) {
private void updateChunk(Engine engine, NativeWorld level, Mantle<Matter> mantle,
int chunkX, int chunkZ, boolean requireNeighbors) {
if (requireNeighbors) {
for (int x = -1; x <= 1; x++) {
for (int z = -1; z <= 1; z++) {
@@ -195,11 +202,11 @@ public final class ModdedChunkUpdateService implements ModdedTickableService {
}
}
Mantle<Matter> mantle = engine.getMantle().getMantle();
if (!mantle.isChunkLoaded(chunkX, chunkZ)) {
warmupMantleChunk(mantle, chunkX, chunkZ);
return;
}
DeferredPlacementRecords.removeInvalidated(engine, mantle, chunkX, chunkZ);
if (mantle.hasFlag(chunkX, chunkZ, MantleFlag.ETCHED)) {
return;
}
@@ -225,30 +232,51 @@ public final class ModdedChunkUpdateService implements ModdedTickableService {
int baseX = chunkX << 4;
int baseZ = chunkZ << 4;
int minHeight = engine.getWorld().minHeight();
materializeDeferredSlice(chunk, TileWrapper.class, () ->
chunk.iterate(TileWrapper.class, (Integer x, Integer yf, Integer z, TileWrapper v) -> {
int y = yf + minHeight;
if (y < level.minHeight() || y >= level.maxHeight() - 1) {
return;
}
applyTile(level, baseX + (x & 15), y, baseZ + (z & 15), v.getData());
}));
materializeDeferredSlice(chunk, TileWrapper.class, () -> {
for (DeferredPlacementRecords.Pending<TileWrapper> record : DeferredPlacementRecords.snapshot(chunk, TileWrapper.class)) {
if (!DeferredPlacementRecords.isPending(chunk, TileWrapper.class, record)) {
continue;
}
NativeBlockPoint position = record.position();
int y = position.y() + minHeight;
if (y < level.minHeight() || y >= level.maxHeight() - 1) {
continue;
}
applyTile(level, baseX + (position.x() & 15), y, baseZ + (position.z() & 15), record.value().getData());
}
});
}
private void runCustomPass(Engine engine, NativeWorld level, int chunkX, int chunkZ, MantleChunk<Matter> chunk) {
int baseX = chunkX << 4;
int baseZ = chunkZ << 4;
int minHeight = engine.getWorld().minHeight();
materializeDeferredSlice(chunk, Identifier.class, () ->
chunk.iterate(Identifier.class, (Integer x, Integer yf, Integer z, Identifier identifier) -> {
int y = yf + minHeight;
if (y < level.minHeight() || y >= level.maxHeight() - 1) {
return;
}
NativeBlockPoint position = new NativeBlockPoint(baseX + (x & 15), y, baseZ + (z & 15));
ModdedCustomContentRegistry.processBlockPlacement(engine, identifier.toString(),
() -> NativeWorldMaintenance.placement(level, position));
}));
StaleCarriers stale = new StaleCarriers();
materializeDeferredSlice(chunk, Identifier.class, () -> {
for (DeferredPlacementRecords.Pending<Identifier> record : DeferredPlacementRecords.snapshot(chunk, Identifier.class)) {
if (!DeferredPlacementRecords.isPending(chunk, Identifier.class, record)) {
continue;
}
NativeBlockPoint local = record.position();
int y = local.y() + minHeight;
if (y < level.minHeight() || y >= level.maxHeight() - 1) {
continue;
}
int blockX = baseX + (local.x() & 15);
int blockZ = baseZ + (local.z() & 15);
String key = record.value().toString();
if (stale.carrierReplaced(key, level.getBlock(blockX, y, blockZ))) {
continue;
}
NativeBlockPoint position = new NativeBlockPoint(blockX, y, blockZ);
CustomBlockOwnership.Entry ownership = ModdedCustomContentRegistry.processBlockPlacement(engine, key,
() -> NativeWorldMaintenance.placement(level, position));
if (ownership != null) {
DeferredPlacementRecords.remember(engine, chunk, local, ownership);
}
}
});
stale.report(level.name(), chunkX, chunkZ);
}
static void materializeDeferredSlice(MantleChunk<Matter> chunk, Class<?> sliceType, Runnable materializer) {
@@ -386,4 +414,41 @@ public final class ModdedChunkUpdateService implements ModdedTickableService {
NativeWorldMaintenance.refresh(level, pos);
}
}
private static final class StaleCarriers {
private static final String UNCHECKED = "";
private final Map<String, String> carriers = new HashMap<>();
private int skipped;
private boolean carrierReplaced(String key, NativeBlockState live) {
String carrier = carriers.get(key);
if (carrier == null) {
carrier = carrierKey(key);
carriers.put(key, carrier);
}
if (carrier.equals(UNCHECKED) || carrier.equals(IrisProceduralBlocks.materialKey(live))
|| ((carrier.equals("minecraft:fire") || carrier.equals("minecraft:soul_fire")) && live.isAir())) {
return false;
}
skipped++;
return true;
}
private void report(String levelName, int chunkX, int chunkZ) {
if (skipped == 0) {
return;
}
IrisLogging.debug("Skipped %d stale custom block records at chunk %d,%d in %s",
skipped, chunkX, chunkZ, levelName);
}
private static String carrierKey(String key) {
ModdedBlockData resolved = ModdedCustomContentRegistry.resolveBlock(key);
if (resolved == null || resolved.state().isAir()) {
return UNCHECKED;
}
return IrisProceduralBlocks.materialKey(resolved.state());
}
}
}
@@ -28,6 +28,10 @@ import art.arcane.iris.modded.ModdedSavedTerrainCapture;
import java.util.concurrent.CompletableFuture;
import art.arcane.iris.generation.runtime.EnginePlatformHooks;
import art.arcane.iris.generation.mantle.CustomBlockOwnership;
import art.arcane.iris.modded.api.ModdedCustomContentRegistry;
import art.arcane.volmlib.nativelib.terrain.NativeBlockPoint;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldMaintenance;
import art.arcane.volmlib.nativelib.terrain.structure.NativeStructureVolume;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.generation.terrain.IrisDimensionRuntimeContract;
@@ -72,6 +76,12 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
private volatile ExecutorService executor;
private long lastPollAt;
@Override
public boolean removeCustomBlock(Engine engine, NativeBlockPoint position, CustomBlockOwnership.Entry entry) {
return ModdedCustomContentRegistry.removeGenerated(engine, entry,
() -> NativeWorldMaintenance.placement(engine.getWorld().platformWorld(), position));
}
@Override
public CompletableFuture<Void> flushSavedTerrainCapture(Engine engine) {
return ModdedSavedTerrainCapture.flush(engine);
@@ -3,6 +3,7 @@ package art.arcane.iris.modded;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedNativeStructureWorldgenAccess;
import art.arcane.iris.structure.placement.IrisStructureTerrain;
import art.arcane.volmlib.nativelib.terrain.NativeBlockChangeSet;
import art.arcane.volmlib.util.structure.StructureTerrainMode;
import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeStructurePostProcessor;
import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeStructureTerrainIntegrator;
@@ -230,8 +231,9 @@ public class ModdedNativeStructureWorldgenAccessTest {
recording.world(), generationCenter,
(x, z) -> SURFACE_FIRST_FREE_Y,
(x, z) -> FLOOR_FIRST_FREE_Y,
true,
position -> false);
true, true,
position -> false,
NativeBlockChangeSet.disabled());
int x = generationCenter.getMiddleBlockX();
int z = generationCenter.getMiddleBlockZ();
@@ -248,8 +250,9 @@ public class ModdedNativeStructureWorldgenAccessTest {
recording.world(), generationCenter(),
(x, z) -> SURFACE_FIRST_FREE_Y,
(x, z) -> FLOOR_FIRST_FREE_Y,
false,
position::equals);
false, true,
position::equals,
NativeBlockChangeSet.disabled());
assertSame(Blocks.DIRT, access.getBlockState(position).getBlock());
assertFalse(access.ensureCanWrite(position));
@@ -270,6 +273,37 @@ public class ModdedNativeStructureWorldgenAccessTest {
assertEquals(1, recording.mutations);
}
@Test
public void acceptedWritesAndReportedChangesAreTrackedOutsideProtectedCells() {
RecordingDelegate recording = new RecordingDelegate();
BlockPos protectedCell = generationCenter().getMiddleBlockPosition(70);
WorldGenLevel delegate = recording.world();
NativeBlockChangeSet changes = NativeBlockChangeSet.create(
ModdedNativeStructureWorldgenAccess.changeBounds(delegate, generationCenter()));
ModdedNativeStructureWorldgenAccess access = ModdedNativeStructureWorldgenAccess.create(
delegate, generationCenter(),
(x, z) -> SURFACE_FIRST_FREE_Y,
(x, z) -> FLOOR_FIRST_FREE_Y,
false, true,
protectedCell::equals,
changes);
BlockPos written = protectedCell.above();
BlockPos removed = protectedCell.east();
BlockPos reported = protectedCell.above(2);
assertFalse(access.setBlock(protectedCell, Blocks.STONE.defaultBlockState(), 2));
assertTrue(access.setBlock(written, Blocks.STONE.defaultBlockState(), 2));
assertTrue(access.removeBlock(removed, false));
access.recordBlockChange(reported.getX(), reported.getY(), reported.getZ());
access.recordBlockChange(protectedCell.getX(), protectedCell.getY(), protectedCell.getZ());
assertEquals(3, changes.size());
assertTrue(changes.contains(written.getX(), written.getY(), written.getZ()));
assertTrue(changes.contains(removed.getX(), removed.getY(), removed.getZ()));
assertTrue(changes.contains(reported.getX(), reported.getY(), reported.getZ()));
assertFalse(changes.contains(protectedCell.getX(), protectedCell.getY(), protectedCell.getZ()));
}
@Test
public void statuslessChunkReadsPreserveWorldgenDelegateSemantics() {
RecordingDelegate recording = new RecordingDelegate();
@@ -319,8 +353,9 @@ public class ModdedNativeStructureWorldgenAccessTest {
recording.world(), generationCenter(),
(x, z) -> SURFACE_FIRST_FREE_Y,
(x, z) -> FLOOR_FIRST_FREE_Y,
false,
position -> false);
false, true,
position -> false,
NativeBlockChangeSet.disabled());
}
private static ChunkPos generationCenter() {
@@ -1,5 +1,9 @@
package art.arcane.iris.modded.api;
import art.arcane.iris.generation.mantle.CustomBlockOwnership;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.integration.Identifier;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeBlockPlacement;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeEntityRuntime;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeSpawnedEntity;
import net.minecraft.SharedConstants;
@@ -9,6 +13,7 @@ import org.junit.Test;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
@@ -20,6 +25,33 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class ModdedCustomContentRegistryTest {
@Test
public void removalReturnsToOriginalProviderWithTokenAndPlacementState() {
ModdedDataProvider provider = mock(ModdedDataProvider.class);
Engine engine = mock(Engine.class);
NativeBlockPlacement placement = mock(NativeBlockPlacement.class);
ModdedBlockPlacementContext context = new ModdedBlockPlacementContext(
engine, placement, "provider_test:furniture/chair", Map.of("variant", "oak"));
when(provider.modId()).thenReturn("ownership_test");
when(provider.isReady()).thenReturn(true);
when(provider.isValidProvider("provider_test:furniture/chair", ModdedDataType.BLOCK)).thenReturn(true);
when(provider.processBlockPlacement(context)).thenReturn("entity-token");
when(provider.removeGenerated(context, "entity-token")).thenReturn(true);
ModdedCustomContentRegistry.Discovery discovery = ModdedCustomContentRegistry.discover(List.of(provider));
try {
CustomBlockOwnership.Entry ownership = ModdedCustomContentRegistry.processBlockPlacement(engine,
"provider_test:furniture/chair[variant=oak]", () -> placement);
assertEquals(new CustomBlockOwnership.Entry(
Identifier.fromString("ownership_test:provider_test/furniture/chair[variant=oak]"),
"entity-token", false), ownership);
assertTrue(ModdedCustomContentRegistry.removeGenerated(engine, ownership, () -> placement));
verify(provider).removeGenerated(context, "entity-token");
} finally {
discovery.rollback();
}
}
@BeforeClass
public static void bootstrapRegistries() {
SharedConstants.tryDetectVersion();
@@ -0,0 +1,244 @@
package art.arcane.iris.modded.service;
import art.arcane.iris.generation.mantle.EngineMantle;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.EngineMetrics;
import art.arcane.iris.integration.Identifier;
import art.arcane.iris.modded.api.ModdedBlockData;
import art.arcane.iris.modded.api.ModdedBlockPlacementContext;
import art.arcane.iris.modded.api.ModdedCustomContentRegistry;
import art.arcane.iris.modded.api.ModdedDataProvider;
import art.arcane.iris.modded.api.ModdedDataType;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.world.IrisWorld;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedBlockState;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.volmlib.util.function.Consumer4;
import art.arcane.volmlib.util.mantle.flag.MantleFlag;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
import art.arcane.volmlib.util.mantle.runtime.MantleDataAdapter;
import art.arcane.volmlib.util.mantle.runtime.MantleHooks;
import art.arcane.volmlib.util.matter.Matter;
import net.minecraft.SharedConstants;
import net.minecraft.core.BlockPos;
import net.minecraft.server.Bootstrap;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.NoteBlock;
import net.minecraft.world.level.block.state.BlockState;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;
import org.mockito.MockedStatic;
import org.mockito.invocation.InvocationOnMock;
import java.lang.reflect.Proxy;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
public class ModdedChunkUpdateServiceCarrierTest {
private static final String MOD_ID = "iris_carrier_guard_test";
private static final String LAMP = MOD_ID + ":lamp";
private static final String CHAIR = MOD_ID + ":chair";
private static final String FIRE = MOD_ID + ":fire";
private static final String SOUL_FIRE = MOD_ID + ":soul_fire";
private static final List<BlockPos> PLACED = new CopyOnWriteArrayList<>();
@BeforeClass
public static void bootstrapMinecraftRegistries() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
ModdedCustomContentRegistry.register(new CarrierProvider());
}
@Before
public void clearPlacements() {
PLACED.clear();
}
@Test
public void staleRecordsAreSkippedWhileIntactAndAirCarrierRecordsArePlaced() {
RecordMantleChunk chunk = new RecordMantleChunk(List.of(
new Placed(1, LAMP),
new Placed(2, LAMP),
new Placed(3, LAMP),
new Placed(4, CHAIR)));
NativeWorld level = level(Map.of(
1, Blocks.NOTE_BLOCK.defaultBlockState().setValue(NoteBlock.NOTE, 7),
2, Blocks.AIR.defaultBlockState(),
3, Blocks.COBBLESTONE.defaultBlockState(),
4, Blocks.COBBLESTONE.defaultBlockState()));
List<String> debug = new ArrayList<>();
Engine engine = engine(chunk);
try (MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
logging.when(() -> IrisLogging.debug(anyString(), any(Object[].class))).thenAnswer((InvocationOnMock invocation) -> {
Object[] arguments = invocation.getArguments();
debug.add(String.format((String) arguments[0], Arrays.copyOfRange(arguments, 1, arguments.length)));
return null;
});
new ModdedChunkUpdateService().updateRegeneratedChunk(engine, level, engine.getMantle().getMantle(), 0, 0);
}
assertEquals(List.of(new BlockPos(1, 6, 1), new BlockPos(4, 6, 4)), PLACED);
assertTrue(chunk.isFlagged(MantleFlag.ETCHED));
assertTrue(chunk.customDeleted());
assertEquals(1, debug.size());
assertTrue(debug.getFirst(), debug.getFirst().contains("2 stale"));
}
@Test
public void extinguishedFireCarriersArePlacedWhileCoveredCarriersAreSkipped() {
RecordMantleChunk chunk = new RecordMantleChunk(List.of(
new Placed(1, FIRE), new Placed(2, SOUL_FIRE),
new Placed(3, FIRE), new Placed(4, SOUL_FIRE)));
NativeWorld level = level(Map.of(
1, Blocks.AIR.defaultBlockState(),
2, Blocks.CAVE_AIR.defaultBlockState(),
3, Blocks.COBBLESTONE.defaultBlockState(),
4, Blocks.COBBLESTONE.defaultBlockState()));
Engine engine = engine(chunk);
new ModdedChunkUpdateService().updateRegeneratedChunk(engine, level, engine.getMantle().getMantle(), 0, 0);
assertEquals(List.of(new BlockPos(1, 6, 1), new BlockPos(2, 6, 2)), PLACED);
assertTrue(chunk.customDeleted());
}
private static NativeWorld level(Map<Integer, BlockState> live) {
ServerLevel serverLevel = mock(ServerLevel.class);
when(serverLevel.getBlockState(any(BlockPos.class))).thenReturn(Blocks.STONE.defaultBlockState());
NativeWorld level = mock(NativeWorld.class);
when(level.name()).thenReturn("example:carriers");
when(level.minHeight()).thenReturn(-64);
when(level.maxHeight()).thenReturn(320);
when(level.nativeHandle()).thenReturn(serverLevel);
for (Map.Entry<Integer, BlockState> entry : live.entrySet()) {
int offset = entry.getKey();
when(level.getBlock(offset, 6, offset)).thenReturn(ModdedBlockState.of(entry.getValue(), null));
}
return level;
}
private static Engine engine(RecordMantleChunk chunk) {
@SuppressWarnings("unchecked")
Mantle<Matter> mantle = mock(Mantle.class);
when(mantle.isChunkLoaded(0, 0)).thenReturn(true);
when(mantle.hasFlag(0, 0, MantleFlag.ETCHED)).thenAnswer((InvocationOnMock invocation) -> chunk.isFlagged(MantleFlag.ETCHED));
when(mantle.useChunk(0, 0)).thenAnswer((InvocationOnMock invocation) -> chunk.use());
IrisWorld world = mock(IrisWorld.class);
when(world.minHeight()).thenReturn(-64);
EngineMantle engineMantle = mock(EngineMantle.class);
when(engineMantle.getMantle()).thenReturn(mantle);
Engine engine = mock(Engine.class);
when(engine.getWorld()).thenReturn(world);
when(engine.getMetrics()).thenReturn(new EngineMetrics(8));
when(engine.getMantle()).thenReturn(engineMantle);
return engine;
}
@SuppressWarnings("unchecked")
private static MantleDataAdapter<Matter> emptyAdapter() {
return (MantleDataAdapter<Matter>) Proxy.newProxyInstance(
MantleDataAdapter.class.getClassLoader(),
new Class<?>[]{MantleDataAdapter.class},
(proxy, method, arguments) -> {
throw new UnsupportedOperationException(method.getName());
}
);
}
private record Placed(int offset, String key) {
}
private static final class RecordMantleChunk extends MantleChunk<Matter> {
private final List<Placed> records;
private boolean customDeleted;
private RecordMantleChunk(List<Placed> records) {
super(1, 0, 0, emptyAdapter(), MantleHooks.NONE);
this.records = records;
}
@Override
public <T> void iterate(Class<T> type, Consumer4<Integer, Integer, Integer, T> iterator) {
if (type != Identifier.class) {
return;
}
for (Placed record : records) {
iterator.accept(record.offset(), 70, record.offset(), type.cast(Identifier.fromString(record.key())));
}
}
@Override
public <T> T get(int x, int y, int z, Class<T> type) {
if (type == Identifier.class && y == 70 && x == z) {
for (Placed record : records) {
if (record.offset() == x) {
return type.cast(Identifier.fromString(record.key()));
}
}
}
return null;
}
@Override
public void deleteSlices(Class<?> type) {
if (type == Identifier.class) {
customDeleted = true;
}
}
private boolean customDeleted() {
return customDeleted;
}
}
private static final class CarrierProvider implements ModdedDataProvider {
@Override
public String modId() {
return MOD_ID;
}
@Override
public Collection<String> getTypes(ModdedDataType type) {
return type == ModdedDataType.BLOCK ? List.of(LAMP, CHAIR, FIRE, SOUL_FIRE) : List.of();
}
@Override
public boolean isValidProvider(String id, ModdedDataType type) {
return type == ModdedDataType.BLOCK && getTypes(type).contains(id);
}
@Override
public ModdedBlockData getBlockData(String blockId, Map<String, String> state) {
BlockState carrier = switch (blockId) {
case CHAIR -> Blocks.AIR.defaultBlockState();
case FIRE -> Blocks.FIRE.defaultBlockState();
case SOUL_FIRE -> Blocks.SOUL_FIRE.defaultBlockState();
default -> Blocks.NOTE_BLOCK.defaultBlockState();
};
return ModdedBlockData.deferred(ModdedBlockState.of(carrier, null));
}
@Override
public String processBlockPlacement(ModdedBlockPlacementContext context) {
PLACED.add(context.placement().position());
return null;
}
}
}
@@ -68,6 +68,29 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class ModdedChunkUpdateServiceTest {
@Test
@SuppressWarnings("unchecked")
public void regeneratedCleanupUsesSuppliedMantleAfterCurrentRuntimeChanges() {
Mantle<Matter> selected = mock(Mantle.class);
Mantle<Matter> replacement = mock(Mantle.class);
MantleDataAdapter<Matter> adapter = mock(MantleDataAdapter.class);
MantleChunk<Matter> chunk = new MantleChunk<>(1, 0, 0, adapter, MantleHooks.NONE);
when(selected.isChunkLoaded(0, 0)).thenReturn(true);
when(selected.useChunk(0, 0)).thenAnswer(invocation -> chunk.use());
when(selected.hasFlag(0, 0, MantleFlag.ETCHED)).thenReturn(true);
NativeWorld level = mock(NativeWorld.class);
when(level.isChunkLoaded(0, 0)).thenReturn(true);
Engine engine = engineWith(replacement);
when(engine.getWorld().platformWorld()).thenReturn(level);
new ModdedChunkUpdateService().updateRegeneratedChunk(engine, level, selected, 0, 0);
verify(selected).useChunk(0, 0);
verify(replacement, never()).isChunkLoaded(anyInt(), anyInt());
verify(replacement, never()).useChunk(anyInt(), anyInt());
assertFalse(chunk.inUse());
}
@Test
public void fallingFluidUpdateRetainsLevelEightStateAndRequestsPhysics() {
SharedConstants.tryDetectVersion();
@@ -154,7 +177,7 @@ public class ModdedChunkUpdateServiceTest {
return null;
});
service.updateRegeneratedChunk(engine, level, 0, 0);
service.updateRegeneratedChunk(engine, level, engine.getMantle().getMantle(), 0, 0);
assertFalse(chunk.isFlagged(MantleFlag.ETCHED));
assertFalse(chunk.isFlagged(MantleFlag.UPDATE));
@@ -162,7 +185,7 @@ public class ModdedChunkUpdateServiceTest {
logging.verify(() -> IrisLogging.reportError(any(Throwable.class)), never());
logging.verify(() -> IrisLogging.reportError(anyString(), any(Throwable.class)), never());
service.updateRegeneratedChunk(engine, level, 0, 0);
service.updateRegeneratedChunk(engine, level, engine.getMantle().getMantle(), 0, 0);
assertTrue(chunk.isFlagged(MantleFlag.ETCHED));
assertTrue(chunk.deleted());
@@ -187,7 +210,7 @@ public class ModdedChunkUpdateServiceTest {
});
assertThrows(SavedBiomeUnavailableException.class,
() -> new ModdedChunkUpdateService().updateRegeneratedChunk(engine, level, 0, 0));
() -> new ModdedChunkUpdateService().updateRegeneratedChunk(engine, level, engine.getMantle().getMantle(), 0, 0));
assertFalse(chunk.isFlagged(MantleFlag.ETCHED));
assertFalse(chunk.isFlagged(MantleFlag.UPDATE));
@@ -215,13 +238,13 @@ public class ModdedChunkUpdateServiceTest {
});
assertSame(failure, assertThrows(IllegalStateException.class,
() -> service.updateRegeneratedChunk(engine, level, 0, 0)));
() -> service.updateRegeneratedChunk(engine, level, engine.getMantle().getMantle(), 0, 0)));
assertFalse(chunk.isFlagged(MantleFlag.UPDATE));
assertFalse(chunk.isFlagged(MantleFlag.ETCHED));
assertFalse(chunk.deleted());
verify(chunk.slice, never()).set(2, 5, 2, null);
service.updateRegeneratedChunk(engine, level, 0, 0);
service.updateRegeneratedChunk(engine, level, engine.getMantle().getMantle(), 0, 0);
assertTrue(chunk.isFlagged(MantleFlag.ETCHED));
assertTrue(chunk.deleted());
@@ -136,6 +136,18 @@
"replacement": "when(level.getMaxBuildHeight()).thenReturn(320);"
}
],
"art/arcane/iris/modded/api/ModdedCustomContentRegistryTest.java": [
{
"pattern": "\\bResourceLocation\\b",
"replacement": "Identifier"
}
],
"art/arcane/iris/modded/service/ModdedChunkUpdateServiceCarrierTest.java": [
{
"pattern": "\\bResourceLocation\\b",
"replacement": "Identifier"
}
],
"art/arcane/volmlib/nativelib/minecraft26_2/modded/NativeSpawnPopulationTest.java": [
{
"pattern": "when\\(level\\.getMaxY\\(\\)\\)\\.thenReturn\\(320\\);",
+2
View File
@@ -33,6 +33,8 @@ plugins {
rootProject.name = 'iris-neoforge'
apply from: new File(settingsDir, '../../gradle/runtime-cache.settings.gradle').canonicalFile
dependencyResolutionManagement {
versionCatalogs {
libs {
+5 -2
View File
@@ -310,6 +310,7 @@ List<Object> moddedSharedInputs = []
'adapters/modded-common/src',
'adapters/client-common/src',
'core/src/main',
'core/src/testFixtures',
'core/build.gradle',
'core/agent/src',
'core/agent/build.gradle',
@@ -327,6 +328,7 @@ List<Object> moddedSharedInputs = []
'gradle/native-minecraft-sources.gradle',
'gradle/mod-target.gradle',
'gradle/mod-targets.json',
'gradle/runtime-cache.settings.gradle',
'gradle/modded-version-sources.gradle'
].each { String path -> moddedSharedInputs.add(layout.projectDirectory.file(path).asFile) }
if (localVolmLibCheckout != null) {
@@ -688,12 +690,13 @@ if (!(irisRuntimeJava in [21, 25])) {
throw new GradleException('irisRuntimeJava must be 21 or 25')
}
Set<String> sharedRuntimeProjects = [':core', ':core:agent', ':spi'] as Set<String>
String runtimeOutputVariant = gradle.extensions.extraProperties.get('irisRuntimeOutputVariant').toString()
allprojects {
apply plugin: 'java'
boolean java21Runtime = irisRuntimeJava == 21 && sharedRuntimeProjects.contains(project.path)
if (java21Runtime) {
layout.buildDirectory.set(layout.projectDirectory.dir('build/java21'))
if (!runtimeOutputVariant.isEmpty() && sharedRuntimeProjects.contains(project.path)) {
layout.buildDirectory.set(layout.projectDirectory.dir("build/${runtimeOutputVariant}"))
}
tasks.withType(Test).configureEach {
@@ -15,6 +15,7 @@ import net.momirealms.craftengine.bukkit.api.CraftEngineFurniture;
import net.momirealms.craftengine.bukkit.api.CraftEngineItems;
import net.momirealms.craftengine.bukkit.api.event.CraftEngineReloadEvent;
import net.momirealms.craftengine.bukkit.item.BukkitItemDefinition;
import net.momirealms.craftengine.bukkit.entity.furniture.BukkitFurniture;
import net.momirealms.craftengine.bukkit.plugin.BukkitCraftEngine;
import net.momirealms.craftengine.core.block.BlockDefinition;
import net.momirealms.craftengine.core.block.ImmutableBlockState;
@@ -24,10 +25,12 @@ import net.momirealms.craftengine.core.entity.furniture.FurnitureVariant;
import net.momirealms.craftengine.core.registry.Holder;
import net.momirealms.craftengine.core.util.Key;
import org.bukkit.Bukkit;
import org.bukkit.Chunk;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import org.bukkit.entity.Entity;
import org.bukkit.inventory.ItemStack;
import org.bukkit.plugin.Plugin;
import org.junit.Test;
@@ -37,6 +40,7 @@ import java.util.List;
import java.util.Map;
import java.util.MissingResourceException;
import java.util.Set;
import java.util.UUID;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
@@ -48,12 +52,59 @@ import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class CraftEngineDataProviderTest {
@Test
public void furniturePlacementReturnsExactEntityTokenAndCleanupKeepsReplacement() {
FurnitureDefinition definition = furnitureDefinition();
Engine engine = mock(Engine.class);
SeedManager seeds = mock(SeedManager.class);
when(engine.getSeedManager()).thenReturn(seeds);
Block block = mock(Block.class);
when(block.getLocation()).thenAnswer(invocation -> new Location(null, 0, 70, 0));
Chunk chunk = mock(Chunk.class);
when(block.getChunk()).thenReturn(chunk);
when(chunk.isEntitiesLoaded()).thenReturn(true);
Entity original = mock(Entity.class);
Entity replacement = mock(Entity.class);
UUID originalId = UUID.randomUUID();
when(original.getUniqueId()).thenReturn(originalId);
when(replacement.getUniqueId()).thenReturn(UUID.randomUUID());
when(chunk.getEntities()).thenReturn(new Entity[]{replacement, original});
BukkitFurniture placed = mock(BukkitFurniture.class);
when(placed.baseEntity()).thenReturn(original);
CraftEngineDataProvider provider = new CraftEngineDataProvider();
try (MockedStatic<CraftEngineBlocks> blocks = mockStatic(CraftEngineBlocks.class);
MockedStatic<CraftEngineFurniture> furniture = mockStatic(CraftEngineFurniture.class)) {
furniture.when(() -> CraftEngineFurniture.byId(Key.of("test:chair"))).thenReturn(definition);
furniture.when(() -> CraftEngineFurniture.place(any(Location.class), eq(definition), eq("alpha"), eq(false)))
.thenReturn(placed);
furniture.when(() -> CraftEngineFurniture.remove(original, false, false)).thenReturn(true);
String token = provider.processUpdate(engine, block, Identifier.fromString("test:chair"));
assertEquals(originalId.toString(), token);
assertTrue(provider.removeGenerated(block, token));
furniture.verify(() -> CraftEngineFurniture.remove(original, false, false));
furniture.verify(() -> CraftEngineFurniture.remove(replacement, false, false), never());
}
}
@Test
public void furnitureCleanupWaitsForEntitiesToLoad() {
Block block = mock(Block.class);
Chunk chunk = mock(Chunk.class);
when(block.getChunk()).thenReturn(chunk);
assertFalse(new CraftEngineDataProvider().removeGenerated(block, UUID.randomUUID().toString()));
verify(chunk, never()).getEntities();
}
@Test
public void startupWaitsForReloadEventAndEmptyLoadedRegistriesAreReady() {
CraftEngineDataProvider provider = spy(new CraftEngineDataProvider());
@@ -0,0 +1,198 @@
package art.arcane.iris.platform.generation;
import art.arcane.iris.generation.block.IrisCustomData;
import art.arcane.iris.generation.mantle.CustomBlockOwnership;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.SeedManager;
import art.arcane.iris.integration.ExternalDataProvider;
import art.arcane.iris.integration.ExternalDataSVC;
import art.arcane.iris.integration.Identifier;
import art.arcane.iris.integration.data.CraftEngineDataProvider;
import art.arcane.iris.platform.bukkit.BukkitBlockResolution;
import art.arcane.iris.platform.registry.RegistryUtil;
import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.testsupport.BukkitTestServer;
import art.arcane.iris.world.IrisWorld;
import art.arcane.volmlib.util.function.Consumer4;
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
import art.arcane.volmlib.util.matter.Matter;
import art.arcane.volmlib.util.matter.MatterSlice;
import net.momirealms.craftengine.bukkit.api.CraftEngineBlocks;
import net.momirealms.craftengine.bukkit.api.CraftEngineFurniture;
import net.momirealms.craftengine.bukkit.api.CraftEngineItems;
import net.momirealms.craftengine.bukkit.api.event.CraftEngineReloadEvent;
import net.momirealms.craftengine.bukkit.entity.furniture.BukkitFurniture;
import net.momirealms.craftengine.bukkit.plugin.BukkitCraftEngine;
import net.momirealms.craftengine.core.block.BlockDefinition;
import net.momirealms.craftengine.core.block.ImmutableBlockState;
import net.momirealms.craftengine.core.entity.furniture.FurnitureDefinition;
import net.momirealms.craftengine.core.entity.furniture.FurnitureVariant;
import net.momirealms.craftengine.core.registry.Holder;
import net.momirealms.craftengine.core.util.Key;
import org.bukkit.Chunk;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import org.bukkit.entity.Entity;
import org.junit.Test;
import org.mockito.MockedStatic;
import org.mockito.invocation.InvocationOnMock;
import java.lang.reflect.Field;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.junit.Assert.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
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 CraftEngineStaleRecordTest {
@Test
public void stalePlacementRecordsAreSkippedAndIntactOnesPlaced() throws Exception {
BukkitTestServer.install();
BlockDefinition lampDefinition = mock(BlockDefinition.class);
ImmutableBlockState lamp = mock(ImmutableBlockState.class);
when(lamp.owner()).thenReturn(Holder.direct(lampDefinition));
when(lamp.toString()).thenReturn("test:lamp");
when(lampDefinition.defaultState()).thenReturn(lamp);
FurnitureDefinition chairDefinition = mock(FurnitureDefinition.class);
when(chairDefinition.id()).thenReturn(Key.of("test:chair"));
when(chairDefinition.variants()).thenReturn(Map.of("alpha", mock(FurnitureVariant.class)));
BlockData noteBlock = blockData(Material.NOTE_BLOCK);
BlockData air = blockData(Material.AIR);
CraftEngineDataProvider provider = new CraftEngineDataProvider();
ExternalDataSVC service = new ExternalDataSVC();
activate(service, provider);
Engine engine = mock(Engine.class);
when(engine.getWorld()).thenReturn(mock(IrisWorld.class));
SeedManager seeds = mock(SeedManager.class);
when(engine.getSeedManager()).thenReturn(seeds);
World world = mock(World.class);
when(world.getMinHeight()).thenReturn(0);
when(world.getMaxHeight()).thenReturn(256);
Chunk chunk = mock(Chunk.class);
when(chunk.getWorld()).thenReturn(world);
block(chunk, 1, Material.NOTE_BLOCK);
block(chunk, 2, Material.AIR);
block(chunk, 3, Material.COBBLESTONE);
block(chunk, 4, Material.COBBLESTONE);
try (MockedStatic<BukkitCraftEngine> craftEngine = mockStatic(BukkitCraftEngine.class);
MockedStatic<CraftEngineBlocks> blocks = mockStatic(CraftEngineBlocks.class);
MockedStatic<CraftEngineFurniture> furniture = mockStatic(CraftEngineFurniture.class);
MockedStatic<CraftEngineItems> items = mockStatic(CraftEngineItems.class);
MockedStatic<RegistryUtil> registry = materialRegistry();
MockedStatic<BukkitBlockResolution> resolution = mockStatic(BukkitBlockResolution.class);
MockedStatic<IrisServices> services = mockStatic(IrisServices.class)) {
resolution.when(BukkitBlockResolution::getAir).thenReturn(air);
services.when(() -> IrisServices.get(ExternalDataSVC.class)).thenReturn(service);
blocks.when(CraftEngineBlocks::loadedBlocks).thenReturn(Map.of(Key.of("test:lamp"), lampDefinition));
furniture.when(CraftEngineFurniture::loadedFurniture).thenReturn(Map.of(Key.of("test:chair"), chairDefinition));
items.when(CraftEngineItems::loadedItems).thenReturn(Map.of());
blocks.when(() -> CraftEngineBlocks.byId(Key.of("test:lamp"))).thenReturn(lampDefinition);
blocks.when(() -> CraftEngineBlocks.getBukkitBlockData(lamp)).thenReturn(noteBlock);
blocks.when(() -> CraftEngineBlocks.place(any(Location.class), eq(lamp), eq(false))).thenReturn(true);
furniture.when(() -> CraftEngineFurniture.byId(Key.of("test:chair"))).thenReturn(chairDefinition);
BukkitFurniture placed = mock(BukkitFurniture.class);
Entity entity = mock(Entity.class);
when(placed.baseEntity()).thenReturn(entity);
when(entity.getUniqueId()).thenReturn(UUID.randomUUID());
furniture.when(() -> CraftEngineFurniture.place(any(Location.class), eq(chairDefinition), eq("alpha"), eq(false)))
.thenReturn(placed);
provider.onReload(mock(CraftEngineReloadEvent.class));
Identifier lampRecord = generatedRecord(service, "test:lamp");
Identifier chairRecord = generatedRecord(service, "test:chair");
assertEquals("craftengine:test/lamp", lampRecord.toString());
assertEquals("craftengine:test/chair[variant=alpha]", chairRecord.toString());
EngineBukkitOps.materializeCustomBlocks(engine, chunk, records(
new Placed(1, lampRecord),
new Placed(2, lampRecord),
new Placed(3, lampRecord),
new Placed(4, chairRecord)));
blocks.verify(() -> CraftEngineBlocks.place(any(Location.class), eq(lamp), eq(false)), times(1));
blocks.verify(() -> CraftEngineBlocks.place(location(1), lamp, false));
blocks.verify(() -> CraftEngineBlocks.place(location(2), lamp, false), never());
blocks.verify(() -> CraftEngineBlocks.place(location(3), lamp, false), never());
furniture.verify(() -> CraftEngineFurniture.place(location(4), chairDefinition, "alpha", false));
}
}
private static MockedStatic<RegistryUtil> materialRegistry() {
MockedStatic<RegistryUtil> registry = mockStatic(RegistryUtil.class);
registry.when(() -> RegistryUtil.find(Material.class, "grass", "short_grass")).thenReturn(Material.SHORT_GRASS);
return registry;
}
private static Identifier generatedRecord(ExternalDataSVC service, String key) {
BlockData resolved = service.getBlockData(Identifier.fromString(key)).orElseThrow();
return ((IrisCustomData) resolved).getCustom();
}
private static BlockData blockData(Material material) {
BlockData data = mock(BlockData.class);
when(data.getMaterial()).thenReturn(material);
return data;
}
private static Block block(Chunk chunk, int offset, Material type) {
Block block = mock(Block.class);
when(block.getType()).thenReturn(type);
when(block.getX()).thenReturn(offset);
when(block.getY()).thenReturn(10);
when(block.getZ()).thenReturn(offset);
when(block.getLocation()).thenAnswer((InvocationOnMock invocation) -> location(offset));
when(chunk.getBlock(offset, 10, offset)).thenReturn(block);
return block;
}
private static Location location(int offset) {
return new Location(null, offset, 10, offset);
}
@SuppressWarnings("unchecked")
private static MantleChunk<Matter> records(Placed... records) {
MantleChunk<Matter> chunk = mock(MantleChunk.class);
Matter section = mock(Matter.class);
MatterSlice<CustomBlockOwnership> ownership = mock(MatterSlice.class);
MatterSlice<Identifier> deferred = mock(MatterSlice.class);
when(chunk.getOrCreate(anyInt())).thenReturn(section);
when(section.<CustomBlockOwnership>slice(CustomBlockOwnership.class)).thenReturn(ownership);
when(section.getSlice(Identifier.class)).thenReturn(deferred);
for (Placed record : records) {
when(chunk.get(record.offset(), 10, record.offset(), Identifier.class)).thenReturn(record.identifier());
when(deferred.get(record.offset(), 10, record.offset())).thenReturn(record.identifier());
}
doAnswer((InvocationOnMock invocation) -> {
Consumer4<Integer, Integer, Integer, Identifier> iterator = invocation.getArgument(1);
for (Placed record : records) {
iterator.accept(record.offset(), 10, record.offset(), record.identifier());
}
return null;
}).when(chunk).iterate(eq(Identifier.class), any());
return chunk;
}
@SuppressWarnings("unchecked")
private static void activate(ExternalDataSVC service, ExternalDataProvider provider) throws Exception {
Field field = ExternalDataSVC.class.getDeclaredField("activeProviders");
field.setAccessible(true);
((List<ExternalDataProvider>) field.get(service)).add(provider);
}
private record Placed(int offset, Identifier identifier) {
}
}
@@ -242,11 +242,11 @@ public class IrisProceduralTree implements IrisProceduralPlacement {
private IrisTreeRootStyle rootStyle = IrisTreeRootStyle.BUTTRESS;
@MinNumber(0)
@Description("Override the root depth/reach in blocks. 0 means auto (scales with tree height).")
@Description("Root depth in blocks, also used for ground-contact extension up to 16 blocks. 0 scales with tree height.")
private int rootDepth = 0;
@MinNumber(0)
@Description("Override the root flare radius in blocks. 0 means auto.")
@Description("Horizontal reach of buttress and stilt roots. 0 uses a compact radius based on trunk width. Taproots ignore this field.")
private double rootFlare = 0;
@MinNumber(1)
@@ -40,6 +40,12 @@ public final class ProceduralTreeGenerator {
long baseSeed = rng.getSeed();
List<TreeTrunkBuilder.Limb> limbs = TreeTrunkBuilder.build(canvas, tree, height);
Set<TreeBlockCanvas.Vec> supportPositions = new HashSet<>();
for (TreeBlockCanvas.Vec position : canvas.getTrunk()) {
if (position.y() == 0) {
supportPositions.add(position);
}
}
boolean wantEndpoints = tree.getDecorators() != null && !tree.getDecorators().isEmpty();
List<int[]> branchEndpoints = wantEndpoints ? new ArrayList<>() : null;
@@ -55,7 +61,13 @@ public final class ProceduralTreeGenerator {
}
if (tree.isRoots()) {
Set<TreeBlockCanvas.Vec> existingWood = new HashSet<>(canvas.getTrunk());
TreeRootBuilder.build(canvas, tree, height, baseSeed);
for (TreeBlockCanvas.Vec position : canvas.getTrunk()) {
if (position.y() <= 0 && !existingWood.contains(position)) {
supportPositions.add(position);
}
}
}
if (tree.isPlausible()) {
@@ -82,10 +94,14 @@ public final class ProceduralTreeGenerator {
TreePlausibility.apply(resolved, trunkPositions, leafPositions, tree);
return assemble(resolved);
int rootReach = tree.isRoots() ? tree.getRootDepth() > 0 ? tree.getRootDepth()
: Math.max(2, (int) Math.round(height * 0.18)) : 0;
return assemble(resolved, supportPositions, leafPositions, rootReach);
}
private static IrisObject assemble(Map<TreeBlockCanvas.Vec, NativeBlockState> resolved) {
private static IrisObject assemble(Map<TreeBlockCanvas.Vec, NativeBlockState> resolved,
Set<TreeBlockCanvas.Vec> supportPositions,
Set<TreeBlockCanvas.Vec> leafPositions, int rootReach) {
if (resolved.isEmpty()) {
return null;
}
@@ -105,21 +121,26 @@ public final class ProceduralTreeGenerator {
maxZ = Math.max(maxZ, v.z());
}
int w = maxX - minX + 1;
int w = 2 * Math.max(Math.abs(minX), Math.abs(maxX)) + 1;
int h = maxY - minY + 1;
int d = maxZ - minZ + 1;
int cx = w / 2;
int d = 2 * Math.max(Math.abs(minZ), Math.abs(maxZ)) + 1;
int cy = h / 2;
int cz = d / 2;
IrisObject object = new IrisObject(w, h, d);
List<TreeGrounding.Root> roots = new ArrayList<>(supportPositions.size());
Set<TreeBlockCanvas.Vec> canopy = new HashSet<>(leafPositions.size());
for (Map.Entry<TreeBlockCanvas.Vec, NativeBlockState> entry : resolved.entrySet()) {
TreeBlockCanvas.Vec v = entry.getKey();
int nx = v.x() - minX - cx;
int ny = v.y() - cy + 1;
int nz = v.z() - minZ - cz;
object.getBlocks().put(new IrisBlockVector(nx, ny, nz), entry.getValue());
object.getBlocks().put(new IrisBlockVector(v.x(), ny, v.z()), entry.getValue());
if (supportPositions.contains(v)) {
roots.add(new TreeGrounding.Root(v.x(), ny, v.z(), entry.getValue()));
}
if (leafPositions.contains(v)) {
canopy.add(new TreeBlockCanvas.Vec(v.x(), ny, v.z()));
}
}
object.setTreeGrounding(TreeGrounding.create(roots, rootReach, canopy));
return object;
}
@@ -335,7 +335,9 @@ public final class TreeCanopyBuilder {
int x = (int) Math.round(ox + dx * t);
int y = (int) Math.round(oy + dy * t - sagBlocks * t * t);
int z = (int) Math.round(oz + dz * t);
if (!canvas.has(x, y, z)) {
TreeBlockCanvas.Cell existing = canvas.get(x, y, z);
if (existing == null || existing.role() == TreeBlockCanvas.Role.LEAF
|| existing.role() == TreeBlockCanvas.Role.SECONDARY_LEAF) {
canvas.setTrunk(x, y, z, TreeBlockCanvas.Role.TRUNK, axis);
}
tip = new int[]{x, y, z};
@@ -0,0 +1,122 @@
package art.arcane.iris.generation.decoration.tree;
import art.arcane.iris.generation.geometry.IrisBlockVector;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.structure.object.IObjectPlacer;
import art.arcane.iris.structure.object.IrisObjectPlacement;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.volmlib.nativelib.terrain.structure.NativeStructureVolume;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.math.BlockPosition;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
public record TreeGrounding(List<Root> roots, int maxExtension, Set<TreeBlockCanvas.Vec> canopy) {
public TreeGrounding {
roots = List.copyOf(roots);
maxExtension = Math.max(0, Math.min(16, maxExtension));
canopy = Set.copyOf(canopy);
}
public static TreeGrounding create(List<Root> roots, int maxExtension, Set<TreeBlockCanvas.Vec> canopy) {
Map<Long, Root> columns = new HashMap<>();
for (Root root : roots) {
long key = ((long) root.x() << 32) | (root.z() & 0xffffffffL);
Root previous = columns.get(key);
if (previous == null || root.y() < previous.y()) {
columns.put(key, root);
}
}
List<Root> lowest = new ArrayList<>(columns.values());
lowest.sort(Comparator.comparingInt(Root::x).thenComparingInt(Root::z));
return new TreeGrounding(lowest, maxExtension, canopy);
}
public boolean isCanopy(int x, int y, int z) {
return canopy.contains(new TreeBlockCanvas.Vec(x, y, z));
}
public KList<IrisBlockVector> supportOffsets() {
KList<IrisBlockVector> offsets = new KList<>();
for (Root root : roots) {
offsets.add(new IrisBlockVector(root.x(), root.y(), root.z()));
}
return offsets;
}
public Plan plan(IObjectPlacer placer, Placement placement) {
List<Extension> extensions = new ArrayList<>();
IrisObjectPlacement config = placement.config();
BlockPosition origin = placement.origin();
BlockPosition spin = placement.spin();
int solidDepth = Math.max(1, config.getSurfaceSupportDepth());
for (Root root : roots) {
IrisBlockVector offset = config.getRotation().rotate(
new IrisBlockVector(root.x(), root.y(), root.z()), spin.getX(), spin.getY(), spin.getZ());
offset = config.getTranslate().translate(offset, config.getRotation(), spin.getX(), spin.getY(), spin.getZ());
int x = origin.getX() + (int) Math.round(offset.getX());
int y = origin.getY() + (int) Math.round(offset.getY());
int z = origin.getZ() + (int) Math.round(offset.getZ());
int surface = placer.getHighest(x, z, placement.data(), true);
int contact = Math.min(surface, y - 1);
if (y - contact - 1 > maxExtension) {
return Plan.UNSUPPORTED;
}
for (int below = 0; below < solidDepth; below++) {
if (placer.isCarved(x, contact - below, z) || !placer.isSurfaceSolid(x, contact - below, z)) {
return Plan.UNSUPPORTED;
}
}
if (contact < y - 1) {
NativeBlockState state = config.getRotation().rotate(root.state(), spin.getX(), spin.getY(), spin.getZ());
if (state == null) {
return Plan.UNSUPPORTED;
}
Extension extension = new Extension(x, y - 1, contact, z, state);
if (intersectsStructure(placer, extension)) {
return Plan.UNSUPPORTED;
}
extensions.add(extension);
}
}
return new Plan(!roots.isEmpty(), List.copyOf(extensions));
}
private static boolean intersectsStructure(IObjectPlacer placer, Extension extension) {
Engine engine = placer.getEngine();
if (engine == null) {
return false;
}
KList<NativeStructureVolume> volumes = engine.getNativeStructureVolumes(
extension.x(), extension.z(), extension.x(), extension.z());
if (volumes == null) {
return false;
}
for (NativeStructureVolume volume : volumes) {
if (volume.intersects(extension.x(), extension.bottomY() + engine.getMinHeight(), extension.z(),
extension.x(), extension.topY() + engine.getMinHeight(), extension.z())) {
return true;
}
}
return false;
}
public record Root(int x, int y, int z, NativeBlockState state) {
}
public record Placement(IrisData data, IrisObjectPlacement config, BlockPosition origin, BlockPosition spin) {
}
public record Extension(int x, int topY, int bottomY, int z, NativeBlockState state) {
}
public record Plan(boolean supported, List<Extension> extensions) {
private static final Plan UNSUPPORTED = new Plan(false, List.of());
}
}
@@ -28,120 +28,152 @@ public final class TreeRootBuilder {
}
public static void build(TreeBlockCanvas canvas, IrisProceduralTree tree, int height, long baseSeed) {
List<int[]> cells = new ArrayList<>();
List<TreeBlockCanvas.Vec> cells = new ArrayList<>();
for (TreeBlockCanvas.Vec v : canvas.getTrunk()) {
if (v.y() == 0) {
cells.add(new int[]{v.x(), v.z()});
cells.add(v);
}
}
if (cells.isEmpty()) {
return;
}
cells.sort(TreeBlockCanvas.Vec::compareTo);
int depth = tree.getRootDepth() > 0 ? tree.getRootDepth() : autoDepth(height);
RNG rng = new RNG(baseSeed ^ 0x5009L);
double cx = 0;
double cz = 0;
for (int[] c : cells) {
cx += c[0];
cz += c[1];
for (TreeBlockCanvas.Vec cell : cells) {
cx += cell.x();
cz += cell.z();
}
cx /= cells.size();
cz /= cells.size();
double baseRadius = 1.0;
for (int[] c : cells) {
baseRadius = Math.max(baseRadius, Math.hypot(c[0] - cx, c[1] - cz) + 0.5);
for (TreeBlockCanvas.Vec cell : cells) {
baseRadius = Math.max(baseRadius, Math.hypot(cell.x() - cx, cell.z() - cz) + 0.5);
}
double flare = tree.getRootFlare() > 0 ? tree.getRootFlare() : baseRadius + Math.max(2.0, depth * 0.6);
double flare = tree.getRootFlare() > 0 ? tree.getRootFlare() : baseRadius + Math.max(0.75, baseRadius * 0.35);
RootShape shape = new RootShape(cells, cx, cz, baseRadius, depth, flare);
IrisTreeRootStyle style = tree.getRootStyle();
switch (style) {
case TAPROOT -> taproot(canvas, cells, cx, cz, baseRadius, depth);
case TAPROOT -> taproot(canvas, shape);
case BUTTRESS -> {
taproot(canvas, cells, cx, cz, baseRadius, depth);
buttress(canvas, cells, cx, cz, baseRadius, depth, flare, rng);
taproot(canvas, shape);
buttress(canvas, shape, rng);
}
case STILT -> {
taproot(canvas, cells, cx, cz, baseRadius, Math.min(depth, 3));
stilts(canvas, cx, cz, baseRadius, flare, height, cells.size(), rng);
stilts(canvas, shape, height, rng);
taproot(canvas, new RootShape(cells, cx, cz, baseRadius, Math.min(depth, 3), flare));
}
}
}
private static void taproot(TreeBlockCanvas canvas, List<int[]> cells, double cx, double cz, double baseRadius, int depth) {
int icx = (int) Math.round(cx);
int icz = (int) Math.round(cz);
for (int k = 1; k <= depth; k++) {
double frac = k / (double) depth;
double keepR = baseRadius * (1.0 - 0.6 * frac);
for (int[] c : cells) {
if (Math.hypot(c[0] - cx, c[1] - cz) <= keepR + 1e-6) {
placeRoot(canvas, c[0], -k, c[1], TreeBlockCanvas.Axis.Y);
private static void taproot(TreeBlockCanvas canvas, RootShape shape) {
TreeBlockCanvas.Vec center = nearestCell(shape.cells(), shape.centerX(), shape.centerZ());
for (int k = 1; k <= shape.depth(); k++) {
double fraction = k / (double) shape.depth();
double keepRadius = shape.radius() * (1.0 - fraction);
for (TreeBlockCanvas.Vec cell : shape.cells()) {
if (Math.hypot(cell.x() - shape.centerX(), cell.z() - shape.centerZ()) <= keepRadius) {
placeRoot(canvas, cell.x(), -k, cell.z(), TreeBlockCanvas.Axis.Y);
}
}
placeRoot(canvas, icx, -k, icz, TreeBlockCanvas.Axis.Y);
placeRoot(canvas, center.x(), -k, center.z(), TreeBlockCanvas.Axis.Y);
}
}
private static void buttress(TreeBlockCanvas canvas, List<int[]> cells, double cx, double cz, double baseRadius,
int depth, double flare, RNG rng) {
int nLegs = Math.max(4, Math.min(12, cells.size() + 2));
for (int li = 0; li < nLegs; li++) {
double ang = 2.0 * Math.PI * li / nLegs + rng.d(-0.25, 0.25);
double ca = Math.cos(ang);
double sa = Math.sin(ang);
int prevX = (int) Math.round(cx + baseRadius * ca);
int prevZ = (int) Math.round(cz + baseRadius * sa);
for (int k = 1; k <= depth; k++) {
double frac = k / (double) depth;
double r = baseRadius + (flare - baseRadius) * frac;
int x = (int) Math.round(cx + r * ca);
int z = (int) Math.round(cz + r * sa);
placeRoot(canvas, x, -k, z, TreeBlockCanvas.Axis.Y);
if (x != prevX || z != prevZ) {
placeRoot(canvas, prevX, -k, prevZ, TreeBlockCanvas.Axis.Y);
}
prevX = x;
prevZ = z;
private static void buttress(TreeBlockCanvas canvas, RootShape shape, RNG rng) {
int legs = Math.max(3, Math.min(8, shape.cells().size() + 2));
for (int leg = 0; leg < legs; leg++) {
double angle = 2.0 * Math.PI * leg / legs + rng.d(-0.25, 0.25);
double reach = shape.flare() * rng.d(0.8, 1.0);
int x = (int) Math.round(shape.centerX() + reach * Math.cos(angle));
int z = (int) Math.round(shape.centerZ() + reach * Math.sin(angle));
int depth = legDepth(shape.depth(), leg, legs);
TreeBlockCanvas.Vec start = nearestCell(shape.cells(), x, z);
rasterizeRoot(canvas, start, new TreeBlockCanvas.Vec(x, -depth, z));
}
}
private static void stilts(TreeBlockCanvas canvas, RootShape shape, int height, RNG rng) {
int stiltTop = Math.min(height - 1, Math.max(2, Math.min(7, (int) Math.round(0.25 * height))));
List<TreeBlockCanvas.Vec> attachments = new ArrayList<>();
for (TreeBlockCanvas.Vec cell : canvas.getTrunk()) {
if (cell.y() == stiltTop) {
attachments.add(cell);
}
}
}
private static void stilts(TreeBlockCanvas canvas, double cx, double cz, double baseRadius, double flare,
int height, int cellCount, RNG rng) {
int stiltTop = Math.max(2, Math.min(7, (int) Math.round(0.25 * height)));
int nProps = Math.max(4, Math.min(10, cellCount + 2));
for (int p = 0; p < nProps; p++) {
double ang = 2.0 * Math.PI * p / nProps + rng.d(-0.25, 0.25);
double ca = Math.cos(ang);
double sa = Math.sin(ang);
int sx = (int) Math.round(cx + baseRadius * ca);
int sz = (int) Math.round(cz + baseRadius * sa);
int fx = (int) Math.round(cx + flare * ca);
int fz = (int) Math.round(cz + flare * sa);
rasterizeRoot(canvas, sx, stiltTop, sz, fx, -2, fz);
if (attachments.isEmpty()) {
attachments.addAll(shape.cells());
}
attachments.sort(TreeBlockCanvas.Vec::compareTo);
int props = Math.max(3, Math.min(8, shape.cells().size() + 2));
for (int prop = 0; prop < props; prop++) {
double angle = 2.0 * Math.PI * prop / props + rng.d(-0.25, 0.25);
double reach = shape.flare() * rng.d(0.8, 1.0);
int x = (int) Math.round(shape.centerX() + reach * Math.cos(angle));
int z = (int) Math.round(shape.centerZ() + reach * Math.sin(angle));
int depth = legDepth(Math.min(shape.depth(), 3), prop, props);
TreeBlockCanvas.Vec start = nearestCell(attachments, x, z);
rasterizeRoot(canvas, start, new TreeBlockCanvas.Vec(x, -depth, z));
}
}
private static void rasterizeRoot(TreeBlockCanvas canvas, int ox, int oy, int oz, int ex, int ey, int ez) {
int dx = ex - ox;
int dy = ey - oy;
int dz = ez - oz;
private static int legDepth(int depth, int leg, int count) {
return Math.max(1, (int) Math.round(depth * (0.55 + 0.45 * leg / Math.max(1, count - 1))));
}
private static TreeBlockCanvas.Vec nearestCell(List<TreeBlockCanvas.Vec> cells, double x, double z) {
TreeBlockCanvas.Vec nearest = cells.getFirst();
double distance = Double.POSITIVE_INFINITY;
for (TreeBlockCanvas.Vec cell : cells) {
double dx = cell.x() - x;
double dz = cell.z() - z;
double candidateDistance = dx * dx + dz * dz;
if (candidateDistance < distance) {
nearest = cell;
distance = candidateDistance;
}
}
return nearest;
}
private static void rasterizeRoot(TreeBlockCanvas canvas, TreeBlockCanvas.Vec start, TreeBlockCanvas.Vec end) {
int dx = end.x() - start.x();
int dy = end.y() - start.y();
int dz = end.z() - start.z();
int steps = Math.max(Math.max(Math.abs(dx), Math.abs(dy)), Math.max(Math.abs(dz), 1));
TreeBlockCanvas.Axis axis = TreeFunctions.logAxis(dx, dy, dz);
for (int i = 0; i <= steps; i++) {
int x = start.x();
int y = start.y();
int z = start.z();
for (int i = 1; i <= steps; i++) {
double t = i / (double) steps;
int x = (int) Math.round(ox + dx * t);
int y = (int) Math.round(oy + dy * t);
int z = (int) Math.round(oz + dz * t);
placeRoot(canvas, x, y, z, axis);
int nextX = (int) Math.round(start.x() + dx * t);
int nextY = (int) Math.round(start.y() + dy * t);
int nextZ = (int) Math.round(start.z() + dz * t);
while (y != nextY) {
y += Integer.signum(nextY - y);
placeRoot(canvas, x, y, z, axis);
}
while (x != nextX) {
x += Integer.signum(nextX - x);
placeRoot(canvas, x, y, z, axis);
}
while (z != nextZ) {
z += Integer.signum(nextZ - z);
placeRoot(canvas, x, y, z, axis);
}
}
}
private static void placeRoot(TreeBlockCanvas canvas, int x, int y, int z, TreeBlockCanvas.Axis axis) {
if (!canvas.has(x, y, z)) {
TreeBlockCanvas.Cell cell = canvas.get(x, y, z);
if (cell == null || cell.role() != TreeBlockCanvas.Role.TRUNK && cell.role() != TreeBlockCanvas.Role.SECONDARY_TRUNK) {
canvas.setTrunk(x, y, z, TreeBlockCanvas.Role.TRUNK, axis);
}
}
@@ -149,4 +181,8 @@ public final class TreeRootBuilder {
private static int autoDepth(int height) {
return Math.max(2, Math.min(16, (int) Math.round(0.18 * height)));
}
private record RootShape(List<TreeBlockCanvas.Vec> cells, double centerX, double centerZ,
double radius, int depth, double flare) {
}
}
@@ -28,6 +28,7 @@ import art.arcane.iris.structure.object.IObjectPlacer;
import art.arcane.iris.generation.block.TileData;
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCell;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.volmlib.util.math.BlockPosition;
import art.arcane.iris.generation.block.B;
import art.arcane.volmlib.util.collection.KList;
import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap;
@@ -49,6 +50,7 @@ final class CaveObjectPlacementTransaction implements IObjectPlacer {
private final Long2IntOpenHashMap surfaceHeights;
private final Long2IntOpenHashMap caveCeilings;
private int blockWrites;
private BlockPosition canopyPosition;
CaveObjectPlacementTransaction(IObjectPlacer delegate, int anchorY, int minDepthBelowSurface) {
this.delegate = delegate;
@@ -95,13 +97,36 @@ final class CaveObjectPlacementTransaction implements IObjectPlacer {
}
}
for (BufferedMutation mutation : mutations) {
mutation.apply(delegate);
}
applyMutations();
discard();
return CommitResult.COMMITTED;
}
private void applyMutations() {
boolean canopyOpen = false;
boolean skipCanopy = false;
try {
for (BufferedMutation mutation : mutations) {
if (mutation.value() instanceof NativeBlockState) {
if (canopyOpen) {
delegate.endTreeCanopy();
canopyOpen = false;
}
skipCanopy = mutation.canopy()
&& !delegate.beginTreeCanopy(new BlockPosition(mutation.x(), mutation.y(), mutation.z()));
canopyOpen = mutation.canopy() && !skipCanopy;
}
if (!mutation.canopy() || !skipCanopy) {
mutation.apply(delegate);
}
}
} finally {
if (canopyOpen) {
delegate.endTreeCanopy();
}
}
}
void discard() {
mutations.clear();
bufferedBlocks.clear();
@@ -139,11 +164,30 @@ final class CaveObjectPlacementTransaction implements IObjectPlacer {
if (state == null) {
return;
}
mutations.add(new BlockMutation(x, y, z, state));
mutations.add(new BlockMutation(x, y, z, state, isCanopy(x, y, z)));
bufferedBlocks.put(new PositionKey(x, y, z), state);
blockWrites++;
}
@Override
public boolean beginTreeCanopy(BlockPosition position) {
if (!IObjectPlacer.super.beginTreeCanopy(position)) {
return false;
}
canopyPosition = position;
return true;
}
@Override
public void endTreeCanopy() {
canopyPosition = null;
}
private boolean isCanopy(int x, int y, int z) {
return canopyPosition != null && canopyPosition.getX() == x
&& canopyPosition.getY() == y && canopyPosition.getZ() == z;
}
@Override
public NativeBlockState get(int x, int y, int z) {
NativeBlockState buffered = bufferedBlocks.get(new PositionKey(x, y, z));
@@ -198,7 +242,7 @@ final class CaveObjectPlacementTransaction implements IObjectPlacer {
@Override
public void setTile(int x, int y, int z, TileData tile) {
if (tile != null) {
mutations.add(new TileMutation(x, y, z, tile));
mutations.add(new TileMutation(x, y, z, tile, isCanopy(x, y, z)));
}
}
@@ -207,7 +251,7 @@ final class CaveObjectPlacementTransaction implements IObjectPlacer {
if (data == null) {
return;
}
mutations.add(new DataMutation(x, y, z, data));
mutations.add(new DataMutation(x, y, z, data, isCanopy(x, y, z)));
if (data instanceof NativeBlockState state) {
bufferedBlocks.put(new PositionKey(x, y, z), state);
blockWrites++;
@@ -291,24 +335,26 @@ final class CaveObjectPlacementTransaction implements IObjectPlacer {
Object value();
boolean canopy();
void apply(IObjectPlacer placer);
}
private record BlockMutation(int x, int y, int z, NativeBlockState value) implements BufferedMutation {
private record BlockMutation(int x, int y, int z, NativeBlockState value, boolean canopy) implements BufferedMutation {
@Override
public void apply(IObjectPlacer placer) {
placer.set(x, y, z, value);
}
}
private record TileMutation(int x, int y, int z, TileData value) implements BufferedMutation {
private record TileMutation(int x, int y, int z, TileData value, boolean canopy) implements BufferedMutation {
@Override
public void apply(IObjectPlacer placer) {
placer.setTile(x, y, z, value);
}
}
private record DataMutation(int x, int y, int z, Object value) implements BufferedMutation {
private record DataMutation(int x, int y, int z, Object value, boolean canopy) implements BufferedMutation {
@Override
public void apply(IObjectPlacer placer) {
placer.setData(x, y, z, value);
@@ -0,0 +1,43 @@
package art.arcane.iris.generation.mantle;
import art.arcane.iris.integration.Identifier;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
public record CustomBlockOwnership(List<Entry> entries) {
public CustomBlockOwnership {
entries = List.copyOf(entries);
}
public CustomBlockOwnership add(Entry entry) {
if (entries.contains(entry)) {
return this;
}
List<Entry> updated = new ArrayList<>(entries);
updated.add(entry);
return new CustomBlockOwnership(updated);
}
public CustomBlockOwnership invalidate() {
List<Entry> updated = new ArrayList<>(entries.size());
for (Entry entry : entries) {
updated.add(new Entry(entry.identifier(), entry.token(), true));
}
return new CustomBlockOwnership(updated);
}
public CustomBlockOwnership remove(Entry entry) {
List<Entry> updated = new ArrayList<>(entries);
updated.remove(entry);
return updated.isEmpty() ? null : new CustomBlockOwnership(updated);
}
public record Entry(Identifier identifier, String token, boolean invalidated) {
public Entry {
Objects.requireNonNull(identifier);
Objects.requireNonNull(token);
}
}
}
@@ -0,0 +1,341 @@
package art.arcane.iris.generation.mantle;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.EnginePlatformHooks;
import art.arcane.iris.integration.Identifier;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.world.storage.matter.TileWrapper;
import art.arcane.volmlib.nativelib.terrain.NativeBlockChangeSet;
import art.arcane.volmlib.nativelib.terrain.NativeBlockPoint;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.volmlib.util.mantle.flag.MantleFlag;
import art.arcane.volmlib.util.mantle.flag.ReservedFlag;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
import art.arcane.volmlib.util.mantle.MantleClosedException;
import art.arcane.volmlib.util.matter.Matter;
import art.arcane.volmlib.util.matter.MatterSlice;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Set;
public final class DeferredPlacementRecords {
private static final Set<MantleFlag> RETAINED_MARGIN_FLAGS = Set.of(
MantleFlag.TILE, MantleFlag.CUSTOM, MantleFlag.UPDATE, MantleFlag.ETCHED,
MantleFlag.INITIAL_SPAWNED, MantleFlag.INITIAL_SPAWNED_MARKER, MantleFlag.DISCOVERED);
private DeferredPlacementRecords() {
}
public static void resetMantleChunk(Mantle<Matter> mantle, int chunkX, int chunkZ, boolean target) {
MantleChunk<Matter> chunk = mantle.useChunk(chunkX, chunkZ);
try {
synchronized (chunk) {
for (int index = 0; index < chunk.sectionCount(); index++) {
Matter section = chunk.get(index);
if (section == null) {
continue;
}
section.getSliceTypes().removeIf(type -> type != CustomBlockOwnership.class);
if (section.isEmpty()) {
chunk.delete(index);
}
}
for (ReservedFlag flag : ReservedFlag.values()) {
if (target || !RETAINED_MARGIN_FLAGS.contains(flag)) {
chunk.flag(flag, false);
}
}
for (int ordinal = MantleFlag.MIN_ORDINAL; ordinal <= MantleFlag.MAX_ORDINAL; ordinal++) {
chunk.flag(MantleFlag.of(ordinal), false);
}
}
} finally {
chunk.release();
}
}
public static void invalidateCustomBlockOwnership(MantleChunk<Matter> chunk) {
synchronized (chunk) {
List<Pending<CustomBlockOwnership>> records =
snapshot(chunk, CustomBlockOwnership.class);
for (Pending<CustomBlockOwnership> record : records) {
NativeBlockPoint position = record.position();
chunk.get(position.y() >> 4).slice(CustomBlockOwnership.class)
.set(position.x() & 15, position.y() & 15, position.z() & 15, record.value().invalidate());
}
}
}
public static <T> List<Pending<T>> snapshot(MantleChunk<Matter> chunk, Class<T> type) {
List<Pending<T>> records = new ArrayList<>();
synchronized (chunk) {
chunk.iterate(type, (x, y, z, value) -> records.add(new Pending<>(new NativeBlockPoint(x, y, z), value)));
}
return records;
}
public static <T> boolean isPending(MantleChunk<Matter> chunk, Class<T> type, Pending<T> record) {
NativeBlockPoint position = record.position();
synchronized (chunk) {
return Objects.equals(record.value(), chunk.get(position.x(), position.y(), position.z(), type));
}
}
public static NativeBlockChangeSet track(Engine engine, NativeBlockChangeSet.Bounds bounds) {
Mantle<Matter> mantle = engine.getMantle().getMantle();
for (int chunkX = bounds.minChunkX(); chunkX <= bounds.maxChunkX(); chunkX++) {
for (int chunkZ = bounds.minChunkZ(); chunkZ <= bounds.maxChunkZ(); chunkZ++) {
if (hasRecords(mantle, chunkX, chunkZ)) {
return NativeBlockChangeSet.create(bounds);
}
}
}
return NativeBlockChangeSet.disabled();
}
public static void invalidate(Engine engine, NativeBlockChangeSet changes) {
if (changes.isEmpty()) {
return;
}
Mantle<Matter> mantle = engine.getMantle().getMantle();
int minHeight = engine.getMinHeight();
changes.forEachChunk((chunkX, chunkZ) -> {
MantleChunk<Matter> chunk = invalidateChunk(mantle, changes, chunkX, chunkZ, minHeight);
if (chunk != null) {
scheduleRemoval(engine, chunk, minHeight);
}
});
}
public static NativeBlockChangeSet positions(Engine engine, NativeBlockChangeSet.Bounds bounds) {
Mantle<Matter> mantle = engine.getMantle().getMantle();
NativeBlockChangeSet positions = null;
int minHeight = engine.getMinHeight();
for (int chunkX = bounds.minChunkX(); chunkX <= bounds.maxChunkX(); chunkX++) {
for (int chunkZ = bounds.minChunkZ(); chunkZ <= bounds.maxChunkZ(); chunkZ++) {
MantleChunk<Matter> chunk = mantle.useChunk(chunkX, chunkZ);
try {
synchronized (chunk) {
if (!hasRecords(chunk)) {
continue;
}
if (positions == null) {
positions = NativeBlockChangeSet.create(bounds);
}
collectPositions(chunk, positions, Identifier.class, minHeight);
collectPositions(chunk, positions, TileWrapper.class, minHeight);
collectPositions(chunk, positions, CustomBlockOwnership.class, minHeight);
}
} finally {
chunk.release();
}
}
}
return positions == null || positions.isEmpty() ? NativeBlockChangeSet.disabled() : positions;
}
public static void remember(Engine engine, MantleChunk<Matter> chunk, NativeBlockPoint position,
CustomBlockOwnership.Entry entry) {
boolean invalidated;
synchronized (chunk) {
Matter section = chunk.getOrCreate(position.y() >> 4);
MatterSlice<Identifier> deferred = section.getSlice(Identifier.class);
invalidated = entry.invalidated() || deferred == null
|| deferred.get(position.x() & 15, position.y() & 15, position.z() & 15) == null;
CustomBlockOwnership.Entry completed = invalidated
? new CustomBlockOwnership.Entry(entry.identifier(), entry.token(), true) : entry;
MatterSlice<CustomBlockOwnership> slice = section.slice(CustomBlockOwnership.class);
CustomBlockOwnership existing = slice.get(position.x() & 15, position.y() & 15, position.z() & 15);
CustomBlockOwnership updated = existing == null
? new CustomBlockOwnership(List.of(completed)) : existing.add(completed);
slice.set(position.x() & 15, position.y() & 15, position.z() & 15, updated);
}
if (invalidated) {
scheduleRemoval(engine, chunk, engine.getMinHeight());
}
}
public static void removeInvalidated(Engine engine, int chunkX, int chunkZ) {
removeInvalidated(engine, engine.getMantle().getMantle(), chunkX, chunkZ);
}
public static void removeInvalidated(Engine engine, Mantle<Matter> mantle, int chunkX, int chunkZ) {
NativeWorld world = engine.getWorld().platformWorld();
if (world == null || engine.isClosed() || !world.isChunkLoaded(chunkX, chunkZ)) {
return;
}
if (mantle.isClosed()) {
return;
}
MantleChunk<Matter> chunk = mantle.useChunk(chunkX, chunkZ);
try {
removeInvalidated(engine, chunk, engine.getMinHeight());
} finally {
chunk.release();
}
}
private static void scheduleRemoval(Engine engine, MantleChunk<Matter> chunk, int minHeight) {
NativeWorld world = engine.getWorld().platformWorld();
if (world != null && IrisPlatforms.isBound()) {
IrisPlatforms.get().scheduler().region(world, chunk.getX(), chunk.getZ(),
() -> removeScheduled(new ScheduledRemoval(engine, world, chunk, minHeight)));
}
}
private static void removeScheduled(ScheduledRemoval removal) {
MantleChunk<Matter> chunk = removal.chunk();
if (removal.engine().isClosed() || chunk.isClosed()
|| !removal.world().isChunkLoaded(chunk.getX(), chunk.getZ())) {
return;
}
try {
chunk.use();
} catch (MantleClosedException closed) {
IrisLogging.reportError("Custom content cleanup deferred after mantle chunk "
+ chunk.getX() + "," + chunk.getZ() + " closed.", closed);
return;
}
try {
removeInvalidated(removal.engine(), chunk, removal.minHeight());
} finally {
chunk.release();
}
}
private static void removeInvalidated(Engine engine, MantleChunk<Matter> chunk, int minHeight) {
EnginePlatformHooks hooks = IrisServices.getOrNull(EnginePlatformHooks.class);
if (hooks == null) {
return;
}
int baseX = chunk.getX() << 4;
int baseZ = chunk.getZ() << 4;
List<Removal> removals = new ArrayList<>();
synchronized (chunk) {
chunk.iterate(CustomBlockOwnership.class, (x, y, z, ownership) -> {
for (CustomBlockOwnership.Entry entry : ownership.entries()) {
if (entry.invalidated()) {
removals.add(new Removal(new NativeBlockPoint(baseX + (x & 15),
y + minHeight, baseZ + (z & 15)), entry));
}
}
});
}
for (Removal removal : removals) {
try {
if (hooks.removeCustomBlock(engine, removal.position(), removal.entry())) {
forgetRemoval(chunk, removal, minHeight);
}
} catch (RuntimeException | LinkageError error) {
IrisLogging.reportError("Failed to remove generated custom content at " + removal.position() + ".", error);
}
}
}
private static <T> void collectPositions(MantleChunk<Matter> chunk, NativeBlockChangeSet positions,
Class<T> type, int minHeight) {
int baseX = chunk.getX() << 4;
int baseZ = chunk.getZ() << 4;
chunk.iterate(type, (x, y, z, value) -> positions.add(baseX + (x & 15), y + minHeight, baseZ + (z & 15)));
}
private static void forgetRemoval(MantleChunk<Matter> chunk, Removal removal, int minHeight) {
NativeBlockPoint position = removal.position();
int y = position.y() - minHeight;
synchronized (chunk) {
Matter section = chunk.get(y >> 4);
if (section == null) {
return;
}
MatterSlice<CustomBlockOwnership> slice = section.getSlice(CustomBlockOwnership.class);
if (slice == null) {
return;
}
CustomBlockOwnership ownership = slice.get(position.x() & 15, y & 15, position.z() & 15);
if (ownership != null) {
slice.set(position.x() & 15, y & 15, position.z() & 15, ownership.remove(removal.entry()));
}
}
}
private static boolean hasRecords(Mantle<Matter> mantle, int chunkX, int chunkZ) {
MantleChunk<Matter> chunk = mantle.useChunk(chunkX, chunkZ);
try {
synchronized (chunk) {
return hasRecords(chunk);
}
} finally {
chunk.release();
}
}
private static boolean hasRecords(MantleChunk<Matter> chunk) {
for (int section = 0; section < chunk.sectionCount(); section++) {
Matter matter = chunk.get(section);
if (matter != null && (matter.hasSlice(Identifier.class) || matter.hasSlice(TileWrapper.class)
|| matter.hasSlice(CustomBlockOwnership.class))) {
return true;
}
}
return false;
}
private static MantleChunk<Matter> invalidateChunk(Mantle<Matter> mantle, NativeBlockChangeSet changes,
int chunkX, int chunkZ, int minHeight) {
MantleChunk<Matter> chunk = mantle.useChunk(chunkX, chunkZ);
try {
synchronized (chunk) {
if (!hasRecords(chunk)) {
return null;
}
boolean[] cleanup = new boolean[1];
changes.forEachPosition(chunkX, chunkZ, (x, y, z) ->
cleanup[0] |= clearRecords(chunk, x & 15, y - minHeight, z & 15));
return cleanup[0] ? chunk : null;
}
} finally {
chunk.release();
}
}
private static boolean clearRecords(MantleChunk<Matter> chunk, int x, int y, int z) {
if (y < 0 || (y >> 4) >= chunk.sectionCount()) {
return false;
}
Matter section = chunk.get(y >> 4);
if (section == null) {
return false;
}
clearRecord(section.getSlice(Identifier.class), x, y & 15, z);
clearRecord(section.getSlice(TileWrapper.class), x, y & 15, z);
MatterSlice<CustomBlockOwnership> ownership = section.getSlice(CustomBlockOwnership.class);
if (ownership != null) {
CustomBlockOwnership current = ownership.get(x, y & 15, z);
if (current != null) {
ownership.set(x, y & 15, z, current.invalidate());
return true;
}
}
return false;
}
private static void clearRecord(MatterSlice<?> slice, int x, int y, int z) {
if (slice != null) {
slice.set(x, y, z, null);
}
}
private record Removal(NativeBlockPoint position, CustomBlockOwnership.Entry entry) {
}
public record Pending<T>(NativeBlockPoint position, T value) {
}
private record ScheduledRemoval(Engine engine, NativeWorld world, MantleChunk<Matter> chunk, int minHeight) {
}
}
@@ -24,6 +24,7 @@ import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.structure.object.IObjectPlacer;
import art.arcane.iris.generation.block.TileData;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.volmlib.util.math.BlockPosition;
import art.arcane.iris.generation.block.B;
import art.arcane.volmlib.util.collection.KList;
import org.jetbrains.annotations.Nullable;
@@ -43,6 +44,7 @@ final class FloatingObjectPlacementTransaction implements IObjectPlacer {
private final KList<BufferedMutation> mutations;
private final Map<PositionKey, NativeBlockState> bufferedBlocks;
private int blockWrites;
private BlockPosition canopyPosition;
FloatingObjectPlacementTransaction(IObjectPlacer delegate, ObjectContinuationBundle.PlacementKey placementKey) {
this.delegate = Objects.requireNonNull(delegate);
@@ -70,9 +72,7 @@ final class FloatingObjectPlacementTransaction implements IObjectPlacer {
return CommitResult.REJECTED_SUPPORT;
}
}
for (BufferedMutation mutation : mutations) {
mutation.apply(delegate);
}
applyMutations();
persistContinuation(engine);
discard();
return CommitResult.COMMITTED;
@@ -94,9 +94,17 @@ final class FloatingObjectPlacementTransaction implements IObjectPlacer {
}
IrisMatterSupport.ensureRegistered();
LinkedHashMap<ObjectContinuationBundle.ChunkPosition, Matter> payloads = new LinkedHashMap<>();
boolean skipCanopy = false;
for (BufferedMutation mutation : mutations) {
ObjectContinuationBundle.ChunkPosition chunk = new ObjectContinuationBundle.ChunkPosition(mutation.x() >> 4, mutation.z() >> 4);
Matter payload = payloads.computeIfAbsent(chunk, ignored -> new IrisMatter(16, engine.getHeight(), 16));
if (mutation.value() instanceof NativeBlockState) {
skipCanopy = !ObjectContinuationPersistence.beginBlock(payload, mutation.x() & 15,
mutation.y(), mutation.z() & 15, mutation.canopy());
}
if (mutation.canopy() && skipCanopy) {
continue;
}
ObjectContinuationPersistence.put(payload, mutation.x() & 15, mutation.y(), mutation.z() & 15, mutation.value());
}
LinkedHashMap<ObjectContinuationBundle.ChunkPosition, Supplier<Matter>> destinations = new LinkedHashMap<>();
@@ -105,6 +113,31 @@ final class FloatingObjectPlacementTransaction implements IObjectPlacer {
new ObjectContinuationBundle.Bounds(minimumX, minimumZ, maximumX, maximumZ), destinations);
}
private void applyMutations() {
boolean canopyOpen = false;
boolean skipCanopy = false;
try {
for (BufferedMutation mutation : mutations) {
if (mutation.value() instanceof NativeBlockState) {
if (canopyOpen) {
delegate.endTreeCanopy();
canopyOpen = false;
}
skipCanopy = mutation.canopy()
&& !delegate.beginTreeCanopy(new BlockPosition(mutation.x(), mutation.y(), mutation.z()));
canopyOpen = mutation.canopy() && !skipCanopy;
}
if (!mutation.canopy() || !skipCanopy) {
mutation.apply(delegate);
}
}
} finally {
if (canopyOpen) {
delegate.endTreeCanopy();
}
}
}
void discard() {
mutations.clear();
bufferedBlocks.clear();
@@ -126,11 +159,30 @@ final class FloatingObjectPlacementTransaction implements IObjectPlacer {
if (state == null) {
return;
}
mutations.add(new BlockMutation(x, y, z, state));
mutations.add(new BlockMutation(x, y, z, state, isCanopy(x, y, z)));
bufferedBlocks.put(new PositionKey(x, y, z), state);
blockWrites++;
}
@Override
public boolean beginTreeCanopy(BlockPosition position) {
if (!IObjectPlacer.super.beginTreeCanopy(position)) {
return false;
}
canopyPosition = position;
return true;
}
@Override
public void endTreeCanopy() {
canopyPosition = null;
}
private boolean isCanopy(int x, int y, int z) {
return canopyPosition != null && canopyPosition.getX() == x
&& canopyPosition.getY() == y && canopyPosition.getZ() == z;
}
@Override
public NativeBlockState get(int x, int y, int z) {
NativeBlockState state = bufferedBlocks.get(new PositionKey(x, y, z));
@@ -176,7 +228,7 @@ final class FloatingObjectPlacementTransaction implements IObjectPlacer {
@Override
public void setTile(int x, int y, int z, TileData tile) {
if (tile != null) {
mutations.add(new TileMutation(x, y, z, tile));
mutations.add(new TileMutation(x, y, z, tile, isCanopy(x, y, z)));
}
}
@@ -185,7 +237,7 @@ final class FloatingObjectPlacementTransaction implements IObjectPlacer {
if (data == null) {
return;
}
mutations.add(new DataMutation(x, y, z, data));
mutations.add(new DataMutation(x, y, z, data, isCanopy(x, y, z)));
if (data instanceof NativeBlockState state) {
bufferedBlocks.put(new PositionKey(x, y, z), state);
blockWrites++;
@@ -228,24 +280,26 @@ final class FloatingObjectPlacementTransaction implements IObjectPlacer {
Object value();
boolean canopy();
void apply(IObjectPlacer placer);
}
private record BlockMutation(int x, int y, int z, NativeBlockState value) implements BufferedMutation {
private record BlockMutation(int x, int y, int z, NativeBlockState value, boolean canopy) implements BufferedMutation {
@Override
public void apply(IObjectPlacer placer) {
placer.set(x, y, z, value);
}
}
private record TileMutation(int x, int y, int z, TileData value) implements BufferedMutation {
private record TileMutation(int x, int y, int z, TileData value, boolean canopy) implements BufferedMutation {
@Override
public void apply(IObjectPlacer placer) {
placer.setTile(x, y, z, value);
}
}
private record DataMutation(int x, int y, int z, Object value) implements BufferedMutation {
private record DataMutation(int x, int y, int z, Object value, boolean canopy) implements BufferedMutation {
@Override
public void apply(IObjectPlacer placer) {
placer.setData(x, y, z, value);
@@ -24,6 +24,7 @@ import art.arcane.iris.structure.object.IObjectPlacer;
import art.arcane.iris.generation.block.TileData;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.volmlib.util.math.BlockPosition;
final class GoldenDebugObjectPlacer implements IObjectPlacer {
private static final int[] GOLDEN_DEBUG_TARGET = parseGoldenDebugTarget(resolveGoldenDebugSpec());
@@ -93,6 +94,16 @@ final class GoldenDebugObjectPlacer implements IObjectPlacer {
delegate.set(x, y, z, d);
}
@Override
public boolean beginTreeCanopy(BlockPosition position) {
return delegate.beginTreeCanopy(position);
}
@Override
public void endTreeCanopy() {
delegate.endTreeCanopy();
}
@Override
public NativeBlockState get(int x, int y, int z) {
return delegate.get(x, y, z);
@@ -1,9 +1,11 @@
package art.arcane.iris.generation.mantle;
import art.arcane.iris.generation.block.TileData;
import art.arcane.iris.generation.decoration.tree.TreeBlockMaterial;
import art.arcane.iris.integration.Identifier;
import art.arcane.iris.world.storage.matter.TileWrapper;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.volmlib.util.hunk.Hunk;
import art.arcane.volmlib.util.mantle.flag.MantleFlag;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
@@ -12,6 +14,7 @@ import art.arcane.volmlib.util.matter.MatterSlice;
import java.util.List;
import java.util.Map;
import java.util.BitSet;
import java.util.function.Supplier;
public final class ObjectContinuationPersistence {
@@ -68,6 +71,95 @@ public final class ObjectContinuationPersistence {
}
}
public static boolean beginBlock(Matter payload, int x, int y, int z, boolean canopy) {
if (canopy) {
MatterSlice<NativeBlockState> blocks = payload.getSlice(NativeBlockState.class);
NativeBlockState existing = blocks == null ? null : blocks.get(x, y, z);
if (existing != null && existing.isSolid()) {
return false;
}
payload.slice(TreeCanopy.class).set(x, y, z, TreeCanopy.INSTANCE);
} else {
MatterSlice<TreeCanopy> conditions = payload.getSlice(TreeCanopy.class);
if (conditions != null) {
conditions.set(x, y, z, null);
}
}
return true;
}
public static void restore(Mantle<Matter> mantle, int chunkX, int chunkZ, Matter continuation,
Hunk<NativeBlockState> blocks, boolean applyBlocks) {
BitSet blocked = blockedCanopy(continuation, blocks, applyBlocks);
MatterSlice<NativeBlockState> states = continuation.getSlice(NativeBlockState.class);
MantleChunk<Matter> chunk = mantle.useChunk(chunkX, chunkZ);
try {
synchronized (chunk) {
if (states != null) {
states.iterateSync((x, y, z, state) -> {
if (blocked.get(cellIndex(x, y, z))) {
return;
}
Matter section = chunk.get(y >> 4);
if (section != null) {
clearMetadata(section, x, y & 15, z);
}
});
}
for (Map.Entry<Class<?>, MatterSlice<?>> entry : continuation.getSliceMap().entrySet()) {
if (entry.getKey() == TreeCanopy.class) {
continue;
}
entry.getValue().iterateSync((x, y, z, value) -> {
if (!blocked.get(cellIndex(x, y, z))) {
chunk.getOrCreate(y >> 4).slice(entry.getKey()).set(x, y & 15, z, value);
}
});
}
}
} finally {
chunk.release();
}
if (applyBlocks && states != null) {
states.iterateSync((x, y, z, state) -> {
if (!blocked.get(cellIndex(x, y, z))) {
blocks.set(x, y, z, state);
}
});
}
}
private static BitSet blockedCanopy(Matter continuation, Hunk<NativeBlockState> blocks, boolean applyBlocks) {
BitSet blocked = new BitSet();
MatterSlice<TreeCanopy> conditions = continuation.getSlice(TreeCanopy.class);
if (conditions != null) {
conditions.iterateSync((x, y, z, condition) -> {
NativeBlockState existing = blocks.get(x, y, z);
if (!applyBlocks || existing != null && existing.isSolid()) {
blocked.set(cellIndex(x, y, z));
}
});
}
return blocked;
}
private static int cellIndex(int x, int y, int z) {
return (y << 8) | (z << 4) | x;
}
private static void clearMetadata(Matter section, int x, int y, int z) {
clearMetadata(section.getSlice(Identifier.class), x, y, z);
clearMetadata(section.getSlice(String.class), x, y, z);
clearMetadata(section.getSlice(TreeBlockMaterial.class), x, y, z);
clearMetadata(section.getSlice(TileWrapper.class), x, y, z);
}
private static void clearMetadata(MatterSlice<?> slice, int x, int y, int z) {
if (slice != null) {
slice.set(x, y, z, null);
}
}
private static boolean contains(ObjectContinuationBundle bundle, ObjectContinuationBundle.PlacementKey key) {
if (bundle == null) {
return false;
@@ -10,6 +10,7 @@ import art.arcane.iris.generation.subterrain.SubterrainRasterizer;
import art.arcane.iris.generation.block.TileData;
import art.arcane.iris.structure.object.IObjectPlacer;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.volmlib.util.math.BlockPosition;
import art.arcane.iris.generation.block.B;
import art.arcane.iris.world.storage.matter.TileWrapper;
import art.arcane.volmlib.util.matter.MatterCavern;
@@ -34,6 +35,7 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer {
private final List<Mutation> mutations;
private final List<PlacementRange> placements = new ArrayList<>();
private final Long2ObjectOpenHashMap<Int2ObjectOpenHashMap<OverlayCell>> overlay;
private BlockPosition canopyPosition;
private boolean hydrologyGateKnown;
private int hydrologyGateChunkX;
private int hydrologyGateChunkZ;
@@ -82,7 +84,15 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer {
}
void apply(ObjectSourcePlan plan) {
boolean skipCanopy = false;
for (Mutation mutation : plan.mutationsFor(destinationChunkX, destinationChunkZ)) {
if (mutation.key().type() == NativeBlockState.class) {
NativeBlockState existing = mutation.canopy() ? get(mutation.key().x(), mutation.key().y(), mutation.key().z()) : null;
skipCanopy = existing != null && existing.isSolid();
}
if (mutation.canopy() && skipCanopy) {
continue;
}
switch (mutation) {
case SetMutation set -> setData(set.key().x(), set.key().y(), set.key().z(), set.value(), set);
case CustomBlockMutation custom -> set(custom.key().x(), custom.key().y(), custom.key().z(), custom.state(), custom);
@@ -158,12 +168,31 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer {
cell.tile = CLEARED;
cell.identifier = identifier;
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, isCanopy(x, y, z)));
return;
}
setData(x, y, z, state);
}
@Override
public boolean beginTreeCanopy(BlockPosition position) {
if (!ObjectPassPlacer.super.beginTreeCanopy(position)) {
return false;
}
canopyPosition = position;
return true;
}
@Override
public void endTreeCanopy() {
canopyPosition = null;
}
private boolean isCanopy(int x, int y, int z) {
return canopyPosition != null && canopyPosition.getX() == x
&& canopyPosition.getY() == y && canopyPosition.getZ() == z;
}
@Override
public NativeBlockState get(int x, int y, int z) {
OverlayCell cell = overlayCell(x, y, z);
@@ -257,7 +286,7 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer {
cell.put(type, data);
}
mutations.add(replayed != null
? replayed : new SetMutation(new DataKey(x, y, z, type), data));
? replayed : new SetMutation(new DataKey(x, y, z, type), data, isCanopy(x, y, z)));
}
@Override
@@ -476,10 +505,12 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer {
DataKey key();
boolean canopy();
void apply(IObjectPlacer placer);
}
record SetMutation(DataKey key, Object value) implements Mutation {
record SetMutation(DataKey key, Object value, boolean canopy) implements Mutation {
@Override
public int x() {
return key.x();
@@ -496,7 +527,7 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer {
}
}
record CustomBlockMutation(DataKey key, NativeBlockState state) implements Mutation {
record CustomBlockMutation(DataKey key, NativeBlockState state, boolean canopy) implements Mutation {
@Override
public int x() {
return key.x();
@@ -32,7 +32,8 @@ final class ObjectSourcePlan {
private static final int PLACEMENT_DESTINATION_BYTES = 40;
private static final int UNKNOWN_PAYLOAD_BYTES = 256;
private static final int CUSTOM_BIT = 1 << 8;
private static final int Y_SHIFT = 9;
private static final int CANOPY_BIT = 1 << 9;
private static final int Y_SHIFT = 10;
private static final int MAXIMUM_Y = (1 << (31 - Y_SHIFT)) - 1;
private final long[] destinations;
@@ -102,7 +103,7 @@ final class ObjectSourcePlan {
custom = CUSTOM_BIT;
}
}
cells[position] = (key.y() << Y_SHIFT) | custom | ((key.z() & 15) << 4) | (key.x() & 15);
cells[position] = (key.y() << Y_SHIFT) | custom | (mutation.canopy() ? CANOPY_BIT : 0) | ((key.z() & 15) << 4) | (key.x() & 15);
valueIndexes[position] = table.indexOf(value);
}
values = table.values.toArray();
@@ -275,9 +276,9 @@ final class ObjectSourcePlan {
Object value = values[valueIndexes[start + index]];
decoded[index] = (cell & CUSTOM_BIT) != 0
? new ObjectDestinationTransaction.CustomBlockMutation(
new ObjectDestinationTransaction.DataKey(x, y, z, NativeBlockState.class), (NativeBlockState) value)
new ObjectDestinationTransaction.DataKey(x, y, z, NativeBlockState.class), (NativeBlockState) value, (cell & CANOPY_BIT) != 0)
: new ObjectDestinationTransaction.SetMutation(
new ObjectDestinationTransaction.DataKey(x, y, z, typeOf(value)), value);
new ObjectDestinationTransaction.DataKey(x, y, z, typeOf(value)), value, (cell & CANOPY_BIT) != 0);
}
return List.of(decoded);
}
@@ -285,10 +286,17 @@ final class ObjectSourcePlan {
static byte[] encode(List<ObjectDestinationTransaction.Mutation> mutations, int height) {
IrisMatterSupport.ensureRegistered();
Matter payload = new IrisMatter(16, height, 16);
boolean skipCanopy = false;
for (ObjectDestinationTransaction.Mutation mutation : mutations) {
ObjectDestinationTransaction.DataKey key = mutation.key();
int x = key.x() & 15;
int z = key.z() & 15;
if (key.type() == NativeBlockState.class) {
skipCanopy = !ObjectContinuationPersistence.beginBlock(payload, x, key.y(), z, mutation.canopy());
}
if (mutation.canopy() && skipCanopy) {
continue;
}
if (mutation instanceof ObjectDestinationTransaction.CustomBlockMutation custom) {
payload.slice(NativeBlockState.class).set(x, key.y(), z, custom.state().placementBaseState());
payload.slice(Identifier.class).set(x, key.y(), z, Identifier.fromString(custom.state().deferredPlacementKey()));
@@ -0,0 +1,5 @@
package art.arcane.iris.generation.mantle;
public enum TreeCanopy {
INSTANCE
}
@@ -23,11 +23,9 @@ import art.arcane.iris.generation.concurrent.MultiBurst;
import art.arcane.iris.generation.context.ChunkContext;
import art.arcane.iris.generation.context.IrisContext;
import art.arcane.iris.generation.mantle.EngineMantle;
import art.arcane.iris.generation.mantle.ObjectContinuationPersistence;
import art.arcane.iris.generation.stage.IrisDimensionStackActuator;
import art.arcane.iris.generation.stage.IrisTransitionGeometryActuator;
import art.arcane.iris.integration.Identifier;
import art.arcane.iris.generation.decoration.tree.TreeBlockMaterial;
import art.arcane.iris.world.storage.matter.TileWrapper;
import art.arcane.iris.world.history.FloatingBiomeOverlay;
import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter;
import art.arcane.iris.world.history.SavedTerrainChunk;
@@ -36,13 +34,10 @@ import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.documentation.BlockCoordinates;
import art.arcane.volmlib.util.hunk.Hunk;
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
import art.arcane.volmlib.util.matter.Matter;
import art.arcane.volmlib.util.matter.MatterSlice;
import java.io.IOException;
import java.util.List;
import java.util.Map;
public interface EngineMode extends Staged {
KList<EngineStage> getTerrainStages();
@@ -221,7 +216,7 @@ public interface EngineMode extends Staged {
recordNaturalTerrain(x, z, blocks, biomes, ctx);
IrisDimensionStackActuator.clearHostNaturalMetadata(getEngine(), x, z, biomes.getHeight(), ctx);
ctx.beginContent();
List<Matter> continuations = restoreObjectContinuations(x, z);
List<Matter> continuations = restoreObjectContinuations(x, z, blocks);
if (!continuations.isEmpty()) {
ctx.setCaveTerrain(null);
}
@@ -229,16 +224,13 @@ public interface EngineMode extends Staged {
i.generate(x, z, blocks, biomes, multicore, ctx);
}
for (Matter continuation : continuations) {
restoreObjectMetadata(x, z, continuation);
MatterSlice<NativeBlockState> states = continuation.getSlice(NativeBlockState.class);
if (states != null) {
states.iterateSync((localX, y, localZ, state) -> blocks.set(localX, y, localZ, state));
}
ObjectContinuationPersistence.restore(getMantle().getMantle(), x >> 4, z >> 4,
continuation, blocks, true);
}
}
}
private List<Matter> restoreObjectContinuations(int x, int z) {
private List<Matter> restoreObjectContinuations(int x, int z, Hunk<NativeBlockState> blocks) {
if (!(getEngine() instanceof IrisEngine irisEngine)) {
return List.of();
}
@@ -257,7 +249,8 @@ public interface EngineMode extends Staged {
if (continuation.getHeight() != getEngine().getHeight()) {
throw new IOException("Object continuation height does not match the world");
}
restoreObjectMetadata(x, z, continuation);
ObjectContinuationPersistence.restore(getMantle().getMantle(), x >> 4, z >> 4,
continuation, blocks, false);
}
return continuations;
} catch (IOException failure) {
@@ -265,42 +258,6 @@ public interface EngineMode extends Staged {
}
}
private void restoreObjectMetadata(int x, int z, Matter continuation) {
MantleChunk<Matter> chunk = getMantle().getMantle().useChunk(x >> 4, z >> 4);
try {
synchronized (chunk) {
MatterSlice<NativeBlockState> states = continuation.getSlice(NativeBlockState.class);
if (states != null) {
states.iterateSync((localX, y, localZ, state) -> {
Matter section = chunk.get(y >> 4);
if (section != null) {
clearObjectMetadata(section, localX, y & 15, localZ);
}
});
}
for (Map.Entry<Class<?>, MatterSlice<?>> entry : continuation.getSliceMap().entrySet()) {
entry.getValue().iterateSync((localX, y, localZ, value) ->
chunk.getOrCreate(y >> 4).slice(entry.getKey()).set(localX, y & 15, localZ, value));
}
}
} finally {
chunk.release();
}
}
private static void clearObjectMetadata(Matter section, int x, int y, int z) {
clearObjectMetadata(section.getSlice(Identifier.class), x, y, z);
clearObjectMetadata(section.getSlice(String.class), x, y, z);
clearObjectMetadata(section.getSlice(TreeBlockMaterial.class), x, y, z);
clearObjectMetadata(section.getSlice(TileWrapper.class), x, y, z);
}
private static void clearObjectMetadata(MatterSlice<?> slice, int x, int y, int z) {
if (slice != null) {
slice.set(x, y, z, null);
}
}
private void recordNaturalTerrain(int x, int z, Hunk<NativeBlockState> blocks,
Hunk<NativeBiome> biomes, ChunkContext context) {
if (!(getEngine() instanceof IrisEngine irisEngine)) {
@@ -21,6 +21,8 @@ package art.arcane.iris.generation.runtime;
import art.arcane.volmlib.nativelib.terrain.structure.NativeStructureVolume;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.generation.mantle.CustomBlockOwnership;
import art.arcane.volmlib.nativelib.terrain.NativeBlockPoint;
import art.arcane.iris.world.history.SavedTerrainChunk;
import java.util.concurrent.CompletableFuture;
@@ -28,6 +30,10 @@ import art.arcane.iris.generation.mantle.MantleComponent;
import art.arcane.volmlib.util.collection.KList;
public interface EnginePlatformHooks {
default boolean removeCustomBlock(Engine engine, NativeBlockPoint position, CustomBlockOwnership.Entry entry) {
return false;
}
default CompletableFuture<Void> flushSavedTerrainCapture(Engine engine) {
return CompletableFuture.failedFuture(new UnsupportedOperationException(
"This platform does not support saved terrain checkpoints."));
@@ -44,6 +44,7 @@ import org.bukkit.event.block.BlockBreakEvent;
import org.bukkit.event.block.BlockDropItemEvent;
import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.event.player.PlayerTeleportEvent;
import org.bukkit.event.world.EntitiesLoadEvent;
import java.util.concurrent.Future;
import java.util.concurrent.CompletableFuture;
@@ -249,6 +250,16 @@ public class IrisWorldManager extends EngineAssignedWorldManager {
}
}
@EventHandler
public void onEntitiesLoad(EntitiesLoadEvent event) {
runManagerTask("bukkit_world_manager_entities_load", () -> {
if (!event.getEntities().isEmpty()
&& event.getWorld().equals(BukkitWorldBinding.world(getEngine().getWorld()))) {
chunkMaintenance.onEntitiesLoaded(event.getChunk());
}
});
}
@Override
public void onChunkUnload(Chunk e) {
final var future = cleanup.remove(Cache.key(e.getX(), e.getZ()));
@@ -104,6 +104,16 @@ final class WorldChunkMaintenance {
}
}
void onEntitiesLoaded(Chunk chunk) {
int chunkX = chunk.getX();
int chunkZ = chunk.getZ();
World world = chunk.getWorld();
invalidateMaterialization(chunkX, chunkZ);
J.runRegion(world, chunkX, chunkZ,
manager.managedTask("bukkit_world_manager_entity_content_cleanup",
() -> scheduleChunkUpdate(world, chunkX, chunkZ)), 1);
}
void discoverChunks() {
World world = BukkitWorldBinding.world(manager.getEngine().getWorld());
if (world == null) {
@@ -120,7 +120,13 @@ public abstract class ExternalDataProvider {
* @param block The block where the block should be placed
* @param blockId The blockId to place
*/
public void processUpdate(@NotNull Engine engine, @NotNull Block block, @NotNull Identifier blockId) {}
public String processUpdate(@NotNull Engine engine, @NotNull Block block, @NotNull Identifier blockId) {
return null;
}
public boolean removeGenerated(@NotNull Block block, @NotNull String token) {
return false;
}
/**
* Spawns a mob in the specified location using the given engine and entity identifier.
@@ -19,6 +19,7 @@
package art.arcane.iris.integration;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.generation.mantle.CustomBlockOwnership;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.integration.data.DataType;
import art.arcane.volmlib.nativelib.terrain.BlockProperty;
@@ -247,13 +248,24 @@ public class ExternalDataSVC implements IrisService {
}
}
public void processUpdate(Engine engine, Block block, Identifier blockId) {
public CustomBlockOwnership.Entry processUpdate(Engine engine, Block block, Identifier blockId) {
Pair<Identifier, KMap<String, String>> state = parseState(blockId);
BlockProvider match = findBlockProvider(state.getA());
if (match == null) {
throw new MissingResourceException("No matching provider found for external block placement.", blockId.namespace(), blockId.key());
}
match.provider().processUpdate(engine, block, buildState(match.identifier(), state.getB()));
String token = match.provider().processUpdate(engine, block, buildState(match.identifier(), state.getB()));
return token == null ? null : new CustomBlockOwnership.Entry(
qualifyBlockId(match.provider(), buildState(match.identifier(), state.getB())), token, false);
}
public boolean removeGenerated(Block block, CustomBlockOwnership.Entry entry) {
for (ExternalDataProvider provider : activeProviders) {
if (provider.getPluginId().equalsIgnoreCase(entry.identifier().namespace()) && provider.isReady()) {
return provider.removeGenerated(block, entry.token());
}
}
return false;
}
public boolean placeBlock(Block block, Identifier blockId) {
@@ -18,6 +18,7 @@ import net.momirealms.craftengine.bukkit.api.CraftEngineFurniture;
import net.momirealms.craftengine.bukkit.api.CraftEngineItems;
import net.momirealms.craftengine.bukkit.api.event.CraftEngineReloadEvent;
import net.momirealms.craftengine.bukkit.item.BukkitItemDefinition;
import net.momirealms.craftengine.bukkit.entity.furniture.BukkitFurniture;
import net.momirealms.craftengine.bukkit.plugin.BukkitCraftEngine;
import net.momirealms.craftengine.core.block.BlockDefinition;
import net.momirealms.craftengine.core.block.ImmutableBlockState;
@@ -27,9 +28,11 @@ import net.momirealms.craftengine.core.block.property.Property;
import net.momirealms.craftengine.core.entity.furniture.FurnitureDefinition;
import net.momirealms.craftengine.core.util.Key;
import org.bukkit.Bukkit;
import org.bukkit.Chunk;
import org.bukkit.Location;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import org.bukkit.entity.Entity;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.inventory.ItemStack;
@@ -43,6 +46,7 @@ import java.util.Map;
import java.util.MissingResourceException;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -140,21 +144,38 @@ public class CraftEngineDataProvider extends ExternalDataProvider implements Lis
}
@Override
public void processUpdate(@NotNull Engine engine, @NotNull Block block, @NotNull Identifier blockId) {
public String processUpdate(@NotNull Engine engine, @NotNull Block block, @NotNull Identifier blockId) {
Pair<Identifier, KMap<String, String>> statePair = ExternalDataSVC.parseState(blockId);
Identifier baseBlockId = statePair.getA();
KMap<String, String> state = statePair.getB();
ImmutableBlockState blockState = findBlockState(baseBlockId);
if (blockState != null) {
placeBlockState(block, applyProperties(baseBlockId, blockState, state), blockId);
return;
return null;
}
FurnitureDefinition furniture = requireFurniture(baseBlockId);
KMap<String, String> properties = validateFurnitureProperties(baseBlockId, furniture, state);
Location location = parseYawAndPitch(engine, block, properties);
if (CraftEngineFurniture.place(location, furniture, properties.get("variant"), false) == null) {
BukkitFurniture placed = CraftEngineFurniture.place(location, furniture, properties.get("variant"), false);
if (placed == null) {
throw missing(blockId, "Failed to place CraftEngine furniture.");
}
return placed.baseEntity().getUniqueId().toString();
}
@Override
public boolean removeGenerated(@NotNull Block block, @NotNull String token) {
Chunk chunk = block.getChunk();
if (!chunk.isEntitiesLoaded()) {
return false;
}
UUID entityId = UUID.fromString(token);
for (Entity entity : chunk.getEntities()) {
if (entityId.equals(entity.getUniqueId())) {
return CraftEngineFurniture.remove(entity, false, false);
}
}
return true;
}
@Override
@@ -84,10 +84,11 @@ public class ItemAdderDataProvider extends ExternalDataProvider implements Liste
}
@Override
public void processUpdate(@NotNull Engine engine, @NotNull Block block, @NotNull Identifier blockId) {
public String processUpdate(@NotNull Engine engine, @NotNull Block block, @NotNull Identifier blockId) {
if (CustomBlock.place(blockId.toString(), block.getLocation()) == null) {
throw new MissingResourceException("Failed to place ItemsAdder block.", blockId.namespace(), blockId.key());
}
return null;
}
@Override
@@ -47,17 +47,18 @@ public class KGeneratorsDataProvider extends ExternalDataProvider {
}
@Override
public void processUpdate(@NotNull Engine engine, @NotNull Block block, @NotNull Identifier blockId) {
if (block.getType() != Material.STRUCTURE_VOID) return;
public String processUpdate(@NotNull Engine engine, @NotNull Block block, @NotNull Identifier blockId) {
if (block.getType() != Material.STRUCTURE_VOID) return null;
IGeneratorLocation existing = KGeneratorsAPI.getLoadedGeneratorLocation(block.getLocation());
if (existing != null) return;
if (existing != null) return null;
block.setBlockData(BukkitBlockResolution.getAir(), false);
Generator generator = Main.getGenerators().get(blockId.key());
if (generator == null) return;
if (generator == null) return null;
GeneratorLocation location = new GeneratorLocation(-1, generator, block.getLocation(), Main.getPlacedGenerators().getChunkInfo(block.getChunk()), null, null);
Main.getDatabases().getDb().saveGenerator(location);
Main.getPlacedGenerators().addLoaded(location);
Main.getSchedules().schedule(location, true);
return null;
}
@Override
@@ -112,15 +112,15 @@ public class MythicCrucibleDataProvider extends ExternalDataProvider {
}
@Override
public void processUpdate(@NotNull Engine engine, @NotNull Block block, @NotNull Identifier blockId) {
public String processUpdate(@NotNull Engine engine, @NotNull Block block, @NotNull Identifier blockId) {
Pair<Identifier, KMap<String, String>> parsedState = ExternalDataSVC.parseState(blockId);
KMap<String, String> state = parsedState.getB();
blockId = parsedState.getA();
Optional<CrucibleItem> item = itemManager.getItem(blockId.key());
if (item.isEmpty()) return;
if (item.isEmpty()) return null;
FurnitureItemContext furniture = item.get().getFurnitureData();
if (furniture == null) return;
if (furniture == null) return null;
Pair<Float, BlockFace> pair = parseYawAndFace(engine, block, state);
BiomeColor type = null;
@@ -130,10 +130,11 @@ public class MythicCrucibleDataProvider extends ExternalDataProvider {
} catch (NullPointerException | IllegalArgumentException ignored) {}
if (type != null) {
Color biomeColor = INMS.get().getBiomeColor(block.getLocation(), type);
if (biomeColor == null) return;
if (biomeColor == null) return null;
color = Chroma.of(biomeColor.getRGB());
}
furniture.place(block, pair.getB(), pair.getA(), color);
return null;
}
@Override
@@ -89,24 +89,24 @@ public class NexoDataProvider extends ExternalDataProvider {
}
@Override
public void processUpdate(@NotNull Engine engine, @NotNull Block block, @NotNull Identifier blockId) {
public String processUpdate(@NotNull Engine engine, @NotNull Block block, @NotNull Identifier blockId) {
Pair<Identifier, KMap<String, String>> statePair = ExternalDataSVC.parseState(blockId);
KMap<String, String> state = statePair.getB();
blockId = statePair.getA();
if (NexoBlocks.isCustomBlock(blockId.key())) {
NexoBlocks.place(blockId.key(), block.getLocation());
return;
return null;
}
if (!NexoFurniture.isFurniture(blockId.key()))
return;
return null;
Pair<Float, BlockFace> pair = parseYawAndFace(engine, block, state);
ItemDisplay display = NexoFurniture.place(blockId.key(), block.getLocation(), pair.getA(), pair.getB());
if (display == null) return;
if (display == null) return null;
ItemStack itemStack = display.getItemStack();
if (itemStack == null) return;
if (itemStack == null) return null;
BiomeColor type = null;
try {
@@ -115,7 +115,7 @@ public class NexoDataProvider extends ExternalDataProvider {
if (type != null) {
Color biomeColor = INMS.get().getBiomeColor(block.getLocation(), type);
if (biomeColor == null) return;
if (biomeColor == null) return null;
ItemMeta meta = itemStack.getItemMeta();
switch (meta) {
case LeatherArmorMeta armor -> armor.setColor(fromARGB(biomeColor.getAlpha(), biomeColor.getRed(), biomeColor.getGreen(), biomeColor.getBlue()));
@@ -126,6 +126,7 @@ public class NexoDataProvider extends ExternalDataProvider {
itemStack.setItemMeta(meta);
}
display.setItemStack(itemStack);
return null;
}
@Override
@@ -61,8 +61,9 @@ public final class OraxenDataProvider extends ExternalDataProvider implements Li
}
@Override
public void processUpdate(@NotNull Engine engine, @NotNull Block block, @NotNull Identifier blockId) {
public String processUpdate(@NotNull Engine engine, @NotNull Block block, @NotNull Identifier blockId) {
OraxenBlocks.place(blockId.key(), block.getLocation());
return null;
}
@Override
@@ -45,6 +45,9 @@ import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.collection.KSet;
import art.arcane.volmlib.util.io.AtomicFileIO;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonParseException;
import art.arcane.iris.platform.bootstrap.ServerProperties;
import art.arcane.iris.platform.bukkit.plugin.VolmitSender;
import art.arcane.iris.world.task.J;
@@ -104,6 +107,12 @@ public class ServerConfigurator {
if (failure != null) {
IrisLogging.reportError("Unable to persist the Spigot timeout setting.", failure);
}
} else {
Throwable failure = J.attemptCatch(() -> timeoutConfiguration().restore(
"settings.timeout-time", serverConfigurationOption("spigot-settings"), Bukkit.spigot().getConfig()));
if (failure != null) {
IrisLogging.reportError("Unable to restore the Spigot timeout setting.", failure);
}
}
if (s.isConfigurePaperWatchdogDelay() && BukkitPlatform.isPaperServer()) {
@@ -111,6 +120,12 @@ public class ServerConfigurator {
if (failure != null) {
IrisLogging.reportError("Unable to persist the Paper watchdog setting.", failure);
}
} else if (!s.isConfigurePaperWatchdogDelay()) {
Throwable failure = J.attemptCatch(() -> timeoutConfiguration().restore(
"watchdog.early-warning-delay", null, null));
if (failure != null) {
IrisLogging.reportError("Unable to restore the Paper watchdog setting.", failure);
}
}
if (DefaultPackBootstrapProvisioner.wasProvisionedThisStartup()) {
@@ -238,26 +253,26 @@ public class ServerConfigurator {
}
private static void increaseKeepAliveSpigot() throws IOException, ReflectiveOperationException {
increaseKeepAliveSpigot(serverConfigurationOption("spigot-settings"), Bukkit.spigot().getConfig());
increaseKeepAliveSpigot(serverConfigurationOption("spigot-settings"), Bukkit.spigot().getConfig(), timeoutConfiguration());
}
static void increaseKeepAliveSpigot(Path file, YamlConfiguration configuration) throws IOException {
static void increaseKeepAliveSpigot(Path file, YamlConfiguration configuration, ServerTimeoutConfiguration timeouts) throws IOException {
long previous = configuration.getLong("settings.timeout-time");
long minimum = TimeUnit.MINUTES.toSeconds(5);
if (previous >= minimum) {
return;
}
persistTimeout(file, configuration, "settings.timeout-time", minimum);
timeouts.raise(file, configuration, "settings.timeout-time", minimum);
IrisLogging.warn("Updated " + file + " settings.timeout-time: " + previous + " -> " + minimum
+ " seconds. Takes effect after a server restart.");
IrisLogging.warn("Disable autoConfiguration.configureSpigotTimeoutTime in iris.json to manage this setting yourself.");
}
private static void increasePaperWatchdog() throws IOException, InvalidConfigurationException, ReflectiveOperationException {
increasePaperWatchdog(serverConfigurationOption("paper-settings-directory").resolve("paper-global.yml"));
increasePaperWatchdog(serverConfigurationOption("paper-settings-directory").resolve("paper-global.yml"), timeoutConfiguration());
}
static void increasePaperWatchdog(Path file) throws IOException, InvalidConfigurationException {
static void increasePaperWatchdog(Path file, ServerTimeoutConfiguration timeouts) throws IOException, InvalidConfigurationException {
YamlConfiguration configuration = new YamlConfiguration();
configuration.load(file.toFile());
long previous = configuration.getLong("watchdog.early-warning-delay");
@@ -265,22 +280,14 @@ public class ServerConfigurator {
if (previous >= minimum) {
return;
}
persistTimeout(file, configuration, "watchdog.early-warning-delay", minimum);
timeouts.raise(file, configuration, "watchdog.early-warning-delay", minimum);
IrisLogging.warn("Updated " + file + " watchdog.early-warning-delay: " + previous + " -> " + minimum
+ " milliseconds. Takes effect after a server restart.");
IrisLogging.warn("Disable autoConfiguration.configurePaperWatchdogDelay in iris.json to manage this setting yourself.");
}
private static void persistTimeout(Path file, YamlConfiguration configuration, String key, long minimum) throws IOException {
Path target = file.toRealPath();
Object previous = configuration.isSet(key) ? configuration.get(key) : null;
configuration.set(key, minimum);
try {
AtomicFileIO.writeString(target, configuration.saveToString());
} catch (IOException | RuntimeException failure) {
configuration.set(key, previous);
throw failure;
}
private static ServerTimeoutConfiguration timeoutConfiguration() {
return new ServerTimeoutConfiguration(IrisPlatforms.get().dataFolder().toPath().resolve("server-timeouts.json"));
}
static Path serverConfigurationOption(String name) throws ReflectiveOperationException, IOException {
@@ -964,4 +971,131 @@ public class ServerConfigurator {
}
}
static final class ServerTimeoutConfiguration {
private static final Object MUTATION_LOCK = new Object();
private static final Gson JSON = new GsonBuilder().setPrettyPrinting().serializeNulls().create();
private final Path recoveryFile;
ServerTimeoutConfiguration(Path recoveryFile) {
this.recoveryFile = Objects.requireNonNull(recoveryFile);
}
void raise(Path file, YamlConfiguration configuration, String key, long minimum) throws IOException {
synchronized (MUTATION_LOCK) {
Path target = file.toRealPath();
YamlConfiguration persisted = load(target);
Long original = value(persisted, key);
List<SavedTimeout> changes = readChanges();
SavedTimeout saved = find(changes, target, key);
if (saved == null || !Objects.equals(original, saved.original()) && !Objects.equals(original, saved.applied())) {
changes.remove(saved);
changes.add(new SavedTimeout(target.toString(), key, original, minimum));
writeChanges(changes);
}
Object previous = configuration.isSet(key) ? configuration.get(key) : null;
configuration.set(key, minimum);
try {
AtomicFileIO.writeString(target, configuration.saveToString());
} catch (IOException | RuntimeException failure) {
configuration.set(key, previous);
throw failure;
}
}
}
void restore(String key, Path liveFile, YamlConfiguration live) throws IOException {
synchronized (MUTATION_LOCK) {
List<SavedTimeout> changes = readChanges();
for (SavedTimeout saved : List.copyOf(changes)) {
if (!saved.key().equals(key)) {
continue;
}
Path target = Path.of(saved.file());
YamlConfiguration configuration = load(target);
if (Objects.equals(value(configuration, key), saved.applied())) {
configuration.set(key, saved.original());
AtomicFileIO.writeString(target, configuration.saveToString());
if (live != null && target.equals(liveFile.toRealPath())
&& Objects.equals(value(live, key), saved.applied())) {
live.set(key, saved.original());
}
IrisLogging.info("Restored " + target + " " + key + " to "
+ (saved.original() == null ? "its server default" : saved.original())
+ ". Takes effect after a server restart.");
} else {
IrisLogging.info("Kept the current " + target + " " + key
+ " because it differs from the value saved by Iris.");
}
changes.remove(saved);
writeChanges(changes);
}
}
}
private List<SavedTimeout> readChanges() throws IOException {
if (!Files.exists(recoveryFile)) {
return new ArrayList<>();
}
try {
SavedTimeouts saved = JSON.fromJson(Files.readString(recoveryFile), SavedTimeouts.class);
if (saved == null) {
throw new IOException("Empty server timeout recovery data: " + recoveryFile);
}
return new ArrayList<>(saved.changes());
} catch (JsonParseException | IllegalArgumentException exception) {
throw new IOException("Invalid server timeout recovery data: " + recoveryFile, exception);
}
}
private void writeChanges(List<SavedTimeout> changes) throws IOException {
AtomicFileIO.writeString(recoveryFile, JSON.toJson(new SavedTimeouts(changes)));
}
private static SavedTimeout find(List<SavedTimeout> changes, Path target, String key) {
for (SavedTimeout change : changes) {
if (change.file().equals(target.toString()) && change.key().equals(key)) {
return change;
}
}
return null;
}
private static YamlConfiguration load(Path file) throws IOException {
YamlConfiguration configuration = new YamlConfiguration();
try {
configuration.load(file.toFile());
} catch (InvalidConfigurationException exception) {
throw new IOException("Invalid server configuration: " + file, exception);
}
return configuration;
}
private static Long value(YamlConfiguration configuration, String key) throws IOException {
if (!configuration.isSet(key)) {
return null;
}
if (!configuration.isInt(key) && !configuration.isLong(key)) {
throw new IOException("Server timeout " + key + " must be an integer.");
}
return configuration.getLong(key);
}
private record SavedTimeout(String file, String key, Long original, long applied) {
private SavedTimeout {
Objects.requireNonNull(file, "file");
Objects.requireNonNull(key, "key");
if (!Path.of(file).isAbsolute() || applied <= 0
|| !key.equals("settings.timeout-time") && !key.equals("watchdog.early-warning-delay")) {
throw new IllegalArgumentException("Invalid saved server timeout");
}
}
}
private record SavedTimeouts(List<SavedTimeout> changes) {
private SavedTimeouts {
changes = List.copyOf(changes);
}
}
}
}
@@ -4,6 +4,7 @@ import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.volmlib.nativelib.terrain.structure.StructureCachePolicy;
import art.arcane.volmlib.nativelib.terrain.structure.StructureStagePolicy;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.mantle.DeferredPlacementRecords;
import art.arcane.iris.generation.terrain.IrisMaterialPalette;
import art.arcane.iris.structure.nativegen.GenerationWritePolicy;
import art.arcane.iris.structure.nativegen.IrisStructurePolicy;
@@ -13,6 +14,7 @@ import art.arcane.iris.structure.nativegen.NativeStructureStartPlan;
import art.arcane.iris.structure.nativegen.NativeStructureVolumeIndex;
import art.arcane.iris.structure.nativegen.StructureLocateSearch;
import art.arcane.iris.structure.placement.IrisStructureLocator;
import art.arcane.volmlib.nativelib.terrain.NativeBlockChangeSet;
import art.arcane.volmlib.nativelib.terrain.NativeBlockPositionPredicate;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.volmlib.nativelib.terrain.structure.StructureLocateSearchAccess;
@@ -103,6 +105,16 @@ public final class BukkitStructureStagePolicy implements StructureStagePolicy<
context.getDimensionStackContext(), context.getMinHeight());
}
@Override
public NativeBlockChangeSet trackBlockChanges(Engine context, NativeBlockChangeSet.Bounds bounds) {
return DeferredPlacementRecords.track(context, bounds);
}
@Override
public void invalidateBlockChanges(Engine context, NativeBlockChangeSet changes) {
DeferredPlacementRecords.invalidate(context, changes);
}
@Override
public IntBinaryOperator surfaceHeight(Engine context) {
return (x, z) -> Engine.hostHeight(context, x, z, true) + context.getMinHeight();
@@ -23,6 +23,9 @@ import art.arcane.iris.world.history.GenerationFindCatalog;
import art.arcane.iris.world.event.IrisLootEvent;
import art.arcane.iris.integration.Identifier;
import art.arcane.iris.integration.ExternalDataSVC;
import art.arcane.iris.generation.mantle.CustomBlockOwnership;
import art.arcane.iris.generation.mantle.DeferredPlacementRecords;
import art.arcane.volmlib.nativelib.terrain.NativeBlockPoint;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.GenerationSessionLease;
import art.arcane.iris.generation.locator.Locator;
@@ -36,6 +39,7 @@ import art.arcane.iris.world.loot.InventorySlotType;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.world.loot.IrisLootTable;
import art.arcane.iris.generation.terrain.IrisRegion;
import art.arcane.iris.generation.block.IrisCustomData;
import art.arcane.iris.generation.block.TileData;
import art.arcane.iris.platform.bukkit.BukkitBlockResolution;
import art.arcane.iris.platform.bukkit.BukkitBlockState;
@@ -83,7 +87,10 @@ import org.bukkit.loot.Lootable;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture;
@@ -133,48 +140,73 @@ public final class EngineBukkitOps {
Runnable tileTask = () -> materializeTiles(engine, c, chunk);
Runnable customTask = () -> materializeCustomBlocks(engine, c, chunk);
Runnable updateTask = () -> materializeUpdates(engine, c, chunk);
Runnable materialize = () -> {
DeferredPlacementRecords.removeInvalidated(engine, mantle, c.getX(), c.getZ());
runMaterializationPasses(chunk, tileTask, customTask, updateTask);
};
if (shouldRunChunkUpdateInline(c)) {
runMaterializationPasses(chunk, tileTask, customTask, updateTask);
materialize.run();
return;
}
runScheduledMaterializationPasses(c, () -> runMaterializationPasses(chunk, tileTask, customTask, updateTask));
runScheduledMaterializationPasses(c, materialize);
} finally {
chunk.release();
}
}
static void materializeTiles(Engine engine, Chunk chunk, MantleChunk<Matter> mantleChunk) {
mantleChunk.iterate(TileWrapper.class, (x, y, z, value) -> {
int worldY = y + engine.getWorld().minHeight();
StaleRecords stale = new StaleRecords();
for (DeferredPlacementRecords.Pending<TileWrapper> record : DeferredPlacementRecords.snapshot(mantleChunk, TileWrapper.class)) {
if (!DeferredPlacementRecords.isPending(mantleChunk, TileWrapper.class, record)) {
continue;
}
NativeBlockPoint position = record.position();
TileWrapper value = record.value();
int worldY = position.y() + engine.getWorld().minHeight();
if (worldY < chunk.getWorld().getMinHeight() || worldY >= chunk.getWorld().getMaxHeight()) {
return;
continue;
}
Block block = chunk.getBlock(position.x() & 15, worldY, position.z() & 15);
if (stale.tileReplaced(block, value.getData())) {
continue;
}
Block block = chunk.getBlock(x & 15, worldY, z & 15);
if (!TileData.setTileState(block, value.getData())) {
NamespacedKey blockTypeKey = KeyedType.getKey(block.getType());
String blockType = blockTypeKey == null ? block.getType().name() : blockTypeKey.toString();
String tileType = value.getData().getMaterialKey();
IrisLogging.warn("Failed to set tile entity data at [%d %d %d | %s] for tile %s!", block.getX(), block.getY(), block.getZ(), blockType, tileType);
}
});
}
mantleChunk.deleteSlices(TileWrapper.class);
stale.report("tile", chunk);
}
static void materializeCustomBlocks(Engine engine, Chunk chunk, MantleChunk<Matter> mantleChunk) {
mantleChunk.iterate(Identifier.class, (x, y, z, value) -> {
int worldY = y + engine.getWorld().minHeight();
if (worldY < chunk.getWorld().getMinHeight() || worldY >= chunk.getWorld().getMaxHeight()) {
return;
StaleRecords stale = new StaleRecords();
for (DeferredPlacementRecords.Pending<Identifier> record : DeferredPlacementRecords.snapshot(mantleChunk, Identifier.class)) {
if (!DeferredPlacementRecords.isPending(mantleChunk, Identifier.class, record)) {
continue;
}
IrisServices.get(ExternalDataSVC.class).processUpdate(
engine,
chunk.getBlock(x & 15, worldY, z & 15),
value
);
});
NativeBlockPoint position = record.position();
Identifier value = record.value();
int worldY = position.y() + engine.getWorld().minHeight();
if (worldY < chunk.getWorld().getMinHeight() || worldY >= chunk.getWorld().getMaxHeight()) {
continue;
}
ExternalDataSVC service = IrisServices.get(ExternalDataSVC.class);
Block block = chunk.getBlock(position.x() & 15, worldY, position.z() & 15);
if (stale.carrierReplaced(service, block, value)) {
continue;
}
CustomBlockOwnership.Entry ownership = service.processUpdate(engine, block, value);
if (ownership != null) {
DeferredPlacementRecords.remember(engine, mantleChunk, position, ownership);
}
}
mantleChunk.deleteSlices(Identifier.class);
stale.report("custom block", chunk);
}
static void materializeUpdates(Engine engine, Chunk chunk, MantleChunk<Matter> mantleChunk) {
@@ -855,4 +887,50 @@ public final class EngineBukkitOps {
}
}
private static final class StaleRecords {
private final Map<Identifier, Material> carriers = new HashMap<>();
private int skipped;
private boolean carrierReplaced(ExternalDataSVC service, Block block, Identifier record) {
Material carrier = carriers.get(record);
if (carrier == null) {
carrier = carrierType(service, record);
carriers.put(record, carrier);
}
Material live = block.getType();
if (carrier == Material.AIR || live == carrier
|| ((carrier == Material.FIRE || carrier == Material.SOUL_FIRE)
&& (live == Material.AIR || live == Material.CAVE_AIR || live == Material.VOID_AIR))) {
return false;
}
skipped++;
return true;
}
private boolean tileReplaced(Block block, TileData tile) {
if (tile.resolveMaterial() == null || tile.isApplicable(block.getBlockData())) {
return false;
}
skipped++;
return true;
}
private void report(String records, Chunk chunk) {
if (skipped == 0) {
return;
}
IrisLogging.debug("Skipped %d stale %s records at chunk %d,%d in %s",
skipped, records, chunk.getX(), chunk.getZ(), chunk.getWorld().getName());
}
private static Material carrierType(ExternalDataSVC service, Identifier record) {
Optional<BlockData> resolved = service.getBlockData(record);
if (resolved.isEmpty()) {
return Material.AIR;
}
BlockData data = resolved.get();
Material type = (data instanceof IrisCustomData custom ? custom.getBase() : data).getMaterial();
return type == Material.CAVE_AIR || type == Material.VOID_AIR ? Material.AIR : type;
}
}
}
@@ -1,7 +1,9 @@
package art.arcane.iris.structure.nativegen;
import art.arcane.volmlib.nativelib.terrain.NativeBlockPositionPredicate;
import art.arcane.volmlib.nativelib.terrain.NativeBlockChangeSet;
import art.arcane.iris.generation.mantle.DeferredPlacementRecords;
import art.arcane.iris.generation.runtime.DimensionStackContext;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.biome.IrisBiome;
@@ -48,6 +50,16 @@ public class ImportedFeaturePolicy implements NativeImportedFeaturePolicy<IrisDi
return NativeFeatureGenerationPolicy.control(engine);
}
@Override
public NativeBlockChangeSet trackedBlockPositions(NativeBlockChangeSet.Bounds bounds) {
return DeferredPlacementRecords.positions(engine, bounds);
}
@Override
public void invalidateBlockChanges(NativeBlockChangeSet changes) {
DeferredPlacementRecords.invalidate(engine, changes);
}
@Override
public Set<String> visibleBiomeKeys() {
Set<String> keys = new LinkedHashSet<>();
@@ -23,6 +23,7 @@ import art.arcane.iris.generation.block.TileData;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.volmlib.util.math.BlockPosition;
import org.jetbrains.annotations.Nullable;
public class DecayControlPlacer implements IObjectPlacer {
@@ -47,6 +48,16 @@ public class DecayControlPlacer implements IObjectPlacer {
delegate.set(x, y, z, d);
}
@Override
public boolean beginTreeCanopy(BlockPosition position) {
return delegate.beginTreeCanopy(position);
}
@Override
public void endTreeCanopy() {
delegate.endTreeCanopy();
}
@Override
public NativeBlockState get(int x, int y, int z) {
return delegate.get(x, y, z);
@@ -22,6 +22,7 @@ import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.block.TileData;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.volmlib.util.math.BlockPosition;
import art.arcane.volmlib.util.math.RNG;
public class HeightmapObjectPlacer implements IObjectPlacer {
@@ -47,6 +48,16 @@ public class HeightmapObjectPlacer implements IObjectPlacer {
oplacer.set(param1Int1, param1Int2, param1Int3, param1BlockData);
}
@Override
public boolean beginTreeCanopy(BlockPosition position) {
return oplacer.beginTreeCanopy(position);
}
@Override
public void endTreeCanopy() {
oplacer.endTreeCanopy();
}
public NativeBlockState get(int param1Int1, int param1Int2, int param1Int3) {
return oplacer.get(param1Int1, param1Int2, param1Int3);
}
@@ -24,6 +24,7 @@ import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.volmlib.util.math.BlockPosition;
import org.jetbrains.annotations.Nullable;
public interface IObjectPlacer {
@@ -35,6 +36,14 @@ public interface IObjectPlacer {
NativeBlockState get(int x, int y, int z);
default boolean beginTreeCanopy(BlockPosition position) {
NativeBlockState existing = get(position.getX(), position.getY(), position.getZ());
return existing == null || !existing.isSolid();
}
default void endTreeCanopy() {
}
boolean isPreventingDecay();
boolean isCarved(int x, int y, int z);
@@ -20,6 +20,7 @@ package art.arcane.iris.structure.object;
import art.arcane.iris.structure.object.FloatingObjectFootprint;
import art.arcane.iris.generation.block.TileData;
import art.arcane.iris.generation.decoration.tree.TreeGrounding;
import art.arcane.iris.generation.cave.CarveResult;
import art.arcane.iris.pack.value.IrisPosition;
@@ -144,6 +145,9 @@ public class IrisObject extends IrisRegistrant {
transient final AtomicCache<KList<IrisBlockVector>> surfaceSupportOffsets = new AtomicCache<>();
transient final AtomicCache<FloatingObjectFootprint> floatingFootprint = new AtomicCache<>();
@Getter
@Setter
private transient TreeGrounding treeGrounding;
@Getter
VectorMap<NativeBlockState> blocks;
@Getter
VectorMap<TileData> states;
@@ -203,6 +207,7 @@ public class IrisObject extends IrisRegistrant {
copy.setLoader(getLoader());
copy.setLoadFile(getLoadFile());
copy.setCenter(getCenter().clone());
copy.setTreeGrounding(treeGrounding);
blocks.forEach(copy.blocks::put);
states.forEach((position, tile) -> copy.states.put(position, tile.clone()));
@@ -349,6 +354,9 @@ public class IrisObject extends IrisRegistrant {
KList<IrisBlockVector> getSurfaceSupportOffsets() {
return surfaceSupportOffsets.aquire(() -> {
if (treeGrounding != null) {
return treeGrounding.supportOffsets();
}
readLock.lock();
try {
int lowestY = Integer.MAX_VALUE;
@@ -23,6 +23,7 @@ import art.arcane.iris.generation.block.TileData;
import art.arcane.iris.generation.cave.CarveResult;
import art.arcane.iris.generation.cave.CarvingMode;
import art.arcane.iris.generation.decoration.IrisProceduralBlocks;
import art.arcane.iris.generation.decoration.tree.TreeGrounding;
import art.arcane.iris.generation.decoration.IrisStiltSettings;
import art.arcane.iris.structure.object.IrisSurfaceSupport;
import art.arcane.iris.generation.decoration.IrisVacuumSettings;
@@ -331,6 +332,18 @@ final class IrisObjectPlacementRunner {
return -1;
}
TreeGrounding.Plan grounding = null;
if (self.getTreeGrounding() != null && yv < 0 && config.getCarvingSupport().supportsSurface()
&& !config.isForcePlace() && !config.isFromBottom() && !paint && !ceilingHang
&& config.getMode() != ObjectPlaceMode.FLOATING && !rawStructurePiece) {
grounding = self.getTreeGrounding().plan(placer, new TreeGrounding.Placement(rdata, config,
new BlockPosition(x, y + yrand, z), new BlockPosition(spinx, spiny, spinz)));
if (!grounding.supported()) {
return -1;
}
stilting = false;
}
IrisPaintSurfaceProjection paintProjection = null;
KList<IrisBlockVector> paintSupport = null;
if (paint) {
@@ -467,6 +480,7 @@ final class IrisObjectPlacementRunner {
try {
VectorMap<NativeBlockState> blocks = self.blocks;
VectorMap<TileData> states = self.states;
TreeGrounding tree = self.getTreeGrounding();
// Zero-tile objects are the common case: skip the two Key allocations VectorMap#get needs to prove null.
boolean hasStates = !states.isEmpty();
@@ -622,42 +636,53 @@ final class IrisObjectPlacementRunner {
continue;
}
data = normalizePlacedWaterlogging(data, placer, xx, yy, zz, waterlogCandidate);
if (!rawStructurePiece && B.isVineBlock(data)) {
data = attachVineFaces(placer, data, xx, yy, zz);
boolean canopy = tree != null && tree.isCanopy(g.getBlockX(), g.getBlockY(), g.getBlockZ());
if (canopy && !placer.beginTreeCanopy(new BlockPosition(xx, yy, zz))) {
continue;
}
// Short-circuit order matters for cost only: the mantle read is paid solely
// for vine blocks. Both operands are pure, so the value is unchanged.
boolean wouldReplace = !rawStructurePiece && B.isVineBlock(data) && B.isSolid(placer.get(xx, yy, zz));
String material = IrisObjectShaping.materialKey(data);
boolean air = material.equals("minecraft:air") || material.equals("minecraft:cave_air");
boolean place = shouldPlaceObjectBlock(rawStructurePiece, air, wouldReplace);
try {
data = normalizePlacedWaterlogging(data, placer, xx, yy, zz, waterlogCandidate);
if (data.isCustom() || place) {
placer.set(xx, yy, zz, data);
if (heightmap != null) {
Position2 pos = new Position2(xx, zz);
Integer currentHeight = heightmap.get(pos);
if (currentHeight == null || currentHeight < yy) {
heightmap.put(pos, yy);
if (!rawStructurePiece && B.isVineBlock(data)) {
data = attachVineFaces(placer, data, xx, yy, zz);
}
// Short-circuit order matters for cost only: the mantle read is paid solely
// for vine blocks. Both operands are pure, so the value is unchanged.
boolean wouldReplace = !rawStructurePiece && B.isVineBlock(data) && B.isSolid(placer.get(xx, yy, zz));
String material = IrisObjectShaping.materialKey(data);
boolean air = material.equals("minecraft:air") || material.equals("minecraft:cave_air");
boolean place = shouldPlaceObjectBlock(rawStructurePiece, air, wouldReplace);
if (data.isCustom() || place) {
placer.set(xx, yy, zz, data);
if (heightmap != null) {
Position2 pos = new Position2(xx, zz);
Integer currentHeight = heightmap.get(pos);
if (currentHeight == null || currentHeight < yy) {
heightmap.put(pos, yy);
}
}
if (tile != null) {
placer.setTile(xx, yy, zz, tile);
}
if (markers != null && markers.containsKey(g)) {
placer.setData(xx, yy, zz, new MatterMarker(markers.get(g)));
}
if (listener != null) {
listener.accept(new BlockPosition(xx, yy, zz), data);
}
if (vacuuming && yy < vacuumLowest) {
vacuumLowest = yy;
}
if (vacuuming && yy > vacuumHighest) {
vacuumHighest = yy;
}
}
if (tile != null) {
placer.setTile(xx, yy, zz, tile);
}
if (markers != null && markers.containsKey(g)) {
placer.setData(xx, yy, zz, new MatterMarker(markers.get(g)));
}
if (listener != null) {
listener.accept(new BlockPosition(xx, yy, zz), data);
}
if (vacuuming && yy < vacuumLowest) {
vacuumLowest = yy;
}
if (vacuuming && yy > vacuumHighest) {
vacuumHighest = yy;
} finally {
if (canopy) {
placer.endTreeCanopy();
}
}
}
@@ -665,6 +690,19 @@ final class IrisObjectPlacementRunner {
self.readLock.unlock();
}
if (grounding != null) {
for (TreeGrounding.Extension extension : grounding.extensions()) {
for (int rootY = extension.topY(); rootY >= extension.bottomY(); rootY--) {
NativeBlockState rootState = normalizePlacedWaterlogging(extension.state(), placer,
extension.x(), rootY, extension.z(), waterlogCandidate);
placer.set(extension.x(), rootY, extension.z(), rootState);
if (listener != null) {
listener.accept(new BlockPosition(extension.x(), rootY, extension.z()), rootState);
}
}
}
}
if (stilting) {
self.readLock.lock();
try {
@@ -20,6 +20,8 @@ package art.arcane.iris.structure.object;
import art.arcane.iris.generation.block.TileData;
import art.arcane.iris.generation.decoration.IrisProceduralBlocks;
import art.arcane.iris.generation.decoration.tree.TreeBlockCanvas;
import art.arcane.iris.generation.decoration.tree.TreeGrounding;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
@@ -29,6 +31,9 @@ import art.arcane.volmlib.util.math.Vector3i;
import art.arcane.volmlib.util.format.Form;
import art.arcane.volmlib.util.scheduling.PrecisionStopwatch;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
@@ -264,6 +269,19 @@ final class IrisObjectShaping {
self.shrinkOffset = offset;
self.blocks = b;
self.states = s;
if (self.getTreeGrounding() != null) {
List<TreeGrounding.Root> roots = new ArrayList<>();
for (TreeGrounding.Root root : self.getTreeGrounding().roots()) {
roots.add(new TreeGrounding.Root(root.x() + offset.getX(), root.y() + offset.getY(),
root.z() + offset.getZ(), root.state()));
}
Set<TreeBlockCanvas.Vec> canopy = new HashSet<>();
for (TreeBlockCanvas.Vec leaf : self.getTreeGrounding().canopy()) {
canopy.add(new TreeBlockCanvas.Vec(leaf.x() + offset.getX(), leaf.y() + offset.getY(),
leaf.z() + offset.getZ()));
}
self.setTreeGrounding(TreeGrounding.create(roots, self.getTreeGrounding().maxExtension(), canopy));
}
self.surfaceSupportOffsets.reset();
self.floatingFootprint.reset();
}
@@ -23,6 +23,8 @@ import art.arcane.iris.pack.value.IrisPosition;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.iris.generation.block.B;
import art.arcane.iris.generation.decoration.tree.TreeBlockCanvas;
import art.arcane.iris.generation.decoration.tree.TreeGrounding;
import art.arcane.iris.generation.block.VectorMap;
import art.arcane.iris.generation.geometry.AxisAlignedBB;
import art.arcane.iris.generation.geometry.IrisBlockVector;
@@ -33,10 +35,12 @@ import art.arcane.volmlib.util.interpolation.Interpolation3D;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
/**
* Geometric transforms for {@link IrisObject}: rotation, scaling and the interpolated upscalers.
@@ -56,6 +60,8 @@ final class IrisObjectTransforms {
try {
VectorMap<NativeBlockState> d = new VectorMap<>();
VectorMap<TileData> dx = new VectorMap<>();
TreeGrounding grounding = self.getTreeGrounding();
Set<TreeBlockCanvas.Vec> canopy = grounding == null ? null : new HashSet<>();
for (Map.Entry<IrisBlockVector, NativeBlockState> entry : self.blocks) {
NativeBlockState rotated = r.rotate(entry.getValue(), spinx, spiny, spinz);
@@ -64,6 +70,16 @@ final class IrisObjectTransforms {
}
IrisBlockVector destination = r.rotate(entry.getKey(), spinx, spiny, spinz);
d.put(destination, rotated);
if (canopy != null) {
TreeBlockCanvas.Vec cell = new TreeBlockCanvas.Vec(destination.getBlockX(),
destination.getBlockY(), destination.getBlockZ());
IrisBlockVector source = entry.getKey();
if (grounding.isCanopy(source.getBlockX(), source.getBlockY(), source.getBlockZ())) {
canopy.add(cell);
} else {
canopy.remove(cell);
}
}
TileData tile = self.states.get(entry.getKey());
if (tile == null) {
dx.remove(destination);
@@ -74,6 +90,17 @@ final class IrisObjectTransforms {
self.blocks = d;
self.states = dx;
if (grounding != null) {
List<TreeGrounding.Root> roots = new ArrayList<>();
for (TreeGrounding.Root root : grounding.roots()) {
IrisBlockVector position = r.rotate(new IrisBlockVector(root.x(), root.y(), root.z()), spinx, spiny, spinz);
NativeBlockState state = d.get(position);
if (state != null) {
roots.add(new TreeGrounding.Root(position.getBlockX(), position.getBlockY(), position.getBlockZ(), state));
}
}
self.setTreeGrounding(TreeGrounding.create(roots, grounding.maxExtension(), canopy));
}
IrisObjectShaping.shrinkwrap(self);
self.surfaceSupportOffsets.reset();
self.floatingFootprint.reset();
@@ -99,6 +126,8 @@ final class IrisObjectTransforms {
boolean savedOrigin = origin == ScaleOrigin.SAVED;
List<ScaledVoxel> voxels;
IrisObject output;
TreeGrounding grounding;
Vector3i sourceCenter;
self.readLock.lock();
try {
output = savedOrigin ? createOriginScaledObject(self, scale)
@@ -107,7 +136,8 @@ final class IrisObjectTransforms {
output.setLoader(self.getLoader());
output.setLoadFile(self.getLoadFile());
voxels = new ArrayList<>(self.blocks.size());
Vector3i sourceCenter = self.getCenter();
sourceCenter = self.getCenter().clone();
grounding = self.getTreeGrounding();
IrisBlockVector targetCenter = new IrisBlockVector(output.getCenter().getX(),
output.getCenter().getY(), output.getCenter().getZ());
for (Map.Entry<IrisBlockVector, NativeBlockState> entry : self.blocks) {
@@ -124,7 +154,8 @@ final class IrisObjectTransforms {
}
TileData tile = self.states.get(sourcePosition);
voxels.add(new ScaledVoxel(sourcePosition.clone(), minimum, maximum, entry.getValue(),
tile == null ? null : tile.clone()));
tile == null ? null : tile.clone(), grounding != null && grounding.isCanopy(
sourcePosition.getBlockX(), sourcePosition.getBlockY(), sourcePosition.getBlockZ())));
}
} finally {
self.readLock.unlock();
@@ -134,8 +165,9 @@ final class IrisObjectTransforms {
.thenComparingInt(voxel -> voxel.source().getBlockY())
.thenComparingInt(voxel -> voxel.source().getBlockZ()));
}
Set<TreeBlockCanvas.Vec> canopy = grounding == null ? null : new HashSet<>();
for (ScaledVoxel voxel : voxels) {
writeScaledVoxel(output, voxel);
writeScaledVoxel(output, voxel, canopy);
}
VectorMap<NativeBlockState> scaledBlocks = output.blocks;
@@ -147,10 +179,43 @@ final class IrisObjectTransforms {
}
}
removeInapplicableTiles(output, scaledBlocks);
if (grounding != null) {
canopy.removeIf(cell -> !output.blocks.containsKey(new IrisBlockVector(cell.x(), cell.y(), cell.z())));
scaleRoots(grounding, sourceCenter, output, scale, savedOrigin, canopy);
}
return output;
}
private static void writeScaledVoxel(IrisObject output, ScaledVoxel voxel) {
private static void scaleRoots(TreeGrounding grounding, Vector3i sourceCenter, IrisObject output,
double scale, boolean savedOrigin, Set<TreeBlockCanvas.Vec> canopy) {
List<TreeGrounding.Root> roots = new ArrayList<>();
Vector3i targetCenter = output.getCenter();
for (TreeGrounding.Root root : grounding.roots()) {
IrisBlockVector position = new IrisBlockVector(root.x(), root.y(), root.z());
if (!savedOrigin) {
position.add(new IrisBlockVector(sourceCenter.getX(), sourceCenter.getY(), sourceCenter.getZ()));
}
IrisBlockVector minimum = scaledMinimum(position, scale);
IrisBlockVector maximum = scaledMaximum(position, scale);
if (!savedOrigin) {
IrisBlockVector offset = new IrisBlockVector(targetCenter.getX(), targetCenter.getY(), targetCenter.getZ());
minimum.subtract(offset);
maximum.subtract(offset);
}
int y = minimum.getBlockY();
for (int x = minimum.getBlockX(); x <= maximum.getBlockX(); x++) {
for (int z = minimum.getBlockZ(); z <= maximum.getBlockZ(); z++) {
NativeBlockState state = output.blocks.get(new IrisBlockVector(x, y, z));
if (state != null) {
roots.add(new TreeGrounding.Root(x, y, z, state));
}
}
}
}
output.setTreeGrounding(TreeGrounding.create(roots, (int) Math.ceil(grounding.maxExtension() * scale), canopy));
}
private static void writeScaledVoxel(IrisObject output, ScaledVoxel voxel, Set<TreeBlockCanvas.Vec> canopy) {
IrisBlockVector minimum = voxel.minimum();
IrisBlockVector maximum = voxel.maximum();
IrisBlockVector position = minimum.clone();
@@ -161,6 +226,14 @@ final class IrisObjectTransforms {
for (int y = minimum.getBlockY(); y <= maximum.getBlockY(); y++) {
position.setY(y);
output.blocks.put(position, voxel.block());
if (canopy != null) {
TreeBlockCanvas.Vec cell = new TreeBlockCanvas.Vec(x, y, z);
if (voxel.canopy()) {
canopy.add(cell);
} else {
canopy.remove(cell);
}
}
if (voxel.tile() == null) {
output.states.remove(position);
} else {
@@ -468,7 +541,7 @@ final class IrisObjectTransforms {
}
private record ScaledVoxel(IrisBlockVector source, IrisBlockVector minimum, IrisBlockVector maximum,
NativeBlockState block, TileData tile) {
NativeBlockState block, TileData tile, boolean canopy) {
}
private enum ScaleOrigin {
@@ -28,11 +28,14 @@ import art.arcane.iris.generation.chunk.TerrainChunkBiomeHunkView;
import art.arcane.iris.platform.generation.EngineBukkitOps;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.mantle.EngineMantle;
import art.arcane.iris.generation.mantle.DeferredPlacementRecords;
import art.arcane.volmlib.nativelib.terrain.NativeBiome;
import art.arcane.iris.generation.concurrent.MultiBurst;
import art.arcane.iris.platform.bukkit.plugin.VolmitSender;
import art.arcane.iris.world.task.J;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
import art.arcane.volmlib.util.matter.Matter;
import org.bukkit.Chunk;
import org.bukkit.ChunkSnapshot;
import org.bukkit.Material;
@@ -96,11 +99,12 @@ public final class InPlaceChunkRegenerator {
private void resetMantleMargin() {
EngineMantle engineMantle = engine.getMantle();
Mantle mantle = engineMantle.getMantle();
Mantle<Matter> mantle = engineMantle.getMantle();
int margin = radius + Math.max(engineMantle.getRadius(), engineMantle.getRealRadius()) + 1;
for (int dx = -margin; dx <= margin; dx++) {
for (int dz = -margin; dz <= margin; dz++) {
mantle.deleteChunk(centerChunkX + dx, centerChunkZ + dz);
DeferredPlacementRecords.resetMantleChunk(mantle, centerChunkX + dx, centerChunkZ + dz,
Math.abs(dx) <= radius && Math.abs(dz) <= radius);
}
}
}
@@ -188,7 +192,14 @@ public final class InPlaceChunkRegenerator {
}
}
EngineBukkitOps.updateChunk(engine, chunk);
Mantle<Matter> mantle = engine.getMantle().getMantle();
MantleChunk<Matter> records = mantle.useChunk(chunkX, chunkZ);
try {
DeferredPlacementRecords.invalidateCustomBlockOwnership(records);
} finally {
records.release();
}
EngineBukkitOps.updateChunk(engine, chunk, mantle);
world.refreshChunk(chunkX, chunkZ);
}
@@ -0,0 +1,53 @@
package art.arcane.iris.world.storage.matter;
import art.arcane.iris.generation.mantle.CustomBlockOwnership;
import art.arcane.iris.integration.Identifier;
import art.arcane.volmlib.util.data.palette.Palette;
import art.arcane.volmlib.util.matter.Sliced;
import art.arcane.volmlib.util.matter.slices.RawMatter;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
@Sliced
public final class CustomBlockOwnershipMatter extends RawMatter<CustomBlockOwnership> {
public CustomBlockOwnershipMatter() {
this(1, 1, 1);
}
public CustomBlockOwnershipMatter(int width, int height, int depth) {
super(width, height, depth, CustomBlockOwnership.class);
}
@Override
public Palette<CustomBlockOwnership> getGlobalPalette() {
return null;
}
@Override
public void writeNode(CustomBlockOwnership ownership, DataOutputStream output) throws IOException {
output.writeInt(ownership.entries().size());
for (CustomBlockOwnership.Entry entry : ownership.entries()) {
output.writeUTF(entry.identifier().toString());
output.writeUTF(entry.token());
output.writeBoolean(entry.invalidated());
}
}
@Override
public CustomBlockOwnership readNode(DataInputStream input) throws IOException {
int size = input.readInt();
if (size < 0 || size > 65_536) {
throw new IOException("Invalid custom block ownership count: " + size);
}
List<CustomBlockOwnership.Entry> entries = new ArrayList<>(size);
for (int index = 0; index < size; index++) {
entries.add(new CustomBlockOwnership.Entry(Identifier.fromString(input.readUTF()),
input.readUTF(), input.readBoolean()));
}
return new CustomBlockOwnership(entries);
}
}
@@ -40,7 +40,9 @@ public final class IrisMatterSupport {
}
IrisMatter.registerSliceType(new EntityMatter());
IrisMatter.registerSliceType(new CustomBlockOwnershipMatter());
IrisMatter.registerSliceType(new ObjectContinuationMatter());
IrisMatter.registerSliceType(new TreeCanopyMatter());
IrisMatter.registerSliceType("art.arcane.iris.core.link.Identifier", new IdentifierMatter());
IrisMatter.registerSliceType("art.arcane.iris.engine.framework.NativeStructureOwnershipBundle", new NativeStructureOwnershipMatter());
IrisMatter.registerSliceType(new PlatformBlockMatter());
@@ -0,0 +1,39 @@
package art.arcane.iris.world.storage.matter;
import art.arcane.iris.generation.mantle.TreeCanopy;
import art.arcane.volmlib.util.data.palette.Palette;
import art.arcane.volmlib.util.matter.Sliced;
import art.arcane.volmlib.util.matter.slices.RawMatter;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
@Sliced
public final class TreeCanopyMatter extends RawMatter<TreeCanopy> {
public TreeCanopyMatter() {
this(1, 1, 1);
}
public TreeCanopyMatter(int width, int height, int depth) {
super(width, height, depth, TreeCanopy.class);
}
@Override
public Palette<TreeCanopy> getGlobalPalette() {
return null;
}
@Override
public void writeNode(TreeCanopy canopy, DataOutputStream output) throws IOException {
output.writeByte(1);
}
@Override
public TreeCanopy readNode(DataInputStream input) throws IOException {
if (input.readUnsignedByte() != 1) {
throw new IOException("Invalid tree canopy placement condition");
}
return TreeCanopy.INSTANCE;
}
}
@@ -0,0 +1,62 @@
package art.arcane.iris.generation.decoration.tree;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class TreeCanopyBuilderTest {
@Test
public void laterBranchesGrowThroughEarlierLeafClusters() {
IrisProceduralTree tree = branchedTree();
TreeBlockCanvas canvas = new TreeBlockCanvas();
double[][] offsets = TreeTrunkBuilder.build(canvas, tree, 6).getFirst().offsets();
TreeCanopyBuilder.build(canvas, tree, 6, offsets, 0, 42L, null);
for (int y = 0; y < 6; y++) {
for (int z = 0; z <= 4; z++) {
assertTrue(canvas.getTrunk().contains(new TreeBlockCanvas.Vec(0, y, z)));
assertFalse(canvas.getLeaf().contains(new TreeBlockCanvas.Vec(0, y, z)));
}
}
}
@Test
public void branchWoodReplacesBothLeafRolesButKeepsWoodAndAccents() {
IrisProceduralTree tree = branchedTree();
TreeBlockCanvas canvas = new TreeBlockCanvas();
double[][] offsets = TreeTrunkBuilder.build(canvas, tree, 6).getFirst().offsets();
canvas.setLeaf(0, 1, 1, TreeBlockCanvas.Role.LEAF);
canvas.setLeaf(0, 1, 2, TreeBlockCanvas.Role.SECONDARY_LEAF);
canvas.setTrunk(0, 1, 3, TreeBlockCanvas.Role.SECONDARY_TRUNK, TreeBlockCanvas.Axis.X);
canvas.setDecor(0, 1, 4, 0, "north");
TreeCanopyBuilder.build(canvas, tree, 6, offsets, 0, 42L, null);
assertEquals(TreeBlockCanvas.Role.TRUNK, canvas.get(0, 1, 1).role());
assertEquals(TreeBlockCanvas.Role.TRUNK, canvas.get(0, 1, 2).role());
assertEquals(TreeBlockCanvas.Role.SECONDARY_TRUNK, canvas.get(0, 1, 3).role());
assertEquals(TreeBlockCanvas.Axis.X, canvas.get(0, 1, 3).axis());
assertEquals(TreeBlockCanvas.Role.DECORATOR, canvas.get(0, 1, 4).role());
assertFalse(canvas.getLeaf().contains(new TreeBlockCanvas.Vec(0, 1, 1)));
assertFalse(canvas.getLeaf().contains(new TreeBlockCanvas.Vec(0, 1, 2)));
}
private static IrisProceduralTree branchedTree() {
IrisProceduralTree tree = new IrisProceduralTree();
IrisTreeBranches branches = new IrisTreeBranches();
branches.setProbabilityFunction(IrisTreeBranchProbability.CONSTANT);
branches.setProbabilityConstant(1);
branches.setLengthFunction(IrisTreeFunction.CONSTANT);
branches.setLengthConstant(4);
branches.setAzimuthMode(IrisTreeAzimuthMode.CONSTANT);
branches.setAzimuth(0);
branches.setElevation(0);
branches.setClusterRadius(2);
branches.setClusterMode(IrisTreeLeafMode.FILLED);
tree.getCanopy().setBranches(branches);
return tree;
}
}
@@ -0,0 +1,218 @@
package art.arcane.iris.generation.decoration.tree;
import org.junit.Test;
import java.util.ArrayDeque;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class TreeRootBuilderTest {
@Test
public void everyRootHasAFaceConnectedPathToExistingWood() {
for (IrisTreeRootStyle style : IrisTreeRootStyle.values()) {
for (int width : new int[]{1, 2, 5}) {
for (long seed : new long[]{1L, 42L, 5923L}) {
IrisProceduralTree tree = new IrisProceduralTree();
tree.setRootStyle(style);
tree.setTrunkWidth(width);
tree.setRootDepth(8);
tree.setRootFlare(7);
tree.setLeanAngle(40);
tree.setLeanAzimuth(45);
TreeBlockCanvas canvas = trunk(tree, 24);
Set<TreeBlockCanvas.Vec> original = new HashSet<>(canvas.getTrunk());
TreeRootBuilder.build(canvas, tree, 24, seed);
assertAllRootsConnected(canvas, original);
}
}
}
}
@Test
public void automaticFlareRemainsCompactForTallThinTrees() {
IrisProceduralTree tree = new IrisProceduralTree();
for (int height : new int[]{8, 30, 80}) {
TreeBlockCanvas canvas = trunk(tree, height);
TreeRootBuilder.build(canvas, tree, height, 42L);
for (TreeBlockCanvas.Vec cell : canvas.getTrunk()) {
if (cell.y() < 0) {
assertTrue(Math.abs(cell.x()) <= 2);
assertTrue(Math.abs(cell.z()) <= 2);
}
}
}
}
@Test
public void taprootNarrowsToOneCellWithoutExpandingTheBase() {
IrisProceduralTree tree = new IrisProceduralTree();
tree.setTrunkWidth(5);
tree.setRootDepth(7);
tree.setRootStyle(IrisTreeRootStyle.TAPROOT);
TreeBlockCanvas canvas = trunk(tree, 20);
Set<TreeBlockCanvas.Vec> original = new HashSet<>(canvas.getTrunk());
TreeRootBuilder.build(canvas, tree, 20, 42L);
int previousSize = 25;
for (int depth = 1; depth <= 7; depth++) {
int size = 0;
for (TreeBlockCanvas.Vec cell : canvas.getTrunk()) {
if (cell.y() == -depth) {
size++;
assertTrue(original.contains(new TreeBlockCanvas.Vec(cell.x(), 0, cell.z())));
}
}
assertTrue(size > 0);
assertTrue(size <= previousSize);
previousSize = size;
}
assertEquals(1, previousSize);
}
@Test
public void buttressTipsEndAtDifferentDepths() {
IrisProceduralTree tree = new IrisProceduralTree();
tree.setRootDepth(8);
tree.setRootFlare(7);
TreeBlockCanvas canvas = trunk(tree, 20);
TreeRootBuilder.build(canvas, tree, 20, 42L);
Map<TreeBlockCanvas.Vec, Integer> outerDepths = new HashMap<>();
for (TreeBlockCanvas.Vec cell : canvas.getTrunk()) {
if (cell.y() < 0 && Math.hypot(cell.x(), cell.z()) >= 5) {
TreeBlockCanvas.Vec column = new TreeBlockCanvas.Vec(cell.x(), 0, cell.z());
outerDepths.merge(column, cell.y(), Math::min);
}
}
assertTrue(new HashSet<>(outerDepths.values()).size() > 1);
}
@Test
public void explicitFlareKeepsWideStiltRoots() {
IrisProceduralTree tree = new IrisProceduralTree();
tree.setRootStyle(IrisTreeRootStyle.STILT);
tree.setRootFlare(8);
tree.setRootDepth(5);
TreeBlockCanvas canvas = trunk(tree, 24);
Set<TreeBlockCanvas.Vec> original = new HashSet<>(canvas.getTrunk());
TreeRootBuilder.build(canvas, tree, 24, 42L);
double reach = 0;
boolean aboveGround = false;
for (TreeBlockCanvas.Vec cell : canvas.getTrunk()) {
if (!original.contains(cell)) {
reach = Math.max(reach, Math.hypot(cell.x(), cell.z()));
aboveGround |= cell.y() > 0;
}
}
assertTrue(reach >= 6);
assertTrue(aboveGround);
assertAllRootsConnected(canvas, original);
}
@Test
public void stiltRootsAttachToTheActualLeaningTrunk() {
IrisProceduralTree tree = new IrisProceduralTree();
tree.setRootStyle(IrisTreeRootStyle.STILT);
tree.setLeanAngle(55);
tree.setLeanAzimuth(90);
tree.setRootFlare(3);
TreeBlockCanvas canvas = trunk(tree, 24);
Set<TreeBlockCanvas.Vec> original = new HashSet<>(canvas.getTrunk());
TreeRootBuilder.build(canvas, tree, 24, 42L);
boolean attachedAboveGround = false;
for (TreeBlockCanvas.Vec cell : canvas.getTrunk()) {
if (!original.contains(cell) && cell.y() >= 4) {
for (TreeBlockCanvas.Vec neighbor : neighbors(cell)) {
attachedAboveGround |= original.contains(neighbor);
}
}
}
assertTrue(attachedAboveGround);
assertAllRootsConnected(canvas, original);
}
@Test
public void intersectingFoliageDoesNotSeverRoots() {
IrisProceduralTree tree = new IrisProceduralTree();
tree.setRootDepth(5);
tree.setRootFlare(5);
TreeBlockCanvas canvas = trunk(tree, 20);
Set<TreeBlockCanvas.Vec> original = new HashSet<>(canvas.getTrunk());
for (int x = -5; x <= 5; x++) {
for (int z = -5; z <= 5; z++) {
canvas.setLeaf(x, -1, z, TreeBlockCanvas.Role.LEAF);
}
}
TreeRootBuilder.build(canvas, tree, 20, 42L);
assertAllRootsConnected(canvas, original);
assertEquals(TreeBlockCanvas.Role.TRUNK, canvas.get(0, -1, 0).role());
}
@Test
public void rootGeometryIsDeterministic() {
for (IrisTreeRootStyle style : IrisTreeRootStyle.values()) {
IrisProceduralTree tree = new IrisProceduralTree();
tree.setRootStyle(style);
tree.setTrunkWidth(2);
TreeBlockCanvas first = trunk(tree, 30);
TreeBlockCanvas second = trunk(tree, 30);
TreeRootBuilder.build(first, tree, 30, 5923L);
TreeRootBuilder.build(second, tree, 30, 5923L);
assertEquals(first.getCells(), second.getCells());
}
}
private static TreeBlockCanvas trunk(IrisProceduralTree tree, int height) {
TreeBlockCanvas canvas = new TreeBlockCanvas();
TreeTrunkBuilder.build(canvas, tree, height);
return canvas;
}
private static void assertAllRootsConnected(TreeBlockCanvas canvas, Set<TreeBlockCanvas.Vec> original) {
Set<TreeBlockCanvas.Vec> reached = new HashSet<>(original);
ArrayDeque<TreeBlockCanvas.Vec> pending = new ArrayDeque<>(original);
while (!pending.isEmpty()) {
TreeBlockCanvas.Vec cell = pending.removeFirst();
for (TreeBlockCanvas.Vec neighbor : neighbors(cell)) {
if (canvas.getTrunk().contains(neighbor) && reached.add(neighbor)) {
pending.addLast(neighbor);
}
}
}
Set<TreeBlockCanvas.Vec> roots = new HashSet<>(canvas.getTrunk());
roots.removeAll(original);
assertFalse(roots.isEmpty());
assertEquals(canvas.getTrunk(), reached);
}
private static TreeBlockCanvas.Vec[] neighbors(TreeBlockCanvas.Vec cell) {
return new TreeBlockCanvas.Vec[]{
new TreeBlockCanvas.Vec(cell.x() - 1, cell.y(), cell.z()),
new TreeBlockCanvas.Vec(cell.x() + 1, cell.y(), cell.z()),
new TreeBlockCanvas.Vec(cell.x(), cell.y() - 1, cell.z()),
new TreeBlockCanvas.Vec(cell.x(), cell.y() + 1, cell.z()),
new TreeBlockCanvas.Vec(cell.x(), cell.y(), cell.z() - 1),
new TreeBlockCanvas.Vec(cell.x(), cell.y(), cell.z() + 1)
};
}
}
@@ -28,9 +28,11 @@ import art.arcane.iris.generation.block.TileData;
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveAction;
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCell;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.volmlib.util.math.BlockPosition;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertSame;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
@@ -42,6 +44,26 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class CaveObjectPlacementTransactionTest {
@Test
public void canopyCommitRechecksSolidsAndSkipsAssociatedMetadata() {
IObjectPlacer delegate = createPlacer(128, 80, 20, 60);
CaveObjectPlacementTransaction transaction = new CaveObjectPlacementTransaction(delegate, 20, 10);
NativeBlockState canopy = mock(NativeBlockState.class);
NativeBlockState trunk = mock(NativeBlockState.class);
when(trunk.isSolid()).thenReturn(true);
when(delegate.beginTreeCanopy(any())).thenCallRealMethod();
assertTrue(transaction.beginTreeCanopy(new BlockPosition(4, 30, 7)));
transaction.set(4, 30, 7, canopy);
transaction.setData(4, 30, 7, "canopy");
transaction.endTreeCanopy();
when(delegate.get(4, 30, 7)).thenReturn(trunk);
assertEquals(CaveObjectPlacementTransaction.CommitResult.COMMITTED, transaction.commit());
verify(delegate, never()).set(4, 30, 7, canopy);
verify(delegate, never()).setData(4, 30, 7, "canopy");
}
@Test
public void validPlacementCommitsBlocksTilesAndMetadataTogether() {
IObjectPlacer delegate = createPlacer(128, 80, 20, 60);
@@ -0,0 +1,154 @@
package art.arcane.iris.generation.mantle;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.IrisEngineMantle;
import art.arcane.iris.integration.Identifier;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.testsupport.PlatformLeakGuard;
import art.arcane.iris.world.IrisWorld;
import art.arcane.iris.world.storage.matter.IrisMatterSupport;
import art.arcane.iris.world.storage.matter.PreObjectMatterTest;
import art.arcane.volmlib.nativelib.terrain.NativeBlockPoint;
import art.arcane.volmlib.util.mantle.flag.MantleFlag;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
import art.arcane.volmlib.util.mantle.runtime.MantleHooks;
import art.arcane.volmlib.util.matter.Matter;
import org.junit.Before;
import org.junit.ClassRule;
import org.junit.Test;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class DeferredPlacementRegenerationTest {
@ClassRule
public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean();
private static final Identifier ID = Identifier.fromString("craftengine:example/chair");
private static final CustomBlockOwnership.Entry ORIGINAL = new CustomBlockOwnership.Entry(ID, "original", false);
private static final NativeBlockPoint POSITION = new NativeBlockPoint(3, 70, 5);
private Mantle<Matter> mantle;
private MantleChunk<Matter> chunk;
@Before
@SuppressWarnings("unchecked")
public void setUp() {
PreObjectMatterTest.setUpBukkit();
IrisMatterSupport.ensureRegistered();
chunk = spy(new MantleChunk<>(24, 1, -1,
IrisEngineMantle.createRuntimeDataAdapter(mock(IrisData.class)), MantleHooks.NONE));
mantle = mock(Mantle.class);
when(mantle.useChunk(1, -1)).thenAnswer(invocation -> chunk.use());
chunk.getOrCreate(POSITION.y() >> 4).slice(CustomBlockOwnership.class)
.set(POSITION.x(), POSITION.y() & 15, POSITION.z(), new CustomBlockOwnership(List.of(ORIGINAL)));
chunk.getOrCreate(POSITION.y() >> 4).slice(Identifier.class)
.set(POSITION.x(), POSITION.y() & 15, POSITION.z(), ID);
chunk.flag(MantleFlag.OBJECT, true);
chunk.flag(MantleFlag.of(200), true);
chunk.flag(MantleFlag.TILE, true);
chunk.flag(MantleFlag.CUSTOM, true);
chunk.flag(MantleFlag.UPDATE, true);
chunk.flag(MantleFlag.ETCHED, true);
chunk.flag(MantleFlag.INITIAL_SPAWNED_MARKER, true);
}
@Test
public void marginResetKeepsOwnershipAndPreventsDuplicateMaterialization() {
CustomBlockOwnership before = ownership();
DeferredPlacementRecords.resetMantleChunk(mantle, 1, -1, false);
assertSame(before, ownership());
assertNull(chunk.get(POSITION.x(), POSITION.y(), POSITION.z(), Identifier.class));
assertFalse(chunk.isFlagged(MantleFlag.OBJECT));
assertFalse(chunk.isFlagged(MantleFlag.of(200)));
assertTrue(chunk.isFlagged(MantleFlag.TILE));
assertTrue(chunk.isFlagged(MantleFlag.CUSTOM));
assertTrue(chunk.isFlagged(MantleFlag.UPDATE));
assertTrue(chunk.isFlagged(MantleFlag.INITIAL_SPAWNED_MARKER));
AtomicInteger placements = new AtomicInteger();
chunk.raiseFlagUnchecked(MantleFlag.ETCHED, placements::incrementAndGet);
assertEquals(0, placements.get());
verify(mantle, never()).deleteChunk(1, -1);
}
@Test
public void targetResetKeepsOwnershipUntilSuccessfulLiveReplacement() {
DeferredPlacementRecords.resetMantleChunk(mantle, 1, -1, true);
assertEquals(List.of(ORIGINAL), ownership().entries());
assertFalse(chunk.isFlagged(MantleFlag.TILE));
assertFalse(chunk.isFlagged(MantleFlag.CUSTOM));
assertFalse(chunk.isFlagged(MantleFlag.UPDATE));
assertFalse(chunk.isFlagged(MantleFlag.ETCHED));
assertFalse(chunk.isFlagged(MantleFlag.INITIAL_SPAWNED_MARKER));
}
@Test(timeout = 5000)
public void successfulTargetReplacementInvalidatesOldTokensWithoutDeletingNewRecords() {
DeferredPlacementRecords.resetMantleChunk(mantle, 1, -1, true);
chunk.getOrCreate(POSITION.y() >> 4).slice(Identifier.class)
.set(POSITION.x(), POSITION.y() & 15, POSITION.z(), ID);
DeferredPlacementRecords.invalidateCustomBlockOwnership(chunk);
assertEquals(List.of(new CustomBlockOwnership.Entry(ID, "original", true)), ownership().entries());
assertEquals(ID, chunk.get(POSITION.x(), POSITION.y(), POSITION.z(), Identifier.class));
}
@Test
public void ownershipAddedDuringResetSurvivesInTheSameChunk() throws Exception {
CountDownLatch resetting = new CountDownLatch(1);
CountDownLatch resume = new CountDownLatch(1);
AtomicBoolean firstRead = new AtomicBoolean(true);
doAnswer(invocation -> {
if (firstRead.compareAndSet(true, false)) {
resetting.countDown();
assertTrue(resume.await(5, TimeUnit.SECONDS));
}
return invocation.callRealMethod();
}).when(chunk).get(0);
Engine engine = mock(Engine.class);
when(engine.getWorld()).thenReturn(mock(IrisWorld.class));
CustomBlockOwnership.Entry concurrent = new CustomBlockOwnership.Entry(ID, "concurrent", false);
ExecutorService executor = Executors.newFixedThreadPool(2);
try {
Future<?> reset = executor.submit(() -> DeferredPlacementRecords.resetMantleChunk(mantle, 1, -1, false));
assertTrue(resetting.await(5, TimeUnit.SECONDS));
Future<?> remember = executor.submit(() -> DeferredPlacementRecords.remember(engine, chunk, POSITION, concurrent));
resume.countDown();
reset.get(5, TimeUnit.SECONDS);
remember.get(5, TimeUnit.SECONDS);
assertEquals(List.of(ORIGINAL, new CustomBlockOwnership.Entry(ID, "concurrent", true)), ownership().entries());
assertFalse(chunk.inUse());
} finally {
resume.countDown();
executor.shutdownNow();
}
}
private CustomBlockOwnership ownership() {
return chunk.get(POSITION.x(), POSITION.y(), POSITION.z(), CustomBlockOwnership.class);
}
}
@@ -21,6 +21,7 @@ package art.arcane.iris.generation.mantle;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.block.TileData;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.volmlib.util.math.BlockPosition;
import org.junit.Test;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
@@ -31,6 +32,7 @@ import art.arcane.iris.structure.object.IObjectPlacer;
import art.arcane.iris.world.storage.matter.IrisMatterSupport;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertNotNull;
import static org.mockito.ArgumentMatchers.any;
@@ -42,6 +44,28 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class FloatingObjectPlacementTransactionTest {
@Test
public void canopyCommitRechecksSolidsAndSkipsAssociatedMetadata() {
IslandObjectPlacer delegate = createPlacer(true);
FloatingObjectPlacementTransaction transaction = new FloatingObjectPlacementTransaction(delegate,
new ObjectContinuationBundle.PlacementKey(ObjectContinuationBundle.Kind.FLOATING, 0, 0, 0));
NativeBlockState canopy = mock(NativeBlockState.class);
NativeBlockState trunk = mock(NativeBlockState.class);
when(trunk.isSolid()).thenReturn(true);
when(delegate.beginTreeCanopy(any())).thenCallRealMethod();
when(delegate.canWriteObjectBlock(4, 101, 7)).thenReturn(true);
assertTrue(transaction.beginTreeCanopy(new BlockPosition(4, 101, 7)));
transaction.set(4, 101, 7, canopy);
transaction.setData(4, 101, 7, "canopy");
transaction.endTreeCanopy();
when(delegate.get(4, 101, 7)).thenReturn(trunk);
assertEquals(FloatingObjectPlacementTransaction.CommitResult.COMMITTED, transaction.commit());
verify(delegate, never()).set(4, 101, 7, canopy);
verify(delegate, never()).setData(4, 101, 7, "canopy");
}
@Test
public void unsupportedFinalTerrainRejectsBlocksTilesAndMarkersTogether() {
IslandObjectPlacer delegate = createPlacer(false);
@@ -0,0 +1,221 @@
package art.arcane.iris.generation.mantle;
import art.arcane.iris.generation.block.TileData;
import art.arcane.iris.generation.decoration.tree.TreeBlockMaterial;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.integration.Identifier;
import art.arcane.iris.spi.IrisPlatform;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.PlatformRegistries;
import art.arcane.iris.structure.object.IObjectPlacer;
import art.arcane.iris.testsupport.PlatformLeakGuard;
import art.arcane.iris.world.storage.matter.IrisMatterSupport;
import art.arcane.iris.world.storage.matter.TileWrapper;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.volmlib.util.hunk.Hunk;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
import art.arcane.volmlib.util.math.BlockPosition;
import art.arcane.volmlib.util.matter.IrisMatter;
import art.arcane.volmlib.util.matter.Matter;
import art.arcane.volmlib.util.matter.MatterSlice;
import org.junit.After;
import org.junit.Before;
import org.junit.ClassRule;
import org.junit.Test;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.util.List;
import static org.junit.Assert.assertEquals;
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.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class ObjectContinuationCanopyTest {
@ClassRule
public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean();
private IrisPlatform previous;
private NativeBlockState trunk;
private NativeBlockState canopy;
private NativeBlockState custom;
@Before
public void bindPalette() {
IrisMatterSupport.ensureRegistered();
previous = IrisPlatforms.isBound() ? IrisPlatforms.get() : null;
IrisPlatform platform = mock(IrisPlatform.class);
PlatformRegistries registries = mock(PlatformRegistries.class);
trunk = mock(NativeBlockState.class);
canopy = mock(NativeBlockState.class);
custom = mock(NativeBlockState.class);
when(trunk.key()).thenReturn("minecraft:oak_log");
when(trunk.isSolid()).thenReturn(true);
when(canopy.key()).thenReturn("minecraft:red_stained_glass");
when(canopy.isSolid()).thenReturn(true);
when(custom.isCustom()).thenReturn(true);
when(custom.placementBaseState()).thenReturn(canopy);
when(custom.deferredPlacementKey()).thenReturn("test:custom_canopy");
when(platform.registries()).thenReturn(registries);
when(registries.decodeBlockState(trunk.key())).thenReturn(trunk);
when(registries.decodeBlockState(canopy.key())).thenReturn(canopy);
IrisPlatforms.unbind();
IrisPlatforms.bind(platform);
}
@After
public void restorePalette() {
IrisPlatforms.unbind();
if (previous != null) {
IrisPlatforms.bind(previous);
}
}
@Test
public void encodingKeepsTrunkProvenanceWhenLaterCustomCanopyOverlaps() throws Exception {
Matter payload = decode(List.of(
new ObjectDestinationTransaction.SetMutation(key(16, NativeBlockState.class), trunk, false),
new ObjectDestinationTransaction.SetMutation(key(16, String.class), "original-trunk", false),
new ObjectDestinationTransaction.CustomBlockMutation(key(16, NativeBlockState.class), custom, true),
new ObjectDestinationTransaction.SetMutation(key(16, String.class), "rejected-canopy", true),
new ObjectDestinationTransaction.CustomBlockMutation(key(17, NativeBlockState.class), custom, true)));
assertSame(trunk, payload.getSlice(NativeBlockState.class).get(0, 5, 1));
assertEquals("original-trunk", payload.getSlice(String.class).get(0, 5, 1));
assertNull(payload.getSlice(Identifier.class).get(0, 5, 1));
assertNull(payload.getSlice(TreeCanopy.class).get(0, 5, 1));
assertSame(canopy, payload.getSlice(NativeBlockState.class).get(1, 5, 1));
assertEquals(Identifier.fromString("test:custom_canopy"), payload.getSlice(Identifier.class).get(1, 5, 1));
assertSame(TreeCanopy.INSTANCE, payload.getSlice(TreeCanopy.class).get(1, 5, 1));
}
@Test
public void unconditionalBlockClearsPreviousCanopyCondition() throws Exception {
Matter payload = decode(List.of(
new ObjectDestinationTransaction.CustomBlockMutation(key(16, NativeBlockState.class), custom, true),
new ObjectDestinationTransaction.SetMutation(key(16, NativeBlockState.class), trunk, false)));
assertSame(trunk, payload.getSlice(NativeBlockState.class).get(0, 5, 1));
MatterSlice<TreeCanopy> conditions = payload.getSlice(TreeCanopy.class);
assertTrue(conditions == null || conditions.get(0, 5, 1) == null);
MatterSlice<Identifier> identifiers = payload.getSlice(Identifier.class);
assertTrue(identifiers == null || identifiers.get(0, 5, 1) == null);
}
@Test
public void replayDefersCanopyAndPreservesLaterStructureMetadata() throws Exception {
ReplayFixture fixture = new ReplayFixture();
Matter payload = customPayload();
ObjectContinuationPersistence.restore(fixture.mantle, 1, 0, payload, fixture.blocks, false);
assertFalse(fixture.section.hasSlice(Identifier.class));
assertNull(fixture.blocks.get(0, 5, 1));
fixture.blocks.set(0, 5, 1, trunk);
Identifier existingId = Identifier.fromString("test:existing_structure");
TreeBlockMaterial provenance = TreeBlockMaterial.of("minecraft:oak_log");
TileWrapper tile = new TileWrapper(mock(TileData.class));
fixture.section.slice(NativeBlockState.class).set(0, 5, 1, trunk);
fixture.section.slice(Identifier.class).set(0, 5, 1, existingId);
fixture.section.slice(String.class).set(0, 5, 1, "original-trunk");
fixture.section.slice(TreeBlockMaterial.class).set(0, 5, 1, provenance);
fixture.section.slice(TileWrapper.class).set(0, 5, 1, tile);
ObjectContinuationPersistence.restore(fixture.mantle, 1, 0, payload, fixture.blocks, true);
assertSame(trunk, fixture.blocks.get(0, 5, 1));
assertSame(trunk, fixture.section.getSlice(NativeBlockState.class).get(0, 5, 1));
assertEquals(existingId, fixture.section.getSlice(Identifier.class).get(0, 5, 1));
assertEquals("original-trunk", fixture.section.getSlice(String.class).get(0, 5, 1));
assertEquals(provenance, fixture.section.getSlice(TreeBlockMaterial.class).get(0, 5, 1));
assertSame(tile, fixture.section.getSlice(TileWrapper.class).get(0, 5, 1));
assertFalse(fixture.section.hasSlice(TreeCanopy.class));
}
@Test
public void replayPlacesCustomCanopyIntoAirAndClearsOldTileMetadata() throws Exception {
ReplayFixture fixture = new ReplayFixture();
fixture.section.slice(TileWrapper.class).set(0, 5, 1, new TileWrapper(mock(TileData.class)));
fixture.section.slice(TreeBlockMaterial.class).set(0, 5, 1, TreeBlockMaterial.of("minecraft:oak_log"));
ObjectContinuationPersistence.restore(fixture.mantle, 1, 0, customPayload(), fixture.blocks, true);
assertSame(canopy, fixture.blocks.get(0, 5, 1));
assertEquals(Identifier.fromString("test:custom_canopy"), fixture.section.getSlice(Identifier.class).get(0, 5, 1));
assertEquals("incoming-canopy", fixture.section.getSlice(String.class).get(0, 5, 1));
assertNull(fixture.section.getSlice(TileWrapper.class).get(0, 5, 1));
assertNull(fixture.section.getSlice(TreeBlockMaterial.class).get(0, 5, 1));
assertFalse(fixture.section.hasSlice(TreeCanopy.class));
}
@Test
@SuppressWarnings("unchecked")
public void floatingContinuationRetainsCustomCanopyCondition() throws Exception {
IObjectPlacer delegate = mock(IObjectPlacer.class);
Engine engine = mock(Engine.class);
EngineMantle engineMantle = mock(EngineMantle.class);
Mantle<Matter> mantle = mock(Mantle.class);
MantleChunk<Matter> firstChunk = mock(MantleChunk.class);
MantleChunk<Matter> secondChunk = mock(MantleChunk.class);
Matter first = new IrisMatter(16, 16, 16);
Matter second = new IrisMatter(16, 16, 16);
when(delegate.getEngine()).thenReturn(engine);
when(delegate.beginTreeCanopy(any())).thenCallRealMethod();
when(engine.getHeight()).thenReturn(64);
when(engine.getMantle()).thenReturn(engineMantle);
when(engineMantle.getMantle()).thenReturn(mantle);
when(mantle.useChunk(0, 0)).thenReturn(firstChunk);
when(mantle.useChunk(1, 0)).thenReturn(secondChunk);
when(firstChunk.getOrCreate(0)).thenReturn(first);
when(secondChunk.getOrCreate(0)).thenReturn(second);
FloatingObjectPlacementTransaction transaction = new FloatingObjectPlacementTransaction(delegate,
new ObjectContinuationBundle.PlacementKey(ObjectContinuationBundle.Kind.FLOATING, 0, 0, 0));
transaction.set(15, 5, 1, trunk);
assertTrue(transaction.beginTreeCanopy(new BlockPosition(16, 5, 1)));
transaction.set(16, 5, 1, custom);
transaction.setData(16, 5, 1, "floating-canopy");
transaction.endTreeCanopy();
assertEquals(FloatingObjectPlacementTransaction.CommitResult.COMMITTED, transaction.commit());
Matter payload = second.<ObjectContinuationBundle>getSlice(ObjectContinuationBundle.class)
.get(0, 0, 0).fragments().getFirst().decode();
assertSame(TreeCanopy.INSTANCE, payload.getSlice(TreeCanopy.class).get(0, 5, 1));
assertSame(canopy, payload.getSlice(NativeBlockState.class).get(0, 5, 1));
assertEquals(Identifier.fromString("test:custom_canopy"), payload.getSlice(Identifier.class).get(0, 5, 1));
assertEquals("floating-canopy", payload.getSlice(String.class).get(0, 5, 1));
}
private Matter customPayload() throws IOException {
return decode(List.of(
new ObjectDestinationTransaction.CustomBlockMutation(key(16, NativeBlockState.class), custom, true),
new ObjectDestinationTransaction.SetMutation(key(16, String.class), "incoming-canopy", true)));
}
private static Matter decode(List<ObjectDestinationTransaction.Mutation> mutations) throws IOException {
return Matter.read(new ByteArrayInputStream(ObjectSourcePlan.encode(mutations, 64)));
}
private static ObjectDestinationTransaction.DataKey key(int x, Class<?> type) {
return new ObjectDestinationTransaction.DataKey(x, 5, 1, type);
}
private static final class ReplayFixture {
private final Mantle<Matter> mantle;
private final Matter section = new IrisMatter(16, 16, 16);
private final Hunk<NativeBlockState> blocks = Hunk.newArrayHunk(16, 64, 16);
@SuppressWarnings("unchecked")
private ReplayFixture() {
mantle = mock(Mantle.class);
MantleChunk<Matter> chunk = mock(MantleChunk.class);
when(mantle.useChunk(1, 0)).thenReturn(chunk);
when(chunk.get(0)).thenReturn(section);
when(chunk.getOrCreate(0)).thenReturn(section);
}
}
}
@@ -166,11 +166,11 @@ public class ObjectContinuationTest {
properties.put("CustomName", "continued chest");
TileWrapper tile = new TileWrapper(new TileData("minecraft:chest", properties));
List<ObjectDestinationTransaction.Mutation> mutations = List.of(
new ObjectDestinationTransaction.CustomBlockMutation(key(NativeBlockState.class), custom),
new ObjectDestinationTransaction.SetMutation(key(String.class), "tree@42"),
new ObjectDestinationTransaction.SetMutation(key(TreeBlockMaterial.class), TreeBlockMaterial.of("minecraft:oak_log")),
new ObjectDestinationTransaction.SetMutation(key(TileWrapper.class), tile),
new ObjectDestinationTransaction.SetMutation(new ObjectDestinationTransaction.DataKey(17, 5, 1, NativeBlockState.class), air));
new ObjectDestinationTransaction.CustomBlockMutation(key(NativeBlockState.class), custom, false),
new ObjectDestinationTransaction.SetMutation(key(String.class), "tree@42", false),
new ObjectDestinationTransaction.SetMutation(key(TreeBlockMaterial.class), TreeBlockMaterial.of("minecraft:oak_log"), false),
new ObjectDestinationTransaction.SetMutation(key(TileWrapper.class), tile, false),
new ObjectDestinationTransaction.SetMutation(new ObjectDestinationTransaction.DataKey(17, 5, 1, NativeBlockState.class), air, false));
ObjectContinuationBundle.Fragment fragment = new ObjectContinuationBundle.Fragment(
new ObjectContinuationBundle.PlacementKey(ObjectContinuationBundle.Kind.BIOME, 0, 0, 2), new ObjectContinuationBundle.Bounds(15, 1, 17, 1),
List.of(new ObjectContinuationBundle.ChunkPosition(0, 0), new ObjectContinuationBundle.ChunkPosition(1, 0)),
@@ -215,7 +215,7 @@ public class ObjectContinuationTest {
ObjectContinuationBundle.Fragment fragment = new ObjectContinuationBundle.Fragment(
new ObjectContinuationBundle.PlacementKey(ObjectContinuationBundle.Kind.BIOME, 0, 0, 0), new ObjectContinuationBundle.Bounds(15, 1, 16, 1),
List.of(new ObjectContinuationBundle.ChunkPosition(0, 0), new ObjectContinuationBundle.ChunkPosition(1, 0)),
ObjectSourcePlan.encode(List.of(new ObjectDestinationTransaction.SetMutation(key(IrisSpawner.class), spawner)), 64));
ObjectSourcePlan.encode(List.of(new ObjectDestinationTransaction.SetMutation(key(IrisSpawner.class), spawner, false)), 64));
try (IrisMatterContext.Scope outer = IrisMatterContext.open(current)) {
assertThrows(IOException.class, fragment::decode);
@@ -278,7 +278,7 @@ public class ObjectContinuationTest {
}
private static ObjectDestinationTransaction.Mutation marker(int x, String marker) {
return new ObjectDestinationTransaction.SetMutation(new ObjectDestinationTransaction.DataKey(x, 5, 1, String.class), marker);
return new ObjectDestinationTransaction.SetMutation(new ObjectDestinationTransaction.DataKey(x, 5, 1, String.class), marker, false);
}
private static final class Fixture {
@@ -14,6 +14,8 @@ import art.arcane.iris.generation.terrain.Terrain3DColumnFixtures;
import art.arcane.iris.spi.IrisPlatform;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.volmlib.util.math.BlockPosition;
import art.arcane.iris.structure.object.DecayControlPlacer;
import art.arcane.iris.spi.PlatformRegistries;
import art.arcane.iris.testsupport.BukkitTestServer;
import art.arcane.iris.world.storage.matter.IrisMatterSupport;
@@ -51,6 +53,89 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class ObjectDestinationTransactionTest {
@Test
public void separateSourceCanopiesPreservePriorSolidsAndTheirMetadata() {
for (boolean custom : new boolean[]{false, true}) {
MantleWriter writer = writer();
NativeBlockState trunk = mock(NativeBlockState.class);
NativeBlockState canopy = mock(NativeBlockState.class);
NativeBlockState base = mock(NativeBlockState.class);
when(trunk.isSolid()).thenReturn(true);
if (custom) {
when(canopy.isCustom()).thenReturn(true);
when(canopy.deferredPlacementKey()).thenReturn("test:canopy");
when(canopy.placementBaseState()).thenReturn(base);
}
TreeBlockMaterial trunkMaterial = new TreeBlockMaterial("test:trunk");
TreeBlockMaterial canopyMaterial = new TreeBlockMaterial("test:canopy");
ObjectDestinationTransaction source = new ObjectDestinationTransaction(writer, 0, 0);
DecayControlPlacer placer = new DecayControlPlacer(source);
for (int x : new int[]{17, 18}) {
assertTrue(placer.beginTreeCanopy(new BlockPosition(x, 7, 1)));
placer.set(x, 7, 1, canopy);
placer.setData(x, 7, 1, "canopy");
placer.setData(x, 7, 1, canopyMaterial);
placer.endTreeCanopy();
}
ObjectSourcePlan plan = source.sourcePlanSince(0);
for (ObjectDestinationTransaction.Mutation mutation : plan.mutationsFor(1, 0)) {
assertTrue(mutation.canopy());
}
ObjectDestinationTransaction destination = new ObjectDestinationTransaction(writer, 1, 0);
destination.set(17, 7, 1, trunk);
destination.setData(17, 7, 1, "trunk");
destination.setData(17, 7, 1, trunkMaterial);
destination.apply(plan);
assertSame(trunk, destination.get(17, 7, 1));
assertEquals("trunk", destination.getDataIfPresent(17, 7, 1, String.class));
assertEquals(trunkMaterial, destination.getDataIfPresent(17, 7, 1, TreeBlockMaterial.class));
assertNull(destination.getDataIfPresent(17, 7, 1, Identifier.class));
assertSame(custom ? base : canopy, destination.get(18, 7, 1));
assertEquals("canopy", destination.getDataIfPresent(18, 7, 1, String.class));
destination.commit();
verify(writer, never()).set(17, 7, 1, canopy);
verify(writer, never()).setData(17, 7, 1, canopy);
verify(writer, never()).setData(17, 7, 1, "canopy");
verify(writer, never()).setData(17, 7, 1, canopyMaterial);
}
}
@Test
public void skippedCanopyDoesNotSuppressLaterWoodAtTheSameCell() {
MantleWriter writer = writer();
NativeBlockState existing = mock(NativeBlockState.class);
NativeBlockState canopy = mock(NativeBlockState.class);
NativeBlockState replacement = mock(NativeBlockState.class);
when(existing.isSolid()).thenReturn(true);
ObjectDestinationTransaction source = new ObjectDestinationTransaction(writer, 0, 0);
assertTrue(source.beginTreeCanopy(new BlockPosition(17, 7, 1)));
source.set(17, 7, 1, canopy);
source.setData(17, 7, 1, "canopy");
source.endTreeCanopy();
source.set(17, 7, 1, replacement);
source.setData(17, 7, 1, "replacement");
ObjectDestinationTransaction destination = new ObjectDestinationTransaction(writer, 1, 0);
destination.set(17, 7, 1, existing);
destination.apply(source.sourcePlanSince(0));
assertSame(replacement, destination.get(17, 7, 1));
assertEquals("replacement", destination.getDataIfPresent(17, 7, 1, String.class));
destination.commit();
verify(writer, never()).setData(17, 7, 1, canopy);
verify(writer, never()).setData(17, 7, 1, "canopy");
verify(writer).setData(17, 7, 1, replacement);
}
@Test
public void canopyCaptureRejectsExistingSolidsWithoutMutations() {
MantleWriter writer = writer();
NativeBlockState solid = mock(NativeBlockState.class);
when(solid.isSolid()).thenReturn(true);
when(writer.getPrerequisiteBlock(4, 7, 1)).thenReturn(solid);
ObjectDestinationTransaction source = new ObjectDestinationTransaction(writer, 0, 0);
assertFalse(source.beginTreeCanopy(new BlockPosition(4, 7, 1)));
assertEquals(0, source.mutationCheckpoint());
}
@Test
public void sourceCaptureCannotReplaceAuthoredRoomSolids() {
MantleWriter writer = writer();
@@ -326,7 +326,7 @@ public class ObjectSourcePlanCacheTest {
List<ObjectDestinationTransaction.Mutation> mutations = new ArrayList<>();
for (int[] chunk : chunks) {
mutations.add(new ObjectDestinationTransaction.SetMutation(
new ObjectDestinationTransaction.DataKey(chunk[0] << 4, 4, chunk[1] << 4, String.class), "marker"));
new ObjectDestinationTransaction.DataKey(chunk[0] << 4, 4, chunk[1] << 4, String.class), "marker", false));
}
return new ObjectSourcePlan(mutations);
}
@@ -74,14 +74,14 @@ public class ObjectSourcePlanTest {
int z = centerZ + random.nextInt(2 * reach + 1) - reach;
int y = random.nextInt(768);
mutations.add(switch (random.nextInt(6)) {
case 0 -> new ObjectDestinationTransaction.CustomBlockMutation(key(x, y, z, NativeBlockState.class), custom);
case 1 -> new ObjectDestinationTransaction.SetMutation(key(x, y, z, String.class), placementMarker);
case 0 -> new ObjectDestinationTransaction.CustomBlockMutation(key(x, y, z, NativeBlockState.class), custom, random.nextBoolean());
case 1 -> new ObjectDestinationTransaction.SetMutation(key(x, y, z, String.class), placementMarker, random.nextBoolean());
case 2 -> new ObjectDestinationTransaction.SetMutation(key(x, y, z, TreeBlockMaterial.class),
new TreeBlockMaterial("minecraft:oak_log"));
case 3 -> new ObjectDestinationTransaction.SetMutation(key(x, y, z, MatterMarker.class), marker);
new TreeBlockMaterial("minecraft:oak_log"), random.nextBoolean());
case 3 -> new ObjectDestinationTransaction.SetMutation(key(x, y, z, MatterMarker.class), marker, random.nextBoolean());
default -> {
NativeBlockState state = states[random.nextInt(states.length)];
yield new ObjectDestinationTransaction.SetMutation(key(x, y, z, NativeBlockState.class), state);
yield new ObjectDestinationTransaction.SetMutation(key(x, y, z, NativeBlockState.class), state, random.nextBoolean());
}
});
}
@@ -109,11 +109,11 @@ public class ObjectSourcePlanTest {
@Test
public void heightsOutsideThePackedRangeAreRejected() {
assertThrows(IllegalArgumentException.class, () -> new ObjectSourcePlan(List.of(
new ObjectDestinationTransaction.SetMutation(key(0, -1, 0, String.class), "marker"))));
new ObjectDestinationTransaction.SetMutation(key(0, -1, 0, String.class), "marker", false))));
assertThrows(IllegalArgumentException.class, () -> new ObjectSourcePlan(List.of(
new ObjectDestinationTransaction.SetMutation(key(0, 1 << 22, 0, String.class), "marker"))));
new ObjectDestinationTransaction.SetMutation(key(0, 1 << 22, 0, String.class), "marker", false))));
assertThrows(IllegalArgumentException.class, () -> new ObjectSourcePlan(List.of(
new ObjectDestinationTransaction.SetMutation(key(0, 4, 0, Object.class), "marker"))));
new ObjectDestinationTransaction.SetMutation(key(0, 4, 0, Object.class), "marker", false))));
}
@Test
@@ -139,7 +139,7 @@ public class ObjectSourcePlanTest {
List<ObjectDestinationTransaction.Mutation> trees = new ArrayList<>();
for (int index = 0; index < 100; index++) {
trees.add(new ObjectDestinationTransaction.SetMutation(key(index & 15, 4, 0, TreeBlockMaterial.class),
new TreeBlockMaterial("minecraft:oak_log")));
new TreeBlockMaterial("minecraft:oak_log"), false));
}
int single = new ObjectSourcePlan(one).estimatedRetainedBytes();
@@ -150,15 +150,15 @@ public class ObjectSourcePlanTest {
@Test
public void retainedBytesIncludeVariableLengthMarkers() {
ObjectDestinationTransaction.DataKey key = new ObjectDestinationTransaction.DataKey(0, 4, 0, String.class);
ObjectSourcePlan shortMarker = new ObjectSourcePlan(List.of(new ObjectDestinationTransaction.SetMutation(key, "a")));
ObjectSourcePlan longMarker = new ObjectSourcePlan(List.of(new ObjectDestinationTransaction.SetMutation(key, "a".repeat(4096))));
ObjectSourcePlan shortMarker = new ObjectSourcePlan(List.of(new ObjectDestinationTransaction.SetMutation(key, "a", false)));
ObjectSourcePlan longMarker = new ObjectSourcePlan(List.of(new ObjectDestinationTransaction.SetMutation(key, "a".repeat(4096), false)));
assertTrue(longMarker.estimatedRetainedBytes() - shortMarker.estimatedRetainedBytes() >= 8190);
}
@Test
public void unknownPayloadsAreWeighedInsteadOfDisablingRetention() {
ObjectSourcePlan unknown = new ObjectSourcePlan(List.of(new ObjectDestinationTransaction.SetMutation(
key(0, 4, 0, Object.class), new Object())));
key(0, 4, 0, Object.class), new Object(), false)));
assertTrue(unknown.estimatedRetainedBytes() > new ObjectSourcePlan(List.of(mutationAt(0, 0))).estimatedRetainedBytes());
assertTrue(unknown.estimatedRetainedBytes() < 4096);
@@ -169,9 +169,9 @@ public class ObjectSourcePlanTest {
List<ObjectDestinationTransaction.Mutation> shortMarkers = List.of(mutationAt(0, 0), mutationAt(16, 0));
List<ObjectDestinationTransaction.Mutation> longMarkers = List.of(
new ObjectDestinationTransaction.SetMutation(
new ObjectDestinationTransaction.DataKey(0, 4, 0, String.class), "a".repeat(4096)),
new ObjectDestinationTransaction.DataKey(0, 4, 0, String.class), "a".repeat(4096), false),
new ObjectDestinationTransaction.SetMutation(
new ObjectDestinationTransaction.DataKey(16, 4, 0, String.class), "b".repeat(4096)));
new ObjectDestinationTransaction.DataKey(16, 4, 0, String.class), "b".repeat(4096), false));
ObjectDestinationTransaction.PlacementRange whole = new ObjectDestinationTransaction.PlacementRange(0, 2);
ObjectSourcePlan shortPlain = new ObjectSourcePlan(shortMarkers);
ObjectSourcePlan shortCrossing = new ObjectSourcePlan(shortMarkers, List.of(whole));
@@ -224,6 +224,6 @@ public class ObjectSourcePlanTest {
private static ObjectDestinationTransaction.Mutation mutationAt(int x, int z) {
return new ObjectDestinationTransaction.SetMutation(
new ObjectDestinationTransaction.DataKey(x, 4, z, String.class), "marker");
new ObjectDestinationTransaction.DataKey(x, 4, z, String.class), "marker", false);
}
}
@@ -1,5 +1,6 @@
package art.arcane.iris.pack.datapack;
import art.arcane.iris.pack.datapack.ServerConfigurator.ServerTimeoutConfiguration;
import art.arcane.iris.spi.IrisLogging;
import org.bukkit.Bukkit;
import org.bukkit.Server;
@@ -41,7 +42,7 @@ public class ServerConfiguratorTimeoutTest {
return null;
});
ServerConfigurator.increaseKeepAliveSpigot(file, live);
ServerConfigurator.increaseKeepAliveSpigot(file, live, timeouts());
assertEquals(300L, live.getLong("settings.timeout-time"));
live.set("world-settings.default.sample-world-setting", true);
@@ -62,7 +63,7 @@ public class ServerConfiguratorTimeoutTest {
YamlConfiguration live = load(file);
try (MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
ServerConfigurator.increaseKeepAliveSpigot(file, live);
ServerConfigurator.increaseKeepAliveSpigot(file, live, timeouts());
assertEquals(600L, live.getLong("settings.timeout-time"));
assertEquals(original, Files.readString(file));
@@ -81,7 +82,7 @@ public class ServerConfiguratorTimeoutTest {
YamlConfiguration live = load(link);
try (MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
ServerConfigurator.increaseKeepAliveSpigot(link, live);
ServerConfigurator.increaseKeepAliveSpigot(link, live, timeouts());
assertTrue(Files.isSymbolicLink(link));
assertEquals(relativeTarget, Files.readSymbolicLink(link));
@@ -99,7 +100,7 @@ public class ServerConfiguratorTimeoutTest {
+ "misc:\n sample-setting: retained\n");
try (MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
ServerConfigurator.increasePaperWatchdog(file);
ServerConfigurator.increasePaperWatchdog(file, timeouts());
YamlConfiguration persisted = load(file);
assertEquals(180000L, persisted.getLong("watchdog.early-warning-delay"));
@@ -117,7 +118,7 @@ public class ServerConfiguratorTimeoutTest {
Files.writeString(file, original);
try (MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
ServerConfigurator.increasePaperWatchdog(file);
ServerConfigurator.increasePaperWatchdog(file, timeouts());
assertEquals(original, Files.readString(file));
logging.verifyNoInteractions();
@@ -131,7 +132,7 @@ public class ServerConfiguratorTimeoutTest {
Files.writeString(file, malformed);
try (MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
assertThrows(InvalidConfigurationException.class, () -> ServerConfigurator.increasePaperWatchdog(file));
assertThrows(InvalidConfigurationException.class, () -> ServerConfigurator.increasePaperWatchdog(file, timeouts()));
assertEquals(malformed, Files.readString(file));
logging.verifyNoInteractions();
@@ -148,7 +149,7 @@ public class ServerConfiguratorTimeoutTest {
try (MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
assertThrows(IOException.class, () -> ServerConfigurator.increaseKeepAliveSpigot(
blockedParent.resolve("spigot.yml"), live));
blockedParent.resolve("spigot.yml"), live, timeouts()));
assertEquals(60L, live.getLong("settings.timeout-time"));
assertEquals("retained", live.getString("settings.sample-setting"));
@@ -175,6 +176,10 @@ public class ServerConfiguratorTimeoutTest {
}
}
private ServerTimeoutConfiguration timeouts() {
return new ServerTimeoutConfiguration(temporaryFolder.getRoot().toPath().resolve("server-timeouts.json"));
}
private static YamlConfiguration load(Path file) throws IOException, InvalidConfigurationException {
YamlConfiguration configuration = new YamlConfiguration();
configuration.load(file.toFile());
@@ -0,0 +1,182 @@
package art.arcane.iris.pack.datapack;
import art.arcane.iris.pack.datapack.ServerConfigurator.ServerTimeoutConfiguration;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.volmlib.util.io.AtomicFileIO;
import org.bukkit.configuration.file.YamlConfiguration;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.mockito.MockedStatic;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.mockStatic;
public class ServerTimeoutConfigurationTest {
private static final String SPIGOT_KEY = "settings.timeout-time";
private static final String PAPER_KEY = "watchdog.early-warning-delay";
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void originalValuesSurviveRestartsAndRestoreIndependently() throws Exception {
Path spigot = configuration("spigot.yml", "settings:\n timeout-time: 60\n");
Path paper = configuration("paper-global.yml", "watchdog:\n early-warning-delay: 10000\n early-warning-every: 5000\n");
try (MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
ServerConfigurator.increaseKeepAliveSpigot(spigot, load(spigot), timeouts());
ServerConfigurator.increasePaperWatchdog(paper, timeouts());
ServerConfigurator.increaseKeepAliveSpigot(spigot, load(spigot), timeouts());
YamlConfiguration live = load(spigot);
timeouts().restore(SPIGOT_KEY, spigot, live);
assertEquals(60L, load(spigot).getLong(SPIGOT_KEY));
assertEquals(60L, live.getLong(SPIGOT_KEY));
assertEquals(180000L, load(paper).getLong(PAPER_KEY));
live.set("settings.unrelated", true);
live.save(spigot.toFile());
assertEquals(60L, load(spigot).getLong(SPIGOT_KEY));
timeouts().restore(PAPER_KEY, null, null);
assertEquals(10000L, load(paper).getLong(PAPER_KEY));
assertEquals(5000L, load(paper).getLong("watchdog.early-warning-every"));
assertFalse(Files.readString(recoveryFile()).contains("spigot.yml"));
assertFalse(Files.readString(recoveryFile()).contains("paper-global.yml"));
}
}
@Test
public void restoringAbsentSettingRemovesTheOverride() throws Exception {
Path file = configuration("spigot.yml", "settings:\n unrelated: retained\n");
YamlConfiguration live = load(file);
try (MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
ServerConfigurator.increaseKeepAliveSpigot(file, live, timeouts());
timeouts().restore(SPIGOT_KEY, file, live);
assertFalse(load(file).isSet(SPIGOT_KEY));
assertFalse(live.isSet(SPIGOT_KEY));
assertEquals("retained", load(file).getString("settings.unrelated"));
}
}
@Test
public void restorePreservesOperatorChangesAndOtherSettings() throws Exception {
Path file = configuration("spigot.yml", "settings:\n timeout-time: 60\n");
try (MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
ServerConfigurator.increaseKeepAliveSpigot(file, load(file), timeouts());
Files.writeString(file, "# Operator settings\nsettings:\n timeout-time: 90\n unrelated: retained\n");
String changed = Files.readString(file);
timeouts().restore(SPIGOT_KEY, file, load(file));
assertEquals(changed, Files.readString(file));
ServerConfigurator.increaseKeepAliveSpigot(file, load(file), timeouts());
timeouts().restore(SPIGOT_KEY, file, load(file));
assertEquals(90L, load(file).getLong(SPIGOT_KEY));
assertEquals("retained", load(file).getString("settings.unrelated"));
}
}
@Test
public void symlinkSurvivesRaiseAndRestore() throws Exception {
Path target = configuration("shared-spigot.yml", "settings:\n timeout-time: 60\n");
Path link = temporaryFolder.getRoot().toPath().resolve("spigot.yml");
Files.createSymbolicLink(link, target.getFileName());
try (MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
ServerConfigurator.increaseKeepAliveSpigot(link, load(link), timeouts());
timeouts().restore(SPIGOT_KEY, link, load(link));
assertTrue(Files.isSymbolicLink(link));
assertEquals(60L, load(target).getLong(SPIGOT_KEY));
}
}
@Test
public void aLaterAdjustmentRecordsTheOperatorsNewOriginal() throws Exception {
Path file = configuration("spigot.yml", "settings:\n timeout-time: 60\n");
try (MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
ServerConfigurator.increaseKeepAliveSpigot(file, load(file), timeouts());
Files.writeString(file, "settings:\n timeout-time: 90\n");
ServerConfigurator.increaseKeepAliveSpigot(file, load(file), timeouts());
timeouts().restore(SPIGOT_KEY, file, load(file));
assertEquals(90L, load(file).getLong(SPIGOT_KEY));
}
}
@Test
public void failedRecoveryWriteLeavesServerSettingsUntouched() throws Exception {
Path file = configuration("spigot.yml", "settings:\n timeout-time: 60\n");
YamlConfiguration live = load(file);
Path blocked = temporaryFolder.newFile("blocked").toPath();
ServerTimeoutConfiguration timeouts = new ServerTimeoutConfiguration(blocked.resolve("server-timeouts.json"));
assertThrows(IOException.class, () -> timeouts.raise(file, live, SPIGOT_KEY, 300L));
assertEquals(60L, load(file).getLong(SPIGOT_KEY));
assertEquals(60L, live.getLong(SPIGOT_KEY));
}
@Test
public void failedServerWriteKeepsTheOriginalAndRestoresTheLiveValue() throws Exception {
Path file = configuration("spigot.yml", "settings:\n timeout-time: 60\n");
YamlConfiguration live = load(file);
try (MockedStatic<AtomicFileIO> writes = mockStatic(AtomicFileIO.class, CALLS_REAL_METHODS)) {
writes.when(() -> AtomicFileIO.writeString(eq(file.toRealPath()), anyString()))
.thenThrow(new IOException("blocked server write"));
assertThrows(IOException.class, () -> timeouts().raise(file, live, SPIGOT_KEY, 300L));
}
assertEquals(60L, load(file).getLong(SPIGOT_KEY));
assertEquals(60L, live.getLong(SPIGOT_KEY));
assertTrue(Files.readString(recoveryFile()).contains("\"original\": 60"));
}
@Test
public void interruptedRestoreRetriesWithoutOverwritingLaterOperatorEdits() throws Exception {
Path file = configuration("spigot.yml", "settings:\n timeout-time: 60\n");
try (MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
ServerConfigurator.increaseKeepAliveSpigot(file, load(file), timeouts());
try (MockedStatic<AtomicFileIO> writes = mockStatic(AtomicFileIO.class, CALLS_REAL_METHODS)) {
writes.when(() -> AtomicFileIO.writeString(eq(recoveryFile()), anyString()))
.thenThrow(new IOException("interrupted recovery write"));
assertThrows(IOException.class, () -> timeouts().restore(SPIGOT_KEY, file, load(file)));
}
assertEquals(60L, load(file).getLong(SPIGOT_KEY));
assertTrue(Files.readString(recoveryFile()).contains("spigot.yml"));
Files.writeString(file, "settings:\n timeout-time: 120\n");
timeouts().restore(SPIGOT_KEY, file, load(file));
assertEquals(120L, load(file).getLong(SPIGOT_KEY));
assertFalse(Files.readString(recoveryFile()).contains("spigot.yml"));
}
}
@Test
public void malformedRecoveryDataPreventsNewConfigurationWrites() throws Exception {
Path file = configuration("spigot.yml", "settings:\n timeout-time: 60\n");
Files.writeString(recoveryFile(), "{invalid");
assertThrows(IOException.class, () -> timeouts().raise(file, load(file), SPIGOT_KEY, 300L));
assertEquals(60L, load(file).getLong(SPIGOT_KEY));
assertEquals("{invalid", Files.readString(recoveryFile()));
}
private Path configuration(String name, String content) throws IOException {
Path file = temporaryFolder.newFile(name).toPath();
Files.writeString(file, content);
return file;
}
private ServerTimeoutConfiguration timeouts() {
return new ServerTimeoutConfiguration(recoveryFile());
}
private Path recoveryFile() {
return temporaryFolder.getRoot().toPath().resolve("server-timeouts.json");
}
private static YamlConfiguration load(Path file) throws Exception {
YamlConfiguration configuration = new YamlConfiguration();
configuration.load(file.toFile());
return configuration;
}
}
@@ -83,7 +83,7 @@ public class SchemaBuilderIdentityTest {
@ClassRule
public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean();
private static final String EXPECTED_SCHEMA_DIGEST = "c99b0db94bfd6f316738beb87401edde5b52eed11bfa419148bb2e8c08b0d7a9";
private static final String EXPECTED_SCHEMA_DIGEST = "f0be31c11f7b03061a88387c66578058f447e5803cabe536a34721066d2bc636";
private static final List<Class<?>> SCHEMA_ROOTS = List.of(
IrisDimension.class,
@@ -0,0 +1,299 @@
package art.arcane.iris.platform.generation;
import art.arcane.iris.generation.mantle.CustomBlockOwnership;
import art.arcane.iris.generation.mantle.DeferredPlacementRecords;
import art.arcane.iris.generation.mantle.EngineMantle;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.EnginePlatformHooks;
import art.arcane.iris.generation.runtime.IrisEngineMantle;
import art.arcane.iris.integration.Identifier;
import art.arcane.iris.integration.ExternalDataSVC;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.spi.IrisPlatform;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.spi.PlatformRegistries;
import art.arcane.iris.spi.PlatformScheduler;
import art.arcane.iris.testsupport.PlatformLeakGuard;
import art.arcane.iris.world.IrisWorld;
import art.arcane.iris.world.storage.matter.CustomBlockOwnershipMatter;
import art.arcane.iris.world.storage.matter.IrisMatterSupport;
import art.arcane.iris.world.storage.matter.PreObjectMatterTest;
import art.arcane.volmlib.nativelib.terrain.NativeBlockChangeSet;
import art.arcane.volmlib.nativelib.terrain.NativeBlockPoint;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
import art.arcane.volmlib.util.mantle.runtime.MantleHooks;
import art.arcane.volmlib.util.matter.Matter;
import org.bukkit.Chunk;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.junit.After;
import org.junit.Before;
import org.junit.ClassRule;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class CustomBlockOwnershipTest {
@ClassRule
public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean();
private static final int MIN_HEIGHT = -64;
private static final NativeBlockPoint WORLD_POSITION = new NativeBlockPoint(19, 70, -11);
private static final NativeBlockPoint MANTLE_POSITION = new NativeBlockPoint(3, 134, 5);
private static final CustomBlockOwnership.Entry ORIGINAL = new CustomBlockOwnership.Entry(
Identifier.fromString("craftengine:example/chair"), "original-entity", false);
private static final CustomBlockOwnership.Entry REPLACEMENT = new CustomBlockOwnership.Entry(
Identifier.fromString("craftengine:example/chair"), "replacement-entity", false);
private IrisPlatform previous;
private PlatformScheduler scheduler;
private EnginePlatformHooks hooks;
private Engine engine;
private NativeWorld world;
private MantleChunk<Matter> chunk;
@Before
@SuppressWarnings("unchecked")
public void setUp() {
PreObjectMatterTest.setUpBukkit();
IrisMatterSupport.ensureRegistered();
previous = IrisPlatforms.isBound() ? IrisPlatforms.get() : null;
IrisPlatform platform = mock(IrisPlatform.class);
PlatformRegistries registries = mock(PlatformRegistries.class);
when(registries.block("AIR")).thenReturn(mock(NativeBlockState.class));
when(platform.registries()).thenReturn(registries);
scheduler = mock(PlatformScheduler.class);
when(platform.scheduler()).thenReturn(scheduler);
IrisPlatforms.unbind();
IrisPlatforms.bind(platform);
chunk = new MantleChunk<>(24, 1, -1,
IrisEngineMantle.createRuntimeDataAdapter(mock(IrisData.class)), MantleHooks.NONE);
Mantle<Matter> mantle = mock(Mantle.class);
when(mantle.useChunk(1, -1)).thenAnswer(invocation -> chunk.use());
EngineMantle engineMantle = mock(EngineMantle.class);
when(engineMantle.getMantle()).thenReturn(mantle);
world = mock(NativeWorld.class);
when(world.isChunkLoaded(1, -1)).thenReturn(true);
IrisWorld irisWorld = mock(IrisWorld.class);
when(irisWorld.platformWorld()).thenReturn(world);
when(irisWorld.minHeight()).thenReturn(MIN_HEIGHT);
engine = mock(Engine.class);
when(engine.getWorld()).thenReturn(irisWorld);
when(engine.getMantle()).thenReturn(engineMantle);
when(engine.getMinHeight()).thenReturn(MIN_HEIGHT);
hooks = mock(EnginePlatformHooks.class);
IrisServices.register(EnginePlatformHooks.class, hooks);
}
@After
public void tearDown() {
IrisServices.remove(EnginePlatformHooks.class);
IrisPlatforms.unbind();
if (previous != null) {
IrisPlatforms.bind(previous);
}
}
@Test
public void materializedFurnitureEnablesTrackingAndRemovesOnOwningThread() {
remember(ORIGINAL);
NativeBlockChangeSet changes = changes();
assertNotSame(NativeBlockChangeSet.disabled(), changes);
DeferredPlacementRecords.invalidate(engine, changes);
verify(hooks, never()).removeCustomBlock(any(), any(), any());
CustomBlockOwnership.Entry pending = pendingOriginal();
assertEquals(List.of(pending), ownership().entries());
when(hooks.removeCustomBlock(engine, WORLD_POSITION, pending)).thenReturn(true);
scheduledTask().run();
verify(hooks).removeCustomBlock(engine, WORLD_POSITION, pending);
assertNull(ownership());
}
@Test
public void queuedRemovalKeepsReplacementOwnership() {
remember(ORIGINAL);
DeferredPlacementRecords.invalidate(engine, changes());
remember(REPLACEMENT);
when(hooks.removeCustomBlock(engine, WORLD_POSITION, pendingOriginal())).thenReturn(true);
scheduledTask().run();
assertEquals(List.of(REPLACEMENT), ownership().entries());
verify(hooks, never()).removeCustomBlock(eq(engine), eq(WORLD_POSITION), eq(REPLACEMENT));
}
@Test
public void unloadedChunkRetainsRemovalUntilLoadedAndRetriesDeclinedCleanup() {
remember(ORIGINAL);
DeferredPlacementRecords.invalidate(engine, changes());
when(world.isChunkLoaded(1, -1)).thenReturn(false);
scheduledTask().run();
verify(hooks, never()).removeCustomBlock(any(), any(), any());
when(world.isChunkLoaded(1, -1)).thenReturn(true);
DeferredPlacementRecords.removeInvalidated(engine, 1, -1);
assertEquals(List.of(pendingOriginal()), ownership().entries());
when(hooks.removeCustomBlock(engine, WORLD_POSITION, pendingOriginal())).thenReturn(true);
DeferredPlacementRecords.removeInvalidated(engine, 1, -1);
assertNull(ownership());
}
@Test
public void ownershipPersistsPendingAndReplacementTokens() throws Exception {
CustomBlockOwnership expected = new CustomBlockOwnership(List.of(pendingOriginal(), REPLACEMENT));
CustomBlockOwnershipMatter matter = new CustomBlockOwnershipMatter();
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
matter.writeNode(expected, new DataOutputStream(bytes));
assertEquals(expected, matter.readNode(new DataInputStream(new ByteArrayInputStream(bytes.toByteArray()))));
}
@Test
public void invalidationDuringProviderCallbackRemovesCompletedPlacement() {
chunk.getOrCreate(MANTLE_POSITION.y() >> 4).slice(Identifier.class)
.set(MANTLE_POSITION.x(), MANTLE_POSITION.y() & 15, MANTLE_POSITION.z(), ORIGINAL.identifier());
DeferredPlacementRecords.invalidate(engine, changes());
DeferredPlacementRecords.remember(engine, chunk, MANTLE_POSITION, ORIGINAL);
assertEquals(List.of(pendingOriginal()), ownership().entries());
when(hooks.removeCustomBlock(engine, WORLD_POSITION, pendingOriginal())).thenReturn(true);
scheduledTask().run();
assertNull(ownership());
}
@Test(timeout = 10000)
public void providerCallbackAllowsConcurrentInvalidationAndSkipsCanceledSnapshotEntries() {
chunk.getOrCreate(MANTLE_POSITION.y() >> 4).slice(Identifier.class)
.set(MANTLE_POSITION.x(), MANTLE_POSITION.y() & 15, MANTLE_POSITION.z(), ORIGINAL.identifier());
chunk.getOrCreate(MANTLE_POSITION.y() >> 4).slice(Identifier.class)
.set(MANTLE_POSITION.x() + 1, MANTLE_POSITION.y() & 15, MANTLE_POSITION.z(), ORIGINAL.identifier());
NativeBlockChangeSet changes = changes();
changes.add(WORLD_POSITION.x() + 1, WORLD_POSITION.y(), WORLD_POSITION.z());
World bukkitWorld = mock(World.class);
when(bukkitWorld.getMinHeight()).thenReturn(MIN_HEIGHT);
when(bukkitWorld.getMaxHeight()).thenReturn(320);
Chunk bukkitChunk = mock(Chunk.class);
when(bukkitChunk.getWorld()).thenReturn(bukkitWorld);
Block started = mock(Block.class);
Block canceled = mock(Block.class);
when(bukkitChunk.getBlock(3, 70, 5)).thenReturn(started);
when(bukkitChunk.getBlock(4, 70, 5)).thenReturn(canceled);
ExternalDataSVC provider = mock(ExternalDataSVC.class);
IrisServices.register(ExternalDataSVC.class, provider);
try (ExecutorService worker = Executors.newVirtualThreadPerTaskExecutor()) {
when(provider.processUpdate(engine, started, ORIGINAL.identifier())).thenAnswer(invocation -> {
CompletableFuture.runAsync(() -> DeferredPlacementRecords.invalidate(engine, changes), worker)
.get(3, TimeUnit.SECONDS);
return ORIGINAL;
});
EngineBukkitOps.materializeCustomBlocks(engine, bukkitChunk, chunk);
verify(provider).processUpdate(engine, started, ORIGINAL.identifier());
verify(provider, never()).processUpdate(eq(engine), eq(canceled), any());
assertEquals(List.of(pendingOriginal()), ownership().entries());
when(hooks.removeCustomBlock(engine, WORLD_POSITION, pendingOriginal())).thenReturn(true);
scheduledTask().run();
assertNull(ownership());
} finally {
IrisServices.remove(ExternalDataSVC.class);
}
}
@Test
@SuppressWarnings("unchecked")
public void queuedInvalidationKeepsSelectedMantleAndHeightAfterRuntimeChanges() {
remember(ORIGINAL);
DeferredPlacementRecords.invalidate(engine, changes());
Mantle<Matter> replacementMantle = mock(Mantle.class);
EngineMantle replacementRuntime = mock(EngineMantle.class);
when(replacementRuntime.getMantle()).thenReturn(replacementMantle);
when(engine.getMantle()).thenReturn(replacementRuntime);
when(engine.getMinHeight()).thenReturn(0);
when(hooks.removeCustomBlock(engine, WORLD_POSITION, pendingOriginal())).thenReturn(true);
scheduledTask().run();
assertNull(ownership());
verify(hooks).removeCustomBlock(engine, WORLD_POSITION, pendingOriginal());
verify(replacementMantle, never()).useChunk(anyInt(), anyInt());
}
@Test
@SuppressWarnings("unchecked")
public void completedPlacementUsesSuppliedChunkWhenEngineSelectsAnotherMantle() {
Mantle<Matter> replacementMantle = mock(Mantle.class);
EngineMantle replacementRuntime = mock(EngineMantle.class);
when(replacementRuntime.getMantle()).thenReturn(replacementMantle);
when(engine.getMantle()).thenReturn(replacementRuntime);
DeferredPlacementRecords.remember(engine, chunk, MANTLE_POSITION, ORIGINAL);
when(engine.getMinHeight()).thenReturn(0);
when(hooks.removeCustomBlock(engine, WORLD_POSITION, pendingOriginal())).thenReturn(true);
scheduledTask().run();
assertNull(ownership());
verify(hooks).removeCustomBlock(engine, WORLD_POSITION, pendingOriginal());
verify(replacementMantle, never()).useChunk(anyInt(), anyInt());
}
private void remember(CustomBlockOwnership.Entry entry) {
chunk.getOrCreate(MANTLE_POSITION.y() >> 4).slice(Identifier.class)
.set(MANTLE_POSITION.x(), MANTLE_POSITION.y() & 15, MANTLE_POSITION.z(), entry.identifier());
DeferredPlacementRecords.remember(engine, chunk, MANTLE_POSITION, entry);
}
private NativeBlockChangeSet changes() {
NativeBlockChangeSet changes = DeferredPlacementRecords.track(engine,
new NativeBlockChangeSet.Bounds(1, -1, 1, -1, MIN_HEIGHT, 384));
changes.add(WORLD_POSITION.x(), WORLD_POSITION.y(), WORLD_POSITION.z());
return changes;
}
private Runnable scheduledTask() {
ArgumentCaptor<Runnable> task = ArgumentCaptor.forClass(Runnable.class);
verify(scheduler).region(eq(world), eq(1), eq(-1), task.capture());
return task.getValue();
}
private CustomBlockOwnership ownership() {
return chunk.get(MANTLE_POSITION.y() >> 4).getSlice(CustomBlockOwnership.class)
.get(MANTLE_POSITION.x(), MANTLE_POSITION.y() & 15, MANTLE_POSITION.z());
}
private static CustomBlockOwnership.Entry pendingOriginal() {
return new CustomBlockOwnership.Entry(ORIGINAL.identifier(), ORIGINAL.token(), true);
}
}
@@ -0,0 +1,266 @@
package art.arcane.iris.platform.generation;
import art.arcane.iris.generation.block.TileData;
import art.arcane.iris.generation.block.IrisCustomData;
import art.arcane.iris.generation.mantle.DeferredPlacementRecords;
import art.arcane.iris.generation.mantle.CustomBlockOwnership;
import art.arcane.iris.generation.mantle.EngineMantle;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.IrisEngineMantle;
import art.arcane.iris.integration.ExternalDataSVC;
import art.arcane.iris.integration.Identifier;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.spi.IrisPlatform;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.spi.PlatformRegistries;
import art.arcane.iris.structure.nativegen.ImportedFeaturePolicy;
import art.arcane.iris.testsupport.PlatformLeakGuard;
import art.arcane.iris.world.IrisWorld;
import art.arcane.iris.world.storage.matter.IrisMatterSupport;
import art.arcane.iris.world.storage.matter.PreObjectMatterTest;
import art.arcane.iris.world.storage.matter.TileWrapper;
import art.arcane.volmlib.nativelib.terrain.NativeBlockChangeSet;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.volmlib.util.collection.KMap;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
import art.arcane.volmlib.util.mantle.runtime.MantleDataAdapter;
import art.arcane.volmlib.util.mantle.runtime.MantleHooks;
import art.arcane.volmlib.util.matter.Matter;
import org.bukkit.Chunk;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import org.junit.ClassRule;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.util.HashMap;
import java.util.Map;
import java.util.List;
import java.util.Optional;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class DeferredPlacementInvalidationTest {
@ClassRule
public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean();
private static final int MIN_HEIGHT = -64;
private static final int SECTIONS = 24;
@Test
public void nativeWritesClearDeferredRecordsSoMaterializationSkipsThem() {
withMantleData(adapter -> {
Map<Long, MantleChunk<Matter>> chunks = new HashMap<>();
MantleChunk<Matter> stored = chunk(chunks, adapter, 0, 0);
Identifier replaced = Identifier.fromString("craftengine:fence_post");
Identifier kept = Identifier.fromString("craftengine:lantern");
setRecord(stored, 3, 70, 5, Identifier.class, replaced);
setRecord(stored, 3, 76, 5, Identifier.class, kept);
setRecord(stored, 4, 70, 5, TileWrapper.class,
new TileWrapper(new TileData("minecraft:chest", new KMap<>())));
Engine engine = engine(chunks, adapter);
NativeBlockChangeSet changes = DeferredPlacementRecords.track(engine, bounds());
changes.add(3, 70, 5);
changes.add(4, 70, 5);
DeferredPlacementRecords.invalidate(engine, changes);
assertNull(record(stored, 4, 70, 5, TileWrapper.class));
assertEquals(kept, record(stored, 3, 76, 5, Identifier.class));
World world = mock(World.class);
when(world.getMinHeight()).thenReturn(MIN_HEIGHT);
when(world.getMaxHeight()).thenReturn(320);
Chunk bukkitChunk = mock(Chunk.class);
when(bukkitChunk.getWorld()).thenReturn(world);
Block replacedBlock = mock(Block.class);
Block keptBlock = mock(Block.class);
when(bukkitChunk.getBlock(3, 70, 5)).thenReturn(replacedBlock);
when(bukkitChunk.getBlock(3, 76, 5)).thenReturn(keptBlock);
ExternalDataSVC provider = mock(ExternalDataSVC.class);
try (MockedStatic<IrisServices> services = mockStatic(IrisServices.class)) {
services.when(() -> IrisServices.get(ExternalDataSVC.class)).thenReturn(provider);
EngineBukkitOps.materializeCustomBlocks(engine, bukkitChunk, stored);
}
verify(provider).processUpdate(engine, keptBlock, kept);
verify(provider, never()).processUpdate(eq(engine), eq(replacedBlock), any(Identifier.class));
});
}
@Test
public void regionsWithoutDeferredRecordsAreNotTracked() {
withMantleData(adapter -> {
Map<Long, MantleChunk<Matter>> chunks = new HashMap<>();
setRecord(chunk(chunks, adapter, 3, 0), 0, 70, 0, Identifier.class,
Identifier.fromString("craftengine:outside"));
assertSame(NativeBlockChangeSet.disabled(),
DeferredPlacementRecords.track(engine(chunks, adapter), bounds()));
assertSame(NativeBlockChangeSet.disabled(),
DeferredPlacementRecords.positions(engine(chunks, adapter), bounds()));
});
}
@Test
public void importedFeatureCutRemovesFireRecordBeforeMaterialization() {
withMantleData(adapter -> {
Map<Long, MantleChunk<Matter>> chunks = new HashMap<>();
MantleChunk<Matter> stored = chunk(chunks, adapter, 0, 0);
Identifier fire = Identifier.fromString("craftengine:example/fire");
setRecord(stored, 3, 70, 5, Identifier.class, fire);
setRecord(stored, 4, 70, 5, Identifier.class, fire);
Engine engine = engine(chunks, adapter);
ImportedFeaturePolicy policy = new ImportedFeaturePolicy(engine);
NativeBlockChangeSet candidates = policy.trackedBlockPositions(bounds());
assertEquals(2, candidates.size());
NativeBlockChangeSet changes = NativeBlockChangeSet.create(candidates.bounds());
changes.add(3, 70, 5);
policy.invalidateBlockChanges(changes);
assertNull(record(stored, 3, 70, 5, Identifier.class));
World world = mock(World.class);
when(world.getMinHeight()).thenReturn(MIN_HEIGHT);
when(world.getMaxHeight()).thenReturn(320);
Chunk bukkitChunk = mock(Chunk.class);
when(bukkitChunk.getWorld()).thenReturn(world);
Block cut = mock(Block.class);
Block extinguished = mock(Block.class);
when(cut.getType()).thenReturn(Material.AIR);
when(extinguished.getType()).thenReturn(Material.AIR);
when(bukkitChunk.getBlock(3, 70, 5)).thenReturn(cut);
when(bukkitChunk.getBlock(4, 70, 5)).thenReturn(extinguished);
BlockData carrier = mock(BlockData.class);
when(carrier.getMaterial()).thenReturn(Material.FIRE);
ExternalDataSVC provider = mock(ExternalDataSVC.class);
when(provider.getBlockData(fire)).thenReturn(Optional.of(IrisCustomData.of(carrier, fire)));
try (MockedStatic<IrisServices> services = mockStatic(IrisServices.class)) {
services.when(() -> IrisServices.get(ExternalDataSVC.class)).thenReturn(provider);
EngineBukkitOps.materializeCustomBlocks(engine, bukkitChunk, stored);
}
verify(provider, never()).processUpdate(eq(engine), eq(cut), any());
verify(provider).processUpdate(engine, extinguished, fire);
});
}
@Test
public void neighbouringRecordsEnableTrackingForTheWholeWriteRegion() {
withMantleData(adapter -> {
Map<Long, MantleChunk<Matter>> chunks = new HashMap<>();
setRecord(chunk(chunks, adapter, 1, -1), 0, 70, 0, TileWrapper.class,
new TileWrapper(new TileData("minecraft:barrel", new KMap<>())));
NativeBlockChangeSet changes = DeferredPlacementRecords.track(engine(chunks, adapter), bounds());
assertNotSame(NativeBlockChangeSet.disabled(), changes);
changes.add(16, 70, -16);
DeferredPlacementRecords.invalidate(engine(chunks, adapter), changes);
assertNull(record(chunks.get(key(1, -1)), 0, 70, 0, TileWrapper.class));
});
}
@Test
public void importedFeatureCandidatesIncludeNeighbourOwnershipAndDeduplicateSlices() {
withMantleData(adapter -> {
Map<Long, MantleChunk<Matter>> chunks = new HashMap<>();
MantleChunk<Matter> stored = chunk(chunks, adapter, -1, 1);
Identifier identifier = Identifier.fromString("craftengine:example/chair");
setRecord(stored, 15, 70, 2, Identifier.class, identifier);
setRecord(stored, 15, 70, 2, TileWrapper.class,
new TileWrapper(new TileData("minecraft:chest", new KMap<>())));
setRecord(stored, 14, 71, 3, CustomBlockOwnership.class,
new CustomBlockOwnership(List.of(new CustomBlockOwnership.Entry(identifier, "entity-token", false))));
setRecord(chunk(chunks, adapter, 2, 0), 0, 70, 0, Identifier.class, identifier);
NativeBlockChangeSet positions = DeferredPlacementRecords.positions(engine(chunks, adapter), bounds());
assertEquals(2, positions.size());
assertTrue(positions.contains(-1, 70, 18));
assertTrue(positions.contains(-2, 71, 19));
});
}
private static NativeBlockChangeSet.Bounds bounds() {
return new NativeBlockChangeSet.Bounds(-1, -1, 1, 1, MIN_HEIGHT, 384);
}
@SuppressWarnings("unchecked")
private static Engine engine(Map<Long, MantleChunk<Matter>> chunks, MantleDataAdapter<Matter> adapter) {
Mantle<Matter> mantle = mock(Mantle.class);
when(mantle.useChunk(anyInt(), anyInt())).thenAnswer(invocation ->
chunk(chunks, adapter, invocation.getArgument(0), invocation.getArgument(1)).use());
EngineMantle engineMantle = mock(EngineMantle.class);
when(engineMantle.getMantle()).thenReturn(mantle);
IrisWorld world = mock(IrisWorld.class);
when(world.minHeight()).thenReturn(MIN_HEIGHT);
Engine engine = mock(Engine.class);
when(engine.getMantle()).thenReturn(engineMantle);
when(engine.getWorld()).thenReturn(world);
when(engine.getMinHeight()).thenReturn(MIN_HEIGHT);
return engine;
}
private static MantleChunk<Matter> chunk(Map<Long, MantleChunk<Matter>> chunks, MantleDataAdapter<Matter> adapter,
int chunkX, int chunkZ) {
return chunks.computeIfAbsent(key(chunkX, chunkZ),
ignored -> new MantleChunk<>(SECTIONS, chunkX, chunkZ, adapter, MantleHooks.NONE));
}
private static long key(int chunkX, int chunkZ) {
return ((long) chunkX << 32) | (chunkZ & 0xffffffffL);
}
private static <T> void setRecord(MantleChunk<Matter> chunk, int x, int worldY, int z, Class<T> type, T value) {
int y = worldY - MIN_HEIGHT;
chunk.getOrCreate(y >> 4).slice(type).set(x & 15, y & 15, z & 15, value);
}
private static <T> T record(MantleChunk<Matter> chunk, int x, int worldY, int z, Class<T> type) {
int y = worldY - MIN_HEIGHT;
return chunk.get(y >> 4).getSlice(type).get(x & 15, y & 15, z & 15);
}
private static void withMantleData(MantleTest test) {
PreObjectMatterTest.setUpBukkit();
IrisMatterSupport.ensureRegistered();
IrisPlatform previous = IrisPlatforms.isBound() ? IrisPlatforms.get() : null;
IrisPlatform platform = mock(IrisPlatform.class);
PlatformRegistries registries = mock(PlatformRegistries.class);
when(registries.block("AIR")).thenReturn(mock(NativeBlockState.class));
when(platform.registries()).thenReturn(registries);
IrisPlatforms.unbind();
IrisPlatforms.bind(platform);
try {
test.run(IrisEngineMantle.createRuntimeDataAdapter(mock(IrisData.class)));
} finally {
IrisPlatforms.unbind();
if (previous != null) {
IrisPlatforms.bind(previous);
}
}
}
@FunctionalInterface
private interface MantleTest {
void run(MantleDataAdapter<Matter> adapter);
}
}
@@ -51,6 +51,7 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
@@ -76,6 +77,7 @@ public class EngineBukkitOpsDeferredMaterializationTest {
iterator.accept(1, 2, 3, identifier);
return null;
}).when(stored).iterate(eq(Identifier.class), any());
doReturn(identifier).when(stored).get(1, 2, 3, Identifier.class);
Engine engine = mock(Engine.class);
when(engine.getWorld()).thenReturn(mock(IrisWorld.class));
when(engine.getMetrics()).thenReturn(new EngineMetrics(8));
@@ -0,0 +1,288 @@
package art.arcane.iris.platform.generation;
import art.arcane.iris.generation.block.IrisCustomData;
import art.arcane.iris.generation.block.TileData;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.integration.ExternalDataSVC;
import art.arcane.iris.integration.Identifier;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.testsupport.PlatformLeakGuard;
import art.arcane.iris.world.IrisWorld;
import art.arcane.iris.world.storage.matter.TileWrapper;
import art.arcane.volmlib.util.collection.KMap;
import art.arcane.volmlib.util.function.Consumer4;
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
import art.arcane.volmlib.util.matter.Matter;
import org.bukkit.Chunk;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.TileState;
import org.bukkit.block.data.BlockData;
import org.junit.ClassRule;
import org.junit.Test;
import org.mockito.MockedStatic;
import org.mockito.invocation.InvocationOnMock;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class EngineBukkitOpsStaleRecordTest {
@ClassRule
public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean();
private static final Identifier ORE = Identifier.fromString("itemsadder:forest/amber_ore");
private static final Identifier CHAIR = Identifier.fromString("craftengine:test/chair[variant=alpha]");
@Test
public void customRecordOverFlattenedAirIsNotPlaced() {
Fixture fixture = new Fixture();
fixture.carrier(ORE, Material.NOTE_BLOCK);
Block flattened = fixture.block(1, 10, 1, Material.AIR);
MantleChunk<Matter> records = customRecords(new Placed<>(1, 10, 1, ORE));
fixture.materializeCustomBlocks(records);
verify(fixture.service, never()).processUpdate(any(), eq(flattened), any());
verify(records).deleteSlices(Identifier.class);
}
@Test
public void customRecordOverReplacedBlockIsNotPlaced() {
Fixture fixture = new Fixture();
fixture.carrier(ORE, Material.NOTE_BLOCK);
Block wall = fixture.block(2, 10, 2, Material.COBBLESTONE);
MantleChunk<Matter> records = customRecords(new Placed<>(2, 10, 2, ORE));
fixture.materializeCustomBlocks(records);
verify(fixture.service, never()).processUpdate(any(), eq(wall), any());
verify(records).deleteSlices(Identifier.class);
}
@Test
public void customRecordOverIntactCarrierIsPlaced() {
Fixture fixture = new Fixture();
fixture.carrier(ORE, Material.NOTE_BLOCK);
Block intact = fixture.block(3, 10, 3, Material.NOTE_BLOCK);
fixture.materializeCustomBlocks(customRecords(new Placed<>(3, 10, 3, ORE)));
verify(fixture.service).processUpdate(fixture.engine, intact, ORE);
}
@Test
public void extinguishedFireCarrierIsPlacedButCoveredFireIsSkipped() {
for (Material carrier : List.of(Material.FIRE, Material.SOUL_FIRE)) {
Fixture fixture = new Fixture();
fixture.carrier(ORE, carrier);
Block extinguished = fixture.block(1, 10, 1, Material.AIR);
Block caveAir = fixture.block(2, 10, 2, Material.CAVE_AIR);
Block wall = fixture.block(3, 10, 3, Material.COBBLESTONE);
fixture.materializeCustomBlocks(customRecords(
new Placed<>(1, 10, 1, ORE),
new Placed<>(2, 10, 2, ORE),
new Placed<>(3, 10, 3, ORE)));
verify(fixture.service).processUpdate(fixture.engine, extinguished, ORE);
verify(fixture.service).processUpdate(fixture.engine, caveAir, ORE);
verify(fixture.service, never()).processUpdate(any(), eq(wall), any());
}
}
@Test
public void airCarrierFurnitureRecordIsPlacedWhateverTheLiveBlock() {
Fixture fixture = new Fixture();
fixture.carrier(CHAIR, Material.AIR);
Block open = fixture.block(4, 10, 4, Material.AIR);
Block covered = fixture.block(5, 10, 5, Material.COBBLESTONE);
fixture.materializeCustomBlocks(customRecords(
new Placed<>(4, 10, 4, CHAIR),
new Placed<>(5, 10, 5, CHAIR)));
verify(fixture.service).processUpdate(fixture.engine, open, CHAIR);
verify(fixture.service).processUpdate(fixture.engine, covered, CHAIR);
}
@Test
public void unresolvableCarrierLeavesPlacementToTheProvider() {
Fixture fixture = new Fixture();
when(fixture.service.getBlockData(ORE)).thenReturn(Optional.empty());
Block block = fixture.block(6, 10, 6, Material.STONE);
fixture.materializeCustomBlocks(customRecords(new Placed<>(6, 10, 6, ORE)));
verify(fixture.service).processUpdate(fixture.engine, block, ORE);
}
@Test
public void carrierResolvesOncePerIdentifierAndStaleRecordsLogOncePerChunk() {
Fixture fixture = new Fixture();
fixture.carrier(ORE, Material.NOTE_BLOCK);
fixture.block(1, 10, 1, Material.AIR);
fixture.block(2, 10, 2, Material.COBBLESTONE);
Block intact = fixture.block(3, 10, 3, Material.NOTE_BLOCK);
MantleChunk<Matter> records = customRecords(
new Placed<>(1, 10, 1, ORE),
new Placed<>(2, 10, 2, ORE),
new Placed<>(3, 10, 3, ORE));
List<String> debug = fixture.materializeCustomBlocksCapturingDebug(records);
verify(fixture.service, times(1)).getBlockData(ORE);
verify(fixture.service, times(1)).processUpdate(any(), any(), any());
verify(fixture.service).processUpdate(fixture.engine, intact, ORE);
assertEquals(1, debug.size());
assertTrue(debug.getFirst(), debug.getFirst().contains("2 stale"));
}
@Test
public void staleTileRecordIsSkippedWithoutWarning() {
Fixture fixture = new Fixture();
TileData chest = chestTile();
Block replaced = fixture.block(7, 10, 7, Material.STONE);
BlockData stone = blockData(Material.STONE);
when(replaced.getBlockData()).thenReturn(stone);
try (MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
EngineBukkitOps.materializeTiles(fixture.engine, fixture.chunk, tileRecords(new Placed<>(7, 10, 7, chest)));
logging.verify(() -> IrisLogging.warn(anyString(), any(Object[].class)), never());
logging.verify(() -> IrisLogging.debug(anyString(), any(Object[].class)), times(1));
}
verify(chest, never()).toBukkitTry(any());
}
@Test
public void intactTileRecordIsApplied() {
Fixture fixture = new Fixture();
TileData chest = chestTile();
Block intact = fixture.block(8, 10, 8, Material.CHEST);
BlockData chestData = blockData(Material.CHEST);
when(intact.getBlockData()).thenReturn(chestData);
when(intact.getState()).thenReturn(mock(TileState.class));
doReturn(true).when(chest).toBukkitTry(intact);
try (MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
EngineBukkitOps.materializeTiles(fixture.engine, fixture.chunk, tileRecords(new Placed<>(8, 10, 8, chest)));
logging.verify(() -> IrisLogging.warn(anyString(), any(Object[].class)), never());
}
verify(chest).toBukkitTry(intact);
}
private static TileData chestTile() {
TileData tile = spy(new TileData("minecraft:chest", new KMap<>()));
doReturn(Material.CHEST).when(tile).resolveMaterial();
return tile;
}
private static BlockData blockData(Material material) {
BlockData data = mock(BlockData.class);
when(data.getMaterial()).thenReturn(material);
return data;
}
@SafeVarargs
private static MantleChunk<Matter> customRecords(Placed<Identifier>... records) {
return records(Identifier.class, records);
}
@SafeVarargs
private static MantleChunk<Matter> tileRecords(Placed<TileData>... records) {
List<Placed<TileWrapper>> wrapped = new ArrayList<>(records.length);
for (Placed<TileData> record : records) {
wrapped.add(new Placed<>(record.x(), record.y(), record.z(), new TileWrapper(record.value())));
}
return records(TileWrapper.class, wrapped.toArray(Placed[]::new));
}
@SuppressWarnings("unchecked")
private static <T> MantleChunk<Matter> records(Class<T> type, Placed<T>[] records) {
MantleChunk<Matter> chunk = mock(MantleChunk.class);
for (Placed<T> record : records) {
when(chunk.get(record.x(), record.y(), record.z(), type)).thenReturn(record.value());
}
doAnswer((InvocationOnMock invocation) -> {
Consumer4<Integer, Integer, Integer, T> iterator = invocation.getArgument(1);
for (Placed<T> record : records) {
iterator.accept(record.x(), record.y(), record.z(), record.value());
}
return null;
}).when(chunk).iterate(eq(type), any());
return chunk;
}
private record Placed<T>(int x, int y, int z, T value) {
}
private static final class Fixture {
private final Engine engine = mock(Engine.class);
private final Chunk chunk = mock(Chunk.class);
private final ExternalDataSVC service = mock(ExternalDataSVC.class);
private Fixture() {
IrisWorld irisWorld = mock(IrisWorld.class);
when(engine.getWorld()).thenReturn(irisWorld);
World world = mock(World.class);
when(world.getMinHeight()).thenReturn(0);
when(world.getMaxHeight()).thenReturn(256);
when(world.getName()).thenReturn("example");
when(chunk.getWorld()).thenReturn(world);
}
private void carrier(Identifier identifier, Material material) {
BlockData carrier = IrisCustomData.of(blockData(material), identifier);
when(service.getBlockData(identifier)).thenReturn(Optional.of(carrier));
}
private Block block(int x, int y, int z, Material type) {
Block block = mock(Block.class);
when(block.getType()).thenReturn(type);
when(chunk.getBlock(x, y, z)).thenReturn(block);
return block;
}
private void materializeCustomBlocks(MantleChunk<Matter> records) {
try (MockedStatic<IrisServices> services = mockStatic(IrisServices.class)) {
services.when(() -> IrisServices.get(ExternalDataSVC.class)).thenReturn(service);
EngineBukkitOps.materializeCustomBlocks(engine, chunk, records);
}
}
private List<String> materializeCustomBlocksCapturingDebug(MantleChunk<Matter> records) {
List<String> messages = new ArrayList<>();
try (MockedStatic<IrisServices> services = mockStatic(IrisServices.class);
MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
services.when(() -> IrisServices.get(ExternalDataSVC.class)).thenReturn(service);
logging.when(() -> IrisLogging.debug(anyString(), any(Object[].class))).thenAnswer((InvocationOnMock invocation) -> {
Object[] arguments = invocation.getArguments();
messages.add(String.format((String) arguments[0], Arrays.copyOfRange(arguments, 1, arguments.length)));
return null;
});
EngineBukkitOps.materializeCustomBlocks(engine, chunk, records);
}
return messages;
}
}
}
@@ -0,0 +1,498 @@
package art.arcane.iris.structure.object;
import art.arcane.iris.generation.decoration.tree.IrisProceduralTree;
import art.arcane.iris.generation.decoration.tree.ProceduralTreeGenerator;
import art.arcane.iris.generation.decoration.tree.TreeBlockCanvas;
import art.arcane.iris.generation.decoration.tree.TreeGrounding;
import art.arcane.iris.generation.geometry.IrisBlockVector;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.testsupport.PlatformBinding;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.volmlib.util.math.RNG;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class TreeGroundingPlacementTest {
private static final int SURFACE_Y = 80;
@Rule
public final PlatformBinding platform = PlatformBinding.mockPlatform();
private IrisData data;
private NativeBlockState wood;
private NativeBlockState leaves;
@Before
public void bindStates() {
wood = state("minecraft:oak_log");
leaves = state("minecraft:oak_leaves");
when(leaves.isFoliage()).thenReturn(true);
Engine engine = mock(Engine.class);
when(engine.getHeight()).thenReturn(256);
data = mock(IrisData.class);
when(data.getEngine()).thenReturn(engine);
}
@Test
public void flatGroundKeepsTheAuthoredBase() {
IrisObject tree = tree(true);
Terrain terrain = new Terrain();
assertTrue(place(tree, terrain, placement()) >= 0);
assertEquals(tree.getBlocks().size(), terrain.written.size());
assertSame(wood, terrain.written.get(new Position(0, 80, 0)));
assertSame(wood, terrain.written.get(new Position(1, 81, 0)));
assertFalse(terrain.written.containsKey(new Position(1, 79, 0)));
}
@Test
public void gentleSlopeExtendsOnlyUnsupportedRootColumns() {
Terrain terrain = new Terrain();
terrain.heights.put(new Column(1, 0), 77);
assertTrue(place(tree(true), terrain, placement()) >= 0);
for (int y = 77; y <= 81; y++) {
assertSame(wood, terrain.written.get(new Position(1, y, 0)));
}
assertFalse(terrain.written.containsKey(new Position(0, 79, 0)));
assertFalse(terrain.written.containsKey(new Position(2, 79, 0)));
}
@Test
public void cliffRejectionWritesNoPartOfTheTreeOrListener() {
Terrain terrain = new Terrain();
terrain.heights.put(new Column(1, 0), 70);
Map<Position, NativeBlockState> observed = new HashMap<>();
int result = tree(true).place(0, -1, 0, terrain.placer, placement(), new RNG(42L),
(position, state) -> observed.put(new Position(position.getX(), position.getY(), position.getZ()), state),
null, data);
assertEquals(-1, result);
assertTrue(terrain.written.isEmpty());
assertTrue(observed.isEmpty());
}
@Test
public void carvedRootContactRejectsBeforeWriting() {
Terrain terrain = new Terrain();
terrain.carved.add(new Position(1, 79, 0));
assertEquals(-1, place(tree(true), terrain, placement()));
assertTrue(terrain.written.isEmpty());
}
@Test
public void rotatedTranslatedRootsUseTheirFinalWorldCoordinates() {
Terrain terrain = new Terrain();
terrain.heights.put(new Column(13, 17), 78);
IrisObjectPlacement placement = placement();
placement.setRotation(IrisObjectRotation.of(0, 90, 0));
placement.setTranslate(new IrisObjectTranslate().setX(2).setY(1).setZ(3));
int result = tree(true).place(10, -1, 20, terrain.placer, placement, new RNG(42L), data);
assertTrue(result >= 0);
assertSame(wood, terrain.written.get(new Position(13, 81, 18)));
for (int y = 78; y <= 82; y++) {
assertSame(wood, terrain.written.get(new Position(13, y, 17)));
}
assertFalse(terrain.written.containsKey(new Position(11, 80, 20)));
}
@Test
public void rootlessTreesRequireContactWithoutAddingRoots() {
Terrain flat = new Terrain();
IrisObject rootless = tree(false);
assertTrue(place(rootless, flat, placement()) >= 0);
assertEquals(rootless.getBlocks().size(), flat.written.size());
Terrain slope = new Terrain();
slope.heights.put(new Column(1, 0), 79);
assertEquals(-1, place(rootless, slope, placement()));
assertTrue(slope.written.isEmpty());
}
@Test
public void placementListenerReceivesEveryGroundingBlock() {
Terrain terrain = new Terrain();
terrain.heights.put(new Column(1, 0), 77);
Map<Position, NativeBlockState> observed = new HashMap<>();
int result = tree(true).place(0, -1, 0, terrain.placer, placement(), new RNG(42L),
(position, state) -> observed.put(new Position(position.getX(), position.getY(), position.getZ()), state),
null, data);
assertTrue(result >= 0);
assertEquals(terrain.written, observed);
assertSame(wood, observed.get(new Position(1, 77, 0)));
}
@Test
public void repeatedPlacementDoesNotMutateCachedVariants() {
IrisObject tree = tree(true);
Map<Position, NativeBlockState> original = snapshot(tree);
TreeGrounding originalGrounding = tree.getTreeGrounding();
Terrain first = new Terrain();
first.heights.put(new Column(1, 0), 77);
Terrain cliff = new Terrain();
cliff.heights.put(new Column(1, 0), 60);
Terrain second = new Terrain();
second.heights.put(new Column(1, 0), 77);
assertTrue(place(tree, first, placement()) >= 0);
assertEquals(-1, place(tree, cliff, placement()));
assertTrue(place(tree, second, placement()) >= 0);
assertEquals(first.written, second.written);
assertEquals(original, snapshot(tree));
assertSame(originalGrounding, tree.getTreeGrounding());
}
@Test
public void canopyOverhangDoesNotBecomeAGroundSupportFootprint() {
Terrain terrain = new Terrain();
terrain.heights.put(new Column(4, 0), 20);
terrain.carved.add(new Position(4, 20, 0));
assertTrue(place(tree(true), terrain, placement()) >= 0);
assertSame(leaves, terrain.written.get(new Position(4, 84, 0)));
assertFalse(terrain.written.containsKey(new Position(4, 80, 0)));
}
@Test
public void genericStiltingDoesNotRepeatTheRootBase() {
IrisObject tree = tree(true);
Terrain ordinary = new Terrain();
ordinary.heights.put(new Column(1, 0), 77);
Terrain stilted = new Terrain();
stilted.heights.put(new Column(1, 0), 77);
assertTrue(place(tree, ordinary, placement()) >= 0);
assertTrue(place(tree, stilted, placement().setMode(ObjectPlaceMode.CENTER_STILT)) >= 0);
assertEquals(ordinary.written, stilted.written);
}
@Test
public void generatedLeaningTreesKeepTheirTrunkAtThePlacementOrigin() {
NativeBlockState stone = state("minecraft:stone");
when(platform.registries().block(anyString())).thenReturn(stone);
when(platform.registries().blockOrNull(anyString(), anyBoolean())).thenReturn(stone);
IrisProceduralTree definition = new IrisProceduralTree();
definition.setTrunk("minecraft:stone");
definition.setLeaves("minecraft:stone");
definition.setLeanAngle(40);
definition.setLeanAzimuth(90);
definition.setRoots(false);
definition.setPlausible(false);
IrisObject generated = ProceduralTreeGenerator.generate(definition, 20, new RNG(42L), data);
assertNotNull(generated);
assertNotNull(generated.getTreeGrounding());
List<TreeGrounding.Root> roots = generated.getTreeGrounding().roots();
assertEquals(1, roots.size());
assertEquals(0, roots.getFirst().x());
assertEquals(0, roots.getFirst().z());
assertEquals(1 - generated.getCenter().getBlockY(), roots.getFirst().y());
Terrain terrain = new Terrain();
assertTrue(place(generated, terrain, placement()) >= 0);
assertSame(stone, terrain.written.get(new Position(0, 81, 0)));
}
@Test
public void scaledVariantsRetainGroundingAtCliffs() {
IrisObject source = tree(true);
Map<Position, NativeBlockState> original = snapshot(source);
for (double scale : new double[]{1.5, 2, 3}) {
IrisObjectPlacement placement = placement();
IrisObject scaled = placement.scaleObject(new RNG(42L), source,
new IrisDimension().setAllObjectScaleFactor(scale));
assertNotSame(source, scaled);
assertGroundingMatchesBlocks(scaled);
Terrain terrain = new Terrain();
for (TreeGrounding.Root root : scaled.getTreeGrounding().roots()) {
if (root.x() != 0 || root.z() != 0) {
terrain.heights.put(new Column(root.x(), root.z()), 40);
}
}
assertFalse(terrain.heights.isEmpty());
assertEquals(-1, place(scaled, terrain, placement));
assertTrue(terrain.written.isEmpty());
assertEquals(original, snapshot(source));
}
}
@Test
public void scaledRootMetadataMatchesBlocksForBothOrigins() {
IrisObject source = tree(true);
TreeGrounding grounding = source.getTreeGrounding();
for (double scale : new double[]{0.5, 1.5, 2, 3}) {
IrisObject centered = source.scaled(scale, IrisObjectPlacementScaleInterpolator.NONE);
IrisObject savedOrigin = source.scaledAroundOrigin(scale, IrisObjectPlacementScaleInterpolator.NONE);
assertGroundingMatchesBlocks(centered);
assertGroundingMatchesBlocks(savedOrigin);
assertEquals((int) Math.ceil(3 * scale), centered.getTreeGrounding().maxExtension());
assertEquals((int) Math.ceil(3 * scale), savedOrigin.getTreeGrounding().maxExtension());
assertSame(grounding, source.getTreeGrounding());
}
}
@Test
public void rotatedCopiesKeepMetadataOnTheirActualRootBlocks() {
IrisObject source = tree(true);
Map<Position, NativeBlockState> original = snapshot(source);
TreeGrounding grounding = source.getTreeGrounding();
for (int angle : new int[]{45, 90, 180, 270}) {
IrisObject rotated = source.rotateCopy(IrisObjectRotation.of(0, angle, 0));
assertGroundingMatchesBlocks(rotated);
assertEquals(3, rotated.getTreeGrounding().maxExtension());
assertEquals(original, snapshot(source));
assertSame(grounding, source.getTreeGrounding());
}
}
@Test
public void shrinkwrapMovesRootMetadataAndInvalidatesItsFootprint() {
IrisObject source = tree(true);
Map<Position, NativeBlockState> original = snapshot(source);
IrisObject shrunk = source.copy();
List<IrisBlockVector> oldFootprint = List.copyOf(shrunk.getSurfaceSupportOffsets());
shrunk.shrinkwrap();
assertGroundingMatchesBlocks(shrunk);
assertFalse(oldFootprint.equals(shrunk.getSurfaceSupportOffsets()));
assertEquals(shrunk.getTreeGrounding().supportOffsets(), shrunk.getSurfaceSupportOffsets());
assertEquals(original, snapshot(source));
}
@Test
public void overlappingCanopiesPreserveWoodAndSkipListenerWrites() {
for (Palette palette : List.of(new Palette("minecraft:oak_wood", "minecraft:birch_leaves"),
new Palette("minecraft:warped_hyphae", "minecraft:warped_wart_block"),
new Palette("test:trunk", "test:canopy"))) {
wood = state(palette.trunk());
leaves = state(palette.canopy());
Terrain terrain = new Terrain();
IrisObject earlier = tree(true);
earlier.getBlocks().put(new IrisBlockVector(0, 1, 0), wood);
assertTrue(earlier.place(4, -1, 0, terrain.placer, placement(), new RNG(42L), data) >= 0);
IrisObject later = tree(true);
later.getBlocks().put(new IrisBlockVector(4, 2, 0), leaves);
later.setTreeGrounding(TreeGrounding.create(later.getTreeGrounding().roots(), 3,
Set.of(new TreeBlockCanvas.Vec(4, 1, 0), new TreeBlockCanvas.Vec(4, 2, 0))));
Map<Position, NativeBlockState> observed = new HashMap<>();
int result = later.place(0, -1, 0, terrain.placer, placement(), new RNG(42L),
(position, state) -> observed.put(new Position(position.getX(), position.getY(), position.getZ()), state),
null, data);
assertTrue(result >= 0);
assertSame(wood, terrain.written.get(new Position(4, 84, 0)));
assertFalse(observed.containsKey(new Position(4, 84, 0)));
assertSame(leaves, observed.get(new Position(4, 85, 0)));
}
}
@Test
public void canopyRolesRemainDistinctWhenWoodAndLeavesShareAMaterial() {
leaves = wood;
NativeBlockState existing = state("test:older_trunk");
Terrain terrain = new Terrain();
terrain.written.put(new Position(4, 84, 0), existing);
terrain.written.put(new Position(0, 83, 0), existing);
assertTrue(place(tree(true), terrain, placement()) >= 0);
assertSame(existing, terrain.written.get(new Position(4, 84, 0)));
assertSame(wood, terrain.written.get(new Position(0, 83, 0)));
}
@Test
public void treeWoodStillReplacesOverlappingCanopy() {
Terrain terrain = new Terrain();
terrain.written.put(new Position(0, 83, 0), leaves);
assertTrue(place(tree(true), terrain, placement()) >= 0);
assertSame(wood, terrain.written.get(new Position(0, 83, 0)));
}
@Test
public void ordinaryObjectCanopiesKeepTheirPlacementSemantics() {
Terrain terrain = new Terrain();
terrain.written.put(new Position(4, 84, 0), wood);
IrisObject ordinary = tree(true);
ordinary.setTreeGrounding(null);
assertTrue(place(ordinary, terrain, placement()) >= 0);
assertSame(leaves, terrain.written.get(new Position(4, 84, 0)));
}
@Test
public void transformedCanopyRolesStillProtectExistingSolids() {
IrisObject source = tree(true);
IrisObject shrunk = source.copy();
shrunk.shrinkwrap();
for (IrisObject transformed : List.of(source.copy(), shrunk,
source.rotateCopy(IrisObjectRotation.of(0, 90, 0)),
source.scaled(2, IrisObjectPlacementScaleInterpolator.NONE),
source.scaledAroundOrigin(2, IrisObjectPlacementScaleInterpolator.NONE))) {
assertGroundingMatchesBlocks(transformed);
TreeBlockCanvas.Vec canopy = transformed.getTreeGrounding().canopy().iterator().next();
Position target = new Position(canopy.x(), 100 + canopy.y(), canopy.z());
NativeBlockState existing = state("test:older_trunk");
Terrain terrain = new Terrain();
terrain.written.put(target, existing);
assertTrue(transformed.place(0, 100, 0, terrain.placer, placement(), new RNG(42L), data) >= 0);
assertSame(existing, terrain.written.get(target));
}
}
private int place(IrisObject tree, Terrain terrain, IrisObjectPlacement placement) {
return tree.place(0, -1, 0, terrain.placer, placement, new RNG(42L), data);
}
private IrisObjectPlacement placement() {
return new IrisObjectPlacement().setMode(ObjectPlaceMode.CENTER_HEIGHT)
.setRotation(IrisObjectRotation.of(0, 0, 0)).setSurfaceSupportBuffer(0);
}
private IrisObject tree(boolean rooted) {
IrisObject tree = new IrisObject(9, 7, 3);
tree.setLoader(data);
tree.getBlocks().put(new IrisBlockVector(0, -2, 0), wood);
tree.getBlocks().put(new IrisBlockVector(0, -1, 0), wood);
tree.getBlocks().put(new IrisBlockVector(0, 0, 0), wood);
tree.getBlocks().put(new IrisBlockVector(1, -2, 0), wood);
tree.getBlocks().put(new IrisBlockVector(4, 0, 0), wood);
tree.getBlocks().put(new IrisBlockVector(4, 1, 0), leaves);
List<TreeGrounding.Root> roots = new ArrayList<>();
if (rooted) {
tree.getBlocks().put(new IrisBlockVector(0, -3, 0), wood);
roots.add(new TreeGrounding.Root(0, -3, 0, wood));
} else {
roots.add(new TreeGrounding.Root(0, -2, 0, wood));
}
roots.add(new TreeGrounding.Root(1, -2, 0, wood));
tree.setTreeGrounding(TreeGrounding.create(roots, rooted ? 3 : 0,
Set.of(new TreeBlockCanvas.Vec(4, 1, 0))));
return tree;
}
private static Map<Position, NativeBlockState> snapshot(IrisObject tree) {
Map<Position, NativeBlockState> snapshot = new HashMap<>();
for (Map.Entry<IrisBlockVector, NativeBlockState> entry : tree.getBlocks()) {
IrisBlockVector position = entry.getKey();
snapshot.put(new Position(position.getBlockX(), position.getBlockY(), position.getBlockZ()), entry.getValue());
}
return snapshot;
}
private void assertGroundingMatchesBlocks(IrisObject tree) {
TreeGrounding grounding = tree.getTreeGrounding();
assertNotNull(grounding);
assertFalse(grounding.roots().isEmpty());
Set<Column> columns = new HashSet<>();
for (TreeGrounding.Root root : grounding.roots()) {
assertTrue(columns.add(new Column(root.x(), root.z())));
assertSame(root.state(), tree.getBlocks().get(new IrisBlockVector(root.x(), root.y(), root.z())));
assertFalse(root.state().isFoliage());
}
for (Map.Entry<IrisBlockVector, NativeBlockState> entry : tree.getBlocks()) {
IrisBlockVector cell = entry.getKey();
assertEquals(entry.getValue() == leaves, grounding.isCanopy(cell.getBlockX(), cell.getBlockY(), cell.getBlockZ()));
}
}
private static NativeBlockState state(String key) {
NativeBlockState state = mock(NativeBlockState.class);
when(state.key()).thenReturn(key);
when(state.materialKey()).thenReturn(key);
when(state.isSolid()).thenReturn(true);
when(state.isOccluding()).thenReturn(true);
return state;
}
private static final class Terrain {
private final IObjectPlacer placer = mock(IObjectPlacer.class);
private final Map<Column, Integer> heights = new HashMap<>();
private final Map<Position, NativeBlockState> written = new HashMap<>();
private final Set<Position> carved = new HashSet<>();
private Terrain() {
when(placer.beginTreeCanopy(any())).thenCallRealMethod();
when(placer.getHighest(anyInt(), anyInt(), any())).thenAnswer(invocation ->
surface(invocation.getArgument(0), invocation.getArgument(1)));
when(placer.getHighest(anyInt(), anyInt(), any(), anyBoolean())).thenAnswer(invocation ->
surface(invocation.getArgument(0), invocation.getArgument(1)));
when(placer.isSurfaceSolid(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> {
int x = invocation.getArgument(0);
int y = invocation.getArgument(1);
int z = invocation.getArgument(2);
return y >= 0 && y <= surface(x, z) && !carved.contains(new Position(x, y, z));
});
when(placer.isCarved(anyInt(), anyInt(), anyInt())).thenAnswer(invocation ->
carved.contains(new Position(invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2))));
when(placer.get(anyInt(), anyInt(), anyInt())).thenAnswer(invocation ->
written.get(new Position(invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2))));
doAnswer(invocation -> {
Position position = new Position(invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2));
NativeBlockState state = invocation.getArgument(3);
written.put(position, state);
return null;
}).when(placer).set(anyInt(), anyInt(), anyInt(), any());
}
private int surface(int x, int z) {
return heights.getOrDefault(new Column(x, z), SURFACE_Y);
}
}
private record Column(int x, int z) {
}
private record Position(int x, int y, int z) {
}
private record Palette(String trunk, String canopy) {
}
}
@@ -18,7 +18,7 @@ TaskProvider<JavaExec> resolvedRevisionVerification = tasks.register('verifyReso
classpath = files('../../buildSrc/build/libs/buildSrc.jar')
mainClass.set('art.arcane.iris.buildtools.GenerationBuildRevision')
doFirst {
String runtimeVariant = providers.gradleProperty('irisRuntimeJava').getOrElse(irisModTarget.java.toString()) == '21' ? 'java21/' : ''
String runtimeVariant = gradle.extensions.extraProperties.get('irisRuntimeOutputVariant').toString()
List<String> arguments = ['verify-dependencies', file("../../core/build/${runtimeVariant}generated/generation-revision/META-INF/iris/generation-kernels/abi-1.revision").absolutePath]
for (ResolvedArtifact artifact : configurations.bundle.resolvedConfiguration.resolvedArtifacts) {
String group = artifact.moduleVersion.id.group
+23
View File
@@ -0,0 +1,23 @@
import groovy.json.JsonSlurper
boolean adapter = rootProject.name.startsWith('iris-')
File irisDirectory = adapter ? new File(settingsDir, '../..').canonicalFile : settingsDir
String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse('26.3')
Map<String, Map<String, Object>> modTargets = (Map<String, Map<String, Object>>) new JsonSlurper()
.parse(new File(irisDirectory, 'gradle/mod-targets.json'))
Map<String, Object> modTarget = modTargets.get(minecraftVersion)
if (modTarget == null) {
throw new GradleException("Unsupported Iris mod target ${minecraftVersion}; choose ${modTargets.keySet().join(', ')}")
}
String runtimeJava = providers.gradleProperty('irisRuntimeJava').getOrElse(modTarget.java.toString())
String variant = adapter ? "minecraft${minecraftVersion}-java${runtimeJava}" : "java${runtimeJava}"
File projectCacheRoot = gradle.startParameter.projectCacheDir ?: new File(settingsDir, '.gradle')
gradle.startParameter.projectCacheDir = new File(projectCacheRoot, variant)
String runtimeOutputVariant = runtimeJava == '21' ? 'java21/' : ''
if (adapter) {
runtimeOutputVariant = "modded/${rootProject.name}/${variant}/"
} else if (gradle.parent != null && gradle.parent.extensions.extraProperties.has('irisRuntimeOutputVariant')) {
runtimeOutputVariant = gradle.parent.extensions.extraProperties.get('irisRuntimeOutputVariant').toString()
}
gradle.extensions.extraProperties.set('irisRuntimeOutputVariant', runtimeOutputVariant)
+1
View File
@@ -22,6 +22,7 @@ plugins {
rootProject.name = 'Iris'
apply from: new File(settingsDir, 'gradle/runtime-cache.settings.gradle')
apply from: new File(settingsDir, 'gradle/volmlib-resolution.settings.gradle')
include(':core', ':core:agent')