diff --git a/core/src/main/java/art/arcane/iris/generation/context/ChunkContext.java b/core/src/main/java/art/arcane/iris/generation/context/ChunkContext.java index bcb02f8dc..68433612e 100644 --- a/core/src/main/java/art/arcane/iris/generation/context/ChunkContext.java +++ b/core/src/main/java/art/arcane/iris/generation/context/ChunkContext.java @@ -97,22 +97,23 @@ public class ChunkContext { } List fillTasks = new ArrayList<>(6); if (resolvedPlan.height) { - fillTasks.add(() -> height.fillRounded(roundedHeight)); + fillTasks.add(new PrefillFillTask(() -> height.fillRounded(roundedHeight), + capturePrefillMetric ? metrics.getContextPrefillHeight() : null)); } if (resolvedPlan.biome) { - fillTasks.add(new PrefillFillTask(biome)); + fillTasks.add(new PrefillFillTask(biome::fill, capturePrefillMetric ? metrics.getContextPrefillBiome() : null)); } if (resolvedPlan.rock) { - fillTasks.add(new PrefillFillTask(rock)); + fillTasks.add(new PrefillFillTask(rock::fill, capturePrefillMetric ? metrics.getContextPrefillRock() : null)); } if (resolvedPlan.fluid) { - fillTasks.add(new PrefillFillTask(fluid)); + fillTasks.add(new PrefillFillTask(fluid::fill, capturePrefillMetric ? metrics.getContextPrefillFluid() : null)); } if (resolvedPlan.region) { - fillTasks.add(new PrefillFillTask(region)); + fillTasks.add(new PrefillFillTask(region::fill, capturePrefillMetric ? metrics.getContextPrefillRegion() : null)); } if (resolvedPlan.cave) { - fillTasks.add(new PrefillFillTask(cave)); + fillTasks.add(new PrefillFillTask(cave::fill, capturePrefillMetric ? metrics.getContextPrefillCave() : null)); } if (!shouldPrefillAsync(resolvedPlan, fillTasks.size())) { @@ -297,15 +298,23 @@ public class ChunkContext { } private static final class PrefillFillTask implements Runnable { - private final ChunkedDataCache dataCache; + private final Runnable fill; + private final EngineMetrics.StageTimer timer; - private PrefillFillTask(ChunkedDataCache dataCache) { - this.dataCache = dataCache; + private PrefillFillTask(Runnable fill, EngineMetrics.StageTimer timer) { + this.fill = fill; + this.timer = timer; } @Override public void run() { - dataCache.fill(); + if (timer == null) { + fill.run(); + return; + } + long startedNanos = System.nanoTime(); + fill.run(); + timer.put((System.nanoTime() - startedNanos) / 1_000_000D); } } } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTileCache.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTileCache.java index bc8178418..f0c0854b7 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTileCache.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTileCache.java @@ -1,6 +1,7 @@ package art.arcane.iris.generation.hydrology; import art.arcane.iris.generation.runtime.GenerationClosedException; +import art.arcane.iris.generation.runtime.GenerationCounter; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.generation.concurrent.MultiBurst; import art.arcane.iris.generation.stream.ProvisionalSampling; @@ -499,6 +500,9 @@ public final class HydrologyTileCache implements AutoCloseable { } public Optional columnAt(int blockX, int blockZ) { + if (GenerationCounter.ENABLED) { + GenerationCounter.HYDROLOGY_LOOKUPS.increment(); + } return chunkColumns(blockX, blockZ).columnAt(blockX, blockZ); } @@ -507,6 +511,9 @@ public final class HydrologyTileCache implements AutoCloseable { if (!waitsForbidden()) { return HydrologyColumnSnapshot.ready(columnAt(blockX, blockZ).orElse(null)); } + if (GenerationCounter.ENABLED) { + GenerationCounter.HYDROLOGY_LOOKUPS.increment(); + } int chunkX = Math.floorDiv(blockX, CHUNK_SIZE); int chunkZ = Math.floorDiv(blockZ, CHUNK_SIZE); ChunkColumns local = localChunks(cacheEpoch.get()).get(chunkX, chunkZ); diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/EngineMetrics.java b/core/src/main/java/art/arcane/iris/generation/runtime/EngineMetrics.java index 95d7e08fa..711ddf35b 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/EngineMetrics.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/EngineMetrics.java @@ -24,63 +24,65 @@ import lombok.Data; import java.util.LinkedHashMap; import java.util.Map; +import java.util.concurrent.atomic.DoubleAdder; +import java.util.concurrent.atomic.LongAdder; @Data public class EngineMetrics { - private final AtomicRollingSequence total; - private final AtomicRollingSequence updates; - private final AtomicRollingSequence terrain; - private final AtomicRollingSequence biome; - private final AtomicRollingSequence parallax; - private final AtomicRollingSequence parallaxInsert; - private final AtomicRollingSequence post; - private final AtomicRollingSequence perfection; - private final AtomicRollingSequence api; - private final AtomicRollingSequence decoration; - private final AtomicRollingSequence cave; - private final AtomicRollingSequence ravine; - private final AtomicRollingSequence deposit; - private final AtomicRollingSequence carveResolve; - private final AtomicRollingSequence carveApply; - private final AtomicRollingSequence noiseHeightInterpolate; - private final AtomicRollingSequence noiseHeightGenerator; - private final AtomicRollingSequence contextPrefill; - private final AtomicRollingSequence contextPrefillHeight; - private final AtomicRollingSequence contextPrefillBiome; - private final AtomicRollingSequence contextPrefillRock; - private final AtomicRollingSequence contextPrefillFluid; - private final AtomicRollingSequence contextPrefillRegion; - private final AtomicRollingSequence contextPrefillCave; - private final AtomicRollingSequence pregenWaitPermit; - private final AtomicRollingSequence pregenWaitAdaptive; + private final StageTimer total; + private final StageTimer updates; + private final StageTimer terrain; + private final StageTimer biome; + private final StageTimer parallax; + private final StageTimer parallaxInsert; + private final StageTimer post; + private final StageTimer perfection; + private final StageTimer api; + private final StageTimer decoration; + private final StageTimer cave; + private final StageTimer ravine; + private final StageTimer deposit; + private final StageTimer carveResolve; + private final StageTimer carveApply; + private final StageTimer noiseHeightInterpolate; + private final StageTimer noiseHeightGenerator; + private final StageTimer contextPrefill; + private final StageTimer contextPrefillHeight; + private final StageTimer contextPrefillBiome; + private final StageTimer contextPrefillRock; + private final StageTimer contextPrefillFluid; + private final StageTimer contextPrefillRegion; + private final StageTimer contextPrefillCave; + private final StageTimer pregenWaitPermit; + private final StageTimer pregenWaitAdaptive; public EngineMetrics(int mem) { - this.total = new AtomicRollingSequence(mem); - this.terrain = new AtomicRollingSequence(mem); - this.api = new AtomicRollingSequence(mem); - this.biome = new AtomicRollingSequence(mem); - this.perfection = new AtomicRollingSequence(mem); - this.parallax = new AtomicRollingSequence(mem); - this.parallaxInsert = new AtomicRollingSequence(mem); - this.post = new AtomicRollingSequence(mem); - this.decoration = new AtomicRollingSequence(mem); - this.updates = new AtomicRollingSequence(mem); - this.cave = new AtomicRollingSequence(mem); - this.ravine = new AtomicRollingSequence(mem); - this.deposit = new AtomicRollingSequence(mem); - this.carveResolve = new AtomicRollingSequence(mem); - this.carveApply = new AtomicRollingSequence(mem); - this.noiseHeightInterpolate = new AtomicRollingSequence(mem); - this.noiseHeightGenerator = new AtomicRollingSequence(mem); - this.contextPrefill = new AtomicRollingSequence(mem); - this.contextPrefillHeight = new AtomicRollingSequence(mem); - this.contextPrefillBiome = new AtomicRollingSequence(mem); - this.contextPrefillRock = new AtomicRollingSequence(mem); - this.contextPrefillFluid = new AtomicRollingSequence(mem); - this.contextPrefillRegion = new AtomicRollingSequence(mem); - this.contextPrefillCave = new AtomicRollingSequence(mem); - this.pregenWaitPermit = new AtomicRollingSequence(mem); - this.pregenWaitAdaptive = new AtomicRollingSequence(mem); + this.total = new StageTimer(mem); + this.terrain = new StageTimer(mem); + this.api = new StageTimer(mem); + this.biome = new StageTimer(mem); + this.perfection = new StageTimer(mem); + this.parallax = new StageTimer(mem); + this.parallaxInsert = new StageTimer(mem); + this.post = new StageTimer(mem); + this.decoration = new StageTimer(mem); + this.updates = new StageTimer(mem); + this.cave = new StageTimer(mem); + this.ravine = new StageTimer(mem); + this.deposit = new StageTimer(mem); + this.carveResolve = new StageTimer(mem); + this.carveApply = new StageTimer(mem); + this.noiseHeightInterpolate = new StageTimer(mem); + this.noiseHeightGenerator = new StageTimer(mem); + this.contextPrefill = new StageTimer(mem); + this.contextPrefillHeight = new StageTimer(mem); + this.contextPrefillBiome = new StageTimer(mem); + this.contextPrefillRock = new StageTimer(mem); + this.contextPrefillFluid = new StageTimer(mem); + this.contextPrefillRegion = new StageTimer(mem); + this.contextPrefillCave = new StageTimer(mem); + this.pregenWaitPermit = new StageTimer(mem); + this.pregenWaitAdaptive = new StageTimer(mem); } public KMap pull() { @@ -134,4 +136,61 @@ public class EngineMetrics { averages.entrySet().removeIf(entry -> !Double.isFinite(entry.getValue()) || entry.getValue() < 0D); return Map.copyOf(averages); } + + public Map stageTotals() { + Map totals = new LinkedHashMap<>(); + totals.put("total", total.snapshot()); + totals.put("terrain", terrain.snapshot()); + totals.put("biome", biome.snapshot()); + totals.put("parallax", parallax.snapshot()); + totals.put("parallax.insert", parallaxInsert.snapshot()); + totals.put("post", post.snapshot()); + totals.put("perfection", perfection.snapshot()); + totals.put("decoration", decoration.snapshot()); + totals.put("api", api.snapshot()); + totals.put("updates", updates.snapshot()); + totals.put("cave", cave.snapshot()); + totals.put("ravine", ravine.snapshot()); + totals.put("deposit", deposit.snapshot()); + totals.put("carve.resolve", carveResolve.snapshot()); + totals.put("carve.apply", carveApply.snapshot()); + totals.put("noise.height.interpolate", noiseHeightInterpolate.snapshot()); + totals.put("noise.height.generator", noiseHeightGenerator.snapshot()); + totals.put("context.prefill", contextPrefill.snapshot()); + totals.put("context.prefill.height", contextPrefillHeight.snapshot()); + totals.put("context.prefill.biome", contextPrefillBiome.snapshot()); + totals.put("context.prefill.rock", contextPrefillRock.snapshot()); + totals.put("context.prefill.fluid", contextPrefillFluid.snapshot()); + totals.put("context.prefill.region", contextPrefillRegion.snapshot()); + totals.put("context.prefill.cave", contextPrefillCave.snapshot()); + totals.put("pregen.wait.permit", pregenWaitPermit.snapshot()); + totals.put("pregen.wait.adaptive", pregenWaitAdaptive.snapshot()); + return totals; + } + + public static final class StageTimer extends AtomicRollingSequence { + private final DoubleAdder totalMillis = new DoubleAdder(); + private final LongAdder samples = new LongAdder(); + + public StageTimer(int size) { + super(size); + } + + @Override + public void put(double millis) { + super.put(millis); + totalMillis.add(millis); + samples.increment(); + } + + public StageTotal snapshot() { + return new StageTotal(totalMillis.sum(), samples.sum()); + } + } + + public record StageTotal(double millis, long samples) { + public StageTotal since(StageTotal earlier) { + return new StageTotal(millis - earlier.millis(), samples - earlier.samples()); + } + } } diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/GenerationCounter.java b/core/src/main/java/art/arcane/iris/generation/runtime/GenerationCounter.java new file mode 100644 index 000000000..70c3aa3d2 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/runtime/GenerationCounter.java @@ -0,0 +1,34 @@ +package art.arcane.iris.generation.runtime; + +import java.util.concurrent.atomic.LongAdder; + +public enum GenerationCounter { + GENERATOR_EVALUATIONS("generator_evaluations"), + CORNER_STARCASTS("corner_starcasts"), + HYDROLOGY_LOOKUPS("hydrology_lookups"); + + public static final boolean ENABLED = Boolean.getBoolean("iris.generation.counters"); + + private final String key; + private final LongAdder count = new LongAdder(); + + GenerationCounter(String key) { + this.key = key; + } + + public String key() { + return key; + } + + public void add(long amount) { + count.add(amount); + } + + public void increment() { + count.increment(); + } + + public long sum() { + return count.sum(); + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java b/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java index 64dcf075c..265782228 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java @@ -867,6 +867,9 @@ public class IrisComplex implements DataProvider { break; } generator.getHeights(activeXs, activeZs, seed, noise, activeCount); + if (GenerationCounter.ENABLED) { + GenerationCounter.GENERATOR_EVALUATIONS.add(activeCount); + } for (int sample = 0; sample < activeCount; sample++) { int index = active[sample]; if (!Double.isFinite(noise[sample])) { @@ -1838,6 +1841,9 @@ public class IrisComplex implements DataProvider { long sharedKey = SharedCornerBounds.key(gx, gz); long packed = shared.get(sharedKey, interpolatorIndex); if (packed == Long.MIN_VALUE) { + if (GenerationCounter.ENABLED) { + GenerationCounter.CORNER_STARCASTS.increment(); + } packed = computePackedBounds(cache, engine, interpolator, generators, gx, gz); if (!Double.isFinite(boundsLow(packed)) || !Double.isFinite(boundsHigh(packed))) { // A non-finite corner is a transient sampling fault, not a fact about the terrain: @@ -2080,6 +2086,9 @@ public class IrisComplex implements DataProvider { } return height / generators.length; } + if (GenerationCounter.ENABLED) { + GenerationCounter.GENERATOR_EVALUATIONS.add(generators.length); + } for (IrisGenerator generator : generators) { double noise = generator.getHeight(x, z, seed); if (!Double.isFinite(noise)) { diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/EngineMetricsStageTotalsTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/EngineMetricsStageTotalsTest.java new file mode 100644 index 000000000..731687e79 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/runtime/EngineMetricsStageTotalsTest.java @@ -0,0 +1,47 @@ +package art.arcane.iris.generation.runtime; + +import org.junit.Test; + +import java.util.Map; +import java.util.Set; + +import static org.junit.Assert.assertEquals; + +public final class EngineMetricsStageTotalsTest { + @Test + public void stageTotalsAccumulatePastTheRollingWindow() { + EngineMetrics metrics = new EngineMetrics(2); + metrics.getTerrain().put(1.5D); + metrics.getTerrain().put(2.5D); + metrics.getTerrain().put(4D); + + EngineMetrics.StageTotal terrain = metrics.stageTotals().get("terrain"); + + assertEquals(8D, terrain.millis(), 0D); + assertEquals(3L, terrain.samples()); + assertEquals(3.25D, metrics.getTerrain().getAverage(), 0D); + } + + @Test + public void stageTotalsSinceAnEarlierSnapshotCoverOnlyTheLaterPuts() { + EngineMetrics metrics = new EngineMetrics(4); + metrics.getCarveResolve().put(3D); + EngineMetrics.StageTotal before = metrics.stageTotals().get("carve.resolve"); + metrics.getCarveResolve().put(5D); + metrics.getCarveResolve().put(7D); + + EngineMetrics.StageTotal window = metrics.stageTotals().get("carve.resolve").since(before); + + assertEquals(12D, window.millis(), 0D); + assertEquals(2L, window.samples()); + } + + @Test + public void stageTotalsNameEveryPulledTimer() { + EngineMetrics metrics = new EngineMetrics(1); + Map totals = metrics.stageTotals(); + + assertEquals(Set.copyOf(metrics.pull().keySet()), Set.copyOf(totals.keySet())); + assertEquals(metrics.pull().size(), totals.size()); + } +} diff --git a/probe/build.gradle b/probe/build.gradle index bf17231f1..e09a02a40 100644 --- a/probe/build.gradle +++ b/probe/build.gradle @@ -175,6 +175,12 @@ tasks.register('genProbe', JavaExec) { } systemProperty('iris.probe.threads', providers.gradleProperty('probeThreads').orElse('1').get()) systemProperty('iris.probe.pregenProfile', providers.gradleProperty('probePregenProfile').orElse('false').get()) + systemProperty('iris.probe.hydrologyPreplan', providers.gradleProperty('probeHydrologyPreplan').orElse('false').get()) + systemProperty('iris.probe.historyRouter', providers.gradleProperty('probeHistoryRouter').orElse('false').get()) + systemProperty('iris.probe.productionBuffers', providers.gradleProperty('probeProductionBuffers').orElse('false').get()) + String probeCounters = providers.gradleProperty('probeCounters').orElse('false').get() + systemProperty('iris.generation.counters', probeCounters) + systemProperty('volmlib.cache.countContention', probeCounters) dependsOn(':core:compileJava') doFirst { if (!probePack.isPresent() || probePack.get().isBlank()) { diff --git a/probe/src/main/java/art/arcane/iris/probe/ChunkDump.java b/probe/src/main/java/art/arcane/iris/probe/ChunkDump.java index 5c3b1e85e..6d072caae 100644 --- a/probe/src/main/java/art/arcane/iris/probe/ChunkDump.java +++ b/probe/src/main/java/art/arcane/iris/probe/ChunkDump.java @@ -20,6 +20,7 @@ package art.arcane.iris.probe; import art.arcane.volmlib.nativelib.terrain.NativeBiome; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.nativelib.terrain.NativeBlockVolume; import art.arcane.volmlib.util.hunk.Hunk; import java.io.BufferedInputStream; @@ -84,11 +85,12 @@ public final class ChunkDump { char[] biomeIndices = new char[size]; PaletteBuilder blockPalette = new PaletteBuilder(); PaletteBuilder biomePalette = new PaletteBuilder(); + NativeBlockVolume stored = blocks instanceof NativeBlockVolume volume ? volume : null; int index = 0; for (int x = 0; x < 16; x++) { for (int z = 0; z < 16; z++) { for (int y = 0; y < height; y++) { - NativeBlockState state = blocks.get(x, y, z); + NativeBlockState state = stored == null ? blocks.get(x, y, z) : stored.getStoredRaw(x, y, z); NativeBiome biome = biomes.get(x, y, z); blockIndices[index] = blockPalette.indexOf(state, state == null ? NULL_BLOCK_KEY : null); biomeIndices[index] = biomePalette.indexOf(biome, biome == null ? NULL_BIOME_KEY : null); diff --git a/probe/src/main/java/art/arcane/iris/probe/GenerationProbe.java b/probe/src/main/java/art/arcane/iris/probe/GenerationProbe.java index 671b2b408..547d667c4 100644 --- a/probe/src/main/java/art/arcane/iris/probe/GenerationProbe.java +++ b/probe/src/main/java/art/arcane/iris/probe/GenerationProbe.java @@ -18,31 +18,42 @@ package art.arcane.iris.probe; +import art.arcane.iris.pack.datapack.DataVersion; import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.pack.PackValidationResult; import art.arcane.iris.pack.PackValidator; +import art.arcane.iris.generation.context.IrisContext; import art.arcane.iris.generation.runtime.IrisEngine; import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.EngineAssignedComponent; import art.arcane.iris.generation.runtime.EngineEffects; import art.arcane.iris.generation.runtime.EngineEffectsProvider; +import art.arcane.iris.generation.runtime.EngineMetrics; import art.arcane.iris.generation.runtime.EnginePlatformHooks; import art.arcane.iris.generation.runtime.EngineTarget; import art.arcane.iris.generation.runtime.EngineWorldManager; import art.arcane.iris.generation.runtime.EngineWorldManagerProvider; +import art.arcane.iris.generation.runtime.GenerationCounter; +import art.arcane.iris.generation.runtime.GenerationSessionLease; import art.arcane.iris.generation.runtime.MeteredCache; import art.arcane.iris.generation.runtime.PreservationRegistry; import art.arcane.iris.configuration.IrisSettings; import art.arcane.iris.generation.concurrent.MultiBurst; +import art.arcane.iris.generation.hydrology.HydrologyPlannerSettings; import art.arcane.iris.generation.hydrology.HydrologyTileCache; +import art.arcane.iris.generation.hydrology.HydrologyTileKey; import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntime; import art.arcane.iris.generation.terrain.IrisDimension; import art.arcane.iris.world.IrisWorld; +import art.arcane.iris.world.history.GenerationHistory; +import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter; +import art.arcane.iris.world.history.IrisBoundarySignatureSampler; import art.arcane.iris.world.pregen.PregenPerformanceProfile; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.IrisServices; import art.arcane.volmlib.nativelib.terrain.NativeBiome; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.cache.CacheContention; import art.arcane.volmlib.util.hunk.Hunk; import art.arcane.volmlib.util.noise.FractalBillowSimplexNoise; @@ -66,6 +77,7 @@ 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.AtomicInteger; public final class GenerationProbe { private static final List REPORTED = Collections.synchronizedList(new ArrayList<>()); @@ -168,7 +180,8 @@ public final class GenerationProbe { } } - record ProbeOptions(int threads, Path dumpFile, boolean pregenProfile) { + record ProbeOptions(int threads, Path dumpFile, boolean pregenProfile, boolean hydrologyPreplan, + boolean historyRouter, boolean productionBuffers, boolean counters) { ProbeOptions { if (threads < 1) { throw new IllegalArgumentException("Thread count must be at least 1."); @@ -183,7 +196,15 @@ public final class GenerationProbe { return new ProbeOptions( Integer.parseInt(System.getProperty("iris.probe.threads", "1").trim()), dump.isEmpty() ? null : Path.of(dump), - Boolean.parseBoolean(System.getProperty("iris.probe.pregenProfile", "false").trim())); + booleanProperty("iris.probe.pregenProfile"), + booleanProperty("iris.probe.hydrologyPreplan"), + booleanProperty("iris.probe.historyRouter"), + booleanProperty("iris.probe.productionBuffers"), + booleanProperty("iris.generation.counters")); + } + + private static boolean booleanProperty(String key) { + return Boolean.parseBoolean(System.getProperty(key, "false").trim()); } boolean parallel() { @@ -257,6 +278,29 @@ public final class GenerationProbe { TimingSummary measuredTimings, String signature) { } + record WindowMetrics(Map stages, Map counters) { + static WindowMetrics capture(Engine engine, boolean counters) { + return new WindowMetrics(engine.getMetrics().stageTotals(), counters ? counterValues() : Map.of()); + } + + private static Map counterValues() { + Map values = new LinkedHashMap<>(); + for (GenerationCounter counter : GenerationCounter.values()) { + values.put(counter.key(), counter.sum()); + } + values.put("cache_duplicate_computations", CacheContention.duplicateComputations()); + return values; + } + } + + record HydrologyPreplan(int tiles, int tileSize, int publicationRadius, int reachChunks, long elapsedNanos) { + String machineLine() { + return String.format(Locale.ROOT, + "IRIS_GENPROBE_HYDROLOGY_PREPLAN version=1 tiles=%d tile_size=%d publication_radius=%d reach_chunks=%d planned_ms=%.3f", + tiles, tileSize, publicationRadius, reachChunks, nanosToMillis(elapsedNanos)); + } + } + record ProbeResult(String status, String dimensionKey, int warmupChunks, int measuredChunks, boolean multicore, boolean studio, int successfulChunks, int failedChunks, long engineReadyNanos, long firstChunkNanos, @@ -285,6 +329,51 @@ public final class GenerationProbe { } } + static String stagesLine(WindowMetrics start, WindowMetrics end, int chunks, int threads) { + StringBuilder line = new StringBuilder(512); + line.append("IRIS_GENPROBE_STAGES version=1 chunks=").append(chunks).append(" threads=").append(threads); + for (Map.Entry entry : end.stages().entrySet()) { + EngineMetrics.StageTotal earlier = start.stages().get(entry.getKey()); + EngineMetrics.StageTotal window = earlier == null ? entry.getValue() : entry.getValue().since(earlier); + line.append(' ').append(entry.getKey()).append('=') + .append(String.format(Locale.ROOT, "%.3f", window.millis())); + } + return line.toString(); + } + + static String countersLine(WindowMetrics start, WindowMetrics end, int chunks) { + StringBuilder line = new StringBuilder(256); + line.append("IRIS_GENPROBE_COUNTERS version=1 chunks=").append(chunks); + for (Map.Entry entry : end.counters().entrySet()) { + long window = entry.getValue() - start.counters().getOrDefault(entry.getKey(), 0L); + line.append(' ').append(entry.getKey()).append('=').append(window) + .append(' ').append(entry.getKey()).append("_per_chunk=") + .append(String.format(Locale.ROOT, "%.1f", chunks == 0 ? 0D : window / (double) chunks)); + } + return line.toString(); + } + + static List preplanTiles(ProbeConfiguration configuration, int tileSize, int publicationRadius, + int reachChunks) { + HydrologyTileCache.PregenerationArea area = pregenerationArea(configuration); + long reach = (long) reachChunks * 16L + publicationRadius; + int minimumX = Math.toIntExact(Math.floorDiv(area.minimumBlockX() - reach, tileSize)) - 1; + int minimumZ = Math.toIntExact(Math.floorDiv(area.minimumBlockZ() - reach, tileSize)) - 1; + int maximumX = Math.toIntExact(Math.floorDiv(area.maximumBlockX() + reach, tileSize)) + 1; + int maximumZ = Math.toIntExact(Math.floorDiv(area.maximumBlockZ() + reach, tileSize)) + 1; + int centerX = Math.floorDiv(area.centerBlockX(), tileSize); + int centerZ = Math.floorDiv(area.centerBlockZ(), tileSize); + List keys = new ArrayList<>((maximumX - minimumX + 1) * (maximumZ - minimumZ + 1)); + for (int tileZ = minimumZ; tileZ <= maximumZ; tileZ++) { + for (int tileX = minimumX; tileX <= maximumX; tileX++) { + keys.add(new HydrologyTileKey(tileX, tileZ)); + } + } + keys.sort(Comparator.comparingInt((HydrologyTileKey key) -> + Math.max(Math.abs(key.tileX() - centerX), Math.abs(key.tileZ() - centerZ)))); + return keys; + } + public static void main(String[] args) { ProbeConfiguration configuration; ProbeOptions options; @@ -354,7 +443,7 @@ public final class GenerationProbe { Throwable executionFailure = null; try { File pack = clonePack(configuration.packSource(), workRoot); - configureProbeRuntime(workRoot); + configureProbeRuntime(workRoot, options.historyRouter()); validatePack(pack); System.out.println("[genprobe] pack: " + configuration.packSource().getAbsolutePath()); @@ -367,29 +456,48 @@ public final class GenerationProbe { System.out.println("[genprobe] studio: " + configuration.studio()); System.out.println("[genprobe] threads: " + options.threads()); System.out.println("[genprobe] pregen profile: " + options.pregenProfile()); + System.out.println("[genprobe] hydrology preplan: " + options.hydrologyPreplan()); + System.out.println("[genprobe] history router: " + options.historyRouter()); + System.out.println("[genprobe] production buffers: " + options.productionBuffers()); + System.out.println("[genprobe] counters: " + options.counters()); System.out.println("[genprobe] dump file: " + (options.dumpFile() == null ? "none" : options.dumpFile())); long engineStart = System.nanoTime(); + File worldFolder = new File(workRoot, "world"); data = IrisData.get(pack); - IrisDimension dimension = data.getDimensionLoader().load(configuration.dimensionKey()); - if (dimension == null) { - throw new IllegalStateException("Dimension '" + configuration.dimensionKey() - + "' did not load from " + pack.getAbsolutePath()); + IrisDimension dimension = loadDimension(data, configuration.dimensionKey()); + GenerationHistory history = null; + if (options.historyRouter()) { + history = HeadlessGenerationHistorySession.create(new RealPackProbeSupport.HistoryRequest( + worldFolder.toPath(), data, dimension, configuration.seed()), DataVersion.getLatest().get()); + data.close(); + data = IrisData.openRuntime(history.activePackRoot().toFile()); + data.bindGenerationRegistryContract(history.activeEpoch().registryContract()); + dimension = loadDimension(data, configuration.dimensionKey()); } IrisWorld world = IrisWorld.builder() .platformIdentity("iris:probe") .name("probe") .seed(configuration.seed()) - .worldFolder(new File(workRoot, "world")) + .worldFolder(worldFolder) .minHeight(dimension.getMinHeight()) .maxHeight(dimension.getMaxHeight()) .build(); EngineTarget target = new EngineTarget(world, dimension, data); - engine = new IrisEngine( - target, - configuration.studio() - ? IrisEngine.InitializationMode.STUDIO - : IrisEngine.InitializationMode.RUNTIME); + IrisEngine.InitializationMode mode = configuration.studio() + ? IrisEngine.InitializationMode.STUDIO + : IrisEngine.InitializationMode.RUNTIME; + GenerationHistoryRuntimeRouter router = null; + if (history == null) { + engine = new IrisEngine(target, mode); + } else { + IrisEngine historyEngine = new IrisEngine(target, mode, + history.paths().activationMantleRoot(history.activeActivation().activationId()), + history.activeEpoch().kernelVersion(), null); + engine = historyEngine; + router = GenerationHistoryRuntimeRouter.attach(historyEngine, history, IrisBoundarySignatureSampler.INSTANCE); + router.preloadActiveRuntimes(); + } long engineReadyNanos = System.nanoTime() - engineStart; List initNoise = settleAndDrain(); @@ -398,9 +506,16 @@ public final class GenerationProbe { + " seed=" + engine.getSeedManager().getSeed() + " minY=" + engine.getMinHeight() + " maxY=" + engine.getMaxHeight() + " timeMs=" + String.format(Locale.ROOT, "%.3f", nanosToMillis(engineReadyNanos))); + System.out.println("IRIS_GENPROBE_MANTLE version=1 radius=" + engine.getMantle().getRadius() + + " real_radius=" + engine.getMantle().getRealRadius()); + ProbeGenerator generator = new ProbeGenerator(engine, router, options.productionBuffers()); long nativeSamplesBefore = FractalBillowSimplexNoise.nativeSampleCount(); - GenerationResult generation = generateWithProfile(engine, configuration, options); + GenerationResult generation = generateWithProfile(generator, configuration, options); verifyBackend(nativeSamplesBefore); + if (options.productionBuffers()) { + System.out.println("IRIS_GENPROBE_BUFFERS version=1 mode=production biome_expanded_chunks=" + + generator.expandedBiomeHunks()); + } String status = generation.failedChunks() == 0 ? "PASS" : "FAIL"; printGenerationFailures(generation); return new ProbeResult( @@ -445,7 +560,16 @@ public final class GenerationProbe { } } - private static void configureProbeRuntime(File workRoot) { + private static IrisDimension loadDimension(IrisData data, String dimensionKey) { + IrisDimension dimension = data.getDimensionLoader().load(dimensionKey); + if (dimension == null) { + throw new IllegalStateException("Dimension '" + dimensionKey + "' did not load from " + + data.getDataFolder().getAbsolutePath()); + } + return dimension; + } + + private static void configureProbeRuntime(File workRoot, boolean generationRegistry) { REPORTED.clear(); StubPlatform.bindGenerationStateHandlers(); StubPlatform.verbose(false); @@ -456,7 +580,8 @@ public final class GenerationProbe { IrisServices.register(EngineEffectsProvider.class, (EngineEffectsProvider) InertEffects::new); IrisServices.register(EnginePlatformHooks.class, new InertPlatformHooks()); IrisPlatforms.unbind(); - IrisPlatforms.bind(new StubPlatform(new File(workRoot, "platform-data"))); + IrisPlatforms.bind(new StubPlatform(new File(workRoot, "platform-data"), + generationRegistry ? ProbeGenerationRegistry.INSTANCE : null)); } private static void validatePack(File pack) { @@ -473,21 +598,73 @@ public final class GenerationProbe { System.out.println("[genprobe] offline pack validation: PASS"); } - private static GenerationResult generateWithProfile(Engine engine, ProbeConfiguration configuration, + private static GenerationResult generateWithProfile(ProbeGenerator generator, ProbeConfiguration configuration, ProbeOptions options) throws Exception { + Engine engine = generator.engine(); if (!options.pregenProfile()) { - return generate(engine, configuration, options); + preplanHydrology(engine, configuration, options); + return generate(generator, configuration, options); } PregenPerformanceProfile.apply(engine); IrisHydrologyRuntime hydrology = engine.getComplex().getHydrologyRuntime(); try (HydrologyTileCache.PregenerationScope ignored = hydrology == null ? null : hydrology.preparePregeneration(pregenerationArea(configuration))) { - return generate(engine, configuration, options); + preplanHydrology(engine, configuration, options); + return generate(generator, configuration, options); } finally { PregenPerformanceProfile.restore(); } } + private static void preplanHydrology(Engine engine, ProbeConfiguration configuration, ProbeOptions options) + throws Exception { + if (!options.hydrologyPreplan()) { + return; + } + IrisHydrologyRuntime hydrology = engine.getComplex().getHydrologyRuntime(); + if (hydrology == null) { + System.out.println("IRIS_GENPROBE_HYDROLOGY_PREPLAN version=1 tiles=0 skipped=no-hydrology"); + return; + } + HydrologyPlannerSettings settings = hydrology.settings(); + int tileSize = settings.routing().tileSize(); + int publicationRadius = settings.publicationRadius(); + int reachChunks = Math.max(engine.getMantle().getRadius(), engine.getMantle().getRealRadius()); + List keys = preplanTiles(configuration, tileSize, publicationRadius, reachChunks); + long started = System.nanoTime(); + ExecutorService executor = Executors.newFixedThreadPool(Math.min(keys.size(), 4)); + try { + List> plans = new ArrayList<>(keys.size()); + for (HydrologyTileKey key : keys) { + plans.add(executor.submit(() -> hydrology.tile(key))); + } + for (Future plan : plans) { + awaitPlan(plan); + } + } finally { + executor.shutdown(); + } + HydrologyPreplan preplan = new HydrologyPreplan(keys.size(), tileSize, publicationRadius, reachChunks, + System.nanoTime() - started); + printDistinctCauses("hydrology preplan reported errors", drainReported()); + System.out.println(preplan.machineLine()); + } + + private static void awaitPlan(Future plan) throws Exception { + try { + plan.get(); + } catch (ExecutionException failure) { + Throwable cause = failure.getCause(); + if (cause instanceof Error error) { + throw error; + } + if (cause instanceof Exception exception) { + throw exception; + } + throw new IllegalStateException(cause); + } + } + static HydrologyTileCache.PregenerationArea pregenerationArea(ProbeConfiguration configuration) { List coordinates = scheduleCoordinates( configuration.warmupChunks() + configuration.measuredChunks(), @@ -531,8 +708,9 @@ public final class GenerationProbe { return Math.atan2(coordinate.z() - centerChunkZ, coordinate.x() - centerChunkX); } - private static GenerationResult generate(Engine engine, ProbeConfiguration configuration, + private static GenerationResult generate(ProbeGenerator generator, ProbeConfiguration configuration, ProbeOptions options) throws Exception { + Engine engine = generator.engine(); int totalChunks = configuration.warmupChunks() + configuration.measuredChunks(); List coordinates = scheduleCoordinates( totalChunks, configuration.centerChunkX(), configuration.centerChunkZ()); @@ -541,29 +719,41 @@ public final class GenerationProbe { engine.getMinHeight(), height); try (OutcomeSink sink = new OutcomeSink(options.dumpFile(), header, configuration.measuredChunks())) { if (options.parallel()) { - generateParallel(engine, configuration, options, coordinates, sink); + generateParallel(generator, configuration, options, coordinates, sink); } else { - generateSequential(engine, configuration, coordinates, sink); + generateSequential(generator, configuration, options, coordinates, sink); } return sink.result(configuration); } } - private static void generateSequential(Engine engine, ProbeConfiguration configuration, - List coordinates, OutcomeSink sink) throws IOException { + private static void generateSequential(ProbeGenerator generator, ProbeConfiguration configuration, + ProbeOptions options, List coordinates, + OutcomeSink sink) throws IOException { int totalChunks = coordinates.size(); long measuredStartEpochMillis = 0L; + WindowMetrics measuredStart = null; for (int index = 0; index < totalChunks; index++) { if (index == configuration.warmupChunks()) { measuredStartEpochMillis = System.currentTimeMillis(); + measuredStart = WindowMetrics.capture(generator.engine(), options.counters()); } drainReported(); - ChunkOutcome outcome = generateChunk(engine, index, coordinates.get(index), configuration.multicore()); + ChunkOutcome outcome = generator.generate(index, coordinates.get(index), configuration.multicore()); outcome.failures().addAll(drainReported()); sink.accept(outcome, index >= configuration.warmupChunks()); printProgress(configuration, index + 1, totalChunks); } printWindow(measuredStartEpochMillis, System.currentTimeMillis()); + printWindowMetrics(measuredStart, WindowMetrics.capture(generator.engine(), options.counters()), + configuration.measuredChunks(), options); + } + + private static void printWindowMetrics(WindowMetrics start, WindowMetrics end, int chunks, ProbeOptions options) { + System.out.println(stagesLine(start, end, chunks, options.threads())); + if (options.counters()) { + System.out.println(countersLine(start, end, chunks)); + } } private static void printWindow(long startEpochMillis, long endEpochMillis) { @@ -571,8 +761,9 @@ public final class GenerationProbe { + " end_epoch_ms=" + endEpochMillis); } - private static void generateParallel(Engine engine, ProbeConfiguration configuration, ProbeOptions options, - List coordinates, OutcomeSink sink) throws Exception { + private static void generateParallel(ProbeGenerator generator, ProbeConfiguration configuration, + ProbeOptions options, List coordinates, + OutcomeSink sink) throws Exception { ChunkOutcome[] outcomes = new ChunkOutcome[coordinates.size()]; ExecutorService executor = Executors.newFixedThreadPool(options.threads()); long warmupWallNanos; @@ -585,7 +776,7 @@ public final class GenerationProbe { warmup.add(index); } long warmupStarted = System.nanoTime(); - runPhase(executor, engine, configuration, coordinates, warmup, outcomes); + runPhase(executor, generator, configuration, coordinates, warmup, outcomes); warmupWallNanos = System.nanoTime() - warmupStarted; unattributed.addAll(drainReported()); System.out.println("[genprobe] warmup complete: " + configuration.warmupChunks() + " chunks on " @@ -593,11 +784,14 @@ public final class GenerationProbe { List measured = spiralOrder(coordinates, configuration.warmupChunks(), configuration.centerChunkX(), configuration.centerChunkZ()); + WindowMetrics measuredStart = WindowMetrics.capture(generator.engine(), options.counters()); long measuredStartEpochMillis = System.currentTimeMillis(); long measuredStarted = System.nanoTime(); - runPhase(executor, engine, configuration, coordinates, measured, outcomes); + runPhase(executor, generator, configuration, coordinates, measured, outcomes); measuredWallNanos = System.nanoTime() - measuredStarted; printWindow(measuredStartEpochMillis, System.currentTimeMillis()); + printWindowMetrics(measuredStart, WindowMetrics.capture(generator.engine(), options.counters()), + configuration.measuredChunks(), options); unattributed.addAll(drainReported()); System.out.println("[genprobe] measured progress: " + configuration.measuredChunks() + "/" + configuration.measuredChunks()); @@ -631,13 +825,13 @@ public final class GenerationProbe { System.out.println(throughput.machineLine()); } - private static void runPhase(ExecutorService executor, Engine engine, ProbeConfiguration configuration, - List coordinates, List order, - ChunkOutcome[] outcomes) throws Exception { + private static void runPhase(ExecutorService executor, ProbeGenerator generator, + ProbeConfiguration configuration, List coordinates, + List order, ChunkOutcome[] outcomes) throws Exception { List> futures = new ArrayList<>(order.size()); for (int index : order) { ChunkCoordinate coordinate = coordinates.get(index); - futures.add(executor.submit(() -> generateChunk(engine, index, coordinate, configuration.multicore()))); + futures.add(executor.submit(() -> generator.generate(index, coordinate, configuration.multicore()))); } for (Future future : futures) { ChunkOutcome outcome; @@ -657,21 +851,6 @@ public final class GenerationProbe { } } - private static ChunkOutcome generateChunk(Engine engine, int index, ChunkCoordinate coordinate, - boolean multicore) { - int height = engine.getTarget().getHeight(); - List failures = new ArrayList<>(); - Hunk blocks = Hunk.newArrayHunk(16, height, 16); - Hunk biomes = Hunk.newArrayHunk(16, height, 16); - long started = System.nanoTime(); - try { - engine.generate(coordinate.x() << 4, coordinate.z() << 4, blocks, biomes, multicore); - } catch (Throwable e) { - failures.add(e); - } - return new ChunkOutcome(index, coordinate, System.nanoTime() - started, blocks, biomes, failures); - } - private static void printProgress(ProbeConfiguration configuration, int completed, int totalChunks) { if (completed == configuration.warmupChunks()) { System.out.println("[genprobe] warmup complete: " + configuration.warmupChunks() + " chunks"); @@ -878,6 +1057,72 @@ public final class GenerationProbe { return nanos / 1_000_000D; } + static final class ProbeGenerator { + private final Engine engine; + private final GenerationHistoryRuntimeRouter router; + private final boolean productionBuffers; + private final AtomicInteger expandedBiomeHunks = new AtomicInteger(); + + ProbeGenerator(Engine engine, GenerationHistoryRuntimeRouter router, boolean productionBuffers) { + this.engine = engine; + this.router = router; + this.productionBuffers = productionBuffers; + } + + Engine engine() { + return engine; + } + + int expandedBiomeHunks() { + return expandedBiomeHunks.get(); + } + + ChunkOutcome generate(int index, ChunkCoordinate coordinate, boolean multicore) { + int height = engine.getTarget().getHeight(); + List failures = new ArrayList<>(); + Hunk blocks = newBlocks(height); + Hunk biomes = newBiomes(height); + long started = System.nanoTime(); + try { + generateInto(coordinate.x() << 4, coordinate.z() << 4, blocks, biomes, multicore); + } catch (Throwable e) { + failures.add(e); + } + long elapsed = System.nanoTime() - started; + if (biomes instanceof ProductionBiomeHunk production && production.expanded()) { + expandedBiomeHunks.incrementAndGet(); + } + return new ChunkOutcome(index, coordinate, elapsed, blocks, biomes, failures); + } + + Hunk newBlocks(int height) { + return productionBuffers ? new ProductionBlockHunk(height) : Hunk.newArrayHunk(16, height, 16); + } + + Hunk newBiomes(int height) { + return productionBuffers ? new ProductionBiomeHunk(height, ProbeGenerator::plainsBiome) + : Hunk.newArrayHunk(16, height, 16); + } + + private void generateInto(int blockX, int blockZ, Hunk blocks, Hunk biomes, + boolean multicore) throws Exception { + if (router == null) { + engine.generate(blockX, blockZ, blocks, biomes, multicore); + return; + } + try (GenerationHistoryRuntimeRouter.CoordinateScope historyScope = router.openCoordinateScope(blockX, blockZ); + GenerationSessionLease lease = engine.acquireGenerationLease("probe_terrain_stage"); + IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + engine.generate(blockX, blockZ, blocks, biomes, multicore); + historyScope.claimGeneratedSemantics(); + } + } + + private static NativeBiome plainsBiome() { + return IrisPlatforms.get().registries().biome("minecraft:plains"); + } + } + private static final class OutcomeSink implements AutoCloseable { private final MessageDigest signature = sha256(); private final List measuredTimings; diff --git a/probe/src/main/java/art/arcane/iris/probe/ProbeGenerationRegistry.java b/probe/src/main/java/art/arcane/iris/probe/ProbeGenerationRegistry.java new file mode 100644 index 000000000..644845015 --- /dev/null +++ b/probe/src/main/java/art/arcane/iris/probe/ProbeGenerationRegistry.java @@ -0,0 +1,51 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.probe; + +import art.arcane.iris.spi.PlatformGenerationRegistry; + +import java.util.List; +import java.util.Locale; + +final class ProbeGenerationRegistry implements PlatformGenerationRegistry { + static final ProbeGenerationRegistry INSTANCE = new ProbeGenerationRegistry(); + + private ProbeGenerationRegistry() { + } + + @Override + public String runtimeIdentity() { + return "iris-probe"; + } + + @Override + public List legacyCustomBiomeResourceKeys(String packName, String dimensionKey, String customBiomeId) { + return List.of(dimensionKey.toLowerCase(Locale.ROOT) + ":" + customBiomeId.toLowerCase(Locale.ROOT)); + } + + @Override + public String dimensionTypeResourceKey(String packName, String dimensionKey, String dimensionTypeKey) { + return "iris:" + dimensionTypeKey.toLowerCase(Locale.ROOT); + } + + @Override + public Definition definition(String registryKey, String resourceKey) { + return Definition.resourceIdentity(registryKey, resourceKey); + } +} diff --git a/probe/src/main/java/art/arcane/iris/probe/ProductionBiomeHunk.java b/probe/src/main/java/art/arcane/iris/probe/ProductionBiomeHunk.java new file mode 100644 index 000000000..dbd0be877 --- /dev/null +++ b/probe/src/main/java/art/arcane/iris/probe/ProductionBiomeHunk.java @@ -0,0 +1,112 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.probe; + +import art.arcane.volmlib.nativelib.terrain.NativeBiome; +import art.arcane.volmlib.util.hunk.HunkMutationSupport; +import art.arcane.volmlib.util.hunk.storage.StorageHunk; + +import java.util.function.Supplier; + +final class ProductionBiomeHunk extends StorageHunk { + private static final int CHUNK_SIZE = 16; + + private final NativeBiome[] columns = new NativeBiome[CHUNK_SIZE * CHUNK_SIZE]; + private final Supplier fallback; + private NativeBiome[] cells; + private volatile NativeBiome defaultBiome; + + ProductionBiomeHunk(int height, Supplier fallback) { + super(CHUNK_SIZE, height, CHUNK_SIZE); + this.fallback = fallback; + } + + @Override + public void set(int x1, int y1, int z1, int x2, int y2, int z2, NativeBiome biome) { + if (x1 == x2 && z1 == z2 && y1 == 0 && y2 == getHeight() - 1) { + fillColumn(x1, z1, biome); + return; + } + HunkMutationSupport.setRangeInclusive(this, x1, y1, z1, x2, y2, z2, biome); + } + + @Override + public void setRaw(int x, int y, int z, NativeBiome biome) { + NativeBiome[] storage = cells == null ? expand() : cells; + storage[cellIndex(x, y, z)] = biome; + } + + @Override + public NativeBiome getRaw(int x, int y, int z) { + NativeBiome[] storage = cells; + NativeBiome biome = storage == null ? columns[columnIndex(x, z)] : storage[cellIndex(x, y, z)]; + if (biome != null) { + return biome; + } + NativeBiome resolved = defaultBiome; + if (resolved == null) { + resolved = fallback.get(); + defaultBiome = resolved; + } + return resolved; + } + + boolean expanded() { + return cells != null; + } + + private void fillColumn(int x, int z, NativeBiome biome) { + NativeBiome[] storage = cells; + if (storage == null) { + columns[columnIndex(x, z)] = biome; + return; + } + int stride = CHUNK_SIZE * CHUNK_SIZE; + int index = columnIndex(x, z); + for (int y = 0; y < getHeight(); y++) { + storage[index] = biome; + index += stride; + } + } + + private NativeBiome[] expand() { + NativeBiome[] storage = new NativeBiome[CHUNK_SIZE * getHeight() * CHUNK_SIZE]; + int stride = CHUNK_SIZE * CHUNK_SIZE; + for (int column = 0; column < stride; column++) { + NativeBiome biome = columns[column]; + if (biome == null) { + continue; + } + for (int index = column; index < storage.length; index += stride) { + storage[index] = biome; + } + } + cells = storage; + return storage; + } + + private static int columnIndex(int x, int z) { + return ((z & (CHUNK_SIZE - 1)) * CHUNK_SIZE) + (x & (CHUNK_SIZE - 1)); + } + + private int cellIndex(int x, int y, int z) { + int clampedY = Math.max(0, Math.min(getHeight() - 1, y)); + return (clampedY * CHUNK_SIZE * CHUNK_SIZE) + columnIndex(x, z); + } +} diff --git a/probe/src/main/java/art/arcane/iris/probe/ProductionBlockHunk.java b/probe/src/main/java/art/arcane/iris/probe/ProductionBlockHunk.java new file mode 100644 index 000000000..91201e3cd --- /dev/null +++ b/probe/src/main/java/art/arcane/iris/probe/ProductionBlockHunk.java @@ -0,0 +1,86 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.probe; + +import art.arcane.iris.generation.block.BoundBlockState; +import art.arcane.iris.generation.chunk.ColumnExtent; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.nativelib.terrain.NativeBlockVolume; +import art.arcane.volmlib.util.hunk.storage.StorageHunk; + +import java.lang.invoke.MethodHandles; +import java.lang.invoke.VarHandle; +import java.util.Arrays; + +final class ProductionBlockHunk extends StorageHunk implements NativeBlockVolume, ColumnExtent { + private static final BoundBlockState AIR = BoundBlockState.of("AIR"); + private static final VarHandle COLUMN_TOPS = MethodHandles.arrayElementVarHandle(int[].class); + + private final NativeBlockState[] data; + private final int[] columnTops; + private final int zStride; + + ProductionBlockHunk(int height) { + super(16, height, 16); + this.zStride = 16 * height; + this.data = new NativeBlockState[zStride * 16]; + this.columnTops = new int[256]; + Arrays.fill(columnTops, -1); + } + + @Override + public void setRaw(int x, int y, int z, NativeBlockState t) { + if (t == null) { + return; + } + data[index(x, y, z)] = t; + int column = (z << 4) | x; + int top = (int) COLUMN_TOPS.getOpaque(columnTops, column); + while (y > top) { + int witness = (int) COLUMN_TOPS.compareAndExchange(columnTops, column, top, y); + if (witness == top) { + break; + } + top = witness; + } + } + + @Override + public NativeBlockState getRaw(int x, int y, int z) { + if (y < 0 || y >= getHeight()) { + return AIR.get(); + } + NativeBlockState state = data[index(x, y, z)]; + return state != null ? state : AIR.get(); + } + + @Override + public NativeBlockState getStoredRaw(int x, int y, int z) { + return data[index(x, y, z)]; + } + + @Override + public int highestStoredY(int x, int z) { + return (int) COLUMN_TOPS.getOpaque(columnTops, (z << 4) | x); + } + + private int index(int x, int y, int z) { + return (z * zStride) + (y << 4) + x; + } +} diff --git a/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java b/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java index 65c658e28..d7dd3a673 100644 --- a/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java +++ b/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java @@ -26,6 +26,7 @@ import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.LogLevel; import art.arcane.volmlib.nativelib.terrain.NativeBiome; import art.arcane.iris.spi.PlatformBiomeWriter; +import art.arcane.iris.spi.PlatformGenerationRegistry; import art.arcane.volmlib.nativelib.terrain.NativeBlockProperty; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; import art.arcane.volmlib.nativelib.entity.NativeEntityType; @@ -78,7 +79,7 @@ public final class StubPlatform implements IrisPlatform { return StubBlockState.of(key); } - private final StubRegistries registries = new StubRegistries(); + private final StubRegistries registries; private final StubScheduler scheduler = new StubScheduler(); private final StubStructureHooks structureHooks = new StubStructureHooks(); private final StubBiomeWriter biomeWriter = new StubBiomeWriter(); @@ -89,7 +90,12 @@ public final class StubPlatform implements IrisPlatform { } public StubPlatform(File dataFolder) { + this(dataFolder, null); + } + + public StubPlatform(File dataFolder, PlatformGenerationRegistry generationRegistry) { this.dataFolder = dataFolder; + this.registries = new StubRegistries(generationRegistry); } private static final class StubBlockState implements NativeBlockState { @@ -620,6 +626,20 @@ public final class StubPlatform implements IrisPlatform { } private static final class StubRegistries implements PlatformRegistries { + private final PlatformGenerationRegistry generationRegistry; + + private StubRegistries(PlatformGenerationRegistry generationRegistry) { + this.generationRegistry = generationRegistry; + } + + @Override + public PlatformGenerationRegistry generationRegistry() { + if (generationRegistry == null) { + return PlatformRegistries.super.generationRegistry(); + } + return generationRegistry; + } + @Override public NativeBlockState decodeBlockState(String key) { return blockOrNull(key); diff --git a/probe/src/test/java/art/arcane/iris/probe/GenerationProbeTest.java b/probe/src/test/java/art/arcane/iris/probe/GenerationProbeTest.java index 716aca6b8..1bbc84ab2 100644 --- a/probe/src/test/java/art/arcane/iris/probe/GenerationProbeTest.java +++ b/probe/src/test/java/art/arcane/iris/probe/GenerationProbeTest.java @@ -1,15 +1,20 @@ package art.arcane.iris.probe; import art.arcane.iris.generation.hydrology.HydrologyTileCache; +import art.arcane.iris.generation.hydrology.HydrologyTileKey; +import art.arcane.iris.generation.runtime.EngineMetrics; import org.junit.Test; import java.io.File; import java.nio.file.Path; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Set; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertThrows; @@ -128,7 +133,96 @@ public final class GenerationProbeTest { GenerationProbe.ProbeOptions defaults = GenerationProbe.ProbeOptions.fromSystemProperties(); assertEquals(1, defaults.threads()); assertNull(defaults.dumpFile()); - assertThrows(IllegalArgumentException.class, () -> new GenerationProbe.ProbeOptions(0, null, false)); + assertThrows(IllegalArgumentException.class, + () -> new GenerationProbe.ProbeOptions(0, null, false, false, false, false, false)); + assertFalse(defaults.hydrologyPreplan()); + assertFalse(defaults.historyRouter()); + assertFalse(defaults.productionBuffers()); + assertFalse(defaults.counters()); + } + + @Test + public void readsHarnessFidelityOptionsFromSystemProperties() { + System.setProperty("iris.probe.hydrologyPreplan", "true"); + System.setProperty("iris.probe.historyRouter", "true"); + System.setProperty("iris.probe.productionBuffers", "true"); + System.setProperty("iris.generation.counters", "true"); + try { + GenerationProbe.ProbeOptions options = GenerationProbe.ProbeOptions.fromSystemProperties(); + assertTrue(options.hydrologyPreplan()); + assertTrue(options.historyRouter()); + assertTrue(options.productionBuffers()); + assertTrue(options.counters()); + assertFalse(options.parallel()); + } finally { + System.clearProperty("iris.probe.hydrologyPreplan"); + System.clearProperty("iris.probe.historyRouter"); + System.clearProperty("iris.probe.productionBuffers"); + System.clearProperty("iris.generation.counters"); + } + } + + @Test + public void stageLineReportsOnlyTheMeasuredWindow() { + Map before = new LinkedHashMap<>(); + before.put("total", new EngineMetrics.StageTotal(100D, 10L)); + before.put("context.prefill.height", new EngineMetrics.StageTotal(0D, 0L)); + Map after = new LinkedHashMap<>(); + after.put("total", new EngineMetrics.StageTotal(350.25D, 20L)); + after.put("context.prefill.height", new EngineMetrics.StageTotal(12.5D, 10L)); + + String line = GenerationProbe.stagesLine(new GenerationProbe.WindowMetrics(before, Map.of()), + new GenerationProbe.WindowMetrics(after, Map.of()), 10, 1); + + assertEquals("IRIS_GENPROBE_STAGES version=1 chunks=10 threads=1 total=250.250 context.prefill.height=12.500", line); + } + + @Test + public void counterLineReportsWindowTotalsAndPerChunkRates() { + Map before = new LinkedHashMap<>(); + before.put("generator_evaluations", 1_000L); + before.put("corner_starcasts", 0L); + Map after = new LinkedHashMap<>(); + after.put("generator_evaluations", 5_000L); + after.put("corner_starcasts", 330L); + + String line = GenerationProbe.countersLine(new GenerationProbe.WindowMetrics(Map.of(), before), + new GenerationProbe.WindowMetrics(Map.of(), after), 2); + + assertEquals("IRIS_GENPROBE_COUNTERS version=1 chunks=2 generator_evaluations=4000" + + " generator_evaluations_per_chunk=2000.0 corner_starcasts=330 corner_starcasts_per_chunk=165.0", line); + } + + @Test + public void hydrologyPreplanCoversEveryTileAScheduledChunkComposesFromPlusTheNeighbourRing() { + GenerationProbe.ProbeConfiguration configuration = new GenerationProbe.ProbeConfiguration( + new File("/tmp/pack"), "overworld", 256, 1024, 2048, 2048, false, false, 1337L); + int tileSize = 512; + int publicationRadius = 96; + int reachChunks = 13; + List keys = GenerationProbe.preplanTiles(configuration, tileSize, publicationRadius, reachChunks); + Set planned = new HashSet<>(keys); + + assertEquals(keys.size(), planned.size()); + for (GenerationProbe.ChunkCoordinate chunk : GenerationProbe.scheduleCoordinates(1280, 2048, 2048)) { + for (int offsetX = -reachChunks; offsetX <= reachChunks; offsetX++) { + for (int offsetZ = -reachChunks; offsetZ <= reachChunks; offsetZ++) { + long minimumX = (long) (chunk.x() + offsetX) * 16L; + long minimumZ = (long) (chunk.z() + offsetZ) * 16L; + int firstX = Math.floorDiv(Math.toIntExact(minimumX - publicationRadius), tileSize) - 1; + int lastX = Math.floorDiv(Math.toIntExact(minimumX + 15L + publicationRadius), tileSize) + 1; + int firstZ = Math.floorDiv(Math.toIntExact(minimumZ - publicationRadius), tileSize) - 1; + int lastZ = Math.floorDiv(Math.toIntExact(minimumZ + 15L + publicationRadius), tileSize) + 1; + for (int tileX = firstX; tileX <= lastX; tileX++) { + for (int tileZ = firstZ; tileZ <= lastZ; tileZ++) { + assertTrue(planned.contains(new HydrologyTileKey(tileX, tileZ))); + } + } + } + } + } + HydrologyTileKey first = keys.getFirst(); + assertEquals(new HydrologyTileKey(Math.floorDiv(2048 * 16 + 8, tileSize), Math.floorDiv(2048 * 16 + 8, tileSize)), first); } @Test diff --git a/probe/src/test/java/art/arcane/iris/probe/ProductionHunkTest.java b/probe/src/test/java/art/arcane/iris/probe/ProductionHunkTest.java new file mode 100644 index 000000000..1a21fb510 --- /dev/null +++ b/probe/src/test/java/art/arcane/iris/probe/ProductionHunkTest.java @@ -0,0 +1,126 @@ +package art.arcane.iris.probe; + +import art.arcane.volmlib.nativelib.terrain.NativeBiome; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.hunk.Hunk; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public final class ProductionHunkTest { + private static final int HEIGHT = 24; + + @Test + public void blockHunkIgnoresNullWritesAndTracksTheHighestStoredCellPerColumn() { + ProductionBlockHunk blocks = new ProductionBlockHunk(HEIGHT); + NativeBlockState stone = StubPlatform.blockStateForTest("minecraft:stone"); + NativeBlockState dirt = StubPlatform.blockStateForTest("minecraft:dirt"); + + blocks.setRaw(3, 5, 7, stone); + blocks.setRaw(3, 9, 7, dirt); + blocks.setRaw(3, 2, 7, stone); + blocks.setRaw(3, 9, 7, null); + + assertSame(dirt, blocks.getStoredRaw(3, 9, 7)); + assertSame(stone, blocks.getStoredRaw(3, 5, 7)); + assertNull(blocks.getStoredRaw(3, 6, 7)); + assertEquals(9, blocks.highestStoredY(3, 7)); + assertEquals(-1, blocks.highestStoredY(7, 3)); + } + + @Test + public void dumpCaptureOfTheBlockHunkMatchesAnArrayHunkHoldingTheSameWrites() { + ProductionBlockHunk production = new ProductionBlockHunk(HEIGHT); + Hunk array = Hunk.newArrayHunk(16, HEIGHT, 16); + Hunk biomes = Hunk.newArrayHunk(16, HEIGHT, 16); + NativeBlockState stone = StubPlatform.blockStateForTest("minecraft:stone"); + NativeBlockState water = StubPlatform.blockStateForTest("minecraft:water"); + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + int top = (x * 7 + z * 3) % HEIGHT; + for (int y = 0; y <= top; y++) { + NativeBlockState state = y == top ? water : stone; + production.setRaw(x, y, z, state); + array.setRaw(x, y, z, state); + } + } + } + + ChunkDump.ChunkSnapshot fromProduction = ChunkDump.capture(4, -9, production, biomes, HEIGHT); + ChunkDump.ChunkSnapshot fromArray = ChunkDump.capture(4, -9, array, biomes, HEIGHT); + + assertArrayEquals(fromArray.blockPalette(), fromProduction.blockPalette()); + assertArrayEquals(fromArray.blocks(), fromProduction.blocks()); + assertTrue(List.of(fromProduction.blockPalette()).contains(ChunkDump.NULL_BLOCK_KEY)); + } + + @Test + public void biomeHunkKeepsWholeColumnWritesPerColumnAndReadsLikeAnArrayHunk() { + ProductionBiomeHunk production = new ProductionBiomeHunk(HEIGHT, () -> biome("minecraft:plains")); + Hunk array = Hunk.newArrayHunk(16, HEIGHT, 16); + NativeBiome forest = biome("minecraft:forest"); + NativeBiome desert = biome("minecraft:desert"); + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + NativeBiome column = ((x + z) & 1) == 0 ? forest : desert; + production.set(x, 0, z, x, HEIGHT - 1, z, column); + array.set(x, 0, z, x, HEIGHT - 1, z, column); + } + } + + assertFalse(production.expanded()); + assertSameCells(array, production); + + production.setRaw(2, 4, 9, desert); + array.setRaw(2, 4, 9, desert); + production.set(5, 0, 5, 5, HEIGHT - 1, 5, desert); + array.set(5, 0, 5, 5, HEIGHT - 1, 5, desert); + + assertTrue(production.expanded()); + assertSameCells(array, production); + } + + @Test + public void biomeHunkReadsTheFallbackOnlyForUnwrittenCells() { + NativeBiome plains = biome("minecraft:plains"); + ProductionBiomeHunk production = new ProductionBiomeHunk(HEIGHT, () -> plains); + NativeBiome forest = biome("minecraft:forest"); + production.set(1, 0, 1, 1, HEIGHT - 1, 1, forest); + + assertSame(forest, production.getRaw(1, HEIGHT - 1, 1)); + assertSame(plains, production.getRaw(0, 0, 0)); + } + + private static void assertSameCells(Hunk expected, Hunk actual) { + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + for (int y = 0; y < HEIGHT; y++) { + assertSame(expected.get(x, y, z), actual.get(x, y, z)); + } + } + } + } + + private static NativeBiome biome(String key) { + return new TestBiome(key); + } + + private record TestBiome(String key) implements NativeBiome { + @Override + public String namespace() { + return key.substring(0, key.indexOf(':')); + } + + @Override + public Object nativeHandle() { + return key; + } + } +}