Compare commits

...
Author SHA1 Message Date
Brian Neumann-Fopiano b0860f94fc Gap-free object rotation
- Rotations off the right angles fill every target cell from the nearest source block
- Source blocks that no target cell samples still place at their rotated position
- Right-angle rotations keep the direct transform
- Markers, canopy and tile data follow their resampled source blocks
- Datapack export bakes off-angle variants with the same resampler
- The unused rotateCopy paths are removed
- Placement goldens match the resampled output
2026-10-31 19:23:25 -04:00
Brian Neumann-Fopiano 2462330787 Transition water caps
- Saved and blended columns find solid layers that cap water at the water head
- Surface search passes over a water cap to the ground below it
- A capped column keeps its fluid height at the top of the cap
- A painted cap counts only where the blended column keeps the same blocks
- Saved terrain sources report their water surface
- Displacement fluid keys come from the first fluid at or below the fluid height
- Empty fluid keys never win the fluid blend
2026-10-31 19:23:25 -04:00
Brian Neumann-Fopiano c2eca3e6bd Hydrology climate zones
- Hydrology climate splits the land into wet and dry zones with a noise style
- Dry zones keep only the dryRainfall share of rainfall and pond density
- Zone edges ease across the transition band
- Policy climateZones exempts an area from the dry zones
- Policy fluid also fills streams and rivers that flow through the area
- Bank steepness reads the steepest natural ground between the bank and the water
- Bank edges roughen with smooth noise
- Natural ground heights reach the rasterizer at full precision
- Settings reject climate values outside 0..1
- Hydrology probes read the full precision natural heights
2026-10-31 19:23:25 -04:00
Brian Neumann-Fopiano 304e66b74b Version 4.3.7
- Iris version is 4.3.7 in gradle.properties
- Root build defaults to version 4.3.7
- Fabric, Forge and NeoForge builds default to version 4.3.7
2026-10-31 19:13:08 -04:00
Brian Neumann-Fopiano f1c82e8cec Saved world on create
- /iris create saves a new world before spawn preparation
- A crash before the first autosave keeps the world's seed
2026-10-31 13:22:37 -04:00
Brian Neumann-Fopiano 9339c0869e Saved world on create
- /iris create saves a new world before spawn preparation
- A crash before the first autosave keeps the world's seed
2026-10-31 12:39:29 -04:00
Brian Neumann-Fopiano bb32ea9d25 Datapack export
- /iris datapack export compiles a pack dimension into a 26.3 worldgen datapack
- Bukkit and mod loaders share the export arguments
- Pack compiler stages lower noise, terrain, caves, biomes, decoration, objects and structures to 26.3 resources
- Every approximation reports an export limit diagnostic
- Decorators and objects follow Iris placement rules for water, slope, height and support
- Procedural objects export their pre-built variants as templates
- Jigsaw exports share the template encoder and publisher with the worldgen export
- Both platforms expose vanilla data from the server classpath
- GoldenHashEngine and the export probes share one chunk spiral scan
- Headless 26.3 probes measure export cost and likeness against Iris
2026-10-31 03:41:40 -04:00
Brian Neumann-Fopiano 7ff4292013 Datapack export fidelity fixes
- Aquatic decorators place on the sea floor in water only
- Decorator waterlogging follows the placement site
- Templates never bake waterlogged blocks
- Object blocks outside the nominal box stay in the template
- Procedural objects export their pre-built variants as content-keyed templates
- Surface objects anchor on the ocean floor heightmap
- Surface objects reject leaf, log, or fluid ground under the origin
- Surface support checks test base corners with a slope-scaled drop
- Support ground search covers the full 16 block window
- MIN_HEIGHT, MAX_HEIGHT, and stilt modes sample terrain across every allowed rotation
- Procedural tree roots extend down to the ground
- Underwater objects need water and stay below the surface
- On-water objects anchor at the fluid surface
- Unsupported coral, sea pickles, and wall fans are left out of templates
- Object placements honor slopeCondition
- Two-block plants place both halves
- Shore decorators need solid ground below
- Decorator coverage matches the Iris style density
- Patchy decorator styles export as noise threshold patches
- Palette end weights round to the nearest count
- Ores in deepslate place their deepslate variants
- Pack description tells players to keep the Default pack selected
- Aquifer floods open sea cells near the fluid height
2026-10-31 03:36:32 -04:00
Brian Neumann-Fopiano 1ed1044474 Datapack export
- /iris datapack export compiles one pack dimension into a worldgen datapack for Minecraft 26.3 and later
- Bukkit and mod loaders take the same version, mode, namespace, output, format, objects and replace arguments
- The default overworld mode replaces the Overworld of a new world with the default world type
- Preset and dimension modes write a selectable world preset or a separate dimension
- The export runs in the background and publishes the zip or folder atomically
- Pack compiler stages turn noise, terrain, caves, subterrain, biomes, surface, decoration, objects and structures into a version-neutral IR
- Capability lowering fits every field to 26.3 density function ranges
- Disjoint min and max nodes fold to the input that always wins
- The 26.3 writer emits density functions, noise settings, biomes, features, structures, presets and dimensions
- Every export carries the Iris logo as its pack.png
- Unused objects, templates and features are pruned from the output
- The pruner keeps pack.png without reading references from its bytes
- Imported pack datapacks are bundled into the export
- Every approximation reports an export limit diagnostic
- Generation cost is counted against a node budget and reported as a warning
- Cave biomes over the wall palette limit use the closest kept palette
- Jigsaw exports share the template encoder, block states, destinations and publisher with the worldgen export
- DataVersion carries the pack format minor and world data version
- Both platforms expose vanilla data from the server classpath
- GoldenHashEngine and the export probes share one chunk spiral scan
- Headless 26.3 probes measure export cost and compare column and chunk likeness against Iris
- Real-codec tests decode the exported resources with the 26.3 registries
- Paper registry tests run in isolated registry scopes
- Command help and messages cover the export in every language file
2026-10-30 22:59:04 -04:00
Brian Neumann-Fopiano b00e28e42e Modded terrain write tests
- Tests cover fresh section writes in the modded chunk generator
2026-10-30 22:50:49 -04:00
Brian Neumann-Fopiano b61d2f305b Modded structure state key
- Modded biome policy supplies the structure state key from the generator
- History worlds key structure state by the active epoch
- Studio worlds get a fresh transient key
- Tests cover the key binding and the structure state codec
2026-10-30 22:50:49 -04:00
Brian Neumann-Fopiano 618bb28591 Fixed lattice eviction race
- Lattice eviction sorts a snapshot of tile access stamps
- Concurrent tile use during eviction no longer breaks the sort
- Test covers eviction under concurrent fills
2026-10-30 22:47:20 -04:00
Brian Neumann-Fopiano 6ceda7c53b Pregen optimization pass
- Hydrology height lattice stores samples in tiles with least-recently-used eviction
- Evicted lattice cells are no longer sampled twice
- Pond rim columns go through the lattice column cache
- Window planning reads its coarse nodes from the fine lattice
- Prefetch stays inside the pregeneration area while a pregeneration runs
- Sea inlet splat compares squared distances
- Bounds sampling answers a column from region constants when every reachable biome shares the bounds
- Base terrain heights are shared through a per-column memo
- Chunk water rasters use per-chunk palettes and a larger cache
- Raster channel lookups use a 4-block segment grid
- Chunks with no water in reach return the empty raster directly
- Riffle spreads stop at cells that cannot reach the chunk
- Cave pool terrain is built only for chunks with a pool site
- Concurrent raster builds for one chunk share a single build
- Section buffers resolve air once and support bulk column reads
- Cave decoration, perfection and transition capture read bounded column windows
- Generation history claims and saved biome writes elect one writer per batch
2026-10-30 22:34:31 -04:00
Brian Neumann-Fopiano b475d20aec Merge branch 'perf-gen' into perf-review 2026-10-30 21:42:42 -04:00
Brian Neumann-Fopiano e9c18cdc08 Merge branch 'perf-raster' into perf-review 2026-10-30 21:29:13 -04:00
Brian Neumann-Fopiano b10966af47 Merge branch 'perf-dem' into perf-review 2026-10-30 21:29:13 -04:00
Brian Neumann-Fopiano 8e625d4547 Merge branch 'perf-plan' into perf-review 2026-10-30 21:29:13 -04:00
Brian Neumann-Fopiano 0d148c69b5 Shared concurrent raster builds
- Made concurrent demands for one chunk wait for a single water raster build
- Added a test that a second demand reuses the build already running
2026-10-30 21:02:36 -04:00
Brian Neumann-Fopiano 806e847e90 Bounded column rereads
- Natural cave decoration reads columns in sixteen-row windows and drops the window after each decorated zone
- Perfection reads cells directly after a spike is normalized instead of rereading the column below it
2026-10-30 21:02:23 -04:00
Brian Neumann-Fopiano ed83c6fdeb Unqueued history claim waits
- Semantic claims and saved biome writes elect one writer per batch instead of queueing every caller on the monitor
- Waiting callers park until their batch settles and wake the next queued caller when the writer leaves
- Admission tests count parked followers as queued behind the history writer
2026-10-30 20:55:54 -04:00
Brian Neumann-Fopiano b2990104d2 Sped up hydrology sampling
- Bounds sampling answers a column from its region, land or sea type, or noise-picked biome when every biome it can still become shares the group bounds
- Base terrain heights are kept in a shared per-column memo for hydrology batches, terrain3D anchors and scalar sampling
- Hydrology height batches skip columns whose base height is already known
- The per-batch base height map and its thread local are removed
- The four inferred biome streams share one context region stream
- BakedStream exposes a baked-plane lookup without falling back to the source
- Tests cover region constants, the base height memo and bit-exact heights with and without region constants
2026-10-30 20:54:55 -04:00
Brian Neumann-Fopiano def73cff96 Clipped pregen prefetch
- Limited chunk prefetch rings to the windows a bounded pregeneration still needs
- Forwarded pregeneration region bounds from the async, medieval and modded methods
- Restored unclipped prefetch when the pregeneration closes
- Added a runtime test for clipped and restored prefetch
2026-10-30 20:54:55 -04:00
Brian Neumann-Fopiano ca33273d17 Sped up inlet splat
- Compared squared distances in SeaInletPlanner.splat and kept hypot for cells inside the rounding band
2026-10-30 20:54:54 -04:00
Brian Neumann-Fopiano 8773d4b008 Tiled hydrology lattice
- Stored lattice samples in compact 32x32 tiles with least-recently-used tile eviction
- Cached sampled pond rim columns in a block-resolution tile cache
- Read window coarse nodes from the fine lattice at stride 2
- Removed the window lattice and FineLatticeSource
- Sized the fine lattice for an eleven-window span and the region lattice for a seven-region span
- Updated lattice, planner and probe harness callers for the single fine lattice
2026-10-30 20:54:41 -04:00
Brian Neumann-Fopiano 1a914a404a Sped up column scans
- Section buffers resolve air once per buffer instead of on every empty read
- Section buffer writes remember the last palette entry they resolved
- Added bulk column reads to section buffers and the update-listening hunk
- Natural cave decoration, perfection and transition geometry capture read whole columns at once
- Iris execution contexts bind through a thread slot table instead of a ThreadLocal
- Semantic capture tracks resolved cave cells in a fixed bitmap instead of a BitSet
- Added tests for bulk column reads and for contexts when threads outnumber the slot table
2026-10-30 20:25:50 -04:00
Brian Neumann-Fopiano 1899de7f3d Deferred cave pool terrain
- Opened the cave pool terrain view only for chunks that draw a pool site
- Added a test that chunks without a pool site never open their caves
2026-10-30 20:09:02 -04:00
Brian Neumann-Fopiano 3c39092cc7 Pruned riffle distance spreads
- Stopped riffle distance spreads at cells that cannot reach the chunk
2026-10-30 20:09:02 -04:00
Brian Neumann-Fopiano 0c7adc5da4 Indexed raster channel lookups
- Indexed reach segments into a channel grid around each rasterized chunk
- Skipped sea inlet column checks outside each inlet's bounds
- Returned the empty raster for chunks with no water features in reach
- Added a test that covered channel lookups match the full segment scan
2026-10-30 20:09:02 -04:00
Brian Neumann-Fopiano a879985875 Cached more water rasters
- Stored raster tiers fluids and feature ids as per-chunk palettes
- Derived cave ceilings from terrain heights and water heads
- Raised the water raster cache from 1024 to 8192 chunks
2026-10-30 20:09:02 -04:00
Brian Neumann-Fopiano c700138b66 Native provider loading
- Native provider classes load from the provider jars before the plugin class loader
- Another plugin bundling VolmLib natives no longer supplies Iris native capabilities on Spigot
- Tests cover provider jar classes taking precedence over the plugin loader
2026-10-29 01:49:27 -04:00
Brian Neumann-Fopiano 21ab5baf27 Fixed Spigot agent attach
- Iris restores the JVM SIGQUIT handler when the server console replaced it
- The Java agent attaches on Spigot servers with a terminal console
- Tests cover restoring the handler after a console resets it
2026-10-29 01:09:16 -04:00
Brian Neumann-Fopiano 58ab3fcbbe Smaller jar libraries
- LWJGL downloads through the server library loaders
- The VolmLib stamp records a commit only for its own Git repository
2026-10-28 22:58:34 -04:00
Brian Neumann-Fopiano b6e9f8a14d Fixed VolmLib commit stamp
- The VolmLib stamp records a commit only when the checkout is its own Git repository
- A VolmLib copy inside another repository stamps an unknown commit
2026-10-28 22:58:20 -04:00
Brian Neumann-Fopiano 0d76d81dab Downloaded LWJGL libraries
- LWJGL is no longer shaded into the Bukkit jar
- plugin.yml lists the LWJGL jars and Linux natives as Spigot libraries
- The Paper loader resolves the same plugin.yml list through the Maven Central mirror
- The packed payload no longer reserves the LWJGL natives prefix
- Tests cover reading the library list from the plugin jar
2026-10-28 21:55:09 -04:00
Brian Neumann-Fopiano aec8f5be8e Updated Bukkit purity allowlist
- Jigsaw connector tools are included in the Bukkit allowlist
- Jigsaw floor painting is included in the Bukkit allowlist
- MantleWriter no longer needs a Bukkit allowance
- The removed IrisPack class no longer has an allowance
2026-10-28 21:31:46 -04:00
Brian Neumann-Fopiano 0f9c045524 Merge remote-tracking branch 'origin/master' 2026-10-28 21:30:18 -04:00
Brian Fopiano 1d4f95829b Fixed entity spawn deadlock
- Rise-enabled spawning prepares its starting position inline on the primary thread
- Asynchronous callers keep scheduled preparation
2026-10-28 21:27:01 -04:00
Brian Neumann-Fopiano ce4771b609 Version 4.3.6
- Iris version is 4.3.6 in gradle.properties
- Root build defaults to version 4.3.6
- Fabric, Forge and NeoForge builds default to version 4.3.6
2026-10-28 18:41:17 -04:00
Brian Neumann-Fopiano 7d73c4d150 Jigsaw junctions and studio tools
- Rotatable jigsaw candidates try their rotations in random order
- Candidate connectors are tried by priority within each rotation
- Planar candidates must agree with every occupied neighbor socket
- Planar assembly falls back to source-only matching before it fails
- Connectors whose branch placed nothing stay open
- Spatial default kit covers all 24 Y-rotation topologies
- Planar start pool includes Blank at weight 1
- Vanilla spatial projects omit Iris themes and placement caps
- Vanilla spatial projects use max depth 4
- 1.21.1 export derives the vertical distance from the structure
- Workcell floors map the active variant's connectors
- Floor froglights mark up, down and up-down connectors
- Connector Tool places, moves, re-orients and removes spatial connectors
- Connector Tool re-seats planar face-center sockets
- Main menu pages workcells five at a time
- Spatial variants can be deleted while another variant remains
- Tool sticks act on the variant they were made for
- Destructive tool confirmation needs two separate clicks
- Pistons cannot extend into protected cells
- Autosave capture failures back off and notify the owner
- Autosave plays the save bell
- Hidden-connector air final states are stored as air
- Reset Connector Blocks clears unsaved markers
- Folia logout releases reset and resize reservations
- Spatial workcell rename relabels its variant
- Workcell teleports land inside the focused workcell
- Replacement layouts keep rendering and hydration working
- Container preview probes stay out of chat
- Non-owners can run commands that are not recognized as mutating
- Spatial resize or rotation past the workcell volume limit is refused
- Tests cover default kit assembly, connector placement, floor glyphs and menu paging
2026-10-28 17:14:14 -04:00
Brian Neumann-Fopiano b3579e623a Added Studio hotload command
- /iris studio hotload and its refresh alias reload the open Studio pack on Bukkit
- Bukkit hotload refuses while Studio entry, pause or close is pending
- Modded servers register the same hotload and refresh commands
- Modded hotload reports busy while that Studio is already hotloading
- Hotload messages and help entries are localized in every language file
- Bukkit artifact verification reads the jar task output
- Multiplexor takes the Bukkit artifact from the packedJar task
- BukkitTestServer shares one-time registry setup across tests
- Tests and a gameplay scenario cover both hotload commands
2026-10-28 13:32:33 -04:00
Brian Neumann-Fopiano 300ecbfc8b Fixed container memory pressure
- Container pressure excludes committed heap the JVM is not using
- Reclaim, pregen gating and status text use the adjusted figure
- Tests cover the committed heap subtraction
2026-10-28 13:15:35 -04:00
Michael 0c760d9256 Fix main thread deadlock when spawning rise-out-of-ground entities
IrisEntity.spawn waited on J.sfut(...).get() to move the spawn point below
ground. J.sfut schedules the task for the next main thread tick, so when spawn
already runs on the main thread (ambient spawning in WorldEntitySpawner) the
future can never complete and the server stops ticking. Run the check inline
when already on the main thread.
2026-10-28 03:56:54 -05:00
Brian Neumann-Fopiano 3dc958edcf Stabilized shutdown and Studio
- Shutdown protection initializes before enable and drains inline when retention fails
- Cleanup preserves class loader retention and closes native providers in order
- Error reports retain context and handle empty stack traces
- Studio maps retain partial coverage and suspend hidden views
- Hydrology previews refresh when plans and rasters complete
- Terrain scratch hashing spreads lattice samples across cache slots
- Plugin and probe artifacts include LWJGL libraries and native resources
- Regression tests cover shutdown, error reporting, map progress, and terrain scratch
2026-10-26 23:29:21 -04:00
SwiftSwamp f64a7c6753 Fixed wandering trader spawning
- World injection attaches vanilla event spawners for NORMAL dimension environments
- Covers worlds loaded at server start, Studio worlds and Folia worlds
- Binding test covers overworld and nether environments
2026-10-26 18:05:45 -06:00
Brian Neumann-Fopiano 7756598057 Integrated hydrology updates
- Riverbed generation includes upstream shutdown fixes
2026-10-26 18:02:26 -04:00
Brian Neumann-Fopiano 75837d757b Improved riverbed generation
- Pond levels follow intersecting lower channels
- Pond sampling covers requested flood margins
- Water steps align with sea level
- River crests grade into surrounding beds
- Riverbeds include varied cross sections and finer noise
- Submerged soils use stable block types
- Hydrology probes inspect wet bed geometry
2026-10-26 18:01:26 -04:00
SwiftSwamp 2690e0878b Fixed shutdown teardown and shutdown staging wait
- Server stop defers teardown only while the plugin class loader close is held
- Shutdown with no Iris world loaded tears down during plugin disable
- Pre-unload hook drains generators inline when the class loader close cannot be held
- INMSBinding reports whether the class loader close is held
- Shutdown tests cover the held and unheld pre-unload paths
- Shutdown waits for an in-progress world replacement to finish staging
- Replacements started after shutdown begins are refused
- Admission tests cover the shutdown wait and the refusal
2026-10-26 09:26:30 -06:00
Brian Neumann-Fopiano 494cdb1a5c Refine hydrology and Studio
- Add dimension-wide hydrology density controls.
- Validate pond containment and shorelines at block resolution.
- Prioritize requested water plans and deduplicate prefetch work.
- Keep completed water previews visible while details load.
- Add pregeneration map navigation and Studio theme controls.
- Reject pack mutations during generation-registry capture.
2026-10-26 00:08:25 -04:00
Brian Neumann-Fopiano 1d182769d8 Merge object placement
- Integrate PR #1255 placement lifecycle modules.
- Preserve dry-anchor rejection for water-surface objects.
- Verify the merged placement guards and output reference.
2026-10-25 23:46:48 -04:00
Brian Neumann-Fopiano 584e8758a7 Update Studio previews
- Merge the eight additional commits from PR #1254.
- Preserve closure guards while refreshing object and hidden 3D views.
- Stop hidden crossfade timers and verify frame retention.
2026-10-25 17:25:18 -04:00
piexel 3766a40256 Well.. not for now at least let me just remove that- 2026-10-25 22:21:33 +01:00
piexel b7499d23f2 Sample terrain height for ERODE_STILT 2026-10-25 22:21:33 +01:00
piexel 46d458e6e4 Rewrite object placement as resolved lifecycle modules 2026-10-25 22:21:33 +01:00
Brian Neumann-Fopiano 9f49d29e2b Integrate Iris Studio
- Merge the unified Studio interface and command entry points.
- Adapt water views and provisional sampling to basin hydrology.
- Restore diagnostic estimates and the biome water overlay.
- Fix render cancellation, preview closure, and platform position callbacks.
- Fall back to software rendering when native bindings are unavailable.
- Cover renderer, lifecycle, and scheduler regressions.
2026-10-25 16:56:49 -04:00
piexel e3bc27d50c Keep hidden 3D views from covering the open view
A 3D view asked for a frame while its card was hidden, for example when
the sidebar resized, showed its GPU window again. AWT maps a shown
canvas even under a hidden parent, so the window covered the open view
in black. Frames only go into the window while the view is on screen.

Model used: Opus 5.5
2026-10-25 21:49:03 +01:00
piexel 6900f270ee Shorten the palette crossfade to 70 ms
The 150 ms fade made switching feel sluggish.

Model used: Opus 5.5
2026-10-25 21:42:44 +01:00
Brian Neumann-Fopiano db0f98d103 Basin hydrology
- The route-and-reject hydrology system and its probes are removed
- Hydrology is configured under dimension hydrology and per biome, region and dimension hydrologyPolicy
- riverPolicy and the old hydrology config types are gone
- Grid kernels cover priority flood, D8 routing, flow accumulation, basin extraction, breaching and stream burning
- A basin spills at its pour point and an interior bump can never be the spill
- Region plans cover 4096-block regions on a 64-block grid for major rivers and lakes
- Window plans cover 512-block windows on an 8-block grid for reaches and ponds
- Plans are cached in memory, persisted per world under iris/hydrology and keyed by pack, seed, world height and settings
- Only the main runtime of an engine persists plans and stores swap on hotload and default runtime change
- Demand rasters outrank prefetch and stopping pregen withdraws its queued prefetch
- Ponds come from natural basins, seeded cups and chained cups with strictly falling levels
- A pond level never exceeds its rim plus maxFill or the end grade of any inflow
- Pond outlet loops are opened at their lowest pond
- Reaches split at junctions, pond outlets and 256-block grid lines
- Reaches meander, fit the valley, grade to a running minimum and smooth with Chaikin passes
- Drops of two blocks or more get a lip and one-block corner steps become dry rim ledges
- Cascades and waterfalls write falling fluid from the mantle at level eight
- Inflows into region lakes end at the lake level
- River mouths and water beside the sea sit at the sea surface
- The per-chunk raster gives terrain height, water head, role, tier, fluid, cave ceiling and shore distance
- WaterRole codes are stable and exposed to packs through the WATER_ROLE engine stream
- WATER_HEAD replaces the old river engine streams and an unknown stream name fails the pack load
- Banks take SMOOTH, LINEAR, CONCAVE, TERRACED or CLIFF profiles
- Containment berms grow as tall as the water needs
- Columns within two blocks of water stay solid and keep no Terrain3D overhang
- Seam dams hold water where new chunks meet chunks generated earlier
- The transition blend skips hydrology-owned columns
- Caves stay at least two blocks below river and pond beds
- The painter is the only writer of still water
- Objects cannot open water, falls or bank ground and solid objects may stand in water
- On-water objects need fluid at their anchor column
- Floating child islands stay out of channels
- Sea inlets run inland from cliff coasts at sea level and end in a chamber pool
- Inlet passages are sealed against carvers, subterrain features and the post pass
- Cave pools settle into carved lows, keep one fluid and stay out of inlet space
- Pool and inlet cells use their own cave actions in mantle data
- Water vegetation lists sit on tiers, ponds and hydrologyPolicy with SEA_FLOOR, SEA_SURFACE and SHORE_LINE parts
- Pack validation compiles hydrology settings and rejects empty or water-holding palettes
- Chunk generation fails closed when its 3x3 water rasters are not planned
- /iris find covers stream, river, major river, cascade, waterfall, mouth, pond, lake, dam, sea inlet and cave pool
- Generation history records the new feature types and drops old hydrology entries
- The studio map draws water from rasters up close and from plans when zoomed out
- Pregen prepares the current region and one region ahead
- hydrologyProbe, riverTransectProbe, hydrologyBlockProbe, seaInletProbe and cavePoolProbe report containment, rises, seams, tick conversion and missing surface
- Test forks run with a 2 GB heap, a 60-minute timeout and the Mockito agent loaded explicitly
- Generation with hydrology off is unchanged
2026-10-25 15:28:26 -04:00
piexel 547c52540a Crossfade between palettes instead of flashing empty
Switching source kept nothing on screen until the new one drew. The old
frame now stays until the first new frame lands, then fades out over it
in 150 ms.

Model used: Claude Opus 5.5
2026-10-25 20:06:30 +01:00
piexel 806a33976a Stop palette switches flashing a magnified texture
Switching palettes dropped the last frame's timing, so the first frame
fell back to one sample per ~29 pixels and drew a texture pixel in each
square. The first frame is now sized from the previous source's speed,
and coarse frames use block colours instead of texture pixels.

Model used: Claude Opus 5.5
2026-10-25 19:58:08 +01:00
piexel 065c6fed1d Draw small decoration tops in palette previews
Icons now show each box's top above its front, half as deep and lit as
Minecraft lights each side, so sea pickles, candles and eggs show caps.

Model used: Claude Opus 5.5
2026-10-25 19:39:01 +01:00
piexel 360f38a819 Show a newly picked object without moving the cursor
A frame asked for while the last one was being taken, like the one that
ends a load, was drawn and swapped before the earlier frame's swap, so
the window kept the old object until something else redrew it.

Model used: Claude Opus 5.5
2026-10-25 19:39:01 +01:00
piexel 5204d94ebe Draw small decorations from the front in palette previews
- Sea pickles, candles, lanterns, turtle eggs and potted plants show their shape instead of their whole unfolded texture
- Flat decorations like pressure plates, rails and lily pads keep their top view

Model used: Claude Opus 5.5
2026-10-25 19:22:02 +01:00
piexel 48bfa7d15b Call the cave biome view Cave land again
- The map's cave view shows the 2D cave biome stream, not the biomes caves get per block, so it goes back to v4's name

Model used: Claude Opus 5.5
2026-10-25 19:22:02 +01:00
piexel 2acfac36fc Show 3D frames only once panels over them are painted
- A frame drawn into a 3D view's window waits until Swing has painted what lies behind the floating panels, so the ground behind the height slider no longer trails the view while it moves

Model used: Claude Opus 5.5
2026-10-25 18:56:25 +01:00
piexel 54213d4b62 Keep coarser ground in 3D views until new backdrop arrives
- Backdrop not sampled yet borrows the nearest sampled tile up to four levels coarser, so zooming in leaves no gap
2026-10-25 16:43:27 +01:00
piexel ff6668a48b Fix decorator palettes and stale theme on swapped panels
- Decorator palettes no longer look up a layer they don't have
- Sidebar and inspector panels pick up a theme switch made while they were off screen
2026-10-25 16:00:17 +01:00
piexel 29cca99947 Port the v3 map as iris std map
- iris std map opens the v3 map window; noise, palettes, objects, procedural and image maps are views in it
- Rivers view draws v4 hydrology, with an empty state when the pack has rivers off
- 3D views fall back to software when LWJGL isn't on the classpath; modded jars use the game's LWJGL
- FlatLaf and LWJGL join core; v4's separate studio windows are removed
2026-10-25 16:00:17 +01:00
Brian Neumann-Fopiano 2d211cf106 B+ 2026-10-23 10:41:36 -04:00
Brian Neumann-Fopiano 3240d57a3b Merged Iris turbo
- Columnar terrain bake, compiled generator and cave density programs, batched lattice noise

- Carve cell kernel, object bake, section buffer output, placement reach bound

- Lock-free history scope and subterrain caches, caller-run mantle components, hydrology halo and trial batching

- Cave apply, deposit, perfection and platform glue trims; probe parity oracle
2026-10-22 19:15:08 -04:00
Brian Neumann-Fopiano 908dbcf4cc Merged hydrology cold start trials
- Idle-aware fallback trial batches shorten the cold plan
2026-10-22 17:26:19 -04:00
Brian Neumann-Fopiano 4703d473cc Idle-aware fallback trial batches
- Fallback trials budget a measured 128 MB each instead of 1 GB

- Trial batches grow with idle hydrology workers up to the trial limit
2026-10-22 17:26:18 -04:00
Brian Neumann-Fopiano c3b3f1d8aa Waited for occupied dispatcher in test
- Concurrency test starts generation only after every burst worker is blocked
2026-10-22 12:11:48 -04:00
Brian Neumann-Fopiano 60eec4e2ee Updated volume index test reflection
- Volume index tests read entries through the concurrent volume cache
2026-10-22 11:59:13 -04:00
Brian Neumann-Fopiano c6d7a814e0 Merged hydrology cold start
- Shared bounds-biome memo and erosion raster without per-visit lookups
2026-10-22 11:47:20 -04:00
Brian Neumann-Fopiano 45e00f4794 Merged small stages
- Single-pass deposit footprints with balanced preparation, cached perfection and placement state checks, run-scanned history receipts
2026-10-22 11:36:07 -04:00
Brian Neumann-Fopiano 9d4c55d11a Merged carve field reads
- Batched baked carve field reads, per-thread bake chunk memory, terminal carving roots return early
2026-10-22 11:29:26 -04:00
Brian Neumann-Fopiano c62a31e16b Merged platform diet
- Per-chunk biome stage resolver, subterrain candidate gate, section-walk heightmaps, reused history compressors

# Conflicts:
#	core/src/main/java/art/arcane/iris/generation/runtime/Engine.java
2026-10-22 11:29:26 -04:00
Brian Neumann-Fopiano 18ba6e32ac Probe history digest
- Adds an opt-in digest of semantic and saved biome records per chunk
2026-10-22 11:05:58 -04:00
Brian Neumann-Fopiano f7f9f5f651 History capture fast paths
- Natural terrain capture scans columns as block state runs
- Biome samples reuse the palette index of an identical key
- Cave biome capture samples the subterrain planner directly when live
- Repeated object markers skip decoding
- Saved biome encoding hashes each distinct column once
2026-10-22 11:05:58 -04:00
Brian Neumann-Fopiano d9c5d00be3 Perfection and ore lookups
- Perfection memoizes solid and spike flags per block state identity
- Terrain painting resolves the ore chance engine once per chunk
2026-10-22 11:05:58 -04:00
Brian Neumann-Fopiano 97c67b794b Subterrain lookup check order
- Subterrain cell lookup resolves the dimension only after the complex is present
2026-10-22 10:55:21 -04:00
Brian Neumann-Fopiano c6e9f5a1ee Merged cave apply
- Per-chunk subterrain sampler, memoized dimension feature gate, bitmask walls, cached floor and ceiling layers
2026-10-22 10:11:34 -04:00
Brian Neumann-Fopiano 1fe26e6288 Shared bounds biomes and paged erosion raster
- Bounds starcasts share whole-column base biome samples across threads through a heap-sized lock-free memo
- Erosion raster geometry tracks station owners in paged local arrays and builds its index map in first-visit order
2026-10-22 10:02:53 -04:00
Brian Neumann-Fopiano 0b4d860ff4 Native noise packaging
- Default artifacts carry the CPU native noise libraries inside the packed payload

- Java 21 modded jars exclude Java 25 native classes
2026-10-22 08:58:32 -04:00
Brian Neumann-Fopiano 9d9acc7a3a Restore semantic cave sampling
- Semantic capture samples subterrain cave biomes per voxel as before
2026-10-22 07:32:35 -04:00
Brian Neumann-Fopiano 04f2798c06 Lock-free volume reads
- Structure volume caches read through concurrent maps and publish only current builds
- Natural terrain receipts reuse a per-thread deflater with the platform gzip framing
- Tests pin receipt and saved biome bytes across reused compressors
2026-10-22 07:29:53 -04:00
Brian Neumann-Fopiano 9cd470c7c8 Deposit clump fast paths
- Clump randoms use a plain-field LCG with the RNG sequence and a step jump
- Single-block palettes build footprints from row masks and jump the pick draws
- Ellipsoid nodes, row offsets and node sines reuse thread scratch and size tables
- Terrain-scoped footprint plans that cannot reach the chunk surface are skipped
- Above-terrain plans are skipped when no solid host sits above the surface
- Preparation runs as at most four balanced slices with one join per chunk
- Placement caches target, bedrock and replaceable checks per block state
2026-10-22 07:28:34 -04:00
Brian Neumann-Fopiano 8fbc006781 Hide subterrain gate accessors
- The memoized subterrain dimension gate no longer gets generated getter and setter methods
2026-10-22 07:22:46 -04:00
Brian Neumann-Fopiano e38f956d66 Terminal carving roots
- Roots without selectable children resolve to themselves before the per-column child cache
- Selection plans build through one shared helper for root checks and child selection
2026-10-22 07:19:57 -04:00
Brian Neumann-Fopiano 67fd9046ba Baked carve field reads
- ChunkBakeCache.fillColumns batches the missing requested columns of one chunk plane and publishes them
- BakedDoubleStream and BakedStream expose fillColumns with the per-column fallback when memoization is not allowed
- Carve profile field reads heights, regions, surface and cave biomes from the nine overlapped chunk planes
- Fluid presence samples the fluid plane only for columns below the fluid height
- Carving reuses one dimension carving resolver state per thread scratch
- Bake recent-entry memo holds a 16-slot per-thread chunk table instead of one entry
2026-10-22 07:14:09 -04:00
Brian Neumann-Fopiano 0bdb022f56 Chunk-scoped cave lookups
- Subterrain cells for carve and decoration resolve through a per-chunk sampler with a column height hull instead of per-block engine dispatch
- IrisEngine subterrain lookups read the thread frame and runtime once and memoize the dimension gate, removing the per-call disabled-component list scan
- Unrouted workers resolve cave biomes through the captured carving snapshot and column cache with subterrain biomes checked first
- Captured cave biome lookups bypass the per-voxel hash cache
- Internal carve walls come from word-level column masks instead of hashed neighbor puts, painted after cheap solidity and surface checks
- Floor and ceiling layers are memoized per column when the biome layers do not depend on the zone height
- Zone scanning walks carved runs, the decoration scan memoizes block solidity and reuses the previous block, and waterlogging normalization skips snapshots without hydrology
- Boundary walls read own cells straight from the snapshot; the unused snapshot face builder and face accessors are removed
2026-10-22 06:42:35 -04:00
Brian Neumann-Fopiano 0fe2363feb Platform glue diet
- Chunk-scoped visible biome resolver without global cache, per-quart RNG or repeated runtime lookups
- Subterrain probe skips per-sample subterrain lookups in chunks without candidates
- Striped structure volume caches
- Saved biome store reuses deflaters and remembers ready region files
- Semantic journal skips repeated storage and journal stat checks
2026-10-22 06:40:12 -04:00
Brian Neumann-Fopiano af90765a92 Restored burst parallelism in test
- Burst parallelism test restores the shared pool so later concurrency tests see the default size
2026-10-22 04:49:45 -04:00
Brian Neumann-Fopiano a25d8ec367 Merged review fixes
- Heap-sized terrain bake, bounded object rotation bakes, generation-counted bake thread locals, unused bake assembly removed
2026-10-22 04:48:46 -04:00
Brian Neumann-Fopiano fea9aa4f19 Narrowed bake sizing constants
- Bake sizing constants other than the floor are package-private
2026-10-22 04:29:08 -04:00
Brian Neumann-Fopiano 5c4bce0c1a Merged wave1 into FIX1 2026-10-22 04:28:00 -04:00
Brian Neumann-Fopiano 23637beb0c Merged parallel waits
- Caller-run mantle components when the window is full, barrier help, generation scope propagated to component tasks
2026-10-22 04:18:16 -04:00
Brian Neumann-Fopiano b712cbf93d Hydrology pool host cap
- Hydrology planner pool minimum never exceeds the host core count (floor 2)
2026-10-22 04:08:14 -04:00
Brian Neumann-Fopiano ae1a84702c Scratch resets and gated counters
- Terrain batch scratch marks reset in finally blocks so a failing kernel cannot grow the scratch
- Generator evaluation counters only count when generation counters are enabled
- Core tests run with generation counters enabled
2026-10-22 04:08:14 -04:00
Brian Neumann-Fopiano 6582d9d14d Heap-sized terrain bake
- Bake capacity derived from the heap (13 KB per entry against 1/32 of maxMemory, 2048 to 32768 entries); detached runtimes use the floor
- Pregeneration profile no longer raises the global noise cache size or the bake; it only raises burst parallelism
- Removed the hydrology bake reservation, noise cache budget, expansion machinery and the tile cache terrain preparation hook
- Bake thread recents compare a generation counter; clear empties retired entries
- Removed unused column bake, bake registry and bake accessors
2026-10-22 04:08:07 -04:00
Brian Neumann-Fopiano bf9e65252c Shared object placement layout
- Object placement bakes split into one per-object layout (palette, tiles, canopy) and per-variant offsets
- Variants beyond the per-object limit are built transiently instead of clearing the variant map
- High-variant golden and no-rebuild tests
2026-10-22 04:00:16 -04:00
Brian Neumann-Fopiano d7cad729e4 Merged warped lattice batching
- Cave lattice corners evaluated through batched point noise per pass
2026-10-22 03:08:40 -04:00
Brian Neumann-Fopiano cb008b6428 Merged wave1 into parallel waits
- Terrain bake and object bake from turbo/wave1
2026-10-22 02:56:35 -04:00
Brian Neumann-Fopiano e91eb258aa Mantle window helping
- A caller whose submission window is full runs the next component itself instead of parking
- Pass barriers run still-queued component tasks in reverse order before waiting
- Queued component tasks reopen the submitting generation's runtime scope on whichever thread runs them
- Helpers only run queued tasks bound to their own runtime or when they hold none
- A finished submission no longer reports a task as dropped once a helper started it
- Burst worker scope test keeps one stage on a worker while the caller helps
2026-10-22 02:56:23 -04:00
Brian Neumann-Fopiano 544707f5d6 Merged object bake
- Baked rotated object variants, packed transactions, non-blocking source plan builds
2026-10-22 02:49:58 -04:00
Brian Neumann-Fopiano aa929290c0 Merged terrain bake
- Per-chunk columnar bake with compiled generator programs replaces the height, biome and region stream caches
2026-10-22 02:24:24 -04:00
Brian Neumann-Fopiano a2e4ec7070 Batched warped lattice noise
- Cell lattice passes gather every missing corner sample of the pass and evaluate base, detail and modules through NoiseProgram.fillPoints3D
- Warp B samples and single-row warp A samples are evaluated as one point batch per pass; longer warp A runs keep fillColumn3D
- Module bands compact their points before the batch call and scatter back in the original accumulation order
- Lattice batch buffers and their NoiseScratch live in a per-thread CaveLatticeBatch chain that nested carves acquire separately
- Lattice test compares pass-wide point batches against scalar plane density bit for bit
2026-10-22 02:20:35 -04:00
Brian Neumann-Fopiano d2106806a5 Merged wave1 into terrain bake
# Conflicts:
#	core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java
#	probe/src/main/java/art/arcane/iris/probe/GenerationProbe.java
2026-10-22 02:16:40 -04:00
Brian Neumann-Fopiano ab51822fd4 Single subterrain check per write
- Drop the repeated subterrain protection lookup inside MantleWriter.setData; the same position was already rejected before the chunk lock
2026-10-22 01:32:15 -04:00
Brian Neumann-Fopiano d7a2a52c72 Planner bake reservation
- Hydrology planner height reads reserve bake capacity up to the hydrology cache target from the shared budget
- Planner reservation is released when the complex closes
2026-10-22 01:21:41 -04:00
Brian Neumann-Fopiano 024214d7f0 Merged carve noise batching
- Compiled cave density fields with batched per-pass lattices and shared warp fields
2026-10-22 01:18:30 -04:00
Brian Neumann-Fopiano ad73c33480 Columnar object mutation log
- Log transaction writes in parallel arrays instead of mutation records
- Replay source plans straight from their packed cells without decoding records
- Build source plans from a log slice
- Journal commit originals in a packed-key set instead of a linked map
- Buffer cave object writes in parallel arrays
- Drop the now-unused import in the runner
2026-10-22 01:04:22 -04:00
Brian Neumann-Fopiano e8f8546b8a Merged section output
- Palette-indexed section buffer, run-length terrain painting, padded column tops
2026-10-22 00:49:32 -04:00
Brian Neumann-Fopiano fd47240db9 Marker placement golden
- Record 480 marked placements (exact and loose markers, 4 rotations) recorded on the pre-bake runner
2026-10-22 00:37:37 -04:00
Brian Neumann-Fopiano be2fe12524 Hold object lock per placement
- Keep the object read lock across the bake lookup, block pass, grounding and stilt pass so a placement reads one consistent snapshot
2026-10-22 00:29:39 -04:00
Brian Neumann-Fopiano e376e1f59a Purity allowlist successor
- ChunkDataOutput replaces ChunkDataHunkHolder as the Bukkit-coupled chunk data writer
2026-10-22 00:25:11 -04:00
Brian Neumann-Fopiano b9409fb5ec Smaller warp column cache
- Warp lattice columns keep a quarter of the density column capacity
2026-10-22 00:20:25 -04:00
Brian Neumann-Fopiano d3defa5563 Merged GPU noise packaging
- Exclude GPU noise classes from the default plugin artifact
2026-10-22 00:18:20 -04:00
Brian Neumann-Fopiano 81f2d2332f Planner reads memoize in bake
- Hydrology planner height and natural samples memoize columns in the bake like other reads
- Batched planner base heights publish into the base plane
- Removed the direct-mapped sparse base height memo
- Only bounds lattice biome reads stay out of the bake
2026-10-22 00:12:25 -04:00
Brian Neumann-Fopiano 14da077ce1 Exclude GPU noise classes
- Java 21 runtime jars skip the Java 25 GpuNoise classes like the other native noise classes
2026-10-22 00:05:15 -04:00
Brian Neumann-Fopiano be30285718 Leaner object transactions
- Memoize the last subterrain protection verdict in object destination transactions
- Key buffered cave object blocks by packed positions and check each position once at commit
- Build every free source plan before waiting on plans other threads are building
- Reuse TreeBlockMaterial values per block state
2026-10-22 00:03:34 -04:00
Brian Neumann-Fopiano a25233edbc Compiled scalar group heights
- Layer and generator programs sample single points through NoiseProgram.sample2D
- Scalar interpolator group means use the compiled programs when the seed matches
- Single-point program heights are property-tested against the generator path
2026-10-21 23:57:11 -04:00
Brian Neumann-Fopiano 4d2e52bcc7 Palette section buffer
- Add SectionBuffer: chunk palette of written instances and short[4096] ids per section in y, z, x order
- Column tops are indexed per x-slice and padded so fan-out slices do not share a cache line
- Add ColumnRunHunk and forward column runs through the listening hunk and block update listener
- Terrain painter emits column runs and evaluates ores only inside the column's ore bands
- Bukkit generation writes into SectionBuffer and applies it through ChunkDataOutput
- Remove ChunkDataHunkHolder and the probe's ProductionBlockHunk copy
- Probe production buffers use SectionBuffer
2026-10-21 23:54:58 -04:00
Brian Neumann-Fopiano 943c8d67e5 Baked object placement
- Bake rotated block offsets, palette indexes, tiles and canopy bits per object and spin kernel
- Resolve decay, rotation, waterlog and air facts once per palette entry per placement
- Skip the destination read for waterloggable states that cannot become waterlogged
- Move SpinKernel to its own file
2026-10-21 23:50:55 -04:00
Brian Neumann-Fopiano fa996a2e24 Recent bake entry fast path
- Facade reads reuse the thread's last chunk entry instead of probing the bake map
- Stores and full-chunk fills refresh that entry from the map
- Surface lattice lookups reuse the previous point's cell indices
2026-10-21 23:48:07 -04:00
Brian Neumann-Fopiano 2bfa9c4528 Trimmed bake accessors
- Removed facade and plane accessors nothing reads
2026-10-21 23:38:09 -04:00
Brian Neumann-Fopiano b526b480a7 Object placement golden test
- Record 4000 placements (200 objects x 4 rotations x 5 seeds) as one block-stream hash with RNG draw state
2026-10-21 23:30:46 -04:00
Brian Neumann-Fopiano ab69be59c7 Rarity and capacity checks
- Rarity stream picks are property-tested against the VolmLib rarity selection
- Pregen capacity growth scales the bake by the same factor as engine start
2026-10-21 23:29:29 -04:00
Brian Neumann-Fopiano b510f0dbfe Batched shared cave lattice
- Cell kernel passes fill a per-pass density lattice in field-major batches before classifying rows
- Lattice density columns are shared between profiles with identical density fields
- Warp samples are shared between profiles with the same warp field and batch along lattice columns
- Added lattice read, sample and warp counters
- Added bit-exact lattice column and field sharing tests
2026-10-21 23:28:01 -04:00
Brian Neumann-Fopiano 7408d02bbe Unboxed biome selection
- Rarity selection streams pick from precomputed cumulative tables
- Implode and surface biome fixes take primitive coordinates
- Child selection reuses one derived child seed instead of a new RNG per level
- Base biome selection reads bridge and inferred streams directly
2026-10-21 23:27:01 -04:00
Brian Neumann-Fopiano 1acda886b3 Memoized bake planes
- Planes fill lazily per column with ready bits and batch only missing columns on full-chunk requests
- Natural fills batch only the base heights Terrain3D still needs and reuse the sparse memo
- Bounds lattice and hydrology planner reads stay out of the bake
- Bake capacity is eight times the noise cache, covering the measured working set
- Generator evaluation counters and a bake census print from the probe
- Parity test compares batched chunk base heights and evaluation counts with the scalar path
2026-10-21 23:22:16 -04:00
Brian Neumann-Fopiano 4dec940311 Compiled cave density programs
- Carver base, detail, warp and module fields evaluate through compiled noise programs
- Near-parity tests inject replacement densities as compiled programs
2026-10-21 23:21:45 -04:00
Brian Neumann-Fopiano df11ad2db0 Pack style carve golden
- Added a four-profile golden scenario with pack cave styles sharing warp and density fields
2026-10-21 23:15:40 -04:00
Brian Neumann-Fopiano f283d482a4 Merged threads and hydrology
- Bounded hydrology halo, performance-core pools
2026-10-21 23:14:43 -04:00
Brian Neumann-Fopiano 3f07db2b9e Merged mantle infrastructure
- Sliding mantle submissions and deferred in-flight neighbour waits
2026-10-21 23:14:43 -04:00
Brian Neumann-Fopiano 391d5346ae Merged native noise packaging
- Probe and build wiring for the optional native noise library
2026-10-21 23:06:10 -04:00
Brian Neumann-Fopiano 78de23e461 Merged carve kernel
- Cell-bound cave carve classification with exact mixed-cell evaluation

# Conflicts:
#	probe/src/main/java/art/arcane/iris/probe/GenerationProbe.java
2026-10-21 23:04:20 -04:00
Brian Neumann-Fopiano 6cc3adf76b Merged bench harness
- Probe stage timings, counters, hydrology preplan, router and production buffer options

# Conflicts:
#	core/src/main/java/art/arcane/iris/generation/context/ChunkContext.java
2026-10-21 23:03:48 -04:00
Brian Neumann-Fopiano 35e4e30ecd Performance core pools
- Generation pool sized to performance cores
- Pregen burst raise sized from performance cores
- Hydrology planner pool sized to performance cores, at least four
2026-10-21 23:03:20 -04:00
Brian Neumann-Fopiano 393ea10cdf Pregen hydrology reach
- Pregen tile speculation limited to the generation reach around the area instead of a 512-block halo
2026-10-21 21:58:31 -04:00
Brian Neumann-Fopiano 037156ab7d Added chunk terrain bake
- Compiled generator programs evaluate layer trees in batches through NoiseProgram
- TerrainProgram accumulates interpolator group means over chunk columns
- ChunkBakeCache holds compute-once per-chunk planes with provisional results never published
- Height, biome, region, rock, fluid and cave streams read the bake as thin facades
- Sparse hydrology and bounds reads stay scalar with a direct-mapped base height memo
- ColumnBake assembles id planes, heights, fluid surface, columns and the apron on demand
2026-10-21 21:52:07 -04:00
Brian Neumann-Fopiano 29e7e0384d Merged placement reach
- True object placement reach bounds shrink the carve window from 13 to 8 chunks
2026-10-21 21:47:25 -04:00
Brian Neumann-Fopiano 2d60898744 Native noise packaging
- Exclude native noise classes and libraries from Java 21 runtime jars
- Map the probe native library and policy properties to the native noise loader
2026-10-21 21:35:06 -04:00
Brian Neumann-Fopiano 6c7896f2a4 Merged locks and caches
- Lock-free history scope, shared subterrain plan caches, stateless cave biome hash, custom biome key cache
2026-10-21 21:15:07 -04:00
Brian Neumann-Fopiano b34e5e7676 Bound slope placement rotation
- Use the radial three-dimensional bound whenever rotateTowardsSlope is set, including placements whose configured rotation is disabled
- Group the support read margin helper with the other static factories
2026-10-21 21:09:57 -04:00
Brian Neumann-Fopiano 2471066937 Merged pipeline hygiene
- Lazy shore-band noise, zero interpolator groups dropped, inline prefill, dead streams removed
2026-10-21 21:03:22 -04:00
Brian Neumann-Fopiano ab468a198d Guarded nested corner blocks
- Fell back to per-group grid sampling when a height sample nests inside another on the same thread
- Covered nested sampling with a bit-exact parity test
2026-10-21 20:41:10 -04:00
Brian Neumann-Fopiano 7aaf346ce3 True placement reach bound
- Add PlacementReach: centered half-extent reach from loaded object keys, scale and shrink chains, enumerated rotation kernels, translate, warp and vacuum
- Size the object pass write radius and read radius from PlacementReach instead of full object width
- Size floating reach from scaled and shrunk object spans
- Count cave profile vacuum modes and upper-dimension effective rotation in the object reach
- Keep the transition-band footprint check on its existing footprint
- Add runner-backed and pack-wide reach tests
2026-10-21 20:24:46 -04:00
Brian Neumann-Fopiano 65542be8d6 Tightened mixed cave cells
- Classify mixed cells without fade, boost or protection through a branch-light column loop
- Hoist the per-row bilinear terms shared by every column of a cell row
- Mark open cells from the pass bitset when every pass column is active
2026-10-21 20:16:21 -04:00
Brian Neumann-Fopiano dcfadc8d1d Merged content trim
- Single-pass deposit ellipsoids, run-classified perfection scans, gated cave markers
2026-10-21 20:01:22 -04:00
Brian Neumann-Fopiano b03f1c90e1 Streamlined chunk prefill
- Read interpolator corner bounds through a per-thread chunk block and skip groups whose cell corners are all zero
- Prefilled chunk context caches inline on generation threads with heights ahead of biomes
- Kept the burst-pool prefill only for the platform main thread, with heights and biomes in one task
- Painted surface fluid from the chunk context cache instead of re-sampling the fluid stream
2026-10-21 19:53:23 -04:00
Brian Neumann-Fopiano e39e59699e Restore deposit clump RNG
- Return deposit clumps to the standard RNG; the unsynchronized generator measured no gain
2026-10-21 19:48:15 -04:00
Brian Neumann-Fopiano 13730f207a Drop deposit scan comment
- Remove the explanatory block comment added on the pack scan
2026-10-21 19:46:03 -04:00
Brian Neumann-Fopiano 217ed04f45 Split probe helpers
- Move chunk generation and buffer selection into ProbeChunkGenerator
- Move measured-window stage and counter lines into ProbeWindowMetrics
- Move hydrology pre-planning into ProbeHydrologyPreplan
2026-10-21 19:45:12 -04:00
Brian Neumann-Fopiano e3135d8b01 Unsynchronized deposit clump random
- Draw deposit clump randomness from a single-owner generator with the java.util.Random sequence
2026-10-21 19:37:27 -04:00
Brian Neumann-Fopiano 6ebb700be0 Reuse cave biome cache
- Carve scratch keeps one cave biome cache per thread, cleared per chunk
2026-10-21 19:29:37 -04:00
Brian Neumann-Fopiano 7c4a5e1937 Added cave cell kernel
- Decide whole 8x8 adaptive cells as solid or open from lattice corner bounds with an ulp guard
- Evaluate only mixed cells per column with the original bilinear arithmetic
- Share the row kernel between serial and parallel adaptive classification
- Count solid, open and mixed cells and exact rows, printed by the generation probe
- Pin carve write sequences for eight carver configurations with golden hashes
2026-10-21 19:26:52 -04:00
Brian Neumann-Fopiano 62d7c7e18a Sliding mantle submissions
- Replaced the barrier after every 8 component submissions with a per-caller cap that frees a slot as each component finishes and stops on the first failure
- Inline windows claim components without waiting and run components another generation holds after the rest of the pass
- Prefetched only the radius of passes that generate in the current phase
- Added straggler and held-claim tests and modeled claims in the locking chunk fixture
2026-10-21 19:24:44 -04:00
Brian Neumann-Fopiano c1732d50be Trimmed height pipeline work
- Removed the unused decoration, derivative and identity streams and Engine.getBiomeID
- Skipped the shore-height noise when a column sits outside the proven shore band
- Dropped interpolator groups whose bounds are zero in every base-capable biome
2026-10-21 19:21:49 -04:00
Brian Neumann-Fopiano 1821fedb24 Trim cave decoration markers
- Stop writing cave ceiling markers, which nothing reads
- Write cave floor markers only when the dimension has cave spawners
- Reuse the zone floor subterrain cell for zone decoration
2026-10-21 19:21:46 -04:00
Brian Neumann-Fopiano 41142ee5b5 Memoize perfection block classes
- Classify column blocks once per state run during perfection scans
2026-10-21 19:21:46 -04:00
Brian Neumann-Fopiano b6b01c88f5 Probe fidelity options
- Print measured-window stage totals from EngineMetrics as IRIS_GENPROBE_STAGES
- Time each context prefill task into its prefill timer
- Add gated generation counters for generator evaluations, corner starcasts and hydrology lookups
- Add -PprobeHydrologyPreplan to plan the probe area's hydrology tiles before warmup
- Add -PprobeHistoryRouter to generate through a generation-history router and registry contract
- Add -PprobeProductionBuffers to generate into production-equivalent block and biome hunks
- Print the mantle radius as IRIS_GENPROBE_MANTLE
2026-10-21 19:20:17 -04:00
Brian Neumann-Fopiano 3bd76d47a9 Single-pass deposit clumps
- Place ellipsoid deposit clumps from the footprint cells instead of rebuilding the clump object
- Skip ore cave-biome lookups when no pack biome scales ore deposits
- Reject terrain-limited clumps before biome work when scaling cannot apply
- Compute the chunk terrain limit once per chunk
- Draw the exposure discard roll only when the discard chance is positive
2026-10-21 19:16:57 -04:00
Brian Neumann-Fopiano 7282a46a26 Lock-free scopes and caches
- History coordinate scopes read the router and routing flag lock-free
- Subterrain planner shares bounded plan and chunk candidate caches
- Cave blend roll uses a stateless hash identical to the parallel RNG
- Custom biome resource keys are cached per dimension and biome identity
2026-10-21 19:15:01 -04:00
Brian Neumann-Fopiano e2430500cf Added probe parity oracle
- Dump every genProbe chunk as palette-indexed blocks and biomes with -PprobeDumpFile
- Add genDiff task comparing two dumps by coordinate with block, surface, biome and category parity
- Generate measured chunks on N threads in spiral order with -PprobeThreads and report wall-clock cps
- Mirror pregeneration with -PprobePregenProfile (pregen performance profile and hydrology prefetch scope)
- Print the measured window for JFR attribution and accept -PprobeJfrSettings
- Keep chunk hashes and the run signature byte-identical to the sequential probe
2026-10-21 18:47:25 -04:00
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
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
1738 changed files with 165662 additions and 102520 deletions

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
{
"name": "Iris",
"platform": "modded",
"versionProperty": "irisVersion",
"betaTag": "modded-beta",
"targets": "gradle/mod-targets.json",
"volmlibRef": "master"
}
+327
View File
@@ -0,0 +1,327 @@
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import sys
import tomllib
import urllib.error
import urllib.parse
import urllib.request
import zipfile
def read_config(root: Path) -> dict:
return json.loads((root / '.github/release.json').read_text())
def targets(root: Path, config: dict) -> list[dict[str, str]]:
if config['platform'] == 'bukkit':
return [{'id': 'bukkit', 'loader': 'bukkit', 'minecraft': ''}]
manifest: Path = root / config['targets']
if manifest.is_file():
versions: dict = json.loads(manifest.read_text())
entries: list[tuple[str, list[str]]] = [(version, ['fabric', 'forge', 'neoforge']) for version in versions]
else:
entries = []
for path in sorted(manifest.glob('*.json')):
target: dict = json.loads(path.read_text())
if path.stem != target['minecraft']:
raise ValueError(f'Minecraft manifest does not match {path.name}')
entries.append((target['minecraft'], list(target['loaders'])))
matrix: list[dict[str, str]] = []
for version, loaders in entries:
if not re.fullmatch(r'[0-9]+(?:\.[0-9]+)+', version):
raise ValueError(f'Invalid Minecraft version: {version}')
if set(loaders) != {'fabric', 'forge', 'neoforge'}:
raise ValueError(f'Incomplete loader matrix for {version}')
for loader in sorted(loaders):
matrix.append({'id': f'{loader}-{version}', 'loader': loader, 'minecraft': version})
if not matrix:
raise ValueError('Release matrix is empty')
return matrix
def plan(root: Path, config: dict, event: dict, environment: dict) -> dict:
branch: str = event.get('repository', {}).get('default_branch', '')
if (environment.get('GITHUB_EVENT_NAME') != 'push' or event.get('deleted')
or not branch or event.get('ref') != f'refs/heads/{branch}'):
return {'channel': 'none'}
commit: dict = event.get('head_commit') or {}
message: str = commit.get('message', '')
beta: bool = 'B+' in message
stable: bool = 'V+' in message
if beta and stable:
raise ValueError('Use exactly one release marker: B+ or V+')
if not beta and not stable:
return {'channel': 'none'}
sha: str = environment['GITHUB_SHA']
if event.get('after') != sha or commit.get('id') != sha or not re.fullmatch(r'[a-f0-9]{40}', sha):
raise ValueError('Release commit does not match the pushed HEAD')
key: str = config['versionProperty']
properties: str = (root / 'gradle.properties').read_text()
values: list[str] = re.findall(rf'^{re.escape(key)}\s*=\s*(\S+)\s*$', properties, re.MULTILINE)
if len(values) != 1 or not re.fullmatch(r'(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)', values[0]):
raise ValueError(f'{key} must declare one stable major.minor.patch version')
run: int = int(environment['GITHUB_RUN_NUMBER'])
attempt: int = int(environment['GITHUB_RUN_ATTEMPT'])
if run < 1 or attempt < 1:
raise ValueError('Invalid workflow run identity')
version: str = f'{values[0]}-beta.{run}.{attempt}' if beta else values[0]
return {
'channel': 'beta' if beta else 'stable',
'version': version,
'version_property': key,
'tag': config['betaTag'] if beta else f'v{version}',
'sha': sha,
'run': run,
'attempt': attempt,
'matrix': {'include': targets(root, config)},
'volmlib_ref': config['volmlibRef'],
'wormholes_ref': config.get('wormholesRef', ''),
}
def collect_artifacts(directory: Path, release: dict) -> dict[str, bytes]:
expected: dict[str, dict] = {f"release-{entry['id']}": entry for entry in release['matrix']['include']}
if {path.name for path in directory.iterdir()} != set(expected):
raise ValueError('Downloaded artifacts do not match the complete release matrix')
assets: dict[str, bytes] = {}
descriptors: dict[str, str] = {'bukkit': 'plugin.yml', 'fabric': 'fabric.mod.json',
'forge': 'META-INF/mods.toml', 'neoforge': 'META-INF/neoforge.mods.toml'}
for name, target in expected.items():
folder: Path = directory / name
paths: list[Path] = list(folder.iterdir()) if folder.is_dir() and not folder.is_symlink() else []
if len(paths) != 1 or paths[0].suffix != '.jar' or paths[0].is_symlink() or not paths[0].is_file():
raise ValueError(f'{name} must contain exactly one runtime jar')
path: Path = paths[0]
loader: str = target['loader']
suffix: str = re.escape(release['version']) + r'-bukkit\.jar'
if loader != 'bukkit':
display: str = {'fabric': 'Fabric', 'forge': 'Forge', 'neoforge': 'NeoForge'}[loader]
suffix = (r'v' + re.escape(release['version']) + r' \[' + display + r'\] '
+ re.escape(target['minecraft']) + r'\+[^/\s]+\.jar')
if not re.fullmatch(r'[A-Za-z][A-Za-z0-9]*' + ('-' if loader == 'bukkit' else ' ') + suffix, path.name):
raise ValueError(f'Artifact does not match the release target: {path.name}')
with zipfile.ZipFile(path) as archive:
entries: set[str] = set(archive.namelist())
descriptor: str = descriptors[target['loader']]
if descriptor not in entries:
raise ValueError(f'{path.name} is missing {descriptor}')
excluded: set[str] = set(descriptors.values()) - {descriptor}
if 'velocity-plugin.json' in entries or entries.intersection(excluded):
raise ValueError(f'{path.name} contains an excluded platform')
contents: str = archive.read(descriptor).decode()
if loader == 'bukkit':
match: re.Match | None = re.search(r'^version:\s*[\'\"]?([^\s\'\"]+)', contents, re.MULTILINE)
version: str | None = match[1] if match else None
elif loader == 'fabric':
metadata: dict = json.loads(contents)
version = metadata.get('version')
if metadata.get('depends', {}).get('minecraft') not in (target['minecraft'], '~' + target['minecraft']):
raise ValueError(f'{path.name} declares a different Minecraft target')
else:
metadata = tomllib.loads(contents)
mods: list[dict] = metadata.get('mods', [])
version = mods[0].get('version') if len(mods) == 1 else None
dependencies: list[dict] = metadata.get('dependencies', {}).get(mods[0].get('modId'), []) if len(mods) == 1 else []
minecraft: list[str] = [entry.get('versionRange') for entry in dependencies if entry.get('modId') == 'minecraft']
if minecraft != ['[' + target['minecraft'] + ']']:
raise ValueError(f'{path.name} declares a different Minecraft target')
if version != release['version']:
raise ValueError(f'{path.name} declares a different product version')
asset_name: str = re.sub(r'[^A-Za-z0-9._-]+', '-', path.name)
if asset_name in assets:
raise ValueError(f'Duplicate release asset: {asset_name}')
assets[asset_name] = path.read_bytes()
checksums: str = ''.join(f'{hashlib.sha256(data).hexdigest()} {name}\n' for name, data in sorted(assets.items()))
assets[f"SHA256SUMS-{release['version']}.txt"] = checksums.encode()
return assets
class GitHub:
def __init__(self, repository: str, token: str):
if not re.fullmatch(r'[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', repository):
raise ValueError('Invalid GitHub repository')
self.repository = repository
self.token = token
def request(self, method: str, path: str, payload: dict | bytes | None = None, missing: bool = False) -> dict | list | None:
url: str = path if path.startswith('https://uploads.github.com/') else f'https://api.github.com/repos/{self.repository}/{path}'
binary: bool = isinstance(payload, bytes)
data: bytes | None = payload if binary else json.dumps(payload).encode() if payload is not None else None
request: urllib.request.Request = urllib.request.Request(url, data=data, method=method, headers={
'Authorization': f'Bearer {self.token}',
'Accept': 'application/vnd.github+json',
'X-GitHub-Api-Version': '2026-03-10',
'Content-Type': 'application/octet-stream' if binary else 'application/json',
'User-Agent': 'Volmit-release',
})
try:
with urllib.request.urlopen(request, timeout=120) as response:
body: bytes = response.read()
return json.loads(body) if body else None
except urllib.error.HTTPError as error:
if missing and error.code == 404:
return None
raise RuntimeError(f'GitHub {method} {urllib.parse.urlsplit(url).path}: HTTP {error.code}') from error
def tag_commit(self, tag: str) -> str | None:
reference: dict | None = self.request('GET', f'git/ref/tags/{tag}', missing=True)
if reference is None:
return None
target: dict = reference['object']
for _ in range(8):
if target['type'] == 'commit':
return target['sha']
if target['type'] != 'tag':
break
target = self.request('GET', f"git/tags/{target['sha']}")['object']
raise ValueError(f'{tag} does not resolve to a commit')
def assets(self, release_id: int) -> list[dict]:
result: list[dict] = []
page: int = 1
while True:
batch: list[dict] = self.request('GET', f'releases/{release_id}/assets?per_page=100&page={page}')
result.extend(batch)
if len(batch) < 100:
return result
page += 1
def resolve_dependency(repository: str, reference: str, token: str) -> str:
commit: dict = GitHub(repository, token).request('GET', f'commits/{urllib.parse.quote(reference, safe="")}')
sha: str = commit['sha']
if not re.fullmatch(r'[a-f0-9]{40}', sha):
raise ValueError(f'{repository} did not resolve to a commit')
return sha
def release_metadata(existing: dict) -> dict:
match: re.Match | None = re.search(r'<!-- release: (.+) -->', existing.get('body') or '')
if match is None:
raise ValueError('Existing release has no publication identity; refusing to overwrite it')
return json.loads(match[1])
def verify_assets(existing: list[dict], assets: dict[str, bytes]) -> bool:
return {entry['name']: entry.get('digest') for entry in existing} == {
name: 'sha256:' + hashlib.sha256(data).hexdigest() for name, data in assets.items()}
def publish(github: GitHub, config: dict, release: dict, assets: dict[str, bytes]) -> str:
tag: str = release['tag']
beta: bool = release['channel'] == 'beta'
existing: dict | None = github.request('GET', f'releases/tags/{tag}', missing=True)
commit: str | None = github.tag_commit(tag)
if not beta and commit is not None and commit != release['sha']:
raise ValueError(f'{tag} already points to another commit')
if existing is not None:
metadata: dict = release_metadata(existing)
if bool(existing['prerelease']) != beta:
raise ValueError('Existing release belongs to a different release channel')
if beta and (metadata['run'], metadata['attempt']) > (release['run'], release['attempt']):
return 'Skipped older beta run'
if not beta and metadata['sha'] != release['sha']:
raise ValueError('Stable release belongs to another commit')
if not existing['draft'] and commit == release['sha'] and metadata['version'] == release['version']:
if verify_assets(github.assets(existing['id']), assets):
return 'Release already published with identical artifacts'
if not beta:
raise ValueError('Published release artifacts differ; refusing to overwrite them')
if existing.get('immutable'):
raise ValueError('Rolling beta requires mutable releases in repository settings')
if not beta and not existing['draft']:
raise ValueError('Stable release is already published')
elif beta and commit is not None:
raise ValueError('Beta tag exists without a release; refusing to move it')
identity: dict = {key: release[key] for key in ('version', 'sha', 'run', 'attempt', 'volmlib_ref', 'wormholes_ref') if key in release}
platform: str = 'Modded' if config['platform'] == 'modded' else 'CraftBukkit'
body: str = (f"{platform} jars for {config['name']} {release['version']}.\n\n"
f"Commit: `{release['sha']}`\n\n"
f"<!-- release: {json.dumps(identity, sort_keys=True)} -->")
attributes: dict = {
'tag_name': tag, 'target_commitish': release['sha'],
'name': f"{config['name']} {platform} {'Beta' if beta else release['version']}",
'body': body, 'prerelease': beta, 'make_latest': 'false',
}
if existing is None:
existing = github.request('POST', 'releases', {**attributes, 'draft': True})
elif beta:
existing = github.request('PATCH', f"releases/{existing['id']}", {**attributes, 'draft': True})
uploaded: list[dict] = github.assets(existing['id'])
by_name: dict[str, dict] = {entry['name']: entry for entry in uploaded}
for name, data in assets.items():
digest: str = 'sha256:' + hashlib.sha256(data).hexdigest()
if name in by_name:
if by_name[name].get('state') == 'starter':
github.request('DELETE', f"releases/assets/{by_name[name]['id']}")
elif by_name[name].get('digest') != digest:
raise ValueError(f'Existing asset differs: {name}')
else:
continue
upload_url: str = existing['upload_url'].split('{')[0] + '?' + urllib.parse.urlencode({'name': name})
receipt: dict = github.request('POST', upload_url, data)
if receipt.get('digest') != digest or receipt['name'] != name:
raise ValueError(f'Upload verification failed: {name}')
for entry in uploaded:
if entry['name'] not in assets:
github.request('DELETE', f"releases/assets/{entry['id']}")
commit = github.tag_commit(tag)
if commit is None:
github.request('POST', 'git/refs', {'ref': f'refs/tags/{tag}', 'sha': release['sha']})
elif commit != release['sha']:
if not beta:
raise ValueError('Stable tag changed during publication')
github.request('PATCH', f'git/refs/tags/{tag}', {'sha': release['sha'], 'force': True})
if not beta and config['platform'] == 'bukkit':
latest: dict | None = github.request('GET', 'releases/latest', missing=True)
match: re.Match | None = re.match(r'^v?(\d+)\.(\d+)\.(\d+)(?:$|-)', latest['tag_name']) if latest else None
if latest is None or (match and tuple(map(int, release['version'].split('.'))) > tuple(map(int, match.groups()))):
attributes['make_latest'] = 'true'
github.request('PATCH', f"releases/{existing['id']}", {**attributes, 'draft': False})
return f"Published {tag} at {release['sha']}"
def main() -> None:
parser: argparse.ArgumentParser = argparse.ArgumentParser()
parser.add_argument('command', choices=['plan', 'publish'])
parser.add_argument('--artifacts', type=Path)
arguments: argparse.Namespace = parser.parse_args()
root: Path = Path(__file__).resolve().parent.parent
config: dict = read_config(root)
event: dict = json.loads(Path(os.environ['GITHUB_EVENT_PATH']).read_text())
environment: dict = dict(os.environ)
if arguments.command == 'publish':
environment['GITHUB_RUN_ATTEMPT'] = environment['RELEASE_ATTEMPT']
release: dict = plan(root, config, event, environment)
if arguments.command == 'plan':
if release['channel'] != 'none':
release['volmlib_ref'] = resolve_dependency('VolmitSoftware/VolmLib', config['volmlibRef'], environment['GH_TOKEN'])
if release['wormholes_ref']:
release['wormholes_ref'] = resolve_dependency('VolmitSoftware/Wormholes', config['wormholesRef'], environment['GH_TOKEN'])
with Path(os.environ['GITHUB_OUTPUT']).open('a') as output:
for key, value in release.items():
output.write(f'{key}={json.dumps(value, separators=(",", ":")) if isinstance(value, dict) else value}\n')
print(f"Release channel: {release['channel']}")
elif release['channel'] != 'none':
if arguments.artifacts is None:
raise ValueError('Publication requires the complete artifact directory')
for key in ('volmlib_ref', 'wormholes_ref'):
if release[key]:
resolved: str = environment[f'RELEASE_{key.upper()}']
if not re.fullmatch(r'[a-f0-9]{40}', resolved):
raise ValueError(f'{key} is not a resolved dependency commit')
release[key] = resolved
github: GitHub = GitHub(os.environ['GITHUB_REPOSITORY'], os.environ['GH_TOKEN'])
print(publish(github, config, release, collect_artifacts(arguments.artifacts, release)))
if __name__ == '__main__':
try:
main()
except (ValueError, RuntimeError) as error:
print(f'::error::{error}', file=sys.stderr)
sys.exit(1)
+718
View File
@@ -0,0 +1,718 @@
import copy
import hashlib
import importlib.util
import io
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
import urllib.parse
import zipfile
SPEC = importlib.util.spec_from_file_location('release', Path(__file__).with_name('release.py'))
release = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(release)
SHA = 'a' * 40
OTHER_SHA = 'b' * 40
def digest(data: bytes) -> str:
return 'sha256:' + hashlib.sha256(data).hexdigest()
class FakeGitHub:
def __init__(self) -> None:
self.tags: dict[str, str] = {}
self.releases: dict[int, dict] = {}
self.uploads: dict[int, dict[str, dict]] = {}
self.writes: list[tuple[str, str]] = []
self.latest: dict | None = None
self.fail_upload: str | None = None
self.fail_delete: int | None = None
self.invalid_receipt: bool = False
self.next_asset: int = 1
def seed(self, publication: dict, assets: dict[str, bytes], draft: bool = False,
immutable: bool = False) -> dict:
identity: dict = {key: publication[key] for key in ('sha', 'version', 'run', 'attempt')}
record: dict = {
'id': len(self.releases) + 1,
'tag_name': publication['tag'],
'body': '<!-- release: ' + json.dumps(identity) + ' -->',
'draft': draft,
'prerelease': publication['channel'] == 'beta',
'immutable': immutable,
}
record['upload_url'] = f"https://uploads.github.com/repos/example/product/releases/{record['id']}/assets{{?name,label}}"
self.releases[record['id']] = record
self.uploads[record['id']] = {}
for name, data in assets.items():
self.add_asset(record['id'], name, data)
self.tags[publication['tag']] = publication['sha']
return record
def add_asset(self, identifier: int, name: str, data: bytes) -> dict:
receipt: dict = {'id': self.next_asset, 'name': name, 'digest': digest(data)}
self.next_asset += 1
self.uploads[identifier][name] = receipt
return copy.deepcopy(receipt)
def tag_commit(self, tag: str) -> str | None:
return self.tags.get(tag)
def assets(self, identifier: int) -> list[dict]:
return copy.deepcopy(list(self.uploads[identifier].values()))
def request(self, method: str, path: str, payload: dict | bytes | None = None,
missing: bool = False) -> dict | list | None:
if method != 'GET':
self.writes.append((method, path))
if method == 'GET' and path.startswith('releases/tags/'):
tag: str = path.removeprefix('releases/tags/')
return copy.deepcopy(next((record for record in self.releases.values()
if record['tag_name'] == tag), None))
if method == 'GET' and path == 'releases/latest':
return copy.deepcopy(self.latest)
if method == 'POST' and path == 'releases':
identifier: int = len(self.releases) + 1
record: dict = copy.deepcopy(payload)
record.update({'id': identifier, 'immutable': False,
'upload_url': f'https://uploads.github.com/repos/example/product/releases/{identifier}/assets{{?name,label}}'})
self.releases[identifier] = record
self.uploads[identifier] = {}
return copy.deepcopy(record)
if method == 'POST' and path.startswith('https://uploads.github.com/'):
address: urllib.parse.SplitResult = urllib.parse.urlsplit(path)
name: str = urllib.parse.parse_qs(address.query)['name'][0]
if name == self.fail_upload:
raise RuntimeError('Upload unavailable')
identifier: int = int(address.path.split('/')[-2])
receipt: dict = self.add_asset(identifier, name, payload)
if self.invalid_receipt:
receipt['digest'] = digest(b'corrupted')
return receipt
if method == 'POST' and path == 'git/refs':
tag: str = payload['ref'].removeprefix('refs/tags/')
if tag in self.tags:
raise RuntimeError('Tag already exists')
self.tags[tag] = payload['sha']
return {'object': {'type': 'commit', 'sha': payload['sha']}}
if method == 'PATCH' and path.startswith('git/refs/tags/'):
self.tags[path.removeprefix('git/refs/tags/')] = payload['sha']
return None
if method == 'DELETE' and path.startswith('releases/assets/'):
identifier: int = int(path.rsplit('/', 1)[1])
if identifier == self.fail_delete:
raise RuntimeError('Delete unavailable')
for uploaded in self.uploads.values():
for name, entry in list(uploaded.items()):
if entry['id'] == identifier:
del uploaded[name]
return None
raise AssertionError(f'Unknown asset: {identifier}')
if method == 'PATCH' and path.startswith('releases/'):
identifier: int = int(path.rsplit('/', 1)[1])
self.releases[identifier].update(copy.deepcopy(payload))
if payload.get('make_latest') == 'true':
self.latest = copy.deepcopy(self.releases[identifier])
return copy.deepcopy(self.releases[identifier])
raise AssertionError(f'Unexpected API request: {method} {path}')
class ReleaseTestCase(unittest.TestCase):
def setUp(self) -> None:
temporary: tempfile.TemporaryDirectory = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root: Path = Path(temporary.name)
self.config: dict = {'name': 'Example', 'platform': 'bukkit', 'versionProperty': 'exampleVersion',
'betaTag': 'bukkit-beta', 'volmlibRef': 'c' * 40}
(self.root / 'gradle.properties').write_text('exampleVersion=1.2.3\n')
self.event: dict = {'repository': {'default_branch': 'main'}, 'ref': 'refs/heads/main',
'after': SHA, 'head_commit': {'id': SHA, 'message': 'V+ Publish'}}
self.environment: dict = {'GITHUB_EVENT_NAME': 'push', 'GITHUB_SHA': SHA,
'GITHUB_RUN_NUMBER': '17', 'GITHUB_RUN_ATTEMPT': '2'}
network = patch.object(release.urllib.request, 'urlopen', side_effect=AssertionError('Network is forbidden'))
network.start()
self.addCleanup(network.stop)
def publication(self, channel: str = 'stable', **changes) -> dict:
result: dict = {
'channel': channel, 'version': '1.2.3-beta.17.2' if channel == 'beta' else '1.2.3',
'tag': 'bukkit-beta' if channel == 'beta' else 'v1.2.3', 'sha': SHA, 'run': 17, 'attempt': 2,
'matrix': {'include': [{'id': 'bukkit', 'loader': 'bukkit', 'minecraft': ''}]},
}
result.update(changes)
return result
def matrix(self, versions: tuple[str, ...] = ('1.20.1', '1.21.1')) -> list[dict]:
return [{'id': f'{loader}-{version}', 'loader': loader, 'minecraft': version}
for version in versions for loader in ('fabric', 'forge', 'neoforge')]
def artifact_tree(self, publication: dict, extra: tuple[str, ...] = ()) -> Path:
directory: Path = self.root / 'artifacts'
directory.mkdir(exist_ok=True)
descriptors: dict[str, str] = {'bukkit': 'plugin.yml', 'fabric': 'fabric.mod.json',
'forge': 'META-INF/mods.toml', 'neoforge': 'META-INF/neoforge.mods.toml'}
for target in publication['matrix']['include']:
folder: Path = directory / f"release-{target['id']}"
folder.mkdir()
loader: str = target['loader']
if loader == 'bukkit':
filename: str = f"Example-{publication['version']}-bukkit.jar"
metadata: str = f"name: Example\nversion: {publication['version']}\nmain: example.Plugin\n"
else:
display: str = {'fabric': 'Fabric', 'forge': 'Forge', 'neoforge': 'NeoForge'}[loader]
filename = f"Example v{publication['version']} [{display}] {target['minecraft']}+1.0.0.jar"
if loader == 'fabric':
metadata = json.dumps({'schemaVersion': 1, 'id': 'example', 'version': publication['version'],
'depends': {'minecraft': '~' + target['minecraft']}})
else:
metadata = (f"modLoader = 'javafml'\nloaderVersion = '[1,)'\nlicense = 'MIT'\n"
f"[[mods]]\nmodId = 'example'\nversion = '{publication['version']}'\n"
f"[[dependencies.example]]\nmodId = 'minecraft'\n"
f"versionRange = '[{target['minecraft']}]'\n")
path: Path = folder / filename
with zipfile.ZipFile(path, 'w') as archive:
archive.writestr(descriptors[loader], metadata)
for name in extra:
archive.writestr(name, '')
return directory
class PlanTests(ReleaseTestCase):
def test_markers_are_case_sensitive(self) -> None:
for message, channel in [('B+ Beta', 'beta'), ('V+ Release', 'stable'),
('b+ beta', 'none'), ('v+ release', 'none'), ('Fix a bug', 'none')]:
with self.subTest(message=message):
self.event['head_commit']['message'] = message
result: dict = release.plan(self.root, self.config, self.event, self.environment)
self.assertEqual(channel, result['channel'])
if channel == 'beta':
self.assertEqual('1.2.3-beta.17.2', result['version'])
self.assertEqual('bukkit-beta', result['tag'])
elif channel == 'stable':
self.assertEqual('1.2.3', result['version'])
self.assertEqual('v1.2.3', result['tag'])
def test_both_markers_fail(self) -> None:
self.event['head_commit']['message'] = 'B+ first\nV+ second'
with self.assertRaises(ValueError):
release.plan(self.root, self.config, self.event, self.environment)
def test_only_pushed_default_branch_head_can_release(self) -> None:
cases: list[tuple[dict, dict]] = [
({'ref': 'refs/heads/feature'}, {}), ({'ref': 'refs/tags/V+1.2.3'}, {}),
({'deleted': True}, {}), ({'repository': {}}, {}),
({'head_commit': None}, {}), ({}, {'GITHUB_EVENT_NAME': 'pull_request'}),
]
for event_changes, environment_changes in cases:
with self.subTest(event=event_changes, environment=environment_changes):
self.assertEqual({'channel': 'none'}, release.plan(
self.root, self.config, {**self.event, **event_changes},
{**self.environment, **environment_changes}))
def test_head_identity_must_match_event_and_environment(self) -> None:
for field in ('after', 'commit', 'environment'):
with self.subTest(field=field):
event: dict = copy.deepcopy(self.event)
environment: dict = self.environment.copy()
if field == 'commit':
event['head_commit']['id'] = OTHER_SHA
elif field == 'environment':
environment['GITHUB_SHA'] = 'not-a-sha'
else:
event['after'] = OTHER_SHA
with self.assertRaises(ValueError):
release.plan(self.root, self.config, event, environment)
def test_old_commits_do_not_trigger_release(self) -> None:
self.event['commits'] = [{'id': OTHER_SHA, 'message': 'V+ Release'}]
self.event['head_commit']['message'] = 'Fix a bug'
self.assertEqual({'channel': 'none'}, release.plan(self.root, self.config, self.event, self.environment))
def test_invalid_or_duplicate_versions_fail(self) -> None:
for value in ('', 'exampleVersion=01.2.3\n', 'exampleVersion=1.2\n',
'exampleVersion=1.2.3-beta.1\n', 'exampleVersion=1.2.3\nexampleVersion=2.3.4\n'):
with self.subTest(value=value):
(self.root / 'gradle.properties').write_text(value)
with self.assertRaises(ValueError):
release.plan(self.root, self.config, self.event, self.environment)
def test_invalid_run_identity_fails(self) -> None:
for key in ('GITHUB_RUN_NUMBER', 'GITHUB_RUN_ATTEMPT'):
with self.subTest(key=key), self.assertRaises(ValueError):
release.plan(self.root, self.config, self.event, {**self.environment, key: '0'})
def test_minecraft_file_matrix_contains_all_loaders(self) -> None:
(self.root / 'targets.json').write_text(json.dumps({'1.20.1': {}, '1.21.1': {}}))
config: dict = {**self.config, 'platform': 'modded', 'targets': 'targets.json'}
self.assertEqual(self.matrix(), release.targets(self.root, config))
def test_minecraft_directory_matrix_matches_files(self) -> None:
folder: Path = self.root / 'targets'
folder.mkdir()
(folder / '1.20.1.json').write_text(json.dumps({'minecraft': '1.20.1',
'loaders': ['fabric', 'forge', 'neoforge']}))
config: dict = {**self.config, 'platform': 'modded', 'targets': 'targets'}
self.assertEqual(self.matrix(('1.20.1',)), release.targets(self.root, config))
for minecraft, loaders in [('1.20.2', ['fabric', 'forge', 'neoforge']), ('1.20.1', ['fabric'])]:
with self.subTest(minecraft=minecraft, loaders=loaders):
(folder / '1.20.1.json').write_text(json.dumps({'minecraft': minecraft, 'loaders': loaders}))
with self.assertRaises(ValueError):
release.targets(self.root, config)
def test_empty_or_invalid_minecraft_matrix_fails(self) -> None:
for content in ({}, {'invalid': {}}):
with self.subTest(content=content):
(self.root / 'targets.json').write_text(json.dumps(content))
with self.assertRaises(ValueError):
release.targets(self.root, {**self.config, 'platform': 'modded', 'targets': 'targets.json'})
class ArtifactTests(ReleaseTestCase):
def test_complete_modded_matrix_and_checksums(self) -> None:
publication: dict = self.publication(matrix={'include': self.matrix()})
assets: dict[str, bytes] = release.collect_artifacts(self.artifact_tree(publication), publication)
self.assertEqual(7, len(assets))
checksums: bytes = assets.pop('SHA256SUMS-1.2.3.txt')
self.assertEqual(''.join(f'{hashlib.sha256(data).hexdigest()} {name}\n'
for name, data in sorted(assets.items())).encode(), checksums)
def test_missing_or_extra_matrix_target_fails(self) -> None:
publication: dict = self.publication()
directory: Path = self.artifact_tree(publication)
(directory / 'release-extra').mkdir()
with self.assertRaises(ValueError):
release.collect_artifacts(directory, publication)
(directory / 'release-extra').rmdir()
(directory / 'release-bukkit').rename(directory / 'release-wrong')
with self.assertRaises(ValueError):
release.collect_artifacts(directory, publication)
def test_intermediate_artifact_beside_runtime_fails(self) -> None:
publication: dict = self.publication()
directory: Path = self.artifact_tree(publication)
(directory / 'release-bukkit' / 'sources.jar').write_bytes(b'sources')
with self.assertRaises(ValueError):
release.collect_artifacts(directory, publication)
def test_symbolic_link_artifacts_fail(self) -> None:
publication: dict = self.publication()
directory: Path = self.artifact_tree(publication)
path: Path = next((directory / 'release-bukkit').iterdir())
target: Path = self.root / path.name
path.rename(target)
path.symlink_to(target)
with self.assertRaises(ValueError):
release.collect_artifacts(directory, publication)
def test_bukkit_is_forbidden_in_modded_release(self) -> None:
publication: dict = self.publication(matrix={'include': self.matrix(('1.20.1',))})
with self.assertRaises(ValueError):
release.collect_artifacts(self.artifact_tree(publication, ('plugin.yml',)), publication)
def test_velocity_is_forbidden_in_bukkit_release(self) -> None:
publication: dict = self.publication()
with self.assertRaises(ValueError):
release.collect_artifacts(self.artifact_tree(publication, ('velocity-plugin.json',)), publication)
def test_modded_descriptor_is_forbidden_in_bukkit_release(self) -> None:
publication: dict = self.publication()
with self.assertRaises(ValueError):
release.collect_artifacts(self.artifact_tree(publication, ('fabric.mod.json',)), publication)
def test_required_platform_descriptor_is_checked(self) -> None:
publication: dict = self.publication()
directory: Path = self.artifact_tree(publication)
path: Path = next((directory / 'release-bukkit').iterdir())
with zipfile.ZipFile(path, 'w') as archive:
archive.writestr('fabric.mod.json', '{}')
with self.assertRaises(ValueError):
release.collect_artifacts(directory, publication)
def test_wrong_version_and_embedded_version_suffix_fail(self) -> None:
publication: dict = self.publication()
directory: Path = self.artifact_tree(publication)
path: Path = next((directory / 'release-bukkit').iterdir())
for version in ('9.8.7', '11.2.3'):
with self.subTest(version=version):
changed: Path = path.with_name(f'Example-{version}-bukkit.jar')
path.rename(changed)
with self.assertRaises(ValueError):
release.collect_artifacts(directory, publication)
changed.rename(path)
def test_descriptor_version_must_match_release(self) -> None:
publication: dict = self.publication()
directory: Path = self.artifact_tree(publication)
path: Path = next((directory / 'release-bukkit').iterdir())
with zipfile.ZipFile(path, 'w') as archive:
archive.writestr('plugin.yml', 'name: Example\nversion: 1.2.4\nmain: example.Plugin\n')
with self.assertRaises(ValueError):
release.collect_artifacts(directory, publication)
def test_modded_descriptor_versions_must_match_release(self) -> None:
publication: dict = self.publication(matrix={'include': self.matrix(('1.20.1',))})
directory: Path = self.artifact_tree(publication)
for folder in directory.iterdir():
with self.subTest(target=folder.name):
path: Path = next(folder.iterdir())
original: bytes = path.read_bytes()
with zipfile.ZipFile(path) as archive:
descriptor: str = archive.namelist()[0]
contents: str = archive.read(descriptor).decode().replace('1.2.3', '1.2.4')
with zipfile.ZipFile(path, 'w') as archive:
archive.writestr(descriptor, contents)
with self.assertRaises(ValueError):
release.collect_artifacts(directory, publication)
path.write_bytes(original)
def test_modded_filename_must_match_minecraft_target(self) -> None:
publication: dict = self.publication(matrix={'include': self.matrix(('1.20.1',))})
directory: Path = self.artifact_tree(publication)
path: Path = next((directory / 'release-fabric-1.20.1').iterdir())
path.rename(path.with_name(path.name.replace('1.20.1', '1.21.1')))
with self.assertRaises(ValueError):
release.collect_artifacts(directory, publication)
def test_modded_metadata_must_match_minecraft_target(self) -> None:
publication: dict = self.publication(matrix={'include': self.matrix(('1.20.1',))})
directory: Path = self.artifact_tree(publication)
for folder in directory.iterdir():
with self.subTest(target=folder.name):
path: Path = next(folder.iterdir())
original: bytes = path.read_bytes()
with zipfile.ZipFile(path) as archive:
descriptor: str = archive.namelist()[0]
contents: str = archive.read(descriptor).decode().replace('1.20.1', '1.21.1')
with zipfile.ZipFile(path, 'w') as archive:
archive.writestr(descriptor, contents)
with self.assertRaises(ValueError):
release.collect_artifacts(directory, publication)
path.write_bytes(original)
class PublicationTests(ReleaseTestCase):
def setUp(self) -> None:
super().setUp()
self.github: FakeGitHub = FakeGitHub()
self.assets: dict[str, bytes] = {'Example-1.2.3-bukkit.jar': b'jar', 'SHA256SUMS-1.2.3.txt': b'checksums'}
def test_new_stable_release_is_published_at_head(self) -> None:
release.publish(self.github, self.config, self.publication(), self.assets)
record: dict = self.github.releases[1]
self.assertEqual(SHA, self.github.tags['v1.2.3'])
self.assertFalse(record['draft'])
self.assertFalse(record['prerelease'])
self.assertEqual('true', record['make_latest'])
self.assertEqual(set(self.assets), set(self.github.uploads[1]))
def test_stable_existing_tag_collision_has_no_writes(self) -> None:
self.github.tags['v1.2.3'] = OTHER_SHA
with self.assertRaises(ValueError):
release.publish(self.github, self.config, self.publication(), self.assets)
self.assertEqual([], self.github.writes)
self.assertEqual(OTHER_SHA, self.github.tags['v1.2.3'])
def test_stable_same_content_rerun_has_no_writes(self) -> None:
self.github.seed(self.publication(), self.assets, immutable=True)
result: str = release.publish(self.github, self.config, self.publication(), self.assets)
self.assertIn('already published', result)
self.assertEqual([], self.github.writes)
def test_published_stable_artifacts_cannot_be_replaced(self) -> None:
self.github.seed(self.publication(), self.assets, immutable=True)
with self.assertRaises(ValueError):
release.publish(self.github, self.config, self.publication(),
{**self.assets, 'Example-1.2.3-bukkit.jar': b'different'})
self.assertEqual([], self.github.writes)
def test_stable_release_identity_collision_has_no_writes(self) -> None:
self.github.seed(self.publication(sha=OTHER_SHA), self.assets)
self.github.tags['v1.2.3'] = SHA
with self.assertRaises(ValueError):
release.publish(self.github, self.config, self.publication(), self.assets)
self.assertEqual([], self.github.writes)
def test_stable_release_cannot_reuse_published_prerelease(self) -> None:
record: dict = self.github.seed(self.publication(), self.assets)
record['prerelease'] = True
with self.assertRaises(ValueError):
release.publish(self.github, self.config, self.publication(), self.assets)
self.assertEqual([], self.github.writes)
def test_partially_uploaded_stable_draft_resumes(self) -> None:
record: dict = self.github.seed(self.publication(), {'Example-1.2.3-bukkit.jar': b'jar'}, draft=True)
original_id: int = self.github.uploads[record['id']]['Example-1.2.3-bukkit.jar']['id']
release.publish(self.github, self.config, self.publication(), self.assets)
self.assertFalse(self.github.releases[record['id']]['draft'])
self.assertEqual(original_id, self.github.uploads[record['id']]['Example-1.2.3-bukkit.jar']['id'])
self.assertEqual(set(self.assets), set(self.github.uploads[record['id']]))
self.assertEqual(1, len(self.github.releases))
def test_conflicting_draft_asset_fails_without_replacement(self) -> None:
self.github.seed(self.publication(), {'Example-1.2.3-bukkit.jar': b'other'}, draft=True)
with self.assertRaises(ValueError):
release.publish(self.github, self.config, self.publication(), self.assets)
self.assertEqual([], self.github.writes)
self.assertTrue(self.github.releases[1]['draft'])
def test_newer_beta_run_or_attempt_is_preserved(self) -> None:
for run, attempt in ((18, 1), (17, 3)):
with self.subTest(run=run, attempt=attempt):
github: FakeGitHub = FakeGitHub()
github.seed(self.publication('beta', run=run, attempt=attempt, sha=OTHER_SHA), self.assets)
result: str = release.publish(github, self.config, self.publication('beta'), self.assets)
self.assertIn('Skipped', result)
self.assertEqual([], github.writes)
self.assertEqual(OTHER_SHA, github.tags['bukkit-beta'])
def test_successful_beta_replaces_assets_and_remains_prerelease(self) -> None:
previous: dict = self.publication('beta', run=16, attempt=1, sha=OTHER_SHA, version='1.2.3-beta.16.1')
record: dict = self.github.seed(previous, {'old.jar': b'old'})
release.publish(self.github, self.config, self.publication('beta'), self.assets)
current: dict = self.github.releases[record['id']]
self.assertEqual(SHA, self.github.tags['bukkit-beta'])
self.assertTrue(current['prerelease'])
self.assertFalse(current['draft'])
self.assertEqual('false', current['make_latest'])
self.assertEqual(set(self.assets), set(self.github.uploads[record['id']]))
def test_failed_beta_upload_preserves_tag_and_assets_in_recoverable_draft(self) -> None:
previous: dict = self.publication('beta', run=16, attempt=1, sha=OTHER_SHA, version='1.2.3-beta.16.1')
record: dict = self.github.seed(previous, {'old.jar': b'old'})
self.github.fail_upload = 'SHA256SUMS-1.2.3.txt'
with self.assertRaises(RuntimeError):
release.publish(self.github, self.config, self.publication('beta'), self.assets)
self.assertEqual(OTHER_SHA, self.github.tags['bukkit-beta'])
self.assertTrue(self.github.releases[record['id']]['draft'])
self.assertEqual(17, release.release_metadata(self.github.releases[record['id']])['run'])
self.assertIn('old.jar', self.github.uploads[record['id']])
self.assertFalse(any(method == 'DELETE' or path.startswith('git/refs')
for method, path in self.github.writes))
def test_failed_beta_cleanup_resumes_without_reuploading_verified_assets(self) -> None:
previous: dict = self.publication('beta', run=16, attempt=1, sha=OTHER_SHA, version='1.2.3-beta.16.1')
record: dict = self.github.seed(previous, {'old.jar': b'old'})
self.github.fail_delete = self.github.uploads[record['id']]['old.jar']['id']
with self.assertRaises(RuntimeError):
release.publish(self.github, self.config, self.publication('beta'), self.assets)
self.assertTrue(self.github.releases[record['id']]['draft'])
self.assertEqual(OTHER_SHA, self.github.tags['bukkit-beta'])
uploaded_ids: dict[str, int] = {name: self.github.uploads[record['id']][name]['id'] for name in self.assets}
self.github.fail_delete = None
release.publish(self.github, self.config, self.publication('beta'), self.assets)
self.assertFalse(self.github.releases[record['id']]['draft'])
self.assertEqual(SHA, self.github.tags['bukkit-beta'])
self.assertEqual(uploaded_ids, {name: entry['id'] for name, entry in self.github.uploads[record['id']].items()})
def test_incomplete_newer_beta_draft_blocks_older_run(self) -> None:
self.github.seed(self.publication('beta', run=18, sha=OTHER_SHA), {}, draft=True)
result: str = release.publish(self.github, self.config, self.publication('beta'), self.assets)
self.assertIn('Skipped', result)
self.assertEqual([], self.github.writes)
self.assertTrue(self.github.releases[1]['draft'])
def test_starter_upload_in_draft_is_replaced_before_publication(self) -> None:
self.github.seed(self.publication(), {'Example-1.2.3-bukkit.jar': b''}, draft=True)
starter: dict = self.github.uploads[1]['Example-1.2.3-bukkit.jar']
identifier: int = starter['id']
starter.update({'state': 'starter', 'digest': None})
release.publish(self.github, self.config, self.publication(), self.assets)
self.assertFalse(self.github.releases[1]['draft'])
self.assertNotEqual(identifier, self.github.uploads[1]['Example-1.2.3-bukkit.jar']['id'])
self.assertEqual(digest(b'jar'), self.github.uploads[1]['Example-1.2.3-bukkit.jar']['digest'])
def test_invalid_upload_digest_does_not_create_tag_or_publish(self) -> None:
self.github.invalid_receipt = True
with self.assertRaises(ValueError):
release.publish(self.github, self.config, self.publication(), self.assets)
self.assertEqual({}, self.github.tags)
self.assertTrue(self.github.releases[1]['draft'])
def test_beta_will_not_replace_stable_or_immutable_release(self) -> None:
for immutable, prerelease in ((True, True), (False, False)):
with self.subTest(immutable=immutable, prerelease=prerelease):
github: FakeGitHub = FakeGitHub()
record: dict = github.seed(self.publication('beta', run=16, sha=OTHER_SHA), self.assets,
immutable=immutable)
record['prerelease'] = prerelease
with self.assertRaises(ValueError):
release.publish(github, self.config, self.publication('beta'), self.assets)
self.assertEqual([], github.writes)
def test_unowned_existing_release_is_preserved(self) -> None:
record: dict = self.github.seed(self.publication(), self.assets)
record['body'] = 'Published manually'
with self.assertRaises(ValueError):
release.publish(self.github, self.config, self.publication(), self.assets)
self.assertEqual([], self.github.writes)
def test_beta_tag_without_release_is_preserved(self) -> None:
self.github.tags['bukkit-beta'] = OTHER_SHA
with self.assertRaises(ValueError):
release.publish(self.github, self.config, self.publication('beta'), self.assets)
self.assertEqual([], self.github.writes)
def test_modded_stable_release_never_changes_latest(self) -> None:
self.github.latest = {'tag_name': 'v1.0.0'}
release.publish(self.github, {**self.config, 'platform': 'modded'}, self.publication(), self.assets)
self.assertFalse(self.github.releases[1]['prerelease'])
self.assertEqual('false', self.github.releases[1]['make_latest'])
self.assertEqual('v1.0.0', self.github.latest['tag_name'])
def test_bukkit_backport_does_not_replace_newer_latest(self) -> None:
for latest in ('v1.3.0', 'v2.0.0', 'v1.2.3'):
with self.subTest(latest=latest):
github: FakeGitHub = FakeGitHub()
github.latest = {'tag_name': latest}
release.publish(github, self.config, self.publication(), self.assets)
self.assertEqual('false', github.releases[1]['make_latest'])
self.assertEqual(latest, github.latest['tag_name'])
def test_newer_bukkit_release_becomes_latest(self) -> None:
self.github.latest = {'tag_name': 'v1.2.2'}
release.publish(self.github, self.config, self.publication(), self.assets)
self.assertEqual('true', self.github.releases[1]['make_latest'])
self.assertEqual('v1.2.3', self.github.latest['tag_name'])
class GitHubReadTests(ReleaseTestCase):
def test_annotated_tag_resolves_to_commit(self) -> None:
github: release.GitHub = release.GitHub('example/product', 'test-token')
responses: dict[str, dict] = {
'git/ref/tags/v1.2.3': {'object': {'type': 'tag', 'sha': OTHER_SHA}},
f'git/tags/{OTHER_SHA}': {'object': {'type': 'commit', 'sha': SHA}},
}
with patch.object(github, 'request', side_effect=lambda method, path, **options: responses[path]):
self.assertEqual(SHA, github.tag_commit('v1.2.3'))
def test_noncommit_tag_cannot_be_published(self) -> None:
github: release.GitHub = release.GitHub('example/product', 'test-token')
with patch.object(github, 'request', return_value={'object': {'type': 'tree', 'sha': SHA}}):
with self.assertRaises(ValueError):
github.tag_commit('v1.2.3')
def test_assets_include_all_pages(self) -> None:
github: release.GitHub = release.GitHub('example/product', 'test-token')
first: list[dict] = [{'id': number, 'name': f'{number}.jar'} for number in range(100)]
last: list[dict] = [{'id': 100, 'name': 'last.jar'}]
with patch.object(github, 'request', side_effect=[first, last]):
self.assertEqual(first + last, github.assets(1))
def test_invalid_repository_is_rejected(self) -> None:
for repository in ('example', '../example/product', 'example/product/extra'):
with self.subTest(repository=repository), self.assertRaises(ValueError):
release.GitHub(repository, 'test-token')
class DependencyResolutionTests(ReleaseTestCase):
def test_branch_resolves_to_full_commit_sha(self) -> None:
with patch.object(release.GitHub, 'request', return_value={'sha': SHA}) as request:
self.assertEqual(SHA, release.resolve_dependency('example/product', 'master', 'test-token'))
request.assert_called_once_with('GET', 'commits/master')
def test_full_commit_reference_is_verified(self) -> None:
with patch.object(release.GitHub, 'request', return_value={'sha': SHA}) as request:
self.assertEqual(SHA, release.resolve_dependency('example/product', SHA, 'test-token'))
request.assert_called_once_with('GET', f'commits/{SHA}')
def test_incomplete_or_invalid_resolved_sha_fails(self) -> None:
for sha in ('', 'a' * 39, 'a' * 41, 'A' * 40, 'g' * 40, 'master'):
with self.subTest(sha=sha), patch.object(release.GitHub, 'request', return_value={'sha': sha}):
with self.assertRaises(ValueError):
release.resolve_dependency('example/product', 'master', 'test-token')
def test_reference_is_encoded_as_single_url_path_component(self) -> None:
with patch.object(release.GitHub, 'request', return_value={'sha': SHA}) as request:
self.assertEqual(SHA, release.resolve_dependency('example/product', 'feature/next+release #?', 'test-token'))
request.assert_called_once_with('GET', 'commits/feature%2Fnext%2Brelease%20%23%3F')
class MainTests(ReleaseTestCase):
def setUp(self) -> None:
super().setUp()
self.config.update({'volmlibRef': 'master', 'wormholesRef': 'release/api'})
(self.root / '.github').mkdir()
(self.root / '.github/release.json').write_text(json.dumps(self.config))
self.event['head_commit']['message'] = 'B+ Publish beta'
(self.root / 'event.json').write_text(json.dumps(self.event))
self.environment.update({
'GITHUB_EVENT_PATH': str(self.root / 'event.json'),
'GITHUB_OUTPUT': str(self.root / 'output.txt'),
'GITHUB_REPOSITORY': 'example/product',
'GH_TOKEN': 'test-token',
})
script = patch.object(release, '__file__', str(self.root / '.github/release.py'))
script.start()
self.addCleanup(script.stop)
output = patch('sys.stdout', new=io.StringIO())
output.start()
self.addCleanup(output.stop)
def test_plan_freezes_each_dependency_branch_once(self) -> None:
with patch.dict(release.os.environ, self.environment, clear=True), \
patch.object(release.sys, 'argv', ['release.py', 'plan']), \
patch.object(release.GitHub, 'request', side_effect=[{'sha': SHA}, {'sha': OTHER_SHA}]) as request:
release.main()
outputs: dict[str, str] = dict(line.split('=', 1) for line in (self.root / 'output.txt').read_text().splitlines())
self.assertEqual(SHA, outputs['volmlib_ref'])
self.assertEqual(OTHER_SHA, outputs['wormholes_ref'])
self.assertEqual('1.2.3-beta.17.2', outputs['version'])
self.assertEqual('2', outputs['attempt'])
self.assertEqual(2, request.call_count)
self.assertEqual(['commits/master', 'commits/release%2Fapi'], [call.args[1] for call in request.call_args_list])
def test_unmarked_push_never_resolves_dependencies(self) -> None:
self.event['head_commit']['message'] = 'Fix a bug'
(self.root / 'event.json').write_text(json.dumps(self.event))
with patch.dict(release.os.environ, self.environment, clear=True), \
patch.object(release.sys, 'argv', ['release.py', 'plan']), \
patch.object(release, 'resolve_dependency', side_effect=AssertionError('Unexpected dependency lookup')):
release.main()
self.assertEqual('channel=none\n', (self.root / 'output.txt').read_text())
def test_publish_rerun_uses_original_planned_attempt_and_dependency_commits(self) -> None:
publication: dict = self.publication('beta')
artifacts: Path = self.artifact_tree(publication)
github: FakeGitHub = FakeGitHub()
self.environment.update({'GITHUB_RUN_ATTEMPT': '3', 'RELEASE_ATTEMPT': '2',
'RELEASE_VOLMLIB_REF': SHA, 'RELEASE_WORMHOLES_REF': OTHER_SHA})
with patch.dict(release.os.environ, self.environment, clear=True), \
patch.object(release.sys, 'argv', ['release.py', 'publish', '--artifacts', str(artifacts)]), \
patch.object(release, 'GitHub', return_value=github), \
patch.object(release, 'resolve_dependency', side_effect=AssertionError('Dependencies must stay frozen')):
release.main()
metadata: dict = release.release_metadata(github.releases[1])
self.assertEqual('1.2.3-beta.17.2', metadata['version'])
self.assertEqual(2, metadata['attempt'])
self.assertEqual(SHA, metadata['volmlib_ref'])
self.assertEqual(OTHER_SHA, metadata['wormholes_ref'])
self.assertFalse(github.releases[1]['draft'])
self.assertEqual({'Example-1.2.3-beta.17.2-bukkit.jar', 'SHA256SUMS-1.2.3-beta.17.2.txt'},
set(github.uploads[1]))
writes: list[tuple[str, str]] = github.writes.copy()
release.os.environ['GITHUB_RUN_ATTEMPT'] = '4'
release.main()
self.assertEqual(writes, github.writes)
def test_publish_rejects_unresolved_dependency_reference_before_writing(self) -> None:
artifacts: Path = self.artifact_tree(self.publication('beta'))
github: FakeGitHub = FakeGitHub()
self.environment.update({'RELEASE_ATTEMPT': '2', 'RELEASE_VOLMLIB_REF': SHA,
'RELEASE_WORMHOLES_REF': OTHER_SHA})
for key in ('RELEASE_VOLMLIB_REF', 'RELEASE_WORMHOLES_REF'):
with self.subTest(key=key), \
patch.dict(release.os.environ, {**self.environment, key: 'master'}, clear=True), \
patch.object(release.sys, 'argv', ['release.py', 'publish', '--artifacts', str(artifacts)]), \
patch.object(release, 'GitHub', return_value=github):
with self.assertRaises(ValueError):
release.main()
self.assertEqual([], github.writes)
if __name__ == '__main__':
unittest.main()
+157
View File
@@ -0,0 +1,157 @@
name: Release
on:
push:
branches: ['**']
pull_request:
paths:
- '.github/**'
- '**/*.gradle'
- '**/gradle.properties'
- 'gradle/**/*.json'
permissions:
contents: read
env:
PYTHONDONTWRITEBYTECODE: '1'
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
persist-credentials: false
- name: Test release handling
run: python3 -m unittest discover -s .github -p 'test_release.py' -v
plan:
needs: validate
if: github.event_name == 'push' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
runs-on: ubuntu-latest
outputs:
channel: ${{ steps.release.outputs.channel }}
version: ${{ steps.release.outputs.version }}
attempt: ${{ steps.release.outputs.attempt }}
version_property: ${{ steps.release.outputs.version_property }}
matrix: ${{ steps.release.outputs.matrix }}
volmlib_ref: ${{ steps.release.outputs.volmlib_ref }}
wormholes_ref: ${{ steps.release.outputs.wormholes_ref }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
persist-credentials: false
- name: Select release channel and targets
id: release
env:
GH_TOKEN: ${{ github.token }}
run: python3 .github/release.py plan
build:
needs: plan
if: needs.plan.outputs.channel != 'none'
runs-on: ubuntu-latest
timeout-minutes: 120
strategy:
fail-fast: false
max-parallel: 3
matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}
env:
ORG_GRADLE_PROJECT_localVolmLibDirectory: ${{ github.workspace }}/VolmLib
RELEASE_VERSION: ${{ needs.plan.outputs.version }}
VERSION_PROPERTY: ${{ needs.plan.outputs.version_property }}
RELEASE_LOADER: ${{ matrix.loader }}
MINECRAFT_VERSION: ${{ matrix.minecraft }}
steps:
- name: Check out release sources
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
path: project
submodules: recursive
persist-credentials: false
- name: Check out VolmLib
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
repository: VolmitSoftware/VolmLib
ref: ${{ needs.plan.outputs.volmlib_ref }}
path: VolmLib
persist-credentials: false
- name: Check out Wormholes API sources
if: needs.plan.outputs.wormholes_ref != ''
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
repository: VolmitSoftware/Wormholes
ref: ${{ needs.plan.outputs.wormholes_ref }}
path: WormholesPlugin
submodules: recursive
persist-credentials: false
- name: Set up Java 21 toolchain
uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad
with:
distribution: temurin
java-version: '21'
- name: Set up Java 25
uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad
with:
distribution: temurin
java-version: '25'
- uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6
- name: Build Wormholes API
if: needs.plan.outputs.wormholes_ref != ''
working-directory: WormholesPlugin
run: ./gradlew apiJar --no-daemon --max-workers=2 '-Dorg.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8'
- name: Locate Wormholes API
if: needs.plan.outputs.wormholes_ref != ''
shell: bash
run: |
api_jars=(WormholesPlugin/build/libs/*-api.jar)
test "${#api_jars[@]}" -eq 1
test -f "${api_jars[0]}"
echo "ORG_GRADLE_PROJECT_wormholesApiJar=$PWD/${api_jars[0]}" >> "$GITHUB_ENV"
- name: Build and verify release jar
working-directory: project
shell: bash
run: |
arguments=(prepareRelease --no-daemon --max-workers=2
'-Dorg.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8'
"-P$VERSION_PROPERTY=$RELEASE_VERSION")
if [[ "$RELEASE_LOADER" != bukkit ]]; then
arguments+=("-PreleaseLoader=$RELEASE_LOADER" "-PminecraftVersion=$MINECRAFT_VERSION"
-PmoddedBuildParallelism=1 -PmoddedTestForks=1)
fi
./gradlew "${arguments[@]}"
- name: Upload verified jar
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9
with:
name: release-${{ matrix.id }}
path: project/build/release/*.jar
if-no-files-found: error
retention-days: 7
compression-level: 0
publish:
needs: [plan, build]
runs-on: ubuntu-latest
permissions:
contents: write
concurrency:
group: release-publication
cancel-in-progress: false
queue: max
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
persist-credentials: false
- name: Download complete release matrix
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333
with:
pattern: release-*
path: build/release-artifacts
- name: Publish verified release
env:
GH_TOKEN: ${{ github.token }}
RELEASE_ATTEMPT: ${{ needs.plan.outputs.attempt }}
RELEASE_VOLMLIB_REF: ${{ needs.plan.outputs.volmlib_ref }}
RELEASE_WORMHOLES_REF: ${{ needs.plan.outputs.wormholes_ref }}
run: python3 .github/release.py publish --artifacts build/release-artifacts
+2
View File
@@ -81,3 +81,5 @@ BUILDS/
/docs/ /docs/
/.perf/ /.perf/
/.codegraph/ /.codegraph/
.superpowers/
+16 -24
View File
@@ -248,34 +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: the Bukkit jar This builds and verifies the Bukkit plugin, API, SPI and the Fabric, Forge and NeoForge jars
and a Fabric, Forge and NeoForge jar for every Minecraft version in `gradle/mod-targets.json` for every Minecraft version declared in `gradle/mod-targets.json`. Artifacts stay in the
(1.21.1, 26.1.2, 26.2 and 26.3): 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`, and `build<Loader><mc>` for one mod jar, for example Multiplexor reads `gradle/multiplexor.gradle` to discover targets and build each in a
`buildFabric26_2` or `buildNeoForge1_21_1`. `buildFabric`, `buildForge` and `buildNeoforge` export the separate source sandbox. Use its project build and deployment commands to export jars
26.3 jars. `./gradlew checkAll` runs every check, then builds and verifies all twelve mod jars and runs their or install them into managed test instances. Gradle builds do not copy artifacts to
unit tests; Forge 1.21.1 only compiles its tests because Forge 52 cannot bootstrap Minecraft outside external destinations.
FML. The
SPI jar (the pure-JVM adapter/platform contract, not the stable plugin API) is built to `spi/build/libs/` by
`./gradlew :spi:jar`.
`./gradlew buildAll` also runs `buildAllToOut` and copies the jars into a consumer dropin tree
for a local test server. The consumer directory defaults to `../../[Minecraft Server]/consumers/`
when it exists, otherwise `build/consumers/` inside the repo. `-Plocation=/path/to/consumers`
changes only the consumer directory. Both tasks copy every verified platform jar 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);
@@ -493,7 +497,13 @@ public class NativeStructureVolumeIndexTest {
private static Map<?, ?> cache(NativeStructureVolumeIndex index, String name) throws Exception { private static Map<?, ?> cache(NativeStructureVolumeIndex index, String name) throws Exception {
Field field = NativeStructureVolumeIndex.class.getDeclaredField(name); Field field = NativeStructureVolumeIndex.class.getDeclaredField(name);
field.setAccessible(true); field.setAccessible(true);
return (Map<?, ?>) field.get(index); Object value = field.get(index);
if (value instanceof Map<?, ?> map) {
return map;
}
Field entries = value.getClass().getDeclaredField("entries");
entries.setAccessible(true);
return (Map<?, ?>) entries.get(value);
} }
private static StructureStart swampHut(int chunkX, int chunkZ, int x, int z) { private static StructureStart swampHut(int chunkX, int chunkZ, int x, int z) {
@@ -544,7 +554,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 +607,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) {
@@ -0,0 +1,563 @@
package art.arcane.iris.probe;
import art.arcane.iris.pack.datapack.export.WorldgenDatapackExporter;
import art.arcane.iris.pack.datapack.export.WorldgenExportProgress;
import art.arcane.iris.pack.datapack.export.WorldgenExportRequest;
import art.arcane.iris.pack.datapack.export.WorldgenExportResult;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import net.minecraft.core.RegistryAccess;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.dimension.LevelStem;
import net.minecraft.world.level.levelgen.Aquifer;
import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.world.level.levelgen.NoiseGeneratorSettings;
import net.minecraft.world.level.levelgen.NoiseRouter;
import net.minecraft.world.level.levelgen.densityfunction.DensityFunction;
import java.io.File;
import java.io.IOException;
import java.io.PrintStream;
import java.lang.management.ManagementFactory;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.SortedMap;
import java.util.TreeMap;
import java.util.concurrent.Callable;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
public final class DatapackCostProbe {
private static final String LOG_PREFIX = "[datapack-cost]";
static final String VANILLA_PRESET = "minecraft:normal";
private static final int WARMUP_ORIGIN = -4096;
private static final int MINIMUM_CALLS = 8;
private static final int TOP_OFFENDERS = 10;
private static final long ANALYSIS_STACK_BYTES = 1L << 30;
private static final double MEGABYTE = 1024.0 * 1024.0;
private static final double NANOS_PER_SECOND = 1_000_000_000.0;
private DatapackCostProbe() {
}
public static void main(String[] arguments) {
PrintStream diagnostics = System.err;
try {
execute(Configuration.parse(arguments));
} catch (Throwable failure) {
failure.printStackTrace(diagnostics);
diagnostics.flush();
System.exit(1);
}
System.out.flush();
System.exit(0);
}
private static void execute(Configuration configuration) throws Exception {
HeadlessNativeBootstrap.initialize();
Files.createDirectories(configuration.work());
Path work = Files.createTempDirectory(configuration.work(), "run-");
List<HeadlessNativeRegistries> loaded = new ArrayList<>(2);
try {
log("exporting " + configuration.dimension() + " from " + configuration.pack());
ExportRun export = export(configuration, work);
log(String.format(Locale.ROOT, "exported in %.1f s: %d files, %d density functions", export.stats().seconds(),
export.stats().files(), export.stats().densityFunctions()));
DensityJsonBreakdown.Report densityJson = onLargeStack("density-json",
() -> DensityJsonBreakdown.analyze(export.files(), configuration.dimension()).report());
Path datapack = work.resolve("datapack");
writeFiles(export.files(), datapack);
Path vanillaPack = Files.createDirectories(work.resolve("vanilla"));
ExportStats exportStats = export.stats();
export.files().clear();
long baseline = settledHeap();
long loadStarted = System.nanoTime();
HeadlessNativeRegistries vanilla = isolatedLoad(vanillaPack);
double vanillaLoad = (System.nanoTime() - loadStarted) / NANOS_PER_SECOND;
loaded.add(vanilla);
long vanillaHeap = settledHeap();
loadStarted = System.nanoTime();
HeadlessNativeRegistries exported = isolatedLoad(datapack);
double exportLoad = (System.nanoTime() - loadStarted) / NANOS_PER_SECOND;
loaded.add(exported);
long exportHeap = settledHeap();
log(String.format(Locale.ROOT, "registries loaded: vanilla %.2f s, export %.2f s", vanillaLoad, exportLoad));
WorldgenStageBench vanillaBench = new WorldgenStageBench(new WorldgenStageBench.Options("vanilla",
presetOverworld(vanilla.registries(), VANILLA_PRESET), vanilla.registries(), configuration.seed()));
warm(vanillaBench, configuration);
long vanillaSamplers = settledHeap();
WorldgenStageBench exportBench = new WorldgenStageBench(new WorldgenStageBench.Options("export",
exportedOverworld(exported.registries()), exported.registries(), configuration.seed()));
warm(exportBench, configuration);
long exportSamplers = settledHeap();
double loadStart = ManagementFactory.getOperatingSystemMXBean().getSystemLoadAverage();
Measurement vanillaMeasurement = new Measurement(vanillaBench);
Measurement exportMeasurement = new Measurement(exportBench);
measure(List.of(vanillaMeasurement, exportMeasurement), configuration);
double loadEnd = ManagementFactory.getOperatingSystemMXBean().getSystemLoadAverage();
Map<String, GeneratorStats> generators = new LinkedHashMap<>();
generators.put("vanilla", vanillaMeasurement.stats(vanillaLoad, vanillaHeap - baseline, vanillaSamplers - exportHeap));
generators.put("export", exportMeasurement.stats(exportLoad, exportHeap - vanillaHeap, exportSamplers - vanillaSamplers));
log("analyzing density function graphs");
Map<String, List<DensityCostAnalysis.RootCost>> roots = new LinkedHashMap<>();
roots.put("vanilla", onLargeStack("density-cost-vanilla", () -> analyzeRoots(vanillaBench.settings(), configuration.dimension())));
roots.put("export", onLargeStack("density-cost-export", () -> analyzeRoots(exportBench.settings(), configuration.dimension())));
Report report = new Report(configuration.pack().toString(), configuration.dimension(), configuration.seed(),
configuration.rounds() * configuration.side() * configuration.side(), configuration.warmup() * configuration.warmup(),
configuration.rounds(), new MachineLoad(Runtime.getRuntime().availableProcessors(), loadStart, loadEnd),
"single thread for chunk stages and calls, vanilla and export interleaved per round; registry load uses HeadlessNativeRegistries' 4-thread loader; export uses Iris's own pools",
"feature decoration not measured: Paper's applyBiomeDecoration needs a live ServerLevel and the headless feature world lives in test fixtures",
exportStats, generators, densityJson, roots, definitions());
writeReport(report, configuration.output());
System.out.println(table(report));
log("report written to " + configuration.output());
} finally {
for (HeadlessNativeRegistries registries : loaded) {
registries.close();
}
deleteTree(work);
}
}
private static ExportRun export(Configuration configuration, Path work) throws Exception {
Path exportRoot = Files.createDirectories(work.resolve("exports"));
StageClock clock = new StageClock();
WorldgenExportResult result;
long started;
long finished;
try (RealPackProbeSupport.Workspace workspace = RealPackProbeSupport.openWorkspace(new RealPackProbeSupport.WorkspaceOptions(
configuration.pack().toFile(), configuration.dimension(), LOG_PREFIX, new HeadlessNativeRuntime(), work))) {
WorldgenExportRequest request = WorldgenExportRequest.builder()
.pack(workspace.pack().toPath())
.dimension(configuration.dimension())
.exportRoot(exportRoot)
.objects(true)
.build();
started = System.nanoTime();
clock.start(started);
result = WorldgenDatapackExporter.create().export(request, clock);
finished = System.nanoTime();
}
if (!result.isSuccess()) {
throw new IllegalStateException("Export failed: " + result.message() + " " + result.diagnostics());
}
SortedMap<String, byte[]> files = zipEntries(result.output());
ExportStats stats = new ExportStats((finished - started) / NANOS_PER_SECOND, clock.seconds(finished), Files.size(result.output()),
files.size(), count(files, "/worldgen/density_function/"), bytes(files, "/worldgen/density_function/"),
count(files, "/worldgen/noise/"), count(files, "/worldgen/biome/"), result.diagnostics().size());
Files.delete(result.output());
return new ExportRun(stats, files);
}
private static void warm(WorldgenStageBench bench, Configuration configuration) {
long started = System.nanoTime();
bench.chunks(WARMUP_ORIGIN, WARMUP_ORIGIN, configuration.warmup(), new WorldgenStageBench.ChunkSamples());
WorldgenStageBench.CallBudget budget = new WorldgenStageBench.CallBudget(MINIMUM_CALLS * 4, MINIMUM_CALLS,
TimeUnit.SECONDS.toNanos(configuration.callSeconds()) / 4);
bench.uncachedBiome(budget, new WorldgenStageBench.CallSamples());
bench.baseHeight(budget, Heightmap.Types.WORLD_SURFACE_WG, new WorldgenStageBench.CallSamples());
bench.baseColumn(budget, new WorldgenStageBench.CallSamples());
log(String.format(Locale.ROOT, "%s warmed in %.1f s", bench.label(), (System.nanoTime() - started) / NANOS_PER_SECOND));
}
private static void measure(List<Measurement> measurements, Configuration configuration) {
int stride = configuration.side() + 4;
WorldgenStageBench.CallBudget budget = new WorldgenStageBench.CallBudget(
Math.max(1, configuration.calls() / configuration.rounds()), Math.max(1, MINIMUM_CALLS / configuration.rounds()),
TimeUnit.SECONDS.toNanos(configuration.callSeconds()) / configuration.rounds());
for (int round = 0; round < configuration.rounds(); round++) {
for (int index = 0; index < measurements.size(); index++) {
Measurement measurement = measurements.get((index + round) % measurements.size());
long started = System.nanoTime();
measurement.bench.chunks(round * stride, round * stride, configuration.side(), measurement.chunks);
measurement.bench.uncachedBiome(budget, measurement.uncachedBiome);
measurement.bench.baseHeight(budget, Heightmap.Types.WORLD_SURFACE_WG, measurement.surfaceHeight);
measurement.bench.baseHeight(budget, Heightmap.Types.OCEAN_FLOOR_WG, measurement.floorHeight);
measurement.bench.baseColumn(budget, measurement.column);
log(String.format(Locale.ROOT, "%s round %d measured in %.1f s", measurement.bench.label(), round,
(System.nanoTime() - started) / NANOS_PER_SECOND));
}
}
}
private static List<DensityCostAnalysis.RootCost> analyzeRoots(NoiseGeneratorSettings settings, String dimension) {
DensityCostAnalysis analysis = new DensityCostAnalysis(id -> DensityJsonBreakdown.category(dimension, id), TOP_OFFENDERS);
Map<String, DensityFunction> roots = new LinkedHashMap<>();
NoiseRouter router = settings.noiseRouter();
roots.put("temperature", router.temperature());
roots.put("vegetation", router.vegetation());
roots.put("continents", router.continents());
roots.put("erosion", router.erosion());
roots.put("depth", router.depth());
roots.put("ridges", router.ridges());
roots.put("chunk_surface_level", router.chunkSurfaceLevel());
roots.put("final_density", router.finalDensity());
if (settings.aquifers().isPresent()) {
Aquifer.Config aquifer = settings.aquifers().get();
roots.put("aquifer.barrier", aquifer.barrierNoise());
roots.put("aquifer.fluid_level_floodedness", aquifer.fluidLevelFloodednessNoise());
roots.put("aquifer.fluid_level_spread", aquifer.fluidLevelSpreadNoise());
roots.put("aquifer.lava", aquifer.lavaNoise());
roots.put("aquifer.exclusion", aquifer.exclusion());
roots.put("aquifer.surface_level", aquifer.surfaceLevel());
}
List<DensityCostAnalysis.RootCost> costs = new ArrayList<>(roots.size());
for (Map.Entry<String, DensityFunction> root : roots.entrySet()) {
costs.add(analysis.analyze(root.getKey(), root.getValue()));
}
return costs;
}
static LevelStem presetOverworld(RegistryAccess registries, String preset) {
return registries.lookupOrThrow(Registries.WORLD_PRESET)
.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, Identifier.parse(preset))).value().overworld()
.orElseThrow(() -> new IllegalStateException("World preset " + preset + " has no overworld"));
}
static LevelStem exportedOverworld(RegistryAccess registries) {
return registries.lookupOrThrow(Registries.LEVEL_STEM).getOrThrow(LevelStem.OVERWORLD).value();
}
private static HeadlessNativeRegistries isolatedLoad(Path directory) {
try (PaperRegistryScope scope = PaperRegistryScope.open()) {
return HeadlessNativeRegistries.load(directory);
}
}
private static long settledHeap() throws InterruptedException {
for (int pass = 0; pass < 4; pass++) {
System.gc();
Thread.sleep(200L);
}
return ManagementFactory.getMemoryMXBean().getHeapMemoryUsage().getUsed();
}
private static <T> T onLargeStack(String name, Callable<T> work) throws Exception {
AtomicReference<T> result = new AtomicReference<>();
AtomicReference<Throwable> failure = new AtomicReference<>();
Thread thread = new Thread(null, () -> {
try {
result.set(work.call());
} catch (Throwable problem) {
failure.set(problem);
}
}, name, ANALYSIS_STACK_BYTES);
thread.start();
thread.join();
if (failure.get() instanceof Exception exception) {
throw exception;
}
if (failure.get() instanceof Error error) {
throw error;
}
return result.get();
}
private static SortedMap<String, byte[]> zipEntries(Path zip) throws IOException {
SortedMap<String, byte[]> files = new TreeMap<>();
try (ZipFile archive = new ZipFile(zip.toFile())) {
Enumeration<? extends ZipEntry> entries = archive.entries();
while (entries.hasMoreElements()) {
ZipEntry entry = entries.nextElement();
if (!entry.isDirectory()) {
files.put(entry.getName(), archive.getInputStream(entry).readAllBytes());
}
}
}
return files;
}
private static void writeFiles(SortedMap<String, byte[]> files, Path directory) throws IOException {
Files.createDirectories(directory);
for (Map.Entry<String, byte[]> file : files.entrySet()) {
Path target = directory.resolve(file.getKey()).normalize();
if (!target.startsWith(directory)) {
throw new IllegalArgumentException("Export file escapes the datapack root: " + file.getKey());
}
Files.createDirectories(target.getParent());
Files.write(target, file.getValue());
}
}
private static int count(SortedMap<String, byte[]> files, String folder) {
int count = 0;
for (String path : files.keySet()) {
if (path.contains(folder) && path.endsWith(".json")) {
count++;
}
}
return count;
}
private static long bytes(SortedMap<String, byte[]> files, String folder) {
long bytes = 0;
for (Map.Entry<String, byte[]> file : files.entrySet()) {
if (file.getKey().contains(folder) && file.getKey().endsWith(".json")) {
bytes += file.getValue().length;
}
}
return bytes;
}
private static void writeReport(Report report, Path output) throws IOException {
Files.createDirectories(output.getParent());
Gson gson = new GsonBuilder().setPrettyPrinting().serializeSpecialFloatingPointValues().create();
Files.writeString(output, gson.toJson(report), StandardCharsets.UTF_8);
}
private static Map<String, String> definitions() {
Map<String, String> definitions = new LinkedHashMap<>();
definitions.put("chunks", "per stage: wall ms and current-thread CPU ms per chunk; rounds of side x side chunks alternate vanilla and export");
definitions.put("chunks.biomes", "ChunkGenerator.createBiomes on a fresh ProtoChunk, ms per chunk over the grid plus a one-chunk ring");
definitions.put("chunks.noise", "NoiseChunk construction (aquifer init) plus NoiseBasedChunkGenerator.doFill, ms per chunk");
definitions.put("chunks.surface", "MaterialSystem.buildSurface with the fill's NoiseChunk and a region biome manager, ms per chunk");
definitions.put("chunks.carverBiome", "one uncached BiomeSource lookup at the chunk origin, as ChunkAccess.carverBiome memoizes per chunk, ms per chunk");
definitions.put("uncachedBiome", "BiomeSource.createUncachedResolver(...).getNoiseBiome at random quart positions within 32k blocks, wall us per call and mean thread CPU us per call");
definitions.put("baseHeight", "NoiseBasedChunkGenerator.getBaseHeight as structure placement calls it, us per call");
definitions.put("baseColumn", "NoiseBasedChunkGenerator.getBaseColumn, us per call");
definitions.put("retainedHeapBytes", "used heap after four System.gc passes, delta across the registry load");
definitions.put("samplerHeapBytes", "used heap delta across RandomState creation and warmup (compiled samplers and buffer pools)");
definitions.put("roots.dagNodes", "distinct non-constant density function and spline nodes reachable, references shared");
definitions.put("roots.cachedNodes", "non-constant evaluations per point with caches enabled: every distinct cache body once, all branches");
definitions.put("roots.expandedNodes", "non-constant evaluations per point with every reference and cache re-expanded, all branches");
definitions.put("roots.uncachedPointWorst", "worst single uncached point evaluation with lazy splines, interval_select and range_choice; interpolated off-lattice costs 8 corners at expanded cost; find_top_surface counts its probe steps");
definitions.put("roots.dagShare", "share of the distinct nodes by the category of the named file that holds them; offset copies are Grad2 and Taps duplicates");
definitions.put("roots.exclusive", "named density functions ranked by multiplicity x own nodes in the expanded tree");
definitions.put("roots.inclusive", "named density functions ranked by multiplicity x subtree nodes in the expanded tree");
definitions.put("densityJson.structurallyDuplicateBytes", "bytes beyond one file per JSON shape, with numbers, density function references and noise ids abstracted");
return definitions;
}
private static String table(Report report) {
StringBuilder out = new StringBuilder(4096);
GeneratorStats vanilla = report.generators().get("vanilla");
GeneratorStats export = report.generators().get("export");
out.append(String.format(Locale.ROOT, "%s %s seed %d: %d chunks in %d interleaved rounds after %d warmup chunks, one thread, load %.0f-%.0f on %d cores%n",
LOG_PREFIX, report.dimension(), report.seed(), report.chunks(), report.rounds(), report.warmupChunks(),
report.load().loadAverageStart(), report.load().loadAverageEnd(), report.load().processors()));
out.append(String.format(Locale.ROOT, "%-34s %10s %10s %8s %10s %10s %8s%n", "stage (mean)", "vanilla", "export", "ratio",
"cpu van", "cpu exp", "ratio"));
out.append(String.format(Locale.ROOT, "%-34s %10s %10.1f%n", "export (s)", "-", report.export().seconds()));
row(out, "registry load (s)", vanilla.registryLoadSeconds(), export.registryLoadSeconds());
row(out, "retained heap after load (MB)", vanilla.retainedHeapBytes() / MEGABYTE, export.retainedHeapBytes() / MEGABYTE);
row(out, "sampler heap after warmup (MB)", vanilla.samplerHeapBytes() / MEGABYTE, export.samplerHeapBytes() / MEGABYTE);
stage(out, "biome fill (ms/chunk)", vanilla.chunks().biomes(), export.chunks().biomes());
stage(out, "noise fill (ms/chunk)", vanilla.chunks().noise(), export.chunks().noise());
stage(out, "surface (ms/chunk)", vanilla.chunks().surface(), export.chunks().surface());
stage(out, "carver biome lookup (ms/chunk)", vanilla.chunks().carverBiome(), export.chunks().carverBiome());
row(out, "chunk total (ms/chunk)", vanilla.chunks().totalMean(), export.chunks().totalMean(),
vanilla.chunks().totalCpuMean(), export.chunks().totalCpuMean());
call(out, "uncached getNoiseBiome (us/call)", vanilla.uncachedBiome(), export.uncachedBiome());
call(out, "getBaseHeight WORLD_SURFACE (us)", vanilla.baseHeightSurface(), export.baseHeightSurface());
call(out, "getBaseHeight OCEAN_FLOOR (us)", vanilla.baseHeightFloor(), export.baseHeightFloor());
call(out, "getBaseColumn (us/call)", vanilla.baseColumn(), export.baseColumn());
out.append(String.format(Locale.ROOT, "%-24s %9s %9s %9s %10s %10s %10s %10s%n", "router root", "dag(v)", "dag",
"cached", "expand(v)", "expanded", "uncPt(v)", "uncachedPt"));
List<DensityCostAnalysis.RootCost> vanillaRoots = report.roots().get("vanilla");
List<DensityCostAnalysis.RootCost> exportRoots = report.roots().get("export");
for (DensityCostAnalysis.RootCost root : exportRoots) {
DensityCostAnalysis.RootCost reference = find(vanillaRoots, root.root());
out.append(String.format(Locale.ROOT, "%-24s %9s %9s %9s %10s %10s %10s %10s%n", root.root(),
reference == null ? "-" : compact(reference.dagNodes()), compact(root.dagNodes()), compact(root.cachedNodes()),
reference == null ? "-" : compact(reference.expandedNodes()), compact(root.expandedNodes()),
reference == null ? "-" : compact(reference.uncachedPointWorst()), compact(root.uncachedPointWorst())));
}
out.append("top expanded offenders (exclusive share of root, multiplicity)\n");
for (DensityCostAnalysis.RootCost root : exportRoots) {
if (root.exclusive().isEmpty() || root.expandedNodes() < 1.0e4) {
continue;
}
StringBuilder line = new StringBuilder(String.format(Locale.ROOT, " %-22s", root.root()));
for (int index = 0; index < Math.min(2, root.exclusive().size()); index++) {
DensityCostAnalysis.Offender offender = root.exclusive().get(index);
line.append(String.format(Locale.ROOT, " %4.1f%% %s x%s;", 100.0 * offender.contribution() / root.expandedNodes(),
shortName(offender.name()), compact(offender.multiplicity())));
}
out.append(line).append('\n');
}
for (DensityCostAnalysis.RootCost root : exportRoots) {
if (root.dagNodes() < 1.0e4) {
continue;
}
List<Map.Entry<String, Double>> shares = new ArrayList<>(root.dagShare().entrySet());
shares.sort(Map.Entry.<String, Double>comparingByValue().reversed());
StringBuilder line = new StringBuilder(String.format(Locale.ROOT, " dag %-18s", root.root()));
for (int index = 0; index < Math.min(3, shares.size()); index++) {
line.append(String.format(Locale.ROOT, " %4.1f%% %s;", 100.0 * shares.get(index).getValue(), shares.get(index).getKey()));
}
out.append(line).append('\n');
}
DensityJsonBreakdown.Report json = report.densityJson();
out.append(String.format(Locale.ROOT, "density function JSON %.0f MB in %d files, %d cellular instances in %d shapes%n",
json.bytes() / MEGABYTE, json.files(), json.cellularInstances(), json.cellularShapes().size()));
for (int index = 0; index < Math.min(9, json.categories().size()); index++) {
DensityJsonBreakdown.CategoryBytes category = json.categories().get(index);
out.append(String.format(Locale.ROOT, " %-22s %8.1f MB %7d files %7d shapes %8.1f MB dup%n", category.category(),
category.bytes() / MEGABYTE, category.files(), category.distinctShapes(), category.structurallyDuplicateBytes() / MEGABYTE));
}
for (int index = 0; index < Math.min(4, json.libraryOwners().size()); index++) {
DensityJsonBreakdown.Bytes owner = json.libraryOwners().get(index);
out.append(String.format(Locale.ROOT, " %-48s %8.1f MB%n", owner.name(), owner.bytes() / MEGABYTE));
}
return out.toString();
}
private static void row(StringBuilder out, String label, double vanilla, double export) {
out.append(String.format(Locale.ROOT, "%-34s %10.3f %10.3f %8s%n", label, vanilla, export, ratio(vanilla, export)));
}
private static void row(StringBuilder out, String label, double vanilla, double export, double vanillaCpu, double exportCpu) {
out.append(String.format(Locale.ROOT, "%-34s %10.3f %10.3f %8s %10.3f %10.3f %8s%n", label, vanilla, export, ratio(vanilla, export),
vanillaCpu, exportCpu, ratio(vanillaCpu, exportCpu)));
}
private static void stage(StringBuilder out, String label, WorldgenStageBench.StageTiming vanilla, WorldgenStageBench.StageTiming export) {
row(out, label, vanilla.wall().mean(), export.wall().mean(), vanilla.cpu().mean(), export.cpu().mean());
}
private static void call(StringBuilder out, String label, WorldgenStageBench.CallTiming vanilla, WorldgenStageBench.CallTiming export) {
row(out, label, vanilla.wall().mean(), export.wall().mean(), vanilla.cpuMean(), export.cpuMean());
}
private static String ratio(double vanilla, double export) {
return vanilla > 0.0 ? String.format(Locale.ROOT, "%.1fx", export / vanilla) : "-";
}
private static DensityCostAnalysis.RootCost find(List<DensityCostAnalysis.RootCost> roots, String name) {
for (DensityCostAnalysis.RootCost root : roots) {
if (root.root().equals(name)) {
return root;
}
}
return null;
}
private static String shortName(String name) {
int separator = name.indexOf(':');
String path = separator < 0 ? name : name.substring(separator + 1);
return path.length() <= 34 ? path : "..." + path.substring(path.length() - 31);
}
private static String compact(double value) {
if (value < 1.0e4) {
return String.format(Locale.ROOT, "%.0f", value);
}
return String.format(Locale.ROOT, "%.2e", value);
}
private static void log(String message) {
System.out.println(LOG_PREFIX + " " + message);
}
private static void deleteTree(Path root) throws IOException {
if (!Files.exists(root)) {
return;
}
HeadlessNativeRegistries.delete(root);
}
record Configuration(Path pack, String dimension, long seed, int side, int rounds, int warmup, int calls, long callSeconds,
Path output, Path work) {
Configuration {
if (side < 1 || rounds < 1 || warmup < 1 || calls < MINIMUM_CALLS || callSeconds < 1) {
throw new IllegalArgumentException("side, rounds, warmup and callSeconds must be positive and calls at least " + MINIMUM_CALLS);
}
}
static Configuration parse(String[] arguments) {
if (arguments.length != 10) {
throw new IllegalArgumentException("Usage: pack dimension seed side rounds warmupSide calls callSeconds output work");
}
return new Configuration(new File(arguments[0]).toPath().toAbsolutePath(), arguments[1], Long.parseLong(arguments[2]),
Integer.parseInt(arguments[3]), Integer.parseInt(arguments[4]), Integer.parseInt(arguments[5]),
Integer.parseInt(arguments[6]), Long.parseLong(arguments[7]), new File(arguments[8]).toPath().toAbsolutePath(),
new File(arguments[9]).toPath().toAbsolutePath());
}
}
record ExportStats(double seconds, Map<String, Double> stageSeconds, long zipBytes, int files, int densityFunctions,
long densityFunctionBytes, int noises, int biomes, int diagnostics) {
}
record GeneratorStats(double registryLoadSeconds, long retainedHeapBytes, long samplerHeapBytes, WorldgenStageBench.ChunkTimings chunks,
WorldgenStageBench.CallTiming uncachedBiome, WorldgenStageBench.CallTiming baseHeightSurface,
WorldgenStageBench.CallTiming baseHeightFloor, WorldgenStageBench.CallTiming baseColumn) {
}
record MachineLoad(int processors, double loadAverageStart, double loadAverageEnd) {
}
record Report(String pack, String dimension, long seed, int chunks, int warmupChunks, int rounds, MachineLoad load,
String threading, String decoration,
ExportStats export, Map<String, GeneratorStats> generators, DensityJsonBreakdown.Report densityJson,
Map<String, List<DensityCostAnalysis.RootCost>> roots, Map<String, String> definitions) {
}
private record ExportRun(ExportStats stats, SortedMap<String, byte[]> files) {
}
private static final class Measurement {
private final WorldgenStageBench bench;
private final WorldgenStageBench.ChunkSamples chunks = new WorldgenStageBench.ChunkSamples();
private final WorldgenStageBench.CallSamples uncachedBiome = new WorldgenStageBench.CallSamples();
private final WorldgenStageBench.CallSamples surfaceHeight = new WorldgenStageBench.CallSamples();
private final WorldgenStageBench.CallSamples floorHeight = new WorldgenStageBench.CallSamples();
private final WorldgenStageBench.CallSamples column = new WorldgenStageBench.CallSamples();
private Measurement(WorldgenStageBench bench) {
this.bench = bench;
}
private GeneratorStats stats(double loadSeconds, long retainedHeap, long samplerHeap) {
return new GeneratorStats(loadSeconds, retainedHeap, samplerHeap, chunks.timings(), uncachedBiome.timing(),
surfaceHeight.timing(), floorHeight.timing(), column.timing());
}
}
private static final class StageClock implements WorldgenExportProgress {
private final Map<String, Long> starts = new LinkedHashMap<>();
private long origin;
private void start(long nanos) {
origin = nanos;
}
@Override
public void stage(String stage) {
starts.put(stage, System.nanoTime());
}
@Override
public void resources(int done, int total) {
}
private Map<String, Double> seconds(long finished) {
Map<String, Double> seconds = new LinkedHashMap<>();
String previous = "setup";
long previousStart = origin;
for (Map.Entry<String, Long> entry : starts.entrySet()) {
seconds.put(previous, (entry.getValue() - previousStart) / NANOS_PER_SECOND);
previous = entry.getKey();
previousStart = entry.getValue();
}
seconds.put(previous, (finished - previousStart) / NANOS_PER_SECOND);
return seconds;
}
}
}
@@ -0,0 +1,331 @@
package art.arcane.iris.probe;
import art.arcane.iris.generation.concurrent.MultiBurst;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier;
import art.arcane.iris.pack.datapack.export.WorldgenDatapackExporter;
import art.arcane.iris.pack.datapack.export.WorldgenExportMode;
import art.arcane.iris.pack.datapack.export.WorldgenExportProgress;
import art.arcane.iris.pack.datapack.export.WorldgenExportRequest;
import art.arcane.iris.pack.datapack.export.WorldgenExportResult;
import art.arcane.iris.pack.datapack.export.WorldgenExportService;
import art.arcane.iris.pack.datapack.export.compile.surface.MaterialProgramCompiler;
import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic;
import art.arcane.iris.probe.fidelity.BiomeShareReport;
import art.arcane.iris.probe.fidelity.BiomeTypes;
import art.arcane.iris.probe.fidelity.ColumnSource;
import art.arcane.iris.probe.fidelity.ColumnSurvey;
import art.arcane.iris.probe.fidelity.FidelityComparison;
import art.arcane.iris.probe.fidelity.FidelityReport;
import art.arcane.iris.probe.fidelity.FidelitySampling;
import art.arcane.iris.probe.fidelity.FidelitySamples;
import art.arcane.iris.probe.fidelity.FidelityScanner;
import art.arcane.iris.probe.fidelity.FidelitySource;
import art.arcane.iris.probe.fidelity.FidelityStats;
import art.arcane.iris.probe.fidelity.FidelityThresholds;
import art.arcane.iris.probe.fidelity.IrisEngineColumnSource;
import art.arcane.iris.probe.fidelity.IrisEngineFidelitySource;
import art.arcane.iris.probe.fidelity.SurveyWindow;
import art.arcane.iris.probe.fidelity.VanillaNoiseColumnSource;
import art.arcane.iris.probe.fidelity.VanillaNoiseFidelitySource;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.SortedMap;
import java.util.TreeMap;
public final class DatapackFidelityProbe {
private static final String LOG_PREFIX = "[datapack-fidelity]";
private static final String USAGE = "Usage: datapackFidelityProbe pack dimension seed radiusChunks stride output surveyBlocks surveyStride surveySeeds mode";
private static final String TARGET_VERSION = "26.3";
private static final String EXPORT_FORMAT = "directory";
private static final String EXPORT_OUTPUT = "export";
private static final int MAX_STRIDE = 16;
private static final int CHUNK_WIDTH = 16;
private static final int ARGUMENTS = 10;
private static final String COLUMN_SCOPE = "columns";
private static final String CHUNK_SCOPE = "chunks";
private static final int EXIT_ERROR = 1;
private static final int EXIT_FIDELITY_FAILED = 3;
private DatapackFidelityProbe() {
}
public static void main(String[] arguments) {
int status;
try {
status = execute(arguments, WorldgenDatapackExporter.create()).passed() ? 0 : EXIT_FIDELITY_FAILED;
} catch (Throwable failure) {
failure.printStackTrace(System.err);
status = EXIT_ERROR;
}
System.out.flush();
System.err.flush();
System.exit(status);
}
static FidelityReport execute(String[] arguments, WorldgenExportService exporter) throws Exception {
Objects.requireNonNull(exporter, "exporter");
Configuration configuration = Configuration.parse(arguments);
System.out.println(LOG_PREFIX + " pack=" + configuration.pack() + " dimension=" + configuration.dimension()
+ " seed=" + configuration.seed() + " radiusChunks=" + configuration.radiusChunks() + " stride=" + configuration.stride()
+ " surveyBlocks=" + configuration.survey().size() + " surveyStride=" + configuration.survey().stride()
+ " surveySeeds=" + configuration.surveySeeds() + " mode=" + configuration.mode().label());
Outcome outcome = compare(configuration, exporter);
writeReport(configuration.output(), outcome.json());
System.out.print(outcome.table());
System.out.println(LOG_PREFIX + " report=" + configuration.output());
return outcome.report();
}
private static Outcome compare(Configuration configuration, WorldgenExportService exporter) throws Exception {
Path scratch = Files.createTempDirectory("iris-datapack-fidelity-");
try {
return compareIn(scratch, configuration, exporter);
} finally {
ProbeWorkspaceCleanup.deleteRecursively(scratch);
}
}
private static Outcome compareIn(Path scratch, Configuration configuration, WorldgenExportService exporter) throws Exception {
HeadlessNativeRuntime runtime = new HeadlessNativeRuntime();
try (RealPackProbeSupport.Workspace workspace = RealPackProbeSupport.openWorkspace(new RealPackProbeSupport.WorkspaceOptions(
configuration.pack().toFile(), configuration.dimension(), LOG_PREFIX, runtime, scratch))) {
WorldgenExportResult export = export(exporter, workspace.pack().toPath(), configuration.dimension(), scratch.resolve("exports"));
Target target = new Target(export.output(), WorldgenExportRequest.DEFAULT_NAMESPACE,
VanillaResourceIdentifier.sanitizePath(configuration.dimension()), export.loadKeyByBiomeId());
return switch (configuration.mode()) {
case FULL -> Outcome.of(compareAll(workspace, target, configuration));
case BIOMES -> Outcome.of(compareBiomes(workspace, target, configuration));
};
}
}
private static FidelityReport compareAll(RealPackProbeSupport.Workspace workspace, Target target, Configuration configuration) throws Exception {
List<Long> seeds = configuration.surveySeeds();
FidelityStats irisColumns = null;
FidelityStats exportColumns = null;
FidelitySamples chunks = null;
for (long seed : seeds) {
try (RealPackProbeSupport.EngineSession session = workspace.openEngine(seed, false, "fidelity-" + seed)) {
Surveyed surveyed = survey(session.engine(), target, seed, configuration.survey());
irisColumns = irisColumns == null ? surveyed.iris() : irisColumns.merge(surveyed.iris());
exportColumns = exportColumns == null ? surveyed.export() : exportColumns.merge(surveyed.export());
if (seed == configuration.seed()) {
chunks = scanChunks(session.engine(), target, configuration);
}
}
}
FidelitySampling columnSampling = new FidelitySampling(COLUMN_SCOPE, configuration.survey().size(), configuration.survey().stride(),
seeds, irisColumns.columns());
return FidelityComparison.compare(new FidelitySamples(irisColumns, exportColumns, columnSampling), chunks, FidelityThresholds.defaults());
}
private static BiomeShareReport compareBiomes(RealPackProbeSupport.Workspace workspace, Target target, Configuration configuration) throws Exception {
List<FidelitySamples> seeds = new ArrayList<>(configuration.seedCount());
Map<String, String> biomeTypes = Map.of();
for (long seed : configuration.surveySeeds()) {
try (RealPackProbeSupport.EngineSession session = workspace.openEngine(seed, false, "biomes-" + seed)) {
Engine engine = session.engine();
Surveyed surveyed = survey(engine, target, seed, configuration.survey());
if (biomeTypes.isEmpty()) {
biomeTypes = BiomeTypes.of(engine.getDimension().getAllRegions(engine), key -> engine.getData().getBiomeLoader().load(key));
}
seeds.add(new FidelitySamples(surveyed.iris(), surveyed.export(), new FidelitySampling(COLUMN_SCOPE, configuration.survey().size(),
configuration.survey().stride(), List.of(seed), surveyed.iris().columns())));
}
}
return FidelityComparison.compareBiomeShares(seeds, biomeTypes, FidelityThresholds.defaults());
}
private static Surveyed survey(Engine engine, Target target, long seed, SurveyWindow window) throws Exception {
try (ColumnSource iris = new IrisEngineColumnSource(engine);
ColumnSource vanilla = new VanillaNoiseColumnSource(target.sampler(seed), target.loadKeyByBiomeId())) {
requireSameHeightRange(iris.minY(), iris.maxY(), vanilla.minY(), vanilla.maxY());
return new Surveyed(surveyed(iris, window, seed), surveyed(vanilla, window, seed));
}
}
private static FidelityStats surveyed(ColumnSource source, SurveyWindow window, long seed) {
long started = System.nanoTime();
FidelityStats stats = ColumnSurvey.scan(source, window, MultiBurst.burst.parallelism());
System.out.println(LOG_PREFIX + " surveyed " + source.label() + " seed=" + seed + " columns=" + stats.columns()
+ " in " + (System.nanoTime() - started) / 1_000_000L + "ms");
return stats;
}
private static FidelitySamples scanChunks(Engine engine, Target target, Configuration configuration) throws Exception {
try (IrisEngineFidelitySource iris = new IrisEngineFidelitySource(engine);
VanillaNoiseFidelitySource vanilla = new VanillaNoiseFidelitySource(target.sampler(configuration.seed()), target.loadKeyByBiomeId(),
target.namespace() + ":" + MaterialProgramCompiler.WALL_SHELL)) {
requireSameHeightRange(iris.minY(), iris.maxY(), vanilla.minY(), vanilla.maxY());
FidelityStats irisStats = scan(iris, configuration);
FidelityStats exportStats = scan(vanilla, configuration);
FidelitySampling sampling = new FidelitySampling(CHUNK_SCOPE, (2 * configuration.radiusChunks() + 1) * CHUNK_WIDTH,
configuration.stride(), List.of(configuration.seed()), irisStats.columns());
return new FidelitySamples(irisStats, exportStats, sampling);
}
}
private static WorldgenExportResult export(WorldgenExportService exporter, Path pack, String dimension, Path exportRoot) throws IOException {
Files.createDirectories(exportRoot);
WorldgenExportRequest request = WorldgenExportRequest.builder()
.pack(pack)
.dimension(dimension)
.version(TARGET_VERSION)
.format(EXPORT_FORMAT)
.output(EXPORT_OUTPUT)
.exportRoot(exportRoot)
.build();
WorldgenExportResult result = exporter.export(request, WorldgenExportProgress.NONE);
if (!result.isSuccess()) {
throw new IllegalStateException("Datapack export of dimension '" + dimension + "' was " + result.status() + ": " + result.message());
}
System.out.println(LOG_PREFIX + " exported target=" + TARGET_VERSION + " biomes=" + result.loadKeyByBiomeId().size()
+ " diagnostics=" + result.diagnostics().size() + " " + diagnosticCounts(result.diagnostics()));
return result;
}
private static FidelityStats scan(FidelitySource source, Configuration configuration) {
long started = System.nanoTime();
FidelityStats stats = FidelityScanner.scan(source, 0, 0, configuration.radiusChunks(), configuration.stride(),
MultiBurst.burst.parallelism());
System.out.println(LOG_PREFIX + " sampled " + source.label() + " columns=" + stats.columns() + " terrainColumns=" + stats.heightColumns()
+ " in " + (System.nanoTime() - started) / 1_000_000L + "ms");
return stats;
}
private static void requireSameHeightRange(int irisMinY, int irisMaxY, int exportMinY, int exportMaxY) {
if (irisMinY != exportMinY || irisMaxY != exportMaxY) {
throw new IllegalStateException("Iris height range [" + irisMinY + ", " + irisMaxY
+ ") differs from the exported dimension range [" + exportMinY + ", " + exportMaxY + ")");
}
}
private static SortedMap<String, Integer> diagnosticCounts(List<ExportDiagnostic> diagnostics) {
SortedMap<String, Integer> counts = new TreeMap<>();
for (ExportDiagnostic diagnostic : diagnostics) {
counts.merge(diagnostic.limit().name(), 1, Integer::sum);
}
return counts;
}
private static void writeReport(Path output, String json) throws IOException {
Path parent = output.getParent();
Files.createDirectories(parent);
Path staged = Files.createTempFile(parent, ".datapack-fidelity-", ".json");
try {
Files.writeString(staged, json + "\n", StandardCharsets.UTF_8);
Files.move(staged, output, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} finally {
Files.deleteIfExists(staged);
}
}
private enum Mode {
FULL,
BIOMES;
private static Mode parse(String value) {
String normalized = value.trim().toLowerCase(Locale.ROOT);
for (Mode mode : values()) {
if (mode.label().equals(normalized)) {
return mode;
}
}
throw new IllegalArgumentException("mode must be full or biomes, got '" + value + "'");
}
private String label() {
return name().toLowerCase(Locale.ROOT);
}
}
private record Outcome(FidelityReport report, String json, String table) {
private static Outcome of(FidelityReport report) {
return new Outcome(report, report.toJson(), report.toTable());
}
private static Outcome of(BiomeShareReport report) {
return new Outcome(report.mean(), report.toJson(), report.toTable());
}
}
private record Surveyed(FidelityStats iris, FidelityStats export) {
}
private record Target(Path output, String namespace, String dimension, Map<String, String> loadKeyByBiomeId) {
private WorldgenExportSampler sampler(long seed) throws IOException {
return WorldgenExportSampler.open(output, WorldgenExportMode.OVERWORLD, namespace + ":" + dimension, seed);
}
}
private record Configuration(Path pack, String dimension, long seed, int radiusChunks, int stride, Path output, SurveyWindow survey,
int seedCount, Mode mode) {
private static Configuration parse(String[] arguments) {
if (arguments.length != ARGUMENTS) {
throw new IllegalArgumentException(USAGE);
}
Path pack = Path.of(arguments[0]).toAbsolutePath().normalize();
if (!Files.isDirectory(pack)) {
throw new IllegalArgumentException("Pack folder " + pack + " does not exist");
}
String dimension = arguments[1].trim();
if (dimension.isEmpty()) {
throw new IllegalArgumentException("Dimension key must not be blank");
}
long seed = parseLong(arguments[2], "seed");
int radiusChunks = parseInt(arguments[3], "radiusChunks");
if (radiusChunks < 0) {
throw new IllegalArgumentException("radiusChunks must be zero or positive, got " + radiusChunks);
}
int stride = parseInt(arguments[4], "stride");
if (stride < 1 || stride > MAX_STRIDE) {
throw new IllegalArgumentException("stride must be within [1, " + MAX_STRIDE + "], got " + stride);
}
Path output = Path.of(arguments[5]).toAbsolutePath().normalize();
if (Files.isDirectory(output)) {
throw new IllegalArgumentException("Report output " + output + " is a directory");
}
SurveyWindow survey = new SurveyWindow(0, 0, parseInt(arguments[6], "surveyBlocks"), parseInt(arguments[7], "surveyStride"));
int seedCount = parseInt(arguments[8], "surveySeeds");
if (seedCount < 1) {
throw new IllegalArgumentException("surveySeeds must be at least 1, got " + seedCount);
}
Mode mode = Mode.parse(arguments[9]);
return new Configuration(pack, dimension, seed, radiusChunks, stride, output, survey, seedCount, mode);
}
private List<Long> surveySeeds() {
List<Long> seeds = new ArrayList<>(seedCount);
for (int index = 0; index < seedCount; index++) {
seeds.add(seed + index);
}
return seeds;
}
private static long parseLong(String value, String name) {
try {
return Long.parseLong(value.trim());
} catch (NumberFormatException failure) {
throw new IllegalArgumentException(name + " must be an integer, got '" + value + "'", failure);
}
}
private static int parseInt(String value, String name) {
try {
return Integer.parseInt(value.trim());
} catch (NumberFormatException failure) {
throw new IllegalArgumentException(name + " must be an integer, got '" + value + "'", failure);
}
}
}
}
@@ -0,0 +1,349 @@
package art.arcane.iris.probe;
import net.minecraft.core.Holder;
import net.minecraft.util.CubicSpline;
import net.minecraft.world.level.levelgen.densityfunction.DensityFunction;
import net.minecraft.world.level.levelgen.densityfunction.DensityFunctions;
import net.minecraft.world.level.levelgen.densityfunction.generator.ConstantFunction;
import net.minecraft.world.level.levelgen.densityfunction.op.CacheFunction;
import net.minecraft.world.level.levelgen.densityfunction.op.FindTopSurfaceFunction;
import net.minecraft.world.level.levelgen.densityfunction.op.InterpolatedFunction;
import net.minecraft.world.level.levelgen.densityfunction.op.IntervalSelectFunction;
import net.minecraft.world.level.levelgen.densityfunction.op.RangeChoiceFunction;
import net.minecraft.world.level.levelgen.densityfunction.op.SplineFunction;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.function.Function;
final class DensityCostAnalysis {
private static final double LATTICE_CORNERS = 8.0;
private static final int MAX_SURFACE_STEPS = 4096;
private static final String INLINE = "<inline>";
private final Function<String, String> categories;
private final int topCount;
private final Map<Object, Costs> costs = new IdentityHashMap<>();
private final Map<DensityFunction, Body> bodies = new IdentityHashMap<>();
private final Map<DensityFunction, String> names = new IdentityHashMap<>();
DensityCostAnalysis(Function<String, String> categories, int topCount) {
this.categories = categories;
this.topCount = topCount;
}
RootCost analyze(String root, DensityFunction function) {
Costs total = cost(function);
Reach reach = new Reach();
reach(function, reach, categories.apply(rootName(function, root)));
double cached = total.cachedLocal();
for (CacheFunction cache : reach.caches.keySet()) {
cached += cost(cache.input()).cachedLocal();
}
Body rootBody = new Body();
local(function, rootBody, true);
Map<DensityFunction, Double> multiplicity = multiplicity(rootBody);
List<Offender> offenders = new ArrayList<>(multiplicity.size());
Map<String, Double> shares = new TreeMap<>();
double expanded = total.expanded();
shares.merge(categories.apply(rootName(function, root)), rootBody.self / expanded, Double::sum);
for (Map.Entry<DensityFunction, Double> entry : multiplicity.entrySet()) {
DensityFunction value = entry.getKey();
Body body = body(value);
String name = names.get(value);
String category = categories.apply(name);
double contribution = entry.getValue() * body.self;
shares.merge(category, contribution / expanded, Double::sum);
offenders.add(new Offender(name, category, entry.getValue(), body.self, cost(value).expanded(),
contribution, entry.getValue() * cost(value).expanded()));
}
List<Offender> exclusive = top(offenders, Comparator.comparingDouble(Offender::contribution).reversed());
List<Offender> inclusive = top(offenders, Comparator.comparingDouble(Offender::inclusive).reversed());
Map<String, Double> dagShares = new TreeMap<>();
for (Map.Entry<String, Double> entry : reach.categories.entrySet()) {
dagShares.put(entry.getKey(), entry.getValue() / Math.max(1L, reach.nodes));
}
return new RootCost(root, reach.nodes, cached, expanded, total.lazy(), multiplicity.size(), exclusive, inclusive, shares, dagShares);
}
private List<Offender> top(List<Offender> offenders, Comparator<Offender> order) {
List<Offender> sorted = new ArrayList<>(offenders);
sorted.sort(order);
return List.copyOf(sorted.subList(0, Math.min(topCount, sorted.size())));
}
private String rootName(DensityFunction function, String root) {
if (function instanceof DensityFunctions.HolderHolder(Holder<DensityFunction> holder)) {
return name(holder);
}
return root;
}
private Costs cost(DensityFunction function) {
if (function instanceof DensityFunctions.HolderHolder(Holder<DensityFunction> holder)) {
DensityFunction value = holder.value();
names.putIfAbsent(value, name(holder));
return cost(value);
}
Costs known = costs.get(function);
if (known != null) {
return known;
}
Costs computed = compute(function);
costs.put(function, computed);
return computed;
}
private Costs compute(DensityFunction function) {
return switch (function) {
case ConstantFunction ignored -> new Costs(0.0, 0.0, 0.0);
case CacheFunction cache -> {
Costs input = cost(cache.input());
yield new Costs(1.0 + input.expanded(), 1.0 + input.lazy(), 1.0);
}
case SplineFunction spline -> {
Costs inner = spline(spline.spline());
yield new Costs(1.0 + inner.expanded(), 1.0 + inner.lazy(), 1.0 + inner.cachedLocal());
}
case IntervalSelectFunction select -> {
Costs input = cost(select.input());
double expanded = 1.0 + input.expanded();
double cached = 1.0 + input.cachedLocal();
double lazy = 0.0;
for (DensityFunction branch : select.functions()) {
Costs branchCost = cost(branch);
expanded += branchCost.expanded();
cached += branchCost.cachedLocal();
lazy = Math.max(lazy, branchCost.lazy());
}
yield new Costs(expanded, 1.0 + input.lazy() + lazy, cached);
}
case RangeChoiceFunction choice -> {
Costs input = cost(choice.input());
Costs inside = cost(choice.whenInRange());
Costs outside = cost(choice.whenOutOfRange());
yield new Costs(1.0 + input.expanded() + inside.expanded() + outside.expanded(),
1.0 + input.lazy() + Math.max(inside.lazy(), outside.lazy()),
1.0 + input.cachedLocal() + inside.cachedLocal() + outside.cachedLocal());
}
case InterpolatedFunction grid -> {
Costs input = cost(grid.input());
yield new Costs(1.0 + input.expanded(), 1.0 + LATTICE_CORNERS * input.expanded(), 1.0 + input.cachedLocal());
}
case FindTopSurfaceFunction surface -> {
Costs density = cost(surface.density());
Costs upper = cost(surface.upperBound());
double steps = surfaceSteps(surface);
yield new Costs(1.0 + density.expanded() + upper.expanded(), 1.0 + upper.lazy() + steps * density.lazy(),
1.0 + density.cachedLocal() + upper.cachedLocal());
}
default -> {
double expanded = 1.0;
double lazy = 1.0;
double cached = 1.0;
for (DensityFunction child : children(function)) {
Costs childCost = cost(child);
expanded += childCost.expanded();
lazy += childCost.lazy();
cached += childCost.cachedLocal();
}
yield new Costs(expanded, lazy, cached);
}
};
}
private Costs spline(CubicSpline<SplineFunction.Coordinate> spline) {
if (!(spline instanceof CubicSpline.Multipoint<SplineFunction.Coordinate> multipoint)) {
return new Costs(0.0, 0.0, 0.0);
}
Costs known = costs.get(multipoint);
if (known != null) {
return known;
}
Costs coordinate = cost(multipoint.coordinate().function());
List<CubicSpline<SplineFunction.Coordinate>> values = multipoint.values();
double expanded = 1.0 + coordinate.expanded();
double cached = 1.0 + coordinate.cachedLocal();
double[] lazy = new double[values.size()];
for (int index = 0; index < values.size(); index++) {
Costs value = spline(values.get(index));
expanded += value.expanded();
cached += value.cachedLocal();
lazy[index] = value.lazy();
}
double worstPair = lazy.length == 1 ? lazy[0] : 0.0;
for (int index = 0; index + 1 < lazy.length; index++) {
worstPair = Math.max(worstPair, lazy[index] + lazy[index + 1]);
}
Costs computed = new Costs(expanded, 1.0 + coordinate.lazy() + worstPair, cached);
costs.put(multipoint, computed);
return computed;
}
private void reach(DensityFunction function, Reach reach, String category) {
if (function instanceof DensityFunctions.HolderHolder(Holder<DensityFunction> holder)) {
reach(holder.value(), reach, categories.apply(name(holder)));
return;
}
if (function instanceof ConstantFunction || reach.visited.put(function, Boolean.TRUE) != null) {
return;
}
reach.count(category);
if (function instanceof CacheFunction cache) {
reach.caches.put(cache, Boolean.TRUE);
}
if (function instanceof SplineFunction spline) {
reachSpline(spline.spline(), reach, category);
return;
}
for (DensityFunction child : children(function)) {
reach(child, reach, category);
}
}
private void reachSpline(CubicSpline<SplineFunction.Coordinate> spline, Reach reach, String category) {
if (!(spline instanceof CubicSpline.Multipoint<SplineFunction.Coordinate> multipoint)) {
return;
}
if (reach.visited.put(multipoint, Boolean.TRUE) != null) {
return;
}
reach.count(category);
reach(multipoint.coordinate().function(), reach, category);
for (CubicSpline<SplineFunction.Coordinate> value : multipoint.values()) {
reachSpline(value, reach, category);
}
}
private Body body(DensityFunction value) {
Body known = bodies.get(value);
if (known != null) {
return known;
}
Body body = new Body();
local(value, body, true);
bodies.put(value, body);
return body;
}
private void local(DensityFunction function, Body body, boolean top) {
if (function instanceof DensityFunctions.HolderHolder(Holder<DensityFunction> holder)) {
if (!top) {
DensityFunction value = holder.value();
names.putIfAbsent(value, name(holder));
body.references.merge(value, 1.0, Double::sum);
return;
}
local(holder.value(), body, true);
return;
}
if (function instanceof ConstantFunction) {
return;
}
body.self++;
if (function instanceof SplineFunction spline) {
localSpline(spline.spline(), body);
return;
}
for (DensityFunction child : children(function)) {
local(child, body, false);
}
}
private void localSpline(CubicSpline<SplineFunction.Coordinate> spline, Body body) {
if (!(spline instanceof CubicSpline.Multipoint<SplineFunction.Coordinate> multipoint)) {
return;
}
body.self++;
local(multipoint.coordinate().function(), body, false);
for (CubicSpline<SplineFunction.Coordinate> value : multipoint.values()) {
localSpline(value, body);
}
}
private Map<DensityFunction, Double> multiplicity(Body root) {
List<DensityFunction> order = new ArrayList<>();
Map<DensityFunction, Boolean> visited = new IdentityHashMap<>();
for (DensityFunction child : root.references.keySet()) {
postOrder(child, visited, order);
}
Collections.reverse(order);
Map<DensityFunction, Double> multiplicity = new IdentityHashMap<>(order.size() * 2);
for (Map.Entry<DensityFunction, Double> entry : root.references.entrySet()) {
multiplicity.merge(entry.getKey(), entry.getValue(), Double::sum);
}
for (DensityFunction value : order) {
double times = multiplicity.getOrDefault(value, 0.0);
for (Map.Entry<DensityFunction, Double> entry : body(value).references.entrySet()) {
multiplicity.merge(entry.getKey(), times * entry.getValue(), Double::sum);
}
}
return multiplicity;
}
private void postOrder(DensityFunction value, Map<DensityFunction, Boolean> visited, List<DensityFunction> order) {
if (visited.put(value, Boolean.TRUE) != null) {
return;
}
for (DensityFunction child : body(value).references.keySet()) {
postOrder(child, visited, order);
}
order.add(value);
}
private static double surfaceSteps(FindTopSurfaceFunction surface) {
double upper = surface.upperBound().range().max();
if (!Double.isFinite(upper)) {
return MAX_SURFACE_STEPS;
}
double steps = Math.floor((upper - surface.lowerBound()) / surface.cellHeight()) + 1.0;
return Math.clamp(steps, 1.0, MAX_SURFACE_STEPS);
}
private static List<DensityFunction> children(DensityFunction function) {
List<DensityFunction> children = new ArrayList<>(4);
function.rewriteChildren(child -> {
children.add(child);
return child;
});
return children;
}
private static String name(Holder<DensityFunction> holder) {
return holder.unwrapKey().map(key -> key.identifier().toString()).orElse(INLINE);
}
record Costs(double expanded, double lazy, double cachedLocal) {
}
record Offender(String name, String category, double multiplicity, double selfNodes, double subtreeNodes,
double contribution, double inclusive) {
}
record RootCost(String root, long dagNodes, double cachedNodes, double expandedNodes, double uncachedPointWorst,
int namedSubtrees, List<Offender> exclusive, List<Offender> inclusive, Map<String, Double> categoryShare,
Map<String, Double> dagShare) {
}
private static final class Reach {
private final Map<Object, Boolean> visited = new IdentityHashMap<>();
private final Map<CacheFunction, Boolean> caches = new IdentityHashMap<>();
private final Map<String, Double> categories = new TreeMap<>();
private long nodes;
private void count(String category) {
nodes++;
categories.merge(category, 1.0, Double::sum);
}
}
private static final class Body {
private final Map<DensityFunction, Double> references = new IdentityHashMap<>();
private double self;
}
}
@@ -0,0 +1,409 @@
package art.arcane.iris.probe;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.google.gson.JsonPrimitive;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.SortedMap;
import java.util.TreeMap;
final class DensityJsonBreakdown {
private static final String DENSITY_FUNCTIONS = "/worldgen/density_function/";
private static final String NOISES = "/worldgen/noise/";
private static final String JSON = ".json";
private static final String CELLULAR = "cellular";
private static final String NOISE_STYLE = "noise style";
private static final String UNREFERENCED = "unreferenced";
private static final String OFFSET_MARKER = "/offset_";
private static final String OFFSET_SUFFIX = " offset copies";
private static final int INSTANCE_SEGMENTS = 3;
private static final Set<String> ROUTER_ROOTS = Set.of("final_density", "temperature", "vegetation", "continents", "erosion",
"depth", "ridges", "chunk_surface_level", "barrier", "fluid_level_floodedness", "fluid_level_spread", "lava", "exclusion",
"surface_level", "spawn_elevation");
private static final long FNV_OFFSET = 0xcbf29ce484222325L;
private static final long FNV_PRIME = 0x100000001b3L;
private static final int TOP = 10;
private final String dimension;
private final String dimensionPrefix;
private final Map<String, Integer> categoryIndex = new HashMap<>();
private final List<String> categoryNames = new ArrayList<>();
private final Map<String, Entry> entries = new TreeMap<>();
private final Map<String, List<String>> parents = new HashMap<>();
private final Map<String, Long> owners = new HashMap<>();
private DensityJsonBreakdown(String dimension) {
this.dimension = dimension;
dimensionPrefix = dimension + "/";
}
static DensityJsonBreakdown analyze(SortedMap<String, byte[]> files, String dimension) {
DensityJsonBreakdown breakdown = new DensityJsonBreakdown(dimension);
breakdown.load(files);
return breakdown;
}
static String category(String dimension, String id) {
String base = baseCategory(dimension, id);
return id.contains(OFFSET_MARKER) ? base + OFFSET_SUFFIX : base;
}
private static String baseCategory(String dimension, String id) {
int separator = id.indexOf(':');
String path = separator < 0 ? id : id.substring(separator + 1);
String dimensionPrefix = dimension + "/";
if (path.startsWith(dimensionPrefix)) {
path = path.substring(dimensionPrefix.length());
}
if (ROUTER_ROOTS.contains(path)) {
return "router";
}
if (path.startsWith("cell/")) {
return CELLULAR;
}
if (path.startsWith("style/")) {
return NOISE_STYLE;
}
if (path.startsWith("biome/selector/")) {
return "biome selector";
}
if (path.startsWith("biome/carving/")) {
return "biome carving";
}
if (path.startsWith("biome/")) {
return "biome decision";
}
if (path.equals("height") || path.startsWith("height/") || path.startsWith("terrain/height/")) {
return "terrain height";
}
if (path.startsWith("terrain/generator/")) {
return "terrain generator";
}
if (path.startsWith("terrain/volume/")) {
return "terrain volumetric";
}
if (path.startsWith("terrain/")) {
return "terrain bands";
}
if (path.startsWith("cave")) {
return "caves";
}
if (path.startsWith("surface/")) {
return "surface";
}
int slash = path.indexOf('/');
return slash < 0 ? path : path.substring(0, slash);
}
Report report() {
Map<String, CategoryAccumulator> categories = new TreeMap<>();
Map<String, CategoryAccumulator> groups = new TreeMap<>();
Map<String, Long> libraryOwners = new TreeMap<>();
long totalBytes = 0;
long totalNodes = 0;
for (Map.Entry<String, Entry> item : entries.entrySet()) {
String id = item.getKey();
Entry entry = item.getValue();
totalBytes += entry.bytes;
totalNodes += entry.nodes;
String category = category(dimension, id);
categories.computeIfAbsent(category, ignored -> new CategoryAccumulator()).add(entry);
groups.computeIfAbsent(group(id), ignored -> new CategoryAccumulator()).add(entry);
if (isLibrary(category)) {
libraryOwners.merge(category + " via " + ownerLabel(owners(id)), entry.bytes, Long::sum);
}
}
List<CategoryBytes> categoryRows = rows(categories, Integer.MAX_VALUE);
List<CategoryBytes> groupRows = rows(groups, 40);
List<Bytes> ownerRows = new ArrayList<>();
for (Map.Entry<String, Long> owner : libraryOwners.entrySet()) {
ownerRows.add(new Bytes(owner.getKey(), owner.getValue()));
}
ownerRows.sort(Comparator.comparingLong(Bytes::bytes).reversed());
List<Bytes> largest = new ArrayList<>();
for (Map.Entry<String, Entry> item : entries.entrySet()) {
largest.add(new Bytes(item.getKey(), item.getValue().bytes));
}
largest.sort(Comparator.comparingLong(Bytes::bytes).reversed());
return new Report(entries.size(), totalBytes, totalNodes, categoryRows, groupRows, ownerRows,
List.copyOf(largest.subList(0, Math.min(TOP, largest.size()))), cellularInstances());
}
private void load(SortedMap<String, byte[]> files) {
Map<String, byte[]> bodies = new HashMap<>();
Set<String> noises = new HashSet<>();
for (Map.Entry<String, byte[]> file : files.entrySet()) {
String id = resourceId(file.getKey(), DENSITY_FUNCTIONS);
if (id != null) {
bodies.put(id, file.getValue());
continue;
}
String noise = resourceId(file.getKey(), NOISES);
if (noise != null) {
noises.add(noise);
}
}
for (Map.Entry<String, byte[]> body : bodies.entrySet()) {
JsonElement json = JsonParser.parseString(new String(body.getValue(), StandardCharsets.UTF_8));
Entry entry = new Entry(body.getValue().length);
entry.shape = shape(json, bodies.keySet(), noises, entry);
entries.put(body.getKey(), entry);
for (String reference : entry.references) {
parents.computeIfAbsent(reference, ignored -> new ArrayList<>()).add(body.getKey());
}
}
}
private long shape(JsonElement element, Set<String> functions, Set<String> noises, Entry entry) {
if (element.isJsonObject()) {
entry.nodes++;
JsonObject object = element.getAsJsonObject();
long hash = mix(FNV_OFFSET, '{');
for (String key : new TreeMap<>(object.asMap()).keySet()) {
hash = mix(mix(hash, key.hashCode()), shape(object.get(key), functions, noises, entry));
}
return mix(hash, '}');
}
if (element.isJsonArray()) {
JsonArray array = element.getAsJsonArray();
long hash = mix(FNV_OFFSET, '[');
for (JsonElement item : array) {
hash = mix(hash, shape(item, functions, noises, entry));
}
return mix(hash, ']');
}
if (element.isJsonPrimitive()) {
JsonPrimitive primitive = element.getAsJsonPrimitive();
if (primitive.isNumber()) {
return mix(FNV_OFFSET, 'N');
}
if (primitive.isString()) {
String value = primitive.getAsString();
if (functions.contains(value)) {
entry.references.add(value);
return mix(FNV_OFFSET, 'R');
}
if (noises.contains(value)) {
return mix(FNV_OFFSET, 'Z');
}
return mix(FNV_OFFSET, value.hashCode());
}
return mix(FNV_OFFSET, primitive.getAsBoolean() ? 'T' : 'F');
}
return mix(FNV_OFFSET, '0');
}
private List<CellularShape> cellularInstances() {
Map<String, List<String>> instances = new TreeMap<>();
for (String id : entries.keySet()) {
if (!isLibrary(category(dimension, id)) || !id.contains(":cell/")) {
continue;
}
instances.computeIfAbsent(instanceKey(id), ignored -> new ArrayList<>()).add(id);
}
Map<Long, CellularAccumulator> shapes = new HashMap<>();
for (Map.Entry<String, List<String>> instance : instances.entrySet()) {
List<String> members = instance.getValue();
members.sort(String::compareTo);
long hash = FNV_OFFSET;
long bytes = 0;
long owner = 0L;
StringBuilder layout = new StringBuilder();
for (String member : members) {
Entry entry = entries.get(member);
String suffix = member.substring(instance.getKey().length());
hash = mix(mix(hash, suffix.hashCode()), entry.shape);
bytes += entry.bytes;
owner |= owners(member);
if (!layout.isEmpty()) {
layout.append(',');
}
layout.append(suffix);
}
CellularAccumulator accumulator = shapes.computeIfAbsent(hash, ignored -> new CellularAccumulator(instance.getKey(), layout.toString()));
accumulator.instances++;
accumulator.bytes += bytes;
accumulator.owners |= owner;
}
List<CellularShape> rows = new ArrayList<>(shapes.size());
for (CellularAccumulator accumulator : shapes.values()) {
rows.add(new CellularShape(accumulator.example, accumulator.layout, accumulator.instances, accumulator.bytes, ownerLabel(accumulator.owners)));
}
rows.sort(Comparator.comparingLong(CellularShape::bytes).reversed());
return List.copyOf(rows);
}
private long owners(String id) {
Long known = owners.get(id);
if (known != null) {
return known;
}
owners.put(id, 0L);
long mask = 0L;
List<String> referrers = parents.get(id);
if (referrers == null) {
mask = bit(UNREFERENCED);
} else {
for (String parent : referrers) {
String category = category(dimension, parent);
mask |= isLibrary(category) ? owners(parent) : bit(category);
}
}
owners.put(id, mask);
return mask;
}
private long bit(String category) {
int index = categoryIndex.computeIfAbsent(category, name -> {
categoryNames.add(name);
return categoryNames.size() - 1;
});
if (index >= Long.SIZE) {
throw new IllegalStateException("More than " + Long.SIZE + " density function owner categories");
}
return 1L << index;
}
private String ownerLabel(long mask) {
if (mask == 0L) {
return UNREFERENCED;
}
List<String> labels = new ArrayList<>(Long.bitCount(mask));
for (int index = 0; index < categoryNames.size(); index++) {
if ((mask & (1L << index)) != 0L) {
labels.add(categoryNames.get(index));
}
}
labels.sort(String::compareTo);
return String.join("+", labels);
}
private String group(String id) {
int separator = id.indexOf(':');
String path = separator < 0 ? id : id.substring(separator + 1);
if (path.startsWith(dimensionPrefix)) {
path = path.substring(dimensionPrefix.length());
}
String[] segments = path.split("/");
if (segments.length == 1) {
return segments[0];
}
if (segments[0].equals("cell") || segments[0].equals("style")) {
return segments[0] + "/*/" + segments[segments.length - 1];
}
return segments[0] + "/" + segments[1];
}
private static boolean isLibrary(String category) {
return category.startsWith(CELLULAR) || category.startsWith(NOISE_STYLE);
}
private static String instanceKey(String id) {
int end = id.indexOf(':') + 1;
for (int segment = 0; segment < INSTANCE_SEGMENTS && end > 0; segment++) {
int slash = id.indexOf('/', end);
if (slash < 0) {
return id;
}
end = slash + 1;
}
return id.substring(0, end - 1);
}
private static List<CategoryBytes> rows(Map<String, CategoryAccumulator> accumulators, int limit) {
List<CategoryBytes> rows = new ArrayList<>(accumulators.size());
for (Map.Entry<String, CategoryAccumulator> item : accumulators.entrySet()) {
CategoryAccumulator accumulator = item.getValue();
long representative = 0;
for (long bytes : accumulator.representativeBytes.values()) {
representative += bytes;
}
rows.add(new CategoryBytes(item.getKey(), accumulator.files, accumulator.bytes, accumulator.nodes,
accumulator.representativeBytes.size(), accumulator.bytes - representative));
}
rows.sort(Comparator.comparingLong(CategoryBytes::bytes).reversed());
return List.copyOf(rows.subList(0, Math.min(limit, rows.size())));
}
private static String resourceId(String path, String folder) {
int marker = path.indexOf(folder);
if (!path.startsWith("data/") || marker < 0 || !path.endsWith(JSON)) {
return null;
}
return path.substring("data/".length(), marker) + ":" + path.substring(marker + folder.length(), path.length() - JSON.length());
}
private static long mix(long hash, long value) {
long mixed = hash ^ value;
mixed *= FNV_PRIME;
return mixed ^ (mixed >>> 29);
}
record Report(int files, long bytes, long nodes, List<CategoryBytes> categories, List<CategoryBytes> groups,
List<Bytes> libraryOwners, List<Bytes> largest, List<CellularShape> cellularShapes) {
int cellularInstances() {
int count = 0;
for (CellularShape shape : cellularShapes) {
count += shape.instances();
}
return count;
}
}
record CategoryBytes(String category, int files, long bytes, long nodes, int distinctShapes, long structurallyDuplicateBytes) {
}
record Bytes(String name, long bytes) {
}
record CellularShape(String example, String members, int instances, long bytes, String owners) {
}
private static final class Entry {
private final long bytes;
private final List<String> references = new ArrayList<>();
private long nodes;
private long shape;
private Entry(long bytes) {
this.bytes = bytes;
}
}
private static final class CategoryAccumulator {
private final Map<Long, Long> representativeBytes = new HashMap<>();
private int files;
private long bytes;
private long nodes;
private void add(Entry entry) {
files++;
bytes += entry.bytes;
nodes += entry.nodes;
representativeBytes.putIfAbsent(entry.shape, entry.bytes);
}
}
private static final class CellularAccumulator {
private final String example;
private final String layout;
private int instances;
private long bytes;
private long owners;
private CellularAccumulator(String example, String layout) {
this.example = example;
this.layout = layout;
}
}
}
@@ -1,6 +1,7 @@
package art.arcane.iris.probe; package art.arcane.iris.probe;
import art.arcane.iris.configuration.IrisSettings; import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.platform.ClasspathVanillaDataSource;
import art.arcane.iris.platform.bukkit.nms.BukkitGenerationRegistry; import art.arcane.iris.platform.bukkit.nms.BukkitGenerationRegistry;
import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.IrisPlatform;
import art.arcane.iris.spi.PlatformGenerationRegistry; import art.arcane.iris.spi.PlatformGenerationRegistry;
@@ -9,6 +10,7 @@ import art.arcane.iris.spi.PlatformBiomeWriter;
import art.arcane.iris.spi.PlatformRegistries; import art.arcane.iris.spi.PlatformRegistries;
import art.arcane.iris.spi.PlatformScheduler; import art.arcane.iris.spi.PlatformScheduler;
import art.arcane.iris.spi.PlatformStructureHooks; import art.arcane.iris.spi.PlatformStructureHooks;
import art.arcane.iris.spi.VanillaDataSource;
import art.arcane.volmlib.nativelib.entity.NativeEntityType; import art.arcane.volmlib.nativelib.entity.NativeEntityType;
import art.arcane.volmlib.nativelib.item.NativeItem; import art.arcane.volmlib.nativelib.item.NativeItem;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedBlockState; import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedBlockState;
@@ -35,6 +37,7 @@ final class HeadlessNativePlatform implements IrisPlatform, PlatformRegistries,
private final NativeRegistryAccess nativeAccess; private final NativeRegistryAccess nativeAccess;
private final PlatformBiomeWriter biomeWriter; private final PlatformBiomeWriter biomeWriter;
private final PlatformGenerationRegistry generationRegistry; private final PlatformGenerationRegistry generationRegistry;
private final ClasspathVanillaDataSource vanillaData = new ClasspathVanillaDataSource(() -> SharedConstants.class);
private final NativeBlockResolver blocks = new NativeBlockResolver(this); private final NativeBlockResolver blocks = new NativeBlockResolver(this);
private final NativeBlockState air = blocks.getAir(); private final NativeBlockState air = blocks.getAir();
private final ConcurrentHashMap<String, NativeBlockState> states = new ConcurrentHashMap<>(); private final ConcurrentHashMap<String, NativeBlockState> states = new ConcurrentHashMap<>();
@@ -215,6 +218,11 @@ final class HeadlessNativePlatform implements IrisPlatform, PlatformRegistries,
return biomeWriter; return biomeWriter;
} }
@Override
public VanillaDataSource vanillaData() {
return vanillaData;
}
@Override @Override
public File dataFolder() { public File dataFolder() {
return services.dataFolder(); return services.dataFolder();
@@ -4,6 +4,7 @@ import art.arcane.iris.pack.datapack.IrisDatapackCompiler;
import art.arcane.iris.pack.datapack.v263.DataFixerV263; import art.arcane.iris.pack.datapack.v263.DataFixerV263;
import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KList;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.RegistryAccess; import net.minecraft.core.RegistryAccess;
import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
@@ -25,6 +26,7 @@ import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.function.Function;
import java.util.stream.Stream; import java.util.stream.Stream;
final class HeadlessNativeRegistries implements AutoCloseable { final class HeadlessNativeRegistries implements AutoCloseable {
@@ -70,7 +72,7 @@ final class HeadlessNativeRegistries implements AutoCloseable {
} }
} }
private static HeadlessNativeRegistries load(Path directory) { static HeadlessNativeRegistries load(Path directory) {
PackLocationInfo location = new PackLocationInfo("iris-headless", Component.literal("Iris"), PackLocationInfo location = new PackLocationInfo("iris-headless", Component.literal("Iris"),
PackSource.WORLD, Optional.empty()); PackSource.WORLD, Optional.empty());
MultiPackResourceManager resources = new MultiPackResourceManager(PackType.SERVER_DATA, List.of( MultiPackResourceManager resources = new MultiPackResourceManager(PackType.SERVER_DATA, List.of(
@@ -78,12 +80,17 @@ final class HeadlessNativeRegistries implements AutoCloseable {
try { try {
RegistryAccess.Frozen builtins = RegistryAccess.fromRegistryOfRegistries(BuiltInRegistries.REGISTRY); RegistryAccess.Frozen builtins = RegistryAccess.fromRegistryOfRegistries(BuiltInRegistries.REGISTRY);
RegistryAccess.Frozen loaded; RegistryAccess.Frozen loaded;
RegistryAccess.Frozen dimensions;
try (ExecutorService executor = Executors.newFixedThreadPool(4)) { try (ExecutorService executor = Executors.newFixedThreadPool(4)) {
loaded = RegistryDataLoader.load(resources, builtins.listRegistries().toList(), loaded = RegistryDataLoader.load(resources, builtins.listRegistries().toList(),
RegistryDataLoader.WORLD_REGISTRIES, executor).join(); RegistryDataLoader.WORLD_REGISTRIES, executor).join();
List<HolderLookup.RegistryLookup<?>> dimensionContext =
Stream.concat(builtins.listRegistries(), loaded.listRegistries()).toList();
dimensions = RegistryDataLoader.load(resources, dimensionContext,
RegistryDataLoader.DIMENSION_REGISTRIES, executor).join();
} }
RegistryAccess.Frozen registries = new RegistryAccess.ImmutableRegistryAccess( RegistryAccess.Frozen registries = new RegistryAccess.ImmutableRegistryAccess(Stream.of(
Stream.concat(builtins.registries(), loaded.registries())).freeze(); builtins.registries(), loaded.registries(), dimensions.registries()).flatMap(Function.identity())).freeze();
HeadlessNativeBootstrap.bindComponents(registries); HeadlessNativeBootstrap.bindComponents(registries);
return new HeadlessNativeRegistries(directory, resources, registries); return new HeadlessNativeRegistries(directory, resources, registries);
} catch (RuntimeException failure) { } catch (RuntimeException failure) {
@@ -106,7 +113,7 @@ final class HeadlessNativeRegistries implements AutoCloseable {
delete(directory); delete(directory);
} }
private static void delete(Path directory) throws IOException { static void delete(Path directory) throws IOException {
try (Stream<Path> paths = Files.walk(directory)) { try (Stream<Path> paths = Files.walk(directory)) {
for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) { for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) {
Files.delete(path); Files.delete(path);
@@ -32,6 +32,7 @@ final class HeadlessNativeRuntime implements RealPackProbeSupport.RuntimeBinding
private final Path stagingRoot; private final Path stagingRoot;
private NativeRegionTerrainWriter writer; private NativeRegionTerrainWriter writer;
private HeadlessNativeRegistries nativeRegistries; private HeadlessNativeRegistries nativeRegistries;
private File preparedPack;
private HolderLookup.Provider registries; private HolderLookup.Provider registries;
HeadlessNativeRuntime() { HeadlessNativeRuntime() {
@@ -60,12 +61,16 @@ final class HeadlessNativeRuntime implements RealPackProbeSupport.RuntimeBinding
@Override @Override
public void prepare(IrisData data, IrisDimension dimension) { public void prepare(IrisData data, IrisDimension dimension) {
try { try {
if (nativeRegistries != null) { File pack = data.getDataFolder().getCanonicalFile();
if (nativeRegistries != null && !pack.equals(preparedPack)) {
throw new IllegalStateException("Headless native runtime supports one immutable pack per process"); throw new IllegalStateException("Headless native runtime supports one immutable pack per process");
} }
nativeRegistries = HeadlessNativeRegistries.compile(data.getDataFolder()); if (nativeRegistries == null) {
nativeRegistries = HeadlessNativeRegistries.compile(data.getDataFolder());
preparedPack = pack;
writer = new NativeRegionTerrainWriter(nativeRegistries.registries());
}
registries = nativeRegistries.registries(); registries = nativeRegistries.registries();
writer = new NativeRegionTerrainWriter(nativeRegistries.registries());
} catch (IOException failure) { } catch (IOException failure) {
throw new UncheckedIOException("Cannot load native generation registries", failure); throw new UncheckedIOException("Cannot load native generation registries", failure);
} }
@@ -92,6 +97,7 @@ final class HeadlessNativeRuntime implements RealPackProbeSupport.RuntimeBinding
if (nativeRegistries != null) { if (nativeRegistries != null) {
nativeRegistries.close(); nativeRegistries.close();
nativeRegistries = null; nativeRegistries = null;
preparedPack = null;
} }
} }
@@ -2,8 +2,9 @@ package art.arcane.iris.probe;
import art.arcane.iris.configuration.IrisSettings; import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.generation.concurrent.MultiBurst; import art.arcane.iris.generation.concurrent.MultiBurst;
import art.arcane.iris.generation.hydrology.HydrologyTileCache; import art.arcane.iris.generation.hydrology.engine.HydrologyPregeneration;
import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntime; import art.arcane.iris.generation.hydrology.plan.WindowKey;
import art.arcane.iris.world.pregen.PregenHydrology;
import art.arcane.iris.world.pregen.PregenPerformanceProfile; import art.arcane.iris.world.pregen.PregenPerformanceProfile;
import net.minecraft.core.registries.Registries; import net.minecraft.core.registries.Registries;
@@ -31,11 +32,16 @@ import java.nio.file.Path;
import java.nio.file.StandardCopyOption; import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption; import java.nio.file.StandardOpenOption;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.HexFormat; import java.util.HexFormat;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Set;
public final class OfflineRegionGenerator { public final class OfflineRegionGenerator {
private static final int CHUNKS_PER_HYDROLOGY_REGION = WindowKey.SIZE >> 4;
private OfflineRegionGenerator() { private OfflineRegionGenerator() {
} }
@@ -97,14 +103,13 @@ public final class OfflineRegionGenerator {
PregenPerformanceProfile.apply(session.engine()); PregenPerformanceProfile.apply(session.engine());
System.out.println("generationProfile=pregen noiseCacheSize=" + IrisSettings.get().getPerformance().getNoiseCacheSize() System.out.println("generationProfile=pregen noiseCacheSize=" + IrisSettings.get().getPerformance().getNoiseCacheSize()
+ " burstParallelism=" + MultiBurst.burst.parallelism() + " multicore=true"); + " burstParallelism=" + MultiBurst.burst.parallelism() + " multicore=true");
IrisHydrologyRuntime hydrology = session.engine().getComplex().getHydrologyRuntime(); try (HydrologyPregeneration pregeneration = PregenHydrology.open(session.engine());
try (HydrologyTileCache.PregenerationScope hydrologyScope = hydrology == null ? null
: hydrology.preparePregeneration(configuration.hydrologyArea());
HeadlessTerrainContext context = HeadlessTerrainContext.create(new HeadlessTerrainContext.Options( HeadlessTerrainContext context = HeadlessTerrainContext.create(new HeadlessTerrainContext.Options(
session.engine(), runtime, staged.resolve("templates"), configuration.levelKey(), session.engine(), runtime, staged.resolve("templates"), configuration.levelKey(),
configuration.noiseSettings(), spigot)); configuration.noiseSettings(), spigot));
RegionGenerationWindow workers = new RegionGenerationWindow(configuration.parallelism(), RegionGenerationWindow workers = new RegionGenerationWindow(configuration.parallelism(),
RegionGenerationWindow.Policy.STANDALONE)) { RegionGenerationWindow.Policy.STANDALONE)) {
PregenHydrology.prepareAll(pregeneration, configuration.hydrologyRegions());
HeadlessRegionTerrain.Session rolling = new HeadlessRegionTerrain.Session(workers); HeadlessRegionTerrain.Session rolling = new HeadlessRegionTerrain.Session(workers);
Path regionDirectory = session.engine().getTarget().getWorld().worldFolder().toPath().resolve("region"); Path regionDirectory = session.engine().getTarget().getWorld().worldFolder().toPath().resolve("region");
Files.createDirectory(regionDirectory); Files.createDirectory(regionDirectory);
@@ -239,14 +244,27 @@ public final class OfflineRegionGenerator {
} }
} }
HydrologyTileCache.PregenerationArea hydrologyArea() { List<long[]> hydrologyRegions() {
int halo = NativeRegionPlan.terrainHalo(); int halo = NativeRegionPlan.terrainHalo();
long minimumX = ((long) chunkX - halo) * 16L; Set<Long> seen = new HashSet<>();
long minimumZ = ((long) chunkZ - halo) * 16L; List<long[]> regions = new ArrayList<>();
long maximumX = ((long) chunkX + (long) width * regionsX + halo) * 16L - 1L; for (int row = 0; row < regionsZ; row++) {
long maximumZ = ((long) chunkZ + (long) width * regionsZ + halo) * 16L - 1L; for (int column = 0; column < regionsX; column++) {
return new HydrologyTileCache.PregenerationArea(Math.toIntExact((minimumX + maximumX) / 2L), int regionX = row % 2 == 0 ? column : regionsX - column - 1;
Math.toIntExact((minimumZ + maximumZ) / 2L), minimumX, minimumZ, maximumX, maximumZ); int minimumX = Math.floorDiv(chunkX + regionX * width - halo, CHUNKS_PER_HYDROLOGY_REGION);
int maximumX = Math.floorDiv(chunkX + (regionX + 1) * width - 1 + halo, CHUNKS_PER_HYDROLOGY_REGION);
int minimumZ = Math.floorDiv(chunkZ + row * width - halo, CHUNKS_PER_HYDROLOGY_REGION);
int maximumZ = Math.floorDiv(chunkZ + (row + 1) * width - 1 + halo, CHUNKS_PER_HYDROLOGY_REGION);
for (int hydrologyZ = minimumZ; hydrologyZ <= maximumZ; hydrologyZ++) {
for (int hydrologyX = minimumX; hydrologyX <= maximumX; hydrologyX++) {
if (seen.add(((long) hydrologyX << 32) | (hydrologyZ & 0xFFFFFFFFL))) {
regions.add(new long[]{hydrologyX, hydrologyZ});
}
}
}
}
}
return regions;
} }
static Configuration parse(String[] arguments) throws IOException { static Configuration parse(String[] arguments) throws IOException {
@@ -0,0 +1,39 @@
package art.arcane.iris.probe;
import io.papermc.paper.registry.PaperRegistryAccess;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;
final class PaperRegistryScope implements AutoCloseable {
private final Map<Object, Object> registries;
private final Map<Object, Object> previous;
private PaperRegistryScope(Map<Object, Object> registries) {
this.registries = registries;
this.previous = new HashMap<>(registries);
registries.clear();
}
static PaperRegistryScope open() {
return new PaperRegistryScope(registries());
}
@Override
public void close() {
registries.clear();
registries.putAll(previous);
}
@SuppressWarnings("unchecked")
private static Map<Object, Object> registries() {
try {
Field field = PaperRegistryAccess.class.getDeclaredField("registries");
field.setAccessible(true);
return (Map<Object, Object>) field.get(PaperRegistryAccess.instance());
} catch (ReflectiveOperationException failure) {
throw new IllegalStateException("Paper registry access has no registries map to isolate", failure);
}
}
}
@@ -0,0 +1,301 @@
package art.arcane.iris.probe;
import art.arcane.iris.pack.datapack.export.WorldgenExportMode;
import net.minecraft.core.Holder;
import net.minecraft.core.QuartPos;
import net.minecraft.core.RegistryAccess;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.util.datafix.DataFixers;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.LevelHeightAccessor;
import net.minecraft.world.level.StructureManager;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.biome.BiomeManager;
import net.minecraft.world.level.biome.BiomeResolver;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.chunk.PalettedContainerFactory;
import net.minecraft.world.level.chunk.ProtoChunk;
import net.minecraft.world.level.chunk.UpgradeData;
import net.minecraft.world.level.dimension.DimensionType;
import net.minecraft.world.level.dimension.LevelStem;
import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
import net.minecraft.world.level.levelgen.NoiseChunk;
import net.minecraft.world.level.levelgen.NoiseGeneratorSettings;
import net.minecraft.world.level.levelgen.NoiseSettings;
import net.minecraft.world.level.levelgen.RandomState;
import net.minecraft.world.level.levelgen.WorldGenerationContext;
import net.minecraft.world.level.levelgen.blending.Blender;
import net.minecraft.world.level.levelgen.densityfunction.DensityFunction;
import net.minecraft.world.level.levelgen.densityfunction.DensityVolume;
import net.minecraft.world.level.levelgen.densityfunction.SamplerContext;
import net.minecraft.world.level.levelgen.densityfunction.ScopedDensityBuffer;
import net.minecraft.world.level.levelgen.structure.Structure;
import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager;
import net.minecraft.world.level.storage.LevelStorageSource;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.SortedMap;
import java.util.SortedSet;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.function.Predicate;
import java.util.stream.Stream;
public final class WorldgenExportSampler implements AutoCloseable {
private static final String DENSITY_FUNCTIONS = "/worldgen/density_function/";
private static final int CHUNK_WIDTH = 16;
private static final Method CREATE_NOISE_CHUNK = generatorMethod("createNoiseChunk", ChunkAccess.class, StructureManager.class, Blender.class,
RandomState.class, NoiseSettings.class);
private static final Method DO_FILL = generatorMethod("doFill", NoiseChunk.class, ChunkAccess.class);
private final HeadlessNativeRegistries loaded;
private final NoiseBasedChunkGenerator generator;
private final RandomState randomState;
private final ThreadLocal<BiomeResolver> resolvers;
private final LevelHeightAccessor heights;
private final SortedSet<String> densityFunctionIds;
private final ThreadLocal<SamplerContext> densityContexts = ThreadLocal.withInitial(() -> SamplerContext.builder().enableCaches().build());
private final PalettedContainerFactory containers;
private final long seed;
private final long biomeZoomSeed;
private WorldgenExportSampler(HeadlessNativeRegistries loaded, LevelStem stem, long seed, SortedSet<String> densityFunctionIds) {
this.loaded = loaded;
if (!(stem.generator() instanceof NoiseBasedChunkGenerator noiseGenerator)) {
throw new IllegalStateException("Exported dimension uses " + stem.generator().getClass().getSimpleName() + ", not a noise generator");
}
this.generator = noiseGenerator;
this.randomState = RandomState.create(loaded.registries().lookupOrThrow(Registries.NOISE), seed, noiseGenerator.generatorSettings().value());
this.resolvers = ThreadLocal.withInitial(() -> noiseGenerator.getBiomeSource().createCachingResolver(randomState));
DimensionType type = stem.type().value();
this.heights = LevelHeightAccessor.create(type.minY(), type.height());
this.densityFunctionIds = densityFunctionIds;
this.containers = PalettedContainerFactory.create(loaded.registries());
this.seed = seed;
this.biomeZoomSeed = BiomeManager.obfuscateSeed(seed);
}
public static WorldgenExportSampler open(Map<String, byte[]> files, WorldgenExportMode mode, String dimensionId, long seed) throws IOException {
Objects.requireNonNull(mode);
Identifier dimension = Identifier.parse(dimensionId);
HeadlessNativeBootstrap.initialize();
Path directory = Files.createTempDirectory("iris-worldgen-export-");
HeadlessNativeRegistries loaded;
try {
for (Map.Entry<String, byte[]> file : files.entrySet()) {
Path target = directory.resolve(file.getKey()).normalize();
if (!target.startsWith(directory)) {
throw new IllegalArgumentException("Export file escapes the datapack root: " + file.getKey());
}
Files.createDirectories(target.getParent());
Files.write(target, file.getValue());
}
try (PaperRegistryScope scope = PaperRegistryScope.open()) {
loaded = HeadlessNativeRegistries.load(directory);
}
} catch (IOException | RuntimeException failure) {
HeadlessNativeRegistries.delete(directory);
throw failure;
}
try {
return new WorldgenExportSampler(loaded, stem(loaded.registries(), mode, dimension), seed, densityFunctionIds(files));
} catch (RuntimeException failure) {
loaded.close();
throw failure;
}
}
public static WorldgenExportSampler open(Path datapack, WorldgenExportMode mode, String dimensionId, long seed) throws IOException {
return open(files(datapack), mode, dimensionId, seed);
}
public float density(String functionId, int x, int y, int z) {
DensityFunction function = loaded.registries().lookupOrThrow(Registries.DENSITY_FUNCTION)
.getOrThrow(ResourceKey.create(Registries.DENSITY_FUNCTION, Identifier.parse(functionId))).value();
return randomState.getSampler(function).sampleValue(densityContexts.get(), x, y, z);
}
public float[] densityChunk(DensityFunction function, int chunkX, int chunkZ) {
DensityVolume volume = new DensityVolume(CHUNK_WIDTH, heights.getHeight(), CHUNK_WIDTH, chunkX * CHUNK_WIDTH, heights.getMinY(), chunkZ * CHUNK_WIDTH);
return sampleVolume(function, volume);
}
public DensityFunction finalDensity() {
return generator.generatorSettings().value().noiseRouter().finalDensity();
}
public int baseHeight(int x, int z) {
return generator.getBaseHeight(x, z, Heightmap.Types.WORLD_SURFACE_WG, heights, randomState);
}
public int floorHeight(int x, int z) {
return generator.getBaseHeight(x, z, Heightmap.Types.OCEAN_FLOOR_WG, heights, randomState);
}
public ProtoChunk terrainChunk(int chunkX, int chunkZ) {
ProtoChunk chunk = new ProtoChunk(new ChunkPos(chunkX, chunkZ), UpgradeData.EMPTY, heights, containers, null);
NoiseGeneratorSettings settings = generator.generatorSettings().value();
NoiseSettings noise = settings.noiseSettings().clampToHeightAccessor(chunk.getHeightAccessorForGeneration());
try (NoiseChunk noiseChunk = (NoiseChunk) invoke(CREATE_NOISE_CHUNK, chunk, NoStructures.INSTANCE, Blender.empty(), randomState, noise)) {
invoke(DO_FILL, noiseChunk, chunk);
randomState.surfaceSystem().buildSurface(randomState, new BiomeManager(resolvers.get(), biomeZoomSeed),
new WorldGenerationContext(generator, chunk.getHeightAccessorForGeneration()), chunk, noiseChunk, settings.materialRule().value(), null);
}
return chunk;
}
public StructureStart structureStart(String structureId, int chunkX, int chunkZ) throws IOException {
Holder.Reference<Structure> structure = loaded.registries().lookupOrThrow(Registries.STRUCTURE)
.getOrThrow(ResourceKey.create(Registries.STRUCTURE, Identifier.parse(structureId)));
Path storageRoot = Files.createTempDirectory("iris-worldgen-export-structures-");
try (LevelStorageSource.LevelStorageAccess storage = LevelStorageSource.createDefault(storageRoot).createAccess("export")) {
StructureTemplateManager templates = new StructureTemplateManager(loaded.resources(), storage, DataFixers.getDataFixer(),
loaded.registries().lookupOrThrow(Registries.BLOCK));
return structure.value().generate(structure, Level.OVERWORLD, loaded.registries(), generator, generator.getBiomeSource(),
randomState.createClimateSampler(SamplerContext.builder().enableCaches().build()), randomState, templates, seed,
new ChunkPos(chunkX, chunkZ), 0, heights, structure.value().biomes()::contains);
} finally {
HeadlessNativeRegistries.delete(storageRoot);
}
}
public String biome(int x, int y, int z) {
Holder<Biome> biome = resolvers.get().getNoiseBiome(QuartPos.fromBlock(x), QuartPos.fromBlock(y), QuartPos.fromBlock(z));
return biome.unwrapKey().orElseThrow().identifier().toString();
}
public Set<String> possibleBiomes() {
Set<String> biomes = new TreeSet<>();
for (Holder<Biome> biome : generator.getBiomeSource().possibleBiomes()) {
biomes.add(biome.unwrapKey().orElseThrow().identifier().toString());
}
return biomes;
}
public SortedSet<String> densityFunctionIds() {
return densityFunctionIds;
}
public int minY() {
return heights.getMinY();
}
public int height() {
return heights.getHeight();
}
public int seaLevel() {
return generator.getSeaLevel();
}
public RegistryAccess registries() {
return loaded.registries();
}
@Override
public void close() throws IOException {
loaded.close();
}
private float[] sampleVolume(DensityFunction function, DensityVolume volume) {
try (ScopedDensityBuffer buffer = randomState.getSampler(function).bind(densityContexts.get()).sampleVolume(volume)) {
float[] values = new float[volume.size()];
for (int index = 0; index < values.length; index++) {
values[index] = buffer.get(index);
}
return values;
}
}
private Object invoke(Method method, Object... arguments) {
try {
return method.invoke(generator, arguments);
} catch (InvocationTargetException failure) {
if (failure.getCause() instanceof RuntimeException runtime) {
throw runtime;
}
if (failure.getCause() instanceof Error error) {
throw error;
}
throw new IllegalStateException("Terrain stage " + method.getName() + " failed", failure.getCause());
} catch (IllegalAccessException failure) {
throw new IllegalStateException("Terrain stage " + method.getName() + " is not accessible", failure);
}
}
private static Method generatorMethod(String name, Class<?>... parameters) {
try {
Method method = NoiseBasedChunkGenerator.class.getDeclaredMethod(name, parameters);
method.setAccessible(true);
return method;
} catch (NoSuchMethodException failure) {
throw new IllegalStateException("NoiseBasedChunkGenerator has no terrain stage " + name, failure);
}
}
private static LevelStem stem(RegistryAccess registries, WorldgenExportMode mode, Identifier dimension) {
return switch (mode) {
case PRESET -> registries.lookupOrThrow(Registries.WORLD_PRESET)
.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, dimension)).value().overworld()
.orElseThrow(() -> new IllegalStateException("World preset " + dimension + " has no overworld"));
case DIMENSION -> levelStem(registries, dimension);
case OVERWORLD -> levelStem(registries, LevelStem.OVERWORLD.identifier());
};
}
private static LevelStem levelStem(RegistryAccess registries, Identifier id) {
return registries.lookupOrThrow(Registries.LEVEL_STEM).getOrThrow(ResourceKey.create(Registries.LEVEL_STEM, id)).value();
}
private static SortedMap<String, byte[]> files(Path datapack) throws IOException {
if (!Files.isDirectory(datapack)) {
throw new IOException("Exported datapack " + datapack + " is not a directory");
}
SortedMap<String, byte[]> files = new TreeMap<>();
try (Stream<Path> paths = Files.walk(datapack)) {
List<Path> regular = paths.filter(Files::isRegularFile).toList();
for (Path file : regular) {
files.put(datapack.relativize(file).toString().replace('\\', '/'), Files.readAllBytes(file));
}
}
return files;
}
private static SortedSet<String> densityFunctionIds(Map<String, byte[]> files) {
SortedSet<String> ids = new TreeSet<>();
for (String path : files.keySet()) {
int marker = path.indexOf(DENSITY_FUNCTIONS);
if (path.startsWith("data/") && marker > 0 && path.endsWith(".json")) {
String namespace = path.substring("data/".length(), marker);
ids.add(namespace + ":" + path.substring(marker + DENSITY_FUNCTIONS.length(), path.length() - ".json".length()));
}
}
return ids;
}
private static final class NoStructures extends StructureManager {
private static final NoStructures INSTANCE = new NoStructures();
private NoStructures() {
super(null, null, null);
}
@Override
public List<StructureStart> startsForStructure(int chunkX, int chunkZ, Predicate<Structure> predicate) {
return List.of();
}
}
}
@@ -0,0 +1,394 @@
package art.arcane.iris.probe;
import io.papermc.paper.configuration.WorldConfiguration;
import net.minecraft.core.Holder;
import net.minecraft.core.QuartPos;
import net.minecraft.core.RegistryAccess;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.LevelHeightAccessor;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.biome.BiomeManager;
import net.minecraft.world.level.biome.BiomeResolver;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.chunk.PalettedContainerFactory;
import net.minecraft.world.level.chunk.ProtoChunk;
import net.minecraft.world.level.chunk.UpgradeData;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.minecraft.world.level.dimension.DimensionType;
import net.minecraft.world.level.dimension.LevelStem;
import net.minecraft.world.level.levelgen.Aquifer;
import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
import net.minecraft.world.level.levelgen.NoiseChunk;
import net.minecraft.world.level.levelgen.NoiseGeneratorSettings;
import net.minecraft.world.level.levelgen.NoiseSettings;
import net.minecraft.world.level.levelgen.RandomState;
import net.minecraft.world.level.levelgen.WorldGenerationContext;
import net.minecraft.world.level.levelgen.blending.Blender;
import net.minecraft.world.level.levelgen.densityfunction.DensityVolume;
import net.minecraft.world.level.levelgen.material.rule.MaterialRule;
import org.spigotmc.SpigotWorldConfig;
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadMXBean;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.SplittableRandom;
import java.util.function.Supplier;
final class WorldgenStageBench {
private static final int SAMPLE_RADIUS_BLOCKS = 32_768;
private static final double NANOS_PER_MILLI = 1_000_000.0;
private static final double NANOS_PER_MICRO = 1_000.0;
private static final ThreadMXBean THREADS = ManagementFactory.getThreadMXBean();
private final String label;
private final NoiseBasedChunkGenerator generator;
private final NoiseGeneratorSettings settings;
private final RandomState randomState;
private final LevelHeightAccessor heights;
private final PalettedContainerFactory containers;
private final long seed;
private final Method fill;
private final Aquifer.FluidPicker fluidPicker;
private final BiomeResolver uncached;
private final Level surfaceLevel;
WorldgenStageBench(Options options) {
label = options.label();
if (!(options.stem().generator() instanceof NoiseBasedChunkGenerator noiseGenerator)) {
throw new IllegalStateException(label + " uses " + options.stem().generator().getClass().getSimpleName() + ", not a noise generator");
}
generator = noiseGenerator;
settings = noiseGenerator.generatorSettings().value();
seed = options.seed();
randomState = RandomState.create(options.registries().lookupOrThrow(Registries.NOISE), seed, settings);
DimensionType type = options.stem().type().value();
heights = LevelHeightAccessor.create(type.minY(), type.height());
containers = PalettedContainerFactory.create(options.registries());
fill = fillMethod();
fluidPicker = fluidPicker(noiseGenerator);
uncached = noiseGenerator.getBiomeSource().createUncachedResolver(randomState);
surfaceLevel = paperConfiguredLevel();
}
String label() {
return label;
}
NoiseGeneratorSettings settings() {
return settings;
}
void chunks(int originChunkX, int originChunkZ, int side, ChunkSamples samples) {
int ring = side + 2;
Map<Long, ProtoChunk> chunks = new HashMap<>(ring * ring * 2);
for (int dz = -1; dz <= side; dz++) {
for (int dx = -1; dx <= side; dx++) {
ChunkPos position = new ChunkPos(originChunkX + dx, originChunkZ + dz);
ProtoChunk chunk = new ProtoChunk(position, UpgradeData.EMPTY, heights, containers, null);
long started = System.nanoTime();
long startedCpu = THREADS.getCurrentThreadCpuTime();
generator.createBiomes(randomState, Blender.empty(), null, chunk).join();
samples.biomes.add(System.nanoTime() - started, THREADS.getCurrentThreadCpuTime() - startedCpu);
chunk.setPersistedStatus(ChunkStatus.BIOMES);
chunks.put(ChunkPos.pack(position.x(), position.z()), chunk);
}
}
RegionBiomes region = new RegionBiomes(chunks, uncached);
BiomeManager biomeManager = new BiomeManager(region, BiomeManager.obfuscateSeed(seed));
MaterialRule materialRule = settings.materialRule().value();
for (int dz = 0; dz < side; dz++) {
for (int dx = 0; dx < side; dx++) {
int chunkX = originChunkX + dx;
int chunkZ = originChunkZ + dz;
ProtoChunk chunk = chunks.get(ChunkPos.pack(chunkX, chunkZ));
Set<Holder<Biome>> possible = possibleBiomes(chunks, chunkX, chunkZ);
long started = System.nanoTime();
long startedCpu = THREADS.getCurrentThreadCpuTime();
try (NoiseChunk noiseChunk = new NoiseChunk(randomState, null, settings, fluidPicker, Blender.empty(), volume(chunk))) {
fill(noiseChunk, chunk);
long filled = System.nanoTime();
long filledCpu = THREADS.getCurrentThreadCpuTime();
randomState.surfaceSystem().buildSurface(randomState, biomeManager,
new WorldGenerationContext(generator, chunk.getHeightAccessorForGeneration(), surfaceLevel), chunk, noiseChunk, materialRule, possible);
samples.surface.add(System.nanoTime() - filled, THREADS.getCurrentThreadCpuTime() - filledCpu);
samples.noise.add(filled - started, filledCpu - startedCpu);
}
ChunkPos position = chunk.getPos();
long carverStarted = System.nanoTime();
long carverStartedCpu = THREADS.getCurrentThreadCpuTime();
uncached.getNoiseBiome(QuartPos.fromBlock(position.getMinBlockX()), 0, QuartPos.fromBlock(position.getMinBlockZ()));
samples.carverBiome.add(System.nanoTime() - carverStarted, THREADS.getCurrentThreadCpuTime() - carverStartedCpu);
samples.chunks++;
}
}
samples.biomeFallbacks += region.fallbacks();
}
void uncachedBiome(CallBudget budget, CallSamples samples) {
SplittableRandom random = new SplittableRandom(seed ^ 0x5DEECE66DL ^ samples.count);
int minQuart = QuartPos.fromBlock(heights.getMinY());
int quartHeight = QuartPos.fromBlock(heights.getHeight());
long deadline = System.nanoTime() + budget.limitNanos();
long startedCpu = THREADS.getCurrentThreadCpuTime();
int taken = 0;
while (taken < budget.calls() && (taken < budget.minimumCalls() || System.nanoTime() < deadline)) {
int quartX = QuartPos.fromBlock(random.nextInt(-SAMPLE_RADIUS_BLOCKS, SAMPLE_RADIUS_BLOCKS));
int quartY = minQuart + random.nextInt(quartHeight);
int quartZ = QuartPos.fromBlock(random.nextInt(-SAMPLE_RADIUS_BLOCKS, SAMPLE_RADIUS_BLOCKS));
long started = System.nanoTime();
uncached.getNoiseBiome(quartX, quartY, quartZ);
samples.wall.add(System.nanoTime() - started);
taken++;
}
samples.finish(taken, THREADS.getCurrentThreadCpuTime() - startedCpu);
}
void baseHeight(CallBudget budget, Heightmap.Types type, CallSamples samples) {
SplittableRandom random = new SplittableRandom(seed ^ type.ordinal() ^ 0x2545F4914F6CDD1DL ^ samples.count);
long deadline = System.nanoTime() + budget.limitNanos();
long startedCpu = THREADS.getCurrentThreadCpuTime();
int taken = 0;
while (taken < budget.calls() && (taken < budget.minimumCalls() || System.nanoTime() < deadline)) {
int x = random.nextInt(-SAMPLE_RADIUS_BLOCKS, SAMPLE_RADIUS_BLOCKS);
int z = random.nextInt(-SAMPLE_RADIUS_BLOCKS, SAMPLE_RADIUS_BLOCKS);
long started = System.nanoTime();
generator.getBaseHeight(x, z, type, heights, randomState);
samples.wall.add(System.nanoTime() - started);
taken++;
}
samples.finish(taken, THREADS.getCurrentThreadCpuTime() - startedCpu);
}
void baseColumn(CallBudget budget, CallSamples samples) {
SplittableRandom random = new SplittableRandom(seed ^ 0x9E3779B97F4A7C15L ^ samples.count);
long deadline = System.nanoTime() + budget.limitNanos();
long startedCpu = THREADS.getCurrentThreadCpuTime();
int taken = 0;
while (taken < budget.calls() && (taken < budget.minimumCalls() || System.nanoTime() < deadline)) {
int x = random.nextInt(-SAMPLE_RADIUS_BLOCKS, SAMPLE_RADIUS_BLOCKS);
int z = random.nextInt(-SAMPLE_RADIUS_BLOCKS, SAMPLE_RADIUS_BLOCKS);
long started = System.nanoTime();
generator.getBaseColumn(x, z, heights, randomState);
samples.wall.add(System.nanoTime() - started);
taken++;
}
samples.finish(taken, THREADS.getCurrentThreadCpuTime() - startedCpu);
}
private void fill(NoiseChunk noiseChunk, ChunkAccess chunk) {
try {
fill.invoke(generator, noiseChunk, chunk);
} catch (IllegalAccessException failure) {
throw new IllegalStateException("Cannot invoke NoiseBasedChunkGenerator.doFill for " + label, failure);
} catch (InvocationTargetException failure) {
if (failure.getCause() instanceof RuntimeException runtime) {
throw runtime;
}
throw new IllegalStateException("Noise fill failed for " + label, failure.getCause());
}
}
private DensityVolume volume(ChunkAccess chunk) {
NoiseSettings noise = settings.noiseSettings().clampToHeightAccessor(chunk.getHeightAccessorForGeneration());
ChunkPos position = chunk.getPos();
return new DensityVolume(16, noise.height(), 16, position.getMinBlockX(), noise.minY(), position.getMinBlockZ());
}
private static Set<Holder<Biome>> possibleBiomes(Map<Long, ProtoChunk> chunks, int chunkX, int chunkZ) {
Set<Holder<Biome>> possible = new HashSet<>();
for (int dz = -1; dz <= 1; dz++) {
for (int dx = -1; dx <= 1; dx++) {
chunks.get(ChunkPos.pack(chunkX + dx, chunkZ + dz)).collectBiomesInPalette(possible);
}
}
return possible;
}
private static Method fillMethod() {
try {
Method method = NoiseBasedChunkGenerator.class.getDeclaredMethod("doFill", NoiseChunk.class, ChunkAccess.class);
method.setAccessible(true);
return method;
} catch (NoSuchMethodException failure) {
throw new IllegalStateException("NoiseBasedChunkGenerator has no doFill(NoiseChunk, ChunkAccess)", failure);
}
}
@SuppressWarnings("unchecked")
private static Aquifer.FluidPicker fluidPicker(NoiseBasedChunkGenerator generator) {
try {
Field field = NoiseBasedChunkGenerator.class.getDeclaredField("globalFluidPicker");
field.setAccessible(true);
return ((Supplier<Aquifer.FluidPicker>) field.get(generator)).get();
} catch (ReflectiveOperationException failure) {
throw new IllegalStateException("NoiseBasedChunkGenerator has no globalFluidPicker supplier", failure);
}
}
private static Level paperConfiguredLevel() {
try {
Constructor<WorldConfiguration> constructor = WorldConfiguration.class.getDeclaredConstructor(SpigotWorldConfig.class, Identifier.class);
constructor.setAccessible(true);
WorldConfiguration configuration = constructor.newInstance(null, Identifier.withDefaultNamespace("overworld"));
configuration.environment = configuration.new Environment();
Class<?> unsafeType = Class.forName("sun.misc.Unsafe");
Field unsafeField = unsafeType.getDeclaredField("theUnsafe");
unsafeField.setAccessible(true);
Method allocate = unsafeType.getMethod("allocateInstance", Class.class);
Level level = (Level) allocate.invoke(unsafeField.get(null), ServerLevel.class);
Field paperConfig = Level.class.getDeclaredField("paperConfig");
paperConfig.setAccessible(true);
paperConfig.set(level, configuration);
return level;
} catch (ReflectiveOperationException failure) {
throw new IllegalStateException("Cannot build the Paper world configuration that surface rules read", failure);
}
}
record Options(String label, LevelStem stem, RegistryAccess registries, long seed) {
Options {
Objects.requireNonNull(label, "label");
Objects.requireNonNull(stem, "stem");
Objects.requireNonNull(registries, "registries");
}
}
record CallBudget(int calls, int minimumCalls, long limitNanos) {
}
record ChunkTimings(int chunks, StageTiming biomes, StageTiming noise, StageTiming surface, StageTiming carverBiome,
long biomeFallbacks) {
double totalMean() {
return biomes.wall().mean() + noise.wall().mean() + surface.wall().mean() + carverBiome.wall().mean();
}
double totalCpuMean() {
return biomes.cpu().mean() + noise.cpu().mean() + surface.cpu().mean() + carverBiome.cpu().mean();
}
}
record StageTiming(Summary wall, Summary cpu) {
}
record CallTiming(Summary wall, int calls, double cpuMean) {
}
record Summary(int count, double mean, double median, double p90, double min, double max) {
private static Summary of(long[] nanos, double unit) {
if (nanos.length == 0) {
return new Summary(0, Double.NaN, Double.NaN, Double.NaN, Double.NaN, Double.NaN);
}
long[] sorted = nanos.clone();
Arrays.sort(sorted);
double total = 0.0;
for (long value : sorted) {
total += value;
}
return new Summary(sorted.length, total / sorted.length / unit, percentile(sorted, 0.5) / unit,
percentile(sorted, 0.9) / unit, sorted[0] / unit, sorted[sorted.length - 1] / unit);
}
private static double percentile(long[] sorted, double fraction) {
int index = (int) Math.min(sorted.length - 1, Math.round(fraction * (sorted.length - 1)));
return sorted[index];
}
}
static final class ChunkSamples {
private final StageSamples biomes = new StageSamples();
private final StageSamples noise = new StageSamples();
private final StageSamples surface = new StageSamples();
private final StageSamples carverBiome = new StageSamples();
private int chunks;
private long biomeFallbacks;
ChunkTimings timings() {
return new ChunkTimings(chunks, biomes.timing(), noise.timing(), surface.timing(), carverBiome.timing(), biomeFallbacks);
}
}
static final class CallSamples {
private final Samples wall = new Samples();
private int count;
private long cpuNanos;
CallTiming timing() {
return new CallTiming(Summary.of(wall.values(), NANOS_PER_MICRO), count,
count == 0 ? Double.NaN : cpuNanos / (double) count / NANOS_PER_MICRO);
}
private void finish(int calls, long cpu) {
count += calls;
cpuNanos += cpu;
}
}
private static final class StageSamples {
private final Samples wall = new Samples();
private final Samples cpu = new Samples();
private void add(long wallNanos, long cpuNanos) {
wall.add(wallNanos);
cpu.add(cpuNanos);
}
private StageTiming timing() {
return new StageTiming(Summary.of(wall.values(), NANOS_PER_MILLI), Summary.of(cpu.values(), NANOS_PER_MILLI));
}
}
private static final class Samples {
private long[] values = new long[64];
private int size;
private void add(long value) {
if (size == values.length) {
values = Arrays.copyOf(values, size * 2);
}
values[size++] = value;
}
private long[] values() {
return Arrays.copyOf(values, size);
}
}
private static final class RegionBiomes implements BiomeResolver {
private final Map<Long, ProtoChunk> chunks;
private final BiomeResolver fallback;
private long fallbacks;
private RegionBiomes(Map<Long, ProtoChunk> chunks, BiomeResolver fallback) {
this.chunks = chunks;
this.fallback = fallback;
}
@Override
public Holder<Biome> getNoiseBiome(int quartX, int quartY, int quartZ) {
ProtoChunk chunk = chunks.get(ChunkPos.pack(QuartPos.toSection(quartX), QuartPos.toSection(quartZ)));
if (chunk == null) {
fallbacks++;
return fallback.getNoiseBiome(quartX, quartY, quartZ);
}
return chunk.getNoiseBiome(quartX, quartY, quartZ);
}
private long fallbacks() {
return fallbacks;
}
}
}
@@ -0,0 +1,61 @@
package art.arcane.iris.probe.fidelity;
import art.arcane.iris.probe.WorldgenExportSampler;
import java.io.IOException;
import java.util.Map;
import java.util.Objects;
public final class VanillaNoiseColumnSource implements ColumnSource {
private final WorldgenExportSampler sampler;
private final Map<String, String> loadKeyByBiomeId;
private final int minY;
private final int maxY;
private final int seaLevel;
public VanillaNoiseColumnSource(WorldgenExportSampler sampler, Map<String, String> loadKeyByBiomeId) {
this.sampler = Objects.requireNonNull(sampler, "sampler");
this.loadKeyByBiomeId = Map.copyOf(loadKeyByBiomeId);
this.minY = sampler.minY();
this.maxY = sampler.minY() + sampler.height();
this.seaLevel = sampler.seaLevel();
}
@Override
public String label() {
return "vanilla:26.3";
}
@Override
public int minY() {
return minY;
}
@Override
public int maxY() {
return maxY;
}
@Override
public FidelityColumn column(int x, int z) {
int floor = sampler.floorHeight(x, z);
int terrainY = floor - 1;
boolean wet = floor < seaLevel && sampler.baseHeight(x, z) > floor;
return new FidelityColumn(loadKey(x, Math.max(minY, terrainY), z), terrainY, wet);
}
@Override
public void close() throws IOException {
sampler.close();
}
private String loadKey(int x, int y, int z) {
String biomeId = sampler.biome(x, y, z);
String loadKey = loadKeyByBiomeId.get(biomeId);
if (loadKey == null) {
throw new IllegalStateException("Exported biome " + biomeId + " at " + x + "," + y + "," + z
+ " has no Iris load key in the export result");
}
return loadKey;
}
}
@@ -0,0 +1,204 @@
package art.arcane.iris.probe.fidelity;
import art.arcane.iris.probe.WorldgenExportSampler;
import net.minecraft.core.Holder;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.chunk.LevelChunkSection;
import net.minecraft.world.level.chunk.ProtoChunk;
import net.minecraft.world.level.levelgen.densityfunction.DensityFunction;
import net.minecraft.world.level.levelgen.densityfunction.DensityFunctions;
import net.minecraft.world.level.levelgen.densityfunction.op.BinaryFunction;
import java.io.IOException;
import java.util.IdentityHashMap;
import java.util.Map;
import java.util.Objects;
public final class VanillaNoiseFidelitySource implements FidelitySource {
private static final int CHUNK_WIDTH = 16;
private static final byte SOLID = 0;
private static final byte OPEN = 1;
private static final byte FLUID = 2;
private static final byte LAVA = 3;
private final WorldgenExportSampler sampler;
private final Map<String, String> loadKeyByBiomeId;
private final String wallShell;
private final DensityFunction massDensity;
private final boolean walls;
private final int minY;
private final int maxY;
public VanillaNoiseFidelitySource(WorldgenExportSampler sampler, Map<String, String> loadKeyByBiomeId, String wallShell) {
this.sampler = Objects.requireNonNull(sampler, "sampler");
this.loadKeyByBiomeId = Map.copyOf(loadKeyByBiomeId);
this.wallShell = Objects.requireNonNull(wallShell, "wallShell");
this.massDensity = terrainMass(sampler.finalDensity());
this.walls = sampler.densityFunctionIds().contains(wallShell);
this.minY = sampler.minY();
this.maxY = sampler.minY() + sampler.height();
}
@Override
public String label() {
return "vanilla:26.3";
}
@Override
public int minY() {
return minY;
}
@Override
public int maxY() {
return maxY;
}
@Override
public FidelityChunk sample(int chunkX, int chunkZ) {
return new TerrainChunk(sampler.terrainChunk(chunkX, chunkZ), chunkX * CHUNK_WIDTH, chunkZ * CHUNK_WIDTH);
}
@Override
public void close() throws IOException {
sampler.close();
}
private String loadKey(int x, int y, int z) {
String biomeId = sampler.biome(x, y, z);
String loadKey = loadKeyByBiomeId.get(biomeId);
if (loadKey == null) {
throw new IllegalStateException("Exported biome " + biomeId + " at " + x + "," + y + "," + z
+ " has no Iris load key in the export result");
}
return loadKey;
}
static DensityFunction terrainMass(DensityFunction finalDensity) {
DensityFunction root = finalDensity;
while (root instanceof DensityFunctions.HolderHolder(Holder<DensityFunction> holder)) {
root = holder.value();
}
return root instanceof BinaryFunction(BinaryFunction.Type type, DensityFunction terrain, DensityFunction ignored) && type == BinaryFunction.Type.MIN
? terrain : root;
}
private static byte classify(Block block) {
String id = BuiltInRegistries.BLOCK.getKey(block).toString();
if (FidelityBlocks.open(id)) {
return OPEN;
}
if (FidelityBlocks.lava(id)) {
return LAVA;
}
return FidelityBlocks.fluid(id) ? FLUID : SOLID;
}
private record Column(int surfaceY, String biome, byte[] kinds) {
}
private final class TerrainChunk implements FidelityChunk {
private final ProtoChunk terrain;
private final int blockX;
private final int blockZ;
private final Column[] columns = new Column[CHUNK_WIDTH * CHUNK_WIDTH];
private final Map<Block, Byte> kinds = new IdentityHashMap<>();
private float[] masses;
private TerrainChunk(ProtoChunk terrain, int blockX, int blockZ) {
this.terrain = terrain;
this.blockX = blockX;
this.blockZ = blockZ;
}
@Override
public int surfaceY(int localX, int localZ) {
return column(localX, localZ).surfaceY();
}
@Override
public String surfaceBiome(int localX, int localZ) {
return column(localX, localZ).biome();
}
@Override
public boolean open(int localX, int y, int localZ) {
return kind(localX, y, localZ) == OPEN;
}
@Override
public boolean fluid(int localX, int y, int localZ) {
byte kind = kind(localX, y, localZ);
return kind == FLUID || kind == LAVA;
}
@Override
public boolean lava(int localX, int y, int localZ) {
return kind(localX, y, localZ) == LAVA;
}
@Override
public String block(int localX, int y, int localZ) {
return null;
}
@Override
public boolean terrainMass(int localX, int y, int localZ) {
if (y < minY || y >= maxY) {
throw new IllegalArgumentException("Y " + y + " is outside [" + minY + ", " + maxY + ")");
}
if (localX < 0 || localX >= CHUNK_WIDTH || localZ < 0 || localZ >= CHUNK_WIDTH) {
throw new IllegalArgumentException("Column " + localX + "," + localZ + " is outside the chunk");
}
if (masses == null) {
masses = sampler.densityChunk(massDensity, Math.floorDiv(blockX, CHUNK_WIDTH), Math.floorDiv(blockZ, CHUNK_WIDTH));
}
return masses[(localZ * CHUNK_WIDTH + localX) * (maxY - minY) + y - minY] > 0.0F;
}
@Override
public boolean wallsKnown() {
return walls;
}
@Override
public boolean wall(int localX, int y, int localZ) {
return walls && sampler.density(wallShell, blockX + localX, y, blockZ + localZ) > 0.0F;
}
private byte kind(int localX, int y, int localZ) {
if (y < minY || y >= maxY) {
throw new IllegalArgumentException("Y " + y + " is outside [" + minY + ", " + maxY + ")");
}
return column(localX, localZ).kinds()[y - minY];
}
private Column column(int localX, int localZ) {
if (localX < 0 || localX >= CHUNK_WIDTH || localZ < 0 || localZ >= CHUNK_WIDTH) {
throw new IllegalArgumentException("Column " + localX + "," + localZ + " is outside the chunk");
}
int index = localZ * CHUNK_WIDTH + localX;
Column column = columns[index];
if (column == null) {
column = sampleColumn(localX, localZ);
columns[index] = column;
}
return column;
}
private Column sampleColumn(int localX, int localZ) {
byte[] column = new byte[maxY - minY];
int surfaceY = minY - 1;
for (int y = maxY - 1; y >= minY; y--) {
LevelChunkSection section = terrain.getSection(terrain.getSectionIndex(y));
byte kind = kinds.computeIfAbsent(section.getBlockState(localX, y & (CHUNK_WIDTH - 1), localZ).getBlock(), VanillaNoiseFidelitySource::classify);
column[y - minY] = kind;
if (kind == SOLID && surfaceY < minY) {
surfaceY = y;
}
}
return new Column(surfaceY, loadKey(blockX + localX, Math.max(minY, surfaceY), blockZ + localZ), column);
}
}
}
@@ -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() {
@@ -0,0 +1,73 @@
package art.arcane.iris.probe;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.spi.VanillaDataSource;
import net.minecraft.resources.Identifier;
import net.minecraft.server.packs.PackResources;
import net.minecraft.server.packs.PackType;
import net.minecraft.server.packs.repository.ServerPacksSource;
import net.minecraft.server.packs.resources.IoSupplier;
import org.bukkit.Bukkit;
import org.junit.BeforeClass;
import org.junit.Test;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
public final class BukkitVanillaDataTest {
private static final String PLAINS = "data/minecraft/worldgen/biome/plains.json";
private static final String BIOME_TAGS = "data/minecraft/tags/worldgen/biome";
@BeforeClass
public static void bootstrap() {
HeadlessNativeBootstrap.initialize();
}
@Test
public void bukkitPlatformReadsTheServerPlainsBiome() throws IOException {
byte[] plains = new BukkitPlatform().vanillaData().read(PLAINS).orElseThrow();
assertTrue(new String(plains, StandardCharsets.UTF_8).contains("\"temperature\""));
try (PackResources vanilla = ServerPacksSource.createVanillaPackSource().fullResources()) {
IoSupplier<InputStream> resource = vanilla.getResource(PackType.SERVER_DATA,
Identifier.withDefaultNamespace("worldgen/biome/plains.json"));
try (InputStream input = resource.get()) {
assertArrayEquals(input.readAllBytes(), plains);
}
}
assertNull(Bukkit.getServer());
}
@Test
public void bukkitPlatformListsTheBiomeTagsTheGameLoads() {
List<String> expected = new ArrayList<>();
try (PackResources vanilla = ServerPacksSource.createVanillaPackSource().fullResources()) {
vanilla.listResources(PackType.SERVER_DATA, "minecraft", "tags/worldgen/biome",
(id, resource) -> expected.add("data/" + id.getNamespace() + "/" + id.getPath()));
}
Collections.sort(expected);
List<String> listed = new BukkitPlatform().vanillaData().list(BIOME_TAGS);
assertFalse(listed.isEmpty());
assertTrue(listed.contains(BIOME_TAGS + "/is_overworld.json"));
assertEquals(expected, listed);
}
@Test
public void headlessPlatformReadsTheSameVanillaData() {
HeadlessNativePlatform platform = new HeadlessNativePlatform(new File("unused-native-platform"),
HeadlessNativeTestRegistries::get);
VanillaDataSource bukkit = new BukkitPlatform().vanillaData();
assertArrayEquals(bukkit.read(PLAINS).orElseThrow(), platform.vanillaData().read(PLAINS).orElseThrow());
assertEquals(bukkit.list(BIOME_TAGS), platform.vanillaData().list(BIOME_TAGS));
}
}
@@ -0,0 +1,146 @@
package art.arcane.iris.probe;
import net.minecraft.core.RegistryAccess;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
import net.minecraft.world.level.levelgen.NoiseGeneratorSettings;
import net.minecraft.world.level.levelgen.RandomState;
import net.minecraft.world.level.levelgen.densityfunction.CachingDensitySampler;
import net.minecraft.world.level.levelgen.densityfunction.DensityFunction;
import net.minecraft.world.level.levelgen.densityfunction.DensitySampler;
import net.minecraft.world.level.levelgen.material.rule.ConditionRule;
import net.minecraft.world.level.levelgen.material.rule.MaterialRule;
import net.minecraft.world.level.levelgen.material.rule.OreVeinRule;
import net.minecraft.world.level.levelgen.material.rule.SequenceRule;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Deque;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
final class CompiledCaches {
private static final String MINECRAFT_PACKAGE = "net.minecraft.";
private static final String DENSITY_PACKAGE = "net.minecraft.world.level.levelgen.densityfunction.";
private static final String SPLINE_PACKAGE = "net.minecraft.util.";
private static final String SPLINE_NAME = "Spline";
private final RegistryAccess registries;
private final NoiseGeneratorSettings settings;
private final RandomState state;
private CompiledCaches(RegistryAccess registries, NoiseGeneratorSettings settings, long seed) {
this.registries = registries;
this.settings = settings;
this.state = RandomState.create(registries.lookupOrThrow(Registries.NOISE), seed, settings);
}
static CompiledCaches open(RegistryAccess registries, long seed) {
if (!(DatapackCostProbe.exportedOverworld(registries).generator() instanceof NoiseBasedChunkGenerator generator)) {
throw new IllegalStateException("Exported overworld has no noise generator");
}
return new CompiledCaches(registries, generator.generatorSettings().value(), seed);
}
DensityFunction finalDensity() {
return settings.noiseRouter().finalDensity();
}
DensityFunction function(String id) {
return registries.lookupOrThrow(Registries.DENSITY_FUNCTION)
.getOrThrow(ResourceKey.create(Registries.DENSITY_FUNCTION, Identifier.parse(id))).value();
}
List<OreVeinRule> oreVeins() {
List<OreVeinRule> veins = new ArrayList<>();
Deque<MaterialRule> pending = new ArrayDeque<>();
pending.push(settings.materialRule().value());
while (!pending.isEmpty()) {
MaterialRule rule = pending.pop();
switch (rule) {
case OreVeinRule vein -> veins.add(vein);
case SequenceRule sequence -> sequence.sequence().reversed().forEach(pending::push);
case ConditionRule condition -> pending.push(condition.thenRun());
default -> {
}
}
}
return veins;
}
int rootId(DensityFunction function) {
if (!(state.getSampler(function) instanceof CachingDensitySampler cache)) {
throw new IllegalStateException("Density function " + function + " does not compile to a cache");
}
return cache.id();
}
Set<Integer> ids(DensityFunction function) {
Set<Integer> ids = new TreeSet<>();
Set<Object> visited = Collections.newSetFromMap(new IdentityHashMap<>());
Deque<Object> pending = new ArrayDeque<>();
pending.push(state.getSampler(function));
while (!pending.isEmpty()) {
Object node = pending.pop();
if (node == null || !visited.add(node)) {
continue;
}
if (node instanceof CachingDensitySampler cache) {
ids.add(cache.id());
}
for (Object child : children(node)) {
pending.push(child);
}
}
return ids;
}
private static List<Object> children(Object node) {
List<Object> children = new ArrayList<>();
if (node instanceof Object[] array) {
Collections.addAll(children, array);
return children;
}
if (node instanceof Collection<?> collection) {
children.addAll(collection);
return children;
}
if (!traversed(node)) {
return children;
}
for (Class<?> type = node.getClass(); type != null && type.getName().startsWith(MINECRAFT_PACKAGE); type = type.getSuperclass()) {
for (Field field : type.getDeclaredFields()) {
if (Modifier.isStatic(field.getModifiers()) || field.getType().isPrimitive()) {
continue;
}
children.add(read(field, node));
}
}
return children;
}
private static boolean traversed(Object node) {
if (node instanceof Enum<?>) {
return false;
}
String name = node.getClass().getName();
return node instanceof DensitySampler || name.startsWith(DENSITY_PACKAGE) || name.startsWith(SPLINE_PACKAGE) && name.contains(SPLINE_NAME);
}
private static Object read(Field field, Object owner) {
try {
field.setAccessible(true);
return field.get(owner);
} catch (IllegalAccessException | RuntimeException failure) {
throw new IllegalStateException("Cannot read " + field + " of " + owner.getClass().getName(), failure);
}
}
}
@@ -0,0 +1,46 @@
package art.arcane.iris.probe;
import art.arcane.iris.probe.fidelity.FidelityReport;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.junit.ClassRule;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public final class DatapackFidelityProbeBiomeModeTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@Rule
public TemporaryFolder temporary = new TemporaryFolder();
@Test(timeout = 300_000L)
public void biomeModeJudgesTheSeedMeanWithoutTheChunkScan() throws Exception {
Path pack = DatapackFidelityProbeTest.pack(temporary);
Path output = temporary.getRoot().toPath().resolve("reports").resolve("biomes.json");
FidelityReport report = DatapackFidelityProbe.execute(
new String[]{pack.toString(), "main", "1337", "0", "1", output.toString(), "256", "32", "3", "biomes"}, DatapackFidelityProbeTest.exporter());
JsonObject json = JsonParser.parseString(Files.readString(output, StandardCharsets.UTF_8)).getAsJsonObject();
assertTrue(report.toTable(), report.passed());
assertTrue(json.get("passed").getAsBoolean());
assertEquals(1.0D, DatapackFidelityProbeTest.metric(report, "biome.share.plains").export(), 0.0D);
assertEquals(1, report.sampling().size());
assertEquals(List.of(1337L, 1338L, 1339L), report.sampling().getFirst().seeds());
assertEquals(3, json.getAsJsonArray("seeds").size());
assertEquals(3, json.getAsJsonObject("seedsPassing").getAsJsonObject("biome.share.plains").get("passing").getAsInt());
assertTrue(report.observations().stream().anyMatch(observation -> observation.name().equals("biome.type.land")
&& observation.iris() == 1.0D && observation.export() == 1.0D));
assertTrue(RealPackProbeSupport.drainReported().isEmpty());
}
}
@@ -0,0 +1,116 @@
package art.arcane.iris.probe;
import art.arcane.iris.pack.datapack.export.WorldgenDatapackExporter;
import art.arcane.iris.pack.datapack.export.compile.DimensionStage;
import art.arcane.iris.pack.datapack.export.compile.ImageMapStage;
import art.arcane.iris.pack.datapack.export.compile.PackCompiler;
import art.arcane.iris.pack.datapack.export.emit.WorldgenJsonWriter;
import art.arcane.iris.probe.fidelity.FidelityMetric;
import art.arcane.iris.probe.fidelity.FidelityReport;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.junit.ClassRule;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public final class DatapackFidelityProbeTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@Rule
public TemporaryFolder temporary = new TemporaryFolder();
@Test(timeout = 300_000L)
public void probeExportsTheSyntheticPackAndComparesBothGenerators() throws Exception {
Path pack = pack(temporary);
Path output = temporary.getRoot().toPath().resolve("reports").resolve("fidelity.json");
FidelityReport report = DatapackFidelityProbe.execute(
new String[]{pack.toString(), "main", "1337", "1", "8", output.toString(), "256", "32", "2", "full"}, exporter());
JsonObject json = JsonParser.parseString(Files.readString(output, StandardCharsets.UTF_8)).getAsJsonObject();
assertTrue(report.toTable(), report.passed());
assertTrue(json.get("passed").getAsBoolean());
assertEquals(report.metrics().size(), json.getAsJsonArray("metrics").size());
FidelityMetric biome = metric(report, "biome.share.plains");
assertEquals(1.0D, biome.iris(), 0.0D);
assertEquals(1.0D, biome.export(), 0.0D);
assertTrue(biome.pass());
assertEquals(0.0D, metric(report, "biome.totalVariation").delta(), 0.0D);
assertEquals(1.0D, metric(report, "height.landFraction").export(), 0.0D);
JsonObject columns = json.getAsJsonArray("sampling").get(0).getAsJsonObject();
assertEquals("columns", columns.get("scope").getAsString());
assertEquals(256, columns.get("windowBlocks").getAsInt());
assertEquals(32, columns.get("strideBlocks").getAsInt());
assertEquals(2, columns.getAsJsonArray("seeds").size());
assertEquals(128L, columns.get("columns").getAsLong());
JsonObject chunks = json.getAsJsonArray("sampling").get(1).getAsJsonObject();
assertEquals("chunks", chunks.get("scope").getAsString());
assertEquals(48, chunks.get("windowBlocks").getAsInt());
assertTrue(RealPackProbeSupport.drainReported().isEmpty());
}
@Test
public void malformedArgumentsAreRejectedBeforeThePackOpens() throws Exception {
Path pack = pack(temporary);
String output = temporary.getRoot().toPath().resolve("unused.json").toString();
IllegalArgumentException missing = assertThrows(IllegalArgumentException.class,
() -> DatapackFidelityProbe.execute(new String[]{pack.toString(), "main"}, exporter()));
IllegalArgumentException stride = assertThrows(IllegalArgumentException.class,
() -> DatapackFidelityProbe.execute(new String[]{pack.toString(), "main", "1337", "1", "17", output, "256", "32", "1", "full"}, exporter()));
IllegalArgumentException survey = assertThrows(IllegalArgumentException.class,
() -> DatapackFidelityProbe.execute(new String[]{pack.toString(), "main", "1337", "1", "8", output, "250", "32", "1", "full"}, exporter()));
IllegalArgumentException seeds = assertThrows(IllegalArgumentException.class,
() -> DatapackFidelityProbe.execute(new String[]{pack.toString(), "main", "1337", "1", "8", output, "256", "32", "0", "full"}, exporter()));
IllegalArgumentException mode = assertThrows(IllegalArgumentException.class,
() -> DatapackFidelityProbe.execute(new String[]{pack.toString(), "main", "1337", "1", "8", output, "256", "32", "1", "caves"}, exporter()));
assertTrue(missing.getMessage(), missing.getMessage().startsWith("Usage:"));
assertTrue(stride.getMessage(), stride.getMessage().contains("stride"));
assertTrue(survey.getMessage(), survey.getMessage().contains("multiple"));
assertTrue(seeds.getMessage(), seeds.getMessage().contains("surveySeeds"));
assertTrue(mode.getMessage(), mode.getMessage().contains("caves"));
assertFalse(Files.exists(Path.of(output)));
}
static WorldgenDatapackExporter exporter() {
return new WorldgenDatapackExporter(WorldgenJsonWriter::forTarget, new PackCompiler(List.of(new DimensionStage(), new ImageMapStage())));
}
static FidelityMetric metric(FidelityReport report, String name) {
for (FidelityMetric metric : report.metrics()) {
if (metric.name().equals(name)) {
return metric;
}
}
throw new AssertionError("Report has no metric " + name + ": " + report.toJson());
}
static Path pack(TemporaryFolder temporary) throws Exception {
Path pack = temporary.newFolder().toPath();
for (String directory : List.of("dimensions", "regions", "biomes", "generators")) {
Files.createDirectories(pack.resolve(directory));
}
Files.writeString(pack.resolve("dimensions/main.json"), """
{"name":"Fidelity","focus":"plains","regions":["owned"],"landChance":1,"carvingEnabled":false}
""");
Files.writeString(pack.resolve("regions/owned.json"), "{\"landBiomes\":[\"plains\"]}");
Files.writeString(pack.resolve("generators/flat.json"), "{\"composite\":[]}");
Files.writeString(pack.resolve("biomes/plains.json"), """
{"derivative":"minecraft:plains","generators":[{"generator":"flat","min":0,"max":0}]}
""");
return pack;
}
}
@@ -0,0 +1,110 @@
package art.arcane.iris.probe;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.levelgen.densityfunction.DensityFunction;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.ClassRule;
import org.junit.Test;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.Map;
import static org.junit.Assert.assertEquals;
public final class DensityCostAnalysisTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
private static final double EPSILON = 1.0E-9;
private static final String LEAF = "{\"type\":\"noise\",\"noise\":\"minecraft:temperature\",\"xz_scale\":1.0,\"y_scale\":0.0}";
private static HeadlessNativeRegistries registries;
@BeforeClass
public static void loadGraphs() throws IOException {
Map<String, String> definitions = new LinkedHashMap<>();
definitions.put(path("leaf"), LEAF);
definitions.put(path("pair"), "{\"type\":\"add\",\"left\":\"t:leaf\",\"right\":\"t:leaf\"}");
definitions.put(path("quad"), "{\"type\":\"mul\",\"left\":\"t:pair\",\"right\":\"t:pair\"}");
definitions.put(path("cached"), "{\"type\":\"cache\",\"input\":\"t:pair\"}");
definitions.put(path("twice"), "{\"type\":\"add\",\"left\":\"t:cached\",\"right\":\"t:cached\"}");
definitions.put(path("select"), "{\"type\":\"interval_select\",\"input\":\"t:leaf\",\"thresholds\":[0.0],\"functions\":[\"t:quad\",\"t:pair\"]}");
definitions.put(path("spline"), "{\"type\":\"spline\",\"spline\":{\"coordinate\":\"t:leaf\",\"points\":["
+ "{\"location\":-1.0,\"value\":" + nested("t:quad") + ",\"derivative\":0.0},"
+ "{\"location\":0.0,\"value\":0.0,\"derivative\":0.0},"
+ "{\"location\":1.0,\"value\":" + nested("t:quad") + ",\"derivative\":0.0}]}}");
definitions.put(path("grid"), "{\"type\":\"interpolated\",\"input\":\"t:pair\",\"cell_size_xz\":4,\"cell_size_y\":8}");
registries = HeadlessNativeRegistries.load(definitions);
}
@AfterClass
public static void closeRegistries() throws IOException {
if (registries != null) {
registries.close();
}
}
@Test
public void referencesExpandWhileTheDagSharesThem() {
DensityCostAnalysis.RootCost quad = analysis().analyze("quad", function("quad"));
assertEquals(3L, quad.dagNodes());
assertEquals(7.0, quad.expandedNodes(), EPSILON);
assertEquals(7.0, quad.cachedNodes(), EPSILON);
assertEquals(2, quad.namedSubtrees());
assertEquals("t:leaf", quad.exclusive().getFirst().name());
assertEquals(4.0, quad.exclusive().getFirst().multiplicity(), EPSILON);
assertEquals(4.0, quad.exclusive().getFirst().contribution(), EPSILON);
assertEquals("t:pair", quad.inclusive().getFirst().name());
assertEquals(6.0, quad.inclusive().getFirst().inclusive(), EPSILON);
assertEquals(1.0 / 3.0, quad.dagShare().get("leaf"), EPSILON);
assertEquals(1.0 / 3.0, quad.dagShare().get("quad"), EPSILON);
}
@Test
public void cachesEvaluateTheirBodyOnceWhenEnabled() {
DensityCostAnalysis.RootCost twice = analysis().analyze("twice", function("twice"));
assertEquals(4L, twice.dagNodes());
assertEquals(9.0, twice.expandedNodes(), EPSILON);
assertEquals(6.0, twice.cachedNodes(), EPSILON);
}
@Test
public void uncachedPointEvaluationTakesOneSelectedBranch() {
DensityCostAnalysis analysis = analysis();
DensityCostAnalysis.RootCost select = analysis.analyze("select", function("select"));
assertEquals(12.0, select.expandedNodes(), EPSILON);
assertEquals(9.0, select.uncachedPointWorst(), EPSILON);
DensityCostAnalysis.RootCost spline = analysis.analyze("spline", function("spline"));
assertEquals(19.0, spline.expandedNodes(), EPSILON);
assertEquals(11.0, spline.uncachedPointWorst(), EPSILON);
}
@Test
public void offLatticeGridPointsSampleEightCorners() {
DensityCostAnalysis.RootCost grid = analysis().analyze("grid", function("grid"));
assertEquals(4.0, grid.expandedNodes(), EPSILON);
assertEquals(25.0, grid.uncachedPointWorst(), EPSILON);
}
private static DensityCostAnalysis analysis() {
return new DensityCostAnalysis(id -> DensityJsonBreakdown.category("overworld", id), 10);
}
private static DensityFunction function(String name) {
return registries.registries().lookupOrThrow(Registries.DENSITY_FUNCTION)
.getOrThrow(ResourceKey.create(Registries.DENSITY_FUNCTION, Identifier.parse("t:" + name))).value();
}
private static String nested(String coordinate) {
return "{\"coordinate\":\"" + coordinate + "\",\"points\":[{\"location\":0.0,\"value\":1.0,\"derivative\":0.0},"
+ "{\"location\":1.0,\"value\":2.0,\"derivative\":0.0}]}";
}
private static String path(String name) {
return "data/t/worldgen/density_function/" + name + ".json";
}
}
@@ -0,0 +1,88 @@
package art.arcane.iris.probe;
import org.junit.Test;
import java.nio.charset.StandardCharsets;
import java.util.SortedMap;
import java.util.TreeMap;
import static org.junit.Assert.assertEquals;
public final class DensityJsonBreakdownTest {
private static final String TEMPERATURE = "{\"type\":\"add\",\"left\":\"x:biome/selector/0\",\"right\":1.0}";
private static final String SELECTOR = "{\"type\":\"mul\",\"left\":\"x:cell/cellular/a/value\",\"right\":2.0}";
private static final String CELL_A = "{\"type\":\"noise\",\"noise\":\"x:hash_a\",\"xz_scale\":0.5,\"y_scale\":0.0}";
private static final String CELL_B = "{\"type\":\"noise\",\"noise\":\"x:hash_b\",\"xz_scale\":0.25,\"y_scale\":0.0}";
private static final String FINAL_DENSITY = "{\"type\":\"min\",\"left\":\"x:cell/cellular/b/value\",\"right\":0.0}";
private static final String NOISE = "{\"firstOctave\":-1,\"amplitudes\":[1.0]}";
@Test
public void categoriesFollowProducingCompilers() {
assertEquals("router", DensityJsonBreakdown.category("overworld", "x:overworld/final_density"));
assertEquals("cellular", DensityJsonBreakdown.category("overworld", "x:cell/cracks/abc/distance"));
assertEquals("biome selector", DensityJsonBreakdown.category("overworld", "x:biome/selector/12"));
assertEquals("biome decision", DensityJsonBreakdown.category("overworld", "x:biome/base_code"));
assertEquals("terrain height", DensityJsonBreakdown.category("overworld", "x:height"));
assertEquals("terrain bands", DensityJsonBreakdown.category("overworld", "x:terrain/plains/blend/3"));
assertEquals("noise style", DensityJsonBreakdown.category("overworld", "x:style/shared/ab12"));
assertEquals("cellular offset copies", DensityJsonBreakdown.category("overworld", "x:cell/cellular/abc/vx_0_0/offset_x4_z0"));
assertEquals("biome selector offset copies", DensityJsonBreakdown.category("overworld", "x:biome/selector/4/offset_x0_z4"));
}
@Test
public void reportsBytesShapesAndCellularOwners() {
SortedMap<String, byte[]> files = new TreeMap<>();
put(files, "density_function/overworld/temperature", TEMPERATURE);
put(files, "density_function/biome/selector/0", SELECTOR);
put(files, "density_function/cell/cellular/a/value", CELL_A);
put(files, "density_function/cell/cellular/b/value", CELL_B);
put(files, "density_function/overworld/final_density", FINAL_DENSITY);
put(files, "noise/hash_a", NOISE);
put(files, "noise/hash_b", NOISE);
DensityJsonBreakdown.Report report = DensityJsonBreakdown.analyze(files, "overworld").report();
assertEquals(5, report.files());
assertEquals(bytes(TEMPERATURE, SELECTOR, CELL_A, CELL_B, FINAL_DENSITY), report.bytes());
DensityJsonBreakdown.CategoryBytes cellular = category(report, "cellular");
assertEquals(2, cellular.files());
assertEquals(1, cellular.distinctShapes());
assertEquals(bytes(CELL_B), cellular.structurallyDuplicateBytes());
assertEquals(2, category(report, "router").files());
assertEquals(2, report.cellularInstances());
assertEquals(1, report.cellularShapes().size());
assertEquals("biome selector+router", report.cellularShapes().getFirst().owners());
assertEquals(bytes(CELL_A), owner(report, "cellular via biome selector"));
assertEquals(bytes(CELL_B), owner(report, "cellular via router"));
}
private static DensityJsonBreakdown.CategoryBytes category(DensityJsonBreakdown.Report report, String name) {
for (DensityJsonBreakdown.CategoryBytes category : report.categories()) {
if (category.category().equals(name)) {
return category;
}
}
throw new AssertionError("No category " + name + " in " + report.categories());
}
private static long owner(DensityJsonBreakdown.Report report, String name) {
for (DensityJsonBreakdown.Bytes owner : report.libraryOwners()) {
if (owner.name().equals(name)) {
return owner.bytes();
}
}
throw new AssertionError("No owner " + name + " in " + report.libraryOwners());
}
private static long bytes(String... bodies) {
long total = 0;
for (String body : bodies) {
total += body.getBytes(StandardCharsets.UTF_8).length;
}
return total;
}
private static void put(SortedMap<String, byte[]> files, String path, String body) {
files.put("data/x/worldgen/" + path + ".json", body.getBytes(StandardCharsets.UTF_8));
}
}
@@ -137,7 +137,7 @@ public class HeadlessChunkGeneratorTest {
} }
Files.writeString(pack.resolve("dimensions/main.json"), """ Files.writeString(pack.resolve("dimensions/main.json"), """
{"name":"Native columns","focus":"swamp","regions":["owned"],"fluidHeight":40,"logicalHeight":256, {"name":"Native columns","focus":"swamp","regions":["owned"],"fluidHeight":40,"logicalHeight":256,
"landChance":1,"carvingEnabled":false,"hydrology":{"rivers":{"enabled":false}}} "landChance":1,"carvingEnabled":false}
"""); """);
Files.writeString(pack.resolve("regions/owned.json"), "{\"landBiomes\":[\"swamp\"]}"); Files.writeString(pack.resolve("regions/owned.json"), "{\"landBiomes\":[\"swamp\"]}");
Files.writeString(pack.resolve("generators/flat.json"), "{\"composite\":[]}"); Files.writeString(pack.resolve("generators/flat.json"), "{\"composite\":[]}");
@@ -1,7 +1,6 @@
package art.arcane.iris.probe; package art.arcane.iris.probe;
import art.arcane.iris.structure.nativegen.NativeStructureVolumeIndex; import art.arcane.iris.structure.nativegen.NativeStructureVolumeIndex;
import art.arcane.iris.generation.hydrology.HydrologyTileCache;
import art.arcane.volmlib.nativelib.terrain.structure.NativeStructureVolume; import art.arcane.volmlib.nativelib.terrain.structure.NativeStructureVolume;
import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KList;
import net.minecraft.nbt.CompoundTag; import net.minecraft.nbt.CompoundTag;
@@ -68,11 +67,10 @@ public final class HeadlessRegionTerrainTest {
OfflineRegionGenerator.Configuration export = new OfflineRegionGenerator.Configuration( OfflineRegionGenerator.Configuration export = new OfflineRegionGenerator.Configuration(
workspace.pack(), "main", 69420L, -3, 2, 1, 2, 3, 4, configuration, workspace.pack(), "main", 69420L, -3, 2, 1, 2, 3, 4, configuration,
"probe", Level.OVERWORLD, NoiseGeneratorSettings.OVERWORLD, root.resolve("export")); "probe", Level.OVERWORLD, NoiseGeneratorSettings.OVERWORLD, root.resolve("export"));
HydrologyTileCache.PregenerationArea bounds = export.hydrologyArea(); List<long[]> regions = export.hydrologyRegions();
assertEquals(-80L, bounds.minimumBlockX()); assertEquals(2, regions.size());
assertEquals(0L, bounds.minimumBlockZ()); assertArrayEquals(new long[]{-1L, 0L}, regions.get(0));
assertEquals(15L, bounds.maximumBlockX()); assertArrayEquals(new long[]{0L, 0L}, regions.get(1));
assertEquals(111L, bounds.maximumBlockZ());
assertEquals(1, result.targetChunks()); assertEquals(1, result.targetChunks());
assertEquals(529, result.chunks().size()); assertEquals(529, result.chunks().size());
long terrain = result.chunks().stream().filter(chunk -> chunk.getPersistedStatus() == ChunkStatus.TERRAIN).count(); long terrain = result.chunks().stream().filter(chunk -> chunk.getPersistedStatus() == ChunkStatus.TERRAIN).count();
@@ -189,7 +187,7 @@ public final class HeadlessRegionTerrainTest {
} }
Files.writeString(pack.resolve("dimensions/main.json"), """ Files.writeString(pack.resolve("dimensions/main.json"), """
{"name":"Native region","focus":"swamp","regions":["owned"],"fluidHeight":40,"logicalHeight":256, {"name":"Native region","focus":"swamp","regions":["owned"],"fluidHeight":40,"logicalHeight":256,
"landChance":1,"carvingEnabled":false,"hydrology":{"rivers":{"enabled":false}}} "landChance":1,"carvingEnabled":false}
"""); """);
Files.writeString(pack.resolve("regions/owned.json"), "{\"landBiomes\":[\"swamp\"]}"); Files.writeString(pack.resolve("regions/owned.json"), "{\"landBiomes\":[\"swamp\"]}");
Files.writeString(pack.resolve("generators/flat.json"), "{\"composite\":[]}"); Files.writeString(pack.resolve("generators/flat.json"), "{\"composite\":[]}");
@@ -1,37 +1,19 @@
package art.arcane.iris.probe; package art.arcane.iris.probe;
import io.papermc.paper.registry.PaperRegistryAccess;
import org.junit.rules.TestRule; import org.junit.rules.TestRule;
import org.junit.runner.Description; import org.junit.runner.Description;
import org.junit.runners.model.Statement; import org.junit.runners.model.Statement;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;
public final class PaperRegistryIsolation implements TestRule { public final class PaperRegistryIsolation implements TestRule {
@Override @Override
public Statement apply(Statement base, Description description) { public Statement apply(Statement base, Description description) {
return new Statement() { return new Statement() {
@Override @Override
public void evaluate() throws Throwable { public void evaluate() throws Throwable {
Map<Object, Object> registries = registries(); try (PaperRegistryScope scope = PaperRegistryScope.open()) {
Map<Object, Object> previous = new HashMap<>(registries);
registries.clear();
try {
base.evaluate(); base.evaluate();
} finally {
registries.clear();
registries.putAll(previous);
} }
} }
}; };
} }
@SuppressWarnings("unchecked")
private static Map<Object, Object> registries() throws ReflectiveOperationException {
Field field = PaperRegistryAccess.class.getDeclaredField("registries");
field.setAccessible(true);
return (Map<Object, Object>) field.get(PaperRegistryAccess.instance());
}
} }
@@ -0,0 +1,126 @@
package art.arcane.iris.probe;
import art.arcane.iris.pack.datapack.DataVersion;
import art.arcane.iris.pack.datapack.export.emit.DensityFunctionEmitter;
import art.arcane.iris.pack.datapack.export.emit.FunctionTable;
import art.arcane.iris.pack.datapack.export.ir.Axis;
import art.arcane.iris.pack.datapack.export.ir.Field;
import art.arcane.iris.pack.datapack.export.ir.NoiseDef;
import art.arcane.iris.pack.datapack.export.ir.ValueRange;
import art.arcane.iris.pack.datapack.export.lower.ExportCapabilities;
import art.arcane.iris.pack.datapack.export.lower.VanillaBounds;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.util.Interval;
import org.junit.ClassRule;
import org.junit.Test;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public final class VanillaBoundsParity263Test {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
private static final String NAMESPACE = "iris_export";
private static final List<NoiseDef> NOISES = List.of(new NoiseDef("parity/base", -3, List.of(1.0, 0.5, 0.25)),
new NoiseDef("parity/sparse", 0, List.of(0.0, 1.5, 0.0, 0.7)));
private static final Field BASE = new Field.Noise(NAMESPACE + ":parity/base", 0.25, 0.0, null, null, null);
private static final Field SPARSE = new Field.Noise(NAMESPACE + ":parity/sparse", 0.5, 0.25, null, null, null);
private static final Field UNIT = new Field.Clamp(BASE, 0.0, 1.0);
private static final Field BAND = new Field.Clamp(SPARSE, 0.3, 0.9);
@Test
public void boundsMatchMinecraftForEveryEmittedNode() throws Exception {
List<Field> fields = fields();
VanillaBounds bounds = VanillaBounds.of(NAMESPACE, NOISES);
DensityFunctionEmitter emitter = DensityFunctionEmitter.forTarget(ExportCapabilities.forTarget(DataVersion.V26_3), NAMESPACE, bounds);
FunctionTable table = new FunctionTable(NAMESPACE);
List<String> references = new ArrayList<>(fields.size());
for (int index = 0; index < fields.size(); index++) {
JsonElement emitted = emitter.emit(fields.get(index), table);
boolean reference = emitted.isJsonPrimitive() && emitted.getAsJsonPrimitive().isString();
references.add(reference ? emitted.getAsString() : table.register("parity/field_" + index, emitted));
}
Map<String, String> definitions = new LinkedHashMap<>();
for (Map.Entry<String, JsonElement> function : table.files().entrySet()) {
definitions.put("data/" + NAMESPACE + "/worldgen/density_function/" + function.getKey() + ".json", function.getValue().toString());
}
for (NoiseDef noise : NOISES) {
definitions.put("data/" + NAMESPACE + "/worldgen/noise/" + noise.id() + ".json", noiseJson(noise).toString());
}
try (HeadlessNativeRegistries registries = HeadlessNativeRegistries.load(definitions)) {
for (int index = 0; index < fields.size(); index++) {
Interval vanilla = registries.registries().lookupOrThrow(Registries.DENSITY_FUNCTION)
.getOrThrow(ResourceKey.create(Registries.DENSITY_FUNCTION, Identifier.parse(references.get(index)))).value().range();
Optional<ValueRange> modelled = bounds.range(fields.get(index));
assertTrue("field " + index + " " + fields.get(index) + " has no modelled range, Minecraft " + vanilla, modelled.isPresent());
assertEquals("field " + index + " " + fields.get(index), new ValueRange(vanilla.min(), vanilla.max()), modelled.get());
}
}
}
private static List<Field> fields() {
Field scaled = new Field.Binary(Field.BinaryOp.MUL, new Field.Const(0.3), BAND);
Field curve = new Field.Curve(new Field.Binary(Field.BinaryOp.MUL, BASE, new Field.Const(1.7)), List.of(
new Field.Knot(-1.0, new Field.Const(0.2), 0.75),
new Field.Knot(0.1, new Field.Curve(SPARSE, List.of(new Field.Knot(-0.5, new Field.Const(-1.0), 0.0),
new Field.Knot(0.5, new Field.Const(1.3), 2.0))), 0.0),
new Field.Knot(0.8, new Field.Const(0.6), -1.5)));
List<Field> fields = new ArrayList<>();
fields.add(BASE);
fields.add(SPARSE);
fields.add(new Field.Const(0.1));
fields.add(new Field.Coord(Axis.Y, Field.Tiling.CLAMP, -64.0, 320.0, 1.5, -0.7));
fields.add(new Field.Clamp(BASE, -0.3, 0.8));
for (Field.BinaryOp op : Field.BinaryOp.values()) {
fields.add(new Field.Binary(op, BASE, new Field.Binary(Field.BinaryOp.ADD, BAND, new Field.Const(0.6))));
}
fields.add(new Field.Binary(Field.BinaryOp.POW, UNIT, new Field.Const(2.5)));
fields.add(new Field.Binary(Field.BinaryOp.MAX, scaled, new Field.Const(0.0)));
fields.add(new Field.Binary(Field.BinaryOp.MIN, new Field.Const(2.0), scaled));
fields.add(new Field.Binary(Field.BinaryOp.MIN, new Field.Const(0.2), scaled));
for (Field.UnaryOp op : Field.UnaryOp.values()) {
fields.add(new Field.Unary(op, new Field.Binary(Field.BinaryOp.SUB, SPARSE, new Field.Const(0.4))));
}
fields.add(new Field.Lerp(UNIT, BASE, new Field.Binary(Field.BinaryOp.MUL, SPARSE, new Field.Const(3.0))));
for (Field.RoundMode mode : Field.RoundMode.values()) {
fields.add(new Field.Round(mode, new Field.Binary(Field.BinaryOp.MUL, BASE, new Field.Const(10.3)), new Field.Const(2.5)));
}
fields.add(curve);
fields.add(new Field.Curve(UNIT, List.of(new Field.Knot(0.0, new Field.Const(0.0), -3.0), new Field.Knot(1.0, new Field.Const(1.0), -3.0))));
fields.add(new Field.Partition(BASE, List.of(-0.2, 0.4), List.of(new Field.Const(-3.0), new Field.Const(0.5), new Field.Const(7.25))));
fields.add(new Field.Mux(new Field.Binary(Field.BinaryOp.MUL, UNIT, new Field.Const(2.0)), List.of(new Field.Const(4.0), BAND, scaled)));
fields.add(new Field.Gate(BASE, new Field.Binary(Field.BinaryOp.ADD, BAND, new Field.Const(40.0))));
fields.add(new Field.RangeIf(BASE, -0.1, 0.2, new Field.Const(-2.0), BAND));
fields.add(new Field.Grid(new Field.Binary(Field.BinaryOp.ADD, BASE, SPARSE), 4, 8));
fields.add(new Field.Shared("parity/shared", new Field.Binary(Field.BinaryOp.SUB, BAND, BASE)));
fields.add(new Field.Slice(new Field.Binary(Field.BinaryOp.MUL, SPARSE, new Field.Const(-2.0)), Axis.Y, 64));
fields.add(new Field.TopSurface(new Field.Binary(Field.BinaryOp.SUB, BASE, new Field.Const(0.1)), 200, -48, 4));
return fields;
}
private static JsonObject noiseJson(NoiseDef noise) {
JsonArray modifiers = new JsonArray(noise.amplitudes().size());
for (double amplitude : noise.amplitudes()) {
modifiers.add(amplitude);
}
JsonObject json = new JsonObject();
json.addProperty("base_octave", noise.firstOctave());
json.addProperty("octave_count", noise.amplitudes().size());
json.add("amplitude_modifiers", modifiers);
json.addProperty("base_amplitude", 1.0);
json.addProperty("normalize", false);
return json;
}
}
@@ -0,0 +1,242 @@
package art.arcane.iris.probe;
import art.arcane.iris.pack.datapack.DataVersion;
import art.arcane.iris.pack.datapack.export.compile.noise.VanillaNoiseModel;
import net.minecraft.core.HolderGetter;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.levelgen.NoiseGeneratorSettings;
import net.minecraft.world.level.levelgen.PositionalRandomFactory;
import net.minecraft.world.level.levelgen.RandomState;
import net.minecraft.world.level.levelgen.XoroshiroRandomSource;
import net.minecraft.world.level.levelgen.synth.Noise;
import net.minecraft.world.level.levelgen.synth.NormalNoise;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.ClassRule;
import org.junit.Test;
import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.SplittableRandom;
import java.util.stream.Collectors;
import static org.junit.Assert.assertTrue;
public final class VanillaNoiseModelParity263Test {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
private static final int POINTS = 4096;
private static final double TOLERANCE = 1.0E-5D;
private static final double RUNTIME_TOLERANCE = 1.0E-4D;
private static final List<Long> WORLD_SEEDS = List.of(0L, 8_675_309L, -4_611_686_018_427_387_904L);
private static final List<ParityNoise> NOISES = List.of(
ParityNoise.mapped("iris_export:parity/single", -7, List.of(1.0D)),
ParityNoise.mapped("iris_export:parity/fbm", -9, List.of(1.0D, 1.0D, 0.5D, 0.25D)),
ParityNoise.mapped("iris_export:parity/sparse", -10, List.of(1.5D, 0.0D, 1.0D, 0.0D, 0.0D, 0.0D)),
ParityNoise.mapped("iris_export:parity/leading_zero", -6, List.of(0.0D, 0.0D, 1.0D, 2.0D)),
ParityNoise.mapped("iris_export:parity/positive", 2, List.of(1.0D, 0.5D)),
ParityNoise.profiled("iris_export:parity/enabled",
"{\"base_octave\":-10,\"octave_count\":6,\"amplitude_modifiers\":[1.5,0.0,1.0,0.0,0.0,0.0],"
+ "\"base_amplitude\":1.2453007926713473}",
new VanillaNoiseModel.OctaveProfile(-10, 6, List.of(1.5D, 0.0D, 1.0D, 0.0D, 0.0D, 0.0D),
1.2453007926713473D, VanillaNoiseModel.Normalization.ENABLED)),
ParityNoise.profiled("iris_export:parity/legacy",
"{\"base_octave\":-9,\"octave_count\":3,\"amplitude_modifiers\":[1.0,0.0,1.0],\"normalize\":\"legacy\"}",
new VanillaNoiseModel.OctaveProfile(-9, 3, List.of(1.0D, 0.0D, 1.0D), 1.0D,
VanillaNoiseModel.Normalization.LEGACY)),
ParityNoise.profiled("iris_export:parity/defaults", "{\"base_octave\":-5}",
new VanillaNoiseModel.OctaveProfile(-5, 1, List.of(), 1.0D, VanillaNoiseModel.Normalization.ENABLED)),
ParityNoise.profiled("iris_export:parity/scaled",
"{\"base_octave\":-6,\"octave_count\":2,\"normalize\":false,\"base_amplitude\":0.5}",
new VanillaNoiseModel.OctaveProfile(-6, 2, List.of(), 0.5D, VanillaNoiseModel.Normalization.DISABLED)));
private static HeadlessNativeRegistries registries;
@BeforeClass
public static void loadNoiseDefinitions() throws IOException {
Map<String, String> definitions = new LinkedHashMap<>();
for (ParityNoise noise : NOISES) {
definitions.put(resourcePath(noise.id()), noise.json());
}
registries = HeadlessNativeRegistries.load(definitions);
}
@AfterClass
public static void closeRegistries() throws IOException {
if (registries != null) {
registries.close();
}
}
@Test
public void modelMatchesNoiseBuiltFromLoadedDefinitions() {
List<String> report = new ArrayList<>();
for (long seed : WORLD_SEEDS) {
PositionalRandomFactory world = new VanillaPrecisionRandom(new XoroshiroRandomSource(seed)).forkPositional();
for (ParityNoise noise : NOISES) {
NormalNoise definition = registries.registries().lookupOrThrow(Registries.NOISE).getOrThrow(key(noise.id())).value();
Noise real = definition.create(world.fromHashOf(Identifier.parse(noise.id())));
report.add(noise.id() + "@" + seed + "=" + assertParity(noise, seed, real, TOLERANCE));
}
}
System.out.println("26.3 vanilla-precision parity " + report);
}
@Test
public void modelMatchesRuntimeRandomStateNoise() {
List<String> report = new ArrayList<>();
for (long seed : WORLD_SEEDS) {
RandomState state = randomState(seed);
for (ParityNoise noise : NOISES) {
Noise real = state.getOrCreateNoise(key(noise.id()));
report.add(noise.id() + "@" + seed + "=" + assertParity(noise, seed, real, RUNTIME_TOLERANCE));
}
}
System.out.println("26.3 runtime parity " + report);
}
private static double assertParity(ParityNoise noise, long seed, Noise real, double tolerance) {
VanillaNoiseModel model = noise.model(seed);
SplittableRandom random = new SplittableRandom(seed ^ noise.id().hashCode());
double worst = 0.0D;
double peak = 0.0D;
for (int index = 0; index < POINTS; index++) {
double range = random.nextInt(4) == 0 ? 30_000_000.0D : 100_000.0D;
double x = random.nextDouble(-range, range);
double y = random.nextDouble(-2_048.0D, 2_048.0D);
double z = random.nextDouble(-range, range);
double expected = real.get(x, y, z);
worst = Math.max(worst, Math.abs(expected - model.sample(x, y, z)));
peak = Math.max(peak, Math.abs(expected));
}
assertTrue(noise.id() + " seed " + seed + " differs by " + worst, worst <= tolerance);
assertTrue(noise.id() + " seed " + seed + " is flat", peak > 0.05D);
return worst;
}
private static RandomState randomState(long seed) {
HolderGetter<NormalNoise> noises = registries.registries().lookupOrThrow(Registries.NOISE);
NoiseGeneratorSettings settings = registries.registries().lookupOrThrow(Registries.NOISE_SETTINGS)
.getOrThrow(NoiseGeneratorSettings.OVERWORLD).value();
return RandomState.create(noises, seed, settings);
}
private static ResourceKey<NormalNoise> key(String id) {
return ResourceKey.create(Registries.NOISE, Identifier.parse(id));
}
private static String resourcePath(String id) {
Identifier identifier = Identifier.parse(id);
return "data/" + identifier.getNamespace() + "/worldgen/noise/" + identifier.getPath() + ".json";
}
private record ParityNoise(String id, String json, int firstOctave, List<Double> amplitudes,
VanillaNoiseModel.OctaveProfile profile) {
private static ParityNoise mapped(String id, int firstOctave, List<Double> amplitudes) {
String modifiers = amplitudes.stream().map(String::valueOf).collect(Collectors.joining(","));
String json = "{\"base_octave\":" + firstOctave + ",\"octave_count\":" + amplitudes.size()
+ ",\"amplitude_modifiers\":[" + modifiers + "],\"base_amplitude\":1.0,\"normalize\":false}";
return new ParityNoise(id, json, firstOctave, amplitudes, null);
}
private static ParityNoise profiled(String id, String json, VanillaNoiseModel.OctaveProfile profile) {
return new ParityNoise(id, json, 0, List.of(), profile);
}
private VanillaNoiseModel model(long seed) {
if (profile == null) {
return VanillaNoiseModel.create(id, firstOctave, amplitudes, seed, DataVersion.V26_3);
}
return VanillaNoiseModel.create(id, profile, seed);
}
}
private static final class VanillaPrecisionRandom implements RandomSource {
private static final double DOUBLE_UNIT = 1.1102230246251565E-16D;
private final RandomSource source;
private VanillaPrecisionRandom(RandomSource source) {
this.source = source;
}
@Override
public RandomSource fork() {
return new VanillaPrecisionRandom(source.fork());
}
@Override
public PositionalRandomFactory forkPositional() {
return new VanillaPrecisionPositional(source.forkPositional());
}
@Override
public void setSeed(long seed) {
source.setSeed(seed);
}
@Override
public int nextInt() {
return source.nextInt();
}
@Override
public int nextInt(int bound) {
return source.nextInt(bound);
}
@Override
public long nextLong() {
return source.nextLong();
}
@Override
public boolean nextBoolean() {
return source.nextBoolean();
}
@Override
public float nextFloat() {
return source.nextFloat();
}
@Override
public double nextDouble() {
return (source.nextLong() >>> 11) * DOUBLE_UNIT;
}
@Override
public double nextGaussian() {
return source.nextGaussian();
}
}
private record VanillaPrecisionPositional(PositionalRandomFactory factory) implements PositionalRandomFactory {
@Override
public RandomSource at(int x, int y, int z) {
return new VanillaPrecisionRandom(factory.at(x, y, z));
}
@Override
public RandomSource fromHashOf(String name) {
return new VanillaPrecisionRandom(factory.fromHashOf(name));
}
@Override
public RandomSource fromSeed(long seed) {
return new VanillaPrecisionRandom(factory.fromSeed(seed));
}
@Override
public void parityConfigString(StringBuilder builder) {
factory.parityConfigString(builder);
}
}
}
@@ -0,0 +1,700 @@
package art.arcane.iris.probe;
import art.arcane.iris.pack.datapack.DataVersion;
import art.arcane.iris.pack.datapack.export.AtomicDatapackPublisher;
import art.arcane.iris.pack.datapack.export.DatapackExportFormat;
import art.arcane.iris.pack.datapack.export.ExportIrFixtures;
import art.arcane.iris.pack.datapack.export.GenerationCost;
import art.arcane.iris.pack.datapack.export.WorldgenDatapackExporter;
import art.arcane.iris.pack.datapack.export.WorldgenExportMode;
import art.arcane.iris.pack.datapack.export.WorldgenExportProgress;
import art.arcane.iris.pack.datapack.export.WorldgenExportRequest;
import art.arcane.iris.pack.datapack.export.WorldgenExportResult;
import art.arcane.iris.pack.datapack.export.compile.PackCompiler;
import art.arcane.iris.pack.datapack.export.compile.surface.MaterialProgramCompiler;
import art.arcane.iris.pack.datapack.export.compile.terrain.FieldMacros;
import art.arcane.iris.pack.datapack.export.compile.terrain.HeightCompiler;
import art.arcane.iris.pack.datapack.export.emit.NestingSplitter;
import art.arcane.iris.pack.datapack.export.emit.WorldgenJsonWriter;
import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic;
import art.arcane.iris.pack.datapack.export.ir.ExportIr;
import art.arcane.iris.pack.datapack.export.ir.ExportLimit;
import art.arcane.iris.pack.datapack.export.ir.Field;
import art.arcane.iris.pack.datapack.export.ir.FieldEvaluator;
import art.arcane.iris.pack.datapack.export.ir.NoiseDef;
import art.arcane.iris.pack.datapack.export.lower.GenerationBudget;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Holder;
import net.minecraft.core.HolderSet;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.tags.WorldPresetTags;
import net.minecraft.util.Interval;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.ProtoChunk;
import net.minecraft.world.level.dimension.LevelStem;
import net.minecraft.world.level.levelgen.densityfunction.DensityFunction;
import net.minecraft.world.level.levelgen.densityfunction.DfRewriteRule;
import net.minecraft.world.level.levelgen.densityfunction.op.BinaryFunction;
import net.minecraft.world.level.levelgen.material.rule.OreVeinRule;
import net.minecraft.world.level.levelgen.presets.WorldPreset;
import net.minecraft.world.level.levelgen.structure.BoundingBox;
import net.minecraft.world.level.levelgen.structure.Structure;
import net.minecraft.world.level.levelgen.structure.StructureStart;
import org.junit.AfterClass;
import org.junit.Assume;
import org.junit.BeforeClass;
import org.junit.ClassRule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Deque;
import java.util.Enumeration;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.SortedMap;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.concurrent.atomic.AtomicReference;
import java.util.zip.GZIPInputStream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public final class WorldgenExport263CodecTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@ClassRule
public static final TemporaryFolder overworldFolder = new TemporaryFolder();
private static final long SEED = 1337L;
private static final String DIMENSION_ID = ExportIrFixtures.NAMESPACE + ":" + ExportIrFixtures.DIMENSION;
private static final String HEIGHT_ID = ExportIrFixtures.NAMESPACE + ":" + ExportIrFixtures.DIMENSION + "/height";
private static final double SURFACE_TOLERANCE = 2.0;
private static final String BIOME_PREFIX = "data/" + ExportIrFixtures.NAMESPACE + "/worldgen/biome/";
private static final int[] SAMPLE_Y = {-64, 0, 64, 200};
private static final int LATTICE = 16;
private static final int LATTICE_STEP = 61;
private static final int HEIGHT_POINTS = 8;
private static final int HEIGHT_STEP = 97;
private static final String PROBE_PACK = "iris.exportProbePack";
private static final String OVERWORLD = "overworld";
private static final String OVERWORLD_ID = WorldgenExportRequest.DEFAULT_NAMESPACE + ":" + OVERWORLD;
private static final String OVERWORLD_BIOME_PREFIX = "data/" + WorldgenExportRequest.DEFAULT_NAMESPACE + "/worldgen/biome/";
private static final String LOG_PREFIX = "[worldgen-export]";
private static final int OVERWORLD_LATTICE = 4;
private static final int OVERWORLD_STEP = 509;
private static final int MAX_NESTING = 255;
private static final String CAVE_FIELD = "cave/field";
private static final String WALL_FALLBACK = "uses the nearest kept palette (minecraft:";
private static final double WALL_SHELL_BAND_LIMIT = 0.25;
private static final String DENSITY_FUNCTION_RESOURCE = "worldgen/density_function";
private static final double MEGABYTE = 1024.0 * 1024.0;
private static final int OVERWORLD_LAVA_Y = -248;
private static final int LAVA_LINE = -54;
private static final int DEEP_CHUNKS = 2;
private static final int DEEP_STRIDE = 4;
private static final int CHUNK_WIDTH = 16;
private static final float OPEN_MARGIN = 1.0e-3f;
private static final int LAVA_SEARCH_RADIUS = 1024;
private static final int LAVA_SEARCH_STEP = 16;
private static final int LAVA_CELLS = 4;
private static final int AGREEMENT_POINTS = 16;
private static final double AGREEMENT_TOLERANCE = 1.0;
private static final int AGREEMENT_OUTLIERS = 2;
private static final String SUBTERRAIN_STRUCTURES = "data/" + WorldgenExportRequest.DEFAULT_NAMESPACE + "/worldgen/structure/subterrain/";
private static final String SUBTERRAIN_TEMPLATES = "data/" + WorldgenExportRequest.DEFAULT_NAMESPACE + "/structure/subterrain/";
private static final int ROOM_GRID = 24;
private static final int ROOM_STEP_CHUNKS = 8;
private static final int ROOM_ATTEMPTS = 4;
private static final int ROOM_REACH = 128;
private static final Map<WorldgenExportMode, SortedMap<String, byte[]>> FILES = new EnumMap<>(WorldgenExportMode.class);
private static final Map<WorldgenExportMode, WorldgenExportSampler> SAMPLERS = new EnumMap<>(WorldgenExportMode.class);
private static SortedMap<String, byte[]> exportedOverworld;
private static WorldgenExportResult exportedOverworldResult;
private static ExportIr exportedOverworldIr;
@BeforeClass
public static void loadExports() throws IOException {
for (WorldgenExportMode mode : WorldgenExportMode.values()) {
SortedMap<String, byte[]> files = WorldgenJsonWriter.forTarget(DataVersion.V26_3).write(ExportIrFixtures.flatNoise(DataVersion.V26_3, mode));
FILES.put(mode, files);
SAMPLERS.put(mode, WorldgenExportSampler.open(files, mode, DIMENSION_ID, SEED));
}
}
@AfterClass
public static void closeExports() throws IOException {
for (WorldgenExportSampler sampler : SAMPLERS.values()) {
sampler.close();
}
SAMPLERS.clear();
FILES.clear();
exportedOverworld = null;
exportedOverworldResult = null;
exportedOverworldIr = null;
}
@Test
public void fixtureLoadsInEveryMode() {
for (WorldgenExportMode mode : WorldgenExportMode.values()) {
WorldgenExportSampler sampler = SAMPLERS.get(mode);
assertEquals(ExportIrFixtures.MIN_Y, sampler.minY());
assertEquals(ExportIrFixtures.HEIGHT, sampler.height());
assertEquals(ExportIrFixtures.SEA_LEVEL, sampler.seaLevel());
assertFalse(sampler.densityFunctionIds().isEmpty());
for (String id : sampler.densityFunctionIds()) {
assertTrue(mode + " " + id, sampler.registries().lookupOrThrow(Registries.DENSITY_FUNCTION).containsKey(Identifier.parse(id)));
}
assertTrue(sampler.registries().lookupOrThrow(Registries.NOISE_SETTINGS).containsKey(Identifier.parse(DIMENSION_ID)));
assertTrue(sampler.registries().lookupOrThrow(Registries.DIMENSION_TYPE).containsKey(Identifier.parse(DIMENSION_ID)));
assertTrue(sampler.registries().lookupOrThrow(Registries.MATERIAL_RULE).containsKey(Identifier.parse(DIMENSION_ID)));
boolean preset = sampler.registries().lookupOrThrow(Registries.WORLD_PRESET).containsKey(Identifier.parse(DIMENSION_ID));
boolean dimension = sampler.registries().lookupOrThrow(Registries.LEVEL_STEM).containsKey(Identifier.parse(DIMENSION_ID));
boolean overworld = sampler.registries().lookupOrThrow(Registries.LEVEL_STEM).containsKey(LevelStem.OVERWORLD.identifier());
assertEquals(mode.toString(), mode == WorldgenExportMode.PRESET, preset);
assertEquals(mode.toString(), mode == WorldgenExportMode.DIMENSION, dimension);
assertEquals(mode.toString(), mode == WorldgenExportMode.OVERWORLD, overworld);
}
WorldgenExportSampler presetSampler = SAMPLERS.get(WorldgenExportMode.PRESET);
Holder<WorldPreset> preset = presetSampler.registries().lookupOrThrow(Registries.WORLD_PRESET)
.getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, Identifier.parse(DIMENSION_ID)));
assertTrue(preset.value().createWorldDimensions().get(LevelStem.NETHER).isPresent());
assertTrue(preset.value().createWorldDimensions().get(LevelStem.END).isPresent());
HolderSet.Named<WorldPreset> normal = presetSampler.registries().lookupOrThrow(Registries.WORLD_PRESET).getOrThrow(WorldPresetTags.NORMAL);
assertTrue(normal.contains(preset));
}
@Test
public void everyDensityFunctionEvaluatesFinite() {
for (WorldgenExportMode mode : WorldgenExportMode.values()) {
WorldgenExportSampler sampler = SAMPLERS.get(mode);
for (String id : sampler.densityFunctionIds()) {
for (int y : SAMPLE_Y) {
for (int xIndex = 0; xIndex < LATTICE; xIndex++) {
for (int zIndex = 0; zIndex < LATTICE; zIndex++) {
int x = (xIndex - LATTICE / 2) * LATTICE_STEP;
int z = (zIndex - LATTICE / 2) * LATTICE_STEP;
float value = sampler.density(id, x, y, z);
assertTrue(mode + " " + id + " at " + x + "," + y + "," + z + " = " + value, Float.isFinite(value));
}
}
}
}
}
}
@Test
public void baseHeightWithinDimension() {
for (WorldgenExportMode mode : WorldgenExportMode.values()) {
WorldgenExportSampler sampler = SAMPLERS.get(mode);
Set<Integer> heights = new HashSet<>();
int land = 0;
for (int xIndex = 0; xIndex < HEIGHT_POINTS; xIndex++) {
for (int zIndex = 0; zIndex < HEIGHT_POINTS; zIndex++) {
int x = (xIndex - HEIGHT_POINTS / 2) * HEIGHT_STEP;
int z = (zIndex - HEIGHT_POINTS / 2) * HEIGHT_STEP;
int height = sampler.baseHeight(x, z);
assertTrue(mode + " height " + height + " at " + x + "," + z,
height >= sampler.minY() && height <= sampler.minY() + sampler.height());
float surface = sampler.density(HEIGHT_ID, x, 0, z);
int floor = sampler.floorHeight(x, z);
assertTrue(mode + " height " + height + " below floor " + floor, height >= floor);
if (surface > sampler.seaLevel() + 1) {
assertTrue(mode + " floor " + floor + " against surface " + surface, Math.abs(floor - surface) <= SURFACE_TOLERANCE);
land++;
}
heights.add(height);
}
}
assertTrue(mode + " surface never varies", heights.size() > 1);
assertTrue(mode + " has no land column above sea level", land > 0);
}
}
@Test
public void resolverReturnsOnlyExportedBiomes() {
for (WorldgenExportMode mode : WorldgenExportMode.values()) {
WorldgenExportSampler sampler = SAMPLERS.get(mode);
Set<String> exported = exportedBiomes(FILES.get(mode), BIOME_PREFIX, ExportIrFixtures.NAMESPACE);
assertFalse(exported.isEmpty());
assertTrue(mode + " " + sampler.possibleBiomes(), exported.containsAll(sampler.possibleBiomes()));
for (int y : SAMPLE_Y) {
for (int xIndex = 0; xIndex < LATTICE; xIndex++) {
for (int zIndex = 0; zIndex < LATTICE; zIndex++) {
String biome = sampler.biome((xIndex - LATTICE / 2) * LATTICE_STEP, y, (zIndex - LATTICE / 2) * LATTICE_STEP);
assertTrue(mode + " " + biome, exported.contains(biome));
}
}
}
}
}
@Test
public void overworldPackLoads() throws Exception {
SortedMap<String, byte[]> files = overworldFiles();
assertGenerationBudget(exportedOverworldResult);
assertWallPalettesFallBack(exportedOverworldResult);
for (Map.Entry<String, byte[]> file : files.entrySet()) {
if (file.getKey().endsWith(".json")) {
int depth = NestingSplitter.depth(JsonParser.parseString(new String(file.getValue(), StandardCharsets.UTF_8)));
assertTrue(file.getKey() + " nests " + depth, depth <= MAX_NESTING);
}
if (file.getKey().endsWith(".nbt")) {
assertCompleteGzip(file.getKey(), file.getValue());
}
}
try (WorldgenExportSampler sampler = WorldgenExportSampler.open(files, WorldgenExportMode.OVERWORLD, OVERWORLD_ID, SEED)) {
Set<String> roots = new TreeSet<>();
for (String id : sampler.densityFunctionIds()) {
if (id.startsWith(OVERWORLD_ID + "/")) {
roots.add(id);
}
}
assertTrue(roots.toString(), roots.contains(OVERWORLD_ID + "/final_density"));
for (String id : roots) {
for (int y : SAMPLE_Y) {
for (int xIndex = 0; xIndex < OVERWORLD_LATTICE; xIndex++) {
for (int zIndex = 0; zIndex < OVERWORLD_LATTICE; zIndex++) {
int x = (xIndex - OVERWORLD_LATTICE / 2) * OVERWORLD_STEP;
int z = (zIndex - OVERWORLD_LATTICE / 2) * OVERWORLD_STEP;
float value = sampler.density(id, x, y, z);
assertTrue(id + " at " + x + "," + y + "," + z + " = " + value, Float.isFinite(value));
}
}
}
}
assertNaturalHeightMatchesTheEvaluator(exportedOverworldIr, sampler);
Set<String> exported = exportedBiomes(files, OVERWORLD_BIOME_PREFIX, WorldgenExportRequest.DEFAULT_NAMESPACE);
assertFalse(exported.isEmpty());
assertTrue(sampler.possibleBiomes().toString(), exported.containsAll(sampler.possibleBiomes()));
for (int xIndex = 0; xIndex < HEIGHT_POINTS; xIndex++) {
for (int zIndex = 0; zIndex < HEIGHT_POINTS; zIndex++) {
int x = (xIndex - HEIGHT_POINTS / 2) * HEIGHT_STEP;
int z = (zIndex - HEIGHT_POINTS / 2) * HEIGHT_STEP;
int height = sampler.baseHeight(x, z);
assertTrue("height " + height + " at " + x + "," + z, height >= sampler.minY() && height <= sampler.minY() + sampler.height());
for (int y : SAMPLE_Y) {
String biome = sampler.biome(x, y, z);
assertTrue(biome + " at " + x + "," + y + "," + z, exported.contains(biome));
}
}
}
}
}
@Test
public void overworldHasNoDisjointMinMax() throws Exception {
SortedMap<String, byte[]> files = overworldFiles();
try (WorldgenExportSampler sampler = WorldgenExportSampler.open(files, WorldgenExportMode.OVERWORLD, OVERWORLD_ID, SEED)) {
MinMaxAudit audit = new MinMaxAudit();
for (String id : sampler.densityFunctionIds()) {
audit.rewrite(sampler.registries().lookupOrThrow(Registries.DENSITY_FUNCTION)
.getOrThrow(ResourceKey.create(Registries.DENSITY_FUNCTION, Identifier.parse(id))).value());
}
System.out.println(LOG_PREFIX + " min/max nodes " + audit.checked() + ", disjoint " + audit.disjoint().size());
assertTrue(audit.checked() > 0);
assertTrue(audit.disjoint().size() + " disjoint min/max nodes: " + audit.disjoint(), audit.disjoint().isEmpty());
}
}
@Test
public void overworldDeepCavesOpenWithoutLava() throws Exception {
SortedMap<String, byte[]> files = overworldFiles();
String field = WorldgenExportRequest.DEFAULT_NAMESPACE + ":" + CAVE_FIELD;
long open = 0L;
long lava = 0L;
try (WorldgenExportSampler sampler = WorldgenExportSampler.open(files, WorldgenExportMode.OVERWORLD, OVERWORLD_ID, SEED)) {
assertTrue(field, sampler.densityFunctionIds().contains(field));
BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos();
for (int chunkX = -DEEP_CHUNKS; chunkX < DEEP_CHUNKS; chunkX++) {
for (int chunkZ = -DEEP_CHUNKS; chunkZ < DEEP_CHUNKS; chunkZ++) {
ProtoChunk chunk = sampler.terrainChunk(chunkX, chunkZ);
for (int x = 0; x < CHUNK_WIDTH; x++) {
for (int z = 0; z < CHUNK_WIDTH; z++) {
for (int y = OVERWORLD_LAVA_Y + 1; y < LAVA_LINE; y++) {
BlockState state = chunk.getBlockState(position.set(x, y, z));
assertFalse(state + " at chunk " + chunkX + "," + chunkZ + " " + x + "," + y + "," + z, state.is(Blocks.LAVA));
}
}
}
for (int x = 0; x < CHUNK_WIDTH; x += DEEP_STRIDE) {
for (int z = 0; z < CHUNK_WIDTH; z += DEEP_STRIDE) {
int blockX = (chunkX << 4) + x;
int blockZ = (chunkZ << 4) + z;
for (int y = OVERWORLD_LAVA_Y + 1; y < LAVA_LINE; y++) {
if (sampler.density(field, blockX, y, blockZ) > -OPEN_MARGIN) {
continue;
}
BlockState state = chunk.getBlockState(position.set(x, y, z));
assertTrue(state + " at " + blockX + "," + y + "," + blockZ, state.isAir());
open++;
}
}
}
}
}
for (BlockPos cell : openLavaBandCells(sampler, field)) {
ProtoChunk chunk = sampler.terrainChunk(cell.getX() >> 4, cell.getZ() >> 4);
BlockState state = chunk.getBlockState(position.set(cell.getX() & (CHUNK_WIDTH - 1), cell.getY(), cell.getZ() & (CHUNK_WIDTH - 1)));
assertTrue(state + " at " + cell, state.is(Blocks.LAVA));
lava++;
}
}
System.out.println(LOG_PREFIX + " deep cave cells open " + open + ", lava band cells " + lava);
assertTrue("no deep cave cell opened", open > 0L);
assertTrue("no lava band cell opened", lava > 0L);
}
@Test
public void overworldMaterialPassComputesTheCaveFieldOnce() throws Exception {
SortedMap<String, byte[]> files = overworldFiles();
try (WorldgenExportSampler sampler = WorldgenExportSampler.open(files, WorldgenExportMode.OVERWORLD, OVERWORLD_ID, SEED)) {
CompiledCaches caches = CompiledCaches.open(sampler.registries(), SEED);
int field = caches.rootId(caches.function(WorldgenExportRequest.DEFAULT_NAMESPACE + ":" + CAVE_FIELD));
List<OreVeinRule> veins = caches.oreVeins();
DensityFunction opening = veins.getFirst().density();
assertTrue(caches.ids(opening).contains(field));
assertTrue(caches.ids(caches.finalDensity()).contains(field));
Set<Integer> caveCaches = caches.ids(caches.function(WorldgenExportRequest.DEFAULT_NAMESPACE + ":" + CAVE_FIELD));
int shells = 0;
for (OreVeinRule vein : veins) {
if (!vein.oreBlock().isAir()) {
assertTrue("wall shell " + shells + " " + vein.oreBlock(), caches.ids(vein.density()).contains(field));
assertTrue("wall shell " + shells + " " + vein.oreBlock(), caveCaches.containsAll(caches.ids(vein.density())));
shells++;
}
}
JsonObject shell = JsonParser.parseString(new String(files.get("data/" + WorldgenExportRequest.DEFAULT_NAMESPACE + "/" + DENSITY_FUNCTION_RESOURCE
+ "/" + MaterialProgramCompiler.WALL_SHELL + ".json"), StandardCharsets.UTF_8)).getAsJsonObject();
double band = shell.get("max_exclusive").getAsDouble();
System.out.println(LOG_PREFIX + " material pass: " + veins.size() + " ore vein rules, " + shells + " wall shells read cave field cache " + field
+ " within band " + band);
assertTrue(String.valueOf(shells), shells > 0);
assertTrue(String.valueOf(band), band > 0.0 && band < WALL_SHELL_BAND_LIMIT);
}
}
@Test
public void overworldSubterrainRoomsPlace() throws Exception {
SortedMap<String, byte[]> files = overworldFiles();
SortedMap<String, JsonObject> structures = new TreeMap<>();
for (Map.Entry<String, byte[]> file : files.entrySet()) {
if (file.getKey().startsWith(SUBTERRAIN_STRUCTURES) && file.getKey().endsWith(".json")) {
String path = file.getKey().substring(SUBTERRAIN_STRUCTURES.length(), file.getKey().length() - ".json".length());
structures.put(WorldgenExportRequest.DEFAULT_NAMESPACE + ":subterrain/" + path,
JsonParser.parseString(new String(file.getValue(), StandardCharsets.UTF_8)).getAsJsonObject());
}
}
long templateBytes = 0L;
for (Map.Entry<String, byte[]> file : files.entrySet()) {
if (file.getKey().startsWith(SUBTERRAIN_TEMPLATES)) {
templateBytes += file.getValue().length;
}
}
System.out.println(LOG_PREFIX + " subterrain structures " + structures.size() + ", structure sets "
+ count(files, "/worldgen/structure_set/subterrain/", ".json") + ", templates " + count(files, "/structure/subterrain/", ".nbt")
+ ", template bytes " + templateBytes);
assertFalse(structures.isEmpty());
try (WorldgenExportSampler sampler = WorldgenExportSampler.open(files, WorldgenExportMode.OVERWORLD, OVERWORLD_ID, SEED)) {
for (Map.Entry<String, JsonObject> entry : structures.entrySet()) {
Structure structure = sampler.registries().lookupOrThrow(Registries.STRUCTURE)
.getOrThrow(ResourceKey.create(Registries.STRUCTURE, Identifier.parse(entry.getKey()))).value();
JsonObject height = entry.getValue().getAsJsonObject("start_height");
int y = (height.getAsJsonObject("min_inclusive").get("absolute").getAsInt()
+ height.getAsJsonObject("max_inclusive").get("absolute").getAsInt()) / 2;
List<ChunkPos> matches = new ArrayList<>();
for (int xIndex = 0; xIndex < ROOM_GRID; xIndex++) {
for (int zIndex = 0; zIndex < ROOM_GRID; zIndex++) {
ChunkPos chunk = new ChunkPos((xIndex - ROOM_GRID / 2) * ROOM_STEP_CHUNKS, (zIndex - ROOM_GRID / 2) * ROOM_STEP_CHUNKS);
String biome = sampler.biome(chunk.getMinBlockX(), y, chunk.getMinBlockZ());
Holder<Biome> holder = sampler.registries().lookupOrThrow(Registries.BIOME)
.getOrThrow(ResourceKey.create(Registries.BIOME, Identifier.parse(biome)));
if (structure.biomes().contains(holder)) {
matches.add(chunk);
}
}
}
System.out.println(String.format(Locale.ROOT, "%s subterrain %s biome match %d/%d at y %d", LOG_PREFIX, entry.getKey(),
matches.size(), ROOM_GRID * ROOM_GRID, y));
assertFalse(entry.getKey() + " finds no biome at y " + y, matches.isEmpty());
StructureStart start = null;
for (ChunkPos chunk : matches.subList(0, Math.min(ROOM_ATTEMPTS, matches.size()))) {
StructureStart candidate = sampler.structureStart(entry.getKey(), chunk.x(), chunk.z());
if (candidate.isValid()) {
start = candidate;
BoundingBox box = start.getBoundingBox();
assertTrue(entry.getKey() + " " + box, chunk.getMinBlockX() - box.minX() <= ROOM_REACH && box.maxX() - chunk.getMinBlockX() <= ROOM_REACH);
assertTrue(entry.getKey() + " " + box, chunk.getMinBlockZ() - box.minZ() <= ROOM_REACH && box.maxZ() - chunk.getMinBlockZ() <= ROOM_REACH);
assertTrue(entry.getKey() + " " + box, box.minY() >= sampler.minY() && box.maxY() < sampler.minY() + sampler.height());
break;
}
}
assertTrue(entry.getKey() + " never starts", start != null);
}
}
}
private static List<BlockPos> openLavaBandCells(WorldgenExportSampler sampler, String field) {
List<BlockPos> cells = new ArrayList<>(LAVA_CELLS);
for (int x = -LAVA_SEARCH_RADIUS; x < LAVA_SEARCH_RADIUS; x += LAVA_SEARCH_STEP) {
for (int z = -LAVA_SEARCH_RADIUS; z < LAVA_SEARCH_RADIUS; z += LAVA_SEARCH_STEP) {
for (int y = sampler.minY(); y <= OVERWORLD_LAVA_Y; y++) {
if (sampler.density(field, x, y, z) > -OPEN_MARGIN) {
continue;
}
cells.add(new BlockPos(x, y, z));
if (cells.size() == LAVA_CELLS) {
return cells;
}
}
}
}
return cells;
}
private static synchronized SortedMap<String, byte[]> overworldFiles() throws Exception {
String probePack = System.getProperty(PROBE_PACK);
Assume.assumeTrue("Set -P" + PROBE_PACK + " to export a real pack", probePack != null && !probePack.isBlank());
if (exportedOverworld != null) {
return exportedOverworld;
}
Path root = overworldFolder.newFolder("exports").toPath();
SortedMap<String, byte[]> untrimmed = new TreeMap<>();
AtomicReference<ExportIr> compiled = new AtomicReference<>();
WorldgenExportResult result;
try (RealPackProbeSupport.Workspace workspace = RealPackProbeSupport.openWorkspace(new RealPackProbeSupport.WorkspaceOptions(
new File(probePack), OVERWORLD, LOG_PREFIX, new HeadlessNativeRuntime(), overworldFolder.newFolder("workspace").toPath()))) {
WorldgenExportRequest request = WorldgenExportRequest.builder()
.pack(workspace.pack().toPath())
.dimension(OVERWORLD)
.exportRoot(root)
.objects(true)
.build();
result = new WorldgenDatapackExporter(target -> ir -> {
compiled.set(ir);
SortedMap<String, byte[]> written = WorldgenJsonWriter.forTarget(target).write(ir);
untrimmed.putAll(written);
return written;
}, PackCompiler.create()).export(request, WorldgenExportProgress.NONE);
}
assertTrue(result.message() + " " + result.diagnostics(), result.isSuccess());
SortedMap<String, byte[]> files = zipEntries(result.output());
Path untrimmedZip = root.resolve("untrimmed.zip");
new AtomicDatapackPublisher().publish(untrimmed, untrimmedZip, DatapackExportFormat.ZIP, false, ".untrimmed-");
report(result, untrimmed, Files.size(untrimmedZip), files);
exportedOverworld = files;
exportedOverworldResult = result;
exportedOverworldIr = compiled.get();
return files;
}
private static void assertGenerationBudget(WorldgenExportResult result) {
GenerationCost cost = result.cost();
Set<String> flagged = new TreeSet<>();
for (ExportDiagnostic diagnostic : result.diagnostics()) {
if (diagnostic.limit() == ExportLimit.GENERATION_COST) {
flagged.add(diagnostic.resource());
}
}
for (GenerationCost.Root root : GenerationCost.Root.values()) {
GenerationCost.RootCost rootCost = cost.root(root).orElseThrow();
System.out.println(String.format(Locale.ROOT, "%s cost %s worst case %.3e expanded %.3e distinct %d", LOG_PREFIX, root.id(),
rootCost.worstCaseNodes(), rootCost.expandedNodes(), rootCost.distinctNodes()));
}
for (GenerationCost.Root root : GenerationCost.Root.values()) {
if (!root.climate()) {
continue;
}
double limit = GenerationBudget.worstCaseLimit(root).orElseThrow();
double nodes = cost.root(root).orElseThrow().worstCaseNodes();
String resource = "noise_settings/" + OVERWORLD + "/" + root.id();
assertTrue(root.id() + " evaluates " + nodes + " nodes, budget " + limit, nodes <= limit);
assertFalse(resource + " " + flagged, flagged.contains(resource));
}
long finalDensity = cost.root(GenerationCost.Root.FINAL_DENSITY).orElseThrow().distinctNodes();
String finalResource = "noise_settings/" + OVERWORLD + "/" + GenerationCost.Root.FINAL_DENSITY.id();
System.out.println(String.format(Locale.ROOT, "%s final_density distinct %d of %d, density function JSON %.1f of %.1f MB", LOG_PREFIX, finalDensity,
GenerationBudget.FINAL_DENSITY_DISTINCT_NODES, cost.densityFunctionBytes() / MEGABYTE, GenerationBudget.DENSITY_FUNCTION_JSON_BYTES / MEGABYTE));
assertEquals(finalResource + " " + flagged, finalDensity > GenerationBudget.FINAL_DENSITY_DISTINCT_NODES, flagged.contains(finalResource));
assertTrue(cost.densityFunctionBytes() + " bytes", cost.densityFunctionBytes() > 0L);
assertTrue(cost.densityFunctionBytes() + " bytes", cost.densityFunctionBytes() <= GenerationBudget.DENSITY_FUNCTION_JSON_BYTES);
assertFalse(flagged.toString(), flagged.contains(DENSITY_FUNCTION_RESOURCE));
}
private static void assertWallPalettesFallBack(WorldgenExportResult result) {
int fallbacks = 0;
for (ExportDiagnostic diagnostic : result.diagnostics()) {
if (diagnostic.limit() == ExportLimit.WALL_PALETTE && diagnostic.severity() == ExportDiagnostic.Severity.WARNING) {
assertTrue(diagnostic.resource() + " " + diagnostic.message(), diagnostic.message().contains(WALL_FALLBACK));
fallbacks++;
}
}
System.out.println(LOG_PREFIX + " wall palettes over the ore vein budget " + fallbacks);
}
private static void assertNaturalHeightMatchesTheEvaluator(ExportIr ir, WorldgenExportSampler sampler) {
Field.Shared height = shared(ir.router().chunkSurfaceLevel(), HeightCompiler.HEIGHT);
Map<String, NoiseDef> noises = new HashMap<>();
for (NoiseDef noise : ir.noises()) {
noises.put(ir.namespace() + ":" + noise.id(), noise);
}
String heightId = ir.namespace() + ":" + HeightCompiler.HEIGHT;
int outliers = 0;
StringBuilder detail = new StringBuilder();
for (int xIndex = 0; xIndex < AGREEMENT_POINTS; xIndex++) {
for (int zIndex = 0; zIndex < AGREEMENT_POINTS; zIndex++) {
int x = (xIndex - AGREEMENT_POINTS / 2) * OVERWORLD_STEP;
int z = (zIndex - AGREEMENT_POINTS / 2) * OVERWORLD_STEP;
double evaluated = FieldEvaluator.sample(height.expr(), noises, SEED, DataVersion.V26_3, x, 0, z);
double exported = sampler.density(heightId, x, 0, z);
if (Math.abs(evaluated - exported) > AGREEMENT_TOLERANCE) {
outliers++;
detail.append(' ').append(x).append(',').append(z).append('=').append(evaluated).append('/').append(exported);
}
}
}
assertTrue(outliers + " columns differ:" + detail, outliers <= AGREEMENT_OUTLIERS);
}
private static Field.Shared shared(Field root, String name) {
Set<Field> visited = Collections.newSetFromMap(new IdentityHashMap<>());
Deque<Field> pending = new ArrayDeque<>();
pending.push(root);
while (!pending.isEmpty()) {
Field field = pending.pop();
if (!visited.add(field)) {
continue;
}
if (field instanceof Field.Shared candidate && candidate.name().equals(name)) {
return candidate;
}
for (Field child : FieldMacros.children(field)) {
pending.push(child);
}
}
throw new AssertionError("Router has no shared field " + name);
}
private static void assertCompleteGzip(String path, byte[] bytes) throws IOException {
try (InputStream input = new GZIPInputStream(new ByteArrayInputStream(bytes))) {
input.readAllBytes();
assertEquals(path, -1, input.read());
}
}
private static void report(WorldgenExportResult result, SortedMap<String, byte[]> untrimmed, long untrimmedZipBytes,
SortedMap<String, byte[]> files) throws IOException {
SortedMap<ExportLimit, Integer> limits = new TreeMap<>();
for (ExportDiagnostic diagnostic : result.diagnostics()) {
limits.merge(diagnostic.limit(), 1, Integer::sum);
}
System.out.println(LOG_PREFIX + " untrimmed zip bytes " + untrimmedZipBytes + ", files " + untrimmed.size()
+ ", trimmed zip bytes " + Files.size(result.output()) + ", files " + files.size() + ", trim " + result.trim());
System.out.println(LOG_PREFIX + " diagnostics " + result.diagnostics().size() + " " + limits);
reportCounts("untrimmed", untrimmed);
reportCounts("trimmed", files);
}
private static void reportCounts(String label, SortedMap<String, byte[]> files) {
System.out.println(LOG_PREFIX + " " + label + " templates " + count(files, "/structure/", ".nbt")
+ ", structures " + count(files, "/worldgen/structure/", ".json")
+ ", structure sets " + count(files, "/worldgen/structure_set/", ".json")
+ ", placed features " + count(files, "/worldgen/placed_feature/", ".json")
+ ", features " + count(files, "/worldgen/feature/", ".json")
+ ", density functions " + count(files, "/worldgen/density_function/", ".json")
+ ", noises " + count(files, "/worldgen/noise/", ".json")
+ ", biomes " + count(files, "/worldgen/biome/", ".json"));
}
private static int count(SortedMap<String, byte[]> files, String folder, String suffix) {
int count = 0;
for (String path : files.keySet()) {
if (path.contains(folder) && path.endsWith(suffix)) {
count++;
}
}
return count;
}
private static SortedMap<String, byte[]> zipEntries(Path zip) throws IOException {
SortedMap<String, byte[]> files = new TreeMap<>();
try (ZipFile archive = new ZipFile(zip.toFile())) {
Enumeration<? extends ZipEntry> entries = archive.entries();
while (entries.hasMoreElements()) {
ZipEntry entry = entries.nextElement();
if (!entry.isDirectory()) {
files.put(entry.getName(), archive.getInputStream(entry).readAllBytes());
}
}
}
return files;
}
private static Set<String> exportedBiomes(SortedMap<String, byte[]> files, String prefix, String namespace) {
Set<String> biomes = new TreeSet<>();
for (String path : files.keySet()) {
if (path.startsWith(prefix)) {
biomes.add(namespace + ":" + path.substring(prefix.length(), path.length() - ".json".length()));
}
}
return biomes;
}
private static final class MinMaxAudit implements DfRewriteRule {
private final Set<DensityFunction> visited = Collections.newSetFromMap(new IdentityHashMap<>());
private final List<String> disjoint = new ArrayList<>();
private int checked;
@Override
public DensityFunction rewrite(DensityFunction function) {
if (!visited.add(function)) {
return function;
}
if (function instanceof BinaryFunction binary && (binary.type() == BinaryFunction.Type.MIN || binary.type() == BinaryFunction.Type.MAX)) {
checked++;
Interval left = binary.left().range();
Interval right = binary.right().range();
if (left.max() < right.min() || right.max() < left.min()) {
disjoint.add(binary.type() + " " + left + " " + right);
}
}
function.rewriteChildren(this);
return function;
}
private int checked() {
return checked;
}
private List<String> disjoint() {
return disjoint;
}
}
}
@@ -0,0 +1,136 @@
package art.arcane.iris.probe;
import art.arcane.iris.pack.datapack.export.ExportTestPacks;
import art.arcane.iris.pack.datapack.export.WorldgenDatapackExporter;
import art.arcane.iris.pack.datapack.export.WorldgenExportMode;
import art.arcane.iris.pack.datapack.export.WorldgenExportProgress;
import art.arcane.iris.pack.datapack.export.WorldgenExportRequest;
import art.arcane.iris.pack.datapack.export.WorldgenExportResult;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.ProtoChunk;
import net.minecraft.world.level.levelgen.densityfunction.DensityFunction;
import org.junit.ClassRule;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
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.Set;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public final class WorldgenExportDeepCaves263Test {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
private static final long SEED = 1337L;
private static final String NAMESPACE = WorldgenExportRequest.DEFAULT_NAMESPACE;
private static final String DIMENSION_ID = NAMESPACE + ":" + ExportTestPacks.DIMENSION;
private static final int MIN_Y = -64;
private static final int LAVA_Y = -62;
private static final int LAVA_LINE = -54;
private static final int CAVE_TOP = -14;
private static final int CHUNK_WIDTH = 16;
private static final String CAVE_FIELD = "cave/field";
private static final String DIMENSION = """
{
"regions": ["region"],
"dimensionHeight": {"min": -64, "max": 192},
"fluidHeight": 63,
"caveLavaHeight": 2,
"caveProfile": {
"enabled": true,
"verticalRange": {"min": 0, "max": 50},
"verticalEdgeFade": 0,
"baseDensityStyle": {"style": "SIMPLEX", "zoom": 0.6},
"detailDensityStyle": {"style": "SIMPLEX", "zoom": 0.3},
"baseWeight": 1.0,
"detailWeight": 0.0,
"densityThreshold": {"min": 5.0, "max": 5.0, "style": {"style": "SIMPLEX"}},
"thresholdBias": 0.0,
"surfaceClearance": 0,
"allowSurfaceBreak": false,
"allowFluid": false,
"allowLava": true,
"modules": []
}
}
""";
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Rule
public ExportTestPacks.BoundPlatform platform = new ExportTestPacks.BoundPlatform();
@Test
public void deepCavesOpenAsAirAboveTheIrisLavaBand() throws IOException {
WorldgenExportResult result = export();
assertTrue(result.message() + " " + result.diagnostics(), result.isSuccess());
JsonObject head = JsonParser.parseString(Files.readString(result.output().resolve("data/" + NAMESPACE + "/worldgen/material_rule/"
+ ExportTestPacks.DIMENSION + ".json"), StandardCharsets.UTF_8)).getAsJsonObject().getAsJsonArray("sequence").get(0).getAsJsonObject();
assertEquals("minecraft:ore_vein", head.get("type").getAsString());
String opening = head.get("density").getAsString();
assertTrue(opening, Files.isRegularFile(densityFunction(result.output(), opening)));
try (WorldgenExportSampler sampler = WorldgenExportSampler.open(result.output(), WorldgenExportMode.OVERWORLD, DIMENSION_ID, SEED)) {
ProtoChunk chunk = sampler.terrainChunk(0, 0);
for (int x = 0; x < CHUNK_WIDTH; x++) {
for (int z = 0; z < CHUNK_WIDTH; z++) {
for (int y = MIN_Y; y <= CAVE_TOP; y++) {
BlockState state = chunk.getBlockState(new BlockPos(x, y, z));
String where = state + " at " + x + "," + y + "," + z;
if (y <= LAVA_Y) {
assertTrue(where, state.is(Blocks.LAVA));
} else {
assertTrue(where, state.isAir());
}
}
}
}
assertTrue(chunk.getBlockState(new BlockPos(0, LAVA_LINE - 1, 0)).isAir());
}
}
@Test
public void deepCaveRuleSharesTheCaveFieldCacheWithTheFinalDensity() throws IOException {
WorldgenExportResult result = export();
assertTrue(result.message() + " " + result.diagnostics(), result.isSuccess());
try (WorldgenExportSampler sampler = WorldgenExportSampler.open(result.output(), WorldgenExportMode.OVERWORLD, DIMENSION_ID, SEED)) {
CompiledCaches caches = CompiledCaches.open(sampler.registries(), SEED);
DensityFunction opening = caches.oreVeins().getFirst().density();
int field = caches.rootId(caches.function(NAMESPACE + ":" + CAVE_FIELD));
Set<Integer> finalDensity = caches.ids(caches.finalDensity());
assertTrue(caches.ids(opening).toString(), caches.ids(opening).contains(field));
assertTrue(finalDensity.toString(), finalDensity.contains(field));
assertTrue(finalDensity.toString(), finalDensity.contains(caches.rootId(opening)));
}
}
private WorldgenExportResult export() throws IOException {
File pack = ExportTestPacks.withDimension(temporaryFolder.newFolder("pack"), DIMENSION);
WorldgenExportRequest request = WorldgenExportRequest.builder()
.pack(pack.toPath())
.dimension(ExportTestPacks.DIMENSION)
.exportRoot(temporaryFolder.newFolder("exports").toPath())
.format("directory")
.build();
return WorldgenDatapackExporter.create().export(request, WorldgenExportProgress.NONE);
}
private static Path densityFunction(Path datapack, String id) {
int separator = id.indexOf(':');
return datapack.resolve("data/" + id.substring(0, separator) + "/worldgen/density_function/" + id.substring(separator + 1) + ".json");
}
}
@@ -0,0 +1,75 @@
package art.arcane.iris.probe;
import art.arcane.iris.pack.datapack.export.ExportTestPacks;
import art.arcane.iris.pack.datapack.export.WorldgenDatapackExporter;
import art.arcane.iris.pack.datapack.export.WorldgenExportMode;
import art.arcane.iris.pack.datapack.export.WorldgenExportProgress;
import art.arcane.iris.pack.datapack.export.WorldgenExportRequest;
import art.arcane.iris.pack.datapack.export.WorldgenExportResult;
import org.junit.ClassRule;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public final class WorldgenExportPipeline263Test {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
private static final long SEED = 1337L;
private static final String DIMENSION_ID = WorldgenExportRequest.DEFAULT_NAMESPACE + ":" + ExportTestPacks.DIMENSION;
private static final int[] SAMPLE_Y = {-64, 0, 64, 200};
private static final int LATTICE = 8;
private static final int LATTICE_STEP = 61;
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Rule
public ExportTestPacks.BoundPlatform platform = new ExportTestPacks.BoundPlatform();
@Test
public void exportedPackLoadsAndSamples() throws IOException {
File pack = ExportTestPacks.basic(temporaryFolder.newFolder("pack"));
Path root = temporaryFolder.newFolder("exports").toPath();
WorldgenExportRequest request = WorldgenExportRequest.builder()
.pack(pack.toPath())
.dimension(ExportTestPacks.DIMENSION)
.exportRoot(root)
.format("directory")
.build();
WorldgenExportResult result = WorldgenDatapackExporter.create().export(request, WorldgenExportProgress.NONE);
assertTrue(result.message() + " " + result.diagnostics(), result.isSuccess());
try (WorldgenExportSampler sampler = WorldgenExportSampler.open(result.output(), WorldgenExportMode.OVERWORLD, DIMENSION_ID, SEED)) {
assertFalse(sampler.densityFunctionIds().isEmpty());
for (String id : sampler.densityFunctionIds()) {
for (int y : SAMPLE_Y) {
for (int xIndex = 0; xIndex < LATTICE; xIndex++) {
for (int zIndex = 0; zIndex < LATTICE; zIndex++) {
int x = (xIndex - LATTICE / 2) * LATTICE_STEP;
int z = (zIndex - LATTICE / 2) * LATTICE_STEP;
float value = sampler.density(id, x, y, z);
assertTrue(id + " at " + x + "," + y + "," + z + " = " + value, Float.isFinite(value));
}
}
}
}
for (int xIndex = 0; xIndex < LATTICE; xIndex++) {
for (int zIndex = 0; zIndex < LATTICE; zIndex++) {
int x = (xIndex - LATTICE / 2) * LATTICE_STEP;
int z = (zIndex - LATTICE / 2) * LATTICE_STEP;
int height = sampler.baseHeight(x, z);
assertTrue("height " + height + " at " + x + "," + z, height >= sampler.minY() && height <= sampler.minY() + sampler.height());
}
}
}
}
}
@@ -0,0 +1,97 @@
package art.arcane.iris.probe;
import art.arcane.iris.pack.datapack.export.ExportTestPacks;
import art.arcane.iris.pack.datapack.export.WorldgenDatapackExporter;
import art.arcane.iris.pack.datapack.export.WorldgenExportMode;
import art.arcane.iris.pack.datapack.export.WorldgenExportProgress;
import art.arcane.iris.pack.datapack.export.WorldgenExportRequest;
import art.arcane.iris.pack.datapack.export.WorldgenExportResult;
import org.junit.ClassRule;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.File;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public final class WorldgenExportSamplerThreadsTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
private static final long SEED = 1337L;
private static final String DIMENSION_ID = WorldgenExportRequest.DEFAULT_NAMESPACE + ":" + ExportTestPacks.DIMENSION;
private static final String FINAL_DENSITY_ID = DIMENSION_ID + "/final_density";
private static final int THREADS = 8;
private static final int POINTS = 2048;
private static final int ROW = 64;
private static final int STEP = 37;
private static final int ROUNDS = 3;
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Rule
public ExportTestPacks.BoundPlatform platform = new ExportTestPacks.BoundPlatform();
@Test
public void concurrentSamplingMatchesSequentialSampling() throws Exception {
File pack = ExportTestPacks.basic(temporaryFolder.newFolder("pack"));
WorldgenExportRequest request = WorldgenExportRequest.builder()
.pack(pack.toPath())
.dimension(ExportTestPacks.DIMENSION)
.exportRoot(temporaryFolder.newFolder("exports").toPath())
.format("directory")
.build();
WorldgenExportResult result = WorldgenDatapackExporter.create().export(request, WorldgenExportProgress.NONE);
assertTrue(result.message() + " " + result.diagnostics(), result.isSuccess());
try (WorldgenExportSampler sampler = WorldgenExportSampler.open(result.output(), WorldgenExportMode.OVERWORLD, DIMENSION_ID, SEED)) {
assertTrue(sampler.densityFunctionIds().toString(), sampler.densityFunctionIds().contains(FINAL_DENSITY_ID));
Sample expected = sweep(sampler, 0, 1).call();
ExecutorService executor = Executors.newFixedThreadPool(THREADS);
try {
List<Future<Sample>> futures = new ArrayList<>(THREADS);
for (int thread = 0; thread < THREADS; thread++) {
futures.add(executor.submit(sweep(sampler, thread * (POINTS / THREADS), ROUNDS)));
}
for (Future<Sample> future : futures) {
Sample sample = future.get();
assertArrayEquals(expected.density(), sample.density(), 0.0F);
assertEquals(List.of(expected.biome()), List.of(sample.biome()));
}
} finally {
executor.shutdownNow();
}
}
}
private static Callable<Sample> sweep(WorldgenExportSampler sampler, int offset, int rounds) {
return () -> {
float[] density = new float[POINTS];
String[] biome = new String[POINTS];
for (int round = 0; round < rounds; round++) {
for (int step = 0; step < POINTS; step++) {
int index = (offset + step) % POINTS;
int x = (index % ROW - ROW / 2) * STEP;
int y = (index * 7) % 256 - 64;
int z = (index / ROW - POINTS / ROW / 2) * STEP;
density[index] = sampler.density(FINAL_DENSITY_ID, x, y, z);
biome[index] = sampler.biome(x, y, z);
}
}
return new Sample(density, biome);
};
}
private record Sample(float[] density, String[] biome) {
}
}
@@ -0,0 +1,90 @@
package art.arcane.iris.probe;
import art.arcane.iris.pack.datapack.export.ExportTestPacks;
import art.arcane.iris.pack.datapack.export.WorldgenDatapackExporter;
import art.arcane.iris.pack.datapack.export.WorldgenExportMode;
import art.arcane.iris.pack.datapack.export.WorldgenExportProgress;
import art.arcane.iris.pack.datapack.export.WorldgenExportRequest;
import art.arcane.iris.pack.datapack.export.WorldgenExportResult;
import art.arcane.iris.testsupport.CompatTestPlatform;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.ProtoChunk;
import org.junit.ClassRule;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.File;
import java.io.IOException;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public final class WorldgenExportSeaLevel263Test {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
private static final long SEED = 1337L;
private static final String DIMENSION_ID = WorldgenExportRequest.DEFAULT_NAMESPACE + ":" + ExportTestPacks.DIMENSION;
private static final int FLUID_HEIGHT = 40;
private static final int SEA_FLOOR = 28;
private static final int CHUNK_WIDTH = 16;
private static final String DIMENSION = """
{
"regions": ["region"],
"dimensionHeight": {"min": -64, "max": 192},
"fluidHeight": 40,
"landChance": 0,
"carvingEnabled": false
}
""";
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Rule
public ExportTestPacks.BoundPlatform platform = new ExportTestPacks.BoundPlatform();
@Test
public void waterFillsThroughTheIrisFluidHeight() throws IOException {
WorldgenExportResult result = export();
assertTrue(result.message() + " " + result.diagnostics(), result.isSuccess());
try (WorldgenExportSampler sampler = WorldgenExportSampler.open(result.output(), WorldgenExportMode.OVERWORLD, DIMENSION_ID, SEED)) {
ProtoChunk chunk = sampler.terrainChunk(0, 0);
for (int x = 0; x < CHUNK_WIDTH; x++) {
for (int z = 0; z < CHUNK_WIDTH; z++) {
assertSolid(chunk.getBlockState(new BlockPos(x, SEA_FLOOR, z)), x, z);
for (int y = SEA_FLOOR + 1; y <= FLUID_HEIGHT; y++) {
BlockState state = chunk.getBlockState(new BlockPos(x, y, z));
assertTrue(state + " at " + x + "," + y + "," + z, state.is(Blocks.WATER));
}
BlockState above = chunk.getBlockState(new BlockPos(x, FLUID_HEIGHT + 1, z));
assertTrue(above + " above the fluid height at " + x + "," + z, above.isAir());
}
}
assertEquals(FLUID_HEIGHT + 1, sampler.seaLevel());
}
}
private static void assertSolid(BlockState state, int x, int z) {
assertTrue(state + " sea floor at " + x + "," + z, !state.isAir() && state.getFluidState().isEmpty());
}
private WorldgenExportResult export() throws IOException {
File pack = ExportTestPacks.withDimension(temporaryFolder.newFolder("pack"), DIMENSION);
CompatTestPlatform.write(pack, "generators/flat.json", "{\"composite\":[]}");
CompatTestPlatform.write(pack, "biomes/ocean.json", """
{"name":"Ocean","generators":[{"generator":"flat","min":-12,"max":-12}]}
""");
WorldgenExportRequest request = WorldgenExportRequest.builder()
.pack(pack.toPath())
.dimension(ExportTestPacks.DIMENSION)
.exportRoot(temporaryFolder.newFolder("exports").toPath())
.format("directory")
.build();
return WorldgenDatapackExporter.create().export(request, WorldgenExportProgress.NONE);
}
}
@@ -0,0 +1,165 @@
package art.arcane.iris.probe;
import art.arcane.iris.pack.datapack.export.ExportTestPacks;
import art.arcane.iris.pack.datapack.export.WorldgenDatapackExporter;
import art.arcane.iris.pack.datapack.export.WorldgenExportMode;
import art.arcane.iris.pack.datapack.export.WorldgenExportProgress;
import art.arcane.iris.pack.datapack.export.WorldgenExportRequest;
import art.arcane.iris.pack.datapack.export.WorldgenExportResult;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.core.registries.Registries;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.NbtAccounter;
import net.minecraft.nbt.NbtIo;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.Rotation;
import net.minecraft.world.level.levelgen.GenerationStep;
import net.minecraft.world.level.levelgen.structure.BoundingBox;
import net.minecraft.world.level.levelgen.structure.PoolElementStructurePiece;
import net.minecraft.world.level.levelgen.structure.Structure;
import net.minecraft.world.level.levelgen.structure.StructureSet;
import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.structure.placement.RandomSpreadStructurePlacement;
import net.minecraft.world.level.levelgen.structure.pools.ListPoolElement;
import net.minecraft.world.level.levelgen.structure.structures.JigsawStructure;
import net.minecraft.world.level.levelgen.structure.templatesystem.StructurePlaceSettings;
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplate;
import org.junit.ClassRule;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.stream.Stream;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public final class WorldgenExportSubterrain263Test {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
private static final long SEED = 1337L;
private static final String DIMENSION_ID = WorldgenExportRequest.DEFAULT_NAMESPACE + ":" + ExportTestPacks.DIMENSION;
private static final String POOLS = WorldgenExportRequest.DEFAULT_NAMESPACE + ":subterrain/pools";
private static final String TUBES = WorldgenExportRequest.DEFAULT_NAMESPACE + ":subterrain/tubes";
private static final String ANCHOR = WorldgenExportRequest.DEFAULT_NAMESPACE + ":subterrain/anchor";
private static final String TEMPLATES = "data/" + WorldgenExportRequest.DEFAULT_NAMESPACE + "/structure/subterrain/";
private static final int CHUNK_X = 3;
private static final int CHUNK_Z = -2;
private static final int POOLS_CENTER_Y = -49;
private static final int POOLS_BELOW_CENTER = 10;
private static final int POOLS_EXTENT = 10;
private static final int MAX_REACH = 128;
private static final String DIMENSION = """
{"regions": ["region"], "subterrainFeatures": [
{"id": "pools", "family": "CENOTE", "worldYRange": {"min": -60, "max": -40}, "spacing": 64, "probability": 1.0,
"radius": 8, "height": 12, "fluid": "WATER", "fluidDepth": 2, "shapeWarp": 0.5, "pillarSpacing": 0,
"formationFraction": 0.0, "solid": "minecraft:stone"},
{"id": "tubes", "family": "LAVA_TUBE", "allowedRegions": ["region"], "worldYRange": {"min": -60, "max": 40},
"spacing": 320, "probability": 0.5, "length": 300, "radius": 6, "height": 8, "fluidDepth": 2, "chimneyHeight": 0,
"shapeWarp": 0.0, "pillarSpacing": 0, "formationFraction": 0.0, "solid": "minecraft:stone"}]}
""";
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Rule
public ExportTestPacks.BoundPlatform platform = new ExportTestPacks.BoundPlatform();
@Test
public void subterrainRoomsLoadAndPlaceThroughRealCodecs() throws IOException {
File pack = ExportTestPacks.withDimension(temporaryFolder.newFolder("pack"), DIMENSION);
WorldgenExportRequest request = WorldgenExportRequest.builder()
.pack(pack.toPath())
.dimension(ExportTestPacks.DIMENSION)
.exportRoot(temporaryFolder.newFolder("exports").toPath())
.format("directory")
.build();
WorldgenExportResult result = WorldgenDatapackExporter.create().export(request, WorldgenExportProgress.NONE);
assertTrue(result.message() + " " + result.diagnostics(), result.isSuccess());
try (WorldgenExportSampler sampler = WorldgenExportSampler.open(result.output(), WorldgenExportMode.OVERWORLD, DIMENSION_ID, SEED)) {
assertRegistered(sampler, POOLS, 4, 2);
assertRegistered(sampler, TUBES, 20, 10);
int cornerX = CHUNK_X << 4;
int cornerZ = CHUNK_Z << 4;
BoundingBox pools = placedBox(sampler, POOLS);
assertEquals(cornerX - POOLS_EXTENT, pools.minX());
assertEquals(cornerX + POOLS_EXTENT, pools.maxX());
assertEquals(cornerZ - POOLS_EXTENT, pools.minZ());
assertEquals(cornerZ + POOLS_EXTENT, pools.maxZ());
assertEquals(POOLS_CENTER_Y - POOLS_BELOW_CENTER, pools.minY());
BoundingBox tubes = placedBox(sampler, TUBES);
assertEquals(2 * MAX_REACH + 1, Math.max(tubes.getXSpan(), tubes.getZSpan()));
assertTrue(tubes.toString(), tubes.minX() >= cornerX - MAX_REACH && tubes.maxX() <= cornerX + MAX_REACH);
assertTrue(tubes.toString(), tubes.minZ() >= cornerZ - MAX_REACH && tubes.maxZ() <= cornerZ + MAX_REACH);
assertTrue(tubes.toString(), tubes.minY() >= -60 && tubes.maxY() <= 40);
}
assertTemplatesResolve(result.output());
}
private static void assertRegistered(WorldgenExportSampler sampler, String id, int spacing, int separation) {
Identifier identifier = Identifier.parse(id);
Structure structure = sampler.registries().lookupOrThrow(Registries.STRUCTURE)
.getOrThrow(ResourceKey.create(Registries.STRUCTURE, identifier)).value();
assertTrue(id, structure instanceof JigsawStructure);
assertEquals(id, GenerationStep.Decoration.UNDERGROUND_STRUCTURES, structure.step());
StructureSet set = sampler.registries().lookupOrThrow(Registries.STRUCTURE_SET)
.getOrThrow(ResourceKey.create(Registries.STRUCTURE_SET, identifier)).value();
RandomSpreadStructurePlacement placement = (RandomSpreadStructurePlacement) set.placement();
assertEquals(id, spacing, placement.spacing());
assertEquals(id, separation, placement.separation());
assertTrue(id, sampler.registries().lookupOrThrow(Registries.TEMPLATE_POOL).containsKey(identifier));
}
private static BoundingBox placedBox(WorldgenExportSampler sampler, String id) throws IOException {
StructureStart start = sampler.structureStart(id, CHUNK_X, CHUNK_Z);
assertTrue(id, start.isValid());
assertEquals(id, 1, start.getPieces().size());
PoolElementStructurePiece piece = (PoolElementStructurePiece) start.getPieces().getFirst();
assertTrue(id + " " + piece.getElement(), piece.getElement() instanceof ListPoolElement);
return start.getBoundingBox();
}
private static void assertTemplatesResolve(Path datapack) throws IOException {
List<Path> templates;
try (Stream<Path> paths = Files.walk(datapack.resolve(TEMPLATES))) {
templates = paths.filter(path -> path.toString().endsWith(".nbt")).sorted().toList();
}
assertEquals(12, templates.size());
boolean lava = false;
for (Path path : templates) {
CompoundTag tag;
try (InputStream input = Files.newInputStream(path)) {
tag = NbtIo.readCompressed(input, NbtAccounter.unlimitedHeap());
}
StructureTemplate template = new StructureTemplate();
template.load(BuiltInRegistries.BLOCK, tag);
StructurePlaceSettings settings = new StructurePlaceSettings();
assertTrue(path.toString(), template.filterBlocks(BlockPos.ZERO, settings, Blocks.AIR).isEmpty());
if (path.getFileName().toString().endsWith("_core.nbt")) {
assertFalse(path.toString(), template.filterBlocks(BlockPos.ZERO, settings, Blocks.CAVE_AIR).isEmpty());
List<StructureTemplate.JigsawBlockInfo> jigsaws = template.getJigsaws(BlockPos.ZERO, Rotation.NONE);
assertEquals(path.toString(), 1, jigsaws.size());
assertEquals(ANCHOR, jigsaws.getFirst().name().toString());
lava |= !template.filterBlocks(BlockPos.ZERO, settings, Blocks.LAVA).isEmpty();
} else {
assertFalse(path.toString(), template.filterBlocks(BlockPos.ZERO, settings, Blocks.STONE).isEmpty());
}
}
assertTrue("no lava tube template holds lava", lava);
}
}
@@ -0,0 +1,111 @@
package art.arcane.iris.probe;
import art.arcane.iris.pack.datapack.DataVersion;
import art.arcane.iris.pack.datapack.export.ExportIrFixtures;
import art.arcane.iris.pack.datapack.export.WorldgenExportMode;
import art.arcane.iris.pack.datapack.export.emit.WorldgenJsonWriter;
import net.minecraft.world.level.levelgen.Heightmap;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.ClassRule;
import org.junit.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.SortedMap;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public final class WorldgenStageBenchTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
private static final long SEED = 1337L;
private static final int SIDE = 2;
private static final WorldgenStageBench.CallBudget BUDGET = new WorldgenStageBench.CallBudget(32, 8, 0L);
private static HeadlessNativeRegistries loaded;
@BeforeClass
public static void loadFixture() throws IOException {
HeadlessNativeBootstrap.initialize();
SortedMap<String, byte[]> files = WorldgenJsonWriter.forTarget(DataVersion.V26_3)
.write(ExportIrFixtures.flatNoise(DataVersion.V26_3, WorldgenExportMode.PRESET));
Path directory = Files.createTempDirectory("iris-stage-bench-");
for (Map.Entry<String, byte[]> file : files.entrySet()) {
Path target = directory.resolve(file.getKey());
Files.createDirectories(target.getParent());
Files.write(target, file.getValue());
}
try (PaperRegistryScope scope = PaperRegistryScope.open()) {
loaded = HeadlessNativeRegistries.load(directory);
}
}
@AfterClass
public static void closeFixture() throws IOException {
if (loaded != null) {
loaded.close();
}
}
@Test
public void exportedFixtureRunsEveryStage() {
assertStages(bench("export", ExportIrFixtures.NAMESPACE + ":" + ExportIrFixtures.DIMENSION));
}
@Test
public void vanillaOverworldRunsEveryStage() {
assertStages(bench("vanilla", DatapackCostProbe.VANILLA_PRESET));
}
private static void assertStages(WorldgenStageBench bench) {
WorldgenStageBench.ChunkSamples samples = new WorldgenStageBench.ChunkSamples();
bench.chunks(0, 0, SIDE, samples);
bench.chunks(SIDE + 4, 0, SIDE, samples);
WorldgenStageBench.ChunkTimings timings = samples.timings();
assertEquals(2 * SIDE * SIDE, timings.chunks());
assertEquals(2 * (SIDE + 2) * (SIDE + 2), timings.biomes().wall().count());
assertEquals(2 * SIDE * SIDE, timings.noise().cpu().count());
assertEquals(2 * SIDE * SIDE, timings.surface().wall().count());
assertEquals(2 * SIDE * SIDE, timings.carverBiome().wall().count());
assertEquals(0L, timings.biomeFallbacks());
assertTrue(Double.isFinite(timings.totalMean()));
assertTrue(Double.isFinite(timings.totalCpuMean()));
assertCalls(samples(bench, Call.UNCACHED_BIOME));
assertCalls(samples(bench, Call.BASE_HEIGHT));
assertCalls(samples(bench, Call.BASE_COLUMN));
}
private static void assertCalls(WorldgenStageBench.CallTiming timing) {
assertEquals(2 * BUDGET.minimumCalls(), timing.calls());
assertEquals(2 * BUDGET.minimumCalls(), timing.wall().count());
assertTrue(Double.isFinite(timing.cpuMean()));
}
private static WorldgenStageBench.CallTiming samples(WorldgenStageBench bench, Call call) {
WorldgenStageBench.CallSamples samples = new WorldgenStageBench.CallSamples();
for (int round = 0; round < 2; round++) {
switch (call) {
case UNCACHED_BIOME -> bench.uncachedBiome(BUDGET, samples);
case BASE_HEIGHT -> bench.baseHeight(BUDGET, Heightmap.Types.WORLD_SURFACE_WG, samples);
case BASE_COLUMN -> bench.baseColumn(BUDGET, samples);
}
}
return samples.timing();
}
private static WorldgenStageBench bench(String label, String preset) {
return new WorldgenStageBench(new WorldgenStageBench.Options(label,
DatapackCostProbe.presetOverworld(loaded.registries(), preset), loaded.registries(), SEED));
}
private enum Call {
UNCACHED_BIOME,
BASE_HEIGHT,
BASE_COLUMN
}
}
@@ -0,0 +1,99 @@
package art.arcane.iris.probe.fidelity;
import art.arcane.iris.pack.datapack.DataVersion;
import art.arcane.iris.pack.datapack.export.ExportIrFixtures;
import art.arcane.iris.pack.datapack.export.WorldgenExportMode;
import art.arcane.iris.pack.datapack.export.emit.WorldgenJsonWriter;
import art.arcane.iris.pack.datapack.export.ir.AquiferDef;
import art.arcane.iris.pack.datapack.export.ir.Axis;
import art.arcane.iris.pack.datapack.export.ir.BiomeDef;
import art.arcane.iris.pack.datapack.export.ir.ClimateLayout;
import art.arcane.iris.pack.datapack.export.ir.DimensionDef;
import art.arcane.iris.pack.datapack.export.ir.ExportIr;
import art.arcane.iris.pack.datapack.export.ir.Field;
import art.arcane.iris.pack.datapack.export.ir.MaterialProgram;
import art.arcane.iris.pack.datapack.export.ir.RouterDef;
import art.arcane.iris.probe.PaperRegistryIsolation;
import art.arcane.iris.probe.WorldgenExportSampler;
import org.junit.ClassRule;
import org.junit.Test;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.SortedMap;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public final class VanillaNoiseColumnSourceTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
private static final long SEED = 1337L;
private static final String DIMENSION_ID = ExportIrFixtures.NAMESPACE + ":" + ExportIrFixtures.DIMENSION;
private static final String BIOME_ID = ExportIrFixtures.NAMESPACE + ":" + ExportIrFixtures.BIOME;
private static final String LOAD_KEY = "Flat Plains";
private static final int LAND_HEIGHT = 80;
private static final int SEA_FLOOR_HEIGHT = 40;
private static final int MAX_Y = ExportIrFixtures.MIN_Y + ExportIrFixtures.HEIGHT;
@Test
public void landColumnsReportTheTopSolidBlockAndStayDry() throws IOException {
try (VanillaNoiseColumnSource source = new VanillaNoiseColumnSource(open(LAND_HEIGHT), Map.of(BIOME_ID, LOAD_KEY))) {
assertEquals(ExportIrFixtures.MIN_Y, source.minY());
assertEquals(MAX_Y, source.maxY());
for (int x : new int[]{-37, 0, 113}) {
FidelityColumn column = source.column(x, -x);
assertEquals(new FidelityColumn(LOAD_KEY, LAND_HEIGHT - 1, false), column);
}
}
}
@Test
public void seaColumnsReportTheFloorUnderWater() throws IOException {
WorldgenExportSampler sampler = open(SEA_FLOOR_HEIGHT);
try (VanillaNoiseColumnSource source = new VanillaNoiseColumnSource(sampler, Map.of(BIOME_ID, LOAD_KEY))) {
FidelityStats stats = new FidelityStats(source.minY(), source.maxY());
for (int x : new int[]{-37, 0, 113}) {
FidelityColumn column = source.column(x, x);
assertEquals(new FidelityColumn(LOAD_KEY, sampler.floorHeight(x, x) - 1, true), column);
assertTrue(column.terrainY() + " under sea level", column.terrainY() < ExportIrFixtures.SEA_LEVEL - 1);
assertEquals(ExportIrFixtures.SEA_LEVEL, sampler.baseHeight(x, x));
stats.accept(column);
}
assertEquals(0.0D, stats.landFraction(), 0.0D);
}
}
@Test
public void exportedBiomeWithoutALoadKeyIsRejected() throws IOException {
try (VanillaNoiseColumnSource source = new VanillaNoiseColumnSource(open(LAND_HEIGHT), Map.of())) {
IllegalStateException failure = assertThrows(IllegalStateException.class, () -> source.column(0, 0));
assertTrue(failure.getMessage(), failure.getMessage().contains(BIOME_ID));
}
}
private static WorldgenExportSampler open(int height) throws IOException {
return WorldgenExportSampler.open(flatExport(height), WorldgenExportMode.PRESET, DIMENSION_ID, SEED);
}
private static SortedMap<String, byte[]> flatExport(int height) {
Field zero = new Field.Const(0.0);
Field level = new Field.Const(height);
Field y = new Field.Coord(Axis.Y, Field.Tiling.CLAMP, -4064.0, 4062.0, -4064.0, 4062.0);
Field finalDensity = new Field.Grid(new Field.Binary(Field.BinaryOp.MUL, new Field.Const(0.125),
new Field.Binary(Field.BinaryOp.SUB, level, y)), 4, 8);
RouterDef router = new RouterDef(zero, zero, zero, zero, zero, zero, new Field.Grid(level, 16, 1), finalDensity, AquiferDef.dry(level));
DimensionDef dimension = new DimensionDef(ExportIrFixtures.DIMENSION, ExportIrFixtures.MIN_Y, ExportIrFixtures.HEIGHT,
ExportIrFixtures.HEIGHT, ExportIrFixtures.FLUID_HEIGHT, "minecraft:stone", "minecraft:water", ExportIrFixtures.dimensionType());
ClimateLayout climate = new ClimateLayout(List.of(new ClimateLayout.ClimateEntry(BIOME_ID, ClimateLayout.ClimateAxis.TEMPERATURE, -2.0, 2.0)));
ExportIr ir = new ExportIr(ExportIrFixtures.NAMESPACE, DataVersion.V26_3, WorldgenExportMode.PRESET, dimension, List.of(), router, climate,
List.of(new BiomeDef(ExportIrFixtures.BIOME, ExportIrFixtures.biome())), MaterialProgram.rock("minecraft:stone"),
List.of(), List.of(), List.of());
return WorldgenJsonWriter.forTarget(DataVersion.V26_3).write(ir);
}
}
@@ -0,0 +1,189 @@
package art.arcane.iris.probe.fidelity;
import art.arcane.iris.pack.datapack.DataVersion;
import art.arcane.iris.pack.datapack.export.ExportIrFixtures;
import art.arcane.iris.pack.datapack.export.WorldgenExportMode;
import art.arcane.iris.pack.datapack.export.emit.WorldgenJsonWriter;
import art.arcane.iris.pack.datapack.export.ir.AquiferDef;
import art.arcane.iris.pack.datapack.export.ir.Axis;
import art.arcane.iris.pack.datapack.export.ir.BiomeDef;
import art.arcane.iris.pack.datapack.export.ir.ClimateLayout;
import art.arcane.iris.pack.datapack.export.ir.DimensionDef;
import art.arcane.iris.pack.datapack.export.ir.ExportIr;
import art.arcane.iris.pack.datapack.export.ir.Field;
import art.arcane.iris.pack.datapack.export.ir.JsonResource;
import art.arcane.iris.pack.datapack.export.ir.MaterialProgram;
import art.arcane.iris.pack.datapack.export.ir.RouterDef;
import art.arcane.iris.probe.PaperRegistryIsolation;
import art.arcane.iris.probe.WorldgenExportSampler;
import com.google.gson.JsonParser;
import org.junit.ClassRule;
import org.junit.Test;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.SortedMap;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public final class VanillaNoiseFidelitySourceTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
private static final long SEED = 1337L;
private static final String DIMENSION_ID = ExportIrFixtures.NAMESPACE + ":" + ExportIrFixtures.DIMENSION;
private static final String BIOME_ID = ExportIrFixtures.NAMESPACE + ":" + ExportIrFixtures.BIOME;
private static final String LOAD_KEY = "Flat Plains";
private static final int LAND_HEIGHT = 80;
private static final int MAX_Y = ExportIrFixtures.MIN_Y + ExportIrFixtures.HEIGHT;
private static final String WALL_SHELL = ExportIrFixtures.NAMESPACE + ":cave/wall_shell";
private static final int WALL_MIN_Y = 10;
private static final int WALL_MAX_Y = 20;
private static final int CARVED_HEIGHT = 120;
private static final int VOID_MIN_Y = 96;
private static final int CAVE_MIN_Y = 64;
private static final Field BLOCK_Y = new Field.Coord(Axis.Y, Field.Tiling.CLAMP, -4064.0, 4062.0, -4064.0, 4062.0);
@Test
public void flatExportReportsTheConstantSurface() throws IOException {
WorldgenExportSampler sampler = open(LAND_HEIGHT);
try (VanillaNoiseFidelitySource source = new VanillaNoiseFidelitySource(sampler, Map.of(BIOME_ID, LOAD_KEY), WALL_SHELL)) {
assertEquals(ExportIrFixtures.MIN_Y, source.minY());
assertEquals(MAX_Y, source.maxY());
for (int x : new int[]{-37, 0, 113}) {
assertEquals(LAND_HEIGHT, sampler.baseHeight(x, -x));
}
for (int[] chunkPosition : new int[][]{{0, 0}, {-3, 5}}) {
FidelityChunk chunk = source.sample(chunkPosition[0], chunkPosition[1]);
for (int x = 0; x < 16; x++) {
for (int z = 0; z < 16; z++) {
assertEquals(LAND_HEIGHT - 1, chunk.surfaceY(x, z));
assertEquals(LOAD_KEY, chunk.surfaceBiome(x, z));
assertTrue(chunk.open(x, LAND_HEIGHT, z));
assertNull(chunk.block(x, LAND_HEIGHT - 1, z));
}
}
for (int y = ExportIrFixtures.MIN_Y; y < LAND_HEIGHT; y++) {
assertFalse("y " + y, chunk.open(7, y, 9));
assertFalse("y " + y, chunk.fluid(7, y, 9));
}
}
FidelityStats stats = FidelityScanner.scan(source, 0, 0, 1, 4, 2);
assertEquals(144L, stats.heightColumns());
assertEquals(LAND_HEIGHT - 1, stats.heightQuantile(0.1D), 0.0D);
assertEquals(LAND_HEIGHT - 1, stats.heightQuantile(0.9D), 0.0D);
assertEquals(1.0D, stats.landFraction(), 0.0D);
assertEquals(Map.of(LOAD_KEY, 144L), stats.biomeColumns());
assertEquals(0L, stats.blockColumns());
assertEquals(0L, stats.wallSolidVoxels());
}
}
@Test
public void wallShellDensityMarksWallVoxels() throws IOException {
WorldgenExportSampler sampler = WorldgenExportSampler.open(flatExport(LAND_HEIGHT, wallShell()), WorldgenExportMode.PRESET, DIMENSION_ID, SEED);
try (VanillaNoiseFidelitySource source = new VanillaNoiseFidelitySource(sampler, Map.of(BIOME_ID, LOAD_KEY), WALL_SHELL)) {
FidelityChunk chunk = source.sample(2, -1);
assertTrue(chunk.wallsKnown());
assertTrue(chunk.wall(7, WALL_MIN_Y, 9));
assertTrue(chunk.wall(7, WALL_MAX_Y - 1, 9));
assertFalse(chunk.wall(7, WALL_MIN_Y - 1, 9));
assertFalse(chunk.wall(7, WALL_MAX_Y, 9));
FidelityStats stats = FidelityScanner.scan(source, 0, 0, 1, 4, 2);
long interiorColumns = 9L * 9L;
assertEquals(interiorColumns * (LAND_HEIGHT - 1 - ExportIrFixtures.MIN_Y), stats.wallSolidVoxels());
assertEquals(interiorColumns * (WALL_MAX_Y - WALL_MIN_Y), stats.wallVoxels());
}
}
@Test
public void openSpaceOutsideTheTerrainOperandIsATerrainVoid() throws IOException {
WorldgenExportSampler sampler = WorldgenExportSampler.open(carvedExport(), WorldgenExportMode.PRESET, DIMENSION_ID, SEED);
try (VanillaNoiseFidelitySource source = new VanillaNoiseFidelitySource(sampler, Map.of(BIOME_ID, LOAD_KEY), WALL_SHELL)) {
FidelityChunk chunk = source.sample(1, -2);
assertEquals(CARVED_HEIGHT - 1, chunk.surfaceY(7, 9));
assertTrue(chunk.open(7, VOID_MIN_Y, 9));
assertFalse(chunk.terrainMass(7, VOID_MIN_Y, 9));
assertTrue(chunk.open(7, CAVE_MIN_Y + 3, 9));
assertTrue(chunk.terrainMass(7, CAVE_MIN_Y + 3, 9));
assertFalse(chunk.open(7, VOID_MIN_Y - 4, 9));
assertTrue(chunk.terrainMass(7, VOID_MIN_Y - 4, 9));
FidelityStats stats = FidelityScanner.scan(source, 0, 0, 1, 4, 2);
int voidBand = (VOID_MIN_Y - ExportIrFixtures.MIN_Y) / 16;
int caveBand = (CAVE_MIN_Y - ExportIrFixtures.MIN_Y) / 16;
assertEquals(1.0D, stats.voidShare(voidBand), 0.0D);
assertEquals(0.0D, stats.caveShare(voidBand), 0.0D);
assertEquals(1.0D, stats.caveShare(caveBand), 0.0D);
assertEquals(0.0D, stats.voidShare(caveBand), 0.0D);
}
}
@Test
public void exportWithoutAWallShellReportsNoWalls() throws IOException {
try (VanillaNoiseFidelitySource source = new VanillaNoiseFidelitySource(open(LAND_HEIGHT), Map.of(BIOME_ID, LOAD_KEY), WALL_SHELL)) {
assertFalse(source.sample(0, 0).wallsKnown());
}
}
@Test
public void exportedBiomeWithoutALoadKeyIsRejected() throws IOException {
try (VanillaNoiseFidelitySource source = new VanillaNoiseFidelitySource(open(LAND_HEIGHT), Map.of(), WALL_SHELL)) {
FidelityChunk chunk = source.sample(0, 0);
IllegalStateException failure = assertThrows(IllegalStateException.class, () -> chunk.surfaceBiome(0, 0));
assertTrue(failure.getMessage(), failure.getMessage().contains(BIOME_ID));
}
}
private static WorldgenExportSampler open(int height) throws IOException {
return WorldgenExportSampler.open(flatExport(height), WorldgenExportMode.PRESET, DIMENSION_ID, SEED);
}
private static JsonResource wallShell() {
return new JsonResource("data/" + ExportIrFixtures.NAMESPACE + "/worldgen/density_function/cave/wall_shell.json", JsonParser.parseString(
"{\"type\":\"minecraft:range_choice\",\"input\":{\"type\":\"minecraft:gradient\",\"axis\":\"y\",\"tiling\":\"clamp_to_edge\","
+ "\"from_coordinate\":-4064,\"to_coordinate\":4062,\"from_value\":-4064.0,\"to_value\":4062.0},"
+ "\"min_inclusive\":" + WALL_MIN_Y + ".0,\"max_exclusive\":" + WALL_MAX_Y + ".0,\"when_in_range\":1.0,\"when_out_of_range\":0.0}"));
}
private static SortedMap<String, byte[]> flatExport(int height, JsonResource... resources) {
Field level = new Field.Const(height);
Field finalDensity = new Field.Grid(new Field.Binary(Field.BinaryOp.MUL, new Field.Const(0.125),
new Field.Binary(Field.BinaryOp.SUB, level, BLOCK_Y)), 4, 8);
return export(height, finalDensity, resources);
}
private static SortedMap<String, byte[]> carvedExport() {
Field ramp = new Field.Binary(Field.BinaryOp.MUL, new Field.Const(0.125), new Field.Binary(Field.BinaryOp.SUB, new Field.Const(CARVED_HEIGHT), BLOCK_Y));
Field terrain = new Field.RangeIf(BLOCK_Y, VOID_MIN_Y, VOID_MIN_Y + 16, new Field.Const(-1.0), ramp);
Field caves = new Field.RangeIf(BLOCK_Y, CAVE_MIN_Y, CAVE_MIN_Y + 16, new Field.Const(-1.0), new Field.Const(1.0));
return export(CARVED_HEIGHT, new Field.Binary(Field.BinaryOp.MIN, terrain, caves));
}
private static SortedMap<String, byte[]> export(int height, Field finalDensity, JsonResource... resources) {
Field zero = new Field.Const(0.0);
Field level = new Field.Const(height);
RouterDef router = new RouterDef(zero, zero, zero, zero, zero, zero, new Field.Grid(level, 16, 1), finalDensity, AquiferDef.dry(level));
DimensionDef dimension = new DimensionDef(ExportIrFixtures.DIMENSION, ExportIrFixtures.MIN_Y, ExportIrFixtures.HEIGHT,
ExportIrFixtures.HEIGHT, ExportIrFixtures.FLUID_HEIGHT, "minecraft:stone", "minecraft:water", ExportIrFixtures.dimensionType());
ClimateLayout climate = new ClimateLayout(List.of(new ClimateLayout.ClimateEntry(BIOME_ID, ClimateLayout.ClimateAxis.TEMPERATURE, -2.0, 2.0)));
ExportIr ir = new ExportIr(ExportIrFixtures.NAMESPACE, DataVersion.V26_3, WorldgenExportMode.PRESET, dimension, List.of(), router, climate,
List.of(new BiomeDef(ExportIrFixtures.BIOME, ExportIrFixtures.biome())), MaterialProgram.rock("minecraft:stone"),
List.of(), List.of(resources), List.of());
return WorldgenJsonWriter.forTarget(DataVersion.V26_3).write(ir);
}
}
@@ -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);
@@ -493,7 +497,13 @@ public class NativeStructureVolumeIndexTest {
private static Map<?, ?> cache(NativeStructureVolumeIndex index, String name) throws Exception { private static Map<?, ?> cache(NativeStructureVolumeIndex index, String name) throws Exception {
Field field = NativeStructureVolumeIndex.class.getDeclaredField(name); Field field = NativeStructureVolumeIndex.class.getDeclaredField(name);
field.setAccessible(true); field.setAccessible(true);
return (Map<?, ?>) field.get(index); Object value = field.get(index);
if (value instanceof Map<?, ?> map) {
return map;
}
Field entries = value.getClass().getDeclaredField("entries");
entries.setAccessible(true);
return (Map<?, ?>) entries.get(value);
} }
private static StructureStart swampHut(int chunkX, int chunkZ, int x, int z) { private static StructureStart swampHut(int chunkX, int chunkZ, int x, int z) {
@@ -544,7 +554,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 +607,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) {
+12 -1
View File
@@ -14,7 +14,7 @@ dependencies {
// log4j-core Filter interface, so its constructor reference needs the type resolvable. // log4j-core Filter interface, so its constructor reference needs the type resolvable.
compileOnly(libs.log4j.core) compileOnly(libs.log4j.core)
testImplementation('junit:junit:4.13.2') testImplementation('junit:junit:4.13.2')
testImplementation('org.mockito:mockito-core:5.23.0') testImplementation(libs.mockito.core)
testImplementation(libs.paper.api) testImplementation(libs.paper.api)
testImplementation(libs.bstats) testImplementation(libs.bstats)
testImplementation(project(':core')) testImplementation(project(':core'))
@@ -28,6 +28,16 @@ dependencies {
} }
} }
String lwjglVersion = libs.versions.lwjgl.get()
List<String> mavenLibraries = [
"org.lwjgl:lwjgl:${lwjglVersion}",
"org.lwjgl:lwjgl:jar:natives-linux:${lwjglVersion}",
"org.lwjgl:lwjgl-opengl:${lwjglVersion}",
"org.lwjgl:lwjgl-opengl:jar:natives-linux:${lwjglVersion}",
"org.lwjgl:lwjgl-egl:${lwjglVersion}",
"org.lwjgl:lwjgl-jawt:${lwjglVersion}",
].collect { GString coordinate -> coordinate.toString() }
tasks.named('processResources').configure { tasks.named('processResources').configure {
def pluginProperties = [ def pluginProperties = [
name : rootProject.name, name : rootProject.name,
@@ -35,6 +45,7 @@ tasks.named('processResources').configure {
apiVersion: apiVersion, apiVersion: apiVersion,
main : mainClass, main : mainClass,
bootstrapper: bootstrapperClass, bootstrapper: bootstrapperClass,
libraries : mavenLibraries.collect { String coordinate -> "'${coordinate}'" }.join(', '),
] ]
inputs.properties(pluginProperties) inputs.properties(pluginProperties)
filesMatching(['**/paper-plugin.yml', '**/plugin.yml']) { filesMatching(['**/paper-plugin.yml', '**/plugin.yml']) {
@@ -57,9 +57,12 @@ import art.arcane.iris.localization.IrisLanguage;
import art.arcane.volmlib.util.director.help.DirectorMiniMenu; import art.arcane.volmlib.util.director.help.DirectorMiniMenu;
import art.arcane.volmlib.util.localization.BukkitLanguageSwitcher; import art.arcane.volmlib.util.localization.BukkitLanguageSwitcher;
import art.arcane.iris.integration.MultiverseCoreLink; import art.arcane.iris.integration.MultiverseCoreLink;
import art.arcane.iris.platform.agent.Agent;
import art.arcane.iris.platform.agent.AttachSignal;
import art.arcane.iris.platform.bukkit.nms.INMS; import art.arcane.iris.platform.bukkit.nms.INMS;
import art.arcane.volmlib.nativelib.terrain.ServerShutdownBoundary; import art.arcane.volmlib.nativelib.terrain.ServerShutdownBoundary;
import art.arcane.iris.studio.view.BukkitGuiHost; import art.arcane.iris.studio.view.BukkitGuiHost;
import art.arcane.iris.studio.view.GuiHost;
import art.arcane.iris.studio.view.PregeneratorJob; import art.arcane.iris.studio.view.PregeneratorJob;
import art.arcane.iris.platform.bukkit.BoardSVC; import art.arcane.iris.platform.bukkit.BoardSVC;
import art.arcane.iris.command.CommandSVC; import art.arcane.iris.command.CommandSVC;
@@ -200,10 +203,10 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
private final AtomicBoolean postStopFinisherStarted = new AtomicBoolean(false); private final AtomicBoolean postStopFinisherStarted = new AtomicBoolean(false);
private final AtomicBoolean serverStopTeardownDeferred = new AtomicBoolean(false); private final AtomicBoolean serverStopTeardownDeferred = new AtomicBoolean(false);
private final AtomicBoolean servicesDisabled = new AtomicBoolean(false); private final AtomicBoolean servicesDisabled = new AtomicBoolean(false);
private final AtomicBoolean runtimeTeardownFailed = new AtomicBoolean(false);
private final AtomicBoolean sharedRuntimeClosed = new AtomicBoolean(false); private final AtomicBoolean sharedRuntimeClosed = new AtomicBoolean(false);
private final AtomicBoolean startupBoundaryRestart = new AtomicBoolean(false); private final AtomicBoolean startupBoundaryRestart = new AtomicBoolean(false);
private final AtomicBoolean terminalCleanupCompleted = new AtomicBoolean(false); private final AtomicBoolean terminalCleanupCompleted = new AtomicBoolean(false);
private final AtomicBoolean runtimeTeardownFailed = new AtomicBoolean(false);
private volatile boolean generatorDrainCompleted; private volatile boolean generatorDrainCompleted;
private volatile boolean enableFailed; private volatile boolean enableFailed;
private volatile PlaceholderRegistration papiRegistration; private volatile PlaceholderRegistration papiRegistration;
@@ -224,6 +227,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
private BukkitDebugDump debugDump; private BukkitDebugDump debugDump;
private volatile SettingsHotloadWatch settingsHotloadWatch; private volatile SettingsHotloadWatch settingsHotloadWatch;
private volatile ServerShutdownBoundary serverShutdownBoundary; private volatile ServerShutdownBoundary serverShutdownBoundary;
private boolean shutdownProtectionInitialized;
public static VolmitSender getSender() { public static VolmitSender getSender() {
VolmitSender current = sender; VolmitSender current = sender;
@@ -473,10 +477,16 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
} }
if (debug) { if (debug) {
String n = e.getClass().getCanonicalName() + "-" + e.getStackTrace()[0].getClassName() + "-" + e.getStackTrace()[0].getLineNumber(); String n = e.getClass().getName();
StackTraceElement[] trace = e.getStackTrace();
if (trace.length > 0) {
n += "-" + trace[0].getClassName() + "-" + trace[0].getLineNumber();
}
if (e.getCause() != null) { if (e.getCause() != null) {
n += "-" + e.getCause().getStackTrace()[0].getClassName() + "-" + e.getCause().getStackTrace()[0].getLineNumber(); StackTraceElement[] causeTrace = e.getCause().getStackTrace();
n += "-" + (causeTrace.length == 0 ? e.getCause().getClass().getName()
: causeTrace[0].getClassName() + "-" + causeTrace[0].getLineNumber());
} }
File f = instance.getDataFile("debug", "caught-exceptions", n + ".txt"); File f = instance.getDataFile("debug", "caught-exceptions", n + ".txt");
@@ -570,6 +580,17 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
this::getIrisVersion, this::getIrisVersion,
this::getMCVersion)); this::getMCVersion));
SlimJar.load(); SlimJar.load();
restoreAttachSignal();
}
private static void restoreAttachSignal() {
try {
if (AttachSignal.restoreJvmHandler()) {
info("Restored the JVM SIGQUIT handler that the server console replaced");
}
} catch (RuntimeException failure) {
reportError("Could not restore the JVM SIGQUIT handler; the Iris agent may fail to attach.", failure);
}
} }
private static void bridgeLog(LogLevel level, String message) { private static void bridgeLog(LogLevel level, String message) {
@@ -626,10 +647,10 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
postStopFinisherStarted.set(false); postStopFinisherStarted.set(false);
serverStopTeardownDeferred.set(false); serverStopTeardownDeferred.set(false);
servicesDisabled.set(false); servicesDisabled.set(false);
runtimeTeardownFailed.set(false);
sharedRuntimeClosed.set(false); sharedRuntimeClosed.set(false);
startupBoundaryRestart.set(false); startupBoundaryRestart.set(false);
terminalCleanupCompleted.set(false); terminalCleanupCompleted.set(false);
runtimeTeardownFailed.set(false);
generatorDrainCompleted = false; generatorDrainCompleted = false;
deferredShutdownGenerators.clear(); deferredShutdownGenerators.clear();
MultiBurst.burst.reopen(); MultiBurst.burst.reopen();
@@ -910,6 +931,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
public void onEnable() { public void onEnable() {
enableFailed = false; enableFailed = false;
shutdownProtectionInitialized = false;
IrisStartupValidation.begin(); IrisStartupValidation.begin();
Bukkit.getPluginManager().registerEvents(new IrisStartupAdmissionListener(), this); Bukkit.getPluginManager().registerEvents(new IrisStartupAdmissionListener(), this);
try { try {
@@ -921,13 +943,14 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
refuseUnsupportedServer(); refuseUnsupportedServer();
return; return;
} }
initializeShutdownProtection();
enable(); enable();
} catch (Throwable failure) { } catch (Throwable failure) {
enableFailed = true;
if (!FailedEnableLock.engage(failure, FailedEnableLock.irisWorldsPresent(), if (!FailedEnableLock.engage(failure, FailedEnableLock.irisWorldsPresent(),
generatorResolver.refusals().startupWorldsCreated())) { generatorResolver.refusals().startupWorldsCreated())) {
throw failure; throw failure;
} }
enableFailed = true;
return; return;
} }
BukkitGuiHost.install(); BukkitGuiHost.install();
@@ -945,6 +968,17 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
reportLockedRuntime(); reportLockedRuntime();
} }
private void initializeShutdownProtection() {
if (!INMS.get().supportsIrisWorldGeneration()) {
return;
}
if (!Agent.install()) {
throw new IllegalStateException("Iris Java agent is unavailable; shutdown protection cannot be installed.");
}
INMS.get().initializeLifecycle(getClass().getClassLoader());
shutdownProtectionInitialized = true;
}
private void refuseUnsupportedServer() { private void refuseUnsupportedServer() {
Throwable bindFailure = INMS.bindFailure(); Throwable bindFailure = INMS.bindFailure();
IllegalStateException failure = new IllegalStateException("Iris cannot start: " + (bindFailure == null IllegalStateException failure = new IllegalStateException("Iris cannot start: " + (bindFailure == null
@@ -988,6 +1022,8 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
} }
public void onDisable() { public void onDisable() {
pendingWorldReplacements.awaitStagingForShutdown();
GuiHost.shutdown();
preservePendingBlockDrops(); preservePendingBlockDrops();
papiLifecycleActive = false; papiLifecycleActive = false;
if (updateNotifier != null) { if (updateNotifier != null) {
@@ -1004,7 +1040,8 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
} }
IrisLanguage.shutdown(); IrisLanguage.shutdown();
teardownPapi(); teardownPapi();
boolean deferToServerStop = FailedEnableLock.defersTeardownToServerStop(enableFailed, IrisToolbelt.isServerStopping()); boolean deferToServerStop = FailedEnableLock.defersTeardownToServerStop(enableFailed, IrisToolbelt.isServerStopping())
&& tryDeferPluginClassLoaderClose();
boolean restartingAtStartupBoundary = startupBoundaryRestart.get(); boolean restartingAtStartupBoundary = startupBoundaryRestart.get();
if (restartingAtStartupBoundary) { if (restartingAtStartupBoundary) {
teardownRuntime("startup-boundary-restart", 30L); teardownRuntime("startup-boundary-restart", 30L);
@@ -1036,7 +1073,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
preservePendingBlockDrops(); preservePendingBlockDrops();
papiLifecycleActive = false; papiLifecycleActive = false;
teardownPapi(); teardownPapi();
if (IrisToolbelt.isServerStopping()) { if (IrisToolbelt.isServerStopping() && tryDeferPluginClassLoaderClose()) {
quiesceRuntimeForServerShutdown("pre-unload:" + reason); quiesceRuntimeForServerShutdown("pre-unload:" + reason);
startPostStopFinisher(); startPostStopFinisher();
Iris.debug("Pre-unload hook deferred generator teardown until Paper closes its chunk schedulers."); Iris.debug("Pre-unload hook deferred generator teardown until Paper closes its chunk schedulers.");
@@ -1051,6 +1088,19 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
return CompletableFuture.completedFuture(null); return CompletableFuture.completedFuture(null);
} }
private boolean tryDeferPluginClassLoaderClose() {
if (!shutdownProtectionInitialized) {
return false;
}
try {
INMS.get().deferPluginClassLoaderClose();
return true;
} catch (RuntimeException | LinkageError failure) {
Iris.reportError("Failed to retain the Iris plugin class loader; draining shutdown inline.", failure);
return false;
}
}
/** /**
* Drains the world generators exactly once. Serialized against the JVM shutdown hook so a * Drains the world generators exactly once. Serialized against the JVM shutdown hook so a
* second caller cannot rip the pools or services out from under an in-flight drain. * second caller cannot rip the pools or services out from under an in-flight drain.
@@ -1097,19 +1147,16 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
try { try {
MultiBurst.burst.close(); MultiBurst.burst.close();
} catch (Throwable failure) { } catch (Throwable failure) {
runtimeTeardownFailed.set(true);
Iris.reportError("Failed to close Iris generation workers.", failure); Iris.reportError("Failed to close Iris generation workers.", failure);
} }
try { try {
MultiBurst.hydrology.close(); MultiBurst.hydrology.close();
} catch (Throwable failure) { } catch (Throwable failure) {
runtimeTeardownFailed.set(true);
Iris.reportError("Failed to close Iris hydrology workers.", failure); Iris.reportError("Failed to close Iris hydrology workers.", failure);
} }
try { try {
MultiBurst.ioBurst.close(); MultiBurst.ioBurst.close();
} catch (Throwable failure) { } catch (Throwable failure) {
runtimeTeardownFailed.set(true);
Iris.reportError("Failed to close Iris I/O workers.", failure); Iris.reportError("Failed to close Iris I/O workers.", failure);
} }
clearQueues(); clearQueues();
@@ -1128,7 +1175,6 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
private void quiesceRuntimeForServerShutdown(String reason) { private void quiesceRuntimeForServerShutdown(String reason) {
serverStopTeardownDeferred.set(true); serverStopTeardownDeferred.set(true);
INMS.get().deferPluginClassLoaderClose();
JigsawStudioService jigsawStudioService = IrisServices.getOrNull(JigsawStudioService.class); JigsawStudioService jigsawStudioService = IrisServices.getOrNull(JigsawStudioService.class);
if (jigsawStudioService != null) { if (jigsawStudioService != null) {
try { try {
@@ -1226,30 +1272,37 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
if (!terminalCleanupCompleted.compareAndSet(false, true)) { if (!terminalCleanupCompleted.compareAndSet(false, true)) {
return; return;
} }
J.attempt(() -> INMS.get().uninjectBukkit()); try {
if (INMS.isBound()) {
INMS.get().uninjectBukkit();
}
} catch (Throwable failure) {
Iris.reportError("Failed to remove Iris world-generation hooks.", failure);
}
try { try {
runPostShutdown(); runPostShutdown();
} catch (Throwable e) { } catch (Throwable e) {
runtimeTeardownFailed.set(true); runtimeTeardownFailed.set(true);
Iris.reportError("Failed to run Iris post-shutdown cleanup.", e); Iris.reportError("Failed to run Iris post-shutdown cleanup.", e);
} finally { } finally {
if (serverStopTeardownDeferred.get() && hasActiveShutdownResources()) { boolean activeResources = serverStopTeardownDeferred.get()
runtimeTeardownFailed.set(true); && (runtimeTeardownFailed.get() || hasActiveShutdownResources());
SHUTDOWN_ERRORS.println("[Iris] Owned background work remains active after cleanup; retaining the plugin class loader until JVM exit.");
}
IrisPlatforms.unbind(); IrisPlatforms.unbind();
if (serverStopTeardownDeferred.get() && runtimeTeardownFailed.get()) { BukkitPlatform.releaseHost();
SHUTDOWN_ERRORS.println("[Iris] Runtime cleanup failed; retaining the plugin class loader until JVM exit."); if (activeResources) {
SHUTDOWN_ERRORS.println("[Iris] Owned background work has not completed cleanup; retaining the plugin class loader until JVM exit.");
} else { } else {
try { try {
INMS.get().releasePluginClassLoaderClose(); if (shutdownProtectionInitialized) {
SlimJar.closeNativeProviders(); INMS.get().releasePluginClassLoaderClose();
} else {
SlimJar.closeNativeProviders();
}
} catch (Throwable failure) { } catch (Throwable failure) {
SHUTDOWN_ERRORS.println("[Iris] Failed to release the plugin class loader after runtime cleanup."); SHUTDOWN_ERRORS.println("[Iris] Failed to release the plugin class loader after runtime cleanup.");
failure.printStackTrace(SHUTDOWN_ERRORS); failure.printStackTrace(SHUTDOWN_ERRORS);
} }
} }
BukkitPlatform.releaseHost();
} }
} }
@@ -6,12 +6,22 @@ import io.papermc.paper.plugin.bootstrap.PluginProviderContext;
import io.papermc.paper.plugin.loader.PluginClasspathBuilder; import io.papermc.paper.plugin.loader.PluginClasspathBuilder;
import io.papermc.paper.plugin.loader.PluginLoader; import io.papermc.paper.plugin.loader.PluginLoader;
import io.papermc.paper.plugin.loader.library.impl.JarLibrary; import io.papermc.paper.plugin.loader.library.impl.JarLibrary;
import io.papermc.paper.plugin.loader.library.impl.MavenLibraryResolver;
import org.bukkit.configuration.file.YamlConfiguration;
import org.eclipse.aether.artifact.DefaultArtifact;
import org.eclipse.aether.graph.Dependency;
import org.eclipse.aether.repository.RemoteRepository;
import java.io.IOException; import java.io.IOException;
import java.io.InputStreamReader;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.List; import java.util.List;
import java.util.stream.Stream; import java.util.stream.Stream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
@SuppressWarnings("UnstableApiUsage") @SuppressWarnings("UnstableApiUsage")
public final class IrisPluginLoader implements PluginLoader { public final class IrisPluginLoader implements PluginLoader {
@@ -40,6 +50,21 @@ public final class IrisPluginLoader implements PluginLoader {
classpathBuilder.addLibrary(new JarLibrary(library)); classpathBuilder.addLibrary(new JarLibrary(library));
} }
} }
classpathBuilder.addLibrary(mavenResolver(mavenLibraries(context.getPluginSource())));
}
static List<String> mavenLibraries(Path pluginJar) {
try (ZipFile jar = new ZipFile(pluginJar.toFile())) {
ZipEntry descriptor = jar.getEntry("plugin.yml");
if (descriptor == null) {
throw new IllegalStateException("The Iris jar " + pluginJar + " has no plugin.yml library list.");
}
try (Reader reader = new InputStreamReader(jar.getInputStream(descriptor), StandardCharsets.UTF_8)) {
return YamlConfiguration.loadConfiguration(reader).getStringList("libraries");
}
} catch (IOException failure) {
throw new IllegalStateException("Unable to read the Iris library list from " + pluginJar + ".", failure);
}
} }
static List<Path> relocatedLibraries(Path libraryRoot) { static List<Path> relocatedLibraries(Path libraryRoot) {
@@ -59,6 +84,16 @@ public final class IrisPluginLoader implements PluginLoader {
} }
} }
private static MavenLibraryResolver mavenResolver(List<String> coordinates) {
MavenLibraryResolver resolver = new MavenLibraryResolver();
resolver.addRepository(new RemoteRepository.Builder("central", "default",
MavenLibraryResolver.MAVEN_CENTRAL_DEFAULT_MIRROR).build());
for (String coordinate : coordinates) {
resolver.addDependency(new Dependency(new DefaultArtifact(coordinate), null));
}
return resolver;
}
private static boolean isRelocatedLibrary(Path path) { private static boolean isRelocatedLibrary(Path path) {
int count = path.getNameCount(); int count = path.getNameCount();
for (int i = 0; i < count - 2; i++) { for (int i = 0; i < count - 2; i++) {
@@ -18,21 +18,41 @@
package art.arcane.iris.command; package art.arcane.iris.command;
import art.arcane.iris.pack.datapack.DatapackIngestService; import art.arcane.iris.Iris;
import art.arcane.volmlib.util.collection.KList; import art.arcane.iris.command.handlers.DimensionHandler;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.localization.BukkitCommandMessages;
import art.arcane.iris.localization.C; import art.arcane.iris.localization.C;
import art.arcane.iris.localization.DirectorCommandMessages;
import art.arcane.iris.localization.IrisLanguage;
import art.arcane.iris.pack.datapack.DataVersion;
import art.arcane.iris.pack.datapack.DatapackIngestService;
import art.arcane.iris.pack.datapack.export.DatapackExportDestinations;
import art.arcane.iris.pack.datapack.export.WorldgenExportMode;
import art.arcane.iris.pack.datapack.export.WorldgenExportProgress;
import art.arcane.iris.pack.datapack.export.WorldgenExportRequest;
import art.arcane.iris.pack.datapack.export.WorldgenExportResult;
import art.arcane.iris.pack.datapack.export.WorldgenExportService;
import art.arcane.iris.pack.datapack.export.WorldgenExportServices;
import art.arcane.iris.pack.datapack.export.WorldgenExportTargets;
import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic;
import art.arcane.iris.platform.bukkit.plugin.VolmitSender; import art.arcane.iris.platform.bukkit.plugin.VolmitSender;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.world.task.J; import art.arcane.iris.world.task.J;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.director.annotations.Director; import art.arcane.volmlib.util.director.annotations.Director;
import art.arcane.volmlib.util.director.annotations.Param; import art.arcane.volmlib.util.director.annotations.Param;
import art.arcane.volmlib.util.localization.LanguageAudience;
import art.arcane.volmlib.util.localization.MessageArgument;
import art.arcane.volmlib.util.localization.TextKey;
import java.util.List; import java.util.List;
import java.util.UUID;
import art.arcane.iris.localization.IrisLanguage;
import art.arcane.iris.localization.BukkitCommandMessages;
import art.arcane.volmlib.util.localization.MessageArgument;
@Director(name = "datapack", aliases = {"datapacks", "dp"}, description = "Download & manage external datapack imports", descriptionKey = "iris.director.commanddatapack.director.download_manage_external_datapack_imports_modrinth") @Director(name = "datapack", aliases = {"datapacks", "dp"}, description = "Download & manage external datapack imports", descriptionKey = "iris.director.commanddatapack.director.download_manage_external_datapack_imports_modrinth")
public class CommandDatapack implements DirectorExecutor { public class CommandDatapack implements DirectorExecutor {
private static final int RESOURCE_REPORTS = 4;
@Director(description = "Download/update every datapack listed in a pack dimension's 'datapackImports' and install it into the world so its structures register like vanilla", descriptionKey = "iris.director.commanddatapack.director.download_update_every_datapack_listed_pack_dimension_s_datapackimports_install_it_into", aliases = {"pull"}) @Director(description = "Download/update every datapack listed in a pack dimension's 'datapackImports' and install it into the world so its structures register like vanilla", descriptionKey = "iris.director.commanddatapack.director.download_update_every_datapack_listed_pack_dimension_s_datapackimports_install_it_into", aliases = {"pull"})
public void ingest( public void ingest(
@Param(description = "Restart the server when new datapacks are installed (required for new structures to register and generate)", descriptionKey = "iris.director.commanddatapack.param.restart_server_when_new_datapacks_are_installed_required_new_structures_register_generate", defaultValue = "false") @Param(description = "Restart the server when new datapacks are installed (required for new structures to register and generate)", descriptionKey = "iris.director.commanddatapack.param.restart_server_when_new_datapacks_are_installed_required_new_structures_register_generate", defaultValue = "false")
@@ -71,4 +91,128 @@ public class CommandDatapack implements DirectorExecutor {
) { ) {
DatapackIngestService.remove(sender(), id); DatapackIngestService.remove(sender(), id);
} }
@Director(description = "Export a dimension as a vanilla worldgen datapack", descriptionKey = "iris.director.commanddatapack.director.export_dimension_vanilla_worldgen_datapack")
public void export(
@Param(name = "dimension", description = "The dimension to export", descriptionKey = "iris.director.commanddatapack.param.export_dimension", customHandler = DimensionHandler.class)
IrisDimension dimension,
@Param(name = "version", description = "Target Minecraft version, 26.3 or later (blank uses the latest)", descriptionKey = "iris.director.commanddatapack.param.export_version", defaultValue = "")
String version,
@Param(name = "mode", description = "Output mode: preset, dimension, or overworld", descriptionKey = "iris.director.commanddatapack.param.export_mode", defaultValue = WorldgenExportRequest.DEFAULT_MODE)
String mode,
@Param(name = "namespace", description = "Namespace for the exported resources", descriptionKey = "iris.director.commanddatapack.param.export_namespace", defaultValue = WorldgenExportRequest.DEFAULT_NAMESPACE)
String namespace,
@Param(name = "output", description = "Output name in the datapack exports folder (blank uses the dimension and version)", descriptionKey = "iris.director.commanddatapack.param.export_output", defaultValue = "")
String output,
@Param(name = "format", description = "zip or directory", descriptionKey = "iris.director.commanddatapack.param.export_format", defaultValue = WorldgenExportRequest.DEFAULT_FORMAT)
String format,
@Param(name = "objects", description = "Include objects as structure templates", descriptionKey = "iris.director.commanddatapack.param.export_objects", defaultValue = "true")
boolean objects,
@Param(name = "replace", description = "Replace an existing export with the same name", descriptionKey = "iris.director.commanddatapack.param.export_replace", defaultValue = "false")
boolean replace
) {
VolmitSender sender = sender();
WorldgenExportRequest request = WorldgenExportRequest.builder()
.pack(dimension.getLoader().getDataFolder().toPath())
.dimension(dimension.getLoadKey())
.version(version)
.mode(mode)
.namespace(namespace)
.output(output)
.format(format)
.objects(objects)
.replace(replace)
.exportRoot(IrisPlatforms.get().dataFolder("exports", "datapacks").toPath())
.build();
DataVersion target;
try {
target = validateExport(request);
} catch (IllegalArgumentException invalid) {
sender.sendMessage(C.RED + IrisLanguage.text(DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_INVALID, MessageArgument.untrusted("reason", invalid.getMessage())));
return;
}
WorldgenExportService service = WorldgenExportServices.get();
sender.sendMessage(C.GRAY + IrisLanguage.text(DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_STARTED, MessageArgument.untrusted("dimension", request.dimension()), MessageArgument.untrusted("version", target.getVersion())));
UUID audience = LanguageAudience.current();
ExportFeedback feedback = new ExportFeedback(sender);
J.a(() -> LanguageAudience.run(audience, () -> runExport(service, request, feedback)));
}
static DataVersion validateExport(WorldgenExportRequest request) {
DataVersion target = WorldgenExportTargets.resolve(request.version());
WorldgenExportMode.parse(request.mode());
DatapackExportDestinations.parseFormat(request.format());
return target;
}
private static void runExport(WorldgenExportService service, WorldgenExportRequest request, ExportFeedback feedback) {
WorldgenExportResult result;
try {
result = service.export(request, feedback);
} catch (Throwable failure) {
Iris.reportError("Datapack export failed for '" + request.dimension() + "'.", failure);
feedback.send(C.RED, IrisLanguage.text(DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_FAILED, MessageArgument.untrusted("reason", IrisLanguage.errorDetail(failure))));
return;
}
reportExport(feedback, result);
}
private static void reportExport(ExportFeedback feedback, WorldgenExportResult result) {
for (ExportDiagnostic diagnostic : result.diagnostics()) {
feedback.send(diagnosticColor(diagnostic.severity()), IrisLanguage.text(DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_DIAGNOSTIC, MessageArgument.untrusted("key", diagnosticLabel(diagnostic)), MessageArgument.untrusted("message", diagnostic.message())));
}
if (result.isSuccess()) {
feedback.send(C.GREEN, IrisLanguage.text(DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_EXPORTED, MessageArgument.untrusted("path", result.output())));
return;
}
TextKey outcome = result.status() == WorldgenExportResult.Status.REJECTED
? DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_REJECTED
: DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_FAILED;
feedback.send(C.RED, IrisLanguage.text(outcome, MessageArgument.untrusted("reason", result.message())));
}
private static C diagnosticColor(ExportDiagnostic.Severity severity) {
return switch (severity) {
case ERROR -> C.RED;
case WARNING -> C.YELLOW;
case INFO -> C.GRAY;
};
}
private static String diagnosticLabel(ExportDiagnostic diagnostic) {
return diagnostic.resource().isEmpty()
? diagnostic.limit().name()
: diagnostic.limit().name() + " " + diagnostic.resource();
}
private static final class ExportFeedback implements WorldgenExportProgress {
private final VolmitSender sender;
private ExportFeedback(VolmitSender sender) {
this.sender = sender;
}
@Override
public void stage(String stage) {
send(C.GRAY, IrisLanguage.text(DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_STAGE, MessageArgument.untrusted("stage", stage)));
}
@Override
public void resources(int done, int total) {
int stride = Math.max(1, total / RESOURCE_REPORTS);
if (done != total && done % stride != 0) {
return;
}
send(C.GRAY, IrisLanguage.text(DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_RESOURCES, MessageArgument.untrusted("done", done), MessageArgument.untrusted("total", total)));
}
private void send(C color, String message) {
String line = color + message;
if (sender.isPlayer()) {
J.runEntity(sender.player(), () -> sender.sendMessage(line));
return;
}
sender.sendMessage(line);
}
}
} }
@@ -31,8 +31,7 @@ import art.arcane.iris.structure.placement.IrisStructureLocator;
import art.arcane.iris.structure.nativegen.NativeStructureGenerationPolicy; import art.arcane.iris.structure.nativegen.NativeStructureGenerationPolicy;
import art.arcane.iris.structure.placement.StructureReachability; import art.arcane.iris.structure.placement.StructureReachability;
import art.arcane.iris.world.history.GenerationSemanticQueries; import art.arcane.iris.world.history.GenerationSemanticQueries;
import art.arcane.iris.generation.hydrology.HydrologyFeatureQuery; import art.arcane.iris.generation.hydrology.query.HydrologyFeatureType;
import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntime;
import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.structure.nativegen.IrisNativeStructureDecision; import art.arcane.iris.structure.nativegen.IrisNativeStructureDecision;
import art.arcane.iris.generation.terrain.IrisRegion; import art.arcane.iris.generation.terrain.IrisRegion;
@@ -196,9 +195,9 @@ public class CommandFind implements DirectorExecutor {
EngineBukkitOps.gotoPOI(e, type, player(), teleport); EngineBukkitOps.gotoPOI(e, type, player(), teleport);
} }
@Director(description = "Find an accepted hydrology feature", sync = true) @Director(description = "Find a hydrology water feature", sync = true)
public void river( public void river(
@Param(description = "Feature type: surface, waterfall, underground, grotto, mouth, or a deep-fluid id", customHandler = HydrologyTypeHandler.class) @Param(description = "Feature type: stream, river, major_river, cascade, waterfall, mouth, pond, lake, dam, sea_inlet or cave_pool", customHandler = HydrologyTypeHandler.class)
String type, String type,
@Param(description = "Should you be teleported", defaultValue = "true") @Param(description = "Should you be teleported", defaultValue = "true")
boolean teleport boolean teleport
@@ -212,42 +211,32 @@ public class CommandFind implements DirectorExecutor {
} }
return; return;
} }
IrisHydrologyRuntime runtime = activeEngine.getComplex().getHydrologyRuntime(); HydrologyFeatureType featureType;
HydrologyFeatureQuery query;
try { try {
query = HydrologyFeatureQuery.parse(type); featureType = HydrologyFeatureType.parse(type);
} catch (IllegalArgumentException error) { } catch (IllegalArgumentException error) {
commandSender.sendMessage(C.RED + error.getMessage()); commandSender.sendMessage(C.RED + error.getMessage());
return; return;
} }
String label = featureType.key().replace('_', ' ');
Location origin = target.getLocation(); Location origin = target.getLocation();
World originWorld = origin.getWorld(); World originWorld = origin.getWorld();
int requestedDistance = runtime == null int maximumDistance = GenerationSemanticQueries.RIVER_SEARCH_DISTANCE;
? 8192 commandSender.sendMessage(C.GRAY + GenerationSemanticQueries.riverSearchMessage(featureType, maximumDistance));
: Math.min(8192, runtime.settings().routing().tileSize() * 15);
int maximumDistance = runtime == null
? requestedDistance
: runtime.maximumFeatureSearchDistance(
origin.getBlockX(), origin.getBlockZ(), requestedDistance);
commandSender.sendMessage(C.GRAY + "Searching accepted hydrology plans for " + type + "...");
J.a(() -> { J.a(() -> {
try { try {
GenerationSemanticQueries.RiverResult feature = GenerationSemanticQueries.nearestRiver( GenerationSemanticQueries.RiverResult feature = GenerationSemanticQueries.nearestRiver(
activeEngine, activeEngine,
query, featureType,
origin.getBlockX(), origin.getBlockX(),
origin.getBlockZ(), origin.getBlockZ(),
maximumDistance, maximumDistance
(int visited) -> sendStructureMessage(target, commandSender,
C.GRAY + "Searched " + visited + " hydrology tiles for " + type + "...")
).orElse(null); ).orElse(null);
if (feature == null) { if (feature == null) {
sendStructureMessage(target, commandSender, sendStructureMessage(target, commandSender,
C.YELLOW + "No accepted " + type + " hydrology feature found within " C.YELLOW + GenerationSemanticQueries.riverNotFoundMessage(featureType, maximumDistance));
+ maximumDistance + " blocks.");
return; return;
} }
String label = type + " hydrology feature";
if (!teleport) { if (!teleport) {
sendStructureMessage(target, commandSender, sendStructureMessage(target, commandSender,
C.GREEN + "Found " + label + " at " + feature.x() + ", " + feature.y() + ", " + feature.z() + "."); C.GREEN + "Found " + label + " at " + feature.x() + ", " + feature.y() + ", " + feature.z() + ".");
@@ -259,8 +248,9 @@ public class CommandFind implements DirectorExecutor {
target, originWorld, commandSender, label, destination, true); target, originWorld, commandSender, label, destination, true);
} catch (Throwable error) { } catch (Throwable error) {
sendStructureMessage(target, commandSender, sendStructureMessage(target, commandSender,
C.RED + "Could not locate " + type + " hydrology: " + error.getClass().getSimpleName()); C.RED + "Could not search for " + label + ": " + error.getClass().getSimpleName()
Iris.reportError("Could not locate accepted hydrology feature '" + type + "'.", error); + (error.getMessage() == null ? "" : " - " + error.getMessage()));
Iris.reportError("Could not locate hydrology feature '" + featureType.key() + "'.", error);
} }
}); });
} }
@@ -21,8 +21,10 @@ import art.arcane.iris.structure.StructureImporter;
import art.arcane.iris.structure.VillageImporter; import art.arcane.iris.structure.VillageImporter;
import art.arcane.iris.structure.authoring.StructureKey; import art.arcane.iris.structure.authoring.StructureKey;
import art.arcane.iris.structure.authoring.StructureWriteResult; import art.arcane.iris.structure.authoring.StructureWriteResult;
import art.arcane.iris.structure.export.VanillaJigsawExportFormat; import art.arcane.iris.pack.datapack.export.DatapackExportDestinations;
import art.arcane.iris.pack.datapack.export.DatapackExportFormat;
import art.arcane.iris.structure.export.VanillaJigsawExportRequest; import art.arcane.iris.structure.export.VanillaJigsawExportRequest;
import art.arcane.iris.structure.export.VanillaJigsawExportSettings;
import art.arcane.iris.structure.export.VanillaJigsawExportSource; import art.arcane.iris.structure.export.VanillaJigsawExportSource;
import art.arcane.iris.structure.graph.PlanarJigsawWorkcellResolver; import art.arcane.iris.structure.graph.PlanarJigsawWorkcellResolver;
import art.arcane.iris.generation.terrain.IrisDimension; import art.arcane.iris.generation.terrain.IrisDimension;
@@ -419,9 +421,9 @@ public class CommandJigsaw implements DirectorExecutor {
sendError("Wait for the pending autosave or discard the edits before exporting the on-disk graph."); sendError("Wait for the pending autosave or discard the edits before exporting the on-disk graph.");
return; return;
} }
VanillaJigsawExportFormat exportFormat; DatapackExportFormat exportFormat;
try { try {
exportFormat = CommandJigsawExports.parseFormat(format); exportFormat = DatapackExportDestinations.parseFormat(format);
} catch (IllegalArgumentException exception) { } catch (IllegalArgumentException exception) {
sendError(exception.getMessage()); sendError(exception.getMessage());
return; return;
@@ -430,20 +432,21 @@ public class CommandJigsaw implements DirectorExecutor {
.toPath().toAbsolutePath().normalize(); .toPath().toAbsolutePath().normalize();
Path destination; Path destination;
try { try {
destination = CommandJigsawExports.resolveExportDestination(exportRoot, output, exportFormat); destination = DatapackExportDestinations.resolve(exportRoot, output, exportFormat);
} catch (IllegalArgumentException exception) { } catch (IllegalArgumentException exception) {
sendError(exception.getMessage()); sendError(exception.getMessage());
return; return;
} }
VanillaJigsawExportRequest request; VanillaJigsawExportRequest request;
try { try {
request = VanillaJigsawExportRequest.builder( VanillaJigsawExportSource source = VanillaJigsawExportSource.forData(
VanillaJigsawExportSource.forData( context.request().source(), context.request().structureKey());
context.request().source(), context.request().structureKey()), destination) request = VanillaJigsawExportRequest.builder(source, destination)
.namespace(namespace) .namespace(namespace)
.resourcePath(context.request().structureKey()) .resourcePath(context.request().structureKey())
.format(exportFormat) .format(exportFormat)
.replaceExisting(replace) .replaceExisting(replace)
.settings(VanillaJigsawExportSettings.runtimeDefaults(source.loadStructure()))
.build(); .build();
} catch (IllegalArgumentException exception) { } catch (IllegalArgumentException exception) {
sendError(exception.getMessage()); sendError(exception.getMessage());
@@ -4,7 +4,7 @@ import art.arcane.iris.Iris;
import art.arcane.iris.studio.jigsaw.JigsawStudioService; import art.arcane.iris.studio.jigsaw.JigsawStudioService;
import art.arcane.iris.structure.export.VanillaJigsawDatapackExporter; import art.arcane.iris.structure.export.VanillaJigsawDatapackExporter;
import art.arcane.iris.structure.export.VanillaJigsawExportDiagnostic; import art.arcane.iris.structure.export.VanillaJigsawExportDiagnostic;
import art.arcane.iris.structure.export.VanillaJigsawExportFormat; import art.arcane.iris.pack.datapack.export.DatapackExportDestinations;
import art.arcane.iris.structure.export.VanillaJigsawExportRequest; import art.arcane.iris.structure.export.VanillaJigsawExportRequest;
import art.arcane.iris.structure.export.VanillaJigsawExportResult; import art.arcane.iris.structure.export.VanillaJigsawExportResult;
import art.arcane.iris.localization.C; import art.arcane.iris.localization.C;
@@ -13,18 +13,14 @@ import art.arcane.iris.world.task.J;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.Locale;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.regex.Pattern;
final class CommandJigsawExports { final class CommandJigsawExports {
private static final Pattern EXPORT_OUTPUT_NAME = Pattern.compile("[a-zA-Z0-9_-][a-zA-Z0-9._-]{0,127}");
private static final Set<UUID> EXPORTING_PLAYERS = ConcurrentHashMap.newKeySet(); private static final Set<UUID> EXPORTING_PLAYERS = ConcurrentHashMap.newKeySet();
private static final Set<Path> EXPORT_DESTINATIONS = ConcurrentHashMap.newKeySet();
private CommandJigsawExports() { private CommandJigsawExports() {
} }
@@ -45,11 +41,11 @@ final class CommandJigsawExports {
static boolean beginExport(UUID playerId, Path destination) { static boolean beginExport(UUID playerId, Path destination) {
Objects.requireNonNull(playerId); Objects.requireNonNull(playerId);
Path normalizedDestination = Objects.requireNonNull(destination).toAbsolutePath().normalize(); Objects.requireNonNull(destination);
if (!EXPORTING_PLAYERS.add(playerId)) { if (!EXPORTING_PLAYERS.add(playerId)) {
return false; return false;
} }
if (EXPORT_DESTINATIONS.add(normalizedDestination)) { if (DatapackExportDestinations.claim(destination)) {
return true; return true;
} }
EXPORTING_PLAYERS.remove(playerId); EXPORTING_PLAYERS.remove(playerId);
@@ -58,7 +54,7 @@ final class CommandJigsawExports {
static void finishExport(UUID playerId, Path destination) { static void finishExport(UUID playerId, Path destination) {
EXPORTING_PLAYERS.remove(Objects.requireNonNull(playerId)); EXPORTING_PLAYERS.remove(Objects.requireNonNull(playerId));
EXPORT_DESTINATIONS.remove(Objects.requireNonNull(destination).toAbsolutePath().normalize()); DatapackExportDestinations.release(destination);
} }
static void runExport(ExportOperation operation) { static void runExport(ExportOperation operation) {
@@ -102,35 +98,6 @@ final class CommandJigsawExports {
+ result.status() + ": " + result.output()); + result.status() + ": " + result.output());
} }
static VanillaJigsawExportFormat parseFormat(String value) {
return switch (value.trim().toLowerCase(Locale.ROOT)) {
case "zip" -> VanillaJigsawExportFormat.ZIP;
case "directory", "dir", "folder" -> VanillaJigsawExportFormat.DIRECTORY;
default -> throw new IllegalArgumentException("Export format must be directory or zip.");
};
}
static Path resolveExportDestination(
Path exportRoot,
String output,
VanillaJigsawExportFormat format
) {
Path root = Objects.requireNonNull(exportRoot, "Jigsaw export root").toAbsolutePath().normalize();
String requested = Objects.requireNonNull(output, "Jigsaw export output name");
String name = requested.trim();
if (!name.equals(requested) || !EXPORT_OUTPUT_NAME.matcher(name).matches()) {
throw new IllegalArgumentException(
"Export output must be one file name using letters, numbers, '.', '_', or '-'.");
}
String fileName = format == VanillaJigsawExportFormat.ZIP && !name.endsWith(".zip")
? name + ".zip" : name;
Path destination = root.resolve(fileName).normalize();
if (destination.equals(root) || !root.equals(destination.getParent())) {
throw new IllegalArgumentException("Export output must be one artifact inside the Studio exports folder.");
}
return destination;
}
record ExportOperation( record ExportOperation(
Player player, Player player,
VolmitSender sender, VolmitSender sender,
@@ -21,10 +21,9 @@ package art.arcane.iris.command;
import art.arcane.iris.Iris; import art.arcane.iris.Iris;
import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.configuration.IrisSettings; import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.studio.map.MapWindow;
import art.arcane.iris.studio.map.PaletteExplorerStart;
import art.arcane.iris.studio.view.GuiHost; import art.arcane.iris.studio.view.GuiHost;
import art.arcane.iris.studio.view.ImageMapStudioGUI;
import art.arcane.iris.studio.view.NoiseExplorerGUI;
import art.arcane.iris.studio.view.VisionGUI;
import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.studio.workspace.IrisProject; import art.arcane.iris.studio.workspace.IrisProject;
import art.arcane.iris.studio.workspace.IrisCodeWorkspace; import art.arcane.iris.studio.workspace.IrisCodeWorkspace;
@@ -98,11 +97,14 @@ import java.util.Objects;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import art.arcane.iris.localization.IrisLanguage; import art.arcane.iris.localization.IrisLanguage;
import art.arcane.iris.localization.BukkitCommandMessages; import art.arcane.iris.localization.BukkitCommandMessages;
import art.arcane.volmlib.util.localization.MessageArgument; import art.arcane.volmlib.util.localization.MessageArgument;
import art.arcane.volmlib.util.localization.LanguageAudience;
import art.arcane.iris.localization.BukkitCommandMessagesExtended; import art.arcane.iris.localization.BukkitCommandMessagesExtended;
@Director(name = "studio", aliases = {"std", "s"}, description = "Studio Commands", descriptionKey = "iris.director.commandstudio.director.studio_commands") @Director(name = "studio", aliases = {"std", "s"}, description = "Studio Commands", descriptionKey = "iris.director.commandstudio.director.studio_commands")
public class CommandStudio implements DirectorExecutor { public class CommandStudio implements DirectorExecutor {
@@ -202,6 +204,22 @@ public class CommandStudio implements DirectorExecutor {
})); }));
} }
@Director(description = "Hotload the active Studio pack", descriptionKey = "iris.director.commandstudio.director.hotload_active_studio_pack", aliases = {"refresh"}, origin = DirectorOrigin.BOTH)
public void hotload() {
VolmitSender commandSender = sender();
IrisProject project = Iris.service(StudioSVC.class).getActiveProject();
PlatformChunkGenerator generator = project == null ? null : project.getActiveProvider();
if (generator == null) {
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_STUDIO_NO_OPEN_STUDIO_PROJECTS));
return;
}
UUID audience = LanguageAudience.current();
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_STUDIO_HOTLOADING));
generator.hotloadAsync().whenComplete((ignored, failure) ->
LanguageAudience.run(audience, () -> sendHotloadResult(commandSender, failure)));
}
@Director(description = "Toggle your Studio debug scoreboard", descriptionKey = "iris.director.commandstudio.director.toggle_your_studio_debug_scoreboard", aliases = {"board", "sidebar", "sb"}, origin = DirectorOrigin.PLAYER) @Director(description = "Toggle your Studio debug scoreboard", descriptionKey = "iris.director.commandstudio.director.toggle_your_studio_debug_scoreboard", aliases = {"board", "sidebar", "sb"}, origin = DirectorOrigin.PLAYER)
public void scoreboard() { public void scoreboard() {
Player target = player(); Player target = player();
@@ -255,15 +273,10 @@ public class CommandStudio implements DirectorExecutor {
long seed long seed
) { ) {
if (noGUI()) return; if (noGUI()) return;
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_NOISE_EXPLORER)); VolmitSender commandSender = sender();
UUID openerId = commandSender.isPlayer() ? commandSender.player().getUniqueId() : null;
if (generator == null) { MapWindow.NoiseSelection selection = new MapWindow.NoiseSelection(generator == null ? null : generator.getLoadKey(), generator, seed);
NoiseExplorerGUI.launch(); withPlayerContext(opener -> openNoiseAtPlayer(commandSender, openerId, opener, selection));
return;
}
String generatorKey = generator.getLoadKey();
NoiseExplorerGUI.launchGeneratorKey(generatorKey, generator, seed);
} }
@Director(description = "Open the image-map studio (External GUI)", descriptionKey = "iris.director.commandstudio.director.open_image_map_studio_external_gui", aliases = {"imap"}) @Director(description = "Open the image-map studio (External GUI)", descriptionKey = "iris.director.commandstudio.director.open_image_map_studio_external_gui", aliases = {"imap"})
@@ -279,8 +292,7 @@ public class CommandStudio implements DirectorExecutor {
return; return;
} }
Engine activeEngine = IrisToolbelt.access(world).getEngine(); requestMapStart(world, MapWindow::openImageMaps);
ImageMapStudioGUI.launch(activeEngine);
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_IMAGE_MAP_STUDIO)); sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_IMAGE_MAP_STUDIO));
} }
@@ -420,11 +432,61 @@ public class CommandStudio implements DirectorExecutor {
return; return;
} }
UUID openerId = sender().isPlayer() ? player().getUniqueId() : null; requestMapStart(world, MapWindow::openMap);
VisionGUI.launch(IrisToolbelt.access(world).getEngine(), openerId);
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_MAP)); sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_MAP));
} }
// The map opens where the player stands when they are in that world, otherwise at the world origin.
private PaletteExplorerStart mapStartIn(World world, Player opener, UUID openerId) {
Location center = opener != null && opener.getWorld().equals(world) ? opener.getLocation() : null;
return new PaletteExplorerStart(
IrisToolbelt.access(world).getEngine(),
world.getUID(),
openerId,
null,
center == null ? 0D : center.getX(),
center == null ? 0D : center.getZ()
);
}
private void requestMapStart(World world, Consumer<PaletteExplorerStart> launch) {
UUID openerId = sender().isPlayer() ? player().getUniqueId() : null;
withPlayerContext(opener -> launch.accept(mapStartIn(world, opener, openerId)));
}
private void openNoiseAtPlayer(VolmitSender commandSender, UUID openerId, Player opener, MapWindow.NoiseSelection selection) {
World world = opener == null ? null : opener.getWorld();
PlatformChunkGenerator worldGenerator = world == null ? null : IrisToolbelt.access(world);
Engine engine = worldGenerator == null ? GuiHost.get().findActiveEngine() : worldGenerator.getEngine();
if (engine == null) {
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_YOU_NEED_BE_SPECIFY_IRIS_GENERATED_WORLD));
return;
}
PaletteExplorerStart start = worldGenerator == null
? new PaletteExplorerStart(engine, null, openerId, null, 0D, 0D)
: mapStartIn(world, opener, openerId);
MapWindow.openNoise(start, selection);
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_NOISE_EXPLORER));
}
private void withPlayerContext(Consumer<Player> action) {
Player opener = sender().isPlayer() ? player() : null;
if (opener == null) {
action.accept(null);
return;
}
AtomicBoolean completed = new AtomicBoolean();
Consumer<Player> complete = player -> {
if (completed.compareAndSet(false, true)) {
action.accept(player);
}
};
Runnable retired = () -> complete.accept(null);
if (!J.runEntity(opener, () -> complete.accept(opener.isOnline() ? opener : null), 0, retired)) {
retired.run();
}
}
@Director(description = "Profiles the performance of a dimension", descriptionKey = "iris.director.commandstudio.director.profiles_performance_dimension", origin = DirectorOrigin.PLAYER) @Director(description = "Profiles the performance of a dimension", descriptionKey = "iris.director.commandstudio.director.profiles_performance_dimension", origin = DirectorOrigin.PLAYER)
public void profile( public void profile(
@Param(description = "The dimension to profile", descriptionKey = "iris.director.commandstudio.param.dimension_profile", contextual = true, contextualOverride = true, defaultValue = "default", customHandler = DimensionHandler.class) @Param(description = "The dimension to profile", descriptionKey = "iris.director.commandstudio.param.dimension_profile", contextual = true, contextualOverride = true, defaultValue = "default", customHandler = DimensionHandler.class)
@@ -597,6 +659,16 @@ public class CommandStudio implements DirectorExecutor {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_DONE, MessageArgument.untrusted("value", report.getPath()))); sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_DONE, MessageArgument.untrusted("value", report.getPath())));
} }
private void sendHotloadResult(VolmitSender commandSender, Throwable failure) {
if (failure != null) {
Iris.reportError(failure);
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_STUDIO_HOTLOAD_FAILED,
MessageArgument.untrusted("value", String.valueOf(failure.getMessage()))));
return;
}
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_STUDIO_HOTLOADED));
}
private PlatformChunkGenerator resolveProfileGenerator(IrisDimension dimension) { private PlatformChunkGenerator resolveProfileGenerator(IrisDimension dimension) {
StudioSVC studioService = Iris.service(StudioSVC.class); StudioSVC studioService = Iris.service(StudioSVC.class);
if (studioService != null && studioService.isProjectOpen()) { if (studioService != null && studioService.isProjectOpen()) {
@@ -0,0 +1,50 @@
package art.arcane.iris.platform.agent;
import java.lang.foreign.Arena;
import java.lang.foreign.FunctionDescriptor;
import java.lang.foreign.Linker;
import java.lang.foreign.MemorySegment;
import java.lang.foreign.ValueLayout;
import java.lang.invoke.MethodHandle;
import java.util.Locale;
public final class AttachSignal {
private static final int SIGQUIT = 3;
private static final int SIGTERM = 15;
private static final long ACTION_BYTES = 256;
private static final long SIG_IGN = 1L;
private AttachSignal() {
}
public static boolean restoreJvmHandler() {
if (System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win")) {
return false;
}
Linker linker = Linker.nativeLinker();
MethodHandle sigaction = linker.downcallHandle(
linker.defaultLookup().find("sigaction")
.orElseThrow(() -> new IllegalStateException("sigaction is unavailable")),
FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.JAVA_INT, ValueLayout.ADDRESS, ValueLayout.ADDRESS));
try (Arena arena = Arena.ofConfined()) {
MemorySegment jvm = arena.allocate(ACTION_BYTES);
MemorySegment quit = arena.allocate(ACTION_BYTES);
if ((int) sigaction.invokeExact(SIGTERM, MemorySegment.NULL, jvm) != 0
|| (int) sigaction.invokeExact(SIGQUIT, MemorySegment.NULL, quit) != 0) {
throw new IllegalStateException("sigaction could not read the SIGTERM and SIGQUIT handlers");
}
long handler = jvm.get(ValueLayout.JAVA_LONG, 0);
if (handler == 0L || handler == SIG_IGN || quit.get(ValueLayout.JAVA_LONG, 0) == handler) {
return false;
}
if ((int) sigaction.invokeExact(SIGQUIT, jvm, MemorySegment.NULL) != 0) {
throw new IllegalStateException("sigaction could not restore the JVM SIGQUIT handler");
}
return true;
} catch (RuntimeException | Error failure) {
throw failure;
} catch (Throwable failure) {
throw new IllegalStateException("Unable to restore the JVM SIGQUIT handler", failure);
}
}
}
@@ -17,8 +17,7 @@ import art.arcane.iris.platform.bukkit.terrain.IrisSampleLimits;
import art.arcane.iris.platform.bukkit.terrain.IrisSurfaceClassifier; import art.arcane.iris.platform.bukkit.terrain.IrisSurfaceClassifier;
import art.arcane.iris.platform.bukkit.terrain.IrisWorldInfoFactory; import art.arcane.iris.platform.bukkit.terrain.IrisWorldInfoFactory;
import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.hydrology.HydrologyColumnLayer; import art.arcane.iris.generation.hydrology.raster.WaterRole;
import art.arcane.iris.generation.hydrology.HydrologyColumnSample;
import art.arcane.iris.generation.terrain.InferredType; import art.arcane.iris.generation.terrain.InferredType;
import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.terrain.IrisRegion; import art.arcane.iris.generation.terrain.IrisRegion;
@@ -108,15 +107,15 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService {
try { try {
int surface = engine.getHeight(blockX, blockZ); int surface = engine.getHeight(blockX, blockZ);
HydrologyColumnSample hydrology = hydrologySample(engine, blockX, blockZ); WaterRole role = engine.getComplex().getWaterRoleStream().get(blockX, blockZ);
int fluid = (int) Math.round( int fluid = (int) Math.round(
engine.getComplex().getRiverWaterSurfaceStream().get(blockX, blockZ)); engine.getComplex().getWaterHeadStream().get(blockX, blockZ));
InferredType inferredType = null; InferredType inferredType = null;
if (IrisSurfaceClassifier.requiresSurfaceBiome(surface, fluid)) { if (IrisSurfaceClassifier.requiresSurfaceBiome(surface, fluid)) {
IrisBiome biome = engine.getSurfaceBiome(blockX, blockZ); IrisBiome biome = engine.getSurfaceBiome(blockX, blockZ);
inferredType = biome == null ? null : biome.getInferredType(); inferredType = biome == null ? null : biome.getInferredType();
} }
return IrisSurfaceClassifier.classify(surface, fluid, inferredType, hydrology); return IrisSurfaceClassifier.classify(surface, fluid, inferredType, role);
} catch (Throwable error) { } catch (Throwable error) {
reportQueryFault("surfaceKind", world, error); reportQueryFault("surfaceKind", world, error);
return IrisSurfaceKind.UNKNOWN; return IrisSurfaceKind.UNKNOWN;
@@ -266,12 +265,12 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService {
return false; return false;
} }
HydrologyColumnSample hydrology = wantRiver WaterRole role = wantRiver
? hydrologySample(engine, blockX, blockZ) ? engine.getComplex().getWaterRoleStream().get(blockX, blockZ)
: null; : WaterRole.NONE;
int surface = wantHeight || wantKind ? engine.getHeight(blockX, blockZ) : 0; int surface = wantHeight || wantKind ? engine.getHeight(blockX, blockZ) : 0;
int fluid = wantRiver int fluid = wantRiver
? (int) Math.round(engine.getComplex().getRiverWaterSurfaceStream().get(blockX, blockZ)) ? (int) Math.round(engine.getComplex().getWaterHeadStream().get(blockX, blockZ))
: engine.getDimension().getFluidHeight(); : engine.getDimension().getFluidHeight();
boolean needsBiome = wantBiome boolean needsBiome = wantBiome
|| (wantKind && IrisSurfaceClassifier.requiresSurfaceBiome(surface, fluid)); || (wantKind && IrisSurfaceClassifier.requiresSurfaceBiome(surface, fluid));
@@ -281,29 +280,23 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService {
surface, surface,
fluid, fluid,
biome == null ? null : biome.getInferredType(), biome == null ? null : biome.getInferredType(),
hydrology role
) )
: IrisSurfaceKind.UNKNOWN; : IrisSurfaceKind.UNKNOWN;
String biomeKey = wantBiome && biome != null ? biome.getLoadKey() : null; String biomeKey = wantBiome && biome != null ? biome.getLoadKey() : null;
int natural = wantNaturalHeight int natural = wantNaturalHeight
? (int) Math.round(engine.getComplex().getNaturalHeightStream().get(blockX, blockZ)) + minHeight ? (int) Math.round(engine.getComplex().getNaturalHeightStream().get(blockX, blockZ)) + minHeight
: IrisColumnSample.UNAVAILABLE_HEIGHT; : IrisColumnSample.UNAVAILABLE_HEIGHT;
HydrologyColumnLayer surfaceLayer = hydrology == null
? null
: hydrology.primarySurfaceLayer().orElse(null);
boolean riverPresent = surfaceLayer != null;
IrisRiverState riverState = wantRiverState IrisRiverState riverState = wantRiverState
? riverState(hydrology) ? riverState(role)
: IrisRiverState.NONE; : IrisRiverState.NONE;
double riverDistance = wantRiverDistance && riverPresent double riverDistance = wantRiverDistance
? engine.getComplex().getRiverDistanceStream().get(blockX, blockZ) ? riverDistance(role)
: IrisColumnSample.UNAVAILABLE_RIVER_DISTANCE; : IrisColumnSample.UNAVAILABLE_RIVER_DISTANCE;
int riverFlow = wantRiverFlow && riverPresent int riverFlow = wantRiverFlow
? (int) Math.round(engine.getComplex().getRiverFlowStream().get(blockX, blockZ)) ? riverFlow(role)
: IrisColumnSample.UNAVAILABLE_RIVER_FLOW; : IrisColumnSample.UNAVAILABLE_RIVER_FLOW;
int riverWaterSurfaceY = wantRiverWaterSurface int riverWaterSurfaceY = wantRiverWaterSurface && role.wet()
&& surfaceLayer != null
&& surfaceLayer.connectedFluid()
? fluid + minHeight ? fluid + minHeight
: IrisColumnSample.UNAVAILABLE_HEIGHT; : IrisColumnSample.UNAVAILABLE_HEIGHT;
sink.accept(new IrisColumnSample( sink.accept(new IrisColumnSample(
@@ -327,21 +320,25 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService {
} }
} }
static IrisRiverState riverState(HydrologyColumnSample hydrology) { static IrisRiverState riverState(WaterRole role) {
HydrologyColumnLayer layer = hydrology == null return role.wet() ? IrisRiverState.WET : IrisRiverState.NONE;
? null
: hydrology.primarySurfaceLayer().orElse(null);
if (layer == null || !layer.channel()) {
return IrisRiverState.NONE;
}
return layer.connectedFluid() ? IrisRiverState.WET : IrisRiverState.DRY;
} }
private static HydrologyColumnSample hydrologySample(Engine engine, int blockX, int blockZ) { static double riverDistance(WaterRole role) {
if (engine.getComplex().getHydrologyRuntime() == null) { return switch (role) {
return null; case NONE -> IrisColumnSample.UNAVAILABLE_RIVER_DISTANCE;
} case CHANNEL, POND, MOUTH, CASCADE, WATERFALL, INLET -> 0D;
return engine.getComplex().getHydrologyRuntime().sample(blockX, blockZ).orElse(null); case SHORE, POND_SHORE -> 1D;
case BANK, DAM -> 2D;
};
}
static int riverFlow(WaterRole role) {
return switch (role) {
case NONE, SHORE, POND_SHORE, BANK, DAM -> IrisColumnSample.UNAVAILABLE_RIVER_FLOW;
case CHANNEL, MOUTH, CASCADE, WATERFALL -> 1;
case POND, INLET -> 0;
};
} }
private static Optional<String> key(IrisBiome biome) { private static Optional<String> key(IrisBiome biome) {
@@ -1,8 +1,7 @@
package art.arcane.iris.platform.bukkit.terrain; package art.arcane.iris.platform.bukkit.terrain;
import art.arcane.iris.api.terrain.IrisSurfaceKind; import art.arcane.iris.api.terrain.IrisSurfaceKind;
import art.arcane.iris.generation.hydrology.HydrologyColumnLayer; import art.arcane.iris.generation.hydrology.raster.WaterRole;
import art.arcane.iris.generation.hydrology.HydrologyColumnSample;
import art.arcane.iris.generation.terrain.InferredType; import art.arcane.iris.generation.terrain.InferredType;
public final class IrisSurfaceClassifier { public final class IrisSurfaceClassifier {
@@ -29,25 +28,16 @@ public final class IrisSurfaceClassifier {
int engineSurfaceHeight, int engineSurfaceHeight,
int engineFluidHeight, int engineFluidHeight,
InferredType inferredType, InferredType inferredType,
HydrologyColumnSample hydrology WaterRole role
) { ) {
if (engineSurfaceHeight <= 0) { if (engineSurfaceHeight <= 0) {
return IrisSurfaceKind.VOID; return IrisSurfaceKind.VOID;
} }
HydrologyColumnLayer layer = hydrology == null return switch (role) {
? null case NONE -> classify(engineSurfaceHeight, engineFluidHeight, inferredType);
: hydrology.primarySurfaceLayer().orElse(null); case SHORE, POND_SHORE -> IrisSurfaceKind.RIVER_SHORE;
if (layer != null) { case BANK, DAM -> IrisSurfaceKind.LAND;
if (layer.shore()) { case CHANNEL, POND, MOUTH, CASCADE, WATERFALL, INLET -> IrisSurfaceKind.RIVER;
return IrisSurfaceKind.RIVER_SHORE; };
}
if (layer.channel()) {
return layer.connectedFluid() ? IrisSurfaceKind.RIVER : IrisSurfaceKind.DRY_CHANNEL;
}
if (layer.grading()) {
return IrisSurfaceKind.LAND;
}
}
return classify(engineSurfaceHeight, engineFluidHeight, inferredType);
} }
} }
@@ -23,6 +23,8 @@ import art.arcane.iris.world.event.IrisEngineHotloadEvent;
import art.arcane.iris.world.IrisToolbelt; import art.arcane.iris.world.IrisToolbelt;
import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.platform.generation.PlatformChunkGenerator; import art.arcane.iris.platform.generation.PlatformChunkGenerator;
import art.arcane.iris.studio.StudioSVC;
import art.arcane.iris.studio.workspace.IrisProject;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.World; import org.bukkit.World;
import org.bukkit.event.EventHandler; import org.bukkit.event.EventHandler;
@@ -40,16 +42,21 @@ public final class BukkitGuiHost implements GuiHost.Provider {
GuiHost.set(new BukkitGuiHost()); GuiHost.set(new BukkitGuiHost());
} }
/**
* The engine of the open studio project, which reads the pack folder being edited, or else the first
* Iris world. A normal Iris world runs on its own copy of the pack, made when the world was created.
*/
@Override @Override
public Engine findActiveEngine() { public Engine findActiveEngine() {
Engine studioEngine = findStudioEngine();
if (studioEngine != null) {
return studioEngine;
}
try { try {
for (World world : new ArrayList<>(Bukkit.getWorlds())) { for (World world : new ArrayList<>(Bukkit.getWorlds())) {
try { Engine engine = engineOf(world);
PlatformChunkGenerator access = IrisToolbelt.access(world); if (engine != null) {
if (access != null && access.getEngine() != null && !access.getEngine().isClosed()) { return engine;
return access.getEngine();
}
} catch (Throwable ignored) {
} }
} }
} catch (Throwable ignored) { } catch (Throwable ignored) {
@@ -57,6 +64,35 @@ public final class BukkitGuiHost implements GuiHost.Provider {
return null; return null;
} }
@Override
public Engine findEngine(UUID worldId) {
World world = worldId == null ? null : Bukkit.getWorld(worldId);
return world == null ? null : engineOf(world);
}
private static Engine findStudioEngine() {
try {
StudioSVC studio = Iris.service(StudioSVC.class);
IrisProject project = studio == null || !studio.isProjectOpen() ? null : studio.getActiveProject();
PlatformChunkGenerator provider = project == null ? null : project.getActiveProvider();
Engine engine = provider == null ? null : provider.getEngine();
return engine == null || engine.isClosed() ? null : engine;
} catch (Throwable ignored) {
return null;
}
}
private static Engine engineOf(World world) {
try {
PlatformChunkGenerator access = IrisToolbelt.access(world);
if (access != null && access.getEngine() != null && !access.getEngine().isClosed()) {
return access.getEngine();
}
} catch (Throwable ignored) {
}
return null;
}
@Override @Override
public void registerHotloadHook(Runnable onHotload) { public void registerHotloadHook(Runnable onHotload) {
Listener listener = new HotloadListener(onHotload); Listener listener = new HotloadListener(onHotload);
@@ -18,23 +18,19 @@
package art.arcane.iris.studio.view; package art.arcane.iris.studio.view;
import art.arcane.volmlib.nativelib.view.WorldMarker;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.studio.render.RenderType;
import art.arcane.iris.world.IrisWorld; import art.arcane.iris.world.IrisWorld;
import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.platform.bukkit.BukkitWorldBinding; import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.world.task.J; import art.arcane.iris.world.task.J;
import art.arcane.volmlib.util.format.Form; import art.arcane.volmlib.util.format.Form;
import org.bukkit.Bukkit;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.World; import org.bukkit.World;
import org.bukkit.entity.LivingEntity; import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import java.io.File;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
@@ -56,7 +52,7 @@ public final class BukkitVisionOverlay implements GuiOverlay {
private final AtomicBoolean playerRefreshQueued = new AtomicBoolean(); private final AtomicBoolean playerRefreshQueued = new AtomicBoolean();
private final AtomicLong teleportSequence = new AtomicLong(); private final AtomicLong teleportSequence = new AtomicLong();
private final AtomicReference<VisionTeleportRequest> latestTeleport = new AtomicReference<>(); private final AtomicReference<VisionTeleportRequest> latestTeleport = new AtomicReference<>();
private volatile List<WorldMarker> playerMarkers = List.of(); private volatile List<GuiMarker> playerMarkers = List.of();
public BukkitVisionOverlay(Engine engine, UUID openerId) { public BukkitVisionOverlay(Engine engine, UUID openerId) {
this.engine = engine; this.engine = engine;
@@ -68,7 +64,7 @@ public final class BukkitVisionOverlay implements GuiOverlay {
* built by a server thread. * built by a server thread.
*/ */
@Override @Override
public List<WorldMarker> players() { public List<GuiMarker> players() {
queuePlayerRefresh(); queuePlayerRefresh();
return playerMarkers; return playerMarkers;
} }
@@ -78,26 +74,48 @@ public final class BukkitVisionOverlay implements GuiOverlay {
return; return;
} }
boolean scheduled = J.runGlobal(() -> { boolean scheduled = J.runGlobal(this::refreshPlayers);
try {
List<WorldMarker> markers = new ArrayList<>();
for (Player player : BukkitWorldBinding.players(engine.getWorld())) {
Location at = player.getLocation();
markers.add(WorldMarker.player(player.getName(), at.getX(), at.getZ()));
}
playerMarkers = List.copyOf(markers);
} finally {
playerRefreshQueued.set(false);
}
});
if (!scheduled) { if (!scheduled) {
playerRefreshQueued.set(false); playerRefreshQueued.set(false);
} }
} }
private void refreshPlayers() {
World world = BukkitWorldBinding.world(engine.getWorld());
List<Player> players = BukkitWorldBinding.players(engine.getWorld());
if (players.isEmpty() || world == null) {
playerMarkers = List.of();
playerRefreshQueued.set(false);
return;
}
List<GuiMarker> markers = Collections.synchronizedList(new ArrayList<>(players.size()));
AtomicInteger pending = new AtomicInteger(players.size());
for (Player player : players) {
AtomicBoolean completed = new AtomicBoolean();
Runnable complete = () -> {
if (completed.compareAndSet(false, true) && pending.decrementAndGet() == 0) {
playerMarkers = List.copyOf(markers);
playerRefreshQueued.set(false);
}
};
if (!J.runEntity(player, () -> {
try {
if (player.isOnline() && player.getWorld() == world) {
Location at = player.getLocation();
markers.add(GuiMarker.player(player.getName(), at.getX(), at.getZ(), at.getYaw()));
}
} finally {
complete.run();
}
}, 0, complete)) {
complete.run();
}
}
}
@Override @Override
public void requestEntities(Consumer<List<WorldMarker>> sink) { public void requestEntities(Consumer<List<GuiMarker>> sink) {
J.runGlobal(() -> { J.runGlobal(() -> {
IrisWorld target = engine.getWorld(); IrisWorld target = engine.getWorld();
World world = BukkitWorldBinding.world(target); World world = BukkitWorldBinding.world(target);
@@ -118,7 +136,7 @@ public final class BukkitVisionOverlay implements GuiOverlay {
return; return;
} }
List<WorldMarker> collected = Collections.synchronizedList(new ArrayList<>(living.size())); List<GuiMarker> collected = Collections.synchronizedList(new ArrayList<>(living.size()));
AtomicInteger pending = new AtomicInteger(living.size()); AtomicInteger pending = new AtomicInteger(living.size());
Runnable complete = () -> { Runnable complete = () -> {
if (pending.decrementAndGet() == 0) { if (pending.decrementAndGet() == 0) {
@@ -144,22 +162,22 @@ public final class BukkitVisionOverlay implements GuiOverlay {
}); });
} }
private WorldMarker marker(LivingEntity entity, Location at) { private GuiMarker marker(LivingEntity entity, Location at) {
String label = Form.capitalizeWords(entity.getType().name().toLowerCase(Locale.ROOT).replaceAll("\\Q_\\E", " ")); String label = Form.capitalizeWords(entity.getType().name().toLowerCase(Locale.ROOT).replaceAll("\\Q_\\E", " "));
double maxHealth = 0; double maxHealth = 0;
try { try {
maxHealth = entity.getAttribute(MAX_HEALTH).getValue(); maxHealth = entity.getAttribute(MAX_HEALTH).getValue();
} catch (Throwable ignored) { } catch (Throwable ignored) {
} }
return WorldMarker.entity(label, at.getX(), at.getY(), at.getZ(), entity.getHealth(), maxHealth); return GuiMarker.entity(label, at.getX(), at.getY(), at.getZ(), entity.getHealth(), maxHealth);
} }
@Override @Override
public void teleport(double worldX, double worldZ) { public void teleport(double worldX, double worldZ) {
VisionTeleportRequest request = new VisionTeleportRequest( VisionTeleportRequest request = new VisionTeleportRequest(
teleportSequence.incrementAndGet(), teleportSequence.incrementAndGet(),
VisionGUI.floorWorldCoordinate(worldX), (int) StrictMath.floor(worldX),
VisionGUI.floorWorldCoordinate(worldZ)); (int) StrictMath.floor(worldZ));
latestTeleport.set(request); latestTeleport.set(request);
startTeleport(request); startTeleport(request);
} }
@@ -212,6 +230,46 @@ public final class BukkitVisionOverlay implements GuiOverlay {
} }
} }
@Override
public void requestOpenerPosition(Consumer<OpenerPosition> sink) {
AtomicBoolean completed = new AtomicBoolean();
Consumer<OpenerPosition> complete = position -> {
if (completed.compareAndSet(false, true)) {
sink.accept(position);
}
};
if (openerId == null) {
complete.accept(OpenerPosition.OFFLINE);
return;
}
boolean scheduled = J.runGlobal(() -> {
Player player = Bukkit.getPlayer(openerId);
if (player == null) {
complete.accept(OpenerPosition.OFFLINE);
return;
}
Runnable retired = () -> complete.accept(OpenerPosition.OFFLINE);
if (!J.runEntity(player, () -> complete.accept(positionOf(player,
BukkitWorldBinding.world(engine.getWorld()))), 0, retired)) {
retired.run();
}
});
if (!scheduled) {
complete.accept(OpenerPosition.OFFLINE);
}
}
private static OpenerPosition positionOf(Player player, World world) {
if (!player.isOnline()) {
return OpenerPosition.OFFLINE;
}
if (world == null || player.getWorld() != world) {
return OpenerPosition.IN_OTHER_WORLD;
}
Location at = player.getLocation();
return OpenerPosition.inWorld(at.getX(), at.getY(), at.getZ());
}
private Player selectPlayer(List<Player> players) { private Player selectPlayer(List<Player> players) {
if (openerId == null) { if (openerId == null) {
return players.isEmpty() ? null : players.get(0); return players.isEmpty() ? null : players.get(0);
@@ -316,21 +374,6 @@ public final class BukkitVisionOverlay implements GuiOverlay {
+ blockX + "," + blockZ + ".", failure); + blockX + "," + blockZ + ".", failure);
} }
@Override
public String openInEditor(double worldX, double worldZ, RenderType type) {
IrisComplex complex = engine.getComplex();
File file = switch (type) {
case BIOME, LAYER_LOAD, DECORATOR_LOAD, OBJECT_LOAD, HEIGHT, RIVER ->
complex.getTrueBiomeStream().get(worldX, worldZ).openInVSCode();
case BIOME_LAND -> complex.getLandBiomeStream().get(worldX, worldZ).openInVSCode();
case BIOME_SEA -> complex.getSeaBiomeStream().get(worldX, worldZ).openInVSCode();
case REGION -> complex.getRegionStream().get(worldX, worldZ).openInVSCode();
case CAVE_LAND -> complex.getCaveBiomeStream().get(worldX, worldZ).openInVSCode();
default -> null;
};
return file == null ? null : file.getName();
}
private static final class VisionTeleportRequest { private static final class VisionTeleportRequest {
private final long sequence; private final long sequence;
private final int blockX; private final int blockX;
@@ -58,6 +58,7 @@ import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException; import java.util.concurrent.TimeoutException;
@@ -78,6 +79,9 @@ public final class PendingWorldReplacementManager implements Listener {
private volatile UUID overworldEntryAwaitingVerification; private volatile UUID overworldEntryAwaitingVerification;
private volatile World overworldEntryWorld; private volatile World overworldEntryWorld;
private volatile boolean paperEntryListenerRegistered; private volatile boolean paperEntryListenerRegistered;
private final Object stagingMonitor = new Object();
private boolean stagingAdmissionOpen = true;
private StagingAdmission activeStaging;
public PendingWorldReplacementManager(Iris plugin) { public PendingWorldReplacementManager(Iris plugin) {
this.plugin = Objects.requireNonNull(plugin, "plugin"); this.plugin = Objects.requireNonNull(plugin, "plugin");
@@ -135,7 +139,8 @@ public final class PendingWorldReplacementManager implements Listener {
LifecycleOperationCoordinator.Domain.WORLD_MUTATION, LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
LifecycleOperationCoordinator.OperationKind.WORLD_REPLACE, LifecycleOperationCoordinator.OperationKind.WORLD_REPLACE,
requiredWorldKey.toString() requiredWorldKey.toString()
); WorldReplacementProgress progress = WorldReplacementProgress.start( ); StagingAdmission ignoredAdmission = admitStaging(requiredWorldKey);
WorldReplacementProgress progress = WorldReplacementProgress.start(
requiredSender, requiredWorldKey.toString())) { requiredSender, requiredWorldKey.toString())) {
synchronized (this) { synchronized (this) {
IrisStartupValidation.requireWorldReplacementStagingReady(); IrisStartupValidation.requireWorldReplacementStagingReady();
@@ -271,6 +276,29 @@ public final class PendingWorldReplacementManager implements Listener {
} }
} }
public void awaitStagingForShutdown() {
StagingAdmission staging;
synchronized (stagingMonitor) {
stagingAdmissionOpen = false;
staging = activeStaging;
}
if (staging == null) {
return;
}
Iris.warn("Waiting for the replacement of " + staging.worldKey + " to finish staging before Iris shuts down.");
boolean interrupted = false;
while (staging.finished.getCount() > 0L) {
try {
staging.finished.await();
} catch (InterruptedException e) {
interrupted = true;
}
}
if (interrupted) {
Thread.currentThread().interrupt();
}
}
public synchronized void processPendingStartupReplacements() { public synchronized void processPendingStartupReplacements() {
ArrayList<String> failures = new ArrayList<>(); ArrayList<String> failures = new ArrayList<>();
ArrayList<String> restartBoundaries = new ArrayList<>(); ArrayList<String> restartBoundaries = new ArrayList<>();
@@ -1048,6 +1076,17 @@ public final class PendingWorldReplacementManager implements Listener {
return plugin.getDataFolder().toPath().toAbsolutePath().normalize(); return plugin.getDataFolder().toPath().toAbsolutePath().normalize();
} }
private StagingAdmission admitStaging(NamespacedKey worldKey) throws IOException {
synchronized (stagingMonitor) {
if (!stagingAdmissionOpen) {
throw new IOException("Iris is shutting down. Run the replacement again after the server starts.");
}
StagingAdmission staging = new StagingAdmission(worldKey);
activeStaging = staging;
return staging;
}
}
private synchronized void loadOverworldEntryGuard() throws IOException { private synchronized void loadOverworldEntryGuard() throws IOException {
ExactWorldSlotPathPolicy.Target target = resolveTarget(OVERWORLD_KEY); ExactWorldSlotPathPolicy.Target target = resolveTarget(OVERWORLD_KEY);
Optional<WorldReplacementEntryGuard.Entry> loaded = WorldReplacementEntryGuard.load(target.worldDirectory()); Optional<WorldReplacementEntryGuard.Entry> loaded = WorldReplacementEntryGuard.load(target.worldDirectory());
@@ -1218,6 +1257,25 @@ public final class PendingWorldReplacementManager implements Listener {
record SavedLocationInspection(boolean safe, Throwable failure) { record SavedLocationInspection(boolean safe, Throwable failure) {
} }
private final class StagingAdmission implements AutoCloseable {
private final NamespacedKey worldKey;
private final CountDownLatch finished = new CountDownLatch(1);
private StagingAdmission(NamespacedKey worldKey) {
this.worldKey = worldKey;
}
@Override
public void close() {
synchronized (stagingMonitor) {
if (activeStaging == this) {
activeStaging = null;
}
}
finished.countDown();
}
}
private static final class RestartBoundaryRequired extends IOException { private static final class RestartBoundaryRequired extends IOException {
private RestartBoundaryRequired(String message) { private RestartBoundaryRequired(String message) {
super(message); super(message);
@@ -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()));
@@ -27,3 +27,4 @@ permissions:
description: Allows survival players to fell Iris-managed trees with an axe. description: Allows survival players to fell Iris-managed trees with an axe.
default: op default: op
api-version: '${apiVersion}' api-version: '${apiVersion}'
libraries: [ ${libraries} ]
@@ -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 })
})
}
}
@@ -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,72 @@
import { readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { setup, openStudio, closeStudio, sampleTerrain, readJson, writeJson,
logPosition, logSince } from './studio-support.mjs'
async function hotload(context, fixture, active, command) {
const before = await readJson(fixture.manifestPath(active.key))
const response = await context.command(command, /^Studio hotloaded\.$|^Studio hotload failed:/, 90000)
context.expect(response === 'Studio hotloaded.', 'The manual Studio command did not complete successfully', { command, response })
const after = await readJson(fixture.manifestPath(active.key))
context.expect(after.pendingActivationId === null, 'The manual hotload completed with a pending activation')
context.report.iris.manualHotloads.push({ command, response,
before: before.activeActivationId, after: after.activeActivationId })
return after
}
export default {
name: 'iris-studio-manual-hotload',
description: 'Refresh an open Studio through hotload and its refresh alias, verify terrain, and reject commands without a Studio.',
async run(context) {
const fixture = await setup(context)
const biomeFile = path.join(fixture.pack, 'biomes', 'flat.json')
const original = await readFile(biomeFile, 'utf8')
const logOffset = await logPosition(fixture)
const processId = (await context.observe()).processId
context.report.iris.manualHotloads = []
let active
try {
await context.step('Reject hotload without an open Studio', async () => {
await context.command('/iris studio hotload', /^No open studio projects\.$/, 10000)
})
await context.step('Open Studio and hotload an unchanged pack', async () => {
active = await openStudio(context, fixture)
await sampleTerrain(context, fixture, 0, fixture.state.material)
await hotload(context, fixture, active, '/iris studio hotload')
await sampleTerrain(context, fixture, 4096, fixture.state.material)
})
await context.step('Refresh an edited pack and inspect newly generated terrain', async () => {
const before = await readJson(fixture.manifestPath(active.key))
const biome = await readJson(biomeFile)
biome.name = 'Manual hotload terrain'
biome.layers[0].palette[0].block = 'minecraft:gold_block'
await writeJson(biomeFile, biome)
await hotload(context, fixture, active, '/iris studio refresh')
const after = await fixture.wait(async () => {
const manifest = await readJson(fixture.manifestPath(active.key))
return manifest.activeActivationId !== before.activeActivationId && manifest.pendingActivationId === null ? manifest : false
}, 'The edited Studio pack did not become active', 90000)
context.report.iris.activations.push({ before: before.activeActivationId,
after: after.activeActivationId, material: 'gold_block' })
await sampleTerrain(context, fixture, 16384, 'gold_block')
await sampleTerrain(context, fixture, 0, fixture.state.material)
})
await context.step('Close Studio and reject another refresh', async () => {
await closeStudio(context, fixture, active)
active = undefined
await context.command('/iris studio refresh', /^No open studio projects\.$/, 10000)
await context.command('/minecraft:seed', /Seed:/i, 10000)
})
context.expect((await context.observe()).processId === processId, 'The server restarted during manual Studio hotload')
const log = await logSince(fixture, logOffset)
context.expect(!/Studio generation update failed|Studio hotload failed|Unable to (capture terrain|save the terrain boundary)|ClosedChannelException|Chunk system error|server has stopped responding|could not acquire its engine runtime|Failed to list files/i.test(log),
'Manual Studio hotload emitted a generation, snapshot, storage, or watchdog failure')
} finally {
try {
if (active) await closeStudio(context, fixture, active)
} finally {
await writeFile(biomeFile, original)
}
}
}
}
@@ -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 })
})
}
}
@@ -148,6 +148,9 @@ public class FailedEnableLockTest {
try (EnableFixture fixture = new EnableFixture(false)) { try (EnableFixture fixture = new EnableFixture(false)) {
IllegalStateException failure = assertThrows(IllegalStateException.class, plugin::onEnable); IllegalStateException failure = assertThrows(IllegalStateException.class, plugin::onEnable);
assertTrue(failure.getMessage(), failure.getMessage().contains("already bound")); assertTrue(failure.getMessage(), failure.getMessage().contains("already bound"));
Field failed = Iris.class.getDeclaredField("enableFailed");
failed.setAccessible(true);
assertTrue(failed.getBoolean(plugin));
} }
} }
@@ -0,0 +1,80 @@
package art.arcane.iris;
import art.arcane.iris.configuration.IrisSettings;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.mockito.MockedStatic;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.PrintStream;
import java.lang.reflect.Field;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
public class IrisErrorReportingTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void debugReportingPreservesStacklessFailures() throws Exception {
ArrayIndexOutOfBoundsException failure = new ArrayIndexOutOfBoundsException("raster index");
failure.setStackTrace(new StackTraceElement[0]);
String output = reportWithDebug(failure);
assertEquals(1L, output.lines().filter(line -> line.contains("raster index")).count());
}
@Test
public void debugReportingPreservesFailureWithStacklessCause() throws Exception {
ArrayIndexOutOfBoundsException cause = new ArrayIndexOutOfBoundsException("raster index");
cause.setStackTrace(new StackTraceElement[0]);
IllegalStateException failure = new IllegalStateException("Hydrology failed", cause);
String output = reportWithDebug(failure);
assertEquals(1L, output.lines().filter(line -> line.contains("Hydrology failed")).count());
assertTrue(output.contains("Caused by: java.lang.ArrayIndexOutOfBoundsException: raster index"));
assertTrue(output.contains("IrisErrorReportingTest.debugReportingPreservesFailureWithStacklessCause"));
}
private String reportWithDebug(Throwable failure) throws Exception {
Iris previousPlugin = Iris.instance;
IrisSettings previousSettings = IrisSettings.settings;
PrintStream previousError = System.err;
Iris plugin = mock(Iris.class);
Field deferred = Iris.class.getDeclaredField("serverStopTeardownDeferred");
deferred.setAccessible(true);
deferred.set(plugin, new AtomicBoolean());
File report = new File(temporaryFolder.getRoot(), "failure.txt");
when(plugin.getDataFile(any(String[].class))).thenReturn(report);
ByteArrayOutputStream output = new ByteArrayOutputStream();
try (PrintStream errors = new PrintStream(output, true, StandardCharsets.UTF_8);
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
Iris.instance = plugin;
IrisSettings.settings = new IrisSettings();
IrisSettings.settings.getGeneral().setDebug(true);
System.setErr(errors);
iris.when(() -> Iris.reportError(failure)).thenCallRealMethod();
Iris.reportError(failure);
assertTrue(Files.readString(report.toPath()).contains(failure.toString()));
return output.toString(StandardCharsets.UTF_8);
} finally {
System.setErr(previousError);
IrisSettings.settings = previousSettings;
Iris.instance = previousPlugin;
}
}
}
@@ -4,9 +4,13 @@ import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
import org.junit.rules.TemporaryFolder; import org.junit.rules.TemporaryFolder;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.List; import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
@@ -31,4 +35,19 @@ public class IrisPluginLoaderTest {
assertEquals(List.of(relocatedCaffeine, relocatedGson), libraries); assertEquals(List.of(relocatedCaffeine, relocatedGson), libraries);
} }
@Test
public void readsMavenLibrariesFromTheSpigotDescriptorInThePluginJar() throws Exception {
Path jar = temporaryFolder.getRoot().toPath().resolve("Iris.jar");
try (OutputStream file = Files.newOutputStream(jar); ZipOutputStream zip = new ZipOutputStream(file)) {
zip.putNextEntry(new ZipEntry("plugin.yml"));
zip.write("name: Iris\nlibraries: [ 'org.lwjgl:lwjgl:3.4.3', 'org.lwjgl:lwjgl:jar:natives-linux:3.4.3' ]\n"
.getBytes(StandardCharsets.UTF_8));
zip.closeEntry();
}
List<String> libraries = IrisPluginLoader.mavenLibraries(jar);
assertEquals(List.of("org.lwjgl:lwjgl:3.4.3", "org.lwjgl:lwjgl:jar:natives-linux:3.4.3"), libraries);
}
} }
@@ -1,7 +1,17 @@
package art.arcane.iris; package art.arcane.iris;
import art.arcane.iris.configuration.IrisSettings; import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.generation.concurrent.MultiBurst;
import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.platform.agent.Agent;
import art.arcane.iris.platform.bootstrap.SlimJar;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.platform.bukkit.plugin.IrisService;
import art.arcane.iris.platform.bukkit.plugin.VolmitPlugin;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.world.lifecycle.IrisRuntimeStatics;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.collection.KMap;
import art.arcane.iris.generation.runtime.GenerationSessionLease; import art.arcane.iris.generation.runtime.GenerationSessionLease;
import art.arcane.iris.generation.runtime.GenerationSessionManager; import art.arcane.iris.generation.runtime.GenerationSessionManager;
import art.arcane.iris.platform.bukkit.BukkitWorldBinding; import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
@@ -17,6 +27,7 @@ import art.arcane.iris.world.WorldMaintenance;
import art.arcane.iris.world.runtime.BukkitEnginePlatformHooks; import art.arcane.iris.world.runtime.BukkitEnginePlatformHooks;
import art.arcane.iris.world.task.J; import art.arcane.iris.world.task.J;
import art.arcane.volmlib.nativelib.terrain.ServerShutdownBoundary; import art.arcane.volmlib.nativelib.terrain.ServerShutdownBoundary;
import art.arcane.volmlib.integration.ReloadAware;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey; import org.bukkit.NamespacedKey;
import org.bukkit.World; import org.bukkit.World;
@@ -26,10 +37,13 @@ import org.mockito.MockedStatic;
import java.lang.reflect.Field; import java.lang.reflect.Field;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.lang.reflect.InvocationTargetException;
import java.util.List; import java.util.List;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
@@ -37,10 +51,16 @@ import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.CALLS_REAL_METHODS; import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doCallRealMethod;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
@@ -109,8 +129,6 @@ public class IrisShutdownMaintenanceTest {
} }
verify(fixture.generator).quiesceForServerShutdown(); verify(fixture.generator).quiesceForServerShutdown();
verify(fixture.generator, never()).closeAsync(); verify(fixture.generator, never()).closeAsync();
fixture.nms.verify(INMS::get);
verify(fixture.binding).deferPluginClassLoaderClose();
verify(fixture.binding, never()).releasePluginClassLoaderClose(); verify(fixture.binding, never()).releasePluginClassLoaderClose();
} }
assertEquals(0, sessions.activeLeases()); assertEquals(0, sessions.activeLeases());
@@ -151,6 +169,229 @@ public class IrisShutdownMaintenanceTest {
} }
} }
@Test
public void startupEnsuresAgentReadinessBeforeLifecycleProtectionWithoutWorldInjection() throws Exception {
try (Fixture fixture = new Fixture(); MockedStatic<Agent> agent = mockStatic(Agent.class)) {
when(fixture.binding.supportsIrisWorldGeneration()).thenReturn(true);
agent.when(Agent::install).thenReturn(true);
doAnswer(invocation -> {
agent.verify(Agent::install);
assertSame(fixture.plugin.getClass().getClassLoader(), invocation.getArgument(0));
return null;
}).when(fixture.binding).initializeLifecycle(fixture.plugin.getClass().getClassLoader());
invoke(fixture.plugin, "initializeShutdownProtection");
verify(fixture.binding).initializeLifecycle(fixture.plugin.getClass().getClassLoader());
verify(fixture.binding, never()).injectBukkit();
}
}
@Test
public void unavailableAgentStopsLifecycleInitialization() throws Exception {
try (Fixture fixture = new Fixture(); MockedStatic<Agent> agent = mockStatic(Agent.class)) {
when(fixture.binding.supportsIrisWorldGeneration()).thenReturn(true);
InvocationTargetException failure = assertThrows(InvocationTargetException.class,
() -> invoke(fixture.plugin, "initializeShutdownProtection"));
assertTrue(failure.getCause() instanceof IllegalStateException);
verify(fixture.binding, never()).initializeLifecycle(fixture.plugin.getClass().getClassLoader());
}
}
@Test
public void worldHookTeardownFailureReleasesProtectionAfterServicesAndWorkersStop() throws Exception {
try (Fixture fixture = new Fixture();
MockedStatic<Iris> iris = mockStatic(Iris.class);
MockedStatic<IrisRuntimeStatics> statics = mockStatic(IrisRuntimeStatics.class);
MockedStatic<IrisPlatforms> platforms = mockStatic(IrisPlatforms.class);
MockedStatic<BukkitPlatform> bukkitPlatform = mockStatic(BukkitPlatform.class);
MockedStatic<SlimJar> providers = mockStatic(SlimJar.class)) {
IrisService service = mock(IrisService.class);
IllegalStateException failure = new IllegalStateException("world hook teardown failed");
fixture.nms.when(INMS::isBound).thenReturn(true);
doThrow(failure).when(fixture.binding).uninjectBukkit();
setField(fixture.plugin, "services", new KMap<Class<? extends IrisService>, IrisService>());
setField(fixture.plugin, "enabledServices", List.of(service));
doAnswer(invocation -> {
platforms.verify(IrisPlatforms::unbind);
bukkitPlatform.verify(BukkitPlatform::releaseHost);
assertTrue(MultiBurst.burst.isTerminated());
assertTrue(MultiBurst.hydrology.isTerminated());
assertTrue(MultiBurst.ioBurst.isTerminated());
return null;
}).when(fixture.binding).releasePluginClassLoaderClose();
fixture.quiesce();
try {
fixture.finish(5L);
fixture.finish(5L);
verify(service).onDisable();
iris.verify(() -> Iris.reportError("Failed to remove Iris world-generation hooks.", failure));
assertTrue(MultiBurst.burst.isTerminated());
assertTrue(MultiBurst.hydrology.isTerminated());
assertTrue(MultiBurst.ioBurst.isTerminated());
verify(fixture.binding).releasePluginClassLoaderClose();
platforms.verify(IrisPlatforms::unbind);
bukkitPlatform.verify(BukkitPlatform::releaseHost);
} finally {
MultiBurst.burst.reopen();
MultiBurst.hydrology.reopen();
MultiBurst.ioBurst.reopen();
}
}
}
@Test
public void failedServiceWithAnOwnedExecutorRetainsTheClassLoader() throws Exception {
ExecutorService ownedExecutor = Executors.newSingleThreadExecutor();
CountDownLatch started = new CountDownLatch(1);
CountDownLatch finish = new CountDownLatch(1);
ownedExecutor.submit(() -> {
started.countDown();
finish.await();
return null;
});
try (Fixture fixture = new Fixture();
MockedStatic<Iris> iris = mockStatic(Iris.class);
MockedStatic<IrisRuntimeStatics> statics = mockStatic(IrisRuntimeStatics.class);
MockedStatic<IrisPlatforms> platforms = mockStatic(IrisPlatforms.class);
MockedStatic<BukkitPlatform> bukkitPlatform = mockStatic(BukkitPlatform.class)) {
assertTrue(started.await(5, TimeUnit.SECONDS));
IrisService service = mock(IrisService.class);
IllegalStateException failure = new IllegalStateException("service teardown failed");
doThrow(failure).when(service).onDisable();
setField(fixture.plugin, "services", new KMap<Class<? extends IrisService>, IrisService>());
setField(fixture.plugin, "enabledServices", List.of(service));
fixture.quiesce();
fixture.finish(5L);
verify(service).onDisable();
iris.verify(() -> Iris.reportError("Failed to disable " + service.getClass().getSimpleName() + ".", failure));
assertFalse(ownedExecutor.isTerminated());
verify(fixture.binding, never()).releasePluginClassLoaderClose();
} finally {
finish.countDown();
ownedExecutor.shutdownNow();
assertTrue(ownedExecutor.awaitTermination(5, TimeUnit.SECONDS));
MultiBurst.burst.reopen();
MultiBurst.hydrology.reopen();
MultiBurst.ioBurst.reopen();
}
}
@Test
public void failedPostShutdownCallbackRetainsTheClassLoaderForSkippedCleanup() throws Exception {
ExecutorService ownedExecutor = Executors.newSingleThreadExecutor();
CountDownLatch started = new CountDownLatch(1);
CountDownLatch finish = new CountDownLatch(1);
ownedExecutor.submit(() -> {
started.countDown();
finish.await();
return null;
});
try (Fixture fixture = new Fixture();
MockedStatic<Iris> iris = mockStatic(Iris.class);
MockedStatic<IrisRuntimeStatics> statics = mockStatic(IrisRuntimeStatics.class);
MockedStatic<IrisPlatforms> platforms = mockStatic(IrisPlatforms.class);
MockedStatic<BukkitPlatform> bukkitPlatform = mockStatic(BukkitPlatform.class)) {
assertTrue(started.await(5, TimeUnit.SECONDS));
IllegalStateException failure = new IllegalStateException("post-shutdown callback failed");
AtomicBoolean cleanupAttempted = new AtomicBoolean();
KList<Runnable> callbacks = new KList<>();
callbacks.add(() -> { throw failure; });
callbacks.add(() -> {
cleanupAttempted.set(true);
ownedExecutor.shutdown();
});
Field callbacksField = VolmitPlugin.class.getDeclaredField("postShutdown");
callbacksField.setAccessible(true);
callbacksField.set(fixture.plugin, callbacks);
Method runCallbacks = VolmitPlugin.class.getDeclaredMethod("runPostShutdown");
runCallbacks.setAccessible(true);
runCallbacks.invoke(doCallRealMethod().when(fixture.plugin));
fixture.quiesce();
fixture.finish(5L);
iris.verify(() -> Iris.reportError("Failed to run Iris post-shutdown cleanup.", failure));
assertFalse(cleanupAttempted.get());
assertFalse(ownedExecutor.isShutdown());
assertFalse(ownedExecutor.isTerminated());
verify(fixture.binding, never()).releasePluginClassLoaderClose();
} finally {
finish.countDown();
ownedExecutor.shutdownNow();
assertTrue(ownedExecutor.awaitTermination(5, TimeUnit.SECONDS));
MultiBurst.burst.reopen();
MultiBurst.hydrology.reopen();
MultiBurst.ioBurst.reopen();
}
}
@Test
public void incompleteDrainRetainsServicesWorkersAndClassLoader() throws Exception {
try (Fixture fixture = new Fixture(); MockedStatic<Iris> iris = mockStatic(Iris.class)) {
IrisService service = mock(IrisService.class);
setField(fixture.plugin, "services", new KMap<Class<? extends IrisService>, IrisService>());
setField(fixture.plugin, "enabledServices", List.of(service));
when(fixture.generator.closeAsync()).thenReturn(new CompletableFuture<>());
fixture.quiesce();
fixture.finish(0L);
verify(service, never()).onDisable();
verify(fixture.binding, never()).releasePluginClassLoaderClose();
verify(fixture.binding, never()).uninjectBukkit();
}
}
@Test
public void serverStopPreUnloadDefersTeardownWhileTheClassLoaderIsHeld() throws Exception {
try (Fixture fixture = new Fixture()) {
setField(fixture.plugin, "postStopFinisherStarted", new AtomicBoolean(true));
try {
fixture.commitReloadDuringServerStop();
verify(fixture.binding).deferPluginClassLoaderClose();
verify(fixture.generator).quiesceForServerShutdown();
verify(fixture.generator, never()).closeAsync();
assertTrue(fixture.serverStopTeardownDeferred());
} finally {
WorldMaintenance.endWorldMaintenance(fixture.identity, "pre-unload:HOT_UNLOAD");
}
}
}
@Test
public void serverStopPreUnloadDrainsInlineWhenTheClassLoaderCannotBeHeld() throws Exception {
try (Fixture fixture = new Fixture(); MockedStatic<Iris> iris = mockStatic(Iris.class)) {
IllegalStateException failure = new IllegalStateException("class loader hold failed");
doThrow(failure).when(fixture.binding).deferPluginClassLoaderClose();
try {
fixture.commitReloadDuringServerStop();
verify(fixture.generator).closeAsync();
verify(fixture.generator, never()).quiesceForServerShutdown();
assertFalse(fixture.serverStopTeardownDeferred());
iris.verify(() -> Iris.reportError("Failed to retain the Iris plugin class loader; draining shutdown inline.", failure));
} finally {
WorldMaintenance.endWorldMaintenance(fixture.identity, "pre-unload:HOT_UNLOAD", true);
}
}
}
@Test
public void serverStopPreUnloadDrainsInlineWithoutInitializedProtection() throws Exception {
try (Fixture fixture = new Fixture()) {
setField(fixture.plugin, "shutdownProtectionInitialized", false);
try {
fixture.commitReloadDuringServerStop();
verify(fixture.binding, never()).deferPluginClassLoaderClose();
verify(fixture.generator).closeAsync();
verify(fixture.generator, never()).quiesceForServerShutdown();
assertFalse(fixture.serverStopTeardownDeferred());
} finally {
WorldMaintenance.endWorldMaintenance(fixture.identity, "pre-unload:HOT_UNLOAD", true);
}
}
}
private static void invoke(Iris plugin, String name) throws ReflectiveOperationException {
Method method = Iris.class.getDeclaredMethod(name);
method.setAccessible(true);
method.invoke(plugin);
}
private static void setField(Iris plugin, String name, Object value) throws ReflectiveOperationException { private static void setField(Iris plugin, String name, Object value) throws ReflectiveOperationException {
Field field = Iris.class.getDeclaredField(name); Field field = Iris.class.getDeclaredField(name);
field.setAccessible(true); field.setAccessible(true);
@@ -178,6 +419,12 @@ public class IrisShutdownMaintenanceTest {
private Fixture() throws ReflectiveOperationException { private Fixture() throws ReflectiveOperationException {
IrisSettings.settings = new IrisSettings(); IrisSettings.settings = new IrisSettings();
setField(plugin, "shutdownProtectionInitialized", true);
setField(plugin, "alreadyDrained", new AtomicBoolean());
setField(plugin, "servicesDisabled", new AtomicBoolean());
setField(plugin, "runtimeTeardownFailed", new AtomicBoolean());
setField(plugin, "sharedRuntimeClosed", new AtomicBoolean());
setField(plugin, "terminalCleanupCompleted", new AtomicBoolean());
setField(plugin, "serverStopTeardownDeferred", new AtomicBoolean()); setField(plugin, "serverStopTeardownDeferred", new AtomicBoolean());
setField(plugin, "postStopFinisherStarted", new AtomicBoolean()); setField(plugin, "postStopFinisherStarted", new AtomicBoolean());
setField(plugin, "deferredShutdownGenerators", new CopyOnWriteArrayList<PlatformChunkGenerator>()); setField(plugin, "deferredShutdownGenerators", new CopyOnWriteArrayList<PlatformChunkGenerator>());
@@ -206,6 +453,26 @@ public class IrisShutdownMaintenanceTest {
method.invoke(plugin, "manual_disable", 5L); method.invoke(plugin, "manual_disable", 5L);
} }
private void commitReloadDuringServerStop() throws ReflectiveOperationException {
setField(plugin, "alreadyDrained", new AtomicBoolean());
toolbelt.when(IrisToolbelt::isServerStopping).thenReturn(true);
doCallRealMethod().when(plugin).commitReload(any());
plugin.commitReload(ReloadAware.PreUnloadReason.HOT_UNLOAD);
}
private boolean serverStopTeardownDeferred() throws ReflectiveOperationException {
Field field = Iris.class.getDeclaredField("serverStopTeardownDeferred");
field.setAccessible(true);
return ((AtomicBoolean) field.get(plugin)).get();
}
private void finish(long timeoutSeconds) throws ReflectiveOperationException {
Method method = Iris.class.getDeclaredMethod("finishDeferredRuntimeTeardown", String.class, long.class);
method.setAccessible(true);
method.invoke(plugin, "test_shutdown", timeoutSeconds);
}
private void startFinisher() throws ReflectiveOperationException { private void startFinisher() throws ReflectiveOperationException {
Method method = Iris.class.getDeclaredMethod("startPostStopFinisher"); Method method = Iris.class.getDeclaredMethod("startPostStopFinisher");
method.setAccessible(true); method.setAccessible(true);
@@ -0,0 +1,104 @@
package art.arcane.iris.command;
import art.arcane.iris.command.handlers.DimensionHandler;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.localization.IrisLanguage;
import art.arcane.iris.pack.datapack.DataVersion;
import art.arcane.iris.pack.datapack.export.WorldgenExportRequest;
import art.arcane.volmlib.util.director.annotations.Director;
import art.arcane.volmlib.util.director.annotations.Param;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
public class CommandDatapackExportContractTest {
private static final List<String> NAMES = List.of(
"dimension", "version", "mode", "namespace", "output", "format", "objects", "replace");
private static final List<String> DEFAULTS = List.of(
Param.NO_DEFAULT, "", "overworld", "iris_export", "", "zip", "true", "false");
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void exportParametersAreNamedInOrder() throws Exception {
List<String> names = new ArrayList<>();
for (Parameter parameter : exportMethod().getParameters()) {
names.add(parameter.getAnnotation(Param.class).name());
}
assertEquals(NAMES, names);
}
@Test
public void dimensionIsPositionalAndEveryOtherParameterIsKeyedWithItsDefault() throws Exception {
Parameter[] parameters = exportMethod().getParameters();
List<String> defaults = new ArrayList<>();
for (Parameter parameter : parameters) {
defaults.add(parameter.getAnnotation(Param.class).defaultValue());
}
assertEquals(DEFAULTS, defaults);
assertEquals(DimensionHandler.class, parameters[0].getAnnotation(Param.class).customHandler());
}
@Test
public void exportCommandAndParametersUseRegisteredDescriptions() throws Exception {
Method export = exportMethod();
Director director = export.getAnnotation(Director.class);
assertNotNull(director);
assertEquals(0L, Arrays.stream(director.aliases()).filter((String alias) -> !alias.isBlank()).count());
assertNotNull(director.descriptionKey(), IrisLanguage.catalog().key(director.descriptionKey()));
for (Parameter parameter : export.getParameters()) {
String key = parameter.getAnnotation(Param.class).descriptionKey();
assertNotNull(key, IrisLanguage.catalog().key(key));
}
}
@Test
public void validRequestResolvesItsTarget() {
assertSame(DataVersion.V26_3, CommandDatapack.validateExport(request().build()));
assertSame(DataVersion.V26_3, CommandDatapack.validateExport(request().version("26.3").build()));
}
@Test
public void unsupportedArgumentsAreRejectedWithSharedMessages() {
assertEquals("Unsupported datapack export version '1.21.1'. Supported: 26.3",
reason(request().version("1.21.1").build()));
assertEquals("Unsupported datapack export version '26.2'. Supported: 26.3",
reason(request().version("26.2").build()));
assertEquals("Unknown export mode 'nether'. Expected: preset, dimension, overworld",
reason(request().mode("nether").build()));
assertEquals("Export format must be directory or zip.", reason(request().format("rar").build()));
}
private WorldgenExportRequest.Builder request() {
Path root = temporaryFolder.getRoot().toPath();
return WorldgenExportRequest.builder()
.pack(root.resolve("packs").resolve("overworld"))
.dimension("overworld")
.exportRoot(root.resolve("exports"));
}
private static String reason(WorldgenExportRequest request) {
return assertThrows(IllegalArgumentException.class, () -> CommandDatapack.validateExport(request)).getMessage();
}
private static Method exportMethod() throws NoSuchMethodException {
return CommandDatapack.class.getDeclaredMethod("export", IrisDimension.class, String.class, String.class,
String.class, String.class, String.class, boolean.class, boolean.class);
}
}
@@ -1,7 +1,7 @@
package art.arcane.iris.command; package art.arcane.iris.command;
import art.arcane.iris.Iris; import art.arcane.iris.Iris;
import art.arcane.iris.generation.hydrology.HydrologyFeatureType; import art.arcane.iris.generation.hydrology.query.HydrologyFeatureType;
import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.IrisComplex; import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.localization.IrisLanguage; import art.arcane.iris.localization.IrisLanguage;
@@ -22,6 +22,7 @@ import java.util.OptionalLong;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyInt;
@@ -45,7 +46,7 @@ public class CommandFindHydrologyTest {
@Test @Test
public void worldChangeDuringSearchDoesNotLoadOrTeleportIntoTheNewWorld() { public void worldChangeDuringSearchDoesNotLoadOrTeleportIntoTheNewWorld() {
try (Fixture fixture = new Fixture()) { try (Fixture fixture = new Fixture()) {
fixture.command.river("surface", true); fixture.command.river("river", true);
when(fixture.player.getWorld()).thenReturn(mock(World.class)); when(fixture.player.getWorld()).thenReturn(mock(World.class));
fixture.search.get().run(); fixture.search.get().run();
@@ -58,7 +59,7 @@ public class CommandFindHydrologyTest {
@Test @Test
public void disconnectDuringSearchDoesNotLoadTheDestination() { public void disconnectDuringSearchDoesNotLoadTheDestination() {
try (Fixture fixture = new Fixture()) { try (Fixture fixture = new Fixture()) {
fixture.command.river("surface", true); fixture.command.river("river", true);
when(fixture.player.isOnline()).thenReturn(false); when(fixture.player.isOnline()).thenReturn(false);
fixture.search.get().run(); fixture.search.get().run();
@@ -69,7 +70,7 @@ public class CommandFindHydrologyTest {
@Test @Test
public void worldChangeWhileLoadingTheDestinationCancelsTeleport() { public void worldChangeWhileLoadingTheDestinationCancelsTeleport() {
try (Fixture fixture = new Fixture()) { try (Fixture fixture = new Fixture()) {
fixture.command.river("surface", true); fixture.command.river("river", true);
fixture.search.get().run(); fixture.search.get().run();
when(fixture.player.getWorld()).thenReturn(mock(World.class)); when(fixture.player.getWorld()).thenReturn(mock(World.class));
fixture.chunk.complete(mock(Chunk.class)); fixture.chunk.complete(mock(Chunk.class));
@@ -82,7 +83,7 @@ public class CommandFindHydrologyTest {
@Test @Test
public void successIsReportedOnlyAfterTeleportCompletes() { public void successIsReportedOnlyAfterTeleportCompletes() {
try (Fixture fixture = new Fixture()) { try (Fixture fixture = new Fixture()) {
fixture.command.river("surface", true); fixture.command.river("river", true);
fixture.search.get().run(); fixture.search.get().run();
fixture.chunk.complete(mock(Chunk.class)); fixture.chunk.complete(mock(Chunk.class));
@@ -95,7 +96,7 @@ public class CommandFindHydrologyTest {
@Test @Test
public void rejectedTeleportReportsFailureWithoutSuccess() { public void rejectedTeleportReportsFailureWithoutSuccess() {
try (Fixture fixture = new Fixture()) { try (Fixture fixture = new Fixture()) {
fixture.command.river("surface", true); fixture.command.river("river", true);
fixture.search.get().run(); fixture.search.get().run();
fixture.chunk.complete(mock(Chunk.class)); fixture.chunk.complete(mock(Chunk.class));
fixture.teleport.complete(false); fixture.teleport.complete(false);
@@ -109,7 +110,7 @@ public class CommandFindHydrologyTest {
public void failedTeleportReportsTheFullCauseWithoutSuccess() { public void failedTeleportReportsTheFullCauseWithoutSuccess() {
try (Fixture fixture = new Fixture()) { try (Fixture fixture = new Fixture()) {
IllegalStateException failure = new IllegalStateException("destination unloaded"); IllegalStateException failure = new IllegalStateException("destination unloaded");
fixture.command.river("surface", true); fixture.command.river("river", true);
fixture.search.get().run(); fixture.search.get().run();
fixture.chunk.complete(mock(Chunk.class)); fixture.chunk.complete(mock(Chunk.class));
fixture.teleport.completeExceptionally(failure); fixture.teleport.completeExceptionally(failure);
@@ -120,6 +121,72 @@ public class CommandFindHydrologyTest {
} }
} }
@Test
public void everyFeatureTypeIsSearchedOffTheCommandThreadAtTheRuntimeRadius() {
for (HydrologyFeatureType type : HydrologyFeatureType.values()) {
try (Fixture fixture = new Fixture()) {
fixture.command.river(type.key(), false);
fixture.queries.verify(() -> GenerationSemanticQueries.nearestRiver(any(), any(), anyInt(), anyInt(), anyInt()), never());
fixture.search.get().run();
fixture.queries.verify(() -> GenerationSemanticQueries.nearestRiver(fixture.engine, type, 0, 0,
GenerationSemanticQueries.RIVER_SEARCH_DISTANCE));
verify(fixture.sender).sendMessage(contains("Found " + type.key().replace('_', ' ') + " at 32, 63, 32."));
}
}
}
@Test
public void unknownTypeIsRejectedWithTheAcceptedTypesBeforeAnySearch() {
try (Fixture fixture = new Fixture()) {
fixture.command.river("surface", true);
verify(fixture.sender).sendMessage(contains("Use one of: stream, river, major_river, cascade, waterfall, mouth, pond, lake, dam, sea_inlet, cave_pool."));
assertNull(fixture.search.get());
}
}
@Test
public void missingCavePoolIsReportedAsNotFoundInGeneratedChunks() {
try (Fixture fixture = new Fixture()) {
fixture.queries.when(() -> GenerationSemanticQueries.nearestRiver(eq(fixture.engine), any(), eq(0), eq(0), anyInt()))
.thenReturn(Optional.empty());
fixture.command.river("cave_pool", true);
fixture.search.get().run();
verify(fixture.sender).sendMessage(contains("Searching generated chunks for the nearest cave pool within 7168 blocks"));
verify(fixture.sender).sendMessage(contains("No cave pool found in generated chunks within 7168 blocks."));
}
}
@Test
public void missingFeatureIsReportedAsNotFound() {
try (Fixture fixture = new Fixture()) {
fixture.queries.when(() -> GenerationSemanticQueries.nearestRiver(eq(fixture.engine), any(), eq(0), eq(0), anyInt()))
.thenReturn(Optional.empty());
fixture.command.river("sea_inlet", true);
fixture.search.get().run();
verify(fixture.sender).sendMessage(contains("No sea inlet found within 7168 blocks."));
fixture.platform.verify(() -> BukkitPlatform.chunkAtAsync(any(World.class), anyInt(), anyInt(), eq(true)), never());
}
}
@Test
public void plannerFailureIsReportedAsAnErrorNotAsNotFound() {
try (Fixture fixture = new Fixture()) {
IllegalStateException failure = new IllegalStateException("window planner exploded");
fixture.queries.when(() -> GenerationSemanticQueries.nearestRiver(eq(fixture.engine), any(), eq(0), eq(0), anyInt()))
.thenThrow(failure);
fixture.command.river("lake", true);
fixture.search.get().run();
verify(fixture.sender).sendMessage(contains("Could not search for lake: IllegalStateException - window planner exploded"));
verify(fixture.sender, never()).sendMessage(contains("No lake found"));
fixture.logging.verify(() -> Iris.reportError(any(String.class), eq(failure)));
}
}
private static final class Fixture implements AutoCloseable { private static final class Fixture implements AutoCloseable {
private final CommandFind command = mock(CommandFind.class, CALLS_REAL_METHODS); private final CommandFind command = mock(CommandFind.class, CALLS_REAL_METHODS);
private final Engine engine = mock(Engine.class); private final Engine engine = mock(Engine.class);
@@ -132,7 +199,7 @@ public class CommandFindHydrologyTest {
private final CompletableFuture<Boolean> teleport = new CompletableFuture<>(); private final CompletableFuture<Boolean> teleport = new CompletableFuture<>();
private final MockedStatic<J> scheduler = mockStatic(J.class); private final MockedStatic<J> scheduler = mockStatic(J.class);
private final MockedStatic<BukkitPlatform> platform = mockStatic(BukkitPlatform.class); private final MockedStatic<BukkitPlatform> platform = mockStatic(BukkitPlatform.class);
private final MockedStatic<GenerationSemanticQueries> queries = mockStatic(GenerationSemanticQueries.class); private final MockedStatic<GenerationSemanticQueries> queries = mockStatic(GenerationSemanticQueries.class, CALLS_REAL_METHODS);
private final MockedStatic<IrisLanguage> language = mockStatic(IrisLanguage.class, invocation -> "teleported"); private final MockedStatic<IrisLanguage> language = mockStatic(IrisLanguage.class, invocation -> "teleported");
private final MockedStatic<Iris> logging = mockStatic(Iris.class); private final MockedStatic<Iris> logging = mockStatic(Iris.class);
@@ -160,10 +227,10 @@ public class CommandFindHydrologyTest {
}); });
platform.when(() -> BukkitPlatform.chunkAtAsync(any(World.class), anyInt(), anyInt(), eq(true))).thenReturn(chunk); platform.when(() -> BukkitPlatform.chunkAtAsync(any(World.class), anyInt(), anyInt(), eq(true))).thenReturn(chunk);
platform.when(() -> BukkitPlatform.teleportAsync(eq(player), any(Location.class))).thenReturn(teleport); platform.when(() -> BukkitPlatform.teleportAsync(eq(player), any(Location.class))).thenReturn(teleport);
queries.when(() -> GenerationSemanticQueries.nearestRiver(eq(engine), any(), eq(0), eq(0), anyInt(), any())) queries.when(() -> GenerationSemanticQueries.nearestRiver(eq(engine), any(), eq(0), eq(0), anyInt()))
.thenReturn(Optional.of(new GenerationSemanticQueries.RiverResult( .thenReturn(Optional.of(new GenerationSemanticQueries.RiverResult(
GenerationSemanticQueries.RiverSource.ACTIVE_PREDICTION, GenerationSemanticQueries.RiverSource.ACTIVE_PREDICTION,
HydrologyFeatureType.RIFFLE, Optional.empty(), 1L, 32, 63, 32, OptionalLong.empty()))); HydrologyFeatureType.RIVER, 1L, 32, 63, 32, OptionalLong.empty())));
} }
@Override @Override
@@ -17,7 +17,6 @@ import art.arcane.iris.structure.authoring.StructureOwnershipManifest;
import art.arcane.iris.structure.authoring.StructureSource; import art.arcane.iris.structure.authoring.StructureSource;
import art.arcane.iris.structure.authoring.StructureTransactionWriter; import art.arcane.iris.structure.authoring.StructureTransactionWriter;
import art.arcane.iris.structure.conversion.IrisStructureAdoptionInputKind; import art.arcane.iris.structure.conversion.IrisStructureAdoptionInputKind;
import art.arcane.iris.structure.export.VanillaJigsawExportFormat;
import art.arcane.iris.world.IrisCreator; import art.arcane.iris.world.IrisCreator;
import art.arcane.iris.generation.terrain.IrisDimension; import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.pack.value.IrisDirection; import art.arcane.iris.pack.value.IrisDirection;
@@ -413,24 +412,6 @@ public class CommandJigsawContractTest {
} }
} }
@Test
public void exportOutputIsOneSafeChildArtifact() {
Path root = Path.of("build", "jigsaw-exports").toAbsolutePath().normalize();
assertEquals(root.resolve("village.zip"), CommandJigsawExports.resolveExportDestination(
root, "village", VanillaJigsawExportFormat.ZIP));
assertEquals(root.resolve("village"), CommandJigsawExports.resolveExportDestination(
root, "village", VanillaJigsawExportFormat.DIRECTORY));
assertThrows(IllegalArgumentException.class, () -> CommandJigsawExports.resolveExportDestination(
root, "", VanillaJigsawExportFormat.DIRECTORY));
assertThrows(IllegalArgumentException.class, () -> CommandJigsawExports.resolveExportDestination(
root, ".", VanillaJigsawExportFormat.DIRECTORY));
assertThrows(IllegalArgumentException.class, () -> CommandJigsawExports.resolveExportDestination(
root, "../all-exports", VanillaJigsawExportFormat.DIRECTORY));
assertThrows(IllegalArgumentException.class, () -> CommandJigsawExports.resolveExportDestination(
root, "nested/export", VanillaJigsawExportFormat.DIRECTORY));
}
@Test @Test
public void exportLeaseRejectsDuplicatePlayerAndDestination() { public void exportLeaseRejectsDuplicatePlayerAndDestination() {
UUID firstPlayer = UUID.randomUUID(); UUID firstPlayer = UUID.randomUUID();
@@ -20,26 +20,26 @@ import static org.mockito.Mockito.when;
public class CommandObjectFluidHeightTest { public class CommandObjectFluidHeightTest {
@Test @Test
public void absoluteObjectPlacementShiftsTheColumnRiverHeadFromNegativeMinY() { public void absoluteObjectPlacementShiftsTheColumnWaterHeadFromNegativeMinY() {
World world = mock(World.class); World world = mock(World.class);
Engine engine = mock(Engine.class); Engine engine = mock(Engine.class);
IrisDimension dimension = mock(IrisDimension.class); IrisDimension dimension = mock(IrisDimension.class);
IrisComplex complex = mock(IrisComplex.class); IrisComplex complex = mock(IrisComplex.class);
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
ProceduralStream<Double> riverHead = mock(ProceduralStream.class); ProceduralStream<Double> waterHead = mock(ProceduralStream.class);
Map<Block, BlockData> future = new HashMap<>(); Map<Block, BlockData> future = new HashMap<>();
when(engine.getMinHeight()).thenReturn(-64); when(engine.getMinHeight()).thenReturn(-64);
when(engine.getDimension()).thenReturn(dimension); when(engine.getDimension()).thenReturn(dimension);
when(engine.getComplex()).thenReturn(complex); when(engine.getComplex()).thenReturn(complex);
when(dimension.getFluidHeight()).thenReturn(127); when(dimension.getFluidHeight()).thenReturn(127);
when(complex.getRiverWaterSurfaceStream()).thenReturn(riverHead); when(complex.getWaterHeadStream()).thenReturn(waterHead);
when(riverHead.get(12, -7)).thenReturn(131D); when(waterHead.get(12, -7)).thenReturn(131D);
IObjectPlacer placer = CommandObject.createPlacer(world, future, engine); IObjectPlacer placer = CommandObject.createPlacer(world, future, engine);
assertEquals(63, placer.getFluidHeight()); assertEquals(63, placer.getFluidHeight());
assertEquals(67, placer.getFluidHeight(12, -7)); assertEquals(67, placer.getFluidHeight(12, -7));
verify(riverHead).get(12, -7); verify(waterHead).get(12, -7);
} }
} }
@@ -0,0 +1,112 @@
package art.arcane.iris.command;
import art.arcane.iris.Iris;
import art.arcane.iris.platform.bukkit.plugin.VolmitSender;
import art.arcane.iris.platform.generation.PlatformChunkGenerator;
import art.arcane.iris.studio.StudioSVC;
import art.arcane.iris.studio.workspace.IrisProject;
import art.arcane.volmlib.util.director.annotations.Director;
import art.arcane.volmlib.util.localization.LanguageAudience;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.lang.reflect.Method;
import java.io.File;
import java.util.concurrent.CompletableFuture;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class CommandStudioHotloadTest {
@Test
public void hotloadAndRefreshReportWhenNoStudioIsOpen() throws Exception {
RecordingSender sender = new RecordingSender();
StudioSVC studio = mock(StudioSVC.class);
Method hotload = CommandStudio.class.getDeclaredMethod("hotload");
assertTrue(List.of(hotload.getAnnotation(Director.class).aliases()).contains("refresh"));
try (MockedStatic<Iris> iris = mockStatic(Iris.class)) {
iris.when(() -> Iris.service(StudioSVC.class)).thenReturn(studio);
hotload.invoke(new TestCommand(sender));
assertTrue(sender.messages.getLast(), sender.messages.getLast().contains("No open studio"));
}
}
@Test
public void completionFeedbackWaitsForTheHotload() {
assertCompletionFeedback(null, "Studio hotloaded.");
}
@Test
public void failedHotloadReportsFailureAndLogsTheCause() {
assertCompletionFeedback(new IllegalStateException("Invalid pack"), "Studio hotload failed: Invalid pack");
}
private void assertCompletionFeedback(Throwable failure, String expected) {
RecordingSender sender = new RecordingSender();
StudioSVC studio = mock(StudioSVC.class);
PlatformChunkGenerator generator = mock(PlatformChunkGenerator.class);
IrisProject project = new IrisProject(new File("tutorial"));
project.setActiveProvider(generator);
CompletableFuture<Void> pending = new CompletableFuture<>();
UUID audience = UUID.randomUUID();
when(studio.getActiveProject()).thenReturn(project);
when(generator.hotloadAsync()).thenReturn(pending);
try (MockedStatic<Iris> iris = mockStatic(Iris.class)) {
iris.when(() -> Iris.service(StudioSVC.class)).thenReturn(studio);
LanguageAudience.run(audience, () -> new TestCommand(sender).hotload());
assertNull(LanguageAudience.current());
verify(generator).hotloadAsync();
assertEquals(1, sender.messages.size());
assertTrue(sender.messages.getFirst().contains("Hotloading Studio"));
if (failure == null) {
pending.complete(null);
} else {
pending.completeExceptionally(failure);
iris.verify(() -> Iris.reportError(failure));
}
assertEquals(2, sender.messages.size());
assertTrue(sender.messages.getLast(), sender.messages.getLast().contains(expected));
assertEquals(List.of(audience, audience), sender.audiences);
assertNull(LanguageAudience.current());
}
}
private static final class TestCommand extends CommandStudio {
private final VolmitSender sender;
private TestCommand(VolmitSender sender) {
this.sender = sender;
}
@Override
public VolmitSender sender() {
return sender;
}
}
private static final class RecordingSender extends VolmitSender {
private final List<String> messages = new ArrayList<>();
private final List<UUID> audiences = new ArrayList<>();
private RecordingSender() {
super(null);
}
@Override
public void sendMessage(String message) {
messages.add(message);
audiences.add(LanguageAudience.current());
}
}
}
@@ -0,0 +1,99 @@
package art.arcane.iris.command;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.platform.bukkit.plugin.VolmitSender;
import art.arcane.iris.platform.generation.PlatformChunkGenerator;
import art.arcane.iris.studio.map.MapWindow;
import art.arcane.iris.studio.map.PaletteExplorerStart;
import art.arcane.iris.studio.view.GuiHost;
import art.arcane.iris.world.IrisToolbelt;
import art.arcane.iris.world.task.J;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
public class StudioMapPositionTest {
@Test
public void mapCapturesItsStartingPositionOnThePlayerScheduler() {
IrisSettings previous = IrisSettings.settings;
IrisSettings.settings = new IrisSettings();
IrisSettings.settings.getGui().setUseServerLaunchedGuis(true);
World world = mock(World.class);
Player player = mock(Player.class);
VolmitSender sender = mock(VolmitSender.class);
PlatformChunkGenerator generator = mock(PlatformChunkGenerator.class);
Engine engine = mock(Engine.class);
UUID playerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
AtomicBoolean owned = new AtomicBoolean();
AtomicReference<Runnable> task = new AtomicReference<>();
AtomicReference<PaletteExplorerStart> start = new AtomicReference<>();
when(world.getUID()).thenReturn(worldId);
when(player.getUniqueId()).thenReturn(playerId);
when(player.isOnline()).thenReturn(true);
when(player.getWorld()).thenAnswer(invocation -> {
assertTrue("world read must use the player scheduler", owned.get());
return world;
});
when(player.getLocation()).thenAnswer(invocation -> {
assertTrue("location read must use the player scheduler", owned.get());
return new Location(world, 33.25D, 91D, -72.5D);
});
when(sender.isPlayer()).thenReturn(true);
when(sender.player()).thenReturn(player);
when(generator.getEngine()).thenReturn(engine);
CommandStudio command = new CommandStudio() {
@Override
public VolmitSender sender() {
return sender;
}
};
try (MockedStatic<GuiHost> host = mockStatic(GuiHost.class);
MockedStatic<IrisToolbelt> toolbelt = mockStatic(IrisToolbelt.class);
MockedStatic<J> scheduling = mockStatic(J.class);
MockedStatic<MapWindow> windows = mockStatic(MapWindow.class)) {
host.when(GuiHost::isAvailable).thenReturn(true);
toolbelt.when(() -> IrisToolbelt.isIrisWorld(world)).thenReturn(true);
toolbelt.when(() -> IrisToolbelt.access(world)).thenReturn(generator);
scheduling.when(() -> J.runEntity(same(player), any(Runnable.class), eq(0), any(Runnable.class)))
.thenAnswer(invocation -> {
task.set(invocation.getArgument(1));
return true;
});
windows.when(() -> MapWindow.openMap(any(PaletteExplorerStart.class))).thenAnswer(invocation -> {
start.set(invocation.getArgument(0));
return null;
});
command.map(world);
assertNull(start.get());
owned.set(true);
task.get().run();
assertEquals(engine, start.get().engine());
assertEquals(playerId, start.get().playerId());
assertEquals(worldId, start.get().worldId());
assertEquals(33.25D, start.get().centerX(), 0D);
assertEquals(-72.5D, start.get().centerZ(), 0D);
} finally {
IrisSettings.settings = previous;
}
}
}
@@ -0,0 +1,57 @@
package art.arcane.iris.platform.agent;
import org.junit.Test;
import java.lang.foreign.Arena;
import java.lang.foreign.FunctionDescriptor;
import java.lang.foreign.Linker;
import java.lang.foreign.MemorySegment;
import java.lang.foreign.ValueLayout;
import java.lang.invoke.MethodHandle;
import java.util.Locale;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertTrue;
import static org.junit.Assume.assumeFalse;
public class AttachSignalTest {
private static final int SIGQUIT = 3;
private static final int SIGTERM = 15;
private static final long ACTION_BYTES = 256;
private static final MethodHandle SIGACTION = Linker.nativeLinker().downcallHandle(
Linker.nativeLinker().defaultLookup().find("sigaction").orElseThrow(),
FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.JAVA_INT, ValueLayout.ADDRESS, ValueLayout.ADDRESS));
@Test
public void aConsoleThatResetsQuitGetsTheJvmHandlerBack() throws Throwable {
assumeFalse(System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("win"));
long jvmHandler = handler(SIGTERM);
assertNotEquals(0L, jvmHandler);
try (Arena arena = Arena.ofConfined()) {
MemorySegment original = arena.allocate(ACTION_BYTES);
assertEquals(0, (int) SIGACTION.invokeExact(SIGQUIT, MemorySegment.NULL, original));
try {
MemorySegment reset = arena.allocate(ACTION_BYTES);
assertEquals(0, (int) SIGACTION.invokeExact(SIGQUIT, reset, MemorySegment.NULL));
assertEquals(0L, handler(SIGQUIT));
assertTrue(AttachSignal.restoreJvmHandler());
assertEquals(jvmHandler, handler(SIGQUIT));
assertFalse(AttachSignal.restoreJvmHandler());
} finally {
assertEquals(0, (int) SIGACTION.invokeExact(SIGQUIT, original, MemorySegment.NULL));
}
}
}
private static long handler(int signal) throws Throwable {
try (Arena arena = Arena.ofConfined()) {
MemorySegment action = arena.allocate(ACTION_BYTES);
assertEquals(0, (int) SIGACTION.invokeExact(signal, MemorySegment.NULL, action));
return action.get(ValueLayout.JAVA_LONG, 0);
}
}
}
@@ -9,16 +9,18 @@ import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.runtime.BiomeEnvironment; import art.arcane.iris.generation.runtime.BiomeEnvironment;
import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.terrain.InferredType;
import art.arcane.iris.generation.terrain.IrisDimension; import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.generation.terrain.IrisRegion; import art.arcane.iris.generation.terrain.IrisRegion;
import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.platform.generation.PlatformChunkGenerator; import art.arcane.iris.platform.generation.PlatformChunkGenerator;
import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.world.history.SavedBiomeUnavailableException; import art.arcane.iris.world.history.SavedBiomeUnavailableException;
import art.arcane.iris.generation.hydrology.HydrologyColumnLayer; import art.arcane.volmlib.util.stream.ProceduralStream;
import art.arcane.iris.generation.hydrology.HydrologyColumnSample; import art.arcane.volmlib.util.stream.interpolation.Interpolated;
import art.arcane.iris.generation.hydrology.HydrologyFeatureRef; import art.arcane.iris.api.terrain.IrisColumnSample;
import art.arcane.iris.generation.hydrology.HydrologyFeatureType; import art.arcane.iris.generation.hydrology.raster.WaterRole;
import art.arcane.iris.platform.bukkit.plugin.IrisService; import art.arcane.iris.platform.bukkit.plugin.IrisService;
import org.bukkit.World; import org.bukkit.World;
import org.bukkit.generator.ChunkGenerator; import org.bukkit.generator.ChunkGenerator;
@@ -26,6 +28,7 @@ import org.junit.Test;
import org.mockito.MockedStatic; import org.mockito.MockedStatic;
import java.lang.reflect.Field; import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.EnumSet; import java.util.EnumSet;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -112,10 +115,32 @@ public class IrisTerrainSVCTest {
} }
@Test @Test
public void acceptedHydrologyMapsToThePublicDiagnosticStates() { public void waterRolesMapToThePublicRiverFields() {
assertEquals(IrisRiverState.NONE, IrisTerrainSVC.riverState(null)); for (WaterRole role : WaterRole.values()) {
assertEquals(IrisRiverState.WET, IrisTerrainSVC.riverState(river(true))); assertEquals(role.name(), role.wet() ? IrisRiverState.WET : IrisRiverState.NONE, IrisTerrainSVC.riverState(role));
assertEquals(IrisRiverState.DRY, IrisTerrainSVC.riverState(river(false))); double distance = switch (role) {
case NONE -> IrisColumnSample.UNAVAILABLE_RIVER_DISTANCE;
case CHANNEL, POND, MOUTH, CASCADE, WATERFALL, INLET -> 0D;
case SHORE, POND_SHORE -> 1D;
case BANK, DAM -> 2D;
};
assertEquals(role.name(), distance, IrisTerrainSVC.riverDistance(role), 0D);
int flow = switch (role) {
case NONE, SHORE, POND_SHORE, BANK, DAM -> IrisColumnSample.UNAVAILABLE_RIVER_FLOW;
case CHANNEL, MOUTH, CASCADE, WATERFALL -> 1;
case POND, INLET -> 0;
};
assertEquals(role.name(), flow, IrisTerrainSVC.riverFlow(role));
}
}
@Test
public void columnSamplesReportTheWaterRoleOfEachColumn() throws Exception {
assertEquals(List.of("RIVER", "WET", "0.0", "1", "6"), sampled(WaterRole.CHANNEL, 70));
assertEquals(List.of("RIVER", "WET", "0.0", "0", "6"), sampled(WaterRole.POND, 70));
assertEquals(List.of("RIVER_SHORE", "NONE", "1.0", "-1", "unavailable"), sampled(WaterRole.SHORE, 50));
assertEquals(List.of("LAND", "NONE", "2.0", "-1", "unavailable"), sampled(WaterRole.BANK, 50));
assertEquals(List.of("LAND", "NONE", "NaN", "-1", "unavailable"), sampled(WaterRole.NONE, 50));
} }
@Test @Test
@@ -219,6 +244,33 @@ public class IrisTerrainSVCTest {
} }
} }
private static List<String> sampled(WaterRole role, int head) throws Exception {
Engine engine = mock(Engine.class);
IrisComplex complex = mock(IrisComplex.class);
IrisBiome biome = mock(IrisBiome.class);
when(biome.getInferredType()).thenReturn(InferredType.LAND);
when(engine.getComplex()).thenReturn(complex);
when(engine.getDimension()).thenReturn(mock(IrisDimension.class));
when(engine.getHeight(0, 0)).thenReturn(60);
when(engine.getMinHeight()).thenReturn(-64);
when(engine.getSurfaceBiome(0, 0)).thenReturn(biome);
when(complex.getWaterRoleStream()).thenReturn(ProceduralStream.of((x, z) -> role, Interpolated.of(value -> 0D, value -> role)));
when(complex.getWaterHeadStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> (double) head));
IrisColumnQuery query = IrisColumnQuery.rect(0, 0, 0, 0, 1, EnumSet.of(IrisColumnField.SURFACE_KIND,
IrisColumnField.RIVER_STATE, IrisColumnField.RIVER_DISTANCE, IrisColumnField.RIVER_FLOW,
IrisColumnField.RIVER_WATER_SURFACE_Y));
List<IrisColumnSample> samples = new ArrayList<>();
try (MockedStatic<IrisSettings> settings = mockStatic(IrisSettings.class)) {
settings.when(IrisSettings::get).thenReturn(new IrisSettings());
assertTrue(enabledService().sampleColumns(world(engine), query, samples::add));
}
assertEquals(1, samples.size());
IrisColumnSample sample = samples.get(0);
return List.of(sample.surfaceKind().name(), sample.riverState().name(), String.valueOf(sample.riverDistance()),
String.valueOf(sample.riverFlow()),
sample.riverWaterSurfaceY() == IrisColumnSample.UNAVAILABLE_HEIGHT ? "unavailable" : String.valueOf(sample.riverWaterSurfaceY()));
}
private static World world(Engine engine) { private static World world(Engine engine) {
World world = mock(World.class); World world = mock(World.class);
ChunkGenerator generator = mock(ChunkGenerator.class, withSettings().extraInterfaces(PlatformChunkGenerator.class)); ChunkGenerator generator = mock(ChunkGenerator.class, withSettings().extraInterfaces(PlatformChunkGenerator.class));
@@ -235,41 +287,4 @@ public class IrisTerrainSVCTest {
((AtomicBoolean) enabled.get(service)).set(true); ((AtomicBoolean) enabled.get(service)).set(true);
return service; return service;
} }
private static HydrologyColumnSample river(boolean connectedFluid) {
HydrologyFeatureRef feature = new HydrologyFeatureRef(
1L,
HydrologyFeatureType.SURFACE_POOL,
2L,
3L,
0,
63,
0,
1,
0,
false
);
HydrologyColumnLayer layer = new HydrologyColumnLayer(
feature,
60,
63,
63,
true,
false,
false,
connectedFluid,
false,
false,
true,
connectedFluid,
false,
"water",
"river",
"mouth",
"shore",
"dry",
"cave"
);
return new HydrologyColumnSample(0, 0, 70, 63, false, "parent", List.of(layer));
}
} }
@@ -1,15 +1,10 @@
package art.arcane.iris.platform.bukkit.terrain; package art.arcane.iris.platform.bukkit.terrain;
import art.arcane.iris.api.terrain.IrisSurfaceKind; import art.arcane.iris.api.terrain.IrisSurfaceKind;
import art.arcane.iris.generation.hydrology.HydrologyColumnLayer; import art.arcane.iris.generation.hydrology.raster.WaterRole;
import art.arcane.iris.generation.hydrology.HydrologyColumnSample;
import art.arcane.iris.generation.hydrology.HydrologyFeatureRef;
import art.arcane.iris.generation.hydrology.HydrologyFeatureType;
import art.arcane.iris.generation.terrain.InferredType; import art.arcane.iris.generation.terrain.InferredType;
import org.junit.Test; import org.junit.Test;
import java.util.List;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
@@ -69,98 +64,28 @@ public class IrisSurfaceClassifierTest {
} }
@Test @Test
public void acceptedSurfaceRolesOverrideGenericOceanAndLandKinds() { public void waterRolesOverrideGenericOceanAndLandKinds() {
assertEquals(IrisSurfaceKind.RIVER, IrisSurfaceClassifier.classify( for (WaterRole role : WaterRole.values()) {
60, IrisSurfaceKind expected = switch (role) {
63, case NONE -> IrisSurfaceKind.OCEAN;
InferredType.SEA, case SHORE, POND_SHORE -> IrisSurfaceKind.RIVER_SHORE;
hydrology(HydrologyFeatureType.SURFACE_POOL, true, false, true) case BANK, DAM -> IrisSurfaceKind.LAND;
)); case CHANNEL, POND, MOUTH, CASCADE, WATERFALL, INLET -> IrisSurfaceKind.RIVER;
assertEquals(IrisSurfaceKind.RIVER, IrisSurfaceClassifier.classify( };
60, assertEquals(role.name(), expected, IrisSurfaceClassifier.classify(60, 63, InferredType.SEA, role));
63, }
InferredType.SEA,
hydrology(HydrologyFeatureType.MOUTH, true, false, true)
));
assertEquals(IrisSurfaceKind.RIVER_SHORE, IrisSurfaceClassifier.classify(
64,
63,
InferredType.SHORE,
hydrology(HydrologyFeatureType.SURFACE_POOL, false, true, false)
));
assertEquals(IrisSurfaceKind.DRY_CHANNEL, IrisSurfaceClassifier.classify(
60,
60,
InferredType.LAND,
hydrology(HydrologyFeatureType.SURFACE_POOL, true, false, false)
));
assertEquals(IrisSurfaceKind.LAND, IrisSurfaceClassifier.classify(
60,
60,
InferredType.LAND,
hydrology(HydrologyFeatureType.SURFACE_POOL, false, false, false)
));
} }
@Test @Test
public void voidClassificationWinsOverAcceptedRiverGeometry() { public void voidClassificationWinsOverWaterRoles() {
assertEquals(IrisSurfaceKind.VOID, IrisSurfaceClassifier.classify( for (WaterRole role : WaterRole.values()) {
0, assertEquals(role.name(), IrisSurfaceKind.VOID, IrisSurfaceClassifier.classify(0, 63, InferredType.LAND, role));
63, }
InferredType.LAND,
hydrology(HydrologyFeatureType.SURFACE_POOL, true, false, true)
));
} }
@Test @Test
public void absentRejectedCandidatesDoNotCreatePublicRiverKinds() { public void columnsWithoutAWaterRoleKeepTheGenericKinds() {
assertEquals(IrisSurfaceKind.LAND, IrisSurfaceClassifier.classify( assertEquals(IrisSurfaceKind.LAND, IrisSurfaceClassifier.classify(64, 63, InferredType.LAND, WaterRole.NONE));
64, assertEquals(IrisSurfaceKind.SHORE, IrisSurfaceClassifier.classify(64, 63, InferredType.SHORE, WaterRole.NONE));
63,
InferredType.LAND,
null
));
}
private static HydrologyColumnSample hydrology(
HydrologyFeatureType type,
boolean channel,
boolean shore,
boolean connectedFluid
) {
HydrologyFeatureRef feature = new HydrologyFeatureRef(
1L,
type,
2L,
3L,
0,
63,
0,
1,
0,
false
);
HydrologyColumnLayer layer = new HydrologyColumnLayer(
feature,
60,
63,
63,
channel,
shore,
!channel && !shore,
connectedFluid,
false,
false,
true,
connectedFluid && channel,
false,
"water",
"river",
"mouth",
"shore",
"dry",
"cave"
);
return new HydrologyColumnSample(0, 0, 70, 63, false, "parent", List.of(layer));
} }
} }
@@ -0,0 +1,165 @@
package art.arcane.iris.studio.view;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
import art.arcane.iris.world.IrisWorld;
import art.arcane.iris.world.task.J;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
public class BukkitOpenerPositionTest {
@Test
public void playerMarkersAreCapturedOnTheirEntitySchedulers() {
try (Harness harness = new Harness()) {
harness.binding.when(() -> BukkitWorldBinding.players(harness.target)).thenReturn(List.of(harness.player));
when(harness.player.getName()).thenReturn("Explorer");
assertTrue(harness.overlay.players().isEmpty());
harness.runEntity();
List<GuiMarker> markers = harness.overlay.players();
assertEquals(1, markers.size());
assertEquals("Explorer", markers.getFirst().label());
assertEquals(18.5D, markers.getFirst().worldX(), 0D);
assertEquals(-4.25D, markers.getFirst().worldZ(), 0D);
}
}
@Test
public void positionIsCapturedOnlyOnTheOwningEntityTask() {
try (Harness harness = new Harness()) {
harness.overlay.requestOpenerPosition(harness.results::add);
assertTrue(harness.results.isEmpty());
harness.runEntity();
assertEquals(List.of(OpenerPosition.inWorld(18.5D, 72D, -4.25D)), harness.results);
}
}
@Test
public void retiredEntityCompletesTheRequestOnce() {
try (Harness harness = new Harness()) {
harness.overlay.requestOpenerPosition(harness.results::add);
assertTrue("retirement callback must be registered", harness.retired != null);
harness.retired.run();
harness.retired.run();
assertEquals(List.of(OpenerPosition.OFFLINE), harness.results);
}
}
@Test
public void disconnectBeforeCaptureCompletesAsOfflineAndReconnectCanRefresh() {
try (Harness harness = new Harness()) {
harness.overlay.requestOpenerPosition(harness.results::add);
when(harness.player.isOnline()).thenReturn(false);
harness.runEntity();
assertEquals(List.of(OpenerPosition.OFFLINE), harness.results);
when(harness.player.isOnline()).thenReturn(true);
harness.overlay.requestOpenerPosition(harness.results::add);
harness.runEntity();
assertEquals(List.of(OpenerPosition.OFFLINE, OpenerPosition.inWorld(18.5D, 72D, -4.25D)), harness.results);
}
}
@Test
public void rejectedEntityTaskCompletesOnceEvenIfRetirementAlreadyRan() {
try (Harness harness = new Harness()) {
harness.reject = true;
harness.overlay.requestOpenerPosition(harness.results::add);
assertEquals(List.of(OpenerPosition.OFFLINE), harness.results);
}
}
@Test
public void changingWorldBeforeCaptureReportsOtherWorld() {
try (Harness harness = new Harness()) {
harness.overlay.requestOpenerPosition(harness.results::add);
when(harness.player.getWorld()).thenReturn(mock(World.class));
harness.runEntity();
assertEquals(List.of(OpenerPosition.IN_OTHER_WORLD), harness.results);
}
}
private static final class Harness implements AutoCloseable {
private final Player player = mock(Player.class);
private final World world = mock(World.class);
private final Engine engine = mock(Engine.class);
private final IrisWorld target = mock(IrisWorld.class);
private final UUID opener = UUID.randomUUID();
private final List<OpenerPosition> results = new ArrayList<>();
private final MockedStatic<J> scheduling = mockStatic(J.class);
private final MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
private final MockedStatic<BukkitWorldBinding> binding = mockStatic(BukkitWorldBinding.class);
private final BukkitVisionOverlay overlay = new BukkitVisionOverlay(engine, opener);
private Runnable task;
private Runnable retired;
private boolean entityOwned;
private boolean reject;
private Harness() {
when(engine.getWorld()).thenReturn(target);
when(player.isOnline()).thenReturn(true);
when(player.getWorld()).thenReturn(world);
when(player.getLocation()).thenAnswer(invocation -> {
assertTrue("location read outside owning entity task", entityOwned);
return new Location(world, 18.5D, 72D, -4.25D);
});
bukkit.when(() -> Bukkit.getPlayer(opener)).thenReturn(player);
binding.when(() -> BukkitWorldBinding.world(target)).thenReturn(world);
scheduling.when(() -> J.runGlobal(any(Runnable.class))).thenAnswer(invocation -> {
invocation.getArgument(0, Runnable.class).run();
return true;
});
scheduling.when(() -> J.runEntity(same(player), any(Runnable.class))).thenAnswer(invocation -> {
task = invocation.getArgument(1);
return !reject;
});
scheduling.when(() -> J.runEntity(same(player), any(Runnable.class), eq(0), any(Runnable.class)))
.thenAnswer(invocation -> {
task = invocation.getArgument(1);
retired = invocation.getArgument(3);
if (reject) {
retired.run();
}
return !reject;
});
}
private void runEntity() {
entityOwned = true;
try {
task.run();
} finally {
entityOwned = false;
}
}
@Override
public void close() {
binding.close();
bukkit.close();
scheduling.close();
}
}
}
@@ -11,12 +11,16 @@ import art.arcane.iris.platform.bukkit.plugin.VolmitSender;
import org.bukkit.NamespacedKey; import org.bukkit.NamespacedKey;
import org.junit.Test; import org.junit.Test;
import java.io.IOException;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.Future; import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertThrows;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
@@ -42,4 +46,55 @@ public class PendingWorldReplacementAdmissionTest {
executor.awaitTermination(5L, TimeUnit.SECONDS); executor.awaitTermination(5L, TimeUnit.SECONDS);
} }
} }
@Test
public void shutdownRejectsReplacementsThatStartAfterIt() {
PendingWorldReplacementManager manager = new PendingWorldReplacementManager(mock(Iris.class));
manager.awaitStagingForShutdown();
IOException rejected = assertThrows(IOException.class, () -> manager.stageReplacement(
mock(VolmitSender.class), NamespacedKey.minecraft("overworld"), mock(IrisDimension.class), null));
assertTrue(rejected.getMessage().contains("shutting down"));
assertTrue(LifecycleOperationCoordinator.get().isIdle());
}
@Test
public void shutdownWaitsForTheReplacementBeingStaged() throws Exception {
PendingWorldReplacementManager manager = new PendingWorldReplacementManager(mock(Iris.class));
AtomicReference<Throwable> stagingFailure = new AtomicReference<>();
Thread staging = new Thread(() -> {
try {
manager.stageReplacement(mock(VolmitSender.class), NamespacedKey.minecraft("overworld"),
mock(IrisDimension.class), null);
} catch (Throwable failure) {
stagingFailure.set(failure);
}
});
Thread shutdown = new Thread(manager::awaitStagingForShutdown);
synchronized (manager) {
staging.start();
awaitState(staging, Thread.State.BLOCKED);
shutdown.start();
awaitState(shutdown, Thread.State.WAITING);
assertTrue(staging.isAlive());
}
shutdown.join(TimeUnit.SECONDS.toMillis(10L));
staging.join(TimeUnit.SECONDS.toMillis(10L));
assertFalse(shutdown.isAlive());
assertFalse(staging.isAlive());
assertTrue(stagingFailure.get() instanceof Exception);
assertTrue(LifecycleOperationCoordinator.get().isIdle());
}
private static void awaitState(Thread thread, Thread.State state) throws InterruptedException {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5L);
while (thread.getState() != state) {
if (System.nanoTime() > deadline) {
throw new AssertionError(thread.getName() + " never reached " + state + "; it is " + thread.getState());
}
Thread.sleep(5L);
}
}
} }
Loaded 100 of 1738 files, more files were not shown because too many files have changed in this diff. Show more