Compare commits

..
Author SHA1 Message Date
SwiftSwamp c7e4b94bf6 Issue Templates
- Updating Issue Templates
2026-10-17 07:15:52 -06:00
Brian Neumann-Fopiano a3eccd8a65 Merged current master
- Bring in master commits made during the parity work
2026-10-16 14:30:29 -04:00
Brian Neumann-Fopiano 413d7c4eb2 Documented mod target builds
- README lists the per-target mod jar tasks and checkAll
2026-10-16 12:14:03 -04:00
Brian Neumann-Fopiano ea9f7a79fa Tested 1.21.1 saved dimensions
- Unit test for the 1.21.1 saved-dimension rule: replaced stems keep their saved generator, Iris worlds and new dimensions persist as they run
- The mixin audit test lists the two 1.21.1 persistence mixins as deliberately unaudited
2026-10-16 12:11:25 -04:00
Brian Neumann-Fopiano 64e4c82716 Named malformed history manifests
- Truncated or malformed generation manifests and epoch metadata report as invalid history again after the strict reader change
- The datapack metadata test expects the range and supported_formats to start at the 1.21.1 format
2026-10-16 12:07:36 -04:00
Brian Neumann-Fopiano 835ad2f704 Merge remote-tracking branch 'origin/master' 2026-10-16 11:38:20 -04:00
Brian Neumann-Fopiano 478fd4d350 Built every mod target
- verify<Loader><version> builds, tests and verifies each Fabric, Forge and NeoForge jar for every mod target
- build<Loader><version> exports each verified jar to PluginOuts, removing stale jars of the same loader and version
- buildAllToOut exports the Bukkit jar and all mod target jars; checkAll runs every project check plus the full mod matrix
- Targets list their unit test loaders; Forge 1.21.1 compiles its tests only because Forge 52 cannot bootstrap Minecraft outside FML
- Removed the single-target fabricJar, forgeJar, neoforgeJar, moddedTest and artifact verification tasks
2026-10-16 11:21:01 -04:00
Brian Neumann-Fopiano 97a3f7e7c3 Ran tests under NeoForge 1.21.1
- Classpath scans accept the union filesystem the NeoForge 21.1 test harness mounts and skip test classes merged into it
- The 1.21.1 NeoForge server access test checks the cached world array the tick loop reads
2026-10-16 11:21:01 -04:00
Brian Neumann-Fopiano 8917bdc974 Read 1.21.1 dimension contract
- The world check derives the End dragon fight flag from the dimension effects when the runtime dimension type has no has_ender_dragon_fight field
2026-10-16 11:21:01 -04:00
Brian Neumann-Fopiano 4daae925c6 Added 26.1.2 and 26.2 mod targets
- Mod targets 26.2 and 26.1.2 (inheriting 26.2) with Fabric 0.19.5, Forge 65.1.3 and 64.1.4, NeoForge 26.2.0.89 and 26.1.2.116
- The 26.3 target reads the minecraft26_3 native access rules
- Per-version pack.mcmeta ranges for 26.2 and 26.1.2
- 26.2 and 26.1.2 test overlays and replacements for the pre-26.3 biome, spawn, carver and sign APIs
- NeoForge compiles against the loader's own ASM while the mod jar keeps the bundled ASM
2026-10-16 11:07:09 -04:00
Brian Neumann-Fopiano 0073002b57 Isolated datapack format test
- The default forced datapack format test unbinds the platform so loader test harnesses that bind a runtime cannot change its result
2026-10-16 11:07:09 -04:00
Brian Neumann-Fopiano 79e7b0c79e Chained mod target overlays
- Mod targets list an ordered overlay chain; later overlays replace earlier ones and earlier source replacements apply to inherited files
- Forge and NeoForge metadata take their loader version range from the target
- The Fabric permissions library is bundled only for targets that declare it
- Dropped the 1.21.1 mods.toml copies that only differed in the loader range
2026-10-16 10:44:12 -04:00
Brian Neumann-Fopiano 03c2630497 Fixed startup lifecycle test
- The levels-ready startup test drives the parallel dimension restore instead of the removed per-dimension restore call
2026-10-16 10:44:12 -04:00
Brian Neumann-Fopiano b8aa00b70e Merged 1.21.1 mod targets
- Merge feature/modded-versions into the modded fix integration
- Take loader pins from gradle/mod-targets.json for every target
- Pin the 26.3 NeoForge target to 26.3.0.83
2026-10-16 10:26:02 -04:00
Brian Neumann-Fopiano 62062c4a95 Added 1.21.1 mod targets
- Build Fabric, Forge and NeoForge per target listed in gradle/mod-targets.json (1.21.1, 26.3)
- Add 1.21.1 adapter source overlays under adapters/*/src/versions/1.21.1
- Add verifyModdedMatrix and per-target Java runtime verification of loader jars
- Re-inject persistent dimensions from the levels-ready hook before the watchdog starts
- Wait for queued chunks and the final save before pregen completes
- Add the 1.21.1 data version and datapack supported_formats range
- Cascade regions whose sea or shore pool loses every biome
- Use Gson calls available on the 1.21.1 runtime
2026-10-16 10:25:19 -04:00
Brian Neumann-Fopiano bf99edce87 Merged fast biome locate
- Biome locate searches the biomes chunks are filled with, filtered by region and biome hints
- Locate checks hydrology only for river targets
2026-10-15 19:27:44 -04:00
Brian Neumann-Fopiano 257c7d4f87 Filtered visible biome locate
- /locate biome on non-stacked Iris dimensions searches the visible biomes chunks are filled with instead of falling back to the vanilla structure-biome search
- BiomeSearchFilter rejects columns whose region, land, sea, shore, cave, carving, subterrain or image-map biomes cannot hold a target, so terrain height is computed only for columns that can match
- Hydrology planning state is consulted only when a target comes from a river policy, so a locate no longer queues hydrology tile plans across its whole radius
- Admitted samples resolve inside their generation-history coordinate scope, so saved-biome reads for generated chunks no longer fail the command
- HintedLocator surface hints and IrisBiome visible key enumeration are shared with the locate filter
- Bukkit and modded biome policies share the same filtered search
2026-10-15 19:04:45 -04:00
Brian Neumann-Fopiano 73bf0d64a5 Merged off-thread world creation
- Modded create, enable and replace-overworld prepare worlds in the background
- Worlds still being created refuse duplicate create, delete, disable and update
2026-10-15 19:01:06 -04:00
Brian Neumann-Fopiano 792f9c2da5 Merged replace target refusals
- Modded /iris replace refuses dimensions no level stem defines
- Replacements count as applied only when the live level generates from Iris
- Unknown replace types list installed packs
2026-10-15 19:01:06 -04:00
SwiftSwamp 0b66b0b1df License Update
- Correcting local license file to match GNU Public v3's actual license.
2026-10-15 13:35:34 -06:00
Brian Neumann-Fopiano 369c864e71 Created modded worlds off-thread
- /iris create, /iris world create, /iris world enable and /iris world replace-overworld acknowledge at once, build the world in the background, and report success or the existing failure messages to the sender when it finishes
- Pack validation, the pack dimension check, generation history creation, pack data loading and the engine build run off the server thread; the server thread only registers the world, creates the level and publishes it
- A second create of a world that is still being created, and delete, disable or update of that world, are refused until it is live
- Server shutdown cancels in-flight creations, waits for an engine that is still building, and rolls back its level and registry entry
- Startup restore and world creation share one dimension injection orchestrator, and abandoning an injection also closes an engine that was already built
- Added the creating-in-background message to every locale file
2026-10-15 12:53:58 -04:00
Brian Neumann-Fopiano 31ec5a83c2 Refused runtime replace targets
- Refused /iris replace for dimensions that no level stem defines, including mod levels created while the server starts
- Skipped hand-edited dimensionReplacements entries for such dimensions at startup without moving their data or writing a level stem override
- Derived replaceable dimensions from the recorded worldgen catalog and the applied replacement state
- Recorded a replacement as applied only when the loaded level generates from Iris with the configured pack and seed
- Kept matching Iris generation history without recording it as an applied replacement
- Dropped applied records whose loaded level uses another chunk generator
- Stopped Iris engines bound to replaced levels that a mod swapped out during startup
- Listed installed packs and pack:dimensionKey forms when the replace type is unknown
- Added the refusal and installed-pack messages to every locale
2026-10-15 12:33:47 -04:00
Brian Neumann-Fopiano e1049949c6 Stabilized NeoForge test suite
- Pack validation publication tests pin the registry context instead of reading a bound platform
- NeoForge tests skip the mixin package, which mod-layer tests cannot load
2026-10-15 10:21:07 -04:00
Brian Neumann-Fopiano a9b394ee5d Merged modded dimension replace
- Modded /iris replace stages vanilla, Iris and mod dimension replacements with Paper syntax
- /iris world takeover removed; replacements live in modded.json dimensionReplacements
2026-10-15 10:11:07 -04:00
Brian Neumann-Fopiano a0cf3d79bf Merged off-thread dimension restore
- Saved dimensions prepare in parallel during world load
- Server start only creates levels and publishes engines
2026-10-15 10:11:03 -04:00
Brian Neumann-Fopiano 969ec7fde3 Merged ingest placement caps
- Templates host at most five land or three sea, shore and cave adoptions
- Themed regions only take mod biomes no common region accepts
2026-10-15 10:11:03 -04:00
Brian Neumann-Fopiano b4df23856c Merged modded world listing
- World rows show each world's own seed and saved chunk total
- Rows are labeled by dimension key
2026-10-15 10:11:03 -04:00
Brian Neumann-Fopiano e881e0a33a Merged NeoForge stable target
- NeoForge builds target 26.3.0.83 and require 26.3.0.65-beta or newer
2026-10-15 10:11:03 -04:00
Brian Neumann-Fopiano a7cf2cac9a Unified modded world replace
- Added /iris replace with override and overwrite aliases on Fabric, Forge, and NeoForge using the Paper target, type, and seed arguments
- Resolved main, nether, end, level-name aliases, iris: and bare names, and any loaded mod dimension as replacement targets
- Kept the saved seed when seed is omitted and stored explicit seeds per replaced dimension
- Applied replaced vanilla and mod dimensions through the forced datapack level stem with the configured seed bound at engine creation
- Applied staged Iris world replacements at startup by moving their terrain aside and repointing the dimension registry
- Refused a second staging while a replacement is pending and checked pack environments for vanilla slots
- Added the modded-only off form to cancel a staged replacement or return a replaced dimension to its own generator
- Reset the Overworld spawn and moved returning players out of unsafe positions after an Overworld replacement
- Renamed the takeover reconciler, model, config key, state file, and recovery folder to replacement names
- Removed /iris world takeover and its help entry and localized messages
2026-10-15 10:10:11 -04:00
Brian Neumann-Fopiano 62c610ce76 Restored saved dimensions off-thread
- Saved Iris dimensions are prepared in parallel during world load (server-about-to-start on NeoForge and Forge, server-starting on Fabric), before the server watchdog runs
- Server start only creates the restored levels, builds their engines in parallel, and publishes them, so its wait no longer grows one world at a time
- Each restored dimension opens its generation history once instead of twice and resolves its active pack once instead of three times
- An epoch snapshot whose content matches its validated source pack reuses that validation instead of running the full pack validator for every world
- Level creation reads the configured pack and structure biome keys from data warmed during preparation
- Immutable generation packs feed the prepared hydrology cache identity from the data's memoized fingerprint instead of hashing the pack twice per engine build
- Restore logging reports each dimension's preparation and engine time and how long the server thread waited
2026-10-15 10:08:51 -04:00
Brian Neumann-Fopiano 9a241b85cb Labeled world rows by dimension
- /iris worlds and /iris info label each row with its own dimension key instead of the shared generator identity
- Dropped the redundant world= field now that the row label carries the dimension key
- Rows for worlds whose engine has not started use the same dimension key label
- Listing tests assert per-world row labels, including an unstarted engine row
2026-10-15 09:29:56 -04:00
Brian Neumann-Fopiano 12f30596e5 Targeted NeoForge 26.3.0.83
- NeoForge builds compile against NeoForge 26.3.0.83
- The NeoForge mod requires NeoForge 26.3.0.65-beta or newer, the first build without the cached world array
- NeoForge server access test covers publishing, removing and restoring runtime levels against the live level map
- README platform table and bug template name the current NeoForge target
2026-10-15 09:21:10 -04:00
Brian Neumann-Fopiano 0c9ae9ed4d Fixed modded world listing
- /iris worlds and /iris info report each Iris world's own seed instead of the server level seed
- /iris worlds and /iris info report the world's persisted generated chunk total instead of the per-session counter
- /iris seed reports the Iris world seed on its World seed line
- Added command tests for per-world seeds, persisted chunk totals and the gamemaster seed gate
2026-10-15 09:19:38 -04:00
Brian Neumann-Fopiano 137724157a Kept mod biomes findable
- Themed regions, those at least five times rarer than the dimension's median region, no longer receive adopted biomes while a common region accepts the biome's role
- Themed regions still take a mod biome when no common region has a pool for its role or every common template is full
- Category affinity now chooses among common regions, so a rare biome is no longer locked inside a region that can be absent for thousands of blocks
2026-10-15 09:14:20 -04:00
Brian Neumann-Fopiano 523cc7e7be Capped adoptions per template
- Each pack biome now serves as the template for at most five adopted land biomes and three adopted sea, shore, or cave biomes
- A mod biome whose best template is full falls back to the next best template under the cap
- A mod biome that finds every compatible template full is skipped with a reason naming the cap instead of being dropped
2026-10-15 09:13:55 -04:00
Brian Neumann-Fopiano 0ca33068f3 Discounted occupied flower columns
- Converted patch decorators also lose the share of surface columns that earlier surface patches in the declaring biomes already occupy, estimated from those patches' expected coverage in the catalog
- Canopy and surface occupancy are combined into one vegetation occlusion estimate per decorated feature
2026-10-14 22:49:45 -04:00
Brian Neumann-Fopiano 4d79d5ed5b Matched regions by biome category
- For land biomes with known categories, the region climate tie-breaker averages only the region's templates that share a category with the mod biome, so a mixed region no longer looks temperate on average
- A small region affinity term favors regions whose templates share the mod biome's categories
2026-10-14 22:46:18 -04:00
Brian Neumann-Fopiano ad66538d7a Kept mod trees on plantable ground
- A mod biome that brings its own vegetation pays a template penalty scaled by the share of the template's top surface layer that cannot hold plants, so snow, ice, sand, and stone templates no longer swallow its trees
- The top layer palette is resolved through biome-palette snippets and weighted by palette entry weight
- Mod biomes without their own vegetation keep the closest climate template
2026-10-14 22:44:16 -04:00
Brian Neumann-Fopiano d749633829 Routed cave biome ores
- Ores declared only by adopted cave biomes are written to the deposits of the region each cave biome joined, scoped to those cave biomes, instead of to the cave biome itself where the deposit stage never reads them
- Ores of adopted surface biomes still go to the biome
2026-10-14 22:38:42 -04:00
Brian Neumann-Fopiano 965e19801d Ordered converted ore hosts
- The worldgen catalog (format 2) records the members of every block tag referenced by a captured placed feature, so ore targets using mod block tags convert instead of passing through
- Ore conversion supports noise_based_count and noise_threshold_count placement counts
- Converted ores that produce another converted ore's host block are written ahead of it, so an ore whose host comes from a sibling ore finds that host during the deposit stage
2026-10-14 22:37:28 -04:00
Brian Neumann-Fopiano d5796b275e Audited datapack priority mixin
- The modded mixin audit checks that the pack selection mixin keeping the Iris forced datapack last is applied
- Ordering tests cover a world datapack saved after the Iris pack and already-ordered selections
2026-10-14 21:41:41 -04:00
Brian Neumann-Fopiano c9786f2d23 Calibrated converted decorator density
- Patch decorators converted from features that snap to a leaf-blocked heightmap lose the share of placements that land on tree canopy in the declaring biomes
- Canopy is estimated from the tree-like features that run before the decoration in each declaring biome's vegetal step, using their expected per-chunk count and a typical crown footprint
- Patches that snap below leaves or run before the trees keep their full density
2026-10-14 21:38:00 -04:00
Brian Neumann-Fopiano 7767d6d30a Scoped mod trees per biome
- importedFeatures gains biomeRules, which deny placed feature families or exact keys only in registry biomes matched by key, namespace, or tag; Iris custom biomes match through their vanilla derivative
- Mod ingest classifies tree-like passthrough features from the catalog feature JSON, including trees nested in random selectors, fallen trees, and huge mushrooms
- Tree-like mod features that a mod injects into vanilla biomes the pack already uses are denied in minecraft biomes, so they no longer double the pack's own trees while adopted mod biomes keep them
- Studio schema digest updated for the new rule shape
2026-10-14 21:35:38 -04:00
Brian Neumann-Fopiano 0bc235afb6 Scored ingest biome placement
- Mod biomes pick their template across all regions by climate distance, biome category agreement from minecraft and c tags, a reuse penalty that spreads biomes over equally good templates, and the region's overall climate
- Regions much rarer than the dimension's median region are treated as themed and only receive a mod biome when nothing else fits
- Templates derived from void biomes no longer act as climate references
- /iris ingest mods accepts excludeRegions=<a,b> to keep mod biomes out of named regions; unknown region names are refused
2026-10-14 21:26:22 -04:00
Brian Neumann-Fopiano 8388194129 Trimmed quiet ingest namespaces
- The mod ingest summary and manifest namespace list only include namespaces that produced at least one ingest item, so mods without worldgen content no longer print empty summary lines
2026-10-14 21:19:06 -04:00
Brian Neumann-Fopiano 506e545edf Placed converted ores in hosts
- Ores converted by mod ingest use the FULL_HEIGHT deposit placement scope, so they replace their host blocks anywhere in their height band like vanilla ore features instead of being culled above the column surface
2026-10-14 21:16:48 -04:00
Brian Neumann-Fopiano aedef3c38a Limited borrowed types to modded
- Pack validation rejects borrowedDimensionType on platforms that generate their own Iris dimension type, with an error naming the supported loaders
- Dimension type keys and registry contracts honor borrowedDimensionType only when the platform generation registry supports borrowed types, so Bukkit always derives its generated iris type
- The Bukkit runtime contract always expects the generated Iris type; hotload still refuses a borrowedDimensionType change
2026-10-14 21:10:25 -04:00
Brian Neumann-Fopiano 9481a9ecf0 Reserved Iris ingest namespaces
- Mod ingest never selects the irisworldgen namespace or a namespace equal to a dimension key of the source pack or any installed Iris pack, so Iris's own scoped and legacy alias biomes are not adopted
- Explicitly requesting one of those namespaces is refused
- A namespace that belongs to a loaded mod is never treated as an Iris dimension alias namespace
2026-10-14 21:08:23 -04:00
Brian Neumann-Fopiano b8aabd4fd1 Fixed ingested ore anchors
- above_bottom and below_top ore anchors resolve against the pack's own dimension height, or the borrowed dimension type's height when the pack borrows one, instead of the vanilla dimension type
- Converted deposit heights now match the pack-relative rebase, so a 0..256 pack no longer receives out-of-world ore bands
2026-10-14 21:07:10 -04:00
Brian Neumann-Fopiano a3f607e264 Added exact imported feature denial
- importedFeatures gains disabledExact, which denies normalized complete placed feature keys without matching key families
- Mod ingest puts converted feature keys in disabledExact, so converting mod:ore_tin no longer suppresses mod:ore_tin_large
- Mod ingest keeps vanilla and unselected mod features generating when the source pack already ran importedFeatures, and otherwise disables vanilla plus every unselected mod namespace that has placed features
- Studio schema digest updated for the new field
2026-10-14 21:05:27 -04:00
Brian Neumann-Fopiano 191d9e88e6 Guarded ingest pack replacement
- Mod ingest compares source and target by real path and file identity, so a differently cased target on a case-insensitive filesystem can no longer resolve to and delete the source pack
- replace=true only replaces a target that contains ingest/manifest.json
- Ingest builds into a hidden staging directory and swaps it in atomically, deleting the previous output only after the new pack is complete
2026-10-14 21:02:29 -04:00
Brian Neumann-Fopiano 3ba6d5b9c1 Contained catalog capture errors
- A worldgen catalog capture that fails with any throwable, during the server-thread scan or the background encode, always completes its future and frees the next capture
- Errors are logged with their stacktrace and no longer escape server startup; only virtual machine errors are rethrown after cleanup
2026-10-14 21:01:08 -04:00
Brian Neumann-Fopiano 56ba2e51b7 Preserved merged tag order
- Merged datapack tags keep first-seen value order with duplicates removed instead of re-sorting, so world preset tag order is preserved
- NeoForge remove lists are kept as the deduplicated union of both tags instead of being dropped
2026-10-14 21:00:02 -04:00
Brian Neumann-Fopiano 038833e614 Refused unstageable takeovers
- Datapack staging refuses a takeover it cannot stage when the level has applied takeover state or Iris generation history, instead of silently leaving the mod generator in place
- A level's retained generation history now stages its takeover even when the installed pack is gone
- Takeover pack names and dimension keys are resolved to the installed folder and file names before staging, reconciliation, and recording, and the takeover command stores that form
- Reconciliation refuses to switch an applied level to an uninstalled pack before moving any data, and leaves a never-applied level untouched until its pack is installed
2026-10-14 20:59:09 -04:00
Brian Neumann-Fopiano cf0587c112 Hardened takeover state recording
- A changed level whose existing Iris generation history already uses the configured pack is recorded as applied instead of quarantined
- New and changed takeovers are recorded only once their level generates from the configured pack; removals are recorded at reconciliation
- A changed takeover keeps its previous applied state until the new pack generates, so a later staging failure still sees the level as taken over
- Failing to write the applied takeover state stops the server instead of only logging
2026-10-14 20:54:57 -04:00
Brian Neumann-Fopiano a0c9d48601 Derived retained borrowed pack names
- Retained borrowed-type packs take their pack name from the scoped biome aliases recorded in their generation history instead of the dimension key
- Scoped legacy biome aliases of retained borrowed packs now match the definitions their worlds reference
- A retained pack whose recorded aliases name several packs is refused instead of staged with a guessed name
2026-10-14 20:53:08 -04:00
Brian Neumann-Fopiano 4244aa4ea7 Failed closed on unreadable takeovers
- Modded config reports unparseable files, non-object takeover sections, and invalid takeover entries instead of returning silent defaults
- Takeover reconciliation refuses startup when takeovers are unreadable and the world has an applied takeover state file
- Config writes are refused while takeovers are unreadable so fallback defaults never overwrite the operator's file
2026-10-14 20:51:18 -04:00
Brian Neumann-Fopiano 79e220298f Merged dimension takeover 2026-10-14 20:19:44 -04:00
Brian Neumann-Fopiano e1393e2516 Merged worldgen catalog 2026-10-14 20:19:27 -04:00
Brian Neumann-Fopiano acec550625 Merged forced datapack tags
- Staged packs that write the same data/*/tags file now merge into the union of their values
- Tag entries dedupe by value or id and replace stays false unless a contributor set it
- Every non-tag file keeps strict conflict detection
2026-10-14 20:19:04 -04:00
Brian Neumann-Fopiano 769e077078 Clarified ingest target errors
- Rejected target pack names outside letters, digits, underscores and hyphens before ingest starts
- Reported an existing target pack with a translated replace=true hint instead of the raw path
2026-10-14 20:18:50 -04:00
Brian Neumann-Fopiano 5633eec009 Added dimension takeover
- IrisDimension borrowedDimensionType reuses an existing registered dimension type instead of generating one
- Pack validation rejects borrowedDimensionType values that are not namespaced registry keys
- Generation contracts record the borrowed type key and capture it as an external registry definition
- Borrowed types bind when the pack min equals the type min_y and the pack top fits, with both ranges in the refusal
- Studio hotload rejects borrowed type changes and the world check validates borrowed ranges
- Forced datapack skips generated types and presets for borrowed dimensions and accepts retained borrowed epochs
- modded.json dimensionTakeovers writes a LevelStem per level key using the irisworldgen:iris generator
- Takeover changes quarantine the level chunk, entity, POI, and Iris data before load and track applied state per world
- Fresh levels adopt generation history with content-addressed biomes instead of legacy aliases
- /iris world takeover <dimension> <pack[:dimension]|off> with help and translations in every locale
2026-10-14 20:16:36 -04:00
Brian Neumann-Fopiano 7fc8d20516 Merged mod ingester 2026-10-14 20:14:16 -04:00
Brian Neumann-Fopiano f6f381f6e4 Added modded worldgen catalog
- Captured the worldgen catalog after server start, scanning registries on the server thread and encoding and writing on a worker into config/irisworldgen/catalog/worldgen-catalog.json
- Added WorldgenCatalogFingerprint over namespace versions and registry keys
- Added /iris catalog [namespace|refresh] and /iris ingest mods <source> <target> with keyed dimension, namespaces and replace options
- Added catalog messages and help entries for every shipped locale
- Added tests for catalog assembly, namespace summaries, ingest arguments, the registry scan and command registration
2026-10-14 20:14:11 -04:00
Brian Neumann-Fopiano 665c6df83d Merged mod ingester 2026-10-14 20:12:47 -04:00
Brian Neumann-Fopiano 26dc1d17cf Implemented mod ingest v0
- ModIngester copies the source pack with IrisProjectCopier into a new target pack whose folder name becomes the dimension key, honouring replaceTarget and rejecting overlapping or already-ingested sources
- Mod biomes are classified by minecraft/c tags into land, sea, shore, and cave pools, skipping rivers, other-dimension biomes, and biomes missing the dimension tag
- Each adopted biome joins the region with the nearest land climate and copies the nearest-climate template biome with the mod biome as its derivative and no custom-biome overrides
- Template trees and procedural trees are dropped when the mod biome brings its own non-decorator vegetation
- Ore and scattered ore features become Iris deposits per target with resolved height anchors, counts, rarity, vein size, air-exposure discard, and host blocks
- Vanilla-declared ores become dimension deposits filtered to the declaring biomes when they do not cover the whole dimension; mod-only ores become biome deposits
- Surface simple_block and legacy random_patch features become Iris decorators on adopted mod biomes and on pack biomes whose derivative declares them
- Remaining features pass through native generation, with importedFeatures enabled and minecraft plus every converted key disabled
- Passthrough features shadowed by a disabled prefix or a disabled step are reported as skipped
- Mod structures are reported as adopted unless importedStructures disables them or none of their biomes generate
- The target pack records ingest/manifest.json with the catalog fingerprint, namespaces, generated and modified files, and every item
- Tests cover climate placement, biome classification, ore and patch conversion against current 26.3 formats, propagation, passthrough, manifest, idempotent replace, and pack validation
2026-10-14 20:02:37 -04:00
Brian Neumann-Fopiano 8afb445b1d Added worldgen catalog model
- Added the platform-neutral worldgen catalog records, index and atomic JSON store
- Added the mod ingest request, report, item and tier types
2026-10-14 19:26:03 -04:00
Brian Neumann-Fopiano 3246e88f88 Merged treasure loot fixes
- Failed container fills retain pending work for retry
- Loot events and inventory writes run on the owning thread
- Native loot tables resolve against the current registry
- Gameplay checks cover map destinations and copper containers
2026-10-13 17:00:43 -04:00
Brian Neumann-Fopiano 09d36ec990 Removed temporary movement trace
- Gameplay retains tick delimiters without intercepting packet writes
- Chest outcome and failure phase evidence remain in reports
2026-10-13 16:58:45 -04:00
Brian Neumann-Fopiano 718b13df46 Paced treasure chest interactions
- Chest activations respect the server interaction rate limit
- Stationary clients finish idle ticks every fifty milliseconds
- Missing inventory windows fail within thirty seconds
2026-10-13 16:57:30 -04:00
Brian Neumann-Fopiano f17a9e0977 Bounded treasure inventory actions
- Chest activation and diamond withdrawal stop after ten seconds
- Partial chest evidence records the active phase and window layout
- Folia reports its unavailable reload command without changing pack files
2026-10-13 16:55:00 -04:00
Brian Neumann-Fopiano 9dbb6b6e85 Completed fixture client ticks
- Stationary gameplay sends tick delimiters after movement packets
- An initial delimiter closes movement sent before the scenario starts
2026-10-13 16:52:12 -04:00
Brian Neumann-Fopiano ee3af26f11 Captured fixture movement packets
- Gameplay reports the last 64 movement packets with player position and rotation
- Non-finite packet fields remain visible in failed scenario reports
2026-10-13 16:49:03 -04:00
Brian Neumann-Fopiano 13171edafc Merged treasure gameplay checks
- Gameplay fixtures verify map destinations and retained container loot on Paper and Folia
2026-10-13 16:42:57 -04:00
Brian Neumann-Fopiano ba2ecb7c0f Added treasure loot coverage
- Gameplay opens normal and copper chests before inspecting loot
- Map markers and saved centers match verified structure targets
- Missing and disabled destinations retain unrelated chest loot
- Storage variants, insertion rollback, region ownership, and resource reloads have runtime assertions
- Separate data packs cover Minecraft 26.2 and 26.3 loot formats
2026-10-13 16:42:41 -04:00
Brian Neumann-Fopiano 57882a3f1c Merged container loot fixes
- Container loot rolls retain retry state and restore contents after failed writes
2026-10-13 16:40:04 -04:00
Brian Neumann-Fopiano 56178b0e0a Fixed loot materialization
- Container loot finishes on its owning thread before pending markers clear
- Failed fills restore container contents and deferred native loot state
- Failed materialization remains pending without refilling completed containers
- Vanilla tables resolve from the current registry after reload
- Studio loot previews follow the player scheduler
- Tests cover loot rollback, retries, cancellation, and container classification
2026-10-13 16:39:39 -04:00
Brian Neumann-Fopiano aa3420b4fa Merged terrain sampling
- Concurrent fallback queries share pending terrain samples
- Pure height generators reuse noise contexts across batches
- Storage allocation tests account for the configured heap budget
2026-10-13 15:20:40 -04:00
Brian Neumann-Fopiano cec9568c9a Stabilized allocation limit test
- Prepared tile rejection coverage scales with the heap budget using a small shared fixture
2026-10-13 15:09:39 -04:00
Brian Neumann-Fopiano 5b5f3dae89 Reviewed terrain cache coordination
- Concurrent terrain requests share completion and coordinate claims per batch
2026-10-13 14:58:00 -04:00
Brian Neumann-Fopiano cb7eea604e Reviewed bulk terrain sampling
- Pure terrain generators now support ordered bulk height evaluation
- Column cache batches retain scalar sample values
2026-10-13 14:57:58 -04:00
Brian Neumann-Fopiano a207cdd828 Batched terrain cache coordination
- Cold terrain batches share one completion signal across their owned samples
- Cache claims and publication acquire each populated stripe once
- Owned sample batches preserve request order across stripes and repeated coordinates
2026-10-13 14:57:44 -04:00
Brian Neumann-Fopiano 0d34fae316 Batched terrain noise
- Hydrology grids carry uncached heights through bounded noise batches
- Pure generator layers resolve seed contexts once per batch
- Surface smoothing and volumetric terrain retain scalar output
- Provisional terrain samples stay out of routing caches
- Tests cover exact parity, scalar fallbacks, batch limits and cached misses
2026-10-13 14:57:12 -04:00
Brian Neumann-Fopiano 2889a2dbc9 Reviewed shared terrain samples
- Fallback trials share pending coordinate samples
- Worker provisional results remain uncached
2026-10-13 14:48:24 -04:00
Brian Neumann-Fopiano baf64fb9d1 Shared pending terrain samples
- Overlapping terrain batches and point requests share pending coordinate samples
- Failed terrain loads release reservations for retry
- Provisional worker results mark every caller and stay out of the shared cache
- Concurrency tests cover overlap, recursion, interruption, failure and single-worker batching
2026-10-13 14:47:41 -04:00
Brian Neumann-Fopiano 0e90d38acb Merged terrain bounds fix
- Shared bounds preserve distant coordinates and interpolator identities
- Cache and caller regressions cover concurrent replacement
2026-10-13 13:57:10 -04:00
Brian Neumann-Fopiano acc2619bed Reviewed distant terrain bounds
- Shared terrain bounds retain complete coordinate and interpolator identities
2026-10-13 13:56:31 -04:00
Brian Neumann-Fopiano 750ee87c6b Fixed distant terrain bounds
- Shared terrain bounds retain full coordinates and interpolator identities
- Cache regressions cover distant corners and concurrent replacement
2026-10-13 13:56:26 -04:00
Brian Neumann-Fopiano d34dd48f74 Merged generation and lifecycle fixes
- Folia retains generation resources until native workers drain
- Release builds record canonical world version metadata
- Cave carving reuses fixed density columns
- Offline probes handle concurrent directory writes
- Worker checks allow bounded hydrology prefetch completion
2026-10-13 12:47:47 -04:00
Brian Neumann-Fopiano 99c7559f5d Extended probe worker drain
- Offline generation checks allow bounded hydrology prefetch completion

- Worker timeout failures include active pool state

- Tests cover timeout and interrupt handling
2026-10-13 12:30:09 -04:00
Brian Neumann-Fopiano 65894df632 Extended probe worker drain
- Probe workers share a bounded 120-second settling deadline

- Timeout failures include the unsettled pool state

- Tests cover active work, deadline expiry, and interruption
2026-10-13 12:29:53 -04:00
Brian Neumann-Fopiano 6ff13d11c3 Fixed probe workspace cleanup
- Probe cleanup handles files created during traversal with bounded rescans
- Filesystem failures remain visible
- Symlink targets remain untouched
2026-10-13 12:28:58 -04:00
Brian Neumann-Fopiano 7d8968c45a Fixed probe cleanup
- Offline probes share bounded directory rescans after concurrent file creation
- Cleanup preserves other I/O failures
- Symlink targets remain untouched
- Probe tests use Mockito to reproduce cleanup races and failure boundaries
2026-10-13 12:28:32 -04:00
Brian Neumann-Fopiano 4df536a6ca Reused cave lattice lookups
- Adaptive cave passes retain exact fixed-density columns across height samples
2026-10-13 12:02:35 -04:00
Brian Neumann-Fopiano 54feb80295 Reused cave density columns
- Cave passes reuse validated lattice columns in local scratch
- Generation releases retained columns after each carve
- Regression tests cover density parity and cache lifetime
2026-10-13 12:01:59 -04:00
Brian Neumann-Fopiano 4f32c78982 Fixed Folia shutdown ordering
- Queued generation remains available through native world shutdown

- Deferred resource release requires the authoritative shutdown boundary
2026-10-13 11:46:03 -04:00
Brian Neumann-Fopiano 7dfcd6a989 Fixed world version metadata
- Fresh worlds retain the packaged Iris release number
2026-10-13 11:45:45 -04:00
Brian Neumann-Fopiano 8c0b37c0b8 Fixed Folia shutdown ordering
- Server shutdown keeps queued terrain generation admitted
- Runtime cleanup waits for the authoritative server boundary
- Missing shutdown boundaries retain engine resources
- Tests cover maintenance admission and active generation drains
2026-10-13 11:45:31 -04:00
Brian Neumann-Fopiano 4b44bcb12b Fixed release version metadata
- Engine metadata accepts unsuffixed Iris release versions
- Invalid release numbers return the existing unknown value
- Version tests cover packaged releases and supported Minecraft formats
2026-10-13 11:43:08 -04:00
Brian Neumann-Fopiano 2e3a41f262 Merged generation optimizations
- Generation skips empty floating work and repeated river lookups
- Height prefill uses batched cache access
- Deep fixed fracture chains reuse exact samples
2026-10-13 11:32:21 -04:00
Brian Neumann-Fopiano 45745abaa1 Merged fracture sampling reuse
- Deep fixed noise chains reuse paired samples with exact output
2026-10-13 11:15:03 -04:00
Brian Neumann-Fopiano ac026343c1 Merged height sampling changes
- Height prefill reuses chunk caches and mapped biomes skip unused sampling

- Shared terrain corner storage allocates only when sampled
2026-10-13 11:13:49 -04:00
Brian Neumann-Fopiano 78ddaaaf53 Reduced height sampling work
- Height prefill reads aligned worker chunks through the bulk cache
- Main-thread heights keep nonblocking hydrology sampling
- Biome maps skip unused procedural fallback samples
- Corner bounds allocate only when terrain samples need them
- Tests cover cache parity and provisional values
2026-10-13 11:12:55 -04:00
Brian Neumann-Fopiano de90d0ec5e Merged floating work reduction
- Empty floating placements skip redundant mantle work
2026-10-13 11:12:15 -04:00
Brian Neumann-Fopiano fac3b4e106 Reused nested fracture samples
- Pure deep fracture chains reuse coordinate pairs within each sample
- Each sample rechecks runtime noise purity
- Tests cover bit parity, sample counts, mutations and concurrent engines
2026-10-13 11:11:07 -04:00
Brian Neumann-Fopiano 8757355174 Removed empty floating work
- Objectless floating placements no longer expand mantle generation windows
- Empty floating object passes finish without column sampling
- Tests cover placement reach, reload, failure fallback and completion flags
2026-10-13 11:11:02 -04:00
Brian Neumann-Fopiano 2af594de61 Merged river lookup batching
- Post processing reuses river-weight samples across neighboring columns
2026-10-13 11:07:22 -04:00
Brian Neumann-Fopiano 6e77755279 Batched river lookups
- Post processing samples each river-weight coordinate once per chunk
- River exclusions preserve positive-weight and cardinal-neighbor rules
- Tests cover boundaries, nonfinite weights, wall patches and slabs
2026-10-13 11:06:33 -04:00
Brian Neumann-Fopiano 12591820e8 Corrected release labels
- Iris release version is 4.3.3
- Loader build defaults use the plain release version
- Platform jar labels retain their supported game versions
2026-10-12 19:33:50 -04:00
Brian Neumann-Fopiano a521109a6a Updated release version
- Iris version is 4.3.3-26.2
- Loader build defaults use the current release version
2026-10-12 14:47:39 -04:00
Brian Neumann-Fopiano a9f5145c0f Fixed Iris artifact builds
- Loader jars exclude Bukkit dependency bootstrap classes
- Artifact verification rejects bootstrap references and executable payloads
- Verifier tests cover nested jars and bundled compression libraries
- Local VolmLib native setup finishes before Iris native setup
2026-10-12 11:34:41 -04:00
Brian Neumann-Fopiano e3673afb1e Fixed mining and timeout settings
- Pending biome reads no longer cancel block breaking
- Custom loot resolves saved biome history after accepted breaks
- Native drops survive failed reads and runtime teardown without duplication
- Tree felling captures the original tool for fallback drops
- Modded block loot resolves one historical biome context
- Timeout settings persist atomically through the actual server configuration paths
- Tests cover deferred loot, teardown and timeout persistence
2026-10-12 09:27:26 -04:00
Brian Neumann-Fopiano 2145e90b1c Rebase 2026-10-11 10:35:59 -04:00
SwiftSwamp d480abad50 Added shared update notices
- Iris joins shared Volmit update checks and reports
- The iris.update permission controls update visibility
- general.updateNotifications defaults to enabled
- Accepted settings changes reconfigure update checks
- Shutdown removes the settings listener and closes update checks
- Tests cover persisted opt-outs and notifier lifecycle
2026-10-09 09:30:12 -06:00
391 changed files with 30732 additions and 2357 deletions
+56 -65
View File
@@ -1,87 +1,78 @@
name: Iris Bug Report
description: File a bug report for Iris
labels: [ bug ]
name: Bug report
description: Something in Iris isn't working.
labels: [bug]
type: Bug
assignees: [SwiftSwamp]
body:
- type: markdown
attributes:
value: |
Thank you for taking the time to fill this out!
If this does not work for you, feel free to use the [blank](https://github.com/VolmitSoftware/Iris/issues/new) format.
Make sure you're on the [latest release](https://github.com/VolmitSoftware/Iris/releases/latest) and that it hasn't [already been reported](https://github.com/VolmitSoftware/Iris/issues?q=is%3Aissue).
The Iris plugin supports Minecraft 26.1.2 through 26.3 on Paper, Purpur, Leaf, Canvas, Folia, and Spigot. The Fabric, Forge, and NeoForge builds support 26.3. Both need Java 25.
- type: textarea
id: how
id: issue
attributes:
label: Problem
description: Please give a text description of how you reached the problem
value: |
1. Install Iris...
2. Do this...
3. Do that...
4. Observe the error...
label: What issue are you having?
description: Include any error message you saw.
validations:
required: true
- type: textarea
id: what
id: steps
attributes:
label: Solution
description: Explain where you think the problem comes from (optional)
placeholder: The code to place a is missing b and c...
label: Steps to reproduce
placeholder: |
1. Start a server with Iris installed
2. Change ... in the config
3. Run /iris ...
4. ... happens instead of ...
validations:
required: false
required: true
- type: input
id: diagnostics
attributes:
label: Debug dump or latest log file
description: Run `/iris debug dump upload=true` and paste the link. If Iris won't start, upload `logs/latest.log` to [mclo.gs](https://mclo.gs) and paste that link instead.
placeholder: https://mclo.gs/...
validations:
required: true
- type: input
id: plugin-version
attributes:
label: "Iris version"
description: Run `/iris debug version` (`/iris version` on Fabric, Forge, and NeoForge).
validations:
required: true
- type: dropdown
id: platform
attributes:
label: Platform
description: Which server platform is Iris running on? Pick the loader or server software, not the launcher.
label: Server software
options:
- Paper
- Purpur
- Leaf
- Canvas
- Folia
- Spigot / CraftBukkit
- Fabric
- Forge
- NeoForge
- Other (describe under Problem)
validations:
required: true
- type: dropdown
id: mcversion
attributes:
label: Minecraft Version
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'
- "Paper"
- "Purpur"
- "Leaf"
- "Canvas"
- "Folia"
- "Spigot"
- "Fabric"
- "Forge"
- "NeoForge"
- Other (unsupported)
validations:
required: true
- type: input
id: loaderversion
- type: dropdown
id: minecraft
attributes:
label: Platform / Loader Version
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"
label: Minecraft version
options:
- "26.3"
- "26.2"
- "26.1.2"
- Other (unsupported)
validations:
required: true
- type: input
id: irisversion
- type: textarea
id: extra
attributes:
label: Iris Version
description: What version of Iris are you running? (see console)
placeholder: DO NOT SAY "LATEST"
validations:
required: true
- type: input
id: logs
attributes:
label: Log
description: Paste a full log. Always use [mclogs](https://mclo.gs) Or [Pastebin](https://pastebin.com/). Must not be a crash report. Must be a full log. Must not be a screenshot of a log.
placeholder: https://mslog.gs/...
validations:
required: true
- type: markdown
id: thanks
attributes:
value: "Thank you for filling out the form! We will be with you soon. Please do not ask support to review your report."
label: Anything else?
description: Other plugins involved, screenshots, or videos.
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Help and questions
url: https://volmitsoftware.com/discord
about: Ask setup and configuration questions on the Volmit Discord.
- name: "Iris documentation"
url: https://docs.volmitsoftware.com/iris
about: Installation, commands, permissions, and configuration.
+15 -33
View File
@@ -1,46 +1,28 @@
name: Iris Feature Request
description: File a feature request for Iris. If you want to report a bug this is not the place.
labels: [ feature ]
name: Suggestion
description: Suggest a new feature or a change to Iris.
labels: [enhancement]
type: Feature
assignees: [SwiftSwamp]
body:
- type: markdown
attributes:
value: |
Thank you for taking the time to fill this out!
If this does not work for you, feel free to use the [blank](https://github.com/VolmitSoftware/Iris/issues/new) format.
- type: dropdown
id: arc
Check the [existing suggestions](https://github.com/VolmitSoftware/Iris/issues?q=is%3Aissue+label%3Aenhancement) first and add relevant information to the existing issue.
- type: textarea
id: problem
attributes:
label: Adding, Removing, or Changing
description: What are you doing
options:
- Adding
- Removing
- Changing
validations:
required: true
- type: input
id: atype
attributes:
label: Type of Modification
description: What is it for?
value: I want to ...
label: What are your suggestions for new features or changes?
validations:
required: true
- type: textarea
id: desc
id: proposal
attributes:
label: What are you trying to modify
description: Give as detailed of a description as you can for the modification that you want done (include pictures if applicable)
value: The way I would implement this is ...
label: How should it work?
description: Provide as much detail as possible on what you would like changed or added.
validations:
required: true
- type: textarea
id: alternative
id: extra
attributes:
label: Alternatives
description: What alternatives have you considered?
value: If this could not be implemented I would ...
- type: markdown
id: thanks
attributes:
value: "Thank you for filling out the form! We will be with you soon. Please do not ask support to review your report."
label: Anything else?
description: Any additional information such as screenshots, examples, etc.
+55
View File
@@ -0,0 +1,55 @@
name: Translation
description: A message in Iris is wrong, missing, or unclear.
labels: [bug]
type: Bug
assignees: [SwiftSwamp]
body:
- type: markdown
attributes:
value: |
The language files are in [`core/src/main/resources/languages/`](https://github.com/VolmitSoftware/Iris/tree/HEAD/core/src/main/resources/languages).
You can create a pull request to modify the files directly.
- type: dropdown
id: language
attributes:
label: Language
options:
- English (en_US)
- German (de_DE)
- Spanish (es_ES)
- Finnish (fi_FI)
- French (fr_FR)
- Hebrew (he_IL)
- Italian (it_IT)
- Japanese (ja-JP)
- Korean (ko_KR)
- Lithuanian (lt_LT)
- Dutch (nl_NL)
- Polish (pl_PL)
- Portuguese (pt_PT)
- Russian (ru_RU)
- Turkish (tr_TR)
- Vietnamese (vi_VI)
- Chinese, Simplified (zh_CN)
- Chinese, Traditional (zh_TW)
- Another language (name it below)
validations:
required: true
- type: textarea
id: current
attributes:
label: Current text
validations:
required: true
- type: textarea
id: suggested
attributes:
label: Suggested text
validations:
required: true
- type: input
id: where
attributes:
label: Where does it appear?
description: The command used or message key where it is appearing.
+452 -296
View File
@@ -1,160 +1,215 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for software and other kinds of works.
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed to take away your freedom to share and change the
works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all
versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use
the GNU General Public License for most of our software; it applies also to any other work released this way by its
authors. You can apply it to your programs, too.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make
sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive
source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and
that you know you can do these things.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights.
Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it:
responsibilities to respect the freedom of others.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients
the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must
show them these terms so they know their rights.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute
and/or modify it.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software.
For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems
will not be attributed erroneously to authors of previous versions.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run modified versions of the software inside them, although
the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the
software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely
where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we stand ready to extend this provision to those
domains in future versions of the GPL, as needed to protect the freedom of users.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict
development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger
that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and modification follow.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "
Licensees" and
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission,
other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a
work "based on" the earlier work.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based on the Program.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily
liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy.
Propagation includes copying, distribution (with or without modification), making available to the public, and in some
countries other activities as well.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction
with a user through a computer network, with no transfer of a copy, is not conveying.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright
notice, and (2)
tells the user that there is no warranty for the work (except to the extent that warranties are provided), that
licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a
list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
1. Source Code.
The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means
any non-source form of a work.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body,
or, in the case of interfaces specified for a particular programming language, one that is widely used among developers
working in that language.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in
the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to
enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is
available to the public in source code form. A
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used
to run it.
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (
for an executable work) run the object code and to modify the work, including scripts to control those activities.
However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs
which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding
Source includes interface definition files associated with source files for the work, and the source code for shared
libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data
communication or control flow between those subprograms and other parts of the work.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the
Corresponding Source.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that same work.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
2. Basic Permissions.
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided
the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program.
The output from running a covered work is covered by this License only if the output, given its content, constitutes a
covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not convey, without conditions so long as your license
otherwise remains in force. You may convey covered works to others for the sole purpose of having them make
modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do not control copyright. Those thus making or running
the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that
prohibit them from making any copies of your copyrighted material outside their relationship with you.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not
allowed; section 10 makes it unnecessary.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling
obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or
restricting circumvention of such measures.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the
extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you
disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users,
your or third parties' legal rights to forbid circumvention of technological measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating
that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices
of the absence of any warranty; and give all recipients a copy of this License along with the Program.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for
a fee.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source
code under the terms of section 4, provided that you also meet all of these conditions:
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
@@ -177,17 +232,22 @@ code under the terms of section 4, provided that you also meet all of these cond
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of
the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or
distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the
compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause
this License to apply to the other parts of the aggregate.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License, in one of these ways:
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
@@ -230,55 +290,77 @@ machine-readable Corresponding Source under the terms of this License, in one of
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library,
need not be included in conveying the object code work.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used
for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In
determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a
particular product received by a particular user, "normally used" refers to a typical or common use of that class of
product, regardless of the status of the particular user or of the way in which the particular user actually uses, or
expects or is expected to use, the product. A product is a consumer product regardless of whether the product has
substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of
the product.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods, procedures, authorization keys, or other information
required to install and execute modified versions of a covered work in that User Product from a modified version of its
Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code
is in no case prevented or interfered with solely because modification has been made.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the
conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to
the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding
Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not
apply if neither you nor any third party retains the ability to install modified object code on the User Product (for
example, the work has been installed in ROM).
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a requirement to continue to provide support
service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product
in which it has been modified or installed. Access to a network may be denied when the modification itself materially
and adversely affects the operation of the network or violates the rules and protocols for communication across the
network.
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format
that is publicly documented (and with an implementation available to the public in source code form), and must require
no special password or key for unpacking, reading or copying.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of
its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were
included in this License, to the extent that they are valid under applicable law. If additional permissions apply only
to part of the Program, that part may be used separately under those permissions, but the entire Program remains
governed by this License without regard to the additional permissions.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or
from any part of it. (Additional permissions may be written to require their own removal in certain cases when you
modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have
or can give appropriate copyright permission.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by
the copyright holders of that material) supplement the terms of this License with terms:
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
@@ -303,182 +385,251 @@ the copyright holders of that material) supplement the terms of this License wit
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the
Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with
a term that is a further restriction, you may remove that term. If a license document contains a further restriction but
permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of
that license document, provided that the further restriction does not survive such relicensing or conveying.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a
statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as
exceptions; the above requirements apply either way.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
8. Termination.
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to
propagate or modify it is void, and will automatically terminate your rights under this License (including any patent
licenses granted under the third paragraph of section 11).
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your license from a particular copyright holder is
reinstated (a)
provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b)
permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days
after the cessation.
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you
of the violation by some reasonable means, this is the first time you have received notice of violation of this
License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the
notice.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the licenses of parties who have received copies or
rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not
qualify to receive new licenses for the same material under section 10.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a
covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not
require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered
work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run,
modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third
parties with this License.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or
subdividing an organization, or merging organizations. If propagation of a covered work results from an entity
transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work
the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with
reasonable efforts.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For
example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License,
and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using,
selling, offering for sale, or importing the Program or any portion of it.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the
Program is based. The work thus licensed is called the contributor's "contributor version".
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already
acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or
selling its contributor version, but do not include claims that would be infringed only as a consequence of further
modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent
sublicenses in a manner consistent with the requirements of this License.
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential
patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its
contributor version.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not
to enforce a patent
(such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a
patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not
available for anyone to copy, free of charge and under the terms of this License, through a publicly available network
server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available,
or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a
manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "
Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in
a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in
that country that you have reason to believe are valid.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring
conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing
them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is
automatically extended to all recipients of the covered work and works based on it.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of,
or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You
may not convey a covered work if you are a party to an arrangement with a third party that is in the business of
distributing software, under which you make payment to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a
discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from
those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered
work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to
infringement that may otherwise be available to you under applicable patent law.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this
License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to
satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence
you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further
conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License
would be to refrain entirely from conveying the Program.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work
licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the
resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special
requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply
to the combination as such.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to
time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new
problems or concerns.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the
GNU General Public License "or any later version" applies to it, you have the option of following the terms and
conditions either of that numbered version or of any later version published by the Free Software Foundation. If the
Program does not specify a version number of the GNU General Public License, you may choose any version ever published
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used,
that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the
Program.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different permissions. However, no additional obligations are imposed
on any author or copyright holder as a result of your choosing to follow a later version.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING
THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR
IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.
THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU
ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO
MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL,
INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO
LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM
TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
DAMAGES.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to
their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil
liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program
in return for a fee.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve
this is to make it free software which everyone can redistribute and change under these terms.
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to
most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer
to where the full notice is found.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
@@ -498,21 +649,26 @@ to where the full notice is found.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of
course, your program's commands might be different; for a GUI interface, you would use an "about box".
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for
the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is
a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If
this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+10 -4
View File
@@ -25,7 +25,7 @@ Canonical English is defined in the typed Java catalogs under `core/src/main/jav
| 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 |
| NeoForge | mod jar | 26.3 | Server worldgen + client HUD; requires NeoForge 26.3.0.65-beta+; current target is NeoForge 26.3.0.83 |
Java 25 is required on every platform.
@@ -252,7 +252,9 @@ Requirements: JDK 25 (set `JAVA_HOME` to it). The Gradle wrapper handles everyth
./gradlew buildAllToOut
```
builds every platform artifact into the workspace-level `../PluginOuts/` directory:
builds every platform artifact into the workspace-level `../PluginOuts/` directory: the Bukkit jar
and a Fabric, Forge and NeoForge jar for every Minecraft version in `gradle/mod-targets.json`
(1.21.1, 26.1.2, 26.2 and 26.3):
```
Iris v<version> [CraftBukkit] <mc>.jar
@@ -261,14 +263,18 @@ Iris v<version> [Forge] <mc>+<loader>.jar
Iris v<version> [NeoForge] <mc>+<loader>.jar
```
Per-platform tasks: `./gradlew buildBukkit`, `buildFabric`, `buildForge`, `buildNeoforge`. The
Per-platform tasks: `./gradlew buildBukkit`, and `build<Loader><mc>` for one mod jar, for example
`buildFabric26_2` or `buildNeoForge1_21_1`. `buildFabric`, `buildForge` and `buildNeoforge` export the
26.3 jars. `./gradlew checkAll` runs every check, then builds and verifies all twelve mod jars and runs their
unit tests; Forge 1.21.1 only compiles its tests because Forge 52 cannot bootstrap Minecraft outside
FML. The
SPI jar (the pure-JVM adapter/platform contract, not the stable plugin API) is built to `spi/build/libs/` by
`./gradlew :spi:jar`.
`./gradlew buildAll` also runs `buildAllToOut` and copies the jars into a consumer dropin tree
for a local test server. The consumer directory defaults to `../../[Minecraft Server]/consumers/`
when it exists, otherwise `build/consumers/` inside the repo. `-Plocation=/path/to/consumers`
changes only the consumer directory. Both tasks copy all four verified platform jars into
changes only the consumer directory. Both tasks copy every verified platform jar into
`../PluginOuts/`.
VolmLib is built from source when a `VolmLib` checkout sits beside the repo or in a parent directory
@@ -37,6 +37,7 @@ import art.arcane.iris.world.PendingWorldDeleteQueue;
import art.arcane.iris.world.PendingWorldReplacementManager;
import art.arcane.iris.world.WorldRefusalReporter;
import art.arcane.iris.configuration.SettingsHotloadWatch;
import art.arcane.iris.update.IrisUpdateNotifier;
import art.arcane.iris.pack.datapack.ServerConfigurator;
import art.arcane.iris.pack.datapack.DatapackIngestService;
import art.arcane.iris.pack.datapack.DatapackIngestService.StartupValidationOutcome;
@@ -132,8 +133,11 @@ import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.bukkit.event.Event;
import org.bukkit.event.EventHandler;
import org.bukkit.event.HandlerList;
import org.bukkit.event.Listener;
import org.bukkit.event.server.PluginDisableEvent;
import org.bukkit.event.server.PluginEnableEvent;
import org.bukkit.generator.BiomeProvider;
import org.bukkit.generator.ChunkGenerator;
import org.bukkit.plugin.IllegalPluginAccessException;
@@ -205,6 +209,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
private volatile PlaceholderRegistration papiRegistration;
private volatile IrisPapiListener papiListener;
private volatile IrisPapiState papiState;
private volatile boolean papiLifecycleActive;
private KMap<Class<? extends IrisService>, IrisService> services;
// Copy-on-write: mutated on the main thread during enable() and iterated by the JVM
// shutdown-hook thread during teardown; a plain list would CME and abort the teardown.
@@ -215,9 +220,9 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
private final PendingWorldDeleteQueue pendingWorldDeletes = new PendingWorldDeleteQueue(this);
private final PendingWorldReplacementManager pendingWorldReplacements = new PendingWorldReplacementManager(this);
private BukkitLanguageSwitcher languageSwitcher;
private IrisUpdateNotifier updateNotifier;
private BukkitDebugDump debugDump;
private volatile SettingsHotloadWatch settingsHotloadWatch;
private volatile Thread serverLifecycleThread;
private volatile ServerShutdownBoundary serverShutdownBoundary;
public static VolmitSender getSender() {
@@ -637,6 +642,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
languageSwitcher = BukkitLanguageSwitcher.register(this, IrisLanguage.selections(),
new BukkitLanguageSwitcher.Options("iris", "iris.all",
DirectorMiniMenu.Theme.irisGreen(), IrisLanguage.directorResolver(), IrisLanguage.editorOptions()));
updateNotifier = new IrisUpdateNotifier(this);
PaperLibBootstrap.install();
SimdSupport.install();
timings.mark("bootstrap");
@@ -825,7 +831,6 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
public void addShutdownHook() {
removeShutdownHook();
serverLifecycleThread = Thread.currentThread();
serverShutdownBoundary = INMS.isBound() ? INMS.get().createServerShutdownBoundary() : null;
shutdownHook = new Thread(this::runShutdownHook, "Iris-ShutdownHook");
try {
@@ -864,7 +869,14 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
*/
private CompletableFuture<ManagedWorldLoader.ManagedWorldLoad> loadManagedWorld(String configuredWorldName) {
return worldReconciler.loadWorld(ServerProperties.BUKKIT_YML, configuredWorldName)
.thenApply(result -> new ManagedWorldLoader.ManagedWorldLoad(result.succeeded(), result.message()));
.thenApply(this::managedWorldLoadResult);
}
private ManagedWorldLoader.ManagedWorldLoad managedWorldLoadResult(BukkitWorldReconciler.LoadResult result) {
if (!result.succeeded() && result.failure() != null) {
reportError("Could not explicitly load Iris world " + result.worldKey() + ": " + result.message(), result.failure());
}
return new ManagedWorldLoader.ManagedWorldLoad(result.succeeded(), result.message());
}
public PendingWorldReplacementManager pendingWorldReplacements() {
@@ -928,6 +940,8 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
ServerConfigurator.restartAtStartupBoundary(restartReason);
return;
}
papiLifecycleActive = true;
setupPapi();
reportLockedRuntime();
}
@@ -974,6 +988,12 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
}
public void onDisable() {
preservePendingBlockDrops();
papiLifecycleActive = false;
if (updateNotifier != null) {
updateNotifier.close();
updateNotifier = null;
}
if (debugDump != null) {
debugDump.close();
debugDump = null;
@@ -1013,6 +1033,8 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
@Override
public CompletionStage<Void> commitReload(ReloadAware.PreUnloadReason reason) {
preservePendingBlockDrops();
papiLifecycleActive = false;
teardownPapi();
if (IrisToolbelt.isServerStopping()) {
quiesceRuntimeForServerShutdown("pre-unload:" + reason);
@@ -1140,7 +1162,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
if (generator == null) {
continue;
}
IrisToolbelt.beginWorldMaintenance(world, reason, true);
IrisToolbelt.beginWorldMaintenance(world, reason);
if (!deferredShutdownGenerators.contains(generator)) {
deferredShutdownGenerators.add(generator);
}
@@ -1152,52 +1174,32 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
if (!postStopFinisherStarted.compareAndSet(false, true)) {
return;
}
Thread activeServerThread = serverLifecycleThread;
if (activeServerThread == null) {
Iris.warn("Iris could not start its post-stop runtime finisher because the server lifecycle thread is unavailable.");
return;
}
Thread finisher = new Thread(() -> {
if (!awaitServerThreadTermination(activeServerThread)) {
return;
}
finishDeferredRuntimeTeardown("post-server-stop", 30L);
}, "Iris-PostStop-Finisher");
Thread finisher = new Thread(() -> finishRuntimeAfterServerShutdown("post-server-stop"),
"Iris-PostStop-Finisher");
finisher.setDaemon(false);
finisher.start();
}
static boolean awaitServerThreadTermination(Thread serverThread) {
if (serverThread == null || serverThread == Thread.currentThread()) {
return false;
}
try {
serverThread.join();
return true;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
Iris.reportError("Iris post-stop runtime finisher was interrupted.", e);
return false;
}
}
private void runShutdownHook() {
if (startupBoundaryRestart.get()) {
finishDeferredRuntimeTeardown("startup-boundary-restart-hook", 30L);
return;
}
finishRuntimeAfterServerShutdown("shutdown-hook");
}
private void finishRuntimeAfterServerShutdown(String reason) {
if (!awaitServerShutdownBoundary()) {
SHUTDOWN_ERRORS.println("[Iris] Paper did not reach its post-world-close boundary; retaining the runtime and plugin class loader until JVM exit.");
SHUTDOWN_ERRORS.println("[Iris] The server did not reach its post-world-close boundary; retaining the runtime and plugin class loader until JVM exit.");
return;
}
finishDeferredRuntimeTeardown("shutdown-hook", 30L);
finishDeferredRuntimeTeardown(reason, 30L);
}
private boolean awaitServerShutdownBoundary() {
ServerShutdownBoundary boundary = serverShutdownBoundary;
if (boundary == null) {
return true;
return false;
}
try {
return boundary.await(
@@ -1261,6 +1263,20 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
&& preservation.hasActiveResources();
}
private void preservePendingBlockDrops() {
for (World world : Bukkit.getWorlds()) {
PlatformChunkGenerator generator = IrisToolbelt.access(world);
if (generator != null && generator.getEngine() != null
&& generator.getEngine().getWorldManager() instanceof IrisWorldManager manager) {
manager.flushPendingBlockDropsAsync().whenComplete((ignored, failure) -> {
if (failure != null) {
Iris.reportError("Failed to preserve pending block loot before Iris teardown.", failure);
}
});
}
}
}
private void drainWorldGenerators(String reason, long timeoutSeconds) {
List<World> irisWorlds = new ArrayList<>();
List<PlatformChunkGenerator> generators = new ArrayList<>();
@@ -1315,8 +1331,23 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
}
}
private void setupPapi() {
if (!PlaceholderRegistration.isPlaceholderApiEnabled()) {
@EventHandler
public void onPlaceholderApiEnable(PluginEnableEvent event) {
if ("PlaceholderAPI".equals(event.getPlugin().getName())) {
setupPapi();
}
}
@EventHandler
public void onPlaceholderApiDisable(PluginDisableEvent event) {
if ("PlaceholderAPI".equals(event.getPlugin().getName())) {
teardownPapi();
}
}
private synchronized void setupPapi() {
if (!papiLifecycleActive || papiRegistration != null
|| !PlaceholderRegistration.isPlaceholderApiEnabled()) {
return;
}
@@ -1343,7 +1374,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
papiRegistration = registration;
}
private void teardownPapi() {
private synchronized void teardownPapi() {
IrisPapiListener listener = papiListener;
papiListener = null;
@@ -1455,12 +1486,15 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
public int getIrisVersion() {
String input = getDescription().getVersion();
int hyphenIndex = input.indexOf('-');
if (hyphenIndex != -1) {
String result = input.substring(0, hyphenIndex);
result = result.replaceAll("\\.", "");
return Integer.parseInt(result);
String release = hyphenIndex < 0 ? input : input.substring(0, hyphenIndex);
if (!release.matches("[0-9]+(?:\\.[0-9]+)*")) {
return -1;
}
try {
return Integer.parseInt(release.replace(".", ""));
} catch (NumberFormatException invalid) {
return -1;
}
return -1;
}
public int getMCVersion() {
@@ -81,7 +81,6 @@ import org.bukkit.FluidCollisionMode;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.event.inventory.InventoryType;
import org.bukkit.inventory.Inventory;
import org.bukkit.util.Vector;
@@ -309,23 +308,33 @@ public class CommandStudio implements DirectorExecutor {
O<Integer> ta = new O<>();
ta.set(-1);
var sender = sender();
var player = player();
var engine = engine();
VolmitSender sender = sender();
Player player = player();
Engine engine = engine();
ta.set(J.sr(() ->
{
if (!player.getOpenInventory().getType().equals(InventoryType.CHEST)) {
ta.set(J.sr(() -> {
if (!J.runEntity(player, () -> {
if (player.getOpenInventory().getTopInventory() != inv) {
J.csr(ta.get());
sender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_STUDIO_OPENED_INVENTORY));
return;
}
if (!add) {
inv.clear();
}
Location location = player.getLocation();
try {
EngineBukkitOps.addItems(engine, true, inv, tables, InventorySlotType.STORAGE,
location.getWorld(), location.getBlockX(), location.getBlockY(), location.getBlockZ());
} catch (RuntimeException | Error failure) {
J.csr(ta.get());
Iris.reportError(failure);
sender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_CANNOT_ADD_ITEMS_VIRTUAL_INVENTORY_BECAUSE,
MessageArgument.untrusted("value", String.valueOf(failure.getMessage()))));
}
}, 0, () -> J.csr(ta.get()))) {
J.csr(ta.get());
sender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_STUDIO_OPENED_INVENTORY));
return;
}
if (!add) {
inv.clear();
}
EngineBukkitOps.addItems(engine, true, inv, tables, InventorySlotType.STORAGE, player.getWorld(), player.getLocation().getBlockX(), player.getLocation().getBlockY(), player.getLocation().getBlockZ());
}, fast ? 5 : 35));
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_INVENTORY_NOW));
@@ -0,0 +1,37 @@
package art.arcane.iris.update;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.volmlib.util.update.BukkitUpdateService;
import org.bukkit.plugin.Plugin;
import java.util.function.Consumer;
public final class IrisUpdateNotifier implements AutoCloseable {
private final Consumer<IrisSettings> settingsListener = this::reconfigure;
private final BukkitUpdateService updates;
private volatile boolean enabled;
private boolean closed;
public IrisUpdateNotifier(Plugin plugin) {
enabled = IrisSettings.get().getGeneral().isUpdateNotifications();
updates = BukkitUpdateService.register(plugin, new BukkitUpdateService.Options(
"VolmitSoftware", "Iris", "iris.update", () -> enabled));
IrisSettings.addSettingsListener(settingsListener);
reconfigure(IrisSettings.get());
}
private synchronized void reconfigure(IrisSettings settings) {
if (closed) {
return;
}
enabled = settings.getGeneral().isUpdateNotifications();
updates.reconfigure();
}
@Override
public synchronized void close() {
closed = true;
IrisSettings.removeSettingsListener(settingsListener);
updates.close();
}
}
@@ -896,14 +896,10 @@ public final class IrisWorldGeneratorResolver {
}
private static String dimensionTypeKey(IrisData data, IrisDimension dimension) {
return IrisPlatforms.get()
.registries()
.generationRegistry()
.dimensionTypeResourceKey(
data.getDataFolder().getName(),
dimension.getLoadKey(),
dimension.getDimensionTypeKey()
);
return dimension.dimensionTypeResourceKey(
IrisPlatforms.get().registries().generationRegistry(),
data.getDataFolder().getName()
);
}
private record LoadedHistory(GenerationHistory history, File activePack) {
@@ -3,19 +3,33 @@ package art.arcane.iris.world.tree;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.bukkit.event.block.BlockBreakEvent;
import org.bukkit.inventory.ItemStack;
import java.util.Collection;
import java.util.Objects;
final class RoutedBlockBreakEvent extends BlockBreakEvent implements BlockDropRouter {
private final TreeFellerSVC service;
private final FellingRun run;
private final ItemStack tool;
RoutedBlockBreakEvent(Block block, Player player, FellingRun run, TreeFellerSVC service) {
super(block, player);
this.run = run;
this.service = service;
RoutedBlockBreakEvent(Options options) {
super(options.block(), options.player());
run = options.run();
service = options.service();
tool = Objects.requireNonNull(options.tool(), "tool").clone();
}
@Override
public Collection<ItemStack> captureNativeDrops() {
return getBlock().getDrops(tool);
}
@Override
public boolean routeDrop(Object drop) {
return service.isServiceEnabled() && run.presentation.routeDrop(drop);
}
record Options(Block block, Player player, FellingRun run, TreeFellerSVC service, ItemStack tool) {
}
}
@@ -387,7 +387,8 @@ final class TreeFellingRunner {
return;
}
BlockBreakEvent probe = new RoutedBlockBreakEvent(block, run.candidate.player(), run, service);
BlockBreakEvent probe = new RoutedBlockBreakEvent(new RoutedBlockBreakEvent.Options(
block, run.candidate.player(), run, service, reservation.toolForDrops()));
service.managedEvents.add(probe);
try {
Bukkit.getPluginManager().callEvent(probe);
@@ -10,6 +10,9 @@ authors: [ cyberpwn, NextdoorPsycho, Vatuu ]
website: VolmitSoftware.com
description: More than a Dimension!
permissions:
iris.update:
description: Allows viewing Iris updates in VolmLib's combined update reports.
default: op
iris.debugdump:
description: Allows creating and optionally uploading diagnostic reports.
default: op
@@ -25,34 +28,26 @@ permissions:
dependencies:
server:
PlaceholderAPI:
load: BEFORE
load: OMIT
required: false
join-classpath: true
CraftEngine:
load: BEFORE
load: OMIT
required: false
join-classpath: true
Nexo:
load: BEFORE
load: OMIT
required: false
join-classpath: true
Oraxen:
load: BEFORE
load: OMIT
required: false
join-classpath: true
ItemsAdder:
load: BEFORE
load: OMIT
required: false
join-classpath: true
SCore:
load: BEFORE
required: false
join-classpath: true
ExecutableItems:
load: BEFORE
required: false
join-classpath: false
MythicLib:
load: OMIT
required: false
join-classpath: true
@@ -61,11 +56,7 @@ dependencies:
required: false
join-classpath: true
eco:
load: BEFORE
required: false
join-classpath: true
EcoItems:
load: BEFORE
load: OMIT
required: false
join-classpath: true
MythicMobs:
@@ -77,7 +68,7 @@ dependencies:
required: false
join-classpath: true
KGenerators:
load: BEFORE
load: OMIT
required: false
join-classpath: true
Multiverse-Core:
@@ -85,6 +76,6 @@ dependencies:
required: false
join-classpath: true
WorldEdit:
load: BEFORE
load: OMIT
required: false
join-classpath: true
@@ -3,18 +3,6 @@ version: ${version}
main: ${main}
folia-supported: true
load: STARTUP
softdepend:
- PlaceholderAPI
- CraftEngine
- Nexo
- Oraxen
- ItemsAdder
- SCore
- ExecutableItems
- eco
- EcoItems
- KGenerators
- WorldEdit
loadbefore: [ Multiverse-Core ]
authors: [ cyberpwn, NextdoorPsycho, Vatuu ]
website: VolmitSoftware.com
@@ -23,6 +11,9 @@ commands:
iris:
aliases: [ ir, irs ]
permissions:
iris.update:
description: Allows viewing Iris updates in VolmLib's combined update reports.
default: op
iris.debugdump:
description: Allows creating and optionally uploading diagnostic reports.
default: op
@@ -2,6 +2,7 @@ name: IrisNativeLifecycleFixture
version: 1.0.0
main: art.arcane.iris.fixture.NativeLifecycleFixture
api-version: '26.1.2'
folia-supported: true
depend: [Iris]
commands:
irislifecycle:
@@ -1,8 +1,12 @@
package art.arcane.iris.fixture;
import art.arcane.iris.Iris;
import art.arcane.volmlib.nativelib.NativeAdapters;
import art.arcane.iris.world.runtime.RuntimeInjection;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.structure.placement.IrisStructureLocator;
import art.arcane.iris.world.IrisCreator;
import art.arcane.iris.world.task.J;
import art.arcane.iris.world.IrisToolbelt;
import art.arcane.iris.world.IrisWorldStorage;
import art.arcane.iris.world.WorldCreatorCompat;
@@ -13,6 +17,15 @@ import org.bukkit.entity.Player;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.Material;
import org.bukkit.block.Biome;
import org.bukkit.block.Container;
import org.bukkit.loot.Lootable;
import org.bukkit.loot.LootTable;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.MapMeta;
import org.bukkit.map.MapView;
import org.bukkit.generator.structure.Structure;
import org.bukkit.util.StructureSearchResult;
import art.arcane.volmlib.nativelib.terrain.NativeTerrainAccess;
import art.arcane.volmlib.nativelib.terrain.NativeStructureReader;
import art.arcane.volmlib.nativelib.terrain.NativeWorldRuntime;
@@ -38,12 +51,15 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
private static final NamespacedKey WORLD_KEY = NamespacedKey.minecraft("native_lifecycle_probe");
private static final NamespacedKey TERRAIN_KEY = new NamespacedKey("iris", "native_terrain_probe");
private static final NamespacedKey COMMAND_KEY = new NamespacedKey("iris", "lifecycle_probe");
private static final NamespacedKey TREASURE_KEY = new NamespacedKey("iris", "treasure_map_probe");
private static final long WORLD_SEED = 78264193L;
private NativeWorldRuntime runtime;
private TreasureLootChecks treasureLootChecks;
@Override
public void onEnable() {
runtime = NativeAdapters.require(NativeWorldRuntime.class);
treasureLootChecks = new TreasureLootChecks(this);
NativeTerrainAccess terrain = NativeAdapters.require(NativeTerrainAccess.class);
getLogger().info("Native lifecycle ready: " + runtime.description()
+ "; terrain provider=" + terrain.getClass().getName());
@@ -56,12 +72,32 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (arguments.length != 1) {
if (arguments.length == 0) {
sender.sendMessage("Use /irislifecycle create|unload|status|structures|terrain-create|terrain-check|terrain-unload|lifecycle-status|lifecycle-entry");
return true;
}
try {
switch (arguments[0]) {
case "treasure-map-create" -> createTreasureMapWorld(sender);
case "treasure-map-limit" -> runTreasureRegion(sender, () -> checkTreasureMapLimit(sender));
case "treasure-map-chest" -> {
if (arguments.length != 3) {
throw new IllegalArgumentException("Use treasure-map-chest <destination> <CHEST|COPPER_CHEST>");
}
runTreasureRegion(sender, () -> prepareTreasureMapChest(sender, arguments[1], arguments[2]));
}
case "treasure-map-inspect" -> {
if (arguments.length != 2) {
throw new IllegalArgumentException("Use treasure-map-inspect <destination>");
}
runTreasureRegion(sender, () -> inspectTreasureMapChest(sender, arguments[1]));
}
case "treasure-map-check" -> runTreasureRegion(sender, () -> verifyTreasureMapLoot(sender));
case "treasure-extra-check" -> runTreasureRegion(sender, () -> treasureLootChecks.verify(sender));
case "treasure-region-check" -> runTreasureRegion(sender, () -> treasureLootChecks.verifyRegion(sender));
case "treasure-reload-before" -> runTreasureRegion(sender, () -> treasureLootChecks.verifyReload(sender, false));
case "treasure-reload-after" -> runTreasureRegion(sender, () -> treasureLootChecks.verifyReload(sender, true));
case "treasure-resources" -> treasureLootChecks.reportResourceReloads(sender);
case "create" -> create(sender);
case "unload" -> unload(sender);
case "structures" -> structures(sender);
@@ -80,6 +116,177 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
return true;
}
private void createTreasureMapWorld(CommandSender sender) {
if (!(sender instanceof Player player)) {
throw new IllegalStateException("Treasure map fixture requires a player");
}
runTreasureRegion(sender, () -> prepareTreasureMapWorld(player));
}
private void runTreasureRegion(CommandSender sender, Runnable task) {
ensureTreasureMapWorld()
.thenCompose(ignored -> J.runRegionFuture(Bukkit.getWorld(TREASURE_KEY), 0, 0, task))
.whenComplete((ignored, failure) -> {
if (failure != null) {
fail(sender, failure);
}
});
}
private CompletableFuture<?> ensureTreasureMapWorld() {
if (Bukkit.getWorld(TREASURE_KEY) != null) {
return CompletableFuture.completedFuture(null);
}
if (WorldCreatorCompat.persistentLevelRoot(TREASURE_KEY).exists()) {
return Iris.instance.worldReconciler().loadWorld(ServerProperties.BUKKIT_YML, TREASURE_KEY.toString());
}
return runAsync(() -> {
try {
new IrisCreator().name(TREASURE_KEY.toString()).dimension("treasure-map-fixture")
.seed(WORLD_SEED).create();
} catch (Exception failure) {
throw new IllegalStateException("Treasure map fixture creation failed", failure);
}
});
}
private void prepareTreasureMapWorld(Player player) {
World world = Bukkit.getWorld(TREASURE_KEY);
if (world == null || !IrisToolbelt.isIrisWorld(world) || world.getSeed() != WORLD_SEED
|| !"treasure-map-fixture".equals(IrisToolbelt.access(world).getEngine().getDimension().getLoadKey())) {
throw new IllegalStateException("Treasure map fixture world identity is invalid");
}
for (int x = 0; x <= 8; x++) {
for (int z = 0; z <= 8; z++) {
world.getBlockAt(x, 160, z).setType(Material.STONE, false);
for (int y = 161; y <= 164; y++) {
world.getBlockAt(x, y, z).setType(Material.AIR, false);
}
}
}
if (!J.runEntity(player, () -> player.teleportAsync(new Location(world, 0.5, 161, 0.5)).whenComplete((teleported, failure) -> {
if (failure != null) {
fail(player, failure);
} else if (!Boolean.TRUE.equals(teleported)) {
fail(player, new IllegalStateException("Treasure map fixture teleport failed"));
} else {
player.sendMessage("TREASURE_MAP_READY world=" + TREASURE_KEY
+ " version=" + Bukkit.getMinecraftVersion() + " folia=" + J.isFolia());
}
}))) {
throw new IllegalStateException("Could not schedule treasure map fixture teleport");
}
}
private void checkTreasureMapLimit(CommandSender sender) {
World world = Bukkit.getWorld(TREASURE_KEY);
if (world == null) {
throw new IllegalStateException("Treasure map fixture world is absent");
}
Engine engine = IrisToolbelt.access(world).getEngine();
IrisStructureLocator.LocateResult located = IrisStructureLocator.locateInPlacementRings(
engine, "minecraft:buried_treasure", 2, 2, 50, (x, z) -> true);
if (located.status() != IrisStructureLocator.LocateStatus.SEARCH_LIMIT_REACHED) {
throw new IllegalStateException("Blocked treasure placement returned " + located.status());
}
sender.sendMessage("TREASURE_MAP_LIMIT status=" + located.status());
}
private void prepareTreasureMapChest(CommandSender sender, String destination, String material) {
World world = Bukkit.getWorld(TREASURE_KEY);
treasureMapChest(world, destination, material);
sender.sendMessage("TREASURE_MAP_CHEST destination=" + destination + " material=" + material);
}
private Container treasureMapChest(World world, String destination, String material) {
requireTreasureDestination(destination);
if (!List.of("CHEST", "COPPER_CHEST").contains(material)) {
throw new IllegalArgumentException("Unknown treasure map container: " + material);
}
LootTable loot = Bukkit.getLootTable(new NamespacedKey("iris_qa", destination));
if (loot == null) {
throw new IllegalStateException("Treasure map loot table is absent: " + destination);
}
world.getBlockAt(2, 161, 2).setType(Material.AIR, false);
world.getBlockAt(2, 161, 2).setType(Material.valueOf(material), false);
Container chest = (Container) world.getBlockAt(2, 161, 2).getState();
((Lootable) chest).setLootTable(loot);
((Lootable) chest).setSeed(WORLD_SEED);
if (!chest.update(true, false)) {
throw new IllegalStateException("Treasure map loot chest was not installed");
}
return (Container) world.getBlockAt(2, 161, 2).getState();
}
private void verifyTreasureMapLoot(CommandSender sender) {
checkTreasureMapLimit(sender);
World world = Bukkit.getWorld(TREASURE_KEY);
for (String destination : List.of("blocked", "disabled", "found", "mixed")) {
Inventory inventory = treasureMapChest(world, destination, "CHEST").getInventory();
inspectTreasureMapChest(sender, destination);
inventory.remove(Material.DIAMOND);
Inventory reopened = ((Container) world.getBlockAt(2, 161, 2).getState()).getInventory();
if (reopened.contains(Material.DIAMOND)
|| !(reopened.contains(Material.FILLED_MAP) || reopened.contains(Material.MAP))) {
throw new IllegalStateException("Reopening changed " + destination + " chest loot");
}
}
sender.sendMessage("TREASURE_MAP_CHECK destinations=4 retained=true");
}
private void inspectTreasureMapChest(CommandSender sender, String destination) {
requireTreasureDestination(destination);
World world = Bukkit.getWorld(TREASURE_KEY);
Inventory inventory = ((Container) world.getBlockAt(2, 161, 2).getState()).getInventory();
ItemStack map = null;
int diamonds = 0;
for (ItemStack item : inventory.getContents()) {
if (item == null) {
continue;
}
if ((item.getType() == Material.MAP || item.getType() == Material.FILLED_MAP)
&& item.getAmount() == 1 && map == null) {
map = item;
} else if (item.getType() == Material.DIAMOND) {
diamonds += item.getAmount();
} else {
throw new IllegalStateException("Unexpected chest loot: " + item);
}
}
if (map == null || diamonds != 1) {
throw new IllegalStateException("Invalid " + destination + " chest: map=" + map + " diamonds=" + diamonds);
}
boolean expectedLocated = destination.equals("found") || destination.equals("mixed");
MapView view = map.getItemMeta() instanceof MapMeta meta && meta.hasMapView() ? meta.getMapView() : null;
if ((view != null) != expectedLocated) {
throw new IllegalStateException("Unexpected map data for " + destination + ": " + map);
}
String result = "TREASURE_MAP_INSPECT destination=" + destination + " diamonds=1 located=" + expectedLocated;
if (view != null) {
StructureSearchResult target = world.locateNearestStructure(
new Location(world, 2, 161, 2), Structure.DESERT_PYRAMID, 50, false);
if (target == null || target.getStructure() != Structure.DESERT_PYRAMID) {
throw new IllegalStateException("Map has no verified pyramid target");
}
int targetX = target.getLocation().getBlockX();
int targetZ = target.getLocation().getBlockZ();
if (view.getWorld() != world || view.getScale() != MapView.Scale.CLOSEST
|| view.getCenterX() != Math.floorDiv(targetX + 64, 128) * 128
|| view.getCenterZ() != Math.floorDiv(targetZ + 64, 128) * 128) {
throw new IllegalStateException("Map data does not cover the verified pyramid at " + target.getLocation());
}
result += " mapId=" + view.getId() + " targetX=" + targetX + " targetZ=" + targetZ
+ " centerX=" + view.getCenterX() + " centerZ=" + view.getCenterZ();
}
sender.sendMessage(result);
}
private void requireTreasureDestination(String destination) {
if (!List.of("blocked", "disabled", "found", "mixed").contains(destination)) {
throw new IllegalArgumentException("Unknown treasure map destination: " + destination);
}
}
private void lifecycleStatus(CommandSender sender) throws Exception {
World world = Bukkit.getWorld(COMMAND_KEY);
Path storage = WorldCreatorCompat.persistentLevelRoot(COMMAND_KEY).toPath();
@@ -282,11 +489,11 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
}
public boolean regionized() {
return false;
return J.isFolia();
}
public boolean primaryThread() {
return Bukkit.isPrimaryThread();
return J.isFolia() ? Bukkit.isGlobalTickThread() : Bukkit.isPrimaryThread();
}
public CompletableFuture<Void> runGlobal(Runnable task) {
@@ -299,10 +506,10 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
result.completeExceptionally(failure);
}
};
if (Bukkit.isPrimaryThread()) {
if (primaryThread()) {
wrapped.run();
} else {
Bukkit.getScheduler().runTask(this, wrapped);
} else if (!J.runGlobal(wrapped)) {
result.completeExceptionally(new IllegalStateException("Could not schedule fixture task"));
}
return result;
}
@@ -0,0 +1,259 @@
package art.arcane.iris.fixture;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.platform.bukkit.BukkitBlockResolution;
import art.arcane.iris.platform.generation.EngineBukkitOps;
import art.arcane.iris.world.IrisToolbelt;
import art.arcane.iris.world.loot.InventorySlotType;
import art.arcane.iris.world.loot.IrisVanillaLootTable;
import art.arcane.iris.world.loot.IrisLootTable;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
import art.arcane.iris.world.task.J;
import art.arcane.volmlib.util.matter.Matter;
import io.papermc.paper.event.world.StructuresLocateEvent;
import io.papermc.paper.event.server.ServerResourcesReloadedEvent;
import io.papermc.paper.math.Position;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.Container;
import org.bukkit.block.Chest;
import org.bukkit.command.CommandSender;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.world.LootGenerateEvent;
import org.bukkit.generator.structure.Structure;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.Inventory;
import org.bukkit.plugin.java.JavaPlugin;
import org.bukkit.util.StructureSearchResult;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Proxy;
import java.util.Collection;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
public final class TreasureLootChecks implements Listener {
private static final NamespacedKey WORLD_KEY = new NamespacedKey("iris", "treasure_map_probe");
private static final List<String> CHESTS = List.of(
"CHEST", "COPPER_CHEST", "EXPOSED_COPPER_CHEST", "WEATHERED_COPPER_CHEST",
"OXIDIZED_COPPER_CHEST", "WAXED_COPPER_CHEST", "WAXED_EXPOSED_COPPER_CHEST",
"WAXED_WEATHERED_COPPER_CHEST", "WAXED_OXIDIZED_COPPER_CHEST");
private final IrisVanillaLootTable reloadTable = new IrisVanillaLootTable("iris_qa:reload");
private final AtomicInteger resourceReloads = new AtomicInteger();
private LocateAction locateAction = LocateAction.PASS;
private int locateEvents;
private int lootEvents;
private boolean lootEventsOwned = true;
public TreasureLootChecks(JavaPlugin plugin) {
Bukkit.getPluginManager().registerEvents(this, plugin);
}
public void verify(CommandSender sender) {
World world = requireWorld();
verifyCopperLoot(world);
sender.sendMessage("TREASURE_COPPER_LOOT variants=8 normal=true diamonds=1");
verifyInventoryRollback(world);
sender.sendMessage("TREASURE_LOOT_ROLLBACK contents=true nativeTable=true seed=true retry=true");
verifyLocateEvents(world);
sender.sendMessage("TREASURE_LOCATE_EVENTS cancelled=true replaced=true calls=2");
sender.sendMessage("TREASURE_EXTRA_CHECK variants=9 rollback=true locateEvents=true");
}
public void verifyRegion(CommandSender sender) {
World world = requireWorld();
if (!J.isOwnedByCurrentRegion(world, 0, 0)) {
throw new IllegalStateException("Fixture does not own its loot region");
}
lootEvents = 0;
lootEventsOwned = true;
verifyCopperLoot(world);
if (lootEvents != CHESTS.size() || !lootEventsOwned) {
throw new IllegalStateException("Loot event ownership failed: count=" + lootEvents
+ " owned=" + lootEventsOwned);
}
sender.sendMessage("TREASURE_REGION_LOOT copper=8 normal=1 events=" + lootEvents
+ " owned=true folia=" + J.isFolia());
}
@EventHandler
public void onLoot(LootGenerateEvent event) {
if (event.getWorld().getKey().equals(WORLD_KEY) && event.isPlugin()) {
Location origin = event.getLootContext().getLocation();
lootEvents++;
lootEventsOwned &= J.isOwnedByCurrentRegion(event.getWorld(),
origin.getBlockX() >> 4, origin.getBlockZ() >> 4);
}
}
public void verifyReload(CommandSender sender, boolean reloaded) {
World world = requireWorld();
Material expected = reloaded ? Material.EMERALD : Material.DIAMOND;
Collection<ItemStack> items = reloadTable.getLoot(
false, 783L, InventorySlotType.STORAGE, world, 2, 161, 2);
if (items.size() != 1 || items.iterator().next().getType() != expected) {
throw new IllegalStateException("Reloaded loot did not contain " + expected + ": " + items);
}
sender.sendMessage("TREASURE_RELOAD material=" + expected);
}
public void reportResourceReloads(CommandSender sender) {
sender.sendMessage("TREASURE_RESOURCES revision=" + resourceReloads.get());
}
@EventHandler
public void onResourcesReloaded(ServerResourcesReloadedEvent event) {
resourceReloads.incrementAndGet();
}
@EventHandler
public void onLocate(StructuresLocateEvent event) {
if (!event.getWorld().getKey().equals(WORLD_KEY) || locateAction == LocateAction.PASS) {
return;
}
locateEvents++;
if (locateAction == LocateAction.CANCEL) {
event.setCancelled(true);
} else {
event.setResult(new StructuresLocateEvent.Result(Position.block(123, 100, 456), Structure.DESERT_PYRAMID));
}
}
private void verifyCopperLoot(World world) {
Engine engine = IrisToolbelt.access(world).getEngine();
Block block = world.getBlockAt(4, 161, 4);
MantleChunk<Matter> mantle = engine.getMantle().getMantle().useChunk(block.getChunk());
try {
for (String name : CHESTS) {
block.setType(Material.AIR, false);
block.setType(Material.valueOf(name), false);
if (!BukkitBlockResolution.isStorageChest(block.getBlockData())) {
throw new IllegalStateException("Chest is excluded from Iris loot: " + name);
}
EngineBukkitOps.update(engine, 4, 161, 4, block.getChunk(), mantle);
Container chest = (Container) block.getState();
int diamonds = 0;
for (ItemStack item : chest.getInventory().getContents()) {
if (item == null) {
continue;
}
if (item.getType() != Material.DIAMOND) {
throw new IllegalStateException("Unexpected " + name + " loot: " + item);
}
diamonds += item.getAmount();
}
if (diamonds != 1) {
throw new IllegalStateException(name + " contains " + diamonds + " diamonds");
}
}
} finally {
mantle.release();
}
}
private void verifyInventoryRollback(World world) {
Engine engine = IrisToolbelt.access(world).getEngine();
Block block = world.getBlockAt(6, 161, 4);
block.setType(Material.AIR, false);
block.setType(Material.CHEST, false);
Chest chest = (Chest) block.getState();
chest.getBlockInventory().setItem(0, new ItemStack(Material.EMERALD, 3));
chest = (Chest) block.getState();
NamespacedKey key = new NamespacedKey("iris_qa", "reload");
chest.setLootTable(Bukkit.getLootTable(key));
chest.setSeed(913L);
if (!chest.update(true, false)) {
throw new IllegalStateException("Could not set native loot for rollback check");
}
Inventory inventory = chest.getBlockInventory();
IllegalStateException injected = new IllegalStateException("Fixture insertion interruption");
Inventory interrupted = (Inventory) Proxy.newProxyInstance(Inventory.class.getClassLoader(),
new Class<?>[]{Inventory.class}, (proxy, method, arguments) -> {
Object result;
try {
result = method.invoke(inventory, arguments);
} catch (InvocationTargetException failure) {
throw failure.getCause();
}
if (method.getName().equals("addItem")) {
throw injected;
}
return result;
});
KList<IrisLootTable> tables = new KList<>();
tables.add(new IrisVanillaLootTable("iris_qa:reload"));
try {
EngineBukkitOps.addItems(engine, false, interrupted, tables, InventorySlotType.STORAGE,
world, 6, 161, 4);
throw new IllegalStateException("Interrupted insertion did not fail");
} catch (IllegalStateException failure) {
if (failure != injected) {
throw failure;
}
}
Chest restored = (Chest) block.getState();
if (restored.getLootTable() == null || !restored.getLootTable().getKey().equals(key)
|| restored.getSeed() != 913L) {
throw new IllegalStateException("Rollback lost the native loot table or seed");
}
ItemStack existing = restored.getSnapshotInventory().getItem(0);
if (existing == null || existing.getType() != Material.EMERALD || existing.getAmount() != 3) {
throw new IllegalStateException("Rollback lost the original contents: " + existing);
}
EngineBukkitOps.addItems(engine, false, restored.getBlockInventory(), tables, InventorySlotType.STORAGE,
world, 6, 161, 4);
int diamonds = 0;
int emeralds = 0;
for (ItemStack item : restored.getBlockInventory().getContents()) {
if (item == null) {
continue;
}
if (item.getType() == Material.DIAMOND) {
diamonds += item.getAmount();
} else if (item.getType() == Material.EMERALD) {
emeralds += item.getAmount();
}
}
if (diamonds != 2 || emeralds != 3 || ((Chest) block.getState()).getLootTable() != null) {
throw new IllegalStateException("Loot retry failed: diamonds=" + diamonds + " emeralds=" + emeralds);
}
}
private void verifyLocateEvents(World world) {
Location origin = new Location(world, 2, 161, 2);
locateEvents = 0;
try {
locateAction = LocateAction.CANCEL;
if (world.locateNearestStructure(origin, Structure.DESERT_PYRAMID, 50, false) != null) {
throw new IllegalStateException("Cancelled structure search returned a target");
}
locateAction = LocateAction.REPLACE;
StructureSearchResult result = world.locateNearestStructure(origin, Structure.DESERT_PYRAMID, 50, false);
if (result == null || result.getStructure() != Structure.DESERT_PYRAMID
|| result.getLocation().getBlockX() != 123 || result.getLocation().getBlockY() != 100
|| result.getLocation().getBlockZ() != 456 || locateEvents != 2) {
throw new IllegalStateException("Structure search ignored event result: " + result);
}
} finally {
locateAction = LocateAction.PASS;
}
}
private World requireWorld() {
World world = Bukkit.getWorld(WORLD_KEY);
if (world == null || !IrisToolbelt.isIrisWorld(world)) {
throw new IllegalStateException("Treasure map fixture world is absent");
}
return world;
}
private enum LocateAction {
PASS, CANCEL, REPLACE
}
}
@@ -0,0 +1,71 @@
import { readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { setup, openStudio, closeStudio, sampleTerrain, readJson, writeJson,
logPosition, logSince } from './studio-support.mjs'
export default {
name: 'iris-studio-hotload-lifecycle',
description: 'Generate cold chunks during Studio edits and close after saving an edit.',
async run(context) {
const fixture = await setup(context)
const biomeFile = path.join(fixture.pack, 'biomes', 'flat.json')
const original = await readFile(biomeFile, 'utf8')
const logOffset = await logPosition(fixture)
let active
let x = 16384
try {
active = await openStudio(context, fixture)
await sampleTerrain(context, fixture, 0, fixture.state.material)
for (let cycle = 0; cycle < 3; cycle++) {
await context.step(`Load cold chunks during hotload ${cycle + 1}`, async () => {
const manifestFile = fixture.manifestPath(active.key)
const before = await readJson(manifestFile)
const biome = await readJson(biomeFile)
const material = cycle % 2 === 0 ? 'gold_block' : 'diamond_block'
biome.name = `Lifecycle terrain ${cycle + 1}`
biome.layers[0].palette[0].block = `minecraft:${material}`
const startedAt = Date.now()
await writeJson(biomeFile, biome)
x += 512
await context.command(`/minecraft:tp @s ${x + 0.5} 101 0.5`, /Teleported/i, 15000)
const after = await fixture.wait(async () => {
const manifest = await readJson(manifestFile)
return manifest.activeActivationId !== before.activeActivationId ? manifest : false
}, 'Hotload did not complete while the player requested fresh chunks', 90000)
const activationDurationMs = Date.now() - startedAt
await fixture.wait(() => Math.abs(context.bot.entity.position.x - x - 0.5) < 1,
'Cold chunk teleport did not arrive after hotload', 30000)
context.report.iris.activations.push({ before: before.activeActivationId,
after: after.activeActivationId, material, coldChunkRequests: 1, activationDurationMs })
x += 2048
await sampleTerrain(context, fixture, x, material)
})
}
await context.step('Close Studio while a saved edit is activating', async () => {
const biome = await readJson(biomeFile)
biome.name = 'Lifecycle close terrain'
await writeJson(biomeFile, biome)
const pending = await fixture.wait(async () => {
const manifest = await readJson(fixture.manifestPath(active.key))
return manifest.pendingActivationId !== null ? manifest : false
}, 'The saved close edit did not enter activation', 90000)
context.report.iris.closePendingActivationId = pending.pendingActivationId
await closeStudio(context, fixture, active)
active = undefined
await context.command('/minecraft:seed', /Seed:/i, 10000)
})
await context.step('Reopen the edited pack and retain usable generation', async () => {
active = await openStudio(context, fixture)
await sampleTerrain(context, fixture, 0, 'gold_block')
await closeStudio(context, fixture, active)
active = undefined
})
const log = await logSince(fixture, logOffset)
context.expect(!/Studio generation update failed|Unable to (capture terrain|save the terrain boundary)|ClosedChannelException|Chunk system error|server has stopped responding|could not acquire its engine runtime|Failed to list files/i.test(log),
'Studio lifecycle emitted a generation, snapshot, storage, or watchdog failure')
} finally {
if (active) await closeStudio(context, fixture, active)
await writeFile(biomeFile, original)
}
}
}
@@ -0,0 +1,33 @@
{
"type": "minecraft:chest",
"pools": [
{
"rolls": 1,
"entries": [
{
"type": "minecraft:item",
"name": "minecraft:map",
"functions": [
{
"destination": "iris_qa:blocked",
"decoration": "minecraft:red_x",
"zoom": 0,
"search_radius": 50,
"skip_existing_chunks": false,
"function": "minecraft:exploration_map"
}
]
}
]
},
{
"rolls": 1,
"entries": [
{
"type": "minecraft:item",
"name": "minecraft:diamond"
}
]
}
]
}
@@ -0,0 +1,33 @@
{
"type": "minecraft:chest",
"pools": [
{
"rolls": 1,
"entries": [
{
"type": "minecraft:item",
"name": "minecraft:map",
"functions": [
{
"destination": "iris_qa:disabled",
"decoration": "minecraft:red_x",
"zoom": 0,
"search_radius": 50,
"skip_existing_chunks": false,
"function": "minecraft:exploration_map"
}
]
}
]
},
{
"rolls": 1,
"entries": [
{
"type": "minecraft:item",
"name": "minecraft:diamond"
}
]
}
]
}
@@ -0,0 +1,33 @@
{
"type": "minecraft:chest",
"pools": [
{
"rolls": 1,
"entries": [
{
"type": "minecraft:item",
"name": "minecraft:map",
"functions": [
{
"destination": "iris_qa:found",
"decoration": "minecraft:red_x",
"zoom": 0,
"search_radius": 50,
"skip_existing_chunks": false,
"function": "minecraft:exploration_map"
}
]
}
]
},
{
"rolls": 1,
"entries": [
{
"type": "minecraft:item",
"name": "minecraft:diamond"
}
]
}
]
}
@@ -0,0 +1,33 @@
{
"type": "minecraft:chest",
"pools": [
{
"rolls": 1,
"entries": [
{
"type": "minecraft:item",
"name": "minecraft:map",
"functions": [
{
"destination": "iris_qa:mixed",
"decoration": "minecraft:red_x",
"zoom": 0,
"search_radius": 50,
"skip_existing_chunks": false,
"function": "minecraft:exploration_map"
}
]
}
]
},
{
"rolls": 1,
"entries": [
{
"type": "minecraft:item",
"name": "minecraft:diamond"
}
]
}
]
}
@@ -0,0 +1,14 @@
{
"type": "minecraft:chest",
"pools": [
{
"rolls": 1,
"entries": [
{
"type": "minecraft:item",
"name": "minecraft:diamond"
}
]
}
]
}
@@ -0,0 +1,5 @@
{
"values": [
"minecraft:buried_treasure"
]
}
@@ -0,0 +1,5 @@
{
"values": [
"minecraft:mansion"
]
}
@@ -0,0 +1,5 @@
{
"values": [
"minecraft:desert_pyramid"
]
}
@@ -0,0 +1,6 @@
{
"values": [
"minecraft:buried_treasure",
"minecraft:desert_pyramid"
]
}
@@ -0,0 +1,13 @@
{
"pack": {
"description": "Treasure map gameplay fixtures",
"min_format": [
107,
1
],
"max_format": [
107,
1
]
}
}
@@ -0,0 +1,31 @@
{
"type": "minecraft:chest",
"pools": [
{
"rolls": 1,
"entries": [
{
"type": "minecraft:item",
"name": "minecraft:filled_map",
"modifier": {
"destination": "#iris_qa:blocked",
"decoration": "minecraft:red_x",
"zoom": 0,
"search_radius": 50,
"skip_existing_chunks": false,
"type": "minecraft:exploration_map"
}
}
]
},
{
"rolls": 1,
"entries": [
{
"type": "minecraft:item",
"name": "minecraft:diamond"
}
]
}
]
}
@@ -0,0 +1,31 @@
{
"type": "minecraft:chest",
"pools": [
{
"rolls": 1,
"entries": [
{
"type": "minecraft:item",
"name": "minecraft:filled_map",
"modifier": {
"destination": "#iris_qa:disabled",
"decoration": "minecraft:red_x",
"zoom": 0,
"search_radius": 50,
"skip_existing_chunks": false,
"type": "minecraft:exploration_map"
}
}
]
},
{
"rolls": 1,
"entries": [
{
"type": "minecraft:item",
"name": "minecraft:diamond"
}
]
}
]
}
@@ -0,0 +1,31 @@
{
"type": "minecraft:chest",
"pools": [
{
"rolls": 1,
"entries": [
{
"type": "minecraft:item",
"name": "minecraft:filled_map",
"modifier": {
"destination": "#iris_qa:found",
"decoration": "minecraft:red_x",
"zoom": 0,
"search_radius": 50,
"skip_existing_chunks": false,
"type": "minecraft:exploration_map"
}
}
]
},
{
"rolls": 1,
"entries": [
{
"type": "minecraft:item",
"name": "minecraft:diamond"
}
]
}
]
}
@@ -0,0 +1,31 @@
{
"type": "minecraft:chest",
"pools": [
{
"rolls": 1,
"entries": [
{
"type": "minecraft:item",
"name": "minecraft:filled_map",
"modifier": {
"destination": "#iris_qa:mixed",
"decoration": "minecraft:red_x",
"zoom": 0,
"search_radius": 50,
"skip_existing_chunks": false,
"type": "minecraft:exploration_map"
}
}
]
},
{
"rolls": 1,
"entries": [
{
"type": "minecraft:item",
"name": "minecraft:diamond"
}
]
}
]
}
@@ -0,0 +1,14 @@
{
"type": "minecraft:chest",
"pools": [
{
"rolls": 1,
"entries": [
{
"type": "minecraft:item",
"name": "minecraft:diamond"
}
]
}
]
}
@@ -0,0 +1,5 @@
{
"values": [
"minecraft:buried_treasure"
]
}
@@ -0,0 +1,5 @@
{
"values": [
"minecraft:mansion"
]
}
@@ -0,0 +1,5 @@
{
"values": [
"minecraft:desert_pyramid"
]
}
@@ -0,0 +1,6 @@
{
"values": [
"minecraft:buried_treasure",
"minecraft:desert_pyramid"
]
}
@@ -0,0 +1,13 @@
{
"pack": {
"description": "Treasure map gameplay fixtures",
"min_format": [
121,
0
],
"max_format": [
121,
0
]
}
}
@@ -0,0 +1,32 @@
{
"name": "Flat",
"derivative": "minecraft:plains",
"vanillaDerivative": "minecraft:plains",
"layers": [
{
"minHeight": 1,
"maxHeight": 1,
"palette": [
{
"block": "minecraft:lime_wool"
}
]
},
{
"minHeight": 384,
"maxHeight": 384,
"palette": [
{
"block": "minecraft:green_concrete"
}
]
}
],
"generators": [
{
"generator": "flat",
"min": 96,
"max": 96
}
]
}
@@ -0,0 +1,64 @@
{
"name": "Treasure Map Fixture",
"version": 1,
"mode": {
"type": "OVERWORLD"
},
"regions": [
"flat"
],
"focusRegion": "flat",
"focus": "flat",
"landChance": 1,
"environment": "NORMAL",
"dimensionHeight": {
"min": -64,
"max": 320
},
"logicalHeight": 384,
"fluidHeight": 0,
"bedrock": false,
"rockPalette": {
"palette": [
{
"block": "minecraft:red_concrete"
}
]
},
"importedStructures": {
"disabled": [
"minecraft:"
]
},
"structures": [
{
"placementId": "blocked-treasure",
"nativeStructures": [
{
"structure": "minecraft:buried_treasure"
}
],
"distribution": "RANDOM_SPREAD",
"spacing": 1,
"separation": 0,
"minHeight": 0,
"maxHeight": 0
},
{
"placementId": "reachable-pyramid",
"nativeStructures": [
{
"structure": "minecraft:desert_pyramid"
}
],
"distribution": "RANDOM_SPREAD",
"spacing": 16,
"separation": 4
}
],
"loot": {
"tables": [
"fixture-diamond"
]
}
}
@@ -0,0 +1,15 @@
{
"interpolator": {
"function": "NONE",
"horizontalScale": 1
},
"seed": 310,
"composite": [
{
"seed": 310,
"style": {
"style": "FLAT"
}
}
]
}
@@ -0,0 +1,15 @@
{
"name": "Fixture Diamond",
"rarity": 1,
"minPicked": 1,
"maxPicked": 1,
"maxTries": 1,
"loot": [
{
"type": "diamond",
"rarity": 1,
"minAmount": 1,
"maxAmount": 1
}
]
}
@@ -0,0 +1,12 @@
{
"name": "Flat",
"landBiomes": [
"flat"
],
"seaBiomes": [
"flat"
],
"shoreBiomes": [
"flat"
]
}
@@ -0,0 +1,211 @@
import { readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
const simplifyNbt = value => {
if (Array.isArray(value)) return value.map(simplifyNbt)
if (value === null || typeof value !== 'object') return value
if ('type' in value && 'value' in value) return simplifyNbt(value.value)
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, simplifyNbt(entry)]))
}
const completeClientTicks = context => {
const client = context.bot._client
context.expect(context.bot.supportFeature('sendsClientTickEndPacket'), 'Fixture client supports tick delimiters')
let tickTimer
const finishTick = () => {
clearTimeout(tickTimer)
client.write('tick_end', {})
tickTimer = setTimeout(finishTick, 50)
}
context.bot.on('move', finishTick)
finishTick()
context.signal.addEventListener('abort', () => {
clearTimeout(tickTimer)
context.bot.removeListener('move', finishTick)
}, { once: true })
}
export default {
name: 'iris-treasure-maps',
description: 'Verify exploration-map targets, bounded misses, retained loot, and normal/copper storage on Iris.',
async run(context) {
completeClientTicks(context)
context.bot.physicsEnabled = false
context.bot.clearControlStates()
context.bot.entity.velocity.set(0, 0, 0)
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
context.expect(/^isolated=true\s*$/m.test(source), 'Treasure map fixtures require an isolated instance')
const command = async (operation, expected, timeoutMs = 30000) => {
const token = expected.split(' ')[0]
const response = await context.command(`/irislifecycle ${operation}`,
new RegExp(`${token}|NATIVE_FAILED|TREASURE_.*_FAILED`), timeoutMs)
context.expect(response.includes(expected), response)
return response
}
const bounded = (label, action) => context.waitUntil(async () => {
await action()
return true
}, { label, timeoutMs: 10000 })
let lastActivation = 0
const openChest = async material => {
const position = context.bot.entity.position.clone().set(2, 161, 2)
await context.waitUntil(() => context.bot.blockAt(position)?.name === material, {
label: `${material} reached the client`, timeoutMs: 10000
})
const delay = 150 - (performance.now() - lastActivation)
if (delay > 0) await context.sleep(delay)
const opened = context.waitForEvent('windowOpen', undefined, 30000)
await bounded(`Activate ${material}`, () => context.bot.activateBlock(context.bot.blockAt(position)))
lastActivation = performance.now()
const [window] = await opened
return window
}
const chestItems = window => window.slots.slice(0, window.inventoryStart).filter(Boolean)
const mapId = item => item.componentMap?.get('map_id')?.data
let missItem
await context.step('create and enter the Iris treasure map world', async () => {
const response = await command('treasure-map-create', 'TREASURE_MAP_READY world=iris:treasure_map_probe', 180000)
const version = response.match(/version=(\S+)/)?.[1]
context.expect(/^26\.[23](?:[.-]|$)/.test(version ?? ''), 'Fixture requires its matching 26.2 or 26.3 data pack', { version })
missItem = version.startsWith('26.2') ? 'map' : 'filled_map'
context.report.treasurePlatform = { version, folia: response.includes('folia=true'), missItem }
await context.waitUntil(() => context.bot.entity.position.distanceTo(
context.bot.entity.position.clone().set(0.5, 161, 0.5)
) < 2, { label: 'Fixture teleport reached the client', timeoutMs: 30000 })
})
await context.step('confirm the missing target exhausts the placement search budget', async () => {
await command('treasure-map-limit', 'TREASURE_MAP_LIMIT status=SEARCH_LIMIT_REACHED', 120000)
})
for (const material of ['chest', 'copper_chest']) {
for (const destination of ['blocked', 'disabled', 'found', 'mixed']) {
await context.step(`open ${material} with ${destination} map target and retain all loot`, async () => {
const evidence = { destination, material, phase: 'prepare' }
context.report.treasureMaps ??= []
context.report.treasureMaps.push(evidence)
const located = destination === 'found' || destination === 'mixed'
const mapName = located ? 'filled_map' : missItem
await command(`treasure-map-chest ${destination} ${material.toUpperCase()}`,
`TREASURE_MAP_CHEST destination=${destination} material=${material.toUpperCase()}`)
evidence.phase = 'open'
const window = await openChest(material)
evidence.windowType = window.type
evidence.inventoryStart = window.inventoryStart
let targetId
let diamondType
let diamondsBefore
let marker
let verified
try {
await context.waitUntil(() => {
const items = chestItems(window)
return items.some(item => item.name === mapName) && items.some(item => item.name === 'diamond')
}, { label: `${destination} chest contains map and diamond`, timeoutMs: 30000 })
const items = chestItems(window)
const map = items.find(item => item.name === mapName)
const diamond = items.find(item => item.name === 'diamond')
targetId = mapId(map)
context.expect(items.length === 2 && map.count === 1 && diamond.count === 1,
'Loot table must produce exactly one map and one diamond')
context.expect((targetId !== undefined) === located, 'Only reachable destinations receive map data', {
destination, components: map.components
})
const response = await command(`treasure-map-inspect ${destination}`,
`TREASURE_MAP_INSPECT destination=${destination} diamonds=1 located=${located}`, 120000)
verified = Object.fromEntries([...response.matchAll(/(mapId|targetX|targetZ|centerX|centerZ)=(-?\d+)/g)]
.map(([, key, value]) => [key, Number(value)]))
const decorations = simplifyNbt(map.componentMap?.get('map_decorations')?.data)
marker = decorations?.['+']
if (located) {
context.expect(targetId === verified.mapId, 'Client and saved map IDs agree', { targetId, verified })
context.expect(marker?.type === 'minecraft:red_x'
&& marker.x === verified.targetX && marker.z === verified.targetZ,
'Map marker points to the verified pyramid', { marker, verified, components: map.components })
} else {
context.expect(marker === undefined, 'Missing destinations must not invent a map marker', { decorations })
}
diamondType = diamond.type
diamondsBefore = context.bot.inventory.count(diamondType)
evidence.phase = 'withdraw'
evidence.diamondSlot = diamond.slot
await bounded(`Withdraw diamond from ${material} ${destination}`,
() => context.bot.clickWindow(diamond.slot, 0, 1))
evidence.phase = 'withdrawn'
await context.waitUntil(() => chestItems(window).every(item => item.name !== 'diamond'), {
label: 'Diamond transferred out of the chest', timeoutMs: 10000
})
} finally {
context.bot.closeWindow(window)
}
evidence.phase = 'reopen'
const reopened = await openChest(material)
try {
await context.waitUntil(() => chestItems(reopened).some(item => item.name === mapName), {
label: 'Saved map remains in the reopened chest', timeoutMs: 10000
})
const retained = chestItems(reopened)
context.expect(retained.length === 1 && retained[0].name === mapName && retained[0].count === 1,
'Reopening must not regenerate chest loot')
context.expect(mapId(retained[0]) === targetId, 'Reopening must retain the same map data')
context.expect(JSON.stringify(simplifyNbt(retained[0].componentMap?.get('map_decorations')?.data)?.['+'])
=== JSON.stringify(marker), 'Reopening must retain the same destination marker')
await context.waitUntil(() => context.bot.inventory.count(diamondType) === diamondsBefore + 1, {
label: 'Chest awards exactly one diamond', timeoutMs: 10000
})
Object.assign(evidence, { phase: 'complete', mapItem: mapName,
mapId: targetId ?? null, marker: marker ?? null, verified, reopened: true })
} finally {
context.bot.closeWindow(reopened)
}
})
}
}
await context.step('verify storage variants, insertion rollback, and locate event results', async () => {
await command('treasure-extra-check', 'TREASURE_EXTRA_CHECK variants=9 rollback=true locateEvents=true', 120000)
})
await context.step('verify plugin loot runs on its owning region', async () => {
await command('treasure-region-check', 'TREASURE_REGION_LOOT copper=8 normal=1 events=9 owned=true', 120000)
})
await context.step('verify native loot table reload support', async () => {
await command('treasure-reload-before', 'TREASURE_RELOAD material=DIAMOND')
if (context.report.treasurePlatform.folia) {
const response = await context.command('/minecraft:reload', /Unknown or incomplete command|Reloading/i, 10000)
context.expect(response.includes('Unknown or incomplete command'),
'Folia fixture expects its unavailable data pack reload command', { response })
context.report.treasureReload = { status: 'unavailable', before: 'diamond', evidence: response }
return
}
const properties = await readFile(path.join(context.server.directory, 'server.properties'), 'utf8')
const levelName = properties.match(/^level-name=(.+)$/m)?.[1].trim() ?? 'world'
const tablePath = path.join(context.server.directory, levelName, 'datapacks', 'iris_treasure',
'data', 'iris_qa', 'loot_table', 'reload.json')
const original = await readFile(tablePath, 'utf8')
const replacement = JSON.parse(original)
context.expect(replacement.pools[0].entries[0].name === 'minecraft:diamond',
'Reload fixture must start with its diamond table')
const revision = async () => {
const response = await command('treasure-resources', 'TREASURE_RESOURCES revision=')
return Number(response.match(/revision=(\d+)/)?.[1])
}
const reload = async () => {
const before = await revision()
await context.command('/minecraft:reload', /Reloading/i, 30000)
await context.waitUntil(async () => await revision() > before, {
label: 'Server resource reload completed', timeoutMs: 120000, intervalMs: 500
})
}
replacement.pools[0].entries[0].name = 'minecraft:emerald'
try {
await writeFile(tablePath, `${JSON.stringify(replacement, null, 2)}\n`)
await reload()
await command('treasure-reload-after', 'TREASURE_RELOAD material=EMERALD')
context.report.treasureReload = { status: 'passed', before: 'diamond', after: 'emerald' }
} finally {
await writeFile(tablePath, original)
if (!context.signal.aborted) {
await reload()
await command('treasure-reload-before', 'TREASURE_RELOAD material=DIAMOND')
}
}
})
}
}
@@ -0,0 +1,230 @@
package art.arcane.iris;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.GenerationSessionLease;
import art.arcane.iris.generation.runtime.GenerationSessionManager;
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
import art.arcane.iris.platform.bukkit.nms.INMS;
import art.arcane.iris.platform.bukkit.nms.INMSBinding;
import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.platform.generation.PlatformChunkGenerator;
import art.arcane.iris.studio.view.PregeneratorJob;
import art.arcane.iris.testsupport.PlatformBinding;
import art.arcane.iris.world.IrisToolbelt;
import art.arcane.iris.world.IrisWorld;
import art.arcane.iris.world.WorldMaintenance;
import art.arcane.iris.world.runtime.BukkitEnginePlatformHooks;
import art.arcane.iris.world.task.J;
import art.arcane.volmlib.nativelib.terrain.ServerShutdownBoundary;
import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey;
import org.bukkit.World;
import org.junit.ClassRule;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
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;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class IrisShutdownMaintenanceTest {
@ClassRule
public static final PlatformBinding PLATFORM = PlatformBinding.mockPlatform();
@Test
public void finisherRetainsResourcesWhileTheAuthoritativeBoundaryIsPending() throws Exception {
try (Fixture fixture = new Fixture()) {
ServerShutdownBoundary boundary = mock(ServerShutdownBoundary.class);
CountDownLatch waiting = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
AtomicReference<Thread> finisher = new AtomicReference<>();
when(boundary.await(anyLong(), eq(TimeUnit.SECONDS))).thenAnswer(invocation -> {
finisher.set(Thread.currentThread());
waiting.countDown();
assertTrue(release.await(5, TimeUnit.SECONDS));
return false;
});
setField(fixture.plugin, "serverShutdownBoundary", boundary);
fixture.quiesce();
fixture.startFinisher();
try {
assertTrue(waiting.await(5, TimeUnit.SECONDS));
fixture.startFinisher();
verify(fixture.generator, never()).closeAsync();
verify(fixture.binding, never()).releasePluginClassLoaderClose();
} finally {
release.countDown();
Thread running = finisher.get();
if (running != null) {
running.join(5_000L);
assertFalse(running.isAlive());
}
}
verify(boundary, times(1)).await(anyLong(), eq(TimeUnit.SECONDS));
verify(fixture.generator, never()).closeAsync();
}
}
@Test
public void missingShutdownBoundaryDoesNotAuthorizeResourceRelease() throws Exception {
try (Fixture fixture = new Fixture()) {
Method method = Iris.class.getDeclaredMethod("awaitServerShutdownBoundary");
method.setAccessible(true);
assertEquals(false, method.invoke(fixture.plugin));
}
}
@Test
public void serverStopKeepsFoliaGenerationAdmittedUntilTheServerDrains() throws Exception {
try (Fixture fixture = new Fixture()) {
GenerationSessionManager sessions = new GenerationSessionManager();
try (GenerationSessionLease active = sessions.acquireForEngine(fixture.engine, "active_chunk")) {
fixture.quiesce();
assertTrue(WorldMaintenance.isWorldMaintenanceActive(fixture.identity));
assertFalse(fixture.hooks.shouldRefuseGeneration(fixture.engine));
try (GenerationSessionLease queued = sessions.acquireForEngine(fixture.engine, "queued_chunk")) {
assertEquals(active.sessionId(), queued.sessionId());
assertEquals(2, sessions.activeLeases());
}
verify(fixture.generator).quiesceForServerShutdown();
verify(fixture.generator, never()).closeAsync();
fixture.nms.verify(INMS::get);
verify(fixture.binding).deferPluginClassLoaderClose();
verify(fixture.binding, never()).releasePluginClassLoaderClose();
}
assertEquals(0, sessions.activeLeases());
fixture.drain();
verify(fixture.generator).closeAsync();
}
}
@Test
public void serverStopDoesNotOverrideExistingBlockingMaintenance() throws Exception {
try (Fixture fixture = new Fixture()) {
WorldMaintenance.beginWorldMaintenance(fixture.identity, "world_unload", true);
try {
fixture.quiesce();
assertTrue(fixture.hooks.shouldRefuseGeneration(fixture.engine));
} finally {
WorldMaintenance.endWorldMaintenance(fixture.identity, "world_unload", true);
}
assertFalse(fixture.hooks.shouldRefuseGeneration(fixture.engine));
}
}
@Test
public void manualDisableStillBlocksGenerationBeforeClosingTheGenerator() throws Exception {
try (Fixture fixture = new Fixture()) {
when(fixture.generator.closeAsync()).thenAnswer(invocation -> {
assertTrue(fixture.hooks.shouldRefuseGeneration(fixture.engine));
return CompletableFuture.completedFuture(null);
});
try {
fixture.drain();
assertTrue(fixture.hooks.shouldRefuseGeneration(fixture.engine));
verify(fixture.generator).closeAsync();
verify(fixture.generator, never()).quiesceForServerShutdown();
} finally {
WorldMaintenance.endWorldMaintenance(fixture.identity, "manual_disable", true);
}
}
}
private static void setField(Iris plugin, String name, Object value) throws ReflectiveOperationException {
Field field = Iris.class.getDeclaredField(name);
field.setAccessible(true);
field.set(plugin, value);
}
private static final class Fixture implements AutoCloseable {
private final Iris plugin = mock(Iris.class);
private final World world = mock(World.class);
private final IrisWorld irisWorld = mock(IrisWorld.class);
private final Engine engine = mock(Engine.class);
private final PlatformChunkGenerator generator = mock(PlatformChunkGenerator.class);
private final BukkitEnginePlatformHooks hooks = new BukkitEnginePlatformHooks();
private final INMSBinding binding = mock(INMSBinding.class);
private final String identity = "iris:shutdown_test";
private final IrisSettings previousSettings = IrisSettings.settings;
private final MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
private final MockedStatic<IrisToolbelt> toolbelt = mockStatic(IrisToolbelt.class, CALLS_REAL_METHODS);
private final MockedStatic<BukkitWorldBinding> worlds = mockStatic(BukkitWorldBinding.class);
private final MockedStatic<IrisServices> services = mockStatic(IrisServices.class);
private final MockedStatic<INMS> nms = mockStatic(INMS.class);
private final MockedStatic<J> tasks = mockStatic(J.class, CALLS_REAL_METHODS);
private final MockedStatic<PregeneratorJob> pregenerator = mockStatic(PregeneratorJob.class);
private int quiesces;
private Fixture() throws ReflectiveOperationException {
IrisSettings.settings = new IrisSettings();
setField(plugin, "serverStopTeardownDeferred", new AtomicBoolean());
setField(plugin, "postStopFinisherStarted", new AtomicBoolean());
setField(plugin, "deferredShutdownGenerators", new CopyOnWriteArrayList<PlatformChunkGenerator>());
when(world.getKey()).thenReturn(NamespacedKey.fromString(identity));
when(engine.getWorld()).thenReturn(irisWorld);
when(irisWorld.hasPlatformWorld()).thenReturn(true);
when(irisWorld.identity()).thenReturn(identity);
when(generator.closeAsync()).thenReturn(CompletableFuture.completedFuture(null));
bukkit.when(Bukkit::getWorlds).thenReturn(List.of(world));
toolbelt.when(() -> IrisToolbelt.access(world)).thenReturn(generator);
worlds.when(() -> BukkitWorldBinding.world(irisWorld)).thenReturn(world);
nms.when(INMS::get).thenReturn(binding);
tasks.when(J::isFolia).thenReturn(true);
}
private void quiesce() throws ReflectiveOperationException {
Method method = Iris.class.getDeclaredMethod("quiesceRuntimeForServerShutdown", String.class);
method.setAccessible(true);
method.invoke(plugin, "server_stop");
quiesces++;
}
private void drain() throws ReflectiveOperationException {
Method method = Iris.class.getDeclaredMethod("drainWorldGenerators", String.class, long.class);
method.setAccessible(true);
method.invoke(plugin, "manual_disable", 5L);
}
private void startFinisher() throws ReflectiveOperationException {
Method method = Iris.class.getDeclaredMethod("startPostStopFinisher");
method.setAccessible(true);
method.invoke(plugin);
}
@Override
public void close() {
for (int index = 0; index < quiesces; index++) {
WorldMaintenance.endWorldMaintenance(identity, "server_stop");
}
pregenerator.close();
tasks.close();
nms.close();
services.close();
worlds.close();
toolbelt.close();
bukkit.close();
IrisSettings.settings = previousSettings;
}
}
}
@@ -0,0 +1,67 @@
package art.arcane.iris;
import org.bukkit.Bukkit;
import org.bukkit.plugin.PluginDescriptionFile;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.io.InputStream;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertNotNull;
import static org.mockito.Answers.CALLS_REAL_METHODS;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
public class IrisVersionNumberTest {
@Test
public void releaseAndQualifiedBuildsUseTheSameVersionNumber() {
for (String version : List.of("4.3.3", "4.3.3-SNAPSHOT", "4.3.3-test")) {
assertEquals(version, 433, plugin(version).getIrisVersion());
}
}
@Test
public void processedPluginMetadataProvidesAValidEngineVersion() throws Exception {
try (InputStream stream = getClass().getResourceAsStream("/plugin.yml")) {
assertNotNull(stream);
PluginDescriptionFile description = new PluginDescriptionFile(stream);
Iris plugin = mock(Iris.class, CALLS_REAL_METHODS);
doReturn(description).when(plugin).getDescription();
assertNotEquals(-1, plugin.getIrisVersion());
}
}
@Test
public void malformedAndOverflowingReleaseNumbersStayInvalid() {
for (String version : List.of("development", "4..3", ".4.3.3", "4.3.3.", "4.3a.3-test",
"2147483648", "999999999999999999999-SNAPSHOT", "-SNAPSHOT")) {
assertEquals(version, -1, plugin(version).getIrisVersion());
}
}
@Test
public void supportedMinecraftVersionsRetainTheirEngineNumbers() {
Iris plugin = mock(Iris.class, CALLS_REAL_METHODS);
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class)) {
bukkit.when(Bukkit::getVersion).thenReturn("26.1.2-74-ver/26.1.2@e4e17fc (MC: 26.1.2)");
assertEquals(2612, plugin.getMCVersion());
bukkit.when(Bukkit::getVersion).thenReturn("26.1.2-8-ver/26.1.x@62dc0f2 (MC: 26.1.2)");
assertEquals(2612, plugin.getMCVersion());
bukkit.when(Bukkit::getVersion).thenReturn("26.2-133 (MC: 26.2)");
assertEquals(262, plugin.getMCVersion());
bukkit.when(Bukkit::getVersion).thenReturn("26.3-169 (MC: 26.3)");
assertEquals(263, plugin.getMCVersion());
}
}
private static Iris plugin(String version) {
Iris plugin = mock(Iris.class, CALLS_REAL_METHODS);
doReturn(new PluginDescriptionFile("Iris", version, Iris.class.getName())).when(plugin).getDescription();
return plugin;
}
}
@@ -20,33 +20,20 @@ import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
public class PaperPluginMetadataTest {
private static final List<String> OPTIONAL_PLUGIN_IDS = List.of(
private static final List<String> JOINED_PLUGIN_IDS = List.of(
"PlaceholderAPI",
"CraftEngine",
"Nexo",
"Oraxen",
"ItemsAdder",
"SCore",
"ExecutableItems",
"MythicLib",
"MMOItems",
"eco",
"EcoItems",
"MythicMobs",
"MythicCrucible",
"KGenerators",
"Multiverse-Core",
"WorldEdit"
);
private static final List<String> JOINED_PLUGIN_IDS = OPTIONAL_PLUGIN_IDS.stream()
.filter(pluginId -> !"ExecutableItems".equals(pluginId) && !"Multiverse-Core".equals(pluginId))
.toList();
private static final List<String> UNORDERED_PLUGIN_IDS = List.of(
"MythicLib", "MMOItems", "MythicMobs", "MythicCrucible");
private static final List<String> BUKKIT_SOFT_DEPEND_IDS = OPTIONAL_PLUGIN_IDS.stream()
.filter(pluginId -> !"Multiverse-Core".equals(pluginId))
.filter(pluginId -> !UNORDERED_PLUGIN_IDS.contains(pluginId))
.toList();
@Test
public void paperMetadataDeclaresBootstrapAndFoliaSupport() throws Exception {
@@ -75,10 +62,13 @@ public class PaperPluginMetadataTest {
+ " description: Allows survival players to fell Iris-managed trees with an axe.\n"
+ " default: op"));
for (String pluginId : JOINED_PLUGIN_IDS) {
String loadOrder = UNORDERED_PLUGIN_IDS.contains(pluginId) ? "OMIT" : "BEFORE";
assertTrue(metadata.contains(optionalDependencyBlock(pluginId, loadOrder, true)));
assertTrue(metadata.contains(optionalDependencyBlock(pluginId, "OMIT", true)));
}
assertTrue(metadata.contains(optionalDependencyBlock("ExecutableItems", "BEFORE", false)));
assertFalse(metadata.contains(" ExecutableItems:"));
assertFalse(metadata.contains(" EcoItems:"));
assertFalse(metadata.contains(" MythicLib:"));
assertEquals(1, metadata.lines().filter(line -> line.equals(" load: AFTER")).count());
assertEquals(0, metadata.lines().filter(line -> line.equals(" load: BEFORE")).count());
assertTrue(metadata.contains(optionalDependencyBlock("Multiverse-Core", "AFTER", true)));
}
@@ -95,7 +85,7 @@ public class PaperPluginMetadataTest {
Map<String, Map<String, Object>> commands = metadata.getCommands();
assertTrue(commands.containsKey("iris"));
assertEquals(List.of("ir", "irs"), commands.get("iris").get("aliases"));
assertEquals(BUKKIT_SOFT_DEPEND_IDS, metadata.getSoftDepend());
assertTrue(metadata.getSoftDepend().isEmpty());
assertEquals(List.of("Multiverse-Core"), metadata.getLoadBeforePlugins());
Permission all = metadata.getPermissions().stream()
.filter(permission -> "iris.all".equals(permission.getName()))
@@ -0,0 +1,103 @@
package art.arcane.iris.integration;
import art.arcane.iris.Iris;
import art.arcane.volmlib.util.bukkit.papi.PlaceholderRegistration;
import org.bukkit.Bukkit;
import org.bukkit.event.HandlerList;
import org.bukkit.event.server.PluginDisableEvent;
import org.bukkit.event.server.PluginEnableEvent;
import org.bukkit.plugin.Plugin;
import org.bukkit.plugin.PluginManager;
import org.junit.Test;
import org.mockito.MockedConstruction;
import org.mockito.MockedStatic;
import java.lang.reflect.Field;
import java.util.logging.Logger;
import static org.junit.Assert.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doCallRealMethod;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockConstruction;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class IrisPapiLifecycleTest {
@Test
public void lateEnableRegistersOnceAndReattachesAfterDisable() throws Exception {
Iris iris = plugin(true);
Plugin placeholders = namedPlugin("PlaceholderAPI");
PluginManager plugins = mock(PluginManager.class);
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
MockedStatic<HandlerList> handlers = mockStatic(HandlerList.class);
MockedStatic<PlaceholderRegistration> availability = mockStatic(PlaceholderRegistration.class);
MockedStatic<IrisPapiInstaller> installer = mockStatic(IrisPapiInstaller.class);
MockedConstruction<PlaceholderRegistration> registrations = mockConstruction(PlaceholderRegistration.class)) {
bukkit.when(Bukkit::getPluginManager).thenReturn(plugins);
availability.when(PlaceholderRegistration::isPlaceholderApiEnabled).thenReturn(true);
installer.when(() -> IrisPapiInstaller.install(any(), any(), any())).thenReturn(true);
iris.onPlaceholderApiEnable(new PluginEnableEvent(placeholders));
iris.onPlaceholderApiEnable(new PluginEnableEvent(placeholders));
assertEquals(1, registrations.constructed().size());
verify(plugins).registerEvents(any(IrisPapiListener.class), eq(iris));
iris.onPlaceholderApiDisable(new PluginDisableEvent(namedPlugin("Unrelated")));
verify(registrations.constructed().getFirst(), never()).unregister();
iris.onPlaceholderApiDisable(new PluginDisableEvent(placeholders));
verify(registrations.constructed().getFirst()).unregister();
handlers.verify(() -> HandlerList.unregisterAll(any(IrisPapiListener.class)));
iris.onPlaceholderApiEnable(new PluginEnableEvent(placeholders));
assertEquals(2, registrations.constructed().size());
verify(plugins, times(2)).registerEvents(any(IrisPapiListener.class), eq(iris));
}
}
@Test
public void enableEventsCannotRegisterBeforeIrisIsReadyOrDuringShutdown() throws Exception {
Iris iris = plugin(false);
Plugin placeholders = namedPlugin("PlaceholderAPI");
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
MockedStatic<IrisPapiInstaller> installer = mockStatic(IrisPapiInstaller.class)) {
iris.onPlaceholderApiEnable(new PluginEnableEvent(placeholders));
installer.verifyNoInteractions();
}
}
@Test
public void unrelatedEnableEventsDoNotRegisterPlaceholders() throws Exception {
Iris iris = plugin(true);
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
MockedStatic<IrisPapiInstaller> installer = mockStatic(IrisPapiInstaller.class)) {
iris.onPlaceholderApiEnable(new PluginEnableEvent(namedPlugin("Unrelated")));
installer.verifyNoInteractions();
}
}
private static Iris plugin(boolean active) throws ReflectiveOperationException {
Iris iris = mock(Iris.class);
when(iris.getLogger()).thenReturn(Logger.getAnonymousLogger());
doCallRealMethod().when(iris).onPlaceholderApiEnable(any());
doCallRealMethod().when(iris).onPlaceholderApiDisable(any());
Field lifecycle = Iris.class.getDeclaredField("papiLifecycleActive");
lifecycle.setAccessible(true);
lifecycle.setBoolean(iris, active);
return iris;
}
private static Plugin namedPlugin(String name) {
Plugin plugin = mock(Plugin.class);
when(plugin.getName()).thenReturn(name);
return plugin;
}
}
@@ -0,0 +1,61 @@
package art.arcane.iris.update;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.volmlib.util.update.BukkitUpdateService;
import org.bukkit.plugin.Plugin;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
public class IrisUpdateNotifierTest {
@Test
public void acceptedSettingsReconfigureChecksAndClosingRemovesTheListener() {
IrisSettings previous = IrisSettings.settings;
IrisSettings.settings = new IrisSettings();
Plugin plugin = mock(Plugin.class);
BukkitUpdateService service = mock(BukkitUpdateService.class);
try (MockedStatic<BukkitUpdateService> registration = mockStatic(BukkitUpdateService.class)) {
registration.when(() -> BukkitUpdateService.register(eq(plugin), any())).thenReturn(service);
IrisUpdateNotifier notifier = new IrisUpdateNotifier(plugin);
try {
ArgumentCaptor<BukkitUpdateService.Options> options = ArgumentCaptor.forClass(BukkitUpdateService.Options.class);
registration.verify(() -> BukkitUpdateService.register(eq(plugin), options.capture()));
assertEquals("VolmitSoftware", options.getValue().owner());
assertEquals("Iris", options.getValue().repository());
assertEquals("iris.update", options.getValue().permission());
assertTrue(options.getValue().enabled().getAsBoolean());
clearInvocations(service);
assertFalse(IrisSettings.applyHotloadSnapshot(
"{\"general\":{\"updateNotifications\":false}}", candidate -> false));
assertTrue(options.getValue().enabled().getAsBoolean());
verifyNoInteractions(service);
IrisSettings.installHotloadSnapshot("{\"general\":{\"updateNotifications\":false}}");
assertFalse(options.getValue().enabled().getAsBoolean());
verify(service).reconfigure();
IrisSettings.settings.getGeneral().setUpdateNotifications(true);
assertFalse(options.getValue().enabled().getAsBoolean());
} finally {
notifier.close();
}
verify(service).close();
clearInvocations(service);
IrisSettings.installHotloadSnapshot("{}");
verifyNoInteractions(service);
} finally {
IrisSettings.settings = previous;
}
}
}
@@ -0,0 +1,41 @@
package art.arcane.iris.world.tree;
import org.bukkit.Material;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.junit.Test;
import java.util.Collection;
import java.util.List;
import static org.junit.Assert.assertSame;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
public class RoutedBlockBreakEventTest {
@Test
public void nativeLootUsesTheReservedToolCloneWithoutReadingPlayerInventory() {
Block block = mock(Block.class);
Player player = mock(Player.class);
ItemStack reservedTool = mock(ItemStack.class);
ItemStack capturedTool = mock(ItemStack.class);
ItemStack drop = mock(ItemStack.class);
Collection<ItemStack> expected = List.of(drop);
when(reservedTool.clone()).thenReturn(capturedTool);
when(block.getDrops(capturedTool)).thenReturn(expected);
RoutedBlockBreakEvent event = new RoutedBlockBreakEvent(new RoutedBlockBreakEvent.Options(
block, player, mock(FellingRun.class), mock(TreeFellerSVC.class), reservedTool));
reservedTool.setType(Material.AIR);
assertSame(expected, event.captureNativeDrops());
verify(reservedTool).clone();
verify(block).getDrops(capturedTool);
verify(block, never()).getDrops(reservedTool);
verifyNoInteractions(player);
}
}
+66 -16
View File
@@ -22,6 +22,7 @@ import org.gradle.api.file.FileCopyDetails
import org.gradle.api.file.FileSystemOperations
import org.gradle.api.file.FileTree
import org.gradle.api.tasks.compile.JavaCompile
import org.gradle.api.attributes.Bundling
import org.gradle.jvm.toolchain.JavaLanguageVersion
import javax.inject.Inject
@@ -38,10 +39,14 @@ buildscript {
plugins {
id 'java'
id 'net.fabricmc.fabric-loom' version '1.17.14'
id 'net.fabricmc.fabric-loom' version '1.17.21' apply false
id 'net.fabricmc.fabric-loom-remap' version '1.17.21' apply false
alias(libs.plugins.shadow)
}
apply from: file('../../gradle/mod-target.gradle')
apply plugin: irisModTarget.fabricRemap ? 'net.fabricmc.fabric-loom-remap' : 'net.fabricmc.fabric-loom'
apply plugin: 'art.arcane.volmit-packaging'
interface IrisFileOperations {
@@ -56,9 +61,9 @@ 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.3.2-26.2'))
String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.3.3'))
String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3'))
String fabricLoaderVersion = providers.gradleProperty('fabricLoaderVersion').getOrElse(rootProperties.getProperty('fabricLoaderVersion', '0.19.5'))
String fabricLoaderVersion = providers.gradleProperty('fabricLoaderVersion').getOrElse(irisModTarget.fabricLoader.toString())
Closure<String> irisArtifactName = { String platform, String targetVersion ->
return "Iris v${project.version} [${platform}] ${targetVersion}.jar"
}
@@ -141,7 +146,11 @@ configurations.create('jij') {
dependencies {
minecraft("com.mojang:minecraft:${minecraftVersion}")
implementation("net.fabricmc:fabric-loader:${fabricLoaderVersion}")
if (irisModTarget.fabricRemap) {
mappings(loom.officialMojangMappings())
}
String modConfiguration = irisModTarget.fabricRemap ? 'modImplementation' : 'implementation'
add(modConfiguration, "net.fabricmc:fabric-loader:${fabricLoaderVersion}")
testImplementation('junit:junit:4.13.2')
testImplementation('org.mockito:mockito-core:5.23.0')
testImplementation(testFixtures('art.arcane:core'))
@@ -168,11 +177,19 @@ dependencies {
libs.fabricApi.keyMapping,
libs.fabricApi.permission
]
if (irisModTarget.fabricModules != null) {
fabricApi = ((Map<String, String>) irisModTarget.fabricModules).collect { String module, String moduleVersion ->
"net.fabricmc.fabric-api:${module}:${moduleVersion}".toString()
}
}
if (irisModTarget.fabricPermissions != null) {
fabricApi.add("me.lucko:fabric-permissions-api:${irisModTarget.fabricPermissions}".toString())
}
fabricApi.each { Object notation ->
add('jij', notation)
add('implementation', notation)
add(modConfiguration, notation)
}
compileOnly('org.slf4j:slf4j-api:2.0.17')
compileOnly("org.slf4j:slf4j-api:${irisModTarget.slf4j}")
compileOnly(libs.spigot) {
transitive = false
}
@@ -201,21 +218,28 @@ dependencies {
libs.jaxen
]
shared.each { Object notation ->
add('bundle', notation)
add('devBundle', notation)
['bundle', 'devBundle'].each { String configuration ->
add(configuration, notation) {
if (group == 'art.arcane' && name == 'core') {
attributes {
attribute(Bundling.BUNDLING_ATTRIBUTE, objects.named(Bundling, Bundling.EXTERNAL))
}
}
}
}
}
}
tasks.named('compileJava', JavaCompile).configure {
options.encoding = 'UTF-8'
options.release.set(25)
options.release.set((int) irisModTarget.java)
}
tasks.named('test').configure {
}
loom {
accessWidenerPath = art.arcane.volmit.packaging.NativeAccessRules.prepare(nativeSourceImplementation, "fabric", layout.buildDirectory.dir("native-access").get().asFile)
accessWidenerPath = art.arcane.volmit.packaging.NativeAccessRules.prepare(irisModTarget.nativeAccess.toString(), "fabric", layout.buildDirectory.dir("native-access").get().asFile)
runs {
client {
runDir(providers.gradleProperty('irisClientRunDir').getOrElse('run'))
@@ -270,6 +294,15 @@ Map<String, String> nestedFabricApiJars = [
'fabric-permission-api-v1' : 'fabric-permission-api-v1.jar'
]
if (irisModTarget.fabricModules != null) {
nestedFabricApiJars = ((Map<String, String>) irisModTarget.fabricModules).collectEntries { String module, String ignored ->
[(module): "${module}.jar".toString()]
}
}
if (irisModTarget.fabricPermissions != null) {
nestedFabricApiJars.put('fabric-permissions-api', 'fabric-permissions-api.jar')
}
FileTree staleLoaderJars = fileTree(layout.buildDirectory.dir('libs')) {
include('Iris v* [Fabric] *.jar')
include('iris-fabric-*.jar')
@@ -288,6 +321,10 @@ tasks.named('shadowJar', ShadowJar).configure {
archiveFileName.set(irisArtifactName('Fabric', "${minecraftVersion}+${fabricLoaderVersion}"))
configurations = [project.configurations.named('bundle').get()]
mergeServiceFiles()
if ((int) irisModTarget.java == 21) {
exclude('art/arcane/volmlib/util/noise/NativeBillowLoader*.class')
exclude('art/arcane/volmlib/util/noise/BillowBatch*.class')
}
exclude('META-INF/maven/**')
exclude('META-INF/proguard/**')
exclude('META-INF/*.SF')
@@ -313,6 +350,8 @@ tasks.named('shadowJar', ShadowJar).configure {
// The Bukkit platform binding cannot load without org.bukkit on the classpath, which no mod
// loader has. Shipping it only adds dead classes that reference a missing package.
exclude('art/arcane/iris/platform/bukkit/**')
exclude('art/arcane/iris/platform/bootstrap/SlimJar*.class')
exclude('art/arcane/iris/platform/bootstrap/NativeRuntimeLibraries*.class')
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
filesMatching('META-INF/services/**') { FileCopyDetails entry ->
entry.duplicatesStrategy = DuplicatesStrategy.INCLUDE
@@ -344,28 +383,39 @@ tasks.named('shadowJar', ShadowJar).configure {
}
}
// The thin `jar`/`remapJar` outputs carry valid fabric.mod.json metadata but bundle zero
// dependencies. Installing one is an instant NoClassDefFoundError, and they sit in build/libs right
// next to the real artifact. shadowJar is the only shippable Fabric jar.
// The thin jar omits bundled dependencies. Obfuscated targets remap the complete shadow jar.
tasks.named('jar').configure {
enabled = false
}
tasks.matching { it.name == 'remapJar' }.configureEach {
enabled = false
enabled = (boolean) irisModTarget.fabricRemap
if (irisModTarget.fabricRemap) {
dependsOn(tasks.named('shadowJar'))
inputFile.set(tasks.named('shadowJar', ShadowJar).flatMap { it.archiveFile })
archiveFileName.set(irisArtifactName('Fabric', "${minecraftVersion}+${fabricLoaderVersion}"))
addNestedDependencies.set(false)
}
}
if (irisModTarget.fabricRemap) {
tasks.named('shadowJar', ShadowJar).configure {
destinationDirectory.set(layout.buildDirectory.dir('devlibs'))
}
}
tasks.named('assemble').configure {
dependsOn(tasks.named('shadowJar'))
dependsOn(tasks.named(irisModTarget.fabricRemap ? 'remapJar' : 'shadowJar'))
}
pluginPackaging {
artifacts {
create('distribution') {
taskName = 'shadowJar'
taskName = irisModTarget.fabricRemap ? 'remapJar' : 'shadowJar'
policyName = 'Iris Fabric'
prunePrefixes = ['art/arcane/volmlib/']
keepPrefixes = ['art/arcane/volmlib/util/matter/slices/']
}
}
}
apply from: file('../../gradle/modded-version-sources.gradle')
@@ -0,0 +1,78 @@
{
"schemaVersion": 1,
"id": "irisworldgen",
"version": "${version}",
"name": "Iris",
"description": "Iris World Generation Engine (Fabric adapter - native chunk generator, explicit Iris world datapack workflow, engine lifecycle)",
"authors": [
"Arcane Arts (Volmit Software)"
],
"contributors": [
"cyberpwn",
"NextdoorPsycho",
"Vatuu"
],
"contact": {
"homepage": "https://docs.volmit.com/iris/",
"issues": "https://github.com/VolmitSoftware/Iris/issues",
"sources": "https://github.com/VolmitSoftware/Iris"
},
"license": "GPL-3.0",
"icon": "assets/irisworldgen/icon.png",
"environment": "*",
"accessWidener": "volmlib.accesswidener",
"mixins": [
"volmlib.fabric.mixins.json",
"volmlib.entity.mixins.json",
{
"config": "volmlib.client.mixins.json",
"environment": "client"
}
],
"entrypoints": {
"main": [
"art.arcane.iris.fabric.IrisFabricBootstrap"
],
"client": [
"art.arcane.iris.fabric.IrisFabricClient"
]
},
"jars": [
{
"file": "META-INF/jars/fabric-api-base.jar"
},
{
"file": "META-INF/jars/fabric-registry-sync-v0.jar"
},
{
"file": "META-INF/jars/fabric-resource-loader-v0.jar"
},
{
"file": "META-INF/jars/fabric-lifecycle-events-v1.jar"
},
{
"file": "META-INF/jars/fabric-command-api-v2.jar"
},
{
"file": "META-INF/jars/fabric-events-interaction-v0.jar"
},
{
"file": "META-INF/jars/fabric-networking-api-v1.jar"
},
{
"file": "META-INF/jars/fabric-rendering-v1.jar"
},
{
"file": "META-INF/jars/fabric-key-binding-api-v1.jar"
},
{
"file": "META-INF/jars/fabric-permissions-api.jar"
}
],
"depends": {
"fabricloader": ">=0.19.5",
"minecraft": "${minecraftVersion}",
"java": ">=21",
"fabric-permissions-api-v0": "*"
}
}
@@ -0,0 +1,102 @@
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.ResourceLocation;
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.ConfiguredWorldCarver;
import net.minecraft.world.level.levelgen.feature.ConfiguredFeature;
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\":{\"fog_color\":12638463,\"water_color\":4159204,\"water_fog_color\":329011,\"sky_color\":7907327},"
+ "\"carvers\":{},\"features\":[],\"spawners\":{},\"spawn_costs\":{}}";
@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, ResourceLocation.parse("iris:modded_ore")),
new PlacedFeature(Holder.direct(mock(ConfiguredFeature.class)), List.of()),
RegistrationInfo.BUILT_IN);
features.freeze();
MappedRegistry<ConfiguredWorldCarver<?>> carvers = new MappedRegistry<>(Registries.CONFIGURED_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, ResourceLocation.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()));
}
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));
}
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));
}
}
+31 -9
View File
@@ -22,6 +22,7 @@ import org.gradle.api.file.FileCopyDetails
import org.gradle.api.file.FileSystemOperations
import org.gradle.api.file.FileTree
import org.gradle.api.tasks.compile.JavaCompile
import org.gradle.api.attributes.Bundling
import org.gradle.jvm.toolchain.JavaLanguageVersion
import javax.inject.Inject
@@ -42,6 +43,8 @@ plugins {
alias(libs.plugins.shadow)
}
apply from: file('../../gradle/mod-target.gradle')
apply plugin: 'art.arcane.volmit-packaging'
interface IrisFileOperations {
@@ -56,9 +59,9 @@ 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.3.2-26.2'))
String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.3.3'))
String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3'))
String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse(rootProperties.getProperty('forgeVersion', '26.3-66.0.8'))
String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse(irisModTarget.forge.toString())
Closure<String> loaderDisplayVersion = { String loaderVersion ->
String coordinatePrefix = "${minecraftVersion}-"
if (loaderVersion.startsWith(coordinatePrefix)) {
@@ -165,7 +168,7 @@ dependencies {
testImplementation(libs.spigot) {
transitive = false
}
compileOnly('org.slf4j:slf4j-api:2.0.17')
compileOnly("org.slf4j:slf4j-api:${irisModTarget.slf4j}")
compileOnly(libs.spigot) {
transitive = false
}
@@ -200,14 +203,23 @@ dependencies {
libs.jaxen
]
shared.each { Object notation ->
add('bundle', notation)
add('devBundle', notation)
add('runBundle', notation)
['bundle', 'devBundle', 'runBundle'].each { String configuration ->
add(configuration, notation) {
if (group == 'art.arcane' && name == 'core') {
attributes {
attribute(Bundling.BUNDLING_ATTRIBUTE, objects.named(Bundling, Bundling.EXTERNAL))
}
}
}
}
}
}
minecraft {
accessTransformer.from(art.arcane.volmit.packaging.NativeAccessRules.prepare(nativeSourceImplementation, "forge", layout.buildDirectory.dir("native-access").get().asFile))
if (irisModTarget.fabricRemap) {
mappings channel: 'official', version: minecraftVersion
}
accessTransformer.from(art.arcane.volmit.packaging.NativeAccessRules.prepare(irisModTarget.nativeAccess.toString(), "forge", layout.buildDirectory.dir("native-access").get().asFile))
runs {
register('client') {
workingDir = layout.projectDirectory.dir('run')
@@ -241,7 +253,8 @@ minecraft {
tasks.named('compileJava', JavaCompile).configure {
options.encoding = 'UTF-8'
options.release.set(25)
options.compilerArgs.addAll(['-Xmaxerrs', '2000'])
options.release.set((int) irisModTarget.java)
}
tasks.named('test').configure {
@@ -250,8 +263,9 @@ tasks.named('test').configure {
processResources {
inputs.property('version', irisVersion)
inputs.property('minecraftVersion', minecraftVersion)
inputs.property('loaderRange', irisModTarget.forgeRange.toString())
filesMatching('META-INF/mods.toml') {
expand('version': irisVersion, 'minecraftVersion': minecraftVersion)
expand('version': irisVersion, 'minecraftVersion': minecraftVersion, 'loaderRange': irisModTarget.forgeRange.toString())
}
}
@@ -280,6 +294,10 @@ tasks.named('shadowJar', ShadowJar).configure {
configurations = [project.configurations.named('bundle').get()]
from(sourceSets.main.output)
mergeServiceFiles()
if ((int) irisModTarget.java == 21) {
exclude('art/arcane/volmlib/util/noise/NativeBillowLoader*.class')
exclude('art/arcane/volmlib/util/noise/BillowBatch*.class')
}
exclude('META-INF/maven/**')
exclude('META-INF/proguard/**')
exclude('META-INF/*.SF')
@@ -305,6 +323,8 @@ tasks.named('shadowJar', ShadowJar).configure {
// The Bukkit platform binding cannot load without org.bukkit on the classpath, which no mod
// loader has. Shipping it only adds dead classes that reference a missing package.
exclude('art/arcane/iris/platform/bukkit/**')
exclude('art/arcane/iris/platform/bootstrap/SlimJar*.class')
exclude('art/arcane/iris/platform/bootstrap/NativeRuntimeLibraries*.class')
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
filesMatching('META-INF/services/**') { FileCopyDetails entry ->
entry.duplicatesStrategy = DuplicatesStrategy.INCLUDE
@@ -343,3 +363,5 @@ pluginPackaging {
}
}
}
apply from: file('../../gradle/modded-version-sources.gradle')
@@ -1,5 +1,5 @@
modLoader = "javafml"
loaderVersion = "[66,67)"
loaderVersion = "${loaderRange}"
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 = "[66,67)"
versionRange = "${loaderRange}"
ordering = "NONE"
side = "BOTH"
@@ -0,0 +1,29 @@
package art.arcane.iris.forge;
import art.arcane.iris.modded.IrisModdedChunkGenerator;
import art.arcane.iris.modded.ModdedEngineBootstrap;
import art.arcane.iris.modded.ModdedLifecycleCallbacks;
import art.arcane.iris.modded.service.ModdedTreeFellerService;
import art.arcane.volmlib.nativelib.modded.NativeLoaderOptions;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeChunkGeneratorDefinition;
import art.arcane.volmlib.nativelib.minecraft26_2.forge.NativeForgeLoader;
import art.arcane.volmlib.nativelib.minecraft26_2.forge.NativeForgeBootstrap;
import net.minecraftforge.fml.common.Mod;
import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext;
import net.minecraftforge.fml.loading.FMLLoader;
@Mod("irisworldgen")
public final class IrisForgeBootstrap {
public IrisForgeBootstrap(FMLJavaModLoadingContext context) {
NativeForgeLoader loader = new NativeForgeLoader(new NativeLoaderOptions(
"irisworldgen", "treefeller", ModdedTreeFellerService::runBreakProbe));
NativeChunkGeneratorDefinition generator = IrisModdedChunkGenerator.DEFINITION;
ModdedEngineBootstrap.bootCommon(loader, "Forge " + FMLLoader.versionInfo().forgeVersion(),
() -> NativeForgeBootstrap.registerGenerators(context, generator, "block_drops"));
ForgeProtocolNetworking.register();
if (loader.clientEnvironment()) {
IrisForgeClient.init(context);
}
NativeForgeBootstrap.install(context, loader, ModdedLifecycleCallbacks.create());
}
}
@@ -0,0 +1,25 @@
package art.arcane.iris.forge;
import art.arcane.iris.client.IrisClient;
import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext;
import art.arcane.volmlib.nativelib.minecraft26_2.forge.NativeForgeClientHooks;
import art.arcane.iris.client.IrisClientHud;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolChannel;
public final class IrisForgeClient {
private IrisForgeClient() {
}
public static void init(FMLJavaModLoadingContext context) {
IrisClient.bindSender(IrisForgeClient::sendToServer);
NativeForgeClientHooks.install(context, IrisClientHud.binding(), IrisClient::onWorldJoin, IrisClient::onDisconnect);
}
private static void sendToServer(byte[] frame) {
NativeProtocolChannel channel = ForgeProtocolNetworking.channel();
if (channel == null) {
return;
}
channel.sendToServer(frame);
}
}
@@ -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.ResourceLocation;
import net.minecraft.resources.RegistryOps;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.Bootstrap;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.entity.MobCategory;
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.ConfiguredWorldCarver;
import net.minecraft.world.level.levelgen.feature.ConfiguredFeature;
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\":{\"fog_color\":12638463,\"water_color\":4159204,\"water_fog_color\":329011,\"sky_color\":7907327},"
+ "\"carvers\":{},\"features\":[],\"spawners\":{},\"spawn_costs\":{}}";
@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, ResourceLocation.parse("iris:modded_ore")),
new PlacedFeature(Holder.direct(mock(ConfiguredFeature.class)), List.of()),
RegistrationInfo.BUILT_IN);
features.freeze();
MappedRegistry<ConfiguredWorldCarver<?>> carvers = new MappedRegistry<>(Registries.CONFIGURED_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, ResourceLocation.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().getMobSettings().getMobs(MobCategory.MONSTER).unwrap().stream()
.anyMatch(spawn -> spawn.type == EntityType.ZOMBIE));
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.getClimateSettings().setTemperature(0.1F);
builder.getSpecialEffects().waterColor(0x102030);
builder.getMobSpawnSettings().addSpawn(MobCategory.MONSTER, new MobSpawnSettings.SpawnerData(EntityType.ZOMBIE, 5, 1, 2));
builder.getGenerationSettings().addFeature(GenerationStep.Decoration.UNDERGROUND_ORES, ore);
}
@Override
public MapCodec<? extends BiomeModifier> codec() {
throw new UnsupportedOperationException();
}
}
}
@@ -0,0 +1,54 @@
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.MobSpawnType;
import net.minecraft.world.entity.Mob;
import net.minecraft.world.level.ServerLevelAccessor;
import net.minecraftforge.common.MinecraftForge;
import net.minecraftforge.event.entity.living.MobSpawnEvent;
import net.minecraftforge.eventbus.api.Event;
import org.junit.BeforeClass;
import org.junit.Test;
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 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, MobSpawnType.CHUNK_GENERATION)).thenReturn(true);
when(mob.checkSpawnObstruction(level)).thenReturn(true);
AtomicReference<MobSpawnType> observed = new AtomicReference<>();
Consumer<MobSpawnEvent.PositionCheck> listener = event -> {
observed.set(event.getSpawnType());
event.setResult(Event.Result.DENY);
};
MinecraftForge.EVENT_BUS.addListener(listener);
try {
assertFalse(loader.checkSpawnPosition(mob, level, MobSpawnType.CHUNK_GENERATION));
} finally {
MinecraftForge.EVENT_BUS.unregister(listener);
}
assertEquals(MobSpawnType.CHUNK_GENERATION, observed.get());
assertTrue(loader.checkSpawnPosition(mob, level, MobSpawnType.CHUNK_GENERATION));
}
}
@@ -0,0 +1,101 @@
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.entity.EntityTypes;
import net.minecraft.world.entity.MobCategory;
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.ConfiguredWorldCarver;
import net.minecraft.world.level.levelgen.feature.ConfiguredFeature;
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\":[],"
+ "\"spawners\":{},\"spawn_costs\":{}}";
@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(ConfiguredFeature.class)), List.of()),
RegistrationInfo.BUILT_IN);
features.freeze();
MappedRegistry<ConfiguredWorldCarver<?>> carvers = new MappedRegistry<>(Registries.CONFIGURED_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().modifiableBiomeInfo().get().climateSettings().temperature(), 0.0F);
assertEquals(0x102030, biome.value().modifiableBiomeInfo().get().effects().waterColor() & 0xFFFFFF);
assertTrue(biome.value().getGenerationSettings().hasFeature(ore.value()));
assertTrue(biome.value().getMobSettings().getMobs(MobCategory.MONSTER).unwrap().stream()
.anyMatch(spawn -> spawn.value().type() == EntityTypes.ZOMBIE));
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.getClimateSettings().setTemperature(0.1F);
builder.getSpecialEffects().waterColor(0x102030);
builder.getMobSpawnSettings().addSpawn(MobCategory.MONSTER, 5,
new MobSpawnSettings.SpawnerData(EntityTypes.ZOMBIE, 1, 2));
builder.getGenerationSettings().addFeature(GenerationStep.Decoration.UNDERGROUND_ORES, ore);
}
@Override
public MapCodec<? extends BiomeModifier> codec() {
throw new UnsupportedOperationException();
}
}
}
@@ -100,6 +100,7 @@ import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.OptionalLong;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.TimeUnit;
@@ -477,6 +478,7 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
return existing;
}
try {
applyReplacementSeed(level);
Engine created = ModdedWorldEngines.get(
level,
activePack,
@@ -511,6 +513,16 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
}
}
private void applyReplacementSeed(NativeWorld level) {
if (seedOverride != Long.MIN_VALUE) {
return;
}
OptionalLong replacementSeed = ModdedDimensionReplacements.loadedSeed(level.name());
if (replacementSeed.isPresent()) {
seedOverride = replacementSeed.getAsLong();
}
}
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)) {
@@ -743,14 +755,8 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
PackValidationRegistry.requireLoadable(packRoot);
packDirectory = packRoot.toFile();
}
IrisData data = IrisData.get(packDirectory);
IrisDimension dimension = data.getDimensionLoader().load(activeDimensionKey);
if (dimension == null) {
throw new IllegalStateException("Iris dimension '" + activeDimensionKey
+ "' missing from pack " + packDirectory.getAbsolutePath());
}
ModdedDimensionMetadata.ConfiguredPack resolved = new ModdedDimensionMetadata.ConfiguredPack(
data, dimension, ModdedDimensionMetadata.dimensionMetadata(dimension));
ModdedDimensionMetadata.ConfiguredPack resolved =
ModdedDimensionMetadata.ConfiguredPack.load(packDirectory, activeDimensionKey);
configuredPack = resolved;
heightMetadata = resolved.metadata();
return resolved;
@@ -239,50 +239,21 @@ public final class MainWorldService {
private static Path quarantineVanillaDimensions(Path worldRoot) throws IOException {
Path recovery = markerFile().getParent().resolve("mainworld-recovery-" + UUID.randomUUID());
List<Path> moved = new ArrayList<>();
List<Path> sources = new ArrayList<>();
for (String file : NativeServerWorldLayout.metadataFiles()) {
moveToRecovery(worldRoot, worldRoot.resolve(file), recovery, moved);
sources.add(worldRoot.resolve(file));
}
for (String folder : NativeServerWorldLayout.primaryFolders()) {
moveToRecovery(worldRoot, worldRoot.resolve(folder), recovery, moved);
sources.add(worldRoot.resolve(folder));
}
moveToRecovery(worldRoot, worldRoot.resolve(IrisEngineMantle.STORAGE_FOLDER_NAME), recovery, moved);
sources.add(worldRoot.resolve(IrisEngineMantle.STORAGE_FOLDER_NAME));
for (String folder : NativeServerWorldLayout.dimensionFolders()) {
moveToRecovery(worldRoot, worldRoot.resolve(folder), recovery, moved);
}
if (moved.isEmpty()) {
Files.deleteIfExists(recovery);
sources.add(worldRoot.resolve(folder));
}
ModdedWorldQuarantine.move(worldRoot, sources, recovery);
return recovery;
}
private static void moveToRecovery(Path worldRoot, Path source, Path recovery,
List<Path> moved) throws IOException {
if (!Files.exists(source)) {
return;
}
Path relative = worldRoot.relativize(source);
Path target = recovery.resolve(relative);
Files.createDirectories(target.getParent());
try {
Files.move(source, target);
moved.add(relative);
} catch (IOException failure) {
for (int index = moved.size() - 1; index >= 0; index--) {
Path rollbackRelative = moved.get(index);
Path rollbackSource = recovery.resolve(rollbackRelative);
Path rollbackTarget = worldRoot.resolve(rollbackRelative);
try {
Files.createDirectories(rollbackTarget.getParent());
Files.move(rollbackSource, rollbackTarget);
} catch (IOException rollbackFailure) {
failure.addSuppressed(rollbackFailure);
}
}
throw failure;
}
}
private static String escape(String value) {
return value.replace(":", "\\:");
}
@@ -0,0 +1,38 @@
/*
* 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;
public final class MissingPackDimensionException extends IllegalStateException {
private final String pack;
private final String dimensionKey;
public MissingPackDimensionException(String pack, String dimensionKey) {
super("Pack '" + pack + "' does not contain dimensions/" + dimensionKey + ".json");
this.pack = pack;
this.dimensionKey = dimensionKey;
}
public String pack() {
return pack;
}
public String dimensionKey() {
return dimensionKey;
}
}
@@ -42,6 +42,7 @@ import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.biome.IrisBiomeCustom;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.generation.context.IrisContext;
import art.arcane.iris.generation.locator.BiomeSearchFilter;
import art.arcane.volmlib.util.math.RNG;
import java.io.IOException;
@@ -300,10 +301,6 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
if (!isReady(engine)) {
throw new IllegalStateException("Iris visible biome search has no active engine runtime");
}
DimensionStackContext stackContext = engine.getDimensionStackContext();
if (stackContext == null) {
return query.defaultFallback().get();
}
Set<H> candidates = new HashSet<>();
for (H biome : collectVisibleBiomeHolders()) {
if (query.allowed().test(biome)) {
@@ -313,16 +310,86 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
if (candidates.isEmpty()) {
return null;
}
DimensionStackContext stackContext = engine.getDimensionStackContext();
if (stackContext != null) {
return query.search().search(candidates, (blockX, blockZ) -> {
int quartX = blockX >> 2;
int quartZ = blockZ >> 2;
DimensionStackLayout stackLayout = stackContext.sample(quartX << 2, quartZ << 2);
return blockY -> resolveVisibleBiome(engine, quartX, blockY >> 2, quartZ,
query.sampler(), stackLayout);
});
}
BiomeSearchFilter filter = BiomeSearchFilter.of(engine, searchBiomeKeys(engine, candidates));
int minHeight = engine.getMinHeight();
return query.search().search(candidates, (blockX, blockZ) -> {
int quartX = blockX >> 2;
int quartZ = blockZ >> 2;
DimensionStackLayout stackLayout = stackContext.sample(quartX << 2, quartZ << 2);
return blockY -> resolveVisibleBiome(engine, quartX, blockY >> 2, quartZ,
query.sampler(), stackLayout);
BiomeSearchFilter.Column column = filter.column(quartX << 2, quartZ << 2);
return blockY -> column.admits(((blockY >> 2) << 2) - minHeight)
? locateVisibleBiome(engine, quartX, blockY >> 2, quartZ, query.sampler())
: null;
});
}
}
private H locateVisibleBiome(Engine engine, int quartX, int quartY, int quartZ, S sampler) {
try (GenerationHistoryRuntimeRouter.CoordinateScope ignored = openHistoryCoordinateScope(
engine, quartX << 2, quartZ << 2, "modded_locate_visible_biome")) {
return resolveVisibleBiome(engine, quartX, quartY, quartZ, sampler, null);
}
}
private Set<String> searchBiomeKeys(Engine engine, Set<H> candidates) {
NativeBiomeSourceAccess.RegistryView<H> registry = biomeRegistry();
if (registry == null) {
throw new IllegalStateException("Iris visible biome search has no biome registry");
}
H plains = resolveHolder(registry, "minecraft:plains");
boolean fallbackCandidate = plains != null && candidates.contains(plains);
Set<String> keys = new HashSet<>();
for (OwnedBiome ownedBiome : getOwnedBiomes(engine)) {
if (emitsCandidate(engine, ownedBiome, registry, candidates, fallbackCandidate)) {
keys.add(ownedBiome.biome().getLoadKey());
}
}
return keys;
}
private boolean emitsCandidate(Engine engine, OwnedBiome ownedBiome, NativeBiomeSourceAccess.RegistryView<H> registry,
Set<H> candidates, boolean fallbackCandidate) {
IrisBiome irisBiome = ownedBiome.biome();
if (irisBiome.isCustom()) {
for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) {
if (customBiome == null) {
if (fallbackCandidate) {
return true;
}
continue;
}
String key = ownedBiome.dimension() == engine.getDimension() && ownedBiome.data() == engine.getData()
? ModdedWorldgenIds.biomeRef(engine, customBiome.getId())
: ownedBiome.data().customBiomeResourceKey(ownedBiome.dimension(), customBiome);
if (emits(registry, key, candidates, fallbackCandidate)) {
return true;
}
}
return false;
}
for (String key : irisBiome.getVisibleBiomeKeys(engine)) {
if (emits(registry, key, candidates, fallbackCandidate)) {
return true;
}
}
return false;
}
private static <H> boolean emits(NativeBiomeSourceAccess.RegistryView<H> registry, String key,
Set<H> candidates, boolean fallbackCandidate) {
H holder = key == null ? null : resolveHolder(registry, key);
return holder == null ? fallbackCandidate : candidates.contains(holder);
}
private H getNoiseBiome(Engine engine, int quartX, int quartY, int quartZ,
S sampler) {
if (isGuaranteedSurfaceBiome(engine, quartY)) {
@@ -31,6 +31,7 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeBlockDropHooks;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.BiomeEnvironment;
import art.arcane.iris.structure.placement.LootResolver;
import art.arcane.iris.generation.decoration.tree.TreeBlockMaterial;
import art.arcane.iris.generation.biome.IrisBiome;
@@ -181,26 +182,30 @@ public final class ModdedBlockBreakHandler {
NativeBlockState brokenState,
Engine engine
) {
KList<IrisBlockDrops> providers = providers(engine, position, brokenState);
if (providers.isEmpty()) {
return Result.empty();
}
BiomeEnvironment environment = engine.getBiomeEnvironment(position.x(),
position.y() - engine.getMinHeight(), position.z());
try (BiomeEnvironment.Scope ignored = engine.openBiomeEnvironmentScope(environment)) {
KList<IrisBlockDrops> providers = providers(environment, brokenState);
if (providers.isEmpty()) {
return Result.empty();
}
KList<NativeItemStack> drops = new KList<>();
boolean replaceVanillaDrops = false;
for (IrisBlockDrops provider : providers) {
replaceVanillaDrops |= provider.isReplaceVanillaDrops();
for (IrisLoot loot : provider.getDrops()) {
if (!LootResolver.oneIn(RNG.r, loot.getRarity())) {
continue;
}
NativeItemStack stack = ModdedItemTranslator.stack(loot, RNG.r, ModdedItemTranslator.context(level));
if (stack != null && !stack.isEmpty()) {
drops.add(stack);
KList<NativeItemStack> drops = new KList<>();
boolean replaceVanillaDrops = false;
for (IrisBlockDrops provider : providers) {
replaceVanillaDrops |= provider.isReplaceVanillaDrops();
for (IrisLoot loot : provider.getDrops()) {
if (!LootResolver.oneIn(RNG.r, loot.getRarity())) {
continue;
}
NativeItemStack stack = ModdedItemTranslator.stack(loot, RNG.r, ModdedItemTranslator.context(level));
if (stack != null && !stack.isEmpty()) {
drops.add(stack);
}
}
}
return new Result(drops, replaceVanillaDrops, null);
}
return new Result(drops, replaceVanillaDrops, null);
}
private static void removeMarker(Engine engine, NativeBlockPoint position) {
@@ -225,22 +230,17 @@ public final class ModdedBlockBreakHandler {
engine.getMantle().getMantle().remove(position.x(), mantleY, position.z(), TreeBlockMaterial.class);
}
private static KList<IrisBlockDrops> providers(Engine engine, NativeBlockPoint position, NativeBlockState brokenState) {
private static KList<IrisBlockDrops> providers(BiomeEnvironment environment, NativeBlockState brokenState) {
KList<IrisBlockDrops> providers = new KList<>();
IrisData data = engine.getData();
int relativeY = position.y() - engine.getMinHeight();
IrisBiome biome = engine.getBiome(position.x(), relativeY, position.z());
if (biome != null) {
addMatching(providers, biome.getBlockDrops(), brokenState, data);
}
IrisData data = environment.data();
IrisBiome biome = environment.biome();
addMatching(providers, biome.getBlockDrops(), brokenState, data);
if (skipsParents(providers)) {
return providers;
}
IrisRegion region = engine.getRegion(position.x(), relativeY, position.z());
if (region != null) {
addMatching(providers, region.getBlockDrops(), brokenState, data);
}
addMatching(providers, engine.getDimension().getBlockDrops(), brokenState, data);
IrisRegion region = environment.region();
addMatching(providers, region.getBlockDrops(), brokenState, data);
addMatching(providers, environment.dimension().getBlockDrops(), brokenState, data);
return providers;
}
@@ -0,0 +1,92 @@
/*
* 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 art.arcane.volmlib.util.json.JSONArray;
import art.arcane.volmlib.util.json.JSONObject;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Map;
final class ModdedDatapackTagMerge {
private static final String REPLACE = "replace";
private static final String VALUES = "values";
private static final String REMOVE = "remove";
private ModdedDatapackTagMerge() {
}
static boolean isTag(Path relative) {
return relative.getNameCount() >= 4
&& "data".equals(relative.getName(0).toString())
&& "tags".equals(relative.getName(2).toString())
&& relative.getFileName().toString().endsWith(".json");
}
static void merge(Path source, Path destination) throws IOException {
JSONObject incoming = read(source);
JSONObject existing = read(destination);
JSONObject tag = new JSONObject();
tag.put(REPLACE, existing.optBoolean(REPLACE, false) || incoming.optBoolean(REPLACE, false));
tag.put(VALUES, union(existing, incoming, VALUES, source, destination));
if (existing.has(REMOVE) || incoming.has(REMOVE)) {
tag.put(REMOVE, union(existing, incoming, REMOVE, source, destination));
}
Files.writeString(destination, tag.toString(4), StandardCharsets.UTF_8);
}
private static JSONArray union(JSONObject existing, JSONObject incoming, String field, Path source, Path destination)
throws IOException {
Map<String, Object> entries = new LinkedHashMap<>();
collect(existing, field, entries, destination);
collect(incoming, field, entries, source);
JSONArray merged = new JSONArray();
for (Object entry : entries.values()) {
merged.put(entry);
}
return merged;
}
private static JSONObject read(Path tag) throws IOException {
try {
return new JSONObject(Files.readString(tag, StandardCharsets.UTF_8));
} catch (RuntimeException invalid) {
throw new IOException("Iris cannot merge the invalid datapack tag " + tag + ".", invalid);
}
}
private static void collect(JSONObject tag, String field, Map<String, Object> entries, Path origin) throws IOException {
JSONArray values = tag.optJSONArray(field);
if (values == null) {
return;
}
for (int index = 0; index < values.length(); index++) {
Object entry = values.get(index);
String key = entry instanceof JSONObject object ? object.optString("id", "") : entry.toString();
if (key.isBlank()) {
throw new IOException("Iris cannot merge datapack tag " + origin + ": " + field + " entry " + index + " has no id.");
}
entries.putIfAbsent(key, entry);
}
}
}
@@ -0,0 +1,148 @@
/*
* 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 art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.modded.command.ModdedPackCommands;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
public final class ModdedDimensionCreations {
private static final Map<String, ModdedDimensionInjection.Async<?, ?, ?>> ACTIVE = new ConcurrentHashMap<>();
private ModdedDimensionCreations() {
}
public static CompletableFuture<Void> create(
NativeModdedServer server,
String dimensionId,
String pack,
String packDimensionKey,
long seed
) {
if (ModdedDimensionRegistryStore.get(server, dimensionId) != null) {
return CompletableFuture.failedFuture(new IllegalStateException(
ModdedDimensionManager.alreadyExists(dimensionId)));
}
ModdedDimensionRegistryStore.PersistentDimension dimension =
new ModdedDimensionRegistryStore.PersistentDimension(dimensionId, pack, packDimensionKey, seed);
ModdedDimensionInjection.Async<ModdedWorldEngines.PreparedPack, ModdedDimensionManager.PendingInjection, Engine> creation =
ModdedDimensionInjection.async(dimension, new CreationSteps(server), server::execute);
if (ACTIVE.putIfAbsent(dimensionId, creation) != null) {
return CompletableFuture.failedFuture(new IllegalStateException(beingCreated(dimensionId)));
}
CompletableFuture<Void> created = creation.result().handle(
(ModdedDimensionInjection.Result result, Throwable failure) -> settle(creation, dimension, result));
creation.start();
return created;
}
public static boolean isCreating(String dimensionId) {
return ACTIVE.containsKey(dimensionId);
}
public static void requireNotCreating(String dimensionId) {
if (isCreating(dimensionId)) {
throw new IllegalStateException(beingCreated(dimensionId));
}
}
public static void cancelAll() {
List<ModdedDimensionInjection.Async<?, ?, ?>> active = new ArrayList<>(ACTIVE.values());
for (ModdedDimensionInjection.Async<?, ?, ?> creation : active) {
creation.cancel();
}
}
private static Void settle(
ModdedDimensionInjection.Async<?, ?, ?> creation,
ModdedDimensionRegistryStore.PersistentDimension dimension,
ModdedDimensionInjection.Result result
) {
ACTIVE.remove(dimension.id(), creation);
Throwable cause = result == null
? new IllegalStateException("Iris dimension creation for '" + dimension.id() + "' ended without a result")
: result.failure();
if (cause instanceof Error fatal) {
throw fatal;
}
if (cause != null) {
throw cause instanceof RuntimeException runtimeException ? runtimeException : new IllegalStateException(cause);
}
ModdedIrisLog.info("Iris created runtime dimension '{}' (pack={} dim={}): prepared {}ms, engine {}ms",
dimension.id(), dimension.pack(), dimension.dimension(), result.preparedMillis(), result.builtMillis());
return null;
}
private static String beingCreated(String dimensionId) {
return "Iris dimension '" + dimensionId + "' is still being created. Try again when it is live.";
}
private record CreationSteps(NativeModdedServer server)
implements ModdedDimensionInjection.Steps<ModdedWorldEngines.PreparedPack, ModdedDimensionManager.PendingInjection, Engine> {
@Override
public ModdedWorldEngines.PreparedPack prepare(ModdedDimensionRegistryStore.PersistentDimension dimension) {
ModdedStartup.requirePackForWorldCreation(dimension.pack());
IrisData data = IrisData.get(new File(ModdedPackCommands.packsRoot(), dimension.pack()));
if (data.getDimensionLoader().load(dimension.dimension()) == null) {
throw new MissingPackDimensionException(dimension.pack(), dimension.dimension());
}
return ModdedWorldEngines.prepareCreated(server, dimension);
}
@Override
public ModdedDimensionManager.PendingInjection begin(
ModdedDimensionRegistryStore.PersistentDimension dimension,
ModdedWorldEngines.PreparedPack prepared
) {
return ModdedDimensionManager.beginCreation(server, dimension, prepared.active());
}
@Override
public Engine build(ModdedDimensionManager.PendingInjection level, ModdedWorldEngines.PreparedPack prepared) {
if (level.present()) {
prepared.close();
return null;
}
return ModdedWorldEngines.createPrepared(level.handle().level(), level.handle().seed(), prepared);
}
@Override
public void complete(ModdedDimensionManager.PendingInjection level, Engine engine) {
ModdedDimensionManager.completeCreation(server, level, engine);
}
@Override
public void abandon(ModdedDimensionManager.PendingInjection level, Engine engine, Throwable failure) {
ModdedDimensionManager.abandonCreation(server, level, engine, failure);
}
@Override
public void discard(ModdedWorldEngines.PreparedPack prepared) {
prepared.close();
}
}
}
@@ -0,0 +1,434 @@
/*
* 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.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
final class ModdedDimensionInjection<P, L, E> implements AutoCloseable {
private static final int MINIMUM_WORKERS = 2;
private static final long CANCEL_BUILD_WAIT_SECONDS = 120L;
private final List<Preparation<P>> preparations;
private final Steps<P, L, E> steps;
private final ExecutorService workers;
private final AtomicBoolean finished = new AtomicBoolean();
private ModdedDimensionInjection(List<Preparation<P>> preparations, Steps<P, L, E> steps, ExecutorService workers) {
this.preparations = preparations;
this.steps = steps;
this.workers = workers;
}
static <P, L, E> ModdedDimensionInjection<P, L, E> start(
List<ModdedDimensionRegistryStore.PersistentDimension> dimensions,
Steps<P, L, E> steps
) {
Steps<P, L, E> requiredSteps = Objects.requireNonNull(steps, "steps");
ExecutorService workers = Executors.newFixedThreadPool(workerCount(dimensions.size()),
Thread.ofPlatform().name("Iris Dimension Restore ", 1L).daemon(true).factory());
List<Preparation<P>> preparations = new ArrayList<>(dimensions.size());
for (ModdedDimensionRegistryStore.PersistentDimension dimension : dimensions) {
preparations.add(new Preparation<>(dimension, CompletableFuture.supplyAsync(
() -> timed(() -> requiredSteps.prepare(dimension)), workers)));
}
return new ModdedDimensionInjection<>(List.copyOf(preparations), requiredSteps, workers);
}
static <P, L, E> Async<P, L, E> async(
ModdedDimensionRegistryStore.PersistentDimension dimension,
Steps<P, L, E> steps,
Executor serverThread
) {
return new Async<>(dimension, steps, serverThread);
}
void awaitPreparations() {
List<CompletableFuture<Timed<P>>> futures = new ArrayList<>(preparations.size());
for (Preparation<P> preparation : preparations) {
futures.add(preparation.future());
}
CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new))
.handle((Void ignored, Throwable failure) -> null)
.join();
}
List<Result> finish() {
if (!finished.compareAndSet(false, true)) {
throw new IllegalStateException("Iris dimension injection already finished");
}
try {
List<Building<L, E>> building = new ArrayList<>(preparations.size());
Result[] results = new Result[preparations.size()];
for (int index = 0; index < preparations.size(); index++) {
Preparation<P> preparation = preparations.get(index);
Timed<P> prepared;
try {
prepared = join(preparation.future());
} catch (Throwable failure) {
results[index] = new Result(preparation.dimension(), failure, 0L, 0L);
continue;
}
L level;
try {
level = steps.begin(preparation.dimension(), prepared.value());
} catch (Throwable failure) {
discard(steps, prepared.value(), failure);
results[index] = new Result(preparation.dimension(), failure, prepared.millis(), 0L);
continue;
}
CompletableFuture<Timed<E>> engine = CompletableFuture.supplyAsync(
() -> timed(() -> steps.build(level, prepared.value())), workers);
building.add(new Building<>(index, preparation.dimension(), prepared.millis(), level, engine));
}
for (Building<L, E> pending : building) {
results[pending.index()] = complete(pending);
}
return List.of(results);
} finally {
workers.shutdown();
}
}
@Override
public void close() {
if (finished.compareAndSet(false, true)) {
for (Preparation<P> preparation : preparations) {
preparation.future().whenComplete((Timed<P> prepared, Throwable failure) -> {
if (prepared != null) {
discard(steps, prepared.value(), null);
}
});
}
}
workers.shutdown();
}
private Result complete(Building<L, E> pending) {
Timed<E> engine;
try {
engine = join(pending.engine());
} catch (Throwable failure) {
abandon(steps, pending.level(), null, failure);
return new Result(pending.dimension(), failure, pending.preparedMillis(), 0L);
}
try {
steps.complete(pending.level(), engine.value());
return new Result(pending.dimension(), null, pending.preparedMillis(), engine.millis());
} catch (Throwable failure) {
return new Result(pending.dimension(), failure, pending.preparedMillis(), engine.millis());
}
}
private static <P, L, E> void abandon(Steps<P, L, E> steps, L level, E engine, Throwable failure) {
try {
steps.abandon(level, engine, failure);
} catch (Throwable cleanupFailure) {
failure.addSuppressed(cleanupFailure);
}
}
private static <P, L, E> void discard(Steps<P, L, E> steps, P prepared, Throwable failure) {
try {
steps.discard(prepared);
} catch (Throwable cleanupFailure) {
if (failure != null) {
failure.addSuppressed(cleanupFailure);
return;
}
ModdedIrisLog.error("Iris could not release an unused dimension preparation", cleanupFailure);
}
}
private static int workerCount(int dimensions) {
int processors = Runtime.getRuntime().availableProcessors();
return Math.max(1, Math.min(dimensions, Math.max(MINIMUM_WORKERS, processors / 2)));
}
private static <T> Timed<T> timed(Step<T> step) {
long startedAt = System.nanoTime();
try {
T value = step.run();
return new Timed<>(value, (System.nanoTime() - startedAt) / 1_000_000L);
} catch (RuntimeException | Error failure) {
throw failure;
} catch (Exception failure) {
throw new CompletionException(failure);
}
}
private static Throwable unwrap(Throwable failure) {
Throwable current = failure;
while (current instanceof CompletionException && current.getCause() != null) {
current = current.getCause();
}
return current;
}
private static <T> T join(CompletableFuture<T> future) throws Throwable {
try {
return future.get();
} catch (ExecutionException failure) {
Throwable cause = failure.getCause();
if (cause instanceof CompletionException wrapped && wrapped.getCause() != null) {
throw wrapped.getCause();
}
throw cause == null ? failure : cause;
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Interrupted while injecting Iris dimensions", interrupted);
}
}
interface Steps<P, L, E> {
P prepare(ModdedDimensionRegistryStore.PersistentDimension dimension) throws Exception;
L begin(ModdedDimensionRegistryStore.PersistentDimension dimension, P prepared) throws Exception;
E build(L level, P prepared) throws Exception;
void complete(L level, E engine) throws Exception;
void abandon(L level, E engine, Throwable failure);
void discard(P prepared);
}
record Result(
ModdedDimensionRegistryStore.PersistentDimension dimension,
Throwable failure,
long preparedMillis,
long builtMillis
) {
}
static final class Async<P, L, E> {
private final ModdedDimensionRegistryStore.PersistentDimension dimension;
private final Steps<P, L, E> steps;
private final Executor serverThread;
private final ExecutorService worker;
private final CompletableFuture<Result> result = new CompletableFuture<>();
private State state = State.UNSTARTED;
private Timed<P> prepared;
private L level;
private CompletableFuture<Timed<E>> engine;
private Async(
ModdedDimensionRegistryStore.PersistentDimension dimension,
Steps<P, L, E> steps,
Executor serverThread
) {
this.dimension = Objects.requireNonNull(dimension, "dimension");
this.steps = Objects.requireNonNull(steps, "steps");
this.serverThread = Objects.requireNonNull(serverThread, "serverThread");
this.worker = Executors.newSingleThreadExecutor(
Thread.ofPlatform().name("Iris Dimension Create " + dimension.id()).daemon(true).factory());
}
CompletableFuture<Result> result() {
return result;
}
synchronized void start() {
if (state != State.UNSTARTED) {
throw new IllegalStateException("Iris dimension injection for '" + dimension.id() + "' already started");
}
state = State.PREPARING;
CompletableFuture.supplyAsync(() -> timed(() -> steps.prepare(dimension)), worker)
.whenComplete(this::prepared);
}
void cancel() {
Timed<P> unused = null;
CompletableFuture<Timed<E>> building = null;
L created = null;
synchronized (this) {
if (state == State.DONE || state == State.CANCELLED) {
return;
}
if (state == State.PREPARED) {
unused = prepared;
} else if (state == State.BUILDING) {
building = engine;
created = level;
}
state = State.CANCELLED;
}
CancellationException cancellation = new CancellationException("Iris dimension injection for '"
+ dimension.id() + "' was cancelled because the server is stopping");
long preparedMillis = 0L;
if (unused != null) {
discard(steps, unused.value(), cancellation);
preparedMillis = unused.millis();
}
if (building != null) {
abandon(steps, created, awaitCancelledEngine(building, cancellation), cancellation);
}
done(new Result(dimension, cancellation, preparedMillis, 0L));
}
private void prepared(Timed<P> value, Throwable failure) {
synchronized (this) {
if (state == State.CANCELLED) {
if (value != null) {
discard(steps, value.value(), null);
}
worker.shutdown();
return;
}
if (failure != null) {
state = State.DONE;
} else {
prepared = value;
state = State.PREPARED;
}
}
if (failure != null) {
done(new Result(dimension, unwrap(failure), 0L, 0L));
return;
}
serverThread.execute(this::begin);
}
private void begin() {
Timed<P> ready;
synchronized (this) {
if (state != State.PREPARED) {
return;
}
ready = prepared;
state = State.BEGINNING;
}
L created;
try {
created = steps.begin(dimension, ready.value());
} catch (Throwable failure) {
discard(steps, ready.value(), failure);
synchronized (this) {
state = State.DONE;
}
done(new Result(dimension, failure, ready.millis(), 0L));
return;
}
CompletableFuture<Timed<E>> building;
synchronized (this) {
level = created;
state = State.BUILDING;
building = CompletableFuture.supplyAsync(() -> timed(() -> steps.build(created, ready.value())), worker);
engine = building;
}
building.whenComplete((Timed<E> built, Throwable failure) -> serverThread.execute(this::complete));
}
private void complete() {
CompletableFuture<Timed<E>> building;
L created;
long preparedMillis;
synchronized (this) {
if (state != State.BUILDING) {
return;
}
state = State.DONE;
building = engine;
created = level;
preparedMillis = prepared.millis();
}
Timed<E> built;
try {
built = join(building);
} catch (Throwable failure) {
abandon(steps, created, null, failure);
done(new Result(dimension, failure, preparedMillis, 0L));
return;
}
try {
steps.complete(created, built.value());
done(new Result(dimension, null, preparedMillis, built.millis()));
} catch (Throwable failure) {
done(new Result(dimension, failure, preparedMillis, built.millis()));
}
}
private E awaitCancelledEngine(CompletableFuture<Timed<E>> building, CancellationException cancellation) {
try {
return building.get(CANCEL_BUILD_WAIT_SECONDS, TimeUnit.SECONDS).value();
} catch (ExecutionException failure) {
return null;
} catch (TimeoutException failure) {
ModdedIrisLog.error("Iris dimension engine for '{}' did not finish within {}s of shutdown; abandoning its level",
dimension.id(), CANCEL_BUILD_WAIT_SECONDS);
cancellation.addSuppressed(failure);
return null;
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
cancellation.addSuppressed(interrupted);
return null;
}
}
private void done(Result outcome) {
worker.shutdown();
result.complete(outcome);
}
private enum State {
UNSTARTED,
PREPARING,
PREPARED,
BEGINNING,
BUILDING,
DONE,
CANCELLED
}
}
@FunctionalInterface
private interface Step<T> {
T run() throws Exception;
}
private record Timed<T>(T value, long millis) {
}
private record Preparation<P>(
ModdedDimensionRegistryStore.PersistentDimension dimension,
CompletableFuture<Timed<P>> future
) {
}
private record Building<L, E>(
int index,
ModdedDimensionRegistryStore.PersistentDimension dimension,
long preparedMillis,
L level,
CompletableFuture<Timed<E>> engine
) {
}
}
@@ -107,150 +107,172 @@ public final class ModdedDimensionManager {
) {
return create(
server,
dimensionId,
pack,
packDimensionKey,
seed,
ModdedGenerationMode.TRANSIENT_STUDIO
new DimensionRequest(dimensionId, pack, packDimensionKey, seed),
() -> transientStudioPack(pack, packDimensionKey)
);
}
static Handle restorePersistent(
static PendingInjection beginInjection(
NativeModdedServer server,
String dimensionId,
String pack,
String packDimensionKey,
long seed
) {
return create(
server,
dimensionId,
pack,
packDimensionKey,
seed,
ModdedGenerationMode.PERSISTENT_RESTORE
);
}
private static Handle create(
NativeModdedServer server,
String dimensionId,
String pack,
String packDimensionKey,
long seed,
ModdedDimensionRegistryStore.PersistentDimension dimension,
ModdedGenerationHistoryStorage.ActivePack active,
ModdedGenerationMode generationMode
) {
NativeDimensionRuntime serverAccess = requireAccess();
DimensionRequest requested = new DimensionRequest(dimension.id(), dimension.pack(), dimension.dimension(), dimension.seed());
synchronized (LOCK) {
String key = dimensionId;
RuntimePack runtimePack = switch (generationMode) {
case TRANSIENT_STUDIO -> transientStudioPack(pack, packDimensionKey);
case PERSISTENT_CREATE -> persistentRuntimePack(
ModdedGenerationHistoryStorage.createOrStage(
server,
key,
pack,
packDimensionKey,
seed
),
generationMode
);
case PERSISTENT_RESTORE -> persistentRuntimePack(
ModdedGenerationHistoryStorage.restoreOrAdopt(
server,
key,
pack,
packDimensionKey,
seed
),
generationMode
);
};
Handle existing = HANDLES.get(dimensionId);
if (existing != null && serverAccess.hasLevel(server, key)) {
Handle refreshed = new Handle(dimensionId, pack, packDimensionKey, seed, existing.level(), existing.generator());
HANDLES.put(dimensionId, refreshed);
return refreshed;
Handle present = presentHandle(server, serverAccess, requested);
if (present != null) {
return new PendingInjection(present, true);
}
if (serverAccess.hasLevel(server, key)) {
NativeWorld present = level(server, dimensionId);
IrisModdedChunkGenerator generator = NativeWorldGenerators.find(present, IrisModdedChunkGenerator.class);
if (generator == null) {
throw new IllegalStateException("Iris cannot inject dimension '" + dimensionId + "': a non-Iris level with that id is already loaded");
}
ModdedIrisLog.warn("Iris dimension '{}' is already present in the running server; reusing it", dimensionId);
Handle handle = new Handle(dimensionId, pack, packDimensionKey, seed, present, generator);
HANDLES.put(dimensionId, handle);
return handle;
}
try {
Handle handle = inject(
server,
serverAccess,
dimensionId,
key,
pack,
packDimensionKey,
seed,
runtimePack
);
HANDLES.put(dimensionId, handle);
ModdedIrisLog.info("Iris injected runtime dimension '{}' (pack={} dim={} seed={})", dimensionId, pack, packDimensionKey, seed);
return handle;
RuntimePack runtimePack = persistentRuntimePack(active, generationMode);
return new PendingInjection(createLevel(server, serverAccess, requested, runtimePack), false);
} catch (Throwable e) {
ModdedIrisLog.error("Iris failed to inject runtime dimension '{}' (pack={} dim={} seed={})", dimensionId, pack, packDimensionKey, seed, e);
throw new IllegalStateException("Iris runtime dimension injection failed for " + dimensionId, e);
throw injectionFailure(requested, e);
}
}
}
public static Handle createPersistent(NativeModdedServer server, String dimensionId, String pack, String packDimensionKey, long seed) {
static Handle completeInjection(NativeModdedServer server, PendingInjection pending, Engine engine) {
if (pending.present()) {
return pending.handle();
}
NativeDimensionRuntime serverAccess = requireAccess();
synchronized (LOCK) {
ModdedDimensionRegistryStore.PersistentDimension previous =
ModdedDimensionRegistryStore.get(server, dimensionId);
if (previous != null) {
throw new IllegalStateException("Iris dimension '" + dimensionId
+ "' already exists. Use the Iris world update command to stage a pack update.");
}
ModdedDimensionRegistryStore.put(server, new ModdedDimensionRegistryStore.PersistentDimension(
dimensionId,
pack,
packDimensionKey,
seed
));
try {
return create(
server,
dimensionId,
pack,
packDimensionKey,
seed,
ModdedGenerationMode.PERSISTENT_CREATE
);
return register(publishLevel(server, serverAccess, pending.handle(), engine));
} catch (Throwable e) {
try {
ModdedDimensionRegistryStore.remove(server, dimensionId);
} catch (Throwable rollbackFailure) {
e.addSuppressed(rollbackFailure);
}
if (e instanceof RuntimeException runtimeException) {
throw runtimeException;
}
if (e instanceof Error fatalError) {
throw fatalError;
}
throw new IllegalStateException("Iris runtime dimension injection failed for " + dimensionId, e);
throw injectionFailure(DimensionRequest.of(pending.handle()), e);
}
}
}
static void abandonInjection(NativeModdedServer server, PendingInjection pending, Engine engine, Throwable failure) {
if (engine != null) {
try {
ModdedWorldEngines.closeUnregistered(engine);
} catch (Throwable cleanupFailure) {
failure.addSuppressed(cleanupFailure);
ModdedIrisLog.error("Iris failed to close the abandoned engine for '{}'",
pending.handle().dimensionId(), cleanupFailure);
}
}
if (pending.present()) {
return;
}
NativeDimensionRuntime serverAccess = requireAccess();
Handle handle = pending.handle();
synchronized (LOCK) {
rollbackInjection(server, serverAccess, handle.dimensionId(), handle.level(), handle.generator(), false, failure);
}
}
static PendingInjection beginCreation(
NativeModdedServer server,
ModdedDimensionRegistryStore.PersistentDimension dimension,
ModdedGenerationHistoryStorage.ActivePack active
) {
synchronized (LOCK) {
if (ModdedDimensionRegistryStore.get(server, dimension.id()) != null) {
throw new IllegalStateException(alreadyExists(dimension.id()));
}
ModdedDimensionRegistryStore.put(server, dimension);
try {
return beginInjection(server, dimension, active, ModdedGenerationMode.PERSISTENT_CREATE);
} catch (RuntimeException | Error failure) {
forgetCreation(server, dimension.id(), failure);
throw failure;
}
}
}
static Handle completeCreation(NativeModdedServer server, PendingInjection pending, Engine engine) {
try {
return completeInjection(server, pending, engine);
} catch (RuntimeException | Error failure) {
forgetCreation(server, pending.handle().dimensionId(), failure);
throw failure;
}
}
static void abandonCreation(NativeModdedServer server, PendingInjection pending, Engine engine, Throwable failure) {
abandonInjection(server, pending, engine, failure);
forgetCreation(server, pending.handle().dimensionId(), failure);
}
static String alreadyExists(String dimensionId) {
return "Iris dimension '" + dimensionId
+ "' already exists. Use the Iris world update command to stage a pack update.";
}
private static void forgetCreation(NativeModdedServer server, String dimensionId, Throwable failure) {
try {
ModdedDimensionRegistryStore.remove(server, dimensionId);
} catch (Throwable rollbackFailure) {
failure.addSuppressed(rollbackFailure);
}
}
private static Handle create(NativeModdedServer server, DimensionRequest requested, RuntimePackSource source) {
NativeDimensionRuntime serverAccess = requireAccess();
synchronized (LOCK) {
RuntimePack runtimePack = source.open();
Handle present = presentHandle(server, serverAccess, requested);
if (present != null) {
return present;
}
try {
return register(publishLevel(server, serverAccess,
createLevel(server, serverAccess, requested, runtimePack), null));
} catch (Throwable e) {
throw injectionFailure(requested, e);
}
}
}
private static Handle presentHandle(NativeModdedServer server, NativeDimensionRuntime serverAccess, DimensionRequest requested) {
String dimensionId = requested.dimensionId();
Handle existing = HANDLES.get(dimensionId);
if (existing != null && serverAccess.hasLevel(server, dimensionId)) {
Handle refreshed = requested.bind(existing.level(), existing.generator());
HANDLES.put(dimensionId, refreshed);
return refreshed;
}
if (!serverAccess.hasLevel(server, dimensionId)) {
return null;
}
NativeWorld present = level(server, dimensionId);
IrisModdedChunkGenerator generator = NativeWorldGenerators.find(present, IrisModdedChunkGenerator.class);
if (generator == null) {
throw new IllegalStateException("Iris cannot inject dimension '" + dimensionId + "': a non-Iris level with that id is already loaded");
}
ModdedIrisLog.warn("Iris dimension '{}' is already present in the running server; reusing it", dimensionId);
Handle handle = requested.bind(present, generator);
HANDLES.put(dimensionId, handle);
return handle;
}
private static Handle register(Handle handle) {
HANDLES.put(handle.dimensionId(), handle);
ModdedIrisLog.info("Iris injected runtime dimension '{}' (pack={} dim={} seed={})",
handle.dimensionId(), handle.pack(), handle.packDimensionKey(), handle.seed());
return handle;
}
private static IllegalStateException injectionFailure(DimensionRequest requested, Throwable failure) {
ModdedIrisLog.error("Iris failed to inject runtime dimension '{}' (pack={} dim={} seed={})",
requested.dimensionId(), requested.pack(), requested.packDimensionKey(), requested.seed(), failure);
return new IllegalStateException("Iris runtime dimension injection failed for " + requested.dimensionId(), failure);
}
public static UpdateResult stagePersistentUpdate(
NativeModdedServer server,
String dimensionId,
String pack,
String packDimensionKey
) {
ModdedDimensionCreations.requireNotCreating(dimensionId);
synchronized (LOCK) {
ModdedDimensionRegistryStore.PersistentDimension previous =
ModdedDimensionRegistryStore.get(server, dimensionId);
@@ -313,6 +335,7 @@ public final class ModdedDimensionManager {
}
public static boolean removePersistent(NativeModdedServer server, String dimensionId, boolean wipeStorage) {
ModdedDimensionCreations.requireNotCreating(dimensionId);
ModdedDimensionRegistryStore.PersistentDimension previous =
ModdedDimensionRegistryStore.get(server, dimensionId);
ModdedDimensionRegistryStore.remove(server, dimensionId);
@@ -392,16 +415,16 @@ public final class ModdedDimensionManager {
}
}
private static Handle inject(
private static Handle createLevel(
NativeModdedServer server,
NativeDimensionRuntime serverAccess,
String dimensionId,
String key,
String pack,
String packDimensionKey,
long seed,
DimensionRequest requested,
RuntimePack runtimePack
) {
String dimensionId = requested.dimensionId();
String pack = requested.pack();
String packDimensionKey = requested.packDimensionKey();
long seed = requested.seed();
String typeRef = runtimePack.dimensionTypeKey();
ModdedForcedDatapack.requireRegisteredDimensionType(typeRef,
serverAccess.hasDimensionType(server, typeRef) ? Optional.of(Boolean.TRUE) : Optional.empty(),
@@ -414,35 +437,54 @@ public final class ModdedDimensionManager {
runtimePack.generationMode());
return generator;
}));
NativeWorld level = created.world();
IrisModdedChunkGenerator generator = created.generator();
try {
serverAccess.initialize(server, created.world());
return requested.bind(created.world(), created.generator());
} catch (Throwable error) {
rollbackInjection(server, serverAccess, dimensionId, created.world(), created.generator(), false, error);
throw rethrow(dimensionId, error);
}
}
private static Handle publishLevel(
NativeModdedServer server,
NativeDimensionRuntime serverAccess,
Handle created,
Engine prepared
) {
NativeWorld level = created.level();
IrisModdedChunkGenerator generator = created.generator();
boolean loadEventStarted = false;
try {
serverAccess.initialize(server, level);
if (prepared != null) {
ModdedWorldEngines.installPrepared(level, prepared);
}
generator.bindLevel(level);
Handle handle = new Handle(dimensionId, pack, packDimensionKey, seed, level, generator);
NativeWorld previous = serverAccess.publish(server, level);
if (previous != null) {
throw new IllegalStateException("Iris cannot inject dimension '" + dimensionId
throw new IllegalStateException("Iris cannot inject dimension '" + created.dimensionId()
+ "': the level was registered concurrently");
}
serverAccess.addWorldBorderListener(server, level);
loadEventStarted = true;
ModdedEngineBootstrap.loader().fireDynamicLevelLoad(level);
return handle;
return created;
} catch (Throwable error) {
rollbackInjection(server, serverAccess, key, level, generator, loadEventStarted, error);
if (error instanceof RuntimeException runtimeException) {
throw runtimeException;
}
if (error instanceof Error fatalError) {
throw fatalError;
}
throw new IllegalStateException("Iris runtime dimension injection failed for " + dimensionId, error);
rollbackInjection(server, serverAccess, created.dimensionId(), level, generator, loadEventStarted, error);
throw rethrow(created.dimensionId(), error);
}
}
private static RuntimeException rethrow(String dimensionId, Throwable error) {
if (error instanceof RuntimeException runtimeException) {
return runtimeException;
}
if (error instanceof Error fatalError) {
throw fatalError;
}
return new IllegalStateException("Iris runtime dimension injection failed for " + dimensionId, error);
}
private static void rollbackInjection(NativeModdedServer server, NativeDimensionRuntime serverAccess,
String key, NativeWorld level,
IrisModdedChunkGenerator generator, boolean loadEventStarted,
@@ -511,19 +553,21 @@ public final class ModdedDimensionManager {
.normalize();
PackValidationRegistry.requireLoadable(packRoot);
IrisData data = IrisData.openDatapackCompiler(packRoot.toFile());
String dimensionTypeRef;
try {
IrisDimension dimension = data.getDimensionLoader().load(dimensionKey, false);
if (dimension == null) {
throw new IllegalStateException("Transient Iris Studio pack does not contain dimension '"
+ dimensionKey + "'.");
}
dimensionTypeRef = ModdedWorldgenIds.dimensionTypeRef(pack, dimension);
} finally {
data.close();
}
return new RuntimePack(
packRoot,
dimensionKey,
ModdedWorldgenIds.dimensionTypeRef(pack, dimensionKey),
dimensionTypeRef,
ModdedGenerationMode.TRANSIENT_STUDIO
);
}
@@ -542,9 +586,30 @@ public final class ModdedDimensionManager {
}
}
@FunctionalInterface
private interface RuntimePackSource {
RuntimePack open();
}
private record DimensionRequest(String dimensionId, String pack, String packDimensionKey, long seed) {
private static DimensionRequest of(Handle handle) {
return new DimensionRequest(handle.dimensionId(), handle.pack(), handle.packDimensionKey(), handle.seed());
}
private Handle bind(NativeWorld level, IrisModdedChunkGenerator generator) {
return new Handle(dimensionId, pack, packDimensionKey, seed, level, generator);
}
}
public record Handle(String dimensionId, String pack, String packDimensionKey, long seed, NativeWorld level, IrisModdedChunkGenerator generator) {
}
record PendingInjection(Handle handle, boolean present) {
PendingInjection {
handle = Objects.requireNonNull(handle, "handle");
}
}
public record UpdateResult(
boolean pending,
boolean restartRequired,
@@ -28,6 +28,7 @@ import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.generation.terrain.IrisRegion;
import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeResourceKeys;
import java.io.File;
import java.util.LinkedHashSet;
import java.util.Locale;
import java.util.Set;
@@ -126,6 +127,20 @@ final class ModdedDimensionMetadata {
}
}
static void warmConfiguredPack(File packDirectory, String dimensionKey) {
ConfiguredPack configured = ConfiguredPack.load(packDirectory, dimensionKey);
collectConfiguredBiomeKeys(configured.dimension(), configured.data());
}
record ConfiguredPack(IrisData data, IrisDimension dimension, DimensionMetadata metadata) {
static ConfiguredPack load(File packDirectory, String dimensionKey) {
IrisData data = IrisData.get(packDirectory);
IrisDimension dimension = data.getDimensionLoader().load(dimensionKey);
if (dimension == null) {
throw new IllegalStateException("Iris dimension '" + dimensionKey
+ "' missing from pack " + packDirectory.getAbsolutePath());
}
return new ConfiguredPack(data, dimension, dimensionMetadata(dimension));
}
}
}
@@ -194,6 +194,11 @@ public final class ModdedDimensionRegistryStore {
write(file, dimensions, List.of());
}
static synchronized void writeWorldRoot(Path worldRoot, List<PersistentDimension> dimensions) {
Path file = storeFile(worldRoot);
write(file, dimensions, contents(file).unparsed());
}
private static void write(Path file, List<PersistentDimension> dimensions, List<Object> unparsed) {
JSONArray entries = new JSONArray();
for (PersistentDimension dimension : dimensions) {
@@ -0,0 +1,144 @@
/*
* 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 art.arcane.volmlib.util.json.JSONObject;
import java.nio.file.Path;
import java.util.Objects;
import java.util.OptionalLong;
import java.util.regex.Pattern;
public record ModdedDimensionReplacement(String levelKey, String pack, String packDimension, OptionalLong seed) {
private static final Pattern NAMESPACE = Pattern.compile("[a-z0-9_.-]+");
private static final Pattern PATH = Pattern.compile("[a-z0-9_.-]+(?:/[a-z0-9_.-]+)*");
private static final Pattern PACK = Pattern.compile("[A-Za-z0-9_.-]+");
private static final Pattern PACK_DIMENSION = Pattern.compile("[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*");
private static final String TYPE = "type";
private static final String SEED = "seed";
public ModdedDimensionReplacement {
Objects.requireNonNull(levelKey, "levelKey");
Objects.requireNonNull(pack, "pack");
Objects.requireNonNull(packDimension, "packDimension");
Objects.requireNonNull(seed, "seed");
}
public static ModdedDimensionReplacement parse(String levelKey, String type, OptionalLong seed) {
String level = requireLevelKey(levelKey);
String reference = type == null ? "" : type.trim();
int packColon = reference.indexOf(':');
String pack = packColon >= 0 ? reference.substring(0, packColon) : reference;
String packDimension = packColon >= 0 ? reference.substring(packColon + 1) : reference;
if (!PACK.matcher(pack).matches()
|| !PACK_DIMENSION.matcher(packDimension).matches()
|| pack.contains("..")
|| packDimension.contains("..")) {
throw new IllegalArgumentException("Invalid replacement type '" + type
+ "' for " + level + ". Use pack or pack:dimensionKey.");
}
OptionalLong replacementSeed = Objects.requireNonNull(seed, "seed");
if (replacementSeed.isPresent()) {
requireUsableSeed(replacementSeed.getAsLong());
}
return new ModdedDimensionReplacement(level, pack, packDimension, replacementSeed);
}
public static long requireUsableSeed(long seed) {
if (seed == Long.MIN_VALUE) {
throw new IllegalArgumentException("Seed " + Long.MIN_VALUE
+ " is reserved on mod loaders; choose any other signed 64-bit integer");
}
return seed;
}
public static ModdedDimensionReplacement fromJson(String levelKey, Object value) {
if (!(value instanceof JSONObject entry)) {
throw new IllegalArgumentException("the replacement must be an object with a \"type\" and an optional \"seed\"");
}
Object type = entry.opt(TYPE);
if (!(type instanceof String typeRef)) {
throw new IllegalArgumentException("the replacement \"type\" must be a pack or pack:dimensionKey string");
}
if (!entry.has(SEED) || entry.isNull(SEED)) {
return parse(levelKey, typeRef, OptionalLong.empty());
}
Object seed = entry.opt(SEED);
if (!(seed instanceof Integer) && !(seed instanceof Long)) {
throw new IllegalArgumentException("the replacement \"seed\" must be a signed 64-bit integer");
}
return parse(levelKey, typeRef, OptionalLong.of(((Number) seed).longValue()));
}
public JSONObject toJson() {
JSONObject json = new JSONObject();
json.put(TYPE, packRef());
if (seed.isPresent()) {
json.put(SEED, seed.getAsLong());
}
return json;
}
public String packRef() {
return pack.equals(packDimension) ? pack : pack + ":" + packDimension;
}
public ModdedDimensionReplacement withSeed(long replacementSeed) {
return new ModdedDimensionReplacement(levelKey, pack, packDimension, OptionalLong.of(replacementSeed));
}
public ModdedDimensionReplacement withPack(String canonicalPack, String canonicalPackDimension) {
return new ModdedDimensionReplacement(levelKey, canonicalPack, canonicalPackDimension, seed);
}
public String namespace() {
return levelKey.substring(0, levelKey.indexOf(':'));
}
public String path() {
return levelKey.substring(levelKey.indexOf(':') + 1);
}
public Path storageFolder(Path worldRoot) {
return storageFolder(worldRoot, levelKey);
}
public static Path storageFolder(Path worldRoot, String levelKey) {
String level = requireLevelKey(levelKey);
int colon = level.indexOf(':');
Path folder = worldRoot.resolve("dimensions").resolve(level.substring(0, colon));
for (String segment : level.substring(colon + 1).split("/")) {
folder = folder.resolve(segment);
}
return folder;
}
public static String requireLevelKey(String levelKey) {
String level = levelKey == null ? "" : levelKey.trim();
int colon = level.indexOf(':');
if (colon <= 0
|| !NAMESPACE.matcher(level.substring(0, colon)).matches()
|| !PATH.matcher(level.substring(colon + 1)).matches()
|| level.contains("..")) {
throw new IllegalArgumentException("Invalid dimension key '" + levelKey
+ "'. Use namespace:path, for example examplemod:skylands.");
}
return level;
}
}
@@ -0,0 +1,315 @@
/*
* 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 art.arcane.iris.modded.catalog.ModdedWorldgenCatalogs;
import art.arcane.iris.pack.ingest.catalog.CatalogLevelStem;
import art.arcane.iris.pack.ingest.catalog.WorldgenCatalog;
import art.arcane.iris.pack.ingest.catalog.WorldgenCatalogStore;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.volmlib.util.json.JSONObject;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.OptionalLong;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
public final class ModdedDimensionReplacements {
public static final String IRIS_GENERATOR = "irisworldgen:iris";
private static final Set<String> VANILLA_LEVELS = Set.of("minecraft:overworld", "minecraft:the_nether",
"minecraft:the_end");
private static volatile RecordedCatalog recordedCatalog;
private static final String PLACEHOLDER_BIOME_SOURCE = "minecraft:fixed";
private static final String PLACEHOLDER_BIOME = "minecraft:plains";
private static final String DIMENSIONS = "dimensions";
private static final String JSON = ".json";
private ModdedDimensionReplacements() {
}
public static String levelStemJson(String dimensionTypeKey, ModdedDimensionReplacement replacement) {
JSONObject biomeSource = new JSONObject();
biomeSource.put("type", PLACEHOLDER_BIOME_SOURCE);
biomeSource.put("biome", PLACEHOLDER_BIOME);
JSONObject generator = new JSONObject();
generator.put("type", IRIS_GENERATOR);
generator.put("biome_source", biomeSource);
generator.put("dimension", replacement.packRef());
JSONObject stem = new JSONObject();
stem.put("type", Objects.requireNonNull(dimensionTypeKey, "dimensionTypeKey"));
stem.put("generator", generator);
return stem.toString(2) + "\n";
}
public static Path writeLevelStem(Path datapackRoot, ModdedDimensionReplacement replacement, String dimensionTypeKey)
throws IOException {
Path output = datapackRoot.resolve("data").resolve(replacement.namespace()).resolve("dimension");
String[] segments = replacement.path().split("/");
for (int index = 0; index < segments.length - 1; index++) {
output = output.resolve(segments[index]);
}
output = output.resolve(segments[segments.length - 1] + ".json");
Files.createDirectories(output.getParent());
Files.writeString(output, levelStemJson(dimensionTypeKey, replacement), StandardCharsets.UTF_8);
return output;
}
public static void writeLevelStems(Path datapackRoot, Map<String, ModdedDimensionReplacement> configured,
StagedTypes staged, Path worldRoot, ReplaceableLevels levels) throws IOException {
for (ModdedDimensionReplacement replacement : new TreeMap<>(configured).values()) {
if (!levels.replaceable(replacement.levelKey())) {
ModdedIrisLog.error(unreplaceableReason(replacement.levelKey(), levels)
+ " Iris writes no dimension override for it.");
continue;
}
String typeRef = staged.typeRef(replacement, worldRoot);
if (typeRef == null) {
skipUnstaged(replacement, staged, worldRoot);
continue;
}
writeLevelStem(datapackRoot, replacement, typeRef);
ModdedIrisLog.info("Iris dimension replacement staged: {} generates from {} with dimension type {}",
replacement.levelKey(), replacement.packRef(), typeRef);
}
}
public static Map<String, ModdedDimensionReplacement> configured() throws IOException {
return canonical(ModdedModConfig.get().dimensionReplacements(), IrisPlatforms.get().packsFolderNoCreate().toPath());
}
public static Map<String, ModdedDimensionReplacement> stageable(Map<String, ModdedDimensionReplacement> configured,
ReplaceableLevels levels) {
Map<String, ModdedDimensionReplacement> stageable = new TreeMap<>();
for (ModdedDimensionReplacement replacement : configured.values()) {
if (levels.replaceable(replacement.levelKey())) {
stageable.put(replacement.levelKey(), replacement);
}
}
return Map.copyOf(stageable);
}
public static ReplaceableLevels replaceableLevels(Path worldRoot, Map<String, ModdedDimensionReplacement> configured)
throws IOException {
if (configured.isEmpty()) {
return ReplaceableLevels.unknown();
}
return replaceableLevels(worldRoot);
}
public static ReplaceableLevels replaceableLevels(Path worldRoot) throws IOException {
Set<String> applied = worldRoot == null
? Set.of()
: ModdedReplacementReconciler.readState(worldRoot).applied().keySet();
Optional<WorldgenCatalog> catalog = ModdedWorldgenCatalogs.latest();
if (catalog.isEmpty()) {
catalog = recordedCatalog();
}
return catalog.isEmpty()
? ReplaceableLevels.unknown()
: ReplaceableLevels.of(catalog.get().levelStems(), applied);
}
public static String unreplaceableReason(String levelKey, ReplaceableLevels levels) {
if (!levels.known()) {
return "Iris refuses to replace " + levelKey + ": it has no record of which dimensions this server loads"
+ " from level stems yet. Start the server once so Iris can read the dimension registry.";
}
return "Iris refuses to replace " + levelKey + ": it is not a dimension the server loads from a level stem;"
+ " a mod creates it while the server runs, so its data stays untouched.";
}
private static Optional<WorldgenCatalog> recordedCatalog() {
Path file = ModdedWorldgenCatalogs.catalogFile();
if (!Files.isRegularFile(file)) {
return Optional.empty();
}
try {
long modified = Files.getLastModifiedTime(file).toMillis();
RecordedCatalog cached = recordedCatalog;
if (cached != null && cached.file().equals(file) && cached.modified() == modified) {
return Optional.of(cached.catalog());
}
WorldgenCatalog catalog = WorldgenCatalogStore.read(file);
recordedCatalog = new RecordedCatalog(file, modified, catalog);
return Optional.of(catalog);
} catch (IOException | RuntimeException unreadable) {
ModdedIrisLog.error("Iris could not read the recorded worldgen catalog at {}; only the vanilla dimensions are replaceable until the next start records it",
file, unreadable);
return Optional.empty();
}
}
public static OptionalLong loadedSeed(String levelKey) {
ModdedModConfig config = ModdedModConfig.loaded();
if (config == null) {
return OptionalLong.empty();
}
ModdedDimensionReplacement replacement = config.dimensionReplacements().get(levelKey);
return replacement == null ? OptionalLong.empty() : replacement.seed();
}
static Map<String, ModdedDimensionReplacement> canonical(Map<String, ModdedDimensionReplacement> configured,
Path packsRoot) throws IOException {
Map<String, ModdedDimensionReplacement> canonical = new TreeMap<>();
for (ModdedDimensionReplacement replacement : configured.values()) {
canonical.put(replacement.levelKey(), canonical(replacement, packsRoot));
}
return Map.copyOf(canonical);
}
public static ModdedDimensionReplacement canonical(ModdedDimensionReplacement replacement, Path packsRoot)
throws IOException {
Path pack = packsRoot.resolve(replacement.pack());
if (!Files.isDirectory(pack)) {
return replacement;
}
String packName = sameIgnoringCase(pack.toRealPath().getFileName().toString(), replacement.pack());
Path dimensions = pack.resolve(DIMENSIONS);
Path dimensionFile = dimensions.resolve(replacement.packDimension() + JSON);
String dimension = replacement.packDimension();
if (Files.isRegularFile(dimensionFile)) {
String relative = dimensions.toRealPath().relativize(dimensionFile.toRealPath()).toString()
.replace(File.separatorChar, '/');
dimension = sameIgnoringCase(relative.substring(0, relative.length() - JSON.length()), replacement.packDimension());
}
return replacement.withPack(packName, dimension);
}
public static boolean installed(ModdedDimensionReplacement replacement, Path packsRoot) {
return Files.isRegularFile(packsRoot.resolve(replacement.pack()).resolve(DIMENSIONS)
.resolve(replacement.packDimension() + JSON));
}
public static boolean matches(ModdedDimensionReplacement applied, ModdedDimensionReplacement configured) {
if (applied == null || configured == null) {
return applied == configured;
}
if (!applied.packRef().equals(configured.packRef())) {
return false;
}
return configured.seed().isEmpty() || configured.seed().equals(applied.seed());
}
public static Set<String> changedLevels(Map<String, ModdedDimensionReplacement> applied,
Map<String, ModdedDimensionReplacement> configured) {
Set<String> levels = new TreeSet<>(applied.keySet());
levels.addAll(configured.keySet());
Set<String> changed = new TreeSet<>();
for (String level : levels) {
if (!matches(applied.get(level), configured.get(level))) {
changed.add(level);
}
}
return changed;
}
private static void skipUnstaged(ModdedDimensionReplacement replacement, StagedTypes staged, Path worldRoot)
throws IOException {
String reason = "pack '" + replacement.pack() + "' dimension '" + replacement.packDimension()
+ "' is not installed or failed validation" + caseHint(replacement, staged);
if (worldRoot != null && ModdedReplacementReconciler.hasReplacementRecord(worldRoot, replacement.levelKey())) {
throw new IOException("Iris refused to stage the replacement of " + replacement.levelKey() + ": " + reason
+ ", and the level already has an applied replacement or Iris generation history. Restore the pack, or remove the "
+ replacement.levelKey() + " entry from dimensionReplacements in modded.json to hand the level back to its own generator.");
}
ModdedIrisLog.error("Iris skipped the replacement of {}: {}, so the dimension keeps its own generator and the replacement stays unapplied",
replacement.levelKey(), reason);
}
private static String caseHint(ModdedDimensionReplacement replacement, StagedTypes staged) {
String wanted = replacement.pack() + ":" + replacement.packDimension();
for (String installed : staged.installed().keySet()) {
if (installed.equalsIgnoreCase(wanted)) {
return "; pack names and dimension keys are case-sensitive, the installed dimension is " + installed;
}
}
return "";
}
private static String sameIgnoringCase(String actual, String configured) {
return actual.equalsIgnoreCase(configured) ? actual : configured;
}
public static String fingerprint(Map<String, ModdedDimensionReplacement> configured) {
StringBuilder canonical = new StringBuilder();
for (Map.Entry<String, ModdedDimensionReplacement> entry : new TreeMap<>(configured).entrySet()) {
canonical.append(entry.getKey()).append('=').append(entry.getValue().packRef()).append('\n');
}
return canonical.toString();
}
private record RecordedCatalog(Path file, long modified, WorldgenCatalog catalog) {
}
public record ReplaceableLevels(boolean known, Map<String, String> stemGenerators, Set<String> applied) {
public ReplaceableLevels {
stemGenerators = Map.copyOf(stemGenerators);
applied = Set.copyOf(applied);
}
public static ReplaceableLevels unknown() {
return new ReplaceableLevels(false, Map.of(), Set.of());
}
public static ReplaceableLevels of(List<CatalogLevelStem> stems, Set<String> applied) {
Map<String, String> generators = new TreeMap<>();
for (CatalogLevelStem stem : stems) {
generators.put(stem.key(), stem.generatorType());
}
return new ReplaceableLevels(true, generators, applied);
}
public boolean replaceable(String levelKey) {
if (VANILLA_LEVELS.contains(levelKey)) {
return true;
}
String generator = stemGenerators.get(levelKey);
if (generator == null) {
return false;
}
return !IRIS_GENERATOR.equals(generator) || applied.contains(levelKey);
}
}
public record StagedTypes(Map<String, String> installed, Map<Path, String> retained) {
public static StagedTypes create() {
return new StagedTypes(new LinkedHashMap<>(), new LinkedHashMap<>());
}
String typeRef(ModdedDimensionReplacement replacement, Path worldRoot) {
if (worldRoot != null) {
String retainedType = retained.get(replacement.storageFolder(worldRoot).toAbsolutePath().normalize());
if (retainedType != null) {
return retainedType;
}
}
return installed.get(replacement.pack() + ":" + replacement.packDimension());
}
}
}
@@ -43,6 +43,10 @@ public final class ModdedDimensionStorage {
private ModdedDimensionStorage() {
}
static List<String> chunkDataFolders() {
return CHUNK_DATA_FOLDERS;
}
public static File storageFolder(NativeModdedServer server, String dimension) {
return server.dimensionFolder(dimension).toFile();
}
@@ -58,6 +58,7 @@ import art.arcane.iris.generation.block.BlockDataMergeSupport;
import art.arcane.iris.structure.object.IrisObjectRotation;
import art.arcane.iris.generation.block.TileData;
import art.arcane.iris.modded.api.ModdedCustomContentRegistry;
import art.arcane.iris.modded.catalog.ModdedWorldgenCatalogs;
import art.arcane.iris.modded.command.IrisModdedCommands;
import art.arcane.iris.modded.command.ModdedGuiHost;
import art.arcane.iris.modded.command.ModdedObjectUndo;
@@ -144,6 +145,7 @@ public final class ModdedEngineBootstrap {
}
IrisModdedCommands.openDownloadAdmission();
ModdedStartup.prepareForStartup();
ModdedStartup.prepareDimensionRestore(server);
IrisModdedChunkGenerator.startGenPool();
bindWorldGenerators(server);
services().enableAll();
@@ -163,6 +165,13 @@ public final class ModdedEngineBootstrap {
finishPreviousServer("server start");
captureInitialSpawn(server);
ModdedStartup.prepareForStartup();
ModdedStartup.prepareDimensionRestore(server);
}
public static void levelsReady(NativeModdedServer server) {
server.refreshWorlds();
bindWorldGenerators(server);
ModdedStartup.reinjectPersistentDimensions(server);
}
public static void serverStarted(NativeModdedServer server) {
@@ -171,8 +180,15 @@ public final class ModdedEngineBootstrap {
server.refreshWorlds();
bindWorldGenerators(server);
ModdedStartup.runOnce(server);
reconcileSpawn(server);
boolean replacedOverworld = !loader().clientEnvironment() && ModdedReplacementEntry.prepare(server);
reconcileSpawn(server, replacedOverworld);
if (!loader().clientEnvironment()) {
ModdedReplacementReconciler.recordApplied(server);
ModdedReplacementReconciler.stopUnpublishedEngines(server);
ModdedReplacementEntry.retireIfComplete();
}
ModdedWorldCheck.serverStarted(server);
ModdedWorldgenCatalogs.capture(server);
}
public static void levelLoaded(NativeWorld world) {
@@ -203,6 +219,7 @@ public final class ModdedEngineBootstrap {
stoppedPending = true;
Throwable failure = null;
failure = runStopStage(failure, "pack downloads", IrisModdedCommands::shutdownDownloads);
failure = runStopStage(failure, "dimension creations", ModdedDimensionCreations::cancelAll);
failure = runStopStage(failure, "world check", () -> ModdedWorldCheck.serverStopped(stoppingServer));
failure = runStopStage(failure, "protocol", ModdedProtocolHandler::stop);
failure = runStopStage(failure, "pregenerator", ModdedPregenJob::shutdown);
@@ -270,6 +287,7 @@ public final class ModdedEngineBootstrap {
Throwable failure = null;
failure = runStopStage(failure, "world engines", ModdedWorldEngines::shutdown);
failure = runStopStage(failure, "primary world router", ModdedPrimaryWorldRouter::clear);
failure = runStopStage(failure, "replacement entry guard", ModdedReplacementEntry::clear);
failure = runStopStage(failure, "dimension manager", ModdedDimensionManager::clear);
ModdedScheduler scheduler = schedulerOrNull();
if (scheduler != null) {
@@ -325,7 +343,7 @@ public final class ModdedEngineBootstrap {
spawnCaptureServer = server;
}
private static void reconcileSpawn(NativeModdedServer server) {
private static void reconcileSpawn(NativeModdedServer server, boolean replacedOverworld) {
NativeServerSpawn spawn = new NativeServerSpawn(server);
NativeServerSpawn.Respawn current = spawn.current();
if (current == null || current.world() == null) {
@@ -337,7 +355,8 @@ public final class ModdedEngineBootstrap {
}
String dimensionId = level.name();
boolean studio = dimensionId.startsWith("irisworldgen:studio_");
if (!shouldReconcileSpawn(initialSpawnWasDefault, studio, current.position().x(), current.position().z())) {
if (!shouldReconcileSpawn(initialSpawnWasDefault || replacedOverworld, studio,
current.position().x(), current.position().z())) {
return;
}
int surfaceY = spawn.surfaceAtOrigin(level);
@@ -367,6 +386,9 @@ public final class ModdedEngineBootstrap {
selfTest(moddedLoader.getClass().getClassLoader());
bind();
IrisLanguage.initialize();
if (!moddedLoader.clientEnvironment()) {
ModdedReplacementReconciler.reconcileEarly();
}
ModdedStartup.prefetchStartupDatapack();
if (!moddedLoader.clientEnvironment()) {
MainWorldService.reconcileEarly();
@@ -324,6 +324,10 @@ public final class ModdedForcedDatapack {
false
);
digest.update(inputFingerprint.getBytes(StandardCharsets.UTF_8));
Map<String, ModdedDimensionReplacement> replacements = ModdedDimensionReplacements.configured();
digest.update(ModdedDimensionReplacements.fingerprint(ModdedDimensionReplacements.stageable(replacements,
ModdedDimensionReplacements.replaceableLevels(worldRootOrNull(), replacements)))
.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest.digest());
}
@@ -333,15 +337,21 @@ public final class ModdedForcedDatapack {
int packCount = 0;
KList<String> presetIds = new KList<>();
ModdedDimensionReplacements.StagedTypes stagedDimensionTypes = ModdedDimensionReplacements.StagedTypes.create();
Path worldRoot = worldRootOrNull();
Set<PackSelection> legacySelections = unadoptedSelections(worldRoot);
List<File> packRoots = compilationPackRoots(worldRoot);
for (File pack : packRoots) {
PackStageContext context = packStageContext(pack.toPath(), legacySelections);
if (stagePack(context, fixer, stagingDirectory, seenBiomes, presetIds)) {
if (stagePack(context, fixer, stagingDirectory, seenBiomes, presetIds, stagedDimensionTypes)) {
packCount++;
}
}
if (!ModdedEngineBootstrap.loader().clientEnvironment()) {
Map<String, ModdedDimensionReplacement> replacements = ModdedDimensionReplacements.configured();
ModdedDimensionReplacements.writeLevelStems(stagingDirectory, replacements, stagedDimensionTypes, worldRoot,
ModdedDimensionReplacements.replaceableLevels(worldRoot, replacements));
}
IrisDatapackCompiler.installRetainedRegistrySources(packRoots, List.of(stagingDirectory.toFile()), fixer);
writePackMeta(stagingDirectory);
@@ -362,7 +372,8 @@ public final class ModdedForcedDatapack {
private static boolean stagePack(PackStageContext context, IDataFixer fixer, Path stagingDirectory,
Map<String, KSet<String>> seenBiomes,
KList<String> presetIds) throws IOException {
KList<String> presetIds,
ModdedDimensionReplacements.StagedTypes stagedDimensionTypes) throws IOException {
File sourcePack = context.packRoot().toFile();
PackValidationResult validation;
try {
@@ -392,11 +403,12 @@ public final class ModdedForcedDatapack {
stagingDirectory.getParent(), PACK_FOLDER + ".pack-" + sourcePack.getName() + "-");
Map<String, KSet<String>> packBiomes = new LinkedHashMap<>();
KList<String> packPresetIds = new KList<>();
ModdedDimensionReplacements.StagedTypes packDimensionTypes = ModdedDimensionReplacements.StagedTypes.create();
KList<File> packFolders = new KList<>();
packFolders.add(packStagingDirectory.toFile());
boolean installed;
try {
installed = installPack(context, fixer, packFolders, packBiomes, packPresetIds);
installed = installPack(context, fixer, packFolders, packBiomes, packPresetIds, packDimensionTypes);
} catch (Throwable installationFailure) {
if (context.required()) {
throw requiredPackFailure(context, "datapack serialization", installationFailure);
@@ -414,6 +426,8 @@ public final class ModdedForcedDatapack {
mergeDirectory(packStagingDirectory, stagingDirectory);
mergeBiomes(seenBiomes, packBiomes);
presetIds.addAll(packPresetIds);
stagedDimensionTypes.installed().putAll(packDimensionTypes.installed());
stagedDimensionTypes.retained().putAll(packDimensionTypes.retained());
return true;
} finally {
try {
@@ -454,6 +468,8 @@ public final class ModdedForcedDatapack {
Files.createDirectories(destination);
} else if (!Files.exists(destination)) {
Files.copy(source, destination);
} else if (Files.isRegularFile(destination) && ModdedDatapackTagMerge.isTag(relative)) {
ModdedDatapackTagMerge.merge(source, destination);
} else if (!Files.isRegularFile(destination) || Files.mismatch(source, destination) != -1L) {
throw new IOException("Conflicting Iris datapack output at " + destination + ".");
}
@@ -470,7 +486,8 @@ public final class ModdedForcedDatapack {
private static boolean installPack(PackStageContext context, IDataFixer fixer, KList<File> folders,
Map<String, KSet<String>> seenBiomes,
KList<String> presetIds) throws IOException {
KList<String> presetIds,
ModdedDimensionReplacements.StagedTypes dimensionTypes) throws IOException {
File packFolder = context.packRoot().toFile();
String packName = context.packName();
File dimensionsDirectory = new File(packFolder, "dimensions");
@@ -504,18 +521,30 @@ public final class ModdedForcedDatapack {
biomesForNamespace(seenBiomes, "iris"),
context
);
writeDimensionType(
folders,
fixer,
dimension,
context.dimensionTypeRef(dimensionKey),
context.emitLegacyDimensionTypeAlias(),
context
);
String typeRef = context.dimensionTypeRef(dimensionKey);
if (dimension.hasBorrowedDimensionType()) {
typeRef = requireBorrowedType(context, dimension, typeRef);
} else {
requireScopedType(context, typeRef);
writeDimensionType(
folders,
fixer,
dimension,
typeRef,
context.emitLegacyDimensionTypeAlias(),
context
);
}
if (context.retainedDimensionRoot() != null) {
dimensionTypes.retained().put(context.retainedDimensionRoot(), typeRef);
}
if (context.emitPresets()) {
String presetRef = ModdedWorldgenIds.presetRef(packName, dimensionKey);
writeWorldPreset(folders, packName, dimensionKey, presetRef);
presetIds.add(presetRef);
dimensionTypes.installed().put(packName + ":" + dimensionKey, typeRef);
if (!dimension.hasBorrowedDimensionType()) {
String presetRef = ModdedWorldgenIds.presetRef(packName, dimensionKey);
writeWorldPreset(folders, packName, dimensionKey, presetRef);
presetIds.add(presetRef);
}
}
installedDimensions++;
}
@@ -542,6 +571,22 @@ public final class ModdedForcedDatapack {
+ " (datapack source injection failed; see the Iris boot ERROR), so a restart alone will not register it.")));
}
private static void requireScopedType(PackStageContext context, String stagedTypeRef) throws IOException {
if (context.required() && ModdedWorldgenIds.scopedDimensionType(stagedTypeRef).isEmpty()) {
throw new IOException("Retained Iris dimension type key is not scoped: " + stagedTypeRef);
}
}
private static String requireBorrowedType(PackStageContext context, IrisDimension dimension, String stagedTypeRef)
throws IOException {
String borrowed = dimension.getBorrowedDimensionType().trim();
if (context.required() && !borrowed.equals(stagedTypeRef)) {
throw new IOException("Retained Iris generation pack at " + context.packRoot() + " borrows dimension type "
+ borrowed + ", but its generation history records " + stagedTypeRef + ".");
}
return borrowed;
}
private static void writeWorldPreset(KList<File> folders, String packName, String dimensionKey,
String presetRef) throws IOException {
String dimensionRef = dimensionKey.equals(packName) ? packName : packName + ":" + dimensionKey;
@@ -789,14 +834,16 @@ public final class ModdedForcedDatapack {
Path normalizedRoot = packRoot.toAbsolutePath().normalize();
RetainedPack retained = retainedPack(normalizedRoot);
if (retained != null) {
ModdedWorldgenIds.ScopedDimension identity = ModdedWorldgenIds
.scopedDimensionType(retained.epoch().dimensionContract().dimensionTypeKey())
.orElseThrow(() -> new IOException("Retained Iris dimension type key is not scoped: "
+ retained.epoch().dimensionContract().dimensionTypeKey()));
if (!identity.dimension().equals(retained.epoch().dimensionContract().dimensionKey())) {
GenerationEpoch.DimensionContract contract = retained.epoch().dimensionContract();
Optional<ModdedWorldgenIds.ScopedDimension> scoped = ModdedWorldgenIds.scopedDimensionType(contract.dimensionTypeKey());
if (scoped.isEmpty()) {
return PackStageContext.retained(normalizedRoot, retainedPackName(retained.epoch(), normalizedRoot), retained);
}
ModdedWorldgenIds.ScopedDimension identity = scoped.get();
if (!identity.dimension().equals(contract.dimensionKey())) {
throw new IOException("Retained Iris dimension type identity does not match its epoch contract.");
}
return PackStageContext.retained(normalizedRoot, identity.pack(), retained.epoch());
return PackStageContext.retained(normalizedRoot, identity.pack(), retained);
}
String packName = normalizedRoot.getFileName().toString();
@@ -809,6 +856,16 @@ public final class ModdedForcedDatapack {
return PackStageContext.installed(normalizedRoot, packName, legacyDimensions);
}
static String retainedPackName(GenerationEpoch epoch, Path packRoot) throws IOException {
String dimensionKey = epoch.dimensionContract().dimensionKey();
Set<String> packs = ModdedWorldgenIds.aliasPacks(epoch.registryContract(), dimensionKey);
if (packs.size() > 1) {
throw new IOException("Retained Iris generation pack at " + packRoot
+ " records scoped biome aliases from several packs " + packs + ".");
}
return packs.isEmpty() ? dimensionKey : packs.iterator().next();
}
private static RetainedPack retainedPack(Path packRoot) throws IOException {
Path epochRoot = packRoot.getParent();
Path epochsRoot = epochRoot == null ? null : epochRoot.getParent();
@@ -868,7 +925,8 @@ public final class ModdedForcedDatapack {
boolean emitLegacyDimensionTypeAlias,
boolean required,
Set<String> legacyDimensions,
GenerationRegistryContract retainedContract
GenerationRegistryContract retainedContract,
Path retainedDimensionRoot
) implements IrisCustomBiomeAliasResolver {
private static PackStageContext installed(
Path packRoot,
@@ -884,6 +942,7 @@ public final class ModdedForcedDatapack {
true,
false,
Set.copyOf(legacyDimensions),
null,
null
);
}
@@ -891,8 +950,9 @@ public final class ModdedForcedDatapack {
private static PackStageContext retained(
Path packRoot,
String packName,
GenerationEpoch epoch
RetainedPack retained
) {
GenerationEpoch epoch = retained.epoch();
return new PackStageContext(
packRoot,
packName,
@@ -902,7 +962,8 @@ public final class ModdedForcedDatapack {
true,
true,
Set.of(),
epoch.registryContract()
epoch.registryContract(),
retained.history().paths().dimensionRoot().toAbsolutePath().normalize()
);
}
@@ -9,6 +9,7 @@ import art.arcane.iris.pack.BrokenPackException;
import art.arcane.iris.pack.PackFingerprints;
import art.arcane.iris.pack.PackValidationRegistry;
import art.arcane.iris.pack.PackValidationResult;
import art.arcane.iris.world.history.GenerationAdmission;
import art.arcane.iris.world.history.GenerationEpoch;
import art.arcane.iris.world.history.GenerationEpochContractFactory;
import art.arcane.iris.world.history.GenerationHistory;
@@ -28,6 +29,7 @@ import java.util.Collection;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Stream;
final class ModdedGenerationHistoryStorage {
private ModdedGenerationHistoryStorage() {
@@ -52,7 +54,7 @@ final class ModdedGenerationHistoryStorage {
ModdedGenerationHistoryStorage::requireRegistryDefinitions,
IrisSettings.get().getGenerator().getGenerationTransitionWidthBlocks()
);
return resolveActive(history);
return resolveActive(history, source);
} catch (IOException failure) {
throw historyFailure(levelKey, failure);
}
@@ -67,25 +69,85 @@ final class ModdedGenerationHistoryStorage {
) {
Path dimensionRoot = dimensionRoot(server, levelKey);
try {
GenerationHistory history = restoreOrAdopt(
dimensionRoot,
worldSeed,
() -> {
Path source = selectedPackSource(selectedPack, selectedDimensionKey);
return new AdoptionSource(source, captureCandidate(
source,
selectedDimensionKey,
GenerationRegistryContractFactory.CustomBiomeAliasPolicy.RETAIN_LEGACY_ALIASES
));
},
ModdedGenerationHistoryStorage::requireRegistryDefinitions
);
return resolveActive(history);
GenerationHistory history = openForRestore(dimensionRoot, selectedPack, selectedDimensionKey, worldSeed);
return resolveActive(history, installedPackSource(selectedPack));
} catch (IOException failure) {
throw historyFailure(levelKey, failure);
}
}
static PreparedHistory prepareRestore(
NativeModdedServer server,
String levelKey,
String selectedPack,
String selectedDimensionKey,
long worldSeed
) {
Path dimensionRoot = dimensionRoot(server, levelKey);
try {
GenerationHistory history = openForRestore(dimensionRoot, selectedPack, selectedDimensionKey, worldSeed);
return prepare(history, installedPackSource(selectedPack));
} catch (IOException failure) {
throw historyFailure(levelKey, failure);
}
}
static PreparedHistory prepareCreate(
NativeModdedServer server,
String levelKey,
String selectedPack,
String selectedDimensionKey,
long worldSeed
) {
Path dimensionRoot = dimensionRoot(server, levelKey);
try {
Path source = selectedPackSource(selectedPack, selectedDimensionKey);
GenerationHistory history = createOrStage(
dimensionRoot,
source,
worldSeed,
captureCandidate(source, selectedDimensionKey),
ModdedGenerationHistoryStorage::requireRegistryDefinitions,
IrisSettings.get().getGenerator().getGenerationTransitionWidthBlocks()
);
return prepare(history, source);
} catch (IOException failure) {
throw historyFailure(levelKey, failure);
}
}
private static PreparedHistory prepare(GenerationHistory history, Path validatedSource) throws IOException {
GenerationAdmission.RuntimeLease lease = history.retainRuntime();
try {
history.prepareCurrentGenerator(IrisSettings.get().getGenerator().getGenerationTransitionWidthBlocks());
return new PreparedHistory(resolveActive(history, validatedSource), lease);
} catch (IOException | RuntimeException | Error failure) {
lease.close();
throw failure;
}
}
private static GenerationHistory openForRestore(
Path dimensionRoot,
String selectedPack,
String selectedDimensionKey,
long worldSeed
) throws IOException {
return restoreOrAdopt(
dimensionRoot,
worldSeed,
() -> {
Path source = selectedPackSource(selectedPack, selectedDimensionKey);
return new AdoptionSource(source, captureCandidate(
source,
selectedDimensionKey,
adoptionAliasPolicy(dimensionRoot)
));
},
ModdedGenerationHistoryStorage::requireRegistryDefinitions
);
}
static ActivePack openOrAdopt(
NativeWorld level,
String selectedPack,
@@ -170,10 +232,23 @@ final class ModdedGenerationHistoryStorage {
);
}
static ActivePack resolveActive(GenerationHistory history) throws IOException {
static GenerationRegistryContractFactory.CustomBiomeAliasPolicy adoptionAliasPolicy(Path dimensionRoot)
throws IOException {
Path regions = dimensionRoot.resolve("region");
if (!Files.isDirectory(regions)) {
return GenerationRegistryContractFactory.CustomBiomeAliasPolicy.CONTENT_ADDRESSED_ONLY;
}
try (Stream<Path> files = Files.list(regions)) {
return files.anyMatch((Path file) -> file.getFileName().toString().endsWith(".mca"))
? GenerationRegistryContractFactory.CustomBiomeAliasPolicy.RETAIN_LEGACY_ALIASES
: GenerationRegistryContractFactory.CustomBiomeAliasPolicy.CONTENT_ADDRESSED_ONLY;
}
}
static ActivePack resolveActive(GenerationHistory history, Path validatedSource) throws IOException {
GenerationEpoch epoch = history.activeEpoch();
Path packRoot = normalize(history.activePackRoot());
validatePack(packRoot);
validatePack(packRoot, validatedSource);
validateDimension(packRoot, epoch);
return new ActivePack(
packRoot,
@@ -183,9 +258,12 @@ final class ModdedGenerationHistoryStorage {
);
}
static PackValidationResult validatePack(Path packRoot) {
PackValidationResult validation = PackValidationRegistry.getMatching(
packRoot, PackFingerprints.computePackTreeFingerprint(packRoot.toFile()));
static PackValidationResult validatePack(Path packRoot, Path validatedSource) {
String contentFingerprint = PackFingerprints.computePackTreeFingerprint(packRoot.toFile());
PackValidationResult validation = PackValidationRegistry.getMatching(packRoot, contentFingerprint);
if (validation == null && validatedSource != null) {
validation = PackValidationRegistry.publishMatchingCopy(validatedSource, packRoot, contentFingerprint);
}
if (validation == null) {
validation = ModdedStartup.validatePack(packRoot.toFile());
}
@@ -246,10 +324,9 @@ final class ModdedGenerationHistoryStorage {
PlatformGenerationRegistry generationRegistry = IrisPlatforms.get()
.registries()
.generationRegistry();
String dimensionTypeKey = generationRegistry.dimensionTypeResourceKey(
source.getFileName().toString(),
dimension.getLoadKey(),
dimension.getDimensionTypeKey()
String dimensionTypeKey = dimension.dimensionTypeResourceKey(
generationRegistry,
source.getFileName().toString()
);
GenerationEpoch.DimensionContract dimensionContract = GenerationEpochContractFactory.create(
dimension,
@@ -273,6 +350,11 @@ final class ModdedGenerationHistoryStorage {
return normalize(ModdedWorldEngines.resolvePack(pack, dimensionKey).toPath());
}
private static Path installedPackSource(String pack) {
File folder = ModdedWorldEngines.packFolder(pack);
return folder.isDirectory() ? normalize(folder.toPath()) : null;
}
private static GenerationRegistryContract requireRegistryDefinitions(
Collection<GenerationRegistryContract> contracts
)
@@ -333,6 +415,13 @@ final class ModdedGenerationHistoryStorage {
}
}
record PreparedHistory(ActivePack active, GenerationAdmission.RuntimeLease lease) {
PreparedHistory {
active = Objects.requireNonNull(active, "active");
lease = Objects.requireNonNull(lease, "lease");
}
}
record Candidate(
String packFingerprint,
GenerationEpoch.DimensionContract dimensionContract,
@@ -46,6 +46,11 @@ final class ModdedGenerationRegistry implements PlatformGenerationRegistry {
);
}
@Override
public boolean supportsBorrowedDimensionTypes() {
return true;
}
@Override
public Definition definition(String registryKey, String resourceKey) {
return nativeRegistry.definition(registryKey, resourceKey);
@@ -14,6 +14,7 @@ public final class ModdedLifecycleCallbacks {
return new NativeModdedCallbacks(
ModdedEngineBootstrap::serverAboutToStart,
ModdedEngineBootstrap::start,
ModdedEngineBootstrap::levelsReady,
ModdedEngineBootstrap::serverStarted,
ModdedEngineBootstrap::stop,
ModdedEngineBootstrap::stopped,
@@ -45,6 +45,9 @@ public final class ModdedMixinAudit {
private static final AtomicBoolean AUDITED = new AtomicBoolean(false);
static final List<ExpectedMixin> EXPECTED = List.of(
new ExpectedMixin(ENTITY_CONFIG, "ServerLevelsReadyMixin",
NativeMixinTarget.SERVER, "volmlib$levelsReady",
false, NativeMixinFlags::levelsReadyRan),
new ExpectedMixin(ENTITY_CONFIG, "RegistryClientNamesMixin",
NativeMixinTarget.REGISTRY_PACKET, "volmlib$clientRegistryNames",
false, NativeMixinFlags::registryClientNamesRan),
@@ -61,6 +64,10 @@ public final class ModdedMixinAudit {
NativeMixinTarget.STRUCTURE_PALETTE,
"iris$installConcurrentBlockCache",
false, NativeMixinFlags::structureTemplatePaletteRan),
new ExpectedMixin(ENTITY_CONFIG, "PackRepositorySelectionMixin",
NativeMixinTarget.PACK_REPOSITORY,
"volmlib$keepNativeDatapacksLast",
false, NativeMixinFlags::packSelectionRan),
new ExpectedMixin(CLIENT_CONFIG, "NativeWorldOpenFlowsMixin",
NativeMixinTarget.WORLD_OPEN_FLOWS,
"volmlib$openWorldCheckWorldStemCompatibility",
@@ -111,7 +118,7 @@ public final class ModdedMixinAudit {
ModdedIrisLog.error(" missing: {}", entry);
}
ModdedIrisLog.error("The mixin config was not registered for this loader (fabric.mod.json mixins, neoforge.mods.toml [[mixins]], forge MixinConfigs manifest attribute).");
ModdedIrisLog.error("Entity persistence, custom mob loot, parallel structure safety, readable biome identifiers, or Iris world-type labels are disabled until this is fixed.");
ModdedIrisLog.error("Entity persistence, custom mob loot, parallel structure safety, Iris datapack priority, readable biome identifiers, or Iris world-type labels are disabled until this is fixed.");
ModdedIrisLog.error("===============================================================");
}
@@ -24,9 +24,15 @@ import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.TreeSet;
public final class ModdedModConfig {
private static final Object LOCK = new Object();
private static final String DIMENSION_REPLACEMENTS = "dimensionReplacements";
private static volatile ModdedModConfig instance;
private final String defaultPack;
@@ -35,15 +41,20 @@ public final class ModdedModConfig {
private final String mainWorldPack;
private final long mainWorldSeed;
private final boolean mainWorldAutoRestart;
private final Map<String, ModdedDimensionReplacement> dimensionReplacements;
private final List<String> replacementProblems;
private ModdedModConfig(String defaultPack, String primaryWorld, boolean routePlayersToPrimaryWorld,
String mainWorldPack, long mainWorldSeed, boolean mainWorldAutoRestart) {
String mainWorldPack, long mainWorldSeed, boolean mainWorldAutoRestart,
Replacements replacements) {
this.defaultPack = defaultPack;
this.primaryWorld = primaryWorld == null ? "" : primaryWorld.trim();
this.routePlayersToPrimaryWorld = routePlayersToPrimaryWorld;
this.mainWorldPack = mainWorldPack == null ? "" : mainWorldPack.trim();
this.mainWorldSeed = mainWorldSeed;
this.mainWorldAutoRestart = mainWorldAutoRestart;
this.dimensionReplacements = Map.copyOf(replacements.entries());
this.replacementProblems = List.copyOf(replacements.problems());
}
public static ModdedModConfig get() {
@@ -60,11 +71,15 @@ public final class ModdedModConfig {
}
}
public static ModdedModConfig loaded() {
return instance;
}
public static void setPrimaryWorld(String dimensionId) {
synchronized (LOCK) {
ModdedModConfig current = get();
ModdedModConfig current = writable();
ModdedModConfig updated = new ModdedModConfig(current.defaultPack, dimensionId, current.routePlayersToPrimaryWorld,
current.mainWorldPack, current.mainWorldSeed, current.mainWorldAutoRestart);
current.mainWorldPack, current.mainWorldSeed, current.mainWorldAutoRestart, current.replacements());
instance = updated;
write(configFile(), updated);
}
@@ -72,14 +87,22 @@ public final class ModdedModConfig {
public static void setMainWorld(String packRef, long seed) {
synchronized (LOCK) {
ModdedModConfig current = get();
ModdedModConfig current = writable();
ModdedModConfig updated = new ModdedModConfig(current.defaultPack, current.primaryWorld, current.routePlayersToPrimaryWorld,
packRef == null ? "" : packRef.trim(), seed, current.mainWorldAutoRestart);
packRef == null ? "" : packRef.trim(), seed, current.mainWorldAutoRestart, current.replacements());
instance = updated;
write(configFile(), updated);
}
}
public static boolean setDimensionReplacement(ModdedDimensionReplacement replacement) {
return updateDimensionReplacement(replacement.levelKey(), replacement);
}
public static boolean clearDimensionReplacement(String levelKey) {
return updateDimensionReplacement(levelKey, null);
}
public String defaultPack() {
return defaultPack;
}
@@ -104,30 +127,108 @@ public final class ModdedModConfig {
return mainWorldAutoRestart;
}
public Map<String, ModdedDimensionReplacement> dimensionReplacements() {
return dimensionReplacements;
}
public List<String> replacementProblems() {
return replacementProblems;
}
static ModdedModConfig read(Path file) {
ModdedModConfig defaults = defaults();
JSONObject json;
try {
json = new JSONObject(Files.readString(file, StandardCharsets.UTF_8));
} catch (RuntimeException | IOException unreadable) {
ModdedIrisLog.error("Iris modded config at {} is unreadable; using defaults", file, unreadable);
return new ModdedModConfig(defaults.defaultPack, defaults.primaryWorld, defaults.routePlayersToPrimaryWorld,
defaults.mainWorldPack, defaults.mainWorldSeed, defaults.mainWorldAutoRestart,
new Replacements(Map.of(), List.of("Iris could not read " + file + ": " + unreadable.getMessage())));
}
return new ModdedModConfig(
json.optString("defaultPack", defaults.defaultPack),
json.optString("primaryWorld", defaults.primaryWorld),
json.optBoolean("routePlayersToPrimaryWorld", defaults.routePlayersToPrimaryWorld),
json.optString("mainWorldPack", defaults.mainWorldPack),
json.optLong("mainWorldSeed", defaults.mainWorldSeed),
json.optBoolean("mainWorldAutoRestart", defaults.mainWorldAutoRestart),
readDimensionReplacements(json, file));
}
private Replacements replacements() {
return new Replacements(dimensionReplacements, replacementProblems);
}
private static ModdedModConfig writable() {
ModdedModConfig current = get();
if (!current.replacementProblems.isEmpty()) {
throw new IllegalStateException("Iris will not rewrite " + configFile()
+ " while its dimension replacements are unreadable: " + String.join("; ", current.replacementProblems)
+ ". Fix the file and restart first.");
}
return current;
}
private static ModdedModConfig defaults() {
return new ModdedModConfig("overworld", "", true, "", 0L, false, new Replacements(Map.of(), List.of()));
}
private static boolean updateDimensionReplacement(String levelKey, ModdedDimensionReplacement replacement) {
synchronized (LOCK) {
ModdedModConfig current = writable();
Map<String, ModdedDimensionReplacement> replacements = new TreeMap<>(current.dimensionReplacements);
ModdedDimensionReplacement previous = replacement == null
? replacements.remove(levelKey)
: replacements.put(levelKey, replacement);
if (replacement == null ? previous == null : replacement.equals(previous)) {
return false;
}
ModdedModConfig updated = new ModdedModConfig(current.defaultPack, current.primaryWorld, current.routePlayersToPrimaryWorld,
current.mainWorldPack, current.mainWorldSeed, current.mainWorldAutoRestart, new Replacements(replacements, List.of()));
instance = updated;
write(configFile(), updated);
return true;
}
}
private static Path configFile() {
return ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen").resolve("modded.json");
}
private static ModdedModConfig load() {
Path file = configFile();
ModdedModConfig defaults = new ModdedModConfig("overworld", "", true, "", 0L, false);
if (!Files.isRegularFile(file)) {
ModdedModConfig defaults = defaults();
write(file, defaults);
return defaults;
}
try {
JSONObject json = new JSONObject(Files.readString(file, StandardCharsets.UTF_8));
return new ModdedModConfig(
json.optString("defaultPack", defaults.defaultPack),
json.optString("primaryWorld", defaults.primaryWorld),
json.optBoolean("routePlayersToPrimaryWorld", defaults.routePlayersToPrimaryWorld),
json.optString("mainWorldPack", defaults.mainWorldPack),
json.optLong("mainWorldSeed", defaults.mainWorldSeed),
json.optBoolean("mainWorldAutoRestart", defaults.mainWorldAutoRestart));
} catch (RuntimeException | IOException e) {
ModdedIrisLog.error("Iris modded config at {} is invalid; using defaults", file, e);
return defaults;
return read(file);
}
private static Replacements readDimensionReplacements(JSONObject json, Path file) {
if (!json.has(DIMENSION_REPLACEMENTS) || json.isNull(DIMENSION_REPLACEMENTS)) {
return new Replacements(Map.of(), List.of());
}
JSONObject section = json.optJSONObject(DIMENSION_REPLACEMENTS);
if (section == null) {
return new Replacements(Map.of(), List.of("Iris could not read " + DIMENSION_REPLACEMENTS + " in " + file
+ ": it must be an object mapping dimension keys to replacements"));
}
Map<String, ModdedDimensionReplacement> replacements = new TreeMap<>();
List<String> problems = new ArrayList<>();
for (String levelKey : new TreeSet<>(section.keySet())) {
try {
ModdedDimensionReplacement replacement = ModdedDimensionReplacement.fromJson(levelKey, section.opt(levelKey));
replacements.put(replacement.levelKey(), replacement);
} catch (IllegalArgumentException invalid) {
problems.add("Iris could not read dimension replacement '" + levelKey + "' in " + file + ": " + invalid.getMessage());
}
}
for (String problem : problems) {
ModdedIrisLog.error(problem);
}
return new Replacements(replacements, problems);
}
private static void write(Path file, ModdedModConfig config) {
@@ -138,6 +239,11 @@ public final class ModdedModConfig {
json.put("mainWorldPack", config.mainWorldPack);
json.put("mainWorldSeed", config.mainWorldSeed);
json.put("mainWorldAutoRestart", config.mainWorldAutoRestart);
JSONObject replacements = new JSONObject();
for (Map.Entry<String, ModdedDimensionReplacement> entry : new TreeMap<>(config.dimensionReplacements).entrySet()) {
replacements.put(entry.getKey(), entry.getValue().toJson());
}
json.put(DIMENSION_REPLACEMENTS, replacements);
try {
Files.createDirectories(file.getParent());
Files.writeString(file, json.toString(4), StandardCharsets.UTF_8);
@@ -145,4 +251,7 @@ public final class ModdedModConfig {
ModdedIrisLog.error("Iris failed to write modded config at {}", file, e);
}
}
private record Replacements(Map<String, ModdedDimensionReplacement> entries, List<String> problems) {
}
}
@@ -80,6 +80,11 @@ public final class ModdedPlatform implements IrisPlatform {
return loader.platformName();
}
@Override
public boolean supportsBorrowedDimensionTypes() {
return true;
}
@Override
public String minecraftVersion() {
return loader.minecraftVersion();
@@ -127,6 +127,7 @@ public final class ModdedProtocolHandler {
if (player == null || ModdedPrimaryWorldRouter.refuseIfUnavailable(player)) {
return;
}
ModdedReplacementEntry.onPlayerJoin(ModdedEngineBootstrap.currentServer(), player);
String sessionId = sessionId(player);
ModdedStartup.warnStartupFailuresTo(player);
ModdedPackUpdateService packUpdates = ModdedEngineBootstrap.services().service(ModdedPackUpdateService.class);
@@ -0,0 +1,150 @@
/*
* 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 art.arcane.iris.world.lifecycle.WorldReplacementEntryGuard;
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.NativeProtocolPlayer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeServerSpawn;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldTeleport;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
public final class ModdedReplacementEntry {
private static final Set<UUID> PENDING = ConcurrentHashMap.newKeySet();
private static volatile Path guardedWorld;
private static volatile UUID transaction;
private ModdedReplacementEntry() {
}
public static boolean prepare(NativeModdedServer server) {
clear();
Path overworld = ModdedDimensionReplacement.storageFolder(server.root(), ModdedReplacementReconciler.OVERWORLD);
Optional<WorldReplacementEntryGuard.Entry> entry;
try {
entry = WorldReplacementEntryGuard.load(overworld);
} catch (IOException failure) {
ModdedIrisLog.error("Iris could not read the Overworld replacement entry marker under {}", overworld, failure);
return false;
}
if (entry.isEmpty()) {
return false;
}
PENDING.addAll(entry.get().pendingPlayers());
transaction = entry.get().transactionId();
guardedWorld = overworld;
ModdedIrisLog.info("Iris replaced the Overworld; {} returning player(s) are checked for a safe position on their next join",
PENDING.size());
return true;
}
public static void retireIfComplete() {
Path world = guardedWorld;
UUID current = transaction;
if (world == null || current == null || !PENDING.isEmpty()) {
return;
}
CompletableFuture.runAsync(() -> retire(world, current));
}
public static void onPlayerJoin(NativeModdedServer server, NativeProtocolPlayer player) {
if (guardedWorld == null || server == null || player == null || !PENDING.contains(player.id())) {
return;
}
NativeWorld overworld = server.overworld();
if (overworld == null || !player.isInWorld(overworld) || standingSafely(overworld, player)) {
complete(player.id());
return;
}
NativeServerSpawn.Respawn spawn = new NativeServerSpawn(server).current();
boolean overworldSpawn = spawn != null && NativeDimensionRuntime.sameWorld(spawn.world(), overworld);
double x = overworldSpawn ? spawn.position().x() + 0.5D : 0.5D;
double z = overworldSpawn ? spawn.position().z() + 0.5D : 0.5D;
UUID playerId = player.id();
NativeWorldTeleport.teleport(player, new NativeWorldTeleport.Destination(server, overworld, x, Double.MIN_VALUE, z,
ModdedTeleportDeadline.fromNow()))
.whenComplete((Boolean moved, Throwable failure) -> {
if (failure != null || !Boolean.TRUE.equals(moved)) {
ModdedIrisLog.error("Iris could not move {} out of the replaced Overworld terrain to the spawn surface",
playerId, failure);
return;
}
ModdedIrisLog.info("Iris moved {} from an unsafe position in the replaced Overworld to the spawn surface",
playerId);
complete(playerId);
});
}
public static void clear() {
PENDING.clear();
guardedWorld = null;
transaction = null;
}
static boolean standingSafely(NativeWorld world, NativeProtocolPlayer player) {
int y = player.blockY();
if (y < world.minHeight() || y + 1 >= world.maxHeight()) {
return false;
}
return !world.getBlock(player.blockX(), y, player.blockZ()).isSolid()
&& !world.getBlock(player.blockX(), y + 1, player.blockZ()).isSolid();
}
private static void complete(UUID playerId) {
Path world = guardedWorld;
UUID current = transaction;
if (world == null || current == null || !PENDING.remove(playerId)) {
return;
}
CompletableFuture.runAsync(() -> persistCompletion(world, current, playerId));
}
private static synchronized void persistCompletion(Path world, UUID current, UUID playerId) {
try {
Optional<WorldReplacementEntryGuard.Entry> updated = WorldReplacementEntryGuard.completePlayer(world, current, playerId);
if (updated.isPresent() && updated.get().pendingPlayers().isEmpty()) {
retire(world, current);
}
} catch (IOException | RuntimeException failure) {
ModdedIrisLog.error("Iris could not record the safe Overworld replacement entry of {}", playerId, failure);
}
}
private static synchronized void retire(Path world, UUID current) {
try {
if (WorldReplacementEntryGuard.retireIfEmpty(world, current)) {
ModdedIrisLog.info("Iris retired the Overworld replacement entry marker: every known player entered safely");
if (world.equals(guardedWorld)) {
clear();
}
}
} catch (IOException | RuntimeException failure) {
ModdedIrisLog.error("Iris could not retire the Overworld replacement entry marker under {}", world, failure);
}
}
}
@@ -0,0 +1,449 @@
/*
* 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 art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.world.history.GenerationEpoch;
import art.arcane.iris.world.history.GenerationHistory;
import art.arcane.iris.world.history.GenerationHistoryPaths;
import art.arcane.iris.world.lifecycle.WorldReplacementEntryGuard;
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.NativeWorldPaths;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.volmlib.util.json.JSONObject;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.OptionalLong;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.UUID;
import java.util.function.Predicate;
public final class ModdedReplacementReconciler {
public static final String OVERWORLD = "minecraft:overworld";
private static final String STATE_FILE = "data/irisworldgen/dimension-replacements.json";
private static final String APPLIED = "replacements";
private static final String PENDING = "pending";
private static final String IRIS_WORLD_NAMESPACE = "irisworldgen:";
private ModdedReplacementReconciler() {
}
public static void reconcileEarly() {
try {
ModdedModConfig config = ModdedModConfig.get();
Map<String, ModdedDimensionReplacement> configured = ModdedDimensionReplacements.configured();
Path worldRoot = MainWorldService.configuredWorldRootIfPresent();
if (worldRoot == null) {
if (!configured.isEmpty()) {
ModdedIrisLog.info("Iris dimension replacements {} apply to a new world; nothing to move aside",
configured.keySet());
}
return;
}
Path recoveryParent = ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen");
Path packsRoot = IrisPlatforms.get().packsFolderNoCreate().toPath();
Result result = reconcile(new Request(worldRoot, configured, config.replacementProblems(),
(ModdedDimensionReplacement replacement) -> ModdedDimensionReplacements.installed(replacement, packsRoot),
recoveryParent, ModdedDimensionReplacements.replaceableLevels(worldRoot, configured)));
if (!result.adoptedLevels().isEmpty()) {
ModdedIrisLog.info("Iris kept the existing terrain of {}: its Iris generation history already uses the configured pack and seed; the replacement is recorded once the level generates from it",
result.adoptedLevels());
}
if (!result.replacedWorlds().isEmpty()) {
ModdedIrisLog.warn("Iris replaced the Iris worlds {} from their staged packs", result.replacedWorlds());
}
if (result.quarantinedLevels().isEmpty() && result.replacedWorlds().isEmpty()) {
return;
}
ModdedIrisLog.warn("Iris dimension replacement changed for {}: {}", movedLevels(result),
result.recovery() == null
? "no existing chunk data to move aside"
: "moved their chunk, entity, POI, and Iris Mantle data to " + result.recovery()
+ " so they regenerate (player data kept)");
} catch (Throwable failure) {
ModdedIrisLog.error("Iris dimension replacement reconciliation failed", failure);
throw new IllegalStateException("Iris refused startup after dimension replacement reconciliation failed", failure);
}
}
public static void recordApplied(NativeModdedServer server) {
Optional<Path> worldRoot = NativeWorldPaths.root(server);
if (worldRoot.isEmpty()) {
return;
}
try {
recordApplied(worldRoot.get(), ModdedDimensionReplacements.configured(),
(String levelKey) -> liveLevel(server, levelKey));
} catch (IOException | RuntimeException failure) {
ModdedIrisLog.error("Iris is stopping the server because it could not record the applied dimension replacements under {}; an unrecorded replacement would be moved aside again on the next start",
worldRoot.get(), failure);
server.halt(false);
}
}
public static void stopUnpublishedEngines(NativeModdedServer server) {
for (String levelKey : ModdedWorldEngines.evictUnpublished(server,
ModdedModConfig.get().dimensionReplacements().keySet())) {
ModdedIrisLog.error("Iris stopped the engine it started for {}: a mod replaced that level with its own chunk generator while the server started",
levelKey);
}
}
static synchronized Map<String, ModdedDimensionReplacement> recordApplied(Path worldRoot,
Map<String, ModdedDimensionReplacement> configured,
LiveLevels levels) throws IOException {
State state = readState(worldRoot);
Map<String, ModdedDimensionReplacement> recorded = new TreeMap<>(state.applied());
for (ModdedDimensionReplacement replacement : new TreeMap<>(configured).values()) {
Optional<LiveLevel> live = levels.level(replacement.levelKey());
if (live.isPresent() && live.get().generates(replacement)) {
recorded.put(replacement.levelKey(), replacement.withSeed(live.get().seed()));
continue;
}
if (live.isPresent() && !live.get().iris()) {
recorded.remove(replacement.levelKey());
ModdedIrisLog.error("Iris replacement of {} is configured but the loaded level uses another chunk generator; a mod creates that level while the server runs, so Iris cannot replace it. Remove its dimensionReplacements entry from modded.json",
replacement.levelKey());
continue;
}
ModdedIrisLog.warn("Iris replacement of {} is configured but the level does not generate from {}{}; it stays unapplied",
replacement.levelKey(), replacement.packRef(),
replacement.seed().isPresent() ? " with seed " + replacement.seed().getAsLong() : "");
}
if (!recorded.equals(state.applied())) {
writeState(worldRoot, new State(recorded, state.pending()));
}
return Map.copyOf(recorded);
}
static synchronized Result reconcile(Request request) throws IOException {
Path worldRoot = request.worldRoot();
requireReadableConfig(worldRoot, request.configProblems());
State state = readState(worldRoot);
Map<String, ModdedDimensionReplacement> applied = state.applied();
Set<String> refreshed = new TreeSet<>();
boolean irisWorldsPending = false;
for (String level : state.pending().keySet()) {
if (level.startsWith(IRIS_WORLD_NAMESPACE)) {
irisWorldsPending = true;
} else if (request.configured().containsKey(level)) {
refreshed.add(level);
}
}
Set<String> changed = new TreeSet<>(ModdedDimensionReplacements.changedLevels(applied, request.configured()));
changed.addAll(refreshed);
Map<String, ModdedDimensionReplacement> recorded = new TreeMap<>(applied);
Set<String> quarantined = new TreeSet<>();
Set<String> adopted = new TreeSet<>();
Set<String> unreplaceable = new TreeSet<>();
for (String level : changed) {
ModdedDimensionReplacement replacement = request.configured().get(level);
if (replacement == null) {
recorded.remove(level);
quarantined.add(level);
continue;
}
if (!request.levels().replaceable(level)) {
unreplaceable.add(level);
ModdedIrisLog.error(ModdedDimensionReplacements.unreplaceableReason(level, request.levels())
+ " Remove its dimensionReplacements entry from modded.json.");
continue;
}
Path storage = replacement.storageFolder(worldRoot);
boolean keepHistory = !refreshed.contains(level) && matchingHistorySeed(storage, replacement).isPresent();
if (keepHistory) {
adopted.add(level);
} else if (request.installed().test(replacement)) {
quarantined.add(level);
} else {
refuseOrSkipUninstalled(worldRoot, applied, replacement);
}
}
List<ModdedDimensionRegistryStore.PersistentDimension> registry = irisWorldsPending
? ModdedDimensionRegistryStore.loadWorldRoot(worldRoot)
: List.of();
Map<String, ModdedDimensionReplacement> replacedWorlds = new TreeMap<>();
for (ModdedDimensionReplacement pending : state.pending().values()) {
if (!pending.levelKey().startsWith(IRIS_WORLD_NAMESPACE)) {
continue;
}
if (registered(registry, pending.levelKey())) {
replacedWorlds.put(pending.levelKey(), pending);
} else {
ModdedIrisLog.warn("Iris dropped the staged replacement of {}: the Iris world is no longer registered",
pending.levelKey());
}
}
List<Path> sources = new ArrayList<>();
for (String level : quarantined) {
addChunkData(sources, ModdedDimensionReplacement.storageFolder(worldRoot, level));
}
for (String level : replacedWorlds.keySet()) {
addChunkData(sources, ModdedDimensionReplacement.storageFolder(worldRoot, level));
}
Path recovery = request.recoveryParent().resolve("replacement-recovery-" + UUID.randomUUID());
List<Path> moved = sources.isEmpty() ? List.of() : ModdedWorldQuarantine.move(worldRoot, sources, recovery);
if (quarantined.contains(OVERWORLD)) {
WorldReplacementEntryGuard.stage(worldRoot, ModdedDimensionReplacement.storageFolder(worldRoot, OVERWORLD),
UUID.randomUUID());
}
if (!replacedWorlds.isEmpty()) {
ModdedDimensionRegistryStore.writeWorldRoot(worldRoot, repointed(registry, replacedWorlds));
}
State reconciled = new State(recorded, Map.of());
if (!reconciled.equals(state)) {
writeState(worldRoot, reconciled);
}
return new Result(Set.copyOf(quarantined), Set.copyOf(adopted), Set.copyOf(replacedWorlds.keySet()),
Set.copyOf(unreplaceable), moved.isEmpty() ? null : recovery);
}
public static boolean pending(Path worldRoot, String levelKey, Map<String, ModdedDimensionReplacement> configured)
throws IOException {
State state = readState(worldRoot);
if (state.pending().containsKey(levelKey)) {
return true;
}
ModdedDimensionReplacement replacement = configured.get(levelKey);
return replacement != null && !ModdedDimensionReplacements.matches(state.applied().get(levelKey), replacement);
}
public static Optional<ModdedDimensionReplacement> applied(Path worldRoot, String levelKey) throws IOException {
return Optional.ofNullable(readState(worldRoot).applied().get(levelKey));
}
public static synchronized void stagePending(Path worldRoot, ModdedDimensionReplacement replacement) throws IOException {
if (replacement.seed().isEmpty()) {
throw new IllegalArgumentException("A staged replacement needs its effective seed: " + replacement.levelKey());
}
State state = readState(worldRoot);
Map<String, ModdedDimensionReplacement> pending = new TreeMap<>(state.pending());
pending.put(replacement.levelKey(), replacement);
writeState(worldRoot, new State(state.applied(), pending));
}
public static synchronized boolean cancelPending(Path worldRoot, String levelKey) throws IOException {
State state = readState(worldRoot);
Map<String, ModdedDimensionReplacement> pending = new TreeMap<>(state.pending());
if (pending.remove(levelKey) == null) {
return false;
}
writeState(worldRoot, new State(state.applied(), pending));
return true;
}
static boolean hasReplacementRecord(Path worldRoot, String levelKey) throws IOException {
return readState(worldRoot).applied().containsKey(levelKey) || hasGenerationHistory(
ModdedDimensionReplacement.storageFolder(worldRoot, levelKey));
}
private static Set<String> movedLevels(Result result) {
Set<String> levels = new TreeSet<>(result.quarantinedLevels());
levels.addAll(result.replacedWorlds());
return levels;
}
private static void addChunkData(List<Path> sources, Path storage) {
for (String folder : ModdedDimensionStorage.chunkDataFolders()) {
sources.add(storage.resolve(folder));
}
}
private static boolean registered(List<ModdedDimensionRegistryStore.PersistentDimension> registry, String levelKey) {
for (ModdedDimensionRegistryStore.PersistentDimension dimension : registry) {
if (dimension.id().equals(levelKey)) {
return true;
}
}
return false;
}
private static List<ModdedDimensionRegistryStore.PersistentDimension> repointed(
List<ModdedDimensionRegistryStore.PersistentDimension> registry,
Map<String, ModdedDimensionReplacement> replacedWorlds) {
List<ModdedDimensionRegistryStore.PersistentDimension> updated = new ArrayList<>(registry.size());
for (ModdedDimensionRegistryStore.PersistentDimension dimension : registry) {
ModdedDimensionReplacement replacement = replacedWorlds.get(dimension.id());
updated.add(replacement == null ? dimension : new ModdedDimensionRegistryStore.PersistentDimension(
dimension.id(), replacement.pack(), replacement.packDimension(), replacement.seed().getAsLong()));
}
return updated;
}
private static void refuseOrSkipUninstalled(Path worldRoot, Map<String, ModdedDimensionReplacement> applied,
ModdedDimensionReplacement replacement) throws IOException {
if (applied.containsKey(replacement.levelKey()) || hasGenerationHistory(replacement.storageFolder(worldRoot))) {
throw new IOException("Iris refused to switch the replacement of " + replacement.levelKey() + " to "
+ replacement.packRef() + ": pack '" + replacement.pack() + "' dimension '" + replacement.packDimension()
+ "' is not installed, and the level already has an applied replacement or Iris generation history. Install the pack or restore the previous replacement.");
}
ModdedIrisLog.error("Iris left {} untouched: its replacement pack '{}' dimension '{}' is not installed",
replacement.levelKey(), replacement.pack(), replacement.packDimension());
}
private static boolean hasGenerationHistory(Path storage) {
return Files.exists(GenerationHistoryPaths.forDimension(storage).generationRoot(), LinkOption.NOFOLLOW_LINKS);
}
static OptionalLong matchingHistorySeed(Path storage, ModdedDimensionReplacement replacement) throws IOException {
if (!hasGenerationHistory(storage)) {
return OptionalLong.empty();
}
GenerationEpoch epoch = GenerationHistory.inspectPacks(storage).manifest().activeEpoch();
if (replacement.seed().isPresent() && replacement.seed().getAsLong() != epoch.worldSeed()) {
return OptionalLong.empty();
}
GenerationEpoch.DimensionContract contract = epoch.dimensionContract();
if (!contract.dimensionKey().equals(replacement.packDimension())) {
return OptionalLong.empty();
}
Optional<ModdedWorldgenIds.ScopedDimension> scoped = ModdedWorldgenIds.scopedDimensionType(contract.dimensionTypeKey());
boolean samePack = scoped.isPresent()
? scoped.get().equals(new ModdedWorldgenIds.ScopedDimension(replacement.pack(), replacement.packDimension()))
: aliasMatches(epoch, contract, replacement);
return samePack ? OptionalLong.of(epoch.worldSeed()) : OptionalLong.empty();
}
private static boolean aliasMatches(GenerationEpoch epoch, GenerationEpoch.DimensionContract contract,
ModdedDimensionReplacement replacement) {
Set<String> packs = ModdedWorldgenIds.aliasPacks(epoch.registryContract(), contract.dimensionKey());
return packs.isEmpty() || packs.contains(replacement.pack());
}
private static Optional<LiveLevel> liveLevel(NativeModdedServer server, String levelKey) {
NativeWorld world = server.world(levelKey);
if (world == null) {
return Optional.empty();
}
IrisModdedChunkGenerator generator = NativeWorldGenerators.find(world, IrisModdedChunkGenerator.class);
if (generator == null) {
return Optional.of(new LiveLevel(false, "", 0L));
}
return Optional.of(new LiveLevel(true, generator.dimensionKey(), generator.visibleBiomeSeed()));
}
private static void requireReadableConfig(Path worldRoot, List<String> configProblems) throws IOException {
if (configProblems.isEmpty()) {
return;
}
Path state = worldRoot.resolve(STATE_FILE);
if (!Files.exists(state)) {
return;
}
throw new IOException("Iris refused to reconcile dimension replacements because the configured replacements are unreadable while "
+ state + " records applied replacements: " + String.join("; ", configProblems)
+ ". Fix the modded config so every replacement entry is valid; Iris never treats an unreadable replacement as removed.");
}
static State readState(Path worldRoot) throws IOException {
Path state = worldRoot.resolve(STATE_FILE);
if (!Files.isRegularFile(state)) {
return State.EMPTY;
}
JSONObject json = new JSONObject(Files.readString(state, StandardCharsets.UTF_8));
return new State(readSection(json, APPLIED, state), readSection(json, PENDING, state));
}
static void writeState(Path worldRoot, State replacements) throws IOException {
Path state = worldRoot.resolve(STATE_FILE);
Files.createDirectories(state.getParent());
JSONObject json = new JSONObject();
json.put(APPLIED, section(replacements.applied()));
json.put(PENDING, section(replacements.pending()));
Path temporary = state.resolveSibling(state.getFileName() + ".tmp-" + UUID.randomUUID());
Files.writeString(temporary, json.toString(2), StandardCharsets.UTF_8);
try {
Files.move(temporary, state, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} catch (IOException atomicUnsupported) {
Files.move(temporary, state, StandardCopyOption.REPLACE_EXISTING);
}
}
private static Map<String, ModdedDimensionReplacement> readSection(JSONObject json, String name, Path state)
throws IOException {
JSONObject section = json.optJSONObject(name);
if (section == null) {
return Map.of();
}
Map<String, ModdedDimensionReplacement> replacements = new TreeMap<>();
for (String level : section.keySet()) {
try {
replacements.put(level, ModdedDimensionReplacement.fromJson(level, section.opt(level)));
} catch (IllegalArgumentException invalid) {
throw new IOException("Iris could not read replacement '" + level + "' in " + state + ": "
+ invalid.getMessage(), invalid);
}
}
return Map.copyOf(replacements);
}
private static JSONObject section(Map<String, ModdedDimensionReplacement> replacements) {
JSONObject section = new JSONObject();
for (Map.Entry<String, ModdedDimensionReplacement> entry : new TreeMap<>(replacements).entrySet()) {
section.put(entry.getKey(), entry.getValue().toJson());
}
return section;
}
interface LiveLevels {
Optional<LiveLevel> level(String levelKey);
}
record LiveLevel(boolean iris, String packRef, long seed) {
LiveLevel {
Objects.requireNonNull(packRef, "packRef");
}
boolean generates(ModdedDimensionReplacement replacement) {
return iris && packRef.equals(replacement.packRef())
&& (replacement.seed().isEmpty() || replacement.seed().getAsLong() == seed);
}
}
record State(Map<String, ModdedDimensionReplacement> applied, Map<String, ModdedDimensionReplacement> pending) {
static final State EMPTY = new State(Map.of(), Map.of());
State {
applied = Map.copyOf(applied);
pending = Map.copyOf(pending);
}
}
record Request(Path worldRoot, Map<String, ModdedDimensionReplacement> configured, List<String> configProblems,
Predicate<ModdedDimensionReplacement> installed, Path recoveryParent,
ModdedDimensionReplacements.ReplaceableLevels levels) {
}
record Result(Set<String> quarantinedLevels, Set<String> adoptedLevels, Set<String> replacedWorlds,
Set<String> unreplaceableLevels, Path recovery) {
}
}
@@ -27,6 +27,7 @@ import art.arcane.iris.pack.PackValidationRegistry;
import art.arcane.iris.pack.PackValidationCache;
import art.arcane.iris.pack.PackValidationResult;
import art.arcane.iris.pack.PackValidator;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.modded.command.ModdedPackCommands;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
@@ -39,19 +40,29 @@ import java.util.List;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
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 AtomicBoolean RESTORE_BEGUN = new AtomicBoolean(false);
private static final AtomicReference<ModdedDimensionInjection<ModdedWorldEngines.PreparedPack, ModdedDimensionManager.PendingInjection, Engine>> RESTORE =
new AtomicReference<>();
private static final List<String> DIMENSION_FAILURES = new CopyOnWriteArrayList<>();
private ModdedStartup() {
}
public static void reset() {
ModdedDimensionInjection<ModdedWorldEngines.PreparedPack, ModdedDimensionManager.PendingInjection, Engine> restore =
RESTORE.getAndSet(null);
if (restore != null) {
restore.close();
}
PREPARED.set(false);
STARTED.set(false);
RESTORE_BEGUN.set(false);
DIMENSION_FAILURES.clear();
}
@@ -62,6 +73,24 @@ public final class ModdedStartup {
validateAllPacks();
}
public static void prepareDimensionRestore(NativeModdedServer server) {
if (server == null || !RESTORE_BEGUN.compareAndSet(false, true)) {
return;
}
List<ModdedDimensionRegistryStore.PersistentDimension> dimensions =
ModdedDimensionRegistryStore.loadForStartup(server);
if (dimensions.isEmpty()) {
return;
}
long startedAt = System.currentTimeMillis();
ModdedDimensionInjection<ModdedWorldEngines.PreparedPack, ModdedDimensionManager.PendingInjection, Engine> restore =
ModdedDimensionInjection.start(dimensions, new PersistentRestoreSteps(server));
RESTORE.set(restore);
restore.awaitPreparations();
ModdedIrisLog.info("Iris prepared {} persistent dimension(s) during world load in {}ms",
dimensions.size(), System.currentTimeMillis() - startedAt);
}
/**
* Boot trigger for the forced datapack. Runs on its own daemon thread rather than the Iris scheduler:
* ModdedEngineBootstrap.start clears the async queue at SERVER_STARTING, which would silently drop this
@@ -91,7 +120,6 @@ public final class ModdedStartup {
}
ModdedForcedDatapack.verifyInjected();
ModdedMixinAudit.runOnce();
reinjectPersistentDimensions(server);
ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull();
if (scheduler == null) {
@@ -219,38 +247,46 @@ public final class ModdedStartup {
}
static void reinjectPersistentDimensions(NativeModdedServer server) {
List<ModdedDimensionRegistryStore.PersistentDimension> dimensions =
ModdedDimensionRegistryStore.loadForStartup(server);
if (dimensions.isEmpty()) {
prepareDimensionRestore(server);
ModdedDimensionInjection<ModdedWorldEngines.PreparedPack, ModdedDimensionManager.PendingInjection, Engine> restore =
RESTORE.getAndSet(null);
if (restore == null) {
return;
}
finishDimensionRestore(restore);
}
static void finishDimensionRestore(ModdedDimensionInjection<?, ?, ?> restore) {
long startedAt = System.currentTimeMillis();
List<ModdedDimensionInjection.Result> results;
try (restore) {
results = restore.finish();
}
long waited = System.currentTimeMillis() - startedAt;
int injected = 0;
int index = 0;
long startedAt = System.currentTimeMillis();
for (ModdedDimensionRegistryStore.PersistentDimension dimension : dimensions) {
OutOfMemoryError outOfMemory = null;
for (ModdedDimensionInjection.Result result : results) {
index++;
long dimensionStartedAt = System.currentTimeMillis();
try {
ModdedDimensionManager.restorePersistent(
server,
dimension.id(),
dimension.pack(),
dimension.dimension(),
dimension.seed()
);
ModdedDimensionRegistryStore.PersistentDimension dimension = result.dimension();
Throwable failure = result.failure();
if (failure == null) {
injected++;
ModdedIrisLog.info("Iris re-injected {}/{} '{}' (pack={} dim={}) in {}ms",
index, dimensions.size(), dimension.id(), dimension.pack(), dimension.dimension(),
System.currentTimeMillis() - dimensionStartedAt);
} catch (Throwable e) {
reportDimensionFailure(dimension, e);
if (e instanceof OutOfMemoryError outOfMemory) {
throw outOfMemory;
}
ModdedIrisLog.info("Iris re-injected {}/{} '{}' (pack={} dim={}): prepared {}ms, engine {}ms",
index, results.size(), dimension.id(), dimension.pack(), dimension.dimension(),
result.preparedMillis(), result.builtMillis());
continue;
}
reportDimensionFailure(dimension, failure);
if (failure instanceof OutOfMemoryError fatal && outOfMemory == null) {
outOfMemory = fatal;
}
}
ModdedIrisLog.info("Iris re-injected {}/{} persistent dimension(s) at startup in {}ms",
injected, dimensions.size(), System.currentTimeMillis() - startedAt);
ModdedIrisLog.info("Iris re-injected {}/{} persistent dimension(s) at startup; server thread waited {}ms",
injected, results.size(), waited);
if (outOfMemory != null) {
throw outOfMemory;
}
}
private static void reportDimensionFailure(ModdedDimensionRegistryStore.PersistentDimension dimension, Throwable failure) {
@@ -304,4 +340,44 @@ public final class ModdedStartup {
}
}
private record PersistentRestoreSteps(NativeModdedServer server)
implements ModdedDimensionInjection.Steps<ModdedWorldEngines.PreparedPack, ModdedDimensionManager.PendingInjection, Engine> {
@Override
public ModdedWorldEngines.PreparedPack prepare(ModdedDimensionRegistryStore.PersistentDimension dimension) {
return ModdedWorldEngines.preparePersistent(server, dimension);
}
@Override
public ModdedDimensionManager.PendingInjection begin(
ModdedDimensionRegistryStore.PersistentDimension dimension,
ModdedWorldEngines.PreparedPack prepared
) {
return ModdedDimensionManager.beginInjection(
server, dimension, prepared.active(), ModdedGenerationMode.PERSISTENT_RESTORE);
}
@Override
public Engine build(ModdedDimensionManager.PendingInjection level, ModdedWorldEngines.PreparedPack prepared) {
if (level.present()) {
prepared.close();
return null;
}
return ModdedWorldEngines.createPrepared(level.handle().level(), level.handle().seed(), prepared);
}
@Override
public void complete(ModdedDimensionManager.PendingInjection level, Engine engine) {
ModdedDimensionManager.completeInjection(server, level, engine);
}
@Override
public void abandon(ModdedDimensionManager.PendingInjection level, Engine engine, Throwable failure) {
ModdedDimensionManager.abandonInjection(server, level, engine, failure);
}
@Override
public void discard(ModdedWorldEngines.PreparedPack prepared) {
prepared.close();
}
}
}
@@ -20,6 +20,7 @@ package art.arcane.iris.modded;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
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.NativeWorldDimensions;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldGenerators;
@@ -31,6 +32,7 @@ import art.arcane.iris.generation.runtime.IrisEngine;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.EngineTarget;
import art.arcane.iris.world.history.GenerationActivation;
import art.arcane.iris.world.history.GenerationAdmission;
import art.arcane.iris.world.history.GenerationEpoch;
import art.arcane.iris.world.history.GenerationEpochContractFactory;
import art.arcane.iris.world.history.GenerationHistory;
@@ -48,8 +50,10 @@ import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
public final class ModdedWorldEngines {
@@ -91,6 +95,19 @@ public final class ModdedWorldEngines {
}
}
static List<String> evictUnpublished(NativeModdedServer server, Set<String> levelKeys) {
List<String> evicted = new ArrayList<>();
for (NativeWorld level : new ArrayList<>(ENGINES.keySet())) {
if (!levelKeys.contains(level.name())
|| NativeDimensionRuntime.sameWorld(server.world(level.name()), level)) {
continue;
}
evict(level);
evicted.add(level.name());
}
return evicted;
}
static void evictOrThrow(NativeWorld level) {
Engine[] removed = new Engine[1];
ENGINES.computeIfPresent(level, (NativeWorld ignored, Engine current) -> {
@@ -128,6 +145,60 @@ public final class ModdedWorldEngines {
});
}
static PreparedPack preparePersistent(
NativeModdedServer server,
ModdedDimensionRegistryStore.PersistentDimension dimension
) {
ModdedEngineBootstrap.bind();
return prepared(ModdedGenerationHistoryStorage.prepareRestore(
server,
dimension.id(),
dimension.pack(),
dimension.dimension(),
dimension.seed()
));
}
static PreparedPack prepareCreated(
NativeModdedServer server,
ModdedDimensionRegistryStore.PersistentDimension dimension
) {
ModdedEngineBootstrap.bind();
return prepared(ModdedGenerationHistoryStorage.prepareCreate(
server,
dimension.id(),
dimension.pack(),
dimension.dimension(),
dimension.seed()
));
}
private static PreparedPack prepared(ModdedGenerationHistoryStorage.PreparedHistory prepared) {
try {
ModdedDimensionMetadata.warmConfiguredPack(prepared.active().packRoot().toFile(), prepared.active().dimensionKey());
return new PreparedPack(prepared.active(), prepared.lease(), loadPack(prepared.active()));
} catch (RuntimeException | Error failure) {
prepared.lease().close();
throw failure;
}
}
static Engine createPrepared(NativeWorld level, long seedOverride, PreparedPack prepared) {
ModdedEngineBootstrap.bind();
long seed = seedOverride == Long.MIN_VALUE ? level.seed() : seedOverride;
try (prepared) {
return attachHistoryEngine(level, seed, prepared.active(), prepared.take());
} catch (IOException failure) {
throw new IllegalStateException("Iris generation history could not initialize runtime routing for '"
+ level.name() + "'.", failure);
}
}
static void installPrepared(NativeWorld level, Engine engine) {
applyWorldBoundary(level, engine);
installReplacement(level, engine);
}
static void closeUnregistered(Engine engine) {
close(engine);
ENGINES.entrySet().removeIf((Map.Entry<NativeWorld, Engine> entry) -> entry.getValue() == engine);
@@ -142,9 +213,13 @@ public final class ModdedWorldEngines {
ModdedGenerationMode generationMode
) {
Engine engine = create(level, pack, dimensionKey, seedOverride, generationMode);
applyWorldBoundary(level, engine);
return engine;
}
private static void applyWorldBoundary(NativeWorld level, Engine engine) {
try {
engine.getPlatformHooks().applyWorldBoundary(engine);
return engine;
} catch (Throwable failure) {
try {
close(engine);
@@ -197,57 +272,63 @@ public final class ModdedWorldEngines {
GenerationHistory history = opened.history();
try (GenerationAdmission.RuntimeLease startupAdmission = history.retainRuntime()) {
history.prepareCurrentGenerator(IrisSettings.get().getGenerator().getGenerationTransitionWidthBlocks());
opened = ModdedGenerationHistoryStorage.resolveActive(history);
long openedActivationId = history.activeActivation().activationId();
IrisEngine candidate = buildEngine(level, seed, opened);
boolean ready = false;
try {
GenerationHistoryRuntimeRouter router = candidate.attachGenerationHistory(
ModdedGenerationHistoryStorage.ActivePack active = ModdedGenerationHistoryStorage.resolveActive(history, null);
return attachHistoryEngine(level, seed, active, loadPack(active));
}
}
private static IrisEngine attachHistoryEngine(
NativeWorld level,
long seed,
ModdedGenerationHistoryStorage.ActivePack active,
LoadedPack loaded
) throws IOException {
GenerationHistory history = active.history();
long openedActivationId = history.activeActivation().activationId();
IrisEngine candidate = buildEngine(level, seed, active, loaded);
boolean ready = false;
try {
GenerationHistoryRuntimeRouter router = candidate.attachGenerationHistory(
history,
IrisBoundarySignatureSampler.INSTANCE,
IrisSettings.get().getGenerator().getGenerationTransitionWidthBlocks()
);
router.preloadActiveRuntimes();
if (history.activeActivation().activationId() != openedActivationId) {
close(candidate);
ModdedGenerationHistoryStorage.ActivePack promoted =
ModdedGenerationHistoryStorage.resolveActive(history, null);
candidate = buildEngine(level, seed, promoted, loadPack(promoted));
router = GenerationHistoryRuntimeRouter.attach(
candidate,
history,
IrisBoundarySignatureSampler.INSTANCE,
IrisSettings.get().getGenerator().getGenerationTransitionWidthBlocks()
IrisBoundarySignatureSampler.INSTANCE
);
router.preloadActiveRuntimes();
if (history.activeActivation().activationId() != openedActivationId) {
close(candidate);
ModdedGenerationHistoryStorage.ActivePack promoted =
ModdedGenerationHistoryStorage.resolveActive(history);
candidate = buildEngine(level, seed, promoted);
router = GenerationHistoryRuntimeRouter.attach(
candidate,
history,
IrisBoundarySignatureSampler.INSTANCE
);
router.preloadActiveRuntimes();
}
requireReady(candidate, level, ModdedGenerationMode.PERSISTENT_RESTORE);
ready = true;
ModdedIrisLog.info("Iris engine up for {}: pack={} dim={} seed={} height={}..{} activation={}",
level.name(),
candidate.getData().getDataFolder().getAbsolutePath(),
candidate.getDimension().getLoadKey(),
seed,
candidate.getDimension().getMinHeight(),
candidate.getDimension().getMaxHeight(),
history.activeActivation().activationId());
return candidate;
} finally {
if (!ready) {
close(candidate);
}
}
requireReady(candidate, level, ModdedGenerationMode.PERSISTENT_RESTORE);
ready = true;
ModdedIrisLog.info("Iris engine up for {}: pack={} dim={} seed={} height={}..{} activation={}",
level.name(),
candidate.getData().getDataFolder().getAbsolutePath(),
candidate.getDimension().getLoadKey(),
seed,
candidate.getDimension().getMinHeight(),
candidate.getDimension().getMaxHeight(),
history.activeActivation().activationId());
return candidate;
} finally {
if (!ready) {
close(candidate);
}
}
}
private static IrisEngine buildEngine(
NativeWorld level,
long seed,
ModdedGenerationHistoryStorage.ActivePack active
) throws IOException {
private static LoadedPack loadPack(ModdedGenerationHistoryStorage.ActivePack active) {
File packDir = active.packRoot().toFile();
PackValidationRegistry.requireLoadable(active.packRoot());
IrisData data = IrisData.openRuntime(packDir);
boolean runtimeOwnsData = false;
boolean loaded = false;
try {
IrisDimension dimension = data.getDimensionLoader().load(active.dimensionKey());
if (dimension == null) {
@@ -256,7 +337,26 @@ public final class ModdedWorldEngines {
throw new IllegalStateException("Iris dimension '" + active.dimensionKey()
+ "' missing from immutable pack " + packDir.getAbsolutePath());
}
data.generationPackFingerprint();
loaded = true;
return new LoadedPack(data, dimension);
} finally {
if (!loaded) {
data.close();
}
}
}
private static IrisEngine buildEngine(
NativeWorld level,
long seed,
ModdedGenerationHistoryStorage.ActivePack active,
LoadedPack loaded
) throws IOException {
IrisData data = loaded.data();
IrisDimension dimension = loaded.dimension();
boolean runtimeOwnsData = false;
try {
validateDimensionContract(active.dimensionContract(), dimension, level);
File worldFolder = NativeModdedServer.forWorld(level).dimensionFolder(level.name()).toFile();
IrisWorld world = buildWorld(level, seed, dimension, worldFolder);
@@ -391,6 +491,15 @@ public final class ModdedWorldEngines {
(actualType.maximumHeight() - actualType.minimumHeight()),
actualType.logicalHeight());
String runtimeName = "Modded level '" + level.name() + "'";
if (dimension.hasBorrowedDimensionType()) {
if (!recorded.dimensionTypeKey().equals(dimension.getBorrowedDimensionType().trim())) {
throw new IllegalStateException("Immutable Iris dimension contract for '" + level.name()
+ "' records dimension type " + recorded.dimensionTypeKey() + ", but its pack borrows "
+ dimension.getBorrowedDimensionType().trim() + ".");
}
expected.requireBorrowedFit(runtimeName, actual);
return;
}
expected.requireExact(runtimeName, actual);
expected.requireHeight(runtimeName, level.minHeight(), (level.maxHeight() - level.minHeight()));
}
@@ -419,6 +528,55 @@ public final class ModdedWorldEngines {
}
}
private record LoadedPack(IrisData data, IrisDimension dimension) {
private LoadedPack {
data = Objects.requireNonNull(data, "data");
dimension = Objects.requireNonNull(dimension, "dimension");
}
}
static final class PreparedPack implements AutoCloseable {
private final ModdedGenerationHistoryStorage.ActivePack active;
private final GenerationAdmission.RuntimeLease lease;
private LoadedPack loaded;
private PreparedPack(
ModdedGenerationHistoryStorage.ActivePack active,
GenerationAdmission.RuntimeLease lease,
LoadedPack loaded
) {
this.active = Objects.requireNonNull(active, "active");
this.lease = Objects.requireNonNull(lease, "lease");
this.loaded = Objects.requireNonNull(loaded, "loaded");
}
ModdedGenerationHistoryStorage.ActivePack active() {
return active;
}
private synchronized LoadedPack take() {
LoadedPack taken = loaded;
if (taken == null) {
throw new IllegalStateException("Iris prepared pack for " + active.packRoot() + " was already used");
}
loaded = null;
return taken;
}
@Override
public synchronized void close() {
LoadedPack unused = loaded;
loaded = null;
try {
if (unused != null) {
unused.data().close();
}
} finally {
lease.close();
}
}
}
public static void shutdown() {
Throwable failure = null;
for (Map.Entry<NativeWorld, Engine> entry : new ArrayList<>(ENGINES.entrySet())) {
@@ -0,0 +1,73 @@
/*
* 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.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
final class ModdedWorldQuarantine {
private ModdedWorldQuarantine() {
}
static List<Path> move(Path worldRoot, List<Path> sources, Path recovery) throws IOException {
List<Path> moved = new ArrayList<>(sources.size());
try {
for (Path source : sources) {
moveOne(worldRoot, source, recovery, moved);
}
} catch (IOException failure) {
rollback(worldRoot, recovery, moved, failure);
throw failure;
}
if (moved.isEmpty()) {
Files.deleteIfExists(recovery);
}
return List.copyOf(moved);
}
private static void moveOne(Path worldRoot, Path source, Path recovery, List<Path> moved) throws IOException {
if (!Files.exists(source)) {
return;
}
Path relative = worldRoot.relativize(source);
if (relative.isAbsolute() || relative.startsWith("..")) {
throw new IOException("Iris refuses to quarantine " + source + " outside the world " + worldRoot);
}
Path target = recovery.resolve(relative);
Files.createDirectories(target.getParent());
Files.move(source, target);
moved.add(relative);
}
private static void rollback(Path worldRoot, Path recovery, List<Path> moved, IOException failure) {
for (int index = moved.size() - 1; index >= 0; index--) {
Path relative = moved.get(index);
Path rollbackTarget = worldRoot.resolve(relative);
try {
Files.createDirectories(rollbackTarget.getParent());
Files.move(recovery.resolve(relative), rollbackTarget);
} catch (IOException rollbackFailure) {
failure.addSuppressed(rollbackFailure);
}
}
}
}
@@ -1,12 +1,18 @@
package art.arcane.iris.modded;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.world.history.GenerationRegistryContract;
import art.arcane.iris.world.history.GenerationRegistryContract.PhysicalResourceKey;
import art.arcane.iris.world.history.GenerationRegistryContractFactory;
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
public final class ModdedWorldgenIds {
private static final String NAMESPACE = "irisworldgen";
@@ -22,6 +28,12 @@ public final class ModdedWorldgenIds {
return NAMESPACE + ":" + scopedPath(pack, dimension) + "/dimension_type";
}
public static String dimensionTypeRef(String pack, IrisDimension dimension) {
return dimension.hasBorrowedDimensionType()
? dimension.getBorrowedDimensionType().trim()
: dimensionTypeRef(pack, dimension.getLoadKey());
}
public static String biomePathPrefix(String pack, String dimension) {
return scopedPath(pack, dimension) + "/biomes";
}
@@ -61,6 +73,41 @@ public final class ModdedWorldgenIds {
return Optional.of(new ScopedDimension(pack, dimension));
}
static Set<String> aliasPacks(GenerationRegistryContract contract, String dimension) {
Set<String> packs = new TreeSet<>();
for (PhysicalResourceKey key : contract.definitions().keySet()) {
if (!GenerationRegistryContractFactory.BIOME_REGISTRY.equals(key.registryKey())) {
continue;
}
Optional<ScopedDimension> scoped = scopedBiome(key.resourceKey());
if (scoped.isPresent() && scoped.get().dimension().equals(dimension)) {
packs.add(scoped.get().pack());
}
}
return packs;
}
static Optional<ScopedDimension> scopedBiome(String biomeRef) {
String prefix = NAMESPACE + ":";
if (biomeRef == null || !biomeRef.startsWith(prefix)) {
return Optional.empty();
}
String[] parts = biomeRef.substring(prefix.length()).split("/", 6);
if (parts.length != 6
|| !"packs".equals(parts[0])
|| !"dimensions".equals(parts[2])
|| !"biomes".equals(parts[4])
|| parts[5].isEmpty()) {
return Optional.empty();
}
String pack = decode(parts[1]);
String dimension = decode(parts[3]);
if (pack == null || dimension == null) {
return Optional.empty();
}
return Optional.of(new ScopedDimension(pack, dimension));
}
public static String displayName(String presetPath) {
String[] parts = presetPath.split("/");
if (parts.length != 5 || !"packs".equals(parts[0])
@@ -22,6 +22,7 @@ package art.arcane.iris.modded;
import art.arcane.iris.pack.datapack.DataVersion;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.volmlib.util.json.JSONObject;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldDimensions;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldInspection;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldInspection.Dimension;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
@@ -34,6 +35,9 @@ final class WorldCheckDimensionContract {
static boolean checkDimensionType(NativeWorld level, IrisModdedChunkGenerator generator) {
try {
IrisDimension dimension = generator.commandEngine().getDimension();
if (dimension.hasBorrowedDimensionType()) {
return checkBorrowedDimensionType(level, dimension);
}
Dimension expected = expectedDimensionContract(dimension);
Dimension actual = new NativeWorldInspection(level).dimension();
boolean pass = matchesDimensionContract(level.minHeight(), (level.maxHeight() - level.minHeight()), expected, actual);
@@ -54,6 +58,24 @@ final class WorldCheckDimensionContract {
}
}
private static boolean checkBorrowedDimensionType(NativeWorld level, IrisDimension dimension) {
String borrowed = dimension.getBorrowedDimensionType().trim();
String actualKey = NativeWorldDimensions.settings(level).key();
boolean pass = borrowed.equals(actualKey)
&& dimension.getMinHeight() == level.minHeight()
&& dimension.getMaxHeight() <= level.maxHeight();
String detail = "borrowed=" + borrowed + ",actual=" + actualKey
+ ",packRange=" + dimension.getMinHeight() + ".." + dimension.getMaxHeight()
+ ",levelRange=" + level.minHeight() + ".." + level.maxHeight();
WorldCheckPredicates.qaEvent("dimension_type", dimension.getLoadKey(), pass, detail);
if (!pass) {
ModdedIrisLog.error("[worldcheck] borrowed dimension type mismatch for {}: {}", dimension.getLoadKey(), detail);
} else {
ModdedIrisLog.info("[worldcheck] borrowed dimension type contract: {}", detail);
}
return pass;
}
static Dimension expectedDimensionContract(IrisDimension dimension) {
JSONObject json = new JSONObject(dimension.getDimensionType().toJson(DataVersion.getRuntime().get()));
return new Dimension(
@@ -64,7 +86,8 @@ final class WorldCheckDimensionContract {
(float) json.getDouble("ambient_light"),
json.getBoolean("has_skylight"),
json.getBoolean("has_ceiling"),
json.getBoolean("has_ender_dragon_fight"),
json.has("has_ender_dragon_fight") ? json.getBoolean("has_ender_dragon_fight")
: "minecraft:the_end".equals(json.optString("effects")),
json.getInt("monster_spawn_block_light_limit"));
}
@@ -0,0 +1,32 @@
/*
* 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.catalog;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModMetadata;
import java.util.List;
import java.util.Objects;
public record CatalogCaptureContext(String minecraftVersion, String platform, long capturedAtEpochMillis, List<NativeModMetadata> mods) {
public CatalogCaptureContext {
Objects.requireNonNull(minecraftVersion, "minecraftVersion");
Objects.requireNonNull(platform, "platform");
mods = List.copyOf(Objects.requireNonNull(mods, "mods"));
}
}
@@ -0,0 +1,167 @@
/*
* 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.catalog;
import art.arcane.iris.modded.ModdedEngineBootstrap;
import art.arcane.iris.modded.ModdedIrisLog;
import art.arcane.iris.pack.ingest.catalog.WorldgenCatalog;
import art.arcane.iris.pack.ingest.catalog.WorldgenCatalogStore;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedLoader;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldgenScan;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldgenSnapshot;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldgenSnapshot.EncodeFailures;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Path;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
public final class ModdedWorldgenCatalogs {
private static final AtomicReference<WorldgenCatalog> LATEST = new AtomicReference<>();
private static final AtomicReference<CompletableFuture<CaptureResult>> RUNNING = new AtomicReference<>();
private ModdedWorldgenCatalogs() {
}
public static Optional<WorldgenCatalog> latest() {
return Optional.ofNullable(LATEST.get());
}
public static Path catalogFile() {
return ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen").resolve("catalog")
.resolve(WorldgenCatalogStore.FILE_NAME);
}
public static CompletableFuture<CaptureResult> capture(NativeModdedServer server) {
Objects.requireNonNull(server, "server");
return capture(() -> scan(server));
}
static CompletableFuture<CaptureResult> capture(CaptureScan scan) {
CompletableFuture<CaptureResult> pending = new CompletableFuture<>();
CompletableFuture<CaptureResult> running = RUNNING.compareAndExchange(null, pending);
if (running != null) {
return running;
}
CaptureWork work;
try {
work = scan.scan();
} catch (Throwable failure) {
ModdedIrisLog.error("Iris could not read the worldgen registries for the catalog", failure);
finish(pending, null, failure);
rethrowIfVirtualMachineError(failure);
return pending;
}
Thread worker = new Thread(() -> complete(work, pending), "Iris Worldgen Catalog");
worker.setDaemon(true);
worker.start();
return pending;
}
private static CaptureWork scan(NativeModdedServer server) {
long startedAt = System.nanoTime();
NativeModdedLoader loader = ModdedEngineBootstrap.loader();
NativeWorldgenScan scan = NativeWorldgenScan.scan(server, loader);
CatalogCaptureContext context = new CatalogCaptureContext(loader.minecraftVersion(), loader.platformName(),
System.currentTimeMillis(), loader.mods());
PendingCapture capture = new PendingCapture(scan, context, catalogFile(), startedAt, elapsedMillis(startedAt));
return () -> encodeAndWrite(capture);
}
private static void complete(CaptureWork work, CompletableFuture<CaptureResult> pending) {
try {
finish(pending, work.run(), null);
} catch (IOException failure) {
ModdedIrisLog.error("Iris could not write the worldgen catalog", failure);
finish(pending, null, new UncheckedIOException(failure));
} catch (Throwable failure) {
ModdedIrisLog.error("Iris could not build the worldgen catalog", failure);
finish(pending, null, failure);
rethrowIfVirtualMachineError(failure);
}
}
private static CaptureResult encodeAndWrite(PendingCapture capture) throws IOException {
NativeWorldgenSnapshot snapshot = capture.scan().encode();
WorldgenCatalog catalog = WorldgenCatalogAssembler.assemble(snapshot, capture.context());
WorldgenCatalogStore.write(capture.file(), catalog);
LATEST.set(catalog);
CaptureResult result = new CaptureResult(catalog, capture.file(), elapsedMillis(capture.startedAt()));
ModdedIrisLog.info("Iris worldgen catalog: {} namespaces, {} biomes, {} placed features, {} structures, "
+ "{} dimension types, {} level stems -> {} ({}ms server thread, {}ms total)",
catalog.namespaces().size(), catalog.biomes().size(), catalog.placedFeatures().size(),
catalog.structures().size(), catalog.dimensionTypes().size(), catalog.levelStems().size(),
capture.file(), capture.scanMillis(), result.millis());
reportEncodeFailures(snapshot.failures());
return result;
}
private static void reportEncodeFailures(EncodeFailures failures) {
if (failures.count() == 0) {
return;
}
String first = failures.samples().isEmpty() ? "" : failures.samples().getFirst();
if (failures.firstException() != null) {
ModdedIrisLog.warn("Iris worldgen catalog left {} entries without JSON; first: {}", failures.count(), first,
failures.firstException());
return;
}
ModdedIrisLog.warn("Iris worldgen catalog left {} entries without JSON; first: {}", failures.count(), first);
}
private static void finish(CompletableFuture<CaptureResult> pending, CaptureResult result, Throwable failure) {
RUNNING.compareAndSet(pending, null);
if (failure != null) {
pending.completeExceptionally(failure);
return;
}
pending.complete(result);
}
private static void rethrowIfVirtualMachineError(Throwable failure) {
if (failure instanceof VirtualMachineError virtualMachineError) {
throw virtualMachineError;
}
}
private static long elapsedMillis(long startedAt) {
return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt);
}
public record CaptureResult(WorldgenCatalog catalog, Path file, long millis) {
}
@FunctionalInterface
interface CaptureScan {
CaptureWork scan();
}
@FunctionalInterface
interface CaptureWork {
CaptureResult run() throws IOException;
}
private record PendingCapture(NativeWorldgenScan scan, CatalogCaptureContext context, Path file, long startedAt, long scanMillis) {
}
}
@@ -0,0 +1,192 @@
/*
* 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.catalog;
import art.arcane.iris.pack.ingest.catalog.CatalogBiome;
import art.arcane.iris.pack.ingest.catalog.CatalogDimensionType;
import art.arcane.iris.pack.ingest.catalog.CatalogLevelStem;
import art.arcane.iris.pack.ingest.catalog.CatalogNamespace;
import art.arcane.iris.pack.ingest.catalog.CatalogPlacedFeature;
import art.arcane.iris.pack.ingest.catalog.CatalogSpawn;
import art.arcane.iris.pack.ingest.catalog.CatalogStructure;
import art.arcane.iris.pack.ingest.catalog.WorldgenCatalog;
import art.arcane.iris.pack.ingest.catalog.WorldgenCatalogFingerprint;
import art.arcane.iris.pack.ingest.catalog.WorldgenCatalogIndex;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModMetadata;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldgenSnapshot;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldgenSnapshot.WorldgenBiome;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldgenSnapshot.WorldgenDimensionType;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldgenSnapshot.WorldgenLevelStem;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldgenSnapshot.WorldgenPlacedFeature;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldgenSnapshot.WorldgenSpawn;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldgenSnapshot.WorldgenStructure;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
public final class WorldgenCatalogAssembler {
private static final String MINECRAFT = "minecraft";
private WorldgenCatalogAssembler() {
}
public static WorldgenCatalog assemble(NativeWorldgenSnapshot snapshot, CatalogCaptureContext context) {
Objects.requireNonNull(snapshot, "snapshot");
Objects.requireNonNull(context, "context");
List<CatalogBiome> biomes = biomes(snapshot.biomes());
List<CatalogPlacedFeature> placedFeatures = placedFeatures(snapshot.placedFeatures());
List<CatalogStructure> structures = structures(snapshot.structures());
List<CatalogDimensionType> dimensionTypes = dimensionTypes(snapshot.dimensionTypes());
List<CatalogLevelStem> levelStems = levelStems(snapshot.levelStems());
Map<String, List<String>> blockTags = referencedBlockTags(placedFeatures, snapshot.blockTags());
Map<String, List<String>> registryKeys = new LinkedHashMap<>();
registryKeys.put("biome", biomes.stream().map(CatalogBiome::key).toList());
registryKeys.put("placed_feature", placedFeatures.stream().map(CatalogPlacedFeature::key).toList());
registryKeys.put("structure", structures.stream().map(CatalogStructure::key).toList());
registryKeys.put("dimension_type", dimensionTypes.stream().map(CatalogDimensionType::key).toList());
registryKeys.put("level_stem", levelStems.stream().map(CatalogLevelStem::key).toList());
registryKeys.put("block_tag", List.copyOf(blockTags.keySet()));
List<CatalogNamespace> namespaces = namespaces(registryKeys, context);
return new WorldgenCatalog(WorldgenCatalog.FORMAT, context.minecraftVersion(), context.platform(),
context.capturedAtEpochMillis(), WorldgenCatalogFingerprint.of(namespaces, registryKeys), namespaces,
biomes, placedFeatures, structures, dimensionTypes, levelStems, blockTags);
}
private static Map<String, List<String>> referencedBlockTags(List<CatalogPlacedFeature> features,
Map<String, List<String>> available) {
Set<String> referenced = new TreeSet<>();
for (CatalogPlacedFeature feature : features) {
collectTagMatches(feature.config(), referenced);
}
Map<String, List<String>> tags = new TreeMap<>();
for (String tag : referenced) {
List<String> members = available.get(tag);
if (members != null) {
tags.put(tag, members);
}
}
return tags;
}
private static void collectTagMatches(JsonElement element, Set<String> tags) {
if (element == null || element.isJsonNull() || element.isJsonPrimitive()) {
return;
}
if (element.isJsonArray()) {
for (JsonElement entry : element.getAsJsonArray()) {
collectTagMatches(entry, tags);
}
return;
}
JsonObject object = element.getAsJsonObject();
JsonElement predicate = object.get("predicate_type");
JsonElement tag = object.get("tag");
if (predicate != null && predicate.isJsonPrimitive() && predicate.getAsString().endsWith("tag_match")
&& tag != null && tag.isJsonPrimitive()) {
String key = tag.getAsString();
tags.add(key.contains(":") ? key : MINECRAFT + ":" + key);
}
for (Map.Entry<String, JsonElement> entry : object.entrySet()) {
collectTagMatches(entry.getValue(), tags);
}
}
private static List<CatalogNamespace> namespaces(Map<String, List<String>> registryKeys, CatalogCaptureContext context) {
Map<String, CatalogNamespace> namespaces = new TreeMap<>();
for (NativeModMetadata mod : context.mods()) {
namespaces.put(mod.id(), new CatalogNamespace(mod.id(), mod.displayName(), mod.version()));
}
for (List<String> keys : registryKeys.values()) {
for (String key : keys) {
String namespace = WorldgenCatalogIndex.namespaceOf(key);
if (!namespaces.containsKey(namespace)) {
namespaces.put(namespace, fallbackNamespace(namespace, context));
}
}
}
if (!namespaces.containsKey(MINECRAFT)) {
namespaces.put(MINECRAFT, fallbackNamespace(MINECRAFT, context));
}
return List.copyOf(namespaces.values());
}
private static CatalogNamespace fallbackNamespace(String namespace, CatalogCaptureContext context) {
if (MINECRAFT.equals(namespace)) {
return new CatalogNamespace(MINECRAFT, "Minecraft", context.minecraftVersion());
}
return new CatalogNamespace(namespace, namespace, "");
}
private static List<CatalogBiome> biomes(List<WorldgenBiome> biomes) {
List<CatalogBiome> result = new ArrayList<>(biomes.size());
for (WorldgenBiome biome : biomes) {
List<CatalogSpawn> spawns = new ArrayList<>(biome.spawns().size());
for (WorldgenSpawn spawn : biome.spawns()) {
spawns.add(new CatalogSpawn(spawn.category(), spawn.entity(), spawn.weight(), spawn.minCount(), spawn.maxCount()));
}
result.add(new CatalogBiome(biome.key(), biome.temperature(), biome.downfall(), biome.hasPrecipitation(),
biome.tags(), biome.featureSteps(), spawns, biome.definition()));
}
return result;
}
private static List<CatalogPlacedFeature> placedFeatures(List<WorldgenPlacedFeature> features) {
List<CatalogPlacedFeature> result = new ArrayList<>(features.size());
for (WorldgenPlacedFeature feature : features) {
result.add(new CatalogPlacedFeature(feature.key(), feature.feature(), feature.featureType(),
feature.config(), feature.placement()));
}
return result;
}
private static List<CatalogStructure> structures(List<WorldgenStructure> structures) {
List<CatalogStructure> result = new ArrayList<>(structures.size());
for (WorldgenStructure structure : structures) {
result.add(new CatalogStructure(structure.key(), structure.type(), structure.step(),
structure.terrainAdaptation(), structure.biomeTag(), structure.biomes(), structure.structureSets()));
}
return result;
}
private static List<CatalogDimensionType> dimensionTypes(List<WorldgenDimensionType> types) {
List<CatalogDimensionType> result = new ArrayList<>(types.size());
for (WorldgenDimensionType type : types) {
result.add(new CatalogDimensionType(type.key(), type.minY(), type.height(), type.logicalHeight(), type.definition()));
}
return result;
}
private static List<CatalogLevelStem> levelStems(List<WorldgenLevelStem> stems) {
List<CatalogLevelStem> result = new ArrayList<>(stems.size());
for (WorldgenLevelStem stem : stems) {
result.add(new CatalogLevelStem(stem.key(), stem.dimensionType(), stem.generatorType(),
stem.biomeSourceType(), stem.generator()));
}
return result;
}
}
@@ -0,0 +1,173 @@
/*
* 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.catalog;
import art.arcane.iris.pack.ingest.catalog.CatalogBiome;
import art.arcane.iris.pack.ingest.catalog.CatalogDimensionType;
import art.arcane.iris.pack.ingest.catalog.CatalogLevelStem;
import art.arcane.iris.pack.ingest.catalog.CatalogNamespace;
import art.arcane.iris.pack.ingest.catalog.CatalogPlacedFeature;
import art.arcane.iris.pack.ingest.catalog.CatalogStructure;
import art.arcane.iris.pack.ingest.catalog.WorldgenCatalog;
import art.arcane.iris.pack.ingest.catalog.WorldgenCatalogIndex;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.TreeMap;
import java.util.function.Function;
public final class WorldgenCatalogSummary {
private static final String VANILLA_PREFIX = "minecraft:";
private WorldgenCatalogSummary() {
}
public static List<NamespaceSummary> namespaces(WorldgenCatalog catalog) {
Map<String, Builder> builders = new TreeMap<>();
for (CatalogNamespace namespace : catalog.namespaces()) {
builders.put(namespace.namespace(), new Builder(namespace));
}
collect(builders, catalog.biomes(), CatalogBiome::key, Builder::biomes);
collect(builders, catalog.placedFeatures(), CatalogPlacedFeature::key, Builder::placedFeatures);
collect(builders, catalog.structures(), CatalogStructure::key, Builder::structures);
collect(builders, catalog.dimensionTypes(), CatalogDimensionType::key, Builder::dimensionTypes);
collect(builders, catalog.levelStems(), CatalogLevelStem::key, Builder::levelStems);
for (CatalogPlacedFeature feature : catalog.placedFeatures()) {
builder(builders, WorldgenCatalogIndex.namespaceOf(feature.key())).featureTypes()
.merge(feature.featureType(), 1, Integer::sum);
}
List<NamespaceSummary> summaries = new ArrayList<>(builders.size());
for (Builder builder : builders.values()) {
summaries.add(builder.build());
}
return summaries;
}
public static Optional<NamespaceSummary> namespace(WorldgenCatalog catalog, String namespace) {
for (NamespaceSummary summary : namespaces(catalog)) {
if (summary.namespace().namespace().equals(namespace)) {
return Optional.of(summary);
}
}
return Optional.empty();
}
public static String featureTypes(List<TypeCount> types, int limit) {
List<String> labels = new ArrayList<>(Math.min(types.size(), limit));
for (int index = 0; index < types.size() && index < limit; index++) {
TypeCount type = types.get(index);
labels.add(shortType(type.type()) + " " + type.count());
}
return withRemainder(String.join(", ", labels), types.size() - labels.size());
}
public static String limited(List<String> keys, String namespace, int limit) {
List<String> labels = new ArrayList<>(Math.min(keys.size(), limit));
String ownPrefix = namespace + ":";
for (int index = 0; index < keys.size() && index < limit; index++) {
String key = keys.get(index);
labels.add(key.startsWith(ownPrefix) ? key.substring(ownPrefix.length()) : key);
}
return withRemainder(String.join(", ", labels), keys.size() - labels.size());
}
private static String shortType(String type) {
return type.startsWith(VANILLA_PREFIX) ? type.substring(VANILLA_PREFIX.length()) : type;
}
private static String withRemainder(String joined, int remainder) {
return remainder > 0 ? joined + " (+" + remainder + " more)" : joined;
}
private static <T> void collect(Map<String, Builder> builders, List<T> entries, Function<T, String> key,
Function<Builder, List<String>> target) {
for (T entry : entries) {
String entryKey = key.apply(entry);
target.apply(builder(builders, WorldgenCatalogIndex.namespaceOf(entryKey))).add(entryKey);
}
}
private static Builder builder(Map<String, Builder> builders, String namespace) {
return builders.computeIfAbsent(namespace, (String missing) -> new Builder(new CatalogNamespace(missing, missing, "")));
}
public record TypeCount(String type, int count) {
public TypeCount {
Objects.requireNonNull(type, "type");
}
}
public record NamespaceSummary(
CatalogNamespace namespace,
List<String> biomes,
List<String> placedFeatures,
List<TypeCount> featureTypes,
List<String> structures,
List<String> dimensionTypes,
List<String> levelStems) {
public NamespaceSummary {
Objects.requireNonNull(namespace, "namespace");
biomes = List.copyOf(biomes);
placedFeatures = List.copyOf(placedFeatures);
featureTypes = List.copyOf(featureTypes);
structures = List.copyOf(structures);
dimensionTypes = List.copyOf(dimensionTypes);
levelStems = List.copyOf(levelStems);
}
public boolean hasContent() {
return !biomes.isEmpty() || !placedFeatures.isEmpty() || !structures.isEmpty()
|| !dimensionTypes.isEmpty() || !levelStems.isEmpty();
}
}
private record Builder(
CatalogNamespace namespace,
List<String> biomes,
List<String> placedFeatures,
Map<String, Integer> featureTypes,
List<String> structures,
List<String> dimensionTypes,
List<String> levelStems) {
private Builder(CatalogNamespace namespace) {
this(namespace, new ArrayList<>(), new ArrayList<>(), new TreeMap<>(), new ArrayList<>(), new ArrayList<>(),
new ArrayList<>());
}
private NamespaceSummary build() {
List<TypeCount> types = new ArrayList<>(featureTypes.size());
for (Map.Entry<String, Integer> entry : featureTypes.entrySet()) {
types.add(new TypeCount(entry.getKey(), entry.getValue()));
}
types.sort(Comparator.comparingInt(TypeCount::count).reversed().thenComparing(TypeCount::type));
return new NamespaceSummary(namespace, sorted(biomes), sorted(placedFeatures), types, sorted(structures),
sorted(dimensionTypes), sorted(levelStems));
}
private static List<String> sorted(List<String> keys) {
List<String> copy = new ArrayList<>(keys);
copy.sort(null);
return copy;
}
}
}
@@ -294,16 +294,14 @@ public final class IrisModdedCommands {
}
Engine engine = irisGenerator.engineIfBound();
if (engine == null) {
lines.add(irisIdentity + ": pack=" + irisGenerator.dimensionKey()
+ " world=" + dimensionId + " (engine not started yet)");
lines.add(dimensionId + ": pack=" + irisGenerator.dimensionKey() + " (engine not started yet)");
continue;
}
String featureStatus = irisGenerator.importedFeaturesStatus();
lines.add(irisIdentity + ": pack=" + engine.getDimension().getLoadKey()
+ " world=" + dimensionId
+ (showSeed ? " seed=" + level.seed() : "")
lines.add(dimensionId + ": pack=" + engine.getDimension().getLoadKey()
+ (showSeed ? " seed=" + engine.getWorld().getRawWorldSeed() : "")
+ " height=" + engine.getMinHeight() + ".." + engine.getMaxHeight()
+ " generated=" + engine.getGenerated()
+ " generated=" + engine.getEngineData().getStatistics().getChunksGenerated()
+ (featureStatus == null ? "" : " importedFeatures=" + featureStatus)
+ " data=" + engine.getData().getDataFolder().getName());
}
@@ -329,7 +327,7 @@ public final class IrisModdedCommands {
fail(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_THIS_DIMENSION_IS_NOT_GENERATED_BY_IRIS_14));
return 0;
}
ok(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_WORLD_SEED, MessageArgument.untrusted("value", level.seed())));
ok(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_WORLD_SEED, MessageArgument.untrusted("value", engine.getWorld().getRawWorldSeed())));
ok(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_ENGINE_SEED_MIXED, MessageArgument.untrusted("value", engine.getSeedManager().getSeed()), MessageArgument.untrusted("value2", engine.getSeedManager().getFullMixedSeed())));
return 1;
}
@@ -0,0 +1,154 @@
/*
* 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.command;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.regex.Pattern;
record ModIngestArguments(String sourcePack, String targetPack, String dimension, Set<String> namespaces,
Set<String> excludedRegions, boolean replace) {
static final List<String> OPTION_SUGGESTIONS = List.of("dimension=", "namespaces=", "excludeRegions=", "replace=true");
private static final Pattern PACK = Pattern.compile("[A-Za-z0-9_.-]+");
private static final Pattern TARGET_PACK = Pattern.compile("[A-Za-z0-9_-]+");
private static final Pattern DIMENSION = Pattern.compile("[A-Za-z0-9_/.-]+");
private static final Pattern NAMESPACE = Pattern.compile("[a-z0-9_.-]+");
private static final Pattern REGION = Pattern.compile("[A-Za-z0-9_/.-]+");
private static final String DIMENSION_KEY = "dimension";
private static final String NAMESPACES_KEY = "namespaces";
private static final String REPLACE_KEY = "replace";
private static final String EXCLUDE_REGIONS_KEY = "excludeRegions";
ModIngestArguments {
Objects.requireNonNull(sourcePack, "sourcePack");
Objects.requireNonNull(targetPack, "targetPack");
Objects.requireNonNull(dimension, "dimension");
namespaces = Set.copyOf(Objects.requireNonNull(namespaces, "namespaces"));
excludedRegions = Set.copyOf(Objects.requireNonNull(excludedRegions, "excludedRegions"));
}
static ModIngestArguments parse(String raw) {
String trimmed = raw == null ? "" : raw.trim();
String[] tokens = trimmed.isEmpty() ? new String[0] : trimmed.split("\\s+");
String source = null;
String target = null;
Map<String, String> options = new HashMap<>(4);
for (String token : tokens) {
int equals = token.indexOf('=');
if (equals < 0) {
if (source == null) {
source = token;
} else if (target == null) {
target = token;
} else {
throw new IllegalArgumentException("unexpected argument '" + token + "'");
}
continue;
}
String key = token.substring(0, equals);
if (!key.equals(DIMENSION_KEY) && !key.equals(NAMESPACES_KEY) && !key.equals(REPLACE_KEY)
&& !key.equals(EXCLUDE_REGIONS_KEY)) {
throw new IllegalArgumentException("unknown option '" + key + "'");
}
if (options.putIfAbsent(key, token.substring(equals + 1)) != null) {
throw new IllegalArgumentException("option '" + key + "' given twice");
}
}
if (source == null) {
throw new IllegalArgumentException("missing source pack");
}
if (target == null) {
throw new IllegalArgumentException("missing target pack");
}
requirePack(source);
requirePack(target);
if (!TARGET_PACK.matcher(target).matches()) {
throw new IllegalArgumentException("invalid target pack name '" + target + "'");
}
if (source.equals(target)) {
throw new IllegalArgumentException("source and target must be different packs");
}
String dimension = options.getOrDefault(DIMENSION_KEY, source);
if (!DIMENSION.matcher(dimension).matches() || dimension.contains("..")) {
throw new IllegalArgumentException("invalid dimension key '" + dimension + "'");
}
return new ModIngestArguments(source, target, dimension, namespaces(options.get(NAMESPACES_KEY)),
regions(options.get(EXCLUDE_REGIONS_KEY)), replace(options.get(REPLACE_KEY)));
}
private static void requirePack(String pack) {
if (!PACK.matcher(pack).matches() || pack.contains("..")) {
throw new IllegalArgumentException("invalid pack name '" + pack + "'");
}
}
private static Set<String> namespaces(String raw) {
if (raw == null) {
return Set.of();
}
Set<String> namespaces = new LinkedHashSet<>();
for (String part : raw.split(",")) {
if (part.isEmpty()) {
continue;
}
if (!NAMESPACE.matcher(part).matches()) {
throw new IllegalArgumentException("invalid namespace '" + part + "'");
}
namespaces.add(part);
}
if (namespaces.isEmpty()) {
throw new IllegalArgumentException("namespaces must not be empty");
}
return namespaces;
}
private static Set<String> regions(String raw) {
if (raw == null) {
return Set.of();
}
Set<String> regions = new LinkedHashSet<>();
for (String part : raw.split(",")) {
if (part.isEmpty()) {
continue;
}
if (!REGION.matcher(part).matches() || part.contains("..")) {
throw new IllegalArgumentException("invalid region '" + part + "'");
}
regions.add(part);
}
if (regions.isEmpty()) {
throw new IllegalArgumentException("excludeRegions must not be empty");
}
return regions;
}
private static boolean replace(String raw) {
if (raw == null || raw.equals("false")) {
return false;
}
if (raw.equals("true")) {
return true;
}
throw new IllegalArgumentException("replace must be true or false");
}
}
@@ -0,0 +1,313 @@
/*
* 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.command;
import art.arcane.iris.localization.IrisLanguage;
import art.arcane.iris.modded.ModdedIrisLog;
import art.arcane.iris.modded.catalog.ModdedWorldgenCatalogs;
import art.arcane.iris.modded.catalog.ModdedWorldgenCatalogs.CaptureResult;
import art.arcane.iris.modded.catalog.WorldgenCatalogSummary;
import art.arcane.iris.modded.catalog.WorldgenCatalogSummary.NamespaceSummary;
import art.arcane.iris.modded.localization.ModdedCatalogMessages;
import art.arcane.iris.pack.PackDirectoryResolver;
import art.arcane.iris.pack.ingest.ModIngestReport;
import art.arcane.iris.pack.ingest.ModIngestRequest;
import art.arcane.iris.pack.ingest.ModIngester;
import art.arcane.iris.pack.ingest.catalog.WorldgenCatalog;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeCommandRegistration;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeCommandSource;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.util.localization.MessageArgument;
import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
import com.mojang.brigadier.context.CommandContext;
import com.mojang.brigadier.suggestion.SuggestionProvider;
import com.mojang.brigadier.suggestion.Suggestions;
import com.mojang.brigadier.suggestion.SuggestionsBuilder;
import java.io.File;
import java.io.IOException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Predicate;
final class ModdedCatalogCommands {
private static final Predicate<NativeCommandSource> GATE = NativeCommandRegistration.GAMEMASTERS;
private static final int LIST_FEATURE_TYPES = 3;
private static final int DETAIL_LIMIT = 24;
private static final String NONE = "-";
private static final AtomicReference<NamespaceCache> NAMESPACE_CACHE = new AtomicReference<>();
private static final SuggestionProvider<NativeCommandSource> NAMESPACES = (CommandContext<NativeCommandSource> context,
SuggestionsBuilder builder) ->
NativeCommandRegistration.suggest(contentNamespaces(), builder);
private static final SuggestionProvider<NativeCommandSource> INGEST_ARGUMENTS = ModdedCatalogCommands::suggestIngestArguments;
private ModdedCatalogCommands() {
}
static LiteralArgumentBuilder<NativeCommandSource> catalogTree() {
return NativeCommandRegistration.literal("catalog").requires(GATE)
.executes(ModdedCommandTree.localized((CommandContext<NativeCommandSource> context) -> list(context.getSource())))
.then(NativeCommandRegistration.literal("refresh")
.executes(ModdedCommandTree.localized((CommandContext<NativeCommandSource> context) -> refresh(context.getSource()))))
.then(NativeCommandRegistration.argument("namespace", StringArgumentType.word()).suggests(NAMESPACES)
.executes(ModdedCommandTree.localized((CommandContext<NativeCommandSource> context) ->
detail(context.getSource(), StringArgumentType.getString(context, "namespace")))));
}
static LiteralArgumentBuilder<NativeCommandSource> ingestTree() {
return NativeCommandRegistration.literal("ingest").requires(GATE)
.executes(ModdedCommandTree.localized((CommandContext<NativeCommandSource> context) -> ModdedCommandHelp.send(context.getSource(), "ingest")))
.then(NativeCommandRegistration.literal("mods")
.executes(ModdedCommandTree.localized((CommandContext<NativeCommandSource> context) -> ingest(context.getSource(), "")))
.then(NativeCommandRegistration.argument("arguments", StringArgumentType.greedyString()).suggests(INGEST_ARGUMENTS)
.executes(ModdedCommandTree.localized((CommandContext<NativeCommandSource> context) ->
ingest(context.getSource(), StringArgumentType.getString(context, "arguments"))))));
}
private static int list(NativeCommandSource source) {
Optional<WorldgenCatalog> latest = ModdedWorldgenCatalogs.latest();
if (latest.isEmpty()) {
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCatalogMessages.MISSING));
return 0;
}
WorldgenCatalog catalog = latest.get();
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCatalogMessages.HEADER,
MessageArgument.untrusted("name", catalog.platform()),
MessageArgument.untrusted("version", catalog.minecraftVersion()),
MessageArgument.untrusted("count", catalog.namespaces().size()),
MessageArgument.untrusted("value", catalog.biomes().size()),
MessageArgument.untrusted("value2", catalog.placedFeatures().size()),
MessageArgument.untrusted("value3", catalog.structures().size()),
MessageArgument.untrusted("value4", catalog.dimensionTypes().size()),
MessageArgument.untrusted("value5", catalog.levelStems().size())));
int quiet = 0;
for (NamespaceSummary summary : WorldgenCatalogSummary.namespaces(catalog)) {
if (!summary.hasContent()) {
quiet++;
continue;
}
IrisModdedCommands.ok(source, namespaceLine(summary));
}
if (quiet > 0) {
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCatalogMessages.QUIET, MessageArgument.untrusted("count", quiet)));
}
return 1;
}
private static int detail(NativeCommandSource source, String namespace) {
Optional<WorldgenCatalog> latest = ModdedWorldgenCatalogs.latest();
if (latest.isEmpty()) {
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCatalogMessages.MISSING));
return 0;
}
Optional<NamespaceSummary> found = WorldgenCatalogSummary.namespace(latest.get(), namespace);
if (found.isEmpty() || !found.get().hasContent()) {
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCatalogMessages.UNKNOWN_NAMESPACE,
MessageArgument.untrusted("name", namespace)));
return 0;
}
NamespaceSummary summary = found.get();
IrisModdedCommands.ok(source, namespaceLine(summary));
if (!summary.biomes().isEmpty()) {
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCatalogMessages.DETAIL_BIOMES,
MessageArgument.untrusted("count", summary.biomes().size()),
MessageArgument.untrusted("value", WorldgenCatalogSummary.limited(summary.biomes(), namespace, DETAIL_LIMIT))));
}
if (!summary.placedFeatures().isEmpty()) {
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCatalogMessages.DETAIL_FEATURES,
MessageArgument.untrusted("count", summary.placedFeatures().size()),
MessageArgument.untrusted("value", WorldgenCatalogSummary.featureTypes(summary.featureTypes(), DETAIL_LIMIT))));
}
if (!summary.structures().isEmpty()) {
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCatalogMessages.DETAIL_STRUCTURES,
MessageArgument.untrusted("count", summary.structures().size()),
MessageArgument.untrusted("value", WorldgenCatalogSummary.limited(summary.structures(), namespace, DETAIL_LIMIT))));
}
if (!summary.dimensionTypes().isEmpty() || !summary.levelStems().isEmpty()) {
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCatalogMessages.DETAIL_DIMENSIONS,
MessageArgument.untrusted("value", orNone(WorldgenCatalogSummary.limited(summary.dimensionTypes(), namespace, DETAIL_LIMIT))),
MessageArgument.untrusted("value2", orNone(WorldgenCatalogSummary.limited(summary.levelStems(), namespace, DETAIL_LIMIT)))));
}
return 1;
}
private static int refresh(NativeCommandSource source) {
NativeModdedServer server = source.server();
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCatalogMessages.REFRESHING));
ModdedWorldgenCatalogs.capture(server).whenComplete((CaptureResult result, Throwable failure) ->
source.execute(() -> reportRefresh(source, result, failure)));
return 1;
}
private static void reportRefresh(NativeCommandSource source, CaptureResult result, Throwable failure) {
if (failure != null) {
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCatalogMessages.REFRESH_FAILED,
MessageArgument.untrusted("reason", reason(failure))));
return;
}
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCatalogMessages.REFRESHED,
MessageArgument.untrusted("duration", result.millis() + " ms"),
MessageArgument.untrusted("path", result.file().toString())));
}
private static int ingest(NativeCommandSource source, String raw) {
ModIngestArguments arguments;
try {
arguments = ModIngestArguments.parse(raw);
} catch (IllegalArgumentException invalid) {
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCatalogMessages.INGEST_INVALID,
MessageArgument.untrusted("reason", invalid.getMessage())));
return 0;
}
Optional<WorldgenCatalog> catalog = ModdedWorldgenCatalogs.latest();
if (catalog.isEmpty()) {
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCatalogMessages.MISSING));
return 0;
}
File packsRoot = ModdedPackCommands.packsRoot();
File sourcePack = PackDirectoryResolver.resolveExisting(packsRoot, arguments.sourcePack());
if (sourcePack == null) {
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCatalogMessages.INGEST_PACK_MISSING,
MessageArgument.untrusted("pack", arguments.sourcePack()),
MessageArgument.untrusted("path", packsRoot.getAbsolutePath())));
return 0;
}
Path targetPack = packsRoot.toPath().toAbsolutePath().normalize().resolve(arguments.targetPack());
ModIngestRequest request = new ModIngestRequest(sourcePack.toPath(), targetPack, arguments.dimension(),
catalog.get(), arguments.namespaces(), arguments.excludedRegions(), arguments.replace());
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCatalogMessages.INGEST_STARTED,
MessageArgument.untrusted("source", arguments.sourcePack()),
MessageArgument.untrusted("target", arguments.targetPack()),
MessageArgument.untrusted("dimension", arguments.dimension())));
Thread worker = new Thread(() -> runIngest(source, request), "Iris Mod Ingest");
worker.setDaemon(true);
worker.start();
return 1;
}
private static void runIngest(NativeCommandSource source, ModIngestRequest request) {
try {
ModIngestReport report = ModIngester.ingest(request);
source.execute(() -> reportIngest(source, report));
} catch (FileAlreadyExistsException exists) {
source.execute(() -> IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCatalogMessages.INGEST_TARGET_EXISTS,
MessageArgument.untrusted("target", request.targetPack().getFileName().toString()))));
} catch (IOException | RuntimeException failure) {
ModdedIrisLog.error("Iris mod ingest into " + request.targetPack() + " failed", failure);
String reason = reason(failure);
source.execute(() -> IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCatalogMessages.INGEST_FAILED,
MessageArgument.untrusted("reason", reason))));
}
}
private static void reportIngest(NativeCommandSource source, ModIngestReport report) {
for (String line : report.summaryLines()) {
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCatalogMessages.INGEST_LINE, MessageArgument.untrusted("summary", line)));
}
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCatalogMessages.INGEST_COMPLETE,
MessageArgument.untrusted("path", report.targetPack().toString())));
}
private static String namespaceLine(NamespaceSummary summary) {
String version = summary.namespace().version().isEmpty() ? NONE : summary.namespace().version();
return IrisLanguage.plain(ModdedCatalogMessages.NAMESPACE,
MessageArgument.untrusted("name", summary.namespace().namespace()),
MessageArgument.untrusted("version", version),
MessageArgument.untrusted("value", summary.biomes().size()),
MessageArgument.untrusted("value2", summary.placedFeatures().size()),
MessageArgument.untrusted("detail", orNone(WorldgenCatalogSummary.featureTypes(summary.featureTypes(), LIST_FEATURE_TYPES))),
MessageArgument.untrusted("value3", summary.structures().size()),
MessageArgument.untrusted("value4", summary.dimensionTypes().size()),
MessageArgument.untrusted("value5", summary.levelStems().size()));
}
private static String orNone(String value) {
return value.isEmpty() ? NONE : value;
}
private static String reason(Throwable failure) {
Throwable cause = failure instanceof CompletionException && failure.getCause() != null ? failure.getCause() : failure;
String message = cause.getMessage();
return message == null || message.isBlank() ? cause.getClass().getSimpleName() : message;
}
private static List<String> contentNamespaces() {
Optional<WorldgenCatalog> latest = ModdedWorldgenCatalogs.latest();
if (latest.isEmpty()) {
return List.of();
}
NamespaceCache cached = NAMESPACE_CACHE.get();
if (cached != null && cached.catalog() == latest.get()) {
return cached.namespaces();
}
List<String> namespaces = new ArrayList<>();
for (NamespaceSummary summary : WorldgenCatalogSummary.namespaces(latest.get())) {
if (summary.hasContent()) {
namespaces.add(summary.namespace().namespace());
}
}
NamespaceCache created = new NamespaceCache(latest.get(), List.copyOf(namespaces));
NAMESPACE_CACHE.set(created);
return created.namespaces();
}
private static CompletableFuture<Suggestions> suggestIngestArguments(CommandContext<NativeCommandSource> context,
SuggestionsBuilder builder) {
String remaining = builder.getRemaining();
int lastSpace = remaining.lastIndexOf(' ');
int bareArguments = 0;
List<String> usedOptions = new ArrayList<>(3);
for (String token : remaining.substring(0, Math.max(0, lastSpace)).trim().split("\\s+")) {
if (token.isEmpty()) {
continue;
}
int equals = token.indexOf('=');
if (equals < 0) {
bareArguments++;
} else {
usedOptions.add(token.substring(0, equals + 1));
}
}
SuggestionsBuilder current = builder.createOffset(builder.getStart() + lastSpace + 1);
if (bareArguments < 2) {
List<String> packs = new ArrayList<>();
for (File pack : PackDirectoryResolver.listVisiblePackDirectories(ModdedPackCommands.packsRoot())) {
packs.add(pack.getName());
}
return NativeCommandRegistration.suggest(packs, current);
}
List<String> options = new ArrayList<>(ModIngestArguments.OPTION_SUGGESTIONS.size());
for (String option : ModIngestArguments.OPTION_SUGGESTIONS) {
if (!usedOptions.contains(option.substring(0, option.indexOf('=') + 1))) {
options.add(option);
}
}
return NativeCommandRegistration.suggest(options, current);
}
private record NamespaceCache(WorldgenCatalog catalog, List<String> namespaces) {
}
}
@@ -18,8 +18,10 @@
package art.arcane.iris.modded.command;
import art.arcane.iris.localization.DirectorCommandMessages;
import art.arcane.iris.localization.IrisLanguage;
import art.arcane.iris.localization.IrisMessages;
import art.arcane.iris.modded.localization.ModdedCatalogMessages;
import art.arcane.iris.modded.localization.ModdedHelpMessages;
import art.arcane.volmlib.util.director.help.DirectorHelpMessages;
import art.arcane.volmlib.util.localization.MessageArgument;
@@ -68,6 +70,8 @@ final class ModdedCommandHelp {
Entry.command("debug", "", ModdedHelpMessages.COMMAND_DEBUG_TOGGLE_IRIS_DEBUG_LOGGING_AND_SAVE_SETTINGS_JSON),
Entry.command("reload", "", ModdedHelpMessages.COMMAND_RELOAD_RELOAD_SETTINGS_JSON_ALSO_HOTLOADED_AUTOMATICALLY_EVERY_3S),
Entry.command("download", "<pack=overworld|pack=underworld|link=<zip-url>> [overwrite=true]", ModdedHelpMessages.COMMAND_DOWNLOAD_DOWNLOAD_A_PACK_PROJECT, "dl"),
Entry.command("catalog", "[namespace|refresh]", ModdedCatalogMessages.HELP_CATALOG),
Entry.group("ingest", ModdedCatalogMessages.HELP_INGEST),
Entry.command("metrics", "", ModdedHelpMessages.COMMAND_METRICS_PRINT_GENERATION_METRICS_FOR_YOUR_CURRENT_IRIS_DIMENSION, "measure"),
Entry.command("regen", "[radius]", ModdedHelpMessages.COMMAND_REGEN_DELETE_AND_REGENERATE_NEARBY_CHUNKS_IN_PLACE, "rg"),
Entry.group("pregen", ModdedHelpMessages.GROUP_PREGEN_PREGENERATE_AN_IRIS_DIMENSION, "pregenerate"),
@@ -76,6 +80,7 @@ final class ModdedCommandHelp {
Entry.group("object", ModdedHelpMessages.GROUP_OBJECT_OBJECT_WAND_SAVE_PASTE_ANALYZE_AND_UNDO_TOOLS, "o"),
Entry.group("edit", ModdedHelpMessages.GROUP_EDIT_OPEN_PACK_BIOME_REGION_AND_DIMENSION_JSON_FILES_IN_YOUR_DESKTOP_EDITOR),
Entry.command("create", "<name> [pack|pack:dimensionKey] [seed]", ModdedHelpMessages.COMMAND_CREATE_CREATE_AND_INJECT_A_PERSISTENT_IRIS_DIMENSION_QUOTE_PACK_DIMENSIONKEY_TO_PICK, "c"),
Entry.command("replace", "<target> [type=<pack|pack:dimensionKey|off>] [seed=<seed>]", DirectorCommandMessages.COMMAND_IRIS_PARAM_REPLACE_EXACT_EXISTING_WORLD_SLOT_NEXT_RESTART, "override", "overwrite"),
Entry.command("height", "", ModdedHelpMessages.COMMAND_HEIGHT_PRINT_WORLD_HEIGHT),
Entry.command("worlds", "", ModdedHelpMessages.COMMAND_WORLDS_LIST_WORLD_ACCESS, "accesslist"),
Entry.group("studio", ModdedHelpMessages.GROUP_STUDIO_PACK_PROJECT_CREATION_PACKAGING_AND_REPORTS, "std", "s"),
@@ -165,6 +170,9 @@ final class ModdedCommandHelp {
Entry.command("compat", "[pack]", ModdedHelpMessages.COMMAND_COMPAT_SHOW_CONTENT_THAT_CANNOT_GENERATE_ON_THIS_MINECRAFT_VERSION, "cp")
));
SECTIONS.put("pk", SECTIONS.get("pack"));
SECTIONS.put("ingest", List.of(
Entry.command("mods", "<source> <target> [dimension=<key>] [namespaces=<a,b>] [excludeRegions=<a,b>] [replace=true]", ModdedCatalogMessages.HELP_INGEST_MODS)
));
SECTIONS.put("world", List.of(
Entry.command("enable", "<dimension> <pack|pack:dimensionKey> [seed|random]", ModdedHelpMessages.COMMAND_ENABLE_CREATE_AND_INJECT_A_PERSISTENT_IRIS_DIMENSION_AT_RUNTIME_DOWNLOADS_THE_PACK, "create"),
Entry.command("replace-overworld", "<pack|pack:dimensionKey> [seed|random]", ModdedHelpMessages.COMMAND_REPLACE_OVERWORLD_INJECT_AN_IRIS_PRIMARY_WORLD_AND_ROUTE_PLAYERS_THERE_INSTEAD_OF_THE),
@@ -114,6 +114,9 @@ final class ModdedCommandTree {
root.then(downloadTree("download"));
root.then(downloadTree("dl"));
root.then(ModdedCatalogCommands.catalogTree());
root.then(ModdedCatalogCommands.ingestTree());
root.then(metricsTree("metrics"));
root.then(metricsTree("measure"));
@@ -135,6 +138,9 @@ final class ModdedCommandTree {
root.then(createTree("create"));
root.then(createTree("c"));
root.then(ModdedReplaceCommands.tree("replace"));
root.then(ModdedReplaceCommands.tree("override"));
root.then(ModdedReplaceCommands.tree("overwrite"));
root.then(ModdedStudioCommands.tree("studio"));
root.then(ModdedStudioCommands.tree("std"));
@@ -163,6 +163,9 @@ public final class ModdedDatapackCommands {
continue;
}
IrisDimension dimension = engine.getDimension();
if (dimension.hasBorrowedDimensionType()) {
continue;
}
String json;
try {
json = dimension.getDimensionType().toJson(DataVersion.getRuntime().get());

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