Compare commits

...
149 Commits
Author SHA1 Message Date
Brian Neumann-Fopiano c3359e0a16 Fixing a handfull of issues! 2026-10-21 18:07:10 -04:00
Brian Neumann-Fopiano 1132242c58 Reduced verification allocations
- Dependency hashing reuses one read buffer per artifact
- Hash tests cover large, short, and empty entries
2026-10-21 09:08:00 -04:00
Brian Neumann-Fopiano 43cb336b85 Reviewed verification allocations
- Dependency hashing reuses one read buffer per artifact
- Hash tests cover mixed entry sizes
2026-10-21 09:06:24 -04:00
Brian Neumann-Fopiano dc1c90f63f Reduced verification allocations
- Dependency hashing reuses one read buffer per artifact
- Hash tests cover large, short, and empty entries
2026-10-21 09:06:24 -04:00
Brian Neumann-Fopiano 463feea60b Improved Iris build scheduling
- Resolved SlimJar only for shaded core artifacts
- Applied bounded parallel Forge test execution
2026-10-21 08:27:49 -04:00
Brian Neumann-Fopiano 49f7d11442 Reviewed Iris build scheduling
- Combined dependency task wiring and Forge test limits
2026-10-21 08:25:11 -04:00
Brian Neumann-Fopiano 0eb661f14d Trimmed unused build work
- Limited SlimJar generation to shaded core artifacts
- Honored configured mod test fork limits
- Set Forge targets to four test JVMs
2026-10-21 08:24:55 -04:00
Brian Neumann-Fopiano 5e02c5efdd Forwarded nested dependency settings
- Nested mod builds inherit the Paperweight source generation property
2026-10-21 08:00:04 -04:00
Brian Neumann-Fopiano 8dea0ae3e7 Reviewed dependency source forwarding
- Mod builds retain source generation settings across nested Gradle invocations
2026-10-21 07:58:12 -04:00
Brian Neumann-Fopiano b669e04acf Forwarded dependency source settings
- Nested mod builds inherit the Paperweight source generation property
2026-10-21 07:57:41 -04:00
Brian Neumann-Fopiano 294598d5e1 Improved isolated Forge builds
- Mod verification uses its included core build without duplicate prerequisites
- Nested sandbox builds divide their reserved workers across matrix lanes
- Forge targets reserve eight workers and 16 GiB with two test forks
2026-10-21 07:18:03 -04:00
Brian Neumann-Fopiano 79f5d02a7d Reviewed Forge build budgets
- Divided nested build workers across sandbox matrix lanes
- Reserved eight workers and 16 GiB for Forge verification
2026-10-21 07:11:10 -04:00
Brian Neumann-Fopiano 826a752437 Expanded Forge build budgets
- Sandbox nested builds divide reserved workers across matrix lanes
- Sandbox nested JVM processor counts follow each lane worker budget
- Forge targets reserve eight workers and 16 GiB with two test forks
2026-10-21 07:08:26 -04:00
Brian Neumann-Fopiano 389c2c9c42 Reviewed modded prerequisites
- Modded targets build core jars and fixtures in their own included build
2026-10-21 06:46:34 -04:00
Brian Neumann-Fopiano e1ff5447ca Removed duplicate core prerequisite
- Modded builds resolve core jars and test fixtures through their included build
- Forge setup starts without waiting for unused outer core outputs
2026-10-21 06:43:07 -04:00
Brian Neumann-Fopiano 96903e9cf3 Expanded Iris test parallelism
- Bukkit builds allocate eight workers for the complete core test suite
- Bukkit builds reserve 16 GiB for concurrent test JVMs
2026-10-21 06:34:44 -04:00
Brian Neumann-Fopiano e68c93c0f1 Improved isolated Iris builds
- Bukkit builds allocate four workers and 8 GiB for tests
- Forge sandbox builds retain their private Minecraft dependency cache
2026-10-21 06:22:45 -04:00
Brian Neumann-Fopiano 97da131884 Reviewed Forge cache persistence
- Isolated Forge targets retain generated Minecraft dependencies in their private Gradle homes
2026-10-21 06:22:45 -04:00
Brian Neumann-Fopiano 20ff08aeb7 Persisted Forge build caches
- Sandboxed Forge lanes retain Mavenizer and ForgeGradle caches in their private Gradle home
- Nested mod builds receive the sandbox property
2026-10-21 06:22:20 -04:00
Brian Neumann-Fopiano 9add1a0fa6 Reviewed Bukkit build allocation
- Bukkit build targets reserve four workers and 8 GiB for the complete test suite
2026-10-21 06:18:41 -04:00
Brian Neumann-Fopiano 1b80f4055b Raised Bukkit build budget
- Bukkit source builds use four Gradle workers
- Bukkit source builds reserve 8 GiB of memory
2026-10-21 06:14:01 -04:00
Brian Neumann-Fopiano 365d6f2810 Merged project build improvements
- Reviewed build controls and validation changes are integrated locally
2026-10-21 05:33:21 -04:00
Brian Neumann-Fopiano c17c8bece1 Reviewed sandbox source stamps
- Source stamp changes are integrated for local review
2026-10-21 05:32:24 -04:00
Brian Neumann-Fopiano d84050baac Stabilized sandbox source stamps
- Build constants use the captured Iris source fingerprint
- Unrelated project changes preserve compiled Iris cache inputs
2026-10-21 05:32:24 -04:00
Brian Neumann-Fopiano 7fb684faf5 Merged sandbox build targets
- Gradle exposes Bukkit, API, SPI, and loader targets with isolated build requirements
- Build tasks retain verified artifacts inside their project directories
- API generation writes a jar without publishing or external copies
- Captured source hashes identify sandbox builds
- Build instructions describe manager deployment
2026-10-21 03:41:57 -04:00
Brian Neumann-Fopiano 1342fa6a06 Reviewed sandbox build targets
- Gradle exposes Bukkit, API, SPI, and loader targets with isolated build requirements
- Build tasks retain verified artifacts inside their project directories
- API generation writes a jar without publishing or external copies
- Captured source hashes identify sandbox builds
- Build instructions describe manager deployment
2026-10-21 03:41:57 -04:00
Brian Neumann-Fopiano 52f1a18a1b Declared sandbox build targets
- Gradle exposes Bukkit, API, SPI, and loader targets with isolated build requirements
- Build tasks retain verified artifacts inside their project directories
- API generation writes a jar without publishing or external copies
- Captured source hashes identify sandbox builds
- Build instructions describe manager deployment
2026-10-21 03:41:18 -04:00
Brian Neumann-Fopiano fc810221bb Parallelized mod builds
- Mod targets run through bounded Gradle workers with separate caches and daemon slots
- Target logs retain verification failures and bounded output summaries
- Nested tests use bounded forks and JVM thread counts
- Lifecycle tests control deadlines through injected clocks
2026-10-21 02:53:00 -04:00
Brian Neumann-Fopiano d7059d84a4 V+ 2026-10-20 17:22:54 -04:00
Brian Neumann-Fopiano b357eee181 Fixed runtime configuration
- Timeout overrides retain original values for restoration
- Timeout restoration preserves operator changes and symbolic links
- Tests cover timeout persistence and failed configuration writes
- Runtime targets use separate Gradle caches and shared module outputs
- Modded build inputs include test fixtures and runtime cache settings
2026-10-18 18:15:46 -04:00
Brian Neumann-Fopiano 64f7a7a9ac Merged generation fixes
- Procedural trees keep compact terrain-supported roots
- Canopies preserve trunks across chunk generation and replay
- CraftEngine content follows native terrain replacement and regeneration
- Regression fixtures cover tree geometry and custom content cleanup
2026-10-18 18:04:40 -04:00
Brian Neumann-Fopiano a6aeceff86 Grounded procedural trees
- Compact connected roots extend bounded gaps into supporting terrain
- Unsupported cliff placements leave no partial trees
- Root and canopy roles follow object scaling and rotation
- Canopies preserve solid trunk cells across chunk replay and saved continuations
- Branch wood replaces leaf clusters within each tree
- Regression tests cover slopes and overlapping tree placements
- Gameplay checks compare grounded spindle geometry across both packs
2026-10-18 18:00:40 -04:00
Brian Neumann-Fopiano 51c626c410 Merged custom content fixes
- Deferred placement guards and generated content ownership share the current engine
- Native terrain writes invalidate stale custom content across platforms
2026-10-18 17:59:20 -04:00
Brian Neumann-Fopiano 34417b2f5b Tracked generated content ownership
- Generated furniture keeps ownership tokens for targeted removal
- Native writes invalidate pending custom content and tile records
- Regeneration and chunk updates remove invalidated content on owning threads
- Provider callbacks return ownership tokens across server platforms
- Regression fixtures cover terrain replacement and restart cleanup
2026-10-18 17:58:30 -04:00
SwiftSwamp c3a9449a02 Archive
- Archiving old media to the volmit media repo
2026-10-17 11:43:24 -06:00
Brian Neumann-Fopiano 1076ab47a7 Merge remote-tracking branch 'origin/master' 2026-10-17 11:27:09 -04:00
Brian Neumann-Fopiano 032a8ba46a V+ 2026-10-17 11:03:58 -04:00
SwiftSwamp c7e4b94bf6 Issue Templates
- Updating Issue Templates
2026-10-17 07:15:52 -06:00
Brian Neumann-Fopiano ed5483d75a Merged stale placement guard
- Combine the carrier check with native write invalidation
2026-10-16 19:37:15 -04:00
Brian Neumann-Fopiano ff537f4abc Cleared records under native writes
- Added DeferredPlacementRecords to gate tracking on Identifier and TileWrapper slices
- Cleared deferred custom and tile records at positions native structure placement changed
- Implemented block change tracking in the Bukkit and modded structure policies
- Covered invalidation through the custom block materialization pass
- Covered modded wrapper change tracking and protected cells
- Passed disabled change sets in existing wrapper tests
2026-10-16 19:36:35 -04:00
Brian Neumann-Fopiano dbc9d95372 Skip stale deferred placements
- Skip deferred custom block records whose live block no longer matches the provider carrier type
- Leave air-carrier furniture and unresolvable carriers to the provider unchanged
- Resolve each carrier once per chunk pass and log skipped records once per chunk at debug level
- Skip tile records quietly when the live block no longer matches the tile material
- Apply the same carrier check to the modded custom block pass
- Add Bukkit, CraftEngine and modded regression tests for flattened, replaced, intact and furniture records
2026-10-16 19:32:01 -04:00
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
485 changed files with 34920 additions and 2652 deletions

No files matched your search

