Compare commits

...
121 Commits
Author SHA1 Message Date
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
1136 changed files with 96390 additions and 100410 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/
/.perf/
/.codegraph/
.superpowers/
@@ -497,7 +497,13 @@ public class NativeStructureVolumeIndexTest {
private static Map<?, ?> cache(NativeStructureVolumeIndex index, String name) throws Exception {
Field field = NativeStructureVolumeIndex.class.getDeclaredField(name);
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) {
@@ -2,8 +2,9 @@ package art.arcane.iris.probe;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.generation.concurrent.MultiBurst;
import art.arcane.iris.generation.hydrology.HydrologyTileCache;
import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntime;
import art.arcane.iris.generation.hydrology.engine.HydrologyPregeneration;
import art.arcane.iris.generation.hydrology.plan.WindowKey;
import art.arcane.iris.world.pregen.PregenHydrology;
import art.arcane.iris.world.pregen.PregenPerformanceProfile;
import net.minecraft.core.registries.Registries;
@@ -31,11 +32,16 @@ import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.List;
import java.util.Locale;
import java.util.Set;
public final class OfflineRegionGenerator {
private static final int CHUNKS_PER_HYDROLOGY_REGION = WindowKey.SIZE >> 4;
private OfflineRegionGenerator() {
}
@@ -97,14 +103,13 @@ public final class OfflineRegionGenerator {
PregenPerformanceProfile.apply(session.engine());
System.out.println("generationProfile=pregen noiseCacheSize=" + IrisSettings.get().getPerformance().getNoiseCacheSize()
+ " burstParallelism=" + MultiBurst.burst.parallelism() + " multicore=true");
IrisHydrologyRuntime hydrology = session.engine().getComplex().getHydrologyRuntime();
try (HydrologyTileCache.PregenerationScope hydrologyScope = hydrology == null ? null
: hydrology.preparePregeneration(configuration.hydrologyArea());
try (HydrologyPregeneration pregeneration = PregenHydrology.open(session.engine());
HeadlessTerrainContext context = HeadlessTerrainContext.create(new HeadlessTerrainContext.Options(
session.engine(), runtime, staged.resolve("templates"), configuration.levelKey(),
configuration.noiseSettings(), spigot));
RegionGenerationWindow workers = new RegionGenerationWindow(configuration.parallelism(),
RegionGenerationWindow.Policy.STANDALONE)) {
PregenHydrology.prepareAll(pregeneration, configuration.hydrologyRegions());
HeadlessRegionTerrain.Session rolling = new HeadlessRegionTerrain.Session(workers);
Path regionDirectory = session.engine().getTarget().getWorld().worldFolder().toPath().resolve("region");
Files.createDirectory(regionDirectory);
@@ -239,14 +244,27 @@ public final class OfflineRegionGenerator {
}
}
HydrologyTileCache.PregenerationArea hydrologyArea() {
List<long[]> hydrologyRegions() {
int halo = NativeRegionPlan.terrainHalo();
long minimumX = ((long) chunkX - halo) * 16L;
long minimumZ = ((long) chunkZ - halo) * 16L;
long maximumX = ((long) chunkX + (long) width * regionsX + halo) * 16L - 1L;
long maximumZ = ((long) chunkZ + (long) width * regionsZ + halo) * 16L - 1L;
return new HydrologyTileCache.PregenerationArea(Math.toIntExact((minimumX + maximumX) / 2L),
Math.toIntExact((minimumZ + maximumZ) / 2L), minimumX, minimumZ, maximumX, maximumZ);
Set<Long> seen = new HashSet<>();
List<long[]> regions = new ArrayList<>();
for (int row = 0; row < regionsZ; row++) {
for (int column = 0; column < regionsX; column++) {
int regionX = row % 2 == 0 ? column : regionsX - column - 1;
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 {
@@ -137,7 +137,7 @@ public class HeadlessChunkGeneratorTest {
}
Files.writeString(pack.resolve("dimensions/main.json"), """
{"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("generators/flat.json"), "{\"composite\":[]}");
@@ -1,7 +1,6 @@
package art.arcane.iris.probe;
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.util.collection.KList;
import net.minecraft.nbt.CompoundTag;
@@ -68,11 +67,10 @@ public final class HeadlessRegionTerrainTest {
OfflineRegionGenerator.Configuration export = new OfflineRegionGenerator.Configuration(
workspace.pack(), "main", 69420L, -3, 2, 1, 2, 3, 4, configuration,
"probe", Level.OVERWORLD, NoiseGeneratorSettings.OVERWORLD, root.resolve("export"));
HydrologyTileCache.PregenerationArea bounds = export.hydrologyArea();
assertEquals(-80L, bounds.minimumBlockX());
assertEquals(0L, bounds.minimumBlockZ());
assertEquals(15L, bounds.maximumBlockX());
assertEquals(111L, bounds.maximumBlockZ());
List<long[]> regions = export.hydrologyRegions();
assertEquals(2, regions.size());
assertArrayEquals(new long[]{-1L, 0L}, regions.get(0));
assertArrayEquals(new long[]{0L, 0L}, regions.get(1));
assertEquals(1, result.targetChunks());
assertEquals(529, result.chunks().size());
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"), """
{"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("generators/flat.json"), "{\"composite\":[]}");
@@ -497,7 +497,13 @@ public class NativeStructureVolumeIndexTest {
private static Map<?, ?> cache(NativeStructureVolumeIndex index, String name) throws Exception {
Field field = NativeStructureVolumeIndex.class.getDeclaredField(name);
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) {
+1 -1
View File
@@ -14,7 +14,7 @@ dependencies {
// log4j-core Filter interface, so its constructor reference needs the type resolvable.
compileOnly(libs.log4j.core)
testImplementation('junit:junit:4.13.2')
testImplementation('org.mockito:mockito-core:5.23.0')
testImplementation(libs.mockito.core)
testImplementation(libs.paper.api)
testImplementation(libs.bstats)
testImplementation(project(':core'))
@@ -60,6 +60,7 @@ import art.arcane.iris.integration.MultiverseCoreLink;
import art.arcane.iris.platform.bukkit.nms.INMS;
import art.arcane.volmlib.nativelib.terrain.ServerShutdownBoundary;
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.platform.bukkit.BoardSVC;
import art.arcane.iris.command.CommandSVC;
@@ -988,6 +989,8 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
}
public void onDisable() {
pendingWorldReplacements.awaitStagingForShutdown();
GuiHost.shutdown();
preservePendingBlockDrops();
papiLifecycleActive = false;
if (updateNotifier != null) {
@@ -1004,7 +1007,8 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
}
IrisLanguage.shutdown();
teardownPapi();
boolean deferToServerStop = FailedEnableLock.defersTeardownToServerStop(enableFailed, IrisToolbelt.isServerStopping());
boolean deferToServerStop = FailedEnableLock.defersTeardownToServerStop(enableFailed, IrisToolbelt.isServerStopping())
&& INMS.get().deferPluginClassLoaderClose();
boolean restartingAtStartupBoundary = startupBoundaryRestart.get();
if (restartingAtStartupBoundary) {
teardownRuntime("startup-boundary-restart", 30L);
@@ -1036,7 +1040,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
preservePendingBlockDrops();
papiLifecycleActive = false;
teardownPapi();
if (IrisToolbelt.isServerStopping()) {
if (IrisToolbelt.isServerStopping() && INMS.get().deferPluginClassLoaderClose()) {
quiesceRuntimeForServerShutdown("pre-unload:" + reason);
startPostStopFinisher();
Iris.debug("Pre-unload hook deferred generator teardown until Paper closes its chunk schedulers.");
@@ -1128,7 +1132,6 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
private void quiesceRuntimeForServerShutdown(String reason) {
serverStopTeardownDeferred.set(true);
INMS.get().deferPluginClassLoaderClose();
JigsawStudioService jigsawStudioService = IrisServices.getOrNull(JigsawStudioService.class);
if (jigsawStudioService != null) {
try {
@@ -31,8 +31,7 @@ import art.arcane.iris.structure.placement.IrisStructureLocator;
import art.arcane.iris.structure.nativegen.NativeStructureGenerationPolicy;
import art.arcane.iris.structure.placement.StructureReachability;
import art.arcane.iris.world.history.GenerationSemanticQueries;
import art.arcane.iris.generation.hydrology.HydrologyFeatureQuery;
import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntime;
import art.arcane.iris.generation.hydrology.query.HydrologyFeatureType;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.structure.nativegen.IrisNativeStructureDecision;
import art.arcane.iris.generation.terrain.IrisRegion;
@@ -196,9 +195,9 @@ public class CommandFind implements DirectorExecutor {
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(
@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,
@Param(description = "Should you be teleported", defaultValue = "true")
boolean teleport
@@ -212,42 +211,32 @@ public class CommandFind implements DirectorExecutor {
}
return;
}
IrisHydrologyRuntime runtime = activeEngine.getComplex().getHydrologyRuntime();
HydrologyFeatureQuery query;
HydrologyFeatureType featureType;
try {
query = HydrologyFeatureQuery.parse(type);
featureType = HydrologyFeatureType.parse(type);
} catch (IllegalArgumentException error) {
commandSender.sendMessage(C.RED + error.getMessage());
return;
}
String label = featureType.key().replace('_', ' ');
Location origin = target.getLocation();
World originWorld = origin.getWorld();
int requestedDistance = runtime == null
? 8192
: 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 + "...");
int maximumDistance = GenerationSemanticQueries.RIVER_SEARCH_DISTANCE;
commandSender.sendMessage(C.GRAY + GenerationSemanticQueries.riverSearchMessage(featureType, maximumDistance));
J.a(() -> {
try {
GenerationSemanticQueries.RiverResult feature = GenerationSemanticQueries.nearestRiver(
activeEngine,
query,
featureType,
origin.getBlockX(),
origin.getBlockZ(),
maximumDistance,
(int visited) -> sendStructureMessage(target, commandSender,
C.GRAY + "Searched " + visited + " hydrology tiles for " + type + "...")
maximumDistance
).orElse(null);
if (feature == null) {
sendStructureMessage(target, commandSender,
C.YELLOW + "No accepted " + type + " hydrology feature found within "
+ maximumDistance + " blocks.");
C.YELLOW + GenerationSemanticQueries.riverNotFoundMessage(featureType, maximumDistance));
return;
}
String label = type + " hydrology feature";
if (!teleport) {
sendStructureMessage(target, commandSender,
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);
} catch (Throwable error) {
sendStructureMessage(target, commandSender,
C.RED + "Could not locate " + type + " hydrology: " + error.getClass().getSimpleName());
Iris.reportError("Could not locate accepted hydrology feature '" + type + "'.", error);
C.RED + "Could not search for " + label + ": " + error.getClass().getSimpleName()
+ (error.getMessage() == null ? "" : " - " + error.getMessage()));
Iris.reportError("Could not locate hydrology feature '" + featureType.key() + "'.", error);
}
});
}
@@ -21,10 +21,9 @@ package art.arcane.iris.command;
import art.arcane.iris.Iris;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
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.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.studio.workspace.IrisProject;
import art.arcane.iris.studio.workspace.IrisCodeWorkspace;
@@ -98,7 +97,9 @@ import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import art.arcane.iris.localization.IrisLanguage;
import art.arcane.iris.localization.BukkitCommandMessages;
@@ -255,15 +256,10 @@ public class CommandStudio implements DirectorExecutor {
long seed
) {
if (noGUI()) return;
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_NOISE_EXPLORER));
if (generator == null) {
NoiseExplorerGUI.launch();
return;
}
String generatorKey = generator.getLoadKey();
NoiseExplorerGUI.launchGeneratorKey(generatorKey, generator, seed);
VolmitSender commandSender = sender();
UUID openerId = commandSender.isPlayer() ? commandSender.player().getUniqueId() : null;
MapWindow.NoiseSelection selection = new MapWindow.NoiseSelection(generator == null ? null : generator.getLoadKey(), generator, seed);
withPlayerContext(opener -> openNoiseAtPlayer(commandSender, openerId, opener, selection));
}
@Director(description = "Open the image-map studio (External GUI)", descriptionKey = "iris.director.commandstudio.director.open_image_map_studio_external_gui", aliases = {"imap"})
@@ -279,8 +275,7 @@ public class CommandStudio implements DirectorExecutor {
return;
}
Engine activeEngine = IrisToolbelt.access(world).getEngine();
ImageMapStudioGUI.launch(activeEngine);
requestMapStart(world, MapWindow::openImageMaps);
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_IMAGE_MAP_STUDIO));
}
@@ -420,11 +415,61 @@ public class CommandStudio implements DirectorExecutor {
return;
}
UUID openerId = sender().isPlayer() ? player().getUniqueId() : null;
VisionGUI.launch(IrisToolbelt.access(world).getEngine(), openerId);
requestMapStart(world, MapWindow::openMap);
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)
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)
@@ -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.IrisWorldInfoFactory;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.hydrology.HydrologyColumnLayer;
import art.arcane.iris.generation.hydrology.HydrologyColumnSample;
import art.arcane.iris.generation.hydrology.raster.WaterRole;
import art.arcane.iris.generation.terrain.InferredType;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.terrain.IrisRegion;
@@ -108,15 +107,15 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService {
try {
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(
engine.getComplex().getRiverWaterSurfaceStream().get(blockX, blockZ));
engine.getComplex().getWaterHeadStream().get(blockX, blockZ));
InferredType inferredType = null;
if (IrisSurfaceClassifier.requiresSurfaceBiome(surface, fluid)) {
IrisBiome biome = engine.getSurfaceBiome(blockX, blockZ);
inferredType = biome == null ? null : biome.getInferredType();
}
return IrisSurfaceClassifier.classify(surface, fluid, inferredType, hydrology);
return IrisSurfaceClassifier.classify(surface, fluid, inferredType, role);
} catch (Throwable error) {
reportQueryFault("surfaceKind", world, error);
return IrisSurfaceKind.UNKNOWN;
@@ -266,12 +265,12 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService {
return false;
}
HydrologyColumnSample hydrology = wantRiver
? hydrologySample(engine, blockX, blockZ)
: null;
WaterRole role = wantRiver
? engine.getComplex().getWaterRoleStream().get(blockX, blockZ)
: WaterRole.NONE;
int surface = wantHeight || wantKind ? engine.getHeight(blockX, blockZ) : 0;
int fluid = wantRiver
? (int) Math.round(engine.getComplex().getRiverWaterSurfaceStream().get(blockX, blockZ))
? (int) Math.round(engine.getComplex().getWaterHeadStream().get(blockX, blockZ))
: engine.getDimension().getFluidHeight();
boolean needsBiome = wantBiome
|| (wantKind && IrisSurfaceClassifier.requiresSurfaceBiome(surface, fluid));
@@ -281,29 +280,23 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService {
surface,
fluid,
biome == null ? null : biome.getInferredType(),
hydrology
role
)
: IrisSurfaceKind.UNKNOWN;
String biomeKey = wantBiome && biome != null ? biome.getLoadKey() : null;
int natural = wantNaturalHeight
? (int) Math.round(engine.getComplex().getNaturalHeightStream().get(blockX, blockZ)) + minHeight
: IrisColumnSample.UNAVAILABLE_HEIGHT;
HydrologyColumnLayer surfaceLayer = hydrology == null
? null
: hydrology.primarySurfaceLayer().orElse(null);
boolean riverPresent = surfaceLayer != null;
IrisRiverState riverState = wantRiverState
? riverState(hydrology)
? riverState(role)
: IrisRiverState.NONE;
double riverDistance = wantRiverDistance && riverPresent
? engine.getComplex().getRiverDistanceStream().get(blockX, blockZ)
double riverDistance = wantRiverDistance
? riverDistance(role)
: IrisColumnSample.UNAVAILABLE_RIVER_DISTANCE;
int riverFlow = wantRiverFlow && riverPresent
? (int) Math.round(engine.getComplex().getRiverFlowStream().get(blockX, blockZ))
int riverFlow = wantRiverFlow
? riverFlow(role)
: IrisColumnSample.UNAVAILABLE_RIVER_FLOW;
int riverWaterSurfaceY = wantRiverWaterSurface
&& surfaceLayer != null
&& surfaceLayer.connectedFluid()
int riverWaterSurfaceY = wantRiverWaterSurface && role.wet()
? fluid + minHeight
: IrisColumnSample.UNAVAILABLE_HEIGHT;
sink.accept(new IrisColumnSample(
@@ -327,21 +320,25 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService {
}
}
static IrisRiverState riverState(HydrologyColumnSample hydrology) {
HydrologyColumnLayer layer = hydrology == null
? null
: hydrology.primarySurfaceLayer().orElse(null);
if (layer == null || !layer.channel()) {
return IrisRiverState.NONE;
}
return layer.connectedFluid() ? IrisRiverState.WET : IrisRiverState.DRY;
static IrisRiverState riverState(WaterRole role) {
return role.wet() ? IrisRiverState.WET : IrisRiverState.NONE;
}
private static HydrologyColumnSample hydrologySample(Engine engine, int blockX, int blockZ) {
if (engine.getComplex().getHydrologyRuntime() == null) {
return null;
}
return engine.getComplex().getHydrologyRuntime().sample(blockX, blockZ).orElse(null);
static double riverDistance(WaterRole role) {
return 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;
};
}
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) {
@@ -1,8 +1,7 @@
package art.arcane.iris.platform.bukkit.terrain;
import art.arcane.iris.api.terrain.IrisSurfaceKind;
import art.arcane.iris.generation.hydrology.HydrologyColumnLayer;
import art.arcane.iris.generation.hydrology.HydrologyColumnSample;
import art.arcane.iris.generation.hydrology.raster.WaterRole;
import art.arcane.iris.generation.terrain.InferredType;
public final class IrisSurfaceClassifier {
@@ -29,25 +28,16 @@ public final class IrisSurfaceClassifier {
int engineSurfaceHeight,
int engineFluidHeight,
InferredType inferredType,
HydrologyColumnSample hydrology
WaterRole role
) {
if (engineSurfaceHeight <= 0) {
return IrisSurfaceKind.VOID;
}
HydrologyColumnLayer layer = hydrology == null
? null
: hydrology.primarySurfaceLayer().orElse(null);
if (layer != null) {
if (layer.shore()) {
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);
return switch (role) {
case NONE -> classify(engineSurfaceHeight, engineFluidHeight, inferredType);
case SHORE, POND_SHORE -> IrisSurfaceKind.RIVER_SHORE;
case BANK, DAM -> IrisSurfaceKind.LAND;
case CHANNEL, POND, MOUTH, CASCADE, WATERFALL, INLET -> IrisSurfaceKind.RIVER;
};
}
}
@@ -23,6 +23,8 @@ import art.arcane.iris.world.event.IrisEngineHotloadEvent;
import art.arcane.iris.world.IrisToolbelt;
import art.arcane.iris.generation.runtime.Engine;
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.World;
import org.bukkit.event.EventHandler;
@@ -40,16 +42,21 @@ public final class BukkitGuiHost implements GuiHost.Provider {
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
public Engine findActiveEngine() {
Engine studioEngine = findStudioEngine();
if (studioEngine != null) {
return studioEngine;
}
try {
for (World world : new ArrayList<>(Bukkit.getWorlds())) {
try {
PlatformChunkGenerator access = IrisToolbelt.access(world);
if (access != null && access.getEngine() != null && !access.getEngine().isClosed()) {
return access.getEngine();
}
} catch (Throwable ignored) {
Engine engine = engineOf(world);
if (engine != null) {
return engine;
}
}
} catch (Throwable ignored) {
@@ -57,6 +64,35 @@ public final class BukkitGuiHost implements GuiHost.Provider {
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
public void registerHotloadHook(Runnable onHotload) {
Listener listener = new HotloadListener(onHotload);
@@ -18,23 +18,19 @@
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.studio.render.RenderType;
import art.arcane.iris.world.IrisWorld;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.world.task.J;
import art.arcane.volmlib.util.format.Form;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import java.io.File;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@@ -56,7 +52,7 @@ public final class BukkitVisionOverlay implements GuiOverlay {
private final AtomicBoolean playerRefreshQueued = new AtomicBoolean();
private final AtomicLong teleportSequence = new AtomicLong();
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) {
this.engine = engine;
@@ -68,7 +64,7 @@ public final class BukkitVisionOverlay implements GuiOverlay {
* built by a server thread.
*/
@Override
public List<WorldMarker> players() {
public List<GuiMarker> players() {
queuePlayerRefresh();
return playerMarkers;
}
@@ -78,26 +74,48 @@ public final class BukkitVisionOverlay implements GuiOverlay {
return;
}
boolean scheduled = J.runGlobal(() -> {
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);
}
});
boolean scheduled = J.runGlobal(this::refreshPlayers);
if (!scheduled) {
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
public void requestEntities(Consumer<List<WorldMarker>> sink) {
public void requestEntities(Consumer<List<GuiMarker>> sink) {
J.runGlobal(() -> {
IrisWorld target = engine.getWorld();
World world = BukkitWorldBinding.world(target);
@@ -118,7 +136,7 @@ public final class BukkitVisionOverlay implements GuiOverlay {
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());
Runnable complete = () -> {
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", " "));
double maxHealth = 0;
try {
maxHealth = entity.getAttribute(MAX_HEALTH).getValue();
} 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
public void teleport(double worldX, double worldZ) {
VisionTeleportRequest request = new VisionTeleportRequest(
teleportSequence.incrementAndGet(),
VisionGUI.floorWorldCoordinate(worldX),
VisionGUI.floorWorldCoordinate(worldZ));
(int) StrictMath.floor(worldX),
(int) StrictMath.floor(worldZ));
latestTeleport.set(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) {
if (openerId == null) {
return players.isEmpty() ? null : players.get(0);
@@ -316,21 +374,6 @@ public final class BukkitVisionOverlay implements GuiOverlay {
+ 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 final long sequence;
private final int blockX;
@@ -58,6 +58,7 @@ import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
@@ -78,6 +79,9 @@ public final class PendingWorldReplacementManager implements Listener {
private volatile UUID overworldEntryAwaitingVerification;
private volatile World overworldEntryWorld;
private volatile boolean paperEntryListenerRegistered;
private final Object stagingMonitor = new Object();
private boolean stagingAdmissionOpen = true;
private StagingAdmission activeStaging;
public PendingWorldReplacementManager(Iris plugin) {
this.plugin = Objects.requireNonNull(plugin, "plugin");
@@ -135,7 +139,8 @@ public final class PendingWorldReplacementManager implements Listener {
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
LifecycleOperationCoordinator.OperationKind.WORLD_REPLACE,
requiredWorldKey.toString()
); WorldReplacementProgress progress = WorldReplacementProgress.start(
); StagingAdmission ignoredAdmission = admitStaging(requiredWorldKey);
WorldReplacementProgress progress = WorldReplacementProgress.start(
requiredSender, requiredWorldKey.toString())) {
synchronized (this) {
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() {
ArrayList<String> failures = new ArrayList<>();
ArrayList<String> restartBoundaries = new ArrayList<>();
@@ -1048,6 +1076,17 @@ public final class PendingWorldReplacementManager implements Listener {
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 {
ExactWorldSlotPathPolicy.Target target = resolveTarget(OVERWORLD_KEY);
Optional<WorldReplacementEntryGuard.Entry> loaded = WorldReplacementEntryGuard.load(target.worldDirectory());
@@ -1218,6 +1257,25 @@ public final class PendingWorldReplacementManager implements Listener {
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 RestartBoundaryRequired(String message) {
super(message);
@@ -16,6 +16,7 @@ import art.arcane.iris.world.IrisWorld;
import art.arcane.iris.world.WorldMaintenance;
import art.arcane.iris.world.runtime.BukkitEnginePlatformHooks;
import art.arcane.iris.world.task.J;
import art.arcane.volmlib.integration.ReloadAware;
import art.arcane.volmlib.nativelib.terrain.ServerShutdownBoundary;
import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey;
@@ -37,9 +38,11 @@ import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.doCallRealMethod;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
@@ -109,8 +112,6 @@ public class IrisShutdownMaintenanceTest {
}
verify(fixture.generator).quiesceForServerShutdown();
verify(fixture.generator, never()).closeAsync();
fixture.nms.verify(INMS::get);
verify(fixture.binding).deferPluginClassLoaderClose();
verify(fixture.binding, never()).releasePluginClassLoaderClose();
}
assertEquals(0, sessions.activeLeases());
@@ -151,6 +152,37 @@ public class IrisShutdownMaintenanceTest {
}
}
@Test
public void serverStopPreUnloadDefersTeardownWhileTheClassLoaderIsHeld() throws Exception {
try (Fixture fixture = new Fixture()) {
when(fixture.binding.deferPluginClassLoaderClose()).thenReturn(true);
setField(fixture.plugin, "postStopFinisherStarted", new AtomicBoolean(true));
try {
fixture.commitReloadDuringServerStop();
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()) {
when(fixture.binding.deferPluginClassLoaderClose()).thenReturn(false);
try {
fixture.commitReloadDuringServerStop();
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 setField(Iris plugin, String name, Object value) throws ReflectiveOperationException {
Field field = Iris.class.getDeclaredField(name);
field.setAccessible(true);
@@ -206,6 +238,19 @@ public class IrisShutdownMaintenanceTest {
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 startFinisher() throws ReflectiveOperationException {
Method method = Iris.class.getDeclaredMethod("startPostStopFinisher");
method.setAccessible(true);
@@ -1,7 +1,7 @@
package art.arcane.iris.command;
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.IrisComplex;
import art.arcane.iris.localization.IrisLanguage;
@@ -22,6 +22,7 @@ import java.util.OptionalLong;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
@@ -45,7 +46,7 @@ public class CommandFindHydrologyTest {
@Test
public void worldChangeDuringSearchDoesNotLoadOrTeleportIntoTheNewWorld() {
try (Fixture fixture = new Fixture()) {
fixture.command.river("surface", true);
fixture.command.river("river", true);
when(fixture.player.getWorld()).thenReturn(mock(World.class));
fixture.search.get().run();
@@ -58,7 +59,7 @@ public class CommandFindHydrologyTest {
@Test
public void disconnectDuringSearchDoesNotLoadTheDestination() {
try (Fixture fixture = new Fixture()) {
fixture.command.river("surface", true);
fixture.command.river("river", true);
when(fixture.player.isOnline()).thenReturn(false);
fixture.search.get().run();
@@ -69,7 +70,7 @@ public class CommandFindHydrologyTest {
@Test
public void worldChangeWhileLoadingTheDestinationCancelsTeleport() {
try (Fixture fixture = new Fixture()) {
fixture.command.river("surface", true);
fixture.command.river("river", true);
fixture.search.get().run();
when(fixture.player.getWorld()).thenReturn(mock(World.class));
fixture.chunk.complete(mock(Chunk.class));
@@ -82,7 +83,7 @@ public class CommandFindHydrologyTest {
@Test
public void successIsReportedOnlyAfterTeleportCompletes() {
try (Fixture fixture = new Fixture()) {
fixture.command.river("surface", true);
fixture.command.river("river", true);
fixture.search.get().run();
fixture.chunk.complete(mock(Chunk.class));
@@ -95,7 +96,7 @@ public class CommandFindHydrologyTest {
@Test
public void rejectedTeleportReportsFailureWithoutSuccess() {
try (Fixture fixture = new Fixture()) {
fixture.command.river("surface", true);
fixture.command.river("river", true);
fixture.search.get().run();
fixture.chunk.complete(mock(Chunk.class));
fixture.teleport.complete(false);
@@ -109,7 +110,7 @@ public class CommandFindHydrologyTest {
public void failedTeleportReportsTheFullCauseWithoutSuccess() {
try (Fixture fixture = new Fixture()) {
IllegalStateException failure = new IllegalStateException("destination unloaded");
fixture.command.river("surface", true);
fixture.command.river("river", true);
fixture.search.get().run();
fixture.chunk.complete(mock(Chunk.class));
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 final CommandFind command = mock(CommandFind.class, CALLS_REAL_METHODS);
private final Engine engine = mock(Engine.class);
@@ -132,7 +199,7 @@ public class CommandFindHydrologyTest {
private final CompletableFuture<Boolean> teleport = new CompletableFuture<>();
private final MockedStatic<J> scheduler = mockStatic(J.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<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.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(
GenerationSemanticQueries.RiverSource.ACTIVE_PREDICTION,
HydrologyFeatureType.RIFFLE, Optional.empty(), 1L, 32, 63, 32, OptionalLong.empty())));
HydrologyFeatureType.RIVER, 1L, 32, 63, 32, OptionalLong.empty())));
}
@Override
@@ -20,26 +20,26 @@ import static org.mockito.Mockito.when;
public class CommandObjectFluidHeightTest {
@Test
public void absoluteObjectPlacementShiftsTheColumnRiverHeadFromNegativeMinY() {
public void absoluteObjectPlacementShiftsTheColumnWaterHeadFromNegativeMinY() {
World world = mock(World.class);
Engine engine = mock(Engine.class);
IrisDimension dimension = mock(IrisDimension.class);
IrisComplex complex = mock(IrisComplex.class);
@SuppressWarnings("unchecked")
ProceduralStream<Double> riverHead = mock(ProceduralStream.class);
ProceduralStream<Double> waterHead = mock(ProceduralStream.class);
Map<Block, BlockData> future = new HashMap<>();
when(engine.getMinHeight()).thenReturn(-64);
when(engine.getDimension()).thenReturn(dimension);
when(engine.getComplex()).thenReturn(complex);
when(dimension.getFluidHeight()).thenReturn(127);
when(complex.getRiverWaterSurfaceStream()).thenReturn(riverHead);
when(riverHead.get(12, -7)).thenReturn(131D);
when(complex.getWaterHeadStream()).thenReturn(waterHead);
when(waterHead.get(12, -7)).thenReturn(131D);
IObjectPlacer placer = CommandObject.createPlacer(world, future, engine);
assertEquals(63, placer.getFluidHeight());
assertEquals(67, placer.getFluidHeight(12, -7));
verify(riverHead).get(12, -7);
verify(waterHead).get(12, -7);
}
}
@@ -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;
}
}
}
@@ -9,16 +9,18 @@ import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.runtime.BiomeEnvironment;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.terrain.InferredType;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.generation.terrain.IrisRegion;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.platform.generation.PlatformChunkGenerator;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.world.history.SavedBiomeUnavailableException;
import art.arcane.iris.generation.hydrology.HydrologyColumnLayer;
import art.arcane.iris.generation.hydrology.HydrologyColumnSample;
import art.arcane.iris.generation.hydrology.HydrologyFeatureRef;
import art.arcane.iris.generation.hydrology.HydrologyFeatureType;
import art.arcane.volmlib.util.stream.ProceduralStream;
import art.arcane.volmlib.util.stream.interpolation.Interpolated;
import art.arcane.iris.api.terrain.IrisColumnSample;
import art.arcane.iris.generation.hydrology.raster.WaterRole;
import art.arcane.iris.platform.bukkit.plugin.IrisService;
import org.bukkit.World;
import org.bukkit.generator.ChunkGenerator;
@@ -26,6 +28,7 @@ import org.junit.Test;
import org.mockito.MockedStatic;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import java.util.Optional;
@@ -112,10 +115,32 @@ public class IrisTerrainSVCTest {
}
@Test
public void acceptedHydrologyMapsToThePublicDiagnosticStates() {
assertEquals(IrisRiverState.NONE, IrisTerrainSVC.riverState(null));
assertEquals(IrisRiverState.WET, IrisTerrainSVC.riverState(river(true)));
assertEquals(IrisRiverState.DRY, IrisTerrainSVC.riverState(river(false)));
public void waterRolesMapToThePublicRiverFields() {
for (WaterRole role : WaterRole.values()) {
assertEquals(role.name(), role.wet() ? IrisRiverState.WET : IrisRiverState.NONE, IrisTerrainSVC.riverState(role));
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
@@ -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) {
World world = mock(World.class);
ChunkGenerator generator = mock(ChunkGenerator.class, withSettings().extraInterfaces(PlatformChunkGenerator.class));
@@ -235,41 +287,4 @@ public class IrisTerrainSVCTest {
((AtomicBoolean) enabled.get(service)).set(true);
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;
import art.arcane.iris.api.terrain.IrisSurfaceKind;
import art.arcane.iris.generation.hydrology.HydrologyColumnLayer;
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.hydrology.raster.WaterRole;
import art.arcane.iris.generation.terrain.InferredType;
import org.junit.Test;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
@@ -69,98 +64,28 @@ public class IrisSurfaceClassifierTest {
}
@Test
public void acceptedSurfaceRolesOverrideGenericOceanAndLandKinds() {
assertEquals(IrisSurfaceKind.RIVER, IrisSurfaceClassifier.classify(
60,
63,
InferredType.SEA,
hydrology(HydrologyFeatureType.SURFACE_POOL, true, false, true)
));
assertEquals(IrisSurfaceKind.RIVER, IrisSurfaceClassifier.classify(
60,
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)
));
public void waterRolesOverrideGenericOceanAndLandKinds() {
for (WaterRole role : WaterRole.values()) {
IrisSurfaceKind expected = switch (role) {
case NONE -> IrisSurfaceKind.OCEAN;
case SHORE, POND_SHORE -> IrisSurfaceKind.RIVER_SHORE;
case BANK, DAM -> IrisSurfaceKind.LAND;
case CHANNEL, POND, MOUTH, CASCADE, WATERFALL, INLET -> IrisSurfaceKind.RIVER;
};
assertEquals(role.name(), expected, IrisSurfaceClassifier.classify(60, 63, InferredType.SEA, role));
}
}
@Test
public void voidClassificationWinsOverAcceptedRiverGeometry() {
assertEquals(IrisSurfaceKind.VOID, IrisSurfaceClassifier.classify(
0,
63,
InferredType.LAND,
hydrology(HydrologyFeatureType.SURFACE_POOL, true, false, true)
));
public void voidClassificationWinsOverWaterRoles() {
for (WaterRole role : WaterRole.values()) {
assertEquals(role.name(), IrisSurfaceKind.VOID, IrisSurfaceClassifier.classify(0, 63, InferredType.LAND, role));
}
}
@Test
public void absentRejectedCandidatesDoNotCreatePublicRiverKinds() {
assertEquals(IrisSurfaceKind.LAND, IrisSurfaceClassifier.classify(
64,
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));
public void columnsWithoutAWaterRoleKeepTheGenericKinds() {
assertEquals(IrisSurfaceKind.LAND, IrisSurfaceClassifier.classify(64, 63, InferredType.LAND, WaterRole.NONE));
assertEquals(IrisSurfaceKind.SHORE, IrisSurfaceClassifier.classify(64, 63, InferredType.SHORE, WaterRole.NONE));
}
}
@@ -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.junit.Test;
import java.io.IOException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertThrows;
import static org.mockito.Mockito.mock;
@@ -42,4 +46,55 @@ public class PendingWorldReplacementAdmissionTest {
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);
}
}
}
+10 -4
View File
@@ -58,6 +58,7 @@ IrisFileOperations irisFileOperations = objects.newInstance(IrisFileOperations)
apply from: file('../../gradle/modded-generation-build-revision.gradle')
apply from: file('../../gradle/volmlib-stamp.gradle')
apply from: file('../../gradle/java21-runtime-exclusions.gradle')
Properties rootProperties = new Properties()
file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) }
@@ -130,6 +131,8 @@ configurations.named('bundle').configure {
exclude(group: 'de.crazydev22.slimjar.helper')
exclude(group: 'de.crazydev22.slimjar')
exclude(group: 'io.github.slimjar')
// The game ships its own LWJGL; without the modules it leaves out, the map's 3D views draw in software.
exclude(group: 'org.lwjgl')
}
configurations.compileClasspath.extendsFrom(configurations.devBundle)
@@ -152,7 +155,7 @@ dependencies {
String modConfiguration = irisModTarget.fabricRemap ? 'modImplementation' : 'implementation'
add(modConfiguration, "net.fabricmc:fabric-loader:${fabricLoaderVersion}")
testImplementation('junit:junit:4.13.2')
testImplementation('org.mockito:mockito-core:5.23.0')
testImplementation(libs.mockito.core)
testImplementation(testFixtures('art.arcane:core'))
testImplementation(libs.spigot) {
transitive = false
@@ -215,7 +218,9 @@ dependencies {
libs.caffeine,
'com.fulcrumgenomics:jlibdeflate:0.2.0',
libs.dom4j,
libs.jaxen
libs.jaxen,
libs.flatlaf,
libs.flatlaf.extras
]
shared.each { Object notation ->
['bundle', 'devBundle'].each { String configuration ->
@@ -235,6 +240,8 @@ tasks.named('compileJava', JavaCompile).configure {
options.release.set((int) irisModTarget.java)
}
apply from: file('../../gradle/mockito-agent.gradle')
tasks.named('test').configure {
}
@@ -322,8 +329,7 @@ tasks.named('shadowJar', ShadowJar).configure {
configurations = [project.configurations.named('bundle').get()]
mergeServiceFiles()
if ((int) irisModTarget.java == 21) {
exclude('art/arcane/volmlib/util/noise/NativeBillowLoader*.class')
exclude('art/arcane/volmlib/util/noise/BillowBatch*.class')
exclude(java21RuntimeExclusions)
}
exclude('META-INF/maven/**')
exclude('META-INF/proguard/**')
+12 -4
View File
@@ -57,6 +57,7 @@ IrisFileOperations irisFileOperations = objects.newInstance(IrisFileOperations)
apply from: file('../../gradle/modded-generation-build-revision.gradle')
apply from: file('../../gradle/volmlib-stamp.gradle')
apply from: file('../../gradle/java21-runtime-exclusions.gradle')
Properties rootProperties = new Properties()
file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) }
@@ -151,12 +152,16 @@ configurations.named('bundle').configure {
exclude(group: 'de.crazydev22.slimjar.helper')
exclude(group: 'de.crazydev22.slimjar')
exclude(group: 'io.github.slimjar')
// The game ships its own LWJGL; without the modules it leaves out, the map's 3D views draw in software.
exclude(group: 'org.lwjgl')
}
configurations.named('runBundle').configure {
exclude(group: 'de.crazydev22.slimjar.helper')
exclude(group: 'de.crazydev22.slimjar')
exclude(group: 'io.github.slimjar')
// The game ships its own LWJGL; without the modules it leaves out, the map's 3D views draw in software.
exclude(group: 'org.lwjgl')
}
configurations.compileClasspath.extendsFrom(configurations.devBundle)
@@ -177,7 +182,7 @@ dependencies {
? minecraft.dependency("net.minecraftforge:forge:${forgeVersion}")
: minecraft.dependency("iris-${buildLane}", "net.minecraftforge:forge:${forgeVersion}"))
testImplementation('junit:junit:4.13.2')
testImplementation('org.mockito:mockito-core:5.23.0')
testImplementation(libs.mockito.core)
testImplementation(testFixtures('art.arcane:core'))
testImplementation(libs.spigot) {
transitive = false
@@ -214,7 +219,9 @@ dependencies {
libs.caffeine,
'com.fulcrumgenomics:jlibdeflate:0.2.0',
libs.dom4j,
libs.jaxen
libs.jaxen,
libs.flatlaf,
libs.flatlaf.extras
]
shared.each { Object notation ->
['bundle', 'devBundle', 'runBundle'].each { String configuration ->
@@ -271,6 +278,8 @@ tasks.named('compileJava', JavaCompile).configure {
options.release.set((int) irisModTarget.java)
}
apply from: file('../../gradle/mockito-agent.gradle')
tasks.named('test').configure {
}
@@ -309,8 +318,7 @@ tasks.named('shadowJar', ShadowJar).configure {
from(sourceSets.main.output)
mergeServiceFiles()
if ((int) irisModTarget.java == 21) {
exclude('art/arcane/volmlib/util/noise/NativeBillowLoader*.class')
exclude('art/arcane/volmlib/util/noise/BillowBatch*.class')
exclude(java21RuntimeExclusions)
}
exclude('META-INF/maven/**')
exclude('META-INF/proguard/**')
@@ -227,6 +227,7 @@ public final class ModdedEngineBootstrap {
failure = runStopStage(failure, "wand service", ModdedWandService::clearAll);
failure = runStopStage(failure, "block break handler", ModdedBlockBreakHandler::clear);
failure = runStopStage(failure, "studio commands", ModdedStudioCommands::clear);
failure = runStopStage(failure, "gui windows", GuiHost::shutdown);
failure = runStopStage(failure, "gui host", ModdedGuiHost::clear);
failure = runStopStage(failure, "services", () -> services().disableAll());
reportStopFailure("stopping", failure);
@@ -25,7 +25,7 @@ import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.structure.placement.IrisStructureLocator;
import art.arcane.iris.structure.nativegen.NativeStructureGenerationPolicy;
import art.arcane.iris.structure.placement.StructureReachability;
import art.arcane.iris.generation.hydrology.HydrologyFeatureQuery;
import art.arcane.iris.generation.hydrology.query.HydrologyFeatureType;
import art.arcane.iris.world.history.GenerationFindCatalog;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.structure.nativegen.IrisNativeStructureDecision;
@@ -61,7 +61,7 @@ final class ModdedCommandSuggestions {
static final SuggestionProvider<NativeCommandSource> OBJECT_KEYS = (CommandContext<NativeCommandSource> context, SuggestionsBuilder builder) -> suggestObjectKeys(context, builder);
static final SuggestionProvider<NativeCommandSource> STRUCTURE_KEYS = (CommandContext<NativeCommandSource> context, SuggestionsBuilder builder) -> suggestStructureKeys(context, builder);
static final SuggestionProvider<NativeCommandSource> POI_TYPES = (CommandContext<NativeCommandSource> context, SuggestionsBuilder builder) -> NativeCommandRegistration.suggest(List.of("buried_treasure"), builder);
static final SuggestionProvider<NativeCommandSource> HYDROLOGY_TYPES = (CommandContext<NativeCommandSource> context, SuggestionsBuilder builder) -> suggestHydrologyTypes(context, builder);
static final SuggestionProvider<NativeCommandSource> HYDROLOGY_TYPES = (CommandContext<NativeCommandSource> context, SuggestionsBuilder builder) -> NativeCommandRegistration.suggest(HydrologyFeatureType.keys(), builder);
static final SuggestionProvider<NativeCommandSource> PACK_NAMES = (CommandContext<NativeCommandSource> context, SuggestionsBuilder builder) -> suggestPackNames(context, builder);
static final SuggestionProvider<NativeCommandSource> DIMENSION_NAMES = (CommandContext<NativeCommandSource> context, SuggestionsBuilder builder) -> suggestDimensionNames(context, builder);
@@ -87,24 +87,6 @@ final class ModdedCommandSuggestions {
return builder.buildFuture();
}
private static CompletableFuture<Suggestions> suggestHydrologyTypes(
CommandContext<NativeCommandSource> context,
SuggestionsBuilder builder
) {
ModdedCommandFeedback.tab(context.getSource());
try {
Engine engine = IrisModdedCommands.engineFor(context.getSource().world());
if (engine != null && engine.getComplex().getHydrologyRuntime() != null) {
return NativeCommandRegistration.suggest(
engine.getComplex().getHydrologyRuntime().featureQueryKeys(), builder);
}
} catch (Throwable error) {
warnTabFailure("hydrology types", context.getSource(), error);
}
return NativeCommandRegistration.suggest(
HydrologyFeatureQuery.suggestions(List.of()), builder);
}
static Set<String> reachableBiomeKeys(Engine engine) {
return reachableBiomeKeys(GenerationFindCatalog.biomes(engine));
}
@@ -39,8 +39,7 @@ import art.arcane.iris.structure.placement.StructureReachability;
import art.arcane.iris.generation.runtime.WrongEngineBroException;
import art.arcane.iris.world.history.GenerationSemanticQueries;
import art.arcane.iris.world.history.GenerationFindCatalog;
import art.arcane.iris.generation.hydrology.HydrologyFeatureQuery;
import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntime;
import art.arcane.iris.generation.hydrology.query.HydrologyFeatureType;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.structure.nativegen.IrisNativeStructureDecision;
import art.arcane.iris.generation.terrain.IrisRegion;
@@ -143,39 +142,31 @@ final class ModdedLocateCommands {
IrisModdedCommands.fail(source, "This dimension is not generated by Iris.");
return 0;
}
IrisHydrologyRuntime runtime = engine.getComplex().getHydrologyRuntime();
HydrologyFeatureQuery query;
HydrologyFeatureType featureType;
try {
query = HydrologyFeatureQuery.parse(type);
featureType = HydrologyFeatureType.parse(type);
} catch (IllegalArgumentException error) {
IrisModdedCommands.fail(source, error.getMessage());
return 0;
}
String label = featureType.key().replace('_', ' ');
int originX = player.blockX();
int originZ = player.blockZ();
int requestedDistance = runtime == null
? 8192
: Math.min(8192, runtime.settings().routing().tileSize() * 15);
int maximumDistance = runtime == null
? requestedDistance
: runtime.maximumFeatureSearchDistance(originX, originZ, requestedDistance);
int maximumDistance = GenerationSemanticQueries.RIVER_SEARCH_DISTANCE;
NativeModdedServer server = source.server();
IrisModdedCommands.ok(source, "Searching accepted hydrology plans for " + type + "...");
IrisModdedCommands.ok(source, GenerationSemanticQueries.riverSearchMessage(featureType, maximumDistance));
MultiBurst.burst.completeValueAsync(() -> GenerationSemanticQueries.nearestRiver(
engine, query, originX, originZ, maximumDistance,
(int visited) -> server.execute(() -> IrisModdedCommands.ok(source,
"Searched " + visited + " hydrology tiles for " + type + "..."))).orElse(null))
engine, featureType, originX, originZ, maximumDistance).orElse(null))
.whenComplete((GenerationSemanticQueries.RiverResult feature, Throwable error) -> server.execute(() -> {
if (error != null) {
ModdedIrisLog.error("Hydrology locate failed for {}", type, error);
IrisModdedCommands.fail(source,
"Could not locate " + type + " hydrology: " + error.getClass().getSimpleName());
Throwable cause = error instanceof CompletionException && error.getCause() != null ? error.getCause() : error;
ModdedIrisLog.error("Hydrology locate failed for {}", featureType.key(), cause);
IrisModdedCommands.fail(source, "Could not search for " + label + ": " + cause.getClass().getSimpleName()
+ (cause.getMessage() == null ? "" : " - " + cause.getMessage()));
return;
}
if (feature == null) {
IrisModdedCommands.fail(source,
"No accepted " + type + " hydrology feature found within "
+ maximumDistance + " blocks.");
IrisModdedCommands.fail(source, GenerationSemanticQueries.riverNotFoundMessage(featureType, maximumDistance));
return;
}
teleportToStructure(
@@ -185,7 +176,7 @@ final class ModdedLocateCommands {
feature.x(),
feature.y() + 2,
feature.z(),
type + " hydrology feature"
label
);
}));
return 1;
@@ -23,6 +23,7 @@ import art.arcane.volmlib.nativelib.terrain.NativePregenRuntime;
import art.arcane.volmlib.nativelib.terrain.NativePregenRuntime.ChunkLoad;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.world.pregen.MantleHeapPressure;
import art.arcane.iris.world.pregen.PregenHydrology;
import art.arcane.iris.world.pregen.PregenListener;
import art.arcane.iris.world.pregen.PregenMantleBackpressure;
import art.arcane.iris.world.pregen.PregeneratorMethod;
@@ -30,6 +31,7 @@ import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.modded.ModdedGenPool;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
@@ -71,6 +73,8 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
private final PregenMantleBackpressure backpressure;
private final PauseWhenEmptyGuard pauseGuard;
private final LongSupplier clock;
private final AtomicBoolean closing = new AtomicBoolean();
private final PregenHydrology hydrology;
public ModdedPregenMethod(Configuration configuration) {
this(configuration, System::nanoTime);
@@ -80,6 +84,7 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
this.clock = Objects.requireNonNull(clock);
runtime = configuration.runtime();
engine = configuration.engine();
hydrology = new PregenHydrology(() -> engine);
sync = configuration.sync();
pauseGuard = new PauseWhenEmptyGuard(runtime);
IrisSettings.IrisSettingsPregen pregen = IrisSettings.get().getPregen();
@@ -121,6 +126,8 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
@Override
public void close() {
closing.set(true);
hydrology.close();
try {
awaitPendingChunks();
ModdedIrisLog.info("Iris modded pregen closing: dim={} completed={} pending={} peakInFlight={} finalLimit={}",
@@ -341,6 +348,13 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
return false;
}
@Override
public void onRegionsAhead(List<long[]> regions) {
if (!closing.get()) {
hydrology.prepare(regions);
}
}
@Override
public String getMethod(int x, int z) {
return "Modded";
@@ -23,10 +23,9 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldView;
import art.arcane.iris.modded.ModdedIrisLog;
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.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.StructurePackageClosure;
import art.arcane.iris.pack.ImageMapPackageClosure;
@@ -38,6 +37,7 @@ import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.biome.IrisBiomeGeneratorLink;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.world.entity.IrisEntitySpawn;
import art.arcane.iris.world.IrisWorld;
import art.arcane.iris.generation.noise.IrisGenerator;
import art.arcane.iris.pack.PackExportClosure;
import art.arcane.iris.world.entity.IrisEntity;
@@ -247,8 +247,9 @@ public final class ModdedStudioCommands {
NativeProtocolPlayer player = source.player();
ModdedGuiHost.bindContext(worldView(source.server(), level), engine, player == null ? null : player.id());
}
if (generatorKey == null || generatorKey.isBlank()) {
NoiseExplorerGUI.launch();
Engine mapEngine = engine != null ? engine : GuiHost.get().findActiveEngine();
if ((generatorKey == null || generatorKey.isBlank()) && mapEngine != null) {
MapWindow.openNoise(mapStart(mapEngine, source.player()), new MapWindow.NoiseSelection(null, null, seed));
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_OPENING_NOISE_EXPLORER_ON_SERVER_DISPLAY));
return 1;
}
@@ -262,7 +263,7 @@ public final class ModdedStudioCommands {
return 0;
}
String selectedGeneratorKey = generatorKey.trim();
NoiseExplorerGUI.launchGeneratorKey(selectedGeneratorKey, generator, seed);
MapWindow.openNoise(mapStart(engine, source.player()), new MapWindow.NoiseSelection(selectedGeneratorKey, generator, seed));
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_OPENING_NOISE_EXPLORER_GENERATOR_SEED, MessageArgument.untrusted("value", generatorKey.trim()), MessageArgument.untrusted("seed", seed)));
return 1;
}
@@ -280,11 +281,20 @@ public final class ModdedStudioCommands {
}
NativeProtocolPlayer player = source.player();
ModdedGuiHost.bindContext(worldView(source.server(), level), engine, player == null ? null : player.id());
VisionGUI.launch(engine, player == null ? null : player.id());
MapWindow.openMap(mapStart(engine, player));
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_OPENING_VISION_MAP_ON_SERVER_DISPLAY, MessageArgument.untrusted("value", level.name())));
return 1;
}
// Modded worlds have no Bukkit world id; the map finds the engine again through the bound GUI context.
private static PaletteExplorerStart mapStart(Engine engine, NativeProtocolPlayer player) {
IrisWorld world = engine.getWorld();
NativeWorld level = world == null ? null : world.platformWorld();
boolean inWorld = player != null && player.connected() && level != null && player.inWorld(level);
return new PaletteExplorerStart(engine, null, player == null ? null : player.id(), null,
inWorld ? player.x() : 0D, inWorld ? player.z() : 0D);
}
private static int imageMap(NativeCommandSource source) {
NativeWorld level = source.world();
Engine engine = IrisModdedCommands.engineFor(level);
@@ -294,13 +304,13 @@ public final class ModdedStudioCommands {
));
return 0;
}
if (!ImageMapStudioGUI.isAvailable()) {
if (!GuiHost.isAvailable() || !IrisSettings.get().getGui().isUseServerLaunchedGuis()) {
IrisModdedCommands.fail(source, guiUnavailableMessage());
return 0;
}
NativeProtocolPlayer player = source.player();
ModdedGuiHost.bindContext(worldView(source.server(), level), engine, player == null ? null : player.id());
ImageMapStudioGUI.launch(engine);
MapWindow.openImageMaps(mapStart(engine, player));
IrisModdedCommands.ok(source, IrisLanguage.plain(
ModdedCommandMessages.MODDED_STUDIO_COMMANDS_OPENING_IMAGE_MAP_STUDIO_ON_SERVER_DISPLAY,
MessageArgument.untrusted("value", level.name())
@@ -18,21 +18,22 @@
package art.arcane.iris.modded.command;
import art.arcane.iris.studio.view.GuiHost;
import art.arcane.volmlib.nativelib.view.WorldMarker;
import art.arcane.iris.studio.view.GuiMarker;
import art.arcane.iris.studio.view.GuiOverlay;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.studio.view.OpenerPosition;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.studio.render.RenderType;
import art.arcane.iris.modded.ModdedIrisLog;
import art.arcane.iris.modded.ModdedTeleportDeadline;
import art.arcane.iris.world.IrisWorld;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.volmlib.nativelib.view.WorldView;
import java.awt.Desktop;
import java.io.File;
import java.util.List;
import java.util.Optional;
import art.arcane.volmlib.nativelib.view.WorldView;
import java.util.UUID;
import java.util.concurrent.RejectedExecutionException;
import java.util.function.Consumer;
public final class ModdedVisionOverlay implements GuiOverlay {
@@ -46,14 +47,33 @@ public final class ModdedVisionOverlay implements GuiOverlay {
opener = context.opener();
}
// WorldView doesn't report which way a player faces, so their markers all face south.
@Override
public List<WorldMarker> players() {
return world.players();
public List<GuiMarker> players() {
return world.players().stream()
.map(player -> GuiMarker.player(player.label(), player.worldX(), player.worldZ(), 0F))
.toList();
}
@Override
public void requestEntities(Consumer<List<WorldMarker>> sink) {
world.requestEntities(sink);
public void requestEntities(Consumer<List<GuiMarker>> sink) {
world.requestEntities(entities -> sink.accept(entities.stream()
.map(entity -> GuiMarker.entity(entity.label(), entity.worldX(), entity.worldY(), entity.worldZ(),
entity.health(), entity.maxHealth()))
.toList()));
}
@Override
public void requestOpenerPosition(Consumer<OpenerPosition> sink) {
if (opener == null) {
sink.accept(OpenerPosition.OFFLINE);
return;
}
try {
world.execute(() -> sink.accept(openerPosition()));
} catch (RejectedExecutionException failure) {
sink.accept(OpenerPosition.OFFLINE);
}
}
@Override
@@ -80,23 +100,25 @@ public final class ModdedVisionOverlay implements GuiOverlay {
});
}
@Override
public String openInEditor(double worldX, double worldZ, RenderType type) {
if (!GuiHost.isAvailable() || !Desktop.isDesktopSupported()) {
return null;
private OpenerPosition openerPosition() {
IrisWorld target = engine.getWorld();
if (target == null || engine.isClosing() || engine.isClosed()) {
return OpenerPosition.OFFLINE;
}
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();
NativeWorld level = target.platformWorld();
if (level == null) {
return OpenerPosition.OFFLINE;
}
NativeProtocolPlayer player = NativeModdedServer.forWorld(level).player(opener);
if (player == null || !player.connected()) {
return OpenerPosition.OFFLINE;
}
if (!player.inWorld(level)) {
return OpenerPosition.IN_OTHER_WORLD;
}
return OpenerPosition.inWorld(player.x(), player.y(), player.z());
}
public record Context(WorldView world, Engine engine, UUID opener) {
}
@@ -0,0 +1,149 @@
package art.arcane.iris.modded.command;
import art.arcane.iris.generation.hydrology.engine.HydrologyRuntime;
import art.arcane.iris.generation.hydrology.query.HydrologyFeatureQuery;
import art.arcane.iris.generation.hydrology.query.HydrologyFeatureRef;
import art.arcane.iris.generation.hydrology.query.HydrologyFeatureType;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.world.history.GenerationSemanticQueries;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeCommandSource;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeStructureQueries;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.MockedConstruction;
import org.mockito.MockedStatic;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import static org.junit.Assert.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyDouble;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockConstruction;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class ModdedLocateRiverTest {
private static final int DISTANCE = GenerationSemanticQueries.RIVER_SEARCH_DISTANCE;
@Test
public void unknownTypeFailsBeforeSearching() {
Fixture fixture = new Fixture();
try (MockedStatic<IrisModdedCommands> commands = fixture.commands()) {
assertEquals(0, ModdedLocateCommands.gotoRiver(fixture.source, "ocean_trench"));
commands.verify(() -> IrisModdedCommands.fail(eq(fixture.source), anyString()));
commands.verify(() -> IrisModdedCommands.ok(eq(fixture.source), anyString()), never());
verify(fixture.server, never()).execute(any());
verify(fixture.runtime, never()).nearestFeature(anyInt(), anyInt(), any(), anyInt(), any());
}
}
@Test
public void missingFeatureReportsNotFound() throws Exception {
Fixture fixture = new Fixture();
when(fixture.runtime.nearestFeature(anyInt(), anyInt(), any(), anyInt(), any())).thenReturn(Optional.empty());
try (MockedStatic<IrisModdedCommands> commands = fixture.commands()) {
assertEquals(1, ModdedLocateCommands.gotoRiver(fixture.source, "river"));
fixture.finishOnServerThread();
commands.verify(() -> IrisModdedCommands.fail(fixture.source, "No river found within " + DISTANCE + " blocks."));
verify(fixture.player, never()).teleport(any(), anyDouble(), anyDouble(), anyDouble());
}
}
@Test
public void plannerFailureIsReportedAsAnErrorNotAsNotFound() throws Exception {
Fixture fixture = new Fixture();
when(fixture.runtime.nearestFeature(anyInt(), anyInt(), any(), anyInt(), any()))
.thenThrow(new IllegalStateException("window planner exploded"));
try (MockedStatic<IrisModdedCommands> commands = fixture.commands()) {
assertEquals(1, ModdedLocateCommands.gotoRiver(fixture.source, "major_river"));
fixture.finishOnServerThread();
commands.verify(() -> IrisModdedCommands.fail(fixture.source,
"Could not search for major river: IllegalStateException - window planner exploded"));
commands.verify(() -> IrisModdedCommands.fail(eq(fixture.source), eq("No major river found within " + DISTANCE + " blocks.")),
never());
verify(fixture.player, never()).teleport(any(), anyDouble(), anyDouble(), anyDouble());
}
}
@Test
public void foundFeatureTeleportsAboveItFromThePlayersPosition() throws Exception {
Fixture fixture = new Fixture();
when(fixture.runtime.nearestFeature(anyInt(), anyInt(), any(), anyInt(), any()))
.thenReturn(Optional.of(new HydrologyFeatureRef(9L, HydrologyFeatureType.WATERFALL, 400, 130, -250)));
when(fixture.player.teleport(fixture.world, 400.5D, 68D, -249.5D)).thenReturn(true);
try (MockedStatic<IrisModdedCommands> commands = fixture.commands();
MockedConstruction<NativeStructureQueries> queries = mockConstruction(NativeStructureQueries.class)) {
assertEquals(1, ModdedLocateCommands.gotoRiver(fixture.source, "waterfall"));
fixture.finishOnServerThread();
ArgumentCaptor<HydrologyFeatureQuery> query = ArgumentCaptor.forClass(HydrologyFeatureQuery.class);
verify(fixture.runtime).nearestFeature(eq(120), eq(-75), query.capture(), eq(DISTANCE), any());
assertEquals(HydrologyFeatureQuery.of(HydrologyFeatureType.WATERFALL), query.getValue());
verify(queries.constructed().getFirst()).loadChunk(400 >> 4, -250 >> 4);
verify(fixture.player).teleport(fixture.world, 400.5D, 68D, -249.5D);
commands.verify(() -> IrisModdedCommands.ok(eq(fixture.source), anyString()), times(2));
commands.verify(() -> IrisModdedCommands.fail(eq(fixture.source), anyString()), never());
}
}
private static final class Fixture {
private final NativeCommandSource source = mock(NativeCommandSource.class);
private final NativeWorld world = mock(NativeWorld.class);
private final NativeProtocolPlayer player = mock(NativeProtocolPlayer.class);
private final NativeModdedServer server = mock(NativeModdedServer.class);
private final Engine engine = mock(Engine.class);
private final HydrologyRuntime runtime = mock(HydrologyRuntime.class);
private final CompletableFuture<Runnable> completion = new CompletableFuture<>();
private Fixture() {
IrisComplex complex = mock(IrisComplex.class);
IrisDimension dimension = mock(IrisDimension.class);
when(source.player()).thenReturn(player);
when(source.world()).thenReturn(world);
when(source.server()).thenReturn(server);
when(player.blockX()).thenReturn(120);
when(player.blockZ()).thenReturn(-75);
when(player.connected()).thenReturn(true);
when(player.inWorld(world)).thenReturn(true);
when(world.minHeight()).thenReturn(-64);
when(world.maxHeight()).thenReturn(320);
when(engine.getComplex()).thenReturn(complex);
when(engine.getDimension()).thenReturn(dimension);
when(dimension.getMinHeight()).thenReturn(-64);
when(complex.getHydrologyRuntime()).thenReturn(runtime);
when(runtime.enabled()).thenReturn(true);
doAnswer(invocation -> {
completion.complete(invocation.getArgument(0));
return null;
}).when(server).execute(any());
}
private MockedStatic<IrisModdedCommands> commands() {
MockedStatic<IrisModdedCommands> commands = mockStatic(IrisModdedCommands.class);
commands.when(() -> IrisModdedCommands.engineFor(world)).thenReturn(engine);
return commands;
}
private void finishOnServerThread() throws Exception {
completion.get(10L, TimeUnit.SECONDS).run();
}
}
}
@@ -1,6 +1,7 @@
package art.arcane.iris.modded.command;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.generation.hydrology.engine.HydrologyPregeneration;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.modded.ModdedGenPool;
import art.arcane.iris.world.pregen.MantleHeapPressure;
@@ -11,8 +12,10 @@ import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import org.mockito.MockedStatic;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
@@ -31,9 +34,11 @@ import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
@@ -89,6 +94,25 @@ public class ModdedPregenMethodLifecycleTest {
verify(runtime, never()).execute(any());
}
@Test
public void closeWithdrawsQueuedHydrologyPrefetchAndRefusesLaterAnnouncements() {
HydrologyPregeneration epoch = mock(HydrologyPregeneration.class);
when(engine.getComplex().getHydrologyRuntime().openPregeneration()).thenReturn(epoch);
when(runtime.isServerThread()).thenReturn(true);
assertTrue(method.deferFinalSaveToServerThread());
List<long[]> ahead = List.of(new long[]{0L, 0L}, new long[]{1L, 0L});
method.onRegionsAhead(ahead);
method.close();
method.onRegionsAhead(ahead);
InOrder order = inOrder(epoch);
order.verify(epoch).prepare(ahead);
order.verify(epoch).close();
verify(epoch, times(1)).prepare(anyList());
method.completeDeferredFinalSave();
}
@Test
public void asynchronousCallerCannotClaimTheShutdownSave() {
assertFalse(method.deferFinalSaveToServerThread());
@@ -1,25 +1,98 @@
package art.arcane.iris.modded.command;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.studio.view.OpenerPosition;
import art.arcane.iris.world.IrisWorld;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.volmlib.nativelib.view.WorldView;
import org.junit.Test;
import org.mockito.MockedStatic;
import org.mockito.invocation.InvocationOnMock;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
public class ModdedVisionOverlayTest {
@Test
public void rejectedServerTaskCompletesThePositionRequestAsOffline() {
WorldView world = mock(WorldView.class);
doThrow(new RejectedExecutionException("Server stopped")).when(world).execute(any(Runnable.class));
ModdedVisionOverlay overlay = new ModdedVisionOverlay(new ModdedVisionOverlay.Context(world,
mock(Engine.class), UUID.randomUUID()));
AtomicReference<OpenerPosition> position = new AtomicReference<>();
overlay.requestOpenerPosition(position::set);
assertEquals(OpenerPosition.OFFLINE, position.get());
}
@Test
public void openerPositionRunsOnTheServerAndTracksDisconnectAndWorldChanges() {
WorldView world = mock(WorldView.class);
AtomicReference<Runnable> scheduled = new AtomicReference<>();
doAnswer(invocation -> {
scheduled.set(invocation.getArgument(0));
return null;
}).when(world).execute(any(Runnable.class));
Engine engine = mock(Engine.class);
IrisWorld irisWorld = mock(IrisWorld.class);
NativeWorld level = mock(NativeWorld.class);
NativeModdedServer server = mock(NativeModdedServer.class);
NativeProtocolPlayer player = mock(NativeProtocolPlayer.class);
UUID opener = UUID.randomUUID();
when(engine.getWorld()).thenReturn(irisWorld);
when(irisWorld.platformWorld()).thenReturn(level);
when(server.player(opener)).thenReturn(player);
when(player.connected()).thenReturn(true);
when(player.inWorld(level)).thenReturn(true);
when(player.x()).thenReturn(21.5D);
when(player.y()).thenReturn(85.25D);
when(player.z()).thenReturn(-39.5D);
ModdedVisionOverlay overlay = new ModdedVisionOverlay(new ModdedVisionOverlay.Context(world, engine, opener));
AtomicReference<OpenerPosition> position = new AtomicReference<>();
try (MockedStatic<NativeModdedServer> nativeServer = mockStatic(NativeModdedServer.class)) {
nativeServer.when(() -> NativeModdedServer.forWorld(level)).thenReturn(server);
overlay.requestOpenerPosition(position::set);
assertNull("position reads must wait for the server task", position.get());
scheduled.get().run();
assertEquals(OpenerPosition.inWorld(21.5D, 85.25D, -39.5D), position.get());
overlay.requestOpenerPosition(position::set);
when(player.connected()).thenReturn(false);
scheduled.get().run();
assertEquals(OpenerPosition.OFFLINE, position.get());
when(player.connected()).thenReturn(true);
when(player.inWorld(level)).thenReturn(false);
overlay.requestOpenerPosition(position::set);
scheduled.get().run();
assertEquals(OpenerPosition.IN_OTHER_WORLD, position.get());
when(player.inWorld(level)).thenReturn(true);
overlay.requestOpenerPosition(position::set);
scheduled.get().run();
assertEquals(OpenerPosition.inWorld(21.5D, 85.25D, -39.5D), position.get());
}
}
/**
* A Vision click can land anywhere in the world, so the destination chunk is usually cold and gets the same
* bounded deadline as /iris tp.
+10 -4
View File
@@ -56,6 +56,7 @@ IrisFileOperations irisFileOperations = objects.newInstance(IrisFileOperations)
apply from: file('../../gradle/modded-generation-build-revision.gradle')
apply from: file('../../gradle/volmlib-stamp.gradle')
apply from: file('../../gradle/java21-runtime-exclusions.gradle')
Properties rootProperties = new Properties()
file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) }
@@ -131,6 +132,8 @@ configurations.named('bundle').configure {
exclude(group: 'de.crazydev22.slimjar.helper')
exclude(group: 'de.crazydev22.slimjar')
exclude(group: 'io.github.slimjar')
// The game ships its own LWJGL; without the modules it leaves out, the map's 3D views draw in software.
exclude(group: 'org.lwjgl')
}
configurations.compileClasspath.extendsFrom(configurations.devBundle)
@@ -140,7 +143,7 @@ configurations.testRuntimeClasspath.extendsFrom(configurations.runtimeClasspath,
dependencies {
testImplementation('junit:junit:4.13.2')
testImplementation('org.mockito:mockito-core:5.23.0')
testImplementation(libs.mockito.core)
testImplementation(testFixtures('art.arcane:core'))
testImplementation(libs.spigot) {
transitive = false
@@ -172,7 +175,9 @@ dependencies {
libs.caffeine,
'com.fulcrumgenomics:jlibdeflate:0.2.0',
libs.dom4j,
libs.jaxen
libs.jaxen,
libs.flatlaf,
libs.flatlaf.extras
]
add('bundle', libs.asm)
add('devBundle', 'org.ow2.asm:asm') {
@@ -260,6 +265,8 @@ tasks.named('compileJava', JavaCompile).configure {
options.release.set((int) irisModTarget.java)
}
apply from: file('../../gradle/mockito-agent.gradle')
tasks.named('test').configure {
useJUnitPlatform()
classpath = classpath.minus(developmentModLibraries).plus(sourceSets.modLibraries.output)
@@ -295,8 +302,7 @@ tasks.named('shadowJar', ShadowJar).configure {
from(sourceSets.main.output)
mergeServiceFiles()
if ((int) irisModTarget.java == 21) {
exclude('art/arcane/volmlib/util/noise/NativeBillowLoader*.class')
exclude('art/arcane/volmlib/util/noise/BillowBatch*.class')
exclude(java21RuntimeExclusions)
}
exclude('META-INF/maven/**')
exclude('META-INF/proguard/**')
+22 -2
View File
@@ -127,7 +127,7 @@ nmsBindings.each { String key ->
compileOnly(rootProject.libs.annotations)
compileOnly(rootProject.libs.byteBuddy.core)
testImplementation('junit:junit:4.13.2')
testImplementation('org.mockito:mockito-core:5.23.0')
testImplementation(rootProject.libs.mockito.core)
if (key == 'v26_3_R1') {
testImplementation(project(':probe'))
}
@@ -230,6 +230,7 @@ List<Object> moddedSharedInputs = []
'gradle/libs.versions.toml',
'gradle/volmlib-resolution.settings.gradle',
'gradle/volmlib-stamp.gradle',
'gradle/java21-runtime-exclusions.gradle',
'gradle/generation-build-revision.gradle',
'gradle/modded-generation-build-revision.gradle',
'gradle/native-minecraft-sources.gradle',
@@ -245,7 +246,8 @@ List<Object> moddedSharedInputs = []
].each { String path -> moddedSharedInputs.add(layout.projectDirectory.file(path).asFile) }
if (localVolmLibCheckout != null) {
moddedSharedInputs.add(fileTree(localVolmLibCheckout) {
include('shared/src/main/**', 'native-api/src/main/**', 'native-terrain-api/src/main/**',
include('shared/src/main/**', 'shared/src/nativeNoise/**', 'shared/native/noise/prebuilt/**',
'native-api/src/main/**', 'native-terrain-api/src/main/**',
'native/**/src/main/**',
'native/minecraft-common/src/**', 'packaging/src/main/**',
'**/build.gradle', 'settings.gradle', 'gradle.properties', 'gradle/libs.versions.toml')
@@ -437,6 +439,21 @@ if (selectedModdedBuilds.isEmpty()) {
throw new GradleException("Minecraft ${minecraftVersion} is not a mod target in gradle/mod-targets.json")
}
tasks.register('prepareRelease', Sync) {
group = 'distribution'
description = 'Builds, verifies and stages one selected mod loader jar for publication.'
Provider<IrisModdedBuild> releaseBuild = providers.provider {
String releaseLoader = providers.gradleProperty('releaseLoader').orNull
if (!moddedLoaderLabels.containsKey(releaseLoader)) {
throw new GradleException('prepareRelease requires -PreleaseLoader=fabric, forge or neoforge')
}
return selectedModdedBuilds[releaseLoader].get()
}
dependsOn(releaseBuild)
from(releaseBuild.flatMap { IrisModdedBuild task -> task.artifactFile })
into(layout.buildDirectory.dir('release'))
}
tasks.register('buildFabric') {
group = 'iris'
description = "Builds and verifies the Fabric ${minecraftVersion} jar."
@@ -569,11 +586,14 @@ allprojects {
layout.buildDirectory.set(layout.projectDirectory.dir("build/${runtimeOutputVariant}"))
}
apply from: rootProject.file('gradle/mockito-agent.gradle')
tasks.withType(Test).configureEach {
int moduleForks = project.path.startsWith(':adapters:bukkit:') ? Math.min(2, testForks)
: project.path == ':probe' ? Math.min(4, testForks) : testForks
maxParallelForks = Math.min(gradle.startParameter.maxWorkerCount, moduleForks)
jvmArgs('-XX:ParallelGCThreads=2', '-XX:ConcGCThreads=1', '-XX:CICompilerCount=2')
timeout = java.time.Duration.ofMinutes(60)
}
java {
+16 -5
View File
@@ -39,6 +39,7 @@ plugins {
}
apply from: rootProject.file('gradle/runtime-relocations.gradle')
apply from: rootProject.file('gradle/java21-runtime-exclusions.gradle')
def lib = 'art.arcane.iris.util'
@@ -135,6 +136,13 @@ dependencies {
implementation('com.moandjiezana.toml:toml4j:0.7.2') {
transitive = false
}
// Shaded: the runtime downloader can neither fetch the natives classifiers nor relocate lwjgl's own jar.
implementation(libs.lwjgl)
implementation(libs.lwjgl.opengl)
implementation(libs.lwjgl.egl)
implementation(libs.lwjgl.jawt)
runtimeOnly(variantOf(libs.lwjgl) { classifier('natives-linux') })
runtimeOnly(variantOf(libs.lwjgl.opengl) { classifier('natives-linux') })
// Dynamically Loaded
slim(libs.gson)
slim(libs.lru)
@@ -160,13 +168,15 @@ dependencies {
slim(libs.byteBuddy.agent)
slim(libs.dom4j)
slim(libs.jaxen)
slim(libs.flatlaf)
slim(libs.flatlaf.extras)
testFixturesImplementation('junit:junit:4.13.2')
testFixturesImplementation('org.mockito:mockito-core:5.23.0')
testFixturesImplementation(libs.mockito.core)
testFixturesCompileOnly(libs.paper.api)
testImplementation('junit:junit:4.13.2')
testImplementation('org.mockito:mockito-core:5.23.0')
testImplementation(libs.mockito.core)
testImplementation(libs.paper.api)
testImplementation(libs.log4j.core)
testRuntimeOnly(libs.paper.api)
@@ -176,8 +186,10 @@ dependencies {
}
tasks.named('test').configure {
maxHeapSize = '1g'
timeout = java.time.Duration.ofMinutes(60)
maxHeapSize = '2g'
jvmArgs('-XX:+ExitOnOutOfMemoryError')
systemProperty('iris.generation.counters', 'true')
}
java {
@@ -474,8 +486,7 @@ tasks.named('shadowJar', ShadowJar).configure {
dependsOn(embeddedAgentJar)
mergeServiceFiles()
if (providers.gradleProperty('irisRuntimeJava').getOrElse(rootProject.irisModTarget.java.toString()) == '21') {
exclude('art/arcane/volmlib/util/noise/NativeBillowLoader*.class')
exclude('art/arcane/volmlib/util/noise/BillowBatch*.class')
exclude(java21RuntimeExclusions)
}
// minimize() stays off. Caffeine reaches its 520 generated cache and node classes through
// MethodHandles.Lookup.findClass on a computed name, and the VolmLib command, GUI and hotload
+1 -1
View File
@@ -16,8 +16,8 @@ art/arcane/iris/generation/block/IrisCustomData.java
art/arcane/iris/generation/block/LegacyTileData.java
art/arcane/iris/generation/block/TileData.java
art/arcane/iris/generation/chunk/ChunkBiomeHunkView.java
art/arcane/iris/generation/chunk/ChunkDataHunkHolder.java
art/arcane/iris/generation/chunk/ChunkDataHunkView.java
art/arcane/iris/generation/chunk/ChunkDataOutput.java
art/arcane/iris/generation/chunk/ChunkHunkView.java
art/arcane/iris/generation/chunk/LinkedTerrainChunk.java
art/arcane/iris/generation/chunk/TerrainChunk.java
@@ -1,25 +1,14 @@
package art.arcane.iris.command.specialhandlers;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.hydrology.HydrologyFeatureQuery;
import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntime;
import art.arcane.iris.command.DirectorParameterHandler;
import art.arcane.iris.generation.hydrology.query.HydrologyFeatureType;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.director.exceptions.DirectorParsingException;
import java.util.List;
public final class HydrologyTypeHandler implements DirectorParameterHandler<String> {
@Override
public KList<String> getPossibilities() {
Engine activeEngine = engine();
if (activeEngine == null) {
return new KList<>(HydrologyFeatureQuery.suggestions(List.of()));
}
IrisHydrologyRuntime runtime = activeEngine.getComplex().getHydrologyRuntime();
return new KList<>(runtime == null
? HydrologyFeatureQuery.suggestions(List.of())
: runtime.featureQueryKeys());
return new KList<>(HydrologyFeatureType.keys());
}
@Override
@@ -29,16 +18,11 @@ public final class HydrologyTypeHandler implements DirectorParameterHandler<Stri
@Override
public String parse(String input, boolean force) throws DirectorParsingException {
String normalized = input == null ? "" : input.trim();
if (normalized.isEmpty()) {
throw new DirectorParsingException("Hydrology feature type must not be blank.");
try {
return HydrologyFeatureType.parse(input).key();
} catch (IllegalArgumentException error) {
throw new DirectorParsingException(error.getMessage());
}
for (String possibility : getPossibilities(normalized)) {
if (possibility.equalsIgnoreCase(normalized)) {
return possibility;
}
}
return normalized;
}
@Override
@@ -48,6 +32,6 @@ public final class HydrologyTypeHandler implements DirectorParameterHandler<Stri
@Override
public String getRandomDefault() {
return "surface";
return HydrologyFeatureType.RIVER.key();
}
}
@@ -373,6 +373,7 @@ public class IrisSettings {
public boolean useServerLaunchedGuis = true;
public boolean maximumPregenGuiFPS = false;
public boolean colorMode = true;
public String theme = "auto";
}
@Data
@@ -0,0 +1,26 @@
package art.arcane.iris.generation.bake;
public enum BakePlane {
REGION(false),
BASE_BIOME(false),
BASE_HEIGHT(true),
NATURAL_HEIGHT(true),
NATURAL_TRUE_BIOME(false),
HEIGHT(true),
TRUE_BIOME(false),
ROCK(false),
FLUID(false),
CAVE_BIOME(false);
static final BakePlane[] PLANES = values();
private final boolean doubles;
BakePlane(boolean doubles) {
this.doubles = doubles;
}
public boolean doubles() {
return doubles;
}
}
@@ -0,0 +1,152 @@
package art.arcane.iris.generation.bake;
import art.arcane.iris.generation.cache.ConcurrentClockCache;
import art.arcane.iris.generation.stream.ProvisionalSampling;
import java.util.Arrays;
import java.util.Objects;
public final class ChunkBakeCache {
public static final int MINIMUM_CAPACITY = 2_048;
static final int MAXIMUM_CAPACITY = 32_768;
static final long BYTES_PER_ENTRY = 13L * 1_024L;
static final int HEAP_SHARE = 32;
private static final int RECENT_SLOTS = 16;
private static final ThreadLocal<int[]> SPARSE = ThreadLocal.withInitial(() -> new int[1]);
private final PlaneSource source;
private final ConcurrentClockCache<ChunkPlanes> entries;
private final ThreadLocal<Recent> recent = ThreadLocal.withInitial(Recent::new);
private volatile int generation;
public ChunkBakeCache(PlaneSource source, int capacityChunks) {
this.source = Objects.requireNonNull(source, "Bake plane source");
this.entries = new ConcurrentClockCache<>(Math.max(16, capacityChunks));
}
public static int capacityFor(long maxMemory) {
long entries = Math.max(0L, maxMemory) / HEAP_SHARE / BYTES_PER_ENTRY;
return (int) Math.max(MINIMUM_CAPACITY, Math.min(MAXIMUM_CAPACITY, entries));
}
public int capacity() {
return entries.capacity();
}
public synchronized void clear() {
generation++;
entries.forEach(ChunkPlanes::release);
entries.clear();
}
public long[] census() {
long[] counts = new long[BakePlane.PLANES.length + 1];
entries.forEach(planes -> {
counts[BakePlane.PLANES.length]++;
for (BakePlane plane : BakePlane.PLANES) {
if (planes.planeIfPresent(plane) != null) {
counts[plane.ordinal()]++;
}
}
});
return counts;
}
public ChunkPlanes.Plane planeIfPresent(BakePlane plane, int chunkX, int chunkZ) {
long key = key(chunkX, chunkZ);
int slot = Recent.slot(chunkX, chunkZ);
Recent last = recent.get();
int current = generation;
ChunkPlanes planes = last.get(key, slot, current);
if (planes == null) {
planes = entries.get(key);
if (planes == null) {
return null;
}
last.remember(key, slot, planes, current);
}
return planes.planeIfPresent(plane);
}
public ChunkPlanes.Plane plane(BakePlane plane, int chunkX, int chunkZ) {
return planes(chunkX, chunkZ).plane(plane);
}
public Object complete(BakePlane plane, int chunkX, int chunkZ) {
return remembered(chunkX, chunkZ).complete(plane, source);
}
public void fillColumns(BakePlane plane, int chunkX, int chunkZ, long[] columns, Object target) {
remembered(chunkX, chunkZ).plane(plane).fillColumns(source, chunkX, chunkZ, columns, target);
}
public boolean memoAllowed() {
return SPARSE.get()[0] == 0 && !source.mainThread();
}
public void storeDouble(BakePlane plane, int blockX, int blockZ, double value, long revision) {
if (revision != ProvisionalSampling.revision()) {
return;
}
remembered(blockX >> 4, blockZ >> 4).plane(plane).storeDouble(((blockZ & 15) << 4) | (blockX & 15), value);
}
public void storeObject(BakePlane plane, int blockX, int blockZ, Object value, long revision) {
if (revision != ProvisionalSampling.revision()) {
return;
}
remembered(blockX >> 4, blockZ >> 4).plane(plane).storeObject(((blockZ & 15) << 4) | (blockX & 15), value);
}
private ChunkPlanes planes(int chunkX, int chunkZ) {
long key = key(chunkX, chunkZ);
ChunkPlanes existing = entries.get(key);
return existing != null ? existing : entries.putIfAbsent(key, new ChunkPlanes(chunkX, chunkZ));
}
private ChunkPlanes remembered(int chunkX, int chunkZ) {
int current = generation;
ChunkPlanes planes = planes(chunkX, chunkZ);
recent.get().remember(key(chunkX, chunkZ), Recent.slot(chunkX, chunkZ), planes, current);
return planes;
}
public static void enterSparse() {
SPARSE.get()[0]++;
}
public static void exitSparse() {
SPARSE.get()[0]--;
}
public static boolean inSparseScope() {
return SPARSE.get()[0] > 0;
}
public static long key(int chunkX, int chunkZ) {
return ((long) chunkX << 32) ^ (chunkZ & 0xffffffffL);
}
private static final class Recent {
private final long[] keys = new long[RECENT_SLOTS];
private final ChunkPlanes[] planes = new ChunkPlanes[RECENT_SLOTS];
private int generation = -1;
private static int slot(int chunkX, int chunkZ) {
return ((chunkZ & 3) << 2) | (chunkX & 3);
}
private ChunkPlanes get(long key, int slot, int generation) {
return this.generation == generation && keys[slot] == key ? planes[slot] : null;
}
private void remember(long key, int slot, ChunkPlanes value, int generation) {
if (this.generation != generation) {
Arrays.fill(planes, null);
this.generation = generation;
}
keys[slot] = key;
planes[slot] = value;
}
}
}
@@ -0,0 +1,245 @@
package art.arcane.iris.generation.bake;
import art.arcane.iris.generation.stream.ProvisionalSampling;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.atomic.AtomicReferenceArray;
public final class ChunkPlanes {
private static final VarHandle READY = MethodHandles.arrayElementVarHandle(long[].class);
private final int chunkX;
private final int chunkZ;
private final AtomicReferenceArray<Plane> planes = new AtomicReferenceArray<>(BakePlane.PLANES.length);
ChunkPlanes(int chunkX, int chunkZ) {
this.chunkX = chunkX;
this.chunkZ = chunkZ;
}
public int chunkX() {
return chunkX;
}
public int chunkZ() {
return chunkZ;
}
public Plane planeIfPresent(BakePlane kind) {
return planes.getAcquire(kind.ordinal());
}
public Plane plane(BakePlane kind) {
int slot = kind.ordinal();
Plane plane = planes.getAcquire(slot);
if (plane != null) {
return plane;
}
Plane created = new Plane(kind);
Plane witness = planes.compareAndExchangeRelease(slot, null, created);
return witness == null ? created : witness;
}
Object complete(BakePlane kind, PlaneSource source) {
Plane plane = plane(kind);
while (true) {
if (plane.complete) {
return plane.values;
}
Pending running = plane.filling.get();
if (running != null) {
if (running.owner == Thread.currentThread()) {
return plane.unpublishedFill(source, chunkX, chunkZ);
}
running.await();
continue;
}
Pending pending = new Pending(Thread.currentThread());
if (!plane.filling.compareAndSet(null, pending)) {
continue;
}
try {
return plane.fill(source, chunkX, chunkZ);
} finally {
plane.filling.set(null);
pending.complete();
}
}
}
void release() {
for (int slot = 0; slot < planes.length(); slot++) {
planes.setRelease(slot, null);
}
}
public static final class Plane {
private final BakePlane kind;
private final Object values;
private final long[] ready = new long[4];
private final AtomicReference<Pending> filling = new AtomicReference<>();
private volatile boolean complete;
private Plane(BakePlane kind) {
this.kind = kind;
this.values = kind.doubles() ? new double[256] : new Object[256];
}
public boolean ready(int index) {
return complete || ((long) READY.getAcquire(ready, index >>> 6) & (1L << (index & 63))) != 0L;
}
public double doubleAt(int index) {
return ((double[]) values)[index];
}
public Object objectAt(int index) {
return ((Object[]) values)[index];
}
public void storeDouble(int index, double value) {
((double[]) values)[index] = value;
markReady(index);
}
public void storeObject(int index, Object value) {
((Object[]) values)[index] = value;
markReady(index);
}
private void markReady(int index) {
READY.getAndBitwiseOrRelease(ready, index >>> 6, 1L << (index & 63));
}
private long[] missing() {
long[] missing = new long[4];
for (int word = 0; word < 4; word++) {
missing[word] = ~(long) READY.getAcquire(ready, word);
}
return missing;
}
private Object fill(PlaneSource source, int chunkX, int chunkZ) {
long[] missing = missing();
Object computed = kind.doubles() ? new double[256] : new Object[256];
long revision = ProvisionalSampling.revision();
source.fill(kind, chunkX, chunkZ, missing, computed);
if (revision != ProvisionalSampling.revision()) {
return merge(missing, computed);
}
for (int index = 0; index < 256; index++) {
if ((missing[index >>> 6] & (1L << (index & 63))) != 0L) {
if (kind.doubles()) {
((double[]) values)[index] = ((double[]) computed)[index];
} else {
((Object[]) values)[index] = ((Object[]) computed)[index];
}
}
}
for (int word = 0; word < 4; word++) {
READY.setRelease(ready, word, -1L);
}
complete = true;
return values;
}
void fillColumns(PlaneSource source, int chunkX, int chunkZ, long[] columns, Object target) {
long[] missing = new long[4];
boolean any = false;
if (!complete) {
for (int word = 0; word < 4; word++) {
missing[word] = columns[word] & ~(long) READY.getAcquire(ready, word);
any |= missing[word] != 0L;
}
}
if (!any) {
copyColumns(columns, values, target);
return;
}
long revision = ProvisionalSampling.revision();
source.fill(kind, chunkX, chunkZ, missing, target);
long[] known = new long[4];
for (int word = 0; word < 4; word++) {
known[word] = columns[word] & ~missing[word];
}
copyColumns(known, values, target);
if (revision == ProvisionalSampling.revision()) {
publish(missing, target);
}
}
private void publish(long[] missing, Object computed) {
copyColumns(missing, computed, values);
for (int word = 0; word < 4; word++) {
if (missing[word] != 0L) {
READY.getAndBitwiseOrRelease(ready, word, missing[word]);
}
}
}
private void copyColumns(long[] columns, Object from, Object to) {
for (int word = 0; word < 4; word++) {
long bits = columns[word];
while (bits != 0L) {
int index = (word << 6) | Long.numberOfTrailingZeros(bits);
if (kind.doubles()) {
((double[]) to)[index] = ((double[]) from)[index];
} else {
((Object[]) to)[index] = ((Object[]) from)[index];
}
bits &= bits - 1L;
}
}
}
private Object unpublishedFill(PlaneSource source, int chunkX, int chunkZ) {
long[] missing = missing();
Object computed = kind.doubles() ? new double[256] : new Object[256];
source.fill(kind, chunkX, chunkZ, missing, computed);
return merge(missing, computed);
}
private Object merge(long[] missing, Object computed) {
for (int index = 0; index < 256; index++) {
if ((missing[index >>> 6] & (1L << (index & 63))) == 0L) {
if (kind.doubles()) {
((double[]) computed)[index] = ((double[]) values)[index];
} else {
((Object[]) computed)[index] = ((Object[]) values)[index];
}
}
}
return computed;
}
}
private static final class Pending {
private final Thread owner;
private boolean done;
private Pending(Thread owner) {
this.owner = owner;
}
private synchronized void complete() {
done = true;
notifyAll();
}
private synchronized void await() {
boolean interrupted = false;
while (!done) {
try {
wait();
} catch (InterruptedException e) {
interrupted = true;
}
}
if (interrupted) {
Thread.currentThread().interrupt();
}
}
}
}
@@ -0,0 +1,59 @@
package art.arcane.iris.generation.bake;
import art.arcane.iris.generation.runtime.GenerationCounter;
import java.util.concurrent.atomic.LongAdder;
public final class GeneratorEvaluations {
private static final LongAdder BAKED_CHUNKS = new LongAdder();
private static final LongAdder BAKED = new LongAdder();
private static final LongAdder BATCHED = new LongAdder();
private static final LongAdder SCALAR = new LongAdder();
private GeneratorEvaluations() {
}
public static void bakedChunk(long evaluations) {
if (!GenerationCounter.ENABLED) {
return;
}
BAKED_CHUNKS.increment();
BAKED.add(evaluations);
}
public static void batched(long evaluations) {
if (!GenerationCounter.ENABLED) {
return;
}
BATCHED.add(evaluations);
}
public static void scalar(int evaluations) {
if (!GenerationCounter.ENABLED) {
return;
}
SCALAR.add(evaluations);
}
public static Snapshot snapshot() {
return new Snapshot(BAKED_CHUNKS.sum(), BAKED.sum(), BATCHED.sum(), SCALAR.sum());
}
public static void reset() {
BAKED_CHUNKS.reset();
BAKED.reset();
BATCHED.reset();
SCALAR.reset();
}
public record Snapshot(long bakedChunks, long bakedEvaluations, long batchedEvaluations, long scalarEvaluations) {
public double bakedEvaluationsPerChunk() {
return bakedChunks == 0L ? 0D : (double) bakedEvaluations / bakedChunks;
}
public Snapshot minus(Snapshot earlier) {
return new Snapshot(bakedChunks - earlier.bakedChunks, bakedEvaluations - earlier.bakedEvaluations,
batchedEvaluations - earlier.batchedEvaluations, scalarEvaluations - earlier.scalarEvaluations);
}
}
}
@@ -0,0 +1,7 @@
package art.arcane.iris.generation.bake;
public interface PlaneSource {
void fill(BakePlane plane, int chunkX, int chunkZ, long[] missing, Object values);
boolean mainThread();
}
@@ -0,0 +1,168 @@
package art.arcane.iris.generation.bake;
import art.arcane.iris.generation.noise.GeneratorProgram;
import art.arcane.iris.generation.noise.IrisGenerator;
import art.arcane.iris.generation.noise.TerrainBatchScratch;
import art.arcane.volmlib.util.math.M;
import java.util.Arrays;
public final class TerrainProgram {
private static final int SKIPPED = 0;
private static final int CONSTANT = 1;
private static final int ACTIVE = 2;
private final IrisGenerator[][] generators;
private final GeneratorProgram[][] programs;
private final long heightSeed;
private TerrainProgram(IrisGenerator[][] generators, GeneratorProgram[][] programs, long heightSeed) {
this.generators = generators;
this.programs = programs;
this.heightSeed = heightSeed;
}
public static TerrainProgram compile(IrisGenerator[][] generators, long heightSeed) {
GeneratorProgram[][] programs = new GeneratorProgram[generators.length][];
for (int group = 0; group < generators.length; group++) {
programs[group] = new GeneratorProgram[generators[group].length];
for (int index = 0; index < generators[group].length; index++) {
IrisGenerator generator = generators[group][index];
programs[group][index] = generator.supportsBatch() ? generator.compileProgram(heightSeed) : null;
}
}
return new TerrainProgram(generators, programs, heightSeed);
}
public long heightSeed() {
return heightSeed;
}
public double average(int group, TerrainBatchScratch scratch, double low, double high, double x, double z) {
IrisGenerator[] members = generators[group];
if (members.length == 0) {
return 0D;
}
if (!Double.isFinite(low) || !Double.isFinite(high)) {
throw new IllegalStateException("Terrain generator bounds were not finite at " + x + "," + z
+ " (generator=" + members[0].getLoadKey() + ", bounds=" + low + ".." + high + ").");
}
double height = 0D;
if (low == high) {
for (int member = 0; member < members.length; member++) {
height += low;
}
return height / members.length;
}
GeneratorEvaluations.scalar(members.length);
GeneratorProgram[] compiled = programs[group];
for (int member = 0; member < members.length; member++) {
GeneratorProgram program = compiled[member];
double noise = program != null ? program.height(scratch, x, z) : members[member].getHeight(x, z, heightSeed);
if (!Double.isFinite(noise)) {
throw new IllegalStateException("Terrain generator noise was not finite at " + x + "," + z
+ " (generator=" + members[member].getLoadKey() + ", noise=" + noise
+ ", bounds=" + low + ".." + high + ").");
}
height += M.lerp(low, high, noise);
}
return height / members.length;
}
public int groups() {
return generators.length;
}
public int groupSize(int group) {
return generators[group].length;
}
public long accumulate(int group, TerrainBatchScratch scratch, double[] xs, double[] zs, int count,
double[] lows, double[] highs, double[] heights) {
IrisGenerator[] members = generators[group];
if (members.length == 0 || count == 0) {
return 0L;
}
int mark = scratch.mark();
int indexMark = scratch.indexMark();
try {
return accumulate(members, group, scratch, xs, zs, count, lows, highs, heights);
} finally {
scratch.reset(mark);
scratch.resetIndices(indexMark);
}
}
private long accumulate(IrisGenerator[] members, int group, TerrainBatchScratch scratch, double[] xs, double[] zs,
int count, double[] lows, double[] highs, double[] heights) {
int[] modes = scratch.indices(count);
int activeCount = 0;
for (int index = 0; index < count; index++) {
double low = lows[index];
double high = highs[index];
if (low == 0D && high == 0D) {
modes[index] = SKIPPED;
continue;
}
if (!Double.isFinite(low) || !Double.isFinite(high)) {
throw new IllegalStateException("Terrain generator bounds were not finite at " + xs[index] + "," + zs[index]
+ " (generator=" + members[0].getLoadKey() + ", bounds=" + low + ".." + high + ").");
}
if (low == high) {
modes[index] = CONSTANT;
double sum = 0D;
for (int member = 0; member < members.length; member++) {
sum += low;
}
heights[index] += sum / members.length;
continue;
}
modes[index] = ACTIVE;
activeCount++;
}
if (activeCount == 0) {
return 0L;
}
double[] activeX = scratch.buffer(activeCount);
double[] activeZ = scratch.buffer(activeCount);
double[] activeLow = scratch.buffer(activeCount);
double[] activeHigh = scratch.buffer(activeCount);
int[] activeIndex = scratch.indices(activeCount);
int cursor = 0;
for (int index = 0; index < count; index++) {
if (modes[index] == ACTIVE) {
activeX[cursor] = xs[index];
activeZ[cursor] = zs[index];
activeLow[cursor] = lows[index];
activeHigh[cursor] = highs[index];
activeIndex[cursor++] = index;
}
}
double[] sums = scratch.buffer(activeCount);
double[] noise = scratch.buffer(activeCount);
Arrays.fill(sums, 0, activeCount, 0D);
GeneratorProgram[] compiled = programs[group];
for (int member = 0; member < members.length; member++) {
GeneratorProgram program = compiled[member];
if (program != null) {
program.heights(scratch, activeX, activeZ, activeCount, noise);
} else {
for (int index = 0; index < activeCount; index++) {
noise[index] = members[member].getHeight(activeX[index], activeZ[index], heightSeed);
}
}
for (int index = 0; index < activeCount; index++) {
if (!Double.isFinite(noise[index])) {
throw new IllegalStateException("Terrain generator noise was not finite at " + activeX[index] + "," + activeZ[index]
+ " (generator=" + members[member].getLoadKey() + ", noise=" + noise[index]
+ ", bounds=" + activeLow[index] + ".." + activeHigh[index] + ").");
}
sums[index] += M.lerp(activeLow[index], activeHigh[index], noise[index]);
}
}
for (int index = 0; index < activeCount; index++) {
heights[activeIndex[index]] += sums[index] / members.length;
}
return (long) activeCount * members.length;
}
}
@@ -26,7 +26,7 @@ import art.arcane.iris.generation.decoration.IrisDepositVariant;
import art.arcane.iris.generation.decoration.IrisOreGenerator;
import art.arcane.iris.generation.decoration.IrisOreBands;
import art.arcane.iris.generation.decoration.IrisProceduralObjects;
import art.arcane.iris.generation.hydrology.IrisRiverPolicy;
import art.arcane.iris.generation.hydrology.config.IrisHydrologyPolicy;
import art.arcane.iris.generation.noise.IrisGeneratorStyle;
import art.arcane.iris.generation.noise.NoiseStyle;
import art.arcane.iris.generation.terrain.InferredType;
@@ -161,8 +161,8 @@ public class IrisBiome extends IrisRegistrant implements Rarity {
private int lockLayersMax = 7;
@Description("Profile-driven 3D cave configuration")
private IrisCaveProfile caveProfile = new IrisCaveProfile();
@Description("Biome-level river placement, routing, profile, biome, and geometry policy. Omit to inherit region and dimension settings.")
private IrisRiverPolicy riverPolicy = null;
@Description("Water settings for this biome: rainfall, pond density, where streams and ponds may form, and the fluid that fills them. A value left unset comes from the region, then from the dimension.")
private IrisHydrologyPolicy hydrologyPolicy = null;
@MinNumber(1)
@MaxNumber(512)
@Description("The rarity of this biome (integer)")
@@ -515,7 +515,10 @@ public class IrisBiome extends IrisRegistrant implements Rarity {
}
public CNG getChildrenGenerator(RNG random, int sig, double scale) {
long seed = deriveChildStyleSeed(random, sig).getSeed();
return getChildrenGenerator(deriveChildStyleSeed(random, sig).getSeed(), scale);
}
public CNG getChildrenGenerator(long seed, double scale) {
IrisData data = getLoader();
Engine engine = data == null ? null : data.getEngine();
CachedChildGenerator recent = recentChildGenerator;
@@ -533,6 +536,10 @@ public class IrisBiome extends IrisRegistrant implements Rarity {
return random.nextParallelRNG(signature * CHILDREN_SEED_SALT);
}
public static long childStyleSeed(RNG random, int signature) {
return deriveChildStyleSeed(random, signature).getSeed();
}
public KList<NativeBlockState> generateLayers(IrisDimension dim, double wx, double wz, RNG random, int maxDepth, int height, IrisData rdata, IrisComplex complex) {
return IrisBiomeLayerGenerator.generateLayers(this, dim, wx, wz, random, maxDepth, height, rdata, complex);
}
@@ -573,6 +580,10 @@ public class IrisBiome extends IrisRegistrant implements Rarity {
return IrisBiomeLayerGenerator.getLayerSeaHeightGenerators(this, rng, data);
}
public KList<CNG> getCaveCeilingLayerHeightGenerators(RNG rng, IrisData data) {
return IrisBiomeLayerGenerator.getCaveCeilingLayerHeightGenerators(this, rng, data);
}
public NativeBlockState getSurfaceBlock(int x, int z, RNG rng, IrisData idm) {
return IrisBiomeLayerGenerator.getSurfaceBlock(this, x, z, rng, idm);
}
@@ -148,8 +148,7 @@ final class IrisBiomeLayerGenerator {
return data;
}
KList<CNG> heightGenerators = getHeightGenerators(
biome.getLayerCeilingHeightGenerators(), ceilingLayers, 7235, random, rdata);
KList<CNG> heightGenerators = getCaveCeilingLayerHeightGenerators(biome, random, rdata);
for (int i = 0; i < layerCount; i++) {
IrisBiomePaletteLayer layer = ceilingLayers.get(i);
@@ -317,6 +316,10 @@ final class IrisBiomeLayerGenerator {
return getHeightGenerators(biome.getLayerSeaHeightGenerators(), biome.getSeaLayers(), 7735, rng, data);
}
static KList<CNG> getCaveCeilingLayerHeightGenerators(IrisBiome biome, RNG rng, IrisData data) {
return getHeightGenerators(biome.getLayerCeilingHeightGenerators(), biome.getCaveCeilingLayers(), 7235, rng, data);
}
private static KList<CNG> getHeightGenerators(IrisBiomeLayerHeightCache cache,
KList<IrisBiomePaletteLayer> layers,
int seedOffset, RNG rng, IrisData data) {
@@ -1,169 +0,0 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.generation.chunk;
import art.arcane.iris.platform.bukkit.nms.INMS;
import art.arcane.volmlib.nativelib.terrain.NativeBlockVolume;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.iris.generation.block.BoundBlockState;
import art.arcane.iris.generation.block.IrisCustomData;
import art.arcane.volmlib.util.hunk.storage.StorageHunk;
import org.bukkit.block.data.BlockData;
import org.bukkit.generator.ChunkGenerator.ChunkData;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;
import java.util.Arrays;
/**
* Plain-array block buffer for one chunk. Stages write disjoint columns when they fan out and every hand-off
* between threads is a join, so the cells need no per-access fences. Each column tracks its highest stored y;
* a raise is a CAS so concurrent writers to one column can never lose the maximum.
*/
public class ChunkDataHunkHolder extends StorageHunk<NativeBlockState> implements NativeBlockVolume, ColumnExtent {
private static final BoundBlockState AIR = BoundBlockState.of("AIR");
private static final VarHandle COLUMN_TOPS = MethodHandles.arrayElementVarHandle(int[].class);
private final ChunkData chunk;
private final NativeBlockState[] data;
private final int[] columnTops;
private final int zStride;
public ChunkDataHunkHolder(ChunkData chunk) {
super(16, chunk.getMaxHeight() - chunk.getMinHeight(), 16);
this.chunk = chunk;
this.zStride = 16 * getHeight();
this.data = new NativeBlockState[zStride * 16];
this.columnTops = new int[256];
Arrays.fill(columnTops, -1);
}
@Override
public int getWidth() {
return 16;
}
@Override
public int getDepth() {
return 16;
}
@Override
public void setRaw(int x, int y, int z, NativeBlockState t) {
// Block-hunk contract: null means "no write", never "erase" — ChunkDataHunkView and
// the modded ModdedBlockBuffer already discard nulls, and storing one here made the
// Bukkit output diverge from the modded loaders for the same engine emission.
if (t == null) {
return;
}
data[index(x, y, z)] = t;
int column = (z << 4) | x;
int top = (int) COLUMN_TOPS.getOpaque(columnTops, column);
while (y > top) {
int witness = (int) COLUMN_TOPS.compareAndExchange(columnTops, column, top, y);
if (witness == top) {
break;
}
top = witness;
}
}
@Override
public NativeBlockState getRaw(int x, int y, int z) {
if (y < 0 || y >= getHeight()) {
return AIR.get();
}
NativeBlockState b = data[index(x, y, z)];
return b != null ? b : AIR.get();
}
@Override
public NativeBlockState getStoredRaw(int x, int y, int z) {
return data[index(x, y, z)];
}
@Override
public int highestStoredY(int x, int z) {
return (int) COLUMN_TOPS.getOpaque(columnTops, (z << 4) | x);
}
private int index(int x, int y, int z) {
return (z * zStride) + (y << 4) + x;
}
public void apply() {
applyTo(chunk);
}
public void applyTo(ChunkData target) {
if (INMS.get().applyChunkDataBlocks(target, this)) {
return;
}
int height = getHeight();
for (int x = 0; x < getWidth(); x++) {
for (int z = 0; z < getDepth(); z++) {
BlockData activeBlock = null;
int runStart = -1;
for (int y = 0; y < height; y++) {
NativeBlockState state = getStoredRaw(x, y, z);
BlockData block = state == null ? null : (BlockData) state.nativeHandle();
// Custom wrappers are not real Bukkit data; write the vanilla base like the
// NMS fast path (NMSBinding.applyChunkDataBlocks) does.
if (block instanceof IrisCustomData custom) {
block = custom.getBase();
}
if (block == null) {
flushRun(target, x, z, runStart, y, activeBlock);
activeBlock = null;
runStart = -1;
continue;
}
if (activeBlock != null && activeBlock.equals(block)) {
continue;
}
flushRun(target, x, z, runStart, y, activeBlock);
activeBlock = block;
runStart = y;
}
flushRun(target, x, z, runStart, height, activeBlock);
}
}
}
private void flushRun(ChunkData target, int x, int z, int startY, int endY, BlockData block) {
if (block == null || startY < 0 || endY <= startY) {
return;
}
int minY = target.getMinHeight();
if (endY - startY == 1) {
target.setBlock(x, startY + minY, z, block);
return;
}
target.setRegion(x, startY + minY, z, x + 1, endY + minY, z + 1, block);
}
}
@@ -0,0 +1,82 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.generation.chunk;
import art.arcane.iris.generation.block.IrisCustomData;
import art.arcane.iris.platform.bukkit.nms.INMS;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import org.bukkit.block.data.BlockData;
import org.bukkit.generator.ChunkGenerator.ChunkData;
public final class ChunkDataOutput {
private ChunkDataOutput() {
}
public static void apply(ChunkData target, SectionBuffer blocks) {
if (INMS.get().applyChunkDataBlocks(target, blocks)) {
return;
}
int height = blocks.getHeight();
for (int x = 0; x < 16; x++) {
for (int z = 0; z < 16; z++) {
BlockData activeBlock = null;
int runStart = -1;
for (int y = 0; y < height; y++) {
NativeBlockState state = blocks.getStoredRaw(x, y, z);
BlockData block = state == null ? null : (BlockData) state.nativeHandle();
if (block instanceof IrisCustomData custom) {
block = custom.getBase();
}
if (block == null) {
flushRun(target, x, z, runStart, y, activeBlock);
activeBlock = null;
runStart = -1;
continue;
}
if (activeBlock != null && activeBlock.equals(block)) {
continue;
}
flushRun(target, x, z, runStart, y, activeBlock);
activeBlock = block;
runStart = y;
}
flushRun(target, x, z, runStart, height, activeBlock);
}
}
}
private static void flushRun(ChunkData target, int x, int z, int startY, int endY, BlockData block) {
if (block == null || startY < 0 || endY <= startY) {
return;
}
int minY = target.getMinHeight();
if (endY - startY == 1) {
target.setBlock(x, startY + minY, z, block);
return;
}
target.setRegion(x, startY + minY, z, x + 1, endY + minY, z + 1, block);
}
}
@@ -20,7 +20,7 @@ package art.arcane.iris.generation.chunk;
import art.arcane.volmlib.util.hunk.Hunk;
public final class ColumnExtentListeningHunk<T> implements Hunk<T>, ColumnExtent {
public final class ColumnExtentListeningHunk<T> implements Hunk<T>, ColumnExtent, ColumnRunHunk<T> {
private final Hunk<T> source;
private final Listener<T> listener;
@@ -35,6 +35,12 @@ public final class ColumnExtentListeningHunk<T> implements Hunk<T>, ColumnExtent
source.setRaw(x, y, z, t);
}
@Override
public void fillColumnRun(int x, int z, int yFromInclusive, int yToInclusive, T value) {
listener.onRun(x, z, yFromInclusive, yToInclusive, value);
ColumnRunHunk.fillColumnRun(source, x, z, yFromInclusive, yToInclusive, value);
}
@Override
public T getRaw(int x, int y, int z) {
return source.getRaw(x, y, z);
@@ -63,5 +69,11 @@ public final class ColumnExtentListeningHunk<T> implements Hunk<T>, ColumnExtent
@FunctionalInterface
public interface Listener<T> {
void onWrite(int x, int y, int z, T value);
default void onRun(int x, int z, int yFromInclusive, int yToInclusive, T value) {
for (int y = yToInclusive; y >= yFromInclusive; y--) {
onWrite(x, y, z, value);
}
}
}
}
@@ -0,0 +1,36 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.generation.chunk;
import art.arcane.volmlib.util.hunk.Hunk;
public interface ColumnRunHunk<T> {
void fillColumnRun(int x, int z, int yFromInclusive, int yToInclusive, T value);
@SuppressWarnings("unchecked")
static <T> void fillColumnRun(Hunk<T> hunk, int x, int z, int yFromInclusive, int yToInclusive, T value) {
if (hunk instanceof ColumnRunHunk<?> runs) {
((ColumnRunHunk<T>) runs).fillColumnRun(x, z, yFromInclusive, yToInclusive, value);
return;
}
for (int y = yToInclusive; y >= yFromInclusive; y--) {
hunk.setRaw(x, y, z, value);
}
}
}
@@ -0,0 +1,268 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.generation.chunk;
import art.arcane.iris.generation.block.BoundBlockState;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.volmlib.nativelib.terrain.NativeSectionVolume;
import art.arcane.volmlib.util.hunk.Hunk;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;
import java.util.Arrays;
public final class SectionBuffer implements Hunk<NativeBlockState>, NativeSectionVolume, ColumnExtent, ColumnRunHunk<NativeBlockState> {
private static final BoundBlockState AIR = BoundBlockState.of("AIR");
private static final VarHandle SECTIONS = MethodHandles.arrayElementVarHandle(short[][].class);
private static final VarHandle COLUMN_TOPS = MethodHandles.arrayElementVarHandle(int[].class);
private static final int SECTION_CELLS = 4096;
private static final int MAX_PALETTE_ID = 0xFFFF;
private static final int INITIAL_PALETTE_CAPACITY = 64;
private static final int COLUMN_SLICE_SHIFT = 5;
private final int height;
private final short[][] sections;
private final int[] columnTops;
private final Object paletteLock = new Object();
private PaletteEntry[] slots;
private NativeBlockState[] states;
private int paletteSize;
public SectionBuffer(int height) {
if (height <= 0) {
throw new IllegalArgumentException("Unsupported section buffer height " + height);
}
this.height = height;
this.sections = new short[(height + 15) >> 4][];
this.columnTops = new int[16 << COLUMN_SLICE_SHIFT];
Arrays.fill(columnTops, -1);
this.slots = new PaletteEntry[INITIAL_PALETTE_CAPACITY << 1];
this.states = new NativeBlockState[INITIAL_PALETTE_CAPACITY];
this.paletteSize = 1;
}
@Override
public int getWidth() {
return 16;
}
@Override
public int getHeight() {
return height;
}
@Override
public int getDepth() {
return 16;
}
public int paletteId(NativeBlockState state) {
if (state == null) {
return 0;
}
PaletteEntry[] table = slots;
int mask = table.length - 1;
int slot = System.identityHashCode(state) & mask;
PaletteEntry entry;
while ((entry = table[slot]) != null) {
if (entry.state == state) {
return entry.id;
}
slot = (slot + 1) & mask;
}
return insert(state);
}
@Override
public void setRaw(int x, int y, int z, NativeBlockState t) {
if (t == null) {
return;
}
writableSection(y >> 4)[((y & 15) << 8) | (z << 4) | x] = (short) paletteId(t);
raiseTop((x << COLUMN_SLICE_SHIFT) | z, y);
}
@Override
public NativeBlockState getRaw(int x, int y, int z) {
if (y < 0 || y >= height) {
return AIR.get();
}
short[] section = sections[y >> 4];
if (section != null) {
int id = section[((y & 15) << 8) | (z << 4) | x] & 0xFFFF;
if (id != 0) {
return state(id);
}
}
return AIR.get();
}
@Override
public NativeBlockState getStoredRaw(int x, int y, int z) {
if (y < 0 || y >= height) {
return null;
}
short[] section = sections[y >> 4];
if (section == null) {
return null;
}
int id = section[((y & 15) << 8) | (z << 4) | x] & 0xFFFF;
return id == 0 ? null : state(id);
}
@Override
public void fillColumnRun(int x, int z, int yFromInclusive, int yToInclusive, NativeBlockState value) {
if (value == null || yToInclusive < yFromInclusive) {
return;
}
fillColumnRun(x, z, yFromInclusive, yToInclusive, paletteId(value));
}
public void fillColumnRun(int localX, int localZ, int yFromInclusive, int yToInclusive, int paletteId) {
if (paletteId == 0 || yToInclusive < yFromInclusive) {
return;
}
short value = (short) paletteId;
int column = (localZ << 4) | localX;
int y = yFromInclusive;
while (y <= yToInclusive) {
int sectionIndex = y >> 4;
short[] section = writableSection(sectionIndex);
int last = Math.min(yToInclusive, (sectionIndex << 4) | 15);
int end = ((last & 15) << 8) | column;
for (int index = ((y & 15) << 8) | column; index <= end; index += 256) {
section[index] = value;
}
y = last + 1;
}
raiseTop((localX << COLUMN_SLICE_SHIFT) | localZ, yToInclusive);
}
@Override
public int highestStoredY(int x, int z) {
return (int) COLUMN_TOPS.getOpaque(columnTops, (x << COLUMN_SLICE_SHIFT) | z);
}
@Override
public int sectionCount() {
return sections.length;
}
@Override
public short[] section(int sectionIndex) {
return sections[sectionIndex];
}
@Override
public NativeBlockState[] palette() {
synchronized (paletteLock) {
return Arrays.copyOf(states, paletteSize);
}
}
private short[] writableSection(int sectionIndex) {
short[] section = sections[sectionIndex];
if (section != null) {
return section;
}
short[] created = new short[SECTION_CELLS];
short[] witness = (short[]) SECTIONS.compareAndExchange(sections, sectionIndex, (short[]) null, created);
return witness == null ? created : witness;
}
private void raiseTop(int column, int y) {
int top = (int) COLUMN_TOPS.getOpaque(columnTops, column);
while (y > top) {
int witness = (int) COLUMN_TOPS.compareAndExchange(columnTops, column, top, y);
if (witness == top) {
return;
}
top = witness;
}
}
private NativeBlockState state(int id) {
NativeBlockState[] current = states;
if (id < current.length) {
NativeBlockState state = current[id];
if (state != null) {
return state;
}
}
synchronized (paletteLock) {
return states[id];
}
}
private int insert(NativeBlockState state) {
synchronized (paletteLock) {
PaletteEntry[] table = slots;
int mask = table.length - 1;
int slot = System.identityHashCode(state) & mask;
PaletteEntry entry;
while ((entry = table[slot]) != null) {
if (entry.state == state) {
return entry.id;
}
slot = (slot + 1) & mask;
}
int id = paletteSize;
if (id > MAX_PALETTE_ID) {
throw new IllegalStateException("A chunk wrote more than " + MAX_PALETTE_ID + " distinct block state instances");
}
NativeBlockState[] current = states;
if (id == current.length) {
current = Arrays.copyOf(current, current.length << 1);
}
current[id] = state;
states = current;
paletteSize = id + 1;
PaletteEntry created = new PaletteEntry(state, id);
if (id << 1 >= table.length) {
slots = rehash(table, created);
} else {
table[slot] = created;
}
return id;
}
}
private static PaletteEntry[] rehash(PaletteEntry[] table, PaletteEntry created) {
PaletteEntry[] grown = new PaletteEntry[table.length << 1];
for (PaletteEntry entry : table) {
if (entry != null) {
place(grown, entry);
}
}
place(grown, created);
return grown;
}
private static void place(PaletteEntry[] table, PaletteEntry entry) {
int mask = table.length - 1;
int slot = System.identityHashCode(entry.state) & mask;
while (table[slot] != null) {
slot = (slot + 1) & mask;
}
table[slot] = entry;
}
private record PaletteEntry(NativeBlockState state, int id) {
}
}
@@ -26,7 +26,7 @@ public class MultiBurst extends MultiBurstSupport {
private final AtomicInteger parallelismBaseline = new AtomicInteger();
public MultiBurst() {
this("Iris");
this("Iris", Thread.MIN_PRIORITY, MultiBurstSupport::performanceProcessors);
}
public MultiBurst(String name) {
@@ -140,8 +140,15 @@ public class MultiBurst extends MultiBurstSupport {
static final class HydrologyBurst extends MultiBurst {
HydrologyBurst() {
super("Iris Hydrology", () -> Math.max(2,
IrisSettings.getThreadCount(IrisSettings.get().getConcurrency().getParallelism())));
super("Iris Hydrology", HydrologyBurst::plannerParallelism);
}
static int plannerParallelism() {
return plannerParallelism(MultiBurstSupport.performanceProcessors(), Runtime.getRuntime().availableProcessors());
}
static int plannerParallelism(int performanceProcessors, int availableProcessors) {
return Math.max(2, Math.min(Math.max(4, performanceProcessors), availableProcessors));
}
@Override
@@ -5,7 +5,7 @@ import art.arcane.iris.generation.runtime.DimensionStackLayout;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.runtime.EngineMetrics;
import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntime;
import art.arcane.iris.generation.hydrology.raster.ChunkWaterRaster;
import art.arcane.iris.generation.biome.FloatingIslandBoundarySampler;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.mantle.CaveTerrainSnapshot;
@@ -17,8 +17,6 @@ import art.arcane.volmlib.nativelib.terrain.NativeBiome;
import art.arcane.volmlib.util.hunk.Hunk;
import art.arcane.iris.generation.concurrent.MultiBurst;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
public class ChunkContext {
@@ -27,6 +25,7 @@ public class ChunkContext {
private FloatingBiomeOverlay floatingBiomes;
private CaveTerrainSnapshot caveTerrain;
private TransitionTerrainColumn[] transitionTerrain;
private volatile ChunkWaterRaster waterRaster;
private final int x;
private final int z;
private final IrisComplex complex;
@@ -91,42 +90,11 @@ public class ChunkContext {
PrefillPlan resolvedPlan = prefillPlan == null ? PrefillPlan.NO_CAVE : prefillPlan;
boolean capturePrefillMetric = metrics != null;
long totalStartNanos = capturePrefillMetric ? System.nanoTime() : 0L;
IrisHydrologyRuntime hydrologyRuntime = complex.getHydrologyRuntime();
if (hydrologyRuntime != null && (resolvedPlan.height || resolvedPlan.biome)) {
hydrologyRuntime.prepareChunkColumns(x, z);
}
List<Runnable> fillTasks = new ArrayList<>(6);
if (resolvedPlan.height) {
fillTasks.add(() -> height.fillRounded(roundedHeight));
}
if (resolvedPlan.biome) {
fillTasks.add(new PrefillFillTask(biome));
}
if (resolvedPlan.rock) {
fillTasks.add(new PrefillFillTask(rock));
}
if (resolvedPlan.fluid) {
fillTasks.add(new PrefillFillTask(fluid));
}
if (resolvedPlan.region) {
fillTasks.add(new PrefillFillTask(region));
}
if (resolvedPlan.cave) {
fillTasks.add(new PrefillFillTask(cave));
}
if (!shouldPrefillAsync(resolvedPlan, fillTasks.size())) {
for (Runnable fillTask : fillTasks) {
fillTask.run();
}
if (shouldPrefillAsync(resolvedPlan, complex.isPlatformMainThread())) {
prefillAsync(resolvedPlan);
} else {
List<CompletableFuture<Void>> futures = new ArrayList<>(fillTasks.size());
for (Runnable fillTask : fillTasks) {
futures.add(CompletableFuture.runAsync(fillTask, MultiBurst.burst));
}
for (CompletableFuture<Void> future : futures) {
future.join();
}
prefillSurface(resolvedPlan);
prefillPalettes(resolvedPlan);
}
if (capturePrefillMetric) {
metrics.getContextPrefill().put((System.nanoTime() - totalStartNanos) / 1_000_000D);
@@ -134,14 +102,49 @@ public class ChunkContext {
}
}
static boolean shouldPrefillAsync(PrefillPlan prefillPlan, int fillTaskCount) {
if (prefillPlan == PrefillPlan.NATURAL_TERRAIN || fillTaskCount <= 1 || !IrisPlatforms.isBound()) {
static boolean shouldPrefillAsync(PrefillPlan prefillPlan, boolean platformMainThread) {
if (!platformMainThread || prefillPlan == PrefillPlan.NATURAL_TERRAIN || prefillPlan == PrefillPlan.NONE
|| !IrisPlatforms.isBound()) {
return false;
}
return !MultiBurst.burst.ownsCurrentThread();
}
private void prefillSurface(PrefillPlan prefillPlan) {
if (prefillPlan.height || prefillPlan.biome) {
waterRaster();
}
if (prefillPlan.height) {
height.fillRounded(roundedHeight);
}
if (prefillPlan.biome) {
biome.fill();
}
}
private void prefillPalettes(PrefillPlan prefillPlan) {
if (prefillPlan.rock) {
rock.fill();
}
if (prefillPlan.fluid) {
fluid.fill();
}
if (prefillPlan.region) {
region.fill();
}
if (prefillPlan.cave) {
cave.fill();
}
}
private void prefillAsync(PrefillPlan prefillPlan) {
CompletableFuture<Void> surface = CompletableFuture.runAsync(() -> prefillSurface(prefillPlan), MultiBurst.burst);
CompletableFuture<Void> palettes = CompletableFuture.runAsync(() -> prefillPalettes(prefillPlan), MultiBurst.burst);
surface.join();
palettes.join();
}
public void setTerrainBiomeOutput(Hunk<NativeBiome> terrainBiomes) {
this.terrainBiomes = terrainBiomes;
}
@@ -206,6 +209,18 @@ public class ChunkContext {
return complex;
}
public ChunkWaterRaster waterRaster() {
ChunkWaterRaster known = waterRaster;
if (known != null) {
return known;
}
ChunkWaterRaster raster = complex.getHydrologyRuntime().raster(x >> 4, z >> 4);
if (!raster.provisional()) {
waterRaster = raster;
}
return raster;
}
public FloatingIslandBoundarySampler getFloatingIslandBoundarySampler() {
return floatingIslandBoundarySampler;
}
@@ -295,17 +310,4 @@ public class ChunkContext {
public record TransitionTerrainColumn(Terrain3DColumn solids, int fluidHeight) {
}
private static final class PrefillFillTask implements Runnable {
private final ChunkedDataCache<?> dataCache;
private PrefillFillTask(ChunkedDataCache<?> dataCache) {
this.dataCache = dataCache;
}
@Override
public void run() {
dataCache.fill();
}
}
}
@@ -542,7 +542,7 @@ final class DecoratorCore {
return stack;
}
private static boolean allowsSurface(IrisDecorator decorator, NativeBlockState surface, IrisData data) {
static boolean allowsSurface(IrisDecorator decorator, NativeBlockState surface, IrisData data) {
return decorator.isForcePlace()
|| (decorator.getWhitelist() == null || matchesPalette(decorator.getWhitelistArray(data), surface))
&& (decorator.getBlacklist() == null || !matchesPalette(decorator.getBlacklistArray(data), surface));
@@ -0,0 +1,82 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.generation.decoration;
import art.arcane.volmlib.util.math.RNG;
public final class DepositRandom extends RNG {
private static final long MULTIPLIER = 0x5DEECE66DL;
private static final long ADDEND = 0xBL;
private static final long MASK = (1L << 48) - 1L;
private static final int JUMP_TABLE_SIZE = 48;
private static final long[] JUMP_MULTIPLIERS = new long[JUMP_TABLE_SIZE];
private static final long[] JUMP_ADDENDS = new long[JUMP_TABLE_SIZE];
static {
long multiplier = MULTIPLIER;
long addend = ADDEND;
for (int bit = 0; bit < JUMP_TABLE_SIZE; bit++) {
JUMP_MULTIPLIERS[bit] = multiplier;
JUMP_ADDENDS[bit] = addend;
addend = (addend * multiplier + addend) & MASK;
multiplier = (multiplier * multiplier) & MASK;
}
}
private long state;
public DepositRandom() {
super(0L);
}
public DepositRandom(long seed) {
super(seed);
}
@Override
public void setSeed(long seed) {
state = (seed ^ MULTIPLIER) & MASK;
}
@Override
protected int next(int bits) {
long advanced = (state * MULTIPLIER + ADDEND) & MASK;
state = advanced;
return (int) (advanced >>> (48 - bits));
}
public long state() {
return state;
}
public void restore(long state) {
this.state = state & MASK;
}
public void skip(long steps) {
long remaining = steps;
long advanced = state;
for (int bit = 0; remaining != 0L && bit < JUMP_TABLE_SIZE; bit++, remaining >>>= 1) {
if ((remaining & 1L) != 0L) {
advanced = (advanced * JUMP_MULTIPLIERS[bit] + JUMP_ADDENDS[bit]) & MASK;
}
}
state = advanced;
}
}
@@ -0,0 +1,390 @@
package art.arcane.iris.generation.decoration;
import art.arcane.iris.generation.block.B;
import art.arcane.iris.generation.block.IrisBlockData;
import art.arcane.iris.generation.hydrology.plan.HydrologySettings;
import art.arcane.iris.generation.hydrology.raster.WaterRole;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.volmlib.util.hunk.Hunk;
import art.arcane.volmlib.util.math.RNG;
import java.util.ArrayList;
import java.util.List;
public final class HydrologyVegetation {
private static final long SALT = 0x4859445256454745L;
private static final long GOLDEN = 0x9e3779b97f4a7c15L;
private static final int[][] SIDES = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
private final long seed;
private final Engine engine;
private final RNG floorRng;
private final RNG surfaceRng;
private final RNG shoreRng;
public HydrologyVegetation(long seed, Engine engine) {
this.seed = seed ^ SALT;
this.engine = engine;
this.floorRng = new RNG(DecoratorCore.partSeed(this.seed, IrisDecorationPart.SEA_FLOOR));
this.surfaceRng = new RNG(DecoratorCore.partSeed(this.seed, IrisDecorationPart.SEA_SURFACE));
this.shoreRng = new RNG(DecoratorCore.partSeed(this.seed, IrisDecorationPart.SHORE_LINE));
}
public void decorate(Hunk<NativeBlockState> output, int localX, int localZ, int x, int z, WaterRole role, Water water, IrisData data) {
Placement placement = placement(role);
if (placement == Placement.NONE) {
return;
}
Decorators decorators = water.decorators(x, z);
if (decorators.empty()) {
return;
}
int ground = water.ground(x, z);
if (ground < 0 || ground + 1 >= output.getHeight()) {
return;
}
Column column = new Column(output, localX, localZ, x, z, ground, water, data);
if (placement == Placement.SHORE) {
decorateShore(column, decorators.shore);
return;
}
int head = water.head(x, z);
if (head <= ground || head + 1 >= output.getHeight()) {
return;
}
decorateFloor(column, head, decorators.floor);
if (placement == Placement.STILL && decorators.surface.length > 0 && still(water, x, z, head)) {
decorateSurface(column, head, decorators.surface);
}
}
private static Placement placement(WaterRole role) {
return switch (role) {
case CHANNEL, POND -> Placement.STILL;
case CASCADE, WATERFALL -> Placement.FLOOR;
case BANK, SHORE, POND_SHORE -> Placement.SHORE;
case NONE, DAM, MOUTH, INLET -> Placement.NONE;
};
}
private void decorateFloor(Column column, int head, IrisDecorator[] bucket) {
int room = head - column.ground - 1;
if (room < 1 || bucket.length == 0) {
return;
}
IrisDecorator decorator = pick(bucket, floorRng, column, IrisDecorationPart.SEA_FLOOR);
NativeBlockState support = column.output.get(column.localX, column.ground, column.localZ);
if (decorator == null || !B.isSolid(support) || !column.accepts(decorator, support)) {
return;
}
column.place(decorator, floorRng, column.ground + 1, room, Slot.FLOOR, null);
}
private void decorateSurface(Column column, int head, IrisDecorator[] bucket) {
NativeBlockState support = column.output.get(column.localX, head, column.localZ);
if (!isWaterSource(support)) {
return;
}
IrisDecorator decorator = pick(bucket, surfaceRng, column, IrisDecorationPart.SEA_SURFACE);
if (decorator == null || !column.accepts(decorator, support)) {
return;
}
column.place(decorator, surfaceRng, head + 1, column.output.getHeight() - head - 1, Slot.SURFACE, null);
}
private void decorateShore(Column column, IrisDecorator[] bucket) {
NativeBlockState support = column.output.get(column.localX, column.ground, column.localZ);
if (bucket.length == 0 || support == null || !support.isSolid()) {
return;
}
IrisDecorator decorator = pick(bucket, shoreRng, column, IrisDecorationPart.SHORE_LINE);
if (decorator == null || !column.accepts(decorator, support)) {
return;
}
column.place(decorator, shoreRng, column.ground + 1, column.output.getHeight() - column.ground - 1, Slot.SHORE, support);
}
private IrisDecorator pick(IrisDecorator[] bucket, RNG partRng, Column column, IrisDecorationPart part) {
IrisDecorator picked = null;
long choice = seed + part.ordinal();
int count = 0;
for (IrisDecorator decorator : bucket) {
if (!decorator.passesChanceGate(partRng, column.x, column.z, column.data)) {
continue;
}
count++;
choice = mix(choice ^ mix(column.x * 0x632be59bd9b4e019L + column.z * 0x8cb92ba72f3d8dd7L + count));
if (count == 1 || Long.remainderUnsigned(choice, count) == 0L) {
picked = decorator;
}
}
if (picked == null || picked.isForcePlace() || picked.getSlopeCondition().isDefault()
|| picked.getSlopeCondition().isValid(column.water.slope(column.x, column.z))) {
return picked;
}
return null;
}
static boolean still(Water water, int x, int z, int head) {
for (int[] side : SIDES) {
int neighbourX = x + side[0];
int neighbourZ = z + side[1];
int neighbourHead = water.head(neighbourX, neighbourZ);
if (neighbourHead != head && water.ground(neighbourX, neighbourZ) < neighbourHead) {
return false;
}
}
return true;
}
static boolean isWaterSource(NativeBlockState state) {
if (state == null || !state.isWater()) {
return false;
}
String level = IrisProceduralBlocks.propertyValue(state, "level");
return level == null || level.equals("0");
}
static boolean holdsWater(NativeBlockState state) {
return B.isFluid(state) || B.isWaterLogged(state) || IrisSurfaceDecorator.isAquaticPlacement(state);
}
static boolean plannedWater(Water water, int x, int z, int y) {
int ground = water.ground(x, z);
int top = water.head(x, z);
if (y <= ground || top <= ground) {
return false;
}
for (int[] side : SIDES) {
int neighbourHead = water.head(x + side[0], z + side[1]);
if (neighbourHead > top && water.ground(x + side[0], z + side[1]) < neighbourHead) {
top = neighbourHead;
}
}
return y <= top;
}
private static boolean twoBlock(NativeBlockState state) {
String half = IrisProceduralBlocks.propertyValue(state, "half");
return "lower".equals(half) || "upper".equals(half);
}
private static long mix(long value) {
long mixed = (value + GOLDEN ^ (value + GOLDEN >>> 30)) * 0xbf58476d1ce4e5b9L;
mixed = (mixed ^ (mixed >>> 27)) * 0x94d049bb133111ebL;
return mixed ^ (mixed >>> 31);
}
public interface Water {
int ground(int x, int z);
int head(int x, int z);
double slope(int x, int z);
Decorators decorators(int x, int z);
}
private enum Placement {
NONE,
SHORE,
FLOOR,
STILL
}
private enum Slot {
FLOOR,
SURFACE,
SHORE
}
private final class Column {
private final Hunk<NativeBlockState> output;
private final int localX;
private final int localZ;
private final int x;
private final int z;
private final int ground;
private final Water water;
private final IrisData data;
private Column(Hunk<NativeBlockState> output, int localX, int localZ, int x, int z, int ground, Water water, IrisData data) {
this.output = output;
this.localX = localX;
this.localZ = localZ;
this.x = x;
this.z = z;
this.ground = ground;
this.water = water;
this.data = data;
}
private boolean accepts(IrisDecorator decorator, NativeBlockState support) {
return decorator.isForcePlace() || DecoratorCore.allowsSurface(decorator, support, data);
}
private int stack(IrisDecorator decorator, RNG rng, int room) {
return decorator.isStacking() ? Math.min(room, DecoratorCore.computeStack(decorator, rng, x, z, data, room)) : 1;
}
private void place(IrisDecorator decorator, RNG rng, int baseY, int room, Slot slot, NativeBlockState support) {
NativeBlockState first = decorator.pickBlockData(rng, data, x, z);
if (first == null) {
return;
}
if (twoBlock(first)) {
if (room >= 2) {
placePair(decorator, first, baseY, slot, support);
}
return;
}
int stack = stack(decorator, rng, room);
NativeBlockState sampled = first;
boolean sampledTop = false;
for (int index = 0; index < stack; index++) {
int y = baseY + index;
boolean top = (stack == 1 ? 1D : (double) index / (stack - 1)) >= decorator.getTopThreshold();
if (top != sampledTop) {
sampled = top ? decorator.pickBlockDataTop(rng, data, x, z) : decorator.pickBlockData(rng, data, x, z);
sampledTop = top;
}
NativeBlockState block = sampled == null ? null : fit(sampled, y, slot, decorator, index == 0 ? support : null);
if (block == null) {
return;
}
output.set(localX, y, localZ, block);
}
}
private void placePair(IrisDecorator decorator, NativeBlockState block, int y, Slot slot, NativeBlockState support) {
NativeBlockState lower = fit(block.withProperty("half", "lower"), y, slot, decorator, support);
NativeBlockState upper = lower == null ? null : fit(block.withProperty("half", "upper"), y + 1, slot, decorator, null);
if (upper != null) {
output.set(localX, y, localZ, lower);
output.set(localX, y + 1, localZ, upper);
}
}
private NativeBlockState fit(NativeBlockState block, int y, Slot slot, IrisDecorator decorator, NativeBlockState support) {
NativeBlockState existing = output.get(localX, y, localZ);
if (slot == Slot.FLOOR) {
NativeBlockState placed = IrisProceduralBlocks.normalizeWaterlogging(block, existing, true);
return isWaterSource(existing) && IrisSurfaceDecorator.isAquaticPlacement(placed) ? placed : null;
}
if (!B.isAir(existing) || holdsWater(block)) {
return null;
}
if (slot == Slot.SURFACE) {
return block;
}
if (besideWater(y) || support != null && !DecoratorCore.isValidShorelineSupport(decorator, block, support)) {
return null;
}
return IrisSugarCane.canPlace(block, output, localX, y, localZ, x, z, engine) ? block : null;
}
private boolean besideWater(int y) {
for (int[] side : SIDES) {
int neighbourX = localX + side[0];
int neighbourZ = localZ + side[1];
if (neighbourX >= 0 && neighbourX < output.getWidth() && neighbourZ >= 0 && neighbourZ < output.getDepth()) {
if (holdsWater(output.get(neighbourX, y, neighbourZ))) {
return true;
}
} else if (plannedWater(water, x + side[0], z + side[1], y)) {
return true;
}
}
return false;
}
}
public static final class Decorators {
public static final Decorators EMPTY = new Decorators(new IrisDecorator[0], new IrisDecorator[0], new IrisDecorator[0]);
private final IrisDecorator[] floor;
private final IrisDecorator[] surface;
private final IrisDecorator[] shore;
private Decorators(IrisDecorator[] floor, IrisDecorator[] surface, IrisDecorator[] shore) {
this.floor = floor;
this.surface = surface;
this.shore = shore;
}
public static Decorators compile(List<IrisDecorator> decorators, IrisData data, String path) {
HydrologySettings.requireDecorators(decorators, path);
if (decorators == null || decorators.isEmpty()) {
return EMPTY;
}
List<IrisDecorator> floor = new ArrayList<>();
List<IrisDecorator> surface = new ArrayList<>();
List<IrisDecorator> shore = new ArrayList<>();
for (int index = 0; index < decorators.size(); index++) {
IrisDecorator decorator = decorators.get(index);
String field = path + "[" + index + "]";
IrisDecorationPart part = decorator.getPartOf();
requirePlaced(decorator.getPalette(), data, field + ".palette", part);
requirePlaced(decorator.getTopPalette(), data, field + ".topPalette", part);
requireKnown(decorator.getWhitelist(), data, field + ".whitelist");
requireKnown(decorator.getBlacklist(), data, field + ".blacklist");
switch (part) {
case SEA_FLOOR -> floor.add(decorator);
case SEA_SURFACE -> surface.add(decorator);
default -> shore.add(decorator);
}
}
return new Decorators(floor.toArray(new IrisDecorator[0]), surface.toArray(new IrisDecorator[0]), shore.toArray(new IrisDecorator[0]));
}
public boolean empty() {
return floor.length == 0 && surface.length == 0 && shore.length == 0;
}
private static void requirePlaced(List<IrisBlockData> blocks, IrisData data, String path, IrisDecorationPart part) {
if (blocks == null) {
return;
}
for (int index = 0; index < blocks.size(); index++) {
String field = path + "[" + index + "]";
String name = blocks.get(index).getBlock();
NativeBlockState state = requireKnown(blocks.get(index), data, field);
if (state.isFluid()) {
throw new IllegalArgumentException(field + " names the fluid '" + name + "'; water decorators never place fluids");
}
if (part == IrisDecorationPart.SEA_FLOOR && !waterCompatible(state)) {
throw new IllegalArgumentException(field + " names '" + name + "', which cannot stand in water; SEA_FLOOR decorators place"
+ " only waterloggable or water plants such as seagrass, kelp, coral and sea pickles");
}
if (part != IrisDecorationPart.SEA_FLOOR && holdsWater(state)) {
throw new IllegalArgumentException(field + " names '" + name + "', which holds water; " + part
+ " decorators grow in air and place only blocks without water, never waterlogged blocks, seagrass, kelp, sea pickles or coral");
}
}
}
private static void requireKnown(List<IrisBlockData> blocks, IrisData data, String path) {
if (blocks == null) {
return;
}
for (int index = 0; index < blocks.size(); index++) {
requireKnown(blocks.get(index), data, path + "[" + index + "]");
}
}
private static NativeBlockState requireKnown(IrisBlockData block, IrisData data, String path) {
NativeBlockState state = block.getBlockData(data);
if (state == null || state.isAir()) {
throw new IllegalArgumentException(path + " names the block '" + block.getBlock()
+ "', which does not exist on this server; water decorators never place air");
}
return state;
}
private static boolean waterCompatible(NativeBlockState state) {
return IrisSurfaceDecorator.isAquaticPlacement(state) || IrisProceduralBlocks.propertyValue(state, "waterlogged") != null;
}
}
}
@@ -39,6 +39,7 @@ import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.collection.KSet;
import art.arcane.volmlib.util.math.BlockPosition;
import art.arcane.volmlib.util.math.RNG;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@@ -57,6 +58,7 @@ import lombok.experimental.Accessors;
public class IrisDepositGenerator {
private static final int FOOTPRINT_MAXIMUM_SIZE = 64;
private static final ThreadLocal<FootprintCells> FOOTPRINT_CELLS = ThreadLocal.withInitial(FootprintCells::new);
private static final double[][] NODE_SINES = nodeSines();
private static final long FNV_OFFSET_BASIS = 0xcbf29ce484222325L;
private static final long FNV_PRIME = 0x100000001b3L;
@@ -282,28 +284,52 @@ public class IrisDepositGenerator {
* only for clumps it keeps. Returns null without drawing when the shape has no footprint path.
*/
public ClumpFootprint sampleClumpFootprint(RNG rng, IrisData rdata) {
if (shape != IrisDepositShape.VANILLA_ELLIPSOID || minSize > FOOTPRINT_MAXIMUM_SIZE
|| maxSize > FOOTPRINT_MAXIMUM_SIZE) {
return null;
}
int paletteSize = getBlockData(rdata).size();
if (paletteSize == 0) {
if (!usesClumpFootprints(rdata)) {
return null;
}
KList<NativeBlockState> resolvedPalette = getBlockData(rdata);
int paletteSize = resolvedPalette.size();
int size = rng.i(minSize, maxSize + 1);
if (size <= 0) {
return ClumpFootprint.EMPTY;
}
FootprintCells cells = FOOTPRINT_CELLS.get();
cells.reset();
vanillaEllipsoidCells(rng, size, cells);
if (cells.count == 0) {
return ClumpFootprint.EMPTY;
if (paletteSize == 1) {
emitEllipsoidRows(rng, size, cells);
if (cells.count == 0) {
return ClumpFootprint.EMPTY;
}
skipUniformPicks(rng, cells.count);
} else {
emitEllipsoidCells(rng, size, cells, cells.scratch);
if (cells.count == 0) {
return ClumpFootprint.EMPTY;
}
for (int cell = 0; cell < cells.count; cell++) {
cells.picks[cell] = rng.i(0, paletteSize);
}
}
for (int cell = 0; cell < cells.count; cell++) {
rng.i(0, paletteSize);
return new ClumpFootprint(cells.extentX * 2 + 1, cells.minY, cells, cells.stamp, resolvedPalette);
}
public boolean usesClumpFootprints(IrisData rdata) {
return shape == IrisDepositShape.VANILLA_ELLIPSOID && minSize <= FOOTPRINT_MAXIMUM_SIZE
&& maxSize <= FOOTPRINT_MAXIMUM_SIZE && !getBlockData(rdata).isEmpty();
}
public static int lowestFootprintOffset(int size) {
return -4 - (Math.max(0, size) + 15) / 16;
}
private static void skipUniformPicks(RNG rng, int count) {
if (rng instanceof DepositRandom random) {
random.skip(2L * count);
return;
}
for (int cell = 0; cell < count; cell++) {
rng.nextDouble();
}
return new ClumpFootprint(cells.extentX * 2 + 1, cells.minY, false);
}
/**
@@ -312,15 +338,87 @@ public class IrisDepositGenerator {
* test is bit-identical to evaluating {@code nx * nx + ny * ny + nz * nz} in place.
*/
static void vanillaEllipsoidCells(RNG rng, int size, CellSink sink) {
emitEllipsoidCells(rng, size, sink, new EllipsoidScratch());
}
private static void emitEllipsoidCells(RNG rng, int size, CellSink sink, EllipsoidScratch scratch) {
double[] nodes = ellipsoidNodes(rng, size, scratch);
for (int node = 0; node < size; node++) {
if (!scratch.sphere(nodes, node)) {
continue;
}
double[] squaredY = scratch.squaredY;
double[] squaredZ = scratch.squaredZ;
for (int x = scratch.minX; x <= scratch.maxX; x++) {
double nx = (x + 0.5D - scratch.centerX) / scratch.radius;
double squaredX = nx * nx;
if (squaredX >= 1D) {
continue;
}
for (int y = 0; y < scratch.spanY; y++) {
double squaredXY = squaredX + squaredY[y];
if (squaredXY >= 1D) {
continue;
}
for (int z = 0; z < scratch.spanZ; z++) {
if (squaredXY + squaredZ[z] < 1D) {
sink.add(x, scratch.minY + y, scratch.minZ + z);
}
}
}
}
}
}
private static void emitEllipsoidRows(RNG rng, int size, FootprintCells cells) {
EllipsoidScratch scratch = cells.scratch;
double[] nodes = ellipsoidNodes(rng, size, scratch);
for (int node = 0; node < size; node++) {
if (!scratch.sphere(nodes, node)) {
continue;
}
double[] squaredY = scratch.squaredY;
double[] squaredZ = scratch.squaredZ;
int zShift = scratch.minZ + FootprintCells.OFFSET;
for (int x = scratch.minX; x <= scratch.maxX; x++) {
double nx = (x + 0.5D - scratch.centerX) / scratch.radius;
double squaredX = nx * nx;
if (squaredX >= 1D) {
continue;
}
for (int y = 0; y < scratch.spanY; y++) {
double squaredXY = squaredX + squaredY[y];
if (squaredXY >= 1D) {
continue;
}
long row = 0L;
for (int z = 0; z < scratch.spanZ; z++) {
if (squaredXY + squaredZ[z] < 1D) {
row |= 1L << (zShift + z);
}
}
if (row != 0L) {
cells.addRow(x, scratch.minY + y, row);
}
}
}
}
cells.finishRows();
}
private static double[] ellipsoidNodes(RNG rng, int size, EllipsoidScratch scratch) {
float angle = rng.nextFloat() * (float) Math.PI;
float reach = size / 8F;
double startX = Math.sin(angle) * reach;
double endX = -Math.sin(angle) * reach;
double startZ = Math.cos(angle) * reach;
double endZ = -Math.cos(angle) * reach;
double sine = Math.sin(angle);
double cosine = Math.cos(angle);
double startX = sine * reach;
double endX = -sine * reach;
double startZ = cosine * reach;
double endZ = -cosine * reach;
double startY = rng.nextInt(3) - 2;
double endY = rng.nextInt(3) - 2;
double[] nodes = new double[size * 4];
double[] nodes = scratch.nodes(size * 4);
double[] sines = size < NODE_SINES.length ? NODE_SINES[size] : null;
for (int i = 0; i < size; i++) {
float progress = (float) i / size;
@@ -328,7 +426,8 @@ public class IrisDepositGenerator {
nodes[i * 4] = startX + (endX - startX) * progress;
nodes[i * 4 + 1] = startY + (endY - startY) * progress;
nodes[i * 4 + 2] = startZ + (endZ - startZ) * progress;
nodes[i * 4 + 3] = ((Math.sin(Math.PI * progress) + 1D) * radiusNoise + 1D) / 2D;
double arc = sines == null ? Math.sin(Math.PI * progress) : sines[i];
nodes[i * 4 + 3] = ((arc + 1D) * radiusNoise + 1D) / 2D;
}
for (int i = 0; i < size - 1; i++) {
@@ -353,59 +452,20 @@ public class IrisDepositGenerator {
}
}
}
return nodes;
}
double[] squaredY = new double[16];
double[] squaredZ = new double[16];
for (int i = 0; i < size; i++) {
double radius = nodes[i * 4 + 3];
if (radius < 0D) {
continue;
}
double centerX = nodes[i * 4];
double centerY = nodes[i * 4 + 1];
double centerZ = nodes[i * 4 + 2];
int minX = (int) Math.floor(centerX - radius);
int maxX = Math.max((int) Math.floor(centerX + radius), minX);
int minY = (int) Math.floor(centerY - radius);
int maxY = Math.max((int) Math.floor(centerY + radius), minY);
int minZ = (int) Math.floor(centerZ - radius);
int maxZ = Math.max((int) Math.floor(centerZ + radius), minZ);
int spanY = maxY - minY + 1;
int spanZ = maxZ - minZ + 1;
if (spanY > squaredY.length) {
squaredY = new double[spanY];
}
if (spanZ > squaredZ.length) {
squaredZ = new double[spanZ];
}
for (int y = 0; y < spanY; y++) {
double ny = (minY + y + 0.5D - centerY) / radius;
squaredY[y] = ny * ny;
}
for (int z = 0; z < spanZ; z++) {
double nz = (minZ + z + 0.5D - centerZ) / radius;
squaredZ[z] = nz * nz;
}
for (int x = minX; x <= maxX; x++) {
double nx = (x + 0.5D - centerX) / radius;
double squaredX = nx * nx;
if (squaredX >= 1D) {
continue;
}
for (int y = 0; y < spanY; y++) {
double squaredXY = squaredX + squaredY[y];
if (squaredXY >= 1D) {
continue;
}
for (int z = 0; z < spanZ; z++) {
if (squaredXY + squaredZ[z] < 1D) {
sink.add(x, minY + y, minZ + z);
}
}
}
private static double[][] nodeSines() {
double[][] sines = new double[FOOTPRINT_MAXIMUM_SIZE + 1][];
for (int size = 1; size <= FOOTPRINT_MAXIMUM_SIZE; size++) {
double[] values = new double[size];
for (int i = 0; i < size; i++) {
float progress = (float) i / size;
values[i] = Math.sin(Math.PI * progress);
}
sines[size] = values;
}
return sines;
}
IrisObject generateVanillaScattered(RNG rng, IrisData rdata, int size) {
@@ -630,8 +690,134 @@ public class IrisDepositGenerator {
void add(int x, int y, int z);
}
public record ClumpFootprint(int width, int minY, boolean empty) {
static final ClumpFootprint EMPTY = new ClumpFootprint(1, 0, true);
public static final class ClumpFootprint {
static final ClumpFootprint EMPTY = new ClumpFootprint(1, 0, null, 0L, null);
private final int width;
private final int minY;
private final FootprintCells source;
private final long stamp;
private final KList<NativeBlockState> palette;
private ClumpFootprint(int width, int minY, FootprintCells source, long stamp, KList<NativeBlockState> palette) {
this.width = width;
this.minY = minY;
this.source = source;
this.stamp = stamp;
this.palette = palette;
}
public int width() {
return width;
}
public int minY() {
return minY;
}
public boolean empty() {
return source == null;
}
public ClumpCells cells() {
if (source == null) {
return ClumpCells.EMPTY;
}
if (source.stamp != stamp) {
throw new IllegalStateException("Deposit footprint cells were resampled before they were read");
}
return source.toCells(palette);
}
}
public static final class ClumpCells {
static final ClumpCells EMPTY = new ClumpCells(new int[0], new NativeBlockState[0], null);
private final int[] positions;
private final NativeBlockState[] blocks;
private final NativeBlockState uniform;
private ClumpCells(int[] positions, NativeBlockState[] blocks, NativeBlockState uniform) {
this.positions = positions;
this.blocks = blocks;
this.uniform = uniform;
}
public int size() {
return positions.length;
}
public int x(int cell) {
return (positions[cell] >> 12) - FootprintCells.OFFSET;
}
public int y(int cell) {
return ((positions[cell] >> 6) & (FootprintCells.AXIS - 1)) - FootprintCells.OFFSET;
}
public int z(int cell) {
return (positions[cell] & (FootprintCells.AXIS - 1)) - FootprintCells.OFFSET;
}
public NativeBlockState block(int cell) {
return blocks == null ? uniform : blocks[cell];
}
}
private static final class EllipsoidScratch {
private double[] nodes = new double[256];
private double[] squaredY = new double[16];
private double[] squaredZ = new double[16];
private double centerX;
private double radius;
private int minX;
private int maxX;
private int minY;
private int spanY;
private int minZ;
private int spanZ;
private double[] nodes(int length) {
if (nodes.length < length) {
nodes = new double[length];
}
return nodes;
}
private boolean sphere(double[] values, int node) {
double sphereRadius = values[node * 4 + 3];
if (sphereRadius < 0D) {
return false;
}
double sphereX = values[node * 4];
double sphereY = values[node * 4 + 1];
double sphereZ = values[node * 4 + 2];
minX = (int) Math.floor(sphereX - sphereRadius);
maxX = Math.max((int) Math.floor(sphereX + sphereRadius), minX);
minY = (int) Math.floor(sphereY - sphereRadius);
int maxY = Math.max((int) Math.floor(sphereY + sphereRadius), minY);
minZ = (int) Math.floor(sphereZ - sphereRadius);
int maxZ = Math.max((int) Math.floor(sphereZ + sphereRadius), minZ);
spanY = maxY - minY + 1;
spanZ = maxZ - minZ + 1;
if (spanY > squaredY.length) {
squaredY = new double[spanY];
}
if (spanZ > squaredZ.length) {
squaredZ = new double[spanZ];
}
for (int y = 0; y < spanY; y++) {
double ny = (minY + y + 0.5D - sphereY) / sphereRadius;
squaredY[y] = ny * ny;
}
for (int z = 0; z < spanZ; z++) {
double nz = (minZ + z + 0.5D - sphereZ) / sphereRadius;
squaredZ[z] = nz * nz;
}
centerX = sphereX;
radius = sphereRadius;
return true;
}
}
private static final class FootprintCells implements CellSink {
@@ -639,10 +825,16 @@ public class IrisDepositGenerator {
private static final int AXIS = 64;
private final long[] occupied = new long[AXIS * AXIS * AXIS / 64];
private final int[] touched = new int[AXIS * AXIS * AXIS / 64];
private final EllipsoidScratch scratch = new EllipsoidScratch();
private int[] order = new int[256];
private int[] picks = new int[256];
private long[] sortKeys = new long[256];
private int touchedCount;
private int count;
private int extentX;
private int minY;
private boolean uniform;
private long stamp;
private void reset() {
for (int index = 0; index < touchedCount; index++) {
@@ -652,6 +844,8 @@ public class IrisDepositGenerator {
count = 0;
extentX = 0;
minY = Integer.MAX_VALUE;
uniform = false;
stamp++;
}
@Override
@@ -667,9 +861,72 @@ public class IrisDepositGenerator {
touched[touchedCount++] = word;
}
occupied[word] = current | bit;
count++;
if (count == order.length) {
order = Arrays.copyOf(order, count * 2);
picks = Arrays.copyOf(picks, count * 2);
sortKeys = new long[count * 2];
}
order[count++] = index;
extentX = Math.max(extentX, Math.abs(x));
minY = Math.min(minY, y);
}
private void addRow(int x, int y, long row) {
int word = (x + OFFSET) * AXIS + (y + OFFSET);
long current = occupied[word];
if (current == 0L) {
touched[touchedCount++] = word;
}
occupied[word] = current | row;
}
private void finishRows() {
int cells = 0;
int extent = 0;
int lowest = Integer.MAX_VALUE;
for (int index = 0; index < touchedCount; index++) {
int word = touched[index];
cells += Long.bitCount(occupied[word]);
extent = Math.max(extent, Math.abs(word / AXIS - OFFSET));
lowest = Math.min(lowest, word % AXIS - OFFSET);
}
count = cells;
extentX = extent;
minY = lowest;
uniform = true;
}
private ClumpCells toCells(KList<NativeBlockState> palette) {
if (uniform) {
return toUniformCells(palette.get(0));
}
for (int cell = 0; cell < count; cell++) {
sortKeys[cell] = ((long) order[cell] << 32) | picks[cell];
}
Arrays.sort(sortKeys, 0, count);
int[] positions = new int[count];
NativeBlockState[] blocks = new NativeBlockState[count];
for (int cell = 0; cell < count; cell++) {
long key = sortKeys[cell];
positions[cell] = (int) (key >>> 32);
blocks[cell] = palette.get((int) key);
}
return new ClumpCells(positions, blocks, null);
}
private ClumpCells toUniformCells(NativeBlockState block) {
Arrays.sort(touched, 0, touchedCount);
int[] positions = new int[count];
int cell = 0;
for (int index = 0; index < touchedCount; index++) {
int word = touched[index];
long row = occupied[word];
while (row != 0L) {
positions[cell++] = (word << 6) | Long.numberOfTrailingZeros(row);
row &= row - 1L;
}
}
return new ClumpCells(positions, null, block);
}
}
}
@@ -1,5 +1,6 @@
package art.arcane.iris.generation.decoration;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.pack.value.IrisRange;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
@@ -79,6 +80,14 @@ public final class IrisOreBands {
return minY <= maxY;
}
public int lowestY() {
return minY;
}
public int highestY() {
return maxY;
}
public boolean contains(int y) {
return y >= minY && y <= maxY;
}
@@ -86,10 +95,10 @@ public final class IrisOreBands {
/**
* The ore placed at a Y inside {@link #contains(int)}, or null when no generator places one.
*/
public NativeBlockState generate(int x, int y, int z, RNG rng, IrisData data) {
public NativeBlockState generate(int x, int y, int z, RNG rng, IrisData data, Engine engine) {
IrisOreGenerator[] candidates = byY == null ? generators : byY[y - minY];
for (IrisOreGenerator candidate : candidates) {
NativeBlockState ore = candidate.generate(x, y, z, rng, data);
NativeBlockState ore = candidate.generate(x, y, z, rng, data, engine);
if (ore != null) {
return ore;
}
@@ -65,6 +65,10 @@ public class IrisOreGenerator {
}
public NativeBlockState generate(int x, int y, int z, RNG rng, IrisData data) {
return generate(x, y, z, rng, data, data == null ? null : data.getEngine());
}
public NativeBlockState generate(int x, int y, int z, RNG rng, IrisData data, Engine engine) {
if (threshold <= 0 || !range.contains(y)) {
return null;
}
@@ -73,7 +77,7 @@ public class IrisOreGenerator {
return null;
}
if (chance(rng, data).sample(x, y, z) > threshold) {
if (chance(rng, data, engine).sample(x, y, z) > threshold) {
return null;
}
@@ -81,8 +85,11 @@ public class IrisOreGenerator {
}
private OreChance chance(RNG rng, IrisData data) {
return chance(rng, data, data == null ? null : data.getEngine());
}
private OreChance chance(RNG rng, IrisData data, Engine engine) {
long seed = rng.getSeed();
Engine engine = data == null ? null : data.getEngine();
CachedChance recent = recentChance;
if (recent != null && recent.key.seed == seed && recent.key.data == data && recent.key.engine == engine) {
return recent.chance;
@@ -2,8 +2,6 @@ package art.arcane.iris.generation.decoration;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.hydrology.HydrologyColumnLayer;
import art.arcane.iris.generation.hydrology.HydrologyColumnSample;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.volmlib.util.hunk.Hunk;
@@ -71,21 +69,18 @@ final class IrisSugarCane {
private static boolean plannedWater(Engine engine, int x, int y, int z) {
IrisComplex complex = engine.getComplex();
HydrologyColumnSample sample = complex.sampleHydrologyColumn(x, z);
HydrologyColumnLayer fluid = sample == null ? null : sample.primarySurfaceFluidLayerOrNull();
if (fluid != null) {
return y > fluid.bedY() && y <= fluid.fluidHeadY()
&& supportsAdjacent(complex.resolveHydrologyFluid(fluid.profileKey(), x, z));
}
int terrainHeight = (int) Math.round(complex.getHeightStream().getDouble(x, z));
int fluidHeight = (int) Math.round(complex.getRiverWaterSurfaceStream().getDouble(x, z));
int fluidHeight = (int) Math.round(complex.getWaterHeadStream().getDouble(x, z));
if (y <= terrainHeight || y > fluidHeight) {
return false;
}
if (complex.getWaterRoleStream().get(x, z).wet()) {
return supportsAdjacent(complex.resolveWaterFluid(x, z));
}
IrisBiome biome = complex.getTrueBiomeStream().get(x, z);
KList<NativeBlockState> layers = biome.generateSeaLayers(x, z,
new RNG(engine.getSeedManager().getTerrain()), fluidHeight - terrainHeight, engine.getData());
int depth = fluidHeight - y;
return supportsAdjacent(layers.hasIndex(depth) ? layers.get(depth) : complex.resolveSurfaceFluid(x, z));
return supportsAdjacent(layers.hasIndex(depth) ? layers.get(depth) : complex.resolveSeaFluid(x, z));
}
}
@@ -1,35 +0,0 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.hydrology.cave.CavePosition;
record CaveCandidateBounds(
int entryX,
int entryY,
int entryZ,
int waterHeadY,
int horizontalRadius,
int maximumDepth,
int dryHeadroom
) {
boolean containsColumn(int x, int z) {
long deltaX = (long) x - entryX;
long deltaZ = (long) z - entryZ;
long radiusSquared = (long) horizontalRadius * horizontalRadius;
return deltaX * deltaX + deltaZ * deltaZ <= radiusSquared;
}
int minimumY() {
return Math.toIntExact((long) entryY - maximumDepth);
}
int maximumY() {
return Math.max(entryY, Math.addExact(waterHeadY, dryHeadroom + 1));
}
boolean contains(CavePosition position) {
if (!containsColumn(position.x(), position.z())) {
return false;
}
return position.y() >= minimumY() && position.y() <= maximumY();
}
}
@@ -1,202 +0,0 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.hydrology.cave.CavePosition;
import art.arcane.iris.generation.hydrology.cave.CavePositionIndex;
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveAction;
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCandidate;
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveFluidPolicy;
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveMode;
import art.arcane.iris.generation.hydrology.cave.HydrologyCavePlannerSettings;
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveSource;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
final class CaveCandidateBuilder {
private final RiverCourse course;
private final HydraulicSegment representative;
private final LinkedHashMap<CavePosition, HydrologyCaveAction> actions;
private final LinkedHashSet<CavePosition> openings;
private final CavePositionIndex openingIndex;
private final List<CaveSurfaceOpening> surfaceOpenings;
private int minimumX;
private int maximumX;
private int minimumY;
private int maximumY;
private int minimumZ;
private int maximumZ;
CaveCandidateBuilder(RiverCourse course, HydraulicSegment representative) {
this.course = course;
this.representative = representative;
this.actions = new LinkedHashMap<>();
this.openings = new LinkedHashSet<>();
this.openingIndex = new CavePositionIndex();
this.surfaceOpenings = HydrologyCaveCourseFilter.surfaceOpenings(course);
this.minimumX = Integer.MAX_VALUE;
this.maximumX = Integer.MIN_VALUE;
this.minimumY = Integer.MAX_VALUE;
this.maximumY = Integer.MIN_VALUE;
this.minimumZ = Integer.MAX_VALUE;
this.maximumZ = Integer.MIN_VALUE;
}
RiverCourse course() {
return course;
}
private HydraulicSegment representative() {
return representative;
}
boolean isEmpty() {
return actions.isEmpty();
}
void addAction(CavePosition position, HydrologyCaveAction action) {
HydrologyCaveAction existing = actions.get(position);
if (existing == null || actionPriority(action) < actionPriority(existing)) {
actions.put(position, action);
}
include(position);
}
void addOpening(CavePosition position) {
if (openingIndex.add(position.x(), position.y(), position.z())) {
openings.add(position);
}
include(position);
}
void addOpening(int x, int y, int z) {
if (openingIndex.add(x, y, z)) {
openings.add(new CavePosition(x, y, z));
}
include(x, y, z);
}
void addOpeningNeighborhood(CavePosition position) {
addOpening(position);
for (int[] offset : HydrologyCaveCourseFilter.NEIGHBORS) {
addOpening(
position.x() + offset[0],
position.y() + offset[1],
position.z() + offset[2]
);
}
}
void addAdjacentSurfaceOpenings(
HydrologyFootprintCompiler.ValidationRaster validation
) {
ArrayList<CavePosition> actionPositions = new ArrayList<>(actions.keySet());
for (CavePosition position : actionPositions) {
for (int[] offset : HydrologyCaveCourseFilter.HORIZONTAL_NEIGHBORS) {
int neighborX = position.x() + offset[0];
int neighborZ = position.z() + offset[1];
if (actions.containsKey(new CavePosition(neighborX, position.y(), neighborZ))) {
continue;
}
HydrologyColumnSample neighbor = validation.surfaceColumnAt(neighborX, neighborZ);
if (neighbor == null) {
continue;
}
for (HydrologyColumnLayer layer : neighbor.layers()) {
if (layer.feature().courseId() == course.id() && layer.channel() && layer.fluidOwned()
&& layer.bedY() < position.y() && position.y() <= layer.fluidHeadY()) {
addOpening(neighborX, position.y(), neighborZ);
break;
}
}
}
}
}
void addSurfaceOpening(HydrologyColumnLayer layer, CavePosition position) {
for (CaveSurfaceOpening opening : surfaceOpenings) {
if (!opening.matches(layer, position)) {
continue;
}
if (opening.includeNeighborhood()) {
addOpening(position);
for (int[] offset : HydrologyCaveCourseFilter.NEIGHBORS) {
int y = position.y() + offset[1];
if (y >= opening.minimumY()) {
addOpening(position.x() + offset[0], y, position.z() + offset[2]);
}
}
} else {
addOpening(position);
}
}
}
private void include(CavePosition position) {
include(position.x(), position.y(), position.z());
}
private void include(int x, int y, int z) {
minimumX = Math.min(minimumX, x);
maximumX = Math.max(maximumX, x);
minimumY = Math.min(minimumY, y);
maximumY = Math.max(maximumY, y);
minimumZ = Math.min(minimumZ, z);
maximumZ = Math.max(maximumZ, z);
}
HydrologyCaveCandidate build(HydrologyCaveCourseFilter.Options options) {
HydrologyPoint start = representative.start();
int waterHead = representative.upstreamHeadY();
int entryY = Math.max(waterHead, maximumY);
CavePosition entry = new CavePosition(start.x(), entryY, start.z());
CavePosition target = new CavePosition(start.x(), waterHead, start.z());
int horizontalRadius = HydrologyCaveCourseFilter.horizontalRadius(
entry.x(),
entry.z(),
minimumX,
maximumX,
minimumZ,
maximumZ
);
int maximumDepth = Math.max(1, entryY - minimumY + 2);
int dryHeadroom = Math.max(0, maximumY - waterHead);
int volume = Math.max(1, actions.size());
HydrologyCavePlannerSettings settings = new HydrologyCavePlannerSettings(
horizontalRadius,
maximumDepth,
volume,
1,
1,
dryHeadroom,
HydrologyCaveFluidPolicy.REJECT_EXISTING,
horizontalRadius,
maximumDepth
);
return new HydrologyCaveCandidate(
new HydrologyCaveSource(
course.id(),
entry,
target,
waterHead,
HydrologyCaveMode.GENERATED_GROTTO
),
course.profileKey(),
settings,
options.connectToExistingCaves() && course.type() == RiverCourseType.UNDERGROUND,
actions,
openings
);
}
private static int actionPriority(HydrologyCaveAction action) {
return switch (action) {
case WET_SOURCE -> 0;
case FALLING_FLUID -> 1;
case DRY_AIR -> 2;
case SEAL_GUARD -> 3;
};
}
}
@@ -1,395 +0,0 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.hydrology.cave.CavePosition;
import art.arcane.iris.generation.hydrology.cave.CaveVoxelView;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
final class CaveCandidateSpanBuilder {
private final RiverCourse course;
private final HydraulicSegment representative;
private final LinkedHashMap<Long, CaveSpanSet> actions;
private final LinkedHashMap<Long, CaveSpanSet> openings;
private final LinkedHashMap<Long, CaveSpanSet> coastalGrottoActions;
private final LinkedHashMap<Long, CaveSpanSet> inlandGrottoActions;
private final List<CaveSurfaceOpening> surfaceOpenings;
private int minimumX;
private int maximumX;
private int minimumY;
private int maximumY;
private int minimumZ;
private int maximumZ;
CaveCandidateSpanBuilder(RiverCourse course, HydraulicSegment representative) {
this.course = course;
this.representative = representative;
this.actions = new LinkedHashMap<>();
this.openings = new LinkedHashMap<>();
this.coastalGrottoActions = new LinkedHashMap<>();
this.inlandGrottoActions = new LinkedHashMap<>();
this.surfaceOpenings = HydrologyCaveCourseFilter.surfaceOpenings(course);
this.minimumX = Integer.MAX_VALUE;
this.maximumX = Integer.MIN_VALUE;
this.minimumY = Integer.MAX_VALUE;
this.maximumY = Integer.MIN_VALUE;
this.minimumZ = Integer.MAX_VALUE;
this.maximumZ = Integer.MIN_VALUE;
}
RiverCourse course() {
return course;
}
HydraulicSegment representative() {
return representative;
}
boolean isEmpty() {
return actions.isEmpty();
}
boolean allowsIntentionalSurfaceExposure() {
return course.surfaceSinkholeContinuation();
}
void addAction(int x, int z, HydrologyColumnLayer layer) {
int minimumActionY = layer.bedY() + 1;
int maximumActionY = layer.ceilingY();
if (minimumActionY > maximumActionY) {
return;
}
addSpan(actions, x, z, minimumActionY, maximumActionY);
if (layer.feature().type() == HydrologyFeatureType.COASTAL_GROTTO) {
addSpan(coastalGrottoActions, x, z, minimumActionY, maximumActionY);
} else if (layer.feature().type() == HydrologyFeatureType.INLAND_GROTTO) {
addSpan(inlandGrottoActions, x, z, minimumActionY, maximumActionY);
}
include(x, minimumActionY, z);
include(x, maximumActionY, z);
for (CaveSurfaceOpening opening : surfaceOpenings) {
if (!opening.matchesColumn(layer, x, z)) {
continue;
}
int minimumOpeningY = Math.max(minimumActionY, opening.minimumY());
if (minimumOpeningY > maximumActionY) {
continue;
}
if (!opening.includeNeighborhood()) {
addOpening(x, z, minimumOpeningY, maximumActionY);
continue;
}
addOpening(x, z, Math.max(minimumOpeningY - 1, opening.minimumY()), maximumActionY + 1);
for (int[] offset : HydrologyCaveCourseFilter.HORIZONTAL_NEIGHBORS) {
addOpening(x + offset[0], z + offset[1], minimumOpeningY, maximumActionY);
}
}
}
void addOceanOpening(int x, int z, int minimumOpeningY, int maximumOpeningY) {
if (minimumOpeningY <= maximumOpeningY) {
addOpening(x, z, minimumOpeningY, maximumOpeningY);
}
}
void addOpeningNeighborhood(
int x,
int z,
int minimumOpeningY,
int maximumOpeningY
) {
if (minimumOpeningY > maximumOpeningY) {
return;
}
addOpening(x, z, minimumOpeningY - 1, maximumOpeningY + 1);
for (int[] offset : HydrologyCaveCourseFilter.HORIZONTAL_NEIGHBORS) {
addOpening(x + offset[0], z + offset[1], minimumOpeningY, maximumOpeningY);
}
}
void addAdjacentSurfaceOpenings(
HydrologyFootprintCompiler.ValidationRaster validation
) {
for (Map.Entry<Long, CaveSpanSet> entry : actions.entrySet()) {
int x = RiverFootprint.unpackX(entry.getKey());
int z = RiverFootprint.unpackZ(entry.getKey());
for (int[] offset : HydrologyCaveCourseFilter.HORIZONTAL_NEIGHBORS) {
int neighborX = x + offset[0];
int neighborZ = z + offset[1];
HydrologyColumnSample neighbor = validation.surfaceColumnAt(neighborX, neighborZ);
if (neighbor == null) {
continue;
}
for (HydrologyColumnLayer layer : neighbor.layers()) {
if (layer.feature().courseId() != course.id() || !layer.channel() || !layer.fluidOwned()) {
continue;
}
for (CaveYSpan span : entry.getValue().spans()) {
int minimumY = Math.max(span.minimumY(), layer.bedY() + 1);
int maximumY = Math.min(span.maximumY(), layer.fluidHeadY());
if (minimumY <= maximumY) {
addAdjacentFluidOpening(neighborX, neighborZ, minimumY, maximumY);
}
}
}
}
}
}
private void addAdjacentFluidOpening(int x, int z, int minimumY, int maximumY) {
CaveSpanSet owned = actions.get(RiverFootprint.pack(x, z));
if (owned == null) {
addOpening(x, z, minimumY, maximumY);
return;
}
int cursor = minimumY;
for (CaveYSpan span : owned.spans()) {
if (span.maximumY() < cursor) {
continue;
}
if (span.minimumY() > maximumY) {
break;
}
if (cursor < span.minimumY()) {
addOpening(x, z, cursor, Math.min(maximumY, span.minimumY() - 1));
}
cursor = Math.max(cursor, span.maximumY() + 1);
if (cursor > maximumY) {
return;
}
}
addOpening(x, z, cursor, maximumY);
}
HydrologyFeatureType oversizedGrotto(HydrologyCaveCourseFilter.Options options) {
if (positionCount(coastalGrottoActions)
> options.maximumVolume(HydrologyFeatureType.COASTAL_GROTTO)) {
return HydrologyFeatureType.COASTAL_GROTTO;
}
if (positionCount(inlandGrottoActions)
> options.maximumVolume(HydrologyFeatureType.INLAND_GROTTO)) {
return HydrologyFeatureType.INLAND_GROTTO;
}
return null;
}
long positionCount() {
return positionCount(actions);
}
boolean exposed(CaveVoxelView view) {
CaveCandidateBounds bounds = bounds();
for (Map.Entry<Long, CaveSpanSet> entry : actions.entrySet()) {
int x = RiverFootprint.unpackX(entry.getKey());
int z = RiverFootprint.unpackZ(entry.getKey());
CaveSpanSet actionSpans = entry.getValue();
CaveSpanSet openingSpans = openings.get(entry.getKey());
if (exposedDifference(actionSpans, null, openingSpans, x, z, bounds, view)) {
return true;
}
for (CaveYSpan span : actionSpans.spans()) {
int lowerBoundary = span.minimumY() - 1;
if (!actionSpans.contains(lowerBoundary)
&& !contains(openingSpans, lowerBoundary)
&& exposed(x, lowerBoundary, z, bounds, view)) {
return true;
}
int upperBoundary = span.maximumY() + 1;
if (!actionSpans.contains(upperBoundary)
&& !contains(openingSpans, upperBoundary)
&& exposed(x, upperBoundary, z, bounds, view)) {
return true;
}
}
for (int[] offset : HydrologyCaveCourseFilter.HORIZONTAL_NEIGHBORS) {
int neighborX = x + offset[0];
int neighborZ = z + offset[1];
long neighborKey = RiverFootprint.pack(neighborX, neighborZ);
if (exposedDifference(
actionSpans,
actions.get(neighborKey),
openings.get(neighborKey),
neighborX,
neighborZ,
bounds,
view
)) {
return true;
}
}
}
return false;
}
private boolean exposedDifference(
CaveSpanSet source,
CaveSpanSet excludedActions,
CaveSpanSet excludedOpenings,
int x,
int z,
CaveCandidateBounds bounds,
CaveVoxelView view
) {
for (CaveYSpan span : source.spans()) {
if (exposedDifference(
span,
excludedActions,
excludedOpenings,
x,
z,
bounds,
view
)) {
return true;
}
}
return false;
}
private boolean exposedDifference(
CaveYSpan source,
CaveSpanSet excludedActions,
CaveSpanSet excludedOpenings,
int x,
int z,
CaveCandidateBounds bounds,
CaveVoxelView view
) {
List<CaveYSpan> actionSpans = excludedActions == null ? List.of() : excludedActions.spans();
List<CaveYSpan> openingSpans = excludedOpenings == null ? List.of() : excludedOpenings.spans();
int actionIndex = 0;
int openingIndex = 0;
long cursor = source.minimumY();
while (cursor <= source.maximumY()) {
while (actionIndex < actionSpans.size()
&& actionSpans.get(actionIndex).maximumY() < cursor) {
actionIndex++;
}
while (openingIndex < openingSpans.size()
&& openingSpans.get(openingIndex).maximumY() < cursor) {
openingIndex++;
}
CaveYSpan action = actionIndex < actionSpans.size() ? actionSpans.get(actionIndex) : null;
CaveYSpan opening = openingIndex < openingSpans.size() ? openingSpans.get(openingIndex) : null;
CaveYSpan excluded = first(action, opening);
if (excluded == null || excluded.minimumY() > source.maximumY()) {
return exposed(x, (int) cursor, source.maximumY(), z, bounds, view);
}
if (cursor < excluded.minimumY()
&& exposed(x, (int) cursor, excluded.minimumY() - 1, z, bounds, view)) {
return true;
}
cursor = Math.max(cursor, (long) excluded.maximumY() + 1L);
if (excluded == action) {
actionIndex++;
} else {
openingIndex++;
}
}
return false;
}
private CaveYSpan first(CaveYSpan first, CaveYSpan second) {
if (first == null) {
return second;
}
if (second == null) {
return first;
}
return first.minimumY() <= second.minimumY() ? first : second;
}
boolean exposed(
int x,
int minimumY,
int maximumY,
int z,
CaveCandidateBounds bounds,
CaveVoxelView view
) {
if (!bounds.containsColumn(x, z)) {
return false;
}
int boundedMinimumY = Math.max(minimumY, bounds.minimumY());
int boundedMaximumY = Math.min(maximumY, bounds.maximumY());
return boundedMinimumY <= boundedMaximumY
&& view.hasAboveTerrainSurface(x, z, boundedMinimumY, boundedMaximumY);
}
boolean exposed(
int x,
int y,
int z,
CaveCandidateBounds bounds,
CaveVoxelView view
) {
CavePosition position = new CavePosition(x, y, z);
return bounds.contains(position)
&& view.isInWorld(position)
&& view.isAboveTerrainSurface(position);
}
private CaveCandidateBounds bounds() {
HydrologyPoint start = representative.start();
int waterHead = representative.upstreamHeadY();
int entryY = Math.max(waterHead, maximumY);
int horizontalRadius = HydrologyCaveCourseFilter.horizontalRadius(
start.x(),
start.z(),
minimumX,
maximumX,
minimumZ,
maximumZ
);
int maximumDepth = Math.max(1, entryY - minimumY + 2);
int dryHeadroom = Math.max(0, maximumY - waterHead);
return new CaveCandidateBounds(
start.x(),
entryY,
start.z(),
waterHead,
horizontalRadius,
maximumDepth,
dryHeadroom
);
}
private void addOpening(int x, int z, int minimumOpeningY, int maximumOpeningY) {
addSpan(openings, x, z, minimumOpeningY, maximumOpeningY);
include(x, minimumOpeningY, z);
include(x, maximumOpeningY, z);
}
private void include(int x, int y, int z) {
minimumX = Math.min(minimumX, x);
maximumX = Math.max(maximumX, x);
minimumY = Math.min(minimumY, y);
maximumY = Math.max(maximumY, y);
minimumZ = Math.min(minimumZ, z);
maximumZ = Math.max(maximumZ, z);
}
private static void addSpan(
Map<Long, CaveSpanSet> spans,
int x,
int z,
int minimumY,
int maximumY
) {
spans.computeIfAbsent(RiverFootprint.pack(x, z), (Long ignored) -> new CaveSpanSet())
.add(minimumY, maximumY);
}
private static boolean contains(CaveSpanSet spans, int y) {
return spans != null && spans.contains(y);
}
static long positionCount(Map<Long, CaveSpanSet> spans) {
long count = 0L;
for (CaveSpanSet spanSet : spans.values()) {
count += spanSet.positionCount();
}
return count;
}
}
@@ -1,10 +0,0 @@
package art.arcane.iris.generation.hydrology;
import java.util.List;
record CaveCourseGraph(
List<DrainageNode> nodes,
List<DrainageEdge> edges,
List<RiverOutlet> outlets
) {
}
@@ -1,103 +0,0 @@
package art.arcane.iris.generation.hydrology;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
final class CaveSpanSet {
private int[] pending;
private int size;
private List<CaveYSpan> normalized;
CaveSpanSet() {
this.pending = new int[4];
this.normalized = null;
}
void add(int minimumY, int maximumY) {
if (minimumY > maximumY) {
return;
}
int requiredLength = Math.multiplyExact(size + 1, 2);
if (requiredLength > pending.length) {
pending = Arrays.copyOf(pending, Math.multiplyExact(pending.length, 2));
}
pending[size * 2] = minimumY;
pending[size * 2 + 1] = maximumY;
size++;
normalized = null;
}
List<CaveYSpan> spans() {
if (normalized != null) {
return normalized;
}
sortPending();
ArrayList<CaveYSpan> merged = new ArrayList<>(size);
for (int index = 0; index < size; index++) {
CaveYSpan span = new CaveYSpan(pending[index * 2], pending[index * 2 + 1]);
if (merged.isEmpty()) {
merged.add(span);
continue;
}
CaveYSpan previous = merged.getLast();
if ((long) span.minimumY() > (long) previous.maximumY() + 1L) {
merged.add(span);
continue;
}
merged.set(
merged.size() - 1,
new CaveYSpan(previous.minimumY(), Math.max(previous.maximumY(), span.maximumY()))
);
}
normalized = List.copyOf(merged);
return normalized;
}
private void sortPending() {
for (int index = 1; index < size; index++) {
int minimumY = pending[index * 2];
int maximumY = pending[index * 2 + 1];
int insertionIndex = index;
while (insertionIndex > 0) {
int previousMinimumY = pending[(insertionIndex - 1) * 2];
int previousMaximumY = pending[(insertionIndex - 1) * 2 + 1];
if (previousMinimumY < minimumY
|| previousMinimumY == minimumY && previousMaximumY <= maximumY) {
break;
}
pending[insertionIndex * 2] = previousMinimumY;
pending[insertionIndex * 2 + 1] = previousMaximumY;
insertionIndex--;
}
pending[insertionIndex * 2] = minimumY;
pending[insertionIndex * 2 + 1] = maximumY;
}
}
boolean contains(int y) {
List<CaveYSpan> spans = spans();
int minimumIndex = 0;
int maximumIndex = spans.size() - 1;
while (minimumIndex <= maximumIndex) {
int index = (minimumIndex + maximumIndex) >>> 1;
CaveYSpan span = spans.get(index);
if (y < span.minimumY()) {
maximumIndex = index - 1;
} else if (y > span.maximumY()) {
minimumIndex = index + 1;
} else {
return true;
}
}
return false;
}
long positionCount() {
long count = 0L;
for (CaveYSpan span : spans()) {
count += (long) span.maximumY() - span.minimumY() + 1L;
}
return count;
}
}
@@ -1,18 +0,0 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCandidate;
import java.util.Objects;
record CaveSurfaceComposition(
HydrologyCaveCandidate candidate,
boolean accepted
) {
static CaveSurfaceComposition accepted(HydrologyCaveCandidate candidate) {
return new CaveSurfaceComposition(Objects.requireNonNull(candidate), true);
}
static CaveSurfaceComposition rejected() {
return new CaveSurfaceComposition(null, false);
}
}
@@ -1,79 +0,0 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.hydrology.cave.CavePosition;
import java.util.List;
record CaveSurfaceOpening(
long segmentId,
HydrologyPoint point,
double interiorX,
double interiorZ,
long radiusSquared,
int minimumY,
boolean includeNeighborhood,
boolean surfaceFall
) {
static CaveSurfaceOpening create(
HydraulicSegment segment,
boolean start,
long radiusSquared,
int minimumY,
boolean includeNeighborhood
) {
List<HydrologyPoint> centerline = segment.centerline();
int boundaryIndex = start ? 0 : centerline.size() - 1;
int interiorIndex = start
? Math.min(1, centerline.size() - 1)
: Math.max(0, centerline.size() - 2);
HydrologyPoint point = centerline.get(boundaryIndex);
HydrologyPoint interior = centerline.get(interiorIndex);
double deltaX = interior.x() - point.x();
double deltaZ = interior.z() - point.z();
double lengthSquared = deltaX * deltaX + deltaZ * deltaZ;
double scale = 0D;
if (includeNeighborhood) {
scale = lengthSquared <= radiusSquared
? 1D
: StrictMath.sqrt(radiusSquared / lengthSquared);
}
return new CaveSurfaceOpening(
segment.id(),
point,
point.x() + deltaX * scale,
point.z() + deltaZ * scale,
radiusSquared,
minimumY,
includeNeighborhood,
segment.type().isSurface() && segment.fallingFluid()
);
}
boolean matches(HydrologyColumnLayer layer, CavePosition position) {
return matchesColumn(layer, position.x(), position.z())
&& position.y() >= minimumY;
}
boolean matchesColumn(HydrologyColumnLayer layer, int x, int z) {
if (surfaceFall) {
return layer.fallingFluid() && layer.feature().segmentId() == segmentId;
}
return (includeNeighborhood || layer.feature().segmentId() == segmentId)
&& distanceSquared(x, z) <= radiusSquared;
}
private double distanceSquared(int x, int z) {
double pathX = interiorX - point.x();
double pathZ = interiorZ - point.z();
double pathLengthSquared = pathX * pathX + pathZ * pathZ;
double positionX = x - point.x();
double positionZ = z - point.z();
double progress = pathLengthSquared == 0D
? 0D
: (positionX * pathX + positionZ * pathZ) / pathLengthSquared;
double clampedProgress = Math.max(0D, Math.min(1D, progress));
double deltaX = positionX - pathX * clampedProgress;
double deltaZ = positionZ - pathZ * clampedProgress;
return deltaX * deltaX + deltaZ * deltaZ;
}
}
@@ -1,4 +0,0 @@
package art.arcane.iris.generation.hydrology;
record CaveYSpan(int minimumY, int maximumY) {
}
@@ -1,12 +0,0 @@
package art.arcane.iris.generation.hydrology;
import java.util.List;
import java.util.Map;
record CompiledGraph(
List<DrainageNode> nodes,
List<DrainageEdge> edges,
List<RiverOutlet> outlets,
Map<Integer, DrainageEdge> edgeByUpstream
) {
}
@@ -1,9 +0,0 @@
package art.arcane.iris.generation.hydrology;
import java.util.Objects;
record CourseEdgeKey(RiverCourseType type, long edgeId) {
CourseEdgeKey {
Objects.requireNonNull(type, "type");
}
}
@@ -1,14 +0,0 @@
package art.arcane.iris.generation.hydrology;
import java.util.List;
import java.util.Objects;
record CrossTileBlockers(
List<HydrologyCrossTileCaveAdmission.RankedClaim> caveClaims,
List<HydrologyCrossTileSurfaceAdmission.RankedClaim> surfaceClaims
) {
CrossTileBlockers {
caveClaims = List.copyOf(Objects.requireNonNull(caveClaims, "caveClaims"));
surfaceClaims = List.copyOf(Objects.requireNonNull(surfaceClaims, "surfaceClaims"));
}
}
@@ -1,7 +0,0 @@
package art.arcane.iris.generation.hydrology;
interface CrossTileDraftAdmission {
void prepare();
CrossTilePublicationAdmission admit(HydrologyCaveCourseFilter.Result result);
}
@@ -1,15 +0,0 @@
package art.arcane.iris.generation.hydrology;
import java.util.List;
import java.util.Objects;
record CrossTilePublicationAdmission(
HydrologyCaveCourseFilter.Result result,
List<HydrologyDiagnosticCandidate> diagnostics,
boolean rejectedCourses
) {
CrossTilePublicationAdmission {
Objects.requireNonNull(result, "result");
diagnostics = List.copyOf(Objects.requireNonNull(diagnostics, "diagnostics"));
}
}
@@ -1,9 +0,0 @@
package art.arcane.iris.generation.hydrology;
import java.util.Objects;
record CrossTileRejectedCourse(RiverCourse course, long winnerSourceId) {
CrossTileRejectedCourse {
Objects.requireNonNull(course, "course");
}
}
@@ -1,4 +0,0 @@
package art.arcane.iris.generation.hydrology;
record CrossTileRejectionKey(long courseId, long winnerCourseId) {
}
@@ -1,17 +0,0 @@
package art.arcane.iris.generation.hydrology;
import java.util.List;
import java.util.Objects;
record CrossTileResolution(
HydrologyOwnerDraft draft,
List<CrossTileRejectedCourse> observedRejections,
int iterations,
int ownerCount,
long resolutionNanos
) {
CrossTileResolution {
Objects.requireNonNull(draft, "draft");
observedRejections = List.copyOf(Objects.requireNonNull(observedRejections, "observedRejections"));
}
}
@@ -1,73 +0,0 @@
package art.arcane.iris.generation.hydrology;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
final class CrossTileResolutionContext {
final Map<HydrologyTileKey, CrossTileResolvedOwner> resolved;
private final Set<HydrologyTileKey> resolving;
final HydrologyTileKey root;
private final long maximumTileOffset;
private final int maximumOwners;
int iterations;
CrossTileResolutionContext(
HydrologyTileKey root,
long maximumTileOffset,
int maximumOwners
) {
this.resolved = new HashMap<>();
this.resolving = new HashSet<>();
this.root = Objects.requireNonNull(root, "root");
this.maximumTileOffset = maximumTileOffset;
this.maximumOwners = maximumOwners;
}
CrossTileResolvedOwner resolved(HydrologyTileKey key) {
return resolved.get(key);
}
void remember(HydrologyTileKey key, CrossTileResolvedOwner owner) {
long offsetX = Math.abs((long) key.tileX() - root.tileX());
long offsetZ = Math.abs((long) key.tileZ() - root.tileZ());
if (offsetX > maximumTileOffset || offsetZ > maximumTileOffset) {
throw new IllegalStateException("Cross-tile owner dependency exceeded its color-ranked geometry bound.");
}
CrossTileResolvedOwner existing = resolved.putIfAbsent(key, owner);
if (existing != null
&& !existing.withoutFootprintCompiler().equals(owner.withoutFootprintCompiler())) {
throw new IllegalStateException("Cross-tile owner resolution produced inconsistent cached results for "
+ key.tileX() + "," + key.tileZ() + " while planning " + root.tileX() + "," + root.tileZ() + ".");
}
if (resolved.size() > maximumOwners) {
throw new IllegalStateException("Cross-tile owner dependencies exceeded their color-ranked count bound.");
}
}
boolean begin(HydrologyTileKey key) {
return resolving.add(key);
}
void end(HydrologyTileKey key) {
resolving.remove(key);
}
void recordIteration() {
iterations++;
}
int iterations() {
return iterations;
}
int ownerCount() {
return resolved.size();
}
HydrologyTileKey root() {
return root;
}
}
@@ -1,21 +0,0 @@
package art.arcane.iris.generation.hydrology;
import java.util.List;
import java.util.Objects;
record CrossTileResolvedOwner(
HydrologyOwnerDraft draft,
List<CrossTileRejectedCourse> observedRejections
) {
CrossTileResolvedOwner {
Objects.requireNonNull(draft, "draft");
observedRejections = List.copyOf(Objects.requireNonNull(observedRejections, "observedRejections"));
}
CrossTileResolvedOwner withoutFootprintCompiler() {
HydrologyOwnerDraft uncachedDraft = draft.withoutFootprintCompiler();
return uncachedDraft == draft
? this
: new CrossTileResolvedOwner(uncachedDraft, observedRejections);
}
}
@@ -1,8 +0,0 @@
package art.arcane.iris.generation.hydrology;
enum DraftPhase {
CONTEXT,
SELECT,
SETTLE,
PUBLISH
}
@@ -1,60 +0,0 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.spi.IrisLogging;
/**
* Wall-clock breakdown of one owner draft for the debug log: sampling and routing context, source
* selection, settling, publication passes, and the time spent waiting on lower-rank neighbour drafts.
*/
final class DraftProfile {
private final long[] phaseNanos = new long[DraftPhase.values().length];
private long dependencyNanos;
private int dependencies;
private int admissions;
int earlyOwners;
int routeCalls;
int routeSolves;
long routeSolveNanos;
int rasterCalls;
long rasterNanos;
int filterCalls;
long filterNanos;
long record(DraftPhase phase, long started) {
long now = System.nanoTime();
phaseNanos[phase.ordinal()] += now - started;
return now;
}
void recordDependencies(int count, long started) {
dependencyNanos += System.nanoTime() - started;
dependencies += count;
admissions++;
}
void log(HydrologyTileKey key, int rank, long totalNanos) {
IrisLogging.debug(
"Hydrology owner %d,%d rank=%d drafted in %dms: context=%dms select=%dms settle=%dms publish=%dms deps=%d wait=%dms admissions=%d earlyOwners=%d routes=%d reuses=%d routeSolve=%dms rasters=%d raster=%dms filters=%d filter=%dms on %s",
key.tileX(),
key.tileZ(),
rank,
totalNanos / 1_000_000L,
phaseNanos[DraftPhase.CONTEXT.ordinal()] / 1_000_000L,
phaseNanos[DraftPhase.SELECT.ordinal()] / 1_000_000L,
phaseNanos[DraftPhase.SETTLE.ordinal()] / 1_000_000L,
phaseNanos[DraftPhase.PUBLISH.ordinal()] / 1_000_000L,
dependencies,
dependencyNanos / 1_000_000L,
admissions,
earlyOwners,
routeCalls,
routeCalls - routeSolves,
routeSolveNanos / 1_000_000L,
rasterCalls,
rasterNanos / 1_000_000L,
filterCalls,
filterNanos / 1_000_000L,
Thread.currentThread().getName()
);
}
}
@@ -1,36 +0,0 @@
package art.arcane.iris.generation.hydrology;
import java.util.List;
import java.util.Objects;
public record DrainageEdge(
long id,
long upstreamNodeId,
long downstreamNodeId,
long outletId,
double cost,
int contributingSurfaceSources,
int contributingUndergroundSources,
List<HydrologyPoint> centerline
) {
public DrainageEdge {
if (upstreamNodeId == downstreamNodeId) {
throw new IllegalArgumentException("A drainage edge cannot point to the same node.");
}
if (!Double.isFinite(cost) || cost <= 0D) {
throw new IllegalArgumentException("Drainage edge cost must be finite and positive.");
}
if (contributingSurfaceSources < 0 || contributingUndergroundSources < 0) {
throw new IllegalArgumentException("Contributing source counts cannot be negative.");
}
Objects.requireNonNull(centerline, "centerline");
centerline = List.copyOf(centerline);
if (centerline.size() < 2) {
throw new IllegalArgumentException("A drainage edge requires at least two centerline points.");
}
}
public int totalContributingSources() {
return contributingSurfaceSources + contributingUndergroundSources;
}
}
@@ -1,26 +0,0 @@
package art.arcane.iris.generation.hydrology;
import java.util.Objects;
public record DrainageNode(
long id,
int x,
int z,
HydrologyTerrainSample terrain,
double potential,
long outletId
) {
public DrainageNode {
Objects.requireNonNull(terrain, "terrain");
if (!Double.isFinite(potential) || potential < 0D) {
throw new IllegalArgumentException("Drainage potential must be finite and non-negative.");
}
if (terrain.ocean()) {
throw new IllegalArgumentException("Drainage nodes must remain landward of the first ocean column.");
}
}
public HydrologyPoint naturalPoint() {
return new HydrologyPoint(x, terrain.naturalHeight(), z);
}
}
@@ -1,11 +0,0 @@
package art.arcane.iris.generation.hydrology;
record FootprintCenterlineProjection(
int pointIndex,
double x,
double z,
double distance,
int flowX,
int flowZ
) {
}
@@ -1,11 +0,0 @@
package art.arcane.iris.generation.hydrology;
import java.util.List;
record FootprintCourseRasterKey(
long courseId,
RiverCourseType courseType,
String profileKey,
List<HydraulicSegment> segments
) {
}
@@ -1,11 +0,0 @@
package art.arcane.iris.generation.hydrology;
record FootprintFeatureKey(
long courseId,
long segmentId,
HydrologyFeatureType type,
int role,
int flowX,
int flowZ
) {
}
@@ -1,20 +0,0 @@
package art.arcane.iris.generation.hydrology;
record FootprintLayerShape(
int channelRadius,
double shoreWidth,
double gradingWidth,
int bed,
int fluidHead,
int ceiling,
boolean ellipsoid,
boolean archedChannel,
boolean roundedSurfaceBed,
boolean organicBoundary,
boolean fallingThroat,
boolean inlet
) {
double totalRadius() {
return channelRadius + shoreWidth + gradingWidth;
}
}
@@ -1,173 +0,0 @@
package art.arcane.iris.generation.hydrology;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
final class FootprintMutableColumn {
private final int x;
private final int z;
private final int naturalHeight;
private final int seaLevel;
private final boolean ocean;
private final String parentBiomeKey;
private HydrologyColumnLayer singleLayer;
private LinkedHashMap<Long, HydrologyColumnLayer> layers;
FootprintMutableColumn(int x, int z, HydrologyTerrainSample terrain, int seaLevel) {
this.x = x;
this.z = z;
this.naturalHeight = terrain.naturalHeight();
this.seaLevel = seaLevel;
this.ocean = terrain.ocean();
this.parentBiomeKey = terrain.parentBiomeKey();
}
FootprintMutableColumn(HydrologyColumnSample sample) {
this.x = sample.x();
this.z = sample.z();
this.naturalHeight = sample.naturalHeight();
this.seaLevel = sample.seaLevel();
this.ocean = sample.ocean();
this.parentBiomeKey = sample.parentBiomeKey();
for (HydrologyColumnLayer layer : sample.layers()) {
add(layer);
}
}
void add(HydrologyColumnLayer layer) {
if (layers == null) {
if (singleLayer == null) {
singleLayer = layer;
return;
}
if (singleLayer.feature().id() == layer.feature().id()) {
singleLayer = merge(singleLayer, layer);
return;
}
layers = new LinkedHashMap<>();
layers.put(singleLayer.feature().id(), singleLayer);
singleLayer = null;
}
HydrologyColumnLayer existing = layers.get(layer.feature().id());
layers.put(layer.feature().id(), existing == null ? layer : merge(existing, layer));
}
HydrologyColumnSample build() {
List<HydrologyColumnLayer> builtLayers;
if (layers != null) {
builtLayers = resolveSurfaceHeadConflicts(new ArrayList<>(layers.values()));
} else if (singleLayer != null) {
builtLayers = List.of(singleLayer);
} else {
builtLayers = List.of();
}
return new HydrologyColumnSample(
x,
z,
naturalHeight,
seaLevel,
ocean,
parentBiomeKey,
builtLayers
);
}
private List<HydrologyColumnLayer> resolveSurfaceHeadConflicts(List<HydrologyColumnLayer> candidates) {
HashMap<Long, HydrologyColumnLayer> selectedByCourse = new HashMap<>();
for (HydrologyColumnLayer candidate : candidates) {
if (!conflictingSurfaceCandidate(candidate)) {
continue;
}
long courseId = candidate.feature().courseId();
HydrologyColumnLayer selected = selectedByCourse.get(courseId);
if (selected == null || prefersSurfaceLayer(candidate, selected)) {
selectedByCourse.put(courseId, candidate);
}
}
if (selectedByCourse.isEmpty()) {
return candidates;
}
ArrayList<HydrologyColumnLayer> resolved = new ArrayList<>(candidates.size());
for (HydrologyColumnLayer candidate : candidates) {
HydrologyColumnLayer selected = selectedByCourse.get(candidate.feature().courseId());
if (!conflictingSurfaceCandidate(candidate)
|| selected == null
|| candidate.fluidHeadY() == selected.fluidHeadY()) {
resolved.add(candidate);
}
}
return List.copyOf(resolved);
}
private boolean conflictingSurfaceCandidate(HydrologyColumnLayer layer) {
return layer.feature().type().isSurface()
&& layer.channel()
&& layer.connectedFluid()
&& layer.fluidOwned()
&& !layer.fallingFluid();
}
private boolean prefersSurfaceLayer(HydrologyColumnLayer candidate, HydrologyColumnLayer selected) {
if (candidate.receivingPool() != selected.receivingPool()) {
return candidate.receivingPool();
}
if (candidate.fluidHeadY() != selected.fluidHeadY()) {
return candidate.fluidHeadY() > selected.fluidHeadY();
}
return candidate.feature().id() < selected.feature().id();
}
void merge(HydrologyColumnSample sample) {
if (sample.x() != x
|| sample.z() != z
|| sample.naturalHeight() != naturalHeight
|| sample.seaLevel() != seaLevel
|| sample.ocean() != ocean
|| !sample.parentBiomeKey().equals(parentBiomeKey)) {
throw new IllegalStateException("Hydrology course footprints disagree on terrain metadata at "
+ x + "," + z + ".");
}
for (HydrologyColumnLayer layer : sample.layers()) {
add(layer);
}
}
private HydrologyColumnLayer merge(HydrologyColumnLayer first, HydrologyColumnLayer second) {
if (first.equals(second)) {
return first;
}
HydrologyFeatureRef feature = first.feature().y() <= second.feature().y()
? first.feature()
: second.feature();
boolean drySurfaceBlend = first.feature().type().isSurface()
&& second.feature().type().isSurface()
&& !first.channel()
&& !second.channel();
int bedY = drySurfaceBlend
? Math.max(first.bedY(), second.bedY())
: Math.min(first.bedY(), second.bedY());
return new HydrologyColumnLayer(
feature,
bedY,
Math.max(first.fluidHeadY(), second.fluidHeadY()),
Math.max(first.ceilingY(), second.ceilingY()),
first.channel() || second.channel(),
first.shore() || second.shore(),
first.grading() || second.grading(),
first.connectedFluid() || second.connectedFluid(),
first.fallingFluid() || second.fallingFluid(),
first.receivingPool() || second.receivingPool(),
(first.terrainOwned() || second.terrainOwned()) && (!drySurfaceBlend || bedY != naturalHeight),
first.fluidOwned() || second.fluidOwned(),
first.oceanApron() || second.oceanApron(),
first.profileKey(),
first.surfaceBiomeKey(),
first.mouthBiomeKey(),
first.shoreBiomeKey(),
first.bankBiomeKey(),
first.floodedCaveBiomeKey()
);
}
}
@@ -1,11 +0,0 @@
package art.arcane.iris.generation.hydrology;
record FootprintRasterStencil(
int[] deltaXs,
int[] deltaZs,
double[] distances
) {
int size() {
return deltaXs.length;
}
}
@@ -1,37 +0,0 @@
package art.arcane.iris.generation.hydrology;
import java.util.Arrays;
final class FootprintRasterStencilBuilder {
private int[] deltaXs;
private int[] deltaZs;
private double[] distances;
private int size;
FootprintRasterStencilBuilder() {
this.deltaXs = new int[64];
this.deltaZs = new int[64];
this.distances = new double[64];
}
void add(int deltaX, int deltaZ, double distance) {
if (size == deltaXs.length) {
int expandedSize = Math.multiplyExact(size, 2);
deltaXs = Arrays.copyOf(deltaXs, expandedSize);
deltaZs = Arrays.copyOf(deltaZs, expandedSize);
distances = Arrays.copyOf(distances, expandedSize);
}
deltaXs[size] = deltaX;
deltaZs[size] = deltaZ;
distances[size] = distance;
size++;
}
FootprintRasterStencil build() {
return new FootprintRasterStencil(
Arrays.copyOf(deltaXs, size),
Arrays.copyOf(deltaZs, size),
Arrays.copyOf(distances, size)
);
}
}
@@ -1,8 +0,0 @@
package art.arcane.iris.generation.hydrology;
record FootprintRasterStencilKey(
int channelRadius,
double shoreWidth,
double gradingWidth
) {
}
@@ -1,12 +0,0 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.hydrology.surface.SurfaceFootprint;
import java.util.List;
record FootprintValidationCourseRaster(
long courseId,
List<HydrologyColumnSample> columns,
SurfaceFootprint surface
) {
}
@@ -1,87 +0,0 @@
package art.arcane.iris.generation.hydrology;
import java.util.Arrays;
import java.util.Objects;
public record HydraulicChannelProfile(double[] widths, double[] depths) {
public HydraulicChannelProfile {
Objects.requireNonNull(widths, "widths");
Objects.requireNonNull(depths, "depths");
if (widths.length == 0 || widths.length != depths.length) {
throw new IllegalArgumentException("Channel dimensions require equally sized, nonempty station arrays.");
}
widths = widths.clone();
depths = depths.clone();
for (int station = 0; station < widths.length; station++) {
if (!Double.isFinite(widths[station]) || widths[station] <= 0D
|| !Double.isFinite(depths[station]) || depths[station] <= 0D) {
throw new IllegalArgumentException("Channel dimensions must be finite and positive.");
}
}
}
public static HydraulicChannelProfile uniform(double width, double depth) {
return new HydraulicChannelProfile(new double[]{width}, new double[]{depth});
}
public static HydraulicChannelProfile range(double[] widths, double[] depths, int from, int to) {
Objects.checkFromToIndex(from, to, widths.length);
Objects.checkFromToIndex(from, to, depths.length);
return new HydraulicChannelProfile(Arrays.copyOfRange(widths, from, to), Arrays.copyOfRange(depths, from, to));
}
@Override
public double[] widths() {
return widths.clone();
}
@Override
public double[] depths() {
return depths.clone();
}
public int size() {
return widths.length;
}
public double widthAt(int station) {
return widths[widths.length == 1 ? 0 : station];
}
public double depthAt(int station) {
return depths[depths.length == 1 ? 0 : station];
}
public double maximumWidth() {
double maximum = 0D;
for (double width : widths) {
maximum = Math.max(maximum, width);
}
return maximum;
}
public double maximumDepth() {
double maximum = 0D;
for (double depth : depths) {
maximum = Math.max(maximum, depth);
}
return maximum;
}
@Override
public boolean equals(Object other) {
return other instanceof HydraulicChannelProfile profile
&& Arrays.equals(widths, profile.widths)
&& Arrays.equals(depths, profile.depths);
}
@Override
public int hashCode() {
return 31 * Arrays.hashCode(widths) + Arrays.hashCode(depths);
}
@Override
public String toString() {
return "HydraulicChannelProfile[widths=" + Arrays.toString(widths) + ", depths=" + Arrays.toString(depths) + "]";
}
}
@@ -1,74 +0,0 @@
package art.arcane.iris.generation.hydrology;
import java.util.List;
import java.util.Objects;
public record HydraulicSegment(
long id,
long courseId,
HydrologyFeatureType type,
int upstreamHeadY,
int downstreamHeadY,
int width,
int depth,
boolean fallingFluid,
boolean receivingPool,
List<HydrologyPoint> centerline,
HydraulicChannelProfile channelProfile
) {
public HydraulicSegment {
Objects.requireNonNull(type, "type");
Objects.requireNonNull(centerline, "centerline");
Objects.requireNonNull(channelProfile, "channelProfile");
centerline = List.copyOf(centerline);
if (centerline.isEmpty()) {
throw new IllegalArgumentException("A hydraulic segment requires a centerline.");
}
if (upstreamHeadY < downstreamHeadY) {
throw new IllegalArgumentException("Hydraulic heads cannot rise downstream.");
}
if (width <= 0 || depth <= 0) {
throw new IllegalArgumentException("Hydraulic segment width and depth must be positive.");
}
if (channelProfile.size() != 1 && channelProfile.size() != centerline.size()) {
throw new IllegalArgumentException("Channel dimensions must cover every centerline station.");
}
if (channelProfile.maximumWidth() > width || channelProfile.maximumDepth() > depth) {
throw new IllegalArgumentException("Hydraulic segment bounds must contain its channel dimensions.");
}
int drop = upstreamHeadY - downstreamHeadY;
if (fallingFluid && drop == 0) {
throw new IllegalArgumentException("Falling fluid requires a positive head transition.");
}
if (drop > 0 && !type.isDrop()) {
throw new IllegalArgumentException("A positive head transition requires a drop segment type.");
}
if (drop == 0 && type.isDrop()) {
throw new IllegalArgumentException("A drop segment type requires a positive head transition.");
}
if (drop > 0 && !fallingFluid) {
if (centerline.getFirst().y() != upstreamHeadY || centerline.getLast().y() != downstreamHeadY) {
throw new IllegalArgumentException("A graded head transition must span its declared heads.");
}
int previousHead = upstreamHeadY;
for (HydrologyPoint point : centerline) {
if (point.y() > previousHead || point.y() < downstreamHeadY) {
throw new IllegalArgumentException("A graded head transition must descend monotonically.");
}
previousHead = point.y();
}
}
}
public int drop() {
return upstreamHeadY - downstreamHeadY;
}
public HydrologyPoint start() {
return centerline.getFirst();
}
public HydrologyPoint end() {
return centerline.getLast();
}
}
@@ -1,325 +0,0 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.stream.ProvisionalSampling;
import art.arcane.volmlib.util.cache.CacheKey;
import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap;
import it.unimi.dsi.fastutil.longs.Long2ObjectMap;
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
import java.util.Objects;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RejectedExecutionException;
final class HydrologyBasisCache {
private static final int MAXIMUM_ENTRIES = 32_768;
private static final int MAXIMUM_STRIPES = 64;
private final Stripe[] full;
private final Stripe[] withoutSlope;
HydrologyBasisCache() {
this(MAXIMUM_ENTRIES);
}
HydrologyBasisCache(int maximumEntries) {
if (maximumEntries < 1 || Integer.bitCount(maximumEntries) != 1) {
throw new IllegalArgumentException("Terrain basis cache size must be a positive power of two.");
}
int stripeCount = Math.min(MAXIMUM_STRIPES, maximumEntries);
full = new Stripe[stripeCount];
withoutSlope = new Stripe[stripeCount];
for (int index = 0; index < stripeCount; index++) {
full[index] = new Stripe(maximumEntries / stripeCount);
withoutSlope[index] = new Stripe(maximumEntries / stripeCount);
}
}
HydrologyTerrainSample sample(int x, int z, HydrologyNaturalTerrainSampler sampler) {
return sample(x, z, sampler, false);
}
HydrologyTerrainSample sampleWithoutSlope(int x, int z, HydrologyNaturalTerrainSampler sampler) {
return sample(x, z, sampler, true);
}
void seed(Long2ObjectMap<HydrologyTerrainSample> samples) {
seed(samples, full);
}
HydrologyTerrainSample[] sampleWithoutSlopeBatch(long[] coordinates, int count,
HydrologyNaturalTerrainSampler sampler) {
Objects.checkFromIndexSize(0, count, coordinates.length);
if (count > HydrologyTerrainSampler.MAXIMUM_BATCH_SIZE) {
throw new IllegalArgumentException("Terrain basis batch exceeds its sample limit");
}
HydrologyTerrainSample[] samples = new HydrologyTerrainSample[count];
Pending[] pending = new Pending[count];
long[] ownedCoordinates = new long[count];
Pending[] owned = new Pending[count];
int ownedCount = count;
Completion completion = new Completion();
int[] next = new int[count];
int[] heads = groupByStripe(coordinates, count, next);
try {
for (int stripeIndex = 0; stripeIndex < withoutSlope.length; stripeIndex++) {
if (heads[stripeIndex] == 0) {
continue;
}
Stripe stripe = withoutSlope[stripeIndex];
synchronized (stripe) {
for (int position = heads[stripeIndex]; position != 0; position = next[position - 1]) {
int index = position - 1;
long packed = coordinates[index];
HydrologyTerrainSample cached = stripe.samples.getAndMoveToFirst(packed);
if (cached != null || stripe.samples.containsKey(packed)) {
samples[index] = cached;
continue;
}
Pending loading = stripe.pending.get(packed);
if (loading == null) {
loading = new Pending(completion);
stripe.pending.put(packed, loading);
ownedCoordinates[index] = packed;
owned[index] = loading;
}
pending[index] = loading;
}
}
}
int claimed = 0;
for (int index = 0; index < count; index++) {
if (owned[index] != null) {
ownedCoordinates[claimed] = ownedCoordinates[index];
owned[claimed++] = owned[index];
}
}
ownedCount = claimed;
if (ownedCount > 0) {
loadBatch(ownedCoordinates, owned, ownedCount, sampler);
}
} catch (RuntimeException | Error failure) {
for (int index = 0; index < ownedCount; index++) {
if (owned[index] != null) {
fail(withoutSlope, ownedCoordinates[index], owned[index], failure);
}
}
completion.failure = failure;
throw failure;
} finally {
completion.complete.countDown();
}
for (int index = 0; index < count; index++) {
if (pending[index] != null) {
samples[index] = pending[index].await();
}
}
return samples;
}
void seedWithoutSlope(Long2ObjectMap<HydrologyTerrainSample> samples) {
seed(samples, withoutSlope);
}
private HydrologyTerrainSample sample(int x, int z, HydrologyNaturalTerrainSampler sampler, boolean omitSlope) {
long packed = RiverFootprint.pack(x, z);
Stripe[] stripes = omitSlope ? withoutSlope : full;
Stripe stripe = stripe(stripes, packed);
Pending loading;
boolean owner = false;
synchronized (stripe) {
HydrologyTerrainSample cached = stripe.samples.getAndMoveToFirst(packed);
if (cached != null || stripe.samples.containsKey(packed)) {
return cached;
}
loading = stripe.pending.get(packed);
if (loading == null) {
loading = new Pending(new Completion());
stripe.pending.put(packed, loading);
owner = true;
}
}
if (owner) {
try {
HydrologyTerrainSample sampled = ProvisionalSampling.memoizable(() -> omitSlope
? sampler.sampleBasisWithoutSlope(x, z) : sampler.sampleBasis(x, z));
complete(stripes, packed, loading, sampled, false);
} catch (ProvisionalSampling.Unmemoizable provisional) {
complete(stripes, packed, loading, provisional.value(), true);
} catch (RuntimeException | Error failure) {
fail(stripes, packed, loading, failure);
throw failure;
} finally {
loading.completion.complete.countDown();
}
}
return loading.await();
}
private void loadBatch(long[] coordinates, Pending[] pending, int count, HydrologyNaturalTerrainSampler sampler) {
HydrologyTerrainSample[] samples;
boolean provisional = false;
try {
samples = ProvisionalSampling.memoizable(() -> sampler.sampleBasisWithoutSlopeBatch(coordinates, count));
} catch (ProvisionalSampling.Unmemoizable value) {
samples = value.value();
provisional = true;
}
if (samples.length < count) {
throw new IllegalStateException("Terrain basis batch returned too few samples");
}
int[] next = new int[count];
int[] heads = groupByStripe(coordinates, count, next);
for (int stripeIndex = 0; stripeIndex < withoutSlope.length; stripeIndex++) {
if (heads[stripeIndex] == 0) {
continue;
}
Stripe stripe = withoutSlope[stripeIndex];
synchronized (stripe) {
for (int position = heads[stripeIndex]; position != 0; position = next[position - 1]) {
int index = position - 1;
complete(stripe, coordinates[index], pending[index], samples[index], provisional);
}
}
}
}
private int[] groupByStripe(long[] coordinates, int count, int[] next) {
int[] heads = new int[withoutSlope.length];
for (int index = count - 1; index >= 0; index--) {
int stripeIndex = (int) CacheKey.mix(coordinates[index]) & (withoutSlope.length - 1);
next[index] = heads[stripeIndex];
heads[stripeIndex] = index + 1;
}
return heads;
}
private void complete(Stripe[] stripes, long packed, Pending pending, HydrologyTerrainSample sample,
boolean provisional) {
Stripe stripe = stripe(stripes, packed);
synchronized (stripe) {
complete(stripe, packed, pending, sample, provisional);
}
}
private void complete(Stripe stripe, long packed, Pending pending, HydrologyTerrainSample sample,
boolean provisional) {
if (stripe.samples.containsKey(packed)) {
sample = stripe.samples.getAndMoveToFirst(packed);
provisional = false;
} else if (!provisional) {
stripe.put(packed, sample);
}
stripe.pending.remove(packed);
pending.sample = sample;
pending.provisional = provisional;
}
private void fail(Stripe[] stripes, long packed, Pending pending, Throwable failure) {
Stripe stripe = stripe(stripes, packed);
synchronized (stripe) {
if (stripe.pending.remove(packed, pending)) {
pending.completion.failure = failure;
}
}
}
private void seed(Long2ObjectMap<HydrologyTerrainSample> samples, Stripe[] stripes) {
for (Long2ObjectMap.Entry<HydrologyTerrainSample> entry : samples.long2ObjectEntrySet()) {
Stripe stripe = stripe(stripes, entry.getLongKey());
synchronized (stripe) {
stripe.put(entry.getLongKey(), entry.getValue());
}
}
}
private Stripe stripe(Stripe[] stripes, long packed) {
return stripes[(int) CacheKey.mix(packed) & (stripes.length - 1)];
}
private static final class Stripe {
private final int maximumEntries;
private final Long2ObjectLinkedOpenHashMap<HydrologyTerrainSample> samples;
private final Long2ObjectOpenHashMap<Pending> pending = new Long2ObjectOpenHashMap<>();
private Stripe(int maximumEntries) {
this.maximumEntries = maximumEntries;
samples = new Long2ObjectLinkedOpenHashMap<>(Math.min(16, maximumEntries));
}
private void put(long packed, HydrologyTerrainSample value) {
if (samples.size() >= maximumEntries && !samples.containsKey(packed)) {
samples.removeLast();
}
samples.putAndMoveToFirst(packed, value);
}
}
private static final class Pending {
private final Completion completion;
private HydrologyTerrainSample sample;
private boolean provisional;
private Pending(Completion completion) {
this.completion = completion;
}
private HydrologyTerrainSample await() {
completion.await();
if (provisional) {
ProvisionalSampling.mark();
}
return sample;
}
}
private static final class Completion implements ForkJoinPool.ManagedBlocker {
private final Thread owner = Thread.currentThread();
private final CountDownLatch complete = new CountDownLatch(1);
private Throwable failure;
private void await() {
if (!isReleasable() && owner == Thread.currentThread()) {
throw new IllegalStateException("Recursive terrain basis sampling");
}
boolean interrupted = false;
boolean managed = true;
try {
while (!isReleasable()) {
try {
if (managed) {
ForkJoinPool.managedBlock(this);
} else {
complete.await();
}
} catch (InterruptedException interruption) {
interrupted = true;
managed = false;
} catch (RejectedExecutionException unavailable) {
managed = false;
}
}
} finally {
if (interrupted) {
Thread.currentThread().interrupt();
}
}
if (failure instanceof Error error) {
throw error;
}
if (failure instanceof RuntimeException runtime) {
throw runtime;
}
}
@Override
public boolean block() throws InterruptedException {
complete.await();
return true;
}
@Override
public boolean isReleasable() {
return complete.getCount() == 0;
}
}
}
Loaded 100 of 1136 files, more files were not shown because too many files have changed in this diff. Show more