Make world generation fully deterministic; add golden-hash verification harness

Generation now produces bit-identical output across repeated runs, server
restarts, and parallel generation (golden-hash verified, 289/289 chunks).

Engine fixes:
- MatterGenerator: components flagged complete before generateLayer finished
  (raiseFlagUnchecked), letting concurrent neighbors consume half-written
  mantle data; switched to completion-locked raiseFlagSuspend
- MantleObjectComponent: procedural objects (trees/ruins/formations/coral/
  fungi/crystals) were absent from computeRadius, so canopies crossed chunk
  borders without ordering guarantees; variant footprints now counted
- MantleCarvingComponent: carve profile ordering depended on IdentityHashMap
  iteration and hashCode sort ties; replaced with first-seen column-scan
  order and creation-sequence tiebreaks
- IrisObject/IrisPostModifier: shared BlockData instances mutated in place
  (vine facing x3, auto-waterlog x2, leaf persistence); clone before mutate
- IrisObject: slope-following placement mutated the shared pack-defined
  rotation config; now builds a local rotation on a copied placement
- IrisObjectPlacement: surface-warp CNG seeded from first-caller chunk RNG;
  now seeded from the engine seed manager
- GenerationCacheWarmer: engine init pre-bakes placement/decorator/ore/
  procedural caches in sorted order, removing lazy first-touch ordering
- IrisProceduralPlacement: expose getVariantObjects for radius computation

Verification harness:
- /iris dev goldenhash: buffered chunk generation (no world writes), SHA-256
  per-chunk block+biome digests, golden capture/verify with per-chunk
  mismatch reporting, auto-diagnosis (repeat-gen + mantle-reset comparison,
  block-level diffs), deep per-chunk blockstate dumps, threads/deep params,
  full mantle reset (saveAll + tectonic file wipe) for reproducible runs