+56 -65
View File
@@ -1,87 +1,78 @@
name: Iris Bug Report name: Bug report
description: File a bug report for Iris description: Something in Iris isn't working.
labels: [ bug ] labels: [bug]
type: Bug
assignees: [SwiftSwamp]
body: body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
Thank you for taking the time to fill this out! 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).
If this does not work for you, feel free to use the [blank](https://github.com/VolmitSoftware/Iris/issues/new) format.
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 - type: textarea
id: how id: issue
attributes: attributes:
label: Problem label: What issue are you having?
description: Please give a text description of how you reached the problem description: Include any error message you saw.
value: |
1. Install Iris...
2. Do this...
3. Do that...
4. Observe the error...
validations: validations:
required: true required: true
- type: textarea - type: textarea
id: what id: steps
attributes: attributes:
label: Solution label: Steps to reproduce
description: Explain where you think the problem comes from (optional) placeholder: |
placeholder: The code to place a is missing b and c... 1. Start a server with Iris installed
2. Change ... in the config
3. Run /iris ...
4. ... happens instead of ...
validations: 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 - type: dropdown
id: platform id: platform
attributes: attributes:
label: Platform label: Server software
description: Which server platform is Iris running on? Pick the loader or server software, not the launcher.
options: options:
- Paper - "Paper"
- Purpur - "Purpur"
- Leaf - "Leaf"
- Canvas - "Canvas"
- Folia - "Folia"
- Spigot / CraftBukkit - "Spigot"
- Fabric - "Fabric"
- Forge - "Forge"
- NeoForge - "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'
- Other (unsupported) - Other (unsupported)
validations: validations:
required: true required: true
- type: input - type: dropdown
id: loaderversion id: minecraft
attributes: attributes:
label: Platform / Loader Version label: Minecraft 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". options:
placeholder: DO NOT SAY "LATEST" - "26.3"
- "26.2"
- "26.1.2"
- Other (unsupported)
validations: validations:
required: true required: true
- type: input - type: textarea
id: irisversion id: extra
attributes: attributes:
label: Iris Version label: Anything else?
description: What version of Iris are you running? (see console) description: Other plugins involved, screenshots, or videos.
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."
+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 name: Suggestion
description: File a feature request for Iris. If you want to report a bug this is not the place. description: Suggest a new feature or a change to Iris.
labels: [ feature ] labels: [enhancement]
type: Feature
assignees: [SwiftSwamp]
body: body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
Thank you for taking the time to fill this out! Check the [existing suggestions](https://github.com/VolmitSoftware/Iris/issues?q=is%3Aissue+label%3Aenhancement) first and add relevant information to the existing issue.
If this does not work for you, feel free to use the [blank](https://github.com/VolmitSoftware/Iris/issues/new) format. - type: textarea
- type: dropdown id: problem
id: arc
attributes: attributes:
label: Adding, Removing, or Changing label: What are your suggestions for new features or changes?
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 ...
validations: validations:
required: true required: true
- type: textarea - type: textarea
id: desc id: proposal
attributes: attributes:
label: What are you trying to modify label: How should it work?
description: Give as detailed of a description as you can for the modification that you want done (include pictures if applicable) description: Provide as much detail as possible on what you would like changed or added.
value: The way I would implement this is ...
validations: validations:
required: true required: true
- type: textarea - type: textarea
id: alternative id: extra
attributes: attributes:
label: Alternatives label: Anything else?
description: What alternatives have you considered? description: Any additional information such as screenshots, examples, etc.
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."
+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 GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007 Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> 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. Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble 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 The licenses for most software and other practical works are designed
works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all to take away your freedom to share and change the works. By contrast,
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 is intended to guarantee your freedom to
the GNU General Public License for most of our software; it applies also to any other work released this way by its share and change all versions of a program--to make sure it remains free
authors. You can apply it to your programs, too. 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 When we speak of free software, we are referring to freedom, not
sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive price. Our General Public Licenses are designed to make sure that you
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 have the freedom to distribute copies of free software (and charge for
that you know you can do these things. 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. To protect your rights, we need to prevent others from denying you
Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: these rights or asking you to surrender the rights. Therefore, you have
responsibilities to respect the freedom of others. 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 For example, if you distribute copies of such a program, whether
the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must gratis or for a fee, you must pass on to the recipients the same
show them these terms so they know their rights. 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: 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 (1) assert copyright on the software, and (2) offer you this License
and/or modify it. 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 the developers' and authors' protection, the GPL clearly explains
For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems that there is no warranty for this free software. For both users' and
will not be attributed erroneously to authors of previous versions. 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 Some devices are designed to deny users access to install or run
the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the modified versions of the software inside them, although the manufacturer
software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely can do so. This is fundamentally incompatible with the aim of
where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those protecting users' freedom to change the software. The systematic
products. If such problems arise substantially in other domains, we stand ready to extend this provision to those pattern of such abuse occurs in the area of products for individuals to
domains in future versions of the GPL, as needed to protect the freedom of users. 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 Finally, every program is threatened constantly by software patents.
development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger States should not allow patents to restrict development and use of
that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that 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. 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 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". " "The Program" refers to any copyrightable work licensed under this
Licensees" and License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations. "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, To "modify" a work means to copy from or adapt all or part of the work
other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a in a fashion requiring copyright permission, other than the making of an
work "based on" the earlier work. 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 To "propagate" a work means to do anything with it that, without
liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. permission, would make you directly or secondarily liable for
Propagation includes copying, distribution (with or without modification), making available to the public, and in some infringement under applicable copyright law, except executing it on a
countries other activities as well. 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 To "convey" a work means any kind of propagation that enables other
with a user through a computer network, with no transfer of a copy, is not conveying. 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" 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 to the extent that it includes a convenient and prominently visible
notice, and (2) 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 tells the user that there is no warranty for the work (except to the
licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a extent that warranties are provided), that licensees may convey the
list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 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 The "source code" for a work means the preferred form of the work
any non-source form of a 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, A "Standard Interface" means an interface that either is an official
or, in the case of interfaces specified for a particular programming language, one that is widely used among developers standard defined by a recognized standards body, or, in the case of
working in that language. 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 "System Libraries" of an executable work include anything, other
the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to than the work as a whole, that (a) is included in the normal form of
enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is packaging a Major Component, but which is not part of that Major
available to the public in source code form. A 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 "Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system (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 (if any) on which the executable work runs, or a compiler used to
to run it. 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 ( The "Corresponding Source" for a work in object code form means all
for an executable work) run the object code and to modify the work, including scripts to control those activities. the source code needed to generate, install, and (for an executable
However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs work) run the object code and to modify the work, including scripts to
which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding control those activities. However, it does not include the work's
Source includes interface definition files associated with source files for the work, and the source code for shared System Libraries, or general-purpose tools or generally available free
libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data programs which are used unmodified in performing those activities but
communication or control flow between those subprograms and other parts of the work. 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 The Corresponding Source need not include anything that users
Corresponding Source. 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 All rights granted under this License are granted for the term of
the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. copyright on the Program, and are irrevocable provided the stated
The output from running a covered work is covered by this License only if the output, given its content, constitutes a conditions are met. This License explicitly affirms your unlimited
covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. 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 You may make, run and propagate covered works that you do not
otherwise remains in force. You may convey covered works to others for the sole purpose of having them make convey, without conditions so long as your license otherwise remains
modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with in force. You may convey covered works to others for the sole purpose
the terms of this License in conveying all material for which you do not control copyright. Those thus making or running of having them make modifications exclusively for you, or provide you
the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that with facilities for running those works, provided that you comply with
prohibit them from making any copies of your copyrighted material outside their relationship with you. 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 Conveying under any other circumstances is permitted solely under
allowed; section 10 makes it unnecessary. 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 No covered work shall be deemed part of an effective technological
obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or measure under any applicable law fulfilling obligations under article
restricting circumvention of such measures. 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 When you convey a covered work, you waive any legal power to forbid
extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you circumvention of technological measures to the extent such circumvention
disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, is effected by exercising rights under this License with respect to
your or third parties' legal rights to forbid circumvention of technological measures. 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 You may convey verbatim copies of the Program's source code as you
conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating receive it, in any medium, provided that you conspicuously and
that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices appropriately publish on each copy an appropriate copyright notice;
of the absence of any warranty; and give all recipients a copy of this License along with the Program. 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 You may charge any price or no price for each copy that you convey,
a fee. 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 You may convey a work based on the Program, or the modifications to
code under the terms of section 4, provided that you also meet all of these conditions: 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 a) The work must carry prominent notices stating that you modified
it, and giving a relevant date. 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 interfaces that do not display Appropriate Legal Notices, your
work need not make them do so. 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 A compilation of a covered work with other separate and independent
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 works, which are not by their nature extensions of the covered work,
distribution medium, is called an and which are not combined with it such as to form a larger program,
"aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the in or on a volume of a storage or distribution medium, is called an
compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause "aggregate" if the compilation and its resulting copyright are not
this License to apply to the other parts of the aggregate. 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 You may convey a covered work in object code form under the terms
machine-readable Corresponding Source under the terms of this License, in one of these ways: 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 a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the (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 Source of the work are being offered to the general public at no
charge under subsection 6d. charge under subsection 6d.
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, A separable portion of the object code, whose source code is excluded
need not be included in conveying the object code work. 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 A "User Product" is either (1) a "consumer product", which means any
for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In tangible personal property which is normally used for personal, family,
determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a or household purposes, or (2) anything designed or sold for incorporation
particular product received by a particular user, "normally used" refers to a typical or common use of that class of into a dwelling. In determining whether a product is a consumer product,
product, regardless of the status of the particular user or of the way in which the particular user actually uses, or doubtful cases shall be resolved in favor of coverage. For a particular
expects or is expected to use, the product. A product is a consumer product regardless of whether the product has product received by a particular user, "normally used" refers to a
substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of typical or common use of that class of product, regardless of the status
the product. 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 "Installation Information" for a User Product means any methods,
required to install and execute modified versions of a covered work in that User Product from a modified version of its procedures, authorization keys, or other information required to install
Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code and execute modified versions of a covered work in that User Product from
is in no case prevented or interfered with solely because modification has been made. 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 If you convey an object code work under this section in, or with, or
conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to specifically for use in, a User Product, and the conveying occurs as
the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding part of a transaction in which the right of possession and use of the
Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not User Product is transferred to the recipient in perpetuity or for a
apply if neither you nor any third party retains the ability to install modified object code on the User Product (for fixed term (regardless of how the transaction is characterized), the
example, the work has been installed in ROM). 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 The requirement to provide Installation Information does not include a
service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product requirement to continue to provide support service, warranty, or updates
in which it has been modified or installed. Access to a network may be denied when the modification itself materially for a work that has been modified or installed by the recipient, or for
and adversely affects the operation of the network or violates the rules and protocols for communication across the the User Product in which it has been modified or installed. Access to a
network. 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 Corresponding Source conveyed, and Installation Information provided,
that is publicly documented (and with an implementation available to the public in source code form), and must require in accord with this section must be in a format that is publicly
no special password or key for unpacking, reading or copying. 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 "Additional permissions" are terms that supplement the terms of this
its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were License by making exceptions from one or more of its conditions.
included in this License, to the extent that they are valid under applicable law. If additional permissions apply only Additional permissions that are applicable to the entire Program shall
to part of the Program, that part may be used separately under those permissions, but the entire Program remains be treated as though they were included in this License, to the extent
governed by this License without regard to the additional permissions. 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 When you convey a copy of a covered work, you may at your option
from any part of it. (Additional permissions may be written to require their own removal in certain cases when you remove any additional permissions from that copy, or from any part of
modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have it. (Additional permissions may be written to require their own
or can give appropriate copyright permission. 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 Notwithstanding any other provision of this License, for material you
the copyright holders of that material) supplement the terms of this License with terms: 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 a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or 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 any liability that these contractual assumptions directly impose on
those licensors and authors. those licensors and authors.
All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the All other non-permissive additional terms are considered "further
Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with restrictions" within the meaning of section 10. If the Program as you
a term that is a further restriction, you may remove that term. If a license document contains a further restriction but received it, or any part of it, contains a notice stating that it is
permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of governed by this License along with a term that is a further
that license document, provided that the further restriction does not survive such relicensing or conveying. 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 If you add terms to a covered work in accord with this section, you
statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. 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 Additional terms, permissive or non-permissive, may be stated in the
exceptions; the above requirements apply either way. 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 You may not propagate or modify a covered work except as expressly
propagate or modify it is void, and will automatically terminate your rights under this License (including any patent provided under this License. Any attempt otherwise to propagate or
licenses granted under the third paragraph of section 11). 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 However, if you cease all violation of this License, then your
reinstated (a) license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) provisionally, unless and until the copyright holder explicitly and
permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days finally terminates your license, and (b) permanently, if the copyright
after the cessation. 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 Moreover, your license from a particular copyright holder is
of the violation by some reasonable means, this is the first time you have received notice of violation of this reinstated permanently if the copyright holder notifies you of the
License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the violation by some reasonable means, this is the first time you have
notice. 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 Termination of your rights under this section does not terminate the
rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not licenses of parties who have received copies or rights from you under
qualify to receive new licenses for the same material under section 10. 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 You are not required to accept this License in order to receive or
covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not run a copy of the Program. Ancillary propagation of a covered work
require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered occurring solely as a consequence of using peer-to-peer transmission
work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a 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. 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, Each time you convey a covered work, the recipient automatically
modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third receives a license from the original licensors, to run, modify and
parties with this License. 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 An "entity transaction" is a transaction transferring control of an
subdividing an organization, or merging organizations. If propagation of a covered work results from an entity organization, or substantially all assets of one, or subdividing an
transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work organization, or merging organizations. If propagation of a covered
the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the work results from an entity transaction, each party to that
Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with transaction who receives a copy of the work also receives whatever
reasonable efforts. 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 You may not impose any further restrictions on the exercise of the
example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, rights granted or affirmed under this License. For example, you may
and you may not initiate litigation not impose a license fee, royalty, or other charge for exercise of
(including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, rights granted under this License, and you may not initiate litigation
selling, offering for sale, or importing the Program or any portion of it. (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 A "contributor" is a copyright holder who authorizes use under this
Program is based. The work thus licensed is called the contributor's "contributor version". 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 A contributor's "essential patent claims" are all patent claims
acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or owned or controlled by the contributor, whether already acquired or
selling its contributor version, but do not include claims that would be infringed only as a consequence of further hereafter acquired, that would be infringed by some manner, permitted
modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent by this License, of making, using, or selling its contributor version,
sublicenses in a manner consistent with the requirements of this License. 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 Each contributor grants you a non-exclusive, worldwide, royalty-free
patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its patent license under the contributor's essential patent claims, to
contributor version. 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 In the following three paragraphs, a "patent license" is any express
to enforce a patent 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 (such as an express permission to practice a patent or covenant not to
patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. 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 If you convey a covered work, knowingly relying on a patent license,
available for anyone to copy, free of charge and under the terms of this License, through a publicly available network and the Corresponding Source of the work is not available for anyone
server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, to copy, free of charge and under the terms of this License, through a
or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a publicly available network server or other readily accessible means,
manner consistent with the requirements of this License, to extend the patent license to downstream recipients. " then you must either (1) cause the Corresponding Source to be so
Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in available, or (2) arrange to deprive yourself of the benefit of the
a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in patent license for this particular work, or (3) arrange, in a manner
that country that you have reason to believe are valid. 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 If, pursuant to or in connection with a single transaction or
conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing arrangement, you convey, or propagate by procuring conveyance of, a
them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is covered work, and grant a patent license to some of the parties
automatically extended to all recipients of the covered work and works based on it. 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, A patent license is "discriminatory" if it does not include within
or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You the scope of its coverage, prohibits the exercise of, or is
may not convey a covered work if you are a party to an arrangement with a third party that is in the business of conditioned on the non-exercise of one or more of the rights that are
distributing software, under which you make payment to the third party based on the extent of your activity of conveying specifically granted under this License. You may not convey a covered
the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a work if you are a party to an arrangement with a third party that is
discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from in the business of distributing software, under which you make payment
those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered to the third party based on the extent of your activity of conveying
work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. 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 Nothing in this License shall be construed as excluding or limiting
infringement that may otherwise be available to you under applicable patent law. 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 If conditions are imposed on you (whether by court order, agreement or
License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to otherwise) that contradict the conditions of this License, they do not
satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence excuse you from the conditions of this License. If you cannot convey a
you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further covered work so as to satisfy simultaneously your obligations under this
conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License License and any other pertinent obligations, then as a consequence you may
would be to refrain entirely from conveying the Program. 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 Notwithstanding any other provision of this License, you have
licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the permission to link or combine any covered work with a work licensed
resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special under version 3 of the GNU Affero General Public License into a single
requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply combined work, and to convey the resulting work. The terms of this
to the combination as such. 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 The Free Software Foundation may publish revised and/or new versions of
time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new the GNU General Public License from time to time. Such new versions will
problems or concerns. 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 Each version is given a distinguishing version number. If the
GNU General Public License "or any later version" applies to it, you have the option of following the terms and Program specifies that a certain numbered version of the GNU General
conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Public License "or any later version" applies to it, you have the
Program does not specify a version number of the GNU General Public License, you may choose any version ever published 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. 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, If the Program specifies that a proxy can decide which future
that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the versions of the GNU General Public License can be used, that proxy's
Program. 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 Later license versions may give you additional or different
on any author or copyright holder as a result of your choosing to follow a later version. 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 THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 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 IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DAMAGES. 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 If the disclaimer of warranty and limitation of liability provided
their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil above cannot be given local legal effect according to their terms,
liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program reviewing courts shall apply local law that most closely approximates
in return for a fee. 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 END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs 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 If you develop a new program, and you want it to be of the greatest
this is to make it free software which everyone can redistribute and change under these terms. 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 To do so, attach the following notices to the program. It is safest
most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to attach them to the start of each source file to most effectively
to where the full notice is found. 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.> <one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author> 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. 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> <program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details. 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 The hypothetical commands `show w' and `show c' should show the appropriate
course, your program's commands might be different; for a GUI interface, you would use an "about box". 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 You should also get your employer (if you work as a programmer) or school,
the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see 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/>. <https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is The GNU General Public License does not permit incorporating your program
a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If into proprietary programs. If your program is a subroutine library, you
this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read 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>. <https://www.gnu.org/licenses/why-not-lgpl.html>.
+17 -19
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 | | 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+ | | 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 | | 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. Java 25 is required on every platform.
@@ -248,28 +248,26 @@ which is what a board reader means.
Requirements: JDK 25 (set `JAVA_HOME` to it). The Gradle wrapper handles everything else. Requirements: JDK 25 (set `JAVA_HOME` to it). The Gradle wrapper handles everything else.
``` ```sh
./gradlew buildAllToOut ./gradlew buildAll
``` ```
builds every platform artifact into the workspace-level `../PluginOuts/` directory: This builds and verifies the Bukkit plugin, API, SPI and the Fabric, Forge and NeoForge jars
for every Minecraft version declared in `gradle/mod-targets.json`. Artifacts stay in the
producing module's `build/` directory. The packed Bukkit plugin is in `build/libs/`, the
API is in `build/api/`, the SPI is in `spi/build/libs/`, and mod jars are in
`adapters/<loader>/build/<minecraft>/libs/`.
``` Use `./gradlew buildBukkit` for the plugin, or `verify<Loader><mc>` for one mod jar,
Iris v<version> [CraftBukkit] <mc>.jar for example `verifyFabric26_2` or `verifyNeoForge1_21_1`. `buildFabric`, `buildForge`
Iris v<version> [Fabric] <mc>+<loader>.jar and `buildNeoforge` select the `minecraftVersion` Gradle property. `./gradlew checkAll`
Iris v<version> [Forge] <mc>+<loader>.jar runs every check plus the complete mod matrix. Forge 1.21.1 compiles its tests because
Iris v<version> [NeoForge] <mc>+<loader>.jar Forge 52 cannot bootstrap Minecraft outside FML.
```
Per-platform tasks: `./gradlew buildBukkit`, `buildFabric`, `buildForge`, `buildNeoforge`. The Multiplexor reads `gradle/multiplexor.gradle` to discover targets and build each in a
SPI jar (the pure-JVM adapter/platform contract, not the stable plugin API) is built to `spi/build/libs/` by separate source sandbox. Use its project build and deployment commands to export jars
`./gradlew :spi:jar`. or install them into managed test instances. Gradle builds do not copy artifacts to
external destinations.
`./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
`../PluginOuts/`.
VolmLib is built from source when a `VolmLib` checkout sits beside the repo or in a parent directory VolmLib is built from source when a `VolmLib` checkout sits beside the repo or in a parent directory
(`VOLMLIB_DIR` or `-PlocalVolmLibDirectory=<path>` pick another one). Without one, or with (`VOLMLIB_DIR` or `-PlocalVolmLibDirectory=<path>` pick another one). Without one, or with
@@ -245,9 +245,15 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
for (int y = groundY + 1; y <= groundY + 32; y++) { for (int y = groundY + 1; y <= groundY + 32; y++) {
assertTrue("Roof at " + x + "," + y, state(blocks, x, y, 0).isAir()); assertTrue("Roof at " + x + "," + y, state(blocks, x, y, 0).isAir());
} }
for (int y = 12; y < groundY; y++) { int supportY = x < 2 ? groundY - 33 : 12;
for (int y = supportY; y < groundY; y++) {
assertTrue("Missing foundation at " + x + "," + y, state(blocks, x, y, 0).isSolid()); assertTrue("Missing foundation at " + x + "," + y, state(blocks, x, y, 0).isSolid());
} }
if (x < 2) {
for (int y = 13; y < supportY; y++) {
assertTrue("Cave beneath support at " + x + "," + y, state(blocks, x, y, 0).isAir());
}
}
} }
} }
@@ -268,6 +274,57 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
assertTrue(state(blocks, 0, groundY + 1, 0).isAir()); assertTrue(state(blocks, 0, groundY + 1, 0).isAir());
} }
@Test
public void flattenPreservesCavesBelowIntactGroundAtTheSkirtEdge() {
StructureStart start = desertStart();
int groundY = start.getBoundingBox().minY() - 1;
int x = start.getBoundingBox().maxX() + 23;
int z = start.getBoundingBox().minZ();
BoundingBox area = new BoundingBox(x, 0, z, x, 127, z);
Map<BlockPos, BlockState> blocks = new HashMap<>();
for (int y = groundY - 35; y <= groundY; y++) {
BlockState block = y >= groundY - 34 && y <= groundY - 13
? Blocks.CAVE_AIR.defaultBlockState() : Blocks.STONE.defaultBlockState();
put(blocks, x, y, z, block);
}
put(blocks, x, groundY - 1, z, Blocks.DIRT.defaultBlockState());
put(blocks, x, groundY, z, Blocks.GRASS_BLOCK.defaultBlockState());
NativeStructureTerrainIntegrator.TerrainTarget target = surfaceTarget(start, StructureTerrainMode.FLATTEN);
((IrisStructureTerrain) target.terrain()).setHorizontalPadding(24).setFlattenRange(64);
NativeStructureSurfaceFitter.prepareSurfaceStructures(world(blocks), area, List.of(target), (px, pz) -> groundY);
for (int y = groundY - 34; y <= groundY - 13; y++) {
assertEquals("Cave at " + y, Blocks.CAVE_AIR.defaultBlockState(), state(blocks, x, y, z));
}
assertEquals(Blocks.DIRT.defaultBlockState(), state(blocks, x, groundY - 1, z));
}
@Test
public void flattenBridgesTheFoundationGapWithoutFillingACaveBelowItsSupport() {
StructureStart start = desertStart();
int groundY = start.getBoundingBox().minY() - 1;
BoundingBox area = new BoundingBox(0, 0, 0, 0, 127, 0);
Map<BlockPos, BlockState> blocks = new HashMap<>();
put(blocks, 0, groundY, 0, Blocks.GRASS_BLOCK.defaultBlockState());
for (int y = groundY - 11; y <= groundY - 9; y++) {
put(blocks, 0, y, 0, Blocks.STONE.defaultBlockState());
}
for (int y = groundY - 20; y <= groundY - 12; y++) {
put(blocks, 0, y, 0, Blocks.CAVE_AIR.defaultBlockState());
}
NativeStructureSurfaceFitter.prepareSurfaceStructures(world(blocks), area,
List.of(surfaceTarget(start, StructureTerrainMode.FLATTEN)), (x, z) -> groundY);
for (int y = groundY - 8; y < groundY; y++) {
assertEquals("Foundation at " + y, Blocks.STONE.defaultBlockState(), state(blocks, 0, y, 0));
}
for (int y = groundY - 20; y <= groundY - 12; y++) {
assertEquals("Cave at " + y, Blocks.CAVE_AIR.defaultBlockState(), state(blocks, 0, y, 0));
}
}
@Test @Test
public void flattenLeavesBuriedAndSubmergedInstancesUntouched() { public void flattenLeavesBuriedAndSubmergedInstancesUntouched() {
StructureStart start = desertStart(); StructureStart start = desertStart();
@@ -619,10 +676,18 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
for (int z = 0; z <= 8; z++) { for (int z = 0; z <= 8; z++) {
int groundY = plan.flattenedHeights().get(NativeStructureTemplateOccupancy.columnKey(x, z)) - 1; int groundY = plan.flattenedHeights().get(NativeStructureTemplateOccupancy.columnKey(x, z)) - 1;
assertTrue("Village grade at " + x + "," + z + ": " + groundY, groundY >= 64 && groundY <= 70); assertTrue("Village grade at " + x + "," + z + ": " + groundY, groundY >= 64 && groundY <= 70);
for (int y = 20; y <= groundY; y++) { boolean originalFoundation = (x == 0 || x == 8) && (z == 0 || z == 8);
int supportY = originalFoundation ? 63 : 20;
for (int y = supportY; y <= groundY; y++) {
assertTrue("Disconnected village ground at " + x + "," + y + "," + z, assertTrue("Disconnected village ground at " + x + "," + y + "," + z,
state(blocks, x, y, z).isSolid()); state(blocks, x, y, z).isSolid());
} }
if (originalFoundation) {
for (int y = 21; y < supportY; y++) {
assertTrue("Cave beneath foundation at " + x + "," + y + "," + z,
state(blocks, x, y, z).isAir());
}
}
for (int y = groundY + 1; y <= 96; y++) { for (int y = groundY + 1; y <= 96; y++) {
assertTrue("Roof remains above village at " + x + "," + y + "," + z, assertTrue("Roof remains above village at " + x + "," + y + "," + z,
state(blocks, x, y, z).isAir()); state(blocks, x, y, z).isAir());
@@ -3,6 +3,7 @@ package art.arcane.iris.platform.bukkit.nms.v26_2_R1;
import art.arcane.volmlib.nativelib.v26_2_R1.terrain.NativeStructureWorldgenAccess; import art.arcane.volmlib.nativelib.v26_2_R1.terrain.NativeStructureWorldgenAccess;
import art.arcane.iris.structure.placement.IrisStructureTerrain; import art.arcane.iris.structure.placement.IrisStructureTerrain;
import art.arcane.volmlib.nativelib.terrain.NativeBlockChangeSet;
import art.arcane.volmlib.util.structure.StructureTerrainMode; import art.arcane.volmlib.util.structure.StructureTerrainMode;
import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeStructurePostProcessor; import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeStructurePostProcessor;
import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeStructureTerrainIntegrator; import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeStructureTerrainIntegrator;
@@ -230,8 +231,9 @@ public class NativeStructureWorldgenAccessTest {
recording.world(), generationCenter, recording.world(), generationCenter,
(x, z) -> SURFACE_FIRST_FREE_Y, (x, z) -> SURFACE_FIRST_FREE_Y,
(x, z) -> FLOOR_FIRST_FREE_Y, (x, z) -> FLOOR_FIRST_FREE_Y,
true, true, true,
position -> false); position -> false,
NativeBlockChangeSet.disabled());
int x = generationCenter.getMiddleBlockX(); int x = generationCenter.getMiddleBlockX();
int z = generationCenter.getMiddleBlockZ(); int z = generationCenter.getMiddleBlockZ();
@@ -248,8 +250,9 @@ public class NativeStructureWorldgenAccessTest {
recording.world(), generationCenter(), recording.world(), generationCenter(),
(x, z) -> SURFACE_FIRST_FREE_Y, (x, z) -> SURFACE_FIRST_FREE_Y,
(x, z) -> FLOOR_FIRST_FREE_Y, (x, z) -> FLOOR_FIRST_FREE_Y,
false, false, true,
position::equals); position::equals,
NativeBlockChangeSet.disabled());
assertSame(Blocks.DIRT, access.getBlockState(position).getBlock()); assertSame(Blocks.DIRT, access.getBlockState(position).getBlock());
assertFalse(access.ensureCanWrite(position)); assertFalse(access.ensureCanWrite(position));
@@ -319,8 +322,9 @@ public class NativeStructureWorldgenAccessTest {
recording.world(), generationCenter(), recording.world(), generationCenter(),
(x, z) -> SURFACE_FIRST_FREE_Y, (x, z) -> SURFACE_FIRST_FREE_Y,
(x, z) -> FLOOR_FIRST_FREE_Y, (x, z) -> FLOOR_FIRST_FREE_Y,
false, false, true,
position -> false); position -> false,
NativeBlockChangeSet.disabled());
} }
private static ChunkPos generationCenter() { private static ChunkPos generationCenter() {
@@ -9,6 +9,7 @@ import art.arcane.iris.structure.nativegen.NativeStructureVolumeIndex;
import art.arcane.iris.generation.runtime.IrisEngine; import art.arcane.iris.generation.runtime.IrisEngine;
import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.Engine;
import art.arcane.volmlib.nativelib.terrain.structure.NativeStructureVolume; import art.arcane.volmlib.nativelib.terrain.structure.NativeStructureVolume;
import art.arcane.volmlib.nativelib.terrain.structure.StructureTerrainSettings;
import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter; import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter;
import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KList;
import net.minecraft.SharedConstants; import net.minecraft.SharedConstants;
@@ -69,7 +70,8 @@ public class NativeStructureVolumeIndexTest {
StructureStart start = swampHut(0, 0, 4, 6); StructureStart start = swampHut(0, 0, 4, 6);
BoundingBox bounds = start.getPieces().getFirst().getBoundingBox(); BoundingBox bounds = start.getPieces().getFirst().getBoundingBox();
KList<NativeStructureVolume> volumes = NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, start); KList<NativeStructureVolume> volumes = NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, start,
StructureTerrainSettings.SOURCE, (x, z) -> 63);
assertEquals(1, volumes.size()); assertEquals(1, volumes.size());
NativeStructureVolume volume = volumes.getFirst(); NativeStructureVolume volume = volumes.getFirst();
@@ -84,8 +86,10 @@ public class NativeStructureVolumeIndexTest {
@Test @Test
public void invalidStartsContributeNoVolumes() { public void invalidStartsContributeNoVolumes() {
assertNull(NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, StructureStart.INVALID_START)); assertNull(NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, StructureStart.INVALID_START,
assertNull(NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, null)); StructureTerrainSettings.SOURCE, (x, z) -> 63));
assertNull(NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, null,
StructureTerrainSettings.SOURCE, (x, z) -> 63));
} }
@Test @Test
@@ -323,7 +327,7 @@ public class NativeStructureVolumeIndexTest {
BlockingResolver coordination = new BlockingResolver(); BlockingResolver coordination = new BlockingResolver();
NativeStructureVolumeIndex.OriginResolver volumes = (engine, chunkX, chunkZ) -> NativeStructureVolumeIndex.OriginResolver volumes = (engine, chunkX, chunkZ) ->
new KList<>(new NativeStructureVolume(chunkX + ":" + chunkZ, new KList<>(new NativeStructureVolume(chunkX + ":" + chunkZ,
-256, 0, -256, 256, 255, 256)); -256, 0, -256, 256, 255, 256, null));
NativeStructureVolumeIndex index = NativeStructureVolumeIndex.forTesting((engine, chunkX, chunkZ) -> { NativeStructureVolumeIndex index = NativeStructureVolumeIndex.forTesting((engine, chunkX, chunkZ) -> {
coordination.volumesAt(engine, chunkX, chunkZ); coordination.volumesAt(engine, chunkX, chunkZ);
return volumes.volumesAt(engine, chunkX, chunkZ); return volumes.volumesAt(engine, chunkX, chunkZ);
@@ -369,7 +373,7 @@ public class NativeStructureVolumeIndexTest {
terrain.join(); terrain.join();
} }
return new KList<>(new NativeStructureVolume(chunkX + ":" + chunkZ, return new KList<>(new NativeStructureVolume(chunkX + ":" + chunkZ,
-256, 0, -256, 256, 100, 256)); -256, 0, -256, 256, 100, 256, null));
}); });
ExecutorService owner = Executors.newSingleThreadExecutor(); ExecutorService owner = Executors.newSingleThreadExecutor();
ForkJoinPool workers = new ForkJoinPool(2); ForkJoinPool workers = new ForkJoinPool(2);
@@ -544,7 +548,8 @@ public class NativeStructureVolumeIndexTest {
if (chunkX != originChunkX || chunkZ != originChunkZ) { if (chunkX != originChunkX || chunkZ != originChunkZ) {
return NativeStructureVolume.NONE; return NativeStructureVolume.NONE;
} }
return NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, swampHut(chunkX, chunkZ, 4, 6)); return NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, swampHut(chunkX, chunkZ, 4, 6),
StructureTerrainSettings.SOURCE, (x, z) -> 63);
} }
} }
@@ -596,7 +601,7 @@ public class NativeStructureVolumeIndexTest {
return new KList<>(new NativeStructureVolume( return new KList<>(new NativeStructureVolume(
chunkX + ":" + chunkZ, chunkX + ":" + chunkZ,
0, 0, 0, 0, 0, 0,
15, 255, 15)); 15, 255, 15, null));
} }
} }
private record ScopedEngine(IrisEngine engine, AtomicInteger scopes) { private record ScopedEngine(IrisEngine engine, AtomicInteger scopes) {
@@ -245,9 +245,15 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
for (int y = groundY + 1; y <= groundY + 32; y++) { for (int y = groundY + 1; y <= groundY + 32; y++) {
assertTrue("Roof at " + x + "," + y, state(blocks, x, y, 0).isAir()); assertTrue("Roof at " + x + "," + y, state(blocks, x, y, 0).isAir());
} }
for (int y = 12; y < groundY; y++) { int supportY = x < 2 ? groundY - 33 : 12;
for (int y = supportY; y < groundY; y++) {
assertTrue("Missing foundation at " + x + "," + y, state(blocks, x, y, 0).isSolid()); assertTrue("Missing foundation at " + x + "," + y, state(blocks, x, y, 0).isSolid());
} }
if (x < 2) {
for (int y = 13; y < supportY; y++) {
assertTrue("Cave beneath support at " + x + "," + y, state(blocks, x, y, 0).isAir());
}
}
} }
} }
@@ -268,6 +274,57 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
assertTrue(state(blocks, 0, groundY + 1, 0).isAir()); assertTrue(state(blocks, 0, groundY + 1, 0).isAir());
} }
@Test
public void flattenPreservesCavesBelowIntactGroundAtTheSkirtEdge() {
StructureStart start = desertStart();
int groundY = start.getBoundingBox().minY() - 1;
int x = start.getBoundingBox().maxX() + 23;
int z = start.getBoundingBox().minZ();
BoundingBox area = new BoundingBox(x, 0, z, x, 127, z);
Map<BlockPos, BlockState> blocks = new HashMap<>();
for (int y = groundY - 35; y <= groundY; y++) {
BlockState block = y >= groundY - 34 && y <= groundY - 13
? Blocks.CAVE_AIR.defaultBlockState() : Blocks.STONE.defaultBlockState();
put(blocks, x, y, z, block);
}
put(blocks, x, groundY - 1, z, Blocks.DIRT.defaultBlockState());
put(blocks, x, groundY, z, Blocks.GRASS_BLOCK.defaultBlockState());
NativeStructureTerrainIntegrator.TerrainTarget target = surfaceTarget(start, StructureTerrainMode.FLATTEN);
((IrisStructureTerrain) target.terrain()).setHorizontalPadding(24).setFlattenRange(64);
NativeStructureSurfaceFitter.prepareSurfaceStructures(world(blocks), area, List.of(target), (px, pz) -> groundY);
for (int y = groundY - 34; y <= groundY - 13; y++) {
assertEquals("Cave at " + y, Blocks.CAVE_AIR.defaultBlockState(), state(blocks, x, y, z));
}
assertEquals(Blocks.DIRT.defaultBlockState(), state(blocks, x, groundY - 1, z));
}
@Test
public void flattenBridgesTheFoundationGapWithoutFillingACaveBelowItsSupport() {
StructureStart start = desertStart();
int groundY = start.getBoundingBox().minY() - 1;
BoundingBox area = new BoundingBox(0, 0, 0, 0, 127, 0);
Map<BlockPos, BlockState> blocks = new HashMap<>();
put(blocks, 0, groundY, 0, Blocks.GRASS_BLOCK.defaultBlockState());
for (int y = groundY - 11; y <= groundY - 9; y++) {
put(blocks, 0, y, 0, Blocks.STONE.defaultBlockState());
}
for (int y = groundY - 20; y <= groundY - 12; y++) {
put(blocks, 0, y, 0, Blocks.CAVE_AIR.defaultBlockState());
}
NativeStructureSurfaceFitter.prepareSurfaceStructures(world(blocks), area,
List.of(surfaceTarget(start, StructureTerrainMode.FLATTEN)), (x, z) -> groundY);
for (int y = groundY - 8; y < groundY; y++) {
assertEquals("Foundation at " + y, Blocks.STONE.defaultBlockState(), state(blocks, 0, y, 0));
}
for (int y = groundY - 20; y <= groundY - 12; y++) {
assertEquals("Cave at " + y, Blocks.CAVE_AIR.defaultBlockState(), state(blocks, 0, y, 0));
}
}
@Test @Test
public void flattenLeavesBuriedAndSubmergedInstancesUntouched() { public void flattenLeavesBuriedAndSubmergedInstancesUntouched() {
StructureStart start = desertStart(); StructureStart start = desertStart();
@@ -619,10 +676,18 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
for (int z = 0; z <= 8; z++) { for (int z = 0; z <= 8; z++) {
int groundY = plan.flattenedHeights().get(NativeStructureTemplateOccupancy.columnKey(x, z)) - 1; int groundY = plan.flattenedHeights().get(NativeStructureTemplateOccupancy.columnKey(x, z)) - 1;
assertTrue("Village grade at " + x + "," + z + ": " + groundY, groundY >= 64 && groundY <= 70); assertTrue("Village grade at " + x + "," + z + ": " + groundY, groundY >= 64 && groundY <= 70);
for (int y = 20; y <= groundY; y++) { boolean originalFoundation = (x == 0 || x == 8) && (z == 0 || z == 8);
int supportY = originalFoundation ? 63 : 20;
for (int y = supportY; y <= groundY; y++) {
assertTrue("Disconnected village ground at " + x + "," + y + "," + z, assertTrue("Disconnected village ground at " + x + "," + y + "," + z,
state(blocks, x, y, z).isSolid()); state(blocks, x, y, z).isSolid());
} }
if (originalFoundation) {
for (int y = 21; y < supportY; y++) {
assertTrue("Cave beneath foundation at " + x + "," + y + "," + z,
state(blocks, x, y, z).isAir());
}
}
for (int y = groundY + 1; y <= 96; y++) { for (int y = groundY + 1; y <= 96; y++) {
assertTrue("Roof remains above village at " + x + "," + y + "," + z, assertTrue("Roof remains above village at " + x + "," + y + "," + z,
state(blocks, x, y, z).isAir()); state(blocks, x, y, z).isAir());
@@ -3,6 +3,7 @@ package art.arcane.iris.platform.bukkit.nms.v26_3_R1;
import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeStructureWorldgenAccess; import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeStructureWorldgenAccess;
import art.arcane.iris.structure.placement.IrisStructureTerrain; import art.arcane.iris.structure.placement.IrisStructureTerrain;
import art.arcane.volmlib.nativelib.terrain.NativeBlockChangeSet;
import art.arcane.volmlib.util.structure.StructureTerrainMode; import art.arcane.volmlib.util.structure.StructureTerrainMode;
import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeStructurePostProcessor; import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeStructurePostProcessor;
import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeStructureTerrainIntegrator; import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeStructureTerrainIntegrator;
@@ -230,8 +231,9 @@ public class NativeStructureWorldgenAccessTest {
recording.world(), generationCenter, recording.world(), generationCenter,
(x, z) -> SURFACE_FIRST_FREE_Y, (x, z) -> SURFACE_FIRST_FREE_Y,
(x, z) -> FLOOR_FIRST_FREE_Y, (x, z) -> FLOOR_FIRST_FREE_Y,
true, true, true,
position -> false); position -> false,
NativeBlockChangeSet.disabled());
int x = generationCenter.getMiddleBlockX(); int x = generationCenter.getMiddleBlockX();
int z = generationCenter.getMiddleBlockZ(); int z = generationCenter.getMiddleBlockZ();
@@ -248,8 +250,9 @@ public class NativeStructureWorldgenAccessTest {
recording.world(), generationCenter(), recording.world(), generationCenter(),
(x, z) -> SURFACE_FIRST_FREE_Y, (x, z) -> SURFACE_FIRST_FREE_Y,
(x, z) -> FLOOR_FIRST_FREE_Y, (x, z) -> FLOOR_FIRST_FREE_Y,
false, false, true,
position::equals); position::equals,
NativeBlockChangeSet.disabled());
assertSame(Blocks.DIRT, access.getBlockState(position).getBlock()); assertSame(Blocks.DIRT, access.getBlockState(position).getBlock());
assertFalse(access.ensureCanWrite(position)); assertFalse(access.ensureCanWrite(position));
@@ -319,8 +322,9 @@ public class NativeStructureWorldgenAccessTest {
recording.world(), generationCenter(), recording.world(), generationCenter(),
(x, z) -> SURFACE_FIRST_FREE_Y, (x, z) -> SURFACE_FIRST_FREE_Y,
(x, z) -> FLOOR_FIRST_FREE_Y, (x, z) -> FLOOR_FIRST_FREE_Y,
false, false, true,
position -> false); position -> false,
NativeBlockChangeSet.disabled());
} }
private static ChunkPos generationCenter() { private static ChunkPos generationCenter() {
@@ -9,6 +9,7 @@ import art.arcane.iris.structure.nativegen.NativeStructureVolumeIndex;
import art.arcane.iris.generation.runtime.IrisEngine; import art.arcane.iris.generation.runtime.IrisEngine;
import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.Engine;
import art.arcane.volmlib.nativelib.terrain.structure.NativeStructureVolume; import art.arcane.volmlib.nativelib.terrain.structure.NativeStructureVolume;
import art.arcane.volmlib.nativelib.terrain.structure.StructureTerrainSettings;
import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter; import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter;
import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KList;
import net.minecraft.SharedConstants; import net.minecraft.SharedConstants;
@@ -69,7 +70,8 @@ public class NativeStructureVolumeIndexTest {
StructureStart start = swampHut(0, 0, 4, 6); StructureStart start = swampHut(0, 0, 4, 6);
BoundingBox bounds = start.getPieces().getFirst().getBoundingBox(); BoundingBox bounds = start.getPieces().getFirst().getBoundingBox();
KList<NativeStructureVolume> volumes = NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, start); KList<NativeStructureVolume> volumes = NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, start,
StructureTerrainSettings.SOURCE, (x, z) -> 63);
assertEquals(1, volumes.size()); assertEquals(1, volumes.size());
NativeStructureVolume volume = volumes.getFirst(); NativeStructureVolume volume = volumes.getFirst();
@@ -84,8 +86,10 @@ public class NativeStructureVolumeIndexTest {
@Test @Test
public void invalidStartsContributeNoVolumes() { public void invalidStartsContributeNoVolumes() {
assertNull(NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, StructureStart.INVALID_START)); assertNull(NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, StructureStart.INVALID_START,
assertNull(NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, null)); StructureTerrainSettings.SOURCE, (x, z) -> 63));
assertNull(NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, null,
StructureTerrainSettings.SOURCE, (x, z) -> 63));
} }
@Test @Test
@@ -323,7 +327,7 @@ public class NativeStructureVolumeIndexTest {
BlockingResolver coordination = new BlockingResolver(); BlockingResolver coordination = new BlockingResolver();
NativeStructureVolumeIndex.OriginResolver volumes = (engine, chunkX, chunkZ) -> NativeStructureVolumeIndex.OriginResolver volumes = (engine, chunkX, chunkZ) ->
new KList<>(new NativeStructureVolume(chunkX + ":" + chunkZ, new KList<>(new NativeStructureVolume(chunkX + ":" + chunkZ,
-256, 0, -256, 256, 255, 256)); -256, 0, -256, 256, 255, 256, null));
NativeStructureVolumeIndex index = NativeStructureVolumeIndex.forTesting((engine, chunkX, chunkZ) -> { NativeStructureVolumeIndex index = NativeStructureVolumeIndex.forTesting((engine, chunkX, chunkZ) -> {
coordination.volumesAt(engine, chunkX, chunkZ); coordination.volumesAt(engine, chunkX, chunkZ);
return volumes.volumesAt(engine, chunkX, chunkZ); return volumes.volumesAt(engine, chunkX, chunkZ);
@@ -369,7 +373,7 @@ public class NativeStructureVolumeIndexTest {
terrain.join(); terrain.join();
} }
return new KList<>(new NativeStructureVolume(chunkX + ":" + chunkZ, return new KList<>(new NativeStructureVolume(chunkX + ":" + chunkZ,
-256, 0, -256, 256, 100, 256)); -256, 0, -256, 256, 100, 256, null));
}); });
ExecutorService owner = Executors.newSingleThreadExecutor(); ExecutorService owner = Executors.newSingleThreadExecutor();
ForkJoinPool workers = new ForkJoinPool(2); ForkJoinPool workers = new ForkJoinPool(2);
@@ -544,7 +548,8 @@ public class NativeStructureVolumeIndexTest {
if (chunkX != originChunkX || chunkZ != originChunkZ) { if (chunkX != originChunkX || chunkZ != originChunkZ) {
return NativeStructureVolume.NONE; return NativeStructureVolume.NONE;
} }
return NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, swampHut(chunkX, chunkZ, 4, 6)); return NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, swampHut(chunkX, chunkZ, 4, 6),
StructureTerrainSettings.SOURCE, (x, z) -> 63);
} }
} }
@@ -596,7 +601,7 @@ public class NativeStructureVolumeIndexTest {
return new KList<>(new NativeStructureVolume( return new KList<>(new NativeStructureVolume(
chunkX + ":" + chunkZ, chunkX + ":" + chunkZ,
0, 0, 0, 0, 0, 0,
15, 255, 15)); 15, 255, 15, null));
} }
} }
private record ScopedEngine(IrisEngine engine, AtomicInteger scopes) { private record ScopedEngine(IrisEngine engine, AtomicInteger scopes) {
@@ -223,7 +223,6 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
private IrisUpdateNotifier updateNotifier; private IrisUpdateNotifier updateNotifier;
private BukkitDebugDump debugDump; private BukkitDebugDump debugDump;
private volatile SettingsHotloadWatch settingsHotloadWatch; private volatile SettingsHotloadWatch settingsHotloadWatch;
private volatile Thread serverLifecycleThread;
private volatile ServerShutdownBoundary serverShutdownBoundary; private volatile ServerShutdownBoundary serverShutdownBoundary;
public static VolmitSender getSender() { public static VolmitSender getSender() {
@@ -832,7 +831,6 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
public void addShutdownHook() { public void addShutdownHook() {
removeShutdownHook(); removeShutdownHook();
serverLifecycleThread = Thread.currentThread();
serverShutdownBoundary = INMS.isBound() ? INMS.get().createServerShutdownBoundary() : null; serverShutdownBoundary = INMS.isBound() ? INMS.get().createServerShutdownBoundary() : null;
shutdownHook = new Thread(this::runShutdownHook, "Iris-ShutdownHook"); shutdownHook = new Thread(this::runShutdownHook, "Iris-ShutdownHook");
try { try {
@@ -1164,7 +1162,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
if (generator == null) { if (generator == null) {
continue; continue;
} }
IrisToolbelt.beginWorldMaintenance(world, reason, true); IrisToolbelt.beginWorldMaintenance(world, reason);
if (!deferredShutdownGenerators.contains(generator)) { if (!deferredShutdownGenerators.contains(generator)) {
deferredShutdownGenerators.add(generator); deferredShutdownGenerators.add(generator);
} }
@@ -1176,52 +1174,32 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
if (!postStopFinisherStarted.compareAndSet(false, true)) { if (!postStopFinisherStarted.compareAndSet(false, true)) {
return; return;
} }
Thread activeServerThread = serverLifecycleThread; Thread finisher = new Thread(() -> finishRuntimeAfterServerShutdown("post-server-stop"),
if (activeServerThread == null) { "Iris-PostStop-Finisher");
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");
finisher.setDaemon(false); finisher.setDaemon(false);
finisher.start(); 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() { private void runShutdownHook() {
if (startupBoundaryRestart.get()) { if (startupBoundaryRestart.get()) {
finishDeferredRuntimeTeardown("startup-boundary-restart-hook", 30L); finishDeferredRuntimeTeardown("startup-boundary-restart-hook", 30L);
return; return;
} }
finishRuntimeAfterServerShutdown("shutdown-hook");
}
private void finishRuntimeAfterServerShutdown(String reason) {
if (!awaitServerShutdownBoundary()) { 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; return;
} }
finishDeferredRuntimeTeardown("shutdown-hook", 30L); finishDeferredRuntimeTeardown(reason, 30L);
} }
private boolean awaitServerShutdownBoundary() { private boolean awaitServerShutdownBoundary() {
ServerShutdownBoundary boundary = serverShutdownBoundary; ServerShutdownBoundary boundary = serverShutdownBoundary;
if (boundary == null) { if (boundary == null) {
return true; return false;
} }
try { try {
return boundary.await( return boundary.await(
@@ -1508,12 +1486,15 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
public int getIrisVersion() { public int getIrisVersion() {
String input = getDescription().getVersion(); String input = getDescription().getVersion();
int hyphenIndex = input.indexOf('-'); int hyphenIndex = input.indexOf('-');
if (hyphenIndex != -1) { String release = hyphenIndex < 0 ? input : input.substring(0, hyphenIndex);
String result = input.substring(0, hyphenIndex); if (!release.matches("[0-9]+(?:\\.[0-9]+)*")) {
result = result.replaceAll("\\.", ""); return -1;
return Integer.parseInt(result); }
try {
return Integer.parseInt(release.replace(".", ""));
} catch (NumberFormatException invalid) {
return -1;
} }
return -1;
} }
public int getMCVersion() { public int getMCVersion() {
@@ -81,7 +81,6 @@ import org.bukkit.FluidCollisionMode;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.World; import org.bukkit.World;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.event.inventory.InventoryType;
import org.bukkit.inventory.Inventory; import org.bukkit.inventory.Inventory;
import org.bukkit.util.Vector; import org.bukkit.util.Vector;
@@ -309,23 +308,33 @@ public class CommandStudio implements DirectorExecutor {
O<Integer> ta = new O<>(); O<Integer> ta = new O<>();
ta.set(-1); ta.set(-1);
var sender = sender(); VolmitSender sender = sender();
var player = player(); Player player = player();
var engine = engine(); Engine engine = engine();
ta.set(J.sr(() -> ta.set(J.sr(() -> {
{ if (!J.runEntity(player, () -> {
if (!player.getOpenInventory().getType().equals(InventoryType.CHEST)) { 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()); 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)); }, fast ? 5 : 35));
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_INVENTORY_NOW)); sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_INVENTORY_NOW));
@@ -896,14 +896,10 @@ public final class IrisWorldGeneratorResolver {
} }
private static String dimensionTypeKey(IrisData data, IrisDimension dimension) { private static String dimensionTypeKey(IrisData data, IrisDimension dimension) {
return IrisPlatforms.get() return dimension.dimensionTypeResourceKey(
.registries() IrisPlatforms.get().registries().generationRegistry(),
.generationRegistry() data.getDataFolder().getName()
.dimensionTypeResourceKey( );
data.getDataFolder().getName(),
dimension.getLoadKey(),
dimension.getDimensionTypeKey()
);
} }
private record LoadedHistory(GenerationHistory history, File activePack) { private record LoadedHistory(GenerationHistory history, File activePack) {
@@ -19,6 +19,10 @@
package art.arcane.iris.world.runtime; package art.arcane.iris.world.runtime;
import art.arcane.iris.api.world.IrisWorldPhase; import art.arcane.iris.api.world.IrisWorldPhase;
import art.arcane.iris.generation.mantle.CustomBlockOwnership;
import art.arcane.iris.integration.ExternalDataSVC;
import art.arcane.iris.spi.IrisServices;
import art.arcane.volmlib.nativelib.terrain.NativeBlockPoint;
import art.arcane.iris.pack.datapack.ServerConfigurator; import art.arcane.iris.pack.datapack.ServerConfigurator;
import art.arcane.iris.pack.datapack.DatapackIngestService; import art.arcane.iris.pack.datapack.DatapackIngestService;
import art.arcane.iris.world.event.IrisEngineHotloadEvent; import art.arcane.iris.world.event.IrisEngineHotloadEvent;
@@ -51,6 +55,13 @@ import org.bukkit.World;
import org.bukkit.WorldBorder; import org.bukkit.WorldBorder;
public final class BukkitEnginePlatformHooks implements EnginePlatformHooks { public final class BukkitEnginePlatformHooks implements EnginePlatformHooks {
@Override
public boolean removeCustomBlock(Engine engine, NativeBlockPoint position, CustomBlockOwnership.Entry entry) {
ExternalDataSVC service = IrisServices.getOrNull(ExternalDataSVC.class);
return service != null && service.removeGenerated(
BukkitWorldBinding.world(engine.getWorld()).getBlockAt(position.x(), position.y(), position.z()), entry);
}
@Override @Override
public CompletableFuture<Void> flushSavedTerrainCapture(Engine engine) { public CompletableFuture<Void> flushSavedTerrainCapture(Engine engine) {
return INMS.get().flushSavedTerrainCapture(BukkitWorldBinding.world(engine.getWorld())); return INMS.get().flushSavedTerrainCapture(BukkitWorldBinding.world(engine.getWorld()));
@@ -0,0 +1,46 @@
{
"name": "Flat",
"derivative": "minecraft:snowy_plains",
"vanillaDerivative": "minecraft:snowy_plains",
"layers": [
{
"minHeight": 1,
"maxHeight": 1,
"palette": [
{
"block": "minecraft:lime_wool"
}
]
},
{
"minHeight": 384,
"maxHeight": 384,
"palette": [
{
"block": "minecraft:green_concrete"
}
]
}
],
"generators": [
{
"generator": "flat",
"min": 96,
"max": 96
}
],
"decorators": [
{
"palette": [
{
"block": "craftengine:default/flower_basket",
"data": {
"variant": "ground"
}
}
],
"chance": 1,
"forcePlace": true
}
]
}
@@ -0,0 +1,39 @@
{
"name": "CraftEngine Imported Feature Fixture",
"version": 1,
"mode": {
"type": "OVERWORLD"
},
"regions": [
"flat"
],
"focusRegion": "flat",
"focus": "flat",
"landChance": 1,
"environment": "NORMAL",
"dimensionHeight": {
"min": -64,
"max": 320
},
"logicalHeight": 384,
"fluidHeight": 0,
"bedrock": false,
"rockPalette": {
"palette": [
{
"block": "minecraft:red_concrete"
}
]
},
"importedStructures": {
"disabled": [
"minecraft:"
]
},
"importedFeatures": {
"enabled": true,
"steps": [
"TOP_LAYER_MODIFICATION"
]
}
}
@@ -0,0 +1,15 @@
{
"interpolator": {
"function": "NONE",
"horizontalScale": 1
},
"seed": 310,
"composite": [
{
"seed": 310,
"style": {
"style": "FLAT"
}
}
]
}
@@ -0,0 +1,12 @@
{
"name": "Flat",
"landBiomes": [
"flat"
],
"seaBiomes": [
"flat"
],
"shoreBiomes": [
"flat"
]
}
@@ -0,0 +1,35 @@
plugins {
id 'java'
}
version = '1.0.0'
repositories {
mavenCentral()
maven { url = 'https://repo.papermc.io/repository/maven-public/' }
}
dependencies {
compileOnly files(providers.gradleProperty('irisJar').get())
compileOnly files(providers.gradleProperty('craftEngineJar').get())
compileOnly 'io.papermc.paper:paper-api:26.1.2.build.74-stable'
}
java {
toolchain.languageVersion = JavaLanguageVersion.of(25)
}
sourceSets {
main {
java.srcDirs = ['src']
resources.srcDirs = ['resources']
}
}
tasks.withType(JavaCompile).configureEach {
options.compilerArgs += '-parameters'
}
tasks.named('jar') {
archiveBaseName = 'IrisCraftEngineNativeFixture'
}
@@ -0,0 +1,12 @@
name: IrisCraftEngineNativeFixture
version: 1.0.0
main: art.arcane.iris.fixture.CraftEngineNativeFixture
api-version: '26.1.2'
folia-supported: true
depend: [Iris, CraftEngine]
commands:
iriscenative:
permission: iriscenative.run
permissions:
iriscenative.run:
default: op
@@ -0,0 +1 @@
rootProject.name = 'iris-craftengine-native-fixture'
@@ -0,0 +1,385 @@
package art.arcane.iris.fixture;
import art.arcane.iris.generation.block.IrisCustomData;
import art.arcane.iris.generation.block.TileData;
import art.arcane.iris.generation.mantle.DeferredPlacementRecords;
import art.arcane.iris.generation.mantle.CustomBlockOwnership;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.integration.ExternalDataSVC;
import art.arcane.iris.integration.Identifier;
import art.arcane.iris.platform.generation.EngineBukkitOps;
import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.world.IrisCreator;
import art.arcane.iris.world.IrisToolbelt;
import art.arcane.iris.world.storage.matter.TileWrapper;
import art.arcane.iris.world.task.J;
import art.arcane.volmlib.nativelib.terrain.NativeBlockChangeSet;
import art.arcane.volmlib.util.mantle.flag.MantleFlag;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
import art.arcane.volmlib.util.matter.Matter;
import net.momirealms.craftengine.bukkit.api.CraftEngineBlocks;
import net.momirealms.craftengine.bukkit.api.CraftEngineFurniture;
import net.momirealms.craftengine.core.util.Key;
import org.bukkit.Bukkit;
import org.bukkit.Chunk;
import org.bukkit.GameMode;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.bukkit.plugin.java.JavaPlugin;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.logging.Level;
public final class CraftEngineNativeFixture extends JavaPlugin {
private static final NamespacedKey WORLD_KEY = new NamespacedKey("iris", "ce_native_probe");
private static final NamespacedKey FEATURE_WORLD_KEY = new NamespacedKey("iris", "ce_native_feature_probe");
private static final int TEST_Y = 180;
private static final int TEST_Z = 8;
private static final String BLOCK_ID = "craftengine:default/topaz_ore";
private static final String FURNITURE_ID = "craftengine:default/flower_basket[variant=ground]";
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (!(sender instanceof Player player) || arguments.length != 1) {
sender.sendMessage("CE_FAILED Use /iriscenative prepare|pending|neighbor|loaded|features|restart from a player");
return true;
}
if (arguments[0].equals("prepare")) {
player.setGameMode(GameMode.CREATIVE);
prepare(player);
return true;
}
if (arguments[0].equals("features")) {
importedFeatures(player);
return true;
}
World world = Bukkit.getWorld(WORLD_KEY);
if (world == null) {
fail(player, new IllegalStateException("Fixture world has not been created"));
return true;
}
if (arguments[0].equals("restart")) {
loadChunks().thenCompose(ignored -> J.runRegionFuture(world, 0, 0,
() -> later(player, world, () -> restarted(player, world))))
.whenComplete((ignored, failure) -> {
if (failure != null) {
fail(player, failure);
}
});
return true;
}
J.runRegionFuture(world, 0, 0, () -> {
switch (arguments[0]) {
case "pending" -> pending(player, world);
case "neighbor" -> neighbor(player, world);
case "loaded" -> invalidateLoaded(player, world, IrisToolbelt.access(world).getEngine());
default -> throw new IllegalArgumentException("Unknown fixture operation");
}
}).whenComplete((ignored, failure) -> {
if (failure != null) {
fail(player, failure);
}
});
return true;
}
private void prepare(Player player) {
CompletableFuture.runAsync(() -> {
if (Bukkit.getWorld(WORLD_KEY) == null) {
try {
new IrisCreator().name(WORLD_KEY.toString()).dimension("ce-native-fixture")
.seed(78264193L).create();
} catch (Exception failure) {
throw new IllegalStateException("Could not create CraftEngine fixture world", failure);
}
}
}).thenCompose(ignored -> loadChunks()).thenCompose(ignored -> {
World world = Bukkit.getWorld(WORLD_KEY);
return J.runRegionFuture(world, 0, 0, () -> preparePlatform(world));
}).thenAccept(ignored -> {
World world = Bukkit.getWorld(WORLD_KEY);
if (!J.runEntity(player, () -> player.teleportAsync(new Location(world, 8.5, TEST_Y + 3, 8.5))
.whenComplete((teleported, failure) -> {
if (failure != null || !Boolean.TRUE.equals(teleported)) {
fail(player, failure == null ? new IllegalStateException("Fixture teleport failed") : failure);
return;
}
player.sendMessage("CE_READY world=" + WORLD_KEY + " folia=" + J.isFolia());
}))) {
throw new IllegalStateException("Could not schedule fixture teleport");
}
}).exceptionally(failure -> {
fail(player, failure);
return null;
});
}
private void importedFeatures(Player player) {
CompletableFuture.runAsync(() -> {
if (Bukkit.getWorld(FEATURE_WORLD_KEY) == null) {
try {
new IrisCreator().name(FEATURE_WORLD_KEY.toString()).dimension("ce-native-feature-fixture")
.seed(78264193L).create();
} catch (Exception failure) {
throw new IllegalStateException("Could not create imported feature fixture world", failure);
}
}
}).thenCompose(ignored -> {
World world = Bukkit.getWorld(FEATURE_WORLD_KEY);
return world.getChunkAtAsync(0, 0, true);
}).thenCompose(chunk -> J.runRegionFuture(chunk.getWorld(), 0, 0, () -> {
World world = chunk.getWorld();
Engine engine = IrisToolbelt.access(world).getEngine();
int snow = 0;
int pending = 0;
for (int x = 0; x < 16; x++) {
for (int z = 0; z < 16; z++) {
if (world.getBlockAt(x, 97, z).getType() == Material.SNOW) {
snow++;
}
if (engine.getMantle().getMantle().get(x, 97 - engine.getMinHeight(), z, Identifier.class) != null) {
pending++;
}
}
}
require(snow == 256, "Imported snow feature did not cover all 256 test columns: " + snow);
require(pending == 0, "Imported feature left pending furniture records: " + pending);
for (Entity entity : chunk.getEntities()) {
require(!CraftEngineFurniture.isFurniture(entity), "Imported snow feature left generated furniture");
}
if (!J.runEntity(player, () -> player.teleportAsync(new Location(world, 8.5, 101, 8.5))
.whenComplete((teleported, failure) -> {
if (failure != null || !Boolean.TRUE.equals(teleported)) {
fail(player, failure == null ? new IllegalStateException("Feature world teleport failed") : failure);
return;
}
player.sendMessage("CE_FEATURES snow=256 pending=0 furniture=0");
}))) {
throw new IllegalStateException("Could not schedule feature world teleport");
}
})).exceptionally(failure -> {
fail(player, failure);
return null;
});
}
private CompletableFuture<Void> loadChunks() {
World world = Bukkit.getWorld(WORLD_KEY);
require(world != null && IrisToolbelt.isIrisWorld(world), "Fixture must use a real Iris world");
List<CompletableFuture<Void>> chunks = new ArrayList<>(15);
for (int x = -1; x <= 3; x++) {
for (int z = -1; z <= 1; z++) {
chunks.add(world.getChunkAtAsync(x, z, true).thenCompose(chunk ->
J.runRegionFuture(world, chunk.getX(), chunk.getZ(), () -> chunk.addPluginChunkTicket(this))));
}
}
return CompletableFuture.allOf(chunks.toArray(CompletableFuture[]::new));
}
private void preparePlatform(World world) {
require(CraftEngineBlocks.byId(Key.of("default:topaz_ore")) != null, "CraftEngine default block is missing");
require(CraftEngineFurniture.byId(Key.of("default:flower_basket")) != null, "CraftEngine furniture is missing");
for (int chunkX = 0; chunkX <= 2; chunkX++) {
for (Entity entity : world.getChunkAt(chunkX, 0).getEntities()) {
if (CraftEngineFurniture.isFurniture(entity)) {
CraftEngineFurniture.remove(entity, false, false);
}
}
}
for (int x = 0; x <= 47; x++) {
for (int z = 2; z <= 14; z++) {
world.getBlockAt(x, TEST_Y - 1, z).setType(Material.STONE, false);
for (int y = TEST_Y; y <= TEST_Y + 5; y++) {
world.getBlockAt(x, y, z).setType(Material.AIR, false);
}
}
}
}
private void pending(Player player, World world) {
Engine engine = IrisToolbelt.access(world).getEngine();
Mantle<Matter> mantle = engine.getMantle().getMantle();
queue(engine, world, 2, BLOCK_ID);
queue(engine, world, 4, BLOCK_ID);
queue(engine, world, 6, BLOCK_ID);
queue(engine, world, 8, BLOCK_ID);
queue(engine, world, 10, FURNITURE_ID);
queue(engine, world, 12, FURNITURE_ID);
Block chest = world.getBlockAt(14, TEST_Y, TEST_Z);
chest.setType(Material.CHEST, false);
mantle.set(14, TEST_Y - engine.getMinHeight(), TEST_Z, new TileWrapper(TileData.getTileState(chest, false)));
world.getBlockAt(4, TEST_Y, TEST_Z).setType(Material.AIR, false);
world.getBlockAt(6, TEST_Y, TEST_Z).setType(Material.COBBLESTONE, false);
NativeBlockChangeSet changes = DeferredPlacementRecords.track(engine, bounds(world));
changes.add(8, TEST_Y, TEST_Z);
changes.add(12, TEST_Y - 1, TEST_Z);
changes.add(12, TEST_Y, TEST_Z);
changes.add(14, TEST_Y, TEST_Z);
world.getBlockAt(8, TEST_Y, TEST_Z).setType(Material.AIR, false);
world.getBlockAt(12, TEST_Y - 1, TEST_Z).setType(Material.AIR, false);
chest.setType(Material.COBBLESTONE, false);
DeferredPlacementRecords.invalidate(engine, changes);
require(record(engine, 8, Identifier.class) == null, "Native cut retained a pending custom record");
require(record(engine, 12, Identifier.class) == null, "Flatten column retained pending furniture");
require(record(engine, 14, TileWrapper.class) == null, "Native wall retained pending chest data");
require(record(engine, 2, Identifier.class) != null, "Native writes invalidated an untouched custom record");
replay(engine, world, 0);
CustomBlockOwnership ownership = record(engine, 10, CustomBlockOwnership.class);
require(record(engine, 2, Identifier.class) == null, "Custom materialization did not consume pending records");
require(CraftEngineBlocks.isCustomBlock(world.getBlockAt(2, TEST_Y, TEST_Z)), "Intact custom block was not placed");
require(world.getBlockAt(4, TEST_Y, TEST_Z).getType().isAir(), "Cut custom block was resurrected");
require(world.getBlockAt(6, TEST_Y, TEST_Z).getType() == Material.COBBLESTONE, "Custom block replaced native wall");
require(world.getBlockAt(8, TEST_Y, TEST_Z).getType().isAir(), "Invalidated custom block was resurrected");
require(chest.getType() == Material.COBBLESTONE, "Chest data replaced native wall");
later(player, world, () -> {
int intactFurniture = furnitureAt(world, 10);
require(intactFurniture == 1, "Intact furniture count is " + intactFurniture
+ "; pending=" + record(engine, 10, Identifier.class)
+ "; ownership=" + ownership
+ "; chunkEntities=" + world.getChunkAt(0, 0).getEntities().length);
require(furnitureAt(world, 12) == 0, "Flattened furniture was resurrected");
player.sendMessage("CE_PENDING intact=1 air=1 wall=1 invalidated=1 flatten=1 chest=1 furniture=1");
});
}
private void neighbor(Player player, World world) {
Engine engine = IrisToolbelt.access(world).getEngine();
queue(engine, world, 18, FURNITURE_ID);
queue(engine, world, 20, FURNITURE_ID);
queue(engine, world, 22, FURNITURE_ID);
queue(engine, world, 42, FURNITURE_ID);
replay(engine, world, 1);
replay(engine, world, 2);
world.getBlockAt(40, TEST_Y, TEST_Z).setType(Material.DIAMOND_BLOCK, false);
later(player, world, () -> {
require(furnitureAt(world, 18) == 1 && furnitureAt(world, 20) == 1
&& furnitureAt(world, 22) == 1, "Neighbor furniture did not materialize");
require(furnitureAt(world, 42) == 1 && record(engine, 42, CustomBlockOwnership.class) != null,
"Regeneration target furniture did not materialize with saved ownership");
if (!J.runEntity(player, () -> player.teleportAsync(new Location(world, 40.5, TEST_Y + 3, TEST_Z + 0.5))
.whenComplete((teleported, failure) -> {
if (failure != null || !Boolean.TRUE.equals(teleported)) {
fail(player, failure == null ? new IllegalStateException("Regeneration target teleport failed") : failure);
return;
}
player.sendMessage("CE_NEIGHBOR_READY chunk=2,0 furniture=3 target=1");
}))) {
throw new IllegalStateException("Could not schedule regeneration target teleport");
}
});
}
private void invalidateLoaded(Player player, World world, Engine engine) {
require(world.getBlockAt(40, TEST_Y, TEST_Z).getType().isAir(), "Adjacent target chunk did not regenerate");
require(furnitureAt(world, 18) == 1 && furnitureAt(world, 20) == 1
&& furnitureAt(world, 22) == 1, "Neighbor furniture did not materialize");
NativeBlockChangeSet changes = DeferredPlacementRecords.track(engine, bounds(world));
changes.add(18, TEST_Y - 1, TEST_Z);
changes.add(18, TEST_Y, TEST_Z);
changes.add(22, TEST_Y, TEST_Z);
world.getBlockAt(18, TEST_Y - 1, TEST_Z).setType(Material.AIR, false);
world.getBlockAt(18, TEST_Y, TEST_Z).setType(Material.COBBLESTONE, false);
DeferredPlacementRecords.invalidate(engine, changes);
require(CraftEngineFurniture.place(new Location(world, 22.5, TEST_Y, TEST_Z + 0.5),
Key.of("default:flower_basket"), "ground", false) != null,
"Could not place replacement furniture before scheduled cleanup");
later(player, world, () -> {
require(furnitureAt(world, 42) == 0, "Regeneration retained target chunk furniture");
require(furnitureAt(world, 18) == 0, "Native write left materialized neighbor furniture");
require(furnitureAt(world, 20) == 1, "Native write removed untouched neighbor furniture");
require(furnitureAt(world, 22) == 1, "Native cleanup removed replacement furniture or retained the old one");
require(world.getBlockAt(18, TEST_Y, TEST_Z).getType() == Material.COBBLESTONE,
"Furniture cleanup replaced the native wall");
player.sendMessage("CE_LOADED removed=1 untouched=1 replacement=1 wall=1 target=1");
});
}
private void restarted(Player player, World world) {
require(furnitureAt(world, 20) == 1, "Generated neighbor furniture did not survive restart");
require(furnitureAt(world, 10) == 1, "Untouched furniture did not survive restart");
Engine engine = IrisToolbelt.access(world).getEngine();
NativeBlockChangeSet changes = DeferredPlacementRecords.track(engine, bounds(world));
changes.add(20, TEST_Y, TEST_Z);
DeferredPlacementRecords.invalidate(engine, changes);
later(player, world, () -> {
require(furnitureAt(world, 20) == 0, "Persisted furniture ownership was not invalidated after restart");
require(furnitureAt(world, 10) == 1, "Restart cleanup removed untouched furniture");
player.sendMessage("CE_RESTART removed=1 untouched=1");
});
}
private void queue(Engine engine, World world, int x, String identifier) {
ExternalDataSVC service = IrisServices.get(ExternalDataSVC.class);
BlockData data = service.getBlockData(Identifier.fromString(identifier)).orElseThrow();
require(data instanceof IrisCustomData, "Iris did not resolve real CraftEngine content: " + identifier);
IrisCustomData custom = (IrisCustomData) data;
world.getBlockAt(x, TEST_Y, TEST_Z).setBlockData(custom.getBase(), false);
engine.getMantle().getMantle().set(x, TEST_Y - engine.getMinHeight(), TEST_Z, custom.getCustom());
}
private <T> T record(Engine engine, int x, Class<T> type) {
return engine.getMantle().getMantle().get(x, TEST_Y - engine.getMinHeight(), TEST_Z, type);
}
private void replay(Engine engine, World world, int chunkX) {
Mantle<Matter> mantle = engine.getMantle().getMantle();
for (int x = chunkX - 1; x <= chunkX + 1; x++) {
for (int z = -1; z <= 1; z++) {
require(world.isChunkLoaded(x, z), "Replay neighbor is not loaded: " + x + "," + z);
}
}
require(mantle.isLoaded(world.getChunkAt(chunkX, 0)), "Replay mantle chunk is not loaded: " + chunkX);
mantle.flag(chunkX, 0, MantleFlag.ETCHED, false);
mantle.flag(chunkX, 0, MantleFlag.TILE, false);
mantle.flag(chunkX, 0, MantleFlag.CUSTOM, false);
EngineBukkitOps.updateChunk(engine, world.getChunkAt(chunkX, 0), mantle);
}
private int furnitureAt(World world, int x) {
int count = 0;
for (Entity entity : world.getChunkAt(x >> 4, TEST_Z >> 4).getEntities()) {
Location location = entity.getLocation();
if (location.getBlockX() == x && location.getBlockY() == TEST_Y && location.getBlockZ() == TEST_Z
&& CraftEngineFurniture.getLoadedFurnitureByMetaEntity(entity) != null) {
count++;
}
}
return count;
}
private void later(Player player, World world, Runnable assertion) {
Bukkit.getRegionScheduler().runDelayed(this, new Location(world, 8, TEST_Y, TEST_Z), task -> {
try {
assertion.run();
} catch (Throwable failure) {
fail(player, failure);
}
}, 20);
}
private NativeBlockChangeSet.Bounds bounds(World world) {
return new NativeBlockChangeSet.Bounds(-1, -1, 3, 1, world.getMinHeight(), world.getMaxHeight() - world.getMinHeight());
}
private void require(boolean condition, String message) {
if (!condition) {
throw new IllegalStateException(message);
}
}
private void fail(CommandSender sender, Throwable failure) {
getLogger().log(Level.SEVERE, "CraftEngine native placement assertion failed", failure);
sender.sendMessage("CE_FAILED " + failure.getMessage());
}
}
@@ -0,0 +1,32 @@
{
"name": "Flat",
"derivative": "minecraft:plains",
"vanillaDerivative": "minecraft:plains",
"layers": [
{
"minHeight": 1,
"maxHeight": 1,
"palette": [
{
"block": "minecraft:lime_wool"
}
]
},
{
"minHeight": 384,
"maxHeight": 384,
"palette": [
{
"block": "minecraft:green_concrete"
}
]
}
],
"generators": [
{
"generator": "flat",
"min": 96,
"max": 96
}
]
}
@@ -0,0 +1,33 @@
{
"name": "CraftEngine Native Fixture",
"version": 1,
"mode": {
"type": "OVERWORLD"
},
"regions": [
"flat"
],
"focusRegion": "flat",
"focus": "flat",
"landChance": 1,
"environment": "NORMAL",
"dimensionHeight": {
"min": -64,
"max": 320
},
"logicalHeight": 384,
"fluidHeight": 0,
"bedrock": false,
"rockPalette": {
"palette": [
{
"block": "minecraft:red_concrete"
}
]
},
"importedStructures": {
"disabled": [
"minecraft:"
]
}
}
@@ -0,0 +1,15 @@
{
"interpolator": {
"function": "NONE",
"horizontalScale": 1
},
"seed": 310,
"composite": [
{
"seed": 310,
"style": {
"style": "FLAT"
}
}
]
}
@@ -0,0 +1,12 @@
{
"name": "Flat",
"landBiomes": [
"flat"
],
"seaBiomes": [
"flat"
],
"shoreBiomes": [
"flat"
]
}
@@ -0,0 +1,15 @@
import { readFile } from 'node:fs/promises'
import path from 'node:path'
export default {
name: 'iris-craftengine-native-restart',
description: 'Verify persisted generated furniture ownership after a full server restart.',
async run(context) {
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
context.expect(/^isolated=true\s*$/m.test(source), 'CraftEngine regression requires an isolated instance')
await context.step('native writes remove persisted ownership after restart', async () => {
const response = await context.command('/iriscenative restart', /CE_RESTART|CE_FAILED/, 60000)
context.expect(response.includes('CE_RESTART removed=1 untouched=1'), response)
})
}
}
@@ -0,0 +1,49 @@
import { readFile } from 'node:fs/promises'
import path from 'node:path'
export default {
name: 'iris-craftengine-native',
description: 'Verify deferred native invalidation and loaded neighbor cleanup with real Iris and CraftEngine.',
async run(context) {
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
context.expect(/^isolated=true\s*$/m.test(source), 'CraftEngine regression requires an isolated instance')
await context.step('create and enter a real Iris world with CraftEngine content', async () => {
const response = await context.command('/iriscenative prepare', /CE_READY|CE_FAILED/, 180000)
context.expect(response.includes('CE_READY'), response)
})
await context.step('native writes remove pending block, chest, and flatten furniture records', async () => {
const response = await context.command('/iriscenative pending', /CE_PENDING|CE_FAILED/, 60000)
context.expect(response.includes('CE_PENDING intact=1 air=1 wall=1 invalidated=1 flatten=1 chest=1 furniture=1'), response)
await context.waitUntil(() => {
const origin = context.bot.entity.position.floored()
const cut = context.bot.blockAt(origin.set(4, 180, 8))
const wall = context.bot.blockAt(origin.set(6, 180, 8))
const invalidated = context.bot.blockAt(origin.set(8, 180, 8))
const chest = context.bot.blockAt(origin.set(14, 180, 8))
return cut?.name === 'air' && wall?.name === 'cobblestone'
&& invalidated?.name === 'air' && chest?.name === 'cobblestone'
}, { label: 'Client observes retained native air and walls', timeoutMs: 30000, intervalMs: 100 })
})
await context.step('regeneration removes target furniture and preserves neighbor ownership', async () => {
const ready = await context.command('/iriscenative neighbor', /CE_NEIGHBOR_READY|CE_FAILED/, 30000)
context.expect(ready.includes('CE_NEIGHBOR_READY chunk=2,0 furniture=3 target=1'), ready)
const regenerated = await context.command('/iris developer regen radius=0', /Regen complete:|Regen failed:/, 90000)
context.expect(regenerated.includes('Regen complete:'), regenerated)
const response = await context.command('/iriscenative loaded', /CE_LOADED|CE_FAILED/, 30000)
context.expect(response.includes('CE_LOADED removed=1 untouched=1 replacement=1 wall=1 target=1'), response)
})
await context.step('imported snow features replace generated furniture records', async () => {
const response = await context.command('/iriscenative features', /CE_FEATURES|CE_FAILED/, 180000)
context.expect(response.includes('CE_FEATURES snow=256 pending=0 furniture=0'), response)
await context.waitUntil(() => {
const position = context.bot.entity.position.floored()
for (let x = 0; x < 16; x++) {
for (let z = 0; z < 16; z++) {
if (context.bot.blockAt(position.set(x, 97, z))?.name !== 'snow') return false
}
}
return true
}, { label: 'Client observes 256 imported snow layers', timeoutMs: 30000, intervalMs: 100 })
})
}
}
@@ -2,6 +2,7 @@ name: IrisNativeLifecycleFixture
version: 1.0.0 version: 1.0.0
main: art.arcane.iris.fixture.NativeLifecycleFixture main: art.arcane.iris.fixture.NativeLifecycleFixture
api-version: '26.1.2' api-version: '26.1.2'
folia-supported: true
depend: [Iris] depend: [Iris]
commands: commands:
irislifecycle: irislifecycle:
@@ -1,8 +1,12 @@
package art.arcane.iris.fixture; package art.arcane.iris.fixture;
import art.arcane.iris.Iris;
import art.arcane.volmlib.nativelib.NativeAdapters; import art.arcane.volmlib.nativelib.NativeAdapters;
import art.arcane.iris.world.runtime.RuntimeInjection; 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.IrisCreator;
import art.arcane.iris.world.task.J;
import art.arcane.iris.world.IrisToolbelt; import art.arcane.iris.world.IrisToolbelt;
import art.arcane.iris.world.IrisWorldStorage; import art.arcane.iris.world.IrisWorldStorage;
import art.arcane.iris.world.WorldCreatorCompat; import art.arcane.iris.world.WorldCreatorCompat;
@@ -13,6 +17,15 @@ import org.bukkit.entity.Player;
import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.Material; import org.bukkit.Material;
import org.bukkit.block.Biome; import org.bukkit.block.Biome;
import 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.NativeTerrainAccess;
import art.arcane.volmlib.nativelib.terrain.NativeStructureReader; import art.arcane.volmlib.nativelib.terrain.NativeStructureReader;
import art.arcane.volmlib.nativelib.terrain.NativeWorldRuntime; 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 WORLD_KEY = NamespacedKey.minecraft("native_lifecycle_probe");
private static final NamespacedKey TERRAIN_KEY = new NamespacedKey("iris", "native_terrain_probe"); private static final NamespacedKey TERRAIN_KEY = new NamespacedKey("iris", "native_terrain_probe");
private static final NamespacedKey COMMAND_KEY = new NamespacedKey("iris", "lifecycle_probe"); private static final 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 static final long WORLD_SEED = 78264193L;
private NativeWorldRuntime runtime; private NativeWorldRuntime runtime;
private TreasureLootChecks treasureLootChecks;
@Override @Override
public void onEnable() { public void onEnable() {
runtime = NativeAdapters.require(NativeWorldRuntime.class); runtime = NativeAdapters.require(NativeWorldRuntime.class);
treasureLootChecks = new TreasureLootChecks(this);
NativeTerrainAccess terrain = NativeAdapters.require(NativeTerrainAccess.class); NativeTerrainAccess terrain = NativeAdapters.require(NativeTerrainAccess.class);
getLogger().info("Native lifecycle ready: " + runtime.description() getLogger().info("Native lifecycle ready: " + runtime.description()
+ "; terrain provider=" + terrain.getClass().getName()); + "; terrain provider=" + terrain.getClass().getName());
@@ -56,12 +72,32 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
@Override @Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) { public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (arguments.length != 1) { if (arguments.length == 0) {
sender.sendMessage("Use /irislifecycle create|unload|status|structures|terrain-create|terrain-check|terrain-unload|lifecycle-status|lifecycle-entry"); sender.sendMessage("Use /irislifecycle create|unload|status|structures|terrain-create|terrain-check|terrain-unload|lifecycle-status|lifecycle-entry");
return true; return true;
} }
try { try {
switch (arguments[0]) { 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 "create" -> create(sender);
case "unload" -> unload(sender); case "unload" -> unload(sender);
case "structures" -> structures(sender); case "structures" -> structures(sender);
@@ -80,6 +116,177 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
return true; 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 { private void lifecycleStatus(CommandSender sender) throws Exception {
World world = Bukkit.getWorld(COMMAND_KEY); World world = Bukkit.getWorld(COMMAND_KEY);
Path storage = WorldCreatorCompat.persistentLevelRoot(COMMAND_KEY).toPath(); Path storage = WorldCreatorCompat.persistentLevelRoot(COMMAND_KEY).toPath();
@@ -282,11 +489,11 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
} }
public boolean regionized() { public boolean regionized() {
return false; return J.isFolia();
} }
public boolean primaryThread() { public boolean primaryThread() {
return Bukkit.isPrimaryThread(); return J.isFolia() ? Bukkit.isGlobalTickThread() : Bukkit.isPrimaryThread();
} }
public CompletableFuture<Void> runGlobal(Runnable task) { public CompletableFuture<Void> runGlobal(Runnable task) {
@@ -299,10 +506,10 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
result.completeExceptionally(failure); result.completeExceptionally(failure);
} }
}; };
if (Bukkit.isPrimaryThread()) { if (primaryThread()) {
wrapped.run(); wrapped.run();
} else { } else if (!J.runGlobal(wrapped)) {
Bukkit.getScheduler().runTask(this, wrapped); result.completeExceptionally(new IllegalStateException("Could not schedule fixture task"));
} }
return result; 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,61 @@
import { readFile } from 'node:fs/promises'
import path from 'node:path'
const world = 'native-terrain-support'
const air = new Set(['air', 'cave_air', 'void_air'])
export default {
name: 'iris-native-terrain-support',
description: 'Verify native terrain fitting preserves buried caves and excludes trees from lowered ground.',
async run(context) {
const bot = context.bot
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
context.expect(/^isolated=true\s*$/m.test(source), 'Requires an isolated instance')
const position = bot.entity.position.clone()
const at = (x, y, z) => bot.blockAt(position.clone().set(x, y, z))
const teleport = async (x, y, z) => {
await context.command(`/minecraft:tp @s ${x + 0.5} ${y} ${z + 0.5}`, /Teleported/i, 60000)
await context.waitUntil(() => Math.abs(bot.entity.position.x - x - 0.5) < 1
&& Math.abs(bot.entity.position.z - z - 0.5) < 1 && at(x, y, z),
{ label: 'Coordinate chunk received', timeoutMs: 90000, intervalMs: 100 })
}
await context.step('enter the seeded overworld', async () => {
if (context.options.command !== 'reuse') {
await context.command(`/iris create name=${world} type=overworld seed=1337`,
/Successfully created your world/i, 240000)
}
await context.command(`/iris tp ${world}`)
await context.waitUntil(async () => {
const snapshot = await context.observe()
const player = snapshot.players.find(entry => entry.username === bot.username)
return snapshot.worlds.some(entry => entry.id === player?.world && entry.name.endsWith(world))
}, { label: 'Entered seeded overworld', timeoutMs: 90000, intervalMs: 100 })
await context.command('/minecraft:gamemode spectator @s', /Spectator Mode/i)
await context.command('/minecraft:seed', /Seed:.*1337/)
})
await context.step('preserve caves beneath the ruined portal terrain skirt', async () => {
await teleport(995, 44, -3611)
await context.waitUntil(() => at(990, 43, -3626) && at(992, 43, -3627),
{ label: 'Cave columns received', timeoutMs: 90000, intervalMs: 100 })
for (const [x, z, bottom, top] of [[990, -3626, 33, 54], [992, -3627, 34, 55]]) {
for (let y = bottom; y <= top; y++) {
const block = at(x, y, z)
context.expect(block && air.has(block.name), 'Native terrain fitting filled a buried cave',
{ x, y, z, material: block?.name })
}
await context.command(`/execute if block ${x} 43 ${z} minecraft:cave_air run say CAVE_PRESERVED_${x}`,
new RegExp(`CAVE_PRESERVED_${x}`))
}
})
await context.step('exclude the pine whose support is removed by village grading', async () => {
await teleport(-2601, 174, -7063)
await context.waitUntil(() => at(-2601, 167, -7063),
{ label: 'Pine column received', timeoutMs: 90000, intervalMs: 100 })
const block = at(-2601, 167, -7063)
context.expect(air.has(block.name), 'Tree remains above terrain removed by native grading',
{ x: -2601, y: 167, z: -7063, material: block.name })
await context.command('/execute if block -2601 167 -7063 minecraft:air run say PINE_EXCLUDED',
/PINE_EXCLUDED/)
})
}
}
@@ -0,0 +1,20 @@
{
"name": "Flat",
"derivative": "minecraft:plains",
"vanillaDerivative": "minecraft:plains",
"layers": [
{"minHeight": 1, "maxHeight": 1, "palette": [{"block": "minecraft:lime_wool"}]},
{"minHeight": 128, "maxHeight": 128, "palette": [{"block": "minecraft:stone"}]}
],
"generators": [{"generator": "flat", "min": 32, "max": 32}],
"objects": [{
"place": ["smolshroom1"],
"chance": 1,
"density": 8,
"mode": "ORGANIC_STILT",
"rotation": {"enabled": false},
"scale": {"size": 0.75, "interpolation": "NONE"},
"translate": {"y": 6},
"stiltSettings": {"organicMaxScan": 8, "organicJitter": 0, "organicScratch": 0}
}]
}
@@ -0,0 +1,15 @@
{
"name": "Object Scaling Fixture",
"version": 1,
"mode": {"type": "OVERWORLD"},
"regions": ["flat"],
"focusRegion": "flat",
"focus": "flat",
"landChance": 1,
"environment": "NORMAL",
"dimensionHeight": {"min": 0, "max": 128},
"logicalHeight": 128,
"fluidHeight": 0,
"bedrock": false,
"caveProfile": {"enabled": false}
}
@@ -0,0 +1,5 @@
{
"seed": 310,
"interpolator": {"function": "NONE", "horizontalScale": 1},
"composite": [{"seed": 310, "style": {"style": "FLAT"}}]
}
@@ -0,0 +1,6 @@
{
"name": "Flat",
"landBiomes": ["flat"],
"seaBiomes": ["flat"],
"shoreBiomes": ["flat"]
}
@@ -0,0 +1,105 @@
import { createHash } from 'node:crypto'
import { readFile, realpath } from 'node:fs/promises'
import path from 'node:path'
export default {
name: 'iris-object-scaling',
description: 'Verify fractional mushroom scaling retains its cap and uses its stem for organic supports.',
async run(context) {
const directory = await realpath(context.server.directory)
const source = await readFile(path.join(directory, '.server-source'), 'utf8')
context.expect(/^isolated=true\s*$/m.test(source), 'Object scaling requires an isolated instance')
const pack = await realpath(path.join(directory, 'plugins', 'Iris', 'packs', 'object-scaling-fixture'))
context.expect(pack.startsWith(`${directory}${path.sep}`), 'Object scaling requires a local fixture pack')
const object = await readFile(path.join(pack, 'objects', 'smolshroom1.iob'))
const objectHash = createHash('sha256').update(object).digest('hex')
const biome = JSON.parse(await readFile(path.join(pack, 'biomes', 'flat.json'), 'utf8'))
const surfaceY = biome.generators[0].min
const originalStemY = surfaceY + biome.objects[0].translate.y
const expectedCapY = originalStemY + 1
const maximumExtension = biome.objects[0].stiltSettings.organicMaxScan
context.expect(surfaceY === 32 && expectedCapY === 39 && maximumExtension === 8,
'Expected the bounded organic support fixture', { surfaceY, expectedCapY, maximumExtension })
const world = context.options.command ?? 'object_scaling_probe'
context.expect(/^[a-z0-9_]+$/.test(world), 'Fixture world name must be a simple identifier')
await context.step('create the fractional mushroom fixture world', async () => {
const response = await context.command(`/iris create name=${world} type=object-scaling-fixture seed=1337`,
/Successfully created your world|World ready|Failed to create|Exception raised/i, 180000)
context.expect(/Successfully created your world|World ready/i.test(response), response)
await context.command(`/iris tp ${world}`)
await context.waitUntil(async () => {
const observation = await context.observe()
const player = observation.players.find(entry => entry.username === context.bot.username)
const current = observation.worlds.find(entry => entry.id === player?.world)
return current?.key === `iris:${world}` || current?.name.endsWith(world)
}, { label: 'Enter the scaling fixture world', timeoutMs: 60000, intervalMs: 100 })
await context.command('/minecraft:gamemode spectator @s')
await context.waitUntil(() => context.bot.game.gameMode === 'spectator',
{ label: 'Spectator mode', timeoutMs: 30000, intervalMs: 100 })
await context.command('/minecraft:tp @s 0.5 48 0.5', /Teleported/i, 30000)
})
const columns = []
await context.step('client sees one cap and continuous stem supports', async () => {
const position = context.bot.entity.position.clone()
await context.waitUntil(() => {
if (Math.abs(context.bot.entity.position.x - 0.5) > 1
|| Math.abs(context.bot.entity.position.z - 0.5) > 1) return false
for (const x of [-16, 0, 16, 31]) {
for (const z of [-16, 0, 16, 31]) {
for (const y of [0, 32, 63]) {
if (!context.bot.blockAt(position.set(x, y, z))) return false
}
}
}
return true
}, { label: 'Complete mushroom fixture chunks', timeoutMs: 60000, intervalMs: 100 })
for (let x = -16; x < 32; x++) {
for (let z = -16; z < 32; z++) {
const caps = []
const stems = []
for (let y = 1; y < 63; y++) {
const block = context.bot.blockAt(position.set(x, y, z))
context.expect(block !== null, 'Every sampled mushroom block must be loaded', { x, y, z })
if (block.name === 'red_mushroom_block') caps.push(y)
if (block.name === 'mushroom_stem') stems.push(y)
}
if (caps.length === 0) continue
context.expect(caps.length === 1, 'Mushroom cap was copied into its stem or support', { x, z, caps })
const capY = caps[0]
context.expect(capY === expectedCapY, 'Fractional scaling changed the expected mushroom height',
{ x, z, capY, expectedCapY })
const bottomY = stems[0]
const stemBlocks = stems.length
context.expect(bottomY >= originalStemY - maximumExtension && bottomY <= surfaceY + 1,
'Mushroom support must reach the surface within its configured extension limit',
{ x, z, capY, bottomY, surfaceY, maximumExtension, stems })
context.expect(stemBlocks === capY - bottomY
&& stems.every((y, index) => y === bottomY + index),
'Scaled mushroom must have a continuous stem from its cap to the ground', { x, z, capY, stems })
const supportY = bottomY - 1
const support = context.bot.blockAt(position.set(x, supportY, z))?.name
const above = context.bot.blockAt(position.set(x, capY + 1, z))?.name
context.expect(support === (supportY === surfaceY ? 'lime_wool' : 'stone'),
'Mushroom stem must terminate on the fixture terrain', { x, z, capY, supportY, support })
context.expect(above === 'air', 'Scaled mushroom has unexpected blocks above its cap', { x, z, capY, above })
columns.push({ x, z, surfaceY, capY, bottomY, stemBlocks,
buriedStemBlocks: Math.max(0, surfaceY - bottomY + 1) })
}
}
context.expect(columns.length >= 8, 'Expected at least eight scaled mushroom columns', { columns })
})
await context.step('server confirms the sampled stem and cap materials', async () => {
for (const [index, column] of columns.slice(0, 8).entries()) {
const { x, z, surfaceY, capY, bottomY } = column
const marker = `IRIS_SCALING_OK_${index}`
await context.command(`/minecraft:execute if block ${x} ${surfaceY + 1} ${z} minecraft:mushroom_stem`
+ ` if block ${x} ${bottomY} ${z} minecraft:mushroom_stem`
+ ` if block ${x} ${capY} ${z} minecraft:red_mushroom_block run minecraft:say ${marker}`,
new RegExp(marker), 10000)
}
})
context.report.objectScaling = { world, scale: 0.75, object: 'smolshroom1', objectHash,
surfaceY, maximumExtension, checkedColumns: columns.length,
serverConfirmedColumns: Math.min(8, columns.length), columns }
}
}
@@ -0,0 +1,82 @@
import { createHash } from 'node:crypto'
import { readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
const worlds = [
{ name: 'seed-selection-default', seed: '1337', argument: '' },
{ name: 'seed-selection-positive', seed: '78264193', argument: ' seed=78264193' },
{ name: 'seed-selection-negative', seed: '-734829104958217364', argument: ' seed=-734829104958217364' },
{ name: 'seed-selection-zero', seed: '0', argument: ' seed=0' }
]
export default {
name: 'iris-seed-selection',
description: 'Verify omitted, positive, negative, and zero creation seeds against visible terrain and a cold restart.',
async run(context) {
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
context.expect(/^isolated=true\s*$/m.test(source), 'Seed selection requires an isolated instance')
const wait = (predicate, label) => context.waitUntil(predicate, { label, timeoutMs: 60000, intervalMs: 100 })
const restart = context.options.command === 'restart-check'
const stateFile = path.join(context.server.directory, '.seed-selection.json')
const saved = restart ? JSON.parse(await readFile(stateFile, 'utf8')) : undefined
const jarHash = createHash('sha256').update(await readFile(path.join(context.server.directory, 'plugins', 'Iris.jar'))).digest('hex')
const processId = (await context.observe()).processId
if (restart) {
context.expect(saved.processId !== processId, 'Restart-check requires a cold server restart')
context.expect(saved.jarHash === jarHash, 'Restart-check requires the same Iris jar')
}
const outputs = {}
for (const world of worlds) {
await context.step(`${restart ? 'Reload' : 'Create'} ${world.name} with seed ${world.seed}`, async () => {
if (!restart) {
await context.command(`/iris create name=${world.name} type=cohesion${world.argument}`,
/Successfully created your world/i, 180000)
}
await context.command(`/iris tp ${world.name}`)
await wait(async () => {
const observation = await context.observe()
const player = observation.players.find(entry => entry.username === context.bot.username)
const current = observation.worlds.find(entry => entry.id === player?.world)
return current?.key === `iris:${world.name}` || current?.name.endsWith(world.name)
}, `Enter ${world.name}`)
await context.command('/minecraft:gamemode spectator @s')
await wait(() => context.bot.game.gameMode === 'spectator', 'Spectator mode')
await context.command('/minecraft:seed', new RegExp(`Seed:\\s*\\[?${world.seed}\\]?(?:\\s|$)`))
const x = 1024
const z = -1024
await context.command(`/minecraft:tp @s ${x + 8.5} 48 ${z + 8.5}`, /Teleported/i, 30000)
await wait(() => Math.abs(context.bot.entity.position.x - x - 8.5) < 1
&& Math.abs(context.bot.entity.position.z - z - 8.5) < 1, 'Sample teleport')
const position = context.bot.entity.position.clone()
await wait(() => context.bot.blockAt(position.clone().set(x, 0, z))
&& context.bot.blockAt(position.clone().set(x + 15, 63, z + 15)), 'Complete sample chunk')
const digest = createHash('sha256')
let stone = 0
for (let dx = 0; dx < 16; dx++) {
for (let dz = 0; dz < 16; dz++) {
for (let y = 0; y < 64; y++) {
const block = context.bot.blockAt(position.set(x + dx, y, z + dz))
context.expect(block !== null, 'Missing sampled block', { x: x + dx, y, z: z + dz })
digest.update(`${block.stateId},`)
if (block.name === 'stone') stone++
}
}
}
context.expect(stone > 1000, 'Fixture terrain was not generated', { world: world.name, stone })
const blocks = digest.digest('hex')
outputs[world.name] = { seed: world.seed, blocks }
if (restart) {
context.expect(saved.worlds[world.name].seed === world.seed, 'Seed expectation changed after restart')
context.expect(saved.worlds[world.name].blocks === blocks, 'Persisted terrain changed after restart',
{ world: world.name, before: saved.worlds[world.name].blocks, after: blocks })
}
})
}
const signatures = new Set(Object.values(outputs).map(output => output.blocks))
context.expect(signatures.size === worlds.length, 'Different requested seeds generated identical sample chunks', outputs)
context.report.seedSelection = { restart, jarHash, worlds: outputs }
if (!restart) {
await writeFile(stateFile, JSON.stringify({ processId, jarHash, worlds: outputs }))
}
}
}
@@ -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,143 @@
import { readFile, realpath } from 'node:fs/promises'
import path from 'node:path'
const nonGround = new Set(['air', 'cave_air', 'void_air', 'water', 'lava', 'nether_wart_block', 'warped_wart_block'])
const chunkOffsets = [-56, -40, -24, -8, 8, 24, 40, 56]
function isWood(block) {
return block !== null && /_(wood|hyphae|log|stem)$/.test(block.name)
}
function isGround(block) {
return block !== null && block.boundingBox === 'block' && !nonGround.has(block.name)
&& !block.name.endsWith('_leaves') && !isWood(block)
}
function positionKey(position) {
return `${position.x},${position.y},${position.z}`
}
function groundSpread(bot, base) {
const heights = []
for (const x of [-3, 0, 3]) {
for (const z of [-3, 0, 3]) {
for (let y = base.y + 6; y >= base.y - 8; y--) {
if (!isGround(bot.blockAt(base.offset(x, y - base.y, z)))) continue
heights.push(y)
break
}
}
}
return heights.length === 9 ? Math.max(...heights) - Math.min(...heights) : null
}
function inspectTrees(context) {
const bot = context.bot
const center = bot.entity.position.floored()
const woodIds = Object.values(bot.registry.blocksByName).filter(isWood).map(block => block.id)
const positions = bot.findBlocks({ matching: woodIds, point: center, maxDistance: 64, count: 20000 })
const columns = new Map()
for (const position of positions) {
const key = `${position.x},${position.z}`
if (!columns.has(key)) columns.set(key, [])
columns.get(key).push(position)
}
const trees = []
for (const column of columns.values()) {
column.sort((first, second) => first.y - second.y)
const base = column[0]
const height = column[column.length - 1].y - base.y + 1
if (column.length < 8 || height < 8 || column.length / height < 0.65) continue
if (column.some(position => position.distanceTo(center) >= 60)) continue
if (![0, 1, 2, 3].every(y => isWood(bot.blockAt(base.offset(0, y, 0))))) continue
const upperGaps = []
for (let y = base.y; y < base.y + height; y++) {
const block = bot.blockAt(base.offset(0, y - base.y, 0))
if (block === null || !isWood(block)) upperGaps.push({ y, block: block?.name ?? null })
}
const grounded = isGround(bot.blockAt(base.offset(0, -1, 0)))
const spread = groundSpread(bot, base)
trees.push({ base: { x: base.x, y: base.y, z: base.z }, woodBlocks: column.length,
height, grounded, terrainSpread: spread, upperGaps })
}
return trees
}
export default {
name: 'iris-tree-grounding',
description: 'Verify grounded spindle bases, continuous overlapping trunks, and paired pack geometry.',
async run(context) {
const directory = await realpath(context.server.directory)
const source = await readFile(path.join(directory, '.server-source'), 'utf8')
context.expect(/^isolated=true\s*$/m.test(source), 'Tree generation requires an isolated instance')
const packs = await realpath(path.join(directory, 'plugins', 'Iris', 'packs'))
context.expect(packs.startsWith(`${directory}${path.sep}`), 'Tree tests require local pack copies')
context.report.treeWorlds = []
context.report.treeGrounding = []
context.report.treeContinuity = []
for (const pack of ['overworld', 'underworld']) {
const dimension = JSON.parse(await readFile(path.join(packs, pack, 'dimensions', `${pack}.json`), 'utf8'))
context.expect(dimension.focus === 'estranged/spindlewood', 'Pack must focus on spindlewood', { pack })
await context.step(`create ${pack} spindle world`, async () => {
const world = `tree_${pack}_${Date.now().toString(36)}`
const response = await context.command(`/iris create name=${world} type=${pack} seed=78264193`,
/Successfully created your world|Failed to create/, 240000)
context.expect(response.includes('Successfully created your world'), response)
context.report.treeWorlds.push({ pack, name: world, seed: 78264193, biome: dimension.focus })
if (context.bot.game.gameMode !== 'spectator') {
await context.command('/minecraft:gamemode spectator', /spectator/i, 30000)
}
await context.waitUntil(() => context.bot.game.gameMode === 'spectator',
{ label: 'Spectator mode for terrain inspection', timeoutMs: 30000, intervalMs: 100 })
})
await context.step(`${pack} trees connect to flat and sloped terrain`, async () => {
const origin = context.bot.entity.position.floored()
const inspected = new Map()
for (const offset of [0, 80, 160]) {
await context.command(`/minecraft:tp @s ${origin.x + offset + 0.5} ${origin.y + 12} ${origin.z + 0.5}`,
/Teleported/i, 30000)
await context.waitUntil(() => {
const center = context.bot.entity.position.floored()
if (Math.abs(center.x - origin.x - offset) > 1) return false
return chunkOffsets.every(x => chunkOffsets.every(z => context.bot.blockAt(center.offset(x, 0, z)) !== null))
}, { label: 'Complete terrain neighborhood', timeoutMs: 90000, intervalMs: 250 })
const trees = inspectTrees(context)
for (const tree of trees) {
context.expect(tree.grounded, 'Generated spindle trunk base has no terrain connection', { pack, ...tree })
inspected.set(positionKey(tree.base), tree)
}
if (inspected.size >= 8 && [...inspected.values()].some(tree => tree.terrainSpread >= 2)) break
}
const trees = [...inspected.values()]
const sloped = trees.filter(tree => tree.terrainSpread >= 2)
context.expect(trees.length >= 8, 'Expected at least eight generated spindle trunk bases', { pack, trees })
context.expect(sloped.length > 0, 'Expected a grounded tree on uneven terrain', { pack, trees })
context.report.treeGrounding.push({ pack, trees: trees.length, sloped: sloped.length, samples: trees })
})
await context.step(`${pack} overlapping canopy preserves the seeded main trunk`, async () => {
await context.command('/minecraft:tp @s 0.5 88 0.5', /Teleported/i, 30000)
await context.waitUntil(() => {
const center = context.bot.entity.position.floored()
return Math.abs(center.x) < 2 && Math.abs(center.z) < 2
&& context.bot.blockAt(center.offset(-7 - center.x, 69 - center.y, 6 - center.z)) !== null
}, { label: 'Seeded trunk neighborhood', timeoutMs: 90000, intervalMs: 250 })
const center = context.bot.entity.position.floored()
const blocks = []
for (let y = 69; y <= 83; y++) {
const block = context.bot.blockAt(center.offset(-7 - center.x, y - center.y, 6 - center.z))
context.expect(isWood(block), 'Overlapping canopy interrupted the seeded main trunk',
{ pack, x: -7, y, z: 6, block: block?.name ?? null })
blocks.push({ y, block: block.name })
}
context.report.treeContinuity.push({ pack, x: -7, z: 6, blocks })
})
}
await context.step('paired packs retain matching sampled tree geometry', async () => {
const geometry = context.report.treeGrounding.map(result => result.samples.map(tree => ({
base: tree.base, woodBlocks: tree.woodBlocks, height: tree.height, terrainSpread: tree.terrainSpread
})).sort((first, second) => positionKey(first.base).localeCompare(positionKey(second.base))))
context.expect(JSON.stringify(geometry[0]) === JSON.stringify(geometry[1]),
'Paired spindle packs produced different sampled geometry', { geometry })
})
}
}
@@ -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;
}
}
+64 -16
View File
@@ -22,6 +22,7 @@ import org.gradle.api.file.FileCopyDetails
import org.gradle.api.file.FileSystemOperations import org.gradle.api.file.FileSystemOperations
import org.gradle.api.file.FileTree import org.gradle.api.file.FileTree
import org.gradle.api.tasks.compile.JavaCompile import org.gradle.api.tasks.compile.JavaCompile
import org.gradle.api.attributes.Bundling
import org.gradle.jvm.toolchain.JavaLanguageVersion import org.gradle.jvm.toolchain.JavaLanguageVersion
import javax.inject.Inject import javax.inject.Inject
@@ -38,10 +39,14 @@ buildscript {
plugins { plugins {
id 'java' 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) 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' apply plugin: 'art.arcane.volmit-packaging'
interface IrisFileOperations { interface IrisFileOperations {
@@ -56,9 +61,9 @@ apply from: file('../../gradle/volmlib-stamp.gradle')
Properties rootProperties = new Properties() Properties rootProperties = new Properties()
file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) } file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) }
String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.3.3-26.2')) String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.3.5'))
String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3')) String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3'))
String fabricLoaderVersion = providers.gradleProperty('fabricLoaderVersion').getOrElse(rootProperties.getProperty('fabricLoaderVersion', '0.19.5')) String fabricLoaderVersion = providers.gradleProperty('fabricLoaderVersion').getOrElse(irisModTarget.fabricLoader.toString())
Closure<String> irisArtifactName = { String platform, String targetVersion -> Closure<String> irisArtifactName = { String platform, String targetVersion ->
return "Iris v${project.version} [${platform}] ${targetVersion}.jar" return "Iris v${project.version} [${platform}] ${targetVersion}.jar"
} }
@@ -141,7 +146,11 @@ configurations.create('jij') {
dependencies { dependencies {
minecraft("com.mojang:minecraft:${minecraftVersion}") 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('junit:junit:4.13.2')
testImplementation('org.mockito:mockito-core:5.23.0') testImplementation('org.mockito:mockito-core:5.23.0')
testImplementation(testFixtures('art.arcane:core')) testImplementation(testFixtures('art.arcane:core'))
@@ -168,11 +177,19 @@ dependencies {
libs.fabricApi.keyMapping, libs.fabricApi.keyMapping,
libs.fabricApi.permission 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 -> fabricApi.each { Object notation ->
add('jij', 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) { compileOnly(libs.spigot) {
transitive = false transitive = false
} }
@@ -201,21 +218,28 @@ dependencies {
libs.jaxen libs.jaxen
] ]
shared.each { Object notation -> shared.each { Object notation ->
add('bundle', notation) ['bundle', 'devBundle'].each { String configuration ->
add('devBundle', notation) add(configuration, notation) {
if (group == 'art.arcane' && name == 'core') {
attributes {
attribute(Bundling.BUNDLING_ATTRIBUTE, objects.named(Bundling, Bundling.EXTERNAL))
}
}
}
}
} }
} }
tasks.named('compileJava', JavaCompile).configure { tasks.named('compileJava', JavaCompile).configure {
options.encoding = 'UTF-8' options.encoding = 'UTF-8'
options.release.set(25) options.release.set((int) irisModTarget.java)
} }
tasks.named('test').configure { tasks.named('test').configure {
} }
loom { 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 { runs {
client { client {
runDir(providers.gradleProperty('irisClientRunDir').getOrElse('run')) runDir(providers.gradleProperty('irisClientRunDir').getOrElse('run'))
@@ -270,6 +294,15 @@ Map<String, String> nestedFabricApiJars = [
'fabric-permission-api-v1' : 'fabric-permission-api-v1.jar' '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')) { FileTree staleLoaderJars = fileTree(layout.buildDirectory.dir('libs')) {
include('Iris v* [Fabric] *.jar') include('Iris v* [Fabric] *.jar')
include('iris-fabric-*.jar') include('iris-fabric-*.jar')
@@ -288,6 +321,10 @@ tasks.named('shadowJar', ShadowJar).configure {
archiveFileName.set(irisArtifactName('Fabric', "${minecraftVersion}+${fabricLoaderVersion}")) archiveFileName.set(irisArtifactName('Fabric', "${minecraftVersion}+${fabricLoaderVersion}"))
configurations = [project.configurations.named('bundle').get()] configurations = [project.configurations.named('bundle').get()]
mergeServiceFiles() 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/maven/**')
exclude('META-INF/proguard/**') exclude('META-INF/proguard/**')
exclude('META-INF/*.SF') exclude('META-INF/*.SF')
@@ -346,28 +383,39 @@ tasks.named('shadowJar', ShadowJar).configure {
} }
} }
// The thin `jar`/`remapJar` outputs carry valid fabric.mod.json metadata but bundle zero // The thin jar omits bundled dependencies. Obfuscated targets remap the complete shadow jar.
// 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.
tasks.named('jar').configure { tasks.named('jar').configure {
enabled = false enabled = false
} }
tasks.matching { it.name == 'remapJar' }.configureEach { 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 { tasks.named('assemble').configure {
dependsOn(tasks.named('shadowJar')) dependsOn(tasks.named(irisModTarget.fabricRemap ? 'remapJar' : 'shadowJar'))
} }
pluginPackaging { pluginPackaging {
artifacts { artifacts {
create('distribution') { create('distribution') {
taskName = 'shadowJar' taskName = irisModTarget.fabricRemap ? 'remapJar' : 'shadowJar'
policyName = 'Iris Fabric' policyName = 'Iris Fabric'
prunePrefixes = ['art/arcane/volmlib/'] prunePrefixes = ['art/arcane/volmlib/']
keepPrefixes = ['art/arcane/volmlib/util/matter/slices/'] keepPrefixes = ['art/arcane/volmlib/util/matter/slices/']
} }
} }
} }
apply from: file('../../gradle/modded-version-sources.gradle')
+2
View File
@@ -33,6 +33,8 @@ plugins {
rootProject.name = 'iris-fabric' rootProject.name = 'iris-fabric'
apply from: new File(settingsDir, '../../gradle/runtime-cache.settings.gradle').canonicalFile
dependencyResolutionManagement { dependencyResolutionManagement {
versionCatalogs { versionCatalogs {
libs { libs {
@@ -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));
}
}
+44 -10
View File
@@ -17,11 +17,13 @@
*/ */
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
import net.minecraftforge.gradle.shadow.net.minecraftforge.gradleutils.shared.EnhancedPlugin
import org.gradle.api.file.DeleteSpec import org.gradle.api.file.DeleteSpec
import org.gradle.api.file.FileCopyDetails import org.gradle.api.file.FileCopyDetails
import org.gradle.api.file.FileSystemOperations import org.gradle.api.file.FileSystemOperations
import org.gradle.api.file.FileTree import org.gradle.api.file.FileTree
import org.gradle.api.tasks.compile.JavaCompile import org.gradle.api.tasks.compile.JavaCompile
import org.gradle.api.attributes.Bundling
import org.gradle.jvm.toolchain.JavaLanguageVersion import org.gradle.jvm.toolchain.JavaLanguageVersion
import javax.inject.Inject import javax.inject.Inject
@@ -42,6 +44,8 @@ plugins {
alias(libs.plugins.shadow) alias(libs.plugins.shadow)
} }
apply from: file('../../gradle/mod-target.gradle')
apply plugin: 'art.arcane.volmit-packaging' apply plugin: 'art.arcane.volmit-packaging'
interface IrisFileOperations { interface IrisFileOperations {
@@ -56,9 +60,20 @@ apply from: file('../../gradle/volmlib-stamp.gradle')
Properties rootProperties = new Properties() Properties rootProperties = new Properties()
file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) } file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) }
String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.3.3-26.2')) String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.3.5'))
String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3')) String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3'))
String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse(rootProperties.getProperty('forgeVersion', '26.3-66.0.8')) String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse(irisModTarget.forge.toString())
String buildLane = gradle.extensions.extraProperties.has('irisBuildLane')
? gradle.extensions.extraProperties.get('irisBuildLane').toString() : null
if (buildLane != null) {
File laneCache = providers.gradleProperty('multiplexorSandbox').getOrElse('false').toBoolean()
? new File(gradle.gradleUserHomeDir, "caches/iris-forge/${buildLane}")
: file(".gradle/iris-lanes/${buildLane}")
plugins.withType(EnhancedPlugin).configureEach { EnhancedPlugin<?> forgePlugin ->
forgePlugin.globalCaches().set(new File(laneCache, 'forgegradle'))
}
minecraft.mavenizerOutput.set(new File(laneCache, 'mavenizer/repo'))
}
Closure<String> loaderDisplayVersion = { String loaderVersion -> Closure<String> loaderDisplayVersion = { String loaderVersion ->
String coordinatePrefix = "${minecraftVersion}-" String coordinatePrefix = "${minecraftVersion}-"
if (loaderVersion.startsWith(coordinatePrefix)) { if (loaderVersion.startsWith(coordinatePrefix)) {
@@ -158,14 +173,16 @@ configurations.testRuntimeClasspath.extendsFrom(configurations.runtimeClasspath,
} }
dependencies { dependencies {
implementation minecraft.dependency("net.minecraftforge:forge:${forgeVersion}") implementation(buildLane == null
? minecraft.dependency("net.minecraftforge:forge:${forgeVersion}")
: minecraft.dependency("iris-${buildLane}", "net.minecraftforge:forge:${forgeVersion}"))
testImplementation('junit:junit:4.13.2') testImplementation('junit:junit:4.13.2')
testImplementation('org.mockito:mockito-core:5.23.0') testImplementation('org.mockito:mockito-core:5.23.0')
testImplementation(testFixtures('art.arcane:core')) testImplementation(testFixtures('art.arcane:core'))
testImplementation(libs.spigot) { testImplementation(libs.spigot) {
transitive = false transitive = false
} }
compileOnly('org.slf4j:slf4j-api:2.0.17') compileOnly("org.slf4j:slf4j-api:${irisModTarget.slf4j}")
compileOnly(libs.spigot) { compileOnly(libs.spigot) {
transitive = false transitive = false
} }
@@ -200,14 +217,23 @@ dependencies {
libs.jaxen libs.jaxen
] ]
shared.each { Object notation -> shared.each { Object notation ->
add('bundle', notation) ['bundle', 'devBundle', 'runBundle'].each { String configuration ->
add('devBundle', notation) add(configuration, notation) {
add('runBundle', notation) if (group == 'art.arcane' && name == 'core') {
attributes {
attribute(Bundling.BUNDLING_ATTRIBUTE, objects.named(Bundling, Bundling.EXTERNAL))
}
}
}
}
} }
} }
minecraft { 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 { runs {
register('client') { register('client') {
workingDir = layout.projectDirectory.dir('run') workingDir = layout.projectDirectory.dir('run')
@@ -241,7 +267,8 @@ minecraft {
tasks.named('compileJava', JavaCompile).configure { tasks.named('compileJava', JavaCompile).configure {
options.encoding = 'UTF-8' options.encoding = 'UTF-8'
options.release.set(25) options.compilerArgs.addAll(['-Xmaxerrs', '2000'])
options.release.set((int) irisModTarget.java)
} }
tasks.named('test').configure { tasks.named('test').configure {
@@ -250,8 +277,9 @@ tasks.named('test').configure {
processResources { processResources {
inputs.property('version', irisVersion) inputs.property('version', irisVersion)
inputs.property('minecraftVersion', minecraftVersion) inputs.property('minecraftVersion', minecraftVersion)
inputs.property('loaderRange', irisModTarget.forgeRange.toString())
filesMatching('META-INF/mods.toml') { filesMatching('META-INF/mods.toml') {
expand('version': irisVersion, 'minecraftVersion': minecraftVersion) expand('version': irisVersion, 'minecraftVersion': minecraftVersion, 'loaderRange': irisModTarget.forgeRange.toString())
} }
} }
@@ -280,6 +308,10 @@ tasks.named('shadowJar', ShadowJar).configure {
configurations = [project.configurations.named('bundle').get()] configurations = [project.configurations.named('bundle').get()]
from(sourceSets.main.output) from(sourceSets.main.output)
mergeServiceFiles() 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/maven/**')
exclude('META-INF/proguard/**') exclude('META-INF/proguard/**')
exclude('META-INF/*.SF') exclude('META-INF/*.SF')
@@ -345,3 +377,5 @@ pluginPackaging {
} }
} }
} }
apply from: file('../../gradle/modded-version-sources.gradle')
+2
View File
@@ -33,6 +33,8 @@ plugins {
rootProject.name = 'iris-forge' rootProject.name = 'iris-forge'
apply from: new File(settingsDir, '../../gradle/runtime-cache.settings.gradle').canonicalFile
dependencyResolutionManagement { dependencyResolutionManagement {
versionCatalogs { versionCatalogs {
libs { libs {
@@ -1,5 +1,5 @@
modLoader = "javafml" modLoader = "javafml"
loaderVersion = "[66,67)" loaderVersion = "${loaderRange}"
license = "GPL-3.0" license = "GPL-3.0"
issueTrackerURL = "https://github.com/VolmitSoftware/Iris/issues" issueTrackerURL = "https://github.com/VolmitSoftware/Iris/issues"
@@ -16,7 +16,7 @@ logoFile = "assets/irisworldgen/icon.png"
[[dependencies.irisworldgen]] [[dependencies.irisworldgen]]
modId = "forge" modId = "forge"
mandatory = true mandatory = true
versionRange = "[66,67)" versionRange = "${loaderRange}"
ordering = "NONE" ordering = "NONE"
side = "BOTH" 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();
}
}
}
@@ -57,6 +57,8 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedChunkGenera
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeGeneratorContext; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeGeneratorContext;
import art.arcane.volmlib.nativelib.terrain.NativeWorld; import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
import art.arcane.volmlib.util.matter.Matter;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedStructureStage; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedStructureStage;
import art.arcane.iris.world.safeguard.GenerationRefusalNotice; import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
@@ -100,6 +102,7 @@ import java.util.Collection;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.OptionalLong;
import java.util.Set; import java.util.Set;
import java.util.TreeSet; import java.util.TreeSet;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@@ -477,6 +480,7 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
return existing; return existing;
} }
try { try {
applyReplacementSeed(level);
Engine created = ModdedWorldEngines.get( Engine created = ModdedWorldEngines.get(
level, level,
activePack, activePack,
@@ -511,6 +515,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) { 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. // A runtime that is stopping says nothing about the world: fail this call, let the next bind try again.
if (GenerationFailures.isShutdownFailure(error)) { if (GenerationFailures.isShutdownFailure(error)) {
@@ -743,14 +757,8 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
PackValidationRegistry.requireLoadable(packRoot); PackValidationRegistry.requireLoadable(packRoot);
packDirectory = packRoot.toFile(); packDirectory = packRoot.toFile();
} }
IrisData data = IrisData.get(packDirectory); ModdedDimensionMetadata.ConfiguredPack resolved =
IrisDimension dimension = data.getDimensionLoader().load(activeDimensionKey); ModdedDimensionMetadata.ConfiguredPack.load(packDirectory, 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));
configuredPack = resolved; configuredPack = resolved;
heightMetadata = resolved.metadata(); heightMetadata = resolved.metadata();
return resolved; return resolved;
@@ -823,12 +831,12 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
importedFeatures.invalidate(); importedFeatures.invalidate();
} }
public void updateRegeneratedChunk(int chunkX, int chunkZ) { public void updateRegeneratedChunk(Mantle<Matter> mantle, int chunkX, int chunkZ) {
ModdedChunkUpdateService service = ModdedEngineBootstrap.services().service(ModdedChunkUpdateService.class); ModdedChunkUpdateService service = ModdedEngineBootstrap.services().service(ModdedChunkUpdateService.class);
if (service == null) { if (service == null) {
throw new IllegalStateException("Iris chunk update service is unavailable during regeneration"); throw new IllegalStateException("Iris chunk update service is unavailable during regeneration");
} }
service.updateRegeneratedChunk(engine(), boundLevel(), chunkX, chunkZ); service.updateRegeneratedChunk(engine(), boundLevel(), mantle, chunkX, chunkZ);
} }
public NativeBiomeResolver regenBiomeResolver() { public NativeBiomeResolver regenBiomeResolver() {
@@ -239,50 +239,21 @@ public final class MainWorldService {
private static Path quarantineVanillaDimensions(Path worldRoot) throws IOException { private static Path quarantineVanillaDimensions(Path worldRoot) throws IOException {
Path recovery = markerFile().getParent().resolve("mainworld-recovery-" + UUID.randomUUID()); Path recovery = markerFile().getParent().resolve("mainworld-recovery-" + UUID.randomUUID());
List<Path> moved = new ArrayList<>(); List<Path> sources = new ArrayList<>();
for (String file : NativeServerWorldLayout.metadataFiles()) { for (String file : NativeServerWorldLayout.metadataFiles()) {
moveToRecovery(worldRoot, worldRoot.resolve(file), recovery, moved); sources.add(worldRoot.resolve(file));
} }
for (String folder : NativeServerWorldLayout.primaryFolders()) { 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()) { for (String folder : NativeServerWorldLayout.dimensionFolders()) {
moveToRecovery(worldRoot, worldRoot.resolve(folder), recovery, moved); sources.add(worldRoot.resolve(folder));
}
if (moved.isEmpty()) {
Files.deleteIfExists(recovery);
} }
ModdedWorldQuarantine.move(worldRoot, sources, recovery);
return 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) { private static String escape(String value) {
return value.replace(":", "\\:"); 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.biome.IrisBiomeCustom;
import art.arcane.iris.generation.terrain.IrisDimension; import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.generation.context.IrisContext; import art.arcane.iris.generation.context.IrisContext;
import art.arcane.iris.generation.locator.BiomeSearchFilter;
import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.math.RNG;
import java.io.IOException; import java.io.IOException;
@@ -300,10 +301,6 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
if (!isReady(engine)) { if (!isReady(engine)) {
throw new IllegalStateException("Iris visible biome search has no active engine runtime"); 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<>(); Set<H> candidates = new HashSet<>();
for (H biome : collectVisibleBiomeHolders()) { for (H biome : collectVisibleBiomeHolders()) {
if (query.allowed().test(biome)) { if (query.allowed().test(biome)) {
@@ -313,16 +310,86 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
if (candidates.isEmpty()) { if (candidates.isEmpty()) {
return null; 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) -> { return query.search().search(candidates, (blockX, blockZ) -> {
int quartX = blockX >> 2; int quartX = blockX >> 2;
int quartZ = blockZ >> 2; int quartZ = blockZ >> 2;
DimensionStackLayout stackLayout = stackContext.sample(quartX << 2, quartZ << 2); BiomeSearchFilter.Column column = filter.column(quartX << 2, quartZ << 2);
return blockY -> resolveVisibleBiome(engine, quartX, blockY >> 2, quartZ, return blockY -> column.admits(((blockY >> 2) << 2) - minHeight)
query.sampler(), stackLayout); ? 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, private H getNoiseBiome(Engine engine, int quartX, int quartY, int quartZ,
S sampler) { S sampler) {
if (isGuaranteedSurfaceBiome(engine, quartY)) { if (isGuaranteedSurfaceBiome(engine, quartY)) {
@@ -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( return create(
server, server,
dimensionId, new DimensionRequest(dimensionId, pack, packDimensionKey, seed),
pack, () -> transientStudioPack(pack, packDimensionKey)
packDimensionKey,
seed,
ModdedGenerationMode.TRANSIENT_STUDIO
); );
} }
static Handle restorePersistent( static PendingInjection beginInjection(
NativeModdedServer server, NativeModdedServer server,
String dimensionId, ModdedDimensionRegistryStore.PersistentDimension dimension,
String pack, ModdedGenerationHistoryStorage.ActivePack active,
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,
ModdedGenerationMode generationMode ModdedGenerationMode generationMode
) { ) {
NativeDimensionRuntime serverAccess = requireAccess(); NativeDimensionRuntime serverAccess = requireAccess();
DimensionRequest requested = new DimensionRequest(dimension.id(), dimension.pack(), dimension.dimension(), dimension.seed());
synchronized (LOCK) { synchronized (LOCK) {
String key = dimensionId; Handle present = presentHandle(server, serverAccess, requested);
RuntimePack runtimePack = switch (generationMode) { if (present != null) {
case TRANSIENT_STUDIO -> transientStudioPack(pack, packDimensionKey); return new PendingInjection(present, true);
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;
} }
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 { try {
Handle handle = inject( RuntimePack runtimePack = persistentRuntimePack(active, generationMode);
server, return new PendingInjection(createLevel(server, serverAccess, requested, runtimePack), false);
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;
} catch (Throwable e) { } catch (Throwable e) {
ModdedIrisLog.error("Iris failed to inject runtime dimension '{}' (pack={} dim={} seed={})", dimensionId, pack, packDimensionKey, seed, e); throw injectionFailure(requested, e);
throw new IllegalStateException("Iris runtime dimension injection failed for " + dimensionId, 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) { 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 { try {
return create( return register(publishLevel(server, serverAccess, pending.handle(), engine));
server,
dimensionId,
pack,
packDimensionKey,
seed,
ModdedGenerationMode.PERSISTENT_CREATE
);
} catch (Throwable e) { } catch (Throwable e) {
try { throw injectionFailure(DimensionRequest.of(pending.handle()), e);
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);
} }
} }
} }
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( public static UpdateResult stagePersistentUpdate(
NativeModdedServer server, NativeModdedServer server,
String dimensionId, String dimensionId,
String pack, String pack,
String packDimensionKey String packDimensionKey
) { ) {
ModdedDimensionCreations.requireNotCreating(dimensionId);
synchronized (LOCK) { synchronized (LOCK) {
ModdedDimensionRegistryStore.PersistentDimension previous = ModdedDimensionRegistryStore.PersistentDimension previous =
ModdedDimensionRegistryStore.get(server, dimensionId); ModdedDimensionRegistryStore.get(server, dimensionId);
@@ -313,6 +335,7 @@ public final class ModdedDimensionManager {
} }
public static boolean removePersistent(NativeModdedServer server, String dimensionId, boolean wipeStorage) { public static boolean removePersistent(NativeModdedServer server, String dimensionId, boolean wipeStorage) {
ModdedDimensionCreations.requireNotCreating(dimensionId);
ModdedDimensionRegistryStore.PersistentDimension previous = ModdedDimensionRegistryStore.PersistentDimension previous =
ModdedDimensionRegistryStore.get(server, dimensionId); ModdedDimensionRegistryStore.get(server, dimensionId);
ModdedDimensionRegistryStore.remove(server, dimensionId); ModdedDimensionRegistryStore.remove(server, dimensionId);
@@ -392,16 +415,16 @@ public final class ModdedDimensionManager {
} }
} }
private static Handle inject( private static Handle createLevel(
NativeModdedServer server, NativeModdedServer server,
NativeDimensionRuntime serverAccess, NativeDimensionRuntime serverAccess,
String dimensionId, DimensionRequest requested,
String key,
String pack,
String packDimensionKey,
long seed,
RuntimePack runtimePack RuntimePack runtimePack
) { ) {
String dimensionId = requested.dimensionId();
String pack = requested.pack();
String packDimensionKey = requested.packDimensionKey();
long seed = requested.seed();
String typeRef = runtimePack.dimensionTypeKey(); String typeRef = runtimePack.dimensionTypeKey();
ModdedForcedDatapack.requireRegisteredDimensionType(typeRef, ModdedForcedDatapack.requireRegisteredDimensionType(typeRef,
serverAccess.hasDimensionType(server, typeRef) ? Optional.of(Boolean.TRUE) : Optional.empty(), serverAccess.hasDimensionType(server, typeRef) ? Optional.of(Boolean.TRUE) : Optional.empty(),
@@ -414,35 +437,54 @@ public final class ModdedDimensionManager {
runtimePack.generationMode()); runtimePack.generationMode());
return generator; return generator;
})); }));
NativeWorld level = created.world(); try {
IrisModdedChunkGenerator generator = created.generator(); 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; boolean loadEventStarted = false;
try { try {
serverAccess.initialize(server, level); if (prepared != null) {
ModdedWorldEngines.installPrepared(level, prepared);
}
generator.bindLevel(level); generator.bindLevel(level);
Handle handle = new Handle(dimensionId, pack, packDimensionKey, seed, level, generator);
NativeWorld previous = serverAccess.publish(server, level); NativeWorld previous = serverAccess.publish(server, level);
if (previous != null) { 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"); + "': the level was registered concurrently");
} }
serverAccess.addWorldBorderListener(server, level); serverAccess.addWorldBorderListener(server, level);
loadEventStarted = true; loadEventStarted = true;
ModdedEngineBootstrap.loader().fireDynamicLevelLoad(level); ModdedEngineBootstrap.loader().fireDynamicLevelLoad(level);
return handle; return created;
} catch (Throwable error) { } catch (Throwable error) {
rollbackInjection(server, serverAccess, key, level, generator, loadEventStarted, error); rollbackInjection(server, serverAccess, created.dimensionId(), level, generator, loadEventStarted, error);
if (error instanceof RuntimeException runtimeException) { throw rethrow(created.dimensionId(), error);
throw runtimeException;
}
if (error instanceof Error fatalError) {
throw fatalError;
}
throw new IllegalStateException("Iris runtime dimension injection failed for " + 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, private static void rollbackInjection(NativeModdedServer server, NativeDimensionRuntime serverAccess,
String key, NativeWorld level, String key, NativeWorld level,
IrisModdedChunkGenerator generator, boolean loadEventStarted, IrisModdedChunkGenerator generator, boolean loadEventStarted,
@@ -511,19 +553,21 @@ public final class ModdedDimensionManager {
.normalize(); .normalize();
PackValidationRegistry.requireLoadable(packRoot); PackValidationRegistry.requireLoadable(packRoot);
IrisData data = IrisData.openDatapackCompiler(packRoot.toFile()); IrisData data = IrisData.openDatapackCompiler(packRoot.toFile());
String dimensionTypeRef;
try { try {
IrisDimension dimension = data.getDimensionLoader().load(dimensionKey, false); IrisDimension dimension = data.getDimensionLoader().load(dimensionKey, false);
if (dimension == null) { if (dimension == null) {
throw new IllegalStateException("Transient Iris Studio pack does not contain dimension '" throw new IllegalStateException("Transient Iris Studio pack does not contain dimension '"
+ dimensionKey + "'."); + dimensionKey + "'.");
} }
dimensionTypeRef = ModdedWorldgenIds.dimensionTypeRef(pack, dimension);
} finally { } finally {
data.close(); data.close();
} }
return new RuntimePack( return new RuntimePack(
packRoot, packRoot,
dimensionKey, dimensionKey,
ModdedWorldgenIds.dimensionTypeRef(pack, dimensionKey), dimensionTypeRef,
ModdedGenerationMode.TRANSIENT_STUDIO 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) { 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( public record UpdateResult(
boolean pending, boolean pending,
boolean restartRequired, boolean restartRequired,
@@ -28,6 +28,7 @@ import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.generation.terrain.IrisRegion; import art.arcane.iris.generation.terrain.IrisRegion;
import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeResourceKeys; import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeResourceKeys;
import java.io.File;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.Locale; import java.util.Locale;
import java.util.Set; 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) { 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()); 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) { private static void write(Path file, List<PersistentDimension> dimensions, List<Object> unparsed) {
JSONArray entries = new JSONArray(); JSONArray entries = new JSONArray();
for (PersistentDimension dimension : dimensions) { 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() { private ModdedDimensionStorage() {
} }
static List<String> chunkDataFolders() {
return CHUNK_DATA_FOLDERS;
}
public static File storageFolder(NativeModdedServer server, String dimension) { public static File storageFolder(NativeModdedServer server, String dimension) {
return server.dimensionFolder(dimension).toFile(); 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.structure.object.IrisObjectRotation;
import art.arcane.iris.generation.block.TileData; import art.arcane.iris.generation.block.TileData;
import art.arcane.iris.modded.api.ModdedCustomContentRegistry; 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.IrisModdedCommands;
import art.arcane.iris.modded.command.ModdedGuiHost; import art.arcane.iris.modded.command.ModdedGuiHost;
import art.arcane.iris.modded.command.ModdedObjectUndo; import art.arcane.iris.modded.command.ModdedObjectUndo;
@@ -144,6 +145,7 @@ public final class ModdedEngineBootstrap {
} }
IrisModdedCommands.openDownloadAdmission(); IrisModdedCommands.openDownloadAdmission();
ModdedStartup.prepareForStartup(); ModdedStartup.prepareForStartup();
ModdedStartup.prepareDimensionRestore(server);
IrisModdedChunkGenerator.startGenPool(); IrisModdedChunkGenerator.startGenPool();
bindWorldGenerators(server); bindWorldGenerators(server);
services().enableAll(); services().enableAll();
@@ -163,6 +165,13 @@ public final class ModdedEngineBootstrap {
finishPreviousServer("server start"); finishPreviousServer("server start");
captureInitialSpawn(server); captureInitialSpawn(server);
ModdedStartup.prepareForStartup(); ModdedStartup.prepareForStartup();
ModdedStartup.prepareDimensionRestore(server);
}
public static void levelsReady(NativeModdedServer server) {
server.refreshWorlds();
bindWorldGenerators(server);
ModdedStartup.reinjectPersistentDimensions(server);
} }
public static void serverStarted(NativeModdedServer server) { public static void serverStarted(NativeModdedServer server) {
@@ -171,8 +180,15 @@ public final class ModdedEngineBootstrap {
server.refreshWorlds(); server.refreshWorlds();
bindWorldGenerators(server); bindWorldGenerators(server);
ModdedStartup.runOnce(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); ModdedWorldCheck.serverStarted(server);
ModdedWorldgenCatalogs.capture(server);
} }
public static void levelLoaded(NativeWorld world) { public static void levelLoaded(NativeWorld world) {
@@ -203,6 +219,7 @@ public final class ModdedEngineBootstrap {
stoppedPending = true; stoppedPending = true;
Throwable failure = null; Throwable failure = null;
failure = runStopStage(failure, "pack downloads", IrisModdedCommands::shutdownDownloads); 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, "world check", () -> ModdedWorldCheck.serverStopped(stoppingServer));
failure = runStopStage(failure, "protocol", ModdedProtocolHandler::stop); failure = runStopStage(failure, "protocol", ModdedProtocolHandler::stop);
failure = runStopStage(failure, "pregenerator", ModdedPregenJob::shutdown); failure = runStopStage(failure, "pregenerator", ModdedPregenJob::shutdown);
@@ -270,6 +287,7 @@ public final class ModdedEngineBootstrap {
Throwable failure = null; Throwable failure = null;
failure = runStopStage(failure, "world engines", ModdedWorldEngines::shutdown); failure = runStopStage(failure, "world engines", ModdedWorldEngines::shutdown);
failure = runStopStage(failure, "primary world router", ModdedPrimaryWorldRouter::clear); failure = runStopStage(failure, "primary world router", ModdedPrimaryWorldRouter::clear);
failure = runStopStage(failure, "replacement entry guard", ModdedReplacementEntry::clear);
failure = runStopStage(failure, "dimension manager", ModdedDimensionManager::clear); failure = runStopStage(failure, "dimension manager", ModdedDimensionManager::clear);
ModdedScheduler scheduler = schedulerOrNull(); ModdedScheduler scheduler = schedulerOrNull();
if (scheduler != null) { if (scheduler != null) {
@@ -325,7 +343,7 @@ public final class ModdedEngineBootstrap {
spawnCaptureServer = server; spawnCaptureServer = server;
} }
private static void reconcileSpawn(NativeModdedServer server) { private static void reconcileSpawn(NativeModdedServer server, boolean replacedOverworld) {
NativeServerSpawn spawn = new NativeServerSpawn(server); NativeServerSpawn spawn = new NativeServerSpawn(server);
NativeServerSpawn.Respawn current = spawn.current(); NativeServerSpawn.Respawn current = spawn.current();
if (current == null || current.world() == null) { if (current == null || current.world() == null) {
@@ -337,7 +355,8 @@ public final class ModdedEngineBootstrap {
} }
String dimensionId = level.name(); String dimensionId = level.name();
boolean studio = dimensionId.startsWith("irisworldgen:studio_"); 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; return;
} }
int surfaceY = spawn.surfaceAtOrigin(level); int surfaceY = spawn.surfaceAtOrigin(level);
@@ -367,6 +386,9 @@ public final class ModdedEngineBootstrap {
selfTest(moddedLoader.getClass().getClassLoader()); selfTest(moddedLoader.getClass().getClassLoader());
bind(); bind();
IrisLanguage.initialize(); IrisLanguage.initialize();
if (!moddedLoader.clientEnvironment()) {
ModdedReplacementReconciler.reconcileEarly();
}
ModdedStartup.prefetchStartupDatapack(); ModdedStartup.prefetchStartupDatapack();
if (!moddedLoader.clientEnvironment()) { if (!moddedLoader.clientEnvironment()) {
MainWorldService.reconcileEarly(); MainWorldService.reconcileEarly();
@@ -324,6 +324,10 @@ public final class ModdedForcedDatapack {
false false
); );
digest.update(inputFingerprint.getBytes(StandardCharsets.UTF_8)); 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()); return HexFormat.of().formatHex(digest.digest());
} }
@@ -333,15 +337,21 @@ public final class ModdedForcedDatapack {
int packCount = 0; int packCount = 0;
KList<String> presetIds = new KList<>(); KList<String> presetIds = new KList<>();
ModdedDimensionReplacements.StagedTypes stagedDimensionTypes = ModdedDimensionReplacements.StagedTypes.create();
Path worldRoot = worldRootOrNull(); Path worldRoot = worldRootOrNull();
Set<PackSelection> legacySelections = unadoptedSelections(worldRoot); Set<PackSelection> legacySelections = unadoptedSelections(worldRoot);
List<File> packRoots = compilationPackRoots(worldRoot); List<File> packRoots = compilationPackRoots(worldRoot);
for (File pack : packRoots) { for (File pack : packRoots) {
PackStageContext context = packStageContext(pack.toPath(), legacySelections); PackStageContext context = packStageContext(pack.toPath(), legacySelections);
if (stagePack(context, fixer, stagingDirectory, seenBiomes, presetIds)) { if (stagePack(context, fixer, stagingDirectory, seenBiomes, presetIds, stagedDimensionTypes)) {
packCount++; 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); IrisDatapackCompiler.installRetainedRegistrySources(packRoots, List.of(stagingDirectory.toFile()), fixer);
writePackMeta(stagingDirectory); writePackMeta(stagingDirectory);
@@ -362,7 +372,8 @@ public final class ModdedForcedDatapack {
private static boolean stagePack(PackStageContext context, IDataFixer fixer, Path stagingDirectory, private static boolean stagePack(PackStageContext context, IDataFixer fixer, Path stagingDirectory,
Map<String, KSet<String>> seenBiomes, Map<String, KSet<String>> seenBiomes,
KList<String> presetIds) throws IOException { KList<String> presetIds,
ModdedDimensionReplacements.StagedTypes stagedDimensionTypes) throws IOException {
File sourcePack = context.packRoot().toFile(); File sourcePack = context.packRoot().toFile();
PackValidationResult validation; PackValidationResult validation;
try { try {
@@ -392,11 +403,12 @@ public final class ModdedForcedDatapack {
stagingDirectory.getParent(), PACK_FOLDER + ".pack-" + sourcePack.getName() + "-"); stagingDirectory.getParent(), PACK_FOLDER + ".pack-" + sourcePack.getName() + "-");
Map<String, KSet<String>> packBiomes = new LinkedHashMap<>(); Map<String, KSet<String>> packBiomes = new LinkedHashMap<>();
KList<String> packPresetIds = new KList<>(); KList<String> packPresetIds = new KList<>();
ModdedDimensionReplacements.StagedTypes packDimensionTypes = ModdedDimensionReplacements.StagedTypes.create();
KList<File> packFolders = new KList<>(); KList<File> packFolders = new KList<>();
packFolders.add(packStagingDirectory.toFile()); packFolders.add(packStagingDirectory.toFile());
boolean installed; boolean installed;
try { try {
installed = installPack(context, fixer, packFolders, packBiomes, packPresetIds); installed = installPack(context, fixer, packFolders, packBiomes, packPresetIds, packDimensionTypes);
} catch (Throwable installationFailure) { } catch (Throwable installationFailure) {
if (context.required()) { if (context.required()) {
throw requiredPackFailure(context, "datapack serialization", installationFailure); throw requiredPackFailure(context, "datapack serialization", installationFailure);
@@ -414,6 +426,8 @@ public final class ModdedForcedDatapack {
mergeDirectory(packStagingDirectory, stagingDirectory); mergeDirectory(packStagingDirectory, stagingDirectory);
mergeBiomes(seenBiomes, packBiomes); mergeBiomes(seenBiomes, packBiomes);
presetIds.addAll(packPresetIds); presetIds.addAll(packPresetIds);
stagedDimensionTypes.installed().putAll(packDimensionTypes.installed());
stagedDimensionTypes.retained().putAll(packDimensionTypes.retained());
return true; return true;
} finally { } finally {
try { try {
@@ -454,6 +468,8 @@ public final class ModdedForcedDatapack {
Files.createDirectories(destination); Files.createDirectories(destination);
} else if (!Files.exists(destination)) { } else if (!Files.exists(destination)) {
Files.copy(source, 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) { } else if (!Files.isRegularFile(destination) || Files.mismatch(source, destination) != -1L) {
throw new IOException("Conflicting Iris datapack output at " + destination + "."); 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, private static boolean installPack(PackStageContext context, IDataFixer fixer, KList<File> folders,
Map<String, KSet<String>> seenBiomes, Map<String, KSet<String>> seenBiomes,
KList<String> presetIds) throws IOException { KList<String> presetIds,
ModdedDimensionReplacements.StagedTypes dimensionTypes) throws IOException {
File packFolder = context.packRoot().toFile(); File packFolder = context.packRoot().toFile();
String packName = context.packName(); String packName = context.packName();
File dimensionsDirectory = new File(packFolder, "dimensions"); File dimensionsDirectory = new File(packFolder, "dimensions");
@@ -504,18 +521,30 @@ public final class ModdedForcedDatapack {
biomesForNamespace(seenBiomes, "iris"), biomesForNamespace(seenBiomes, "iris"),
context context
); );
writeDimensionType( String typeRef = context.dimensionTypeRef(dimensionKey);
folders, if (dimension.hasBorrowedDimensionType()) {
fixer, typeRef = requireBorrowedType(context, dimension, typeRef);
dimension, } else {
context.dimensionTypeRef(dimensionKey), requireScopedType(context, typeRef);
context.emitLegacyDimensionTypeAlias(), writeDimensionType(
context folders,
); fixer,
dimension,
typeRef,
context.emitLegacyDimensionTypeAlias(),
context
);
}
if (context.retainedDimensionRoot() != null) {
dimensionTypes.retained().put(context.retainedDimensionRoot(), typeRef);
}
if (context.emitPresets()) { if (context.emitPresets()) {
String presetRef = ModdedWorldgenIds.presetRef(packName, dimensionKey); dimensionTypes.installed().put(packName + ":" + dimensionKey, typeRef);
writeWorldPreset(folders, packName, dimensionKey, presetRef); if (!dimension.hasBorrowedDimensionType()) {
presetIds.add(presetRef); String presetRef = ModdedWorldgenIds.presetRef(packName, dimensionKey);
writeWorldPreset(folders, packName, dimensionKey, presetRef);
presetIds.add(presetRef);
}
} }
installedDimensions++; 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."))); + " (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, private static void writeWorldPreset(KList<File> folders, String packName, String dimensionKey,
String presetRef) throws IOException { String presetRef) throws IOException {
String dimensionRef = dimensionKey.equals(packName) ? packName : packName + ":" + dimensionKey; String dimensionRef = dimensionKey.equals(packName) ? packName : packName + ":" + dimensionKey;
@@ -789,14 +834,16 @@ public final class ModdedForcedDatapack {
Path normalizedRoot = packRoot.toAbsolutePath().normalize(); Path normalizedRoot = packRoot.toAbsolutePath().normalize();
RetainedPack retained = retainedPack(normalizedRoot); RetainedPack retained = retainedPack(normalizedRoot);
if (retained != null) { if (retained != null) {
ModdedWorldgenIds.ScopedDimension identity = ModdedWorldgenIds GenerationEpoch.DimensionContract contract = retained.epoch().dimensionContract();
.scopedDimensionType(retained.epoch().dimensionContract().dimensionTypeKey()) Optional<ModdedWorldgenIds.ScopedDimension> scoped = ModdedWorldgenIds.scopedDimensionType(contract.dimensionTypeKey());
.orElseThrow(() -> new IOException("Retained Iris dimension type key is not scoped: " if (scoped.isEmpty()) {
+ retained.epoch().dimensionContract().dimensionTypeKey())); return PackStageContext.retained(normalizedRoot, retainedPackName(retained.epoch(), normalizedRoot), retained);
if (!identity.dimension().equals(retained.epoch().dimensionContract().dimensionKey())) { }
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."); 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(); String packName = normalizedRoot.getFileName().toString();
@@ -809,6 +856,16 @@ public final class ModdedForcedDatapack {
return PackStageContext.installed(normalizedRoot, packName, legacyDimensions); 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 { private static RetainedPack retainedPack(Path packRoot) throws IOException {
Path epochRoot = packRoot.getParent(); Path epochRoot = packRoot.getParent();
Path epochsRoot = epochRoot == null ? null : epochRoot.getParent(); Path epochsRoot = epochRoot == null ? null : epochRoot.getParent();
@@ -868,7 +925,8 @@ public final class ModdedForcedDatapack {
boolean emitLegacyDimensionTypeAlias, boolean emitLegacyDimensionTypeAlias,
boolean required, boolean required,
Set<String> legacyDimensions, Set<String> legacyDimensions,
GenerationRegistryContract retainedContract GenerationRegistryContract retainedContract,
Path retainedDimensionRoot
) implements IrisCustomBiomeAliasResolver { ) implements IrisCustomBiomeAliasResolver {
private static PackStageContext installed( private static PackStageContext installed(
Path packRoot, Path packRoot,
@@ -884,6 +942,7 @@ public final class ModdedForcedDatapack {
true, true,
false, false,
Set.copyOf(legacyDimensions), Set.copyOf(legacyDimensions),
null,
null null
); );
} }
@@ -891,8 +950,9 @@ public final class ModdedForcedDatapack {
private static PackStageContext retained( private static PackStageContext retained(
Path packRoot, Path packRoot,
String packName, String packName,
GenerationEpoch epoch RetainedPack retained
) { ) {
GenerationEpoch epoch = retained.epoch();
return new PackStageContext( return new PackStageContext(
packRoot, packRoot,
packName, packName,
@@ -902,7 +962,8 @@ public final class ModdedForcedDatapack {
true, true,
true, true,
Set.of(), 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.PackFingerprints;
import art.arcane.iris.pack.PackValidationRegistry; import art.arcane.iris.pack.PackValidationRegistry;
import art.arcane.iris.pack.PackValidationResult; import art.arcane.iris.pack.PackValidationResult;
import art.arcane.iris.world.history.GenerationAdmission;
import art.arcane.iris.world.history.GenerationEpoch; import art.arcane.iris.world.history.GenerationEpoch;
import art.arcane.iris.world.history.GenerationEpochContractFactory; import art.arcane.iris.world.history.GenerationEpochContractFactory;
import art.arcane.iris.world.history.GenerationHistory; import art.arcane.iris.world.history.GenerationHistory;
@@ -28,6 +29,7 @@ import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Stream;
final class ModdedGenerationHistoryStorage { final class ModdedGenerationHistoryStorage {
private ModdedGenerationHistoryStorage() { private ModdedGenerationHistoryStorage() {
@@ -52,7 +54,7 @@ final class ModdedGenerationHistoryStorage {
ModdedGenerationHistoryStorage::requireRegistryDefinitions, ModdedGenerationHistoryStorage::requireRegistryDefinitions,
IrisSettings.get().getGenerator().getGenerationTransitionWidthBlocks() IrisSettings.get().getGenerator().getGenerationTransitionWidthBlocks()
); );
return resolveActive(history); return resolveActive(history, source);
} catch (IOException failure) { } catch (IOException failure) {
throw historyFailure(levelKey, failure); throw historyFailure(levelKey, failure);
} }
@@ -67,25 +69,85 @@ final class ModdedGenerationHistoryStorage {
) { ) {
Path dimensionRoot = dimensionRoot(server, levelKey); Path dimensionRoot = dimensionRoot(server, levelKey);
try { try {
GenerationHistory history = restoreOrAdopt( GenerationHistory history = openForRestore(dimensionRoot, selectedPack, selectedDimensionKey, worldSeed);
dimensionRoot, return resolveActive(history, installedPackSource(selectedPack));
worldSeed,
() -> {
Path source = selectedPackSource(selectedPack, selectedDimensionKey);
return new AdoptionSource(source, captureCandidate(
source,
selectedDimensionKey,
GenerationRegistryContractFactory.CustomBiomeAliasPolicy.RETAIN_LEGACY_ALIASES
));
},
ModdedGenerationHistoryStorage::requireRegistryDefinitions
);
return resolveActive(history);
} catch (IOException failure) { } catch (IOException failure) {
throw historyFailure(levelKey, 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( static ActivePack openOrAdopt(
NativeWorld level, NativeWorld level,
String selectedPack, 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(); GenerationEpoch epoch = history.activeEpoch();
Path packRoot = normalize(history.activePackRoot()); Path packRoot = normalize(history.activePackRoot());
validatePack(packRoot); validatePack(packRoot, validatedSource);
validateDimension(packRoot, epoch); validateDimension(packRoot, epoch);
return new ActivePack( return new ActivePack(
packRoot, packRoot,
@@ -183,9 +258,12 @@ final class ModdedGenerationHistoryStorage {
); );
} }
static PackValidationResult validatePack(Path packRoot) { static PackValidationResult validatePack(Path packRoot, Path validatedSource) {
PackValidationResult validation = PackValidationRegistry.getMatching( String contentFingerprint = PackFingerprints.computePackTreeFingerprint(packRoot.toFile());
packRoot, PackFingerprints.computePackTreeFingerprint(packRoot.toFile())); PackValidationResult validation = PackValidationRegistry.getMatching(packRoot, contentFingerprint);
if (validation == null && validatedSource != null) {
validation = PackValidationRegistry.publishMatchingCopy(validatedSource, packRoot, contentFingerprint);
}
if (validation == null) { if (validation == null) {
validation = ModdedStartup.validatePack(packRoot.toFile()); validation = ModdedStartup.validatePack(packRoot.toFile());
} }
@@ -246,10 +324,9 @@ final class ModdedGenerationHistoryStorage {
PlatformGenerationRegistry generationRegistry = IrisPlatforms.get() PlatformGenerationRegistry generationRegistry = IrisPlatforms.get()
.registries() .registries()
.generationRegistry(); .generationRegistry();
String dimensionTypeKey = generationRegistry.dimensionTypeResourceKey( String dimensionTypeKey = dimension.dimensionTypeResourceKey(
source.getFileName().toString(), generationRegistry,
dimension.getLoadKey(), source.getFileName().toString()
dimension.getDimensionTypeKey()
); );
GenerationEpoch.DimensionContract dimensionContract = GenerationEpochContractFactory.create( GenerationEpoch.DimensionContract dimensionContract = GenerationEpochContractFactory.create(
dimension, dimension,
@@ -273,6 +350,11 @@ final class ModdedGenerationHistoryStorage {
return normalize(ModdedWorldEngines.resolvePack(pack, dimensionKey).toPath()); 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( private static GenerationRegistryContract requireRegistryDefinitions(
Collection<GenerationRegistryContract> contracts 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( record Candidate(
String packFingerprint, String packFingerprint,
GenerationEpoch.DimensionContract dimensionContract, GenerationEpoch.DimensionContract dimensionContract,
@@ -46,6 +46,11 @@ final class ModdedGenerationRegistry implements PlatformGenerationRegistry {
); );
} }
@Override
public boolean supportsBorrowedDimensionTypes() {
return true;
}
@Override @Override
public Definition definition(String registryKey, String resourceKey) { public Definition definition(String registryKey, String resourceKey) {
return nativeRegistry.definition(registryKey, resourceKey); return nativeRegistry.definition(registryKey, resourceKey);
@@ -14,6 +14,7 @@ public final class ModdedLifecycleCallbacks {
return new NativeModdedCallbacks( return new NativeModdedCallbacks(
ModdedEngineBootstrap::serverAboutToStart, ModdedEngineBootstrap::serverAboutToStart,
ModdedEngineBootstrap::start, ModdedEngineBootstrap::start,
ModdedEngineBootstrap::levelsReady,
ModdedEngineBootstrap::serverStarted, ModdedEngineBootstrap::serverStarted,
ModdedEngineBootstrap::stop, ModdedEngineBootstrap::stop,
ModdedEngineBootstrap::stopped, ModdedEngineBootstrap::stopped,
Loaded 100 of 485 files, more files were not shown because too many files have changed in this diff. Show more