From 4ef6fc7cc6d4f52829fb5598f242fc1c2a6165d0 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Fri, 2 Oct 2026 14:43:48 -0400 Subject: [PATCH] Enforce seed cohesion - Scope cached generation fields to their seed and context. - Warm decorators and ores with their canonical generation seeds. - Stabilize object traversal and procedural placement choices. - Isolate smart-bore variants from shared source objects. - Pin noise and full-engine output across generation orders. - Verify same-jar generation through cold server restarts. --- .../src/test/gameplay/seed-cohesion.mjs | 102 +++++++++ .../biome/FloatingIslandSample.java | 40 +++- .../iris/generation/biome/IrisBiome.java | 47 +++- .../biome/IrisFloatingChildBiomes.java | 85 ++++++-- .../iris/generation/block/VectorMap.java | 205 +++++++++--------- .../decoration/FloatingDecorator.java | 6 +- .../decoration/IrisCeilingDecorator.java | 4 +- .../generation/decoration/IrisDecorator.java | 69 ++++-- .../decoration/IrisDepositGenerator.java | 8 +- .../decoration/IrisEngineDecorator.java | 4 + .../decoration/IrisOreGenerator.java | 45 +++- .../decoration/IrisSeaFloorDecorator.java | 8 +- .../decoration/IrisSeaSurfaceDecorator.java | 8 +- .../decoration/IrisShoreLineDecorator.java | 10 +- .../decoration/IrisSurfaceDecorator.java | 4 +- .../decoration/ruin/RuinDecoratorApplier.java | 3 + .../decoration/tree/TreeBlockCanvas.java | 10 +- .../decoration/tree/TreeDecoratorApplier.java | 31 ++- .../decoration/tree/TreeSupport.java | 8 +- .../iris/generation/noise/IrisExpression.java | 15 +- .../noise/IrisExpressionFunction.java | 65 +++++- .../iris/generation/noise/IrisGenerator.java | 25 ++- .../generation/noise/IrisGeneratorStyle.java | 32 ++- .../runtime/GenerationCacheWarmer.java | 19 +- .../generation/terrain/IrisDimension.java | 29 ++- .../terrain/IrisMaterialPalette.java | 1 - .../iris/structure/object/IrisObject.java | 4 +- .../object/IrisObjectPlacementRunner.java | 9 +- .../structure/object/IrisObjectShaping.java | 35 +++ .../biome/FloatingIslandMemoContextTest.java | 52 +++++ .../biome/IrisBiomeFieldSeedCohesionTest.java | 84 +++++++ .../block/VectorMapDeterminismTest.java | 102 +++++++++ .../IrisDecoratorSeedCohesionTest.java | 148 +++++++++++++ .../decoration/IrisOreSeedCohesionTest.java | 109 ++++++++++ .../tree/TreeDecoratorApplierTest.java | 64 ++++++ .../noise/IrisExpressionSeedCohesionTest.java | 101 +++++++++ .../IrisGeneratorStyleCacheIsolationTest.java | 11 + .../noise/NoiseSeedCohesionTest.java | 81 +++++++ .../stage/IrisDepositModifierParityTest.java | 21 +- .../terrain/TerrainFieldSeedCohesionTest.java | 39 ++++ .../object/IrisObjectIoPaletteTest.java | 49 ++++- ...isObjectPlacementRunnerRegressionTest.java | 60 +++++ .../object/IrisObjectShapingTest.java | 23 ++ .../iris/probe/GenerationOrderProbe.java | 2 +- .../iris/probe/RealPackProbeSupport.java | 2 +- .../iris/probe/SeedCohesionProbeTest.java | 74 +++++++ .../seed-cohesion/biomes/terrain.json | 18 ++ .../seed-cohesion/dimensions/cohesion.json | 11 + .../seed-cohesion/generators/terrain.json | 5 + .../seed-cohesion/regions/terrain.json | 6 + 50 files changed, 1734 insertions(+), 259 deletions(-) create mode 100644 adapters/bukkit/plugin/src/test/gameplay/seed-cohesion.mjs create mode 100644 core/src/test/java/art/arcane/iris/generation/biome/FloatingIslandMemoContextTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/biome/IrisBiomeFieldSeedCohesionTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/block/VectorMapDeterminismTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/decoration/IrisDecoratorSeedCohesionTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/decoration/IrisOreSeedCohesionTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/noise/IrisExpressionSeedCohesionTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/noise/NoiseSeedCohesionTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/terrain/TerrainFieldSeedCohesionTest.java create mode 100644 probe/src/test/java/art/arcane/iris/probe/SeedCohesionProbeTest.java create mode 100644 probe/src/test/resources/seed-cohesion/biomes/terrain.json create mode 100644 probe/src/test/resources/seed-cohesion/dimensions/cohesion.json create mode 100644 probe/src/test/resources/seed-cohesion/generators/terrain.json create mode 100644 probe/src/test/resources/seed-cohesion/regions/terrain.json diff --git a/adapters/bukkit/plugin/src/test/gameplay/seed-cohesion.mjs b/adapters/bukkit/plugin/src/test/gameplay/seed-cohesion.mjs new file mode 100644 index 000000000..b02aaea71 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/seed-cohesion.mjs @@ -0,0 +1,102 @@ +import { createHash } from 'node:crypto' +import { readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' + +const chunks = [[-64, -64], [64, -64], [-64, 64], [64, 64]] + +export default { + name: 'iris-seed-cohesion', + description: 'Compare client-visible decorated chunks in two same-seed worlds explored in opposite orders.', + async run(context) { + const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8') + context.expect(/^isolated=true\s*$/m.test(source), 'Seed cohesion requires an isolated instance') + const wait = (predicate, label) => context.waitUntil(predicate, { label, timeoutMs: 60000, intervalMs: 100 }) + const currentWorld = async () => { + const observation = await context.observe() + const player = observation.players.find(entry => entry.username === context.bot.username) + return observation.worlds.find(entry => entry.id === player?.world) + } + const enter = async name => { + await context.command(`/iris tp ${name}`) + await wait(async () => (await currentWorld())?.key === `iris:${name}` + || (await currentWorld())?.name.endsWith(name), `Enter ${name}`) + await context.command('/minecraft:gamemode spectator @s') + await wait(() => context.bot.game.gameMode === 'spectator', 'Spectator mode') + await context.command('/minecraft:seed', /Seed:.*1337/) + } + const capture = async coordinates => { + const hashes = {} + const decorators = {} + let decorationCount = 0 + for (const [cx, cz] of coordinates) { + const x = cx * 16 + const z = cz * 16 + await context.command(`/minecraft:tp @s ${x + 8.5} 48 ${z + 8.5}`, /Teleported/i, 30000) + await wait(() => Math.abs(context.bot.entity.position.x - x - 8.5) < 1 + && Math.abs(context.bot.entity.position.z - z - 8.5) < 1, 'Chunk sample teleport') + const position = context.bot.entity.position.clone() + await wait(() => context.bot.blockAt(position.clone().set(x, 1, z)) + && context.bot.blockAt(position.clone().set(x + 15, 63, z + 15)), 'Complete sample chunk') + const digest = createHash('sha256') + let decoration = 0 + let terrain = 0 + for (let dx = 0; dx < 16; dx++) { + for (let dz = 0; dz < 16; dz++) { + for (let y = 0; y < 64; y++) { + const block = context.bot.blockAt(position.set(x + dx, y, z + dz)) + context.expect(block !== null, 'Missing sampled block', { x: x + dx, y, z: z + dz }) + digest.update(`${block.stateId},`) + if (block.name === 'granite' || block.name === 'andesite') decoration++ + if (block.name === 'stone') terrain++ + } + } + } + context.expect(terrain > 1000, 'Fixture terrain was not generated', { cx, cz, terrain }) + decorationCount += decoration + decorators[`${cx},${cz}`] = decoration + hashes[`${cx},${cz}`] = digest.digest('hex') + } + context.expect(decorationCount > 0, 'Fixture decorators were not generated in sampled chunks', { decorators }) + context.report.decoratorCoverage ??= [] + context.report.decoratorCoverage.push(decorators) + return hashes + } + const baseline = 'seed-cohesion-forward' + const reverse = 'seed-cohesion-reverse' + const stateFile = path.join(context.server.directory, '.seed-cohesion.json') + const jarHash = createHash('sha256').update(await readFile(path.join(context.server.directory, 'plugins', 'Iris.jar'))).digest('hex') + const restart = context.options.command === 'restart-check' + const saved = restart ? JSON.parse(await readFile(stateFile, 'utf8')) : undefined + if (restart) { + context.expect(jarHash === saved.jarHash, 'Restart-check requires the same Iris jar', { before: saved.jarHash, after: jarHash }) + context.expect((await context.observe()).processId !== saved.processId, 'Restart-check requires a cold server restart') + } + const outputs = [] + for (const [name, coordinates] of [[baseline, chunks], [reverse, [...chunks].reverse()]]) { + await context.step(`${restart ? 'Reload' : 'Create'} and sample ${name}`, async () => { + if (!restart) { + await context.command(`/iris create name=${name} type=cohesion seed=1337`, /Successfully created your world/i, 180000) + } + await enter(name) + const output = await capture(coordinates) + if (restart) { + for (const [coordinate, hash] of Object.entries(output)) { + context.expect(saved.hashes[coordinate] === hash, 'Persisted chunk changed after restart', { name, coordinate, before: saved.hashes[coordinate], after: hash }) + } + Object.assign(output, await capture(coordinates.map(([cx, cz]) => [cx * 3, cz * 3]))) + } + outputs.push(output) + }) + } + await context.step('Compare all sampled blocks', async () => { + for (const [coordinate, hash] of Object.entries(outputs[0])) { + context.expect(outputs[1][coordinate] === hash, 'Same-seed worlds differ', + { coordinate, forward: hash, reverse: outputs[1][coordinate] }) + } + context.report.seedCohesion = { seed: 1337, jarHash, blocksPerChunk: 16384, restart, hashes: outputs[0] } + if (!restart) { + await writeFile(stateFile, JSON.stringify({ processId: (await context.observe()).processId, jarHash, hashes: outputs[0] })) + } + }) + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/biome/FloatingIslandSample.java b/core/src/main/java/art/arcane/iris/generation/biome/FloatingIslandSample.java index 3b8bdc544..d5c86bd85 100644 --- a/core/src/main/java/art/arcane/iris/generation/biome/FloatingIslandSample.java +++ b/core/src/main/java/art/arcane/iris/generation/biome/FloatingIslandSample.java @@ -51,7 +51,7 @@ public final class FloatingIslandSample { private static final double CARVE_MAX_VERTICAL_RUN_FRACTION = 0.18D; private static final ThreadLocal LAST_REJECT = ThreadLocal.withInitial(() -> new int[1]); private static final ThreadLocal LAST_DENSITY = ThreadLocal.withInitial(() -> new double[2]); - private static final ThreadLocal> CHUNK_MEMO = ThreadLocal.withInitial(HashMap::new); + private static final ThreadLocal CHUNK_MEMO = ThreadLocal.withInitial(ChunkMemo::new); private static final ThreadLocal> FOOTPRINT_MEMO = ThreadLocal.withInitial(IdentityHashMap::new); private static final AtomicBoolean NULL_CNG_WARNED = new AtomicBoolean(false); @@ -68,21 +68,32 @@ public final class FloatingIslandSample { } public static void clearThreadCaches() { + CHUNK_MEMO.remove(); + FOOTPRINT_MEMO.remove(); + LAST_REJECT.remove(); + LAST_DENSITY.remove(); } public static void clearChunkMemo() { - CHUNK_MEMO.get().clear(); + CHUNK_MEMO.get().samples.clear(); FOOTPRINT_MEMO.get().clear(); } public static FloatingIslandSample sampleMemoized(IrisBiome parent, int wx, int wz, int chunkHeight, long baseSeed, IrisData data, Engine engine, FloatingIslandBoundarySampler boundarySampler) { long key = (((long) wx) << 32) ^ (wz & 0xFFFFFFFFL); - HashMap memo = CHUNK_MEMO.get(); - if (memo.containsKey(key)) { - return memo.get(key); + ChunkMemo memo = CHUNK_MEMO.get(); + if (memo.context == null || !memo.context.matches(wx >> 4, wz >> 4, chunkHeight, baseSeed, + data, engine, boundarySampler)) { + memo.samples.clear(); + FOOTPRINT_MEMO.get().clear(); + memo.context = new MemoContext(wx >> 4, wz >> 4, chunkHeight, baseSeed, data, engine, boundarySampler); + } + MemoizedSample cached = memo.samples.get(key); + if (cached != null && cached.parent == parent) { + return cached.sample; } FloatingIslandSample result = sample(parent, wx, wz, chunkHeight, baseSeed, data, engine, boundarySampler); - memo.put(key, result); + memo.samples.put(key, new MemoizedSample(parent, result)); return result; } @@ -799,4 +810,21 @@ public final class FloatingIslandSample { } }; } + + private static final class ChunkMemo { + private final HashMap samples = new HashMap<>(256); + private MemoContext context; + } + + private record MemoContext(int chunkX, int chunkZ, int height, long seed, IrisData data, Engine engine, + FloatingIslandBoundarySampler boundarySampler) { + private boolean matches(int chunkX, int chunkZ, int height, long seed, IrisData data, Engine engine, + FloatingIslandBoundarySampler boundarySampler) { + return this.chunkX == chunkX && this.chunkZ == chunkZ && this.height == height && this.seed == seed + && this.data == data && this.engine == engine && this.boundarySampler == boundarySampler; + } + } + + private record MemoizedSample(IrisBiome parent, FloatingIslandSample sample) { + } } diff --git a/core/src/main/java/art/arcane/iris/generation/biome/IrisBiome.java b/core/src/main/java/art/arcane/iris/generation/biome/IrisBiome.java index 9a5e69c05..eebe8ca94 100644 --- a/core/src/main/java/art/arcane/iris/generation/biome/IrisBiome.java +++ b/core/src/main/java/art/arcane/iris/generation/biome/IrisBiome.java @@ -105,7 +105,12 @@ public class IrisBiome extends IrisRegistrant implements Rarity { private final transient AtomicCache cacheColorDecoratorLoad = new AtomicCache<>(); private final transient AtomicCache cacheColorLayerLoad = new AtomicCache<>(); private final transient AtomicCache cacheColorDepositLoad = new AtomicCache<>(); - private final transient AtomicCache childrenCell = new AtomicCache<>(); + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private final transient LazyBoundedCache childrenGenerators = new LazyBoundedCache<>(8); + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private transient volatile CachedChildGenerator recentChildGenerator; @Getter(AccessLevel.NONE) private final transient LazyBoundedCache biomeGenerators = new LazyBoundedCache<>(BIOME_GENERATOR_CACHE_SIZE); @@ -512,11 +517,18 @@ public class IrisBiome extends IrisRegistrant implements Rarity { } public CNG getChildrenGenerator(RNG random, int sig, double scale) { - return childrenCell.aquire(() -> createChildrenGenerator(random, sig, scale)); - } - - private CNG createChildrenGenerator(RNG random, int sig, double scale) { - return getChildStyle().createScaledGenerator(deriveChildStyleSeed(random, sig), getLoader(), scale); + long seed = deriveChildStyleSeed(random, sig).getSeed(); + IrisData data = getLoader(); + Engine engine = data == null ? null : data.getEngine(); + CachedChildGenerator recent = recentChildGenerator; + if (recent != null && recent.key.matches(seed, scale, data, engine)) { + return recent.generator; + } + ChildGeneratorKey key = new ChildGeneratorKey(seed, scale, data, engine); + CNG generator = childrenGenerators.computeIfAbsent(key, + ignored -> getChildStyle().createScaledGenerator(new RNG(seed), data, scale)); + recentChildGenerator = new CachedChildGenerator(key, generator); + return generator; } static RNG deriveChildStyleSeed(RNG random, int signature) { @@ -698,4 +710,27 @@ public class IrisBiome extends IrisRegistrant implements Rarity { this.generator = generator; } } + + private record ChildGeneratorKey(long seed, double scale, IrisData data, Engine engine) { + private boolean matches(long seed, double scale, IrisData data, Engine engine) { + return this.seed == seed && Double.doubleToLongBits(this.scale) == Double.doubleToLongBits(scale) + && this.data == data && this.engine == engine; + } + + @Override + public boolean equals(Object object) { + return object instanceof ChildGeneratorKey other && matches(other.seed, other.scale, other.data, other.engine); + } + + @Override + public int hashCode() { + int hash = Long.hashCode(seed); + hash = 31 * hash + Double.hashCode(scale); + hash = 31 * hash + System.identityHashCode(data); + return 31 * hash + System.identityHashCode(engine); + } + } + + private record CachedChildGenerator(ChildGeneratorKey key, CNG generator) { + } } diff --git a/core/src/main/java/art/arcane/iris/generation/biome/IrisFloatingChildBiomes.java b/core/src/main/java/art/arcane/iris/generation/biome/IrisFloatingChildBiomes.java index 2cef212b8..55111cad3 100644 --- a/core/src/main/java/art/arcane/iris/generation/biome/IrisFloatingChildBiomes.java +++ b/core/src/main/java/art/arcane/iris/generation/biome/IrisFloatingChildBiomes.java @@ -33,6 +33,10 @@ import art.arcane.volmlib.util.math.Rarity; import art.arcane.iris.pack.loading.IrisData; import art.arcane.volmlib.util.cache.AtomicCache; +import art.arcane.iris.generation.cache.LazyBoundedCache; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.Setter; import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.pack.schema.annotation.ArrayType; import art.arcane.volmlib.util.documentation.Description; @@ -43,7 +47,6 @@ import art.arcane.iris.pack.schema.annotation.RegistryListResource; import art.arcane.iris.pack.schema.annotation.Snippet; import art.arcane.volmlib.util.noise.CNG; import art.arcane.volmlib.util.collection.KList; -import art.arcane.volmlib.util.math.RNG; import lombok.AllArgsConstructor; import lombok.Data; import lombok.EqualsAndHashCode; @@ -59,48 +62,59 @@ import lombok.experimental.Accessors; @Data public class IrisFloatingChildBiomes implements Rarity { private final transient AtomicCache resolvedBiome = new AtomicCache<>(); - private final transient AtomicCache footprintCache = new AtomicCache<>(); - private final transient AtomicCache edgeTaperProfileCache = new AtomicCache<>(); - private final transient AtomicCache pickerCache = new AtomicCache<>(); - private final transient AtomicCache altitudeCache = new AtomicCache<>(); - private final transient AtomicCache topShapeCache = new AtomicCache<>(); - private final transient AtomicCache bottomCache = new AtomicCache<>(); - private final transient AtomicCache wallWarpCache = new AtomicCache<>(); - private final transient AtomicCache carveCache = new AtomicCache<>(); - private final transient AtomicCache carvingProfileSamplerCache = new AtomicCache<>(true); + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private final transient LazyBoundedCache edgeTaperProfiles = new LazyBoundedCache<>(8); + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private transient volatile CachedEdgeProfile recentEdgeProfile; + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private final transient LazyBoundedCache carvingProfileSamplers = new LazyBoundedCache<>(8); + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private transient volatile CachedCarvingProfile recentCarvingProfile; private final transient AtomicCache shrinkScaleCache = new AtomicCache<>(); public CNG getFootprintCng(long baseSeed, IrisData data) { - return footprintCache.aquire(() -> getFootprintStyle().create(new RNG(baseSeed ^ 0xF007B17DL), data)); + return getFootprintStyle().create(baseSeed ^ 0xF007B17DL, data); } public FloatingIslandEdgeProfile getEdgeTaperProfile(long baseSeed, IrisData data) { - return edgeTaperProfileCache.aquire(() -> { + Engine engine = data == null ? null : data.getEngine(); + CachedEdgeProfile recent = recentEdgeProfile; + if (recent != null && recent.key.matches(baseSeed, data, engine)) { + return recent.profile; + } + RuntimeKey key = new RuntimeKey(baseSeed, data, engine); + FloatingIslandEdgeProfile profile = edgeTaperProfiles.computeIfAbsent(key, ignored -> { int width = FloatingIslandEdgeProfile.clampWidth(getEdgeTaperWidth()); double amplitude = FloatingIslandEdgeProfile.clampVariationAmplitude( getEdgeTaperVariationAmplitude(), width); IrisGeneratorStyle style = getEdgeTaperVariationStyle(); CNG variation = amplitude > 0.0D && style != null - ? style.create(new RNG(baseSeed ^ 0xED6E7A9E5L), data) + ? style.create(baseSeed ^ 0xED6E7A9E5L, data) : null; return new FloatingIslandEdgeProfile(width, getEdgeTaperExponent(), amplitude, variation); }); + recentEdgeProfile = new CachedEdgeProfile(key, profile); + return profile; } public CNG getPickerCng(long baseSeed, IrisData data) { - return pickerCache.aquire(() -> getPickerStyle().create(new RNG(baseSeed ^ 0x91C4E72DL), data)); + return getPickerStyle().create(baseSeed ^ 0x91C4E72DL, data); } public CNG getAltitudeCng(long baseSeed, IrisData data) { - return altitudeCache.aquire(() -> getAltitudeStyle().create(new RNG(baseSeed ^ 0xA17DEBBL), data)); + return getAltitudeStyle().create(baseSeed ^ 0xA17DEBBL, data); } public CNG getTopShapeCng(long baseSeed, IrisData data) { - return topShapeCache.aquire(() -> getTopShapeStyle().create(new RNG(baseSeed ^ 0x70970601DEFL), data)); + return getTopShapeStyle().create(baseSeed ^ 0x70970601DEFL, data); } public CNG getBottomCng(long baseSeed, IrisData data) { - return bottomCache.aquire(() -> getBottomStyle().create(new RNG(baseSeed ^ 0xB0770075CAFEL), data)); + return getBottomStyle().create(baseSeed ^ 0xB0770075CAFEL, data); } public CNG getWallWarpCng(long baseSeed, IrisData data) { @@ -108,7 +122,7 @@ public class IrisFloatingChildBiomes implements Rarity { if (style == null) { return null; } - return wallWarpCache.aquire(() -> style.create(new RNG(baseSeed ^ 0xA117BA17E0FL), data)); + return style.create(baseSeed ^ 0xA117BA17E0FL, data); } public CNG getCarveCng(long baseSeed, IrisData data) { @@ -116,7 +130,7 @@ public class IrisFloatingChildBiomes implements Rarity { if (style == null) { return null; } - return carveCache.aquire(() -> style.create(new RNG(baseSeed ^ 0xCA5EC1EE5EL), data)); + return style.create(baseSeed ^ 0xCA5EC1EE5EL, data); } public IrisCaveProfileSampler getCarvingProfileSampler(Engine engine, IrisData data) { @@ -124,7 +138,12 @@ public class IrisFloatingChildBiomes implements Rarity { return null; } - return carvingProfileSamplerCache.aquire(() -> { + CachedCarvingProfile recent = recentCarvingProfile; + if (recent != null && recent.key.matches(0L, data, engine)) { + return recent.sampler; + } + RuntimeKey key = new RuntimeKey(0L, data, engine); + IrisCaveProfileSampler sampler = carvingProfileSamplers.computeIfAbsent(key, ignored -> { IrisBiome resolved = resolveCarvingBiome(carving, engine, data); if (resolved == null || resolved.getCaveProfile() == null || !resolved.getCaveProfile().isEnabled()) { return null; @@ -132,6 +151,8 @@ public class IrisFloatingChildBiomes implements Rarity { return new IrisCaveProfileSampler(engine, resolved.getCaveProfile()); }); + recentCarvingProfile = new CachedCarvingProfile(key, sampler); + return sampler; } public boolean hasCarvingReference() { @@ -379,4 +400,28 @@ public class IrisFloatingChildBiomes implements Rarity { return loaded; }); } + + private record RuntimeKey(long seed, IrisData data, Engine engine) { + private boolean matches(long seed, IrisData data, Engine engine) { + return this.seed == seed && this.data == data && this.engine == engine; + } + + @Override + public boolean equals(Object object) { + return object instanceof RuntimeKey other && matches(other.seed, other.data, other.engine); + } + + @Override + public int hashCode() { + int hash = Long.hashCode(seed); + hash = 31 * hash + System.identityHashCode(data); + return 31 * hash + System.identityHashCode(engine); + } + } + + private record CachedEdgeProfile(RuntimeKey key, FloatingIslandEdgeProfile profile) { + } + + private record CachedCarvingProfile(RuntimeKey key, IrisCaveProfileSampler sampler) { + } } diff --git a/core/src/main/java/art/arcane/iris/generation/block/VectorMap.java b/core/src/main/java/art/arcane/iris/generation/block/VectorMap.java index c3e336771..12c1c191e 100644 --- a/core/src/main/java/art/arcane/iris/generation/block/VectorMap.java +++ b/core/src/main/java/art/arcane/iris/generation/block/VectorMap.java @@ -6,18 +6,28 @@ import art.arcane.iris.generation.geometry.IrisBlockVector; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import java.util.ArrayList; +import java.util.Comparator; import java.util.Iterator; +import java.util.List; import java.util.Map; import java.util.NoSuchElementException; +import java.util.concurrent.atomic.AtomicLong; import java.util.function.BiConsumer; import java.util.function.Function; public class VectorMap implements Iterable> { private final Map> map = new KMap<>(); - private transient volatile long modificationRevision; + private final AtomicLong modificationRevision = new AtomicLong(); + private transient volatile OrderedSnapshot orderedSnapshot; public long modificationRevision() { - return modificationRevision; + return modificationRevision.get(); + } + + private void changed() { + modificationRevision.incrementAndGet(); + orderedSnapshot = null; } public int size() { @@ -30,7 +40,7 @@ public class VectorMap implements Iterable> { public boolean containsKey(@NonNull IrisBlockVector vector) { if (map.isEmpty()) return false; - var chunk = map.get(chunk(vector)); + Map chunk = map.get(chunk(vector)); return chunk != null && chunk.containsKey(relative(vector)); } @@ -40,14 +50,14 @@ public class VectorMap implements Iterable> { public @Nullable T get(@NonNull IrisBlockVector vector) { if (map.isEmpty()) return null; - var chunk = map.get(chunk(vector)); + Map chunk = map.get(chunk(vector)); return chunk == null ? null : chunk.get(relative(vector)); } public @Nullable T put(@NonNull IrisBlockVector vector, @NonNull T value) { T previous = map.computeIfAbsent(chunk(vector), k -> new KMap<>()) .put(relative(vector), value); - modificationRevision++; + changed(); return previous; } @@ -59,7 +69,7 @@ public class VectorMap implements Iterable> { return mappingFunction.apply(vector); }); if (inserted[0]) { - modificationRevision++; + changed(); } return value; } @@ -77,7 +87,7 @@ public class VectorMap implements Iterable> { }); if (removed[0] != null) { - modificationRevision++; + changed(); } return (T) removed[0]; @@ -89,22 +99,47 @@ public class VectorMap implements Iterable> { public void clear() { if (!map.isEmpty()) { - modificationRevision++; + map.clear(); + changed(); } - map.clear(); } public void forEach(@NonNull BiConsumer<@NonNull IrisBlockVector, @NonNull T> consumer) { - map.forEach((chunk, values) -> { - int rX = chunk.x << 10; - int rY = chunk.y << 10; - int rZ = chunk.z << 10; + Cursor cursor = cursor(); + while (cursor.next()) { + consumer.accept(cursor.key().clone(), cursor.value()); + } + } - values.forEach((relative, value) -> consumer.accept( - relative.resolve(rX, rY, rZ), - value - )); - }); + private List> orderedEntries() { + long revision = modificationRevision.get(); + OrderedSnapshot cached = orderedSnapshot; + if (cached != null && cached.revision() == revision) { + return cached.entries(); + } + synchronized (this) { + revision = modificationRevision.get(); + cached = orderedSnapshot; + if (cached != null && cached.revision() == revision) { + return cached.entries(); + } + List> entries = new ArrayList<>(); + map.forEach((chunk, values) -> values.forEach((relative, value) -> entries.add( + new OrderedEntry<>((chunk.x << 10) + relative.x, (chunk.y << 10) + relative.y, + (chunk.z << 10) + relative.z, value)))); + entries.sort(Comparator.comparingInt((OrderedEntry entry) -> entry.x()) + .thenComparingInt(OrderedEntry::y).thenComparingInt(OrderedEntry::z)); + if (modificationRevision.get() == revision) { + orderedSnapshot = new OrderedSnapshot<>(revision, entries); + } + return entries; + } + } + + private record OrderedEntry(int x, int y, int z, T value) { + } + + private record OrderedSnapshot(long revision, List> entries) { } private static Key chunk(IrisBlockVector vector) { @@ -139,32 +174,21 @@ public class VectorMap implements Iterable> { } public final class Cursor { - private final Iterator>> chunkIterator = map.entrySet().iterator(); + private final List> entries = orderedEntries(); private final IrisBlockVector position = new IrisBlockVector(0, 0, 0); - private Iterator> relativeIterator; - private int rX, rY, rZ; + private int index; private T value; public boolean next() { - while (relativeIterator == null || !relativeIterator.hasNext()) { - if (!chunkIterator.hasNext()) { - value = null; - return false; - } - - Map.Entry> chunk = chunkIterator.next(); - rX = chunk.getKey().x << 10; - rY = chunk.getKey().y << 10; - rZ = chunk.getKey().z << 10; - relativeIterator = chunk.getValue().entrySet().iterator(); + if (index >= entries.size()) { + value = null; + return false; } - - Map.Entry entry = relativeIterator.next(); - Key relative = entry.getKey(); - position.setX(rX + relative.x); - position.setY(rY + relative.y); - position.setZ(rZ + relative.z); - value = entry.getValue(); + OrderedEntry entry = entries.get(index++); + position.setX(entry.x()); + position.setY(entry.y()); + position.setZ(entry.z()); + value = entry.value(); return true; } @@ -181,79 +205,62 @@ public class VectorMap implements Iterable> { } public class EntryIterator implements Iterator> { - private final Iterator>> chunkIterator = map.entrySet().iterator(); - private Iterator> relativeIterator; - private int rX, rY, rZ; + private final List> entries = orderedEntries(); + private int index; + private OrderedEntry last; @Override public boolean hasNext() { - return advance(); + return index < entries.size(); } @Override public Map.Entry next() { - if (!advance()) throw new NoSuchElementException(); - - var entry = relativeIterator.next(); - return Map.entry(entry.getKey().resolve(rX, rY, rZ), entry.getValue()); - } - - private boolean advance() { - while (relativeIterator == null || !relativeIterator.hasNext()) { - if (!chunkIterator.hasNext()) return false; - var chunk = chunkIterator.next(); - rX = chunk.getKey().x << 10; - rY = chunk.getKey().y << 10; - rZ = chunk.getKey().z << 10; - relativeIterator = chunk.getValue().entrySet().iterator(); + if (!hasNext()) { + throw new NoSuchElementException(); } - - return true; + OrderedEntry entry = entries.get(index++); + last = entry; + return Map.entry(new IrisBlockVector(entry.x(), entry.y(), entry.z()), entry.value()); } @Override public void remove() { - if (relativeIterator == null) throw new IllegalStateException("No element to remove"); - relativeIterator.remove(); - modificationRevision++; + if (last == null) { + throw new IllegalStateException("No element to remove"); + } + VectorMap.this.remove(new IrisBlockVector(last.x(), last.y(), last.z())); + last = null; } } public class KeyIterator implements Iterator, Iterable { - private final Iterator>> chunkIterator = map.entrySet().iterator(); - private Iterator relativeIterator; - private int rX, rY, rZ; + private final List> entries = orderedEntries(); + private int index; + private OrderedEntry last; @Override public boolean hasNext() { - return advance(); + return index < entries.size(); } @Override public IrisBlockVector next() { - if (!advance()) throw new NoSuchElementException(); - - return relativeIterator.next().resolve(rX, rY, rZ); - } - - private boolean advance() { - while (relativeIterator == null || !relativeIterator.hasNext()) { - if (!chunkIterator.hasNext()) return false; - var chunk = chunkIterator.next(); - rX = chunk.getKey().x << 10; - rY = chunk.getKey().y << 10; - rZ = chunk.getKey().z << 10; - relativeIterator = chunk.getValue().keySet().iterator(); + if (!hasNext()) { + throw new NoSuchElementException(); } - - return true; + OrderedEntry entry = entries.get(index++); + last = entry; + return new IrisBlockVector(entry.x(), entry.y(), entry.z()); } @Override public void remove() { - if (relativeIterator == null) throw new IllegalStateException("No element to remove"); - relativeIterator.remove(); - modificationRevision++; + if (last == null) { + throw new IllegalStateException("No element to remove"); + } + VectorMap.this.remove(new IrisBlockVector(last.x(), last.y(), last.z())); + last = null; } @Override @@ -263,35 +270,31 @@ public class VectorMap implements Iterable> { } public class ValueIterator implements Iterator, Iterable { - private final Iterator> chunkIterator = map.values().iterator(); - private Iterator relativeIterator; + private final List> entries = orderedEntries(); + private int index; + private OrderedEntry last; @Override public boolean hasNext() { - return advance(); + return index < entries.size(); } @Override public T next() { - if (!advance()) throw new NoSuchElementException(); - - return relativeIterator.next(); - } - - private boolean advance() { - while (relativeIterator == null || !relativeIterator.hasNext()) { - if (!chunkIterator.hasNext()) return false; - relativeIterator = chunkIterator.next().values().iterator(); + if (!hasNext()) { + throw new NoSuchElementException(); } - - return true; + last = entries.get(index++); + return last.value(); } @Override public void remove() { - if (relativeIterator == null) throw new IllegalStateException("No element to remove"); - relativeIterator.remove(); - modificationRevision++; + if (last == null) { + throw new IllegalStateException("No element to remove"); + } + VectorMap.this.remove(new IrisBlockVector(last.x(), last.y(), last.z())); + last = null; } @Override diff --git a/core/src/main/java/art/arcane/iris/generation/decoration/FloatingDecorator.java b/core/src/main/java/art/arcane/iris/generation/decoration/FloatingDecorator.java index 70aa470fc..5aec93b86 100644 --- a/core/src/main/java/art/arcane/iris/generation/decoration/FloatingDecorator.java +++ b/core/src/main/java/art/arcane/iris/generation/decoration/FloatingDecorator.java @@ -35,7 +35,7 @@ public class FloatingDecorator { int xf, int zf, int realX, int realZ, int height, int max, Hunk data, RNG rng, Runnable candidatesNullCallback) { - RNG gRNG = new RNG(DecoratorCore.partSeed(engine.getSeedManager().getDecorator(), part)); + RNG gRNG = new RNG(DecoratorCore.partSeed(engine.getSeedManager().getComponent(), part)); IrisDecorator decorator = DecoratorCore.pickDecorator(target, part, gRNG, rng, engine.getData(), realX, realZ); if (decorator == null) { @@ -45,11 +45,11 @@ public class FloatingDecorator { if (!decorator.isStacking()) { DecoratorCore.placeFloatingSimple(decorator, xf, zf, realX, realZ, height, max, data, - rng, engine.getData(), engine.getMantle()); + gRNG, engine.getData(), engine.getMantle()); return max > 1 ? 1 : 0; } return DecoratorCore.placeFloatingStacked(decorator, xf, zf, realX, realZ, height, max, data, - rng, engine.getData(), engine.getMantle()); + gRNG, engine.getData(), engine.getMantle()); } } diff --git a/core/src/main/java/art/arcane/iris/generation/decoration/IrisCeilingDecorator.java b/core/src/main/java/art/arcane/iris/generation/decoration/IrisCeilingDecorator.java index 4b8e176e8..e5809f382 100644 --- a/core/src/main/java/art/arcane/iris/generation/decoration/IrisCeilingDecorator.java +++ b/core/src/main/java/art/arcane/iris/generation/decoration/IrisCeilingDecorator.java @@ -60,13 +60,13 @@ public class IrisCeilingDecorator extends IrisEngineDecorator { return; } } - DecoratorCore.placeSingleAt(decorator, x, z, realX, height, realZ, data, rng, getData(), true, getEngine().getMantle()); + DecoratorCore.placeSingleAt(decorator, x, z, realX, height, realZ, data, partRNG, getData(), true, getEngine().getMantle()); return; } DecoratorCore.PlaceOpts opts = DecoratorCore.SCRATCH_OPTS.get(); opts.reset(); opts.caveSkipFluid = caveSkipFluid; - DecoratorCore.placeStackDown(decorator, x, z, realX, realZ, height, 0, data, rng, getData(), max, opts, getEngine().getMantle()); + DecoratorCore.placeStackDown(decorator, x, z, realX, realZ, height, 0, data, partRNG, getData(), max, opts, getEngine().getMantle()); } } diff --git a/core/src/main/java/art/arcane/iris/generation/decoration/IrisDecorator.java b/core/src/main/java/art/arcane/iris/generation/decoration/IrisDecorator.java index a0e8fc629..759665e8a 100644 --- a/core/src/main/java/art/arcane/iris/generation/decoration/IrisDecorator.java +++ b/core/src/main/java/art/arcane/iris/generation/decoration/IrisDecorator.java @@ -19,6 +19,8 @@ package art.arcane.iris.generation.decoration; import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.cache.LazyBoundedCache; import art.arcane.iris.generation.block.IrisBlockData; import art.arcane.iris.generation.noise.IrisGeneratorStyle; import art.arcane.iris.generation.noise.NoiseStyle; @@ -50,9 +52,8 @@ import lombok.experimental.Accessors; @Description("A biome decorator is used for placing flowers, grass, cacti and so on") @Data public class IrisDecorator { - private final transient AtomicCache layerGenerator = new AtomicCache<>(); - private final transient AtomicCache varianceGenerator = new AtomicCache<>(); - private final transient AtomicCache heightGenerator = new AtomicCache<>(); + private final transient LazyBoundedCache generators = new LazyBoundedCache<>(32); + private transient volatile Generators recentGenerators; private final transient AtomicCache> blockData = new AtomicCache<>(); private final transient AtomicCache> blockDataTops = new AtomicCache<>(); private final transient AtomicCache blockDataArray = new AtomicCache<>(); @@ -129,33 +130,34 @@ public class IrisDecorator { } public CNG getHeightGenerator(RNG rng, IrisData data) { - CNG cached = heightGenerator.getIfPresent(); - - if (cached != null) { - return cached; - } - - return heightGenerator.aquire(() -> + AtomicCache cache = generators(rng, data).height; + CNG cached = cache.getIfPresent(); + return cached != null ? cached : cache.aquire(() -> heightVariance.create(rng.nextParallelRNG(getBlockData(data).size() + stackMax + stackMin), data)); } public CNG getGenerator(RNG rng, IrisData data) { - CNG cached = layerGenerator.getIfPresent(); - - if (cached != null) { - return cached; - } - - return layerGenerator.aquire(() -> style.create(rng.nextParallelRNG(getBlockData(data).size()), data)); + AtomicCache cache = generators(rng, data).layer; + CNG cached = cache.getIfPresent(); + return cached != null ? cached : cache.aquire(() -> style.create(rng.nextParallelRNG(getBlockData(data).size()), data)); } public CNG getVarianceGenerator(RNG rng, IrisData data) { - CNG cached = varianceGenerator.getIfPresent(); - return cached != null ? cached : buildVarianceGeneratorOnce(rng, data); + AtomicCache cache = generators(rng, data).variance; + CNG cached = cache.getIfPresent(); + return cached != null ? cached : cache.aquire(() -> createVarianceGenerator(rng, data)); } - private CNG buildVarianceGeneratorOnce(RNG rng, IrisData data) { - return varianceGenerator.aquire(() -> createVarianceGenerator(rng, data)); + private Generators generators(RNG rng, IrisData data) { + long seed = rng.getSeed(); + Engine engine = data == null ? null : data.getEngine(); + Generators recent = recentGenerators; + if (recent != null && recent.key.seed == seed && recent.key.data == data && recent.key.engine == engine) { + return recent; + } + Generators selected = generators.computeIfAbsent(new GeneratorKey(seed, data, engine), Generators::new); + recentGenerators = selected; + return selected; } private CNG createVarianceGenerator(RNG rng, IrisData data) { @@ -358,4 +360,29 @@ public class IrisDecorator { public boolean isStacking() { return getStackMax() > 1; } + + private static final class Generators { + private final GeneratorKey key; + private final AtomicCache layer = new AtomicCache<>(); + private final AtomicCache variance = new AtomicCache<>(); + private final AtomicCache height = new AtomicCache<>(); + + private Generators(GeneratorKey key) { + this.key = key; + } + } + + private record GeneratorKey(long seed, IrisData data, Engine engine) { + @Override + public boolean equals(Object other) { + return other instanceof GeneratorKey key + && seed == key.seed && data == key.data && engine == key.engine; + } + + @Override + public int hashCode() { + return 31 * (31 * Long.hashCode(seed) + System.identityHashCode(data)) + + System.identityHashCode(engine); + } + } } diff --git a/core/src/main/java/art/arcane/iris/generation/decoration/IrisDepositGenerator.java b/core/src/main/java/art/arcane/iris/generation/decoration/IrisDepositGenerator.java index ffc7dd39b..681f823e8 100644 --- a/core/src/main/java/art/arcane/iris/generation/decoration/IrisDepositGenerator.java +++ b/core/src/main/java/art/arcane/iris/generation/decoration/IrisDepositGenerator.java @@ -39,6 +39,8 @@ import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KSet; import art.arcane.volmlib.util.math.BlockPosition; import art.arcane.volmlib.util.math.RNG; +import java.util.LinkedHashSet; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import lombok.AllArgsConstructor; @@ -269,7 +271,7 @@ public class IrisDepositGenerator { return new IrisObject(1, 1, 1); } - KSet cells = new KSet<>(); + Set cells = new LinkedHashSet<>(); vanillaEllipsoidCells(rng, size, (x, y, z) -> cells.add(new BlockPosition(x, y, z))); return objectFromCells(cells, rng, rdata); } @@ -407,7 +409,7 @@ public class IrisDepositGenerator { } IrisObject generateVanillaScattered(RNG rng, IrisData rdata, int size) { - KSet cells = new KSet<>(); + Set cells = new LinkedHashSet<>(); int candidates = rng.nextInt(Math.max(0, size) + 1); for (int i = 0; i < candidates; i++) { int magnitude = Math.min(i, 7); @@ -419,7 +421,7 @@ public class IrisDepositGenerator { return objectFromCells(cells, rng, rdata); } - private IrisObject objectFromCells(KSet cells, RNG rng, IrisData rdata) { + private IrisObject objectFromCells(Set cells, RNG rng, IrisData rdata) { if (cells.isEmpty()) { return new IrisObject(1, 1, 1); } diff --git a/core/src/main/java/art/arcane/iris/generation/decoration/IrisEngineDecorator.java b/core/src/main/java/art/arcane/iris/generation/decoration/IrisEngineDecorator.java index c0005c41b..0a4fdb94d 100644 --- a/core/src/main/java/art/arcane/iris/generation/decoration/IrisEngineDecorator.java +++ b/core/src/main/java/art/arcane/iris/generation/decoration/IrisEngineDecorator.java @@ -34,6 +34,10 @@ public abstract class IrisEngineDecorator extends EngineAssignedComponent implem this.part = part; } + public static long seedForPart(long baseSeed, IrisDecorationPart part) { + return DecoratorCore.partSeed(baseSeed, part); + } + @BlockCoordinates protected RNG getRNG(int x, int z) { long seed = DecoratorCore.partSeed(getSeed(), part); diff --git a/core/src/main/java/art/arcane/iris/generation/decoration/IrisOreGenerator.java b/core/src/main/java/art/arcane/iris/generation/decoration/IrisOreGenerator.java index a5cd15539..b5eeff86f 100644 --- a/core/src/main/java/art/arcane/iris/generation/decoration/IrisOreGenerator.java +++ b/core/src/main/java/art/arcane/iris/generation/decoration/IrisOreGenerator.java @@ -24,7 +24,8 @@ import art.arcane.iris.generation.terrain.IrisMaterialPalette; import art.arcane.iris.pack.value.IrisRange; import art.arcane.iris.pack.loading.IrisData; -import art.arcane.volmlib.util.cache.AtomicCache; +import art.arcane.iris.generation.cache.LazyBoundedCache; +import art.arcane.iris.generation.runtime.Engine; import art.arcane.volmlib.util.documentation.Description; import art.arcane.iris.pack.schema.annotation.MaxNumber; import art.arcane.iris.pack.schema.annotation.MinNumber; @@ -55,10 +56,11 @@ public class IrisOreGenerator { @Description("Vertical band (min, max) this ore can generate in, in engine-local Y where 0 is the bottom of the dimension, not world Y.") private IrisRange range = new IrisRange(30, 80); - private transient AtomicCache chanceCache = new AtomicCache<>(); + private final transient LazyBoundedCache chanceCache = new LazyBoundedCache<>(32); + private transient volatile CachedChance recentChance; public void warm(RNG rng, IrisData data) { - chanceCache.aquire(() -> OreChance.of(chanceStyle.create(rng, data))); + chance(rng, data); palette.getLayerGenerator(rng, data); } @@ -71,18 +73,43 @@ public class IrisOreGenerator { return null; } - OreChance chance = chanceCache.getIfPresent(); - if (chance == null) { - chance = chanceCache.aquire(() -> OreChance.of(chanceStyle.create(rng, data))); - } - - if (chance.sample(x, y, z) > threshold) { + if (chance(rng, data).sample(x, y, z) > threshold) { return null; } return palette.get(rng, x, y, z, data); } + private OreChance chance(RNG rng, IrisData data) { + long seed = rng.getSeed(); + Engine engine = data == null ? null : data.getEngine(); + CachedChance recent = recentChance; + if (recent != null && recent.key.seed == seed && recent.key.data == data && recent.key.engine == engine) { + return recent.chance; + } + ChanceKey key = new ChanceKey(seed, data, engine); + OreChance selected = chanceCache.computeIfAbsent(key, ignored -> OreChance.of(chanceStyle.create(rng, data))); + recentChance = new CachedChance(key, selected); + return selected; + } + + private record ChanceKey(long seed, IrisData data, Engine engine) { + @Override + public boolean equals(Object other) { + return other instanceof ChanceKey key + && seed == key.seed && data == key.data && engine == key.engine; + } + + @Override + public int hashCode() { + return 31 * (31 * Long.hashCode(seed) + System.identityHashCode(data)) + + System.identityHashCode(engine); + } + } + + private record CachedChance(ChanceKey key, OreChance chance) { + } + /** * The chance field, with its single value resolved once when it is constant (a FLAT style), * which bands such as a full deepslate layer sample at every block. diff --git a/core/src/main/java/art/arcane/iris/generation/decoration/IrisSeaFloorDecorator.java b/core/src/main/java/art/arcane/iris/generation/decoration/IrisSeaFloorDecorator.java index 2d8b98482..da820ab7d 100644 --- a/core/src/main/java/art/arcane/iris/generation/decoration/IrisSeaFloorDecorator.java +++ b/core/src/main/java/art/arcane/iris/generation/decoration/IrisSeaFloorDecorator.java @@ -53,7 +53,7 @@ public class IrisSeaFloorDecorator extends IrisEngineDecorator { && !decorator.getSlopeCondition().isValid(getComplex().getSlopeStream().get(realX, realZ))) { return; } - NativeBlockState block = decorator.getBlockData100(biome, rng, realX, height, realZ, getData()); + NativeBlockState block = decorator.getBlockData100(biome, partRNG, realX, height, realZ, getData()); if (block != null) { data.set(x, height, z, block); } @@ -61,13 +61,13 @@ public class IrisSeaFloorDecorator extends IrisEngineDecorator { } int stack = Math.min(limit - height, - DecoratorCore.computeStack(decorator, rng, realX, realZ, getData(), max - height)); + DecoratorCore.computeStack(decorator, partRNG, realX, realZ, getData(), max - height)); for (int i = 0; i < stack; i++) { int h = height + i; double threshold = stack == 1 ? 1.0 : ((double) i) / (stack - 1); NativeBlockState block = threshold >= decorator.getTopThreshold() - ? decorator.getBlockDataForTop(biome, rng, realX, h, realZ, getData()) - : decorator.getBlockData100(biome, rng, realX, h, realZ, getData()); + ? decorator.getBlockDataForTop(biome, partRNG, realX, h, realZ, getData()) + : decorator.getBlockData100(biome, partRNG, realX, h, realZ, getData()); if (block == null) { break; } diff --git a/core/src/main/java/art/arcane/iris/generation/decoration/IrisSeaSurfaceDecorator.java b/core/src/main/java/art/arcane/iris/generation/decoration/IrisSeaSurfaceDecorator.java index 1687638bc..1bf09b1cb 100644 --- a/core/src/main/java/art/arcane/iris/generation/decoration/IrisSeaSurfaceDecorator.java +++ b/core/src/main/java/art/arcane/iris/generation/decoration/IrisSeaSurfaceDecorator.java @@ -51,7 +51,7 @@ public class IrisSeaSurfaceDecorator extends IrisEngineDecorator { if (!decorator.isStacking()) { int targetY = height + 1; if (DecoratorCore.canReplaceStackTarget(data.get(x, targetY, z), false)) { - NativeBlockState block = decorator.getBlockData100(biome, rng, realX, height, realZ, getData()); + NativeBlockState block = decorator.getBlockData100(biome, partRNG, realX, height, realZ, getData()); if (block != null) { data.set(x, targetY, z, block); } @@ -60,7 +60,7 @@ public class IrisSeaSurfaceDecorator extends IrisEngineDecorator { } int stack = Math.min(maxBaseY - height, - DecoratorCore.computeStack(decorator, rng, realX, realZ, getData(), max - height)); + DecoratorCore.computeStack(decorator, partRNG, realX, realZ, getData(), max - height)); for (int i = 0; i < stack; i++) { int h = height + i; int targetY = h + 1; @@ -69,8 +69,8 @@ public class IrisSeaSurfaceDecorator extends IrisEngineDecorator { } double threshold = stack == 1 ? 1.0 : ((double) i) / (stack - 1); NativeBlockState block = threshold >= decorator.getTopThreshold() - ? decorator.getBlockDataForTop(biome, rng, realX, h, realZ, getData()) - : decorator.getBlockData100(biome, rng, realX, h, realZ, getData()); + ? decorator.getBlockDataForTop(biome, partRNG, realX, h, realZ, getData()) + : decorator.getBlockData100(biome, partRNG, realX, h, realZ, getData()); if (block == null) { break; } diff --git a/core/src/main/java/art/arcane/iris/generation/decoration/IrisShoreLineDecorator.java b/core/src/main/java/art/arcane/iris/generation/decoration/IrisShoreLineDecorator.java index 918c66c00..d85cace71 100644 --- a/core/src/main/java/art/arcane/iris/generation/decoration/IrisShoreLineDecorator.java +++ b/core/src/main/java/art/arcane/iris/generation/decoration/IrisShoreLineDecorator.java @@ -102,7 +102,7 @@ public class IrisShoreLineDecorator extends IrisEngineDecorator { || !DecoratorCore.canReplaceStackTarget(data.get(x, targetY, z), false)) { return; } - NativeBlockState block = decorator.getBlockData100(biome, rng, realX, height, realZ, getData()); + NativeBlockState block = decorator.getBlockData100(biome, partRNG, realX, height, realZ, getData()); if (block != null && DecoratorCore.isValidShorelineSupport(decorator, block, support) && IrisSugarCane.canPlace(block, data, x, targetY, z, realX, realZ, getEngine())) { data.set(x, targetY, z, block); @@ -111,7 +111,7 @@ public class IrisShoreLineDecorator extends IrisEngineDecorator { return; } - int stack = decorator.getHeight(rng, realX, realZ, getData()); + int stack = decorator.getHeight(partRNG, realX, realZ, getData()); if (decorator.isScaleStack()) { stack = (int) Math.ceil((double) (max - height) * ((double) stack / 100)); } else { @@ -124,7 +124,7 @@ public class IrisShoreLineDecorator extends IrisEngineDecorator { return; } - NativeBlockState block = decorator.getBlockDataForTop(biome, rng, realX, height, realZ, getData()); + NativeBlockState block = decorator.getBlockDataForTop(biome, partRNG, realX, height, realZ, getData()); if (block != null && DecoratorCore.isValidShorelineSupport(decorator, block, support) && IrisSugarCane.canPlace(block, data, x, targetY, z, realX, realZ, getEngine())) { data.set(x, targetY, z, block); @@ -142,8 +142,8 @@ public class IrisShoreLineDecorator extends IrisEngineDecorator { } double threshold = ((double) i) / (stack - 1); NativeBlockState block = threshold >= decorator.getTopThreshold() - ? decorator.getBlockDataForTop(biome, rng, realX, h, realZ, getData()) - : decorator.getBlockData100(biome, rng, realX, h, realZ, getData()); + ? decorator.getBlockDataForTop(biome, partRNG, realX, h, realZ, getData()) + : decorator.getBlockData100(biome, partRNG, realX, h, realZ, getData()); if (block == null) { break; } diff --git a/core/src/main/java/art/arcane/iris/generation/decoration/IrisSurfaceDecorator.java b/core/src/main/java/art/arcane/iris/generation/decoration/IrisSurfaceDecorator.java index 97d20bba3..25a2599c9 100644 --- a/core/src/main/java/art/arcane/iris/generation/decoration/IrisSurfaceDecorator.java +++ b/core/src/main/java/art/arcane/iris/generation/decoration/IrisSurfaceDecorator.java @@ -86,13 +86,13 @@ public class IrisSurfaceDecorator extends IrisEngineDecorator { opts.fluidHeight = fluidHeight; opts.caveSkipFluid = caveSkipFluid; opts.mantle = getEngine().getMantle(); - DecoratorCore.placeStackUp(decorator, x, z, realX, realZ, height, max, data, rng, getData(), opts); + DecoratorCore.placeStackUp(decorator, x, z, realX, realZ, height, max, data, partRNG, getData(), opts); aquaticSnapshot.restoreIfUnsupported(data, x, z); return; } DecoratorCore.placeSurfaceSingle(decorator, x, z, realX, height, realZ, - data, rng, getData(), underwater, caveSkipFluid, getEngine().getMantle()); + data, partRNG, getData(), underwater, caveSkipFluid, getEngine().getMantle()); aquaticSnapshot.restoreIfUnsupported(data, x, z); } diff --git a/core/src/main/java/art/arcane/iris/generation/decoration/ruin/RuinDecoratorApplier.java b/core/src/main/java/art/arcane/iris/generation/decoration/ruin/RuinDecoratorApplier.java index 6a92d2adf..5893a98dd 100644 --- a/core/src/main/java/art/arcane/iris/generation/decoration/ruin/RuinDecoratorApplier.java +++ b/core/src/main/java/art/arcane/iris/generation/decoration/ruin/RuinDecoratorApplier.java @@ -25,6 +25,7 @@ import art.arcane.volmlib.util.math.Vector3i; import art.arcane.volmlib.util.math.RNG; import java.util.ArrayList; +import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -42,6 +43,8 @@ final class RuinDecoratorApplier { case BASE_SCATTER -> baseScatterCandidates(canvas, Math.max(1, decorator.getScatterRadius())); }; + candidates.sort(Comparator.comparingInt(Vector3i::getX) + .thenComparingInt(Vector3i::getY).thenComparingInt(Vector3i::getZ)); RNG paletteRng = new RNG(seed); for (Vector3i v : candidates) { if (!rng.chance(decorator.getChance())) { diff --git a/core/src/main/java/art/arcane/iris/generation/decoration/tree/TreeBlockCanvas.java b/core/src/main/java/art/arcane/iris/generation/decoration/tree/TreeBlockCanvas.java index 393def2a5..f4f85f48e 100644 --- a/core/src/main/java/art/arcane/iris/generation/decoration/tree/TreeBlockCanvas.java +++ b/core/src/main/java/art/arcane/iris/generation/decoration/tree/TreeBlockCanvas.java @@ -24,7 +24,15 @@ import java.util.Map; import java.util.Set; public class TreeBlockCanvas { - public record Vec(int x, int y, int z) { + public record Vec(int x, int y, int z) implements Comparable { + @Override + public int compareTo(Vec other) { + int comparison = Integer.compare(x, other.x); + if (comparison == 0) { + comparison = Integer.compare(y, other.y); + } + return comparison == 0 ? Integer.compare(z, other.z) : comparison; + } } public enum Role { diff --git a/core/src/main/java/art/arcane/iris/generation/decoration/tree/TreeDecoratorApplier.java b/core/src/main/java/art/arcane/iris/generation/decoration/tree/TreeDecoratorApplier.java index 7ee57355e..c31cf448b 100644 --- a/core/src/main/java/art/arcane/iris/generation/decoration/tree/TreeDecoratorApplier.java +++ b/core/src/main/java/art/arcane/iris/generation/decoration/tree/TreeDecoratorApplier.java @@ -23,6 +23,9 @@ import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.math.RNG; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; import java.util.List; import java.util.Map; @@ -90,7 +93,7 @@ public final class TreeDecoratorApplier { private static void trunkSurface(TreeBlockCanvas canvas, IrisTreeDecorator dec, int idx, RNG rng) { int[][] sides = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; String[] facings = {"east", "west", "south", "north"}; - for (TreeBlockCanvas.Vec v : new KList<>(canvas.getTrunk())) { + for (TreeBlockCanvas.Vec v : sortedPositions(canvas.getTrunk())) { for (int i = 0; i < sides.length; i++) { int nx = v.x() + sides[i][0]; int nz = v.z() + sides[i][1]; @@ -105,7 +108,7 @@ public final class TreeDecoratorApplier { private static void leafSurface(TreeBlockCanvas canvas, IrisTreeDecorator dec, int idx, RNG rng) { int[][] sides = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; String[] facings = {"east", "west", "south", "north"}; - for (TreeBlockCanvas.Vec v : new KList<>(canvas.getLeaf())) { + for (TreeBlockCanvas.Vec v : sortedPositions(canvas.getLeaf())) { for (int i = 0; i < sides.length; i++) { int nx = v.x() + sides[i][0]; int nz = v.z() + sides[i][1]; @@ -118,7 +121,7 @@ public final class TreeDecoratorApplier { } private static void branchSurface(TreeBlockCanvas canvas, IrisTreeDecorator dec, int idx, RNG rng) { - for (TreeBlockCanvas.Vec v : new KList<>(canvas.getTrunk())) { + for (TreeBlockCanvas.Vec v : sortedPositions(canvas.getTrunk())) { int ay = v.y() + 1; if (canvas.has(v.x(), ay, v.z()) || rng.nextDouble() > dec.getChance()) { continue; @@ -136,7 +139,7 @@ public final class TreeDecoratorApplier { colTop.put(key, v.y()); } } - for (Map.Entry e : colTop.entrySet()) { + for (Map.Entry e : sortedColumns(colTop)) { int x = unpackX(e.getKey()); int z = unpackZ(e.getKey()); int aboveY = e.getValue() + 1; @@ -149,7 +152,7 @@ public final class TreeDecoratorApplier { private static void canopyBottom(TreeBlockCanvas canvas, IrisTreeDecorator dec, int idx, RNG rng) { KMap colBottom = columnLeafBottoms(canvas); - for (Map.Entry e : colBottom.entrySet()) { + for (Map.Entry e : sortedColumns(colBottom)) { int x = unpackX(e.getKey()); int z = unpackZ(e.getKey()); int belowY = e.getValue() - 1; @@ -163,7 +166,7 @@ public final class TreeDecoratorApplier { private static void canopyHang(TreeBlockCanvas canvas, IrisTreeDecorator dec, int idx, RNG rng) { int maxLen = Math.max(1, dec.getLength()); KMap colBottom = columnLeafBottoms(canvas); - for (Map.Entry e : colBottom.entrySet()) { + for (Map.Entry e : sortedColumns(colBottom)) { if (rng.nextDouble() > dec.getChance()) { continue; } @@ -182,7 +185,7 @@ public final class TreeDecoratorApplier { private static void trunkBase(TreeBlockCanvas canvas, IrisTreeDecorator dec, int idx, RNG rng) { int[][] sides = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}}; - for (TreeBlockCanvas.Vec v : new KList<>(canvas.getTrunk())) { + for (TreeBlockCanvas.Vec v : sortedPositions(canvas.getTrunk())) { if (v.y() != 0) { continue; } @@ -205,7 +208,7 @@ public final class TreeDecoratorApplier { if (maxY == Integer.MIN_VALUE) { return; } - for (TreeBlockCanvas.Vec v : new KList<>(canvas.getTrunk())) { + for (TreeBlockCanvas.Vec v : sortedPositions(canvas.getTrunk())) { if (v.y() != maxY) { continue; } @@ -269,6 +272,18 @@ public final class TreeDecoratorApplier { return colBottom; } + private static List sortedPositions(Collection positions) { + List sorted = new ArrayList<>(positions); + sorted.sort(null); + return sorted; + } + + private static List> sortedColumns(Map columns) { + List> sorted = new ArrayList<>(columns.entrySet()); + sorted.sort(Comparator.comparingLong(Map.Entry::getKey)); + return sorted; + } + private static String facingAway(int cx, int cz, int x, int z) { int dx = x - cx; int dz = z - cz; diff --git a/core/src/main/java/art/arcane/iris/generation/decoration/tree/TreeSupport.java b/core/src/main/java/art/arcane/iris/generation/decoration/tree/TreeSupport.java index da01e4a4c..d88a8541c 100644 --- a/core/src/main/java/art/arcane/iris/generation/decoration/tree/TreeSupport.java +++ b/core/src/main/java/art/arcane/iris/generation/decoration/tree/TreeSupport.java @@ -44,15 +44,13 @@ public final class TreeSupport { int best = Integer.MAX_VALUE; for (TreeBlockCanvas.Vec wood : canvas.getTrunk()) { int cd = chebyshev(leaf, wood); - if (cd < best) { + if (cd < best || cd == best && nearest != null && wood.compareTo(nearest) < 0) { best = cd; nearest = wood; - if (cd <= 1) { - break; - } } } - if (nearest != null && best > worstGap) { + if (nearest != null && (best > worstGap + || best == worstGap && worstLeaf != null && leaf.compareTo(worstLeaf) < 0)) { worstGap = best; worstLeaf = leaf; worstWood = nearest; diff --git a/core/src/main/java/art/arcane/iris/generation/noise/IrisExpression.java b/core/src/main/java/art/arcane/iris/generation/noise/IrisExpression.java index cdb5e67a1..8830d1f46 100644 --- a/core/src/main/java/art/arcane/iris/generation/noise/IrisExpression.java +++ b/core/src/main/java/art/arcane/iris/generation/noise/IrisExpression.java @@ -24,6 +24,10 @@ import com.dfsek.paralithic.eval.parser.Parser; import com.dfsek.paralithic.eval.parser.Scope; import art.arcane.iris.pack.loading.IrisRegistrant; import art.arcane.volmlib.util.cache.AtomicCache; +import art.arcane.iris.generation.cache.LazyBoundedCache; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.Setter; import art.arcane.iris.generation.noise.IrisExpressionFunction.FunctionContext; import art.arcane.iris.pack.schema.annotation.ArrayType; import art.arcane.volmlib.util.documentation.Description; @@ -58,7 +62,9 @@ public class IrisExpression extends IrisRegistrant { private String expression; private transient AtomicCache expressionCache = new AtomicCache<>(); - private transient AtomicCache> streamCache = new AtomicCache<>(); + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private final transient LazyBoundedCache> streams = new LazyBoundedCache<>(8); private Expression expression() { return expressionCache.aquire(() -> { @@ -94,8 +100,11 @@ public class IrisExpression extends IrisRegistrant { } public ProceduralStream stream(RNG rng) { - return streamCache.aquire(() -> ProceduralStream.of((x, z) -> evaluate(rng, x, z), - (x, y, z) -> evaluate(rng, x, y, z), Interpolated.DOUBLE)); + return streams.computeIfAbsent(rng.getSeed(), seed -> { + RNG streamRng = new RNG(seed); + return ProceduralStream.of((x, z) -> evaluate(streamRng, x, z), + (x, y, z) -> evaluate(streamRng, x, y, z), Interpolated.DOUBLE); + }); } public double evaluate(RNG rng, double x, double z) { diff --git a/core/src/main/java/art/arcane/iris/generation/noise/IrisExpressionFunction.java b/core/src/main/java/art/arcane/iris/generation/noise/IrisExpressionFunction.java index 09203f148..d831ec99b 100644 --- a/core/src/main/java/art/arcane/iris/generation/noise/IrisExpressionFunction.java +++ b/core/src/main/java/art/arcane/iris/generation/noise/IrisExpressionFunction.java @@ -10,7 +10,11 @@ import art.arcane.volmlib.util.documentation.Description; import art.arcane.iris.pack.schema.annotation.MinNumber; import art.arcane.iris.pack.schema.annotation.Required; import art.arcane.iris.pack.schema.annotation.Snippet; -import art.arcane.volmlib.util.collection.KMap; +import art.arcane.iris.generation.cache.LazyBoundedCache; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.volmlib.util.stream.ProceduralStream; + +import java.util.Objects; import art.arcane.volmlib.util.math.RNG; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -48,7 +52,10 @@ public class IrisExpressionFunction implements DynamicFunction { @Getter(AccessLevel.NONE) @Setter(AccessLevel.NONE) - private transient final KMap cache = new KMap<>(); + private transient final LazyBoundedCache providers = new LazyBoundedCache<>(8); + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private transient volatile CachedProvider recentProvider; private transient IrisData data; public boolean isValid() { @@ -74,19 +81,53 @@ public class IrisExpressionFunction implements DynamicFunction { @Override public double eval(@Nullable Context raw, double... args) { - return cache.computeIfAbsent((FunctionContext) raw, context -> { - assert context != null; - if (engineStreamValue != null) { - var stream = engineStreamValue.get(data.getEngine()); - return d -> stream.get(d[0], d[1]); - } + FunctionContext context = Objects.requireNonNull((FunctionContext) raw, "Expression context"); + IrisData currentData = data; + Engine engine = currentData == null ? null : currentData.getEngine(); + long seed = context.rng.getSeed(); + CachedProvider recent = recentProvider; + if (recent != null && recent.key.matches(seed, currentData, engine)) { + return recent.provider.eval(args); + } + FunctionKey key = new FunctionKey(seed, currentData, engine); + Provider provider = providers.computeIfAbsent(key, ignored -> createProvider(key)); + recentProvider = new CachedProvider(key, provider); + return provider.eval(args); + } - if (styleValue != null) { - return styleValue.createNoCache(context.rng, data)::noise; + private Provider createProvider(FunctionKey key) { + if (engineStreamValue != null) { + if (key.engine == null) { + throw new IllegalStateException("Expression function '" + name + "' requires an active Iris engine."); } + ProceduralStream stream = engineStreamValue.get(key.engine); + return coordinates -> stream.get(coordinates[0], coordinates[1]); + } + if (styleValue != null) { + return styleValue.create(new RNG(key.seed), key.data, key.engine)::noise; + } + return coordinates -> Double.NaN; + } - return d -> Double.NaN; - }).eval(args); + private record FunctionKey(long seed, IrisData data, Engine engine) { + private boolean matches(long seed, IrisData data, Engine engine) { + return this.seed == seed && this.data == data && this.engine == engine; + } + + @Override + public boolean equals(Object object) { + return object instanceof FunctionKey other && matches(other.seed, other.data, other.engine); + } + + @Override + public int hashCode() { + int hash = Long.hashCode(seed); + hash = 31 * hash + System.identityHashCode(data); + return 31 * hash + System.identityHashCode(engine); + } + } + + private record CachedProvider(FunctionKey key, Provider provider) { } public record FunctionContext(@NonNull RNG rng) implements Context { diff --git a/core/src/main/java/art/arcane/iris/generation/noise/IrisGenerator.java b/core/src/main/java/art/arcane/iris/generation/noise/IrisGenerator.java index 8d6de0f48..3b75b7578 100644 --- a/core/src/main/java/art/arcane/iris/generation/noise/IrisGenerator.java +++ b/core/src/main/java/art/arcane/iris/generation/noise/IrisGenerator.java @@ -19,7 +19,7 @@ package art.arcane.iris.generation.noise; import art.arcane.iris.pack.loading.IrisRegistrant; -import art.arcane.volmlib.util.cache.AtomicCache; +import art.arcane.iris.generation.cache.LazyBoundedCache; import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.pack.schema.annotation.ArrayType; @@ -54,7 +54,12 @@ public class IrisGenerator extends IrisRegistrant { private static final int SURFACE_CACHE_STRIPES = 16; private static final int SURFACE_CACHE_STRIPE_SIZE = 1024; - private final transient AtomicCache cellGen = new AtomicCache<>(); + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private final transient LazyBoundedCache cellGenerators = new LazyBoundedCache<>(8); + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private transient volatile CachedCellGenerator recentCellGenerator; @Getter(AccessLevel.NONE) @Setter(AccessLevel.NONE) private transient volatile SurfaceCache surfaceSamples; @@ -115,7 +120,17 @@ public class IrisGenerator extends IrisRegistrant { } public CellGenerator getCellGenerator(long seed) { - return cellGen.aquire(() -> new CellGenerator(new RNG(seed + 239466))); + CachedCellGenerator recent = recentCellGenerator; + if (recent != null && recent.seed == seed) { + return recent.generator; + } + CellGenerator generator = cellGenerators.computeIfAbsent(seed, ignored -> { + CellGenerator created = new CellGenerator(new RNG(seed + 239466)); + created.setShuffle(getCellFractureShuffle()); + return created; + }); + recentCellGenerator = new CachedCellGenerator(seed, generator); + return generator; } public T fit(T[] v, long superSeed, double rx, double rz) { @@ -296,7 +311,6 @@ public class IrisGenerator extends IrisRegistrant { } public double cell(double rx, double rz, double v, double superSeed) { - getCellGenerator(getSeed() + 46222).setShuffle(getCellFractureShuffle()); double fractureX = rx / getCellFractureZoom(); double fractureZ = rz / getCellFractureZoom(); return getCellGenerator(getSeed() + 46222).getDistance(fractureX, fractureZ) > getCellPercentSize() ? (v * getCellFractureHeight()) : v; @@ -363,4 +377,7 @@ public class IrisGenerator extends IrisRegistrant { entries.set(slot, sample); } } + + private record CachedCellGenerator(long seed, CellGenerator generator) { + } } diff --git a/core/src/main/java/art/arcane/iris/generation/noise/IrisGeneratorStyle.java b/core/src/main/java/art/arcane/iris/generation/noise/IrisGeneratorStyle.java index 7ae67270a..8f5ea4261 100644 --- a/core/src/main/java/art/arcane/iris/generation/noise/IrisGeneratorStyle.java +++ b/core/src/main/java/art/arcane/iris/generation/noise/IrisGeneratorStyle.java @@ -58,6 +58,9 @@ public class IrisGeneratorStyle { @Setter(AccessLevel.NONE) private final transient LazyBoundedCache generatorCache = new LazyBoundedCache<>(GENERATOR_CACHE_SIZE); + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private transient volatile CachedGenerator recentGenerator; @Description("The base noise style. Used when neither expression nor imageMap is set; a failed expression also falls back to this style.") private NoiseStyle style = NoiseStyle.FLAT; @@ -124,7 +127,7 @@ public class IrisGeneratorStyle { } private int hash() { - return Objects.hash(expression, imageMapHash(), multiplier, fracture != null ? fracture.hash() : 0, exponent, cacheSize, zoom, cellularZoom, cellularFrequency, style); + return Objects.hash(expression, imageMapHash(), multiplier, fracture != null ? fracture.hash() : 0, exponent, cacheSize, zoom, cellularZoom, cellularFrequency, style == null ? null : style.name()); } public int prebakeSignature() { @@ -233,10 +236,24 @@ public class IrisGeneratorStyle { return create(rng, data, resolveEngine(data)); } + public CNG create(long seed, IrisData data) { + return cachedGenerator(seed, data, resolveEngine(data)); + } + public CNG create(RNG rng, IrisData data, Engine engine) { - GeneratorCacheKey key = new GeneratorCacheKey(data, engine, rng.getSeed()); - return generatorCache.computeIfAbsent(key, - ignored -> createNoCache(rng, data, 1, 0, false, engine)); + return cachedGenerator(rng.getSeed(), data, engine); + } + + private CNG cachedGenerator(long seed, IrisData data, Engine engine) { + CachedGenerator recent = recentGenerator; + if (recent != null && recent.key.matches(data, engine, seed)) { + return recent.generator; + } + GeneratorCacheKey key = new GeneratorCacheKey(data, engine, seed); + CNG generator = generatorCache.computeIfAbsent(key, + ignored -> createNoCache(new RNG(seed), data, 1, 0, false, engine)); + recentGenerator = new CachedGenerator(key, generator); + return generator; } @SuppressWarnings("BooleanMethodIsAlwaysInverted") @@ -295,6 +312,10 @@ public class IrisGeneratorStyle { this.seed = seed; } + private boolean matches(IrisData data, Engine engine, long seed) { + return this.data == data && this.engine == engine && this.seed == seed; + } + @Override public boolean equals(Object other) { if (this == other) { @@ -313,4 +334,7 @@ public class IrisGeneratorStyle { return 31 * result + Long.hashCode(seed); } } + + private record CachedGenerator(GeneratorCacheKey key, CNG generator) { + } } diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/GenerationCacheWarmer.java b/core/src/main/java/art/arcane/iris/generation/runtime/GenerationCacheWarmer.java index 2a0400860..125243be7 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/GenerationCacheWarmer.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/GenerationCacheWarmer.java @@ -21,6 +21,7 @@ package art.arcane.iris.generation.runtime; import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.decoration.IrisDecorator; +import art.arcane.iris.generation.decoration.IrisEngineDecorator; import art.arcane.iris.structure.object.IrisObjectPlacement; import art.arcane.iris.generation.decoration.IrisOreGenerator; import art.arcane.iris.generation.decoration.IrisProceduralObjects; @@ -47,8 +48,8 @@ public final class GenerationCacheWarmer { biomes.sort(Comparator.comparing(IrisBiome::getLoadKey)); for (IrisBiome biome : biomes) { warmPlacements(biome.getObjects(), root, counter, data, engine); - warmDecorators(biome.getDecorators(), root, counter, data); - warmOres(biome.getOres(), root, counter, data); + warmDecorators(biome.getDecorators(), engine.getSeedManager().getComponent(), counter, data); + warmOres(biome.getOres(), engine.getSeedManager().getTerrain(), counter, data); warmProcedural(biome.getProceduralObjects(), root, counter, data); } @@ -56,11 +57,11 @@ public final class GenerationCacheWarmer { regions.sort(Comparator.comparing(IrisRegion::getLoadKey)); for (IrisRegion region : regions) { warmPlacements(region.getObjects(), root, counter, data, engine); - warmOres(region.getOres(), root, counter, data); + warmOres(region.getOres(), engine.getSeedManager().getTerrain(), counter, data); warmProcedural(region.getProceduralObjects(), root, counter, data); } - warmOres(engine.getDimension().getOres(), root, counter, data); + warmOres(engine.getDimension().getOres(), engine.getSeedManager().getTerrain(), counter, data); IrisLogging.debug("[IrisEngine timing] cache warm " + counter[0] + " configs=" + (M.ms() - start) + "ms"); } @@ -80,7 +81,7 @@ public final class GenerationCacheWarmer { } } - private static void warmDecorators(KList decorators, RNG root, int[] counter, IrisData data) { + private static void warmDecorators(KList decorators, long componentSeed, int[] counter, IrisData data) { if (decorators == null) { return; } @@ -88,14 +89,15 @@ public final class GenerationCacheWarmer { if (decorator == null) { continue; } - RNG rng = root.nextParallelRNG(counter[0]++); + counter[0]++; + RNG rng = new RNG(IrisEngineDecorator.seedForPart(componentSeed, decorator.getPartOf())); decorator.getHeightGenerator(rng, data); decorator.getGenerator(rng, data); decorator.getVarianceGenerator(rng, data); } } - private static void warmOres(KList ores, RNG root, int[] counter, IrisData data) { + private static void warmOres(KList ores, long terrainSeed, int[] counter, IrisData data) { if (ores == null) { return; } @@ -103,7 +105,8 @@ public final class GenerationCacheWarmer { if (ore == null) { continue; } - ore.warm(root.nextParallelRNG(counter[0]++), data); + counter[0]++; + ore.warm(new RNG(terrainSeed), data); } } diff --git a/core/src/main/java/art/arcane/iris/generation/terrain/IrisDimension.java b/core/src/main/java/art/arcane/iris/generation/terrain/IrisDimension.java index 56db9deec..650cf2bd4 100644 --- a/core/src/main/java/art/arcane/iris/generation/terrain/IrisDimension.java +++ b/core/src/main/java/art/arcane/iris/generation/terrain/IrisDimension.java @@ -67,6 +67,7 @@ import art.arcane.iris.pack.datapack.DataVersion; import art.arcane.iris.pack.datapack.IDataFixer; import art.arcane.iris.pack.datapack.IDataFixer.Dimension; import art.arcane.volmlib.util.cache.AtomicCache; +import art.arcane.iris.generation.cache.LazyBoundedCache; import art.arcane.iris.world.history.GenerationRegistryContractFactory; import art.arcane.iris.pack.schema.annotation.ArrayType; import art.arcane.volmlib.util.documentation.Description; @@ -89,6 +90,9 @@ import art.arcane.volmlib.util.math.Position2; import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.noise.CNG; import lombok.Data; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.Setter; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import lombok.ToString; @@ -130,7 +134,12 @@ public class IrisDimension extends IrisRegistrant { private final transient AtomicCache staticObjectLayer = new AtomicCache<>(); private final transient AtomicCache rockLayerGenerator = new AtomicCache<>(); private final transient AtomicCache fluidLayerGenerator = new AtomicCache<>(); - private final transient AtomicCache coordFracture = new AtomicCache<>(); + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private final transient LazyBoundedCache coordinateFractures = new LazyBoundedCache<>(8); + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private transient volatile CachedCoordinateFracture recentCoordinateFracture; private final transient AtomicCache sinr = new AtomicCache<>(); private final transient AtomicCache cosr = new AtomicCache<>(); private final transient AtomicCache rad = new AtomicCache<>(); @@ -537,12 +546,15 @@ public class IrisDimension extends IrisRegistrant { } public CNG getCoordFracture(RNG rng, int signature) { - return coordFracture.aquire(() -> - { - CNG coordFracture = CNG.signature(rng.nextParallelRNG(signature)); - coordFracture.scale(0.012 / coordFractureZoom); - return coordFracture; - }); + long seed = rng.getSeed() + signature; + CachedCoordinateFracture recent = recentCoordinateFracture; + if (recent != null && recent.seed == seed) { + return recent.generator; + } + CNG generator = coordinateFractures.computeIfAbsent(seed, ignored -> + CNG.signature(new RNG(seed)).scale(0.012 / coordFractureZoom)); + recentCoordinateFracture = new CachedCoordinateFracture(seed, generator); + return generator; } public double getDimensionAngle() { @@ -1301,4 +1313,7 @@ public class IrisDimension extends IrisRegistrant { IO.writeAll(dimTypeVanilla, json); } } + + private record CachedCoordinateFracture(long seed, CNG generator) { + } } diff --git a/core/src/main/java/art/arcane/iris/generation/terrain/IrisMaterialPalette.java b/core/src/main/java/art/arcane/iris/generation/terrain/IrisMaterialPalette.java index b11222afb..e8fd30193 100644 --- a/core/src/main/java/art/arcane/iris/generation/terrain/IrisMaterialPalette.java +++ b/core/src/main/java/art/arcane/iris/generation/terrain/IrisMaterialPalette.java @@ -67,7 +67,6 @@ public class IrisMaterialPalette implements StructurePalette { @Getter(AccessLevel.NONE) @Setter(AccessLevel.NONE) private final transient AtomicReference recentLayerGenerator = new AtomicReference<>(); - private final transient AtomicCache heightGenerator = new AtomicCache<>(); @Description("The style of noise") private IrisGeneratorStyle style = NoiseStyle.STATIC.style(); @MinNumber(0.0001) diff --git a/core/src/main/java/art/arcane/iris/structure/object/IrisObject.java b/core/src/main/java/art/arcane/iris/structure/object/IrisObject.java index 54c99ec6a..c489d8d90 100644 --- a/core/src/main/java/art/arcane/iris/structure/object/IrisObject.java +++ b/core/src/main/java/art/arcane/iris/structure/object/IrisObject.java @@ -138,6 +138,7 @@ public class IrisObject extends IrisRegistrant { @Getter @Setter protected transient volatile boolean smartBored = false; + transient volatile IrisObjectShaping.SmartBoreVariant smartBoreVariant; @Setter protected transient AtomicCache aabb = new AtomicCache<>(); transient final AtomicCache> surfaceSupportOffsets = new AtomicCache<>(); @@ -335,7 +336,8 @@ public class IrisObject extends IrisRegistrant { } public int place(int x, int yv, int z, IObjectPlacer oplacer, IrisObjectPlacement config, RNG rng, BiConsumer listener, CarveResult c, IrisData rdata) { - return new IrisObjectPlacementRunner(this).place(x, yv, z, oplacer, config, rng, listener, c, rdata); + IrisObject placedObject = config.isSmartBore() ? IrisObjectShaping.smartBoredVariant(this) : this; + return new IrisObjectPlacementRunner(placedObject).place(x, yv, z, oplacer, config, rng, listener, c, rdata); } KList getSurfaceSupportOffsets() { diff --git a/core/src/main/java/art/arcane/iris/structure/object/IrisObjectPlacementRunner.java b/core/src/main/java/art/arcane/iris/structure/object/IrisObjectPlacementRunner.java index aa44c295f..6f09fa225 100644 --- a/core/src/main/java/art/arcane/iris/structure/object/IrisObjectPlacementRunner.java +++ b/core/src/main/java/art/arcane/iris/structure/object/IrisObjectPlacementRunner.java @@ -49,6 +49,7 @@ import art.arcane.volmlib.util.math.Position2; import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.matter.MatterMarker; +import java.util.Comparator; import java.util.Objects; import java.util.Optional; import java.util.Set; @@ -123,10 +124,6 @@ final class IrisObjectPlacementRunner { } } - if (config.isSmartBore()) { - IrisObjectShaping.ensureSmartBored(self); - } - boolean warped = !config.getWarp().isFlat(); // Placement-invariant hoists off the per-voxel loop: the warp CNG resolution went // through a keyed cache probe per block, and the two boolean gates below cut per-block @@ -902,7 +899,9 @@ final class IrisObjectPlacementRunner { if (heightmap != null) { RNG rngx = rng.nextParallelRNG(3468854); - for (Position2 i : heightmap.k()) { + KList snowColumns = heightmap.k(); + snowColumns.sort(Comparator.comparingInt(Position2::getX).thenComparingInt(Position2::getZ)); + for (Position2 i : snowColumns) { int vx = i.getX(); int vy = heightmap.get(i); int vz = i.getZ(); diff --git a/core/src/main/java/art/arcane/iris/structure/object/IrisObjectShaping.java b/core/src/main/java/art/arcane/iris/structure/object/IrisObjectShaping.java index 4f48c1ffc..9a3dc9d6a 100644 --- a/core/src/main/java/art/arcane/iris/structure/object/IrisObjectShaping.java +++ b/core/src/main/java/art/arcane/iris/structure/object/IrisObjectShaping.java @@ -39,6 +39,41 @@ final class IrisObjectShaping { private IrisObjectShaping() { } + static IrisObject smartBoredVariant(IrisObject self) { + SmartBoreVariant cached = self.smartBoreVariant; + if (cached != null && cached.matches(self)) { + return cached.object(); + } + self.writeLock.lock(); + try { + cached = self.smartBoreVariant; + if (cached != null && cached.matches(self)) { + return cached.object(); + } + IrisObject variant = self.copy(); + ensureSmartBored(variant); + self.smartBoreVariant = new SmartBoreVariant(self.blocks, self.states, + self.blocks.modificationRevision(), self.states.modificationRevision(), + self.w, self.h, self.d, self.center.getX(), self.center.getY(), self.center.getZ(), variant); + return variant; + } finally { + self.writeLock.unlock(); + } + } + + record SmartBoreVariant(VectorMap blocks, VectorMap states, + long blockRevision, long stateRevision, int width, int height, int depth, + int centerX, int centerY, int centerZ, IrisObject object) { + boolean matches(IrisObject source) { + return blocks == source.blocks && states == source.states + && blockRevision == source.blocks.modificationRevision() + && stateRevision == source.states.modificationRevision() + && width == source.w && height == source.h && depth == source.d + && centerX == source.center.getX() && centerY == source.center.getY() + && centerZ == source.center.getZ(); + } + } + static void ensureSmartBored(IrisObject self) { if (self.smartBored) { return; diff --git a/core/src/test/java/art/arcane/iris/generation/biome/FloatingIslandMemoContextTest.java b/core/src/test/java/art/arcane/iris/generation/biome/FloatingIslandMemoContextTest.java new file mode 100644 index 000000000..515964bc6 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/biome/FloatingIslandMemoContextTest.java @@ -0,0 +1,52 @@ +package art.arcane.iris.generation.biome; + +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.volmlib.util.collection.KList; +import org.junit.After; +import org.junit.Test; + +import static org.junit.Assert.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class FloatingIslandMemoContextTest { + @After + public void clearMemo() { + FloatingIslandSample.clearThreadCaches(); + } + + @Test + public void rejectedColumnsAreCachedOnlyForTheirParentAndContext() { + FloatingIslandSample.clearThreadCaches(); + IrisBiome first = parent(); + IrisBiome second = parent(); + IrisData data = mock(IrisData.class); + Engine engine = mock(Engine.class); + FloatingIslandBoundarySampler boundary = new FloatingIslandBoundarySampler((x, z) -> first); + sample(first, 320, 7L, data, engine, boundary); + sample(first, 320, 7L, data, engine, boundary); + sample(second, 320, 7L, data, engine, boundary); + verify(first, times(1)).getFloatingChildBiomes(); + verify(second, times(1)).getFloatingChildBiomes(); + sample(first, 320, 9L, data, engine, boundary); + sample(first, 128, 9L, data, engine, boundary); + sample(first, 128, 9L, data, mock(Engine.class), boundary); + sample(first, 128, 9L, mock(IrisData.class), engine, boundary); + sample(first, 128, 9L, data, engine, new FloatingIslandBoundarySampler((x, z) -> first)); + verify(first, times(6)).getFloatingChildBiomes(); + } + + private static void sample(IrisBiome parent, int height, long seed, IrisData data, Engine engine, + FloatingIslandBoundarySampler boundary) { + assertNull(FloatingIslandSample.sampleMemoized(parent, -16, 32, height, seed, data, engine, boundary)); + } + + private static IrisBiome parent() { + IrisBiome parent = mock(IrisBiome.class); + when(parent.getFloatingChildBiomes()).thenReturn(new KList<>()); + return parent; + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/biome/IrisBiomeFieldSeedCohesionTest.java b/core/src/test/java/art/arcane/iris/generation/biome/IrisBiomeFieldSeedCohesionTest.java new file mode 100644 index 000000000..12453b944 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/biome/IrisBiomeFieldSeedCohesionTest.java @@ -0,0 +1,84 @@ +package art.arcane.iris.generation.biome; + +import art.arcane.iris.generation.noise.NoiseStyle; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.noise.CNG; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertSame; +import static org.mockito.Mockito.mock; + +public class IrisBiomeFieldSeedCohesionTest { + @Test + public void childrenGeneratorsSeparateSeedsSignaturesAndScales() { + IrisData data = mock(IrisData.class); + IrisBiome shared = biome(data); + for (long seed : new long[]{29L, 11L, 29L}) { + for (int signature : new int[]{2137, 91}) { + for (double scale : new double[]{4D, 0.25D}) { + assertArrayEquals(samples(biome(data).getChildrenGenerator(new RNG(seed), signature, scale)), + samples(shared.getChildrenGenerator(new RNG(seed), signature, scale)), 0D); + } + } + } + CNG first = shared.getChildrenGenerator(new RNG(29L), 2137, 4D); + assertSame(first, shared.getChildrenGenerator(new RNG(29L), 2137, 4D)); + } + + @Test(timeout = 15000) + public void concurrentFloatingFieldsAndEvictionRetainSeedAssignments() throws Exception { + IrisData data = mock(IrisData.class); + IrisFloatingChildBiomes shared = new IrisFloatingChildBiomes(); + ExecutorService workers = Executors.newFixedThreadPool(4); + try { + List> results = new ArrayList<>(16); + for (int index = 0; index < 16; index++) { + long seed = index; + results.add(workers.submit(() -> floatingSamples(shared, seed, data))); + } + for (int index = 0; index < results.size(); index++) { + double[] expected = floatingSamples(new IrisFloatingChildBiomes(), index, data); + assertArrayEquals(expected, results.get(index).get(5L, TimeUnit.SECONDS), 0D); + assertArrayEquals(expected, floatingSamples(shared, index, data), 0D); + } + } finally { + workers.shutdownNow(); + } + } + + private static IrisBiome biome(IrisData data) { + IrisBiome biome = new IrisBiome().setChildStyle(NoiseStyle.SIMPLEX.style()); + biome.setLoader(data); + return biome; + } + + private static double[] floatingSamples(IrisFloatingChildBiomes entry, long seed, IrisData data) { + double x = -71.25D; + double z = 145.75D; + return new double[]{ + entry.getFootprintCng(seed, data).noise(x, z), + entry.getPickerCng(seed, data).noise(x, z), + entry.getAltitudeCng(seed, data).noise(x, z), + entry.getTopShapeCng(seed, data).noise(x, z), + entry.getBottomCng(seed, data).noise(x, z), + entry.getEdgeTaperProfile(seed, data).fade(4, -71, 145) + }; + } + + private static double[] samples(CNG generator) { + double[] sampled = new double[16]; + for (int index = 0; index < sampled.length; index++) { + sampled[index] = generator.noise(index * 23.25D - 128D, index * -19.75D + 64D); + } + return sampled; + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/block/VectorMapDeterminismTest.java b/core/src/test/java/art/arcane/iris/generation/block/VectorMapDeterminismTest.java new file mode 100644 index 000000000..69cd26916 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/block/VectorMapDeterminismTest.java @@ -0,0 +1,102 @@ +package art.arcane.iris.generation.block; + +import art.arcane.iris.generation.geometry.IrisBlockVector; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Random; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public class VectorMapDeterminismTest { + @Test + public void collisionBucketsAndChunkBoundariesHaveCanonicalOrder() { + List positions = new ArrayList<>(); + for (int chunk = -2; chunk <= 2; chunk++) { + for (int index = 0; index < 16; index++) { + positions.add(new IrisBlockVector((chunk * 1024) + (index * 64), chunk - 1, index & 1)); + } + } + List expected = null; + for (int fixture = 0; fixture < 64; fixture++) { + Collections.shuffle(positions, new Random(fixture)); + VectorMap map = new VectorMap<>(); + for (IrisBlockVector position : positions) { + map.put(position, position.getBlockX()); + } + List observed = walk(map); + if (expected == null) { + expected = observed; + } + assertEquals("fixture " + fixture, expected, observed); + assertEquals(observed, walk(map)); + } + } + + @Test + public void cachedCursorReflectsReplacementInsertionRemovalAndClear() { + VectorMap map = new VectorMap<>(); + IrisBlockVector origin = new IrisBlockVector(0, 0, 0); + map.put(origin, "before"); + assertEquals(List.of("0:0:0:before"), walk(map)); + map.put(origin, "after"); + assertEquals(List.of("0:0:0:after"), walk(map)); + map.computeIfAbsent(new IrisBlockVector(-1, 0, 0), position -> "added"); + assertEquals(List.of("-1:0:0:added", "0:0:0:after"), walk(map)); + map.remove(origin); + assertEquals(List.of("-1:0:0:added"), walk(map)); + map.clear(); + assertTrue(walk(map).isEmpty()); + map.put(origin, "fresh"); + assertEquals(List.of("0:0:0:fresh"), walk(map)); + } + + @Test + public void returnedMutablePositionsCannotCorruptCacheOrIteratorRemoval() { + VectorMap map = new VectorMap<>(); + map.put(new IrisBlockVector(0, 0, 0), "first"); + map.put(new IrisBlockVector(1, 0, 0), "second"); + Iterator keys = map.keys(); + keys.next().setX(200); + keys.remove(); + assertFalse(map.containsKey(new IrisBlockVector(0, 0, 0))); + assertEquals(List.of("1:0:0:second"), walk(map)); + Iterator> entries = map.iterator(); + entries.next().getKey().setX(300); + entries.remove(); + assertTrue(map.isEmpty()); + } + + @Test + public void warmedCursorReusesItsPositionAndDoesNotExposeSnapshotKeys() { + VectorMap map = new VectorMap<>(); + map.put(new IrisBlockVector(0, 0, 0), "first"); + map.put(new IrisBlockVector(1, 0, 0), "second"); + VectorMap.Cursor cursor = map.cursor(); + assertTrue(cursor.next()); + IrisBlockVector first = cursor.key(); + first.setX(100); + assertTrue(cursor.next()); + assertSame(first, cursor.key()); + assertEquals(1, cursor.key().getBlockX()); + assertEquals(List.of("0:0:0:first", "1:0:0:second"), walk(map)); + } + + private static List walk(VectorMap map) { + List output = new ArrayList<>(); + VectorMap.Cursor cursor = map.cursor(); + while (cursor.next()) { + IrisBlockVector position = cursor.key(); + output.add(position.getBlockX() + ":" + position.getBlockY() + ":" + position.getBlockZ() + + ":" + cursor.value()); + } + return output; + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/decoration/IrisDecoratorSeedCohesionTest.java b/core/src/test/java/art/arcane/iris/generation/decoration/IrisDecoratorSeedCohesionTest.java new file mode 100644 index 000000000..049638623 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/decoration/IrisDecoratorSeedCohesionTest.java @@ -0,0 +1,148 @@ +package art.arcane.iris.generation.decoration; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.noise.IrisGeneratorStyle; +import art.arcane.iris.generation.noise.NoiseStyle; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.GenerationCacheWarmer; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.testsupport.KeyedBlockState; +import art.arcane.iris.testsupport.RunningEnginePackData; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.hunk.Hunk; +import art.arcane.volmlib.util.math.RNG; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisDecoratorSeedCohesionTest { + private static final long[] SEEDS = {101L, -73L, 90181L}; + private static final int COLUMNS = 96; + + @Test + public void decorationKeepsPinnedOutputAcrossColumnOrderAndWarmup() { + long[] expected = {151633654196388515L, -2981136988801910995L, -8591254808024440278L}; + long[] actual = new long[SEEDS.length]; + for (int i = 0; i < SEEDS.length; i++) { + Fixture cold = new Fixture(SEEDS[i], decorator(), RunningEnginePackData.create()); + long[] baseline = cold.generate(false); + Fixture reverse = new Fixture(SEEDS[i], decorator(), RunningEnginePackData.create()); + Fixture warm = new Fixture(SEEDS[i], decorator(), RunningEnginePackData.create()); + GenerationCacheWarmer.warm(warm.engine); + assertArrayEquals(baseline, reverse.generate(true)); + assertArrayEquals(baseline, warm.generate(true)); + actual[i] = hash(baseline); + } + assertEquals(Arrays.toString(expected), Arrays.toString(actual)); + } + + @Test + public void concurrentFirstUseKeepsIndependentSeedResults() throws Exception { + IrisData data = RunningEnginePackData.create(); + IrisDecorator shared = decorator(); + List expected = new ArrayList<>(); + for (long seed : SEEDS) { + expected.add(new Fixture(seed, decorator(), data).generate(false)); + } + ExecutorService executor = Executors.newFixedThreadPool(6); + try { + List> tasks = new ArrayList<>(); + for (int round = 0; round < 24; round++) { + long seed = SEEDS[round % SEEDS.length]; + tasks.add(() -> new Fixture(seed, shared, data).generate(true)); + } + List> futures = executor.invokeAll(tasks, 30, TimeUnit.SECONDS); + for (int i = 0; i < futures.size(); i++) { + assertArrayEquals("task=" + i, expected.get(i % SEEDS.length), futures.get(i).get()); + } + } finally { + executor.shutdownNow(); + executor.awaitTermination(10, TimeUnit.SECONDS); + } + } + + private static IrisDecorator decorator() { + KList body = new KList<>( + new KeyedBlockState("minecraft:stone"), new KeyedBlockState("minecraft:dirt")); + KList top = new KList<>( + new KeyedBlockState("minecraft:granite"), new KeyedBlockState("minecraft:andesite")); + IrisDecorator decorator = new IrisDecorator() { + @Override + public KList getBlockData(IrisData data) { + return body; + } + + @Override + public KList getBlockDataTops(IrisData data) { + return top; + } + }; + return decorator.setPartOf(IrisDecorationPart.SEA_FLOOR).setChance(0.61D).setForcePlace(true) + .setStackMin(2).setStackMax(7) + .setStyle(new IrisGeneratorStyle(NoiseStyle.SIMPLEX).setZoom(7D)) + .setVariance(new IrisGeneratorStyle(NoiseStyle.SIMPLEX).setZoom(4D)) + .setHeightVariance(new IrisGeneratorStyle(NoiseStyle.SIMPLEX).setZoom(9D)); + } + + private static long hash(long[] values) { + long hash = 0xcbf29ce484222325L; + for (long value : values) { + hash = (hash ^ value) * 0x100000001b3L; + } + return hash; + } + + private static final class Fixture { + private final Engine engine = mock(Engine.class, RETURNS_DEEP_STUBS); + private final IrisBiome biome = mock(IrisBiome.class); + private final IrisSeaFloorDecorator placement; + + private Fixture(long seed, IrisDecorator decorator, IrisData data) { + when(engine.getData()).thenReturn(data); + when(engine.getHeight()).thenReturn(32); + when(engine.getSeedManager().getComponent()).thenReturn(seed); + when(engine.getAllBiomes()).thenReturn(new KList<>(biome)); + when(engine.getDimension().getAllRegions(engine)).thenReturn(new KList<>()); + when(engine.getDimension().getOres()).thenReturn(new KList<>()); + when(biome.getLoadKey()).thenReturn("fixture"); + when(biome.getObjects()).thenReturn(new KList<>()); + when(biome.getOres()).thenReturn(new KList<>()); + when(biome.getProceduralObjects()).thenReturn(null); + when(biome.getDecorators()).thenReturn(new KList<>(decorator)); + when(biome.getDecoratorBucket(IrisDecorationPart.SEA_FLOOR)).thenReturn(new IrisDecorator[]{decorator}); + placement = new IrisSeaFloorDecorator(engine); + } + + private long[] generate(boolean reverse) { + long[] output = new long[COLUMNS]; + for (int index = 0; index < COLUMNS; index++) { + int column = reverse ? COLUMNS - index - 1 : index; + Hunk hunk = Hunk.newArrayHunk(1, 32, 1); + int x = column * 29 - 1312; + int z = column * 17 - 781; + placement.decorate(0, 0, x, x + 1, x - 1, z, z + 1, z - 1, hunk, biome, 4, 32); + long hash = 0xcbf29ce484222325L; + for (int y = 0; y < 32; y++) { + NativeBlockState block = hunk.get(0, y, 0); + hash = (hash ^ (block == null ? 0 : block.key().hashCode())) * 0x100000001b3L; + } + output[column] = hash; + } + return output; + } + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/decoration/IrisOreSeedCohesionTest.java b/core/src/test/java/art/arcane/iris/generation/decoration/IrisOreSeedCohesionTest.java new file mode 100644 index 000000000..b0cda3c3f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/decoration/IrisOreSeedCohesionTest.java @@ -0,0 +1,109 @@ +package art.arcane.iris.generation.decoration; + +import art.arcane.iris.generation.noise.IrisGeneratorStyle; +import art.arcane.iris.generation.noise.NoiseStyle; +import art.arcane.iris.generation.terrain.IrisMaterialPalette; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.pack.value.IrisRange; +import art.arcane.iris.testsupport.KeyedBlockState; +import art.arcane.iris.testsupport.RunningEnginePackData; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; + +public class IrisOreSeedCohesionTest { + private static final long[] SEEDS = {101L, -73L, 90181L}; + private static final int SAMPLES = 4096; + + @Test + public void oreFieldsKeepPinnedOutputAcrossSeedOrderAndWarmup() { + IrisData data = RunningEnginePackData.create(); + IrisOreGenerator shared = generator(); + long[] actual = new long[SEEDS.length]; + for (int i = SEEDS.length - 1; i >= 0; i--) { + IrisOreGenerator cold = generator(); + long[] expected = sample(cold, new RNG(SEEDS[i]), data, false); + shared.warm(new RNG(SEEDS[(i + 1) % SEEDS.length]), data); + assertArrayEquals(expected, sample(shared, new RNG(SEEDS[i]), data, true)); + RNG consumed = new RNG(SEEDS[i]); + for (int draw = 0; draw < 128; draw++) { + consumed.nextLong(); + } + assertArrayEquals(expected, sample(generator(), consumed, data, true)); + actual[i] = hash(expected); + } + assertEquals(Arrays.toString(new long[]{8469391283002463309L, -3738838309875838243L, 8458758241968211373L}), Arrays.toString(actual)); + } + + @Test + public void concurrentOreFirstUseKeepsSeedsIndependent() throws Exception { + IrisData data = RunningEnginePackData.create(); + IrisOreGenerator shared = generator(); + List expected = new ArrayList<>(); + for (long seed : SEEDS) { + expected.add(sample(generator(), new RNG(seed), data, false)); + } + ExecutorService executor = Executors.newFixedThreadPool(6); + try { + List> tasks = new ArrayList<>(); + for (int index = 0; index < 18; index++) { + long seed = SEEDS[index % SEEDS.length]; + tasks.add(() -> sample(shared, new RNG(seed), data, true)); + } + List> futures = executor.invokeAll(tasks, 30, TimeUnit.SECONDS); + for (int index = 0; index < futures.size(); index++) { + assertArrayEquals(expected.get(index % SEEDS.length), futures.get(index).get()); + } + } finally { + executor.shutdownNow(); + executor.awaitTermination(10, TimeUnit.SECONDS); + } + } + + private static IrisOreGenerator generator() { + KList states = new KList<>( + new KeyedBlockState("minecraft:iron_ore"), new KeyedBlockState("minecraft:gold_ore")); + IrisMaterialPalette palette = new IrisMaterialPalette() { + @Override + public KList getBlockData(IrisData data) { + return states; + } + }; + palette.setStyle(new IrisGeneratorStyle(NoiseStyle.SIMPLEX)); + return new IrisOreGenerator().setPalette(palette).setThreshold(0.57D) + .setRange(new IrisRange(0D, 256D)) + .setChanceStyle(new IrisGeneratorStyle(NoiseStyle.SIMPLEX)); + } + + private static long[] sample(IrisOreGenerator generator, RNG rng, IrisData data, boolean reverse) { + long[] output = new long[SAMPLES]; + for (int index = 0; index < SAMPLES; index++) { + int sample = reverse ? SAMPLES - index - 1 : index; + NativeBlockState block = generator.generate(sample * 13 - 901, + sample % 257, sample * 7 - 713, rng, data); + output[sample] = block == null ? 0L : block.key().hashCode(); + } + return output; + } + + private static long hash(long[] values) { + long hash = 0xcbf29ce484222325L; + for (long value : values) { + hash = (hash ^ value) * 0x100000001b3L; + } + return hash; + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/decoration/tree/TreeDecoratorApplierTest.java b/core/src/test/java/art/arcane/iris/generation/decoration/tree/TreeDecoratorApplierTest.java index 4baf53acf..92dcedae6 100644 --- a/core/src/test/java/art/arcane/iris/generation/decoration/tree/TreeDecoratorApplierTest.java +++ b/core/src/test/java/art/arcane/iris/generation/decoration/tree/TreeDecoratorApplierTest.java @@ -4,11 +4,75 @@ import art.arcane.volmlib.util.collection.KList; import org.junit.Test; import java.util.List; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Map; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; public class TreeDecoratorApplierTest { + @Test + public void allDecoratorTargetsKeepPinnedOutputAcrossHashCollisionInsertionOrders() { + long[] seeds = {101L, -73L, 90181L}; + long[] actual = new long[seeds.length]; + for (int i = 0; i < seeds.length; i++) { + long hash = 0xcbf29ce484222325L; + for (IrisTreeDecoratorTarget target : IrisTreeDecoratorTarget.values()) { + TreeBlockCanvas forward = collisionCanvas(false); + TreeBlockCanvas reverse = collisionCanvas(true); + IrisProceduralTree tree = surfaceTree(target, true); + tree.getDecorators().getFirst().setChance(0.41D).setLength(4); + TreeDecoratorApplier.apply(forward, tree, seeds[i], List.of()); + TreeDecoratorApplier.apply(reverse, tree, seeds[i], List.of()); + assertEquals(target + ":" + seeds[i], forward.getCells(), reverse.getCells()); + List positions = new ArrayList<>(forward.getCells().keySet()); + positions.sort(null); + for (TreeBlockCanvas.Vec position : positions) { + TreeBlockCanvas.Cell cell = forward.getCells().get(position); + hash = (hash ^ position.hashCode()) * 0x100000001b3L; + hash = (hash ^ cell.role().ordinal()) * 0x100000001b3L; + hash = (hash ^ cell.decoratorIndex()) * 0x100000001b3L; + hash = (hash ^ (cell.facing() == null ? 0 : cell.facing().hashCode())) * 0x100000001b3L; + } + } + actual[i] = hash; + } + assertEquals(Arrays.toString(new long[]{4350422081939262412L, 3647024406982151043L, 7110665103288366817L}), Arrays.toString(actual)); + } + + @Test + public void supportTendrilsChooseTheSameEqualDistanceEndpointsAcrossInsertionOrders() { + TreeBlockCanvas forward = supportCanvas(false); + TreeBlockCanvas reverse = supportCanvas(true); + TreeSupport.ensureLeavesSupported(forward, 4); + TreeSupport.ensureLeavesSupported(reverse, 4); + assertEquals(forward.getCells(), reverse.getCells()); + } + + private static TreeBlockCanvas collisionCanvas(boolean reverse) { + TreeBlockCanvas canvas = new TreeBlockCanvas(); + for (int index = 0; index < 24; index++) { + int position = reverse ? 23 - index : index; + int x = position - 12; + int y = -31 * x; + canvas.setTrunk(x, y, 0, TreeBlockCanvas.Role.TRUNK, TreeBlockCanvas.Axis.Y); + canvas.setLeaf(x, y, 1, TreeBlockCanvas.Role.LEAF); + } + return canvas; + } + + private static TreeBlockCanvas supportCanvas(boolean reverse) { + TreeBlockCanvas canvas = new TreeBlockCanvas(); + int[] coordinates = reverse ? new int[]{3, -3} : new int[]{-3, 3}; + for (int x : coordinates) { + canvas.setTrunk(x, 0, 0, TreeBlockCanvas.Role.TRUNK, TreeBlockCanvas.Axis.Y); + canvas.setLeaf(x * 4, 0, 2, TreeBlockCanvas.Role.LEAF); + canvas.setLeaf(x * 4, 0, -2, TreeBlockCanvas.Role.LEAF); + } + return canvas; + } + @Test public void trunkAttachmentsFaceAwayFromTheirWoodSupport() { TreeBlockCanvas canvas = new TreeBlockCanvas(); diff --git a/core/src/test/java/art/arcane/iris/generation/noise/IrisExpressionSeedCohesionTest.java b/core/src/test/java/art/arcane/iris/generation/noise/IrisExpressionSeedCohesionTest.java new file mode 100644 index 000000000..647b1090b --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/noise/IrisExpressionSeedCohesionTest.java @@ -0,0 +1,101 @@ +package art.arcane.iris.generation.noise; + +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.IrisComplex; +import art.arcane.iris.generation.runtime.IrisEngineStreamType; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.stream.ProceduralStream; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisExpressionSeedCohesionTest { + @Test + public void expressionStreamsSeparateSeedsAndInitializationOrder() { + IrisData data = mock(IrisData.class); + IrisExpression expression = expression(data); + double first = expression(data).evaluate(new RNG(17L), 23.5D, -94.75D); + double second = expression(data).evaluate(new RNG(53L), 23.5D, -94.75D); + ProceduralStream secondStream = expression.stream(new RNG(53L)); + ProceduralStream firstStream = expression.stream(new RNG(17L)); + assertEquals(second, secondStream.get(23.5D, -94.75D), 0D); + assertEquals(first, firstStream.get(23.5D, -94.75D), 0D); + assertSame(firstStream, expression.stream(new RNG(17L))); + } + + @Test + public void expressionFunctionTracksEngineIdentity() { + Engine firstEngine = engine(17D); + Engine secondEngine = engine(53D); + AtomicReference active = new AtomicReference<>(firstEngine); + IrisData data = mock(IrisData.class); + when(data.getEngine()).thenAnswer(ignored -> active.get()); + IrisExpressionFunction function = new IrisExpressionFunction() + .setName("height") + .setData(data) + .setEngineStreamValue(IrisEngineStreamType.HEIGHT); + IrisExpressionFunction.FunctionContext context = new IrisExpressionFunction.FunctionContext(new RNG(91L)); + assertEquals(17D, function.eval(context, 4D, -8D), 0D); + active.set(secondEngine); + assertEquals(53D, function.eval(context, 4D, -8D), 0D); + active.set(firstEngine); + assertEquals(17D, function.eval(context, 4D, -8D), 0D); + } + + @Test(timeout = 15000) + public void concurrentFunctionInitializationAndEvictionRetainSeedSamples() throws Exception { + IrisData data = mock(IrisData.class); + IrisExpressionFunction shared = function(data); + ExecutorService workers = Executors.newFixedThreadPool(4); + try { + List> results = new ArrayList<>(32); + double[] expected = new double[32]; + for (int index = 0; index < 32; index++) { + long seed = index; + expected[index] = function(data).eval(new IrisExpressionFunction.FunctionContext(new RNG(seed)), 23.5D, -94.75D); + results.add(workers.submit(() -> shared.eval( + new IrisExpressionFunction.FunctionContext(new RNG(seed)), 23.5D, -94.75D))); + } + for (int index = 0; index < results.size(); index++) { + assertEquals(expected[index], results.get(index).get(5L, TimeUnit.SECONDS), 0D); + assertEquals(expected[index], shared.eval(new IrisExpressionFunction.FunctionContext(new RNG(index)), 23.5D, -94.75D), 0D); + } + } finally { + workers.shutdownNow(); + } + } + + private static IrisExpression expression(IrisData data) { + IrisExpression expression = new IrisExpression() + .setExpression("field(x, z) + value") + .setFunctions(new KList<>(function(data))) + .setVariables(new KList<>(new IrisExpressionLoad().setName("value").setStyleValue(NoiseStyle.PERLIN.style()))); + expression.setLoader(data); + return expression; + } + + private static IrisExpressionFunction function(IrisData data) { + return new IrisExpressionFunction().setName("field").setStyleValue(NoiseStyle.SIMPLEX.style()).setData(data); + } + + private static Engine engine(double height) { + IrisComplex complex = mock(IrisComplex.class); + when(complex.getHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> height)); + Engine engine = mock(Engine.class); + when(engine.getComplex()).thenReturn(complex); + return engine; + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/noise/IrisGeneratorStyleCacheIsolationTest.java b/core/src/test/java/art/arcane/iris/generation/noise/IrisGeneratorStyleCacheIsolationTest.java index c2dcd1bc6..346cc9196 100644 --- a/core/src/test/java/art/arcane/iris/generation/noise/IrisGeneratorStyleCacheIsolationTest.java +++ b/core/src/test/java/art/arcane/iris/generation/noise/IrisGeneratorStyleCacheIsolationTest.java @@ -10,6 +10,7 @@ import org.junit.rules.TemporaryFolder; import java.util.concurrent.atomic.AtomicReference; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNotSame; import static org.junit.Assert.assertSame; @@ -20,6 +21,16 @@ public class IrisGeneratorStyleCacheIsolationTest { @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); + @Test + public void persistedCacheSignaturesUseStableStyleNames() { + assertEquals(-492944473, new IrisGeneratorStyle(NoiseStyle.SIMPLEX).prebakeSignature()); + assertEquals(992324154, new IrisGeneratorStyle(NoiseStyle.FLAT).prebakeSignature()); + assertEquals(-848988881, new IrisGeneratorStyle(NoiseStyle.STATIC).prebakeSignature()); + assertEquals(990163649, new IrisGeneratorStyle().setStyle(null).prebakeSignature()); + assertEquals(1067243926, new IrisGeneratorStyle(NoiseStyle.SIMPLEX) + .setFracture(new IrisGeneratorStyle(NoiseStyle.STATIC)).prebakeSignature()); + } + @Test public void cacheSeparatesSeedsAndEngineIdentity() throws Exception { Engine firstEngine = mock(Engine.class); diff --git a/core/src/test/java/art/arcane/iris/generation/noise/NoiseSeedCohesionTest.java b/core/src/test/java/art/arcane/iris/generation/noise/NoiseSeedCohesionTest.java new file mode 100644 index 000000000..ace172193 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/noise/NoiseSeedCohesionTest.java @@ -0,0 +1,81 @@ +package art.arcane.iris.generation.noise; + +import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.noise.CNG; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; + +public class NoiseSeedCohesionTest { + private static final long[] SEEDS = {813753L, -935731L, Long.MIN_VALUE}; + private static final double[] COORDINATES = {-29_999_983.25D, -513D, -64D, -1D, 0D, 0.125D, 17D, 64D, 8192D, 29_999_983.25D}; + + @Test + public void allStylesRetainPinnedSamples() { + long hash = 0xcbf29ce484222325L; + for (NoiseStyle style : NoiseStyle.values()) { + for (long seed : SEEDS) { + for (double sample : samples(style.create(new RNG(seed)), false)) { + hash = (hash ^ Double.doubleToLongBits(sample)) * 0x100000001b3L; + } + } + } + assertEquals("All noise style samples", 5059606393854142913L, hash); + } + + @Test(timeout = 30000) + public void allStylesIgnoreSamplingOrderAndConcurrentReaders() throws Exception { + ExecutorService workers = Executors.newFixedThreadPool(4); + try { + for (NoiseStyle style : NoiseStyle.values()) { + CNG generator = style.create(new RNG(SEEDS[0])); + double[] expected = samples(generator, false); + assertArrayEquals(style.name(), expected, samples(generator, true), 0D); + List> results = new ArrayList<>(4); + for (int index = 0; index < 4; index++) { + boolean reversed = index % 2 == 0; + results.add(workers.submit(() -> samples(generator, reversed))); + } + for (Future result : results) { + assertArrayEquals(style.name(), expected, result.get(5L, TimeUnit.SECONDS), 0D); + } + } + } finally { + workers.shutdownNow(); + } + } + + @Test + public void generatorCacheEvictionRetainsSamples() { + for (NoiseStyle style : NoiseStyle.values()) { + IrisGeneratorStyle configured = style.style(); + double[] expected = samples(configured.create(new RNG(SEEDS[0]), null), false); + for (long seed = 0; seed < 9; seed++) { + configured.create(new RNG(seed), null); + } + assertArrayEquals(style.name(), expected, + samples(configured.create(new RNG(SEEDS[0]), null), true), 0D); + } + } + + private static double[] samples(CNG generator, boolean reversed) { + double[] sampled = new double[COORDINATES.length * 3]; + for (int cursor = 0; cursor < COORDINATES.length; cursor++) { + int index = reversed ? COORDINATES.length - 1 - cursor : cursor; + double x = COORDINATES[index]; + double z = x * -0.713D; + sampled[index * 3] = generator.noise(x); + sampled[index * 3 + 1] = generator.noise(x, z); + sampled[index * 3 + 2] = generator.noise(x, 81.25D, z); + } + return sampled; + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/stage/IrisDepositModifierParityTest.java b/core/src/test/java/art/arcane/iris/generation/stage/IrisDepositModifierParityTest.java index 8b69e3b0b..94d872dfa 100644 --- a/core/src/test/java/art/arcane/iris/generation/stage/IrisDepositModifierParityTest.java +++ b/core/src/test/java/art/arcane/iris/generation/stage/IrisDepositModifierParityTest.java @@ -35,6 +35,7 @@ import org.junit.Test; import org.mockito.MockedStatic; import art.arcane.iris.pack.loading.ResourceLoader; +import java.util.Arrays; import java.util.HashMap; import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; @@ -78,16 +79,16 @@ public class IrisDepositModifierParityTest { public void configuredShapesKeepRecordedBlocksAcrossScopesAndCavePolicies() { long[] seeds = {101L, -73L, 90181L}; long[][] expected = { - {-4609692219192155345L, 784L, 2118L, 53L, 67L, 11L}, - {-331296667371868787L, 818L, 2413L, 94L, 49L, 20L}, - {-3365014205342608518L, 641L, 2340L, 58L, 45L, 27L} + {-681153659952315909L, 784L, 2118L, 53L, 67L, 10L}, + {9111006654067022779L, 818L, 2414L, 93L, 49L, 20L}, + {831979810025539302L, 641L, 2340L, 58L, 45L, 28L} }; + long[][] actual = new long[seeds.length][]; for (int i = 0; i < seeds.length; i++) { - for (boolean multicore : new boolean[]{false, true}) { - assertArrayEquals("seed=" + seeds[i] + ", multicore=" + multicore, - expected[i], generateConfigured(seeds[i], multicore)); - } + actual[i] = generateConfigured(seeds[i], false); + assertArrayEquals("seed=" + seeds[i], actual[i], generateConfigured(seeds[i], true)); } + assertEquals(Arrays.deepToString(expected), Arrays.deepToString(actual)); } @Test @@ -104,9 +105,11 @@ public class IrisDepositModifierParityTest { @Test public void unmatchableSurfaceBiomeFiltersSkipClumpsWithoutChangingOutput() { - long[] expected = {-4609692219192155345L, 784L, 2118L, 53L, 67L, 11L}; + long[] expected = {-681153659952315909L, 784L, 2118L, 53L, 67L, 10L}; AtomicInteger clumps = new AtomicInteger(); - assertArrayEquals(expected, generateConfigured(101L, false, true, false, new AtomicInteger(), clumps)); + long[] filtered = generateConfigured(101L, false, true, false, new AtomicInteger(), clumps); + assertArrayEquals(generateConfigured(101L, false), filtered); + assertEquals(Arrays.toString(expected), Arrays.toString(filtered)); assertEquals(0, clumps.get()); } diff --git a/core/src/test/java/art/arcane/iris/generation/terrain/TerrainFieldSeedCohesionTest.java b/core/src/test/java/art/arcane/iris/generation/terrain/TerrainFieldSeedCohesionTest.java new file mode 100644 index 000000000..93d88a9e3 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/terrain/TerrainFieldSeedCohesionTest.java @@ -0,0 +1,39 @@ +package art.arcane.iris.generation.terrain; + +import art.arcane.iris.generation.noise.IrisGenerator; +import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.noise.CNG; +import art.arcane.volmlib.util.noise.CellGenerator; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; + +public class TerrainFieldSeedCohesionTest { + @Test + public void coordinateFracturesSeparateSeedAndSignature() { + IrisDimension shared = new IrisDimension(); + for (long seed : new long[]{91L, 17L, 91L}) { + for (int signature : new int[]{73, 211}) { + CNG expected = new IrisDimension().getCoordFracture(new RNG(seed), signature); + CNG actual = shared.getCoordFracture(new RNG(seed), signature); + for (int index = 0; index < 8; index++) { + assertEquals(expected.noise(index * 128D - 600D, index * -251D + 300D), + actual.noise(index * 128D - 600D, index * -251D + 300D), 0D); + } + } + } + } + + @Test + public void cellGeneratorsRetainSeedAssignments() { + IrisGenerator generator = new IrisGenerator(); + CellGenerator first = generator.getCellGenerator(17L); + CellGenerator second = generator.getCellGenerator(91L); + assertNotSame(first, second); + assertSame(first, generator.getCellGenerator(17L)); + CellGenerator expected = new IrisGenerator().getCellGenerator(91L); + assertEquals(expected.getDistance(17D, -81D), second.getDistance(17D, -81D), 0D); + } +} diff --git a/core/src/test/java/art/arcane/iris/structure/object/IrisObjectIoPaletteTest.java b/core/src/test/java/art/arcane/iris/structure/object/IrisObjectIoPaletteTest.java index d70637e81..9f8791f3c 100644 --- a/core/src/test/java/art/arcane/iris/structure/object/IrisObjectIoPaletteTest.java +++ b/core/src/test/java/art/arcane/iris/structure/object/IrisObjectIoPaletteTest.java @@ -1,9 +1,11 @@ package art.arcane.iris.structure.object; import art.arcane.iris.testsupport.KeyedBlockState; +import art.arcane.iris.testsupport.PlatformBinding; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; import art.arcane.iris.generation.geometry.IrisBlockVector; +import org.junit.Rule; import org.junit.Test; import java.io.ByteArrayInputStream; @@ -13,21 +15,30 @@ import java.io.IOException; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; +import java.util.Collections; +import java.util.Random; import java.util.HashSet; import java.util.HexFormat; import java.util.List; import java.util.Map; import java.util.Set; +import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; public class IrisObjectIoPaletteTest { private static final int BLOCK_COUNT = 30_000; private static final int UNIQUE_KEYS = 3_000; - private static final String PINNED_DIGEST = "9fbd42fe7686cc725e0acab8595b0223d71c1a45762b78ac0e6146230dbb8849"; + private static final String PINNED_DIGEST = "eee0bae5702dc3d8c79b5ba28d0782cdfa581662237629bb13a3adee6f757fc7"; + + @Rule + public final PlatformBinding platform = PlatformBinding.mockPlatform(); @Test - public void writesTheSameBytesAsTheOrderedPaletteScan() throws IOException { + public void pinsCanonicalPaletteAndBlockOrder() throws IOException { ByteArrayOutputStream out = new ByteArrayOutputStream(); IrisObjectIO.write(representativeObject(), out); @@ -36,7 +47,7 @@ public class IrisObjectIoPaletteTest { } @Test - public void paletteKeepsFirstSeenInsertionOrder() throws IOException { + public void paletteKeepsFirstSeenCanonicalTraversalOrder() throws IOException { IrisObject object = representativeObject(); List firstSeen = new ArrayList<>(); Set seen = new HashSet<>(); @@ -53,6 +64,38 @@ public class IrisObjectIoPaletteTest { assertEquals(firstSeen, writtenPalette(out.toByteArray())); } + @Test + public void insertionPermutationsWriteIdenticalBytesAndReadBackEveryBlock() throws Throwable { + when(platform.registries().blockOrNull(anyString(), eq(false))) + .thenAnswer(invocation -> new KeyedBlockState(invocation.getArgument(0))); + IrisObject source = representativeObject(); + ByteArrayOutputStream canonical = new ByteArrayOutputStream(); + IrisObjectIO.write(source, canonical); + byte[] expected = canonical.toByteArray(); + List> blocks = new ArrayList<>(); + source.blocks.forEach((position, state) -> blocks.add(Map.entry(position, state))); + for (int fixture = 0; fixture < 8; fixture++) { + Collections.shuffle(blocks, new Random(fixture)); + IrisObject permuted = new IrisObject(source.w, source.h, source.d); + for (Map.Entry entry : blocks) { + permuted.blocks.put(entry.getKey(), entry.getValue()); + } + ByteArrayOutputStream encoded = new ByteArrayOutputStream(); + IrisObjectIO.write(permuted, encoded); + assertArrayEquals("fixture " + fixture, expected, encoded.toByteArray()); + IrisObject decoded = new IrisObject(); + IrisObjectIO.read(decoded, new ByteArrayInputStream(encoded.toByteArray())); + assertEquals(source.w, decoded.w); + assertEquals(source.h, decoded.h); + assertEquals(source.d, decoded.d); + assertEquals(source.blocks.size(), decoded.blocks.size()); + assertEquals(source.states.size(), decoded.states.size()); + for (Map.Entry entry : blocks) { + assertEquals(entry.getValue().key(), decoded.blocks.get(entry.getKey()).key()); + } + } + } + private static List writtenPalette(byte[] bytes) throws IOException { DataInputStream din = new DataInputStream(new ByteArrayInputStream(bytes)); din.readInt(); diff --git a/core/src/test/java/art/arcane/iris/structure/object/IrisObjectPlacementRunnerRegressionTest.java b/core/src/test/java/art/arcane/iris/structure/object/IrisObjectPlacementRunnerRegressionTest.java index 082d8f543..fba5fe5c3 100644 --- a/core/src/test/java/art/arcane/iris/structure/object/IrisObjectPlacementRunnerRegressionTest.java +++ b/core/src/test/java/art/arcane/iris/structure/object/IrisObjectPlacementRunnerRegressionTest.java @@ -1,6 +1,7 @@ package art.arcane.iris.structure.object; import art.arcane.iris.generation.block.TileData; +import art.arcane.iris.generation.geometry.IrisBlockVector; import art.arcane.iris.generation.decoration.IrisStiltSettings; import art.arcane.iris.generation.block.IrisBlockData; import art.arcane.iris.generation.decoration.formation.FormationGenerator; @@ -32,6 +33,8 @@ import org.junit.ClassRule; import org.junit.Test; import java.util.ArrayList; +import java.util.Collections; +import java.util.Random; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -218,6 +221,63 @@ public class IrisObjectPlacementRunnerRegressionTest { assertEquals(ANCHOR_Y, snowWrites.get(0).y()); } + @Test + public void smartBorePlacementLeavesSharedObjectUnchangedForPlainPlacements() { + IrisObject object = new IrisObject(3, 1, 1); + object.setLoader(data); + object.setUnsigned(0, 0, 0, solid); + object.setUnsigned(2, 0, 0, solid); + RecordingPlacer before = new RecordingPlacer(null); + RecordingPlacer bored = new RecordingPlacer(null); + RecordingPlacer after = new RecordingPlacer(null); + IrisObjectPlacement plain = placement().setMode(ObjectPlaceMode.STRUCTURE_PIECE); + + object.place(0, ANCHOR_Y, 0, before, plain, new RNG(12L), data); + object.place(0, ANCHOR_Y, 0, bored, plain.toPlacement().setSmartBore(true), new RNG(12L), data); + object.place(0, ANCHOR_Y, 0, after, plain, new RNG(12L), data); + + assertEquals(before.writes(), after.writes()); + assertEquals(2, object.getBlocks().size()); + assertEquals(3, bored.writes().size()); + assertFalse(object.isSmartBored()); + } + + @Test + public void randomizedEditsAndSnowIgnoreHashCollisionInsertionOrder() { + NativeBlockState replacement = state("minecraft:calcite", true); + IrisObjectReplace edit = mock(IrisObjectReplace.class); + when(edit.getChance()).thenReturn(0.45F); + when(edit.getFind(data)).thenReturn(new KList<>(solid)); + when(edit.getReplace(any(RNG.class), anyDouble(), anyDouble(), anyDouble(), any(IrisData.class))) + .thenReturn(replacement); + IrisMaterialPalette palette = mock(IrisMaterialPalette.class); + when(palette.getTile(any(RNG.class), anyDouble(), anyDouble(), anyDouble(), any(IrisData.class))) + .thenReturn(Optional.empty()); + when(edit.getReplace()).thenReturn(palette); + IrisObjectPlacement placement = placement().setMode(ObjectPlaceMode.STRUCTURE_PIECE) + .setSnow(0.85D).setEdit(new KList<>(edit)); + List insertionOrder = new ArrayList<>(); + for (int index = 0; index < 16; index++) { + insertionOrder.add(index); + } + Map expected = null; + for (int fixture = 0; fixture < 64; fixture++) { + Collections.shuffle(insertionOrder, new Random(fixture)); + IrisObject object = new IrisObject(1024, 1, 1); + for (int index : insertionOrder) { + object.getBlocks().put(new IrisBlockVector(index * 64, 0, 0), solid); + } + RecordingPlacer placer = new RecordingPlacer(null); + object.place(0, ANCHOR_Y, 0, placer, placement, new RNG(123456L), data); + if (expected == null) { + expected = new HashMap<>(placer.world); + } + assertEquals("fixture " + fixture, expected, placer.world); + } + assertTrue(expected.containsValue(replacement)); + assertTrue(expected.containsValue(solid)); + } + @Test public void debugPlacementDoesNotMutateSmartBoreCache() { IrisObject object = new IrisObject(1, 1, 1); diff --git a/core/src/test/java/art/arcane/iris/structure/object/IrisObjectShapingTest.java b/core/src/test/java/art/arcane/iris/structure/object/IrisObjectShapingTest.java index 588e200b7..51dbb437d 100644 --- a/core/src/test/java/art/arcane/iris/structure/object/IrisObjectShapingTest.java +++ b/core/src/test/java/art/arcane/iris/structure/object/IrisObjectShapingTest.java @@ -18,6 +18,8 @@ import java.util.Map; import java.util.Random; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotSame; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.anyString; @@ -94,6 +96,27 @@ public class IrisObjectShapingTest { assertTrue(object.getBlocks().isEmpty()); } + @Test + public void smartBoreVariantIsReusedAndInvalidatedBySourceEdits() { + IrisObject source = new IrisObject(3, 1, 1); + source.getBlocks().put(new IrisBlockVector(-1, 0, 0), IrisObject.States.stone()); + source.getBlocks().put(new IrisBlockVector(1, 0, 0), IrisObject.States.stone()); + long sourceRevision = source.getBlocks().modificationRevision(); + + IrisObject first = IrisObjectShaping.smartBoredVariant(source); + + assertFalse(source.isSmartBored()); + assertEquals(sourceRevision, source.getBlocks().modificationRevision()); + assertEquals(2, source.getBlocks().size()); + assertEquals(3, first.getBlocks().size()); + assertSame(first, IrisObjectShaping.smartBoredVariant(source)); + source.getBlocks().put(new IrisBlockVector(0, 0, 0), IrisObject.States.air()); + IrisObject second = IrisObjectShaping.smartBoredVariant(source); + assertNotSame(first, second); + assertSame(IrisObject.States.air(), second.getBlocks().get(new IrisBlockVector(0, 0, 0))); + assertSame(second, IrisObjectShaping.smartBoredVariant(source)); + } + private static void closeAxis(Map blocks, int axis) { List occupied = new ArrayList<>(blocks.keySet()); for (Cell start : occupied) { diff --git a/probe/src/main/java/art/arcane/iris/probe/GenerationOrderProbe.java b/probe/src/main/java/art/arcane/iris/probe/GenerationOrderProbe.java index 1146cb570..5644ea040 100644 --- a/probe/src/main/java/art/arcane/iris/probe/GenerationOrderProbe.java +++ b/probe/src/main/java/art/arcane/iris/probe/GenerationOrderProbe.java @@ -326,7 +326,7 @@ public final class GenerationOrderProbe { ); } - private static ProbeResult run(ProbeConfiguration configuration) throws Exception { + static ProbeResult run(ProbeConfiguration configuration) throws Exception { System.out.println(LOG_PREFIX + " pack: " + configuration.packSource().getAbsolutePath()); System.out.println(LOG_PREFIX + " dimension: " + configuration.dimensionKey()); System.out.println(LOG_PREFIX + " seed: " + configuration.seed()); diff --git a/probe/src/main/java/art/arcane/iris/probe/RealPackProbeSupport.java b/probe/src/main/java/art/arcane/iris/probe/RealPackProbeSupport.java index 4b5cf9bdb..a7689a363 100644 --- a/probe/src/main/java/art/arcane/iris/probe/RealPackProbeSupport.java +++ b/probe/src/main/java/art/arcane/iris/probe/RealPackProbeSupport.java @@ -537,7 +537,7 @@ final class RealPackProbeSupport { private static File clonePack(File source, File workRoot) throws Exception { File destination = new File(workRoot, "pack"); if (System.getProperty("os.name").startsWith("Mac")) { - Process clone = new ProcessBuilder("/bin/cp", "-Rc", source.getAbsolutePath(), destination.getAbsolutePath()) + Process clone = new ProcessBuilder("/bin/cp", "-RLc", source.getAbsolutePath(), destination.getAbsolutePath()) .inheritIO().start(); if (clone.waitFor() == 0) { return destination; diff --git a/probe/src/test/java/art/arcane/iris/probe/SeedCohesionProbeTest.java b/probe/src/test/java/art/arcane/iris/probe/SeedCohesionProbeTest.java new file mode 100644 index 000000000..e95e59da7 --- /dev/null +++ b/probe/src/test/java/art/arcane/iris/probe/SeedCohesionProbeTest.java @@ -0,0 +1,74 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.testsupport.IrisRuntimeState; +import org.junit.AfterClass; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; + +public final class SeedCohesionProbeTest { + @Rule + public TemporaryFolder temporary = new TemporaryFolder(); + + @AfterClass + public static void releaseRuntime() { + IrisRuntimeState.reset(); + } + + @Test + public void linkedPackInputsAreCopiedIntoAnIndependentWorkspace() throws Exception { + URL resource = SeedCohesionProbeTest.class.getResource("/seed-cohesion"); + assertNotNull(resource); + Path source = Path.of(resource.toURI()); + Path link = temporary.getRoot().toPath().resolve("linked-pack"); + Files.createSymbolicLink(link, source); + try (RealPackProbeSupport.Workspace workspace = RealPackProbeSupport.openWorkspace( + link.toFile(), "cohesion", "[seed-cohesion-copy]")) { + Path copy = workspace.pack().toPath(); + assertFalse(Files.isSymbolicLink(copy)); + assertFalse(Files.isSameFile(source, copy)); + assertEquals(Files.readString(source.resolve("dimensions/cohesion.json")), + Files.readString(copy.resolve("dimensions/cohesion.json"))); + } + } + + @Test(timeout = 180_000L) + public void completeChunksRemainStableAcrossSeedOrderThreadsAndRestarts() throws Exception { + GenerationOrderProbe.AggregateSignature first = generate(1337L, false); + GenerationOrderProbe.AggregateSignature parallel = generate(1337L, true); + GenerationOrderProbe.AggregateSignature alternate = generate(-8675309L, true); + GenerationOrderProbe.AggregateSignature restarted = generate(1337L, true); + assertEquals(first, parallel); + assertEquals(first, restarted); + assertNotEquals(first.blocks(), alternate.blocks()); + assertEquals(new GenerationOrderProbe.AggregateSignature( + "3ddfe89b98273ec609574918ac461bdd0e15365a2840a8ab73285818981feb74", + "0f54e6504dc0b66b3c0d9b9cc4406a07a63d14e5eba4c30db7c3d7f74a5d61c7", + "9bcf2d354c6f17849046153c25beeb3ce08b81d3409037d2968f28d30d832253"), first); + assertEquals(new GenerationOrderProbe.AggregateSignature( + "0ad13dd2ea0d3eb467f1fded74403f7efb5245a3a06799b39c1d1d17dd32b926", + "0f54e6504dc0b66b3c0d9b9cc4406a07a63d14e5eba4c30db7c3d7f74a5d61c7", + "e4f6fb0c86cae9be76a1e957156e42a762b4d75458fb1483fe856bda1cfd3a1e"), alternate); + } + + private static GenerationOrderProbe.AggregateSignature generate(long seed, boolean multicore) throws Exception { + URL resource = SeedCohesionProbeTest.class.getResource("/seed-cohesion"); + assertNotNull(resource); + File pack = new File(resource.toURI()); + GenerationOrderProbe.ProbeConfiguration configuration = new GenerationOrderProbe.ProbeConfiguration( + pack, "cohesion", seed, -1, 1, -1, 1, 4, 91L, multicore, false); + GenerationOrderProbe.ProbeResult result = GenerationOrderProbe.run(configuration); + assertEquals("PASS", result.status()); + return result.signature(); + } +} diff --git a/probe/src/test/resources/seed-cohesion/biomes/terrain.json b/probe/src/test/resources/seed-cohesion/biomes/terrain.json new file mode 100644 index 000000000..929d48b81 --- /dev/null +++ b/probe/src/test/resources/seed-cohesion/biomes/terrain.json @@ -0,0 +1,18 @@ +{ + "name": "Terrain", + "derivative": "minecraft:plains", + "vanillaDerivative": "minecraft:plains", + "layers": [{"palette": [{"block": "minecraft:stone"}]}], + "generators": [{"generator": "terrain", "min": 20, "max": 30}], + "decorators": [{ + "chance": 0.61, + "style": {"style": "SIMPLEX", "zoom": 7}, + "variance": {"style": "SIMPLEX", "zoom": 4}, + "heightVariance": {"style": "SIMPLEX", "zoom": 9}, + "forcePlace": true, + "stackMin": 1, + "stackMax": 5, + "palette": [{"block": "minecraft:stone"}, {"block": "minecraft:dirt"}], + "topPalette": [{"block": "minecraft:granite"}, {"block": "minecraft:andesite"}] + }] +} diff --git a/probe/src/test/resources/seed-cohesion/dimensions/cohesion.json b/probe/src/test/resources/seed-cohesion/dimensions/cohesion.json new file mode 100644 index 000000000..e0736cf40 --- /dev/null +++ b/probe/src/test/resources/seed-cohesion/dimensions/cohesion.json @@ -0,0 +1,11 @@ +{ + "name": "Seed cohesion fixture", + "version": 1, + "mode": {"type": "OVERWORLD"}, + "regions": ["terrain"], + "environment": "NORMAL", + "dimensionHeight": {"min": 0, "max": 64}, + "logicalHeight": 64, + "fluidHeight": 10, + "caveProfile": {"enabled": false} +} diff --git a/probe/src/test/resources/seed-cohesion/generators/terrain.json b/probe/src/test/resources/seed-cohesion/generators/terrain.json new file mode 100644 index 000000000..68b3b98e2 --- /dev/null +++ b/probe/src/test/resources/seed-cohesion/generators/terrain.json @@ -0,0 +1,5 @@ +{ + "seed": 310, + "interpolator": {"function": "NONE", "horizontalScale": 1}, + "composite": [{"seed": 310, "style": {"style": "SIMPLEX", "zoom": 19}}] +} diff --git a/probe/src/test/resources/seed-cohesion/regions/terrain.json b/probe/src/test/resources/seed-cohesion/regions/terrain.json new file mode 100644 index 000000000..27710a70d --- /dev/null +++ b/probe/src/test/resources/seed-cohesion/regions/terrain.json @@ -0,0 +1,6 @@ +{ + "name": "Terrain", + "landBiomes": ["terrain"], + "seaBiomes": ["terrain"], + "shoreBiomes": ["terrain"] +}