Compare commits

..
Author SHA1 Message Date
Brian Neumann-Fopiano c700138b66 Native provider loading
- Native provider classes load from the provider jars before the plugin class loader
- Another plugin bundling VolmLib natives no longer supplies Iris native capabilities on Spigot
- Tests cover provider jar classes taking precedence over the plugin loader
2026-10-29 01:49:27 -04:00
Brian Neumann-Fopiano 21ab5baf27 Fixed Spigot agent attach
- Iris restores the JVM SIGQUIT handler when the server console replaced it
- The Java agent attaches on Spigot servers with a terminal console
- Tests cover restoring the handler after a console resets it
2026-10-29 01:09:16 -04:00
Brian Neumann-Fopiano 58ab3fcbbe Smaller jar libraries
- LWJGL downloads through the server library loaders
- The VolmLib stamp records a commit only for its own Git repository
2026-10-28 22:58:34 -04:00
Brian Neumann-Fopiano b6e9f8a14d Fixed VolmLib commit stamp
- The VolmLib stamp records a commit only when the checkout is its own Git repository
- A VolmLib copy inside another repository stamps an unknown commit
2026-10-28 22:58:20 -04:00
Brian Neumann-Fopiano 0d76d81dab Downloaded LWJGL libraries
- LWJGL is no longer shaded into the Bukkit jar
- plugin.yml lists the LWJGL jars and Linux natives as Spigot libraries
- The Paper loader resolves the same plugin.yml list through the Maven Central mirror
- The packed payload no longer reserves the LWJGL natives prefix
- Tests cover reading the library list from the plugin jar
2026-10-28 21:55:09 -04:00
Brian Neumann-Fopiano aec8f5be8e Updated Bukkit purity allowlist
- Jigsaw connector tools are included in the Bukkit allowlist
- Jigsaw floor painting is included in the Bukkit allowlist
- MantleWriter no longer needs a Bukkit allowance
- The removed IrisPack class no longer has an allowance
2026-10-28 21:31:46 -04:00
Brian Neumann-Fopiano 0f9c045524 Merge remote-tracking branch 'origin/master' 2026-10-28 21:30:18 -04:00
Brian Fopiano 1d4f95829b Fixed entity spawn deadlock
- Rise-enabled spawning prepares its starting position inline on the primary thread
- Asynchronous callers keep scheduled preparation
2026-10-28 21:27:01 -04:00
Brian Neumann-Fopiano ce4771b609 Version 4.3.6
- Iris version is 4.3.6 in gradle.properties
- Root build defaults to version 4.3.6
- Fabric, Forge and NeoForge builds default to version 4.3.6
2026-10-28 18:41:17 -04:00
Brian Neumann-Fopiano 7d73c4d150 Jigsaw junctions and studio tools
- Rotatable jigsaw candidates try their rotations in random order
- Candidate connectors are tried by priority within each rotation
- Planar candidates must agree with every occupied neighbor socket
- Planar assembly falls back to source-only matching before it fails
- Connectors whose branch placed nothing stay open
- Spatial default kit covers all 24 Y-rotation topologies
- Planar start pool includes Blank at weight 1
- Vanilla spatial projects omit Iris themes and placement caps
- Vanilla spatial projects use max depth 4
- 1.21.1 export derives the vertical distance from the structure
- Workcell floors map the active variant's connectors
- Floor froglights mark up, down and up-down connectors
- Connector Tool places, moves, re-orients and removes spatial connectors
- Connector Tool re-seats planar face-center sockets
- Main menu pages workcells five at a time
- Spatial variants can be deleted while another variant remains
- Tool sticks act on the variant they were made for
- Destructive tool confirmation needs two separate clicks
- Pistons cannot extend into protected cells
- Autosave capture failures back off and notify the owner
- Autosave plays the save bell
- Hidden-connector air final states are stored as air
- Reset Connector Blocks clears unsaved markers
- Folia logout releases reset and resize reservations
- Spatial workcell rename relabels its variant
- Workcell teleports land inside the focused workcell
- Replacement layouts keep rendering and hydration working
- Container preview probes stay out of chat
- Non-owners can run commands that are not recognized as mutating
- Spatial resize or rotation past the workcell volume limit is refused
- Tests cover default kit assembly, connector placement, floor glyphs and menu paging
2026-10-28 17:14:14 -04:00
Brian Neumann-Fopiano b3579e623a Added Studio hotload command
- /iris studio hotload and its refresh alias reload the open Studio pack on Bukkit
- Bukkit hotload refuses while Studio entry, pause or close is pending
- Modded servers register the same hotload and refresh commands
- Modded hotload reports busy while that Studio is already hotloading
- Hotload messages and help entries are localized in every language file
- Bukkit artifact verification reads the jar task output
- Multiplexor takes the Bukkit artifact from the packedJar task
- BukkitTestServer shares one-time registry setup across tests
- Tests and a gameplay scenario cover both hotload commands
2026-10-28 13:32:33 -04:00
Brian Neumann-Fopiano 300ecbfc8b Fixed container memory pressure
- Container pressure excludes committed heap the JVM is not using
- Reclaim, pregen gating and status text use the adjusted figure
- Tests cover the committed heap subtraction
2026-10-28 13:15:35 -04:00
Michael 0c760d9256 Fix main thread deadlock when spawning rise-out-of-ground entities
IrisEntity.spawn waited on J.sfut(...).get() to move the spawn point below
ground. J.sfut schedules the task for the next main thread tick, so when spawn
already runs on the main thread (ambient spawning in WorldEntitySpawner) the
future can never complete and the server stops ticking. Run the check inline
when already on the main thread.
2026-10-28 03:56:54 -05:00
Brian Neumann-Fopiano 3dc958edcf Stabilized shutdown and Studio
- Shutdown protection initializes before enable and drains inline when retention fails
- Cleanup preserves class loader retention and closes native providers in order
- Error reports retain context and handle empty stack traces
- Studio maps retain partial coverage and suspend hidden views
- Hydrology previews refresh when plans and rasters complete
- Terrain scratch hashing spreads lattice samples across cache slots
- Plugin and probe artifacts include LWJGL libraries and native resources
- Regression tests cover shutdown, error reporting, map progress, and terrain scratch
2026-10-26 23:29:21 -04:00
SwiftSwamp f64a7c6753 Fixed wandering trader spawning
- World injection attaches vanilla event spawners for NORMAL dimension environments
- Covers worlds loaded at server start, Studio worlds and Folia worlds
- Binding test covers overworld and nether environments
2026-10-26 18:05:45 -06:00
Brian Neumann-Fopiano 7756598057 Integrated hydrology updates
- Riverbed generation includes upstream shutdown fixes
2026-10-26 18:02:26 -04:00
Brian Neumann-Fopiano 75837d757b Improved riverbed generation
- Pond levels follow intersecting lower channels
- Pond sampling covers requested flood margins
- Water steps align with sea level
- River crests grade into surrounding beds
- Riverbeds include varied cross sections and finer noise
- Submerged soils use stable block types
- Hydrology probes inspect wet bed geometry
2026-10-26 18:01:26 -04:00
SwiftSwamp 2690e0878b Fixed shutdown teardown and shutdown staging wait
- Server stop defers teardown only while the plugin class loader close is held
- Shutdown with no Iris world loaded tears down during plugin disable
- Pre-unload hook drains generators inline when the class loader close cannot be held
- INMSBinding reports whether the class loader close is held
- Shutdown tests cover the held and unheld pre-unload paths
- Shutdown waits for an in-progress world replacement to finish staging
- Replacements started after shutdown begins are refused
- Admission tests cover the shutdown wait and the refusal
2026-10-26 09:26:30 -06:00
Brian Neumann-Fopiano 494cdb1a5c Refine hydrology and Studio
- Add dimension-wide hydrology density controls.
- Validate pond containment and shorelines at block resolution.
- Prioritize requested water plans and deduplicate prefetch work.
- Keep completed water previews visible while details load.
- Add pregeneration map navigation and Studio theme controls.
- Reject pack mutations during generation-registry capture.
2026-10-26 00:08:25 -04:00
Brian Neumann-Fopiano 1d182769d8 Merge object placement
- Integrate PR #1255 placement lifecycle modules.
- Preserve dry-anchor rejection for water-surface objects.
- Verify the merged placement guards and output reference.
2026-10-25 23:46:48 -04:00
Brian Neumann-Fopiano 584e8758a7 Update Studio previews
- Merge the eight additional commits from PR #1254.
- Preserve closure guards while refreshing object and hidden 3D views.
- Stop hidden crossfade timers and verify frame retention.
2026-10-25 17:25:18 -04:00
Brian Neumann-Fopiano 9f49d29e2b Integrate Iris Studio
- Merge the unified Studio interface and command entry points.
- Adapt water views and provisional sampling to basin hydrology.
- Restore diagnostic estimates and the biome water overlay.
- Fix render cancellation, preview closure, and platform position callbacks.
- Fall back to software rendering when native bindings are unavailable.
- Cover renderer, lifecycle, and scheduler regressions.
2026-10-25 16:56:49 -04:00
piexel e3bc27d50c Keep hidden 3D views from covering the open view
A 3D view asked for a frame while its card was hidden, for example when
the sidebar resized, showed its GPU window again. AWT maps a shown
canvas even under a hidden parent, so the window covered the open view
in black. Frames only go into the window while the view is on screen.

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

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

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

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

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

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

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

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

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

No files matched your search

