Compare commits

...
Author SHA1 Message Date
Brian Neumann-Fopiano 058d527c73 VolmLib pin removed
- Local builds use the sibling VolmLib checkout for every VolmLib module with no pinned revision
- Builds without a local VolmLib use VolmLib master-SNAPSHOT like the other plugins
- The native runtime manifest and keep rules come from the embedded natives
- Iris loads only embedded natives and never downloads them
- Every jar records the VolmLib commit it was built from
2026-09-29 09:58:09 -04:00
Brian Neumann-Fopiano 15826162b4 Loader task freshness
- Always rerun the loader Exec tasks when VolmLib resolves from the published snapshot
- Read the VolmLib dirty state without taking the optional index lock
2026-09-29 09:50:38 -04:00
Brian Neumann-Fopiano 8d28ad4111 Embedded-only native loader
- Drop the repository storage and slimjar download path from NativeRuntimeLibraries
- Reject native manifests that do not use embedded storage
- Report provider hash mismatches as IOException
2026-09-29 09:33:30 -04:00
Brian Neumann-Fopiano bf11d44370 VolmLib pin removal
- The build resolves VolmLib master-SNAPSHOT when no sibling checkout is substituted
- The native runtime manifest, roots and keep rules come from the embedded natives
- The packed jar natives are verified against the embedded provider shas
- Every jar carries META-INF/volmit/volmlib.properties
- The build logs the resolved VolmLib once
- publishedNativeRuntime, the loader coordinate flags, mavenLocal and publish-volmlib are gone
2026-09-29 09:33:26 -04:00
Brian Neumann-Fopiano f60538343e Fail-closed generation fixes
- History validation always re-renders stored semantics with the current fixer
- Broken Iris worlds get a refusing generator and a banner instead of vanilla
- A failed enable locks Iris worlds instead of disabling the plugin
- Engine failures fail the chunk instead of skipping objects or features
- Modded engines close after levels unload instead of at server stopping
- Folia maintenance refuses chunks only after players leave the world
- Saved-biome loading defers chunk update passes
- Mixin audit handler names are checked against the compiled mixins
- Teleports wait up to 120 s and report the real failure
2026-09-29 08:49:50 -04:00
Brian Neumann-Fopiano 94c179fbd4 Fabric original biomes
- Capture original biomes at Fabric mod init so generated biome definitions exclude Fabric biome modifications
- Test the generated definition against a biome modified in place the way Fabric does it
2026-09-29 08:44:08 -04:00
Brian Neumann-Fopiano c7cca23155 Startup refusal fixes
- Bootstrap audits vanilla slots that hold an Iris world
- Startup stops when such a slot has unusable history
- Startup also stops when bukkit.yml no longer binds such a slot to Iris
- The audit detail carries the history read cause
- File system failures are named by type
- Bootstrap failures print the refusal banner instead of a bare stack trace
- A vanilla slot holding an Iris world is told to restore its history and keep its binding
- The vanilla slot remedy adds deleting level.dat when the level was never created
- A failed enable locks only for bukkit.yml worlds the server loads
2026-09-29 08:44:07 -04:00
Brian Neumann-Fopiano b1fbede364 Maintenance after evacuation
- Mantle-bypass maintenance starts only after players leave the world
- This covers studio close, studio disable, world remove, world unload and create rollback
- Maintenance ends only when it was entered
- Folia no longer treats a refused chunk requested during evacuation as a chunk system crash
2026-09-29 08:44:07 -04:00
Brian Neumann-Fopiano f55fde17d4 Shared teleport deadline
- Give primary world routing and Iris Vision teleports the 120 second first-chunk deadline /iris tp uses
2026-09-29 07:13:27 -04:00
Brian Neumann-Fopiano 96a400b585 Fail-closed modded decoration
- A generator with a refused or stopping engine refuses imported feature settings
- Those settings no longer fall back to vanilla ones
- Decoration fails when a sealed runtime has no settled feature table
- Decoration fails when the feature table was retired before placement
- Stacked initial spawns fail when the surface spawn biome lookup is rejected
- Those spawns no longer fall back to the chunk's top biome
- Bind failures from a stopping runtime stay out of the refusal memo and banner
- The next bind after such a failure retries
2026-09-29 07:13:23 -04:00
Brian Neumann-Fopiano 0cb3417755 Merge branch 'client-fixes' into review 2026-09-29 06:50:57 -04:00
Brian Neumann-Fopiano 8cf9e01d90 Engine failure test style
- Fixes an indentation slip and types the answer lambdas explicitly
2026-09-29 06:50:24 -04:00
Brian Neumann-Fopiano 2d5af3b61e Bounded teleport deadline
- /iris tp waits up to 120 seconds for the destination chunk
- A refusing generator or removed dimension still fails the command
- Tests cover the deadline and the reported timeout limit
2026-09-29 06:50:24 -04:00
Brian Neumann-Fopiano 782a85c26a Modded teardown fallback
- Closes engines, pools and scheduler a previous server left open when the next server starts
- Flushes them at JVM exit once the stopping server thread has ended
- Corrects the scheduler shutdown comment
2026-09-29 06:50:24 -04:00
Brian Neumann-Fopiano ea516b4a59 Refused maintenance generation
- A chunk is refused while Folia world maintenance forbids mantle stages
- Such chunks no longer end up missing objects, caves, deposits or their REAL flag
- Refused chunks warn at most every 5 seconds
- The overworld mode no longer bypasses mantle stages
2026-09-29 06:50:24 -04:00
Brian Neumann-Fopiano dd4633ad3a Isolated chunk update failures
- Treats only loading saved biome information as an engine failure
- Leaves a container without loot and warns once when its saved biome cannot be recovered
- Keeps a failed chunk unetched and retries it without stopping the rest of the level's update pass
- Ends a level's pass only when its engine or mantle is closed
- Covers the deferred, permanent and isolated update paths through updateChunk and the server tick
2026-09-29 06:50:24 -04:00
Brian Neumann-Fopiano 82fa69611e Accurate teleport failures
- Waits for the destination chunk without a wall-clock deadline so a cold first chunk no longer fails the teleport
- Reports the failure cause, an unloaded dimension, an offline player or a refused teleport separately
- Adds the new messages to every locale
2026-09-29 06:50:24 -04:00
Brian Neumann-Fopiano 5cadd9b8c4 Deferred saved biome updates
- Defers a chunk update pass while saved biome information is loading and retries it on a later pass
- Forgets only the updates already applied so a retry never refills a chest
- Propagates engine failures from loot application instead of marking the chunk done
2026-09-29 06:50:24 -04:00
Brian Neumann-Fopiano 259f885238 Modded shutdown after levels close
- Stops downloads, protocol, pregen, tools and services at server stopping
- Keeps engines, mantle, generation history, the generation pool and burst pools open while the loader drains chunk generation
- Closes each engine when its level unloads and sweeps the rest at server stopped
- Logs a failed level unload instead of aborting the loader's level close loop
2026-09-29 06:50:24 -04:00
Brian Neumann-Fopiano 54f3595ba7 Propagated engine failures
- GenerationClosedException covers closed history routers, stages, saved biome runtimes and admissions
- It also covers ownership stores, hydrology caches and engine runtimes
- GenerationFailures tells engine, lifecycle, executor and JVM failures from content failures
- Engine failures rethrow from object, cave object, procedural, floating, upper and decorator catch sites
- Biome layer, noise bound, rotation and mantle write catch sites rethrow them too
- Only content failures are skipped
- A source plan is never cached or persisted without an object a healthy engine places
- A transient engine failure no longer turns imported features off
2026-09-29 06:50:23 -04:00
Brian Neumann-Fopiano a9f07ce5c6 Merge branch 'fail-closed' into review 2026-09-29 06:34:18 -04:00
Brian Neumann-Fopiano 14f99c6df3 Merge branch 'render-drift' into review 2026-09-29 06:34:18 -04:00
Brian Neumann-Fopiano eefcbc40ef Fail-closed review fixes
- Modded logins are refused only when this save's dimension registry lists the primary world
- Otherwise Iris warns once and leaves routing idle
- An unreadable dimension registry counts as expecting the primary world
- A vanilla slot bound to Iris without Iris storage points at the bukkit.yml entry and /iris replace
- The refusal consequence comes from whether startup worlds exist instead of the first tick
- The first WorldLoadEvent or a hotload marks startup worlds as existing
- A failed enable on a hotload says the server keeps running
- Reconciler resolution failures report the refusal banner
- Operators get a join notice for such failures until the world loads
- The fail-closed generator is built even when the cause message cannot be read
- Tests drive Iris.onEnable for the lock, the unsupported-server lock and the rethrow
- The failed-enable teardown decision lives in FailedEnableLock with a test
2026-09-29 06:30:28 -04:00
Brian Neumann-Fopiano 7db46a8eb8 Forge biome modifier validation
- Pass the modded loader into the generation registry so biome modifier output stays out of validation
- Cover Forge modifiers on climate, attributes, effects and features against the canonical definition
2026-09-29 06:16:57 -04:00
Brian Neumann-Fopiano 3a4da2086e Fail-closed world generation
- Every Iris world the Bukkit resolver cannot serve gets a fail-closed generator
- CraftServer no longer receives a throw that it turns into vanilla
- A refusal banner logs the world, key, cause chain and what happens next
- The reconciler uses a throwing resolution so a broken world fails without stopping the server
- A failed enable on a server with Iris worlds keeps Iris enabled with a locked runtime
- bukkit.yml bindings in vanilla slots count as Iris worlds for that lock
- The shutdown-based vanilla fallback guard is gone
- A recorded modded bind failure rethrows instead of rebinding on every chunk call
- The first modded bind failure logs a banner
- Persistent-dimension reinject failures log a banner and repeat to operators on join
- Modded logins are refused while the configured primary Iris world is missing
2026-09-29 05:51:10 -04:00
Brian Neumann-Fopiano 3e4a2432bc Semantic registry validation
- Generated registry validation always re-renders recorded semantics with the current fixer
- Validation canonicalizes the rendering like the installer
- Renderer changes need no version bump
- The renderer identity SPI, both identity strings and the identity-based source preference are gone
- New captures record the fixer class as an informational renderer identity
- The v1 history format is unchanged
- The modded forced datapack, world check and datapack command use the runtime data version
- That is the same data version validation uses
- Tests cover a renderer change under an unchanged identity
- Tests cover 26.3 bare natural_mob_spawns history against the overlay rendering
- Tests cover semantic round trips for every supported fixer
2026-09-29 05:41:18 -04:00
Brian Neumann-Fopiano 76a07242b5 Fixed client mixin audit
- Matches the volmlib$ handler names of the client world-open and world-type mixins
- Keys expected mixins by their mixin config file and reports the target class name
- Verifies every expected handler is an injector declared by its mixin class
- Requires every registered volmlib mixin to be audited or deliberately unaudited
2026-09-29 05:36:45 -04:00
Brian Neumann-Fopiano aa428c0383 f 2026-09-29 04:43:37 -04:00
Brian Neumann-Fopiano c67af47dfe Modded 26.3
- Fabric, Forge and NeoForge target Minecraft 26.3
- Fabric uses loader 0.19.5 with Fabric API modules from 0.161.0+26.3
- Forge uses 26.3-66.0.8 with ForgeGradle 7.0.40
- NeoForge uses 26.3.0.33-beta with ModDevGradle 2.0.147
- Modded forced datapacks use the 26.3 data fixer and pack format 121
- Biome mob spawns are written as vanilla overlays
- Stronghold ring biome searches use the natural biome stream
- Chunk generation mob spawns go through the loader spawn position event
- README and the bug template list the supported versions
2026-09-29 04:11:57 -04:00
Brian Neumann-Fopiano 539cebaa21 Supported version text
- README names 26.3 as the master target
- README lists the plugin jar for 26.1.2 to 26.3
- Bug template offers 26.3, 26.2 and 26.1.2 with current loader examples
- Plugin api-version comment covers 26.1.2 through 26.3
2026-09-29 03:58:55 -04:00
Brian Neumann-Fopiano 5e95248057 Overlay biome mob spawns
- 26.3 custom biomes write natural_mob_spawns as an overlay modifier with its argument, matching vanilla biomes
- NeoForge biome modifiers can add spawns to Iris biomes again
- The 26.3 custom biome codec test checks the overlay modifier
2026-09-29 03:56:40 -04:00
Brian Neumann-Fopiano 6fd877ebfc Natural stronghold ring biomes
- The modded structure-state biome lookup resolves from the natural true biome stream, the same as the Bukkit ring search
- Stronghold ring placement no longer plans hydrology tiles around its 128 positions, so chunk structure starts stop waiting on river plans out to ~24k blocks
- Ring lookups are cached per runtime and cleared with the other biome caches
2026-09-29 03:56:40 -04:00
Brian Neumann-Fopiano 4da9661885 Loader spawn position check
- The modded chunk generator hands VolmLib the bound loader for chunk-generation creature checks
- Each loader build tests its spawn position check: Forge and NeoForge fire MobSpawnEvent.PositionCheck and honor a denial, Fabric applies the vanilla rule and obstruction checks
2026-09-29 03:56:40 -04:00
Brian Neumann-Fopiano 92e5daa13e Modded 26.3 datapacks
- The forced datapack, datapack commands, and world check render biomes and dimension types with the 26.3 data fixer and pack format 121
- Modded tests use the 26.3 feature, placement, spawner, sign, and biome resolver APIs
- The water spawn test binds a blocks_motion fixture so solid waterlogged blocks still obstruct spawns
2026-09-29 02:48:58 -04:00
Brian Neumann-Fopiano 94c7181aec Modded 26.3 targets
- Fabric, Forge, and NeoForge build against Minecraft 26.3 with Fabric Loader 0.19.5, Forge 26.3-66.0.8, and NeoForge 26.3.0.33-beta
- Fabric API modules follow fabric-api 0.161.0+26.3, ForgeGradle is 7.0.40, and ModDevGradle is 2.0.147
- Loader metadata requires Fabric Loader 0.19.5, Forge 66, NeoForge 26.3, and Minecraft 26.3
- The mod resource pack declares formats 97.1 through 121.0
- The v26_3_R1 headless sources no longer unpack a version overlay
- The README lists the 26.3 loader targets
2026-09-29 02:48:50 -04:00
Brian Neumann-Fopiano b7626ab23f Column biome fallback
- Column biome hunk takes its fallback biome from the caller
- The fallback resolves on first unset read
- Bukkit chunk generator supplies plains
- Core keeps no new Bukkit coupling
2026-09-29 01:12:16 -04:00
Brian Neumann-Fopiano 08ffb96680 Modded registry factories
- Modded platform gets registry access from the modded server factory
- Engine bootstrap and tile parity test get tile readers from the modded server factory
- Registry access tests build through the modded server factory
2026-09-29 01:12:16 -04:00
Brian Neumann-Fopiano d6226240a0 Dropped minecraft-common scope
- Generation revision no longer requires the removed minecraft-common sources
- Modded shared inputs drop the removed module
- Loader source sets no longer add the removed module
- README points shared Minecraft-native code at VolmLib
2026-09-29 00:50:30 -04:00
Brian Neumann-Fopiano 85849693b6 Pregen throughput work
- Object source plans are packed per destination and built once
- Source plans retire once every destination in reach consumed them
- Rotated block states and property variants are memoized
- Generation history syncs to disk from a background group commit
- History syncs at world save, shutdown, pregen end and promotion
- History capture reuses per-column biome and mantle reads
- Semantic summaries are built only when read
- Hydrology plans the whole pregen area nearest first
- Chunks without hydrology skip composition and cave cell lookups
- Server-thread stand-in hydrology answers are never cached
- Neighbour drafts stay inside the planned pregen area
- Terrain3D caches are lock-free
- Cave density lattice samples are shared between neighbouring chunks
- The eager carve window is 27 by 27 chunks with on-demand terrain beyond it
- Cave cells are copied once per chunk for the carve stage
- Terrain columns are painted z-major with per-level ore bands
- Deposit clumps that cannot place are rejected before building
- Dead biome inject writes are gone
- Chunk output buffers track each column top
- Engine getters use a thread-id frame table
- Pregen keeps region border chunks until neighbouring regions finish
- Mantle reads take plate pins so saves cannot close plates in use
2026-09-29 00:37:46 -04:00
Brian Neumann-Fopiano 319d249445 History sync test drain stub
- Close test stubs the admission drain with the deadline signature
- Stubbed drain reports every request drained
2026-09-29 00:33:13 -04:00
Brian Neumann-Fopiano 73af3efe12 Merge master
# Conflicts:
#	core/src/main/java/art/arcane/iris/world/pregen/AsyncPregenMethod.java
2026-09-29 00:30:12 -04:00
Brian Neumann-Fopiano 6c7796f12f Pregen failure handling
- Chunk generator reports generation failures to the running pregen job
- Pregen methods forward generation failures to the async method
- Async pregen fails every outstanding request through an in-flight ledger
- In-flight requests settle once and ignore late completions
- Pregen aborts with the failing chunk as its cause
- Admission drain waits with a 60 second deadline
- Closing the admission gate fails leftover requests
- Tests cover failure propagation, the ledger and the drain deadline
2026-09-29 00:29:34 -04:00
Brian Neumann-Fopiano 3722602a78 Transition dry layer fix
- Dry layers keep head and ceiling equal to the bed after the transition taper
- Displaced layers that dry out also keep head and ceiling at the bed
- Transition displacement reads its datum from the original fluid head
- Tests cover dry banks and drying channels across a transition band
2026-09-29 00:29:34 -04:00
Brian Neumann-Fopiano 7b06cd51ac Modded test fixes
- Language asset test locates the directory through the English file
- Structure template test reads the mixin class bytes instead of loading it
- Water spawn test initializes block state caches before running
- Logging policy check runs after the native sources are unpacked
- Native class boundary check runs after classes compile
2026-09-29 00:29:26 -04:00
Brian Neumann-Fopiano 34684da611 Modded pack metadata
- Modded jars ship pack.mcmeta for client resources and mod data
- Modded jars bundle jlibdeflate and its native libraries
- Artifact check requires pack.mcmeta and the jlibdeflate entries
- Artifact check rejects client language resources without pack.mcmeta
- Test loads the pack metadata against the current Minecraft formats
2026-09-29 00:29:26 -04:00
Brian Neumann-Fopiano c20296d106 Modded server registries
- Modded platform hands the running server to registry access
- Tile reader is built from the running server
- Registry test covers startup, reload and shutdown of dynamic registries
2026-09-29 00:29:25 -04:00
Brian Neumann-Fopiano ce1aad8a35 Revert per-chunk hydrology samples
- painter read of the chunk column array changed bank and shore cut materials (three terrain actuator tests)
2026-09-28 18:52:06 -04:00
Brian Neumann-Fopiano e4f20a7f7f Merge W3-polish 2026-09-28 18:49:32 -04:00
Brian Neumann-Fopiano bcb59d3584 Merge W2H-platerace 2026-09-28 16:20:12 -04:00
Brian Neumann-Fopiano 7ac5ffdb81 Per-chunk hydrology samples
- terrain painter reads the chunk's column array once instead of 256 cache lookups
- test pins chunk samples against per-column lookups on a real plan
2026-09-28 15:54:23 -04:00
Brian Neumann-Fopiano 6202ae5d06 Cheaper ellipsoid deposit cells
- squared y/z offsets computed once per sphere row, same evaluation order
- test pins cells, order and RNG draws against the per-cell form
2026-09-28 15:54:22 -04:00
Brian Neumann-Fopiano 59165df427 Take chunk uses through pins
- Every getChunk(..).use() site calls Mantle.useChunk, which cannot fail because a save closed the chunk in between
- Covered-chunk cleanup, custom block and static object layers take their chunk through useChunk
- Discovered flag writes go through Mantle.flag instead of an unpinned chunk
- Test mantles stub useChunk the way the real one behaves
2026-09-28 15:46:54 -04:00
Brian Neumann-Fopiano 6bcf90bb79 Merge W2G-paperio 2026-09-28 15:27:14 -04:00
Brian Neumann-Fopiano ca85cd31b7 Retain pregen dependency chunks
- PregenChunkRetention holds every chunk in a FULL request's dependency square until all in-area regions reaching it drained; drained regions keep only the border strips undrained neighbours need
- Removes the unload/reload churn (about 1.5 reloads per generated chunk at r1000: lattice passes, next region's border, future-region and ring protos)
- Capped at one chunk per MB of heap; over the cap the drained strips farthest from the active region are released; heap pressure and close release everything
- Regions still save and unload at drain; the early-unload test now pins that plus border retention
2026-09-28 15:20:14 -04:00
Brian Neumann-Fopiano 8899c9e3f9 Merge W2F-hydroplanner 2026-09-28 15:02:08 -04:00
Brian Neumann-Fopiano 491382abaa Merge W2C-terrain 2026-09-28 14:44:12 -04:00
Brian Neumann-Fopiano b4ee500c14 Restore evicted owners from plans
- A demanded owner missing from the owner cache is restored from its planned tile (resident or prepared-plan store) instead of drafted again
- Same restore path as persisted tiles; plans identical
2026-09-28 14:34:37 -04:00
Brian Neumann-Fopiano 0695a1d707 Merge W2D-mantleio 2026-09-28 14:25:12 -04:00
Brian Neumann-Fopiano 0b2a606a25 Hydrology plan digest probe
- Plans real pack tiles headless and prints a digest per tile with planning CPU
- Optional pregen radius plans them under a pregeneration scope and prepared-plan store
- Expected-digest file fails the run on any changed plan
2026-09-28 14:21:59 -04:00
Brian Neumann-Fopiano f3b16b784b Merge W2B-carving 2026-09-28 14:15:03 -04:00
Brian Neumann-Fopiano cb31510b89 Skip quiet block update checks
- ChunkBlockUpdateListener remembers the last state with no update or custom effect
- Plain terrain runs skip the per-block isUpdatable and isCustom checks
- Test replays random writes against the per-write catch semantics
2026-09-28 14:13:20 -04:00
Brian Neumann-Fopiano 7a03df5c82 Merge W2A-history 2026-09-28 14:10:07 -04:00
Brian Neumann-Fopiano adbcba3efc Batch history biome reads
- saved biome capture resolves each quart column through one engine call
- column-invariant height, region, surface and cave fallback resolved once
- cavern reads use one pinned mantle chunk per column
- saved biome cells interned per capture
- engine getters borrow the bound route runtime for other chunks instead of opening a stage
2026-09-28 14:04:36 -04:00
Brian Neumann-Fopiano 0ee0f62b9d Limit early owner drafts in pregen
- Pregeneration scope limits the lower-rank neighbour drafts an owner starts early to tiles the area composes from
- Undemanded ring drafts (and the early neighbours they started) are no longer computed; demanded ones still resolve on demand
- r2000 tile set: 80 -> 54 drafts, planning CPU -32%, plans identical
2026-09-28 13:58:58 -04:00
Brian Neumann-Fopiano 55e4a70b3b Drop stale cave snapshots
- dimension stack metadata clearing and continuation restore rewrite cavern cells after sCave, so they drop the context snapshot and decoration re-reads the mantle
- test that decoration reuses only its own chunk's snapshot
2026-09-28 13:55:18 -04:00
Brian Neumann-Fopiano 53b36fe197 Resident plate lookups
- Loaded-plate probes use Mantle.getLoadedRegion instead of the region map
2026-09-28 13:51:05 -04:00
Brian Neumann-Fopiano 195f1862af Wall scope test via real matter
- feeds caverns through a real mantle section now that sCave snapshots the chunk instead of iterating it
2026-09-28 13:50:36 -04:00
Brian Neumann-Fopiano c00dfbddce Cheaper cave biome resolution
- carving root entries cached per world Y in arrays instead of a boxed-key map
- semantic cave capture skips re-adding the key it added last
2026-09-28 13:43:39 -04:00
Brian Neumann-Fopiano 2fef055e5c Stream terrain boundary capture
- boundary columns build runs directly instead of voxel lists, tree sets and hash maps
- palette sorted once per column with a direct comparator, same canonical order
- geometry source reuses the last encoded voxel
- equivalence test against the previous encoding
2026-09-28 13:43:39 -04:00
Brian Neumann-Fopiano 20e10999dd Lazy semantic region summaries
- claims cache the next region shard without rebuilding its key summary
- summaries are computed when a query or compaction reads them
2026-09-28 13:43:38 -04:00
Brian Neumann-Fopiano 2ac9609407 Cheaper block writes and palette lookups
- ColumnExtentListeningHunk reports writes through a primitive listener instead of boxing ListeningHunk
- Palette layer generators memoized per seed slot instead of one shared recent entry
2026-09-28 13:41:08 -04:00
Brian Neumann-Fopiano f4a06efa21 Terrain column painter
- Paint chunks z-major so neighbouring columns share block-buffer cache lines
- Fill the solid run below layers, river paint and cave ceilings with ores or rock in one loop
- Resolve chunk constants, air and bedrock once per chunk; drop the unread shared lastBedrock write
- IrisOreBands replaces IrisOreGeneratorBounds: per-Y candidate generators in list order
- Resolve a constant (FLAT) ore chance once instead of sampling it per block
- Column parity test against the per-block algorithm over randomized columns
2026-09-28 13:41:08 -04:00
Brian Neumann-Fopiano df299bb97d Cheaper mantle writer close
- close reads each window slot before the atomic swap instead of swapping all 5,929 slots
- terrain-ready flags live with the terrain access and exist only on content writers
2026-09-28 13:36:39 -04:00
Brian Neumann-Fopiano 2930ca40fb LRU plate eviction in pregen
- Over the plate cap, the least recently used idle plates are saved one at a time on the IO pool while submissions continue
- Submissions wait only past the cap plus a small overshoot
- Replaces trimming and unloading every idle plate, which evicted the active working set and read it back
2026-09-28 13:34:33 -04:00
Brian Neumann-Fopiano 0052dfec39 Trimmed mantle section writes
- The section adapter writes without re-trimming; the chunk writer trims right before
2026-09-28 13:34:33 -04:00
Brian Neumann-Fopiano e31113aaa2 Captured cave biomes in content
- decorateNaturalCaves resolves coordinates of its own chunk through the captured column path; other coordinates keep the history dispatch
2026-09-28 13:32:53 -04:00
Brian Neumann-Fopiano 808e133cac Carve terrain on demand
- eager terrain window covers the replayed object sources plus one chunk of footprint (radius 13 instead of 19 for overworld)
- content writers generate a chunk's missing terrain components before handing it out, so reads past the window stay identical
- getEagerInputRadius replaces isInputGenerationLazy
2026-09-28 13:32:53 -04:00
Brian Neumann-Fopiano 8d93431319 Skip empty cave slice copies
- snapshot copies only present palette values and drops sections without cells
2026-09-28 13:32:53 -04:00
Brian Neumann-Fopiano 184a04d97f Snapshot cave terrain per chunk
- sCave copies the chunk's cavern and hydrology terrain view once (journal originals win) instead of per-voxel monitor, journal and slice lookups
- hydrology lookups answer null without work in chunks with no cave cells
- decorateNaturalCaves reuses the sCave snapshot through the chunk context
2026-09-28 13:11:28 -04:00
Brian Neumann-Fopiano 08353e3bdd Merge L1-objects 2026-09-28 12:26:54 -04:00
Brian Neumann-Fopiano 9d3597703d Merge L3-hydrology 2026-09-28 12:20:03 -04:00
Brian Neumann-Fopiano c4dcb32334 Merge L5-output 2026-09-28 12:19:03 -04:00
Brian Neumann-Fopiano cd61b75ff6 Merge L4-terrain 2026-09-28 12:19:03 -04:00
Brian Neumann-Fopiano 83c6c66294 Merge L2-pipeline 2026-09-28 12:19:03 -04:00
Brian Neumann-Fopiano 1614d6a9a7 Remember neighbourhood answers
- Each thread keeps its recent final hasColumnsAround answers next to its chunk slots, so the object and hydrology gates skip the nine-chunk scan on repeat
2026-09-28 12:05:05 -04:00
Brian Neumann-Fopiano af2167e2f7 Hide rotation memo accessors
- Keep the rotated-state memo out of the Lombok getter and toString
2026-09-28 11:57:34 -04:00
Brian Neumann-Fopiano 2d5da0076c Unboxed base terrain height
- Base height stream samples through the primitive double stream
2026-09-28 11:57:07 -04:00
Brian Neumann-Fopiano dcdda0d3c7 Hoist cave scan column top
- read the column top once per column and again only after zone decoration writes
2026-09-28 11:57:02 -04:00
Brian Neumann-Fopiano 34f00d86cf Thread-id table for engine frames
- frames live in a 256-slot table indexed by thread id, ThreadLocal only on slot collisions
- the single ThreadLocal probed long collision chains in the workers' thread-local maps
2026-09-28 11:57:02 -04:00
Brian Neumann-Fopiano 82f52cd6ec Skip empty chunk compositions
- Queued and demand compositions of chunks the occupancy index already knows are empty return without loading tiles
2026-09-28 11:55:13 -04:00
Brian Neumann-Fopiano 14f3a263df Retire consumed source plans
- Count the destinations in reach that asked for each source
- Drop the entry once all of them did, so only the frontier stays cached
2026-09-28 11:50:13 -04:00
Brian Neumann-Fopiano d59517aeaa Hydrology occupancy and lookahead
- Index planned tiles by occupied chunk; empty chunks skip composition, tile residency and cave-cell lookups
- Plan the whole pregen area nearest first with several roots, past the tile budget when plans persist
- Restore persisted tiles without a planning root permit
- Never memoize answers the server thread gave before its hydrology plan landed
2026-09-28 11:45:53 -04:00
Brian Neumann-Fopiano a35ecca7bd Group commit generation history
- Semantic journal and saved-biome appends write without forcing; a shared flusher forces touched files within 2s outside every store lock
- GenerationHistory.sync forces pending history at world save, engine shutdown, pregen completion and before activation promotion
- A failed deferred force poisons the store the same way an uncertain append did
- Cache the active epoch pack root instead of regex-validating it on every stage open
- Tests pin append-without-force, sync barriers, failure poisoning and the save, shutdown and pregen hooks
2026-09-28 11:41:17 -04:00
Brian Neumann-Fopiano 066017cdf6 Read ore chance cache first
- Skip the capturing supplier allocation once the chance noise exists
2026-09-28 11:38:41 -04:00
Brian Neumann-Fopiano 8878e9aa5f Spread native volume cache keys
- Mix chunk keys so origin and query caches stop treeifying buckets
2026-09-28 11:36:50 -04:00
Brian Neumann-Fopiano ec1984949b Reject deposit clumps early
- Ellipsoid footprint dry run replays the build draws without building
- Terrain-scoped clumps above every column limit are dropped before building
- Surface biome filters no chunk column passes skip the deposit
2026-09-28 11:35:56 -04:00
Brian Neumann-Fopiano f2236a8482 Skip floating fields for plain parents
- parent biome lookups read the biome stream directly instead of building the boundary field
- fields are only built where a parent with floating children needs edge distances
2026-09-28 11:26:41 -04:00
Brian Neumann-Fopiano 1df51b230d Column biome output buffer
- Bukkit noise generation keeps one biome per column until a partial column write
- drops the full-resolution biome array allocation and fill per chunk
2026-09-28 11:23:24 -04:00
Brian Neumann-Fopiano 2f8d3b1d1a Memoize object block states
- Cache vanilla withProperty variants per state
- Cache rotated states per rotation and quantized spin
- Hoist the stilt dirt state and rotate only target-layer stilt blocks
2026-09-28 11:22:51 -04:00
Brian Neumann-Fopiano d0008086ac One thread frame per engine
- runtime assembly, biome environment and runtime scope share one ThreadLocal frame
- engine getters do a single ThreadLocal lookup
2026-09-28 11:17:51 -04:00
Brian Neumann-Fopiano badca54e5d Plain block state flags
- lazily computed BukkitBlockState flags are bytes instead of volatile Booleans
2026-09-28 11:17:51 -04:00
Brian Neumann-Fopiano ec07f55fd8 Pack and drain object plans
- Store source plans as packed per-destination cell runs over a value table
- Weigh shared payloads once and give tiles and markers finite weights
- Release a plan once every touched destination consumed it, keeping its index
- Rebuild only on repeated consumption; debug stats for builds and waits
2026-09-28 11:12:37 -04:00
Brian Neumann-Fopiano b47c7ae6aa Skip impossible deposit variants
- Scan pack once per data load for declared depositVariants
- Placed ores skip the per-block cave biome lookup when none exist
2026-09-28 11:11:20 -04:00
Brian Neumann-Fopiano bc618e20ec Plain chunk output buffer
- ChunkDataHunkHolder stores cells in a plain array and tracks each column's highest stored y
- natural cave decoration and perfection stop scanning above the column top
2026-09-28 11:11:11 -04:00
Brian Neumann-Fopiano 2ce36eb6f1 Share cave density lattice
- Adaptive plane lattice samples cached per carver across neighbour chunks
- Grid cleared with carvers on hotload
2026-09-28 11:06:34 -04:00
Brian Neumann-Fopiano 631c486a5a Lock-free Terrain3D caches
- Replace synchronized move-to-last LRU stripes with chunk-block CLOCK cache
- Copy-on-write compiled profile map, lock only on miss
- Raise column cache to 2048 chunks
2026-09-28 11:06:34 -04:00
Brian Neumann-Fopiano 8b5c109d55 Sample noise biome cache size
- check the cache bound on a few quarts per column instead of every insert
2026-09-28 11:05:42 -04:00
Brian Neumann-Fopiano 3df1fba518 Drop dead biome inject writes
- sBiome, floating biomes and publishNaturalMetadata no longer write MatterBiomeInject; nothing reads it
- stack metadata clearing only runs for dimension stacks
2026-09-28 11:05:42 -04:00
Brian Neumann-Fopiano 241260a762 Support latched tree felling
- Add a run hook controlling continued sneaking.
- Preserve latched runs when players release sneak.
2026-09-28 07:03:58 -04:00
Brian Neumann-Fopiano c706eab60f Merged pregeneration reliability fixes
- Integrate hydrology persistence, retry, and memory-management changes.
- Preserve terrain-transition object continuations with byte-based cache accounting.
- Verify merged object plans, continuation replay, and cache behavior.
2026-09-28 02:15:30 -04:00
Brian Neumann-Fopiano 21f8440299 Stabilized hydrology pregeneration
- Preserve exact river owner plans across cache eviction and persistence.
- Retry required hydrology plans without generating riverless terrain.
- Bound object plans and pregeneration residency by available heap.
- Release completed native chunks and avoid empty reclamation flushes.
- Cover persistence, admission, cancellation, and ownership regressions.
2026-09-28 02:13:17 -04:00
Brian Neumann-Fopiano 8c3e5d547b Reworked terrain transitions
- New terrain heights meet saved boundaries while retaining local relief
- Surface water levels approach saved fluid heights across the band
- Cave and physical biome matching use a four-block seam patch
- Terrain generates once without speculative neighbor containment
- Bounded caches serve transition membership and boundary lookups
- Saved biome writes honor the selected durability policy
- Release labels and presentation code leave generation identity unchanged
- Unfinished objects retain blocks and metadata across pack changes
- Whole structure assemblies preserve pending excavation and foundations
- New placements keep pending historical pieces clear
- Transition probes cover cutovers and retained object fragments
2026-09-27 15:31:31 -04:00
486 changed files with 23371 additions and 5649 deletions
+4 -2
View File
@@ -49,9 +49,11 @@ body:
id: mcversion
attributes:
label: Minecraft Version
description: What version of Minecraft is the server on? Iris 4.x targets 26.2 only; older versions are not supported.
description: What version of Minecraft is the server on? Iris 4.x supports 26.1.2 to 26.3 on Bukkit-family servers and 26.3 on Fabric, Forge and NeoForge; older versions are not supported.
options:
- '26.3'
- '26.2'
- '26.1.2'
- Other (unsupported)
validations:
required: true
@@ -59,7 +61,7 @@ body:
id: loaderversion
attributes:
label: Platform / Loader Version
description: Exact server or loader build (see console). For example "Paper 26.2-60", "Fabric Loader 0.19.3", "NeoForge 26.2.0.12-beta".
description: Exact server or loader build (see console). For example "Paper 26.3-133", "Fabric Loader 0.19.5", "NeoForge 26.3.0.33-beta".
placeholder: DO NOT SAY "LATEST"
validations:
required: true
+17 -10
View File
@@ -4,7 +4,7 @@ Iris is a world generation engine for Minecraft servers and mod loaders. It gene
biomes, caves, structures, objects, and entities from editable JSON packs, with a full in-game
studio authoring workflow. The same engine runs as a Bukkit-family plugin and as a Fabric, Forge,
or NeoForge server mod. Cross-platform generation targets deterministic parity for identical
artifacts, pack bytes, seeds, and test areas. The master branch targets Minecraft 26.2.
artifacts, pack bytes, seeds, and test areas. The master branch targets Minecraft 26.3.
# [Support](https://discord.gg/3xxPTpT) **|** [Documentation](https://github.com/VolmitSoftware/docs/blob/master/iris/00-overview.md) **|** [Git](https://github.com/IrisDimensions)
@@ -20,12 +20,12 @@ Canonical English is defined in the typed Java catalogs under `core/src/main/jav
| Platform | Artifact | Minecraft | Notes |
|---|---|---|---|
| Paper / Purpur / Leaf / Canvas | plugin jar | 26.1.2 - 26.2 | Full feature set |
| Folia | plugin jar | 26.1.2 - 26.2 | Region-safe scheduling throughout |
| Spigot / CraftBukkit | plugin jar | 26.1.2 - 26.2 | Managed `iris:*` creation and generation; exact vanilla-slot `/iris replace` is unavailable |
| Fabric | mod jar | 26.2 | Server worldgen + client HUD; requires Fabric Loader 0.19.3+ |
| Forge | mod jar | 26.2 | Server worldgen + client HUD; current target is Forge 26.2-65.1.1 |
| NeoForge | mod jar | 26.2 | Server worldgen + client HUD; current target is NeoForge 26.2.0.59 |
| Paper / Purpur / Leaf / Canvas | plugin jar | 26.1.2 - 26.3 | Full feature set |
| Folia | plugin jar | 26.1.2 - 26.3 | Region-safe scheduling throughout |
| Spigot / CraftBukkit | plugin jar | 26.1.2 - 26.3 | Managed `iris:*` creation and generation; exact vanilla-slot `/iris replace` is unavailable |
| Fabric | mod jar | 26.3 | Server worldgen + client HUD; requires Fabric Loader 0.19.5+ |
| Forge | mod jar | 26.3 | Server worldgen + client HUD; current target is Forge 26.3-66.0.8 |
| NeoForge | mod jar | 26.3 | Server worldgen + client HUD; current target is NeoForge 26.3.0.33-beta |
Java 25 is required on every platform.
@@ -271,6 +271,12 @@ when it exists, otherwise `build/consumers/` inside the repo. `-Plocation=/path/
changes only the consumer directory. Both tasks copy all four verified platform jars into
`../PluginOuts/`.
VolmLib is built from source when a `VolmLib` checkout sits beside the repo or in a parent directory
(`VOLMLIB_DIR` or `-PlocalVolmLibDirectory=<path>` pick another one). Without one, or with
`-PuseLocalVolmLib=false`, the build uses VolmLib `master-SNAPSHOT` from JitPack. There is no VolmLib
version to bump. The build logs which VolmLib it used, and every jar records it in
`META-INF/volmit/volmlib.properties`.
If you need help compiling as a developer or contributor, ask in the Discord.
## Adapters / modded development
@@ -298,9 +304,10 @@ properties:
| `-PirisWorldCheck=<world>` | `iris.worldcheck` | Post-generation world integrity check |
Fabric additionally takes `-PirisClientRunDir=<dir>` to relocate the `runClient` working directory.
Shared code lives in `adapters/minecraft-common` (all adapters), `adapters/modded-common`
(loaders + the shared test suite), and `adapters/client-common` (client HUD and world-type
screens); every adapter adds those source directories, so one edit reaches all three loaders.
Shared code lives in `adapters/modded-common` (loaders + the shared test suite) and
`adapters/client-common` (client HUD and world-type screens); every adapter adds those source
directories, so one edit reaches all three loaders. Shared Minecraft-native code lives in VolmLib
(`native/minecraft-common`).
For IDE import you can surface the three adapter builds in the root composite with
`-PincludeModdedAdapters=true`. It is off by default: each adapter includes the root build back to
@@ -4,12 +4,12 @@ import art.arcane.volmlib.nativelib.v26_2_R1.terrain.NativeChunkGenerator;
import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeTerrainColumns;
import art.arcane.iris.platform.bukkit.nms.BukkitTerrainColumnPolicy;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.GenerationSessionLease;
import art.arcane.iris.world.history.BoundaryColumnGeometry;
import art.arcane.iris.world.history.TerrainBoundarySignature;
import art.arcane.iris.testsupport.PlatformLeakGuard;
import art.arcane.volmlib.nativelib.terrain.NativeTerrainColumnPolicy;
import net.minecraft.SharedConstants;
import net.minecraft.server.Bootstrap;
import net.minecraft.world.level.LevelHeightAccessor;
@@ -24,7 +24,7 @@ import java.util.Optional;
import java.util.OptionalInt;
import static org.junit.Assert.assertEquals;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.mock;
@@ -32,6 +32,7 @@ import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.never;
public final class IrisChunkGeneratorHeightCacheTest {
@Rule
@@ -44,68 +45,72 @@ public final class IrisChunkGeneratorHeightCacheTest {
}
@Test
public void repeatedNativeHeightSurvivesResolvedTerrainEvictionAndRuntimeChange() throws Exception {
public void repeatedNativeHeightUsesLiveHeightQueriesAcrossRuntimeChanges() throws Exception {
Fixture fixture = fixture();
LevelHeightAccessor bounds = LevelHeightAccessor.create(-16, 32);
assertEquals(-11, fixture.generator().getBaseHeight(-1, -33, Heightmap.Types.WORLD_SURFACE, bounds, null));
when(fixture.complex().resolvedTerrainColumn(-1, -33))
.thenThrow(new IllegalStateException("Full terrain was evicted"));
when(fixture.engine().getHeight(-1, -33, false)).thenReturn(4);
for (int index = 0; index < 100; index++) {
assertEquals(-11, fixture.generator().getBaseHeight(
-1, -33, Heightmap.Types.WORLD_SURFACE, bounds, null));
}
verify(fixture.complex(), times(1)).resolvedTerrainColumn(-1, -33);
verify(fixture.engine(), times(100)).getHeight(-1, -33, false);
when(fixture.engine().getCacheID()).thenReturn(2);
doReturn(Optional.of(signature())).when(fixture.complex()).resolvedTerrainColumn(-1, -33);
assertEquals(-11, fixture.generator().getBaseHeight(-1, -33, Heightmap.Types.WORLD_SURFACE, bounds, null));
verify(fixture.complex(), times(2)).resolvedTerrainColumn(-1, -33);
when(fixture.engine().getHeight(-1, -33, false)).thenReturn(7);
assertEquals(-8, fixture.generator().getBaseHeight(-1, -33, Heightmap.Types.WORLD_SURFACE, bounds, null));
verify(fixture.engine(), never()).getComplex();
verify(fixture.engine(), never()).getMode();
}
@Test
public void ordinaryHeightRemainsLiveUntilResolvedTransitionTerrainExists() throws Exception {
public void nativeHeightPredicatesSelectFluidOrSolidHeightWithoutGeneratingTerrain() throws Exception {
Fixture fixture = fixture();
LevelHeightAccessor bounds = LevelHeightAccessor.create(-16, 32);
when(fixture.complex().resolvedTerrainColumn(0, 0))
.thenReturn(Optional.empty(), Optional.empty(), Optional.of(signature()));
when(fixture.engine().getHeight(0, 0, false)).thenReturn(20, 25);
when(fixture.engine().getHeight(0, 0, true)).thenReturn(8);
assertEquals(5, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
assertEquals(10, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
assertEquals(-11, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
assertEquals(-11, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
assertEquals(-7, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.OCEAN_FLOOR, bounds, null));
verify(fixture.engine(), times(2)).getHeight(0, 0, false);
verify(fixture.complex(), times(3)).resolvedTerrainColumn(0, 0);
verify(fixture.engine(), times(1)).getHeight(0, 0, true);
verify(fixture.engine(), never()).getComplex();
verify(fixture.engine(), never()).getMode();
}
@Test
public void nativePredicatesAndAccessorBoundsRemainExact() throws Exception {
Fixture fixture = fixture();
NativeTerrainColumnPolicy policy = mock(NativeTerrainColumnPolicy.class);
NativeTerrainColumnPolicy.ColumnSession session = mock(NativeTerrainColumnPolicy.ColumnSession.class);
when(policy.openQuery(any())).thenReturn(session);
when(policy.placementKey(anyString())).thenAnswer(invocation -> invocation.getArgument(0));
doReturn(Optional.of(signature().geometry())).when(session).resolvedColumn();
NativeChunkGenerator<?, ?, ?, ?, ?> generator = generator(policy);
LevelHeightAccessor bounds = LevelHeightAccessor.create(-16, 32);
for (int repeat = 0; repeat < 2; repeat++) {
assertEquals(-11, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
assertEquals(-13, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.OCEAN_FLOOR, bounds, null));
assertEquals(-13, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING, bounds, null));
assertEquals(-14, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, bounds, null));
assertEquals(-14, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE,
assertEquals(-11, generator.getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
assertEquals(-13, generator.getBaseHeight(0, 0, Heightmap.Types.OCEAN_FLOOR, bounds, null));
assertEquals(-13, generator.getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING, bounds, null));
assertEquals(-14, generator.getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, bounds, null));
assertEquals(-14, generator.getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE,
LevelHeightAccessor.create(-16, 2), null));
assertEquals(-14, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING_NO_LEAVES,
assertEquals(-14, generator.getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING_NO_LEAVES,
LevelHeightAccessor.create(-14, 3), null));
}
verify(fixture.complex(), times(6)).resolvedTerrainColumn(0, 0);
verify(session, times(6)).resolvedColumn();
}
private static Fixture fixture() throws Exception {
Engine engine = mock(Engine.class);
IrisComplex complex = mock(IrisComplex.class);
when(engine.getComplex()).thenReturn(complex);
when(engine.getCacheID()).thenReturn(1);
when(engine.acquireGenerationLease(anyString())).thenReturn(GenerationSessionLease.noop());
when(complex.resolvedTerrainColumn(anyInt(), anyInt())).thenReturn(Optional.of(signature()));
return new Fixture(generator(new BukkitTerrainColumnPolicy(engine, null)), engine);
}
private static NativeChunkGenerator<?, ?, ?, ?, ?> generator(NativeTerrainColumnPolicy policy) throws Exception {
NativeChunkGenerator<?, ?, ?, ?, ?> generator = mock(NativeChunkGenerator.class, CALLS_REAL_METHODS);
set(generator, "terrainColumns", new NativeTerrainColumns(new BukkitTerrainColumnPolicy(engine, null)));
return new Fixture(generator, engine, complex);
set(generator, "terrainColumns", new NativeTerrainColumns(policy));
return generator;
}
private static void set(NativeChunkGenerator<?, ?, ?, ?, ?> generator, String name, Object value) throws Exception {
@@ -127,6 +132,6 @@ public final class IrisChunkGeneratorHeightCacheTest {
new TerrainBoundarySignature.BiomeEncoding(List.of("minecraft:plains"), new short[]{0})), geometry);
}
private record Fixture(NativeChunkGenerator<?, ?, ?, ?, ?> generator, Engine engine, IrisComplex complex) {
private record Fixture(NativeChunkGenerator<?, ?, ?, ?, ?> generator, Engine engine) {
}
}
@@ -0,0 +1,84 @@
package art.arcane.iris.platform.bukkit.nms.v26_2_R1;
import art.arcane.volmlib.nativelib.v26_2_R1.terrain.NativeTerrainAccessImpl;
import ca.spottedleaf.moonrise.patches.chunk_system.scheduling.ChunkHolderManager;
import ca.spottedleaf.moonrise.patches.chunk_system.scheduling.ChunkTaskScheduler;
import net.minecraft.SharedConstants;
import net.minecraft.server.Bootstrap;
import net.minecraft.server.level.ChunkLevel;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.TicketType;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import org.bukkit.craftbukkit.CraftWorld;
import org.junit.BeforeClass;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import java.lang.reflect.Field;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class NmsChunkRetentionTest {
@BeforeClass
public static void bootstrapMinecraftRegistries() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
}
@Test
public void retentionRequestsNothingBeyondTheOuterRingOfAFullRequest() {
NativeTerrainAccessImpl binding = mock(NativeTerrainAccessImpl.class, CALLS_REAL_METHODS);
assertEquals(ChunkLevel.RADIUS_AROUND_FULL_CHUNK, binding.fullChunkDependencyRadius());
assertEquals(ChunkLevel.MAX_LEVEL, ChunkHolderManager.MAX_TICKET_LEVEL);
assertEquals(ChunkLevel.generationStatus(ChunkLevel.FULL_CHUNK_LEVEL + binding.fullChunkDependencyRadius()),
ChunkLevel.generationStatus(ChunkHolderManager.MAX_TICKET_LEVEL));
assertEquals(ChunkStatus.STRUCTURE_STARTS, ChunkLevel.generationStatus(ChunkHolderManager.MAX_TICKET_LEVEL));
}
@Test
public void retainAndReleaseUseOnePermanentNonPersistentTicketAtTheMaximumLevel() throws Exception {
NativeTerrainAccessImpl binding = mock(NativeTerrainAccessImpl.class, CALLS_REAL_METHODS);
CraftWorld world = mock(CraftWorld.class);
ServerLevel level = mock(ServerLevel.class);
ChunkTaskScheduler scheduler = mock(ChunkTaskScheduler.class);
ChunkHolderManager manager = mock(ChunkHolderManager.class);
Field managerField = ChunkTaskScheduler.class.getField("chunkHolderManager");
managerField.setAccessible(true);
managerField.set(scheduler, manager);
when(world.getHandle()).thenReturn(level);
when(level.moonrise$getChunkTaskScheduler()).thenReturn(scheduler);
when(manager.addTicketAtLevel(any(TicketType.class), eq(3), eq(-4), eq(ChunkHolderManager.MAX_TICKET_LEVEL), isNull()))
.thenReturn(true);
when(manager.removeTicketAtLevel(any(TicketType.class), eq(3), eq(-4), eq(ChunkHolderManager.MAX_TICKET_LEVEL), isNull()))
.thenReturn(true);
assertTrue(binding.retainChunk(world, 3, -4));
ArgumentCaptor<TicketType> type = ArgumentCaptor.forClass(TicketType.class);
verify(manager).addTicketAtLevel(type.capture(), eq(3), eq(-4), eq(ChunkHolderManager.MAX_TICKET_LEVEL), isNull());
assertEquals(TicketType.NO_TIMEOUT, type.getValue().timeout());
assertTrue(type.getValue().doesLoad());
assertFalse(type.getValue().doesSimulate());
assertFalse(type.getValue().persist());
assertTrue(binding.releaseChunk(world, 3, -4));
verify(manager).removeTicketAtLevel(same(type.getValue()), eq(3), eq(-4), eq(ChunkHolderManager.MAX_TICKET_LEVEL), isNull());
binding.retainChunk(world, 3, -4);
ArgumentCaptor<TicketType> again = ArgumentCaptor.forClass(TicketType.class);
verify(manager, times(2)).addTicketAtLevel(again.capture(), eq(3), eq(-4),
eq(ChunkHolderManager.MAX_TICKET_LEVEL), isNull());
assertSame(type.getValue(), again.getValue());
}
}
@@ -15,7 +15,6 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedBlockState;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeBlockResolver;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeRegistryAccess;
import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeGenerationRegistryImpl;
import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeWorldGenerationImpl;
import art.arcane.volmlib.nativelib.terrain.NativeBiome;
import art.arcane.volmlib.nativelib.terrain.NativeBlockProperty;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
@@ -49,7 +48,7 @@ final class HeadlessNativePlatform implements IrisPlatform, PlatformRegistries,
return access;
}
throw new IllegalStateException("Generation registry contract requires loaded native registries");
}), "bukkit-generation-registry-v1", new NativeWorldGenerationImpl().generationRendererIdentity());
}), "bukkit-generation-registry-v1");
nativeAccess = new NativeRegistryAccess(new NativeRegistryAccess.Configuration(registries,
() -> { throw new UnsupportedOperationException("Headless reloadable registries are not loaded"); },
message -> { throw new IllegalStateException("Unavailable native registry: " + message); }));
@@ -4,12 +4,12 @@ import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeChunkGenerator;
import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeTerrainColumns;
import art.arcane.iris.platform.bukkit.nms.BukkitTerrainColumnPolicy;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.GenerationSessionLease;
import art.arcane.iris.world.history.BoundaryColumnGeometry;
import art.arcane.iris.world.history.TerrainBoundarySignature;
import art.arcane.iris.testsupport.PlatformLeakGuard;
import art.arcane.volmlib.nativelib.terrain.NativeTerrainColumnPolicy;
import net.minecraft.SharedConstants;
import net.minecraft.server.Bootstrap;
import net.minecraft.world.level.LevelHeightAccessor;
@@ -24,7 +24,7 @@ import java.util.Optional;
import java.util.OptionalInt;
import static org.junit.Assert.assertEquals;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.mock;
@@ -32,6 +32,7 @@ import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.never;
public final class IrisChunkGeneratorHeightCacheTest {
@Rule
@@ -45,68 +46,72 @@ public final class IrisChunkGeneratorHeightCacheTest {
}
@Test
public void repeatedNativeHeightSurvivesResolvedTerrainEvictionAndRuntimeChange() throws Exception {
public void repeatedNativeHeightUsesLiveHeightQueriesAcrossRuntimeChanges() throws Exception {
Fixture fixture = fixture();
LevelHeightAccessor bounds = LevelHeightAccessor.create(-16, 32);
assertEquals(-11, fixture.generator().getBaseHeight(-1, -33, Heightmap.Types.WORLD_SURFACE, bounds, null));
when(fixture.complex().resolvedTerrainColumn(-1, -33))
.thenThrow(new IllegalStateException("Full terrain was evicted"));
when(fixture.engine().getHeight(-1, -33, false)).thenReturn(4);
for (int index = 0; index < 100; index++) {
assertEquals(-11, fixture.generator().getBaseHeight(
-1, -33, Heightmap.Types.WORLD_SURFACE, bounds, null));
}
verify(fixture.complex(), times(1)).resolvedTerrainColumn(-1, -33);
verify(fixture.engine(), times(100)).getHeight(-1, -33, false);
when(fixture.engine().getCacheID()).thenReturn(2);
doReturn(Optional.of(signature())).when(fixture.complex()).resolvedTerrainColumn(-1, -33);
assertEquals(-11, fixture.generator().getBaseHeight(-1, -33, Heightmap.Types.WORLD_SURFACE, bounds, null));
verify(fixture.complex(), times(2)).resolvedTerrainColumn(-1, -33);
when(fixture.engine().getHeight(-1, -33, false)).thenReturn(7);
assertEquals(-8, fixture.generator().getBaseHeight(-1, -33, Heightmap.Types.WORLD_SURFACE, bounds, null));
verify(fixture.engine(), never()).getComplex();
verify(fixture.engine(), never()).getMode();
}
@Test
public void ordinaryHeightRemainsLiveUntilResolvedTransitionTerrainExists() throws Exception {
public void nativeHeightPredicatesSelectFluidOrSolidHeightWithoutGeneratingTerrain() throws Exception {
Fixture fixture = fixture();
LevelHeightAccessor bounds = LevelHeightAccessor.create(-16, 32);
when(fixture.complex().resolvedTerrainColumn(0, 0))
.thenReturn(Optional.empty(), Optional.empty(), Optional.of(signature()));
when(fixture.engine().getHeight(0, 0, false)).thenReturn(20, 25);
when(fixture.engine().getHeight(0, 0, true)).thenReturn(8);
assertEquals(5, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
assertEquals(10, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
assertEquals(-11, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
assertEquals(-11, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
assertEquals(-7, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.OCEAN_FLOOR, bounds, null));
verify(fixture.engine(), times(2)).getHeight(0, 0, false);
verify(fixture.complex(), times(3)).resolvedTerrainColumn(0, 0);
verify(fixture.engine(), times(1)).getHeight(0, 0, true);
verify(fixture.engine(), never()).getComplex();
verify(fixture.engine(), never()).getMode();
}
@Test
public void nativePredicatesAndAccessorBoundsRemainExact() throws Exception {
Fixture fixture = fixture();
NativeTerrainColumnPolicy policy = mock(NativeTerrainColumnPolicy.class);
NativeTerrainColumnPolicy.ColumnSession session = mock(NativeTerrainColumnPolicy.ColumnSession.class);
when(policy.openQuery(any())).thenReturn(session);
when(policy.placementKey(anyString())).thenAnswer(invocation -> invocation.getArgument(0));
doReturn(Optional.of(signature().geometry())).when(session).resolvedColumn();
NativeChunkGenerator<?, ?, ?, ?, ?> generator = generator(policy);
LevelHeightAccessor bounds = LevelHeightAccessor.create(-16, 32);
for (int repeat = 0; repeat < 2; repeat++) {
assertEquals(-11, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
assertEquals(-13, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.OCEAN_FLOOR, bounds, null));
assertEquals(-13, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING, bounds, null));
assertEquals(-14, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, bounds, null));
assertEquals(-14, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE,
assertEquals(-11, generator.getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
assertEquals(-13, generator.getBaseHeight(0, 0, Heightmap.Types.OCEAN_FLOOR, bounds, null));
assertEquals(-13, generator.getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING, bounds, null));
assertEquals(-14, generator.getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, bounds, null));
assertEquals(-14, generator.getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE,
LevelHeightAccessor.create(-16, 2), null));
assertEquals(-14, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING_NO_LEAVES,
assertEquals(-14, generator.getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING_NO_LEAVES,
LevelHeightAccessor.create(-14, 3), null));
}
verify(fixture.complex(), times(6)).resolvedTerrainColumn(0, 0);
verify(session, times(6)).resolvedColumn();
}
private static Fixture fixture() throws Exception {
Engine engine = mock(Engine.class);
IrisComplex complex = mock(IrisComplex.class);
when(engine.getComplex()).thenReturn(complex);
when(engine.getCacheID()).thenReturn(1);
when(engine.acquireGenerationLease(anyString())).thenReturn(GenerationSessionLease.noop());
when(complex.resolvedTerrainColumn(anyInt(), anyInt())).thenReturn(Optional.of(signature()));
return new Fixture(generator(new BukkitTerrainColumnPolicy(engine, null)), engine);
}
private static NativeChunkGenerator<?, ?, ?, ?, ?> generator(NativeTerrainColumnPolicy policy) throws Exception {
NativeChunkGenerator<?, ?, ?, ?, ?> generator = mock(NativeChunkGenerator.class, CALLS_REAL_METHODS);
set(generator, "terrainColumns", new NativeTerrainColumns(new BukkitTerrainColumnPolicy(engine, null)));
return new Fixture(generator, engine, complex);
set(generator, "terrainColumns", new NativeTerrainColumns(policy));
return generator;
}
private static void set(NativeChunkGenerator<?, ?, ?, ?, ?> generator, String name, Object value) throws Exception {
@@ -128,6 +133,6 @@ public final class IrisChunkGeneratorHeightCacheTest {
new TerrainBoundarySignature.BiomeEncoding(List.of("minecraft:plains"), new short[]{0})), geometry);
}
private record Fixture(NativeChunkGenerator<?, ?, ?, ?, ?> generator, Engine engine, IrisComplex complex) {
private record Fixture(NativeChunkGenerator<?, ?, ?, ?, ?> generator, Engine engine) {
}
}
@@ -13,6 +13,7 @@ import net.minecraft.data.registries.VanillaRegistries;
import net.minecraft.resources.RegistryOps;
import net.minecraft.server.Bootstrap;
import net.minecraft.world.attribute.EnvironmentAttributes;
import net.minecraft.world.attribute.modifier.MobSpawnSettingsModifier;
import net.minecraft.world.entity.MobCategory;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.biome.MobSpawnSettings;
@@ -53,5 +54,7 @@ public class CustomBiomeCodecTest {
assertEquals(2, decoded.count().minInclusive());
assertEquals(4, decoded.count().maxInclusive());
assertEquals(0x102030, biome.getWaterColor() & 0xFFFFFF);
assertEquals(MobSpawnSettingsModifier.overlay(),
biome.getAttributes().get(EnvironmentAttributes.NATURAL_MOB_SPAWNS).modifier());
}
}
@@ -0,0 +1,84 @@
package art.arcane.iris.platform.bukkit.nms.v26_3_R1;
import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeTerrainAccessImpl;
import ca.spottedleaf.moonrise.patches.chunk_system.scheduling.ChunkHolderManager;
import ca.spottedleaf.moonrise.patches.chunk_system.scheduling.ChunkTaskScheduler;
import net.minecraft.SharedConstants;
import net.minecraft.server.Bootstrap;
import net.minecraft.server.level.ChunkLevel;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.TicketType;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import org.bukkit.craftbukkit.CraftWorld;
import org.junit.BeforeClass;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import java.lang.reflect.Field;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class NmsChunkRetentionTest {
@BeforeClass
public static void bootstrapMinecraftRegistries() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
}
@Test
public void retentionRequestsNothingBeyondTheOuterRingOfAFullRequest() {
NativeTerrainAccessImpl binding = mock(NativeTerrainAccessImpl.class, CALLS_REAL_METHODS);
assertEquals(ChunkLevel.RADIUS_AROUND_FULL_CHUNK, binding.fullChunkDependencyRadius());
assertEquals(ChunkLevel.MAX_LEVEL, ChunkHolderManager.MAX_TICKET_LEVEL);
assertEquals(ChunkLevel.generationStatus(ChunkLevel.FULL_CHUNK_LEVEL + binding.fullChunkDependencyRadius()),
ChunkLevel.generationStatus(ChunkHolderManager.MAX_TICKET_LEVEL));
assertEquals(ChunkStatus.STRUCTURE_STARTS, ChunkLevel.generationStatus(ChunkHolderManager.MAX_TICKET_LEVEL));
}
@Test
public void retainAndReleaseUseOnePermanentNonPersistentTicketAtTheMaximumLevel() throws Exception {
NativeTerrainAccessImpl binding = mock(NativeTerrainAccessImpl.class, CALLS_REAL_METHODS);
CraftWorld world = mock(CraftWorld.class);
ServerLevel level = mock(ServerLevel.class);
ChunkTaskScheduler scheduler = mock(ChunkTaskScheduler.class);
ChunkHolderManager manager = mock(ChunkHolderManager.class);
Field managerField = ChunkTaskScheduler.class.getField("chunkHolderManager");
managerField.setAccessible(true);
managerField.set(scheduler, manager);
when(world.getHandle()).thenReturn(level);
when(level.moonrise$getChunkTaskScheduler()).thenReturn(scheduler);
when(manager.addTicketAtLevel(any(TicketType.class), eq(3), eq(-4), eq(ChunkHolderManager.MAX_TICKET_LEVEL), isNull()))
.thenReturn(true);
when(manager.removeTicketAtLevel(any(TicketType.class), eq(3), eq(-4), eq(ChunkHolderManager.MAX_TICKET_LEVEL), isNull()))
.thenReturn(true);
assertTrue(binding.retainChunk(world, 3, -4));
ArgumentCaptor<TicketType> type = ArgumentCaptor.forClass(TicketType.class);
verify(manager).addTicketAtLevel(type.capture(), eq(3), eq(-4), eq(ChunkHolderManager.MAX_TICKET_LEVEL), isNull());
assertEquals(TicketType.NO_TIMEOUT, type.getValue().timeout());
assertTrue(type.getValue().doesLoad());
assertFalse(type.getValue().doesSimulate());
assertFalse(type.getValue().persist());
assertTrue(binding.releaseChunk(world, 3, -4));
verify(manager).removeTicketAtLevel(same(type.getValue()), eq(3), eq(-4), eq(ChunkHolderManager.MAX_TICKET_LEVEL), isNull());
binding.retainChunk(world, 3, -4);
ArgumentCaptor<TicketType> again = ArgumentCaptor.forClass(TicketType.class);
verify(manager, times(2)).addTicketAtLevel(again.capture(), eq(3), eq(-4),
eq(ChunkHolderManager.MAX_TICKET_LEVEL), isNull());
assertSame(type.getValue(), again.getValue());
}
}
@@ -0,0 +1,156 @@
package art.arcane.iris.probe;
import art.arcane.iris.generation.biome.IrisBiomeCustom;
import art.arcane.iris.generation.biome.IrisBiomeCustomSpawn;
import art.arcane.iris.generation.biome.IrisBiomeCustomSpawnType;
import art.arcane.iris.pack.datapack.v263.DataFixerV263;
import art.arcane.iris.spi.PlatformGenerationRegistry;
import art.arcane.iris.world.history.GenerationRegistryContract;
import art.arcane.iris.world.history.GenerationRegistryContractFactory;
import art.arcane.volmlib.util.collection.KList;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.bukkit.Bukkit;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Test;
import java.io.File;
import java.io.IOException;
import java.util.Map;
import java.util.Set;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows;
public final class BareMobSpawnHistoryTest {
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 =
new GenerationRegistryContract.PhysicalResourceKey(
GenerationRegistryContractFactory.BIOME_REGISTRY,
"iris:biomes/" + "c".repeat(64)
);
private static HeadlessNativeRegistries loaded;
private static PlatformGenerationRegistry registry;
@BeforeClass
public static void installOverlayRendering() throws IOException {
assertNull(Bukkit.getServer());
loaded = HeadlessNativeRegistries.load(Map.of(
"data/iris/worldgen/biome/biomes/" + "c".repeat(64) + ".json",
spawningBiome().generateJson(new DataFixerV263())
));
registry = new HeadlessNativePlatform(new File("unused-native-platform"), loaded::registries)
.generationRegistry();
}
@AfterClass
public static void closeRegistries() throws IOException {
if (loaded != null) {
loaded.close();
}
}
@Test
public void bareMobSpawnHistoryValidatesAgainstTheInstalledOverlayRendering() throws IOException {
GenerationRegistryContract legacy = legacyContract(INSTALLED, spawningBiome());
GenerationRegistryContract.GeneratedSource source = legacy.generatedSources().get(INSTALLED);
assertEquals(GenerationRegistryContract.FINGERPRINT_SCHEMA_VERSION_ONE, legacy.fingerprintSchema());
assertEquals(LEGACY_RENDERER_IDENTITY, source.rendererIdentity());
assertNotEquals(
JsonParser.parseString(source.sourceJson()),
JsonParser.parseString(registry.generatedDefinition(
INSTALLED.registryKey(),
INSTALLED.resourceKey()
).value())
);
GenerationRegistryContract available = GenerationRegistryContractFactory.captureRequiredDefinitions(
Set.of(legacy),
new DataFixerV263(),
registry
);
legacy.requireDefinitionsAvailableIn(available);
}
@Test
public void bareMobSpawnHistoryStillRejectsDifferentSemantics() throws IOException {
GenerationRegistryContract changed = legacyContract(INSTALLED, spawningBiome().setTemperature(0.2D));
IOException failure = assertThrows(IOException.class,
() -> GenerationRegistryContractFactory.captureRequiredDefinitions(
Set.of(changed),
new DataFixerV263(),
registry
));
assertEquals("Historical generated registry definition changed for minecraft:worldgen/biome / "
+ INSTALLED.resourceKey() + ".", failure.getMessage());
}
@Test
public void bareMobSpawnHistoryStillRejectsAMissingDefinition() throws IOException {
GenerationRegistryContract missing = legacyContract(new GenerationRegistryContract.PhysicalResourceKey(
GenerationRegistryContractFactory.BIOME_REGISTRY,
"iris:biomes/" + "d".repeat(64)
), spawningBiome());
assertThrows(IOException.class, () -> GenerationRegistryContractFactory.captureRequiredDefinitions(
Set.of(missing),
new DataFixerV263(),
registry
));
}
private static GenerationRegistryContract legacyContract(
GenerationRegistryContract.PhysicalResourceKey key,
IrisBiomeCustom biome
) {
JsonObject rendered = JsonParser.parseString(biome.generateJson(new DataFixerV263())).getAsJsonObject();
JsonObject attributes = rendered.getAsJsonObject("attributes");
attributes.add(NATURAL_MOB_SPAWNS, attributes.getAsJsonObject(NATURAL_MOB_SPAWNS).get("argument"));
String captured = registry.canonicalDefinition(key.registryKey(), key.resourceKey(), rendered.toString())
.value();
String semantic = GenerationRegistryContractFactory.customBiomeEffectiveSemanticJson(biome, null);
GenerationRegistryContract.GeneratedSource source = new GenerationRegistryContract.GeneratedSource(
GenerationRegistryContractFactory.CUSTOM_BIOME_EFFECTIVE_SOURCE_SCHEMA,
GenerationRegistryContractFactory.fingerprintCustomBiomeAuthoredDefinition(semantic),
semantic,
LEGACY_RENDERER_IDENTITY,
GenerationRegistryContractFactory.fingerprintDefinition(
key,
PlatformGenerationRegistry.Definition.exactJson(captured),
"generated"
),
captured
);
return GenerationRegistryContract.fromDefinitionsAndGeneratedSources(
Map.of(key, GenerationRegistryContractFactory.fingerprintGeneratedSemantic(key, source)),
Map.of(key, source)
);
}
private static IrisBiomeCustom spawningBiome() {
return new IrisBiomeCustom()
.setId("tester")
.setSpawnRarity(2)
.setSpawns(new KList<>(
new IrisBiomeCustomSpawn()
.setType("minecraft:zombie")
.setGroup(IrisBiomeCustomSpawnType.MONSTER)
.setMinCount(2)
.setMaxCount(4)
.setWeight(7),
new IrisBiomeCustomSpawn()
.setType("minecraft:cow")
.setGroup(IrisBiomeCustomSpawnType.CREATURE)
.setMinCount(3)
.setMaxCount(3)
.setWeight(5)
));
}
}
@@ -1,7 +1,6 @@
package art.arcane.iris.probe;
import art.arcane.iris.spi.PlatformGenerationRegistry;
import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeWorldGenerationImpl;
import net.minecraft.data.registries.VanillaRegistries;
import org.bukkit.Bukkit;
import org.junit.Test;
@@ -22,8 +21,6 @@ public final class HeadlessGenerationRegistryTest {
HeadlessNativeTestRegistries::get);
PlatformGenerationRegistry registry = platform.generationRegistry();
assertEquals("bukkit-generation-registry-v1", registry.runtimeIdentity());
assertEquals(new NativeWorldGenerationImpl().generationRendererIdentity(),
registry.generatedDefinitionRendererIdentity());
assertEquals(registry.canonicalDefinition("minecraft:worldgen/biome", "iris:native_test", biome),
registry.generatedDefinition("minecraft:worldgen/biome", "iris:native_test"));
assertEquals(registry.canonicalDefinition("minecraft:dimension_type", "iris:native_test", dimension),
+2 -6
View File
@@ -2,15 +2,11 @@ String apiVersion = providers.gradleProperty('apiVersion').get()
def mainClass = 'art.arcane.iris.Iris'
def bootstrapperClass = 'art.arcane.iris.IrisBootstrap'
String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate')
.orElse('com.github.VolmitSoftware:VolmLib:master-SNAPSHOT')
.get()
dependencies {
compileOnly(project(':core'))
compileOnly(libs.lombok)
annotationProcessor(libs.lombok)
compileOnly(volmLibCoordinate) {
compileOnly('com.github.VolmitSoftware.VolmLib:volmlib-shared:master-SNAPSHOT') {
transitive = false
}
compileOnly(libs.paper.api)
@@ -23,7 +19,7 @@ dependencies {
testImplementation(libs.bstats)
testImplementation(project(':core'))
testImplementation(testFixtures(project(':core')))
testImplementation(volmLibCoordinate) {
testImplementation('com.github.VolmitSoftware.VolmLib:volmlib-shared:master-SNAPSHOT') {
transitive = false
}
compileOnly(libs.placeholderApi)
@@ -0,0 +1,88 @@
package art.arcane.iris;
import art.arcane.iris.platform.bootstrap.ServerProperties;
import art.arcane.iris.world.IrisStartupValidation;
import art.arcane.iris.world.IrisWorldStorage;
import art.arcane.iris.world.lifecycle.BukkitWorldConfiguration;
import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
import java.io.File;
import java.io.IOException;
import java.util.List;
/**
* Keeps a failed enable from handing Iris worlds to the vanilla generator.
* <p>
* Paper disables a plugin whose onEnable fails, and CraftServer builds every world bukkit.yml points at a disabled
* generator plugin with the vanilla generator. On a server with Iris worlds, Iris therefore stays enabled with its
* runtime locked: the generator resolver answers every Iris world with a fail-closed generator, which stops the
* level before it exists, and the admission listener refuses logins. It only needs IrisStartupValidation and
* logging, never a service the failed enable may not have created.
*/
final class FailedEnableLock {
private FailedEnableLock() {
}
/**
* @return true when Iris must stay enabled and locked, false when the failure may disable Iris as before
*/
static boolean engage(Throwable failure, boolean irisWorldsPresent, boolean startupWorldsCreated) {
if (!irisWorldsPresent) {
return false;
}
IrisStartupValidation.markRuntimeInvalid("Iris failed to enable: " + GenerationRefusalNotice.summary(failure));
List<String> notice = notice(failure, startupWorldsCreated);
try {
for (String line : notice) {
Iris.error(line);
}
Iris.reportError(failure);
} catch (Throwable loggingFailure) {
notice.forEach(System.err::println);
failure.addSuppressed(loggingFailure);
failure.printStackTrace(System.err);
}
return true;
}
/**
* Deferring teardown to the server stop quiesces through the NMS binding and waits for generators Paper closes.
* A failed enable may have neither, so it tears down at once even while the server stops.
*/
static boolean defersTeardownToServerStop(boolean enableFailed, boolean serverStopping) {
return !enableFailed && serverStopping;
}
/**
* @param startupWorldsCreated true when Iris enabled on a running server (a hotload), whose startup worlds
* already exist, so nothing stops
*/
static List<String> notice(Throwable failure, boolean startupWorldsCreated) {
return GenerationRefusalNotice.compose(
"Iris failed to enable",
GenerationRefusalNotice.causes(failure),
List.of(
"Iris stays enabled with a locked runtime so no Iris world falls back to vanilla generation.",
startupWorldsCreated
? "Every Iris world that is not loaded yet refuses to load; the server keeps running"
+ " and no chunks are written for them."
: "Every Iris world refuses to generate: server startup stops before the first one"
+ " loads, and no chunks are written.",
"Player logins are refused until this is fixed and the server restarts."
));
}
static boolean irisWorldsPresent() {
try {
return irisWorldsPresent(IrisWorldStorage.levelRoot(), ServerProperties.BUKKIT_YML);
} catch (Throwable unreadable) {
Iris.reportError("Could not inspect this server for Iris worlds; treating it as having them.", unreadable);
return true;
}
}
static boolean irisWorldsPresent(File levelRoot, File bukkitConfiguration) throws IOException {
return IrisWorldStorage.hasManagedWorldStorage(levelRoot)
|| BukkitWorldConfiguration.configuresLoadingIrisWorld(bukkitConfiguration, levelRoot.toPath());
}
}
@@ -32,9 +32,9 @@ import art.arcane.iris.world.IrisStartupValidation;
import art.arcane.iris.world.IrisStartupAdmissionListener;
import art.arcane.iris.world.BukkitWorldReconciler;
import art.arcane.iris.world.IrisWorldGeneratorResolver;
import art.arcane.iris.world.IrisWorldStorage;
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.pack.datapack.ServerConfigurator;
import art.arcane.iris.pack.datapack.DatapackIngestService;
@@ -200,6 +200,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
private final AtomicBoolean terminalCleanupCompleted = new AtomicBoolean(false);
private final AtomicBoolean runtimeTeardownFailed = new AtomicBoolean(false);
private volatile boolean generatorDrainCompleted;
private volatile boolean enableFailed;
private volatile PlaceholderRegistration papiRegistration;
private volatile IrisPapiListener papiListener;
private volatile IrisPapiState papiState;
@@ -613,21 +614,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
return plugin == null ? ComponentLog.discriminator("Iris", "&a") : plugin.getTag();
}
/**
* @return false when the bootstrap was aborted (unsupported server version); the caller
* must bail out of onEnable without touching any further setup.
*/
private boolean enable() {
if (!INMS.isBound()) {
Throwable bindFailure = INMS.bindFailure();
Iris.error("Iris cannot start: " + (bindFailure == null
? "no NMS binding is available for this server version."
: bindFailure.getMessage()));
// Deferred one tick: disablePlugin from inside onEnable re-enters onDisable
// synchronously and the loader then continues registering the half-enabled plugin.
J.s(() -> Bukkit.getPluginManager().disablePlugin(this), 1);
return false;
}
private void enable() {
EnableTimings timings = new EnableTimings();
alreadyDrained.set(false);
postStopFinisherStarted.set(false);
@@ -788,7 +775,6 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
});
timings.mark("startupTasks");
timings.report();
return true;
}
private static final class EnableTimings {
@@ -910,20 +896,25 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
}
public void onEnable() {
IrisPlatforms.bind(new BukkitPlatform());
enableFailed = false;
IrisStartupValidation.begin();
Bukkit.getPluginManager().registerEvents(new IrisStartupAdmissionListener(), this);
Bukkit.getPluginManager().registerEvents(pendingWorldReplacements, this);
pendingWorldReplacements.registerPlatformEntryListener();
boolean enabled;
try {
enabled = enable();
generatorResolver.refusals().attach(this);
IrisPlatforms.bind(new BukkitPlatform());
Bukkit.getPluginManager().registerEvents(pendingWorldReplacements, this);
pendingWorldReplacements.registerPlatformEntryListener();
if (!INMS.isBound()) {
refuseUnsupportedServer();
return;
}
enable();
} catch (Throwable failure) {
refuseVanillaFallback(failure);
throw failure;
}
if (!enabled) {
refuseVanillaFallback(null);
if (!FailedEnableLock.engage(failure, FailedEnableLock.irisWorldsPresent(),
generatorResolver.refusals().startupWorldsCreated())) {
throw failure;
}
enableFailed = true;
return;
}
BukkitGuiHost.install();
@@ -939,54 +930,32 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
reportLockedRuntime();
}
private void refuseUnsupportedServer() {
Throwable bindFailure = INMS.bindFailure();
IllegalStateException failure = new IllegalStateException("Iris cannot start: " + (bindFailure == null
? "no NMS binding is available for this server version."
: bindFailure.getMessage()), bindFailure);
if (FailedEnableLock.engage(failure, FailedEnableLock.irisWorldsPresent(),
generatorResolver.refusals().startupWorldsCreated())) {
enableFailed = true;
return;
}
Iris.error(failure.getMessage());
// Deferred one tick: disablePlugin from inside onEnable re-enters onDisable
// synchronously and the loader then continues registering the half-enabled plugin.
J.s(() -> Bukkit.getPluginManager().disablePlugin(this), 1);
}
private static void reportLockedRuntime() {
String denial = IrisStartupValidation.denialReason().orElse(null);
if (denial == null) {
return;
}
boolean managedStorage;
try {
managedStorage = IrisWorldStorage.hasManagedWorldStorage(IrisWorldStorage.levelRoot());
} catch (Throwable unavailable) {
Iris.reportError("Could not inspect Iris world storage while reporting the locked runtime.", unavailable);
managedStorage = false;
}
for (String line : RuntimeLockNotice.compose(denial, managedStorage)) {
for (String line : RuntimeLockNotice.compose(denial, FailedEnableLock.irisWorldsPresent())) {
Iris.error(line);
}
}
/**
* Stops a server whose Iris worlds would otherwise be generated by the vanilla generator.
* <p>
* A disabled Iris gets no {@code getDefaultWorldGenerator} call at all, so the server falls back to
* vanilla for every world bukkit.yml points at Iris and writes vanilla terrain into their region files.
* There is no Bukkit API that refuses a world at that point, so the server is stopped instead. This is
* damage control, not prevention: level creation runs in the same startup step that enables plugins, so
* spawn chunks of the affected worlds can still be written before the stop takes effect. The prevention
* lives in IrisBootstrap, which refuses startup before any level is created.
*/
private static void refuseVanillaFallback(Throwable failure) {
File levelRoot;
try {
levelRoot = IrisWorldStorage.levelRoot();
} catch (Throwable unavailable) {
return;
}
if (!IrisWorldStorage.hasManagedWorldStorage(levelRoot)) {
return;
}
Iris.error("Iris did not enable and this server has Iris worlds; stopping the server before they generate vanilla terrain.");
if (failure != null) {
Iris.reportError("Iris enable failed", failure);
}
try {
Bukkit.shutdown();
} catch (Throwable unavailable) {
Iris.error("Could not stop the server: " + unavailable.getClass().getSimpleName());
}
}
public BukkitDebugDump debugDump() {
return debugDump;
}
@@ -1014,11 +983,11 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
}
IrisLanguage.shutdown();
teardownPapi();
boolean serverStopping = IrisToolbelt.isServerStopping();
boolean deferToServerStop = FailedEnableLock.defersTeardownToServerStop(enableFailed, IrisToolbelt.isServerStopping());
boolean restartingAtStartupBoundary = startupBoundaryRestart.get();
if (restartingAtStartupBoundary) {
teardownRuntime("startup-boundary-restart", 30L);
} else if (serverStopping) {
} else if (deferToServerStop) {
quiesceRuntimeForServerShutdown("onDisable");
startPostStopFinisher();
} else {
@@ -1036,7 +1005,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
}
// super.onDisable() cancels plugin tasks and unregisters every listener.
super.onDisable();
if (!serverStopping || restartingAtStartupBoundary) {
if (!deferToServerStop || restartingAtStartupBoundary) {
finishTerminalCleanup();
}
}
@@ -1457,6 +1426,14 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
return generatorResolver.resolveDefaultWorldGenerator(worldName, id);
}
public ChunkGenerator requireWorldGenerator(String worldName, String id) {
return generatorResolver.requireWorldGenerator(worldName, id);
}
public WorldRefusalReporter worldRefusals() {
return generatorResolver.refusals();
}
public void splash() {
Iris.info("Custom Biomes: " + INMS.get().countCustomBiomes());
printPacks();
@@ -13,6 +13,7 @@ import art.arcane.iris.pack.datapack.DataVersion;
import io.papermc.paper.ServerBuildInfo;
import art.arcane.iris.pack.DefaultPackBootstrapProvisioner.ProvisionResult;
import art.arcane.iris.platform.bootstrap.SlimJar;
import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
import io.papermc.paper.plugin.bootstrap.BootstrapContext;
import io.papermc.paper.plugin.bootstrap.PluginBootstrap;
import io.papermc.paper.plugin.lifecycle.event.LifecycleEvent;
@@ -21,6 +22,7 @@ import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
@@ -112,16 +114,18 @@ public final class IrisBootstrap implements PluginBootstrap {
* Stops startup when a world the server is about to load carries Iris storage Iris cannot generate from.
* <p>
* The server enumerates levels from {@code <levelRoot>/dimensions/<namespace>/<key>} on disk, so a world
* whose folder is present is going to be loaded whatever Iris does; and CraftBukkit swallows whatever
* {@code getDefaultWorldGenerator} throws and falls back to the vanilla generator, which writes vanilla
* terrain straight into that world's region files. A pack snapshot that has gone missing therefore has
* to be found here, before any level is created, exactly as a corrupt {@code dimensions/<id>.json} is.
* whose folder is present is going to be loaded whatever Iris does. The generator resolver answers a world
* it cannot generate with a fail-closed generator, which stops startup at that world's own level creation,
* after the levels before it have loaded. A pack snapshot that has gone missing is found here instead,
* before any level is created, exactly as a corrupt {@code dimensions/<id>.json} is. An Iris world in a
* vanilla slot whose bukkit.yml entry no longer names Iris stops startup too: CraftServer would build it
* with the vanilla generator without ever asking Iris.
* <p>
* A world with no folder at all is not a startup failure: nothing enumerates it, nothing loads it, and
* its bukkit.yml and Multiverse entries are what make restoring the folder a complete recovery. Neither
* is a folder that holds no pack snapshot and no world data - {@link #quarantineWorthlessHusks} has
* already moved those out of the dimensions tree, and one that survives it (a Spigot-layout world folder,
* or a move that failed) owns nothing a vanilla generator could destroy. Both are only reported.
* or a move that failed) owns nothing to lose. Both are only reported here.
*/
static void requireUsableWorldStorage(BukkitStartupPaths startupPaths) throws IOException {
Path levelRoot = startupPaths.levelRoot();
@@ -135,6 +139,7 @@ public final class IrisBootstrap implements PluginBootstrap {
levelRoot
);
StringBuilder unusable = new StringBuilder();
StringBuilder unbound = new StringBuilder();
for (IrisWorldStorageEntry entry : entries) {
switch (entry.state()) {
case PRESENT -> {
@@ -152,15 +157,29 @@ public final class IrisBootstrap implements PluginBootstrap {
.append(": ")
.append(entry.detail() == null ? "storage is unusable" : entry.detail())
.append(')');
case UNBOUND -> unbound.append(unbound.isEmpty() ? "" : "; ")
.append(entry.configuredWorldName())
.append(" (")
.append(entry.storagePath())
.append("): set worlds.")
.append(entry.configuredWorldName())
.append(".generator to Iris:<pack> in bukkit.yml, or move the folder aside to start a new"
+ " vanilla world there");
}
}
if (unusable.isEmpty()) {
return;
List<String> refusals = new ArrayList<>(2);
if (!unusable.isEmpty()) {
refusals.add("Iris world storage is unusable and the server would generate vanilla terrain over it: "
+ unusable + ". Restore each world folder from a backup, or move it aside; a world with no"
+ " folder is reported and skipped, and the server starts.");
}
if (!unbound.isEmpty()) {
refusals.add("Iris worlds in vanilla slots are not bound to Iris, so the server would generate vanilla"
+ " terrain into them: " + unbound + ".");
}
if (!refusals.isEmpty()) {
throw new IllegalStateException(String.join(" ", refusals));
}
throw new IllegalStateException("Iris world storage is unusable and the server would generate vanilla"
+ " terrain over it: " + unusable
+ ". Restore each world folder from a backup, or move it aside; a world with no folder is"
+ " reported and skipped, and the server starts.");
}
static void armStartupFailure(BootstrapContext context, Throwable failure) {
@@ -175,17 +194,30 @@ public final class IrisBootstrap implements PluginBootstrap {
context.getLifecycleManager().registerEventHandler(eventType, event -> {
throw new IllegalStateException("Iris bootstrap did not establish safe world-generation state.", failure);
});
List<String> notice = startupRefusalNotice(failure);
try {
context.getLogger().error(
"Iris bootstrap failed; registry and world startup will be stopped at datapack discovery.",
failure
);
for (String line : notice) {
context.getLogger().error(line);
}
context.getLogger().warn("Iris bootstrap failure", failure);
} catch (Throwable loggingFailure) {
notice.forEach(System.err::println);
failure.addSuppressed(loggingFailure);
failure.printStackTrace(System.err);
}
}
static List<String> startupRefusalNotice(Throwable failure) {
return GenerationRefusalNotice.compose(
"Iris stopped server startup before any world loads",
GenerationRefusalNotice.causes(failure),
List.of(
"Startup stops at datapack discovery: no world loads and no chunks are written.",
"Fix the cause, then start the server again. Paper's --safeMode hint does not apply:"
+ " Iris worlds must not load without Iris."
));
}
private static ProvisionResult provision(BootstrapContext context, BukkitStartupPaths startupPaths) {
try {
return DefaultPackBootstrapProvisioner.provision(new BootstrapRequest(
@@ -1,6 +1,10 @@
package art.arcane.iris.api.tree;
public interface TreeFellerRunHooks {
default boolean requiresSneaking() {
return true;
}
TreeFellerRunHooks NONE = new TreeFellerRunHooks() {
@Override
public void onActivationAccepted() {
@@ -511,7 +511,9 @@ public class CommandIris implements DirectorExecutor {
return;
}
PlatformChunkGenerator generator = IrisToolbelt.access(world);
IrisToolbelt.beginWorldMaintenance(world, "world-unload", true);
// Maintenance that forbids mantle stages refuses every chunk, and Folia treats a refused chunk as a chunk
// system failure, so it starts only once no player is left to request one.
AtomicBoolean maintenanceEntered = new AtomicBoolean(false);
try {
AtomicBoolean terminalTimeout = new AtomicBoolean(false);
CompletableFuture<Boolean> sequence = IrisToolbelt.evacuateAsync(world)
@@ -523,6 +525,8 @@ public class CommandIris implements DirectorExecutor {
if (!Boolean.TRUE.equals(evacuated)) {
return CompletableFuture.completedFuture(false);
}
IrisToolbelt.beginWorldMaintenance(world, "world-unload", true);
maintenanceEntered.set(true);
return WorldLifecycleService.get().unloadAsync(world, true);
})
.thenCompose(unloaded -> {
@@ -537,7 +541,9 @@ public class CommandIris implements DirectorExecutor {
});
guardUnloadCompletion(sequence, terminalTimeout, world.getName())
.whenComplete((unloaded, throwable) -> {
IrisToolbelt.endWorldMaintenance(world, "world-unload", true);
if (maintenanceEntered.get()) {
IrisToolbelt.endWorldMaintenance(world, "world-unload", true);
}
lease.close();
Runnable response = () -> reportUnloadResult(responseSender, world, unloaded, throwable);
if (responseSender.isPlayer() && J.runEntity(responseSender.player(), response)) {
@@ -546,7 +552,9 @@ public class CommandIris implements DirectorExecutor {
J.s(response);
});
} catch (Exception e) {
IrisToolbelt.endWorldMaintenance(world, "world-unload", true);
if (maintenanceEntered.get()) {
IrisToolbelt.endWorldMaintenance(world, "world-unload", true);
}
lease.close();
responseSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_UNLOAD_WORLD_3, MessageArgument.untrusted("value", String.valueOf(e.getMessage()))));
Iris.reportError("Failed to unload world \"" + world.getName() + "\".", e);
@@ -722,19 +722,30 @@ public final class BukkitWorldReconciler {
return WorldIdentity.resolve(worldKey);
}
/**
* A world Iris cannot resolve is refused like one CraftServer asked for, with the banner and operator notice,
* then fails this load only.
*/
@Override
public CompletableFuture<World> createWorld(NamespacedKey worldKey, String dimension, Long seed) {
String configuredWorldName = worldKey.toString();
ChunkGenerator generator;
IrisDimension irisDimension;
try {
String logicalWorldName = IrisWorldStorage.logicalName(worldKey);
String configuredWorldName = configuredWorldName(worldKey);
Iris.info("Loading World: %s | Generator: %s", logicalWorldName, dimension);
ChunkGenerator generator = plugin.getDefaultWorldGenerator(configuredWorldName, dimension);
IrisDimension irisDimension = IrisWorldGeneratorResolver.loadDimension(configuredWorldName, dimension);
configuredWorldName = configuredWorldName(worldKey);
Iris.info("Loading World: %s | Generator: %s", IrisWorldStorage.logicalName(worldKey), dimension);
generator = plugin.requireWorldGenerator(configuredWorldName, dimension);
irisDimension = IrisWorldGeneratorResolver.loadDimension(configuredWorldName, dimension);
if (generator == null || irisDimension == null) {
throw new IllegalStateException("Could not resolve the Iris generator or dimension \"" + dimension + "\".");
}
Iris.info(C.LIGHT_PURPLE + "Preparing Spawn for " + logicalWorldName + " using Iris:" + dimension + "...");
} catch (Throwable refusal) {
plugin.worldRefusals().report(configuredWorldName, worldKey, refusal);
return CompletableFuture.failedFuture(refusal);
}
try {
Iris.info(C.LIGHT_PURPLE + "Preparing Spawn for " + IrisWorldStorage.logicalName(worldKey)
+ " using Iris:" + dimension + "...");
WorldCreator creator = WorldCreatorCompat.ofPersistentKey(worldKey)
.generator(generator)
.environment(BukkitEnvironment.from(irisDimension.getEnvironment()));
@@ -18,6 +18,7 @@
package art.arcane.iris.world;
import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
import org.bukkit.HeightMap;
import org.bukkit.Location;
import org.bukkit.World;
@@ -48,6 +49,20 @@ final class IrisFailClosedChunkGenerator extends ChunkGenerator {
+ "' remains locked: " + denialReason);
}
/**
* Built on the way back to CraftServer, which turns any throw into the vanilla generator, so a cause that cannot
* be summarized is named by its type instead.
*/
static IrisFailClosedChunkGenerator refused(String worldName, Throwable failure) {
String reason;
try {
reason = GenerationRefusalNotice.summary(failure);
} catch (Throwable unreadable) {
reason = failure.getClass().getName();
}
return new IrisFailClosedChunkGenerator("Iris refuses to generate '" + worldName + "': " + reason);
}
@Override
public void generateNoise(@NotNull WorldInfo worldInfo, @NotNull Random random, int x, int z, @NotNull ChunkData chunkData) {
throw refusal();
@@ -73,6 +73,7 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Supplier;
import java.util.stream.Stream;
/**
* Pack validation, dimension lookup, and the world generator / biome provider resolution that the
@@ -90,6 +91,7 @@ public final class IrisWorldGeneratorResolver {
private final AtomicBoolean externalContentRefreshQueued = new AtomicBoolean();
private final AtomicBoolean externalContentRefreshRequested = new AtomicBoolean();
private final Map<Path, PendingSnapshot> pendingSnapshots = new ConcurrentHashMap<>();
private final WorldRefusalReporter refusals = new WorldRefusalReporter();
public IrisWorldGeneratorResolver(VolmitPlugin plugin) {
this.plugin = plugin;
@@ -480,12 +482,28 @@ public final class IrisWorldGeneratorResolver {
return fallback.get();
}
/**
* The generator CraftServer asks for. Paper turns a throw or a null here into the vanilla generator, so every
* request aimed at an Iris world gets a generator back: the Iris one, or a fail-closed one that refuses to let
* the level be created. Only the PlotSquared discovery sentinel, which is never created, gets null.
*/
@Nullable
public ChunkGenerator resolveDefaultWorldGenerator(String worldName, @Nullable String id) {
if (isPlotSquaredGeneratorDiscoveryProbe(worldName, id)) {
Iris.debug("Ignoring PlotSquared generator discovery probe");
return null;
}
try {
return requireWorldGenerator(worldName, id);
} catch (Throwable failure) {
return refuseWorld(worldName, failure);
}
}
/**
* Resolution for Iris' own loads, which report a failure per world instead of handing it to CraftServer.
*/
public ChunkGenerator requireWorldGenerator(String worldName, @Nullable String id) {
if (isGeneratorDiscoveryProbe(worldName, id)) {
Iris.debug("Generator discovery probe for loaded world " + worldName);
return IrisFailClosedChunkGenerator.discoveryProbe(worldName);
@@ -505,21 +523,37 @@ public final class IrisWorldGeneratorResolver {
return startupLock;
}
Iris.debug("Default World Generator Called for " + worldName + " using ID: " + id);
if (id == null || id.isEmpty()) id = IrisSettings.get().getGenerator().getDefaultWorldType();
Iris.debug("Generator ID: " + id + " requested by bukkit/plugin");
String dimension = id == null || id.isEmpty() ? IrisSettings.get().getGenerator().getDefaultWorldType() : id;
Iris.debug("Generator ID: " + dimension + " requested by bukkit/plugin");
File levelRoot = IrisWorldStorage.levelRoot();
NamespacedKey worldKey = configuredWorldKey(worldName, levelRoot.getName(), levelRoot);
requireWorldKeyAvailable(worldName, worldKey);
requireOwnedWorld(worldName, levelRoot, worldKey);
return resolveFrozenWorldGenerator(worldName, dimension);
}
public WorldRefusalReporter refusals() {
return refusals;
}
private ChunkGenerator refuseWorld(String worldName, Throwable failure) {
refusals.report(worldName, refusalKey(worldName), failure);
try {
return resolveFrozenWorldGenerator(worldName, id);
} catch (RuntimeException failure) {
Iris.reportError("Refusing to load configured Iris world '" + worldName
+ "' because its frozen world-local pack snapshot could not be used.", failure);
Bukkit.shutdown();
throw failure;
Iris.reportError(failure);
} catch (Throwable loggingFailure) {
System.err.println("[Iris] Could not log the refusal of '" + worldName + "': "
+ loggingFailure.getClass().getName());
}
return IrisFailClosedChunkGenerator.refused(worldName, failure);
}
@Nullable
private static NamespacedKey refusalKey(String worldName) {
try {
return messageWorldKey(worldName, IrisWorldStorage.levelRoot().getName());
} catch (Throwable unresolved) {
return null;
}
}
@@ -588,12 +622,56 @@ public final class IrisWorldGeneratorResolver {
return;
}
NamespacedKey messageKey = messageWorldKey(worldName, levelRoot.getName());
if (!IRIS_DIMENSION_NAMESPACE.equals(messageKey.getNamespace())) {
if (dimensionRoot != null && IrisWorldStorage.holdsIrisContent(dimensionRoot.toPath())) {
throw new IllegalStateException("'" + worldName + "' (" + messageKey + ") is an Iris world whose"
+ " generation history is missing from "
+ GenerationHistoryPaths.forDimension(dimensionRoot.toPath()).generationRoot()
+ ", so Iris cannot generate it. Restore that folder from a backup. Keep worlds." + worldName
+ ".generator in bukkit.yml: without it the server generates vanilla terrain into this world.");
}
// Only bukkit.yml binds a vanilla slot at startup, and the refusal stops startup, so no command can run
// until that binding is gone.
throw new IllegalStateException("'" + worldName + "' (" + messageKey
+ ") is a vanilla world slot with no Iris world storage, so Iris cannot generate it."
+ " Remove worlds." + worldName + ".generator from bukkit.yml"
+ leftoverLevelDataRemoval(levelRoot)
+ ", start the server, then run /iris replace " + messageKey
+ " type=<pack> to replace it with Iris on the next restart.");
}
throw new IllegalStateException("'" + worldName + "' (" + messageKey
+ ") has no Iris world storage, so Iris cannot generate it."
+ " Create Iris worlds with /iris create " + IrisWorldStorage.logicalName(messageKey)
+ " type=<pack>; Iris registers them with Multiverse itself.");
}
/**
* A start refused before the level's overworld existed still leaves level.dat behind, and vanilla cannot start
* from a level.dat without world generation settings. A level that holds no chunks loses nothing to its removal.
*/
private static String leftoverLevelDataRemoval(File levelRoot) {
Path level = levelRoot.toPath();
Path overworld = level.resolve("dimensions").resolve("minecraft").resolve("overworld");
if (Files.exists(overworld.resolve("data").resolve("minecraft").resolve("world_gen_settings.dat"),
LinkOption.NOFOLLOW_LINKS)
|| hasEntries(overworld.resolve("region"))
|| hasEntries(level.resolve("region"))) {
return "";
}
return " and delete " + level.resolve("level.dat") + " (this level has no chunks and was never created)";
}
private static boolean hasEntries(Path directory) {
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
return false;
}
try (Stream<Path> entries = Files.list(directory)) {
return entries.findAny().isPresent();
} catch (IOException unreadable) {
return true;
}
}
private static boolean hasGenerationStorage(File dimensionRoot) {
GenerationHistoryPaths paths = GenerationHistoryPaths.forDimension(dimensionRoot.toPath());
return Files.isDirectory(paths.generationRoot(), LinkOption.NOFOLLOW_LINKS)
@@ -0,0 +1,122 @@
/*
* Iris is a World Generator for Minecraft Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.world;
import art.arcane.iris.Iris;
import art.arcane.iris.localization.C;
import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
import art.arcane.volmlib.util.bukkit.WorldIdentity;
import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.world.WorldLoadEvent;
import org.bukkit.plugin.Plugin;
import org.jetbrains.annotations.Nullable;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentSkipListMap;
/**
* Reports Iris worlds that refused to generate: a console banner that says what the refusal does to the server, and
* an operator notice on join for as long as the world stays unloaded, since the banner scrolls away.
*/
public final class WorldRefusalReporter implements Listener {
private static final String UNRESOLVED_KEY = "unresolved key";
private final Map<String, String> operatorNotices = new ConcurrentSkipListMap<>();
private volatile boolean startupWorldsCreated;
/**
* Iris enabled on a running server (a hotload) finds the startup worlds already loaded.
*/
public void attach(Plugin plugin) {
if (!Bukkit.getWorlds().isEmpty()) {
startupWorldsCreated = true;
}
Bukkit.getPluginManager().registerEvents(this, plugin);
}
/**
* Paper creates every startup level before it prepares any, and preparing one fires the first WorldLoadEvent.
* A generator asked for after that belongs to a runtime create, which fails only its caller.
*/
public boolean startupWorldsCreated() {
return startupWorldsCreated;
}
@EventHandler(priority = EventPriority.MONITOR)
public void onWorldLoad(WorldLoadEvent event) {
startupWorldsCreated = true;
try {
operatorNotices.remove(WorldIdentity.key(event.getWorld()).toString());
} catch (RuntimeException unkeyed) {
Iris.debug("Loaded world " + event.getWorld().getName() + " has no key to clear a refusal notice by");
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onPlayerJoin(PlayerJoinEvent event) {
if (operatorNotices.isEmpty()) {
return;
}
Player player = event.getPlayer();
if (!player.isOp() && !player.hasPermission("iris.all")) {
return;
}
for (String notice : operatorNotices.values()) {
player.sendMessage(C.RED + notice);
}
}
/**
* Never throws: the caller is handing CraftServer a fail-closed generator or failing a load, and a reporting
* failure must not turn either into something else.
*/
public void report(String worldName, @Nullable NamespacedKey worldKey, Throwable failure) {
String key = worldKey == null ? UNRESOLVED_KEY : worldKey.toString();
try {
for (String line : notice(worldName, key, failure, startupWorldsCreated)) {
Iris.error(line);
}
operatorNotices.put(worldKey == null ? worldName : key, "Iris world '" + worldName + "' (" + key
+ ") did not load and refuses to generate: " + GenerationRefusalNotice.summary(failure));
} catch (Throwable reportingFailure) {
System.err.println("[Iris] Iris refused to generate world '" + worldName + "' (" + key
+ "); the refusal could not be reported: " + reportingFailure.getClass().getName());
}
}
static List<String> notice(String worldName, String worldKey, Throwable failure, boolean startupWorldsCreated) {
return GenerationRefusalNotice.compose(
"Iris refused to generate world '" + worldName + "' (" + worldKey + ")",
GenerationRefusalNotice.causes(failure),
List.of(
"Iris does not generate this world and does not let vanilla or any other generator"
+ " write it; no chunks are written.",
startupWorldsCreated
? "The world does not load; the server keeps running. Fix the cause, then load it again."
: "Server startup stops before this world loads. Fix the cause, then start the server again."
));
}
}
@@ -189,7 +189,7 @@ public final class BukkitEnginePlatformHooks implements EnginePlatformHooks {
}
@Override
public boolean shouldBypassMantleStages(Engine engine) {
public boolean shouldRefuseGeneration(Engine engine) {
if (!J.isFolia() || !engine.getWorld().hasPlatformWorld()) {
return false;
}
@@ -219,7 +219,7 @@ public class TreeFellerSVC implements IrisService, IrisTreeFellerService {
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void haltWhenSneakingStops(PlayerToggleSneakEvent event) {
if (!event.isSneaking()) {
runner.finishRuns(event.getPlayer().getUniqueId());
runner.finishSneakRuns(event.getPlayer().getUniqueId());
}
}
@@ -523,10 +523,15 @@ final class TreeFellingRunner {
}
private boolean isRunControlActive(FellingRun run, Player player) {
return player.isOnline()
&& player.getGameMode() == GameMode.SURVIVAL
&& player.isSneaking()
&& player.getWorld().equals(run.candidate.world());
try {
return player.isOnline()
&& player.getGameMode() == GameMode.SURVIVAL
&& (!run.runHooks.requiresSneaking() || player.isSneaking())
&& player.getWorld().equals(run.candidate.world());
} catch (RuntimeException error) {
IrisLogging.reportError("An Iris tree-feller integration control check failed.", error);
return false;
}
}
private boolean reserveLogCost(FellingRun run) {
@@ -622,6 +627,24 @@ final class TreeFellingRunner {
}
}
void finishSneakRuns(UUID playerId) {
Set<FellingRun> runs = service.activeRuns.get(playerId);
if (runs == null) {
return;
}
for (FellingRun run : List.copyOf(runs)) {
try {
if (!run.runHooks.requiresSneaking()) {
continue;
}
} catch (RuntimeException error) {
IrisLogging.reportError("An Iris tree-feller integration control check failed.", error);
}
run.abortReason = "halted";
finish(run);
}
}
private Map<ChunkPosition, List<TreeMarkerTraversal.Position>> groupByChunk(
List<TreeMarkerTraversal.Position> positions
) {
@@ -0,0 +1,244 @@
package art.arcane.iris;
import art.arcane.iris.platform.bukkit.nms.INMS;
import art.arcane.iris.spi.IrisPlatform;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.world.IrisStartupAdmissionListener;
import art.arcane.iris.world.IrisStartupValidation;
import art.arcane.iris.world.IrisWorldGeneratorResolver;
import art.arcane.iris.world.PendingWorldReplacementManager;
import art.arcane.iris.world.task.J;
import com.destroystokyo.paper.profile.PlayerProfile;
import org.bukkit.Bukkit;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.event.player.AsyncPlayerPreLoginEvent;
import org.bukkit.generator.ChunkGenerator;
import org.bukkit.generator.WorldInfo;
import org.bukkit.plugin.PluginManager;
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.lang.reflect.Field;
import java.net.InetAddress;
import java.util.List;
import java.util.UUID;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
/**
* Paper disables a plugin whose onEnable throws, and CraftServer then builds every world bukkit.yml points at a
* disabled generator plugin with the vanilla generator. A failed enable on a server with Iris worlds therefore has
* to leave Iris enabled and locked instead.
*/
public class FailedEnableLockTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@After
public void disableValidation() {
IrisStartupValidation.disable();
IrisPlatforms.unbind();
}
@Test
public void enableFailureWithIrisWorldsLocksTheRuntimeInsteadOfDisablingIris() throws Exception {
IrisStartupValidation.begin();
IllegalStateException failure = new IllegalStateException("service graph exploded");
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
assertTrue(FailedEnableLock.engage(failure, true, false));
String denial = IrisStartupValidation.denialReason().orElseThrow();
assertTrue(denial, denial.contains("service graph exploded"));
ChunkGenerator generator = new IrisWorldGeneratorResolver(null)
.resolveDefaultWorldGenerator("world", "overworld");
assertNotNull("a null generator is the vanilla generator", generator);
IllegalStateException refusal = assertThrows(IllegalStateException.class,
() -> generator.getDefaultBiomeProvider(mock(WorldInfo.class)));
assertTrue(refusal.getMessage(), refusal.getMessage().contains("service graph exploded"));
bukkit.verify(Bukkit::shutdown, never());
}
AsyncPlayerPreLoginEvent login = new AsyncPlayerPreLoginEvent(
"LockedTest", InetAddress.getLoopbackAddress(), UUID.randomUUID(), false, mock(PlayerProfile.class));
new IrisStartupAdmissionListener().onAsyncPlayerPreLogin(login);
assertEquals(AsyncPlayerPreLoginEvent.Result.KICK_OTHER, login.getLoginResult());
}
@Test
public void enableFailureWithoutIrisWorldsLeavesIrisToBeDisabled() {
IrisStartupValidation.begin();
try (MockedStatic<Iris> iris = mockStatic(Iris.class)) {
assertFalse(FailedEnableLock.engage(new IllegalStateException("service graph exploded"), false, false));
}
String denial = IrisStartupValidation.denialReason().orElseThrow();
assertFalse(denial, denial.contains("service graph exploded"));
}
@Test
public void noticeSaysIrisStaysLockedAndWhatThatBlocks() {
String notice = String.join("\n", FailedEnableLock.notice(
new IllegalStateException("outer", new IllegalArgumentException("inner")), false));
assertTrue(notice, notice.contains("Iris failed to enable"));
assertTrue(notice, notice.contains("outer"));
assertTrue(notice, notice.contains("inner"));
assertTrue(notice, notice.contains("vanilla"));
assertTrue(notice, notice.contains("startup stops"));
assertTrue(notice, notice.contains("logins"));
}
/**
* A hotload enables Iris on a running server whose startup worlds already exist, so nothing stops.
*/
@Test
public void noticeAfterStartupWorldsExistSaysTheServerKeepsRunning() {
String notice = String.join("\n", FailedEnableLock.notice(new IllegalStateException("outer"), true));
assertFalse(notice, notice.contains("startup stops"));
assertTrue(notice, notice.contains("keeps running"));
}
/**
* IrisPlatforms.bind refuses a second platform, which is a real way for enable to throw. It has to throw inside
* the guarded region, and the throw must not reach Paper.
*/
@Test
public void onEnableKeepsIrisEnabledAndLockedWhenEnableThrowsOnAServerWithIrisWorlds() throws Exception {
IrisPlatforms.bind(mock(IrisPlatform.class));
Iris plugin = plugin();
try (EnableFixture fixture = new EnableFixture(true)) {
plugin.onEnable();
String denial = IrisStartupValidation.denialReason().orElseThrow();
assertTrue(denial, denial.contains("already bound"));
ChunkGenerator generator = plugin.getDefaultWorldGenerator("world", "overworld");
assertNotNull("a null generator is the vanilla generator", generator);
IllegalStateException refusal = assertThrows(IllegalStateException.class,
() -> generator.getDefaultBiomeProvider(mock(WorldInfo.class)));
assertTrue(refusal.getMessage(), refusal.getMessage().contains("already bound"));
fixture.bukkit.verify(Bukkit::shutdown, never());
}
}
@Test
public void onEnableStillThrowsToPaperWhenTheServerHasNoIrisWorlds() throws Exception {
IrisPlatforms.bind(mock(IrisPlatform.class));
Iris plugin = plugin();
try (EnableFixture fixture = new EnableFixture(false)) {
IllegalStateException failure = assertThrows(IllegalStateException.class, plugin::onEnable);
assertTrue(failure.getMessage(), failure.getMessage().contains("already bound"));
}
}
@Test
public void anUnsupportedServerWithIrisWorldsLocksInsteadOfDisablingIris() throws Exception {
Iris plugin = plugin();
set(plugin, "pendingWorldReplacements", mock(PendingWorldReplacementManager.class));
try (EnableFixture fixture = new EnableFixture(true);
MockedStatic<INMS> nms = mockStatic(INMS.class);
MockedStatic<J> scheduler = mockStatic(J.class)) {
nms.when(INMS::isBound).thenReturn(false);
nms.when(INMS::bindFailure).thenReturn(new IllegalStateException("no NMS binding for 26.9"));
plugin.onEnable();
String denial = IrisStartupValidation.denialReason().orElseThrow();
assertTrue(denial, denial.contains("no NMS binding for 26.9"));
scheduler.verify(() -> J.s(any(Runnable.class), anyInt()), never());
}
}
/**
* Deferring to the server stop quiesces through the NMS binding and waits for generators a failed enable never
* made, so a failed enable tears down at once even while the server stops.
*/
@Test
public void aFailedEnableNeverDefersItsTeardownToTheServerStop() {
assertFalse(FailedEnableLock.defersTeardownToServerStop(true, true));
assertFalse(FailedEnableLock.defersTeardownToServerStop(true, false));
assertTrue(FailedEnableLock.defersTeardownToServerStop(false, true));
assertFalse(FailedEnableLock.defersTeardownToServerStop(false, false));
}
@Test
public void irisWorldsAreManagedStorageOrABukkitBindingForAWorldThatLoads() throws Exception {
File levelRoot = temporaryFolder.newFolder("level", "world");
File bukkit = new File(temporaryFolder.getRoot(), "bukkit.yml");
assertFalse(FailedEnableLock.irisWorldsPresent(levelRoot, bukkit));
YamlConfiguration yaml = new YamlConfiguration();
yaml.set("worlds.removed_iris_world.generator", "Iris:overworld");
yaml.save(bukkit);
assertFalse("a leftover entry for a world that never loads", FailedEnableLock.irisWorldsPresent(levelRoot, bukkit));
yaml.set("worlds.world.generator", "Iris:overworld");
yaml.save(bukkit);
assertTrue("an Iris overworld lives in a vanilla slot", FailedEnableLock.irisWorldsPresent(levelRoot, bukkit));
assertTrue(bukkit.delete());
assertTrue(new File(levelRoot, "dimensions/iris/moon").mkdirs());
assertTrue(FailedEnableLock.irisWorldsPresent(levelRoot, bukkit));
}
/**
* Paper builds plugins through its class loader, so the plugin under test is the real class with its fields
* set by hand: the resolver getDefaultWorldGenerator delegates to, and whatever enable touches before failing.
*/
private static Iris plugin() throws Exception {
Iris plugin = mock(Iris.class, CALLS_REAL_METHODS);
set(plugin, "generatorResolver", new IrisWorldGeneratorResolver(plugin));
return plugin;
}
private static void set(Iris plugin, String name, Object value) throws Exception {
Field field = Iris.class.getDeclaredField(name);
field.setAccessible(true);
field.set(plugin, value);
}
/**
* irisWorldsPresent reads the real server.properties, which a test has none of, so the answer is set here.
*/
private static final class EnableFixture implements AutoCloseable {
private final MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
private final MockedStatic<Iris> iris = mockStatic(Iris.class);
private final MockedStatic<FailedEnableLock> lock = mockStatic(FailedEnableLock.class, CALLS_REAL_METHODS);
private EnableFixture(boolean irisWorlds) {
PluginManager plugins = mock(PluginManager.class);
bukkit.when(Bukkit::getPluginManager).thenReturn(plugins);
bukkit.when(Bukkit::getWorlds).thenReturn(List.of());
lock.when(FailedEnableLock::irisWorldsPresent).thenReturn(irisWorlds);
}
@Override
public void close() {
lock.close();
iris.close();
bukkit.close();
}
}
}
@@ -8,10 +8,16 @@ import io.papermc.paper.plugin.lifecycle.event.handler.configuration.LifecycleEv
import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEventType;
import net.kyori.adventure.text.logger.slf4j.ComponentLogger;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import java.io.IOException;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class IrisBootstrapFailureContractTest {
@@ -32,6 +38,27 @@ public class IrisBootstrapFailureContractTest {
assertSame(cause, failure.getCause());
}
@Test
public void bootstrapFailureIsReportedWithTheRefusalBanner() {
BootstrapContext context = mock(BootstrapContext.class);
ComponentLogger logger = mock(ComponentLogger.class);
when(context.getLogger()).thenReturn(logger);
when(context.getLifecycleManager()).thenReturn(new CapturingLifecycleManager());
IllegalStateException failure = new IllegalStateException("Iris world storage is unusable",
new IOException("Invalid generation manifest: manifest.json"));
IrisBootstrap.armStartupFailure(context, failure, lifecycleEventType());
ArgumentCaptor<String> lines = ArgumentCaptor.forClass(String.class);
verify(logger, atLeastOnce()).error(lines.capture());
String banner = String.join("\n", lines.getAllValues());
assertTrue(banner, banner.startsWith("=".repeat(78)));
assertTrue(banner, banner.contains("Cause: Iris world storage is unusable"));
assertTrue(banner, banner.contains("Caused by: Invalid generation manifest: manifest.json"));
assertTrue(banner, banner.contains("no chunks are written"));
assertTrue(banner, banner.contains("--safeMode"));
}
@SuppressWarnings("unchecked")
private static LifecycleEventType<BootstrapContext, LifecycleEvent, ?> lifecycleEventType() {
return (LifecycleEventType<BootstrapContext, LifecycleEvent, ?>) mock(LifecycleEventType.class);
@@ -175,6 +175,81 @@ public class IrisBootstrapWorldStorageContractTest {
assertFalse(MissingWorldStorageLog.hasWarned("world_iris_moon"));
}
/**
* A vanilla slot that holds an Iris world is generated from its history like any Iris world, so a broken
* history stops startup before the level exists instead of reaching the level-load refusal.
*/
@Test
public void startupIsRefusedWhenABoundVanillaSlotLostItsGenerationHistory() throws Exception {
Path serverRoot = temporaryFolder.newFolder("broken-overworld-server").toPath();
Files.createDirectories(serverRoot.resolve("world/dimensions/minecraft/overworld/iris/engine-data"));
Files.createDirectories(serverRoot.resolve("world/dimensions/minecraft/overworld/region"));
Files.writeString(serverRoot.resolve("world/dimensions/minecraft/overworld/region/r.0.0.mca"), "terrain");
writeServerProperties(serverRoot);
writeBukkitWorlds(serverRoot, "world");
IllegalStateException failure = assertThrows(
IllegalStateException.class,
() -> IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot)));
assertTrue(failure.getMessage(),
failure.getMessage().contains(serverRoot.toRealPath().resolve("world/dimensions/minecraft/overworld")
.toString()));
assertTrue(failure.getMessage(), failure.getMessage().contains("generation history"));
assertFalse(failure.getMessage(), failure.getMessage().contains("Remove worlds."));
}
@Test
public void unusableStorageNamesWhyTheHistoryCannotBeRead() throws Exception {
Path serverRoot = temporaryFolder.newFolder("cut-history-server").toPath();
Path generation = serverRoot.resolve("world/dimensions/minecraft/overworld/iris/generation");
Files.createDirectories(generation);
Files.writeString(generation.resolve("manifest.json"), "{\"schema\":1,\"epochs\":[{\"id", StandardCharsets.UTF_8);
writeServerProperties(serverRoot);
writeBukkitWorlds(serverRoot, "world");
IllegalStateException failure = assertThrows(
IllegalStateException.class,
() -> IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot)));
assertTrue(failure.getMessage(), failure.getMessage().contains("Invalid generation manifest"));
assertFalse(failure.getMessage(), failure.getMessage().contains("\n"));
}
/**
* Without its bukkit.yml binding the server builds an Iris world in a vanilla slot with the vanilla generator,
* which writes vanilla terrain into it.
*/
@Test
public void startupIsRefusedWhenAnIrisWorldInAVanillaSlotLostItsBinding() throws Exception {
Path serverRoot = temporaryFolder.newFolder("unbound-overworld-server").toPath();
Files.createDirectories(serverRoot.resolve("world/dimensions/minecraft/overworld/iris/pack"));
writeServerProperties(serverRoot);
writeBukkitWorlds(serverRoot);
IllegalStateException failure = assertThrows(
IllegalStateException.class,
() -> IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot)));
assertTrue(failure.getMessage(), failure.getMessage().contains("worlds.world.generator"));
assertTrue(failure.getMessage(),
failure.getMessage().contains(serverRoot.toRealPath().resolve("world/dimensions/minecraft/overworld")
.toString()));
}
@Test
public void startupContinuesForVanillaSlotsWithoutIrisContent() throws Exception {
Path serverRoot = temporaryFolder.newFolder("vanilla-server").toPath();
Files.createDirectories(serverRoot.resolve("world/dimensions/minecraft/overworld/region"));
Files.writeString(serverRoot.resolve("world/dimensions/minecraft/overworld/region/r.0.0.mca"), "terrain");
Files.createDirectories(serverRoot.resolve("world/dimensions/minecraft/the_nether/iris"));
Files.writeString(serverRoot.resolve("world/dimensions/minecraft/the_nether/iris/.DS_Store"), "finder");
writeServerProperties(serverRoot);
writeBukkitWorlds(serverRoot);
IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot));
}
private static BukkitStartupPaths startupPaths(Path serverRoot) throws Exception {
return BukkitStartupPaths.resolve(serverRoot, new String[0]);
}
@@ -11,12 +11,14 @@ import art.arcane.iris.world.history.GenerationHistory;
import art.arcane.iris.world.history.GenerationHistoryPaths;
import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter;
import art.arcane.iris.world.history.GenerationManifest;
import java.io.File;
import java.io.IOException;
import java.lang.reflect.Constructor;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey;
import org.bukkit.World;
@@ -26,9 +28,11 @@ import org.junit.rules.TemporaryFolder;
import org.mockito.MockedStatic;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class BukkitWorldReconcilerRuntimeTest {
@@ -95,6 +99,33 @@ public class BukkitWorldReconcilerRuntimeTest {
}
}
/**
* The startup reconcile and /iris load create worlds through the backend, so a world Iris refuses there needs
* the same banner and operator notice as one CraftServer asked for.
*/
@Test
public void aRefusedReconcilerCreateIsReportedAsARefusal() throws Exception {
NamespacedKey key = new NamespacedKey("iris", "probe");
IllegalStateException refusal = new IllegalStateException("Iris generation history is unusable at /srv/probe.");
Iris plugin = mock(Iris.class);
WorldRefusalReporter refusals = mock(WorldRefusalReporter.class);
when(plugin.worldRefusals()).thenReturn(refusals);
when(plugin.requireWorldGenerator("world_iris_probe", "overworld")).thenThrow(refusal);
try (MockedStatic<Iris> iris = mockStatic(Iris.class);
MockedStatic<IrisWorldStorage> storage = mockStatic(IrisWorldStorage.class)) {
storage.when(IrisWorldStorage::levelRoot).thenReturn(new File("world"));
storage.when(() -> IrisWorldStorage.configuredWorldName(key, "world")).thenReturn("world_iris_probe");
storage.when(() -> IrisWorldStorage.logicalName(key)).thenReturn("probe");
CompletableFuture<World> created = backend(plugin).createWorld(key, "overworld", 1337L);
ExecutionException failure = assertThrows(ExecutionException.class, created::get);
assertSame(refusal, failure.getCause());
verify(refusals).report("world_iris_probe", key, refusal);
}
}
private RuntimeFixture fixture() throws Exception {
Path container = temporaryFolder.newFolder().toPath();
Path root = container.resolve("world/dimensions/iris/probe");
@@ -134,11 +165,14 @@ public class BukkitWorldReconcilerRuntimeTest {
when(dimension.getLoadKey()).thenReturn("overworld");
when(engine.getData()).thenReturn(data);
when(data.getDataFolder()).thenReturn(pack.toFile());
return new RuntimeFixture(backend(mock(Iris.class)), world, key, generator, engine, history, root, pack);
}
private static BukkitWorldReconciler.Backend backend(Iris plugin) throws Exception {
Class<?> backendClass = Class.forName(BukkitWorldReconciler.class.getName() + "$BukkitBackend");
Constructor<?> constructor = backendClass.getDeclaredConstructor(Iris.class);
constructor.setAccessible(true);
BukkitWorldReconciler.Backend backend = (BukkitWorldReconciler.Backend) constructor.newInstance(mock(Iris.class));
return new RuntimeFixture(backend, world, key, generator, engine, history, root, pack);
return (BukkitWorldReconciler.Backend) constructor.newInstance(plugin);
}
private record RuntimeFixture(BukkitWorldReconciler.Backend backend, World world, NamespacedKey key,
@@ -0,0 +1,77 @@
package art.arcane.iris.world;
import org.bukkit.World;
import org.bukkit.generator.ChunkGenerator;
import org.bukkit.generator.WorldInfo;
import org.junit.Test;
import java.io.IOException;
import java.util.List;
import java.util.Random;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
/**
* Paper 26.3 asks a plugin generator for its biome provider in MinecraftServer.createLevel and
* CraftServer.createWorld before the level exists, outside any catch. A throw there is the only thing that stops a
* broken Iris world from being created at all, so every fail-closed generator must keep throwing from it.
*/
public class IrisFailClosedChunkGeneratorTest {
@Test
public void everyFailClosedGeneratorRefusesTheBiomeProviderBeforeTheLevelExists() {
List<ChunkGenerator> generators = List.of(
IrisFailClosedChunkGenerator.discoveryProbe("world"),
IrisFailClosedChunkGenerator.startupLock("world", "locked for a test"),
IrisFailClosedChunkGenerator.refused("world", new IllegalStateException("history drifted")));
for (ChunkGenerator generator : generators) {
IllegalStateException refusal = assertThrows(
IllegalStateException.class,
() -> generator.getDefaultBiomeProvider(mock(WorldInfo.class)));
assertTrue(refusal.getMessage(), refusal.getMessage().contains("'world'"));
assertThrows(IllegalStateException.class, () -> generator.getDefaultPopulators(mock(World.class)));
assertThrows(IllegalStateException.class,
() -> generator.getFixedSpawnLocation(mock(World.class), new Random()));
assertFalse(generator.shouldGenerateNoise());
}
}
@Test
public void refusalCarriesTheWholeCauseChain() {
IllegalStateException failure = new IllegalStateException("Iris generation history is unusable at /srv/moon.",
new IOException("Historical generated registry definition changed"));
IllegalStateException refusal = assertThrows(
IllegalStateException.class,
() -> IrisFailClosedChunkGenerator.refused("world_iris_moon", failure)
.getDefaultBiomeProvider(mock(WorldInfo.class)));
assertTrue(refusal.getMessage(), refusal.getMessage().contains("world_iris_moon"));
assertTrue(refusal.getMessage(), refusal.getMessage().contains("generation history is unusable"));
assertTrue(refusal.getMessage(), refusal.getMessage().contains("registry definition changed"));
}
/**
* The refusal is built on the way back to CraftServer, which turns any throw into the vanilla generator, so an
* unreadable cause still has to produce a generator.
*/
@Test
public void aCauseWhoseMessageCannotBeReadStillRefuses() {
IllegalStateException unreadable = new IllegalStateException() {
@Override
public String getMessage() {
throw new UnsupportedOperationException("message unavailable");
}
};
ChunkGenerator generator = IrisFailClosedChunkGenerator.refused("world_iris_moon", unreadable);
IllegalStateException refusal = assertThrows(
IllegalStateException.class,
() -> generator.getDefaultBiomeProvider(mock(WorldInfo.class)));
assertTrue(refusal.getMessage(), refusal.getMessage().contains("world_iris_moon"));
}
}
@@ -24,6 +24,7 @@ import org.bukkit.generator.WorldInfo;
import org.mockito.MockedStatic;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -256,18 +257,12 @@ public class IrisWorldGeneratorResolverTest {
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
Server server = mock(Server.class);
when(server.getLevelDirectory()).thenReturn(levelRoot.toPath());
bukkit.when(Bukkit::getServer).thenReturn(server);
bukkit.when(Bukkit::getWorldContainer).thenReturn(worldContainer);
bukkit.when(Bukkit::getWorlds).thenReturn(List.of());
mockServer(bukkit, worldContainer, levelRoot, List.of());
IllegalStateException failure = assertThrows(
IllegalStateException.class,
() -> new IrisWorldGeneratorResolver(null)
.resolveDefaultWorldGenerator("CheckingPlotSquaredGenerator", "overworld"));
String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null)
.resolveDefaultWorldGenerator("CheckingPlotSquaredGenerator", "overworld"));
assertTrue(failure.getMessage(), failure.getMessage().contains("CheckingPlotSquaredGenerator"));
assertTrue(refusal, refusal.contains("CheckingPlotSquaredGenerator"));
bukkit.verify(Bukkit::shutdown, never());
}
}
@@ -382,32 +377,26 @@ public class IrisWorldGeneratorResolverTest {
}
@Test
public void externalCreateWithoutIrisStorageThrowsWithoutShutdown() throws Exception {
public void externalCreateWithoutIrisStorageFailsClosedWithoutShutdown() throws Exception {
File worldContainer = temporaryFolder.newFolder("external-create");
File levelRoot = new File(worldContainer, "world");
assertTrue(levelRoot.mkdirs());
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
Server server = mock(Server.class);
when(server.getLevelDirectory()).thenReturn(levelRoot.toPath());
bukkit.when(Bukkit::getServer).thenReturn(server);
bukkit.when(Bukkit::getWorldContainer).thenReturn(worldContainer);
bukkit.when(Bukkit::getWorlds).thenReturn(List.of());
mockServer(bukkit, worldContainer, levelRoot, List.of());
IllegalStateException failure = assertThrows(
IllegalStateException.class,
() -> new IrisWorldGeneratorResolver(null)
.resolveDefaultWorldGenerator("mvtest", "overworld"));
String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null)
.resolveDefaultWorldGenerator("mvtest", "overworld"));
assertTrue(failure.getMessage(), failure.getMessage().contains("mvtest"));
assertTrue(failure.getMessage(), failure.getMessage().contains("/iris create"));
assertTrue(refusal, refusal.contains("mvtest"));
assertTrue(refusal, refusal.contains("/iris create"));
bukkit.verify(Bukkit::shutdown, never());
}
}
@Test
public void ownedIrisWorldWithUnusableSnapshotStillStopsTheServer() throws Exception {
public void ownedIrisWorldWithUnusableSnapshotFailsClosedWithoutShutdown() throws Exception {
File worldContainer = temporaryFolder.newFolder("owned-broken");
File levelRoot = new File(worldContainer, "world");
assertTrue(levelRoot.mkdirs());
@@ -415,46 +404,162 @@ public class IrisWorldGeneratorResolverTest {
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
Server server = mock(Server.class);
when(server.getLevelDirectory()).thenReturn(levelRoot.toPath());
bukkit.when(Bukkit::getServer).thenReturn(server);
bukkit.when(Bukkit::getWorldContainer).thenReturn(worldContainer);
bukkit.when(Bukkit::getWorlds).thenReturn(List.of());
mockServer(bukkit, worldContainer, levelRoot, List.of());
assertThrows(
IllegalStateException.class,
() -> new IrisWorldGeneratorResolver(null)
.resolveDefaultWorldGenerator("world_iris_moon", "overworld"));
String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null)
.resolveDefaultWorldGenerator("world_iris_moon", "overworld"));
bukkit.verify(Bukkit::shutdown);
assertTrue(refusal, refusal.contains("world_iris_moon"));
bukkit.verify(Bukkit::shutdown, never());
}
}
/**
* The tester's failure: a world whose generation history no longer matches the live registries. CraftServer
* turns a throw here into the vanilla generator, so the history failure has to come back as a generator that
* refuses to create the level.
*/
@Test
public void unusableGenerationHistoryFailsClosedWithoutShutdown() throws Exception {
File worldContainer = temporaryFolder.newFolder("history-broken").getCanonicalFile();
File levelRoot = new File(worldContainer, "world");
File dimensionRoot = new File(levelRoot, "dimensions/iris/moon");
assertTrue(new File(dimensionRoot, "iris/generation").mkdirs());
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
MockedStatic<Iris> iris = mockStatic(Iris.class);
MockedStatic<IrisWorlds> worlds = mockStatic(IrisWorlds.class)) {
mockServer(bukkit, worldContainer, levelRoot, List.of());
worlds.when(() -> IrisWorlds.readBukkitWorldSeed("moon")).thenReturn(1337L);
String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null)
.resolveDefaultWorldGenerator("moon", "overworld"));
assertTrue(refusal, refusal.contains("'moon'"));
assertTrue(refusal, refusal.contains("generation history is unusable"));
bukkit.verify(Bukkit::shutdown, never());
}
}
@Test
public void vanillaDimensionSlotWithoutFrozenPackIsNotOwned() throws Exception {
public void internalLoadsGetTheResolutionFailureWithoutShutdown() throws Exception {
File worldContainer = temporaryFolder.newFolder("internal-load").getCanonicalFile();
File levelRoot = new File(worldContainer, "world");
File dimensionRoot = new File(levelRoot, "dimensions/iris/moon");
assertTrue(new File(dimensionRoot, "iris/generation").mkdirs());
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
MockedStatic<Iris> iris = mockStatic(Iris.class);
MockedStatic<IrisWorlds> worlds = mockStatic(IrisWorlds.class)) {
mockServer(bukkit, worldContainer, levelRoot, List.of());
worlds.when(() -> IrisWorlds.readBukkitWorldSeed("moon")).thenReturn(1337L);
IllegalStateException failure = assertThrows(
IllegalStateException.class,
() -> new IrisWorldGeneratorResolver(null).requireWorldGenerator("moon", "overworld"));
assertTrue(failure.getMessage(), failure.getMessage().contains("generation history is unusable"));
bukkit.verify(Bukkit::shutdown, never());
iris.verify(() -> Iris.error(any(String.class), any(Object[].class)), never());
}
}
/**
* A vanilla slot bound to Iris in bukkit.yml with no Iris storage stops startup, so no command can run until the
* binding is gone. /iris create is for new worlds anyway; a vanilla slot is taken over with /iris replace.
*/
@Test
public void vanillaDimensionSlotWithoutFrozenPackFailsClosed() throws Exception {
File worldContainer = temporaryFolder.newFolder("vanilla-slot");
File levelRoot = new File(worldContainer, "world");
assertTrue(new File(levelRoot, "dimensions/minecraft/the_nether").mkdirs());
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
Server server = mock(Server.class);
when(server.getLevelDirectory()).thenReturn(levelRoot.toPath());
bukkit.when(Bukkit::getServer).thenReturn(server);
bukkit.when(Bukkit::getWorldContainer).thenReturn(worldContainer);
bukkit.when(Bukkit::getWorlds).thenReturn(List.of());
mockServer(bukkit, worldContainer, levelRoot, List.of());
IllegalStateException failure = assertThrows(
IllegalStateException.class,
() -> new IrisWorldGeneratorResolver(null)
.resolveDefaultWorldGenerator("world_nether", "overworld"));
String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null)
.resolveDefaultWorldGenerator("world_nether", "overworld"));
assertTrue(failure.getMessage(), failure.getMessage().contains("minecraft:the_nether"));
assertTrue(failure.getMessage(), failure.getMessage().contains("/iris create"));
assertTrue(refusal, refusal.contains("minecraft:the_nether"));
assertTrue(refusal, refusal.contains("worlds.world_nether.generator"));
assertTrue(refusal, refusal.contains("bukkit.yml"));
assertTrue(refusal, refusal.contains("/iris replace minecraft:the_nether type=<pack>"));
assertFalse(refusal, refusal.contains("/iris create"));
bukkit.verify(Bukkit::shutdown, never());
}
}
/**
* The fix for a vanilla slot bound to Iris is removing the binding. Given for an Iris world whose history is
* gone, that fix loads the world under the vanilla generator, which writes vanilla terrain into it.
*/
@Test
public void vanillaSlotHoldingAnIrisWorldKeepsItsBindingAndPointsAtTheHistory() throws Exception {
File worldContainer = temporaryFolder.newFolder("iris-in-vanilla-slot");
File levelRoot = new File(worldContainer, "world");
File dimensionRoot = new File(levelRoot, "dimensions/minecraft/overworld");
assertTrue(new File(dimensionRoot, "iris/engine-data").mkdirs());
assertTrue(new File(dimensionRoot, "data/minecraft").mkdirs());
assertTrue(new File(dimensionRoot, "data/minecraft/world_gen_settings.dat").createNewFile());
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
mockServer(bukkit, worldContainer, levelRoot, List.of());
String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null)
.resolveDefaultWorldGenerator("world", "overworld"));
assertTrue(refusal, refusal.contains("minecraft:overworld"));
assertTrue(refusal, refusal.contains("is an Iris world"));
assertTrue(refusal, refusal.contains(new File(dimensionRoot, "iris/generation").getPath()));
assertFalse(refusal, refusal.contains("Remove worlds.world.generator"));
assertFalse(refusal, refusal.contains("/iris replace"));
}
}
/**
* A start refused before the overworld existed still leaves level.dat behind, and vanilla cannot start from a
* level.dat that has no world generation settings next to it.
*/
@Test
public void vanillaSlotRefusalOnANeverCreatedLevelNamesTheLeftoverLevelDat() throws Exception {
File worldContainer = temporaryFolder.newFolder("never-created");
File levelRoot = new File(worldContainer, "world");
assertTrue(levelRoot.mkdirs());
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
mockServer(bukkit, worldContainer, levelRoot, List.of());
String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null)
.resolveDefaultWorldGenerator("world", "overworld"));
assertTrue(refusal, refusal.contains("Remove worlds.world.generator"));
assertTrue(refusal, refusal.contains(new File(levelRoot, "level.dat").getPath()));
}
}
@Test
public void vanillaSlotRefusalOnAnExistingLevelLeavesLevelDatAlone() throws Exception {
File worldContainer = temporaryFolder.newFolder("existing-vanilla");
File levelRoot = new File(worldContainer, "world");
File settings = new File(levelRoot, "dimensions/minecraft/overworld/data/minecraft/world_gen_settings.dat");
assertTrue(settings.getParentFile().mkdirs());
assertTrue(settings.createNewFile());
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
mockServer(bukkit, worldContainer, levelRoot, List.of());
String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null)
.resolveDefaultWorldGenerator("world", "overworld"));
assertTrue(refusal, refusal.contains("Remove worlds.world.generator"));
assertFalse(refusal, refusal.contains("level.dat"));
}
}
@Test
public void alreadyLoadedIrisKeyIsRefusedBeforeAnyEngineCanStart() throws Exception {
File worldContainer = temporaryFolder.newFolder("duplicate-key");
@@ -468,23 +573,39 @@ public class IrisWorldGeneratorResolverTest {
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
Server server = mock(Server.class);
when(server.getLevelDirectory()).thenReturn(levelRoot.toPath());
bukkit.when(Bukkit::getServer).thenReturn(server);
bukkit.when(Bukkit::getWorldContainer).thenReturn(worldContainer);
bukkit.when(Bukkit::getWorlds).thenReturn(List.of(loaded));
mockServer(bukkit, worldContainer, levelRoot, List.of(loaded));
IllegalStateException failure = assertThrows(
IllegalStateException.class,
() -> new IrisWorldGeneratorResolver(null)
.resolveDefaultWorldGenerator("irisworld", "overworld"));
String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null)
.resolveDefaultWorldGenerator("irisworld", "overworld"));
assertTrue(failure.getMessage(), failure.getMessage().contains("iris:irisworld"));
assertTrue(failure.getMessage(), failure.getMessage().contains("world_iris_irisworld"));
assertTrue(refusal, refusal.contains("iris:irisworld"));
assertTrue(refusal, refusal.contains("world_iris_irisworld"));
bukkit.verify(Bukkit::shutdown, never());
}
}
private static void mockServer(MockedStatic<Bukkit> bukkit, File worldContainer, File levelRoot, List<World> worlds) {
Server server = mock(Server.class);
when(server.getLevelDirectory()).thenReturn(levelRoot.toPath());
bukkit.when(Bukkit::getServer).thenReturn(server);
bukkit.when(Bukkit::getWorldContainer).thenReturn(worldContainer);
bukkit.when(Bukkit::getWorlds).thenReturn(worlds);
}
/**
* Resolves through CraftServer's fallback and proves the result is Iris' refusal rather than vanilla: Paper
* calls getDefaultBiomeProvider before the level exists, and that throw is what stops the level being created.
*/
private static String assertFailsClosed(Supplier<ChunkGenerator> resolution) {
ChunkGenerator vanillaFallback = mock(ChunkGenerator.class);
ChunkGenerator selected = craftBukkitGeneratorOrFallback(resolution, vanillaFallback);
assertNotSame(vanillaFallback, selected);
IllegalStateException refusal = assertThrows(
IllegalStateException.class,
() -> selected.getDefaultBiomeProvider(mock(WorldInfo.class)));
return refusal.getMessage();
}
private static void writeValidPack(Path packRoot) throws Exception {
Files.createDirectories(packRoot.resolve("dimensions"));
Files.createDirectories(packRoot.resolve("regions"));
@@ -0,0 +1,145 @@
package art.arcane.iris.world;
import art.arcane.iris.Iris;
import net.kyori.adventure.text.Component;
import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.world.WorldLoadEvent;
import org.bukkit.plugin.Plugin;
import org.bukkit.plugin.PluginManager;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Whether a refused world stops startup depends on who asked for it: Paper's startup level creation aborts the
* server, a runtime create only fails its caller. The banner must not claim the wrong one.
*/
public class WorldRefusalReporterTest {
private static final NamespacedKey MOON = new NamespacedKey("iris", "moon");
@Test
public void aRefusalBeforeAnyWorldLoadedSaysStartupStops() {
String banner = reportAndCapture(new WorldRefusalReporter());
assertTrue(banner, banner.contains("'moon'"));
assertTrue(banner, banner.contains("iris:moon"));
assertTrue(banner, banner.contains("Iris generation history is unusable at /srv/moon."));
assertTrue(banner, banner.contains("Historical generated registry definition changed for iris:biomes/abc"));
assertTrue(banner, banner.contains("no chunks"));
assertTrue(banner, banner.contains("startup stops"));
}
/**
* Paper creates every startup level before it prepares any, and preparing one fires the first WorldLoadEvent.
* A plugin enabling at POSTWORLD creates its worlds after that, before the first tick, and the server keeps
* running when that create fails.
*/
@Test
public void aRefusalAfterTheFirstWorldLoadSaysTheServerKeepsRunning() {
WorldRefusalReporter reporter = new WorldRefusalReporter();
reporter.onWorldLoad(new WorldLoadEvent(world(NamespacedKey.minecraft("overworld"))));
String banner = reportAndCapture(reporter);
assertTrue(banner, banner.contains("keeps running"));
assertFalse(banner, banner.contains("startup stops"));
}
@Test
public void irisEnabledAfterWorldsExistTreatsEveryRefusalAsRuntime() {
WorldRefusalReporter reporter = new WorldRefusalReporter();
Plugin plugin = mock(Plugin.class);
PluginManager plugins = mock(PluginManager.class);
World overworld = world(NamespacedKey.minecraft("overworld"));
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class)) {
bukkit.when(Bukkit::getWorlds).thenReturn(List.of(overworld));
bukkit.when(Bukkit::getPluginManager).thenReturn(plugins);
reporter.attach(plugin);
}
assertTrue(reporter.startupWorldsCreated());
verify(plugins).registerEvents(reporter, plugin);
String banner = reportAndCapture(reporter);
assertTrue(banner, banner.contains("keeps running"));
}
@Test
public void operatorsHearAboutARefusedWorldOnJoinUntilItLoads() {
WorldRefusalReporter reporter = new WorldRefusalReporter();
reportAndCapture(reporter);
Player operator = player(true);
Player member = player(false);
reporter.onPlayerJoin(new PlayerJoinEvent(operator, Component.empty()));
reporter.onPlayerJoin(new PlayerJoinEvent(member, Component.empty()));
verify(operator).sendMessage(contains("iris:moon"));
verify(member, never()).sendMessage(anyString());
reporter.onWorldLoad(new WorldLoadEvent(world(MOON)));
Player later = player(true);
reporter.onPlayerJoin(new PlayerJoinEvent(later, Component.empty()));
verify(later, never()).sendMessage(anyString());
}
@Test
public void aFailureWhileReportingNeverEscapes() {
Throwable unreadable = new IllegalStateException() {
@Override
public String getMessage() {
throw new UnsupportedOperationException("message unavailable");
}
};
try (MockedStatic<Iris> iris = mockStatic(Iris.class)) {
new WorldRefusalReporter().report("moon", MOON, unreadable);
}
}
private static String reportAndCapture(WorldRefusalReporter reporter) {
IllegalStateException failure = new IllegalStateException("Iris generation history is unusable at /srv/moon.",
new IOException("Historical generated registry definition changed for iris:biomes/abc"));
List<String> lines = new ArrayList<>();
try (MockedStatic<Iris> iris = mockStatic(Iris.class)) {
iris.when(() -> Iris.error(anyString(), any(Object[].class))).thenAnswer(invocation -> {
lines.add(invocation.getArgument(0, String.class));
return null;
});
reporter.report("moon", MOON, failure);
}
return String.join("\n", lines);
}
private static World world(NamespacedKey key) {
World world = mock(World.class);
when(world.getKey()).thenReturn(key);
when(world.getName()).thenReturn(key.getKey());
return world;
}
private static Player player(boolean operator) {
Player player = mock(Player.class);
when(player.isOp()).thenReturn(operator);
return player;
}
}
+10 -11
View File
@@ -32,11 +32,7 @@ buildscript {
maven { url = uri('https://jitpack.io') }
}
dependencies {
Properties libraryProperties = new Properties()
file('../../gradle.properties').withInputStream { InputStream stream -> libraryProperties.load(stream) }
String libraryCoordinate = providers.gradleProperty('volmLibCoordinate')
.getOrElse(libraryProperties.getProperty('volmLibCoordinate'))
classpath("com.github.VolmitSoftware.VolmLib:packaging:${libraryCoordinate.tokenize(':').last()}")
classpath('com.github.VolmitSoftware.VolmLib:packaging:master-SNAPSHOT')
}
}
@@ -56,13 +52,13 @@ interface IrisFileOperations {
IrisFileOperations irisFileOperations = objects.newInstance(IrisFileOperations)
apply from: file('../../gradle/modded-generation-build-revision.gradle')
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 minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.2'))
String fabricLoaderVersion = providers.gradleProperty('fabricLoaderVersion').getOrElse(rootProperties.getProperty('fabricLoaderVersion', '0.19.3'))
String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate').getOrElse(rootProperties.getProperty('volmLibCoordinate', 'com.github.VolmitSoftware:VolmLib:master-SNAPSHOT'))
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 ->
return "Iris v${project.version} [${platform}] ${targetVersion}.jar"
}
@@ -83,7 +79,6 @@ sourceSets {
main {
java {
srcDir '../modded-common/src/main/java'
srcDir '../minecraft-common/src/main/java'
srcDir '../client-common/src/main/java'
}
resources {
@@ -181,10 +176,10 @@ dependencies {
compileOnly(libs.spigot) {
transitive = false
}
add('bundle', volmLibCoordinate) {
add('bundle', 'com.github.VolmitSoftware.VolmLib:volmlib-shared:master-SNAPSHOT') {
transitive = false
}
add('devBundle', volmLibCoordinate) {
add('devBundle', 'com.github.VolmitSoftware.VolmLib:volmlib-shared:master-SNAPSHOT') {
transitive = false
}
add('bundle', libs.zip) {
@@ -201,6 +196,7 @@ dependencies {
libs.asm,
libs.lru,
libs.caffeine,
'com.fulcrumgenomics:jlibdeflate:0.2.0',
libs.dom4j,
libs.jaxen
]
@@ -286,6 +282,9 @@ tasks.named('shadowJar', ShadowJar).configure {
spec.delete(staleLoaderJars, legacyLoaderJar)
}
}
from(tasks.named('generateVolmLibStamp')) {
into('META-INF/volmit')
}
archiveFileName.set(irisArtifactName('Fabric', "${minecraftVersion}+${fabricLoaderVersion}"))
configurations = [project.configurations.named('bundle').get()]
mergeServiceFiles()
@@ -17,6 +17,7 @@ public final class IrisFabricBootstrap implements ModInitializer {
public void onInitialize() {
NativeFabricLoader loader = new NativeFabricLoader(new NativeLoaderOptions(
"irisworldgen", "treefeller", ModdedTreeFellerService::runBreakProbe));
loader.captureOriginalBiomes();
NativeChunkGeneratorDefinition generator = IrisModdedChunkGenerator.DEFINITION;
NativeFabricPackSources.bind(ModdedForcedDatapack::repositorySource);
ModdedEngineBootstrap.bootCommon(loader, "Fabric", () -> NativeFabricBootstrap.registerGenerator(generator));
@@ -70,7 +70,7 @@
}
],
"depends": {
"fabricloader": ">=0.19.3",
"fabricloader": ">=0.19.5",
"minecraft": "~${minecraftVersion}",
"java": ">=25"
}
@@ -0,0 +1,108 @@
package art.arcane.volmlib.nativelib.minecraft26_2.fabric;
import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeRegistryDefinitions;
import art.arcane.volmlib.nativelib.modded.NativeLoaderOptions;
import com.google.gson.JsonParser;
import com.mojang.serialization.JsonOps;
import com.mojang.serialization.Lifecycle;
import net.minecraft.SharedConstants;
import net.minecraft.core.Holder;
import net.minecraft.core.HolderSet;
import net.minecraft.core.MappedRegistry;
import net.minecraft.core.RegistrationInfo;
import net.minecraft.core.RegistryAccess;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.RegistryOps;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.Bootstrap;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.biome.BiomeGenerationSettings;
import net.minecraft.world.level.levelgen.GenerationStep;
import net.minecraft.world.level.levelgen.carver.WorldCarver;
import net.minecraft.world.level.levelgen.feature.Feature;
import net.minecraft.world.level.levelgen.placement.PlacedFeature;
import org.junit.BeforeClass;
import org.junit.Test;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
import java.util.function.BooleanSupplier;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
public class NativeFabricBiomeDefinitionTest {
private static final String BIOME_REGISTRY = "minecraft:worldgen/biome";
private static final String BIOME_KEY = "iris:biomes/fabric_modified";
private static final String SOURCE_JSON = "{\"has_precipitation\":true,\"temperature\":0.8,\"downfall\":0.4,"
+ "\"effects\":{\"water_color\":\"#3f76e4\"},\"carvers\":[],\"features\":[]}";
@BeforeClass
public static void bootstrap() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
}
@Test
public void generatedBiomeDefinitionExcludesFabricBiomeModifications() throws Exception {
MappedRegistry<PlacedFeature> features = new MappedRegistry<>(Registries.PLACED_FEATURE, Lifecycle.stable());
Holder.Reference<PlacedFeature> ore = features.register(
ResourceKey.create(Registries.PLACED_FEATURE, Identifier.parse("iris:modded_ore")),
new PlacedFeature(Holder.direct(mock(Feature.class)), List.of()),
RegistrationInfo.BUILT_IN);
features.freeze();
MappedRegistry<WorldCarver> carvers = new MappedRegistry<>(Registries.CARVER, Lifecycle.stable());
carvers.freeze();
MappedRegistry<Biome> biomes = new MappedRegistry<>(Registries.BIOME, Lifecycle.stable());
RegistryAccess access = new RegistryAccess.ImmutableRegistryAccess(List.of(biomes, features, carvers));
Biome source = Biome.DIRECT_CODEC.parse(RegistryOps.create(JsonOps.INSTANCE, access),
JsonParser.parseString(SOURCE_JSON)).getOrThrow();
NativeFabricLoader loader = new NativeFabricLoader(new NativeLoaderOptions("irisworldgen", "treefeller",
BooleanSupplier::getAsBoolean));
loader.recordOriginalBiome(source);
Holder.Reference<Biome> biome = biomes.register(
ResourceKey.create(Registries.BIOME, Identifier.parse(BIOME_KEY)), source, RegistrationInfo.BUILT_IN);
biomes.freeze();
addFeatureInPlace(biome.value().getGenerationSettings(), ore);
warmInPlace(biome.value());
NativeRegistryDefinitions definitions = new NativeRegistryDefinitions(() -> access, loader::unmodifiedBiome);
assertEquals(0.1F, biome.value().getBaseTemperature(), 0.0F);
assertTrue(biome.value().getGenerationSettings().features()
.get(GenerationStep.Decoration.UNDERGROUND_ORES.ordinal()).contains(ore));
assertEquals(JsonParser.parseString(definitions.canonicalDefinition(BIOME_REGISTRY, BIOME_KEY, SOURCE_JSON).value()),
JsonParser.parseString(definitions.generatedDefinition(BIOME_REGISTRY, BIOME_KEY).value()));
}
/**
* What a Fabric biome modification that adds an ore does: it replaces the feature list of the registered biome's
* own generation settings.
*/
private static void addFeatureInPlace(BiomeGenerationSettings settings, Holder<PlacedFeature> feature)
throws ReflectiveOperationException {
List<HolderSet<PlacedFeature>> steps = new ArrayList<>(settings.features());
while (steps.size() <= GenerationStep.Decoration.UNDERGROUND_ORES.ordinal()) {
steps.add(HolderSet.direct());
}
steps.set(GenerationStep.Decoration.UNDERGROUND_ORES.ordinal(), HolderSet.direct(feature));
Field features = BiomeGenerationSettings.class.getDeclaredField("features");
features.setAccessible(true);
features.set(settings, List.copyOf(steps));
}
/**
* What a Fabric weather modification does: it replaces the registered biome's climate settings.
*/
private static void warmInPlace(Biome biome) throws ReflectiveOperationException {
Constructor<Biome.ClimateSettings> climate = Biome.ClimateSettings.class.getDeclaredConstructor(
boolean.class, float.class, Biome.TemperatureModifier.class, float.class);
climate.setAccessible(true);
Field climateSettings = Biome.class.getDeclaredField("climateSettings");
climateSettings.setAccessible(true);
climateSettings.set(biome, climate.newInstance(true, 0.1F, Biome.TemperatureModifier.NONE, 0.4F));
}
}
@@ -0,0 +1,43 @@
package art.arcane.volmlib.nativelib.minecraft26_2.fabric;
import art.arcane.volmlib.nativelib.modded.NativeLoaderOptions;
import net.minecraft.SharedConstants;
import net.minecraft.server.Bootstrap;
import net.minecraft.world.entity.EntitySpawnReason;
import net.minecraft.world.entity.Mob;
import net.minecraft.world.level.ServerLevelAccessor;
import org.junit.BeforeClass;
import org.junit.Test;
import java.util.function.BooleanSupplier;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class NativeFabricSpawnPositionTest {
@BeforeClass
public static void bootstrap() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
}
@Test
public void chunkGenerationSpawnsUseTheVanillaRulesAndObstructionChecks() {
NativeFabricLoader loader = new NativeFabricLoader(
new NativeLoaderOptions("irisworldgen", "treefeller", BooleanSupplier::getAsBoolean));
ServerLevelAccessor level = mock(ServerLevelAccessor.class);
Mob mob = mock(Mob.class);
when(mob.checkSpawnRules(level, EntitySpawnReason.CHUNK_GENERATION)).thenReturn(true);
when(mob.checkSpawnObstruction(level)).thenReturn(true);
assertTrue(loader.checkSpawnPosition(mob, level, EntitySpawnReason.CHUNK_GENERATION));
when(mob.checkSpawnObstruction(level)).thenReturn(false);
assertFalse(loader.checkSpawnPosition(mob, level, EntitySpawnReason.CHUNK_GENERATION));
when(mob.checkSpawnObstruction(level)).thenReturn(true);
when(mob.checkSpawnRules(level, EntitySpawnReason.CHUNK_GENERATION)).thenReturn(false);
assertFalse(loader.checkSpawnPosition(mob, level, EntitySpawnReason.CHUNK_GENERATION));
}
}
+13 -14
View File
@@ -32,17 +32,13 @@ buildscript {
maven { url = uri('https://jitpack.io') }
}
dependencies {
Properties libraryProperties = new Properties()
file('../../gradle.properties').withInputStream { InputStream stream -> libraryProperties.load(stream) }
String libraryCoordinate = providers.gradleProperty('volmLibCoordinate')
.getOrElse(libraryProperties.getProperty('volmLibCoordinate'))
classpath("com.github.VolmitSoftware.VolmLib:packaging:${libraryCoordinate.tokenize(':').last()}")
classpath('com.github.VolmitSoftware.VolmLib:packaging:master-SNAPSHOT')
}
}
plugins {
id 'java'
id 'net.minecraftforge.gradle' version '7.0.31'
id 'net.minecraftforge.gradle' version '7.0.40'
alias(libs.plugins.shadow)
}
@@ -56,13 +52,13 @@ interface IrisFileOperations {
IrisFileOperations irisFileOperations = objects.newInstance(IrisFileOperations)
apply from: file('../../gradle/modded-generation-build-revision.gradle')
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 minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.2'))
String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse(rootProperties.getProperty('forgeVersion', '26.2-65.1.1'))
String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate').getOrElse(rootProperties.getProperty('volmLibCoordinate', 'com.github.VolmitSoftware:VolmLib:master-SNAPSHOT'))
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 ->
String coordinatePrefix = "${minecraftVersion}-"
if (loaderVersion.startsWith(coordinatePrefix)) {
@@ -91,7 +87,6 @@ sourceSets {
main {
java {
srcDir '../modded-common/src/main/java'
srcDir '../minecraft-common/src/main/java'
srcDir '../client-common/src/main/java'
}
resources {
@@ -174,13 +169,13 @@ dependencies {
compileOnly(libs.spigot) {
transitive = false
}
add('bundle', volmLibCoordinate) {
add('bundle', 'com.github.VolmitSoftware.VolmLib:volmlib-shared:master-SNAPSHOT') {
transitive = false
}
add('devBundle', volmLibCoordinate) {
add('devBundle', 'com.github.VolmitSoftware.VolmLib:volmlib-shared:master-SNAPSHOT') {
transitive = false
}
add('runBundle', volmLibCoordinate) {
add('runBundle', 'com.github.VolmitSoftware.VolmLib:volmlib-shared:master-SNAPSHOT') {
transitive = false
}
add('bundle', libs.zip) {
@@ -200,6 +195,7 @@ dependencies {
libs.asm,
libs.lru,
libs.caffeine,
'com.fulcrumgenomics:jlibdeflate:0.2.0',
libs.dom4j,
libs.jaxen
]
@@ -271,8 +267,11 @@ tasks.named('shadowJar', ShadowJar).configure {
spec.delete(staleLoaderJars, legacyLoaderJar)
}
}
from(tasks.named('generateVolmLibStamp')) {
into('META-INF/volmit')
}
manifest {
// FML 26.2 has no TOML mixin handling at all (ModFileParser reads neither [[mixins]] nor
// FML 26.3 has no TOML mixin handling at all (ModFileParser reads neither [[mixins]] nor
// accessTransformers on Forge). This manifest attribute is the only registration mechanism;
// do not move it into mods.toml.
attributes('MixinConfigs': 'volmlib.entity.mixins.json,volmlib.client.mixins.json')
@@ -1,5 +1,5 @@
modLoader = "javafml"
loaderVersion = "[65,66)"
loaderVersion = "[66,67)"
license = "GPL-3.0"
issueTrackerURL = "https://github.com/VolmitSoftware/Iris/issues"
@@ -16,7 +16,7 @@ logoFile = "assets/irisworldgen/icon.png"
[[dependencies.irisworldgen]]
modId = "forge"
mandatory = true
versionRange = "[65,66)"
versionRange = "[66,67)"
ordering = "NONE"
side = "BOTH"
@@ -0,0 +1,100 @@
package art.arcane.volmlib.nativelib.minecraft26_2.forge;
import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeRegistryDefinitions;
import art.arcane.volmlib.nativelib.modded.NativeLoaderOptions;
import com.google.gson.JsonParser;
import com.mojang.serialization.JsonOps;
import com.mojang.serialization.Lifecycle;
import com.mojang.serialization.MapCodec;
import net.minecraft.SharedConstants;
import net.minecraft.core.Holder;
import net.minecraft.core.MappedRegistry;
import net.minecraft.core.RegistrationInfo;
import net.minecraft.core.RegistryAccess;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.RegistryOps;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.Bootstrap;
import net.minecraft.world.attribute.EnvironmentAttributes;
import net.minecraft.world.attribute.modifier.MobSpawnSettingsModifier;
import net.minecraft.world.entity.EntityTypes;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.biome.MobSpawnSettings;
import net.minecraft.world.level.levelgen.GenerationStep;
import net.minecraft.world.level.levelgen.carver.WorldCarver;
import net.minecraft.world.level.levelgen.feature.Feature;
import net.minecraft.world.level.levelgen.placement.PlacedFeature;
import net.minecraftforge.common.world.BiomeModifier;
import net.minecraftforge.common.world.ModifiableBiomeInfo;
import org.junit.BeforeClass;
import org.junit.Test;
import java.util.List;
import java.util.function.BooleanSupplier;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
public class NativeForgeBiomeDefinitionTest {
private static final String BIOME_REGISTRY = "minecraft:worldgen/biome";
private static final String BIOME_KEY = "iris:biomes/forge_modified";
private static final String SOURCE_JSON = "{\"has_precipitation\":true,\"temperature\":0.8,\"downfall\":0.4,"
+ "\"effects\":{\"water_color\":\"#3f76e4\"},\"carvers\":[],\"features\":[]}";
@BeforeClass
public static void bootstrap() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
}
@Test
public void generatedBiomeDefinitionExcludesForgeBiomeModifierOutput() {
MappedRegistry<PlacedFeature> features = new MappedRegistry<>(Registries.PLACED_FEATURE, Lifecycle.stable());
Holder.Reference<PlacedFeature> ore = features.register(
ResourceKey.create(Registries.PLACED_FEATURE, Identifier.parse("iris:modded_ore")),
new PlacedFeature(Holder.direct(mock(Feature.class)), List.of()),
RegistrationInfo.BUILT_IN);
features.freeze();
MappedRegistry<WorldCarver> carvers = new MappedRegistry<>(Registries.CARVER, Lifecycle.stable());
carvers.freeze();
MappedRegistry<Biome> biomes = new MappedRegistry<>(Registries.BIOME, Lifecycle.stable());
RegistryAccess access = new RegistryAccess.ImmutableRegistryAccess(List.of(biomes, features, carvers));
Biome source = Biome.DIRECT_CODEC.parse(RegistryOps.create(JsonOps.INSTANCE, access),
JsonParser.parseString(SOURCE_JSON)).getOrThrow();
Holder.Reference<Biome> biome = biomes.register(
ResourceKey.create(Registries.BIOME, Identifier.parse(BIOME_KEY)), source, RegistrationInfo.BUILT_IN);
biomes.freeze();
biome.value().modifiableBiomeInfo().applyBiomeModifiers(biome, List.of(new ModdedContent(ore)));
NativeRegistryDefinitions definitions = new NativeRegistryDefinitions(() -> access,
new NativeForgeLoader(new NativeLoaderOptions("irisworldgen", "treefeller",
BooleanSupplier::getAsBoolean))::unmodifiedBiome);
assertEquals(0.1F, biome.value().getBaseTemperature(), 0.0F);
assertEquals(0x102030, biome.value().getWaterColor() & 0xFFFFFF);
assertTrue(biome.value().getGenerationSettings().hasFeature(ore.value()));
assertTrue(biome.value().getAttributes().contains(EnvironmentAttributes.NATURAL_MOB_SPAWNS));
assertEquals(JsonParser.parseString(definitions.canonicalDefinition(BIOME_REGISTRY, BIOME_KEY, SOURCE_JSON).value()),
JsonParser.parseString(definitions.generatedDefinition(BIOME_REGISTRY, BIOME_KEY).value()));
}
private record ModdedContent(Holder<PlacedFeature> ore) implements BiomeModifier {
@Override
public void modify(Holder<Biome> biome, Phase phase, ModifiableBiomeInfo.BiomeInfo.Builder builder) {
if (phase != Phase.ADD) {
return;
}
builder.climateSettings().setTemperature(0.1F);
builder.effects().waterColor(0x102030);
builder.attributes().modify(EnvironmentAttributes.NATURAL_MOB_SPAWNS, MobSpawnSettingsModifier.overlay(),
new MobSpawnSettings.Builder().addSpawn(EntityTypes.ZOMBIE, 5, 1, 2).build());
builder.generationSettings().addFeature(GenerationStep.Decoration.UNDERGROUND_ORES, ore);
}
@Override
public MapCodec<? extends BiomeModifier> codec() {
throw new UnsupportedOperationException();
}
}
}
@@ -0,0 +1,52 @@
package art.arcane.volmlib.nativelib.minecraft26_2.forge;
import art.arcane.volmlib.nativelib.modded.NativeLoaderOptions;
import net.minecraft.SharedConstants;
import net.minecraft.server.Bootstrap;
import net.minecraft.world.entity.EntitySpawnReason;
import net.minecraft.world.entity.Mob;
import net.minecraft.world.level.ServerLevelAccessor;
import net.minecraftforge.common.util.Result;
import net.minecraftforge.event.entity.living.MobSpawnEvent;
import net.minecraftforge.eventbus.api.listener.EventListener;
import org.junit.BeforeClass;
import org.junit.Test;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BooleanSupplier;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class NativeForgeSpawnPositionTest {
@BeforeClass
public static void bootstrap() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
}
@Test
public void chunkGenerationSpawnsFireThePositionCheckEventAndHonorItsDenial() {
NativeForgeLoader loader = new NativeForgeLoader(
new NativeLoaderOptions("irisworldgen", "treefeller", BooleanSupplier::getAsBoolean));
ServerLevelAccessor level = mock(ServerLevelAccessor.class);
Mob mob = mock(Mob.class);
when(mob.checkSpawnRules(level, EntitySpawnReason.CHUNK_GENERATION)).thenReturn(true);
when(mob.checkSpawnObstruction(level)).thenReturn(true);
AtomicReference<EntitySpawnReason> observed = new AtomicReference<>();
EventListener listener = MobSpawnEvent.PositionCheck.BUS.addListener((MobSpawnEvent.PositionCheck event) -> {
observed.set(event.getSpawnReason());
event.setResult(Result.DENY);
});
try {
assertFalse(loader.checkSpawnPosition(mob, level, EntitySpawnReason.CHUNK_GENERATION));
} finally {
MobSpawnEvent.PositionCheck.BUS.removeListener(listener);
}
assertEquals(EntitySpawnReason.CHUNK_GENERATION, observed.get());
assertTrue(loader.checkSpawnPosition(mob, level, EntitySpawnReason.CHUNK_GENERATION));
}
}
@@ -44,6 +44,7 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeFeatureBiomeSourc
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeDimensionRuntime;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedLoader;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeGeneratorOwner;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeGeneratorHandle;
@@ -58,6 +59,7 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeGeneratorContext;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedStructureStage;
import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
import art.arcane.iris.structure.nativegen.NativeStructureOwnershipRecord;
import art.arcane.iris.structure.nativegen.IrisStructurePolicy;
@@ -66,12 +68,12 @@ import art.arcane.iris.world.history.TerrainNativeBlockKeys;
import art.arcane.volmlib.nativelib.terrain.NativeChunkWritePolicy;
import art.arcane.iris.world.history.TerrainBoundarySignature;
import art.arcane.iris.world.history.NativeBiomeSpawnSelection;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.pack.PackValidationRegistry;
import art.arcane.iris.generation.runtime.IrisEngine;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.GenerationFailures;
import art.arcane.iris.generation.runtime.GenerationSessionException;
import art.arcane.iris.generation.runtime.GenerationSessionLease;
import art.arcane.iris.structure.nativegen.NativeFeatureGenerationPolicy;
@@ -454,11 +456,14 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
private Engine bindEngine(NativeWorld level) {
requireBindingAllowed();
// 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) {
engineBinding.fail(error);
recordBindFailure(level, error);
throw error;
}
// Cache the owning level so hot paths never scan the level map to find themselves.
@@ -500,7 +505,7 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
engineBinding.complete(created);
return created;
} catch (Throwable error) {
engineBinding.fail(error);
recordBindFailure(level, error);
if (error instanceof RuntimeException runtimeException) {
throw runtimeException;
}
@@ -512,6 +517,31 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
}
}
private void recordBindFailure(NativeWorld level, Throwable error) {
// A runtime that is stopping says nothing about the world: fail this call, let the next bind try again.
if (GenerationFailures.isShutdownFailure(error)) {
ModdedIrisLog.warn("Iris bind of {} ({}) stopped with its runtime: {}", level.name(), dimensionKey,
error.toString());
return;
}
engineBinding.fail(error);
for (String line : bindFailureNotice(level.name(), dimensionKey, error)) {
ModdedIrisLog.error(line);
}
}
static List<String> bindFailureNotice(String levelName, String dimensionKey, Throwable failure) {
return GenerationRefusalNotice.compose(
"Iris refused to generate " + levelName + " (generator " + dimensionKey + ")",
GenerationRefusalNotice.causes(failure),
List.of(
"Iris does not generate this level and does not let vanilla or any other generator"
+ " write it; no chunks are written.",
"A startup level stops the server; a runtime dimension stays unloaded."
+ " Fix the cause, then start the server again."
));
}
private void requireCompletedShutdown(Engine current) {
if (current == null || !current.isClosing() || current.isClosed()) {
return;
@@ -565,21 +595,11 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
}
}
Engine engineOrNull() {
requireBindingAllowed();
Engine cached = engine;
requireCompletedShutdown(cached);
if (cached != null && !cached.isClosed()) {
return cached;
}
try {
return engine();
} catch (Throwable ignored) {
return null;
}
}
Engine structureEngineOrNull() {
/**
* Null only while no level uses this generator (world creation screens, codec validation). A bound level whose
* engine is refused, unloading or stopping throws, so no stage can mistake it for an unbound generator.
*/
Engine boundEngineOrNull() {
requireBindingAllowed();
engineBinding.throwIfFailed(dimensionKey);
Engine cached = engine;
@@ -984,7 +1004,7 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
@Override
public <H, S> NativeModdedBiomePolicy<H, S> biomePolicy(NativeBiomeSourceAccess<H, S> source) {
ModdedBiomePolicy<H, S> policy = new ModdedBiomePolicy<>(source);
policy.bind(new ModdedBiomePolicy.RuntimeCallbacks(this::structureEngineOrNull, this::awaitStructureEngine,
policy.bind(new ModdedBiomePolicy.RuntimeCallbacks(this::boundEngineOrNull, this::awaitStructureEngine,
this::allowsGenerationHistoryBypass, this::configuredStructureBiomeKeys,
IrisModdedChunkGenerator::retainedBiomeKeys, this::visibleBiomeSeed));
return policy;
@@ -1012,6 +1032,11 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
return ModdedEngineBootstrap.currentServer();
}
@Override
public NativeModdedLoader loader() {
return ModdedEngineBootstrap.loader();
}
@Override
public Engine current() {
return engine();
@@ -1086,7 +1111,7 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
@Override
public NativeBlockColumn resolvedColumn(Engine current, int x, int z) {
return current.getComplex().resolvedTerrainColumn(x, z).map(TerrainBoundarySignature::geometry).orElse(null);
return null;
}
@Override
@@ -29,6 +29,7 @@ import java.util.function.LongSupplier;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.generation.stream.ProvisionalSampling;
import art.arcane.iris.generation.runtime.DimensionStackContext;
import art.arcane.iris.generation.runtime.DimensionStackLayout;
import art.arcane.iris.generation.runtime.DimensionTerrainContext;
@@ -71,6 +72,7 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
private volatile BiomeHolderTable<H> visibleBiomeCache = new BiomeHolderTable<>();
private volatile BiomeHolderTable<H> structureBiomeCache = new BiomeHolderTable<>();
private volatile BiomeHolderTable<H> surfaceStructureBiomeCache = new BiomeHolderTable<>();
private volatile BiomeHolderTable<H> naturalStructureBiomeCache = new BiomeHolderTable<>();
private volatile RuntimeCallbacks runtime;
private volatile Set<String> possibleStructureBiomeKeys;
private volatile BiomeKeySets biomeKeySets;
@@ -93,6 +95,7 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
visibleBiomeCache = new BiomeHolderTable<>();
structureBiomeCache = new BiomeHolderTable<>();
surfaceStructureBiomeCache = new BiomeHolderTable<>();
naturalStructureBiomeCache = new BiomeHolderTable<>();
warnedUnresolvedBiomeKeys.clear();
possibleStructureBiomeKeys = null;
biomeKeySets = null;
@@ -103,6 +106,7 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
visibleBiomeCache.evictRuntime(runtimeIdentity);
structureBiomeCache.evictRuntime(runtimeIdentity);
surfaceStructureBiomeCache.evictRuntime(runtimeIdentity);
naturalStructureBiomeCache.evictRuntime(runtimeIdentity);
}
/**
@@ -262,7 +266,7 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
@Override
public H getNoiseBiome(int quartX, int quartY, int quartZ, S sampler) {
Engine engine = engineOrNull();
Engine engine = boundEngineOrNull();
if (engine == null) {
return access.serializedNoise(quartX, quartY, quartZ, sampler);
}
@@ -284,7 +288,7 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
@Override
public NativeBiomeSourcePolicy.BiomeLocation<H> findClosestBiome3d(ClosestQuery<H, S> query) {
Engine engine = engineOrNull();
Engine engine = boundEngineOrNull();
if (engine == null) {
return query.unboundFallback().get();
}
@@ -338,7 +342,12 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
return cached;
}
}
H resolved = resolveStructureBiome(engine, quartX, quartY, quartZ, sampler);
H resolved;
try {
resolved = ProvisionalSampling.memoizable(() -> resolveStructureBiome(engine, quartX, quartY, quartZ, sampler));
} catch (ProvisionalSampling.Unmemoizable provisional) {
return provisional.value();
}
if (cacheable) {
cache.put(runtimeIdentity, key, resolved);
}
@@ -346,7 +355,7 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
}
public H getVisibleNoiseBiome(int quartX, int quartY, int quartZ, S sampler) {
Engine engine = engineOrNull();
Engine engine = boundEngineOrNull();
if (engine == null) {
return access.serializedNoise(quartX, quartY, quartZ, sampler);
}
@@ -388,8 +397,13 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
return cached;
}
}
H resolved = resolveVisibleBiome(
engine, quartX, quartY, quartZ, sampler, null);
H resolved;
try {
resolved = ProvisionalSampling.memoizable(() -> resolveVisibleBiome(
engine, quartX, quartY, quartZ, sampler, null));
} catch (ProvisionalSampling.Unmemoizable provisional) {
return provisional.value();
}
if (cacheable) {
cache.put(runtimeIdentity, key, resolved);
}
@@ -397,7 +411,7 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
}
public H getVisibleSurfaceBiome(int blockX, int blockZ) {
Engine engine = engineOrNull();
Engine engine = boundEngineOrNull();
if (engine == null) {
return null;
}
@@ -406,15 +420,15 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
try (historyScope) {
GenerationSessionLease lease = tryAcquireGenerationLease(engine, "modded_surface_spawn_biome");
if (lease == null) {
return null;
throw new IllegalStateException("Iris surface spawn biome lookup was rejected during an engine transition");
}
try (lease; IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) {
if (!isReady(engine)) {
return null;
throw new IllegalStateException("Iris surface spawn biome lookup has no active engine runtime");
}
NativeBiomeSourceAccess.RegistryView<H> registry = biomeRegistry();
if (registry == null) {
return null;
throw new IllegalStateException("Iris surface spawn biome lookup has no biome registry");
}
int quartX = ((blockX) >> 2);
int quartZ = ((blockZ) >> 2);
@@ -449,7 +463,7 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
}
public boolean isStructureReachable(Iterable<H> biomes) {
Engine engine = engineOrNull();
Engine engine = boundEngineOrNull();
if (engine == null) {
return isStructureReachable(biomes, possibleStructureBiomeKeys());
}
@@ -480,7 +494,7 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
int radius = query.radius();
S sampler = query.sampler();
int minQuartY = ((y - radius) >> 2);
Engine engine = engineOrNull();
Engine engine = boundEngineOrNull();
if (engine == null) {
return query.fallback().get();
}
@@ -531,7 +545,12 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
if (cached != null) {
return cached;
}
H resolved = resolveSurfaceStructureBiome(engine, quartX, quartZ, sampler);
H resolved;
try {
resolved = ProvisionalSampling.memoizable(() -> resolveSurfaceStructureBiome(engine, quartX, quartZ, sampler));
} catch (ProvisionalSampling.Unmemoizable provisional) {
return provisional.value();
}
cache.put(runtimeIdentity, key, resolved);
return resolved;
}
@@ -775,11 +794,40 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
if (!isReady(engine)) {
throw new IllegalStateException("Iris structure biome lookup has no active engine runtime");
}
return getNoiseBiome(engine, quartX, quartY, quartZ, sampler);
return getNaturalStructureBiome(engine, quartX, quartZ);
}
}
}
// The structure state only samples biomes for the concentric-ring search, which reaches ~24k blocks out for
// strongholds. The natural surface answers there without planning hydrology tiles, the same as the Bukkit ring search.
private H getNaturalStructureBiome(Engine engine, int quartX, int quartZ) {
long key = packColumnKey(quartX, quartZ);
int runtimeIdentity = engine.getCacheID();
BiomeHolderTable<H> cache = naturalStructureBiomeCache;
H cached = cache.get(runtimeIdentity, key);
if (cached != null) {
return cached;
}
NativeBiomeSourceAccess.RegistryView<H> registry = biomeRegistry();
if (registry == null) {
throw new IllegalStateException("Iris structure biome lookup has no biome registry");
}
int blockX = quartX << 2;
int blockZ = quartZ << 2;
IrisBiome irisBiome = engine.getComplex().getNaturalTrueBiomeStream().get(blockX, blockZ);
if (irisBiome == null) {
throw new IllegalStateException("Iris returned no natural structure biome at block " + blockX + "," + blockZ);
}
H resolved = resolveHolder(registry, irisBiome.getStructureDerivativeKey());
if (resolved == null) {
throw new IllegalStateException("Iris natural structure biome derivative '"
+ irisBiome.getStructureDerivativeKey() + "' is not registered at block " + blockX + "," + blockZ);
}
cache.put(runtimeIdentity, key, resolved);
return resolved;
}
static long biomeResolutionSeed(long worldSeed, int blockX, int blockY, int blockZ) {
return worldSeed
^ ((long) blockX * 341873128712L)
@@ -826,7 +874,7 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
return engine != null && !engine.isClosed() && engine.getComplex() != null;
}
private Engine engineOrNull() {
private Engine boundEngineOrNull() {
RuntimeCallbacks current = runtime;
return current == null ? null : current.currentEngine().get();
}
@@ -79,6 +79,8 @@ import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.generation.concurrent.MultiBurst;
import java.util.ArrayDeque;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
public final class ModdedEngineBootstrap {
private static final String[] CORE_SELF_TEST_CLASSES = {
@@ -87,12 +89,17 @@ public final class ModdedEngineBootstrap {
"art.arcane.iris.pack.loading.IrisData"
};
private static final Object LOCK = new Object();
private static final Object STOP_LOCK = new Object();
private static final AtomicBoolean EXIT_FLUSH_REGISTERED = new AtomicBoolean();
private static final long EXIT_FLUSH_SERVER_WAIT_SECONDS = 30L;
private static final ModdedServiceManager UNBOUND_SERVICE_MANAGER = new ModdedServiceManager();
private static volatile NativeModdedLoader loader;
private static volatile BoundRuntime runtime;
private static volatile NativeModdedServer currentServer;
private static volatile NativeModdedServer spawnCaptureServer;
private static volatile boolean initialSpawnWasDefault;
private static volatile boolean stoppedPending;
private static volatile Thread stoppingThread;
private ModdedEngineBootstrap() {
}
@@ -119,10 +126,12 @@ public final class ModdedEngineBootstrap {
}
public static void start(NativeModdedServer server) {
finishPreviousServer("server start");
registerExitFlush();
captureInitialSpawn(server);
currentServer = server;
bind();
// Pair of the stop() burst-pools stage. Load-bearing on integrated servers: once
// Pair of the stopped() burst-pools stage. Load-bearing on integrated servers: once
// closed, MultiBurst falls back to a same-thread executor, so a second world load
// without reopen() would silently run every burst inline.
MultiBurst.burst.reopen();
@@ -150,6 +159,7 @@ public final class ModdedEngineBootstrap {
}
public static void serverAboutToStart(NativeModdedServer server) {
finishPreviousServer("server start");
captureInitialSpawn(server);
ModdedStartup.prepareForStartup();
}
@@ -180,20 +190,16 @@ public final class ModdedEngineBootstrap {
try {
generator.unbindEngine(world);
} catch (Throwable exception) {
// Never propagate into the loader's unload loop: a throw there skips closing every later level. A
// runtime dimension removal retries the eviction itself and fails with the retained mapping.
ModdedIrisLog.error("Iris engine unload failed for {}", world.name(), exception);
if (exception instanceof RuntimeException runtimeException) {
throw runtimeException;
}
if (exception instanceof Error fatalError) {
throw fatalError;
}
throw new IllegalStateException("Iris engine unload failed for "
+ world.name(), exception);
}
}
public static void stop() {
NativeModdedServer stoppingServer = currentServer;
stoppingThread = Thread.currentThread();
stoppedPending = true;
Throwable failure = null;
failure = runStopStage(failure, "pack downloads", IrisModdedCommands::shutdownDownloads);
failure = runStopStage(failure, "world check", () -> ModdedWorldCheck.serverStopped(stoppingServer));
@@ -205,6 +211,62 @@ public final class ModdedEngineBootstrap {
failure = runStopStage(failure, "studio commands", ModdedStudioCommands::clear);
failure = runStopStage(failure, "gui host", ModdedGuiHost::clear);
failure = runStopStage(failure, "services", () -> services().disableAll());
reportStopFailure("stopping", failure);
}
/**
* The loaders still drain queued chunk generation after server stopping and only close levels afterwards, so
* engines, their mantle and generation history, and the generation pools stay open until the server stopped.
* Engines of unloaded levels are already closed by {@link #levelUnloaded}; this closes whatever remains.
* Fabric fires it at the tail of stopServer, so a throw there skips it: the next server start or the JVM
* exit runs it instead.
*/
public static void stopped() {
synchronized (STOP_LOCK) {
stoppedPending = false;
stoppingThread = null;
closeStoppedServer();
}
}
private static void finishPreviousServer(String trigger) {
synchronized (STOP_LOCK) {
if (!stoppedPending) {
return;
}
ModdedIrisLog.warn("Iris closing the engines the previous server left open ({})", trigger);
stopped();
}
}
private static void registerExitFlush() {
if (EXIT_FLUSH_REGISTERED.compareAndSet(false, true)) {
Runtime.getRuntime().addShutdownHook(new Thread(ModdedEngineBootstrap::flushAtExit, "Iris Exit Flush"));
}
}
private static void flushAtExit() {
if (!stoppedPending) {
return;
}
Thread serverThread = stoppingThread;
if (serverThread != null && serverThread != Thread.currentThread()) {
try {
serverThread.join(TimeUnit.SECONDS.toMillis(EXIT_FLUSH_SERVER_WAIT_SECONDS));
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
return;
}
if (serverThread.isAlive()) {
ModdedIrisLog.warn("Iris skipped its exit flush: the server thread is still stopping");
return;
}
}
finishPreviousServer("JVM exit");
}
private static void closeStoppedServer() {
Throwable failure = null;
failure = runStopStage(failure, "world engines", ModdedWorldEngines::shutdown);
failure = runStopStage(failure, "primary world router", ModdedPrimaryWorldRouter::clear);
failure = runStopStage(failure, "dimension manager", ModdedDimensionManager::clear);
@@ -226,10 +288,14 @@ public final class ModdedEngineBootstrap {
spawnCaptureServer = null;
initialSpawnWasDefault = false;
});
reportStopFailure("stopped", failure);
}
private static void reportStopFailure(String phase, Throwable failure) {
if (failure != null) {
// The shutdown path must not propagate: propagating aborts the remaining loader stop handlers and
// can leave the level unsaved. Every stage already logged its own failure.
ModdedIrisLog.error("Iris modded shutdown completed with failures", failure);
ModdedIrisLog.error("Iris modded shutdown ({}) completed with failures", phase, failure);
}
}
@@ -391,10 +457,7 @@ public final class ModdedEngineBootstrap {
BlockDataMergeSupport.StateMerger previousMerger = BlockDataMergeSupport.bindPlatformMerger(new NativeStateMerger(ModdedBlockResolution.BLOCKS)::merge);
rollback.add(() -> BlockDataMergeSupport.restorePlatformMerger(previousMerger));
NativeTileReader nativeTileReader = new NativeTileReader(() -> {
NativeModdedServer server = boundLoader.currentServer();
return server == null ? null : server.registryAccess();
});
NativeTileReader nativeTileReader = NativeModdedServer.tileReader(boundLoader::currentServer);
TileData.TileReader previousTileReader = TileData.bindPlatformReader(in -> ModdedTileData.wrap(nativeTileReader.read(in)));
rollback.add(() -> TileData.restorePlatformReader(previousTileReader));
@@ -314,12 +314,13 @@ public final class ModdedForcedDatapack {
} catch (NoSuchAlgorithmException missing) {
throw new IllegalStateException("SHA-256 is unavailable", missing);
}
DataVersion dataVersion = DataVersion.getRuntime();
digest.update((HASH_SALT + '|' + ModdedEngineBootstrap.loader().platformName()
+ '|' + DataVersion.V26_2.getPackFormat() + '\n').getBytes(StandardCharsets.UTF_8));
+ '|' + dataVersion.getPackFormat() + '\n').getBytes(StandardCharsets.UTF_8));
String inputFingerprint = IrisDatapackCompiler.computeInputFingerprint(
compilationPackRoots(),
List.of(),
DataVersion.V26_2.get(),
dataVersion.get(),
false
);
digest.update(inputFingerprint.getBytes(StandardCharsets.UTF_8));
@@ -328,7 +329,7 @@ public final class ModdedForcedDatapack {
private static void writeStagedPack(Path stagingDirectory) throws IOException {
Map<String, KSet<String>> seenBiomes = new LinkedHashMap<>();
IDataFixer fixer = DataVersion.V26_2.get();
IDataFixer fixer = DataVersion.getRuntime().get();
int packCount = 0;
KList<String> presetIds = new KList<>();
@@ -679,7 +680,7 @@ public final class ModdedForcedDatapack {
}
static void writePackMeta(Path packDirectory) throws IOException {
int packFormat = DataVersion.V26_2.getPackFormat();
int packFormat = DataVersion.getRuntime().getPackFormat();
String json = "{\n"
+ " \"pack\": {\n"
+ " \"description\": \"Iris world generation biomes and dimension types for installed packs.\",\n"
@@ -9,12 +9,10 @@ import java.util.Objects;
final class ModdedGenerationRegistry implements PlatformGenerationRegistry {
private final NativeGenerationRegistry nativeRegistry;
private final String runtimeIdentity;
private final String rendererIdentity;
ModdedGenerationRegistry(NativeGenerationRegistry nativeRegistry, Identity identity) {
ModdedGenerationRegistry(NativeGenerationRegistry nativeRegistry, String runtimeIdentity) {
this.nativeRegistry = Objects.requireNonNull(nativeRegistry, "nativeRegistry");
this.runtimeIdentity = requireText(identity.runtime(), "runtimeIdentity");
this.rendererIdentity = requireText(identity.renderer(), "rendererIdentity");
this.runtimeIdentity = requireText(runtimeIdentity, "runtimeIdentity");
}
@Override
@@ -22,11 +20,6 @@ final class ModdedGenerationRegistry implements PlatformGenerationRegistry {
return runtimeIdentity;
}
@Override
public String generatedDefinitionRendererIdentity() {
return rendererIdentity;
}
@Override
public String customBiomeResourceKey(String identitySha256) {
return PlatformGenerationRegistry.contentAddressedCustomBiomeResourceKey(identitySha256);
@@ -75,7 +68,4 @@ final class ModdedGenerationRegistry implements PlatformGenerationRegistry {
}
return required;
}
record Identity(String runtime, String renderer) {
}
}
@@ -25,6 +25,8 @@ import art.arcane.volmlib.nativelib.terrain.feature.NativeFeatureTable;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedImportedFeatures;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.GenerationClosedException;
import art.arcane.iris.generation.runtime.GenerationFailures;
import art.arcane.iris.generation.runtime.GenerationSessionLease;
import art.arcane.iris.structure.nativegen.NativeFeatureGenerationPolicy;
import art.arcane.iris.structure.nativegen.IrisImportedFeatureControl;
@@ -95,10 +97,12 @@ final class ModdedImportedFeatureStage implements NativeModdedGeneratorPolicy.Fe
* the per-step feature lists and {@code BiomeFilter}'s hasFeature gate both see real features for a biome
* whose datapack JSON declares none by design. Real registry biomes pass straight through.
*
* <p>With {@code importedFeatures} disabled there is no table and this is vanilla's default getter.
* <p>With {@code importedFeatures} disabled there is no table and this is vanilla's default getter. So is an
* unbound generator, which only world creation and codec validation ask. A bound generator whose engine is
* refused or stopping throws: BiomeFilter would otherwise drop every imported feature of a custom biome.
*/
public NativeFeatureTable currentTable() {
Engine engine = generator == null ? null : generator.engineOrNull();
Engine engine = generator == null ? null : generator.boundEngineOrNull();
return engine == null ? null : featureTables.get(engine.getCacheID());
}
@@ -111,8 +115,15 @@ final class ModdedImportedFeatureStage implements NativeModdedGeneratorPolicy.Fe
* the build itself is serialized because {@code applyBiomeDecoration} calls this from every worldgen
* thread, and two threads that both found the stage unprepared would each run {@code FeatureSorter}, whose
* cycle detection is the expensive part. Waiting here is safe: this caller holds no generator monitor.
* A sealed runtime keeps the table it already settled but cannot build one, and decorating without it
* would persist a chunk with no imported features, so it fails the chunk instead.
*/
public void prepare(Engine engine) {
if ((engine.isClosed() || engine.isClosing())
&& !settled(engine.getCacheID(), biomeSource.packGeneration())) {
throw new GenerationClosedException("Iris imported features cannot prepare runtime "
+ engine.getCacheID() + " while its engine is closing.");
}
prepare(engine, true);
}
@@ -127,7 +138,7 @@ final class ModdedImportedFeatureStage implements NativeModdedGeneratorPolicy.Fe
}
private void prepare(Engine engine, boolean waitForBuild) {
if (engine == null || engine.isClosed() || engine.isClosing()) {
if (engine.isClosed() || engine.isClosing()) {
return;
}
int runtimeIdentity = engine.getCacheID();
@@ -163,6 +174,7 @@ final class ModdedImportedFeatureStage implements NativeModdedGeneratorPolicy.Fe
try {
control = NativeFeatureGenerationPolicy.control(engine);
} catch (RuntimeException error) {
GenerationFailures.rethrowEngineFailure(error);
ModdedIrisLog.error("Iris could not read importedFeatures for this dimension; features off: {}",
error.toString(), error);
markInert(runtimeIdentity, generation);
@@ -177,6 +189,7 @@ final class ModdedImportedFeatureStage implements NativeModdedGeneratorPolicy.Fe
IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) {
built = nativeFeatures.buildTable(new ModdedImportedFeaturePolicy(engine), control, generation);
} catch (Throwable error) {
GenerationFailures.rethrowEngineFailure(error);
ModdedIrisLog.error("Iris importedFeatures is off for {}: feature table construction failed: {}",
dimensionKey(engine), error.toString(), error);
markInert(runtimeIdentity, generation);
@@ -201,17 +214,24 @@ final class ModdedImportedFeatureStage implements NativeModdedGeneratorPolicy.Fe
inertGenerations.put(runtimeIdentity, generation);
}
/**
* Null only for a runtime whose features are settled off. A table retired or superseded since this chunk's
* prepare fails the chunk: skipping the pass would persist it without imported features.
*/
public NativeFeatureTable placementTable(Engine engine) {
int runtimeIdentity = engine.getCacheID();
long generation = biomeSource.packGeneration();
NativeFeatureTable table = featureTables.get(runtimeIdentity);
if (table == null) {
if (table == null && Long.valueOf(generation).equals(inertGenerations.get(runtimeIdentity))) {
WorldCheckFeaturePlacement.recordFeaturesOff();
return null;
}
if (table.generation() != biomeSource.packGeneration()) {
featureTables.remove(runtimeIdentity, table);
inertGenerations.remove(runtimeIdentity);
return null;
if (table == null || table.generation() != generation) {
if (table != null) {
featureTables.remove(runtimeIdentity, table);
}
throw new GenerationClosedException("Iris imported feature table for runtime " + runtimeIdentity
+ " was retired before decoration.");
}
return generator == null ? null : table;
}
@@ -16,6 +16,7 @@ public final class ModdedLifecycleCallbacks {
ModdedEngineBootstrap::start,
ModdedEngineBootstrap::serverStarted,
ModdedEngineBootstrap::stop,
ModdedEngineBootstrap::stopped,
ModdedEngineBootstrap::levelLoaded,
ModdedEngineBootstrap::levelUnloaded,
ModdedProtocolHandler::onPlayerJoin,
@@ -40,29 +40,31 @@ import java.util.function.BooleanSupplier;
* references and is safe on a dedicated server.
*/
public final class ModdedMixinAudit {
private static final String ENTITY_CONFIG = "volmlib.entity.mixins.json";
private static final String CLIENT_CONFIG = "volmlib.client.mixins.json";
private static final AtomicBoolean AUDITED = new AtomicBoolean(false);
private static final List<ExpectedMixin> EXPECTED = List.of(
new ExpectedMixin("EntityPersistenceMixin", "entity",
static final List<ExpectedMixin> EXPECTED = List.of(
new ExpectedMixin(ENTITY_CONFIG, "EntityPersistenceMixin",
NativeMixinTarget.ENTITY, "iris$applyGeneratedPersistence",
false, NativeMixinFlags::entityPersistenceRan),
new ExpectedMixin("LivingEntityLootMixin", "entity",
new ExpectedMixin(ENTITY_CONFIG, "LivingEntityLootMixin",
NativeMixinTarget.LIVING_ENTITY, "iris$replaceBaseLoot",
false, NativeMixinFlags::livingEntityLootRan),
new ExpectedMixin("MobAwarenessMixin", "entity",
new ExpectedMixin(ENTITY_CONFIG, "MobAwarenessMixin",
NativeMixinTarget.MOB, "iris$tickUnawareMob",
false, NativeMixinFlags::mobAwarenessRan),
new ExpectedMixin("StructureTemplatePaletteConcurrencyMixin", "common",
new ExpectedMixin(ENTITY_CONFIG, "StructureTemplatePaletteConcurrencyMixin",
NativeMixinTarget.STRUCTURE_PALETTE,
"iris$installConcurrentBlockCache",
false, NativeMixinFlags::structureTemplatePaletteRan),
new ExpectedMixin("NativeWorldOpenFlowsMixin", "client",
new ExpectedMixin(CLIENT_CONFIG, "NativeWorldOpenFlowsMixin",
NativeMixinTarget.WORLD_OPEN_FLOWS,
"iris$openWorldCheckWorldStemCompatibility",
"volmlib$openWorldCheckWorldStemCompatibility",
true, NativeMixinFlags::worldOpenFlowsRan),
new ExpectedMixin("NativeWorldTypeEntryMixin", "client",
new ExpectedMixin(CLIENT_CONFIG, "NativeWorldTypeEntryMixin",
NativeMixinTarget.WORLD_TYPE_ENTRY,
"iris$describePreset",
"volmlib$describePreset",
true, NativeMixinFlags::worldTypeEntryRan));
private ModdedMixinAudit() {
@@ -90,7 +92,7 @@ public final class ModdedMixinAudit {
if (isApplied(expected)) {
applied.add(expected.mixinName() + (expected.ran().getAsBoolean() ? "" : " (not yet exercised)"));
} else {
missing.add(expected.config() + '/' + expected.mixinName() + " -> " + expected.targetClass());
missing.add(expected.config() + '/' + expected.mixinName() + " -> " + expected.targetClass().className());
}
}
if (missing.isEmpty()) {
@@ -119,7 +121,7 @@ public final class ModdedMixinAudit {
}
}
private record ExpectedMixin(String mixinName, String config, NativeMixinTarget targetClass, String handlerMethod,
boolean clientOnly, BooleanSupplier ran) {
record ExpectedMixin(String config, String mixinName, NativeMixinTarget targetClass, String handlerMethod,
boolean clientOnly, BooleanSupplier ran) {
}
}
@@ -102,7 +102,8 @@ final class ModdedNativeStructurePolicy implements NativeModdedStructureStage.Po
@Override
public NativeBlockPositionPredicate protectedPositions(Engine context) {
return GenerationWritePolicy.protectedPositions(context.getDimension().getStaticObjectLayer(context.getData()),
return GenerationWritePolicy.protectedPositions(context.getDimension().getStaticObjectLayer(context.getData())
.forTransition(context),
context.getDimensionStackContext(), context.getMinHeight());
}
@@ -20,7 +20,6 @@ package art.arcane.iris.modded;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeRegistryAccess;
import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeRegistryDefinitions;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeEntitySpawns;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedLoader;
@@ -38,8 +37,6 @@ import art.arcane.iris.spi.PlatformScheduler;
import art.arcane.iris.spi.PlatformStructureHooks;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import net.minecraft.core.HolderLookup;
import java.io.File;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@@ -63,25 +60,13 @@ public final class ModdedPlatform implements IrisPlatform {
public ModdedPlatform(NativeModdedLoader loader) {
this.loader = loader;
this.registries = new ModdedRegistries(
new NativeRegistryAccess(new NativeRegistryAccess.Configuration(
ModdedPlatform::nativeRegistries, ModdedPlatform::reloadableRegistries,
ModdedRegistries::warnNotReady)),
ModdedPlatform::generationRegistry);
NativeModdedServer.registryAccess(ModdedEngineBootstrap::currentServer, ModdedRegistries::warnNotReady),
() -> generationRegistry(loader));
this.scheduler = new ModdedScheduler();
this.structureHooks = new ModdedStructureHooks(ModdedEngineBootstrap::currentServer);
this.biomeWriter = new ModdedBiomeWriter(new NativeBiomeRegistry(ModdedEngineBootstrap::currentServer, "minecraft:plains"));
}
private static HolderLookup.Provider nativeRegistries() {
NativeModdedServer server = ModdedEngineBootstrap.currentServer();
return server == null ? null : server.registryAccess();
}
private static HolderLookup.Provider reloadableRegistries() {
NativeModdedServer server = ModdedEngineBootstrap.currentServer();
return server == null ? null : server.reloadableRegistries();
}
public static void errorSink(Consumer<Throwable> sink) {
ERROR_SINK = sink;
}
@@ -367,13 +352,12 @@ public final class ModdedPlatform implements IrisPlatform {
}
}
private static PlatformGenerationRegistry generationRegistry() {
private static PlatformGenerationRegistry generationRegistry(NativeModdedLoader loader) {
if (ModdedEngineBootstrap.currentServer() == null) {
throw new IllegalStateException("Minecraft server is not ready for generation registry capture.");
}
return new ModdedGenerationRegistry(NativeModdedServer.registryDefinitions(ModdedEngineBootstrap::currentServer),
new ModdedGenerationRegistry.Identity("modded-generation-registry-v1",
"modded-generated-registry-json-v1|minecraft:" + BuildConstants.MINECRAFT_VERSION));
return new ModdedGenerationRegistry(NativeModdedServer.registryDefinitions(ModdedEngineBootstrap::currentServer, loader),
"modded-generation-registry-v1");
}
}
@@ -18,15 +18,17 @@
package art.arcane.iris.modded;
import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldTeleport;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeDimensionRuntime;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
@@ -36,6 +38,8 @@ public final class ModdedPrimaryWorldRouter {
private static final Set<UUID> routed = ConcurrentHashMap.newKeySet();
private static final Set<UUID> inFlight = ConcurrentHashMap.newKeySet();
private static int tickCounter = 0;
private static volatile String loginRefusal;
private static volatile String absentPrimary;
private ModdedPrimaryWorldRouter() {
}
@@ -43,6 +47,8 @@ public final class ModdedPrimaryWorldRouter {
public static void clear() {
routed.clear();
inFlight.clear();
loginRefusal = null;
absentPrimary = null;
}
/**
@@ -56,6 +62,20 @@ public final class ModdedPrimaryWorldRouter {
}
}
/**
* Disconnects a joining player while the configured primary world is missing.
*
* @return true when the player was refused
*/
public static boolean refuseIfUnavailable(NativeProtocolPlayer player) {
String refusal = loginRefusal;
if (refusal == null || player == null) {
return false;
}
player.disconnect(refusal);
return true;
}
public static void tick(NativeModdedServer server) {
if (server == null) {
return;
@@ -68,17 +88,23 @@ public final class ModdedPrimaryWorldRouter {
ModdedModConfig config = ModdedModConfig.get();
if (!config.routePlayersToPrimaryWorld()) {
loginRefusal = null;
return;
}
String primary = config.primaryWorld();
if (primary.isBlank()) {
loginRefusal = null;
return;
}
NativeWorld target = ModdedDimensionManager.level(server, primary);
if (target == null) {
if (ModdedStartup.dimensionsRestored()) {
judgeMissingPrimary(server, primary);
}
return;
}
loginRefusal = null;
NativeWorld overworld = server.overworld();
if (NativeDimensionRuntime.sameWorld(target, overworld)) {
return;
@@ -97,7 +123,7 @@ public final class ModdedPrimaryWorldRouter {
try {
CompletableFuture<Boolean> teleport = NativeWorldTeleport.teleport(player,
new NativeWorldTeleport.Destination(server, target, player.x(), Double.MIN_VALUE, player.z(),
System.nanoTime() + TimeUnit.SECONDS.toNanos(10)));
ModdedTeleportDeadline.fromNow()));
teleport.whenComplete((success, failure) -> {
inFlight.remove(id);
if (Boolean.TRUE.equals(success) && failure == null) {
@@ -115,4 +141,62 @@ public final class ModdedPrimaryWorldRouter {
}
});
}
/**
* primaryWorld is instance-wide but the dimension it names belongs to one save. Only a save whose registry lists
* it expected it here, so only that save has a primary world that failed to load.
*/
private static void judgeMissingPrimary(NativeModdedServer server, String primary) {
if (loginRefusal == null && primary.equals(absentPrimary)) {
return;
}
if (loginRefusal == null && !expectedInThisSave(server, primary)) {
absentPrimary = primary;
ModdedIrisLog.warn("Iris primary world '" + primary + "' from config/irisworldgen/modded.json does not"
+ " exist in this save, so players are not routed. Create it here with /iris world replace-overworld"
+ " <pack> or clear primaryWorld.");
return;
}
refuseLogins(server, primary);
}
private static boolean expectedInThisSave(NativeModdedServer server, String primary) {
try {
return ModdedDimensionRegistryStore.get(server, primary) != null;
} catch (RuntimeException unreadable) {
ModdedIrisLog.error("Iris could not read this save's dimension registry to judge primary world '"
+ primary + "'", unreadable);
return true;
}
}
/**
* Players left in the overworld would generate the terrain the primary Iris world was configured to replace, so a
* primary world this save expected and did not load refuses every player instead.
*/
private static void refuseLogins(NativeModdedServer server, String primary) {
String refusal = "Iris primary world '" + primary + "' is not loaded, so this server refuses players."
+ " An operator has to check the server console.";
if (loginRefusal == null) {
for (String line : GenerationRefusalNotice.compose(
"Iris refused player logins: primary world '" + primary + "' is not loaded",
List.of("routePlayersToPrimaryWorld sends players to '" + primary
+ "', which this save's dimension registry expects but did not load at startup."),
List.of(
"Players are disconnected instead of playing in the overworld, so no chunks are"
+ " written there.",
"Fix the dimension (see the Iris errors above) or clear primaryWorld in"
+ " config/irisworldgen/modded.json, then start the server again."
))) {
ModdedIrisLog.error(line);
}
}
loginRefusal = refusal;
List<NativeProtocolPlayer> online = new ArrayList<>();
// Disconnecting removes the player from the list being walked, so collect first.
server.forEachPlayer(online::add);
for (NativeProtocolPlayer player : online) {
player.disconnect(refusal);
}
}
}
@@ -123,11 +123,11 @@ public final class ModdedProtocolHandler {
}
public static void onPlayerJoin(NativeProtocolPlayer player) {
if (player == null) {
if (player == null || ModdedPrimaryWorldRouter.refuseIfUnavailable(player)) {
return;
}
String sessionId = sessionId(player);
ModdedStartup.warnPackFailuresTo(player);
ModdedStartup.warnStartupFailuresTo(player);
IrisSessionRegistry current = registry;
ModdedProtocolTransport currentTransport = transport;
if (current == null || currentTransport == null) {
@@ -195,7 +195,8 @@ public final class ModdedScheduler implements PlatformScheduler {
mainQueue.clear();
delayedQueue.clear();
mainThread = null;
// Shutdown stage that always runs (ModdedEngineBootstrap.stop): release the level snapshot with it.
// Runs from ModdedEngineBootstrap.stopped, or its fallback at the next server start or JVM exit when the
// loader skipped it: release the level snapshot with it.
ModdedServerLevels.forget();
}
@@ -26,6 +26,7 @@ import art.arcane.iris.pack.PackValidationResult;
import art.arcane.iris.pack.PackValidator;
import art.arcane.iris.modded.command.ModdedPackCommands;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
@@ -36,6 +37,7 @@ import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
@@ -43,6 +45,7 @@ public final class ModdedStartup {
private static final int COMPAT_BOOT_KEY_CAP = 3;
private static final AtomicBoolean PREPARED = new AtomicBoolean(false);
private static final AtomicBoolean STARTED = new AtomicBoolean(false);
private static final List<String> DIMENSION_FAILURES = new CopyOnWriteArrayList<>();
private ModdedStartup() {
}
@@ -50,6 +53,7 @@ public final class ModdedStartup {
public static void reset() {
PREPARED.set(false);
STARTED.set(false);
DIMENSION_FAILURES.clear();
}
public static void prepareForStartup() {
@@ -208,7 +212,7 @@ public final class ModdedStartup {
}
}
private static void reinjectPersistentDimensions(NativeModdedServer server) {
static void reinjectPersistentDimensions(NativeModdedServer server) {
List<ModdedDimensionRegistryStore.PersistentDimension> dimensions =
ModdedDimensionRegistryStore.loadForStartup(server);
if (dimensions.isEmpty()) {
@@ -233,7 +237,7 @@ public final class ModdedStartup {
index, dimensions.size(), dimension.id(), dimension.pack(), dimension.dimension(),
System.currentTimeMillis() - dimensionStartedAt);
} catch (Throwable e) {
ModdedIrisLog.error("Iris failed to re-inject persistent dimension '{}' (pack={} dim={} seed={})", dimension.id(), dimension.pack(), dimension.dimension(), dimension.seed(), e);
reportDimensionFailure(dimension, e);
if (e instanceof OutOfMemoryError outOfMemory) {
throw outOfMemory;
}
@@ -243,6 +247,25 @@ public final class ModdedStartup {
injected, dimensions.size(), System.currentTimeMillis() - startedAt);
}
private static void reportDimensionFailure(ModdedDimensionRegistryStore.PersistentDimension dimension, Throwable failure) {
List<String> notice = GenerationRefusalNotice.compose(
"Iris refused to load dimension '" + dimension.id() + "' (pack=" + dimension.pack()
+ " dim=" + dimension.dimension() + " seed=" + dimension.seed() + ")",
GenerationRefusalNotice.causes(failure),
List.of(
"Iris does not generate this dimension and does not let vanilla or any other generator"
+ " write it; no chunks are written.",
"It stays unloaded. Fix the cause, then start the server again."
));
for (String line : notice) {
ModdedIrisLog.error(line);
}
ModdedIrisLog.error("Iris failed to re-inject persistent dimension '{}' (pack={} dim={} seed={})",
dimension.id(), dimension.pack(), dimension.dimension(), dimension.seed(), failure);
DIMENSION_FAILURES.add("Iris dimension '" + dimension.id() + "' did not load and refuses to generate: "
+ GenerationRefusalNotice.summary(failure));
}
private static long newestModificationMillis(Path root) {
long newest = 0L;
try (Stream<Path> walk = Files.walk(root)) {
@@ -261,13 +284,24 @@ public final class ModdedStartup {
}
/**
* SP-6: a pack excluded by validation is otherwise only visible in the console. Tell the operators who
* can actually act on it when they join.
* True once the first tick with a player list has re-injected the persistent dimensions. Before that a runtime
* dimension that is not loaded yet is still on its way.
*/
public static void warnPackFailuresTo(NativeProtocolPlayer player) {
public static boolean dimensionsRestored() {
return STARTED.get();
}
/**
* SP-6: a pack excluded by validation or a persistent dimension that did not load is otherwise only visible in
* the console. Tell the operators who can actually act on it when they join.
*/
public static void warnStartupFailuresTo(NativeProtocolPlayer player) {
if (player == null || !player.isGameMaster()) {
return;
}
for (String failure : DIMENSION_FAILURES) {
player.sendMessage(failure);
}
for (Map.Entry<String, PackValidationResult> entry : PackValidationRegistry.snapshot().entrySet()) {
PackValidationResult result = entry.getValue();
if (result == null || result.isLoadable()) {
@@ -0,0 +1,37 @@
/*
* Iris is a World Generator for Minecraft Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.modded;
import java.util.concurrent.TimeUnit;
/**
* Deadline for a teleport that waits on its destination chunk. The first chunk of a cold dimension also loads the
* pack and binds the engine; the deadline only bounds a destination whose chunk never completes (a refusing
* generator or a removed dimension).
*/
public final class ModdedTeleportDeadline {
private static final long DEADLINE_SECONDS = 120L;
private ModdedTeleportDeadline() {
}
public static long fromNow() {
return System.nanoTime() + TimeUnit.SECONDS.toNanos(DEADLINE_SECONDS);
}
}
@@ -426,9 +426,9 @@ public final class ModdedWorldEngines {
Engine engine = entry.getValue();
try {
// Latch the generator's unloading flag BEFORE closing (unbindEngine sets it,
// then evicts): chunk-system drain work running after this stage would
// otherwise see a closed engine and silently rebuild a fresh engine + Mantle
// that no teardown stage ever closes, writing plates after the final save.
// then evicts): late chunk work would otherwise see a closed engine and
// silently rebuild a fresh engine + Mantle that no teardown stage ever
// closes, writing plates after the final save.
IrisModdedChunkGenerator generator = NativeWorldGenerators.find(level, IrisModdedChunkGenerator.class);
if (generator != null) {
generator.unbindEngine(level);
@@ -245,7 +245,7 @@ public final class ModdedWorldManager implements EngineWorldManager {
return true;
}
MantleChunk<Matter> chunk = mantle.getChunk(chunkX, chunkZ).use();
MantleChunk<Matter> chunk = mantle.useChunk(chunkX, chunkZ);
try {
List<PreparedMarkerSpawn> markers = markerSystemEnabled()
? prepareMarkerSpawns(level, chunkX, chunkZ, chunk) : List.of();
@@ -380,7 +380,7 @@ public final class ModdedWorldManager implements EngineWorldManager {
return;
}
MantleChunk<Matter> chunk = mantle.getChunk(chunkX, chunkZ).use();
MantleChunk<Matter> chunk = mantle.useChunk(chunkX, chunkZ);
try {
if (markerSystemEnabled()) {
spawnPreparedMarkers(level, prepareMarkerSpawns(level, chunkX, chunkZ, chunk), false);
@@ -55,7 +55,7 @@ final class WorldCheckDimensionContract {
}
static Dimension expectedDimensionContract(IrisDimension dimension) {
JSONObject json = new JSONObject(dimension.getDimensionType().toJson(DataVersion.V26_2.get()));
JSONObject json = new JSONObject(dimension.getDimensionType().toJson(DataVersion.getRuntime().get()));
return new Dimension(
json.getInt("min_y"),
json.getInt("height"),
@@ -36,6 +36,7 @@ import art.arcane.iris.modded.ModdedEngineBootstrap;
import art.arcane.iris.modded.ModdedForcedDatapack;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedLoader;
import art.arcane.iris.modded.ModdedScheduler;
import art.arcane.iris.modded.ModdedTeleportDeadline;
import art.arcane.iris.modded.ModdedWorldgenIds;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.volmlib.util.collection.KMap;
@@ -60,6 +61,7 @@ import java.util.Locale;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.TimeUnit;
import art.arcane.volmlib.util.localization.MessageArgument;
@@ -133,7 +135,7 @@ public final class IrisModdedCommands {
NativeModdedServer server = source.server();
CompletableFuture<Boolean> teleport = NativeWorldTeleport.teleport(player, new NativeWorldTeleport.Destination(
server, ModdedDimensionManager.level(server, dimensionId), 8.5D, Double.MIN_VALUE, 8.5D,
System.nanoTime() + TimeUnit.SECONDS.toNanos(10L)));
ModdedTeleportDeadline.fromNow()));
teleport.whenComplete((success, failure) -> {
if (Boolean.TRUE.equals(success) && failure == null) {
return;
@@ -142,14 +144,42 @@ public final class IrisModdedCommands {
ModdedIrisLog.error("Iris teleport into '{}' failed for {}",
dimensionId, player.id(), failure);
}
server.execute(() -> fail(source, IrisLanguage.plain(
ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORT_FAILED_DIMENSION_IS_NOT_LOADED,
MessageArgument.untrusted("dimensionId", dimensionId))));
server.execute(() -> fail(source, teleportFailure(dimensionId, player.name(), failure,
ModdedDimensionManager.level(server, dimensionId) != null, server.player(player.id()) != null)));
});
ok(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORTING, MessageArgument.untrusted("value", player.name()), MessageArgument.untrusted("dimensionId", dimensionId)));
return 1;
}
static String teleportFailure(String dimensionId, String playerName, Throwable failure,
boolean dimensionLoaded, boolean playerOnline) {
if (failure != null) {
return IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORT_FAILED_REASON,
MessageArgument.untrusted("dimensionId", dimensionId),
MessageArgument.untrusted("reason", failureReason(failure)));
}
if (!dimensionLoaded) {
return IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORT_FAILED_DIMENSION_IS_NOT_LOADED,
MessageArgument.untrusted("dimensionId", dimensionId));
}
if (!playerOnline) {
return IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORT_CANCELLED_PLAYER_OFFLINE,
MessageArgument.untrusted("dimensionId", dimensionId),
MessageArgument.untrusted("value", playerName));
}
return IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORT_REFUSED,
MessageArgument.untrusted("dimensionId", dimensionId));
}
private static String failureReason(Throwable failure) {
Throwable cause = failure;
while (cause instanceof CompletionException && cause.getCause() != null) {
cause = cause.getCause();
}
String message = cause.getMessage();
return message == null || message.isBlank() ? cause.getClass().getSimpleName() : message;
}
static int evacuate(NativeCommandSource source, NativeWorld target) {
NativeModdedServer server = source.server();
NativeWorld level = target != null ? target : source.world();
@@ -165,7 +165,7 @@ public final class ModdedDatapackCommands {
IrisDimension dimension = engine.getDimension();
String json;
try {
json = dimension.getDimensionType().toJson(DataVersion.V26_2.get());
json = dimension.getDimensionType().toJson(DataVersion.getRuntime().get());
} catch (Throwable e) {
ModdedIrisLog.error("Iris dimension type generation failed for {}", dimension.getLoadKey(), e);
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_DATAPACK_COMMANDS_DIMENSION_TYPE_GENERATION_FAILED, MessageArgument.untrusted("value", level.name()), MessageArgument.untrusted("value2", String.valueOf(e.getMessage()))));
@@ -187,7 +187,7 @@ public final class ModdedDatapackCommands {
}
File mcmeta = new File(worldDatapacksFolder(server), WORLD_PACK_NAME + "/pack.mcmeta");
int packFormat = DataVersion.V26_2.getPackFormat();
int packFormat = DataVersion.getRuntime().getPackFormat();
String meta = "{\n"
+ " \"pack\": {\n"
+ " \"description\": \"Iris dimension types derived from the installed Iris packs.\",\n"
@@ -116,6 +116,11 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
}
ModdedIrisLog.info("Iris modded pregen done: dim={} completed={} peakInFlight={} finalLimit={}",
runtime.worldIdentity(), completed.get(), inFlightPeak.get(), adaptiveLimit.get());
try {
engine.syncGenerationHistory();
} catch (RuntimeException e) {
ModdedIrisLog.error("Iris modded pregen could not force generation history to stable storage", e);
}
if (deferFinalSaveIfRequested()) {
return;
}
@@ -498,7 +503,7 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
/**
* A dedicated server with {@code pause-when-empty-seconds > 0} returns from
* {@code MinecraftServer#tickServer} before {@code tickChildren} once it has been empty for that
* long (26.2 only keeps {@code tickConnection} plus the task/chunk-poll window alive). That
* long (26.3 only keeps {@code tickConnection} plus the task/chunk-poll window alive). That
* freezes every per-tick Iris service - world manager, scheduler, protocol sync, pregen HUD - and
* the loader's own generation hooks for the whole job, and console pregen on a default
* server.properties is always empty. The guard zeroes the setting for the duration of the job
@@ -25,6 +25,7 @@ import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.studio.render.RenderType;
import art.arcane.iris.modded.ModdedIrisLog;
import art.arcane.iris.modded.ModdedTeleportDeadline;
import java.awt.Desktop;
import java.io.File;
@@ -32,7 +33,6 @@ import java.util.List;
import java.util.Optional;
import art.arcane.volmlib.nativelib.view.WorldView;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
public final class ModdedVisionOverlay implements GuiOverlay {
@@ -63,8 +63,7 @@ public final class ModdedVisionOverlay implements GuiOverlay {
world.execute(() -> {
int surfaceY = engine.getMinHeight() + engine.getHeight(blockX, blockZ, false) + 2;
Optional<WorldView.TeleportOperation> operation = world.teleport(opener,
new WorldView.Destination(blockX + 0.5D, surfaceY, blockZ + 0.5D,
System.nanoTime() + TimeUnit.SECONDS.toNanos(10L)));
new WorldView.Destination(blockX + 0.5D, surfaceY, blockZ + 0.5D, ModdedTeleportDeadline.fromNow()));
if (operation.isEmpty()) {
return;
}
@@ -71,6 +71,18 @@ public final class ModdedCommandMessages {
"iris.modded.irismoddedcommands.teleport_failed_dimension_is_not_loaded",
"Teleport failed: dimension " + "{dimensionId}" + " is not loaded."
);
public static final TextKey IRIS_MODDED_COMMANDS_TELEPORT_FAILED_REASON = TextKey.of(
"iris.modded.irismoddedcommands.teleport_failed_reason",
"Teleport into " + "{dimensionId}" + " failed: " + "{reason}"
);
public static final TextKey IRIS_MODDED_COMMANDS_TELEPORT_CANCELLED_PLAYER_OFFLINE = TextKey.of(
"iris.modded.irismoddedcommands.teleport_cancelled_player_offline",
"Teleport into " + "{dimensionId}" + " cancelled: " + "{value}" + " is no longer online."
);
public static final TextKey IRIS_MODDED_COMMANDS_TELEPORT_REFUSED = TextKey.of(
"iris.modded.irismoddedcommands.teleport_refused",
"Teleport into " + "{dimensionId}" + " was refused by the server."
);
public static final TextKey IRIS_MODDED_COMMANDS_TELEPORTING = TextKey.of(
"iris.modded.irismoddedcommands.teleporting",
"Teleporting " + "{value}" + " to " + "{dimensionId}" + "..."
@@ -1266,6 +1278,9 @@ public final class ModdedCommandMessages {
IRIS_MODDED_COMMANDS_CONSOLE_MUST_NAME_PLAYER_IRIS_TP_DIMENSION_PLAYER,
IRIS_MODDED_COMMANDS_IS_NOT_GENERATED_BY_IRIS,
IRIS_MODDED_COMMANDS_TELEPORT_FAILED_DIMENSION_IS_NOT_LOADED,
IRIS_MODDED_COMMANDS_TELEPORT_FAILED_REASON,
IRIS_MODDED_COMMANDS_TELEPORT_CANCELLED_PLAYER_OFFLINE,
IRIS_MODDED_COMMANDS_TELEPORT_REFUSED,
IRIS_MODDED_COMMANDS_TELEPORTING,
IRIS_MODDED_COMMANDS_IS_NOT_GENERATED_BY_IRIS_2,
IRIS_MODDED_COMMANDS_CANNOT_EVACUATE_PRIMARY_WORLD_THERE_IS_NOWHERE_SEND_PLAYERS,
@@ -30,6 +30,7 @@ import art.arcane.iris.studio.view.PregeneratorJob;
import art.arcane.iris.integration.Identifier;
import art.arcane.iris.generation.cache.Cache;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.GenerationFailures;
import art.arcane.iris.generation.block.TileData;
import art.arcane.iris.modded.IrisModdedChunkGenerator;
import art.arcane.iris.modded.ModdedBlockResolution;
@@ -38,6 +39,7 @@ import art.arcane.iris.modded.ModdedTileData;
import art.arcane.iris.modded.api.ModdedCustomContentRegistry;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeBlockPlacement;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.world.history.SavedBiomeUnavailableException;
import art.arcane.iris.world.storage.matter.TileWrapper;
import art.arcane.volmlib.util.mantle.flag.MantleFlag;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
@@ -47,6 +49,7 @@ import art.arcane.volmlib.util.matter.MatterCavern;
import art.arcane.volmlib.util.matter.MatterUpdate;
import art.arcane.volmlib.util.scheduling.PrecisionStopwatch;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import it.unimi.dsi.fastutil.ints.IntArrayList;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@@ -149,7 +152,7 @@ public final class ModdedChunkUpdateService implements ModdedTickableService {
int centerZ = player.z() >> 4;
for (int dx = -PLAYER_CHUNK_RADIUS; dx <= PLAYER_CHUNK_RADIUS; dx++) {
for (int dz = -PLAYER_CHUNK_RADIUS; dz <= PLAYER_CHUNK_RADIUS; dz++) {
updateChunk(engine, level, centerX + dx, centerZ + dz);
updatePassChunk(engine, level, centerX + dx, centerZ + dz);
}
}
});
@@ -157,15 +160,29 @@ public final class ModdedChunkUpdateService implements ModdedTickableService {
private void updateForcedChunks(Engine engine, NativeWorld level) {
new NativeWorldMaintenance(level).forEachForcedChunk(chunkKey ->
updateChunk(engine, level, (int) chunkKey, (int) (chunkKey >> 32)));
updatePassChunk(engine, level, (int) chunkKey, (int) (chunkKey >> 32)));
}
public void updateRegeneratedChunk(Engine engine, NativeWorld level, int chunkX, int chunkZ) {
updateChunk(engine, level, chunkX, chunkZ, false);
}
private void updateChunk(Engine engine, NativeWorld level, int chunkX, int chunkZ) {
updateChunk(engine, level, chunkX, chunkZ, true);
/**
* One chunk of a level pass. A failed chunk keeps its flags and retries on a later pass without stopping the
* chunks after it; only a closed engine ends the level's pass.
*/
private void updatePassChunk(Engine engine, NativeWorld level, int chunkX, int chunkZ) {
try {
updateChunk(engine, level, chunkX, chunkZ, true);
} catch (Throwable failure) {
if (engine.isClosed() || engine.getMantle().getMantle().isClosed()) {
throw failure;
}
if (IrisLogging.errorOnce("chunk-update:" + level.name() + ":" + chunkX + "," + chunkZ + ":" + failure,
"Iris chunk update failed at " + chunkX + "," + chunkZ + " in " + level.name() + ", retrying each pass: " + failure)) {
IrisLogging.reportError(failure);
}
}
}
private void updateChunk(Engine engine, NativeWorld level, int chunkX, int chunkZ, boolean requireNeighbors) {
@@ -188,13 +205,18 @@ public final class ModdedChunkUpdateService implements ModdedTickableService {
return;
}
MantleChunk<Matter> chunk = mantle.getChunk(chunkX, chunkZ).use();
MantleChunk<Matter> chunk = mantle.useChunk(chunkX, chunkZ);
try {
chunk.raiseFlagUnchecked(MantleFlag.ETCHED, () -> {
chunk.raiseFlagUnchecked(MantleFlag.TILE, () -> runTilePass(engine, level, chunkX, chunkZ, chunk));
chunk.raiseFlagUnchecked(MantleFlag.CUSTOM, () -> runCustomPass(engine, level, chunkX, chunkZ, chunk));
chunk.raiseFlagUnchecked(MantleFlag.UPDATE, () -> runUpdatePass(engine, level, chunkX, chunkZ, chunk));
});
} catch (SavedBiomeUnavailableException unavailable) {
if (!unavailable.isLoading() || unavailable.getSuppressed().length != 0) {
throw unavailable;
}
IrisLogging.debug("Iris chunk update deferred at " + chunkX + "," + chunkZ + ": saved biomes loading");
} finally {
chunk.release();
}
@@ -315,15 +337,40 @@ public final class ModdedChunkUpdateService implements ModdedTickableService {
}
}
chunk.iterate(MatterUpdate.class, (Integer x, Integer yf, Integer z, MatterUpdate v) -> {
if (v != null && v.isUpdate()) {
update(engine, level, x, yf + minHeight, z, baseX, baseZ, chunk);
}
});
IntArrayList completed = new IntArrayList();
try {
chunk.iterate(MatterUpdate.class, (Integer x, Integer yf, Integer z, MatterUpdate v) -> {
if (v != null && v.isUpdate()) {
update(engine, level, x, yf + minHeight, z, baseX, baseZ, chunk);
completed.add((yf << 8) | ((x & 15) << 4) | (z & 15));
}
});
} catch (RuntimeException | Error failure) {
forgetCompletedUpdates(chunk, completed, failure);
throw failure;
}
chunk.deleteSlices(MatterUpdate.class);
engine.getMetrics().getUpdates().put(stopwatch.getMilliseconds());
}
private static void forgetCompletedUpdates(MantleChunk<Matter> chunk, IntArrayList completed, Throwable failure) {
try {
for (int index = 0; index < completed.size(); index++) {
int position = completed.getInt(index);
int y = position >>> 8;
Matter section = chunk.get(y >> 4);
if (section != null && section.hasSlice(MatterUpdate.class)) {
section.<MatterUpdate>getSlice(MatterUpdate.class)
.set((position >> 4) & 15, y & 15, position & 15, null);
}
}
} catch (RuntimeException | Error cleanupFailure) {
if (cleanupFailure != failure) {
failure.addSuppressed(cleanupFailure);
}
}
}
private void update(Engine engine, NativeWorld level, int x, int y, int z, int baseX, int baseZ, MantleChunk<Matter> chunk) {
if (y < level.minHeight() || y >= level.maxHeight() - 1) {
return;
@@ -337,7 +384,12 @@ public final class ModdedChunkUpdateService implements ModdedTickableService {
}
try {
ModdedLootApplier.apply(engine, level, pos, state, chunk);
} catch (SavedBiomeUnavailableException unavailable) {
GenerationFailures.rethrowEngineFailure(unavailable);
IrisLogging.warnOnce("chunk-update-loot:" + level.name() + ":" + unavailable.getMessage(),
"Iris left the container at " + pos + " in " + level.name() + " without loot: " + unavailable.getMessage());
} catch (Throwable e) {
GenerationFailures.rethrowEngineFailure(e);
IrisLogging.reportError(e);
}
} else {
@@ -0,0 +1,7 @@
{
"pack": {
"description": "Iris client resources and mod data",
"min_format": [97, 1],
"max_format": [121, 0]
}
}
@@ -72,10 +72,10 @@ public class IrisModLanguageAssetsTest {
}
private Set<String> resourceFiles() throws Exception {
URL resource = IrisModLanguageAssetsTest.class.getClassLoader().getResource(ROOT);
assertNotNull("Missing mod language resource directory: " + ROOT, resource);
URL resource = IrisModLanguageAssetsTest.class.getClassLoader().getResource(ROOT + "en_us.json");
assertNotNull("Missing English mod language resource", resource);
assertEquals("Mod language resources must resolve to a directory on disk", "file", resource.getProtocol());
Path directory = Path.of(resource.toURI());
Path directory = Path.of(resource.toURI()).getParent();
try (Stream<Path> paths = Files.list(directory)) {
return paths
.filter(Files::isRegularFile)
@@ -6,6 +6,7 @@ import net.minecraft.SharedConstants;
import net.minecraft.server.Bootstrap;
import net.minecraft.util.random.Weighted;
import net.minecraft.util.random.WeightedList;
import net.minecraft.util.valueproviders.UniformInt;
import net.minecraft.world.entity.EntityTypes;
import net.minecraft.world.level.biome.MobSpawnSettings;
import org.junit.Test;
@@ -24,10 +25,10 @@ public class IrisModdedChunkGeneratorSpawnTest {
@Test
public void explicitSpawnsReplaceMatchingVanillaTypesAndPreserveOthers() {
MobSpawnSettings.SpawnerData zombie = new MobSpawnSettings.SpawnerData(EntityTypes.ZOMBIE, 1, 4);
MobSpawnSettings.SpawnerData vanillaSlime = new MobSpawnSettings.SpawnerData(EntityTypes.SLIME, 1, 1);
MobSpawnSettings.SpawnerData explicitSlime = new MobSpawnSettings.SpawnerData(EntityTypes.SLIME, 2, 5);
MobSpawnSettings.SpawnerData explicitCow = new MobSpawnSettings.SpawnerData(EntityTypes.COW, 2, 4);
MobSpawnSettings.SpawnerData zombie = new MobSpawnSettings.SpawnerData(EntityTypes.ZOMBIE, UniformInt.of(1, 4));
MobSpawnSettings.SpawnerData vanillaSlime = new MobSpawnSettings.SpawnerData(EntityTypes.SLIME, UniformInt.of(1, 1));
MobSpawnSettings.SpawnerData explicitSlime = new MobSpawnSettings.SpawnerData(EntityTypes.SLIME, UniformInt.of(2, 5));
MobSpawnSettings.SpawnerData explicitCow = new MobSpawnSettings.SpawnerData(EntityTypes.COW, UniformInt.of(2, 4));
WeightedList<MobSpawnSettings.SpawnerData> vanilla = WeightedList.of(List.of(
new Weighted<>(zombie, 100),
new Weighted<>(vanillaSlime, 1)));
@@ -0,0 +1,170 @@
package art.arcane.iris.modded;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedChunkGenerator;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.volmlib.util.mantle.MantleClosedException;
import net.minecraft.SharedConstants;
import net.minecraft.server.Bootstrap;
import net.minecraft.server.level.ServerChunkCache;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.chunk.ChunkGenerator;
import org.junit.BeforeClass;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.io.IOException;
import java.lang.reflect.Field;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.when;
/**
* A level whose Iris generator cannot bind must crash its own creation, never fall through to another generator,
* and every later chunk call must fail fast on the recorded failure instead of re-running the whole bind
* (history verification included) on every worker thread.
*/
public class ModdedBindFailureTest {
@BeforeClass
public static void bootstrap() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
}
@Test
public void aRecordedBindFailureIsReportedOnceAndRethrownWithoutBindingAgain() throws Exception {
IrisModdedChunkGenerator generator = generator("overworld:overworld");
NativeWorld level = level(generator, "minecraft:overworld");
field("boundLevel").set(generator, level);
NativeModdedServer server = mock(NativeModdedServer.class);
when(server.generateStructures()).thenReturn(false);
try (MockedStatic<NativeModdedServer> servers = mockStatic(NativeModdedServer.class);
MockedStatic<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) {
servers.when(() -> NativeModdedServer.forWorld(level)).thenReturn(server);
IllegalStateException recorded = assertThrows(IllegalStateException.class, () -> generator.bindLevel(level));
IllegalStateException second = assertThrows(IllegalStateException.class, generator::engine);
IllegalStateException third = assertThrows(IllegalStateException.class, () -> generator.bindLevel(level));
assertSame(recorded, second.getCause());
assertSame(recorded, third.getCause());
servers.verify(() -> NativeModdedServer.forWorld(level), times(1));
log.verify(() -> ModdedIrisLog.error(argThat((String line) -> line != null && line.startsWith("Cause: ")
&& line.contains("generate-structures=false"))), times(1));
}
}
/**
* A bind that fails because the runtime is stopping (level unload, quit to title, server stop) says nothing about
* the world: it fails the chunk that asked, but the next bind tries again and no refusal is announced.
*/
@Test
public void aShutdownFailureDuringBindIsNeitherRememberedNorAnnouncedAsARefusal() throws Exception {
IrisModdedChunkGenerator generator = generator("overworld:overworld");
NativeWorld level = level(generator, "minecraft:overworld");
field("boundLevel").set(generator, level);
NativeModdedServer server = mock(NativeModdedServer.class);
when(server.generateStructures()).thenReturn(true);
MantleClosedException closed = new MantleClosedException("Tectonic Plate is closed!");
try (MockedStatic<NativeModdedServer> servers = mockStatic(NativeModdedServer.class);
MockedStatic<ModdedWorldEngines> engines = mockStatic(ModdedWorldEngines.class);
MockedStatic<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) {
servers.when(() -> NativeModdedServer.forWorld(level)).thenReturn(server);
engines.when(() -> ModdedWorldEngines.get(eq(level), any(), any(), anyLong(), any())).thenThrow(closed);
assertSame(closed, assertThrows(MantleClosedException.class, generator::engine));
assertSame(closed, assertThrows(MantleClosedException.class, generator::engine));
engines.verify(() -> ModdedWorldEngines.get(eq(level), any(), any(), anyLong(), any()), times(2));
log.verify(() -> ModdedIrisLog.error(anyString()), never());
}
}
/**
* Unloading a level resets its binding, so a refusal recorded in one load never carries into the next load of the
* same generator (a runtime dimension removal that rolls back, or a level loaded again in the same JVM).
*/
@Test
public void aRecordedRefusalDoesNotOutliveTheUnloadOfItsLevel() throws Exception {
IrisModdedChunkGenerator generator = generator("overworld:overworld");
NativeWorld level = level(generator, "minecraft:overworld");
doNothing().when((NativeModdedChunkGenerator<?, ?, ?>) field("nativeGenerator").get(generator)).clearCaches();
field("importedFeatures").set(generator, mock(ModdedImportedFeatureStage.class));
field("announced").set(generator, new AtomicBoolean());
NativeModdedServer server = mock(NativeModdedServer.class);
when(server.generateStructures()).thenReturn(false);
try (MockedStatic<NativeModdedServer> servers = mockStatic(NativeModdedServer.class);
MockedStatic<ModdedWorldEngines> engines = mockStatic(ModdedWorldEngines.class);
MockedStatic<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) {
servers.when(() -> NativeModdedServer.forWorld(level)).thenReturn(server);
IllegalStateException first = assertThrows(IllegalStateException.class, () -> generator.bindLevel(level));
generator.unbindEngine(level);
IllegalStateException second = assertThrows(IllegalStateException.class, () -> generator.bindLevel(level));
assertNotSame(first, second.getCause());
servers.verify(() -> NativeModdedServer.forWorld(level), times(2));
engines.verify(() -> ModdedWorldEngines.evictOrThrow(level));
}
}
@Test
public void bindFailureNoticeNamesTheLevelTheCauseAndThatNothingGenerates() {
String notice = String.join("\n", IrisModdedChunkGenerator.bindFailureNotice(
"minecraft:overworld",
"overworld:overworld",
new IllegalStateException("Iris generation history is unusable for 'minecraft:overworld'",
new IOException("Historical generated registry definition changed"))));
assertTrue(notice, notice.contains("minecraft:overworld"));
assertTrue(notice, notice.contains("overworld:overworld"));
assertTrue(notice, notice.contains("generation history is unusable"));
assertTrue(notice, notice.contains("registry definition changed"));
assertTrue(notice, notice.contains("no chunks"));
}
private static IrisModdedChunkGenerator generator(String dimensionKey) throws ReflectiveOperationException {
IrisModdedChunkGenerator generator = mock(IrisModdedChunkGenerator.class, CALLS_REAL_METHODS);
field("nativeGenerator").set(generator, mock(NativeModdedChunkGenerator.class, CALLS_REAL_METHODS));
field("engineBinding").set(generator, new ModdedEngineBinding<Engine>(1L, TimeUnit.SECONDS));
field("dimensionKey").set(generator, dimensionKey);
return generator;
}
private static NativeWorld level(IrisModdedChunkGenerator generator, String name) throws ReflectiveOperationException {
NativeWorld level = mock(NativeWorld.class);
ServerLevel handle = mock(ServerLevel.class);
ServerChunkCache chunkSource = mock(ServerChunkCache.class);
when(level.nativeHandle()).thenReturn(handle);
when(level.name()).thenReturn(name);
when(handle.getChunkSource()).thenReturn(chunkSource);
when(chunkSource.getGenerator()).thenReturn((ChunkGenerator) field("nativeGenerator").get(generator));
return level;
}
private static Field field(String name) throws NoSuchFieldException {
Field field = IrisModdedChunkGenerator.class.getDeclaredField(name);
field.setAccessible(true);
return field;
}
}
@@ -1,6 +1,13 @@
package art.arcane.iris.modded;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.GenerationSessionException;
import art.arcane.iris.generation.runtime.GenerationSessionLease;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.volmlib.nativelib.terrain.NativeBiomeSourceAccess;
import art.arcane.volmlib.util.stream.ProceduralStream;
import art.arcane.volmlib.util.stream.interpolation.Interpolated;
import org.junit.Test;
import java.util.ArrayList;
@@ -13,6 +20,9 @@ import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class ModdedBiomePolicyTest {
@Test
@@ -52,6 +62,52 @@ public class ModdedBiomePolicyTest {
assertTrue(failure.getMessage().contains("example:missing"));
}
@Test
public void structureRingBiomesResolveFromTheNaturalSurfaceWithoutPlanningHydrology() throws Exception {
BiomeToken forest = new BiomeToken("minecraft:forest");
IrisBiome natural = mock(IrisBiome.class);
when(natural.getStructureDerivativeKey()).thenReturn(forest.key());
IrisComplex complex = mock(IrisComplex.class);
when(complex.getNaturalTrueBiomeStream()).thenReturn(
ProceduralStream.of((x, z) -> natural, Interpolated.of(value -> 0D, value -> null)));
when(complex.getTrueBiomeStream()).thenThrow(
new AssertionError("Stronghold ring lookups must not plan hydrology"));
Engine engine = mock(Engine.class);
when(engine.getComplex()).thenReturn(complex);
when(engine.getMinHeight()).thenReturn(-64);
when(engine.acquireGenerationLease(anyString())).thenReturn(GenerationSessionLease.noop());
ModdedBiomePolicy<BiomeToken, Object> policy = new ModdedBiomePolicy<>(
new Access(new ArrayList<>(List.of(forest))));
policy.bind(new ModdedBiomePolicy.RuntimeCallbacks(() -> engine, () -> engine, bound -> true,
() -> Set.of(forest.key()), bound -> Set.of(), () -> 0L));
assertSame(forest, policy.requiredStructureBiome(6000, 0, -6000, null));
assertSame(forest, policy.requiredStructureBiome(6000, -30, -6000, null));
}
/**
* Stacked initial mob spawning asks for the visible surface biome. A null answer made it spawn from the chunk's
* top biome instead, so a sealed or unready engine has to fail the chunk.
*/
@Test
public void surfaceSpawnBiomeRefusesAnEngineInTransitionInsteadOfAnsweringNothing() throws Exception {
Engine sealed = mock(Engine.class);
when(sealed.isClosing()).thenReturn(true);
when(sealed.acquireGenerationLease(anyString())).thenThrow(new GenerationSessionException("sealed", true));
Engine unready = mock(Engine.class);
when(unready.acquireGenerationLease(anyString())).thenReturn(GenerationSessionLease.noop());
assertThrows(IllegalStateException.class, () -> boundTo(sealed).getVisibleSurfaceBiome(8, 8));
assertThrows(IllegalStateException.class, () -> boundTo(unready).getVisibleSurfaceBiome(8, 8));
}
private static ModdedBiomePolicy<BiomeToken, Object> boundTo(Engine engine) {
ModdedBiomePolicy<BiomeToken, Object> policy = new ModdedBiomePolicy<>(new Access(new ArrayList<>()));
policy.bind(new ModdedBiomePolicy.RuntimeCallbacks(() -> engine, () -> engine, bound -> true,
Set::of, bound -> Set.of(), () -> 0L));
return policy;
}
private static ModdedBiomePolicy<BiomeToken, Object> policy(List<BiomeToken> entries,
AtomicReference<Set<String>> configured) {
ModdedBiomePolicy<BiomeToken, Object> policy = new ModdedBiomePolicy<>(new Access(entries));
@@ -17,7 +17,7 @@ import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
/**
* Dist gate. The loader source sets fold modded-common, minecraft-common and client-common into one output, so
* Dist gate. The loader source sets fold modded-common and client-common into one output, so
* nothing at compile time stops a server-side class from touching a client-only type. On a dedicated server
* that is a NoClassDefFoundError at the first call, usually deep inside worldgen.
*
@@ -79,7 +79,7 @@ public class ModdedDimensionTypeParityTest {
IrisDimension end = dimension("the_end", IrisEnvironment.THE_END, 0, 256, 256, new IrisDimensionTypeOptions());
IrisDimension custom = dimension("custom_contract", IrisEnvironment.CUSTOM, -128, 384, 384, customOptions);
List<IrisDimension> dimensions = List.of(overworld, nether, end, custom);
IDataFixer fixer = DataVersion.V26_2.get();
IDataFixer fixer = DataVersion.V26_3.get();
Path packDirectory = Files.createTempDirectory("iris-dimension-contracts");
KList<File> roots = new KList<>();
roots.add(packDirectory.toFile());
@@ -0,0 +1,111 @@
package art.arcane.iris.modded;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.generation.concurrent.MultiBurst;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.modded.command.IrisModdedCommands;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeServerSpawn;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import net.minecraft.SharedConstants;
import net.minecraft.server.Bootstrap;
import net.minecraft.server.level.ServerChunkCache;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.chunk.ChunkGenerator;
import org.junit.After;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;
import org.mockito.MockedConstruction;
import org.mockito.MockedStatic;
import java.util.concurrent.Callable;
import java.util.concurrent.TimeUnit;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.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.verify;
import static org.mockito.Mockito.when;
public class ModdedEngineBootstrapShutdownTest {
private IrisSettings previousSettings;
@BeforeClass
public static void bootstrap() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
}
@Before
public void openRuntime() {
previousSettings = IrisSettings.settings;
IrisSettings.settings = new IrisSettings();
reopenBurstPools();
}
@After
public void restoreRuntime() {
reopenBurstPools();
IrisModdedCommands.openDownloadAdmission();
IrisSettings.settings = previousSettings;
}
@Test
public void serverStoppingKeepsEnginesAndWorkersOpenUntilServerStopped() throws Exception {
NativeWorld level = level();
Engine engine = mock(Engine.class);
ModdedWorldEngines.installReplacement(level, engine);
Callable<Thread> worker = Thread::currentThread;
ModdedEngineBootstrap.stop();
verify(engine, never()).close();
assertTrue(ModdedWorldEngines.activeEngines().contains(engine));
assertNotSame(Thread.currentThread(), MultiBurst.burst.submit(worker).get(10L, TimeUnit.SECONDS));
ModdedEngineBootstrap.stopped();
verify(engine).close();
assertFalse(ModdedWorldEngines.activeEngines().contains(engine));
assertSame(Thread.currentThread(), MultiBurst.burst.submit(worker).get(10L, TimeUnit.SECONDS));
}
@Test
public void nextServerStartClosesTheEnginesOfAServerThatNeverReachedStopped() {
NativeWorld level = level();
Engine engine = mock(Engine.class);
ModdedWorldEngines.installReplacement(level, engine);
ModdedEngineBootstrap.stop();
try (MockedStatic<ModdedStartup> startup = mockStatic(ModdedStartup.class);
MockedConstruction<NativeServerSpawn> spawn = mockConstruction(NativeServerSpawn.class)) {
ModdedEngineBootstrap.serverAboutToStart(mock(NativeModdedServer.class));
}
verify(engine).close();
assertFalse(ModdedWorldEngines.activeEngines().contains(engine));
}
private static NativeWorld level() {
ServerLevel serverLevel = mock(ServerLevel.class);
ServerChunkCache chunks = mock(ServerChunkCache.class);
when(serverLevel.getChunkSource()).thenReturn(chunks);
when(chunks.getGenerator()).thenReturn(mock(ChunkGenerator.class));
NativeWorld level = mock(NativeWorld.class);
when(level.nativeHandle()).thenReturn(serverLevel);
when(level.name()).thenReturn("example:shutdown");
return level;
}
private static void reopenBurstPools() {
MultiBurst.burst.reopen();
MultiBurst.hydrology.reopen();
MultiBurst.ioBurst.reopen();
}
}
@@ -20,6 +20,7 @@ package art.arcane.iris.modded;
import art.arcane.volmlib.util.collection.KSet;
import art.arcane.volmlib.util.json.JSONObject;
import art.arcane.iris.pack.datapack.DataVersion;
import art.arcane.iris.pack.datapack.v1217.DataFixerV1217;
import art.arcane.iris.world.history.GenerationEpochContractFactory;
import art.arcane.iris.world.history.GenerationRegistryContract;
@@ -27,6 +28,8 @@ import art.arcane.iris.world.history.GenerationRegistryContractFactory;
import art.arcane.iris.generation.biome.IrisCustomBiomeAliasResolver;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.generation.terrain.IrisDimensionType;
import art.arcane.iris.spi.IrisPlatform;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.PlatformGenerationRegistry;
import java.lang.reflect.Constructor;
import java.util.Set;
@@ -48,23 +51,48 @@ import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class ModdedForcedDatapackTest {
@Test
public void metadataRetains262FormatWhenBukkitAlsoSupports263() throws IOException {
public void metadataUses263DataFormat() throws IOException {
Path packDirectory = Files.createTempDirectory("iris-modded-pack-format");
try {
ModdedForcedDatapack.writePackMeta(packDirectory);
JSONObject pack = new JSONObject(Files.readString(packDirectory.resolve("pack.mcmeta")))
.getJSONObject("pack");
assertEquals(107, pack.getInt("pack_format"));
assertEquals(107, pack.getInt("min_format"));
assertEquals(107, pack.getInt("max_format"));
assertEquals(121, pack.getInt("pack_format"));
assertEquals(121, pack.getInt("min_format"));
assertEquals(121, pack.getInt("max_format"));
} finally {
deleteTree(packDirectory);
}
}
@Test
public void metadataFollowsTheRuntimeDataVersionThatValidationUses() throws IOException {
IrisPlatform previous = IrisPlatforms.getOrNull();
IrisPlatform runtime = mock(IrisPlatform.class);
when(runtime.minecraftVersion()).thenReturn("26.2");
Path packDirectory = Files.createTempDirectory("iris-modded-runtime-pack-format");
IrisPlatforms.unbind();
IrisPlatforms.bind(runtime);
try {
ModdedForcedDatapack.writePackMeta(packDirectory);
JSONObject pack = new JSONObject(Files.readString(packDirectory.resolve("pack.mcmeta")))
.getJSONObject("pack");
assertEquals(DataVersion.getRuntime().getPackFormat(), pack.getInt("pack_format"));
assertEquals(DataVersion.V26_2.getPackFormat(), pack.getInt("pack_format"));
} finally {
IrisPlatforms.unbind();
if (previous != null) {
IrisPlatforms.bind(previous);
}
deleteTree(packDirectory);
}
}
@Test
public void retainedDimensionSourceRendersBeforeNativeRegistryExists() throws Exception {
DataFixerV1217 fixer = new DataFixerV1217();
@@ -3,6 +3,7 @@ package art.arcane.iris.modded;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeFeatureBiomeSource;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.GenerationClosedException;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.structure.nativegen.IrisImportedFeatureControl;
import org.junit.Test;
@@ -11,6 +12,9 @@ import net.minecraft.SharedConstants;
import net.minecraft.server.Bootstrap;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
@@ -56,17 +60,78 @@ public class ModdedImportedFeatureStageTest {
}
@Test
public void closingRuntimeDoesNotBuildFeatureTables() {
public void aSealedRuntimeWithoutASettledTableRefusesDecorationInsteadOfSkippingFeatures() {
NativeFeatureBiomeSource source = mock(NativeFeatureBiomeSource.class);
when(source.packGeneration()).thenReturn(1L);
Engine engine = mock(Engine.class);
when(engine.getCacheID()).thenReturn(7);
when(engine.isClosing()).thenReturn(true);
ModdedImportedFeatureStage stage = new ModdedImportedFeatureStage(source);
stage.prepare(engine);
stage.prepareWithoutWaiting(engine);
assertThrows(GenerationClosedException.class, () -> stage.prepare(engine));
verify(source, never()).packGeneration();
verify(engine, never()).getDimension();
assertFalse(stage.active());
}
@Test
public void aSealedRuntimeKeepsDecoratingWithTheTableItAlreadySettled() {
NativeFeatureBiomeSource source = mock(NativeFeatureBiomeSource.class);
when(source.packGeneration()).thenReturn(1L);
Engine engine = disabledFeatures(7);
ModdedImportedFeatureStage stage = new ModdedImportedFeatureStage(source);
stage.bind(mock(IrisModdedChunkGenerator.class));
stage.prepare(engine);
when(engine.isClosing()).thenReturn(true);
stage.prepare(engine);
assertNull(stage.placementTable(engine));
}
@Test
public void aTableRetiredBetweenPrepareAndPlacementFailsTheChunk() {
NativeFeatureBiomeSource source = mock(NativeFeatureBiomeSource.class);
when(source.packGeneration()).thenReturn(1L);
Engine engine = disabledFeatures(7);
ModdedImportedFeatureStage stage = new ModdedImportedFeatureStage(source);
stage.bind(mock(IrisModdedChunkGenerator.class));
stage.prepare(engine);
stage.evictRuntime(7);
assertThrows(GenerationClosedException.class, () -> stage.placementTable(engine));
stage.prepare(engine);
when(source.packGeneration()).thenReturn(2L);
assertThrows(GenerationClosedException.class, () -> stage.placementTable(engine));
}
@Test
public void anUnboundGeneratorAnswersVanillaGenerationSettings() {
IrisModdedChunkGenerator generator = mock(IrisModdedChunkGenerator.class);
when(generator.boundEngineOrNull()).thenReturn(null);
ModdedImportedFeatureStage stage = new ModdedImportedFeatureStage(mock(NativeFeatureBiomeSource.class));
stage.bind(generator);
assertNull(stage.currentTable());
}
@Test
public void aFailedBindFailsTheGenerationSettingsInsteadOfDroppingImportedFeatures() {
IllegalStateException refused = new IllegalStateException("Iris generator 'overworld:overworld' failed to bind");
IrisModdedChunkGenerator generator = mock(IrisModdedChunkGenerator.class);
when(generator.boundEngineOrNull()).thenThrow(refused);
ModdedImportedFeatureStage stage = new ModdedImportedFeatureStage(mock(NativeFeatureBiomeSource.class));
stage.bind(generator);
assertSame(refused, assertThrows(IllegalStateException.class, stage::currentTable));
}
private static Engine disabledFeatures(int runtimeIdentity) {
Engine engine = mock(Engine.class);
when(engine.getDimension()).thenReturn(new IrisDimension().setImportedFeatures(new IrisImportedFeatureControl()));
when(engine.getCacheID()).thenReturn(runtimeIdentity);
return engine;
}
}
@@ -0,0 +1,194 @@
package art.arcane.iris.modded;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.junit.Assume;
import org.junit.Test;
import org.objectweb.asm.AnnotationVisitor;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;
import org.objectweb.asm.Type;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
public class ModdedMixinAuditTest {
private static final String MIXIN_ANNOTATION = "Lorg/spongepowered/asm/mixin/Mixin;";
private static final Set<String> INJECTOR_ANNOTATIONS = Set.of(
"Lorg/spongepowered/asm/mixin/injection/Inject;",
"Lorg/spongepowered/asm/mixin/injection/Redirect;",
"Lorg/spongepowered/asm/mixin/injection/ModifyArg;",
"Lorg/spongepowered/asm/mixin/injection/ModifyArgs;",
"Lorg/spongepowered/asm/mixin/injection/ModifyVariable;",
"Lorg/spongepowered/asm/mixin/injection/ModifyConstant;");
private static final Pattern NEOFORGE_MIXIN_CONFIG = Pattern.compile(
"\\[\\[mixins]]\\s*config\\s*=\\s*\"([^\"]+)\"");
// One applied mixin per config proves the config was registered, which is all the audit detects.
private static final Set<String> UNAUDITED = Set.of(
"volmlib.entity.mixins.json/WorldGenerationWriteGuardMixin",
"volmlib.entity.mixins.json/ChunkMapTerrainReceiptAccess",
"volmlib.entity.mixins.json/ChunkTerrainReceiptMixin",
"volmlib.entity.mixins.json/LevelChunkTerrainReceiptMixin",
"volmlib.entity.mixins.json/SerializableTerrainReceiptMixin",
"volmlib.fabric.mixins.json/BlockItemMixin",
"volmlib.fabric.mixins.json/BlockMixin",
"volmlib.fabric.mixins.json/PackRepositoryMixin");
@Test
public void everyExpectedHandlerIsAnInjectorDeclaredByItsMixin() throws IOException {
for (ModdedMixinAudit.ExpectedMixin expected : ModdedMixinAudit.EXPECTED) {
MixinConfig config = MixinConfig.read(expected.config());
String section = config.sections().get(expected.mixinName());
assertNotNull(expected.config() + " does not list " + expected.mixinName(), section);
assertEquals(expected.mixinName() + " client-only flag disagrees with its config section",
"client".equals(section), expected.clientOnly());
MixinClass mixin = MixinClass.read(config.packageName() + '.' + expected.mixinName());
assertTrue(expected.mixinName() + " does not target " + expected.targetClass().className()
+ " (targets " + mixin.targets() + ")",
mixin.targets().contains(expected.targetClass().className()));
assertTrue(expected.mixinName() + " declares no injector handler named " + expected.handlerMethod()
+ " (injectors " + mixin.injectors() + ")",
mixin.injectors().contains(expected.handlerMethod()));
}
}
@Test
public void everyRegisteredMixinIsAuditedOrDeliberatelyUnaudited() throws IOException {
Set<String> registered = registeredConfigs();
Assume.assumeFalse("loader metadata on this classpath declares no mixin configs", registered.isEmpty());
Set<String> audited = new LinkedHashSet<>();
for (ModdedMixinAudit.ExpectedMixin expected : ModdedMixinAudit.EXPECTED) {
audited.add(expected.config() + '/' + expected.mixinName());
}
Set<String> listed = new TreeSet<>();
for (String configName : registered) {
for (String mixinName : MixinConfig.read(configName).sections().keySet()) {
listed.add(configName + '/' + mixinName);
}
}
Set<String> uncovered = new TreeSet<>(listed);
uncovered.removeAll(audited);
uncovered.removeAll(UNAUDITED);
assertEquals("mixins neither audited nor deliberately unaudited", Set.of(), uncovered);
Set<String> unregisteredAudits = new TreeSet<>(audited);
unregisteredAudits.removeAll(listed);
assertEquals("audited mixins missing from the registered configs", Set.of(), unregisteredAudits);
}
private static Set<String> registeredConfigs() throws IOException {
Set<String> configs = new LinkedHashSet<>();
String fabric = readResource("fabric.mod.json");
if (fabric != null) {
for (JsonElement entry : JsonParser.parseString(fabric).getAsJsonObject().getAsJsonArray("mixins")) {
configs.add(entry.isJsonObject()
? entry.getAsJsonObject().get("config").getAsString()
: entry.getAsString());
}
}
String neoforge = readResource("META-INF/neoforge.mods.toml");
if (neoforge != null) {
Matcher matcher = NEOFORGE_MIXIN_CONFIG.matcher(neoforge);
while (matcher.find()) {
configs.add(matcher.group(1));
}
}
return configs;
}
private static String readResource(String name) throws IOException {
try (InputStream input = ModdedMixinAuditTest.class.getClassLoader().getResourceAsStream(name)) {
return input == null ? null : new String(input.readAllBytes(), StandardCharsets.UTF_8);
}
}
private record MixinConfig(String packageName, Map<String, String> sections) {
static MixinConfig read(String name) throws IOException {
String json = readResource(name);
assertNotNull("Missing mixin config resource " + name, json);
JsonObject root = JsonParser.parseString(json).getAsJsonObject();
Map<String, String> sections = new LinkedHashMap<>();
for (String section : new String[]{"mixins", "client", "server"}) {
JsonArray entries = root.getAsJsonArray(section);
if (entries == null) {
continue;
}
for (JsonElement entry : entries) {
sections.put(entry.getAsString(), section);
}
}
return new MixinConfig(root.get("package").getAsString(), sections);
}
}
private record MixinClass(Set<String> targets, Set<String> injectors) {
static MixinClass read(String className) throws IOException {
String resource = className.replace('.', '/') + ".class";
byte[] bytes;
try (InputStream input = ModdedMixinAuditTest.class.getClassLoader().getResourceAsStream(resource)) {
assertNotNull("Missing mixin class " + className, input);
bytes = input.readAllBytes();
}
Set<String> targets = new TreeSet<>();
Set<String> injectors = new TreeSet<>();
new ClassReader(bytes).accept(new ClassVisitor(Opcodes.ASM9) {
@Override
public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) {
if (!MIXIN_ANNOTATION.equals(descriptor)) {
return null;
}
return new AnnotationVisitor(Opcodes.ASM9) {
@Override
public AnnotationVisitor visitArray(String name) {
return new AnnotationVisitor(Opcodes.ASM9) {
@Override
public void visit(String ignored, Object value) {
if (value instanceof Type type) {
targets.add(type.getClassName());
} else if (value instanceof String target) {
targets.add(target.replace('/', '.'));
}
}
};
}
};
}
@Override
public MethodVisitor visitMethod(int access, String name, String descriptor, String signature,
String[] exceptions) {
return new MethodVisitor(Opcodes.ASM9) {
@Override
public AnnotationVisitor visitAnnotation(String annotation, boolean visible) {
if (INJECTOR_ANNOTATIONS.contains(annotation)) {
injectors.add(name);
}
return null;
}
};
}
}, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES);
return new MixinClass(targets, injectors);
}
}
}
@@ -0,0 +1,45 @@
package art.arcane.iris.modded;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.mojang.serialization.JsonOps;
import net.minecraft.SharedConstants;
import net.minecraft.server.Bootstrap;
import net.minecraft.server.packs.PackType;
import net.minecraft.server.packs.metadata.pack.PackMetadataSection;
import org.junit.BeforeClass;
import org.junit.Test;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
public class ModdedPackMetadataTest {
@BeforeClass
public static void bootstrapMinecraftRegistries() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
}
@Test
public void metadataLoadsClientResourcesAndServerDataForCurrentMinecraft() throws IOException {
try (InputStream resource = ModdedPackMetadataTest.class.getResourceAsStream("/pack.mcmeta")) {
assertNotNull(resource);
try (InputStreamReader reader = new InputStreamReader(resource, StandardCharsets.UTF_8)) {
JsonObject metadata = JsonParser.parseReader(reader).getAsJsonObject();
for (PackType type : PackType.values()) {
PackMetadataSection section = PackMetadataSection.forPackType(type).codec()
.parse(JsonOps.INSTANCE, metadata.get("pack")).getOrThrow();
assertEquals("Iris client resources and mod data", section.description().getString());
assertTrue(type.toString(), section.supportedFormats().isValueInRange(
SharedConstants.getCurrentVersion().packVersion(type)));
}
}
}
}
}
@@ -9,6 +9,10 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedServerAccess;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedServerLevels;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.EntitySpawnReason;
import net.minecraft.world.entity.Mob;
import net.minecraft.world.level.ServerLevelAccessor;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.block.state.BlockState;
import org.junit.Rule;
import org.junit.Test;
@@ -33,7 +37,7 @@ public class ModdedPlatformPathsTest {
@Override
public String minecraftVersion() {
return "26.2";
return "26.3";
}
@Override
@@ -83,6 +87,16 @@ public class ModdedPlatformPathsTest {
public boolean canBreakBlock(ServerLevel level, ServerPlayer player, BlockPos position, BlockState state) {
return false;
}
@Override
public boolean checkSpawnPosition(Mob mob, ServerLevelAccessor level, EntitySpawnReason reason) {
return false;
}
@Override
public Biome unmodifiedBiome(Biome biome) {
return biome;
}
});
}
@@ -0,0 +1,256 @@
package art.arcane.iris.modded;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldTeleport;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import org.junit.After;
import org.junit.Test;
import org.mockito.MockedStatic;
import org.mockito.invocation.InvocationOnMock;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* A configured primary Iris world that did not load used to be skipped silently, leaving every player in the
* overworld generating terrain Iris was configured to replace.
*/
public class ModdedPrimaryWorldRouterTest {
private static final int TICK_INTERVAL = 20;
private static final String PRIMARY = "iris:primary";
@After
public void clearRouter() {
ModdedPrimaryWorldRouter.clear();
}
@Test
public void aPrimaryWorldThisSaveListsButDidNotRestoreRefusesPlayers() {
NativeModdedServer server = mock(NativeModdedServer.class);
NativeProtocolPlayer online = mock(NativeProtocolPlayer.class);
onlinePlayers(server, online);
ModdedModConfig routed = config(true, PRIMARY);
try (MockedStatic<ModdedModConfig> configs = mockStatic(ModdedModConfig.class);
MockedStatic<ModdedDimensionManager> manager = mockStatic(ModdedDimensionManager.class);
MockedStatic<ModdedDimensionRegistryStore> registry = mockStatic(ModdedDimensionRegistryStore.class);
MockedStatic<ModdedStartup> startup = mockStatic(ModdedStartup.class);
MockedStatic<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) {
configs.when(ModdedModConfig::get).thenReturn(routed);
manager.when(() -> ModdedDimensionManager.level(server, PRIMARY)).thenReturn(null);
registry.when(() -> ModdedDimensionRegistryStore.get(server, PRIMARY)).thenReturn(persisted());
startup.when(ModdedStartup::dimensionsRestored).thenReturn(true);
evaluate(server);
evaluate(server);
verify(online, times(2)).disconnect(contains(PRIMARY));
log.verify(() -> ModdedIrisLog.error(argThat((String line) -> line != null && line.startsWith("Cause: ")
&& line.contains(PRIMARY))), times(1));
NativeProtocolPlayer joining = mock(NativeProtocolPlayer.class);
assertTrue(ModdedPrimaryWorldRouter.refuseIfUnavailable(joining));
verify(joining).disconnect(contains(PRIMARY));
}
}
/**
* primaryWorld lives in the instance-wide modded.json, but the dimension it names lives in one save. Another
* save, a new world or a reset world folder never had it, so nothing failed there and nothing is refused.
*/
@Test
public void aPrimaryWorldThisSaveNeverHadLeavesRoutingIdleWithOneWarning() {
NativeModdedServer server = mock(NativeModdedServer.class);
NativeProtocolPlayer online = mock(NativeProtocolPlayer.class);
onlinePlayers(server, online);
ModdedModConfig routed = config(true, PRIMARY);
try (MockedStatic<ModdedModConfig> configs = mockStatic(ModdedModConfig.class);
MockedStatic<ModdedDimensionManager> manager = mockStatic(ModdedDimensionManager.class);
MockedStatic<ModdedDimensionRegistryStore> registry = mockStatic(ModdedDimensionRegistryStore.class);
MockedStatic<ModdedStartup> startup = mockStatic(ModdedStartup.class);
MockedStatic<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) {
configs.when(ModdedModConfig::get).thenReturn(routed);
manager.when(() -> ModdedDimensionManager.level(server, PRIMARY)).thenReturn(null);
registry.when(() -> ModdedDimensionRegistryStore.get(server, PRIMARY)).thenReturn(null);
startup.when(ModdedStartup::dimensionsRestored).thenReturn(true);
evaluate(server);
evaluate(server);
verify(online, never()).disconnect(anyString());
assertFalse(ModdedPrimaryWorldRouter.refuseIfUnavailable(mock(NativeProtocolPlayer.class)));
log.verify(() -> ModdedIrisLog.warn(argThat((String line) -> line != null && line.contains(PRIMARY))), times(1));
log.verify(() -> ModdedIrisLog.error(anyString()), never());
}
}
/**
* Joins are processed before the first Iris tick restores persistent dimensions, so a primary world that is
* missing then is still loading, not broken.
*/
@Test
public void aPrimaryWorldIsNotJudgedBeforeStartupRestoresDimensions() {
NativeModdedServer server = mock(NativeModdedServer.class);
NativeProtocolPlayer online = mock(NativeProtocolPlayer.class);
onlinePlayers(server, online);
ModdedModConfig routed = config(true, PRIMARY);
try (MockedStatic<ModdedModConfig> configs = mockStatic(ModdedModConfig.class);
MockedStatic<ModdedDimensionManager> manager = mockStatic(ModdedDimensionManager.class);
MockedStatic<ModdedStartup> startup = mockStatic(ModdedStartup.class);
MockedStatic<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) {
configs.when(ModdedModConfig::get).thenReturn(routed);
manager.when(() -> ModdedDimensionManager.level(server, PRIMARY)).thenReturn(null);
startup.when(ModdedStartup::dimensionsRestored).thenReturn(false);
evaluate(server);
verify(online, never()).disconnect(anyString());
assertFalse(ModdedPrimaryWorldRouter.refuseIfUnavailable(mock(NativeProtocolPlayer.class)));
}
}
@Test
public void refusalLiftsOnceThePrimaryWorldIsLoadedOrUnconfigured() {
NativeModdedServer server = mock(NativeModdedServer.class);
NativeWorld overworld = mock(NativeWorld.class);
when(server.overworld()).thenReturn(overworld);
onlinePlayers(server, mock(NativeProtocolPlayer.class));
ModdedModConfig routed = config(true, PRIMARY);
ModdedModConfig unconfigured = config(true, "");
try (MockedStatic<ModdedModConfig> configs = mockStatic(ModdedModConfig.class);
MockedStatic<ModdedDimensionManager> manager = mockStatic(ModdedDimensionManager.class);
MockedStatic<ModdedDimensionRegistryStore> registry = mockStatic(ModdedDimensionRegistryStore.class);
MockedStatic<ModdedStartup> startup = mockStatic(ModdedStartup.class);
MockedStatic<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) {
registry.when(() -> ModdedDimensionRegistryStore.get(server, PRIMARY)).thenReturn(persisted());
startup.when(ModdedStartup::dimensionsRestored).thenReturn(true);
configs.when(ModdedModConfig::get).thenReturn(routed);
evaluate(server);
assertTrue(ModdedPrimaryWorldRouter.refuseIfUnavailable(mock(NativeProtocolPlayer.class)));
configs.when(ModdedModConfig::get).thenReturn(unconfigured);
evaluate(server);
assertFalse(ModdedPrimaryWorldRouter.refuseIfUnavailable(mock(NativeProtocolPlayer.class)));
configs.when(ModdedModConfig::get).thenReturn(routed);
evaluate(server);
manager.when(() -> ModdedDimensionManager.level(server, PRIMARY)).thenReturn(overworld);
evaluate(server);
assertFalse(ModdedPrimaryWorldRouter.refuseIfUnavailable(mock(NativeProtocolPlayer.class)));
}
}
/**
* A registry that cannot be read cannot prove the primary world was never here, so it counts as expected.
*/
@Test
public void anUnreadableRegistryRefusesPlayersRatherThanGuessing() {
NativeModdedServer server = mock(NativeModdedServer.class);
NativeProtocolPlayer online = mock(NativeProtocolPlayer.class);
onlinePlayers(server, online);
ModdedModConfig routed = config(true, PRIMARY);
try (MockedStatic<ModdedModConfig> configs = mockStatic(ModdedModConfig.class);
MockedStatic<ModdedDimensionManager> manager = mockStatic(ModdedDimensionManager.class);
MockedStatic<ModdedDimensionRegistryStore> registry = mockStatic(ModdedDimensionRegistryStore.class);
MockedStatic<ModdedStartup> startup = mockStatic(ModdedStartup.class);
MockedStatic<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) {
configs.when(ModdedModConfig::get).thenReturn(routed);
manager.when(() -> ModdedDimensionManager.level(server, PRIMARY)).thenReturn(null);
registry.when(() -> ModdedDimensionRegistryStore.get(server, PRIMARY))
.thenThrow(new IllegalStateException("registry could not be read"));
startup.when(ModdedStartup::dimensionsRestored).thenReturn(true);
evaluate(server);
verify(online).disconnect(contains(PRIMARY));
assertTrue(ModdedPrimaryWorldRouter.refuseIfUnavailable(mock(NativeProtocolPlayer.class)));
}
}
/**
* Routing waits on the primary world's first chunk, which on a cold world also loads the pack and binds the
* engine, so it gets the same bounded deadline as /iris tp instead of timing out and retrying every second.
*/
@Test
public void routingWaitsForAColdPrimaryWorldWithinTheTeleportDeadline() {
NativeModdedServer server = mock(NativeModdedServer.class);
NativeWorld overworld = mock(NativeWorld.class);
when(overworld.nativeHandle()).thenReturn(new Object());
NativeWorld primary = mock(NativeWorld.class);
when(primary.nativeHandle()).thenReturn(new Object());
when(server.overworld()).thenReturn(overworld);
NativeProtocolPlayer player = mock(NativeProtocolPlayer.class);
when(player.id()).thenReturn(UUID.randomUUID());
when(player.isInWorld(overworld)).thenReturn(true);
onlinePlayers(server, player);
ModdedModConfig routed = config(true, PRIMARY);
AtomicReference<NativeWorldTeleport.Destination> requested = new AtomicReference<>();
long before = System.nanoTime();
try (MockedStatic<ModdedModConfig> configs = mockStatic(ModdedModConfig.class);
MockedStatic<ModdedDimensionManager> manager = mockStatic(ModdedDimensionManager.class);
MockedStatic<NativeWorldTeleport> teleport = mockStatic(NativeWorldTeleport.class)) {
configs.when(ModdedModConfig::get).thenReturn(routed);
manager.when(() -> ModdedDimensionManager.level(server, PRIMARY)).thenReturn(primary);
teleport.when(() -> NativeWorldTeleport.teleport(any(), any())).thenAnswer((InvocationOnMock invocation) -> {
requested.set(invocation.getArgument(1));
return new CompletableFuture<Boolean>();
});
evaluate(server);
}
long after = System.nanoTime();
assertSame(primary, requested.get().world());
long deadline = requested.get().deadlineNanos();
assertTrue("deadline must outlast a cold first chunk", deadline - before >= TimeUnit.SECONDS.toNanos(60L));
assertTrue("deadline must be bounded", deadline - after <= TimeUnit.SECONDS.toNanos(120L));
}
private static ModdedDimensionRegistryStore.PersistentDimension persisted() {
return new ModdedDimensionRegistryStore.PersistentDimension(PRIMARY, "overworld", "overworld", 1337L);
}
private static void evaluate(NativeModdedServer server) {
for (int tick = 0; tick < TICK_INTERVAL; tick++) {
ModdedPrimaryWorldRouter.tick(server);
}
}
@SuppressWarnings("unchecked")
private static void onlinePlayers(NativeModdedServer server, NativeProtocolPlayer player) {
doAnswer(invocation -> {
invocation.getArgument(0, Consumer.class).accept(player);
return null;
}).when(server).forEachPlayer(any());
}
private static ModdedModConfig config(boolean route, String primary) {
ModdedModConfig config = mock(ModdedModConfig.class);
when(config.routePlayersToPrimaryWorld()).thenReturn(route);
when(config.primaryWorld()).thenReturn(primary);
return config;
}
}
@@ -70,8 +70,7 @@ public class ModdedSavedBiomeConsumersTest {
when(engine.getWorld()).thenReturn(mock(IrisWorld.class));
when(engineMantle.getMantle()).thenReturn(mantle);
when(mantle.isChunkLoaded(2, -1)).thenReturn(true);
when(mantle.getChunk(2, -1)).thenReturn(chunk);
when(chunk.use()).thenReturn(chunk);
when(mantle.useChunk(2, -1)).thenReturn(chunk);
BiomeEnvironment environment = environment();
when(engine.getSurfaceBiomeEnvironment(40, -8))
.thenThrow(new SavedBiomeUnavailableException("Loading", true))
@@ -0,0 +1,71 @@
package art.arcane.iris.modded;
import art.arcane.iris.pack.PackValidationRegistry;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import org.junit.After;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* A persistent dimension that cannot be re-injected is skipped, which already writes no chunks. The skip used to be
* one console line; it has to be a banner and has to reach the operators who can fix it.
*/
public class ModdedStartupReinjectFailureTest {
@After
public void resetStartup() {
ModdedStartup.reset();
PackValidationRegistry.clear();
}
@Test
public void aDimensionThatFailsToReinjectIsBanneredAndReportedToOperatorsOnJoin() {
NativeModdedServer server = mock(NativeModdedServer.class);
ModdedDimensionRegistryStore.PersistentDimension moon =
new ModdedDimensionRegistryStore.PersistentDimension("iris:moon", "overworld", "overworld", 1337L);
IllegalStateException failure = new IllegalStateException("Iris generation history is unusable for 'iris:moon'");
try (MockedStatic<ModdedDimensionRegistryStore> store = mockStatic(ModdedDimensionRegistryStore.class);
MockedStatic<ModdedDimensionManager> manager = mockStatic(ModdedDimensionManager.class);
MockedStatic<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) {
store.when(() -> ModdedDimensionRegistryStore.loadForStartup(server)).thenReturn(List.of(moon));
manager.when(() -> ModdedDimensionManager.restorePersistent(server, "iris:moon", "overworld", "overworld", 1337L))
.thenThrow(failure);
ModdedStartup.reinjectPersistentDimensions(server);
log.verify(() -> ModdedIrisLog.error(argThat((String line) -> line != null && line.contains("iris:moon")
&& line.startsWith("Iris refused"))), times(1));
log.verify(() -> ModdedIrisLog.error(argThat((String line) -> line != null && line.startsWith("Cause: ")
&& line.contains("generation history is unusable"))), times(1));
}
NativeProtocolPlayer operator = mock(NativeProtocolPlayer.class);
when(operator.isGameMaster()).thenReturn(true);
NativeProtocolPlayer player = mock(NativeProtocolPlayer.class);
ModdedStartup.warnStartupFailuresTo(operator);
ModdedStartup.warnStartupFailuresTo(player);
verify(operator).sendMessage(argThat((String message) -> message.contains("iris:moon")
&& message.contains("generation history is unusable")));
verify(player, never()).sendMessage(anyString());
ModdedStartup.reset();
NativeProtocolPlayer laterOperator = mock(NativeProtocolPlayer.class);
when(laterOperator.isGameMaster()).thenReturn(true);
ModdedStartup.warnStartupFailuresTo(laterOperator);
verify(laterOperator, never()).sendMessage(any());
}
}
@@ -14,16 +14,18 @@ import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.Bootstrap;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.biome.BiomeResolver;
import net.minecraft.world.level.biome.BiomeSource;
import net.minecraft.world.level.biome.Climate;
import net.minecraft.world.level.chunk.ChunkGeneratorStructureState;
import net.minecraft.world.level.levelgen.RandomState;
import net.minecraft.world.level.levelgen.structure.StructureSet;
import net.minecraft.world.level.levelgen.structure.placement.AbstractSpreadingStructurePlacement;
import net.minecraft.world.level.levelgen.structure.placement.RandomSpreadStructurePlacement;
import net.minecraft.world.level.levelgen.structure.placement.RandomSpreadType;
import net.minecraft.world.level.levelgen.structure.placement.StructurePlacement;
import net.minecraft.world.level.levelgen.structure.placement.StructurePlacementType;
import org.junit.BeforeClass;
import org.junit.Test;
@@ -166,10 +168,10 @@ public class ModdedStructureSetFrequencyOverridesTest {
) {
return new RandomSpreadStructurePlacement(
Vec3i.ZERO,
StructurePlacement.FrequencyReductionMethod.DEFAULT,
AbstractSpreadingStructurePlacement.FrequencyReductionMethod.DEFAULT,
1F,
salt,
Optional.of(new StructurePlacement.ExclusionZone(exclusionTarget, 1)),
Optional.of(new AbstractSpreadingStructurePlacement.ExclusionZone(exclusionTarget, 1)),
spacing,
separation,
RandomSpreadType.LINEAR);
@@ -183,6 +185,7 @@ public class ModdedStructureSetFrequencyOverridesTest {
RandomState.class,
BiomeSource.class,
long.class,
ChunkPos.class,
long.class,
List.class);
constructor.setAccessible(true);
@@ -190,6 +193,7 @@ public class ModdedStructureSetFrequencyOverridesTest {
null,
new EmptyBiomeSource(),
1L,
ChunkPos.ZERO,
1L,
sets);
}
@@ -211,8 +215,8 @@ public class ModdedStructureSetFrequencyOverridesTest {
private static final class EmptyBiomeSource extends BiomeSource {
@Override
public Holder<Biome> getNoiseBiome(int x, int y, int z, Climate.Sampler sampler) {
return null;
public BiomeResolver createResolver(Climate.Sampler sampler) {
return (x, y, z) -> null;
}
@Override
@@ -226,18 +230,14 @@ public class ModdedStructureSetFrequencyOverridesTest {
}
}
private static final class UnsupportedPlacement extends StructurePlacement {
private UnsupportedPlacement() {
super(Vec3i.ZERO, FrequencyReductionMethod.DEFAULT, 1F, 1, Optional.empty());
}
private static final class UnsupportedPlacement implements StructurePlacement {
@Override
protected boolean isPlacementChunk(ChunkGeneratorStructureState state, int x, int z) {
public boolean isStructureChunk(ChunkGeneratorStructureState state, int x, int z) {
return false;
}
@Override
public StructurePlacementType<?> type() {
public com.mojang.serialization.MapCodec<? extends StructurePlacement> codec() {
return null;
}
}
@@ -1,6 +1,7 @@
package art.arcane.iris.modded;
import org.junit.Test;
import org.objectweb.asm.ClassReader;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
@@ -62,9 +63,11 @@ public class ModdedStructureTemplateCacheTest {
config = new String(input.readAllBytes(), StandardCharsets.UTF_8);
}
assertTrue(config.contains("\"StructureTemplatePaletteConcurrencyMixin\""));
assertNotNull(Class.forName(
"art.arcane.volmlib.nativelib.minecraft26_2.modded.mixin.StructureTemplatePaletteConcurrencyMixin",
false,
ModdedStructureTemplateCacheTest.class.getClassLoader()));
String mixin = "art/arcane/volmlib/nativelib/minecraft26_2/modded/mixin/StructureTemplatePaletteConcurrencyMixin";
try (InputStream input = ModdedStructureTemplateCacheTest.class.getClassLoader()
.getResourceAsStream(mixin + ".class")) {
assertNotNull(input);
assertEquals(mixin, new ClassReader(input).getClassName());
}
}
}
@@ -0,0 +1,40 @@
package art.arcane.iris.modded;
import art.arcane.iris.generation.runtime.Engine;
import net.minecraft.SharedConstants;
import net.minecraft.server.Bootstrap;
import org.junit.BeforeClass;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
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 final class ModdedTerrainHeightQueryTest {
@BeforeClass
public static void bootstrap() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
}
@Test
public void nativeQueriesUseLiveHeightsWithoutGeneratingTerrain() {
IrisModdedChunkGenerator generator = mock(IrisModdedChunkGenerator.class, CALLS_REAL_METHODS);
Engine engine = mock(Engine.class);
assertNull(generator.resolvedColumn(engine, -1, -33));
verifyNoInteractions(engine);
when(engine.getHeight(-1, -33, true)).thenReturn(12, 18);
when(engine.getHeight(-1, -33, false)).thenReturn(24);
assertEquals(12, generator.terrainHeight(engine, -1, -33, true, true));
assertEquals(18, generator.terrainHeight(engine, -1, -33, true, true));
assertEquals(24, generator.terrainHeight(engine, -1, -33, false, true));
verify(engine, never()).getComplex();
verify(engine, never()).getMode();
}
}
@@ -1,5 +1,6 @@
package art.arcane.iris.modded;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeTileReader;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedBlockState;
import art.arcane.iris.generation.block.TileData;
@@ -18,6 +19,7 @@ import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.entity.BarrelBlockEntity;
import net.minecraft.world.level.block.entity.ChestBlockEntity;
import net.minecraft.world.level.block.entity.SignBlockEntity;
import net.minecraft.world.level.block.entity.SignTextSlot;
import net.minecraft.world.level.block.entity.SpawnerBlockEntity;
import net.minecraft.world.level.storage.TagValueInput;
import org.junit.BeforeClass;
@@ -41,7 +43,7 @@ public class ModdedTileParityTest {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
registries = RegistryAccess.fromRegistryOfRegistries(BuiltInRegistries.REGISTRY);
NativeTileReader nativeTileReader = new NativeTileReader(() -> null);
NativeTileReader nativeTileReader = NativeModdedServer.tileReader(() -> null);
TileData.bindPlatformReader(in -> ModdedTileData.wrap(nativeTileReader.read(in)));
TileData.bindPlatformFactory(ModdedTileData::fromProperties);
}
@@ -60,9 +62,9 @@ public class ModdedTileParityTest {
sign.loadWithComponents(TagValueInput.create(ProblemReporter.DISCARDING, registries, tile.nativeData().payload()));
assertEquals("Iris", sign.getFrontText().getMessage(0, false).getString());
assertEquals("parity", sign.getBackText().getMessage(3, false).getString());
assertEquals(DyeColor.BLUE, sign.getFrontText().getColor());
assertEquals("Iris", sign.getText(SignTextSlot.FRONT).getMessages(false).get(0).getString());
assertEquals("parity", sign.getText(SignTextSlot.BACK).getMessages(false).get(3).getString());
assertEquals(DyeColor.BLUE, sign.getText(SignTextSlot.FRONT).getColor());
}
@Test
@@ -11,14 +11,22 @@ import art.arcane.iris.generation.decoration.IrisSurface;
import art.arcane.volmlib.util.math.RNG;
import net.minecraft.SharedConstants;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.core.registries.Registries;
import net.minecraft.server.Bootstrap;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.tags.BlockTags;
import net.minecraft.tags.TagLoader;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import org.junit.BeforeClass;
import org.junit.Test;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.junit.Assert.assertEquals;
@@ -34,6 +42,13 @@ public class ModdedWaterEntitySpawnTest {
public static void bootstrapMinecraftRegistries() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
for (Block block : BuiltInRegistries.BLOCK) {
for (BlockState state : block.getStateDefinition().getPossibleStates()) {
state.initCache();
}
}
BuiltInRegistries.BLOCK.prepareTagReload(new TagLoader.LoadResult<>(Registries.BLOCK, Map.of(
BlockTags.BLOCKS_MOTION, List.of(Blocks.OAK_FENCE.builtInRegistryHolder())))).apply();
}
@Test
@@ -1,20 +1,31 @@
package art.arcane.iris.modded;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeRegistryAccess;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.terrain.NativeBlockProperty;
import net.minecraft.SharedConstants;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.Bootstrap;
import net.minecraft.server.MinecraftServer;
import org.junit.BeforeClass;
import org.junit.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Stream;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class NativeRegistryAccessTest {
@BeforeClass
@@ -25,7 +36,7 @@ public class NativeRegistryAccessTest {
@Test
public void statePropertiesPreserveTypesDefaultsAndSharedGroups() {
NativeRegistryAccess access = new NativeRegistryAccess(new NativeRegistryAccess.Configuration(() -> null, () -> null, message -> {}));
NativeRegistryAccess access = NativeModdedServer.registryAccess(() -> null, message -> {});
Map<String, List<NativeBlockProperty>> properties = access.blockStateProperties();
assertSame(properties.get("minecraft:stone"), properties.get("minecraft:dirt"));
@@ -41,7 +52,7 @@ public class NativeRegistryAccessTest {
@Test
public void staticRegistriesResolveWithoutAnActiveServerAndDynamicReadsStayEmpty() {
List<String> warnings = new ArrayList<>();
NativeRegistryAccess access = new NativeRegistryAccess(new NativeRegistryAccess.Configuration(() -> null, () -> null, warnings::add));
NativeRegistryAccess access = NativeModdedServer.registryAccess(() -> null, warnings::add);
assertEquals("minecraft:diamond_sword", access.item("DIAMOND SWORD").key());
assertEquals("creature", access.entity("minecraft:cow").spawnCategory());
@@ -49,6 +60,34 @@ public class NativeRegistryAccessTest {
assertTrue(access.biomeKeys().isEmpty());
assertTrue(access.structureKeys().isEmpty());
assertTrue(access.enchantmentKeys().isEmpty());
assertEquals(List.of("biome", "structure", "enchantment"), warnings);
assertTrue(access.lootTableKeys().isEmpty());
assertEquals(List.of("biome", "structure", "enchantment", "loot table"), warnings);
}
@Test
public void dynamicRegistriesFollowServerStartupReloadAndShutdown() {
AtomicReference<NativeModdedServer> activeServer = new AtomicReference<>();
List<String> warnings = new ArrayList<>();
NativeRegistryAccess access = NativeModdedServer.registryAccess(activeServer::get, warnings::add);
assertTrue(access.lootTableKeys().isEmpty());
MinecraftServer server = mock(MinecraftServer.class, RETURNS_DEEP_STUBS);
when(server.registryAccess().lookupOrThrow(Registries.BIOME).listElementIds())
.thenAnswer(invocation -> Stream.of(ResourceKey.create(Registries.BIOME, Identifier.parse("example:biome"))));
when(server.reloadableRegistries().lookup().lookupOrThrow(Registries.LOOT_TABLE).listElementIds())
.thenAnswer(invocation -> Stream.of(ResourceKey.create(Registries.LOOT_TABLE, Identifier.parse("example:initial"))));
activeServer.set(NativeModdedServer.fromHandle(server));
assertEquals(List.of("example:biome"), access.biomeKeys());
assertEquals(List.of("example:initial"), access.lootTableKeys());
HolderLookup.Provider reloadedRegistries = mock(HolderLookup.Provider.class, RETURNS_DEEP_STUBS);
when(reloadedRegistries.lookupOrThrow(Registries.LOOT_TABLE).listElementIds())
.thenAnswer(invocation -> Stream.of(ResourceKey.create(Registries.LOOT_TABLE, Identifier.parse("example:reloaded"))));
when(server.reloadableRegistries().lookup()).thenReturn(reloadedRegistries);
assertEquals(List.of("example:reloaded"), access.lootTableKeys());
activeServer.set(null);
assertTrue(access.biomeKeys().isEmpty());
assertTrue(access.lootTableKeys().isEmpty());
assertEquals(List.of("loot table", "biome", "loot table"), warnings);
}
}
@@ -0,0 +1,145 @@
package art.arcane.iris.modded.command;
import art.arcane.iris.localization.IrisLanguage;
import art.arcane.iris.modded.IrisModdedChunkGenerator;
import art.arcane.iris.modded.ModdedDimensionManager;
import art.arcane.iris.modded.localization.ModdedCommandMessages;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeCommandSource;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldGenerators;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldTeleport;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.volmlib.util.localization.MessageArgument;
import net.minecraft.SharedConstants;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.Bootstrap;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerChunkCache;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.level.Level;
import org.junit.BeforeClass;
import org.junit.Test;
import org.mockito.MockedStatic;
import org.mockito.invocation.InvocationOnMock;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
public class IrisModdedCommandsTeleportTest {
private static final String DIMENSION = "irisworldgen:1";
@BeforeClass
public static void bootstrap() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
}
@Test
public void teleportWaitsForAColdDestinationWithinABoundedDeadline() {
NativeModdedServer server = mock(NativeModdedServer.class);
NativeCommandSource source = mock(NativeCommandSource.class);
when(source.server()).thenReturn(server);
NativeWorld level = mock(NativeWorld.class);
when(level.name()).thenReturn(DIMENSION);
NativeProtocolPlayer player = mock(NativeProtocolPlayer.class);
when(player.name()).thenReturn("Tester");
AtomicReference<NativeWorldTeleport.Destination> requested = new AtomicReference<>();
long before = System.nanoTime();
try (MockedStatic<NativeWorldGenerators> generators = mockStatic(NativeWorldGenerators.class);
MockedStatic<ModdedDimensionManager> dimensions = mockStatic(ModdedDimensionManager.class);
MockedStatic<NativeWorldTeleport> teleport = mockStatic(NativeWorldTeleport.class)) {
generators.when(() -> NativeWorldGenerators.find(level, IrisModdedChunkGenerator.class))
.thenReturn(mock(IrisModdedChunkGenerator.class));
dimensions.when(() -> ModdedDimensionManager.level(server, DIMENSION)).thenReturn(level);
teleport.when(() -> NativeWorldTeleport.teleport(any(), any())).thenAnswer((InvocationOnMock invocation) -> {
requested.set(invocation.getArgument(1));
return new CompletableFuture<Boolean>();
});
assertEquals(1, IrisModdedCommands.tp(source, level, player));
}
long after = System.nanoTime();
long deadline = requested.get().deadlineNanos();
assertTrue("deadline must outlast a cold first chunk", deadline - before >= TimeUnit.SECONDS.toNanos(60L));
assertTrue("deadline must be bounded", deadline - after <= TimeUnit.SECONDS.toNanos(120L));
}
@Test(timeout = 15_000L)
public void expiredTeleportReportsTheCallersDeadline() throws Exception {
MinecraftServer server = mock(MinecraftServer.class);
when(server.isSameThread()).thenReturn(true);
ResourceKey<Level> dimension = ResourceKey.create(Registries.DIMENSION, Identifier.parse(DIMENSION));
ServerChunkCache chunks = mock(ServerChunkCache.class);
when(chunks.addTicketAndLoadWithRadius(any(), any(), anyInt())).thenReturn(new CompletableFuture<>());
ServerLevel serverLevel = mock(ServerLevel.class);
when(serverLevel.getServer()).thenReturn(server);
when(serverLevel.dimension()).thenReturn(dimension);
when(serverLevel.getChunkSource()).thenReturn(chunks);
when(server.getLevel(dimension)).thenReturn(serverLevel);
ServerPlayer serverPlayer = mock(ServerPlayer.class);
when(serverPlayer.getUUID()).thenReturn(UUID.randomUUID());
NativeWorld level = mock(NativeWorld.class);
when(level.nativeHandle()).thenReturn(serverLevel);
CompletableFuture<Boolean> teleport = NativeWorldTeleport.teleport(NativeProtocolPlayer.fromHandle(serverPlayer),
new NativeWorldTeleport.Destination(NativeModdedServer.fromHandle(server), level, 8.5D,
Double.MIN_VALUE, 8.5D, System.nanoTime() + TimeUnit.SECONDS.toNanos(2L)));
ExecutionException failure = assertThrows(ExecutionException.class, () -> teleport.get(10L, TimeUnit.SECONDS));
assertTrue(failure.getCause() instanceof TimeoutException);
assertTrue(failure.getCause().getMessage(), failure.getCause().getMessage().contains("within 2 seconds"));
}
@Test
public void failureReportsItsRootCauseInsteadOfAnUnloadedDimension() {
String message = IrisModdedCommands.teleportFailure(DIMENSION, "Tester",
new CompletionException(new IllegalStateException("Exception generating new chunk")), true, true);
assertEquals(IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORT_FAILED_REASON,
MessageArgument.untrusted("dimensionId", DIMENSION),
MessageArgument.untrusted("reason", "Exception generating new chunk")), message);
}
@Test
public void failureWithoutMessageNamesTheFailureType() {
String message = IrisModdedCommands.teleportFailure(DIMENSION, "Tester", new TimeoutException(), true, true);
assertEquals(IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORT_FAILED_REASON,
MessageArgument.untrusted("dimensionId", DIMENSION),
MessageArgument.untrusted("reason", "TimeoutException")), message);
}
@Test
public void unloadedDimensionIsReportedOnlyWhenItIsGone() {
assertEquals(IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORT_FAILED_DIMENSION_IS_NOT_LOADED,
MessageArgument.untrusted("dimensionId", DIMENSION)),
IrisModdedCommands.teleportFailure(DIMENSION, "Tester", null, false, true));
assertEquals(IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORT_CANCELLED_PLAYER_OFFLINE,
MessageArgument.untrusted("dimensionId", DIMENSION),
MessageArgument.untrusted("value", "Tester")),
IrisModdedCommands.teleportFailure(DIMENSION, "Tester", null, true, false));
assertEquals(IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORT_REFUSED,
MessageArgument.untrusted("dimensionId", DIMENSION)),
IrisModdedCommands.teleportFailure(DIMENSION, "Tester", null, true, true));
}
}
@@ -0,0 +1,56 @@
package art.arcane.iris.modded.command;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.volmlib.nativelib.view.WorldView;
import org.junit.Test;
import org.mockito.invocation.InvocationOnMock;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class ModdedVisionOverlayTest {
/**
* A Vision click can land anywhere in the world, so the destination chunk is usually cold and gets the same
* bounded deadline as /iris tp.
*/
@Test
public void teleportWaitsForAColdDestinationWithinTheTeleportDeadline() {
WorldView world = mock(WorldView.class);
doAnswer((InvocationOnMock invocation) -> {
invocation.getArgument(0, Runnable.class).run();
return null;
}).when(world).execute(any());
UUID opener = UUID.randomUUID();
AtomicReference<WorldView.Destination> requested = new AtomicReference<>();
when(world.teleport(eq(opener), any())).thenAnswer((InvocationOnMock invocation) -> {
requested.set(invocation.getArgument(1));
return Optional.empty();
});
Engine engine = mock(Engine.class);
when(engine.getMinHeight()).thenReturn(-64);
when(engine.getHeight(anyInt(), anyInt(), eq(false))).thenReturn(130);
long before = System.nanoTime();
new ModdedVisionOverlay(new ModdedVisionOverlay.Context(world, engine, opener)).teleport(-3.5D, 17.25D);
long after = System.nanoTime();
WorldView.Destination destination = requested.get();
assertEquals(-3.5D, destination.x(), 0D);
assertEquals(68D, destination.y(), 0D);
assertEquals(17.5D, destination.z(), 0D);
assertTrue("deadline must outlast a cold first chunk",
destination.deadlineNanos() - before >= TimeUnit.SECONDS.toNanos(60L));
assertTrue("deadline must be bounded", destination.deadlineNanos() - after <= TimeUnit.SECONDS.toNanos(120L));
}
}
@@ -2,15 +2,30 @@ package art.arcane.iris.modded.service;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedPlatformWorld;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.generation.mantle.EngineMantle;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.EngineMetrics;
import art.arcane.iris.modded.IrisModdedChunkGenerator;
import art.arcane.iris.modded.ModdedLootApplier;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.world.IrisWorld;
import art.arcane.iris.world.history.SavedBiomeUnavailableException;
import art.arcane.iris.world.storage.matter.TileWrapper;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldGenerators;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldMaintenance;
import art.arcane.volmlib.nativelib.terrain.NativeBlockPoint;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.volmlib.util.function.Consumer4;
import art.arcane.volmlib.util.mantle.flag.MantleFlag;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
import art.arcane.volmlib.util.mantle.runtime.MantleDataAdapter;
import art.arcane.volmlib.util.mantle.runtime.MantleHooks;
import art.arcane.volmlib.util.matter.Matter;
import art.arcane.volmlib.util.matter.MatterSlice;
import art.arcane.volmlib.util.matter.MatterUpdate;
import art.arcane.volmlib.util.matter.slices.UpdateMatter;
import net.minecraft.SharedConstants;
@@ -24,19 +39,33 @@ import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.material.FlowingFluid;
import org.junit.Test;
import org.mockito.InOrder;
import org.mockito.MockedConstruction;
import org.mockito.MockedStatic;
import org.mockito.invocation.InvocationOnMock;
import java.lang.reflect.Proxy;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.LongConsumer;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.inOrder;
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.verify;
import static org.mockito.Mockito.when;
public class ModdedChunkUpdateServiceTest {
@@ -70,6 +99,148 @@ public class ModdedChunkUpdateServiceTest {
assertTrue(chunk.deleted());
}
@Test
public void loadingSavedBiomeDefersThePassAndForgetsOnlyCompletedLoot() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
IrisWorld world = mock(IrisWorld.class);
when(world.minHeight()).thenReturn(-64);
Engine engine = mock(Engine.class);
when(engine.getWorld()).thenReturn(world);
when(engine.getMetrics()).thenReturn(new EngineMetrics(8));
ServerLevel level = mock(ServerLevel.class);
when(level.getMinY()).thenReturn(-64);
when(level.getMaxY()).thenReturn(319);
when(level.getHeight()).thenReturn(384);
when(level.getBlockState(any(BlockPos.class))).thenReturn(Blocks.CHEST.defaultBlockState());
ChestUpdateMantleChunk chunk = new ChestUpdateMantleChunk();
SavedBiomeUnavailableException loading = new SavedBiomeUnavailableException(
"Saved biome information is loading at chunk 0,0. Try again shortly.", true);
try (MockedStatic<ModdedLootApplier> loot = mockStatic(ModdedLootApplier.class)) {
loot.when(() -> ModdedLootApplier.apply(any(), any(), any(), any(), any())).thenAnswer((InvocationOnMock invocation) -> {
NativeBlockPoint position = invocation.getArgument(2);
if (position.x() == 2) {
throw loading;
}
return null;
});
SavedBiomeUnavailableException thrown = assertThrows(SavedBiomeUnavailableException.class,
() -> new ModdedChunkUpdateService().runUpdatePass(
engine, new ModdedPlatformWorld(level), 0, 0, chunk));
assertSame(loading, thrown);
}
assertFalse(chunk.deleted());
verify(chunk.slice).set(1, 5, 1, null);
verify(chunk.slice, never()).set(2, 5, 2, null);
}
@Test
public void loadingSavedBiomeLeavesTheChunkUnetchedWithoutAnErrorAndRetriesOnTheNextPass() {
ChestUpdateMantleChunk chunk = new ChestUpdateMantleChunk();
Engine engine = updateEngine(chunk);
NativeWorld level = chestLevel();
AtomicInteger loadingAttempts = new AtomicInteger();
ModdedChunkUpdateService service = new ModdedChunkUpdateService();
try (MockedStatic<ModdedLootApplier> loot = mockStatic(ModdedLootApplier.class);
MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
loot.when(() -> ModdedLootApplier.apply(any(), any(), any(), any(), any())).thenAnswer((InvocationOnMock invocation) -> {
NativeBlockPoint position = invocation.getArgument(2);
if (position.x() == 2 && loadingAttempts.getAndIncrement() == 0) {
throw new SavedBiomeUnavailableException("Saved biome information is loading at chunk 0,0.", true);
}
return null;
});
service.updateRegeneratedChunk(engine, level, 0, 0);
assertFalse(chunk.isFlagged(MantleFlag.ETCHED));
assertFalse(chunk.isFlagged(MantleFlag.UPDATE));
assertFalse(chunk.deleted());
logging.verify(() -> IrisLogging.reportError(any(Throwable.class)), never());
logging.verify(() -> IrisLogging.reportError(anyString(), any(Throwable.class)), never());
service.updateRegeneratedChunk(engine, level, 0, 0);
assertTrue(chunk.isFlagged(MantleFlag.ETCHED));
assertTrue(chunk.deleted());
}
}
@Test
public void permanentSavedBiomeUnavailabilityCompletesThePassWithAWarning() {
ChestUpdateMantleChunk chunk = new ChestUpdateMantleChunk();
Engine engine = updateEngine(chunk);
NativeWorld level = chestLevel();
String reason = "This chunk has no exact saved Iris biome assignment for generation 3.";
try (MockedStatic<ModdedLootApplier> loot = mockStatic(ModdedLootApplier.class);
MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
loot.when(() -> ModdedLootApplier.apply(any(), any(), any(), any(), any())).thenAnswer((InvocationOnMock invocation) -> {
NativeBlockPoint position = invocation.getArgument(2);
if (position.x() == 2) {
throw new SavedBiomeUnavailableException(reason, false);
}
return null;
});
new ModdedChunkUpdateService().updateRegeneratedChunk(engine, level, 0, 0);
assertTrue(chunk.isFlagged(MantleFlag.ETCHED));
assertTrue(chunk.deleted());
logging.verify(() -> IrisLogging.warnOnce(anyString(), contains(reason)));
logging.verify(() -> IrisLogging.reportError(any(Throwable.class)), never());
logging.verify(() -> IrisLogging.reportError(anyString(), any(Throwable.class)), never());
}
}
@Test
public void failedChunkDoesNotStopTheLevelPassForItsOtherChunks() {
IrisSettings previousSettings = IrisSettings.settings;
IrisSettings.settings = new IrisSettings();
FailingMantleChunk failing = new FailingMantleChunk();
ChestUpdateMantleChunk healthy = new ChestUpdateMantleChunk();
@SuppressWarnings("unchecked")
Mantle<Matter> mantle = mock(Mantle.class);
when(mantle.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
when(mantle.useChunk(0, 0)).thenAnswer((InvocationOnMock invocation) -> failing.use());
when(mantle.useChunk(4, 0)).thenAnswer((InvocationOnMock invocation) -> healthy.use());
Engine engine = engineWith(mantle);
NativeWorld level = chestLevel();
NativeModdedServer server = mock(NativeModdedServer.class);
when(server.worlds()).thenReturn(List.of(level));
IrisModdedChunkGenerator generator = mock(IrisModdedChunkGenerator.class);
when(generator.engineIfBound()).thenReturn(engine);
try (MockedStatic<NativeWorldGenerators> generators = mockStatic(NativeWorldGenerators.class);
MockedStatic<ModdedLootApplier> loot = mockStatic(ModdedLootApplier.class);
MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class);
MockedConstruction<NativeWorldMaintenance> maintenance = mockConstruction(NativeWorldMaintenance.class,
(NativeWorldMaintenance mocked, MockedConstruction.Context context) -> {
when(mocked.hasForcedChunks()).thenReturn(true);
doAnswer((InvocationOnMock invocation) -> {
LongConsumer action = invocation.getArgument(0);
action.accept(0L);
action.accept(4L);
return null;
}).when(mocked).forEachForcedChunk(any(LongConsumer.class));
})) {
generators.when(() -> NativeWorldGenerators.find(level, IrisModdedChunkGenerator.class)).thenReturn(generator);
new ModdedChunkUpdateService().onServerTick(server);
assertFalse(failing.isFlagged(MantleFlag.ETCHED));
assertTrue(healthy.isFlagged(MantleFlag.ETCHED));
assertTrue(healthy.deleted());
logging.verify(() -> IrisLogging.errorOnce(anyString(), anyString()));
} finally {
IrisSettings.settings = previousSettings;
}
}
@Test
public void scansWhenPlayersArePresent() {
assertTrue(ModdedChunkUpdateService.hasUpdateTargets(true, false));
@@ -166,6 +337,94 @@ public class ModdedChunkUpdateServiceTest {
}
}
private static final class ChestUpdateMantleChunk extends MantleChunk<Matter> {
@SuppressWarnings("unchecked")
private final MatterSlice<MatterUpdate> slice = mock(MatterSlice.class);
private final Matter section = mock(Matter.class);
private boolean deleted;
private ChestUpdateMantleChunk() {
super(1, 0, 0, emptyAdapter(), MantleHooks.NONE);
when(section.hasSlice(MatterUpdate.class)).thenReturn(true);
when(section.getSlice(MatterUpdate.class)).thenReturn(slice);
}
@Override
public Matter get(int index) {
return section;
}
@Override
public <T> void iterate(
Class<T> type,
Consumer4<Integer, Integer, Integer, T> iterator
) {
if (type == MatterUpdate.class) {
iterator.accept(1, 5, 1, type.cast(UpdateMatter.ON));
iterator.accept(2, 5, 2, type.cast(UpdateMatter.ON));
}
}
@Override
public void deleteSlices(Class<?> type) {
if (type == MatterUpdate.class) {
deleted = true;
}
}
private boolean deleted() {
return deleted;
}
}
private static Engine updateEngine(ChestUpdateMantleChunk chunk) {
@SuppressWarnings("unchecked")
Mantle<Matter> mantle = mock(Mantle.class);
when(mantle.isChunkLoaded(0, 0)).thenReturn(true);
when(mantle.hasFlag(0, 0, MantleFlag.ETCHED)).thenAnswer((InvocationOnMock invocation) -> chunk.isFlagged(MantleFlag.ETCHED));
when(mantle.useChunk(0, 0)).thenAnswer((InvocationOnMock invocation) -> chunk.use());
return engineWith(mantle);
}
private static Engine engineWith(Mantle<Matter> mantle) {
IrisWorld world = mock(IrisWorld.class);
when(world.minHeight()).thenReturn(-64);
EngineMantle engineMantle = mock(EngineMantle.class);
when(engineMantle.getMantle()).thenReturn(mantle);
Engine engine = mock(Engine.class);
when(engine.getWorld()).thenReturn(world);
when(engine.getMetrics()).thenReturn(new EngineMetrics(8));
when(engine.getMantle()).thenReturn(engineMantle);
return engine;
}
private static NativeWorld chestLevel() {
NativeBlockState chest = mock(NativeBlockState.class);
when(chest.isStorage()).thenReturn(true);
when(chest.isStorageChest()).thenReturn(true);
NativeWorld level = mock(NativeWorld.class);
when(level.name()).thenReturn("example:updates");
when(level.minHeight()).thenReturn(-64);
when(level.maxHeight()).thenReturn(320);
when(level.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
when(level.getBlock(anyInt(), anyInt(), anyInt())).thenReturn(chest);
return level;
}
private static final class FailingMantleChunk extends MantleChunk<Matter> {
private FailingMantleChunk() {
super(1, 0, 0, emptyAdapter(), MantleHooks.NONE);
}
@Override
public <T> void iterate(
Class<T> type,
Consumer4<Integer, Integer, Integer, T> iterator
) {
throw new IllegalStateException("corrupt " + type.getSimpleName() + " slice");
}
}
@SuppressWarnings("unchecked")
private static MantleDataAdapter<Matter> emptyAdapter() {
return (MantleDataAdapter<Matter>) Proxy.newProxyInstance(
@@ -6,7 +6,11 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedPlatformWorld;
import net.minecraft.SharedConstants;
import net.minecraft.server.Bootstrap;
import net.minecraft.world.level.levelgen.feature.Feature;
import net.minecraft.world.level.levelgen.feature.FallenTreeFeature;
import net.minecraft.world.level.levelgen.feature.HugeBrownMushroomFeature;
import net.minecraft.world.level.levelgen.feature.HugeRedMushroomFeature;
import net.minecraft.world.level.levelgen.feature.OreFeature;
import net.minecraft.world.level.levelgen.feature.TreeFeature;
import net.minecraft.world.level.levelgen.structure.BoundingBox;
import org.junit.BeforeClass;
import org.junit.Test;
@@ -32,11 +36,11 @@ public class NativeStructureOperationsTest {
@Test
public void objectFeaturesMatchPaperGroups() {
assertEquals("trees", NativeStructureOperations.classifyFeature(Feature.TREE));
assertEquals("fallen_trees", NativeStructureOperations.classifyFeature(Feature.FALLEN_TREE));
assertEquals("mushrooms", NativeStructureOperations.classifyFeature(Feature.HUGE_BROWN_MUSHROOM));
assertEquals("mushrooms", NativeStructureOperations.classifyFeature(Feature.HUGE_RED_MUSHROOM));
assertNull(NativeStructureOperations.classifyFeature(Feature.ORE));
assertEquals("trees", NativeStructureOperations.classifyFeature(mock(TreeFeature.class)));
assertEquals("fallen_trees", NativeStructureOperations.classifyFeature(mock(FallenTreeFeature.class)));
assertEquals("mushrooms", NativeStructureOperations.classifyFeature(mock(HugeBrownMushroomFeature.class)));
assertEquals("mushrooms", NativeStructureOperations.classifyFeature(mock(HugeRedMushroomFeature.class)));
assertNull(NativeStructureOperations.classifyFeature(mock(OreFeature.class)));
}
@Test
+11 -12
View File
@@ -32,17 +32,13 @@ buildscript {
maven { url = uri('https://jitpack.io') }
}
dependencies {
Properties libraryProperties = new Properties()
file('../../gradle.properties').withInputStream { InputStream stream -> libraryProperties.load(stream) }
String libraryCoordinate = providers.gradleProperty('volmLibCoordinate')
.getOrElse(libraryProperties.getProperty('volmLibCoordinate'))
classpath("com.github.VolmitSoftware.VolmLib:packaging:${libraryCoordinate.tokenize(':').last()}")
classpath('com.github.VolmitSoftware.VolmLib:packaging:master-SNAPSHOT')
}
}
plugins {
id 'java'
id 'net.neoforged.moddev' version '2.0.141'
id 'net.neoforged.moddev' version '2.0.147'
alias(libs.plugins.shadow)
}
@@ -56,13 +52,13 @@ interface IrisFileOperations {
IrisFileOperations irisFileOperations = objects.newInstance(IrisFileOperations)
apply from: file('../../gradle/modded-generation-build-revision.gradle')
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 minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.2'))
String neoForgeVersion = providers.gradleProperty('neoForgeVersion').getOrElse(rootProperties.getProperty('neoForgeVersion', '26.2.0.59'))
String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate').getOrElse(rootProperties.getProperty('volmLibCoordinate', 'com.github.VolmitSoftware:VolmLib:master-SNAPSHOT'))
String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3'))
String neoForgeVersion = providers.gradleProperty('neoForgeVersion').getOrElse(rootProperties.getProperty('neoForgeVersion', '26.3.0.33-beta'))
Closure<String> irisArtifactName = { String platform, String targetVersion ->
return "Iris v${project.version} [${platform}] ${targetVersion}.jar"
}
@@ -84,7 +80,6 @@ sourceSets {
main {
java {
srcDir '../modded-common/src/main/java'
srcDir '../minecraft-common/src/main/java'
srcDir '../client-common/src/main/java'
}
resources {
@@ -153,10 +148,10 @@ dependencies {
compileOnly(libs.spigot) {
transitive = false
}
add('bundle', volmLibCoordinate) {
add('bundle', 'com.github.VolmitSoftware.VolmLib:volmlib-shared:master-SNAPSHOT') {
transitive = false
}
add('devBundle', volmLibCoordinate) {
add('devBundle', 'com.github.VolmitSoftware.VolmLib:volmlib-shared:master-SNAPSHOT') {
transitive = false
}
add('bundle', libs.zip) {
@@ -173,6 +168,7 @@ dependencies {
libs.asm,
libs.lru,
libs.caffeine,
'com.fulcrumgenomics:jlibdeflate:0.2.0',
libs.dom4j,
libs.jaxen
]
@@ -274,6 +270,9 @@ tasks.named('shadowJar', ShadowJar).configure {
spec.delete(staleLoaderJars, legacyLoaderJar)
}
}
from(tasks.named('generateVolmLibStamp')) {
into('META-INF/volmit')
}
archiveFileName.set(irisArtifactName('NeoForge', "${minecraftVersion}+${neoForgeVersion}"))
configurations = [project.configurations.named('bundle').get()]
from(sourceSets.main.output)
@@ -25,7 +25,7 @@ logoFile = "assets/irisworldgen/icon.png"
[[dependencies.irisworldgen]]
modId = "neoforge"
type = "required"
versionRange = "[26.2,26.3)"
versionRange = "[26.3,26.4)"
ordering = "NONE"
side = "BOTH"
@@ -0,0 +1,58 @@
package art.arcane.volmlib.nativelib.minecraft26_2.neoforge;
import art.arcane.volmlib.nativelib.modded.NativeLoaderOptions;
import net.minecraft.SharedConstants;
import net.minecraft.server.Bootstrap;
import net.minecraft.world.entity.EntitySpawnReason;
import net.minecraft.world.entity.Mob;
import net.minecraft.world.level.ServerLevelAccessor;
import net.neoforged.neoforge.common.NeoForge;
import net.neoforged.neoforge.event.entity.living.MobSpawnEvent;
import org.junit.BeforeClass;
import org.junit.Test;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BooleanSupplier;
import java.util.function.Consumer;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class NativeNeoForgeSpawnPositionTest {
@BeforeClass
public static void bootstrap() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
}
@Test
public void chunkGenerationSpawnsFireThePositionCheckEventAndHonorItsDenial() {
NativeNeoForgeLoader loader = new NativeNeoForgeLoader(
new NativeLoaderOptions("irisworldgen", "treefeller", BooleanSupplier::getAsBoolean));
ServerLevelAccessor level = mock(ServerLevelAccessor.class);
Mob mob = mock(Mob.class);
when(mob.checkSpawnRules(level, EntitySpawnReason.CHUNK_GENERATION)).thenReturn(true);
when(mob.checkSpawnObstruction(level)).thenReturn(true);
AtomicBoolean deny = new AtomicBoolean(true);
AtomicReference<EntitySpawnReason> observed = new AtomicReference<>();
Consumer<MobSpawnEvent.PositionCheck> listener = (MobSpawnEvent.PositionCheck event) -> {
if (event.getEntity() == mob && deny.get()) {
observed.set(event.getSpawnType());
event.setResult(MobSpawnEvent.PositionCheck.Result.FAIL);
}
};
NeoForge.EVENT_BUS.addListener(MobSpawnEvent.PositionCheck.class, listener);
try {
assertFalse(loader.checkSpawnPosition(mob, level, EntitySpawnReason.CHUNK_GENERATION));
assertEquals(EntitySpawnReason.CHUNK_GENERATION, observed.get());
deny.set(false);
assertTrue(loader.checkSpawnPosition(mob, level, EntitySpawnReason.CHUNK_GENERATION));
} finally {
NeoForge.EVENT_BUS.unregister(listener);
}
}
}
+69 -118
View File
@@ -43,8 +43,7 @@ buildscript {
maven { url = uri('https://jitpack.io') }
}
dependencies {
String volmLibRevision = providers.gradleProperty('volmLibCoordinate').get().tokenize(':').last()
classpath("com.github.VolmitSoftware.VolmLib:packaging:${volmLibRevision}")
classpath('com.github.VolmitSoftware.VolmLib:packaging:master-SNAPSHOT')
}
}
@@ -55,6 +54,7 @@ plugins {
apply plugin: 'art.arcane.volmit-packaging'
apply from: file('gradle/runtime-relocations.gradle')
apply from: file('gradle/volmlib-stamp.gradle')
interface IrisFileOperations {
@Inject
@@ -70,21 +70,15 @@ project(':core') {
group = 'art.arcane'
version = rootProject.version
}
String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse('26.2')
// The Bukkit jar retains 26.1.2 and 26.2 alongside 26.3; loader jars stay 26.2-only.
String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse('26.3')
// The Bukkit jar retains 26.1.2 and 26.2 alongside 26.3; loader jars are 26.3-only.
String bukkitMinecraftRange = providers.gradleProperty('bukkitMinecraftRange').getOrElse('26.1.2-26.3')
String fabricLoaderVersion = providers.gradleProperty('fabricLoaderVersion').getOrElse('0.19.3')
String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse('26.2-65.1.1')
String neoForgeVersion = providers.gradleProperty('neoForgeVersion').getOrElse('26.2.0.59')
String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate')
.orElse('com.github.VolmitSoftware:VolmLib:master-SNAPSHOT')
.get()
String useLocalVolmLib = providers.gradleProperty('useLocalVolmLib').getOrElse('true')
String localVolmLibDirectory = providers.gradleProperty('localVolmLibDirectory').getOrNull()
boolean useMavenLocal = providers.gradleProperty('useMavenLocal')
.orElse(useLocalVolmLib.equalsIgnoreCase('true') ? 'false' : 'true')
.map { String value -> value.equalsIgnoreCase('true') }
.get()
String fabricLoaderVersion = providers.gradleProperty('fabricLoaderVersion').getOrElse('0.19.5')
String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse('26.3-66.0.8')
String neoForgeVersion = providers.gradleProperty('neoForgeVersion').getOrElse('26.3.0.33-beta')
String volmLibShared = 'com.github.VolmitSoftware.VolmLib:volmlib-shared:master-SNAPSHOT'
File localVolmLibCheckout = (File) gradle.extensions.extraProperties.get('volmLibDirectory')
Map<String, String> volmLibStamp = (Map<String, String>) gradle.extensions.extraProperties.get('volmLibStamp')
Closure<String> loaderDisplayVersion = { String loaderVersion ->
String coordinatePrefix = "${minecraftVersion}-"
if (loaderVersion.startsWith(coordinatePrefix)) {
@@ -153,11 +147,11 @@ nmsBindings.each { String key ->
}
dependencies {
implementation("com.github.VolmitSoftware.VolmLib:native-${key}:${volmLibCoordinate.tokenize(':').last()}") {
implementation("com.github.VolmitSoftware.VolmLib:native-${key}:master-SNAPSHOT") {
transitive = false
}
compileOnly(project(':core'))
compileOnly(volmLibCoordinate) {
compileOnly(volmLibShared) {
transitive = false
}
compileOnly(rootProject.libs.annotations)
@@ -169,10 +163,10 @@ nmsBindings.each { String key ->
}
testImplementation(project(':core'))
testImplementation(testFixtures(project(':core')))
testImplementation("com.github.VolmitSoftware.VolmLib:native-common:${volmLibCoordinate.tokenize(':').last()}") {
testImplementation('com.github.VolmitSoftware.VolmLib:native-common:master-SNAPSHOT') {
transitive = false
}
testCompileOnly(volmLibCoordinate) {
testCompileOnly(volmLibShared) {
transitive = false
}
testRuntimeOnly(project(':core'))
@@ -183,49 +177,32 @@ nmsBindings.each { String key ->
def included = configurations.create('included')
def nativeRuntime = configurations.create('nativeRuntime')
def publishedNativeRuntime = configurations.create('publishedNativeRuntime') {
canBeConsumed = false
transitive = false
resolutionStrategy.useGlobalDependencySubstitutionRules = false
}
repositories.configureEach { repository ->
if (repository.name != 'publishedNativeProviders') {
repository.content { notForConfigurations('publishedNativeRuntime') }
}
}
repositories.maven {
name = 'publishedNativeProviders'
url = uri('https://jitpack.io/')
content {
onlyForConfigurations('publishedNativeRuntime')
includeGroup('com.github.VolmitSoftware.VolmLib')
}
}
def nativeRuntimeOutput = layout.buildDirectory.dir('generated/native-runtime')
def nativeRuntimeRules = layout.buildDirectory.file('generated/native-runtime-keep.pro')
List<String> nativeRuntimeModules = ['native-common'] + nmsBindings.collect { String adapter -> 'native-' + adapter }
dependencies {
nativeRuntimeModules.each { String module ->
add('nativeRuntime', "com.github.VolmitSoftware.VolmLib:${module}:${volmLibCoordinate.tokenize(':').last()}") {
add('nativeRuntime', "com.github.VolmitSoftware.VolmLib:${module}:master-SNAPSHOT") {
transitive = false
}
add('publishedNativeRuntime', "com.github.VolmitSoftware.VolmLib:${module}:${volmLibCoordinate.tokenize(':').last()}")
}
}
// The natives PackedJar embeds are the single source for the runtime manifest, the reachability roots and
// the ProGuard keep rules, so the shrinker can never prune against a different provider build.
Closure<Map<String, File>> embeddedNatives = { ->
Map<ComponentIdentifier, File> resolved = new LinkedHashMap<>()
nativeRuntime.incoming.artifacts.artifacts.each { artifact ->
resolved.put(artifact.id.componentIdentifier, artifact.file)
}
return PackedJar.resolveNativeArtifacts(resolved)
}
def generateNativeRuntime = tasks.register('generateNativeRuntime') {
inputs.files(publishedNativeRuntime)
inputs.property('version', volmLibCoordinate.tokenize(':').last())
inputs.property('adapters', nmsBindings)
inputs.files(nativeRuntime)
inputs.property('modules', nativeRuntimeModules)
outputs.dir(nativeRuntimeOutput)
outputs.file(nativeRuntimeRules)
doLast {
Map<ComponentIdentifier, File> resolved = new LinkedHashMap<>()
publishedNativeRuntime.incoming.artifacts.artifacts.each { artifact ->
resolved.put(artifact.id.componentIdentifier, artifact.file)
}
Map<String, File> artifacts = NativeRuntimeArtifacts.publishedArtifacts(resolved,
nativeRuntimeModules, volmLibCoordinate.tokenize(':').last())
NativeRuntimeArtifacts.generate(artifacts, volmLibCoordinate.tokenize(':').last(),
NativeRuntimeArtifacts.generate(embeddedNatives(), nativeRuntimeModules,
nativeRuntimeOutput.get().asFile, nativeRuntimeRules.get().asFile)
}
}
@@ -240,6 +217,9 @@ tasks.named('jar', Jar).configure {
inputs.files(included)
dependsOn(generateNativeRuntime)
from(nativeRuntimeOutput)
from(tasks.named('generateVolmLibStamp')) {
into('META-INF/volmit')
}
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
includeEmptyDirs = false
from(jarJar)
@@ -312,35 +292,9 @@ tasks.register('buildBukkit', Copy) {
// task therefore has to carry the full inventory of what those builds read: every shared source tree,
// every build script and version file they load, and the local VolmLib checkout when the composite
// resolves one. Anything missing from that list publishes a stale jar.
Closure<File> resolveLocalVolmLibDirectory = { ->
String configured = localVolmLibDirectory != null && !localVolmLibDirectory.isBlank()
? localVolmLibDirectory
: System.getenv('VOLMLIB_DIR')
if (configured != null && !configured.isBlank()) {
File configuredDirectory = new File(configured)
if (!configuredDirectory.isAbsolute()) {
configuredDirectory = new File(rootDir, configured)
}
return configuredDirectory.isDirectory() ? configuredDirectory : null
}
File current = rootDir
while (current != null) {
File candidate = new File(current, 'VolmLib')
if (new File(candidate, 'settings.gradle').isFile() || new File(candidate, 'settings.gradle.kts').isFile()) {
return candidate
}
current = current.parentFile
}
return null
}
List<Object> moddedSharedInputs = []
[
'adapters/modded-common/src',
'adapters/minecraft-common/src',
'adapters/client-common/src',
'core/src/main',
'core/build.gradle',
@@ -354,11 +308,11 @@ List<Object> moddedSharedInputs = []
'lombok.config',
'gradle/libs.versions.toml',
'gradle/volmlib-resolution.settings.gradle',
'gradle/volmlib-stamp.gradle',
'gradle/generation-build-revision.gradle',
'gradle/modded-generation-build-revision.gradle',
'gradle/native-minecraft-sources.gradle'
].each { String path -> moddedSharedInputs.add(layout.projectDirectory.file(path).asFile) }
File localVolmLibCheckout = useLocalVolmLib.equalsIgnoreCase('true') ? resolveLocalVolmLibDirectory() : null
if (localVolmLibCheckout != null) {
moddedSharedInputs.add(fileTree(localVolmLibCheckout) {
include('shared/src/main/**', 'native-api/src/main/**', 'native-terrain-api/src/main/**',
@@ -369,6 +323,16 @@ if (localVolmLibCheckout != null) {
})
}
// The published master-SNAPSHOT is resolved inside the nested build, so no outer input changes when it
// moves. Without a local checkout the Exec tasks always run and the nested build decides what is stale.
Closure<Void> declareVolmLibInputs = { Exec task ->
task.inputs.property('volmLibStamp', volmLibStamp)
if (localVolmLibCheckout == null) {
task.outputs.upToDateWhen { false }
}
return null
}
Closure<Void> declareModdedJarIo = { Exec task, String adapter, String artifactName, Map<String, String> versions ->
task.inputs.files(moddedSharedInputs).withPropertyName('moddedSharedSources')
.withPathSensitivity(PathSensitivity.RELATIVE)
@@ -378,8 +342,7 @@ Closure<Void> declareModdedJarIo = { Exec task, String adapter, String artifactN
layout.projectDirectory.file("adapters/${adapter}/settings.gradle").asFile
).withPropertyName('adapterSources')
versions.each { String key, String value -> task.inputs.property(key, value) }
task.inputs.property('useLocalVolmLib', useLocalVolmLib)
task.inputs.property('volmLibCoordinate', volmLibCoordinate)
declareVolmLibInputs(task)
task.outputs.file(layout.projectDirectory.file("adapters/${adapter}/build/libs/${artifactName}"))
return null
}
@@ -395,8 +358,7 @@ tasks.register('fabricJar', Exec) {
"-PirisVersion=${project.version}",
"-PminecraftVersion=${minecraftVersion}",
"-PfabricLoaderVersion=${fabricLoaderVersion}",
"-PuseLocalVolmLib=${useLocalVolmLib}",
"-PvolmLibCoordinate=${volmLibCoordinate}"
"-PuseLocalVolmLib=${localVolmLibCheckout != null}"
]
if (localVolmLibCheckout != null) {
environment('ORG_GRADLE_PROJECT_localVolmLibDirectory', localVolmLibCheckout.absolutePath)
@@ -433,8 +395,7 @@ tasks.register('forgeJar', Exec) {
"-PirisVersion=${project.version}",
"-PminecraftVersion=${minecraftVersion}",
"-PforgeVersion=${forgeVersion}",
"-PuseLocalVolmLib=${useLocalVolmLib}",
"-PvolmLibCoordinate=${volmLibCoordinate}"
"-PuseLocalVolmLib=${localVolmLibCheckout != null}"
]
if (localVolmLibCheckout != null) {
environment('ORG_GRADLE_PROJECT_localVolmLibDirectory', localVolmLibCheckout.absolutePath)
@@ -471,8 +432,7 @@ tasks.register('neoforgeJar', Exec) {
"-PirisVersion=${project.version}",
"-PminecraftVersion=${minecraftVersion}",
"-PneoForgeVersion=${neoForgeVersion}",
"-PuseLocalVolmLib=${useLocalVolmLib}",
"-PvolmLibCoordinate=${volmLibCoordinate}"
"-PuseLocalVolmLib=${localVolmLibCheckout != null}"
]
if (localVolmLibCheckout != null) {
environment('ORG_GRADLE_PROJECT_localVolmLibDirectory', localVolmLibCheckout.absolutePath)
@@ -529,8 +489,7 @@ tasks.register('moddedTest', Exec) {
"-PirisVersion=${project.version}",
"-PminecraftVersion=${minecraftVersion}",
"-PfabricLoaderVersion=${fabricLoaderVersion}",
"-PuseLocalVolmLib=${useLocalVolmLib}",
"-PvolmLibCoordinate=${volmLibCoordinate}"
"-PuseLocalVolmLib=${localVolmLibCheckout != null}"
]
if (localVolmLibCheckout != null) {
environment('ORG_GRADLE_PROJECT_localVolmLibDirectory', localVolmLibCheckout.absolutePath)
@@ -546,8 +505,7 @@ tasks.register('moddedTest', Exec) {
inputs.property('irisVersion', project.version.toString())
inputs.property('minecraftVersion', minecraftVersion)
inputs.property('fabricLoaderVersion', fabricLoaderVersion)
inputs.property('useLocalVolmLib', useLocalVolmLib)
inputs.property('volmLibCoordinate', volmLibCoordinate)
declareVolmLibInputs(it)
outputs.dir(layout.projectDirectory.dir('adapters/fabric/build/test-results/test'))
mustRunAfter(tasks.named('jar'))
mustRunAfter(project(':core').tasks.named('check'))
@@ -576,8 +534,17 @@ tasks.named('check').configure {
}
List<String> commonModdedArtifactEntries = [
'pack.mcmeta',
'com/fulcrumgenomics/jlibdeflate/LibdeflateDecompressor.class',
'com/fulcrumgenomics/jlibdeflate/LibdeflateException.class',
'native/linux-aarch64/libjlibdeflate.so',
'native/linux-x86_64/libjlibdeflate.so',
'native/osx-aarch64/libjlibdeflate.dylib',
'native/osx-x86_64/libjlibdeflate.dylib',
'native/windows-x86_64/jlibdeflate.dll',
'META-INF/iris/generation-kernels/catalog.tsv',
'META-INF/iris/generation-kernels/abi-1.revision',
'META-INF/volmit/volmlib.properties',
'META-INF/services/art.arcane.iris.localization.IrisMessageContributor',
'art/arcane/iris/generation/runtime/IrisEngine.class',
'art/arcane/iris/pack/datapack/DataVersion.class',
@@ -693,6 +660,7 @@ List<String> requiredBukkitArtifactEntries = [
'slimjar.dat',
'slimjar-resolutions.dat',
'META-INF/iris/native-runtime.properties',
'META-INF/volmit/volmlib.properties',
'iris-language-source.properties',
'agent.jar',
'art/arcane/iris/Iris.class',
@@ -718,6 +686,7 @@ tasks.register('verifyBukkitArtifact') {
dependsOn('jar')
File artifact = bukkitArtifact
inputs.file(artifact)
inputs.files(nativeRuntime)
inputs.files(commandHandlerSources)
inputs.files(mantleComponentSources)
doLast {
@@ -726,7 +695,7 @@ tasks.register('verifyBukkitArtifact') {
requiredBukkitArtifactEntries,
16,
runtimeDownloadedPrefixes)
NativeRuntimeArtifacts.verify(artifact, nmsBindings, volmLibCoordinate.tokenize(':').last())
NativeRuntimeArtifacts.verify(artifact, nativeRuntimeModules, embeddedNatives())
logger.lifecycle("Verified ${artifact.name} packaging and class reference graph")
}
}
@@ -812,11 +781,6 @@ tasks.register('irisDev', Copy) {
dependsOn(':core:javadocJar')
}
configurations.configureEach {
resolutionStrategy.cacheChangingModulesFor(0, 'seconds')
resolutionStrategy.cacheDynamicVersionsFor(0, 'seconds')
}
allprojects {
apply plugin: 'java'
@@ -827,9 +791,6 @@ allprojects {
}
repositories {
if (useMavenLocal) {
mavenLocal()
}
mavenCentral()
maven { url = uri('https://repo.papermc.io/repository/maven-public/') }
maven { url = uri('https://repo.codemc.org/repository/maven-public/') }
@@ -983,22 +944,15 @@ project(':adapters:bukkit:nms:v26_3_R1') {
canBeResolved = true
}
dependencies.add(headlessNativeSources.name,
"com.github.VolmitSoftware.VolmLib:native-minecraft26_2-sources:${volmLibCoordinate.tokenize(':').last()}")
'com.github.VolmitSoftware.VolmLib:native-minecraft26_2-sources:master-SNAPSHOT')
List<String> headlessNativeTypes = ['ModdedBlockState', 'ModdedBiome', 'NativeBlockProperties', 'NativeBlockResolver',
'NativeBlockFaces', 'NativeStateRotator', 'NativeStateMerger',
'NativeRegistryAccess', 'ModdedItem', 'ModdedEntityType', 'NativeTileReader', 'NativeTileData']
TaskProvider<Sync> unpackHeadlessNativeSources = tasks.register('unpackHeadlessNativeSources', Sync) {
dependsOn(headlessNativeSources)
inputs.property('nativeTypes', headlessNativeTypes)
inputs.property('nativeVersion', '26.3')
from(provider { headlessNativeSources.collect { File artifact -> zipTree(artifact) } }) {
headlessNativeTypes.each { String type -> include("art/arcane/volmlib/nativelib/minecraft26_2/modded/${type}.java") }
include('versions/26.3/**')
eachFile { FileCopyDetails source ->
if (source.path.startsWith('versions/26.3/')) {
source.path = source.path.substring('versions/26.3/'.length())
}
}
}
from(rootProject.file('adapters/modded-common/src/main/java')) {
include('art/arcane/iris/modded/ModdedDecoratorHooks.java',
@@ -1016,10 +970,10 @@ project(':adapters:bukkit:nms:v26_3_R1') {
dependencies {
offlineImplementation(project(':probe'))
offlineImplementation(project(':core'))
offlineImplementation("com.github.VolmitSoftware.VolmLib:native-common:${volmLibCoordinate.tokenize(':').last()}") {
offlineImplementation('com.github.VolmitSoftware.VolmLib:native-common:master-SNAPSHOT') {
transitive = false
}
offlineImplementation(volmLibCoordinate) {
offlineImplementation(volmLibShared) {
transitive = false
}
}
@@ -1132,14 +1086,11 @@ commands:
}
tasks.withType(PackedJar).configureEach {
nativeManifestPath.set('META-INF/iris/native-runtime.properties')
nativeResourceDirectory.set('META-INF/iris/native/')
dependsOn(tasks.named('verifyBukkitArtifact'), configurations.nativeRuntime)
nativeArtifacts.set(providers.provider {
Map<ComponentIdentifier, File> resolved = new LinkedHashMap<>()
configurations.nativeRuntime.incoming.artifacts.artifacts.each { artifact ->
resolved.put(artifact.id.componentIdentifier, artifact.file)
}
PackedJar.resolveNativeArtifacts(resolved)
})
nativeManifestPath.set(NativeRuntimeArtifacts.MANIFEST)
nativeResourceDirectory.set(NativeRuntimeArtifacts.RESOURCES)
dependsOn(tasks.named('verifyBukkitArtifact'), nativeRuntime)
nativeArtifacts.set(providers.provider { embeddedNatives() })
doLast { PackedJar task ->
NativeRuntimeArtifacts.verifyPacked(task.outputJar.get().asFile, nativeRuntimeModules, embeddedNatives())
}
}
+1
View File
@@ -32,6 +32,7 @@ dependencies {
implementation(gradleApi())
implementation('org.ow2.asm:asm:9.10.1')
implementation('org.apache.commons:commons-compress:1.28.0')
implementation('org.tukaani:xz:1.10')
implementation('io.papermc.paperweight:paperweight-userdev:2.0.0-beta.21')
testImplementation('junit:junit:4.13.2')
}
@@ -85,6 +85,11 @@ public final class ModdedArtifactVerifier {
throw new GradleException(artifact.getName() + " is missing " + requiredEntry);
}
}
if (jar.getJarEntry("assets/irisworldgen/lang/en_us.json") != null
&& jar.getJarEntry("pack.mcmeta") == null) {
throw new GradleException(artifact.getName()
+ " ships Iris client language resources without pack.mcmeta");
}
Set<String> bundledRuntimeEntries = new LinkedHashSet<>();
Set<String> privateReferenceClasses = new LinkedHashSet<>();
@@ -1,7 +1,7 @@
import org.gradle.api.GradleException;
import org.gradle.api.artifacts.component.ComponentIdentifier;
import org.gradle.api.artifacts.component.ModuleComponentIdentifier;
import org.tukaani.xz.XZInputStream;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
@@ -11,6 +11,7 @@ import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Enumeration;
import java.util.HexFormat;
import java.util.List;
@@ -21,46 +22,32 @@ import java.util.TreeMap;
import java.util.TreeSet;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
public final class NativeRuntimeArtifacts {
public static final String MANIFEST = "META-INF/iris/native-runtime.properties";
public static final String RESOURCES = "META-INF/iris/native/";
private static final String PAYLOAD = "META-INF/volmit/runtime.jar.xz";
private static final String GROUP = "com.github.VolmitSoftware.VolmLib";
private static final String NATIVE = "art/arcane/volmlib/nativelib/";
private NativeRuntimeArtifacts() {
}
public static Map<String, File> publishedArtifacts(Map<ComponentIdentifier, File> resolved,
List<String> modules, String version) {
Map<String, File> artifacts = new TreeMap<>();
for (Map.Entry<ComponentIdentifier, File> entry : resolved.entrySet()) {
if (!(entry.getKey() instanceof ModuleComponentIdentifier component)
|| !GROUP.equals(component.getGroup()) || !version.equals(component.getVersion())
|| !modules.contains(component.getModule())) {
throw new GradleException("Native runtime providers must resolve to published coordinates at "
+ version + ": " + entry.getKey().getDisplayName());
}
if (artifacts.put(component.getModule(), entry.getValue()) != null) {
throw new GradleException("Duplicate native runtime provider: " + component.getModule());
}
}
if (!artifacts.keySet().equals(new TreeSet<>(modules))) {
throw new GradleException("Published native runtime providers differ: " + artifacts.keySet()
+ "; expected " + modules);
}
return artifacts;
}
public static void generate(Map<String, File> artifacts, String version, File output, File rules) throws IOException {
public static void generate(Map<String, File> natives, List<String> modules, File output, File rules) throws IOException {
requireModules(natives, modules);
Map<String, File> providers = new TreeMap<>(natives);
List<String> manifest = new ArrayList<>();
manifest.add("modules=" + String.join(",", artifacts.keySet()));
manifest.add("repository=https://jitpack.io/");
manifest.add("modules=" + String.join(",", providers.keySet()));
manifest.add("storage=embedded");
Set<String> retained = new TreeSet<>();
for (Map.Entry<String, File> artifact : artifacts.entrySet()) {
String module = artifact.getKey();
manifest.add(module + ".coordinate=" + GROUP + ":" + module + ":" + version);
manifest.add(module + ".sha256=" + digest(artifact.getValue()));
try (JarFile jar = new JarFile(artifact.getValue())) {
for (Map.Entry<String, File> provider : providers.entrySet()) {
String module = provider.getKey();
String digest = digest(Files.readAllBytes(provider.getValue().toPath()));
manifest.add(module + ".coordinate=" + GROUP + ":" + module + ":embedded-" + digest);
manifest.add(module + ".sha256=" + digest);
try (JarFile jar = new JarFile(provider.getValue())) {
Enumeration<JarEntry> entries = jar.entries();
while (entries.hasMoreElements()) {
JarEntry entry = entries.nextElement();
@@ -92,31 +79,15 @@ public final class NativeRuntimeArtifacts {
Files.write(rules.toPath(), keep, StandardCharsets.UTF_8);
}
public static void verify(File artifact, List<String> adapters, String version) throws IOException {
public static void verify(File artifact, List<String> modules, Map<String, File> natives) throws IOException {
requireModules(natives, modules);
try (JarFile jar = new JarFile(artifact)) {
JarEntry entry = jar.getJarEntry(MANIFEST);
if (entry == null) {
throw new GradleException("Missing native runtime dependency manifest");
}
Properties manifest = new Properties();
try (InputStream input = jar.getInputStream(entry)) {
manifest.load(input);
}
Set<String> expected = new TreeSet<>();
expected.add("native-common");
for (String adapter : adapters) {
expected.add("native-" + adapter);
}
Set<String> actual = new TreeSet<>(List.of(manifest.getProperty("modules", "").split(",")));
if (!expected.equals(actual)) {
throw new GradleException("Native dependency modules differ: " + actual + "; expected " + expected);
}
for (String module : expected) {
String coordinate = GROUP + ":" + module + ":" + version;
if (!coordinate.equals(manifest.getProperty(module + ".coordinate"))
|| !manifest.getProperty(module + ".sha256", "").matches("[0-9a-f]{64}")) {
throw new GradleException("Invalid native runtime dependency: " + module);
}
verifyManifest(input.readAllBytes(), natives);
}
Enumeration<JarEntry> entries = jar.entries();
while (entries.hasMoreElements()) {
@@ -128,6 +99,82 @@ public final class NativeRuntimeArtifacts {
}
}
public static void verifyPacked(File artifact, List<String> modules, Map<String, File> natives) throws IOException {
requireModules(natives, modules);
Map<String, byte[]> contents;
try (JarFile jar = new JarFile(artifact)) {
JarEntry payload = jar.getJarEntry(PAYLOAD);
if (payload == null) {
contents = read(Files.newInputStream(artifact.toPath()));
} else {
try (InputStream input = new XZInputStream(jar.getInputStream(payload))) {
contents = read(new ByteArrayInputStream(input.readAllBytes()));
}
JarEntry outer = jar.getJarEntry(MANIFEST);
byte[] embedded = contents.get(MANIFEST);
if (outer != null && embedded != null) {
try (InputStream input = jar.getInputStream(outer)) {
if (!Arrays.equals(input.readAllBytes(), embedded)) {
throw new GradleException("Packed native runtime manifests differ");
}
}
}
}
}
byte[] manifest = contents.get(MANIFEST);
if (manifest == null) {
throw new GradleException("Missing native runtime dependency manifest");
}
verifyManifest(manifest, natives);
for (String module : natives.keySet()) {
byte[] provider = contents.get(RESOURCES + module + ".jar");
if (provider == null) {
throw new GradleException("Missing embedded native provider: " + module);
}
if (!digest(provider).equals(digest(Files.readAllBytes(natives.get(module).toPath())))) {
throw new GradleException("Embedded native provider differs from the resolved build: " + module);
}
}
}
private static void verifyManifest(byte[] bytes, Map<String, File> natives) throws IOException {
Properties manifest = new Properties();
manifest.load(new ByteArrayInputStream(bytes));
Set<String> actual = new TreeSet<>(List.of(manifest.getProperty("modules", "").split(",")));
if (!natives.keySet().equals(actual)) {
throw new GradleException("Native dependency modules differ: " + actual + "; expected " + natives.keySet());
}
if (!"embedded".equals(manifest.getProperty("storage"))) {
throw new GradleException("Native runtime providers must be embedded: " + manifest.getProperty("storage"));
}
for (Map.Entry<String, File> provider : natives.entrySet()) {
String module = provider.getKey();
String digest = digest(Files.readAllBytes(provider.getValue().toPath()));
if (!digest.equals(manifest.getProperty(module + ".sha256"))
|| !(GROUP + ":" + module + ":embedded-" + digest).equals(manifest.getProperty(module + ".coordinate"))) {
throw new GradleException("Native runtime manifest does not describe the embedded provider: " + module);
}
}
}
private static void requireModules(Map<String, File> natives, List<String> modules) {
if (!natives.keySet().equals(new TreeSet<>(modules))) {
throw new GradleException("Embedded native providers differ: " + natives.keySet() + "; expected " + modules);
}
}
private static Map<String, byte[]> read(InputStream source) throws IOException {
Map<String, byte[]> contents = new TreeMap<>();
try (ZipInputStream archive = new ZipInputStream(source)) {
for (ZipEntry entry = archive.getNextEntry(); entry != null; entry = archive.getNextEntry()) {
if (!entry.isDirectory()) {
contents.put(entry.getName(), archive.readAllBytes());
}
}
}
return contents;
}
private static boolean implementation(String name) {
if (!name.startsWith(NATIVE)) {
return false;
@@ -137,9 +184,9 @@ public final class NativeRuntimeArtifacts {
|| relative.matches("v[0-9_]+R[0-9]+/.*");
}
private static String digest(File file) throws IOException {
private static String digest(byte[] bytes) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(file.toPath())));
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException(exception);
}
@@ -11,7 +11,6 @@ public final class GenerationRevisionScope {
"core/src/main/java",
"spi/src/main/java",
"adapters/bukkit/plugin/src/main/java",
"adapters/minecraft-common/src/main/java",
"adapters/modded-common/src/main/java",
"adapters/client-common/src/main/java",
"adapters/fabric/src/main/java",
@@ -29,6 +28,10 @@ public final class GenerationRevisionScope {
"core/src/main/java/art/arcane/iris/world/safeguard/Diagnostic.java",
"core/src/main/java/art/arcane/iris/world/safeguard/ValueWithDiagnostics.java",
"core/src/main/java/art/arcane/iris/diagnostics/splash/IrisSplashRenderer.java",
"core/src/main/java/art/arcane/iris/diagnostics/splash/IrisSplashComposer.java",
"core/src/main/java/art/arcane/iris/diagnostics/splash/IrisSplashPackScanner.java",
"adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedIrisSplash.java",
"adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandDebug.java",
"adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandDeveloper.java",
"adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandPregen.java",
"adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandIris.java",
@@ -33,6 +33,31 @@ public class GenerationBuildRevisionTest {
GenerationBuildRevision.fingerprint(GenerationBuildRevision.capture(fixture.options(), fixture.dependencies())));
}
@Test
public void dependencyReleaseNamesAndMavenMetadataDoNotChangeGenerationIdentity() throws Exception {
Fixture fixture = fixture();
String original = GenerationBuildRevision.fingerprint(GenerationBuildRevision.read(fixture.revision()));
Path released = fixture.root().resolve("math-2.0.jar");
try (ZipOutputStream output = new ZipOutputStream(Files.newOutputStream(released))) {
for (Map.Entry<String, String> entry : Map.of(
"META-INF/MANIFEST.MF", "Manifest-Version: 1.0\r\nImplementation-Version: 2.0\r\nBuild-Time: 12345\r\n\r\n",
"META-INF/maven/example/math/pom.properties", "groupId=example\nartifactId=math\nversion=2.0\n",
"META-INF/maven/example/math/pom.xml", "<project><version>2.0</version></project>",
"example/Math.class", "same code").entrySet()) {
ZipEntry archiveEntry = new ZipEntry(entry.getKey());
archiveEntry.setTime(1_700_000_000_000L);
output.putNextEntry(archiveEntry);
output.write(entry.getValue().getBytes(StandardCharsets.UTF_8));
output.closeEntry();
}
}
Map<String, Path> dependencies = Map.of("math", released);
GenerationBuildRevision.verifySnapshot(fixture.root(), fixture.revision(), dependencies);
assertEquals(original, GenerationBuildRevision.fingerprint(
GenerationBuildRevision.capture(fixture.options(), dependencies)));
}
@Test
public void rejectsChangedAddedAndDeletedGenerationSources() throws Exception {
Fixture fixture = fixture();
@@ -44,6 +44,25 @@ public class GenerationRevisionScopeTest {
}
}
@Test
public void releaseVersionAndExpandedPluginMetadataPreserveTheGenerationFingerprint() throws Exception {
Fixture fixture = fixture();
String original = GenerationBuildRevision.fingerprint(GenerationBuildRevision.read(fixture.revision()));
for (String version : List.of("4.1.2-26.2", "4.1.3-26.2", "4.1.3-26.2-build-123")) {
write(fixture.root().resolve("gradle.properties"), "irisVersion=" + version + "\n");
write(fixture.root().resolve("adapters/bukkit/plugin/src/main/resources/paper-plugin.yml"),
"name: Iris\nversion: " + version + "\n");
write(fixture.root().resolve("adapters/bukkit/plugin/build/resources/main/paper-plugin.yml"),
"name: Iris\nversion: " + version + "\n");
write(fixture.root().resolve("adapters/fabric/build/resources/main/fabric.mod.json"),
"{\"id\":\"iris\",\"version\":\"" + version + "\"}\n");
GenerationBuildRevision.verifySnapshot(fixture.root(), fixture.revision(), fixture.dependencies());
assertEquals(original, GenerationBuildRevision.fingerprint(
GenerationBuildRevision.capture(fixture.options(), fixture.dependencies())));
}
}
@Test
public void generationChangesProduceANewBuildRevision() throws Exception {
Fixture fixture = fixture();
@@ -88,6 +107,10 @@ public class GenerationRevisionScopeTest {
}
assertFalse(manifest.sources().containsKey(ENGINE_ROOT + "EngineDiagnostics.java"));
assertFalse(manifest.sources().containsKey(COMMAND_ROOT + "CommandIris.java"));
assertFalse(manifest.sources().containsKey(COMMAND_ROOT + "CommandDebug.java"));
assertFalse(manifest.sources().containsKey("core/src/main/java/art/arcane/iris/diagnostics/splash/IrisSplashComposer.java"));
assertFalse(manifest.sources().containsKey("core/src/main/java/art/arcane/iris/diagnostics/splash/IrisSplashPackScanner.java"));
assertFalse(manifest.sources().containsKey("adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedIrisSplash.java"));
}
@Test
@@ -44,6 +44,28 @@ public class ModdedArtifactVerifierTest {
ModdedArtifactVerifier.verify(artifact, REQUIRED_ENTRIES);
}
@Test
public void rejectsClientLanguagesWithoutPackMetadata() throws Exception {
Map<String, byte[]> entries = validEntries();
entries.put("assets/irisworldgen/lang/en_us.json", "{}".getBytes(StandardCharsets.UTF_8));
File artifact = createArtifact(entries);
GradleException failure = assertThrows(GradleException.class,
() -> ModdedArtifactVerifier.verify(artifact, REQUIRED_ENTRIES));
assertTrue(failure.getMessage().contains("client language resources without pack.mcmeta"));
}
@Test
public void acceptsClientLanguagesWithPackMetadata() throws Exception {
Map<String, byte[]> entries = validEntries();
entries.put("assets/irisworldgen/lang/en_us.json", "{}".getBytes(StandardCharsets.UTF_8));
entries.put("pack.mcmeta", ("{\"pack\":{\"description\":\"Iris\","
+ "\"min_format\":[88,0],\"max_format\":[107,1]}}")
.getBytes(StandardCharsets.UTF_8));
ModdedArtifactVerifier.verify(createArtifact(entries), REQUIRED_ENTRIES);
}
@Test
public void rejectsRelocatedRuntimeReference() throws Exception {
Map<String, byte[]> entries = validEntries();

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