Compare commits

...
Author SHA1 Message Date
Brian Neumann-Fopiano cfa274825f Refine terrain and pregeneration
- Add coherent river width variation and shape source and terminal pools.
- Preserve separated hydrology intervals when checking river connectivity.
- Reduce footprint sorting, engine context lookup, and terrain blending work.
- Reuse modded snapshot validation after content and registry checks.
- Explain production pack update staging and restart requirements.
- Retain pregeneration map updates before its desktop window opens.
- Improve pregeneration layout, coordinate hover, keyboard controls, and error details.
- Add the new desktop labels to every supported language catalog.
- Add the buildPsychoLT entry point for verified platform outputs.
- Cover terrain, validation, and desktop behavior with regression tests.
2026-10-07 10:25:41 -04:00
Brian Neumann-Fopiano e72189adc7 Adopt cooperative reload cleanup
- Return cleanup completion from the committed Iris reload callback
2026-10-07 10:24:59 -04:00
Brian Neumann-Fopiano 7f5ce70f49 Improve generation and lifecycle safety
- Reduce terrain, noise, hydrology, and decorator sampling overhead.
- Reduce object transaction allocations while preserving rollback behavior.
- Optimize structure placement and cave anchor sampling.
- Parallelize Studio cache warmup and protect world entry and hotload recovery.
- Validate pack fingerprints and publish pack replacements atomically.
- Notify modded operators about outdated packs without synchronizing pack data.
- Harden world creation, loading, evacuation, and teleport failure recovery.
- Coalesce queued region flushes while preserving chunk save ordering.
- Remove obsolete mantle, pack, and cave planning code.
- Add regression tests and reusable gameplay fixtures.
2026-10-06 20:51:28 -04:00
Brian Neumann-Fopiano a2bcd7a302 V+
- Bump Iris from 4.3.0-26.2 to 4.3.1-26.2.
- Align Fabric, Forge, and NeoForge version defaults with the release.
2026-10-05 16:48:32 -04:00
Brian Neumann-Fopiano 7226fc4400 Refine organic cave generation
- Add seeded cave surface variation and rounded basin undersides.
- Keep dry cave intersections open while preserving fluid seals.
- Apply room biome decorators and preserve their floor substrates.
- Allow ceiling growth above reserved walking clearance.
- Restrict feature placement by region across live and saved generation.
- Cover cave geometry, placement, decoration, and regional filtering with regression tests.
2026-10-05 16:22:55 -04:00
Brian Neumann-Fopiano c479e94c49 Fix biome names and conditions
- Register readable client biome IDs while preserving saved registry identities.
- Audit the modded registry naming mixin.
- Return false from MythicMobs conditions while saved biome data loads.
- Cover biome naming and MythicMobs loading behavior with regression tests.
2026-10-04 20:10:26 -04:00
Brian Neumann-Fopiano ee5aaec87c Fixed Structure Crash
- Don't carash anymore when you disable structures in the server.properties.
2026-10-04 15:32:54 -04:00
Brian Neumann-Fopiano 65ae3f1b45 Cleanup 2026-10-04 15:17:12 -04:00
Brian Neumann-Fopiano a6149b27c9 Adapter patches
(tests)
2026-10-04 13:08:41 -04:00
Brian Neumann-Fopiano 4b4b418c61 Merge Iris generation changes
- Combine authored subterrain features with native CPU generation.
- Verify dimension JSON and schemas preserve subterrain configuration.
2026-10-04 10:11:44 -04:00
Brian Neumann-Fopiano 8d5cf43139 Enable native noise generation
- Enable exact Rust Billow sampling and report the selected backend.
- Add generation parity probes and native comparison tooling.
2026-10-04 09:59:58 -04:00
Brian Neumann-Fopiano 5858eb366d Add subterrain features
- Generate authored underground rooms, materials, fluids, and formations.
- Preserve underground biome ownership across generation and saved terrain.
- Add subterrain searches and regression coverage.
2026-10-04 09:59:57 -04:00
Brian Neumann-Fopiano be0d3b0156 Fix seeded entity spawning
- Derive initial chunk and marker populations from stable location seeds.
- Persist initial spawn completion after region-owned application.
- Seed entity loot and attribute modifier identities.
- Cover spawning, reload completion, and registry fixtures with regression tests.
2026-10-04 06:14:46 -04:00
Brian Neumann-Fopiano 12e8bb5a09 v 2026-10-02 17:13:30 -04:00
Brian Neumann-Fopiano a765e76aa3 Restore natural mob spawning
- Restore cave spawning and correct floor placement across platforms.
- Enforce category population limits and clamp ambient spawn batches.
- Honor mob-spawning rules and entity-ticking chunk admission.
- Separate chunk saving from configurable distance-despawn retention.
- Add spawning regressions and Folia gameplay acceptance coverage.
2026-10-02 16:55:52 -04:00
Brian Neumann-Fopiano 4ef6fc7cc6 Enforce seed cohesion
- Scope cached generation fields to their seed and context.
- Warm decorators and ores with their canonical generation seeds.
- Stabilize object traversal and procedural placement choices.
- Isolate smart-bore variants from shared source objects.
- Pin noise and full-engine output across generation orders.
- Verify same-jar generation through cold server restarts.
2026-10-02 14:43:48 -04:00
Brian Fopiano 6b82ea99e2 Isolate scaled noise generators
- Isolate biome-child and carving-child noise generators.
- Isolate decorator variance generators.
- Share fresh scaled-generator construction.
- Test shared-generator isolation at each caller.
2026-10-02 07:48:01 -04:00
Brian Fopiano 813f3c7bcf Fix continent zoom compounding
- Build independent continent generators before applying zoom.
- Share continent construction across runtime contexts.
- Cover repeated construction and shared-generator isolation.
2026-10-02 07:47:31 -04:00
CeeVeeR 318c1e100b chore:
- Extracted shared style generator sampling to GeneratorSampling.sampleSharedGenerator

Ref.: https://github.com/VolmitSoftware/Iris/issues/1251
2026-10-02 12:37:51 +02:00
CeeVeeR 357f14e900 chore:
- Extracted shared SAMPLE_BLOCKS to GeneratorSampling

Ref.: https://github.com/VolmitSoftware/Iris/issues/1251
2026-10-02 12:36:55 +02:00
CeeVeeR 99cc2383d6 chore:
- Replaced IrisBiome children generator scaling with createScaledGenerator

Ref.: https://github.com/VolmitSoftware/Iris/issues/1251
2026-10-02 12:35:22 +02:00
CeeVeeR 76d71f8344 chore:
- Replaced IrisDimensionCarvingEntry shrink scaling with createScaledGenerator

Ref.: https://github.com/VolmitSoftware/Iris/issues/1251
2026-10-02 12:34:21 +02:00
CeeVeeR 1544c34f44 chore:
- Moved continent zoom scaling to IrisGeneratorStyle.createScaledGenerator

Ref.: https://github.com/VolmitSoftware/Iris/issues/1251
2026-10-02 12:33:17 +02:00
CeeVeeR 00601678d9 fix:
- Replaced IrisGeneratorStyle.create with createNoCache in IrisDecorator
- Removed @ExpectedToFailUntilFixed from the IrisDecorator test

Ref.: https://github.com/VolmitSoftware/Iris/issues/1251
2026-10-02 12:17:57 +02:00
CeeVeeR 700dc53559 fix:
- Replaced IrisGeneratorStyle.create with createNoCache in IrisBiome
- Removed @ExpectedToFailUntilFixed from the IrisBiome test

Ref.: https://github.com/VolmitSoftware/Iris/issues/1251
2026-10-02 12:17:19 +02:00
CeeVeeR 0faae93049 fix:
- Replaced IrisGeneratorStyle.create with createNoCache in IrisDimensionCarvingEntry
- Removed @ExpectedToFailUntilFixed from the IrisDimensionCarvingEntry test

Ref.: https://github.com/VolmitSoftware/Iris/issues/1251
2026-10-02 12:16:41 +02:00
CeeVeeR 92310ef839 test:
- Added decorator shared variance style test, expected to fail until the fix

Ref.: https://github.com/VolmitSoftware/Iris/issues/1251
2026-10-02 12:15:33 +02:00
CeeVeeR 5fa6d82b79 chore:
- Extracted first variance generator build to buildVarianceGeneratorOnce

Ref.: https://github.com/VolmitSoftware/Iris/issues/1251
2026-10-02 12:14:58 +02:00
CeeVeeR 2078bece40 chore:
- Extracted variance generator construction to createVarianceGenerator

Ref.: https://github.com/VolmitSoftware/Iris/issues/1251
2026-10-02 12:14:23 +02:00
CeeVeeR 077b48f47f chore:
- Extracted variance seed derivation to deriveVarianceSeed

Ref.: https://github.com/VolmitSoftware/Iris/issues/1251
2026-10-02 12:13:48 +02:00
CeeVeeR 7981fa3ce5 test:
- Added biome shared child style test, expected to fail until the fix

Ref.: https://github.com/VolmitSoftware/Iris/issues/1251
2026-10-02 12:11:32 +02:00
CeeVeeR 5c4f7bb521 chore:
- Extracted children generator construction to createChildrenGenerator

Ref.: https://github.com/VolmitSoftware/Iris/issues/1251
2026-10-02 12:10:44 +02:00
CeeVeeR 0d16f8e57f chore:
- Extracted magic value 2137 to CHILDREN_SEED_SALT

Ref.: https://github.com/VolmitSoftware/Iris/issues/1251
2026-10-02 12:10:10 +02:00
CeeVeeR cf7ebcece1 chore:
- Extracted child style seed derivation to deriveChildStyleSeed

Ref.: https://github.com/VolmitSoftware/Iris/issues/1251
2026-10-02 12:09:35 +02:00
CeeVeeR 8f8a3b2355 chore:
- Extracted magic value 0.0001 to MINIMUM_CHILD_SHRINK_FACTOR

Ref.: https://github.com/VolmitSoftware/Iris/issues/1251
2026-10-02 12:08:42 +02:00
CeeVeeR ffc409a4ac chore:
- Extracted magic value 2137 to CHILDREN_SEED_SALT

Ref.: https://github.com/VolmitSoftware/Iris/issues/1251
2026-10-02 12:08:07 +02:00
CeeVeeR 5b5c1975c6 chore:
- Extracted generator sampling and running engine pack data to testsupport

Ref.: https://github.com/VolmitSoftware/Iris/issues/1251
2026-10-02 12:07:10 +02:00
Brian Neumann-Fopiano 25c04c0157 Integrate optional terrain transforms
- Add versioned terrain providers that run before content placement.
- Resolve transformed terrain heights, occupancy, and cave metadata through bounded caches.
- Initialize native structures after binding the modded generation runtime.
- Cover transform behavior, provider resolution, and structure binding with regression tests.
2026-10-02 05:39:20 -04:00
CeeVeeR 1e25c07eac test:
- Added carving entry shared child style test, expected to fail until the fix

Ref.: https://github.com/VolmitSoftware/Iris/issues/1251
2026-10-02 07:31:17 +02:00
CeeVeeR ff74a6f811 chore:
- Extracted children generator construction to createChildrenGenerator

Ref.: https://github.com/VolmitSoftware/Iris/issues/1251
2026-10-02 07:30:25 +02:00
CeeVeeR 9e07009f1d chore:
- Extracted child shrink scaling to scaleByShrinkFactor

Ref.: https://github.com/VolmitSoftware/Iris/issues/1251
2026-10-02 07:29:53 +02:00
CeeVeeR 53172c38ad chore:
- Extracted child style seed derivation to deriveChildStyleSeed

Ref.: https://github.com/VolmitSoftware/Iris/issues/1251
2026-10-02 07:29:21 +02:00
CeeVeeR 658c6b0fde chore:
- Extracted children generator seed derivation to deriveChildrenGeneratorSeed

Ref.: https://github.com/VolmitSoftware/Iris/issues/1251
2026-10-02 07:28:29 +02:00
CeeVeeR b998ff535a chore:
- Extracted entry id hashing to hashOfId

Ref.: https://github.com/VolmitSoftware/Iris/issues/1251
2026-10-02 07:27:56 +02:00
CeeVeeR ba4708f2bb chore:
- Renamed takeSharedGenerator to createContinentalStyleGenerator

Ref.: https://github.com/VolmitSoftware/Iris/issues/1251
2026-10-02 05:23:25 +02:00
CeeVeeR bda770650b fix:
- Replaced IrisGeneratorStyle.create with createNoCache in ContinentGenerator
- Removed @ExpectedToFailUntilFixed from the continent tests

Ref.: https://github.com/VolmitSoftware/Iris/issues/1251
2026-10-02 05:14:52 +02:00
CeeVeeR 8bccb48c09 test:
- Added shared style generator test, expected to fail until the fix

Ref.: https://github.com/VolmitSoftware/Iris/issues/1251
2026-10-02 05:13:26 +02:00
CeeVeeR a9df19cae2 chore:
- Opened ContinentGenerator.deriveContinentSeed to the package

Ref.: https://github.com/VolmitSoftware/Iris/issues/1251
2026-10-02 05:13:22 +02:00
CeeVeeR 4ac49baaff test:
- Added overworld continental style rebuild test, expected to fail until the fix

Ref.: https://github.com/VolmitSoftware/Iris/issues/1251
2026-10-02 05:12:56 +02:00
CeeVeeR 38a7e0fdb5 test:
- Added second rebuild test, expected to fail until the fix

Ref.: https://github.com/VolmitSoftware/Iris/issues/1251
2026-10-02 05:12:51 +02:00
CeeVeeR 7e73e8f599 test:
- Added rebuild test, expected to fail until the fix

Ref.: https://github.com/VolmitSoftware/Iris/issues/1251
2026-10-02 05:12:47 +02:00
CeeVeeR 788ac06713 test:
- Added continent zoom stretch test

Ref.: https://github.com/VolmitSoftware/Iris/issues/1251
2026-10-02 05:12:43 +02:00
CeeVeeR 622b40f786 test:
- Added ContinentGeneratorTest checking the continent map at world start

Ref.: https://github.com/VolmitSoftware/Iris/issues/1251
2026-10-02 05:12:40 +02:00
CeeVeeR 72eff9b775 feat:
- Added ExpectedToFailUntilFixed marker and rule

Ref.: https://github.com/VolmitSoftware/Iris/issues/1251
2026-10-02 05:12:37 +02:00
CeeVeeR 789644b93f chore:
- Replaced DimensionTerrainContext continent chain with ContinentGenerator

Ref.: https://github.com/VolmitSoftware/Iris/issues/1251
2026-10-02 04:23:14 +02:00
CeeVeeR f07dfb7a85 chore:
- Inlined IrisComplex.createContinentGenerator

Ref.: https://github.com/VolmitSoftware/Iris/issues/1251
2026-10-02 04:22:42 +02:00
CeeVeeR 5bffa6eb04 chore:
- Moved continent generator construction to ContinentGenerator

Ref.: https://github.com/VolmitSoftware/Iris/issues/1251
2026-10-02 04:22:10 +02:00
CeeVeeR 0b6ac19fa7 chore:
- Extracted continent generator construction to createContinentGenerator

Ref.: https://github.com/VolmitSoftware/Iris/issues/1251
2026-10-02 04:21:38 +02:00
CeeVeeR b7e6259a05 chore:
- Extracted shared continent generator lookup to takeSharedContinentGenerator

Ref.: https://github.com/VolmitSoftware/Iris/issues/1251
2026-10-02 04:21:05 +02:00
CeeVeeR a0aa62b8c6 chore:
- Extracted continent zoom scaling to scaleByContinentZoom

Ref.: https://github.com/VolmitSoftware/Iris/issues/1251
2026-10-02 04:20:32 +02:00
CeeVeeR d80fcedd91 chore:
- Extracted continent seed derivation to createContinentSeedSource

Ref.: https://github.com/VolmitSoftware/Iris/issues/1251
2026-10-02 04:19:59 +02:00
CeeVeeR 41664fc605 chore:
- Extracted magic value 234234565 to CONTINENT_SEED_SALT

Ref.: https://github.com/VolmitSoftware/Iris/issues/1251
2026-10-02 04:19:27 +02:00
Brian Neumann-Fopiano 74df63f717 Merged React metric fixes
- Pregen queue reports in-flight chunk requests
- Pregen remaining reports chunks left
- Generation timings go unavailable after ten seconds without generation
2026-09-30 10:10:15 -04:00
Brian Neumann-Fopiano f46041690b Test timing freshness gate
- Test that capture drops generation timings once generation is idle
- Test that capture carries the engine timing averages while fresh
- Remove the hand-built idle snapshot test that passed without the gate
2026-09-29 18:37:15 -04:00
Brian Neumann-Fopiano b8433340c9 Split pregen queue metrics
- Publish in-flight pregen chunk requests as iris.pregen-queue
- Publish chunks left in the job as iris.pregen-remaining
- Mark generation timings unavailable after 10 seconds without generation
2026-09-29 18:09:55 -04:00
Brian Neumann-Fopiano 593c3f732a Studio hotload deadlock fix
- Live boundary capture runs without the generation history lock
- Promotion prepares the boundary and activates under the lock in separate steps
- Tab completion and find lookups stay responsive while a Studio save captures terrain
- Admission test covers history reads during the boundary checkpoint
2026-09-29 11:58:25 -04:00
511 changed files with 25238 additions and 5836 deletions

No files matched your search