- IrisEngineSVC: mantle trim/unload suspended while a goldenhash scan runs
- MantleCarvingComponentTop2BlendTest: sequence-tiebreak regression test
This commit is contained in:
Brian Neumann-Fopiano
2026-06-11 12:40:21 -04:00
parent 58d24ca55e
commit 6a68884ce9
14 changed files with 758 additions and 46 deletions
@@ -26,6 +26,7 @@ import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.core.nms.datapack.DataVersion;
import art.arcane.iris.core.runtime.ChunkClearer;
import art.arcane.iris.core.runtime.GoldenHashScanner;
import art.arcane.iris.core.runtime.InPlaceChunkRegenerator;
import art.arcane.iris.core.service.IrisEngineSVC;
import art.arcane.iris.core.service.StudioSVC;
@@ -361,4 +362,49 @@ public class CommandDeveloper implements DirectorExecutor {
new InPlaceChunkRegenerator(world, engine, sender(), centerX, centerZ, radius).start();
}
@Director(name = "goldenhash", aliases = {"gold"}, description = "Generate chunks into buffers (no world writes) and hash blocks+biomes; captures a golden file or verifies against an existing one. Resets mantle in the scanned area - use on disposable test worlds.", origin = DirectorOrigin.BOTH)
public void goldenhash(
@Param(description = "The world to scan", contextual = true)
World world,
@Param(name = "radius", description = "Radius in chunks around the center", defaultValue = "8")
int radius,
@Param(name = "center-x", description = "Center chunk X", defaultValue = "0")
int centerX,
@Param(name = "center-z", description = "Center chunk Z", defaultValue = "0")
int centerZ,
@Param(name = "reset-mantle", description = "Delete mantle data in the scan area first for full regeneration from scratch", defaultValue = "true")
boolean resetMantle,
@Param(name = "threads", description = "Concurrent chunk generations; 1 = strictly serial for order-dependence testing", defaultValue = "8")
int threads,
@Param(name = "deep", description = "Also dump full per-chunk non-air blockstates for offline diffing", defaultValue = "false")
boolean deep
) {
if (radius < 0) {
sender().sendMessage(C.RED + "Radius must be 0 or greater.");
return;
}
if (world == null || !IrisToolbelt.isIrisWorld(world)) {
sender().sendMessage(C.RED + "Target must be an Iris world.");
return;
}
PlatformChunkGenerator access = IrisToolbelt.access(world);
if (access == null || access.getEngine() == null) {
sender().sendMessage(C.RED + "The engine access for this world is null.");
return;
}
int chunks = (radius * 2 + 1) * (radius * 2 + 1);
sender().sendMessage(C.GREEN + "GoldenHash started: " + C.GOLD + chunks + C.GREEN
+ " chunk(s) around " + C.GOLD + centerX + "," + centerZ + C.GREEN
+ " in buffers (world untouched).");
Iris.info("goldenhash start: world=" + world.getName()
+ " center=" + centerX + "," + centerZ
+ " radius=" + radius
+ " chunks=" + chunks);
new GoldenHashScanner(world, access.getEngine(), sender(), centerX, centerZ, radius, resetMantle, threads, deep).start();
}
}
@@ -0,0 +1,443 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.runtime;
import art.arcane.iris.Iris;
import art.arcane.iris.engine.data.chunk.TerrainChunk;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.mantle.EngineMantle;
import art.arcane.iris.util.common.format.C;
import art.arcane.iris.util.common.parallel.MultiBurst;
import art.arcane.iris.util.common.plugin.VolmitSender;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
import org.bukkit.Bukkit;
import org.bukkit.World;
import org.bukkit.block.Biome;
import org.bukkit.block.data.BlockData;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Semaphore;
import java.util.concurrent.atomic.AtomicInteger;
public final class GoldenHashScanner {
private static final AtomicInteger ACTIVE_SCANS = new AtomicInteger(0);
private static final String FORMAT = "iris-goldenhash v1";
public static boolean isScanActive() {
return ACTIVE_SCANS.get() > 0;
}
private static final int BIOME_STEP = 4;
private static final int MAX_REPORTED_MISMATCHES = 10;
private static final byte[] NULL_BIOME = "null\n".getBytes(StandardCharsets.UTF_8);
private final World world;
private final Engine engine;
private final VolmitSender sender;
private final int centerChunkX;
private final int centerChunkZ;
private final int radius;
private final boolean resetMantle;
private final int threads;
private final boolean deep;
private final File goldenFile;
private final ChunkJobReporter reporter;
public GoldenHashScanner(World world, Engine engine, VolmitSender sender, int centerChunkX, int centerChunkZ, int radius, boolean resetMantle, int threads, boolean deep) {
this.world = world;
this.engine = engine;
this.sender = sender;
this.centerChunkX = centerChunkX;
this.centerChunkZ = centerChunkZ;
this.radius = Math.max(0, radius);
this.resetMantle = resetMantle;
this.threads = Math.max(1, threads);
this.deep = deep;
this.goldenFile = Iris.instance.getDataFile("golden", engine.getDimension().getLoadKey()
+ "-s" + world.getSeed()
+ "-c" + centerChunkX + "x" + centerChunkZ
+ "-r" + this.radius + ".hashes");
this.reporter = new ChunkJobReporter(sender, "GoldenHash", world);
}
public void start() {
reporter.start();
Thread thread = new Thread(this::run, "Iris GoldenHash");
thread.setDaemon(true);
thread.start();
}
private void run() {
boolean error = false;
ACTIVE_SCANS.incrementAndGet();
try {
if (resetMantle) {
reporter.setStage("Resetting mantle");
resetMantleFull();
}
List<int[]> targets = ChunkJobReporter.orderedTargets(centerChunkX, centerChunkZ, radius);
reporter.setTotal(targets.size());
reporter.setStage("Generating");
Map<Long, String> lines = scan(targets);
if (lines.size() != targets.size()) {
sender.sendMessage(C.RED + "GoldenHash aborted: " + (targets.size() - lines.size()) + " chunk(s) failed to generate. No golden file written.");
error = true;
return;
}
reporter.setStage("Comparing");
if (goldenFile.exists()) {
error = !verify(lines);
} else {
capture(lines);
}
} catch (Throwable e) {
Iris.reportError(e);
error = true;
} finally {
ACTIVE_SCANS.decrementAndGet();
reporter.finish(error);
}
}
private void resetMantleFull() {
Mantle mantle = engine.getMantle().getMantle();
mantle.saveAll();
File folder = mantle.getDataFolder();
File[] files = folder.listFiles();
if (files != null) {
for (File file : files) {
if (file.isFile()) {
file.delete();
}
}
}
}
private Map<Long, String> scan(List<int[]> targets) throws InterruptedException {
Map<Long, String> lines = new ConcurrentHashMap<>();
Semaphore inFlight = new Semaphore(threads);
CountDownLatch done = new CountDownLatch(targets.size());
for (int[] target : targets) {
int chunkX = target[0];
int chunkZ = target[1];
inFlight.acquire();
MultiBurst.burst.lazy(() -> {
boolean ok = false;
try {
TerrainChunk buffer = TerrainChunk.create(world);
engine.generate(chunkX << 4, chunkZ << 4, buffer, false);
lines.put(chunkKey(chunkX, chunkZ), hashChunk(chunkX, chunkZ, buffer));
if (deep) {
writeDeepDump(chunkX, chunkZ, buffer);
}
ok = true;
} catch (Throwable e) {
Iris.reportError(e);
} finally {
reporter.countApplied(ok);
inFlight.release();
done.countDown();
}
});
}
done.await();
return lines;
}
private String hashChunk(int chunkX, int chunkZ, TerrainChunk buffer) {
MessageDigest blockDigest = sha256();
MessageDigest biomeDigest = sha256();
int minY = buffer.getMinHeight();
int maxY = buffer.getMaxHeight();
IdentityHashMap<BlockData, byte[]> blockCache = new IdentityHashMap<>();
Map<Biome, byte[]> biomeCache = new HashMap<>();
for (int x = 0; x < 16; x++) {
for (int z = 0; z < 16; z++) {
for (int y = minY; y < maxY; y++) {
BlockData data = buffer.getBlockData(x, y, z);
byte[] bytes = blockCache.computeIfAbsent(data, d -> (d.getAsString() + "\n").getBytes(StandardCharsets.UTF_8));
blockDigest.update(bytes);
}
}
}
for (int x = 0; x < 16; x += BIOME_STEP) {
for (int z = 0; z < 16; z += BIOME_STEP) {
for (int y = minY; y < maxY; y += BIOME_STEP) {
Biome biome = buffer.getBiome(x, y, z);
byte[] bytes = biome == null
? NULL_BIOME
: biomeCache.computeIfAbsent(biome, b -> (biomeKey(b) + "\n").getBytes(StandardCharsets.UTF_8));
biomeDigest.update(bytes);
}
}
}
return chunkX + " " + chunkZ + " "
+ HexFormat.of().formatHex(blockDigest.digest()) + " "
+ HexFormat.of().formatHex(biomeDigest.digest());
}
private void capture(Map<Long, String> lines) throws IOException {
List<String> body = orderedBody(lines);
String combined = combinedHash(body);
List<String> out = new ArrayList<>();
out.add("#" + FORMAT);
out.add("#world=" + world.getName());
out.add("#dim=" + engine.getDimension().getLoadKey());
out.add("#seed=" + world.getSeed());
out.add("#mc=" + Bukkit.getBukkitVersion());
out.add("#minY=" + world.getMinHeight() + " maxY=" + world.getMaxHeight());
out.add("#center=" + centerChunkX + "," + centerChunkZ);
out.add("#radius=" + radius);
out.addAll(body);
out.add("#combined=" + combined);
Files.write(goldenFile.toPath(), out, StandardCharsets.UTF_8);
sender.sendMessage(C.GREEN + "Golden captured: " + C.GOLD + body.size() + " chunks" + C.GREEN + " combined=" + C.GOLD + shortHash(combined));
sender.sendMessage(C.GRAY + goldenFile.getAbsolutePath());
Iris.info("goldenhash captured: " + goldenFile.getAbsolutePath() + " combined=" + combined);
}
private boolean verify(Map<Long, String> lines) throws IOException {
List<String> existing = Files.readAllLines(goldenFile.toPath(), StandardCharsets.UTF_8);
Map<String, String> meta = new HashMap<>();
Map<String, String> goldenChunks = new HashMap<>();
for (String line : existing) {
if (line.startsWith("#")) {
int eq = line.indexOf('=');
if (eq > 0) {
meta.put(line.substring(1, eq), line.substring(eq + 1));
}
} else if (!line.isBlank()) {
int second = line.indexOf(' ', line.indexOf(' ') + 1);
goldenChunks.put(line.substring(0, second), line);
}
}
String expectedSeed = String.valueOf(world.getSeed());
String expectedDim = engine.getDimension().getLoadKey();
if (!expectedSeed.equals(meta.get("seed")) || !expectedDim.equals(meta.get("dim"))) {
sender.sendMessage(C.RED + "Golden file is for dim=" + meta.get("dim") + " seed=" + meta.get("seed")
+ " but this world is dim=" + expectedDim + " seed=" + expectedSeed + ". Aborting.");
return false;
}
String mc = Bukkit.getBukkitVersion();
if (!mc.equals(meta.get("mc"))) {
sender.sendMessage(C.YELLOW + "Golden was captured on mc=" + meta.get("mc") + ", running mc=" + mc + ". Diffs may be version-induced.");
}
List<String> body = orderedBody(lines);
List<String> mismatches = new ArrayList<>();
for (String line : body) {
int second = line.indexOf(' ', line.indexOf(' ') + 1);
String key = line.substring(0, second);
String golden = goldenChunks.get(key);
if (!line.equals(golden)) {
mismatches.add(key + (golden == null ? " (missing in golden)" : ""));
}
}
String combined = combinedHash(body);
if (mismatches.isEmpty()) {
sender.sendMessage(C.GREEN + "GOLDEN MATCH: " + C.GOLD + body.size() + "/" + goldenChunks.size() + C.GREEN
+ " chunks, combined=" + C.GOLD + shortHash(combined));
Iris.info("goldenhash MATCH: " + goldenFile.getName() + " combined=" + combined);
return true;
}
sender.sendMessage(C.RED + "GOLDEN MISMATCH: " + mismatches.size() + "/" + body.size() + " chunks differ.");
for (int i = 0; i < Math.min(MAX_REPORTED_MISMATCHES, mismatches.size()); i++) {
sender.sendMessage(C.RED + " chunk " + mismatches.get(i));
}
if (mismatches.size() > MAX_REPORTED_MISMATCHES) {
sender.sendMessage(C.RED + " ... and " + (mismatches.size() - MAX_REPORTED_MISMATCHES) + " more");
}
File current = new File(goldenFile.getParentFile(), goldenFile.getName() + ".new");
List<String> out = new ArrayList<>(body);
out.add("#combined=" + combined);
Files.write(current.toPath(), out, StandardCharsets.UTF_8);
sender.sendMessage(C.YELLOW + "Current hashes written to " + current.getName());
Iris.info("goldenhash MISMATCH: " + mismatches.size() + "/" + body.size() + " -> " + current.getAbsolutePath());
reporter.setStage("Diagnosing");
diagnose(mismatches.getFirst());
return false;
}
private void diagnose(String mismatchKey) {
try {
String[] parts = mismatchKey.trim().split(" ");
int chunkX = Integer.parseInt(parts[0]);
int chunkZ = Integer.parseInt(parts[1]);
TerrainChunk first = TerrainChunk.create(world);
engine.generate(chunkX << 4, chunkZ << 4, first, false);
TerrainChunk second = TerrainChunk.create(world);
engine.generate(chunkX << 4, chunkZ << 4, second, false);
int minY = first.getMinHeight();
int maxY = first.getMaxHeight();
List<String> diffs = new ArrayList<>();
for (int x = 0; x < 16 && diffs.size() < 50; x++) {
for (int z = 0; z < 16 && diffs.size() < 50; z++) {
for (int y = minY; y < maxY && diffs.size() < 50; y++) {
String a = first.getBlockData(x, y, z).getAsString();
String b = second.getBlockData(x, y, z).getAsString();
if (!a.equals(b)) {
diffs.add(x + " " + y + " " + z + " | " + a + " | " + b);
}
}
}
}
EngineMantle engineMantle = engine.getMantle();
int margin = Math.max(engineMantle.getRadius(), engineMantle.getRealRadius()) + 1;
for (int dx = -margin; dx <= margin; dx++) {
for (int dz = -margin; dz <= margin; dz++) {
engineMantle.getMantle().deleteChunk(chunkX + dx, chunkZ + dz);
}
}
TerrainChunk reset = TerrainChunk.create(world);
engine.generate(chunkX << 4, chunkZ << 4, reset, false);
List<String> mantleDiffs = new ArrayList<>();
for (int x = 0; x < 16 && mantleDiffs.size() < 80; x++) {
for (int z = 0; z < 16 && mantleDiffs.size() < 80; z++) {
for (int y = minY; y < maxY && mantleDiffs.size() < 80; y++) {
String a = first.getBlockData(x, y, z).getAsString();
String b = reset.getBlockData(x, y, z).getAsString();
if (!a.equals(b)) {
mantleDiffs.add(x + " " + y + " " + z + " | scan: " + a + " | mantle-reset: " + b);
}
}
}
}
List<String> report = new ArrayList<>();
report.add("#goldenhash diagnosis chunk=" + chunkX + "," + chunkZ);
report.add("#repeat-generation: " + (diffs.isEmpty() ? "STABLE (nondeterminism is order/state-dependent, not per-call)" : "UNSTABLE (" + diffs.size() + "+ diffs between two back-to-back generations)"));
report.addAll(diffs);
report.add("#mantle-reset regeneration: " + (mantleDiffs.isEmpty() ? "STABLE (mantle rebuild reproduces scan output)" : "DIVERGED (" + mantleDiffs.size() + "+ diffs - mantle build is state/order dependent)"));
report.addAll(mantleDiffs);
report.add("#full non-air dump of generation 1 follows (x y z state)");
for (int x = 0; x < 16; x++) {
for (int z = 0; z < 16; z++) {
for (int y = minY; y < maxY; y++) {
String state = first.getBlockData(x, y, z).getAsString();
if (!state.equals("minecraft:air") && !state.equals("minecraft:cave_air") && !state.equals("minecraft:void_air")) {
report.add(x + " " + y + " " + z + " " + state);
}
}
}
}
File diag = new File(goldenFile.getParentFile(), goldenFile.getName() + ".diag-c" + chunkX + "x" + chunkZ + ".txt");
Files.write(diag.toPath(), report, StandardCharsets.UTF_8);
sender.sendMessage((diffs.isEmpty() ? C.YELLOW + "Repeat-gen STABLE" : C.RED + "Repeat-gen UNSTABLE (" + diffs.size() + "+ block diffs)")
+ C.GRAY + ", " + (mantleDiffs.isEmpty() ? C.YELLOW + "mantle-reset STABLE" : C.RED + "mantle-reset DIVERGED (" + mantleDiffs.size() + "+ diffs)")
+ C.GRAY + " -> " + diag.getName());
Iris.info("goldenhash diag: chunk=" + chunkX + "," + chunkZ + " repeatStable=" + diffs.isEmpty() + " -> " + diag.getAbsolutePath());
} catch (Throwable e) {
Iris.reportError(e);
sender.sendMessage(C.RED + "Diagnosis failed: " + e.getMessage());
}
}
private List<String> orderedBody(Map<Long, String> lines) {
Map<Long, String> sorted = new TreeMap<>(lines);
return new ArrayList<>(sorted.values());
}
private String combinedHash(List<String> body) {
MessageDigest digest = sha256();
for (String line : body) {
digest.update((line + "\n").getBytes(StandardCharsets.UTF_8));
}
return HexFormat.of().formatHex(digest.digest());
}
private void writeDeepDump(int chunkX, int chunkZ, TerrainChunk buffer) throws IOException {
File dir = new File(goldenFile.getParentFile(), goldenFile.getName() + (goldenFile.exists() ? ".deep-verify" : ".deep"));
dir.mkdirs();
int minY = buffer.getMinHeight();
int maxY = buffer.getMaxHeight();
List<String> out = new ArrayList<>();
for (int x = 0; x < 16; x++) {
for (int z = 0; z < 16; z++) {
for (int y = minY; y < maxY; y++) {
String state = buffer.getBlockData(x, y, z).getAsString();
if (!state.equals("minecraft:air") && !state.equals("minecraft:cave_air") && !state.equals("minecraft:void_air")) {
out.add(x + " " + y + " " + z + " " + state);
}
}
}
}
Files.write(new File(dir, chunkX + "_" + chunkZ + ".txt").toPath(), out, StandardCharsets.UTF_8);
}
private static String biomeKey(Biome biome) {
for (String method : new String[]{"getKeyOrNull", "getKeyOrThrow", "getKey"}) {
try {
Object key = Biome.class.getMethod(method).invoke(biome);
if (key != null) {
return key.toString();
}
} catch (Throwable ignored) {
}
}
return biome.toString();
}
private static String shortHash(String hex) {
return hex.substring(0, 12);
}
private static long chunkKey(int x, int z) {
return (((long) x) << 32) ^ (z & 0xFFFFFFFFL);
}
private static MessageDigest sha256() {
try {
return MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
}
@@ -4,6 +4,7 @@ import com.google.common.util.concurrent.AtomicDouble;
import art.arcane.iris.Iris;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.loader.ResourceLoader;
import art.arcane.iris.core.runtime.GoldenHashScanner;
import art.arcane.iris.core.tools.IrisToolbelt;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
@@ -310,6 +311,9 @@ public class IrisEngineSVC implements IrisService {
if (shouldSkipForMaintenance(engineWorld)) {
return;
}
if (GoldenHashScanner.isScanActive()) {
return;
}
try {
engine.getMantle().trim(activeIdleDuration(engine), activeTectonicLimit(engine));
@@ -338,6 +342,10 @@ public class IrisEngineSVC implements IrisService {
return;
}
if (GoldenHashScanner.isScanActive()) {
return;
}
try {
long unloadStart = System.currentTimeMillis();
int count = engine.getMantle().unloadTectonicPlate(IrisSettings.get().getPerformance().getEngineSVC().forceMulticoreWrite ? 0 : activeTectonicLimit(engine));
@@ -0,0 +1,126 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.engine;
import art.arcane.iris.Iris;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisDecorator;
import art.arcane.iris.engine.object.IrisObjectPlacement;
import art.arcane.iris.engine.object.IrisOreGenerator;
import art.arcane.iris.engine.object.IrisProceduralObjects;
import art.arcane.iris.engine.object.IrisProceduralPlacement;
import art.arcane.iris.engine.object.IrisRegion;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.math.M;
import art.arcane.volmlib.util.math.RNG;
import java.util.Comparator;
public final class GenerationCacheWarmer {
private GenerationCacheWarmer() {
}
public static void warm(Engine engine) {
long start = M.ms();
IrisData data = engine.getData();
RNG root = new RNG(engine.getSeedManager().getComponent() + 7777L);
int[] counter = {0};
try {
KList<IrisBiome> biomes = engine.getAllBiomes();
biomes.sort(Comparator.comparing(IrisBiome::getLoadKey));
for (IrisBiome biome : biomes) {
warmPlacements(biome.getObjects(), root, counter, data);
warmDecorators(biome.getDecorators(), root, counter, data);
warmOres(biome.getOres(), root, counter, data);
warmProcedural(biome.getProceduralObjects(), root, counter, data);
}
KList<IrisRegion> regions = engine.getDimension().getAllRegions(engine);
regions.sort(Comparator.comparing(IrisRegion::getLoadKey));
for (IrisRegion region : regions) {
warmPlacements(region.getObjects(), root, counter, data);
warmOres(region.getOres(), root, counter, data);
warmProcedural(region.getProceduralObjects(), root, counter, data);
}
warmOres(engine.getDimension().getOres(), root, counter, data);
} catch (Throwable e) {
Iris.reportError(e);
Iris.warn("Generation cache warm pass failed: " + e.getMessage());
}
Iris.debug("[IrisEngine timing] cache warm " + counter[0] + " configs=" + (M.ms() - start) + "ms");
}
private static void warmPlacements(KList<IrisObjectPlacement> placements, RNG root, int[] counter, IrisData data) {
if (placements == null) {
return;
}
for (IrisObjectPlacement placement : placements) {
if (placement == null) {
continue;
}
RNG rng = root.nextParallelRNG(counter[0]++);
placement.getSurfaceWarp(rng, data);
placement.getDensity(rng, 0, 0, data);
}
}
private static void warmDecorators(KList<IrisDecorator> decorators, RNG root, int[] counter, IrisData data) {
if (decorators == null) {
return;
}
for (IrisDecorator decorator : decorators) {
if (decorator == null) {
continue;
}
RNG rng = root.nextParallelRNG(counter[0]++);
decorator.getHeightGenerator(rng, data);
decorator.getGenerator(rng, data);
decorator.getVarianceGenerator(rng, data);
}
}
private static void warmOres(KList<IrisOreGenerator> ores, RNG root, int[] counter, IrisData data) {
if (ores == null) {
return;
}
for (IrisOreGenerator ore : ores) {
if (ore == null) {
continue;
}
ore.warm(root.nextParallelRNG(counter[0]++), data);
}
}
private static void warmProcedural(IrisProceduralObjects procedural, RNG root, int[] counter, IrisData data) {
if (procedural == null) {
return;
}
for (IrisProceduralPlacement placement : procedural.getAllPlacements()) {
if (placement == null) {
continue;
}
placement.getVariantObject(data, root.nextParallelRNG(counter[0]++));
}
}
}
@@ -161,6 +161,9 @@ public class IrisEngine implements Engine {
_t0 = M.ms();
setupEngine();
Iris.debug("[IrisEngine timing] setupEngine total=" + (M.ms() - _t0) + "ms");
_t0 = M.ms();
GenerationCacheWarmer.warm(this);
Iris.debug("[IrisEngine timing] cache warm total=" + (M.ms() - _t0) + "ms");
Iris.debug("Engine Initialized " + getCacheID());
}
@@ -92,7 +92,7 @@ public interface MatterGenerator {
int finalPassZ = passZ;
MantleChunk<Matter> finalChunk = chunk;
MantleComponent finalComponent = component;
Runnable task = () -> finalChunk.raiseFlagUnchecked(finalComponent.getFlag(),
Runnable task = () -> finalChunk.raiseFlagSuspend(finalComponent.getFlag(),
() -> finalComponent.generateLayer(writer, finalPassX, finalPassZ, context));
if (multicore) {
@@ -34,6 +34,8 @@ import art.arcane.iris.engine.object.IrisRange;
import art.arcane.iris.util.project.context.ChunkContext;
import art.arcane.iris.util.project.stream.ProceduralStream;
import art.arcane.iris.util.project.stream.utility.ChunkFillableDoubleStream2D;
import art.arcane.iris.util.simd.SimdKernels;
import art.arcane.iris.util.simd.SimdSupport;
import art.arcane.volmlib.util.documentation.ChunkCoordinates;
import art.arcane.volmlib.util.mantle.flag.ReservedFlag;
import art.arcane.volmlib.util.math.PowerOfTwoCoordinates;
@@ -162,9 +164,11 @@ public class MantleCarvingComponent extends IrisMantleComponent {
IrisCaveProfile[] profileField = blendScratch.profileField;
Map<IrisCaveProfile, double[]> columnProfileWeights = blendScratch.columnProfileWeights;
IdentityHashMap<IrisCaveProfile, Boolean> activeProfiles = blendScratch.activeProfiles;
List<IrisCaveProfile> profileOrder = blendScratch.profileOrder;
IrisCaveProfile[] kernelProfiles = blendScratch.kernelProfiles;
double[] kernelProfileWeights = blendScratch.kernelProfileWeights;
activeProfiles.clear();
profileOrder.clear();
fillProfileField(profileField, chunkX, chunkZ, complex, resolverState, blendScratch);
for (int localX = 0; localX < CHUNK_SIZE; localX++) {
@@ -217,34 +221,31 @@ public class MantleCarvingComponent extends IrisMantleComponent {
} else if (!activeProfiles.containsKey(profile)) {
Arrays.fill(weights, 0D);
}
activeProfiles.put(profile, Boolean.TRUE);
if (activeProfiles.put(profile, Boolean.TRUE) == null) {
profileOrder.add(profile);
}
weights[columnIndex] = columnWeight;
}
}
}
List<WeightedProfile> columnWeightedProfiles = new ArrayList<>();
for (IrisCaveProfile profile : activeProfiles.keySet()) {
for (IrisCaveProfile profile : profileOrder) {
double[] weights = columnProfileWeights.get(profile);
if (weights == null) {
continue;
}
double totalWeight = 0D;
double maxWeight = 0D;
for (double weight : weights) {
totalWeight += weight;
if (weight > maxWeight) {
maxWeight = weight;
}
}
SimdKernels kernels = SimdSupport.kernels();
double totalWeight = kernels.sum(weights, weights.length);
double maxWeight = kernels.max(weights, weights.length);
if (maxWeight < MIN_WEIGHT) {
continue;
}
double averageWeight = totalWeight / CHUNK_AREA;
columnWeightedProfiles.add(new WeightedProfile(profile, weights, averageWeight, null));
columnWeightedProfiles.add(new WeightedProfile(profile, weights, averageWeight, null, columnWeightedProfiles.size()));
}
List<WeightedProfile> blendedProfiles = limitAndMergeBlendedProfiles(columnWeightedProfiles, MAX_BLENDED_PROFILE_PASSES, CHUNK_AREA);
@@ -277,6 +278,7 @@ public class MantleCarvingComponent extends IrisMantleComponent {
buildDimensionColumnPlan(columnPlan, chunkX, chunkZ, entry, resolverState);
Map<IrisCaveProfile, double[]> rootProfileColumnWeights = new IdentityHashMap<>();
List<IrisCaveProfile> rootProfileOrder = new ArrayList<>();
IrisRange worldYRange = entry.getWorldYRange();
for (int columnIndex = 0; columnIndex < CHUNK_AREA; columnIndex++) {
IrisDimensionCarvingEntry resolvedEntry = columnPlan[columnIndex];
@@ -290,14 +292,17 @@ public class MantleCarvingComponent extends IrisMantleComponent {
continue;
}
double[] columnWeights = rootProfileColumnWeights.computeIfAbsent(profile, key -> new double[CHUNK_AREA]);
double[] columnWeights = rootProfileColumnWeights.get(profile);
if (columnWeights == null) {
columnWeights = new double[CHUNK_AREA];
rootProfileColumnWeights.put(profile, columnWeights);
rootProfileOrder.add(profile);
}
columnWeights[columnIndex] = 1D;
}
List<Map.Entry<IrisCaveProfile, double[]>> profileEntries = new ArrayList<>(rootProfileColumnWeights.entrySet());
profileEntries.sort((a, b) -> Integer.compare(a.getKey().hashCode(), b.getKey().hashCode()));
for (Map.Entry<IrisCaveProfile, double[]> profileEntry : profileEntries) {
weightedProfiles.add(new WeightedProfile(profileEntry.getKey(), profileEntry.getValue(), -1D, worldYRange));
for (IrisCaveProfile profile : rootProfileOrder) {
weightedProfiles.add(new WeightedProfile(profile, rootProfileColumnWeights.get(profile), -1D, worldYRange, weightedProfiles.size()));
}
}
@@ -545,7 +550,7 @@ public class MantleCarvingComponent extends IrisMantleComponent {
List<WeightedProfile> mergedProfiles = new ArrayList<>();
for (WeightedProfile keptProfile : keptProfiles) {
double averageWeight = computeAverageWeight(keptProfile.columnWeights, areaSize);
mergedProfiles.add(new WeightedProfile(keptProfile.profile, keptProfile.columnWeights, averageWeight, keptProfile.worldYRange));
mergedProfiles.add(new WeightedProfile(keptProfile.profile, keptProfile.columnWeights, averageWeight, keptProfile.worldYRange, keptProfile.sequence));
}
mergedProfiles.sort(MantleCarvingComponent::compareByCarveOrder);
return mergedProfiles;
@@ -556,7 +561,7 @@ public class MantleCarvingComponent extends IrisMantleComponent {
if (weightOrder != 0) {
return weightOrder;
}
return Integer.compare(profileSortKey(a.profile), profileSortKey(b.profile));
return Integer.compare(a.sequence, b.sequence);
}
private static int compareByCarveOrder(WeightedProfile a, WeightedProfile b) {
@@ -564,14 +569,7 @@ public class MantleCarvingComponent extends IrisMantleComponent {
if (weightOrder != 0) {
return weightOrder;
}
return Integer.compare(profileSortKey(a.profile), profileSortKey(b.profile));
}
private static int profileSortKey(IrisCaveProfile profile) {
if (profile == null) {
return 0;
}
return profile.hashCode();
return Integer.compare(a.sequence, b.sequence);
}
private static double computeAverageWeight(double[] weights) {
@@ -607,12 +605,14 @@ public class MantleCarvingComponent extends IrisMantleComponent {
private final double[] columnWeights;
private final double averageWeight;
private final IrisRange worldYRange;
private final int sequence;
private WeightedProfile(IrisCaveProfile profile, double[] columnWeights, double averageWeight, IrisRange worldYRange) {
private WeightedProfile(IrisCaveProfile profile, double[] columnWeights, double averageWeight, IrisRange worldYRange, int sequence) {
this.profile = profile;
this.columnWeights = columnWeights;
this.averageWeight = averageWeight;
this.worldYRange = worldYRange;
this.sequence = sequence;
}
private double averageWeight() {
@@ -627,6 +627,7 @@ public class MantleCarvingComponent extends IrisMantleComponent {
private final IdentityHashMap<IrisCaveProfile, double[]> columnProfileWeights = new IdentityHashMap<>();
private final IdentityHashMap<IrisDimensionCarvingEntry, IrisDimensionCarvingEntry[]> dimensionColumnPlans = new IdentityHashMap<>();
private final IdentityHashMap<IrisCaveProfile, Boolean> activeProfiles = new IdentityHashMap<>();
private final List<IrisCaveProfile> profileOrder = new ArrayList<>();
private final double[] fieldSurfaceHeights = new double[FIELD_SIZE * FIELD_SIZE];
private final IrisRegion[] fieldRegions = new IrisRegion[FIELD_SIZE * FIELD_SIZE];
private final IrisBiome[] fieldSurfaceBiomes = new IrisBiome[FIELD_SIZE * FIELD_SIZE];
@@ -1381,8 +1381,10 @@ public class MantleObjectComponent extends IrisMantleComponent {
vacuumPlacements.add(j);
}
}
updateProceduralRadiusBounds(region.getProceduralObjects(), xg, zg);
}
for (IrisBiome biome : dimension.getAllBiomes(this::getData)) {
updateProceduralRadiusBounds(biome.getProceduralObjects(), xg, zg);
for (IrisObjectPlacement j : biome.getObjects()) {
if (j.getScale().canScaleBeyond()) {
scalars.put(j.getScale(), j.getPlace());
@@ -1414,6 +1416,24 @@ public class MantleObjectComponent extends IrisMantleComponent {
return Math.max(xg.get(), zg.get());
}
private void updateProceduralRadiusBounds(IrisProceduralObjects procedural, AtomicInteger xg, AtomicInteger zg) {
if (procedural == null || procedural.isEmpty()) {
return;
}
for (IrisProceduralPlacement placement : procedural.getAllPlacements()) {
if (placement == null) {
continue;
}
for (IrisObject variant : placement.getVariantObjects(getData())) {
if (variant == null) {
continue;
}
xg.getAndSet(Math.max(variant.getW(), xg.get()));
zg.getAndSet(Math.max(variant.getD(), zg.get()));
}
}
}
private void updateRadiusBounds(
KMap<String, BlockVector> sizeCache,
AtomicInteger xg,
@@ -225,14 +225,17 @@ public class IrisPostModifier extends EngineAssignedModifier<BlockData> {
// Foliage
b = getPostBlock(x, h + 1, z, currentPostX, currentPostZ, currentData);
if (B.isVineBlock(b) && b instanceof MultipleFacing f) {
if (B.isVineBlock(b) && b instanceof MultipleFacing) {
MultipleFacing f = (MultipleFacing) b.clone();
int finalH = h + 1;
f.getAllowedFaces().forEach(face -> {
BlockData d = getPostBlock(x + face.getModX(), finalH + face.getModY(), z + face.getModZ(), currentPostX, currentPostZ, currentData);
f.setFace(face, !B.isAir(d) && !B.isVineBlock(d));
});
setPostBlock(x, h + 1, z, b, currentPostX, currentPostZ, currentData);
if (!f.equals(b)) {
setPostBlock(x, h + 1, z, f, currentPostX, currentPostZ, currentData);
}
}
if (B.isFoliage(b) || b.getMaterial().equals(Material.DEAD_BUSH)) {
@@ -698,13 +698,19 @@ public class IrisObject extends IrisRegistrant {
}
double newRotation = config.getRotation().getYAxis().getMin() + slopeRotationY;
IrisObjectRotation originalRotation = config.getRotation();
IrisObjectRotation slopeRotation = new IrisObjectRotation();
slopeRotation.setXAxis(originalRotation.getXAxis());
slopeRotation.setZAxis(originalRotation.getZAxis());
if (newRotation == 0) {
config.getRotation().setYAxis(new IrisAxisRotationClamp(false, false, 0, 0, 90));
config.getRotation().setEnabled(config.getRotation().canRotateX() || config.getRotation().canRotateZ());
slopeRotation.setYAxis(new IrisAxisRotationClamp(false, false, 0, 0, 90));
slopeRotation.setEnabled(originalRotation.canRotateX() || originalRotation.canRotateZ());
} else {
config.getRotation().setYAxis(new IrisAxisRotationClamp(true, false, newRotation, newRotation, 90));
config.getRotation().setEnabled(true);
slopeRotation.setYAxis(new IrisAxisRotationClamp(true, false, newRotation, newRotation, 90));
slopeRotation.setEnabled(true);
}
config = config.toPlacement(config.getPlace().toArray(new String[0]));
config.setRotation(slopeRotation);
}
}
@@ -1048,7 +1054,9 @@ public class IrisObject extends IrisRegistrant {
}
if (placer.isPreventingDecay() && (data) instanceof Leaves && !((Leaves) (data)).isPersistent()) {
((Leaves) data).setPersistent(true);
Leaves leaves = (Leaves) data.clone();
leaves.setPersistent(true);
data = leaves;
}
for (IrisObjectReplace j : config.getEdit()) {
@@ -1103,17 +1111,24 @@ public class IrisObject extends IrisRegistrant {
}
if (data instanceof Waterlogged && shouldAutoWaterlogBlock(placer, config, yv, xx, yy, zz)) {
((Waterlogged) data).setWaterlogged(true);
Waterlogged waterlogged = (Waterlogged) data.clone();
waterlogged.setWaterlogged(true);
data = waterlogged;
}
if (B.isVineBlock(data)) {
MultipleFacing f = (MultipleFacing) data;
MultipleFacing f = (MultipleFacing) data.clone();
boolean facesChanged = false;
for (BlockFace face : f.getAllowedFaces()) {
BlockData facingBlock = placer.get(xx + face.getModX(), yy + face.getModY(), zz + face.getModZ());
if (B.isSolid(facingBlock) && !B.isVineBlock(facingBlock)) {
f.setFace(face, true);
facesChanged = true;
}
}
if (facesChanged) {
data = f;
}
}
if (listener != null) {
@@ -1314,8 +1329,11 @@ public class IrisObject extends IrisRegistrant {
int highest = placer.getHighest(xx, zz, getLoader(), true);
if (d instanceof Waterlogged && shouldAutoWaterlogBlock(placer, config, yv, xx, highest, zz))
((Waterlogged) d).setWaterlogged(true);
if (d instanceof Waterlogged && shouldAutoWaterlogBlock(placer, config, yv, xx, highest, zz)) {
Waterlogged waterlogged = (Waterlogged) d.clone();
waterlogged.setWaterlogged(true);
d = waterlogged;
}
int lowerBound = highest - 1;
if (settings != null) {
@@ -1352,13 +1370,18 @@ public class IrisObject extends IrisRegistrant {
}
if (B.isVineBlock(d)) {
MultipleFacing f = (MultipleFacing) d;
MultipleFacing f = (MultipleFacing) d.clone();
boolean facesChanged = false;
for (BlockFace face : f.getAllowedFaces()) {
BlockData facingBlock = placer.get(xx + face.getModX(), j + face.getModY(), zz + face.getModZ());
if (B.isSolid(facingBlock) && !B.isVineBlock(facingBlock)) {
f.setFace(face, true);
facesChanged = true;
}
}
if (facesChanged) {
d = f;
}
}
placer.set(xx, j, zz, d);
}
@@ -21,6 +21,7 @@ package art.arcane.iris.engine.object;
import art.arcane.iris.Iris;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.data.cache.AtomicCache;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.object.annotations.*;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.collection.KMap;
@@ -202,8 +203,11 @@ public class IrisObjectPlacement {
}
public CNG getSurfaceWarp(RNG rng, IrisData data) {
return surfaceWarp.aquire(() ->
getWarp().create(rng, data));
return surfaceWarp.aquire(() -> {
Engine engine = data.getEngine();
RNG warpRng = engine != null ? new RNG(engine.getSeedManager().getComponent() + 1024) : new RNG(8675309L);
return getWarp().create(warpRng, data);
});
}
public double warp(RNG rng, double x, double y, double z, IrisData data) {
@@ -48,6 +48,11 @@ public class IrisOreGenerator {
private transient AtomicCache<CNG> chanceCache = new AtomicCache<>();
public void warm(RNG rng, IrisData data) {
chanceCache.aquire(() -> chanceStyle.create(rng, data));
palette.getLayerGenerator(rng, data);
}
public BlockData generate(int x, int y, int z, RNG rng, IrisData data) {
if (palette.getPalette().isEmpty()) {
return null;
@@ -19,6 +19,7 @@
package art.arcane.iris.engine.object;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.math.RNG;
/**
@@ -40,4 +41,6 @@ public interface IrisProceduralPlacement {
IrisObjectPlacement asPlacement();
IrisObject getVariantObject(IrisData data, RNG rng);
KList<IrisObject> getVariantObjects(IrisData data);
}
@@ -23,7 +23,7 @@ public class MantleCarvingComponentTop2BlendTest {
@BeforeClass
public static void setup() throws Exception {
Class<?> weightedProfileClass = Class.forName("art.arcane.iris.engine.mantle.components.MantleCarvingComponent$WeightedProfile");
weightedProfileConstructor = weightedProfileClass.getDeclaredConstructor(IrisCaveProfile.class, double[].class, double.class, Class.forName("art.arcane.iris.engine.object.IrisRange"));
weightedProfileConstructor = weightedProfileClass.getDeclaredConstructor(IrisCaveProfile.class, double[].class, double.class, Class.forName("art.arcane.iris.engine.object.IrisRange"), int.class);
weightedProfileConstructor.setAccessible(true);
limitMethod = MantleCarvingComponent.class.getDeclaredMethod("limitAndMergeBlendedProfiles", List.class, int.class, int.class);
limitMethod.setAccessible(true);
@@ -76,6 +76,33 @@ public class MantleCarvingComponentTop2BlendTest {
assertEquals(1.0D, byProfile.get(second)[254], 0D);
}
@Test
public void topTwoTieBreaksBySequenceNotIdentity() throws Exception {
IrisCaveProfile first = new IrisCaveProfile().setEnabled(true).setBaseWeight(1.0D);
IrisCaveProfile second = new IrisCaveProfile().setEnabled(true).setBaseWeight(1.0D);
IrisCaveProfile third = new IrisCaveProfile().setEnabled(true).setBaseWeight(1.0D);
double[] weightsA = new double[256];
double[] weightsB = new double[256];
double[] weightsC = new double[256];
weightsA[0] = 0.5D;
weightsB[0] = 0.5D;
weightsC[0] = 0.5D;
List<Object> weighted = new ArrayList<>();
weighted.add(weightedProfileConstructor.newInstance(first, weightsA, average(weightsA), null, 0));
weighted.add(weightedProfileConstructor.newInstance(second, weightsB, average(weightsB), null, 1));
weighted.add(weightedProfileConstructor.newInstance(third, weightsC, average(weightsC), null, 2));
List<?> limited = invokeLimit(weighted, 2);
assertEquals(2, limited.size());
Map<IrisCaveProfile, double[]> byProfile = extractWeightsByProfile(limited);
assertEquals(true, byProfile.containsKey(first));
assertEquals(true, byProfile.containsKey(second));
assertEquals(false, byProfile.containsKey(third));
}
private WeightedInput createWeightedProfiles() throws Exception {
IrisCaveProfile first = new IrisCaveProfile().setEnabled(true).setBaseWeight(1.31D);
IrisCaveProfile second = new IrisCaveProfile().setEnabled(true).setBaseWeight(1.17D);
@@ -99,9 +126,9 @@ public class MantleCarvingComponentTop2BlendTest {
thirdWeights[255] = 0.4D;
List<Object> weighted = new ArrayList<>();
weighted.add(weightedProfileConstructor.newInstance(first, firstWeights, average(firstWeights), null));
weighted.add(weightedProfileConstructor.newInstance(second, secondWeights, average(secondWeights), null));
weighted.add(weightedProfileConstructor.newInstance(third, thirdWeights, average(thirdWeights), null));
weighted.add(weightedProfileConstructor.newInstance(first, firstWeights, average(firstWeights), null, 0));
weighted.add(weightedProfileConstructor.newInstance(second, secondWeights, average(secondWeights), null, 1));
weighted.add(weightedProfileConstructor.newInstance(third, thirdWeights, average(thirdWeights), null, 2));
return new WeightedInput(weighted, profiles);
}