Compare commits

..
Author SHA1 Message Date
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 2d211cf106 B+ 2026-10-23 10:41:36 -04:00
Brian Neumann-Fopiano 3240d57a3b Merged Iris turbo
- Columnar terrain bake, compiled generator and cave density programs, batched lattice noise

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

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

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

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

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

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

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

# Conflicts:
#	core/src/main/java/art/arcane/iris/generation/context/ChunkContext.java
2026-10-21 23:03:48 -04:00
Brian Neumann-Fopiano 35e4e30ecd Performance core pools
- Generation pool sized to performance cores
- Pregen burst raise sized from performance cores
- Hydrology planner pool sized to performance cores, at least four
2026-10-21 23:03:20 -04:00
Brian Neumann-Fopiano 393ea10cdf Pregen hydrology reach
- Pregen tile speculation limited to the generation reach around the area instead of a 512-block halo
2026-10-21 21:58:31 -04:00
Brian Neumann-Fopiano 037156ab7d Added chunk terrain bake
- Compiled generator programs evaluate layer trees in batches through NoiseProgram
- TerrainProgram accumulates interpolator group means over chunk columns
- ChunkBakeCache holds compute-once per-chunk planes with provisional results never published
- Height, biome, region, rock, fluid and cave streams read the bake as thin facades
- Sparse hydrology and bounds reads stay scalar with a direct-mapped base height memo
- ColumnBake assembles id planes, heights, fluid surface, columns and the apron on demand
2026-10-21 21:52:07 -04:00
Brian Neumann-Fopiano 29e7e0384d Merged placement reach
- True object placement reach bounds shrink the carve window from 13 to 8 chunks
2026-10-21 21:47:25 -04:00
Brian Neumann-Fopiano 2d60898744 Native noise packaging
- Exclude native noise classes and libraries from Java 21 runtime jars
- Map the probe native library and policy properties to the native noise loader
2026-10-21 21:35:06 -04:00
Brian Neumann-Fopiano 6c7896f2a4 Merged locks and caches
- Lock-free history scope, shared subterrain plan caches, stateless cave biome hash, custom biome key cache
2026-10-21 21:15:07 -04:00
Brian Neumann-Fopiano b34e5e7676 Bound slope placement rotation
- Use the radial three-dimensional bound whenever rotateTowardsSlope is set, including placements whose configured rotation is disabled
- Group the support read margin helper with the other static factories
2026-10-21 21:09:57 -04:00
Brian Neumann-Fopiano 2471066937 Merged pipeline hygiene
- Lazy shore-band noise, zero interpolator groups dropped, inline prefill, dead streams removed
2026-10-21 21:03:22 -04:00
Brian Neumann-Fopiano ab468a198d Guarded nested corner blocks
- Fell back to per-group grid sampling when a height sample nests inside another on the same thread
- Covered nested sampling with a bit-exact parity test
2026-10-21 20:41:10 -04:00
Brian Neumann-Fopiano 7aaf346ce3 True placement reach bound
- Add PlacementReach: centered half-extent reach from loaded object keys, scale and shrink chains, enumerated rotation kernels, translate, warp and vacuum
- Size the object pass write radius and read radius from PlacementReach instead of full object width
- Size floating reach from scaled and shrunk object spans
- Count cave profile vacuum modes and upper-dimension effective rotation in the object reach
- Keep the transition-band footprint check on its existing footprint
- Add runner-backed and pack-wide reach tests
2026-10-21 20:24:46 -04:00
Brian Neumann-Fopiano 65542be8d6 Tightened mixed cave cells
- Classify mixed cells without fade, boost or protection through a branch-light column loop
- Hoist the per-row bilinear terms shared by every column of a cell row
- Mark open cells from the pass bitset when every pass column is active
2026-10-21 20:16:21 -04:00
Brian Neumann-Fopiano dcfadc8d1d Merged content trim
- Single-pass deposit ellipsoids, run-classified perfection scans, gated cave markers
2026-10-21 20:01:22 -04:00
Brian Neumann-Fopiano b03f1c90e1 Streamlined chunk prefill
- Read interpolator corner bounds through a per-thread chunk block and skip groups whose cell corners are all zero
- Prefilled chunk context caches inline on generation threads with heights ahead of biomes
- Kept the burst-pool prefill only for the platform main thread, with heights and biomes in one task
- Painted surface fluid from the chunk context cache instead of re-sampling the fluid stream
2026-10-21 19:53:23 -04:00
Brian Neumann-Fopiano e39e59699e Restore deposit clump RNG
- Return deposit clumps to the standard RNG; the unsynchronized generator measured no gain
2026-10-21 19:48:15 -04:00
Brian Neumann-Fopiano 13730f207a Drop deposit scan comment
- Remove the explanatory block comment added on the pack scan
2026-10-21 19:46:03 -04:00
Brian Neumann-Fopiano 217ed04f45 Split probe helpers
- Move chunk generation and buffer selection into ProbeChunkGenerator
- Move measured-window stage and counter lines into ProbeWindowMetrics
- Move hydrology pre-planning into ProbeHydrologyPreplan
2026-10-21 19:45:12 -04:00
Brian Neumann-Fopiano e3135d8b01 Unsynchronized deposit clump random
- Draw deposit clump randomness from a single-owner generator with the java.util.Random sequence
2026-10-21 19:37:27 -04:00
Brian Neumann-Fopiano 6ebb700be0 Reuse cave biome cache
- Carve scratch keeps one cave biome cache per thread, cleared per chunk
2026-10-21 19:29:37 -04:00
Brian Neumann-Fopiano 7c4a5e1937 Added cave cell kernel
- Decide whole 8x8 adaptive cells as solid or open from lattice corner bounds with an ulp guard
- Evaluate only mixed cells per column with the original bilinear arithmetic
- Share the row kernel between serial and parallel adaptive classification
- Count solid, open and mixed cells and exact rows, printed by the generation probe
- Pin carve write sequences for eight carver configurations with golden hashes
2026-10-21 19:26:52 -04:00
Brian Neumann-Fopiano 62d7c7e18a Sliding mantle submissions
- Replaced the barrier after every 8 component submissions with a per-caller cap that frees a slot as each component finishes and stops on the first failure
- Inline windows claim components without waiting and run components another generation holds after the rest of the pass
- Prefetched only the radius of passes that generate in the current phase
- Added straggler and held-claim tests and modeled claims in the locking chunk fixture
2026-10-21 19:24:44 -04:00
Brian Neumann-Fopiano c1732d50be Trimmed height pipeline work
- Removed the unused decoration, derivative and identity streams and Engine.getBiomeID
- Skipped the shore-height noise when a column sits outside the proven shore band
- Dropped interpolator groups whose bounds are zero in every base-capable biome
2026-10-21 19:21:49 -04:00
Brian Neumann-Fopiano 1821fedb24 Trim cave decoration markers
- Stop writing cave ceiling markers, which nothing reads
- Write cave floor markers only when the dimension has cave spawners
- Reuse the zone floor subterrain cell for zone decoration
2026-10-21 19:21:46 -04:00
Brian Neumann-Fopiano 41142ee5b5 Memoize perfection block classes
- Classify column blocks once per state run during perfection scans
2026-10-21 19:21:46 -04:00
Brian Neumann-Fopiano b6b01c88f5 Probe fidelity options
- Print measured-window stage totals from EngineMetrics as IRIS_GENPROBE_STAGES
- Time each context prefill task into its prefill timer
- Add gated generation counters for generator evaluations, corner starcasts and hydrology lookups
- Add -PprobeHydrologyPreplan to plan the probe area's hydrology tiles before warmup
- Add -PprobeHistoryRouter to generate through a generation-history router and registry contract
- Add -PprobeProductionBuffers to generate into production-equivalent block and biome hunks
- Print the mantle radius as IRIS_GENPROBE_MANTLE
2026-10-21 19:20:17 -04:00
Brian Neumann-Fopiano 3bd76d47a9 Single-pass deposit clumps
- Place ellipsoid deposit clumps from the footprint cells instead of rebuilding the clump object
- Skip ore cave-biome lookups when no pack biome scales ore deposits
- Reject terrain-limited clumps before biome work when scaling cannot apply
- Compute the chunk terrain limit once per chunk
- Draw the exposure discard roll only when the discard chance is positive
2026-10-21 19:16:57 -04:00
Brian Neumann-Fopiano 7282a46a26 Lock-free scopes and caches
- History coordinate scopes read the router and routing flag lock-free
- Subterrain planner shares bounded plan and chunk candidate caches
- Cave blend roll uses a stateless hash identical to the parallel RNG
- Custom biome resource keys are cached per dimension and biome identity
2026-10-21 19:15:01 -04:00
Brian Neumann-Fopiano e2430500cf Added probe parity oracle
- Dump every genProbe chunk as palette-indexed blocks and biomes with -PprobeDumpFile
- Add genDiff task comparing two dumps by coordinate with block, surface, biome and category parity
- Generate measured chunks on N threads in spiral order with -PprobeThreads and report wall-clock cps
- Mirror pregeneration with -PprobePregenProfile (pregen performance profile and hydrology prefetch scope)
- Print the measured window for JFR attribution and accept -PprobeJfrSettings
- Keep chunk hashes and the run signature byte-identical to the sequential probe
2026-10-21 18:47:25 -04:00
Brian Neumann-Fopiano c3359e0a16 Fixing a handfull of issues! 2026-10-21 18:07:10 -04:00
Brian Neumann-Fopiano 1132242c58 Reduced verification allocations
- Dependency hashing reuses one read buffer per artifact
- Hash tests cover large, short, and empty entries
2026-10-21 09:08:00 -04:00
Brian Neumann-Fopiano 43cb336b85 Reviewed verification allocations
- Dependency hashing reuses one read buffer per artifact
- Hash tests cover mixed entry sizes
2026-10-21 09:06:24 -04:00
Brian Neumann-Fopiano dc1c90f63f Reduced verification allocations
- Dependency hashing reuses one read buffer per artifact
- Hash tests cover large, short, and empty entries
2026-10-21 09:06:24 -04:00
Brian Neumann-Fopiano 463feea60b Improved Iris build scheduling
- Resolved SlimJar only for shaded core artifacts
- Applied bounded parallel Forge test execution
2026-10-21 08:27:49 -04:00
Brian Neumann-Fopiano 49f7d11442 Reviewed Iris build scheduling
- Combined dependency task wiring and Forge test limits
2026-10-21 08:25:11 -04:00
Brian Neumann-Fopiano 0eb661f14d Trimmed unused build work
- Limited SlimJar generation to shaded core artifacts
- Honored configured mod test fork limits
- Set Forge targets to four test JVMs
2026-10-21 08:24:55 -04:00
Brian Neumann-Fopiano 5e02c5efdd Forwarded nested dependency settings
- Nested mod builds inherit the Paperweight source generation property
2026-10-21 08:00:04 -04:00
Brian Neumann-Fopiano 8dea0ae3e7 Reviewed dependency source forwarding
- Mod builds retain source generation settings across nested Gradle invocations
2026-10-21 07:58:12 -04:00
Brian Neumann-Fopiano b669e04acf Forwarded dependency source settings
- Nested mod builds inherit the Paperweight source generation property
2026-10-21 07:57:41 -04:00
Brian Neumann-Fopiano 294598d5e1 Improved isolated Forge builds
- Mod verification uses its included core build without duplicate prerequisites
- Nested sandbox builds divide their reserved workers across matrix lanes
- Forge targets reserve eight workers and 16 GiB with two test forks
2026-10-21 07:18:03 -04:00
Brian Neumann-Fopiano 79f5d02a7d Reviewed Forge build budgets
- Divided nested build workers across sandbox matrix lanes
- Reserved eight workers and 16 GiB for Forge verification
2026-10-21 07:11:10 -04:00
Brian Neumann-Fopiano 826a752437 Expanded Forge build budgets
- Sandbox nested builds divide reserved workers across matrix lanes
- Sandbox nested JVM processor counts follow each lane worker budget
- Forge targets reserve eight workers and 16 GiB with two test forks
2026-10-21 07:08:26 -04:00
Brian Neumann-Fopiano 389c2c9c42 Reviewed modded prerequisites
- Modded targets build core jars and fixtures in their own included build
2026-10-21 06:46:34 -04:00
Brian Neumann-Fopiano e1ff5447ca Removed duplicate core prerequisite
- Modded builds resolve core jars and test fixtures through their included build
- Forge setup starts without waiting for unused outer core outputs
2026-10-21 06:43:07 -04:00
Brian Neumann-Fopiano 96903e9cf3 Expanded Iris test parallelism
- Bukkit builds allocate eight workers for the complete core test suite
- Bukkit builds reserve 16 GiB for concurrent test JVMs
2026-10-21 06:34:44 -04:00
Brian Neumann-Fopiano e68c93c0f1 Improved isolated Iris builds
- Bukkit builds allocate four workers and 8 GiB for tests
- Forge sandbox builds retain their private Minecraft dependency cache
2026-10-21 06:22:45 -04:00
Brian Neumann-Fopiano 97da131884 Reviewed Forge cache persistence
- Isolated Forge targets retain generated Minecraft dependencies in their private Gradle homes
2026-10-21 06:22:45 -04:00
Brian Neumann-Fopiano 20ff08aeb7 Persisted Forge build caches
- Sandboxed Forge lanes retain Mavenizer and ForgeGradle caches in their private Gradle home
- Nested mod builds receive the sandbox property
2026-10-21 06:22:20 -04:00
Brian Neumann-Fopiano 9add1a0fa6 Reviewed Bukkit build allocation
- Bukkit build targets reserve four workers and 8 GiB for the complete test suite
2026-10-21 06:18:41 -04:00
Brian Neumann-Fopiano 1b80f4055b Raised Bukkit build budget
- Bukkit source builds use four Gradle workers
- Bukkit source builds reserve 8 GiB of memory
2026-10-21 06:14:01 -04:00
Brian Neumann-Fopiano 365d6f2810 Merged project build improvements
- Reviewed build controls and validation changes are integrated locally
2026-10-21 05:33:21 -04:00
Brian Neumann-Fopiano c17c8bece1 Reviewed sandbox source stamps
- Source stamp changes are integrated for local review
2026-10-21 05:32:24 -04:00
Brian Neumann-Fopiano d84050baac Stabilized sandbox source stamps
- Build constants use the captured Iris source fingerprint
- Unrelated project changes preserve compiled Iris cache inputs
2026-10-21 05:32:24 -04:00
Brian Neumann-Fopiano 7fb684faf5 Merged sandbox build targets
- Gradle exposes Bukkit, API, SPI, and loader targets with isolated build requirements
- Build tasks retain verified artifacts inside their project directories
- API generation writes a jar without publishing or external copies
- Captured source hashes identify sandbox builds
- Build instructions describe manager deployment
2026-10-21 03:41:57 -04:00
Brian Neumann-Fopiano 1342fa6a06 Reviewed sandbox build targets
- Gradle exposes Bukkit, API, SPI, and loader targets with isolated build requirements
- Build tasks retain verified artifacts inside their project directories
- API generation writes a jar without publishing or external copies
- Captured source hashes identify sandbox builds
- Build instructions describe manager deployment
2026-10-21 03:41:57 -04:00
Brian Neumann-Fopiano 52f1a18a1b Declared sandbox build targets
- Gradle exposes Bukkit, API, SPI, and loader targets with isolated build requirements
- Build tasks retain verified artifacts inside their project directories
- API generation writes a jar without publishing or external copies
- Captured source hashes identify sandbox builds
- Build instructions describe manager deployment
2026-10-21 03:41:18 -04:00
Brian Neumann-Fopiano fc810221bb Parallelized mod builds
- Mod targets run through bounded Gradle workers with separate caches and daemon slots
- Target logs retain verification failures and bounded output summaries
- Nested tests use bounded forks and JVM thread counts
- Lifecycle tests control deadlines through injected clocks
2026-10-21 02:53:00 -04:00
Brian Neumann-Fopiano d7059d84a4 V+ 2026-10-20 17:22:54 -04:00
Brian Neumann-Fopiano b357eee181 Fixed runtime configuration
- Timeout overrides retain original values for restoration
- Timeout restoration preserves operator changes and symbolic links
- Tests cover timeout persistence and failed configuration writes
- Runtime targets use separate Gradle caches and shared module outputs
- Modded build inputs include test fixtures and runtime cache settings
2026-10-18 18:15:46 -04:00
Brian Neumann-Fopiano 64f7a7a9ac Merged generation fixes
- Procedural trees keep compact terrain-supported roots
- Canopies preserve trunks across chunk generation and replay
- CraftEngine content follows native terrain replacement and regeneration
- Regression fixtures cover tree geometry and custom content cleanup
2026-10-18 18:04:40 -04:00
Brian Neumann-Fopiano a6aeceff86 Grounded procedural trees
- Compact connected roots extend bounded gaps into supporting terrain
- Unsupported cliff placements leave no partial trees
- Root and canopy roles follow object scaling and rotation
- Canopies preserve solid trunk cells across chunk replay and saved continuations
- Branch wood replaces leaf clusters within each tree
- Regression tests cover slopes and overlapping tree placements
- Gameplay checks compare grounded spindle geometry across both packs
2026-10-18 18:00:40 -04:00
Brian Neumann-Fopiano 51c626c410 Merged custom content fixes
- Deferred placement guards and generated content ownership share the current engine
- Native terrain writes invalidate stale custom content across platforms
2026-10-18 17:59:20 -04:00
Brian Neumann-Fopiano 34417b2f5b Tracked generated content ownership
- Generated furniture keeps ownership tokens for targeted removal
- Native writes invalidate pending custom content and tile records
- Regeneration and chunk updates remove invalidated content on owning threads
- Provider callbacks return ownership tokens across server platforms
- Regression fixtures cover terrain replacement and restart cleanup
2026-10-18 17:58:30 -04:00
Brian Neumann-Fopiano ed5483d75a Merged stale placement guard
- Combine the carrier check with native write invalidation
2026-10-16 19:37:15 -04:00
Brian Neumann-Fopiano ff537f4abc Cleared records under native writes
- Added DeferredPlacementRecords to gate tracking on Identifier and TileWrapper slices
- Cleared deferred custom and tile records at positions native structure placement changed
- Implemented block change tracking in the Bukkit and modded structure policies
- Covered invalidation through the custom block materialization pass
- Covered modded wrapper change tracking and protected cells
- Passed disabled change sets in existing wrapper tests
2026-10-16 19:36:35 -04:00
Brian Neumann-Fopiano dbc9d95372 Skip stale deferred placements
- Skip deferred custom block records whose live block no longer matches the provider carrier type
- Leave air-carrier furniture and unresolvable carriers to the provider unchanged
- Resolve each carrier once per chunk pass and log skipped records once per chunk at debug level
- Skip tile records quietly when the live block no longer matches the tile material
- Apply the same carrier check to the modded custom block pass
- Add Bukkit, CraftEngine and modded regression tests for flattened, replaced, intact and furniture records
2026-10-16 19:32:01 -04:00
360 changed files with 29388 additions and 5460 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
+16 -24
View File
@@ -248,34 +248,26 @@ which is what a board reader means.
Requirements: JDK 25 (set `JAVA_HOME` to it). The Gradle wrapper handles everything else. Requirements: JDK 25 (set `JAVA_HOME` to it). The Gradle wrapper handles everything else.
``` ```sh
./gradlew buildAllToOut ./gradlew buildAll
``` ```
builds every platform artifact into the workspace-level `../PluginOuts/` directory: the Bukkit jar This builds and verifies the Bukkit plugin, API, SPI and the Fabric, Forge and NeoForge jars
and a Fabric, Forge and NeoForge jar for every Minecraft version in `gradle/mod-targets.json` for every Minecraft version declared in `gradle/mod-targets.json`. Artifacts stay in the
(1.21.1, 26.1.2, 26.2 and 26.3): producing module's `build/` directory. The packed Bukkit plugin is in `build/libs/`, the
API is in `build/api/`, the SPI is in `spi/build/libs/`, and mod jars are in
`adapters/<loader>/build/<minecraft>/libs/`.
``` Use `./gradlew buildBukkit` for the plugin, or `verify<Loader><mc>` for one mod jar,
Iris v<version> [CraftBukkit] <mc>.jar for example `verifyFabric26_2` or `verifyNeoForge1_21_1`. `buildFabric`, `buildForge`
Iris v<version> [Fabric] <mc>+<loader>.jar and `buildNeoforge` select the `minecraftVersion` Gradle property. `./gradlew checkAll`
Iris v<version> [Forge] <mc>+<loader>.jar runs every check plus the complete mod matrix. Forge 1.21.1 compiles its tests because
Iris v<version> [NeoForge] <mc>+<loader>.jar Forge 52 cannot bootstrap Minecraft outside FML.
```
Per-platform tasks: `./gradlew buildBukkit`, and `build<Loader><mc>` for one mod jar, for example Multiplexor reads `gradle/multiplexor.gradle` to discover targets and build each in a
`buildFabric26_2` or `buildNeoForge1_21_1`. `buildFabric`, `buildForge` and `buildNeoforge` export the separate source sandbox. Use its project build and deployment commands to export jars
26.3 jars. `./gradlew checkAll` runs every check, then builds and verifies all twelve mod jars and runs their or install them into managed test instances. Gradle builds do not copy artifacts to
unit tests; Forge 1.21.1 only compiles its tests because Forge 52 cannot bootstrap Minecraft outside external destinations.
FML. The
SPI jar (the pure-JVM adapter/platform contract, not the stable plugin API) is built to `spi/build/libs/` by
`./gradlew :spi:jar`.
`./gradlew buildAll` also runs `buildAllToOut` and copies the jars into a consumer dropin tree
for a local test server. The consumer directory defaults to `../../[Minecraft Server]/consumers/`
when it exists, otherwise `build/consumers/` inside the repo. `-Plocation=/path/to/consumers`
changes only the consumer directory. Both tasks copy every verified platform jar into
`../PluginOuts/`.
VolmLib is built from source when a `VolmLib` checkout sits beside the repo or in a parent directory VolmLib is built from source when a `VolmLib` checkout sits beside the repo or in a parent directory
(`VOLMLIB_DIR` or `-PlocalVolmLibDirectory=<path>` pick another one). Without one, or with (`VOLMLIB_DIR` or `-PlocalVolmLibDirectory=<path>` pick another one). Without one, or with
@@ -245,9 +245,15 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
for (int y = groundY + 1; y <= groundY + 32; y++) { for (int y = groundY + 1; y <= groundY + 32; y++) {
assertTrue("Roof at " + x + "," + y, state(blocks, x, y, 0).isAir()); assertTrue("Roof at " + x + "," + y, state(blocks, x, y, 0).isAir());
} }
for (int y = 12; y < groundY; y++) { int supportY = x < 2 ? groundY - 33 : 12;
for (int y = supportY; y < groundY; y++) {
assertTrue("Missing foundation at " + x + "," + y, state(blocks, x, y, 0).isSolid()); assertTrue("Missing foundation at " + x + "," + y, state(blocks, x, y, 0).isSolid());
} }
if (x < 2) {
for (int y = 13; y < supportY; y++) {
assertTrue("Cave beneath support at " + x + "," + y, state(blocks, x, y, 0).isAir());
}
}
} }
} }
@@ -268,6 +274,57 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
assertTrue(state(blocks, 0, groundY + 1, 0).isAir()); assertTrue(state(blocks, 0, groundY + 1, 0).isAir());
} }
@Test
public void flattenPreservesCavesBelowIntactGroundAtTheSkirtEdge() {
StructureStart start = desertStart();
int groundY = start.getBoundingBox().minY() - 1;
int x = start.getBoundingBox().maxX() + 23;
int z = start.getBoundingBox().minZ();
BoundingBox area = new BoundingBox(x, 0, z, x, 127, z);
Map<BlockPos, BlockState> blocks = new HashMap<>();
for (int y = groundY - 35; y <= groundY; y++) {
BlockState block = y >= groundY - 34 && y <= groundY - 13
? Blocks.CAVE_AIR.defaultBlockState() : Blocks.STONE.defaultBlockState();
put(blocks, x, y, z, block);
}
put(blocks, x, groundY - 1, z, Blocks.DIRT.defaultBlockState());
put(blocks, x, groundY, z, Blocks.GRASS_BLOCK.defaultBlockState());
NativeStructureTerrainIntegrator.TerrainTarget target = surfaceTarget(start, StructureTerrainMode.FLATTEN);
((IrisStructureTerrain) target.terrain()).setHorizontalPadding(24).setFlattenRange(64);
NativeStructureSurfaceFitter.prepareSurfaceStructures(world(blocks), area, List.of(target), (px, pz) -> groundY);
for (int y = groundY - 34; y <= groundY - 13; y++) {
assertEquals("Cave at " + y, Blocks.CAVE_AIR.defaultBlockState(), state(blocks, x, y, z));
}
assertEquals(Blocks.DIRT.defaultBlockState(), state(blocks, x, groundY - 1, z));
}
@Test
public void flattenBridgesTheFoundationGapWithoutFillingACaveBelowItsSupport() {
StructureStart start = desertStart();
int groundY = start.getBoundingBox().minY() - 1;
BoundingBox area = new BoundingBox(0, 0, 0, 0, 127, 0);
Map<BlockPos, BlockState> blocks = new HashMap<>();
put(blocks, 0, groundY, 0, Blocks.GRASS_BLOCK.defaultBlockState());
for (int y = groundY - 11; y <= groundY - 9; y++) {
put(blocks, 0, y, 0, Blocks.STONE.defaultBlockState());
}
for (int y = groundY - 20; y <= groundY - 12; y++) {
put(blocks, 0, y, 0, Blocks.CAVE_AIR.defaultBlockState());
}
NativeStructureSurfaceFitter.prepareSurfaceStructures(world(blocks), area,
List.of(surfaceTarget(start, StructureTerrainMode.FLATTEN)), (x, z) -> groundY);
for (int y = groundY - 8; y < groundY; y++) {
assertEquals("Foundation at " + y, Blocks.STONE.defaultBlockState(), state(blocks, 0, y, 0));
}
for (int y = groundY - 20; y <= groundY - 12; y++) {
assertEquals("Cave at " + y, Blocks.CAVE_AIR.defaultBlockState(), state(blocks, 0, y, 0));
}
}
@Test @Test
public void flattenLeavesBuriedAndSubmergedInstancesUntouched() { public void flattenLeavesBuriedAndSubmergedInstancesUntouched() {
StructureStart start = desertStart(); StructureStart start = desertStart();
@@ -619,10 +676,18 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
for (int z = 0; z <= 8; z++) { for (int z = 0; z <= 8; z++) {
int groundY = plan.flattenedHeights().get(NativeStructureTemplateOccupancy.columnKey(x, z)) - 1; int groundY = plan.flattenedHeights().get(NativeStructureTemplateOccupancy.columnKey(x, z)) - 1;
assertTrue("Village grade at " + x + "," + z + ": " + groundY, groundY >= 64 && groundY <= 70); assertTrue("Village grade at " + x + "," + z + ": " + groundY, groundY >= 64 && groundY <= 70);
for (int y = 20; y <= groundY; y++) { boolean originalFoundation = (x == 0 || x == 8) && (z == 0 || z == 8);
int supportY = originalFoundation ? 63 : 20;
for (int y = supportY; y <= groundY; y++) {
assertTrue("Disconnected village ground at " + x + "," + y + "," + z, assertTrue("Disconnected village ground at " + x + "," + y + "," + z,
state(blocks, x, y, z).isSolid()); state(blocks, x, y, z).isSolid());
} }
if (originalFoundation) {
for (int y = 21; y < supportY; y++) {
assertTrue("Cave beneath foundation at " + x + "," + y + "," + z,
state(blocks, x, y, z).isAir());
}
}
for (int y = groundY + 1; y <= 96; y++) { for (int y = groundY + 1; y <= 96; y++) {
assertTrue("Roof remains above village at " + x + "," + y + "," + z, assertTrue("Roof remains above village at " + x + "," + y + "," + z,
state(blocks, x, y, z).isAir()); state(blocks, x, y, z).isAir());
@@ -3,6 +3,7 @@ package art.arcane.iris.platform.bukkit.nms.v26_2_R1;
import art.arcane.volmlib.nativelib.v26_2_R1.terrain.NativeStructureWorldgenAccess; import art.arcane.volmlib.nativelib.v26_2_R1.terrain.NativeStructureWorldgenAccess;
import art.arcane.iris.structure.placement.IrisStructureTerrain; import art.arcane.iris.structure.placement.IrisStructureTerrain;
import art.arcane.volmlib.nativelib.terrain.NativeBlockChangeSet;
import art.arcane.volmlib.util.structure.StructureTerrainMode; import art.arcane.volmlib.util.structure.StructureTerrainMode;
import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeStructurePostProcessor; import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeStructurePostProcessor;
import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeStructureTerrainIntegrator; import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeStructureTerrainIntegrator;
@@ -230,8 +231,9 @@ public class NativeStructureWorldgenAccessTest {
recording.world(), generationCenter, recording.world(), generationCenter,
(x, z) -> SURFACE_FIRST_FREE_Y, (x, z) -> SURFACE_FIRST_FREE_Y,
(x, z) -> FLOOR_FIRST_FREE_Y, (x, z) -> FLOOR_FIRST_FREE_Y,
true, true, true,
position -> false); position -> false,
NativeBlockChangeSet.disabled());
int x = generationCenter.getMiddleBlockX(); int x = generationCenter.getMiddleBlockX();
int z = generationCenter.getMiddleBlockZ(); int z = generationCenter.getMiddleBlockZ();
@@ -248,8 +250,9 @@ public class NativeStructureWorldgenAccessTest {
recording.world(), generationCenter(), recording.world(), generationCenter(),
(x, z) -> SURFACE_FIRST_FREE_Y, (x, z) -> SURFACE_FIRST_FREE_Y,
(x, z) -> FLOOR_FIRST_FREE_Y, (x, z) -> FLOOR_FIRST_FREE_Y,
false, false, true,
position::equals); position::equals,
NativeBlockChangeSet.disabled());
assertSame(Blocks.DIRT, access.getBlockState(position).getBlock()); assertSame(Blocks.DIRT, access.getBlockState(position).getBlock());
assertFalse(access.ensureCanWrite(position)); assertFalse(access.ensureCanWrite(position));
@@ -319,8 +322,9 @@ public class NativeStructureWorldgenAccessTest {
recording.world(), generationCenter(), recording.world(), generationCenter(),
(x, z) -> SURFACE_FIRST_FREE_Y, (x, z) -> SURFACE_FIRST_FREE_Y,
(x, z) -> FLOOR_FIRST_FREE_Y, (x, z) -> FLOOR_FIRST_FREE_Y,
false, false, true,
position -> false); position -> false,
NativeBlockChangeSet.disabled());
} }
private static ChunkPos generationCenter() { private static ChunkPos generationCenter() {
@@ -9,6 +9,7 @@ import art.arcane.iris.structure.nativegen.NativeStructureVolumeIndex;
import art.arcane.iris.generation.runtime.IrisEngine; import art.arcane.iris.generation.runtime.IrisEngine;
import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.Engine;
import art.arcane.volmlib.nativelib.terrain.structure.NativeStructureVolume; import art.arcane.volmlib.nativelib.terrain.structure.NativeStructureVolume;
import art.arcane.volmlib.nativelib.terrain.structure.StructureTerrainSettings;
import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter; import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter;
import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KList;
import net.minecraft.SharedConstants; import net.minecraft.SharedConstants;
@@ -69,7 +70,8 @@ public class NativeStructureVolumeIndexTest {
StructureStart start = swampHut(0, 0, 4, 6); StructureStart start = swampHut(0, 0, 4, 6);
BoundingBox bounds = start.getPieces().getFirst().getBoundingBox(); BoundingBox bounds = start.getPieces().getFirst().getBoundingBox();
KList<NativeStructureVolume> volumes = NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, start); KList<NativeStructureVolume> volumes = NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, start,
StructureTerrainSettings.SOURCE, (x, z) -> 63);
assertEquals(1, volumes.size()); assertEquals(1, volumes.size());
NativeStructureVolume volume = volumes.getFirst(); NativeStructureVolume volume = volumes.getFirst();
@@ -84,8 +86,10 @@ public class NativeStructureVolumeIndexTest {
@Test @Test
public void invalidStartsContributeNoVolumes() { public void invalidStartsContributeNoVolumes() {
assertNull(NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, StructureStart.INVALID_START)); assertNull(NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, StructureStart.INVALID_START,
assertNull(NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, null)); StructureTerrainSettings.SOURCE, (x, z) -> 63));
assertNull(NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, null,
StructureTerrainSettings.SOURCE, (x, z) -> 63));
} }
@Test @Test
@@ -323,7 +327,7 @@ public class NativeStructureVolumeIndexTest {
BlockingResolver coordination = new BlockingResolver(); BlockingResolver coordination = new BlockingResolver();
NativeStructureVolumeIndex.OriginResolver volumes = (engine, chunkX, chunkZ) -> NativeStructureVolumeIndex.OriginResolver volumes = (engine, chunkX, chunkZ) ->
new KList<>(new NativeStructureVolume(chunkX + ":" + chunkZ, new KList<>(new NativeStructureVolume(chunkX + ":" + chunkZ,
-256, 0, -256, 256, 255, 256)); -256, 0, -256, 256, 255, 256, null));
NativeStructureVolumeIndex index = NativeStructureVolumeIndex.forTesting((engine, chunkX, chunkZ) -> { NativeStructureVolumeIndex index = NativeStructureVolumeIndex.forTesting((engine, chunkX, chunkZ) -> {
coordination.volumesAt(engine, chunkX, chunkZ); coordination.volumesAt(engine, chunkX, chunkZ);
return volumes.volumesAt(engine, chunkX, chunkZ); return volumes.volumesAt(engine, chunkX, chunkZ);
@@ -369,7 +373,7 @@ public class NativeStructureVolumeIndexTest {
terrain.join(); terrain.join();
} }
return new KList<>(new NativeStructureVolume(chunkX + ":" + chunkZ, return new KList<>(new NativeStructureVolume(chunkX + ":" + chunkZ,
-256, 0, -256, 256, 100, 256)); -256, 0, -256, 256, 100, 256, null));
}); });
ExecutorService owner = Executors.newSingleThreadExecutor(); ExecutorService owner = Executors.newSingleThreadExecutor();
ForkJoinPool workers = new ForkJoinPool(2); ForkJoinPool workers = new ForkJoinPool(2);
@@ -493,7 +497,13 @@ public class NativeStructureVolumeIndexTest {
private static Map<?, ?> cache(NativeStructureVolumeIndex index, String name) throws Exception { private static Map<?, ?> cache(NativeStructureVolumeIndex index, String name) throws Exception {
Field field = NativeStructureVolumeIndex.class.getDeclaredField(name); Field field = NativeStructureVolumeIndex.class.getDeclaredField(name);
field.setAccessible(true); field.setAccessible(true);
return (Map<?, ?>) field.get(index); Object value = field.get(index);
if (value instanceof Map<?, ?> map) {
return map;
}
Field entries = value.getClass().getDeclaredField("entries");
entries.setAccessible(true);
return (Map<?, ?>) entries.get(value);
} }
private static StructureStart swampHut(int chunkX, int chunkZ, int x, int z) { private static StructureStart swampHut(int chunkX, int chunkZ, int x, int z) {
@@ -544,7 +554,8 @@ public class NativeStructureVolumeIndexTest {
if (chunkX != originChunkX || chunkZ != originChunkZ) { if (chunkX != originChunkX || chunkZ != originChunkZ) {
return NativeStructureVolume.NONE; return NativeStructureVolume.NONE;
} }
return NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, swampHut(chunkX, chunkZ, 4, 6)); return NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, swampHut(chunkX, chunkZ, 4, 6),
StructureTerrainSettings.SOURCE, (x, z) -> 63);
} }
} }
@@ -596,7 +607,7 @@ public class NativeStructureVolumeIndexTest {
return new KList<>(new NativeStructureVolume( return new KList<>(new NativeStructureVolume(
chunkX + ":" + chunkZ, chunkX + ":" + chunkZ,
0, 0, 0, 0, 0, 0,
15, 255, 15)); 15, 255, 15, null));
} }
} }
private record ScopedEngine(IrisEngine engine, AtomicInteger scopes) { private record ScopedEngine(IrisEngine engine, AtomicInteger scopes) {
@@ -245,9 +245,15 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
for (int y = groundY + 1; y <= groundY + 32; y++) { for (int y = groundY + 1; y <= groundY + 32; y++) {
assertTrue("Roof at " + x + "," + y, state(blocks, x, y, 0).isAir()); assertTrue("Roof at " + x + "," + y, state(blocks, x, y, 0).isAir());
} }
for (int y = 12; y < groundY; y++) { int supportY = x < 2 ? groundY - 33 : 12;
for (int y = supportY; y < groundY; y++) {
assertTrue("Missing foundation at " + x + "," + y, state(blocks, x, y, 0).isSolid()); assertTrue("Missing foundation at " + x + "," + y, state(blocks, x, y, 0).isSolid());
} }
if (x < 2) {
for (int y = 13; y < supportY; y++) {
assertTrue("Cave beneath support at " + x + "," + y, state(blocks, x, y, 0).isAir());
}
}
} }
} }
@@ -268,6 +274,57 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
assertTrue(state(blocks, 0, groundY + 1, 0).isAir()); assertTrue(state(blocks, 0, groundY + 1, 0).isAir());
} }
@Test
public void flattenPreservesCavesBelowIntactGroundAtTheSkirtEdge() {
StructureStart start = desertStart();
int groundY = start.getBoundingBox().minY() - 1;
int x = start.getBoundingBox().maxX() + 23;
int z = start.getBoundingBox().minZ();
BoundingBox area = new BoundingBox(x, 0, z, x, 127, z);
Map<BlockPos, BlockState> blocks = new HashMap<>();
for (int y = groundY - 35; y <= groundY; y++) {
BlockState block = y >= groundY - 34 && y <= groundY - 13
? Blocks.CAVE_AIR.defaultBlockState() : Blocks.STONE.defaultBlockState();
put(blocks, x, y, z, block);
}
put(blocks, x, groundY - 1, z, Blocks.DIRT.defaultBlockState());
put(blocks, x, groundY, z, Blocks.GRASS_BLOCK.defaultBlockState());
NativeStructureTerrainIntegrator.TerrainTarget target = surfaceTarget(start, StructureTerrainMode.FLATTEN);
((IrisStructureTerrain) target.terrain()).setHorizontalPadding(24).setFlattenRange(64);
NativeStructureSurfaceFitter.prepareSurfaceStructures(world(blocks), area, List.of(target), (px, pz) -> groundY);
for (int y = groundY - 34; y <= groundY - 13; y++) {
assertEquals("Cave at " + y, Blocks.CAVE_AIR.defaultBlockState(), state(blocks, x, y, z));
}
assertEquals(Blocks.DIRT.defaultBlockState(), state(blocks, x, groundY - 1, z));
}
@Test
public void flattenBridgesTheFoundationGapWithoutFillingACaveBelowItsSupport() {
StructureStart start = desertStart();
int groundY = start.getBoundingBox().minY() - 1;
BoundingBox area = new BoundingBox(0, 0, 0, 0, 127, 0);
Map<BlockPos, BlockState> blocks = new HashMap<>();
put(blocks, 0, groundY, 0, Blocks.GRASS_BLOCK.defaultBlockState());
for (int y = groundY - 11; y <= groundY - 9; y++) {
put(blocks, 0, y, 0, Blocks.STONE.defaultBlockState());
}
for (int y = groundY - 20; y <= groundY - 12; y++) {
put(blocks, 0, y, 0, Blocks.CAVE_AIR.defaultBlockState());
}
NativeStructureSurfaceFitter.prepareSurfaceStructures(world(blocks), area,
List.of(surfaceTarget(start, StructureTerrainMode.FLATTEN)), (x, z) -> groundY);
for (int y = groundY - 8; y < groundY; y++) {
assertEquals("Foundation at " + y, Blocks.STONE.defaultBlockState(), state(blocks, 0, y, 0));
}
for (int y = groundY - 20; y <= groundY - 12; y++) {
assertEquals("Cave at " + y, Blocks.CAVE_AIR.defaultBlockState(), state(blocks, 0, y, 0));
}
}
@Test @Test
public void flattenLeavesBuriedAndSubmergedInstancesUntouched() { public void flattenLeavesBuriedAndSubmergedInstancesUntouched() {
StructureStart start = desertStart(); StructureStart start = desertStart();
@@ -619,10 +676,18 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
for (int z = 0; z <= 8; z++) { for (int z = 0; z <= 8; z++) {
int groundY = plan.flattenedHeights().get(NativeStructureTemplateOccupancy.columnKey(x, z)) - 1; int groundY = plan.flattenedHeights().get(NativeStructureTemplateOccupancy.columnKey(x, z)) - 1;
assertTrue("Village grade at " + x + "," + z + ": " + groundY, groundY >= 64 && groundY <= 70); assertTrue("Village grade at " + x + "," + z + ": " + groundY, groundY >= 64 && groundY <= 70);
for (int y = 20; y <= groundY; y++) { boolean originalFoundation = (x == 0 || x == 8) && (z == 0 || z == 8);
int supportY = originalFoundation ? 63 : 20;
for (int y = supportY; y <= groundY; y++) {
assertTrue("Disconnected village ground at " + x + "," + y + "," + z, assertTrue("Disconnected village ground at " + x + "," + y + "," + z,
state(blocks, x, y, z).isSolid()); state(blocks, x, y, z).isSolid());
} }
if (originalFoundation) {
for (int y = 21; y < supportY; y++) {
assertTrue("Cave beneath foundation at " + x + "," + y + "," + z,
state(blocks, x, y, z).isAir());
}
}
for (int y = groundY + 1; y <= 96; y++) { for (int y = groundY + 1; y <= 96; y++) {
assertTrue("Roof remains above village at " + x + "," + y + "," + z, assertTrue("Roof remains above village at " + x + "," + y + "," + z,
state(blocks, x, y, z).isAir()); state(blocks, x, y, z).isAir());
@@ -3,6 +3,7 @@ package art.arcane.iris.platform.bukkit.nms.v26_3_R1;
import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeStructureWorldgenAccess; import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeStructureWorldgenAccess;
import art.arcane.iris.structure.placement.IrisStructureTerrain; import art.arcane.iris.structure.placement.IrisStructureTerrain;
import art.arcane.volmlib.nativelib.terrain.NativeBlockChangeSet;
import art.arcane.volmlib.util.structure.StructureTerrainMode; import art.arcane.volmlib.util.structure.StructureTerrainMode;
import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeStructurePostProcessor; import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeStructurePostProcessor;
import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeStructureTerrainIntegrator; import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeStructureTerrainIntegrator;
@@ -230,8 +231,9 @@ public class NativeStructureWorldgenAccessTest {
recording.world(), generationCenter, recording.world(), generationCenter,
(x, z) -> SURFACE_FIRST_FREE_Y, (x, z) -> SURFACE_FIRST_FREE_Y,
(x, z) -> FLOOR_FIRST_FREE_Y, (x, z) -> FLOOR_FIRST_FREE_Y,
true, true, true,
position -> false); position -> false,
NativeBlockChangeSet.disabled());
int x = generationCenter.getMiddleBlockX(); int x = generationCenter.getMiddleBlockX();
int z = generationCenter.getMiddleBlockZ(); int z = generationCenter.getMiddleBlockZ();
@@ -248,8 +250,9 @@ public class NativeStructureWorldgenAccessTest {
recording.world(), generationCenter(), recording.world(), generationCenter(),
(x, z) -> SURFACE_FIRST_FREE_Y, (x, z) -> SURFACE_FIRST_FREE_Y,
(x, z) -> FLOOR_FIRST_FREE_Y, (x, z) -> FLOOR_FIRST_FREE_Y,
false, false, true,
position::equals); position::equals,
NativeBlockChangeSet.disabled());
assertSame(Blocks.DIRT, access.getBlockState(position).getBlock()); assertSame(Blocks.DIRT, access.getBlockState(position).getBlock());
assertFalse(access.ensureCanWrite(position)); assertFalse(access.ensureCanWrite(position));
@@ -319,8 +322,9 @@ public class NativeStructureWorldgenAccessTest {
recording.world(), generationCenter(), recording.world(), generationCenter(),
(x, z) -> SURFACE_FIRST_FREE_Y, (x, z) -> SURFACE_FIRST_FREE_Y,
(x, z) -> FLOOR_FIRST_FREE_Y, (x, z) -> FLOOR_FIRST_FREE_Y,
false, false, true,
position -> false); position -> false,
NativeBlockChangeSet.disabled());
} }
private static ChunkPos generationCenter() { private static ChunkPos generationCenter() {
@@ -9,6 +9,7 @@ import art.arcane.iris.structure.nativegen.NativeStructureVolumeIndex;
import art.arcane.iris.generation.runtime.IrisEngine; import art.arcane.iris.generation.runtime.IrisEngine;
import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.Engine;
import art.arcane.volmlib.nativelib.terrain.structure.NativeStructureVolume; import art.arcane.volmlib.nativelib.terrain.structure.NativeStructureVolume;
import art.arcane.volmlib.nativelib.terrain.structure.StructureTerrainSettings;
import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter; import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter;
import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KList;
import net.minecraft.SharedConstants; import net.minecraft.SharedConstants;
@@ -69,7 +70,8 @@ public class NativeStructureVolumeIndexTest {
StructureStart start = swampHut(0, 0, 4, 6); StructureStart start = swampHut(0, 0, 4, 6);
BoundingBox bounds = start.getPieces().getFirst().getBoundingBox(); BoundingBox bounds = start.getPieces().getFirst().getBoundingBox();
KList<NativeStructureVolume> volumes = NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, start); KList<NativeStructureVolume> volumes = NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, start,
StructureTerrainSettings.SOURCE, (x, z) -> 63);
assertEquals(1, volumes.size()); assertEquals(1, volumes.size());
NativeStructureVolume volume = volumes.getFirst(); NativeStructureVolume volume = volumes.getFirst();
@@ -84,8 +86,10 @@ public class NativeStructureVolumeIndexTest {
@Test @Test
public void invalidStartsContributeNoVolumes() { public void invalidStartsContributeNoVolumes() {
assertNull(NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, StructureStart.INVALID_START)); assertNull(NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, StructureStart.INVALID_START,
assertNull(NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, null)); StructureTerrainSettings.SOURCE, (x, z) -> 63));
assertNull(NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, null,
StructureTerrainSettings.SOURCE, (x, z) -> 63));
} }
@Test @Test
@@ -323,7 +327,7 @@ public class NativeStructureVolumeIndexTest {
BlockingResolver coordination = new BlockingResolver(); BlockingResolver coordination = new BlockingResolver();
NativeStructureVolumeIndex.OriginResolver volumes = (engine, chunkX, chunkZ) -> NativeStructureVolumeIndex.OriginResolver volumes = (engine, chunkX, chunkZ) ->
new KList<>(new NativeStructureVolume(chunkX + ":" + chunkZ, new KList<>(new NativeStructureVolume(chunkX + ":" + chunkZ,
-256, 0, -256, 256, 255, 256)); -256, 0, -256, 256, 255, 256, null));
NativeStructureVolumeIndex index = NativeStructureVolumeIndex.forTesting((engine, chunkX, chunkZ) -> { NativeStructureVolumeIndex index = NativeStructureVolumeIndex.forTesting((engine, chunkX, chunkZ) -> {
coordination.volumesAt(engine, chunkX, chunkZ); coordination.volumesAt(engine, chunkX, chunkZ);
return volumes.volumesAt(engine, chunkX, chunkZ); return volumes.volumesAt(engine, chunkX, chunkZ);
@@ -369,7 +373,7 @@ public class NativeStructureVolumeIndexTest {
terrain.join(); terrain.join();
} }
return new KList<>(new NativeStructureVolume(chunkX + ":" + chunkZ, return new KList<>(new NativeStructureVolume(chunkX + ":" + chunkZ,
-256, 0, -256, 256, 100, 256)); -256, 0, -256, 256, 100, 256, null));
}); });
ExecutorService owner = Executors.newSingleThreadExecutor(); ExecutorService owner = Executors.newSingleThreadExecutor();
ForkJoinPool workers = new ForkJoinPool(2); ForkJoinPool workers = new ForkJoinPool(2);
@@ -493,7 +497,13 @@ public class NativeStructureVolumeIndexTest {
private static Map<?, ?> cache(NativeStructureVolumeIndex index, String name) throws Exception { private static Map<?, ?> cache(NativeStructureVolumeIndex index, String name) throws Exception {
Field field = NativeStructureVolumeIndex.class.getDeclaredField(name); Field field = NativeStructureVolumeIndex.class.getDeclaredField(name);
field.setAccessible(true); field.setAccessible(true);
return (Map<?, ?>) field.get(index); Object value = field.get(index);
if (value instanceof Map<?, ?> map) {
return map;
}
Field entries = value.getClass().getDeclaredField("entries");
entries.setAccessible(true);
return (Map<?, ?>) entries.get(value);
} }
private static StructureStart swampHut(int chunkX, int chunkZ, int x, int z) { private static StructureStart swampHut(int chunkX, int chunkZ, int x, int z) {
@@ -544,7 +554,8 @@ public class NativeStructureVolumeIndexTest {
if (chunkX != originChunkX || chunkZ != originChunkZ) { if (chunkX != originChunkX || chunkZ != originChunkZ) {
return NativeStructureVolume.NONE; return NativeStructureVolume.NONE;
} }
return NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, swampHut(chunkX, chunkZ, 4, 6)); return NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, swampHut(chunkX, chunkZ, 4, 6),
StructureTerrainSettings.SOURCE, (x, z) -> 63);
} }
} }
@@ -596,7 +607,7 @@ public class NativeStructureVolumeIndexTest {
return new KList<>(new NativeStructureVolume( return new KList<>(new NativeStructureVolume(
chunkX + ":" + chunkZ, chunkX + ":" + chunkZ,
0, 0, 0, 0, 0, 0,
15, 255, 15)); 15, 255, 15, null));
} }
} }
private record ScopedEngine(IrisEngine engine, AtomicInteger scopes) { private record ScopedEngine(IrisEngine engine, AtomicInteger scopes) {
@@ -19,6 +19,10 @@
package art.arcane.iris.world.runtime; package art.arcane.iris.world.runtime;
import art.arcane.iris.api.world.IrisWorldPhase; import art.arcane.iris.api.world.IrisWorldPhase;
import art.arcane.iris.generation.mantle.CustomBlockOwnership;
import art.arcane.iris.integration.ExternalDataSVC;
import art.arcane.iris.spi.IrisServices;
import art.arcane.volmlib.nativelib.terrain.NativeBlockPoint;
import art.arcane.iris.pack.datapack.ServerConfigurator; import art.arcane.iris.pack.datapack.ServerConfigurator;
import art.arcane.iris.pack.datapack.DatapackIngestService; import art.arcane.iris.pack.datapack.DatapackIngestService;
import art.arcane.iris.world.event.IrisEngineHotloadEvent; import art.arcane.iris.world.event.IrisEngineHotloadEvent;
@@ -51,6 +55,13 @@ import org.bukkit.World;
import org.bukkit.WorldBorder; import org.bukkit.WorldBorder;
public final class BukkitEnginePlatformHooks implements EnginePlatformHooks { public final class BukkitEnginePlatformHooks implements EnginePlatformHooks {
@Override
public boolean removeCustomBlock(Engine engine, NativeBlockPoint position, CustomBlockOwnership.Entry entry) {
ExternalDataSVC service = IrisServices.getOrNull(ExternalDataSVC.class);
return service != null && service.removeGenerated(
BukkitWorldBinding.world(engine.getWorld()).getBlockAt(position.x(), position.y(), position.z()), entry);
}
@Override @Override
public CompletableFuture<Void> flushSavedTerrainCapture(Engine engine) { public CompletableFuture<Void> flushSavedTerrainCapture(Engine engine) {
return INMS.get().flushSavedTerrainCapture(BukkitWorldBinding.world(engine.getWorld())); return INMS.get().flushSavedTerrainCapture(BukkitWorldBinding.world(engine.getWorld()));
@@ -0,0 +1,46 @@
{
"name": "Flat",
"derivative": "minecraft:snowy_plains",
"vanillaDerivative": "minecraft:snowy_plains",
"layers": [
{
"minHeight": 1,
"maxHeight": 1,
"palette": [
{
"block": "minecraft:lime_wool"
}
]
},
{
"minHeight": 384,
"maxHeight": 384,
"palette": [
{
"block": "minecraft:green_concrete"
}
]
}
],
"generators": [
{
"generator": "flat",
"min": 96,
"max": 96
}
],
"decorators": [
{
"palette": [
{
"block": "craftengine:default/flower_basket",
"data": {
"variant": "ground"
}
}
],
"chance": 1,
"forcePlace": true
}
]
}
@@ -0,0 +1,39 @@
{
"name": "CraftEngine Imported Feature Fixture",
"version": 1,
"mode": {
"type": "OVERWORLD"
},
"regions": [
"flat"
],
"focusRegion": "flat",
"focus": "flat",
"landChance": 1,
"environment": "NORMAL",
"dimensionHeight": {
"min": -64,
"max": 320
},
"logicalHeight": 384,
"fluidHeight": 0,
"bedrock": false,
"rockPalette": {
"palette": [
{
"block": "minecraft:red_concrete"
}
]
},
"importedStructures": {
"disabled": [
"minecraft:"
]
},
"importedFeatures": {
"enabled": true,
"steps": [
"TOP_LAYER_MODIFICATION"
]
}
}
@@ -0,0 +1,15 @@
{
"interpolator": {
"function": "NONE",
"horizontalScale": 1
},
"seed": 310,
"composite": [
{
"seed": 310,
"style": {
"style": "FLAT"
}
}
]
}
@@ -0,0 +1,12 @@
{
"name": "Flat",
"landBiomes": [
"flat"
],
"seaBiomes": [
"flat"
],
"shoreBiomes": [
"flat"
]
}
@@ -0,0 +1,35 @@
plugins {
id 'java'
}
version = '1.0.0'
repositories {
mavenCentral()
maven { url = 'https://repo.papermc.io/repository/maven-public/' }
}
dependencies {
compileOnly files(providers.gradleProperty('irisJar').get())
compileOnly files(providers.gradleProperty('craftEngineJar').get())
compileOnly 'io.papermc.paper:paper-api:26.1.2.build.74-stable'
}
java {
toolchain.languageVersion = JavaLanguageVersion.of(25)
}
sourceSets {
main {
java.srcDirs = ['src']
resources.srcDirs = ['resources']
}
}
tasks.withType(JavaCompile).configureEach {
options.compilerArgs += '-parameters'
}
tasks.named('jar') {
archiveBaseName = 'IrisCraftEngineNativeFixture'
}
@@ -0,0 +1,12 @@
name: IrisCraftEngineNativeFixture
version: 1.0.0
main: art.arcane.iris.fixture.CraftEngineNativeFixture
api-version: '26.1.2'
folia-supported: true
depend: [Iris, CraftEngine]
commands:
iriscenative:
permission: iriscenative.run
permissions:
iriscenative.run:
default: op
@@ -0,0 +1 @@
rootProject.name = 'iris-craftengine-native-fixture'
@@ -0,0 +1,385 @@
package art.arcane.iris.fixture;
import art.arcane.iris.generation.block.IrisCustomData;
import art.arcane.iris.generation.block.TileData;
import art.arcane.iris.generation.mantle.DeferredPlacementRecords;
import art.arcane.iris.generation.mantle.CustomBlockOwnership;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.integration.ExternalDataSVC;
import art.arcane.iris.integration.Identifier;
import art.arcane.iris.platform.generation.EngineBukkitOps;
import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.world.IrisCreator;
import art.arcane.iris.world.IrisToolbelt;
import art.arcane.iris.world.storage.matter.TileWrapper;
import art.arcane.iris.world.task.J;
import art.arcane.volmlib.nativelib.terrain.NativeBlockChangeSet;
import art.arcane.volmlib.util.mantle.flag.MantleFlag;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
import art.arcane.volmlib.util.matter.Matter;
import net.momirealms.craftengine.bukkit.api.CraftEngineBlocks;
import net.momirealms.craftengine.bukkit.api.CraftEngineFurniture;
import net.momirealms.craftengine.core.util.Key;
import org.bukkit.Bukkit;
import org.bukkit.Chunk;
import org.bukkit.GameMode;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.bukkit.plugin.java.JavaPlugin;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.logging.Level;
public final class CraftEngineNativeFixture extends JavaPlugin {
private static final NamespacedKey WORLD_KEY = new NamespacedKey("iris", "ce_native_probe");
private static final NamespacedKey FEATURE_WORLD_KEY = new NamespacedKey("iris", "ce_native_feature_probe");
private static final int TEST_Y = 180;
private static final int TEST_Z = 8;
private static final String BLOCK_ID = "craftengine:default/topaz_ore";
private static final String FURNITURE_ID = "craftengine:default/flower_basket[variant=ground]";
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (!(sender instanceof Player player) || arguments.length != 1) {
sender.sendMessage("CE_FAILED Use /iriscenative prepare|pending|neighbor|loaded|features|restart from a player");
return true;
}
if (arguments[0].equals("prepare")) {
player.setGameMode(GameMode.CREATIVE);
prepare(player);
return true;
}
if (arguments[0].equals("features")) {
importedFeatures(player);
return true;
}
World world = Bukkit.getWorld(WORLD_KEY);
if (world == null) {
fail(player, new IllegalStateException("Fixture world has not been created"));
return true;
}
if (arguments[0].equals("restart")) {
loadChunks().thenCompose(ignored -> J.runRegionFuture(world, 0, 0,
() -> later(player, world, () -> restarted(player, world))))
.whenComplete((ignored, failure) -> {
if (failure != null) {
fail(player, failure);
}
});
return true;
}
J.runRegionFuture(world, 0, 0, () -> {
switch (arguments[0]) {
case "pending" -> pending(player, world);
case "neighbor" -> neighbor(player, world);
case "loaded" -> invalidateLoaded(player, world, IrisToolbelt.access(world).getEngine());
default -> throw new IllegalArgumentException("Unknown fixture operation");
}
}).whenComplete((ignored, failure) -> {
if (failure != null) {
fail(player, failure);
}
});
return true;
}
private void prepare(Player player) {
CompletableFuture.runAsync(() -> {
if (Bukkit.getWorld(WORLD_KEY) == null) {
try {
new IrisCreator().name(WORLD_KEY.toString()).dimension("ce-native-fixture")
.seed(78264193L).create();
} catch (Exception failure) {
throw new IllegalStateException("Could not create CraftEngine fixture world", failure);
}
}
}).thenCompose(ignored -> loadChunks()).thenCompose(ignored -> {
World world = Bukkit.getWorld(WORLD_KEY);
return J.runRegionFuture(world, 0, 0, () -> preparePlatform(world));
}).thenAccept(ignored -> {
World world = Bukkit.getWorld(WORLD_KEY);
if (!J.runEntity(player, () -> player.teleportAsync(new Location(world, 8.5, TEST_Y + 3, 8.5))
.whenComplete((teleported, failure) -> {
if (failure != null || !Boolean.TRUE.equals(teleported)) {
fail(player, failure == null ? new IllegalStateException("Fixture teleport failed") : failure);
return;
}
player.sendMessage("CE_READY world=" + WORLD_KEY + " folia=" + J.isFolia());
}))) {
throw new IllegalStateException("Could not schedule fixture teleport");
}
}).exceptionally(failure -> {
fail(player, failure);
return null;
});
}
private void importedFeatures(Player player) {
CompletableFuture.runAsync(() -> {
if (Bukkit.getWorld(FEATURE_WORLD_KEY) == null) {
try {
new IrisCreator().name(FEATURE_WORLD_KEY.toString()).dimension("ce-native-feature-fixture")
.seed(78264193L).create();
} catch (Exception failure) {
throw new IllegalStateException("Could not create imported feature fixture world", failure);
}
}
}).thenCompose(ignored -> {
World world = Bukkit.getWorld(FEATURE_WORLD_KEY);
return world.getChunkAtAsync(0, 0, true);
}).thenCompose(chunk -> J.runRegionFuture(chunk.getWorld(), 0, 0, () -> {
World world = chunk.getWorld();
Engine engine = IrisToolbelt.access(world).getEngine();
int snow = 0;
int pending = 0;
for (int x = 0; x < 16; x++) {
for (int z = 0; z < 16; z++) {
if (world.getBlockAt(x, 97, z).getType() == Material.SNOW) {
snow++;
}
if (engine.getMantle().getMantle().get(x, 97 - engine.getMinHeight(), z, Identifier.class) != null) {
pending++;
}
}
}
require(snow == 256, "Imported snow feature did not cover all 256 test columns: " + snow);
require(pending == 0, "Imported feature left pending furniture records: " + pending);
for (Entity entity : chunk.getEntities()) {
require(!CraftEngineFurniture.isFurniture(entity), "Imported snow feature left generated furniture");
}
if (!J.runEntity(player, () -> player.teleportAsync(new Location(world, 8.5, 101, 8.5))
.whenComplete((teleported, failure) -> {
if (failure != null || !Boolean.TRUE.equals(teleported)) {
fail(player, failure == null ? new IllegalStateException("Feature world teleport failed") : failure);
return;
}
player.sendMessage("CE_FEATURES snow=256 pending=0 furniture=0");
}))) {
throw new IllegalStateException("Could not schedule feature world teleport");
}
})).exceptionally(failure -> {
fail(player, failure);
return null;
});
}
private CompletableFuture<Void> loadChunks() {
World world = Bukkit.getWorld(WORLD_KEY);
require(world != null && IrisToolbelt.isIrisWorld(world), "Fixture must use a real Iris world");
List<CompletableFuture<Void>> chunks = new ArrayList<>(15);
for (int x = -1; x <= 3; x++) {
for (int z = -1; z <= 1; z++) {
chunks.add(world.getChunkAtAsync(x, z, true).thenCompose(chunk ->
J.runRegionFuture(world, chunk.getX(), chunk.getZ(), () -> chunk.addPluginChunkTicket(this))));
}
}
return CompletableFuture.allOf(chunks.toArray(CompletableFuture[]::new));
}
private void preparePlatform(World world) {
require(CraftEngineBlocks.byId(Key.of("default:topaz_ore")) != null, "CraftEngine default block is missing");
require(CraftEngineFurniture.byId(Key.of("default:flower_basket")) != null, "CraftEngine furniture is missing");
for (int chunkX = 0; chunkX <= 2; chunkX++) {
for (Entity entity : world.getChunkAt(chunkX, 0).getEntities()) {
if (CraftEngineFurniture.isFurniture(entity)) {
CraftEngineFurniture.remove(entity, false, false);
}
}
}
for (int x = 0; x <= 47; x++) {
for (int z = 2; z <= 14; z++) {
world.getBlockAt(x, TEST_Y - 1, z).setType(Material.STONE, false);
for (int y = TEST_Y; y <= TEST_Y + 5; y++) {
world.getBlockAt(x, y, z).setType(Material.AIR, false);
}
}
}
}
private void pending(Player player, World world) {
Engine engine = IrisToolbelt.access(world).getEngine();
Mantle<Matter> mantle = engine.getMantle().getMantle();
queue(engine, world, 2, BLOCK_ID);
queue(engine, world, 4, BLOCK_ID);
queue(engine, world, 6, BLOCK_ID);
queue(engine, world, 8, BLOCK_ID);
queue(engine, world, 10, FURNITURE_ID);
queue(engine, world, 12, FURNITURE_ID);
Block chest = world.getBlockAt(14, TEST_Y, TEST_Z);
chest.setType(Material.CHEST, false);
mantle.set(14, TEST_Y - engine.getMinHeight(), TEST_Z, new TileWrapper(TileData.getTileState(chest, false)));
world.getBlockAt(4, TEST_Y, TEST_Z).setType(Material.AIR, false);
world.getBlockAt(6, TEST_Y, TEST_Z).setType(Material.COBBLESTONE, false);
NativeBlockChangeSet changes = DeferredPlacementRecords.track(engine, bounds(world));
changes.add(8, TEST_Y, TEST_Z);
changes.add(12, TEST_Y - 1, TEST_Z);
changes.add(12, TEST_Y, TEST_Z);
changes.add(14, TEST_Y, TEST_Z);
world.getBlockAt(8, TEST_Y, TEST_Z).setType(Material.AIR, false);
world.getBlockAt(12, TEST_Y - 1, TEST_Z).setType(Material.AIR, false);
chest.setType(Material.COBBLESTONE, false);
DeferredPlacementRecords.invalidate(engine, changes);
require(record(engine, 8, Identifier.class) == null, "Native cut retained a pending custom record");
require(record(engine, 12, Identifier.class) == null, "Flatten column retained pending furniture");
require(record(engine, 14, TileWrapper.class) == null, "Native wall retained pending chest data");
require(record(engine, 2, Identifier.class) != null, "Native writes invalidated an untouched custom record");
replay(engine, world, 0);
CustomBlockOwnership ownership = record(engine, 10, CustomBlockOwnership.class);
require(record(engine, 2, Identifier.class) == null, "Custom materialization did not consume pending records");
require(CraftEngineBlocks.isCustomBlock(world.getBlockAt(2, TEST_Y, TEST_Z)), "Intact custom block was not placed");
require(world.getBlockAt(4, TEST_Y, TEST_Z).getType().isAir(), "Cut custom block was resurrected");
require(world.getBlockAt(6, TEST_Y, TEST_Z).getType() == Material.COBBLESTONE, "Custom block replaced native wall");
require(world.getBlockAt(8, TEST_Y, TEST_Z).getType().isAir(), "Invalidated custom block was resurrected");
require(chest.getType() == Material.COBBLESTONE, "Chest data replaced native wall");
later(player, world, () -> {
int intactFurniture = furnitureAt(world, 10);
require(intactFurniture == 1, "Intact furniture count is " + intactFurniture
+ "; pending=" + record(engine, 10, Identifier.class)
+ "; ownership=" + ownership
+ "; chunkEntities=" + world.getChunkAt(0, 0).getEntities().length);
require(furnitureAt(world, 12) == 0, "Flattened furniture was resurrected");
player.sendMessage("CE_PENDING intact=1 air=1 wall=1 invalidated=1 flatten=1 chest=1 furniture=1");
});
}
private void neighbor(Player player, World world) {
Engine engine = IrisToolbelt.access(world).getEngine();
queue(engine, world, 18, FURNITURE_ID);
queue(engine, world, 20, FURNITURE_ID);
queue(engine, world, 22, FURNITURE_ID);
queue(engine, world, 42, FURNITURE_ID);
replay(engine, world, 1);
replay(engine, world, 2);
world.getBlockAt(40, TEST_Y, TEST_Z).setType(Material.DIAMOND_BLOCK, false);
later(player, world, () -> {
require(furnitureAt(world, 18) == 1 && furnitureAt(world, 20) == 1
&& furnitureAt(world, 22) == 1, "Neighbor furniture did not materialize");
require(furnitureAt(world, 42) == 1 && record(engine, 42, CustomBlockOwnership.class) != null,
"Regeneration target furniture did not materialize with saved ownership");
if (!J.runEntity(player, () -> player.teleportAsync(new Location(world, 40.5, TEST_Y + 3, TEST_Z + 0.5))
.whenComplete((teleported, failure) -> {
if (failure != null || !Boolean.TRUE.equals(teleported)) {
fail(player, failure == null ? new IllegalStateException("Regeneration target teleport failed") : failure);
return;
}
player.sendMessage("CE_NEIGHBOR_READY chunk=2,0 furniture=3 target=1");
}))) {
throw new IllegalStateException("Could not schedule regeneration target teleport");
}
});
}
private void invalidateLoaded(Player player, World world, Engine engine) {
require(world.getBlockAt(40, TEST_Y, TEST_Z).getType().isAir(), "Adjacent target chunk did not regenerate");
require(furnitureAt(world, 18) == 1 && furnitureAt(world, 20) == 1
&& furnitureAt(world, 22) == 1, "Neighbor furniture did not materialize");
NativeBlockChangeSet changes = DeferredPlacementRecords.track(engine, bounds(world));
changes.add(18, TEST_Y - 1, TEST_Z);
changes.add(18, TEST_Y, TEST_Z);
changes.add(22, TEST_Y, TEST_Z);
world.getBlockAt(18, TEST_Y - 1, TEST_Z).setType(Material.AIR, false);
world.getBlockAt(18, TEST_Y, TEST_Z).setType(Material.COBBLESTONE, false);
DeferredPlacementRecords.invalidate(engine, changes);
require(CraftEngineFurniture.place(new Location(world, 22.5, TEST_Y, TEST_Z + 0.5),
Key.of("default:flower_basket"), "ground", false) != null,
"Could not place replacement furniture before scheduled cleanup");
later(player, world, () -> {
require(furnitureAt(world, 42) == 0, "Regeneration retained target chunk furniture");
require(furnitureAt(world, 18) == 0, "Native write left materialized neighbor furniture");
require(furnitureAt(world, 20) == 1, "Native write removed untouched neighbor furniture");
require(furnitureAt(world, 22) == 1, "Native cleanup removed replacement furniture or retained the old one");
require(world.getBlockAt(18, TEST_Y, TEST_Z).getType() == Material.COBBLESTONE,
"Furniture cleanup replaced the native wall");
player.sendMessage("CE_LOADED removed=1 untouched=1 replacement=1 wall=1 target=1");
});
}
private void restarted(Player player, World world) {
require(furnitureAt(world, 20) == 1, "Generated neighbor furniture did not survive restart");
require(furnitureAt(world, 10) == 1, "Untouched furniture did not survive restart");
Engine engine = IrisToolbelt.access(world).getEngine();
NativeBlockChangeSet changes = DeferredPlacementRecords.track(engine, bounds(world));
changes.add(20, TEST_Y, TEST_Z);
DeferredPlacementRecords.invalidate(engine, changes);
later(player, world, () -> {
require(furnitureAt(world, 20) == 0, "Persisted furniture ownership was not invalidated after restart");
require(furnitureAt(world, 10) == 1, "Restart cleanup removed untouched furniture");
player.sendMessage("CE_RESTART removed=1 untouched=1");
});
}
private void queue(Engine engine, World world, int x, String identifier) {
ExternalDataSVC service = IrisServices.get(ExternalDataSVC.class);
BlockData data = service.getBlockData(Identifier.fromString(identifier)).orElseThrow();
require(data instanceof IrisCustomData, "Iris did not resolve real CraftEngine content: " + identifier);
IrisCustomData custom = (IrisCustomData) data;
world.getBlockAt(x, TEST_Y, TEST_Z).setBlockData(custom.getBase(), false);
engine.getMantle().getMantle().set(x, TEST_Y - engine.getMinHeight(), TEST_Z, custom.getCustom());
}
private <T> T record(Engine engine, int x, Class<T> type) {
return engine.getMantle().getMantle().get(x, TEST_Y - engine.getMinHeight(), TEST_Z, type);
}
private void replay(Engine engine, World world, int chunkX) {
Mantle<Matter> mantle = engine.getMantle().getMantle();
for (int x = chunkX - 1; x <= chunkX + 1; x++) {
for (int z = -1; z <= 1; z++) {
require(world.isChunkLoaded(x, z), "Replay neighbor is not loaded: " + x + "," + z);
}
}
require(mantle.isLoaded(world.getChunkAt(chunkX, 0)), "Replay mantle chunk is not loaded: " + chunkX);
mantle.flag(chunkX, 0, MantleFlag.ETCHED, false);
mantle.flag(chunkX, 0, MantleFlag.TILE, false);
mantle.flag(chunkX, 0, MantleFlag.CUSTOM, false);
EngineBukkitOps.updateChunk(engine, world.getChunkAt(chunkX, 0), mantle);
}
private int furnitureAt(World world, int x) {
int count = 0;
for (Entity entity : world.getChunkAt(x >> 4, TEST_Z >> 4).getEntities()) {
Location location = entity.getLocation();
if (location.getBlockX() == x && location.getBlockY() == TEST_Y && location.getBlockZ() == TEST_Z
&& CraftEngineFurniture.getLoadedFurnitureByMetaEntity(entity) != null) {
count++;
}
}
return count;
}
private void later(Player player, World world, Runnable assertion) {
Bukkit.getRegionScheduler().runDelayed(this, new Location(world, 8, TEST_Y, TEST_Z), task -> {
try {
assertion.run();
} catch (Throwable failure) {
fail(player, failure);
}
}, 20);
}
private NativeBlockChangeSet.Bounds bounds(World world) {
return new NativeBlockChangeSet.Bounds(-1, -1, 3, 1, world.getMinHeight(), world.getMaxHeight() - world.getMinHeight());
}
private void require(boolean condition, String message) {
if (!condition) {
throw new IllegalStateException(message);
}
}
private void fail(CommandSender sender, Throwable failure) {
getLogger().log(Level.SEVERE, "CraftEngine native placement assertion failed", failure);
sender.sendMessage("CE_FAILED " + failure.getMessage());
}
}
@@ -0,0 +1,32 @@
{
"name": "Flat",
"derivative": "minecraft:plains",
"vanillaDerivative": "minecraft:plains",
"layers": [
{
"minHeight": 1,
"maxHeight": 1,
"palette": [
{
"block": "minecraft:lime_wool"
}
]
},
{
"minHeight": 384,
"maxHeight": 384,
"palette": [
{
"block": "minecraft:green_concrete"
}
]
}
],
"generators": [
{
"generator": "flat",
"min": 96,
"max": 96
}
]
}
@@ -0,0 +1,33 @@
{
"name": "CraftEngine Native Fixture",
"version": 1,
"mode": {
"type": "OVERWORLD"
},
"regions": [
"flat"
],
"focusRegion": "flat",
"focus": "flat",
"landChance": 1,
"environment": "NORMAL",
"dimensionHeight": {
"min": -64,
"max": 320
},
"logicalHeight": 384,
"fluidHeight": 0,
"bedrock": false,
"rockPalette": {
"palette": [
{
"block": "minecraft:red_concrete"
}
]
},
"importedStructures": {
"disabled": [
"minecraft:"
]
}
}
@@ -0,0 +1,15 @@
{
"interpolator": {
"function": "NONE",
"horizontalScale": 1
},
"seed": 310,
"composite": [
{
"seed": 310,
"style": {
"style": "FLAT"
}
}
]
}
@@ -0,0 +1,12 @@
{
"name": "Flat",
"landBiomes": [
"flat"
],
"seaBiomes": [
"flat"
],
"shoreBiomes": [
"flat"
]
}
@@ -0,0 +1,15 @@
import { readFile } from 'node:fs/promises'
import path from 'node:path'
export default {
name: 'iris-craftengine-native-restart',
description: 'Verify persisted generated furniture ownership after a full server restart.',
async run(context) {
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
context.expect(/^isolated=true\s*$/m.test(source), 'CraftEngine regression requires an isolated instance')
await context.step('native writes remove persisted ownership after restart', async () => {
const response = await context.command('/iriscenative restart', /CE_RESTART|CE_FAILED/, 60000)
context.expect(response.includes('CE_RESTART removed=1 untouched=1'), response)
})
}
}
@@ -0,0 +1,49 @@
import { readFile } from 'node:fs/promises'
import path from 'node:path'
export default {
name: 'iris-craftengine-native',
description: 'Verify deferred native invalidation and loaded neighbor cleanup with real Iris and CraftEngine.',
async run(context) {
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
context.expect(/^isolated=true\s*$/m.test(source), 'CraftEngine regression requires an isolated instance')
await context.step('create and enter a real Iris world with CraftEngine content', async () => {
const response = await context.command('/iriscenative prepare', /CE_READY|CE_FAILED/, 180000)
context.expect(response.includes('CE_READY'), response)
})
await context.step('native writes remove pending block, chest, and flatten furniture records', async () => {
const response = await context.command('/iriscenative pending', /CE_PENDING|CE_FAILED/, 60000)
context.expect(response.includes('CE_PENDING intact=1 air=1 wall=1 invalidated=1 flatten=1 chest=1 furniture=1'), response)
await context.waitUntil(() => {
const origin = context.bot.entity.position.floored()
const cut = context.bot.blockAt(origin.set(4, 180, 8))
const wall = context.bot.blockAt(origin.set(6, 180, 8))
const invalidated = context.bot.blockAt(origin.set(8, 180, 8))
const chest = context.bot.blockAt(origin.set(14, 180, 8))
return cut?.name === 'air' && wall?.name === 'cobblestone'
&& invalidated?.name === 'air' && chest?.name === 'cobblestone'
}, { label: 'Client observes retained native air and walls', timeoutMs: 30000, intervalMs: 100 })
})
await context.step('regeneration removes target furniture and preserves neighbor ownership', async () => {
const ready = await context.command('/iriscenative neighbor', /CE_NEIGHBOR_READY|CE_FAILED/, 30000)
context.expect(ready.includes('CE_NEIGHBOR_READY chunk=2,0 furniture=3 target=1'), ready)
const regenerated = await context.command('/iris developer regen radius=0', /Regen complete:|Regen failed:/, 90000)
context.expect(regenerated.includes('Regen complete:'), regenerated)
const response = await context.command('/iriscenative loaded', /CE_LOADED|CE_FAILED/, 30000)
context.expect(response.includes('CE_LOADED removed=1 untouched=1 replacement=1 wall=1 target=1'), response)
})
await context.step('imported snow features replace generated furniture records', async () => {
const response = await context.command('/iriscenative features', /CE_FEATURES|CE_FAILED/, 180000)
context.expect(response.includes('CE_FEATURES snow=256 pending=0 furniture=0'), response)
await context.waitUntil(() => {
const position = context.bot.entity.position.floored()
for (let x = 0; x < 16; x++) {
for (let z = 0; z < 16; z++) {
if (context.bot.blockAt(position.set(x, 97, z))?.name !== 'snow') return false
}
}
return true
}, { label: 'Client observes 256 imported snow layers', timeoutMs: 30000, intervalMs: 100 })
})
}
}
@@ -0,0 +1,61 @@
import { readFile } from 'node:fs/promises'
import path from 'node:path'
const world = 'native-terrain-support'
const air = new Set(['air', 'cave_air', 'void_air'])
export default {
name: 'iris-native-terrain-support',
description: 'Verify native terrain fitting preserves buried caves and excludes trees from lowered ground.',
async run(context) {
const bot = context.bot
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
context.expect(/^isolated=true\s*$/m.test(source), 'Requires an isolated instance')
const position = bot.entity.position.clone()
const at = (x, y, z) => bot.blockAt(position.clone().set(x, y, z))
const teleport = async (x, y, z) => {
await context.command(`/minecraft:tp @s ${x + 0.5} ${y} ${z + 0.5}`, /Teleported/i, 60000)
await context.waitUntil(() => Math.abs(bot.entity.position.x - x - 0.5) < 1
&& Math.abs(bot.entity.position.z - z - 0.5) < 1 && at(x, y, z),
{ label: 'Coordinate chunk received', timeoutMs: 90000, intervalMs: 100 })
}
await context.step('enter the seeded overworld', async () => {
if (context.options.command !== 'reuse') {
await context.command(`/iris create name=${world} type=overworld seed=1337`,
/Successfully created your world/i, 240000)
}
await context.command(`/iris tp ${world}`)
await context.waitUntil(async () => {
const snapshot = await context.observe()
const player = snapshot.players.find(entry => entry.username === bot.username)
return snapshot.worlds.some(entry => entry.id === player?.world && entry.name.endsWith(world))
}, { label: 'Entered seeded overworld', timeoutMs: 90000, intervalMs: 100 })
await context.command('/minecraft:gamemode spectator @s', /Spectator Mode/i)
await context.command('/minecraft:seed', /Seed:.*1337/)
})
await context.step('preserve caves beneath the ruined portal terrain skirt', async () => {
await teleport(995, 44, -3611)
await context.waitUntil(() => at(990, 43, -3626) && at(992, 43, -3627),
{ label: 'Cave columns received', timeoutMs: 90000, intervalMs: 100 })
for (const [x, z, bottom, top] of [[990, -3626, 33, 54], [992, -3627, 34, 55]]) {
for (let y = bottom; y <= top; y++) {
const block = at(x, y, z)
context.expect(block && air.has(block.name), 'Native terrain fitting filled a buried cave',
{ x, y, z, material: block?.name })
}
await context.command(`/execute if block ${x} 43 ${z} minecraft:cave_air run say CAVE_PRESERVED_${x}`,
new RegExp(`CAVE_PRESERVED_${x}`))
}
})
await context.step('exclude the pine whose support is removed by village grading', async () => {
await teleport(-2601, 174, -7063)
await context.waitUntil(() => at(-2601, 167, -7063),
{ label: 'Pine column received', timeoutMs: 90000, intervalMs: 100 })
const block = at(-2601, 167, -7063)
context.expect(air.has(block.name), 'Tree remains above terrain removed by native grading',
{ x: -2601, y: 167, z: -7063, material: block.name })
await context.command('/execute if block -2601 167 -7063 minecraft:air run say PINE_EXCLUDED',
/PINE_EXCLUDED/)
})
}
}
@@ -0,0 +1,20 @@
{
"name": "Flat",
"derivative": "minecraft:plains",
"vanillaDerivative": "minecraft:plains",
"layers": [
{"minHeight": 1, "maxHeight": 1, "palette": [{"block": "minecraft:lime_wool"}]},
{"minHeight": 128, "maxHeight": 128, "palette": [{"block": "minecraft:stone"}]}
],
"generators": [{"generator": "flat", "min": 32, "max": 32}],
"objects": [{
"place": ["smolshroom1"],
"chance": 1,
"density": 8,
"mode": "ORGANIC_STILT",
"rotation": {"enabled": false},
"scale": {"size": 0.75, "interpolation": "NONE"},
"translate": {"y": 6},
"stiltSettings": {"organicMaxScan": 8, "organicJitter": 0, "organicScratch": 0}
}]
}
@@ -0,0 +1,15 @@
{
"name": "Object Scaling Fixture",
"version": 1,
"mode": {"type": "OVERWORLD"},
"regions": ["flat"],
"focusRegion": "flat",
"focus": "flat",
"landChance": 1,
"environment": "NORMAL",
"dimensionHeight": {"min": 0, "max": 128},
"logicalHeight": 128,
"fluidHeight": 0,
"bedrock": false,
"caveProfile": {"enabled": false}
}
@@ -0,0 +1,5 @@
{
"seed": 310,
"interpolator": {"function": "NONE", "horizontalScale": 1},
"composite": [{"seed": 310, "style": {"style": "FLAT"}}]
}
@@ -0,0 +1,6 @@
{
"name": "Flat",
"landBiomes": ["flat"],
"seaBiomes": ["flat"],
"shoreBiomes": ["flat"]
}
@@ -0,0 +1,105 @@
import { createHash } from 'node:crypto'
import { readFile, realpath } from 'node:fs/promises'
import path from 'node:path'
export default {
name: 'iris-object-scaling',
description: 'Verify fractional mushroom scaling retains its cap and uses its stem for organic supports.',
async run(context) {
const directory = await realpath(context.server.directory)
const source = await readFile(path.join(directory, '.server-source'), 'utf8')
context.expect(/^isolated=true\s*$/m.test(source), 'Object scaling requires an isolated instance')
const pack = await realpath(path.join(directory, 'plugins', 'Iris', 'packs', 'object-scaling-fixture'))
context.expect(pack.startsWith(`${directory}${path.sep}`), 'Object scaling requires a local fixture pack')
const object = await readFile(path.join(pack, 'objects', 'smolshroom1.iob'))
const objectHash = createHash('sha256').update(object).digest('hex')
const biome = JSON.parse(await readFile(path.join(pack, 'biomes', 'flat.json'), 'utf8'))
const surfaceY = biome.generators[0].min
const originalStemY = surfaceY + biome.objects[0].translate.y
const expectedCapY = originalStemY + 1
const maximumExtension = biome.objects[0].stiltSettings.organicMaxScan
context.expect(surfaceY === 32 && expectedCapY === 39 && maximumExtension === 8,
'Expected the bounded organic support fixture', { surfaceY, expectedCapY, maximumExtension })
const world = context.options.command ?? 'object_scaling_probe'
context.expect(/^[a-z0-9_]+$/.test(world), 'Fixture world name must be a simple identifier')
await context.step('create the fractional mushroom fixture world', async () => {
const response = await context.command(`/iris create name=${world} type=object-scaling-fixture seed=1337`,
/Successfully created your world|World ready|Failed to create|Exception raised/i, 180000)
context.expect(/Successfully created your world|World ready/i.test(response), response)
await context.command(`/iris tp ${world}`)
await context.waitUntil(async () => {
const observation = await context.observe()
const player = observation.players.find(entry => entry.username === context.bot.username)
const current = observation.worlds.find(entry => entry.id === player?.world)
return current?.key === `iris:${world}` || current?.name.endsWith(world)
}, { label: 'Enter the scaling fixture world', timeoutMs: 60000, intervalMs: 100 })
await context.command('/minecraft:gamemode spectator @s')
await context.waitUntil(() => context.bot.game.gameMode === 'spectator',
{ label: 'Spectator mode', timeoutMs: 30000, intervalMs: 100 })
await context.command('/minecraft:tp @s 0.5 48 0.5', /Teleported/i, 30000)
})
const columns = []
await context.step('client sees one cap and continuous stem supports', async () => {
const position = context.bot.entity.position.clone()
await context.waitUntil(() => {
if (Math.abs(context.bot.entity.position.x - 0.5) > 1
|| Math.abs(context.bot.entity.position.z - 0.5) > 1) return false
for (const x of [-16, 0, 16, 31]) {
for (const z of [-16, 0, 16, 31]) {
for (const y of [0, 32, 63]) {
if (!context.bot.blockAt(position.set(x, y, z))) return false
}
}
}
return true
}, { label: 'Complete mushroom fixture chunks', timeoutMs: 60000, intervalMs: 100 })
for (let x = -16; x < 32; x++) {
for (let z = -16; z < 32; z++) {
const caps = []
const stems = []
for (let y = 1; y < 63; y++) {
const block = context.bot.blockAt(position.set(x, y, z))
context.expect(block !== null, 'Every sampled mushroom block must be loaded', { x, y, z })
if (block.name === 'red_mushroom_block') caps.push(y)
if (block.name === 'mushroom_stem') stems.push(y)
}
if (caps.length === 0) continue
context.expect(caps.length === 1, 'Mushroom cap was copied into its stem or support', { x, z, caps })
const capY = caps[0]
context.expect(capY === expectedCapY, 'Fractional scaling changed the expected mushroom height',
{ x, z, capY, expectedCapY })
const bottomY = stems[0]
const stemBlocks = stems.length
context.expect(bottomY >= originalStemY - maximumExtension && bottomY <= surfaceY + 1,
'Mushroom support must reach the surface within its configured extension limit',
{ x, z, capY, bottomY, surfaceY, maximumExtension, stems })
context.expect(stemBlocks === capY - bottomY
&& stems.every((y, index) => y === bottomY + index),
'Scaled mushroom must have a continuous stem from its cap to the ground', { x, z, capY, stems })
const supportY = bottomY - 1
const support = context.bot.blockAt(position.set(x, supportY, z))?.name
const above = context.bot.blockAt(position.set(x, capY + 1, z))?.name
context.expect(support === (supportY === surfaceY ? 'lime_wool' : 'stone'),
'Mushroom stem must terminate on the fixture terrain', { x, z, capY, supportY, support })
context.expect(above === 'air', 'Scaled mushroom has unexpected blocks above its cap', { x, z, capY, above })
columns.push({ x, z, surfaceY, capY, bottomY, stemBlocks,
buriedStemBlocks: Math.max(0, surfaceY - bottomY + 1) })
}
}
context.expect(columns.length >= 8, 'Expected at least eight scaled mushroom columns', { columns })
})
await context.step('server confirms the sampled stem and cap materials', async () => {
for (const [index, column] of columns.slice(0, 8).entries()) {
const { x, z, surfaceY, capY, bottomY } = column
const marker = `IRIS_SCALING_OK_${index}`
await context.command(`/minecraft:execute if block ${x} ${surfaceY + 1} ${z} minecraft:mushroom_stem`
+ ` if block ${x} ${bottomY} ${z} minecraft:mushroom_stem`
+ ` if block ${x} ${capY} ${z} minecraft:red_mushroom_block run minecraft:say ${marker}`,
new RegExp(marker), 10000)
}
})
context.report.objectScaling = { world, scale: 0.75, object: 'smolshroom1', objectHash,
surfaceY, maximumExtension, checkedColumns: columns.length,
serverConfirmedColumns: Math.min(8, columns.length), columns }
}
}
@@ -0,0 +1,82 @@
import { createHash } from 'node:crypto'
import { readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
const worlds = [
{ name: 'seed-selection-default', seed: '1337', argument: '' },
{ name: 'seed-selection-positive', seed: '78264193', argument: ' seed=78264193' },
{ name: 'seed-selection-negative', seed: '-734829104958217364', argument: ' seed=-734829104958217364' },
{ name: 'seed-selection-zero', seed: '0', argument: ' seed=0' }
]
export default {
name: 'iris-seed-selection',
description: 'Verify omitted, positive, negative, and zero creation seeds against visible terrain and a cold restart.',
async run(context) {
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
context.expect(/^isolated=true\s*$/m.test(source), 'Seed selection requires an isolated instance')
const wait = (predicate, label) => context.waitUntil(predicate, { label, timeoutMs: 60000, intervalMs: 100 })
const restart = context.options.command === 'restart-check'
const stateFile = path.join(context.server.directory, '.seed-selection.json')
const saved = restart ? JSON.parse(await readFile(stateFile, 'utf8')) : undefined
const jarHash = createHash('sha256').update(await readFile(path.join(context.server.directory, 'plugins', 'Iris.jar'))).digest('hex')
const processId = (await context.observe()).processId
if (restart) {
context.expect(saved.processId !== processId, 'Restart-check requires a cold server restart')
context.expect(saved.jarHash === jarHash, 'Restart-check requires the same Iris jar')
}
const outputs = {}
for (const world of worlds) {
await context.step(`${restart ? 'Reload' : 'Create'} ${world.name} with seed ${world.seed}`, async () => {
if (!restart) {
await context.command(`/iris create name=${world.name} type=cohesion${world.argument}`,
/Successfully created your world/i, 180000)
}
await context.command(`/iris tp ${world.name}`)
await wait(async () => {
const observation = await context.observe()
const player = observation.players.find(entry => entry.username === context.bot.username)
const current = observation.worlds.find(entry => entry.id === player?.world)
return current?.key === `iris:${world.name}` || current?.name.endsWith(world.name)
}, `Enter ${world.name}`)
await context.command('/minecraft:gamemode spectator @s')
await wait(() => context.bot.game.gameMode === 'spectator', 'Spectator mode')
await context.command('/minecraft:seed', new RegExp(`Seed:\\s*\\[?${world.seed}\\]?(?:\\s|$)`))
const x = 1024
const z = -1024
await context.command(`/minecraft:tp @s ${x + 8.5} 48 ${z + 8.5}`, /Teleported/i, 30000)
await wait(() => Math.abs(context.bot.entity.position.x - x - 8.5) < 1
&& Math.abs(context.bot.entity.position.z - z - 8.5) < 1, 'Sample teleport')
const position = context.bot.entity.position.clone()
await wait(() => context.bot.blockAt(position.clone().set(x, 0, z))
&& context.bot.blockAt(position.clone().set(x + 15, 63, z + 15)), 'Complete sample chunk')
const digest = createHash('sha256')
let stone = 0
for (let dx = 0; dx < 16; dx++) {
for (let dz = 0; dz < 16; dz++) {
for (let y = 0; y < 64; y++) {
const block = context.bot.blockAt(position.set(x + dx, y, z + dz))
context.expect(block !== null, 'Missing sampled block', { x: x + dx, y, z: z + dz })
digest.update(`${block.stateId},`)
if (block.name === 'stone') stone++
}
}
}
context.expect(stone > 1000, 'Fixture terrain was not generated', { world: world.name, stone })
const blocks = digest.digest('hex')
outputs[world.name] = { seed: world.seed, blocks }
if (restart) {
context.expect(saved.worlds[world.name].seed === world.seed, 'Seed expectation changed after restart')
context.expect(saved.worlds[world.name].blocks === blocks, 'Persisted terrain changed after restart',
{ world: world.name, before: saved.worlds[world.name].blocks, after: blocks })
}
})
}
const signatures = new Set(Object.values(outputs).map(output => output.blocks))
context.expect(signatures.size === worlds.length, 'Different requested seeds generated identical sample chunks', outputs)
context.report.seedSelection = { restart, jarHash, worlds: outputs }
if (!restart) {
await writeFile(stateFile, JSON.stringify({ processId, jarHash, worlds: outputs }))
}
}
}
@@ -0,0 +1,143 @@
import { readFile, realpath } from 'node:fs/promises'
import path from 'node:path'
const nonGround = new Set(['air', 'cave_air', 'void_air', 'water', 'lava', 'nether_wart_block', 'warped_wart_block'])
const chunkOffsets = [-56, -40, -24, -8, 8, 24, 40, 56]
function isWood(block) {
return block !== null && /_(wood|hyphae|log|stem)$/.test(block.name)
}
function isGround(block) {
return block !== null && block.boundingBox === 'block' && !nonGround.has(block.name)
&& !block.name.endsWith('_leaves') && !isWood(block)
}
function positionKey(position) {
return `${position.x},${position.y},${position.z}`
}
function groundSpread(bot, base) {
const heights = []
for (const x of [-3, 0, 3]) {
for (const z of [-3, 0, 3]) {
for (let y = base.y + 6; y >= base.y - 8; y--) {
if (!isGround(bot.blockAt(base.offset(x, y - base.y, z)))) continue
heights.push(y)
break
}
}
}
return heights.length === 9 ? Math.max(...heights) - Math.min(...heights) : null
}
function inspectTrees(context) {
const bot = context.bot
const center = bot.entity.position.floored()
const woodIds = Object.values(bot.registry.blocksByName).filter(isWood).map(block => block.id)
const positions = bot.findBlocks({ matching: woodIds, point: center, maxDistance: 64, count: 20000 })
const columns = new Map()
for (const position of positions) {
const key = `${position.x},${position.z}`
if (!columns.has(key)) columns.set(key, [])
columns.get(key).push(position)
}
const trees = []
for (const column of columns.values()) {
column.sort((first, second) => first.y - second.y)
const base = column[0]
const height = column[column.length - 1].y - base.y + 1
if (column.length < 8 || height < 8 || column.length / height < 0.65) continue
if (column.some(position => position.distanceTo(center) >= 60)) continue
if (![0, 1, 2, 3].every(y => isWood(bot.blockAt(base.offset(0, y, 0))))) continue
const upperGaps = []
for (let y = base.y; y < base.y + height; y++) {
const block = bot.blockAt(base.offset(0, y - base.y, 0))
if (block === null || !isWood(block)) upperGaps.push({ y, block: block?.name ?? null })
}
const grounded = isGround(bot.blockAt(base.offset(0, -1, 0)))
const spread = groundSpread(bot, base)
trees.push({ base: { x: base.x, y: base.y, z: base.z }, woodBlocks: column.length,
height, grounded, terrainSpread: spread, upperGaps })
}
return trees
}
export default {
name: 'iris-tree-grounding',
description: 'Verify grounded spindle bases, continuous overlapping trunks, and paired pack geometry.',
async run(context) {
const directory = await realpath(context.server.directory)
const source = await readFile(path.join(directory, '.server-source'), 'utf8')
context.expect(/^isolated=true\s*$/m.test(source), 'Tree generation requires an isolated instance')
const packs = await realpath(path.join(directory, 'plugins', 'Iris', 'packs'))
context.expect(packs.startsWith(`${directory}${path.sep}`), 'Tree tests require local pack copies')
context.report.treeWorlds = []
context.report.treeGrounding = []
context.report.treeContinuity = []
for (const pack of ['overworld', 'underworld']) {
const dimension = JSON.parse(await readFile(path.join(packs, pack, 'dimensions', `${pack}.json`), 'utf8'))
context.expect(dimension.focus === 'estranged/spindlewood', 'Pack must focus on spindlewood', { pack })
await context.step(`create ${pack} spindle world`, async () => {
const world = `tree_${pack}_${Date.now().toString(36)}`
const response = await context.command(`/iris create name=${world} type=${pack} seed=78264193`,
/Successfully created your world|Failed to create/, 240000)
context.expect(response.includes('Successfully created your world'), response)
context.report.treeWorlds.push({ pack, name: world, seed: 78264193, biome: dimension.focus })
if (context.bot.game.gameMode !== 'spectator') {
await context.command('/minecraft:gamemode spectator', /spectator/i, 30000)
}
await context.waitUntil(() => context.bot.game.gameMode === 'spectator',
{ label: 'Spectator mode for terrain inspection', timeoutMs: 30000, intervalMs: 100 })
})
await context.step(`${pack} trees connect to flat and sloped terrain`, async () => {
const origin = context.bot.entity.position.floored()
const inspected = new Map()
for (const offset of [0, 80, 160]) {
await context.command(`/minecraft:tp @s ${origin.x + offset + 0.5} ${origin.y + 12} ${origin.z + 0.5}`,
/Teleported/i, 30000)
await context.waitUntil(() => {
const center = context.bot.entity.position.floored()
if (Math.abs(center.x - origin.x - offset) > 1) return false
return chunkOffsets.every(x => chunkOffsets.every(z => context.bot.blockAt(center.offset(x, 0, z)) !== null))
}, { label: 'Complete terrain neighborhood', timeoutMs: 90000, intervalMs: 250 })
const trees = inspectTrees(context)
for (const tree of trees) {
context.expect(tree.grounded, 'Generated spindle trunk base has no terrain connection', { pack, ...tree })
inspected.set(positionKey(tree.base), tree)
}
if (inspected.size >= 8 && [...inspected.values()].some(tree => tree.terrainSpread >= 2)) break
}
const trees = [...inspected.values()]
const sloped = trees.filter(tree => tree.terrainSpread >= 2)
context.expect(trees.length >= 8, 'Expected at least eight generated spindle trunk bases', { pack, trees })
context.expect(sloped.length > 0, 'Expected a grounded tree on uneven terrain', { pack, trees })
context.report.treeGrounding.push({ pack, trees: trees.length, sloped: sloped.length, samples: trees })
})
await context.step(`${pack} overlapping canopy preserves the seeded main trunk`, async () => {
await context.command('/minecraft:tp @s 0.5 88 0.5', /Teleported/i, 30000)
await context.waitUntil(() => {
const center = context.bot.entity.position.floored()
return Math.abs(center.x) < 2 && Math.abs(center.z) < 2
&& context.bot.blockAt(center.offset(-7 - center.x, 69 - center.y, 6 - center.z)) !== null
}, { label: 'Seeded trunk neighborhood', timeoutMs: 90000, intervalMs: 250 })
const center = context.bot.entity.position.floored()
const blocks = []
for (let y = 69; y <= 83; y++) {
const block = context.bot.blockAt(center.offset(-7 - center.x, y - center.y, 6 - center.z))
context.expect(isWood(block), 'Overlapping canopy interrupted the seeded main trunk',
{ pack, x: -7, y, z: 6, block: block?.name ?? null })
blocks.push({ y, block: block.name })
}
context.report.treeContinuity.push({ pack, x: -7, z: 6, blocks })
})
}
await context.step('paired packs retain matching sampled tree geometry', async () => {
const geometry = context.report.treeGrounding.map(result => result.samples.map(tree => ({
base: tree.base, woodBlocks: tree.woodBlocks, height: tree.height, terrainSpread: tree.terrainSpread
})).sort((first, second) => positionKey(first.base).localeCompare(positionKey(second.base))))
context.expect(JSON.stringify(geometry[0]) === JSON.stringify(geometry[1]),
'Paired spindle packs produced different sampled geometry', { geometry })
})
}
}
+3 -3
View File
@@ -58,10 +58,11 @@ IrisFileOperations irisFileOperations = objects.newInstance(IrisFileOperations)
apply from: file('../../gradle/modded-generation-build-revision.gradle') apply from: file('../../gradle/modded-generation-build-revision.gradle')
apply from: file('../../gradle/volmlib-stamp.gradle') apply from: file('../../gradle/volmlib-stamp.gradle')
apply from: file('../../gradle/java21-runtime-exclusions.gradle')
Properties rootProperties = new Properties() Properties rootProperties = new Properties()
file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) } file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) }
String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.3.4')) String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.3.5'))
String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3')) String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3'))
String fabricLoaderVersion = providers.gradleProperty('fabricLoaderVersion').getOrElse(irisModTarget.fabricLoader.toString()) String fabricLoaderVersion = providers.gradleProperty('fabricLoaderVersion').getOrElse(irisModTarget.fabricLoader.toString())
Closure<String> irisArtifactName = { String platform, String targetVersion -> Closure<String> irisArtifactName = { String platform, String targetVersion ->
@@ -322,8 +323,7 @@ tasks.named('shadowJar', ShadowJar).configure {
configurations = [project.configurations.named('bundle').get()] configurations = [project.configurations.named('bundle').get()]
mergeServiceFiles() mergeServiceFiles()
if ((int) irisModTarget.java == 21) { if ((int) irisModTarget.java == 21) {
exclude('art/arcane/volmlib/util/noise/NativeBillowLoader*.class') exclude(java21RuntimeExclusions)
exclude('art/arcane/volmlib/util/noise/BillowBatch*.class')
} }
exclude('META-INF/maven/**') exclude('META-INF/maven/**')
exclude('META-INF/proguard/**') exclude('META-INF/proguard/**')
+2
View File
@@ -33,6 +33,8 @@ plugins {
rootProject.name = 'iris-fabric' rootProject.name = 'iris-fabric'
apply from: new File(settingsDir, '../../gradle/runtime-cache.settings.gradle').canonicalFile
dependencyResolutionManagement { dependencyResolutionManagement {
versionCatalogs { versionCatalogs {
libs { libs {
+18 -4
View File
@@ -17,6 +17,7 @@
*/ */
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
import net.minecraftforge.gradle.shadow.net.minecraftforge.gradleutils.shared.EnhancedPlugin
import org.gradle.api.file.DeleteSpec import org.gradle.api.file.DeleteSpec
import org.gradle.api.file.FileCopyDetails import org.gradle.api.file.FileCopyDetails
import org.gradle.api.file.FileSystemOperations import org.gradle.api.file.FileSystemOperations
@@ -56,12 +57,24 @@ IrisFileOperations irisFileOperations = objects.newInstance(IrisFileOperations)
apply from: file('../../gradle/modded-generation-build-revision.gradle') apply from: file('../../gradle/modded-generation-build-revision.gradle')
apply from: file('../../gradle/volmlib-stamp.gradle') apply from: file('../../gradle/volmlib-stamp.gradle')
apply from: file('../../gradle/java21-runtime-exclusions.gradle')
Properties rootProperties = new Properties() Properties rootProperties = new Properties()
file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) } file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) }
String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.3.4')) String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.3.5'))
String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3')) String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3'))
String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse(irisModTarget.forge.toString()) String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse(irisModTarget.forge.toString())
String buildLane = gradle.extensions.extraProperties.has('irisBuildLane')
? gradle.extensions.extraProperties.get('irisBuildLane').toString() : null
if (buildLane != null) {
File laneCache = providers.gradleProperty('multiplexorSandbox').getOrElse('false').toBoolean()
? new File(gradle.gradleUserHomeDir, "caches/iris-forge/${buildLane}")
: file(".gradle/iris-lanes/${buildLane}")
plugins.withType(EnhancedPlugin).configureEach { EnhancedPlugin<?> forgePlugin ->
forgePlugin.globalCaches().set(new File(laneCache, 'forgegradle'))
}
minecraft.mavenizerOutput.set(new File(laneCache, 'mavenizer/repo'))
}
Closure<String> loaderDisplayVersion = { String loaderVersion -> Closure<String> loaderDisplayVersion = { String loaderVersion ->
String coordinatePrefix = "${minecraftVersion}-" String coordinatePrefix = "${minecraftVersion}-"
if (loaderVersion.startsWith(coordinatePrefix)) { if (loaderVersion.startsWith(coordinatePrefix)) {
@@ -161,7 +174,9 @@ configurations.testRuntimeClasspath.extendsFrom(configurations.runtimeClasspath,
} }
dependencies { dependencies {
implementation minecraft.dependency("net.minecraftforge:forge:${forgeVersion}") implementation(buildLane == null
? minecraft.dependency("net.minecraftforge:forge:${forgeVersion}")
: minecraft.dependency("iris-${buildLane}", "net.minecraftforge:forge:${forgeVersion}"))
testImplementation('junit:junit:4.13.2') testImplementation('junit:junit:4.13.2')
testImplementation('org.mockito:mockito-core:5.23.0') testImplementation('org.mockito:mockito-core:5.23.0')
testImplementation(testFixtures('art.arcane:core')) testImplementation(testFixtures('art.arcane:core'))
@@ -295,8 +310,7 @@ tasks.named('shadowJar', ShadowJar).configure {
from(sourceSets.main.output) from(sourceSets.main.output)
mergeServiceFiles() mergeServiceFiles()
if ((int) irisModTarget.java == 21) { if ((int) irisModTarget.java == 21) {
exclude('art/arcane/volmlib/util/noise/NativeBillowLoader*.class') exclude(java21RuntimeExclusions)
exclude('art/arcane/volmlib/util/noise/BillowBatch*.class')
} }
exclude('META-INF/maven/**') exclude('META-INF/maven/**')
exclude('META-INF/proguard/**') exclude('META-INF/proguard/**')
+2
View File
@@ -33,6 +33,8 @@ plugins {
rootProject.name = 'iris-forge' rootProject.name = 'iris-forge'
apply from: new File(settingsDir, '../../gradle/runtime-cache.settings.gradle').canonicalFile
dependencyResolutionManagement { dependencyResolutionManagement {
versionCatalogs { versionCatalogs {
libs { libs {
@@ -57,6 +57,8 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedChunkGenera
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeGeneratorContext; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeGeneratorContext;
import art.arcane.volmlib.nativelib.terrain.NativeWorld; import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
import art.arcane.volmlib.util.matter.Matter;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedStructureStage; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedStructureStage;
import art.arcane.iris.world.safeguard.GenerationRefusalNotice; import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
@@ -829,12 +831,12 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
importedFeatures.invalidate(); importedFeatures.invalidate();
} }
public void updateRegeneratedChunk(int chunkX, int chunkZ) { public void updateRegeneratedChunk(Mantle<Matter> mantle, int chunkX, int chunkZ) {
ModdedChunkUpdateService service = ModdedEngineBootstrap.services().service(ModdedChunkUpdateService.class); ModdedChunkUpdateService service = ModdedEngineBootstrap.services().service(ModdedChunkUpdateService.class);
if (service == null) { if (service == null) {
throw new IllegalStateException("Iris chunk update service is unavailable during regeneration"); throw new IllegalStateException("Iris chunk update service is unavailable during regeneration");
} }
service.updateRegeneratedChunk(engine(), boundLevel(), chunkX, chunkZ); service.updateRegeneratedChunk(engine(), boundLevel(), mantle, chunkX, chunkZ);
} }
public NativeBiomeResolver regenBiomeResolver() { public NativeBiomeResolver regenBiomeResolver() {
@@ -2,6 +2,7 @@ package art.arcane.iris.modded;
import art.arcane.volmlib.nativelib.terrain.structure.StructureStagePolicy; import art.arcane.volmlib.nativelib.terrain.structure.StructureStagePolicy;
import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.mantle.DeferredPlacementRecords;
import art.arcane.iris.generation.terrain.IrisMaterialPalette; import art.arcane.iris.generation.terrain.IrisMaterialPalette;
import art.arcane.iris.structure.nativegen.GenerationWritePolicy; import art.arcane.iris.structure.nativegen.GenerationWritePolicy;
import art.arcane.iris.structure.nativegen.IrisStructurePolicy; import art.arcane.iris.structure.nativegen.IrisStructurePolicy;
@@ -13,6 +14,7 @@ import art.arcane.iris.structure.nativegen.StructureLocateSearch;
import art.arcane.iris.structure.placement.IrisStructureLocator; import art.arcane.iris.structure.placement.IrisStructureLocator;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedStructureStage; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedStructureStage;
import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeStructureVolumeSource; import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeStructureVolumeSource;
import art.arcane.volmlib.nativelib.terrain.NativeBlockChangeSet;
import art.arcane.volmlib.nativelib.terrain.NativeBlockPositionPredicate; import art.arcane.volmlib.nativelib.terrain.NativeBlockPositionPredicate;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState; import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.volmlib.nativelib.terrain.structure.StructureLocateSearchAccess; import art.arcane.volmlib.nativelib.terrain.structure.StructureLocateSearchAccess;
@@ -107,6 +109,16 @@ final class ModdedNativeStructurePolicy implements NativeModdedStructureStage.Po
context.getDimensionStackContext(), context.getMinHeight()); context.getDimensionStackContext(), context.getMinHeight());
} }
@Override
public NativeBlockChangeSet trackBlockChanges(Engine context, NativeBlockChangeSet.Bounds bounds) {
return DeferredPlacementRecords.track(context, bounds);
}
@Override
public void invalidateBlockChanges(Engine context, NativeBlockChangeSet changes) {
DeferredPlacementRecords.invalidate(context, changes);
}
@Override @Override
public IntBinaryOperator surfaceHeight(Engine context) { public IntBinaryOperator surfaceHeight(Engine context) {
return (x, z) -> Engine.hostHeight(context, x, z, true) + context.getMinHeight(); return (x, z) -> Engine.hostHeight(context, x, z, true) + context.getMinHeight();
@@ -19,6 +19,8 @@
package art.arcane.iris.modded.api; package art.arcane.iris.modded.api;
import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.mantle.CustomBlockOwnership;
import art.arcane.iris.integration.Identifier;
import art.arcane.iris.modded.ModdedIrisLog; import art.arcane.iris.modded.ModdedIrisLog;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedBlockState; import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedBlockState;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeBlockPlacement; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeBlockPlacement;
@@ -297,11 +299,11 @@ public final class ModdedCustomContentRegistry {
* consulted for that position. An unparseable key or no matching provider is logged and skipped. Called on the * consulted for that position. An unparseable key or no matching provider is logged and skipped. Called on the
* server thread with the chunk loaded. * server thread with the chunk loaded.
*/ */
public static void processBlockPlacement(Engine engine, String key, Supplier<NativeBlockPlacement> placement) { public static CustomBlockOwnership.Entry processBlockPlacement(Engine engine, String key, Supplier<NativeBlockPlacement> placement) {
String base = parseIdentifier(key); String base = parseIdentifier(key);
if (base == null) { if (base == null) {
ModdedIrisLog.warn("Iris deferred custom block placement rejected invalid id {}", key); ModdedIrisLog.warn("Iris deferred custom block placement rejected invalid id {}", key);
return; return null;
} }
Map<String, String> state = parseState(key); Map<String, String> state = parseState(key);
for (ModdedDataProvider provider : PROVIDERS) { for (ModdedDataProvider provider : PROVIDERS) {
@@ -309,14 +311,33 @@ public final class ModdedCustomContentRegistry {
continue; continue;
} }
try { try {
provider.processBlockPlacement(new ModdedBlockPlacementContext( String token = provider.processBlockPlacement(new ModdedBlockPlacementContext(
engine, placement.get(), base, state)); engine, placement.get(), base, state));
int properties = key.indexOf('[');
String qualifiedKey = base.replace(':', '/') + (properties < 0 ? "" : key.substring(properties));
return token == null ? null : new CustomBlockOwnership.Entry(
new Identifier(provider.modId(), qualifiedKey), token, false);
} catch (Throwable error) { } catch (Throwable error) {
ModdedIrisLog.error("Iris custom content provider '{}' failed post-placement for {}", provider.modId(), key, error); ModdedIrisLog.error("Iris custom content provider '{}' failed post-placement for {}", provider.modId(), key, error);
} }
return; return null;
} }
ModdedIrisLog.warn("Iris deferred custom block placement has no provider for {}", key); ModdedIrisLog.warn("Iris deferred custom block placement has no provider for {}", key);
return null;
}
public static boolean removeGenerated(Engine engine, CustomBlockOwnership.Entry entry,
Supplier<NativeBlockPlacement> placement) {
for (ModdedDataProvider provider : PROVIDERS) {
if (provider.modId().equals(entry.identifier().namespace()) && provider.isReady()) {
String storedKey = entry.identifier().key();
int separator = storedKey.indexOf('/');
String key = storedKey.substring(0, separator) + ":" + storedKey.substring(separator + 1);
return provider.removeGenerated(new ModdedBlockPlacementContext(
engine, placement.get(), parseIdentifier(key), parseState(key)), entry.token());
}
}
return false;
} }
/** /**
@@ -95,7 +95,12 @@ public interface ModdedDataProvider {
* Called on the server thread, once per deferred position, by the first provider that claims the identifier - * Called on the server thread, once per deferred position, by the first provider that claims the identifier -
* later providers are not consulted for that position. Only fires for states returned as deferred. * later providers are not consulted for that position. Only fires for states returned as deferred.
*/ */
default void processBlockPlacement(ModdedBlockPlacementContext context) { default String processBlockPlacement(ModdedBlockPlacementContext context) {
return null;
}
default boolean removeGenerated(ModdedBlockPlacementContext context, String token) {
return false;
} }
/** /**
@@ -30,6 +30,7 @@ import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.modded.ModdedGenPool; import art.arcane.iris.modded.ModdedGenPool;
import art.arcane.volmlib.util.mantle.runtime.Mantle; import art.arcane.volmlib.util.mantle.runtime.Mantle;
import java.util.Objects;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
import java.util.concurrent.Semaphore; import java.util.concurrent.Semaphore;
@@ -39,6 +40,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import java.util.function.LongSupplier;
public final class ModdedPregenMethod implements PregeneratorMethod { public final class ModdedPregenMethod implements PregeneratorMethod {
private static final int ADAPTIVE_TIMEOUT_STEP = 3; private static final int ADAPTIVE_TIMEOUT_STEP = 3;
@@ -68,8 +70,14 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
private final int timeoutSeconds; private final int timeoutSeconds;
private final PregenMantleBackpressure backpressure; private final PregenMantleBackpressure backpressure;
private final PauseWhenEmptyGuard pauseGuard; private final PauseWhenEmptyGuard pauseGuard;
private final LongSupplier clock;
public ModdedPregenMethod(Configuration configuration) { public ModdedPregenMethod(Configuration configuration) {
this(configuration, System::nanoTime);
}
ModdedPregenMethod(Configuration configuration, LongSupplier clock) {
this.clock = Objects.requireNonNull(clock);
runtime = configuration.runtime(); runtime = configuration.runtime();
engine = configuration.engine(); engine = configuration.engine();
sync = configuration.sync(); sync = configuration.sync();
@@ -178,7 +186,7 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
if (sync || runtime.isServerThread()) { if (sync || runtime.isServerThread()) {
return; return;
} }
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds) long deadline = clock.getAsLong() + TimeUnit.SECONDS.toNanos(timeoutSeconds)
+ TimeUnit.MILLISECONDS.toNanos(FINAL_SAVE_TIMEOUT_MILLIS); + TimeUnit.MILLISECONDS.toNanos(FINAL_SAVE_TIMEOUT_MILLIS);
boolean interrupted = false; boolean interrupted = false;
try { try {
@@ -186,7 +194,7 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
if (finalSaveOnServerThread.get() || !runtime.isRunning()) { if (finalSaveOnServerThread.get() || !runtime.isRunning()) {
return; return;
} }
if (System.nanoTime() >= deadline) { if (clock.getAsLong() >= deadline) {
throw new IllegalStateException("Iris pregen chunk callbacks did not finish for " throw new IllegalStateException("Iris pregen chunk callbacks did not finish for "
+ runtime.worldIdentity() + ": " + inFlight.get() + " pending"); + runtime.worldIdentity() + ": " + inFlight.get() + " pending");
} }
@@ -263,14 +271,14 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
} }
private void awaitFinalSave(FinalSaveRequest request) { private void awaitFinalSave(FinalSaveRequest request) {
long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(FINAL_SAVE_TIMEOUT_MILLIS); long deadline = clock.getAsLong() + TimeUnit.MILLISECONDS.toNanos(FINAL_SAVE_TIMEOUT_MILLIS);
boolean interrupted = false; boolean interrupted = false;
try { try {
while (true) { while (true) {
if (deferFinalSaveIfRequested()) { if (deferFinalSaveIfRequested()) {
return; return;
} }
long remainingNanos = deadline - System.nanoTime(); long remainingNanos = deadline - clock.getAsLong();
if (remainingNanos <= 0L) { if (remainingNanos <= 0L) {
ModdedIrisLog.warn("Iris pregen level save did not complete in time for {}", runtime.worldIdentity()); ModdedIrisLog.warn("Iris pregen level save did not complete in time for {}", runtime.worldIdentity());
return; return;
@@ -22,6 +22,7 @@ import art.arcane.iris.modded.ModdedIrisLog;
import art.arcane.iris.world.WorldMaintenance; import art.arcane.iris.world.WorldMaintenance;
import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.mantle.EngineMantle; import art.arcane.iris.generation.mantle.EngineMantle;
import art.arcane.iris.generation.mantle.DeferredPlacementRecords;
import art.arcane.iris.modded.IrisModdedChunkGenerator; import art.arcane.iris.modded.IrisModdedChunkGenerator;
import art.arcane.iris.modded.ModdedBlockBuffer; import art.arcane.iris.modded.ModdedBlockBuffer;
import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.IrisPlatforms;
@@ -36,6 +37,9 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeCommandSource;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeChunkRegeneration; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeChunkRegeneration;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import art.arcane.volmlib.nativelib.terrain.NativeWorld; import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
import art.arcane.volmlib.util.matter.Matter;
import java.util.List; import java.util.List;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
@@ -110,10 +114,12 @@ public final class ModdedRegen {
private void resetMantleMargin() { private void resetMantleMargin() {
EngineMantle engineMantle = engine.getMantle(); EngineMantle engineMantle = engine.getMantle();
Mantle<Matter> mantle = engineMantle.getMantle();
int margin = radius + Math.max(engineMantle.getRadius(), engineMantle.getRealRadius()) + 1; int margin = radius + Math.max(engineMantle.getRadius(), engineMantle.getRealRadius()) + 1;
for (int dx = -margin; dx <= margin; dx++) { for (int dx = -margin; dx <= margin; dx++) {
for (int dz = -margin; dz <= margin; dz++) { for (int dz = -margin; dz <= margin; dz++) {
engineMantle.getMantle().deleteChunk(centerX + dx, centerZ + dz); DeferredPlacementRecords.resetMantleChunk(mantle, centerX + dx, centerZ + dz,
Math.abs(dx) <= radius && Math.abs(dz) <= radius);
} }
} }
} }
@@ -199,10 +205,21 @@ public final class ModdedRegen {
} }
private void apply(int chunkX, int chunkZ, ModdedBlockBuffer blocks, Hunk<NativeBiome> biomes) { private void apply(int chunkX, int chunkZ, ModdedBlockBuffer blocks, Hunk<NativeBiome> biomes) {
Mantle<Matter> mantle = engine.getMantle().getMantle();
regeneration.apply(new NativeChunkRegeneration.Replacement(chunkX, chunkZ, regeneration.apply(new NativeChunkRegeneration.Replacement(chunkX, chunkZ,
engine.getMinHeight(), engine.getMaxHeight() - engine.getMinHeight(), engine.getMinHeight(), engine.getMaxHeight() - engine.getMinHeight(),
blocks::rawOrNull, generator.regenBiomeResolver()), blocks::rawOrNull, generator.regenBiomeResolver()),
() -> generator.updateRegeneratedChunk(chunkX, chunkZ)); () -> completeReplacement(mantle, chunkX, chunkZ));
}
private void completeReplacement(Mantle<Matter> mantle, int chunkX, int chunkZ) {
MantleChunk<Matter> records = mantle.useChunk(chunkX, chunkZ);
try {
DeferredPlacementRecords.invalidateCustomBlockOwnership(records);
} finally {
records.release();
}
generator.updateRegeneratedChunk(mantle, chunkX, chunkZ);
} }
private void ok(String message) { private void ok(String message) {
@@ -28,13 +28,17 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeBlockProperties;
import art.arcane.iris.configuration.IrisSettings; import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.studio.view.PregeneratorJob; import art.arcane.iris.studio.view.PregeneratorJob;
import art.arcane.iris.integration.Identifier; import art.arcane.iris.integration.Identifier;
import art.arcane.iris.generation.mantle.CustomBlockOwnership;
import art.arcane.iris.generation.mantle.DeferredPlacementRecords;
import art.arcane.iris.generation.cache.Cache; import art.arcane.iris.generation.cache.Cache;
import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.block.TileData; import art.arcane.iris.generation.block.TileData;
import art.arcane.iris.generation.decoration.IrisProceduralBlocks;
import art.arcane.iris.modded.IrisModdedChunkGenerator; import art.arcane.iris.modded.IrisModdedChunkGenerator;
import art.arcane.iris.modded.ModdedBlockResolution; import art.arcane.iris.modded.ModdedBlockResolution;
import art.arcane.iris.modded.ModdedLootApplier; import art.arcane.iris.modded.ModdedLootApplier;
import art.arcane.iris.modded.ModdedTileData; import art.arcane.iris.modded.ModdedTileData;
import art.arcane.iris.modded.api.ModdedBlockData;
import art.arcane.iris.modded.api.ModdedCustomContentRegistry; import art.arcane.iris.modded.api.ModdedCustomContentRegistry;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeBlockPlacement; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeBlockPlacement;
import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisLogging;
@@ -50,6 +54,8 @@ import art.arcane.volmlib.util.scheduling.PrecisionStopwatch;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import it.unimi.dsi.fastutil.ints.IntArrayList; import it.unimi.dsi.fastutil.ints.IntArrayList;
import java.util.HashMap;
import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
@@ -162,8 +168,8 @@ public final class ModdedChunkUpdateService implements ModdedTickableService {
updatePassChunk(engine, level, (int) chunkKey, (int) (chunkKey >> 32))); updatePassChunk(engine, level, (int) chunkKey, (int) (chunkKey >> 32)));
} }
public void updateRegeneratedChunk(Engine engine, NativeWorld level, int chunkX, int chunkZ) { public void updateRegeneratedChunk(Engine engine, NativeWorld level, Mantle<Matter> mantle, int chunkX, int chunkZ) {
updateChunk(engine, level, chunkX, chunkZ, false); updateChunk(engine, level, mantle, chunkX, chunkZ, false);
} }
/** /**
@@ -172,7 +178,7 @@ public final class ModdedChunkUpdateService implements ModdedTickableService {
*/ */
private void updatePassChunk(Engine engine, NativeWorld level, int chunkX, int chunkZ) { private void updatePassChunk(Engine engine, NativeWorld level, int chunkX, int chunkZ) {
try { try {
updateChunk(engine, level, chunkX, chunkZ, true); updateChunk(engine, level, engine.getMantle().getMantle(), chunkX, chunkZ, true);
} catch (Throwable failure) { } catch (Throwable failure) {
if (engine.isClosed() || engine.getMantle().getMantle().isClosed()) { if (engine.isClosed() || engine.getMantle().getMantle().isClosed()) {
throw failure; throw failure;
@@ -184,7 +190,8 @@ public final class ModdedChunkUpdateService implements ModdedTickableService {
} }
} }
private void updateChunk(Engine engine, NativeWorld level, int chunkX, int chunkZ, boolean requireNeighbors) { private void updateChunk(Engine engine, NativeWorld level, Mantle<Matter> mantle,
int chunkX, int chunkZ, boolean requireNeighbors) {
if (requireNeighbors) { if (requireNeighbors) {
for (int x = -1; x <= 1; x++) { for (int x = -1; x <= 1; x++) {
for (int z = -1; z <= 1; z++) { for (int z = -1; z <= 1; z++) {
@@ -195,11 +202,11 @@ public final class ModdedChunkUpdateService implements ModdedTickableService {
} }
} }
Mantle<Matter> mantle = engine.getMantle().getMantle();
if (!mantle.isChunkLoaded(chunkX, chunkZ)) { if (!mantle.isChunkLoaded(chunkX, chunkZ)) {
warmupMantleChunk(mantle, chunkX, chunkZ); warmupMantleChunk(mantle, chunkX, chunkZ);
return; return;
} }
DeferredPlacementRecords.removeInvalidated(engine, mantle, chunkX, chunkZ);
if (mantle.hasFlag(chunkX, chunkZ, MantleFlag.ETCHED)) { if (mantle.hasFlag(chunkX, chunkZ, MantleFlag.ETCHED)) {
return; return;
} }
@@ -225,30 +232,51 @@ public final class ModdedChunkUpdateService implements ModdedTickableService {
int baseX = chunkX << 4; int baseX = chunkX << 4;
int baseZ = chunkZ << 4; int baseZ = chunkZ << 4;
int minHeight = engine.getWorld().minHeight(); int minHeight = engine.getWorld().minHeight();
materializeDeferredSlice(chunk, TileWrapper.class, () -> materializeDeferredSlice(chunk, TileWrapper.class, () -> {
chunk.iterate(TileWrapper.class, (Integer x, Integer yf, Integer z, TileWrapper v) -> { for (DeferredPlacementRecords.Pending<TileWrapper> record : DeferredPlacementRecords.snapshot(chunk, TileWrapper.class)) {
int y = yf + minHeight; if (!DeferredPlacementRecords.isPending(chunk, TileWrapper.class, record)) {
if (y < level.minHeight() || y >= level.maxHeight() - 1) { continue;
return; }
} NativeBlockPoint position = record.position();
applyTile(level, baseX + (x & 15), y, baseZ + (z & 15), v.getData()); int y = position.y() + minHeight;
})); if (y < level.minHeight() || y >= level.maxHeight() - 1) {
continue;
}
applyTile(level, baseX + (position.x() & 15), y, baseZ + (position.z() & 15), record.value().getData());
}
});
} }
private void runCustomPass(Engine engine, NativeWorld level, int chunkX, int chunkZ, MantleChunk<Matter> chunk) { private void runCustomPass(Engine engine, NativeWorld level, int chunkX, int chunkZ, MantleChunk<Matter> chunk) {
int baseX = chunkX << 4; int baseX = chunkX << 4;
int baseZ = chunkZ << 4; int baseZ = chunkZ << 4;
int minHeight = engine.getWorld().minHeight(); int minHeight = engine.getWorld().minHeight();
materializeDeferredSlice(chunk, Identifier.class, () -> StaleCarriers stale = new StaleCarriers();
chunk.iterate(Identifier.class, (Integer x, Integer yf, Integer z, Identifier identifier) -> { materializeDeferredSlice(chunk, Identifier.class, () -> {
int y = yf + minHeight; for (DeferredPlacementRecords.Pending<Identifier> record : DeferredPlacementRecords.snapshot(chunk, Identifier.class)) {
if (y < level.minHeight() || y >= level.maxHeight() - 1) { if (!DeferredPlacementRecords.isPending(chunk, Identifier.class, record)) {
return; continue;
} }
NativeBlockPoint position = new NativeBlockPoint(baseX + (x & 15), y, baseZ + (z & 15)); NativeBlockPoint local = record.position();
ModdedCustomContentRegistry.processBlockPlacement(engine, identifier.toString(), int y = local.y() + minHeight;
() -> NativeWorldMaintenance.placement(level, position)); if (y < level.minHeight() || y >= level.maxHeight() - 1) {
})); continue;
}
int blockX = baseX + (local.x() & 15);
int blockZ = baseZ + (local.z() & 15);
String key = record.value().toString();
if (stale.carrierReplaced(key, level.getBlock(blockX, y, blockZ))) {
continue;
}
NativeBlockPoint position = new NativeBlockPoint(blockX, y, blockZ);
CustomBlockOwnership.Entry ownership = ModdedCustomContentRegistry.processBlockPlacement(engine, key,
() -> NativeWorldMaintenance.placement(level, position));
if (ownership != null) {
DeferredPlacementRecords.remember(engine, chunk, local, ownership);
}
}
});
stale.report(level.name(), chunkX, chunkZ);
} }
static void materializeDeferredSlice(MantleChunk<Matter> chunk, Class<?> sliceType, Runnable materializer) { static void materializeDeferredSlice(MantleChunk<Matter> chunk, Class<?> sliceType, Runnable materializer) {
@@ -386,4 +414,41 @@ public final class ModdedChunkUpdateService implements ModdedTickableService {
NativeWorldMaintenance.refresh(level, pos); NativeWorldMaintenance.refresh(level, pos);
} }
} }
private static final class StaleCarriers {
private static final String UNCHECKED = "";
private final Map<String, String> carriers = new HashMap<>();
private int skipped;
private boolean carrierReplaced(String key, NativeBlockState live) {
String carrier = carriers.get(key);
if (carrier == null) {
carrier = carrierKey(key);
carriers.put(key, carrier);
}
if (carrier.equals(UNCHECKED) || carrier.equals(IrisProceduralBlocks.materialKey(live))
|| ((carrier.equals("minecraft:fire") || carrier.equals("minecraft:soul_fire")) && live.isAir())) {
return false;
}
skipped++;
return true;
}
private void report(String levelName, int chunkX, int chunkZ) {
if (skipped == 0) {
return;
}
IrisLogging.debug("Skipped %d stale custom block records at chunk %d,%d in %s",
skipped, chunkX, chunkZ, levelName);
}
private static String carrierKey(String key) {
ModdedBlockData resolved = ModdedCustomContentRegistry.resolveBlock(key);
if (resolved == null || resolved.state().isAir()) {
return UNCHECKED;
}
return IrisProceduralBlocks.materialKey(resolved.state());
}
}
} }
@@ -28,6 +28,10 @@ import art.arcane.iris.modded.ModdedSavedTerrainCapture;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import art.arcane.iris.generation.runtime.EnginePlatformHooks; import art.arcane.iris.generation.runtime.EnginePlatformHooks;
import art.arcane.iris.generation.mantle.CustomBlockOwnership;
import art.arcane.iris.modded.api.ModdedCustomContentRegistry;
import art.arcane.volmlib.nativelib.terrain.NativeBlockPoint;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldMaintenance;
import art.arcane.volmlib.nativelib.terrain.structure.NativeStructureVolume; import art.arcane.volmlib.nativelib.terrain.structure.NativeStructureVolume;
import art.arcane.iris.generation.terrain.IrisDimension; import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.generation.terrain.IrisDimensionRuntimeContract; import art.arcane.iris.generation.terrain.IrisDimensionRuntimeContract;
@@ -72,6 +76,12 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
private volatile ExecutorService executor; private volatile ExecutorService executor;
private long lastPollAt; private long lastPollAt;
@Override
public boolean removeCustomBlock(Engine engine, NativeBlockPoint position, CustomBlockOwnership.Entry entry) {
return ModdedCustomContentRegistry.removeGenerated(engine, entry,
() -> NativeWorldMaintenance.placement(engine.getWorld().platformWorld(), position));
}
@Override @Override
public CompletableFuture<Void> flushSavedTerrainCapture(Engine engine) { public CompletableFuture<Void> flushSavedTerrainCapture(Engine engine) {
return ModdedSavedTerrainCapture.flush(engine); return ModdedSavedTerrainCapture.flush(engine);
@@ -3,6 +3,7 @@ package art.arcane.iris.modded;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedNativeStructureWorldgenAccess; import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedNativeStructureWorldgenAccess;
import art.arcane.iris.structure.placement.IrisStructureTerrain; import art.arcane.iris.structure.placement.IrisStructureTerrain;
import art.arcane.volmlib.nativelib.terrain.NativeBlockChangeSet;
import art.arcane.volmlib.util.structure.StructureTerrainMode; import art.arcane.volmlib.util.structure.StructureTerrainMode;
import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeStructurePostProcessor; import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeStructurePostProcessor;
import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeStructureTerrainIntegrator; import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeStructureTerrainIntegrator;
@@ -230,8 +231,9 @@ public class ModdedNativeStructureWorldgenAccessTest {
recording.world(), generationCenter, recording.world(), generationCenter,
(x, z) -> SURFACE_FIRST_FREE_Y, (x, z) -> SURFACE_FIRST_FREE_Y,
(x, z) -> FLOOR_FIRST_FREE_Y, (x, z) -> FLOOR_FIRST_FREE_Y,
true, true, true,
position -> false); position -> false,
NativeBlockChangeSet.disabled());
int x = generationCenter.getMiddleBlockX(); int x = generationCenter.getMiddleBlockX();
int z = generationCenter.getMiddleBlockZ(); int z = generationCenter.getMiddleBlockZ();
@@ -248,8 +250,9 @@ public class ModdedNativeStructureWorldgenAccessTest {
recording.world(), generationCenter(), recording.world(), generationCenter(),
(x, z) -> SURFACE_FIRST_FREE_Y, (x, z) -> SURFACE_FIRST_FREE_Y,
(x, z) -> FLOOR_FIRST_FREE_Y, (x, z) -> FLOOR_FIRST_FREE_Y,
false, false, true,
position::equals); position::equals,
NativeBlockChangeSet.disabled());
assertSame(Blocks.DIRT, access.getBlockState(position).getBlock()); assertSame(Blocks.DIRT, access.getBlockState(position).getBlock());
assertFalse(access.ensureCanWrite(position)); assertFalse(access.ensureCanWrite(position));
@@ -270,6 +273,37 @@ public class ModdedNativeStructureWorldgenAccessTest {
assertEquals(1, recording.mutations); assertEquals(1, recording.mutations);
} }
@Test
public void acceptedWritesAndReportedChangesAreTrackedOutsideProtectedCells() {
RecordingDelegate recording = new RecordingDelegate();
BlockPos protectedCell = generationCenter().getMiddleBlockPosition(70);
WorldGenLevel delegate = recording.world();
NativeBlockChangeSet changes = NativeBlockChangeSet.create(
ModdedNativeStructureWorldgenAccess.changeBounds(delegate, generationCenter()));
ModdedNativeStructureWorldgenAccess access = ModdedNativeStructureWorldgenAccess.create(
delegate, generationCenter(),
(x, z) -> SURFACE_FIRST_FREE_Y,
(x, z) -> FLOOR_FIRST_FREE_Y,
false, true,
protectedCell::equals,
changes);
BlockPos written = protectedCell.above();
BlockPos removed = protectedCell.east();
BlockPos reported = protectedCell.above(2);
assertFalse(access.setBlock(protectedCell, Blocks.STONE.defaultBlockState(), 2));
assertTrue(access.setBlock(written, Blocks.STONE.defaultBlockState(), 2));
assertTrue(access.removeBlock(removed, false));
access.recordBlockChange(reported.getX(), reported.getY(), reported.getZ());
access.recordBlockChange(protectedCell.getX(), protectedCell.getY(), protectedCell.getZ());
assertEquals(3, changes.size());
assertTrue(changes.contains(written.getX(), written.getY(), written.getZ()));
assertTrue(changes.contains(removed.getX(), removed.getY(), removed.getZ()));
assertTrue(changes.contains(reported.getX(), reported.getY(), reported.getZ()));
assertFalse(changes.contains(protectedCell.getX(), protectedCell.getY(), protectedCell.getZ()));
}
@Test @Test
public void statuslessChunkReadsPreserveWorldgenDelegateSemantics() { public void statuslessChunkReadsPreserveWorldgenDelegateSemantics() {
RecordingDelegate recording = new RecordingDelegate(); RecordingDelegate recording = new RecordingDelegate();
@@ -319,8 +353,9 @@ public class ModdedNativeStructureWorldgenAccessTest {
recording.world(), generationCenter(), recording.world(), generationCenter(),
(x, z) -> SURFACE_FIRST_FREE_Y, (x, z) -> SURFACE_FIRST_FREE_Y,
(x, z) -> FLOOR_FIRST_FREE_Y, (x, z) -> FLOOR_FIRST_FREE_Y,
false, false, true,
position -> false); position -> false,
NativeBlockChangeSet.disabled());
} }
private static ChunkPos generationCenter() { private static ChunkPos generationCenter() {
@@ -1,5 +1,9 @@
package art.arcane.iris.modded.api; package art.arcane.iris.modded.api;
import art.arcane.iris.generation.mantle.CustomBlockOwnership;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.integration.Identifier;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeBlockPlacement;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeEntityRuntime; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeEntityRuntime;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeSpawnedEntity; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeSpawnedEntity;
import net.minecraft.SharedConstants; import net.minecraft.SharedConstants;
@@ -9,6 +13,7 @@ import org.junit.Test;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Map;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
@@ -20,6 +25,33 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
public class ModdedCustomContentRegistryTest { public class ModdedCustomContentRegistryTest {
@Test
public void removalReturnsToOriginalProviderWithTokenAndPlacementState() {
ModdedDataProvider provider = mock(ModdedDataProvider.class);
Engine engine = mock(Engine.class);
NativeBlockPlacement placement = mock(NativeBlockPlacement.class);
ModdedBlockPlacementContext context = new ModdedBlockPlacementContext(
engine, placement, "provider_test:furniture/chair", Map.of("variant", "oak"));
when(provider.modId()).thenReturn("ownership_test");
when(provider.isReady()).thenReturn(true);
when(provider.isValidProvider("provider_test:furniture/chair", ModdedDataType.BLOCK)).thenReturn(true);
when(provider.processBlockPlacement(context)).thenReturn("entity-token");
when(provider.removeGenerated(context, "entity-token")).thenReturn(true);
ModdedCustomContentRegistry.Discovery discovery = ModdedCustomContentRegistry.discover(List.of(provider));
try {
CustomBlockOwnership.Entry ownership = ModdedCustomContentRegistry.processBlockPlacement(engine,
"provider_test:furniture/chair[variant=oak]", () -> placement);
assertEquals(new CustomBlockOwnership.Entry(
Identifier.fromString("ownership_test:provider_test/furniture/chair[variant=oak]"),
"entity-token", false), ownership);
assertTrue(ModdedCustomContentRegistry.removeGenerated(engine, ownership, () -> placement));
verify(provider).removeGenerated(context, "entity-token");
} finally {
discovery.rollback();
}
}
@BeforeClass @BeforeClass
public static void bootstrapRegistries() { public static void bootstrapRegistries() {
SharedConstants.tryDetectVersion(); SharedConstants.tryDetectVersion();
@@ -21,6 +21,7 @@ import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException; import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
@@ -97,6 +98,9 @@ public class ModdedPregenMethodLifecycleTest {
@Test @Test
public void finalSaveWaitsForEveryCallbackBeyondFiveSeconds() throws Exception { public void finalSaveWaitsForEveryCallbackBeyondFiveSeconds() throws Exception {
AtomicLong clock = new AtomicLong();
CountDownLatch started = new CountDownLatch(1);
CountDownLatch advanced = new CountDownLatch(2);
CompletableFuture<ChunkLoad> load = new CompletableFuture<>(); CompletableFuture<ChunkLoad> load = new CompletableFuture<>();
CompletableFuture<ChunkLoad> failedLoad = new CompletableFuture<>(); CompletableFuture<ChunkLoad> failedLoad = new CompletableFuture<>();
PregenListener listener = mock(PregenListener.class); PregenListener listener = mock(PregenListener.class);
@@ -110,16 +114,22 @@ public class ModdedPregenMethodLifecycleTest {
}).when(runtime).execute(any()); }).when(runtime).execute(any());
try (MockedStatic<MantleHeapPressure> pressure = mockStatic(MantleHeapPressure.class)) { try (MockedStatic<MantleHeapPressure> pressure = mockStatic(MantleHeapPressure.class)) {
pressure.when(MantleHeapPressure::memoryBudgetBytes).thenReturn(512L << 20); pressure.when(MantleHeapPressure::memoryBudgetBytes).thenReturn(512L << 20);
method = new ModdedPregenMethod(new ModdedPregenMethod.Configuration(runtime, engine, false)); method = new ModdedPregenMethod(new ModdedPregenMethod.Configuration(runtime, engine, false), () -> {
long now = clock.get();
if (now >= TimeUnit.SECONDS.toNanos(6)) {
advanced.countDown();
} else {
started.countDown();
}
return now;
});
method.generateChunk(2, 3, listener); method.generateChunk(2, 3, listener);
method.generateChunk(4, 5, listener); method.generateChunk(4, 5, listener);
CountDownLatch started = new CountDownLatch(1); Future<?> closing = executor.submit(method::close);
Future<?> closing = executor.submit(() -> {
started.countDown();
method.close();
});
assertTrue(started.await(5, TimeUnit.SECONDS)); assertTrue(started.await(5, TimeUnit.SECONDS));
assertThrows(TimeoutException.class, () -> closing.get(6, TimeUnit.SECONDS)); clock.set(TimeUnit.SECONDS.toNanos(6));
assertTrue(advanced.await(5, TimeUnit.SECONDS));
assertFalse(closing.isDone());
verify(runtime, never()).save(); verify(runtime, never()).save();
verify(engine, never()).syncGenerationHistory(); verify(engine, never()).syncGenerationHistory();
load.complete(new ChunkLoad(true, null)); load.complete(new ChunkLoad(true, null));
@@ -0,0 +1,244 @@
package art.arcane.iris.modded.service;
import art.arcane.iris.generation.mantle.EngineMantle;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.EngineMetrics;
import art.arcane.iris.integration.Identifier;
import art.arcane.iris.modded.api.ModdedBlockData;
import art.arcane.iris.modded.api.ModdedBlockPlacementContext;
import art.arcane.iris.modded.api.ModdedCustomContentRegistry;
import art.arcane.iris.modded.api.ModdedDataProvider;
import art.arcane.iris.modded.api.ModdedDataType;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.world.IrisWorld;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedBlockState;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.volmlib.util.function.Consumer4;
import art.arcane.volmlib.util.mantle.flag.MantleFlag;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
import art.arcane.volmlib.util.mantle.runtime.MantleDataAdapter;
import art.arcane.volmlib.util.mantle.runtime.MantleHooks;
import art.arcane.volmlib.util.matter.Matter;
import net.minecraft.SharedConstants;
import net.minecraft.core.BlockPos;
import net.minecraft.server.Bootstrap;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.NoteBlock;
import net.minecraft.world.level.block.state.BlockState;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;
import org.mockito.MockedStatic;
import org.mockito.invocation.InvocationOnMock;
import java.lang.reflect.Proxy;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
public class ModdedChunkUpdateServiceCarrierTest {
private static final String MOD_ID = "iris_carrier_guard_test";
private static final String LAMP = MOD_ID + ":lamp";
private static final String CHAIR = MOD_ID + ":chair";
private static final String FIRE = MOD_ID + ":fire";
private static final String SOUL_FIRE = MOD_ID + ":soul_fire";
private static final List<BlockPos> PLACED = new CopyOnWriteArrayList<>();
@BeforeClass
public static void bootstrapMinecraftRegistries() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
ModdedCustomContentRegistry.register(new CarrierProvider());
}
@Before
public void clearPlacements() {
PLACED.clear();
}
@Test
public void staleRecordsAreSkippedWhileIntactAndAirCarrierRecordsArePlaced() {
RecordMantleChunk chunk = new RecordMantleChunk(List.of(
new Placed(1, LAMP),
new Placed(2, LAMP),
new Placed(3, LAMP),
new Placed(4, CHAIR)));
NativeWorld level = level(Map.of(
1, Blocks.NOTE_BLOCK.defaultBlockState().setValue(NoteBlock.NOTE, 7),
2, Blocks.AIR.defaultBlockState(),
3, Blocks.COBBLESTONE.defaultBlockState(),
4, Blocks.COBBLESTONE.defaultBlockState()));
List<String> debug = new ArrayList<>();
Engine engine = engine(chunk);
try (MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
logging.when(() -> IrisLogging.debug(anyString(), any(Object[].class))).thenAnswer((InvocationOnMock invocation) -> {
Object[] arguments = invocation.getArguments();
debug.add(String.format((String) arguments[0], Arrays.copyOfRange(arguments, 1, arguments.length)));
return null;
});
new ModdedChunkUpdateService().updateRegeneratedChunk(engine, level, engine.getMantle().getMantle(), 0, 0);
}
assertEquals(List.of(new BlockPos(1, 6, 1), new BlockPos(4, 6, 4)), PLACED);
assertTrue(chunk.isFlagged(MantleFlag.ETCHED));
assertTrue(chunk.customDeleted());
assertEquals(1, debug.size());
assertTrue(debug.getFirst(), debug.getFirst().contains("2 stale"));
}
@Test
public void extinguishedFireCarriersArePlacedWhileCoveredCarriersAreSkipped() {
RecordMantleChunk chunk = new RecordMantleChunk(List.of(
new Placed(1, FIRE), new Placed(2, SOUL_FIRE),
new Placed(3, FIRE), new Placed(4, SOUL_FIRE)));
NativeWorld level = level(Map.of(
1, Blocks.AIR.defaultBlockState(),
2, Blocks.CAVE_AIR.defaultBlockState(),
3, Blocks.COBBLESTONE.defaultBlockState(),
4, Blocks.COBBLESTONE.defaultBlockState()));
Engine engine = engine(chunk);
new ModdedChunkUpdateService().updateRegeneratedChunk(engine, level, engine.getMantle().getMantle(), 0, 0);
assertEquals(List.of(new BlockPos(1, 6, 1), new BlockPos(2, 6, 2)), PLACED);
assertTrue(chunk.customDeleted());
}
private static NativeWorld level(Map<Integer, BlockState> live) {
ServerLevel serverLevel = mock(ServerLevel.class);
when(serverLevel.getBlockState(any(BlockPos.class))).thenReturn(Blocks.STONE.defaultBlockState());
NativeWorld level = mock(NativeWorld.class);
when(level.name()).thenReturn("example:carriers");
when(level.minHeight()).thenReturn(-64);
when(level.maxHeight()).thenReturn(320);
when(level.nativeHandle()).thenReturn(serverLevel);
for (Map.Entry<Integer, BlockState> entry : live.entrySet()) {
int offset = entry.getKey();
when(level.getBlock(offset, 6, offset)).thenReturn(ModdedBlockState.of(entry.getValue(), null));
}
return level;
}
private static Engine engine(RecordMantleChunk chunk) {
@SuppressWarnings("unchecked")
Mantle<Matter> mantle = mock(Mantle.class);
when(mantle.isChunkLoaded(0, 0)).thenReturn(true);
when(mantle.hasFlag(0, 0, MantleFlag.ETCHED)).thenAnswer((InvocationOnMock invocation) -> chunk.isFlagged(MantleFlag.ETCHED));
when(mantle.useChunk(0, 0)).thenAnswer((InvocationOnMock invocation) -> chunk.use());
IrisWorld world = mock(IrisWorld.class);
when(world.minHeight()).thenReturn(-64);
EngineMantle engineMantle = mock(EngineMantle.class);
when(engineMantle.getMantle()).thenReturn(mantle);
Engine engine = mock(Engine.class);
when(engine.getWorld()).thenReturn(world);
when(engine.getMetrics()).thenReturn(new EngineMetrics(8));
when(engine.getMantle()).thenReturn(engineMantle);
return engine;
}
@SuppressWarnings("unchecked")
private static MantleDataAdapter<Matter> emptyAdapter() {
return (MantleDataAdapter<Matter>) Proxy.newProxyInstance(
MantleDataAdapter.class.getClassLoader(),
new Class<?>[]{MantleDataAdapter.class},
(proxy, method, arguments) -> {
throw new UnsupportedOperationException(method.getName());
}
);
}
private record Placed(int offset, String key) {
}
private static final class RecordMantleChunk extends MantleChunk<Matter> {
private final List<Placed> records;
private boolean customDeleted;
private RecordMantleChunk(List<Placed> records) {
super(1, 0, 0, emptyAdapter(), MantleHooks.NONE);
this.records = records;
}
@Override
public <T> void iterate(Class<T> type, Consumer4<Integer, Integer, Integer, T> iterator) {
if (type != Identifier.class) {
return;
}
for (Placed record : records) {
iterator.accept(record.offset(), 70, record.offset(), type.cast(Identifier.fromString(record.key())));
}
}
@Override
public <T> T get(int x, int y, int z, Class<T> type) {
if (type == Identifier.class && y == 70 && x == z) {
for (Placed record : records) {
if (record.offset() == x) {
return type.cast(Identifier.fromString(record.key()));
}
}
}
return null;
}
@Override
public void deleteSlices(Class<?> type) {
if (type == Identifier.class) {
customDeleted = true;
}
}
private boolean customDeleted() {
return customDeleted;
}
}
private static final class CarrierProvider implements ModdedDataProvider {
@Override
public String modId() {
return MOD_ID;
}
@Override
public Collection<String> getTypes(ModdedDataType type) {
return type == ModdedDataType.BLOCK ? List.of(LAMP, CHAIR, FIRE, SOUL_FIRE) : List.of();
}
@Override
public boolean isValidProvider(String id, ModdedDataType type) {
return type == ModdedDataType.BLOCK && getTypes(type).contains(id);
}
@Override
public ModdedBlockData getBlockData(String blockId, Map<String, String> state) {
BlockState carrier = switch (blockId) {
case CHAIR -> Blocks.AIR.defaultBlockState();
case FIRE -> Blocks.FIRE.defaultBlockState();
case SOUL_FIRE -> Blocks.SOUL_FIRE.defaultBlockState();
default -> Blocks.NOTE_BLOCK.defaultBlockState();
};
return ModdedBlockData.deferred(ModdedBlockState.of(carrier, null));
}
@Override
public String processBlockPlacement(ModdedBlockPlacementContext context) {
PLACED.add(context.placement().position());
return null;
}
}
}
@@ -68,6 +68,29 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
public class ModdedChunkUpdateServiceTest { public class ModdedChunkUpdateServiceTest {
@Test
@SuppressWarnings("unchecked")
public void regeneratedCleanupUsesSuppliedMantleAfterCurrentRuntimeChanges() {
Mantle<Matter> selected = mock(Mantle.class);
Mantle<Matter> replacement = mock(Mantle.class);
MantleDataAdapter<Matter> adapter = mock(MantleDataAdapter.class);
MantleChunk<Matter> chunk = new MantleChunk<>(1, 0, 0, adapter, MantleHooks.NONE);
when(selected.isChunkLoaded(0, 0)).thenReturn(true);
when(selected.useChunk(0, 0)).thenAnswer(invocation -> chunk.use());
when(selected.hasFlag(0, 0, MantleFlag.ETCHED)).thenReturn(true);
NativeWorld level = mock(NativeWorld.class);
when(level.isChunkLoaded(0, 0)).thenReturn(true);
Engine engine = engineWith(replacement);
when(engine.getWorld().platformWorld()).thenReturn(level);
new ModdedChunkUpdateService().updateRegeneratedChunk(engine, level, selected, 0, 0);
verify(selected).useChunk(0, 0);
verify(replacement, never()).isChunkLoaded(anyInt(), anyInt());
verify(replacement, never()).useChunk(anyInt(), anyInt());
assertFalse(chunk.inUse());
}
@Test @Test
public void fallingFluidUpdateRetainsLevelEightStateAndRequestsPhysics() { public void fallingFluidUpdateRetainsLevelEightStateAndRequestsPhysics() {
SharedConstants.tryDetectVersion(); SharedConstants.tryDetectVersion();
@@ -154,7 +177,7 @@ public class ModdedChunkUpdateServiceTest {
return null; return null;
}); });
service.updateRegeneratedChunk(engine, level, 0, 0); service.updateRegeneratedChunk(engine, level, engine.getMantle().getMantle(), 0, 0);
assertFalse(chunk.isFlagged(MantleFlag.ETCHED)); assertFalse(chunk.isFlagged(MantleFlag.ETCHED));
assertFalse(chunk.isFlagged(MantleFlag.UPDATE)); assertFalse(chunk.isFlagged(MantleFlag.UPDATE));
@@ -162,7 +185,7 @@ public class ModdedChunkUpdateServiceTest {
logging.verify(() -> IrisLogging.reportError(any(Throwable.class)), never()); logging.verify(() -> IrisLogging.reportError(any(Throwable.class)), never());
logging.verify(() -> IrisLogging.reportError(anyString(), any(Throwable.class)), never()); logging.verify(() -> IrisLogging.reportError(anyString(), any(Throwable.class)), never());
service.updateRegeneratedChunk(engine, level, 0, 0); service.updateRegeneratedChunk(engine, level, engine.getMantle().getMantle(), 0, 0);
assertTrue(chunk.isFlagged(MantleFlag.ETCHED)); assertTrue(chunk.isFlagged(MantleFlag.ETCHED));
assertTrue(chunk.deleted()); assertTrue(chunk.deleted());
@@ -187,7 +210,7 @@ public class ModdedChunkUpdateServiceTest {
}); });
assertThrows(SavedBiomeUnavailableException.class, assertThrows(SavedBiomeUnavailableException.class,
() -> new ModdedChunkUpdateService().updateRegeneratedChunk(engine, level, 0, 0)); () -> new ModdedChunkUpdateService().updateRegeneratedChunk(engine, level, engine.getMantle().getMantle(), 0, 0));
assertFalse(chunk.isFlagged(MantleFlag.ETCHED)); assertFalse(chunk.isFlagged(MantleFlag.ETCHED));
assertFalse(chunk.isFlagged(MantleFlag.UPDATE)); assertFalse(chunk.isFlagged(MantleFlag.UPDATE));
@@ -215,13 +238,13 @@ public class ModdedChunkUpdateServiceTest {
}); });
assertSame(failure, assertThrows(IllegalStateException.class, assertSame(failure, assertThrows(IllegalStateException.class,
() -> service.updateRegeneratedChunk(engine, level, 0, 0))); () -> service.updateRegeneratedChunk(engine, level, engine.getMantle().getMantle(), 0, 0)));
assertFalse(chunk.isFlagged(MantleFlag.UPDATE)); assertFalse(chunk.isFlagged(MantleFlag.UPDATE));
assertFalse(chunk.isFlagged(MantleFlag.ETCHED)); assertFalse(chunk.isFlagged(MantleFlag.ETCHED));
assertFalse(chunk.deleted()); assertFalse(chunk.deleted());
verify(chunk.slice, never()).set(2, 5, 2, null); verify(chunk.slice, never()).set(2, 5, 2, null);
service.updateRegeneratedChunk(engine, level, 0, 0); service.updateRegeneratedChunk(engine, level, engine.getMantle().getMantle(), 0, 0);
assertTrue(chunk.isFlagged(MantleFlag.ETCHED)); assertTrue(chunk.isFlagged(MantleFlag.ETCHED));
assertTrue(chunk.deleted()); assertTrue(chunk.deleted());
@@ -136,6 +136,18 @@
"replacement": "when(level.getMaxBuildHeight()).thenReturn(320);" "replacement": "when(level.getMaxBuildHeight()).thenReturn(320);"
} }
], ],
"art/arcane/iris/modded/api/ModdedCustomContentRegistryTest.java": [
{
"pattern": "\\bResourceLocation\\b",
"replacement": "Identifier"
}
],
"art/arcane/iris/modded/service/ModdedChunkUpdateServiceCarrierTest.java": [
{
"pattern": "\\bResourceLocation\\b",
"replacement": "Identifier"
}
],
"art/arcane/volmlib/nativelib/minecraft26_2/modded/NativeSpawnPopulationTest.java": [ "art/arcane/volmlib/nativelib/minecraft26_2/modded/NativeSpawnPopulationTest.java": [
{ {
"pattern": "when\\(level\\.getMaxY\\(\\)\\)\\.thenReturn\\(320\\);", "pattern": "when\\(level\\.getMaxY\\(\\)\\)\\.thenReturn\\(320\\);",
+3 -3
View File
@@ -56,10 +56,11 @@ IrisFileOperations irisFileOperations = objects.newInstance(IrisFileOperations)
apply from: file('../../gradle/modded-generation-build-revision.gradle') apply from: file('../../gradle/modded-generation-build-revision.gradle')
apply from: file('../../gradle/volmlib-stamp.gradle') apply from: file('../../gradle/volmlib-stamp.gradle')
apply from: file('../../gradle/java21-runtime-exclusions.gradle')
Properties rootProperties = new Properties() Properties rootProperties = new Properties()
file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) } file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) }
String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.3.4')) String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.3.5'))
String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3')) String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3'))
String neoForgeVersion = providers.gradleProperty('neoForgeVersion').getOrElse(irisModTarget.neoForge.toString()) String neoForgeVersion = providers.gradleProperty('neoForgeVersion').getOrElse(irisModTarget.neoForge.toString())
Closure<String> irisArtifactName = { String platform, String targetVersion -> Closure<String> irisArtifactName = { String platform, String targetVersion ->
@@ -295,8 +296,7 @@ tasks.named('shadowJar', ShadowJar).configure {
from(sourceSets.main.output) from(sourceSets.main.output)
mergeServiceFiles() mergeServiceFiles()
if ((int) irisModTarget.java == 21) { if ((int) irisModTarget.java == 21) {
exclude('art/arcane/volmlib/util/noise/NativeBillowLoader*.class') exclude(java21RuntimeExclusions)
exclude('art/arcane/volmlib/util/noise/BillowBatch*.class')
} }
exclude('META-INF/maven/**') exclude('META-INF/maven/**')
exclude('META-INF/proguard/**') exclude('META-INF/proguard/**')
+2
View File
@@ -33,6 +33,8 @@ plugins {
rootProject.name = 'iris-neoforge' rootProject.name = 'iris-neoforge'
apply from: new File(settingsDir, '../../gradle/runtime-cache.settings.gradle').canonicalFile
dependencyResolutionManagement { dependencyResolutionManagement {
versionCatalogs { versionCatalogs {
libs { libs {
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
./gradlew buildAllToOut --no-parallel -PuseLocalVolmLib=false "$@" ./gradlew buildAll "$@"
+121 -229
View File
@@ -9,9 +9,6 @@ import org.gradle.api.plugins.JavaPluginExtension
import org.gradle.api.provider.Provider import org.gradle.api.provider.Provider
import org.gradle.jvm.toolchain.JavaLauncher import org.gradle.jvm.toolchain.JavaLauncher
import org.gradle.jvm.toolchain.JavaToolchainService import org.gradle.jvm.toolchain.JavaToolchainService
import org.gradle.api.file.CopySpec
import org.gradle.api.file.DeleteSpec
import org.gradle.api.file.FileSystemOperations
import org.gradle.api.file.FileTree import org.gradle.api.file.FileTree
import org.gradle.api.plugins.JavaPlugin import org.gradle.api.plugins.JavaPlugin
import org.gradle.api.initialization.IncludedBuild import org.gradle.api.initialization.IncludedBuild
@@ -20,8 +17,6 @@ import org.gradle.api.tasks.compile.JavaCompile
import org.gradle.jvm.tasks.Jar import org.gradle.jvm.tasks.Jar
import org.gradle.jvm.toolchain.JavaLanguageVersion import org.gradle.jvm.toolchain.JavaLanguageVersion
import javax.inject.Inject
/* /*
* Iris is a World Generator for Minecraft Bukkit Servers * Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2021 Arcane Arts (Volmit Software) * Copyright (c) 2021 Arcane Arts (Volmit Software)
@@ -59,15 +54,8 @@ apply plugin: 'art.arcane.volmit-packaging'
apply from: file('gradle/runtime-relocations.gradle') apply from: file('gradle/runtime-relocations.gradle')
apply from: file('gradle/volmlib-stamp.gradle') apply from: file('gradle/volmlib-stamp.gradle')
interface IrisFileOperations {
@Inject
FileSystemOperations getFileSystemOperations()
}
IrisFileOperations irisFileOperations = objects.newInstance(IrisFileOperations)
group = 'art.arcane' group = 'art.arcane'
version = providers.gradleProperty('irisVersion').getOrElse('4.3.4') version = providers.gradleProperty('irisVersion').getOrElse('4.3.5')
project(':core') { project(':core') {
group = 'art.arcane' group = 'art.arcane'
@@ -99,41 +87,11 @@ Closure<String> irisArtifactName = { String platform, String targetVersion ->
return "Iris v${project.version} [${platform}] ${targetVersion}.jar" return "Iris v${project.version} [${platform}] ${targetVersion}.jar"
} }
String bukkitArtifactName = irisArtifactName('CraftBukkit', bukkitMinecraftRange) String bukkitArtifactName = irisArtifactName('CraftBukkit', bukkitMinecraftRange)
String fabricArtifactName = irisArtifactName('Fabric', "${minecraftVersion}+${loaderDisplayVersion(fabricLoaderVersion)}")
String forgeArtifactName = irisArtifactName('Forge', "${minecraftVersion}+${loaderDisplayVersion(forgeVersion)}")
String neoForgeArtifactName = irisArtifactName('NeoForge', "${minecraftVersion}+${loaderDisplayVersion(neoForgeVersion)}")
Map<String, String> runtimeDownloadRelocations = (Map<String, String>) ext.runtimeDownloadRelocations Map<String, String> runtimeDownloadRelocations = (Map<String, String>) ext.runtimeDownloadRelocations
List<String> runtimeDownloadedPrefixes = (List<String>) ext.runtimeDownloadedPrefixes List<String> runtimeDownloadedPrefixes = (List<String>) ext.runtimeDownloadedPrefixes
File pluginOutDirectory = file(providers.gradleProperty('pluginOutDirectory').getOrElse('../PluginOuts'))
File legacyBukkitJar = layout.buildDirectory.file("libs/Iris-${project.version}.jar").get().asFile
File bukkitArtifact = layout.buildDirectory.file("libs/${bukkitArtifactName}").get().asFile File bukkitArtifact = layout.buildDirectory.file("libs/${bukkitArtifactName}").get().asFile
File consumerBukkitArtifact = layout.buildDirectory.file("consumer/${bukkitArtifactName}").get().asFile
File fabricArtifact = layout.projectDirectory.file("adapters/fabric/build/${minecraftVersion}/libs/${fabricArtifactName}").asFile
File forgeArtifact = layout.projectDirectory.file("adapters/forge/build/${minecraftVersion}/libs/${forgeArtifactName}").asFile
File neoForgeArtifact = layout.projectDirectory.file("adapters/neoforge/build/${minecraftVersion}/libs/${neoForgeArtifactName}").asFile
File consumerDirectory = layout.buildDirectory.dir('consumer').get().asFile
FileTree staleConsumerBukkitJars = fileTree(consumerDirectory) {
include('Iris v* [CraftBukkit] *.jar')
}
FileTree staleBukkitOutJars = fileTree(pluginOutDirectory) {
include('Iris v* [CraftBukkit] *.jar')
}
String selectedModdedSuffix = minecraftVersion.replace('.', '_')
apply plugin: ApiGenerator apply plugin: ApiGenerator
// Where `buildAll` drops the per-platform jars for a local test server. Use the approved sibling
// Multiplexor workspace when it exists, with a repo-local fallback for fresh clones.
File multiplexorConsumerLocation = new File(rootDir, '../../[Minecraft Server]/consumers').canonicalFile
String defaultConsumerLocation = multiplexorConsumerLocation.isDirectory()
? multiplexorConsumerLocation.absolutePath
: layout.buildDirectory.dir('consumers').get().asFile.absolutePath
String consumerLocation = providers.gradleProperty('location')
.getOrElse(defaultConsumerLocation)
String bukkitConsumerPath = "${consumerLocation}/plugin-consumers/dropins/plugins"
String fabricConsumerPath = "${consumerLocation}/fabric-mod-consumers/dropins/mods"
String forgeConsumerPath = "${consumerLocation}/forge-mod-consumers/dropins/mods"
String neoForgeConsumerPath = "${consumerLocation}/neoforge-mod-consumers/dropins/mods"
List<String> nmsBindings = ['v26_2_R1', 'v26_3_R1'] List<String> nmsBindings = ['v26_2_R1', 'v26_3_R1']
ext.nativeDevelopmentAdapters = nmsBindings ext.nativeDevelopmentAdapters = nmsBindings
nmsBindings.each { String key -> nmsBindings.each { String key ->
@@ -240,65 +198,14 @@ tasks.named('jar', Jar).configure {
exclude('com/fren_gor/**') exclude('com/fren_gor/**')
} }
doFirst {
irisFileOperations.fileSystemOperations.delete { DeleteSpec spec -> spec.delete(legacyBukkitJar) }
}
archiveFileName.set(bukkitArtifactName) archiveFileName.set(bukkitArtifactName)
} }
tasks.register('iris', Copy) { tasks.register('buildBukkit') {
group = 'iris' group = 'build'
dependsOn('packedJar') description = 'Builds and verifies the packed Bukkit plugin.'
from({ tasks.named('packedJar').get().outputJar }) dependsOn('packedJar', 'check', ':core:check', ':spi:check', ':adapters:bukkit:plugin:check')
rename { String ignored -> bukkitArtifactName } dependsOn(nmsBindings.collect { String binding -> ":adapters:bukkit:nms:${binding}:check".toString() })
into(layout.buildDirectory.dir('consumer'))
doFirst {
irisFileOperations.fileSystemOperations.delete { DeleteSpec spec -> spec.delete(staleConsumerBukkitJars) }
}
}
tasks.register('buildSwiftSwamp') {
group = 'iris'
description = 'Builds every platform jar and drops them in the local BUILDS output directory.'
outputs.upToDateWhen { false }
dependsOn('iris', "verifyFabric${selectedModdedSuffix}", "verifyForge${selectedModdedSuffix}",
"verifyNeoForge${selectedModdedSuffix}")
File swiftSwampOut = file('C://VolmitSoftware/BUILDS')
Map<String, File> swiftSwampSources = [
'Iris.jar' : consumerBukkitArtifact,
'Iris-Fabric.jar' : fabricArtifact,
'Iris-Forge.jar' : forgeArtifact,
'Iris-Neoforge.jar': neoForgeArtifact
]
doLast {
swiftSwampSources.each { String target, File source ->
irisFileOperations.fileSystemOperations.copy { CopySpec spec ->
spec.from(source)
spec.into(swiftSwampOut)
spec.rename { String ignored -> target }
}
}
println('')
println('=== Iris buildSwiftSwamp -> ' + swiftSwampOut + ' ===')
['Iris.jar', 'Iris-Fabric.jar', 'Iris-Forge.jar', 'Iris-Neoforge.jar'].each { String jar ->
File produced = new File(swiftSwampOut, jar)
println(String.format(' %-24s %8d KB', jar, (long) (produced.length() / 1024)))
}
}
}
tasks.register('buildBukkit', Copy) {
group = 'iris'
dependsOn('packedJar')
from({ tasks.named('packedJar').get().outputJar })
rename { String ignored -> bukkitArtifactName }
into(pluginOutDirectory)
File legacyOutJar = new File(pluginOutDirectory, "Iris-${project.version}+mc${minecraftVersion}.jar")
doFirst {
irisFileOperations.fileSystemOperations.delete { DeleteSpec spec ->
spec.delete(staleBukkitOutJars, legacyOutJar)
}
}
} }
// The three loader jars come out of nested standalone Gradle builds driven through Exec. The outer // The three loader jars come out of nested standalone Gradle builds driven through Exec. The outer
@@ -310,6 +217,7 @@ List<Object> moddedSharedInputs = []
'adapters/modded-common/src', 'adapters/modded-common/src',
'adapters/client-common/src', 'adapters/client-common/src',
'core/src/main', 'core/src/main',
'core/src/testFixtures',
'core/build.gradle', 'core/build.gradle',
'core/agent/src', 'core/agent/src',
'core/agent/build.gradle', 'core/agent/build.gradle',
@@ -322,16 +230,24 @@ List<Object> moddedSharedInputs = []
'gradle/libs.versions.toml', 'gradle/libs.versions.toml',
'gradle/volmlib-resolution.settings.gradle', 'gradle/volmlib-resolution.settings.gradle',
'gradle/volmlib-stamp.gradle', 'gradle/volmlib-stamp.gradle',
'gradle/java21-runtime-exclusions.gradle',
'gradle/generation-build-revision.gradle', 'gradle/generation-build-revision.gradle',
'gradle/modded-generation-build-revision.gradle', 'gradle/modded-generation-build-revision.gradle',
'gradle/native-minecraft-sources.gradle', 'gradle/native-minecraft-sources.gradle',
'gradle/mod-target.gradle', 'gradle/mod-target.gradle',
'gradle/mod-targets.json', 'gradle/mod-targets.json',
'gradle/modded-version-sources.gradle' 'gradle/runtime-cache.settings.gradle',
'gradle/nested-build.init.gradle',
'gradle/modded-version-sources.gradle',
'gradle/wrapper/gradle-wrapper.properties',
'gradle/wrapper/gradle-wrapper.jar',
'gradlew',
'gradlew.bat'
].each { String path -> moddedSharedInputs.add(layout.projectDirectory.file(path).asFile) } ].each { String path -> moddedSharedInputs.add(layout.projectDirectory.file(path).asFile) }
if (localVolmLibCheckout != null) { if (localVolmLibCheckout != null) {
moddedSharedInputs.add(fileTree(localVolmLibCheckout) { 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/**/src/main/**',
'native/minecraft-common/src/**', 'packaging/src/main/**', 'native/minecraft-common/src/**', 'packaging/src/main/**',
'**/build.gradle', 'settings.gradle', 'gradle.properties', 'gradle/libs.versions.toml') '**/build.gradle', 'settings.gradle', 'gradle.properties', 'gradle/libs.versions.toml')
@@ -341,11 +257,11 @@ if (localVolmLibCheckout != null) {
// The published master-SNAPSHOT is resolved inside the nested build, so no outer input changes when it // The published master-SNAPSHOT is resolved inside the nested build, so no outer input changes when it
// moves. Without a local checkout the Exec tasks always run and the nested build decides what is stale. // moves. Without a local checkout the Exec tasks always run and the nested build decides what is stale.
Closure<Void> declareVolmLibInputs = { Exec task -> Closure<Void> declareVolmLibInputs = { IrisModdedBuild task ->
task.inputs.property('volmLibStamp', volmLibStamp) task.inputs.property('volmLibStamp', volmLibStamp)
String nativeBillow = providers.gradleProperty('includeNativeBillow').getOrElse('false') String nativeBillow = providers.gradleProperty('includeNativeBillow').getOrElse('false')
task.inputs.property('includeNativeBillow', nativeBillow) task.inputs.property('includeNativeBillow', nativeBillow)
task.environment('ORG_GRADLE_PROJECT_includeNativeBillow', nativeBillow) task.environment.put('ORG_GRADLE_PROJECT_includeNativeBillow', nativeBillow)
if (localVolmLibCheckout == null) { if (localVolmLibCheckout == null) {
task.outputs.upToDateWhen { false } task.outputs.upToDateWhen { false }
} }
@@ -410,10 +326,40 @@ TaskProvider<Task> verifyModdedMatrix = tasks.register('verifyModdedMatrix') {
group = 'verification' group = 'verification'
description = 'Builds, tests and verifies the Fabric, Forge and NeoForge jars of every supported Minecraft mod target.' description = 'Builds, tests and verifies the Fabric, Forge and NeoForge jars of every supported Minecraft mod target.'
} }
List<TaskProvider<Copy>> moddedExports = [] Map<String, TaskProvider<IrisModdedBuild>> selectedModdedBuilds = [:]
Map<String, TaskProvider<Exec>> selectedModdedBuilds = [:] int matrixParallelism = Math.min(gradle.startParameter.maxWorkerCount, Math.max(1,
Map<String, TaskProvider<Copy>> selectedModdedExports = [:] providers.gradleProperty('moddedBuildParallelism').getOrElse(
TaskProvider<Exec> previousMatrixBuild = null Math.min(12, Math.max(1, Runtime.runtime.availableProcessors() - 4)).toString()).toInteger()))
Provider<IrisModdedBuildService> matrixWorkers = gradle.sharedServices.registerIfAbsent(
'irisModdedBuilds', IrisModdedBuildService) {
maxParallelUsages.set(matrixParallelism)
}
List<String> nestedBuildArguments = [gradle.startParameter.buildCacheEnabled ? '--build-cache' : '--no-build-cache']
String paperweightSources = providers.gradleProperty('paperweight.experimental.genSources').orNull
if (paperweightSources != null) {
nestedBuildArguments.add("-Ppaperweight.experimental.genSources=${paperweightSources}".toString())
}
boolean multiplexorSandbox = providers.gradleProperty('multiplexorSandbox').getOrElse('false').toBoolean()
int nestedWorkers = multiplexorSandbox
? Math.max(1, gradle.startParameter.maxWorkerCount.intdiv(matrixParallelism))
: Math.min(2, gradle.startParameter.maxWorkerCount)
int nestedProcessors = multiplexorSandbox ? nestedWorkers : 2
if (multiplexorSandbox) {
nestedBuildArguments.addAll(['--no-daemon', '-PmultiplexorSandbox=true'])
}
String nestedJvmArguments = providers.gradleProperty('org.gradle.jvmargs').getOrElse('') +
" -XX:ActiveProcessorCount=${nestedProcessors}"
if (gradle.startParameter.rerunTasks) {
nestedBuildArguments.add('--rerun-tasks')
}
if (gradle.startParameter.refreshDependencies) {
nestedBuildArguments.add('--refresh-dependencies')
}
for (File initScript : gradle.startParameter.initScripts) {
nestedBuildArguments.addAll(['--init-script', initScript.absolutePath])
}
Map<Integer, TaskProvider<IrisModdedBuild>> daemonSlots = [:]
int nextDaemonSlot = 0
irisModTargets.each { String targetMinecraft, Map<String, Object> target -> irisModTargets.each { String targetMinecraft, Map<String, Object> target ->
String suffix = targetMinecraft.replace('.', '_') String suffix = targetMinecraft.replace('.', '_')
boolean selected = targetMinecraft == minecraftVersion boolean selected = targetMinecraft == minecraftVersion
@@ -424,67 +370,68 @@ irisModTargets.each { String targetMinecraft, Map<String, Object> target ->
String artifactName = irisArtifactName(label, "${targetMinecraft}+${displayedLoader}") String artifactName = irisArtifactName(label, "${targetMinecraft}+${displayedLoader}")
File artifact = file("adapters/${loader}/build/${targetMinecraft}/libs/${artifactName}") File artifact = file("adapters/${loader}/build/${targetMinecraft}/libs/${artifactName}")
boolean unitTests = ((List<String>) (target.testLoaders ?: moddedLoaderLabels.keySet().toList())).contains(loader) boolean unitTests = ((List<String>) (target.testLoaders ?: moddedLoaderLabels.keySet().toList())).contains(loader)
TaskProvider<Exec> buildTarget = tasks.register("verify${label}${suffix}", Exec) { int daemonSlot = nextDaemonSlot++ % matrixParallelism
TaskProvider<IrisModdedBuild> buildTarget = project(':adapters').tasks.register("verify${label}${suffix}", IrisModdedBuild) {
group = 'verification' group = 'verification'
description = "Builds, tests and verifies the ${label} ${targetMinecraft} jar in its own Gradle build." description = "Builds, tests and verifies the ${label} ${targetMinecraft} jar in its own Gradle build."
workingDir = file("adapters/${loader}") usesService(matrixWorkers)
List<String> command = [file(moddedWrapperScript).absolutePath, 'assemble', 'compileTestJava', workingDirectory.set(rootProject.file("adapters/${loader}"))
getLabel().set("${label} ${targetMinecraft}".toString())
artifactFile.set(artifact)
logFile.set(rootProject.layout.buildDirectory.file("reports/modded/${loader}-${targetMinecraft}.log"))
requiredEntries.set(commonModdedArtifactEntries + moddedLoaderEntries[loader])
forbiddenCoreClasses.set(bukkitCoupledCoreClasses)
javaRuntime.set((int) target.java)
List<String> command = [rootProject.file(moddedWrapperScript).absolutePath, 'assemble', 'compileTestJava',
'verifyLoggingPolicy', 'verifyNativeClassBoundary', '--console=plain', 'verifyLoggingPolicy', 'verifyNativeClassBoundary', '--console=plain',
"-PirisVersion=${project.version}", "-PminecraftVersion=${targetMinecraft}", "--max-workers=${nestedWorkers}",
"-Dorg.gradle.jvmargs=${nestedJvmArguments.trim()}",
"-Dorg.gradle.daemon.registry.base=${rootProject.file(".gradle/iris-daemons/slot-${daemonSlot}").absolutePath}",
'-Dorg.gradle.daemon.idletimeout=3600000',
"-PirisBuildLane=${loader}-${targetMinecraft}",
'--project-cache-dir', rootProject.file("adapters/${loader}/.gradle/iris-lanes/${loader}-${targetMinecraft}").absolutePath,
'--init-script', rootProject.file('gradle/nested-build.init.gradle').absolutePath,
"-PirisVersion=${rootProject.version}", "-PminecraftVersion=${targetMinecraft}",
"-P${moddedLoaderProperties[loader]}=${loaderPin}", "-P${moddedLoaderProperties[loader]}=${loaderPin}",
"-PuseLocalVolmLib=${localVolmLibCheckout != null}"] "-PuseLocalVolmLib=${localVolmLibCheckout != null}"]
if (unitTests) { if (unitTests) {
command.add('test') command.add('test')
} }
commandLine(command) command.add("-PtestForks=${providers.gradleProperty('moddedTestForks').getOrElse('1')}")
command.addAll(nestedBuildArguments)
commandLine.set(command.collect { Object argument -> argument.toString() })
if (localVolmLibCheckout != null) { if (localVolmLibCheckout != null) {
environment('ORG_GRADLE_PROJECT_localVolmLibDirectory', localVolmLibCheckout.absolutePath) environment.put('ORG_GRADLE_PROJECT_localVolmLibDirectory', localVolmLibCheckout.absolutePath)
} }
inputs.files(moddedSharedInputs).withPropertyName('moddedSharedSources') inputs.files(moddedSharedInputs).withPropertyName('moddedSharedSources')
.withPathSensitivity(PathSensitivity.RELATIVE) .withPathSensitivity(PathSensitivity.RELATIVE)
inputs.files(file("adapters/${loader}/src"), file("adapters/${loader}/build.gradle"), inputs.files(rootProject.file("adapters/${loader}/src"), rootProject.file("adapters/${loader}/build.gradle"),
file("adapters/${loader}/settings.gradle")).withPropertyName('adapterSources') rootProject.file("adapters/${loader}/settings.gradle"), rootProject.file("adapters/${loader}/gradle.properties"))
.withPropertyName('adapterSources')
inputs.files(gradle.startParameter.allInitScripts).withPropertyName('initScripts')
.withPathSensitivity(PathSensitivity.NONE)
outputs.upToDateWhen { !gradle.startParameter.refreshDependencies }
inputs.properties(target) inputs.properties(target)
inputs.property('loaderVersion', loaderPin) inputs.property('loaderVersion', loaderPin)
inputs.property('irisVersion', project.version.toString()) inputs.property('irisVersion', rootProject.version.toString())
declareVolmLibInputs(it) declareVolmLibInputs(it)
outputs.file(artifact)
if (unitTests) { if (unitTests) {
outputs.dir(file("adapters/${loader}/build/${targetMinecraft}/test-results/test")) outputs.dir(rootProject.file("adapters/${loader}/build/${targetMinecraft}/test-results/test"))
}
doLast {
ModdedArtifactVerifier.verify(artifact, commonModdedArtifactEntries + moddedLoaderEntries[loader],
bukkitCoupledCoreClasses)
ModdedArtifactVerifier.verifyJavaRuntime(artifact, (int) target.java)
logger.lifecycle("Verified ${artifact.name}")
} }
} }
if (previousMatrixBuild != null) { TaskProvider<IrisModdedBuild> previousSlotBuild = daemonSlots[daemonSlot]
TaskProvider<Exec> preceding = previousMatrixBuild if (previousSlotBuild != null) {
buildTarget.configure { mustRunAfter(preceding) } buildTarget.configure { mustRunAfter(previousSlotBuild) }
} }
previousMatrixBuild = buildTarget daemonSlots[daemonSlot] = buildTarget
verifyModdedMatrix.configure { dependsOn(buildTarget) } tasks.register("verify${label}${suffix}") {
File legacyOutJar = new File(pluginOutDirectory, "Iris-${project.version}+mc${targetMinecraft}-${loader}.jar") group = 'verification'
FileTree staleTargetJars = fileTree(pluginOutDirectory) { description = "Builds, tests and verifies the ${label} ${targetMinecraft} jar."
include("Iris v* [${label}] ${targetMinecraft}+*.jar")
}
TaskProvider<Copy> export = tasks.register("build${label}${suffix}", Copy) {
group = 'iris'
description = "Exports the verified ${label} ${targetMinecraft} jar to the plugin output directory."
dependsOn(buildTarget) dependsOn(buildTarget)
from(artifact)
into(pluginOutDirectory)
doFirst {
irisFileOperations.fileSystemOperations.delete { DeleteSpec spec ->
spec.delete(staleTargetJars, legacyOutJar)
}
}
} }
moddedExports.add(export) verifyModdedMatrix.configure { dependsOn(buildTarget) }
if (selected) { if (selected) {
selectedModdedBuilds[loader] = buildTarget selectedModdedBuilds[loader] = buildTarget
selectedModdedExports[loader] = export
} }
} }
} }
@@ -492,38 +439,37 @@ if (selectedModdedBuilds.isEmpty()) {
throw new GradleException("Minecraft ${minecraftVersion} is not a mod target in gradle/mod-targets.json") 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') { tasks.register('buildFabric') {
group = 'iris' group = 'iris'
description = "Exports the verified Fabric ${minecraftVersion} jar." description = "Builds and verifies the Fabric ${minecraftVersion} jar."
dependsOn(selectedModdedExports.fabric) dependsOn(selectedModdedBuilds.fabric)
} }
tasks.register('buildForge') { tasks.register('buildForge') {
group = 'iris' group = 'iris'
description = "Exports the verified Forge ${minecraftVersion} jar." description = "Builds and verifies the Forge ${minecraftVersion} jar."
dependsOn(selectedModdedExports.forge) dependsOn(selectedModdedBuilds.forge)
} }
tasks.register('buildNeoforge') { tasks.register('buildNeoforge') {
group = 'iris' group = 'iris'
description = "Exports the verified NeoForge ${minecraftVersion} jar." description = "Builds and verifies the NeoForge ${minecraftVersion} jar."
dependsOn(selectedModdedExports.neoforge) dependsOn(selectedModdedBuilds.neoforge)
}
gradle.projectsEvaluated {
List<Task> sharedOutputTasks = allprojects.collectMany { Project candidate ->
candidate.tasks.findAll { Task task ->
task instanceof JavaCompile || task instanceof Jar || task instanceof Test
}
}
tasks.withType(Exec).matching { Task task -> task.name.startsWith('verify') }.configureEach {
mustRunAfter(sharedOutputTasks)
mustRunAfter(tasks.named('buildBukkit'))
}
}
tasks.named('check').configure {
dependsOn(selectedModdedBuilds.fabric)
} }
tasks.register('checkAll') { tasks.register('checkAll') {
@@ -608,67 +554,10 @@ tasks.named('buildBukkit').configure {
dependsOn('verifyBukkitArtifact') dependsOn('verifyBukkitArtifact')
} }
tasks.named('iris').configure {
dependsOn('verifyBukkitArtifact')
}
tasks.register('buildAllToOut') {
group = 'iris'
description = 'Builds and verifies the Bukkit jar and every mod target jar into the plugin output directory.'
dependsOn('buildBukkit', ':spi:jar')
dependsOn(moddedExports)
doLast {
File[] jars = pluginOutDirectory.listFiles((java.io.FileFilter) { File f -> f.name.startsWith('Iris v') && f.name.endsWith('.jar') })
println('')
println('=== Iris buildAllToOut -> ' + pluginOutDirectory + ' ===')
if (jars != null) {
jars.sort { it.name }.each { File f ->
println(String.format(' %-60s %8d KB', f.name, (long) (f.length() / 1024)))
}
}
}
}
tasks.register('buildAll') { tasks.register('buildAll') {
group = 'iris' group = 'build'
outputs.upToDateWhen { false } description = 'Builds and verifies the Bukkit plugin, API, SPI and every supported mod target.'
dependsOn('iris', 'buildAllToOut') dependsOn('buildBukkit', 'irisApi', ':spi:jar', verifyModdedMatrix)
Map<String, List<Object>> consumerDrops = [
(bukkitConsumerPath) : ['Iris.jar', consumerBukkitArtifact],
(fabricConsumerPath) : ['Iris-Fabric.jar', fabricArtifact],
(forgeConsumerPath) : ['Iris-Forge.jar', forgeArtifact],
(neoForgeConsumerPath): ['Iris-Neoforge.jar', neoForgeArtifact]
]
doLast {
consumerDrops.each { String consumerPath, List<Object> drop ->
String keep = (String) drop.get(0)
File source = (File) drop.get(1)
irisFileOperations.fileSystemOperations.delete { DeleteSpec spec ->
consumerDrops.values().each { List<Object> other ->
String name = (String) other.get(0)
if (name != keep) {
spec.delete(new File(consumerPath, name))
}
}
}
irisFileOperations.fileSystemOperations.copy { CopySpec spec ->
spec.from(source)
spec.into(consumerPath)
spec.rename { String ignored -> keep }
}
}
println('')
println("=== Iris buildAll -> ${consumerLocation} ===")
println(" ${bukkitConsumerPath}/Iris.jar")
println(" ${fabricConsumerPath}/Iris-Fabric.jar")
println(" ${forgeConsumerPath}/Iris-Forge.jar")
println(" ${neoForgeConsumerPath}/Iris-Neoforge.jar")
}
}
tasks.register('buildPsychoLT') {
group = 'development'
description = 'Builds all verified Iris platform jars and stages them in the local test-server dropins and PluginOuts.'
dependsOn('buildAll')
} }
tasks.register('irisDev', Copy) { tasks.register('irisDev', Copy) {
@@ -688,12 +577,13 @@ if (!(irisRuntimeJava in [21, 25])) {
throw new GradleException('irisRuntimeJava must be 21 or 25') throw new GradleException('irisRuntimeJava must be 21 or 25')
} }
Set<String> sharedRuntimeProjects = [':core', ':core:agent', ':spi'] as Set<String> Set<String> sharedRuntimeProjects = [':core', ':core:agent', ':spi'] as Set<String>
String runtimeOutputVariant = gradle.extensions.extraProperties.get('irisRuntimeOutputVariant').toString()
allprojects { allprojects {
apply plugin: 'java' apply plugin: 'java'
boolean java21Runtime = irisRuntimeJava == 21 && sharedRuntimeProjects.contains(project.path) boolean java21Runtime = irisRuntimeJava == 21 && sharedRuntimeProjects.contains(project.path)
if (java21Runtime) { if (!runtimeOutputVariant.isEmpty() && sharedRuntimeProjects.contains(project.path)) {
layout.buildDirectory.set(layout.projectDirectory.dir('build/java21')) layout.buildDirectory.set(layout.projectDirectory.dir("build/${runtimeOutputVariant}"))
} }
tasks.withType(Test).configureEach { tasks.withType(Test).configureEach {
@@ -1038,3 +928,5 @@ tasks.withType(PackedJar).configureEach {
NativeRuntimeArtifacts.verifyPacked(task.outputJar.get().asFile, nativeRuntimeModules, embeddedNatives()) NativeRuntimeArtifacts.verifyPacked(task.outputJar.get().asFile, nativeRuntimeModules, embeddedNatives())
} }
} }
apply from: file('gradle/multiplexor.gradle')
+4 -1
View File
@@ -21,11 +21,14 @@ repositories {
// Nothing else asks buildSrc for `test`: Gradle only requests the jar that carries the build logic. // Nothing else asks buildSrc for `test`: Gradle only requests the jar that carries the build logic.
// Gating the jar on the tests is what makes them run at all, and it costs nothing once they are green. // Gating the jar on the tests is what makes them run at all, and it costs nothing once they are green.
tasks.named('jar').configure { tasks.named('jar').configure {
dependsOn(tasks.named('test')) if (!gradle.extensions.extraProperties.has('irisBuildLane')) {
dependsOn(tasks.named('test'))
}
} }
tasks.named('test').configure { tasks.named('test').configure {
jvmArgs('--add-opens=java.base/java.lang=ALL-UNNAMED') jvmArgs('--add-opens=java.base/java.lang=ALL-UNNAMED')
jvmArgs('-XX:ParallelGCThreads=2', '-XX:ConcGCThreads=1', '-XX:CICompilerCount=2')
} }
dependencies { dependencies {
+2 -15
View File
@@ -37,11 +37,6 @@ public class ApiGenerator implements Plugin<Project> {
throw new GradleException("Publishing extension not found"); throw new GradleException("Publishing extension not found");
} }
publishing.getRepositories().maven(repository -> {
repository.setName("deployDir");
repository.setUrl(targetDirectory(target).toURI());
});
publishing.getPublications().create("maven", MavenPublication.class, publication -> { publishing.getPublications().create("maven", MavenPublication.class, publication -> {
publication.setGroupId(target.getGroup().toString()); publication.setGroupId(target.getGroup().toString());
publication.setArtifactId("iris"); publication.setArtifactId("iris");
@@ -49,27 +44,19 @@ public class ApiGenerator implements Plugin<Project> {
publication.artifact(task); publication.artifact(task);
}); });
} }
public static File targetDirectory(Project project) {
String dir = System.getenv("DEPLOY_DIR");
if (dir == null) {
return project.getLayout().getBuildDirectory().dir("api").get().getAsFile();
}
return new File(dir);
}
} }
abstract class GenerateApiTask extends DefaultTask { abstract class GenerateApiTask extends DefaultTask {
public GenerateApiTask() { public GenerateApiTask() {
setGroup("iris"); setGroup("iris");
dependsOn("jar"); dependsOn("jar");
finalizedBy("publishMavenPublicationToDeployDirRepository");
doLast(task -> getLogger().lifecycle("The API is located at " + getOutputFile().get().getAsFile().getAbsolutePath())); doLast(task -> getLogger().lifecycle("The API is located at " + getOutputFile().get().getAsFile().getAbsolutePath()));
TaskProvider<Jar> jarTask = getProject().getTasks().named("jar", Jar.class); TaskProvider<Jar> jarTask = getProject().getTasks().named("jar", Jar.class);
getInputFile().convention(jarTask.flatMap(Jar::getArchiveFile)); getInputFile().convention(jarTask.flatMap(Jar::getArchiveFile));
getOutputFile().convention(getProject().getLayout().file(getInputFile().getLocationOnly().map(input -> getOutputFile().convention(getProject().getLayout().file(getInputFile().getLocationOnly().map(input ->
new File(ApiGenerator.targetDirectory(getProject()), input.getAsFile().getName())))); new File(getProject().getLayout().getBuildDirectory().dir("api").get().getAsFile(),
input.getAsFile().getName()))));
} }
@InputFile @InputFile
@@ -0,0 +1,67 @@
import org.gradle.api.DefaultTask;
import org.gradle.api.file.DirectoryProperty;
import org.gradle.api.file.RegularFileProperty;
import org.gradle.api.provider.ListProperty;
import org.gradle.api.provider.MapProperty;
import org.gradle.api.provider.Property;
import org.gradle.api.provider.SetProperty;
import org.gradle.api.tasks.Input;
import org.gradle.api.tasks.Internal;
import org.gradle.api.tasks.LocalState;
import org.gradle.api.tasks.OutputFile;
import org.gradle.api.tasks.TaskAction;
import org.gradle.workers.WorkerExecutor;
import javax.inject.Inject;
import java.util.Map;
public abstract class IrisModdedBuild extends DefaultTask {
public IrisModdedBuild() {
getEnvironment().convention(Map.of());
}
@Input
public abstract Property<String> getLabel();
@Internal
public abstract DirectoryProperty getWorkingDirectory();
@Input
public abstract ListProperty<String> getCommandLine();
@Input
public abstract MapProperty<String, String> getEnvironment();
@Input
public abstract ListProperty<String> getRequiredEntries();
@Input
public abstract SetProperty<String> getForbiddenCoreClasses();
@Input
public abstract Property<Integer> getJavaRuntime();
@OutputFile
public abstract RegularFileProperty getArtifactFile();
@LocalState
public abstract RegularFileProperty getLogFile();
@Inject
protected abstract WorkerExecutor getWorkerExecutor();
@TaskAction
public void build() {
getWorkerExecutor().noIsolation().submit(IrisModdedBuildAction.class, parameters -> {
parameters.getLabel().set(getLabel());
parameters.getWorkingDirectory().set(getWorkingDirectory());
parameters.getCommandLine().set(getCommandLine());
parameters.getEnvironment().set(getEnvironment());
parameters.getRequiredEntries().set(getRequiredEntries());
parameters.getForbiddenCoreClasses().set(getForbiddenCoreClasses());
parameters.getJavaRuntime().set(getJavaRuntime());
parameters.getArtifactFile().set(getArtifactFile());
parameters.getLogFile().set(getLogFile());
});
}
}
@@ -0,0 +1,85 @@
import org.gradle.api.GradleException;
import org.gradle.api.logging.Logger;
import org.gradle.api.logging.Logging;
import org.gradle.process.ExecOperations;
import org.gradle.workers.WorkAction;
import javax.inject.Inject;
import java.io.IOException;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.io.RandomAccessFile;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.Arrays;
import java.util.concurrent.TimeUnit;
public abstract class IrisModdedBuildAction implements WorkAction<IrisModdedBuildParameters> {
private static final Logger LOGGER = Logging.getLogger(IrisModdedBuildAction.class);
private static final int LOG_TAIL_BYTES = 16 * 1024;
private static final int LOG_TAIL_LINES = 40;
@Inject
protected abstract ExecOperations getExecOperations();
@Override
public void execute() {
IrisModdedBuildParameters parameters = getParameters();
String label = parameters.getLabel().get();
Path log = parameters.getLogFile().get().getAsFile().toPath();
long started = System.nanoTime();
LOGGER.lifecycle("{} started; log: {}", label, log);
try {
Files.createDirectories(log.getParent());
try (OutputStream output = Files.newOutputStream(log)) {
getExecOperations().exec(spec -> {
spec.setWorkingDir(parameters.getWorkingDirectory().get().getAsFile());
spec.commandLine(parameters.getCommandLine().get());
spec.environment(parameters.getEnvironment().get());
spec.setStandardOutput(output);
spec.setErrorOutput(output);
}).assertNormalExitValue();
}
ModdedArtifactVerifier.verify(parameters.getArtifactFile().get().getAsFile(),
parameters.getRequiredEntries().get(), parameters.getForbiddenCoreClasses().get());
ModdedArtifactVerifier.verifyJavaRuntime(parameters.getArtifactFile().get().getAsFile(),
parameters.getJavaRuntime().get());
LOGGER.lifecycle("{} succeeded in {} ms", label,
TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started));
} catch (Exception failure) {
appendFailure(log, failure);
String details;
try {
details = logTail(log);
} catch (IOException unreadable) {
failure.addSuppressed(unreadable);
details = "Log could not be read.";
}
LOGGER.error(label + " failed; log: " + log + System.lineSeparator() + details, failure);
throw new GradleException(label + " failed; log: " + log, failure);
}
}
static String logTail(Path log) throws IOException {
try (RandomAccessFile input = new RandomAccessFile(log.toFile(), "r")) {
long offset = Math.max(0L, input.length() - LOG_TAIL_BYTES);
input.seek(offset);
byte[] bytes = new byte[(int) (input.length() - offset)];
input.readFully(bytes);
String[] lines = new String(bytes, StandardCharsets.UTF_8).split("\\R");
return String.join(System.lineSeparator(), Arrays.copyOfRange(lines,
Math.max(0, lines.length - LOG_TAIL_LINES), lines.length));
}
}
private static void appendFailure(Path log, Exception failure) {
try (PrintWriter output = new PrintWriter(Files.newBufferedWriter(log, StandardCharsets.UTF_8,
StandardOpenOption.CREATE, StandardOpenOption.APPEND))) {
failure.printStackTrace(output);
} catch (IOException unwritable) {
failure.addSuppressed(unwritable);
}
}
}
@@ -0,0 +1,27 @@
import org.gradle.api.file.DirectoryProperty;
import org.gradle.api.file.RegularFileProperty;
import org.gradle.api.provider.ListProperty;
import org.gradle.api.provider.MapProperty;
import org.gradle.api.provider.Property;
import org.gradle.api.provider.SetProperty;
import org.gradle.workers.WorkParameters;
public interface IrisModdedBuildParameters extends WorkParameters {
Property<String> getLabel();
DirectoryProperty getWorkingDirectory();
ListProperty<String> getCommandLine();
MapProperty<String, String> getEnvironment();
ListProperty<String> getRequiredEntries();
SetProperty<String> getForbiddenCoreClasses();
Property<Integer> getJavaRuntime();
RegularFileProperty getArtifactFile();
RegularFileProperty getLogFile();
}
@@ -0,0 +1,5 @@
import org.gradle.api.services.BuildService;
import org.gradle.api.services.BuildServiceParameters;
public abstract class IrisModdedBuildService implements BuildService<BuildServiceParameters.None> {
}
@@ -231,11 +231,11 @@ public final class GenerationBuildRevision {
throw new IOException("Duplicate dependency artifact entry: " + name); throw new IOException("Duplicate dependency artifact entry: " + name);
} }
} }
byte[] buffer = new byte[16 * 1024];
for (Map.Entry<String, ZipEntry> entry : entries.entrySet()) { for (Map.Entry<String, ZipEntry> entry : entries.entrySet()) {
update(digest, entry.getKey()); update(digest, entry.getKey());
try (InputStream input = zip.getInputStream(entry.getValue())) { try (InputStream input = zip.getInputStream(entry.getValue())) {
MessageDigest content = digest(); MessageDigest content = digest();
byte[] buffer = new byte[16 * 1024];
int read; int read;
while ((read = input.read(buffer)) != -1) { while ((read = input.read(buffer)) != -1) {
content.update(buffer, 0, read); content.update(buffer, 0, read);
+4 -1
View File
@@ -50,9 +50,12 @@ public class ApiGeneratorTest {
assertEquals(jar.getArchiveFile().get().getAsFile(), api.getInputFile().get().getAsFile()); assertEquals(jar.getArchiveFile().get().getAsFile(), api.getInputFile().get().getAsFile());
assertEquals(jar.getArchiveFileName().get(), api.getOutputFile().get().getAsFile().getName()); assertEquals(jar.getArchiveFileName().get(), api.getOutputFile().get().getAsFile().getName());
assertEquals(ApiGenerator.targetDirectory(project), api.getOutputFile().get().getAsFile().getParentFile()); assertEquals(project.getLayout().getBuildDirectory().dir("api").get().getAsFile(),
api.getOutputFile().get().getAsFile().getParentFile());
assertTrue(api.getFinalizedBy().getDependencies(api).isEmpty());
PublishingExtension publishing = project.getExtensions().getByType(PublishingExtension.class); PublishingExtension publishing = project.getExtensions().getByType(PublishingExtension.class);
assertTrue(publishing.getRepositories().isEmpty());
MavenPublication publication = (MavenPublication) publishing.getPublications().getByName("maven"); MavenPublication publication = (MavenPublication) publishing.getPublications().getByName("maven");
assertEquals("art.arcane", publication.getGroupId()); assertEquals("art.arcane", publication.getGroupId());
assertEquals("iris", publication.getArtifactId()); assertEquals("iris", publication.getArtifactId());
@@ -58,6 +58,22 @@ public class GenerationBuildRevisionTest {
GenerationBuildRevision.capture(fixture.options(), dependencies))); GenerationBuildRevision.capture(fixture.options(), dependencies)));
} }
@Test
public void hashesLargeShortAndEmptyEntriesIndependently() throws Exception {
Path dependency = temporary.newFile("multiple-entries.jar").toPath();
Map<String, String> contents = Map.of("example/Large.class", "abc".repeat(11_000),
"example/Short.class", "tail", "example/Zero.class", "");
try (ZipOutputStream output = new ZipOutputStream(Files.newOutputStream(dependency))) {
for (String name : List.of("example/Zero.class", "example/Short.class", "example/Large.class")) {
output.putNextEntry(new ZipEntry(name));
output.write(contents.get(name).getBytes(StandardCharsets.UTF_8));
output.closeEntry();
}
}
assertEquals("49217af891afa3111d1f70a390acacc8bad700528b61b1ecc5637c060aeb52cd",
GenerationBuildRevision.artifactHash(dependency));
}
@Test @Test
public void rejectsChangedAddedAndDeletedGenerationSources() throws Exception { public void rejectsChangedAddedAndDeletedGenerationSources() throws Exception {
Fixture fixture = fixture(); Fixture fixture = fixture();
@@ -0,0 +1,79 @@
import org.gradle.api.Project;
import org.gradle.testfixtures.ProjectBuilder;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class IrisModdedBuildTest {
@Rule
public TemporaryFolder temporary = new TemporaryFolder();
@Test
public void capturesTheCompleteTargetConfiguration() throws Exception {
Project project = ProjectBuilder.builder().withProjectDir(temporary.newFolder()).build();
IrisModdedBuild task = project.getTasks().create("verifyFabric", IrisModdedBuild.class);
task.getLabel().set("Fabric 26.3");
task.getWorkingDirectory().set(project.getLayout().getProjectDirectory());
task.getCommandLine().set(List.of("gradlew", "assemble", "test"));
task.getRequiredEntries().set(List.of("fabric.mod.json"));
task.getForbiddenCoreClasses().set(Set.of("org/bukkit/Test"));
task.getJavaRuntime().set(25);
task.getArtifactFile().set(project.getLayout().getProjectDirectory().file("build/mod.jar"));
task.getLogFile().set(project.getLayout().getProjectDirectory().file("build/reports/matrix/fabric.log"));
assertEquals(Map.of(), task.getEnvironment().get());
assertEquals(List.of("gradlew", "assemble", "test"), task.getCommandLine().get());
assertEquals(List.of("fabric.mod.json"), task.getRequiredEntries().get());
assertEquals(Set.of("org/bukkit/Test"), task.getForbiddenCoreClasses().get());
assertEquals(Integer.valueOf(25), task.getJavaRuntime().get());
assertEquals(Set.of(task.getArtifactFile().get().getAsFile()), task.getOutputs().getFiles().getFiles());
assertFalse(task.getOutputs().getFiles().contains(task.getLogFile().get().getAsFile()));
}
@Test
public void failureSummaryRetainsTheFinalLinesWithoutReadingAnEntireLog() throws Exception {
Path log = temporary.newFile().toPath();
StringBuilder content = new StringBuilder("discarded first line\n");
for (int index = 0; index < 2000; index++) {
content.append("task output ").append(index).append('\n');
}
content.append("final failure\n");
Files.writeString(log, content);
String tail = IrisModdedBuildAction.logTail(log);
assertEquals(40, tail.lines().count());
assertTrue(tail.endsWith("final failure"));
assertFalse(tail.contains("discarded first line"));
assertTrue(tail.length() <= 16 * 1024);
}
@Test
public void failureSummaryAcceptsEmptyAndUnterminatedLogs() throws Exception {
Path log = temporary.newFile().toPath();
assertEquals("", IrisModdedBuildAction.logTail(log));
Files.writeString(log, "first\r\nlast");
assertEquals("first" + System.lineSeparator() + "last", IrisModdedBuildAction.logTail(log));
}
@Test
public void failureSummaryBoundsAnUnbrokenFailureLine() throws Exception {
Path log = temporary.newFile().toPath();
Files.writeString(log, "x".repeat(100_000) + "final failure");
String tail = IrisModdedBuildAction.logTail(log);
assertEquals(16 * 1024, tail.length());
assertTrue(tail.endsWith("final failure"));
}
}
+27 -4
View File
@@ -1,3 +1,5 @@
import groovy.json.JsonSlurper
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
import io.github.slimjar.resolver.data.Mirror import io.github.slimjar.resolver.data.Mirror
import org.gradle.api.attributes.java.TargetJvmVersion import org.gradle.api.attributes.java.TargetJvmVersion
import org.gradle.api.file.FileTreeElement import org.gradle.api.file.FileTreeElement
@@ -37,6 +39,7 @@ plugins {
} }
apply from: rootProject.file('gradle/runtime-relocations.gradle') apply from: rootProject.file('gradle/runtime-relocations.gradle')
apply from: rootProject.file('gradle/java21-runtime-exclusions.gradle')
def lib = 'art.arcane.iris.util' def lib = 'art.arcane.iris.util'
@@ -176,6 +179,7 @@ dependencies {
tasks.named('test').configure { tasks.named('test').configure {
maxHeapSize = '1g' maxHeapSize = '1g'
jvmArgs('-XX:+ExitOnOutOfMemoryError') jvmArgs('-XX:+ExitOnOutOfMemoryError')
systemProperty('iris.generation.counters', 'true')
} }
java { java {
@@ -197,8 +201,16 @@ slimJar {
} }
} }
TaskProvider<Task> slimJarTask = tasks.named('slimJar')
tasks.withType(ShadowJar).configureEach {
dependsOn(slimJarTask)
}
def embeddedAgentJar = project(':core:agent').tasks.named('jar', Jar) def embeddedAgentJar = project(':core:agent').tasks.named('jar', Jar)
tasks.named('processResources').configure { tasks.named('processResources').configure { Task resources ->
resources.setFinalizedBy(resources.finalizedBy.getDependencies(resources).findAll { Task finalizer ->
finalizer != slimJarTask.get()
})
from(embeddedAgentJar.map { it.archiveFile }) { from(embeddedAgentJar.map { it.archiveFile }) {
rename { String ignored -> 'agent.jar' } rename { String ignored -> 'agent.jar' }
} }
@@ -217,6 +229,18 @@ def generateTemplates = tasks.register('generateTemplates', Copy) {
return 'development' return 'development'
}, },
commit: providers.provider { commit: providers.provider {
String sourceHashesJson = providers.environmentVariable('MULTIPLEXOR_SOURCE_HASHES').orNull
if (sourceHashesJson != null) {
Object sourceHashes = new JsonSlurper().parseText(sourceHashesJson)
if (!(sourceHashes instanceof Map)) {
throw new GradleException('MULTIPLEXOR_SOURCE_HASHES must map project paths to SHA-256 source fingerprints')
}
Object sourceHash = sourceHashes.get(rootProject.projectDir.canonicalPath)
if (!(sourceHash instanceof String) || !(sourceHash ==~ /[a-f0-9]{64}/)) {
throw new GradleException("MULTIPLEXOR_SOURCE_HASHES requires a SHA-256 source fingerprint for ${rootProject.projectDir.canonicalPath}")
}
return "source-${sourceHash}"
}
String commitId = null String commitId = null
Exception failure = null Exception failure = null
try { try {
@@ -447,13 +471,12 @@ List<String> dependencyBuildMetadata = [
String languageTomlSupportClassPattern = 'art/arcane/volmlib/util/config/(?:TomlCodec|ConfigJson|ConfigExposePolicy|ConfigDoc|ConfigDescription|ConfigDocumentation)(?:\\$[^/]+)?\\.class' String languageTomlSupportClassPattern = 'art/arcane/volmlib/util/config/(?:TomlCodec|ConfigJson|ConfigExposePolicy|ConfigDoc|ConfigDescription|ConfigDocumentation)(?:\\$[^/]+)?\\.class'
tasks.named('shadowJar', com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar).configure { tasks.named('shadowJar', ShadowJar).configure {
inputs.property('languageTomlSupportClassPattern', languageTomlSupportClassPattern) inputs.property('languageTomlSupportClassPattern', languageTomlSupportClassPattern)
dependsOn(embeddedAgentJar) dependsOn(embeddedAgentJar)
mergeServiceFiles() mergeServiceFiles()
if (providers.gradleProperty('irisRuntimeJava').getOrElse(rootProject.irisModTarget.java.toString()) == '21') { if (providers.gradleProperty('irisRuntimeJava').getOrElse(rootProject.irisModTarget.java.toString()) == '21') {
exclude('art/arcane/volmlib/util/noise/NativeBillowLoader*.class') exclude(java21RuntimeExclusions)
exclude('art/arcane/volmlib/util/noise/BillowBatch*.class')
} }
// minimize() stays off. Caffeine reaches its 520 generated cache and node classes through // 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 // 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/LegacyTileData.java
art/arcane/iris/generation/block/TileData.java art/arcane/iris/generation/block/TileData.java
art/arcane/iris/generation/chunk/ChunkBiomeHunkView.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/ChunkDataHunkView.java
art/arcane/iris/generation/chunk/ChunkDataOutput.java
art/arcane/iris/generation/chunk/ChunkHunkView.java art/arcane/iris/generation/chunk/ChunkHunkView.java
art/arcane/iris/generation/chunk/LinkedTerrainChunk.java art/arcane/iris/generation/chunk/LinkedTerrainChunk.java
art/arcane/iris/generation/chunk/TerrainChunk.java art/arcane/iris/generation/chunk/TerrainChunk.java
@@ -15,6 +15,7 @@ import net.momirealms.craftengine.bukkit.api.CraftEngineFurniture;
import net.momirealms.craftengine.bukkit.api.CraftEngineItems; import net.momirealms.craftengine.bukkit.api.CraftEngineItems;
import net.momirealms.craftengine.bukkit.api.event.CraftEngineReloadEvent; import net.momirealms.craftengine.bukkit.api.event.CraftEngineReloadEvent;
import net.momirealms.craftengine.bukkit.item.BukkitItemDefinition; import net.momirealms.craftengine.bukkit.item.BukkitItemDefinition;
import net.momirealms.craftengine.bukkit.entity.furniture.BukkitFurniture;
import net.momirealms.craftengine.bukkit.plugin.BukkitCraftEngine; import net.momirealms.craftengine.bukkit.plugin.BukkitCraftEngine;
import net.momirealms.craftengine.core.block.BlockDefinition; import net.momirealms.craftengine.core.block.BlockDefinition;
import net.momirealms.craftengine.core.block.ImmutableBlockState; import net.momirealms.craftengine.core.block.ImmutableBlockState;
@@ -24,10 +25,12 @@ import net.momirealms.craftengine.core.entity.furniture.FurnitureVariant;
import net.momirealms.craftengine.core.registry.Holder; import net.momirealms.craftengine.core.registry.Holder;
import net.momirealms.craftengine.core.util.Key; import net.momirealms.craftengine.core.util.Key;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.Chunk;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.Material; import org.bukkit.Material;
import org.bukkit.block.Block; import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData; import org.bukkit.block.data.BlockData;
import org.bukkit.entity.Entity;
import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.ItemStack;
import org.bukkit.plugin.Plugin; import org.bukkit.plugin.Plugin;
import org.junit.Test; import org.junit.Test;
@@ -37,6 +40,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.MissingResourceException; import java.util.MissingResourceException;
import java.util.Set; import java.util.Set;
import java.util.UUID;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
@@ -48,12 +52,59 @@ import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy; import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
public class CraftEngineDataProviderTest { public class CraftEngineDataProviderTest {
@Test
public void furniturePlacementReturnsExactEntityTokenAndCleanupKeepsReplacement() {
FurnitureDefinition definition = furnitureDefinition();
Engine engine = mock(Engine.class);
SeedManager seeds = mock(SeedManager.class);
when(engine.getSeedManager()).thenReturn(seeds);
Block block = mock(Block.class);
when(block.getLocation()).thenAnswer(invocation -> new Location(null, 0, 70, 0));
Chunk chunk = mock(Chunk.class);
when(block.getChunk()).thenReturn(chunk);
when(chunk.isEntitiesLoaded()).thenReturn(true);
Entity original = mock(Entity.class);
Entity replacement = mock(Entity.class);
UUID originalId = UUID.randomUUID();
when(original.getUniqueId()).thenReturn(originalId);
when(replacement.getUniqueId()).thenReturn(UUID.randomUUID());
when(chunk.getEntities()).thenReturn(new Entity[]{replacement, original});
BukkitFurniture placed = mock(BukkitFurniture.class);
when(placed.baseEntity()).thenReturn(original);
CraftEngineDataProvider provider = new CraftEngineDataProvider();
try (MockedStatic<CraftEngineBlocks> blocks = mockStatic(CraftEngineBlocks.class);
MockedStatic<CraftEngineFurniture> furniture = mockStatic(CraftEngineFurniture.class)) {
furniture.when(() -> CraftEngineFurniture.byId(Key.of("test:chair"))).thenReturn(definition);
furniture.when(() -> CraftEngineFurniture.place(any(Location.class), eq(definition), eq("alpha"), eq(false)))
.thenReturn(placed);
furniture.when(() -> CraftEngineFurniture.remove(original, false, false)).thenReturn(true);
String token = provider.processUpdate(engine, block, Identifier.fromString("test:chair"));
assertEquals(originalId.toString(), token);
assertTrue(provider.removeGenerated(block, token));
furniture.verify(() -> CraftEngineFurniture.remove(original, false, false));
furniture.verify(() -> CraftEngineFurniture.remove(replacement, false, false), never());
}
}
@Test
public void furnitureCleanupWaitsForEntitiesToLoad() {
Block block = mock(Block.class);
Chunk chunk = mock(Chunk.class);
when(block.getChunk()).thenReturn(chunk);
assertFalse(new CraftEngineDataProvider().removeGenerated(block, UUID.randomUUID().toString()));
verify(chunk, never()).getEntities();
}
@Test @Test
public void startupWaitsForReloadEventAndEmptyLoadedRegistriesAreReady() { public void startupWaitsForReloadEventAndEmptyLoadedRegistriesAreReady() {
CraftEngineDataProvider provider = spy(new CraftEngineDataProvider()); CraftEngineDataProvider provider = spy(new CraftEngineDataProvider());
@@ -0,0 +1,198 @@
package art.arcane.iris.platform.generation;
import art.arcane.iris.generation.block.IrisCustomData;
import art.arcane.iris.generation.mantle.CustomBlockOwnership;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.SeedManager;
import art.arcane.iris.integration.ExternalDataProvider;
import art.arcane.iris.integration.ExternalDataSVC;
import art.arcane.iris.integration.Identifier;
import art.arcane.iris.integration.data.CraftEngineDataProvider;
import art.arcane.iris.platform.bukkit.BukkitBlockResolution;
import art.arcane.iris.platform.registry.RegistryUtil;
import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.testsupport.BukkitTestServer;
import art.arcane.iris.world.IrisWorld;
import art.arcane.volmlib.util.function.Consumer4;
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
import art.arcane.volmlib.util.matter.Matter;
import art.arcane.volmlib.util.matter.MatterSlice;
import net.momirealms.craftengine.bukkit.api.CraftEngineBlocks;
import net.momirealms.craftengine.bukkit.api.CraftEngineFurniture;
import net.momirealms.craftengine.bukkit.api.CraftEngineItems;
import net.momirealms.craftengine.bukkit.api.event.CraftEngineReloadEvent;
import net.momirealms.craftengine.bukkit.entity.furniture.BukkitFurniture;
import net.momirealms.craftengine.bukkit.plugin.BukkitCraftEngine;
import net.momirealms.craftengine.core.block.BlockDefinition;
import net.momirealms.craftengine.core.block.ImmutableBlockState;
import net.momirealms.craftengine.core.entity.furniture.FurnitureDefinition;
import net.momirealms.craftengine.core.entity.furniture.FurnitureVariant;
import net.momirealms.craftengine.core.registry.Holder;
import net.momirealms.craftengine.core.util.Key;
import org.bukkit.Chunk;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import org.bukkit.entity.Entity;
import org.junit.Test;
import org.mockito.MockedStatic;
import org.mockito.invocation.InvocationOnMock;
import java.lang.reflect.Field;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.junit.Assert.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.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 CraftEngineStaleRecordTest {
@Test
public void stalePlacementRecordsAreSkippedAndIntactOnesPlaced() throws Exception {
BukkitTestServer.install();
BlockDefinition lampDefinition = mock(BlockDefinition.class);
ImmutableBlockState lamp = mock(ImmutableBlockState.class);
when(lamp.owner()).thenReturn(Holder.direct(lampDefinition));
when(lamp.toString()).thenReturn("test:lamp");
when(lampDefinition.defaultState()).thenReturn(lamp);
FurnitureDefinition chairDefinition = mock(FurnitureDefinition.class);
when(chairDefinition.id()).thenReturn(Key.of("test:chair"));
when(chairDefinition.variants()).thenReturn(Map.of("alpha", mock(FurnitureVariant.class)));
BlockData noteBlock = blockData(Material.NOTE_BLOCK);
BlockData air = blockData(Material.AIR);
CraftEngineDataProvider provider = new CraftEngineDataProvider();
ExternalDataSVC service = new ExternalDataSVC();
activate(service, provider);
Engine engine = mock(Engine.class);
when(engine.getWorld()).thenReturn(mock(IrisWorld.class));
SeedManager seeds = mock(SeedManager.class);
when(engine.getSeedManager()).thenReturn(seeds);
World world = mock(World.class);
when(world.getMinHeight()).thenReturn(0);
when(world.getMaxHeight()).thenReturn(256);
Chunk chunk = mock(Chunk.class);
when(chunk.getWorld()).thenReturn(world);
block(chunk, 1, Material.NOTE_BLOCK);
block(chunk, 2, Material.AIR);
block(chunk, 3, Material.COBBLESTONE);
block(chunk, 4, Material.COBBLESTONE);
try (MockedStatic<BukkitCraftEngine> craftEngine = mockStatic(BukkitCraftEngine.class);
MockedStatic<CraftEngineBlocks> blocks = mockStatic(CraftEngineBlocks.class);
MockedStatic<CraftEngineFurniture> furniture = mockStatic(CraftEngineFurniture.class);
MockedStatic<CraftEngineItems> items = mockStatic(CraftEngineItems.class);
MockedStatic<RegistryUtil> registry = materialRegistry();
MockedStatic<BukkitBlockResolution> resolution = mockStatic(BukkitBlockResolution.class);
MockedStatic<IrisServices> services = mockStatic(IrisServices.class)) {
resolution.when(BukkitBlockResolution::getAir).thenReturn(air);
services.when(() -> IrisServices.get(ExternalDataSVC.class)).thenReturn(service);
blocks.when(CraftEngineBlocks::loadedBlocks).thenReturn(Map.of(Key.of("test:lamp"), lampDefinition));
furniture.when(CraftEngineFurniture::loadedFurniture).thenReturn(Map.of(Key.of("test:chair"), chairDefinition));
items.when(CraftEngineItems::loadedItems).thenReturn(Map.of());
blocks.when(() -> CraftEngineBlocks.byId(Key.of("test:lamp"))).thenReturn(lampDefinition);
blocks.when(() -> CraftEngineBlocks.getBukkitBlockData(lamp)).thenReturn(noteBlock);
blocks.when(() -> CraftEngineBlocks.place(any(Location.class), eq(lamp), eq(false))).thenReturn(true);
furniture.when(() -> CraftEngineFurniture.byId(Key.of("test:chair"))).thenReturn(chairDefinition);
BukkitFurniture placed = mock(BukkitFurniture.class);
Entity entity = mock(Entity.class);
when(placed.baseEntity()).thenReturn(entity);
when(entity.getUniqueId()).thenReturn(UUID.randomUUID());
furniture.when(() -> CraftEngineFurniture.place(any(Location.class), eq(chairDefinition), eq("alpha"), eq(false)))
.thenReturn(placed);
provider.onReload(mock(CraftEngineReloadEvent.class));
Identifier lampRecord = generatedRecord(service, "test:lamp");
Identifier chairRecord = generatedRecord(service, "test:chair");
assertEquals("craftengine:test/lamp", lampRecord.toString());
assertEquals("craftengine:test/chair[variant=alpha]", chairRecord.toString());
EngineBukkitOps.materializeCustomBlocks(engine, chunk, records(
new Placed(1, lampRecord),
new Placed(2, lampRecord),
new Placed(3, lampRecord),
new Placed(4, chairRecord)));
blocks.verify(() -> CraftEngineBlocks.place(any(Location.class), eq(lamp), eq(false)), times(1));
blocks.verify(() -> CraftEngineBlocks.place(location(1), lamp, false));
blocks.verify(() -> CraftEngineBlocks.place(location(2), lamp, false), never());
blocks.verify(() -> CraftEngineBlocks.place(location(3), lamp, false), never());
furniture.verify(() -> CraftEngineFurniture.place(location(4), chairDefinition, "alpha", false));
}
}
private static MockedStatic<RegistryUtil> materialRegistry() {
MockedStatic<RegistryUtil> registry = mockStatic(RegistryUtil.class);
registry.when(() -> RegistryUtil.find(Material.class, "grass", "short_grass")).thenReturn(Material.SHORT_GRASS);
return registry;
}
private static Identifier generatedRecord(ExternalDataSVC service, String key) {
BlockData resolved = service.getBlockData(Identifier.fromString(key)).orElseThrow();
return ((IrisCustomData) resolved).getCustom();
}
private static BlockData blockData(Material material) {
BlockData data = mock(BlockData.class);
when(data.getMaterial()).thenReturn(material);
return data;
}
private static Block block(Chunk chunk, int offset, Material type) {
Block block = mock(Block.class);
when(block.getType()).thenReturn(type);
when(block.getX()).thenReturn(offset);
when(block.getY()).thenReturn(10);
when(block.getZ()).thenReturn(offset);
when(block.getLocation()).thenAnswer((InvocationOnMock invocation) -> location(offset));
when(chunk.getBlock(offset, 10, offset)).thenReturn(block);
return block;
}
private static Location location(int offset) {
return new Location(null, offset, 10, offset);
}
@SuppressWarnings("unchecked")
private static MantleChunk<Matter> records(Placed... records) {
MantleChunk<Matter> chunk = mock(MantleChunk.class);
Matter section = mock(Matter.class);
MatterSlice<CustomBlockOwnership> ownership = mock(MatterSlice.class);
MatterSlice<Identifier> deferred = mock(MatterSlice.class);
when(chunk.getOrCreate(anyInt())).thenReturn(section);
when(section.<CustomBlockOwnership>slice(CustomBlockOwnership.class)).thenReturn(ownership);
when(section.getSlice(Identifier.class)).thenReturn(deferred);
for (Placed record : records) {
when(chunk.get(record.offset(), 10, record.offset(), Identifier.class)).thenReturn(record.identifier());
when(deferred.get(record.offset(), 10, record.offset())).thenReturn(record.identifier());
}
doAnswer((InvocationOnMock invocation) -> {
Consumer4<Integer, Integer, Integer, Identifier> iterator = invocation.getArgument(1);
for (Placed record : records) {
iterator.accept(record.offset(), 10, record.offset(), record.identifier());
}
return null;
}).when(chunk).iterate(eq(Identifier.class), any());
return chunk;
}
@SuppressWarnings("unchecked")
private static void activate(ExternalDataSVC service, ExternalDataProvider provider) throws Exception {
Field field = ExternalDataSVC.class.getDeclaredField("activeProviders");
field.setAccessible(true);
((List<ExternalDataProvider>) field.get(service)).add(provider);
}
private record Placed(int offset, Identifier identifier) {
}
}
@@ -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;
}
}
@@ -515,7 +515,10 @@ public class IrisBiome extends IrisRegistrant implements Rarity {
} }
public CNG getChildrenGenerator(RNG random, int sig, double scale) { 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(); IrisData data = getLoader();
Engine engine = data == null ? null : data.getEngine(); Engine engine = data == null ? null : data.getEngine();
CachedChildGenerator recent = recentChildGenerator; CachedChildGenerator recent = recentChildGenerator;
@@ -533,6 +536,10 @@ public class IrisBiome extends IrisRegistrant implements Rarity {
return random.nextParallelRNG(signature * CHILDREN_SEED_SALT); 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) { 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); return IrisBiomeLayerGenerator.generateLayers(this, dim, wx, wz, random, maxDepth, height, rdata, complex);
} }
@@ -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; 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 Hunk<T> source;
private final Listener<T> listener; private final Listener<T> listener;
@@ -35,6 +35,12 @@ public final class ColumnExtentListeningHunk<T> implements Hunk<T>, ColumnExtent
source.setRaw(x, y, z, t); 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 @Override
public T getRaw(int x, int y, int z) { public T getRaw(int x, int y, int z) {
return source.getRaw(x, y, z); return source.getRaw(x, y, z);
@@ -63,5 +69,11 @@ public final class ColumnExtentListeningHunk<T> implements Hunk<T>, ColumnExtent
@FunctionalInterface @FunctionalInterface
public interface Listener<T> { public interface Listener<T> {
void onWrite(int x, int y, int z, T value); 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(); private final AtomicInteger parallelismBaseline = new AtomicInteger();
public MultiBurst() { public MultiBurst() {
this("Iris"); this("Iris", Thread.MIN_PRIORITY, MultiBurstSupport::performanceProcessors);
} }
public MultiBurst(String name) { public MultiBurst(String name) {
@@ -140,8 +140,15 @@ public class MultiBurst extends MultiBurstSupport {
static final class HydrologyBurst extends MultiBurst { static final class HydrologyBurst extends MultiBurst {
HydrologyBurst() { HydrologyBurst() {
super("Iris Hydrology", () -> Math.max(2, super("Iris Hydrology", HydrologyBurst::plannerParallelism);
IrisSettings.getThreadCount(IrisSettings.get().getConcurrency().getParallelism()))); }
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 @Override
@@ -17,8 +17,6 @@ import art.arcane.volmlib.nativelib.terrain.NativeBiome;
import art.arcane.volmlib.util.hunk.Hunk; import art.arcane.volmlib.util.hunk.Hunk;
import art.arcane.iris.generation.concurrent.MultiBurst; import art.arcane.iris.generation.concurrent.MultiBurst;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
public class ChunkContext { public class ChunkContext {
@@ -95,38 +93,11 @@ public class ChunkContext {
if (hydrologyRuntime != null && (resolvedPlan.height || resolvedPlan.biome)) { if (hydrologyRuntime != null && (resolvedPlan.height || resolvedPlan.biome)) {
hydrologyRuntime.prepareChunkColumns(x, z); hydrologyRuntime.prepareChunkColumns(x, z);
} }
List<Runnable> fillTasks = new ArrayList<>(6); if (shouldPrefillAsync(resolvedPlan, complex.isPlatformMainThread())) {
if (resolvedPlan.height) { prefillAsync(resolvedPlan);
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();
}
} else { } else {
List<CompletableFuture<Void>> futures = new ArrayList<>(fillTasks.size()); prefillSurface(resolvedPlan);
for (Runnable fillTask : fillTasks) { prefillPalettes(resolvedPlan);
futures.add(CompletableFuture.runAsync(fillTask, MultiBurst.burst));
}
for (CompletableFuture<Void> future : futures) {
future.join();
}
} }
if (capturePrefillMetric) { if (capturePrefillMetric) {
metrics.getContextPrefill().put((System.nanoTime() - totalStartNanos) / 1_000_000D); metrics.getContextPrefill().put((System.nanoTime() - totalStartNanos) / 1_000_000D);
@@ -134,14 +105,46 @@ public class ChunkContext {
} }
} }
static boolean shouldPrefillAsync(PrefillPlan prefillPlan, int fillTaskCount) { static boolean shouldPrefillAsync(PrefillPlan prefillPlan, boolean platformMainThread) {
if (prefillPlan == PrefillPlan.NATURAL_TERRAIN || fillTaskCount <= 1 || !IrisPlatforms.isBound()) { if (!platformMainThread || prefillPlan == PrefillPlan.NATURAL_TERRAIN || prefillPlan == PrefillPlan.NONE
|| !IrisPlatforms.isBound()) {
return false; return false;
} }
return !MultiBurst.burst.ownsCurrentThread(); return !MultiBurst.burst.ownsCurrentThread();
} }
private void prefillSurface(PrefillPlan prefillPlan) {
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) { public void setTerrainBiomeOutput(Hunk<NativeBiome> terrainBiomes) {
this.terrainBiomes = terrainBiomes; this.terrainBiomes = terrainBiomes;
} }
@@ -295,17 +298,4 @@ public class ChunkContext {
public record TransitionTerrainColumn(Terrain3DColumn solids, int fluidHeight) { 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();
}
}
} }
@@ -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;
}
}
@@ -39,6 +39,7 @@ import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.collection.KSet; import art.arcane.volmlib.util.collection.KSet;
import art.arcane.volmlib.util.math.BlockPosition; import art.arcane.volmlib.util.math.BlockPosition;
import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.math.RNG;
import java.util.Arrays;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.Set; import java.util.Set;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
@@ -57,6 +58,7 @@ import lombok.experimental.Accessors;
public class IrisDepositGenerator { public class IrisDepositGenerator {
private static final int FOOTPRINT_MAXIMUM_SIZE = 64; private static final int FOOTPRINT_MAXIMUM_SIZE = 64;
private static final ThreadLocal<FootprintCells> FOOTPRINT_CELLS = ThreadLocal.withInitial(FootprintCells::new); 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_OFFSET_BASIS = 0xcbf29ce484222325L;
private static final long FNV_PRIME = 0x100000001b3L; 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. * only for clumps it keeps. Returns null without drawing when the shape has no footprint path.
*/ */
public ClumpFootprint sampleClumpFootprint(RNG rng, IrisData rdata) { public ClumpFootprint sampleClumpFootprint(RNG rng, IrisData rdata) {
if (shape != IrisDepositShape.VANILLA_ELLIPSOID || minSize > FOOTPRINT_MAXIMUM_SIZE if (!usesClumpFootprints(rdata)) {
|| maxSize > FOOTPRINT_MAXIMUM_SIZE) {
return null;
}
int paletteSize = getBlockData(rdata).size();
if (paletteSize == 0) {
return null; return null;
} }
KList<NativeBlockState> resolvedPalette = getBlockData(rdata);
int paletteSize = resolvedPalette.size();
int size = rng.i(minSize, maxSize + 1); int size = rng.i(minSize, maxSize + 1);
if (size <= 0) { if (size <= 0) {
return ClumpFootprint.EMPTY; return ClumpFootprint.EMPTY;
} }
FootprintCells cells = FOOTPRINT_CELLS.get(); FootprintCells cells = FOOTPRINT_CELLS.get();
cells.reset(); cells.reset();
vanillaEllipsoidCells(rng, size, cells); if (paletteSize == 1) {
if (cells.count == 0) { emitEllipsoidRows(rng, size, cells);
return ClumpFootprint.EMPTY; 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++) { return new ClumpFootprint(cells.extentX * 2 + 1, cells.minY, cells, cells.stamp, resolvedPalette);
rng.i(0, paletteSize); }
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. * test is bit-identical to evaluating {@code nx * nx + ny * ny + nz * nz} in place.
*/ */
static void vanillaEllipsoidCells(RNG rng, int size, CellSink sink) { 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 angle = rng.nextFloat() * (float) Math.PI;
float reach = size / 8F; float reach = size / 8F;
double startX = Math.sin(angle) * reach; double sine = Math.sin(angle);
double endX = -Math.sin(angle) * reach; double cosine = Math.cos(angle);
double startZ = Math.cos(angle) * reach; double startX = sine * reach;
double endZ = -Math.cos(angle) * reach; double endX = -sine * reach;
double startZ = cosine * reach;
double endZ = -cosine * reach;
double startY = rng.nextInt(3) - 2; double startY = rng.nextInt(3) - 2;
double endY = 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++) { for (int i = 0; i < size; i++) {
float progress = (float) i / size; float progress = (float) i / size;
@@ -328,7 +426,8 @@ public class IrisDepositGenerator {
nodes[i * 4] = startX + (endX - startX) * progress; nodes[i * 4] = startX + (endX - startX) * progress;
nodes[i * 4 + 1] = startY + (endY - startY) * progress; nodes[i * 4 + 1] = startY + (endY - startY) * progress;
nodes[i * 4 + 2] = startZ + (endZ - startZ) * 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++) { for (int i = 0; i < size - 1; i++) {
@@ -353,59 +452,20 @@ public class IrisDepositGenerator {
} }
} }
} }
return nodes;
}
double[] squaredY = new double[16]; private static double[][] nodeSines() {
double[] squaredZ = new double[16]; double[][] sines = new double[FOOTPRINT_MAXIMUM_SIZE + 1][];
for (int i = 0; i < size; i++) { for (int size = 1; size <= FOOTPRINT_MAXIMUM_SIZE; size++) {
double radius = nodes[i * 4 + 3]; double[] values = new double[size];
if (radius < 0D) { for (int i = 0; i < size; i++) {
continue; float progress = (float) i / size;
} values[i] = Math.sin(Math.PI * progress);
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);
}
}
}
} }
sines[size] = values;
} }
return sines;
} }
IrisObject generateVanillaScattered(RNG rng, IrisData rdata, int size) { IrisObject generateVanillaScattered(RNG rng, IrisData rdata, int size) {
@@ -630,8 +690,134 @@ public class IrisDepositGenerator {
void add(int x, int y, int z); void add(int x, int y, int z);
} }
public record ClumpFootprint(int width, int minY, boolean empty) { public static final class ClumpFootprint {
static final ClumpFootprint EMPTY = new ClumpFootprint(1, 0, true); 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 { private static final class FootprintCells implements CellSink {
@@ -639,10 +825,16 @@ public class IrisDepositGenerator {
private static final int AXIS = 64; private static final int AXIS = 64;
private final long[] occupied = new long[AXIS * AXIS * AXIS / 64]; private final long[] occupied = new long[AXIS * AXIS * AXIS / 64];
private final int[] touched = new int[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 touchedCount;
private int count; private int count;
private int extentX; private int extentX;
private int minY; private int minY;
private boolean uniform;
private long stamp;
private void reset() { private void reset() {
for (int index = 0; index < touchedCount; index++) { for (int index = 0; index < touchedCount; index++) {
@@ -652,6 +844,8 @@ public class IrisDepositGenerator {
count = 0; count = 0;
extentX = 0; extentX = 0;
minY = Integer.MAX_VALUE; minY = Integer.MAX_VALUE;
uniform = false;
stamp++;
} }
@Override @Override
@@ -667,9 +861,72 @@ public class IrisDepositGenerator {
touched[touchedCount++] = word; touched[touchedCount++] = word;
} }
occupied[word] = current | bit; 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)); extentX = Math.max(extentX, Math.abs(x));
minY = Math.min(minY, y); 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; 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.loading.IrisData;
import art.arcane.iris.pack.value.IrisRange; import art.arcane.iris.pack.value.IrisRange;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState; import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
@@ -79,6 +80,14 @@ public final class IrisOreBands {
return minY <= maxY; return minY <= maxY;
} }
public int lowestY() {
return minY;
}
public int highestY() {
return maxY;
}
public boolean contains(int y) { public boolean contains(int y) {
return y >= minY && y <= maxY; 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. * 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]; IrisOreGenerator[] candidates = byY == null ? generators : byY[y - minY];
for (IrisOreGenerator candidate : candidates) { 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) { if (ore != null) {
return ore; return ore;
} }
@@ -65,6 +65,10 @@ public class IrisOreGenerator {
} }
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) {
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)) { if (threshold <= 0 || !range.contains(y)) {
return null; return null;
} }
@@ -73,7 +77,7 @@ public class IrisOreGenerator {
return null; return null;
} }
if (chance(rng, data).sample(x, y, z) > threshold) { if (chance(rng, data, engine).sample(x, y, z) > threshold) {
return null; return null;
} }
@@ -81,8 +85,11 @@ public class IrisOreGenerator {
} }
private OreChance chance(RNG rng, IrisData data) { 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(); long seed = rng.getSeed();
Engine engine = data == null ? null : data.getEngine();
CachedChance recent = recentChance; CachedChance recent = recentChance;
if (recent != null && recent.key.seed == seed && recent.key.data == data && recent.key.engine == engine) { if (recent != null && recent.key.seed == seed && recent.key.data == data && recent.key.engine == engine) {
return recent.chance; return recent.chance;
@@ -242,11 +242,11 @@ public class IrisProceduralTree implements IrisProceduralPlacement {
private IrisTreeRootStyle rootStyle = IrisTreeRootStyle.BUTTRESS; private IrisTreeRootStyle rootStyle = IrisTreeRootStyle.BUTTRESS;
@MinNumber(0) @MinNumber(0)
@Description("Override the root depth/reach in blocks. 0 means auto (scales with tree height).") @Description("Root depth in blocks, also used for ground-contact extension up to 16 blocks. 0 scales with tree height.")
private int rootDepth = 0; private int rootDepth = 0;
@MinNumber(0) @MinNumber(0)
@Description("Override the root flare radius in blocks. 0 means auto.") @Description("Horizontal reach of buttress and stilt roots. 0 uses a compact radius based on trunk width. Taproots ignore this field.")
private double rootFlare = 0; private double rootFlare = 0;
@MinNumber(1) @MinNumber(1)
@@ -40,6 +40,12 @@ public final class ProceduralTreeGenerator {
long baseSeed = rng.getSeed(); long baseSeed = rng.getSeed();
List<TreeTrunkBuilder.Limb> limbs = TreeTrunkBuilder.build(canvas, tree, height); List<TreeTrunkBuilder.Limb> limbs = TreeTrunkBuilder.build(canvas, tree, height);
Set<TreeBlockCanvas.Vec> supportPositions = new HashSet<>();
for (TreeBlockCanvas.Vec position : canvas.getTrunk()) {
if (position.y() == 0) {
supportPositions.add(position);
}
}
boolean wantEndpoints = tree.getDecorators() != null && !tree.getDecorators().isEmpty(); boolean wantEndpoints = tree.getDecorators() != null && !tree.getDecorators().isEmpty();
List<int[]> branchEndpoints = wantEndpoints ? new ArrayList<>() : null; List<int[]> branchEndpoints = wantEndpoints ? new ArrayList<>() : null;
@@ -55,7 +61,13 @@ public final class ProceduralTreeGenerator {
} }
if (tree.isRoots()) { if (tree.isRoots()) {
Set<TreeBlockCanvas.Vec> existingWood = new HashSet<>(canvas.getTrunk());
TreeRootBuilder.build(canvas, tree, height, baseSeed); TreeRootBuilder.build(canvas, tree, height, baseSeed);
for (TreeBlockCanvas.Vec position : canvas.getTrunk()) {
if (position.y() <= 0 && !existingWood.contains(position)) {
supportPositions.add(position);
}
}
} }
if (tree.isPlausible()) { if (tree.isPlausible()) {
@@ -82,10 +94,14 @@ public final class ProceduralTreeGenerator {
TreePlausibility.apply(resolved, trunkPositions, leafPositions, tree); TreePlausibility.apply(resolved, trunkPositions, leafPositions, tree);
return assemble(resolved); int rootReach = tree.isRoots() ? tree.getRootDepth() > 0 ? tree.getRootDepth()
: Math.max(2, (int) Math.round(height * 0.18)) : 0;
return assemble(resolved, supportPositions, leafPositions, rootReach);
} }
private static IrisObject assemble(Map<TreeBlockCanvas.Vec, NativeBlockState> resolved) { private static IrisObject assemble(Map<TreeBlockCanvas.Vec, NativeBlockState> resolved,
Set<TreeBlockCanvas.Vec> supportPositions,
Set<TreeBlockCanvas.Vec> leafPositions, int rootReach) {
if (resolved.isEmpty()) { if (resolved.isEmpty()) {
return null; return null;
} }
@@ -105,21 +121,26 @@ public final class ProceduralTreeGenerator {
maxZ = Math.max(maxZ, v.z()); maxZ = Math.max(maxZ, v.z());
} }
int w = maxX - minX + 1; int w = 2 * Math.max(Math.abs(minX), Math.abs(maxX)) + 1;
int h = maxY - minY + 1; int h = maxY - minY + 1;
int d = maxZ - minZ + 1; int d = 2 * Math.max(Math.abs(minZ), Math.abs(maxZ)) + 1;
int cx = w / 2;
int cy = h / 2; int cy = h / 2;
int cz = d / 2;
IrisObject object = new IrisObject(w, h, d); IrisObject object = new IrisObject(w, h, d);
List<TreeGrounding.Root> roots = new ArrayList<>(supportPositions.size());
Set<TreeBlockCanvas.Vec> canopy = new HashSet<>(leafPositions.size());
for (Map.Entry<TreeBlockCanvas.Vec, NativeBlockState> entry : resolved.entrySet()) { for (Map.Entry<TreeBlockCanvas.Vec, NativeBlockState> entry : resolved.entrySet()) {
TreeBlockCanvas.Vec v = entry.getKey(); TreeBlockCanvas.Vec v = entry.getKey();
int nx = v.x() - minX - cx;
int ny = v.y() - cy + 1; int ny = v.y() - cy + 1;
int nz = v.z() - minZ - cz; object.getBlocks().put(new IrisBlockVector(v.x(), ny, v.z()), entry.getValue());
object.getBlocks().put(new IrisBlockVector(nx, ny, nz), entry.getValue()); if (supportPositions.contains(v)) {
roots.add(new TreeGrounding.Root(v.x(), ny, v.z(), entry.getValue()));
}
if (leafPositions.contains(v)) {
canopy.add(new TreeBlockCanvas.Vec(v.x(), ny, v.z()));
}
} }
object.setTreeGrounding(TreeGrounding.create(roots, rootReach, canopy));
return object; return object;
} }
@@ -335,7 +335,9 @@ public final class TreeCanopyBuilder {
int x = (int) Math.round(ox + dx * t); int x = (int) Math.round(ox + dx * t);
int y = (int) Math.round(oy + dy * t - sagBlocks * t * t); int y = (int) Math.round(oy + dy * t - sagBlocks * t * t);
int z = (int) Math.round(oz + dz * t); int z = (int) Math.round(oz + dz * t);
if (!canvas.has(x, y, z)) { TreeBlockCanvas.Cell existing = canvas.get(x, y, z);
if (existing == null || existing.role() == TreeBlockCanvas.Role.LEAF
|| existing.role() == TreeBlockCanvas.Role.SECONDARY_LEAF) {
canvas.setTrunk(x, y, z, TreeBlockCanvas.Role.TRUNK, axis); canvas.setTrunk(x, y, z, TreeBlockCanvas.Role.TRUNK, axis);
} }
tip = new int[]{x, y, z}; tip = new int[]{x, y, z};
@@ -0,0 +1,122 @@
package art.arcane.iris.generation.decoration.tree;
import art.arcane.iris.generation.geometry.IrisBlockVector;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.structure.object.IObjectPlacer;
import art.arcane.iris.structure.object.IrisObjectPlacement;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.volmlib.nativelib.terrain.structure.NativeStructureVolume;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.math.BlockPosition;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
public record TreeGrounding(List<Root> roots, int maxExtension, Set<TreeBlockCanvas.Vec> canopy) {
public TreeGrounding {
roots = List.copyOf(roots);
maxExtension = Math.max(0, Math.min(16, maxExtension));
canopy = Set.copyOf(canopy);
}
public static TreeGrounding create(List<Root> roots, int maxExtension, Set<TreeBlockCanvas.Vec> canopy) {
Map<Long, Root> columns = new HashMap<>();
for (Root root : roots) {
long key = ((long) root.x() << 32) | (root.z() & 0xffffffffL);
Root previous = columns.get(key);
if (previous == null || root.y() < previous.y()) {
columns.put(key, root);
}
}
List<Root> lowest = new ArrayList<>(columns.values());
lowest.sort(Comparator.comparingInt(Root::x).thenComparingInt(Root::z));
return new TreeGrounding(lowest, maxExtension, canopy);
}
public boolean isCanopy(int x, int y, int z) {
return canopy.contains(new TreeBlockCanvas.Vec(x, y, z));
}
public KList<IrisBlockVector> supportOffsets() {
KList<IrisBlockVector> offsets = new KList<>();
for (Root root : roots) {
offsets.add(new IrisBlockVector(root.x(), root.y(), root.z()));
}
return offsets;
}
public Plan plan(IObjectPlacer placer, Placement placement) {
List<Extension> extensions = new ArrayList<>();
IrisObjectPlacement config = placement.config();
BlockPosition origin = placement.origin();
BlockPosition spin = placement.spin();
int solidDepth = Math.max(1, config.getSurfaceSupportDepth());
for (Root root : roots) {
IrisBlockVector offset = config.getRotation().rotate(
new IrisBlockVector(root.x(), root.y(), root.z()), spin.getX(), spin.getY(), spin.getZ());
offset = config.getTranslate().translate(offset, config.getRotation(), spin.getX(), spin.getY(), spin.getZ());
int x = origin.getX() + (int) Math.round(offset.getX());
int y = origin.getY() + (int) Math.round(offset.getY());
int z = origin.getZ() + (int) Math.round(offset.getZ());
int surface = placer.getHighest(x, z, placement.data(), true);
int contact = Math.min(surface, y - 1);
if (y - contact - 1 > maxExtension) {
return Plan.UNSUPPORTED;
}
for (int below = 0; below < solidDepth; below++) {
if (placer.isCarved(x, contact - below, z) || !placer.isSurfaceSolid(x, contact - below, z)) {
return Plan.UNSUPPORTED;
}
}
if (contact < y - 1) {
NativeBlockState state = config.getRotation().rotate(root.state(), spin.getX(), spin.getY(), spin.getZ());
if (state == null) {
return Plan.UNSUPPORTED;
}
Extension extension = new Extension(x, y - 1, contact, z, state);
if (intersectsStructure(placer, extension)) {
return Plan.UNSUPPORTED;
}
extensions.add(extension);
}
}
return new Plan(!roots.isEmpty(), List.copyOf(extensions));
}
private static boolean intersectsStructure(IObjectPlacer placer, Extension extension) {
Engine engine = placer.getEngine();
if (engine == null) {
return false;
}
KList<NativeStructureVolume> volumes = engine.getNativeStructureVolumes(
extension.x(), extension.z(), extension.x(), extension.z());
if (volumes == null) {
return false;
}
for (NativeStructureVolume volume : volumes) {
if (volume.intersects(extension.x(), extension.bottomY() + engine.getMinHeight(), extension.z(),
extension.x(), extension.topY() + engine.getMinHeight(), extension.z())) {
return true;
}
}
return false;
}
public record Root(int x, int y, int z, NativeBlockState state) {
}
public record Placement(IrisData data, IrisObjectPlacement config, BlockPosition origin, BlockPosition spin) {
}
public record Extension(int x, int topY, int bottomY, int z, NativeBlockState state) {
}
public record Plan(boolean supported, List<Extension> extensions) {
private static final Plan UNSUPPORTED = new Plan(false, List.of());
}
}
@@ -28,120 +28,152 @@ public final class TreeRootBuilder {
} }
public static void build(TreeBlockCanvas canvas, IrisProceduralTree tree, int height, long baseSeed) { public static void build(TreeBlockCanvas canvas, IrisProceduralTree tree, int height, long baseSeed) {
List<int[]> cells = new ArrayList<>(); List<TreeBlockCanvas.Vec> cells = new ArrayList<>();
for (TreeBlockCanvas.Vec v : canvas.getTrunk()) { for (TreeBlockCanvas.Vec v : canvas.getTrunk()) {
if (v.y() == 0) { if (v.y() == 0) {
cells.add(new int[]{v.x(), v.z()}); cells.add(v);
} }
} }
if (cells.isEmpty()) { if (cells.isEmpty()) {
return; return;
} }
cells.sort(TreeBlockCanvas.Vec::compareTo);
int depth = tree.getRootDepth() > 0 ? tree.getRootDepth() : autoDepth(height); int depth = tree.getRootDepth() > 0 ? tree.getRootDepth() : autoDepth(height);
RNG rng = new RNG(baseSeed ^ 0x5009L); RNG rng = new RNG(baseSeed ^ 0x5009L);
double cx = 0; double cx = 0;
double cz = 0; double cz = 0;
for (int[] c : cells) { for (TreeBlockCanvas.Vec cell : cells) {
cx += c[0]; cx += cell.x();
cz += c[1]; cz += cell.z();
} }
cx /= cells.size(); cx /= cells.size();
cz /= cells.size(); cz /= cells.size();
double baseRadius = 1.0; double baseRadius = 1.0;
for (int[] c : cells) { for (TreeBlockCanvas.Vec cell : cells) {
baseRadius = Math.max(baseRadius, Math.hypot(c[0] - cx, c[1] - cz) + 0.5); baseRadius = Math.max(baseRadius, Math.hypot(cell.x() - cx, cell.z() - cz) + 0.5);
} }
double flare = tree.getRootFlare() > 0 ? tree.getRootFlare() : baseRadius + Math.max(2.0, depth * 0.6); double flare = tree.getRootFlare() > 0 ? tree.getRootFlare() : baseRadius + Math.max(0.75, baseRadius * 0.35);
RootShape shape = new RootShape(cells, cx, cz, baseRadius, depth, flare);
IrisTreeRootStyle style = tree.getRootStyle(); IrisTreeRootStyle style = tree.getRootStyle();
switch (style) { switch (style) {
case TAPROOT -> taproot(canvas, cells, cx, cz, baseRadius, depth); case TAPROOT -> taproot(canvas, shape);
case BUTTRESS -> { case BUTTRESS -> {
taproot(canvas, cells, cx, cz, baseRadius, depth); taproot(canvas, shape);
buttress(canvas, cells, cx, cz, baseRadius, depth, flare, rng); buttress(canvas, shape, rng);
} }
case STILT -> { case STILT -> {
taproot(canvas, cells, cx, cz, baseRadius, Math.min(depth, 3)); stilts(canvas, shape, height, rng);
stilts(canvas, cx, cz, baseRadius, flare, height, cells.size(), rng); taproot(canvas, new RootShape(cells, cx, cz, baseRadius, Math.min(depth, 3), flare));
} }
} }
} }
private static void taproot(TreeBlockCanvas canvas, List<int[]> cells, double cx, double cz, double baseRadius, int depth) { private static void taproot(TreeBlockCanvas canvas, RootShape shape) {
int icx = (int) Math.round(cx); TreeBlockCanvas.Vec center = nearestCell(shape.cells(), shape.centerX(), shape.centerZ());
int icz = (int) Math.round(cz); for (int k = 1; k <= shape.depth(); k++) {
for (int k = 1; k <= depth; k++) { double fraction = k / (double) shape.depth();
double frac = k / (double) depth; double keepRadius = shape.radius() * (1.0 - fraction);
double keepR = baseRadius * (1.0 - 0.6 * frac); for (TreeBlockCanvas.Vec cell : shape.cells()) {
for (int[] c : cells) { if (Math.hypot(cell.x() - shape.centerX(), cell.z() - shape.centerZ()) <= keepRadius) {
if (Math.hypot(c[0] - cx, c[1] - cz) <= keepR + 1e-6) { placeRoot(canvas, cell.x(), -k, cell.z(), TreeBlockCanvas.Axis.Y);
placeRoot(canvas, c[0], -k, c[1], TreeBlockCanvas.Axis.Y);
} }
} }
placeRoot(canvas, icx, -k, icz, TreeBlockCanvas.Axis.Y); placeRoot(canvas, center.x(), -k, center.z(), TreeBlockCanvas.Axis.Y);
} }
} }
private static void buttress(TreeBlockCanvas canvas, List<int[]> cells, double cx, double cz, double baseRadius, private static void buttress(TreeBlockCanvas canvas, RootShape shape, RNG rng) {
int depth, double flare, RNG rng) { int legs = Math.max(3, Math.min(8, shape.cells().size() + 2));
int nLegs = Math.max(4, Math.min(12, cells.size() + 2)); for (int leg = 0; leg < legs; leg++) {
for (int li = 0; li < nLegs; li++) { double angle = 2.0 * Math.PI * leg / legs + rng.d(-0.25, 0.25);
double ang = 2.0 * Math.PI * li / nLegs + rng.d(-0.25, 0.25); double reach = shape.flare() * rng.d(0.8, 1.0);
double ca = Math.cos(ang); int x = (int) Math.round(shape.centerX() + reach * Math.cos(angle));
double sa = Math.sin(ang); int z = (int) Math.round(shape.centerZ() + reach * Math.sin(angle));
int prevX = (int) Math.round(cx + baseRadius * ca); int depth = legDepth(shape.depth(), leg, legs);
int prevZ = (int) Math.round(cz + baseRadius * sa); TreeBlockCanvas.Vec start = nearestCell(shape.cells(), x, z);
for (int k = 1; k <= depth; k++) { rasterizeRoot(canvas, start, new TreeBlockCanvas.Vec(x, -depth, z));
double frac = k / (double) depth; }
double r = baseRadius + (flare - baseRadius) * frac; }
int x = (int) Math.round(cx + r * ca);
int z = (int) Math.round(cz + r * sa); private static void stilts(TreeBlockCanvas canvas, RootShape shape, int height, RNG rng) {
placeRoot(canvas, x, -k, z, TreeBlockCanvas.Axis.Y); int stiltTop = Math.min(height - 1, Math.max(2, Math.min(7, (int) Math.round(0.25 * height))));
if (x != prevX || z != prevZ) { List<TreeBlockCanvas.Vec> attachments = new ArrayList<>();
placeRoot(canvas, prevX, -k, prevZ, TreeBlockCanvas.Axis.Y); for (TreeBlockCanvas.Vec cell : canvas.getTrunk()) {
} if (cell.y() == stiltTop) {
prevX = x; attachments.add(cell);
prevZ = z;
} }
} }
} if (attachments.isEmpty()) {
attachments.addAll(shape.cells());
private static void stilts(TreeBlockCanvas canvas, double cx, double cz, double baseRadius, double flare, }
int height, int cellCount, RNG rng) { attachments.sort(TreeBlockCanvas.Vec::compareTo);
int stiltTop = Math.max(2, Math.min(7, (int) Math.round(0.25 * height))); int props = Math.max(3, Math.min(8, shape.cells().size() + 2));
int nProps = Math.max(4, Math.min(10, cellCount + 2)); for (int prop = 0; prop < props; prop++) {
for (int p = 0; p < nProps; p++) { double angle = 2.0 * Math.PI * prop / props + rng.d(-0.25, 0.25);
double ang = 2.0 * Math.PI * p / nProps + rng.d(-0.25, 0.25); double reach = shape.flare() * rng.d(0.8, 1.0);
double ca = Math.cos(ang); int x = (int) Math.round(shape.centerX() + reach * Math.cos(angle));
double sa = Math.sin(ang); int z = (int) Math.round(shape.centerZ() + reach * Math.sin(angle));
int sx = (int) Math.round(cx + baseRadius * ca); int depth = legDepth(Math.min(shape.depth(), 3), prop, props);
int sz = (int) Math.round(cz + baseRadius * sa); TreeBlockCanvas.Vec start = nearestCell(attachments, x, z);
int fx = (int) Math.round(cx + flare * ca); rasterizeRoot(canvas, start, new TreeBlockCanvas.Vec(x, -depth, z));
int fz = (int) Math.round(cz + flare * sa);
rasterizeRoot(canvas, sx, stiltTop, sz, fx, -2, fz);
} }
} }
private static void rasterizeRoot(TreeBlockCanvas canvas, int ox, int oy, int oz, int ex, int ey, int ez) { private static int legDepth(int depth, int leg, int count) {
int dx = ex - ox; return Math.max(1, (int) Math.round(depth * (0.55 + 0.45 * leg / Math.max(1, count - 1))));
int dy = ey - oy; }
int dz = ez - oz;
private static TreeBlockCanvas.Vec nearestCell(List<TreeBlockCanvas.Vec> cells, double x, double z) {
TreeBlockCanvas.Vec nearest = cells.getFirst();
double distance = Double.POSITIVE_INFINITY;
for (TreeBlockCanvas.Vec cell : cells) {
double dx = cell.x() - x;
double dz = cell.z() - z;
double candidateDistance = dx * dx + dz * dz;
if (candidateDistance < distance) {
nearest = cell;
distance = candidateDistance;
}
}
return nearest;
}
private static void rasterizeRoot(TreeBlockCanvas canvas, TreeBlockCanvas.Vec start, TreeBlockCanvas.Vec end) {
int dx = end.x() - start.x();
int dy = end.y() - start.y();
int dz = end.z() - start.z();
int steps = Math.max(Math.max(Math.abs(dx), Math.abs(dy)), Math.max(Math.abs(dz), 1)); int steps = Math.max(Math.max(Math.abs(dx), Math.abs(dy)), Math.max(Math.abs(dz), 1));
TreeBlockCanvas.Axis axis = TreeFunctions.logAxis(dx, dy, dz); TreeBlockCanvas.Axis axis = TreeFunctions.logAxis(dx, dy, dz);
for (int i = 0; i <= steps; i++) { int x = start.x();
int y = start.y();
int z = start.z();
for (int i = 1; i <= steps; i++) {
double t = i / (double) steps; double t = i / (double) steps;
int x = (int) Math.round(ox + dx * t); int nextX = (int) Math.round(start.x() + dx * t);
int y = (int) Math.round(oy + dy * t); int nextY = (int) Math.round(start.y() + dy * t);
int z = (int) Math.round(oz + dz * t); int nextZ = (int) Math.round(start.z() + dz * t);
placeRoot(canvas, x, y, z, axis); while (y != nextY) {
y += Integer.signum(nextY - y);
placeRoot(canvas, x, y, z, axis);
}
while (x != nextX) {
x += Integer.signum(nextX - x);
placeRoot(canvas, x, y, z, axis);
}
while (z != nextZ) {
z += Integer.signum(nextZ - z);
placeRoot(canvas, x, y, z, axis);
}
} }
} }
private static void placeRoot(TreeBlockCanvas canvas, int x, int y, int z, TreeBlockCanvas.Axis axis) { private static void placeRoot(TreeBlockCanvas canvas, int x, int y, int z, TreeBlockCanvas.Axis axis) {
if (!canvas.has(x, y, z)) { TreeBlockCanvas.Cell cell = canvas.get(x, y, z);
if (cell == null || cell.role() != TreeBlockCanvas.Role.TRUNK && cell.role() != TreeBlockCanvas.Role.SECONDARY_TRUNK) {
canvas.setTrunk(x, y, z, TreeBlockCanvas.Role.TRUNK, axis); canvas.setTrunk(x, y, z, TreeBlockCanvas.Role.TRUNK, axis);
} }
} }
@@ -149,4 +181,8 @@ public final class TreeRootBuilder {
private static int autoDepth(int height) { private static int autoDepth(int height) {
return Math.max(2, Math.min(16, (int) Math.round(0.18 * height))); return Math.max(2, Math.min(16, (int) Math.round(0.18 * height)));
} }
private record RootShape(List<TreeBlockCanvas.Vec> cells, double centerX, double centerZ,
double radius, int depth, double flare) {
}
} }
@@ -20,14 +20,29 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.concurrent.Callable; import java.util.concurrent.Callable;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.ForkJoinWorkerThread; import java.util.concurrent.ForkJoinWorkerThread;
final class HydrologyCrossTileResolver { final class HydrologyCrossTileResolver {
private static final int FALLBACK_TRIAL_PARALLELISM = 16; private static final int FALLBACK_TRIAL_PARALLELISM = 16;
private static final int MINIMUM_FALLBACK_TRIAL_BATCH = 2;
private final HydrologyPlanner planner; private final HydrologyPlanner planner;
private final int trialLimit;
HydrologyCrossTileResolver(HydrologyPlanner planner) { HydrologyCrossTileResolver(HydrologyPlanner planner, int trialLimit) {
if (trialLimit < 1) {
throw new IllegalArgumentException("Hydrology fallback trial limit must be positive.");
}
this.planner = planner; this.planner = planner;
this.trialLimit = trialLimit;
}
static int fallbackTrialBatch(int parallelism, int idleWorkers, int trialLimit) {
if (parallelism <= 2) {
return 1;
}
int workers = Math.clamp(idleWorkers + 1L, MINIMUM_FALLBACK_TRIAL_BATCH, parallelism);
return Math.max(1, Math.min(workers, Math.min(trialLimit, FALLBACK_TRIAL_PARALLELISM)));
} }
HydrologyOwnerDraft compileOwnerDraft( HydrologyOwnerDraft compileOwnerDraft(
@@ -285,12 +300,18 @@ final class HydrologyCrossTileResolver {
List<OutletCandidate> outlets, List<OutletCandidate> outlets,
int start int start
) { ) {
int parallelism = HydrologyPlanningAdmission.effectiveParallelism( int parallelism;
Thread.currentThread() instanceof ForkJoinWorkerThread worker int idleWorkers;
? worker.getPool().getParallelism() if (Thread.currentThread() instanceof ForkJoinWorkerThread worker) {
: IrisPlatforms.isBound() ? MultiBurst.hydrology.parallelism() : 1); ForkJoinPool pool = worker.getPool();
int batchSize = parallelism <= 2 ? 1 : Math.max(1, Math.min(HydrologyPlanningAdmission.maximumTrials(), parallelism = HydrologyPlanningAdmission.effectiveParallelism(pool.getParallelism());
Math.min(FALLBACK_TRIAL_PARALLELISM, parallelism))); idleWorkers = pool.getParallelism() - pool.getActiveThreadCount();
} else {
parallelism = HydrologyPlanningAdmission.effectiveParallelism(
IrisPlatforms.isBound() ? MultiBurst.hydrology.parallelism() : 1);
idleWorkers = 0;
}
int batchSize = fallbackTrialBatch(parallelism, idleWorkers, trialLimit);
int end = Math.min(outlets.size(), start + batchSize); int end = Math.min(outlets.size(), start + batchSize);
ArrayList<Callable<FallbackTrial>> tasks = new ArrayList<>(end - start); ArrayList<Callable<FallbackTrial>> tasks = new ArrayList<>(end - start);
HydrologyPlanner.PlanningSamples ownerSamples = planner.planningSamples.get(); HydrologyPlanner.PlanningSamples ownerSamples = planner.planningSamples.get();
@@ -197,7 +197,7 @@ public final class HydrologyPlanner {
geometrySampler, geometrySampler,
settings.seaLevel() settings.seaLevel()
); );
this.crossTile = new HydrologyCrossTileResolver(this); this.crossTile = new HydrologyCrossTileResolver(this, HydrologyPlanningAdmission.maximumTrials());
this.outletPlanner = new HydrologyOutletPlanner(this); this.outletPlanner = new HydrologyOutletPlanner(this);
this.sourcePlanner = new HydrologySourcePlanner(this); this.sourcePlanner = new HydrologySourcePlanner(this);
this.routeGeometry = new HydrologyRouteGeometry(this); this.routeGeometry = new HydrologyRouteGeometry(this);
@@ -10,6 +10,8 @@ import java.util.function.BooleanSupplier;
final class HydrologyPlanningAdmission { final class HydrologyPlanningAdmission {
private static final long BYTES_PER_ROOT = 2L * 1024L * 1024L * 1024L; private static final long BYTES_PER_ROOT = 2L * 1024L * 1024L * 1024L;
private static final long BYTES_PER_TRIAL = 128L * 1024L * 1024L;
private static final int MAXIMUM_TRIAL_LIMIT = 16;
private static final int MAXIMUM_ROOTS = rootLimit( private static final int MAXIMUM_ROOTS = rootLimit(
Runtime.getRuntime().maxMemory(), Runtime.getRuntime().availableProcessors()); Runtime.getRuntime().maxMemory(), Runtime.getRuntime().availableProcessors());
private static final int MAXIMUM_TRIALS = trialLimit(Runtime.getRuntime().maxMemory(), MAXIMUM_ROOTS); private static final int MAXIMUM_TRIALS = trialLimit(Runtime.getRuntime().maxMemory(), MAXIMUM_ROOTS);
@@ -48,7 +50,7 @@ final class HydrologyPlanningAdmission {
if (maximumRoots < 1) { if (maximumRoots < 1) {
throw new IllegalArgumentException("Maximum hydrology roots must be positive."); throw new IllegalArgumentException("Maximum hydrology roots must be positive.");
} }
return (int) Math.clamp(maximumHeap / maximumRoots / (1024L * 1024L * 1024L), 1L, 16L); return (int) Math.clamp(maximumHeap / maximumRoots / BYTES_PER_TRIAL, 1L, MAXIMUM_TRIAL_LIMIT);
} }
static int rootLimit(long maximumHeap, int processors) { static int rootLimit(long maximumHeap, int processors) {
@@ -1,6 +1,7 @@
package art.arcane.iris.generation.hydrology; package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.runtime.GenerationClosedException; import art.arcane.iris.generation.runtime.GenerationClosedException;
import art.arcane.iris.generation.runtime.GenerationCounter;
import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.generation.concurrent.MultiBurst; import art.arcane.iris.generation.concurrent.MultiBurst;
import art.arcane.iris.generation.stream.ProvisionalSampling; import art.arcane.iris.generation.stream.ProvisionalSampling;
@@ -43,7 +44,7 @@ public final class HydrologyTileCache implements AutoCloseable {
private static final byte AROUND_EMPTY = 1; private static final byte AROUND_EMPTY = 1;
private static final byte AROUND_PUBLISHED = 2; private static final byte AROUND_PUBLISHED = 2;
private static final int MAXIMUM_PREGENERATION_TILES = 4096; private static final int MAXIMUM_PREGENERATION_TILES = 4096;
private static final int PREGENERATION_HALO_BLOCKS = 512; private static final int PREGENERATION_HALO_BLOCKS = 256;
private static final HydrologyColumnSample[] NO_COLUMNS = new HydrologyColumnSample[CHUNK_COLUMN_COUNT]; private static final HydrologyColumnSample[] NO_COLUMNS = new HydrologyColumnSample[CHUNK_COLUMN_COUNT];
private static final Cache<SharedTileKey, HydrologyTile> SHARED_TILES = Caffeine.newBuilder() private static final Cache<SharedTileKey, HydrologyTile> SHARED_TILES = Caffeine.newBuilder()
.maximumWeight(HydrologyCacheBudget.sharedStudioBytes()) .maximumWeight(HydrologyCacheBudget.sharedStudioBytes())
@@ -79,7 +80,6 @@ public final class HydrologyTileCache implements AutoCloseable {
private volatile SharedCacheScope sharedCacheScope; private volatile SharedCacheScope sharedCacheScope;
private volatile PreparedHydrologyTileStore persistentStore; private volatile PreparedHydrologyTileStore persistentStore;
private volatile boolean neighbourPrefetchEnabled; private volatile boolean neighbourPrefetchEnabled;
private volatile Runnable terrainPreparation;
private PregenerationScope pregenerationScope; private PregenerationScope pregenerationScope;
private int demandBatches; private int demandBatches;
private int activePrefetches; private int activePrefetches;
@@ -267,7 +267,6 @@ public final class HydrologyTileCache implements AutoCloseable {
private List<HydrologyDiagnosticCandidate> planDiagnostics(HydrologyTileKey key, long epoch) { private List<HydrologyDiagnosticCandidate> planDiagnostics(HydrologyTileKey key, long epoch) {
HydrologyTile tile = planInline(key, epoch); HydrologyTile tile = planInline(key, epoch);
try (HydrologyPlanningAdmission.Permit ignored = HydrologyPlanningAdmission.acquireRoot(closed::get)) { try (HydrologyPlanningAdmission.Permit ignored = HydrologyPlanningAdmission.acquireRoot(closed::get)) {
prepareTerrain();
return List.copyOf(planner.diagnosticCandidates(tile)); return List.copyOf(planner.diagnosticCandidates(tile));
} }
} }
@@ -458,7 +457,6 @@ public final class HydrologyTileCache implements AutoCloseable {
/** Plans the tile under a root permit; a persisted plan is restored before any permit is taken. */ /** Plans the tile under a root permit; a persisted plan is restored before any permit is taken. */
private HydrologyTile planAdmitted(HydrologyTileKey key, long planningEpoch, private HydrologyTile planAdmitted(HydrologyTileKey key, long planningEpoch,
SharedTileKey sharedKey, PreparedHydrologyTileStore store) { SharedTileKey sharedKey, PreparedHydrologyTileStore store) {
prepareTerrain();
HydrologyTile planned = planner.plan(key); HydrologyTile planned = planner.plan(key);
synchronized (publicationLock) { synchronized (publicationLock) {
if (sharedKey != null && !closed.get() && planningEpoch == cacheEpoch.get()) { if (sharedKey != null && !closed.get() && planningEpoch == cacheEpoch.get()) {
@@ -499,6 +497,9 @@ public final class HydrologyTileCache implements AutoCloseable {
} }
public Optional<HydrologyColumnSample> columnAt(int blockX, int blockZ) { public Optional<HydrologyColumnSample> columnAt(int blockX, int blockZ) {
if (GenerationCounter.ENABLED) {
GenerationCounter.HYDROLOGY_LOOKUPS.increment();
}
return chunkColumns(blockX, blockZ).columnAt(blockX, blockZ); return chunkColumns(blockX, blockZ).columnAt(blockX, blockZ);
} }
@@ -507,6 +508,9 @@ public final class HydrologyTileCache implements AutoCloseable {
if (!waitsForbidden()) { if (!waitsForbidden()) {
return HydrologyColumnSnapshot.ready(columnAt(blockX, blockZ).orElse(null)); return HydrologyColumnSnapshot.ready(columnAt(blockX, blockZ).orElse(null));
} }
if (GenerationCounter.ENABLED) {
GenerationCounter.HYDROLOGY_LOOKUPS.increment();
}
int chunkX = Math.floorDiv(blockX, CHUNK_SIZE); int chunkX = Math.floorDiv(blockX, CHUNK_SIZE);
int chunkZ = Math.floorDiv(blockZ, CHUNK_SIZE); int chunkZ = Math.floorDiv(blockZ, CHUNK_SIZE);
ChunkColumns local = localChunks(cacheEpoch.get()).get(chunkX, chunkZ); ChunkColumns local = localChunks(cacheEpoch.get()).get(chunkX, chunkZ);
@@ -770,17 +774,6 @@ public final class HydrologyTileCache implements AutoCloseable {
clear(); clear();
} }
public void setTerrainPreparation(Runnable preparation) {
terrainPreparation = Objects.requireNonNull(preparation, "terrainPreparation");
}
private void prepareTerrain() {
Runnable preparation = terrainPreparation;
if (preparation != null) {
preparation.run();
}
}
private static void collectActiveLoad(List<CompletableFuture<?>> active, PendingLoad<?> load) { private static void collectActiveLoad(List<CompletableFuture<?>> active, PendingLoad<?> load) {
if (load.owner() == Thread.currentThread()) { if (load.owner() == Thread.currentThread()) {
throw new IllegalStateException("Hydrology tile cache cannot close from an active load."); throw new IllegalStateException("Hydrology tile cache cannot close from an active load.");
@@ -123,7 +123,6 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
IrisPlatforms.isBound() ? MultiBurst.hydrology : null, IrisPlatforms.isBound() ? MultiBurst.hydrology : null,
context.waitingForbidden() context.waitingForbidden()
); );
this.cache.setTerrainPreparation(context.terrainPreparation());
IrisLogging.debug("Hydrology runtime: tileSize=%d publicationRadius=%d planningThreads=%d", IrisLogging.debug("Hydrology runtime: tileSize=%d publicationRadius=%d planningThreads=%d",
settings.routing().tileSize(), settings.publicationRadius(), MultiBurst.hydrology.parallelism()); settings.routing().tileSize(), settings.publicationRadius(), MultiBurst.hydrology.parallelism());
} }
@@ -17,8 +17,7 @@ public record IrisHydrologyRuntimeContext(
IrisHydrologyNaturalHeightDescriber naturalHeightDescriber, IrisHydrologyNaturalHeightDescriber naturalHeightDescriber,
IrisHydrologyNaturalOceanClassifier naturalOceanClassifier, IrisHydrologyNaturalOceanClassifier naturalOceanClassifier,
HydrologyCaveVoxelViewFactory caveViewFactory, HydrologyCaveVoxelViewFactory caveViewFactory,
BooleanSupplier waitingForbidden, BooleanSupplier waitingForbidden
Runnable terrainPreparation
) { ) {
/** /**
* {@code waitingForbidden} is true on a thread that must never wait for a cold hydrology plan, * {@code waitingForbidden} is true on a thread that must never wait for a cold hydrology plan,
@@ -37,6 +36,5 @@ public record IrisHydrologyRuntimeContext(
Objects.requireNonNull(naturalOceanClassifier); Objects.requireNonNull(naturalOceanClassifier);
Objects.requireNonNull(caveViewFactory); Objects.requireNonNull(caveViewFactory);
Objects.requireNonNull(waitingForbidden); Objects.requireNonNull(waitingForbidden);
Objects.requireNonNull(terrainPreparation);
} }
} }
Loaded 100 of 360 files, more files were not shown because too many files have changed in this diff. Show more