+5
View File
@@ -76,3 +76,8 @@ BUILDS/
.compatwt/ .compatwt/
.worktrees/ .worktrees/
/VolmLib/
/docs/
/.perf/
/.codegraph/
@@ -14,6 +14,7 @@ import net.minecraft.SharedConstants;
import net.minecraft.server.Bootstrap; import net.minecraft.server.Bootstrap;
import net.minecraft.world.level.LevelHeightAccessor; import net.minecraft.world.level.LevelHeightAccessor;
import net.minecraft.world.level.levelgen.Heightmap; import net.minecraft.world.level.levelgen.Heightmap;
import org.junit.AfterClass;
import org.junit.BeforeClass; import org.junit.BeforeClass;
import org.junit.Rule; import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
@@ -45,6 +46,11 @@ public final class IrisChunkGeneratorHeightCacheTest {
NativeBlockTags.bindHeightmapFixtures(); NativeBlockTags.bindHeightmapFixtures();
} }
@AfterClass
public static void restoreBlockTags() {
NativeBlockTags.restoreTags();
}
@Test @Test
public void repeatedNativeHeightUsesLiveHeightQueriesAcrossRuntimeChanges() throws Exception { public void repeatedNativeHeightUsesLiveHeightQueriesAcrossRuntimeChanges() throws Exception {
Fixture fixture = fixture(); Fixture fixture = fixture();
@@ -20,6 +20,7 @@ import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.RotatedPillarBlock; import net.minecraft.world.level.block.RotatedPillarBlock;
import net.minecraft.world.level.block.LiquidBlock; import net.minecraft.world.level.block.LiquidBlock;
import net.minecraft.world.level.levelgen.Heightmap; import net.minecraft.world.level.levelgen.Heightmap;
import org.junit.AfterClass;
import org.junit.BeforeClass; import org.junit.BeforeClass;
import org.junit.Rule; import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
@@ -46,6 +47,11 @@ public final class NativeTransitionColumnTest {
NativeBlockTags.bindHeightmapFixtures(); NativeBlockTags.bindHeightmapFixtures();
} }
@AfterClass
public static void restoreBlockTags() {
NativeBlockTags.restoreTags();
}
@Test @Test
public void customKeysUseCarrierForNativeColumnsAndHeightmaps() { public void customKeysUseCarrierForNativeColumnsAndHeightmaps() {
String key = "itemsadder:rocks/ruby_ore"; String key = "itemsadder:rocks/ruby_ore";
@@ -1,21 +1,30 @@
package art.arcane.iris.nativegen.v26_3_R1; package art.arcane.iris.nativegen.v26_3_R1;
import net.minecraft.core.Holder; import net.minecraft.core.Holder;
import net.minecraft.core.HolderSet;
import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.core.registries.Registries; import net.minecraft.core.registries.Registries;
import net.minecraft.tags.BlockTags; import net.minecraft.tags.BlockTags;
import net.minecraft.tags.TagLoader; import net.minecraft.tags.TagLoader;
import net.minecraft.tags.TagKey;
import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.Blocks;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
public final class NativeBlockTags { public final class NativeBlockTags {
private static Map<TagKey<Block>, List<Holder<Block>>> originalTags;
private NativeBlockTags() { private NativeBlockTags() {
} }
public static void bindHeightmapFixtures() { public static void bindHeightmapFixtures() {
originalTags = new HashMap<>();
for (HolderSet.Named<Block> tag : BuiltInRegistries.BLOCK.getTags().toList()) {
originalTags.put(tag.key(), tag.stream().toList());
}
List<Holder<Block>> solid = List.of(Blocks.STONE.builtInRegistryHolder(), List<Holder<Block>> solid = List.of(Blocks.STONE.builtInRegistryHolder(),
Blocks.OAK_LOG.builtInRegistryHolder(), Blocks.SPRUCE_LOG.builtInRegistryHolder()); Blocks.OAK_LOG.builtInRegistryHolder(), Blocks.SPRUCE_LOG.builtInRegistryHolder());
List<Holder<Block>> canopy = List.of(Blocks.STONE.builtInRegistryHolder(), List<Holder<Block>> canopy = List.of(Blocks.STONE.builtInRegistryHolder(),
@@ -25,4 +34,12 @@ public final class NativeBlockTags {
BlockTags.BLOCKS_MOTION_IN_HEIGHTMAP, canopy, BlockTags.BLOCKS_MOTION_IN_HEIGHTMAP, canopy,
BlockTags.BLOCKS_MOTION_IN_HEIGHTMAP_NO_LEAVES, solid))).apply(); BlockTags.BLOCKS_MOTION_IN_HEIGHTMAP_NO_LEAVES, solid))).apply();
} }
public static void restoreTags() {
if (originalTags == null) {
return;
}
BuiltInRegistries.BLOCK.prepareTagReload(new TagLoader.LoadResult<>(Registries.BLOCK, originalTags)).apply();
originalTags = null;
}
} }
@@ -34,6 +34,7 @@ import net.minecraft.world.level.levelgen.structure.pieces.PiecesContainer;
import net.minecraft.world.level.levelgen.structure.structures.IglooPieces; import net.minecraft.world.level.levelgen.structure.structures.IglooPieces;
import net.minecraft.world.level.levelgen.structure.structures.SwampHutPiece; import net.minecraft.world.level.levelgen.structure.structures.SwampHutPiece;
import net.minecraft.world.level.levelgen.structure.structures.SwampHutStructure; import net.minecraft.world.level.levelgen.structure.structures.SwampHutStructure;
import org.junit.AfterClass;
import org.junit.BeforeClass; import org.junit.BeforeClass;
import org.junit.Test; import org.junit.Test;
@@ -68,6 +69,11 @@ public class WorldgenTerrainHeightmapsTest {
NativeBlockTags.bindHeightmapFixtures(); NativeBlockTags.bindHeightmapFixtures();
} }
@AfterClass
public static void restoreBlockTags() {
NativeBlockTags.restoreTags();
}
@Test @Test
public void authoringHeightmapsReadPlacedObjectsAndFluidsWithoutTerrainResolvers() { public void authoringHeightmapsReadPlacedObjectsAndFluidsWithoutTerrainResolvers() {
ProtoChunk chunk = terrainChunk(new ChunkPos(0, 0)); ProtoChunk chunk = terrainChunk(new ChunkPos(0, 0));
@@ -13,6 +13,7 @@ import com.google.gson.JsonParser;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.junit.AfterClass; import org.junit.AfterClass;
import org.junit.BeforeClass; import org.junit.BeforeClass;
import org.junit.ClassRule;
import org.junit.Test; import org.junit.Test;
import java.io.File; import java.io.File;
@@ -26,6 +27,9 @@ import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertThrows;
public final class BareMobSpawnHistoryTest { public final class BareMobSpawnHistoryTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
private static final String LEGACY_RENDERER_IDENTITY = "bukkit-v26_3_R1-generated-registry-json-v1"; private static final String LEGACY_RENDERER_IDENTITY = "bukkit-v26_3_R1-generated-registry-json-v1";
private static final String NATURAL_MOB_SPAWNS = "minecraft:gameplay/natural_mob_spawns"; private static final String NATURAL_MOB_SPAWNS = "minecraft:gameplay/natural_mob_spawns";
private static final GenerationRegistryContract.PhysicalResourceKey INSTALLED = private static final GenerationRegistryContract.PhysicalResourceKey INSTALLED =
@@ -29,6 +29,7 @@ import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager;
import net.minecraft.world.level.storage.LevelStorageSource; import net.minecraft.world.level.storage.LevelStorageSource;
import org.junit.Rule; import org.junit.Rule;
import org.junit.ClassRule;
import org.junit.Test; import org.junit.Test;
import org.junit.rules.TemporaryFolder; import org.junit.rules.TemporaryFolder;
import org.bukkit.craftbukkit.generator.CustomChunkGenerator; import org.bukkit.craftbukkit.generator.CustomChunkGenerator;
@@ -49,6 +50,9 @@ import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
public class HeadlessChunkGeneratorTest { public class HeadlessChunkGeneratorTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@Rule @Rule
public TemporaryFolder temporary = new TemporaryFolder(); public TemporaryFolder temporary = new TemporaryFolder();
@@ -31,6 +31,7 @@ import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemp
import net.minecraft.world.level.storage.LevelStorageSource; import net.minecraft.world.level.storage.LevelStorageSource;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.junit.Rule; import org.junit.Rule;
import org.junit.ClassRule;
import org.junit.Test; import org.junit.Test;
import org.junit.rules.TemporaryFolder; import org.junit.rules.TemporaryFolder;
@@ -48,6 +49,9 @@ import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
public final class HeadlessChunkLightingTest { public final class HeadlessChunkLightingTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@Rule @Rule
public TemporaryFolder temporary = new TemporaryFolder(); public TemporaryFolder temporary = new TemporaryFolder();
@@ -38,6 +38,7 @@ import net.minecraft.world.ticks.ScheduledTick;
import org.bukkit.NamespacedKey; import org.bukkit.NamespacedKey;
import org.bukkit.persistence.PersistentDataType; import org.bukkit.persistence.PersistentDataType;
import org.junit.Rule; import org.junit.Rule;
import org.junit.ClassRule;
import org.junit.Test; import org.junit.Test;
import org.junit.rules.TemporaryFolder; import org.junit.rules.TemporaryFolder;
@@ -54,6 +55,9 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
public class HeadlessChunkSerializationTest { public class HeadlessChunkSerializationTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@Rule @Rule
public TemporaryFolder temporary = new TemporaryFolder(); public TemporaryFolder temporary = new TemporaryFolder();
@@ -9,6 +9,7 @@ import net.minecraft.world.item.Items;
import java.util.Map; import java.util.Map;
import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.Blocks;
import org.junit.ClassRule;
import org.junit.Test; import org.junit.Test;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
@@ -18,6 +19,9 @@ import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
public final class HeadlessNativeBootstrapTest { public final class HeadlessNativeBootstrapTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@Test @Test
public void bindsNativeItemComponentsWithoutStartingServer() throws Exception { public void bindsNativeItemComponentsWithoutStartingServer() throws Exception {
HeadlessNativeBootstrap.bindComponents(HeadlessNativeTestRegistries.get()); HeadlessNativeBootstrap.bindComponents(HeadlessNativeTestRegistries.get());
@@ -7,12 +7,16 @@ import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey; import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.biome.Biome; import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.levelgen.structure.Structure; import net.minecraft.world.level.levelgen.structure.Structure;
import org.junit.ClassRule;
import org.junit.Test; import org.junit.Test;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
public final class HeadlessNativeRegistriesTest { public final class HeadlessNativeRegistriesTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@Test @Test
public void nativeStructureBiomeTagsUseTheLoadedBiomeHolders() throws Exception { public void nativeStructureBiomeTagsUseTheLoadedBiomeHolders() throws Exception {
RegistryAccess registries = HeadlessNativeTestRegistries.get(); RegistryAccess registries = HeadlessNativeTestRegistries.get();
@@ -4,6 +4,7 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeRegistryAccess;
import net.minecraft.core.registries.Registries; import net.minecraft.core.registries.Registries;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.junit.BeforeClass; import org.junit.BeforeClass;
import org.junit.ClassRule;
import org.junit.Test; import org.junit.Test;
import java.util.Map; import java.util.Map;
@@ -17,6 +18,9 @@ import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertThrows;
public final class HeadlessNativeRegistryTest { public final class HeadlessNativeRegistryTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@BeforeClass @BeforeClass
public static void bootstrap() { public static void bootstrap() {
HeadlessNativeBootstrap.initialize(); HeadlessNativeBootstrap.initialize();
@@ -11,6 +11,7 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeTileReader;
import net.minecraft.nbt.CompoundTag; import net.minecraft.nbt.CompoundTag;
import net.minecraft.data.registries.VanillaRegistries; import net.minecraft.data.registries.VanillaRegistries;
import org.junit.BeforeClass; import org.junit.BeforeClass;
import org.junit.ClassRule;
import org.junit.Test; import org.junit.Test;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
@@ -23,6 +24,9 @@ import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertThrows;
public final class HeadlessNativeTileTest { public final class HeadlessNativeTileTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@BeforeClass @BeforeClass
public static void bootstrap() { public static void bootstrap() {
HeadlessNativeBootstrap.initialize(); HeadlessNativeBootstrap.initialize();
@@ -17,6 +17,7 @@ import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey; import org.bukkit.NamespacedKey;
import org.bukkit.persistence.PersistentDataType; import org.bukkit.persistence.PersistentDataType;
import org.junit.Rule; import org.junit.Rule;
import org.junit.ClassRule;
import org.junit.Test; import org.junit.Test;
import org.junit.rules.TemporaryFolder; import org.junit.rules.TemporaryFolder;
import org.spigotmc.SpigotWorldConfig; import org.spigotmc.SpigotWorldConfig;
@@ -36,6 +37,9 @@ import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
public final class HeadlessRegionTerrainTest { public final class HeadlessRegionTerrainTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@Rule @Rule
public TemporaryFolder temporary = new TemporaryFolder(); public TemporaryFolder temporary = new TemporaryFolder();
@@ -24,6 +24,7 @@ import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.storage.RegionFile; import net.minecraft.world.level.chunk.storage.RegionFile;
import net.minecraft.world.level.chunk.storage.RegionStorageInfo; import net.minecraft.world.level.chunk.storage.RegionStorageInfo;
import org.junit.BeforeClass; import org.junit.BeforeClass;
import org.junit.ClassRule;
import org.junit.Test; import org.junit.Test;
import java.io.DataInputStream; import java.io.DataInputStream;
@@ -38,6 +39,9 @@ import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
public final class NativeRegionTerrainWriterTest { public final class NativeRegionTerrainWriterTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@BeforeClass @BeforeClass
public static void bootstrap() { public static void bootstrap() {
SharedConstants.tryDetectVersion(); SharedConstants.tryDetectVersion();
@@ -26,6 +26,7 @@ import net.minecraft.world.level.material.Fluids;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.junit.AfterClass; import org.junit.AfterClass;
import org.junit.BeforeClass; import org.junit.BeforeClass;
import org.junit.ClassRule;
import org.junit.Test; import org.junit.Test;
import java.util.ArrayList; import java.util.ArrayList;
@@ -40,6 +41,9 @@ import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
public final class OfflineNativeFeatureFeasibilityTest { public final class OfflineNativeFeatureFeasibilityTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
private static HeadlessNativeRegistries loaded; private static HeadlessNativeRegistries loaded;
private static RegistryAccess registries; private static RegistryAccess registries;
private static DimensionType dimension; private static DimensionType dimension;
@@ -1,6 +1,7 @@
package art.arcane.iris.probe; package art.arcane.iris.probe;
import org.junit.Rule; import org.junit.Rule;
import org.junit.ClassRule;
import org.junit.Test; import org.junit.Test;
import org.junit.rules.TemporaryFolder; import org.junit.rules.TemporaryFolder;
import org.mockito.MockedStatic; import org.mockito.MockedStatic;
@@ -22,6 +23,9 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.mockStatic;
public final class OfflineRegionPublicationTest { public final class OfflineRegionPublicationTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@Rule @Rule
public TemporaryFolder temporary = new TemporaryFolder(); public TemporaryFolder temporary = new TemporaryFolder();
@@ -0,0 +1,37 @@
package art.arcane.iris.probe;
import io.papermc.paper.registry.PaperRegistryAccess;
import org.junit.rules.TestRule;
import org.junit.runner.Description;
import org.junit.runners.model.Statement;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;
public final class PaperRegistryIsolation implements TestRule {
@Override
public Statement apply(Statement base, Description description) {
return new Statement() {
@Override
public void evaluate() throws Throwable {
Map<Object, Object> registries = registries();
Map<Object, Object> previous = new HashMap<>(registries);
registries.clear();
try {
base.evaluate();
} finally {
registries.clear();
registries.putAll(previous);
}
}
};
}
@SuppressWarnings("unchecked")
private static Map<Object, Object> registries() throws ReflectiveOperationException {
Field field = PaperRegistryAccess.class.getDeclaredField("registries");
field.setAccessible(true);
return (Map<Object, Object>) field.get(PaperRegistryAccess.instance());
}
}
@@ -18,6 +18,7 @@
package art.arcane.iris; package art.arcane.iris;
import java.util.concurrent.CompletionStage;
import art.arcane.volmlib.util.diagnostics.BukkitDebugDump; import art.arcane.volmlib.util.diagnostics.BukkitDebugDump;
import art.arcane.iris.generation.runtime.IrisEngineEffects; import art.arcane.iris.generation.runtime.IrisEngineEffects;
import art.arcane.iris.generation.runtime.IrisWorldManager; import art.arcane.iris.generation.runtime.IrisWorldManager;
@@ -1011,20 +1012,21 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
} }
@Override @Override
public void onPreUnload(ReloadAware.PreUnloadReason reason) { public CompletionStage<Void> commitReload(ReloadAware.PreUnloadReason reason) {
teardownPapi(); teardownPapi();
if (IrisToolbelt.isServerStopping()) { if (IrisToolbelt.isServerStopping()) {
quiesceRuntimeForServerShutdown("pre-unload:" + reason); quiesceRuntimeForServerShutdown("pre-unload:" + reason);
startPostStopFinisher(); startPostStopFinisher();
Iris.debug("Pre-unload hook deferred generator teardown until Paper closes its chunk schedulers."); Iris.debug("Pre-unload hook deferred generator teardown until Paper closes its chunk schedulers.");
return; return CompletableFuture.completedFuture(null);
} }
if (alreadyDrained.get()) { if (alreadyDrained.get()) {
Iris.debug("Pre-unload hook skipped; Iris already drained."); Iris.debug("Pre-unload hook skipped; Iris already drained.");
return; return CompletableFuture.completedFuture(null);
} }
Iris.debug("BileTools pre-unload hook fired (" + reason + "). Freezing all Iris worlds."); Iris.debug("BileTools pre-unload hook fired (" + reason + "). Freezing all Iris worlds.");
drainOnce("pre-unload:" + reason, 45L); drainOnce("pre-unload:" + reason, 45L);
return CompletableFuture.completedFuture(null);
} }
/** /**
@@ -25,6 +25,8 @@ import art.arcane.iris.localization.IrisLanguage;
import art.arcane.iris.pack.datapack.DatapackIngestService; import art.arcane.iris.pack.datapack.DatapackIngestService;
import art.arcane.iris.studio.object.ObjectStudioSaveService; import art.arcane.iris.studio.object.ObjectStudioSaveService;
import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.locator.SubterrainLocator;
import art.arcane.iris.generation.subterrain.IrisSubterrainFamily;
import art.arcane.iris.structure.placement.IrisStructureLocator; import art.arcane.iris.structure.placement.IrisStructureLocator;
import art.arcane.iris.structure.nativegen.NativeStructureGenerationPolicy; import art.arcane.iris.structure.nativegen.NativeStructureGenerationPolicy;
import art.arcane.iris.structure.placement.StructureReachability; import art.arcane.iris.structure.placement.StructureReachability;
@@ -72,7 +74,7 @@ import java.util.Set;
@Director(name = "find", origin = DirectorOrigin.PLAYER, description = "Iris Find commands", descriptionKey = "iris.director.commandfind.director.iris_find_commands", aliases = "goto") @Director(name = "find", origin = DirectorOrigin.PLAYER, description = "Iris Find commands", descriptionKey = "iris.director.commandfind.director.iris_find_commands", aliases = "goto")
public class CommandFind implements DirectorExecutor { public class CommandFind implements DirectorExecutor {
@Director(description = "Find a biome", descriptionKey = "iris.director.commandfind.director.find_biome") @Director(description = "Find a biome", sync = true, descriptionKey = "iris.director.commandfind.director.find_biome")
public void biome( public void biome(
@Param(description = "The biome to look for", descriptionKey = "iris.director.commandfind.param.biome_look", customHandler = ReachableBiomeHandler.class) @Param(description = "The biome to look for", descriptionKey = "iris.director.commandfind.param.biome_look", customHandler = ReachableBiomeHandler.class)
IrisBiome biome, IrisBiome biome,
@@ -89,6 +91,78 @@ public class CommandFind implements DirectorExecutor {
EngineBukkitOps.gotoBiome(e, biome, player(), teleport); EngineBukkitOps.gotoBiome(e, biome, player(), teleport);
} }
@Director(description = "Find an authored underground feature by id or geometry family", sync = true)
public void subterrain(
@Param(description = "Feature definition id or family: tectonic_fault, cenote, lava_tube, travertine_terraces")
String feature,
@Param(description = "Search radius in blocks", defaultValue = "8192")
int radius,
@Param(description = "Should you be teleported", defaultValue = "true")
boolean teleport
) {
String key = feature.trim();
IrisSubterrainFamily family = null;
try {
family = IrisSubterrainFamily.valueOf(key.toUpperCase(Locale.ROOT).replace('-', '_'));
} catch (IllegalArgumentException ignored) {
}
locateSubterrain(new SubterrainLocator.Query(family == null ? key : "", family, ""), radius, teleport);
}
@Director(name = "underground-biome", description = "Find a bounded authored underground biome", sync = true)
public void undergroundBiome(
@Param(description = "The underground biome to look for", customHandler = ReachableBiomeHandler.class)
IrisBiome biome,
@Param(description = "Search radius in blocks", defaultValue = "8192")
int radius,
@Param(description = "Should you be teleported", defaultValue = "true")
boolean teleport
) {
locateSubterrain(new SubterrainLocator.Query("", null, biome.getLoadKey()), radius, teleport);
}
private void locateSubterrain(SubterrainLocator.Query query, int radius, boolean teleport) {
Engine activeEngine = engine();
VolmitSender commandSender = sender();
Player target = player();
if (activeEngine == null || commandSender == null || target == null) {
if (commandSender != null) {
commandSender.sendMessage(C.RED + "Run this command from an Iris world.");
}
return;
}
if (radius < 0 || radius > 32768) {
commandSender.sendMessage(C.RED + "Search radius must be between 0 and 32768 blocks.");
return;
}
Location origin = target.getLocation();
World world = origin.getWorld();
commandSender.sendMessage(C.GRAY + "Searching authored underground plans...");
J.a(() -> {
try {
SubterrainLocator.Result found = GenerationSemanticQueries.nearestSubterrain(activeEngine, query,
origin.getBlockX(), origin.getBlockY(), origin.getBlockZ(), radius).orElse(null);
if (found == null) {
sendStructureMessage(target, commandSender,
C.YELLOW + "No matching authored underground volume found within " + radius + " blocks.");
return;
}
String label = found.family().name().toLowerCase(Locale.ROOT) + " " + found.featureId();
if (!teleport) {
sendStructureMessage(target, commandSender, C.GREEN + "Found " + label + " at "
+ found.x() + ", " + found.y() + ", " + found.z() + ".");
return;
}
prepareStructureTeleport(target, world, commandSender, label,
new Location(world, found.x(), found.y(), found.z()), true);
} catch (Throwable error) {
sendStructureMessage(target, commandSender,
C.RED + "Could not locate authored underground volume: " + error.getMessage());
Iris.reportError("Could not locate authored underground volume.", error);
}
});
}
@Director(description = "Find a region", descriptionKey = "iris.director.commandfind.director.find_region") @Director(description = "Find a region", descriptionKey = "iris.director.commandfind.director.find_region")
public void region( public void region(
@Param(description = "The region to look for", descriptionKey = "iris.director.commandfind.param.region_look", customHandler = ReachableRegionHandler.class) @Param(description = "The region to look for", descriptionKey = "iris.director.commandfind.param.region_look", customHandler = ReachableRegionHandler.class)
@@ -18,6 +18,8 @@
package art.arcane.iris.command; package art.arcane.iris.command;
import art.arcane.iris.pack.PackFingerprints;
import art.arcane.iris.Iris; import art.arcane.iris.Iris;
import art.arcane.iris.pack.validation.PackCompatReport; import art.arcane.iris.pack.validation.PackCompatReport;
import art.arcane.iris.pack.PackDirectoryResolver; import art.arcane.iris.pack.PackDirectoryResolver;
@@ -174,7 +176,6 @@ public class CommandPack implements DirectorExecutor {
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_NO_CLEANUP_CANDIDATES_FOUND_PACK, MessageArgument.untrusted("pack", String.valueOf(pack)))); s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_NO_CLEANUP_CANDIDATES_FOUND_PACK, MessageArgument.untrusted("pack", String.valueOf(pack))));
return; return;
} }
PackValidationRegistry.remove(packFolder.getName());
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_QUARANTINED_CLEANUP_CANDIDATE_S_UNDER, MessageArgument.untrusted("size", String.valueOf(result.quarantinedPaths().size())), MessageArgument.untrusted("quarantinePath", String.valueOf(result.quarantinePath())))); s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_QUARANTINED_CLEANUP_CANDIDATE_S_UNDER, MessageArgument.untrusted("size", String.valueOf(result.quarantinedPaths().size())), MessageArgument.untrusted("quarantinePath", String.valueOf(result.quarantinePath()))));
reportPaths(s, result.quarantinedPaths(), BukkitRuntimeMessages.COMMAND_PACK_PATH_QUARANTINED); reportPaths(s, result.quarantinedPaths(), BukkitRuntimeMessages.COMMAND_PACK_PATH_QUARANTINED);
return; return;
@@ -230,7 +231,6 @@ public class CommandPack implements DirectorExecutor {
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_NOTHING_RESTORE_PACK, MessageArgument.untrusted("pack", String.valueOf(pack)))); s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_NOTHING_RESTORE_PACK, MessageArgument.untrusted("pack", String.valueOf(pack))));
return; return;
} }
PackValidationRegistry.remove(packFolder.getName());
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_RESTORED_FILE_S_FROM, MessageArgument.untrusted("size", String.valueOf(result.restoredPaths().size())), MessageArgument.untrusted("dumpPath", String.valueOf(result.dumpPath())))); s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_RESTORED_FILE_S_FROM, MessageArgument.untrusted("size", String.valueOf(result.restoredPaths().size())), MessageArgument.untrusted("dumpPath", String.valueOf(result.dumpPath()))));
reportPaths(s, result.restoredPaths(), BukkitRuntimeMessages.COMMAND_PACK_PATH_RESTORED); reportPaths(s, result.restoredPaths(), BukkitRuntimeMessages.COMMAND_PACK_PATH_RESTORED);
return; return;
@@ -363,47 +363,79 @@ public class CommandPack implements DirectorExecutor {
return persisted == null || persisted.isBlank() ? "unknown" : persisted; return persisted == null || persisted.isBlank() ? "unknown" : persisted;
} }
private PackValidationResult runValidate(VolmitSender s, File packFolder) { PackValidationResult runValidate(VolmitSender s, File packFolder) {
PackValidationRegistry.ValidationTicket ticket =
PackValidationRegistry.tryBeginValidation(packFolder.toPath());
PackValidationResult result;
String contentFingerprint = "";
String contextFingerprint = "";
try { try {
PackValidationResult result = PackValidator.validate(packFolder); if (ticket == null) {
PackValidationRegistry.publish(result); throw new IllegalStateException("Pack content is currently being changed.");
reportResult(s, result); }
return result; contentFingerprint = PackFingerprints.computePackTreeFingerprint(packFolder);
contextFingerprint = PackValidationCache.contextFingerprint();
result = PackValidator.validate(packFolder);
if (!contentFingerprint.equals(PackFingerprints.computePackTreeFingerprint(packFolder))
|| !contextFingerprint.equals(PackValidationCache.contextFingerprint())) {
throw new IllegalStateException("Pack content or validation context changed during validation.");
}
} catch (Throwable e) { } catch (Throwable e) {
Iris.reportError("Pack validation failed for '" + packFolder.getName() + "'", e); Iris.reportError("Pack validation failed for '" + packFolder.getName() + "'", e);
String detail = e.getMessage() == null || e.getMessage().isBlank() String detail = e.getMessage() == null || e.getMessage().isBlank()
? e.getClass().getSimpleName() ? e.getClass().getSimpleName()
: e.getMessage(); : e.getMessage();
PackValidationResult result = new PackValidationResult( result = new PackValidationResult(
packFolder.getName(), packFolder.getName(),
List.of("Pack validation failed with " + e.getClass().getSimpleName() + ": " + detail), List.of("Pack validation failed with " + e.getClass().getSimpleName() + ": " + detail),
List.of(), List.of(),
System.currentTimeMillis()); System.currentTimeMillis());
contentFingerprint = "";
contextFingerprint = "";
}
if (PackValidationRegistry.publishIfCurrent(ticket, result, contentFingerprint, contextFingerprint)) {
PackValidationRegistry.publish(result); PackValidationRegistry.publish(result);
} else {
result = new PackValidationResult(packFolder.getName(),
List.of("Pack content changed during validation."), List.of(), System.currentTimeMillis());
}
reportResult(s, result); reportResult(s, result);
return result; return result;
} }
}
private void persistValidationCache(File packsRoot) { private void persistValidationCache(File packsRoot) {
try {
String contextFingerprint = PackValidationCache.contextFingerprint();
PackFingerprints.PackContentSnapshot snapshot = PackFingerprints.computePackContentSnapshot(packsRoot);
List<File> packDirectories = PackDirectoryResolver.listVisiblePackDirectories(packsRoot); List<File> packDirectories = PackDirectoryResolver.listVisiblePackDirectories(packsRoot);
List<PackValidationResult> results = new ArrayList<>(packDirectories.size()); List<PackValidationResult> results = new ArrayList<>(packDirectories.size());
for (File packDirectory : packDirectories) { for (File packDirectory : packDirectories) {
PackValidationResult result = PackValidationRegistry.get(packDirectory.getName()); PackValidationResult result = PackValidationRegistry.getMatching(
packDirectory.toPath(), snapshot.packContents().get(packDirectory.getName()));
if (result == null) { if (result == null) {
// The boot cache only loads when it covers every pack, so a partial write is
// pointless - but say so instead of silently skipping the persist forever.
Iris.warn("Pack validation cache not written: \"" + packDirectory.getName() Iris.warn("Pack validation cache not written: \"" + packDirectory.getName()
+ "\" has no registered result. Run /iris pack validate all to repopulate it."); + "\" has no validation matching its current content. Run /iris pack validate to refresh it.");
return; return;
} }
results.add(result); results.add(result);
} }
try { if (!snapshot.content().equals(PackFingerprints.computePackContentSnapshot(packsRoot).content())
|| !contextFingerprint.equals(PackValidationCache.contextFingerprint())) {
Iris.warn("Pack validation cache not written because pack content or validation context changed.");
return;
}
for (int index = 0; index < packDirectories.size(); index++) {
File packDirectory = packDirectories.get(index);
if (PackValidationRegistry.getMatching(packDirectory.toPath(),
snapshot.packContents().get(packDirectory.getName())) != results.get(index)) {
Iris.warn("Pack validation cache not written because validation changed during persistence.");
return;
}
}
PackValidationCache.save( PackValidationCache.save(
Iris.instance.getDataFile("cache", "pack-validation.json").toPath(), Iris.instance.getDataFile("cache", "pack-validation.json").toPath(),
PackValidationCache.contentFingerprint(packsRoot), snapshot.content(),
PackValidationCache.contextFingerprint(), contextFingerprint,
results); results);
} catch (IOException | RuntimeException e) { } catch (IOException | RuntimeException e) {
Iris.reportError("Could not persist refreshed pack-validation results", e); Iris.reportError("Could not persist refreshed pack-validation results", e);
@@ -0,0 +1,35 @@
package art.arcane.iris.generation.runtime;
final class GenerationRateWindow {
static final long TIMING_IDLE_MILLIS = 10_000L;
private long lastSampleAtMs;
private int lastGeneratedCount;
private long lastGenerationAtMs;
double sampleChunksPerSecond(int generatedCount, long sampledAtMs) {
int safeGeneratedCount = Math.max(0, generatedCount);
long previousAt = lastSampleAtMs;
int previousCount = lastGeneratedCount;
lastSampleAtMs = sampledAtMs;
lastGeneratedCount = safeGeneratedCount;
if (previousAt <= 0L) {
if (safeGeneratedCount > 0) {
lastGenerationAtMs = sampledAtMs;
}
return 0D;
}
if (sampledAtMs <= previousAt) {
return 0D;
}
int generatedDelta = Math.max(0, safeGeneratedCount - previousCount);
if (generatedDelta > 0) {
lastGenerationAtMs = sampledAtMs;
}
return generatedDelta * 1000D / (sampledAtMs - previousAt);
}
boolean generationTimingsFresh(long sampledAtMs) {
return lastGenerationAtMs > 0L && sampledAtMs - lastGenerationAtMs <= TIMING_IDLE_MILLIS;
}
}
@@ -460,8 +460,12 @@ public final class IrisEngineSVC implements IrisService {
continue; continue;
} }
double chunksPerSecond = registered.sampleChunksPerSecond(engine.getGenerated(), now); double chunksPerSecond = registered.generationRate.sampleChunksPerSecond(engine.getGenerated(), now);
worldSnapshots.add(EngineTelemetrySnapshot.capture(engine, chunksPerSecond, now)); worldSnapshots.add(EngineTelemetrySnapshot.capture(
engine,
chunksPerSecond,
registered.generationRate.generationTimingsFresh(now),
now));
registered.clearMetricsFailure(); registered.clearMetricsFailure();
} catch (Throwable exception) { } catch (Throwable exception) {
if (EngineMaintenance.isMantleClosed(exception)) { if (EngineMaintenance.isMantleClosed(exception)) {
@@ -552,6 +556,7 @@ public final class IrisEngineSVC implements IrisService {
progress.generated(), progress.generated(),
progress.totalChunks(), progress.totalChunks(),
progress.chunksRemaining(), progress.chunksRemaining(),
progress.chunksInFlight(),
progress.chunksPerSecond(), progress.chunksPerSecond(),
progress.eta(), progress.eta(),
progress.elapsed(), progress.elapsed(),
@@ -612,8 +617,7 @@ public final class IrisEngineSVC implements IrisService {
private final AtomicReference<CompletableFuture<Void>> activeMaintenance = new AtomicReference<>(); private final AtomicReference<CompletableFuture<Void>> activeMaintenance = new AtomicReference<>();
private volatile ScheduledFuture<?> maintenance; private volatile ScheduledFuture<?> maintenance;
private volatile boolean closed; private volatile boolean closed;
private long lastGeneratedSampleAtMs; private final GenerationRateWindow generationRate = new GenerationRateWindow();
private int lastGeneratedCount;
private Registered(Registration registration, ScheduledThreadPoolExecutor executor) { private Registered(Registration registration, ScheduledThreadPoolExecutor executor) {
name = registration.name(); name = registration.name();
@@ -696,19 +700,6 @@ public final class IrisEngineSVC implements IrisService {
return activeMaintenance.get() != null; return activeMaintenance.get() != null;
} }
private double sampleChunksPerSecond(int generatedCount, long sampledAtMs) {
int safeGeneratedCount = Math.max(0, generatedCount);
long previousAt = lastGeneratedSampleAtMs;
int previousCount = lastGeneratedCount;
lastGeneratedSampleAtMs = sampledAtMs;
lastGeneratedCount = safeGeneratedCount;
if (previousAt <= 0L || sampledAtMs <= previousAt) {
return 0D;
}
int generatedDelta = Math.max(0, safeGeneratedCount - previousCount);
return generatedDelta * 1000D / (sampledAtMs - previousAt);
}
private void close() { private void close() {
if (closed) { if (closed) {
return; return;
@@ -101,6 +101,7 @@ public record IrisTelemetrySnapshot(
long generated, long generated,
long total, long total,
long remaining, long remaining,
long inFlight,
double chunksPerSecond, double chunksPerSecond,
long etaMs, long etaMs,
long elapsedMs, long elapsedMs,
@@ -115,6 +116,7 @@ public record IrisTelemetrySnapshot(
0L, 0L,
0L, 0L,
0L, 0L,
0L,
0D, 0D,
0L, 0L,
0L, 0L,
@@ -130,6 +132,7 @@ public record IrisTelemetrySnapshot(
generated = Math.max(0L, generated); generated = Math.max(0L, generated);
total = Math.max(0L, total); total = Math.max(0L, total);
remaining = Math.max(0L, remaining); remaining = Math.max(0L, remaining);
inFlight = Math.max(0L, inFlight);
chunksPerSecond = Double.isFinite(chunksPerSecond) ? Math.max(0D, chunksPerSecond) : 0D; chunksPerSecond = Double.isFinite(chunksPerSecond) ? Math.max(0D, chunksPerSecond) : 0D;
etaMs = Math.max(0L, etaMs); etaMs = Math.max(0L, etaMs);
elapsedMs = Math.max(0L, elapsedMs); elapsedMs = Math.max(0L, elapsedMs);
@@ -319,6 +319,7 @@ public class IrisIntegrationService implements IrisService, IntegrationServiceCo
IntegrationMetricSchema.IRIS_PREGEN_GENERATED, IntegrationMetricSchema.IRIS_PREGEN_GENERATED,
IntegrationMetricSchema.IRIS_PREGEN_TOTAL, IntegrationMetricSchema.IRIS_PREGEN_TOTAL,
IntegrationMetricSchema.IRIS_PREGEN_QUEUE, IntegrationMetricSchema.IRIS_PREGEN_QUEUE,
IntegrationMetricSchema.IRIS_PREGEN_REMAINING,
IntegrationMetricSchema.IRIS_PREGEN_THROUGHPUT, IntegrationMetricSchema.IRIS_PREGEN_THROUGHPUT,
IntegrationMetricSchema.IRIS_PREGEN_ETA_MS, IntegrationMetricSchema.IRIS_PREGEN_ETA_MS,
IntegrationMetricSchema.IRIS_PREGEN_ELAPSED_MS, IntegrationMetricSchema.IRIS_PREGEN_ELAPSED_MS,
@@ -332,7 +333,8 @@ public class IrisIntegrationService implements IrisService, IntegrationServiceCo
put(samples, IntegrationMetricSchema.IRIS_PREGEN_PROGRESS, pregenerator.progressPercent(), sampledAtMs); put(samples, IntegrationMetricSchema.IRIS_PREGEN_PROGRESS, pregenerator.progressPercent(), sampledAtMs);
put(samples, IntegrationMetricSchema.IRIS_PREGEN_GENERATED, pregenerator.generated(), sampledAtMs); put(samples, IntegrationMetricSchema.IRIS_PREGEN_GENERATED, pregenerator.generated(), sampledAtMs);
put(samples, IntegrationMetricSchema.IRIS_PREGEN_TOTAL, pregenerator.total(), sampledAtMs); put(samples, IntegrationMetricSchema.IRIS_PREGEN_TOTAL, pregenerator.total(), sampledAtMs);
put(samples, IntegrationMetricSchema.IRIS_PREGEN_QUEUE, pregenerator.remaining(), sampledAtMs); put(samples, IntegrationMetricSchema.IRIS_PREGEN_QUEUE, pregenerator.inFlight(), sampledAtMs);
put(samples, IntegrationMetricSchema.IRIS_PREGEN_REMAINING, pregenerator.remaining(), sampledAtMs);
put(samples, IntegrationMetricSchema.IRIS_PREGEN_THROUGHPUT, pregenerator.chunksPerSecond(), sampledAtMs); put(samples, IntegrationMetricSchema.IRIS_PREGEN_THROUGHPUT, pregenerator.chunksPerSecond(), sampledAtMs);
put(samples, IntegrationMetricSchema.IRIS_PREGEN_ETA_MS, pregenerator.etaMs(), sampledAtMs); put(samples, IntegrationMetricSchema.IRIS_PREGEN_ETA_MS, pregenerator.etaMs(), sampledAtMs);
put(samples, IntegrationMetricSchema.IRIS_PREGEN_ELAPSED_MS, pregenerator.elapsedMs(), sampledAtMs); put(samples, IntegrationMetricSchema.IRIS_PREGEN_ELAPSED_MS, pregenerator.elapsedMs(), sampledAtMs);
@@ -18,8 +18,9 @@
package art.arcane.iris.world; package art.arcane.iris.world;
import art.arcane.iris.pack.PackFingerprints;
import art.arcane.iris.configuration.IrisSettings; import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.pack.datapack.ServerConfigurator;
import art.arcane.iris.Iris; import art.arcane.iris.Iris;
import art.arcane.iris.world.lifecycle.WorldLifecycleStaging; import art.arcane.iris.world.lifecycle.WorldLifecycleStaging;
@@ -184,12 +185,12 @@ public final class IrisWorldGeneratorResolver {
} }
List<String> packNames = packDirs.stream().map(File::getName).sorted().toList(); List<String> packNames = packDirs.stream().map(File::getName).sorted().toList();
Path cacheFile = IrisPlatforms.get().dataFile("cache", "pack-validation.json").toPath(); Path cacheFile = IrisPlatforms.get().dataFile("cache", "pack-validation.json").toPath();
ServerConfigurator.PackContentSnapshot contentSnapshot = PackFingerprints.PackContentSnapshot contentSnapshot =
new ServerConfigurator.PackContentSnapshot("", Map.of()); new PackFingerprints.PackContentSnapshot("", Map.of());
String contextFingerprint = ""; String contextFingerprint = "";
Optional<List<PackValidationResult>> cached = Optional.empty(); Optional<List<PackValidationResult>> cached = Optional.empty();
try { try {
contentSnapshot = ServerConfigurator.computePackContentSnapshot(packsRoot); contentSnapshot = PackFingerprints.computePackContentSnapshot(packsRoot);
contextFingerprint = PackValidationCache.contextFingerprint(); contextFingerprint = PackValidationCache.contextFingerprint();
if (!externalContentChanged) { if (!externalContentChanged) {
cached = PackValidationCache.load( cached = PackValidationCache.load(
@@ -274,19 +275,19 @@ public final class IrisWorldGeneratorResolver {
private static FreshValidation validateStablePacks( private static FreshValidation validateStablePacks(
File packsRoot, File packsRoot,
List<File> initialPackDirs, List<File> initialPackDirs,
ServerConfigurator.PackContentSnapshot initialSnapshot PackFingerprints.PackContentSnapshot initialSnapshot
) { ) {
List<File> packDirs = initialPackDirs; List<File> packDirs = initialPackDirs;
ServerConfigurator.PackContentSnapshot before = initialSnapshot; PackFingerprints.PackContentSnapshot before = initialSnapshot;
for (int attempt = 0; attempt < VALIDATION_STABILITY_ATTEMPTS; attempt++) { for (int attempt = 0; attempt < VALIDATION_STABILITY_ATTEMPTS; attempt++) {
List<String> packNames = packDirs.stream().map(File::getName).sorted().toList(); List<String> packNames = packDirs.stream().map(File::getName).sorted().toList();
List<PackValidationResult> results = validatePacks(packDirs); List<PackValidationResult> results = validatePacks(packDirs);
ServerConfigurator.PackContentSnapshot after; PackFingerprints.PackContentSnapshot after;
try { try {
after = ServerConfigurator.computePackContentSnapshot(packsRoot); after = PackFingerprints.computePackContentSnapshot(packsRoot);
} catch (RuntimeException exception) { } catch (RuntimeException exception) {
Iris.reportError("Could not verify Iris pack bytes after validation", exception); Iris.reportError("Could not verify Iris pack bytes after validation", exception);
after = new ServerConfigurator.PackContentSnapshot("", Map.of()); after = new PackFingerprints.PackContentSnapshot("", Map.of());
} }
List<File> afterPackDirs = PackDirectoryResolver.listVisiblePackDirectories(packsRoot); List<File> afterPackDirs = PackDirectoryResolver.listVisiblePackDirectories(packsRoot);
List<String> afterPackNames = afterPackDirs.stream().map(File::getName).sorted().toList(); List<String> afterPackNames = afterPackDirs.stream().map(File::getName).sorted().toList();
@@ -923,7 +924,7 @@ public final class IrisWorldGeneratorResolver {
private record FreshValidation( private record FreshValidation(
List<File> packDirs, List<File> packDirs,
ServerConfigurator.PackContentSnapshot contentSnapshot, PackFingerprints.PackContentSnapshot contentSnapshot,
List<PackValidationResult> results, List<PackValidationResult> results,
boolean stable boolean stable
) { ) {
@@ -0,0 +1,47 @@
{
"name": "Flat",
"derivative": "minecraft:plains",
"vanillaDerivative": "minecraft:plains",
"layers": [
{
"minHeight": 1,
"maxHeight": 1,
"palette": [
{
"block": "minecraft:lime_wool"
}
]
},
{
"minHeight": 384,
"maxHeight": 384,
"palette": [
{
"block": "minecraft:green_concrete"
}
]
}
],
"generators": [
{
"generator": "flat",
"min": 96,
"max": 96
}
],
"decorators": [
{
"palette": [
{
"block": "minecraft:oak_stairs",
"data": {
"half": "bottom",
"facing": "north"
}
}
],
"chance": 1,
"forcePlace": true
}
]
}
@@ -0,0 +1,28 @@
{
"name": "Decorator Fixture",
"version": 1,
"mode": {
"type": "OVERWORLD"
},
"regions": [
"flat"
],
"focusRegion": "flat",
"focus": "flat",
"landChance": 1,
"environment": "NORMAL",
"dimensionHeight": {
"min": -64,
"max": 320
},
"logicalHeight": 384,
"fluidHeight": 0,
"bedrock": false,
"rockPalette": {
"palette": [
{
"block": "minecraft:red_concrete"
}
]
}
}
@@ -0,0 +1,15 @@
{
"interpolator": {
"function": "NONE",
"horizontalScale": 1
},
"seed": 310,
"composite": [
{
"seed": 310,
"style": {
"style": "FLAT"
}
}
]
}
@@ -0,0 +1,12 @@
{
"name": "Flat",
"landBiomes": [
"flat"
],
"seaBiomes": [
"flat"
],
"shoreBiomes": [
"flat"
]
}
@@ -0,0 +1,40 @@
import { readFile } from 'node:fs/promises'
import path from 'node:path'
export default {
name: 'iris-decorators',
description: 'Verify authored stair decorations occupy one block and retain their half state.',
async run(context) {
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
context.expect(/^isolated=true\s*$/m.test(source), 'Decorator generation requires an isolated instance')
await context.step('create a world with authored stair decorations', async () => {
const response = await context.command('/iris create name=decorator_probe type=decorator-fixture seed=78264193',
/Successfully created your world|Failed to create/, 180000)
context.expect(response.includes('Successfully created your world'), response)
})
await context.step('client sees single bottom-half stair decorations', async () => {
let checked = 0
await context.waitUntil(() => {
checked = 0
const center = context.bot.entity.position.floored()
for (let x = -6; x <= 6; x++) {
for (let z = -6; z <= 6; z++) {
for (let y = -4; y <= 1; y++) {
const position = center.offset(x, y, z)
if (context.bot.blockAt(position)?.name !== 'lime_wool') continue
const decoration = context.bot.blockAt(position.offset(0, 1, 0))
const above = context.bot.blockAt(position.offset(0, 2, 0))
if (!decoration || !above) return false
if (decoration.name !== 'oak_stairs' || decoration.getProperties().half !== 'bottom'
|| above.name !== 'air') return false
checked++
}
}
}
return checked >= 100
}, { label: 'At least 100 authored stair columns with empty space above', timeoutMs: 60000, intervalMs: 250 })
context.expect(checked >= 100, 'Client verified generated decorator columns', { checked })
context.report.decoratorColumns = checked
})
}
}
@@ -4,6 +4,13 @@ import art.arcane.volmlib.nativelib.NativeAdapters;
import art.arcane.iris.world.runtime.RuntimeInjection; import art.arcane.iris.world.runtime.RuntimeInjection;
import art.arcane.iris.world.IrisCreator; import art.arcane.iris.world.IrisCreator;
import art.arcane.iris.world.IrisToolbelt; import art.arcane.iris.world.IrisToolbelt;
import art.arcane.iris.world.IrisWorldStorage;
import art.arcane.iris.world.WorldCreatorCompat;
import art.arcane.iris.world.lifecycle.LifecycleOperationCoordinator;
import art.arcane.iris.platform.bootstrap.ServerProperties;
import org.bukkit.Location;
import org.bukkit.entity.Player;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.Material; import org.bukkit.Material;
import org.bukkit.block.Biome; import org.bukkit.block.Biome;
import art.arcane.volmlib.nativelib.terrain.NativeTerrainAccess; import art.arcane.volmlib.nativelib.terrain.NativeTerrainAccess;
@@ -19,6 +26,10 @@ import org.bukkit.command.CommandSender;
import org.bukkit.generator.ChunkGenerator; import org.bukkit.generator.ChunkGenerator;
import org.bukkit.plugin.java.JavaPlugin; import org.bukkit.plugin.java.JavaPlugin;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.List; import java.util.List;
import java.util.logging.Level; import java.util.logging.Level;
@@ -26,6 +37,7 @@ import java.util.logging.Level;
public final class NativeLifecycleFixture extends JavaPlugin implements WorldRuntimeExecution { public final class NativeLifecycleFixture extends JavaPlugin implements WorldRuntimeExecution {
private static final NamespacedKey WORLD_KEY = NamespacedKey.minecraft("native_lifecycle_probe"); private static final NamespacedKey WORLD_KEY = NamespacedKey.minecraft("native_lifecycle_probe");
private static final NamespacedKey TERRAIN_KEY = new NamespacedKey("iris", "native_terrain_probe"); private static final NamespacedKey TERRAIN_KEY = new NamespacedKey("iris", "native_terrain_probe");
private static final NamespacedKey COMMAND_KEY = new NamespacedKey("iris", "lifecycle_probe");
private static final long WORLD_SEED = 78264193L; private static final long WORLD_SEED = 78264193L;
private NativeWorldRuntime runtime; private NativeWorldRuntime runtime;
@@ -45,7 +57,7 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
@Override @Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) { public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (arguments.length != 1) { if (arguments.length != 1) {
sender.sendMessage("Use /irislifecycle create|unload|status|structures|terrain-create|terrain-check|terrain-unload"); sender.sendMessage("Use /irislifecycle create|unload|status|structures|terrain-create|terrain-check|terrain-unload|lifecycle-status|lifecycle-entry");
return true; return true;
} }
try { try {
@@ -56,6 +68,8 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
case "terrain-create" -> createTerrain(sender); case "terrain-create" -> createTerrain(sender);
case "terrain-check" -> checkTerrain(sender); case "terrain-check" -> checkTerrain(sender);
case "terrain-unload" -> unloadTerrain(sender); case "terrain-unload" -> unloadTerrain(sender);
case "lifecycle-status" -> lifecycleStatus(sender);
case "lifecycle-entry" -> lifecycleEntry(sender);
case "status" -> sender.sendMessage("NATIVE_STATUS available=" + runtime.available() case "status" -> sender.sendMessage("NATIVE_STATUS available=" + runtime.available()
+ " loaded=" + (Bukkit.getWorld(WORLD_KEY) != null)); + " loaded=" + (Bukkit.getWorld(WORLD_KEY) != null));
default -> throw new IllegalArgumentException("Unknown lifecycle operation"); default -> throw new IllegalArgumentException("Unknown lifecycle operation");
@@ -66,6 +80,61 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
return true; return true;
} }
private void lifecycleStatus(CommandSender sender) throws Exception {
World world = Bukkit.getWorld(COMMAND_KEY);
Path storage = WorldCreatorCompat.persistentLevelRoot(COMMAND_KEY).toPath();
Path levelRoot = IrisWorldStorage.levelRoot().toPath();
Path registry = levelRoot.resolve("iris/worlds.json");
boolean registered = Files.exists(registry)
&& Files.readString(registry).contains("\"" + COMMAND_KEY + "\"");
String startupName = IrisWorldStorage.configuredWorldName(COMMAND_KEY, levelRoot.getFileName().toString());
YamlConfiguration configuration = YamlConfiguration.loadConfiguration(ServerProperties.BUKKIT_YML);
boolean configured = configuration.getConfigurationSection("worlds") != null
&& configuration.getConfigurationSection("worlds").contains(startupName);
File quarantineRoot = levelRoot.resolve("dimensions/iris").toFile();
File[] quarantines = quarantineRoot.listFiles((directory, name) -> name.startsWith(".iris-delete-"));
int quarantineCount = quarantines == null ? 0 : quarantines.length;
Path queueFile = Bukkit.getPluginManager().getPlugin("Iris").getDataFolder().toPath()
.resolve("pending-world-deletes.txt");
List<String> queue = Files.exists(queueFile) ? Files.readAllLines(queueFile) : List.of();
boolean queued = quarantineCount > 0;
if (quarantines != null) {
for (File quarantine : quarantines) {
queued &= queue.contains(quarantine.getName());
}
}
boolean disk = Files.exists(storage, LinkOption.NOFOLLOW_LINKS);
String cleanup = disk ? "present" : quarantineCount == 0 ? "deleted" : queued ? "queued" : "unsafe";
boolean idle = true;
LifecycleOperationCoordinator coordinator = LifecycleOperationCoordinator.get();
for (LifecycleOperationCoordinator.Domain domain : LifecycleOperationCoordinator.Domain.values()) {
idle &= coordinator.active(domain).isEmpty();
}
sender.sendMessage("IRIS_LIFECYCLE loaded=" + (world != null) + " registered=" + registered
+ " configured=" + configured + " disk=" + disk + " quarantines=" + quarantineCount
+ " cleanup=" + cleanup + " idle=" + idle);
}
private void lifecycleEntry(CommandSender sender) {
if (!(sender instanceof Player player)) {
throw new IllegalArgumentException("Lifecycle entry requires a player");
}
World world = Bukkit.getWorld(COMMAND_KEY);
if (world == null || player.getWorld() != world || world.getSeed() != WORLD_SEED
|| !IrisToolbelt.isIrisWorld(world)) {
throw new IllegalStateException("Player did not enter the requested seeded Iris world");
}
Location feet = player.getLocation();
Material floor = world.getBlockAt(feet.getBlockX(), feet.getBlockY() - 1, feet.getBlockZ()).getType();
if (floor != Material.LIME_WOOL || !floor.isSolid()
|| !world.getBlockAt(feet.getBlockX(), feet.getBlockY(), feet.getBlockZ()).isPassable()
|| !world.getBlockAt(feet.getBlockX(), feet.getBlockY() + 1, feet.getBlockZ()).isPassable()) {
throw new IllegalStateException("Lifecycle player entry lacks a safe fixture floor");
}
sender.sendMessage("IRIS_LIFECYCLE_ENTRY world=" + COMMAND_KEY + " seed=" + world.getSeed()
+ " floor=lime_wool safe=true");
}
private void create(CommandSender sender) throws ReflectiveOperationException { private void create(CommandSender sender) throws ReflectiveOperationException {
if (!runtime.available() || Bukkit.getWorld(WORLD_KEY) != null) { if (!runtime.available() || Bukkit.getWorld(WORLD_KEY) != null) {
throw new IllegalStateException("Native runtime unavailable or probe world already loaded"); throw new IllegalStateException("Native runtime unavailable or probe world already loaded");
@@ -124,6 +193,7 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
int y = world.getHighestBlockYAt(x, z); int y = world.getHighestBlockYAt(x, z);
if (y < 90 || y > 100 || world.getBlockAt(x, y, z).getType() != Material.LIME_WOOL if (y < 90 || y > 100 || world.getBlockAt(x, y, z).getType() != Material.LIME_WOOL
|| world.getBlockAt(x, y - 3, z).getType() != Material.GREEN_CONCRETE || world.getBlockAt(x, y - 3, z).getType() != Material.GREEN_CONCRETE
|| world.getBlockAt(x, world.getMinHeight(), z).getType() != Material.GREEN_CONCRETE
|| world.getBiome(x, y, z) != Biome.PLAINS) { || world.getBiome(x, y, z) != Biome.PLAINS) {
throw new IllegalStateException("Terrain column differs at " + x + "," + y + "," + z throw new IllegalStateException("Terrain column differs at " + x + "," + y + "," + z
+ ": " + world.getBlockAt(x, y, z).getType()); + ": " + world.getBlockAt(x, y, z).getType());
@@ -11,6 +11,15 @@
"block": "minecraft:lime_wool" "block": "minecraft:lime_wool"
} }
] ]
},
{
"minHeight": 384,
"maxHeight": 384,
"palette": [
{
"block": "minecraft:green_concrete"
}
]
} }
], ],
"generators": [ "generators": [
@@ -21,7 +21,7 @@
"rockPalette": { "rockPalette": {
"palette": [ "palette": [
{ {
"block": "minecraft:green_concrete" "block": "minecraft:red_concrete"
} }
] ]
} }
@@ -0,0 +1,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('irisGsonJar').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 = 'IrisNaturalSpawningFixture'
}
@@ -0,0 +1,12 @@
name: IrisNaturalSpawningFixture
version: 1.0.0
main: art.arcane.iris.fixture.NaturalSpawningFixture
api-version: '26.1.2'
folia-supported: true
depend: [Iris]
commands:
irisspawnqa:
permission: irisspawnqa.run
permissions:
irisspawnqa.run:
default: op
@@ -0,0 +1 @@
rootProject.name = 'iris-natural-spawning-fixture'
@@ -0,0 +1,701 @@
package art.arcane.iris.fixture;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.runtime.BiomeEnvironment;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.world.IrisCreator;
import art.arcane.iris.world.entity.IrisSpawner;
import art.arcane.iris.world.entity.IrisMarker;
import art.arcane.iris.world.entity.EntitySpawnSeed;
import art.arcane.volmlib.util.mantle.flag.MantleFlag;
import art.arcane.iris.pack.value.IrisPosition;
import art.arcane.volmlib.util.math.RNG;
import art.arcane.iris.world.entity.IrisEntitySpawn;
import art.arcane.iris.world.entity.IrisEntity;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.platform.bukkit.BukkitEntityType;
import art.arcane.iris.world.IrisToolbelt;
import art.arcane.iris.world.history.SavedBiomeUnavailableException;
import org.bukkit.Bukkit;
import org.bukkit.Chunk;
import org.bukkit.Difficulty;
import org.bukkit.GameMode;
import org.bukkit.HeightMap;
import org.bukkit.GameRules;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.World;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Entity;
import org.bukkit.entity.EntityType;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.CreatureSpawnEvent;
import org.bukkit.plugin.java.JavaPlugin;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.CompletableFuture;
import java.util.logging.Level;
public final class NaturalSpawningFixture extends JavaPlugin implements Listener {
private static final NamespacedKey WORLD_KEY = new NamespacedKey("iris", "spawn_qa");
private static final long WORLD_SEED = 78264193L;
private static final int FROG_CAPACITY = 16;
private static final int ZOMBIE_CAPACITY = 2;
private static final int BIOME_READY_ATTEMPTS = 60;
private static final long BIOME_RETRY_TICKS = 10L;
private boolean previousAmbient;
private boolean previousMarkers;
private boolean previousForcedPersistence;
private volatile boolean ready;
private int firstZombieBatch;
private int ambientEvents;
private int cancelledAmbientEvents;
private final ThreadLocal<Boolean> ambientInvocation = ThreadLocal.withInitial(() -> false);
@Override
public void onEnable() {
Bukkit.getPluginManager().registerEvents(this, this);
previousAmbient = IrisSettings.get().getWorld().isAmbientEntitySpawningSystem();
previousMarkers = IrisSettings.get().getWorld().isMarkerEntitySpawningSystem();
previousForcedPersistence = IrisSettings.get().getWorld().isForcePersistEntities();
IrisSettings.get().getWorld().setAmbientEntitySpawningSystem(false);
IrisSettings.get().getWorld().setMarkerEntitySpawningSystem(false);
IrisSettings.get().getWorld().setForcePersistEntities(false);
getLogger().info("Natural spawning fixture ready; commands use the Iris ambient path on the owning region.");
}
@Override
public void onDisable() {
IrisSettings.get().getWorld().setAmbientEntitySpawningSystem(previousAmbient);
IrisSettings.get().getWorld().setMarkerEntitySpawningSystem(previousMarkers);
IrisSettings.get().getWorld().setForcePersistEntities(previousForcedPersistence);
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (arguments.length != 1) {
notifySender(sender, "Use /irisspawnqa setup|frogs|zombies|stress|check|seeded|completion|surface|cave");
return true;
}
try {
switch (arguments[0]) {
case "setup" -> setup(sender);
case "frogs" -> runRegion(sender, () -> spawnFrogs(sender));
case "zombies" -> runRegion(sender, () -> spawnZombies(sender));
case "stress" -> runRegion(sender, () -> stress(sender));
case "check" -> runRegion(sender, () -> check(sender));
case "seeded" -> seeded(sender);
case "completion" -> completion(sender);
case "surface" -> view(sender, 67D);
case "cave" -> view(sender, -46D);
default -> throw new IllegalArgumentException("Unknown spawning operation");
}
} catch (Throwable failure) {
fail(sender, failure);
}
return true;
}
@EventHandler(priority = EventPriority.HIGHEST)
public void isolateSpawns(CreatureSpawnEvent event) {
if (!WORLD_KEY.equals(event.getLocation().getWorld().getKey())) {
return;
}
if (!ambientInvocation.get()) {
event.setCancelled(true);
return;
}
ambientEvents++;
if (event.isCancelled()) {
cancelledAmbientEvents++;
}
}
private void setup(CommandSender sender) {
if (!(sender instanceof Player)) {
throw new IllegalArgumentException("Setup requires a player");
}
World world = Bukkit.getWorld(WORLD_KEY);
if (world != null) {
require(IrisToolbelt.isIrisWorld(world) && world.getSeed() == WORLD_SEED,
"Existing fixture key is not the owned Iris world with the expected seed");
ready = false;
Bukkit.getGlobalRegionScheduler().execute(this, () -> configureWorld(sender, world));
return;
}
ready = false;
Bukkit.getAsyncScheduler().runNow(this, task -> createWorld(sender));
}
private void createWorld(CommandSender sender) {
try {
World world = new IrisCreator().name(WORLD_KEY.toString()).dimension("spawn-qa")
.seed(WORLD_SEED).create();
Bukkit.getGlobalRegionScheduler().execute(this, () -> configureWorld(sender, world));
} catch (Throwable failure) {
fail(sender, failure);
}
}
private void configureWorld(CommandSender sender, World world) {
try {
world.setDifficulty(Difficulty.NORMAL);
world.setTime(6000L);
world.setStorm(false);
world.setThundering(false);
world.setGameRule(GameRules.SPAWN_MOBS, true);
List<CompletableFuture<Void>> chunks = new ArrayList<>();
for (int chunkX = -1; chunkX <= 1; chunkX++) {
for (int chunkZ = -1; chunkZ <= 1; chunkZ++) {
chunks.add(retainChunk(world, chunkX, chunkZ));
}
}
CompletableFuture.allOf(chunks.toArray(CompletableFuture[]::new)).whenComplete((ignored, failure) -> {
if (failure != null) {
fail(sender, failure);
} else {
Bukkit.getRegionScheduler().execute(this, world, 0, 0, () -> prepareChunk(sender, world));
}
});
} catch (Throwable failure) {
fail(sender, failure);
}
}
private CompletableFuture<Void> retainChunk(World world, int chunkX, int chunkZ) {
CompletableFuture<Void> retained = new CompletableFuture<>();
world.getChunkAtAsync(chunkX, chunkZ, true).whenComplete((chunk, failure) -> {
if (failure != null) {
retained.completeExceptionally(failure);
return;
}
Bukkit.getRegionScheduler().execute(this, world, chunkX, chunkZ, () -> {
try {
chunk.addPluginChunkTicket(this);
for (Entity entity : chunk.getEntities()) {
if (entity instanceof LivingEntity && !(entity instanceof Player)) {
entity.remove();
}
}
retained.complete(null);
} catch (Throwable error) {
retained.completeExceptionally(error);
}
});
});
return retained;
}
private void prepareChunk(CommandSender sender, World world) {
try {
require(WORLD_KEY.equals(world.getKey()) && IrisToolbelt.isIrisWorld(world)
&& world.getSeed() == WORLD_SEED, "Fixture world ownership changed");
Chunk chunk = world.getChunkAt(0, 0);
int removed = 0;
for (Entity entity : chunk.getEntities()) {
if (entity instanceof LivingEntity && !(entity instanceof Player)) {
entity.remove();
removed++;
}
}
for (Entity entity : chunk.getEntities()) {
require(!(entity instanceof LivingEntity) || entity instanceof Player || !entity.isValid(),
"Fixture target chunk retained a living mob after cleanup: " + entity.getType());
}
getLogger().info("SPAWN_QA_CLEANUP removed=" + removed + " chunk=0,0");
firstZombieBatch = 0;
ambientEvents = 0;
cancelledAmbientEvents = 0;
for (int x = 0; x < 16; x++) {
for (int z = 0; z < 16; z++) {
chunk.getBlock(x, 64, z).setType(Material.GRASS_BLOCK, false);
for (int y = 65; y <= 68; y++) {
chunk.getBlock(x, y, z).setType(Material.AIR, false);
}
for (int y = -48; y <= -42; y++) {
boolean shell = y == -48 || y == -42 || x == 0 || x == 15 || z == 0 || z == 15;
chunk.getBlock(x, y, z).setType(shell ? Material.STONE : Material.CAVE_AIR, false);
}
}
}
if (!IrisToolbelt.isIrisWorld(world) || world.getSeed() != WORLD_SEED) {
throw new IllegalStateException("Fixture is not the requested Iris world");
}
awaitBiomeEnvironment(sender, world, 0);
} catch (Throwable failure) {
fail(sender, failure);
}
}
private void awaitBiomeEnvironment(CommandSender sender, World world, int attempt) {
try {
Engine engine = IrisToolbelt.access(world).getEngine();
engine.getSurfaceBiomeEnvironment(8, 8);
ready = true;
teleport(sender, world, 67D, "SPAWN_QA_SETUP world=" + WORLD_KEY + " seed=" + WORLD_SEED);
} catch (SavedBiomeUnavailableException unavailable) {
if (!unavailable.isLoading() || attempt >= BIOME_READY_ATTEMPTS) {
fail(sender, unavailable);
return;
}
Bukkit.getRegionScheduler().runDelayed(this, world, 0, 0,
task -> awaitBiomeEnvironment(sender, world, attempt + 1), BIOME_RETRY_TICKS);
} catch (Throwable failure) {
fail(sender, failure);
}
}
private void completion(CommandSender sender) {
World world = requireWorld();
Bukkit.getGlobalRegionScheduler().execute(this, () -> {
world.setGameRule(GameRules.SPAWN_MOBS, false);
runRegion(sender, () -> beginCompletion(sender, world));
});
}
private void beginCompletion(CommandSender sender, World world) {
try {
Engine engine = IrisToolbelt.access(world).getEngine();
Object manager = engine.getWorldManager();
Field field = manager.getClass().getDeclaredField("chunkMaintenance");
field.setAccessible(true);
Object maintenance = field.get(manager);
Method run = maintenance.getClass().getDeclaredMethod("runInitialSpawn", World.class, int.class, int.class, Runnable.class);
run.setAccessible(true);
Field pending = maintenance.getClass().getDeclaredField("markerFlagQueue");
pending.setAccessible(true);
AtomicInteger callbacks = new AtomicInteger();
Runnable callback = () -> {
require(Bukkit.isOwnedByCurrentRegion(world, 0, 0), "Initial callback ran outside its owning region");
callbacks.incrementAndGet();
Chunk chunk = world.getChunkAt(0, 0);
invokeAmbient(runtimeSpawn(chunk, "seeded"), chunk, true);
};
run.invoke(maintenance, world, 0, 0, callback);
awaitSkippedInitial(sender, world, new CompletionRun(engine, maintenance, run, pending, callback, callbacks), 0);
} catch (ReflectiveOperationException failure) {
throw new IllegalStateException("Could not start initial completion test", failure);
}
}
private void awaitSkippedInitial(CommandSender sender, World world, CompletionRun state, int attempt) {
Bukkit.getRegionScheduler().runDelayed(this, world, 0, 0, task -> {
try {
require(state.callbacks().get() == 0, "Disabled mob spawning consumed the initial pass");
if (!((Set<?>) state.pending().get(state.maintenance())).isEmpty()) {
require(attempt < 100, "Skipped initial pass did not release its pending state");
awaitSkippedInitial(sender, world, state, attempt + 1);
return;
}
Bukkit.getGlobalRegionScheduler().execute(this, () -> {
world.setGameRule(GameRules.SPAWN_MOBS, true);
runRegion(sender, () -> retryInitialCompletion(sender, world, state));
});
} catch (Throwable failure) {
fail(sender, failure);
}
}, 2L);
}
private void retryInitialCompletion(CommandSender sender, World world, CompletionRun state) {
try {
state.run().invoke(state.maintenance(), world, 0, 0, state.callback());
state.run().invoke(state.maintenance(), world, 0, 0, state.callback());
awaitInitialCompletion(sender, world, state, 0);
} catch (ReflectiveOperationException failure) {
throw new IllegalStateException("Could not retry initial spawning", failure);
}
}
private void awaitInitialCompletion(CommandSender sender, World world, CompletionRun state, int attempt) {
Bukkit.getRegionScheduler().runDelayed(this, world, 0, 0, task -> {
try {
if (state.callbacks().get() == 0 || !((Set<?>) state.pending().get(state.maintenance())).isEmpty()) {
require(attempt < 100, "Initial spawning did not complete after retry");
awaitInitialCompletion(sender, world, state, attempt + 1);
return;
}
require(state.callbacks().get() == 1, "Concurrent initial requests spawned duplicate populations");
state.run().invoke(state.maintenance(), world, 0, 0, state.callback());
Bukkit.getAsyncScheduler().runNow(this, ignored -> {
try {
require(state.engine().getMantle().getMantle().hasFlag(0, 0, MantleFlag.INITIAL_SPAWNED_MARKER), "Initial completion was not persisted");
require(state.callbacks().get() == 1, "Completed initial pass ran again");
evidence(sender, "SPAWN_QA_COMPLETION retry=true callbacks=1 persisted=true owned=true");
} catch (Throwable failure) {
fail(sender, failure);
}
});
} catch (Throwable failure) {
fail(sender, failure);
}
}, 2L);
}
private void seeded(CommandSender sender) {
requireWorld();
List<List<String>> baseline = new ArrayList<>();
sampleInitial(0, false).thenCompose(first -> {
baseline.add(first);
return sampleInitial(1, false);
}).thenCompose(second -> {
baseline.add(second);
return sampleInitial(1, false);
}).thenCompose(repeated -> {
require(baseline.get(1).equals(repeated), "Second chunk changed after reversed generation order");
return sampleInitial(0, false);
}).thenCompose(repeated -> {
require(baseline.get(0).equals(repeated), "First chunk changed after reversed generation order");
return sampleInitial(0, true);
}).thenCompose(marker -> {
baseline.add(marker);
return sampleInitial(1, true);
}).thenCompose(marker -> {
baseline.add(marker);
return sampleInitial(1, true);
}).thenCompose(repeated -> {
require(baseline.get(3).equals(repeated), "Second marker changed after reversed generation order");
return sampleInitial(0, true);
}).whenComplete((repeated, failure) -> {
if (failure != null) {
fail(sender, failure);
return;
}
try {
require(baseline.get(2).equals(repeated), "First marker changed after reversed generation order");
evidence(sender, "SPAWN_QA_SEEDED chunks=2 markers=2 repeats=2 equipment=true attributes=true");
} catch (Throwable error) {
fail(sender, error);
}
});
}
private CompletableFuture<List<String>> sampleInitial(int chunkX, boolean marker) {
CompletableFuture<List<String>> result = new CompletableFuture<>();
World world = requireWorld();
Bukkit.getRegionScheduler().execute(this, world, chunkX, 0, () -> {
try {
Chunk chunk = world.getChunkAt(chunkX, 0);
for (Entity entity : chunk.getEntities()) {
if (entity instanceof LivingEntity && !(entity instanceof Player)) {
entity.remove();
}
}
for (int x = 0; x < 16; x++) {
for (int z = 0; z < 16; z++) {
chunk.getBlock(x, 64, z).setType(Material.GRASS_BLOCK, false);
for (int y = 65; y <= 68; y++) {
chunk.getBlock(x, y, z).setType(Material.AIR, false);
}
}
}
RuntimeSpawn spawn = runtimeSpawn(chunk, "seeded");
IrisSpawner definition = spawn.environment().data().getSpawnerLoader().load("qa/seeded");
require(!definition.canSpawn(spawn.engine(), chunkX, 0), "Ongoing weather/time gate unexpectedly allowed spawning");
for (int i = 0; i < 100 + chunkX; i++) {
RNG.r.nextLong();
}
ambientInvocation.set(true);
try {
for (int i = 0; i < 5; i++) {
LivingEntity witness = (LivingEntity) world.spawnEntity(new Location(world, (chunkX << 4) + 8.5, 65, 8.5), EntityType.ZOMBIE);
witness.setCustomName("Population witness");
witness.setAI(false);
witness.setGravity(false);
}
} finally {
ambientInvocation.remove();
}
if (marker) {
invokeInitialMarker(spawn, definition, new IrisPosition((chunkX << 4) + 5, 64, 5));
} else {
invokeAmbient(spawn, chunk, true);
}
List<String> snapshot = new ArrayList<>();
for (Entity entity : chunk.getEntities()) {
String name = entity.getCustomName();
if (!(entity instanceof LivingEntity living) || name == null || !name.startsWith("Seeded ")) {
continue;
}
ItemStack hand = living.getEquipment().getItemInMainHand();
ItemStack helmet = living.getEquipment().getHelmet();
require(hand.hasItemMeta() && hand.getItemMeta().hasAttributeModifiers(), "Spawned gear has no authored attributes");
require(helmet != null && helmet.getType() == Material.IRON_HELMET, "Spawned helmet was not authored equipment");
Location at = entity.getLocation();
snapshot.add(entity.getType() + ":" + at.getX() + ":" + at.getY() + ":" + at.getZ()
+ ":" + HexFormat.of().formatHex(hand.serializeAsBytes())
+ ":" + HexFormat.of().formatHex(helmet.serializeAsBytes()));
}
require(snapshot.size() >= 2 && snapshot.size() <= 4, "Initial population was blocked by runtime gates or cap: " + snapshot.size());
Collections.sort(snapshot);
result.complete(List.copyOf(snapshot));
} catch (Throwable failure) {
result.completeExceptionally(failure);
}
});
return result;
}
private void invokeInitialMarker(RuntimeSpawn spawn, IrisSpawner definition, IrisPosition position) {
ambientInvocation.set(true);
try (BiomeEnvironment.Scope ignored = spawn.engine().openBiomeEnvironmentScope(spawn.environment())) {
Method method = spawn.spawner().getClass().getDeclaredMethod("spawnMarker", IrisPosition.class, IrisSpawner.class, IrisMarker.class, RNG.class, boolean.class);
method.setAccessible(true);
method.invoke(spawn.spawner(), position, definition, null, EntitySpawnSeed.marker(spawn.engine().getSeedManager().getEntity(), position.getX(), position.getY(), position.getZ()), true);
} catch (InvocationTargetException failure) {
throw new IllegalStateException("Initial marker spawning failed", failure.getCause());
} catch (ReflectiveOperationException failure) {
throw new IllegalStateException("Could not invoke initial marker spawning", failure);
} finally {
ambientInvocation.remove();
}
}
private void spawnFrogs(CommandSender sender) {
Chunk chunk = requireWorld().getChunkAt(0, 0);
RuntimeSpawn spawn = runtimeSpawn(chunk, "frogs");
getLogger().info(spawnDiagnostics(spawn, chunk));
for (int attempt = 0; attempt < 12; attempt++) {
invokeAmbient(spawn, chunk, false);
require(count(chunk, EntityType.FROG) <= FROG_CAPACITY, "Frog batch exceeded its category capacity");
}
require(count(chunk, EntityType.FROG) == FROG_CAPACITY,
"Frog ambient category did not fill: " + spawnDiagnostics(spawn, chunk));
require(count(chunk, EntityType.ZOMBIE) == 0, "Unexpected zombie before cave phase");
verifyPersistence(chunk);
evidence(sender, "SPAWN_QA_FROGS frogs=" + count(chunk, EntityType.FROG)
+ " persistent=true removable=true ambient=true");
}
private void spawnZombies(CommandSender sender) {
Chunk chunk = requireWorld().getChunkAt(0, 0);
require(count(chunk, EntityType.FROG) == FROG_CAPACITY, "Frogs must fill their category before cave spawning");
require(count(chunk, EntityType.ZOMBIE) == 0, "Cave phase already ran");
RuntimeSpawn spawn = runtimeSpawn(chunk, "cave-zombies");
getLogger().info(spawnDiagnostics(spawn, chunk));
invokeAmbient(spawn, chunk, false);
firstZombieBatch = count(chunk, EntityType.ZOMBIE);
require(firstZombieBatch == ZOMBIE_CAPACITY,
"Authored seven-zombie batch was not clamped to capacity two: " + spawnDiagnostics(spawn, chunk));
require(count(chunk, EntityType.FROG) == FROG_CAPACITY, "Cave spawning changed the passive population");
verifyPersistence(chunk);
evidence(sender, "SPAWN_QA_ZOMBIES frogs=" + count(chunk, EntityType.FROG)
+ " zombies=" + firstZombieBatch + " firstBatch=" + firstZombieBatch + " ambient=true");
}
private void stress(CommandSender sender) {
Chunk chunk = requireWorld().getChunkAt(0, 0);
RuntimeSpawn spawn = runtimeSpawn(chunk, "cave-zombies");
for (int attempt = 0; attempt < 50; attempt++) {
invokeAmbient(spawn, chunk, false);
require(count(chunk, EntityType.ZOMBIE) == ZOMBIE_CAPACITY, "Saturated zombie category exceeded capacity");
}
evidence(sender, "SPAWN_QA_STRESS attempts=50 zombies=" + count(chunk, EntityType.ZOMBIE));
}
private void check(CommandSender sender) {
Chunk chunk = requireWorld().getChunkAt(0, 0);
require(count(chunk, EntityType.FROG) == FROG_CAPACITY, "Passive population differs");
require(count(chunk, EntityType.ZOMBIE) == ZOMBIE_CAPACITY, "Hostile population differs");
require(firstZombieBatch == ZOMBIE_CAPACITY, "First hostile batch was not pinned");
require(chunk.getBlock(8, -47, 8).getType() == Material.CAVE_AIR, "Cave is not real CAVE_AIR");
require(chunk.getBlock(8, -47, 8).getLightLevel() == 0, "Cave is not dark");
for (Entity entity : chunk.getEntities()) {
if (entity.getType() == EntityType.ZOMBIE) {
require(entity.getLocation().getY() < -42, "Hostile spawned outside the cave");
}
}
verifyPersistence(chunk);
evidence(sender, "SPAWN_QA_RESULT frogs=16 zombies=2 firstBatch=2 persistent=true removable=true caveAir=true light=0");
}
private RuntimeSpawn runtimeSpawn(Chunk chunk, String biomeKey) {
try {
Engine engine = IrisToolbelt.access(chunk.getWorld()).getEngine();
BiomeEnvironment surface = engine.getSurfaceBiomeEnvironment(8, 8);
IrisBiome biome = surface.data().getBiomeLoader().load(biomeKey);
require(biome != null, "Synthetic biome was not loaded");
BiomeEnvironment selected = new BiomeEnvironment(surface.activationId(), biome, surface.region(),
surface.dimension(), surface.data());
Object manager = engine.getWorldManager();
Field field = manager.getClass().getDeclaredField("entitySpawner");
field.setAccessible(true);
Object spawner = field.get(manager);
Method method = spawner.getClass().getDeclaredMethod("spawnAmbient", Chunk.class, boolean.class, BiomeEnvironment.class);
method.setAccessible(true);
return new RuntimeSpawn(engine, spawner, method, selected);
} catch (ReflectiveOperationException failure) {
throw new IllegalStateException("Could not access Iris ambient spawning", failure);
}
}
private String spawnDiagnostics(RuntimeSpawn spawn, Chunk chunk) {
World world = chunk.getWorld();
int floor = world.getHighestBlockYAt(8, 8, HeightMap.OCEAN_FLOOR);
int top = world.getHighestBlockYAt(8, 8, HeightMap.WORLD_SURFACE);
Map<String, Integer> categories = new TreeMap<>();
Map<String, Integer> types = new TreeMap<>();
for (Entity entity : chunk.getEntities()) {
if (entity instanceof LivingEntity && !(entity instanceof Player)) {
categories.merge(BukkitEntityType.of(entity.getType()).spawnCategory(), 1, Integer::sum);
types.merge(entity.getType().name(), 1, Integer::sum);
}
}
StringBuilder diagnostics = new StringBuilder("SPAWN_QA_DIAGNOSTICS frogs=")
.append(count(chunk, EntityType.FROG)).append(" zombies=").append(count(chunk, EntityType.ZOMBIE))
.append(" categories=").append(categories).append(" types=").append(types)
.append(" loadLevel=").append(chunk.getLoadLevel())
.append(" spawnMobs=").append(world.getGameRuleValue(GameRules.SPAWN_MOBS))
.append(" animals=").append(world.getAllowAnimals()).append(" monsters=").append(world.getAllowMonsters())
.append(" floor=").append(floor).append(" top=").append(top)
.append(" support=").append(world.getBlockAt(8, top, 8).getType())
.append(" above=").append(world.getBlockAt(8, top + 1, 8).getType())
.append(" light=").append(world.getBlockAt(8, top + 1, 8).getLightLevel())
.append(" events=").append(ambientEvents).append(" cancelled=").append(cancelledAmbientEvents);
for (String key : spawn.environment().biome().getEntitySpawners()) {
IrisSpawner spawner = spawn.environment().data().getSpawnerLoader().load(key);
if (spawner == null) {
diagnostics.append(" spawner=").append(key).append(":missing");
continue;
}
diagnostics.append(" spawner=").append(key).append(" canSpawn=")
.append(spawner.canSpawn(spawn.engine(), chunk.getX(), chunk.getZ()))
.append(" excluded=").append(spawner.isCompatExcluded())
.append(" group=").append(spawner.getGroup()).append(" capacity=").append(spawner.getMaxEntitiesPerChunk());
for (IrisEntitySpawn entry : spawner.getSpawns()) {
IrisEntity entity = entry.getRealEntity(spawn.engine());
diagnostics.append(" entity=").append(entry.getEntity());
if (entity == null) {
diagnostics.append(":missing");
continue;
}
diagnostics.append(" type=").append(entity.getBukkitType()).append(" category=").append(entity.spawnCategory())
.append(" surface=").append(entity.getSurface())
.append(" supportMatches=").append(entity.getSurface().matches(world.getBlockAt(8, top, 8)))
.append(" bounds=").append(BukkitPlatform.entityBoundingBox(entity.getBukkitType()));
}
}
return diagnostics.toString();
}
private void invokeAmbient(RuntimeSpawn spawn, Chunk chunk, boolean initial) {
ambientInvocation.set(true);
try (BiomeEnvironment.Scope ignored = spawn.engine().openBiomeEnvironmentScope(spawn.environment())) {
spawn.method().invoke(spawn.spawner(), chunk, initial, spawn.environment());
} catch (InvocationTargetException failure) {
throw new IllegalStateException("Iris ambient spawning failed", failure.getCause());
} catch (ReflectiveOperationException failure) {
throw new IllegalStateException("Could not invoke Iris ambient spawning", failure);
} finally {
ambientInvocation.remove();
}
}
private int count(Chunk chunk, EntityType type) {
int count = 0;
for (Entity entity : chunk.getEntities()) {
if (entity.getType() == type) {
count++;
}
}
return count;
}
private void verifyPersistence(Chunk chunk) {
for (Entity entity : chunk.getEntities()) {
if (entity.getType() == EntityType.FROG || entity.getType() == EntityType.ZOMBIE) {
require(entity.isPersistent(), "Ambient entity stopped participating in chunk saves");
require(entity instanceof LivingEntity living && living.getRemoveWhenFarAway(),
"Ambient entity has distance despawning disabled");
}
}
}
private void view(CommandSender sender, double y) {
teleport(sender, requireWorld(), y, "SPAWN_QA_VIEW y=" + (int) y);
}
private void teleport(CommandSender sender, World world, double y, String message) {
if (!(sender instanceof Player player)) {
throw new IllegalArgumentException("Viewing requires a player");
}
player.getScheduler().run(this, task -> {
player.setGameMode(GameMode.CREATIVE);
player.setAllowFlight(true);
player.setFlying(true);
player.teleportAsync(new Location(world, 40.5D, y, 8.5D)).whenComplete((success, failure) -> {
if (failure != null || !Boolean.TRUE.equals(success)) {
fail(sender, failure == null ? new IllegalStateException("Fixture teleport failed") : failure);
} else {
notifySender(sender, message);
}
});
}, null);
}
private World requireWorld() {
World world = Bukkit.getWorld(WORLD_KEY);
require(ready && world != null, "Run setup before a spawning phase");
return world;
}
private void runRegion(CommandSender sender, Runnable operation) {
World world = requireWorld();
Bukkit.getRegionScheduler().execute(this, world, 0, 0, () -> {
try {
operation.run();
} catch (Throwable failure) {
fail(sender, failure);
}
});
}
private void evidence(CommandSender sender, String message) {
getLogger().info(message);
notifySender(sender, message);
}
private void notifySender(CommandSender sender, String message) {
if (sender instanceof Player player) {
player.getScheduler().run(this, task -> player.sendMessage(message), null);
} else {
sender.sendMessage(message);
}
}
private void fail(CommandSender sender, Throwable failure) {
getLogger().log(Level.SEVERE, "Spawning acceptance failed", failure);
notifySender(sender, "SPAWN_QA_FAILED " + failure.getMessage());
}
private static void require(boolean condition, String message) {
if (!condition) {
throw new IllegalStateException(message);
}
}
private record CompletionRun(Engine engine, Object maintenance, Method run, Field pending, Runnable callback, AtomicInteger callbacks) {
}
private record RuntimeSpawn(Engine engine, Object spawner, Method method, BiomeEnvironment environment) {
}
}
@@ -0,0 +1,26 @@
{
"name": "Cave Zombies",
"derivative": "minecraft:plains",
"vanillaDerivative": "minecraft:plains",
"layers": [
{
"minHeight": 1,
"maxHeight": 1,
"palette": [
{
"block": "minecraft:grass_block"
}
]
}
],
"generators": [
{
"generator": "flat",
"min": 64,
"max": 64
}
],
"entitySpawners": [
"qa/cave-zombies"
]
}
@@ -0,0 +1,26 @@
{
"name": "Frogs",
"derivative": "minecraft:plains",
"vanillaDerivative": "minecraft:plains",
"layers": [
{
"minHeight": 1,
"maxHeight": 1,
"palette": [
{
"block": "minecraft:grass_block"
}
]
}
],
"generators": [
{
"generator": "flat",
"min": 64,
"max": 64
}
],
"entitySpawners": [
"qa/frogs"
]
}
@@ -0,0 +1,26 @@
{
"name": "Seeded population",
"derivative": "minecraft:plains",
"vanillaDerivative": "minecraft:plains",
"layers": [
{
"minHeight": 1,
"maxHeight": 1,
"palette": [
{
"block": "minecraft:grass_block"
}
]
}
],
"generators": [
{
"generator": "flat",
"min": 64,
"max": 64
}
],
"entitySpawners": [
"qa/seeded"
]
}
@@ -0,0 +1,28 @@
{
"name": "Spawn QA",
"version": 1,
"mode": {
"type": "OVERWORLD"
},
"regions": [
"flat"
],
"focusRegion": "flat",
"focus": "frogs",
"landChance": 1,
"environment": "NORMAL",
"dimensionHeight": {
"min": -64,
"max": 320
},
"logicalHeight": 384,
"fluidHeight": 0,
"bedrock": false,
"rockPalette": {
"palette": [
{
"block": "minecraft:stone"
}
]
}
}
@@ -0,0 +1,8 @@
{
"type": "minecraft:frog",
"reason": "NATURAL",
"surface": "LAND",
"ai": false,
"aware": false,
"gravity": false
}
@@ -0,0 +1,29 @@
{
"type": "minecraft:husk",
"reason": "NATURAL",
"surface": "LAND",
"ai": false,
"aware": false,
"gravity": false,
"customName": "Seeded husk",
"invulnerable": true,
"helmet": {
"type": "IRON_HELMET",
"minDurability": 0.1,
"maxDurability": 0.9
},
"mainHand": {
"type": "IRON_SWORD",
"minDurability": 0.1,
"maxDurability": 0.9,
"attributes": [
{
"name": "seeded_damage",
"attribute": "minecraft:attack_damage",
"minAmount": 1.25,
"maxAmount": 6.75,
"chance": 1
}
]
}
}
@@ -0,0 +1,29 @@
{
"type": "minecraft:zombie",
"reason": "NATURAL",
"surface": "LAND",
"ai": false,
"aware": false,
"gravity": false,
"customName": "Seeded zombie",
"invulnerable": true,
"helmet": {
"type": "IRON_HELMET",
"minDurability": 0.1,
"maxDurability": 0.9
},
"mainHand": {
"type": "IRON_SWORD",
"minDurability": 0.1,
"maxDurability": 0.9,
"attributes": [
{
"name": "seeded_damage",
"attribute": "minecraft:attack_damage",
"minAmount": 1.25,
"maxAmount": 6.75,
"chance": 1
}
]
}
}
@@ -0,0 +1,8 @@
{
"type": "minecraft:zombie",
"reason": "NATURAL",
"surface": "LAND",
"ai": false,
"aware": false,
"gravity": false
}
@@ -0,0 +1,15 @@
{
"interpolator": {
"function": "NONE",
"horizontalScale": 1
},
"seed": 310,
"composite": [
{
"seed": 310,
"style": {
"style": "FLAT"
}
}
]
}
@@ -0,0 +1,12 @@
{
"name": "Flat",
"landBiomes": [
"frogs"
],
"seaBiomes": [
"frogs"
],
"shoreBiomes": [
"frogs"
]
}
@@ -0,0 +1,16 @@
{
"group": "CAVE",
"maxEntitiesPerChunk": 2,
"allowedLightLevels": {
"min": 0,
"max": 0
},
"spawns": [
{
"entity": "qa/zombie",
"rarity": 1,
"minSpawns": 7,
"maxSpawns": 7
}
]
}
@@ -0,0 +1,12 @@
{
"group": "NORMAL",
"maxEntitiesPerChunk": 16,
"spawns": [
{
"entity": "qa/frog",
"rarity": 1,
"minSpawns": 7,
"maxSpawns": 7
}
]
}
@@ -0,0 +1,33 @@
{
"group": "NORMAL",
"maxEntitiesPerChunk": 4,
"allowedLightLevels": {
"min": 0,
"max": 0
},
"weather": "DOWNFALL",
"timeBlock": {
"startHour": 20,
"endHour": 3
},
"maximumRate": {
"amount": 1,
"per": {
"days": 1
}
},
"initialSpawns": [
{
"entity": "qa/seeded-zombie",
"rarity": 1,
"minSpawns": 2,
"maxSpawns": 4
},
{
"entity": "qa/seeded-husk",
"rarity": 2,
"minSpawns": 2,
"maxSpawns": 4
}
]
}
@@ -0,0 +1,42 @@
export default {
name: 'iris-natural-spawning',
description: 'Observe category-limited Iris ambient frogs and cave zombies on Folia.',
async run(context) {
const wait = (predicate, label, timeoutMs = 30000) => context.waitUntil(predicate, { label, timeoutMs, intervalMs: 100 })
const command = async (operation, expected, timeoutMs = 30000) => {
const response = await context.command(`/irisspawnqa ${operation}`, /SPAWN_QA_[A-Z]+(?:\s|$)/, timeoutMs)
context.expect(response.includes(expected) && !response.includes('SPAWN_QA_FAILED'), response)
return response
}
const entities = name => Object.values(context.bot.entities).filter(entity => entity.name === name
&& entity.position.x >= 0 && entity.position.x < 16 && entity.position.z >= 0 && entity.position.z < 16)
await context.step('prepare the synthetic Iris world and dark cave', async () => {
await command('setup', 'SPAWN_QA_SETUP world=iris:spawn_qa seed=78264193', 180000)
await wait(() => Math.abs(context.bot.entity.position.y - 67) < 1, 'Fixture world teleport')
})
await context.step('fill the passive category through real Iris ambient spawning', async () => {
await command('frogs', 'SPAWN_QA_FROGS frogs=16 persistent=true removable=true ambient=true', 60000)
await wait(() => entities('frog').length === 16, 'All sixteen ambient frogs became visible')
context.report.irisNaturalSpawning = { visibleFrogs: entities('frog').length }
})
await context.step('spawn an authored seven-zombie batch with capacity two despite existing frogs', async () => {
await command('zombies', 'SPAWN_QA_ZOMBIES frogs=16 zombies=2 firstBatch=2 ambient=true', 60000)
await command('cave', 'SPAWN_QA_VIEW y=-46')
await wait(() => Math.abs(context.bot.entity.position.y + 46) < 1, 'Underground fixture teleport')
await wait(() => context.bot.blockAt(context.bot.entity.position.clone().set(8, -47, 8))?.name === 'cave_air', 'Live cave_air block reached the client')
await wait(() => entities('zombie').length === 2, 'Both ambient cave zombies became visible')
context.expect(entities('zombie').every(entity => entity.position.y < -42), 'Zombies were not inside the underground chamber')
context.report.irisNaturalSpawning.visibleZombies = entities('zombie').length
context.report.irisNaturalSpawning.caveAir = true
})
await context.step('repeat fifty ambient attempts without exceeding the hostile capacity', async () => {
await command('stress', 'SPAWN_QA_STRESS attempts=50 zombies=2', 60000)
await command('check', 'SPAWN_QA_RESULT frogs=16 zombies=2 firstBatch=2 persistent=true removable=true caveAir=true light=0', 30000)
context.expect(entities('zombie').length === 2, 'Client observed a hostile capacity overflow')
context.report.irisNaturalSpawning.saturatedAttempts = 50
context.report.irisNaturalSpawning.firstHostileBatch = 2
context.report.irisNaturalSpawning.removeWhenFarAwayAllowed = true
context.report.irisNaturalSpawning.chunkSaving = true
})
}
}
@@ -0,0 +1,32 @@
{
"name": "Flat",
"derivative": "minecraft:plains",
"vanillaDerivative": "minecraft:plains",
"layers": [
{
"minHeight": 1,
"maxHeight": 1,
"palette": [
{
"block": "minecraft:lime_wool"
}
]
},
{
"minHeight": 384,
"maxHeight": 384,
"palette": [
{
"block": "minecraft:green_concrete"
}
]
}
],
"generators": [
{
"generator": "flat",
"min": 64,
"max": 96
}
]
}
@@ -0,0 +1,28 @@
{
"name": "Noise Sources",
"version": 1,
"mode": {
"type": "OVERWORLD"
},
"regions": [
"flat"
],
"focusRegion": "flat",
"focus": "flat",
"landChance": 1,
"environment": "NORMAL",
"dimensionHeight": {
"min": -64,
"max": 320
},
"logicalHeight": 384,
"fluidHeight": 0,
"bedrock": false,
"rockPalette": {
"palette": [
{
"block": "minecraft:red_concrete"
}
]
}
}
@@ -0,0 +1,3 @@
{
"expression": "0.5 + sin(x * 0.15) * 0.25 + cos(y * 0.13) * 0.25"
}
@@ -0,0 +1,22 @@
{
"interpolator": {
"function": "NONE",
"horizontalScale": 1
},
"seed": 310,
"composite": [
{
"seed": 310,
"style": {
"expression": "terrain"
}
},
{
"seed": 311,
"style": {
"style": "CLOVER_HERMITE",
"zoom": 0.1
}
}
]
}
@@ -0,0 +1,12 @@
{
"name": "Flat",
"landBiomes": [
"flat"
],
"seaBiomes": [
"flat"
],
"shoreBiomes": [
"flat"
]
}
@@ -0,0 +1,48 @@
import { readFile } from 'node:fs/promises'
import path from 'node:path'
export default {
name: 'iris-noise',
description: 'Verify expression and interpolated noise produce varied, bounded terrain visible to a player.',
async run(context) {
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
context.expect(/^isolated=true\s*$/m.test(source), 'Noise generation requires an isolated instance')
await context.step('create a world with expression and Clover noise', async () => {
const response = await context.command('/iris create name=noise_probe type=noise-fixture seed=78264193',
/Successfully created your world|Failed to create/, 180000)
context.expect(response.includes('Successfully created your world'), response)
})
await context.step('client observes bounded terrain with varying heights', async () => {
let columns = 0
let minimum = Infinity
let maximum = -Infinity
await context.waitUntil(() => {
columns = 0
minimum = Infinity
maximum = -Infinity
const center = context.bot.entity.position.floored()
for (let x = -8; x <= 8; x++) {
for (let z = -8; z <= 8; z++) {
let surface = null
for (let y = 60; y <= 100; y++) {
const position = center.offset(x, y - center.y, z)
const block = context.bot.blockAt(position)
if (!block) return false
if (block.name === 'lime_wool') {
if (surface !== null || context.bot.blockAt(position.offset(0, 1, 0))?.name !== 'air') return false
surface = y
}
}
if (surface === null || surface < 64 || surface > 96) return false
columns++
minimum = Math.min(minimum, surface)
maximum = Math.max(maximum, surface)
}
}
return columns === 289 && maximum - minimum >= 3
}, { label: '289 noise columns with bounded, varied surfaces', timeoutMs: 60000, intervalMs: 250 })
context.expect(columns === 289 && maximum - minimum >= 3, 'Client verified noise terrain', { columns, minimum, maximum })
context.report.noiseTerrain = { columns, minimum, maximum }
})
}
}
@@ -0,0 +1,102 @@
import { createHash } from 'node:crypto'
import { readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
const chunks = [[-64, -64], [64, -64], [-64, 64], [64, 64]]
export default {
name: 'iris-seed-cohesion',
description: 'Compare client-visible decorated chunks in two same-seed worlds explored in opposite orders.',
async run(context) {
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
context.expect(/^isolated=true\s*$/m.test(source), 'Seed cohesion requires an isolated instance')
const wait = (predicate, label) => context.waitUntil(predicate, { label, timeoutMs: 60000, intervalMs: 100 })
const currentWorld = async () => {
const observation = await context.observe()
const player = observation.players.find(entry => entry.username === context.bot.username)
return observation.worlds.find(entry => entry.id === player?.world)
}
const enter = async name => {
await context.command(`/iris tp ${name}`)
await wait(async () => (await currentWorld())?.key === `iris:${name}`
|| (await currentWorld())?.name.endsWith(name), `Enter ${name}`)
await context.command('/minecraft:gamemode spectator @s')
await wait(() => context.bot.game.gameMode === 'spectator', 'Spectator mode')
await context.command('/minecraft:seed', /Seed:.*1337/)
}
const capture = async coordinates => {
const hashes = {}
const decorators = {}
let decorationCount = 0
for (const [cx, cz] of coordinates) {
const x = cx * 16
const z = cz * 16
await context.command(`/minecraft:tp @s ${x + 8.5} 48 ${z + 8.5}`, /Teleported/i, 30000)
await wait(() => Math.abs(context.bot.entity.position.x - x - 8.5) < 1
&& Math.abs(context.bot.entity.position.z - z - 8.5) < 1, 'Chunk sample teleport')
const position = context.bot.entity.position.clone()
await wait(() => context.bot.blockAt(position.clone().set(x, 1, z))
&& context.bot.blockAt(position.clone().set(x + 15, 63, z + 15)), 'Complete sample chunk')
const digest = createHash('sha256')
let decoration = 0
let terrain = 0
for (let dx = 0; dx < 16; dx++) {
for (let dz = 0; dz < 16; dz++) {
for (let y = 0; y < 64; y++) {
const block = context.bot.blockAt(position.set(x + dx, y, z + dz))
context.expect(block !== null, 'Missing sampled block', { x: x + dx, y, z: z + dz })
digest.update(`${block.stateId},`)
if (block.name === 'granite' || block.name === 'andesite') decoration++
if (block.name === 'stone') terrain++
}
}
}
context.expect(terrain > 1000, 'Fixture terrain was not generated', { cx, cz, terrain })
decorationCount += decoration
decorators[`${cx},${cz}`] = decoration
hashes[`${cx},${cz}`] = digest.digest('hex')
}
context.expect(decorationCount > 0, 'Fixture decorators were not generated in sampled chunks', { decorators })
context.report.decoratorCoverage ??= []
context.report.decoratorCoverage.push(decorators)
return hashes
}
const baseline = 'seed-cohesion-forward'
const reverse = 'seed-cohesion-reverse'
const stateFile = path.join(context.server.directory, '.seed-cohesion.json')
const jarHash = createHash('sha256').update(await readFile(path.join(context.server.directory, 'plugins', 'Iris.jar'))).digest('hex')
const restart = context.options.command === 'restart-check'
const saved = restart ? JSON.parse(await readFile(stateFile, 'utf8')) : undefined
if (restart) {
context.expect(jarHash === saved.jarHash, 'Restart-check requires the same Iris jar', { before: saved.jarHash, after: jarHash })
context.expect((await context.observe()).processId !== saved.processId, 'Restart-check requires a cold server restart')
}
const outputs = []
for (const [name, coordinates] of [[baseline, chunks], [reverse, [...chunks].reverse()]]) {
await context.step(`${restart ? 'Reload' : 'Create'} and sample ${name}`, async () => {
if (!restart) {
await context.command(`/iris create name=${name} type=cohesion seed=1337`, /Successfully created your world/i, 180000)
}
await enter(name)
const output = await capture(coordinates)
if (restart) {
for (const [coordinate, hash] of Object.entries(output)) {
context.expect(saved.hashes[coordinate] === hash, 'Persisted chunk changed after restart', { name, coordinate, before: saved.hashes[coordinate], after: hash })
}
Object.assign(output, await capture(coordinates.map(([cx, cz]) => [cx * 3, cz * 3])))
}
outputs.push(output)
})
}
await context.step('Compare all sampled blocks', async () => {
for (const [coordinate, hash] of Object.entries(outputs[0])) {
context.expect(outputs[1][coordinate] === hash, 'Same-seed worlds differ',
{ coordinate, forward: hash, reverse: outputs[1][coordinate] })
}
context.report.seedCohesion = { seed: 1337, jarHash, blocksPerChunk: 16384, restart, hashes: outputs[0] }
if (!restart) {
await writeFile(stateFile, JSON.stringify({ processId: (await context.observe()).processId, jarHash, hashes: outputs[0] }))
}
})
}
}
@@ -0,0 +1,24 @@
export default {
name: 'iris-seeded-entities',
description: 'Replay initial chunk and marker populations in reversed order, comparing positions and complete equipment data.',
async run(context) {
const command = async (operation, expected, timeoutMs) => {
const response = await context.command(`/irisspawnqa ${operation}`, /SPAWN_QA_[A-Z]+(?:\s|$)/, timeoutMs)
context.expect(response.includes(expected) && !response.includes('SPAWN_QA_FAILED'), response)
}
await context.step('prepare a controlled Iris world', async () => {
await command('setup', 'SPAWN_QA_SETUP', 180000)
})
await context.step('replay initial mobs despite unrelated randomness and live spawn restrictions', async () => {
await command('seeded', 'SPAWN_QA_SEEDED chunks=2 markers=2 repeats=2 equipment=true attributes=true', 60000)
await context.waitUntil(() => Object.values(context.bot.entities).some(entity => entity.name === 'zombie' || entity.name === 'husk'), {
label: 'Generated mobs reached the client', timeoutMs: 10000, intervalMs: 100
})
context.report.seededEntities = { chunks: 2, markers: 2, repeats: 2, positions: true, equipmentBytes: true, attributeIds: true }
})
await context.step('retry a skipped initial pass and persist completion once', async () => {
await command('completion', 'SPAWN_QA_COMPLETION retry=true callbacks=1 persisted=true owned=true', 30000)
context.report.seededEntities.completionRetry = true
})
}
}
@@ -0,0 +1,45 @@
{
"name": "Flat",
"derivative": "minecraft:plains",
"vanillaDerivative": "minecraft:plains",
"layers": [
{
"minHeight": 1,
"maxHeight": 1,
"palette": [
{
"block": "minecraft:lime_wool"
}
]
},
{
"minHeight": 384,
"maxHeight": 384,
"palette": [
{
"block": "minecraft:stone"
}
]
}
],
"generators": [
{
"generator": "flat",
"min": 64,
"max": 64
}
],
"objects": [
{
"place": [
"object"
],
"chance": 1,
"density": 4,
"mode": "CENTER_HEIGHT",
"rotation": {
"enabled": false
}
}
]
}
@@ -0,0 +1,66 @@
{
"name": "Structure Fixture",
"version": 1,
"mode": {
"type": "OVERWORLD"
},
"regions": [
"flat"
],
"focusRegion": "flat",
"focus": "flat",
"landChance": 1,
"environment": "NORMAL",
"dimensionHeight": {
"min": -64,
"max": 320
},
"logicalHeight": 384,
"fluidHeight": 0,
"bedrock": false,
"rockPalette": {
"palette": [
{
"block": "minecraft:stone"
}
]
},
"structures": [
{
"structures": [
"surface"
],
"placementId": "surface",
"distribution": "DENSITY",
"density": 1,
"anchor": "SURFACE",
"terrain": {
"mode": "PRESERVE"
},
"stilt": {
"maxDepth": 16,
"palette": {
"palette": [
{
"block": "minecraft:diamond_block"
}
]
}
}
},
{
"structures": [
"buried"
],
"placementId": "buried",
"distribution": "DENSITY",
"density": 1,
"anchor": "HEIGHT_BAND",
"minHeight": 24,
"maxHeight": 24,
"terrain": {
"mode": "BORE"
}
}
]
}
@@ -0,0 +1,15 @@
{
"interpolator": {
"function": "NONE",
"horizontalScale": 1
},
"seed": 310,
"composite": [
{
"seed": 310,
"style": {
"style": "FLAT"
}
}
]
}
@@ -0,0 +1,8 @@
{
"object": "buried",
"connectors": [],
"rotatable": false,
"rules": {
"terminal": true
}
}
@@ -0,0 +1,8 @@
{
"object": "surface",
"connectors": [],
"rotatable": false,
"rules": {
"terminal": true
}
}
@@ -0,0 +1,8 @@
{
"pieces": [
{
"piece": "buried",
"weight": 1
}
]
}
@@ -0,0 +1,8 @@
{
"pieces": [
{
"piece": "surface",
"weight": 1
}
]
}
@@ -0,0 +1,12 @@
{
"name": "Flat",
"landBiomes": [
"flat"
],
"seaBiomes": [
"flat"
],
"shoreBiomes": [
"flat"
]
}
@@ -0,0 +1,6 @@
{
"startPool": "buried",
"maxDepth": 1,
"maxSizeChunks": 1,
"placeMode": "STRUCTURE_PIECE"
}
@@ -0,0 +1,6 @@
{
"startPool": "surface",
"maxDepth": 1,
"maxSizeChunks": 1,
"placeMode": "STRUCTURE_PIECE"
}
@@ -0,0 +1,52 @@
import { readFile } from 'node:fs/promises'
import { createHash } from 'node:crypto'
import path from 'node:path'
export default {
name: 'iris-structures',
description: 'Verify surface foundations, buried rooms, and waterlogged object blocks in generated chunks.',
async run(context) {
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
context.expect(/^isolated=true\s*$/m.test(source), 'Structure generation requires an isolated instance')
const world = context.options.command ?? 'structure_probe'
context.expect(/^[a-z0-9_]+$/.test(world), 'Fixture world name must be a simple identifier')
await context.step('create the structure fixture world', async () => {
const response = await context.command(`/iris create name=${world} type=structure-fixture seed=78264193`,
/World ready|Failed to create/, 180000)
context.expect(response.includes('World ready'), response)
})
await context.step('observe structures and objects above and below terrain', async () => {
await context.waitUntil(() => {
if (Math.abs(context.bot.entity.position.x) > 32 || Math.abs(context.bot.entity.position.z) > 32) return false
for (let x = -16; x < 32; x += 16) {
for (let z = -16; z < 32; z += 16) {
for (let y = 16; y <= 80; y += 16) {
if (!context.bot.blockAt(context.bot.entity.position.clone().set(x, y, z))) return false
}
}
}
return true
}, { label: 'Complete structure fixture chunks', timeoutMs: 60000, intervalMs: 100 })
const digest = createHash('sha256')
const counts = { emerald_block: 0, diamond_block: 0, blue_concrete: 0, gold_block: 0, oak_slab: 0 }
for (let x = -16; x < 32; x++) {
for (let z = -16; z < 32; z++) {
for (let y = 16; y <= 80; y++) {
const block = context.bot.blockAt(context.bot.entity.position.clone().set(x, y, z))
context.expect(block !== null, 'Every sampled structure block must be loaded')
digest.update(`${block.stateId}\n`)
if (Object.hasOwn(counts, block.name)) counts[block.name]++
if (block.name === 'emerald_block') context.expect(y > 64, 'Surface platform must be above terrain')
if (block.name === 'blue_concrete') context.expect(y < 40, 'Buried room must remain underground')
if (block.name === 'oak_slab') context.expect(block.getProperties().waterlogged === false,
'Object slabs placed on dry ground must clear waterlogging')
}
}
}
for (const [block, count] of Object.entries(counts)) {
context.expect(count > 0, `Generated fixture must contain ${block}`, { count })
}
context.report.structures = { counts, hash: digest.digest('hex') }
})
}
}
@@ -0,0 +1,74 @@
import { readFile, stat } from 'node:fs/promises'
import { createHash } from 'node:crypto'
import path from 'node:path'
export default {
name: 'iris-studio-open',
description: 'Measure repeated Studio entry and verify visible terrain, spectator mode, and world cleanup.',
async run(context) {
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
context.expect(/^isolated=true\s*$/m.test(source), 'Studio entry requires an isolated instance')
const match = /^([a-z0-9_-]+):([1-4])$/.exec(context.options.command ?? 'overworld:2')
context.expect(match, 'Expected pack:cycles with one to four cycles')
context.report.studioOpen = []
for (let cycle = 0; cycle < Number(match[2]); cycle++) {
await context.step(`open and close Studio ${cycle + 1}`, async () => {
const before = new Set((await context.observe()).worlds.map(world => world.id))
const start = Date.now()
let world
let center
await context.command(`/iris studio open ${match[1]} seed=1337`)
await context.waitUntil(async () => {
const observation = await context.observe()
const player = observation.players.find(entry => entry.username === context.bot.username)
world = observation.worlds.find(entry => entry.id === player?.world)
if (!world || before.has(world.id) || !/iris-[0-9a-f-]{36}$/.test(world.name)) return false
center = context.bot.entity.position.floored()
return world.loadedChunks > 0 && context.bot.blockAt(center) !== null && context.bot.game.gameMode === 'spectator'
}, { label: 'Studio arrival with terrain and spectator mode', timeoutMs: 180000, intervalMs: 100 })
const arrivalMs = Date.now() - start
context.expect(Number.isInteger(context.bot.game.minY) && Number.isInteger(context.bot.game.height),
'Studio dimension must report its vertical range')
await context.waitUntil(() => {
for (let x = -1; x <= 1; x++) {
for (let z = -1; z <= 1; z++) {
for (let y = context.bot.game.minY; y < context.bot.game.minY + context.bot.game.height; y += 16) {
if (context.bot.blockAt(center.offset(x, y - center.y, z)) === null) return false
}
}
}
return true
}, { label: 'Complete neighboring Studio columns', timeoutMs: 60000, intervalMs: 100 })
const digest = createHash('sha256')
let solid = 0
for (let x = -1; x <= 1; x++) {
for (let z = -1; z <= 1; z++) {
for (let y = context.bot.game.minY; y < context.bot.game.minY + context.bot.game.height; y++) {
const block = context.bot.blockAt(center.offset(x, y - center.y, z))
context.expect(block !== null, 'Studio entry column must be available')
digest.update(`${block.name}\n`)
if (!['air', 'cave_air', 'void_air', 'water', 'lava'].includes(block.name)) solid++
}
}
}
context.expect(solid > 0, 'Studio entry must contain solid terrain')
context.report.studioOpen.push({ cycle, arrivalMs, world: world.name, x: center.x, z: center.z,
terrainHash: digest.digest('hex'), solid })
const closed = await context.command('/iris studio close', /^Studio closed\.$|Studio close failed:/, 180000)
context.expect(closed === 'Studio closed.', closed)
await context.waitUntil(async () => !(await context.observe()).worlds.some(entry => entry.id === world.id),
{ label: 'Studio world unload', timeoutMs: 90000, intervalMs: 100 })
const transientName = /iris-[0-9a-f-]{36}$/.exec(world.name)[0]
const properties = await readFile(path.join(context.server.directory, 'server.properties'), 'utf8')
const level = /^level-name=(.+)$/m.exec(properties)?.[1].trim() ?? 'world'
const directory = path.join(context.server.directory, level, 'dimensions', 'iris', transientName)
await context.waitUntil(async () => {
try { await stat(directory); return false } catch (error) {
if (error.code === 'ENOENT') return true
throw error
}
}, { label: 'Studio directory cleanup', timeoutMs: 90000, intervalMs: 100 })
})
}
}
}
@@ -0,0 +1,98 @@
import { readFile } from 'node:fs/promises'
import path from 'node:path'
const worldName = 'lifecycle_probe'
const worldKey = `iris:${worldName}`
const storageName = `world_iris_${worldName}`
export default {
name: 'iris-world-lifecycle',
description: 'Create, enter, evacuate, reload, and remove an Iris world through the player commands.',
async run(context) {
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
context.expect(/^isolated=true\s*$/m.test(source), 'World lifecycle requires an isolated instance')
const wait = (predicate, label, timeoutMs = 180000) => context.waitUntil(predicate, {
label, timeoutMs, intervalMs: 250
})
const playerWorld = async () => {
const observation = await context.observe()
const player = observation.players.find(entry => entry.username === context.bot.username)
return observation.worlds.find(entry => entry.id === player?.world)?.name
}
const status = async () => {
const response = await context.command('/irislifecycle lifecycle-status', /IRIS_LIFECYCLE |NATIVE_FAILED/, 10000)
context.expect(!response.includes('NATIVE_FAILED'), response)
return response
}
const settledStatus = async expected => {
const response = await status()
return response.includes(expected) && response.includes('idle=true')
}
const assertEvacuated = async label => {
let destination
await wait(async () => {
destination = await playerWorld()
return Boolean(destination && destination !== storageName)
}, label, 30000)
context.expect(destination && destination !== storageName, label, { destination })
}
const assertSafeEntry = async () => {
await wait(async () => (await playerWorld()) === storageName, 'Player entered the lifecycle world')
await wait(() => {
const feet = context.bot.entity.position.floored()
const floor = context.bot.blockAt(feet.offset(0, -1, 0))
const body = context.bot.blockAt(feet)
const head = context.bot.blockAt(feet.offset(0, 1, 0))
return context.bot.entity.onGround && floor?.name === 'lime_wool'
&& body?.boundingBox === 'empty' && head?.boundingBox === 'empty'
}, 'Client sees solid terrain below unobstructed player entry', 30000)
const response = await context.command('/irislifecycle lifecycle-entry', /IRIS_LIFECYCLE_ENTRY|NATIVE_FAILED/, 10000)
context.expect(response.includes(`IRIS_LIFECYCLE_ENTRY world=${worldKey} seed=78264193 floor=lime_wool safe=true`), response)
}
await context.step('confirm disposable world is absent', async () => {
const response = await status()
context.expect(response.includes('loaded=false registered=false configured=false disk=false quarantines=0 cleanup=deleted idle=true'), response)
await context.command('/minecraft:gamemode survival @s')
await wait(() => context.bot.game.gameMode === 'survival', 'Survival mode', 10000)
})
await context.step('create through Iris and automatically enter safe terrain', async () => {
await context.command(`/iris create name=${worldName} type=native-terrain seed=78264193`)
await wait(async () => await settledStatus('loaded=true registered=true configured=true disk=true'), 'Created world registration and storage')
await assertSafeEntry()
})
await context.step('unload while the player is inside and verify evacuation', async () => {
context.expect((await playerWorld()) === storageName, 'Player must be inside the world before unload')
await context.command(`/iris unloadWorld ${worldKey}`)
await wait(async () => await settledStatus('loaded=false registered=true configured=true disk=true'), 'World unloaded with storage preserved')
await assertEvacuated('Unload evacuated the player to a loaded world')
})
await context.step('reload the saved world and enter it again', async () => {
await context.command(`/iris loadWorld ${worldName}`)
await wait(async () => await settledStatus('loaded=true registered=true configured=true disk=true'), 'World reloaded')
await context.command(`/iris tp ${worldKey}`)
await assertSafeEntry()
})
await context.step('remove the occupied world and verify disk cleanup', async () => {
await context.command(`/iris remove world=${worldName} delete=true`)
let finalStatus
await wait(async () => {
finalStatus = await status()
return finalStatus.includes('loaded=false registered=false configured=false disk=false')
&& /cleanup=(deleted|queued)\b/.test(finalStatus) && finalStatus.includes('idle=true')
}, 'Removal detached registrations and deleted or queued quarantine')
await assertEvacuated('Removal evacuated the player to a loaded world')
context.report.worldLifecycle = { world: worldKey, cleanup: /cleanup=(deleted|queued)\b/.exec(finalStatus)[1] }
})
if (process.env.IRIS_QA_PACK_URL) {
await context.step('download and overwrite the fixture pack through Iris', async () => {
const response = await context.command(
`/iris download link=${process.env.IRIS_QA_PACK_URL} overwrite=true`,
/Iris pack 'native-terrain' installed|Iris pack download failed\./,
120000
)
context.expect(response.includes("Iris pack 'native-terrain' installed"), response)
})
}
}
}
@@ -0,0 +1,126 @@
package art.arcane.iris.command;
import art.arcane.iris.pack.PackFingerprints;
import art.arcane.iris.Iris;
import art.arcane.iris.pack.PackValidationRegistry;
import art.arcane.iris.pack.PackValidationCache;
import art.arcane.iris.pack.PackValidationResult;
import art.arcane.iris.pack.PackValidator;
import art.arcane.iris.platform.bukkit.plugin.VolmitSender;
import org.junit.After;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.mockito.MockedStatic;
import java.io.File;
import java.nio.file.Files;
import java.util.List;
import java.lang.reflect.Method;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
public class CommandPackValidationPublicationTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@After
public void clearValidation() {
PackValidationRegistry.clear();
}
@Test
public void changedCachedPackCannotBePersistedAsFreshValidation() throws Exception {
File packs = temporaryFolder.newFolder("packs");
File root = new File(packs, "terrain");
Files.createDirectories(root.toPath());
File content = new File(root, "dimension.json");
Files.writeString(content.toPath(), "original");
File cacheFile = new File(temporaryFolder.getRoot(), "validation-cache.json");
Iris previousPlugin = Iris.instance;
Iris plugin = mock(Iris.class);
Iris.instance = plugin;
when(plugin.getDataFile("cache", "pack-validation.json")).thenReturn(cacheFile);
try (MockedStatic<Iris> iris = mockStatic(Iris.class);
MockedStatic<PackValidationCache> cache = mockStatic(PackValidationCache.class, CALLS_REAL_METHODS)) {
cache.when(PackValidationCache::contextFingerprint).thenReturn("context");
PackValidationResult valid = new PackValidationResult(root.getName(), List.of(), List.of(), 1L);
PackValidationRegistry.publish(valid);
PackValidationRegistry.publish(root.toPath(), valid,
PackFingerprints.computePackTreeFingerprint(root), "context");
Files.writeString(content.toPath(), "changed");
Method persist = CommandPack.class.getDeclaredMethod("persistValidationCache", File.class);
persist.setAccessible(true);
persist.invoke(new CommandPack(), packs);
assertFalse(cacheFile.exists());
Files.writeString(content.toPath(), "original");
persist.invoke(new CommandPack(), packs);
assertTrue(cacheFile.exists());
assertTrue(PackValidationCache.load(cacheFile.toPath(),
PackFingerprints.computePackContentSnapshot(packs).content(), "context",
List.of(root.getName())).orElseThrow().getFirst().isLoadable());
} finally {
Iris.instance = previousPlugin;
}
}
@Test
public void commandPublishesCanonicalRootValidation() throws Exception {
File root = temporaryFolder.newFolder("terrain");
PackValidationResult valid = new PackValidationResult(root.getName(), List.of(), List.of(), 1L);
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class)) {
validator.when(() -> PackValidator.validate(root)).thenReturn(valid);
assertSame(valid, new CommandPack().runValidate(mock(VolmitSender.class), root));
assertSame(valid, PackValidationRegistry.get(root.toPath()));
assertSame(valid, PackValidationRegistry.get(root.getName()));
}
}
@Test
public void mutationDuringValidationCannotRepublishStaleResult() throws Exception {
File root = temporaryFolder.newFolder("terrain");
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class);
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
validator.when(() -> PackValidator.validate(root)).thenAnswer(invocation -> {
try (PackValidationRegistry.RootMutation mutation = PackValidationRegistry.beginRootMutation(root.toPath())) {
return new PackValidationResult(root.getName(), List.of(), List.of(), 1L);
}
});
assertFalse(new CommandPack().runValidate(mock(VolmitSender.class), root).isLoadable());
assertNull(PackValidationRegistry.get(root.toPath()));
assertNull(PackValidationRegistry.get(root.getName()));
}
}
@Test
public void externalEditDuringValidationPublishesBlockedResult() throws Exception {
File root = temporaryFolder.newFolder("terrain");
File content = new File(root, "dimension.json");
Files.writeString(content.toPath(), "original");
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class);
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
validator.when(() -> PackValidator.validate(root)).thenAnswer(invocation -> {
Files.writeString(content.toPath(), "changed");
return new PackValidationResult(root.getName(), List.of(), List.of(), 1L);
});
assertFalse(new CommandPack().runValidate(mock(VolmitSender.class), root).isLoadable());
assertFalse(PackValidationRegistry.get(root.toPath()).isLoadable());
}
}
}
@@ -0,0 +1,46 @@
package art.arcane.iris.generation.runtime;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class GenerationRateWindowTest {
@Test
public void generationTimingsGoStaleOnceChunksStopGenerating() {
GenerationRateWindow window = new GenerationRateWindow();
window.sampleChunksPerSecond(0, 1_000L);
assertEquals(20D, window.sampleChunksPerSecond(20, 2_000L), 0D);
assertTrue(window.generationTimingsFresh(2_000L));
assertEquals(0D, window.sampleChunksPerSecond(20, 7_000L), 0D);
assertTrue(window.generationTimingsFresh(7_000L));
assertEquals(0D, window.sampleChunksPerSecond(20, 12_001L), 0D);
assertFalse(window.generationTimingsFresh(12_001L));
window.sampleChunksPerSecond(21, 13_001L);
assertTrue(window.generationTimingsFresh(13_001L));
}
@Test
public void anIdleWorldNeverPublishesTimings() {
GenerationRateWindow window = new GenerationRateWindow();
window.sampleChunksPerSecond(0, 1_000L);
window.sampleChunksPerSecond(0, 2_000L);
assertFalse(window.generationTimingsFresh(2_000L));
}
@Test
public void chunksGeneratedBeforeTheFirstSampleCountAsRecentGeneration() {
GenerationRateWindow window = new GenerationRateWindow();
assertEquals(0D, window.sampleChunksPerSecond(400, 5_000L), 0D);
assertTrue(window.generationTimingsFresh(5_000L));
assertFalse(window.generationTimingsFresh(5_000L + GenerationRateWindow.TIMING_IDLE_MILLIS + 1L));
}
}
@@ -49,6 +49,7 @@ public class IrisIntegrationServiceTest {
100L, 100L,
200L, 200L,
100L, 100L,
48L,
8D, 8D,
2_000L, 2_000L,
5_000L, 5_000L,
@@ -64,6 +65,8 @@ public class IrisIntegrationServiceTest {
IntegrationMetricSchema.IRIS_LOADED_CHUNKS, IntegrationMetricSchema.IRIS_LOADED_CHUNKS,
IntegrationMetricSchema.IRIS_CHUNKS_GENERATED_TOTAL, IntegrationMetricSchema.IRIS_CHUNKS_GENERATED_TOTAL,
IntegrationMetricSchema.IRIS_PREGEN_THROUGHPUT, IntegrationMetricSchema.IRIS_PREGEN_THROUGHPUT,
IntegrationMetricSchema.IRIS_PREGEN_QUEUE,
IntegrationMetricSchema.IRIS_PREGEN_REMAINING,
IntegrationMetricSchema.IRIS_GENERATION_TOTAL_MS, IntegrationMetricSchema.IRIS_GENERATION_TOTAL_MS,
"iris.unsupported" "iris.unsupported"
)); ));
@@ -73,6 +76,8 @@ public class IrisIntegrationServiceTest {
assertEquals(20D, value(samples, IntegrationMetricSchema.IRIS_LOADED_CHUNKS), 0D); assertEquals(20D, value(samples, IntegrationMetricSchema.IRIS_LOADED_CHUNKS), 0D);
assertEquals(50D, value(samples, IntegrationMetricSchema.IRIS_CHUNKS_GENERATED_TOTAL), 0D); assertEquals(50D, value(samples, IntegrationMetricSchema.IRIS_CHUNKS_GENERATED_TOTAL), 0D);
assertEquals(8D, value(samples, IntegrationMetricSchema.IRIS_PREGEN_THROUGHPUT), 0D); assertEquals(8D, value(samples, IntegrationMetricSchema.IRIS_PREGEN_THROUGHPUT), 0D);
assertEquals(48D, value(samples, IntegrationMetricSchema.IRIS_PREGEN_QUEUE), 0D);
assertEquals(100D, value(samples, IntegrationMetricSchema.IRIS_PREGEN_REMAINING), 0D);
assertEquals(4D, value(samples, IntegrationMetricSchema.IRIS_GENERATION_TOTAL_MS), 0D); assertEquals(4D, value(samples, IntegrationMetricSchema.IRIS_GENERATION_TOTAL_MS), 0D);
assertFalse(samples.get("iris.unsupported").available()); assertFalse(samples.get("iris.unsupported").available());
assertEquals("unsupported-key", samples.get("iris.unsupported").message()); assertEquals("unsupported-key", samples.get("iris.unsupported").message());
@@ -84,9 +89,12 @@ public class IrisIntegrationServiceTest {
assertEquals("minecraft:overworld", overworld.scopeId()); assertEquals("minecraft:overworld", overworld.scopeId());
assertEquals(1D, value(overworld.samples(), IntegrationMetricSchema.IRIS_PREGEN_ACTIVE), 0D); assertEquals(1D, value(overworld.samples(), IntegrationMetricSchema.IRIS_PREGEN_ACTIVE), 0D);
assertEquals(8D, value(overworld.samples(), IntegrationMetricSchema.IRIS_PREGEN_THROUGHPUT), 0D); assertEquals(8D, value(overworld.samples(), IntegrationMetricSchema.IRIS_PREGEN_THROUGHPUT), 0D);
assertEquals(48D, value(overworld.samples(), IntegrationMetricSchema.IRIS_PREGEN_QUEUE), 0D);
assertEquals(100D, value(overworld.samples(), IntegrationMetricSchema.IRIS_PREGEN_REMAINING), 0D);
assertEquals("minecraft:the_nether", nether.scopeId()); assertEquals("minecraft:the_nether", nether.scopeId());
assertEquals(0D, value(nether.samples(), IntegrationMetricSchema.IRIS_PREGEN_ACTIVE), 0D); assertEquals(0D, value(nether.samples(), IntegrationMetricSchema.IRIS_PREGEN_ACTIVE), 0D);
assertFalse(nether.samples().get(IntegrationMetricSchema.IRIS_PREGEN_QUEUE).available()); assertFalse(nether.samples().get(IntegrationMetricSchema.IRIS_PREGEN_QUEUE).available());
assertFalse(nether.samples().get(IntegrationMetricSchema.IRIS_PREGEN_REMAINING).available());
} }
@Test @Test
+1 -1
View File
@@ -56,7 +56,7 @@ apply from: file('../../gradle/volmlib-stamp.gradle')
Properties rootProperties = new Properties() Properties rootProperties = new Properties()
file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) } file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) }
String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.1.2-26.2')) String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.3.1-26.2'))
String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3')) String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3'))
String fabricLoaderVersion = providers.gradleProperty('fabricLoaderVersion').getOrElse(rootProperties.getProperty('fabricLoaderVersion', '0.19.5')) String fabricLoaderVersion = providers.gradleProperty('fabricLoaderVersion').getOrElse(rootProperties.getProperty('fabricLoaderVersion', '0.19.5'))
Closure<String> irisArtifactName = { String platform, String targetVersion -> Closure<String> irisArtifactName = { String platform, String targetVersion ->
+1 -1
View File
@@ -56,7 +56,7 @@ apply from: file('../../gradle/volmlib-stamp.gradle')
Properties rootProperties = new Properties() Properties rootProperties = new Properties()
file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) } file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) }
String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.1.2-26.2')) String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.3.1-26.2'))
String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3')) String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3'))
String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse(rootProperties.getProperty('forgeVersion', '26.3-66.0.8')) String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse(rootProperties.getProperty('forgeVersion', '26.3-66.0.8'))
Closure<String> loaderDisplayVersion = { String loaderVersion -> Closure<String> loaderDisplayVersion = { String loaderVersion ->
@@ -195,8 +195,6 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
if (!nativeGenerator.represents(level)) { if (!nativeGenerator.represents(level)) {
throw new IllegalArgumentException("NativeWorld does not use Iris generator '" + dimensionKey + "'"); throw new IllegalArgumentException("NativeWorld does not use Iris generator '" + dimensionKey + "'");
} }
requireGlobalStructureGeneration(
NativeModdedServer.forWorld(level).generateStructures(), dimensionKey);
Engine activeEngine = engineIfBound(); Engine activeEngine = engineIfBound();
Engine replacement = ModdedWorldEngines.prepareReplacement( Engine replacement = ModdedWorldEngines.prepareReplacement(
level, level,
@@ -242,6 +240,7 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
this.announced.set(false); this.announced.set(false);
resetRuntimeCaches(); resetRuntimeCaches();
this.engineBinding.complete(replacement); this.engineBinding.complete(replacement);
this.nativeGenerator.initializeStructureState(level);
// Bind time: a feature-order cycle in the new pack is reported here, once, and degrades to features-off. // Bind time: a feature-order cycle in the new pack is reported here, once, and degrades to features-off.
// Never waits on a build owned by another thread: this method owns the generator monitor and the build // Never waits on a build owned by another thread: this method owns the generator monitor and the build
// path can need it. // path can need it.
@@ -442,12 +441,14 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
} }
Engine current = engineIfBound(); Engine current = engineIfBound();
if (NativeDimensionRuntime.sameWorld(boundLevel, level) && current != null && current.getComplex() != null) { if (NativeDimensionRuntime.sameWorld(boundLevel, level) && current != null && current.getComplex() != null) {
nativeGenerator.initializeStructureState(level);
return; return;
} }
requireCompletedShutdown(engine); requireCompletedShutdown(engine);
unloading = false; unloading = false;
Engine bound = bindEngine(level); Engine bound = bindEngine(level);
nativeGenerator.installVolumeIndex(level, bound); nativeGenerator.installVolumeIndex(level, bound);
nativeGenerator.initializeStructureState(level);
// Bind time: a feature-order cycle is reported here, once, and degrades to features-off. Non-waiting for // Bind time: a feature-order cycle is reported here, once, and degrades to features-off. Non-waiting for
// the same reason as repointAndBind: this method owns the generator monitor. // the same reason as repointAndBind: this method owns the generator monitor.
importedFeatures.prepareWithoutWaiting(bound); importedFeatures.prepareWithoutWaiting(bound);
@@ -459,13 +460,6 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
// A recorded failure stands until repoint or unbind resets the binding; re-running the bind would repeat the // A recorded failure stands until repoint or unbind resets the binding; re-running the bind would repeat the
// history verification on every chunk call and fail the same way. // history verification on every chunk call and fail the same way.
engineBinding.throwIfFailed(dimensionKey); engineBinding.throwIfFailed(dimensionKey);
try {
requireGlobalStructureGeneration(
NativeModdedServer.forWorld(level).generateStructures(), dimensionKey);
} catch (RuntimeException error) {
recordBindFailure(level, error);
throw error;
}
// Cache the owning level so hot paths never scan the level map to find themselves. // Cache the owning level so hot paths never scan the level map to find themselves.
boundLevel = level; boundLevel = level;
Engine cached = engine; Engine cached = engine;
@@ -572,29 +566,6 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
} }
} }
static void requireGlobalStructureGeneration(boolean enabled, String dimensionKey) {
if (enabled) {
return;
}
String remedy = integratedEnvironment()
? "enable 'Generate Structures' for this world; Iris requires it, then deny families through "
+ "importedStructures.disabled or complete keys through importedStructures.disabledExact"
: "set generate-structures=true in server.properties, restart the server, "
+ "then deny families through importedStructures.disabled or complete keys through "
+ "importedStructures.disabledExact";
throw new IllegalStateException("Iris generator '" + dimensionKey
+ "' cannot bind while generate-structures=false; " + remedy);
}
private static boolean integratedEnvironment() {
try {
return ModdedEngineBootstrap.loader().clientEnvironment();
} catch (Throwable e) {
// No loader bound (unit tests, very early boot): assume dedicated wording.
return false;
}
}
/** /**
* Null only while no level uses this generator (world creation screens, codec validation). A bound level whose * Null only while no level uses this generator (world creation screens, codec validation). A bound level whose
* engine is refused, unloading or stopping throws, so no stage can mistake it for an unbound generator. * engine is refused, unloading or stopping throws, so no stage can mistake it for an unbound generator.
@@ -1097,6 +1068,12 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
return current.getDimensionStackContext() != null; return current.getDimensionStackContext() != null;
} }
@Override
public boolean terrainTransformed() {
Engine current = engine;
return current != null && current.getComplex().getTerrainTransform() != null;
}
@Override @Override
public int runtimeId(Engine current) { public int runtimeId(Engine current) {
return current.getCacheID(); return current.getCacheID();
@@ -325,7 +325,7 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
private H getNoiseBiome(Engine engine, int quartX, int quartY, int quartZ, private H getNoiseBiome(Engine engine, int quartX, int quartY, int quartZ,
S sampler) { S sampler) {
if (isGuaranteedSurfaceBiome(quartY, engine.getMinHeight())) { if (isGuaranteedSurfaceBiome(engine, quartY)) {
return getSurfaceStructureBiome(engine, quartX, quartZ, sampler); return getSurfaceStructureBiome(engine, quartX, quartZ, sampler);
} }
boolean cacheable = isBiomeCacheable( boolean cacheable = isBiomeCacheable(
@@ -512,7 +512,7 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
} }
boolean monumentQuery = isMonumentSurfaceBiomeQuery( boolean monumentQuery = isMonumentSurfaceBiomeQuery(
y, radius, engine.getMinHeight(), engine.getDimension().getFluidHeight()); y, radius, engine.getMinHeight(), engine.getDimension().getFluidHeight());
surfaceQuery = monumentQuery || isGuaranteedSurfaceBiome(minQuartY, engine.getMinHeight()); surfaceQuery = monumentQuery || isGuaranteedSurfaceBiome(engine, minQuartY);
} }
if (!surfaceQuery) { if (!surfaceQuery) {
return query.fallback().get(); return query.fallback().get();
@@ -701,6 +701,11 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
int blockZ = quartZ << 2; int blockZ = quartZ << 2;
int internalY = blockY - engine.getMinHeight(); int internalY = blockY - engine.getMinHeight();
int caveSwitchY = Math.max(-8 - engine.getMinHeight(), 40); int caveSwitchY = Math.max(-8 - engine.getMinHeight(), 40);
IrisBiome featureBiome = engine.getSubterrainBiome(blockX, internalY, blockZ);
if (featureBiome != null) {
return createBiomeResolution(engine, featureBiome, true, engine.getDimension(), engine.getData(),
blockX, blockY, blockZ);
}
DimensionStackLayout.Layer stackLayer = includeDimensionStack DimensionStackLayout.Layer stackLayer = includeDimensionStack
? stackLayout == null ? stackLayout == null
? resolveDimensionStackLayer(engine, blockX, internalY, blockZ) ? resolveDimensionStackLayer(engine, blockX, internalY, blockZ)
@@ -846,7 +851,13 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
return configuredBiomeKeys; return configuredBiomeKeys;
} }
static boolean isGuaranteedSurfaceBiome(int quartY, int minHeight) { static boolean isGuaranteedSurfaceBiome(Engine engine, int quartY) {
IrisDimension dimension = engine.getDimension();
if (dimension != null && dimension.allowsSubterrainFeatures()
&& !dimension.getSubterrainFeatures().isEmpty()) {
return false;
}
int minHeight = engine.getMinHeight();
int internalY = (quartY << 2) - minHeight; int internalY = (quartY << 2) - minHeight;
int caveSwitchY = Math.max(-8 - minHeight, 40); int caveSwitchY = Math.max(-8 - minHeight, 40);
return internalY > caveSwitchY; return internalY > caveSwitchY;
@@ -50,7 +50,7 @@ public final class ModdedDecoratorHooks implements DecoratorPlatformHooks.FaceFi
int xx = rX + dx; int xx = rX + dx;
int yy = y + dy; int yy = y + dy;
int zz = rZ + dz; int zz = rZ + dz;
return xx < 0 || xx > 15 || zz < 0 || zz > 15 || yy < 0 || yy > hunk.getHeight() return xx < 0 || xx > 15 || zz < 0 || zz > 15 || yy < 0 || yy >= hunk.getHeight()
? null : hunk.get(xx, yy, zz); ? null : hunk.get(xx, yy, zz);
} }
} }
@@ -70,6 +70,7 @@ import art.arcane.iris.modded.service.ModdedEngineMaintenanceService;
import art.arcane.iris.modded.service.ModdedEntitySpawnService; import art.arcane.iris.modded.service.ModdedEntitySpawnService;
import art.arcane.iris.modded.service.ModdedLogFilterService; import art.arcane.iris.modded.service.ModdedLogFilterService;
import art.arcane.iris.modded.service.ModdedPreservationService; import art.arcane.iris.modded.service.ModdedPreservationService;
import art.arcane.iris.modded.service.ModdedPackUpdateService;
import art.arcane.iris.modded.service.ModdedSettingsHotloadService; import art.arcane.iris.modded.service.ModdedSettingsHotloadService;
import art.arcane.iris.modded.service.ModdedStudioHotloadService; import art.arcane.iris.modded.service.ModdedStudioHotloadService;
import art.arcane.iris.modded.service.ModdedTreeFellerService; import art.arcane.iris.modded.service.ModdedTreeFellerService;
@@ -477,6 +478,7 @@ public final class ModdedEngineBootstrap {
createdServices.register(ModdedLogFilterService.class, new ModdedLogFilterService()); createdServices.register(ModdedLogFilterService.class, new ModdedLogFilterService());
createdServices.register(ModdedEngineMaintenanceService.class, new ModdedEngineMaintenanceService()); createdServices.register(ModdedEngineMaintenanceService.class, new ModdedEngineMaintenanceService());
createdServices.register(ModdedSettingsHotloadService.class, new ModdedSettingsHotloadService()); createdServices.register(ModdedSettingsHotloadService.class, new ModdedSettingsHotloadService());
createdServices.register(ModdedPackUpdateService.class, new ModdedPackUpdateService());
ModdedStudioHotloadService studioHotloadService = createdServices.register( ModdedStudioHotloadService studioHotloadService = createdServices.register(
ModdedStudioHotloadService.class, new ModdedStudioHotloadService()); ModdedStudioHotloadService.class, new ModdedStudioHotloadService());
createdServices.register(ModdedChunkUpdateService.class, new ModdedChunkUpdateService()); createdServices.register(ModdedChunkUpdateService.class, new ModdedChunkUpdateService());
@@ -61,11 +61,12 @@ public final class ModdedEntitySpawner {
} }
double x = blockX + 0.5; double x = blockX + 0.5;
double y = blockY + 0.5; double verticalOffset = irisEntity.getSurface().isFluid() ? 0.5 : 0;
double y = blockY + verticalOffset;
double z = blockZ + 0.5; double z = blockZ + 0.5;
boolean riseEffectActive = irisEntity.isSpawnEffectRiseOutOfGround() && level.hasPlayersNearby(new NativeEntityRuntime.Position(x, y, z), PLAYER_EFFECT_RADIUS); boolean riseEffectActive = irisEntity.isSpawnEffectRiseOutOfGround() && level.hasPlayersNearby(new NativeEntityRuntime.Position(x, y, z), PLAYER_EFFECT_RADIUS);
int spawnBlockY = riseEffectActive ? blockY - 5 : blockY; int spawnBlockY = riseEffectActive ? blockY - 5 : blockY;
double spawnY = spawnBlockY + 0.5; double spawnY = spawnBlockY + verticalOffset;
NativeSpawnedEntity created = create(irisEntity, level, x, spawnY, z); NativeSpawnedEntity created = create(irisEntity, level, x, spawnY, z);
if (created == null) { if (created == null) {
@@ -98,8 +99,7 @@ public final class ModdedEntitySpawner {
String customName = irisEntity.getCustomName(); String customName = irisEntity.getCustomName();
entity.applyBase(irisEntity, customName == null ? null : colorize(customName)); entity.applyBase(irisEntity, customName == null ? null : colorize(customName));
boolean persistent = irisEntity.isKeepEntity() || forcePersist(); applyPersistence(irisEntity, entity, forcePersist());
applyPersistence(entity, persistent);
applyPassengers(engine, irisEntity, entity, level, blockX, blockY, blockZ, rng); applyPassengers(engine, irisEntity, entity, level, blockX, blockY, blockZ, rng);
@@ -273,8 +273,8 @@ public final class ModdedEntitySpawner {
return IrisSettings.get().getWorld().isForcePersistEntities(); return IrisSettings.get().getWorld().isForcePersistEntities();
} }
private static void applyPersistence(NativeSpawnedEntity entity, boolean persistent) { static void applyPersistence(IrisEntity options, NativeSpawnedEntity entity, boolean forcePersistence) {
if (!entity.persistence(persistent)) { if (!entity.persistence(options.isKeepEntity() || forcePersistence)) {
String type = entity.typeKey(); String type = entity.typeKey();
if (WARNED_PERSISTENCE_TYPES.add(type)) { if (WARNED_PERSISTENCE_TYPES.add(type)) {
IrisLogging.warn("Iris entity: vanilla cannot persist non-serializable entity type '" + type + "'"); IrisLogging.warn("Iris entity: vanilla cannot persist non-serializable entity type '" + type + "'");
@@ -27,7 +27,6 @@ import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
/** /**
* Owns the Iris generation pool and the single decision of whether this loader already runs chunk * Owns the Iris generation pool and the single decision of whether this loader already runs chunk
@@ -43,7 +42,7 @@ public final class ModdedGenPool {
private static final long SHUTDOWN_DRAIN_MILLIS = 2_000L; private static final long SHUTDOWN_DRAIN_MILLIS = 2_000L;
private static final AtomicInteger GEN_THREAD_SEQ = new AtomicInteger(); private static final AtomicInteger GEN_THREAD_SEQ = new AtomicInteger();
private static final ChunkSystem CHUNK_SYSTEM = detectChunkSystem(); private static final ChunkSystem CHUNK_SYSTEM = detectChunkSystem();
private static final AtomicReference<ExecutorService> GEN_POOL = new AtomicReference<>(createGenPool()); private static ExecutorService generationPool = createGenPool();
private ModdedGenPool() { private ModdedGenPool() {
} }
@@ -63,39 +62,32 @@ public final class ModdedGenPool {
return CHUNK_SYSTEM.description(); return CHUNK_SYSTEM.description();
} }
static ExecutorService pool() { static synchronized ExecutorService pool() {
ExecutorService pool = GEN_POOL.get(); if (generationPool.isShutdown()) {
if (pool != null && !pool.isShutdown()) {
return pool;
}
start();
ExecutorService restarted = GEN_POOL.get();
if (restarted == null) {
throw new RejectedExecutionException("Iris gen pool is shut down"); throw new RejectedExecutionException("Iris gen pool is shut down");
} }
return restarted; return generationPool;
} }
static void start() { static synchronized void start() {
while (true) { if (!generationPool.isShutdown()) {
ExecutorService current = GEN_POOL.get();
if (current != null && !current.isShutdown()) {
return; return;
} }
ExecutorService created = createGenPool(); if (!generationPool.isTerminated()) {
if (GEN_POOL.compareAndSet(current, created)) { throw new IllegalStateException("Iris gen pool cannot restart while prior workers are still active");
return;
}
created.shutdownNow();
} }
generationPool = createGenPool();
} }
static void shutdown() { static void shutdown() {
ExecutorService pool = GEN_POOL.getAndSet(null); ExecutorService pool;
if (pool == null) { synchronized (ModdedGenPool.class) {
pool = generationPool;
if (pool.isTerminated()) {
return; return;
} }
pool.shutdown(); pool.shutdown();
}
try { try {
if (pool.awaitTermination(SHUTDOWN_DRAIN_MILLIS, TimeUnit.MILLISECONDS)) { if (pool.awaitTermination(SHUTDOWN_DRAIN_MILLIS, TimeUnit.MILLISECONDS)) {
return; return;
@@ -6,10 +6,9 @@ import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.configuration.IrisSettings; import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.pack.BrokenPackException; import art.arcane.iris.pack.BrokenPackException;
import art.arcane.iris.pack.PackValidationCache; import art.arcane.iris.pack.PackFingerprints;
import art.arcane.iris.pack.PackValidationRegistry; import art.arcane.iris.pack.PackValidationRegistry;
import art.arcane.iris.pack.PackValidationResult; import art.arcane.iris.pack.PackValidationResult;
import art.arcane.iris.pack.PackValidator;
import art.arcane.iris.world.history.GenerationEpoch; import art.arcane.iris.world.history.GenerationEpoch;
import art.arcane.iris.world.history.GenerationEpochContractFactory; import art.arcane.iris.world.history.GenerationEpochContractFactory;
import art.arcane.iris.world.history.GenerationHistory; import art.arcane.iris.world.history.GenerationHistory;
@@ -174,7 +173,7 @@ final class ModdedGenerationHistoryStorage {
static ActivePack resolveActive(GenerationHistory history) throws IOException { static ActivePack resolveActive(GenerationHistory history) throws IOException {
GenerationEpoch epoch = history.activeEpoch(); GenerationEpoch epoch = history.activeEpoch();
Path packRoot = normalize(history.activePackRoot()); Path packRoot = normalize(history.activePackRoot());
validatePack(packRoot, epoch.packFingerprint()); validatePack(packRoot);
validateDimension(packRoot, epoch); validateDimension(packRoot, epoch);
return new ActivePack( return new ActivePack(
packRoot, packRoot,
@@ -184,13 +183,16 @@ final class ModdedGenerationHistoryStorage {
); );
} }
private static void validatePack(Path packRoot, String fingerprint) { static PackValidationResult validatePack(Path packRoot) {
String contextFingerprint = PackValidationCache.contextFingerprint(); PackValidationResult validation = PackValidationRegistry.getMatching(
PackValidationResult validation = PackValidator.validate(packRoot.toFile()); packRoot, PackFingerprints.computePackTreeFingerprint(packRoot.toFile()));
PackValidationRegistry.publish(packRoot, validation, fingerprint, contextFingerprint); if (validation == null) {
validation = ModdedStartup.validatePack(packRoot.toFile());
}
if (!validation.isLoadable()) { if (!validation.isLoadable()) {
throw new BrokenPackException(packRoot.toString(), validation.getBlockingErrors()); throw new BrokenPackException(packRoot.toString(), validation.getBlockingErrors());
} }
return validation;
} }
private static void validateDimension( private static void validateDimension(
@@ -20,13 +20,11 @@ package art.arcane.iris.modded;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedLoader; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedLoader;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.configuration.IrisSettings; import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.diagnostics.splash.IrisSplashComposer; import art.arcane.iris.diagnostics.splash.IrisSplashComposer;
import art.arcane.iris.diagnostics.splash.IrisSplashRenderer; import art.arcane.iris.diagnostics.splash.IrisSplashRenderer;
import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisLogging;
import java.io.File;
public final class ModdedIrisSplash { public final class ModdedIrisSplash {
@@ -34,18 +32,11 @@ public final class ModdedIrisSplash {
} }
public static void print(NativeModdedLoader loader) { public static void print(NativeModdedLoader loader) {
printPacks(loader);
if (isLogoEnabled()) { if (isLogoEnabled()) {
printLogo(loader); printLogo(loader);
} }
} }
private static void printPacks(NativeModdedLoader loader) {
File packFolder = IrisPlatforms.get().packsFolderNoCreate();
IrisSplashComposer.composePackLines(packFolder, IrisLogging::reportError)
.thenAccept(lines -> lines.forEach(IrisLogging::info));
}
private static void printLogo(NativeModdedLoader loader) { private static void printLogo(NativeModdedLoader loader) {
String serverLine = loader.platformName() + " / Minecraft " + loader.minecraftVersion(); String serverLine = loader.platformName() + " / Minecraft " + loader.minecraftVersion();
String[] splash = IrisSplashRenderer.renderPlain(); String[] splash = IrisSplashRenderer.renderPlain();
@@ -45,6 +45,9 @@ public final class ModdedMixinAudit {
private static final AtomicBoolean AUDITED = new AtomicBoolean(false); private static final AtomicBoolean AUDITED = new AtomicBoolean(false);
static final List<ExpectedMixin> EXPECTED = List.of( static final List<ExpectedMixin> EXPECTED = List.of(
new ExpectedMixin(ENTITY_CONFIG, "RegistryClientNamesMixin",
NativeMixinTarget.REGISTRY_PACKET, "volmlib$clientRegistryNames",
false, NativeMixinFlags::registryClientNamesRan),
new ExpectedMixin(ENTITY_CONFIG, "EntityPersistenceMixin", new ExpectedMixin(ENTITY_CONFIG, "EntityPersistenceMixin",
NativeMixinTarget.ENTITY, "iris$applyGeneratedPersistence", NativeMixinTarget.ENTITY, "iris$applyGeneratedPersistence",
false, NativeMixinFlags::entityPersistenceRan), false, NativeMixinFlags::entityPersistenceRan),
@@ -108,7 +111,7 @@ public final class ModdedMixinAudit {
ModdedIrisLog.error(" missing: {}", entry); ModdedIrisLog.error(" missing: {}", entry);
} }
ModdedIrisLog.error("The mixin config was not registered for this loader (fabric.mod.json mixins, neoforge.mods.toml [[mixins]], forge MixinConfigs manifest attribute)."); ModdedIrisLog.error("The mixin config was not registered for this loader (fabric.mod.json mixins, neoforge.mods.toml [[mixins]], forge MixinConfigs manifest attribute).");
ModdedIrisLog.error("Entity persistence, custom mob loot, parallel structure safety, or Iris world-type labels are disabled until this is fixed."); ModdedIrisLog.error("Entity persistence, custom mob loot, parallel structure safety, readable biome identifiers, or Iris world-type labels are disabled until this is fixed.");
ModdedIrisLog.error("==============================================================="); ModdedIrisLog.error("===============================================================");
} }
@@ -122,17 +122,6 @@ final class ModdedNativeStructurePolicy implements NativeModdedStructureStage.Po
return ((IrisMaterialPalette) source).get(rng, x, y, z, generator.engine().getData()); return ((IrisMaterialPalette) source).get(rng, x, y, z, generator.engine().getData());
} }
@Override
public RuntimeException structuresDisabled(int chunkX, int chunkZ) {
return new IllegalStateException("Iris cannot generate native structures in chunk "
+ chunkX + "," + chunkZ
+ " because generate-structures=false disables them outside the pack. That flag is fixed when "
+ "the world is created (server.properties generate-structures, or the Generate Structures "
+ "toggle in singleplayer), so it cannot be changed for this world: create a new world with "
+ "structures enabled, then deny families through importedStructures.disabled or complete keys "
+ "through importedStructures.disabledExact");
}
@Override @Override
public String generationLabel(String structureId) { public String generationLabel(String structureId) {
return "Iris native structure " + structureId; return "Iris native structure " + structureId;
@@ -27,16 +27,15 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
public final class ModdedPrimaryWorldRouter { public final class ModdedPrimaryWorldRouter {
private static final int TICK_INTERVAL = 20; private static final int TICK_INTERVAL = 20;
private static final Set<UUID> routed = ConcurrentHashMap.newKeySet(); private static final ConcurrentMap<UUID, CompletableFuture<Boolean>> routes = new ConcurrentHashMap<>();
private static final Set<UUID> inFlight = ConcurrentHashMap.newKeySet();
private static int tickCounter = 0; private static int tickCounter = 0;
private static volatile String loginRefusal; private static volatile String loginRefusal;
private static volatile String absentPrimary; private static volatile String absentPrimary;
@@ -45,8 +44,12 @@ public final class ModdedPrimaryWorldRouter {
} }
public static void clear() { public static void clear() {
routed.clear(); routes.forEach((id, route) -> {
inFlight.clear(); if (routes.remove(id, route)) {
route.cancel(false);
}
});
tickCounter = 0;
loginRefusal = null; loginRefusal = null;
absentPrimary = null; absentPrimary = null;
} }
@@ -57,8 +60,10 @@ public final class ModdedPrimaryWorldRouter {
*/ */
public static void forget(UUID player) { public static void forget(UUID player) {
if (player != null) { if (player != null) {
routed.remove(player); CompletableFuture<Boolean> route = routes.remove(player);
inFlight.remove(player); if (route != null) {
route.cancel(false);
}
} }
} }
@@ -110,36 +115,52 @@ public final class ModdedPrimaryWorldRouter {
return; return;
} }
server.forEachPlayer(player -> { server.forEachPlayer(player -> routePlayer(server, player, overworld, target, primary));
}
private static void routePlayer(NativeModdedServer server, NativeProtocolPlayer player,
NativeWorld overworld, NativeWorld target, String primary) {
UUID id = player.id(); UUID id = player.id();
if (routed.contains(id) || !inFlight.add(id)) { if (routes.containsKey(id)) {
return; return;
} }
CompletableFuture<Boolean> route = new CompletableFuture<>();
if (routes.putIfAbsent(id, route) != null) {
return;
}
route.whenComplete((success, failure) -> {
if (!Boolean.TRUE.equals(success)) {
routes.remove(id, route);
}
});
if (!player.isInWorld(overworld)) { if (!player.isInWorld(overworld)) {
inFlight.remove(id); route.complete(true);
routed.add(id);
return; return;
} }
try { try {
CompletableFuture<Boolean> teleport = NativeWorldTeleport.teleport(player, CompletableFuture<Boolean> teleport = NativeWorldTeleport.teleport(player,
new NativeWorldTeleport.Destination(server, target, player.x(), Double.MIN_VALUE, player.z(), new NativeWorldTeleport.Destination(server, target, player.x(), Double.MIN_VALUE, player.z(),
ModdedTeleportDeadline.fromNow())); ModdedTeleportDeadline.fromNow()));
teleport.whenComplete((success, failure) -> { route.whenComplete((success, failure) -> {
inFlight.remove(id); if (!Boolean.TRUE.equals(success) && !teleport.isDone()) {
if (Boolean.TRUE.equals(success) && failure == null) { teleport.cancel(false);
routed.add(id);
return;
} }
});
teleport.whenComplete((success, failure) -> {
if (failure != null) { if (failure != null) {
if (route.completeExceptionally(failure)) {
ModdedIrisLog.error("Iris failed to route player {} to primary world '{}'", ModdedIrisLog.error("Iris failed to route player {} to primary world '{}'",
id, primary, failure); id, primary, failure);
} }
}); } else {
} catch (Throwable e) { route.complete(Boolean.TRUE.equals(success));
inFlight.remove(id);
ModdedIrisLog.error("Iris failed to route player {} to primary world '{}'", id, primary, e);
} }
}); });
} catch (Throwable failure) {
if (route.completeExceptionally(failure)) {
ModdedIrisLog.error("Iris failed to route player {} to primary world '{}'", id, primary, failure);
}
}
} }
/** /**
@@ -31,6 +31,7 @@ import art.arcane.iris.platform.protocol.IrisVisionRequestService;
import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.spi.IrisServices; import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.spi.protocol.IrisProtocol; import art.arcane.iris.spi.protocol.IrisProtocol;
import art.arcane.iris.modded.service.ModdedPackUpdateService;
import java.util.Objects; import java.util.Objects;
import java.util.UUID; import java.util.UUID;
@@ -128,6 +129,10 @@ public final class ModdedProtocolHandler {
} }
String sessionId = sessionId(player); String sessionId = sessionId(player);
ModdedStartup.warnStartupFailuresTo(player); ModdedStartup.warnStartupFailuresTo(player);
ModdedPackUpdateService packUpdates = ModdedEngineBootstrap.services().service(ModdedPackUpdateService.class);
if (packUpdates != null) {
packUpdates.notifyPlayer(player);
}
IrisSessionRegistry current = registry; IrisSessionRegistry current = registry;
ModdedProtocolTransport currentTransport = transport; ModdedProtocolTransport currentTransport = transport;
if (current == null || currentTransport == null) { if (current == null || currentTransport == null) {
@@ -18,10 +18,13 @@
package art.arcane.iris.modded; package art.arcane.iris.modded;
import art.arcane.iris.pack.PackFingerprints;
import art.arcane.iris.pack.BrokenPackException; import art.arcane.iris.pack.BrokenPackException;
import art.arcane.iris.pack.PackDirectoryResolver; import art.arcane.iris.pack.PackDirectoryResolver;
import art.arcane.iris.pack.PackDownloader; import art.arcane.iris.pack.PackDownloader;
import art.arcane.iris.pack.PackValidationRegistry; import art.arcane.iris.pack.PackValidationRegistry;
import art.arcane.iris.pack.PackValidationCache;
import art.arcane.iris.pack.PackValidationResult; import art.arcane.iris.pack.PackValidationResult;
import art.arcane.iris.pack.PackValidator; import art.arcane.iris.pack.PackValidator;
import art.arcane.iris.modded.command.ModdedPackCommands; import art.arcane.iris.modded.command.ModdedPackCommands;
@@ -31,15 +34,11 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import java.io.File; import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
public final class ModdedStartup { public final class ModdedStartup {
private static final int COMPAT_BOOT_KEY_CAP = 3; private static final int COMPAT_BOOT_KEY_CAP = 3;
@@ -60,36 +59,9 @@ public final class ModdedStartup {
if (!PREPARED.compareAndSet(false, true)) { if (!PREPARED.compareAndSet(false, true)) {
return; return;
} }
reportLegacyPacksDirectory();
validateAllPacks(); validateAllPacks();
} }
/**
* Older builds mkdir'd (and stale guidance sometimes populated) config/iris/packs, but modded
* packs live under config/irisworldgen/packs. Never auto-move user content: warn loudly when
* the legacy directory holds packs, and quietly remove it when it is empty.
*/
private static void reportLegacyPacksDirectory() {
try {
File legacy = ModdedEngineBootstrap.loader().configDir().resolve("iris").resolve("packs").toFile();
if (!legacy.isDirectory()) {
return;
}
if (!PackDirectoryResolver.listVisiblePackDirectories(legacy).isEmpty()) {
File real = art.arcane.iris.spi.IrisPlatforms.get().packsFolderNoCreate();
ModdedIrisLog.warn("Iris found packs under the legacy directory {} - modded packs load from {} only. Move them there.",
legacy.getAbsolutePath(), real.getAbsolutePath());
return;
}
String[] entries = legacy.list();
if (entries == null || entries.length == 0) {
legacy.delete();
}
} catch (Throwable e) {
ModdedIrisLog.debug("Iris legacy packs directory check failed", e);
}
}
/** /**
* Boot trigger for the forced datapack. Runs on its own daemon thread rather than the Iris scheduler: * Boot trigger for the forced datapack. Runs on its own daemon thread rather than the Iris scheduler:
* ModdedEngineBootstrap.start clears the async queue at SERVER_STARTING, which would silently drop this * ModdedEngineBootstrap.start clears the async queue at SERVER_STARTING, which would silently drop this
@@ -140,8 +112,7 @@ public final class ModdedStartup {
} }
for (File packDir : packDirs) { for (File packDir : packDirs) {
try { try {
PackValidationResult result = PackValidator.validate(packDir); PackValidationResult result = validatePack(packDir);
PackValidationRegistry.publish(result);
String minecraftVersion = IrisPlatforms.isBound() ? IrisPlatforms.get().minecraftVersion() : null; String minecraftVersion = IrisPlatforms.isBound() ? IrisPlatforms.get().minecraftVersion() : null;
String compatSummary = PackValidator.compatSummary(result, minecraftVersion); String compatSummary = PackValidator.compatSummary(result, minecraftVersion);
String compatSuffix = compatSummary.isEmpty() ? "" : " " + compatSummary; String compatSuffix = compatSummary.isEmpty() ? "" : " " + compatSummary;
@@ -184,16 +155,15 @@ public final class ModdedStartup {
throw new BrokenPackException(pack, List.of( throw new BrokenPackException(pack, List.of(
"Pack folder does not exist under " + ModdedPackCommands.packsRoot().getAbsolutePath() + ".")); "Pack folder does not exist under " + ModdedPackCommands.packsRoot().getAbsolutePath() + "."));
} }
PackValidationResult cached = PackValidationRegistry.get(pack); Path packRoot = packDir.toPath();
if (cached != null && cached.getValidatedAtMillis() >= newestModificationMillis(packDir.toPath())) {
// prepareForStartup already validated this pack and nothing in it changed since; re-validating per
// persistent dimension at boot costs a full pack parse each time.
return PackValidationRegistry.requireLoadable(pack);
}
try { try {
PackValidationResult result = PackValidator.validate(packDir); PackValidationResult cached = PackValidationRegistry.getMatching(
PackValidationRegistry.publish(result); packRoot, PackFingerprints.computePackTreeFingerprint(packDir));
return PackValidationRegistry.requireLoadable(pack); if (cached != null) {
return PackValidationRegistry.requireLoadable(packRoot);
}
validatePack(packDir);
return PackValidationRegistry.requireLoadable(packRoot);
} catch (BrokenPackException e) { } catch (BrokenPackException e) {
throw e; throw e;
} catch (Throwable e) { } catch (Throwable e) {
@@ -212,6 +182,42 @@ public final class ModdedStartup {
} }
} }
public static PackValidationResult validatePack(File packDir) {
Path packRoot = packDir.toPath();
PackValidationRegistry.ValidationTicket ticket = PackValidationRegistry.tryBeginValidation(packRoot);
if (ticket == null) {
throw new BrokenPackException(packDir.getName(), List.of("Pack content is currently being changed."));
}
String contentFingerprint = PackFingerprints.computePackTreeFingerprint(packDir);
String contextFingerprint = PackValidationCache.contextFingerprint();
PackValidationResult result;
try {
result = PackValidator.validate(packDir);
} catch (Throwable failure) {
ModdedIrisLog.error("Iris pack validation failed for '{}'", packDir.getName(), failure);
String detail = failure.getMessage();
if (detail == null || detail.isBlank()) {
detail = failure.getClass().getSimpleName();
}
result = new PackValidationResult(
packDir.getName(),
List.of("Pack validation failed with " + failure.getClass().getSimpleName() + ": " + detail),
List.of(),
System.currentTimeMillis());
}
if (!contentFingerprint.equals(PackFingerprints.computePackTreeFingerprint(packDir))
|| !contextFingerprint.equals(PackValidationCache.contextFingerprint())) {
PackValidationRegistry.remove(packRoot);
PackValidationRegistry.remove(packDir.getName());
throw new BrokenPackException(packDir.getName(), List.of("Pack content or validation context changed during validation."));
}
if (!PackValidationRegistry.publishIfCurrent(ticket, result, contentFingerprint, contextFingerprint)) {
throw new BrokenPackException(packDir.getName(), List.of("Pack content changed during validation."));
}
PackValidationRegistry.publish(result);
return result;
}
static void reinjectPersistentDimensions(NativeModdedServer server) { static void reinjectPersistentDimensions(NativeModdedServer server) {
List<ModdedDimensionRegistryStore.PersistentDimension> dimensions = List<ModdedDimensionRegistryStore.PersistentDimension> dimensions =
ModdedDimensionRegistryStore.loadForStartup(server); ModdedDimensionRegistryStore.loadForStartup(server);
@@ -266,23 +272,6 @@ public final class ModdedStartup {
+ GenerationRefusalNotice.summary(failure)); + GenerationRefusalNotice.summary(failure));
} }
private static long newestModificationMillis(Path root) {
long newest = 0L;
try (Stream<Path> walk = Files.walk(root)) {
for (Path path : (Iterable<Path>) walk::iterator) {
BasicFileAttributes attributes = Files.readAttributes(path, BasicFileAttributes.class);
long modified = attributes.lastModifiedTime().toMillis();
if (modified > newest) {
newest = modified;
}
}
} catch (IOException | RuntimeException unreadable) {
ModdedIrisLog.debug("Iris could not stat {} for validation reuse; revalidating", root, unreadable);
return Long.MAX_VALUE;
}
return newest;
}
/** /**
* True once the first tick with a player list has re-injected the persistent dimensions. Before that a runtime * True once the first tick with a player list has re-injected the persistent dimensions. Before that a runtime
* dimension that is not loaded yet is still on its way. * dimension that is not loaded yet is still on its way.
@@ -296,7 +285,7 @@ public final class ModdedStartup {
* the console. Tell the operators who can actually act on it when they join. * the console. Tell the operators who can actually act on it when they join.
*/ */
public static void warnStartupFailuresTo(NativeProtocolPlayer player) { public static void warnStartupFailuresTo(NativeProtocolPlayer player) {
if (player == null || !player.isGameMaster()) { if (player == null || (!player.isGameMaster() && !player.isServerOwner())) {
return; return;
} }
for (String failure : DIMENSION_FAILURES) { for (String failure : DIMENSION_FAILURES) {
@@ -44,6 +44,8 @@ import art.arcane.iris.pack.value.IrisPosition;
import art.arcane.iris.pack.value.IrisRange; import art.arcane.iris.pack.value.IrisRange;
import art.arcane.iris.world.entity.IrisSpawnGroup; import art.arcane.iris.world.entity.IrisSpawnGroup;
import art.arcane.iris.world.entity.IrisSpawner; import art.arcane.iris.world.entity.IrisSpawner;
import art.arcane.iris.world.entity.EntitySpawnSeed;
import art.arcane.iris.world.entity.IrisEntitySpawn.SpawnContext;
import art.arcane.iris.generation.decoration.IrisSurface; import art.arcane.iris.generation.decoration.IrisSurface;
import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisLogging;
import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KList;
@@ -55,11 +57,14 @@ import art.arcane.volmlib.util.matter.Matter;
import art.arcane.volmlib.util.matter.MatterMarker; import art.arcane.volmlib.util.matter.MatterMarker;
import art.arcane.volmlib.util.matter.slices.MarkerMatter; import art.arcane.volmlib.util.matter.slices.MarkerMatter;
import java.util.Map;
import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.Comparator;
import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue; import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
@@ -234,6 +239,10 @@ public final class ModdedWorldManager implements EngineWorldManager {
} }
boolean initialSpawnChunk(NativeWorld level, int chunkX, int chunkZ) { boolean initialSpawnChunk(NativeWorld level, int chunkX, int chunkZ) {
if (!isEntitySpawningEnabledForCurrentWorld()
|| !NativeSpawnQueries.ambientAllowed(level, chunkX, chunkZ, true)) {
return false;
}
if (!ModdedEntitySpawner.chunksSafe(entityRuntime(level), chunkX, chunkZ)) { if (!ModdedEntitySpawner.chunksSafe(entityRuntime(level), chunkX, chunkZ)) {
return false; return false;
} }
@@ -248,54 +257,25 @@ public final class ModdedWorldManager implements EngineWorldManager {
MantleChunk<Matter> chunk = mantle.useChunk(chunkX, chunkZ); MantleChunk<Matter> chunk = mantle.useChunk(chunkX, chunkZ);
try { try {
List<PreparedMarkerSpawn> markers = markerSystemEnabled() List<PreparedMarkerSpawn> markers = markerSystemEnabled()
? prepareMarkerSpawns(level, chunkX, chunkZ, chunk) : List.of(); ? prepareMarkerSpawns(level, chunkX, chunkZ, chunk, true) : List.of();
Optional<BiomeEnvironment> environment = ambientSystemEnabled() Optional<BiomeEnvironment> environment = ambientSystemEnabled()
? resolveSurfaceEnvironment(chunkX, chunkZ) : Optional.empty(); ? resolveSurfaceEnvironment(chunkX, chunkZ) : Optional.empty();
chunk.raiseFlagUnchecked(INITIAL_SPAWN_COMPLETION_FLAG, () -> { if (ambientSystemEnabled() && engine.getComplex() == null) {
return false;
}
spawnPreparedMarkers(level, markers, true); spawnPreparedMarkers(level, markers, true);
if (environment.isPresent()) { if (environment.isPresent()) {
scheduleInitialFollowUp(level, chunkX, chunkZ, environment.get()); try (BiomeEnvironment.Scope ignored = engine.openBiomeEnvironmentScope(environment.get())) {
spawnAmbient(level, chunkX, chunkZ, true, environment.get());
} }
}); }
chunk.raiseFlagUnchecked(INITIAL_SPAWN_COMPLETION_FLAG, () -> {});
} finally { } finally {
chunk.release(); chunk.release();
} }
return true; return true;
} }
private void scheduleInitialFollowUp(NativeWorld level, int chunkX, int chunkZ, BiomeEnvironment environment) {
ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull();
if (scheduler == null) {
IrisLogging.error("Iris could not schedule the initial entity-spawn follow-up because the modded scheduler is unavailable.");
return;
}
scheduler.laterGlobal(
() -> EngineLifecycleTasks.run(
engine,
"modded_world_manager_initial_spawn_followup",
() -> runInitialFollowUp(level, chunkX, chunkZ, environment)),
RNG.r.i(5, 200));
}
private void runInitialFollowUp(NativeWorld level, int chunkX, int chunkZ, BiomeEnvironment environment) {
Mantle<Matter> mantle = engine.getMantle().getMantle();
if (closed || engine.isClosed() || mantle.isClosed() || !isEntitySpawningEnabledForCurrentWorld()) {
return;
}
if (!level.isChunkLoaded(chunkX, chunkZ) || !mantle.isChunkLoaded(chunkX, chunkZ)) {
return;
}
if (engine.getComplex() == null) {
return;
}
if (ambientSystemEnabled()) {
try (BiomeEnvironment.Scope ignored = engine.openBiomeEnvironmentScope(environment)) {
spawnAmbient(level, chunkX, chunkZ, true, environment);
}
}
}
private void warmupMantleChunkAsync(long key, int chunkX, int chunkZ) { private void warmupMantleChunkAsync(long key, int chunkX, int chunkZ) {
if (closed || !mantleWarmups.add(key)) { if (closed || !mantleWarmups.add(key)) {
return; return;
@@ -379,11 +359,14 @@ public final class ModdedWorldManager implements EngineWorldManager {
if (!mantle.isChunkLoaded(chunkX, chunkZ)) { if (!mantle.isChunkLoaded(chunkX, chunkZ)) {
return; return;
} }
if (!mantle.hasFlag(chunkX, chunkZ, INITIAL_SPAWN_COMPLETION_FLAG)) {
return;
}
MantleChunk<Matter> chunk = mantle.useChunk(chunkX, chunkZ); MantleChunk<Matter> chunk = mantle.useChunk(chunkX, chunkZ);
try { try {
if (markerSystemEnabled()) { if (markerSystemEnabled()) {
spawnPreparedMarkers(level, prepareMarkerSpawns(level, chunkX, chunkZ, chunk), false); spawnPreparedMarkers(level, prepareMarkerSpawns(level, chunkX, chunkZ, chunk, false), false);
} }
if (ambientSystemEnabled()) { if (ambientSystemEnabled()) {
spawnAmbient(level, chunkX, chunkZ, false); spawnAmbient(level, chunkX, chunkZ, false);
@@ -393,7 +376,7 @@ public final class ModdedWorldManager implements EngineWorldManager {
} }
} }
private List<PreparedMarkerSpawn> prepareMarkerSpawns(NativeWorld level, int chunkX, int chunkZ, MantleChunk<Matter> chunk) { private List<PreparedMarkerSpawn> prepareMarkerSpawns(NativeWorld level, int chunkX, int chunkZ, MantleChunk<Matter> chunk, boolean initial) {
int minHeight = engine.getWorld().minHeight(); int minHeight = engine.getWorld().minHeight();
KList<IrisPosition> obstructed = new KList<>(); KList<IrisPosition> obstructed = new KList<>();
List<PreparedMarkerSpawn> prepared = new ArrayList<>(); List<PreparedMarkerSpawn> prepared = new ArrayList<>();
@@ -428,62 +411,71 @@ public final class ModdedWorldManager implements EngineWorldManager {
if (spawners.isEmpty()) { if (spawners.isEmpty()) {
return; return;
} }
IrisSpawner chosen = spawners.getRandom(); if (initial) {
spawners.sort(Comparator.comparing(IrisSpawner::getLoadKey, Comparator.nullsFirst(Comparator.naturalOrder())));
}
RNG markerRng = initial ? EntitySpawnSeed.marker(engine.getSeedManager().getEntity(), worldX, worldY, worldZ)
: new RNG(RNG.r.nextLong());
IrisSpawner chosen = spawners.getRandom(markerRng);
if (chosen == null) { if (chosen == null) {
return; return;
} }
prepared.add(new PreparedMarkerSpawn(new IrisPosition(worldX, worldY, worldZ), chosen, environment)); prepared.add(new PreparedMarkerSpawn(new IrisPosition(worldX, worldY, worldZ), chosen, resolved, markerRng, environment));
}); });
Mantle<Matter> mantle = engine.getMantle().getMantle(); Mantle<Matter> mantle = engine.getMantle().getMantle();
for (IrisPosition position : obstructed) { for (IrisPosition position : obstructed) {
mantle.remove(position.getX(), position.getY(), position.getZ(), MatterMarker.class); mantle.remove(position.getX(), position.getY(), position.getZ(), MatterMarker.class);
} }
if (initial) {
prepared.sort(Comparator.comparingInt((PreparedMarkerSpawn marker) -> marker.position().getX())
.thenComparingInt(marker -> marker.position().getY())
.thenComparingInt(marker -> marker.position().getZ()));
}
return List.copyOf(prepared); return List.copyOf(prepared);
} }
private KList<IrisSpawner> resolveMarkerSpawners(IrisMarker marker, BiomeEnvironment environment) { private KList<IrisSpawner> resolveMarkerSpawners(IrisMarker marker, BiomeEnvironment environment) {
KList<IrisSpawner> spawners = new KList<>(); KList<IrisSpawner> spawners = new KList<>();
Set<IrisSpawner> seen = new HashSet<>();
for (String key : marker.getSpawners()) { for (String key : marker.getSpawners()) {
IrisSpawner spawner = environment.data().getSpawnerLoader().load(key); IrisSpawner spawner = environment.data().getSpawnerLoader().load(key);
if (spawner == null) { if (spawner == null) {
IrisLogging.error("Cannot load spawner: " + key + " for marker on " + engine.getName()); IrisLogging.error("Cannot load spawner: " + key + " for marker on " + engine.getName());
continue; continue;
} }
spawner.setReferenceMarker(marker); if (!spawner.isCompatExcluded() && seen.add(spawner)) {
spawners.add(spawner); spawners.add(spawner);
} }
}
return spawners; return spawners;
} }
private void spawnPreparedMarkers(NativeWorld level, List<PreparedMarkerSpawn> markers, boolean initial) { private void spawnPreparedMarkers(NativeWorld level, List<PreparedMarkerSpawn> markers, boolean initial) {
for (PreparedMarkerSpawn marker : markers) { for (PreparedMarkerSpawn marker : markers) {
try (BiomeEnvironment.Scope ignored = engine.openBiomeEnvironmentScope(marker.environment())) { try (BiomeEnvironment.Scope ignored = engine.openBiomeEnvironmentScope(marker.environment())) {
spawnFromSpawner(level, marker.position(), marker.spawner(), initial); spawnFromSpawner(level, marker.position(), marker.rng(), new SpawnContext(marker.spawner(), marker.marker(), initial));
} }
} }
} }
private void spawnFromSpawner(NativeWorld level, IrisPosition position, IrisSpawner spawner, boolean initial) { private void spawnFromSpawner(NativeWorld level, IrisPosition position, RNG rng, SpawnContext context) {
KList<IrisEntitySpawn> spawns = initial ? spawner.getInitialSpawns() : spawner.getSpawns(); IrisSpawner spawner = context.spawner();
KList<IrisEntitySpawn> spawns = context.initial() ? spawner.getInitialSpawns() : spawner.getSpawns();
if (spawns.isEmpty()) { if (spawns.isEmpty()) {
return; return;
} }
for (IrisEntitySpawn entry : spawns) { IrisEntitySpawn chosen = rarityPick(spawns, rng);
entry.setReferenceSpawner(spawner);
entry.setReferenceMarker(spawner.getReferenceMarker());
}
IrisEntitySpawn chosen = rarityPick(spawns);
if (chosen == null) { if (chosen == null) {
return; return;
} }
int chunkX = position.getX() >> 4; int chunkX = position.getX() >> 4;
int chunkZ = position.getZ() >> 4; int chunkZ = position.getZ() >> 4;
if (!canSpawn(spawner, chunkX, chunkZ)) { if (!canSpawn(spawner, chunkX, chunkZ, context.initial())) {
return; return;
} }
int spawned = spawnEntryAt(level, chosen, spawner, position); int spawned = spawnEntryAt(level, chosen, position, rng, context);
if (spawned > 0) { if (spawned > 0 && !context.initial()) {
spawner.spawn(engine, chunkX, chunkZ); spawner.spawn(engine, chunkX, chunkZ);
} }
} }
@@ -511,60 +503,177 @@ public final class ModdedWorldManager implements EngineWorldManager {
} }
private void spawnAmbient(NativeWorld level, int chunkX, int chunkZ, boolean initial, BiomeEnvironment environment) { private void spawnAmbient(NativeWorld level, int chunkX, int chunkZ, boolean initial, BiomeEnvironment environment) {
if (!NativeSpawnQueries.ambientAllowed(level, chunkX, chunkZ, initial)) {
return;
}
IrisComplex complex = engine.getComplex(); IrisComplex complex = engine.getComplex();
if (complex == null) { if (complex == null) {
return; return;
} }
IrisBiome biome = environment.biome(); IrisBiome biome = environment.biome();
int chunkMobs = countChunkLivingEntities(level, chunkX, chunkZ); Map<String, Integer> chunkMobs = new HashMap<>(initial ? Map.of()
: NativeSpawnQueries.livingEntityCategories(level, chunkX, chunkZ));
RNG rng = initial ? EntitySpawnSeed.chunk(engine.getSeedManager().getEntity(), chunkX, chunkZ)
: new RNG(RNG.r.nextLong());
KList<IrisEntitySpawn> pool = new KList<>(); KList<SpawnCandidate> pool = new KList<>();
collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.dimension().getEntitySpawners()), biome, chunkX, chunkZ, chunkMobs, initial); collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.dimension().getEntitySpawners()), biome, chunkX, chunkZ, chunkMobs, initial, false);
collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.region().getEntitySpawners()), null, chunkX, chunkZ, chunkMobs, initial); collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.region().getEntitySpawners()), null, chunkX, chunkZ, chunkMobs, initial, false);
collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(biome.getEntitySpawners()), null, chunkX, chunkZ, chunkMobs, initial); collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(biome.getEntitySpawners()), null, chunkX, chunkZ, chunkMobs, initial, false);
if (environment.dimension().hasUndergroundSpawners(engine)) {
spawnUnderground(level, chunkX, chunkZ, initial, chunkMobs, rng.nextParallelRNG(0x5A17D3B48269C0EFL));
}
if (pool.isEmpty()) { if (pool.isEmpty()) {
return; return;
} }
IrisEntitySpawn chosen = rarityPick(pool); SpawnCandidate chosen = rarityPick(pool, rng);
if (chosen == null || chosen.getReferenceSpawner() == null) { if (chosen == null) {
return; return;
} }
IrisSpawner spawner = chosen.getReferenceSpawner(); IrisSpawner spawner = chosen.spawner();
if (!canSpawn(spawner, chunkX, chunkZ)) { if (!canSpawn(spawner, chunkX, chunkZ, initial)) {
return; return;
} }
int spawned = spawnEntry(level, chosen, spawner, chunkX, chunkZ); int spawned = spawnEntry(level, chosen.entry(), chunkX, chunkZ,
if (spawned > 0) { remainingCapacity(chosen.entry(), spawner, chunkMobs), rng, new SpawnContext(spawner, null, initial));
recordSpawned(chosen.entry(), chunkMobs, spawned);
if (spawned > 0 && !initial) {
spawner.spawn(engine, chunkX, chunkZ); spawner.spawn(engine, chunkX, chunkZ);
} }
} }
private void collectSpawns(KList<IrisEntitySpawn> pool, KList<IrisSpawner> spawners, IrisBiome biomeFilter, int chunkX, int chunkZ, int chunkMobs, boolean initial) { private void spawnUnderground(NativeWorld level, int chunkX, int chunkZ, boolean initial,
for (IrisSpawner spawner : spawners) { Map<String, Integer> chunkMobs, RNG random) {
if (spawner == null) { for (IrisSurface surface : List.of(IrisSurface.LAND, IrisSurface.WATER, IrisSurface.LAVA)) {
IrisPosition candidate = findUndergroundCandidate(level, chunkX, chunkZ,
random.nextParallelRNG(surface.ordinal()), surface);
if (candidate == null) {
continue; continue;
} }
if (spawner.getMaxEntitiesPerChunk() <= chunkMobs) { BiomeEnvironment environment;
try {
environment = engine.getBiomeOrMantleEnvironment(candidate.getX(),
candidate.getY() - engine.getWorld().minHeight(), candidate.getZ());
} catch (SavedBiomeUnavailableException unavailable) {
continue;
}
try (BiomeEnvironment.Scope ignored = engine.openBiomeEnvironmentScope(environment)) {
KList<SpawnCandidate> pool = new KList<>();
collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.dimension().getEntitySpawners()),
environment.biome(), chunkX, chunkZ, chunkMobs, initial, true);
collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.region().getEntitySpawners()),
null, chunkX, chunkZ, chunkMobs, initial, true);
collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.biome().getEntitySpawners()),
null, chunkX, chunkZ, chunkMobs, initial, true);
pool.removeIf(selection -> !matchesUndergroundSurface(selection.entry(), surface));
SpawnCandidate selected = rarityPick(pool, random);
if (selected == null) {
continue;
}
int capacity = remainingCapacity(selected.entry(), selected.spawner(), chunkMobs);
int spawned = spawnUndergroundEntry(level, candidate, selected, capacity, random, initial);
recordSpawned(selected.entry(), chunkMobs, spawned);
if (spawned > 0 && !initial) {
selected.spawner().spawn(engine, chunkX, chunkZ);
}
return;
}
}
}
private boolean matchesUndergroundSurface(IrisEntitySpawn entry, IrisSurface surface) {
IrisEntity entity = entry.getRealEntity(engine);
return entity != null && entity.getSurface().isFluid() == surface.isFluid()
&& (!surface.isFluid() || entity.getSurface() == surface);
}
static IrisPosition findUndergroundCandidate(NativeWorld level, int chunkX, int chunkZ, RNG random, IrisSurface surface) {
int minimumY = level.minHeight() + 1;
for (int attempt = 0; attempt < 8; attempt++) {
int x = (chunkX << 4) + random.i(1, 15);
int z = (chunkZ << 4) + random.i(1, 15);
int maximumY = Math.min(level.maxHeight() - 3, NativeSpawnQueries.surfaceHeight(level, x, z, true) - 1);
if (maximumY < minimumY) {
continue;
}
int span = maximumY - minimumY + 1;
int startY = LootResolver.inclusive(random, minimumY, maximumY);
for (int sample = 0; sample < Math.min(span, 128); sample++) {
int y = minimumY + Math.floorMod(startY - minimumY - sample, span);
NativeBlockState state = level.getBlock(x, y, z);
boolean acceptable = surface.isFluid() ? matchesSurface(surface, state)
: state != null && state.isAir() && NativeSpawnQueries.solid(level.getBlock(x, y - 1, z))
&& level.getBlock(x, y + 1, z).isAir();
if (acceptable) {
return new IrisPosition(x, y, z);
}
}
}
return null;
}
private int spawnUndergroundEntry(NativeWorld level, IrisPosition candidate, SpawnCandidate selection,
int capacity, RNG random, boolean initial) {
IrisEntity entity = selection.entry().getRealEntity(engine);
if (entity == null) {
return 0;
}
int x = candidate.getX();
int y = candidate.getY();
int z = candidate.getZ();
int count = Math.min(Math.max(0, capacity), LootResolver.inclusive(random,
selection.entry().getMinSpawns(), selection.entry().getMaxSpawns()));
int spawned = 0;
for (int ordinal = 0; ordinal < count; ordinal++) {
if (!initial && !lightAllowed(selection.spawner(), level, x, y, z)
|| !surfaceMatches(entity.getSurface(), level, x, y, z)
|| !ModdedEntitySpawner.isAreaClearForSpawn(entityRuntime(level), entity, x, y, z)) {
continue;
}
if (ModdedEntitySpawner.spawn(engine, entity, entityRuntime(level), x, y, z,
EntitySpawnSeed.entity(random.getSeed(), ordinal)) != null) {
spawned++;
}
}
return spawned;
}
private void collectSpawns(KList<SpawnCandidate> pool, KList<IrisSpawner> spawners, IrisBiome biomeFilter, int chunkX, int chunkZ, Map<String, Integer> chunkMobs, boolean initial, boolean underground) {
for (IrisSpawner spawner : spawners) {
if (spawner == null || (spawner.getGroup() == IrisSpawnGroup.CAVE) != underground) {
continue; continue;
} }
if (biomeFilter != null && !spawner.isValid(biomeFilter)) { if (biomeFilter != null && !spawner.isValid(biomeFilter)) {
continue; continue;
} }
if (!canSpawn(spawner, chunkX, chunkZ)) { if (!canSpawn(spawner, chunkX, chunkZ, initial)) {
continue; continue;
} }
KList<IrisEntitySpawn> spawns = initial ? spawner.getInitialSpawns() : spawner.getSpawns(); KList<IrisEntitySpawn> spawns = initial ? spawner.getInitialSpawns() : spawner.getSpawns();
for (IrisEntitySpawn entry : spawns) { for (IrisEntitySpawn entry : spawns) {
entry.setReferenceSpawner(spawner); if (remainingCapacity(entry, spawner, chunkMobs) > 0) {
entry.setReferenceMarker(spawner.getReferenceMarker()); pool.add(new SpawnCandidate(entry, spawner));
pool.add(entry); }
} }
} }
} }
private int spawnEntry(NativeWorld level, IrisEntitySpawn entry, IrisSpawner spawner, int chunkX, int chunkZ) { private void recordSpawned(IrisEntitySpawn entry, Map<String, Integer> counts, int spawned) {
IrisEntity entity = entry.getRealEntity(engine);
if (spawned > 0 && entity != null) {
counts.merge(entity.spawnCategory(), spawned, Integer::sum);
}
}
private int remainingCapacity(IrisEntitySpawn entry, IrisSpawner spawner, Map<String, Integer> counts) {
IrisEntity entity = entry.getRealEntity(engine);
return spawner.remainingCapacity(entity, counts);
}
private int spawnEntry(NativeWorld level, IrisEntitySpawn entry, int chunkX, int chunkZ,
int remainingCapacity, RNG rng, SpawnContext context) {
IrisEntity irisEntity = entry.getRealEntity(engine); IrisEntity irisEntity = entry.getRealEntity(engine);
if (irisEntity == null) { if (irisEntity == null) {
return 0; return 0;
@@ -572,18 +681,24 @@ public final class ModdedWorldManager implements EngineWorldManager {
int min = entry.getMinSpawns(); int min = entry.getMinSpawns();
int max = entry.getMaxSpawns(); int max = entry.getMaxSpawns();
int count = LootResolver.inclusive(RNG.r, min, max); int count = Math.min(Math.max(0, remainingCapacity), LootResolver.inclusive(rng, min, max));
if (count <= 0) { if (count <= 0) {
return 0; return 0;
} }
RNG entityRng = entry.getRng().aquire(() -> new RNG(engine.getSeedManager().getEntity())); IrisSpawner spawner = context.spawner();
IrisSpawnGroup group = spawner.getGroup(); IrisSpawnGroup group = spawner.getGroup();
KList<IrisPosition> caveFloors = group == IrisSpawnGroup.CAVE KList<IrisPosition> caveFloors = group == IrisSpawnGroup.CAVE
? engine.getMantle().findMarkers(chunkX, chunkZ, MarkerMatter.CAVE_FLOOR) ? engine.getMantle().findMarkers(chunkX, chunkZ, MarkerMatter.CAVE_FLOOR)
: new KList<>(); : new KList<>();
if (context.initial()) {
caveFloors.sort(Comparator.comparingInt(IrisPosition::getX)
.thenComparingInt(IrisPosition::getY).thenComparingInt(IrisPosition::getZ));
}
int spawned = 0; int spawned = 0;
for (int i = 0; i < count; i++) { for (int i = 0; i < count; i++) {
RNG entityRng = EntitySpawnSeed.entity(rng.getSeed(), i);
RNG positionRng = entityRng.nextParallelRNG(0x632BE59BD9B4E019L);
int worldX; int worldX;
int worldY; int worldY;
int worldZ; int worldZ;
@@ -591,16 +706,16 @@ public final class ModdedWorldManager implements EngineWorldManager {
if (caveFloors.isEmpty()) { if (caveFloors.isEmpty()) {
continue; continue;
} }
IrisPosition caveFloor = caveFloors.getRandom(RNG.r); IrisPosition caveFloor = caveFloors.getRandom(positionRng);
worldX = caveFloor.getX(); worldX = caveFloor.getX();
worldY = caveFloor.getY() + 1; worldY = caveFloor.getY();
worldZ = caveFloor.getZ(); worldZ = caveFloor.getZ();
} else { } else {
worldX = (chunkX << 4) + RNG.r.i(16); worldX = (chunkX << 4) + positionRng.i(16);
worldZ = (chunkZ << 4) + RNG.r.i(16); worldZ = (chunkZ << 4) + positionRng.i(16);
int surfaceY = NativeSpawnQueries.surfaceHeight(level, worldX, worldZ, false); int surfaceY = NativeSpawnQueries.surfaceHeight(level, worldX, worldZ, false);
int solidY = NativeSpawnQueries.surfaceHeight(level, worldX, worldZ, true); int solidY = NativeSpawnQueries.surfaceHeight(level, worldX, worldZ, true);
Integer selectedY = IrisEntitySpawn.selectSurfaceSpawnY(group, irisEntity.getSurface(), solidY, surfaceY, RNG.r); Integer selectedY = IrisEntitySpawn.selectSurfaceSpawnY(group, irisEntity.getSurface(), solidY, surfaceY, positionRng);
if (selectedY == null) { if (selectedY == null) {
continue; continue;
} }
@@ -610,7 +725,7 @@ public final class ModdedWorldManager implements EngineWorldManager {
continue; continue;
} }
// Rarity is applied exactly once, as pool weighting in rarityPick - never re-rolled per position (Bukkit parity). // Rarity is applied exactly once, as pool weighting in rarityPick - never re-rolled per position (Bukkit parity).
if (!lightAllowed(spawner, level, worldX, worldY, worldZ)) { if (!context.initial() && !lightAllowed(spawner, level, worldX, worldY, worldZ)) {
continue; continue;
} }
if (!surfaceMatches(irisEntity.getSurface(), level, worldX, worldY, worldZ)) { if (!surfaceMatches(irisEntity.getSurface(), level, worldX, worldY, worldZ)) {
@@ -626,7 +741,7 @@ public final class ModdedWorldManager implements EngineWorldManager {
return spawned; return spawned;
} }
private int spawnEntryAt(NativeWorld level, IrisEntitySpawn entry, IrisSpawner spawner, IrisPosition position) { private int spawnEntryAt(NativeWorld level, IrisEntitySpawn entry, IrisPosition position, RNG rng, SpawnContext context) {
IrisEntity irisEntity = entry.getRealEntity(engine); IrisEntity irisEntity = entry.getRealEntity(engine);
if (irisEntity == null) { if (irisEntity == null) {
return 0; return 0;
@@ -634,21 +749,21 @@ public final class ModdedWorldManager implements EngineWorldManager {
int min = entry.getMinSpawns(); int min = entry.getMinSpawns();
int max = entry.getMaxSpawns(); int max = entry.getMaxSpawns();
int count = LootResolver.inclusive(RNG.r, min, max); int count = LootResolver.inclusive(rng, min, max);
if (count <= 0) { if (count <= 0) {
return 0; return 0;
} }
exhaustMarker(spawner, position); exhaustMarker(context.marker(), position, EntitySpawnSeed.entity(rng.getSeed(), -1));
RNG entityRng = entry.getRng().aquire(() -> new RNG(engine.getSeedManager().getEntity()));
int worldX = position.getX(); int worldX = position.getX();
int worldY = position.getY() + 1; int worldY = position.getY() + 1;
int worldZ = position.getZ(); int worldZ = position.getZ();
int spawned = 0; int spawned = 0;
for (int i = 0; i < count; i++) { for (int i = 0; i < count; i++) {
RNG entityRng = EntitySpawnSeed.entity(rng.getSeed(), i);
// Rarity is applied exactly once, as pool weighting in rarityPick - never re-rolled per position (Bukkit parity). // Rarity is applied exactly once, as pool weighting in rarityPick - never re-rolled per position (Bukkit parity).
if (!lightAllowed(spawner, level, worldX, worldY, worldZ)) { if (!context.initial() && !lightAllowed(context.spawner(), level, worldX, worldY, worldZ)) {
continue; continue;
} }
if (irisEntity.getSurface().isFluid() if (irisEntity.getSurface().isFluid()
@@ -663,16 +778,15 @@ public final class ModdedWorldManager implements EngineWorldManager {
return spawned; return spawned;
} }
private void exhaustMarker(IrisSpawner spawner, IrisPosition position) { private void exhaustMarker(IrisMarker marker, IrisPosition position, RNG rng) {
IrisMarker marker = spawner.getReferenceMarker(); if (marker == null || !marker.shouldExhaust(rng)) {
if (marker == null || !marker.shouldExhaust()) {
return; return;
} }
engine.getMantle().getMantle().remove(position.getX(), position.getY() - engine.getWorld().minHeight(), position.getZ(), MatterMarker.class); engine.getMantle().getMantle().remove(position.getX(), position.getY() - engine.getWorld().minHeight(), position.getZ(), MatterMarker.class);
} }
private boolean canSpawn(IrisSpawner spawner, int chunkX, int chunkZ) { private boolean canSpawn(IrisSpawner spawner, int chunkX, int chunkZ, boolean initial) {
return spawner.canSpawn(engine, chunkX, chunkZ); return !spawner.isCompatExcluded() && (initial || spawner.canSpawn(engine, chunkX, chunkZ));
} }
private boolean lightAllowed(IrisSpawner spawner, NativeWorld level, int worldX, int worldY, int worldZ) { private boolean lightAllowed(IrisSpawner spawner, NativeWorld level, int worldX, int worldY, int worldZ) {
@@ -708,10 +822,6 @@ public final class ModdedWorldManager implements EngineWorldManager {
|| NativeSpawnQueries.solid(level.getBlock(worldX, worldY + 2, worldZ)); || NativeSpawnQueries.solid(level.getBlock(worldX, worldY + 2, worldZ));
} }
private int countChunkLivingEntities(NativeWorld level, int chunkX, int chunkZ) {
return NativeSpawnQueries.livingEntities(level, chunkX, chunkZ);
}
/** /**
* ServerChunkCache.tickChunks rebuilds NaturalSpawner.SpawnState every tick from every entity in the * ServerChunkCache.tickChunks rebuilds NaturalSpawner.SpawnState every tick from every entity in the
* level, so read that instead of walking all entities again on an Iris timer. The count is the natural * level, so read that instead of walking all entities again on an Iris timer. The count is the natural
@@ -791,9 +901,9 @@ public final class ModdedWorldManager implements EngineWorldManager {
return !studio || studioSetting; return !studio || studioSetting;
} }
private IrisEntitySpawn rarityPick(KList<IrisEntitySpawn> entries) { private static <T extends Rarity> T rarityPick(KList<T> entries, RNG rng) {
KList<IrisEntitySpawn> weighted = Rarity.expandWeighted(entries); KList<T> weighted = Rarity.expandWeighted(entries);
return weighted.isEmpty() ? entries.getRandom() : weighted.getRandom(); return weighted.isEmpty() ? entries.getRandom(rng) : weighted.getRandom(rng);
} }
private static long pack(int x, int z) { private static long pack(int x, int z) {
@@ -888,7 +998,14 @@ public final class ModdedWorldManager implements EngineWorldManager {
return failure; return failure;
} }
private record PreparedMarkerSpawn(IrisPosition position, IrisSpawner spawner, BiomeEnvironment environment) { private record PreparedMarkerSpawn(IrisPosition position, IrisSpawner spawner, IrisMarker marker, RNG rng, BiomeEnvironment environment) {
}
private record SpawnCandidate(IrisEntitySpawn entry, IrisSpawner spawner) implements Rarity {
@Override
public int getRarity() {
return entry.getRarity();
}
} }
private NativeEntityRuntime entityRuntime(NativeWorld level) { private NativeEntityRuntime entityRuntime(NativeWorld level) {
@@ -33,6 +33,7 @@ import art.arcane.iris.modded.IrisModdedChunkGenerator;
import art.arcane.iris.modded.ModdedDimensionManager; import art.arcane.iris.modded.ModdedDimensionManager;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldTeleport; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldTeleport;
import art.arcane.iris.modded.ModdedEngineBootstrap; import art.arcane.iris.modded.ModdedEngineBootstrap;
import art.arcane.iris.modded.service.ModdedPackUpdateService;
import art.arcane.iris.modded.ModdedForcedDatapack; import art.arcane.iris.modded.ModdedForcedDatapack;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedLoader; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedLoader;
import art.arcane.iris.modded.ModdedScheduler; import art.arcane.iris.modded.ModdedScheduler;
@@ -445,6 +446,12 @@ public final class IrisModdedCommands {
); );
String completionMessage = downloadCompletionMessage(result); String completionMessage = downloadCompletionMessage(result);
if (result != null) { if (result != null) {
if (result.changed()) {
ModdedPackUpdateService updates = ModdedEngineBootstrap.services().service(ModdedPackUpdateService.class);
if (updates != null) {
dispatchDownloadFeedback(source, updates::refresh);
}
}
if (completionMessage != null) { if (completionMessage != null) {
dispatchDownloadFeedback(source, () -> ok(source, completionMessage)); dispatchDownloadFeedback(source, () -> ok(source, completionMessage));
} }
@@ -31,6 +31,9 @@ import art.arcane.iris.generation.runtime.GenerationSessionException;
import art.arcane.iris.generation.runtime.GenerationSessionLease; import art.arcane.iris.generation.runtime.GenerationSessionLease;
import art.arcane.iris.structure.placement.IrisStructureLocator; import art.arcane.iris.structure.placement.IrisStructureLocator;
import art.arcane.iris.generation.locator.Locator; import art.arcane.iris.generation.locator.Locator;
import art.arcane.iris.generation.locator.BiomeLocator;
import art.arcane.iris.generation.subterrain.SubterrainPosition;
import art.arcane.iris.world.history.SavedBiomeRuntime;
import art.arcane.iris.structure.nativegen.NativeStructureGenerationPolicy; import art.arcane.iris.structure.nativegen.NativeStructureGenerationPolicy;
import art.arcane.iris.structure.placement.StructureReachability; import art.arcane.iris.structure.placement.StructureReachability;
import art.arcane.iris.generation.runtime.WrongEngineBroException; import art.arcane.iris.generation.runtime.WrongEngineBroException;
@@ -82,7 +85,7 @@ final class ModdedLocateCommands {
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_UNKNOWN_BIOME_2, MessageArgument.untrusted("key", key))); IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_UNKNOWN_BIOME_2, MessageArgument.untrusted("key", key)));
return 0; return 0;
} }
locate(source, level, engine, player, Locator.surfaceBiome(biome.getLoadKey()), "biome " + biome.getLoadKey()); locate(source, level, engine, player, BiomeLocator.forBiome(engine, biome.getLoadKey(), player.blockY()), "biome " + biome.getLoadKey());
return 1; return 1;
} }
@@ -545,12 +548,12 @@ final class ModdedLocateCommands {
previous.cancel(true); previous.cancel(true);
} }
search.whenComplete((Position2 at, Throwable error) -> completeLocate( search.whenComplete((Position2 at, Throwable error) -> completeLocate(
source, level, engine, player, label, server, playerId, search, at, error)); source, level, engine, player, label, server, playerId, search, locator, at, error));
} }
private static void completeLocate(NativeCommandSource source, NativeWorld level, Engine engine, private static void completeLocate(NativeCommandSource source, NativeWorld level, Engine engine,
NativeProtocolPlayer player, String label, NativeModdedServer server, UUID playerId, NativeProtocolPlayer player, String label, NativeModdedServer server, UUID playerId,
CompletableFuture<Position2> search, Position2 at, Throwable error) { CompletableFuture<Position2> search, Locator<?> locator, Position2 at, Throwable error) {
if (ACTIVE_LOCATE_REQUESTS.get(playerId) != search) { if (ACTIVE_LOCATE_REQUESTS.get(playerId) != search) {
return; return;
} }
@@ -576,15 +579,77 @@ final class ModdedLocateCommands {
}); });
return; return;
} }
if (locator instanceof BiomeLocator caveLocator) {
prepareCaveLocate(new CaveLocateRequest(source, level, engine, player, label, server, playerId,
search, caveLocator, at));
return;
}
server.execute(() -> { server.execute(() -> {
if (ACTIVE_LOCATE_REQUESTS.remove(playerId, search)) { if (ACTIVE_LOCATE_REQUESTS.remove(playerId, search)) {
teleportToLocateResult(source, level, engine, player, label, at); teleportToLocateResult(source, level, engine, player, label, locator, at, Optional.empty());
} }
}); });
} }
private static void teleportToLocateResult(NativeCommandSource source, NativeWorld level, Engine engine, private static void prepareCaveLocate(CaveLocateRequest request) {
NativeProtocolPlayer player, String label, Position2 at) { CompletableFuture.supplyAsync(() -> resolveCavePosition(request))
.whenComplete((target, error) -> request.server().execute(() -> loadCaveDestination(request, target, error)));
}
private static SubterrainPosition resolveCavePosition(CaveLocateRequest request) {
try (GenerationSessionLease lease = request.engine().acquireGenerationLease("modded_cave_position");
IrisContext.Scope ignored = IrisContext.open(request.engine(), lease.sessionId(), null)) {
return request.locator().position(request.engine(), request.at());
} catch (GenerationSessionException error) {
throw new CompletionException(error);
}
}
private static void loadCaveDestination(CaveLocateRequest request, SubterrainPosition target, Throwable error) {
if (ACTIVE_LOCATE_REQUESTS.get(request.playerId()) != request.search()) {
return;
}
if (error != null) {
failCaveLocate(request, error);
return;
}
if (target == null || !request.player().connected() || !request.player().inWorld(request.level())) {
if (ACTIVE_LOCATE_REQUESTS.remove(request.playerId(), request.search())) {
IrisModdedCommands.fail(request.source(), "The cave destination is no longer available.");
}
return;
}
try {
request.level().getBlock(target.x(), target.y(), target.z());
request.locator().prepareRead(request.engine(), new Position2(target.x() >> 4, target.z() >> 4))
.whenComplete((read, failure) -> request.server().execute(() -> finishCaveLocate(request, target, read, failure)));
} catch (RuntimeException failure) {
failCaveLocate(request, failure);
}
}
private static void finishCaveLocate(CaveLocateRequest request, SubterrainPosition target,
Optional<SavedBiomeRuntime.ReadSession> read, Throwable error) {
if (error != null) {
failCaveLocate(request, error);
return;
}
if (ACTIVE_LOCATE_REQUESTS.remove(request.playerId(), request.search())) {
teleportToLocateResult(request.source(), request.level(), request.engine(), request.player(),
request.label(), request.locator(), request.at(), Optional.of(new CaveDestination(target, read)));
}
}
private static void failCaveLocate(CaveLocateRequest request, Throwable error) {
ModdedIrisLog.error("Iris cave locate failed for {}", request.label(), error);
if (ACTIVE_LOCATE_REQUESTS.remove(request.playerId(), request.search())) {
IrisModdedCommands.fail(request.source(), "Could not prepare the cave destination. See the server log for details.");
}
}
static void teleportToLocateResult(NativeCommandSource source, NativeWorld level, Engine engine,
NativeProtocolPlayer player, String label, Locator<?> locator, Position2 at,
Optional<CaveDestination> caveDestination) {
// Same liveness guards the structure completion path has: the search can take up to // Same liveness guards the structure completion path has: the search can take up to
// two minutes, and the captured NativeProtocolPlayer may be gone or elsewhere by then. // two minutes, and the captured NativeProtocolPlayer may be gone or elsewhere by then.
if (!player.connected()) { if (!player.connected()) {
@@ -600,6 +665,17 @@ final class ModdedLocateCommands {
try (GenerationSessionLease lease = engine.acquireGenerationLease("modded_locator_teleport"); try (GenerationSessionLease lease = engine.acquireGenerationLease("modded_locator_teleport");
IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) {
int blockY = engine.getMinHeight() + engine.getHeight(blockX, blockZ, false) + 2; int blockY = engine.getMinHeight() + engine.getHeight(blockX, blockZ, false) + 2;
if (locator instanceof BiomeLocator caveLocator) {
CaveDestination prepared = caveDestination.orElseThrow();
SubterrainPosition landing = caveLocator.landing(engine, level, prepared.target(), prepared.read());
if (landing == null) {
IrisModdedCommands.fail(source, "No safe cave landing found for " + label + ".");
return;
}
blockX = landing.x();
blockY = landing.y();
blockZ = landing.z();
}
boolean teleported = player.teleport(level, blockX + 0.5D, blockY, blockZ + 0.5D); boolean teleported = player.teleport(level, blockX + 0.5D, blockY, blockZ + 0.5D);
if (!teleported) { if (!teleported) {
IrisModdedCommands.fail(source, IrisLanguage.plain( IrisModdedCommands.fail(source, IrisLanguage.plain(
@@ -611,7 +687,8 @@ final class ModdedLocateCommands {
return; return;
} }
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORTED_AT_2, MessageArgument.untrusted("label", label), MessageArgument.untrusted("blockX", blockX), MessageArgument.untrusted("blockY", blockY), MessageArgument.untrusted("blockZ", blockZ))); IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORTED_AT_2, MessageArgument.untrusted("label", label), MessageArgument.untrusted("blockX", blockX), MessageArgument.untrusted("blockY", blockY), MessageArgument.untrusted("blockZ", blockZ)));
} catch (GenerationSessionException e) { } catch (GenerationSessionException | RuntimeException e) {
ModdedIrisLog.error("Iris locate teleport failed for {}", label, e);
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_ENGINE_CHANGED_WHILE_LOCATING_TRY_AGAIN, MessageArgument.untrusted("label", label))); IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_ENGINE_CHANGED_WHILE_LOCATING_TRY_AGAIN, MessageArgument.untrusted("label", label)));
} }
} }
@@ -635,6 +712,14 @@ final class ModdedLocateCommands {
NO_PLACEMENT NO_PLACEMENT
} }
record CaveDestination(SubterrainPosition target, Optional<SavedBiomeRuntime.ReadSession> read) {
}
private record CaveLocateRequest(NativeCommandSource source, NativeWorld level, Engine engine,
NativeProtocolPlayer player, String label, NativeModdedServer server,
UUID playerId, CompletableFuture<Position2> search, BiomeLocator locator, Position2 at) {
}
private record NativeStructureTarget(String key, NativeStructureQueries.Reference holder, private record NativeStructureTarget(String key, NativeStructureQueries.Reference holder,
NativeStructureAvailability availability) { NativeStructureAvailability availability) {
} }
@@ -18,6 +18,8 @@
package art.arcane.iris.modded.command; package art.arcane.iris.modded.command;
import art.arcane.iris.modded.ModdedStartup;
import art.arcane.iris.modded.ModdedIrisLog; import art.arcane.iris.modded.ModdedIrisLog;
import art.arcane.iris.pack.validation.PackCompatReport; import art.arcane.iris.pack.validation.PackCompatReport;
import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.IrisPlatforms;
@@ -25,7 +27,6 @@ import art.arcane.iris.pack.PackDirectoryResolver;
import art.arcane.iris.pack.PackResourceCleanup; import art.arcane.iris.pack.PackResourceCleanup;
import art.arcane.iris.pack.PackValidationRegistry; import art.arcane.iris.pack.PackValidationRegistry;
import art.arcane.iris.pack.PackValidationResult; import art.arcane.iris.pack.PackValidationResult;
import art.arcane.iris.pack.PackValidator;
import art.arcane.iris.modded.ModdedEngineBootstrap; import art.arcane.iris.modded.ModdedEngineBootstrap;
import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.builder.LiteralArgumentBuilder; import com.mojang.brigadier.builder.LiteralArgumentBuilder;
@@ -138,8 +139,7 @@ public final class ModdedPackCommands {
int broken = 0; int broken = 0;
for (File target : targets) { for (File target : targets) {
try { try {
PackValidationResult result = PackValidator.validate(target); PackValidationResult result = ModdedStartup.validatePack(target);
PackValidationRegistry.publish(result);
if (!result.isLoadable()) { if (!result.isLoadable()) {
broken++; broken++;
} }
@@ -0,0 +1,138 @@
package art.arcane.iris.modded.service;
import art.arcane.iris.diagnostics.splash.IrisSplashPackScanner;
import art.arcane.iris.diagnostics.splash.IrisSplashPackScanner.SplashPackMetadata;
import art.arcane.iris.pack.BuiltInPackUpdates;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import java.io.File;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.function.Function;
public final class ModdedPackUpdateService implements ModdedTickableService {
private final Function<File, CompletableFuture<PackCheck>> checker;
private final Set<UUID> notified = new HashSet<>();
private CompletableFuture<PackCheck> pending;
private List<String> notices = List.of();
private File packsFolder;
private boolean enabled;
private boolean started;
public ModdedPackUpdateService() {
this(ModdedPackUpdateService::checkPacks);
}
ModdedPackUpdateService(Function<File, CompletableFuture<PackCheck>> checker) {
this.checker = checker;
}
@Override
public void onEnable() {
enabled = true;
}
@Override
public void onDisable() {
enabled = false;
if (pending != null) {
pending.cancel(true);
pending = null;
}
started = false;
notices = List.of();
notified.clear();
packsFolder = null;
}
@Override
public void onServerTick(NativeModdedServer server) {
if (!enabled || server == null || !server.hasPlayerList()) {
return;
}
if (!started) {
started = true;
packsFolder = IrisPlatforms.get().packsFolderNoCreate().getAbsoluteFile();
pending = checker.apply(packsFolder);
}
if (pending == null || !pending.isDone()) {
return;
}
CompletableFuture<PackCheck> completed = pending;
pending = null;
try {
publish(completed.join());
server.forEachPlayer(this::notifyPlayer);
} catch (CompletionException error) {
IrisLogging.reportError("Iris pack update check failed", error);
}
}
public void refresh() {
if (!enabled) {
return;
}
if (pending != null) {
pending.cancel(true);
pending = null;
}
started = false;
notices = List.of();
notified.clear();
}
public void notifyPlayer(NativeProtocolPlayer player) {
if (!enabled || notices.isEmpty() || player == null || !player.connected()
|| (!player.isGameMaster() && !player.isServerOwner()) || !notified.add(player.id())) {
return;
}
for (String notice : notices) {
player.sendMessage(notice);
}
}
private static CompletableFuture<PackCheck> checkPacks(File folder) {
return CompletableFuture.supplyAsync(() -> IrisSplashPackScanner.collect(folder, IrisLogging::reportError))
.thenCompose(packs -> BuiltInPackUpdates.check(packs.stream().map(SplashPackMetadata::name).toList())
.thenApply(updates -> new PackCheck(packs, updates)));
}
private void publish(PackCheck result) {
List<String> updates = new ArrayList<>();
IrisLogging.info("Iris packs: " + packsFolder);
for (SplashPackMetadata pack : result.packs()) {
BuiltInPackUpdates.Update update = result.updates().get(pack.name());
String version = pack.version().matches("[0-9]+") ? "v" + pack.version() : "version unknown";
IrisLogging.info(" " + pack.name() + " " + version
+ (update == null ? "" : update.suffix(pack.version())));
if (update != null && update.newerThan(pack.version())) {
updates.add("Iris " + pack.name() + " release metadata is outdated: installed v" + pack.version()
+ ", latest built-in release v" + update.version() + ".");
updates.add("Back up and review custom edits in " + new File(packsFolder, pack.name())
+ "; close Studio, then update with /iris download pack=" + pack.name() + " overwrite=true.");
updates.add("Existing worlds retain their recorded production pack. Back up the complete world, then stage an update with /iris world update <dimension> "
+ pack.name() + " and restart the server to activate it.");
}
}
notices = List.copyOf(updates);
for (String notice : notices) {
IrisLogging.warn(notice);
}
}
record PackCheck(List<SplashPackMetadata> packs, Map<String, BuiltInPackUpdates.Update> updates) {
PackCheck {
packs = List.copyOf(packs);
updates = Map.copyOf(updates);
}
}
}
@@ -57,6 +57,7 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
public final class ModdedStudioHotloadService implements ModdedTickableService, EnginePlatformHooks { public final class ModdedStudioHotloadService implements ModdedTickableService, EnginePlatformHooks {
@@ -64,6 +65,8 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
private static final long POLL_MILLIS = 250L; private static final long POLL_MILLIS = 250L;
private static final long CHECK_LATCH_MILLIS = 1_000L; private static final long CHECK_LATCH_MILLIS = 1_000L;
private static final long RECENT_GENERATION_HOLDOFF_MILLIS = 2_000L; private static final long RECENT_GENERATION_HOLDOFF_MILLIS = 2_000L;
private static final long SHUTDOWN_TIMEOUT_SECONDS = 30L;
private static final long INTERRUPT_DRAIN_TIMEOUT_SECONDS = 5L;
private final ConcurrentHashMap<String, Watch> watches = new ConcurrentHashMap<>(); private final ConcurrentHashMap<String, Watch> watches = new ConcurrentHashMap<>();
private volatile ExecutorService executor; private volatile ExecutorService executor;
@@ -81,9 +84,13 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
@Override @Override
public void onEnable() { public void onEnable() {
if (executor != null) { ExecutorService current = executor;
if (current != null && !current.isTerminated()) {
if (!current.isShutdown()) {
return; return;
} }
throw new IllegalStateException("Iris Studio hotload cannot restart while prior workers are still active");
}
executor = Executors.newSingleThreadExecutor((Runnable task) -> { executor = Executors.newSingleThreadExecutor((Runnable task) -> {
Thread thread = new Thread(task, "Iris Studio Hotload"); Thread thread = new Thread(task, "Iris Studio Hotload");
thread.setDaemon(true); thread.setDaemon(true);
@@ -96,10 +103,10 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
@Override @Override
public void onDisable() { public void onDisable() {
ExecutorService active = executor; ExecutorService active = executor;
executor = null; if (!shutdownAndDrain(active)) {
if (active != null) { throw new IllegalStateException("Iris Studio hotload worker remained active during service shutdown");
active.shutdownNow();
} }
executor = null;
for (Watch watch : watches.values()) { for (Watch watch : watches.values()) {
watch.close(); watch.close();
} }
@@ -197,7 +204,7 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
@Override @Override
public void onServerTick(NativeModdedServer server) { public void onServerTick(NativeModdedServer server) {
ExecutorService active = executor; ExecutorService active = executor;
if (active == null) { if (active == null || active.isShutdown()) {
return; return;
} }
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
@@ -284,6 +291,30 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
&& !isPregeneratorActive(engine); && !isPregeneratorActive(engine);
} }
private static boolean shutdownAndDrain(ExecutorService active) {
if (active == null) {
return true;
}
active.shutdown();
try {
if (active.awaitTermination(SHUTDOWN_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
return true;
}
active.shutdownNow();
if (active.awaitTermination(INTERRUPT_DRAIN_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
return true;
}
ModdedIrisLog.error("Iris Studio hotload worker did not stop after shutdownNow",
new IllegalStateException("Iris Studio hotload worker is still active"));
return false;
} catch (InterruptedException exception) {
active.shutdownNow();
Thread.currentThread().interrupt();
ModdedIrisLog.error("Interrupted while draining Iris Studio hotload", exception);
return false;
}
}
private boolean throttled(IrisModdedChunkGenerator generator, Engine engine, long now) { private boolean throttled(IrisModdedChunkGenerator generator, Engine engine, long now) {
if (now - generator.lastChunkGenAt() < RECENT_GENERATION_HOLDOFF_MILLIS) { if (now - generator.lastChunkGenAt() < RECENT_GENERATION_HOLDOFF_MILLIS) {
return true; return true;
Loaded 100 of 511 files, more files were not shown because too many files have changed in this diff. Show more