Compare commits

...
Author SHA1 Message Date
Brian Neumann-Fopiano a521109a6a Updated release version
- Iris version is 4.3.3-26.2
- Loader build defaults use the current release version
2026-10-12 14:47:39 -04:00
Brian Neumann-Fopiano a9f5145c0f Fixed Iris artifact builds
- Loader jars exclude Bukkit dependency bootstrap classes
- Artifact verification rejects bootstrap references and executable payloads
- Verifier tests cover nested jars and bundled compression libraries
- Local VolmLib native setup finishes before Iris native setup
2026-10-12 11:34:41 -04:00
Brian Neumann-Fopiano e3673afb1e Fixed mining and timeout settings
- Pending biome reads no longer cancel block breaking
- Custom loot resolves saved biome history after accepted breaks
- Native drops survive failed reads and runtime teardown without duplication
- Tree felling captures the original tool for fallback drops
- Modded block loot resolves one historical biome context
- Timeout settings persist atomically through the actual server configuration paths
- Tests cover deferred loot, teardown and timeout persistence
2026-10-12 09:27:26 -04:00
Brian Neumann-Fopiano 2145e90b1c Rebase 2026-10-11 10:35:59 -04:00
SwiftSwamp d480abad50 Added shared update notices
- Iris joins shared Volmit update checks and reports
- The iris.update permission controls update visibility
- general.updateNotifications defaults to enabled
- Accepted settings changes reconfigure update checks
- Shutdown removes the settings listener and closes update checks
- Tests cover persisted opt-outs and notifier lifecycle
2026-10-09 09:30:12 -06:00
Brian Neumann-Fopiano be0cd567ed V+
- Iris version is 4.3.2-26.2
- Loader build fallbacks use the current release version
2026-10-08 17:17:12 -04:00
Brian Neumann-Fopiano ce7872f667 Removing scheduler
- not needed
2026-10-08 13:14:35 -04:00
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
563 changed files with 28700 additions and 6455 deletions

No files matched your search