+2
View File
@@ -81,3 +81,5 @@ BUILDS/
/docs/
/.perf/
/.codegraph/
.superpowers/
@@ -2,8 +2,9 @@ package art.arcane.iris.probe;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.generation.concurrent.MultiBurst;
import art.arcane.iris.generation.hydrology.HydrologyTileCache;
import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntime;
import art.arcane.iris.generation.hydrology.engine.HydrologyPregeneration;
import art.arcane.iris.generation.hydrology.plan.WindowKey;
import art.arcane.iris.world.pregen.PregenHydrology;
import art.arcane.iris.world.pregen.PregenPerformanceProfile;
import net.minecraft.core.registries.Registries;
@@ -31,11 +32,16 @@ import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.List;
import java.util.Locale;
import java.util.Set;
public final class OfflineRegionGenerator {
private static final int CHUNKS_PER_HYDROLOGY_REGION = WindowKey.SIZE >> 4;
private OfflineRegionGenerator() {
}
@@ -97,14 +103,13 @@ public final class OfflineRegionGenerator {
PregenPerformanceProfile.apply(session.engine());
System.out.println("generationProfile=pregen noiseCacheSize=" + IrisSettings.get().getPerformance().getNoiseCacheSize()
+ " burstParallelism=" + MultiBurst.burst.parallelism() + " multicore=true");
IrisHydrologyRuntime hydrology = session.engine().getComplex().getHydrologyRuntime();
try (HydrologyTileCache.PregenerationScope hydrologyScope = hydrology == null ? null
: hydrology.preparePregeneration(configuration.hydrologyArea());
try (HydrologyPregeneration pregeneration = PregenHydrology.open(session.engine());
HeadlessTerrainContext context = HeadlessTerrainContext.create(new HeadlessTerrainContext.Options(
session.engine(), runtime, staged.resolve("templates"), configuration.levelKey(),
configuration.noiseSettings(), spigot));
RegionGenerationWindow workers = new RegionGenerationWindow(configuration.parallelism(),
RegionGenerationWindow.Policy.STANDALONE)) {
PregenHydrology.prepareAll(pregeneration, configuration.hydrologyRegions());
HeadlessRegionTerrain.Session rolling = new HeadlessRegionTerrain.Session(workers);
Path regionDirectory = session.engine().getTarget().getWorld().worldFolder().toPath().resolve("region");
Files.createDirectory(regionDirectory);
@@ -239,14 +244,27 @@ public final class OfflineRegionGenerator {
}
}
HydrologyTileCache.PregenerationArea hydrologyArea() {
List<long[]> hydrologyRegions() {
int halo = NativeRegionPlan.terrainHalo();
long minimumX = ((long) chunkX - halo) * 16L;
long minimumZ = ((long) chunkZ - halo) * 16L;
long maximumX = ((long) chunkX + (long) width * regionsX + halo) * 16L - 1L;
long maximumZ = ((long) chunkZ + (long) width * regionsZ + halo) * 16L - 1L;
return new HydrologyTileCache.PregenerationArea(Math.toIntExact((minimumX + maximumX) / 2L),
Math.toIntExact((minimumZ + maximumZ) / 2L), minimumX, minimumZ, maximumX, maximumZ);
Set<Long> seen = new HashSet<>();
List<long[]> regions = new ArrayList<>();
for (int row = 0; row < regionsZ; row++) {
for (int column = 0; column < regionsX; column++) {
int regionX = row % 2 == 0 ? column : regionsX - column - 1;
int minimumX = Math.floorDiv(chunkX + regionX * width - halo, CHUNKS_PER_HYDROLOGY_REGION);
int maximumX = Math.floorDiv(chunkX + (regionX + 1) * width - 1 + halo, CHUNKS_PER_HYDROLOGY_REGION);
int minimumZ = Math.floorDiv(chunkZ + row * width - halo, CHUNKS_PER_HYDROLOGY_REGION);
int maximumZ = Math.floorDiv(chunkZ + (row + 1) * width - 1 + halo, CHUNKS_PER_HYDROLOGY_REGION);
for (int hydrologyZ = minimumZ; hydrologyZ <= maximumZ; hydrologyZ++) {
for (int hydrologyX = minimumX; hydrologyX <= maximumX; hydrologyX++) {
if (seen.add(((long) hydrologyX << 32) | (hydrologyZ & 0xFFFFFFFFL))) {
regions.add(new long[]{hydrologyX, hydrologyZ});
}
}
}
}
}
return regions;
}
static Configuration parse(String[] arguments) throws IOException {
@@ -137,7 +137,7 @@ public class HeadlessChunkGeneratorTest {
}
Files.writeString(pack.resolve("dimensions/main.json"), """
{"name":"Native columns","focus":"swamp","regions":["owned"],"fluidHeight":40,"logicalHeight":256,
"landChance":1,"carvingEnabled":false,"hydrology":{"rivers":{"enabled":false}}}
"landChance":1,"carvingEnabled":false}
""");
Files.writeString(pack.resolve("regions/owned.json"), "{\"landBiomes\":[\"swamp\"]}");
Files.writeString(pack.resolve("generators/flat.json"), "{\"composite\":[]}");
@@ -1,7 +1,6 @@
package art.arcane.iris.probe;
import art.arcane.iris.structure.nativegen.NativeStructureVolumeIndex;
import art.arcane.iris.generation.hydrology.HydrologyTileCache;
import art.arcane.volmlib.nativelib.terrain.structure.NativeStructureVolume;
import art.arcane.volmlib.util.collection.KList;
import net.minecraft.nbt.CompoundTag;
@@ -68,11 +67,10 @@ public final class HeadlessRegionTerrainTest {
OfflineRegionGenerator.Configuration export = new OfflineRegionGenerator.Configuration(
workspace.pack(), "main", 69420L, -3, 2, 1, 2, 3, 4, configuration,
"probe", Level.OVERWORLD, NoiseGeneratorSettings.OVERWORLD, root.resolve("export"));
HydrologyTileCache.PregenerationArea bounds = export.hydrologyArea();
assertEquals(-80L, bounds.minimumBlockX());
assertEquals(0L, bounds.minimumBlockZ());
assertEquals(15L, bounds.maximumBlockX());
assertEquals(111L, bounds.maximumBlockZ());
List<long[]> regions = export.hydrologyRegions();
assertEquals(2, regions.size());
assertArrayEquals(new long[]{-1L, 0L}, regions.get(0));
assertArrayEquals(new long[]{0L, 0L}, regions.get(1));
assertEquals(1, result.targetChunks());
assertEquals(529, result.chunks().size());
long terrain = result.chunks().stream().filter(chunk -> chunk.getPersistedStatus() == ChunkStatus.TERRAIN).count();
@@ -189,7 +187,7 @@ public final class HeadlessRegionTerrainTest {
}
Files.writeString(pack.resolve("dimensions/main.json"), """
{"name":"Native region","focus":"swamp","regions":["owned"],"fluidHeight":40,"logicalHeight":256,
"landChance":1,"carvingEnabled":false,"hydrology":{"rivers":{"enabled":false}}}
"landChance":1,"carvingEnabled":false}
""");
Files.writeString(pack.resolve("regions/owned.json"), "{\"landBiomes\":[\"swamp\"]}");
Files.writeString(pack.resolve("generators/flat.json"), "{\"composite\":[]}");
+12 -1
View File
@@ -14,7 +14,7 @@ dependencies {
// log4j-core Filter interface, so its constructor reference needs the type resolvable.
compileOnly(libs.log4j.core)
testImplementation('junit:junit:4.13.2')
testImplementation('org.mockito:mockito-core:5.23.0')
testImplementation(libs.mockito.core)
testImplementation(libs.paper.api)
testImplementation(libs.bstats)
testImplementation(project(':core'))
@@ -28,6 +28,16 @@ dependencies {
}
}
String lwjglVersion = libs.versions.lwjgl.get()
List<String> mavenLibraries = [
"org.lwjgl:lwjgl:${lwjglVersion}",
"org.lwjgl:lwjgl:jar:natives-linux:${lwjglVersion}",
"org.lwjgl:lwjgl-opengl:${lwjglVersion}",
"org.lwjgl:lwjgl-opengl:jar:natives-linux:${lwjglVersion}",
"org.lwjgl:lwjgl-egl:${lwjglVersion}",
"org.lwjgl:lwjgl-jawt:${lwjglVersion}",
].collect { GString coordinate -> coordinate.toString() }
tasks.named('processResources').configure {
def pluginProperties = [
name : rootProject.name,
@@ -35,6 +45,7 @@ tasks.named('processResources').configure {
apiVersion: apiVersion,
main : mainClass,
bootstrapper: bootstrapperClass,
libraries : mavenLibraries.collect { String coordinate -> "'${coordinate}'" }.join(', '),
]
inputs.properties(pluginProperties)
filesMatching(['**/paper-plugin.yml', '**/plugin.yml']) {
@@ -57,9 +57,12 @@ import art.arcane.iris.localization.IrisLanguage;
import art.arcane.volmlib.util.director.help.DirectorMiniMenu;
import art.arcane.volmlib.util.localization.BukkitLanguageSwitcher;
import art.arcane.iris.integration.MultiverseCoreLink;
import art.arcane.iris.platform.agent.Agent;
import art.arcane.iris.platform.agent.AttachSignal;
import art.arcane.iris.platform.bukkit.nms.INMS;
import art.arcane.volmlib.nativelib.terrain.ServerShutdownBoundary;
import art.arcane.iris.studio.view.BukkitGuiHost;
import art.arcane.iris.studio.view.GuiHost;
import art.arcane.iris.studio.view.PregeneratorJob;
import art.arcane.iris.platform.bukkit.BoardSVC;
import art.arcane.iris.command.CommandSVC;
@@ -200,10 +203,10 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
private final AtomicBoolean postStopFinisherStarted = new AtomicBoolean(false);
private final AtomicBoolean serverStopTeardownDeferred = new AtomicBoolean(false);
private final AtomicBoolean servicesDisabled = new AtomicBoolean(false);
private final AtomicBoolean runtimeTeardownFailed = new AtomicBoolean(false);
private final AtomicBoolean sharedRuntimeClosed = new AtomicBoolean(false);
private final AtomicBoolean startupBoundaryRestart = new AtomicBoolean(false);
private final AtomicBoolean terminalCleanupCompleted = new AtomicBoolean(false);
private final AtomicBoolean runtimeTeardownFailed = new AtomicBoolean(false);
private volatile boolean generatorDrainCompleted;
private volatile boolean enableFailed;
private volatile PlaceholderRegistration papiRegistration;
@@ -224,6 +227,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
private BukkitDebugDump debugDump;
private volatile SettingsHotloadWatch settingsHotloadWatch;
private volatile ServerShutdownBoundary serverShutdownBoundary;
private boolean shutdownProtectionInitialized;
public static VolmitSender getSender() {
VolmitSender current = sender;
@@ -473,10 +477,16 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
}
if (debug) {
String n = e.getClass().getCanonicalName() + "-" + e.getStackTrace()[0].getClassName() + "-" + e.getStackTrace()[0].getLineNumber();
String n = e.getClass().getName();
StackTraceElement[] trace = e.getStackTrace();
if (trace.length > 0) {
n += "-" + trace[0].getClassName() + "-" + trace[0].getLineNumber();
}
if (e.getCause() != null) {
n += "-" + e.getCause().getStackTrace()[0].getClassName() + "-" + e.getCause().getStackTrace()[0].getLineNumber();
StackTraceElement[] causeTrace = e.getCause().getStackTrace();
n += "-" + (causeTrace.length == 0 ? e.getCause().getClass().getName()
: causeTrace[0].getClassName() + "-" + causeTrace[0].getLineNumber());
}
File f = instance.getDataFile("debug", "caught-exceptions", n + ".txt");
@@ -570,6 +580,17 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
this::getIrisVersion,
this::getMCVersion));
SlimJar.load();
restoreAttachSignal();
}
private static void restoreAttachSignal() {
try {
if (AttachSignal.restoreJvmHandler()) {
info("Restored the JVM SIGQUIT handler that the server console replaced");
}
} catch (RuntimeException failure) {
reportError("Could not restore the JVM SIGQUIT handler; the Iris agent may fail to attach.", failure);
}
}
private static void bridgeLog(LogLevel level, String message) {
@@ -626,10 +647,10 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
postStopFinisherStarted.set(false);
serverStopTeardownDeferred.set(false);
servicesDisabled.set(false);
runtimeTeardownFailed.set(false);
sharedRuntimeClosed.set(false);
startupBoundaryRestart.set(false);
terminalCleanupCompleted.set(false);
runtimeTeardownFailed.set(false);
generatorDrainCompleted = false;
deferredShutdownGenerators.clear();
MultiBurst.burst.reopen();
@@ -910,6 +931,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
public void onEnable() {
enableFailed = false;
shutdownProtectionInitialized = false;
IrisStartupValidation.begin();
Bukkit.getPluginManager().registerEvents(new IrisStartupAdmissionListener(), this);
try {
@@ -921,13 +943,14 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
refuseUnsupportedServer();
return;
}
initializeShutdownProtection();
enable();
} catch (Throwable failure) {
enableFailed = true;
if (!FailedEnableLock.engage(failure, FailedEnableLock.irisWorldsPresent(),
generatorResolver.refusals().startupWorldsCreated())) {
throw failure;
}
enableFailed = true;
return;
}
BukkitGuiHost.install();
@@ -945,6 +968,17 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
reportLockedRuntime();
}
private void initializeShutdownProtection() {
if (!INMS.get().supportsIrisWorldGeneration()) {
return;
}
if (!Agent.install()) {
throw new IllegalStateException("Iris Java agent is unavailable; shutdown protection cannot be installed.");
}
INMS.get().initializeLifecycle(getClass().getClassLoader());
shutdownProtectionInitialized = true;
}
private void refuseUnsupportedServer() {
Throwable bindFailure = INMS.bindFailure();
IllegalStateException failure = new IllegalStateException("Iris cannot start: " + (bindFailure == null
@@ -988,6 +1022,8 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
}
public void onDisable() {
pendingWorldReplacements.awaitStagingForShutdown();
GuiHost.shutdown();
preservePendingBlockDrops();
papiLifecycleActive = false;
if (updateNotifier != null) {
@@ -1004,7 +1040,8 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
}
IrisLanguage.shutdown();
teardownPapi();
boolean deferToServerStop = FailedEnableLock.defersTeardownToServerStop(enableFailed, IrisToolbelt.isServerStopping());
boolean deferToServerStop = FailedEnableLock.defersTeardownToServerStop(enableFailed, IrisToolbelt.isServerStopping())
&& tryDeferPluginClassLoaderClose();
boolean restartingAtStartupBoundary = startupBoundaryRestart.get();
if (restartingAtStartupBoundary) {
teardownRuntime("startup-boundary-restart", 30L);
@@ -1036,7 +1073,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
preservePendingBlockDrops();
papiLifecycleActive = false;
teardownPapi();
if (IrisToolbelt.isServerStopping()) {
if (IrisToolbelt.isServerStopping() && tryDeferPluginClassLoaderClose()) {
quiesceRuntimeForServerShutdown("pre-unload:" + reason);
startPostStopFinisher();
Iris.debug("Pre-unload hook deferred generator teardown until Paper closes its chunk schedulers.");
@@ -1051,6 +1088,19 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
return CompletableFuture.completedFuture(null);
}
private boolean tryDeferPluginClassLoaderClose() {
if (!shutdownProtectionInitialized) {
return false;
}
try {
INMS.get().deferPluginClassLoaderClose();
return true;
} catch (RuntimeException | LinkageError failure) {
Iris.reportError("Failed to retain the Iris plugin class loader; draining shutdown inline.", failure);
return false;
}
}
/**
* Drains the world generators exactly once. Serialized against the JVM shutdown hook so a
* second caller cannot rip the pools or services out from under an in-flight drain.
@@ -1097,19 +1147,16 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
try {
MultiBurst.burst.close();
} catch (Throwable failure) {
runtimeTeardownFailed.set(true);
Iris.reportError("Failed to close Iris generation workers.", failure);
}
try {
MultiBurst.hydrology.close();
} catch (Throwable failure) {
runtimeTeardownFailed.set(true);
Iris.reportError("Failed to close Iris hydrology workers.", failure);
}
try {
MultiBurst.ioBurst.close();
} catch (Throwable failure) {
runtimeTeardownFailed.set(true);
Iris.reportError("Failed to close Iris I/O workers.", failure);
}
clearQueues();
@@ -1128,7 +1175,6 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
private void quiesceRuntimeForServerShutdown(String reason) {
serverStopTeardownDeferred.set(true);
INMS.get().deferPluginClassLoaderClose();
JigsawStudioService jigsawStudioService = IrisServices.getOrNull(JigsawStudioService.class);
if (jigsawStudioService != null) {
try {
@@ -1226,30 +1272,37 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
if (!terminalCleanupCompleted.compareAndSet(false, true)) {
return;
}
J.attempt(() -> INMS.get().uninjectBukkit());
try {
if (INMS.isBound()) {
INMS.get().uninjectBukkit();
}
} catch (Throwable failure) {
Iris.reportError("Failed to remove Iris world-generation hooks.", failure);
}
try {
runPostShutdown();
} catch (Throwable e) {
runtimeTeardownFailed.set(true);
Iris.reportError("Failed to run Iris post-shutdown cleanup.", e);
} finally {
if (serverStopTeardownDeferred.get() && hasActiveShutdownResources()) {
runtimeTeardownFailed.set(true);
SHUTDOWN_ERRORS.println("[Iris] Owned background work remains active after cleanup; retaining the plugin class loader until JVM exit.");
}
boolean activeResources = serverStopTeardownDeferred.get()
&& (runtimeTeardownFailed.get() || hasActiveShutdownResources());
IrisPlatforms.unbind();
if (serverStopTeardownDeferred.get() && runtimeTeardownFailed.get()) {
SHUTDOWN_ERRORS.println("[Iris] Runtime cleanup failed; retaining the plugin class loader until JVM exit.");
BukkitPlatform.releaseHost();
if (activeResources) {
SHUTDOWN_ERRORS.println("[Iris] Owned background work has not completed cleanup; retaining the plugin class loader until JVM exit.");
} else {
try {
if (shutdownProtectionInitialized) {
INMS.get().releasePluginClassLoaderClose();
} else {
SlimJar.closeNativeProviders();
}
} catch (Throwable failure) {
SHUTDOWN_ERRORS.println("[Iris] Failed to release the plugin class loader after runtime cleanup.");
failure.printStackTrace(SHUTDOWN_ERRORS);
}
}
BukkitPlatform.releaseHost();
}
}
@@ -6,12 +6,22 @@ import io.papermc.paper.plugin.bootstrap.PluginProviderContext;
import io.papermc.paper.plugin.loader.PluginClasspathBuilder;
import io.papermc.paper.plugin.loader.PluginLoader;
import io.papermc.paper.plugin.loader.library.impl.JarLibrary;
import io.papermc.paper.plugin.loader.library.impl.MavenLibraryResolver;
import org.bukkit.configuration.file.YamlConfiguration;
import org.eclipse.aether.artifact.DefaultArtifact;
import org.eclipse.aether.graph.Dependency;
import org.eclipse.aether.repository.RemoteRepository;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.stream.Stream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
@SuppressWarnings("UnstableApiUsage")
public final class IrisPluginLoader implements PluginLoader {
@@ -40,6 +50,21 @@ public final class IrisPluginLoader implements PluginLoader {
classpathBuilder.addLibrary(new JarLibrary(library));
}
}
classpathBuilder.addLibrary(mavenResolver(mavenLibraries(context.getPluginSource())));
}
static List<String> mavenLibraries(Path pluginJar) {
try (ZipFile jar = new ZipFile(pluginJar.toFile())) {
ZipEntry descriptor = jar.getEntry("plugin.yml");
if (descriptor == null) {
throw new IllegalStateException("The Iris jar " + pluginJar + " has no plugin.yml library list.");
}
try (Reader reader = new InputStreamReader(jar.getInputStream(descriptor), StandardCharsets.UTF_8)) {
return YamlConfiguration.loadConfiguration(reader).getStringList("libraries");
}
} catch (IOException failure) {
throw new IllegalStateException("Unable to read the Iris library list from " + pluginJar + ".", failure);
}
}
static List<Path> relocatedLibraries(Path libraryRoot) {
@@ -59,6 +84,16 @@ public final class IrisPluginLoader implements PluginLoader {
}
}
private static MavenLibraryResolver mavenResolver(List<String> coordinates) {
MavenLibraryResolver resolver = new MavenLibraryResolver();
resolver.addRepository(new RemoteRepository.Builder("central", "default",
MavenLibraryResolver.MAVEN_CENTRAL_DEFAULT_MIRROR).build());
for (String coordinate : coordinates) {
resolver.addDependency(new Dependency(new DefaultArtifact(coordinate), null));
}
return resolver;
}
private static boolean isRelocatedLibrary(Path path) {
int count = path.getNameCount();
for (int i = 0; i < count - 2; i++) {
@@ -31,8 +31,7 @@ import art.arcane.iris.structure.placement.IrisStructureLocator;
import art.arcane.iris.structure.nativegen.NativeStructureGenerationPolicy;
import art.arcane.iris.structure.placement.StructureReachability;
import art.arcane.iris.world.history.GenerationSemanticQueries;
import art.arcane.iris.generation.hydrology.HydrologyFeatureQuery;
import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntime;
import art.arcane.iris.generation.hydrology.query.HydrologyFeatureType;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.structure.nativegen.IrisNativeStructureDecision;
import art.arcane.iris.generation.terrain.IrisRegion;
@@ -196,9 +195,9 @@ public class CommandFind implements DirectorExecutor {
EngineBukkitOps.gotoPOI(e, type, player(), teleport);
}
@Director(description = "Find an accepted hydrology feature", sync = true)
@Director(description = "Find a hydrology water feature", sync = true)
public void river(
@Param(description = "Feature type: surface, waterfall, underground, grotto, mouth, or a deep-fluid id", customHandler = HydrologyTypeHandler.class)
@Param(description = "Feature type: stream, river, major_river, cascade, waterfall, mouth, pond, lake, dam, sea_inlet or cave_pool", customHandler = HydrologyTypeHandler.class)
String type,
@Param(description = "Should you be teleported", defaultValue = "true")
boolean teleport
@@ -212,42 +211,32 @@ public class CommandFind implements DirectorExecutor {
}
return;
}
IrisHydrologyRuntime runtime = activeEngine.getComplex().getHydrologyRuntime();
HydrologyFeatureQuery query;
HydrologyFeatureType featureType;
try {
query = HydrologyFeatureQuery.parse(type);
featureType = HydrologyFeatureType.parse(type);
} catch (IllegalArgumentException error) {
commandSender.sendMessage(C.RED + error.getMessage());
return;
}
String label = featureType.key().replace('_', ' ');
Location origin = target.getLocation();
World originWorld = origin.getWorld();
int requestedDistance = runtime == null
? 8192
: Math.min(8192, runtime.settings().routing().tileSize() * 15);
int maximumDistance = runtime == null
? requestedDistance
: runtime.maximumFeatureSearchDistance(
origin.getBlockX(), origin.getBlockZ(), requestedDistance);
commandSender.sendMessage(C.GRAY + "Searching accepted hydrology plans for " + type + "...");
int maximumDistance = GenerationSemanticQueries.RIVER_SEARCH_DISTANCE;
commandSender.sendMessage(C.GRAY + GenerationSemanticQueries.riverSearchMessage(featureType, maximumDistance));
J.a(() -> {
try {
GenerationSemanticQueries.RiverResult feature = GenerationSemanticQueries.nearestRiver(
activeEngine,
query,
featureType,
origin.getBlockX(),
origin.getBlockZ(),
maximumDistance,
(int visited) -> sendStructureMessage(target, commandSender,
C.GRAY + "Searched " + visited + " hydrology tiles for " + type + "...")
maximumDistance
).orElse(null);
if (feature == null) {
sendStructureMessage(target, commandSender,
C.YELLOW + "No accepted " + type + " hydrology feature found within "
+ maximumDistance + " blocks.");
C.YELLOW + GenerationSemanticQueries.riverNotFoundMessage(featureType, maximumDistance));
return;
}
String label = type + " hydrology feature";
if (!teleport) {
sendStructureMessage(target, commandSender,
C.GREEN + "Found " + label + " at " + feature.x() + ", " + feature.y() + ", " + feature.z() + ".");
@@ -259,8 +248,9 @@ public class CommandFind implements DirectorExecutor {
target, originWorld, commandSender, label, destination, true);
} catch (Throwable error) {
sendStructureMessage(target, commandSender,
C.RED + "Could not locate " + type + " hydrology: " + error.getClass().getSimpleName());
Iris.reportError("Could not locate accepted hydrology feature '" + type + "'.", error);
C.RED + "Could not search for " + label + ": " + error.getClass().getSimpleName()
+ (error.getMessage() == null ? "" : " - " + error.getMessage()));
Iris.reportError("Could not locate hydrology feature '" + featureType.key() + "'.", error);
}
});
}
@@ -23,6 +23,7 @@ import art.arcane.iris.structure.authoring.StructureKey;
import art.arcane.iris.structure.authoring.StructureWriteResult;
import art.arcane.iris.structure.export.VanillaJigsawExportFormat;
import art.arcane.iris.structure.export.VanillaJigsawExportRequest;
import art.arcane.iris.structure.export.VanillaJigsawExportSettings;
import art.arcane.iris.structure.export.VanillaJigsawExportSource;
import art.arcane.iris.structure.graph.PlanarJigsawWorkcellResolver;
import art.arcane.iris.generation.terrain.IrisDimension;
@@ -437,13 +438,14 @@ public class CommandJigsaw implements DirectorExecutor {
}
VanillaJigsawExportRequest request;
try {
request = VanillaJigsawExportRequest.builder(
VanillaJigsawExportSource.forData(
context.request().source(), context.request().structureKey()), destination)
VanillaJigsawExportSource source = VanillaJigsawExportSource.forData(
context.request().source(), context.request().structureKey());
request = VanillaJigsawExportRequest.builder(source, destination)
.namespace(namespace)
.resourcePath(context.request().structureKey())
.format(exportFormat)
.replaceExisting(replace)
.settings(VanillaJigsawExportSettings.runtimeDefaults(source.loadStructure()))
.build();
} catch (IllegalArgumentException exception) {
sendError(exception.getMessage());
@@ -21,10 +21,9 @@ package art.arcane.iris.command;
import art.arcane.iris.Iris;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.studio.map.MapWindow;
import art.arcane.iris.studio.map.PaletteExplorerStart;
import art.arcane.iris.studio.view.GuiHost;
import art.arcane.iris.studio.view.ImageMapStudioGUI;
import art.arcane.iris.studio.view.NoiseExplorerGUI;
import art.arcane.iris.studio.view.VisionGUI;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.studio.workspace.IrisProject;
import art.arcane.iris.studio.workspace.IrisCodeWorkspace;
@@ -98,11 +97,14 @@ import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import art.arcane.iris.localization.IrisLanguage;
import art.arcane.iris.localization.BukkitCommandMessages;
import art.arcane.volmlib.util.localization.MessageArgument;
import art.arcane.volmlib.util.localization.LanguageAudience;
import art.arcane.iris.localization.BukkitCommandMessagesExtended;
@Director(name = "studio", aliases = {"std", "s"}, description = "Studio Commands", descriptionKey = "iris.director.commandstudio.director.studio_commands")
public class CommandStudio implements DirectorExecutor {
@@ -202,6 +204,22 @@ public class CommandStudio implements DirectorExecutor {
}));
}
@Director(description = "Hotload the active Studio pack", descriptionKey = "iris.director.commandstudio.director.hotload_active_studio_pack", aliases = {"refresh"}, origin = DirectorOrigin.BOTH)
public void hotload() {
VolmitSender commandSender = sender();
IrisProject project = Iris.service(StudioSVC.class).getActiveProject();
PlatformChunkGenerator generator = project == null ? null : project.getActiveProvider();
if (generator == null) {
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_STUDIO_NO_OPEN_STUDIO_PROJECTS));
return;
}
UUID audience = LanguageAudience.current();
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_STUDIO_HOTLOADING));
generator.hotloadAsync().whenComplete((ignored, failure) ->
LanguageAudience.run(audience, () -> sendHotloadResult(commandSender, failure)));
}
@Director(description = "Toggle your Studio debug scoreboard", descriptionKey = "iris.director.commandstudio.director.toggle_your_studio_debug_scoreboard", aliases = {"board", "sidebar", "sb"}, origin = DirectorOrigin.PLAYER)
public void scoreboard() {
Player target = player();
@@ -255,15 +273,10 @@ public class CommandStudio implements DirectorExecutor {
long seed
) {
if (noGUI()) return;
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_NOISE_EXPLORER));
if (generator == null) {
NoiseExplorerGUI.launch();
return;
}
String generatorKey = generator.getLoadKey();
NoiseExplorerGUI.launchGeneratorKey(generatorKey, generator, seed);
VolmitSender commandSender = sender();
UUID openerId = commandSender.isPlayer() ? commandSender.player().getUniqueId() : null;
MapWindow.NoiseSelection selection = new MapWindow.NoiseSelection(generator == null ? null : generator.getLoadKey(), generator, seed);
withPlayerContext(opener -> openNoiseAtPlayer(commandSender, openerId, opener, selection));
}
@Director(description = "Open the image-map studio (External GUI)", descriptionKey = "iris.director.commandstudio.director.open_image_map_studio_external_gui", aliases = {"imap"})
@@ -279,8 +292,7 @@ public class CommandStudio implements DirectorExecutor {
return;
}
Engine activeEngine = IrisToolbelt.access(world).getEngine();
ImageMapStudioGUI.launch(activeEngine);
requestMapStart(world, MapWindow::openImageMaps);
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_IMAGE_MAP_STUDIO));
}
@@ -420,11 +432,61 @@ public class CommandStudio implements DirectorExecutor {
return;
}
UUID openerId = sender().isPlayer() ? player().getUniqueId() : null;
VisionGUI.launch(IrisToolbelt.access(world).getEngine(), openerId);
requestMapStart(world, MapWindow::openMap);
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_MAP));
}
// The map opens where the player stands when they are in that world, otherwise at the world origin.
private PaletteExplorerStart mapStartIn(World world, Player opener, UUID openerId) {
Location center = opener != null && opener.getWorld().equals(world) ? opener.getLocation() : null;
return new PaletteExplorerStart(
IrisToolbelt.access(world).getEngine(),
world.getUID(),
openerId,
null,
center == null ? 0D : center.getX(),
center == null ? 0D : center.getZ()
);
}
private void requestMapStart(World world, Consumer<PaletteExplorerStart> launch) {
UUID openerId = sender().isPlayer() ? player().getUniqueId() : null;
withPlayerContext(opener -> launch.accept(mapStartIn(world, opener, openerId)));
}
private void openNoiseAtPlayer(VolmitSender commandSender, UUID openerId, Player opener, MapWindow.NoiseSelection selection) {
World world = opener == null ? null : opener.getWorld();
PlatformChunkGenerator worldGenerator = world == null ? null : IrisToolbelt.access(world);
Engine engine = worldGenerator == null ? GuiHost.get().findActiveEngine() : worldGenerator.getEngine();
if (engine == null) {
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_YOU_NEED_BE_SPECIFY_IRIS_GENERATED_WORLD));
return;
}
PaletteExplorerStart start = worldGenerator == null
? new PaletteExplorerStart(engine, null, openerId, null, 0D, 0D)
: mapStartIn(world, opener, openerId);
MapWindow.openNoise(start, selection);
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_NOISE_EXPLORER));
}
private void withPlayerContext(Consumer<Player> action) {
Player opener = sender().isPlayer() ? player() : null;
if (opener == null) {
action.accept(null);
return;
}
AtomicBoolean completed = new AtomicBoolean();
Consumer<Player> complete = player -> {
if (completed.compareAndSet(false, true)) {
action.accept(player);
}
};
Runnable retired = () -> complete.accept(null);
if (!J.runEntity(opener, () -> complete.accept(opener.isOnline() ? opener : null), 0, retired)) {
retired.run();
}
}
@Director(description = "Profiles the performance of a dimension", descriptionKey = "iris.director.commandstudio.director.profiles_performance_dimension", origin = DirectorOrigin.PLAYER)
public void profile(
@Param(description = "The dimension to profile", descriptionKey = "iris.director.commandstudio.param.dimension_profile", contextual = true, contextualOverride = true, defaultValue = "default", customHandler = DimensionHandler.class)
@@ -597,6 +659,16 @@ public class CommandStudio implements DirectorExecutor {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_DONE, MessageArgument.untrusted("value", report.getPath())));
}
private void sendHotloadResult(VolmitSender commandSender, Throwable failure) {
if (failure != null) {
Iris.reportError(failure);
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_STUDIO_HOTLOAD_FAILED,
MessageArgument.untrusted("value", String.valueOf(failure.getMessage()))));
return;
}
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_STUDIO_HOTLOADED));
}
private PlatformChunkGenerator resolveProfileGenerator(IrisDimension dimension) {
StudioSVC studioService = Iris.service(StudioSVC.class);
if (studioService != null && studioService.isProjectOpen()) {
@@ -0,0 +1,50 @@
package art.arcane.iris.platform.agent;
import java.lang.foreign.Arena;
import java.lang.foreign.FunctionDescriptor;
import java.lang.foreign.Linker;
import java.lang.foreign.MemorySegment;
import java.lang.foreign.ValueLayout;
import java.lang.invoke.MethodHandle;
import java.util.Locale;
public final class AttachSignal {
private static final int SIGQUIT = 3;
private static final int SIGTERM = 15;
private static final long ACTION_BYTES = 256;
private static final long SIG_IGN = 1L;
private AttachSignal() {
}
public static boolean restoreJvmHandler() {
if (System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win")) {
return false;
}
Linker linker = Linker.nativeLinker();
MethodHandle sigaction = linker.downcallHandle(
linker.defaultLookup().find("sigaction")
.orElseThrow(() -> new IllegalStateException("sigaction is unavailable")),
FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.JAVA_INT, ValueLayout.ADDRESS, ValueLayout.ADDRESS));
try (Arena arena = Arena.ofConfined()) {
MemorySegment jvm = arena.allocate(ACTION_BYTES);
MemorySegment quit = arena.allocate(ACTION_BYTES);
if ((int) sigaction.invokeExact(SIGTERM, MemorySegment.NULL, jvm) != 0
|| (int) sigaction.invokeExact(SIGQUIT, MemorySegment.NULL, quit) != 0) {
throw new IllegalStateException("sigaction could not read the SIGTERM and SIGQUIT handlers");
}
long handler = jvm.get(ValueLayout.JAVA_LONG, 0);
if (handler == 0L || handler == SIG_IGN || quit.get(ValueLayout.JAVA_LONG, 0) == handler) {
return false;
}
if ((int) sigaction.invokeExact(SIGQUIT, jvm, MemorySegment.NULL) != 0) {
throw new IllegalStateException("sigaction could not restore the JVM SIGQUIT handler");
}
return true;
} catch (RuntimeException | Error failure) {
throw failure;
} catch (Throwable failure) {
throw new IllegalStateException("Unable to restore the JVM SIGQUIT handler", failure);
}
}
}
@@ -17,8 +17,7 @@ import art.arcane.iris.platform.bukkit.terrain.IrisSampleLimits;
import art.arcane.iris.platform.bukkit.terrain.IrisSurfaceClassifier;
import art.arcane.iris.platform.bukkit.terrain.IrisWorldInfoFactory;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.hydrology.HydrologyColumnLayer;
import art.arcane.iris.generation.hydrology.HydrologyColumnSample;
import art.arcane.iris.generation.hydrology.raster.WaterRole;
import art.arcane.iris.generation.terrain.InferredType;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.terrain.IrisRegion;
@@ -108,15 +107,15 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService {
try {
int surface = engine.getHeight(blockX, blockZ);
HydrologyColumnSample hydrology = hydrologySample(engine, blockX, blockZ);
WaterRole role = engine.getComplex().getWaterRoleStream().get(blockX, blockZ);
int fluid = (int) Math.round(
engine.getComplex().getRiverWaterSurfaceStream().get(blockX, blockZ));
engine.getComplex().getWaterHeadStream().get(blockX, blockZ));
InferredType inferredType = null;
if (IrisSurfaceClassifier.requiresSurfaceBiome(surface, fluid)) {
IrisBiome biome = engine.getSurfaceBiome(blockX, blockZ);
inferredType = biome == null ? null : biome.getInferredType();
}
return IrisSurfaceClassifier.classify(surface, fluid, inferredType, hydrology);
return IrisSurfaceClassifier.classify(surface, fluid, inferredType, role);
} catch (Throwable error) {
reportQueryFault("surfaceKind", world, error);
return IrisSurfaceKind.UNKNOWN;
@@ -266,12 +265,12 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService {
return false;
}
HydrologyColumnSample hydrology = wantRiver
? hydrologySample(engine, blockX, blockZ)
: null;
WaterRole role = wantRiver
? engine.getComplex().getWaterRoleStream().get(blockX, blockZ)
: WaterRole.NONE;
int surface = wantHeight || wantKind ? engine.getHeight(blockX, blockZ) : 0;
int fluid = wantRiver
? (int) Math.round(engine.getComplex().getRiverWaterSurfaceStream().get(blockX, blockZ))
? (int) Math.round(engine.getComplex().getWaterHeadStream().get(blockX, blockZ))
: engine.getDimension().getFluidHeight();
boolean needsBiome = wantBiome
|| (wantKind && IrisSurfaceClassifier.requiresSurfaceBiome(surface, fluid));
@@ -281,29 +280,23 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService {
surface,
fluid,
biome == null ? null : biome.getInferredType(),
hydrology
role
)
: IrisSurfaceKind.UNKNOWN;
String biomeKey = wantBiome && biome != null ? biome.getLoadKey() : null;
int natural = wantNaturalHeight
? (int) Math.round(engine.getComplex().getNaturalHeightStream().get(blockX, blockZ)) + minHeight
: IrisColumnSample.UNAVAILABLE_HEIGHT;
HydrologyColumnLayer surfaceLayer = hydrology == null
? null
: hydrology.primarySurfaceLayer().orElse(null);
boolean riverPresent = surfaceLayer != null;
IrisRiverState riverState = wantRiverState
? riverState(hydrology)
? riverState(role)
: IrisRiverState.NONE;
double riverDistance = wantRiverDistance && riverPresent
? engine.getComplex().getRiverDistanceStream().get(blockX, blockZ)
double riverDistance = wantRiverDistance
? riverDistance(role)
: IrisColumnSample.UNAVAILABLE_RIVER_DISTANCE;
int riverFlow = wantRiverFlow && riverPresent
? (int) Math.round(engine.getComplex().getRiverFlowStream().get(blockX, blockZ))
int riverFlow = wantRiverFlow
? riverFlow(role)
: IrisColumnSample.UNAVAILABLE_RIVER_FLOW;
int riverWaterSurfaceY = wantRiverWaterSurface
&& surfaceLayer != null
&& surfaceLayer.connectedFluid()
int riverWaterSurfaceY = wantRiverWaterSurface && role.wet()
? fluid + minHeight
: IrisColumnSample.UNAVAILABLE_HEIGHT;
sink.accept(new IrisColumnSample(
@@ -327,21 +320,25 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService {
}
}
static IrisRiverState riverState(HydrologyColumnSample hydrology) {
HydrologyColumnLayer layer = hydrology == null
? null
: hydrology.primarySurfaceLayer().orElse(null);
if (layer == null || !layer.channel()) {
return IrisRiverState.NONE;
}
return layer.connectedFluid() ? IrisRiverState.WET : IrisRiverState.DRY;
static IrisRiverState riverState(WaterRole role) {
return role.wet() ? IrisRiverState.WET : IrisRiverState.NONE;
}
private static HydrologyColumnSample hydrologySample(Engine engine, int blockX, int blockZ) {
if (engine.getComplex().getHydrologyRuntime() == null) {
return null;
static double riverDistance(WaterRole role) {
return switch (role) {
case NONE -> IrisColumnSample.UNAVAILABLE_RIVER_DISTANCE;
case CHANNEL, POND, MOUTH, CASCADE, WATERFALL, INLET -> 0D;
case SHORE, POND_SHORE -> 1D;
case BANK, DAM -> 2D;
};
}
return engine.getComplex().getHydrologyRuntime().sample(blockX, blockZ).orElse(null);
static int riverFlow(WaterRole role) {
return switch (role) {
case NONE, SHORE, POND_SHORE, BANK, DAM -> IrisColumnSample.UNAVAILABLE_RIVER_FLOW;
case CHANNEL, MOUTH, CASCADE, WATERFALL -> 1;
case POND, INLET -> 0;
};
}
private static Optional<String> key(IrisBiome biome) {
@@ -1,8 +1,7 @@
package art.arcane.iris.platform.bukkit.terrain;
import art.arcane.iris.api.terrain.IrisSurfaceKind;
import art.arcane.iris.generation.hydrology.HydrologyColumnLayer;
import art.arcane.iris.generation.hydrology.HydrologyColumnSample;
import art.arcane.iris.generation.hydrology.raster.WaterRole;
import art.arcane.iris.generation.terrain.InferredType;
public final class IrisSurfaceClassifier {
@@ -29,25 +28,16 @@ public final class IrisSurfaceClassifier {
int engineSurfaceHeight,
int engineFluidHeight,
InferredType inferredType,
HydrologyColumnSample hydrology
WaterRole role
) {
if (engineSurfaceHeight <= 0) {
return IrisSurfaceKind.VOID;
}
HydrologyColumnLayer layer = hydrology == null
? null
: hydrology.primarySurfaceLayer().orElse(null);
if (layer != null) {
if (layer.shore()) {
return IrisSurfaceKind.RIVER_SHORE;
}
if (layer.channel()) {
return layer.connectedFluid() ? IrisSurfaceKind.RIVER : IrisSurfaceKind.DRY_CHANNEL;
}
if (layer.grading()) {
return IrisSurfaceKind.LAND;
}
}
return classify(engineSurfaceHeight, engineFluidHeight, inferredType);
return switch (role) {
case NONE -> classify(engineSurfaceHeight, engineFluidHeight, inferredType);
case SHORE, POND_SHORE -> IrisSurfaceKind.RIVER_SHORE;
case BANK, DAM -> IrisSurfaceKind.LAND;
case CHANNEL, POND, MOUTH, CASCADE, WATERFALL, INLET -> IrisSurfaceKind.RIVER;
};
}
}
@@ -23,6 +23,8 @@ import art.arcane.iris.world.event.IrisEngineHotloadEvent;
import art.arcane.iris.world.IrisToolbelt;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.platform.generation.PlatformChunkGenerator;
import art.arcane.iris.studio.StudioSVC;
import art.arcane.iris.studio.workspace.IrisProject;
import org.bukkit.Bukkit;
import org.bukkit.World;
import org.bukkit.event.EventHandler;
@@ -40,10 +42,47 @@ public final class BukkitGuiHost implements GuiHost.Provider {
GuiHost.set(new BukkitGuiHost());
}
/**
* The engine of the open studio project, which reads the pack folder being edited, or else the first
* Iris world. A normal Iris world runs on its own copy of the pack, made when the world was created.
*/
@Override
public Engine findActiveEngine() {
Engine studioEngine = findStudioEngine();
if (studioEngine != null) {
return studioEngine;
}
try {
for (World world : new ArrayList<>(Bukkit.getWorlds())) {
Engine engine = engineOf(world);
if (engine != null) {
return engine;
}
}
} catch (Throwable ignored) {
}
return null;
}
@Override
public Engine findEngine(UUID worldId) {
World world = worldId == null ? null : Bukkit.getWorld(worldId);
return world == null ? null : engineOf(world);
}
private static Engine findStudioEngine() {
try {
StudioSVC studio = Iris.service(StudioSVC.class);
IrisProject project = studio == null || !studio.isProjectOpen() ? null : studio.getActiveProject();
PlatformChunkGenerator provider = project == null ? null : project.getActiveProvider();
Engine engine = provider == null ? null : provider.getEngine();
return engine == null || engine.isClosed() ? null : engine;
} catch (Throwable ignored) {
return null;
}
}
private static Engine engineOf(World world) {
try {
PlatformChunkGenerator access = IrisToolbelt.access(world);
if (access != null && access.getEngine() != null && !access.getEngine().isClosed()) {
@@ -51,9 +90,6 @@ public final class BukkitGuiHost implements GuiHost.Provider {
}
} catch (Throwable ignored) {
}
}
} catch (Throwable ignored) {
}
return null;
}
@@ -18,23 +18,19 @@
package art.arcane.iris.studio.view;
import art.arcane.volmlib.nativelib.view.WorldMarker;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.studio.render.RenderType;
import art.arcane.iris.world.IrisWorld;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.world.task.J;
import art.arcane.volmlib.util.format.Form;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import java.io.File;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@@ -56,7 +52,7 @@ public final class BukkitVisionOverlay implements GuiOverlay {
private final AtomicBoolean playerRefreshQueued = new AtomicBoolean();
private final AtomicLong teleportSequence = new AtomicLong();
private final AtomicReference<VisionTeleportRequest> latestTeleport = new AtomicReference<>();
private volatile List<WorldMarker> playerMarkers = List.of();
private volatile List<GuiMarker> playerMarkers = List.of();
public BukkitVisionOverlay(Engine engine, UUID openerId) {
this.engine = engine;
@@ -68,7 +64,7 @@ public final class BukkitVisionOverlay implements GuiOverlay {
* built by a server thread.
*/
@Override
public List<WorldMarker> players() {
public List<GuiMarker> players() {
queuePlayerRefresh();
return playerMarkers;
}
@@ -78,26 +74,48 @@ public final class BukkitVisionOverlay implements GuiOverlay {
return;
}
boolean scheduled = J.runGlobal(() -> {
try {
List<WorldMarker> markers = new ArrayList<>();
for (Player player : BukkitWorldBinding.players(engine.getWorld())) {
Location at = player.getLocation();
markers.add(WorldMarker.player(player.getName(), at.getX(), at.getZ()));
}
playerMarkers = List.copyOf(markers);
} finally {
playerRefreshQueued.set(false);
}
});
boolean scheduled = J.runGlobal(this::refreshPlayers);
if (!scheduled) {
playerRefreshQueued.set(false);
}
}
private void refreshPlayers() {
World world = BukkitWorldBinding.world(engine.getWorld());
List<Player> players = BukkitWorldBinding.players(engine.getWorld());
if (players.isEmpty() || world == null) {
playerMarkers = List.of();
playerRefreshQueued.set(false);
return;
}
List<GuiMarker> markers = Collections.synchronizedList(new ArrayList<>(players.size()));
AtomicInteger pending = new AtomicInteger(players.size());
for (Player player : players) {
AtomicBoolean completed = new AtomicBoolean();
Runnable complete = () -> {
if (completed.compareAndSet(false, true) && pending.decrementAndGet() == 0) {
playerMarkers = List.copyOf(markers);
playerRefreshQueued.set(false);
}
};
if (!J.runEntity(player, () -> {
try {
if (player.isOnline() && player.getWorld() == world) {
Location at = player.getLocation();
markers.add(GuiMarker.player(player.getName(), at.getX(), at.getZ(), at.getYaw()));
}
} finally {
complete.run();
}
}, 0, complete)) {
complete.run();
}
}
}
@Override
public void requestEntities(Consumer<List<WorldMarker>> sink) {
public void requestEntities(Consumer<List<GuiMarker>> sink) {
J.runGlobal(() -> {
IrisWorld target = engine.getWorld();
World world = BukkitWorldBinding.world(target);
@@ -118,7 +136,7 @@ public final class BukkitVisionOverlay implements GuiOverlay {
return;
}
List<WorldMarker> collected = Collections.synchronizedList(new ArrayList<>(living.size()));
List<GuiMarker> collected = Collections.synchronizedList(new ArrayList<>(living.size()));
AtomicInteger pending = new AtomicInteger(living.size());
Runnable complete = () -> {
if (pending.decrementAndGet() == 0) {
@@ -144,22 +162,22 @@ public final class BukkitVisionOverlay implements GuiOverlay {
});
}
private WorldMarker marker(LivingEntity entity, Location at) {
private GuiMarker marker(LivingEntity entity, Location at) {
String label = Form.capitalizeWords(entity.getType().name().toLowerCase(Locale.ROOT).replaceAll("\\Q_\\E", " "));
double maxHealth = 0;
try {
maxHealth = entity.getAttribute(MAX_HEALTH).getValue();
} catch (Throwable ignored) {
}
return WorldMarker.entity(label, at.getX(), at.getY(), at.getZ(), entity.getHealth(), maxHealth);
return GuiMarker.entity(label, at.getX(), at.getY(), at.getZ(), entity.getHealth(), maxHealth);
}
@Override
public void teleport(double worldX, double worldZ) {
VisionTeleportRequest request = new VisionTeleportRequest(
teleportSequence.incrementAndGet(),
VisionGUI.floorWorldCoordinate(worldX),
VisionGUI.floorWorldCoordinate(worldZ));
(int) StrictMath.floor(worldX),
(int) StrictMath.floor(worldZ));
latestTeleport.set(request);
startTeleport(request);
}
@@ -212,6 +230,46 @@ public final class BukkitVisionOverlay implements GuiOverlay {
}
}
@Override
public void requestOpenerPosition(Consumer<OpenerPosition> sink) {
AtomicBoolean completed = new AtomicBoolean();
Consumer<OpenerPosition> complete = position -> {
if (completed.compareAndSet(false, true)) {
sink.accept(position);
}
};
if (openerId == null) {
complete.accept(OpenerPosition.OFFLINE);
return;
}
boolean scheduled = J.runGlobal(() -> {
Player player = Bukkit.getPlayer(openerId);
if (player == null) {
complete.accept(OpenerPosition.OFFLINE);
return;
}
Runnable retired = () -> complete.accept(OpenerPosition.OFFLINE);
if (!J.runEntity(player, () -> complete.accept(positionOf(player,
BukkitWorldBinding.world(engine.getWorld()))), 0, retired)) {
retired.run();
}
});
if (!scheduled) {
complete.accept(OpenerPosition.OFFLINE);
}
}
private static OpenerPosition positionOf(Player player, World world) {
if (!player.isOnline()) {
return OpenerPosition.OFFLINE;
}
if (world == null || player.getWorld() != world) {
return OpenerPosition.IN_OTHER_WORLD;
}
Location at = player.getLocation();
return OpenerPosition.inWorld(at.getX(), at.getY(), at.getZ());
}
private Player selectPlayer(List<Player> players) {
if (openerId == null) {
return players.isEmpty() ? null : players.get(0);
@@ -316,21 +374,6 @@ public final class BukkitVisionOverlay implements GuiOverlay {
+ blockX + "," + blockZ + ".", failure);
}
@Override
public String openInEditor(double worldX, double worldZ, RenderType type) {
IrisComplex complex = engine.getComplex();
File file = switch (type) {
case BIOME, LAYER_LOAD, DECORATOR_LOAD, OBJECT_LOAD, HEIGHT, RIVER ->
complex.getTrueBiomeStream().get(worldX, worldZ).openInVSCode();
case BIOME_LAND -> complex.getLandBiomeStream().get(worldX, worldZ).openInVSCode();
case BIOME_SEA -> complex.getSeaBiomeStream().get(worldX, worldZ).openInVSCode();
case REGION -> complex.getRegionStream().get(worldX, worldZ).openInVSCode();
case CAVE_LAND -> complex.getCaveBiomeStream().get(worldX, worldZ).openInVSCode();
default -> null;
};
return file == null ? null : file.getName();
}
private static final class VisionTeleportRequest {
private final long sequence;
private final int blockX;
@@ -58,6 +58,7 @@ import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
@@ -78,6 +79,9 @@ public final class PendingWorldReplacementManager implements Listener {
private volatile UUID overworldEntryAwaitingVerification;
private volatile World overworldEntryWorld;
private volatile boolean paperEntryListenerRegistered;
private final Object stagingMonitor = new Object();
private boolean stagingAdmissionOpen = true;
private StagingAdmission activeStaging;
public PendingWorldReplacementManager(Iris plugin) {
this.plugin = Objects.requireNonNull(plugin, "plugin");
@@ -135,7 +139,8 @@ public final class PendingWorldReplacementManager implements Listener {
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
LifecycleOperationCoordinator.OperationKind.WORLD_REPLACE,
requiredWorldKey.toString()
); WorldReplacementProgress progress = WorldReplacementProgress.start(
); StagingAdmission ignoredAdmission = admitStaging(requiredWorldKey);
WorldReplacementProgress progress = WorldReplacementProgress.start(
requiredSender, requiredWorldKey.toString())) {
synchronized (this) {
IrisStartupValidation.requireWorldReplacementStagingReady();
@@ -271,6 +276,29 @@ public final class PendingWorldReplacementManager implements Listener {
}
}
public void awaitStagingForShutdown() {
StagingAdmission staging;
synchronized (stagingMonitor) {
stagingAdmissionOpen = false;
staging = activeStaging;
}
if (staging == null) {
return;
}
Iris.warn("Waiting for the replacement of " + staging.worldKey + " to finish staging before Iris shuts down.");
boolean interrupted = false;
while (staging.finished.getCount() > 0L) {
try {
staging.finished.await();
} catch (InterruptedException e) {
interrupted = true;
}
}
if (interrupted) {
Thread.currentThread().interrupt();
}
}
public synchronized void processPendingStartupReplacements() {
ArrayList<String> failures = new ArrayList<>();
ArrayList<String> restartBoundaries = new ArrayList<>();
@@ -1048,6 +1076,17 @@ public final class PendingWorldReplacementManager implements Listener {
return plugin.getDataFolder().toPath().toAbsolutePath().normalize();
}
private StagingAdmission admitStaging(NamespacedKey worldKey) throws IOException {
synchronized (stagingMonitor) {
if (!stagingAdmissionOpen) {
throw new IOException("Iris is shutting down. Run the replacement again after the server starts.");
}
StagingAdmission staging = new StagingAdmission(worldKey);
activeStaging = staging;
return staging;
}
}
private synchronized void loadOverworldEntryGuard() throws IOException {
ExactWorldSlotPathPolicy.Target target = resolveTarget(OVERWORLD_KEY);
Optional<WorldReplacementEntryGuard.Entry> loaded = WorldReplacementEntryGuard.load(target.worldDirectory());
@@ -1218,6 +1257,25 @@ public final class PendingWorldReplacementManager implements Listener {
record SavedLocationInspection(boolean safe, Throwable failure) {
}
private final class StagingAdmission implements AutoCloseable {
private final NamespacedKey worldKey;
private final CountDownLatch finished = new CountDownLatch(1);
private StagingAdmission(NamespacedKey worldKey) {
this.worldKey = worldKey;
}
@Override
public void close() {
synchronized (stagingMonitor) {
if (activeStaging == this) {
activeStaging = null;
}
}
finished.countDown();
}
}
private static final class RestartBoundaryRequired extends IOException {
private RestartBoundaryRequired(String message) {
super(message);
@@ -27,3 +27,4 @@ permissions:
description: Allows survival players to fell Iris-managed trees with an axe.
default: op
api-version: '${apiVersion}'
libraries: [ ${libraries} ]
@@ -0,0 +1,72 @@
import { readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { setup, openStudio, closeStudio, sampleTerrain, readJson, writeJson,
logPosition, logSince } from './studio-support.mjs'
async function hotload(context, fixture, active, command) {
const before = await readJson(fixture.manifestPath(active.key))
const response = await context.command(command, /^Studio hotloaded\.$|^Studio hotload failed:/, 90000)
context.expect(response === 'Studio hotloaded.', 'The manual Studio command did not complete successfully', { command, response })
const after = await readJson(fixture.manifestPath(active.key))
context.expect(after.pendingActivationId === null, 'The manual hotload completed with a pending activation')
context.report.iris.manualHotloads.push({ command, response,
before: before.activeActivationId, after: after.activeActivationId })
return after
}
export default {
name: 'iris-studio-manual-hotload',
description: 'Refresh an open Studio through hotload and its refresh alias, verify terrain, and reject commands without a Studio.',
async run(context) {
const fixture = await setup(context)
const biomeFile = path.join(fixture.pack, 'biomes', 'flat.json')
const original = await readFile(biomeFile, 'utf8')
const logOffset = await logPosition(fixture)
const processId = (await context.observe()).processId
context.report.iris.manualHotloads = []
let active
try {
await context.step('Reject hotload without an open Studio', async () => {
await context.command('/iris studio hotload', /^No open studio projects\.$/, 10000)
})
await context.step('Open Studio and hotload an unchanged pack', async () => {
active = await openStudio(context, fixture)
await sampleTerrain(context, fixture, 0, fixture.state.material)
await hotload(context, fixture, active, '/iris studio hotload')
await sampleTerrain(context, fixture, 4096, fixture.state.material)
})
await context.step('Refresh an edited pack and inspect newly generated terrain', async () => {
const before = await readJson(fixture.manifestPath(active.key))
const biome = await readJson(biomeFile)
biome.name = 'Manual hotload terrain'
biome.layers[0].palette[0].block = 'minecraft:gold_block'
await writeJson(biomeFile, biome)
await hotload(context, fixture, active, '/iris studio refresh')
const after = await fixture.wait(async () => {
const manifest = await readJson(fixture.manifestPath(active.key))
return manifest.activeActivationId !== before.activeActivationId && manifest.pendingActivationId === null ? manifest : false
}, 'The edited Studio pack did not become active', 90000)
context.report.iris.activations.push({ before: before.activeActivationId,
after: after.activeActivationId, material: 'gold_block' })
await sampleTerrain(context, fixture, 16384, 'gold_block')
await sampleTerrain(context, fixture, 0, fixture.state.material)
})
await context.step('Close Studio and reject another refresh', async () => {
await closeStudio(context, fixture, active)
active = undefined
await context.command('/iris studio refresh', /^No open studio projects\.$/, 10000)
await context.command('/minecraft:seed', /Seed:/i, 10000)
})
context.expect((await context.observe()).processId === processId, 'The server restarted during manual Studio hotload')
const log = await logSince(fixture, logOffset)
context.expect(!/Studio generation update failed|Studio hotload failed|Unable to (capture terrain|save the terrain boundary)|ClosedChannelException|Chunk system error|server has stopped responding|could not acquire its engine runtime|Failed to list files/i.test(log),
'Manual Studio hotload emitted a generation, snapshot, storage, or watchdog failure')
} finally {
try {
if (active) await closeStudio(context, fixture, active)
} finally {
await writeFile(biomeFile, original)
}
}
}
}
@@ -148,6 +148,9 @@ public class FailedEnableLockTest {
try (EnableFixture fixture = new EnableFixture(false)) {
IllegalStateException failure = assertThrows(IllegalStateException.class, plugin::onEnable);
assertTrue(failure.getMessage(), failure.getMessage().contains("already bound"));
Field failed = Iris.class.getDeclaredField("enableFailed");
failed.setAccessible(true);
assertTrue(failed.getBoolean(plugin));
}
}
@@ -0,0 +1,80 @@
package art.arcane.iris;
import art.arcane.iris.configuration.IrisSettings;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.mockito.MockedStatic;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.PrintStream;
import java.lang.reflect.Field;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
public class IrisErrorReportingTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void debugReportingPreservesStacklessFailures() throws Exception {
ArrayIndexOutOfBoundsException failure = new ArrayIndexOutOfBoundsException("raster index");
failure.setStackTrace(new StackTraceElement[0]);
String output = reportWithDebug(failure);
assertEquals(1L, output.lines().filter(line -> line.contains("raster index")).count());
}
@Test
public void debugReportingPreservesFailureWithStacklessCause() throws Exception {
ArrayIndexOutOfBoundsException cause = new ArrayIndexOutOfBoundsException("raster index");
cause.setStackTrace(new StackTraceElement[0]);
IllegalStateException failure = new IllegalStateException("Hydrology failed", cause);
String output = reportWithDebug(failure);
assertEquals(1L, output.lines().filter(line -> line.contains("Hydrology failed")).count());
assertTrue(output.contains("Caused by: java.lang.ArrayIndexOutOfBoundsException: raster index"));
assertTrue(output.contains("IrisErrorReportingTest.debugReportingPreservesFailureWithStacklessCause"));
}
private String reportWithDebug(Throwable failure) throws Exception {
Iris previousPlugin = Iris.instance;
IrisSettings previousSettings = IrisSettings.settings;
PrintStream previousError = System.err;
Iris plugin = mock(Iris.class);
Field deferred = Iris.class.getDeclaredField("serverStopTeardownDeferred");
deferred.setAccessible(true);
deferred.set(plugin, new AtomicBoolean());
File report = new File(temporaryFolder.getRoot(), "failure.txt");
when(plugin.getDataFile(any(String[].class))).thenReturn(report);
ByteArrayOutputStream output = new ByteArrayOutputStream();
try (PrintStream errors = new PrintStream(output, true, StandardCharsets.UTF_8);
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
Iris.instance = plugin;
IrisSettings.settings = new IrisSettings();
IrisSettings.settings.getGeneral().setDebug(true);
System.setErr(errors);
iris.when(() -> Iris.reportError(failure)).thenCallRealMethod();
Iris.reportError(failure);
assertTrue(Files.readString(report.toPath()).contains(failure.toString()));
return output.toString(StandardCharsets.UTF_8);
} finally {
System.setErr(previousError);
IrisSettings.settings = previousSettings;
Iris.instance = previousPlugin;
}
}
}
@@ -4,9 +4,13 @@ import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import static org.junit.Assert.assertEquals;
@@ -31,4 +35,19 @@ public class IrisPluginLoaderTest {
assertEquals(List.of(relocatedCaffeine, relocatedGson), libraries);
}
@Test
public void readsMavenLibrariesFromTheSpigotDescriptorInThePluginJar() throws Exception {
Path jar = temporaryFolder.getRoot().toPath().resolve("Iris.jar");
try (OutputStream file = Files.newOutputStream(jar); ZipOutputStream zip = new ZipOutputStream(file)) {
zip.putNextEntry(new ZipEntry("plugin.yml"));
zip.write("name: Iris\nlibraries: [ 'org.lwjgl:lwjgl:3.4.3', 'org.lwjgl:lwjgl:jar:natives-linux:3.4.3' ]\n"
.getBytes(StandardCharsets.UTF_8));
zip.closeEntry();
}
List<String> libraries = IrisPluginLoader.mavenLibraries(jar);
assertEquals(List.of("org.lwjgl:lwjgl:3.4.3", "org.lwjgl:lwjgl:jar:natives-linux:3.4.3"), libraries);
}
}
@@ -1,7 +1,17 @@
package art.arcane.iris;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.generation.concurrent.MultiBurst;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.platform.agent.Agent;
import art.arcane.iris.platform.bootstrap.SlimJar;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.platform.bukkit.plugin.IrisService;
import art.arcane.iris.platform.bukkit.plugin.VolmitPlugin;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.world.lifecycle.IrisRuntimeStatics;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.collection.KMap;
import art.arcane.iris.generation.runtime.GenerationSessionLease;
import art.arcane.iris.generation.runtime.GenerationSessionManager;
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
@@ -17,6 +27,7 @@ import art.arcane.iris.world.WorldMaintenance;
import art.arcane.iris.world.runtime.BukkitEnginePlatformHooks;
import art.arcane.iris.world.task.J;
import art.arcane.volmlib.nativelib.terrain.ServerShutdownBoundary;
import art.arcane.volmlib.integration.ReloadAware;
import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey;
import org.bukkit.World;
@@ -26,10 +37,13 @@ import org.mockito.MockedStatic;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.InvocationTargetException;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
@@ -37,10 +51,16 @@ import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doCallRealMethod;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
@@ -109,8 +129,6 @@ public class IrisShutdownMaintenanceTest {
}
verify(fixture.generator).quiesceForServerShutdown();
verify(fixture.generator, never()).closeAsync();
fixture.nms.verify(INMS::get);
verify(fixture.binding).deferPluginClassLoaderClose();
verify(fixture.binding, never()).releasePluginClassLoaderClose();
}
assertEquals(0, sessions.activeLeases());
@@ -151,6 +169,229 @@ public class IrisShutdownMaintenanceTest {
}
}
@Test
public void startupEnsuresAgentReadinessBeforeLifecycleProtectionWithoutWorldInjection() throws Exception {
try (Fixture fixture = new Fixture(); MockedStatic<Agent> agent = mockStatic(Agent.class)) {
when(fixture.binding.supportsIrisWorldGeneration()).thenReturn(true);
agent.when(Agent::install).thenReturn(true);
doAnswer(invocation -> {
agent.verify(Agent::install);
assertSame(fixture.plugin.getClass().getClassLoader(), invocation.getArgument(0));
return null;
}).when(fixture.binding).initializeLifecycle(fixture.plugin.getClass().getClassLoader());
invoke(fixture.plugin, "initializeShutdownProtection");
verify(fixture.binding).initializeLifecycle(fixture.plugin.getClass().getClassLoader());
verify(fixture.binding, never()).injectBukkit();
}
}
@Test
public void unavailableAgentStopsLifecycleInitialization() throws Exception {
try (Fixture fixture = new Fixture(); MockedStatic<Agent> agent = mockStatic(Agent.class)) {
when(fixture.binding.supportsIrisWorldGeneration()).thenReturn(true);
InvocationTargetException failure = assertThrows(InvocationTargetException.class,
() -> invoke(fixture.plugin, "initializeShutdownProtection"));
assertTrue(failure.getCause() instanceof IllegalStateException);
verify(fixture.binding, never()).initializeLifecycle(fixture.plugin.getClass().getClassLoader());
}
}
@Test
public void worldHookTeardownFailureReleasesProtectionAfterServicesAndWorkersStop() throws Exception {
try (Fixture fixture = new Fixture();
MockedStatic<Iris> iris = mockStatic(Iris.class);
MockedStatic<IrisRuntimeStatics> statics = mockStatic(IrisRuntimeStatics.class);
MockedStatic<IrisPlatforms> platforms = mockStatic(IrisPlatforms.class);
MockedStatic<BukkitPlatform> bukkitPlatform = mockStatic(BukkitPlatform.class);
MockedStatic<SlimJar> providers = mockStatic(SlimJar.class)) {
IrisService service = mock(IrisService.class);
IllegalStateException failure = new IllegalStateException("world hook teardown failed");
fixture.nms.when(INMS::isBound).thenReturn(true);
doThrow(failure).when(fixture.binding).uninjectBukkit();
setField(fixture.plugin, "services", new KMap<Class<? extends IrisService>, IrisService>());
setField(fixture.plugin, "enabledServices", List.of(service));
doAnswer(invocation -> {
platforms.verify(IrisPlatforms::unbind);
bukkitPlatform.verify(BukkitPlatform::releaseHost);
assertTrue(MultiBurst.burst.isTerminated());
assertTrue(MultiBurst.hydrology.isTerminated());
assertTrue(MultiBurst.ioBurst.isTerminated());
return null;
}).when(fixture.binding).releasePluginClassLoaderClose();
fixture.quiesce();
try {
fixture.finish(5L);
fixture.finish(5L);
verify(service).onDisable();
iris.verify(() -> Iris.reportError("Failed to remove Iris world-generation hooks.", failure));
assertTrue(MultiBurst.burst.isTerminated());
assertTrue(MultiBurst.hydrology.isTerminated());
assertTrue(MultiBurst.ioBurst.isTerminated());
verify(fixture.binding).releasePluginClassLoaderClose();
platforms.verify(IrisPlatforms::unbind);
bukkitPlatform.verify(BukkitPlatform::releaseHost);
} finally {
MultiBurst.burst.reopen();
MultiBurst.hydrology.reopen();
MultiBurst.ioBurst.reopen();
}
}
}
@Test
public void failedServiceWithAnOwnedExecutorRetainsTheClassLoader() throws Exception {
ExecutorService ownedExecutor = Executors.newSingleThreadExecutor();
CountDownLatch started = new CountDownLatch(1);
CountDownLatch finish = new CountDownLatch(1);
ownedExecutor.submit(() -> {
started.countDown();
finish.await();
return null;
});
try (Fixture fixture = new Fixture();
MockedStatic<Iris> iris = mockStatic(Iris.class);
MockedStatic<IrisRuntimeStatics> statics = mockStatic(IrisRuntimeStatics.class);
MockedStatic<IrisPlatforms> platforms = mockStatic(IrisPlatforms.class);
MockedStatic<BukkitPlatform> bukkitPlatform = mockStatic(BukkitPlatform.class)) {
assertTrue(started.await(5, TimeUnit.SECONDS));
IrisService service = mock(IrisService.class);
IllegalStateException failure = new IllegalStateException("service teardown failed");
doThrow(failure).when(service).onDisable();
setField(fixture.plugin, "services", new KMap<Class<? extends IrisService>, IrisService>());
setField(fixture.plugin, "enabledServices", List.of(service));
fixture.quiesce();
fixture.finish(5L);
verify(service).onDisable();
iris.verify(() -> Iris.reportError("Failed to disable " + service.getClass().getSimpleName() + ".", failure));
assertFalse(ownedExecutor.isTerminated());
verify(fixture.binding, never()).releasePluginClassLoaderClose();
} finally {
finish.countDown();
ownedExecutor.shutdownNow();
assertTrue(ownedExecutor.awaitTermination(5, TimeUnit.SECONDS));
MultiBurst.burst.reopen();
MultiBurst.hydrology.reopen();
MultiBurst.ioBurst.reopen();
}
}
@Test
public void failedPostShutdownCallbackRetainsTheClassLoaderForSkippedCleanup() throws Exception {
ExecutorService ownedExecutor = Executors.newSingleThreadExecutor();
CountDownLatch started = new CountDownLatch(1);
CountDownLatch finish = new CountDownLatch(1);
ownedExecutor.submit(() -> {
started.countDown();
finish.await();
return null;
});
try (Fixture fixture = new Fixture();
MockedStatic<Iris> iris = mockStatic(Iris.class);
MockedStatic<IrisRuntimeStatics> statics = mockStatic(IrisRuntimeStatics.class);
MockedStatic<IrisPlatforms> platforms = mockStatic(IrisPlatforms.class);
MockedStatic<BukkitPlatform> bukkitPlatform = mockStatic(BukkitPlatform.class)) {
assertTrue(started.await(5, TimeUnit.SECONDS));
IllegalStateException failure = new IllegalStateException("post-shutdown callback failed");
AtomicBoolean cleanupAttempted = new AtomicBoolean();
KList<Runnable> callbacks = new KList<>();
callbacks.add(() -> { throw failure; });
callbacks.add(() -> {
cleanupAttempted.set(true);
ownedExecutor.shutdown();
});
Field callbacksField = VolmitPlugin.class.getDeclaredField("postShutdown");
callbacksField.setAccessible(true);
callbacksField.set(fixture.plugin, callbacks);
Method runCallbacks = VolmitPlugin.class.getDeclaredMethod("runPostShutdown");
runCallbacks.setAccessible(true);
runCallbacks.invoke(doCallRealMethod().when(fixture.plugin));
fixture.quiesce();
fixture.finish(5L);
iris.verify(() -> Iris.reportError("Failed to run Iris post-shutdown cleanup.", failure));
assertFalse(cleanupAttempted.get());
assertFalse(ownedExecutor.isShutdown());
assertFalse(ownedExecutor.isTerminated());
verify(fixture.binding, never()).releasePluginClassLoaderClose();
} finally {
finish.countDown();
ownedExecutor.shutdownNow();
assertTrue(ownedExecutor.awaitTermination(5, TimeUnit.SECONDS));
MultiBurst.burst.reopen();
MultiBurst.hydrology.reopen();
MultiBurst.ioBurst.reopen();
}
}
@Test
public void incompleteDrainRetainsServicesWorkersAndClassLoader() throws Exception {
try (Fixture fixture = new Fixture(); MockedStatic<Iris> iris = mockStatic(Iris.class)) {
IrisService service = mock(IrisService.class);
setField(fixture.plugin, "services", new KMap<Class<? extends IrisService>, IrisService>());
setField(fixture.plugin, "enabledServices", List.of(service));
when(fixture.generator.closeAsync()).thenReturn(new CompletableFuture<>());
fixture.quiesce();
fixture.finish(0L);
verify(service, never()).onDisable();
verify(fixture.binding, never()).releasePluginClassLoaderClose();
verify(fixture.binding, never()).uninjectBukkit();
}
}
@Test
public void serverStopPreUnloadDefersTeardownWhileTheClassLoaderIsHeld() throws Exception {
try (Fixture fixture = new Fixture()) {
setField(fixture.plugin, "postStopFinisherStarted", new AtomicBoolean(true));
try {
fixture.commitReloadDuringServerStop();
verify(fixture.binding).deferPluginClassLoaderClose();
verify(fixture.generator).quiesceForServerShutdown();
verify(fixture.generator, never()).closeAsync();
assertTrue(fixture.serverStopTeardownDeferred());
} finally {
WorldMaintenance.endWorldMaintenance(fixture.identity, "pre-unload:HOT_UNLOAD");
}
}
}
@Test
public void serverStopPreUnloadDrainsInlineWhenTheClassLoaderCannotBeHeld() throws Exception {
try (Fixture fixture = new Fixture(); MockedStatic<Iris> iris = mockStatic(Iris.class)) {
IllegalStateException failure = new IllegalStateException("class loader hold failed");
doThrow(failure).when(fixture.binding).deferPluginClassLoaderClose();
try {
fixture.commitReloadDuringServerStop();
verify(fixture.generator).closeAsync();
verify(fixture.generator, never()).quiesceForServerShutdown();
assertFalse(fixture.serverStopTeardownDeferred());
iris.verify(() -> Iris.reportError("Failed to retain the Iris plugin class loader; draining shutdown inline.", failure));
} finally {
WorldMaintenance.endWorldMaintenance(fixture.identity, "pre-unload:HOT_UNLOAD", true);
}
}
}
@Test
public void serverStopPreUnloadDrainsInlineWithoutInitializedProtection() throws Exception {
try (Fixture fixture = new Fixture()) {
setField(fixture.plugin, "shutdownProtectionInitialized", false);
try {
fixture.commitReloadDuringServerStop();
verify(fixture.binding, never()).deferPluginClassLoaderClose();
verify(fixture.generator).closeAsync();
verify(fixture.generator, never()).quiesceForServerShutdown();
assertFalse(fixture.serverStopTeardownDeferred());
} finally {
WorldMaintenance.endWorldMaintenance(fixture.identity, "pre-unload:HOT_UNLOAD", true);
}
}
}
private static void invoke(Iris plugin, String name) throws ReflectiveOperationException {
Method method = Iris.class.getDeclaredMethod(name);
method.setAccessible(true);
method.invoke(plugin);
}
private static void setField(Iris plugin, String name, Object value) throws ReflectiveOperationException {
Field field = Iris.class.getDeclaredField(name);
field.setAccessible(true);
@@ -178,6 +419,12 @@ public class IrisShutdownMaintenanceTest {
private Fixture() throws ReflectiveOperationException {
IrisSettings.settings = new IrisSettings();
setField(plugin, "shutdownProtectionInitialized", true);
setField(plugin, "alreadyDrained", new AtomicBoolean());
setField(plugin, "servicesDisabled", new AtomicBoolean());
setField(plugin, "runtimeTeardownFailed", new AtomicBoolean());
setField(plugin, "sharedRuntimeClosed", new AtomicBoolean());
setField(plugin, "terminalCleanupCompleted", new AtomicBoolean());
setField(plugin, "serverStopTeardownDeferred", new AtomicBoolean());
setField(plugin, "postStopFinisherStarted", new AtomicBoolean());
setField(plugin, "deferredShutdownGenerators", new CopyOnWriteArrayList<PlatformChunkGenerator>());
@@ -206,6 +453,26 @@ public class IrisShutdownMaintenanceTest {
method.invoke(plugin, "manual_disable", 5L);
}
private void commitReloadDuringServerStop() throws ReflectiveOperationException {
setField(plugin, "alreadyDrained", new AtomicBoolean());
toolbelt.when(IrisToolbelt::isServerStopping).thenReturn(true);
doCallRealMethod().when(plugin).commitReload(any());
plugin.commitReload(ReloadAware.PreUnloadReason.HOT_UNLOAD);
}
private boolean serverStopTeardownDeferred() throws ReflectiveOperationException {
Field field = Iris.class.getDeclaredField("serverStopTeardownDeferred");
field.setAccessible(true);
return ((AtomicBoolean) field.get(plugin)).get();
}
private void finish(long timeoutSeconds) throws ReflectiveOperationException {
Method method = Iris.class.getDeclaredMethod("finishDeferredRuntimeTeardown", String.class, long.class);
method.setAccessible(true);
method.invoke(plugin, "test_shutdown", timeoutSeconds);
}
private void startFinisher() throws ReflectiveOperationException {
Method method = Iris.class.getDeclaredMethod("startPostStopFinisher");
method.setAccessible(true);
@@ -1,7 +1,7 @@
package art.arcane.iris.command;
import art.arcane.iris.Iris;
import art.arcane.iris.generation.hydrology.HydrologyFeatureType;
import art.arcane.iris.generation.hydrology.query.HydrologyFeatureType;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.localization.IrisLanguage;
@@ -22,6 +22,7 @@ import java.util.OptionalLong;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
@@ -45,7 +46,7 @@ public class CommandFindHydrologyTest {
@Test
public void worldChangeDuringSearchDoesNotLoadOrTeleportIntoTheNewWorld() {
try (Fixture fixture = new Fixture()) {
fixture.command.river("surface", true);
fixture.command.river("river", true);
when(fixture.player.getWorld()).thenReturn(mock(World.class));
fixture.search.get().run();
@@ -58,7 +59,7 @@ public class CommandFindHydrologyTest {
@Test
public void disconnectDuringSearchDoesNotLoadTheDestination() {
try (Fixture fixture = new Fixture()) {
fixture.command.river("surface", true);
fixture.command.river("river", true);
when(fixture.player.isOnline()).thenReturn(false);
fixture.search.get().run();
@@ -69,7 +70,7 @@ public class CommandFindHydrologyTest {
@Test
public void worldChangeWhileLoadingTheDestinationCancelsTeleport() {
try (Fixture fixture = new Fixture()) {
fixture.command.river("surface", true);
fixture.command.river("river", true);
fixture.search.get().run();
when(fixture.player.getWorld()).thenReturn(mock(World.class));
fixture.chunk.complete(mock(Chunk.class));
@@ -82,7 +83,7 @@ public class CommandFindHydrologyTest {
@Test
public void successIsReportedOnlyAfterTeleportCompletes() {
try (Fixture fixture = new Fixture()) {
fixture.command.river("surface", true);
fixture.command.river("river", true);
fixture.search.get().run();
fixture.chunk.complete(mock(Chunk.class));
@@ -95,7 +96,7 @@ public class CommandFindHydrologyTest {
@Test
public void rejectedTeleportReportsFailureWithoutSuccess() {
try (Fixture fixture = new Fixture()) {
fixture.command.river("surface", true);
fixture.command.river("river", true);
fixture.search.get().run();
fixture.chunk.complete(mock(Chunk.class));
fixture.teleport.complete(false);
@@ -109,7 +110,7 @@ public class CommandFindHydrologyTest {
public void failedTeleportReportsTheFullCauseWithoutSuccess() {
try (Fixture fixture = new Fixture()) {
IllegalStateException failure = new IllegalStateException("destination unloaded");
fixture.command.river("surface", true);
fixture.command.river("river", true);
fixture.search.get().run();
fixture.chunk.complete(mock(Chunk.class));
fixture.teleport.completeExceptionally(failure);
@@ -120,6 +121,72 @@ public class CommandFindHydrologyTest {
}
}
@Test
public void everyFeatureTypeIsSearchedOffTheCommandThreadAtTheRuntimeRadius() {
for (HydrologyFeatureType type : HydrologyFeatureType.values()) {
try (Fixture fixture = new Fixture()) {
fixture.command.river(type.key(), false);
fixture.queries.verify(() -> GenerationSemanticQueries.nearestRiver(any(), any(), anyInt(), anyInt(), anyInt()), never());
fixture.search.get().run();
fixture.queries.verify(() -> GenerationSemanticQueries.nearestRiver(fixture.engine, type, 0, 0,
GenerationSemanticQueries.RIVER_SEARCH_DISTANCE));
verify(fixture.sender).sendMessage(contains("Found " + type.key().replace('_', ' ') + " at 32, 63, 32."));
}
}
}
@Test
public void unknownTypeIsRejectedWithTheAcceptedTypesBeforeAnySearch() {
try (Fixture fixture = new Fixture()) {
fixture.command.river("surface", true);
verify(fixture.sender).sendMessage(contains("Use one of: stream, river, major_river, cascade, waterfall, mouth, pond, lake, dam, sea_inlet, cave_pool."));
assertNull(fixture.search.get());
}
}
@Test
public void missingCavePoolIsReportedAsNotFoundInGeneratedChunks() {
try (Fixture fixture = new Fixture()) {
fixture.queries.when(() -> GenerationSemanticQueries.nearestRiver(eq(fixture.engine), any(), eq(0), eq(0), anyInt()))
.thenReturn(Optional.empty());
fixture.command.river("cave_pool", true);
fixture.search.get().run();
verify(fixture.sender).sendMessage(contains("Searching generated chunks for the nearest cave pool within 7168 blocks"));
verify(fixture.sender).sendMessage(contains("No cave pool found in generated chunks within 7168 blocks."));
}
}
@Test
public void missingFeatureIsReportedAsNotFound() {
try (Fixture fixture = new Fixture()) {
fixture.queries.when(() -> GenerationSemanticQueries.nearestRiver(eq(fixture.engine), any(), eq(0), eq(0), anyInt()))
.thenReturn(Optional.empty());
fixture.command.river("sea_inlet", true);
fixture.search.get().run();
verify(fixture.sender).sendMessage(contains("No sea inlet found within 7168 blocks."));
fixture.platform.verify(() -> BukkitPlatform.chunkAtAsync(any(World.class), anyInt(), anyInt(), eq(true)), never());
}
}
@Test
public void plannerFailureIsReportedAsAnErrorNotAsNotFound() {
try (Fixture fixture = new Fixture()) {
IllegalStateException failure = new IllegalStateException("window planner exploded");
fixture.queries.when(() -> GenerationSemanticQueries.nearestRiver(eq(fixture.engine), any(), eq(0), eq(0), anyInt()))
.thenThrow(failure);
fixture.command.river("lake", true);
fixture.search.get().run();
verify(fixture.sender).sendMessage(contains("Could not search for lake: IllegalStateException - window planner exploded"));
verify(fixture.sender, never()).sendMessage(contains("No lake found"));
fixture.logging.verify(() -> Iris.reportError(any(String.class), eq(failure)));
}
}
private static final class Fixture implements AutoCloseable {
private final CommandFind command = mock(CommandFind.class, CALLS_REAL_METHODS);
private final Engine engine = mock(Engine.class);
@@ -132,7 +199,7 @@ public class CommandFindHydrologyTest {
private final CompletableFuture<Boolean> teleport = new CompletableFuture<>();
private final MockedStatic<J> scheduler = mockStatic(J.class);
private final MockedStatic<BukkitPlatform> platform = mockStatic(BukkitPlatform.class);
private final MockedStatic<GenerationSemanticQueries> queries = mockStatic(GenerationSemanticQueries.class);
private final MockedStatic<GenerationSemanticQueries> queries = mockStatic(GenerationSemanticQueries.class, CALLS_REAL_METHODS);
private final MockedStatic<IrisLanguage> language = mockStatic(IrisLanguage.class, invocation -> "teleported");
private final MockedStatic<Iris> logging = mockStatic(Iris.class);
@@ -160,10 +227,10 @@ public class CommandFindHydrologyTest {
});
platform.when(() -> BukkitPlatform.chunkAtAsync(any(World.class), anyInt(), anyInt(), eq(true))).thenReturn(chunk);
platform.when(() -> BukkitPlatform.teleportAsync(eq(player), any(Location.class))).thenReturn(teleport);
queries.when(() -> GenerationSemanticQueries.nearestRiver(eq(engine), any(), eq(0), eq(0), anyInt(), any()))
queries.when(() -> GenerationSemanticQueries.nearestRiver(eq(engine), any(), eq(0), eq(0), anyInt()))
.thenReturn(Optional.of(new GenerationSemanticQueries.RiverResult(
GenerationSemanticQueries.RiverSource.ACTIVE_PREDICTION,
HydrologyFeatureType.RIFFLE, Optional.empty(), 1L, 32, 63, 32, OptionalLong.empty())));
HydrologyFeatureType.RIVER, 1L, 32, 63, 32, OptionalLong.empty())));
}
@Override
@@ -20,26 +20,26 @@ import static org.mockito.Mockito.when;
public class CommandObjectFluidHeightTest {
@Test
public void absoluteObjectPlacementShiftsTheColumnRiverHeadFromNegativeMinY() {
public void absoluteObjectPlacementShiftsTheColumnWaterHeadFromNegativeMinY() {
World world = mock(World.class);
Engine engine = mock(Engine.class);
IrisDimension dimension = mock(IrisDimension.class);
IrisComplex complex = mock(IrisComplex.class);
@SuppressWarnings("unchecked")
ProceduralStream<Double> riverHead = mock(ProceduralStream.class);
ProceduralStream<Double> waterHead = mock(ProceduralStream.class);
Map<Block, BlockData> future = new HashMap<>();
when(engine.getMinHeight()).thenReturn(-64);
when(engine.getDimension()).thenReturn(dimension);
when(engine.getComplex()).thenReturn(complex);
when(dimension.getFluidHeight()).thenReturn(127);
when(complex.getRiverWaterSurfaceStream()).thenReturn(riverHead);
when(riverHead.get(12, -7)).thenReturn(131D);
when(complex.getWaterHeadStream()).thenReturn(waterHead);
when(waterHead.get(12, -7)).thenReturn(131D);
IObjectPlacer placer = CommandObject.createPlacer(world, future, engine);
assertEquals(63, placer.getFluidHeight());
assertEquals(67, placer.getFluidHeight(12, -7));
verify(riverHead).get(12, -7);
verify(waterHead).get(12, -7);
}
}
@@ -0,0 +1,112 @@
package art.arcane.iris.command;
import art.arcane.iris.Iris;
import art.arcane.iris.platform.bukkit.plugin.VolmitSender;
import art.arcane.iris.platform.generation.PlatformChunkGenerator;
import art.arcane.iris.studio.StudioSVC;
import art.arcane.iris.studio.workspace.IrisProject;
import art.arcane.volmlib.util.director.annotations.Director;
import art.arcane.volmlib.util.localization.LanguageAudience;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.lang.reflect.Method;
import java.io.File;
import java.util.concurrent.CompletableFuture;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class CommandStudioHotloadTest {
@Test
public void hotloadAndRefreshReportWhenNoStudioIsOpen() throws Exception {
RecordingSender sender = new RecordingSender();
StudioSVC studio = mock(StudioSVC.class);
Method hotload = CommandStudio.class.getDeclaredMethod("hotload");
assertTrue(List.of(hotload.getAnnotation(Director.class).aliases()).contains("refresh"));
try (MockedStatic<Iris> iris = mockStatic(Iris.class)) {
iris.when(() -> Iris.service(StudioSVC.class)).thenReturn(studio);
hotload.invoke(new TestCommand(sender));
assertTrue(sender.messages.getLast(), sender.messages.getLast().contains("No open studio"));
}
}
@Test
public void completionFeedbackWaitsForTheHotload() {
assertCompletionFeedback(null, "Studio hotloaded.");
}
@Test
public void failedHotloadReportsFailureAndLogsTheCause() {
assertCompletionFeedback(new IllegalStateException("Invalid pack"), "Studio hotload failed: Invalid pack");
}
private void assertCompletionFeedback(Throwable failure, String expected) {
RecordingSender sender = new RecordingSender();
StudioSVC studio = mock(StudioSVC.class);
PlatformChunkGenerator generator = mock(PlatformChunkGenerator.class);
IrisProject project = new IrisProject(new File("tutorial"));
project.setActiveProvider(generator);
CompletableFuture<Void> pending = new CompletableFuture<>();
UUID audience = UUID.randomUUID();
when(studio.getActiveProject()).thenReturn(project);
when(generator.hotloadAsync()).thenReturn(pending);
try (MockedStatic<Iris> iris = mockStatic(Iris.class)) {
iris.when(() -> Iris.service(StudioSVC.class)).thenReturn(studio);
LanguageAudience.run(audience, () -> new TestCommand(sender).hotload());
assertNull(LanguageAudience.current());
verify(generator).hotloadAsync();
assertEquals(1, sender.messages.size());
assertTrue(sender.messages.getFirst().contains("Hotloading Studio"));
if (failure == null) {
pending.complete(null);
} else {
pending.completeExceptionally(failure);
iris.verify(() -> Iris.reportError(failure));
}
assertEquals(2, sender.messages.size());
assertTrue(sender.messages.getLast(), sender.messages.getLast().contains(expected));
assertEquals(List.of(audience, audience), sender.audiences);
assertNull(LanguageAudience.current());
}
}
private static final class TestCommand extends CommandStudio {
private final VolmitSender sender;
private TestCommand(VolmitSender sender) {
this.sender = sender;
}
@Override
public VolmitSender sender() {
return sender;
}
}
private static final class RecordingSender extends VolmitSender {
private final List<String> messages = new ArrayList<>();
private final List<UUID> audiences = new ArrayList<>();
private RecordingSender() {
super(null);
}
@Override
public void sendMessage(String message) {
messages.add(message);
audiences.add(LanguageAudience.current());
}
}
}
@@ -0,0 +1,99 @@
package art.arcane.iris.command;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.platform.bukkit.plugin.VolmitSender;
import art.arcane.iris.platform.generation.PlatformChunkGenerator;
import art.arcane.iris.studio.map.MapWindow;
import art.arcane.iris.studio.map.PaletteExplorerStart;
import art.arcane.iris.studio.view.GuiHost;
import art.arcane.iris.world.IrisToolbelt;
import art.arcane.iris.world.task.J;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
public class StudioMapPositionTest {
@Test
public void mapCapturesItsStartingPositionOnThePlayerScheduler() {
IrisSettings previous = IrisSettings.settings;
IrisSettings.settings = new IrisSettings();
IrisSettings.settings.getGui().setUseServerLaunchedGuis(true);
World world = mock(World.class);
Player player = mock(Player.class);
VolmitSender sender = mock(VolmitSender.class);
PlatformChunkGenerator generator = mock(PlatformChunkGenerator.class);
Engine engine = mock(Engine.class);
UUID playerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
AtomicBoolean owned = new AtomicBoolean();
AtomicReference<Runnable> task = new AtomicReference<>();
AtomicReference<PaletteExplorerStart> start = new AtomicReference<>();
when(world.getUID()).thenReturn(worldId);
when(player.getUniqueId()).thenReturn(playerId);
when(player.isOnline()).thenReturn(true);
when(player.getWorld()).thenAnswer(invocation -> {
assertTrue("world read must use the player scheduler", owned.get());
return world;
});
when(player.getLocation()).thenAnswer(invocation -> {
assertTrue("location read must use the player scheduler", owned.get());
return new Location(world, 33.25D, 91D, -72.5D);
});
when(sender.isPlayer()).thenReturn(true);
when(sender.player()).thenReturn(player);
when(generator.getEngine()).thenReturn(engine);
CommandStudio command = new CommandStudio() {
@Override
public VolmitSender sender() {
return sender;
}
};
try (MockedStatic<GuiHost> host = mockStatic(GuiHost.class);
MockedStatic<IrisToolbelt> toolbelt = mockStatic(IrisToolbelt.class);
MockedStatic<J> scheduling = mockStatic(J.class);
MockedStatic<MapWindow> windows = mockStatic(MapWindow.class)) {
host.when(GuiHost::isAvailable).thenReturn(true);
toolbelt.when(() -> IrisToolbelt.isIrisWorld(world)).thenReturn(true);
toolbelt.when(() -> IrisToolbelt.access(world)).thenReturn(generator);
scheduling.when(() -> J.runEntity(same(player), any(Runnable.class), eq(0), any(Runnable.class)))
.thenAnswer(invocation -> {
task.set(invocation.getArgument(1));
return true;
});
windows.when(() -> MapWindow.openMap(any(PaletteExplorerStart.class))).thenAnswer(invocation -> {
start.set(invocation.getArgument(0));
return null;
});
command.map(world);
assertNull(start.get());
owned.set(true);
task.get().run();
assertEquals(engine, start.get().engine());
assertEquals(playerId, start.get().playerId());
assertEquals(worldId, start.get().worldId());
assertEquals(33.25D, start.get().centerX(), 0D);
assertEquals(-72.5D, start.get().centerZ(), 0D);
} finally {
IrisSettings.settings = previous;
}
}
}
@@ -0,0 +1,57 @@
package art.arcane.iris.platform.agent;
import org.junit.Test;
import java.lang.foreign.Arena;
import java.lang.foreign.FunctionDescriptor;
import java.lang.foreign.Linker;
import java.lang.foreign.MemorySegment;
import java.lang.foreign.ValueLayout;
import java.lang.invoke.MethodHandle;
import java.util.Locale;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertTrue;
import static org.junit.Assume.assumeFalse;
public class AttachSignalTest {
private static final int SIGQUIT = 3;
private static final int SIGTERM = 15;
private static final long ACTION_BYTES = 256;
private static final MethodHandle SIGACTION = Linker.nativeLinker().downcallHandle(
Linker.nativeLinker().defaultLookup().find("sigaction").orElseThrow(),
FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.JAVA_INT, ValueLayout.ADDRESS, ValueLayout.ADDRESS));
@Test
public void aConsoleThatResetsQuitGetsTheJvmHandlerBack() throws Throwable {
assumeFalse(System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("win"));
long jvmHandler = handler(SIGTERM);
assertNotEquals(0L, jvmHandler);
try (Arena arena = Arena.ofConfined()) {
MemorySegment original = arena.allocate(ACTION_BYTES);
assertEquals(0, (int) SIGACTION.invokeExact(SIGQUIT, MemorySegment.NULL, original));
try {
MemorySegment reset = arena.allocate(ACTION_BYTES);
assertEquals(0, (int) SIGACTION.invokeExact(SIGQUIT, reset, MemorySegment.NULL));
assertEquals(0L, handler(SIGQUIT));
assertTrue(AttachSignal.restoreJvmHandler());
assertEquals(jvmHandler, handler(SIGQUIT));
assertFalse(AttachSignal.restoreJvmHandler());
} finally {
assertEquals(0, (int) SIGACTION.invokeExact(SIGQUIT, original, MemorySegment.NULL));
}
}
}
private static long handler(int signal) throws Throwable {
try (Arena arena = Arena.ofConfined()) {
MemorySegment action = arena.allocate(ACTION_BYTES);
assertEquals(0, (int) SIGACTION.invokeExact(signal, MemorySegment.NULL, action));
return action.get(ValueLayout.JAVA_LONG, 0);
}
}
}
@@ -9,16 +9,18 @@ import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.runtime.BiomeEnvironment;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.terrain.InferredType;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.generation.terrain.IrisRegion;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.platform.generation.PlatformChunkGenerator;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.world.history.SavedBiomeUnavailableException;
import art.arcane.iris.generation.hydrology.HydrologyColumnLayer;
import art.arcane.iris.generation.hydrology.HydrologyColumnSample;
import art.arcane.iris.generation.hydrology.HydrologyFeatureRef;
import art.arcane.iris.generation.hydrology.HydrologyFeatureType;
import art.arcane.volmlib.util.stream.ProceduralStream;
import art.arcane.volmlib.util.stream.interpolation.Interpolated;
import art.arcane.iris.api.terrain.IrisColumnSample;
import art.arcane.iris.generation.hydrology.raster.WaterRole;
import art.arcane.iris.platform.bukkit.plugin.IrisService;
import org.bukkit.World;
import org.bukkit.generator.ChunkGenerator;
@@ -26,6 +28,7 @@ import org.junit.Test;
import org.mockito.MockedStatic;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import java.util.Optional;
@@ -112,10 +115,32 @@ public class IrisTerrainSVCTest {
}
@Test
public void acceptedHydrologyMapsToThePublicDiagnosticStates() {
assertEquals(IrisRiverState.NONE, IrisTerrainSVC.riverState(null));
assertEquals(IrisRiverState.WET, IrisTerrainSVC.riverState(river(true)));
assertEquals(IrisRiverState.DRY, IrisTerrainSVC.riverState(river(false)));
public void waterRolesMapToThePublicRiverFields() {
for (WaterRole role : WaterRole.values()) {
assertEquals(role.name(), role.wet() ? IrisRiverState.WET : IrisRiverState.NONE, IrisTerrainSVC.riverState(role));
double distance = switch (role) {
case NONE -> IrisColumnSample.UNAVAILABLE_RIVER_DISTANCE;
case CHANNEL, POND, MOUTH, CASCADE, WATERFALL, INLET -> 0D;
case SHORE, POND_SHORE -> 1D;
case BANK, DAM -> 2D;
};
assertEquals(role.name(), distance, IrisTerrainSVC.riverDistance(role), 0D);
int flow = switch (role) {
case NONE, SHORE, POND_SHORE, BANK, DAM -> IrisColumnSample.UNAVAILABLE_RIVER_FLOW;
case CHANNEL, MOUTH, CASCADE, WATERFALL -> 1;
case POND, INLET -> 0;
};
assertEquals(role.name(), flow, IrisTerrainSVC.riverFlow(role));
}
}
@Test
public void columnSamplesReportTheWaterRoleOfEachColumn() throws Exception {
assertEquals(List.of("RIVER", "WET", "0.0", "1", "6"), sampled(WaterRole.CHANNEL, 70));
assertEquals(List.of("RIVER", "WET", "0.0", "0", "6"), sampled(WaterRole.POND, 70));
assertEquals(List.of("RIVER_SHORE", "NONE", "1.0", "-1", "unavailable"), sampled(WaterRole.SHORE, 50));
assertEquals(List.of("LAND", "NONE", "2.0", "-1", "unavailable"), sampled(WaterRole.BANK, 50));
assertEquals(List.of("LAND", "NONE", "NaN", "-1", "unavailable"), sampled(WaterRole.NONE, 50));
}
@Test
@@ -219,6 +244,33 @@ public class IrisTerrainSVCTest {
}
}
private static List<String> sampled(WaterRole role, int head) throws Exception {
Engine engine = mock(Engine.class);
IrisComplex complex = mock(IrisComplex.class);
IrisBiome biome = mock(IrisBiome.class);
when(biome.getInferredType()).thenReturn(InferredType.LAND);
when(engine.getComplex()).thenReturn(complex);
when(engine.getDimension()).thenReturn(mock(IrisDimension.class));
when(engine.getHeight(0, 0)).thenReturn(60);
when(engine.getMinHeight()).thenReturn(-64);
when(engine.getSurfaceBiome(0, 0)).thenReturn(biome);
when(complex.getWaterRoleStream()).thenReturn(ProceduralStream.of((x, z) -> role, Interpolated.of(value -> 0D, value -> role)));
when(complex.getWaterHeadStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> (double) head));
IrisColumnQuery query = IrisColumnQuery.rect(0, 0, 0, 0, 1, EnumSet.of(IrisColumnField.SURFACE_KIND,
IrisColumnField.RIVER_STATE, IrisColumnField.RIVER_DISTANCE, IrisColumnField.RIVER_FLOW,
IrisColumnField.RIVER_WATER_SURFACE_Y));
List<IrisColumnSample> samples = new ArrayList<>();
try (MockedStatic<IrisSettings> settings = mockStatic(IrisSettings.class)) {
settings.when(IrisSettings::get).thenReturn(new IrisSettings());
assertTrue(enabledService().sampleColumns(world(engine), query, samples::add));
}
assertEquals(1, samples.size());
IrisColumnSample sample = samples.get(0);
return List.of(sample.surfaceKind().name(), sample.riverState().name(), String.valueOf(sample.riverDistance()),
String.valueOf(sample.riverFlow()),
sample.riverWaterSurfaceY() == IrisColumnSample.UNAVAILABLE_HEIGHT ? "unavailable" : String.valueOf(sample.riverWaterSurfaceY()));
}
private static World world(Engine engine) {
World world = mock(World.class);
ChunkGenerator generator = mock(ChunkGenerator.class, withSettings().extraInterfaces(PlatformChunkGenerator.class));
@@ -235,41 +287,4 @@ public class IrisTerrainSVCTest {
((AtomicBoolean) enabled.get(service)).set(true);
return service;
}
private static HydrologyColumnSample river(boolean connectedFluid) {
HydrologyFeatureRef feature = new HydrologyFeatureRef(
1L,
HydrologyFeatureType.SURFACE_POOL,
2L,
3L,
0,
63,
0,
1,
0,
false
);
HydrologyColumnLayer layer = new HydrologyColumnLayer(
feature,
60,
63,
63,
true,
false,
false,
connectedFluid,
false,
false,
true,
connectedFluid,
false,
"water",
"river",
"mouth",
"shore",
"dry",
"cave"
);
return new HydrologyColumnSample(0, 0, 70, 63, false, "parent", List.of(layer));
}
}
@@ -1,15 +1,10 @@
package art.arcane.iris.platform.bukkit.terrain;
import art.arcane.iris.api.terrain.IrisSurfaceKind;
import art.arcane.iris.generation.hydrology.HydrologyColumnLayer;
import art.arcane.iris.generation.hydrology.HydrologyColumnSample;
import art.arcane.iris.generation.hydrology.HydrologyFeatureRef;
import art.arcane.iris.generation.hydrology.HydrologyFeatureType;
import art.arcane.iris.generation.hydrology.raster.WaterRole;
import art.arcane.iris.generation.terrain.InferredType;
import org.junit.Test;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
@@ -69,98 +64,28 @@ public class IrisSurfaceClassifierTest {
}
@Test
public void acceptedSurfaceRolesOverrideGenericOceanAndLandKinds() {
assertEquals(IrisSurfaceKind.RIVER, IrisSurfaceClassifier.classify(
60,
63,
InferredType.SEA,
hydrology(HydrologyFeatureType.SURFACE_POOL, true, false, true)
));
assertEquals(IrisSurfaceKind.RIVER, IrisSurfaceClassifier.classify(
60,
63,
InferredType.SEA,
hydrology(HydrologyFeatureType.MOUTH, true, false, true)
));
assertEquals(IrisSurfaceKind.RIVER_SHORE, IrisSurfaceClassifier.classify(
64,
63,
InferredType.SHORE,
hydrology(HydrologyFeatureType.SURFACE_POOL, false, true, false)
));
assertEquals(IrisSurfaceKind.DRY_CHANNEL, IrisSurfaceClassifier.classify(
60,
60,
InferredType.LAND,
hydrology(HydrologyFeatureType.SURFACE_POOL, true, false, false)
));
assertEquals(IrisSurfaceKind.LAND, IrisSurfaceClassifier.classify(
60,
60,
InferredType.LAND,
hydrology(HydrologyFeatureType.SURFACE_POOL, false, false, false)
));
public void waterRolesOverrideGenericOceanAndLandKinds() {
for (WaterRole role : WaterRole.values()) {
IrisSurfaceKind expected = switch (role) {
case NONE -> IrisSurfaceKind.OCEAN;
case SHORE, POND_SHORE -> IrisSurfaceKind.RIVER_SHORE;
case BANK, DAM -> IrisSurfaceKind.LAND;
case CHANNEL, POND, MOUTH, CASCADE, WATERFALL, INLET -> IrisSurfaceKind.RIVER;
};
assertEquals(role.name(), expected, IrisSurfaceClassifier.classify(60, 63, InferredType.SEA, role));
}
}
@Test
public void voidClassificationWinsOverAcceptedRiverGeometry() {
assertEquals(IrisSurfaceKind.VOID, IrisSurfaceClassifier.classify(
0,
63,
InferredType.LAND,
hydrology(HydrologyFeatureType.SURFACE_POOL, true, false, true)
));
public void voidClassificationWinsOverWaterRoles() {
for (WaterRole role : WaterRole.values()) {
assertEquals(role.name(), IrisSurfaceKind.VOID, IrisSurfaceClassifier.classify(0, 63, InferredType.LAND, role));
}
}
@Test
public void absentRejectedCandidatesDoNotCreatePublicRiverKinds() {
assertEquals(IrisSurfaceKind.LAND, IrisSurfaceClassifier.classify(
64,
63,
InferredType.LAND,
null
));
}
private static HydrologyColumnSample hydrology(
HydrologyFeatureType type,
boolean channel,
boolean shore,
boolean connectedFluid
) {
HydrologyFeatureRef feature = new HydrologyFeatureRef(
1L,
type,
2L,
3L,
0,
63,
0,
1,
0,
false
);
HydrologyColumnLayer layer = new HydrologyColumnLayer(
feature,
60,
63,
63,
channel,
shore,
!channel && !shore,
connectedFluid,
false,
false,
true,
connectedFluid && channel,
false,
"water",
"river",
"mouth",
"shore",
"dry",
"cave"
);
return new HydrologyColumnSample(0, 0, 70, 63, false, "parent", List.of(layer));
public void columnsWithoutAWaterRoleKeepTheGenericKinds() {
assertEquals(IrisSurfaceKind.LAND, IrisSurfaceClassifier.classify(64, 63, InferredType.LAND, WaterRole.NONE));
assertEquals(IrisSurfaceKind.SHORE, IrisSurfaceClassifier.classify(64, 63, InferredType.SHORE, WaterRole.NONE));
}
}
@@ -0,0 +1,165 @@
package art.arcane.iris.studio.view;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
import art.arcane.iris.world.IrisWorld;
import art.arcane.iris.world.task.J;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
public class BukkitOpenerPositionTest {
@Test
public void playerMarkersAreCapturedOnTheirEntitySchedulers() {
try (Harness harness = new Harness()) {
harness.binding.when(() -> BukkitWorldBinding.players(harness.target)).thenReturn(List.of(harness.player));
when(harness.player.getName()).thenReturn("Explorer");
assertTrue(harness.overlay.players().isEmpty());
harness.runEntity();
List<GuiMarker> markers = harness.overlay.players();
assertEquals(1, markers.size());
assertEquals("Explorer", markers.getFirst().label());
assertEquals(18.5D, markers.getFirst().worldX(), 0D);
assertEquals(-4.25D, markers.getFirst().worldZ(), 0D);
}
}
@Test
public void positionIsCapturedOnlyOnTheOwningEntityTask() {
try (Harness harness = new Harness()) {
harness.overlay.requestOpenerPosition(harness.results::add);
assertTrue(harness.results.isEmpty());
harness.runEntity();
assertEquals(List.of(OpenerPosition.inWorld(18.5D, 72D, -4.25D)), harness.results);
}
}
@Test
public void retiredEntityCompletesTheRequestOnce() {
try (Harness harness = new Harness()) {
harness.overlay.requestOpenerPosition(harness.results::add);
assertTrue("retirement callback must be registered", harness.retired != null);
harness.retired.run();
harness.retired.run();
assertEquals(List.of(OpenerPosition.OFFLINE), harness.results);
}
}
@Test
public void disconnectBeforeCaptureCompletesAsOfflineAndReconnectCanRefresh() {
try (Harness harness = new Harness()) {
harness.overlay.requestOpenerPosition(harness.results::add);
when(harness.player.isOnline()).thenReturn(false);
harness.runEntity();
assertEquals(List.of(OpenerPosition.OFFLINE), harness.results);
when(harness.player.isOnline()).thenReturn(true);
harness.overlay.requestOpenerPosition(harness.results::add);
harness.runEntity();
assertEquals(List.of(OpenerPosition.OFFLINE, OpenerPosition.inWorld(18.5D, 72D, -4.25D)), harness.results);
}
}
@Test
public void rejectedEntityTaskCompletesOnceEvenIfRetirementAlreadyRan() {
try (Harness harness = new Harness()) {
harness.reject = true;
harness.overlay.requestOpenerPosition(harness.results::add);
assertEquals(List.of(OpenerPosition.OFFLINE), harness.results);
}
}
@Test
public void changingWorldBeforeCaptureReportsOtherWorld() {
try (Harness harness = new Harness()) {
harness.overlay.requestOpenerPosition(harness.results::add);
when(harness.player.getWorld()).thenReturn(mock(World.class));
harness.runEntity();
assertEquals(List.of(OpenerPosition.IN_OTHER_WORLD), harness.results);
}
}
private static final class Harness implements AutoCloseable {
private final Player player = mock(Player.class);
private final World world = mock(World.class);
private final Engine engine = mock(Engine.class);
private final IrisWorld target = mock(IrisWorld.class);
private final UUID opener = UUID.randomUUID();
private final List<OpenerPosition> results = new ArrayList<>();
private final MockedStatic<J> scheduling = mockStatic(J.class);
private final MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
private final MockedStatic<BukkitWorldBinding> binding = mockStatic(BukkitWorldBinding.class);
private final BukkitVisionOverlay overlay = new BukkitVisionOverlay(engine, opener);
private Runnable task;
private Runnable retired;
private boolean entityOwned;
private boolean reject;
private Harness() {
when(engine.getWorld()).thenReturn(target);
when(player.isOnline()).thenReturn(true);
when(player.getWorld()).thenReturn(world);
when(player.getLocation()).thenAnswer(invocation -> {
assertTrue("location read outside owning entity task", entityOwned);
return new Location(world, 18.5D, 72D, -4.25D);
});
bukkit.when(() -> Bukkit.getPlayer(opener)).thenReturn(player);
binding.when(() -> BukkitWorldBinding.world(target)).thenReturn(world);
scheduling.when(() -> J.runGlobal(any(Runnable.class))).thenAnswer(invocation -> {
invocation.getArgument(0, Runnable.class).run();
return true;
});
scheduling.when(() -> J.runEntity(same(player), any(Runnable.class))).thenAnswer(invocation -> {
task = invocation.getArgument(1);
return !reject;
});
scheduling.when(() -> J.runEntity(same(player), any(Runnable.class), eq(0), any(Runnable.class)))
.thenAnswer(invocation -> {
task = invocation.getArgument(1);
retired = invocation.getArgument(3);
if (reject) {
retired.run();
}
return !reject;
});
}
private void runEntity() {
entityOwned = true;
try {
task.run();
} finally {
entityOwned = false;
}
}
@Override
public void close() {
binding.close();
bukkit.close();
scheduling.close();
}
}
}
@@ -11,12 +11,16 @@ import art.arcane.iris.platform.bukkit.plugin.VolmitSender;
import org.bukkit.NamespacedKey;
import org.junit.Test;
import java.io.IOException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertThrows;
import static org.mockito.Mockito.mock;
@@ -42,4 +46,55 @@ public class PendingWorldReplacementAdmissionTest {
executor.awaitTermination(5L, TimeUnit.SECONDS);
}
}
@Test
public void shutdownRejectsReplacementsThatStartAfterIt() {
PendingWorldReplacementManager manager = new PendingWorldReplacementManager(mock(Iris.class));
manager.awaitStagingForShutdown();
IOException rejected = assertThrows(IOException.class, () -> manager.stageReplacement(
mock(VolmitSender.class), NamespacedKey.minecraft("overworld"), mock(IrisDimension.class), null));
assertTrue(rejected.getMessage().contains("shutting down"));
assertTrue(LifecycleOperationCoordinator.get().isIdle());
}
@Test
public void shutdownWaitsForTheReplacementBeingStaged() throws Exception {
PendingWorldReplacementManager manager = new PendingWorldReplacementManager(mock(Iris.class));
AtomicReference<Throwable> stagingFailure = new AtomicReference<>();
Thread staging = new Thread(() -> {
try {
manager.stageReplacement(mock(VolmitSender.class), NamespacedKey.minecraft("overworld"),
mock(IrisDimension.class), null);
} catch (Throwable failure) {
stagingFailure.set(failure);
}
});
Thread shutdown = new Thread(manager::awaitStagingForShutdown);
synchronized (manager) {
staging.start();
awaitState(staging, Thread.State.BLOCKED);
shutdown.start();
awaitState(shutdown, Thread.State.WAITING);
assertTrue(staging.isAlive());
}
shutdown.join(TimeUnit.SECONDS.toMillis(10L));
staging.join(TimeUnit.SECONDS.toMillis(10L));
assertFalse(shutdown.isAlive());
assertFalse(staging.isAlive());
assertTrue(stagingFailure.get() instanceof Exception);
assertTrue(LifecycleOperationCoordinator.get().isIdle());
}
private static void awaitState(Thread thread, Thread.State state) throws InterruptedException {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5L);
while (thread.getState() != state) {
if (System.nanoTime() > deadline) {
throw new AssertionError(thread.getName() + " never reached " + state + "; it is " + thread.getState());
}
Thread.sleep(5L);
}
}
}
+9 -3
View File
@@ -62,7 +62,7 @@ apply from: file('../../gradle/java21-runtime-exclusions.gradle')
Properties rootProperties = new Properties()
file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) }
String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.3.5'))
String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.3.6'))
String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3'))
String fabricLoaderVersion = providers.gradleProperty('fabricLoaderVersion').getOrElse(irisModTarget.fabricLoader.toString())
Closure<String> irisArtifactName = { String platform, String targetVersion ->
@@ -131,6 +131,8 @@ configurations.named('bundle').configure {
exclude(group: 'de.crazydev22.slimjar.helper')
exclude(group: 'de.crazydev22.slimjar')
exclude(group: 'io.github.slimjar')
// The game ships its own LWJGL; without the modules it leaves out, the map's 3D views draw in software.
exclude(group: 'org.lwjgl')
}
configurations.compileClasspath.extendsFrom(configurations.devBundle)
@@ -153,7 +155,7 @@ dependencies {
String modConfiguration = irisModTarget.fabricRemap ? 'modImplementation' : 'implementation'
add(modConfiguration, "net.fabricmc:fabric-loader:${fabricLoaderVersion}")
testImplementation('junit:junit:4.13.2')
testImplementation('org.mockito:mockito-core:5.23.0')
testImplementation(libs.mockito.core)
testImplementation(testFixtures('art.arcane:core'))
testImplementation(libs.spigot) {
transitive = false
@@ -216,7 +218,9 @@ dependencies {
libs.caffeine,
'com.fulcrumgenomics:jlibdeflate:0.2.0',
libs.dom4j,
libs.jaxen
libs.jaxen,
libs.flatlaf,
libs.flatlaf.extras
]
shared.each { Object notation ->
['bundle', 'devBundle'].each { String configuration ->
@@ -236,6 +240,8 @@ tasks.named('compileJava', JavaCompile).configure {
options.release.set((int) irisModTarget.java)
}
apply from: file('../../gradle/mockito-agent.gradle')
tasks.named('test').configure {
}
+11 -3
View File
@@ -61,7 +61,7 @@ apply from: file('../../gradle/java21-runtime-exclusions.gradle')
Properties rootProperties = new Properties()
file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) }
String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.3.5'))
String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.3.6'))
String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3'))
String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse(irisModTarget.forge.toString())
String buildLane = gradle.extensions.extraProperties.has('irisBuildLane')
@@ -152,12 +152,16 @@ configurations.named('bundle').configure {
exclude(group: 'de.crazydev22.slimjar.helper')
exclude(group: 'de.crazydev22.slimjar')
exclude(group: 'io.github.slimjar')
// The game ships its own LWJGL; without the modules it leaves out, the map's 3D views draw in software.
exclude(group: 'org.lwjgl')
}
configurations.named('runBundle').configure {
exclude(group: 'de.crazydev22.slimjar.helper')
exclude(group: 'de.crazydev22.slimjar')
exclude(group: 'io.github.slimjar')
// The game ships its own LWJGL; without the modules it leaves out, the map's 3D views draw in software.
exclude(group: 'org.lwjgl')
}
configurations.compileClasspath.extendsFrom(configurations.devBundle)
@@ -178,7 +182,7 @@ dependencies {
? minecraft.dependency("net.minecraftforge:forge:${forgeVersion}")
: minecraft.dependency("iris-${buildLane}", "net.minecraftforge:forge:${forgeVersion}"))
testImplementation('junit:junit:4.13.2')
testImplementation('org.mockito:mockito-core:5.23.0')
testImplementation(libs.mockito.core)
testImplementation(testFixtures('art.arcane:core'))
testImplementation(libs.spigot) {
transitive = false
@@ -215,7 +219,9 @@ dependencies {
libs.caffeine,
'com.fulcrumgenomics:jlibdeflate:0.2.0',
libs.dom4j,
libs.jaxen
libs.jaxen,
libs.flatlaf,
libs.flatlaf.extras
]
shared.each { Object notation ->
['bundle', 'devBundle', 'runBundle'].each { String configuration ->
@@ -272,6 +278,8 @@ tasks.named('compileJava', JavaCompile).configure {
options.release.set((int) irisModTarget.java)
}
apply from: file('../../gradle/mockito-agent.gradle')
tasks.named('test').configure {
}
@@ -227,6 +227,7 @@ public final class ModdedEngineBootstrap {
failure = runStopStage(failure, "wand service", ModdedWandService::clearAll);
failure = runStopStage(failure, "block break handler", ModdedBlockBreakHandler::clear);
failure = runStopStage(failure, "studio commands", ModdedStudioCommands::clear);
failure = runStopStage(failure, "gui windows", GuiHost::shutdown);
failure = runStopStage(failure, "gui host", ModdedGuiHost::clear);
failure = runStopStage(failure, "services", () -> services().disableAll());
reportStopFailure("stopping", failure);
@@ -150,6 +150,7 @@ final class ModdedCommandHelp {
Entry.command("close", "", ModdedHelpMessages.COMMAND_CLOSE_CLOSE_THE_OPEN_STUDIO_DIMENSION_AND_DISCARD_ITS_WORLD, "x"),
Entry.command("tpstudio", "", ModdedHelpMessages.COMMAND_TPSTUDIO_TELEPORT_INTO_THE_OPEN_STUDIO_DIMENSION, "stp"),
Entry.command("status", "", ModdedHelpMessages.COMMAND_STATUS_SHOW_THE_OPEN_STUDIO_DIMENSION_AND_ITS_PACK),
Entry.command("hotload", "", ModdedHelpMessages.COMMAND_HOTLOAD_REFRESH_THE_OPEN_STUDIO, "refresh"),
Entry.command("noise", "[generator] [seed]", ModdedHelpMessages.COMMAND_NOISE_OPEN_THE_NOISE_EXPLORER_GUI_ON_THE_SERVER_DISPLAY, "nmap"),
Entry.command("map", "", ModdedHelpMessages.COMMAND_MAP_OPEN_THE_VISION_MAP_GUI_ON_THE_SERVER_DISPLAY, "render"),
Entry.command("vscode", "[pack]", ModdedHelpMessages.COMMAND_VSCODE_REGENERATE_THE_CODE_WORKSPACE_FOR_A_PACK_AND_OPEN_IT_IN_YOUR, "vsc"),
@@ -25,7 +25,7 @@ import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.structure.placement.IrisStructureLocator;
import art.arcane.iris.structure.nativegen.NativeStructureGenerationPolicy;
import art.arcane.iris.structure.placement.StructureReachability;
import art.arcane.iris.generation.hydrology.HydrologyFeatureQuery;
import art.arcane.iris.generation.hydrology.query.HydrologyFeatureType;
import art.arcane.iris.world.history.GenerationFindCatalog;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.structure.nativegen.IrisNativeStructureDecision;
@@ -61,7 +61,7 @@ final class ModdedCommandSuggestions {
static final SuggestionProvider<NativeCommandSource> OBJECT_KEYS = (CommandContext<NativeCommandSource> context, SuggestionsBuilder builder) -> suggestObjectKeys(context, builder);
static final SuggestionProvider<NativeCommandSource> STRUCTURE_KEYS = (CommandContext<NativeCommandSource> context, SuggestionsBuilder builder) -> suggestStructureKeys(context, builder);
static final SuggestionProvider<NativeCommandSource> POI_TYPES = (CommandContext<NativeCommandSource> context, SuggestionsBuilder builder) -> NativeCommandRegistration.suggest(List.of("buried_treasure"), builder);
static final SuggestionProvider<NativeCommandSource> HYDROLOGY_TYPES = (CommandContext<NativeCommandSource> context, SuggestionsBuilder builder) -> suggestHydrologyTypes(context, builder);
static final SuggestionProvider<NativeCommandSource> HYDROLOGY_TYPES = (CommandContext<NativeCommandSource> context, SuggestionsBuilder builder) -> NativeCommandRegistration.suggest(HydrologyFeatureType.keys(), builder);
static final SuggestionProvider<NativeCommandSource> PACK_NAMES = (CommandContext<NativeCommandSource> context, SuggestionsBuilder builder) -> suggestPackNames(context, builder);
static final SuggestionProvider<NativeCommandSource> DIMENSION_NAMES = (CommandContext<NativeCommandSource> context, SuggestionsBuilder builder) -> suggestDimensionNames(context, builder);
@@ -87,24 +87,6 @@ final class ModdedCommandSuggestions {
return builder.buildFuture();
}
private static CompletableFuture<Suggestions> suggestHydrologyTypes(
CommandContext<NativeCommandSource> context,
SuggestionsBuilder builder
) {
ModdedCommandFeedback.tab(context.getSource());
try {
Engine engine = IrisModdedCommands.engineFor(context.getSource().world());
if (engine != null && engine.getComplex().getHydrologyRuntime() != null) {
return NativeCommandRegistration.suggest(
engine.getComplex().getHydrologyRuntime().featureQueryKeys(), builder);
}
} catch (Throwable error) {
warnTabFailure("hydrology types", context.getSource(), error);
}
return NativeCommandRegistration.suggest(
HydrologyFeatureQuery.suggestions(List.of()), builder);
}
static Set<String> reachableBiomeKeys(Engine engine) {
return reachableBiomeKeys(GenerationFindCatalog.biomes(engine));
}
@@ -39,8 +39,7 @@ import art.arcane.iris.structure.placement.StructureReachability;
import art.arcane.iris.generation.runtime.WrongEngineBroException;
import art.arcane.iris.world.history.GenerationSemanticQueries;
import art.arcane.iris.world.history.GenerationFindCatalog;
import art.arcane.iris.generation.hydrology.HydrologyFeatureQuery;
import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntime;
import art.arcane.iris.generation.hydrology.query.HydrologyFeatureType;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.structure.nativegen.IrisNativeStructureDecision;
import art.arcane.iris.generation.terrain.IrisRegion;
@@ -143,39 +142,31 @@ final class ModdedLocateCommands {
IrisModdedCommands.fail(source, "This dimension is not generated by Iris.");
return 0;
}
IrisHydrologyRuntime runtime = engine.getComplex().getHydrologyRuntime();
HydrologyFeatureQuery query;
HydrologyFeatureType featureType;
try {
query = HydrologyFeatureQuery.parse(type);
featureType = HydrologyFeatureType.parse(type);
} catch (IllegalArgumentException error) {
IrisModdedCommands.fail(source, error.getMessage());
return 0;
}
String label = featureType.key().replace('_', ' ');
int originX = player.blockX();
int originZ = player.blockZ();
int requestedDistance = runtime == null
? 8192
: Math.min(8192, runtime.settings().routing().tileSize() * 15);
int maximumDistance = runtime == null
? requestedDistance
: runtime.maximumFeatureSearchDistance(originX, originZ, requestedDistance);
int maximumDistance = GenerationSemanticQueries.RIVER_SEARCH_DISTANCE;
NativeModdedServer server = source.server();
IrisModdedCommands.ok(source, "Searching accepted hydrology plans for " + type + "...");
IrisModdedCommands.ok(source, GenerationSemanticQueries.riverSearchMessage(featureType, maximumDistance));
MultiBurst.burst.completeValueAsync(() -> GenerationSemanticQueries.nearestRiver(
engine, query, originX, originZ, maximumDistance,
(int visited) -> server.execute(() -> IrisModdedCommands.ok(source,
"Searched " + visited + " hydrology tiles for " + type + "..."))).orElse(null))
engine, featureType, originX, originZ, maximumDistance).orElse(null))
.whenComplete((GenerationSemanticQueries.RiverResult feature, Throwable error) -> server.execute(() -> {
if (error != null) {
ModdedIrisLog.error("Hydrology locate failed for {}", type, error);
IrisModdedCommands.fail(source,
"Could not locate " + type + " hydrology: " + error.getClass().getSimpleName());
Throwable cause = error instanceof CompletionException && error.getCause() != null ? error.getCause() : error;
ModdedIrisLog.error("Hydrology locate failed for {}", featureType.key(), cause);
IrisModdedCommands.fail(source, "Could not search for " + label + ": " + cause.getClass().getSimpleName()
+ (cause.getMessage() == null ? "" : " - " + cause.getMessage()));
return;
}
if (feature == null) {
IrisModdedCommands.fail(source,
"No accepted " + type + " hydrology feature found within "
+ maximumDistance + " blocks.");
IrisModdedCommands.fail(source, GenerationSemanticQueries.riverNotFoundMessage(featureType, maximumDistance));
return;
}
teleportToStructure(
@@ -185,7 +176,7 @@ final class ModdedLocateCommands {
feature.x(),
feature.y() + 2,
feature.z(),
type + " hydrology feature"
label
);
}));
return 1;
@@ -23,6 +23,7 @@ import art.arcane.volmlib.nativelib.terrain.NativePregenRuntime;
import art.arcane.volmlib.nativelib.terrain.NativePregenRuntime.ChunkLoad;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.world.pregen.MantleHeapPressure;
import art.arcane.iris.world.pregen.PregenHydrology;
import art.arcane.iris.world.pregen.PregenListener;
import art.arcane.iris.world.pregen.PregenMantleBackpressure;
import art.arcane.iris.world.pregen.PregeneratorMethod;
@@ -30,6 +31,7 @@ import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.modded.ModdedGenPool;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
@@ -71,6 +73,8 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
private final PregenMantleBackpressure backpressure;
private final PauseWhenEmptyGuard pauseGuard;
private final LongSupplier clock;
private final AtomicBoolean closing = new AtomicBoolean();
private final PregenHydrology hydrology;
public ModdedPregenMethod(Configuration configuration) {
this(configuration, System::nanoTime);
@@ -80,6 +84,7 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
this.clock = Objects.requireNonNull(clock);
runtime = configuration.runtime();
engine = configuration.engine();
hydrology = new PregenHydrology(() -> engine);
sync = configuration.sync();
pauseGuard = new PauseWhenEmptyGuard(runtime);
IrisSettings.IrisSettingsPregen pregen = IrisSettings.get().getPregen();
@@ -121,6 +126,8 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
@Override
public void close() {
closing.set(true);
hydrology.close();
try {
awaitPendingChunks();
ModdedIrisLog.info("Iris modded pregen closing: dim={} completed={} pending={} peakInFlight={} finalLimit={}",
@@ -341,6 +348,13 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
return false;
}
@Override
public void onRegionsAhead(List<long[]> regions) {
if (!closing.get()) {
hydrology.prepare(regions);
}
}
@Override
public String getMethod(int x, int z) {
return "Modded";
@@ -23,10 +23,9 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldView;
import art.arcane.iris.modded.ModdedIrisLog;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.studio.map.MapWindow;
import art.arcane.iris.studio.map.PaletteExplorerStart;
import art.arcane.iris.studio.view.GuiHost;
import art.arcane.iris.studio.view.ImageMapStudioGUI;
import art.arcane.iris.studio.view.NoiseExplorerGUI;
import art.arcane.iris.studio.view.VisionGUI;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.pack.StructurePackageClosure;
import art.arcane.iris.pack.ImageMapPackageClosure;
@@ -38,6 +37,7 @@ import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.biome.IrisBiomeGeneratorLink;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.world.entity.IrisEntitySpawn;
import art.arcane.iris.world.IrisWorld;
import art.arcane.iris.generation.noise.IrisGenerator;
import art.arcane.iris.pack.PackExportClosure;
import art.arcane.iris.world.entity.IrisEntity;
@@ -49,6 +49,7 @@ import art.arcane.iris.modded.ModdedDimensionManager;
import art.arcane.iris.modded.ModdedEngineBootstrap;
import art.arcane.iris.modded.ModdedScheduler;
import art.arcane.iris.modded.ModdedWorkspaceGenerator;
import art.arcane.iris.modded.service.ModdedStudioHotloadService;
import art.arcane.iris.generation.concurrent.BurstExecutor;
import art.arcane.iris.generation.concurrent.MultiBurst;
import art.arcane.volmlib.util.collection.KMap;
@@ -85,6 +86,7 @@ import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executor;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Predicate;
import java.util.regex.Pattern;
@@ -92,6 +94,7 @@ import java.util.stream.Stream;
import art.arcane.iris.localization.IrisLanguage;
import art.arcane.iris.modded.localization.ModdedCommandMessages;
import art.arcane.volmlib.util.localization.LanguageAudience;
import art.arcane.volmlib.util.localization.MessageArgument;
public final class ModdedStudioCommands {
private static final Predicate<NativeCommandSource> GATE = NativeCommandRegistration.GAMEMASTERS;
@@ -152,6 +155,10 @@ public final class ModdedStudioCommands {
.executes(ModdedCommandTree.localized((CommandContext<NativeCommandSource> context) -> tpStudio(context.getSource()))));
root.then(NativeCommandRegistration.literal("status")
.executes(ModdedCommandTree.localized((CommandContext<NativeCommandSource> context) -> status(context.getSource()))));
root.then(NativeCommandRegistration.literal("hotload")
.executes(ModdedCommandTree.localized((CommandContext<NativeCommandSource> context) -> hotload(context.getSource()))));
root.then(NativeCommandRegistration.literal("refresh")
.executes(ModdedCommandTree.localized((CommandContext<NativeCommandSource> context) -> hotload(context.getSource()))));
root.then(workspaceTree("vscode", true));
root.then(workspaceTree("vsc", true));
root.then(workspaceTree("update", false));
@@ -247,8 +254,9 @@ public final class ModdedStudioCommands {
NativeProtocolPlayer player = source.player();
ModdedGuiHost.bindContext(worldView(source.server(), level), engine, player == null ? null : player.id());
}
if (generatorKey == null || generatorKey.isBlank()) {
NoiseExplorerGUI.launch();
Engine mapEngine = engine != null ? engine : GuiHost.get().findActiveEngine();
if ((generatorKey == null || generatorKey.isBlank()) && mapEngine != null) {
MapWindow.openNoise(mapStart(mapEngine, source.player()), new MapWindow.NoiseSelection(null, null, seed));
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_OPENING_NOISE_EXPLORER_ON_SERVER_DISPLAY));
return 1;
}
@@ -262,7 +270,7 @@ public final class ModdedStudioCommands {
return 0;
}
String selectedGeneratorKey = generatorKey.trim();
NoiseExplorerGUI.launchGeneratorKey(selectedGeneratorKey, generator, seed);
MapWindow.openNoise(mapStart(engine, source.player()), new MapWindow.NoiseSelection(selectedGeneratorKey, generator, seed));
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_OPENING_NOISE_EXPLORER_GENERATOR_SEED, MessageArgument.untrusted("value", generatorKey.trim()), MessageArgument.untrusted("seed", seed)));
return 1;
}
@@ -280,11 +288,20 @@ public final class ModdedStudioCommands {
}
NativeProtocolPlayer player = source.player();
ModdedGuiHost.bindContext(worldView(source.server(), level), engine, player == null ? null : player.id());
VisionGUI.launch(engine, player == null ? null : player.id());
MapWindow.openMap(mapStart(engine, player));
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_OPENING_VISION_MAP_ON_SERVER_DISPLAY, MessageArgument.untrusted("value", level.name())));
return 1;
}
// Modded worlds have no Bukkit world id; the map finds the engine again through the bound GUI context.
private static PaletteExplorerStart mapStart(Engine engine, NativeProtocolPlayer player) {
IrisWorld world = engine.getWorld();
NativeWorld level = world == null ? null : world.platformWorld();
boolean inWorld = player != null && player.connected() && level != null && player.inWorld(level);
return new PaletteExplorerStart(engine, null, player == null ? null : player.id(), null,
inWorld ? player.x() : 0D, inWorld ? player.z() : 0D);
}
private static int imageMap(NativeCommandSource source) {
NativeWorld level = source.world();
Engine engine = IrisModdedCommands.engineFor(level);
@@ -294,13 +311,13 @@ public final class ModdedStudioCommands {
));
return 0;
}
if (!ImageMapStudioGUI.isAvailable()) {
if (!GuiHost.isAvailable() || !IrisSettings.get().getGui().isUseServerLaunchedGuis()) {
IrisModdedCommands.fail(source, guiUnavailableMessage());
return 0;
}
NativeProtocolPlayer player = source.player();
ModdedGuiHost.bindContext(worldView(source.server(), level), engine, player == null ? null : player.id());
ImageMapStudioGUI.launch(engine);
MapWindow.openImageMaps(mapStart(engine, player));
IrisModdedCommands.ok(source, IrisLanguage.plain(
ModdedCommandMessages.MODDED_STUDIO_COMMANDS_OPENING_IMAGE_MAP_STUDIO_ON_SERVER_DISPLAY,
MessageArgument.untrusted("value", level.name())
@@ -643,6 +660,72 @@ public final class ModdedStudioCommands {
return transition;
}
private static int hotload(NativeCommandSource source) {
NativeProtocolPlayer player = source.player();
NativeModdedServer server = source.server();
UUID owner = player == null ? CONSOLE_OWNER : player.id();
UUID audience = source.playerId();
Executor feedback = task -> server.execute(() -> LanguageAudience.run(audience, task));
CompletableFuture<Void> transition = TRANSITIONS.submit(owner, () -> hotloadTransition(source, owner, feedback));
transition.whenComplete((ignored, failure) -> {
if (failure == null) {
return;
}
Throwable cause = unwrapFailure(failure);
ModdedIrisLog.error("Iris Studio hotload command failed", cause);
feedback.execute(() -> IrisModdedCommands.fail(source, IrisLanguage.plain(
ModdedCommandMessages.MODDED_STUDIO_COMMANDS_STUDIO_HOTLOAD_FAILED,
MessageArgument.trusted("errorMessage", IrisLanguage.errorDetail(cause)))));
});
return 1;
}
private static CompletableFuture<Void> hotloadTransition(
NativeCommandSource source,
UUID owner,
Executor feedback
) {
return CompletableFuture.supplyAsync(() -> hotloadStudio(source, owner, feedback), feedback)
.thenCompose(request -> request);
}
private static CompletableFuture<Void> hotloadStudio(
NativeCommandSource source,
UUID owner,
Executor feedback
) {
String dimensionId = STUDIOS.get(owner);
if (dimensionId == null) {
IrisModdedCommands.fail(source, IrisLanguage.plain(
ModdedCommandMessages.MODDED_STUDIO_COMMANDS_YOU_DO_NOT_HAVE_OPEN_STUDIO_USE_IRIS_STUDIO_OPEN));
return CompletableFuture.completedFuture(null);
}
NativeWorld studio = ModdedDimensionManager.level(source.server(), dimensionId);
if (studio == null) {
STUDIOS.remove(owner, dimensionId);
IrisModdedCommands.fail(source, IrisLanguage.plain(
ModdedCommandMessages.MODDED_STUDIO_COMMANDS_YOUR_STUDIO_DIMENSION_IS_NO_LONGER_LOADED_USE_IRIS_STUDIO));
return CompletableFuture.completedFuture(null);
}
Engine engine = IrisModdedCommands.engineFor(studio);
ModdedStudioHotloadService service = ModdedEngineBootstrap.services().service(ModdedStudioHotloadService.class);
if (engine == null || service == null) {
throw new IllegalStateException("The Studio engine or hotload service is unavailable.");
}
return service.requestHotload(dimensionId, engine)
.thenAcceptAsync(completed -> sendHotloadResult(source, completed), feedback);
}
private static void sendHotloadResult(NativeCommandSource source, boolean completed) {
if (completed) {
IrisModdedCommands.ok(source, IrisLanguage.plain(
ModdedCommandMessages.MODDED_STUDIO_COMMANDS_STUDIO_HOTLOAD_COMPLETE));
} else {
IrisModdedCommands.fail(source, IrisLanguage.plain(
ModdedCommandMessages.MODDED_STUDIO_COMMANDS_STUDIO_HOTLOAD_BUSY));
}
}
private static int status(NativeCommandSource source) {
NativeModdedServer server = source.server();
List<ModdedDimensionManager.Handle> handles = ModdedDimensionManager.handles();
@@ -18,21 +18,22 @@
package art.arcane.iris.modded.command;
import art.arcane.iris.studio.view.GuiHost;
import art.arcane.volmlib.nativelib.view.WorldMarker;
import art.arcane.iris.studio.view.GuiMarker;
import art.arcane.iris.studio.view.GuiOverlay;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.studio.view.OpenerPosition;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.studio.render.RenderType;
import art.arcane.iris.modded.ModdedIrisLog;
import art.arcane.iris.modded.ModdedTeleportDeadline;
import art.arcane.iris.world.IrisWorld;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.volmlib.nativelib.view.WorldView;
import java.awt.Desktop;
import java.io.File;
import java.util.List;
import java.util.Optional;
import art.arcane.volmlib.nativelib.view.WorldView;
import java.util.UUID;
import java.util.concurrent.RejectedExecutionException;
import java.util.function.Consumer;
public final class ModdedVisionOverlay implements GuiOverlay {
@@ -46,14 +47,33 @@ public final class ModdedVisionOverlay implements GuiOverlay {
opener = context.opener();
}
// WorldView doesn't report which way a player faces, so their markers all face south.
@Override
public List<WorldMarker> players() {
return world.players();
public List<GuiMarker> players() {
return world.players().stream()
.map(player -> GuiMarker.player(player.label(), player.worldX(), player.worldZ(), 0F))
.toList();
}
@Override
public void requestEntities(Consumer<List<WorldMarker>> sink) {
world.requestEntities(sink);
public void requestEntities(Consumer<List<GuiMarker>> sink) {
world.requestEntities(entities -> sink.accept(entities.stream()
.map(entity -> GuiMarker.entity(entity.label(), entity.worldX(), entity.worldY(), entity.worldZ(),
entity.health(), entity.maxHealth()))
.toList()));
}
@Override
public void requestOpenerPosition(Consumer<OpenerPosition> sink) {
if (opener == null) {
sink.accept(OpenerPosition.OFFLINE);
return;
}
try {
world.execute(() -> sink.accept(openerPosition()));
} catch (RejectedExecutionException failure) {
sink.accept(OpenerPosition.OFFLINE);
}
}
@Override
@@ -80,23 +100,25 @@ public final class ModdedVisionOverlay implements GuiOverlay {
});
}
@Override
public String openInEditor(double worldX, double worldZ, RenderType type) {
if (!GuiHost.isAvailable() || !Desktop.isDesktopSupported()) {
return null;
private OpenerPosition openerPosition() {
IrisWorld target = engine.getWorld();
if (target == null || engine.isClosing() || engine.isClosed()) {
return OpenerPosition.OFFLINE;
}
IrisComplex complex = engine.getComplex();
File file = switch (type) {
case BIOME, LAYER_LOAD, DECORATOR_LOAD, OBJECT_LOAD, HEIGHT, RIVER ->
complex.getTrueBiomeStream().get(worldX, worldZ).openInVSCode();
case BIOME_LAND -> complex.getLandBiomeStream().get(worldX, worldZ).openInVSCode();
case BIOME_SEA -> complex.getSeaBiomeStream().get(worldX, worldZ).openInVSCode();
case REGION -> complex.getRegionStream().get(worldX, worldZ).openInVSCode();
case CAVE_LAND -> complex.getCaveBiomeStream().get(worldX, worldZ).openInVSCode();
default -> null;
};
return file == null ? null : file.getName();
NativeWorld level = target.platformWorld();
if (level == null) {
return OpenerPosition.OFFLINE;
}
NativeProtocolPlayer player = NativeModdedServer.forWorld(level).player(opener);
if (player == null || !player.connected()) {
return OpenerPosition.OFFLINE;
}
if (!player.inWorld(level)) {
return OpenerPosition.IN_OTHER_WORLD;
}
return OpenerPosition.inWorld(player.x(), player.y(), player.z());
}
public record Context(WorldView world, Engine engine, UUID opener) {
}
@@ -1016,6 +1016,18 @@ public final class ModdedCommandMessages {
"iris.modded.moddedstudiocommands.studio_close_failed",
"Studio close failed: " + "{value}" + "{errorMessage}"
);
public static final TextKey MODDED_STUDIO_COMMANDS_STUDIO_HOTLOAD_COMPLETE = TextKey.of(
"iris.modded.moddedstudiocommands.studio_hotload_complete",
"Studio hotloaded."
);
public static final TextKey MODDED_STUDIO_COMMANDS_STUDIO_HOTLOAD_BUSY = TextKey.of(
"iris.modded.moddedstudiocommands.studio_hotload_busy",
"Studio is already checking or hotloading its pack. Try again after it finishes."
);
public static final TextKey MODDED_STUDIO_COMMANDS_STUDIO_HOTLOAD_FAILED = TextKey.of(
"iris.modded.moddedstudiocommands.studio_hotload_failed",
"Studio hotload failed{errorMessage}"
);
public static final TextKey MODDED_STUDIO_COMMANDS_STUDIO_CLOSED_WAS_EVACUATED_UNLOADED_ITS_REGION_DATA_DELETED = TextKey.of(
"iris.modded.moddedstudiocommands.studio_closed_was_evacuated_unloaded_its_region_data_deleted",
"Studio closed: " + "{dimensionId}" + " was evacuated, unloaded and its region data deleted."
@@ -1530,6 +1542,9 @@ public final class ModdedCommandMessages {
MODDED_STUDIO_COMMANDS_STUDIO_OPEN_NOW_RUNS_SEED_USE_IRIS_STUDIO_CLOSE_WHEN,
MODDED_STUDIO_COMMANDS_YOU_DO_NOT_HAVE_OPEN_STUDIO_USE_IRIS_STUDIO_OPEN,
MODDED_STUDIO_COMMANDS_STUDIO_CLOSE_FAILED,
MODDED_STUDIO_COMMANDS_STUDIO_HOTLOAD_COMPLETE,
MODDED_STUDIO_COMMANDS_STUDIO_HOTLOAD_BUSY,
MODDED_STUDIO_COMMANDS_STUDIO_HOTLOAD_FAILED,
MODDED_STUDIO_COMMANDS_STUDIO_CLOSED_WAS_EVACUATED_UNLOADED_ITS_REGION_DATA_DELETED,
MODDED_STUDIO_COMMANDS_NO_STUDIO_DIMENSIONS_ARE_CURRENTLY_OPEN,
MODDED_STUDIO_COMMANDS_ACTIVE_STUDIO_DIMENSION_S,
@@ -242,6 +242,10 @@ public final class ModdedHelpMessages {
"iris.modded.help.entry.command.status_studio",
"Show the open studio dimension and its pack"
);
public static final TextKey COMMAND_HOTLOAD_REFRESH_THE_OPEN_STUDIO = TextKey.of(
"iris.modded.help.entry.command.hotload_studio",
"Reload the open Studio pack for newly generated terrain"
);
public static final TextKey COMMAND_NOISE_OPEN_THE_NOISE_EXPLORER_GUI_ON_THE_SERVER_DISPLAY = TextKey.of(
"iris.modded.help.entry.command.noise",
"Open the Noise Explorer GUI on the server display"
@@ -479,6 +483,7 @@ public final class ModdedHelpMessages {
COMMAND_CLOSE_CLOSE_THE_OPEN_STUDIO_DIMENSION_AND_DISCARD_ITS_WORLD,
COMMAND_TPSTUDIO_TELEPORT_INTO_THE_OPEN_STUDIO_DIMENSION,
COMMAND_STATUS_SHOW_THE_OPEN_STUDIO_DIMENSION_AND_ITS_PACK,
COMMAND_HOTLOAD_REFRESH_THE_OPEN_STUDIO,
COMMAND_NOISE_OPEN_THE_NOISE_EXPLORER_GUI_ON_THE_SERVER_DISPLAY,
COMMAND_MAP_OPEN_THE_VISION_MAP_GUI_ON_THE_SERVER_DISPLAY,
COMMAND_VSCODE_REGENERATE_THE_CODE_WORKSPACE_FOR_A_PACK_AND_OPEN_IT_IN_YOUR,
@@ -123,6 +123,33 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
watches.clear();
}
public CompletableFuture<Boolean> requestHotload(String dimensionId, Engine engine) {
if (!dimensionId.startsWith(STUDIO_DIMENSION_PREFIX) || !engine.isStudio()) {
return CompletableFuture.failedFuture(new IllegalArgumentException("Only Studio worlds can be hotloaded."));
}
ExecutorService active = executor;
if (active == null || active.isShutdown()) {
return CompletableFuture.failedFuture(new IllegalStateException("Iris Studio hotload is unavailable."));
}
Watch watch = watches.computeIfAbsent(dimensionId, (String key) -> new Watch());
if (!watch.busy.compareAndSet(false, true)) {
return CompletableFuture.completedFuture(false);
}
try {
return CompletableFuture.supplyAsync(() -> {
try {
hotload(dimensionId, engine);
return true;
} finally {
watch.busy.set(false);
}
}, active);
} catch (RejectedExecutionException rejected) {
watch.busy.set(false);
return CompletableFuture.failedFuture(rejected);
}
}
@Override
public void refreshWorkspace(Engine engine) {
writeWorkspace(engine, "workspace refresh");
@@ -254,7 +281,7 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
try {
active.execute(() -> {
try {
poll(dimensionId, watch, generator, engine);
poll(dimensionId, watch, engine);
} finally {
watch.busy.set(false);
}
@@ -332,14 +359,14 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
return isPregeneratorActive(engine);
}
private void poll(String dimensionId, Watch watch, IrisModdedChunkGenerator generator, Engine engine) {
private void poll(String dimensionId, Watch watch, Engine engine) {
try {
if (watch.engine != engine) {
watch.close();
watch.engine = engine;
watch.folder = new ReactiveFolder(
engine.getData().getDataFolder(),
(KList<File> created, KList<File> changed, KList<File> deleted) -> hotload(dimensionId, generator, engine),
(KList<File> created, KList<File> changed, KList<File> deleted) -> hotload(dimensionId, engine),
new KList<>(".iob", ".json"),
new KList<>(".iris"),
new KList<>());
@@ -354,12 +381,12 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
}
}
private void hotload(String dimensionId, IrisModdedChunkGenerator generator, Engine engine) {
if (engine.isClosed()) {
return;
}
private void hotload(String dimensionId, Engine engine) {
long start = System.currentTimeMillis();
try {
if (engine.isClosed() || engine.isClosing()) {
throw new IllegalStateException("The Studio world is closing or closed.");
}
engine.hotloadSilently();
ModdedIrisLog.info("Iris studio hotload {} pack={} {}ms", dimensionId, engine.getDimension().getLoadKey(), System.currentTimeMillis() - start);
} catch (Throwable e) {
@@ -0,0 +1,149 @@
package art.arcane.iris.modded.command;
import art.arcane.iris.generation.hydrology.engine.HydrologyRuntime;
import art.arcane.iris.generation.hydrology.query.HydrologyFeatureQuery;
import art.arcane.iris.generation.hydrology.query.HydrologyFeatureRef;
import art.arcane.iris.generation.hydrology.query.HydrologyFeatureType;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.world.history.GenerationSemanticQueries;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeCommandSource;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeStructureQueries;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.MockedConstruction;
import org.mockito.MockedStatic;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import static org.junit.Assert.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyDouble;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockConstruction;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class ModdedLocateRiverTest {
private static final int DISTANCE = GenerationSemanticQueries.RIVER_SEARCH_DISTANCE;
@Test
public void unknownTypeFailsBeforeSearching() {
Fixture fixture = new Fixture();
try (MockedStatic<IrisModdedCommands> commands = fixture.commands()) {
assertEquals(0, ModdedLocateCommands.gotoRiver(fixture.source, "ocean_trench"));
commands.verify(() -> IrisModdedCommands.fail(eq(fixture.source), anyString()));
commands.verify(() -> IrisModdedCommands.ok(eq(fixture.source), anyString()), never());
verify(fixture.server, never()).execute(any());
verify(fixture.runtime, never()).nearestFeature(anyInt(), anyInt(), any(), anyInt(), any());
}
}
@Test
public void missingFeatureReportsNotFound() throws Exception {
Fixture fixture = new Fixture();
when(fixture.runtime.nearestFeature(anyInt(), anyInt(), any(), anyInt(), any())).thenReturn(Optional.empty());
try (MockedStatic<IrisModdedCommands> commands = fixture.commands()) {
assertEquals(1, ModdedLocateCommands.gotoRiver(fixture.source, "river"));
fixture.finishOnServerThread();
commands.verify(() -> IrisModdedCommands.fail(fixture.source, "No river found within " + DISTANCE + " blocks."));
verify(fixture.player, never()).teleport(any(), anyDouble(), anyDouble(), anyDouble());
}
}
@Test
public void plannerFailureIsReportedAsAnErrorNotAsNotFound() throws Exception {
Fixture fixture = new Fixture();
when(fixture.runtime.nearestFeature(anyInt(), anyInt(), any(), anyInt(), any()))
.thenThrow(new IllegalStateException("window planner exploded"));
try (MockedStatic<IrisModdedCommands> commands = fixture.commands()) {
assertEquals(1, ModdedLocateCommands.gotoRiver(fixture.source, "major_river"));
fixture.finishOnServerThread();
commands.verify(() -> IrisModdedCommands.fail(fixture.source,
"Could not search for major river: IllegalStateException - window planner exploded"));
commands.verify(() -> IrisModdedCommands.fail(eq(fixture.source), eq("No major river found within " + DISTANCE + " blocks.")),
never());
verify(fixture.player, never()).teleport(any(), anyDouble(), anyDouble(), anyDouble());
}
}
@Test
public void foundFeatureTeleportsAboveItFromThePlayersPosition() throws Exception {
Fixture fixture = new Fixture();
when(fixture.runtime.nearestFeature(anyInt(), anyInt(), any(), anyInt(), any()))
.thenReturn(Optional.of(new HydrologyFeatureRef(9L, HydrologyFeatureType.WATERFALL, 400, 130, -250)));
when(fixture.player.teleport(fixture.world, 400.5D, 68D, -249.5D)).thenReturn(true);
try (MockedStatic<IrisModdedCommands> commands = fixture.commands();
MockedConstruction<NativeStructureQueries> queries = mockConstruction(NativeStructureQueries.class)) {
assertEquals(1, ModdedLocateCommands.gotoRiver(fixture.source, "waterfall"));
fixture.finishOnServerThread();
ArgumentCaptor<HydrologyFeatureQuery> query = ArgumentCaptor.forClass(HydrologyFeatureQuery.class);
verify(fixture.runtime).nearestFeature(eq(120), eq(-75), query.capture(), eq(DISTANCE), any());
assertEquals(HydrologyFeatureQuery.of(HydrologyFeatureType.WATERFALL), query.getValue());
verify(queries.constructed().getFirst()).loadChunk(400 >> 4, -250 >> 4);
verify(fixture.player).teleport(fixture.world, 400.5D, 68D, -249.5D);
commands.verify(() -> IrisModdedCommands.ok(eq(fixture.source), anyString()), times(2));
commands.verify(() -> IrisModdedCommands.fail(eq(fixture.source), anyString()), never());
}
}
private static final class Fixture {
private final NativeCommandSource source = mock(NativeCommandSource.class);
private final NativeWorld world = mock(NativeWorld.class);
private final NativeProtocolPlayer player = mock(NativeProtocolPlayer.class);
private final NativeModdedServer server = mock(NativeModdedServer.class);
private final Engine engine = mock(Engine.class);
private final HydrologyRuntime runtime = mock(HydrologyRuntime.class);
private final CompletableFuture<Runnable> completion = new CompletableFuture<>();
private Fixture() {
IrisComplex complex = mock(IrisComplex.class);
IrisDimension dimension = mock(IrisDimension.class);
when(source.player()).thenReturn(player);
when(source.world()).thenReturn(world);
when(source.server()).thenReturn(server);
when(player.blockX()).thenReturn(120);
when(player.blockZ()).thenReturn(-75);
when(player.connected()).thenReturn(true);
when(player.inWorld(world)).thenReturn(true);
when(world.minHeight()).thenReturn(-64);
when(world.maxHeight()).thenReturn(320);
when(engine.getComplex()).thenReturn(complex);
when(engine.getDimension()).thenReturn(dimension);
when(dimension.getMinHeight()).thenReturn(-64);
when(complex.getHydrologyRuntime()).thenReturn(runtime);
when(runtime.enabled()).thenReturn(true);
doAnswer(invocation -> {
completion.complete(invocation.getArgument(0));
return null;
}).when(server).execute(any());
}
private MockedStatic<IrisModdedCommands> commands() {
MockedStatic<IrisModdedCommands> commands = mockStatic(IrisModdedCommands.class);
commands.when(() -> IrisModdedCommands.engineFor(world)).thenReturn(engine);
return commands;
}
private void finishOnServerThread() throws Exception {
completion.get(10L, TimeUnit.SECONDS).run();
}
}
}
@@ -1,6 +1,7 @@
package art.arcane.iris.modded.command;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.generation.hydrology.engine.HydrologyPregeneration;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.modded.ModdedGenPool;
import art.arcane.iris.world.pregen.MantleHeapPressure;
@@ -11,8 +12,10 @@ import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import org.mockito.MockedStatic;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
@@ -31,9 +34,11 @@ import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
@@ -89,6 +94,25 @@ public class ModdedPregenMethodLifecycleTest {
verify(runtime, never()).execute(any());
}
@Test
public void closeWithdrawsQueuedHydrologyPrefetchAndRefusesLaterAnnouncements() {
HydrologyPregeneration epoch = mock(HydrologyPregeneration.class);
when(engine.getComplex().getHydrologyRuntime().openPregeneration()).thenReturn(epoch);
when(runtime.isServerThread()).thenReturn(true);
assertTrue(method.deferFinalSaveToServerThread());
List<long[]> ahead = List.of(new long[]{0L, 0L}, new long[]{1L, 0L});
method.onRegionsAhead(ahead);
method.close();
method.onRegionsAhead(ahead);
InOrder order = inOrder(epoch);
order.verify(epoch).prepare(ahead);
order.verify(epoch).close();
verify(epoch, times(1)).prepare(anyList());
method.completeDeferredFinalSave();
}
@Test
public void asynchronousCallerCannotClaimTheShutdownSave() {
assertFalse(method.deferFinalSaveToServerThread());
@@ -0,0 +1,113 @@
package art.arcane.iris.modded.command;
import art.arcane.iris.localization.IrisLanguage;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeCommandSource;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeCommandText;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import art.arcane.volmlib.util.localization.LanguageAudience;
import com.mojang.brigadier.CommandDispatcher;
import net.minecraft.SharedConstants;
import net.minecraft.server.Bootstrap;
import org.junit.After;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.File;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class ModdedStudioHotloadLanguageTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
private final UUID playerId = UUID.randomUUID();
private File dataFolder;
@BeforeClass
public static void bootstrap() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
}
@Before
public void setUp() throws Exception {
dataFolder = temporaryFolder.newFolder();
assertTrue(IrisLanguage.reload(dataFolder, "en_US"));
Files.writeString(dataFolder.toPath().resolve("languages/de_DE.toml"),
"\"iris.modded.moddedstudiocommands.you_do_not_have_open_studio_use_iris_studio_open\" = \"Kein Studio geöffnet.\"\n");
IrisLanguage.start();
IrisLanguage.selections().selectPlayer(playerId, "de_DE").get(5L, TimeUnit.SECONDS);
ModdedStudioCommands.clear();
}
@After
public void tearDown() {
ModdedStudioCommands.clear();
IrisLanguage.shutdown();
assertTrue(IrisLanguage.reload(dataFolder, "en_US"));
}
@Test
public void delayedHotloadFeedbackKeepsThePlayersLanguage() throws Exception {
assertEquals(List.of("Kein Studio geöffnet."), runCommand("studio hotload", true));
}
@Test
public void delayedConsoleRefreshUsesTheServerLanguage() throws Exception {
assertEquals(List.of("You do not have an open studio. Use /iris studio open <pack> first."),
runCommand("studio refresh", false));
}
private List<String> runCommand(String command, boolean playerCommand) throws Exception {
NativeModdedServer server = mock(NativeModdedServer.class);
List<Runnable> tasks = new ArrayList<>();
doAnswer(invocation -> {
tasks.add(invocation.getArgument(0));
return null;
}).when(server).execute(any(Runnable.class));
NativeCommandSource source = mock(NativeCommandSource.class);
when(source.server()).thenReturn(server);
when(source.isGameMaster()).thenReturn(true);
if (playerCommand) {
NativeProtocolPlayer player = mock(NativeProtocolPlayer.class);
when(player.id()).thenReturn(playerId);
when(source.player()).thenReturn(player);
when(source.playerId()).thenReturn(playerId);
}
List<String> messages = new ArrayList<>();
doAnswer(invocation -> {
NativeCommandText text = invocation.getArgument(0);
messages.add(text.getString());
return null;
}).when(source).sendFailure(any());
CommandDispatcher<NativeCommandSource> dispatcher = new CommandDispatcher<>();
dispatcher.register(ModdedStudioCommands.tree("studio"));
assertEquals(1, dispatcher.execute(command, source));
assertTrue(messages.isEmpty());
assertNull(LanguageAudience.current());
UUID ambientAudience = playerCommand ? UUID.randomUUID() : playerId;
try (LanguageAudience.Scope audience = LanguageAudience.open(ambientAudience)) {
while (!tasks.isEmpty()) {
tasks.removeFirst().run();
assertEquals(ambientAudience, LanguageAudience.current());
}
}
return messages;
}
}
@@ -1,25 +1,98 @@
package art.arcane.iris.modded.command;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.studio.view.OpenerPosition;
import art.arcane.iris.world.IrisWorld;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.volmlib.nativelib.view.WorldView;
import org.junit.Test;
import org.mockito.MockedStatic;
import org.mockito.invocation.InvocationOnMock;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
public class ModdedVisionOverlayTest {
@Test
public void rejectedServerTaskCompletesThePositionRequestAsOffline() {
WorldView world = mock(WorldView.class);
doThrow(new RejectedExecutionException("Server stopped")).when(world).execute(any(Runnable.class));
ModdedVisionOverlay overlay = new ModdedVisionOverlay(new ModdedVisionOverlay.Context(world,
mock(Engine.class), UUID.randomUUID()));
AtomicReference<OpenerPosition> position = new AtomicReference<>();
overlay.requestOpenerPosition(position::set);
assertEquals(OpenerPosition.OFFLINE, position.get());
}
@Test
public void openerPositionRunsOnTheServerAndTracksDisconnectAndWorldChanges() {
WorldView world = mock(WorldView.class);
AtomicReference<Runnable> scheduled = new AtomicReference<>();
doAnswer(invocation -> {
scheduled.set(invocation.getArgument(0));
return null;
}).when(world).execute(any(Runnable.class));
Engine engine = mock(Engine.class);
IrisWorld irisWorld = mock(IrisWorld.class);
NativeWorld level = mock(NativeWorld.class);
NativeModdedServer server = mock(NativeModdedServer.class);
NativeProtocolPlayer player = mock(NativeProtocolPlayer.class);
UUID opener = UUID.randomUUID();
when(engine.getWorld()).thenReturn(irisWorld);
when(irisWorld.platformWorld()).thenReturn(level);
when(server.player(opener)).thenReturn(player);
when(player.connected()).thenReturn(true);
when(player.inWorld(level)).thenReturn(true);
when(player.x()).thenReturn(21.5D);
when(player.y()).thenReturn(85.25D);
when(player.z()).thenReturn(-39.5D);
ModdedVisionOverlay overlay = new ModdedVisionOverlay(new ModdedVisionOverlay.Context(world, engine, opener));
AtomicReference<OpenerPosition> position = new AtomicReference<>();
try (MockedStatic<NativeModdedServer> nativeServer = mockStatic(NativeModdedServer.class)) {
nativeServer.when(() -> NativeModdedServer.forWorld(level)).thenReturn(server);
overlay.requestOpenerPosition(position::set);
assertNull("position reads must wait for the server task", position.get());
scheduled.get().run();
assertEquals(OpenerPosition.inWorld(21.5D, 85.25D, -39.5D), position.get());
overlay.requestOpenerPosition(position::set);
when(player.connected()).thenReturn(false);
scheduled.get().run();
assertEquals(OpenerPosition.OFFLINE, position.get());
when(player.connected()).thenReturn(true);
when(player.inWorld(level)).thenReturn(false);
overlay.requestOpenerPosition(position::set);
scheduled.get().run();
assertEquals(OpenerPosition.IN_OTHER_WORLD, position.get());
when(player.inWorld(level)).thenReturn(true);
overlay.requestOpenerPosition(position::set);
scheduled.get().run();
assertEquals(OpenerPosition.inWorld(21.5D, 85.25D, -39.5D), position.get());
}
}
/**
* A Vision click can land anywhere in the world, so the destination chunk is usually cold and gets the same
* bounded deadline as /iris tp.
@@ -1,5 +1,6 @@
package art.arcane.iris.modded.service;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.modded.ModdedScheduler;
import art.arcane.volmlib.util.collection.KList;
@@ -8,19 +9,130 @@ import org.mockito.InOrder;
import java.lang.reflect.Field;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class ModdedStudioHotloadServiceTest {
@Test
public void manualHotloadRunsWithoutAFileChangeOnTheWorker() throws Exception {
ModdedStudioHotloadService service = new ModdedStudioHotloadService();
Engine engine = studioEngine();
Thread caller = Thread.currentThread();
doAnswer(invocation -> {
assertNotSame(caller, Thread.currentThread());
return null;
}).when(engine).hotloadSilently();
service.onEnable();
try {
assertTrue(service.requestHotload("irisworldgen:studio_test", engine).get(5L, TimeUnit.SECONDS));
verify(engine).hotloadSilently();
} finally {
service.onDisable();
}
}
@Test
public void overlappingManualHotloadIsRejected() throws Exception {
ModdedStudioHotloadService service = new ModdedStudioHotloadService();
Engine engine = studioEngine();
CountDownLatch entered = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
doAnswer(invocation -> {
entered.countDown();
assertTrue(release.await(5L, TimeUnit.SECONDS));
return null;
}).when(engine).hotloadSilently();
service.onEnable();
try {
CompletableFuture<Boolean> first = service.requestHotload("irisworldgen:studio_test", engine);
assertTrue(entered.await(5L, TimeUnit.SECONDS));
assertFalse(service.requestHotload("irisworldgen:studio_test", engine).get(5L, TimeUnit.SECONDS));
release.countDown();
assertTrue(first.get(5L, TimeUnit.SECONDS));
verify(engine).hotloadSilently();
} finally {
release.countDown();
service.onDisable();
}
}
@Test
public void closingStudioIsNotHotloadedAfterWaitingInTheQueue() throws Exception {
ModdedStudioHotloadService service = new ModdedStudioHotloadService();
Engine engine = studioEngine();
ExecutorService executor = Executors.newSingleThreadExecutor();
CountDownLatch release = new CountDownLatch(1);
executor.submit(() -> {
try {
assertTrue(release.await(5L, TimeUnit.SECONDS));
} catch (InterruptedException failure) {
Thread.currentThread().interrupt();
throw new IllegalStateException(failure);
}
});
setExecutor(service, executor);
try {
CompletableFuture<Boolean> requested = service.requestHotload("irisworldgen:studio_test", engine);
when(engine.isClosing()).thenReturn(true);
release.countDown();
assertThrows(ExecutionException.class, () -> requested.get(5L, TimeUnit.SECONDS));
verify(engine, never()).hotloadSilently();
} finally {
release.countDown();
service.onDisable();
}
}
@Test
public void manualHotloadRejectsProductionWorlds() throws Exception {
ModdedStudioHotloadService service = new ModdedStudioHotloadService();
Engine engine = studioEngine();
service.onEnable();
try {
assertThrows(ExecutionException.class, () -> service.requestHotload("irisworldgen:production", engine)
.get(5L, TimeUnit.SECONDS));
when(engine.isStudio()).thenReturn(false);
assertThrows(ExecutionException.class, () -> service.requestHotload("irisworldgen:studio_test", engine)
.get(5L, TimeUnit.SECONDS));
verify(engine, never()).hotloadSilently();
} finally {
service.onDisable();
}
}
@Test
public void failedManualHotloadReleasesTheWatchForRetry() throws Exception {
ModdedStudioHotloadService service = new ModdedStudioHotloadService();
Engine engine = studioEngine();
doThrow(new IllegalStateException("Invalid pack")).doNothing().when(engine).hotloadSilently();
service.onEnable();
try {
assertThrows(ExecutionException.class, () -> service.requestHotload("irisworldgen:studio_test", engine)
.get(5L, TimeUnit.SECONDS));
assertTrue(service.requestHotload("irisworldgen:studio_test", engine).get(5L, TimeUnit.SECONDS));
} finally {
service.onDisable();
}
}
@Test
public void shutdownDrainsWorkerBeforeAllowingRestart() throws Exception {
ModdedStudioHotloadService service = new ModdedStudioHotloadService();
@@ -102,4 +214,11 @@ public class ModdedStudioHotloadServiceTest {
field.set(service, executor);
}
private static Engine studioEngine() {
Engine engine = mock(Engine.class);
when(engine.isStudio()).thenReturn(true);
when(engine.getDimension()).thenReturn(new IrisDimension());
return engine;
}
}
+9 -3
View File
@@ -60,7 +60,7 @@ apply from: file('../../gradle/java21-runtime-exclusions.gradle')
Properties rootProperties = new Properties()
file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) }
String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.3.5'))
String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.3.6'))
String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3'))
String neoForgeVersion = providers.gradleProperty('neoForgeVersion').getOrElse(irisModTarget.neoForge.toString())
Closure<String> irisArtifactName = { String platform, String targetVersion ->
@@ -132,6 +132,8 @@ configurations.named('bundle').configure {
exclude(group: 'de.crazydev22.slimjar.helper')
exclude(group: 'de.crazydev22.slimjar')
exclude(group: 'io.github.slimjar')
// The game ships its own LWJGL; without the modules it leaves out, the map's 3D views draw in software.
exclude(group: 'org.lwjgl')
}
configurations.compileClasspath.extendsFrom(configurations.devBundle)
@@ -141,7 +143,7 @@ configurations.testRuntimeClasspath.extendsFrom(configurations.runtimeClasspath,
dependencies {
testImplementation('junit:junit:4.13.2')
testImplementation('org.mockito:mockito-core:5.23.0')
testImplementation(libs.mockito.core)
testImplementation(testFixtures('art.arcane:core'))
testImplementation(libs.spigot) {
transitive = false
@@ -173,7 +175,9 @@ dependencies {
libs.caffeine,
'com.fulcrumgenomics:jlibdeflate:0.2.0',
libs.dom4j,
libs.jaxen
libs.jaxen,
libs.flatlaf,
libs.flatlaf.extras
]
add('bundle', libs.asm)
add('devBundle', 'org.ow2.asm:asm') {
@@ -261,6 +265,8 @@ tasks.named('compileJava', JavaCompile).configure {
options.release.set((int) irisModTarget.java)
}
apply from: file('../../gradle/mockito-agent.gradle')
tasks.named('test').configure {
useJUnitPlatform()
classpath = classpath.minus(developmentModLibraries).plus(sourceSets.modLibraries.output)
+12 -7
View File
@@ -55,7 +55,7 @@ apply from: file('gradle/runtime-relocations.gradle')
apply from: file('gradle/volmlib-stamp.gradle')
group = 'art.arcane'
version = providers.gradleProperty('irisVersion').getOrElse('4.3.5')
version = providers.gradleProperty('irisVersion').getOrElse('4.3.6')
project(':core') {
group = 'art.arcane'
@@ -89,7 +89,6 @@ Closure<String> irisArtifactName = { String platform, String targetVersion ->
String bukkitArtifactName = irisArtifactName('CraftBukkit', bukkitMinecraftRange)
Map<String, String> runtimeDownloadRelocations = (Map<String, String>) ext.runtimeDownloadRelocations
List<String> runtimeDownloadedPrefixes = (List<String>) ext.runtimeDownloadedPrefixes
File bukkitArtifact = layout.buildDirectory.file("libs/${bukkitArtifactName}").get().asFile
apply plugin: ApiGenerator
List<String> nmsBindings = ['v26_2_R1', 'v26_3_R1']
@@ -127,7 +126,7 @@ nmsBindings.each { String key ->
compileOnly(rootProject.libs.annotations)
compileOnly(rootProject.libs.byteBuddy.core)
testImplementation('junit:junit:4.13.2')
testImplementation('org.mockito:mockito-core:5.23.0')
testImplementation(rootProject.libs.mockito.core)
if (key == 'v26_3_R1') {
testImplementation(project(':probe'))
}
@@ -194,6 +193,7 @@ tasks.named('jar', Jar).configure {
includeEmptyDirs = false
from(jarJar)
from(provider { included.resolve().collect { zipTree(it) } }) {
exclude('META-INF/INDEX.LIST')
exclude('languages/**')
exclude('com/fren_gor/**')
@@ -534,19 +534,20 @@ List<String> requiredBukkitArtifactEntries = [
tasks.register('verifyBukkitArtifact') {
group = 'verification'
dependsOn('jar')
File artifact = bukkitArtifact
Provider<RegularFile> artifact = tasks.named('jar', Jar).flatMap { Jar task -> task.archiveFile }
inputs.file(artifact)
inputs.files(nativeRuntime)
inputs.files(commandHandlerSources)
inputs.files(mantleComponentSources)
doLast {
File archive = artifact.get().asFile
BukkitArtifactVerifier.verify(
artifact,
archive,
requiredBukkitArtifactEntries,
16,
runtimeDownloadedPrefixes)
NativeRuntimeArtifacts.verify(artifact, nativeRuntimeModules, embeddedNatives())
logger.lifecycle("Verified ${artifact.name} packaging and class reference graph")
NativeRuntimeArtifacts.verify(archive, nativeRuntimeModules, embeddedNatives())
logger.lifecycle("Verified ${archive.name} packaging and class reference graph")
}
}
@@ -586,11 +587,14 @@ allprojects {
layout.buildDirectory.set(layout.projectDirectory.dir("build/${runtimeOutputVariant}"))
}
apply from: rootProject.file('gradle/mockito-agent.gradle')
tasks.withType(Test).configureEach {
int moduleForks = project.path.startsWith(':adapters:bukkit:') ? Math.min(2, testForks)
: project.path == ':probe' ? Math.min(4, testForks) : testForks
maxParallelForks = Math.min(gradle.startParameter.maxWorkerCount, moduleForks)
jvmArgs('-XX:ParallelGCThreads=2', '-XX:ConcGCThreads=1', '-XX:CICompilerCount=2')
timeout = java.time.Duration.ofMinutes(60)
}
java {
@@ -731,6 +735,7 @@ pluginPackaging {
policyName = 'Iris Bukkit'
packed = true
packedJarAccessors = ['art/arcane/iris/platform/bukkit/plugin/VolmitPlugin#getJarFile']
packedPayloadOnlyPrefixes = ['iris-language-guides/']
stripDirectories = true
stripLocalVariables = true
releaseCompression = providers.gradleProperty('compactRelease')
+16 -3
View File
@@ -108,6 +108,10 @@ dependencies {
compileOnly(libs.log4j.api)
compileOnly(libs.log4j.core)
compileOnly(libs.guava)
compileOnly(libs.lwjgl)
compileOnly(libs.lwjgl.opengl)
compileOnly(libs.lwjgl.egl)
compileOnly(libs.lwjgl.jawt)
// Third Party Integrations
compileOnly(libs.nexo)
@@ -161,23 +165,32 @@ dependencies {
slim(libs.byteBuddy.agent)
slim(libs.dom4j)
slim(libs.jaxen)
slim(libs.flatlaf)
slim(libs.flatlaf.extras)
testFixturesImplementation('junit:junit:4.13.2')
testFixturesImplementation('org.mockito:mockito-core:5.23.0')
testFixturesImplementation(libs.mockito.core)
testFixturesCompileOnly(libs.paper.api)
testImplementation('junit:junit:4.13.2')
testImplementation('org.mockito:mockito-core:5.23.0')
testImplementation(libs.mockito.core)
testImplementation(libs.paper.api)
testImplementation(libs.log4j.core)
testRuntimeOnly(libs.paper.api)
testImplementation(libs.multiverseCore)
testImplementation(libs.annotations)
testImplementation(libs.mythic)
testRuntimeOnly(libs.lwjgl)
testRuntimeOnly(libs.lwjgl.opengl)
testRuntimeOnly(libs.lwjgl.egl)
testRuntimeOnly(libs.lwjgl.jawt)
testRuntimeOnly(variantOf(libs.lwjgl) { classifier('natives-linux') })
testRuntimeOnly(variantOf(libs.lwjgl.opengl) { classifier('natives-linux') })
}
tasks.named('test').configure {
maxHeapSize = '1g'
timeout = java.time.Duration.ofMinutes(60)
maxHeapSize = '2g'
jvmArgs('-XX:+ExitOnOutOfMemoryError')
systemProperty('iris.generation.counters', 'true')
}
+2 -2
View File
@@ -26,7 +26,6 @@ art/arcane/iris/generation/decoration/IrisSpeleothems.java
art/arcane/iris/generation/decoration/IrisSurface.java
art/arcane/iris/generation/decoration/tree/IrisTree.java
art/arcane/iris/generation/geometry/AxisAlignedBB.java
art/arcane/iris/generation/mantle/MantleWriter.java
art/arcane/iris/generation/runtime/BukkitEngineWorldManager.java
art/arcane/iris/generation/runtime/EngineAssignedWorldManager.java
art/arcane/iris/generation/runtime/EnginePlayer.java
@@ -57,7 +56,6 @@ art/arcane/iris/integration/data/NexoDataProvider.java
art/arcane/iris/integration/data/OraxenDataProvider.java
art/arcane/iris/localization/C.java
art/arcane/iris/pack/IrisConverter.java
art/arcane/iris/pack/IrisPack.java
art/arcane/iris/pack/IrisPackBenchmarking.java
art/arcane/iris/pack/datapack/DatapackIngestService.java
art/arcane/iris/pack/datapack/ServerConfigurator.java
@@ -126,8 +124,10 @@ art/arcane/iris/studio/jigsaw/JigsawStudioAutosaveScheduler.java
art/arcane/iris/studio/jigsaw/JigsawStudioCapture.java
art/arcane/iris/studio/jigsaw/JigsawStudioCaptureScheduler.java
art/arcane/iris/studio/jigsaw/JigsawStudioChunkWriter.java
art/arcane/iris/studio/jigsaw/JigsawStudioConnectorTool.java
art/arcane/iris/studio/jigsaw/JigsawStudioDetailsMenu.java
art/arcane/iris/studio/jigsaw/JigsawStudioDirtyTracker.java
art/arcane/iris/studio/jigsaw/JigsawStudioFloorPainter.java
art/arcane/iris/studio/jigsaw/JigsawStudioGraphMutations.java
art/arcane/iris/studio/jigsaw/JigsawStudioMainMenu.java
art/arcane/iris/studio/jigsaw/JigsawStudioMarkerParser.java
@@ -1,25 +1,14 @@
package art.arcane.iris.command.specialhandlers;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.hydrology.HydrologyFeatureQuery;
import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntime;
import art.arcane.iris.command.DirectorParameterHandler;
import art.arcane.iris.generation.hydrology.query.HydrologyFeatureType;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.director.exceptions.DirectorParsingException;
import java.util.List;
public final class HydrologyTypeHandler implements DirectorParameterHandler<String> {
@Override
public KList<String> getPossibilities() {
Engine activeEngine = engine();
if (activeEngine == null) {
return new KList<>(HydrologyFeatureQuery.suggestions(List.of()));
}
IrisHydrologyRuntime runtime = activeEngine.getComplex().getHydrologyRuntime();
return new KList<>(runtime == null
? HydrologyFeatureQuery.suggestions(List.of())
: runtime.featureQueryKeys());
return new KList<>(HydrologyFeatureType.keys());
}
@Override
@@ -29,16 +18,11 @@ public final class HydrologyTypeHandler implements DirectorParameterHandler<Stri
@Override
public String parse(String input, boolean force) throws DirectorParsingException {
String normalized = input == null ? "" : input.trim();
if (normalized.isEmpty()) {
throw new DirectorParsingException("Hydrology feature type must not be blank.");
try {
return HydrologyFeatureType.parse(input).key();
} catch (IllegalArgumentException error) {
throw new DirectorParsingException(error.getMessage());
}
for (String possibility : getPossibilities(normalized)) {
if (possibility.equalsIgnoreCase(normalized)) {
return possibility;
}
}
return normalized;
}
@Override
@@ -48,6 +32,6 @@ public final class HydrologyTypeHandler implements DirectorParameterHandler<Stri
@Override
public String getRandomDefault() {
return "surface";
return HydrologyFeatureType.RIVER.key();
}
}
@@ -373,6 +373,7 @@ public class IrisSettings {
public boolean useServerLaunchedGuis = true;
public boolean maximumPregenGuiFPS = false;
public boolean colorMode = true;
public String theme = "auto";
}
@Data
@@ -26,7 +26,7 @@ import art.arcane.iris.generation.decoration.IrisDepositVariant;
import art.arcane.iris.generation.decoration.IrisOreGenerator;
import art.arcane.iris.generation.decoration.IrisOreBands;
import art.arcane.iris.generation.decoration.IrisProceduralObjects;
import art.arcane.iris.generation.hydrology.IrisRiverPolicy;
import art.arcane.iris.generation.hydrology.config.IrisHydrologyPolicy;
import art.arcane.iris.generation.noise.IrisGeneratorStyle;
import art.arcane.iris.generation.noise.NoiseStyle;
import art.arcane.iris.generation.terrain.InferredType;
@@ -161,8 +161,8 @@ public class IrisBiome extends IrisRegistrant implements Rarity {
private int lockLayersMax = 7;
@Description("Profile-driven 3D cave configuration")
private IrisCaveProfile caveProfile = new IrisCaveProfile();
@Description("Biome-level river placement, routing, profile, biome, and geometry policy. Omit to inherit region and dimension settings.")
private IrisRiverPolicy riverPolicy = null;
@Description("Water settings for this biome: rainfall, pond density, where streams and ponds may form, and the fluid that fills them. A value left unset comes from the region, then from the dimension.")
private IrisHydrologyPolicy hydrologyPolicy = null;
@MinNumber(1)
@MaxNumber(512)
@Description("The rarity of this biome (integer)")
@@ -580,6 +580,10 @@ public class IrisBiome extends IrisRegistrant implements Rarity {
return IrisBiomeLayerGenerator.getLayerSeaHeightGenerators(this, rng, data);
}
public KList<CNG> getCaveCeilingLayerHeightGenerators(RNG rng, IrisData data) {
return IrisBiomeLayerGenerator.getCaveCeilingLayerHeightGenerators(this, rng, data);
}
public NativeBlockState getSurfaceBlock(int x, int z, RNG rng, IrisData idm) {
return IrisBiomeLayerGenerator.getSurfaceBlock(this, x, z, rng, idm);
}
@@ -148,8 +148,7 @@ final class IrisBiomeLayerGenerator {
return data;
}
KList<CNG> heightGenerators = getHeightGenerators(
biome.getLayerCeilingHeightGenerators(), ceilingLayers, 7235, random, rdata);
KList<CNG> heightGenerators = getCaveCeilingLayerHeightGenerators(biome, random, rdata);
for (int i = 0; i < layerCount; i++) {
IrisBiomePaletteLayer layer = ceilingLayers.get(i);
@@ -317,6 +316,10 @@ final class IrisBiomeLayerGenerator {
return getHeightGenerators(biome.getLayerSeaHeightGenerators(), biome.getSeaLayers(), 7735, rng, data);
}
static KList<CNG> getCaveCeilingLayerHeightGenerators(IrisBiome biome, RNG rng, IrisData data) {
return getHeightGenerators(biome.getLayerCeilingHeightGenerators(), biome.getCaveCeilingLayers(), 7235, rng, data);
}
private static KList<CNG> getHeightGenerators(IrisBiomeLayerHeightCache cache,
KList<IrisBiomePaletteLayer> layers,
int seedOffset, RNG rng, IrisData data) {
@@ -5,7 +5,7 @@ import art.arcane.iris.generation.runtime.DimensionStackLayout;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.runtime.EngineMetrics;
import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntime;
import art.arcane.iris.generation.hydrology.raster.ChunkWaterRaster;
import art.arcane.iris.generation.biome.FloatingIslandBoundarySampler;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.mantle.CaveTerrainSnapshot;
@@ -25,6 +25,7 @@ public class ChunkContext {
private FloatingBiomeOverlay floatingBiomes;
private CaveTerrainSnapshot caveTerrain;
private TransitionTerrainColumn[] transitionTerrain;
private volatile ChunkWaterRaster waterRaster;
private final int x;
private final int z;
private final IrisComplex complex;
@@ -89,10 +90,6 @@ public class ChunkContext {
PrefillPlan resolvedPlan = prefillPlan == null ? PrefillPlan.NO_CAVE : prefillPlan;
boolean capturePrefillMetric = metrics != null;
long totalStartNanos = capturePrefillMetric ? System.nanoTime() : 0L;
IrisHydrologyRuntime hydrologyRuntime = complex.getHydrologyRuntime();
if (hydrologyRuntime != null && (resolvedPlan.height || resolvedPlan.biome)) {
hydrologyRuntime.prepareChunkColumns(x, z);
}
if (shouldPrefillAsync(resolvedPlan, complex.isPlatformMainThread())) {
prefillAsync(resolvedPlan);
} else {
@@ -115,6 +112,9 @@ public class ChunkContext {
}
private void prefillSurface(PrefillPlan prefillPlan) {
if (prefillPlan.height || prefillPlan.biome) {
waterRaster();
}
if (prefillPlan.height) {
height.fillRounded(roundedHeight);
}
@@ -209,6 +209,18 @@ public class ChunkContext {
return complex;
}
public ChunkWaterRaster waterRaster() {
ChunkWaterRaster known = waterRaster;
if (known != null) {
return known;
}
ChunkWaterRaster raster = complex.getHydrologyRuntime().raster(x >> 4, z >> 4);
if (!raster.provisional()) {
waterRaster = raster;
}
return raster;
}
public FloatingIslandBoundarySampler getFloatingIslandBoundarySampler() {
return floatingIslandBoundarySampler;
}
@@ -542,7 +542,7 @@ final class DecoratorCore {
return stack;
}
private static boolean allowsSurface(IrisDecorator decorator, NativeBlockState surface, IrisData data) {
static boolean allowsSurface(IrisDecorator decorator, NativeBlockState surface, IrisData data) {
return decorator.isForcePlace()
|| (decorator.getWhitelist() == null || matchesPalette(decorator.getWhitelistArray(data), surface))
&& (decorator.getBlacklist() == null || !matchesPalette(decorator.getBlacklistArray(data), surface));
@@ -0,0 +1,390 @@
package art.arcane.iris.generation.decoration;
import art.arcane.iris.generation.block.B;
import art.arcane.iris.generation.block.IrisBlockData;
import art.arcane.iris.generation.hydrology.plan.HydrologySettings;
import art.arcane.iris.generation.hydrology.raster.WaterRole;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.volmlib.util.hunk.Hunk;
import art.arcane.volmlib.util.math.RNG;
import java.util.ArrayList;
import java.util.List;
public final class HydrologyVegetation {
private static final long SALT = 0x4859445256454745L;
private static final long GOLDEN = 0x9e3779b97f4a7c15L;
private static final int[][] SIDES = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
private final long seed;
private final Engine engine;
private final RNG floorRng;
private final RNG surfaceRng;
private final RNG shoreRng;
public HydrologyVegetation(long seed, Engine engine) {
this.seed = seed ^ SALT;
this.engine = engine;
this.floorRng = new RNG(DecoratorCore.partSeed(this.seed, IrisDecorationPart.SEA_FLOOR));
this.surfaceRng = new RNG(DecoratorCore.partSeed(this.seed, IrisDecorationPart.SEA_SURFACE));
this.shoreRng = new RNG(DecoratorCore.partSeed(this.seed, IrisDecorationPart.SHORE_LINE));
}
public void decorate(Hunk<NativeBlockState> output, int localX, int localZ, int x, int z, WaterRole role, Water water, IrisData data) {
Placement placement = placement(role);
if (placement == Placement.NONE) {
return;
}
Decorators decorators = water.decorators(x, z);
if (decorators.empty()) {
return;
}
int ground = water.ground(x, z);
if (ground < 0 || ground + 1 >= output.getHeight()) {
return;
}
Column column = new Column(output, localX, localZ, x, z, ground, water, data);
if (placement == Placement.SHORE) {
decorateShore(column, decorators.shore);
return;
}
int head = water.head(x, z);
if (head <= ground || head + 1 >= output.getHeight()) {
return;
}
decorateFloor(column, head, decorators.floor);
if (placement == Placement.STILL && decorators.surface.length > 0 && still(water, x, z, head)) {
decorateSurface(column, head, decorators.surface);
}
}
private static Placement placement(WaterRole role) {
return switch (role) {
case CHANNEL, POND -> Placement.STILL;
case CASCADE, WATERFALL -> Placement.FLOOR;
case BANK, SHORE, POND_SHORE -> Placement.SHORE;
case NONE, DAM, MOUTH, INLET -> Placement.NONE;
};
}
private void decorateFloor(Column column, int head, IrisDecorator[] bucket) {
int room = head - column.ground - 1;
if (room < 1 || bucket.length == 0) {
return;
}
IrisDecorator decorator = pick(bucket, floorRng, column, IrisDecorationPart.SEA_FLOOR);
NativeBlockState support = column.output.get(column.localX, column.ground, column.localZ);
if (decorator == null || !B.isSolid(support) || !column.accepts(decorator, support)) {
return;
}
column.place(decorator, floorRng, column.ground + 1, room, Slot.FLOOR, null);
}
private void decorateSurface(Column column, int head, IrisDecorator[] bucket) {
NativeBlockState support = column.output.get(column.localX, head, column.localZ);
if (!isWaterSource(support)) {
return;
}
IrisDecorator decorator = pick(bucket, surfaceRng, column, IrisDecorationPart.SEA_SURFACE);
if (decorator == null || !column.accepts(decorator, support)) {
return;
}
column.place(decorator, surfaceRng, head + 1, column.output.getHeight() - head - 1, Slot.SURFACE, null);
}
private void decorateShore(Column column, IrisDecorator[] bucket) {
NativeBlockState support = column.output.get(column.localX, column.ground, column.localZ);
if (bucket.length == 0 || support == null || !support.isSolid()) {
return;
}
IrisDecorator decorator = pick(bucket, shoreRng, column, IrisDecorationPart.SHORE_LINE);
if (decorator == null || !column.accepts(decorator, support)) {
return;
}
column.place(decorator, shoreRng, column.ground + 1, column.output.getHeight() - column.ground - 1, Slot.SHORE, support);
}
private IrisDecorator pick(IrisDecorator[] bucket, RNG partRng, Column column, IrisDecorationPart part) {
IrisDecorator picked = null;
long choice = seed + part.ordinal();
int count = 0;
for (IrisDecorator decorator : bucket) {
if (!decorator.passesChanceGate(partRng, column.x, column.z, column.data)) {
continue;
}
count++;
choice = mix(choice ^ mix(column.x * 0x632be59bd9b4e019L + column.z * 0x8cb92ba72f3d8dd7L + count));
if (count == 1 || Long.remainderUnsigned(choice, count) == 0L) {
picked = decorator;
}
}
if (picked == null || picked.isForcePlace() || picked.getSlopeCondition().isDefault()
|| picked.getSlopeCondition().isValid(column.water.slope(column.x, column.z))) {
return picked;
}
return null;
}
static boolean still(Water water, int x, int z, int head) {
for (int[] side : SIDES) {
int neighbourX = x + side[0];
int neighbourZ = z + side[1];
int neighbourHead = water.head(neighbourX, neighbourZ);
if (neighbourHead != head && water.ground(neighbourX, neighbourZ) < neighbourHead) {
return false;
}
}
return true;
}
static boolean isWaterSource(NativeBlockState state) {
if (state == null || !state.isWater()) {
return false;
}
String level = IrisProceduralBlocks.propertyValue(state, "level");
return level == null || level.equals("0");
}
static boolean holdsWater(NativeBlockState state) {
return B.isFluid(state) || B.isWaterLogged(state) || IrisSurfaceDecorator.isAquaticPlacement(state);
}
static boolean plannedWater(Water water, int x, int z, int y) {
int ground = water.ground(x, z);
int top = water.head(x, z);
if (y <= ground || top <= ground) {
return false;
}
for (int[] side : SIDES) {
int neighbourHead = water.head(x + side[0], z + side[1]);
if (neighbourHead > top && water.ground(x + side[0], z + side[1]) < neighbourHead) {
top = neighbourHead;
}
}
return y <= top;
}
private static boolean twoBlock(NativeBlockState state) {
String half = IrisProceduralBlocks.propertyValue(state, "half");
return "lower".equals(half) || "upper".equals(half);
}
private static long mix(long value) {
long mixed = (value + GOLDEN ^ (value + GOLDEN >>> 30)) * 0xbf58476d1ce4e5b9L;
mixed = (mixed ^ (mixed >>> 27)) * 0x94d049bb133111ebL;
return mixed ^ (mixed >>> 31);
}
public interface Water {
int ground(int x, int z);
int head(int x, int z);
double slope(int x, int z);
Decorators decorators(int x, int z);
}
private enum Placement {
NONE,
SHORE,
FLOOR,
STILL
}
private enum Slot {
FLOOR,
SURFACE,
SHORE
}
private final class Column {
private final Hunk<NativeBlockState> output;
private final int localX;
private final int localZ;
private final int x;
private final int z;
private final int ground;
private final Water water;
private final IrisData data;
private Column(Hunk<NativeBlockState> output, int localX, int localZ, int x, int z, int ground, Water water, IrisData data) {
this.output = output;
this.localX = localX;
this.localZ = localZ;
this.x = x;
this.z = z;
this.ground = ground;
this.water = water;
this.data = data;
}
private boolean accepts(IrisDecorator decorator, NativeBlockState support) {
return decorator.isForcePlace() || DecoratorCore.allowsSurface(decorator, support, data);
}
private int stack(IrisDecorator decorator, RNG rng, int room) {
return decorator.isStacking() ? Math.min(room, DecoratorCore.computeStack(decorator, rng, x, z, data, room)) : 1;
}
private void place(IrisDecorator decorator, RNG rng, int baseY, int room, Slot slot, NativeBlockState support) {
NativeBlockState first = decorator.pickBlockData(rng, data, x, z);
if (first == null) {
return;
}
if (twoBlock(first)) {
if (room >= 2) {
placePair(decorator, first, baseY, slot, support);
}
return;
}
int stack = stack(decorator, rng, room);
NativeBlockState sampled = first;
boolean sampledTop = false;
for (int index = 0; index < stack; index++) {
int y = baseY + index;
boolean top = (stack == 1 ? 1D : (double) index / (stack - 1)) >= decorator.getTopThreshold();
if (top != sampledTop) {
sampled = top ? decorator.pickBlockDataTop(rng, data, x, z) : decorator.pickBlockData(rng, data, x, z);
sampledTop = top;
}
NativeBlockState block = sampled == null ? null : fit(sampled, y, slot, decorator, index == 0 ? support : null);
if (block == null) {
return;
}
output.set(localX, y, localZ, block);
}
}
private void placePair(IrisDecorator decorator, NativeBlockState block, int y, Slot slot, NativeBlockState support) {
NativeBlockState lower = fit(block.withProperty("half", "lower"), y, slot, decorator, support);
NativeBlockState upper = lower == null ? null : fit(block.withProperty("half", "upper"), y + 1, slot, decorator, null);
if (upper != null) {
output.set(localX, y, localZ, lower);
output.set(localX, y + 1, localZ, upper);
}
}
private NativeBlockState fit(NativeBlockState block, int y, Slot slot, IrisDecorator decorator, NativeBlockState support) {
NativeBlockState existing = output.get(localX, y, localZ);
if (slot == Slot.FLOOR) {
NativeBlockState placed = IrisProceduralBlocks.normalizeWaterlogging(block, existing, true);
return isWaterSource(existing) && IrisSurfaceDecorator.isAquaticPlacement(placed) ? placed : null;
}
if (!B.isAir(existing) || holdsWater(block)) {
return null;
}
if (slot == Slot.SURFACE) {
return block;
}
if (besideWater(y) || support != null && !DecoratorCore.isValidShorelineSupport(decorator, block, support)) {
return null;
}
return IrisSugarCane.canPlace(block, output, localX, y, localZ, x, z, engine) ? block : null;
}
private boolean besideWater(int y) {
for (int[] side : SIDES) {
int neighbourX = localX + side[0];
int neighbourZ = localZ + side[1];
if (neighbourX >= 0 && neighbourX < output.getWidth() && neighbourZ >= 0 && neighbourZ < output.getDepth()) {
if (holdsWater(output.get(neighbourX, y, neighbourZ))) {
return true;
}
} else if (plannedWater(water, x + side[0], z + side[1], y)) {
return true;
}
}
return false;
}
}
public static final class Decorators {
public static final Decorators EMPTY = new Decorators(new IrisDecorator[0], new IrisDecorator[0], new IrisDecorator[0]);
private final IrisDecorator[] floor;
private final IrisDecorator[] surface;
private final IrisDecorator[] shore;
private Decorators(IrisDecorator[] floor, IrisDecorator[] surface, IrisDecorator[] shore) {
this.floor = floor;
this.surface = surface;
this.shore = shore;
}
public static Decorators compile(List<IrisDecorator> decorators, IrisData data, String path) {
HydrologySettings.requireDecorators(decorators, path);
if (decorators == null || decorators.isEmpty()) {
return EMPTY;
}
List<IrisDecorator> floor = new ArrayList<>();
List<IrisDecorator> surface = new ArrayList<>();
List<IrisDecorator> shore = new ArrayList<>();
for (int index = 0; index < decorators.size(); index++) {
IrisDecorator decorator = decorators.get(index);
String field = path + "[" + index + "]";
IrisDecorationPart part = decorator.getPartOf();
requirePlaced(decorator.getPalette(), data, field + ".palette", part);
requirePlaced(decorator.getTopPalette(), data, field + ".topPalette", part);
requireKnown(decorator.getWhitelist(), data, field + ".whitelist");
requireKnown(decorator.getBlacklist(), data, field + ".blacklist");
switch (part) {
case SEA_FLOOR -> floor.add(decorator);
case SEA_SURFACE -> surface.add(decorator);
default -> shore.add(decorator);
}
}
return new Decorators(floor.toArray(new IrisDecorator[0]), surface.toArray(new IrisDecorator[0]), shore.toArray(new IrisDecorator[0]));
}
public boolean empty() {
return floor.length == 0 && surface.length == 0 && shore.length == 0;
}
private static void requirePlaced(List<IrisBlockData> blocks, IrisData data, String path, IrisDecorationPart part) {
if (blocks == null) {
return;
}
for (int index = 0; index < blocks.size(); index++) {
String field = path + "[" + index + "]";
String name = blocks.get(index).getBlock();
NativeBlockState state = requireKnown(blocks.get(index), data, field);
if (state.isFluid()) {
throw new IllegalArgumentException(field + " names the fluid '" + name + "'; water decorators never place fluids");
}
if (part == IrisDecorationPart.SEA_FLOOR && !waterCompatible(state)) {
throw new IllegalArgumentException(field + " names '" + name + "', which cannot stand in water; SEA_FLOOR decorators place"
+ " only waterloggable or water plants such as seagrass, kelp, coral and sea pickles");
}
if (part != IrisDecorationPart.SEA_FLOOR && holdsWater(state)) {
throw new IllegalArgumentException(field + " names '" + name + "', which holds water; " + part
+ " decorators grow in air and place only blocks without water, never waterlogged blocks, seagrass, kelp, sea pickles or coral");
}
}
}
private static void requireKnown(List<IrisBlockData> blocks, IrisData data, String path) {
if (blocks == null) {
return;
}
for (int index = 0; index < blocks.size(); index++) {
requireKnown(blocks.get(index), data, path + "[" + index + "]");
}
}
private static NativeBlockState requireKnown(IrisBlockData block, IrisData data, String path) {
NativeBlockState state = block.getBlockData(data);
if (state == null || state.isAir()) {
throw new IllegalArgumentException(path + " names the block '" + block.getBlock()
+ "', which does not exist on this server; water decorators never place air");
}
return state;
}
private static boolean waterCompatible(NativeBlockState state) {
return IrisSurfaceDecorator.isAquaticPlacement(state) || IrisProceduralBlocks.propertyValue(state, "waterlogged") != null;
}
}
}
@@ -2,8 +2,6 @@ package art.arcane.iris.generation.decoration;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.hydrology.HydrologyColumnLayer;
import art.arcane.iris.generation.hydrology.HydrologyColumnSample;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.volmlib.util.hunk.Hunk;
@@ -71,21 +69,18 @@ final class IrisSugarCane {
private static boolean plannedWater(Engine engine, int x, int y, int z) {
IrisComplex complex = engine.getComplex();
HydrologyColumnSample sample = complex.sampleHydrologyColumn(x, z);
HydrologyColumnLayer fluid = sample == null ? null : sample.primarySurfaceFluidLayerOrNull();
if (fluid != null) {
return y > fluid.bedY() && y <= fluid.fluidHeadY()
&& supportsAdjacent(complex.resolveHydrologyFluid(fluid.profileKey(), x, z));
}
int terrainHeight = (int) Math.round(complex.getHeightStream().getDouble(x, z));
int fluidHeight = (int) Math.round(complex.getRiverWaterSurfaceStream().getDouble(x, z));
int fluidHeight = (int) Math.round(complex.getWaterHeadStream().getDouble(x, z));
if (y <= terrainHeight || y > fluidHeight) {
return false;
}
if (complex.getWaterRoleStream().get(x, z).wet()) {
return supportsAdjacent(complex.resolveWaterFluid(x, z));
}
IrisBiome biome = complex.getTrueBiomeStream().get(x, z);
KList<NativeBlockState> layers = biome.generateSeaLayers(x, z,
new RNG(engine.getSeedManager().getTerrain()), fluidHeight - terrainHeight, engine.getData());
int depth = fluidHeight - y;
return supportsAdjacent(layers.hasIndex(depth) ? layers.get(depth) : complex.resolveSurfaceFluid(x, z));
return supportsAdjacent(layers.hasIndex(depth) ? layers.get(depth) : complex.resolveSeaFluid(x, z));
}
}
@@ -1,35 +0,0 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.hydrology.cave.CavePosition;
record CaveCandidateBounds(
int entryX,
int entryY,
int entryZ,
int waterHeadY,
int horizontalRadius,
int maximumDepth,
int dryHeadroom
) {
boolean containsColumn(int x, int z) {
long deltaX = (long) x - entryX;
long deltaZ = (long) z - entryZ;
long radiusSquared = (long) horizontalRadius * horizontalRadius;
return deltaX * deltaX + deltaZ * deltaZ <= radiusSquared;
}
int minimumY() {
return Math.toIntExact((long) entryY - maximumDepth);
}
int maximumY() {
return Math.max(entryY, Math.addExact(waterHeadY, dryHeadroom + 1));
}
boolean contains(CavePosition position) {
if (!containsColumn(position.x(), position.z())) {
return false;
}
return position.y() >= minimumY() && position.y() <= maximumY();
}
}
@@ -1,202 +0,0 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.hydrology.cave.CavePosition;
import art.arcane.iris.generation.hydrology.cave.CavePositionIndex;
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveAction;
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCandidate;
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveFluidPolicy;
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveMode;
import art.arcane.iris.generation.hydrology.cave.HydrologyCavePlannerSettings;
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveSource;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
final class CaveCandidateBuilder {
private final RiverCourse course;
private final HydraulicSegment representative;
private final LinkedHashMap<CavePosition, HydrologyCaveAction> actions;
private final LinkedHashSet<CavePosition> openings;
private final CavePositionIndex openingIndex;
private final List<CaveSurfaceOpening> surfaceOpenings;
private int minimumX;
private int maximumX;
private int minimumY;
private int maximumY;
private int minimumZ;
private int maximumZ;
CaveCandidateBuilder(RiverCourse course, HydraulicSegment representative) {
this.course = course;
this.representative = representative;
this.actions = new LinkedHashMap<>();
this.openings = new LinkedHashSet<>();
this.openingIndex = new CavePositionIndex();
this.surfaceOpenings = HydrologyCaveCourseFilter.surfaceOpenings(course);
this.minimumX = Integer.MAX_VALUE;
this.maximumX = Integer.MIN_VALUE;
this.minimumY = Integer.MAX_VALUE;
this.maximumY = Integer.MIN_VALUE;
this.minimumZ = Integer.MAX_VALUE;
this.maximumZ = Integer.MIN_VALUE;
}
RiverCourse course() {
return course;
}
private HydraulicSegment representative() {
return representative;
}
boolean isEmpty() {
return actions.isEmpty();
}
void addAction(CavePosition position, HydrologyCaveAction action) {
HydrologyCaveAction existing = actions.get(position);
if (existing == null || actionPriority(action) < actionPriority(existing)) {
actions.put(position, action);
}
include(position);
}
void addOpening(CavePosition position) {
if (openingIndex.add(position.x(), position.y(), position.z())) {
openings.add(position);
}
include(position);
}
void addOpening(int x, int y, int z) {
if (openingIndex.add(x, y, z)) {
openings.add(new CavePosition(x, y, z));
}
include(x, y, z);
}
void addOpeningNeighborhood(CavePosition position) {
addOpening(position);
for (int[] offset : HydrologyCaveCourseFilter.NEIGHBORS) {
addOpening(
position.x() + offset[0],
position.y() + offset[1],
position.z() + offset[2]
);
}
}
void addAdjacentSurfaceOpenings(
HydrologyFootprintCompiler.ValidationRaster validation
) {
ArrayList<CavePosition> actionPositions = new ArrayList<>(actions.keySet());
for (CavePosition position : actionPositions) {
for (int[] offset : HydrologyCaveCourseFilter.HORIZONTAL_NEIGHBORS) {
int neighborX = position.x() + offset[0];
int neighborZ = position.z() + offset[1];
if (actions.containsKey(new CavePosition(neighborX, position.y(), neighborZ))) {
continue;
}
HydrologyColumnSample neighbor = validation.surfaceColumnAt(neighborX, neighborZ);
if (neighbor == null) {
continue;
}
for (HydrologyColumnLayer layer : neighbor.layers()) {
if (layer.feature().courseId() == course.id() && layer.channel() && layer.fluidOwned()
&& layer.bedY() < position.y() && position.y() <= layer.fluidHeadY()) {
addOpening(neighborX, position.y(), neighborZ);
break;
}
}
}
}
}
void addSurfaceOpening(HydrologyColumnLayer layer, CavePosition position) {
for (CaveSurfaceOpening opening : surfaceOpenings) {
if (!opening.matches(layer, position)) {
continue;
}
if (opening.includeNeighborhood()) {
addOpening(position);
for (int[] offset : HydrologyCaveCourseFilter.NEIGHBORS) {
int y = position.y() + offset[1];
if (y >= opening.minimumY()) {
addOpening(position.x() + offset[0], y, position.z() + offset[2]);
}
}
} else {
addOpening(position);
}
}
}
private void include(CavePosition position) {
include(position.x(), position.y(), position.z());
}
private void include(int x, int y, int z) {
minimumX = Math.min(minimumX, x);
maximumX = Math.max(maximumX, x);
minimumY = Math.min(minimumY, y);
maximumY = Math.max(maximumY, y);
minimumZ = Math.min(minimumZ, z);
maximumZ = Math.max(maximumZ, z);
}
HydrologyCaveCandidate build(HydrologyCaveCourseFilter.Options options) {
HydrologyPoint start = representative.start();
int waterHead = representative.upstreamHeadY();
int entryY = Math.max(waterHead, maximumY);
CavePosition entry = new CavePosition(start.x(), entryY, start.z());
CavePosition target = new CavePosition(start.x(), waterHead, start.z());
int horizontalRadius = HydrologyCaveCourseFilter.horizontalRadius(
entry.x(),
entry.z(),
minimumX,
maximumX,
minimumZ,
maximumZ
);
int maximumDepth = Math.max(1, entryY - minimumY + 2);
int dryHeadroom = Math.max(0, maximumY - waterHead);
int volume = Math.max(1, actions.size());
HydrologyCavePlannerSettings settings = new HydrologyCavePlannerSettings(
horizontalRadius,
maximumDepth,
volume,
1,
1,
dryHeadroom,
HydrologyCaveFluidPolicy.REJECT_EXISTING,
horizontalRadius,
maximumDepth
);
return new HydrologyCaveCandidate(
new HydrologyCaveSource(
course.id(),
entry,
target,
waterHead,
HydrologyCaveMode.GENERATED_GROTTO
),
course.profileKey(),
settings,
options.connectToExistingCaves() && course.type() == RiverCourseType.UNDERGROUND,
actions,
openings
);
}
private static int actionPriority(HydrologyCaveAction action) {
return switch (action) {
case WET_SOURCE -> 0;
case FALLING_FLUID -> 1;
case DRY_AIR -> 2;
case SEAL_GUARD -> 3;
};
}
}
@@ -1,395 +0,0 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.hydrology.cave.CavePosition;
import art.arcane.iris.generation.hydrology.cave.CaveVoxelView;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
final class CaveCandidateSpanBuilder {
private final RiverCourse course;
private final HydraulicSegment representative;
private final LinkedHashMap<Long, CaveSpanSet> actions;
private final LinkedHashMap<Long, CaveSpanSet> openings;
private final LinkedHashMap<Long, CaveSpanSet> coastalGrottoActions;
private final LinkedHashMap<Long, CaveSpanSet> inlandGrottoActions;
private final List<CaveSurfaceOpening> surfaceOpenings;
private int minimumX;
private int maximumX;
private int minimumY;
private int maximumY;
private int minimumZ;
private int maximumZ;
CaveCandidateSpanBuilder(RiverCourse course, HydraulicSegment representative) {
this.course = course;
this.representative = representative;
this.actions = new LinkedHashMap<>();
this.openings = new LinkedHashMap<>();
this.coastalGrottoActions = new LinkedHashMap<>();
this.inlandGrottoActions = new LinkedHashMap<>();
this.surfaceOpenings = HydrologyCaveCourseFilter.surfaceOpenings(course);
this.minimumX = Integer.MAX_VALUE;
this.maximumX = Integer.MIN_VALUE;
this.minimumY = Integer.MAX_VALUE;
this.maximumY = Integer.MIN_VALUE;
this.minimumZ = Integer.MAX_VALUE;
this.maximumZ = Integer.MIN_VALUE;
}
RiverCourse course() {
return course;
}
HydraulicSegment representative() {
return representative;
}
boolean isEmpty() {
return actions.isEmpty();
}
boolean allowsIntentionalSurfaceExposure() {
return course.surfaceSinkholeContinuation();
}
void addAction(int x, int z, HydrologyColumnLayer layer) {
int minimumActionY = layer.bedY() + 1;
int maximumActionY = layer.ceilingY();
if (minimumActionY > maximumActionY) {
return;
}
addSpan(actions, x, z, minimumActionY, maximumActionY);
if (layer.feature().type() == HydrologyFeatureType.COASTAL_GROTTO) {
addSpan(coastalGrottoActions, x, z, minimumActionY, maximumActionY);
} else if (layer.feature().type() == HydrologyFeatureType.INLAND_GROTTO) {
addSpan(inlandGrottoActions, x, z, minimumActionY, maximumActionY);
}
include(x, minimumActionY, z);
include(x, maximumActionY, z);
for (CaveSurfaceOpening opening : surfaceOpenings) {
if (!opening.matchesColumn(layer, x, z)) {
continue;
}
int minimumOpeningY = Math.max(minimumActionY, opening.minimumY());
if (minimumOpeningY > maximumActionY) {
continue;
}
if (!opening.includeNeighborhood()) {
addOpening(x, z, minimumOpeningY, maximumActionY);
continue;
}
addOpening(x, z, Math.max(minimumOpeningY - 1, opening.minimumY()), maximumActionY + 1);
for (int[] offset : HydrologyCaveCourseFilter.HORIZONTAL_NEIGHBORS) {
addOpening(x + offset[0], z + offset[1], minimumOpeningY, maximumActionY);
}
}
}
void addOceanOpening(int x, int z, int minimumOpeningY, int maximumOpeningY) {
if (minimumOpeningY <= maximumOpeningY) {
addOpening(x, z, minimumOpeningY, maximumOpeningY);
}
}
void addOpeningNeighborhood(
int x,
int z,
int minimumOpeningY,
int maximumOpeningY
) {
if (minimumOpeningY > maximumOpeningY) {
return;
}
addOpening(x, z, minimumOpeningY - 1, maximumOpeningY + 1);
for (int[] offset : HydrologyCaveCourseFilter.HORIZONTAL_NEIGHBORS) {
addOpening(x + offset[0], z + offset[1], minimumOpeningY, maximumOpeningY);
}
}
void addAdjacentSurfaceOpenings(
HydrologyFootprintCompiler.ValidationRaster validation
) {
for (Map.Entry<Long, CaveSpanSet> entry : actions.entrySet()) {
int x = RiverFootprint.unpackX(entry.getKey());
int z = RiverFootprint.unpackZ(entry.getKey());
for (int[] offset : HydrologyCaveCourseFilter.HORIZONTAL_NEIGHBORS) {
int neighborX = x + offset[0];
int neighborZ = z + offset[1];
HydrologyColumnSample neighbor = validation.surfaceColumnAt(neighborX, neighborZ);
if (neighbor == null) {
continue;
}
for (HydrologyColumnLayer layer : neighbor.layers()) {
if (layer.feature().courseId() != course.id() || !layer.channel() || !layer.fluidOwned()) {
continue;
}
for (CaveYSpan span : entry.getValue().spans()) {
int minimumY = Math.max(span.minimumY(), layer.bedY() + 1);
int maximumY = Math.min(span.maximumY(), layer.fluidHeadY());
if (minimumY <= maximumY) {
addAdjacentFluidOpening(neighborX, neighborZ, minimumY, maximumY);
}
}
}
}
}
}
private void addAdjacentFluidOpening(int x, int z, int minimumY, int maximumY) {
CaveSpanSet owned = actions.get(RiverFootprint.pack(x, z));
if (owned == null) {
addOpening(x, z, minimumY, maximumY);
return;
}
int cursor = minimumY;
for (CaveYSpan span : owned.spans()) {
if (span.maximumY() < cursor) {
continue;
}
if (span.minimumY() > maximumY) {
break;
}
if (cursor < span.minimumY()) {
addOpening(x, z, cursor, Math.min(maximumY, span.minimumY() - 1));
}
cursor = Math.max(cursor, span.maximumY() + 1);
if (cursor > maximumY) {
return;
}
}
addOpening(x, z, cursor, maximumY);
}
HydrologyFeatureType oversizedGrotto(HydrologyCaveCourseFilter.Options options) {
if (positionCount(coastalGrottoActions)
> options.maximumVolume(HydrologyFeatureType.COASTAL_GROTTO)) {
return HydrologyFeatureType.COASTAL_GROTTO;
}
if (positionCount(inlandGrottoActions)
> options.maximumVolume(HydrologyFeatureType.INLAND_GROTTO)) {
return HydrologyFeatureType.INLAND_GROTTO;
}
return null;
}
long positionCount() {
return positionCount(actions);
}
boolean exposed(CaveVoxelView view) {
CaveCandidateBounds bounds = bounds();
for (Map.Entry<Long, CaveSpanSet> entry : actions.entrySet()) {
int x = RiverFootprint.unpackX(entry.getKey());
int z = RiverFootprint.unpackZ(entry.getKey());
CaveSpanSet actionSpans = entry.getValue();
CaveSpanSet openingSpans = openings.get(entry.getKey());
if (exposedDifference(actionSpans, null, openingSpans, x, z, bounds, view)) {
return true;
}
for (CaveYSpan span : actionSpans.spans()) {
int lowerBoundary = span.minimumY() - 1;
if (!actionSpans.contains(lowerBoundary)
&& !contains(openingSpans, lowerBoundary)
&& exposed(x, lowerBoundary, z, bounds, view)) {
return true;
}
int upperBoundary = span.maximumY() + 1;
if (!actionSpans.contains(upperBoundary)
&& !contains(openingSpans, upperBoundary)
&& exposed(x, upperBoundary, z, bounds, view)) {
return true;
}
}
for (int[] offset : HydrologyCaveCourseFilter.HORIZONTAL_NEIGHBORS) {
int neighborX = x + offset[0];
int neighborZ = z + offset[1];
long neighborKey = RiverFootprint.pack(neighborX, neighborZ);
if (exposedDifference(
actionSpans,
actions.get(neighborKey),
openings.get(neighborKey),
neighborX,
neighborZ,
bounds,
view
)) {
return true;
}
}
}
return false;
}
private boolean exposedDifference(
CaveSpanSet source,
CaveSpanSet excludedActions,
CaveSpanSet excludedOpenings,
int x,
int z,
CaveCandidateBounds bounds,
CaveVoxelView view
) {
for (CaveYSpan span : source.spans()) {
if (exposedDifference(
span,
excludedActions,
excludedOpenings,
x,
z,
bounds,
view
)) {
return true;
}
}
return false;
}
private boolean exposedDifference(
CaveYSpan source,
CaveSpanSet excludedActions,
CaveSpanSet excludedOpenings,
int x,
int z,
CaveCandidateBounds bounds,
CaveVoxelView view
) {
List<CaveYSpan> actionSpans = excludedActions == null ? List.of() : excludedActions.spans();
List<CaveYSpan> openingSpans = excludedOpenings == null ? List.of() : excludedOpenings.spans();
int actionIndex = 0;
int openingIndex = 0;
long cursor = source.minimumY();
while (cursor <= source.maximumY()) {
while (actionIndex < actionSpans.size()
&& actionSpans.get(actionIndex).maximumY() < cursor) {
actionIndex++;
}
while (openingIndex < openingSpans.size()
&& openingSpans.get(openingIndex).maximumY() < cursor) {
openingIndex++;
}
CaveYSpan action = actionIndex < actionSpans.size() ? actionSpans.get(actionIndex) : null;
CaveYSpan opening = openingIndex < openingSpans.size() ? openingSpans.get(openingIndex) : null;
CaveYSpan excluded = first(action, opening);
if (excluded == null || excluded.minimumY() > source.maximumY()) {
return exposed(x, (int) cursor, source.maximumY(), z, bounds, view);
}
if (cursor < excluded.minimumY()
&& exposed(x, (int) cursor, excluded.minimumY() - 1, z, bounds, view)) {
return true;
}
cursor = Math.max(cursor, (long) excluded.maximumY() + 1L);
if (excluded == action) {
actionIndex++;
} else {
openingIndex++;
}
}
return false;
}
private CaveYSpan first(CaveYSpan first, CaveYSpan second) {
if (first == null) {
return second;
}
if (second == null) {
return first;
}
return first.minimumY() <= second.minimumY() ? first : second;
}
boolean exposed(
int x,
int minimumY,
int maximumY,
int z,
CaveCandidateBounds bounds,
CaveVoxelView view
) {
if (!bounds.containsColumn(x, z)) {
return false;
}
int boundedMinimumY = Math.max(minimumY, bounds.minimumY());
int boundedMaximumY = Math.min(maximumY, bounds.maximumY());
return boundedMinimumY <= boundedMaximumY
&& view.hasAboveTerrainSurface(x, z, boundedMinimumY, boundedMaximumY);
}
boolean exposed(
int x,
int y,
int z,
CaveCandidateBounds bounds,
CaveVoxelView view
) {
CavePosition position = new CavePosition(x, y, z);
return bounds.contains(position)
&& view.isInWorld(position)
&& view.isAboveTerrainSurface(position);
}
private CaveCandidateBounds bounds() {
HydrologyPoint start = representative.start();
int waterHead = representative.upstreamHeadY();
int entryY = Math.max(waterHead, maximumY);
int horizontalRadius = HydrologyCaveCourseFilter.horizontalRadius(
start.x(),
start.z(),
minimumX,
maximumX,
minimumZ,
maximumZ
);
int maximumDepth = Math.max(1, entryY - minimumY + 2);
int dryHeadroom = Math.max(0, maximumY - waterHead);
return new CaveCandidateBounds(
start.x(),
entryY,
start.z(),
waterHead,
horizontalRadius,
maximumDepth,
dryHeadroom
);
}
private void addOpening(int x, int z, int minimumOpeningY, int maximumOpeningY) {
addSpan(openings, x, z, minimumOpeningY, maximumOpeningY);
include(x, minimumOpeningY, z);
include(x, maximumOpeningY, z);
}
private void include(int x, int y, int z) {
minimumX = Math.min(minimumX, x);
maximumX = Math.max(maximumX, x);
minimumY = Math.min(minimumY, y);
maximumY = Math.max(maximumY, y);
minimumZ = Math.min(minimumZ, z);
maximumZ = Math.max(maximumZ, z);
}
private static void addSpan(
Map<Long, CaveSpanSet> spans,
int x,
int z,
int minimumY,
int maximumY
) {
spans.computeIfAbsent(RiverFootprint.pack(x, z), (Long ignored) -> new CaveSpanSet())
.add(minimumY, maximumY);
}
private static boolean contains(CaveSpanSet spans, int y) {
return spans != null && spans.contains(y);
}
static long positionCount(Map<Long, CaveSpanSet> spans) {
long count = 0L;
for (CaveSpanSet spanSet : spans.values()) {
count += spanSet.positionCount();
}
return count;
}
}
@@ -1,10 +0,0 @@
package art.arcane.iris.generation.hydrology;
import java.util.List;
record CaveCourseGraph(
List<DrainageNode> nodes,
List<DrainageEdge> edges,
List<RiverOutlet> outlets
) {
}
@@ -1,103 +0,0 @@
package art.arcane.iris.generation.hydrology;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
final class CaveSpanSet {
private int[] pending;
private int size;
private List<CaveYSpan> normalized;
CaveSpanSet() {
this.pending = new int[4];
this.normalized = null;
}
void add(int minimumY, int maximumY) {
if (minimumY > maximumY) {
return;
}
int requiredLength = Math.multiplyExact(size + 1, 2);
if (requiredLength > pending.length) {
pending = Arrays.copyOf(pending, Math.multiplyExact(pending.length, 2));
}
pending[size * 2] = minimumY;
pending[size * 2 + 1] = maximumY;
size++;
normalized = null;
}
List<CaveYSpan> spans() {
if (normalized != null) {
return normalized;
}
sortPending();
ArrayList<CaveYSpan> merged = new ArrayList<>(size);
for (int index = 0; index < size; index++) {
CaveYSpan span = new CaveYSpan(pending[index * 2], pending[index * 2 + 1]);
if (merged.isEmpty()) {
merged.add(span);
continue;
}
CaveYSpan previous = merged.getLast();
if ((long) span.minimumY() > (long) previous.maximumY() + 1L) {
merged.add(span);
continue;
}
merged.set(
merged.size() - 1,
new CaveYSpan(previous.minimumY(), Math.max(previous.maximumY(), span.maximumY()))
);
}
normalized = List.copyOf(merged);
return normalized;
}
private void sortPending() {
for (int index = 1; index < size; index++) {
int minimumY = pending[index * 2];
int maximumY = pending[index * 2 + 1];
int insertionIndex = index;
while (insertionIndex > 0) {
int previousMinimumY = pending[(insertionIndex - 1) * 2];
int previousMaximumY = pending[(insertionIndex - 1) * 2 + 1];
if (previousMinimumY < minimumY
|| previousMinimumY == minimumY && previousMaximumY <= maximumY) {
break;
}
pending[insertionIndex * 2] = previousMinimumY;
pending[insertionIndex * 2 + 1] = previousMaximumY;
insertionIndex--;
}
pending[insertionIndex * 2] = minimumY;
pending[insertionIndex * 2 + 1] = maximumY;
}
}
boolean contains(int y) {
List<CaveYSpan> spans = spans();
int minimumIndex = 0;
int maximumIndex = spans.size() - 1;
while (minimumIndex <= maximumIndex) {
int index = (minimumIndex + maximumIndex) >>> 1;
CaveYSpan span = spans.get(index);
if (y < span.minimumY()) {
maximumIndex = index - 1;
} else if (y > span.maximumY()) {
minimumIndex = index + 1;
} else {
return true;
}
}
return false;
}
long positionCount() {
long count = 0L;
for (CaveYSpan span : spans()) {
count += (long) span.maximumY() - span.minimumY() + 1L;
}
return count;
}
}
@@ -1,18 +0,0 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCandidate;
import java.util.Objects;
record CaveSurfaceComposition(
HydrologyCaveCandidate candidate,
boolean accepted
) {
static CaveSurfaceComposition accepted(HydrologyCaveCandidate candidate) {
return new CaveSurfaceComposition(Objects.requireNonNull(candidate), true);
}
static CaveSurfaceComposition rejected() {
return new CaveSurfaceComposition(null, false);
}
}
@@ -1,79 +0,0 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.hydrology.cave.CavePosition;
import java.util.List;
record CaveSurfaceOpening(
long segmentId,
HydrologyPoint point,
double interiorX,
double interiorZ,
long radiusSquared,
int minimumY,
boolean includeNeighborhood,
boolean surfaceFall
) {
static CaveSurfaceOpening create(
HydraulicSegment segment,
boolean start,
long radiusSquared,
int minimumY,
boolean includeNeighborhood
) {
List<HydrologyPoint> centerline = segment.centerline();
int boundaryIndex = start ? 0 : centerline.size() - 1;
int interiorIndex = start
? Math.min(1, centerline.size() - 1)
: Math.max(0, centerline.size() - 2);
HydrologyPoint point = centerline.get(boundaryIndex);
HydrologyPoint interior = centerline.get(interiorIndex);
double deltaX = interior.x() - point.x();
double deltaZ = interior.z() - point.z();
double lengthSquared = deltaX * deltaX + deltaZ * deltaZ;
double scale = 0D;
if (includeNeighborhood) {
scale = lengthSquared <= radiusSquared
? 1D
: StrictMath.sqrt(radiusSquared / lengthSquared);
}
return new CaveSurfaceOpening(
segment.id(),
point,
point.x() + deltaX * scale,
point.z() + deltaZ * scale,
radiusSquared,
minimumY,
includeNeighborhood,
segment.type().isSurface() && segment.fallingFluid()
);
}
boolean matches(HydrologyColumnLayer layer, CavePosition position) {
return matchesColumn(layer, position.x(), position.z())
&& position.y() >= minimumY;
}
boolean matchesColumn(HydrologyColumnLayer layer, int x, int z) {
if (surfaceFall) {
return layer.fallingFluid() && layer.feature().segmentId() == segmentId;
}
return (includeNeighborhood || layer.feature().segmentId() == segmentId)
&& distanceSquared(x, z) <= radiusSquared;
}
private double distanceSquared(int x, int z) {
double pathX = interiorX - point.x();
double pathZ = interiorZ - point.z();
double pathLengthSquared = pathX * pathX + pathZ * pathZ;
double positionX = x - point.x();
double positionZ = z - point.z();
double progress = pathLengthSquared == 0D
? 0D
: (positionX * pathX + positionZ * pathZ) / pathLengthSquared;
double clampedProgress = Math.max(0D, Math.min(1D, progress));
double deltaX = positionX - pathX * clampedProgress;
double deltaZ = positionZ - pathZ * clampedProgress;
return deltaX * deltaX + deltaZ * deltaZ;
}
}
@@ -1,4 +0,0 @@
package art.arcane.iris.generation.hydrology;
record CaveYSpan(int minimumY, int maximumY) {
}
@@ -1,12 +0,0 @@
package art.arcane.iris.generation.hydrology;
import java.util.List;
import java.util.Map;
record CompiledGraph(
List<DrainageNode> nodes,
List<DrainageEdge> edges,
List<RiverOutlet> outlets,
Map<Integer, DrainageEdge> edgeByUpstream
) {
}
@@ -1,9 +0,0 @@
package art.arcane.iris.generation.hydrology;
import java.util.Objects;
record CourseEdgeKey(RiverCourseType type, long edgeId) {
CourseEdgeKey {
Objects.requireNonNull(type, "type");
}
}
@@ -1,14 +0,0 @@
package art.arcane.iris.generation.hydrology;
import java.util.List;
import java.util.Objects;
record CrossTileBlockers(
List<HydrologyCrossTileCaveAdmission.RankedClaim> caveClaims,
List<HydrologyCrossTileSurfaceAdmission.RankedClaim> surfaceClaims
) {
CrossTileBlockers {
caveClaims = List.copyOf(Objects.requireNonNull(caveClaims, "caveClaims"));
surfaceClaims = List.copyOf(Objects.requireNonNull(surfaceClaims, "surfaceClaims"));
}
}
@@ -1,7 +0,0 @@
package art.arcane.iris.generation.hydrology;
interface CrossTileDraftAdmission {
void prepare();
CrossTilePublicationAdmission admit(HydrologyCaveCourseFilter.Result result);
}
@@ -1,15 +0,0 @@
package art.arcane.iris.generation.hydrology;
import java.util.List;
import java.util.Objects;
record CrossTilePublicationAdmission(
HydrologyCaveCourseFilter.Result result,
List<HydrologyDiagnosticCandidate> diagnostics,
boolean rejectedCourses
) {
CrossTilePublicationAdmission {
Objects.requireNonNull(result, "result");
diagnostics = List.copyOf(Objects.requireNonNull(diagnostics, "diagnostics"));
}
}
@@ -1,9 +0,0 @@
package art.arcane.iris.generation.hydrology;
import java.util.Objects;
record CrossTileRejectedCourse(RiverCourse course, long winnerSourceId) {
CrossTileRejectedCourse {
Objects.requireNonNull(course, "course");
}
}
@@ -1,4 +0,0 @@
package art.arcane.iris.generation.hydrology;
record CrossTileRejectionKey(long courseId, long winnerCourseId) {
}
@@ -1,17 +0,0 @@
package art.arcane.iris.generation.hydrology;
import java.util.List;
import java.util.Objects;
record CrossTileResolution(
HydrologyOwnerDraft draft,
List<CrossTileRejectedCourse> observedRejections,
int iterations,
int ownerCount,
long resolutionNanos
) {
CrossTileResolution {
Objects.requireNonNull(draft, "draft");
observedRejections = List.copyOf(Objects.requireNonNull(observedRejections, "observedRejections"));
}
}
@@ -1,73 +0,0 @@
package art.arcane.iris.generation.hydrology;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
final class CrossTileResolutionContext {
final Map<HydrologyTileKey, CrossTileResolvedOwner> resolved;
private final Set<HydrologyTileKey> resolving;
final HydrologyTileKey root;
private final long maximumTileOffset;
private final int maximumOwners;
int iterations;
CrossTileResolutionContext(
HydrologyTileKey root,
long maximumTileOffset,
int maximumOwners
) {
this.resolved = new HashMap<>();
this.resolving = new HashSet<>();
this.root = Objects.requireNonNull(root, "root");
this.maximumTileOffset = maximumTileOffset;
this.maximumOwners = maximumOwners;
}
CrossTileResolvedOwner resolved(HydrologyTileKey key) {
return resolved.get(key);
}
void remember(HydrologyTileKey key, CrossTileResolvedOwner owner) {
long offsetX = Math.abs((long) key.tileX() - root.tileX());
long offsetZ = Math.abs((long) key.tileZ() - root.tileZ());
if (offsetX > maximumTileOffset || offsetZ > maximumTileOffset) {
throw new IllegalStateException("Cross-tile owner dependency exceeded its color-ranked geometry bound.");
}
CrossTileResolvedOwner existing = resolved.putIfAbsent(key, owner);
if (existing != null
&& !existing.withoutFootprintCompiler().equals(owner.withoutFootprintCompiler())) {
throw new IllegalStateException("Cross-tile owner resolution produced inconsistent cached results for "
+ key.tileX() + "," + key.tileZ() + " while planning " + root.tileX() + "," + root.tileZ() + ".");
}
if (resolved.size() > maximumOwners) {
throw new IllegalStateException("Cross-tile owner dependencies exceeded their color-ranked count bound.");
}
}
boolean begin(HydrologyTileKey key) {
return resolving.add(key);
}
void end(HydrologyTileKey key) {
resolving.remove(key);
}
void recordIteration() {
iterations++;
}
int iterations() {
return iterations;
}
int ownerCount() {
return resolved.size();
}
HydrologyTileKey root() {
return root;
}
}
@@ -1,21 +0,0 @@
package art.arcane.iris.generation.hydrology;
import java.util.List;
import java.util.Objects;
record CrossTileResolvedOwner(
HydrologyOwnerDraft draft,
List<CrossTileRejectedCourse> observedRejections
) {
CrossTileResolvedOwner {
Objects.requireNonNull(draft, "draft");
observedRejections = List.copyOf(Objects.requireNonNull(observedRejections, "observedRejections"));
}
CrossTileResolvedOwner withoutFootprintCompiler() {
HydrologyOwnerDraft uncachedDraft = draft.withoutFootprintCompiler();
return uncachedDraft == draft
? this
: new CrossTileResolvedOwner(uncachedDraft, observedRejections);
}
}
@@ -1,8 +0,0 @@
package art.arcane.iris.generation.hydrology;
enum DraftPhase {
CONTEXT,
SELECT,
SETTLE,
PUBLISH
}
@@ -1,60 +0,0 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.spi.IrisLogging;
/**
* Wall-clock breakdown of one owner draft for the debug log: sampling and routing context, source
* selection, settling, publication passes, and the time spent waiting on lower-rank neighbour drafts.
*/
final class DraftProfile {
private final long[] phaseNanos = new long[DraftPhase.values().length];
private long dependencyNanos;
private int dependencies;
private int admissions;
int earlyOwners;
int routeCalls;
int routeSolves;
long routeSolveNanos;
int rasterCalls;
long rasterNanos;
int filterCalls;
long filterNanos;
long record(DraftPhase phase, long started) {
long now = System.nanoTime();
phaseNanos[phase.ordinal()] += now - started;
return now;
}
void recordDependencies(int count, long started) {
dependencyNanos += System.nanoTime() - started;
dependencies += count;
admissions++;
}
void log(HydrologyTileKey key, int rank, long totalNanos) {
IrisLogging.debug(
"Hydrology owner %d,%d rank=%d drafted in %dms: context=%dms select=%dms settle=%dms publish=%dms deps=%d wait=%dms admissions=%d earlyOwners=%d routes=%d reuses=%d routeSolve=%dms rasters=%d raster=%dms filters=%d filter=%dms on %s",
key.tileX(),
key.tileZ(),
rank,
totalNanos / 1_000_000L,
phaseNanos[DraftPhase.CONTEXT.ordinal()] / 1_000_000L,
phaseNanos[DraftPhase.SELECT.ordinal()] / 1_000_000L,
phaseNanos[DraftPhase.SETTLE.ordinal()] / 1_000_000L,
phaseNanos[DraftPhase.PUBLISH.ordinal()] / 1_000_000L,
dependencies,
dependencyNanos / 1_000_000L,
admissions,
earlyOwners,
routeCalls,
routeCalls - routeSolves,
routeSolveNanos / 1_000_000L,
rasterCalls,
rasterNanos / 1_000_000L,
filterCalls,
filterNanos / 1_000_000L,
Thread.currentThread().getName()
);
}
}
@@ -1,36 +0,0 @@
package art.arcane.iris.generation.hydrology;
import java.util.List;
import java.util.Objects;
public record DrainageEdge(
long id,
long upstreamNodeId,
long downstreamNodeId,
long outletId,
double cost,
int contributingSurfaceSources,
int contributingUndergroundSources,
List<HydrologyPoint> centerline
) {
public DrainageEdge {
if (upstreamNodeId == downstreamNodeId) {
throw new IllegalArgumentException("A drainage edge cannot point to the same node.");
}
if (!Double.isFinite(cost) || cost <= 0D) {
throw new IllegalArgumentException("Drainage edge cost must be finite and positive.");
}
if (contributingSurfaceSources < 0 || contributingUndergroundSources < 0) {
throw new IllegalArgumentException("Contributing source counts cannot be negative.");
}
Objects.requireNonNull(centerline, "centerline");
centerline = List.copyOf(centerline);
if (centerline.size() < 2) {
throw new IllegalArgumentException("A drainage edge requires at least two centerline points.");
}
}
public int totalContributingSources() {
return contributingSurfaceSources + contributingUndergroundSources;
}
}
@@ -1,26 +0,0 @@
package art.arcane.iris.generation.hydrology;
import java.util.Objects;
public record DrainageNode(
long id,
int x,
int z,
HydrologyTerrainSample terrain,
double potential,
long outletId
) {
public DrainageNode {
Objects.requireNonNull(terrain, "terrain");
if (!Double.isFinite(potential) || potential < 0D) {
throw new IllegalArgumentException("Drainage potential must be finite and non-negative.");
}
if (terrain.ocean()) {
throw new IllegalArgumentException("Drainage nodes must remain landward of the first ocean column.");
}
}
public HydrologyPoint naturalPoint() {
return new HydrologyPoint(x, terrain.naturalHeight(), z);
}
}
@@ -1,11 +0,0 @@
package art.arcane.iris.generation.hydrology;
record FootprintCenterlineProjection(
int pointIndex,
double x,
double z,
double distance,
int flowX,
int flowZ
) {
}
@@ -1,11 +0,0 @@
package art.arcane.iris.generation.hydrology;
import java.util.List;
record FootprintCourseRasterKey(
long courseId,
RiverCourseType courseType,
String profileKey,
List<HydraulicSegment> segments
) {
}
@@ -1,11 +0,0 @@
package art.arcane.iris.generation.hydrology;
record FootprintFeatureKey(
long courseId,
long segmentId,
HydrologyFeatureType type,
int role,
int flowX,
int flowZ
) {
}
@@ -1,20 +0,0 @@
package art.arcane.iris.generation.hydrology;
record FootprintLayerShape(
int channelRadius,
double shoreWidth,
double gradingWidth,
int bed,
int fluidHead,
int ceiling,
boolean ellipsoid,
boolean archedChannel,
boolean roundedSurfaceBed,
boolean organicBoundary,
boolean fallingThroat,
boolean inlet
) {
double totalRadius() {
return channelRadius + shoreWidth + gradingWidth;
}
}
@@ -1,173 +0,0 @@
package art.arcane.iris.generation.hydrology;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
final class FootprintMutableColumn {
private final int x;
private final int z;
private final int naturalHeight;
private final int seaLevel;
private final boolean ocean;
private final String parentBiomeKey;
private HydrologyColumnLayer singleLayer;
private LinkedHashMap<Long, HydrologyColumnLayer> layers;
FootprintMutableColumn(int x, int z, HydrologyTerrainSample terrain, int seaLevel) {
this.x = x;
this.z = z;
this.naturalHeight = terrain.naturalHeight();
this.seaLevel = seaLevel;
this.ocean = terrain.ocean();
this.parentBiomeKey = terrain.parentBiomeKey();
}
FootprintMutableColumn(HydrologyColumnSample sample) {
this.x = sample.x();
this.z = sample.z();
this.naturalHeight = sample.naturalHeight();
this.seaLevel = sample.seaLevel();
this.ocean = sample.ocean();
this.parentBiomeKey = sample.parentBiomeKey();
for (HydrologyColumnLayer layer : sample.layers()) {
add(layer);
}
}
void add(HydrologyColumnLayer layer) {
if (layers == null) {
if (singleLayer == null) {
singleLayer = layer;
return;
}
if (singleLayer.feature().id() == layer.feature().id()) {
singleLayer = merge(singleLayer, layer);
return;
}
layers = new LinkedHashMap<>();
layers.put(singleLayer.feature().id(), singleLayer);
singleLayer = null;
}
HydrologyColumnLayer existing = layers.get(layer.feature().id());
layers.put(layer.feature().id(), existing == null ? layer : merge(existing, layer));
}
HydrologyColumnSample build() {
List<HydrologyColumnLayer> builtLayers;
if (layers != null) {
builtLayers = resolveSurfaceHeadConflicts(new ArrayList<>(layers.values()));
} else if (singleLayer != null) {
builtLayers = List.of(singleLayer);
} else {
builtLayers = List.of();
}
return new HydrologyColumnSample(
x,
z,
naturalHeight,
seaLevel,
ocean,
parentBiomeKey,
builtLayers
);
}
private List<HydrologyColumnLayer> resolveSurfaceHeadConflicts(List<HydrologyColumnLayer> candidates) {
HashMap<Long, HydrologyColumnLayer> selectedByCourse = new HashMap<>();
for (HydrologyColumnLayer candidate : candidates) {
if (!conflictingSurfaceCandidate(candidate)) {
continue;
}
long courseId = candidate.feature().courseId();
HydrologyColumnLayer selected = selectedByCourse.get(courseId);
if (selected == null || prefersSurfaceLayer(candidate, selected)) {
selectedByCourse.put(courseId, candidate);
}
}
if (selectedByCourse.isEmpty()) {
return candidates;
}
ArrayList<HydrologyColumnLayer> resolved = new ArrayList<>(candidates.size());
for (HydrologyColumnLayer candidate : candidates) {
HydrologyColumnLayer selected = selectedByCourse.get(candidate.feature().courseId());
if (!conflictingSurfaceCandidate(candidate)
|| selected == null
|| candidate.fluidHeadY() == selected.fluidHeadY()) {
resolved.add(candidate);
}
}
return List.copyOf(resolved);
}
private boolean conflictingSurfaceCandidate(HydrologyColumnLayer layer) {
return layer.feature().type().isSurface()
&& layer.channel()
&& layer.connectedFluid()
&& layer.fluidOwned()
&& !layer.fallingFluid();
}
private boolean prefersSurfaceLayer(HydrologyColumnLayer candidate, HydrologyColumnLayer selected) {
if (candidate.receivingPool() != selected.receivingPool()) {
return candidate.receivingPool();
}
if (candidate.fluidHeadY() != selected.fluidHeadY()) {
return candidate.fluidHeadY() > selected.fluidHeadY();
}
return candidate.feature().id() < selected.feature().id();
}
void merge(HydrologyColumnSample sample) {
if (sample.x() != x
|| sample.z() != z
|| sample.naturalHeight() != naturalHeight
|| sample.seaLevel() != seaLevel
|| sample.ocean() != ocean
|| !sample.parentBiomeKey().equals(parentBiomeKey)) {
throw new IllegalStateException("Hydrology course footprints disagree on terrain metadata at "
+ x + "," + z + ".");
}
for (HydrologyColumnLayer layer : sample.layers()) {
add(layer);
}
}
private HydrologyColumnLayer merge(HydrologyColumnLayer first, HydrologyColumnLayer second) {
if (first.equals(second)) {
return first;
}
HydrologyFeatureRef feature = first.feature().y() <= second.feature().y()
? first.feature()
: second.feature();
boolean drySurfaceBlend = first.feature().type().isSurface()
&& second.feature().type().isSurface()
&& !first.channel()
&& !second.channel();
int bedY = drySurfaceBlend
? Math.max(first.bedY(), second.bedY())
: Math.min(first.bedY(), second.bedY());
return new HydrologyColumnLayer(
feature,
bedY,
Math.max(first.fluidHeadY(), second.fluidHeadY()),
Math.max(first.ceilingY(), second.ceilingY()),
first.channel() || second.channel(),
first.shore() || second.shore(),
first.grading() || second.grading(),
first.connectedFluid() || second.connectedFluid(),
first.fallingFluid() || second.fallingFluid(),
first.receivingPool() || second.receivingPool(),
(first.terrainOwned() || second.terrainOwned()) && (!drySurfaceBlend || bedY != naturalHeight),
first.fluidOwned() || second.fluidOwned(),
first.oceanApron() || second.oceanApron(),
first.profileKey(),
first.surfaceBiomeKey(),
first.mouthBiomeKey(),
first.shoreBiomeKey(),
first.bankBiomeKey(),
first.floodedCaveBiomeKey()
);
}
}
@@ -1,11 +0,0 @@
package art.arcane.iris.generation.hydrology;
record FootprintRasterStencil(
int[] deltaXs,
int[] deltaZs,
double[] distances
) {
int size() {
return deltaXs.length;
}
}
@@ -1,37 +0,0 @@
package art.arcane.iris.generation.hydrology;
import java.util.Arrays;
final class FootprintRasterStencilBuilder {
private int[] deltaXs;
private int[] deltaZs;
private double[] distances;
private int size;
FootprintRasterStencilBuilder() {
this.deltaXs = new int[64];
this.deltaZs = new int[64];
this.distances = new double[64];
}
void add(int deltaX, int deltaZ, double distance) {
if (size == deltaXs.length) {
int expandedSize = Math.multiplyExact(size, 2);
deltaXs = Arrays.copyOf(deltaXs, expandedSize);
deltaZs = Arrays.copyOf(deltaZs, expandedSize);
distances = Arrays.copyOf(distances, expandedSize);
}
deltaXs[size] = deltaX;
deltaZs[size] = deltaZ;
distances[size] = distance;
size++;
}
FootprintRasterStencil build() {
return new FootprintRasterStencil(
Arrays.copyOf(deltaXs, size),
Arrays.copyOf(deltaZs, size),
Arrays.copyOf(distances, size)
);
}
}
@@ -1,8 +0,0 @@
package art.arcane.iris.generation.hydrology;
record FootprintRasterStencilKey(
int channelRadius,
double shoreWidth,
double gradingWidth
) {
}
@@ -1,12 +0,0 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.hydrology.surface.SurfaceFootprint;
import java.util.List;
record FootprintValidationCourseRaster(
long courseId,
List<HydrologyColumnSample> columns,
SurfaceFootprint surface
) {
}
@@ -1,87 +0,0 @@
package art.arcane.iris.generation.hydrology;
import java.util.Arrays;
import java.util.Objects;
public record HydraulicChannelProfile(double[] widths, double[] depths) {
public HydraulicChannelProfile {
Objects.requireNonNull(widths, "widths");
Objects.requireNonNull(depths, "depths");
if (widths.length == 0 || widths.length != depths.length) {
throw new IllegalArgumentException("Channel dimensions require equally sized, nonempty station arrays.");
}
widths = widths.clone();
depths = depths.clone();
for (int station = 0; station < widths.length; station++) {
if (!Double.isFinite(widths[station]) || widths[station] <= 0D
|| !Double.isFinite(depths[station]) || depths[station] <= 0D) {
throw new IllegalArgumentException("Channel dimensions must be finite and positive.");
}
}
}
public static HydraulicChannelProfile uniform(double width, double depth) {
return new HydraulicChannelProfile(new double[]{width}, new double[]{depth});
}
public static HydraulicChannelProfile range(double[] widths, double[] depths, int from, int to) {
Objects.checkFromToIndex(from, to, widths.length);
Objects.checkFromToIndex(from, to, depths.length);
return new HydraulicChannelProfile(Arrays.copyOfRange(widths, from, to), Arrays.copyOfRange(depths, from, to));
}
@Override
public double[] widths() {
return widths.clone();
}
@Override
public double[] depths() {
return depths.clone();
}
public int size() {
return widths.length;
}
public double widthAt(int station) {
return widths[widths.length == 1 ? 0 : station];
}
public double depthAt(int station) {
return depths[depths.length == 1 ? 0 : station];
}
public double maximumWidth() {
double maximum = 0D;
for (double width : widths) {
maximum = Math.max(maximum, width);
}
return maximum;
}
public double maximumDepth() {
double maximum = 0D;
for (double depth : depths) {
maximum = Math.max(maximum, depth);
}
return maximum;
}
@Override
public boolean equals(Object other) {
return other instanceof HydraulicChannelProfile profile
&& Arrays.equals(widths, profile.widths)
&& Arrays.equals(depths, profile.depths);
}
@Override
public int hashCode() {
return 31 * Arrays.hashCode(widths) + Arrays.hashCode(depths);
}
@Override
public String toString() {
return "HydraulicChannelProfile[widths=" + Arrays.toString(widths) + ", depths=" + Arrays.toString(depths) + "]";
}
}
@@ -1,74 +0,0 @@
package art.arcane.iris.generation.hydrology;
import java.util.List;
import java.util.Objects;
public record HydraulicSegment(
long id,
long courseId,
HydrologyFeatureType type,
int upstreamHeadY,
int downstreamHeadY,
int width,
int depth,
boolean fallingFluid,
boolean receivingPool,
List<HydrologyPoint> centerline,
HydraulicChannelProfile channelProfile
) {
public HydraulicSegment {
Objects.requireNonNull(type, "type");
Objects.requireNonNull(centerline, "centerline");
Objects.requireNonNull(channelProfile, "channelProfile");
centerline = List.copyOf(centerline);
if (centerline.isEmpty()) {
throw new IllegalArgumentException("A hydraulic segment requires a centerline.");
}
if (upstreamHeadY < downstreamHeadY) {
throw new IllegalArgumentException("Hydraulic heads cannot rise downstream.");
}
if (width <= 0 || depth <= 0) {
throw new IllegalArgumentException("Hydraulic segment width and depth must be positive.");
}
if (channelProfile.size() != 1 && channelProfile.size() != centerline.size()) {
throw new IllegalArgumentException("Channel dimensions must cover every centerline station.");
}
if (channelProfile.maximumWidth() > width || channelProfile.maximumDepth() > depth) {
throw new IllegalArgumentException("Hydraulic segment bounds must contain its channel dimensions.");
}
int drop = upstreamHeadY - downstreamHeadY;
if (fallingFluid && drop == 0) {
throw new IllegalArgumentException("Falling fluid requires a positive head transition.");
}
if (drop > 0 && !type.isDrop()) {
throw new IllegalArgumentException("A positive head transition requires a drop segment type.");
}
if (drop == 0 && type.isDrop()) {
throw new IllegalArgumentException("A drop segment type requires a positive head transition.");
}
if (drop > 0 && !fallingFluid) {
if (centerline.getFirst().y() != upstreamHeadY || centerline.getLast().y() != downstreamHeadY) {
throw new IllegalArgumentException("A graded head transition must span its declared heads.");
}
int previousHead = upstreamHeadY;
for (HydrologyPoint point : centerline) {
if (point.y() > previousHead || point.y() < downstreamHeadY) {
throw new IllegalArgumentException("A graded head transition must descend monotonically.");
}
previousHead = point.y();
}
}
}
public int drop() {
return upstreamHeadY - downstreamHeadY;
}
public HydrologyPoint start() {
return centerline.getFirst();
}
public HydrologyPoint end() {
return centerline.getLast();
}
}
@@ -1,325 +0,0 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.stream.ProvisionalSampling;
import art.arcane.volmlib.util.cache.CacheKey;
import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap;
import it.unimi.dsi.fastutil.longs.Long2ObjectMap;
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
import java.util.Objects;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RejectedExecutionException;
final class HydrologyBasisCache {
private static final int MAXIMUM_ENTRIES = 32_768;
private static final int MAXIMUM_STRIPES = 64;
private final Stripe[] full;
private final Stripe[] withoutSlope;
HydrologyBasisCache() {
this(MAXIMUM_ENTRIES);
}
HydrologyBasisCache(int maximumEntries) {
if (maximumEntries < 1 || Integer.bitCount(maximumEntries) != 1) {
throw new IllegalArgumentException("Terrain basis cache size must be a positive power of two.");
}
int stripeCount = Math.min(MAXIMUM_STRIPES, maximumEntries);
full = new Stripe[stripeCount];
withoutSlope = new Stripe[stripeCount];
for (int index = 0; index < stripeCount; index++) {
full[index] = new Stripe(maximumEntries / stripeCount);
withoutSlope[index] = new Stripe(maximumEntries / stripeCount);
}
}
HydrologyTerrainSample sample(int x, int z, HydrologyNaturalTerrainSampler sampler) {
return sample(x, z, sampler, false);
}
HydrologyTerrainSample sampleWithoutSlope(int x, int z, HydrologyNaturalTerrainSampler sampler) {
return sample(x, z, sampler, true);
}
void seed(Long2ObjectMap<HydrologyTerrainSample> samples) {
seed(samples, full);
}
HydrologyTerrainSample[] sampleWithoutSlopeBatch(long[] coordinates, int count,
HydrologyNaturalTerrainSampler sampler) {
Objects.checkFromIndexSize(0, count, coordinates.length);
if (count > HydrologyTerrainSampler.MAXIMUM_BATCH_SIZE) {
throw new IllegalArgumentException("Terrain basis batch exceeds its sample limit");
}
HydrologyTerrainSample[] samples = new HydrologyTerrainSample[count];
Pending[] pending = new Pending[count];
long[] ownedCoordinates = new long[count];
Pending[] owned = new Pending[count];
int ownedCount = count;
Completion completion = new Completion();
int[] next = new int[count];
int[] heads = groupByStripe(coordinates, count, next);
try {
for (int stripeIndex = 0; stripeIndex < withoutSlope.length; stripeIndex++) {
if (heads[stripeIndex] == 0) {
continue;
}
Stripe stripe = withoutSlope[stripeIndex];
synchronized (stripe) {
for (int position = heads[stripeIndex]; position != 0; position = next[position - 1]) {
int index = position - 1;
long packed = coordinates[index];
HydrologyTerrainSample cached = stripe.samples.getAndMoveToFirst(packed);
if (cached != null || stripe.samples.containsKey(packed)) {
samples[index] = cached;
continue;
}
Pending loading = stripe.pending.get(packed);
if (loading == null) {
loading = new Pending(completion);
stripe.pending.put(packed, loading);
ownedCoordinates[index] = packed;
owned[index] = loading;
}
pending[index] = loading;
}
}
}
int claimed = 0;
for (int index = 0; index < count; index++) {
if (owned[index] != null) {
ownedCoordinates[claimed] = ownedCoordinates[index];
owned[claimed++] = owned[index];
}
}
ownedCount = claimed;
if (ownedCount > 0) {
loadBatch(ownedCoordinates, owned, ownedCount, sampler);
}
} catch (RuntimeException | Error failure) {
for (int index = 0; index < ownedCount; index++) {
if (owned[index] != null) {
fail(withoutSlope, ownedCoordinates[index], owned[index], failure);
}
}
completion.failure = failure;
throw failure;
} finally {
completion.complete.countDown();
}
for (int index = 0; index < count; index++) {
if (pending[index] != null) {
samples[index] = pending[index].await();
}
}
return samples;
}
void seedWithoutSlope(Long2ObjectMap<HydrologyTerrainSample> samples) {
seed(samples, withoutSlope);
}
private HydrologyTerrainSample sample(int x, int z, HydrologyNaturalTerrainSampler sampler, boolean omitSlope) {
long packed = RiverFootprint.pack(x, z);
Stripe[] stripes = omitSlope ? withoutSlope : full;
Stripe stripe = stripe(stripes, packed);
Pending loading;
boolean owner = false;
synchronized (stripe) {
HydrologyTerrainSample cached = stripe.samples.getAndMoveToFirst(packed);
if (cached != null || stripe.samples.containsKey(packed)) {
return cached;
}
loading = stripe.pending.get(packed);
if (loading == null) {
loading = new Pending(new Completion());
stripe.pending.put(packed, loading);
owner = true;
}
}
if (owner) {
try {
HydrologyTerrainSample sampled = ProvisionalSampling.memoizable(() -> omitSlope
? sampler.sampleBasisWithoutSlope(x, z) : sampler.sampleBasis(x, z));
complete(stripes, packed, loading, sampled, false);
} catch (ProvisionalSampling.Unmemoizable provisional) {
complete(stripes, packed, loading, provisional.value(), true);
} catch (RuntimeException | Error failure) {
fail(stripes, packed, loading, failure);
throw failure;
} finally {
loading.completion.complete.countDown();
}
}
return loading.await();
}
private void loadBatch(long[] coordinates, Pending[] pending, int count, HydrologyNaturalTerrainSampler sampler) {
HydrologyTerrainSample[] samples;
boolean provisional = false;
try {
samples = ProvisionalSampling.memoizable(() -> sampler.sampleBasisWithoutSlopeBatch(coordinates, count));
} catch (ProvisionalSampling.Unmemoizable value) {
samples = value.value();
provisional = true;
}
if (samples.length < count) {
throw new IllegalStateException("Terrain basis batch returned too few samples");
}
int[] next = new int[count];
int[] heads = groupByStripe(coordinates, count, next);
for (int stripeIndex = 0; stripeIndex < withoutSlope.length; stripeIndex++) {
if (heads[stripeIndex] == 0) {
continue;
}
Stripe stripe = withoutSlope[stripeIndex];
synchronized (stripe) {
for (int position = heads[stripeIndex]; position != 0; position = next[position - 1]) {
int index = position - 1;
complete(stripe, coordinates[index], pending[index], samples[index], provisional);
}
}
}
}
private int[] groupByStripe(long[] coordinates, int count, int[] next) {
int[] heads = new int[withoutSlope.length];
for (int index = count - 1; index >= 0; index--) {
int stripeIndex = (int) CacheKey.mix(coordinates[index]) & (withoutSlope.length - 1);
next[index] = heads[stripeIndex];
heads[stripeIndex] = index + 1;
}
return heads;
}
private void complete(Stripe[] stripes, long packed, Pending pending, HydrologyTerrainSample sample,
boolean provisional) {
Stripe stripe = stripe(stripes, packed);
synchronized (stripe) {
complete(stripe, packed, pending, sample, provisional);
}
}
private void complete(Stripe stripe, long packed, Pending pending, HydrologyTerrainSample sample,
boolean provisional) {
if (stripe.samples.containsKey(packed)) {
sample = stripe.samples.getAndMoveToFirst(packed);
provisional = false;
} else if (!provisional) {
stripe.put(packed, sample);
}
stripe.pending.remove(packed);
pending.sample = sample;
pending.provisional = provisional;
}
private void fail(Stripe[] stripes, long packed, Pending pending, Throwable failure) {
Stripe stripe = stripe(stripes, packed);
synchronized (stripe) {
if (stripe.pending.remove(packed, pending)) {
pending.completion.failure = failure;
}
}
}
private void seed(Long2ObjectMap<HydrologyTerrainSample> samples, Stripe[] stripes) {
for (Long2ObjectMap.Entry<HydrologyTerrainSample> entry : samples.long2ObjectEntrySet()) {
Stripe stripe = stripe(stripes, entry.getLongKey());
synchronized (stripe) {
stripe.put(entry.getLongKey(), entry.getValue());
}
}
}
private Stripe stripe(Stripe[] stripes, long packed) {
return stripes[(int) CacheKey.mix(packed) & (stripes.length - 1)];
}
private static final class Stripe {
private final int maximumEntries;
private final Long2ObjectLinkedOpenHashMap<HydrologyTerrainSample> samples;
private final Long2ObjectOpenHashMap<Pending> pending = new Long2ObjectOpenHashMap<>();
private Stripe(int maximumEntries) {
this.maximumEntries = maximumEntries;
samples = new Long2ObjectLinkedOpenHashMap<>(Math.min(16, maximumEntries));
}
private void put(long packed, HydrologyTerrainSample value) {
if (samples.size() >= maximumEntries && !samples.containsKey(packed)) {
samples.removeLast();
}
samples.putAndMoveToFirst(packed, value);
}
}
private static final class Pending {
private final Completion completion;
private HydrologyTerrainSample sample;
private boolean provisional;
private Pending(Completion completion) {
this.completion = completion;
}
private HydrologyTerrainSample await() {
completion.await();
if (provisional) {
ProvisionalSampling.mark();
}
return sample;
}
}
private static final class Completion implements ForkJoinPool.ManagedBlocker {
private final Thread owner = Thread.currentThread();
private final CountDownLatch complete = new CountDownLatch(1);
private Throwable failure;
private void await() {
if (!isReleasable() && owner == Thread.currentThread()) {
throw new IllegalStateException("Recursive terrain basis sampling");
}
boolean interrupted = false;
boolean managed = true;
try {
while (!isReleasable()) {
try {
if (managed) {
ForkJoinPool.managedBlock(this);
} else {
complete.await();
}
} catch (InterruptedException interruption) {
interrupted = true;
managed = false;
} catch (RejectedExecutionException unavailable) {
managed = false;
}
}
} finally {
if (interrupted) {
Thread.currentThread().interrupt();
}
}
if (failure instanceof Error error) {
throw error;
}
if (failure instanceof RuntimeException runtime) {
throw runtime;
}
}
@Override
public boolean block() throws InterruptedException {
complete.await();
return true;
}
@Override
public boolean isReleasable() {
return complete.getCount() == 0;
}
}
}
@@ -1,72 +0,0 @@
package art.arcane.iris.generation.hydrology;
final class HydrologyCacheBudget {
private static final long MEBIBYTE = 1024L * 1024L;
private static final HydrologyCacheBudget REGIONAL_RUNTIME = forHeap(Runtime.getRuntime().maxMemory(), true);
private static final HydrologyCacheBudget LOCAL_RUNTIME = forHeap(Runtime.getRuntime().maxMemory(), false);
private final long plannerBytes;
private final long publicationBytes;
private final boolean regionalEnabled;
private HydrologyCacheBudget(long retainedBytes, boolean regionalEnabled) {
plannerBytes = retainedBytes / 2L;
publicationBytes = retainedBytes - plannerBytes;
this.regionalEnabled = regionalEnabled;
}
static HydrologyCacheBudget runtime() {
return REGIONAL_RUNTIME;
}
static HydrologyCacheBudget runtime(boolean regionalEnabled) {
return regionalEnabled ? REGIONAL_RUNTIME : LOCAL_RUNTIME;
}
static HydrologyCacheBudget forHeap(long maximumHeapBytes, boolean regionalEnabled) {
if (maximumHeapBytes <= 0L) {
throw new IllegalArgumentException("Maximum heap size must be positive.");
}
return new HydrologyCacheBudget(Math.clamp(maximumHeapBytes / 8L, 8L * MEBIBYTE, 256L * MEBIBYTE), regionalEnabled);
}
static long sharedStudioBytes() {
return Math.clamp(Runtime.getRuntime().maxMemory() / 128L, MEBIBYTE, 16L * MEBIBYTE);
}
long tileBytes() {
return publicationBytes * 3L / 4L;
}
long columnBytes() {
return publicationBytes / 8L;
}
long diagnosticBytes() {
return publicationBytes / 8L;
}
long routingBytes() {
return plannerBytes * 3L / 16L;
}
long ownerBytes() {
return regionalEnabled ? plannerBytes / 4L : plannerBytes * 3L / 4L;
}
long edgeBytes() {
return plannerBytes / 16L;
}
long regionalDraftBytes() {
return regionalEnabled ? plannerBytes / 8L : 0L;
}
long regionalCoarseBytes() {
return regionalEnabled ? plannerBytes / 8L : 0L;
}
long regionalTerrainBytes() {
return regionalEnabled ? plannerBytes / 4L : 0L;
}
}
@@ -1,127 +0,0 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.hydrology.cave.HydrologyCavePlan;
import java.util.List;
final class HydrologyCacheWeights {
private static final long ENTRY_BYTES = 192L;
private static final long TERRAIN_BYTES = 256L;
private static final long POINT_BYTES = 40L;
private HydrologyCacheWeights() {
}
static int bounded(long estimatedBytes, long budget, int maximumEntries) {
return saturated(Math.max(estimatedBytes, Math.ceilDiv(budget, maximumEntries)));
}
static int bounded(long estimatedBytes, long budget, int maximumEntries, int units, int maximumUnits) {
return bounded(Math.max(estimatedBytes, Math.ceilDiv((long) units * budget, maximumUnits)), budget, maximumEntries);
}
static int tile(HydrologyTile tile) {
long bytes = network(tile.nodes(), tile.edges(), tile.outlets(), tile.courses(), tile.cavePlans());
bytes += diagnostics(tile.localDiagnosticCandidates());
bytes += (long) tile.regionalCourseIds().size() * 64L;
if (tile.resolvedOwner() != null) {
bytes += owner(tile.resolvedOwner());
}
RiverFootprint footprint = tile.footprint();
if (footprint != null) {
bytes += (long) footprint.size() * 64L;
for (HydrologyColumnSample sample : footprint.columns().values()) {
bytes += column(sample);
}
}
return saturated(bytes);
}
static int column(HydrologyColumnSample sample) {
return saturated(96L + (long) sample.layers().size() * 192L);
}
static int diagnostics(List<HydrologyDiagnosticCandidate> diagnostics) {
return saturated(ENTRY_BYTES + (long) diagnostics.size() * 112L);
}
static int routing(SourceRoutingContext context) {
return saturated(ENTRY_BYTES + grid(context.grid()) + routingPlan(context.surfaceRouting())
+ routingPlan(context.undergroundRouting()) + diagnostics(context.diagnostics()));
}
static int owner(CrossTileResolvedOwner owner) {
HydrologyCaveCourseFilter.Result result = owner.draft().result();
long bytes = network(result.nodes(), result.edges(), result.outlets(), result.courses(), result.cavePlans())
+ diagnostics(owner.draft().diagnostics());
for (CrossTileRejectedCourse rejection : owner.observedRejections()) {
bytes += 48L + course(rejection.course());
}
return saturated(bytes);
}
static int points(List<HydrologyPoint> points) {
return saturated(ENTRY_BYTES + (long) points.size() * POINT_BYTES);
}
static int regionalNetwork(HydrologyRegionalNetwork network) {
return saturated(network(network.nodes(), network.edges(), network.outlets(), network.courses(), network.cavePlans())
+ diagnostics(network.diagnostics()));
}
static int regionalCoarse(HydrologyRegionalCoarsePlan plan) {
HydrologyRegionalGraph.Tree tree = plan.tree();
long treeBytes = tree == null ? 0L : 96L + (long) tree.selected().length * 8L
+ ((long) tree.accumulation().length + tree.heads().length) * 4L + tree.retainedLabelCount() * 96L;
return saturated(ENTRY_BYTES + grid(plan.grid()) + treeBytes + (long) plan.outlets().size() * 224L
+ (long) plan.sources().size() * 32L + (long) plan.envelopes().size() * 64L);
}
static int terrain(HydrologyTerrainSample terrain) {
return saturated(128L + (terrain == null ? 0L : TERRAIN_BYTES));
}
private static long grid(HydrologySampledGrid grid) {
return grid == null ? 0L : 96L + (long) grid.nodes().size() * (64L + TERRAIN_BYTES + 104L);
}
private static long routingPlan(HydrologyRoutingPlan plan) {
if (plan == null) {
return 0L;
}
return 128L + (long) plan.potential().length * 8L
+ ((long) plan.parent().length + plan.outletIndex().length + plan.routeLengths().length) * 4L
+ (long) plan.outlets().size() * 224L
+ (plan.surfaceDrainage() == null ? 0L : plan.surfaceDrainage().maximumRetainedBytes());
}
private static long network(List<DrainageNode> nodes, List<DrainageEdge> edges, List<RiverOutlet> outlets,
List<RiverCourse> courses, List<HydrologyCavePlan> cavePlans) {
long bytes = ENTRY_BYTES + (long) nodes.size() * (96L + TERRAIN_BYTES) + (long) outlets.size() * 224L;
for (DrainageEdge edge : edges) {
bytes += 96L + points(edge.centerline());
}
for (RiverCourse course : courses) {
bytes += course(course);
}
for (HydrologyCavePlan plan : cavePlans) {
bytes += plan.estimatedRetainedBytes();
}
return bytes;
}
private static long course(RiverCourse course) {
long bytes = 128L;
for (DrainageEdge edge : course.drainageEdges()) {
bytes += 96L + points(edge.centerline());
}
for (HydraulicSegment segment : course.segments()) {
bytes += 128L + points(segment.centerline()) + (long) segment.channelProfile().size() * 16L;
}
return bytes;
}
private static int saturated(long bytes) {
return (int) Math.clamp(bytes, 1L, Integer.MAX_VALUE);
}
}
@@ -1,12 +0,0 @@
package art.arcane.iris.generation.hydrology;
public enum HydrologyCandidateKind {
SOURCE,
OUTLET,
DEEP_FLUID,
POOL,
/** A later course to an outlet that could not be joined to the outlet's main course. */
TRIBUTARY,
REGIONAL_SOURCE,
COASTAL_CHANNEL
}
@@ -1,28 +0,0 @@
package art.arcane.iris.generation.hydrology;
public enum HydrologyCandidateRejection {
NO_LEGAL_OUTLET,
NO_DRAINAGE_PATH,
CONFINED_NO_OUTLET,
ROUTE_LIMIT,
COURSE_TOO_SHORT,
SOURCE_QUOTA,
SOURCE_SPACING,
OUTLET_LIMIT,
OUTLET_LEVEL,
CAVE_CONTAINMENT,
VOLUME_LIMIT,
SURFACE_HEAD_RANGE,
SURFACE_HEAD_DISTRIBUTION,
SURFACE_EXPOSURE,
SURFACE_DROP_UNSUPPORTED,
SURFACE_CORRIDOR_UNSUPPORTED,
SURFACE_TRANSITION_UNSUPPORTED,
SURFACE_SEGMENT_UNSUPPORTED,
SURFACE_SINKHOLE_CLEARANCE,
SURFACE_SHAPE_UNSUPPORTED,
SURFACE_MOUTH_DISCONNECTED,
SURFACE_WATER_CONTAINMENT,
SURFACE_BANK_BUDGET,
POLICY_EXCLUDED;
}
@@ -1,14 +0,0 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.hydrology.cave.CaveVoxelView;
@FunctionalInterface
public interface HydrologyCaveVoxelViewFactory {
CaveVoxelView create(PlannedSurface plannedSurface);
interface PlannedSurface {
int resolve(int x, int z, int naturalHeight);
boolean ownsTerrain(int x, int z);
}
}
Loaded 100 of 1065 files, more files were not shown because too many files have changed in this diff. Show more