+5
View File
@@ -76,3 +76,8 @@ BUILDS/
.compatwt/
.worktrees/
/VolmLib/
/docs/
/.perf/
/.codegraph/
@@ -14,6 +14,7 @@ import net.minecraft.SharedConstants;
import net.minecraft.server.Bootstrap;
import net.minecraft.world.level.LevelHeightAccessor;
import net.minecraft.world.level.levelgen.Heightmap;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Rule;
import org.junit.Test;
@@ -45,6 +46,11 @@ public final class IrisChunkGeneratorHeightCacheTest {
NativeBlockTags.bindHeightmapFixtures();
}
@AfterClass
public static void restoreBlockTags() {
NativeBlockTags.restoreTags();
}
@Test
public void repeatedNativeHeightUsesLiveHeightQueriesAcrossRuntimeChanges() throws Exception {
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.LiquidBlock;
import net.minecraft.world.level.levelgen.Heightmap;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Rule;
import org.junit.Test;
@@ -46,6 +47,11 @@ public final class NativeTransitionColumnTest {
NativeBlockTags.bindHeightmapFixtures();
}
@AfterClass
public static void restoreBlockTags() {
NativeBlockTags.restoreTags();
}
@Test
public void customKeysUseCarrierForNativeColumnsAndHeightmaps() {
String key = "itemsadder:rocks/ruby_ore";
@@ -1,21 +1,30 @@
package art.arcane.iris.nativegen.v26_3_R1;
import net.minecraft.core.Holder;
import net.minecraft.core.HolderSet;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.core.registries.Registries;
import net.minecraft.tags.BlockTags;
import net.minecraft.tags.TagLoader;
import net.minecraft.tags.TagKey;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public final class NativeBlockTags {
private static Map<TagKey<Block>, List<Holder<Block>>> originalTags;
private NativeBlockTags() {
}
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(),
Blocks.OAK_LOG.builtInRegistryHolder(), Blocks.SPRUCE_LOG.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_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.SwampHutPiece;
import net.minecraft.world.level.levelgen.structure.structures.SwampHutStructure;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Test;
@@ -68,6 +69,11 @@ public class WorldgenTerrainHeightmapsTest {
NativeBlockTags.bindHeightmapFixtures();
}
@AfterClass
public static void restoreBlockTags() {
NativeBlockTags.restoreTags();
}
@Test
public void authoringHeightmapsReadPlacedObjectsAndFluidsWithoutTerrainResolvers() {
ProtoChunk chunk = terrainChunk(new ChunkPos(0, 0));
@@ -13,6 +13,7 @@ import com.google.gson.JsonParser;
import org.bukkit.Bukkit;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.ClassRule;
import org.junit.Test;
import java.io.File;
@@ -26,6 +27,9 @@ import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows;
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 NATURAL_MOB_SPAWNS = "minecraft:gameplay/natural_mob_spawns";
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.storage.LevelStorageSource;
import org.junit.Rule;
import org.junit.ClassRule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.bukkit.craftbukkit.generator.CustomChunkGenerator;
@@ -49,6 +50,9 @@ import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
public class HeadlessChunkGeneratorTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@Rule
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 org.bukkit.Bukkit;
import org.junit.Rule;
import org.junit.ClassRule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
@@ -48,6 +49,9 @@ import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public final class HeadlessChunkLightingTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@Rule
public TemporaryFolder temporary = new TemporaryFolder();
@@ -38,6 +38,7 @@ import net.minecraft.world.ticks.ScheduledTick;
import org.bukkit.NamespacedKey;
import org.bukkit.persistence.PersistentDataType;
import org.junit.Rule;
import org.junit.ClassRule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
@@ -54,6 +55,9 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class HeadlessChunkSerializationTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@Rule
public TemporaryFolder temporary = new TemporaryFolder();
@@ -9,6 +9,7 @@ import net.minecraft.world.item.Items;
import java.util.Map;
import net.minecraft.world.level.block.Blocks;
import org.junit.ClassRule;
import org.junit.Test;
import org.bukkit.Bukkit;
@@ -18,6 +19,9 @@ import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public final class HeadlessNativeBootstrapTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@Test
public void bindsNativeItemComponentsWithoutStartingServer() throws Exception {
HeadlessNativeBootstrap.bindComponents(HeadlessNativeTestRegistries.get());
@@ -7,12 +7,16 @@ import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.levelgen.structure.Structure;
import org.junit.ClassRule;
import org.junit.Test;
import static org.junit.Assert.assertTrue;
public final class HeadlessNativeRegistriesTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@Test
public void nativeStructureBiomeTagsUseTheLoadedBiomeHolders() throws Exception {
RegistryAccess registries = HeadlessNativeTestRegistries.get();
@@ -4,6 +4,7 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeRegistryAccess;
import net.minecraft.core.registries.Registries;
import org.bukkit.Bukkit;
import org.junit.BeforeClass;
import org.junit.ClassRule;
import org.junit.Test;
import java.util.Map;
@@ -17,6 +18,9 @@ import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
public final class HeadlessNativeRegistryTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@BeforeClass
public static void bootstrap() {
HeadlessNativeBootstrap.initialize();
@@ -11,6 +11,7 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeTileReader;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.data.registries.VanillaRegistries;
import org.junit.BeforeClass;
import org.junit.ClassRule;
import org.junit.Test;
import java.io.ByteArrayInputStream;
@@ -23,6 +24,9 @@ import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
public final class HeadlessNativeTileTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@BeforeClass
public static void bootstrap() {
HeadlessNativeBootstrap.initialize();
@@ -17,6 +17,7 @@ import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey;
import org.bukkit.persistence.PersistentDataType;
import org.junit.Rule;
import org.junit.ClassRule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.spigotmc.SpigotWorldConfig;
@@ -36,6 +37,9 @@ import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
public final class HeadlessRegionTerrainTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@Rule
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.RegionStorageInfo;
import org.junit.BeforeClass;
import org.junit.ClassRule;
import org.junit.Test;
import java.io.DataInputStream;
@@ -38,6 +39,9 @@ import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public final class NativeRegionTerrainWriterTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@BeforeClass
public static void bootstrap() {
SharedConstants.tryDetectVersion();
@@ -26,6 +26,7 @@ import net.minecraft.world.level.material.Fluids;
import org.bukkit.Bukkit;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.ClassRule;
import org.junit.Test;
import java.util.ArrayList;
@@ -40,6 +41,9 @@ import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public final class OfflineNativeFeatureFeasibilityTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
private static HeadlessNativeRegistries loaded;
private static RegistryAccess registries;
private static DimensionType dimension;
@@ -1,6 +1,7 @@
package art.arcane.iris.probe;
import org.junit.Rule;
import org.junit.ClassRule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.mockito.MockedStatic;
@@ -22,6 +23,9 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
public final class OfflineRegionPublicationTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@Rule
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;
import java.util.concurrent.CompletionStage;
import art.arcane.volmlib.util.diagnostics.BukkitDebugDump;
import art.arcane.iris.generation.runtime.IrisEngineEffects;
import art.arcane.iris.generation.runtime.IrisWorldManager;
@@ -36,6 +37,7 @@ import art.arcane.iris.world.PendingWorldDeleteQueue;
import art.arcane.iris.world.PendingWorldReplacementManager;
import art.arcane.iris.world.WorldRefusalReporter;
import art.arcane.iris.configuration.SettingsHotloadWatch;
import art.arcane.iris.update.IrisUpdateNotifier;
import art.arcane.iris.pack.datapack.ServerConfigurator;
import art.arcane.iris.pack.datapack.DatapackIngestService;
import art.arcane.iris.pack.datapack.DatapackIngestService.StartupValidationOutcome;
@@ -131,8 +133,11 @@ import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.bukkit.event.Event;
import org.bukkit.event.EventHandler;
import org.bukkit.event.HandlerList;
import org.bukkit.event.Listener;
import org.bukkit.event.server.PluginDisableEvent;
import org.bukkit.event.server.PluginEnableEvent;
import org.bukkit.generator.BiomeProvider;
import org.bukkit.generator.ChunkGenerator;
import org.bukkit.plugin.IllegalPluginAccessException;
@@ -204,6 +209,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
private volatile PlaceholderRegistration papiRegistration;
private volatile IrisPapiListener papiListener;
private volatile IrisPapiState papiState;
private volatile boolean papiLifecycleActive;
private KMap<Class<? extends IrisService>, IrisService> services;
// Copy-on-write: mutated on the main thread during enable() and iterated by the JVM
// shutdown-hook thread during teardown; a plain list would CME and abort the teardown.
@@ -214,6 +220,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
private final PendingWorldDeleteQueue pendingWorldDeletes = new PendingWorldDeleteQueue(this);
private final PendingWorldReplacementManager pendingWorldReplacements = new PendingWorldReplacementManager(this);
private BukkitLanguageSwitcher languageSwitcher;
private IrisUpdateNotifier updateNotifier;
private BukkitDebugDump debugDump;
private volatile SettingsHotloadWatch settingsHotloadWatch;
private volatile Thread serverLifecycleThread;
@@ -636,6 +643,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
languageSwitcher = BukkitLanguageSwitcher.register(this, IrisLanguage.selections(),
new BukkitLanguageSwitcher.Options("iris", "iris.all",
DirectorMiniMenu.Theme.irisGreen(), IrisLanguage.directorResolver(), IrisLanguage.editorOptions()));
updateNotifier = new IrisUpdateNotifier(this);
PaperLibBootstrap.install();
SimdSupport.install();
timings.mark("bootstrap");
@@ -863,7 +871,14 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
*/
private CompletableFuture<ManagedWorldLoader.ManagedWorldLoad> loadManagedWorld(String configuredWorldName) {
return worldReconciler.loadWorld(ServerProperties.BUKKIT_YML, configuredWorldName)
.thenApply(result -> new ManagedWorldLoader.ManagedWorldLoad(result.succeeded(), result.message()));
.thenApply(this::managedWorldLoadResult);
}
private ManagedWorldLoader.ManagedWorldLoad managedWorldLoadResult(BukkitWorldReconciler.LoadResult result) {
if (!result.succeeded() && result.failure() != null) {
reportError("Could not explicitly load Iris world " + result.worldKey() + ": " + result.message(), result.failure());
}
return new ManagedWorldLoader.ManagedWorldLoad(result.succeeded(), result.message());
}
public PendingWorldReplacementManager pendingWorldReplacements() {
@@ -927,6 +942,8 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
ServerConfigurator.restartAtStartupBoundary(restartReason);
return;
}
papiLifecycleActive = true;
setupPapi();
reportLockedRuntime();
}
@@ -973,6 +990,12 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
}
public void onDisable() {
preservePendingBlockDrops();
papiLifecycleActive = false;
if (updateNotifier != null) {
updateNotifier.close();
updateNotifier = null;
}
if (debugDump != null) {
debugDump.close();
debugDump = null;
@@ -1011,20 +1034,23 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
}
@Override
public void onPreUnload(ReloadAware.PreUnloadReason reason) {
public CompletionStage<Void> commitReload(ReloadAware.PreUnloadReason reason) {
preservePendingBlockDrops();
papiLifecycleActive = false;
teardownPapi();
if (IrisToolbelt.isServerStopping()) {
quiesceRuntimeForServerShutdown("pre-unload:" + reason);
startPostStopFinisher();
Iris.debug("Pre-unload hook deferred generator teardown until Paper closes its chunk schedulers.");
return;
return CompletableFuture.completedFuture(null);
}
if (alreadyDrained.get()) {
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.");
drainOnce("pre-unload:" + reason, 45L);
return CompletableFuture.completedFuture(null);
}
/**
@@ -1259,6 +1285,20 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
&& preservation.hasActiveResources();
}
private void preservePendingBlockDrops() {
for (World world : Bukkit.getWorlds()) {
PlatformChunkGenerator generator = IrisToolbelt.access(world);
if (generator != null && generator.getEngine() != null
&& generator.getEngine().getWorldManager() instanceof IrisWorldManager manager) {
manager.flushPendingBlockDropsAsync().whenComplete((ignored, failure) -> {
if (failure != null) {
Iris.reportError("Failed to preserve pending block loot before Iris teardown.", failure);
}
});
}
}
}
private void drainWorldGenerators(String reason, long timeoutSeconds) {
List<World> irisWorlds = new ArrayList<>();
List<PlatformChunkGenerator> generators = new ArrayList<>();
@@ -1313,8 +1353,23 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
}
}
private void setupPapi() {
if (!PlaceholderRegistration.isPlaceholderApiEnabled()) {
@EventHandler
public void onPlaceholderApiEnable(PluginEnableEvent event) {
if ("PlaceholderAPI".equals(event.getPlugin().getName())) {
setupPapi();
}
}
@EventHandler
public void onPlaceholderApiDisable(PluginDisableEvent event) {
if ("PlaceholderAPI".equals(event.getPlugin().getName())) {
teardownPapi();
}
}
private synchronized void setupPapi() {
if (!papiLifecycleActive || papiRegistration != null
|| !PlaceholderRegistration.isPlaceholderApiEnabled()) {
return;
}
@@ -1341,7 +1396,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
papiRegistration = registration;
}
private void teardownPapi() {
private synchronized void teardownPapi() {
IrisPapiListener listener = papiListener;
papiListener = null;
@@ -25,6 +25,8 @@ import art.arcane.iris.localization.IrisLanguage;
import art.arcane.iris.pack.datapack.DatapackIngestService;
import art.arcane.iris.studio.object.ObjectStudioSaveService;
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.nativegen.NativeStructureGenerationPolicy;
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")
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(
@Param(description = "The biome to look for", descriptionKey = "iris.director.commandfind.param.biome_look", customHandler = ReachableBiomeHandler.class)
IrisBiome biome,
@@ -89,6 +91,78 @@ public class CommandFind implements DirectorExecutor {
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")
public void region(
@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;
import art.arcane.iris.pack.PackFingerprints;
import art.arcane.iris.Iris;
import art.arcane.iris.pack.validation.PackCompatReport;
import art.arcane.iris.pack.PackDirectoryResolver;
@@ -174,7 +176,6 @@ public class CommandPack implements DirectorExecutor {
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_NO_CLEANUP_CANDIDATES_FOUND_PACK, MessageArgument.untrusted("pack", String.valueOf(pack))));
return;
}
PackValidationRegistry.remove(packFolder.getName());
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_QUARANTINED_CLEANUP_CANDIDATE_S_UNDER, MessageArgument.untrusted("size", String.valueOf(result.quarantinedPaths().size())), MessageArgument.untrusted("quarantinePath", String.valueOf(result.quarantinePath()))));
reportPaths(s, result.quarantinedPaths(), BukkitRuntimeMessages.COMMAND_PACK_PATH_QUARANTINED);
return;
@@ -230,7 +231,6 @@ public class CommandPack implements DirectorExecutor {
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_NOTHING_RESTORE_PACK, MessageArgument.untrusted("pack", String.valueOf(pack))));
return;
}
PackValidationRegistry.remove(packFolder.getName());
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_RESTORED_FILE_S_FROM, MessageArgument.untrusted("size", String.valueOf(result.restoredPaths().size())), MessageArgument.untrusted("dumpPath", String.valueOf(result.dumpPath()))));
reportPaths(s, result.restoredPaths(), BukkitRuntimeMessages.COMMAND_PACK_PATH_RESTORED);
return;
@@ -363,47 +363,79 @@ public class CommandPack implements DirectorExecutor {
return persisted == null || persisted.isBlank() ? "unknown" : persisted;
}
private PackValidationResult runValidate(VolmitSender s, File packFolder) {
PackValidationResult runValidate(VolmitSender s, File packFolder) {
PackValidationRegistry.ValidationTicket ticket =
PackValidationRegistry.tryBeginValidation(packFolder.toPath());
PackValidationResult result;
String contentFingerprint = "";
String contextFingerprint = "";
try {
PackValidationResult result = PackValidator.validate(packFolder);
PackValidationRegistry.publish(result);
reportResult(s, result);
return result;
if (ticket == null) {
throw new IllegalStateException("Pack content is currently being changed.");
}
contentFingerprint = PackFingerprints.computePackTreeFingerprint(packFolder);
contextFingerprint = PackValidationCache.contextFingerprint();
result = PackValidator.validate(packFolder);
if (!contentFingerprint.equals(PackFingerprints.computePackTreeFingerprint(packFolder))
|| !contextFingerprint.equals(PackValidationCache.contextFingerprint())) {
throw new IllegalStateException("Pack content or validation context changed during validation.");
}
} catch (Throwable e) {
Iris.reportError("Pack validation failed for '" + packFolder.getName() + "'", e);
String detail = e.getMessage() == null || e.getMessage().isBlank()
? e.getClass().getSimpleName()
: e.getMessage();
PackValidationResult result = new PackValidationResult(
result = new PackValidationResult(
packFolder.getName(),
List.of("Pack validation failed with " + e.getClass().getSimpleName() + ": " + detail),
List.of(),
System.currentTimeMillis());
PackValidationRegistry.publish(result);
reportResult(s, result);
return result;
contentFingerprint = "";
contextFingerprint = "";
}
if (PackValidationRegistry.publishIfCurrent(ticket, result, contentFingerprint, contextFingerprint)) {
PackValidationRegistry.publish(result);
} else {
result = new PackValidationResult(packFolder.getName(),
List.of("Pack content changed during validation."), List.of(), System.currentTimeMillis());
}
reportResult(s, result);
return result;
}
private void persistValidationCache(File packsRoot) {
List<File> packDirectories = PackDirectoryResolver.listVisiblePackDirectories(packsRoot);
List<PackValidationResult> results = new ArrayList<>(packDirectories.size());
for (File packDirectory : packDirectories) {
PackValidationResult result = PackValidationRegistry.get(packDirectory.getName());
if (result == null) {
// The boot cache only loads when it covers every pack, so a partial write is
// pointless - but say so instead of silently skipping the persist forever.
Iris.warn("Pack validation cache not written: \"" + packDirectory.getName()
+ "\" has no registered result. Run /iris pack validate all to repopulate it.");
try {
String contextFingerprint = PackValidationCache.contextFingerprint();
PackFingerprints.PackContentSnapshot snapshot = PackFingerprints.computePackContentSnapshot(packsRoot);
List<File> packDirectories = PackDirectoryResolver.listVisiblePackDirectories(packsRoot);
List<PackValidationResult> results = new ArrayList<>(packDirectories.size());
for (File packDirectory : packDirectories) {
PackValidationResult result = PackValidationRegistry.getMatching(
packDirectory.toPath(), snapshot.packContents().get(packDirectory.getName()));
if (result == null) {
Iris.warn("Pack validation cache not written: \"" + packDirectory.getName()
+ "\" has no validation matching its current content. Run /iris pack validate to refresh it.");
return;
}
results.add(result);
}
if (!snapshot.content().equals(PackFingerprints.computePackContentSnapshot(packsRoot).content())
|| !contextFingerprint.equals(PackValidationCache.contextFingerprint())) {
Iris.warn("Pack validation cache not written because pack content or validation context changed.");
return;
}
results.add(result);
}
try {
for (int index = 0; index < packDirectories.size(); index++) {
File packDirectory = packDirectories.get(index);
if (PackValidationRegistry.getMatching(packDirectory.toPath(),
snapshot.packContents().get(packDirectory.getName())) != results.get(index)) {
Iris.warn("Pack validation cache not written because validation changed during persistence.");
return;
}
}
PackValidationCache.save(
Iris.instance.getDataFile("cache", "pack-validation.json").toPath(),
PackValidationCache.contentFingerprint(packsRoot),
PackValidationCache.contextFingerprint(),
snapshot.content(),
contextFingerprint,
results);
} catch (IOException | RuntimeException e) {
Iris.reportError("Could not persist refreshed pack-validation results", e);
@@ -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;
}
double chunksPerSecond = registered.sampleChunksPerSecond(engine.getGenerated(), now);
worldSnapshots.add(EngineTelemetrySnapshot.capture(engine, chunksPerSecond, now));
double chunksPerSecond = registered.generationRate.sampleChunksPerSecond(engine.getGenerated(), now);
worldSnapshots.add(EngineTelemetrySnapshot.capture(
engine,
chunksPerSecond,
registered.generationRate.generationTimingsFresh(now),
now));
registered.clearMetricsFailure();
} catch (Throwable exception) {
if (EngineMaintenance.isMantleClosed(exception)) {
@@ -552,6 +556,7 @@ public final class IrisEngineSVC implements IrisService {
progress.generated(),
progress.totalChunks(),
progress.chunksRemaining(),
progress.chunksInFlight(),
progress.chunksPerSecond(),
progress.eta(),
progress.elapsed(),
@@ -612,8 +617,7 @@ public final class IrisEngineSVC implements IrisService {
private final AtomicReference<CompletableFuture<Void>> activeMaintenance = new AtomicReference<>();
private volatile ScheduledFuture<?> maintenance;
private volatile boolean closed;
private long lastGeneratedSampleAtMs;
private int lastGeneratedCount;
private final GenerationRateWindow generationRate = new GenerationRateWindow();
private Registered(Registration registration, ScheduledThreadPoolExecutor executor) {
name = registration.name();
@@ -696,19 +700,6 @@ public final class IrisEngineSVC implements IrisService {
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() {
if (closed) {
return;
@@ -101,6 +101,7 @@ public record IrisTelemetrySnapshot(
long generated,
long total,
long remaining,
long inFlight,
double chunksPerSecond,
long etaMs,
long elapsedMs,
@@ -115,6 +116,7 @@ public record IrisTelemetrySnapshot(
0L,
0L,
0L,
0L,
0D,
0L,
0L,
@@ -130,6 +132,7 @@ public record IrisTelemetrySnapshot(
generated = Math.max(0L, generated);
total = Math.max(0L, total);
remaining = Math.max(0L, remaining);
inFlight = Math.max(0L, inFlight);
chunksPerSecond = Double.isFinite(chunksPerSecond) ? Math.max(0D, chunksPerSecond) : 0D;
etaMs = Math.max(0L, etaMs);
elapsedMs = Math.max(0L, elapsedMs);
@@ -6,6 +6,7 @@ import art.arcane.iris.generation.runtime.IrisTelemetrySnapshot;
import art.arcane.iris.Iris;
import art.arcane.iris.generation.runtime.EngineTelemetrySnapshot;
import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.world.task.J;
import art.arcane.iris.platform.bukkit.plugin.IrisService;
import art.arcane.volmlib.integration.IntegrationHandshakeRequest;
import art.arcane.volmlib.integration.IntegrationHandshakeResponse;
@@ -13,6 +14,9 @@ import art.arcane.volmlib.integration.IntegrationHeartbeat;
import art.arcane.volmlib.integration.IntegrationMetricDescriptor;
import art.arcane.volmlib.integration.IntegrationMetricGroup;
import art.arcane.volmlib.integration.IntegrationMetricSample;
import art.arcane.volmlib.integration.IntegrationMetricPublisher;
import art.arcane.volmlib.integration.IntegrationMetricSnapshot;
import art.arcane.volmlib.integration.IntegrationSnapshotProvider;
import art.arcane.volmlib.integration.IntegrationMetricSchema;
import art.arcane.volmlib.integration.IntegrationProtocolNegotiator;
import art.arcane.volmlib.integration.IntegrationProtocolVersion;
@@ -30,7 +34,7 @@ import java.util.Optional;
import java.util.Set;
import java.util.function.Supplier;
public class IrisIntegrationService implements IrisService, IntegrationServiceContract {
public class IrisIntegrationService implements IrisService, IntegrationSnapshotProvider {
private static final IntegrationProtocolVersion CURRENT_PROTOCOL = new IntegrationProtocolVersion(1, 2);
private static final Set<IntegrationProtocolVersion> SUPPORTED_PROTOCOLS = Set.of(
new IntegrationProtocolVersion(1, 0),
@@ -41,6 +45,7 @@ public class IrisIntegrationService implements IrisService, IntegrationServiceCo
"handshake",
"heartbeat",
"metrics",
IntegrationSnapshotProvider.CAPABILITY,
"metric-groups",
"iris-engine-metrics",
"iris-world-metrics"
@@ -63,6 +68,9 @@ public class IrisIntegrationService implements IrisService, IntegrationServiceCo
);
private final Supplier<IrisTelemetrySnapshot> telemetrySupplier;
private final IntegrationMetricPublisher snapshots = new IntegrationMetricPublisher(
IntegrationMetricSchema.irisKeys().size(), 30_000L);
private int snapshotTaskId = -1;
public IrisIntegrationService() {
this(IrisIntegrationService::currentTelemetry);
@@ -77,11 +85,17 @@ public class IrisIntegrationService implements IrisService, IntegrationServiceCo
@Override
public void onEnable() {
Bukkit.getServicesManager().register(IntegrationServiceContract.class, this, Iris.instance, ServicePriority.Normal);
snapshotTaskId = J.ar(this::publishSnapshots, 20);
Iris.verbose("Integration provider registered for Iris");
}
@Override
public void onDisable() {
if (snapshotTaskId != -1) {
J.car(snapshotTaskId);
snapshotTaskId = -1;
}
snapshots.clear();
Bukkit.getServicesManager().unregister(IntegrationServiceContract.class, this);
}
@@ -188,6 +202,20 @@ public class IrisIntegrationService implements IrisService, IntegrationServiceCo
return Map.copyOf(selected);
}
@Override
public IntegrationMetricSnapshot snapshotMetrics(Set<String> metricKeys) {
return snapshots.snapshotMetrics(metricKeys, System.currentTimeMillis());
}
void publishSnapshots() {
IntegrationMetricPublisher.Demand demand = snapshots.demandedKeys(System.currentTimeMillis());
if (demand.keys().isEmpty()) {
return;
}
Map<String, IntegrationMetricSample> samples = sampleMetrics(demand.keys());
snapshots.publish(demand, System.currentTimeMillis(), samples);
}
@Override
public List<IntegrationMetricGroup> metricGroups() {
IrisTelemetrySnapshot snapshot = safeSnapshot();
@@ -319,6 +347,7 @@ public class IrisIntegrationService implements IrisService, IntegrationServiceCo
IntegrationMetricSchema.IRIS_PREGEN_GENERATED,
IntegrationMetricSchema.IRIS_PREGEN_TOTAL,
IntegrationMetricSchema.IRIS_PREGEN_QUEUE,
IntegrationMetricSchema.IRIS_PREGEN_REMAINING,
IntegrationMetricSchema.IRIS_PREGEN_THROUGHPUT,
IntegrationMetricSchema.IRIS_PREGEN_ETA_MS,
IntegrationMetricSchema.IRIS_PREGEN_ELAPSED_MS,
@@ -332,7 +361,8 @@ public class IrisIntegrationService implements IrisService, IntegrationServiceCo
put(samples, IntegrationMetricSchema.IRIS_PREGEN_PROGRESS, pregenerator.progressPercent(), sampledAtMs);
put(samples, IntegrationMetricSchema.IRIS_PREGEN_GENERATED, pregenerator.generated(), 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_ETA_MS, pregenerator.etaMs(), sampledAtMs);
put(samples, IntegrationMetricSchema.IRIS_PREGEN_ELAPSED_MS, pregenerator.elapsedMs(), sampledAtMs);
@@ -0,0 +1,37 @@
package art.arcane.iris.update;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.volmlib.util.update.BukkitUpdateService;
import org.bukkit.plugin.Plugin;
import java.util.function.Consumer;
public final class IrisUpdateNotifier implements AutoCloseable {
private final Consumer<IrisSettings> settingsListener = this::reconfigure;
private final BukkitUpdateService updates;
private volatile boolean enabled;
private boolean closed;
public IrisUpdateNotifier(Plugin plugin) {
enabled = IrisSettings.get().getGeneral().isUpdateNotifications();
updates = BukkitUpdateService.register(plugin, new BukkitUpdateService.Options(
"VolmitSoftware", "Iris", "iris.update", () -> enabled));
IrisSettings.addSettingsListener(settingsListener);
reconfigure(IrisSettings.get());
}
private synchronized void reconfigure(IrisSettings settings) {
if (closed) {
return;
}
enabled = settings.getGeneral().isUpdateNotifications();
updates.reconfigure();
}
@Override
public synchronized void close() {
closed = true;
IrisSettings.removeSettingsListener(settingsListener);
updates.close();
}
}
@@ -18,8 +18,9 @@
package art.arcane.iris.world;
import art.arcane.iris.pack.PackFingerprints;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.pack.datapack.ServerConfigurator;
import art.arcane.iris.Iris;
import art.arcane.iris.world.lifecycle.WorldLifecycleStaging;
@@ -184,12 +185,12 @@ public final class IrisWorldGeneratorResolver {
}
List<String> packNames = packDirs.stream().map(File::getName).sorted().toList();
Path cacheFile = IrisPlatforms.get().dataFile("cache", "pack-validation.json").toPath();
ServerConfigurator.PackContentSnapshot contentSnapshot =
new ServerConfigurator.PackContentSnapshot("", Map.of());
PackFingerprints.PackContentSnapshot contentSnapshot =
new PackFingerprints.PackContentSnapshot("", Map.of());
String contextFingerprint = "";
Optional<List<PackValidationResult>> cached = Optional.empty();
try {
contentSnapshot = ServerConfigurator.computePackContentSnapshot(packsRoot);
contentSnapshot = PackFingerprints.computePackContentSnapshot(packsRoot);
contextFingerprint = PackValidationCache.contextFingerprint();
if (!externalContentChanged) {
cached = PackValidationCache.load(
@@ -274,19 +275,19 @@ public final class IrisWorldGeneratorResolver {
private static FreshValidation validateStablePacks(
File packsRoot,
List<File> initialPackDirs,
ServerConfigurator.PackContentSnapshot initialSnapshot
PackFingerprints.PackContentSnapshot initialSnapshot
) {
List<File> packDirs = initialPackDirs;
ServerConfigurator.PackContentSnapshot before = initialSnapshot;
PackFingerprints.PackContentSnapshot before = initialSnapshot;
for (int attempt = 0; attempt < VALIDATION_STABILITY_ATTEMPTS; attempt++) {
List<String> packNames = packDirs.stream().map(File::getName).sorted().toList();
List<PackValidationResult> results = validatePacks(packDirs);
ServerConfigurator.PackContentSnapshot after;
PackFingerprints.PackContentSnapshot after;
try {
after = ServerConfigurator.computePackContentSnapshot(packsRoot);
after = PackFingerprints.computePackContentSnapshot(packsRoot);
} catch (RuntimeException exception) {
Iris.reportError("Could not verify Iris pack bytes after validation", exception);
after = new ServerConfigurator.PackContentSnapshot("", Map.of());
after = new PackFingerprints.PackContentSnapshot("", Map.of());
}
List<File> afterPackDirs = PackDirectoryResolver.listVisiblePackDirectories(packsRoot);
List<String> afterPackNames = afterPackDirs.stream().map(File::getName).sorted().toList();
@@ -923,7 +924,7 @@ public final class IrisWorldGeneratorResolver {
private record FreshValidation(
List<File> packDirs,
ServerConfigurator.PackContentSnapshot contentSnapshot,
PackFingerprints.PackContentSnapshot contentSnapshot,
List<PackValidationResult> results,
boolean stable
) {
@@ -3,19 +3,33 @@ package art.arcane.iris.world.tree;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.bukkit.event.block.BlockBreakEvent;
import org.bukkit.inventory.ItemStack;
import java.util.Collection;
import java.util.Objects;
final class RoutedBlockBreakEvent extends BlockBreakEvent implements BlockDropRouter {
private final TreeFellerSVC service;
private final FellingRun run;
private final ItemStack tool;
RoutedBlockBreakEvent(Block block, Player player, FellingRun run, TreeFellerSVC service) {
super(block, player);
this.run = run;
this.service = service;
RoutedBlockBreakEvent(Options options) {
super(options.block(), options.player());
run = options.run();
service = options.service();
tool = Objects.requireNonNull(options.tool(), "tool").clone();
}
@Override
public Collection<ItemStack> captureNativeDrops() {
return getBlock().getDrops(tool);
}
@Override
public boolean routeDrop(Object drop) {
return service.isServiceEnabled() && run.presentation.routeDrop(drop);
}
record Options(Block block, Player player, FellingRun run, TreeFellerSVC service, ItemStack tool) {
}
}
@@ -387,7 +387,8 @@ final class TreeFellingRunner {
return;
}
BlockBreakEvent probe = new RoutedBlockBreakEvent(block, run.candidate.player(), run, service);
BlockBreakEvent probe = new RoutedBlockBreakEvent(new RoutedBlockBreakEvent.Options(
block, run.candidate.player(), run, service, reservation.toolForDrops()));
service.managedEvents.add(probe);
try {
Bukkit.getPluginManager().callEvent(probe);
@@ -10,6 +10,9 @@ authors: [ cyberpwn, NextdoorPsycho, Vatuu ]
website: VolmitSoftware.com
description: More than a Dimension!
permissions:
iris.update:
description: Allows viewing Iris updates in VolmLib's combined update reports.
default: op
iris.debugdump:
description: Allows creating and optionally uploading diagnostic reports.
default: op
@@ -25,34 +28,26 @@ permissions:
dependencies:
server:
PlaceholderAPI:
load: BEFORE
load: OMIT
required: false
join-classpath: true
CraftEngine:
load: BEFORE
load: OMIT
required: false
join-classpath: true
Nexo:
load: BEFORE
load: OMIT
required: false
join-classpath: true
Oraxen:
load: BEFORE
load: OMIT
required: false
join-classpath: true
ItemsAdder:
load: BEFORE
load: OMIT
required: false
join-classpath: true
SCore:
load: BEFORE
required: false
join-classpath: true
ExecutableItems:
load: BEFORE
required: false
join-classpath: false
MythicLib:
load: OMIT
required: false
join-classpath: true
@@ -61,11 +56,7 @@ dependencies:
required: false
join-classpath: true
eco:
load: BEFORE
required: false
join-classpath: true
EcoItems:
load: BEFORE
load: OMIT
required: false
join-classpath: true
MythicMobs:
@@ -77,7 +68,7 @@ dependencies:
required: false
join-classpath: true
KGenerators:
load: BEFORE
load: OMIT
required: false
join-classpath: true
Multiverse-Core:
@@ -85,6 +76,6 @@ dependencies:
required: false
join-classpath: true
WorldEdit:
load: BEFORE
load: OMIT
required: false
join-classpath: true
@@ -3,18 +3,6 @@ version: ${version}
main: ${main}
folia-supported: true
load: STARTUP
softdepend:
- PlaceholderAPI
- CraftEngine
- Nexo
- Oraxen
- ItemsAdder
- SCore
- ExecutableItems
- eco
- EcoItems
- KGenerators
- WorldEdit
loadbefore: [ Multiverse-Core ]
authors: [ cyberpwn, NextdoorPsycho, Vatuu ]
website: VolmitSoftware.com
@@ -23,6 +11,9 @@ commands:
iris:
aliases: [ ir, irs ]
permissions:
iris.update:
description: Allows viewing Iris updates in VolmLib's combined update reports.
default: op
iris.debugdump:
description: Allows creating and optionally uploading diagnostic reports.
default: op
@@ -0,0 +1,47 @@
{
"name": "Flat",
"derivative": "minecraft:plains",
"vanillaDerivative": "minecraft:plains",
"layers": [
{
"minHeight": 1,
"maxHeight": 1,
"palette": [
{
"block": "minecraft:lime_wool"
}
]
},
{
"minHeight": 384,
"maxHeight": 384,
"palette": [
{
"block": "minecraft:green_concrete"
}
]
}
],
"generators": [
{
"generator": "flat",
"min": 96,
"max": 96
}
],
"decorators": [
{
"palette": [
{
"block": "minecraft:oak_stairs",
"data": {
"half": "bottom",
"facing": "north"
}
}
],
"chance": 1,
"forcePlace": true
}
]
}
@@ -0,0 +1,28 @@
{
"name": "Decorator Fixture",
"version": 1,
"mode": {
"type": "OVERWORLD"
},
"regions": [
"flat"
],
"focusRegion": "flat",
"focus": "flat",
"landChance": 1,
"environment": "NORMAL",
"dimensionHeight": {
"min": -64,
"max": 320
},
"logicalHeight": 384,
"fluidHeight": 0,
"bedrock": false,
"rockPalette": {
"palette": [
{
"block": "minecraft:red_concrete"
}
]
}
}
@@ -0,0 +1,15 @@
{
"interpolator": {
"function": "NONE",
"horizontalScale": 1
},
"seed": 310,
"composite": [
{
"seed": 310,
"style": {
"style": "FLAT"
}
}
]
}
@@ -0,0 +1,12 @@
{
"name": "Flat",
"landBiomes": [
"flat"
],
"seaBiomes": [
"flat"
],
"shoreBiomes": [
"flat"
]
}
@@ -0,0 +1,40 @@
import { readFile } from 'node:fs/promises'
import path from 'node:path'
export default {
name: 'iris-decorators',
description: 'Verify authored stair decorations occupy one block and retain their half state.',
async run(context) {
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
context.expect(/^isolated=true\s*$/m.test(source), 'Decorator generation requires an isolated instance')
await context.step('create a world with authored stair decorations', async () => {
const response = await context.command('/iris create name=decorator_probe type=decorator-fixture seed=78264193',
/Successfully created your world|Failed to create/, 180000)
context.expect(response.includes('Successfully created your world'), response)
})
await context.step('client sees single bottom-half stair decorations', async () => {
let checked = 0
await context.waitUntil(() => {
checked = 0
const center = context.bot.entity.position.floored()
for (let x = -6; x <= 6; x++) {
for (let z = -6; z <= 6; z++) {
for (let y = -4; y <= 1; y++) {
const position = center.offset(x, y, z)
if (context.bot.blockAt(position)?.name !== 'lime_wool') continue
const decoration = context.bot.blockAt(position.offset(0, 1, 0))
const above = context.bot.blockAt(position.offset(0, 2, 0))
if (!decoration || !above) return false
if (decoration.name !== 'oak_stairs' || decoration.getProperties().half !== 'bottom'
|| above.name !== 'air') return false
checked++
}
}
}
return checked >= 100
}, { label: 'At least 100 authored stair columns with empty space above', timeoutMs: 60000, intervalMs: 250 })
context.expect(checked >= 100, 'Client verified generated decorator columns', { checked })
context.report.decoratorColumns = checked
})
}
}
@@ -4,6 +4,13 @@ import art.arcane.volmlib.nativelib.NativeAdapters;
import art.arcane.iris.world.runtime.RuntimeInjection;
import art.arcane.iris.world.IrisCreator;
import art.arcane.iris.world.IrisToolbelt;
import art.arcane.iris.world.IrisWorldStorage;
import art.arcane.iris.world.WorldCreatorCompat;
import art.arcane.iris.world.lifecycle.LifecycleOperationCoordinator;
import art.arcane.iris.platform.bootstrap.ServerProperties;
import org.bukkit.Location;
import org.bukkit.entity.Player;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.Material;
import org.bukkit.block.Biome;
import art.arcane.volmlib.nativelib.terrain.NativeTerrainAccess;
@@ -19,6 +26,10 @@ import org.bukkit.command.CommandSender;
import org.bukkit.generator.ChunkGenerator;
import org.bukkit.plugin.java.JavaPlugin;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.util.concurrent.CompletableFuture;
import java.util.List;
import java.util.logging.Level;
@@ -26,6 +37,7 @@ import java.util.logging.Level;
public final class NativeLifecycleFixture extends JavaPlugin implements WorldRuntimeExecution {
private static final NamespacedKey WORLD_KEY = NamespacedKey.minecraft("native_lifecycle_probe");
private static final NamespacedKey TERRAIN_KEY = new NamespacedKey("iris", "native_terrain_probe");
private static final NamespacedKey COMMAND_KEY = new NamespacedKey("iris", "lifecycle_probe");
private static final long WORLD_SEED = 78264193L;
private NativeWorldRuntime runtime;
@@ -45,7 +57,7 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (arguments.length != 1) {
sender.sendMessage("Use /irislifecycle create|unload|status|structures|terrain-create|terrain-check|terrain-unload");
sender.sendMessage("Use /irislifecycle create|unload|status|structures|terrain-create|terrain-check|terrain-unload|lifecycle-status|lifecycle-entry");
return true;
}
try {
@@ -56,6 +68,8 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
case "terrain-create" -> createTerrain(sender);
case "terrain-check" -> checkTerrain(sender);
case "terrain-unload" -> unloadTerrain(sender);
case "lifecycle-status" -> lifecycleStatus(sender);
case "lifecycle-entry" -> lifecycleEntry(sender);
case "status" -> sender.sendMessage("NATIVE_STATUS available=" + runtime.available()
+ " loaded=" + (Bukkit.getWorld(WORLD_KEY) != null));
default -> throw new IllegalArgumentException("Unknown lifecycle operation");
@@ -66,6 +80,61 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
return true;
}
private void lifecycleStatus(CommandSender sender) throws Exception {
World world = Bukkit.getWorld(COMMAND_KEY);
Path storage = WorldCreatorCompat.persistentLevelRoot(COMMAND_KEY).toPath();
Path levelRoot = IrisWorldStorage.levelRoot().toPath();
Path registry = levelRoot.resolve("iris/worlds.json");
boolean registered = Files.exists(registry)
&& Files.readString(registry).contains("\"" + COMMAND_KEY + "\"");
String startupName = IrisWorldStorage.configuredWorldName(COMMAND_KEY, levelRoot.getFileName().toString());
YamlConfiguration configuration = YamlConfiguration.loadConfiguration(ServerProperties.BUKKIT_YML);
boolean configured = configuration.getConfigurationSection("worlds") != null
&& configuration.getConfigurationSection("worlds").contains(startupName);
File quarantineRoot = levelRoot.resolve("dimensions/iris").toFile();
File[] quarantines = quarantineRoot.listFiles((directory, name) -> name.startsWith(".iris-delete-"));
int quarantineCount = quarantines == null ? 0 : quarantines.length;
Path queueFile = Bukkit.getPluginManager().getPlugin("Iris").getDataFolder().toPath()
.resolve("pending-world-deletes.txt");
List<String> queue = Files.exists(queueFile) ? Files.readAllLines(queueFile) : List.of();
boolean queued = quarantineCount > 0;
if (quarantines != null) {
for (File quarantine : quarantines) {
queued &= queue.contains(quarantine.getName());
}
}
boolean disk = Files.exists(storage, LinkOption.NOFOLLOW_LINKS);
String cleanup = disk ? "present" : quarantineCount == 0 ? "deleted" : queued ? "queued" : "unsafe";
boolean idle = true;
LifecycleOperationCoordinator coordinator = LifecycleOperationCoordinator.get();
for (LifecycleOperationCoordinator.Domain domain : LifecycleOperationCoordinator.Domain.values()) {
idle &= coordinator.active(domain).isEmpty();
}
sender.sendMessage("IRIS_LIFECYCLE loaded=" + (world != null) + " registered=" + registered
+ " configured=" + configured + " disk=" + disk + " quarantines=" + quarantineCount
+ " cleanup=" + cleanup + " idle=" + idle);
}
private void lifecycleEntry(CommandSender sender) {
if (!(sender instanceof Player player)) {
throw new IllegalArgumentException("Lifecycle entry requires a player");
}
World world = Bukkit.getWorld(COMMAND_KEY);
if (world == null || player.getWorld() != world || world.getSeed() != WORLD_SEED
|| !IrisToolbelt.isIrisWorld(world)) {
throw new IllegalStateException("Player did not enter the requested seeded Iris world");
}
Location feet = player.getLocation();
Material floor = world.getBlockAt(feet.getBlockX(), feet.getBlockY() - 1, feet.getBlockZ()).getType();
if (floor != Material.LIME_WOOL || !floor.isSolid()
|| !world.getBlockAt(feet.getBlockX(), feet.getBlockY(), feet.getBlockZ()).isPassable()
|| !world.getBlockAt(feet.getBlockX(), feet.getBlockY() + 1, feet.getBlockZ()).isPassable()) {
throw new IllegalStateException("Lifecycle player entry lacks a safe fixture floor");
}
sender.sendMessage("IRIS_LIFECYCLE_ENTRY world=" + COMMAND_KEY + " seed=" + world.getSeed()
+ " floor=lime_wool safe=true");
}
private void create(CommandSender sender) throws ReflectiveOperationException {
if (!runtime.available() || Bukkit.getWorld(WORLD_KEY) != null) {
throw new IllegalStateException("Native runtime unavailable or probe world already loaded");
@@ -124,6 +193,7 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
int y = world.getHighestBlockYAt(x, z);
if (y < 90 || y > 100 || world.getBlockAt(x, y, z).getType() != Material.LIME_WOOL
|| world.getBlockAt(x, y - 3, z).getType() != Material.GREEN_CONCRETE
|| world.getBlockAt(x, world.getMinHeight(), z).getType() != Material.GREEN_CONCRETE
|| world.getBiome(x, y, z) != Biome.PLAINS) {
throw new IllegalStateException("Terrain column differs at " + x + "," + y + "," + z
+ ": " + world.getBlockAt(x, y, z).getType());
@@ -11,6 +11,15 @@
"block": "minecraft:lime_wool"
}
]
},
{
"minHeight": 384,
"maxHeight": 384,
"palette": [
{
"block": "minecraft:green_concrete"
}
]
}
],
"generators": [
@@ -21,7 +21,7 @@
"rockPalette": {
"palette": [
{
"block": "minecraft:green_concrete"
"block": "minecraft:red_concrete"
}
]
}
@@ -0,0 +1,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,71 @@
import { readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { setup, openStudio, closeStudio, sampleTerrain, readJson, writeJson,
logPosition, logSince } from './studio-support.mjs'
export default {
name: 'iris-studio-hotload-lifecycle',
description: 'Generate cold chunks during Studio edits and close after saving an edit.',
async run(context) {
const fixture = await setup(context)
const biomeFile = path.join(fixture.pack, 'biomes', 'flat.json')
const original = await readFile(biomeFile, 'utf8')
const logOffset = await logPosition(fixture)
let active
let x = 16384
try {
active = await openStudio(context, fixture)
await sampleTerrain(context, fixture, 0, fixture.state.material)
for (let cycle = 0; cycle < 3; cycle++) {
await context.step(`Load cold chunks during hotload ${cycle + 1}`, async () => {
const manifestFile = fixture.manifestPath(active.key)
const before = await readJson(manifestFile)
const biome = await readJson(biomeFile)
const material = cycle % 2 === 0 ? 'gold_block' : 'diamond_block'
biome.name = `Lifecycle terrain ${cycle + 1}`
biome.layers[0].palette[0].block = `minecraft:${material}`
const startedAt = Date.now()
await writeJson(biomeFile, biome)
x += 512
await context.command(`/minecraft:tp @s ${x + 0.5} 101 0.5`, /Teleported/i, 15000)
const after = await fixture.wait(async () => {
const manifest = await readJson(manifestFile)
return manifest.activeActivationId !== before.activeActivationId ? manifest : false
}, 'Hotload did not complete while the player requested fresh chunks', 90000)
const activationDurationMs = Date.now() - startedAt
await fixture.wait(() => Math.abs(context.bot.entity.position.x - x - 0.5) < 1,
'Cold chunk teleport did not arrive after hotload', 30000)
context.report.iris.activations.push({ before: before.activeActivationId,
after: after.activeActivationId, material, coldChunkRequests: 1, activationDurationMs })
x += 2048
await sampleTerrain(context, fixture, x, material)
})
}
await context.step('Close Studio while a saved edit is activating', async () => {
const biome = await readJson(biomeFile)
biome.name = 'Lifecycle close terrain'
await writeJson(biomeFile, biome)
const pending = await fixture.wait(async () => {
const manifest = await readJson(fixture.manifestPath(active.key))
return manifest.pendingActivationId !== null ? manifest : false
}, 'The saved close edit did not enter activation', 90000)
context.report.iris.closePendingActivationId = pending.pendingActivationId
await closeStudio(context, fixture, active)
active = undefined
await context.command('/minecraft:seed', /Seed:/i, 10000)
})
await context.step('Reopen the edited pack and retain usable generation', async () => {
active = await openStudio(context, fixture)
await sampleTerrain(context, fixture, 0, 'gold_block')
await closeStudio(context, fixture, active)
active = undefined
})
const log = await logSince(fixture, logOffset)
context.expect(!/Studio generation update failed|Unable to (capture terrain|save the terrain boundary)|ClosedChannelException|Chunk system error|server has stopped responding|could not acquire its engine runtime|Failed to list files/i.test(log),
'Studio lifecycle emitted a generation, snapshot, storage, or watchdog failure')
} finally {
if (active) await closeStudio(context, fixture, active)
await writeFile(biomeFile, original)
}
}
}
@@ -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)
})
}
}
}
@@ -20,33 +20,20 @@ import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
public class PaperPluginMetadataTest {
private static final List<String> OPTIONAL_PLUGIN_IDS = List.of(
private static final List<String> JOINED_PLUGIN_IDS = List.of(
"PlaceholderAPI",
"CraftEngine",
"Nexo",
"Oraxen",
"ItemsAdder",
"SCore",
"ExecutableItems",
"MythicLib",
"MMOItems",
"eco",
"EcoItems",
"MythicMobs",
"MythicCrucible",
"KGenerators",
"Multiverse-Core",
"WorldEdit"
);
private static final List<String> JOINED_PLUGIN_IDS = OPTIONAL_PLUGIN_IDS.stream()
.filter(pluginId -> !"ExecutableItems".equals(pluginId) && !"Multiverse-Core".equals(pluginId))
.toList();
private static final List<String> UNORDERED_PLUGIN_IDS = List.of(
"MythicLib", "MMOItems", "MythicMobs", "MythicCrucible");
private static final List<String> BUKKIT_SOFT_DEPEND_IDS = OPTIONAL_PLUGIN_IDS.stream()
.filter(pluginId -> !"Multiverse-Core".equals(pluginId))
.filter(pluginId -> !UNORDERED_PLUGIN_IDS.contains(pluginId))
.toList();
@Test
public void paperMetadataDeclaresBootstrapAndFoliaSupport() throws Exception {
@@ -75,10 +62,13 @@ public class PaperPluginMetadataTest {
+ " description: Allows survival players to fell Iris-managed trees with an axe.\n"
+ " default: op"));
for (String pluginId : JOINED_PLUGIN_IDS) {
String loadOrder = UNORDERED_PLUGIN_IDS.contains(pluginId) ? "OMIT" : "BEFORE";
assertTrue(metadata.contains(optionalDependencyBlock(pluginId, loadOrder, true)));
assertTrue(metadata.contains(optionalDependencyBlock(pluginId, "OMIT", true)));
}
assertTrue(metadata.contains(optionalDependencyBlock("ExecutableItems", "BEFORE", false)));
assertFalse(metadata.contains(" ExecutableItems:"));
assertFalse(metadata.contains(" EcoItems:"));
assertFalse(metadata.contains(" MythicLib:"));
assertEquals(1, metadata.lines().filter(line -> line.equals(" load: AFTER")).count());
assertEquals(0, metadata.lines().filter(line -> line.equals(" load: BEFORE")).count());
assertTrue(metadata.contains(optionalDependencyBlock("Multiverse-Core", "AFTER", true)));
}
@@ -95,7 +85,7 @@ public class PaperPluginMetadataTest {
Map<String, Map<String, Object>> commands = metadata.getCommands();
assertTrue(commands.containsKey("iris"));
assertEquals(List.of("ir", "irs"), commands.get("iris").get("aliases"));
assertEquals(BUKKIT_SOFT_DEPEND_IDS, metadata.getSoftDepend());
assertTrue(metadata.getSoftDepend().isEmpty());
assertEquals(List.of("Multiverse-Core"), metadata.getLoadBeforePlugins());
Permission all = metadata.getPermissions().stream()
.filter(permission -> "iris.all".equals(permission.getName()))
@@ -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));
}
}
@@ -5,19 +5,66 @@ import art.arcane.iris.generation.runtime.IrisTelemetrySnapshot;
import art.arcane.iris.generation.runtime.EngineTelemetrySnapshot;
import art.arcane.volmlib.integration.IntegrationMetricGroup;
import art.arcane.volmlib.integration.IntegrationMetricSample;
import art.arcane.volmlib.integration.IntegrationMetricSnapshot;
import art.arcane.volmlib.integration.IntegrationSnapshotProvider;
import art.arcane.volmlib.integration.IntegrationMetricSchema;
import org.junit.Test;
import org.bukkit.Bukkit;
import org.bukkit.plugin.ServicesManager;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class IrisIntegrationServiceTest {
@Test
public void disableClearsPublishedSnapshots() {
IrisIntegrationService service = new IrisIntegrationService(() -> IrisTelemetrySnapshot.EMPTY);
Set<String> keys = Set.of(IntegrationMetricSchema.IRIS_WORLD_COUNT);
service.snapshotMetrics(keys);
service.publishSnapshots();
long generation = service.snapshotMetrics(keys).generation();
try (MockedStatic<Bukkit> bukkit = Mockito.mockStatic(Bukkit.class)) {
bukkit.when(Bukkit::getServicesManager).thenReturn(Mockito.mock(ServicesManager.class));
service.onDisable();
}
assertTrue(service.snapshotMetrics(keys).samples().isEmpty());
assertTrue(service.snapshotMetrics(keys).generation() > generation);
}
@Test
public void snapshotRequestsDoNotCollectAndKeepOriginalTelemetryTime() {
long sampledAt = System.currentTimeMillis() - 5_000L;
IrisTelemetrySnapshot source = telemetry(List.of(), IrisTelemetrySnapshot.PregenSnapshot.INACTIVE, sampledAt);
AtomicInteger reads = new AtomicInteger();
IrisIntegrationService service = new IrisIntegrationService(() -> {
reads.incrementAndGet();
return source;
});
Set<String> keys = Set.of(IntegrationMetricSchema.IRIS_WORLD_COUNT);
assertTrue(service.capabilities().contains(IntegrationSnapshotProvider.CAPABILITY));
service.publishSnapshots();
assertEquals(0, reads.get());
assertTrue(service.snapshotMetrics(keys).samples().isEmpty());
assertEquals(0, reads.get());
service.publishSnapshots();
IntegrationMetricSnapshot publication = service.snapshotMetrics(keys);
assertEquals(1, reads.get());
assertEquals(sampledAt, publication.samples().get(IntegrationMetricSchema.IRIS_WORLD_COUNT).sampledAtMs());
assertTrue(publication.capturedAtMs() >= sampledAt);
assertEquals(publication, service.snapshotMetrics(keys));
assertEquals(1, reads.get());
}
@Test
public void unavailableTelemetryDoesNotPublishFalseZeroes() {
IrisIntegrationService service = new IrisIntegrationService(() -> IrisTelemetrySnapshot.EMPTY);
@@ -49,6 +96,7 @@ public class IrisIntegrationServiceTest {
100L,
200L,
100L,
48L,
8D,
2_000L,
5_000L,
@@ -64,6 +112,8 @@ public class IrisIntegrationServiceTest {
IntegrationMetricSchema.IRIS_LOADED_CHUNKS,
IntegrationMetricSchema.IRIS_CHUNKS_GENERATED_TOTAL,
IntegrationMetricSchema.IRIS_PREGEN_THROUGHPUT,
IntegrationMetricSchema.IRIS_PREGEN_QUEUE,
IntegrationMetricSchema.IRIS_PREGEN_REMAINING,
IntegrationMetricSchema.IRIS_GENERATION_TOTAL_MS,
"iris.unsupported"
));
@@ -73,6 +123,8 @@ public class IrisIntegrationServiceTest {
assertEquals(20D, value(samples, IntegrationMetricSchema.IRIS_LOADED_CHUNKS), 0D);
assertEquals(50D, value(samples, IntegrationMetricSchema.IRIS_CHUNKS_GENERATED_TOTAL), 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);
assertFalse(samples.get("iris.unsupported").available());
assertEquals("unsupported-key", samples.get("iris.unsupported").message());
@@ -84,9 +136,12 @@ public class IrisIntegrationServiceTest {
assertEquals("minecraft:overworld", overworld.scopeId());
assertEquals(1D, value(overworld.samples(), IntegrationMetricSchema.IRIS_PREGEN_ACTIVE), 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(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_REMAINING).available());
}
@Test
@@ -0,0 +1,103 @@
package art.arcane.iris.integration;
import art.arcane.iris.Iris;
import art.arcane.volmlib.util.bukkit.papi.PlaceholderRegistration;
import org.bukkit.Bukkit;
import org.bukkit.event.HandlerList;
import org.bukkit.event.server.PluginDisableEvent;
import org.bukkit.event.server.PluginEnableEvent;
import org.bukkit.plugin.Plugin;
import org.bukkit.plugin.PluginManager;
import org.junit.Test;
import org.mockito.MockedConstruction;
import org.mockito.MockedStatic;
import java.lang.reflect.Field;
import java.util.logging.Logger;
import static org.junit.Assert.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doCallRealMethod;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockConstruction;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class IrisPapiLifecycleTest {
@Test
public void lateEnableRegistersOnceAndReattachesAfterDisable() throws Exception {
Iris iris = plugin(true);
Plugin placeholders = namedPlugin("PlaceholderAPI");
PluginManager plugins = mock(PluginManager.class);
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
MockedStatic<HandlerList> handlers = mockStatic(HandlerList.class);
MockedStatic<PlaceholderRegistration> availability = mockStatic(PlaceholderRegistration.class);
MockedStatic<IrisPapiInstaller> installer = mockStatic(IrisPapiInstaller.class);
MockedConstruction<PlaceholderRegistration> registrations = mockConstruction(PlaceholderRegistration.class)) {
bukkit.when(Bukkit::getPluginManager).thenReturn(plugins);
availability.when(PlaceholderRegistration::isPlaceholderApiEnabled).thenReturn(true);
installer.when(() -> IrisPapiInstaller.install(any(), any(), any())).thenReturn(true);
iris.onPlaceholderApiEnable(new PluginEnableEvent(placeholders));
iris.onPlaceholderApiEnable(new PluginEnableEvent(placeholders));
assertEquals(1, registrations.constructed().size());
verify(plugins).registerEvents(any(IrisPapiListener.class), eq(iris));
iris.onPlaceholderApiDisable(new PluginDisableEvent(namedPlugin("Unrelated")));
verify(registrations.constructed().getFirst(), never()).unregister();
iris.onPlaceholderApiDisable(new PluginDisableEvent(placeholders));
verify(registrations.constructed().getFirst()).unregister();
handlers.verify(() -> HandlerList.unregisterAll(any(IrisPapiListener.class)));
iris.onPlaceholderApiEnable(new PluginEnableEvent(placeholders));
assertEquals(2, registrations.constructed().size());
verify(plugins, times(2)).registerEvents(any(IrisPapiListener.class), eq(iris));
}
}
@Test
public void enableEventsCannotRegisterBeforeIrisIsReadyOrDuringShutdown() throws Exception {
Iris iris = plugin(false);
Plugin placeholders = namedPlugin("PlaceholderAPI");
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
MockedStatic<IrisPapiInstaller> installer = mockStatic(IrisPapiInstaller.class)) {
iris.onPlaceholderApiEnable(new PluginEnableEvent(placeholders));
installer.verifyNoInteractions();
}
}
@Test
public void unrelatedEnableEventsDoNotRegisterPlaceholders() throws Exception {
Iris iris = plugin(true);
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
MockedStatic<IrisPapiInstaller> installer = mockStatic(IrisPapiInstaller.class)) {
iris.onPlaceholderApiEnable(new PluginEnableEvent(namedPlugin("Unrelated")));
installer.verifyNoInteractions();
}
}
private static Iris plugin(boolean active) throws ReflectiveOperationException {
Iris iris = mock(Iris.class);
when(iris.getLogger()).thenReturn(Logger.getAnonymousLogger());
doCallRealMethod().when(iris).onPlaceholderApiEnable(any());
doCallRealMethod().when(iris).onPlaceholderApiDisable(any());
Field lifecycle = Iris.class.getDeclaredField("papiLifecycleActive");
lifecycle.setAccessible(true);
lifecycle.setBoolean(iris, active);
return iris;
}
private static Plugin namedPlugin(String name) {
Plugin plugin = mock(Plugin.class);
when(plugin.getName()).thenReturn(name);
return plugin;
}
}
@@ -0,0 +1,61 @@
package art.arcane.iris.update;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.volmlib.util.update.BukkitUpdateService;
import org.bukkit.plugin.Plugin;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
public class IrisUpdateNotifierTest {
@Test
public void acceptedSettingsReconfigureChecksAndClosingRemovesTheListener() {
IrisSettings previous = IrisSettings.settings;
IrisSettings.settings = new IrisSettings();
Plugin plugin = mock(Plugin.class);
BukkitUpdateService service = mock(BukkitUpdateService.class);
try (MockedStatic<BukkitUpdateService> registration = mockStatic(BukkitUpdateService.class)) {
registration.when(() -> BukkitUpdateService.register(eq(plugin), any())).thenReturn(service);
IrisUpdateNotifier notifier = new IrisUpdateNotifier(plugin);
try {
ArgumentCaptor<BukkitUpdateService.Options> options = ArgumentCaptor.forClass(BukkitUpdateService.Options.class);
registration.verify(() -> BukkitUpdateService.register(eq(plugin), options.capture()));
assertEquals("VolmitSoftware", options.getValue().owner());
assertEquals("Iris", options.getValue().repository());
assertEquals("iris.update", options.getValue().permission());
assertTrue(options.getValue().enabled().getAsBoolean());
clearInvocations(service);
assertFalse(IrisSettings.applyHotloadSnapshot(
"{\"general\":{\"updateNotifications\":false}}", candidate -> false));
assertTrue(options.getValue().enabled().getAsBoolean());
verifyNoInteractions(service);
IrisSettings.installHotloadSnapshot("{\"general\":{\"updateNotifications\":false}}");
assertFalse(options.getValue().enabled().getAsBoolean());
verify(service).reconfigure();
IrisSettings.settings.getGeneral().setUpdateNotifications(true);
assertFalse(options.getValue().enabled().getAsBoolean());
} finally {
notifier.close();
}
verify(service).close();
clearInvocations(service);
IrisSettings.installHotloadSnapshot("{}");
verifyNoInteractions(service);
} finally {
IrisSettings.settings = previous;
}
}
}
@@ -0,0 +1,41 @@
package art.arcane.iris.world.tree;
import org.bukkit.Material;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.junit.Test;
import java.util.Collection;
import java.util.List;
import static org.junit.Assert.assertSame;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
public class RoutedBlockBreakEventTest {
@Test
public void nativeLootUsesTheReservedToolCloneWithoutReadingPlayerInventory() {
Block block = mock(Block.class);
Player player = mock(Player.class);
ItemStack reservedTool = mock(ItemStack.class);
ItemStack capturedTool = mock(ItemStack.class);
ItemStack drop = mock(ItemStack.class);
Collection<ItemStack> expected = List.of(drop);
when(reservedTool.clone()).thenReturn(capturedTool);
when(block.getDrops(capturedTool)).thenReturn(expected);
RoutedBlockBreakEvent event = new RoutedBlockBreakEvent(new RoutedBlockBreakEvent.Options(
block, player, mock(FellingRun.class), mock(TreeFellerSVC.class), reservedTool));
reservedTool.setType(Material.AIR);
assertSame(expected, event.captureNativeDrops());
verify(reservedTool).clone();
verify(block).getDrops(capturedTool);
verify(block, never()).getDrops(reservedTool);
verifyNoInteractions(player);
}
}
+3 -1
View File
@@ -56,7 +56,7 @@ apply from: file('../../gradle/volmlib-stamp.gradle')
Properties rootProperties = new Properties()
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.3-26.2'))
String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3'))
String fabricLoaderVersion = providers.gradleProperty('fabricLoaderVersion').getOrElse(rootProperties.getProperty('fabricLoaderVersion', '0.19.5'))
Closure<String> irisArtifactName = { String platform, String targetVersion ->
@@ -313,6 +313,8 @@ tasks.named('shadowJar', ShadowJar).configure {
// The Bukkit platform binding cannot load without org.bukkit on the classpath, which no mod
// loader has. Shipping it only adds dead classes that reference a missing package.
exclude('art/arcane/iris/platform/bukkit/**')
exclude('art/arcane/iris/platform/bootstrap/SlimJar*.class')
exclude('art/arcane/iris/platform/bootstrap/NativeRuntimeLibraries*.class')
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
filesMatching('META-INF/services/**') { FileCopyDetails entry ->
entry.duplicatesStrategy = DuplicatesStrategy.INCLUDE
+3 -1
View File
@@ -56,7 +56,7 @@ apply from: file('../../gradle/volmlib-stamp.gradle')
Properties rootProperties = new Properties()
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.3-26.2'))
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'))
Closure<String> loaderDisplayVersion = { String loaderVersion ->
@@ -305,6 +305,8 @@ tasks.named('shadowJar', ShadowJar).configure {
// The Bukkit platform binding cannot load without org.bukkit on the classpath, which no mod
// loader has. Shipping it only adds dead classes that reference a missing package.
exclude('art/arcane/iris/platform/bukkit/**')
exclude('art/arcane/iris/platform/bootstrap/SlimJar*.class')
exclude('art/arcane/iris/platform/bootstrap/NativeRuntimeLibraries*.class')
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
filesMatching('META-INF/services/**') { FileCopyDetails entry ->
entry.duplicatesStrategy = DuplicatesStrategy.INCLUDE
@@ -195,8 +195,6 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
if (!nativeGenerator.represents(level)) {
throw new IllegalArgumentException("NativeWorld does not use Iris generator '" + dimensionKey + "'");
}
requireGlobalStructureGeneration(
NativeModdedServer.forWorld(level).generateStructures(), dimensionKey);
Engine activeEngine = engineIfBound();
Engine replacement = ModdedWorldEngines.prepareReplacement(
level,
@@ -242,6 +240,7 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
this.announced.set(false);
resetRuntimeCaches();
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.
// Never waits on a build owned by another thread: this method owns the generator monitor and the build
// path can need it.
@@ -442,12 +441,14 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
}
Engine current = engineIfBound();
if (NativeDimensionRuntime.sameWorld(boundLevel, level) && current != null && current.getComplex() != null) {
nativeGenerator.initializeStructureState(level);
return;
}
requireCompletedShutdown(engine);
unloading = false;
Engine bound = bindEngine(level);
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
// the same reason as repointAndBind: this method owns the generator monitor.
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
// history verification on every chunk call and fail the same way.
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.
boundLevel = level;
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
* 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;
}
@Override
public boolean terrainTransformed() {
Engine current = engine;
return current != null && current.getComplex().getTerrainTransform() != null;
}
@Override
public int runtimeId(Engine current) {
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,
S sampler) {
if (isGuaranteedSurfaceBiome(quartY, engine.getMinHeight())) {
if (isGuaranteedSurfaceBiome(engine, quartY)) {
return getSurfaceStructureBiome(engine, quartX, quartZ, sampler);
}
boolean cacheable = isBiomeCacheable(
@@ -512,7 +512,7 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
}
boolean monumentQuery = isMonumentSurfaceBiomeQuery(
y, radius, engine.getMinHeight(), engine.getDimension().getFluidHeight());
surfaceQuery = monumentQuery || isGuaranteedSurfaceBiome(minQuartY, engine.getMinHeight());
surfaceQuery = monumentQuery || isGuaranteedSurfaceBiome(engine, minQuartY);
}
if (!surfaceQuery) {
return query.fallback().get();
@@ -701,6 +701,11 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
int blockZ = quartZ << 2;
int internalY = blockY - engine.getMinHeight();
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
? stackLayout == null
? resolveDimensionStackLayer(engine, blockX, internalY, blockZ)
@@ -846,7 +851,13 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
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 caveSwitchY = Math.max(-8 - minHeight, 40);
return internalY > caveSwitchY;
@@ -31,6 +31,7 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeBlockDropHooks;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.BiomeEnvironment;
import art.arcane.iris.structure.placement.LootResolver;
import art.arcane.iris.generation.decoration.tree.TreeBlockMaterial;
import art.arcane.iris.generation.biome.IrisBiome;
@@ -181,26 +182,30 @@ public final class ModdedBlockBreakHandler {
NativeBlockState brokenState,
Engine engine
) {
KList<IrisBlockDrops> providers = providers(engine, position, brokenState);
if (providers.isEmpty()) {
return Result.empty();
}
BiomeEnvironment environment = engine.getBiomeEnvironment(position.x(),
position.y() - engine.getMinHeight(), position.z());
try (BiomeEnvironment.Scope ignored = engine.openBiomeEnvironmentScope(environment)) {
KList<IrisBlockDrops> providers = providers(environment, brokenState);
if (providers.isEmpty()) {
return Result.empty();
}
KList<NativeItemStack> drops = new KList<>();
boolean replaceVanillaDrops = false;
for (IrisBlockDrops provider : providers) {
replaceVanillaDrops |= provider.isReplaceVanillaDrops();
for (IrisLoot loot : provider.getDrops()) {
if (!LootResolver.oneIn(RNG.r, loot.getRarity())) {
continue;
}
NativeItemStack stack = ModdedItemTranslator.stack(loot, RNG.r, ModdedItemTranslator.context(level));
if (stack != null && !stack.isEmpty()) {
drops.add(stack);
KList<NativeItemStack> drops = new KList<>();
boolean replaceVanillaDrops = false;
for (IrisBlockDrops provider : providers) {
replaceVanillaDrops |= provider.isReplaceVanillaDrops();
for (IrisLoot loot : provider.getDrops()) {
if (!LootResolver.oneIn(RNG.r, loot.getRarity())) {
continue;
}
NativeItemStack stack = ModdedItemTranslator.stack(loot, RNG.r, ModdedItemTranslator.context(level));
if (stack != null && !stack.isEmpty()) {
drops.add(stack);
}
}
}
return new Result(drops, replaceVanillaDrops, null);
}
return new Result(drops, replaceVanillaDrops, null);
}
private static void removeMarker(Engine engine, NativeBlockPoint position) {
@@ -225,22 +230,17 @@ public final class ModdedBlockBreakHandler {
engine.getMantle().getMantle().remove(position.x(), mantleY, position.z(), TreeBlockMaterial.class);
}
private static KList<IrisBlockDrops> providers(Engine engine, NativeBlockPoint position, NativeBlockState brokenState) {
private static KList<IrisBlockDrops> providers(BiomeEnvironment environment, NativeBlockState brokenState) {
KList<IrisBlockDrops> providers = new KList<>();
IrisData data = engine.getData();
int relativeY = position.y() - engine.getMinHeight();
IrisBiome biome = engine.getBiome(position.x(), relativeY, position.z());
if (biome != null) {
addMatching(providers, biome.getBlockDrops(), brokenState, data);
}
IrisData data = environment.data();
IrisBiome biome = environment.biome();
addMatching(providers, biome.getBlockDrops(), brokenState, data);
if (skipsParents(providers)) {
return providers;
}
IrisRegion region = engine.getRegion(position.x(), relativeY, position.z());
if (region != null) {
addMatching(providers, region.getBlockDrops(), brokenState, data);
}
addMatching(providers, engine.getDimension().getBlockDrops(), brokenState, data);
IrisRegion region = environment.region();
addMatching(providers, region.getBlockDrops(), brokenState, data);
addMatching(providers, environment.dimension().getBlockDrops(), brokenState, data);
return providers;
}
@@ -50,7 +50,7 @@ public final class ModdedDecoratorHooks implements DecoratorPlatformHooks.FaceFi
int xx = rX + dx;
int yy = y + dy;
int zz = rZ + dz;
return xx < 0 || xx > 15 || zz < 0 || zz > 15 || yy < 0 || yy > hunk.getHeight()
return xx < 0 || xx > 15 || zz < 0 || zz > 15 || yy < 0 || yy >= hunk.getHeight()
? null : hunk.get(xx, yy, zz);
}
}
@@ -70,6 +70,7 @@ import art.arcane.iris.modded.service.ModdedEngineMaintenanceService;
import art.arcane.iris.modded.service.ModdedEntitySpawnService;
import art.arcane.iris.modded.service.ModdedLogFilterService;
import art.arcane.iris.modded.service.ModdedPreservationService;
import art.arcane.iris.modded.service.ModdedPackUpdateService;
import art.arcane.iris.modded.service.ModdedSettingsHotloadService;
import art.arcane.iris.modded.service.ModdedStudioHotloadService;
import art.arcane.iris.modded.service.ModdedTreeFellerService;
@@ -477,6 +478,7 @@ public final class ModdedEngineBootstrap {
createdServices.register(ModdedLogFilterService.class, new ModdedLogFilterService());
createdServices.register(ModdedEngineMaintenanceService.class, new ModdedEngineMaintenanceService());
createdServices.register(ModdedSettingsHotloadService.class, new ModdedSettingsHotloadService());
createdServices.register(ModdedPackUpdateService.class, new ModdedPackUpdateService());
ModdedStudioHotloadService studioHotloadService = createdServices.register(
ModdedStudioHotloadService.class, new ModdedStudioHotloadService());
createdServices.register(ModdedChunkUpdateService.class, new ModdedChunkUpdateService());
@@ -61,11 +61,12 @@ public final class ModdedEntitySpawner {
}
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;
boolean riseEffectActive = irisEntity.isSpawnEffectRiseOutOfGround() && level.hasPlayersNearby(new NativeEntityRuntime.Position(x, y, z), PLAYER_EFFECT_RADIUS);
int spawnBlockY = riseEffectActive ? blockY - 5 : blockY;
double spawnY = spawnBlockY + 0.5;
double spawnY = spawnBlockY + verticalOffset;
NativeSpawnedEntity created = create(irisEntity, level, x, spawnY, z);
if (created == null) {
@@ -98,8 +99,7 @@ public final class ModdedEntitySpawner {
String customName = irisEntity.getCustomName();
entity.applyBase(irisEntity, customName == null ? null : colorize(customName));
boolean persistent = irisEntity.isKeepEntity() || forcePersist();
applyPersistence(entity, persistent);
applyPersistence(irisEntity, entity, forcePersist());
applyPassengers(engine, irisEntity, entity, level, blockX, blockY, blockZ, rng);
@@ -273,8 +273,8 @@ public final class ModdedEntitySpawner {
return IrisSettings.get().getWorld().isForcePersistEntities();
}
private static void applyPersistence(NativeSpawnedEntity entity, boolean persistent) {
if (!entity.persistence(persistent)) {
static void applyPersistence(IrisEntity options, NativeSpawnedEntity entity, boolean forcePersistence) {
if (!entity.persistence(options.isKeepEntity() || forcePersistence)) {
String type = entity.typeKey();
if (WARNED_PERSISTENCE_TYPES.add(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.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
/**
* Owns the Iris generation pool and the single decision of whether this loader already runs chunk
@@ -43,7 +42,7 @@ public final class ModdedGenPool {
private static final long SHUTDOWN_DRAIN_MILLIS = 2_000L;
private static final AtomicInteger GEN_THREAD_SEQ = new AtomicInteger();
private static final ChunkSystem CHUNK_SYSTEM = detectChunkSystem();
private static final AtomicReference<ExecutorService> GEN_POOL = new AtomicReference<>(createGenPool());
private static ExecutorService generationPool = createGenPool();
private ModdedGenPool() {
}
@@ -63,39 +62,32 @@ public final class ModdedGenPool {
return CHUNK_SYSTEM.description();
}
static ExecutorService pool() {
ExecutorService pool = GEN_POOL.get();
if (pool != null && !pool.isShutdown()) {
return pool;
}
start();
ExecutorService restarted = GEN_POOL.get();
if (restarted == null) {
static synchronized ExecutorService pool() {
if (generationPool.isShutdown()) {
throw new RejectedExecutionException("Iris gen pool is shut down");
}
return restarted;
return generationPool;
}
static void start() {
while (true) {
ExecutorService current = GEN_POOL.get();
if (current != null && !current.isShutdown()) {
return;
}
ExecutorService created = createGenPool();
if (GEN_POOL.compareAndSet(current, created)) {
return;
}
created.shutdownNow();
static synchronized void start() {
if (!generationPool.isShutdown()) {
return;
}
if (!generationPool.isTerminated()) {
throw new IllegalStateException("Iris gen pool cannot restart while prior workers are still active");
}
generationPool = createGenPool();
}
static void shutdown() {
ExecutorService pool = GEN_POOL.getAndSet(null);
if (pool == null) {
return;
ExecutorService pool;
synchronized (ModdedGenPool.class) {
pool = generationPool;
if (pool.isTerminated()) {
return;
}
pool.shutdown();
}
pool.shutdown();
try {
if (pool.awaitTermination(SHUTDOWN_DRAIN_MILLIS, TimeUnit.MILLISECONDS)) {
return;
@@ -6,10 +6,9 @@ import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.configuration.IrisSettings;
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.PackValidationResult;
import art.arcane.iris.pack.PackValidator;
import art.arcane.iris.world.history.GenerationEpoch;
import art.arcane.iris.world.history.GenerationEpochContractFactory;
import art.arcane.iris.world.history.GenerationHistory;
@@ -174,7 +173,7 @@ final class ModdedGenerationHistoryStorage {
static ActivePack resolveActive(GenerationHistory history) throws IOException {
GenerationEpoch epoch = history.activeEpoch();
Path packRoot = normalize(history.activePackRoot());
validatePack(packRoot, epoch.packFingerprint());
validatePack(packRoot);
validateDimension(packRoot, epoch);
return new ActivePack(
packRoot,
@@ -184,13 +183,16 @@ final class ModdedGenerationHistoryStorage {
);
}
private static void validatePack(Path packRoot, String fingerprint) {
String contextFingerprint = PackValidationCache.contextFingerprint();
PackValidationResult validation = PackValidator.validate(packRoot.toFile());
PackValidationRegistry.publish(packRoot, validation, fingerprint, contextFingerprint);
static PackValidationResult validatePack(Path packRoot) {
PackValidationResult validation = PackValidationRegistry.getMatching(
packRoot, PackFingerprints.computePackTreeFingerprint(packRoot.toFile()));
if (validation == null) {
validation = ModdedStartup.validatePack(packRoot.toFile());
}
if (!validation.isLoadable()) {
throw new BrokenPackException(packRoot.toString(), validation.getBlockingErrors());
}
return validation;
}
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.iris.spi.IrisPlatforms;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.diagnostics.splash.IrisSplashComposer;
import art.arcane.iris.diagnostics.splash.IrisSplashRenderer;
import art.arcane.iris.spi.IrisLogging;
import java.io.File;
public final class ModdedIrisSplash {
@@ -34,18 +32,11 @@ public final class ModdedIrisSplash {
}
public static void print(NativeModdedLoader loader) {
printPacks(loader);
if (isLogoEnabled()) {
printLogo(loader);
}
}
private static void printPacks(NativeModdedLoader loader) {
File packFolder = IrisPlatforms.get().packsFolderNoCreate();
IrisSplashComposer.composePackLines(packFolder, IrisLogging::reportError)
.thenAccept(lines -> lines.forEach(IrisLogging::info));
}
private static void printLogo(NativeModdedLoader loader) {
String serverLine = loader.platformName() + " / Minecraft " + loader.minecraftVersion();
String[] splash = IrisSplashRenderer.renderPlain();
Loaded 100 of 563 files, more files were not shown because too many files have changed in this diff. Show more