diff --git a/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/DatapackCostProbe.java b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/DatapackCostProbe.java new file mode 100644 index 000000000..baa9c3d50 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/DatapackCostProbe.java @@ -0,0 +1,563 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.pack.datapack.export.WorldgenDatapackExporter; +import art.arcane.iris.pack.datapack.export.WorldgenExportProgress; +import art.arcane.iris.pack.datapack.export.WorldgenExportRequest; +import art.arcane.iris.pack.datapack.export.WorldgenExportResult; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.level.dimension.LevelStem; +import net.minecraft.world.level.levelgen.Aquifer; +import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.level.levelgen.NoiseGeneratorSettings; +import net.minecraft.world.level.levelgen.NoiseRouter; +import net.minecraft.world.level.levelgen.densityfunction.DensityFunction; + +import java.io.File; +import java.io.IOException; +import java.io.PrintStream; +import java.lang.management.ManagementFactory; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.SortedMap; +import java.util.TreeMap; +import java.util.concurrent.Callable; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +public final class DatapackCostProbe { + private static final String LOG_PREFIX = "[datapack-cost]"; + static final String VANILLA_PRESET = "minecraft:normal"; + private static final int WARMUP_ORIGIN = -4096; + private static final int MINIMUM_CALLS = 8; + private static final int TOP_OFFENDERS = 10; + private static final long ANALYSIS_STACK_BYTES = 1L << 30; + private static final double MEGABYTE = 1024.0 * 1024.0; + private static final double NANOS_PER_SECOND = 1_000_000_000.0; + + private DatapackCostProbe() { + } + + public static void main(String[] arguments) { + PrintStream diagnostics = System.err; + try { + execute(Configuration.parse(arguments)); + } catch (Throwable failure) { + failure.printStackTrace(diagnostics); + diagnostics.flush(); + System.exit(1); + } + System.out.flush(); + System.exit(0); + } + + private static void execute(Configuration configuration) throws Exception { + HeadlessNativeBootstrap.initialize(); + Files.createDirectories(configuration.work()); + Path work = Files.createTempDirectory(configuration.work(), "run-"); + List loaded = new ArrayList<>(2); + try { + log("exporting " + configuration.dimension() + " from " + configuration.pack()); + ExportRun export = export(configuration, work); + log(String.format(Locale.ROOT, "exported in %.1f s: %d files, %d density functions", export.stats().seconds(), + export.stats().files(), export.stats().densityFunctions())); + DensityJsonBreakdown.Report densityJson = onLargeStack("density-json", + () -> DensityJsonBreakdown.analyze(export.files(), configuration.dimension()).report()); + Path datapack = work.resolve("datapack"); + writeFiles(export.files(), datapack); + Path vanillaPack = Files.createDirectories(work.resolve("vanilla")); + ExportStats exportStats = export.stats(); + export.files().clear(); + + long baseline = settledHeap(); + long loadStarted = System.nanoTime(); + HeadlessNativeRegistries vanilla = isolatedLoad(vanillaPack); + double vanillaLoad = (System.nanoTime() - loadStarted) / NANOS_PER_SECOND; + loaded.add(vanilla); + long vanillaHeap = settledHeap(); + loadStarted = System.nanoTime(); + HeadlessNativeRegistries exported = isolatedLoad(datapack); + double exportLoad = (System.nanoTime() - loadStarted) / NANOS_PER_SECOND; + loaded.add(exported); + long exportHeap = settledHeap(); + log(String.format(Locale.ROOT, "registries loaded: vanilla %.2f s, export %.2f s", vanillaLoad, exportLoad)); + + WorldgenStageBench vanillaBench = new WorldgenStageBench(new WorldgenStageBench.Options("vanilla", + presetOverworld(vanilla.registries(), VANILLA_PRESET), vanilla.registries(), configuration.seed())); + warm(vanillaBench, configuration); + long vanillaSamplers = settledHeap(); + WorldgenStageBench exportBench = new WorldgenStageBench(new WorldgenStageBench.Options("export", + exportedOverworld(exported.registries()), exported.registries(), configuration.seed())); + warm(exportBench, configuration); + long exportSamplers = settledHeap(); + + double loadStart = ManagementFactory.getOperatingSystemMXBean().getSystemLoadAverage(); + Measurement vanillaMeasurement = new Measurement(vanillaBench); + Measurement exportMeasurement = new Measurement(exportBench); + measure(List.of(vanillaMeasurement, exportMeasurement), configuration); + double loadEnd = ManagementFactory.getOperatingSystemMXBean().getSystemLoadAverage(); + Map generators = new LinkedHashMap<>(); + generators.put("vanilla", vanillaMeasurement.stats(vanillaLoad, vanillaHeap - baseline, vanillaSamplers - exportHeap)); + generators.put("export", exportMeasurement.stats(exportLoad, exportHeap - vanillaHeap, exportSamplers - vanillaSamplers)); + + log("analyzing density function graphs"); + Map> roots = new LinkedHashMap<>(); + roots.put("vanilla", onLargeStack("density-cost-vanilla", () -> analyzeRoots(vanillaBench.settings(), configuration.dimension()))); + roots.put("export", onLargeStack("density-cost-export", () -> analyzeRoots(exportBench.settings(), configuration.dimension()))); + + Report report = new Report(configuration.pack().toString(), configuration.dimension(), configuration.seed(), + configuration.rounds() * configuration.side() * configuration.side(), configuration.warmup() * configuration.warmup(), + configuration.rounds(), new MachineLoad(Runtime.getRuntime().availableProcessors(), loadStart, loadEnd), + "single thread for chunk stages and calls, vanilla and export interleaved per round; registry load uses HeadlessNativeRegistries' 4-thread loader; export uses Iris's own pools", + "feature decoration not measured: Paper's applyBiomeDecoration needs a live ServerLevel and the headless feature world lives in test fixtures", + exportStats, generators, densityJson, roots, definitions()); + writeReport(report, configuration.output()); + System.out.println(table(report)); + log("report written to " + configuration.output()); + } finally { + for (HeadlessNativeRegistries registries : loaded) { + registries.close(); + } + deleteTree(work); + } + } + + private static ExportRun export(Configuration configuration, Path work) throws Exception { + Path exportRoot = Files.createDirectories(work.resolve("exports")); + StageClock clock = new StageClock(); + WorldgenExportResult result; + long started; + long finished; + try (RealPackProbeSupport.Workspace workspace = RealPackProbeSupport.openWorkspace(new RealPackProbeSupport.WorkspaceOptions( + configuration.pack().toFile(), configuration.dimension(), LOG_PREFIX, new HeadlessNativeRuntime(), work))) { + WorldgenExportRequest request = WorldgenExportRequest.builder() + .pack(workspace.pack().toPath()) + .dimension(configuration.dimension()) + .exportRoot(exportRoot) + .objects(true) + .build(); + started = System.nanoTime(); + clock.start(started); + result = WorldgenDatapackExporter.create().export(request, clock); + finished = System.nanoTime(); + } + if (!result.isSuccess()) { + throw new IllegalStateException("Export failed: " + result.message() + " " + result.diagnostics()); + } + SortedMap files = zipEntries(result.output()); + ExportStats stats = new ExportStats((finished - started) / NANOS_PER_SECOND, clock.seconds(finished), Files.size(result.output()), + files.size(), count(files, "/worldgen/density_function/"), bytes(files, "/worldgen/density_function/"), + count(files, "/worldgen/noise/"), count(files, "/worldgen/biome/"), result.diagnostics().size()); + Files.delete(result.output()); + return new ExportRun(stats, files); + } + + private static void warm(WorldgenStageBench bench, Configuration configuration) { + long started = System.nanoTime(); + bench.chunks(WARMUP_ORIGIN, WARMUP_ORIGIN, configuration.warmup(), new WorldgenStageBench.ChunkSamples()); + WorldgenStageBench.CallBudget budget = new WorldgenStageBench.CallBudget(MINIMUM_CALLS * 4, MINIMUM_CALLS, + TimeUnit.SECONDS.toNanos(configuration.callSeconds()) / 4); + bench.uncachedBiome(budget, new WorldgenStageBench.CallSamples()); + bench.baseHeight(budget, Heightmap.Types.WORLD_SURFACE_WG, new WorldgenStageBench.CallSamples()); + bench.baseColumn(budget, new WorldgenStageBench.CallSamples()); + log(String.format(Locale.ROOT, "%s warmed in %.1f s", bench.label(), (System.nanoTime() - started) / NANOS_PER_SECOND)); + } + + private static void measure(List measurements, Configuration configuration) { + int stride = configuration.side() + 4; + WorldgenStageBench.CallBudget budget = new WorldgenStageBench.CallBudget( + Math.max(1, configuration.calls() / configuration.rounds()), Math.max(1, MINIMUM_CALLS / configuration.rounds()), + TimeUnit.SECONDS.toNanos(configuration.callSeconds()) / configuration.rounds()); + for (int round = 0; round < configuration.rounds(); round++) { + for (int index = 0; index < measurements.size(); index++) { + Measurement measurement = measurements.get((index + round) % measurements.size()); + long started = System.nanoTime(); + measurement.bench.chunks(round * stride, round * stride, configuration.side(), measurement.chunks); + measurement.bench.uncachedBiome(budget, measurement.uncachedBiome); + measurement.bench.baseHeight(budget, Heightmap.Types.WORLD_SURFACE_WG, measurement.surfaceHeight); + measurement.bench.baseHeight(budget, Heightmap.Types.OCEAN_FLOOR_WG, measurement.floorHeight); + measurement.bench.baseColumn(budget, measurement.column); + log(String.format(Locale.ROOT, "%s round %d measured in %.1f s", measurement.bench.label(), round, + (System.nanoTime() - started) / NANOS_PER_SECOND)); + } + } + } + + private static List analyzeRoots(NoiseGeneratorSettings settings, String dimension) { + DensityCostAnalysis analysis = new DensityCostAnalysis(id -> DensityJsonBreakdown.category(dimension, id), TOP_OFFENDERS); + Map roots = new LinkedHashMap<>(); + NoiseRouter router = settings.noiseRouter(); + roots.put("temperature", router.temperature()); + roots.put("vegetation", router.vegetation()); + roots.put("continents", router.continents()); + roots.put("erosion", router.erosion()); + roots.put("depth", router.depth()); + roots.put("ridges", router.ridges()); + roots.put("chunk_surface_level", router.chunkSurfaceLevel()); + roots.put("final_density", router.finalDensity()); + if (settings.aquifers().isPresent()) { + Aquifer.Config aquifer = settings.aquifers().get(); + roots.put("aquifer.barrier", aquifer.barrierNoise()); + roots.put("aquifer.fluid_level_floodedness", aquifer.fluidLevelFloodednessNoise()); + roots.put("aquifer.fluid_level_spread", aquifer.fluidLevelSpreadNoise()); + roots.put("aquifer.lava", aquifer.lavaNoise()); + roots.put("aquifer.exclusion", aquifer.exclusion()); + roots.put("aquifer.surface_level", aquifer.surfaceLevel()); + } + List costs = new ArrayList<>(roots.size()); + for (Map.Entry root : roots.entrySet()) { + costs.add(analysis.analyze(root.getKey(), root.getValue())); + } + return costs; + } + + static LevelStem presetOverworld(RegistryAccess registries, String preset) { + return registries.lookupOrThrow(Registries.WORLD_PRESET) + .getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, Identifier.parse(preset))).value().overworld() + .orElseThrow(() -> new IllegalStateException("World preset " + preset + " has no overworld")); + } + + static LevelStem exportedOverworld(RegistryAccess registries) { + return registries.lookupOrThrow(Registries.LEVEL_STEM).getOrThrow(LevelStem.OVERWORLD).value(); + } + + private static HeadlessNativeRegistries isolatedLoad(Path directory) { + try (PaperRegistryScope scope = PaperRegistryScope.open()) { + return HeadlessNativeRegistries.load(directory); + } + } + + private static long settledHeap() throws InterruptedException { + for (int pass = 0; pass < 4; pass++) { + System.gc(); + Thread.sleep(200L); + } + return ManagementFactory.getMemoryMXBean().getHeapMemoryUsage().getUsed(); + } + + private static T onLargeStack(String name, Callable work) throws Exception { + AtomicReference result = new AtomicReference<>(); + AtomicReference failure = new AtomicReference<>(); + Thread thread = new Thread(null, () -> { + try { + result.set(work.call()); + } catch (Throwable problem) { + failure.set(problem); + } + }, name, ANALYSIS_STACK_BYTES); + thread.start(); + thread.join(); + if (failure.get() instanceof Exception exception) { + throw exception; + } + if (failure.get() instanceof Error error) { + throw error; + } + return result.get(); + } + + private static SortedMap zipEntries(Path zip) throws IOException { + SortedMap files = new TreeMap<>(); + try (ZipFile archive = new ZipFile(zip.toFile())) { + Enumeration entries = archive.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + if (!entry.isDirectory()) { + files.put(entry.getName(), archive.getInputStream(entry).readAllBytes()); + } + } + } + return files; + } + + private static void writeFiles(SortedMap files, Path directory) throws IOException { + Files.createDirectories(directory); + for (Map.Entry file : files.entrySet()) { + Path target = directory.resolve(file.getKey()).normalize(); + if (!target.startsWith(directory)) { + throw new IllegalArgumentException("Export file escapes the datapack root: " + file.getKey()); + } + Files.createDirectories(target.getParent()); + Files.write(target, file.getValue()); + } + } + + private static int count(SortedMap files, String folder) { + int count = 0; + for (String path : files.keySet()) { + if (path.contains(folder) && path.endsWith(".json")) { + count++; + } + } + return count; + } + + private static long bytes(SortedMap files, String folder) { + long bytes = 0; + for (Map.Entry file : files.entrySet()) { + if (file.getKey().contains(folder) && file.getKey().endsWith(".json")) { + bytes += file.getValue().length; + } + } + return bytes; + } + + private static void writeReport(Report report, Path output) throws IOException { + Files.createDirectories(output.getParent()); + Gson gson = new GsonBuilder().setPrettyPrinting().serializeSpecialFloatingPointValues().create(); + Files.writeString(output, gson.toJson(report), StandardCharsets.UTF_8); + } + + private static Map definitions() { + Map definitions = new LinkedHashMap<>(); + definitions.put("chunks", "per stage: wall ms and current-thread CPU ms per chunk; rounds of side x side chunks alternate vanilla and export"); + definitions.put("chunks.biomes", "ChunkGenerator.createBiomes on a fresh ProtoChunk, ms per chunk over the grid plus a one-chunk ring"); + definitions.put("chunks.noise", "NoiseChunk construction (aquifer init) plus NoiseBasedChunkGenerator.doFill, ms per chunk"); + definitions.put("chunks.surface", "MaterialSystem.buildSurface with the fill's NoiseChunk and a region biome manager, ms per chunk"); + definitions.put("chunks.carverBiome", "one uncached BiomeSource lookup at the chunk origin, as ChunkAccess.carverBiome memoizes per chunk, ms per chunk"); + definitions.put("uncachedBiome", "BiomeSource.createUncachedResolver(...).getNoiseBiome at random quart positions within 32k blocks, wall us per call and mean thread CPU us per call"); + definitions.put("baseHeight", "NoiseBasedChunkGenerator.getBaseHeight as structure placement calls it, us per call"); + definitions.put("baseColumn", "NoiseBasedChunkGenerator.getBaseColumn, us per call"); + definitions.put("retainedHeapBytes", "used heap after four System.gc passes, delta across the registry load"); + definitions.put("samplerHeapBytes", "used heap delta across RandomState creation and warmup (compiled samplers and buffer pools)"); + definitions.put("roots.dagNodes", "distinct non-constant density function and spline nodes reachable, references shared"); + definitions.put("roots.cachedNodes", "non-constant evaluations per point with caches enabled: every distinct cache body once, all branches"); + definitions.put("roots.expandedNodes", "non-constant evaluations per point with every reference and cache re-expanded, all branches"); + definitions.put("roots.uncachedPointWorst", "worst single uncached point evaluation with lazy splines, interval_select and range_choice; interpolated off-lattice costs 8 corners at expanded cost; find_top_surface counts its probe steps"); + definitions.put("roots.dagShare", "share of the distinct nodes by the category of the named file that holds them; offset copies are Grad2 and Taps duplicates"); + definitions.put("roots.exclusive", "named density functions ranked by multiplicity x own nodes in the expanded tree"); + definitions.put("roots.inclusive", "named density functions ranked by multiplicity x subtree nodes in the expanded tree"); + definitions.put("densityJson.structurallyDuplicateBytes", "bytes beyond one file per JSON shape, with numbers, density function references and noise ids abstracted"); + return definitions; + } + + private static String table(Report report) { + StringBuilder out = new StringBuilder(4096); + GeneratorStats vanilla = report.generators().get("vanilla"); + GeneratorStats export = report.generators().get("export"); + out.append(String.format(Locale.ROOT, "%s %s seed %d: %d chunks in %d interleaved rounds after %d warmup chunks, one thread, load %.0f-%.0f on %d cores%n", + LOG_PREFIX, report.dimension(), report.seed(), report.chunks(), report.rounds(), report.warmupChunks(), + report.load().loadAverageStart(), report.load().loadAverageEnd(), report.load().processors())); + out.append(String.format(Locale.ROOT, "%-34s %10s %10s %8s %10s %10s %8s%n", "stage (mean)", "vanilla", "export", "ratio", + "cpu van", "cpu exp", "ratio")); + out.append(String.format(Locale.ROOT, "%-34s %10s %10.1f%n", "export (s)", "-", report.export().seconds())); + row(out, "registry load (s)", vanilla.registryLoadSeconds(), export.registryLoadSeconds()); + row(out, "retained heap after load (MB)", vanilla.retainedHeapBytes() / MEGABYTE, export.retainedHeapBytes() / MEGABYTE); + row(out, "sampler heap after warmup (MB)", vanilla.samplerHeapBytes() / MEGABYTE, export.samplerHeapBytes() / MEGABYTE); + stage(out, "biome fill (ms/chunk)", vanilla.chunks().biomes(), export.chunks().biomes()); + stage(out, "noise fill (ms/chunk)", vanilla.chunks().noise(), export.chunks().noise()); + stage(out, "surface (ms/chunk)", vanilla.chunks().surface(), export.chunks().surface()); + stage(out, "carver biome lookup (ms/chunk)", vanilla.chunks().carverBiome(), export.chunks().carverBiome()); + row(out, "chunk total (ms/chunk)", vanilla.chunks().totalMean(), export.chunks().totalMean(), + vanilla.chunks().totalCpuMean(), export.chunks().totalCpuMean()); + call(out, "uncached getNoiseBiome (us/call)", vanilla.uncachedBiome(), export.uncachedBiome()); + call(out, "getBaseHeight WORLD_SURFACE (us)", vanilla.baseHeightSurface(), export.baseHeightSurface()); + call(out, "getBaseHeight OCEAN_FLOOR (us)", vanilla.baseHeightFloor(), export.baseHeightFloor()); + call(out, "getBaseColumn (us/call)", vanilla.baseColumn(), export.baseColumn()); + out.append(String.format(Locale.ROOT, "%-24s %9s %9s %9s %10s %10s %10s %10s%n", "router root", "dag(v)", "dag", + "cached", "expand(v)", "expanded", "uncPt(v)", "uncachedPt")); + List vanillaRoots = report.roots().get("vanilla"); + List exportRoots = report.roots().get("export"); + for (DensityCostAnalysis.RootCost root : exportRoots) { + DensityCostAnalysis.RootCost reference = find(vanillaRoots, root.root()); + out.append(String.format(Locale.ROOT, "%-24s %9s %9s %9s %10s %10s %10s %10s%n", root.root(), + reference == null ? "-" : compact(reference.dagNodes()), compact(root.dagNodes()), compact(root.cachedNodes()), + reference == null ? "-" : compact(reference.expandedNodes()), compact(root.expandedNodes()), + reference == null ? "-" : compact(reference.uncachedPointWorst()), compact(root.uncachedPointWorst()))); + } + out.append("top expanded offenders (exclusive share of root, multiplicity)\n"); + for (DensityCostAnalysis.RootCost root : exportRoots) { + if (root.exclusive().isEmpty() || root.expandedNodes() < 1.0e4) { + continue; + } + StringBuilder line = new StringBuilder(String.format(Locale.ROOT, " %-22s", root.root())); + for (int index = 0; index < Math.min(2, root.exclusive().size()); index++) { + DensityCostAnalysis.Offender offender = root.exclusive().get(index); + line.append(String.format(Locale.ROOT, " %4.1f%% %s x%s;", 100.0 * offender.contribution() / root.expandedNodes(), + shortName(offender.name()), compact(offender.multiplicity()))); + } + out.append(line).append('\n'); + } + for (DensityCostAnalysis.RootCost root : exportRoots) { + if (root.dagNodes() < 1.0e4) { + continue; + } + List> shares = new ArrayList<>(root.dagShare().entrySet()); + shares.sort(Map.Entry.comparingByValue().reversed()); + StringBuilder line = new StringBuilder(String.format(Locale.ROOT, " dag %-18s", root.root())); + for (int index = 0; index < Math.min(3, shares.size()); index++) { + line.append(String.format(Locale.ROOT, " %4.1f%% %s;", 100.0 * shares.get(index).getValue(), shares.get(index).getKey())); + } + out.append(line).append('\n'); + } + DensityJsonBreakdown.Report json = report.densityJson(); + out.append(String.format(Locale.ROOT, "density function JSON %.0f MB in %d files, %d cellular instances in %d shapes%n", + json.bytes() / MEGABYTE, json.files(), json.cellularInstances(), json.cellularShapes().size())); + for (int index = 0; index < Math.min(9, json.categories().size()); index++) { + DensityJsonBreakdown.CategoryBytes category = json.categories().get(index); + out.append(String.format(Locale.ROOT, " %-22s %8.1f MB %7d files %7d shapes %8.1f MB dup%n", category.category(), + category.bytes() / MEGABYTE, category.files(), category.distinctShapes(), category.structurallyDuplicateBytes() / MEGABYTE)); + } + for (int index = 0; index < Math.min(4, json.libraryOwners().size()); index++) { + DensityJsonBreakdown.Bytes owner = json.libraryOwners().get(index); + out.append(String.format(Locale.ROOT, " %-48s %8.1f MB%n", owner.name(), owner.bytes() / MEGABYTE)); + } + return out.toString(); + } + + private static void row(StringBuilder out, String label, double vanilla, double export) { + out.append(String.format(Locale.ROOT, "%-34s %10.3f %10.3f %8s%n", label, vanilla, export, ratio(vanilla, export))); + } + + private static void row(StringBuilder out, String label, double vanilla, double export, double vanillaCpu, double exportCpu) { + out.append(String.format(Locale.ROOT, "%-34s %10.3f %10.3f %8s %10.3f %10.3f %8s%n", label, vanilla, export, ratio(vanilla, export), + vanillaCpu, exportCpu, ratio(vanillaCpu, exportCpu))); + } + + private static void stage(StringBuilder out, String label, WorldgenStageBench.StageTiming vanilla, WorldgenStageBench.StageTiming export) { + row(out, label, vanilla.wall().mean(), export.wall().mean(), vanilla.cpu().mean(), export.cpu().mean()); + } + + private static void call(StringBuilder out, String label, WorldgenStageBench.CallTiming vanilla, WorldgenStageBench.CallTiming export) { + row(out, label, vanilla.wall().mean(), export.wall().mean(), vanilla.cpuMean(), export.cpuMean()); + } + + private static String ratio(double vanilla, double export) { + return vanilla > 0.0 ? String.format(Locale.ROOT, "%.1fx", export / vanilla) : "-"; + } + + private static DensityCostAnalysis.RootCost find(List roots, String name) { + for (DensityCostAnalysis.RootCost root : roots) { + if (root.root().equals(name)) { + return root; + } + } + return null; + } + + private static String shortName(String name) { + int separator = name.indexOf(':'); + String path = separator < 0 ? name : name.substring(separator + 1); + return path.length() <= 34 ? path : "..." + path.substring(path.length() - 31); + } + + private static String compact(double value) { + if (value < 1.0e4) { + return String.format(Locale.ROOT, "%.0f", value); + } + return String.format(Locale.ROOT, "%.2e", value); + } + + private static void log(String message) { + System.out.println(LOG_PREFIX + " " + message); + } + + private static void deleteTree(Path root) throws IOException { + if (!Files.exists(root)) { + return; + } + HeadlessNativeRegistries.delete(root); + } + + record Configuration(Path pack, String dimension, long seed, int side, int rounds, int warmup, int calls, long callSeconds, + Path output, Path work) { + Configuration { + if (side < 1 || rounds < 1 || warmup < 1 || calls < MINIMUM_CALLS || callSeconds < 1) { + throw new IllegalArgumentException("side, rounds, warmup and callSeconds must be positive and calls at least " + MINIMUM_CALLS); + } + } + + static Configuration parse(String[] arguments) { + if (arguments.length != 10) { + throw new IllegalArgumentException("Usage: pack dimension seed side rounds warmupSide calls callSeconds output work"); + } + return new Configuration(new File(arguments[0]).toPath().toAbsolutePath(), arguments[1], Long.parseLong(arguments[2]), + Integer.parseInt(arguments[3]), Integer.parseInt(arguments[4]), Integer.parseInt(arguments[5]), + Integer.parseInt(arguments[6]), Long.parseLong(arguments[7]), new File(arguments[8]).toPath().toAbsolutePath(), + new File(arguments[9]).toPath().toAbsolutePath()); + } + } + + record ExportStats(double seconds, Map stageSeconds, long zipBytes, int files, int densityFunctions, + long densityFunctionBytes, int noises, int biomes, int diagnostics) { + } + + record GeneratorStats(double registryLoadSeconds, long retainedHeapBytes, long samplerHeapBytes, WorldgenStageBench.ChunkTimings chunks, + WorldgenStageBench.CallTiming uncachedBiome, WorldgenStageBench.CallTiming baseHeightSurface, + WorldgenStageBench.CallTiming baseHeightFloor, WorldgenStageBench.CallTiming baseColumn) { + } + + record MachineLoad(int processors, double loadAverageStart, double loadAverageEnd) { + } + + record Report(String pack, String dimension, long seed, int chunks, int warmupChunks, int rounds, MachineLoad load, + String threading, String decoration, + ExportStats export, Map generators, DensityJsonBreakdown.Report densityJson, + Map> roots, Map definitions) { + } + + private record ExportRun(ExportStats stats, SortedMap files) { + } + + private static final class Measurement { + private final WorldgenStageBench bench; + private final WorldgenStageBench.ChunkSamples chunks = new WorldgenStageBench.ChunkSamples(); + private final WorldgenStageBench.CallSamples uncachedBiome = new WorldgenStageBench.CallSamples(); + private final WorldgenStageBench.CallSamples surfaceHeight = new WorldgenStageBench.CallSamples(); + private final WorldgenStageBench.CallSamples floorHeight = new WorldgenStageBench.CallSamples(); + private final WorldgenStageBench.CallSamples column = new WorldgenStageBench.CallSamples(); + + private Measurement(WorldgenStageBench bench) { + this.bench = bench; + } + + private GeneratorStats stats(double loadSeconds, long retainedHeap, long samplerHeap) { + return new GeneratorStats(loadSeconds, retainedHeap, samplerHeap, chunks.timings(), uncachedBiome.timing(), + surfaceHeight.timing(), floorHeight.timing(), column.timing()); + } + } + + private static final class StageClock implements WorldgenExportProgress { + private final Map starts = new LinkedHashMap<>(); + private long origin; + + private void start(long nanos) { + origin = nanos; + } + + @Override + public void stage(String stage) { + starts.put(stage, System.nanoTime()); + } + + @Override + public void resources(int done, int total) { + } + + private Map seconds(long finished) { + Map seconds = new LinkedHashMap<>(); + String previous = "setup"; + long previousStart = origin; + for (Map.Entry entry : starts.entrySet()) { + seconds.put(previous, (entry.getValue() - previousStart) / NANOS_PER_SECOND); + previous = entry.getKey(); + previousStart = entry.getValue(); + } + seconds.put(previous, (finished - previousStart) / NANOS_PER_SECOND); + return seconds; + } + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/DatapackFidelityProbe.java b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/DatapackFidelityProbe.java new file mode 100644 index 000000000..4e8228c42 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/DatapackFidelityProbe.java @@ -0,0 +1,331 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.generation.concurrent.MultiBurst; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; +import art.arcane.iris.pack.datapack.export.WorldgenDatapackExporter; +import art.arcane.iris.pack.datapack.export.WorldgenExportMode; +import art.arcane.iris.pack.datapack.export.WorldgenExportProgress; +import art.arcane.iris.pack.datapack.export.WorldgenExportRequest; +import art.arcane.iris.pack.datapack.export.WorldgenExportResult; +import art.arcane.iris.pack.datapack.export.WorldgenExportService; +import art.arcane.iris.pack.datapack.export.compile.surface.MaterialProgramCompiler; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.probe.fidelity.BiomeShareReport; +import art.arcane.iris.probe.fidelity.BiomeTypes; +import art.arcane.iris.probe.fidelity.ColumnSource; +import art.arcane.iris.probe.fidelity.ColumnSurvey; +import art.arcane.iris.probe.fidelity.FidelityComparison; +import art.arcane.iris.probe.fidelity.FidelityReport; +import art.arcane.iris.probe.fidelity.FidelitySampling; +import art.arcane.iris.probe.fidelity.FidelitySamples; +import art.arcane.iris.probe.fidelity.FidelityScanner; +import art.arcane.iris.probe.fidelity.FidelitySource; +import art.arcane.iris.probe.fidelity.FidelityStats; +import art.arcane.iris.probe.fidelity.FidelityThresholds; +import art.arcane.iris.probe.fidelity.IrisEngineColumnSource; +import art.arcane.iris.probe.fidelity.IrisEngineFidelitySource; +import art.arcane.iris.probe.fidelity.SurveyWindow; +import art.arcane.iris.probe.fidelity.VanillaNoiseColumnSource; +import art.arcane.iris.probe.fidelity.VanillaNoiseFidelitySource; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.SortedMap; +import java.util.TreeMap; + +public final class DatapackFidelityProbe { + private static final String LOG_PREFIX = "[datapack-fidelity]"; + private static final String USAGE = "Usage: datapackFidelityProbe pack dimension seed radiusChunks stride output surveyBlocks surveyStride surveySeeds mode"; + private static final String TARGET_VERSION = "26.3"; + private static final String EXPORT_FORMAT = "directory"; + private static final String EXPORT_OUTPUT = "export"; + private static final int MAX_STRIDE = 16; + private static final int CHUNK_WIDTH = 16; + private static final int ARGUMENTS = 10; + private static final String COLUMN_SCOPE = "columns"; + private static final String CHUNK_SCOPE = "chunks"; + private static final int EXIT_ERROR = 1; + private static final int EXIT_FIDELITY_FAILED = 3; + + private DatapackFidelityProbe() { + } + + public static void main(String[] arguments) { + int status; + try { + status = execute(arguments, WorldgenDatapackExporter.create()).passed() ? 0 : EXIT_FIDELITY_FAILED; + } catch (Throwable failure) { + failure.printStackTrace(System.err); + status = EXIT_ERROR; + } + System.out.flush(); + System.err.flush(); + System.exit(status); + } + + static FidelityReport execute(String[] arguments, WorldgenExportService exporter) throws Exception { + Objects.requireNonNull(exporter, "exporter"); + Configuration configuration = Configuration.parse(arguments); + System.out.println(LOG_PREFIX + " pack=" + configuration.pack() + " dimension=" + configuration.dimension() + + " seed=" + configuration.seed() + " radiusChunks=" + configuration.radiusChunks() + " stride=" + configuration.stride() + + " surveyBlocks=" + configuration.survey().size() + " surveyStride=" + configuration.survey().stride() + + " surveySeeds=" + configuration.surveySeeds() + " mode=" + configuration.mode().label()); + Outcome outcome = compare(configuration, exporter); + writeReport(configuration.output(), outcome.json()); + System.out.print(outcome.table()); + System.out.println(LOG_PREFIX + " report=" + configuration.output()); + return outcome.report(); + } + + private static Outcome compare(Configuration configuration, WorldgenExportService exporter) throws Exception { + Path scratch = Files.createTempDirectory("iris-datapack-fidelity-"); + try { + return compareIn(scratch, configuration, exporter); + } finally { + ProbeWorkspaceCleanup.deleteRecursively(scratch); + } + } + + private static Outcome compareIn(Path scratch, Configuration configuration, WorldgenExportService exporter) throws Exception { + HeadlessNativeRuntime runtime = new HeadlessNativeRuntime(); + try (RealPackProbeSupport.Workspace workspace = RealPackProbeSupport.openWorkspace(new RealPackProbeSupport.WorkspaceOptions( + configuration.pack().toFile(), configuration.dimension(), LOG_PREFIX, runtime, scratch))) { + WorldgenExportResult export = export(exporter, workspace.pack().toPath(), configuration.dimension(), scratch.resolve("exports")); + Target target = new Target(export.output(), WorldgenExportRequest.DEFAULT_NAMESPACE, + VanillaResourceIdentifier.sanitizePath(configuration.dimension()), export.loadKeyByBiomeId()); + return switch (configuration.mode()) { + case FULL -> Outcome.of(compareAll(workspace, target, configuration)); + case BIOMES -> Outcome.of(compareBiomes(workspace, target, configuration)); + }; + } + } + + private static FidelityReport compareAll(RealPackProbeSupport.Workspace workspace, Target target, Configuration configuration) throws Exception { + List seeds = configuration.surveySeeds(); + FidelityStats irisColumns = null; + FidelityStats exportColumns = null; + FidelitySamples chunks = null; + for (long seed : seeds) { + try (RealPackProbeSupport.EngineSession session = workspace.openEngine(seed, false, "fidelity-" + seed)) { + Surveyed surveyed = survey(session.engine(), target, seed, configuration.survey()); + irisColumns = irisColumns == null ? surveyed.iris() : irisColumns.merge(surveyed.iris()); + exportColumns = exportColumns == null ? surveyed.export() : exportColumns.merge(surveyed.export()); + if (seed == configuration.seed()) { + chunks = scanChunks(session.engine(), target, configuration); + } + } + } + FidelitySampling columnSampling = new FidelitySampling(COLUMN_SCOPE, configuration.survey().size(), configuration.survey().stride(), + seeds, irisColumns.columns()); + return FidelityComparison.compare(new FidelitySamples(irisColumns, exportColumns, columnSampling), chunks, FidelityThresholds.defaults()); + } + + private static BiomeShareReport compareBiomes(RealPackProbeSupport.Workspace workspace, Target target, Configuration configuration) throws Exception { + List seeds = new ArrayList<>(configuration.seedCount()); + Map biomeTypes = Map.of(); + for (long seed : configuration.surveySeeds()) { + try (RealPackProbeSupport.EngineSession session = workspace.openEngine(seed, false, "biomes-" + seed)) { + Engine engine = session.engine(); + Surveyed surveyed = survey(engine, target, seed, configuration.survey()); + if (biomeTypes.isEmpty()) { + biomeTypes = BiomeTypes.of(engine.getDimension().getAllRegions(engine), key -> engine.getData().getBiomeLoader().load(key)); + } + seeds.add(new FidelitySamples(surveyed.iris(), surveyed.export(), new FidelitySampling(COLUMN_SCOPE, configuration.survey().size(), + configuration.survey().stride(), List.of(seed), surveyed.iris().columns()))); + } + } + return FidelityComparison.compareBiomeShares(seeds, biomeTypes, FidelityThresholds.defaults()); + } + + private static Surveyed survey(Engine engine, Target target, long seed, SurveyWindow window) throws Exception { + try (ColumnSource iris = new IrisEngineColumnSource(engine); + ColumnSource vanilla = new VanillaNoiseColumnSource(target.sampler(seed), target.loadKeyByBiomeId())) { + requireSameHeightRange(iris.minY(), iris.maxY(), vanilla.minY(), vanilla.maxY()); + return new Surveyed(surveyed(iris, window, seed), surveyed(vanilla, window, seed)); + } + } + + private static FidelityStats surveyed(ColumnSource source, SurveyWindow window, long seed) { + long started = System.nanoTime(); + FidelityStats stats = ColumnSurvey.scan(source, window, MultiBurst.burst.parallelism()); + System.out.println(LOG_PREFIX + " surveyed " + source.label() + " seed=" + seed + " columns=" + stats.columns() + + " in " + (System.nanoTime() - started) / 1_000_000L + "ms"); + return stats; + } + + private static FidelitySamples scanChunks(Engine engine, Target target, Configuration configuration) throws Exception { + try (IrisEngineFidelitySource iris = new IrisEngineFidelitySource(engine); + VanillaNoiseFidelitySource vanilla = new VanillaNoiseFidelitySource(target.sampler(configuration.seed()), target.loadKeyByBiomeId(), + target.namespace() + ":" + MaterialProgramCompiler.WALL_SHELL)) { + requireSameHeightRange(iris.minY(), iris.maxY(), vanilla.minY(), vanilla.maxY()); + FidelityStats irisStats = scan(iris, configuration); + FidelityStats exportStats = scan(vanilla, configuration); + FidelitySampling sampling = new FidelitySampling(CHUNK_SCOPE, (2 * configuration.radiusChunks() + 1) * CHUNK_WIDTH, + configuration.stride(), List.of(configuration.seed()), irisStats.columns()); + return new FidelitySamples(irisStats, exportStats, sampling); + } + } + + private static WorldgenExportResult export(WorldgenExportService exporter, Path pack, String dimension, Path exportRoot) throws IOException { + Files.createDirectories(exportRoot); + WorldgenExportRequest request = WorldgenExportRequest.builder() + .pack(pack) + .dimension(dimension) + .version(TARGET_VERSION) + .format(EXPORT_FORMAT) + .output(EXPORT_OUTPUT) + .exportRoot(exportRoot) + .build(); + WorldgenExportResult result = exporter.export(request, WorldgenExportProgress.NONE); + if (!result.isSuccess()) { + throw new IllegalStateException("Datapack export of dimension '" + dimension + "' was " + result.status() + ": " + result.message()); + } + System.out.println(LOG_PREFIX + " exported target=" + TARGET_VERSION + " biomes=" + result.loadKeyByBiomeId().size() + + " diagnostics=" + result.diagnostics().size() + " " + diagnosticCounts(result.diagnostics())); + return result; + } + + private static FidelityStats scan(FidelitySource source, Configuration configuration) { + long started = System.nanoTime(); + FidelityStats stats = FidelityScanner.scan(source, 0, 0, configuration.radiusChunks(), configuration.stride(), + MultiBurst.burst.parallelism()); + System.out.println(LOG_PREFIX + " sampled " + source.label() + " columns=" + stats.columns() + " terrainColumns=" + stats.heightColumns() + + " in " + (System.nanoTime() - started) / 1_000_000L + "ms"); + return stats; + } + + private static void requireSameHeightRange(int irisMinY, int irisMaxY, int exportMinY, int exportMaxY) { + if (irisMinY != exportMinY || irisMaxY != exportMaxY) { + throw new IllegalStateException("Iris height range [" + irisMinY + ", " + irisMaxY + + ") differs from the exported dimension range [" + exportMinY + ", " + exportMaxY + ")"); + } + } + + private static SortedMap diagnosticCounts(List diagnostics) { + SortedMap counts = new TreeMap<>(); + for (ExportDiagnostic diagnostic : diagnostics) { + counts.merge(diagnostic.limit().name(), 1, Integer::sum); + } + return counts; + } + + private static void writeReport(Path output, String json) throws IOException { + Path parent = output.getParent(); + Files.createDirectories(parent); + Path staged = Files.createTempFile(parent, ".datapack-fidelity-", ".json"); + try { + Files.writeString(staged, json + "\n", StandardCharsets.UTF_8); + Files.move(staged, output, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } finally { + Files.deleteIfExists(staged); + } + } + + private enum Mode { + FULL, + BIOMES; + + private static Mode parse(String value) { + String normalized = value.trim().toLowerCase(Locale.ROOT); + for (Mode mode : values()) { + if (mode.label().equals(normalized)) { + return mode; + } + } + throw new IllegalArgumentException("mode must be full or biomes, got '" + value + "'"); + } + + private String label() { + return name().toLowerCase(Locale.ROOT); + } + } + + private record Outcome(FidelityReport report, String json, String table) { + private static Outcome of(FidelityReport report) { + return new Outcome(report, report.toJson(), report.toTable()); + } + + private static Outcome of(BiomeShareReport report) { + return new Outcome(report.mean(), report.toJson(), report.toTable()); + } + } + + private record Surveyed(FidelityStats iris, FidelityStats export) { + } + + private record Target(Path output, String namespace, String dimension, Map loadKeyByBiomeId) { + private WorldgenExportSampler sampler(long seed) throws IOException { + return WorldgenExportSampler.open(output, WorldgenExportMode.OVERWORLD, namespace + ":" + dimension, seed); + } + } + + private record Configuration(Path pack, String dimension, long seed, int radiusChunks, int stride, Path output, SurveyWindow survey, + int seedCount, Mode mode) { + private static Configuration parse(String[] arguments) { + if (arguments.length != ARGUMENTS) { + throw new IllegalArgumentException(USAGE); + } + Path pack = Path.of(arguments[0]).toAbsolutePath().normalize(); + if (!Files.isDirectory(pack)) { + throw new IllegalArgumentException("Pack folder " + pack + " does not exist"); + } + String dimension = arguments[1].trim(); + if (dimension.isEmpty()) { + throw new IllegalArgumentException("Dimension key must not be blank"); + } + long seed = parseLong(arguments[2], "seed"); + int radiusChunks = parseInt(arguments[3], "radiusChunks"); + if (radiusChunks < 0) { + throw new IllegalArgumentException("radiusChunks must be zero or positive, got " + radiusChunks); + } + int stride = parseInt(arguments[4], "stride"); + if (stride < 1 || stride > MAX_STRIDE) { + throw new IllegalArgumentException("stride must be within [1, " + MAX_STRIDE + "], got " + stride); + } + Path output = Path.of(arguments[5]).toAbsolutePath().normalize(); + if (Files.isDirectory(output)) { + throw new IllegalArgumentException("Report output " + output + " is a directory"); + } + SurveyWindow survey = new SurveyWindow(0, 0, parseInt(arguments[6], "surveyBlocks"), parseInt(arguments[7], "surveyStride")); + int seedCount = parseInt(arguments[8], "surveySeeds"); + if (seedCount < 1) { + throw new IllegalArgumentException("surveySeeds must be at least 1, got " + seedCount); + } + Mode mode = Mode.parse(arguments[9]); + return new Configuration(pack, dimension, seed, radiusChunks, stride, output, survey, seedCount, mode); + } + + private List surveySeeds() { + List seeds = new ArrayList<>(seedCount); + for (int index = 0; index < seedCount; index++) { + seeds.add(seed + index); + } + return seeds; + } + + private static long parseLong(String value, String name) { + try { + return Long.parseLong(value.trim()); + } catch (NumberFormatException failure) { + throw new IllegalArgumentException(name + " must be an integer, got '" + value + "'", failure); + } + } + + private static int parseInt(String value, String name) { + try { + return Integer.parseInt(value.trim()); + } catch (NumberFormatException failure) { + throw new IllegalArgumentException(name + " must be an integer, got '" + value + "'", failure); + } + } + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/DensityCostAnalysis.java b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/DensityCostAnalysis.java new file mode 100644 index 000000000..d5f3f4750 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/DensityCostAnalysis.java @@ -0,0 +1,349 @@ +package art.arcane.iris.probe; + +import net.minecraft.core.Holder; +import net.minecraft.util.CubicSpline; +import net.minecraft.world.level.levelgen.densityfunction.DensityFunction; +import net.minecraft.world.level.levelgen.densityfunction.DensityFunctions; +import net.minecraft.world.level.levelgen.densityfunction.generator.ConstantFunction; +import net.minecraft.world.level.levelgen.densityfunction.op.CacheFunction; +import net.minecraft.world.level.levelgen.densityfunction.op.FindTopSurfaceFunction; +import net.minecraft.world.level.levelgen.densityfunction.op.InterpolatedFunction; +import net.minecraft.world.level.levelgen.densityfunction.op.IntervalSelectFunction; +import net.minecraft.world.level.levelgen.densityfunction.op.RangeChoiceFunction; +import net.minecraft.world.level.levelgen.densityfunction.op.SplineFunction; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.function.Function; + +final class DensityCostAnalysis { + private static final double LATTICE_CORNERS = 8.0; + private static final int MAX_SURFACE_STEPS = 4096; + private static final String INLINE = ""; + + private final Function categories; + private final int topCount; + private final Map costs = new IdentityHashMap<>(); + private final Map bodies = new IdentityHashMap<>(); + private final Map names = new IdentityHashMap<>(); + + DensityCostAnalysis(Function categories, int topCount) { + this.categories = categories; + this.topCount = topCount; + } + + RootCost analyze(String root, DensityFunction function) { + Costs total = cost(function); + Reach reach = new Reach(); + reach(function, reach, categories.apply(rootName(function, root))); + double cached = total.cachedLocal(); + for (CacheFunction cache : reach.caches.keySet()) { + cached += cost(cache.input()).cachedLocal(); + } + Body rootBody = new Body(); + local(function, rootBody, true); + Map multiplicity = multiplicity(rootBody); + List offenders = new ArrayList<>(multiplicity.size()); + Map shares = new TreeMap<>(); + double expanded = total.expanded(); + shares.merge(categories.apply(rootName(function, root)), rootBody.self / expanded, Double::sum); + for (Map.Entry entry : multiplicity.entrySet()) { + DensityFunction value = entry.getKey(); + Body body = body(value); + String name = names.get(value); + String category = categories.apply(name); + double contribution = entry.getValue() * body.self; + shares.merge(category, contribution / expanded, Double::sum); + offenders.add(new Offender(name, category, entry.getValue(), body.self, cost(value).expanded(), + contribution, entry.getValue() * cost(value).expanded())); + } + List exclusive = top(offenders, Comparator.comparingDouble(Offender::contribution).reversed()); + List inclusive = top(offenders, Comparator.comparingDouble(Offender::inclusive).reversed()); + Map dagShares = new TreeMap<>(); + for (Map.Entry entry : reach.categories.entrySet()) { + dagShares.put(entry.getKey(), entry.getValue() / Math.max(1L, reach.nodes)); + } + return new RootCost(root, reach.nodes, cached, expanded, total.lazy(), multiplicity.size(), exclusive, inclusive, shares, dagShares); + } + + private List top(List offenders, Comparator order) { + List sorted = new ArrayList<>(offenders); + sorted.sort(order); + return List.copyOf(sorted.subList(0, Math.min(topCount, sorted.size()))); + } + + private String rootName(DensityFunction function, String root) { + if (function instanceof DensityFunctions.HolderHolder(Holder holder)) { + return name(holder); + } + return root; + } + + private Costs cost(DensityFunction function) { + if (function instanceof DensityFunctions.HolderHolder(Holder holder)) { + DensityFunction value = holder.value(); + names.putIfAbsent(value, name(holder)); + return cost(value); + } + Costs known = costs.get(function); + if (known != null) { + return known; + } + Costs computed = compute(function); + costs.put(function, computed); + return computed; + } + + private Costs compute(DensityFunction function) { + return switch (function) { + case ConstantFunction ignored -> new Costs(0.0, 0.0, 0.0); + case CacheFunction cache -> { + Costs input = cost(cache.input()); + yield new Costs(1.0 + input.expanded(), 1.0 + input.lazy(), 1.0); + } + case SplineFunction spline -> { + Costs inner = spline(spline.spline()); + yield new Costs(1.0 + inner.expanded(), 1.0 + inner.lazy(), 1.0 + inner.cachedLocal()); + } + case IntervalSelectFunction select -> { + Costs input = cost(select.input()); + double expanded = 1.0 + input.expanded(); + double cached = 1.0 + input.cachedLocal(); + double lazy = 0.0; + for (DensityFunction branch : select.functions()) { + Costs branchCost = cost(branch); + expanded += branchCost.expanded(); + cached += branchCost.cachedLocal(); + lazy = Math.max(lazy, branchCost.lazy()); + } + yield new Costs(expanded, 1.0 + input.lazy() + lazy, cached); + } + case RangeChoiceFunction choice -> { + Costs input = cost(choice.input()); + Costs inside = cost(choice.whenInRange()); + Costs outside = cost(choice.whenOutOfRange()); + yield new Costs(1.0 + input.expanded() + inside.expanded() + outside.expanded(), + 1.0 + input.lazy() + Math.max(inside.lazy(), outside.lazy()), + 1.0 + input.cachedLocal() + inside.cachedLocal() + outside.cachedLocal()); + } + case InterpolatedFunction grid -> { + Costs input = cost(grid.input()); + yield new Costs(1.0 + input.expanded(), 1.0 + LATTICE_CORNERS * input.expanded(), 1.0 + input.cachedLocal()); + } + case FindTopSurfaceFunction surface -> { + Costs density = cost(surface.density()); + Costs upper = cost(surface.upperBound()); + double steps = surfaceSteps(surface); + yield new Costs(1.0 + density.expanded() + upper.expanded(), 1.0 + upper.lazy() + steps * density.lazy(), + 1.0 + density.cachedLocal() + upper.cachedLocal()); + } + default -> { + double expanded = 1.0; + double lazy = 1.0; + double cached = 1.0; + for (DensityFunction child : children(function)) { + Costs childCost = cost(child); + expanded += childCost.expanded(); + lazy += childCost.lazy(); + cached += childCost.cachedLocal(); + } + yield new Costs(expanded, lazy, cached); + } + }; + } + + private Costs spline(CubicSpline spline) { + if (!(spline instanceof CubicSpline.Multipoint multipoint)) { + return new Costs(0.0, 0.0, 0.0); + } + Costs known = costs.get(multipoint); + if (known != null) { + return known; + } + Costs coordinate = cost(multipoint.coordinate().function()); + List> values = multipoint.values(); + double expanded = 1.0 + coordinate.expanded(); + double cached = 1.0 + coordinate.cachedLocal(); + double[] lazy = new double[values.size()]; + for (int index = 0; index < values.size(); index++) { + Costs value = spline(values.get(index)); + expanded += value.expanded(); + cached += value.cachedLocal(); + lazy[index] = value.lazy(); + } + double worstPair = lazy.length == 1 ? lazy[0] : 0.0; + for (int index = 0; index + 1 < lazy.length; index++) { + worstPair = Math.max(worstPair, lazy[index] + lazy[index + 1]); + } + Costs computed = new Costs(expanded, 1.0 + coordinate.lazy() + worstPair, cached); + costs.put(multipoint, computed); + return computed; + } + + private void reach(DensityFunction function, Reach reach, String category) { + if (function instanceof DensityFunctions.HolderHolder(Holder holder)) { + reach(holder.value(), reach, categories.apply(name(holder))); + return; + } + if (function instanceof ConstantFunction || reach.visited.put(function, Boolean.TRUE) != null) { + return; + } + reach.count(category); + if (function instanceof CacheFunction cache) { + reach.caches.put(cache, Boolean.TRUE); + } + if (function instanceof SplineFunction spline) { + reachSpline(spline.spline(), reach, category); + return; + } + for (DensityFunction child : children(function)) { + reach(child, reach, category); + } + } + + private void reachSpline(CubicSpline spline, Reach reach, String category) { + if (!(spline instanceof CubicSpline.Multipoint multipoint)) { + return; + } + if (reach.visited.put(multipoint, Boolean.TRUE) != null) { + return; + } + reach.count(category); + reach(multipoint.coordinate().function(), reach, category); + for (CubicSpline value : multipoint.values()) { + reachSpline(value, reach, category); + } + } + + private Body body(DensityFunction value) { + Body known = bodies.get(value); + if (known != null) { + return known; + } + Body body = new Body(); + local(value, body, true); + bodies.put(value, body); + return body; + } + + private void local(DensityFunction function, Body body, boolean top) { + if (function instanceof DensityFunctions.HolderHolder(Holder holder)) { + if (!top) { + DensityFunction value = holder.value(); + names.putIfAbsent(value, name(holder)); + body.references.merge(value, 1.0, Double::sum); + return; + } + local(holder.value(), body, true); + return; + } + if (function instanceof ConstantFunction) { + return; + } + body.self++; + if (function instanceof SplineFunction spline) { + localSpline(spline.spline(), body); + return; + } + for (DensityFunction child : children(function)) { + local(child, body, false); + } + } + + private void localSpline(CubicSpline spline, Body body) { + if (!(spline instanceof CubicSpline.Multipoint multipoint)) { + return; + } + body.self++; + local(multipoint.coordinate().function(), body, false); + for (CubicSpline value : multipoint.values()) { + localSpline(value, body); + } + } + + private Map multiplicity(Body root) { + List order = new ArrayList<>(); + Map visited = new IdentityHashMap<>(); + for (DensityFunction child : root.references.keySet()) { + postOrder(child, visited, order); + } + Collections.reverse(order); + Map multiplicity = new IdentityHashMap<>(order.size() * 2); + for (Map.Entry entry : root.references.entrySet()) { + multiplicity.merge(entry.getKey(), entry.getValue(), Double::sum); + } + for (DensityFunction value : order) { + double times = multiplicity.getOrDefault(value, 0.0); + for (Map.Entry entry : body(value).references.entrySet()) { + multiplicity.merge(entry.getKey(), times * entry.getValue(), Double::sum); + } + } + return multiplicity; + } + + private void postOrder(DensityFunction value, Map visited, List order) { + if (visited.put(value, Boolean.TRUE) != null) { + return; + } + for (DensityFunction child : body(value).references.keySet()) { + postOrder(child, visited, order); + } + order.add(value); + } + + private static double surfaceSteps(FindTopSurfaceFunction surface) { + double upper = surface.upperBound().range().max(); + if (!Double.isFinite(upper)) { + return MAX_SURFACE_STEPS; + } + double steps = Math.floor((upper - surface.lowerBound()) / surface.cellHeight()) + 1.0; + return Math.clamp(steps, 1.0, MAX_SURFACE_STEPS); + } + + private static List children(DensityFunction function) { + List children = new ArrayList<>(4); + function.rewriteChildren(child -> { + children.add(child); + return child; + }); + return children; + } + + private static String name(Holder holder) { + return holder.unwrapKey().map(key -> key.identifier().toString()).orElse(INLINE); + } + + record Costs(double expanded, double lazy, double cachedLocal) { + } + + record Offender(String name, String category, double multiplicity, double selfNodes, double subtreeNodes, + double contribution, double inclusive) { + } + + record RootCost(String root, long dagNodes, double cachedNodes, double expandedNodes, double uncachedPointWorst, + int namedSubtrees, List exclusive, List inclusive, Map categoryShare, + Map dagShare) { + } + + private static final class Reach { + private final Map visited = new IdentityHashMap<>(); + private final Map caches = new IdentityHashMap<>(); + private final Map categories = new TreeMap<>(); + private long nodes; + + private void count(String category) { + nodes++; + categories.merge(category, 1.0, Double::sum); + } + } + + private static final class Body { + private final Map references = new IdentityHashMap<>(); + private double self; + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/DensityJsonBreakdown.java b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/DensityJsonBreakdown.java new file mode 100644 index 000000000..6e3f2e35c --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/DensityJsonBreakdown.java @@ -0,0 +1,409 @@ +package art.arcane.iris.probe; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.SortedMap; +import java.util.TreeMap; + +final class DensityJsonBreakdown { + private static final String DENSITY_FUNCTIONS = "/worldgen/density_function/"; + private static final String NOISES = "/worldgen/noise/"; + private static final String JSON = ".json"; + private static final String CELLULAR = "cellular"; + private static final String NOISE_STYLE = "noise style"; + private static final String UNREFERENCED = "unreferenced"; + private static final String OFFSET_MARKER = "/offset_"; + private static final String OFFSET_SUFFIX = " offset copies"; + private static final int INSTANCE_SEGMENTS = 3; + private static final Set ROUTER_ROOTS = Set.of("final_density", "temperature", "vegetation", "continents", "erosion", + "depth", "ridges", "chunk_surface_level", "barrier", "fluid_level_floodedness", "fluid_level_spread", "lava", "exclusion", + "surface_level", "spawn_elevation"); + private static final long FNV_OFFSET = 0xcbf29ce484222325L; + private static final long FNV_PRIME = 0x100000001b3L; + private static final int TOP = 10; + + private final String dimension; + private final String dimensionPrefix; + private final Map categoryIndex = new HashMap<>(); + private final List categoryNames = new ArrayList<>(); + private final Map entries = new TreeMap<>(); + private final Map> parents = new HashMap<>(); + private final Map owners = new HashMap<>(); + + private DensityJsonBreakdown(String dimension) { + this.dimension = dimension; + dimensionPrefix = dimension + "/"; + } + + static DensityJsonBreakdown analyze(SortedMap files, String dimension) { + DensityJsonBreakdown breakdown = new DensityJsonBreakdown(dimension); + breakdown.load(files); + return breakdown; + } + + static String category(String dimension, String id) { + String base = baseCategory(dimension, id); + return id.contains(OFFSET_MARKER) ? base + OFFSET_SUFFIX : base; + } + + private static String baseCategory(String dimension, String id) { + int separator = id.indexOf(':'); + String path = separator < 0 ? id : id.substring(separator + 1); + String dimensionPrefix = dimension + "/"; + if (path.startsWith(dimensionPrefix)) { + path = path.substring(dimensionPrefix.length()); + } + if (ROUTER_ROOTS.contains(path)) { + return "router"; + } + if (path.startsWith("cell/")) { + return CELLULAR; + } + if (path.startsWith("style/")) { + return NOISE_STYLE; + } + if (path.startsWith("biome/selector/")) { + return "biome selector"; + } + if (path.startsWith("biome/carving/")) { + return "biome carving"; + } + if (path.startsWith("biome/")) { + return "biome decision"; + } + if (path.equals("height") || path.startsWith("height/") || path.startsWith("terrain/height/")) { + return "terrain height"; + } + if (path.startsWith("terrain/generator/")) { + return "terrain generator"; + } + if (path.startsWith("terrain/volume/")) { + return "terrain volumetric"; + } + if (path.startsWith("terrain/")) { + return "terrain bands"; + } + if (path.startsWith("cave")) { + return "caves"; + } + if (path.startsWith("surface/")) { + return "surface"; + } + int slash = path.indexOf('/'); + return slash < 0 ? path : path.substring(0, slash); + } + + Report report() { + Map categories = new TreeMap<>(); + Map groups = new TreeMap<>(); + Map libraryOwners = new TreeMap<>(); + long totalBytes = 0; + long totalNodes = 0; + for (Map.Entry item : entries.entrySet()) { + String id = item.getKey(); + Entry entry = item.getValue(); + totalBytes += entry.bytes; + totalNodes += entry.nodes; + String category = category(dimension, id); + categories.computeIfAbsent(category, ignored -> new CategoryAccumulator()).add(entry); + groups.computeIfAbsent(group(id), ignored -> new CategoryAccumulator()).add(entry); + if (isLibrary(category)) { + libraryOwners.merge(category + " via " + ownerLabel(owners(id)), entry.bytes, Long::sum); + } + } + List categoryRows = rows(categories, Integer.MAX_VALUE); + List groupRows = rows(groups, 40); + List ownerRows = new ArrayList<>(); + for (Map.Entry owner : libraryOwners.entrySet()) { + ownerRows.add(new Bytes(owner.getKey(), owner.getValue())); + } + ownerRows.sort(Comparator.comparingLong(Bytes::bytes).reversed()); + List largest = new ArrayList<>(); + for (Map.Entry item : entries.entrySet()) { + largest.add(new Bytes(item.getKey(), item.getValue().bytes)); + } + largest.sort(Comparator.comparingLong(Bytes::bytes).reversed()); + return new Report(entries.size(), totalBytes, totalNodes, categoryRows, groupRows, ownerRows, + List.copyOf(largest.subList(0, Math.min(TOP, largest.size()))), cellularInstances()); + } + + private void load(SortedMap files) { + Map bodies = new HashMap<>(); + Set noises = new HashSet<>(); + for (Map.Entry file : files.entrySet()) { + String id = resourceId(file.getKey(), DENSITY_FUNCTIONS); + if (id != null) { + bodies.put(id, file.getValue()); + continue; + } + String noise = resourceId(file.getKey(), NOISES); + if (noise != null) { + noises.add(noise); + } + } + for (Map.Entry body : bodies.entrySet()) { + JsonElement json = JsonParser.parseString(new String(body.getValue(), StandardCharsets.UTF_8)); + Entry entry = new Entry(body.getValue().length); + entry.shape = shape(json, bodies.keySet(), noises, entry); + entries.put(body.getKey(), entry); + for (String reference : entry.references) { + parents.computeIfAbsent(reference, ignored -> new ArrayList<>()).add(body.getKey()); + } + } + } + + private long shape(JsonElement element, Set functions, Set noises, Entry entry) { + if (element.isJsonObject()) { + entry.nodes++; + JsonObject object = element.getAsJsonObject(); + long hash = mix(FNV_OFFSET, '{'); + for (String key : new TreeMap<>(object.asMap()).keySet()) { + hash = mix(mix(hash, key.hashCode()), shape(object.get(key), functions, noises, entry)); + } + return mix(hash, '}'); + } + if (element.isJsonArray()) { + JsonArray array = element.getAsJsonArray(); + long hash = mix(FNV_OFFSET, '['); + for (JsonElement item : array) { + hash = mix(hash, shape(item, functions, noises, entry)); + } + return mix(hash, ']'); + } + if (element.isJsonPrimitive()) { + JsonPrimitive primitive = element.getAsJsonPrimitive(); + if (primitive.isNumber()) { + return mix(FNV_OFFSET, 'N'); + } + if (primitive.isString()) { + String value = primitive.getAsString(); + if (functions.contains(value)) { + entry.references.add(value); + return mix(FNV_OFFSET, 'R'); + } + if (noises.contains(value)) { + return mix(FNV_OFFSET, 'Z'); + } + return mix(FNV_OFFSET, value.hashCode()); + } + return mix(FNV_OFFSET, primitive.getAsBoolean() ? 'T' : 'F'); + } + return mix(FNV_OFFSET, '0'); + } + + private List cellularInstances() { + Map> instances = new TreeMap<>(); + for (String id : entries.keySet()) { + if (!isLibrary(category(dimension, id)) || !id.contains(":cell/")) { + continue; + } + instances.computeIfAbsent(instanceKey(id), ignored -> new ArrayList<>()).add(id); + } + Map shapes = new HashMap<>(); + for (Map.Entry> instance : instances.entrySet()) { + List members = instance.getValue(); + members.sort(String::compareTo); + long hash = FNV_OFFSET; + long bytes = 0; + long owner = 0L; + StringBuilder layout = new StringBuilder(); + for (String member : members) { + Entry entry = entries.get(member); + String suffix = member.substring(instance.getKey().length()); + hash = mix(mix(hash, suffix.hashCode()), entry.shape); + bytes += entry.bytes; + owner |= owners(member); + if (!layout.isEmpty()) { + layout.append(','); + } + layout.append(suffix); + } + CellularAccumulator accumulator = shapes.computeIfAbsent(hash, ignored -> new CellularAccumulator(instance.getKey(), layout.toString())); + accumulator.instances++; + accumulator.bytes += bytes; + accumulator.owners |= owner; + } + List rows = new ArrayList<>(shapes.size()); + for (CellularAccumulator accumulator : shapes.values()) { + rows.add(new CellularShape(accumulator.example, accumulator.layout, accumulator.instances, accumulator.bytes, ownerLabel(accumulator.owners))); + } + rows.sort(Comparator.comparingLong(CellularShape::bytes).reversed()); + return List.copyOf(rows); + } + + private long owners(String id) { + Long known = owners.get(id); + if (known != null) { + return known; + } + owners.put(id, 0L); + long mask = 0L; + List referrers = parents.get(id); + if (referrers == null) { + mask = bit(UNREFERENCED); + } else { + for (String parent : referrers) { + String category = category(dimension, parent); + mask |= isLibrary(category) ? owners(parent) : bit(category); + } + } + owners.put(id, mask); + return mask; + } + + private long bit(String category) { + int index = categoryIndex.computeIfAbsent(category, name -> { + categoryNames.add(name); + return categoryNames.size() - 1; + }); + if (index >= Long.SIZE) { + throw new IllegalStateException("More than " + Long.SIZE + " density function owner categories"); + } + return 1L << index; + } + + private String ownerLabel(long mask) { + if (mask == 0L) { + return UNREFERENCED; + } + List labels = new ArrayList<>(Long.bitCount(mask)); + for (int index = 0; index < categoryNames.size(); index++) { + if ((mask & (1L << index)) != 0L) { + labels.add(categoryNames.get(index)); + } + } + labels.sort(String::compareTo); + return String.join("+", labels); + } + + private String group(String id) { + int separator = id.indexOf(':'); + String path = separator < 0 ? id : id.substring(separator + 1); + if (path.startsWith(dimensionPrefix)) { + path = path.substring(dimensionPrefix.length()); + } + String[] segments = path.split("/"); + if (segments.length == 1) { + return segments[0]; + } + if (segments[0].equals("cell") || segments[0].equals("style")) { + return segments[0] + "/*/" + segments[segments.length - 1]; + } + return segments[0] + "/" + segments[1]; + } + + private static boolean isLibrary(String category) { + return category.startsWith(CELLULAR) || category.startsWith(NOISE_STYLE); + } + + private static String instanceKey(String id) { + int end = id.indexOf(':') + 1; + for (int segment = 0; segment < INSTANCE_SEGMENTS && end > 0; segment++) { + int slash = id.indexOf('/', end); + if (slash < 0) { + return id; + } + end = slash + 1; + } + return id.substring(0, end - 1); + } + + private static List rows(Map accumulators, int limit) { + List rows = new ArrayList<>(accumulators.size()); + for (Map.Entry item : accumulators.entrySet()) { + CategoryAccumulator accumulator = item.getValue(); + long representative = 0; + for (long bytes : accumulator.representativeBytes.values()) { + representative += bytes; + } + rows.add(new CategoryBytes(item.getKey(), accumulator.files, accumulator.bytes, accumulator.nodes, + accumulator.representativeBytes.size(), accumulator.bytes - representative)); + } + rows.sort(Comparator.comparingLong(CategoryBytes::bytes).reversed()); + return List.copyOf(rows.subList(0, Math.min(limit, rows.size()))); + } + + private static String resourceId(String path, String folder) { + int marker = path.indexOf(folder); + if (!path.startsWith("data/") || marker < 0 || !path.endsWith(JSON)) { + return null; + } + return path.substring("data/".length(), marker) + ":" + path.substring(marker + folder.length(), path.length() - JSON.length()); + } + + private static long mix(long hash, long value) { + long mixed = hash ^ value; + mixed *= FNV_PRIME; + return mixed ^ (mixed >>> 29); + } + + record Report(int files, long bytes, long nodes, List categories, List groups, + List libraryOwners, List largest, List cellularShapes) { + int cellularInstances() { + int count = 0; + for (CellularShape shape : cellularShapes) { + count += shape.instances(); + } + return count; + } + } + + record CategoryBytes(String category, int files, long bytes, long nodes, int distinctShapes, long structurallyDuplicateBytes) { + } + + record Bytes(String name, long bytes) { + } + + record CellularShape(String example, String members, int instances, long bytes, String owners) { + } + + private static final class Entry { + private final long bytes; + private final List references = new ArrayList<>(); + private long nodes; + private long shape; + + private Entry(long bytes) { + this.bytes = bytes; + } + } + + private static final class CategoryAccumulator { + private final Map representativeBytes = new HashMap<>(); + private int files; + private long bytes; + private long nodes; + + private void add(Entry entry) { + files++; + bytes += entry.bytes; + nodes += entry.nodes; + representativeBytes.putIfAbsent(entry.shape, entry.bytes); + } + } + + private static final class CellularAccumulator { + private final String example; + private final String layout; + private int instances; + private long bytes; + private long owners; + + private CellularAccumulator(String example, String layout) { + this.example = example; + this.layout = layout; + } + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessNativePlatform.java b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessNativePlatform.java index 942228c58..91349e094 100644 --- a/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessNativePlatform.java +++ b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessNativePlatform.java @@ -1,6 +1,7 @@ package art.arcane.iris.probe; import art.arcane.iris.configuration.IrisSettings; +import art.arcane.iris.platform.ClasspathVanillaDataSource; import art.arcane.iris.platform.bukkit.nms.BukkitGenerationRegistry; import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.PlatformGenerationRegistry; @@ -9,6 +10,7 @@ import art.arcane.iris.spi.PlatformBiomeWriter; import art.arcane.iris.spi.PlatformRegistries; import art.arcane.iris.spi.PlatformScheduler; import art.arcane.iris.spi.PlatformStructureHooks; +import art.arcane.iris.spi.VanillaDataSource; import art.arcane.volmlib.nativelib.entity.NativeEntityType; import art.arcane.volmlib.nativelib.item.NativeItem; import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedBlockState; @@ -35,6 +37,7 @@ final class HeadlessNativePlatform implements IrisPlatform, PlatformRegistries, private final NativeRegistryAccess nativeAccess; private final PlatformBiomeWriter biomeWriter; private final PlatformGenerationRegistry generationRegistry; + private final ClasspathVanillaDataSource vanillaData = new ClasspathVanillaDataSource(() -> SharedConstants.class); private final NativeBlockResolver blocks = new NativeBlockResolver(this); private final NativeBlockState air = blocks.getAir(); private final ConcurrentHashMap states = new ConcurrentHashMap<>(); @@ -215,6 +218,11 @@ final class HeadlessNativePlatform implements IrisPlatform, PlatformRegistries, return biomeWriter; } + @Override + public VanillaDataSource vanillaData() { + return vanillaData; + } + @Override public File dataFolder() { return services.dataFolder(); diff --git a/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessNativeRegistries.java b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessNativeRegistries.java index 13f3c3cd3..256aeea2d 100644 --- a/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessNativeRegistries.java +++ b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessNativeRegistries.java @@ -4,6 +4,7 @@ import art.arcane.iris.pack.datapack.IrisDatapackCompiler; import art.arcane.iris.pack.datapack.v263.DataFixerV263; import art.arcane.volmlib.util.collection.KList; +import net.minecraft.core.HolderLookup; import net.minecraft.core.RegistryAccess; import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.network.chat.Component; @@ -25,6 +26,7 @@ import java.util.Map; import java.util.Optional; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.function.Function; import java.util.stream.Stream; final class HeadlessNativeRegistries implements AutoCloseable { @@ -70,7 +72,7 @@ final class HeadlessNativeRegistries implements AutoCloseable { } } - private static HeadlessNativeRegistries load(Path directory) { + static HeadlessNativeRegistries load(Path directory) { PackLocationInfo location = new PackLocationInfo("iris-headless", Component.literal("Iris"), PackSource.WORLD, Optional.empty()); MultiPackResourceManager resources = new MultiPackResourceManager(PackType.SERVER_DATA, List.of( @@ -78,12 +80,17 @@ final class HeadlessNativeRegistries implements AutoCloseable { try { RegistryAccess.Frozen builtins = RegistryAccess.fromRegistryOfRegistries(BuiltInRegistries.REGISTRY); RegistryAccess.Frozen loaded; + RegistryAccess.Frozen dimensions; try (ExecutorService executor = Executors.newFixedThreadPool(4)) { loaded = RegistryDataLoader.load(resources, builtins.listRegistries().toList(), RegistryDataLoader.WORLD_REGISTRIES, executor).join(); + List> dimensionContext = + Stream.concat(builtins.listRegistries(), loaded.listRegistries()).toList(); + dimensions = RegistryDataLoader.load(resources, dimensionContext, + RegistryDataLoader.DIMENSION_REGISTRIES, executor).join(); } - RegistryAccess.Frozen registries = new RegistryAccess.ImmutableRegistryAccess( - Stream.concat(builtins.registries(), loaded.registries())).freeze(); + RegistryAccess.Frozen registries = new RegistryAccess.ImmutableRegistryAccess(Stream.of( + builtins.registries(), loaded.registries(), dimensions.registries()).flatMap(Function.identity())).freeze(); HeadlessNativeBootstrap.bindComponents(registries); return new HeadlessNativeRegistries(directory, resources, registries); } catch (RuntimeException failure) { @@ -106,7 +113,7 @@ final class HeadlessNativeRegistries implements AutoCloseable { delete(directory); } - private static void delete(Path directory) throws IOException { + static void delete(Path directory) throws IOException { try (Stream paths = Files.walk(directory)) { for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) { Files.delete(path); diff --git a/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessNativeRuntime.java b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessNativeRuntime.java index 2608e4f3f..b65bdcc19 100644 --- a/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessNativeRuntime.java +++ b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessNativeRuntime.java @@ -32,6 +32,7 @@ final class HeadlessNativeRuntime implements RealPackProbeSupport.RuntimeBinding private final Path stagingRoot; private NativeRegionTerrainWriter writer; private HeadlessNativeRegistries nativeRegistries; + private File preparedPack; private HolderLookup.Provider registries; HeadlessNativeRuntime() { @@ -60,12 +61,16 @@ final class HeadlessNativeRuntime implements RealPackProbeSupport.RuntimeBinding @Override public void prepare(IrisData data, IrisDimension dimension) { try { - if (nativeRegistries != null) { + File pack = data.getDataFolder().getCanonicalFile(); + if (nativeRegistries != null && !pack.equals(preparedPack)) { throw new IllegalStateException("Headless native runtime supports one immutable pack per process"); } - nativeRegistries = HeadlessNativeRegistries.compile(data.getDataFolder()); + if (nativeRegistries == null) { + nativeRegistries = HeadlessNativeRegistries.compile(data.getDataFolder()); + preparedPack = pack; + writer = new NativeRegionTerrainWriter(nativeRegistries.registries()); + } registries = nativeRegistries.registries(); - writer = new NativeRegionTerrainWriter(nativeRegistries.registries()); } catch (IOException failure) { throw new UncheckedIOException("Cannot load native generation registries", failure); } @@ -92,6 +97,7 @@ final class HeadlessNativeRuntime implements RealPackProbeSupport.RuntimeBinding if (nativeRegistries != null) { nativeRegistries.close(); nativeRegistries = null; + preparedPack = null; } } diff --git a/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/PaperRegistryScope.java b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/PaperRegistryScope.java new file mode 100644 index 000000000..92b36d22a --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/PaperRegistryScope.java @@ -0,0 +1,39 @@ +package art.arcane.iris.probe; + +import io.papermc.paper.registry.PaperRegistryAccess; + +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.Map; + +final class PaperRegistryScope implements AutoCloseable { + private final Map registries; + private final Map previous; + + private PaperRegistryScope(Map registries) { + this.registries = registries; + this.previous = new HashMap<>(registries); + registries.clear(); + } + + static PaperRegistryScope open() { + return new PaperRegistryScope(registries()); + } + + @Override + public void close() { + registries.clear(); + registries.putAll(previous); + } + + @SuppressWarnings("unchecked") + private static Map registries() { + try { + Field field = PaperRegistryAccess.class.getDeclaredField("registries"); + field.setAccessible(true); + return (Map) field.get(PaperRegistryAccess.instance()); + } catch (ReflectiveOperationException failure) { + throw new IllegalStateException("Paper registry access has no registries map to isolate", failure); + } + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/WorldgenExportSampler.java b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/WorldgenExportSampler.java new file mode 100644 index 000000000..96519dfbf --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/WorldgenExportSampler.java @@ -0,0 +1,301 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.pack.datapack.export.WorldgenExportMode; +import net.minecraft.core.Holder; +import net.minecraft.core.QuartPos; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.util.datafix.DataFixers; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.LevelHeightAccessor; +import net.minecraft.world.level.StructureManager; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.biome.BiomeManager; +import net.minecraft.world.level.biome.BiomeResolver; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.chunk.PalettedContainerFactory; +import net.minecraft.world.level.chunk.ProtoChunk; +import net.minecraft.world.level.chunk.UpgradeData; +import net.minecraft.world.level.dimension.DimensionType; +import net.minecraft.world.level.dimension.LevelStem; +import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator; +import net.minecraft.world.level.levelgen.NoiseChunk; +import net.minecraft.world.level.levelgen.NoiseGeneratorSettings; +import net.minecraft.world.level.levelgen.NoiseSettings; +import net.minecraft.world.level.levelgen.RandomState; +import net.minecraft.world.level.levelgen.WorldGenerationContext; +import net.minecraft.world.level.levelgen.blending.Blender; +import net.minecraft.world.level.levelgen.densityfunction.DensityFunction; +import net.minecraft.world.level.levelgen.densityfunction.DensityVolume; +import net.minecraft.world.level.levelgen.densityfunction.SamplerContext; +import net.minecraft.world.level.levelgen.densityfunction.ScopedDensityBuffer; +import net.minecraft.world.level.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.StructureStart; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; +import net.minecraft.world.level.storage.LevelStorageSource; + +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.SortedMap; +import java.util.SortedSet; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.function.Predicate; +import java.util.stream.Stream; + +public final class WorldgenExportSampler implements AutoCloseable { + private static final String DENSITY_FUNCTIONS = "/worldgen/density_function/"; + private static final int CHUNK_WIDTH = 16; + private static final Method CREATE_NOISE_CHUNK = generatorMethod("createNoiseChunk", ChunkAccess.class, StructureManager.class, Blender.class, + RandomState.class, NoiseSettings.class); + private static final Method DO_FILL = generatorMethod("doFill", NoiseChunk.class, ChunkAccess.class); + + private final HeadlessNativeRegistries loaded; + private final NoiseBasedChunkGenerator generator; + private final RandomState randomState; + private final ThreadLocal resolvers; + private final LevelHeightAccessor heights; + private final SortedSet densityFunctionIds; + private final ThreadLocal densityContexts = ThreadLocal.withInitial(() -> SamplerContext.builder().enableCaches().build()); + private final PalettedContainerFactory containers; + private final long seed; + private final long biomeZoomSeed; + + private WorldgenExportSampler(HeadlessNativeRegistries loaded, LevelStem stem, long seed, SortedSet densityFunctionIds) { + this.loaded = loaded; + if (!(stem.generator() instanceof NoiseBasedChunkGenerator noiseGenerator)) { + throw new IllegalStateException("Exported dimension uses " + stem.generator().getClass().getSimpleName() + ", not a noise generator"); + } + this.generator = noiseGenerator; + this.randomState = RandomState.create(loaded.registries().lookupOrThrow(Registries.NOISE), seed, noiseGenerator.generatorSettings().value()); + this.resolvers = ThreadLocal.withInitial(() -> noiseGenerator.getBiomeSource().createCachingResolver(randomState)); + DimensionType type = stem.type().value(); + this.heights = LevelHeightAccessor.create(type.minY(), type.height()); + this.densityFunctionIds = densityFunctionIds; + this.containers = PalettedContainerFactory.create(loaded.registries()); + this.seed = seed; + this.biomeZoomSeed = BiomeManager.obfuscateSeed(seed); + } + + public static WorldgenExportSampler open(Map files, WorldgenExportMode mode, String dimensionId, long seed) throws IOException { + Objects.requireNonNull(mode); + Identifier dimension = Identifier.parse(dimensionId); + HeadlessNativeBootstrap.initialize(); + Path directory = Files.createTempDirectory("iris-worldgen-export-"); + HeadlessNativeRegistries loaded; + try { + for (Map.Entry file : files.entrySet()) { + Path target = directory.resolve(file.getKey()).normalize(); + if (!target.startsWith(directory)) { + throw new IllegalArgumentException("Export file escapes the datapack root: " + file.getKey()); + } + Files.createDirectories(target.getParent()); + Files.write(target, file.getValue()); + } + try (PaperRegistryScope scope = PaperRegistryScope.open()) { + loaded = HeadlessNativeRegistries.load(directory); + } + } catch (IOException | RuntimeException failure) { + HeadlessNativeRegistries.delete(directory); + throw failure; + } + try { + return new WorldgenExportSampler(loaded, stem(loaded.registries(), mode, dimension), seed, densityFunctionIds(files)); + } catch (RuntimeException failure) { + loaded.close(); + throw failure; + } + } + + public static WorldgenExportSampler open(Path datapack, WorldgenExportMode mode, String dimensionId, long seed) throws IOException { + return open(files(datapack), mode, dimensionId, seed); + } + + public float density(String functionId, int x, int y, int z) { + DensityFunction function = loaded.registries().lookupOrThrow(Registries.DENSITY_FUNCTION) + .getOrThrow(ResourceKey.create(Registries.DENSITY_FUNCTION, Identifier.parse(functionId))).value(); + return randomState.getSampler(function).sampleValue(densityContexts.get(), x, y, z); + } + + public float[] densityChunk(DensityFunction function, int chunkX, int chunkZ) { + DensityVolume volume = new DensityVolume(CHUNK_WIDTH, heights.getHeight(), CHUNK_WIDTH, chunkX * CHUNK_WIDTH, heights.getMinY(), chunkZ * CHUNK_WIDTH); + return sampleVolume(function, volume); + } + + public DensityFunction finalDensity() { + return generator.generatorSettings().value().noiseRouter().finalDensity(); + } + + public int baseHeight(int x, int z) { + return generator.getBaseHeight(x, z, Heightmap.Types.WORLD_SURFACE_WG, heights, randomState); + } + + public int floorHeight(int x, int z) { + return generator.getBaseHeight(x, z, Heightmap.Types.OCEAN_FLOOR_WG, heights, randomState); + } + + public ProtoChunk terrainChunk(int chunkX, int chunkZ) { + ProtoChunk chunk = new ProtoChunk(new ChunkPos(chunkX, chunkZ), UpgradeData.EMPTY, heights, containers, null); + NoiseGeneratorSettings settings = generator.generatorSettings().value(); + NoiseSettings noise = settings.noiseSettings().clampToHeightAccessor(chunk.getHeightAccessorForGeneration()); + try (NoiseChunk noiseChunk = (NoiseChunk) invoke(CREATE_NOISE_CHUNK, chunk, NoStructures.INSTANCE, Blender.empty(), randomState, noise)) { + invoke(DO_FILL, noiseChunk, chunk); + randomState.surfaceSystem().buildSurface(randomState, new BiomeManager(resolvers.get(), biomeZoomSeed), + new WorldGenerationContext(generator, chunk.getHeightAccessorForGeneration()), chunk, noiseChunk, settings.materialRule().value(), null); + } + return chunk; + } + + public StructureStart structureStart(String structureId, int chunkX, int chunkZ) throws IOException { + Holder.Reference structure = loaded.registries().lookupOrThrow(Registries.STRUCTURE) + .getOrThrow(ResourceKey.create(Registries.STRUCTURE, Identifier.parse(structureId))); + Path storageRoot = Files.createTempDirectory("iris-worldgen-export-structures-"); + try (LevelStorageSource.LevelStorageAccess storage = LevelStorageSource.createDefault(storageRoot).createAccess("export")) { + StructureTemplateManager templates = new StructureTemplateManager(loaded.resources(), storage, DataFixers.getDataFixer(), + loaded.registries().lookupOrThrow(Registries.BLOCK)); + return structure.value().generate(structure, Level.OVERWORLD, loaded.registries(), generator, generator.getBiomeSource(), + randomState.createClimateSampler(SamplerContext.builder().enableCaches().build()), randomState, templates, seed, + new ChunkPos(chunkX, chunkZ), 0, heights, structure.value().biomes()::contains); + } finally { + HeadlessNativeRegistries.delete(storageRoot); + } + } + + public String biome(int x, int y, int z) { + Holder biome = resolvers.get().getNoiseBiome(QuartPos.fromBlock(x), QuartPos.fromBlock(y), QuartPos.fromBlock(z)); + return biome.unwrapKey().orElseThrow().identifier().toString(); + } + + public Set possibleBiomes() { + Set biomes = new TreeSet<>(); + for (Holder biome : generator.getBiomeSource().possibleBiomes()) { + biomes.add(biome.unwrapKey().orElseThrow().identifier().toString()); + } + return biomes; + } + + public SortedSet densityFunctionIds() { + return densityFunctionIds; + } + + public int minY() { + return heights.getMinY(); + } + + public int height() { + return heights.getHeight(); + } + + public int seaLevel() { + return generator.getSeaLevel(); + } + + public RegistryAccess registries() { + return loaded.registries(); + } + + @Override + public void close() throws IOException { + loaded.close(); + } + + private float[] sampleVolume(DensityFunction function, DensityVolume volume) { + try (ScopedDensityBuffer buffer = randomState.getSampler(function).bind(densityContexts.get()).sampleVolume(volume)) { + float[] values = new float[volume.size()]; + for (int index = 0; index < values.length; index++) { + values[index] = buffer.get(index); + } + return values; + } + } + + private Object invoke(Method method, Object... arguments) { + try { + return method.invoke(generator, arguments); + } catch (InvocationTargetException failure) { + if (failure.getCause() instanceof RuntimeException runtime) { + throw runtime; + } + if (failure.getCause() instanceof Error error) { + throw error; + } + throw new IllegalStateException("Terrain stage " + method.getName() + " failed", failure.getCause()); + } catch (IllegalAccessException failure) { + throw new IllegalStateException("Terrain stage " + method.getName() + " is not accessible", failure); + } + } + + private static Method generatorMethod(String name, Class... parameters) { + try { + Method method = NoiseBasedChunkGenerator.class.getDeclaredMethod(name, parameters); + method.setAccessible(true); + return method; + } catch (NoSuchMethodException failure) { + throw new IllegalStateException("NoiseBasedChunkGenerator has no terrain stage " + name, failure); + } + } + + private static LevelStem stem(RegistryAccess registries, WorldgenExportMode mode, Identifier dimension) { + return switch (mode) { + case PRESET -> registries.lookupOrThrow(Registries.WORLD_PRESET) + .getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, dimension)).value().overworld() + .orElseThrow(() -> new IllegalStateException("World preset " + dimension + " has no overworld")); + case DIMENSION -> levelStem(registries, dimension); + case OVERWORLD -> levelStem(registries, LevelStem.OVERWORLD.identifier()); + }; + } + + private static LevelStem levelStem(RegistryAccess registries, Identifier id) { + return registries.lookupOrThrow(Registries.LEVEL_STEM).getOrThrow(ResourceKey.create(Registries.LEVEL_STEM, id)).value(); + } + + private static SortedMap files(Path datapack) throws IOException { + if (!Files.isDirectory(datapack)) { + throw new IOException("Exported datapack " + datapack + " is not a directory"); + } + SortedMap files = new TreeMap<>(); + try (Stream paths = Files.walk(datapack)) { + List regular = paths.filter(Files::isRegularFile).toList(); + for (Path file : regular) { + files.put(datapack.relativize(file).toString().replace('\\', '/'), Files.readAllBytes(file)); + } + } + return files; + } + + private static SortedSet densityFunctionIds(Map files) { + SortedSet ids = new TreeSet<>(); + for (String path : files.keySet()) { + int marker = path.indexOf(DENSITY_FUNCTIONS); + if (path.startsWith("data/") && marker > 0 && path.endsWith(".json")) { + String namespace = path.substring("data/".length(), marker); + ids.add(namespace + ":" + path.substring(marker + DENSITY_FUNCTIONS.length(), path.length() - ".json".length())); + } + } + return ids; + } + + private static final class NoStructures extends StructureManager { + private static final NoStructures INSTANCE = new NoStructures(); + + private NoStructures() { + super(null, null, null); + } + + @Override + public List startsForStructure(int chunkX, int chunkZ, Predicate predicate) { + return List.of(); + } + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/WorldgenStageBench.java b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/WorldgenStageBench.java new file mode 100644 index 000000000..e04877741 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/WorldgenStageBench.java @@ -0,0 +1,394 @@ +package art.arcane.iris.probe; + +import io.papermc.paper.configuration.WorldConfiguration; +import net.minecraft.core.Holder; +import net.minecraft.core.QuartPos; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.LevelHeightAccessor; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.biome.BiomeManager; +import net.minecraft.world.level.biome.BiomeResolver; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.chunk.PalettedContainerFactory; +import net.minecraft.world.level.chunk.ProtoChunk; +import net.minecraft.world.level.chunk.UpgradeData; +import net.minecraft.world.level.chunk.status.ChunkStatus; +import net.minecraft.world.level.dimension.DimensionType; +import net.minecraft.world.level.dimension.LevelStem; +import net.minecraft.world.level.levelgen.Aquifer; +import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator; +import net.minecraft.world.level.levelgen.NoiseChunk; +import net.minecraft.world.level.levelgen.NoiseGeneratorSettings; +import net.minecraft.world.level.levelgen.NoiseSettings; +import net.minecraft.world.level.levelgen.RandomState; +import net.minecraft.world.level.levelgen.WorldGenerationContext; +import net.minecraft.world.level.levelgen.blending.Blender; +import net.minecraft.world.level.levelgen.densityfunction.DensityVolume; +import net.minecraft.world.level.levelgen.material.rule.MaterialRule; +import org.spigotmc.SpigotWorldConfig; + +import java.lang.management.ManagementFactory; +import java.lang.management.ThreadMXBean; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.SplittableRandom; +import java.util.function.Supplier; + +final class WorldgenStageBench { + private static final int SAMPLE_RADIUS_BLOCKS = 32_768; + private static final double NANOS_PER_MILLI = 1_000_000.0; + private static final double NANOS_PER_MICRO = 1_000.0; + private static final ThreadMXBean THREADS = ManagementFactory.getThreadMXBean(); + + private final String label; + private final NoiseBasedChunkGenerator generator; + private final NoiseGeneratorSettings settings; + private final RandomState randomState; + private final LevelHeightAccessor heights; + private final PalettedContainerFactory containers; + private final long seed; + private final Method fill; + private final Aquifer.FluidPicker fluidPicker; + private final BiomeResolver uncached; + private final Level surfaceLevel; + + WorldgenStageBench(Options options) { + label = options.label(); + if (!(options.stem().generator() instanceof NoiseBasedChunkGenerator noiseGenerator)) { + throw new IllegalStateException(label + " uses " + options.stem().generator().getClass().getSimpleName() + ", not a noise generator"); + } + generator = noiseGenerator; + settings = noiseGenerator.generatorSettings().value(); + seed = options.seed(); + randomState = RandomState.create(options.registries().lookupOrThrow(Registries.NOISE), seed, settings); + DimensionType type = options.stem().type().value(); + heights = LevelHeightAccessor.create(type.minY(), type.height()); + containers = PalettedContainerFactory.create(options.registries()); + fill = fillMethod(); + fluidPicker = fluidPicker(noiseGenerator); + uncached = noiseGenerator.getBiomeSource().createUncachedResolver(randomState); + surfaceLevel = paperConfiguredLevel(); + } + + String label() { + return label; + } + + NoiseGeneratorSettings settings() { + return settings; + } + + void chunks(int originChunkX, int originChunkZ, int side, ChunkSamples samples) { + int ring = side + 2; + Map chunks = new HashMap<>(ring * ring * 2); + for (int dz = -1; dz <= side; dz++) { + for (int dx = -1; dx <= side; dx++) { + ChunkPos position = new ChunkPos(originChunkX + dx, originChunkZ + dz); + ProtoChunk chunk = new ProtoChunk(position, UpgradeData.EMPTY, heights, containers, null); + long started = System.nanoTime(); + long startedCpu = THREADS.getCurrentThreadCpuTime(); + generator.createBiomes(randomState, Blender.empty(), null, chunk).join(); + samples.biomes.add(System.nanoTime() - started, THREADS.getCurrentThreadCpuTime() - startedCpu); + chunk.setPersistedStatus(ChunkStatus.BIOMES); + chunks.put(ChunkPos.pack(position.x(), position.z()), chunk); + } + } + RegionBiomes region = new RegionBiomes(chunks, uncached); + BiomeManager biomeManager = new BiomeManager(region, BiomeManager.obfuscateSeed(seed)); + MaterialRule materialRule = settings.materialRule().value(); + for (int dz = 0; dz < side; dz++) { + for (int dx = 0; dx < side; dx++) { + int chunkX = originChunkX + dx; + int chunkZ = originChunkZ + dz; + ProtoChunk chunk = chunks.get(ChunkPos.pack(chunkX, chunkZ)); + Set> possible = possibleBiomes(chunks, chunkX, chunkZ); + long started = System.nanoTime(); + long startedCpu = THREADS.getCurrentThreadCpuTime(); + try (NoiseChunk noiseChunk = new NoiseChunk(randomState, null, settings, fluidPicker, Blender.empty(), volume(chunk))) { + fill(noiseChunk, chunk); + long filled = System.nanoTime(); + long filledCpu = THREADS.getCurrentThreadCpuTime(); + randomState.surfaceSystem().buildSurface(randomState, biomeManager, + new WorldGenerationContext(generator, chunk.getHeightAccessorForGeneration(), surfaceLevel), chunk, noiseChunk, materialRule, possible); + samples.surface.add(System.nanoTime() - filled, THREADS.getCurrentThreadCpuTime() - filledCpu); + samples.noise.add(filled - started, filledCpu - startedCpu); + } + ChunkPos position = chunk.getPos(); + long carverStarted = System.nanoTime(); + long carverStartedCpu = THREADS.getCurrentThreadCpuTime(); + uncached.getNoiseBiome(QuartPos.fromBlock(position.getMinBlockX()), 0, QuartPos.fromBlock(position.getMinBlockZ())); + samples.carverBiome.add(System.nanoTime() - carverStarted, THREADS.getCurrentThreadCpuTime() - carverStartedCpu); + samples.chunks++; + } + } + samples.biomeFallbacks += region.fallbacks(); + } + + void uncachedBiome(CallBudget budget, CallSamples samples) { + SplittableRandom random = new SplittableRandom(seed ^ 0x5DEECE66DL ^ samples.count); + int minQuart = QuartPos.fromBlock(heights.getMinY()); + int quartHeight = QuartPos.fromBlock(heights.getHeight()); + long deadline = System.nanoTime() + budget.limitNanos(); + long startedCpu = THREADS.getCurrentThreadCpuTime(); + int taken = 0; + while (taken < budget.calls() && (taken < budget.minimumCalls() || System.nanoTime() < deadline)) { + int quartX = QuartPos.fromBlock(random.nextInt(-SAMPLE_RADIUS_BLOCKS, SAMPLE_RADIUS_BLOCKS)); + int quartY = minQuart + random.nextInt(quartHeight); + int quartZ = QuartPos.fromBlock(random.nextInt(-SAMPLE_RADIUS_BLOCKS, SAMPLE_RADIUS_BLOCKS)); + long started = System.nanoTime(); + uncached.getNoiseBiome(quartX, quartY, quartZ); + samples.wall.add(System.nanoTime() - started); + taken++; + } + samples.finish(taken, THREADS.getCurrentThreadCpuTime() - startedCpu); + } + + void baseHeight(CallBudget budget, Heightmap.Types type, CallSamples samples) { + SplittableRandom random = new SplittableRandom(seed ^ type.ordinal() ^ 0x2545F4914F6CDD1DL ^ samples.count); + long deadline = System.nanoTime() + budget.limitNanos(); + long startedCpu = THREADS.getCurrentThreadCpuTime(); + int taken = 0; + while (taken < budget.calls() && (taken < budget.minimumCalls() || System.nanoTime() < deadline)) { + int x = random.nextInt(-SAMPLE_RADIUS_BLOCKS, SAMPLE_RADIUS_BLOCKS); + int z = random.nextInt(-SAMPLE_RADIUS_BLOCKS, SAMPLE_RADIUS_BLOCKS); + long started = System.nanoTime(); + generator.getBaseHeight(x, z, type, heights, randomState); + samples.wall.add(System.nanoTime() - started); + taken++; + } + samples.finish(taken, THREADS.getCurrentThreadCpuTime() - startedCpu); + } + + void baseColumn(CallBudget budget, CallSamples samples) { + SplittableRandom random = new SplittableRandom(seed ^ 0x9E3779B97F4A7C15L ^ samples.count); + long deadline = System.nanoTime() + budget.limitNanos(); + long startedCpu = THREADS.getCurrentThreadCpuTime(); + int taken = 0; + while (taken < budget.calls() && (taken < budget.minimumCalls() || System.nanoTime() < deadline)) { + int x = random.nextInt(-SAMPLE_RADIUS_BLOCKS, SAMPLE_RADIUS_BLOCKS); + int z = random.nextInt(-SAMPLE_RADIUS_BLOCKS, SAMPLE_RADIUS_BLOCKS); + long started = System.nanoTime(); + generator.getBaseColumn(x, z, heights, randomState); + samples.wall.add(System.nanoTime() - started); + taken++; + } + samples.finish(taken, THREADS.getCurrentThreadCpuTime() - startedCpu); + } + + private void fill(NoiseChunk noiseChunk, ChunkAccess chunk) { + try { + fill.invoke(generator, noiseChunk, chunk); + } catch (IllegalAccessException failure) { + throw new IllegalStateException("Cannot invoke NoiseBasedChunkGenerator.doFill for " + label, failure); + } catch (InvocationTargetException failure) { + if (failure.getCause() instanceof RuntimeException runtime) { + throw runtime; + } + throw new IllegalStateException("Noise fill failed for " + label, failure.getCause()); + } + } + + private DensityVolume volume(ChunkAccess chunk) { + NoiseSettings noise = settings.noiseSettings().clampToHeightAccessor(chunk.getHeightAccessorForGeneration()); + ChunkPos position = chunk.getPos(); + return new DensityVolume(16, noise.height(), 16, position.getMinBlockX(), noise.minY(), position.getMinBlockZ()); + } + + private static Set> possibleBiomes(Map chunks, int chunkX, int chunkZ) { + Set> possible = new HashSet<>(); + for (int dz = -1; dz <= 1; dz++) { + for (int dx = -1; dx <= 1; dx++) { + chunks.get(ChunkPos.pack(chunkX + dx, chunkZ + dz)).collectBiomesInPalette(possible); + } + } + return possible; + } + + private static Method fillMethod() { + try { + Method method = NoiseBasedChunkGenerator.class.getDeclaredMethod("doFill", NoiseChunk.class, ChunkAccess.class); + method.setAccessible(true); + return method; + } catch (NoSuchMethodException failure) { + throw new IllegalStateException("NoiseBasedChunkGenerator has no doFill(NoiseChunk, ChunkAccess)", failure); + } + } + + @SuppressWarnings("unchecked") + private static Aquifer.FluidPicker fluidPicker(NoiseBasedChunkGenerator generator) { + try { + Field field = NoiseBasedChunkGenerator.class.getDeclaredField("globalFluidPicker"); + field.setAccessible(true); + return ((Supplier) field.get(generator)).get(); + } catch (ReflectiveOperationException failure) { + throw new IllegalStateException("NoiseBasedChunkGenerator has no globalFluidPicker supplier", failure); + } + } + + private static Level paperConfiguredLevel() { + try { + Constructor constructor = WorldConfiguration.class.getDeclaredConstructor(SpigotWorldConfig.class, Identifier.class); + constructor.setAccessible(true); + WorldConfiguration configuration = constructor.newInstance(null, Identifier.withDefaultNamespace("overworld")); + configuration.environment = configuration.new Environment(); + Class unsafeType = Class.forName("sun.misc.Unsafe"); + Field unsafeField = unsafeType.getDeclaredField("theUnsafe"); + unsafeField.setAccessible(true); + Method allocate = unsafeType.getMethod("allocateInstance", Class.class); + Level level = (Level) allocate.invoke(unsafeField.get(null), ServerLevel.class); + Field paperConfig = Level.class.getDeclaredField("paperConfig"); + paperConfig.setAccessible(true); + paperConfig.set(level, configuration); + return level; + } catch (ReflectiveOperationException failure) { + throw new IllegalStateException("Cannot build the Paper world configuration that surface rules read", failure); + } + } + + record Options(String label, LevelStem stem, RegistryAccess registries, long seed) { + Options { + Objects.requireNonNull(label, "label"); + Objects.requireNonNull(stem, "stem"); + Objects.requireNonNull(registries, "registries"); + } + } + + record CallBudget(int calls, int minimumCalls, long limitNanos) { + } + + record ChunkTimings(int chunks, StageTiming biomes, StageTiming noise, StageTiming surface, StageTiming carverBiome, + long biomeFallbacks) { + double totalMean() { + return biomes.wall().mean() + noise.wall().mean() + surface.wall().mean() + carverBiome.wall().mean(); + } + + double totalCpuMean() { + return biomes.cpu().mean() + noise.cpu().mean() + surface.cpu().mean() + carverBiome.cpu().mean(); + } + } + + record StageTiming(Summary wall, Summary cpu) { + } + + record CallTiming(Summary wall, int calls, double cpuMean) { + } + + record Summary(int count, double mean, double median, double p90, double min, double max) { + private static Summary of(long[] nanos, double unit) { + if (nanos.length == 0) { + return new Summary(0, Double.NaN, Double.NaN, Double.NaN, Double.NaN, Double.NaN); + } + long[] sorted = nanos.clone(); + Arrays.sort(sorted); + double total = 0.0; + for (long value : sorted) { + total += value; + } + return new Summary(sorted.length, total / sorted.length / unit, percentile(sorted, 0.5) / unit, + percentile(sorted, 0.9) / unit, sorted[0] / unit, sorted[sorted.length - 1] / unit); + } + + private static double percentile(long[] sorted, double fraction) { + int index = (int) Math.min(sorted.length - 1, Math.round(fraction * (sorted.length - 1))); + return sorted[index]; + } + } + + static final class ChunkSamples { + private final StageSamples biomes = new StageSamples(); + private final StageSamples noise = new StageSamples(); + private final StageSamples surface = new StageSamples(); + private final StageSamples carverBiome = new StageSamples(); + private int chunks; + private long biomeFallbacks; + + ChunkTimings timings() { + return new ChunkTimings(chunks, biomes.timing(), noise.timing(), surface.timing(), carverBiome.timing(), biomeFallbacks); + } + } + + static final class CallSamples { + private final Samples wall = new Samples(); + private int count; + private long cpuNanos; + + CallTiming timing() { + return new CallTiming(Summary.of(wall.values(), NANOS_PER_MICRO), count, + count == 0 ? Double.NaN : cpuNanos / (double) count / NANOS_PER_MICRO); + } + + private void finish(int calls, long cpu) { + count += calls; + cpuNanos += cpu; + } + } + + private static final class StageSamples { + private final Samples wall = new Samples(); + private final Samples cpu = new Samples(); + + private void add(long wallNanos, long cpuNanos) { + wall.add(wallNanos); + cpu.add(cpuNanos); + } + + private StageTiming timing() { + return new StageTiming(Summary.of(wall.values(), NANOS_PER_MILLI), Summary.of(cpu.values(), NANOS_PER_MILLI)); + } + } + + private static final class Samples { + private long[] values = new long[64]; + private int size; + + private void add(long value) { + if (size == values.length) { + values = Arrays.copyOf(values, size * 2); + } + values[size++] = value; + } + + private long[] values() { + return Arrays.copyOf(values, size); + } + } + + private static final class RegionBiomes implements BiomeResolver { + private final Map chunks; + private final BiomeResolver fallback; + private long fallbacks; + + private RegionBiomes(Map chunks, BiomeResolver fallback) { + this.chunks = chunks; + this.fallback = fallback; + } + + @Override + public Holder getNoiseBiome(int quartX, int quartY, int quartZ) { + ProtoChunk chunk = chunks.get(ChunkPos.pack(QuartPos.toSection(quartX), QuartPos.toSection(quartZ))); + if (chunk == null) { + fallbacks++; + return fallback.getNoiseBiome(quartX, quartY, quartZ); + } + return chunk.getNoiseBiome(quartX, quartY, quartZ); + } + + private long fallbacks() { + return fallbacks; + } + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/fidelity/VanillaNoiseColumnSource.java b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/fidelity/VanillaNoiseColumnSource.java new file mode 100644 index 000000000..d00bd66a2 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/fidelity/VanillaNoiseColumnSource.java @@ -0,0 +1,61 @@ +package art.arcane.iris.probe.fidelity; + +import art.arcane.iris.probe.WorldgenExportSampler; + +import java.io.IOException; +import java.util.Map; +import java.util.Objects; + +public final class VanillaNoiseColumnSource implements ColumnSource { + private final WorldgenExportSampler sampler; + private final Map loadKeyByBiomeId; + private final int minY; + private final int maxY; + private final int seaLevel; + + public VanillaNoiseColumnSource(WorldgenExportSampler sampler, Map loadKeyByBiomeId) { + this.sampler = Objects.requireNonNull(sampler, "sampler"); + this.loadKeyByBiomeId = Map.copyOf(loadKeyByBiomeId); + this.minY = sampler.minY(); + this.maxY = sampler.minY() + sampler.height(); + this.seaLevel = sampler.seaLevel(); + } + + @Override + public String label() { + return "vanilla:26.3"; + } + + @Override + public int minY() { + return minY; + } + + @Override + public int maxY() { + return maxY; + } + + @Override + public FidelityColumn column(int x, int z) { + int floor = sampler.floorHeight(x, z); + int terrainY = floor - 1; + boolean wet = floor < seaLevel && sampler.baseHeight(x, z) > floor; + return new FidelityColumn(loadKey(x, Math.max(minY, terrainY), z), terrainY, wet); + } + + @Override + public void close() throws IOException { + sampler.close(); + } + + private String loadKey(int x, int y, int z) { + String biomeId = sampler.biome(x, y, z); + String loadKey = loadKeyByBiomeId.get(biomeId); + if (loadKey == null) { + throw new IllegalStateException("Exported biome " + biomeId + " at " + x + "," + y + "," + z + + " has no Iris load key in the export result"); + } + return loadKey; + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/fidelity/VanillaNoiseFidelitySource.java b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/fidelity/VanillaNoiseFidelitySource.java new file mode 100644 index 000000000..17afa3036 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/fidelity/VanillaNoiseFidelitySource.java @@ -0,0 +1,204 @@ +package art.arcane.iris.probe.fidelity; + +import art.arcane.iris.probe.WorldgenExportSampler; +import net.minecraft.core.Holder; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.chunk.LevelChunkSection; +import net.minecraft.world.level.chunk.ProtoChunk; +import net.minecraft.world.level.levelgen.densityfunction.DensityFunction; +import net.minecraft.world.level.levelgen.densityfunction.DensityFunctions; +import net.minecraft.world.level.levelgen.densityfunction.op.BinaryFunction; + +import java.io.IOException; +import java.util.IdentityHashMap; +import java.util.Map; +import java.util.Objects; + +public final class VanillaNoiseFidelitySource implements FidelitySource { + private static final int CHUNK_WIDTH = 16; + private static final byte SOLID = 0; + private static final byte OPEN = 1; + private static final byte FLUID = 2; + private static final byte LAVA = 3; + + private final WorldgenExportSampler sampler; + private final Map loadKeyByBiomeId; + private final String wallShell; + private final DensityFunction massDensity; + private final boolean walls; + private final int minY; + private final int maxY; + + public VanillaNoiseFidelitySource(WorldgenExportSampler sampler, Map loadKeyByBiomeId, String wallShell) { + this.sampler = Objects.requireNonNull(sampler, "sampler"); + this.loadKeyByBiomeId = Map.copyOf(loadKeyByBiomeId); + this.wallShell = Objects.requireNonNull(wallShell, "wallShell"); + this.massDensity = terrainMass(sampler.finalDensity()); + this.walls = sampler.densityFunctionIds().contains(wallShell); + this.minY = sampler.minY(); + this.maxY = sampler.minY() + sampler.height(); + } + + @Override + public String label() { + return "vanilla:26.3"; + } + + @Override + public int minY() { + return minY; + } + + @Override + public int maxY() { + return maxY; + } + + @Override + public FidelityChunk sample(int chunkX, int chunkZ) { + return new TerrainChunk(sampler.terrainChunk(chunkX, chunkZ), chunkX * CHUNK_WIDTH, chunkZ * CHUNK_WIDTH); + } + + @Override + public void close() throws IOException { + sampler.close(); + } + + private String loadKey(int x, int y, int z) { + String biomeId = sampler.biome(x, y, z); + String loadKey = loadKeyByBiomeId.get(biomeId); + if (loadKey == null) { + throw new IllegalStateException("Exported biome " + biomeId + " at " + x + "," + y + "," + z + + " has no Iris load key in the export result"); + } + return loadKey; + } + + static DensityFunction terrainMass(DensityFunction finalDensity) { + DensityFunction root = finalDensity; + while (root instanceof DensityFunctions.HolderHolder(Holder holder)) { + root = holder.value(); + } + return root instanceof BinaryFunction(BinaryFunction.Type type, DensityFunction terrain, DensityFunction ignored) && type == BinaryFunction.Type.MIN + ? terrain : root; + } + + private static byte classify(Block block) { + String id = BuiltInRegistries.BLOCK.getKey(block).toString(); + if (FidelityBlocks.open(id)) { + return OPEN; + } + if (FidelityBlocks.lava(id)) { + return LAVA; + } + return FidelityBlocks.fluid(id) ? FLUID : SOLID; + } + + private record Column(int surfaceY, String biome, byte[] kinds) { + } + + private final class TerrainChunk implements FidelityChunk { + private final ProtoChunk terrain; + private final int blockX; + private final int blockZ; + private final Column[] columns = new Column[CHUNK_WIDTH * CHUNK_WIDTH]; + private final Map kinds = new IdentityHashMap<>(); + private float[] masses; + + private TerrainChunk(ProtoChunk terrain, int blockX, int blockZ) { + this.terrain = terrain; + this.blockX = blockX; + this.blockZ = blockZ; + } + + @Override + public int surfaceY(int localX, int localZ) { + return column(localX, localZ).surfaceY(); + } + + @Override + public String surfaceBiome(int localX, int localZ) { + return column(localX, localZ).biome(); + } + + @Override + public boolean open(int localX, int y, int localZ) { + return kind(localX, y, localZ) == OPEN; + } + + @Override + public boolean fluid(int localX, int y, int localZ) { + byte kind = kind(localX, y, localZ); + return kind == FLUID || kind == LAVA; + } + + @Override + public boolean lava(int localX, int y, int localZ) { + return kind(localX, y, localZ) == LAVA; + } + + @Override + public String block(int localX, int y, int localZ) { + return null; + } + + @Override + public boolean terrainMass(int localX, int y, int localZ) { + if (y < minY || y >= maxY) { + throw new IllegalArgumentException("Y " + y + " is outside [" + minY + ", " + maxY + ")"); + } + if (localX < 0 || localX >= CHUNK_WIDTH || localZ < 0 || localZ >= CHUNK_WIDTH) { + throw new IllegalArgumentException("Column " + localX + "," + localZ + " is outside the chunk"); + } + if (masses == null) { + masses = sampler.densityChunk(massDensity, Math.floorDiv(blockX, CHUNK_WIDTH), Math.floorDiv(blockZ, CHUNK_WIDTH)); + } + return masses[(localZ * CHUNK_WIDTH + localX) * (maxY - minY) + y - minY] > 0.0F; + } + + @Override + public boolean wallsKnown() { + return walls; + } + + @Override + public boolean wall(int localX, int y, int localZ) { + return walls && sampler.density(wallShell, blockX + localX, y, blockZ + localZ) > 0.0F; + } + + private byte kind(int localX, int y, int localZ) { + if (y < minY || y >= maxY) { + throw new IllegalArgumentException("Y " + y + " is outside [" + minY + ", " + maxY + ")"); + } + return column(localX, localZ).kinds()[y - minY]; + } + + private Column column(int localX, int localZ) { + if (localX < 0 || localX >= CHUNK_WIDTH || localZ < 0 || localZ >= CHUNK_WIDTH) { + throw new IllegalArgumentException("Column " + localX + "," + localZ + " is outside the chunk"); + } + int index = localZ * CHUNK_WIDTH + localX; + Column column = columns[index]; + if (column == null) { + column = sampleColumn(localX, localZ); + columns[index] = column; + } + return column; + } + + private Column sampleColumn(int localX, int localZ) { + byte[] column = new byte[maxY - minY]; + int surfaceY = minY - 1; + for (int y = maxY - 1; y >= minY; y--) { + LevelChunkSection section = terrain.getSection(terrain.getSectionIndex(y)); + byte kind = kinds.computeIfAbsent(section.getBlockState(localX, y & (CHUNK_WIDTH - 1), localZ).getBlock(), VanillaNoiseFidelitySource::classify); + column[y - minY] = kind; + if (kind == SOLID && surfaceY < minY) { + surfaceY = y; + } + } + return new Column(surfaceY, loadKey(blockX + localX, Math.max(minY, surfaceY), blockZ + localZ), column); + } + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/BukkitVanillaDataTest.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/BukkitVanillaDataTest.java new file mode 100644 index 000000000..20eb291d8 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/BukkitVanillaDataTest.java @@ -0,0 +1,73 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.platform.bukkit.BukkitPlatform; +import art.arcane.iris.spi.VanillaDataSource; +import net.minecraft.resources.Identifier; +import net.minecraft.server.packs.PackResources; +import net.minecraft.server.packs.PackType; +import net.minecraft.server.packs.repository.ServerPacksSource; +import net.minecraft.server.packs.resources.IoSupplier; +import org.bukkit.Bukkit; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public final class BukkitVanillaDataTest { + private static final String PLAINS = "data/minecraft/worldgen/biome/plains.json"; + private static final String BIOME_TAGS = "data/minecraft/tags/worldgen/biome"; + + @BeforeClass + public static void bootstrap() { + HeadlessNativeBootstrap.initialize(); + } + + @Test + public void bukkitPlatformReadsTheServerPlainsBiome() throws IOException { + byte[] plains = new BukkitPlatform().vanillaData().read(PLAINS).orElseThrow(); + assertTrue(new String(plains, StandardCharsets.UTF_8).contains("\"temperature\"")); + try (PackResources vanilla = ServerPacksSource.createVanillaPackSource().fullResources()) { + IoSupplier resource = vanilla.getResource(PackType.SERVER_DATA, + Identifier.withDefaultNamespace("worldgen/biome/plains.json")); + try (InputStream input = resource.get()) { + assertArrayEquals(input.readAllBytes(), plains); + } + } + assertNull(Bukkit.getServer()); + } + + @Test + public void bukkitPlatformListsTheBiomeTagsTheGameLoads() { + List expected = new ArrayList<>(); + try (PackResources vanilla = ServerPacksSource.createVanillaPackSource().fullResources()) { + vanilla.listResources(PackType.SERVER_DATA, "minecraft", "tags/worldgen/biome", + (id, resource) -> expected.add("data/" + id.getNamespace() + "/" + id.getPath())); + } + Collections.sort(expected); + List listed = new BukkitPlatform().vanillaData().list(BIOME_TAGS); + assertFalse(listed.isEmpty()); + assertTrue(listed.contains(BIOME_TAGS + "/is_overworld.json")); + assertEquals(expected, listed); + } + + @Test + public void headlessPlatformReadsTheSameVanillaData() { + HeadlessNativePlatform platform = new HeadlessNativePlatform(new File("unused-native-platform"), + HeadlessNativeTestRegistries::get); + VanillaDataSource bukkit = new BukkitPlatform().vanillaData(); + assertArrayEquals(bukkit.read(PLAINS).orElseThrow(), platform.vanillaData().read(PLAINS).orElseThrow()); + assertEquals(bukkit.list(BIOME_TAGS), platform.vanillaData().list(BIOME_TAGS)); + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/CompiledCaches.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/CompiledCaches.java new file mode 100644 index 000000000..1259d7f4d --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/CompiledCaches.java @@ -0,0 +1,146 @@ +package art.arcane.iris.probe; + +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator; +import net.minecraft.world.level.levelgen.NoiseGeneratorSettings; +import net.minecraft.world.level.levelgen.RandomState; +import net.minecraft.world.level.levelgen.densityfunction.CachingDensitySampler; +import net.minecraft.world.level.levelgen.densityfunction.DensityFunction; +import net.minecraft.world.level.levelgen.densityfunction.DensitySampler; +import net.minecraft.world.level.levelgen.material.rule.ConditionRule; +import net.minecraft.world.level.levelgen.material.rule.MaterialRule; +import net.minecraft.world.level.levelgen.material.rule.OreVeinRule; +import net.minecraft.world.level.levelgen.material.rule.SequenceRule; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Deque; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; + +final class CompiledCaches { + private static final String MINECRAFT_PACKAGE = "net.minecraft."; + private static final String DENSITY_PACKAGE = "net.minecraft.world.level.levelgen.densityfunction."; + private static final String SPLINE_PACKAGE = "net.minecraft.util."; + private static final String SPLINE_NAME = "Spline"; + + private final RegistryAccess registries; + private final NoiseGeneratorSettings settings; + private final RandomState state; + + private CompiledCaches(RegistryAccess registries, NoiseGeneratorSettings settings, long seed) { + this.registries = registries; + this.settings = settings; + this.state = RandomState.create(registries.lookupOrThrow(Registries.NOISE), seed, settings); + } + + static CompiledCaches open(RegistryAccess registries, long seed) { + if (!(DatapackCostProbe.exportedOverworld(registries).generator() instanceof NoiseBasedChunkGenerator generator)) { + throw new IllegalStateException("Exported overworld has no noise generator"); + } + return new CompiledCaches(registries, generator.generatorSettings().value(), seed); + } + + DensityFunction finalDensity() { + return settings.noiseRouter().finalDensity(); + } + + DensityFunction function(String id) { + return registries.lookupOrThrow(Registries.DENSITY_FUNCTION) + .getOrThrow(ResourceKey.create(Registries.DENSITY_FUNCTION, Identifier.parse(id))).value(); + } + + List oreVeins() { + List veins = new ArrayList<>(); + Deque pending = new ArrayDeque<>(); + pending.push(settings.materialRule().value()); + while (!pending.isEmpty()) { + MaterialRule rule = pending.pop(); + switch (rule) { + case OreVeinRule vein -> veins.add(vein); + case SequenceRule sequence -> sequence.sequence().reversed().forEach(pending::push); + case ConditionRule condition -> pending.push(condition.thenRun()); + default -> { + } + } + } + return veins; + } + + int rootId(DensityFunction function) { + if (!(state.getSampler(function) instanceof CachingDensitySampler cache)) { + throw new IllegalStateException("Density function " + function + " does not compile to a cache"); + } + return cache.id(); + } + + Set ids(DensityFunction function) { + Set ids = new TreeSet<>(); + Set visited = Collections.newSetFromMap(new IdentityHashMap<>()); + Deque pending = new ArrayDeque<>(); + pending.push(state.getSampler(function)); + while (!pending.isEmpty()) { + Object node = pending.pop(); + if (node == null || !visited.add(node)) { + continue; + } + if (node instanceof CachingDensitySampler cache) { + ids.add(cache.id()); + } + for (Object child : children(node)) { + pending.push(child); + } + } + return ids; + } + + private static List children(Object node) { + List children = new ArrayList<>(); + if (node instanceof Object[] array) { + Collections.addAll(children, array); + return children; + } + if (node instanceof Collection collection) { + children.addAll(collection); + return children; + } + if (!traversed(node)) { + return children; + } + for (Class type = node.getClass(); type != null && type.getName().startsWith(MINECRAFT_PACKAGE); type = type.getSuperclass()) { + for (Field field : type.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers()) || field.getType().isPrimitive()) { + continue; + } + children.add(read(field, node)); + } + } + return children; + } + + private static boolean traversed(Object node) { + if (node instanceof Enum) { + return false; + } + String name = node.getClass().getName(); + return node instanceof DensitySampler || name.startsWith(DENSITY_PACKAGE) || name.startsWith(SPLINE_PACKAGE) && name.contains(SPLINE_NAME); + } + + private static Object read(Field field, Object owner) { + try { + field.setAccessible(true); + return field.get(owner); + } catch (IllegalAccessException | RuntimeException failure) { + throw new IllegalStateException("Cannot read " + field + " of " + owner.getClass().getName(), failure); + } + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/DatapackFidelityProbeBiomeModeTest.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/DatapackFidelityProbeBiomeModeTest.java new file mode 100644 index 000000000..0c5122e45 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/DatapackFidelityProbeBiomeModeTest.java @@ -0,0 +1,46 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.probe.fidelity.FidelityReport; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public final class DatapackFidelityProbeBiomeModeTest { + @ClassRule + public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation(); + + @Rule + public TemporaryFolder temporary = new TemporaryFolder(); + + @Test(timeout = 300_000L) + public void biomeModeJudgesTheSeedMeanWithoutTheChunkScan() throws Exception { + Path pack = DatapackFidelityProbeTest.pack(temporary); + Path output = temporary.getRoot().toPath().resolve("reports").resolve("biomes.json"); + + FidelityReport report = DatapackFidelityProbe.execute( + new String[]{pack.toString(), "main", "1337", "0", "1", output.toString(), "256", "32", "3", "biomes"}, DatapackFidelityProbeTest.exporter()); + + JsonObject json = JsonParser.parseString(Files.readString(output, StandardCharsets.UTF_8)).getAsJsonObject(); + assertTrue(report.toTable(), report.passed()); + assertTrue(json.get("passed").getAsBoolean()); + assertEquals(1.0D, DatapackFidelityProbeTest.metric(report, "biome.share.plains").export(), 0.0D); + assertEquals(1, report.sampling().size()); + assertEquals(List.of(1337L, 1338L, 1339L), report.sampling().getFirst().seeds()); + assertEquals(3, json.getAsJsonArray("seeds").size()); + assertEquals(3, json.getAsJsonObject("seedsPassing").getAsJsonObject("biome.share.plains").get("passing").getAsInt()); + assertTrue(report.observations().stream().anyMatch(observation -> observation.name().equals("biome.type.land") + && observation.iris() == 1.0D && observation.export() == 1.0D)); + assertTrue(RealPackProbeSupport.drainReported().isEmpty()); + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/DatapackFidelityProbeTest.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/DatapackFidelityProbeTest.java new file mode 100644 index 000000000..fb7458e84 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/DatapackFidelityProbeTest.java @@ -0,0 +1,116 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.pack.datapack.export.WorldgenDatapackExporter; +import art.arcane.iris.pack.datapack.export.compile.DimensionStage; +import art.arcane.iris.pack.datapack.export.compile.ImageMapStage; +import art.arcane.iris.pack.datapack.export.compile.PackCompiler; +import art.arcane.iris.pack.datapack.export.emit.WorldgenJsonWriter; +import art.arcane.iris.probe.fidelity.FidelityMetric; +import art.arcane.iris.probe.fidelity.FidelityReport; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public final class DatapackFidelityProbeTest { + @ClassRule + public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation(); + + @Rule + public TemporaryFolder temporary = new TemporaryFolder(); + + @Test(timeout = 300_000L) + public void probeExportsTheSyntheticPackAndComparesBothGenerators() throws Exception { + Path pack = pack(temporary); + Path output = temporary.getRoot().toPath().resolve("reports").resolve("fidelity.json"); + + FidelityReport report = DatapackFidelityProbe.execute( + new String[]{pack.toString(), "main", "1337", "1", "8", output.toString(), "256", "32", "2", "full"}, exporter()); + + JsonObject json = JsonParser.parseString(Files.readString(output, StandardCharsets.UTF_8)).getAsJsonObject(); + assertTrue(report.toTable(), report.passed()); + assertTrue(json.get("passed").getAsBoolean()); + assertEquals(report.metrics().size(), json.getAsJsonArray("metrics").size()); + FidelityMetric biome = metric(report, "biome.share.plains"); + assertEquals(1.0D, biome.iris(), 0.0D); + assertEquals(1.0D, biome.export(), 0.0D); + assertTrue(biome.pass()); + assertEquals(0.0D, metric(report, "biome.totalVariation").delta(), 0.0D); + assertEquals(1.0D, metric(report, "height.landFraction").export(), 0.0D); + JsonObject columns = json.getAsJsonArray("sampling").get(0).getAsJsonObject(); + assertEquals("columns", columns.get("scope").getAsString()); + assertEquals(256, columns.get("windowBlocks").getAsInt()); + assertEquals(32, columns.get("strideBlocks").getAsInt()); + assertEquals(2, columns.getAsJsonArray("seeds").size()); + assertEquals(128L, columns.get("columns").getAsLong()); + JsonObject chunks = json.getAsJsonArray("sampling").get(1).getAsJsonObject(); + assertEquals("chunks", chunks.get("scope").getAsString()); + assertEquals(48, chunks.get("windowBlocks").getAsInt()); + assertTrue(RealPackProbeSupport.drainReported().isEmpty()); + } + + @Test + public void malformedArgumentsAreRejectedBeforeThePackOpens() throws Exception { + Path pack = pack(temporary); + String output = temporary.getRoot().toPath().resolve("unused.json").toString(); + + IllegalArgumentException missing = assertThrows(IllegalArgumentException.class, + () -> DatapackFidelityProbe.execute(new String[]{pack.toString(), "main"}, exporter())); + IllegalArgumentException stride = assertThrows(IllegalArgumentException.class, + () -> DatapackFidelityProbe.execute(new String[]{pack.toString(), "main", "1337", "1", "17", output, "256", "32", "1", "full"}, exporter())); + IllegalArgumentException survey = assertThrows(IllegalArgumentException.class, + () -> DatapackFidelityProbe.execute(new String[]{pack.toString(), "main", "1337", "1", "8", output, "250", "32", "1", "full"}, exporter())); + IllegalArgumentException seeds = assertThrows(IllegalArgumentException.class, + () -> DatapackFidelityProbe.execute(new String[]{pack.toString(), "main", "1337", "1", "8", output, "256", "32", "0", "full"}, exporter())); + IllegalArgumentException mode = assertThrows(IllegalArgumentException.class, + () -> DatapackFidelityProbe.execute(new String[]{pack.toString(), "main", "1337", "1", "8", output, "256", "32", "1", "caves"}, exporter())); + + assertTrue(missing.getMessage(), missing.getMessage().startsWith("Usage:")); + assertTrue(stride.getMessage(), stride.getMessage().contains("stride")); + assertTrue(survey.getMessage(), survey.getMessage().contains("multiple")); + assertTrue(seeds.getMessage(), seeds.getMessage().contains("surveySeeds")); + assertTrue(mode.getMessage(), mode.getMessage().contains("caves")); + assertFalse(Files.exists(Path.of(output))); + } + + static WorldgenDatapackExporter exporter() { + return new WorldgenDatapackExporter(WorldgenJsonWriter::forTarget, new PackCompiler(List.of(new DimensionStage(), new ImageMapStage()))); + } + + static FidelityMetric metric(FidelityReport report, String name) { + for (FidelityMetric metric : report.metrics()) { + if (metric.name().equals(name)) { + return metric; + } + } + throw new AssertionError("Report has no metric " + name + ": " + report.toJson()); + } + + static Path pack(TemporaryFolder temporary) throws Exception { + Path pack = temporary.newFolder().toPath(); + for (String directory : List.of("dimensions", "regions", "biomes", "generators")) { + Files.createDirectories(pack.resolve(directory)); + } + Files.writeString(pack.resolve("dimensions/main.json"), """ + {"name":"Fidelity","focus":"plains","regions":["owned"],"landChance":1,"carvingEnabled":false} + """); + Files.writeString(pack.resolve("regions/owned.json"), "{\"landBiomes\":[\"plains\"]}"); + Files.writeString(pack.resolve("generators/flat.json"), "{\"composite\":[]}"); + Files.writeString(pack.resolve("biomes/plains.json"), """ + {"derivative":"minecraft:plains","generators":[{"generator":"flat","min":0,"max":0}]} + """); + return pack; + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/DensityCostAnalysisTest.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/DensityCostAnalysisTest.java new file mode 100644 index 000000000..9f0897eb7 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/DensityCostAnalysisTest.java @@ -0,0 +1,110 @@ +package art.arcane.iris.probe; + +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.level.levelgen.densityfunction.DensityFunction; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; + +public final class DensityCostAnalysisTest { + @ClassRule + public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation(); + + private static final double EPSILON = 1.0E-9; + private static final String LEAF = "{\"type\":\"noise\",\"noise\":\"minecraft:temperature\",\"xz_scale\":1.0,\"y_scale\":0.0}"; + + private static HeadlessNativeRegistries registries; + + @BeforeClass + public static void loadGraphs() throws IOException { + Map definitions = new LinkedHashMap<>(); + definitions.put(path("leaf"), LEAF); + definitions.put(path("pair"), "{\"type\":\"add\",\"left\":\"t:leaf\",\"right\":\"t:leaf\"}"); + definitions.put(path("quad"), "{\"type\":\"mul\",\"left\":\"t:pair\",\"right\":\"t:pair\"}"); + definitions.put(path("cached"), "{\"type\":\"cache\",\"input\":\"t:pair\"}"); + definitions.put(path("twice"), "{\"type\":\"add\",\"left\":\"t:cached\",\"right\":\"t:cached\"}"); + definitions.put(path("select"), "{\"type\":\"interval_select\",\"input\":\"t:leaf\",\"thresholds\":[0.0],\"functions\":[\"t:quad\",\"t:pair\"]}"); + definitions.put(path("spline"), "{\"type\":\"spline\",\"spline\":{\"coordinate\":\"t:leaf\",\"points\":[" + + "{\"location\":-1.0,\"value\":" + nested("t:quad") + ",\"derivative\":0.0}," + + "{\"location\":0.0,\"value\":0.0,\"derivative\":0.0}," + + "{\"location\":1.0,\"value\":" + nested("t:quad") + ",\"derivative\":0.0}]}}"); + definitions.put(path("grid"), "{\"type\":\"interpolated\",\"input\":\"t:pair\",\"cell_size_xz\":4,\"cell_size_y\":8}"); + registries = HeadlessNativeRegistries.load(definitions); + } + + @AfterClass + public static void closeRegistries() throws IOException { + if (registries != null) { + registries.close(); + } + } + + @Test + public void referencesExpandWhileTheDagSharesThem() { + DensityCostAnalysis.RootCost quad = analysis().analyze("quad", function("quad")); + assertEquals(3L, quad.dagNodes()); + assertEquals(7.0, quad.expandedNodes(), EPSILON); + assertEquals(7.0, quad.cachedNodes(), EPSILON); + assertEquals(2, quad.namedSubtrees()); + assertEquals("t:leaf", quad.exclusive().getFirst().name()); + assertEquals(4.0, quad.exclusive().getFirst().multiplicity(), EPSILON); + assertEquals(4.0, quad.exclusive().getFirst().contribution(), EPSILON); + assertEquals("t:pair", quad.inclusive().getFirst().name()); + assertEquals(6.0, quad.inclusive().getFirst().inclusive(), EPSILON); + assertEquals(1.0 / 3.0, quad.dagShare().get("leaf"), EPSILON); + assertEquals(1.0 / 3.0, quad.dagShare().get("quad"), EPSILON); + } + + @Test + public void cachesEvaluateTheirBodyOnceWhenEnabled() { + DensityCostAnalysis.RootCost twice = analysis().analyze("twice", function("twice")); + assertEquals(4L, twice.dagNodes()); + assertEquals(9.0, twice.expandedNodes(), EPSILON); + assertEquals(6.0, twice.cachedNodes(), EPSILON); + } + + @Test + public void uncachedPointEvaluationTakesOneSelectedBranch() { + DensityCostAnalysis analysis = analysis(); + DensityCostAnalysis.RootCost select = analysis.analyze("select", function("select")); + assertEquals(12.0, select.expandedNodes(), EPSILON); + assertEquals(9.0, select.uncachedPointWorst(), EPSILON); + DensityCostAnalysis.RootCost spline = analysis.analyze("spline", function("spline")); + assertEquals(19.0, spline.expandedNodes(), EPSILON); + assertEquals(11.0, spline.uncachedPointWorst(), EPSILON); + } + + @Test + public void offLatticeGridPointsSampleEightCorners() { + DensityCostAnalysis.RootCost grid = analysis().analyze("grid", function("grid")); + assertEquals(4.0, grid.expandedNodes(), EPSILON); + assertEquals(25.0, grid.uncachedPointWorst(), EPSILON); + } + + private static DensityCostAnalysis analysis() { + return new DensityCostAnalysis(id -> DensityJsonBreakdown.category("overworld", id), 10); + } + + private static DensityFunction function(String name) { + return registries.registries().lookupOrThrow(Registries.DENSITY_FUNCTION) + .getOrThrow(ResourceKey.create(Registries.DENSITY_FUNCTION, Identifier.parse("t:" + name))).value(); + } + + private static String nested(String coordinate) { + return "{\"coordinate\":\"" + coordinate + "\",\"points\":[{\"location\":0.0,\"value\":1.0,\"derivative\":0.0}," + + "{\"location\":1.0,\"value\":2.0,\"derivative\":0.0}]}"; + } + + private static String path(String name) { + return "data/t/worldgen/density_function/" + name + ".json"; + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/DensityJsonBreakdownTest.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/DensityJsonBreakdownTest.java new file mode 100644 index 000000000..02c449515 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/DensityJsonBreakdownTest.java @@ -0,0 +1,88 @@ +package art.arcane.iris.probe; + +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.util.SortedMap; +import java.util.TreeMap; + +import static org.junit.Assert.assertEquals; + +public final class DensityJsonBreakdownTest { + private static final String TEMPERATURE = "{\"type\":\"add\",\"left\":\"x:biome/selector/0\",\"right\":1.0}"; + private static final String SELECTOR = "{\"type\":\"mul\",\"left\":\"x:cell/cellular/a/value\",\"right\":2.0}"; + private static final String CELL_A = "{\"type\":\"noise\",\"noise\":\"x:hash_a\",\"xz_scale\":0.5,\"y_scale\":0.0}"; + private static final String CELL_B = "{\"type\":\"noise\",\"noise\":\"x:hash_b\",\"xz_scale\":0.25,\"y_scale\":0.0}"; + private static final String FINAL_DENSITY = "{\"type\":\"min\",\"left\":\"x:cell/cellular/b/value\",\"right\":0.0}"; + private static final String NOISE = "{\"firstOctave\":-1,\"amplitudes\":[1.0]}"; + + @Test + public void categoriesFollowProducingCompilers() { + assertEquals("router", DensityJsonBreakdown.category("overworld", "x:overworld/final_density")); + assertEquals("cellular", DensityJsonBreakdown.category("overworld", "x:cell/cracks/abc/distance")); + assertEquals("biome selector", DensityJsonBreakdown.category("overworld", "x:biome/selector/12")); + assertEquals("biome decision", DensityJsonBreakdown.category("overworld", "x:biome/base_code")); + assertEquals("terrain height", DensityJsonBreakdown.category("overworld", "x:height")); + assertEquals("terrain bands", DensityJsonBreakdown.category("overworld", "x:terrain/plains/blend/3")); + assertEquals("noise style", DensityJsonBreakdown.category("overworld", "x:style/shared/ab12")); + assertEquals("cellular offset copies", DensityJsonBreakdown.category("overworld", "x:cell/cellular/abc/vx_0_0/offset_x4_z0")); + assertEquals("biome selector offset copies", DensityJsonBreakdown.category("overworld", "x:biome/selector/4/offset_x0_z4")); + } + + @Test + public void reportsBytesShapesAndCellularOwners() { + SortedMap files = new TreeMap<>(); + put(files, "density_function/overworld/temperature", TEMPERATURE); + put(files, "density_function/biome/selector/0", SELECTOR); + put(files, "density_function/cell/cellular/a/value", CELL_A); + put(files, "density_function/cell/cellular/b/value", CELL_B); + put(files, "density_function/overworld/final_density", FINAL_DENSITY); + put(files, "noise/hash_a", NOISE); + put(files, "noise/hash_b", NOISE); + + DensityJsonBreakdown.Report report = DensityJsonBreakdown.analyze(files, "overworld").report(); + + assertEquals(5, report.files()); + assertEquals(bytes(TEMPERATURE, SELECTOR, CELL_A, CELL_B, FINAL_DENSITY), report.bytes()); + DensityJsonBreakdown.CategoryBytes cellular = category(report, "cellular"); + assertEquals(2, cellular.files()); + assertEquals(1, cellular.distinctShapes()); + assertEquals(bytes(CELL_B), cellular.structurallyDuplicateBytes()); + assertEquals(2, category(report, "router").files()); + assertEquals(2, report.cellularInstances()); + assertEquals(1, report.cellularShapes().size()); + assertEquals("biome selector+router", report.cellularShapes().getFirst().owners()); + assertEquals(bytes(CELL_A), owner(report, "cellular via biome selector")); + assertEquals(bytes(CELL_B), owner(report, "cellular via router")); + } + + private static DensityJsonBreakdown.CategoryBytes category(DensityJsonBreakdown.Report report, String name) { + for (DensityJsonBreakdown.CategoryBytes category : report.categories()) { + if (category.category().equals(name)) { + return category; + } + } + throw new AssertionError("No category " + name + " in " + report.categories()); + } + + private static long owner(DensityJsonBreakdown.Report report, String name) { + for (DensityJsonBreakdown.Bytes owner : report.libraryOwners()) { + if (owner.name().equals(name)) { + return owner.bytes(); + } + } + throw new AssertionError("No owner " + name + " in " + report.libraryOwners()); + } + + private static long bytes(String... bodies) { + long total = 0; + for (String body : bodies) { + total += body.getBytes(StandardCharsets.UTF_8).length; + } + return total; + } + + private static void put(SortedMap files, String path, String body) { + files.put("data/x/worldgen/" + path + ".json", body.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/PaperRegistryIsolation.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/PaperRegistryIsolation.java index 557da823c..e3e16d325 100644 --- a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/PaperRegistryIsolation.java +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/PaperRegistryIsolation.java @@ -1,37 +1,19 @@ package art.arcane.iris.probe; -import io.papermc.paper.registry.PaperRegistryAccess; import org.junit.rules.TestRule; import org.junit.runner.Description; import org.junit.runners.model.Statement; -import java.lang.reflect.Field; -import java.util.HashMap; -import java.util.Map; - public final class PaperRegistryIsolation implements TestRule { @Override public Statement apply(Statement base, Description description) { return new Statement() { @Override public void evaluate() throws Throwable { - Map registries = registries(); - Map previous = new HashMap<>(registries); - registries.clear(); - try { + try (PaperRegistryScope scope = PaperRegistryScope.open()) { base.evaluate(); - } finally { - registries.clear(); - registries.putAll(previous); } } }; } - - @SuppressWarnings("unchecked") - private static Map registries() throws ReflectiveOperationException { - Field field = PaperRegistryAccess.class.getDeclaredField("registries"); - field.setAccessible(true); - return (Map) field.get(PaperRegistryAccess.instance()); - } } diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/VanillaBoundsParity263Test.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/VanillaBoundsParity263Test.java new file mode 100644 index 000000000..b038d573c --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/VanillaBoundsParity263Test.java @@ -0,0 +1,126 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.emit.DensityFunctionEmitter; +import art.arcane.iris.pack.datapack.export.emit.FunctionTable; +import art.arcane.iris.pack.datapack.export.ir.Axis; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.NoiseDef; +import art.arcane.iris.pack.datapack.export.ir.ValueRange; +import art.arcane.iris.pack.datapack.export.lower.ExportCapabilities; +import art.arcane.iris.pack.datapack.export.lower.VanillaBounds; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.util.Interval; +import org.junit.ClassRule; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public final class VanillaBoundsParity263Test { + @ClassRule + public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation(); + + private static final String NAMESPACE = "iris_export"; + private static final List NOISES = List.of(new NoiseDef("parity/base", -3, List.of(1.0, 0.5, 0.25)), + new NoiseDef("parity/sparse", 0, List.of(0.0, 1.5, 0.0, 0.7))); + private static final Field BASE = new Field.Noise(NAMESPACE + ":parity/base", 0.25, 0.0, null, null, null); + private static final Field SPARSE = new Field.Noise(NAMESPACE + ":parity/sparse", 0.5, 0.25, null, null, null); + private static final Field UNIT = new Field.Clamp(BASE, 0.0, 1.0); + private static final Field BAND = new Field.Clamp(SPARSE, 0.3, 0.9); + + @Test + public void boundsMatchMinecraftForEveryEmittedNode() throws Exception { + List fields = fields(); + VanillaBounds bounds = VanillaBounds.of(NAMESPACE, NOISES); + DensityFunctionEmitter emitter = DensityFunctionEmitter.forTarget(ExportCapabilities.forTarget(DataVersion.V26_3), NAMESPACE, bounds); + FunctionTable table = new FunctionTable(NAMESPACE); + List references = new ArrayList<>(fields.size()); + for (int index = 0; index < fields.size(); index++) { + JsonElement emitted = emitter.emit(fields.get(index), table); + boolean reference = emitted.isJsonPrimitive() && emitted.getAsJsonPrimitive().isString(); + references.add(reference ? emitted.getAsString() : table.register("parity/field_" + index, emitted)); + } + Map definitions = new LinkedHashMap<>(); + for (Map.Entry function : table.files().entrySet()) { + definitions.put("data/" + NAMESPACE + "/worldgen/density_function/" + function.getKey() + ".json", function.getValue().toString()); + } + for (NoiseDef noise : NOISES) { + definitions.put("data/" + NAMESPACE + "/worldgen/noise/" + noise.id() + ".json", noiseJson(noise).toString()); + } + try (HeadlessNativeRegistries registries = HeadlessNativeRegistries.load(definitions)) { + for (int index = 0; index < fields.size(); index++) { + Interval vanilla = registries.registries().lookupOrThrow(Registries.DENSITY_FUNCTION) + .getOrThrow(ResourceKey.create(Registries.DENSITY_FUNCTION, Identifier.parse(references.get(index)))).value().range(); + Optional modelled = bounds.range(fields.get(index)); + assertTrue("field " + index + " " + fields.get(index) + " has no modelled range, Minecraft " + vanilla, modelled.isPresent()); + assertEquals("field " + index + " " + fields.get(index), new ValueRange(vanilla.min(), vanilla.max()), modelled.get()); + } + } + } + + private static List fields() { + Field scaled = new Field.Binary(Field.BinaryOp.MUL, new Field.Const(0.3), BAND); + Field curve = new Field.Curve(new Field.Binary(Field.BinaryOp.MUL, BASE, new Field.Const(1.7)), List.of( + new Field.Knot(-1.0, new Field.Const(0.2), 0.75), + new Field.Knot(0.1, new Field.Curve(SPARSE, List.of(new Field.Knot(-0.5, new Field.Const(-1.0), 0.0), + new Field.Knot(0.5, new Field.Const(1.3), 2.0))), 0.0), + new Field.Knot(0.8, new Field.Const(0.6), -1.5))); + List fields = new ArrayList<>(); + fields.add(BASE); + fields.add(SPARSE); + fields.add(new Field.Const(0.1)); + fields.add(new Field.Coord(Axis.Y, Field.Tiling.CLAMP, -64.0, 320.0, 1.5, -0.7)); + fields.add(new Field.Clamp(BASE, -0.3, 0.8)); + for (Field.BinaryOp op : Field.BinaryOp.values()) { + fields.add(new Field.Binary(op, BASE, new Field.Binary(Field.BinaryOp.ADD, BAND, new Field.Const(0.6)))); + } + fields.add(new Field.Binary(Field.BinaryOp.POW, UNIT, new Field.Const(2.5))); + fields.add(new Field.Binary(Field.BinaryOp.MAX, scaled, new Field.Const(0.0))); + fields.add(new Field.Binary(Field.BinaryOp.MIN, new Field.Const(2.0), scaled)); + fields.add(new Field.Binary(Field.BinaryOp.MIN, new Field.Const(0.2), scaled)); + for (Field.UnaryOp op : Field.UnaryOp.values()) { + fields.add(new Field.Unary(op, new Field.Binary(Field.BinaryOp.SUB, SPARSE, new Field.Const(0.4)))); + } + fields.add(new Field.Lerp(UNIT, BASE, new Field.Binary(Field.BinaryOp.MUL, SPARSE, new Field.Const(3.0)))); + for (Field.RoundMode mode : Field.RoundMode.values()) { + fields.add(new Field.Round(mode, new Field.Binary(Field.BinaryOp.MUL, BASE, new Field.Const(10.3)), new Field.Const(2.5))); + } + fields.add(curve); + fields.add(new Field.Curve(UNIT, List.of(new Field.Knot(0.0, new Field.Const(0.0), -3.0), new Field.Knot(1.0, new Field.Const(1.0), -3.0)))); + fields.add(new Field.Partition(BASE, List.of(-0.2, 0.4), List.of(new Field.Const(-3.0), new Field.Const(0.5), new Field.Const(7.25)))); + fields.add(new Field.Mux(new Field.Binary(Field.BinaryOp.MUL, UNIT, new Field.Const(2.0)), List.of(new Field.Const(4.0), BAND, scaled))); + fields.add(new Field.Gate(BASE, new Field.Binary(Field.BinaryOp.ADD, BAND, new Field.Const(40.0)))); + fields.add(new Field.RangeIf(BASE, -0.1, 0.2, new Field.Const(-2.0), BAND)); + fields.add(new Field.Grid(new Field.Binary(Field.BinaryOp.ADD, BASE, SPARSE), 4, 8)); + fields.add(new Field.Shared("parity/shared", new Field.Binary(Field.BinaryOp.SUB, BAND, BASE))); + fields.add(new Field.Slice(new Field.Binary(Field.BinaryOp.MUL, SPARSE, new Field.Const(-2.0)), Axis.Y, 64)); + fields.add(new Field.TopSurface(new Field.Binary(Field.BinaryOp.SUB, BASE, new Field.Const(0.1)), 200, -48, 4)); + return fields; + } + + private static JsonObject noiseJson(NoiseDef noise) { + JsonArray modifiers = new JsonArray(noise.amplitudes().size()); + for (double amplitude : noise.amplitudes()) { + modifiers.add(amplitude); + } + JsonObject json = new JsonObject(); + json.addProperty("base_octave", noise.firstOctave()); + json.addProperty("octave_count", noise.amplitudes().size()); + json.add("amplitude_modifiers", modifiers); + json.addProperty("base_amplitude", 1.0); + json.addProperty("normalize", false); + return json; + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/VanillaNoiseModelParity263Test.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/VanillaNoiseModelParity263Test.java new file mode 100644 index 000000000..43453dd16 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/VanillaNoiseModelParity263Test.java @@ -0,0 +1,242 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.compile.noise.VanillaNoiseModel; +import net.minecraft.core.HolderGetter; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.levelgen.NoiseGeneratorSettings; +import net.minecraft.world.level.levelgen.PositionalRandomFactory; +import net.minecraft.world.level.levelgen.RandomState; +import net.minecraft.world.level.levelgen.XoroshiroRandomSource; +import net.minecraft.world.level.levelgen.synth.Noise; +import net.minecraft.world.level.levelgen.synth.NormalNoise; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.SplittableRandom; +import java.util.stream.Collectors; + +import static org.junit.Assert.assertTrue; + +public final class VanillaNoiseModelParity263Test { + @ClassRule + public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation(); + + private static final int POINTS = 4096; + private static final double TOLERANCE = 1.0E-5D; + private static final double RUNTIME_TOLERANCE = 1.0E-4D; + private static final List WORLD_SEEDS = List.of(0L, 8_675_309L, -4_611_686_018_427_387_904L); + private static final List NOISES = List.of( + ParityNoise.mapped("iris_export:parity/single", -7, List.of(1.0D)), + ParityNoise.mapped("iris_export:parity/fbm", -9, List.of(1.0D, 1.0D, 0.5D, 0.25D)), + ParityNoise.mapped("iris_export:parity/sparse", -10, List.of(1.5D, 0.0D, 1.0D, 0.0D, 0.0D, 0.0D)), + ParityNoise.mapped("iris_export:parity/leading_zero", -6, List.of(0.0D, 0.0D, 1.0D, 2.0D)), + ParityNoise.mapped("iris_export:parity/positive", 2, List.of(1.0D, 0.5D)), + ParityNoise.profiled("iris_export:parity/enabled", + "{\"base_octave\":-10,\"octave_count\":6,\"amplitude_modifiers\":[1.5,0.0,1.0,0.0,0.0,0.0]," + + "\"base_amplitude\":1.2453007926713473}", + new VanillaNoiseModel.OctaveProfile(-10, 6, List.of(1.5D, 0.0D, 1.0D, 0.0D, 0.0D, 0.0D), + 1.2453007926713473D, VanillaNoiseModel.Normalization.ENABLED)), + ParityNoise.profiled("iris_export:parity/legacy", + "{\"base_octave\":-9,\"octave_count\":3,\"amplitude_modifiers\":[1.0,0.0,1.0],\"normalize\":\"legacy\"}", + new VanillaNoiseModel.OctaveProfile(-9, 3, List.of(1.0D, 0.0D, 1.0D), 1.0D, + VanillaNoiseModel.Normalization.LEGACY)), + ParityNoise.profiled("iris_export:parity/defaults", "{\"base_octave\":-5}", + new VanillaNoiseModel.OctaveProfile(-5, 1, List.of(), 1.0D, VanillaNoiseModel.Normalization.ENABLED)), + ParityNoise.profiled("iris_export:parity/scaled", + "{\"base_octave\":-6,\"octave_count\":2,\"normalize\":false,\"base_amplitude\":0.5}", + new VanillaNoiseModel.OctaveProfile(-6, 2, List.of(), 0.5D, VanillaNoiseModel.Normalization.DISABLED))); + + private static HeadlessNativeRegistries registries; + + @BeforeClass + public static void loadNoiseDefinitions() throws IOException { + Map definitions = new LinkedHashMap<>(); + for (ParityNoise noise : NOISES) { + definitions.put(resourcePath(noise.id()), noise.json()); + } + registries = HeadlessNativeRegistries.load(definitions); + } + + @AfterClass + public static void closeRegistries() throws IOException { + if (registries != null) { + registries.close(); + } + } + + @Test + public void modelMatchesNoiseBuiltFromLoadedDefinitions() { + List report = new ArrayList<>(); + for (long seed : WORLD_SEEDS) { + PositionalRandomFactory world = new VanillaPrecisionRandom(new XoroshiroRandomSource(seed)).forkPositional(); + for (ParityNoise noise : NOISES) { + NormalNoise definition = registries.registries().lookupOrThrow(Registries.NOISE).getOrThrow(key(noise.id())).value(); + Noise real = definition.create(world.fromHashOf(Identifier.parse(noise.id()))); + report.add(noise.id() + "@" + seed + "=" + assertParity(noise, seed, real, TOLERANCE)); + } + } + System.out.println("26.3 vanilla-precision parity " + report); + } + + @Test + public void modelMatchesRuntimeRandomStateNoise() { + List report = new ArrayList<>(); + for (long seed : WORLD_SEEDS) { + RandomState state = randomState(seed); + for (ParityNoise noise : NOISES) { + Noise real = state.getOrCreateNoise(key(noise.id())); + report.add(noise.id() + "@" + seed + "=" + assertParity(noise, seed, real, RUNTIME_TOLERANCE)); + } + } + System.out.println("26.3 runtime parity " + report); + } + + private static double assertParity(ParityNoise noise, long seed, Noise real, double tolerance) { + VanillaNoiseModel model = noise.model(seed); + SplittableRandom random = new SplittableRandom(seed ^ noise.id().hashCode()); + double worst = 0.0D; + double peak = 0.0D; + for (int index = 0; index < POINTS; index++) { + double range = random.nextInt(4) == 0 ? 30_000_000.0D : 100_000.0D; + double x = random.nextDouble(-range, range); + double y = random.nextDouble(-2_048.0D, 2_048.0D); + double z = random.nextDouble(-range, range); + double expected = real.get(x, y, z); + worst = Math.max(worst, Math.abs(expected - model.sample(x, y, z))); + peak = Math.max(peak, Math.abs(expected)); + } + assertTrue(noise.id() + " seed " + seed + " differs by " + worst, worst <= tolerance); + assertTrue(noise.id() + " seed " + seed + " is flat", peak > 0.05D); + return worst; + } + + private static RandomState randomState(long seed) { + HolderGetter noises = registries.registries().lookupOrThrow(Registries.NOISE); + NoiseGeneratorSettings settings = registries.registries().lookupOrThrow(Registries.NOISE_SETTINGS) + .getOrThrow(NoiseGeneratorSettings.OVERWORLD).value(); + return RandomState.create(noises, seed, settings); + } + + private static ResourceKey key(String id) { + return ResourceKey.create(Registries.NOISE, Identifier.parse(id)); + } + + private static String resourcePath(String id) { + Identifier identifier = Identifier.parse(id); + return "data/" + identifier.getNamespace() + "/worldgen/noise/" + identifier.getPath() + ".json"; + } + + private record ParityNoise(String id, String json, int firstOctave, List amplitudes, + VanillaNoiseModel.OctaveProfile profile) { + private static ParityNoise mapped(String id, int firstOctave, List amplitudes) { + String modifiers = amplitudes.stream().map(String::valueOf).collect(Collectors.joining(",")); + String json = "{\"base_octave\":" + firstOctave + ",\"octave_count\":" + amplitudes.size() + + ",\"amplitude_modifiers\":[" + modifiers + "],\"base_amplitude\":1.0,\"normalize\":false}"; + return new ParityNoise(id, json, firstOctave, amplitudes, null); + } + + private static ParityNoise profiled(String id, String json, VanillaNoiseModel.OctaveProfile profile) { + return new ParityNoise(id, json, 0, List.of(), profile); + } + + private VanillaNoiseModel model(long seed) { + if (profile == null) { + return VanillaNoiseModel.create(id, firstOctave, amplitudes, seed, DataVersion.V26_3); + } + return VanillaNoiseModel.create(id, profile, seed); + } + } + + private static final class VanillaPrecisionRandom implements RandomSource { + private static final double DOUBLE_UNIT = 1.1102230246251565E-16D; + + private final RandomSource source; + + private VanillaPrecisionRandom(RandomSource source) { + this.source = source; + } + + @Override + public RandomSource fork() { + return new VanillaPrecisionRandom(source.fork()); + } + + @Override + public PositionalRandomFactory forkPositional() { + return new VanillaPrecisionPositional(source.forkPositional()); + } + + @Override + public void setSeed(long seed) { + source.setSeed(seed); + } + + @Override + public int nextInt() { + return source.nextInt(); + } + + @Override + public int nextInt(int bound) { + return source.nextInt(bound); + } + + @Override + public long nextLong() { + return source.nextLong(); + } + + @Override + public boolean nextBoolean() { + return source.nextBoolean(); + } + + @Override + public float nextFloat() { + return source.nextFloat(); + } + + @Override + public double nextDouble() { + return (source.nextLong() >>> 11) * DOUBLE_UNIT; + } + + @Override + public double nextGaussian() { + return source.nextGaussian(); + } + } + + private record VanillaPrecisionPositional(PositionalRandomFactory factory) implements PositionalRandomFactory { + @Override + public RandomSource at(int x, int y, int z) { + return new VanillaPrecisionRandom(factory.at(x, y, z)); + } + + @Override + public RandomSource fromHashOf(String name) { + return new VanillaPrecisionRandom(factory.fromHashOf(name)); + } + + @Override + public RandomSource fromSeed(long seed) { + return new VanillaPrecisionRandom(factory.fromSeed(seed)); + } + + @Override + public void parityConfigString(StringBuilder builder) { + factory.parityConfigString(builder); + } + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/WorldgenExport263CodecTest.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/WorldgenExport263CodecTest.java new file mode 100644 index 000000000..f8d39ff86 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/WorldgenExport263CodecTest.java @@ -0,0 +1,700 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.AtomicDatapackPublisher; +import art.arcane.iris.pack.datapack.export.DatapackExportFormat; +import art.arcane.iris.pack.datapack.export.ExportIrFixtures; +import art.arcane.iris.pack.datapack.export.GenerationCost; +import art.arcane.iris.pack.datapack.export.WorldgenDatapackExporter; +import art.arcane.iris.pack.datapack.export.WorldgenExportMode; +import art.arcane.iris.pack.datapack.export.WorldgenExportProgress; +import art.arcane.iris.pack.datapack.export.WorldgenExportRequest; +import art.arcane.iris.pack.datapack.export.WorldgenExportResult; +import art.arcane.iris.pack.datapack.export.compile.PackCompiler; +import art.arcane.iris.pack.datapack.export.compile.surface.MaterialProgramCompiler; +import art.arcane.iris.pack.datapack.export.compile.terrain.FieldMacros; +import art.arcane.iris.pack.datapack.export.compile.terrain.HeightCompiler; +import art.arcane.iris.pack.datapack.export.emit.NestingSplitter; +import art.arcane.iris.pack.datapack.export.emit.WorldgenJsonWriter; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportIr; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.FieldEvaluator; +import art.arcane.iris.pack.datapack.export.ir.NoiseDef; +import art.arcane.iris.pack.datapack.export.lower.GenerationBudget; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; +import net.minecraft.core.HolderSet; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.tags.WorldPresetTags; +import net.minecraft.util.Interval; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.ProtoChunk; +import net.minecraft.world.level.dimension.LevelStem; +import net.minecraft.world.level.levelgen.densityfunction.DensityFunction; +import net.minecraft.world.level.levelgen.densityfunction.DfRewriteRule; +import net.minecraft.world.level.levelgen.densityfunction.op.BinaryFunction; +import net.minecraft.world.level.levelgen.material.rule.OreVeinRule; +import net.minecraft.world.level.levelgen.presets.WorldPreset; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.level.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.StructureStart; +import org.junit.AfterClass; +import org.junit.Assume; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Deque; +import java.util.Enumeration; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.SortedMap; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.concurrent.atomic.AtomicReference; +import java.util.zip.GZIPInputStream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public final class WorldgenExport263CodecTest { + @ClassRule + public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation(); + + @ClassRule + public static final TemporaryFolder overworldFolder = new TemporaryFolder(); + + private static final long SEED = 1337L; + private static final String DIMENSION_ID = ExportIrFixtures.NAMESPACE + ":" + ExportIrFixtures.DIMENSION; + private static final String HEIGHT_ID = ExportIrFixtures.NAMESPACE + ":" + ExportIrFixtures.DIMENSION + "/height"; + private static final double SURFACE_TOLERANCE = 2.0; + private static final String BIOME_PREFIX = "data/" + ExportIrFixtures.NAMESPACE + "/worldgen/biome/"; + private static final int[] SAMPLE_Y = {-64, 0, 64, 200}; + private static final int LATTICE = 16; + private static final int LATTICE_STEP = 61; + private static final int HEIGHT_POINTS = 8; + private static final int HEIGHT_STEP = 97; + private static final String PROBE_PACK = "iris.exportProbePack"; + private static final String OVERWORLD = "overworld"; + private static final String OVERWORLD_ID = WorldgenExportRequest.DEFAULT_NAMESPACE + ":" + OVERWORLD; + private static final String OVERWORLD_BIOME_PREFIX = "data/" + WorldgenExportRequest.DEFAULT_NAMESPACE + "/worldgen/biome/"; + private static final String LOG_PREFIX = "[worldgen-export]"; + private static final int OVERWORLD_LATTICE = 4; + private static final int OVERWORLD_STEP = 509; + private static final int MAX_NESTING = 255; + private static final String CAVE_FIELD = "cave/field"; + private static final String WALL_FALLBACK = "uses the nearest kept palette (minecraft:"; + private static final double WALL_SHELL_BAND_LIMIT = 0.25; + private static final String DENSITY_FUNCTION_RESOURCE = "worldgen/density_function"; + private static final double MEGABYTE = 1024.0 * 1024.0; + private static final int OVERWORLD_LAVA_Y = -248; + private static final int LAVA_LINE = -54; + private static final int DEEP_CHUNKS = 2; + private static final int DEEP_STRIDE = 4; + private static final int CHUNK_WIDTH = 16; + private static final float OPEN_MARGIN = 1.0e-3f; + private static final int LAVA_SEARCH_RADIUS = 1024; + private static final int LAVA_SEARCH_STEP = 16; + private static final int LAVA_CELLS = 4; + private static final int AGREEMENT_POINTS = 16; + private static final double AGREEMENT_TOLERANCE = 1.0; + private static final int AGREEMENT_OUTLIERS = 2; + private static final String SUBTERRAIN_STRUCTURES = "data/" + WorldgenExportRequest.DEFAULT_NAMESPACE + "/worldgen/structure/subterrain/"; + private static final String SUBTERRAIN_TEMPLATES = "data/" + WorldgenExportRequest.DEFAULT_NAMESPACE + "/structure/subterrain/"; + private static final int ROOM_GRID = 24; + private static final int ROOM_STEP_CHUNKS = 8; + private static final int ROOM_ATTEMPTS = 4; + private static final int ROOM_REACH = 128; + private static final Map> FILES = new EnumMap<>(WorldgenExportMode.class); + private static final Map SAMPLERS = new EnumMap<>(WorldgenExportMode.class); + private static SortedMap exportedOverworld; + private static WorldgenExportResult exportedOverworldResult; + private static ExportIr exportedOverworldIr; + + @BeforeClass + public static void loadExports() throws IOException { + for (WorldgenExportMode mode : WorldgenExportMode.values()) { + SortedMap files = WorldgenJsonWriter.forTarget(DataVersion.V26_3).write(ExportIrFixtures.flatNoise(DataVersion.V26_3, mode)); + FILES.put(mode, files); + SAMPLERS.put(mode, WorldgenExportSampler.open(files, mode, DIMENSION_ID, SEED)); + } + } + + @AfterClass + public static void closeExports() throws IOException { + for (WorldgenExportSampler sampler : SAMPLERS.values()) { + sampler.close(); + } + SAMPLERS.clear(); + FILES.clear(); + exportedOverworld = null; + exportedOverworldResult = null; + exportedOverworldIr = null; + } + + @Test + public void fixtureLoadsInEveryMode() { + for (WorldgenExportMode mode : WorldgenExportMode.values()) { + WorldgenExportSampler sampler = SAMPLERS.get(mode); + assertEquals(ExportIrFixtures.MIN_Y, sampler.minY()); + assertEquals(ExportIrFixtures.HEIGHT, sampler.height()); + assertEquals(ExportIrFixtures.SEA_LEVEL, sampler.seaLevel()); + assertFalse(sampler.densityFunctionIds().isEmpty()); + for (String id : sampler.densityFunctionIds()) { + assertTrue(mode + " " + id, sampler.registries().lookupOrThrow(Registries.DENSITY_FUNCTION).containsKey(Identifier.parse(id))); + } + assertTrue(sampler.registries().lookupOrThrow(Registries.NOISE_SETTINGS).containsKey(Identifier.parse(DIMENSION_ID))); + assertTrue(sampler.registries().lookupOrThrow(Registries.DIMENSION_TYPE).containsKey(Identifier.parse(DIMENSION_ID))); + assertTrue(sampler.registries().lookupOrThrow(Registries.MATERIAL_RULE).containsKey(Identifier.parse(DIMENSION_ID))); + boolean preset = sampler.registries().lookupOrThrow(Registries.WORLD_PRESET).containsKey(Identifier.parse(DIMENSION_ID)); + boolean dimension = sampler.registries().lookupOrThrow(Registries.LEVEL_STEM).containsKey(Identifier.parse(DIMENSION_ID)); + boolean overworld = sampler.registries().lookupOrThrow(Registries.LEVEL_STEM).containsKey(LevelStem.OVERWORLD.identifier()); + assertEquals(mode.toString(), mode == WorldgenExportMode.PRESET, preset); + assertEquals(mode.toString(), mode == WorldgenExportMode.DIMENSION, dimension); + assertEquals(mode.toString(), mode == WorldgenExportMode.OVERWORLD, overworld); + } + WorldgenExportSampler presetSampler = SAMPLERS.get(WorldgenExportMode.PRESET); + Holder preset = presetSampler.registries().lookupOrThrow(Registries.WORLD_PRESET) + .getOrThrow(ResourceKey.create(Registries.WORLD_PRESET, Identifier.parse(DIMENSION_ID))); + assertTrue(preset.value().createWorldDimensions().get(LevelStem.NETHER).isPresent()); + assertTrue(preset.value().createWorldDimensions().get(LevelStem.END).isPresent()); + HolderSet.Named normal = presetSampler.registries().lookupOrThrow(Registries.WORLD_PRESET).getOrThrow(WorldPresetTags.NORMAL); + assertTrue(normal.contains(preset)); + } + + @Test + public void everyDensityFunctionEvaluatesFinite() { + for (WorldgenExportMode mode : WorldgenExportMode.values()) { + WorldgenExportSampler sampler = SAMPLERS.get(mode); + for (String id : sampler.densityFunctionIds()) { + for (int y : SAMPLE_Y) { + for (int xIndex = 0; xIndex < LATTICE; xIndex++) { + for (int zIndex = 0; zIndex < LATTICE; zIndex++) { + int x = (xIndex - LATTICE / 2) * LATTICE_STEP; + int z = (zIndex - LATTICE / 2) * LATTICE_STEP; + float value = sampler.density(id, x, y, z); + assertTrue(mode + " " + id + " at " + x + "," + y + "," + z + " = " + value, Float.isFinite(value)); + } + } + } + } + } + } + + @Test + public void baseHeightWithinDimension() { + for (WorldgenExportMode mode : WorldgenExportMode.values()) { + WorldgenExportSampler sampler = SAMPLERS.get(mode); + Set heights = new HashSet<>(); + int land = 0; + for (int xIndex = 0; xIndex < HEIGHT_POINTS; xIndex++) { + for (int zIndex = 0; zIndex < HEIGHT_POINTS; zIndex++) { + int x = (xIndex - HEIGHT_POINTS / 2) * HEIGHT_STEP; + int z = (zIndex - HEIGHT_POINTS / 2) * HEIGHT_STEP; + int height = sampler.baseHeight(x, z); + assertTrue(mode + " height " + height + " at " + x + "," + z, + height >= sampler.minY() && height <= sampler.minY() + sampler.height()); + float surface = sampler.density(HEIGHT_ID, x, 0, z); + int floor = sampler.floorHeight(x, z); + assertTrue(mode + " height " + height + " below floor " + floor, height >= floor); + if (surface > sampler.seaLevel() + 1) { + assertTrue(mode + " floor " + floor + " against surface " + surface, Math.abs(floor - surface) <= SURFACE_TOLERANCE); + land++; + } + heights.add(height); + } + } + assertTrue(mode + " surface never varies", heights.size() > 1); + assertTrue(mode + " has no land column above sea level", land > 0); + } + } + + @Test + public void resolverReturnsOnlyExportedBiomes() { + for (WorldgenExportMode mode : WorldgenExportMode.values()) { + WorldgenExportSampler sampler = SAMPLERS.get(mode); + Set exported = exportedBiomes(FILES.get(mode), BIOME_PREFIX, ExportIrFixtures.NAMESPACE); + assertFalse(exported.isEmpty()); + assertTrue(mode + " " + sampler.possibleBiomes(), exported.containsAll(sampler.possibleBiomes())); + for (int y : SAMPLE_Y) { + for (int xIndex = 0; xIndex < LATTICE; xIndex++) { + for (int zIndex = 0; zIndex < LATTICE; zIndex++) { + String biome = sampler.biome((xIndex - LATTICE / 2) * LATTICE_STEP, y, (zIndex - LATTICE / 2) * LATTICE_STEP); + assertTrue(mode + " " + biome, exported.contains(biome)); + } + } + } + } + } + + @Test + public void overworldPackLoads() throws Exception { + SortedMap files = overworldFiles(); + assertGenerationBudget(exportedOverworldResult); + assertWallPalettesFallBack(exportedOverworldResult); + for (Map.Entry file : files.entrySet()) { + if (file.getKey().endsWith(".json")) { + int depth = NestingSplitter.depth(JsonParser.parseString(new String(file.getValue(), StandardCharsets.UTF_8))); + assertTrue(file.getKey() + " nests " + depth, depth <= MAX_NESTING); + } + if (file.getKey().endsWith(".nbt")) { + assertCompleteGzip(file.getKey(), file.getValue()); + } + } + try (WorldgenExportSampler sampler = WorldgenExportSampler.open(files, WorldgenExportMode.OVERWORLD, OVERWORLD_ID, SEED)) { + Set roots = new TreeSet<>(); + for (String id : sampler.densityFunctionIds()) { + if (id.startsWith(OVERWORLD_ID + "/")) { + roots.add(id); + } + } + assertTrue(roots.toString(), roots.contains(OVERWORLD_ID + "/final_density")); + for (String id : roots) { + for (int y : SAMPLE_Y) { + for (int xIndex = 0; xIndex < OVERWORLD_LATTICE; xIndex++) { + for (int zIndex = 0; zIndex < OVERWORLD_LATTICE; zIndex++) { + int x = (xIndex - OVERWORLD_LATTICE / 2) * OVERWORLD_STEP; + int z = (zIndex - OVERWORLD_LATTICE / 2) * OVERWORLD_STEP; + float value = sampler.density(id, x, y, z); + assertTrue(id + " at " + x + "," + y + "," + z + " = " + value, Float.isFinite(value)); + } + } + } + } + assertNaturalHeightMatchesTheEvaluator(exportedOverworldIr, sampler); + Set exported = exportedBiomes(files, OVERWORLD_BIOME_PREFIX, WorldgenExportRequest.DEFAULT_NAMESPACE); + assertFalse(exported.isEmpty()); + assertTrue(sampler.possibleBiomes().toString(), exported.containsAll(sampler.possibleBiomes())); + for (int xIndex = 0; xIndex < HEIGHT_POINTS; xIndex++) { + for (int zIndex = 0; zIndex < HEIGHT_POINTS; zIndex++) { + int x = (xIndex - HEIGHT_POINTS / 2) * HEIGHT_STEP; + int z = (zIndex - HEIGHT_POINTS / 2) * HEIGHT_STEP; + int height = sampler.baseHeight(x, z); + assertTrue("height " + height + " at " + x + "," + z, height >= sampler.minY() && height <= sampler.minY() + sampler.height()); + for (int y : SAMPLE_Y) { + String biome = sampler.biome(x, y, z); + assertTrue(biome + " at " + x + "," + y + "," + z, exported.contains(biome)); + } + } + } + } + } + + @Test + public void overworldHasNoDisjointMinMax() throws Exception { + SortedMap files = overworldFiles(); + try (WorldgenExportSampler sampler = WorldgenExportSampler.open(files, WorldgenExportMode.OVERWORLD, OVERWORLD_ID, SEED)) { + MinMaxAudit audit = new MinMaxAudit(); + for (String id : sampler.densityFunctionIds()) { + audit.rewrite(sampler.registries().lookupOrThrow(Registries.DENSITY_FUNCTION) + .getOrThrow(ResourceKey.create(Registries.DENSITY_FUNCTION, Identifier.parse(id))).value()); + } + System.out.println(LOG_PREFIX + " min/max nodes " + audit.checked() + ", disjoint " + audit.disjoint().size()); + assertTrue(audit.checked() > 0); + assertTrue(audit.disjoint().size() + " disjoint min/max nodes: " + audit.disjoint(), audit.disjoint().isEmpty()); + } + } + + @Test + public void overworldDeepCavesOpenWithoutLava() throws Exception { + SortedMap files = overworldFiles(); + String field = WorldgenExportRequest.DEFAULT_NAMESPACE + ":" + CAVE_FIELD; + long open = 0L; + long lava = 0L; + try (WorldgenExportSampler sampler = WorldgenExportSampler.open(files, WorldgenExportMode.OVERWORLD, OVERWORLD_ID, SEED)) { + assertTrue(field, sampler.densityFunctionIds().contains(field)); + BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos(); + for (int chunkX = -DEEP_CHUNKS; chunkX < DEEP_CHUNKS; chunkX++) { + for (int chunkZ = -DEEP_CHUNKS; chunkZ < DEEP_CHUNKS; chunkZ++) { + ProtoChunk chunk = sampler.terrainChunk(chunkX, chunkZ); + for (int x = 0; x < CHUNK_WIDTH; x++) { + for (int z = 0; z < CHUNK_WIDTH; z++) { + for (int y = OVERWORLD_LAVA_Y + 1; y < LAVA_LINE; y++) { + BlockState state = chunk.getBlockState(position.set(x, y, z)); + assertFalse(state + " at chunk " + chunkX + "," + chunkZ + " " + x + "," + y + "," + z, state.is(Blocks.LAVA)); + } + } + } + for (int x = 0; x < CHUNK_WIDTH; x += DEEP_STRIDE) { + for (int z = 0; z < CHUNK_WIDTH; z += DEEP_STRIDE) { + int blockX = (chunkX << 4) + x; + int blockZ = (chunkZ << 4) + z; + for (int y = OVERWORLD_LAVA_Y + 1; y < LAVA_LINE; y++) { + if (sampler.density(field, blockX, y, blockZ) > -OPEN_MARGIN) { + continue; + } + BlockState state = chunk.getBlockState(position.set(x, y, z)); + assertTrue(state + " at " + blockX + "," + y + "," + blockZ, state.isAir()); + open++; + } + } + } + } + } + for (BlockPos cell : openLavaBandCells(sampler, field)) { + ProtoChunk chunk = sampler.terrainChunk(cell.getX() >> 4, cell.getZ() >> 4); + BlockState state = chunk.getBlockState(position.set(cell.getX() & (CHUNK_WIDTH - 1), cell.getY(), cell.getZ() & (CHUNK_WIDTH - 1))); + assertTrue(state + " at " + cell, state.is(Blocks.LAVA)); + lava++; + } + } + System.out.println(LOG_PREFIX + " deep cave cells open " + open + ", lava band cells " + lava); + assertTrue("no deep cave cell opened", open > 0L); + assertTrue("no lava band cell opened", lava > 0L); + } + + @Test + public void overworldMaterialPassComputesTheCaveFieldOnce() throws Exception { + SortedMap files = overworldFiles(); + try (WorldgenExportSampler sampler = WorldgenExportSampler.open(files, WorldgenExportMode.OVERWORLD, OVERWORLD_ID, SEED)) { + CompiledCaches caches = CompiledCaches.open(sampler.registries(), SEED); + int field = caches.rootId(caches.function(WorldgenExportRequest.DEFAULT_NAMESPACE + ":" + CAVE_FIELD)); + List veins = caches.oreVeins(); + DensityFunction opening = veins.getFirst().density(); + assertTrue(caches.ids(opening).contains(field)); + assertTrue(caches.ids(caches.finalDensity()).contains(field)); + Set caveCaches = caches.ids(caches.function(WorldgenExportRequest.DEFAULT_NAMESPACE + ":" + CAVE_FIELD)); + int shells = 0; + for (OreVeinRule vein : veins) { + if (!vein.oreBlock().isAir()) { + assertTrue("wall shell " + shells + " " + vein.oreBlock(), caches.ids(vein.density()).contains(field)); + assertTrue("wall shell " + shells + " " + vein.oreBlock(), caveCaches.containsAll(caches.ids(vein.density()))); + shells++; + } + } + JsonObject shell = JsonParser.parseString(new String(files.get("data/" + WorldgenExportRequest.DEFAULT_NAMESPACE + "/" + DENSITY_FUNCTION_RESOURCE + + "/" + MaterialProgramCompiler.WALL_SHELL + ".json"), StandardCharsets.UTF_8)).getAsJsonObject(); + double band = shell.get("max_exclusive").getAsDouble(); + System.out.println(LOG_PREFIX + " material pass: " + veins.size() + " ore vein rules, " + shells + " wall shells read cave field cache " + field + + " within band " + band); + assertTrue(String.valueOf(shells), shells > 0); + assertTrue(String.valueOf(band), band > 0.0 && band < WALL_SHELL_BAND_LIMIT); + } + } + + @Test + public void overworldSubterrainRoomsPlace() throws Exception { + SortedMap files = overworldFiles(); + SortedMap structures = new TreeMap<>(); + for (Map.Entry file : files.entrySet()) { + if (file.getKey().startsWith(SUBTERRAIN_STRUCTURES) && file.getKey().endsWith(".json")) { + String path = file.getKey().substring(SUBTERRAIN_STRUCTURES.length(), file.getKey().length() - ".json".length()); + structures.put(WorldgenExportRequest.DEFAULT_NAMESPACE + ":subterrain/" + path, + JsonParser.parseString(new String(file.getValue(), StandardCharsets.UTF_8)).getAsJsonObject()); + } + } + long templateBytes = 0L; + for (Map.Entry file : files.entrySet()) { + if (file.getKey().startsWith(SUBTERRAIN_TEMPLATES)) { + templateBytes += file.getValue().length; + } + } + System.out.println(LOG_PREFIX + " subterrain structures " + structures.size() + ", structure sets " + + count(files, "/worldgen/structure_set/subterrain/", ".json") + ", templates " + count(files, "/structure/subterrain/", ".nbt") + + ", template bytes " + templateBytes); + assertFalse(structures.isEmpty()); + try (WorldgenExportSampler sampler = WorldgenExportSampler.open(files, WorldgenExportMode.OVERWORLD, OVERWORLD_ID, SEED)) { + for (Map.Entry entry : structures.entrySet()) { + Structure structure = sampler.registries().lookupOrThrow(Registries.STRUCTURE) + .getOrThrow(ResourceKey.create(Registries.STRUCTURE, Identifier.parse(entry.getKey()))).value(); + JsonObject height = entry.getValue().getAsJsonObject("start_height"); + int y = (height.getAsJsonObject("min_inclusive").get("absolute").getAsInt() + + height.getAsJsonObject("max_inclusive").get("absolute").getAsInt()) / 2; + List matches = new ArrayList<>(); + for (int xIndex = 0; xIndex < ROOM_GRID; xIndex++) { + for (int zIndex = 0; zIndex < ROOM_GRID; zIndex++) { + ChunkPos chunk = new ChunkPos((xIndex - ROOM_GRID / 2) * ROOM_STEP_CHUNKS, (zIndex - ROOM_GRID / 2) * ROOM_STEP_CHUNKS); + String biome = sampler.biome(chunk.getMinBlockX(), y, chunk.getMinBlockZ()); + Holder holder = sampler.registries().lookupOrThrow(Registries.BIOME) + .getOrThrow(ResourceKey.create(Registries.BIOME, Identifier.parse(biome))); + if (structure.biomes().contains(holder)) { + matches.add(chunk); + } + } + } + System.out.println(String.format(Locale.ROOT, "%s subterrain %s biome match %d/%d at y %d", LOG_PREFIX, entry.getKey(), + matches.size(), ROOM_GRID * ROOM_GRID, y)); + assertFalse(entry.getKey() + " finds no biome at y " + y, matches.isEmpty()); + StructureStart start = null; + for (ChunkPos chunk : matches.subList(0, Math.min(ROOM_ATTEMPTS, matches.size()))) { + StructureStart candidate = sampler.structureStart(entry.getKey(), chunk.x(), chunk.z()); + if (candidate.isValid()) { + start = candidate; + BoundingBox box = start.getBoundingBox(); + assertTrue(entry.getKey() + " " + box, chunk.getMinBlockX() - box.minX() <= ROOM_REACH && box.maxX() - chunk.getMinBlockX() <= ROOM_REACH); + assertTrue(entry.getKey() + " " + box, chunk.getMinBlockZ() - box.minZ() <= ROOM_REACH && box.maxZ() - chunk.getMinBlockZ() <= ROOM_REACH); + assertTrue(entry.getKey() + " " + box, box.minY() >= sampler.minY() && box.maxY() < sampler.minY() + sampler.height()); + break; + } + } + assertTrue(entry.getKey() + " never starts", start != null); + } + } + } + + private static List openLavaBandCells(WorldgenExportSampler sampler, String field) { + List cells = new ArrayList<>(LAVA_CELLS); + for (int x = -LAVA_SEARCH_RADIUS; x < LAVA_SEARCH_RADIUS; x += LAVA_SEARCH_STEP) { + for (int z = -LAVA_SEARCH_RADIUS; z < LAVA_SEARCH_RADIUS; z += LAVA_SEARCH_STEP) { + for (int y = sampler.minY(); y <= OVERWORLD_LAVA_Y; y++) { + if (sampler.density(field, x, y, z) > -OPEN_MARGIN) { + continue; + } + cells.add(new BlockPos(x, y, z)); + if (cells.size() == LAVA_CELLS) { + return cells; + } + } + } + } + return cells; + } + + private static synchronized SortedMap overworldFiles() throws Exception { + String probePack = System.getProperty(PROBE_PACK); + Assume.assumeTrue("Set -P" + PROBE_PACK + " to export a real pack", probePack != null && !probePack.isBlank()); + if (exportedOverworld != null) { + return exportedOverworld; + } + Path root = overworldFolder.newFolder("exports").toPath(); + SortedMap untrimmed = new TreeMap<>(); + AtomicReference compiled = new AtomicReference<>(); + WorldgenExportResult result; + try (RealPackProbeSupport.Workspace workspace = RealPackProbeSupport.openWorkspace(new RealPackProbeSupport.WorkspaceOptions( + new File(probePack), OVERWORLD, LOG_PREFIX, new HeadlessNativeRuntime(), overworldFolder.newFolder("workspace").toPath()))) { + WorldgenExportRequest request = WorldgenExportRequest.builder() + .pack(workspace.pack().toPath()) + .dimension(OVERWORLD) + .exportRoot(root) + .objects(true) + .build(); + result = new WorldgenDatapackExporter(target -> ir -> { + compiled.set(ir); + SortedMap written = WorldgenJsonWriter.forTarget(target).write(ir); + untrimmed.putAll(written); + return written; + }, PackCompiler.create()).export(request, WorldgenExportProgress.NONE); + } + assertTrue(result.message() + " " + result.diagnostics(), result.isSuccess()); + SortedMap files = zipEntries(result.output()); + Path untrimmedZip = root.resolve("untrimmed.zip"); + new AtomicDatapackPublisher().publish(untrimmed, untrimmedZip, DatapackExportFormat.ZIP, false, ".untrimmed-"); + report(result, untrimmed, Files.size(untrimmedZip), files); + exportedOverworld = files; + exportedOverworldResult = result; + exportedOverworldIr = compiled.get(); + return files; + } + + private static void assertGenerationBudget(WorldgenExportResult result) { + GenerationCost cost = result.cost(); + Set flagged = new TreeSet<>(); + for (ExportDiagnostic diagnostic : result.diagnostics()) { + if (diagnostic.limit() == ExportLimit.GENERATION_COST) { + flagged.add(diagnostic.resource()); + } + } + for (GenerationCost.Root root : GenerationCost.Root.values()) { + GenerationCost.RootCost rootCost = cost.root(root).orElseThrow(); + System.out.println(String.format(Locale.ROOT, "%s cost %s worst case %.3e expanded %.3e distinct %d", LOG_PREFIX, root.id(), + rootCost.worstCaseNodes(), rootCost.expandedNodes(), rootCost.distinctNodes())); + } + for (GenerationCost.Root root : GenerationCost.Root.values()) { + if (!root.climate()) { + continue; + } + double limit = GenerationBudget.worstCaseLimit(root).orElseThrow(); + double nodes = cost.root(root).orElseThrow().worstCaseNodes(); + String resource = "noise_settings/" + OVERWORLD + "/" + root.id(); + assertTrue(root.id() + " evaluates " + nodes + " nodes, budget " + limit, nodes <= limit); + assertFalse(resource + " " + flagged, flagged.contains(resource)); + } + long finalDensity = cost.root(GenerationCost.Root.FINAL_DENSITY).orElseThrow().distinctNodes(); + String finalResource = "noise_settings/" + OVERWORLD + "/" + GenerationCost.Root.FINAL_DENSITY.id(); + System.out.println(String.format(Locale.ROOT, "%s final_density distinct %d of %d, density function JSON %.1f of %.1f MB", LOG_PREFIX, finalDensity, + GenerationBudget.FINAL_DENSITY_DISTINCT_NODES, cost.densityFunctionBytes() / MEGABYTE, GenerationBudget.DENSITY_FUNCTION_JSON_BYTES / MEGABYTE)); + assertEquals(finalResource + " " + flagged, finalDensity > GenerationBudget.FINAL_DENSITY_DISTINCT_NODES, flagged.contains(finalResource)); + assertTrue(cost.densityFunctionBytes() + " bytes", cost.densityFunctionBytes() > 0L); + assertTrue(cost.densityFunctionBytes() + " bytes", cost.densityFunctionBytes() <= GenerationBudget.DENSITY_FUNCTION_JSON_BYTES); + assertFalse(flagged.toString(), flagged.contains(DENSITY_FUNCTION_RESOURCE)); + } + + private static void assertWallPalettesFallBack(WorldgenExportResult result) { + int fallbacks = 0; + for (ExportDiagnostic diagnostic : result.diagnostics()) { + if (diagnostic.limit() == ExportLimit.WALL_PALETTE && diagnostic.severity() == ExportDiagnostic.Severity.WARNING) { + assertTrue(diagnostic.resource() + " " + diagnostic.message(), diagnostic.message().contains(WALL_FALLBACK)); + fallbacks++; + } + } + System.out.println(LOG_PREFIX + " wall palettes over the ore vein budget " + fallbacks); + } + + private static void assertNaturalHeightMatchesTheEvaluator(ExportIr ir, WorldgenExportSampler sampler) { + Field.Shared height = shared(ir.router().chunkSurfaceLevel(), HeightCompiler.HEIGHT); + Map noises = new HashMap<>(); + for (NoiseDef noise : ir.noises()) { + noises.put(ir.namespace() + ":" + noise.id(), noise); + } + String heightId = ir.namespace() + ":" + HeightCompiler.HEIGHT; + int outliers = 0; + StringBuilder detail = new StringBuilder(); + for (int xIndex = 0; xIndex < AGREEMENT_POINTS; xIndex++) { + for (int zIndex = 0; zIndex < AGREEMENT_POINTS; zIndex++) { + int x = (xIndex - AGREEMENT_POINTS / 2) * OVERWORLD_STEP; + int z = (zIndex - AGREEMENT_POINTS / 2) * OVERWORLD_STEP; + double evaluated = FieldEvaluator.sample(height.expr(), noises, SEED, DataVersion.V26_3, x, 0, z); + double exported = sampler.density(heightId, x, 0, z); + if (Math.abs(evaluated - exported) > AGREEMENT_TOLERANCE) { + outliers++; + detail.append(' ').append(x).append(',').append(z).append('=').append(evaluated).append('/').append(exported); + } + } + } + assertTrue(outliers + " columns differ:" + detail, outliers <= AGREEMENT_OUTLIERS); + } + + private static Field.Shared shared(Field root, String name) { + Set visited = Collections.newSetFromMap(new IdentityHashMap<>()); + Deque pending = new ArrayDeque<>(); + pending.push(root); + while (!pending.isEmpty()) { + Field field = pending.pop(); + if (!visited.add(field)) { + continue; + } + if (field instanceof Field.Shared candidate && candidate.name().equals(name)) { + return candidate; + } + for (Field child : FieldMacros.children(field)) { + pending.push(child); + } + } + throw new AssertionError("Router has no shared field " + name); + } + + private static void assertCompleteGzip(String path, byte[] bytes) throws IOException { + try (InputStream input = new GZIPInputStream(new ByteArrayInputStream(bytes))) { + input.readAllBytes(); + assertEquals(path, -1, input.read()); + } + } + + private static void report(WorldgenExportResult result, SortedMap untrimmed, long untrimmedZipBytes, + SortedMap files) throws IOException { + SortedMap limits = new TreeMap<>(); + for (ExportDiagnostic diagnostic : result.diagnostics()) { + limits.merge(diagnostic.limit(), 1, Integer::sum); + } + System.out.println(LOG_PREFIX + " untrimmed zip bytes " + untrimmedZipBytes + ", files " + untrimmed.size() + + ", trimmed zip bytes " + Files.size(result.output()) + ", files " + files.size() + ", trim " + result.trim()); + System.out.println(LOG_PREFIX + " diagnostics " + result.diagnostics().size() + " " + limits); + reportCounts("untrimmed", untrimmed); + reportCounts("trimmed", files); + } + + private static void reportCounts(String label, SortedMap files) { + System.out.println(LOG_PREFIX + " " + label + " templates " + count(files, "/structure/", ".nbt") + + ", structures " + count(files, "/worldgen/structure/", ".json") + + ", structure sets " + count(files, "/worldgen/structure_set/", ".json") + + ", placed features " + count(files, "/worldgen/placed_feature/", ".json") + + ", features " + count(files, "/worldgen/feature/", ".json") + + ", density functions " + count(files, "/worldgen/density_function/", ".json") + + ", noises " + count(files, "/worldgen/noise/", ".json") + + ", biomes " + count(files, "/worldgen/biome/", ".json")); + } + + private static int count(SortedMap files, String folder, String suffix) { + int count = 0; + for (String path : files.keySet()) { + if (path.contains(folder) && path.endsWith(suffix)) { + count++; + } + } + return count; + } + + private static SortedMap zipEntries(Path zip) throws IOException { + SortedMap files = new TreeMap<>(); + try (ZipFile archive = new ZipFile(zip.toFile())) { + Enumeration entries = archive.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + if (!entry.isDirectory()) { + files.put(entry.getName(), archive.getInputStream(entry).readAllBytes()); + } + } + } + return files; + } + + private static Set exportedBiomes(SortedMap files, String prefix, String namespace) { + Set biomes = new TreeSet<>(); + for (String path : files.keySet()) { + if (path.startsWith(prefix)) { + biomes.add(namespace + ":" + path.substring(prefix.length(), path.length() - ".json".length())); + } + } + return biomes; + } + + private static final class MinMaxAudit implements DfRewriteRule { + private final Set visited = Collections.newSetFromMap(new IdentityHashMap<>()); + private final List disjoint = new ArrayList<>(); + private int checked; + + @Override + public DensityFunction rewrite(DensityFunction function) { + if (!visited.add(function)) { + return function; + } + if (function instanceof BinaryFunction binary && (binary.type() == BinaryFunction.Type.MIN || binary.type() == BinaryFunction.Type.MAX)) { + checked++; + Interval left = binary.left().range(); + Interval right = binary.right().range(); + if (left.max() < right.min() || right.max() < left.min()) { + disjoint.add(binary.type() + " " + left + " " + right); + } + } + function.rewriteChildren(this); + return function; + } + + private int checked() { + return checked; + } + + private List disjoint() { + return disjoint; + } + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/WorldgenExportDeepCaves263Test.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/WorldgenExportDeepCaves263Test.java new file mode 100644 index 000000000..e28db8176 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/WorldgenExportDeepCaves263Test.java @@ -0,0 +1,136 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.pack.datapack.export.ExportTestPacks; +import art.arcane.iris.pack.datapack.export.WorldgenDatapackExporter; +import art.arcane.iris.pack.datapack.export.WorldgenExportMode; +import art.arcane.iris.pack.datapack.export.WorldgenExportProgress; +import art.arcane.iris.pack.datapack.export.WorldgenExportRequest; +import art.arcane.iris.pack.datapack.export.WorldgenExportResult; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.ProtoChunk; +import net.minecraft.world.level.levelgen.densityfunction.DensityFunction; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public final class WorldgenExportDeepCaves263Test { + @ClassRule + public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation(); + + private static final long SEED = 1337L; + private static final String NAMESPACE = WorldgenExportRequest.DEFAULT_NAMESPACE; + private static final String DIMENSION_ID = NAMESPACE + ":" + ExportTestPacks.DIMENSION; + private static final int MIN_Y = -64; + private static final int LAVA_Y = -62; + private static final int LAVA_LINE = -54; + private static final int CAVE_TOP = -14; + private static final int CHUNK_WIDTH = 16; + private static final String CAVE_FIELD = "cave/field"; + private static final String DIMENSION = """ + { + "regions": ["region"], + "dimensionHeight": {"min": -64, "max": 192}, + "fluidHeight": 63, + "caveLavaHeight": 2, + "caveProfile": { + "enabled": true, + "verticalRange": {"min": 0, "max": 50}, + "verticalEdgeFade": 0, + "baseDensityStyle": {"style": "SIMPLEX", "zoom": 0.6}, + "detailDensityStyle": {"style": "SIMPLEX", "zoom": 0.3}, + "baseWeight": 1.0, + "detailWeight": 0.0, + "densityThreshold": {"min": 5.0, "max": 5.0, "style": {"style": "SIMPLEX"}}, + "thresholdBias": 0.0, + "surfaceClearance": 0, + "allowSurfaceBreak": false, + "allowFluid": false, + "allowLava": true, + "modules": [] + } + } + """; + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Rule + public ExportTestPacks.BoundPlatform platform = new ExportTestPacks.BoundPlatform(); + + @Test + public void deepCavesOpenAsAirAboveTheIrisLavaBand() throws IOException { + WorldgenExportResult result = export(); + + assertTrue(result.message() + " " + result.diagnostics(), result.isSuccess()); + JsonObject head = JsonParser.parseString(Files.readString(result.output().resolve("data/" + NAMESPACE + "/worldgen/material_rule/" + + ExportTestPacks.DIMENSION + ".json"), StandardCharsets.UTF_8)).getAsJsonObject().getAsJsonArray("sequence").get(0).getAsJsonObject(); + assertEquals("minecraft:ore_vein", head.get("type").getAsString()); + String opening = head.get("density").getAsString(); + assertTrue(opening, Files.isRegularFile(densityFunction(result.output(), opening))); + try (WorldgenExportSampler sampler = WorldgenExportSampler.open(result.output(), WorldgenExportMode.OVERWORLD, DIMENSION_ID, SEED)) { + ProtoChunk chunk = sampler.terrainChunk(0, 0); + for (int x = 0; x < CHUNK_WIDTH; x++) { + for (int z = 0; z < CHUNK_WIDTH; z++) { + for (int y = MIN_Y; y <= CAVE_TOP; y++) { + BlockState state = chunk.getBlockState(new BlockPos(x, y, z)); + String where = state + " at " + x + "," + y + "," + z; + if (y <= LAVA_Y) { + assertTrue(where, state.is(Blocks.LAVA)); + } else { + assertTrue(where, state.isAir()); + } + } + } + } + assertTrue(chunk.getBlockState(new BlockPos(0, LAVA_LINE - 1, 0)).isAir()); + } + } + + @Test + public void deepCaveRuleSharesTheCaveFieldCacheWithTheFinalDensity() throws IOException { + WorldgenExportResult result = export(); + assertTrue(result.message() + " " + result.diagnostics(), result.isSuccess()); + + try (WorldgenExportSampler sampler = WorldgenExportSampler.open(result.output(), WorldgenExportMode.OVERWORLD, DIMENSION_ID, SEED)) { + CompiledCaches caches = CompiledCaches.open(sampler.registries(), SEED); + DensityFunction opening = caches.oreVeins().getFirst().density(); + int field = caches.rootId(caches.function(NAMESPACE + ":" + CAVE_FIELD)); + Set finalDensity = caches.ids(caches.finalDensity()); + + assertTrue(caches.ids(opening).toString(), caches.ids(opening).contains(field)); + assertTrue(finalDensity.toString(), finalDensity.contains(field)); + assertTrue(finalDensity.toString(), finalDensity.contains(caches.rootId(opening))); + } + } + + private WorldgenExportResult export() throws IOException { + File pack = ExportTestPacks.withDimension(temporaryFolder.newFolder("pack"), DIMENSION); + WorldgenExportRequest request = WorldgenExportRequest.builder() + .pack(pack.toPath()) + .dimension(ExportTestPacks.DIMENSION) + .exportRoot(temporaryFolder.newFolder("exports").toPath()) + .format("directory") + .build(); + return WorldgenDatapackExporter.create().export(request, WorldgenExportProgress.NONE); + } + + private static Path densityFunction(Path datapack, String id) { + int separator = id.indexOf(':'); + return datapack.resolve("data/" + id.substring(0, separator) + "/worldgen/density_function/" + id.substring(separator + 1) + ".json"); + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/WorldgenExportPipeline263Test.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/WorldgenExportPipeline263Test.java new file mode 100644 index 000000000..a34698f64 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/WorldgenExportPipeline263Test.java @@ -0,0 +1,75 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.pack.datapack.export.ExportTestPacks; +import art.arcane.iris.pack.datapack.export.WorldgenDatapackExporter; +import art.arcane.iris.pack.datapack.export.WorldgenExportMode; +import art.arcane.iris.pack.datapack.export.WorldgenExportProgress; +import art.arcane.iris.pack.datapack.export.WorldgenExportRequest; +import art.arcane.iris.pack.datapack.export.WorldgenExportResult; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public final class WorldgenExportPipeline263Test { + @ClassRule + public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation(); + + private static final long SEED = 1337L; + private static final String DIMENSION_ID = WorldgenExportRequest.DEFAULT_NAMESPACE + ":" + ExportTestPacks.DIMENSION; + private static final int[] SAMPLE_Y = {-64, 0, 64, 200}; + private static final int LATTICE = 8; + private static final int LATTICE_STEP = 61; + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Rule + public ExportTestPacks.BoundPlatform platform = new ExportTestPacks.BoundPlatform(); + + @Test + public void exportedPackLoadsAndSamples() throws IOException { + File pack = ExportTestPacks.basic(temporaryFolder.newFolder("pack")); + Path root = temporaryFolder.newFolder("exports").toPath(); + WorldgenExportRequest request = WorldgenExportRequest.builder() + .pack(pack.toPath()) + .dimension(ExportTestPacks.DIMENSION) + .exportRoot(root) + .format("directory") + .build(); + + WorldgenExportResult result = WorldgenDatapackExporter.create().export(request, WorldgenExportProgress.NONE); + + assertTrue(result.message() + " " + result.diagnostics(), result.isSuccess()); + try (WorldgenExportSampler sampler = WorldgenExportSampler.open(result.output(), WorldgenExportMode.OVERWORLD, DIMENSION_ID, SEED)) { + assertFalse(sampler.densityFunctionIds().isEmpty()); + for (String id : sampler.densityFunctionIds()) { + for (int y : SAMPLE_Y) { + for (int xIndex = 0; xIndex < LATTICE; xIndex++) { + for (int zIndex = 0; zIndex < LATTICE; zIndex++) { + int x = (xIndex - LATTICE / 2) * LATTICE_STEP; + int z = (zIndex - LATTICE / 2) * LATTICE_STEP; + float value = sampler.density(id, x, y, z); + assertTrue(id + " at " + x + "," + y + "," + z + " = " + value, Float.isFinite(value)); + } + } + } + } + for (int xIndex = 0; xIndex < LATTICE; xIndex++) { + for (int zIndex = 0; zIndex < LATTICE; zIndex++) { + int x = (xIndex - LATTICE / 2) * LATTICE_STEP; + int z = (zIndex - LATTICE / 2) * LATTICE_STEP; + int height = sampler.baseHeight(x, z); + assertTrue("height " + height + " at " + x + "," + z, height >= sampler.minY() && height <= sampler.minY() + sampler.height()); + } + } + } + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/WorldgenExportSamplerThreadsTest.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/WorldgenExportSamplerThreadsTest.java new file mode 100644 index 000000000..ec1658b29 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/WorldgenExportSamplerThreadsTest.java @@ -0,0 +1,97 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.pack.datapack.export.ExportTestPacks; +import art.arcane.iris.pack.datapack.export.WorldgenDatapackExporter; +import art.arcane.iris.pack.datapack.export.WorldgenExportMode; +import art.arcane.iris.pack.datapack.export.WorldgenExportProgress; +import art.arcane.iris.pack.datapack.export.WorldgenExportRequest; +import art.arcane.iris.pack.datapack.export.WorldgenExportResult; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.nio.file.Path; +import java.util.ArrayList; +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 static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public final class WorldgenExportSamplerThreadsTest { + @ClassRule + public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation(); + + private static final long SEED = 1337L; + private static final String DIMENSION_ID = WorldgenExportRequest.DEFAULT_NAMESPACE + ":" + ExportTestPacks.DIMENSION; + private static final String FINAL_DENSITY_ID = DIMENSION_ID + "/final_density"; + private static final int THREADS = 8; + private static final int POINTS = 2048; + private static final int ROW = 64; + private static final int STEP = 37; + private static final int ROUNDS = 3; + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Rule + public ExportTestPacks.BoundPlatform platform = new ExportTestPacks.BoundPlatform(); + + @Test + public void concurrentSamplingMatchesSequentialSampling() throws Exception { + File pack = ExportTestPacks.basic(temporaryFolder.newFolder("pack")); + WorldgenExportRequest request = WorldgenExportRequest.builder() + .pack(pack.toPath()) + .dimension(ExportTestPacks.DIMENSION) + .exportRoot(temporaryFolder.newFolder("exports").toPath()) + .format("directory") + .build(); + WorldgenExportResult result = WorldgenDatapackExporter.create().export(request, WorldgenExportProgress.NONE); + assertTrue(result.message() + " " + result.diagnostics(), result.isSuccess()); + try (WorldgenExportSampler sampler = WorldgenExportSampler.open(result.output(), WorldgenExportMode.OVERWORLD, DIMENSION_ID, SEED)) { + assertTrue(sampler.densityFunctionIds().toString(), sampler.densityFunctionIds().contains(FINAL_DENSITY_ID)); + Sample expected = sweep(sampler, 0, 1).call(); + ExecutorService executor = Executors.newFixedThreadPool(THREADS); + try { + List> futures = new ArrayList<>(THREADS); + for (int thread = 0; thread < THREADS; thread++) { + futures.add(executor.submit(sweep(sampler, thread * (POINTS / THREADS), ROUNDS))); + } + for (Future future : futures) { + Sample sample = future.get(); + assertArrayEquals(expected.density(), sample.density(), 0.0F); + assertEquals(List.of(expected.biome()), List.of(sample.biome())); + } + } finally { + executor.shutdownNow(); + } + } + } + + private static Callable sweep(WorldgenExportSampler sampler, int offset, int rounds) { + return () -> { + float[] density = new float[POINTS]; + String[] biome = new String[POINTS]; + for (int round = 0; round < rounds; round++) { + for (int step = 0; step < POINTS; step++) { + int index = (offset + step) % POINTS; + int x = (index % ROW - ROW / 2) * STEP; + int y = (index * 7) % 256 - 64; + int z = (index / ROW - POINTS / ROW / 2) * STEP; + density[index] = sampler.density(FINAL_DENSITY_ID, x, y, z); + biome[index] = sampler.biome(x, y, z); + } + } + return new Sample(density, biome); + }; + } + + private record Sample(float[] density, String[] biome) { + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/WorldgenExportSeaLevel263Test.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/WorldgenExportSeaLevel263Test.java new file mode 100644 index 000000000..ae3ea9bb6 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/WorldgenExportSeaLevel263Test.java @@ -0,0 +1,90 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.pack.datapack.export.ExportTestPacks; +import art.arcane.iris.pack.datapack.export.WorldgenDatapackExporter; +import art.arcane.iris.pack.datapack.export.WorldgenExportMode; +import art.arcane.iris.pack.datapack.export.WorldgenExportProgress; +import art.arcane.iris.pack.datapack.export.WorldgenExportRequest; +import art.arcane.iris.pack.datapack.export.WorldgenExportResult; +import art.arcane.iris.testsupport.CompatTestPlatform; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.ProtoChunk; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.IOException; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public final class WorldgenExportSeaLevel263Test { + @ClassRule + public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation(); + + private static final long SEED = 1337L; + private static final String DIMENSION_ID = WorldgenExportRequest.DEFAULT_NAMESPACE + ":" + ExportTestPacks.DIMENSION; + private static final int FLUID_HEIGHT = 40; + private static final int SEA_FLOOR = 28; + private static final int CHUNK_WIDTH = 16; + private static final String DIMENSION = """ + { + "regions": ["region"], + "dimensionHeight": {"min": -64, "max": 192}, + "fluidHeight": 40, + "landChance": 0, + "carvingEnabled": false + } + """; + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Rule + public ExportTestPacks.BoundPlatform platform = new ExportTestPacks.BoundPlatform(); + + @Test + public void waterFillsThroughTheIrisFluidHeight() throws IOException { + WorldgenExportResult result = export(); + + assertTrue(result.message() + " " + result.diagnostics(), result.isSuccess()); + try (WorldgenExportSampler sampler = WorldgenExportSampler.open(result.output(), WorldgenExportMode.OVERWORLD, DIMENSION_ID, SEED)) { + ProtoChunk chunk = sampler.terrainChunk(0, 0); + for (int x = 0; x < CHUNK_WIDTH; x++) { + for (int z = 0; z < CHUNK_WIDTH; z++) { + assertSolid(chunk.getBlockState(new BlockPos(x, SEA_FLOOR, z)), x, z); + for (int y = SEA_FLOOR + 1; y <= FLUID_HEIGHT; y++) { + BlockState state = chunk.getBlockState(new BlockPos(x, y, z)); + assertTrue(state + " at " + x + "," + y + "," + z, state.is(Blocks.WATER)); + } + BlockState above = chunk.getBlockState(new BlockPos(x, FLUID_HEIGHT + 1, z)); + assertTrue(above + " above the fluid height at " + x + "," + z, above.isAir()); + } + } + assertEquals(FLUID_HEIGHT + 1, sampler.seaLevel()); + } + } + + private static void assertSolid(BlockState state, int x, int z) { + assertTrue(state + " sea floor at " + x + "," + z, !state.isAir() && state.getFluidState().isEmpty()); + } + + private WorldgenExportResult export() throws IOException { + File pack = ExportTestPacks.withDimension(temporaryFolder.newFolder("pack"), DIMENSION); + CompatTestPlatform.write(pack, "generators/flat.json", "{\"composite\":[]}"); + CompatTestPlatform.write(pack, "biomes/ocean.json", """ + {"name":"Ocean","generators":[{"generator":"flat","min":-12,"max":-12}]} + """); + WorldgenExportRequest request = WorldgenExportRequest.builder() + .pack(pack.toPath()) + .dimension(ExportTestPacks.DIMENSION) + .exportRoot(temporaryFolder.newFolder("exports").toPath()) + .format("directory") + .build(); + return WorldgenDatapackExporter.create().export(request, WorldgenExportProgress.NONE); + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/WorldgenExportSubterrain263Test.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/WorldgenExportSubterrain263Test.java new file mode 100644 index 000000000..a7c2a172b --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/WorldgenExportSubterrain263Test.java @@ -0,0 +1,165 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.pack.datapack.export.ExportTestPacks; +import art.arcane.iris.pack.datapack.export.WorldgenDatapackExporter; +import art.arcane.iris.pack.datapack.export.WorldgenExportMode; +import art.arcane.iris.pack.datapack.export.WorldgenExportProgress; +import art.arcane.iris.pack.datapack.export.WorldgenExportRequest; +import art.arcane.iris.pack.datapack.export.WorldgenExportResult; +import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.core.registries.Registries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.NbtAccounter; +import net.minecraft.nbt.NbtIo; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.Rotation; +import net.minecraft.world.level.levelgen.GenerationStep; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.level.levelgen.structure.PoolElementStructurePiece; +import net.minecraft.world.level.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.StructureSet; +import net.minecraft.world.level.levelgen.structure.StructureStart; +import net.minecraft.world.level.levelgen.structure.placement.RandomSpreadStructurePlacement; +import net.minecraft.world.level.levelgen.structure.pools.ListPoolElement; +import net.minecraft.world.level.levelgen.structure.structures.JigsawStructure; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructurePlaceSettings; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplate; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Stream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public final class WorldgenExportSubterrain263Test { + @ClassRule + public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation(); + + private static final long SEED = 1337L; + private static final String DIMENSION_ID = WorldgenExportRequest.DEFAULT_NAMESPACE + ":" + ExportTestPacks.DIMENSION; + private static final String POOLS = WorldgenExportRequest.DEFAULT_NAMESPACE + ":subterrain/pools"; + private static final String TUBES = WorldgenExportRequest.DEFAULT_NAMESPACE + ":subterrain/tubes"; + private static final String ANCHOR = WorldgenExportRequest.DEFAULT_NAMESPACE + ":subterrain/anchor"; + private static final String TEMPLATES = "data/" + WorldgenExportRequest.DEFAULT_NAMESPACE + "/structure/subterrain/"; + private static final int CHUNK_X = 3; + private static final int CHUNK_Z = -2; + private static final int POOLS_CENTER_Y = -49; + private static final int POOLS_BELOW_CENTER = 10; + private static final int POOLS_EXTENT = 10; + private static final int MAX_REACH = 128; + private static final String DIMENSION = """ + {"regions": ["region"], "subterrainFeatures": [ + {"id": "pools", "family": "CENOTE", "worldYRange": {"min": -60, "max": -40}, "spacing": 64, "probability": 1.0, + "radius": 8, "height": 12, "fluid": "WATER", "fluidDepth": 2, "shapeWarp": 0.5, "pillarSpacing": 0, + "formationFraction": 0.0, "solid": "minecraft:stone"}, + {"id": "tubes", "family": "LAVA_TUBE", "allowedRegions": ["region"], "worldYRange": {"min": -60, "max": 40}, + "spacing": 320, "probability": 0.5, "length": 300, "radius": 6, "height": 8, "fluidDepth": 2, "chimneyHeight": 0, + "shapeWarp": 0.0, "pillarSpacing": 0, "formationFraction": 0.0, "solid": "minecraft:stone"}]} + """; + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Rule + public ExportTestPacks.BoundPlatform platform = new ExportTestPacks.BoundPlatform(); + + @Test + public void subterrainRoomsLoadAndPlaceThroughRealCodecs() throws IOException { + File pack = ExportTestPacks.withDimension(temporaryFolder.newFolder("pack"), DIMENSION); + WorldgenExportRequest request = WorldgenExportRequest.builder() + .pack(pack.toPath()) + .dimension(ExportTestPacks.DIMENSION) + .exportRoot(temporaryFolder.newFolder("exports").toPath()) + .format("directory") + .build(); + + WorldgenExportResult result = WorldgenDatapackExporter.create().export(request, WorldgenExportProgress.NONE); + + assertTrue(result.message() + " " + result.diagnostics(), result.isSuccess()); + try (WorldgenExportSampler sampler = WorldgenExportSampler.open(result.output(), WorldgenExportMode.OVERWORLD, DIMENSION_ID, SEED)) { + assertRegistered(sampler, POOLS, 4, 2); + assertRegistered(sampler, TUBES, 20, 10); + int cornerX = CHUNK_X << 4; + int cornerZ = CHUNK_Z << 4; + + BoundingBox pools = placedBox(sampler, POOLS); + assertEquals(cornerX - POOLS_EXTENT, pools.minX()); + assertEquals(cornerX + POOLS_EXTENT, pools.maxX()); + assertEquals(cornerZ - POOLS_EXTENT, pools.minZ()); + assertEquals(cornerZ + POOLS_EXTENT, pools.maxZ()); + assertEquals(POOLS_CENTER_Y - POOLS_BELOW_CENTER, pools.minY()); + + BoundingBox tubes = placedBox(sampler, TUBES); + assertEquals(2 * MAX_REACH + 1, Math.max(tubes.getXSpan(), tubes.getZSpan())); + assertTrue(tubes.toString(), tubes.minX() >= cornerX - MAX_REACH && tubes.maxX() <= cornerX + MAX_REACH); + assertTrue(tubes.toString(), tubes.minZ() >= cornerZ - MAX_REACH && tubes.maxZ() <= cornerZ + MAX_REACH); + assertTrue(tubes.toString(), tubes.minY() >= -60 && tubes.maxY() <= 40); + } + assertTemplatesResolve(result.output()); + } + + private static void assertRegistered(WorldgenExportSampler sampler, String id, int spacing, int separation) { + Identifier identifier = Identifier.parse(id); + Structure structure = sampler.registries().lookupOrThrow(Registries.STRUCTURE) + .getOrThrow(ResourceKey.create(Registries.STRUCTURE, identifier)).value(); + assertTrue(id, structure instanceof JigsawStructure); + assertEquals(id, GenerationStep.Decoration.UNDERGROUND_STRUCTURES, structure.step()); + StructureSet set = sampler.registries().lookupOrThrow(Registries.STRUCTURE_SET) + .getOrThrow(ResourceKey.create(Registries.STRUCTURE_SET, identifier)).value(); + RandomSpreadStructurePlacement placement = (RandomSpreadStructurePlacement) set.placement(); + assertEquals(id, spacing, placement.spacing()); + assertEquals(id, separation, placement.separation()); + assertTrue(id, sampler.registries().lookupOrThrow(Registries.TEMPLATE_POOL).containsKey(identifier)); + } + + private static BoundingBox placedBox(WorldgenExportSampler sampler, String id) throws IOException { + StructureStart start = sampler.structureStart(id, CHUNK_X, CHUNK_Z); + assertTrue(id, start.isValid()); + assertEquals(id, 1, start.getPieces().size()); + PoolElementStructurePiece piece = (PoolElementStructurePiece) start.getPieces().getFirst(); + assertTrue(id + " " + piece.getElement(), piece.getElement() instanceof ListPoolElement); + return start.getBoundingBox(); + } + + private static void assertTemplatesResolve(Path datapack) throws IOException { + List templates; + try (Stream paths = Files.walk(datapack.resolve(TEMPLATES))) { + templates = paths.filter(path -> path.toString().endsWith(".nbt")).sorted().toList(); + } + assertEquals(12, templates.size()); + boolean lava = false; + for (Path path : templates) { + CompoundTag tag; + try (InputStream input = Files.newInputStream(path)) { + tag = NbtIo.readCompressed(input, NbtAccounter.unlimitedHeap()); + } + StructureTemplate template = new StructureTemplate(); + template.load(BuiltInRegistries.BLOCK, tag); + StructurePlaceSettings settings = new StructurePlaceSettings(); + assertTrue(path.toString(), template.filterBlocks(BlockPos.ZERO, settings, Blocks.AIR).isEmpty()); + if (path.getFileName().toString().endsWith("_core.nbt")) { + assertFalse(path.toString(), template.filterBlocks(BlockPos.ZERO, settings, Blocks.CAVE_AIR).isEmpty()); + List jigsaws = template.getJigsaws(BlockPos.ZERO, Rotation.NONE); + assertEquals(path.toString(), 1, jigsaws.size()); + assertEquals(ANCHOR, jigsaws.getFirst().name().toString()); + lava |= !template.filterBlocks(BlockPos.ZERO, settings, Blocks.LAVA).isEmpty(); + } else { + assertFalse(path.toString(), template.filterBlocks(BlockPos.ZERO, settings, Blocks.STONE).isEmpty()); + } + } + assertTrue("no lava tube template holds lava", lava); + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/WorldgenStageBenchTest.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/WorldgenStageBenchTest.java new file mode 100644 index 000000000..84d4f18ef --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/WorldgenStageBenchTest.java @@ -0,0 +1,111 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ExportIrFixtures; +import art.arcane.iris.pack.datapack.export.WorldgenExportMode; +import art.arcane.iris.pack.datapack.export.emit.WorldgenJsonWriter; +import net.minecraft.world.level.levelgen.Heightmap; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.SortedMap; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public final class WorldgenStageBenchTest { + @ClassRule + public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation(); + + private static final long SEED = 1337L; + private static final int SIDE = 2; + private static final WorldgenStageBench.CallBudget BUDGET = new WorldgenStageBench.CallBudget(32, 8, 0L); + + private static HeadlessNativeRegistries loaded; + + @BeforeClass + public static void loadFixture() throws IOException { + HeadlessNativeBootstrap.initialize(); + SortedMap files = WorldgenJsonWriter.forTarget(DataVersion.V26_3) + .write(ExportIrFixtures.flatNoise(DataVersion.V26_3, WorldgenExportMode.PRESET)); + Path directory = Files.createTempDirectory("iris-stage-bench-"); + for (Map.Entry file : files.entrySet()) { + Path target = directory.resolve(file.getKey()); + Files.createDirectories(target.getParent()); + Files.write(target, file.getValue()); + } + try (PaperRegistryScope scope = PaperRegistryScope.open()) { + loaded = HeadlessNativeRegistries.load(directory); + } + } + + @AfterClass + public static void closeFixture() throws IOException { + if (loaded != null) { + loaded.close(); + } + } + + @Test + public void exportedFixtureRunsEveryStage() { + assertStages(bench("export", ExportIrFixtures.NAMESPACE + ":" + ExportIrFixtures.DIMENSION)); + } + + @Test + public void vanillaOverworldRunsEveryStage() { + assertStages(bench("vanilla", DatapackCostProbe.VANILLA_PRESET)); + } + + private static void assertStages(WorldgenStageBench bench) { + WorldgenStageBench.ChunkSamples samples = new WorldgenStageBench.ChunkSamples(); + bench.chunks(0, 0, SIDE, samples); + bench.chunks(SIDE + 4, 0, SIDE, samples); + WorldgenStageBench.ChunkTimings timings = samples.timings(); + assertEquals(2 * SIDE * SIDE, timings.chunks()); + assertEquals(2 * (SIDE + 2) * (SIDE + 2), timings.biomes().wall().count()); + assertEquals(2 * SIDE * SIDE, timings.noise().cpu().count()); + assertEquals(2 * SIDE * SIDE, timings.surface().wall().count()); + assertEquals(2 * SIDE * SIDE, timings.carverBiome().wall().count()); + assertEquals(0L, timings.biomeFallbacks()); + assertTrue(Double.isFinite(timings.totalMean())); + assertTrue(Double.isFinite(timings.totalCpuMean())); + assertCalls(samples(bench, Call.UNCACHED_BIOME)); + assertCalls(samples(bench, Call.BASE_HEIGHT)); + assertCalls(samples(bench, Call.BASE_COLUMN)); + } + + private static void assertCalls(WorldgenStageBench.CallTiming timing) { + assertEquals(2 * BUDGET.minimumCalls(), timing.calls()); + assertEquals(2 * BUDGET.minimumCalls(), timing.wall().count()); + assertTrue(Double.isFinite(timing.cpuMean())); + } + + private static WorldgenStageBench.CallTiming samples(WorldgenStageBench bench, Call call) { + WorldgenStageBench.CallSamples samples = new WorldgenStageBench.CallSamples(); + for (int round = 0; round < 2; round++) { + switch (call) { + case UNCACHED_BIOME -> bench.uncachedBiome(BUDGET, samples); + case BASE_HEIGHT -> bench.baseHeight(BUDGET, Heightmap.Types.WORLD_SURFACE_WG, samples); + case BASE_COLUMN -> bench.baseColumn(BUDGET, samples); + } + } + return samples.timing(); + } + + private static WorldgenStageBench bench(String label, String preset) { + return new WorldgenStageBench(new WorldgenStageBench.Options(label, + DatapackCostProbe.presetOverworld(loaded.registries(), preset), loaded.registries(), SEED)); + } + + private enum Call { + UNCACHED_BIOME, + BASE_HEIGHT, + BASE_COLUMN + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/fidelity/VanillaNoiseColumnSourceTest.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/fidelity/VanillaNoiseColumnSourceTest.java new file mode 100644 index 000000000..f7cbd10ab --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/fidelity/VanillaNoiseColumnSourceTest.java @@ -0,0 +1,99 @@ +package art.arcane.iris.probe.fidelity; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ExportIrFixtures; +import art.arcane.iris.pack.datapack.export.WorldgenExportMode; +import art.arcane.iris.pack.datapack.export.emit.WorldgenJsonWriter; +import art.arcane.iris.pack.datapack.export.ir.AquiferDef; +import art.arcane.iris.pack.datapack.export.ir.Axis; +import art.arcane.iris.pack.datapack.export.ir.BiomeDef; +import art.arcane.iris.pack.datapack.export.ir.ClimateLayout; +import art.arcane.iris.pack.datapack.export.ir.DimensionDef; +import art.arcane.iris.pack.datapack.export.ir.ExportIr; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.MaterialProgram; +import art.arcane.iris.pack.datapack.export.ir.RouterDef; +import art.arcane.iris.probe.PaperRegistryIsolation; +import art.arcane.iris.probe.WorldgenExportSampler; +import org.junit.ClassRule; +import org.junit.Test; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.SortedMap; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public final class VanillaNoiseColumnSourceTest { + @ClassRule + public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation(); + + private static final long SEED = 1337L; + private static final String DIMENSION_ID = ExportIrFixtures.NAMESPACE + ":" + ExportIrFixtures.DIMENSION; + private static final String BIOME_ID = ExportIrFixtures.NAMESPACE + ":" + ExportIrFixtures.BIOME; + private static final String LOAD_KEY = "Flat Plains"; + private static final int LAND_HEIGHT = 80; + private static final int SEA_FLOOR_HEIGHT = 40; + private static final int MAX_Y = ExportIrFixtures.MIN_Y + ExportIrFixtures.HEIGHT; + + @Test + public void landColumnsReportTheTopSolidBlockAndStayDry() throws IOException { + try (VanillaNoiseColumnSource source = new VanillaNoiseColumnSource(open(LAND_HEIGHT), Map.of(BIOME_ID, LOAD_KEY))) { + assertEquals(ExportIrFixtures.MIN_Y, source.minY()); + assertEquals(MAX_Y, source.maxY()); + for (int x : new int[]{-37, 0, 113}) { + FidelityColumn column = source.column(x, -x); + assertEquals(new FidelityColumn(LOAD_KEY, LAND_HEIGHT - 1, false), column); + } + } + } + + @Test + public void seaColumnsReportTheFloorUnderWater() throws IOException { + WorldgenExportSampler sampler = open(SEA_FLOOR_HEIGHT); + try (VanillaNoiseColumnSource source = new VanillaNoiseColumnSource(sampler, Map.of(BIOME_ID, LOAD_KEY))) { + FidelityStats stats = new FidelityStats(source.minY(), source.maxY()); + for (int x : new int[]{-37, 0, 113}) { + FidelityColumn column = source.column(x, x); + assertEquals(new FidelityColumn(LOAD_KEY, sampler.floorHeight(x, x) - 1, true), column); + assertTrue(column.terrainY() + " under sea level", column.terrainY() < ExportIrFixtures.SEA_LEVEL - 1); + assertEquals(ExportIrFixtures.SEA_LEVEL, sampler.baseHeight(x, x)); + stats.accept(column); + } + + assertEquals(0.0D, stats.landFraction(), 0.0D); + } + } + + @Test + public void exportedBiomeWithoutALoadKeyIsRejected() throws IOException { + try (VanillaNoiseColumnSource source = new VanillaNoiseColumnSource(open(LAND_HEIGHT), Map.of())) { + IllegalStateException failure = assertThrows(IllegalStateException.class, () -> source.column(0, 0)); + + assertTrue(failure.getMessage(), failure.getMessage().contains(BIOME_ID)); + } + } + + private static WorldgenExportSampler open(int height) throws IOException { + return WorldgenExportSampler.open(flatExport(height), WorldgenExportMode.PRESET, DIMENSION_ID, SEED); + } + + private static SortedMap flatExport(int height) { + Field zero = new Field.Const(0.0); + Field level = new Field.Const(height); + Field y = new Field.Coord(Axis.Y, Field.Tiling.CLAMP, -4064.0, 4062.0, -4064.0, 4062.0); + Field finalDensity = new Field.Grid(new Field.Binary(Field.BinaryOp.MUL, new Field.Const(0.125), + new Field.Binary(Field.BinaryOp.SUB, level, y)), 4, 8); + RouterDef router = new RouterDef(zero, zero, zero, zero, zero, zero, new Field.Grid(level, 16, 1), finalDensity, AquiferDef.dry(level)); + DimensionDef dimension = new DimensionDef(ExportIrFixtures.DIMENSION, ExportIrFixtures.MIN_Y, ExportIrFixtures.HEIGHT, + ExportIrFixtures.HEIGHT, ExportIrFixtures.FLUID_HEIGHT, "minecraft:stone", "minecraft:water", ExportIrFixtures.dimensionType()); + ClimateLayout climate = new ClimateLayout(List.of(new ClimateLayout.ClimateEntry(BIOME_ID, ClimateLayout.ClimateAxis.TEMPERATURE, -2.0, 2.0))); + ExportIr ir = new ExportIr(ExportIrFixtures.NAMESPACE, DataVersion.V26_3, WorldgenExportMode.PRESET, dimension, List.of(), router, climate, + List.of(new BiomeDef(ExportIrFixtures.BIOME, ExportIrFixtures.biome())), MaterialProgram.rock("minecraft:stone"), + List.of(), List.of(), List.of()); + return WorldgenJsonWriter.forTarget(DataVersion.V26_3).write(ir); + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/fidelity/VanillaNoiseFidelitySourceTest.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/fidelity/VanillaNoiseFidelitySourceTest.java new file mode 100644 index 000000000..2f44b684f --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/fidelity/VanillaNoiseFidelitySourceTest.java @@ -0,0 +1,189 @@ +package art.arcane.iris.probe.fidelity; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ExportIrFixtures; +import art.arcane.iris.pack.datapack.export.WorldgenExportMode; +import art.arcane.iris.pack.datapack.export.emit.WorldgenJsonWriter; +import art.arcane.iris.pack.datapack.export.ir.AquiferDef; +import art.arcane.iris.pack.datapack.export.ir.Axis; +import art.arcane.iris.pack.datapack.export.ir.BiomeDef; +import art.arcane.iris.pack.datapack.export.ir.ClimateLayout; +import art.arcane.iris.pack.datapack.export.ir.DimensionDef; +import art.arcane.iris.pack.datapack.export.ir.ExportIr; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.JsonResource; +import art.arcane.iris.pack.datapack.export.ir.MaterialProgram; +import art.arcane.iris.pack.datapack.export.ir.RouterDef; +import art.arcane.iris.probe.PaperRegistryIsolation; +import art.arcane.iris.probe.WorldgenExportSampler; +import com.google.gson.JsonParser; +import org.junit.ClassRule; +import org.junit.Test; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.SortedMap; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public final class VanillaNoiseFidelitySourceTest { + @ClassRule + public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation(); + + private static final long SEED = 1337L; + private static final String DIMENSION_ID = ExportIrFixtures.NAMESPACE + ":" + ExportIrFixtures.DIMENSION; + private static final String BIOME_ID = ExportIrFixtures.NAMESPACE + ":" + ExportIrFixtures.BIOME; + private static final String LOAD_KEY = "Flat Plains"; + private static final int LAND_HEIGHT = 80; + private static final int MAX_Y = ExportIrFixtures.MIN_Y + ExportIrFixtures.HEIGHT; + private static final String WALL_SHELL = ExportIrFixtures.NAMESPACE + ":cave/wall_shell"; + private static final int WALL_MIN_Y = 10; + private static final int WALL_MAX_Y = 20; + private static final int CARVED_HEIGHT = 120; + private static final int VOID_MIN_Y = 96; + private static final int CAVE_MIN_Y = 64; + private static final Field BLOCK_Y = new Field.Coord(Axis.Y, Field.Tiling.CLAMP, -4064.0, 4062.0, -4064.0, 4062.0); + + @Test + public void flatExportReportsTheConstantSurface() throws IOException { + WorldgenExportSampler sampler = open(LAND_HEIGHT); + try (VanillaNoiseFidelitySource source = new VanillaNoiseFidelitySource(sampler, Map.of(BIOME_ID, LOAD_KEY), WALL_SHELL)) { + assertEquals(ExportIrFixtures.MIN_Y, source.minY()); + assertEquals(MAX_Y, source.maxY()); + for (int x : new int[]{-37, 0, 113}) { + assertEquals(LAND_HEIGHT, sampler.baseHeight(x, -x)); + } + for (int[] chunkPosition : new int[][]{{0, 0}, {-3, 5}}) { + FidelityChunk chunk = source.sample(chunkPosition[0], chunkPosition[1]); + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + assertEquals(LAND_HEIGHT - 1, chunk.surfaceY(x, z)); + assertEquals(LOAD_KEY, chunk.surfaceBiome(x, z)); + assertTrue(chunk.open(x, LAND_HEIGHT, z)); + assertNull(chunk.block(x, LAND_HEIGHT - 1, z)); + } + } + for (int y = ExportIrFixtures.MIN_Y; y < LAND_HEIGHT; y++) { + assertFalse("y " + y, chunk.open(7, y, 9)); + assertFalse("y " + y, chunk.fluid(7, y, 9)); + } + } + + FidelityStats stats = FidelityScanner.scan(source, 0, 0, 1, 4, 2); + + assertEquals(144L, stats.heightColumns()); + assertEquals(LAND_HEIGHT - 1, stats.heightQuantile(0.1D), 0.0D); + assertEquals(LAND_HEIGHT - 1, stats.heightQuantile(0.9D), 0.0D); + assertEquals(1.0D, stats.landFraction(), 0.0D); + assertEquals(Map.of(LOAD_KEY, 144L), stats.biomeColumns()); + assertEquals(0L, stats.blockColumns()); + assertEquals(0L, stats.wallSolidVoxels()); + } + } + + @Test + public void wallShellDensityMarksWallVoxels() throws IOException { + WorldgenExportSampler sampler = WorldgenExportSampler.open(flatExport(LAND_HEIGHT, wallShell()), WorldgenExportMode.PRESET, DIMENSION_ID, SEED); + try (VanillaNoiseFidelitySource source = new VanillaNoiseFidelitySource(sampler, Map.of(BIOME_ID, LOAD_KEY), WALL_SHELL)) { + FidelityChunk chunk = source.sample(2, -1); + + assertTrue(chunk.wallsKnown()); + assertTrue(chunk.wall(7, WALL_MIN_Y, 9)); + assertTrue(chunk.wall(7, WALL_MAX_Y - 1, 9)); + assertFalse(chunk.wall(7, WALL_MIN_Y - 1, 9)); + assertFalse(chunk.wall(7, WALL_MAX_Y, 9)); + + FidelityStats stats = FidelityScanner.scan(source, 0, 0, 1, 4, 2); + + long interiorColumns = 9L * 9L; + assertEquals(interiorColumns * (LAND_HEIGHT - 1 - ExportIrFixtures.MIN_Y), stats.wallSolidVoxels()); + assertEquals(interiorColumns * (WALL_MAX_Y - WALL_MIN_Y), stats.wallVoxels()); + } + } + + @Test + public void openSpaceOutsideTheTerrainOperandIsATerrainVoid() throws IOException { + WorldgenExportSampler sampler = WorldgenExportSampler.open(carvedExport(), WorldgenExportMode.PRESET, DIMENSION_ID, SEED); + try (VanillaNoiseFidelitySource source = new VanillaNoiseFidelitySource(sampler, Map.of(BIOME_ID, LOAD_KEY), WALL_SHELL)) { + FidelityChunk chunk = source.sample(1, -2); + + assertEquals(CARVED_HEIGHT - 1, chunk.surfaceY(7, 9)); + assertTrue(chunk.open(7, VOID_MIN_Y, 9)); + assertFalse(chunk.terrainMass(7, VOID_MIN_Y, 9)); + assertTrue(chunk.open(7, CAVE_MIN_Y + 3, 9)); + assertTrue(chunk.terrainMass(7, CAVE_MIN_Y + 3, 9)); + assertFalse(chunk.open(7, VOID_MIN_Y - 4, 9)); + assertTrue(chunk.terrainMass(7, VOID_MIN_Y - 4, 9)); + + FidelityStats stats = FidelityScanner.scan(source, 0, 0, 1, 4, 2); + + int voidBand = (VOID_MIN_Y - ExportIrFixtures.MIN_Y) / 16; + int caveBand = (CAVE_MIN_Y - ExportIrFixtures.MIN_Y) / 16; + assertEquals(1.0D, stats.voidShare(voidBand), 0.0D); + assertEquals(0.0D, stats.caveShare(voidBand), 0.0D); + assertEquals(1.0D, stats.caveShare(caveBand), 0.0D); + assertEquals(0.0D, stats.voidShare(caveBand), 0.0D); + } + } + + @Test + public void exportWithoutAWallShellReportsNoWalls() throws IOException { + try (VanillaNoiseFidelitySource source = new VanillaNoiseFidelitySource(open(LAND_HEIGHT), Map.of(BIOME_ID, LOAD_KEY), WALL_SHELL)) { + assertFalse(source.sample(0, 0).wallsKnown()); + } + } + + @Test + public void exportedBiomeWithoutALoadKeyIsRejected() throws IOException { + try (VanillaNoiseFidelitySource source = new VanillaNoiseFidelitySource(open(LAND_HEIGHT), Map.of(), WALL_SHELL)) { + FidelityChunk chunk = source.sample(0, 0); + + IllegalStateException failure = assertThrows(IllegalStateException.class, () -> chunk.surfaceBiome(0, 0)); + + assertTrue(failure.getMessage(), failure.getMessage().contains(BIOME_ID)); + } + } + + private static WorldgenExportSampler open(int height) throws IOException { + return WorldgenExportSampler.open(flatExport(height), WorldgenExportMode.PRESET, DIMENSION_ID, SEED); + } + + private static JsonResource wallShell() { + return new JsonResource("data/" + ExportIrFixtures.NAMESPACE + "/worldgen/density_function/cave/wall_shell.json", JsonParser.parseString( + "{\"type\":\"minecraft:range_choice\",\"input\":{\"type\":\"minecraft:gradient\",\"axis\":\"y\",\"tiling\":\"clamp_to_edge\"," + + "\"from_coordinate\":-4064,\"to_coordinate\":4062,\"from_value\":-4064.0,\"to_value\":4062.0}," + + "\"min_inclusive\":" + WALL_MIN_Y + ".0,\"max_exclusive\":" + WALL_MAX_Y + ".0,\"when_in_range\":1.0,\"when_out_of_range\":0.0}")); + } + + private static SortedMap flatExport(int height, JsonResource... resources) { + Field level = new Field.Const(height); + Field finalDensity = new Field.Grid(new Field.Binary(Field.BinaryOp.MUL, new Field.Const(0.125), + new Field.Binary(Field.BinaryOp.SUB, level, BLOCK_Y)), 4, 8); + return export(height, finalDensity, resources); + } + + private static SortedMap carvedExport() { + Field ramp = new Field.Binary(Field.BinaryOp.MUL, new Field.Const(0.125), new Field.Binary(Field.BinaryOp.SUB, new Field.Const(CARVED_HEIGHT), BLOCK_Y)); + Field terrain = new Field.RangeIf(BLOCK_Y, VOID_MIN_Y, VOID_MIN_Y + 16, new Field.Const(-1.0), ramp); + Field caves = new Field.RangeIf(BLOCK_Y, CAVE_MIN_Y, CAVE_MIN_Y + 16, new Field.Const(-1.0), new Field.Const(1.0)); + return export(CARVED_HEIGHT, new Field.Binary(Field.BinaryOp.MIN, terrain, caves)); + } + + private static SortedMap export(int height, Field finalDensity, JsonResource... resources) { + Field zero = new Field.Const(0.0); + Field level = new Field.Const(height); + RouterDef router = new RouterDef(zero, zero, zero, zero, zero, zero, new Field.Grid(level, 16, 1), finalDensity, AquiferDef.dry(level)); + DimensionDef dimension = new DimensionDef(ExportIrFixtures.DIMENSION, ExportIrFixtures.MIN_Y, ExportIrFixtures.HEIGHT, + ExportIrFixtures.HEIGHT, ExportIrFixtures.FLUID_HEIGHT, "minecraft:stone", "minecraft:water", ExportIrFixtures.dimensionType()); + ClimateLayout climate = new ClimateLayout(List.of(new ClimateLayout.ClimateEntry(BIOME_ID, ClimateLayout.ClimateAxis.TEMPERATURE, -2.0, 2.0))); + ExportIr ir = new ExportIr(ExportIrFixtures.NAMESPACE, DataVersion.V26_3, WorldgenExportMode.PRESET, dimension, List.of(), router, climate, + List.of(new BiomeDef(ExportIrFixtures.BIOME, ExportIrFixtures.biome())), MaterialProgram.rock("minecraft:stone"), + List.of(), List.of(resources), List.of()); + return WorldgenJsonWriter.forTarget(DataVersion.V26_3).write(ir); + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandDatapack.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandDatapack.java index bb3d3d790..011903b28 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandDatapack.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandDatapack.java @@ -18,21 +18,41 @@ package art.arcane.iris.command; -import art.arcane.iris.pack.datapack.DatapackIngestService; -import art.arcane.volmlib.util.collection.KList; +import art.arcane.iris.Iris; +import art.arcane.iris.command.handlers.DimensionHandler; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.localization.BukkitCommandMessages; import art.arcane.iris.localization.C; +import art.arcane.iris.localization.DirectorCommandMessages; +import art.arcane.iris.localization.IrisLanguage; +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.DatapackIngestService; +import art.arcane.iris.pack.datapack.export.DatapackExportDestinations; +import art.arcane.iris.pack.datapack.export.WorldgenExportMode; +import art.arcane.iris.pack.datapack.export.WorldgenExportProgress; +import art.arcane.iris.pack.datapack.export.WorldgenExportRequest; +import art.arcane.iris.pack.datapack.export.WorldgenExportResult; +import art.arcane.iris.pack.datapack.export.WorldgenExportService; +import art.arcane.iris.pack.datapack.export.WorldgenExportServices; +import art.arcane.iris.pack.datapack.export.WorldgenExportTargets; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; import art.arcane.iris.platform.bukkit.plugin.VolmitSender; +import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.world.task.J; +import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.director.annotations.Director; import art.arcane.volmlib.util.director.annotations.Param; +import art.arcane.volmlib.util.localization.LanguageAudience; +import art.arcane.volmlib.util.localization.MessageArgument; +import art.arcane.volmlib.util.localization.TextKey; import java.util.List; +import java.util.UUID; -import art.arcane.iris.localization.IrisLanguage; -import art.arcane.iris.localization.BukkitCommandMessages; -import art.arcane.volmlib.util.localization.MessageArgument; @Director(name = "datapack", aliases = {"datapacks", "dp"}, description = "Download & manage external datapack imports", descriptionKey = "iris.director.commanddatapack.director.download_manage_external_datapack_imports_modrinth") public class CommandDatapack implements DirectorExecutor { + private static final int RESOURCE_REPORTS = 4; + @Director(description = "Download/update every datapack listed in a pack dimension's 'datapackImports' and install it into the world so its structures register like vanilla", descriptionKey = "iris.director.commanddatapack.director.download_update_every_datapack_listed_pack_dimension_s_datapackimports_install_it_into", aliases = {"pull"}) public void ingest( @Param(description = "Restart the server when new datapacks are installed (required for new structures to register and generate)", descriptionKey = "iris.director.commanddatapack.param.restart_server_when_new_datapacks_are_installed_required_new_structures_register_generate", defaultValue = "false") @@ -71,4 +91,128 @@ public class CommandDatapack implements DirectorExecutor { ) { DatapackIngestService.remove(sender(), id); } + + @Director(description = "Export a dimension as a vanilla worldgen datapack", descriptionKey = "iris.director.commanddatapack.director.export_dimension_vanilla_worldgen_datapack") + public void export( + @Param(name = "dimension", description = "The dimension to export", descriptionKey = "iris.director.commanddatapack.param.export_dimension", customHandler = DimensionHandler.class) + IrisDimension dimension, + @Param(name = "version", description = "Target Minecraft version, 26.3 or later (blank uses the latest)", descriptionKey = "iris.director.commanddatapack.param.export_version", defaultValue = "") + String version, + @Param(name = "mode", description = "Output mode: preset, dimension, or overworld", descriptionKey = "iris.director.commanddatapack.param.export_mode", defaultValue = WorldgenExportRequest.DEFAULT_MODE) + String mode, + @Param(name = "namespace", description = "Namespace for the exported resources", descriptionKey = "iris.director.commanddatapack.param.export_namespace", defaultValue = WorldgenExportRequest.DEFAULT_NAMESPACE) + String namespace, + @Param(name = "output", description = "Output name in the datapack exports folder (blank uses the dimension and version)", descriptionKey = "iris.director.commanddatapack.param.export_output", defaultValue = "") + String output, + @Param(name = "format", description = "zip or directory", descriptionKey = "iris.director.commanddatapack.param.export_format", defaultValue = WorldgenExportRequest.DEFAULT_FORMAT) + String format, + @Param(name = "objects", description = "Include objects as structure templates", descriptionKey = "iris.director.commanddatapack.param.export_objects", defaultValue = "true") + boolean objects, + @Param(name = "replace", description = "Replace an existing export with the same name", descriptionKey = "iris.director.commanddatapack.param.export_replace", defaultValue = "false") + boolean replace + ) { + VolmitSender sender = sender(); + WorldgenExportRequest request = WorldgenExportRequest.builder() + .pack(dimension.getLoader().getDataFolder().toPath()) + .dimension(dimension.getLoadKey()) + .version(version) + .mode(mode) + .namespace(namespace) + .output(output) + .format(format) + .objects(objects) + .replace(replace) + .exportRoot(IrisPlatforms.get().dataFolder("exports", "datapacks").toPath()) + .build(); + DataVersion target; + try { + target = validateExport(request); + } catch (IllegalArgumentException invalid) { + sender.sendMessage(C.RED + IrisLanguage.text(DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_INVALID, MessageArgument.untrusted("reason", invalid.getMessage()))); + return; + } + WorldgenExportService service = WorldgenExportServices.get(); + sender.sendMessage(C.GRAY + IrisLanguage.text(DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_STARTED, MessageArgument.untrusted("dimension", request.dimension()), MessageArgument.untrusted("version", target.getVersion()))); + UUID audience = LanguageAudience.current(); + ExportFeedback feedback = new ExportFeedback(sender); + J.a(() -> LanguageAudience.run(audience, () -> runExport(service, request, feedback))); + } + + static DataVersion validateExport(WorldgenExportRequest request) { + DataVersion target = WorldgenExportTargets.resolve(request.version()); + WorldgenExportMode.parse(request.mode()); + DatapackExportDestinations.parseFormat(request.format()); + return target; + } + + private static void runExport(WorldgenExportService service, WorldgenExportRequest request, ExportFeedback feedback) { + WorldgenExportResult result; + try { + result = service.export(request, feedback); + } catch (Throwable failure) { + Iris.reportError("Datapack export failed for '" + request.dimension() + "'.", failure); + feedback.send(C.RED, IrisLanguage.text(DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_FAILED, MessageArgument.untrusted("reason", IrisLanguage.errorDetail(failure)))); + return; + } + reportExport(feedback, result); + } + + private static void reportExport(ExportFeedback feedback, WorldgenExportResult result) { + for (ExportDiagnostic diagnostic : result.diagnostics()) { + feedback.send(diagnosticColor(diagnostic.severity()), IrisLanguage.text(DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_DIAGNOSTIC, MessageArgument.untrusted("key", diagnosticLabel(diagnostic)), MessageArgument.untrusted("message", diagnostic.message()))); + } + if (result.isSuccess()) { + feedback.send(C.GREEN, IrisLanguage.text(DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_EXPORTED, MessageArgument.untrusted("path", result.output()))); + return; + } + TextKey outcome = result.status() == WorldgenExportResult.Status.REJECTED + ? DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_REJECTED + : DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_FAILED; + feedback.send(C.RED, IrisLanguage.text(outcome, MessageArgument.untrusted("reason", result.message()))); + } + + private static C diagnosticColor(ExportDiagnostic.Severity severity) { + return switch (severity) { + case ERROR -> C.RED; + case WARNING -> C.YELLOW; + case INFO -> C.GRAY; + }; + } + + private static String diagnosticLabel(ExportDiagnostic diagnostic) { + return diagnostic.resource().isEmpty() + ? diagnostic.limit().name() + : diagnostic.limit().name() + " " + diagnostic.resource(); + } + + private static final class ExportFeedback implements WorldgenExportProgress { + private final VolmitSender sender; + + private ExportFeedback(VolmitSender sender) { + this.sender = sender; + } + + @Override + public void stage(String stage) { + send(C.GRAY, IrisLanguage.text(DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_STAGE, MessageArgument.untrusted("stage", stage))); + } + + @Override + public void resources(int done, int total) { + int stride = Math.max(1, total / RESOURCE_REPORTS); + if (done != total && done % stride != 0) { + return; + } + send(C.GRAY, IrisLanguage.text(DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_RESOURCES, MessageArgument.untrusted("done", done), MessageArgument.untrusted("total", total))); + } + + private void send(C color, String message) { + String line = color + message; + if (sender.isPlayer()) { + J.runEntity(sender.player(), () -> sender.sendMessage(line)); + return; + } + sender.sendMessage(line); + } + } } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandJigsaw.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandJigsaw.java index d4a849590..9ac63afd4 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandJigsaw.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandJigsaw.java @@ -21,7 +21,8 @@ import art.arcane.iris.structure.StructureImporter; import art.arcane.iris.structure.VillageImporter; import art.arcane.iris.structure.authoring.StructureKey; import art.arcane.iris.structure.authoring.StructureWriteResult; -import art.arcane.iris.structure.export.VanillaJigsawExportFormat; +import art.arcane.iris.pack.datapack.export.DatapackExportDestinations; +import art.arcane.iris.pack.datapack.export.DatapackExportFormat; import art.arcane.iris.structure.export.VanillaJigsawExportRequest; import art.arcane.iris.structure.export.VanillaJigsawExportSettings; import art.arcane.iris.structure.export.VanillaJigsawExportSource; @@ -420,9 +421,9 @@ public class CommandJigsaw implements DirectorExecutor { sendError("Wait for the pending autosave or discard the edits before exporting the on-disk graph."); return; } - VanillaJigsawExportFormat exportFormat; + DatapackExportFormat exportFormat; try { - exportFormat = CommandJigsawExports.parseFormat(format); + exportFormat = DatapackExportDestinations.parseFormat(format); } catch (IllegalArgumentException exception) { sendError(exception.getMessage()); return; @@ -431,7 +432,7 @@ public class CommandJigsaw implements DirectorExecutor { .toPath().toAbsolutePath().normalize(); Path destination; try { - destination = CommandJigsawExports.resolveExportDestination(exportRoot, output, exportFormat); + destination = DatapackExportDestinations.resolve(exportRoot, output, exportFormat); } catch (IllegalArgumentException exception) { sendError(exception.getMessage()); return; diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandJigsawExports.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandJigsawExports.java index 3e70aa1b3..ff2490056 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandJigsawExports.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandJigsawExports.java @@ -4,7 +4,7 @@ import art.arcane.iris.Iris; import art.arcane.iris.studio.jigsaw.JigsawStudioService; import art.arcane.iris.structure.export.VanillaJigsawDatapackExporter; import art.arcane.iris.structure.export.VanillaJigsawExportDiagnostic; -import art.arcane.iris.structure.export.VanillaJigsawExportFormat; +import art.arcane.iris.pack.datapack.export.DatapackExportDestinations; import art.arcane.iris.structure.export.VanillaJigsawExportRequest; import art.arcane.iris.structure.export.VanillaJigsawExportResult; import art.arcane.iris.localization.C; @@ -13,18 +13,14 @@ import art.arcane.iris.world.task.J; import org.bukkit.entity.Player; import java.nio.file.Path; -import java.util.Locale; import java.util.Objects; import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; -import java.util.regex.Pattern; final class CommandJigsawExports { - private static final Pattern EXPORT_OUTPUT_NAME = Pattern.compile("[a-zA-Z0-9_-][a-zA-Z0-9._-]{0,127}"); private static final Set EXPORTING_PLAYERS = ConcurrentHashMap.newKeySet(); - private static final Set EXPORT_DESTINATIONS = ConcurrentHashMap.newKeySet(); private CommandJigsawExports() { } @@ -45,11 +41,11 @@ final class CommandJigsawExports { static boolean beginExport(UUID playerId, Path destination) { Objects.requireNonNull(playerId); - Path normalizedDestination = Objects.requireNonNull(destination).toAbsolutePath().normalize(); + Objects.requireNonNull(destination); if (!EXPORTING_PLAYERS.add(playerId)) { return false; } - if (EXPORT_DESTINATIONS.add(normalizedDestination)) { + if (DatapackExportDestinations.claim(destination)) { return true; } EXPORTING_PLAYERS.remove(playerId); @@ -58,7 +54,7 @@ final class CommandJigsawExports { static void finishExport(UUID playerId, Path destination) { EXPORTING_PLAYERS.remove(Objects.requireNonNull(playerId)); - EXPORT_DESTINATIONS.remove(Objects.requireNonNull(destination).toAbsolutePath().normalize()); + DatapackExportDestinations.release(destination); } static void runExport(ExportOperation operation) { @@ -102,35 +98,6 @@ final class CommandJigsawExports { + result.status() + ": " + result.output()); } - static VanillaJigsawExportFormat parseFormat(String value) { - return switch (value.trim().toLowerCase(Locale.ROOT)) { - case "zip" -> VanillaJigsawExportFormat.ZIP; - case "directory", "dir", "folder" -> VanillaJigsawExportFormat.DIRECTORY; - default -> throw new IllegalArgumentException("Export format must be directory or zip."); - }; - } - - static Path resolveExportDestination( - Path exportRoot, - String output, - VanillaJigsawExportFormat format - ) { - Path root = Objects.requireNonNull(exportRoot, "Jigsaw export root").toAbsolutePath().normalize(); - String requested = Objects.requireNonNull(output, "Jigsaw export output name"); - String name = requested.trim(); - if (!name.equals(requested) || !EXPORT_OUTPUT_NAME.matcher(name).matches()) { - throw new IllegalArgumentException( - "Export output must be one file name using letters, numbers, '.', '_', or '-'."); - } - String fileName = format == VanillaJigsawExportFormat.ZIP && !name.endsWith(".zip") - ? name + ".zip" : name; - Path destination = root.resolve(fileName).normalize(); - if (destination.equals(root) || !root.equals(destination.getParent())) { - throw new IllegalArgumentException("Export output must be one artifact inside the Studio exports folder."); - } - return destination; - } - record ExportOperation( Player player, VolmitSender sender, diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/command/CommandDatapackExportContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/command/CommandDatapackExportContractTest.java new file mode 100644 index 000000000..9a5dfb42b --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/command/CommandDatapackExportContractTest.java @@ -0,0 +1,104 @@ +package art.arcane.iris.command; + +import art.arcane.iris.command.handlers.DimensionHandler; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.localization.IrisLanguage; +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.WorldgenExportRequest; +import art.arcane.volmlib.util.director.annotations.Director; +import art.arcane.volmlib.util.director.annotations.Param; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; + +public class CommandDatapackExportContractTest { + private static final List NAMES = List.of( + "dimension", "version", "mode", "namespace", "output", "format", "objects", "replace"); + private static final List DEFAULTS = List.of( + Param.NO_DEFAULT, "", "overworld", "iris_export", "", "zip", "true", "false"); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void exportParametersAreNamedInOrder() throws Exception { + List names = new ArrayList<>(); + for (Parameter parameter : exportMethod().getParameters()) { + names.add(parameter.getAnnotation(Param.class).name()); + } + + assertEquals(NAMES, names); + } + + @Test + public void dimensionIsPositionalAndEveryOtherParameterIsKeyedWithItsDefault() throws Exception { + Parameter[] parameters = exportMethod().getParameters(); + List defaults = new ArrayList<>(); + for (Parameter parameter : parameters) { + defaults.add(parameter.getAnnotation(Param.class).defaultValue()); + } + + assertEquals(DEFAULTS, defaults); + assertEquals(DimensionHandler.class, parameters[0].getAnnotation(Param.class).customHandler()); + } + + @Test + public void exportCommandAndParametersUseRegisteredDescriptions() throws Exception { + Method export = exportMethod(); + Director director = export.getAnnotation(Director.class); + + assertNotNull(director); + assertEquals(0L, Arrays.stream(director.aliases()).filter((String alias) -> !alias.isBlank()).count()); + assertNotNull(director.descriptionKey(), IrisLanguage.catalog().key(director.descriptionKey())); + for (Parameter parameter : export.getParameters()) { + String key = parameter.getAnnotation(Param.class).descriptionKey(); + assertNotNull(key, IrisLanguage.catalog().key(key)); + } + } + + @Test + public void validRequestResolvesItsTarget() { + assertSame(DataVersion.V26_3, CommandDatapack.validateExport(request().build())); + assertSame(DataVersion.V26_3, CommandDatapack.validateExport(request().version("26.3").build())); + } + + @Test + public void unsupportedArgumentsAreRejectedWithSharedMessages() { + assertEquals("Unsupported datapack export version '1.21.1'. Supported: 26.3", + reason(request().version("1.21.1").build())); + assertEquals("Unsupported datapack export version '26.2'. Supported: 26.3", + reason(request().version("26.2").build())); + assertEquals("Unknown export mode 'nether'. Expected: preset, dimension, overworld", + reason(request().mode("nether").build())); + assertEquals("Export format must be directory or zip.", reason(request().format("rar").build())); + } + + private WorldgenExportRequest.Builder request() { + Path root = temporaryFolder.getRoot().toPath(); + return WorldgenExportRequest.builder() + .pack(root.resolve("packs").resolve("overworld")) + .dimension("overworld") + .exportRoot(root.resolve("exports")); + } + + private static String reason(WorldgenExportRequest request) { + return assertThrows(IllegalArgumentException.class, () -> CommandDatapack.validateExport(request)).getMessage(); + } + + private static Method exportMethod() throws NoSuchMethodException { + return CommandDatapack.class.getDeclaredMethod("export", IrisDimension.class, String.class, String.class, + String.class, String.class, String.class, boolean.class, boolean.class); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/command/CommandJigsawContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/command/CommandJigsawContractTest.java index 60ce6e21c..784b1072b 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/command/CommandJigsawContractTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/command/CommandJigsawContractTest.java @@ -17,7 +17,6 @@ import art.arcane.iris.structure.authoring.StructureOwnershipManifest; import art.arcane.iris.structure.authoring.StructureSource; import art.arcane.iris.structure.authoring.StructureTransactionWriter; import art.arcane.iris.structure.conversion.IrisStructureAdoptionInputKind; -import art.arcane.iris.structure.export.VanillaJigsawExportFormat; import art.arcane.iris.world.IrisCreator; import art.arcane.iris.generation.terrain.IrisDimension; import art.arcane.iris.pack.value.IrisDirection; @@ -413,24 +412,6 @@ public class CommandJigsawContractTest { } } - @Test - public void exportOutputIsOneSafeChildArtifact() { - Path root = Path.of("build", "jigsaw-exports").toAbsolutePath().normalize(); - - assertEquals(root.resolve("village.zip"), CommandJigsawExports.resolveExportDestination( - root, "village", VanillaJigsawExportFormat.ZIP)); - assertEquals(root.resolve("village"), CommandJigsawExports.resolveExportDestination( - root, "village", VanillaJigsawExportFormat.DIRECTORY)); - assertThrows(IllegalArgumentException.class, () -> CommandJigsawExports.resolveExportDestination( - root, "", VanillaJigsawExportFormat.DIRECTORY)); - assertThrows(IllegalArgumentException.class, () -> CommandJigsawExports.resolveExportDestination( - root, ".", VanillaJigsawExportFormat.DIRECTORY)); - assertThrows(IllegalArgumentException.class, () -> CommandJigsawExports.resolveExportDestination( - root, "../all-exports", VanillaJigsawExportFormat.DIRECTORY)); - assertThrows(IllegalArgumentException.class, () -> CommandJigsawExports.resolveExportDestination( - root, "nested/export", VanillaJigsawExportFormat.DIRECTORY)); - } - @Test public void exportLeaseRejectsDuplicatePlayerAndDestination() { UUID firstPlayer = UUID.randomUUID(); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java index b0359432e..c50ee65bf 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java @@ -25,6 +25,7 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeEntitySpawns; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedLoader; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeBiomeRegistry; import art.arcane.iris.BuildConstants; +import art.arcane.iris.platform.ClasspathVanillaDataSource; import art.arcane.iris.spi.IrisLogging; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeCommandExecutor; import art.arcane.iris.spi.PlatformGenerationRegistry; @@ -35,6 +36,7 @@ import art.arcane.volmlib.nativelib.entity.NativeEntityType; import art.arcane.iris.spi.PlatformRegistries; import art.arcane.iris.spi.PlatformScheduler; import art.arcane.iris.spi.PlatformStructureHooks; +import art.arcane.iris.spi.VanillaDataSource; import art.arcane.volmlib.nativelib.terrain.NativeWorld; import java.io.File; @@ -56,6 +58,7 @@ public final class ModdedPlatform implements IrisPlatform { private final ModdedScheduler scheduler; private final ModdedStructureHooks structureHooks; private final ModdedBiomeWriter biomeWriter; + private final ClasspathVanillaDataSource vanillaData; public ModdedPlatform(NativeModdedLoader loader) { this.loader = loader; @@ -65,6 +68,7 @@ public final class ModdedPlatform implements IrisPlatform { this.scheduler = new ModdedScheduler(); this.structureHooks = new ModdedStructureHooks(ModdedEngineBootstrap::currentServer); this.biomeWriter = new ModdedBiomeWriter(new NativeBiomeRegistry(ModdedEngineBootstrap::currentServer, "minecraft:plains")); + this.vanillaData = new ClasspathVanillaDataSource(() -> overworldClass(ModdedEngineBootstrap.currentServer())); } public static void errorSink(Consumer sink) { @@ -110,6 +114,11 @@ public final class ModdedPlatform implements IrisPlatform { return biomeWriter; } + @Override + public VanillaDataSource vanillaData() { + return vanillaData; + } + @Override public File dataFolder() { File folder = loader.configDir().resolve("iris").toFile(); @@ -365,4 +374,15 @@ public final class ModdedPlatform implements IrisPlatform { "modded-generation-registry-v1"); } + static Class overworldClass(NativeModdedServer server) { + if (server == null) { + throw new IllegalStateException("Vanilla data needs a running Minecraft server."); + } + NativeWorld overworld = server.overworld(); + if (overworld == null) { + throw new IllegalStateException("Vanilla data needs the Minecraft server overworld to be loaded."); + } + return overworld.nativeHandle().getClass(); + } + } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandHelp.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandHelp.java index 99152a571..7b639f441 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandHelp.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandHelp.java @@ -189,7 +189,8 @@ final class ModdedCommandHelp { Entry.command("install", "", ModdedHelpMessages.COMMAND_INSTALL_INSTALL_DIMENSION_TYPE_OVERRIDES_FOR_LOADED_IRIS_DIMENSIONS), Entry.command("list", "", ModdedHelpMessages.COMMAND_LIST_LIST_CONFIGURED_AND_INSTALLED_DATAPACKS, "ls"), Entry.command("ingest", "", ModdedHelpMessages.COMMAND_INGEST_EXPLAIN_BUKKIT_DATAPACK_INGEST_WORKFLOW, "pull"), - Entry.command("remove", "", ModdedHelpMessages.COMMAND_REMOVE_EXPLAIN_DATAPACK_REMOVAL_WORKFLOW, "rm") + Entry.command("remove", "", ModdedHelpMessages.COMMAND_REMOVE_EXPLAIN_DATAPACK_REMOVAL_WORKFLOW, "rm"), + Entry.command("export", " [version=] [mode=overworld] [namespace=iris_export] [output=] [format=zip] [objects=true] [replace=false]", ModdedHelpMessages.COMMAND_EXPORT_EXPORT_A_DIMENSION_AS_A_VANILLA_WORLDGEN_DATAPACK) )); SECTIONS.put("datapacks", SECTIONS.get("datapack")); SECTIONS.put("dp", SECTIONS.get("datapack")); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDatapackCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDatapackCommands.java index b20ce6749..53d52b4f4 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDatapackCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDatapackCommands.java @@ -18,7 +18,14 @@ package art.arcane.iris.modded.command; +import art.arcane.iris.modded.ModdedEngineBootstrap; import art.arcane.iris.modded.ModdedIrisLog; +import art.arcane.iris.pack.datapack.export.WorldgenExportProgress; +import art.arcane.iris.pack.datapack.export.WorldgenExportRequest; +import art.arcane.iris.pack.datapack.export.WorldgenExportResult; +import art.arcane.iris.pack.datapack.export.WorldgenExportService; +import art.arcane.iris.pack.datapack.export.WorldgenExportServices; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.pack.datapack.DataVersion; import art.arcane.iris.pack.PackDirectoryResolver; @@ -33,6 +40,7 @@ import com.mojang.brigadier.context.CommandContext; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeCommandSource; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeCommandRegistration; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeCommandText; import art.arcane.volmlib.nativelib.terrain.NativeWorld; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldDimensions; @@ -40,6 +48,7 @@ import java.io.File; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; @@ -47,15 +56,20 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Set; +import java.util.UUID; import java.util.function.Predicate; +import art.arcane.iris.localization.DirectorCommandMessages; import art.arcane.iris.localization.IrisLanguage; import art.arcane.iris.modded.localization.ModdedCommandMessages; import art.arcane.iris.localization.RuntimeUiMessages; +import art.arcane.volmlib.util.localization.LanguageAudience; import art.arcane.volmlib.util.localization.MessageArgument; +import art.arcane.volmlib.util.localization.TextKey; public final class ModdedDatapackCommands { private static final Predicate GATE = NativeCommandRegistration.GAMEMASTERS; private static final String WORLD_PACK_NAME = "iris"; + private static final int RESOURCE_REPORTS = 4; private ModdedDatapackCommands() { } @@ -79,6 +93,11 @@ public final class ModdedDatapackCommands { root.then(message("ingest", "Managed Modrinth datapack ingest is Bukkit-only. On modded servers install the datapack folder or zip in world/datapacks, enable it, restart, then use /iris datapack list to confirm it is enabled. Registered structures generate natively in Iris dimensions.")); root.then(message("pull", "Managed Modrinth datapack ingest is Bukkit-only. On modded servers install the datapack folder or zip in world/datapacks, enable it, restart, then use /iris datapack list to confirm it is enabled. Registered structures generate natively in Iris dimensions.")); + root.then(NativeCommandRegistration.literal("export") + .executes(ModdedCommandTree.localized((CommandContext context) -> export(context.getSource(), ""))) + .then(NativeCommandRegistration.argument("args", StringArgumentType.greedyString()) + .executes(ModdedCommandTree.localized((CommandContext context) -> export(context.getSource(), StringArgumentType.getString(context, "args")))))); + root.then(message("remove", "Managed datapack removal is Bukkit-only. On modded servers disable the pack, delete its folder or zip from world/datapacks, and restart.")); root.then(message("rm", "Managed datapack removal is Bukkit-only. On modded servers disable the pack, delete its folder or zip from world/datapacks, and restart.")); @@ -291,4 +310,107 @@ public final class ModdedDatapackCommands { } return null; } + + private static int export(NativeCommandSource source, String raw) { + ModdedDatapackExportArguments arguments; + try { + arguments = ModdedDatapackExportArguments.parse(raw); + } catch (IllegalArgumentException invalid) { + IrisModdedCommands.fail(source, IrisLanguage.plain(DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_INVALID, MessageArgument.untrusted("reason", invalid.getMessage()))); + return 0; + } + File packsRoot = ModdedPackCommands.packsRoot(); + File pack = PackDirectoryResolver.resolveExisting(packsRoot, arguments.dimension()); + if (pack == null) { + IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_PACK_NOT_FOUND_UNDER, MessageArgument.untrusted("name", arguments.dimension()), MessageArgument.untrusted("value", packsRoot.getAbsolutePath()))); + return 0; + } + WorldgenExportService service = WorldgenExportServices.get(); + WorldgenExportRequest request = arguments.toRequest(pack.toPath(), exportRoot()); + IrisModdedCommands.ok(source, IrisLanguage.plain(DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_STARTED, MessageArgument.untrusted("dimension", request.dimension()), MessageArgument.untrusted("version", arguments.target().getVersion()))); + ExportFeedback feedback = new ExportFeedback(source); + Thread worker = new Thread(() -> runExport(service, request, feedback), "Iris Datapack Export"); + worker.setDaemon(true); + worker.start(); + return 1; + } + + private static Path exportRoot() { + return ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen").resolve("exports").resolve("datapacks"); + } + + private static void runExport(WorldgenExportService service, WorldgenExportRequest request, ExportFeedback feedback) { + WorldgenExportResult result; + try { + result = service.export(request, feedback); + } catch (Throwable failure) { + ModdedIrisLog.error("Iris datapack export failed for " + request.dimension(), failure); + feedback.post(() -> IrisModdedCommands.fail(feedback.source, IrisLanguage.plain(DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_FAILED, MessageArgument.untrusted("reason", IrisLanguage.errorDetail(failure))))); + return; + } + feedback.post(() -> reportExport(feedback.source, result)); + } + + private static void reportExport(NativeCommandSource source, WorldgenExportResult result) { + for (ExportDiagnostic diagnostic : result.diagnostics()) { + String line = IrisLanguage.plain(DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_DIAGNOSTIC, MessageArgument.untrusted("key", diagnosticLabel(diagnostic)), MessageArgument.untrusted("message", diagnostic.message())); + ModdedCommandFeedback.send(source, NativeCommandText.literal(line).withStyle(diagnosticFormat(diagnostic.severity()))); + } + if (result.isSuccess()) { + IrisModdedCommands.ok(source, IrisLanguage.plain(DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_EXPORTED, MessageArgument.untrusted("path", result.output()))); + return; + } + TextKey outcome = result.status() == WorldgenExportResult.Status.REJECTED + ? DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_REJECTED + : DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_FAILED; + IrisModdedCommands.fail(source, IrisLanguage.plain(outcome, MessageArgument.untrusted("reason", result.message()))); + } + + private static NativeCommandText.Format diagnosticFormat(ExportDiagnostic.Severity severity) { + return switch (severity) { + case ERROR -> NativeCommandText.Format.RED; + case WARNING -> NativeCommandText.Format.YELLOW; + case INFO -> NativeCommandText.Format.GRAY; + }; + } + + private static String diagnosticLabel(ExportDiagnostic diagnostic) { + return diagnostic.resource().isEmpty() + ? diagnostic.limit().name() + : diagnostic.limit().name() + " " + diagnostic.resource(); + } + + private static final class ExportFeedback implements WorldgenExportProgress { + private final NativeCommandSource source; + private final NativeModdedServer server; + private final UUID audience; + + private ExportFeedback(NativeCommandSource source) { + this.source = source; + this.server = source.server(); + this.audience = source.playerId(); + } + + @Override + public void stage(String stage) { + post(() -> progress(IrisLanguage.plain(DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_STAGE, MessageArgument.untrusted("stage", stage)))); + } + + @Override + public void resources(int done, int total) { + int stride = Math.max(1, total / RESOURCE_REPORTS); + if (done != total && done % stride != 0) { + return; + } + post(() -> progress(IrisLanguage.plain(DirectorCommandMessages.COMMAND_DATAPACK_EXPORT_RESOURCES, MessageArgument.untrusted("done", done), MessageArgument.untrusted("total", total)))); + } + + private void post(Runnable task) { + server.execute(() -> LanguageAudience.run(audience, task)); + } + + private void progress(String message) { + ModdedCommandFeedback.send(source, NativeCommandText.literal(message).withStyle(NativeCommandText.Format.GRAY)); + } + } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDatapackExportArguments.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDatapackExportArguments.java new file mode 100644 index 000000000..2b18317df --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDatapackExportArguments.java @@ -0,0 +1,128 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.modded.command; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.DatapackExportDestinations; +import art.arcane.iris.pack.datapack.export.WorldgenExportMode; +import art.arcane.iris.pack.datapack.export.WorldgenExportRequest; +import art.arcane.iris.pack.datapack.export.WorldgenExportTargets; + +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Pattern; + +record ModdedDatapackExportArguments(String dimension, String version, String mode, String namespace, String output, + String format, boolean objects, boolean replace) { + private static final Pattern DIMENSION = Pattern.compile("[A-Za-z0-9_/.-]+"); + private static final String VERSION_KEY = "version"; + private static final String MODE_KEY = "mode"; + private static final String NAMESPACE_KEY = "namespace"; + private static final String OUTPUT_KEY = "output"; + private static final String FORMAT_KEY = "format"; + private static final String OBJECTS_KEY = "objects"; + private static final String REPLACE_KEY = "replace"; + private static final Set OPTIONS = Set.of(VERSION_KEY, MODE_KEY, NAMESPACE_KEY, OUTPUT_KEY, FORMAT_KEY, + OBJECTS_KEY, REPLACE_KEY); + + ModdedDatapackExportArguments { + Objects.requireNonNull(dimension, "dimension"); + Objects.requireNonNull(version, "version"); + Objects.requireNonNull(mode, "mode"); + Objects.requireNonNull(namespace, "namespace"); + Objects.requireNonNull(output, "output"); + Objects.requireNonNull(format, "format"); + } + + static ModdedDatapackExportArguments parse(String raw) { + String trimmed = raw == null ? "" : raw.trim(); + String[] tokens = trimmed.isEmpty() ? new String[0] : trimmed.split("\\s+"); + String dimension = null; + Map options = new HashMap<>(OPTIONS.size()); + for (String token : tokens) { + int equals = token.indexOf('='); + if (equals < 0) { + if (dimension != null) { + throw new IllegalArgumentException("unexpected argument '" + token + "'"); + } + dimension = token; + continue; + } + String key = token.substring(0, equals); + if (!OPTIONS.contains(key)) { + throw new IllegalArgumentException("unknown option '" + key + "'"); + } + if (options.putIfAbsent(key, token.substring(equals + 1)) != null) { + throw new IllegalArgumentException("option '" + key + "' given twice"); + } + } + if (dimension == null) { + throw new IllegalArgumentException("missing dimension"); + } + if (!DIMENSION.matcher(dimension).matches() || dimension.contains("..")) { + throw new IllegalArgumentException("invalid dimension key '" + dimension + "'"); + } + ModdedDatapackExportArguments arguments = new ModdedDatapackExportArguments( + dimension, + options.getOrDefault(VERSION_KEY, ""), + options.getOrDefault(MODE_KEY, WorldgenExportRequest.DEFAULT_MODE), + options.getOrDefault(NAMESPACE_KEY, WorldgenExportRequest.DEFAULT_NAMESPACE), + options.getOrDefault(OUTPUT_KEY, ""), + options.getOrDefault(FORMAT_KEY, WorldgenExportRequest.DEFAULT_FORMAT), + flag(OBJECTS_KEY, options.get(OBJECTS_KEY), true), + flag(REPLACE_KEY, options.get(REPLACE_KEY), false)); + arguments.target(); + WorldgenExportMode.parse(arguments.mode()); + DatapackExportDestinations.parseFormat(arguments.format()); + return arguments; + } + + DataVersion target() { + return WorldgenExportTargets.resolve(version); + } + + WorldgenExportRequest toRequest(Path pack, Path exportRoot) { + return WorldgenExportRequest.builder() + .pack(pack) + .dimension(dimension) + .version(version) + .mode(mode) + .namespace(namespace) + .output(output) + .format(format) + .objects(objects) + .replace(replace) + .exportRoot(exportRoot) + .build(); + } + + private static boolean flag(String key, String raw, boolean fallback) { + if (raw == null) { + return fallback; + } + return switch (raw) { + case "true" -> true; + case "false" -> false; + default -> throw new IllegalArgumentException(key + " must be true or false"); + }; + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/localization/ModdedHelpMessages.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/localization/ModdedHelpMessages.java index d7e3f59d6..7c01fc66f 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/localization/ModdedHelpMessages.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/localization/ModdedHelpMessages.java @@ -330,6 +330,10 @@ public final class ModdedHelpMessages { "iris.modded.help.entry.command.remove", "Explain datapack removal workflow" ); + public static final TextKey COMMAND_EXPORT_EXPORT_A_DIMENSION_AS_A_VANILLA_WORLDGEN_DATAPACK = TextKey.of( + "iris.modded.help.entry.command.export_datapack", + "Export a dimension as a vanilla worldgen datapack" + ); public static final TextKey COMMAND_LIST_REGENERATE_STRUCTURE_INDEX_JSON = TextKey.of( "iris.modded.help.entry.command.list_structures", "Regenerate structure-index.json" @@ -505,6 +509,7 @@ public final class ModdedHelpMessages { COMMAND_LIST_LIST_CONFIGURED_AND_INSTALLED_DATAPACKS, COMMAND_INGEST_EXPLAIN_BUKKIT_DATAPACK_INGEST_WORKFLOW, COMMAND_REMOVE_EXPLAIN_DATAPACK_REMOVAL_WORKFLOW, + COMMAND_EXPORT_EXPORT_A_DIMENSION_AS_A_VANILLA_WORLDGEN_DATAPACK, COMMAND_LIST_REGENERATE_STRUCTURE_INDEX_JSON, COMMAND_INFO_RESOLVE_AN_IRIS_STRUCTURE_GRAPH_AND_REPORT_BOUNDS, COMMAND_PLACE_ASSEMBLE_AND_PLACE_AN_IRIS_STRUCTURE_AT_YOUR_LOCATION, diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedVanillaDataTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedVanillaDataTest.java new file mode 100644 index 000000000..ae4615859 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedVanillaDataTest.java @@ -0,0 +1,50 @@ +package art.arcane.iris.modded; + +import art.arcane.iris.platform.ClasspathVanillaDataSource; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer; +import art.arcane.volmlib.nativelib.terrain.NativeWorld; +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; +import net.minecraft.server.level.ServerLevel; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class ModdedVanillaDataTest { + private static final String PLAINS = "data/minecraft/worldgen/biome/plains.json"; + private static final String BIOME_TAGS = "data/minecraft/tags/worldgen/biome"; + + @BeforeClass + public static void bootstrapRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void overworldLevelClassReadsTheGameVanillaData() { + NativeModdedServer server = mock(NativeModdedServer.class); + NativeWorld overworld = mock(NativeWorld.class); + when(overworld.nativeHandle()).thenReturn(mock(ServerLevel.class)); + when(server.overworld()).thenReturn(overworld); + Class anchor = ModdedPlatform.overworldClass(server); + assertSame(ServerLevel.class, anchor); + ClasspathVanillaDataSource source = new ClasspathVanillaDataSource(() -> anchor); + String plains = new String(source.read(PLAINS).orElseThrow(), StandardCharsets.UTF_8); + assertTrue(plains.contains("\"temperature\"")); + assertTrue(source.list(BIOME_TAGS).contains(BIOME_TAGS + "/is_overworld.json")); + } + + @Test + public void vanillaDataNeedsALoadedOverworld() { + NativeModdedServer server = mock(NativeModdedServer.class); + assertThrows(IllegalStateException.class, () -> ModdedPlatform.overworldClass(null)); + assertThrows(IllegalStateException.class, () -> ModdedPlatform.overworldClass(server)); + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedCommandParityTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedCommandParityTest.java index f4aac6334..9b881691b 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedCommandParityTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedCommandParityTest.java @@ -63,6 +63,9 @@ public class IrisModdedCommandParityTest { CommandNode studio = child(iris, "studio"); child(studio, "package"); child(studio, "pkg"); + CommandNode datapackExport = child(child(iris, "datapack"), "export"); + assertNotNull(datapackExport.getCommand()); + assertNotNull(child(datapackExport, "args").getCommand()); CommandNode download = child(iris, "download"); CommandNode source = child(download, "source"); @@ -211,6 +214,8 @@ public class IrisModdedCommandParityTest { assertTrue(root.stream().anyMatch((String line) -> line.startsWith("/iris teleport [player] (tp) - "))); assertTrue(ModdedCommandHelp.consoleLines("pregen").stream().anyMatch((String line) -> line.startsWith("/iris pregen start [dimension] [at] [x] [z] [gui] [sync] [nocache] - "))); + assertTrue(ModdedCommandHelp.consoleLines("datapack").stream().anyMatch((String line) -> + line.startsWith("/iris datapack export [version=] [mode=overworld] [namespace=iris_export] [output=] [format=zip] [objects=true] [replace=false] - "))); } private static CommandNode child( diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedDatapackExportArgumentsTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedDatapackExportArgumentsTest.java new file mode 100644 index 000000000..b0a82d8ed --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedDatapackExportArgumentsTest.java @@ -0,0 +1,87 @@ +package art.arcane.iris.modded.command; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.WorldgenExportRequest; +import org.junit.Test; + +import java.nio.file.Path; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class ModdedDatapackExportArgumentsTest { + @Test + public void unknownKeyFails() { + assertEquals("unknown option 'seed'", reason("overworld seed=1")); + assertEquals("unknown option 'Version'", reason("overworld Version=26.3")); + } + + @Test + public void duplicateKeyFails() { + assertEquals("option 'replace' given twice", reason("overworld replace=true replace=false")); + assertEquals("option 'version' given twice", reason("overworld version= version=26.3")); + } + + @Test + public void firstBareTokenIsDimension() { + ModdedDatapackExportArguments arguments = ModdedDatapackExportArguments.parse( + " version=26.3 overworld mode=dimension namespace=worlds output=export_one format=directory objects=false replace=true "); + + assertEquals("overworld", arguments.dimension()); + assertEquals("26.3", arguments.version()); + assertEquals("dimension", arguments.mode()); + assertEquals("worlds", arguments.namespace()); + assertEquals("export_one", arguments.output()); + assertEquals("directory", arguments.format()); + assertFalse(arguments.objects()); + assertTrue(arguments.replace()); + assertSame(DataVersion.V26_3, arguments.target()); + assertEquals("unexpected argument 'underworld'", reason("overworld underworld")); + assertEquals("missing dimension", reason(" ")); + assertEquals("missing dimension", reason("version=26.3")); + } + + @Test + public void defaultsMatchBukkit() { + ModdedDatapackExportArguments arguments = ModdedDatapackExportArguments.parse("overworld"); + + assertEquals("", arguments.version()); + assertEquals("overworld", arguments.mode()); + assertEquals("iris_export", arguments.namespace()); + assertEquals("", arguments.output()); + assertEquals("zip", arguments.format()); + assertTrue(arguments.objects()); + assertFalse(arguments.replace()); + assertSame(DataVersion.V26_3, arguments.target()); + + Path pack = Path.of("packs", "overworld"); + Path exportRoot = Path.of("exports", "datapacks"); + WorldgenExportRequest expected = WorldgenExportRequest.builder() + .pack(pack) + .dimension("overworld") + .exportRoot(exportRoot) + .build(); + assertEquals(expected, arguments.toRequest(pack, exportRoot)); + } + + @Test + public void unsupportedValuesFailWithSharedMessages() { + assertEquals("Unsupported datapack export version '1.21.1'. Supported: 26.3", + reason("overworld version=1.21.1")); + assertEquals("Unsupported datapack export version '26.2'. Supported: 26.3", + reason("overworld version=26.2")); + assertEquals("Unknown export mode 'nether'. Expected: preset, dimension, overworld", + reason("overworld mode=nether")); + assertEquals("Export format must be directory or zip.", reason("overworld format=rar")); + assertEquals("objects must be true or false", reason("overworld objects=yes")); + assertEquals("replace must be true or false", reason("overworld replace=1")); + assertEquals("invalid dimension key 'a..b'", reason("a..b")); + } + + private static String reason(String raw) { + return assertThrows(IllegalArgumentException.class, () -> ModdedDatapackExportArguments.parse(raw)).getMessage(); + } +} diff --git a/build.gradle b/build.gradle index d522eb3b9..e351e65d2 100644 --- a/build.gradle +++ b/build.gradle @@ -821,6 +821,13 @@ project(':adapters:bukkit:nms:v26_3_R1') { } sourceSets.test.compileClasspath += sourceSets.offline.output sourceSets.test.runtimeClasspath += sourceSets.offline.output + tasks.named('test').configure { + String exportProbePack = providers.gradleProperty('iris.exportProbePack').orNull + if (exportProbePack != null) { + systemProperty('iris.exportProbePack', exportProbePack) + maxHeapSize = '6g' + } + } TaskProvider offlineJar = tasks.register('offlineJar', Jar) { dependsOn(offlineClasses) @@ -848,6 +855,30 @@ project(':adapters:bukkit:nms:v26_3_R1') { } } + tasks.register('datapackCostProbe', JavaExec) { + group = 'verification' + description = 'Exports an Iris dimension as a 26.3 worldgen datapack and measures its load, chunk and density function cost against vanilla.' + dependsOn(offlineClasses) + mainClass = 'art.arcane.iris.probe.DatapackCostProbe' + classpath = sourceSets.offline.runtimeClasspath + maxHeapSize = providers.gradleProperty('probeHeap').getOrElse('24G') + jvmArgs('--add-modules', 'jdk.incubator.vector', '-Djava.awt.headless=true') + doFirst { + String dimension = providers.gradleProperty('probeDimension').getOrElse('overworld') + String output = providers.gradleProperty('probeOutput') + .getOrElse(layout.buildDirectory.file("reports/datapack-cost/${dimension}.json").get().asFile.absolutePath) + args([rootProject.file(providers.gradleProperty('probePack').get()).absolutePath, dimension, + providers.gradleProperty('probeSeed').getOrElse('1337'), + providers.gradleProperty('probeChunkSide').getOrElse('6'), + providers.gradleProperty('probeRounds').getOrElse('4'), + providers.gradleProperty('probeWarmupSide').getOrElse('6'), + providers.gradleProperty('probeCalls').getOrElse('256'), + providers.gradleProperty('probeCallSeconds').getOrElse('60'), + rootProject.file(output).absolutePath, + layout.buildDirectory.dir('tmp/datapack-cost').get().asFile.absolutePath]) + } + } + tasks.register('regionMantleInspection', JavaExec) { group = 'verification' dependsOn(testClasses) @@ -877,6 +908,32 @@ project(':adapters:bukkit:nms:v26_3_R1') { } } + tasks.register('datapackFidelityProbe', JavaExec) { + group = 'verification' + description = 'Exports an Iris dimension as a 26.3 worldgen datapack and compares both generators on the same seed.' + dependsOn(offlineClasses) + mainClass = 'art.arcane.iris.probe.DatapackFidelityProbe' + classpath = sourceSets.offline.runtimeClasspath + maxHeapSize = '8G' + jvmArgs('--add-modules', 'jdk.incubator.vector', '-Djava.awt.headless=true') + doFirst { + String dimension = providers.gradleProperty('probeDimension').get() + String seed = providers.gradleProperty('probeSeed').get() + String mode = providers.gradleProperty('probeMode').getOrElse('full') + boolean biomes = mode == 'biomes' + String output = providers.gradleProperty('probeOutput') + .getOrElse(layout.buildDirectory.file("reports/datapack-fidelity/${dimension}-${seed}${biomes ? '-biomes' : ''}.json").get().asFile.absolutePath) + args([rootProject.file(providers.gradleProperty('probePack').get()).absolutePath, dimension, seed, + biomes ? providers.gradleProperty('probeRadiusChunks').getOrElse('0') : providers.gradleProperty('probeRadiusChunks').get(), + biomes ? providers.gradleProperty('probeStride').getOrElse('1') : providers.gradleProperty('probeStride').get(), + rootProject.file(output).absolutePath, + providers.gradleProperty('probeSurveyBlocks').getOrElse('65536'), + providers.gradleProperty('probeSurveyStride').getOrElse('256'), + providers.gradleProperty('probeSurveySeeds').getOrElse(biomes ? '7' : '1'), + mode]) + } + } + } diff --git a/core/build.gradle b/core/build.gradle index 6475a019c..b0a82549e 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -193,6 +193,10 @@ tasks.named('test').configure { maxHeapSize = '2g' jvmArgs('-XX:+ExitOnOutOfMemoryError') systemProperty('iris.generation.counters', 'true') + String exportProbePack = providers.gradleProperty('iris.exportProbePack').orNull + if (exportProbePack != null) { + systemProperty('iris.exportProbePack', exportProbePack) + } } java { @@ -360,14 +364,9 @@ List unusedVolmLibPackages = [ ] List unusedVolmLibClasses = [ - 'art/arcane/volmlib/util/nbt/io/ParseException', - 'art/arcane/volmlib/util/nbt/io/SNBTDeserializer', - 'art/arcane/volmlib/util/nbt/io/SNBTParser', 'art/arcane/volmlib/util/nbt/io/SNBTSerializer', 'art/arcane/volmlib/util/nbt/io/SNBTUtil', 'art/arcane/volmlib/util/nbt/io/SNBTWriter', - 'art/arcane/volmlib/util/nbt/io/StringPointer', - 'art/arcane/volmlib/util/io/StringDeserializer', 'art/arcane/volmlib/util/io/StringSerializer', 'art/arcane/volmlib/util/json/EnumType', 'art/arcane/volmlib/util/json/HTTP', diff --git a/core/purity-allowlist.txt b/core/purity-allowlist.txt index 5178cd8b6..6b00a6d66 100644 --- a/core/purity-allowlist.txt +++ b/core/purity-allowlist.txt @@ -119,6 +119,8 @@ art/arcane/iris/studio/StudioSVC.java art/arcane/iris/studio/edit/BlockSignal.java art/arcane/iris/studio/edit/DustRevealer.java art/arcane/iris/studio/generation/JigsawStudioGenerator.java +art/arcane/iris/studio/jigsaw/JigsawStudioConnectorTool.java +art/arcane/iris/studio/jigsaw/JigsawStudioFloorPainter.java art/arcane/iris/studio/generation/ObjectStudioGenerator.java art/arcane/iris/studio/jigsaw/JigsawStudioAutosaveScheduler.java art/arcane/iris/studio/jigsaw/JigsawStudioCapture.java diff --git a/core/src/main/java/art/arcane/iris/generation/validation/ChunkSpiralScan.java b/core/src/main/java/art/arcane/iris/generation/validation/ChunkSpiralScan.java new file mode 100644 index 000000000..38e39f091 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/validation/ChunkSpiralScan.java @@ -0,0 +1,74 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.generation.validation; + +import art.arcane.iris.generation.concurrent.MultiBurst; +import art.arcane.iris.spi.IrisLogging; + +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Semaphore; +import java.util.concurrent.atomic.AtomicInteger; + +public final class ChunkSpiralScan { + private ChunkSpiralScan() { + } + + public static int scan(List targets, int threads, ChunkVisitor visitor, Listener listener) throws InterruptedException { + Semaphore inFlight = new Semaphore(Math.max(1, threads)); + CountDownLatch done = new CountDownLatch(targets.size()); + AtomicInteger failures = new AtomicInteger(); + + for (int[] target : targets) { + int chunkX = target[0]; + int chunkZ = target[1]; + + inFlight.acquire(); + MultiBurst.burst.lazy(() -> { + boolean ok = false; + try { + visitor.visit(chunkX, chunkZ); + ok = true; + } catch (Throwable e) { + failures.incrementAndGet(); + IrisLogging.reportError(e); + listener.chunkFailed(chunkX, chunkZ, e); + } finally { + listener.chunkDone(chunkX, chunkZ, ok); + inFlight.release(); + done.countDown(); + } + }); + } + + done.await(); + return failures.get(); + } + + @FunctionalInterface + public interface ChunkVisitor { + void visit(int chunkX, int chunkZ) throws Exception; + } + + public interface Listener { + void chunkDone(int chunkX, int chunkZ, boolean ok); + + void chunkFailed(int chunkX, int chunkZ, Throwable error); + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/validation/GoldenHashEngine.java b/core/src/main/java/art/arcane/iris/generation/validation/GoldenHashEngine.java index 57e15b6f1..c23e23999 100644 --- a/core/src/main/java/art/arcane/iris/generation/validation/GoldenHashEngine.java +++ b/core/src/main/java/art/arcane/iris/generation/validation/GoldenHashEngine.java @@ -26,7 +26,6 @@ import art.arcane.iris.spi.IrisLogging; import art.arcane.volmlib.nativelib.terrain.NativeBiome; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; import art.arcane.volmlib.util.math.ChunkSpiral; -import art.arcane.iris.generation.concurrent.MultiBurst; import art.arcane.volmlib.util.localization.MessageArgument; import art.arcane.volmlib.util.mantle.runtime.Mantle; @@ -44,8 +43,6 @@ import java.util.List; import java.util.Map; import java.util.TreeMap; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.Semaphore; import java.util.concurrent.atomic.AtomicInteger; public final class GoldenHashEngine { @@ -210,40 +207,18 @@ public final class GoldenHashEngine { private Map scan(List targets) throws InterruptedException { Map lines = new ConcurrentHashMap<>(); - Semaphore inFlight = new Semaphore(threads); - CountDownLatch done = new CountDownLatch(targets.size()); - AtomicInteger completed = new AtomicInteger(); - int total = targets.size(); - - for (int[] target : targets) { - int chunkX = target[0]; - int chunkZ = target[1]; - - inFlight.acquire(); - MultiBurst.burst.lazy(() -> { - boolean ok = false; - try { - ChunkSnapshot snapshot = source.generate(chunkX, chunkZ); - lines.put(chunkKey(chunkX, chunkZ), hashChunk(chunkX, chunkZ, snapshot)); - if (request.deep()) { - writeDeepDump(chunkX, chunkZ, snapshot); - } - ok = true; - } catch (Throwable e) { - IrisLogging.reportError(e); - progress.chunkFailed(chunkX, chunkZ, e); - } finally { - progress.chunkDone(chunkX, chunkZ, ok, completed.incrementAndGet(), total); - inFlight.release(); - done.countDown(); - } - }); - } - - done.await(); + ChunkSpiralScan.scan(targets, threads, (int chunkX, int chunkZ) -> hash(lines, chunkX, chunkZ), new ScanProgress(targets.size())); return lines; } + private void hash(Map lines, int chunkX, int chunkZ) throws Exception { + ChunkSnapshot snapshot = source.generate(chunkX, chunkZ); + lines.put(chunkKey(chunkX, chunkZ), hashChunk(chunkX, chunkZ, snapshot)); + if (request.deep()) { + writeDeepDump(chunkX, chunkZ, snapshot); + } + } + private String hashChunk(int chunkX, int chunkZ, ChunkSnapshot snapshot) { MessageDigest blockDigest = sha256(); MessageDigest biomeDigest = sha256(); @@ -562,4 +537,23 @@ public final class GoldenHashEngine { throw new IllegalStateException(e); } } + + private final class ScanProgress implements ChunkSpiralScan.Listener { + private final AtomicInteger completed = new AtomicInteger(); + private final int total; + + private ScanProgress(int total) { + this.total = total; + } + + @Override + public void chunkDone(int chunkX, int chunkZ, boolean ok) { + progress.chunkDone(chunkX, chunkZ, ok, completed.incrementAndGet(), total); + } + + @Override + public void chunkFailed(int chunkX, int chunkZ, Throwable error) { + progress.chunkFailed(chunkX, chunkZ, error); + } + } } diff --git a/core/src/main/java/art/arcane/iris/localization/DirectorCommandMessages.java b/core/src/main/java/art/arcane/iris/localization/DirectorCommandMessages.java index 7c251f76f..8785174fd 100644 --- a/core/src/main/java/art/arcane/iris/localization/DirectorCommandMessages.java +++ b/core/src/main/java/art/arcane/iris/localization/DirectorCommandMessages.java @@ -33,6 +33,74 @@ public final class DirectorCommandMessages { "iris.director.commanddatapack.param.datapack_id_folder_name_shown_by_iris_datapack_list", "The datapack id (folder name) shown by /iris datapack list" ); + public static final TextKey COMMAND_DATAPACK_EXPORT_DESCRIPTION = TextKey.of( + "iris.director.commanddatapack.director.export_dimension_vanilla_worldgen_datapack", + "Export a dimension as a vanilla worldgen datapack" + ); + public static final TextKey COMMAND_DATAPACK_EXPORT_PARAM_DIMENSION = TextKey.of( + "iris.director.commanddatapack.param.export_dimension", + "The dimension to export" + ); + public static final TextKey COMMAND_DATAPACK_EXPORT_PARAM_VERSION = TextKey.of( + "iris.director.commanddatapack.param.export_version", + "Target Minecraft version, 26.3 or later (blank uses the latest)" + ); + public static final TextKey COMMAND_DATAPACK_EXPORT_PARAM_MODE = TextKey.of( + "iris.director.commanddatapack.param.export_mode", + "Output mode: preset, dimension, or overworld" + ); + public static final TextKey COMMAND_DATAPACK_EXPORT_PARAM_NAMESPACE = TextKey.of( + "iris.director.commanddatapack.param.export_namespace", + "Namespace for the exported resources" + ); + public static final TextKey COMMAND_DATAPACK_EXPORT_PARAM_OUTPUT = TextKey.of( + "iris.director.commanddatapack.param.export_output", + "Output name in the datapack exports folder (blank uses the dimension and version)" + ); + public static final TextKey COMMAND_DATAPACK_EXPORT_PARAM_FORMAT = TextKey.of( + "iris.director.commanddatapack.param.export_format", + "zip or directory" + ); + public static final TextKey COMMAND_DATAPACK_EXPORT_PARAM_OBJECTS = TextKey.of( + "iris.director.commanddatapack.param.export_objects", + "Include objects as structure templates" + ); + public static final TextKey COMMAND_DATAPACK_EXPORT_PARAM_REPLACE = TextKey.of( + "iris.director.commanddatapack.param.export_replace", + "Replace an existing export with the same name" + ); + public static final TextKey COMMAND_DATAPACK_EXPORT_STARTED = TextKey.of( + "iris.director.commanddatapack.export.started", + "Exporting {dimension} as a datapack for Minecraft {version}..." + ); + public static final TextKey COMMAND_DATAPACK_EXPORT_STAGE = TextKey.of( + "iris.director.commanddatapack.export.stage", + "Datapack export: {stage}" + ); + public static final TextKey COMMAND_DATAPACK_EXPORT_RESOURCES = TextKey.of( + "iris.director.commanddatapack.export.resources", + "Datapack export: {done}/{total} resources" + ); + public static final TextKey COMMAND_DATAPACK_EXPORT_DIAGNOSTIC = TextKey.of( + "iris.director.commanddatapack.export.diagnostic", + "{key}: {message}" + ); + public static final TextKey COMMAND_DATAPACK_EXPORT_EXPORTED = TextKey.of( + "iris.director.commanddatapack.export.exported", + "Datapack exported: {path}" + ); + public static final TextKey COMMAND_DATAPACK_EXPORT_REJECTED = TextKey.of( + "iris.director.commanddatapack.export.rejected", + "Datapack export rejected: {reason}" + ); + public static final TextKey COMMAND_DATAPACK_EXPORT_FAILED = TextKey.of( + "iris.director.commanddatapack.export.failed", + "Datapack export failed: {reason}" + ); + public static final TextKey COMMAND_DATAPACK_EXPORT_INVALID = TextKey.of( + "iris.director.commanddatapack.export.invalid", + "Invalid datapack export arguments: {reason}" + ); public static final TextKey COMMAND_DEVELOPER_DIRECTOR_IRIS_WORLD_MANAGER = TextKey.of( "iris.director.commanddeveloper.director.iris_world_manager", "Iris World Manager" @@ -888,6 +956,23 @@ public final class DirectorCommandMessages { COMMAND_DATAPACK_DIRECTOR_LIST_CONFIGURED_DATAPACK_IMPORTS_THEIR_INSTALLED_VERSIONS, COMMAND_DATAPACK_DIRECTOR_REMOVE_INSTALLED_DATAPACK_BY_ID_ALSO_DELETE_ITS_URL_FROM_DATAPACKIMPORTS_KEEP, COMMAND_DATAPACK_PARAM_DATAPACK_ID_FOLDER_NAME_SHOWN_BY_IRIS_DATAPACK_LIST, + COMMAND_DATAPACK_EXPORT_DESCRIPTION, + COMMAND_DATAPACK_EXPORT_PARAM_DIMENSION, + COMMAND_DATAPACK_EXPORT_PARAM_VERSION, + COMMAND_DATAPACK_EXPORT_PARAM_MODE, + COMMAND_DATAPACK_EXPORT_PARAM_NAMESPACE, + COMMAND_DATAPACK_EXPORT_PARAM_OUTPUT, + COMMAND_DATAPACK_EXPORT_PARAM_FORMAT, + COMMAND_DATAPACK_EXPORT_PARAM_OBJECTS, + COMMAND_DATAPACK_EXPORT_PARAM_REPLACE, + COMMAND_DATAPACK_EXPORT_STARTED, + COMMAND_DATAPACK_EXPORT_STAGE, + COMMAND_DATAPACK_EXPORT_RESOURCES, + COMMAND_DATAPACK_EXPORT_DIAGNOSTIC, + COMMAND_DATAPACK_EXPORT_EXPORTED, + COMMAND_DATAPACK_EXPORT_REJECTED, + COMMAND_DATAPACK_EXPORT_FAILED, + COMMAND_DATAPACK_EXPORT_INVALID, COMMAND_DEVELOPER_DIRECTOR_IRIS_WORLD_MANAGER, COMMAND_DEVELOPER_DIRECTOR_GET_LOADED_TECTONICPLATES_COUNT, COMMAND_DEVELOPER_DIRECTOR_HASH_GENERATED_BLOCK_OUTPUT_FIXED_AREA_DETERMINISM_IDENTITY_TESTING, diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/DataVersion.java b/core/src/main/java/art/arcane/iris/pack/datapack/DataVersion.java index f71066934..864429b57 100644 --- a/core/src/main/java/art/arcane/iris/pack/datapack/DataVersion.java +++ b/core/src/main/java/art/arcane/iris/pack/datapack/DataVersion.java @@ -13,20 +13,24 @@ import art.arcane.iris.pack.datapack.v263.DataFixerV263; //https://minecraft.wiki/w/Pack_format @Getter public enum DataVersion { - UNSUPPORTED("0.0.0", 0, () -> null), - V1_21_1("1.21.1", 48, DataFixerV1206::new), - V26_1_2("26.1.2", 101, DataFixerV1217::new), - V26_2("26.2", 107, DataFixerV1217::new), - V26_3("26.3", 121, DataFixerV263::new); + UNSUPPORTED("0.0.0", 0, 0, 0, () -> null), + V1_21_1("1.21.1", 48, 0, 3955, DataFixerV1206::new), + V26_1_2("26.1.2", 101, 1, 4790, DataFixerV1217::new), + V26_2("26.2", 107, 1, 4903, DataFixerV1217::new), + V26_3("26.3", 121, 0, 5023, DataFixerV263::new); private static final KMap cache = new KMap<>(); @Getter(AccessLevel.NONE) private final Supplier constructor; private final String version; private final int packFormat; + private final int packFormatMinor; + private final int worldDataVersion; - DataVersion(String version, int packFormat, Supplier constructor) { + DataVersion(String version, int packFormat, int packFormatMinor, int worldDataVersion, Supplier constructor) { this.constructor = constructor; this.packFormat = packFormat; + this.packFormatMinor = packFormatMinor; + this.worldDataVersion = worldDataVersion; this.version = version; } diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/DatapackExportArchives.java b/core/src/main/java/art/arcane/iris/pack/datapack/DatapackExportArchives.java new file mode 100644 index 000000000..dc5117d91 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/DatapackExportArchives.java @@ -0,0 +1,99 @@ +package art.arcane.iris.pack.datapack; + +import art.arcane.iris.pack.datapack.DatapackIngestService.Entry; +import art.arcane.iris.pack.datapack.ModrinthResolver.ResolvedDatapack; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.volmlib.util.io.IO; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.nio.file.LinkOption; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Objects; + +public final class DatapackExportArchives { + private static final String DIRECT_VERSION_PREFIX = "direct-"; + + private DatapackExportArchives() { + } + + public static Path resolve(String source, DataVersion target) throws IOException { + String normalized = DatapackImportSources.normalizeConfiguredSource(Objects.requireNonNull(source, "datapack import source")); + URI uri = DatapackStartupValidation.parseSourceUri(normalized); + if (uri == null) { + throw new IOException("Empty datapack import source"); + } + if ("file".equalsIgnoreCase(uri.getScheme())) { + return DatapackArchive.requireLocalDatapackPath(uri); + } + IrisPlatform platform = IrisPlatforms.get(); + File root = platform.dataFolder("datapacks"); + File cacheDir = new File(root, "cache"); + boolean running = DataVersion.forMinecraftVersion(platform.minecraftVersion()) == target; + DatapackTransactions.TRANSACTION_LOCK.lock(); + try { + Entry entry = manifestEntry(root, normalized); + Path cached = ingestCache(cacheDir, entry, running); + if (cached != null) { + return cached; + } + return download(cacheDir, normalized, entry, target); + } finally { + DatapackTransactions.TRANSACTION_LOCK.unlock(); + } + } + + private static Entry manifestEntry(File root, String source) { + try { + return DatapackManifestStore.readCommittedManifest(root).find(source); + } catch (IOException exception) { + IrisLogging.reportError("The datapack manifest under " + root.getPath() + " could not be read; '" + source + + "' is resolved again for the export", exception); + return null; + } + } + + private static Path ingestCache(File cacheDir, Entry entry, boolean running) throws IOException { + if (entry == null || entry.id == null || entry.sha1 == null) { + return null; + } + boolean direct = entry.versionId != null && entry.versionId.startsWith(DIRECT_VERSION_PREFIX); + if (!direct && !running) { + return null; + } + File archive = new File(cacheDir, entry.id + "-" + DatapackSupport.safeFile(entry.versionId) + ".zip"); + return matches(archive, entry.sha1) ? archive.toPath() : null; + } + + private static Path download(File cacheDir, String source, Entry entry, DataVersion target) throws IOException { + ResolvedDatapack resolved = ModrinthResolver.resolve(source, target.getVersion()); + String id = entry == null || entry.id == null ? DatapackArchive.deriveId(resolved) : entry.id; + File archive = new File(cacheDir, id + "-" + DatapackSupport.safeFile(resolved.getVersionId()) + ".zip"); + String expected = resolved.getSha1(); + if (expected != null && !expected.isBlank() && matches(archive, expected)) { + return archive.toPath(); + } + DatapackSupport.ensureScratchDirectory(cacheDir, "datapack download cache"); + DatapackArchive.download(resolved.getDownloadUrl(), archive, null, null); + if (expected != null && !expected.isBlank()) { + String checksum = DatapackArchive.sha1(archive); + if (!expected.equalsIgnoreCase(checksum)) { + IO.delete(archive); + throw new IOException("Checksum mismatch for " + id + " (expected " + expected + ", got " + checksum + ")"); + } + } + return archive.toPath(); + } + + private static boolean matches(File archive, String sha1) throws IOException { + Path path = archive.toPath(); + if (Files.isSymbolicLink(path) || !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) { + return false; + } + return sha1.equalsIgnoreCase(DatapackArchive.sha1(archive)); + } +} diff --git a/core/src/main/java/art/arcane/iris/structure/export/AtomicDatapackPublisher.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/AtomicDatapackPublisher.java similarity index 86% rename from core/src/main/java/art/arcane/iris/structure/export/AtomicDatapackPublisher.java rename to core/src/main/java/art/arcane/iris/pack/datapack/export/AtomicDatapackPublisher.java index b63254a3a..09d9becd3 100644 --- a/core/src/main/java/art/arcane/iris/structure/export/AtomicDatapackPublisher.java +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/AtomicDatapackPublisher.java @@ -1,4 +1,4 @@ -package art.arcane.iris.structure.export; +package art.arcane.iris.pack.datapack.export; import java.io.IOException; import java.nio.file.AtomicMoveNotSupportedException; @@ -14,25 +14,26 @@ import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; import java.util.stream.Stream; -final class AtomicDatapackPublisher { - Publication publish( - Map resources, - Path output, - VanillaJigsawExportFormat format, - boolean replaceExisting +public final class AtomicDatapackPublisher { + public Publication publish( + Map files, + Path destination, + DatapackExportFormat format, + boolean replace, + String stagingPrefix ) throws IOException { - Path parent = output.getParent(); + Path parent = destination.getParent(); if (parent == null) { - throw new IOException("Export output has no parent directory: " + output); + throw new IOException("Export output has no parent directory: " + destination); } Files.createDirectories(parent); - Path staging = Files.createTempDirectory(parent, ".iris-jigsaw-export-"); + Path staging = Files.createTempDirectory(parent, stagingPrefix); List cleanupWarnings = new ArrayList<>(); try { - Path artifact = format == VanillaJigsawExportFormat.DIRECTORY - ? createDirectoryArtifact(staging, resources) - : createZipArtifact(staging, resources); - replaceAtomically(artifact, output, replaceExisting, cleanupWarnings); + Path artifact = format == DatapackExportFormat.DIRECTORY + ? createDirectoryArtifact(staging, files) + : createZipArtifact(staging, files); + replaceAtomically(artifact, destination, replace, cleanupWarnings); } finally { try { deleteTree(staging); @@ -152,8 +153,8 @@ final class AtomicDatapackPublisher { } } - record Publication(List cleanupWarnings) { - Publication { + public record Publication(List cleanupWarnings) { + public Publication { cleanupWarnings = List.copyOf(cleanupWarnings); } } diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/ContentTrim.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/ContentTrim.java new file mode 100644 index 000000000..da52a803f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/ContentTrim.java @@ -0,0 +1,22 @@ +package art.arcane.iris.pack.datapack.export; + +public record ContentTrim(int filesBefore, long bytesBefore, int filesAfter, long bytesAfter) { + public static final ContentTrim NONE = new ContentTrim(0, 0L, 0, 0L); + + public ContentTrim { + if (filesBefore < 0 || bytesBefore < 0L || filesAfter < 0 || bytesAfter < 0L) { + throw new IllegalArgumentException("Content trim counts cannot be negative"); + } + if (filesAfter > filesBefore || bytesAfter > bytesBefore) { + throw new IllegalArgumentException("A content trim cannot add files or bytes"); + } + } + + public int filesRemoved() { + return filesBefore - filesAfter; + } + + public long bytesRemoved() { + return bytesBefore - bytesAfter; + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/DatapackExportDestinations.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/DatapackExportDestinations.java new file mode 100644 index 000000000..27d96b55b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/DatapackExportDestinations.java @@ -0,0 +1,53 @@ +package art.arcane.iris.pack.datapack.export; + +import java.nio.file.Path; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Pattern; + +public final class DatapackExportDestinations { + private static final Pattern OUTPUT_NAME = Pattern.compile("[a-zA-Z0-9_-][a-zA-Z0-9._-]{0,127}"); + + private static final Set CLAIMED = ConcurrentHashMap.newKeySet(); + + private DatapackExportDestinations() { + } + + public static DatapackExportFormat parseFormat(String raw) { + return switch (raw.trim().toLowerCase(Locale.ROOT)) { + case "zip" -> DatapackExportFormat.ZIP; + case "directory", "dir", "folder" -> DatapackExportFormat.DIRECTORY; + default -> throw new IllegalArgumentException("Export format must be directory or zip."); + }; + } + + public static Path resolve(Path exportRoot, String output, DatapackExportFormat format) { + Path root = Objects.requireNonNull(exportRoot, "Datapack export root").toAbsolutePath().normalize(); + String requested = Objects.requireNonNull(output, "Datapack export output name"); + String name = requested.trim(); + if (!name.equals(requested) || !OUTPUT_NAME.matcher(name).matches()) { + throw new IllegalArgumentException( + "Export output must be one file name using letters, numbers, '.', '_', or '-'."); + } + String fileName = format == DatapackExportFormat.ZIP && !name.endsWith(".zip") ? name + ".zip" : name; + Path destination = root.resolve(fileName).normalize(); + if (destination.equals(root) || !root.equals(destination.getParent())) { + throw new IllegalArgumentException("Export output must be one artifact inside the exports folder."); + } + return destination; + } + + public static boolean claim(Path destination) { + return CLAIMED.add(normalized(destination)); + } + + public static void release(Path destination) { + CLAIMED.remove(normalized(destination)); + } + + private static Path normalized(Path destination) { + return Objects.requireNonNull(destination, "Datapack export destination").toAbsolutePath().normalize(); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/DatapackExportFormat.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/DatapackExportFormat.java new file mode 100644 index 000000000..1bd13bd81 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/DatapackExportFormat.java @@ -0,0 +1,6 @@ +package art.arcane.iris.pack.datapack.export; + +public enum DatapackExportFormat { + ZIP, + DIRECTORY +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/GenerationCost.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/GenerationCost.java new file mode 100644 index 000000000..9a1ea2ec3 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/GenerationCost.java @@ -0,0 +1,83 @@ +package art.arcane.iris.pack.datapack.export; + +import java.util.Collections; +import java.util.EnumMap; +import java.util.Map; +import java.util.Optional; + +public record GenerationCost(Map roots, long densityFunctionBytes) { + public static final GenerationCost NONE = new GenerationCost(Map.of(), 0L); + private static final String DATA_PREFIX = "data/"; + private static final String DENSITY_FUNCTION_FOLDER = "/worldgen/density_function/"; + private static final String JSON_SUFFIX = ".json"; + + public GenerationCost { + EnumMap copy = new EnumMap<>(Root.class); + copy.putAll(roots); + roots = Collections.unmodifiableMap(copy); + if (densityFunctionBytes < 0L) { + throw new IllegalArgumentException("Density function JSON size cannot be negative, got " + densityFunctionBytes); + } + } + + public static long densityFunctionBytes(Map files) { + long bytes = 0L; + for (Map.Entry file : files.entrySet()) { + String path = file.getKey(); + if (path.startsWith(DATA_PREFIX) && path.contains(DENSITY_FUNCTION_FOLDER) && path.endsWith(JSON_SUFFIX)) { + bytes += file.getValue().length; + } + } + return bytes; + } + + public Optional root(Root root) { + return Optional.ofNullable(roots.get(root)); + } + + public GenerationCost withDensityFunctionBytes(long bytes) { + return new GenerationCost(roots, bytes); + } + + public enum Root { + TEMPERATURE("temperature", true), + VEGETATION("vegetation", true), + CONTINENTS("continents", true), + EROSION("erosion", true), + DEPTH("depth", true), + RIDGES("ridges", true), + CHUNK_SURFACE_LEVEL("chunk_surface_level", false), + FINAL_DENSITY("final_density", false), + AQUIFER_BARRIER("aquifer.barrier", false), + AQUIFER_FLOODEDNESS("aquifer.fluid_level_floodedness", false), + AQUIFER_SPREAD("aquifer.fluid_level_spread", false), + AQUIFER_LAVA("aquifer.lava", false), + AQUIFER_EXCLUSION("aquifer.exclusion", false), + AQUIFER_SURFACE_LEVEL("aquifer.surface_level", false); + + private final String id; + private final boolean climate; + + Root(String id, boolean climate) { + this.id = id; + this.climate = climate; + } + + public String id() { + return id; + } + + public boolean climate() { + return climate; + } + } + + public record RootCost(double worstCaseNodes, double expandedNodes, long distinctNodes) { + public RootCost { + if (!(worstCaseNodes >= 0.0) || !(expandedNodes >= 0.0) || distinctNodes < 0L) { + throw new IllegalArgumentException("Generation cost counts cannot be negative, got " + worstCaseNodes + " worst case, " + + expandedNodes + " expanded and " + distinctNodes + " distinct"); + } + } + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/StructureTemplateEncoder.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/StructureTemplateEncoder.java new file mode 100644 index 000000000..89575ac1d --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/StructureTemplateEncoder.java @@ -0,0 +1,195 @@ +package art.arcane.iris.pack.datapack.export; + +import art.arcane.iris.generation.block.TileData; +import art.arcane.iris.generation.geometry.IrisBlockVector; +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.structure.object.IrisObject; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.math.Vector3i; +import art.arcane.volmlib.util.nbt.io.NBTUtil; +import art.arcane.volmlib.util.nbt.io.NamedTag; +import art.arcane.volmlib.util.nbt.mca.MCABlockStateCodecSupport; +import art.arcane.volmlib.util.nbt.tag.CompoundTag; +import art.arcane.volmlib.util.nbt.tag.IntTag; +import art.arcane.volmlib.util.nbt.tag.ListTag; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.TreeSet; +import java.util.zip.GZIPOutputStream; + +public final class StructureTemplateEncoder { + private static final Comparator BLOCK_ORDER = Comparator + .comparingInt(TemplateBlock::y) + .thenComparingInt(TemplateBlock::x) + .thenComparingInt(TemplateBlock::z); + + public static TemplateVolume volume(IrisObject object, String resource, List diagnostics) { + List inside = new ArrayList<>(); + int outside = 0; + String firstOutside = null; + for (TemplateBlock block : blocks(object, resource, diagnostics)) { + if (block.x() < 0 || block.y() < 0 || block.z() < 0 + || block.x() >= object.getW() || block.y() >= object.getH() || block.z() >= object.getD()) { + outside++; + if (firstOutside == null) { + firstOutside = block.x() + "," + block.y() + "," + block.z(); + } + continue; + } + inside.add(block); + } + if (outside > 0) { + diagnostics.add(new ExportDiagnostic(ExportDiagnostic.Severity.WARNING, ExportLimit.OBJECT_BOUNDS, resource, + outside + " blocks outside the " + object.getW() + "x" + object.getH() + "x" + object.getD() + + " object size are left out of the template, first at " + firstOutside)); + } + return new TemplateVolume(object.getW(), object.getH(), object.getD(), inside); + } + + public static List blocks(IrisObject object, String resource, List diagnostics) { + List blocks = new ArrayList<>(); + Vector3i center = object.getCenter(); + int droppedTiles = 0; + String firstDropped = null; + Set customBlocks = new TreeSet<>(); + for (Map.Entry entry : object.getBlocks()) { + IrisBlockVector signed = entry.getKey(); + int x = signed.getBlockX() + center.getX(); + int y = signed.getBlockY() + center.getY(); + int z = signed.getBlockZ() + center.getZ(); + VanillaBlockState state; + try { + state = VanillaBlockState.parse(entry.getValue().key()); + } catch (IllegalArgumentException exception) { + customBlocks.add(entry.getValue().key()); + continue; + } + TileData tile = object.getStates().get(signed); + CompoundTag nbt = null; + if (tile != null) { + Optional converted = TileEntityNbt.toNbt(tile.getMaterialKey(), tile.getProperties()); + if (converted.isPresent()) { + nbt = converted.get(); + } else { + droppedTiles++; + if (firstDropped == null) { + firstDropped = state.name() + " at " + x + "," + y + "," + z; + } + } + } + blocks.add(new TemplateBlock(x, y, z, state, nbt)); + } + if (!customBlocks.isEmpty()) { + diagnostics.add(new ExportDiagnostic(ExportDiagnostic.Severity.WARNING, ExportLimit.CUSTOM_BLOCK, resource, + "Blocks outside Minecraft are left out of the template: " + String.join(", ", customBlocks))); + } + if (droppedTiles > 0) { + diagnostics.add(new ExportDiagnostic(ExportDiagnostic.Severity.WARNING, ExportLimit.TILE_ENTITY, resource, + droppedTiles + " tile entities have no vanilla block entity form and export as plain blocks, first " + + firstDropped)); + } + return blocks; + } + + public byte[] encode(IrisObject object, List extraBlocks, DataVersion target) throws IOException { + List lossy = new ArrayList<>(); + TemplateVolume volume = volume(object, object.getLoadKey(), lossy); + if (!lossy.isEmpty()) { + throw new IllegalArgumentException("Object '" + object.getLoadKey() + "' cannot be encoded: " + lossy.getFirst().message()); + } + Map blocks = new LinkedHashMap<>(); + for (TemplateBlock block : volume.blocks()) { + blocks.put(new BlockPosition(block.x(), block.y(), block.z()), block); + } + for (TemplateBlock extra : extraBlocks) { + blocks.put(new BlockPosition(extra.x(), extra.y(), extra.z()), extra); + } + return encode(new TemplateVolume(volume.sizeX(), volume.sizeY(), volume.sizeZ(), new ArrayList<>(blocks.values())), target); + } + + public byte[] encode(TemplateVolume volume, DataVersion target) throws IOException { + MCABlockStateCodecSupport.Format format = paletteFormat(target); + List orderedBlocks = new ArrayList<>(volume.blocks()); + orderedBlocks.sort(BLOCK_ORDER); + for (int index = 1; index < orderedBlocks.size(); index++) { + if (BLOCK_ORDER.compare(orderedBlocks.get(index - 1), orderedBlocks.get(index)) == 0) { + TemplateBlock duplicate = orderedBlocks.get(index); + throw new IllegalArgumentException("Template has two blocks at " + duplicate.x() + "," + duplicate.y() + "," + duplicate.z()); + } + } + + Map paletteIndexes = new LinkedHashMap<>(); + List paletteStates = new ArrayList<>(); + for (TemplateBlock block : orderedBlocks) { + String key = block.state().canonical(); + if (!paletteIndexes.containsKey(key)) { + paletteIndexes.put(key, paletteStates.size()); + paletteStates.add(block.state()); + } + } + + CompoundTag root = new CompoundTag(); + root.put("size", intList(volume.sizeX(), volume.sizeY(), volume.sizeZ())); + root.put("palette", palette(paletteStates, format)); + root.put("blocks", blocks(orderedBlocks, paletteIndexes)); + root.put("entities", new ListTag<>(CompoundTag.class)); + root.putInt("DataVersion", target.getWorldDataVersion()); + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (GZIPOutputStream gzip = new GZIPOutputStream(output)) { + NBTUtil.write(new NamedTag("", root), gzip, false); + } + return output.toByteArray(); + } + + private static MCABlockStateCodecSupport.Format paletteFormat(DataVersion target) { + return switch (target) { + case V26_3 -> MCABlockStateCodecSupport.Format.LOWERCASE; + case V1_21_1, V26_1_2, V26_2 -> MCABlockStateCodecSupport.Format.CAPITALIZED; + case UNSUPPORTED -> throw new IllegalArgumentException("Structure templates need a supported target version"); + }; + } + + private ListTag palette(List states, MCABlockStateCodecSupport.Format format) { + ListTag palette = new ListTag<>(CompoundTag.class); + for (VanillaBlockState state : states) { + palette.add(state.toNbt(format)); + } + return palette; + } + + private ListTag blocks(List entries, Map paletteIndexes) { + ListTag blocks = new ListTag<>(CompoundTag.class); + for (TemplateBlock entry : entries) { + CompoundTag block = new CompoundTag(); + block.put("pos", intList(entry.x(), entry.y(), entry.z())); + block.putInt("state", paletteIndexes.get(entry.state().canonical())); + if (entry.nbt() != null) { + block.put("nbt", entry.nbt()); + } + blocks.add(block); + } + return blocks; + } + + private ListTag intList(int... values) { + ListTag list = new ListTag<>(IntTag.class); + for (int value : values) { + list.add(new IntTag(value)); + } + return list; + } + + private record BlockPosition(int x, int y, int z) { + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/TemplateBlock.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/TemplateBlock.java new file mode 100644 index 000000000..5a0c973c1 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/TemplateBlock.java @@ -0,0 +1,11 @@ +package art.arcane.iris.pack.datapack.export; + +import art.arcane.volmlib.util.nbt.tag.CompoundTag; + +import java.util.Objects; + +public record TemplateBlock(int x, int y, int z, VanillaBlockState state, CompoundTag nbt) { + public TemplateBlock { + Objects.requireNonNull(state, "Template block state"); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/TemplateVolume.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/TemplateVolume.java new file mode 100644 index 000000000..5cc790cac --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/TemplateVolume.java @@ -0,0 +1,22 @@ +package art.arcane.iris.pack.datapack.export; + +import java.util.List; + +public record TemplateVolume(int sizeX, int sizeY, int sizeZ, List blocks) { + public TemplateVolume { + if (sizeX < 0 || sizeY < 0 || sizeZ < 0) { + throw new IllegalArgumentException("Template size must not be negative, got " + sizeX + "x" + sizeY + "x" + sizeZ); + } + blocks = List.copyOf(blocks); + for (TemplateBlock block : blocks) { + if (block.x() < 0 || block.y() < 0 || block.z() < 0 || block.x() >= sizeX || block.y() >= sizeY || block.z() >= sizeZ) { + throw new IllegalArgumentException("Template block at " + block.x() + "," + block.y() + "," + block.z() + + " lies outside the " + sizeX + "x" + sizeY + "x" + sizeZ + " template"); + } + } + } + + public int footprint() { + return Math.max(sizeX, sizeZ); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/TileEntityNbt.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/TileEntityNbt.java new file mode 100644 index 000000000..24aaadd24 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/TileEntityNbt.java @@ -0,0 +1,324 @@ +package art.arcane.iris.pack.datapack.export; + +import art.arcane.volmlib.util.nbt.io.SNBTDeserializer; +import art.arcane.volmlib.util.nbt.tag.ByteTag; +import art.arcane.volmlib.util.nbt.tag.CompoundTag; +import art.arcane.volmlib.util.nbt.tag.DoubleTag; +import art.arcane.volmlib.util.nbt.tag.EndTag; +import art.arcane.volmlib.util.nbt.tag.FloatTag; +import art.arcane.volmlib.util.nbt.tag.IntArrayTag; +import art.arcane.volmlib.util.nbt.tag.IntTag; +import art.arcane.volmlib.util.nbt.tag.ListTag; +import art.arcane.volmlib.util.nbt.tag.LongTag; +import art.arcane.volmlib.util.nbt.tag.ShortTag; +import art.arcane.volmlib.util.nbt.tag.StringTag; +import art.arcane.volmlib.util.nbt.tag.Tag; + +import java.io.IOException; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.TreeMap; + +public final class TileEntityNbt { + private static final String SNBT_PROPERTY = "nbt"; + private static final String ID = "id"; + private static final String PROFILE = "profile"; + private static final String UUID = "UUID"; + private static final int MAX_DEPTH = 64; + private static final Set POSITION_KEYS = Set.of("x", "y", "z"); + private static final Map BLOCK_ENTITIES = Map.ofEntries( + Map.entry("minecraft:chest", "minecraft:chest"), + Map.entry("minecraft:trapped_chest", "minecraft:trapped_chest"), + Map.entry("minecraft:ender_chest", "minecraft:ender_chest"), + Map.entry("minecraft:barrel", "minecraft:barrel"), + Map.entry("minecraft:furnace", "minecraft:furnace"), + Map.entry("minecraft:smoker", "minecraft:smoker"), + Map.entry("minecraft:blast_furnace", "minecraft:blast_furnace"), + Map.entry("minecraft:dispenser", "minecraft:dispenser"), + Map.entry("minecraft:dropper", "minecraft:dropper"), + Map.entry("minecraft:hopper", "minecraft:hopper"), + Map.entry("minecraft:brewing_stand", "minecraft:brewing_stand"), + Map.entry("minecraft:beacon", "minecraft:beacon"), + Map.entry("minecraft:lectern", "minecraft:lectern"), + Map.entry("minecraft:jukebox", "minecraft:jukebox"), + Map.entry("minecraft:enchanting_table", "minecraft:enchanting_table"), + Map.entry("minecraft:conduit", "minecraft:conduit"), + Map.entry("minecraft:bell", "minecraft:bell"), + Map.entry("minecraft:crafter", "minecraft:crafter"), + Map.entry("minecraft:daylight_detector", "minecraft:daylight_detector"), + Map.entry("minecraft:comparator", "minecraft:comparator"), + Map.entry("minecraft:end_gateway", "minecraft:end_gateway"), + Map.entry("minecraft:end_portal", "minecraft:end_portal"), + Map.entry("minecraft:sculk_sensor", "minecraft:sculk_sensor"), + Map.entry("minecraft:calibrated_sculk_sensor", "minecraft:calibrated_sculk_sensor"), + Map.entry("minecraft:sculk_catalyst", "minecraft:sculk_catalyst"), + Map.entry("minecraft:sculk_shrieker", "minecraft:sculk_shrieker"), + Map.entry("minecraft:chiseled_bookshelf", "minecraft:chiseled_bookshelf"), + Map.entry("minecraft:decorated_pot", "minecraft:decorated_pot"), + Map.entry("minecraft:creaking_heart", "minecraft:creaking_heart"), + Map.entry("minecraft:trial_spawner", "minecraft:trial_spawner"), + Map.entry("minecraft:vault", "minecraft:vault"), + Map.entry("minecraft:beehive", "minecraft:beehive"), + Map.entry("minecraft:bee_nest", "minecraft:beehive"), + Map.entry("minecraft:spawner", "minecraft:mob_spawner"), + Map.entry("minecraft:campfire", "minecraft:campfire"), + Map.entry("minecraft:soul_campfire", "minecraft:campfire"), + Map.entry("minecraft:command_block", "minecraft:command_block"), + Map.entry("minecraft:chain_command_block", "minecraft:command_block"), + Map.entry("minecraft:repeating_command_block", "minecraft:command_block"), + Map.entry("minecraft:suspicious_sand", "minecraft:brushable_block"), + Map.entry("minecraft:suspicious_gravel", "minecraft:brushable_block")); + private static final List> SUFFIX_BLOCK_ENTITIES = List.of( + Map.entry("_hanging_sign", "minecraft:hanging_sign"), + Map.entry("_sign", "minecraft:sign"), + Map.entry("_banner", "minecraft:banner"), + Map.entry("_head", "minecraft:skull"), + Map.entry("_skull", "minecraft:skull"), + Map.entry("_bed", "minecraft:bed"), + Map.entry("shulker_box", "minecraft:shulker_box"), + Map.entry("_shelf", "minecraft:shelf"), + Map.entry("copper_golem_statue", "minecraft:copper_golem_statue")); + private static final Set BYTE_FIELDS = Set.of( + "Slot", "Count", "has_glowing_text", "is_waxed", "auto", "conditionMet", "powered", "TrackOutput", + "UpdateLastExecution", "OnGround", "Invulnerable", "Silent", "NoGravity", "Glowing", + "PersistenceRequired", "CanPickUpLoot", "LeftHanded", "NoAI", "ExactTeleport", "show_particles", + "show_icon", "ambient"); + private static final Set SHORT_FIELDS = Set.of( + "Delay", "MinSpawnDelay", "MaxSpawnDelay", "SpawnCount", "MaxNearbyEntities", "RequiredPlayerRange", + "SpawnRange", "BurnTime", "CookTime", "CookTimeTotal", "lit_time_remaining", "lit_total_time", + "cooking_time_spent", "cooking_total_time", "Fire", "Air", "HurtTime", "DeathTime"); + private static final Set LONG_FIELDS = Set.of("LootTableSeed", "Age", "LastExecution", "ticks_since_song_started"); + private static final Set FLOAT_FIELDS = Set.of("Health", "AbsorptionAmount", "Rotation", "HandDropChances", "ArmorDropChances"); + + private TileEntityNbt() { + } + + public static Optional blockEntityId(String blockKey) { + String key = normalizeBlockKey(blockKey); + if (key.isEmpty()) { + return Optional.empty(); + } + String exact = BLOCK_ENTITIES.get(key); + if (exact != null) { + return Optional.of(exact); + } + if (!key.startsWith("minecraft:")) { + return Optional.empty(); + } + for (Map.Entry suffix : SUFFIX_BLOCK_ENTITIES) { + if (key.endsWith(suffix.getKey())) { + return Optional.of(suffix.getValue()); + } + } + return Optional.empty(); + } + + public static Optional toNbt(String blockKey, Map properties) { + Optional blockEntity = blockEntityId(blockKey); + if (blockEntity.isEmpty() || properties == null) { + return Optional.empty(); + } + CompoundTag nbt = parseSnbt(properties.get(SNBT_PROPERTY)); + if (nbt == null) { + nbt = compound(properties, "", 0); + } + for (String key : POSITION_KEYS) { + nbt.remove(key); + } + nbt.remove(SNBT_PROPERTY); + if (nbt.size() == 0) { + return Optional.empty(); + } + if (!nbt.containsKey(ID)) { + nbt.putString(ID, blockEntity.get()); + } + return Optional.of(nbt); + } + + private static String normalizeBlockKey(String blockKey) { + if (blockKey == null) { + return ""; + } + String trimmed = blockKey.trim().toLowerCase(Locale.ROOT); + int properties = trimmed.indexOf('['); + String base = properties < 0 ? trimmed : trimmed.substring(0, properties); + if (base.isEmpty()) { + return ""; + } + return base.indexOf(':') < 0 ? "minecraft:" + base : base; + } + + private static CompoundTag parseSnbt(Object snbt) { + if (!(snbt instanceof String text) || text.isBlank()) { + return null; + } + try { + Tag parsed = new SNBTDeserializer().fromString(text); + return parsed instanceof CompoundTag compound ? compound : null; + } catch (IOException | RuntimeException exception) { + return null; + } + } + + private static CompoundTag compound(Map values, String parent, int depth) { + CompoundTag compound = new CompoundTag(); + if (depth > MAX_DEPTH) { + return compound; + } + Map sorted = new TreeMap<>(); + for (Map.Entry entry : values.entrySet()) { + if (entry.getKey() != null && entry.getValue() != null) { + sorted.put(String.valueOf(entry.getKey()), entry.getValue()); + } + } + if (depth == 0) { + sorted.remove(SNBT_PROPERTY); + } + for (Map.Entry entry : sorted.entrySet()) { + Tag tag = tag(entry.getKey(), parent, entry.getValue(), depth + 1); + if (tag != null) { + compound.put(entry.getKey(), tag); + } + } + return compound; + } + + private static Tag tag(String key, String parent, Object value, int depth) { + if (value instanceof Map map) { + return compound(map, key, depth); + } + if (value instanceof List list) { + return list(key, parent, list, depth); + } + if (value instanceof Boolean bool) { + return new ByteTag(bool); + } + if (value instanceof Number number) { + return number(numberKind(key, number), number); + } + return new StringTag(String.valueOf(value)); + } + + private static Tag list(String key, String parent, List values, int depth) { + if (intArray(key, parent) && allNumbers(values)) { + int[] ints = new int[values.size()]; + for (int index = 0; index < ints.length; index++) { + ints[index] = ((Number) values.get(index)).intValue(); + } + return new IntArrayTag(ints); + } + NumberKind listKind = allNumbers(values) ? listNumberKind(key, values) : null; + ListTag list = ListTag.createUnchecked(EndTag.class); + Class elementType = null; + for (Object value : values) { + if (value == null) { + continue; + } + Tag element = listKind == null ? tag(key, parent, value, depth + 1) : number(listKind, (Number) value); + if (element == null) { + continue; + } + if (elementType == null) { + elementType = element.getClass(); + } + if (elementType != element.getClass()) { + return stringList(values); + } + list.addUnchecked(element); + } + return list; + } + + private static ListTag stringList(List values) { + ListTag list = ListTag.createUnchecked(EndTag.class); + for (Object value : values) { + if (value != null) { + list.addUnchecked(new StringTag(String.valueOf(value))); + } + } + return list; + } + + private static boolean intArray(String key, String parent) { + return UUID.equals(key) || (ID.equals(key) && PROFILE.equals(parent)); + } + + private static boolean allNumbers(List values) { + if (values.isEmpty()) { + return false; + } + for (Object value : values) { + if (!(value instanceof Number)) { + return false; + } + } + return true; + } + + private static NumberKind listNumberKind(String key, List values) { + NumberKind declared = declaredKind(key); + if (declared != null) { + return declared; + } + NumberKind widest = NumberKind.INT; + for (Object value : values) { + NumberKind kind = inferredKind((Number) value); + if (kind.ordinal() > widest.ordinal()) { + widest = kind; + } + } + return widest; + } + + private static NumberKind numberKind(String key, Number number) { + NumberKind declared = declaredKind(key); + return declared == null ? inferredKind(number) : declared; + } + + private static NumberKind declaredKind(String key) { + if (BYTE_FIELDS.contains(key)) { + return NumberKind.BYTE; + } + if (SHORT_FIELDS.contains(key)) { + return NumberKind.SHORT; + } + if (LONG_FIELDS.contains(key)) { + return NumberKind.LONG; + } + if (FLOAT_FIELDS.contains(key)) { + return NumberKind.FLOAT; + } + return null; + } + + private static NumberKind inferredKind(Number number) { + if (number instanceof Double || number instanceof Float) { + return NumberKind.DOUBLE; + } + long value = number.longValue(); + return value >= Integer.MIN_VALUE && value <= Integer.MAX_VALUE ? NumberKind.INT : NumberKind.LONG; + } + + private static Tag number(NumberKind kind, Number number) { + return switch (kind) { + case BYTE -> new ByteTag(number.byteValue()); + case SHORT -> new ShortTag(number.shortValue()); + case INT -> new IntTag(number.intValue()); + case LONG -> new LongTag(number.longValue()); + case FLOAT -> new FloatTag(number.floatValue()); + case DOUBLE -> new DoubleTag(number.doubleValue()); + }; + } + + private enum NumberKind { + BYTE, + SHORT, + INT, + LONG, + FLOAT, + DOUBLE + } +} diff --git a/core/src/main/java/art/arcane/iris/structure/export/VanillaBlockState.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/VanillaBlockState.java similarity index 82% rename from core/src/main/java/art/arcane/iris/structure/export/VanillaBlockState.java rename to core/src/main/java/art/arcane/iris/pack/datapack/export/VanillaBlockState.java index 321eb1f67..d23de747b 100644 --- a/core/src/main/java/art/arcane/iris/structure/export/VanillaBlockState.java +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/VanillaBlockState.java @@ -1,4 +1,4 @@ -package art.arcane.iris.structure.export; +package art.arcane.iris.pack.datapack.export; import art.arcane.volmlib.util.nbt.tag.CompoundTag; import art.arcane.volmlib.util.nbt.mca.MCABlockStateCodecSupport; @@ -9,15 +9,15 @@ import java.util.Map; import java.util.TreeMap; import java.util.regex.Pattern; -record VanillaBlockState(String name, Map properties) { +public record VanillaBlockState(String name, Map properties) { private static final Pattern PROPERTY_NAME = Pattern.compile("[a-z0-9_.-]+"); private static final Pattern PROPERTY_VALUE = Pattern.compile("[a-z0-9_.-]+"); - VanillaBlockState { + public VanillaBlockState { properties = Collections.unmodifiableMap(new LinkedHashMap<>(properties)); } - static VanillaBlockState parse(String value) { + public static VanillaBlockState parse(String value) { if (value == null) { throw new IllegalArgumentException("Block state must not be null"); } @@ -58,7 +58,18 @@ record VanillaBlockState(String name, Map properties) { return new VanillaBlockState(name, properties); } - String canonical() { + public VanillaBlockState waterlogged(boolean submerged) { + String value = Boolean.toString(submerged); + String current = properties.get("waterlogged"); + if (current == null || current.equals(value)) { + return this; + } + Map changed = new LinkedHashMap<>(properties); + changed.put("waterlogged", value); + return new VanillaBlockState(name, changed); + } + + public String canonical() { if (properties.isEmpty()) { return name; } @@ -74,7 +85,7 @@ record VanillaBlockState(String name, Map properties) { return builder.append(']').toString(); } - CompoundTag toNbt(MCABlockStateCodecSupport.Format format) { + public CompoundTag toNbt(MCABlockStateCodecSupport.Format format) { CompoundTag state = new CompoundTag(); state.putString(format.nameKey(), name); if (!properties.isEmpty()) { diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/VanillaResourceIdentifier.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/VanillaResourceIdentifier.java new file mode 100644 index 000000000..c8bfab586 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/VanillaResourceIdentifier.java @@ -0,0 +1,104 @@ +package art.arcane.iris.pack.datapack.export; + +import java.util.Locale; +import java.util.StringJoiner; +import java.util.regex.Pattern; + +public final class VanillaResourceIdentifier { + private static final Pattern NAMESPACE = Pattern.compile("[a-z0-9_.-]+"); + private static final Pattern PATH = Pattern.compile("[a-z0-9/._-]+"); + private static final Pattern INVALID_PATH_CHARACTER = Pattern.compile("[^a-z0-9._-]"); + + private VanillaResourceIdentifier() { + } + + public static boolean validNamespace(String value) { + return value != null && NAMESPACE.matcher(value).matches(); + } + + public static boolean validPath(String value) { + if (value == null + || !PATH.matcher(value).matches() + || value.startsWith("/") + || value.endsWith("/") + || value.contains("//")) { + return false; + } + for (String segment : value.split("/")) { + if (segment.equals(".") || segment.equals("..")) { + return false; + } + } + return true; + } + + public static boolean validIdentifier(String value) { + if (value == null) { + return false; + } + int separator = value.indexOf(':'); + if (separator <= 0 || separator != value.lastIndexOf(':')) { + return false; + } + return validNamespace(value.substring(0, separator)) && validPath(value.substring(separator + 1)); + } + + public static String requireNamespace(String value, String role) { + if (!validNamespace(value)) { + throw invalid(value, role); + } + return value; + } + + public static String requirePath(String value, String role) { + if (!validPath(value)) { + throw invalid(value, role); + } + return value; + } + + public static String requireIdentifier(String value, String role) { + if (!validIdentifier(value)) { + throw invalid(value, role); + } + return value; + } + + public static String sanitizePath(String value) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("Cannot derive a resource path from '" + value + "'"); + } + String lowered = value.trim().replace('\\', '/').toLowerCase(Locale.ROOT); + StringJoiner path = new StringJoiner("/"); + for (String segment : lowered.split("/")) { + if (segment.isEmpty()) { + continue; + } + String cleaned = INVALID_PATH_CHARACTER.matcher(segment).replaceAll("_"); + path.add(cleaned.equals(".") || cleaned.equals("..") ? cleaned.replace('.', '_') : cleaned); + } + String result = path.toString(); + if (result.isEmpty()) { + throw new IllegalArgumentException("Cannot derive a resource path from '" + value + "'"); + } + return result; + } + + public static String normalizeConnectorIdentifier(String value) { + if (value == null || value.isBlank()) { + return "minecraft:empty"; + } + String normalized = value.trim(); + if (normalized.indexOf(':') < 0) { + normalized = "minecraft:" + normalized; + } + if (!validIdentifier(normalized)) { + throw new IllegalArgumentException("Invalid resource identifier '" + value + "'"); + } + return normalized; + } + + private static IllegalArgumentException invalid(String value, String role) { + return new IllegalArgumentException("Invalid " + role + " '" + value + "'"); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/WorldgenDatapackExporter.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/WorldgenDatapackExporter.java new file mode 100644 index 000000000..7dfdc126f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/WorldgenDatapackExporter.java @@ -0,0 +1,170 @@ +package art.arcane.iris.pack.datapack.export; + +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.pack.PackValidationResult; +import art.arcane.iris.pack.PackValidator; +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; +import art.arcane.iris.pack.datapack.export.compile.PackCompiler; +import art.arcane.iris.pack.datapack.export.compile.noise.NoiseStyleCompiler; +import art.arcane.iris.pack.datapack.export.emit.UnusedResourcePruner; +import art.arcane.iris.pack.datapack.export.emit.WorldgenJsonWriter; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportIr; +import art.arcane.iris.pack.datapack.export.lower.ExportCapabilities; +import art.arcane.iris.pack.datapack.export.lower.GenerationBudget; +import art.arcane.iris.pack.datapack.export.lower.GenerationCostCounter; +import art.arcane.iris.pack.datapack.export.lower.VanillaBounds; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.spi.IrisLogging; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.SortedMap; +import java.util.function.Function; +import java.util.regex.Pattern; + +public final class WorldgenDatapackExporter implements WorldgenExportService { + private static final String STAGING_PREFIX = ".iris-worldgen-export-"; + private static final Pattern UNSAFE_OUTPUT_CHARACTER = Pattern.compile("[^A-Za-z0-9._-]"); + + private final Function writers; + private final PackCompiler compiler; + + public WorldgenDatapackExporter(Function writers, PackCompiler compiler) { + this.writers = Objects.requireNonNull(writers); + this.compiler = Objects.requireNonNull(compiler); + } + + public static WorldgenDatapackExporter create() { + return new WorldgenDatapackExporter(WorldgenJsonWriter::forTarget, PackCompiler.create()); + } + + @Override + public WorldgenExportResult export(WorldgenExportRequest request, WorldgenExportProgress progress) { + Objects.requireNonNull(request); + Objects.requireNonNull(progress); + ExportPlan plan; + try { + plan = plan(request); + } catch (IllegalArgumentException exception) { + return WorldgenExportResult.rejected(null, exception.getMessage(), List.of()); + } + Path destination = plan.destination(); + if (Files.exists(destination) && !request.replace()) { + return rejected(destination, "Export output already exists: " + destination + ". Pass replace=true to overwrite it."); + } + if (!DatapackExportDestinations.claim(destination)) { + return rejected(destination, "An export to " + destination + " is already running."); + } + try { + return exportClaimed(request, plan, progress); + } finally { + DatapackExportDestinations.release(destination); + } + } + + private ExportPlan plan(WorldgenExportRequest request) { + DataVersion target = WorldgenExportTargets.resolve(request.version()); + WorldgenExportMode mode = WorldgenExportMode.parse(request.mode()); + DatapackExportFormat format = DatapackExportDestinations.parseFormat(request.format()); + String namespace = VanillaResourceIdentifier.requireNamespace(request.namespace(), "export namespace"); + String output = request.output().isEmpty() ? defaultOutput(request.dimension(), target) : request.output(); + return new ExportPlan(target, mode, format, namespace, DatapackExportDestinations.resolve(request.exportRoot(), output, format)); + } + + private WorldgenExportResult exportClaimed(WorldgenExportRequest request, ExportPlan plan, WorldgenExportProgress progress) { + Path destination = plan.destination(); + File pack = request.pack().toFile(); + try { + progress.stage("validate"); + PackValidationResult validation = PackValidator.validateForPackaging(pack); + if (!validation.isLoadable()) { + return rejected(destination, "Pack '" + validation.getPackName() + "' failed validation: " + + String.join("; ", validation.getBlockingErrors())); + } + progress.stage("load"); + IrisData data = IrisData.openRuntime(pack); + try { + IrisDimension dimension = data.getDimensionLoader().load(request.dimension(), false); + if (dimension == null) { + return rejected(destination, "Dimension '" + request.dimension() + "' was not found in pack '" + pack.getName() + "'."); + } + PackCompileContext context = compileContext(data, dimension, plan, request.objects()); + ExportIr ir = compiler.compile(context, progress); + return publish(request, plan, ir, context.biomeCodes().loadKeyByBiomeId(), progress); + } finally { + data.close(); + } + } catch (IOException exception) { + IrisLogging.reportError("Worldgen datapack publication to " + destination + " failed", exception); + return WorldgenExportResult.failed(destination, "Datapack publication failed: " + exception.getMessage(), List.of()); + } catch (RuntimeException exception) { + IrisLogging.reportError("Worldgen datapack export of dimension '" + request.dimension() + "' failed", exception); + return WorldgenExportResult.failed(destination, "Datapack export failed: " + exception.getMessage(), List.of()); + } + } + + private WorldgenExportResult publish(WorldgenExportRequest request, ExportPlan plan, ExportIr ir, Map loadKeyByBiomeId, + WorldgenExportProgress progress) throws IOException { + progress.stage("write"); + GenerationCost routerCost = GenerationCostCounter.count(ir.router(), VanillaBounds.of(ir.namespace(), ir.noises()), ir.target()); + SortedMap files = writers.apply(plan.target()).write(ir); + progress.resources(files.size(), files.size()); + progress.stage("prune"); + UnusedResourcePruner.Pruned pruned = UnusedResourcePruner.prune(files); + progress.trimmed(pruned.trim()); + GenerationCost cost = routerCost.withDensityFunctionBytes(GenerationCost.densityFunctionBytes(pruned.files())); + progress.stage("publish"); + AtomicDatapackPublisher.Publication publication = new AtomicDatapackPublisher().publish( + pruned.files(), + plan.destination(), + plan.format(), + request.replace(), + STAGING_PREFIX); + List costDiagnostics = GenerationBudget.check(cost, ir.dimension().name()); + List diagnostics = new ArrayList<>(ir.diagnostics().size() + pruned.diagnostics().size() + costDiagnostics.size()); + diagnostics.addAll(ir.diagnostics()); + diagnostics.addAll(costDiagnostics); + diagnostics.addAll(pruned.diagnostics()); + return new WorldgenExportResult( + WorldgenExportResult.Status.EXPORTED, + plan.destination(), + String.join("; ", publication.cleanupWarnings()), + diagnostics, + loadKeyByBiomeId, + pruned.trim(), + cost); + } + + private static PackCompileContext compileContext(IrisData data, IrisDimension dimension, ExportPlan plan, boolean objects) { + List diagnostics = new ArrayList<>(); + return PackCompileContext.builder() + .data(data) + .dimension(dimension) + .capabilities(ExportCapabilities.forTarget(plan.target())) + .namespace(plan.namespace()) + .mode(plan.mode()) + .objects(objects) + .diagnostics(diagnostics) + .styles(NoiseStyleCompiler.create(diagnostics)) + .build(); + } + + private static String defaultOutput(String dimension, DataVersion target) { + return UNSAFE_OUTPUT_CHARACTER.matcher(dimension.replace('/', '-')).replaceAll("-") + "-" + target.getVersion(); + } + + private static WorldgenExportResult rejected(Path destination, String message) { + return WorldgenExportResult.rejected(destination, message, List.of()); + } + + private record ExportPlan(DataVersion target, WorldgenExportMode mode, DatapackExportFormat format, String namespace, Path destination) { + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/WorldgenExportMode.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/WorldgenExportMode.java new file mode 100644 index 000000000..2ad8f9802 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/WorldgenExportMode.java @@ -0,0 +1,21 @@ +package art.arcane.iris.pack.datapack.export; + +import java.util.Locale; + +public enum WorldgenExportMode { + PRESET, + DIMENSION, + OVERWORLD; + + public static WorldgenExportMode parse(String raw) { + if (raw == null || raw.isBlank()) { + return OVERWORLD; + } + return switch (raw.trim().toLowerCase(Locale.ROOT)) { + case "preset" -> PRESET; + case "dimension" -> DIMENSION; + case "overworld" -> OVERWORLD; + default -> throw new IllegalArgumentException("Unknown export mode '" + raw + "'. Expected: preset, dimension, overworld"); + }; + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/WorldgenExportProgress.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/WorldgenExportProgress.java new file mode 100644 index 000000000..5c043157b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/WorldgenExportProgress.java @@ -0,0 +1,20 @@ +package art.arcane.iris.pack.datapack.export; + +public interface WorldgenExportProgress { + WorldgenExportProgress NONE = new WorldgenExportProgress() { + @Override + public void stage(String stage) { + } + + @Override + public void resources(int done, int total) { + } + }; + + void stage(String stage); + + void resources(int done, int total); + + default void trimmed(ContentTrim trim) { + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/WorldgenExportRequest.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/WorldgenExportRequest.java new file mode 100644 index 000000000..77fd4bc46 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/WorldgenExportRequest.java @@ -0,0 +1,112 @@ +package art.arcane.iris.pack.datapack.export; + +import java.nio.file.Path; +import java.util.Objects; + +public record WorldgenExportRequest( + Path pack, + String dimension, + String version, + String mode, + String namespace, + String output, + String format, + boolean objects, + boolean replace, + Path exportRoot +) { + public static final String DEFAULT_MODE = "overworld"; + public static final String DEFAULT_NAMESPACE = "iris_export"; + public static final String DEFAULT_FORMAT = "zip"; + + public WorldgenExportRequest { + if (pack == null) { + throw new IllegalArgumentException("Worldgen export request requires a pack directory"); + } + if (dimension == null || dimension.isBlank()) { + throw new IllegalArgumentException("Worldgen export request requires a dimension"); + } + if (exportRoot == null) { + throw new IllegalArgumentException("Worldgen export request requires an export root"); + } + Objects.requireNonNull(version); + Objects.requireNonNull(mode); + Objects.requireNonNull(namespace); + Objects.requireNonNull(output); + Objects.requireNonNull(format); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private Path pack; + private String dimension = ""; + private String version = ""; + private String mode = DEFAULT_MODE; + private String namespace = DEFAULT_NAMESPACE; + private String output = ""; + private String format = DEFAULT_FORMAT; + private boolean objects = true; + private boolean replace; + private Path exportRoot; + + private Builder() { + } + + public Builder pack(Path value) { + pack = Objects.requireNonNull(value); + return this; + } + + public Builder dimension(String value) { + dimension = Objects.requireNonNull(value).trim(); + return this; + } + + public Builder version(String value) { + version = Objects.requireNonNull(value).trim(); + return this; + } + + public Builder mode(String value) { + mode = Objects.requireNonNull(value).trim(); + return this; + } + + public Builder namespace(String value) { + namespace = Objects.requireNonNull(value).trim(); + return this; + } + + public Builder output(String value) { + output = Objects.requireNonNull(value).trim(); + return this; + } + + public Builder format(String value) { + format = Objects.requireNonNull(value).trim(); + return this; + } + + public Builder objects(boolean value) { + objects = value; + return this; + } + + public Builder replace(boolean value) { + replace = value; + return this; + } + + public Builder exportRoot(Path value) { + exportRoot = Objects.requireNonNull(value); + return this; + } + + public WorldgenExportRequest build() { + return new WorldgenExportRequest(pack, dimension, version, mode, namespace, output, format, objects, replace, exportRoot); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/WorldgenExportResult.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/WorldgenExportResult.java new file mode 100644 index 000000000..495028690 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/WorldgenExportResult.java @@ -0,0 +1,48 @@ +package art.arcane.iris.pack.datapack.export; + +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; + +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; + +public record WorldgenExportResult(Status status, Path output, String message, List diagnostics, + Map loadKeyByBiomeId, ContentTrim trim, GenerationCost cost) { + public WorldgenExportResult { + Objects.requireNonNull(status); + if (status == Status.EXPORTED) { + Objects.requireNonNull(output); + } + message = message == null ? "" : message; + diagnostics = List.copyOf(diagnostics); + loadKeyByBiomeId = Collections.unmodifiableSortedMap(new TreeMap<>(loadKeyByBiomeId)); + Objects.requireNonNull(trim); + Objects.requireNonNull(cost); + } + + public static WorldgenExportResult exported(Path output, List diagnostics, Map loadKeyByBiomeId, + ContentTrim trim, GenerationCost cost) { + return new WorldgenExportResult(Status.EXPORTED, output, "", diagnostics, loadKeyByBiomeId, trim, cost); + } + + public static WorldgenExportResult rejected(Path output, String message, List diagnostics) { + return new WorldgenExportResult(Status.REJECTED, output, message, diagnostics, Map.of(), ContentTrim.NONE, GenerationCost.NONE); + } + + public static WorldgenExportResult failed(Path output, String message, List diagnostics) { + return new WorldgenExportResult(Status.FAILED, output, message, diagnostics, Map.of(), ContentTrim.NONE, GenerationCost.NONE); + } + + public boolean isSuccess() { + return status == Status.EXPORTED; + } + + public enum Status { + EXPORTED, + REJECTED, + FAILED + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/WorldgenExportService.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/WorldgenExportService.java new file mode 100644 index 000000000..48c6da8f8 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/WorldgenExportService.java @@ -0,0 +1,5 @@ +package art.arcane.iris.pack.datapack.export; + +public interface WorldgenExportService { + WorldgenExportResult export(WorldgenExportRequest request, WorldgenExportProgress progress); +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/WorldgenExportServices.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/WorldgenExportServices.java new file mode 100644 index 000000000..e50558827 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/WorldgenExportServices.java @@ -0,0 +1,23 @@ +package art.arcane.iris.pack.datapack.export; + +import java.util.Objects; +import java.util.concurrent.atomic.AtomicReference; + +public final class WorldgenExportServices { + private static final AtomicReference SERVICE = new AtomicReference<>(); + + private WorldgenExportServices() { + } + + public static void install(WorldgenExportService service) { + SERVICE.set(Objects.requireNonNull(service, "Datapack export service")); + } + + public static WorldgenExportService get() { + WorldgenExportService service = SERVICE.get(); + if (service != null) { + return service; + } + return SERVICE.updateAndGet(current -> current == null ? WorldgenDatapackExporter.create() : current); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/WorldgenExportTargets.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/WorldgenExportTargets.java new file mode 100644 index 000000000..f811be0c7 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/WorldgenExportTargets.java @@ -0,0 +1,44 @@ +package art.arcane.iris.pack.datapack.export; + +import art.arcane.iris.pack.datapack.DataVersion; + +import java.util.ArrayList; +import java.util.List; +import java.util.StringJoiner; + +public final class WorldgenExportTargets { + private static final DataVersion OLDEST = DataVersion.V26_3; + public static final List SUPPORTED = supported(); + + private WorldgenExportTargets() { + } + + public static DataVersion resolve(String raw) { + if (raw == null || raw.isBlank()) { + return SUPPORTED.getLast(); + } + DataVersion version = DataVersion.forMinecraftVersion(raw.trim()); + if (!SUPPORTED.contains(version)) { + throw unsupported(raw); + } + return version; + } + + public static IllegalArgumentException unsupported(String raw) { + StringJoiner supported = new StringJoiner(", "); + for (DataVersion version : SUPPORTED) { + supported.add(version.getVersion()); + } + return new IllegalArgumentException("Unsupported datapack export version '" + raw + "'. Supported: " + supported); + } + + private static List supported() { + List versions = new ArrayList<>(); + for (DataVersion version : DataVersion.values()) { + if (version.ordinal() >= OLDEST.ordinal()) { + versions.add(version); + } + } + return List.copyOf(versions); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/BlockStateResolver.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/BlockStateResolver.java new file mode 100644 index 000000000..43f4c4f23 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/BlockStateResolver.java @@ -0,0 +1,128 @@ +package art.arcane.iris.pack.datapack.export.compile; + +import art.arcane.iris.generation.block.IrisBlockData; +import art.arcane.iris.pack.ContentKeyValidator; +import art.arcane.iris.pack.datapack.export.VanillaBlockState; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.pack.validation.ContentGate; +import art.arcane.iris.pack.validation.LegacyBlockRenames; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformRegistries; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +public final class BlockStateResolver { + private final IrisData data; + private final PlatformRegistries registries; + private final Set knownBlocks; + private final List diagnostics; + private final Map resolutions = new HashMap<>(); + + public BlockStateResolver(IrisData data, PlatformRegistries registries, List diagnostics) { + this.data = Objects.requireNonNull(data); + this.registries = registries; + this.knownBlocks = registries == null ? Set.of() : knownBlocks(registries.blockTypeKeys()); + this.diagnostics = Objects.requireNonNull(diagnostics); + } + + public static BlockStateResolver forContext(PackCompileContext context) { + PlatformRegistries registries = IrisPlatforms.isBound() ? IrisPlatforms.get().registries() : null; + return new BlockStateResolver(context.data(), registries, context.diagnostics()); + } + + public Optional resolve(IrisBlockData block, String resource) { + if (block == null || block.getBlock() == null || block.getBlock().isBlank()) { + return Optional.empty(); + } + return resolve(stateKey(block), resource); + } + + public Optional resolve(String rawState, String resource) { + String normalized = ContentGate.normalizeState(rawState); + if (normalized == null) { + return Optional.empty(); + } + Resolution resolution = resolutions.computeIfAbsent(normalized, this::parse); + if (resolution.problem() != null) { + ExportDiagnostic diagnostic = new ExportDiagnostic(ExportDiagnostic.Severity.WARNING, ExportLimit.BLOCK_STATE, resource, resolution.problem()); + if (!diagnostics.contains(diagnostic)) { + diagnostics.add(diagnostic); + } + } + return Optional.ofNullable(resolution.state()); + } + + private String stateKey(IrisBlockData block) { + IrisBlockData custom = data.getBlockLoader() == null ? null : data.getBlockLoader().load(block.getBlock(), false); + if (custom == null || custom.getBlock() == null || custom.getBlock().equals(block.getBlock())) { + return block.stateKey(); + } + IrisBlockData merged = IrisBlockData.from(custom.stateKey()); + merged.getData().putAll(block.getData()); + return merged.stateKey(); + } + + private Resolution parse(String normalized) { + String renamed = renamed(normalized); + if (!renamed.startsWith("minecraft:")) { + return new Resolution(null, "Block '" + renamed + "' is not a Minecraft block and is not exported"); + } + String problem = null; + String validated = renamed; + List propertyProblems = registries == null ? List.of() : ContentKeyValidator.validateBlockStateProperties(registries, List.of(renamed)); + if (!propertyProblems.isEmpty()) { + validated = ContentGate.baseKey(renamed); + problem = String.join("; ", propertyProblems) + "; the block exports in its default state"; + } + try { + return new Resolution(VanillaBlockState.parse(validated), problem); + } catch (IllegalArgumentException exception) { + return new Resolution(null, "Block state '" + renamed + "' is not exported: " + exception.getMessage()); + } + } + + private String renamed(String normalized) { + if (knownBlocks.isEmpty() || knownBlocks.contains(ContentGate.baseKey(normalized))) { + return normalized; + } + String properties = normalized.substring(ContentGate.baseKey(normalized).length()); + String current = normalized; + for (int hop = 0; hop < LegacyBlockRenames.MAX_HOPS; hop++) { + String supplement = LegacyBlockRenames.supplementFor(current); + if (supplement == null) { + return normalized; + } + String next = ContentGate.normalizeState(supplement); + if (next == null) { + return normalized; + } + if (knownBlocks.contains(ContentGate.baseKey(next))) { + return ContentGate.baseKey(next) + properties; + } + current = next; + } + return normalized; + } + + private static Set knownBlocks(List keys) { + Set known = new HashSet<>(keys.size()); + for (String key : keys) { + String normalized = ContentGate.normalizeState(key); + if (normalized != null) { + known.add(ContentGate.baseKey(normalized)); + } + } + return known; + } + + private record Resolution(VanillaBlockState state, String problem) { + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/CompileStage.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/CompileStage.java new file mode 100644 index 000000000..ff922a670 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/CompileStage.java @@ -0,0 +1,7 @@ +package art.arcane.iris.pack.datapack.export.compile; + +public interface CompileStage { + String name(); + + void apply(PackCompileContext context); +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/DimensionStage.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/DimensionStage.java new file mode 100644 index 000000000..8cc5eb530 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/DimensionStage.java @@ -0,0 +1,75 @@ +package art.arcane.iris.pack.datapack.export.compile; + +import art.arcane.iris.generation.block.IrisBlockData; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.generation.terrain.IrisDimensionType; +import art.arcane.iris.generation.terrain.IrisDimensionTypeOptions; +import art.arcane.iris.generation.terrain.IrisMaterialPalette; +import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; +import art.arcane.iris.pack.datapack.export.ir.DimensionDef; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.util.List; +import java.util.Locale; + +public final class DimensionStage implements CompileStage { + private static final String DEFAULT_ROCK = "minecraft:stone"; + private static final String DEFAULT_FLUID = "minecraft:water"; + private static final float FULLBRIGHT_AMBIENT_LIGHT = 1.0f; + + @Override + public String name() { + return "dimension"; + } + + @Override + public void apply(PackCompileContext context) { + IrisDimension dimension = context.dimension(); + int minY = dimension.getMinHeight(); + DimensionDef aligned = DimensionDef.aligned( + VanillaResourceIdentifier.sanitizePath(dimension.getLoadKey()), + minY, + dimension.getMaxHeight() - minY, + dimension.getLogicalHeight(), + dimension.getFluidHeight() + minY, + paletteBlock(dimension.getRockPalette(), DEFAULT_ROCK, "rock"), + paletteBlock(dimension.getFluidPalette(), DEFAULT_FLUID, "fluid"), + new JsonObject(), + context.diagnostics()); + IrisDimensionType type = new IrisDimensionType( + dimension.getBaseDimension(), + typeOptions(dimension), + aligned.logicalHeight(), + aligned.height(), + aligned.minY()); + JsonObject typeJson = JsonParser.parseString(type.toJson(context.capabilities().target().get())).getAsJsonObject(); + context.setDimensionDef(new DimensionDef( + aligned.name(), + aligned.minY(), + aligned.height(), + aligned.logicalHeight(), + aligned.fluidHeight(), + aligned.defaultBlock(), + aligned.defaultFluid(), + typeJson)); + } + + private static IrisDimensionTypeOptions typeOptions(IrisDimension dimension) { + IrisDimensionTypeOptions options = dimension.getDimensionOptions(); + return dimension.isFullbright() ? options.copy().ambientLight(FULLBRIGHT_AMBIENT_LIGHT) : options; + } + + private static String paletteBlock(IrisMaterialPalette palette, String fallback, String role) { + List entries = palette == null ? null : palette.getPalette(); + if (entries == null || entries.isEmpty() || entries.getFirst() == null) { + return fallback; + } + String raw = entries.getFirst().getBlock(); + String block = raw == null ? "" : raw.trim().toLowerCase(Locale.ROOT); + int properties = block.indexOf('['); + String id = properties < 0 ? block : block.substring(0, properties); + String namespaced = id.indexOf(':') < 0 ? "minecraft:" + id : id; + return VanillaResourceIdentifier.requireIdentifier(namespaced, "dimension " + role + " block"); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/ExportedBiomes.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/ExportedBiomes.java new file mode 100644 index 000000000..a3cafe046 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/ExportedBiomes.java @@ -0,0 +1,115 @@ +package art.arcane.iris.pack.datapack.export.compile; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.terrain.IrisRegion; +import art.arcane.iris.pack.datapack.export.ir.ClimateLayout; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.SortedMap; +import java.util.SortedSet; +import java.util.TreeMap; +import java.util.TreeSet; + +public final class ExportedBiomes { + private final SortedMap byId; + private final Map loadKeys; + private final Map codeOrder; + private final Map regionByLoadKey; + + private ExportedBiomes(SortedMap byId, Map loadKeys, Map codeOrder, + Map regionByLoadKey) { + this.byId = Collections.unmodifiableSortedMap(byId); + this.loadKeys = Map.copyOf(loadKeys); + this.codeOrder = Map.copyOf(codeOrder); + this.regionByLoadKey = Map.copyOf(regionByLoadKey); + } + + public static ExportedBiomes load(PackCompileContext context) { + SortedMap byId = new TreeMap<>(); + Map loadKeys = new HashMap<>(); + for (Map.Entry entry : context.biomeCodes().loadKeyByBiomeId().entrySet()) { + IrisBiome biome = Objects.requireNonNull(context.data().getBiomeLoader().load(entry.getValue(), false), + "Biome '" + entry.getValue() + "' could not be loaded"); + byId.put(entry.getKey(), biome); + loadKeys.put(entry.getKey(), entry.getValue()); + } + return new ExportedBiomes(byId, loadKeys, codeOrder(context.biomeCodes().layout(), byId.keySet()), regions(context)); + } + + public SortedMap byId() { + return byId; + } + + public SortedSet ids() { + return new TreeSet<>(byId.keySet()); + } + + public SortedSet idsFor(Collection loadKeys) { + SortedSet ids = new TreeSet<>(); + for (Map.Entry entry : this.loadKeys.entrySet()) { + if (loadKeys.contains(entry.getValue())) { + ids.add(entry.getKey()); + } + } + return ids; + } + + public String loadKey(String biomeId) { + return loadKeys.get(biomeId); + } + + public String resource(String biomeId) { + return "biomes/" + loadKeys.get(biomeId); + } + + public IrisRegion region(String biomeId) { + return regionByLoadKey.get(loadKeys.get(biomeId)); + } + + public List inCodeOrder(Iterable biomeIds) { + List ordered = new ArrayList<>(); + for (String biomeId : biomeIds) { + ordered.add(biomeId); + } + ordered.sort(Comparator.comparingInt((String id) -> codeOrder.getOrDefault(id, Integer.MAX_VALUE)).thenComparing(Comparator.naturalOrder())); + return ordered; + } + + private static Map codeOrder(ClimateLayout layout, Iterable biomeIds) { + List entries = new ArrayList<>(layout.entries()); + entries.sort(Comparator.comparingInt((ClimateLayout.ClimateEntry entry) -> entry.axis().ordinal()) + .thenComparingDouble(ClimateLayout.ClimateEntry::min) + .thenComparing(ClimateLayout.ClimateEntry::biomeId)); + Map order = new LinkedHashMap<>(); + for (ClimateLayout.ClimateEntry entry : entries) { + order.putIfAbsent(entry.biomeId(), order.size()); + } + for (String biomeId : biomeIds) { + order.putIfAbsent(biomeId, order.size()); + } + return order; + } + + private static Map regions(PackCompileContext context) { + Map regions = new HashMap<>(); + for (IrisRegion region : context.dimension().getAllRegions(() -> context.data())) { + if (region == null) { + continue; + } + for (IrisBiome biome : region.getAllBiomes(() -> context.data())) { + if (biome != null && biome.getLoadKey() != null) { + regions.putIfAbsent(biome.getLoadKey(), region); + } + } + } + return regions; + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/ImageMapStage.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/ImageMapStage.java new file mode 100644 index 000000000..138cfc839 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/ImageMapStage.java @@ -0,0 +1,117 @@ +package art.arcane.iris.pack.datapack.export.compile; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.biome.IrisBiomeGeneratorLink; +import art.arcane.iris.generation.noise.IrisGenerator; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.generation.terrain.IrisRegion; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.volmlib.util.collection.KList; +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +import java.util.List; +import java.util.Map; +import java.util.SortedMap; +import java.util.SortedSet; +import java.util.TreeMap; +import java.util.TreeSet; + +public final class ImageMapStage implements CompileStage { + private static final String DIMENSION_MESSAGE = "Image maps are not exported; the dimension generates from its procedural layers"; + private static final String STYLE_MESSAGE = "Generator style image maps are not exported; the base noise style is used"; + + @Override + public String name() { + return "image-maps"; + } + + @Override + public void apply(PackCompileContext context) { + IrisDimension dimension = context.dimension(); + IrisData data = context.data(); + Gson gson = data.getGson(); + if (usesImageMaps(gson.toJsonTree(dimension))) { + report(context, "dimensions/" + dimension.getLoadKey(), DIMENSION_MESSAGE); + } + dimension.setImageMaps(new KList<>()); + SortedMap regions = new TreeMap<>(); + for (IrisRegion region : dimension.getAllRegions(() -> data)) { + regions.put(region.getLoadKey(), region); + } + reportStyles(context, gson, "regions/", regions); + SortedMap biomes = new TreeMap<>(); + SortedSet generatorKeys = new TreeSet<>(); + for (IrisBiome biome : dimension.getReachableBiomes(() -> data)) { + biomes.put(biome.getLoadKey(), biome); + collectGenerators(biome.getGenerators(), generatorKeys); + } + reportStyles(context, gson, "biomes/", biomes); + SortedMap generators = new TreeMap<>(); + for (String key : generatorKeys) { + IrisGenerator generator = data.getGeneratorLoader().load(key, false); + if (generator != null) { + generators.put(key, generator); + } + } + reportStyles(context, gson, "generators/", generators); + } + + private static void collectGenerators(List links, SortedSet generatorKeys) { + if (links == null) { + return; + } + for (IrisBiomeGeneratorLink link : links) { + if (link != null && link.getGenerator() != null && !link.getGenerator().isBlank()) { + generatorKeys.add(link.getGenerator().trim()); + } + } + } + + private static void reportStyles(PackCompileContext context, Gson gson, String folder, SortedMap resources) { + for (Map.Entry entry : resources.entrySet()) { + if (usesImageMaps(gson.toJsonTree(entry.getValue()))) { + report(context, folder + entry.getKey(), STYLE_MESSAGE); + } + } + } + + private static void report(PackCompileContext context, String resource, String message) { + context.diagnostics().add(new ExportDiagnostic(ExportDiagnostic.Severity.WARNING, ExportLimit.IMAGE_MAP, resource, message)); + } + + private static boolean usesImageMaps(JsonElement element) { + if (element.isJsonArray()) { + for (JsonElement child : element.getAsJsonArray()) { + if (usesImageMaps(child)) { + return true; + } + } + return false; + } + if (!element.isJsonObject()) { + return false; + } + JsonObject object = element.getAsJsonObject(); + if (namesImageMap(object.get("imageMap")) || bindsImageMaps(object.get("imageMaps"))) { + return true; + } + for (Map.Entry member : object.entrySet()) { + if (usesImageMaps(member.getValue())) { + return true; + } + } + return false; + } + + private static boolean namesImageMap(JsonElement value) { + return value != null && value.isJsonPrimitive() && !value.getAsString().isBlank(); + } + + private static boolean bindsImageMaps(JsonElement value) { + return value != null && value.isJsonArray() && !value.getAsJsonArray().isEmpty(); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/PackCompileContext.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/PackCompileContext.java new file mode 100644 index 000000000..a5f490628 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/PackCompileContext.java @@ -0,0 +1,390 @@ +package art.arcane.iris.pack.datapack.export.compile; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.biome.IrisBiomeCustom; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.generation.terrain.IrisRegion; +import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; +import art.arcane.iris.pack.datapack.export.WorldgenExportMode; +import art.arcane.iris.pack.datapack.export.compile.biome.BiomeCodes; +import art.arcane.iris.pack.datapack.export.compile.biome.BiomeDecision; +import art.arcane.iris.pack.datapack.export.compile.biome.BiomeIdSanitizer; +import art.arcane.iris.pack.datapack.export.compile.decoration.BiomeFeatureLists; +import art.arcane.iris.pack.datapack.export.compile.noise.NoiseRegistry; +import art.arcane.iris.pack.datapack.export.compile.noise.StyleCompiler; +import art.arcane.iris.pack.datapack.export.ir.AquiferDef; +import art.arcane.iris.pack.datapack.export.ir.BinaryResource; +import art.arcane.iris.pack.datapack.export.ir.BiomeDef; +import art.arcane.iris.pack.datapack.export.ir.ClimateLayout; +import art.arcane.iris.pack.datapack.export.ir.DimensionDef; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.FieldValidator; +import art.arcane.iris.pack.datapack.export.ir.JsonResource; +import art.arcane.iris.pack.datapack.export.ir.MaterialProgram; +import art.arcane.iris.pack.datapack.export.lower.ExportCapabilities; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.pack.validation.ContentGate; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +public final class PackCompileContext { + private static final Field NEUTRAL_VOLUME = new Field.Const(0.0); + private static final Field NO_CAVES = new Field.Const(1.0e6); + private static final Field ZERO_CODE = new Field.Const(0.0); + private static final double CLIMATE_SPAN = 1.0; + private static final List LEGACY_BIOME_KEYS = List.of("surface_builder", "depth", "scale", "precipitation", "category", "starts"); + private static final StyleCompiler NO_STYLE_COMPILER = (style, seedSalt, registry, capabilities) -> { + throw new IllegalStateException("No style compiler installed"); + }; + + private final IrisData data; + private final IrisDimension dimension; + private final ExportCapabilities capabilities; + private final String namespace; + private final WorldgenExportMode mode; + private final boolean objects; + private final StyleCompiler styles; + private final NoiseRegistry noises; + private final List diagnostics; + private final BiomeFeatureLists features = new BiomeFeatureLists(); + private final List jsonResources = new ArrayList<>(); + private final List binaryResources = new ArrayList<>(); + private DimensionDef dimensionDef; + private BiomeCodes biomeCodes; + private BiomeDecision biomeDecision; + private BiomeCodes boundBiomeCodes; + private Field boundHeight; + private List biomes; + private Field height; + private Field carvedHeight; + private Field climateHeight; + private Field surfaceEstimate; + private Field volumetric; + private Field caves; + private Field.Shared caveField; + private double wallShellBand; + private Field.Shared deepCaves; + private AquiferDef aquifer; + private MaterialProgram material; + private DefaultBiome defaultBiome; + + private PackCompileContext(Builder builder) { + data = Objects.requireNonNull(builder.data, "Pack compile context requires pack data"); + dimension = Objects.requireNonNull(builder.dimension, "Pack compile context requires a dimension"); + capabilities = Objects.requireNonNull(builder.capabilities, "Pack compile context requires export capabilities"); + namespace = VanillaResourceIdentifier.requireNamespace(builder.namespace, "export namespace"); + mode = Objects.requireNonNull(builder.mode, "Pack compile context requires an export mode"); + objects = builder.objects; + styles = builder.styles; + diagnostics = builder.diagnostics; + noises = new NoiseRegistry(namespace); + } + + public static Builder builder() { + return new Builder(); + } + + public IrisData data() { + return data; + } + + public IrisDimension dimension() { + return dimension; + } + + public ExportCapabilities capabilities() { + return capabilities; + } + + public String namespace() { + return namespace; + } + + public WorldgenExportMode mode() { + return mode; + } + + public boolean objects() { + return objects; + } + + public StyleCompiler styles() { + return styles; + } + + public NoiseRegistry noises() { + return noises; + } + + public List diagnostics() { + return diagnostics; + } + + public BiomeFeatureLists features() { + return features; + } + + public List jsonResources() { + return jsonResources; + } + + public List binaryResources() { + return binaryResources; + } + + public DimensionDef dimensionDef() { + if (dimensionDef == null) { + throw new IllegalStateException("Dimension definition for '" + dimension.getLoadKey() + "' is read before the dimension stage ran"); + } + return dimensionDef; + } + + public void setDimensionDef(DimensionDef value) { + dimensionDef = Objects.requireNonNull(value); + } + + public BiomeCodes biomeCodes() { + if (biomeDecision != null) { + return height == null ? biomeDecision.base() : heightBoundBiomeCodes(); + } + return biomeCodes == null ? defaultBiome().codes() : biomeCodes; + } + + public BiomeCodes baseBiomeCodes() { + if (biomeDecision != null) { + return biomeDecision.base(); + } + return biomeCodes == null ? defaultBiome().codes() : biomeCodes; + } + + public void setBiomeCodes(BiomeCodes value) { + biomeCodes = Objects.requireNonNull(value); + biomeDecision = null; + } + + public void setBiomeDecision(BiomeDecision value) { + biomeDecision = Objects.requireNonNull(value); + biomeCodes = null; + boundBiomeCodes = null; + boundHeight = null; + } + + public List biomes() { + return biomes == null ? defaultBiome().biomes() : biomes; + } + + public void setBiomes(List value) { + biomes = List.copyOf(value); + } + + public Field height() { + return carvedHeight == null ? naturalHeight() : carvedHeight; + } + + public Field naturalHeight() { + return height == null ? new Field.Const(dimensionDef().fluidHeight()) : height; + } + + public void setHeight(Field value) { + FieldValidator.requireTwoDimensional(Objects.requireNonNull(value), "height"); + height = value; + carvedHeight = null; + climateHeight = null; + surfaceEstimate = null; + } + + public Field climateHeight() { + return climateHeight == null ? naturalHeight() : climateHeight; + } + + public void setClimateHeight(Field value) { + FieldValidator.requireTwoDimensional(Objects.requireNonNull(value), "climate height"); + climateHeight = value; + } + + public void setCarvedHeight(Field value) { + FieldValidator.requireTwoDimensional(Objects.requireNonNull(value), "carved height"); + carvedHeight = value; + } + + public Field surfaceEstimate() { + return surfaceEstimate == null ? climateHeight() : surfaceEstimate; + } + + public void setSurfaceEstimate(Field value) { + FieldValidator.requireTwoDimensional(Objects.requireNonNull(value), "surface estimate"); + surfaceEstimate = value; + } + + public Field volumetric() { + return volumetric == null ? NEUTRAL_VOLUME : volumetric; + } + + public void setVolumetric(Field value) { + volumetric = Objects.requireNonNull(value); + } + + public Field caves() { + return caves == null ? NO_CAVES : caves; + } + + public void setCaves(Field value) { + caves = Objects.requireNonNull(value); + } + + public Optional caveField() { + return Optional.ofNullable(caveField); + } + + public void setCaveField(Field.Shared value, double wallBand) { + if (!(wallBand >= 0.0) || Double.isInfinite(wallBand)) { + throw new IllegalArgumentException("Wall shell band must be finite and non-negative, got " + wallBand); + } + caveField = Objects.requireNonNull(value); + wallShellBand = wallBand; + } + + public double wallShellBand() { + return wallShellBand; + } + + public Optional deepCaves() { + return Optional.ofNullable(deepCaves); + } + + public void setDeepCaves(Field.Shared value) { + deepCaves = Objects.requireNonNull(value); + } + + public AquiferDef aquifer() { + return aquifer == null ? AquiferDef.dry(surfaceEstimate()) : aquifer; + } + + public void setAquifer(AquiferDef value) { + aquifer = Objects.requireNonNull(value); + } + + public MaterialProgram material() { + return material == null ? MaterialProgram.rock(dimensionDef().defaultBlock()) : material; + } + + public void setMaterial(MaterialProgram value) { + material = Objects.requireNonNull(value); + } + + private BiomeCodes heightBoundBiomeCodes() { + Field bindingHeight = climateHeight(); + if (boundHeight != bindingHeight) { + boundBiomeCodes = biomeDecision.bind(bindingHeight); + boundHeight = bindingHeight; + } + return boundBiomeCodes; + } + + private DefaultBiome defaultBiome() { + if (defaultBiome == null) { + defaultBiome = createDefaultBiome(); + } + return defaultBiome; + } + + private DefaultBiome createDefaultBiome() { + String loadKey = firstLandBiome(); + String id = BiomeIdSanitizer.sanitize(namespace, loadKey, new HashSet<>()); + String body = new IrisBiomeCustom().generateJson(capabilities.target().get(), new ContentGate(null, Map.of(), null)); + JsonObject json = JsonParser.parseString(body).getAsJsonObject(); + for (String key : LEGACY_BIOME_KEYS) { + json.remove(key); + } + BiomeDef biome = new BiomeDef(id.substring(namespace.length() + 1), json); + ClimateLayout layout = new ClimateLayout(List.of( + new ClimateLayout.ClimateEntry(id, ClimateLayout.ClimateAxis.TEMPERATURE, -CLIMATE_SPAN, CLIMATE_SPAN))); + BiomeCodes codes = new BiomeCodes(ZERO_CODE, ZERO_CODE, ZERO_CODE, layout, Map.of(id, loadKey)); + return new DefaultBiome(List.of(biome), codes); + } + + private String firstLandBiome() { + for (String regionKey : dimension.getRegions()) { + IrisRegion region = data.getRegionLoader().load(regionKey, false); + if (region == null || region.isCompatExcluded()) { + continue; + } + for (String biomeKey : region.getLandBiomes()) { + IrisBiome biome = biomeKey == null ? null : data.getBiomeLoader().load(biomeKey, false); + if (biome != null && !biome.isCompatExcluded()) { + return biome.getLoadKey(); + } + } + } + throw new IllegalStateException("Dimension '" + dimension.getLoadKey() + "' has no exportable land biome in any of its regions"); + } + + private record DefaultBiome(List biomes, BiomeCodes codes) { + } + + public static final class Builder { + private IrisData data; + private IrisDimension dimension; + private ExportCapabilities capabilities; + private String namespace; + private WorldgenExportMode mode = WorldgenExportMode.OVERWORLD; + private boolean objects = true; + private StyleCompiler styles = NO_STYLE_COMPILER; + private List diagnostics = new ArrayList<>(); + + private Builder() { + } + + public Builder data(IrisData value) { + data = Objects.requireNonNull(value); + return this; + } + + public Builder dimension(IrisDimension value) { + dimension = Objects.requireNonNull(value); + return this; + } + + public Builder capabilities(ExportCapabilities value) { + capabilities = Objects.requireNonNull(value); + return this; + } + + public Builder namespace(String value) { + namespace = Objects.requireNonNull(value); + return this; + } + + public Builder mode(WorldgenExportMode value) { + mode = Objects.requireNonNull(value); + return this; + } + + public Builder objects(boolean value) { + objects = value; + return this; + } + + public Builder styles(StyleCompiler value) { + styles = Objects.requireNonNull(value); + return this; + } + + public Builder diagnostics(List value) { + diagnostics = Objects.requireNonNull(value); + return this; + } + + public PackCompileContext build() { + return new PackCompileContext(this); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/PackCompiler.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/PackCompiler.java new file mode 100644 index 000000000..af1ce00e5 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/PackCompiler.java @@ -0,0 +1,73 @@ +package art.arcane.iris.pack.datapack.export.compile; + +import art.arcane.iris.pack.datapack.export.WorldgenExportProgress; +import art.arcane.iris.pack.datapack.export.compile.biome.BiomeCodes; +import art.arcane.iris.pack.datapack.export.compile.biome.BiomeStage; +import art.arcane.iris.pack.datapack.export.compile.cave.CaveStage; +import art.arcane.iris.pack.datapack.export.compile.decoration.DecorationStage; +import art.arcane.iris.pack.datapack.export.compile.object.ObjectStage; +import art.arcane.iris.pack.datapack.export.compile.structure.StructureStage; +import art.arcane.iris.pack.datapack.export.compile.subterrain.SubterrainStage; +import art.arcane.iris.pack.datapack.export.compile.surface.SurfaceStage; +import art.arcane.iris.pack.datapack.export.compile.terrain.RiverStage; +import art.arcane.iris.pack.datapack.export.compile.terrain.TerrainStage; +import art.arcane.iris.pack.datapack.export.ir.DimensionDef; +import art.arcane.iris.pack.datapack.export.ir.ExportIr; +import art.arcane.iris.pack.datapack.export.ir.RouterDef; + +import java.util.List; + +public final class PackCompiler { + private static final String ROUTER_STAGE = "router"; + + private final List stages; + + public PackCompiler(List stages) { + this.stages = List.copyOf(stages); + } + + public static PackCompiler create() { + return new PackCompiler(List.of( + new DimensionStage(), + new ImageMapStage(), + new BiomeStage(), + new TerrainStage(), + new RiverStage(), + new CaveStage(), + new SurfaceStage(), + new DecorationStage(), + new StructureStage(), + new SubterrainStage(), + new ObjectStage())); + } + + public ExportIr compile(PackCompileContext context, WorldgenExportProgress progress) { + for (CompileStage stage : stages) { + progress.stage(stage.name()); + stage.apply(context); + } + progress.stage(ROUTER_STAGE); + DimensionDef dimension = context.dimensionDef(); + BiomeCodes codes = context.biomeCodes(); + RouterDef router = RouterCompiler.compile( + context.height(), + context.volumetric(), + context.caves(), + context.aquifer(), + codes, + dimension); + return new ExportIr( + context.namespace(), + context.capabilities().target(), + context.mode(), + dimension, + context.noises().definitions(), + router, + codes.layout(), + context.features().apply(context.biomes(), context.namespace()), + context.material(), + context.diagnostics(), + context.jsonResources(), + context.binaryResources()); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/RouterCompiler.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/RouterCompiler.java new file mode 100644 index 000000000..2c92054b6 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/RouterCompiler.java @@ -0,0 +1,44 @@ +package art.arcane.iris.pack.datapack.export.compile; + +import art.arcane.iris.pack.datapack.export.compile.biome.BiomeCodes; +import art.arcane.iris.pack.datapack.export.ir.AquiferDef; +import art.arcane.iris.pack.datapack.export.ir.Axis; +import art.arcane.iris.pack.datapack.export.ir.DimensionDef; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.FieldValidator; +import art.arcane.iris.pack.datapack.export.ir.RouterDef; + +public final class RouterCompiler { + private static final Field DENSITY_PER_BLOCK = new Field.Const(1.0); + private static final Field UNUSED_CLIMATE = new Field.Const(0.0); + private static final double HALF_BLOCK = 0.5; + private static final int VOLUME_CELL_XZ = 4; + private static final int VOLUME_CELL_Y = 8; + private static final int SURFACE_CELL_XZ = 16; + private static final int SURFACE_CELL_Y = 1; + + private RouterCompiler() { + } + + public static RouterDef compile(Field height, Field volumetric, Field caves, AquiferDef aquifer, BiomeCodes codes, DimensionDef dimension) { + FieldValidator.requireTwoDimensional(height, "height"); + Field ramp = new Field.Binary(Field.BinaryOp.MUL, DENSITY_PER_BLOCK, new Field.Binary(Field.BinaryOp.SUB, height, blockCenterY(dimension))); + Field terrain = new Field.Binary(Field.BinaryOp.ADD, ramp, new Field.Grid(volumetric, VOLUME_CELL_XZ, VOLUME_CELL_Y)); + return new RouterDef( + codes.surfaceCode(), + codes.caveCode(), + UNUSED_CLIMATE, + UNUSED_CLIMATE, + codes.class3d(), + UNUSED_CLIMATE, + new Field.Grid(height, SURFACE_CELL_XZ, SURFACE_CELL_Y), + new Field.Binary(Field.BinaryOp.MIN, terrain, caves), + aquifer); + } + + private static Field blockCenterY(DimensionDef dimension) { + double bottom = dimension.minY(); + double top = dimension.minY() + dimension.height(); + return new Field.Coord(Axis.Y, Field.Tiling.CLAMP, bottom, top, bottom - HALF_BLOCK, top - HALF_BLOCK); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/WeightedState.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/WeightedState.java new file mode 100644 index 000000000..b5c84467e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/WeightedState.java @@ -0,0 +1,14 @@ +package art.arcane.iris.pack.datapack.export.compile; + +import art.arcane.iris.pack.datapack.export.VanillaBlockState; + +import java.util.Objects; + +public record WeightedState(VanillaBlockState state, int weight) { + public WeightedState { + Objects.requireNonNull(state); + if (weight < 1) { + throw new IllegalArgumentException("Block state weight must be positive, got " + weight + " for " + state.canonical()); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/WorldgenSyntax.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/WorldgenSyntax.java new file mode 100644 index 000000000..fc50b36c0 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/WorldgenSyntax.java @@ -0,0 +1,76 @@ +package art.arcane.iris.pack.datapack.export.compile; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.VanillaBlockState; +import art.arcane.iris.pack.datapack.export.WorldgenExportTargets; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +import java.util.List; +import java.util.Map; + +public final class WorldgenSyntax { + private final DataVersion target; + + private WorldgenSyntax(DataVersion target) { + this.target = target; + } + + public static WorldgenSyntax forTarget(DataVersion target) { + if (!WorldgenExportTargets.SUPPORTED.contains(target)) { + throw WorldgenExportTargets.unsupported(target.getVersion()); + } + return new WorldgenSyntax(target); + } + + public DataVersion target() { + return target; + } + + public String featureFolder() { + return "worldgen/feature"; + } + + public JsonObject blockState(VanillaBlockState state) { + JsonObject json = new JsonObject(); + json.addProperty("id", state.name()); + if (!state.properties().isEmpty()) { + JsonObject properties = new JsonObject(); + for (Map.Entry property : state.properties().entrySet()) { + properties.addProperty(property.getKey(), property.getValue()); + } + json.add("properties", properties); + } + return json; + } + + public JsonObject provider(List entries) { + if (entries.isEmpty()) { + throw new IllegalArgumentException("A block state provider needs at least one state"); + } + if (entries.size() == 1) { + return blockState(entries.getFirst().state()); + } + JsonArray weighted = new JsonArray(entries.size()); + for (WeightedState entry : entries) { + JsonObject item = new JsonObject(); + item.add("data", blockState(entry.state())); + item.addProperty("weight", entry.weight()); + weighted.add(item); + } + JsonObject provider = new JsonObject(); + provider.addProperty("type", "minecraft:weighted"); + provider.add("entries", weighted); + return provider; + } + + public JsonObject feature(String type, JsonObject config) { + JsonObject feature = new JsonObject(); + feature.addProperty("type", type); + for (Map.Entry field : config.entrySet()) { + feature.add(field.getKey(), field.getValue().deepCopy()); + } + return feature; + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/biome/BiomeCodes.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/biome/BiomeCodes.java new file mode 100644 index 000000000..068530cf5 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/biome/BiomeCodes.java @@ -0,0 +1,31 @@ +package art.arcane.iris.pack.datapack.export.compile.biome; + +import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; +import art.arcane.iris.pack.datapack.export.ir.ClimateLayout; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.FieldValidator; + +import java.util.Collections; +import java.util.Map; +import java.util.Objects; +import java.util.SortedMap; +import java.util.TreeMap; + +public record BiomeCodes(Field surfaceCode, Field caveCode, Field class3d, ClimateLayout layout, Map loadKeyByBiomeId) { + public static final String BASE_CODE = "biome/base_code"; + + public BiomeCodes { + Objects.requireNonNull(surfaceCode); + Objects.requireNonNull(caveCode); + Objects.requireNonNull(class3d); + Objects.requireNonNull(layout); + FieldValidator.requireTwoDimensional(surfaceCode, "surface code"); + FieldValidator.requireTwoDimensional(caveCode, "cave code"); + SortedMap loadKeys = new TreeMap<>(loadKeyByBiomeId); + for (Map.Entry entry : loadKeys.entrySet()) { + VanillaResourceIdentifier.requireIdentifier(entry.getKey(), "biome id"); + Objects.requireNonNull(entry.getValue()); + } + loadKeyByBiomeId = Collections.unmodifiableSortedMap(loadKeys); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/biome/BiomeDecision.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/biome/BiomeDecision.java new file mode 100644 index 000000000..f107986c7 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/biome/BiomeDecision.java @@ -0,0 +1,141 @@ +package art.arcane.iris.pack.datapack.export.compile.biome; + +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.FieldValidator; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public final class BiomeDecision { + static final int LAND = 0; + static final int SEA = 1; + static final int SHORE = 2; + static final int SURFACE_SLOT = 0; + private static final double UNDERGROUND_DEPTH = 8.0; + private static final int BRIDGE_WEIGHT = 8; + private static final int LEVEL_WEIGHT = 2; + private static final int ZONE_WEIGHT = 4; + private static final int DEPTH_WEIGHT = 2; + private static final Field UNUSED_CODE = new Field.Const(0.0); + + private final BiomeCodes base; + private final Parts parts; + + BiomeDecision(BiomeCodes base, Parts parts) { + this.base = Objects.requireNonNull(base); + this.parts = Objects.requireNonNull(parts); + } + + public BiomeCodes base() { + return base; + } + + public BiomeCodes bind(Field height) { + FieldValidator.requireTwoDimensional(Objects.requireNonNull(height), "height"); + return new BiomeCodes(surfaceCode(height), base.caveCode(), class3d(height), base.layout(), base.loadKeyByBiomeId()); + } + + private Field surfaceCode(Field height) { + double fluid = parts.fluid(); + double fluidTop = Math.nextUp((float) fluid); + IndexedField levels = IndexedField.partition(height, List.of(fluid - 1.0, fluid, fluidTop), List.of(0, LEVEL_WEIGHT, 2 * LEVEL_WEIGHT, 3 * LEVEL_WEIGHT)); + IndexedField above = IndexedField.partition(new Field.Binary(Field.BinaryOp.SUB, height, parts.shoreHeight()), List.of(fluidTop), List.of(0, 1)); + IndexedField key = IndexedField.sum(List.of(parts.bridge().remap(bridge -> bridge * BRIDGE_WEIGHT), levels, above)); + IndexedField list = key.remap(BiomeDecision::listFor); + List codes = new ArrayList<>(parts.chains().size()); + for (IndexedField chain : parts.chains()) { + codes.add(chain.lookup(index -> parts.surfaceCodes()[index])); + } + if (list.isConstant()) { + return codes.get(list.values().getFirst()); + } + List cases = new ArrayList<>(list.values().getLast() + 1); + for (int value = 0; value <= list.values().getLast(); value++) { + cases.add(list.values().contains(value) ? codes.get(value) : UNUSED_CODE); + } + return new Field.Mux(list.field(), cases); + } + + private Field class3d(Field height) { + if (parts.caveSlot().equals(IndexedField.constant(SURFACE_SLOT)) && parts.carvingSlots().isEmpty()) { + return new Field.Const(0.0); + } + IndexedField zones = parts.zoneKeys(); + Field caveExtra = new Field.Binary(Field.BinaryOp.SUB, parts.requiredDepth(), new Field.Const(UNDERGROUND_DEPTH)); + Field caveZone = zones.lookup(key -> key / ZONE_WEIGHT == CarvingZones.CAVE_ZONE ? 1.0 : 0.0); + Field depthGate = new Field.Binary(Field.BinaryOp.ADD, new Field.Const(UNDERGROUND_DEPTH), new Field.Gate(caveZone, caveExtra)); + Field clearance = new Field.Binary(Field.BinaryOp.SUB, new Field.Binary(Field.BinaryOp.SUB, height, parts.y()), depthGate); + IndexedField deepEnough = IndexedField.partition(clearance, List.of(0.0), List.of(0, 1)); + IndexedField key = IndexedField.sum(List.of(zones.remap(zone -> zone / ZONE_WEIGHT * DEPTH_WEIGHT), deepEnough)); + IndexedField classIndex = key.remap(BiomeDecision::classFor); + List cases = new ArrayList<>(parts.carvingSlots().size() + 2); + cases.add(IndexedField.constant(SURFACE_SLOT)); + cases.add(parts.caveSlot()); + cases.addAll(parts.carvingSlots()); + IndexedField slot = IndexedField.select(classIndex, cases); + return slot.lookup(value -> value == SURFACE_SLOT ? 0.0 : parts.undergroundCodes()[value - 1]); + } + + static int listFor(int key) { + boolean landBase = key / BRIDGE_WEIGHT == LAND; + int level = (key % BRIDGE_WEIGHT) / LEVEL_WEIGHT; + boolean aboveShoreBand = key % LEVEL_WEIGHT == 1; + boolean nearFluid = level >= 1; + boolean belowFluid = level <= 1; + boolean atFluid = level == 2; + int base = landBase ? LAND : SEA; + if (nearFluid) { + if (!aboveShoreBand) { + return SHORE; + } + if (!landBase) { + return LAND; + } + } else if (!landBase && aboveShoreBand) { + return LAND; + } + if (belowFluid && landBase) { + return SEA; + } + if (atFluid) { + return SHORE; + } + return base; + } + + static int classFor(int key) { + int zone = key / DEPTH_WEIGHT; + boolean deepEnough = key % DEPTH_WEIGHT == 1; + if (zone == CarvingZones.SHALLOW_ZONE || !deepEnough) { + return 0; + } + return zone == CarvingZones.CAVE_ZONE ? 1 : zone; + } + + record Parts( + double fluid, + IndexedField bridge, + Field shoreHeight, + List chains, + double[] surfaceCodes, + Field y, + IndexedField zoneKeys, + IndexedField caveSlot, + Field requiredDepth, + List carvingSlots, + double[] undergroundCodes) { + Parts { + Objects.requireNonNull(bridge); + Objects.requireNonNull(shoreHeight); + chains = List.copyOf(chains); + Objects.requireNonNull(surfaceCodes); + Objects.requireNonNull(y); + Objects.requireNonNull(zoneKeys); + Objects.requireNonNull(caveSlot); + Objects.requireNonNull(requiredDepth); + carvingSlots = List.copyOf(carvingSlots); + Objects.requireNonNull(undergroundCodes); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/biome/BiomeDecisionCompiler.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/biome/BiomeDecisionCompiler.java new file mode 100644 index 000000000..4f5bb285d --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/biome/BiomeDecisionCompiler.java @@ -0,0 +1,299 @@ +package art.arcane.iris.pack.datapack.export.compile.biome; + +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.terrain.InferredType; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.generation.terrain.IrisRegion; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; +import art.arcane.iris.pack.datapack.export.compile.noise.StyleCompiler; +import art.arcane.iris.pack.datapack.export.ir.Axis; +import art.arcane.iris.pack.datapack.export.ir.ClimateLayout; +import art.arcane.iris.pack.datapack.export.ir.DimensionDef; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.loading.IrisData; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.SortedMap; +import java.util.SortedSet; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.function.Function; + +public final class BiomeDecisionCompiler { + private static final long REGION_SALT = 883L; + private static final long CONTINENT_SALT = 234_234_565L; + private static final long PICK_SALT = 1_000L; + private static final long CHILD_SALT = 123L; + private static final long SHORE_SALT = 3_458_612L; + private static final double SURFACE_SPAN = 1.0; + private static final double UNDERGROUND_MIN = 1.25; + private static final double UNDERGROUND_MAX = 2.0; + private static final double MINIMUM_CAVE_DEPTH = 8.0; + private static final double NO_CAVE_DEPTH = 100_000.0; + private static final int DEEP_FLOOR_Y = -8; + private static final int DEEP_ABOVE_MIN_Y = 40; + + private final StyleCompiler styles; + + public BiomeDecisionCompiler(StyleCompiler styles) { + this.styles = Objects.requireNonNull(styles); + } + + public BiomeDecision compile(PackCompileContext context) { + IrisDimension dimension = context.dimension(); + IrisData data = context.data(); + DimensionDef dimensionDef = context.dimensionDef(); + BiomePools pools = BiomePools.load(dimension, data); + CarvingZones carving = CarvingZones.plan(dimension, data, Math.max(DEEP_FLOOR_Y, dimensionDef.minY() + DEEP_ABOVE_MIN_Y)); + List surfaceKeys = List.copyOf(pools.surface()); + SortedSet undergroundSet = new TreeSet<>(pools.cave()); + undergroundSet.addAll(carving.biomes()); + List undergroundKeys = List.copyOf(undergroundSet); + Map surfaceIndex = indexOf(surfaceKeys); + Map undergroundIndex = indexOf(undergroundKeys); + Map ids = biomeIds(context.namespace(), surfaceKeys, undergroundKeys); + double[] surfaceCodes = codes(-SURFACE_SPAN, SURFACE_SPAN, surfaceKeys.size()); + double[] undergroundCodes = codes(UNDERGROUND_MIN, UNDERGROUND_MAX, undergroundKeys.size()); + Build build = new Build(pools, surfaceKeys, surfaceIndex, + new SelectorCompiler(styles.selectors(), new SelectorCompiler.Sources(data, context.noises(), context.capabilities()))); + IndexedField region = build.region(dimension); + IndexedField bridge = build.selectors.scaled(dimension.getContinentalStyle(), 1.0 / dimension.getContinentZoom(), CONTINENT_SALT) + .partition(List.of(dimension.getLandChance()), List.of(BiomeDecision.LAND, BiomeDecision.SEA)) + .shared("biome/bridge"); + RegionPicks picks = build.regionPicks(dimension, bridge, undergroundIndex, context.diagnostics()); + List chains = List.of( + build.implode("biome/land", IndexedField.select(region, picks.land()).shared("biome/land/pick")), + build.implode("biome/sea", IndexedField.select(region, picks.sea()).shared("biome/sea/pick")), + build.implode("biome/shore", IndexedField.select(region, picks.shore()).shared("biome/shore/pick"))); + IndexedField baseSurface = IndexedField.select(bridge, chains.subList(0, 2)); + IndexedField caveSlot = IndexedField.select(region, picks.caves()).shared("biome/cave"); + Field caveCode = caveSlot.lookup(value -> value == BiomeDecision.SURFACE_SLOT ? 0.0 : undergroundCodes[value - 1]); + Field requiredDepth = caveSlot.lookup(value -> value == BiomeDecision.SURFACE_SLOT + ? NO_CAVE_DEPTH + : Math.max(MINIMUM_CAVE_DEPTH, pools.biome(undergroundKeys.get(value - 1)).getCaveMinDepthBelowSurface())); + Field y = blockY(dimensionDef); + List carvingSlots = carving.slots(build.selectors, data, key -> undergroundIndex.get(key) + 1); + BiomeCodes base = new BiomeCodes( + new Field.Shared(BiomeCodes.BASE_CODE, baseSurface.lookup(index -> surfaceCodes[index])), + caveCode, + new Field.Const(0.0), + layout(ids, surfaceKeys, surfaceCodes, undergroundKeys, undergroundCodes), + loadKeys(ids)); + BiomeDecision.Parts parts = new BiomeDecision.Parts( + dimensionDef.fluidHeight(), + bridge, + shoreHeight(region, picks.shoreHeights()), + chains, + surfaceCodes, + y, + carving.zoneKeys(y), + caveSlot, + requiredDepth, + carvingSlots, + undergroundCodes); + return new BiomeDecision(base, parts); + } + + private static Field shoreHeight(IndexedField region, List heights) { + if (region.isConstant()) { + return heights.get(region.values().getFirst()); + } + if (new HashSet<>(heights).size() == 1) { + return heights.getFirst(); + } + return new Field.Shared("biome/shore_height", new Field.Mux(region.field(), heights)); + } + + private static SortedMap loadKeys(Map ids) { + SortedMap loadKeys = new TreeMap<>(); + for (Map.Entry entry : ids.entrySet()) { + loadKeys.put(entry.getValue(), entry.getKey()); + } + return loadKeys; + } + + private static Map indexOf(List keys) { + Map index = new HashMap<>(keys.size() * 2); + for (int position = 0; position < keys.size(); position++) { + index.put(keys.get(position), position); + } + return index; + } + + private static Map biomeIds(String namespace, List surfaceKeys, List undergroundKeys) { + SortedSet all = new TreeSet<>(surfaceKeys); + all.addAll(undergroundKeys); + Set taken = new HashSet<>(); + Map ids = new TreeMap<>(); + for (String key : all) { + ids.put(key, BiomeIdSanitizer.sanitize(namespace, key, taken)); + } + return ids; + } + + private static double[] codes(double min, double max, int count) { + double[] codes = new double[count]; + double half = (max - min) / (2.0 * Math.max(1, count)); + for (int index = 0; index < count; index++) { + codes[index] = min + (2 * index + 1) * half; + } + return codes; + } + + private static ClimateLayout layout(Map ids, List surfaceKeys, double[] surfaceCodes, List undergroundKeys, double[] undergroundCodes) { + List entries = new ArrayList<>(surfaceKeys.size() + undergroundKeys.size()); + double surfaceHalf = SURFACE_SPAN / Math.max(1, surfaceKeys.size()); + for (int index = 0; index < surfaceKeys.size(); index++) { + entries.add(new ClimateLayout.ClimateEntry(ids.get(surfaceKeys.get(index)), ClimateLayout.ClimateAxis.TEMPERATURE, + surfaceCodes[index] - surfaceHalf, surfaceCodes[index] + surfaceHalf)); + } + double undergroundHalf = (UNDERGROUND_MAX - UNDERGROUND_MIN) / (2.0 * Math.max(1, undergroundKeys.size())); + for (int index = 0; index < undergroundKeys.size(); index++) { + entries.add(new ClimateLayout.ClimateEntry(ids.get(undergroundKeys.get(index)), ClimateLayout.ClimateAxis.DEPTH, + undergroundCodes[index] - undergroundHalf, Math.min(UNDERGROUND_MAX, undergroundCodes[index] + undergroundHalf))); + } + return new ClimateLayout(entries); + } + + private static Field blockY(DimensionDef dimension) { + double bottom = dimension.minY(); + double top = dimension.minY() + dimension.height(); + return new Field.Coord(Axis.Y, Field.Tiling.CLAMP, bottom, top, bottom, top); + } + + private static final class Build { + private final BiomePools pools; + private final List surfaceKeys; + private final Map surfaceIndex; + private final SelectorCompiler selectors; + private final Map hops = new HashMap<>(); + + private Build(BiomePools pools, List surfaceKeys, Map surfaceIndex, SelectorCompiler selectors) { + this.pools = pools; + this.surfaceKeys = surfaceKeys; + this.surfaceIndex = surfaceIndex; + this.selectors = selectors; + } + + private IndexedField region(IrisDimension dimension) { + List regions = new ArrayList<>(pools.regions().size()); + List leaves = new ArrayList<>(pools.regions().size()); + for (BiomePools.RegionPools regionPools : pools.regions()) { + leaves.add(regions.size()); + regions.add(regionPools.region()); + } + if (regions.size() == 1) { + return IndexedField.constant(0); + } + return selectors.zoomed(dimension.getRegionStyle(), dimension.getRegionZoom(), REGION_SALT) + .partition(BiomePools.rarityCuts(regions), leaves) + .shared("biome/region"); + } + + private RegionPicks regionPicks(IrisDimension dimension, IndexedField bridge, Map undergroundIndex, List diagnostics) { + int count = pools.regions().size(); + RegionPicks picks = new RegionPicks(new ArrayList<>(count), new ArrayList<>(count), new ArrayList<>(count), new ArrayList<>(count), new ArrayList<>(count)); + for (int position = 0; position < count; position++) { + BiomePools.RegionPools regionPools = pools.regions().get(position); + IrisRegion region = regionPools.region(); + IndexedField landPick = pick(dimension, regionPools, InferredType.LAND, surfaceIndex::get, 0); + IndexedField seaPick = pick(dimension, regionPools, InferredType.SEA, surfaceIndex::get, 0); + IndexedField land = landPick == null ? seaPick : landPick; + IndexedField sea = seaPick == null ? landPick : seaPick; + IndexedField shore = pick(dimension, regionPools, InferredType.SHORE, surfaceIndex::get, 0); + IndexedField cave = pick(dimension, regionPools, InferredType.CAVE, undergroundIndex::get, 1); + picks.land().add(land); + picks.sea().add(sea); + picks.shore().add(shore == null ? IndexedField.select(bridge, List.of(land, sea)) : shore); + picks.caves().add(cave == null ? IndexedField.constant(BiomeDecision.SURFACE_SLOT) : cave); + picks.shoreHeights().add(shoreHeight(region, position)); + if (region.getShoreMinimumWidth() > 0.0) { + diagnostics.add(new ExportDiagnostic(ExportDiagnostic.Severity.WARNING, ExportLimit.SHORE_WIDTH, "regions/" + region.getLoadKey(), + "Shore widening by slope is not exported; the shore band follows the shore height only")); + } + } + return picks; + } + + private IndexedField pick(IrisDimension dimension, BiomePools.RegionPools regionPools, InferredType type, Function index, int offset) { + List pool = regionPools.of(type); + if (pool.isEmpty()) { + return null; + } + List leaves = new ArrayList<>(pool.size()); + for (IrisBiome biome : pool) { + leaves.add(index.apply(biome.getLoadKey()) + offset); + } + if (pool.size() == 1) { + return IndexedField.constant(leaves.getFirst()); + } + IrisRegion region = regionPools.region(); + double zoom = switch (type) { + case LAND -> dimension.getBiomeZoom() * dimension.getLandZoom() * region.getLandBiomeZoom(); + case SEA -> dimension.getBiomeZoom() * dimension.getSeaZoom() * region.getSeaBiomeZoom(); + case SHORE -> dimension.getBiomeZoom() * region.getShoreBiomeZoom(); + case CAVE -> dimension.getBiomeZoom() * region.getCaveBiomeZoom(); + }; + IrisGeneratorStyle style = switch (type) { + case LAND -> dimension.getLandBiomeStyle(); + case SEA -> dimension.getSeaBiomeStyle(); + case SHORE -> dimension.getShoreBiomeStyle(); + case CAVE -> dimension.getCaveBiomeStyle(); + }; + return selectors.zoomed(style, zoom, PICK_SALT + type.ordinal()).partition(BiomePools.rarityCuts(pool), leaves); + } + + private Field shoreHeight(IrisRegion region, int position) { + if (region.getShoreHeightMin() == region.getShoreHeightMax()) { + return new Field.Const(region.getShoreHeightMin()); + } + return selectors.sampled(NoiseStyle.IRIS.style(), region.getShoreHeightZoom(), SHORE_SALT + position, region::getShoreHeight).curve(); + } + + private IndexedField implode(String name, IndexedField start) { + IndexedField current = start; + for (int hop = 0; hop < BiomePools.MAX_CHILD_HOPS; hop++) { + List movers = new ArrayList<>(); + for (int value : current.values()) { + BiomePools.ChildPlan plan = pools.plan(surfaceKeys.get(value)); + if (plan != null && plan.moves()) { + movers.add(value); + } + } + if (movers.isEmpty()) { + return current; + } + Map moved = new HashMap<>(movers.size() * 2); + for (int mover : movers) { + moved.put(mover, hops.computeIfAbsent(surfaceKeys.get(mover), this::hop)); + } + current = IndexedField.advance(current, moved).shared(name + "/hop" + (hop + 1)); + } + return current; + } + + private IndexedField hop(String parent) { + IrisBiome biome = pools.biome(parent); + BiomePools.ChildPlan plan = pools.plan(parent); + List leaves = new ArrayList<>(plan.choiceByIndex().size()); + for (String choice : plan.choiceByIndex()) { + leaves.add(surfaceIndex.get(choice)); + } + return selectors.scaled(biome.getChildStyle(), biome.getChildShrinkFactor(), CHILD_SALT) + .partition(BiomePools.selectionCuts(plan.choiceByIndex().size()), leaves); + } + } + + private record RegionPicks(List land, List sea, List shore, List caves, List shoreHeights) { + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/biome/BiomeDefinitionCompiler.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/biome/BiomeDefinitionCompiler.java new file mode 100644 index 000000000..05662f1f5 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/biome/BiomeDefinitionCompiler.java @@ -0,0 +1,299 @@ +package art.arcane.iris.pack.datapack.export.compile.biome; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.biome.IrisBiomeCustom; +import art.arcane.iris.generation.biome.IrisVanillaBiomeTags; +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; +import art.arcane.iris.pack.datapack.export.ir.BiomeDef; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.datapack.export.ir.JsonResource; +import art.arcane.iris.pack.validation.ContentGate; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.VanillaDataSource; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.SortedMap; +import java.util.SortedSet; +import java.util.TreeMap; +import java.util.TreeSet; + +public final class BiomeDefinitionCompiler { + private static final String VANILLA_NAMESPACE = "minecraft"; + private static final String BIOME_FOLDER = "/worldgen/biome/"; + private static final String TAG_FOLDER = "/tags/worldgen/biome"; + private static final String JSON_SUFFIX = ".json"; + private static final String SPAWNS_ATTRIBUTE = "minecraft:gameplay/natural_mob_spawns"; + private static final String SPAWN_PROBABILITY_ATTRIBUTE = "minecraft:gameplay/creature_world_gen_spawn_probability"; + private static final String SPAWN_PROBABILITY = "creature_spawn_probability"; + private static final List CLIMATE_KEYS = List.of("has_precipitation", "temperature", "downfall", "temperature_modifier"); + private static final List CUSTOM_KEYS = List.of("has_precipitation", "temperature", "downfall", "temperature_modifier", + "effects", "attributes", "spawners", "spawn_costs", SPAWN_PROBABILITY); + + private final Optional vanilla; + + public BiomeDefinitionCompiler(Optional vanilla) { + this.vanilla = Objects.requireNonNull(vanilla); + } + + public static BiomeDefinitionCompiler forTarget(DataVersion target) { + if (!IrisPlatforms.isBound()) { + return new BiomeDefinitionCompiler(Optional.empty()); + } + IrisPlatform platform = IrisPlatforms.get(); + boolean running = DataVersion.forMinecraftVersion(platform.minecraftVersion()) == target; + return new BiomeDefinitionCompiler(running ? Optional.ofNullable(platform.vanillaData()) : Optional.empty()); + } + + public Definitions compile(PackCompileContext context, Map loadKeyByBiomeId) { + DataVersion target = context.capabilities().target(); + ContentGate gate = vanilla.isPresent() ? ContentGate.forData(context.data()) : new ContentGate(null, Map.of(), null); + if (vanilla.isEmpty()) { + diagnose(context, "dimensions/" + context.dimension().getLoadKey(), "Vanilla biome data for Minecraft " + target.getVersion() + + " is not available on this server; biomes are built from their Iris fields without vanilla music, ambience, spawns or structure tags"); + } + Map> vanillaTags = vanilla.map(BiomeDefinitionCompiler::directTags).orElse(Map.of()); + SortedMap> members = new TreeMap<>(); + List biomes = new ArrayList<>(loadKeyByBiomeId.size()); + for (Map.Entry entry : new TreeMap<>(loadKeyByBiomeId).entrySet()) { + String id = entry.getKey(); + IrisBiome biome = Objects.requireNonNull(context.data().getBiomeLoader().load(entry.getValue(), false), + "Biome '" + entry.getValue() + "' could not be loaded"); + IrisBiomeCustom custom = custom(context, biome); + String visualKey = biome.getBiomeScatter().isEmpty() ? biome.getDerivativeKey() : namespaced(biome.getBiomeScatter().getFirst()); + Optional base = vanilla.flatMap(source -> read(source, visualKey)); + if (vanilla.isPresent() && base.isEmpty()) { + diagnose(context, "biomes/" + biome.getLoadKey(), "Vanilla biome '" + visualKey + "' has no data for Minecraft " + target.getVersion() + + "; the biome is built from its Iris fields"); + } + JsonObject overlay = custom == null ? null : customJson(custom, target, gate); + JsonObject json = base.isPresent() + ? merge(base.get(), overlay, custom) + : retained(overlay == null ? customJson(new IrisBiomeCustom(), target, gate) : overlay); + json.add("carvers", new JsonArray()); + json.add("features", new JsonArray()); + biomes.add(new BiomeDef(id.substring(id.indexOf(':') + 1), json)); + for (String tag : tags(biome, custom, vanillaTags)) { + members.computeIfAbsent(tag, ignored -> new TreeSet<>()).add(id); + } + } + List tags = new ArrayList<>(members.size()); + for (Map.Entry> tag : members.entrySet()) { + JsonObject json = new JsonObject(); + json.addProperty("replace", false); + JsonArray values = new JsonArray(tag.getValue().size()); + for (String member : tag.getValue()) { + values.add(member); + } + json.add("values", values); + int separator = tag.getKey().indexOf(':'); + tags.add(new JsonResource("data/" + tag.getKey().substring(0, separator) + TAG_FOLDER + "/" + tag.getKey().substring(separator + 1) + JSON_SUFFIX, json)); + } + return new Definitions(biomes, tags); + } + + private static IrisBiomeCustom custom(PackCompileContext context, IrisBiome biome) { + boolean customs = biome.getCustomDerivitives() != null && biome.getCustomDerivitives().size() > 1; + if (customs || biome.getBiomeScatter().size() > 1) { + context.diagnostics().add(new ExportDiagnostic(ExportDiagnostic.Severity.INFO, ExportLimit.BIOME_VARIANT, "biomes/" + biome.getLoadKey(), + "Biomes with several custom derivatives or scatter biomes export as one biome using the first entry")); + } + return biome.isCustom() ? biome.getCustomDerivitives().getFirst() : null; + } + + private static JsonObject customJson(IrisBiomeCustom custom, DataVersion target, ContentGate gate) { + return JsonParser.parseString(custom.generateJson(target.get(), gate)).getAsJsonObject(); + } + + private static JsonObject merge(JsonObject base, JsonObject overlay, IrisBiomeCustom custom) { + JsonObject merged = base.deepCopy(); + if (overlay == null) { + return merged; + } + for (String key : CLIMATE_KEYS) { + if (overlay.has(key)) { + merged.add(key, overlay.get(key).deepCopy()); + } + } + boolean spawnRarity = custom.getSpawnRarity() > 0; + putAll(object(merged, "effects"), overlay.getAsJsonObject("effects")); + JsonObject overlayAttributes = overlay.getAsJsonObject("attributes"); + if (overlayAttributes != null) { + JsonObject attributes = object(merged, "attributes"); + for (Map.Entry attribute : overlayAttributes.entrySet()) { + switch (attribute.getKey()) { + case SPAWNS_ATTRIBUTE -> mergeSpawnAttribute(attributes, attribute.getValue()); + case SPAWN_PROBABILITY_ATTRIBUTE -> { + if (spawnRarity) { + attributes.add(attribute.getKey(), attribute.getValue().deepCopy()); + } + } + default -> attributes.add(attribute.getKey(), attribute.getValue().deepCopy()); + } + } + } + if (overlay.has("spawners")) { + mergeSpawns(merged, overlay); + } + if (spawnRarity && overlay.has(SPAWN_PROBABILITY)) { + merged.add(SPAWN_PROBABILITY, overlay.get(SPAWN_PROBABILITY).deepCopy()); + } + return merged; + } + + private static void mergeSpawnAttribute(JsonObject attributes, JsonElement overlay) { + JsonElement existing = attributes.get(SPAWNS_ATTRIBUTE); + if (existing == null || !existing.isJsonObject() || !overlay.isJsonObject()) { + attributes.add(SPAWNS_ATTRIBUTE, overlay.deepCopy()); + return; + } + JsonObject target = spawnHolder(existing.getAsJsonObject()); + JsonObject source = spawnHolder(overlay.getAsJsonObject()); + appendCategories(object(target, "spawns_by_category"), source.getAsJsonObject("spawns_by_category")); + putAll(object(target, "spawn_costs"), source.getAsJsonObject("spawn_costs")); + } + + private static void mergeSpawns(JsonObject merged, JsonObject overlay) { + appendCategories(object(merged, "spawners"), overlay.getAsJsonObject("spawners")); + putAll(object(merged, "spawn_costs"), overlay.getAsJsonObject("spawn_costs")); + } + + private static JsonObject spawnHolder(JsonObject attribute) { + JsonElement argument = attribute.get("argument"); + return argument != null && argument.isJsonObject() ? argument.getAsJsonObject() : attribute; + } + + private static void appendCategories(JsonObject target, JsonObject source) { + if (source == null) { + return; + } + for (Map.Entry category : source.entrySet()) { + JsonElement existing = target.get(category.getKey()); + JsonArray spawns = existing != null && existing.isJsonArray() ? existing.getAsJsonArray() : new JsonArray(); + spawns.addAll(category.getValue().deepCopy().getAsJsonArray()); + target.add(category.getKey(), spawns); + } + } + + private static void putAll(JsonObject target, JsonObject source) { + if (source == null) { + return; + } + for (Map.Entry entry : source.entrySet()) { + target.add(entry.getKey(), entry.getValue().deepCopy()); + } + } + + private static JsonObject object(JsonObject parent, String key) { + JsonElement existing = parent.get(key); + if (existing != null && existing.isJsonObject()) { + return existing.getAsJsonObject(); + } + JsonObject created = new JsonObject(); + parent.add(key, created); + return created; + } + + private static JsonObject retained(JsonObject custom) { + JsonObject retained = new JsonObject(); + for (String key : CUSTOM_KEYS) { + if (custom.has(key)) { + retained.add(key, custom.get(key).deepCopy()); + } + } + return retained; + } + + private static SortedSet tags(IrisBiome biome, IrisBiomeCustom custom, Map> vanillaTags) { + SortedSet tags = new TreeSet<>(); + if (custom != null && custom.getTags() != null) { + for (String tag : custom.getTags()) { + String normalized = tagId(tag); + if (normalized != null) { + tags.add(normalized); + } + } + } + String structureKey = biome.getVanillaDerivativeKey(); + tags.addAll(vanillaTags.isEmpty() ? IrisVanillaBiomeTags.tagsFor(structureKey) : vanillaTags.getOrDefault(structureKey, List.of())); + return tags; + } + + private static Map> directTags(VanillaDataSource source) { + String root = "data/" + VANILLA_NAMESPACE + TAG_FOLDER; + Map> tags = new HashMap<>(); + for (String path : source.list(root)) { + if (!path.endsWith(JSON_SUFFIX)) { + continue; + } + String tag = VANILLA_NAMESPACE + ":" + path.substring(root.length() + 1, path.length() - JSON_SUFFIX.length()); + Optional json = read(source, path); + JsonArray values = json.isEmpty() ? null : json.get().getAsJsonArray("values"); + if (values == null) { + continue; + } + for (JsonElement value : values) { + String member = value.isJsonObject() ? value.getAsJsonObject().get("id").getAsString() : value.getAsString(); + if (!member.startsWith("#")) { + tags.computeIfAbsent(namespaced(member), ignored -> new ArrayList<>()).add(tag); + } + } + } + return tags; + } + + private static Optional read(VanillaDataSource source, String biomeKeyOrPath) { + String path = biomeKeyOrPath.startsWith("data/") ? biomeKeyOrPath : biomePath(biomeKeyOrPath); + if (path == null) { + return Optional.empty(); + } + return source.read(path).map(bytes -> JsonParser.parseString(new String(bytes, StandardCharsets.UTF_8)).getAsJsonObject()); + } + + private static String biomePath(String biomeKey) { + if (biomeKey == null || !VanillaResourceIdentifier.validIdentifier(biomeKey)) { + return null; + } + int separator = biomeKey.indexOf(':'); + return "data/" + biomeKey.substring(0, separator) + BIOME_FOLDER + biomeKey.substring(separator + 1) + JSON_SUFFIX; + } + + private static String namespaced(String key) { + String trimmed = key.trim().toLowerCase(Locale.ROOT); + return trimmed.indexOf(':') < 0 ? VANILLA_NAMESPACE + ":" + trimmed : trimmed; + } + + private static String tagId(String tag) { + if (tag == null || tag.isBlank()) { + return null; + } + String trimmed = tag.trim(); + String normalized = namespaced(trimmed.startsWith("#") ? trimmed.substring(1) : trimmed); + return VanillaResourceIdentifier.validIdentifier(normalized) ? normalized : null; + } + + private static void diagnose(PackCompileContext context, String resource, String message) { + context.diagnostics().add(new ExportDiagnostic(ExportDiagnostic.Severity.WARNING, ExportLimit.VANILLA_BIOME_DATA, resource, message)); + } + + public record Definitions(List biomes, List tags) { + public Definitions { + biomes = List.copyOf(biomes); + tags = List.copyOf(tags); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/biome/BiomeIdSanitizer.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/biome/BiomeIdSanitizer.java new file mode 100644 index 000000000..6eaa34277 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/biome/BiomeIdSanitizer.java @@ -0,0 +1,27 @@ +package art.arcane.iris.pack.datapack.export.compile.biome; + +import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; + +import java.util.Objects; +import java.util.Set; + +public final class BiomeIdSanitizer { + private static final int FIRST_SUFFIX = 2; + + private BiomeIdSanitizer() { + } + + public static String sanitize(String namespace, String loadKey, Set taken) { + VanillaResourceIdentifier.requireNamespace(namespace, "biome namespace"); + Objects.requireNonNull(taken); + String path = VanillaResourceIdentifier.sanitizePath(loadKey); + String id = namespace + ":" + path; + int suffix = FIRST_SUFFIX; + while (taken.contains(id)) { + id = namespace + ":" + path + "_" + suffix; + suffix++; + } + taken.add(id); + return id; + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/biome/BiomePools.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/biome/BiomePools.java new file mode 100644 index 000000000..da9a7b107 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/biome/BiomePools.java @@ -0,0 +1,192 @@ +package art.arcane.iris.pack.datapack.export.compile.biome; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.terrain.InferredType; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.generation.terrain.IrisRegion; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.pack.value.IrisRaritySelection; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.Rarity; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.SortedMap; +import java.util.SortedSet; +import java.util.TreeMap; +import java.util.TreeSet; + +final class BiomePools { + static final int MAX_CHILD_HOPS = 4; + static final List SURFACE_TYPES = List.of(InferredType.LAND, InferredType.SEA, InferredType.SHORE); + + private final List regions; + private final SortedMap biomes; + private final SortedSet surface; + private final SortedSet cave; + private final Map plans; + + private BiomePools(List regions, SortedMap biomes, SortedSet surface, SortedSet cave, Map plans) { + this.regions = regions; + this.biomes = biomes; + this.surface = surface; + this.cave = cave; + this.plans = plans; + } + + static BiomePools load(IrisDimension dimension, IrisData data) { + List regions = new ArrayList<>(); + SortedMap biomes = new TreeMap<>(); + for (IrisRegion region : data.getRegionLoader().loadAll(dimension.getRegions())) { + if (region.isCompatExcluded()) { + continue; + } + RegionPools pools = new RegionPools( + region, + pool(data, region.getLandBiomes(), biomes), + pool(data, region.getSeaBiomes(), biomes), + pool(data, region.getShoreBiomes(), biomes), + pool(data, region.getCaveBiomes(), biomes)); + if (pools.land().isEmpty() && pools.sea().isEmpty()) { + throw new IllegalStateException("Region '" + region.getLoadKey() + "' has no land or sea biome to export"); + } + regions.add(pools); + } + if (regions.isEmpty()) { + throw new IllegalStateException("Dimension '" + dimension.getLoadKey() + "' has no exportable region"); + } + SortedSet cave = new TreeSet<>(); + SortedSet frontier = new TreeSet<>(); + for (RegionPools pools : regions) { + for (InferredType type : SURFACE_TYPES) { + for (IrisBiome biome : pools.of(type)) { + frontier.add(biome.getLoadKey()); + } + } + for (IrisBiome biome : pools.cave()) { + cave.add(biome.getLoadKey()); + } + } + Map plans = new HashMap<>(); + SortedSet surface = new TreeSet<>(frontier); + for (int hop = 0; hop < MAX_CHILD_HOPS && !frontier.isEmpty(); hop++) { + SortedSet next = new TreeSet<>(); + for (String key : frontier) { + ChildPlan plan = plans.computeIfAbsent(key, ignored -> ChildPlan.of(biomes.get(key), data, biomes)); + for (String choice : plan.choices()) { + if (surface.add(choice)) { + next.add(choice); + } + } + } + frontier = next; + } + return new BiomePools(List.copyOf(regions), biomes, surface, cave, plans); + } + + static List rarityCuts(List options) { + double total = 0.0; + for (Rarity option : options) { + total += 1.0 / Rarity.get(option); + } + List cuts = new ArrayList<>(Math.max(0, options.size() - 1)); + double cumulative = 0.0; + for (int index = 0; index + 1 < options.size(); index++) { + cumulative += 1.0 / Rarity.get(options.get(index)); + cuts.add(cumulative / total); + } + return cuts; + } + + static List selectionCuts(int size) { + int maximum = size - 1; + List cuts = new ArrayList<>(Math.max(0, maximum)); + for (int index = 0; index < maximum; index++) { + cuts.add((index + 0.5) / maximum); + } + return cuts; + } + + List regions() { + return regions; + } + + IrisBiome biome(String loadKey) { + return Objects.requireNonNull(biomes.get(loadKey), "Biome '" + loadKey + "' was not loaded"); + } + + SortedSet surface() { + return surface; + } + + SortedSet cave() { + return cave; + } + + ChildPlan plan(String loadKey) { + return plans.get(loadKey); + } + + private static List pool(IrisData data, KList keys, SortedMap biomes) { + List pool = new ArrayList<>(); + for (IrisBiome biome : data.getBiomeLoader().loadAll(keys)) { + if (biome.isCompatExcluded()) { + continue; + } + biomes.putIfAbsent(biome.getLoadKey(), biome); + pool.add(biome); + } + return List.copyOf(pool); + } + + record RegionPools(IrisRegion region, List land, List sea, List shore, List cave) { + List of(InferredType type) { + return switch (type) { + case LAND -> land; + case SEA -> sea; + case SHORE -> shore; + case CAVE -> cave; + }; + } + } + + record ChildPlan(String parent, List choiceByIndex) { + static ChildPlan of(IrisBiome parent, IrisData data, SortedMap biomes) { + if (parent.getChildren().isEmpty()) { + return new ChildPlan(parent.getLoadKey(), List.of(parent.getLoadKey())); + } + KList options = new KList<>(); + for (IrisBiome child : parent.getRealChildren(() -> data)) { + if (child != null) { + biomes.putIfAbsent(child.getLoadKey(), child); + options.add(child); + } + } + options.add(parent); + IrisRaritySelection selection = IrisRaritySelection.create(options); + long size = selection.size(); + List choices = new ArrayList<>((int) size); + for (long index = 0; index < size; index++) { + choices.add(selection.get(index).getLoadKey()); + } + return new ChildPlan(parent.getLoadKey(), List.copyOf(choices)); + } + + boolean moves() { + for (String choice : choiceByIndex) { + if (!choice.equals(parent)) { + return true; + } + } + return false; + } + + SortedSet choices() { + return new TreeSet<>(choiceByIndex); + } + + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/biome/BiomeStage.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/biome/BiomeStage.java new file mode 100644 index 000000000..bb015f21d --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/biome/BiomeStage.java @@ -0,0 +1,21 @@ +package art.arcane.iris.pack.datapack.export.compile.biome; + +import art.arcane.iris.pack.datapack.export.compile.CompileStage; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; + +public final class BiomeStage implements CompileStage { + @Override + public String name() { + return "biomes"; + } + + @Override + public void apply(PackCompileContext context) { + BiomeDecision decision = new BiomeDecisionCompiler(context.styles()).compile(context); + context.setBiomeDecision(decision); + BiomeDefinitionCompiler.Definitions definitions = BiomeDefinitionCompiler.forTarget(context.capabilities().target()) + .compile(context, decision.base().loadKeyByBiomeId()); + context.setBiomes(definitions.biomes()); + context.jsonResources().addAll(definitions.tags()); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/biome/CarvingZones.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/biome/CarvingZones.java new file mode 100644 index 000000000..cf69b2048 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/biome/CarvingZones.java @@ -0,0 +1,239 @@ +package art.arcane.iris.pack.datapack.export.compile.biome; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.generation.terrain.IrisDimensionCarvingEntry; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.pack.value.IrisRange; +import art.arcane.iris.pack.value.IrisRaritySelection; +import art.arcane.volmlib.util.math.Rarity; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.SortedSet; +import java.util.TreeSet; +import java.util.function.ToIntFunction; + +final class CarvingZones { + static final int SHALLOW_ZONE = 0; + static final int CAVE_ZONE = 1; + static final int FIRST_CARVING_ZONE = 2; + private static final int MAX_CHILD_DEPTH = 32; + private static final double MINIMUM_CHILD_SHRINK_FACTOR = 0.0001; + private static final long CARVING_SALT = 0x0CA4_7E00L; + + private final List cuts; + private final List zoneByInterval; + private final List machines; + private final SortedSet biomes; + + private CarvingZones(List cuts, List zoneByInterval, List machines, SortedSet biomes) { + this.cuts = cuts; + this.zoneByInterval = zoneByInterval; + this.machines = machines; + this.biomes = biomes; + } + + static CarvingZones plan(IrisDimension dimension, IrisData data, int deepTop) { + List entries = new ArrayList<>(); + if (dimension.getCarving() != null) { + for (IrisDimensionCarvingEntry entry : dimension.getCarving()) { + if (entry != null && entry.isEnabled() && entry.getRealBiome(data) != null) { + entries.add(entry); + } + } + } + SortedSet cutSet = new TreeSet<>(); + cutSet.add(deepTop + 1); + for (IrisDimensionCarvingEntry entry : entries) { + IrisRange range = entry.getWorldYRange(); + if (range != null && range.getMin() <= range.getMax()) { + cutSet.add((int) Math.ceil(range.getMin())); + cutSet.add((int) Math.floor(range.getMax()) + 1); + } + } + List cuts = List.copyOf(cutSet); + Map byId = dimension.getCarvingEntryIndex(); + Map zones = new LinkedHashMap<>(); + List machines = new ArrayList<>(); + SortedSet biomes = new TreeSet<>(); + List zoneByInterval = new ArrayList<>(cuts.size() + 1); + for (int interval = 0; interval <= cuts.size(); interval++) { + int y = interval == 0 ? cuts.getFirst() - 1 : cuts.get(interval - 1); + if (y > deepTop) { + zoneByInterval.add(SHALLOW_ZONE); + continue; + } + IrisDimensionCarvingEntry root = null; + List inRange = new ArrayList<>(); + for (int position = 0; position < entries.size(); position++) { + IrisDimensionCarvingEntry entry = entries.get(position); + if (contains(entry, y)) { + root = entry; + inRange.add(position); + } + } + if (root == null) { + zoneByInterval.add(CAVE_ZONE); + continue; + } + ZoneKey key = new ZoneKey(entries.indexOf(root), List.copyOf(inRange)); + Integer zone = zones.get(key); + if (zone == null) { + Machine machine = Machine.build(root, y, byId, data); + zone = FIRST_CARVING_ZONE + machines.size(); + zones.put(key, zone); + machines.add(machine); + for (State state : machine.states()) { + biomes.add(state.entry().getRealBiome(data).getLoadKey()); + } + } + zoneByInterval.add(zone); + } + return new CarvingZones(cuts, List.copyOf(zoneByInterval), List.copyOf(machines), biomes); + } + + SortedSet biomes() { + return biomes; + } + + IndexedField zoneKeys(Field y) { + List thresholds = new ArrayList<>(cuts.size()); + for (int cut : cuts) { + thresholds.add((double) cut); + } + List leaves = new ArrayList<>(zoneByInterval.size()); + for (int zone : zoneByInterval) { + leaves.add(zone * 4); + } + return IndexedField.partition(y, thresholds, leaves); + } + + List slots(SelectorCompiler selectors, IrisData data, ToIntFunction undergroundSlot) { + List slots = new ArrayList<>(machines.size()); + for (int zone = 0; zone < machines.size(); zone++) { + slots.add(machines.get(zone).slot("biome/carving/" + zone, selectors, data, undergroundSlot)); + } + return slots; + } + + private static boolean contains(IrisDimensionCarvingEntry entry, int y) { + IrisRange range = entry.getWorldYRange(); + return range == null || range.contains(y); + } + + private static int clampDepth(int depth) { + return depth <= 0 ? 0 : Math.min(depth, MAX_CHILD_DEPTH); + } + + private record ZoneKey(int root, List inRange) { + } + + private record State(IrisDimensionCarvingEntry entry, int depth) { + } + + private record Move(IrisDimensionCarvingEntry entry, List nextByIndex) { + } + + private record CarvingOption(IrisDimensionCarvingEntry entry, int rarity) implements Rarity { + @Override + public int getRarity() { + return rarity; + } + } + + private record Machine(List states, Map moves, int hops) { + static Machine build(IrisDimensionCarvingEntry root, int y, Map byId, IrisData data) { + List states = new ArrayList<>(); + Map ids = new HashMap<>(); + Map moves = new HashMap<>(); + int hops = clampDepth(root.getChildRecursionDepth()); + State start = new State(root, terminal(root, byId, data) ? 0 : hops); + states.add(start); + ids.put(start, 0); + for (int cursor = 0; cursor < states.size(); cursor++) { + State state = states.get(cursor); + if (state.depth() <= 0) { + continue; + } + IrisRaritySelection selection = selection(state.entry(), byId, data); + if (selection.size() <= 1L) { + continue; + } + List nextByIndex = new ArrayList<>((int) selection.size()); + for (long index = 0; index < selection.size(); index++) { + IrisDimensionCarvingEntry chosen = selection.get(index).entry(); + State next = chosen == state.entry() || !contains(chosen, y) + ? new State(state.entry(), 0) + : new State(chosen, Math.min(state.depth() - 1, clampDepth(chosen.getChildRecursionDepth()))); + Integer id = ids.get(next); + if (id == null) { + id = states.size(); + ids.put(next, id); + states.add(next); + } + nextByIndex.add(id); + } + moves.put(cursor, new Move(state.entry(), List.copyOf(nextByIndex))); + } + return new Machine(List.copyOf(states), Map.copyOf(moves), hops); + } + + IndexedField slot(String name, SelectorCompiler selectors, IrisData data, ToIntFunction undergroundSlot) { + IndexedField current = IndexedField.constant(0); + for (int hop = 0; hop < hops; hop++) { + List movers = new ArrayList<>(); + for (int state : current.values()) { + if (moves.containsKey(state)) { + movers.add(state); + } + } + if (movers.isEmpty()) { + break; + } + Map moved = new HashMap<>(movers.size() * 2); + for (int state : movers) { + Move move = moves.get(state); + IrisDimensionCarvingEntry entry = move.entry(); + SelectorCompiler.Selector selector = selectors.scaled( + entry.getChildStyle(), + Math.max(MINIMUM_CHILD_SHRINK_FACTOR, entry.getChildShrinkFactor()), + CARVING_SALT + Objects.toString(entry.getId(), "").trim().hashCode()); + moved.put(state, selector.partition(BiomePools.selectionCuts(move.nextByIndex().size()), move.nextByIndex())); + } + current = IndexedField.advance(current, moved).shared(name + "/hop" + (hop + 1)); + } + return current.remap(state -> undergroundSlot.applyAsInt(states.get(state).entry().getRealBiome(data).getLoadKey())); + } + + private static boolean terminal(IrisDimensionCarvingEntry entry, Map byId, IrisData data) { + return entry.getChildren() == null || entry.getChildren().isEmpty() || selection(entry, byId, data).size() <= 1L; + } + + private static IrisRaritySelection selection(IrisDimensionCarvingEntry parent, Map byId, IrisData data) { + List options = new ArrayList<>(); + if (parent.getChildren() != null) { + for (String childId : parent.getChildren()) { + if (childId == null || childId.isBlank()) { + continue; + } + IrisDimensionCarvingEntry child = byId.get(childId.trim()); + if (child == null || !child.isEnabled()) { + continue; + } + IrisBiome childBiome = child.getRealBiome(data); + if (childBiome != null) { + options.add(new CarvingOption(child, Math.max(childBiome.getRarity(), 1))); + } + } + } + options.add(new CarvingOption(parent, Math.max(parent.getRealBiome(data).getRarity(), 1))); + return IrisRaritySelection.create(options); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/biome/IndexedField.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/biome/IndexedField.java new file mode 100644 index 000000000..a885b25df --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/biome/IndexedField.java @@ -0,0 +1,162 @@ +package art.arcane.iris.pack.datapack.export.compile.biome; + +import art.arcane.iris.pack.datapack.export.ir.Field; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.SortedSet; +import java.util.TreeSet; +import java.util.function.IntToDoubleFunction; +import java.util.function.IntUnaryOperator; + +record IndexedField(Field field, List values) { + private static final Field UNUSED_CASE = new Field.Const(0.0); + + IndexedField { + Objects.requireNonNull(field); + values = List.copyOf(new TreeSet<>(values)); + if (values.isEmpty()) { + throw new IllegalArgumentException("An indexed field needs at least one value"); + } + } + + static IndexedField constant(int value) { + return new IndexedField(new Field.Const(value), List.of(value)); + } + + static IndexedField partition(Field input, List thresholds, List leaves) { + if (leaves.size() != thresholds.size() + 1) { + throw new IllegalArgumentException("Partition needs " + (thresholds.size() + 1) + " leaves, got " + leaves.size()); + } + List kept = new ArrayList<>(thresholds.size()); + List keptLeaves = new ArrayList<>(leaves.size()); + keptLeaves.add(leaves.getFirst()); + for (int index = 0; index < thresholds.size(); index++) { + int next = leaves.get(index + 1); + if (next == keptLeaves.getLast()) { + continue; + } + kept.add(thresholds.get(index)); + keptLeaves.add(next); + } + if (keptLeaves.size() == 1) { + return constant(keptLeaves.getFirst()); + } + List constants = new ArrayList<>(keptLeaves.size()); + for (int leaf : keptLeaves) { + constants.add(new Field.Const(leaf)); + } + return new IndexedField(new Field.Partition(input, kept, constants), keptLeaves); + } + + static IndexedField select(IndexedField index, List cases) { + if (index.isConstant()) { + return cases.get(index.values().getFirst()); + } + IndexedField first = cases.get(index.values().getFirst()); + boolean uniform = true; + SortedSet reachable = new TreeSet<>(); + for (int value : index.values()) { + IndexedField selected = cases.get(value); + uniform &= selected.equals(first); + reachable.addAll(selected.values()); + } + if (uniform) { + return first; + } + int count = index.values().getLast() + 1; + List muxCases = new ArrayList<>(count); + for (int value = 0; value < count; value++) { + muxCases.add(index.values().contains(value) ? cases.get(value).field() : UNUSED_CASE); + } + return new IndexedField(new Field.Mux(index.field(), muxCases), List.copyOf(reachable)); + } + + static IndexedField advance(IndexedField current, Map moved) { + int last = current.values().getLast(); + List cases = new ArrayList<>(last + 1); + for (int value = 0; value <= last; value++) { + IndexedField next = moved.get(value); + cases.add(next == null ? constant(value) : next); + } + return select(current, cases); + } + + static IndexedField sum(List terms) { + SortedSet values = new TreeSet<>(List.of(0)); + int offset = 0; + Field field = null; + for (IndexedField term : terms) { + SortedSet combined = new TreeSet<>(); + for (int value : values) { + for (int addend : term.values()) { + combined.add(value + addend); + } + } + values = combined; + if (term.isConstant()) { + offset += term.values().getFirst(); + } else { + field = field == null ? term.field() : new Field.Binary(Field.BinaryOp.ADD, field, term.field()); + } + } + if (field == null) { + return constant(offset); + } + Field total = offset == 0 ? field : new Field.Binary(Field.BinaryOp.ADD, field, new Field.Const(offset)); + return new IndexedField(total, List.copyOf(values)); + } + + static List midpoints(List sorted) { + List thresholds = new ArrayList<>(sorted.size() - 1); + for (int index = 0; index + 1 < sorted.size(); index++) { + thresholds.add((sorted.get(index) + sorted.get(index + 1)) / 2.0); + } + return thresholds; + } + + boolean isConstant() { + return values.size() == 1; + } + + IndexedField remap(IntUnaryOperator mapping) { + List mapped = new ArrayList<>(values.size()); + for (int value : values) { + mapped.add(mapping.applyAsInt(value)); + } + if (isConstant()) { + return constant(mapped.getFirst()); + } + return partition(field, midpoints(values), mapped); + } + + Field lookup(IntToDoubleFunction table) { + if (isConstant()) { + return new Field.Const(table.applyAsDouble(values.getFirst())); + } + List thresholds = midpoints(values); + List keptThresholds = new ArrayList<>(thresholds.size()); + List leaves = new ArrayList<>(values.size()); + double previous = table.applyAsDouble(values.getFirst()); + leaves.add(new Field.Const(previous)); + for (int index = 1; index < values.size(); index++) { + double next = table.applyAsDouble(values.get(index)); + if (next == previous) { + continue; + } + keptThresholds.add(thresholds.get(index - 1)); + leaves.add(new Field.Const(next)); + previous = next; + } + if (leaves.size() == 1) { + return leaves.getFirst(); + } + return new Field.Partition(field, keptThresholds, leaves); + } + + IndexedField shared(String name) { + return isConstant() ? this : new IndexedField(new Field.Shared(name, field), values); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/biome/RarityCalibrator.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/biome/RarityCalibrator.java new file mode 100644 index 000000000..5fdf0f943 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/biome/RarityCalibrator.java @@ -0,0 +1,115 @@ +package art.arcane.iris.pack.datapack.export.compile.biome; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.compile.noise.NoiseRegistry; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.FieldEvaluator; +import art.arcane.iris.pack.datapack.export.ir.NoiseDef; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.SplittableRandom; +import java.util.function.DoubleBinaryOperator; + +public final class RarityCalibrator { + private static final int SAMPLES = 8192; + private static final double WINDOW_FEATURES = 96.0; + private static final double MAX_WINDOW_BLOCKS = 4_000_000.0; + private static final long POSITION_SEED = 0x5EED_B10EL; + private static final long WORLD_SEED = 0x1215_B10EL; + + private final NoiseRegistry registry; + private final DataVersion target; + + public RarityCalibrator(NoiseRegistry registry, DataVersion target) { + this.registry = Objects.requireNonNull(registry); + this.target = Objects.requireNonNull(target); + } + + public Calibration calibrate(DoubleBinaryOperator iris, Field compiled, double featureBlocks, long salt) { + Objects.requireNonNull(iris); + Objects.requireNonNull(compiled); + double window = Math.min(MAX_WINDOW_BLOCKS, Math.max(1.0, featureBlocks) * WINDOW_FEATURES); + Map noises = noiseDefinitions(); + SplittableRandom positions = new SplittableRandom(POSITION_SEED ^ salt); + double[] irisSamples = new double[SAMPLES]; + double[] compiledSamples = new double[SAMPLES]; + for (int index = 0; index < SAMPLES; index++) { + int x = (int) Math.floor((positions.nextDouble() - 0.5) * window); + int z = (int) Math.floor((positions.nextDouble() - 0.5) * window); + irisSamples[index] = iris.applyAsDouble(x, z); + compiledSamples[index] = compiled instanceof Field.Const constant + ? constant.value() + : FieldEvaluator.sample(compiled, noises, WORLD_SEED, target, x, 0, z); + } + Arrays.sort(irisSamples); + Arrays.sort(compiledSamples); + return new Calibration(irisSamples, compiledSamples); + } + + private Map noiseDefinitions() { + List definitions = registry.definitions(); + Map noises = new HashMap<>(definitions.size() * 2); + for (NoiseDef definition : definitions) { + noises.put(registry.namespace() + ":" + definition.id(), definition); + } + return noises; + } + + public static final class Calibration { + private final double[] iris; + private final double[] compiled; + + private Calibration(double[] iris, double[] compiled) { + this.iris = iris; + this.compiled = compiled; + } + + public double cumulative(double irisValue) { + int low = 0; + int high = iris.length; + while (low < high) { + int middle = (low + high) >>> 1; + if (iris[middle] < irisValue) { + low = middle + 1; + } else { + high = middle; + } + } + return low / (double) iris.length; + } + + public double threshold(double probability) { + return quantile(compiled, probability); + } + + public double irisQuantile(double probability) { + return quantile(iris, probability); + } + + public List thresholds(List irisCuts) { + List thresholds = new ArrayList<>(irisCuts.size()); + for (double cut : irisCuts) { + thresholds.add(threshold(cumulative(cut))); + } + return thresholds; + } + + private static double quantile(double[] sorted, double probability) { + double position = Math.clamp(probability, 0.0, 1.0) * sorted.length - 0.5; + if (position <= 0.0) { + return sorted[0]; + } + if (position >= sorted.length - 1) { + return sorted[sorted.length - 1]; + } + int lower = (int) Math.floor(position); + double fraction = position - lower; + return sorted[lower] + fraction * (sorted[lower + 1] - sorted[lower]); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/biome/SelectorCompiler.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/biome/SelectorCompiler.java new file mode 100644 index 000000000..175bcaf22 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/biome/SelectorCompiler.java @@ -0,0 +1,184 @@ +package art.arcane.iris.pack.datapack.export.compile.biome; + +import art.arcane.iris.generation.noise.IrisGeneratorStyle; +import art.arcane.iris.pack.datapack.export.compile.noise.NoiseRegistry; +import art.arcane.iris.pack.datapack.export.compile.noise.StyleCompiler; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.FieldValidator; +import art.arcane.iris.pack.datapack.export.lower.ExportCapabilities; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.noise.CNG; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.DoubleBinaryOperator; + +public final class SelectorCompiler { + private static final double COHERENT_BLOCKS = 64.0; + private static final long CALIBRATION_SEED = 0x1215_5E1EL; + private static final int CURVE_KNOTS = 9; + private static final String SHARED_PREFIX = "biome/selector/"; + + private final StyleCompiler styles; + private final IrisData data; + private final NoiseRegistry registry; + private final ExportCapabilities capabilities; + private final RarityCalibrator calibrator; + private final Map selectors = new HashMap<>(); + private int shared; + + public SelectorCompiler(StyleCompiler styles, Sources sources) { + this.styles = Objects.requireNonNull(styles); + Objects.requireNonNull(sources); + data = sources.data(); + registry = sources.registry(); + capabilities = sources.capabilities(); + calibrator = new RarityCalibrator(registry, capabilities.target()); + } + + public Selector zoomed(IrisGeneratorStyle style, double zoom, long salt) { + return selectors.computeIfAbsent(new SelectorKey(style, false, zoom, salt), this::create); + } + + public Selector scaled(IrisGeneratorStyle style, double scale, long salt) { + return selectors.computeIfAbsent(new SelectorKey(style, true, scale, salt), this::create); + } + + public Selector sampled(IrisGeneratorStyle style, double zoom, long salt, DoubleBinaryOperator iris) { + IrisGeneratorStyle exported = exportable(style); + exported.setZoom(exported.getZoom() * zoom); + return compile(exported, salt, iris, COHERENT_BLOCKS * exported.getZoom()); + } + + private Selector create(SelectorKey key) { + IrisGeneratorStyle exported = exportable(key.style()); + RNG seed = new RNG(CALIBRATION_SEED).nextParallelRNG(key.salt()); + if (key.scaled()) { + CNG iris = exported.createScaledGenerator(seed, data, key.factor()); + IrisGeneratorStyle compiled = scaledCopy(exported, key.factor()); + return compile(compiled, key.salt(), iris::noiseFast2D, COHERENT_BLOCKS * compiled.getZoom()); + } + CNG iris = exported.create(seed, data); + double zoom = key.factor(); + IrisGeneratorStyle compiled = copy(exported); + compiled.setZoom(exported.getZoom() * zoom); + return compile(compiled, key.salt(), (x, z) -> iris.noise(x / zoom, z / zoom), COHERENT_BLOCKS * compiled.getZoom()); + } + + private Selector compile(IrisGeneratorStyle style, long salt, DoubleBinaryOperator iris, double featureBlocks) { + Field field = styles.compile(style, salt, registry, capabilities); + FieldValidator.requireTwoDimensional(field, "biome selector"); + RarityCalibrator.Calibration calibration = calibrator.calibrate(iris, field, featureBlocks, salt); + Field selector = field instanceof Field.Const ? field : new Field.Shared(SHARED_PREFIX + shared++, field); + return new Selector(selector, calibration); + } + + private static IrisGeneratorStyle exportable(IrisGeneratorStyle style) { + IrisGeneratorStyle copy = copy(style); + for (IrisGeneratorStyle current = copy; current != null; current = current.getFracture()) { + current.setImageMap(null); + current.setCacheSize(0); + } + return copy; + } + + private static IrisGeneratorStyle scaledCopy(IrisGeneratorStyle style, double scale) { + IrisGeneratorStyle copy = copy(style); + copy.setZoom(style.getZoom() / scale); + IrisGeneratorStyle fracture = copy.getFracture(); + if (fracture != null) { + fracture.setZoom(fracture.getZoom() * scale); + fracture.setMultiplier(fracture.getMultiplier() * scale); + } + return copy; + } + + private static IrisGeneratorStyle copy(IrisGeneratorStyle style) { + IrisGeneratorStyle copy = new IrisGeneratorStyle(style.getStyle()); + copy.setCellularFrequency(style.getCellularFrequency()); + copy.setCellularZoom(style.getCellularZoom()); + copy.setZoom(style.getZoom()); + copy.setExpression(style.getExpression()); + copy.setImageMap(style.getImageMap()); + copy.setMultiplier(style.getMultiplier()); + copy.setExponent(style.getExponent()); + copy.setCacheSize(style.getCacheSize()); + copy.setFracture(style.getFracture() == null ? null : copy(style.getFracture())); + return copy; + } + + public record Sources(IrisData data, NoiseRegistry registry, ExportCapabilities capabilities) { + public Sources { + Objects.requireNonNull(data); + Objects.requireNonNull(registry); + Objects.requireNonNull(capabilities); + } + } + + public record Selector(Field field, RarityCalibrator.Calibration calibration) { + public Selector { + Objects.requireNonNull(field); + Objects.requireNonNull(calibration); + } + + IndexedField partition(List irisCuts, List leaves) { + if (leaves.size() != irisCuts.size() + 1) { + throw new IllegalArgumentException("Selector partition needs " + (irisCuts.size() + 1) + " leaves, got " + leaves.size()); + } + List upper = new ArrayList<>(leaves.size()); + List kept = new ArrayList<>(leaves.size()); + double lower = 0.0; + for (int index = 0; index < leaves.size(); index++) { + double bound = index == irisCuts.size() ? 1.0 : Math.max(lower, calibration.cumulative(irisCuts.get(index))); + if (bound > lower) { + upper.add(bound); + kept.add(leaves.get(index)); + } + lower = bound; + } + if (kept.isEmpty()) { + return IndexedField.constant(leaves.getLast()); + } + List thresholds = new ArrayList<>(kept.size() - 1); + for (int index = 0; index + 1 < kept.size(); index++) { + thresholds.add(calibration.threshold(upper.get(index))); + } + return IndexedField.partition(field, thresholds, kept); + } + + Field curve() { + List knots = new ArrayList<>(CURVE_KNOTS); + double previousLocation = Double.NEGATIVE_INFINITY; + List points = new ArrayList<>(CURVE_KNOTS); + for (int index = 0; index < CURVE_KNOTS; index++) { + double probability = (index + 0.5) / CURVE_KNOTS; + double location = calibration.threshold(probability); + if (location <= previousLocation) { + continue; + } + points.add(new double[]{location, calibration.irisQuantile(probability)}); + previousLocation = location; + } + if (points.size() == 1) { + return new Field.Const(points.getFirst()[1]); + } + for (int index = 0; index < points.size(); index++) { + double derivative = 0.0; + if (index > 0 && index + 1 < points.size()) { + double[] before = points.get(index - 1); + double[] after = points.get(index + 1); + derivative = (after[1] - before[1]) / (after[0] - before[0]); + } + knots.add(new Field.Knot(points.get(index)[0], new Field.Const(points.get(index)[1]), derivative)); + } + return new Field.Curve(field, knots); + } + } + + private record SelectorKey(IrisGeneratorStyle style, boolean scaled, double factor, long salt) { + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/cave/AquiferCompiler.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/cave/AquiferCompiler.java new file mode 100644 index 000000000..90a057ed9 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/cave/AquiferCompiler.java @@ -0,0 +1,135 @@ +package art.arcane.iris.pack.datapack.export.compile.cave; + +import art.arcane.iris.generation.cave.IrisCaveProfile; +import art.arcane.iris.pack.datapack.export.ir.AquiferDef; +import art.arcane.iris.pack.datapack.export.ir.Axis; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.datapack.export.ir.Field; + +import java.util.List; +import java.util.Objects; +import java.util.function.Predicate; + +public final class AquiferCompiler { + private static final int LAVA_LIMIT_Y = -10; + private static final int LAVA_CENTER_MARGIN = 12; + private static final int FLUID_BAND_HEIGHT = 40; + private static final int FLUID_BAND_CENTER = 20; + private static final double SPREAD_LEVEL_SCALE = 10.0; + private static final double LEVEL_ROUNDING_MARGIN = 0.5; + private static final int OPEN_WATER_DEPTH = 8; + private static final int OPEN_WATER_HEADROOM = 8; + private static final int OPEN_WATER_REACH = 32; + private static final int OPEN_WATER_SHORE = 8; + private static final int OPEN_WATER_ESTIMATE_SLACK = 40; + private static final Field OPEN_WATER_LIFT = new Field.Const(2.0); + private static final Field DRY = new Field.Const(-1.0); + private static final Field PARTIAL = new Field.Const(0.6); + private static final Field LOW_BARRIER = new Field.Const(-1.0); + private static final Field NO_EXCLUSION = new Field.Const(-1.0); + private static final Field LAVA = new Field.Const(1.0); + private static final Field NO_LAVA = new Field.Const(0.0); + private static final Field OUTSIDE_BAND = new Field.Const(0.0); + private static final Field INSIDE_BAND = new Field.Const(1.0); + + private final CaveInputs inputs; + private final Field blockY; + private final int lowFluidBand; + private final int highFluidBand; + + public AquiferCompiler(CaveInputs inputs) { + this.inputs = Objects.requireNonNull(inputs); + this.blockY = inputs.blockY(); + this.lowFluidBand = Math.floorDiv(inputs.minY(), FLUID_BAND_HEIGHT) - 1; + this.highFluidBand = Math.floorDiv(inputs.maxY(), FLUID_BAND_HEIGHT) + 1; + } + + public AquiferDef compile(CaveSelection selection) { + AquiferDef dry = AquiferDef.dry(inputs.aquiferSurface()); + boolean lavaWanted = any(selection, IrisCaveProfile::isAllowLava); + boolean lavaExported = lavaWanted && inputs.lavaY() <= LAVA_LIMIT_Y; + if (lavaWanted && !lavaExported) { + inputs.diagnostics().add(new ExportDiagnostic(ExportDiagnostic.Severity.WARNING, ExportLimit.LAVA_HEIGHT, inputs.resource(), + "Cave lava height Y " + inputs.lavaY() + " is above Y " + LAVA_LIMIT_Y + "; caves export without lava")); + } + boolean wet = any(selection, IrisCaveProfile::isAllowFluid); + if (!lavaExported && !wet) { + return new AquiferDef(dry.barrier(), openWater(dry.floodedness()), dry.spread(), dry.lava(), dry.exclusion(), dry.surfaceLevel()); + } + Field spread = lavaExported ? lavaSpread(wet, dry.spread()) : dry.spread(); + Field lava = lavaExported ? lavaBands() : NO_LAVA; + return new AquiferDef(LOW_BARRIER, openWater(floodedness(selection, lavaExported)), spread, lava, NO_EXCLUSION, inputs.aquiferSurface()); + } + + private Field openWater(Field caveFlood) { + Field surface = inputs.surface(); + Field nearFluid = new Field.RangeIf(blockY, inputs.fluidHeight() - OPEN_WATER_REACH, inputs.fluidHeight() + OPEN_WATER_HEADROOM + 1.0, INSIDE_BAND, DRY); + Field lowEstimate = new Field.Binary(Field.BinaryOp.SUB, new Field.Const(inputs.fluidHeight() + OPEN_WATER_ESTIMATE_SLACK), inputs.aquiferSurface()); + Field aboveFloor = new Field.Binary(Field.BinaryOp.ADD, new Field.Binary(Field.BinaryOp.SUB, blockY, surface), new Field.Const(OPEN_WATER_DEPTH)); + Field lowColumn = new Field.Binary(Field.BinaryOp.SUB, new Field.Const(inputs.fluidHeight() + OPEN_WATER_SHORE + LEVEL_ROUNDING_MARGIN), surface); + Field lift = new Field.Gate(aboveFloor, new Field.Gate(lowColumn, OPEN_WATER_LIFT)); + return new Field.Binary(Field.BinaryOp.ADD, caveFlood, new Field.Gate(nearFluid, new Field.Gate(lowEstimate, lift))); + } + + private Field floodedness(CaveSelection selection, boolean lavaExported) { + Field flood = selection.profiles().isEmpty() ? DRY : selection.select(source -> flood(source, lavaExported), DRY); + for (CaveSource band : selection.bands()) { + Field banded = flood(band, lavaExported); + if (banded.equals(flood)) { + continue; + } + Field inside = new Field.Partition(blockY, List.of((double) band.minY(), band.maxY() + 1.0), List.of(OUTSIDE_BAND, INSIDE_BAND, OUTSIDE_BAND)); + flood = new Field.Mux(inside, List.of(flood, banded)); + } + return flood; + } + + private Field flood(CaveSource source, boolean lavaExported) { + IrisCaveProfile profile = source.profile(); + int top = Integer.MIN_VALUE; + if (lavaExported && profile.isAllowLava()) { + top = inputs.lavaY() + LAVA_CENTER_MARGIN; + } + if (profile.isAllowFluid()) { + top = Math.max(top, inputs.fluidHeight()); + } + if (top < inputs.minY()) { + return DRY; + } + if (top >= inputs.maxY()) { + return PARTIAL; + } + return new Field.RangeIf(blockY, inputs.minY(), top + 1.0, PARTIAL, DRY); + } + + private Field lavaSpread(boolean wet, Field vanillaSpread) { + Field levels = new Field.Coord(Axis.Y, Field.Tiling.CLAMP, lowFluidBand, highFluidBand, spreadAt(lowFluidBand), spreadAt(highFluidBand)); + return wet ? new Field.RangeIf(fluidBand(), lowFluidBand, lavaBand() + 1.0, levels, vanillaSpread) : levels; + } + + private Field lavaBands() { + return new Field.RangeIf(fluidBand(), lowFluidBand, lavaBand() + 1.0, LAVA, NO_LAVA); + } + + private Field fluidBand() { + return new Field.Coord(Axis.Y, Field.Tiling.CLAMP, lowFluidBand, highFluidBand, lowFluidBand, highFluidBand); + } + + private int lavaBand() { + return Math.floorDiv(inputs.lavaY() + LAVA_CENTER_MARGIN, FLUID_BAND_HEIGHT); + } + + private double spreadAt(int band) { + return (inputs.lavaY() - FLUID_BAND_CENTER + LEVEL_ROUNDING_MARGIN) / SPREAD_LEVEL_SCALE - band * (FLUID_BAND_HEIGHT / SPREAD_LEVEL_SCALE); + } + + private static boolean any(CaveSelection selection, Predicate property) { + for (CaveSource source : selection.sources()) { + if (property.test(source.profile())) { + return true; + } + } + return false; + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/cave/CaveFieldCompiler.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/cave/CaveFieldCompiler.java new file mode 100644 index 000000000..3e03ba454 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/cave/CaveFieldCompiler.java @@ -0,0 +1,60 @@ +package art.arcane.iris.pack.datapack.export.compile.cave; + +import art.arcane.iris.pack.datapack.export.compile.noise.StyleCompiler; +import art.arcane.iris.pack.datapack.export.ir.Field; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +public final class CaveFieldCompiler { + static final int CELL_XZ = 4; + static final int CELL_Y = 4; + static final double WALL_BAND = 0.03125; + private static final double MIN_GAIN = 1.0 / 64.0; + private static final double MAX_GAIN = 64.0; + private static final Field UNCOVERED = new Field.Const(CaveProfileCompiler.SOLID); + + private final CaveProfileCompiler profiles; + private final CaveInputs inputs; + + public CaveFieldCompiler(StyleCompiler styles, CaveInputs inputs) { + this.profiles = new CaveProfileCompiler(styles, inputs); + this.inputs = inputs; + } + + public Optional compile(CaveSelection selection) { + return union(selection).map(union -> new Field.Grid(union, CELL_XZ, CELL_Y)); + } + + private Optional union(CaveSelection selection) { + List parts = new ArrayList<>(selection.bands().size() + 1); + if (!selection.profiles().isEmpty()) { + parts.add(selection.select(this::normalized, UNCOVERED)); + } + for (CaveSource band : selection.bands()) { + parts.add(normalized(band)); + } + if (parts.isEmpty()) { + return Optional.empty(); + } + Field union = parts.getFirst(); + for (int index = 1; index < parts.size(); index++) { + union = new Field.Binary(Field.BinaryOp.MIN, union, parts.get(index)); + } + return Optional.of(union); + } + + private Field normalized(CaveSource source) { + return profiles.compile(source, gain(source)); + } + + private double gain(CaveSource source) { + Optional interior = profiles.interior(source); + if (interior.isEmpty()) { + return 1.0; + } + double band = WallShellCalibrator.band(interior.get(), inputs.noises().byReference(), inputs.capabilities().target()); + return band > 0.0 ? Math.clamp(WALL_BAND / band, MIN_GAIN, MAX_GAIN) : 1.0; + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/cave/CaveInputs.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/cave/CaveInputs.java new file mode 100644 index 000000000..28179853a --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/cave/CaveInputs.java @@ -0,0 +1,60 @@ +package art.arcane.iris.pack.datapack.export.compile.cave; + +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; +import art.arcane.iris.pack.datapack.export.compile.noise.NoiseRegistry; +import art.arcane.iris.pack.datapack.export.ir.Axis; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.FieldValidator; +import art.arcane.iris.pack.datapack.export.lower.ExportCapabilities; + +import java.util.List; +import java.util.Objects; + +public record CaveInputs( + String resource, + int minY, + int maxY, + int fluidHeight, + int lavaY, + Field surface, + Field aquiferSurface, + NoiseRegistry noises, + ExportCapabilities capabilities, + List diagnostics +) { + public CaveInputs { + Objects.requireNonNull(resource); + Objects.requireNonNull(surface); + Objects.requireNonNull(aquiferSurface); + Objects.requireNonNull(noises); + Objects.requireNonNull(capabilities); + Objects.requireNonNull(diagnostics); + if (maxY <= minY) { + throw new IllegalArgumentException("Cave bounds need maxY above minY, got " + minY + ".." + maxY); + } + FieldValidator.requireTwoDimensional(surface, "cave surface"); + FieldValidator.requireTwoDimensional(aquiferSurface, "aquifer surface"); + } + + public static CaveInputs of(PackCompileContext context) { + IrisDimension dimension = context.dimension(); + int minY = dimension.getMinHeight(); + return new CaveInputs( + "dimensions/" + dimension.getLoadKey(), + minY, + dimension.getMaxHeight() - 1, + context.dimensionDef().fluidHeight(), + minY + dimension.getCaveLavaHeight(), + context.height(), + context.surfaceEstimate(), + context.noises(), + context.capabilities(), + context.diagnostics()); + } + + public Field blockY() { + return new Field.Coord(Axis.Y, Field.Tiling.CLAMP, minY, maxY + 1, minY, maxY + 1); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/cave/CaveInterior.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/cave/CaveInterior.java new file mode 100644 index 000000000..cc46f7f75 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/cave/CaveInterior.java @@ -0,0 +1,14 @@ +package art.arcane.iris.pack.datapack.export.compile.cave; + +import art.arcane.iris.pack.datapack.export.ir.Field; + +import java.util.Objects; + +record CaveInterior(Field field, int minY, int maxY) { + CaveInterior { + Objects.requireNonNull(field); + if (maxY < minY) { + throw new IllegalArgumentException("Cave interior needs maxY at or above minY, got " + minY + ".." + maxY); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/cave/CaveProfileCompiler.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/cave/CaveProfileCompiler.java new file mode 100644 index 000000000..2e3172c55 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/cave/CaveProfileCompiler.java @@ -0,0 +1,294 @@ +package art.arcane.iris.pack.datapack.export.compile.cave; + +import art.arcane.iris.generation.cave.IrisCaveFieldModule; +import art.arcane.iris.generation.cave.IrisCaveProfile; +import art.arcane.iris.generation.noise.IrisGeneratorStyle; +import art.arcane.iris.generation.noise.IrisStyledRange; +import art.arcane.iris.generation.noise.NoiseStyle; +import art.arcane.iris.pack.datapack.export.compile.noise.StyleCompiler; +import art.arcane.iris.pack.datapack.export.ir.Axis; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.value.IrisRange; + +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +public final class CaveProfileCompiler { + static final double SOLID = 1.0; + private static final long BASE_SALT = 934_447L; + private static final long DETAIL_SALT = 612_991L; + private static final long THRESHOLD_SALT = 489_112L; + private static final long SURFACE_BREAK_SALT = 341_219L; + private static final long MODULE_SALT = 1_000_003L; + private static final long MODULE_SALT_STEP = 65_537L; + private static final double LOW_WEIGHT = 0.1; + private static final int CEILING_FADE_DEPTH = 12; + private static final double CLOSURE_EPSILON = 1.0e-6; + private static final double SEALED_MARGIN = 1.0; + private static final Set CELLULAR_STYLES = cellularStyles(); + + private final StyleCompiler styles; + private final CaveInputs inputs; + private final Field blockY; + + public CaveProfileCompiler(StyleCompiler styles, CaveInputs inputs) { + this.styles = Objects.requireNonNull(styles); + this.inputs = Objects.requireNonNull(inputs); + this.blockY = inputs.blockY(); + } + + public Field compile(CaveSource source, double gain) { + if (!(gain > 0.0) || Double.isInfinite(gain)) { + throw new IllegalArgumentException("Cave field gain must be finite and positive, got " + gain); + } + IrisCaveProfile profile = source.profile(); + IrisRange range = profile.getVerticalRange(); + int floor = floor(inputs, source); + int ceiling = Math.min(source.maxY(), Math.min(inputs.maxY(), inputs.minY() + (int) Math.ceil(range.getMax()))); + if (ceiling < floor) { + return new Field.Const(SOLID); + } + double normalization = normalization(profile) / gain; + List terms = densityTerms(source, normalization, floor, ceiling); + edgeFade(profile, floor, ceiling, gain).ifPresent(terms::add); + terms.add(surface(source, closure(profile, normalization), gain)); + return bounded(sum(terms), floor, ceiling); + } + + Optional interior(CaveSource source) { + IrisCaveProfile profile = source.profile(); + int floor = floor(inputs, source); + int ceiling = Math.min(source.maxY(), Math.min(inputs.maxY(), inputs.minY() + (int) Math.ceil(profile.getVerticalRange().getMax()))); + if (ceiling < floor) { + return Optional.empty(); + } + List terms = densityTerms(source, normalization(profile), floor, ceiling); + terms.add(negativeThreshold(profile, 1.0)); + return Optional.of(new CaveInterior(sum(terms), floor, ceiling)); + } + + static int floor(CaveInputs inputs, CaveSource source) { + return Math.max(source.minY(), inputs.minY() + Math.max(0, (int) Math.floor(source.profile().getVerticalRange().getMin()))); + } + + static boolean cellular(IrisGeneratorStyle style) { + if (style.getExpression() != null || style.getImageMap() != null) { + return false; + } + if (style.getCellularFrequency() > 0.0 || CELLULAR_STYLES.contains(style.getStyle())) { + return true; + } + return style.getFracture() != null && cellular(style.getFracture()); + } + + private List densityTerms(CaveSource source, double normalization, int floor, int ceiling) { + IrisCaveProfile profile = source.profile(); + List terms = new ArrayList<>(); + addTerm(terms, source, new Term(profile.getBaseDensityStyle(), BASE_SALT, profile.getBaseWeight(), 0.0, false), normalization); + addTerm(terms, source, new Term(profile.getDetailDensityStyle(), DETAIL_SALT, profile.getDetailWeight(), 0.0, false), normalization); + List modules = profile.getModules(); + for (int index = 0; index < modules.size(); index++) { + IrisCaveFieldModule module = modules.get(index); + IrisRange range = module.getVerticalRange(); + int low = inputs.minY() + (int) Math.floor(range.getMin()); + int high = inputs.minY() + (int) Math.ceil(range.getMax()); + if (module.getWeight() == 0.0 || high < floor || low > ceiling) { + continue; + } + Field term = term(source, new Term(module.getStyle(), MODULE_SALT + index * MODULE_SALT_STEP, module.getWeight(), module.getThreshold(), module.isInvert()), normalization); + terms.add(low <= floor && high >= ceiling ? term : new Field.Gate(window(low, high), term)); + } + return terms; + } + + private void addTerm(List terms, CaveSource source, Term term, double normalization) { + if (term.weight() != 0.0) { + terms.add(term(source, term, normalization)); + } + } + + private Field term(CaveSource source, Term term, double normalization) { + double sign = term.invert() ? -1.0 : 1.0; + double scale = sign * term.weight() / normalization; + Field unit = volume(source, term.style(), term.salt(), Math.abs(term.weight()) < LOW_WEIGHT); + return affine(unit, 2.0 * scale, -scale * (1.0 + term.threshold())); + } + + private Field volume(CaveSource source, IrisGeneratorStyle style, long salt, boolean lowWeight) { + boolean cellularStyle = cellular(style); + if (cellularStyle && lowWeight) { + return VolumeLift.lift(styles.compile(substitute(style), salt, inputs.noises(), inputs.capabilities())); + } + Field compiled = styles.compile(style, salt, inputs.noises(), inputs.capabilities()); + if (compiled.axes().contains(Axis.Y)) { + return compiled; + } + if (!cellularStyle) { + return VolumeLift.lift(compiled); + } + inputs.diagnostics().add(new ExportDiagnostic(ExportDiagnostic.Severity.WARNING, ExportLimit.NOISE_STYLE, source.resource(), + "Cellular cave style " + style.getStyle() + " exports as smooth noise")); + return VolumeLift.lift(styles.compile(substitute(style), salt, inputs.noises(), inputs.capabilities())); + } + + private Field planar(IrisGeneratorStyle style, long salt) { + Field compiled = styles.compile(style, salt, inputs.noises(), inputs.capabilities()); + return compiled.axes().contains(Axis.Y) ? new Field.Slice(compiled, Axis.Y, 0) : compiled; + } + + private Field surface(CaveSource source, double closure, double gain) { + IrisCaveProfile profile = source.profile(); + Field negativeThreshold = negativeThreshold(profile, gain); + Field depthBelowClearance = new Field.Binary(Field.BinaryOp.SUB, + new Field.Binary(Field.BinaryOp.ADD, inputs.surface(), new Field.Const(-Math.max(0, profile.getSurfaceClearance()))), + blockY); + Field sealed = new Field.Curve(depthBelowClearance, List.of( + new Field.Knot(-1.0, new Field.Const(SEALED_MARGIN * gain - closure), 0.0), + new Field.Knot(0.0, new Field.Const(-closure), 0.0), + new Field.Knot(CEILING_FADE_DEPTH, negativeThreshold, 0.0))); + if (!profile.isAllowSurfaceBreak()) { + return sealed; + } + Field breaking = new Field.Binary(Field.BinaryOp.ADD, breakSteps(profile, closure, gain), negativeThreshold); + Field breakNoise = planar(profile.getSurfaceBreakStyle(), SURFACE_BREAK_SALT); + Field cutoff = new Field.Const((profile.getSurfaceBreakNoiseThreshold() + 1.0) / 2.0); + return new Field.Binary(Field.BinaryOp.ADD, + new Field.Gate(new Field.Binary(Field.BinaryOp.SUB, cutoff, breakNoise), sealed), + new Field.Gate(new Field.Binary(Field.BinaryOp.SUB, breakNoise, cutoff), breaking)); + } + + private Field breakSteps(IrisCaveProfile profile, double closure, double gain) { + int depth = Math.max(0, profile.getSurfaceBreakDepth()); + double boost = Math.max(0.0, profile.getSurfaceBreakThresholdBoost()) * gain; + Field belowSurface = new Field.Binary(Field.BinaryOp.SUB, inputs.surface(), blockY); + List knots = new ArrayList<>(); + knots.add(new Field.Knot(-1.0, new Field.Const((thresholdCeiling(profile) + SEALED_MARGIN) * gain - closure + boost), 0.0)); + knots.add(new Field.Knot(0.0, new Field.Const(-boost), 0.0)); + if (boost > 0.0) { + if (depth > 0) { + knots.add(new Field.Knot(depth, new Field.Const(-boost), 0.0)); + } + knots.add(new Field.Knot(depth + 1.0, new Field.Const(0.0), 0.0)); + } + return new Field.Curve(belowSurface, knots); + } + + private Field negativeThreshold(IrisCaveProfile profile, double gain) { + IrisStyledRange range = profile.getDensityThreshold(); + double bias = profile.getThresholdBias(); + if (range.getMin() == range.getMax()) { + return new Field.Const(gain * (bias - range.getMin())); + } + if (range.getStyle().isFlat()) { + return new Field.Const(gain * (bias - (range.getMin() + 0.5 * (range.getMax() - range.getMin())))); + } + return affine(planar(range.getStyle(), THRESHOLD_SALT), gain * (range.getMin() - range.getMax()), gain * (bias - range.getMin())); + } + + private Optional edgeFade(IrisCaveProfile profile, int floor, int ceiling, double gain) { + int fade = Math.max(0, profile.getVerticalEdgeFade()); + double strength = Math.max(0.0, profile.getVerticalEdgeFadeStrength()) * gain; + if (fade <= 0 || strength <= 0.0 || ceiling <= floor) { + return Optional.empty(); + } + List knots = new ArrayList<>(); + knots.add(new Field.Knot(floor, new Field.Const(strength), 0.0)); + if (floor + fade <= ceiling - fade) { + knots.add(new Field.Knot(floor + fade, new Field.Const(0.0), 0.0)); + if (ceiling - fade > floor + fade) { + knots.add(new Field.Knot(ceiling - fade, new Field.Const(0.0), 0.0)); + } + } else { + double middle = floor + (ceiling - floor) / 2.0; + double progress = Math.min(middle - floor, ceiling - middle) / fade; + knots.add(new Field.Knot(middle, new Field.Const((1.0 - smoothstep(progress)) * strength), 0.0)); + } + knots.add(new Field.Knot(ceiling, new Field.Const(strength), 0.0)); + return Optional.of(new Field.Curve(blockY, knots)); + } + + private Field bounded(Field core, int floor, int ceiling) { + if (floor <= inputs.minY() && ceiling >= inputs.maxY()) { + return core; + } + Field shifted = new Field.Binary(Field.BinaryOp.ADD, core, new Field.Const(-SOLID)); + return new Field.Binary(Field.BinaryOp.ADD, new Field.Gate(window(floor, ceiling), shifted), new Field.Const(SOLID)); + } + + private Field window(int low, int high) { + return new Field.RangeIf(blockY, low, high + 1.0, new Field.Const(1.0), new Field.Const(0.0)); + } + + private static Field affine(Field input, double scale, double offset) { + if (input instanceof Field.Const constant) { + return new Field.Const(scale * constant.value() + offset); + } + if (scale == 0.0) { + return new Field.Const(offset); + } + return new Field.Curve(input, List.of( + new Field.Knot(0.0, new Field.Const(offset), scale), + new Field.Knot(1.0, new Field.Const(scale + offset), scale))); + } + + private static Field sum(List terms) { + Field total = terms.getFirst(); + for (int index = 1; index < terms.size(); index++) { + total = new Field.Binary(Field.BinaryOp.ADD, total, terms.get(index)); + } + return total; + } + + private static double normalization(IrisCaveProfile profile) { + double weight = Math.abs(profile.getBaseWeight()) + Math.abs(profile.getDetailWeight()); + for (IrisCaveFieldModule module : profile.getModules()) { + weight += Math.abs(module.getWeight()); + } + return weight <= 0.0 ? 1.0 : weight; + } + + private static double closure(IrisCaveProfile profile, double normalization) { + double minimum = -Math.abs(profile.getBaseWeight()) - Math.abs(profile.getDetailWeight()); + for (IrisCaveFieldModule module : profile.getModules()) { + double threshold = module.getThreshold(); + double low = module.isInvert() ? threshold - 1.0 : -1.0 - threshold; + double high = module.isInvert() ? threshold + 1.0 : 1.0 - threshold; + minimum += Math.min(low * module.getWeight(), high * module.getWeight()); + } + return minimum / normalization - CLOSURE_EPSILON; + } + + private static double thresholdCeiling(IrisCaveProfile profile) { + IrisStyledRange range = profile.getDensityThreshold(); + return Math.max(range.getMin(), range.getMax()) - profile.getThresholdBias(); + } + + private static double smoothstep(double progress) { + double clamped = Math.max(0.0, Math.min(1.0, progress)); + return clamped * clamped * (3.0 - 2.0 * clamped); + } + + private static IrisGeneratorStyle substitute(IrisGeneratorStyle style) { + return new IrisGeneratorStyle(NoiseStyle.SIMPLEX).zoomed(style.getZoom()); + } + + private static Set cellularStyles() { + Set cellular = EnumSet.noneOf(NoiseStyle.class); + for (NoiseStyle style : NoiseStyle.values()) { + String name = style.name(); + if (name.contains("CELL") || name.contains("VASCULAR") || name.contains("GLOB") || name.contains("HEX")) { + cellular.add(style); + } + } + return cellular; + } + + private record Term(IrisGeneratorStyle style, long salt, double weight, double threshold, boolean invert) { + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/cave/CaveSelection.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/cave/CaveSelection.java new file mode 100644 index 000000000..b1254ba36 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/cave/CaveSelection.java @@ -0,0 +1,185 @@ +package art.arcane.iris.pack.datapack.export.compile.cave; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.cave.IrisCaveProfile; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.generation.terrain.IrisDimensionCarvingEntry; +import art.arcane.iris.generation.terrain.IrisRegion; +import art.arcane.iris.pack.datapack.export.compile.biome.BiomeCodes; +import art.arcane.iris.pack.datapack.export.ir.ClimateLayout; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.pack.value.IrisRange; +import art.arcane.volmlib.util.mantle.flag.ReservedFlag; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; + +public record CaveSelection(Field index, List profiles, boolean uncovered, List bands) { + private static final int UNCOVERED = -1; + private static final Field NO_INDEX = new Field.Const(0.0); + private static final Comparator CODE_ORDER = Comparator + .comparingDouble(ClimateLayout.ClimateEntry::min) + .thenComparingDouble(ClimateLayout.ClimateEntry::max) + .thenComparing(ClimateLayout.ClimateEntry::biomeId); + + public CaveSelection { + Objects.requireNonNull(index); + profiles = List.copyOf(profiles); + bands = List.copyOf(bands); + } + + public static CaveSelection resolve(CaveInputs inputs, IrisDimension dimension, IrisData data, BiomeCodes codes) { + if (!dimension.isCarvingEnabled() || dimension.getDisabledComponents().contains(ReservedFlag.CARVED)) { + return new CaveSelection(NO_INDEX, List.of(), false, List.of()); + } + Map caseByProfile = new LinkedHashMap<>(); + List sources = new ArrayList<>(); + IrisCaveProfile dimensionProfile = dimension.getCaveProfile(); + int fallback = enabled(dimensionProfile) + ? caseOf(source(inputs, inputs.resource(), dimensionProfile), caseByProfile, sources) + : UNCOVERED; + List thresholds = new ArrayList<>(); + List leaves = new ArrayList<>(); + leaves.add(fallback); + List entries = caveEntries(codes, regionCaveBiomes(dimension, data)); + for (int position = 0; position < entries.size(); position++) { + ClimateLayout.ClimateEntry entry = entries.get(position); + Optional source = entrySource(inputs, dimension, data, codes.loadKeyByBiomeId().get(entry.biomeId())); + int selected = source.isPresent() ? caseOf(source.get(), caseByProfile, sources) : fallback; + append(thresholds, leaves, entry.min(), selected); + double nextMin = position + 1 < entries.size() ? entries.get(position + 1).min() : Double.POSITIVE_INFINITY; + if (entry.max() < nextMin) { + append(thresholds, leaves, entry.max(), fallback); + } + } + boolean uncovered = leaves.contains(UNCOVERED); + int uncoveredCase = sources.size(); + List constants = new ArrayList<>(leaves.size()); + for (int leaf : leaves) { + constants.add(new Field.Const(leaf == UNCOVERED ? uncoveredCase : leaf)); + } + Field index = thresholds.isEmpty() ? constants.getFirst() : new Field.Partition(codes.caveCode(), thresholds, constants); + return new CaveSelection(index, sources, uncovered, bands(inputs, dimension, data)); + } + + public boolean isEmpty() { + return profiles.isEmpty() && bands.isEmpty(); + } + + public List sources() { + List all = new ArrayList<>(profiles.size() + bands.size()); + all.addAll(profiles); + all.addAll(bands); + return all; + } + + public Field select(Function compiler, Field uncoveredCase) { + List cases = new ArrayList<>(profiles.size() + 1); + for (CaveSource source : profiles) { + cases.add(compiler.apply(source)); + } + if (uncovered || cases.isEmpty()) { + cases.add(uncoveredCase); + } + return cases.size() == 1 ? cases.getFirst() : new Field.Mux(index, cases); + } + + private static void append(List thresholds, List leaves, double start, int selected) { + if (leaves.getLast() == selected) { + return; + } + thresholds.add(start); + leaves.add(selected); + } + + private static List caveEntries(BiomeCodes codes, Set caveBiomes) { + List entries = new ArrayList<>(); + for (ClimateLayout.ClimateEntry entry : codes.layout().entries()) { + if (entry.axis() == ClimateLayout.ClimateAxis.DEPTH && caveBiomes.contains(codes.loadKeyByBiomeId().get(entry.biomeId()))) { + entries.add(entry); + } + } + entries.sort(CODE_ORDER); + return entries; + } + + private static Set regionCaveBiomes(IrisDimension dimension, IrisData data) { + Set caveBiomes = new HashSet<>(); + for (String regionKey : dimension.getRegions()) { + IrisRegion region = regionKey == null ? null : data.getRegionLoader().load(regionKey, false); + if (region != null && !region.isCompatExcluded()) { + caveBiomes.addAll(region.getCaveBiomes()); + } + } + return caveBiomes; + } + + private static Optional entrySource(CaveInputs inputs, IrisDimension dimension, IrisData data, String loadKey) { + IrisBiome biome = loadKey == null ? null : data.getBiomeLoader().load(loadKey, false); + if (biome == null || biome.isCompatExcluded()) { + return Optional.empty(); + } + if (enabled(biome.getCaveProfile())) { + return Optional.of(source(inputs, "biomes/" + biome.getLoadKey(), biome.getCaveProfile())); + } + for (String regionKey : dimension.getRegions()) { + IrisRegion region = regionKey == null ? null : data.getRegionLoader().load(regionKey, false); + if (region == null || region.isCompatExcluded() || !region.getCaveBiomes().contains(loadKey) || !enabled(region.getCaveProfile())) { + continue; + } + return Optional.of(source(inputs, "regions/" + region.getLoadKey(), region.getCaveProfile())); + } + return Optional.empty(); + } + + private static List bands(CaveInputs inputs, IrisDimension dimension, IrisData data) { + List bands = new ArrayList<>(); + List entries = dimension.getCarving(); + if (entries == null) { + return bands; + } + for (IrisDimensionCarvingEntry entry : entries) { + if (entry == null || !entry.isEnabled()) { + continue; + } + IrisBiome biome = entry.getRealBiome(data); + if (biome == null || !enabled(biome.getCaveProfile())) { + continue; + } + IrisRange range = entry.getWorldYRange(); + int low = range == null ? inputs.minY() : Math.max(inputs.minY(), (int) Math.ceil(range.getMin())); + int high = range == null ? inputs.maxY() : Math.min(inputs.maxY(), (int) Math.floor(range.getMax())); + if (high >= low) { + bands.add(new CaveSource("biomes/" + biome.getLoadKey(), biome.getCaveProfile(), low, high)); + } + } + return bands; + } + + private static int caseOf(CaveSource source, Map caseByProfile, List sources) { + Integer existing = caseByProfile.get(source.profile()); + if (existing != null) { + return existing; + } + caseByProfile.put(source.profile(), sources.size()); + sources.add(source); + return sources.size() - 1; + } + + private static CaveSource source(CaveInputs inputs, String resource, IrisCaveProfile profile) { + return new CaveSource(resource, profile, inputs.minY(), inputs.maxY()); + } + + private static boolean enabled(IrisCaveProfile profile) { + return profile != null && profile.isEnabled(); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/cave/CaveSource.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/cave/CaveSource.java new file mode 100644 index 000000000..8101c9c66 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/cave/CaveSource.java @@ -0,0 +1,15 @@ +package art.arcane.iris.pack.datapack.export.compile.cave; + +import art.arcane.iris.generation.cave.IrisCaveProfile; + +import java.util.Objects; + +public record CaveSource(String resource, IrisCaveProfile profile, int minY, int maxY) { + public CaveSource { + Objects.requireNonNull(resource); + Objects.requireNonNull(profile); + if (maxY < minY) { + throw new IllegalArgumentException("Cave source '" + resource + "' needs maxY at or above minY, got " + minY + ".." + maxY); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/cave/CaveStage.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/cave/CaveStage.java new file mode 100644 index 000000000..452d2bc73 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/cave/CaveStage.java @@ -0,0 +1,61 @@ +package art.arcane.iris.pack.datapack.export.compile.cave; + +import art.arcane.iris.pack.datapack.export.compile.CompileStage; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; +import art.arcane.iris.pack.datapack.export.ir.BiomeDef; +import art.arcane.iris.pack.datapack.export.ir.Field; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +public final class CaveStage implements CompileStage { + private static final String CARVERS = "carvers"; + + @Override + public String name() { + return "caves"; + } + + @Override + public void apply(PackCompileContext context) { + CaveInputs inputs = CaveInputs.of(context); + CaveSelection selection = CaveSelection.resolve(inputs, context.dimension(), context.data(), context.biomeCodes()); + CaveFieldCompiler compiler = new CaveFieldCompiler(context.styles(), inputs); + Optional caves = compiler.compile(selection); + if (caves.isPresent()) { + Field.Shared field = new Field.Shared(DeepCaveCompiler.FIELD, caves.get()); + Optional deep = new DeepCaveCompiler(inputs).compile(field, selection); + context.setCaveField(field, CaveFieldCompiler.WALL_BAND); + context.setCaves(deep.map(DeepCaveCompiler.DeepCaves::router).orElse(field)); + deep.ifPresent(found -> context.setDeepCaves(found.opening())); + } + context.setAquifer(new AquiferCompiler(inputs).compile(selection)); + List biomes = context.biomes(); + if (biomes.stream().anyMatch(CaveStage::carves)) { + context.setBiomes(withoutCarvers(biomes)); + } + } + + private static List withoutCarvers(List biomes) { + List cleaned = new ArrayList<>(biomes.size()); + for (BiomeDef biome : biomes) { + if (!carves(biome)) { + cleaned.add(biome); + continue; + } + JsonObject json = biome.json().deepCopy(); + json.add(CARVERS, new JsonArray()); + cleaned.add(new BiomeDef(biome.id(), json)); + } + return cleaned; + } + + private static boolean carves(BiomeDef biome) { + JsonElement carvers = biome.json().get(CARVERS); + return carvers != null && !(carvers.isJsonArray() && carvers.getAsJsonArray().isEmpty()); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/cave/DeepCaveCompiler.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/cave/DeepCaveCompiler.java new file mode 100644 index 000000000..57b5c1182 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/cave/DeepCaveCompiler.java @@ -0,0 +1,78 @@ +package art.arcane.iris.pack.datapack.export.compile.cave; + +import art.arcane.iris.pack.datapack.export.ir.DimensionDef; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.lower.FieldDomains; + +import java.util.Objects; +import java.util.Optional; + +public final class DeepCaveCompiler { + static final int LAVA_LINE = -54; + static final String FIELD = "cave/field"; + static final String OPENING = "cave/deep_opening"; + private static final double OPEN_MARGIN = Math.nextUp(Float.MIN_VALUE); + private static final Field OPEN = new Field.Const(1.0); + private static final Field INSIDE = new Field.Const(0.0); + private static final Field CLOSED = new Field.Const(CaveProfileCompiler.SOLID); + private static final Field UNMASKED = new Field.Const(-FieldDomains.CONSTANT_LIMIT); + + private final CaveInputs inputs; + private final Field blockY; + + public DeepCaveCompiler(CaveInputs inputs) { + this.inputs = Objects.requireNonNull(inputs); + this.blockY = inputs.blockY(); + } + + public Optional compile(Field.Shared field, CaveSelection selection) { + Optional found = zone(selection); + if (found.isEmpty()) { + return Optional.empty(); + } + Zone zone = found.get(); + inputs.diagnostics().add(new ExportDiagnostic(ExportDiagnostic.Severity.WARNING, ExportLimit.DEEP_CAVE_LAYERS, inputs.resource(), + "Caves between Y " + zone.low() + " and Y " + zone.high() + " open in the material pass, so they get wall palettes but no floor or ceiling layers")); + Field opened = new Field.Binary(Field.BinaryOp.MAX, field, inside(zone, INSIDE, CLOSED)); + Field.Shared opening = new Field.Shared(OPENING, + new Field.Gate(new Field.Binary(Field.BinaryOp.SUB, new Field.Const(OPEN_MARGIN), opened), OPEN)); + Field mask = new Field.Binary(Field.BinaryOp.ADD, opening, inside(zone, INSIDE, UNMASKED)); + return Optional.of(new DeepCaves(new Field.Binary(Field.BinaryOp.MAX, field, mask), opening)); + } + + private Optional zone(CaveSelection selection) { + int high = Math.min(line() - 1, inputs.maxY()); + int low = inputs.minY(); + int deepest = Integer.MAX_VALUE; + for (CaveSource source : selection.sources()) { + deepest = Math.min(deepest, CaveProfileCompiler.floor(inputs, source)); + if (source.profile().isAllowLava()) { + low = Math.max(low, inputs.lavaY() + 1); + } + } + if (high < low || high < deepest) { + return Optional.empty(); + } + return Optional.of(new Zone(low, high)); + } + + private int line() { + return Math.min(LAVA_LINE, DimensionDef.seaLevel(inputs.fluidHeight())); + } + + private Field inside(Zone zone, Field inRange, Field outOfRange) { + return new Field.RangeIf(blockY, zone.low(), zone.high() + 1.0, inRange, outOfRange); + } + + public record DeepCaves(Field router, Field.Shared opening) { + public DeepCaves { + Objects.requireNonNull(router); + Objects.requireNonNull(opening); + } + } + + private record Zone(int low, int high) { + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/cave/VolumeLift.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/cave/VolumeLift.java new file mode 100644 index 000000000..38aec223a --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/cave/VolumeLift.java @@ -0,0 +1,67 @@ +package art.arcane.iris.pack.datapack.export.compile.cave; + +import art.arcane.iris.pack.datapack.export.ir.Field; + +import java.util.ArrayList; +import java.util.List; + +final class VolumeLift { + private static final String SHARED_SUFFIX = "/volume"; + + private VolumeLift() { + } + + static Field lift(Field field) { + return switch (field) { + case Field.Const constant -> constant; + case Field.Coord coord -> coord; + case Field.Ref ref -> ref; + case Field.TopSurface topSurface -> topSurface; + case Field.Slice slice -> slice; + case Field.Noise noise -> noise(noise); + case Field.Binary binary -> new Field.Binary(binary.op(), lift(binary.left()), lift(binary.right())); + case Field.Unary unary -> new Field.Unary(unary.op(), lift(unary.input())); + case Field.Clamp clamp -> new Field.Clamp(lift(clamp.input()), clamp.min(), clamp.max()); + case Field.Lerp lerp -> new Field.Lerp(lift(lerp.alpha()), lift(lerp.first()), lift(lerp.second())); + case Field.Round round -> new Field.Round(round.mode(), lift(round.input()), lift(round.multiple())); + case Field.Curve curve -> curve(curve); + case Field.Partition partition -> new Field.Partition(lift(partition.input()), partition.thresholds(), partition.leaves()); + case Field.Mux mux -> new Field.Mux(lift(mux.index()), liftAll(mux.cases())); + case Field.Gate gate -> new Field.Gate(lift(gate.condition()), lift(gate.expr())); + case Field.RangeIf rangeIf -> new Field.RangeIf(lift(rangeIf.input()), rangeIf.min(), rangeIf.max(), lift(rangeIf.inRange()), lift(rangeIf.outOfRange())); + case Field.Grid grid -> new Field.Grid(lift(grid.expr()), grid.cellXZ(), grid.cellY()); + case Field.Shared shared -> shared(shared); + }; + } + + private static Field noise(Field.Noise noise) { + double yScale = noise.yScale() == 0.0 ? noise.xzScale() : noise.yScale(); + return new Field.Noise(noise.noiseId(), noise.xzScale(), yScale, liftShift(noise.shiftX()), liftShift(noise.shiftY()), liftShift(noise.shiftZ())); + } + + private static Field liftShift(Field shift) { + return shift == null ? null : lift(shift); + } + + private static Field.Curve curve(Field.Curve curve) { + List knots = new ArrayList<>(curve.knots().size()); + for (Field.Knot knot : curve.knots()) { + Field value = knot.value() instanceof Field.Curve nested ? curve(nested) : knot.value(); + knots.add(new Field.Knot(knot.location(), value, knot.derivative())); + } + return new Field.Curve(lift(curve.input()), knots); + } + + private static List liftAll(List fields) { + List lifted = new ArrayList<>(fields.size()); + for (Field field : fields) { + lifted.add(lift(field)); + } + return lifted; + } + + private static Field shared(Field.Shared shared) { + Field body = lift(shared.expr()); + return body.equals(shared.expr()) ? shared : new Field.Shared(shared.name() + SHARED_SUFFIX, body); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/cave/WallShellCalibrator.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/cave/WallShellCalibrator.java new file mode 100644 index 000000000..656e5d06b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/cave/WallShellCalibrator.java @@ -0,0 +1,114 @@ +package art.arcane.iris.pack.datapack.export.compile.cave; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ir.FieldEvaluator; +import art.arcane.iris.pack.datapack.export.ir.NoiseDef; + +import java.util.Arrays; +import java.util.Map; +import java.util.SplittableRandom; + +final class WallShellCalibrator { + private static final long SEED = 0x5EED_3A11L; + private static final int PATCHES = 256; + private static final int PATCH_CELLS = 3; + private static final int SPREAD_CELLS = 1024; + + private final Map noises; + private final DataVersion target; + private final int cellXZ; + private final int cellY; + private double[] solid = new double[4096]; + private int solidCount; + private long walls; + + private WallShellCalibrator(Map noises, DataVersion target, int cellXZ, int cellY) { + this.noises = noises; + this.target = target; + this.cellXZ = cellXZ; + this.cellY = cellY; + } + + static double band(CaveInterior interior, Map noises, DataVersion target) { + WallShellCalibrator calibrator = new WallShellCalibrator(noises, target, CaveFieldCompiler.CELL_XZ, CaveFieldCompiler.CELL_Y); + SplittableRandom random = new SplittableRandom(SEED); + for (int patch = 0; patch < PATCHES; patch++) { + calibrator.sample(interior, random); + } + return calibrator.band(); + } + + private void sample(CaveInterior interior, SplittableRandom random) { + int x0 = cellXZ * (random.nextInt(2 * SPREAD_CELLS) - SPREAD_CELLS); + int z0 = cellXZ * (random.nextInt(2 * SPREAD_CELLS) - SPREAD_CELLS); + int lowCell = Math.floorDiv(interior.minY(), cellY); + int highCell = Math.max(lowCell, Math.floorDiv(interior.maxY(), cellY) - PATCH_CELLS); + int y0 = cellY * (lowCell + random.nextInt(highCell - lowCell + 1)); + int corners = PATCH_CELLS + 1; + double[][][] lattice = new double[corners][corners][corners]; + for (int i = 0; i < corners; i++) { + for (int j = 0; j < corners; j++) { + for (int k = 0; k < corners; k++) { + lattice[i][j][k] = FieldEvaluator.sample(interior.field(), noises, SEED, target, x0 + i * cellXZ, y0 + j * cellY, z0 + k * cellXZ); + } + } + } + int width = PATCH_CELLS * cellXZ; + int height = PATCH_CELLS * cellY; + double[][][] voxels = new double[width][height][width]; + for (int x = 0; x < width; x++) { + for (int y = 0; y < height; y++) { + for (int z = 0; z < width; z++) { + voxels[x][y][z] = interpolate(lattice, x, y, z); + } + } + } + for (int x = 1; x < width - 1; x++) { + for (int y = 0; y < height; y++) { + for (int z = 1; z < width - 1; z++) { + double value = voxels[x][y][z]; + if (value < 0.0) { + continue; + } + add(value); + if (voxels[x - 1][y][z] < 0.0 || voxels[x + 1][y][z] < 0.0 || voxels[x][y][z - 1] < 0.0 || voxels[x][y][z + 1] < 0.0) { + walls++; + } + } + } + } + } + + private double interpolate(double[][][] lattice, int x, int y, int z) { + int i = x / cellXZ; + int j = y / cellY; + int k = z / cellXZ; + double fx = (x - i * cellXZ) / (double) cellXZ; + double fy = (y - j * cellY) / (double) cellY; + double fz = (z - k * cellXZ) / (double) cellXZ; + double bottom = lerp(fz, lerp(fx, lattice[i][j][k], lattice[i + 1][j][k]), lerp(fx, lattice[i][j][k + 1], lattice[i + 1][j][k + 1])); + double top = lerp(fz, lerp(fx, lattice[i][j + 1][k], lattice[i + 1][j + 1][k]), lerp(fx, lattice[i][j + 1][k + 1], lattice[i + 1][j + 1][k + 1])); + return lerp(fy, bottom, top); + } + + private void add(double value) { + if (solidCount == solid.length) { + solid = Arrays.copyOf(solid, solid.length * 2); + } + solid[solidCount++] = value; + } + + private double band() { + if (walls == 0L) { + return 0.0; + } + double[] sorted = Arrays.copyOf(solid, solidCount); + Arrays.sort(sorted); + int rank = (int) Math.min(solidCount - 1L, walls); + return sorted[rank]; + } + + private static double lerp(double delta, double start, double end) { + return start + delta * (end - start); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/decoration/BiomeFeatureLists.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/decoration/BiomeFeatureLists.java new file mode 100644 index 000000000..c9ab06d79 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/decoration/BiomeFeatureLists.java @@ -0,0 +1,69 @@ +package art.arcane.iris.pack.datapack.export.compile.decoration; + +import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; +import art.arcane.iris.pack.datapack.export.ir.BiomeDef; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +public final class BiomeFeatureLists { + private static final GenerationStep[] STEPS = GenerationStep.values(); + + private final Map> order = new EnumMap<>(GenerationStep.class); + private final Map>> members = new HashMap<>(); + + public void add(String biomeId, GenerationStep step, String placedFeatureId) { + VanillaResourceIdentifier.requireIdentifier(biomeId, "feature list biome id"); + Objects.requireNonNull(step); + VanillaResourceIdentifier.requireIdentifier(placedFeatureId, "placed feature id"); + order.computeIfAbsent(step, ignored -> new LinkedHashSet<>()).add(placedFeatureId); + members.computeIfAbsent(biomeId, ignored -> new EnumMap<>(GenerationStep.class)) + .computeIfAbsent(step, ignored -> new HashSet<>()) + .add(placedFeatureId); + } + + public List apply(List biomes, String namespace) { + VanillaResourceIdentifier.requireNamespace(namespace, "feature list namespace"); + Set defined = new HashSet<>(biomes.size()); + List result = new ArrayList<>(biomes.size()); + for (BiomeDef biome : biomes) { + String id = namespace + ":" + biome.id(); + defined.add(id); + JsonObject json = biome.json().deepCopy(); + json.add("features", features(members.getOrDefault(id, Map.of()))); + result.add(new BiomeDef(biome.id(), json)); + } + for (String biomeId : members.keySet()) { + if (!defined.contains(biomeId)) { + throw new IllegalArgumentException("Feature lists reference undefined biome '" + biomeId + "'"); + } + } + return result; + } + + private JsonArray features(Map> biomeSteps) { + JsonArray steps = new JsonArray(STEPS.length); + for (GenerationStep step : STEPS) { + JsonArray features = new JsonArray(); + Set biomeFeatures = biomeSteps.get(step); + if (biomeFeatures != null) { + for (String feature : order.get(step)) { + if (biomeFeatures.contains(feature)) { + features.add(feature); + } + } + } + steps.add(features); + } + return steps; + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/decoration/DecorationStage.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/decoration/DecorationStage.java new file mode 100644 index 000000000..b8b339e47 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/decoration/DecorationStage.java @@ -0,0 +1,26 @@ +package art.arcane.iris.pack.datapack.export.compile.decoration; + +import art.arcane.iris.pack.datapack.export.compile.BlockStateResolver; +import art.arcane.iris.pack.datapack.export.compile.CompileStage; +import art.arcane.iris.pack.datapack.export.compile.ExportedBiomes; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; +import art.arcane.iris.pack.datapack.export.compile.WorldgenSyntax; +import art.arcane.iris.pack.datapack.export.compile.surface.RepaintFeatureCompiler; + +public final class DecorationStage implements CompileStage { + @Override + public String name() { + return "decoration"; + } + + @Override + public void apply(PackCompileContext context) { + ExportedBiomes biomes = ExportedBiomes.load(context); + BlockStateResolver blocks = BlockStateResolver.forContext(context); + FeatureCatalog catalog = new FeatureCatalog(context.namespace(), WorldgenSyntax.forTarget(context.capabilities().target())); + new RepaintFeatureCompiler(context, biomes, blocks, catalog).compile(); + new DecoratorCompiler(context, biomes, blocks, catalog).compile(); + new DepositCompiler(context, biomes, blocks, catalog).compile(); + context.jsonResources().addAll(catalog.resources()); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/decoration/DecoratorCompiler.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/decoration/DecoratorCompiler.java new file mode 100644 index 000000000..7f051f2cd --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/decoration/DecoratorCompiler.java @@ -0,0 +1,498 @@ +package art.arcane.iris.pack.datapack.export.compile.decoration; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.block.IrisBlockData; +import art.arcane.iris.generation.decoration.IrisDecorationPart; +import art.arcane.iris.generation.decoration.IrisDecorator; +import art.arcane.iris.generation.noise.IrisGeneratorStyle; +import art.arcane.iris.generation.noise.NoiseStyle; +import art.arcane.iris.pack.datapack.export.VanillaBlockState; +import art.arcane.iris.pack.datapack.export.compile.BlockStateResolver; +import art.arcane.iris.pack.datapack.export.compile.ExportedBiomes; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; +import art.arcane.iris.pack.datapack.export.compile.WeightedState; +import art.arcane.iris.pack.datapack.export.compile.WorldgenSyntax; +import art.arcane.iris.pack.datapack.export.compile.surface.RepaintFeatureCompiler; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; + +public final class DecoratorCompiler { + private static final int COLUMNS = 256; + private static final double CAVE_AIR_SHARE = 0.08; + private static final int CAVE_SCAN_STEPS = 12; + private static final int MAX_ATTEMPTS = 256; + private static final int MAX_PATTERN_STATES = 64; + private static final VanillaBlockState AIR_STATE = new VanillaBlockState("minecraft:air", Map.of()); + private static final Set AIR = Set.of("minecraft:air", "minecraft:cave_air", "minecraft:void_air"); + private static final Set AQUATIC = Set.of("minecraft:seagrass", "minecraft:tall_seagrass", "minecraft:kelp", + "minecraft:kelp_plant", "minecraft:sea_pickle"); + private static final String STYLE_MESSAGE = "Decorators gated by a noise style keep the style's coverage, with its pattern fitted to Minecraft noise"; + private static final String STYLE_STACK_MESSAGE = "Stacked, forced, underwater, and ceiling decorators gated by a noise style keep the style's coverage without its pattern"; + private static final String VARIANCE_MESSAGE = "Decorator palettes varied by a noise style pick their blocks at random"; + private static final String STACK_MESSAGE = "Decorator stacks scaled to cave height use their fixed stack range"; + private static final String CAVE_MESSAGE = "Cave ceiling decorators place at an estimated density because features cannot enumerate every cave ceiling"; + + private final PackCompileContext context; + private final ExportedBiomes biomes; + private final BlockStateResolver blocks; + private final FeatureCatalog catalog; + private final WorldgenSyntax syntax; + private final Map coverages = new HashMap<>(); + private final Map patterns = new HashMap<>(); + + public DecoratorCompiler(PackCompileContext context, ExportedBiomes biomes, BlockStateResolver blocks, FeatureCatalog catalog) { + this.context = context; + this.biomes = biomes; + this.blocks = blocks; + this.catalog = catalog; + this.syntax = catalog.syntax(); + } + + public void compile() { + for (Map.Entry entry : biomes.byId().entrySet()) { + compileBiome(entry.getKey(), entry.getValue()); + } + } + + static double[] columnShares(double[] chances) { + double[] shares = new double[chances.length]; + for (int index = 0; index < chances.length; index++) { + double[] others = new double[chances.length]; + others[0] = 1.0; + int size = 1; + for (int other = 0; other < chances.length; other++) { + if (other == index) { + continue; + } + double chance = chances[other]; + for (int count = size; count >= 0; count--) { + double stays = others[count] * (1.0 - chance); + double grows = count > 0 ? others[count - 1] * chance : 0.0; + others[count] = stays + grows; + } + size++; + } + double expectation = 0.0; + for (int count = 0; count < size; count++) { + expectation += others[count] / (count + 1); + } + shares[index] = chances[index] * expectation; + } + return shares; + } + + static double[] sequentialChances(double[] shares) { + double[] chances = new double[shares.length]; + double claimed = 0.0; + for (int index = 0; index < shares.length; index++) { + double free = 1.0 - claimed; + chances[index] = free <= 0.0 ? 0.0 : Math.min(1.0, shares[index] / free); + claimed += shares[index]; + } + return chances; + } + + private void compileBiome(String biomeId, IrisBiome biome) { + if (biome.getDecorators() == null || biome.getDecorators().isEmpty()) { + return; + } + String resource = biomes.resource(biomeId); + Map> buckets = new EnumMap<>(IrisDecorationPart.class); + for (IrisDecorator decorator : biome.getDecorators()) { + if (decorator == null) { + continue; + } + Optional candidate = candidate(decorator, resource); + IrisDecorationPart part = decorator.getPartOf() == null ? IrisDecorationPart.NONE : decorator.getPartOf(); + candidate.ifPresent(value -> buckets.computeIfAbsent(part, ignored -> new ArrayList<>()).add(value)); + } + for (Map.Entry> bucket : buckets.entrySet()) { + List candidates = bucket.getValue(); + double[] chances = new double[candidates.size()]; + for (int index = 0; index < chances.length; index++) { + chances[index] = candidates.get(index).coverage().share(); + } + double[] shares = columnShares(chances); + for (int index = 0; index < shares.length; index++) { + shares[index] *= 1.0 - candidates.get(index).airShare(); + } + double[] sequential = sequentialChances(shares); + for (int index = 0; index < candidates.size(); index++) { + Candidate candidate = candidates.get(index); + if (candidate.entries().isEmpty() || shares[index] <= 0.0) { + continue; + } + if (bucket.getKey() == IrisDecorationPart.CEILING) { + report(resource, ExportLimit.CAVE_DECORATOR, CAVE_MESSAGE); + if (candidate.coverage().patterned()) { + report(resource, ExportLimit.DECORATOR_STYLE, STYLE_STACK_MESSAGE); + } + context.features().add(biomeId, GenerationStep.UNDERGROUND_DECORATION, ceiling(candidate, shares[index])); + } else { + context.features().add(biomeId, GenerationStep.VEGETAL_DECORATION, column(bucket.getKey(), candidate, sequential[index], resource)); + } + } + } + } + + private Optional candidate(IrisDecorator decorator, String resource) { + List entries = new ArrayList<>(); + int airWeight = 0; + int total = 0; + for (WeightedState entry : weighted(decorator.getPalette(), decorator.getVariance(), resource)) { + total += entry.weight(); + if (AIR.contains(entry.state().name())) { + airWeight += entry.weight(); + } else { + entries.add(entry); + } + } + if (total == 0) { + return Optional.empty(); + } + if (entries.size() > 1 && !isStatic(decorator.getVariance())) { + report(resource, ExportLimit.DECORATOR_STYLE, VARIANCE_MESSAGE); + } + if (decorator.isScaleStack()) { + report(resource, ExportLimit.SCALE_STACK, STACK_MESSAGE); + } + if (decorator.getSlopeCondition() != null && !decorator.getSlopeCondition().isDefault()) { + report(resource, ExportLimit.SLOPE, RepaintFeatureCompiler.SLOPE_MESSAGE); + } + List top = new ArrayList<>(); + for (WeightedState entry : weighted(decorator.getTopPalette(), decorator.getVariance(), resource)) { + if (!AIR.contains(entry.state().name())) { + top.add(entry); + } + } + VanillaBlockState force = decorator.getForceBlock() == null ? null : blocks.resolve(decorator.getForceBlock(), resource).orElse(null); + return Optional.of(new Candidate(decorator, entries, top, (double) airWeight / total, force, aquatic(entries, top), + names(decorator.getWhitelist(), resource), names(decorator.getBlacklist(), resource), coverage(decorator))); + } + + private List weighted(List palette, IrisGeneratorStyle variance, String resource) { + List resolved = new ArrayList<>(palette.size()); + int count = 0; + for (IrisBlockData block : palette) { + Optional state = blocks.resolve(block, resource); + if (state.isPresent()) { + int weight = Math.max(1, block.getWeight()); + resolved.add(new WeightedState(state.get(), weight)); + count += weight; + } + } + if (count <= 1 || !isStatic(variance)) { + return resolved; + } + List rounded = new ArrayList<>(resolved.size()); + int start = 0; + for (WeightedState entry : resolved) { + int end = start + entry.weight(); + rounded.add(new WeightedState(entry.state(), 2 * entry.weight() - (start == 0 ? 1 : 0) - (end == count ? 1 : 0))); + start = end; + } + return rounded; + } + + private String column(IrisDecorationPart part, Candidate candidate, double sequential, String resource) { + IrisDecorationPart target = candidate.aquatic() && part == IrisDecorationPart.NONE ? IrisDecorationPart.SEA_FLOOR : part; + boolean underwater = target == IrisDecorationPart.SEA_FLOOR; + boolean patterned = candidate.coverage().patterned() && !underwater && single(candidate); + if (candidate.coverage().patterned()) { + report(resource, ExportLimit.DECORATOR_STYLE, patterned ? STYLE_MESSAGE : STYLE_STACK_MESSAGE); + } + String featureId = patterned ? patterned(candidate) : feature(candidate, false, underwater); + double chance = patterned ? Math.min(1.0, sequential / candidate.coverage().share()) : sequential; + JsonArray placement = new JsonArray(); + for (JsonObject modifier : Placements.everyColumn()) { + placement.add(modifier); + } + placement.add(Placements.heightmap(underwater ? Placements.OCEAN_FLOOR : Placements.WORLD_SURFACE)); + if (chance < 1.0) { + placement.add(Placements.randomChance(chance)); + } + placement.add(Placements.biome()); + placement.add(Placements.filter(Placements.allOf(predicates(target, candidate)))); + return catalog.placed(name(candidate), featureId, placement); + } + + private String ceiling(Candidate candidate, double share) { + String featureId = feature(candidate, true, false); + JsonArray placement = new JsonArray(); + placement.add(Placements.count(Placements.meanCount(Math.min(MAX_ATTEMPTS, COLUMNS * share / CAVE_AIR_SHARE)))); + placement.add(Placements.inSquare()); + placement.add(Placements.heightRange(Placements.fullHeight())); + placement.add(Placements.environmentScanUp(CAVE_SCAN_STEPS)); + placement.add(Placements.verticalOffset(-1)); + placement.add(Placements.biome()); + placement.add(Placements.filter(Placements.air(0, 0, 0))); + return catalog.placed(name(candidate) + "_ceiling", featureId, placement); + } + + private List predicates(IrisDecorationPart part, Candidate candidate) { + List predicates = new ArrayList<>(); + switch (part) { + case SEA_FLOOR -> { + predicates.add(Placements.water(0, 0, 0)); + if (tall(candidate)) { + predicates.add(Placements.water(0, 1, 0)); + } + } + case SEA_SURFACE -> { + predicates.add(Placements.air(0, 0, 0)); + predicates.add(Placements.water(0, -1, 0)); + } + case SHORE_LINE -> { + predicates.add(Placements.air(0, 0, 0)); + predicates.add(Placements.solid(0, -1, 0)); + predicates.add(Placements.anyOf(List.of(Placements.water(1, -1, 0), Placements.water(-1, -1, 0), Placements.water(0, -1, 1), + Placements.water(0, -1, -1)))); + } + default -> { + predicates.add(Placements.air(0, 0, 0)); + predicates.add(Placements.solid(0, -1, 0)); + } + } + if (!candidate.whitelist().isEmpty()) { + predicates.add(Placements.matchingBlocks(candidate.whitelist(), 0, -1, 0)); + } + if (!candidate.blacklist().isEmpty()) { + predicates.add(Placements.not(Placements.matchingBlocks(candidate.blacklist(), 0, -1, 0))); + } + predicates.addAll(Placements.slopeLimits(candidate.decorator().getSlopeCondition())); + return predicates; + } + + private String feature(Candidate candidate, boolean hanging, boolean underwater) { + IrisDecorator decorator = candidate.decorator(); + int stackMax = stackMax(decorator); + int stackMin = Math.max(1, Math.min(decorator.getStackMin(), stackMax)); + String name = name(candidate); + List entries = waterlogged(candidate.entries(), underwater); + List top = waterlogged(candidate.top(), underwater); + String main; + boolean tall = !hanging && tall(candidate); + if (tall || !hanging && stackMax == 1 && top.isEmpty()) { + JsonObject config = new JsonObject(); + config.add("to_place", syntax.provider(tall ? lowerHalves(entries) : entries)); + main = catalog.feature(name, "minecraft:simple_block", config); + } else { + JsonArray layers = new JsonArray(2); + if (top.isEmpty()) { + layers.add(layer(Placements.intRange(stackMin, stackMax), entries)); + } else { + layers.add(layer(Placements.intRange(stackMin - 1, stackMax - 1), entries)); + layers.add(layer(Placements.intRange(1, 1), top)); + } + JsonObject config = new JsonObject(); + config.add("layers", layers); + config.addProperty("direction", hanging ? "down" : "up"); + config.add("allowed_placement", underwater ? Placements.water(0, 0, 0) : Placements.air(0, 0, 0)); + config.addProperty("prioritize_tip", true); + main = catalog.feature(name, "minecraft:block_column", config); + } + if (candidate.force() == null) { + return main; + } + JsonObject forceConfig = new JsonObject(); + forceConfig.add("to_place", syntax.provider(List.of(new WeightedState(candidate.force(), 1)))); + String force = catalog.feature("decorator/ground_" + path(candidate.force()), "minecraft:simple_block", forceConfig); + JsonArray groundPlacement = new JsonArray(1); + groundPlacement.add(Placements.verticalOffset(-1)); + JsonArray features = new JsonArray(2); + features.add(catalog.inlinePlaced(force, groundPlacement)); + features.add(catalog.inlinePlaced(main, new JsonArray())); + JsonObject sequence = new JsonObject(); + sequence.add("features", features); + return catalog.feature(name + "_forced", "minecraft:sequence", sequence); + } + + private String patterned(Candidate candidate) { + IrisDecorator decorator = candidate.decorator(); + DecoratorCoverage.Pattern pattern = patterns.computeIfAbsent(candidate.coverage(), coverage -> DecoratorCoverage.pattern(coverage, syntax.target())); + String salt = name(candidate) + "|" + decorator.getStyle().getStyle() + "|" + decorator.getStyle().getZoom() + "|" + decorator.getChance(); + JsonObject provider = new JsonObject(); + provider.addProperty("type", "minecraft:noise_threshold"); + provider.addProperty("seed", (long) salt.hashCode()); + provider.add("noise", patternNoise()); + provider.addProperty("scale", (float) pattern.scale()); + provider.addProperty("threshold", (float) pattern.threshold()); + provider.addProperty("high_chance", 0.0f); + provider.add("default_state", syntax.blockState(AIR_STATE)); + provider.add("low_states", expanded(waterlogged(candidate.entries(), false))); + JsonArray high = new JsonArray(1); + high.add(syntax.blockState(AIR_STATE)); + provider.add("high_states", high); + JsonObject config = new JsonObject(); + config.add("to_place", provider); + return catalog.feature(name(candidate), "minecraft:simple_block", config); + } + + private JsonArray expanded(List entries) { + int divisor = 0; + int total = 0; + for (WeightedState entry : entries) { + divisor = gcd(divisor, entry.weight()); + total += entry.weight(); + } + double shrink = Math.min(1.0, (double) MAX_PATTERN_STATES * divisor / total); + JsonArray states = new JsonArray(); + for (WeightedState entry : entries) { + long copies = Math.max(1L, Math.round(entry.weight() / (double) divisor * shrink)); + for (long copy = 0; copy < copies; copy++) { + states.add(syntax.blockState(entry.state())); + } + } + return states; + } + + private static JsonObject patternNoise() { + JsonArray amplitudes = new JsonArray(DecoratorCoverage.PATTERN_AMPLITUDES.size()); + for (double amplitude : DecoratorCoverage.PATTERN_AMPLITUDES) { + amplitudes.add(amplitude); + } + JsonObject noise = new JsonObject(); + noise.addProperty("base_octave", DecoratorCoverage.PATTERN_OCTAVE); + noise.addProperty("octave_count", DecoratorCoverage.PATTERN_AMPLITUDES.size()); + noise.add("amplitude_modifiers", amplitudes); + noise.addProperty("base_amplitude", 1.0); + noise.addProperty("normalize", false); + return noise; + } + + private DecoratorCoverage.Coverage coverage(IrisDecorator decorator) { + double chance = Math.max(0.0, Math.min(1.0, decorator.getChance())); + if (isStatic(decorator.getStyle())) { + return new DecoratorCoverage.Coverage(chance, 0.0); + } + return coverages.computeIfAbsent(new CoverageKey(decorator.getStyle(), chance), + key -> DecoratorCoverage.measure(key.style(), key.chance(), context.data())); + } + + private static boolean single(Candidate candidate) { + IrisDecorator decorator = candidate.decorator(); + return Math.min(decorator.getStackMax(), decorator.getAbsoluteMaxStack()) <= 1 && candidate.top().isEmpty() && candidate.force() == null; + } + + private static int gcd(int first, int second) { + return second == 0 ? first : gcd(second, first % second); + } + + private static List waterlogged(List entries, boolean underwater) { + List placed = new ArrayList<>(entries.size()); + for (WeightedState entry : entries) { + placed.add(new WeightedState(entry.state().waterlogged(underwater), entry.weight())); + } + return placed; + } + + private static int stackMax(IrisDecorator decorator) { + return Math.max(1, Math.min(decorator.getStackMax(), Math.max(1, decorator.getAbsoluteMaxStack()))); + } + + private static boolean tall(Candidate candidate) { + if (stackMax(candidate.decorator()) != 1) { + return false; + } + for (WeightedState entry : candidate.entries()) { + String half = entry.state().properties().get("half"); + if (!"lower".equals(half) && !"upper".equals(half)) { + return false; + } + } + return true; + } + + private static List lowerHalves(List entries) { + List lower = new ArrayList<>(entries.size()); + for (WeightedState entry : entries) { + Map properties = new TreeMap<>(entry.state().properties()); + properties.put("half", "lower"); + lower.add(new WeightedState(new VanillaBlockState(entry.state().name(), properties), entry.weight())); + } + return lower; + } + + private JsonObject layer(JsonElement height, List entries) { + JsonObject layer = new JsonObject(); + layer.add("height", height); + layer.add("provider", syntax.provider(entries)); + return layer; + } + + private List names(List list, String resource) { + TreeSet names = new TreeSet<>(); + if (list == null) { + return List.of(); + } + for (IrisBlockData block : list) { + blocks.resolve(block, resource).ifPresent(state -> names.add(state.name())); + } + return List.copyOf(names); + } + + private void report(String resource, ExportLimit limit, String message) { + ExportDiagnostic diagnostic = new ExportDiagnostic(ExportDiagnostic.Severity.INFO, limit, resource, message); + if (!context.diagnostics().contains(diagnostic)) { + context.diagnostics().add(diagnostic); + } + } + + private static String name(Candidate candidate) { + return "decorator/" + path(candidate.entries().getFirst().state()); + } + + private static String path(VanillaBlockState state) { + return state.name().substring(state.name().indexOf(':') + 1); + } + + private static boolean isStatic(IrisGeneratorStyle style) { + return style == null || style.getStyle() == null || style.getStyle() == NoiseStyle.STATIC; + } + + private static boolean aquatic(List entries, List top) { + if (entries.isEmpty()) { + return false; + } + for (WeightedState entry : entries) { + if (!aquatic(entry.state())) { + return false; + } + } + for (WeightedState entry : top) { + if (!aquatic(entry.state())) { + return false; + } + } + return true; + } + + private static boolean aquatic(VanillaBlockState state) { + String name = state.name(); + if ("true".equals(state.properties().get("waterlogged")) || AQUATIC.contains(name)) { + return true; + } + return name.startsWith("minecraft:") && name.contains("_coral") && !name.startsWith("minecraft:dead_"); + } + + private record Candidate(IrisDecorator decorator, List entries, List top, double airShare, + VanillaBlockState force, boolean aquatic, List whitelist, List blacklist, + DecoratorCoverage.Coverage coverage) { + } + + private record CoverageKey(IrisGeneratorStyle style, double chance) { + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/decoration/DecoratorCoverage.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/decoration/DecoratorCoverage.java new file mode 100644 index 000000000..76174827b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/decoration/DecoratorCoverage.java @@ -0,0 +1,113 @@ +package art.arcane.iris.pack.datapack.export.compile.decoration; + +import art.arcane.iris.generation.noise.IrisGeneratorStyle; +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.compile.noise.VanillaNoiseModel; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.noise.CNG; + +import java.util.Arrays; +import java.util.List; + +final class DecoratorCoverage { + static final int PATTERN_OCTAVE = 0; + static final List PATTERN_AMPLITUDES = List.of(1.0); + private static final long STYLE_SEED = 0x0DEC_0A7EL; + private static final String PATTERN_NOISE = "iris_export:decorator_pattern"; + private static final int GRID = 128; + private static final double STEP_X = 211.0; + private static final double STEP_Z = 157.0; + private static final int[] LAGS = {1, 2, 4, 8, 16, 32, 64, 128}; + private static final double HALF_EXCESS = 0.5; + private static final double MIN_PATCH = 2.0; + private static final int MIN_HITS = 64; + private static final double VANILLA_SCALE = 1.0 / 64.0; + private static final double VANILLA_Y = 64.0; + private static final double MAX_THRESHOLD = 1.0; + + private DecoratorCoverage() { + } + + static Coverage measure(IrisGeneratorStyle style, double chance, IrisData data) { + CNG generator = style.createNoCache(new RNG(STYLE_SEED), data); + double zoom = style.getZoom(); + return measure((x, z) -> generator.fitDouble(0.0, 1.0, x / zoom, z / zoom) <= chance); + } + + static Pattern pattern(Coverage coverage, DataVersion target) { + VanillaNoiseModel model = VanillaNoiseModel.create(PATTERN_NOISE, PATTERN_OCTAVE, PATTERN_AMPLITUDES, 0L, target); + double[] sorted = new double[GRID * GRID]; + int index = 0; + for (int i = 0; i < GRID; i++) { + for (int j = 0; j < GRID; j++) { + sorted[index++] = model.sample(i * STEP_X * VANILLA_SCALE, VANILLA_Y * VANILLA_SCALE, j * STEP_Z * VANILLA_SCALE); + } + } + Arrays.sort(sorted); + double position = coverage.share() * (sorted.length - 1); + int lower = (int) Math.floor(position); + int upper = Math.min(sorted.length - 1, lower + 1); + double threshold = sorted[lower] + (sorted[upper] - sorted[lower]) * (position - lower); + double clamped = Math.max(-MAX_THRESHOLD, Math.min(MAX_THRESHOLD, threshold)); + Coverage vanilla = measure((x, z) -> model.sample(x * VANILLA_SCALE, VANILLA_Y * VANILLA_SCALE, z * VANILLA_SCALE) < clamped); + return new Pattern(clamped, VANILLA_SCALE * vanilla.patch() / coverage.patch()); + } + + private static Coverage measure(Gate gate) { + int hits = 0; + int[] both = new int[LAGS.length]; + for (int i = 0; i < GRID; i++) { + for (int j = 0; j < GRID; j++) { + double x = i * STEP_X; + double z = j * STEP_Z; + if (!gate.passes(x, z)) { + continue; + } + hits++; + for (int lag = 0; lag < LAGS.length; lag++) { + both[lag] += gate.passes(x + LAGS[lag], z) ? 1 : 0; + both[lag] += gate.passes(x, z + LAGS[lag]) ? 1 : 0; + } + } + } + double share = (double) hits / (GRID * GRID); + if (hits < MIN_HITS || share >= 1.0) { + return new Coverage(share, 0.0); + } + return new Coverage(share, patch(both, hits, share)); + } + + private static double patch(int[] both, int hits, double share) { + double previousLag = 0.0; + double previousExcess = 1.0; + for (int lag = 0; lag < LAGS.length; lag++) { + double excess = ((double) both[lag] / (2.0 * hits) - share) / (1.0 - share); + if (excess < HALF_EXCESS) { + double fraction = (previousExcess - HALF_EXCESS) / (previousExcess - excess); + if (previousLag == 0.0) { + return LAGS[lag] * fraction; + } + double low = Math.log(previousLag); + return Math.exp(low + (Math.log(LAGS[lag]) - low) * fraction); + } + previousLag = LAGS[lag]; + previousExcess = excess; + } + return LAGS[LAGS.length - 1]; + } + + record Coverage(double share, double patch) { + boolean patterned() { + return share > 0.0 && share < 1.0 && patch >= MIN_PATCH; + } + } + + record Pattern(double threshold, double scale) { + } + + @FunctionalInterface + private interface Gate { + boolean passes(double x, double z); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/decoration/DepositCompiler.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/decoration/DepositCompiler.java new file mode 100644 index 000000000..960f9b5fe --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/decoration/DepositCompiler.java @@ -0,0 +1,292 @@ +package art.arcane.iris.pack.datapack.export.compile.decoration; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.biome.IrisBiomePaletteLayer; +import art.arcane.iris.generation.block.IrisBlockData; +import art.arcane.iris.generation.decoration.IrisDepositBiomeScope; +import art.arcane.iris.generation.decoration.IrisDepositGenerator; +import art.arcane.iris.generation.decoration.IrisDepositHeightDistribution; +import art.arcane.iris.generation.decoration.IrisDepositPlacementScope; +import art.arcane.iris.generation.decoration.IrisDepositShape; +import art.arcane.iris.generation.terrain.IrisRegion; +import art.arcane.iris.pack.datapack.export.VanillaBlockState; +import art.arcane.iris.pack.datapack.export.compile.BlockStateResolver; +import art.arcane.iris.pack.datapack.export.compile.ExportedBiomes; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; +import art.arcane.iris.pack.datapack.export.compile.WorldgenSyntax; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; + +public final class DepositCompiler { + private static final int MAX_ORE_SIZE = 64; + private static final int MAX_COUNT = 256; + private static final int MIN_ANCHOR = -2032; + private static final int MAX_ANCHOR = 2031; + private static final String HOST_TAG = "deposit_host"; + private static final List HOST_TAG_BASE = List.of("#minecraft:base_stone_overworld", "#minecraft:dirt", "#minecraft:sand"); + private static final String DEEPSLATE = "minecraft:deepslate"; + private static final String DEEPSLATE_PREFIX = "minecraft:deepslate_"; + private static final Set DEEPSLATE_ORES = Set.of("coal", "emerald", "diamond", "copper", "gold", "iron", "lapis", "redstone"); + private static final Set DEEPSLATE_HOSTS = Set.of(DEEPSLATE, "minecraft:deepslate_bricks", "minecraft:deepslate_brick_slab", + "minecraft:deepslate_brick_stairs", "minecraft:deepslate_brick_wall", "minecraft:deepslate_tiles", "minecraft:deepslate_tile_slab", + "minecraft:deepslate_tile_stairs", "minecraft:deepslate_tile_wall", "minecraft:cracked_deepslate_tiles", "minecraft:deepslate_coal_ore", + "minecraft:deepslate_iron_ore", "minecraft:deepslate_copper_ore", "minecraft:deepslate_diamond_ore", "minecraft:deepslate_emerald_ore", + "minecraft:deepslate_gold_ore", "minecraft:deepslate_lapis_ore", "minecraft:deepslate_redstone_ore"); + + private final PackCompileContext context; + private final ExportedBiomes biomes; + private final BlockStateResolver blocks; + private final FeatureCatalog catalog; + private final WorldgenSyntax syntax; + private String hostTag; + + public DepositCompiler(PackCompileContext context, ExportedBiomes biomes, BlockStateResolver blocks, FeatureCatalog catalog) { + this.context = context; + this.biomes = biomes; + this.blocks = blocks; + this.catalog = catalog; + this.syntax = catalog.syntax(); + } + + public void compile() { + List everyBiome = biomes.inCodeOrder(biomes.byId().keySet()); + String dimensionResource = "dimensions/" + context.dimension().getLoadKey(); + for (IrisDepositGenerator deposit : list(context.dimension().getDeposits())) { + place(deposit, everyBiome, dimensionResource); + } + TreeMap> regionBiomes = new TreeMap<>(); + TreeMap regions = new TreeMap<>(); + for (String biomeId : everyBiome) { + IrisRegion region = biomes.region(biomeId); + if (region != null) { + regions.put(region.getLoadKey(), region); + regionBiomes.computeIfAbsent(region.getLoadKey(), ignored -> new ArrayList<>()).add(biomeId); + } + } + for (Map.Entry region : regions.entrySet()) { + for (IrisDepositGenerator deposit : list(region.getValue().getDeposits())) { + place(deposit, regionBiomes.get(region.getKey()), "regions/" + region.getKey()); + } + } + for (Map.Entry biome : biomes.byId().entrySet()) { + for (IrisDepositGenerator deposit : list(biome.getValue().getDeposits())) { + place(deposit, List.of(biome.getKey()), biomes.resource(biome.getKey())); + } + } + } + + private void place(IrisDepositGenerator deposit, List candidates, String resource) { + List biomeIds = filtered(deposit, candidates); + if (biomeIds.isEmpty() || deposit.getMaxPerChunk() <= 0 || deposit.getSpawnChance() <= 0 || deposit.getPerClumpSpawnChance() <= 0) { + return; + } + List states = new ArrayList<>(); + for (IrisBlockData block : list(deposit.getPalette())) { + blocks.resolve(block, resource).ifPresent(state -> { + if (!states.contains(state)) { + states.add(state); + } + }); + } + if (states.isEmpty()) { + return; + } + String feature = feature(deposit, states, resource); + String placed = catalog.placed("deposit/" + path(states.getFirst()), feature, placement(deposit, biomeIds, resource)); + for (String biomeId : biomeIds) { + context.features().add(biomeId, GenerationStep.UNDERGROUND_ORES, placed); + } + } + + private String feature(IrisDepositGenerator deposit, List states, String resource) { + String type = deposit.getShape() == IrisDepositShape.VANILLA_SCATTERED ? "minecraft:scattered_ore" : "minecraft:ore"; + int size = (int) Math.round((Math.max(0, deposit.getMinSize()) + Math.max(0, deposit.getMaxSize())) / 2.0); + if (size > MAX_ORE_SIZE) { + report(resource, ExportLimit.DEPOSIT_SIZE, ExportDiagnostic.Severity.WARNING, + "Deposit clumps of " + size + " blocks export as Minecraft ore veins of " + MAX_ORE_SIZE + " blocks"); + size = MAX_ORE_SIZE; + } + List hosts = hosts(deposit, resource); + List ores = new ArrayList<>(states.size()); + for (VanillaBlockState state : states) { + JsonArray targets = new JsonArray(hosts.size() + 1); + VanillaBlockState deepslateOre = stoneVariant(state, true); + if (hosts.size() == 1 && "minecraft:tag_match".equals(hosts.getFirst().get("predicate_type").getAsString()) && !deepslateOre.equals(state)) { + JsonObject deepslate = new JsonObject(); + deepslate.addProperty("predicate_type", "minecraft:block_match"); + deepslate.addProperty("block", DEEPSLATE); + targets.add(target(deepslate, deepslateOre)); + } + for (JsonObject host : hosts) { + boolean deepslateHost = host.has("block") && DEEPSLATE_HOSTS.contains(host.get("block").getAsString()); + targets.add(target(host, stoneVariant(state, deepslateHost))); + } + JsonObject config = new JsonObject(); + config.addProperty("size", Math.max(0, size)); + config.addProperty("discard_chance_on_air_exposure", (float) Math.max(0.0, Math.min(1.0, deposit.getDiscardChanceOnAirExposure()))); + config.add("targets", targets); + ores.add(catalog.feature("deposit/" + path(state), type, config)); + } + if (ores.size() == 1) { + return ores.getFirst(); + } + JsonArray features = new JsonArray(ores.size()); + for (String ore : ores) { + features.add(catalog.inlinePlaced(ore, new JsonArray())); + } + JsonObject selector = new JsonObject(); + selector.add("features", features); + return catalog.feature("deposit/" + path(states.getFirst()) + "_mix", "minecraft:simple_random_selector", selector); + } + + private JsonObject target(JsonObject host, VanillaBlockState state) { + JsonObject target = new JsonObject(); + target.add("target", host.deepCopy()); + target.add("state", syntax.blockState(state)); + return target; + } + + private static VanillaBlockState stoneVariant(VanillaBlockState ore, boolean deepslate) { + String name = ore.name(); + boolean deep = name.startsWith(DEEPSLATE_PREFIX); + String base = deep ? name.substring(DEEPSLATE_PREFIX.length()) : name.substring(name.indexOf(':') + 1); + if (deep == deepslate || !base.endsWith("_ore") || !DEEPSLATE_ORES.contains(base.substring(0, base.length() - "_ore".length()))) { + return ore; + } + return new VanillaBlockState(deepslate ? DEEPSLATE_PREFIX + base : "minecraft:" + base, ore.properties()); + } + + private JsonArray placement(IrisDepositGenerator deposit, List biomeIds, String resource) { + int minCount = Math.max(0, Math.min(deposit.getMinPerChunk(), deposit.getMaxPerChunk())); + int maxCount = Math.max(minCount, deposit.getMaxPerChunk()); + if (maxCount > MAX_COUNT) { + report(resource, ExportLimit.DEPOSIT_SIZE, ExportDiagnostic.Severity.WARNING, + "Deposits place at most " + MAX_COUNT + " clumps per chunk"); + maxCount = MAX_COUNT; + minCount = Math.min(minCount, maxCount); + } + double spawnChance = Math.min(1.0, deposit.getSpawnChance()); + double clumpChance = Math.min(1.0, deposit.getPerClumpSpawnChance()); + JsonArray placement = new JsonArray(); + if (spawnChance < 1.0) { + placement.add(Placements.randomChance(spawnChance)); + } + placement.add(Placements.count(Placements.intRange(minCount, maxCount))); + placement.add(Placements.inSquare()); + boolean surfaceScope = deposit.getBiomeScope() == IrisDepositBiomeScope.SURFACE; + if (surfaceScope) { + placement.add(Placements.heightmap(Placements.OCEAN_FLOOR)); + placement.add(Placements.filter(Placements.matchingBiomes(biomeIds))); + } + placement.add(Placements.heightRange(height(deposit))); + IrisDepositPlacementScope scope = deposit.getPlacementScope() == null ? IrisDepositPlacementScope.TERRAIN : deposit.getPlacementScope(); + if (scope == IrisDepositPlacementScope.TERRAIN) { + placement.add(Placements.surfaceRelative(Placements.OCEAN_FLOOR, -(Math.max(0, deposit.getSurfaceClearance()) + 1))); + } else if (scope == IrisDepositPlacementScope.ABOVE_TERRAIN) { + report(resource, ExportLimit.DEPOSIT_SCOPE, ExportDiagnostic.Severity.INFO, + "Deposits limited to above-terrain placement also generate inside overhangs and floating terrain within their height band"); + } + if (clumpChance < 1.0) { + placement.add(Placements.randomChance(clumpChance)); + } + if (!surfaceScope) { + placement.add(Placements.biome()); + } + return placement; + } + + private JsonObject height(IrisDepositGenerator deposit) { + int min = clampAnchor(Math.min(deposit.getMinHeight(), deposit.getMaxHeight())); + int max = clampAnchor(Math.max(deposit.getMinHeight(), deposit.getMaxHeight())); + return deposit.getHeightDistribution() == IrisDepositHeightDistribution.TRIANGLE + ? Placements.trapezoidHeight(min, max) + : Placements.uniformHeight(min, max); + } + + private List hosts(IrisDepositGenerator deposit, String resource) { + List hosts = new ArrayList<>(); + TreeSet names = new TreeSet<>(); + for (String raw : list(deposit.getReplaceableBlocks())) { + blocks.resolve(raw, resource).ifPresent(state -> names.add(state.name())); + } + for (String name : names) { + JsonObject host = new JsonObject(); + host.addProperty("predicate_type", "minecraft:block_match"); + host.addProperty("block", name); + hosts.add(host); + } + if (!hosts.isEmpty()) { + return hosts; + } + JsonObject host = new JsonObject(); + host.addProperty("predicate_type", "minecraft:tag_match"); + host.addProperty("tag", hostTag()); + return List.of(host); + } + + private String hostTag() { + if (hostTag != null) { + return hostTag; + } + TreeSet layerBlocks = new TreeSet<>(); + for (Map.Entry biome : biomes.byId().entrySet()) { + for (IrisBiomePaletteLayer layer : list(biome.getValue().getLayers())) { + for (IrisBlockData block : list(layer.getPalette())) { + Optional state = blocks.resolve(block, biomes.resource(biome.getKey())); + state.ifPresent(value -> layerBlocks.add(value.name())); + } + } + } + List values = new ArrayList<>(HOST_TAG_BASE); + values.add(context.dimensionDef().defaultBlock()); + for (String block : layerBlocks) { + if (!values.contains(block) && !block.equals("minecraft:bedrock")) { + values.add(block); + } + } + hostTag = catalog.blockTag(HOST_TAG, values); + return hostTag; + } + + private List filtered(IrisDepositGenerator deposit, List candidates) { + List included = list(deposit.getIncludedBiomes()); + List excluded = list(deposit.getExcludedBiomes()); + List biomeIds = new ArrayList<>(candidates.size()); + for (String biomeId : candidates) { + String loadKey = biomes.loadKey(biomeId); + if ((included.isEmpty() || included.contains(loadKey)) && !excluded.contains(loadKey)) { + biomeIds.add(biomeId); + } + } + return biomeIds; + } + + private void report(String resource, ExportLimit limit, ExportDiagnostic.Severity severity, String message) { + ExportDiagnostic diagnostic = new ExportDiagnostic(severity, limit, resource, message); + if (!context.diagnostics().contains(diagnostic)) { + context.diagnostics().add(diagnostic); + } + } + + private static List list(List values) { + return values == null ? List.of() : values; + } + + private static int clampAnchor(int value) { + return Math.max(MIN_ANCHOR, Math.min(MAX_ANCHOR, value)); + } + + private static String path(VanillaBlockState state) { + return state.name().substring(state.name().indexOf(':') + 1); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/decoration/FeatureCatalog.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/decoration/FeatureCatalog.java new file mode 100644 index 000000000..59751faad --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/decoration/FeatureCatalog.java @@ -0,0 +1,114 @@ +package art.arcane.iris.pack.datapack.export.compile.decoration; + +import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; +import art.arcane.iris.pack.datapack.export.compile.WorldgenSyntax; +import art.arcane.iris.pack.datapack.export.ir.JsonResource; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.SortedMap; +import java.util.TreeMap; + +public final class FeatureCatalog { + private static final String PLACED_FOLDER = "worldgen/placed_feature"; + private static final String BLOCK_TAG_FOLDER = "tags/block"; + + private final String namespace; + private final WorldgenSyntax syntax; + private final Map idsByBody = new HashMap<>(); + private final SortedMap files = new TreeMap<>(); + + public FeatureCatalog(String namespace, WorldgenSyntax syntax) { + this.namespace = VanillaResourceIdentifier.requireNamespace(namespace, "feature namespace"); + this.syntax = syntax; + } + + public WorldgenSyntax syntax() { + return syntax; + } + + public String namespace() { + return namespace; + } + + public String feature(String name, String type, JsonObject config) { + return register(syntax.featureFolder(), name, syntax.feature(type, config)); + } + + public String placed(String name, String featureId, JsonArray placement) { + JsonObject placed = new JsonObject(); + placed.addProperty("feature", featureId); + placed.add("placement", placement); + return register(PLACED_FOLDER, name, placed); + } + + public JsonObject inlinePlaced(String featureId, JsonArray placement) { + JsonObject placed = new JsonObject(); + placed.addProperty("feature", featureId); + placed.add("placement", placement); + return placed; + } + + public String blockTag(String name, List values) { + JsonArray entries = new JsonArray(values.size()); + for (String value : values) { + entries.add(value); + } + JsonObject tag = new JsonObject(); + tag.add("values", entries); + return register(BLOCK_TAG_FOLDER, name, tag); + } + + public List resources() { + List resources = new ArrayList<>(files.size()); + for (Map.Entry file : files.entrySet()) { + resources.add(new JsonResource(file.getKey(), file.getValue())); + } + return resources; + } + + private String register(String folder, String name, JsonElement body) { + String key = folder + "|" + canonical(body); + String existing = idsByBody.get(key); + if (existing != null) { + return existing; + } + String base = VanillaResourceIdentifier.sanitizePath(name); + String path = base; + int suffix = 2; + while (files.containsKey(file(folder, path))) { + path = base + "_" + suffix++; + } + files.put(file(folder, path), body.deepCopy()); + String id = namespace + ":" + path; + idsByBody.put(key, id); + return id; + } + + private String file(String folder, String path) { + return "data/" + namespace + "/" + folder + "/" + path + ".json"; + } + + private static String canonical(JsonElement element) { + if (element.isJsonObject()) { + StringBuilder builder = new StringBuilder("{"); + for (Map.Entry member : new TreeMap<>(element.getAsJsonObject().asMap()).entrySet()) { + builder.append('"').append(member.getKey()).append("\":").append(canonical(member.getValue())).append(','); + } + return builder.append('}').toString(); + } + if (element.isJsonArray()) { + StringBuilder builder = new StringBuilder("["); + for (JsonElement item : element.getAsJsonArray()) { + builder.append(canonical(item)).append(','); + } + return builder.append(']').toString(); + } + return element.toString(); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/decoration/GenerationStep.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/decoration/GenerationStep.java new file mode 100644 index 000000000..bd792322a --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/decoration/GenerationStep.java @@ -0,0 +1,15 @@ +package art.arcane.iris.pack.datapack.export.compile.decoration; + +public enum GenerationStep { + RAW_GENERATION, + LAKES, + LOCAL_MODIFICATIONS, + UNDERGROUND_STRUCTURES, + SURFACE_STRUCTURES, + STRONGHOLDS, + UNDERGROUND_ORES, + UNDERGROUND_DECORATION, + FLUID_SPRINGS, + VEGETAL_DECORATION, + TOP_LAYER_MODIFICATION +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/decoration/Placements.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/decoration/Placements.java new file mode 100644 index 000000000..09bc08766 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/decoration/Placements.java @@ -0,0 +1,313 @@ +package art.arcane.iris.pack.datapack.export.compile.decoration; + +import art.arcane.iris.generation.terrain.IrisSlopeClip; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +public final class Placements { + public static final String WORLD_SURFACE = "WORLD_SURFACE_WG"; + public static final String OCEAN_FLOOR = "OCEAN_FLOOR_WG"; + private static final int CHUNK_EDGE = 15; + private static final int WEIGHT_SCALE = 1000; + private static final int[][] CARDINALS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; + private static final double SLOPE_CEILING = 10.0; + + private Placements() { + } + + public static List everyColumn() { + JsonObject cuboid = typed("minecraft:cuboid"); + cuboid.addProperty("xz_size", CHUNK_EDGE); + cuboid.addProperty("y_size", 1); + return List.of(cuboid, filter(insideWorldBounds(0, -1, 0))); + } + + public static JsonObject heightmap(String heightmap) { + JsonObject json = typed("minecraft:heightmap"); + json.addProperty("heightmap", heightmap); + return json; + } + + public static JsonObject verticalOffset(int y) { + JsonObject json = typed("minecraft:offset"); + json.addProperty("x", 0); + json.addProperty("y", y); + json.addProperty("z", 0); + return json; + } + + public static JsonObject randomChance(double chance) { + JsonObject json = typed("minecraft:random_chance"); + json.addProperty("chance", (float) chance); + return json; + } + + public static JsonObject biome() { + return typed("minecraft:biome"); + } + + public static JsonObject inSquare() { + return typed("minecraft:in_square"); + } + + public static JsonObject count(JsonElement count) { + JsonObject json = typed("minecraft:count"); + json.add("count", count); + return json; + } + + public static JsonObject heightRange(JsonObject height) { + JsonObject json = typed("minecraft:height_range"); + json.add("height", height); + return json; + } + + public static JsonObject uniformHeight(int minAboveBottom, int maxAboveBottom) { + JsonObject json = typed("minecraft:uniform"); + json.add("min_inclusive", aboveBottom(minAboveBottom)); + json.add("max_inclusive", aboveBottom(maxAboveBottom)); + return json; + } + + public static JsonObject trapezoidHeight(int minAboveBottom, int maxAboveBottom) { + JsonObject json = typed("minecraft:trapezoid"); + json.add("min_inclusive", aboveBottom(minAboveBottom)); + json.add("max_inclusive", aboveBottom(maxAboveBottom)); + json.addProperty("plateau", 0); + return json; + } + + public static JsonObject fullHeight() { + JsonObject bottom = new JsonObject(); + bottom.addProperty("above_bottom", 0); + JsonObject top = new JsonObject(); + top.addProperty("below_top", 0); + JsonObject json = typed("minecraft:uniform"); + json.add("min_inclusive", bottom); + json.add("max_inclusive", top); + return json; + } + + public static JsonObject surfaceRelative(String heightmap, int maxInclusive) { + JsonObject json = typed("minecraft:surface_relative_threshold_filter"); + json.addProperty("heightmap", heightmap); + json.addProperty("max_inclusive", maxInclusive); + return json; + } + + public static JsonObject environmentScanUp(int maxSteps) { + JsonObject json = typed("minecraft:environment_scan"); + json.addProperty("direction_of_search", "up"); + json.addProperty("max_steps", maxSteps); + json.add("target_condition", solid(0, 0, 0)); + json.add("allowed_search_condition", air(0, 0, 0)); + return json; + } + + public static JsonObject filter(JsonObject predicate) { + JsonObject json = typed("minecraft:block_predicate_filter"); + json.add("predicate", predicate); + return json; + } + + public static JsonObject uniform(int min, int max) { + JsonObject json = typed("minecraft:uniform"); + json.addProperty("min_inclusive", min); + json.addProperty("max_inclusive", max); + return json; + } + + public static JsonElement intRange(int min, int max) { + return min == max ? new JsonPrimitive(min) : uniform(min, max); + } + + public static JsonElement meanCount(double mean) { + double clamped = Math.max(0.0, mean); + int lower = (int) Math.floor(clamped); + int upperWeight = (int) Math.round((clamped - lower) * WEIGHT_SCALE); + if (upperWeight == 0) { + return new JsonPrimitive(lower); + } + if (upperWeight == WEIGHT_SCALE) { + return new JsonPrimitive(lower + 1); + } + JsonArray distribution = new JsonArray(2); + distribution.add(weighted(lower, WEIGHT_SCALE - upperWeight)); + distribution.add(weighted(lower + 1, upperWeight)); + JsonObject json = typed("minecraft:weighted_list"); + json.add("distribution", distribution); + return json; + } + + public static JsonObject solid(int x, int y, int z) { + return offset(typed("minecraft:solid"), x, y, z); + } + + public static JsonObject air(int x, int y, int z) { + return blockTag("minecraft:air", x, y, z); + } + + public static JsonObject blockTag(String tag, int x, int y, int z) { + JsonObject json = typed("minecraft:matching_block_tag"); + json.addProperty("tag", tag); + return offset(json, x, y, z); + } + + public static JsonObject water(int x, int y, int z) { + return fluids(List.of("minecraft:water", "minecraft:flowing_water"), x, y, z); + } + + public static JsonObject fluids(Collection fluids, int x, int y, int z) { + JsonArray list = new JsonArray(fluids.size()); + for (String fluid : fluids) { + list.add(fluid); + } + JsonObject json = typed("minecraft:matching_fluids"); + json.add("fluids", list); + return offset(json, x, y, z); + } + + public static JsonObject matchingBlocks(Collection blocks, int x, int y, int z) { + JsonArray ids = new JsonArray(blocks.size()); + for (String block : blocks) { + ids.add(block); + } + JsonObject json = typed("minecraft:matching_blocks"); + json.add("blocks", ids); + return offset(json, x, y, z); + } + + public static JsonObject matchingBiomes(Collection biomeIds) { + JsonArray ids = new JsonArray(biomeIds.size()); + for (String id : biomeIds) { + ids.add(id); + } + JsonObject json = typed("minecraft:matching_biomes"); + json.add("biomes", ids); + return json; + } + + public static JsonObject wouldSurvive(JsonObject state) { + JsonObject json = typed("minecraft:would_survive"); + json.add("state", state); + return json; + } + + public static JsonObject insideWorldBounds(int x, int y, int z) { + return offset(typed("minecraft:inside_world_bounds"), x, y, z); + } + + public static JsonObject not(JsonObject predicate) { + JsonObject json = typed("minecraft:not"); + json.add("predicate", predicate); + return json; + } + + public static JsonObject allOf(List predicates) { + return predicates.size() == 1 ? predicates.getFirst() : combined("minecraft:all_of", predicates); + } + + public static JsonObject anyOf(List predicates) { + return predicates.size() == 1 ? predicates.getFirst() : combined("minecraft:any_of", predicates); + } + + public static JsonObject steeperThan(int rise, int groundOffset) { + JsonObject[] directions = new JsonObject[CARDINALS.length * 2]; + for (int index = 0; index < CARDINALS.length; index++) { + int[] cardinal = CARDINALS[index]; + directions[index * 2] = solid(cardinal[0] * 3, groundOffset + rise, cardinal[1] * 3); + directions[index * 2 + 1] = not(solid(cardinal[0] * 3, groundOffset - rise + 1, cardinal[1] * 3)); + } + return anyOf(List.of(directions)); + } + + public static List slopeLimits(IrisSlopeClip clip) { + List limits = new ArrayList<>(2); + if (clip == null || clip.isDefault()) { + return limits; + } + if (clip.getMinimumSlope() > 0) { + limits.add(steeperThan(slopeRise(clip.getMinimumSlope()), -1)); + } + if (clip.getMaximumSlope() < SLOPE_CEILING) { + limits.add(not(steeperThan(slopeRise(clip.getMaximumSlope()) + 1, -1))); + } + return limits; + } + + public static JsonObject dropAtLeast(int drop) { + JsonObject[] directions = new JsonObject[CARDINALS.length]; + for (int index = 0; index < CARDINALS.length; index++) { + directions[index] = not(solid(CARDINALS[index][0], 1 - drop, CARDINALS[index][1])); + } + return anyOf(List.of(directions)); + } + + public static JsonObject exposed() { + JsonObject[] directions = new JsonObject[CARDINALS.length]; + for (int index = 0; index < CARDINALS.length; index++) { + directions[index] = not(solid(CARDINALS[index][0], 0, CARDINALS[index][1])); + } + return allOf(List.of(solid(0, 0, 0), anyOf(List.of(directions)))); + } + + public static JsonObject stepUpBeside() { + JsonObject[] directions = new JsonObject[CARDINALS.length]; + for (int index = 0; index < CARDINALS.length; index++) { + int[] cardinal = CARDINALS[index]; + directions[index] = allOf(List.of(solid(cardinal[0], 0, cardinal[1]), not(solid(cardinal[0], 1, cardinal[1])))); + } + return anyOf(List.of(directions)); + } + + public static JsonObject typed(String type) { + JsonObject json = new JsonObject(); + json.addProperty("type", type); + return json; + } + + private static JsonObject combined(String type, List predicates) { + JsonArray list = new JsonArray(predicates.size()); + for (JsonObject predicate : predicates) { + list.add(predicate); + } + JsonObject json = typed(type); + json.add("predicates", list); + return json; + } + + private static JsonObject aboveBottom(int value) { + JsonObject json = new JsonObject(); + json.addProperty("above_bottom", value); + return json; + } + + private static JsonObject offset(JsonObject predicate, int x, int y, int z) { + if (x == 0 && y == 0 && z == 0) { + return predicate; + } + JsonArray offset = new JsonArray(3); + offset.add(x); + offset.add(y); + offset.add(z); + predicate.add("offset", offset); + return predicate; + } + + private static JsonObject weighted(int data, int weight) { + JsonObject json = new JsonObject(); + json.addProperty("data", data); + json.addProperty("weight", weight); + return json; + } + + private static int slopeRise(double slope) { + return Math.max(1, (int) Math.round(slope)); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/CellHashNoise.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/CellHashNoise.java new file mode 100644 index 000000000..9d4c9ee14 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/CellHashNoise.java @@ -0,0 +1,105 @@ +package art.arcane.iris.pack.datapack.export.compile.noise; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.NoiseDef; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.DoubleUnaryOperator; + +public final class CellHashNoise { + public static final String NOISE_ID = "cell_hash"; + public static final double LATTICE_SPACING = 2.625D; + public static final int CDF_INTERVALS = 168; + private static final String REFERENCE_NOISE = "iris_export:cell_hash"; + private static final long REFERENCE_SEED = 0L; + private static final double REFERENCE_PLANE = 7.375D; + private static final int REFERENCE_SEEDS = 8; + private static final long REFERENCE_SEED_STRIDE = 7_919L; + private static final int REFERENCE_ROWS = 16; + private static final int REFERENCE_COLUMNS = 32; + private static final int PLANE_SLOTS = 1 << 17; + private static final double PLANE_STEP = 4.0D; + private static final double PLANE_OFFSET = 0.375D; + private static final int VOLUME_PLANE_SLOTS = 1 << 22; + private static final double VOLUME_PLANE_RESOLUTION = 512.0D; + private static final long FNV_OFFSET = 0xcbf29ce484222325L; + private static final long FNV_PRIME = 0x100000001b3L; + private static final Map QUANTILES = new ConcurrentHashMap<>(); + + private CellHashNoise() { + } + + public static String register(NoiseRegistry registry) { + return registry.register(new NoiseDef(NOISE_ID, 0, List.of(1.0D))); + } + + public static double plane(String key) { + return PLANE_OFFSET + PLANE_STEP * Math.floorMod(stableHash(key), PLANE_SLOTS); + } + + public static double volumePlane(String key) { + return Math.floorMod(stableHash(key), VOLUME_PLANE_SLOTS) / VOLUME_PLANE_RESOLUTION; + } + + public static Field sample(String noiseId, Field shiftX, Field shiftY, Field shiftZ) { + return new Field.Noise(noiseId, 0.0D, 0.0D, shiftX, shiftY, shiftZ); + } + + public static Field uniform(Field hash, DataVersion target) { + double[] quantiles = quantiles(target); + double[] values = new double[quantiles.length]; + for (int index = 0; index < values.length; index++) { + values[index] = (double) index / CDF_INTERVALS; + } + return CurveKnots.monotone(hash, quantiles, values); + } + + public static Field periodic(Field hash, DataVersion target, DoubleUnaryOperator shape) { + double[] quantiles = quantiles(target); + double[] values = new double[quantiles.length]; + for (int index = 0; index < values.length; index++) { + values[index] = shape.applyAsDouble((double) index / CDF_INTERVALS); + } + return CurveKnots.smooth(hash, quantiles, values); + } + + public static Field white(String key, NoiseRegistry registry, DataVersion target) { + Field hash = new Field.Noise(register(registry), LATTICE_SPACING, 0.0D, null, new Field.Const(plane(key)), null); + return uniform(hash, target); + } + + static long stableHash(String value) { + long hash = FNV_OFFSET; + for (byte part : value.getBytes(StandardCharsets.UTF_8)) { + hash ^= part & 0xFFL; + hash *= FNV_PRIME; + } + hash = (hash ^ hash >>> 30) * 0xbf58476d1ce4e5b9L; + hash = (hash ^ hash >>> 27) * 0x94d049bb133111ebL; + return hash ^ hash >>> 31; + } + + private static double[] quantiles(DataVersion target) { + return QUANTILES.computeIfAbsent(target, CellHashNoise::bake).clone(); + } + + private static double[] bake(DataVersion target) { + double[] samples = new double[REFERENCE_SEEDS * REFERENCE_ROWS * REFERENCE_COLUMNS]; + int index = 0; + for (int seed = 0; seed < REFERENCE_SEEDS; seed++) { + VanillaNoiseModel model = VanillaNoiseModel.create(REFERENCE_NOISE, 0, List.of(1.0D), REFERENCE_SEED + REFERENCE_SEED_STRIDE * seed, target); + for (int row = 0; row < REFERENCE_ROWS; row++) { + for (int column = 0; column < REFERENCE_COLUMNS; column++) { + samples[index++] = model.sample((column - REFERENCE_COLUMNS / 2) * LATTICE_SPACING, REFERENCE_PLANE, (row - REFERENCE_ROWS / 2) * LATTICE_SPACING); + } + } + } + Arrays.sort(samples); + return IrisSampleFit.quantiles(samples, CDF_INTERVALS); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/CellularLibrary.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/CellularLibrary.java new file mode 100644 index 000000000..4d2314879 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/CellularLibrary.java @@ -0,0 +1,319 @@ +package art.arcane.iris.pack.datapack.export.compile.noise; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ir.Axis; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Objects; + +public final class CellularLibrary { + public static final double JITTER_RADIUS = 0.45D; + public static final int JITTER_TURNS = 7; + public static final int VOLUME_CELL_XZ = 4; + public static final int VOLUME_CELL_Y = 8; + private static final DataVersion TARGET = DataVersion.V26_3; + private static final double COORDINATE_STEP = 64.0D; + private static final double HORIZONTAL_EXTENT = 30_000_000.0D; + private static final double VERTICAL_EXTENT = 4096.0D; + private static final double MIN_CELL_BLOCKS = COORDINATE_STEP / 1.0e6D; + private static final double MAX_CELL_BLOCKS = 1.0e6D; + private static final double RANGE_LIMIT = 1.0e6D; + private static final int POLAR_TURNS = 4; + private static final String NAMESPACE_PREFIX = "cell/"; + private static final String COORDINATE_PREFIX = "cell/coord/"; + private static final String QUADRANT_SEGMENT = "/quadrant"; + + private CellularLibrary() { + } + + public static CellularOutputs build(String instanceId, double cellSizeBlocks, Field warpX, Field warpZ, CellularMode mode, NoiseRegistry registry) { + VanillaResourceIdentifier.requirePath(instanceId, "cellular instance id"); + Objects.requireNonNull(mode, "mode"); + Objects.requireNonNull(registry, "registry"); + requireCellSize(cellSizeBlocks); + Construction construction = new Construction(NAMESPACE_PREFIX + instanceId + modeSegment(mode) + "/", instanceId, cellSizeBlocks, CellHashNoise.register(registry), warpX, warpZ); + return switch (mode) { + case GRID_3X3 -> construction.grid(); + case QUADRANT_2X2 -> construction.quadrant(); + case GRID_3X3X3 -> construction.volume(); + }; + } + + static String modeSegment(CellularMode mode) { + return mode == CellularMode.QUADRANT_2X2 ? QUADRANT_SEGMENT : ""; + } + + static Field cellCoordinate(String name, Axis axis, double cellSizeBlocks, Field warpBlocks) { + requireCellSize(cellSizeBlocks); + Field base = blockCoordinate(axis); + Field scaled = cellSizeBlocks == COORDINATE_STEP ? base : multiply(base, COORDINATE_STEP / cellSizeBlocks); + Field warped = warpBlocks == null ? scaled : add(scaled, multiply(warpBlocks, 1.0D / cellSizeBlocks)); + return new Field.Shared(name, warped); + } + + private static Field blockCoordinate(Axis axis) { + double extent = axis == Axis.Y ? VERTICAL_EXTENT : HORIZONTAL_EXTENT; + double value = extent / COORDINATE_STEP; + Field positive = new Field.Coord(axis, Field.Tiling.CLAMP, 0.0D, extent, 0.0D, value); + Field negative = new Field.Coord(axis, Field.Tiling.CLAMP, 0.0D, -extent, 0.0D, -value); + return new Field.Shared(COORDINATE_PREFIX + axis.name().toLowerCase(Locale.ROOT), add(positive, negative)); + } + + private static void requireCellSize(double cellSizeBlocks) { + if (!(cellSizeBlocks >= MIN_CELL_BLOCKS && cellSizeBlocks <= MAX_CELL_BLOCKS)) { + throw new IllegalArgumentException("Cell size must be within [" + MIN_CELL_BLOCKS + ", " + MAX_CELL_BLOCKS + "] blocks, got " + cellSizeBlocks); + } + } + + private static Field add(Field left, Field right) { + return new Field.Binary(Field.BinaryOp.ADD, left, right); + } + + private static Field subtract(Field left, Field right) { + return new Field.Binary(Field.BinaryOp.SUB, left, right); + } + + private static Field multiply(Field left, double factor) { + return new Field.Binary(Field.BinaryOp.MUL, left, new Field.Const(factor)); + } + + private static Field unary(Field.UnaryOp op, Field input) { + return new Field.Unary(op, input); + } + + private static String tag(int offset) { + return switch (offset) { + case -1 -> "m1"; + case 0 -> "0"; + case 1 -> "p1"; + default -> throw new IllegalArgumentException("Cell offset must be -1, 0 or 1, got " + offset); + }; + } + + private static double planarJitterX(int offset, double fraction) { + return offset + JITTER_RADIUS * Math.cos(2.0D * Math.PI * JITTER_TURNS * fraction); + } + + private static double planarJitterZ(int offset, double fraction) { + return offset + JITTER_RADIUS * Math.sin(2.0D * Math.PI * JITTER_TURNS * fraction); + } + + private static double polar(double fraction) { + double phase = POLAR_TURNS * fraction - Math.floor(POLAR_TURNS * fraction); + return 1.0D - 2.0D * Math.abs(2.0D * phase - 1.0D); + } + + private static double volumeJitterX(int offset, double fraction) { + double height = polar(fraction); + return offset + JITTER_RADIUS * Math.sqrt(Math.max(0.0D, 1.0D - height * height)) * Math.cos(2.0D * Math.PI * JITTER_TURNS * fraction); + } + + private static double volumeJitterY(int offset, double fraction) { + return offset + JITTER_RADIUS * polar(fraction); + } + + private static double volumeJitterZ(int offset, double fraction) { + double height = polar(fraction); + return offset + JITTER_RADIUS * Math.sqrt(Math.max(0.0D, 1.0D - height * height)) * Math.sin(2.0D * Math.PI * JITTER_TURNS * fraction); + } + + private static final class Construction { + private final String prefix; + private final double cellSizeBlocks; + private final String hashId; + private final Field plane; + private final double volumePlane; + private final Field xs; + private final Field u; + private final Field ik; + private final Field zs; + private final Field w; + private final Field jk; + + private Construction(String prefix, String instanceId, double cellSizeBlocks, String hashId, Field warpX, Field warpZ) { + this.prefix = prefix; + this.cellSizeBlocks = cellSizeBlocks; + this.hashId = hashId; + this.plane = new Field.Const(CellHashNoise.plane(instanceId)); + this.volumePlane = CellHashNoise.volumePlane(instanceId); + this.xs = cellCoordinate(prefix + "xs", Axis.X, cellSizeBlocks, warpX); + Field i = shared("i", new Field.Round(Field.RoundMode.ROUND, xs, new Field.Const(1.0D))); + this.u = shared("u", subtract(xs, i)); + this.ik = shared("ik", multiply(i, CellHashNoise.LATTICE_SPACING)); + this.zs = cellCoordinate(prefix + "zs", Axis.Z, cellSizeBlocks, warpZ); + Field j = shared("j", new Field.Round(Field.RoundMode.ROUND, zs, new Field.Const(1.0D))); + this.w = shared("w", subtract(zs, j)); + this.jk = shared("jk", multiply(j, CellHashNoise.LATTICE_SPACING)); + } + + private CellularOutputs grid() { + List candidates = new ArrayList<>(9); + for (int di = -1; di <= 1; di++) { + for (int dj = -1; dj <= 1; dj++) { + candidates.add(planar(di, dj)); + } + } + return planarOutputs("", candidates); + } + + private CellularOutputs quadrant() { + Field signX = shared("s", unary(Field.UnaryOp.SIGN, u)); + Field signZ = shared("t", unary(Field.UnaryOp.SIGN, w)); + Field towardX = shared("s_u", subtract(signX, u)); + Field towardZ = shared("t_w", subtract(signZ, w)); + Field neighbourX = shared("ik_s", add(ik, new Field.Binary(Field.BinaryOp.MUL, signX, new Field.Const(CellHashNoise.LATTICE_SPACING)))); + Field neighbourZ = shared("jk_t", add(jk, new Field.Binary(Field.BinaryOp.MUL, signZ, new Field.Const(CellHashNoise.LATTICE_SPACING)))); + List candidates = List.of( + planar(0, 0), + quadrantCandidate("s0", neighbourX, jk, towardX, null), + quadrantCandidate("0t", ik, neighbourZ, null, towardZ), + quadrantCandidate("st", neighbourX, neighbourZ, towardX, towardZ)); + return planarOutputs("q_", candidates); + } + + private CellularOutputs volume() { + Field ys = cellCoordinate(prefix + "ys", Axis.Y, cellSizeBlocks, null); + Field l = shared("l", new Field.Round(Field.RoundMode.ROUND, ys, new Field.Const(1.0D))); + Field v = shared("v", subtract(ys, l)); + Field lk = shared("lk", add(multiply(l, CellHashNoise.LATTICE_SPACING), new Field.Const(volumePlane))); + List candidates = new ArrayList<>(27); + for (int di = -1; di <= 1; di++) { + for (int dl = -1; dl <= 1; dl++) { + for (int dj = -1; dj <= 1; dj++) { + candidates.add(volumeCandidate(di, dl, dj, lk, v)); + } + } + } + Candidate winner = tournament("v_m", candidates); + Extremes extremes = extremes("v_f", candidates); + return new CellularOutputs( + volumeGrid(shared("v_value", CellHashNoise.uniform(winner.hash(), TARGET))), + volumeGrid(extremes.nearest()), + volumeGrid(extremes.second()), + volumeGrid(winnerCoordinate("v_winner_x", xs, winner.offsetX())), + volumeGrid(winnerCoordinate("v_winner_z", zs, winner.offsetZ()))); + } + + private Candidate planar(int di, int dj) { + String tag = tag(di) + "_" + tag(dj); + Field hash = shared("h_" + tag, CellHashNoise.sample(hashId, neighbour(ik, "ik", di), plane, neighbour(jk, "jk", dj))); + Field offsetX = shared("vx_" + tag, subtract(CellHashNoise.periodic(hash, TARGET, fraction -> planarJitterX(di, fraction)), u)); + Field offsetZ = shared("vz_" + tag, subtract(CellHashNoise.periodic(hash, TARGET, fraction -> planarJitterZ(dj, fraction)), w)); + return new Candidate(shared("d_" + tag, natural(List.of(offsetX, offsetZ))), hash, offsetX, offsetZ); + } + + private Candidate quadrantCandidate(String tag, Field shiftX, Field shiftZ, Field towardX, Field towardZ) { + Field hash = shared("q_h_" + tag, CellHashNoise.sample(hashId, shiftX, plane, shiftZ)); + Field jitterX = CellHashNoise.periodic(hash, TARGET, fraction -> planarJitterX(0, fraction)); + Field jitterZ = CellHashNoise.periodic(hash, TARGET, fraction -> planarJitterZ(0, fraction)); + Field offsetX = shared("q_vx_" + tag, towardX == null ? subtract(jitterX, u) : add(jitterX, towardX)); + Field offsetZ = shared("q_vz_" + tag, towardZ == null ? subtract(jitterZ, w) : add(jitterZ, towardZ)); + return new Candidate(shared("q_d_" + tag, natural(List.of(offsetX, offsetZ))), hash, offsetX, offsetZ); + } + + private Candidate volumeCandidate(int di, int dl, int dj, Field lk, Field v) { + String tag = tag(di) + "_" + tag(dl) + "_" + tag(dj); + Field hash = shared("v_h_" + tag, CellHashNoise.sample(hashId, neighbour(ik, "ik", di), neighbour(lk, "lk", dl), neighbour(jk, "jk", dj))); + Field offsetX = shared("v_vx_" + tag, subtract(CellHashNoise.periodic(hash, TARGET, fraction -> volumeJitterX(di, fraction)), u)); + Field offsetY = shared("v_vy_" + tag, subtract(CellHashNoise.periodic(hash, TARGET, fraction -> volumeJitterY(dl, fraction)), v)); + Field offsetZ = shared("v_vz_" + tag, subtract(CellHashNoise.periodic(hash, TARGET, fraction -> volumeJitterZ(dj, fraction)), w)); + return new Candidate(shared("v_d_" + tag, natural(List.of(offsetX, offsetY, offsetZ))), hash, offsetX, offsetZ); + } + + private CellularOutputs planarOutputs(String modePrefix, List candidates) { + Candidate winner = tournament(modePrefix + "m", candidates); + Extremes extremes = extremes(modePrefix + "f", candidates); + return new CellularOutputs( + shared(modePrefix + "value", CellHashNoise.uniform(winner.hash(), TARGET)), + extremes.nearest(), + extremes.second(), + winnerCoordinate(modePrefix + "winner_x", xs, winner.offsetX()), + winnerCoordinate(modePrefix + "winner_z", zs, winner.offsetZ())); + } + + private Candidate tournament(String tagPrefix, List candidates) { + List round = candidates; + int level = 0; + while (round.size() > 1) { + List next = new ArrayList<>((round.size() + 1) / 2); + for (int index = 0; index < round.size(); index += 2) { + if (index + 1 < round.size()) { + next.add(match(tagPrefix + level + "_" + index / 2, round.get(index), round.get(index + 1))); + } else { + next.add(round.get(index)); + } + } + round = next; + level++; + } + return round.getFirst(); + } + + private Candidate match(String tag, Candidate first, Candidate second) { + Field difference = shared(tag + "_diff", subtract(first.distance(), second.distance())); + return new Candidate( + shared(tag + "_d", new Field.Binary(Field.BinaryOp.MIN, first.distance(), second.distance())), + shared(tag + "_h", pick(difference, first.hash(), second.hash())), + shared(tag + "_vx", pick(difference, first.offsetX(), second.offsetX())), + shared(tag + "_vz", pick(difference, first.offsetZ(), second.offsetZ()))); + } + + private Extremes extremes(String tagPrefix, List candidates) { + Field first = candidates.get(0).distance(); + Field second = candidates.get(1).distance(); + Field nearest = shared(tagPrefix + "1_1", new Field.Binary(Field.BinaryOp.MIN, first, second)); + Field runnerUp = shared(tagPrefix + "2_1", new Field.Binary(Field.BinaryOp.MAX, first, second)); + for (int index = 2; index < candidates.size(); index++) { + Field distance = candidates.get(index).distance(); + runnerUp = shared(tagPrefix + "2_" + index, new Field.Binary(Field.BinaryOp.MIN, runnerUp, new Field.Binary(Field.BinaryOp.MAX, nearest, distance))); + nearest = shared(tagPrefix + "1_" + index, new Field.Binary(Field.BinaryOp.MIN, nearest, distance)); + } + return new Extremes(nearest, runnerUp); + } + + private Field winnerCoordinate(String name, Field cellCoordinate, Field offset) { + return shared(name, multiply(add(cellCoordinate, offset), cellSizeBlocks)); + } + + private Field neighbour(Field base, String name, int offset) { + if (offset == 0) { + return base; + } + return shared(name + "_" + tag(offset), add(base, new Field.Const(offset * CellHashNoise.LATTICE_SPACING))); + } + + private Field natural(List offsets) { + Field manhattan = null; + Field euclidean = null; + for (Field offset : offsets) { + Field absolute = unary(Field.UnaryOp.ABS, offset); + Field square = unary(Field.UnaryOp.SQUARE, offset); + manhattan = manhattan == null ? absolute : add(manhattan, absolute); + euclidean = euclidean == null ? square : add(euclidean, square); + } + return add(manhattan, euclidean); + } + + private Field pick(Field difference, Field first, Field second) { + return new Field.RangeIf(difference, -RANGE_LIMIT, 0.0D, first, second); + } + + private Field volumeGrid(Field field) { + return new Field.Grid(field, VOLUME_CELL_XZ, VOLUME_CELL_Y); + } + + private Field shared(String name, Field expr) { + return new Field.Shared(prefix + name, expr); + } + } + + private record Candidate(Field distance, Field hash, Field offsetX, Field offsetZ) { + } + + private record Extremes(Field nearest, Field second) { + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/CellularLookalike.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/CellularLookalike.java new file mode 100644 index 000000000..8ac4a5d2e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/CellularLookalike.java @@ -0,0 +1,29 @@ +package art.arcane.iris.pack.datapack.export.compile.noise; + +import art.arcane.iris.generation.noise.NoiseStyle; +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; + +public final class CellularLookalike { + private static final String NAMESPACE_PREFIX = "cell/"; + + private CellularLookalike() { + } + + public static Field smoothStyle(String instanceId, NoiseStyle style, double zoom, Field warpX, Field warpZ, NoiseRegistry registry, DataVersion target) { + String prefix = prefix(instanceId); + if (!(zoom > 0.0D) || !Double.isFinite(zoom)) { + throw new IllegalArgumentException("Smooth look-alike zoom must be positive and finite, got " + zoom); + } + IrisSampleFit.Profile iris = IrisSampleFit.style(style); + IrisSampleFit.Profile vanilla = IrisSampleFit.vanilla(target); + double xzScale = LeafNoise.xzScale(vanilla, iris.correlationLength() * zoom); + Field noise = LeafNoise.sample(LeafNoise.register(registry), xzScale, prefix + "smooth", warpX, warpZ); + return new Field.Shared(prefix + "smooth", LeafNoise.toMarginal(noise, vanilla, iris)); + } + + private static String prefix(String instanceId) { + return NAMESPACE_PREFIX + VanillaResourceIdentifier.requirePath(instanceId, "cellular look-alike instance id") + "/"; + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/CellularMode.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/CellularMode.java new file mode 100644 index 000000000..749d3930b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/CellularMode.java @@ -0,0 +1,7 @@ +package art.arcane.iris.pack.datapack.export.compile.noise; + +public enum CellularMode { + GRID_3X3, + QUADRANT_2X2, + GRID_3X3X3 +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/CellularOutputs.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/CellularOutputs.java new file mode 100644 index 000000000..7d70445c7 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/CellularOutputs.java @@ -0,0 +1,15 @@ +package art.arcane.iris.pack.datapack.export.compile.noise; + +import art.arcane.iris.pack.datapack.export.ir.Field; + +import java.util.Objects; + +public record CellularOutputs(Field cellValue, Field f1, Field f2, Field winnerX, Field winnerZ) { + public CellularOutputs { + Objects.requireNonNull(cellValue, "cellValue"); + Objects.requireNonNull(f1, "f1"); + Objects.requireNonNull(f2, "f2"); + Objects.requireNonNull(winnerX, "winnerX"); + Objects.requireNonNull(winnerZ, "winnerZ"); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/CellularStyleLowering.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/CellularStyleLowering.java new file mode 100644 index 000000000..d1c7ef31f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/CellularStyleLowering.java @@ -0,0 +1,9 @@ +package art.arcane.iris.pack.datapack.export.compile.noise; + +import art.arcane.iris.generation.noise.IrisGeneratorStyle; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.lower.ExportCapabilities; + +public interface CellularStyleLowering { + Field lower(IrisGeneratorStyle style, long seedSalt, NoiseRegistry registry, ExportCapabilities capabilities); +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/CellularWarp.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/CellularWarp.java new file mode 100644 index 000000000..00314d786 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/CellularWarp.java @@ -0,0 +1,71 @@ +package art.arcane.iris.pack.datapack.export.compile.noise; + +import art.arcane.iris.generation.noise.NoiseStyle; +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ir.Field; + +import java.util.List; + +final class CellularWarp { + static final Offsets NONE = new Offsets(null, null); + private static final double IRIS_FREQUENCY = 0.01D; + private static final double STYLE_FREQUENCY = 1.0D / 64.0D; + + private CellularWarp() { + } + + static Offsets signature(String prefix, Signature signature, double zoom, NoiseRegistry registry, DataVersion target) { + String leafId = LeafNoise.register(registry); + IrisSampleFit.Profile vanilla = IrisSampleFit.vanilla(target); + Offsets inner = NONE; + List levels = signature.levels(); + for (int index = levels.size() - 1; index >= 0; index--) { + inner = level(prefix + "warp" + index, levels.get(index), inner, zoom, leafId, vanilla); + } + return inner; + } + + static Offsets fracture(String prefix, Field fractureX, Field fractureZ, double amplitude) { + return new Offsets(centered(prefix + "fracture_x", fractureX, amplitude), centered(prefix + "fracture_z", fractureZ, amplitude)); + } + + private static Offsets level(String name, Level level, Offsets inner, double zoom, String leafId, IrisSampleFit.Profile vanilla) { + IrisSampleFit.Profile iris = IrisSampleFit.style(level.leaf()); + double frequency = level.scale() * IRIS_FREQUENCY / zoom; + double xzScale = LeafNoise.xzScale(vanilla, iris.correlationLength() * STYLE_FREQUENCY / frequency); + Field amplitude = new Field.Const(level.fracture() * zoom * iris.deviation() / vanilla.deviation()); + Field x = new Field.Binary(Field.BinaryOp.MUL, LeafNoise.sample(leafId, xzScale, name + "_x", inner.x(), inner.z()), amplitude); + Field z = new Field.Binary(Field.BinaryOp.MUL, LeafNoise.sample(leafId, xzScale, name + "_z", inner.x(), inner.z()), amplitude); + return new Offsets(new Field.Shared(name + "_x", x), new Field.Shared(name + "_z", z)); + } + + private static Field centered(String name, Field value, double amplitude) { + Field offset = new Field.Binary(Field.BinaryOp.ADD, value, new Field.Const(-0.5D)); + return new Field.Shared(name, new Field.Binary(Field.BinaryOp.MUL, offset, new Field.Const(amplitude))); + } + + enum Signature { + IRIS(List.of(new Level(NoiseStyle.SIMPLEX, 0.9D, 44.0D), new Level(NoiseStyle.SIMPLEX, 0.21D, 145.0D), new Level(NoiseStyle.SIMPLEX, 0.9D, 620.0D))), + THICK(List.of(new Level(NoiseStyle.SIMPLEX, 0.5D, 44.0D), new Level(NoiseStyle.SIMPLEX, 0.11D, 145.0D), new Level(NoiseStyle.SIMPLEX, 0.4D, 620.0D))), + HALF(List.of(new Level(NoiseStyle.SIMPLEX, 0.9D, 22.0D), new Level(NoiseStyle.SIMPLEX, 0.21D, 99.0D), new Level(NoiseStyle.SIMPLEX, 0.9D, 420.0D))), + DOUBLE(List.of(new Level(NoiseStyle.SIMPLEX, 1.0D, 93.0D), new Level(NoiseStyle.SIMPLEX, 0.9D, 44.0D), new Level(NoiseStyle.SIMPLEX, 0.21D, 145.0D), + new Level(NoiseStyle.SIMPLEX, 0.9D, 620.0D))), + PERLIN(List.of(new Level(NoiseStyle.PERLIN, 1.25D, 250.0D))); + + private final List levels; + + Signature(List levels) { + this.levels = levels; + } + + List levels() { + return levels; + } + } + + record Offsets(Field x, Field z) { + } + + record Level(NoiseStyle leaf, double scale, double fracture) { + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/CurveKnots.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/CurveKnots.java new file mode 100644 index 000000000..8fa713047 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/CurveKnots.java @@ -0,0 +1,60 @@ +package art.arcane.iris.pack.datapack.export.compile.noise; + +import art.arcane.iris.pack.datapack.export.ir.Field; + +import java.util.ArrayList; +import java.util.List; + +final class CurveKnots { + private CurveKnots() { + } + + static Field monotone(Field input, double[] locations, double[] values) { + double[] knots = increasing(locations); + return curve(input, knots, values, monotoneDerivatives(knots, values)); + } + + static Field smooth(Field input, double[] locations, double[] values) { + double[] knots = increasing(locations); + double[] derivatives = new double[knots.length]; + for (int index = 1; index + 1 < knots.length; index++) { + derivatives[index] = (values[index + 1] - values[index - 1]) / (knots[index + 1] - knots[index - 1]); + } + return curve(input, knots, values, derivatives); + } + + private static Field curve(Field input, double[] knots, double[] values, double[] derivatives) { + if (knots.length != values.length) { + throw new IllegalArgumentException("Curve needs one value per knot, got " + values.length + " values for " + knots.length + " knots"); + } + List points = new ArrayList<>(knots.length); + for (int index = 0; index < knots.length; index++) { + points.add(new Field.Knot(knots[index], new Field.Const(values[index]), derivatives[index])); + } + return new Field.Curve(input, points); + } + + private static double[] increasing(double[] locations) { + double[] knots = new double[locations.length]; + float previous = Float.NEGATIVE_INFINITY; + for (int index = 0; index < locations.length; index++) { + float location = (float) locations[index]; + if (location <= previous) { + location = Math.nextUp(previous); + } + knots[index] = location; + previous = location; + } + return knots; + } + + private static double[] monotoneDerivatives(double[] knots, double[] values) { + double[] derivatives = new double[knots.length]; + for (int index = 1; index + 1 < knots.length; index++) { + double left = (values[index] - values[index - 1]) / (knots[index] - knots[index - 1]); + double right = (values[index + 1] - values[index]) / (knots[index + 1] - knots[index]); + derivatives[index] = left * right <= 0.0D ? 0.0D : 2.0D * left * right / (left + right); + } + return derivatives; + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/DefaultCellularStyleLowering.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/DefaultCellularStyleLowering.java new file mode 100644 index 000000000..b8964d944 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/DefaultCellularStyleLowering.java @@ -0,0 +1,397 @@ +package art.arcane.iris.pack.datapack.export.compile.noise; + +import art.arcane.iris.generation.noise.IrisGenerator; +import art.arcane.iris.generation.noise.IrisGeneratorStyle; +import art.arcane.iris.generation.noise.NoiseStyle; +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.lower.ExportCapabilities; + +import java.util.List; +import java.util.Locale; +import java.util.Objects; + +public final class DefaultCellularStyleLowering implements CellularStyleLowering { + private static final double CELL_BLOCKS = 64.0D; + private static final double SIMPLEX_CELL_BLOCKS = 100.0D; + private static final double SIMPLEX_CELL_FRACTURE = 200.0D; + private static final double HEX_BLOCKS = 32.0D; + private static final double SMOOTHING_BLOCKS = 32.0D; + private static final int SMOOTHING_CELL_Y = 8; + private static final double THIN_POWER = 1.0D / 0.65D; + private static final double IRIS_CELL_FREQUENCY = 0.01D; + private static final double CRACK_CELL_SCALE = 0.73D; + private static final double CRACK_SIGNATURE_ZOOM = 1.5625D; + private static final int SECOND_VALUE_TURNS = 5; + private static final int SECOND_VALUE_INTERVALS = 120; + private static final double RANGE_LIMIT = 1.0e6D; + private static final String NAMESPACE_PREFIX = "cell/"; + private static final String FRACTURE_X = "fracture_x"; + private static final String FRACTURE_Z = "fracture_z"; + + private final List diagnostics; + private final CellularStyleLowering nestedStyles; + private final CellularMode cellMode; + + public DefaultCellularStyleLowering(List diagnostics, CellularStyleLowering nestedStyles, CellularMode cellMode) { + this.diagnostics = Objects.requireNonNull(diagnostics, "diagnostics"); + this.nestedStyles = Objects.requireNonNull(nestedStyles, "nestedStyles"); + this.cellMode = Objects.requireNonNull(cellMode, "cellMode"); + if (cellMode == CellularMode.GRID_3X3X3) { + throw new IllegalArgumentException("Planar cell values lower in GRID_3X3 or QUADRANT_2X2 mode, got " + cellMode); + } + } + + public static boolean supports(NoiseStyle style) { + return style != null && shape(style) != null; + } + + @Override + public Field lower(IrisGeneratorStyle style, long seedSalt, NoiseRegistry registry, ExportCapabilities capabilities) { + Objects.requireNonNull(style, "style"); + Objects.requireNonNull(registry, "registry"); + Objects.requireNonNull(capabilities, "capabilities"); + if (style.getExpression() != null || style.getImageMap() != null) { + throw new IllegalArgumentException("Cellular lowering needs a plain noise style, got an expression or image map style"); + } + NoiseStyle noiseStyle = style.getStyle() == null ? NoiseStyle.FLAT : style.getStyle(); + Shape shape = shape(noiseStyle); + if (shape == null) { + return smoothCore(style, noiseStyle, seedSalt, registry, capabilities); + } + if (style.getCellularFrequency() > 0.0D) { + report(noiseStyle, "Noise style " + noiseStyle.name() + " exports without its cellularFrequency cell lookup"); + } + Request request = new Request(instanceId(style, seedSalt, capabilities.target()), cellMode, style, noiseStyle, shape, seedSalt, registry, capabilities); + Field value = switch (shape.family()) { + case CELL_VALUE -> cellValue(request); + case VASCULAR, GLOB, CELL_HEIGHT -> distanceStyle(request); + case SIMPLEX_CELLS -> simplexCells(request); + case SIMPLEX_VASCULAR -> simplexVascular(request); + case WHITE -> white(request); + case HEXAGON -> hexagon(request); + case PATTERN -> pattern(request); + }; + return power(request, value, shape.power() * style.getExponent()); + } + + public Field cellCracks(IrisGenerator generator, Field value, long seedSalt, NoiseRegistry registry, ExportCapabilities capabilities) { + Objects.requireNonNull(generator, "generator"); + Objects.requireNonNull(value, "value"); + Objects.requireNonNull(registry, "registry"); + Objects.requireNonNull(capabilities, "capabilities"); + double height = generator.getCellFractureHeight(); + if (height == 0.0D) { + return value; + } + double zoom = generator.getCellFractureZoom(); + DataVersion target = capabilities.target(); + String description = "cracks|" + seedSalt + "|" + zoom + "|" + generator.getCellFractureShuffle() + "|" + target.name(); + String instanceId = "cracks/" + Long.toUnsignedString(CellHashNoise.stableHash(description), 36); + String prefix = NAMESPACE_PREFIX + instanceId + "/"; + CellularWarp.Offsets chain = CellularWarp.signature(prefix, CellularWarp.Signature.IRIS, zoom, registry, target); + double shuffle = generator.getCellFractureShuffle() * zoom / CRACK_CELL_SCALE; + Field warpX = shuffleWarp(prefix + "shuffle_x", instanceId + "/shuffle_x", chain, zoom, shuffle, registry, target); + Field warpZ = shuffleWarp(prefix + "shuffle_z", instanceId + "/shuffle_z", chain, zoom, shuffle, registry, target); + double cell = zoom / (IRIS_CELL_FREQUENCY * CRACK_CELL_SCALE); + Field distance = halfGap(CellularLibrary.build(instanceId, cell, warpX, warpZ, CellularMode.GRID_3X3, registry)); + Field crack = new Field.Shared(prefix + "distance", distance); + Field raised = new Field.Binary(Field.BinaryOp.MUL, value, new Field.Const(height)); + return new Field.RangeIf(crack, generator.getCellPercentSize(), RANGE_LIMIT, raised, value); + } + + private Field cellValue(Request request) { + CellularWarp.Offsets warp = warp(request); + double zoom = request.zoom(); + Field value = CellularLibrary.build(request.instanceId(), CELL_BLOCKS * zoom, warp.x(), warp.z(), cellMode, request.registry()).cellValue(); + if (!request.shape().smoothed()) { + return value; + } + return new Field.Shared(request.modePrefix() + "smoothed", new Field.Grid(value, smoothingCell(zoom), SMOOTHING_CELL_Y)); + } + + private Field distanceStyle(Request request) { + Family family = request.shape().family(); + CellularWarp.Offsets warp = warp(request); + double cell = CELL_BLOCKS * request.zoom(); + CellularOutputs outputs = CellularLibrary.build(request.instanceId(), cell, warp.x(), warp.z(), CellularMode.GRID_3X3, request.registry()); + Field distance = switch (family) { + case VASCULAR -> vascular(outputs); + case GLOB -> new Field.Binary(Field.BinaryOp.DIV, outputs.f1(), outputs.f2()); + case CELL_HEIGHT -> new Field.Clamp(halfGap(outputs), 0.0D, 1.0D); + default -> throw new IllegalStateException("Family " + family + " is not a distance style"); + }; + return new Field.Shared(request.prefix() + "distance", distance); + } + + private Field simplexCells(Request request) { + if (request.style().getFracture() != null) { + return smooth(request, NoiseStyle.SIMPLEX, userWarp(request)); + } + double cell = SIMPLEX_CELL_BLOCKS * request.zoom(); + Field first = CellularLibrary.build(request.instanceId() + "/cells", cell, null, null, cellMode, request.registry()).cellValue(); + Field second = new Field.Shared(request.modePrefix() + "cells_second", secondValue(first)); + CellularWarp.Offsets fracture = CellularWarp.fracture(request.modePrefix(), first, second, SIMPLEX_CELL_FRACTURE * request.zoom()); + return CellularLookalike.smoothStyle(request.modeId(), NoiseStyle.SIMPLEX, request.zoom(), fracture.x(), fracture.z(), request.registry(), request.target()); + } + + private Field simplexVascular(Request request) { + if (request.style().getFracture() != null) { + return smooth(request, NoiseStyle.SIMPLEX, userWarp(request)); + } + double cell = SIMPLEX_CELL_BLOCKS * request.zoom(); + Field first = veinValue(request, request.instanceId() + "/veins_x", cell); + Field second = veinValue(request, request.instanceId() + "/veins_z", cell); + return smooth(request, NoiseStyle.SIMPLEX, CellularWarp.fracture(request.prefix(), first, second, SIMPLEX_CELL_FRACTURE * request.zoom())); + } + + private Field white(Request request) { + if (!request.shape().smoothed()) { + return new Field.Shared(request.prefix() + "white", CellHashNoise.white(request.instanceId(), request.registry(), request.target())); + } + Field white = CellHashNoise.white(request.instanceId(), request.registry(), request.target()); + return new Field.Shared(request.prefix() + "smoothed", new Field.Grid(white, smoothingCell(request.zoom()), SMOOTHING_CELL_Y)); + } + + private Field hexagon(Request request) { + CellularWarp.Offsets warp = warp(request); + return HexLattice.centerValue(request.instanceId(), HEX_BLOCKS * request.zoom(), warp.x(), warp.z(), request.registry()); + } + + private Field pattern(Request request) { + report(request.noiseStyle(), "Noise style " + request.noiseStyle().name() + " exports as fitted smooth noise"); + return smooth(request, request.noiseStyle(), userWarp(request)); + } + + private Field veinValue(Request request, String instanceId, double cell) { + CellularOutputs outputs = CellularLibrary.build(instanceId, cell, null, null, CellularMode.GRID_3X3, request.registry()); + return new Field.Shared(NAMESPACE_PREFIX + instanceId + "/vascular", vascular(outputs)); + } + + private Field smooth(Request request, NoiseStyle style, CellularWarp.Offsets warp) { + return CellularLookalike.smoothStyle(request.instanceId(), style, request.zoom(), warp.x(), warp.z(), request.registry(), request.target()); + } + + private CellularWarp.Offsets warp(Request request) { + if (request.style().getFracture() != null) { + return userWarp(request); + } + CellularWarp.Signature signature = request.shape().warp(); + if (signature == null) { + return CellularWarp.NONE; + } + return CellularWarp.signature(request.prefix(), signature, request.zoom(), request.registry(), request.target()); + } + + private CellularWarp.Offsets userWarp(Request request) { + IrisGeneratorStyle fracture = request.style().getFracture(); + if (fracture == null) { + return CellularWarp.NONE; + } + IrisGeneratorStyle zoomed = zoomedCopy(fracture, fracture.getZoom() * request.zoom()); + Field x = nestedStyles.lower(zoomed, salt(request.seedSalt(), FRACTURE_X), request.registry(), request.capabilities()); + Field z = nestedStyles.lower(zoomed, salt(request.seedSalt(), FRACTURE_Z), request.registry(), request.capabilities()); + return CellularWarp.fracture(request.modePrefix(), x, z, fracture.getMultiplier() * request.zoom()); + } + + private Field power(Request request, Field value, double exponent) { + if (exponent == 1.0D) { + return value; + } + Field constant = new Field.Const(exponent); + if (request.shape().family() != Family.PATTERN) { + return new Field.Shared(request.modePrefix() + "style", new Field.Binary(Field.BinaryOp.POW, new Field.Clamp(value, 0.0D, 1.0D), constant)); + } + Field magnitude = new Field.Binary(Field.BinaryOp.POW, new Field.Unary(Field.UnaryOp.ABS, value), constant); + return new Field.Shared(request.modePrefix() + "style", new Field.Binary(Field.BinaryOp.MUL, new Field.Unary(Field.UnaryOp.SIGN, value), magnitude)); + } + + private Field shuffleWarp(String name, String instanceId, CellularWarp.Offsets chain, double zoom, double shuffle, NoiseRegistry registry, DataVersion target) { + Field value = CellularLookalike.smoothStyle(instanceId, NoiseStyle.SIMPLEX, CRACK_SIGNATURE_ZOOM * zoom, chain.x(), chain.z(), registry, target); + return new Field.Shared(name, new Field.Binary(Field.BinaryOp.MUL, value, new Field.Const(shuffle))); + } + + private void report(NoiseStyle style, String message) { + ExportDiagnostic diagnostic = new ExportDiagnostic(ExportDiagnostic.Severity.WARNING, ExportLimit.NOISE_STYLE, "noise style " + style.name(), message); + if (!diagnostics.contains(diagnostic)) { + diagnostics.add(diagnostic); + } + } + + private static Field vascular(CellularOutputs outputs) { + Field gap = new Field.Binary(Field.BinaryOp.SUB, outputs.f2(), outputs.f1()); + Field value = new Field.Binary(Field.BinaryOp.ADD, new Field.Const(1.0D), new Field.Binary(Field.BinaryOp.MUL, gap, new Field.Const(-0.5D))); + return new Field.Clamp(value, 0.0D, 1.0D); + } + + private static Field halfGap(CellularOutputs outputs) { + return new Field.Binary(Field.BinaryOp.MUL, new Field.Binary(Field.BinaryOp.SUB, outputs.f2(), outputs.f1()), new Field.Const(0.5D)); + } + + private static Field secondValue(Field cellValue) { + double[] locations = new double[SECOND_VALUE_INTERVALS + 1]; + double[] values = new double[SECOND_VALUE_INTERVALS + 1]; + for (int index = 0; index <= SECOND_VALUE_INTERVALS; index++) { + double fraction = (double) index / SECOND_VALUE_INTERVALS; + double phase = SECOND_VALUE_TURNS * fraction - Math.floor(SECOND_VALUE_TURNS * fraction); + locations[index] = fraction; + values[index] = 1.0D - Math.abs(2.0D * phase - 1.0D); + } + return CurveKnots.smooth(cellValue, locations, values); + } + + private static int smoothingCell(double zoom) { + return Math.max(1, (int) Math.round(SMOOTHING_BLOCKS * zoom)); + } + + private static long salt(long seedSalt, String role) { + return CellHashNoise.stableHash(seedSalt + "|" + role); + } + + private static String instanceId(IrisGeneratorStyle style, long seedSalt, DataVersion target) { + String name = (style.getStyle() == null ? NoiseStyle.FLAT : style.getStyle()).name().toLowerCase(Locale.ROOT); + long hash = CellHashNoise.stableHash(describe(style) + "|" + seedSalt + "|" + target.name()); + return name + "/" + Long.toUnsignedString(hash, 36); + } + + private static String describe(IrisGeneratorStyle style) { + if (style == null) { + return "-"; + } + String name = style.getStyle() == null ? NoiseStyle.FLAT.name() : style.getStyle().name(); + return name + "(" + style.getZoom() + "," + style.getExponent() + "," + style.getMultiplier() + "," + style.getCellularFrequency() + + "," + style.getCellularZoom() + "," + style.getExpression() + "," + style.getImageMap() + "," + describe(style.getFracture()) + ")"; + } + + private Field smoothCore(IrisGeneratorStyle style, NoiseStyle noiseStyle, long seedSalt, NoiseRegistry registry, ExportCapabilities capabilities) { + IrisGeneratorStyle core = withoutCellularParts(style); + if (describe(core).equals(describe(style))) { + throw new IllegalArgumentException("Noise style " + noiseStyle.name() + " is not a cellular-family style"); + } + report(noiseStyle, "Noise style " + noiseStyle.name() + " exports without its cellular fracture and cell lookup"); + return nestedStyles.lower(core, seedSalt, registry, capabilities); + } + + private static IrisGeneratorStyle withoutCellularParts(IrisGeneratorStyle style) { + IrisGeneratorStyle fracture = style.getFracture(); + IrisGeneratorStyle smoothFracture = fracture == null || supports(fracture.getStyle()) ? null : withoutCellularParts(fracture); + return zoomedCopy(style, style.getZoom()).setCellularFrequency(0.0D).setFracture(smoothFracture); + } + + private static IrisGeneratorStyle zoomedCopy(IrisGeneratorStyle style, double zoom) { + return new IrisGeneratorStyle(style.getStyle()) + .setCellularFrequency(style.getCellularFrequency()) + .setCellularZoom(style.getCellularZoom()) + .setZoom(zoom) + .setExpression(style.getExpression()) + .setImageMap(style.getImageMap()) + .setMultiplier(style.getMultiplier()) + .setFracture(style.getFracture()) + .setExponent(style.getExponent()) + .setCacheSize(style.getCacheSize()); + } + + private static Shape shape(NoiseStyle style) { + return switch (style) { + case CELLULAR -> plain(Family.CELL_VALUE); + case CELLULAR_IRIS -> warped(Family.CELL_VALUE, CellularWarp.Signature.IRIS); + case CELLULAR_IRIS_THICK -> warped(Family.CELL_VALUE, CellularWarp.Signature.THICK); + case CELLULAR_IRIS_DOUBLE -> warped(Family.CELL_VALUE, CellularWarp.Signature.DOUBLE); + case CELLULAR_IRIS_HALF -> warped(Family.CELL_VALUE, CellularWarp.Signature.HALF); + case NOWHERE_CELLULAR -> warped(Family.CELL_VALUE, CellularWarp.Signature.PERLIN); + case CELLULAR_BILINEAR, CELLULAR_BICUBIC, CELLULAR_HERMITE, + CELLULAR_STARCAST_3, CELLULAR_STARCAST_6, CELLULAR_STARCAST_9, CELLULAR_STARCAST_12, + CELLULAR_BILINEAR_STARCAST_3, CELLULAR_BILINEAR_STARCAST_6, CELLULAR_BILINEAR_STARCAST_9, CELLULAR_BILINEAR_STARCAST_12, + CELLULAR_HERMITE_STARCAST_3, CELLULAR_HERMITE_STARCAST_6, CELLULAR_HERMITE_STARCAST_9, CELLULAR_HERMITE_STARCAST_12 -> smoothed(Family.CELL_VALUE); + case VASCULAR -> plain(Family.VASCULAR); + case VASCULAR_THIN -> new Shape(Family.VASCULAR, null, false, THIN_POWER); + case VASCULAR_IRIS -> warped(Family.VASCULAR, CellularWarp.Signature.IRIS); + case VASCULAR_IRIS_THICK -> warped(Family.VASCULAR, CellularWarp.Signature.THICK); + case VASCULAR_IRIS_DOUBLE -> warped(Family.VASCULAR, CellularWarp.Signature.DOUBLE); + case VASCULAR_IRIS_HALF -> warped(Family.VASCULAR, CellularWarp.Signature.HALF); + case NOWHERE_VASCULAR -> warped(Family.VASCULAR, CellularWarp.Signature.PERLIN); + case GLOB -> plain(Family.GLOB); + case GLOB_IRIS -> warped(Family.GLOB, CellularWarp.Signature.IRIS); + case GLOB_IRIS_THICK -> warped(Family.GLOB, CellularWarp.Signature.THICK); + case GLOB_IRIS_DOUBLE -> warped(Family.GLOB, CellularWarp.Signature.DOUBLE); + case GLOB_IRIS_HALF -> warped(Family.GLOB, CellularWarp.Signature.HALF); + case NOWHERE_GLOB -> warped(Family.GLOB, CellularWarp.Signature.PERLIN); + case CELLULAR_HEIGHT -> plain(Family.CELL_HEIGHT); + case CELLULAR_HEIGHT_IRIS -> warped(Family.CELL_HEIGHT, CellularWarp.Signature.IRIS); + case CELLULAR_HEIGHT_IRIS_THICK -> warped(Family.CELL_HEIGHT, CellularWarp.Signature.THICK); + case CELLULAR_HEIGHT_IRIS_DOUBLE -> warped(Family.CELL_HEIGHT, CellularWarp.Signature.DOUBLE); + case CELLULAR_HEIGHT_IRIS_HALF -> warped(Family.CELL_HEIGHT, CellularWarp.Signature.HALF); + case SIMPLEX_CELLS -> plain(Family.SIMPLEX_CELLS); + case SIMPLEX_VASCULAR -> plain(Family.SIMPLEX_VASCULAR); + case STATIC -> plain(Family.WHITE); + case STATIC_BILINEAR, STATIC_BICUBIC, STATIC_HERMITE -> smoothed(Family.WHITE); + case HEXAGON, FRACTAL_HEXAGON -> plain(Family.HEXAGON); + case HEXAGON_IRIS -> warped(Family.HEXAGON, CellularWarp.Signature.IRIS); + case HEXAGON_IRIS_THICK -> warped(Family.HEXAGON, CellularWarp.Signature.THICK); + case HEXAGON_IRIS_DOUBLE -> warped(Family.HEXAGON, CellularWarp.Signature.DOUBLE); + case HEXAGON_IRIS_HALF -> warped(Family.HEXAGON, CellularWarp.Signature.HALF); + case NOWHERE_HEXAGON -> warped(Family.HEXAGON, CellularWarp.Signature.PERLIN); + case HEX_JAMES, HEX_SIMPLEX, HEX_RANDOM_SIZE, SIERPINSKI_TRIANGLE, + NOWHERE_HEX_JAMES, NOWHERE_HEX_SIMPLEX, NOWHERE_HEX_RANDOM_SIZE, NOWHERE_SIERPINSKI_TRIANGLE, + FRACTAL_HEX_JAMES, FRACTAL_HEX_SIMPLEX, FRACTAL_HEX_RANDOM_SIZE, FRACTAL_SIERPINSKI_TRIANGLE, + HEX_JAMES_IRIS, HEX_JAMES_IRIS_DOUBLE, HEX_JAMES_IRIS_THICK, HEX_JAMES_IRIS_HALF, + HEX_SIMPLEX_IRIS, HEX_SIMPLEX_IRIS_DOUBLE, HEX_SIMPLEX_IRIS_THICK, HEX_SIMPLEX_IRIS_HALF, + HEX_RANDOM_SIZE_IRIS, HEX_RANDOM_SIZE_IRIS_DOUBLE, HEX_RANDOM_SIZE_IRIS_THICK, HEX_RANDOM_SIZE_IRIS_HALF, + GYROID, QUASICRYSTAL, TRUCHET, CRATER, VORTEX, DUNE, STRATA, WOOD, GABOR, MARBLE, SCALES, CHLADNI, + KALEIDOSCOPE, MENGER_SPONGE, CIRCUIT -> plain(Family.PATTERN); + default -> null; + }; + } + + private static Shape plain(Family family) { + return new Shape(family, null, false, 1.0D); + } + + private static Shape warped(Family family, CellularWarp.Signature warp) { + return new Shape(family, warp, false, 1.0D); + } + + private static Shape smoothed(Family family) { + return new Shape(family, null, true, 1.0D); + } + + private enum Family { + CELL_VALUE, + VASCULAR, + GLOB, + CELL_HEIGHT, + SIMPLEX_CELLS, + SIMPLEX_VASCULAR, + WHITE, + HEXAGON, + PATTERN + } + + private record Shape(Family family, CellularWarp.Signature warp, boolean smoothed, double power) { + } + + private record Request(String instanceId, CellularMode cellMode, IrisGeneratorStyle style, NoiseStyle noiseStyle, Shape shape, long seedSalt, + NoiseRegistry registry, ExportCapabilities capabilities) { + private double zoom() { + return style.getZoom(); + } + + private DataVersion target() { + return capabilities.target(); + } + + private String prefix() { + return NAMESPACE_PREFIX + instanceId + "/"; + } + + private String modeId() { + return instanceId + CellularLibrary.modeSegment(cellMode); + } + + private String modePrefix() { + return NAMESPACE_PREFIX + modeId() + "/"; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/HexLattice.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/HexLattice.java new file mode 100644 index 000000000..34c937861 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/HexLattice.java @@ -0,0 +1,75 @@ +package art.arcane.iris.pack.datapack.export.compile.noise; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ir.Axis; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; + +import java.util.Objects; + +public final class HexLattice { + public static final double COLUMN_SPACING = Math.sqrt(3.0D); + public static final double ROW_SPACING = 3.0D; + public static final double VALUE_FREQUENCY = 0.18D; + private static final DataVersion TARGET = DataVersion.V26_3; + private static final double RANGE_LIMIT = 1.0e6D; + private static final String NAMESPACE_PREFIX = "cell/"; + + private HexLattice() { + } + + public static Centers centers(String instanceId, double hexSizeBlocks, Field warpX, Field warpZ) { + String prefix = prefix(instanceId); + Field x = CellularLibrary.cellCoordinate(prefix + "hex_x", Axis.X, hexSizeBlocks, warpX); + Field z = CellularLibrary.cellCoordinate(prefix + "hex_z", Axis.Z, hexSizeBlocks, warpZ); + Field firstX = new Field.Shared(prefix + "hex_c1x", snap(x, COLUMN_SPACING, 0.0D)); + Field firstZ = new Field.Shared(prefix + "hex_c1z", snap(z, ROW_SPACING, 0.0D)); + Field secondX = new Field.Shared(prefix + "hex_c2x", snap(x, COLUMN_SPACING, COLUMN_SPACING / 2.0D)); + Field secondZ = new Field.Shared(prefix + "hex_c2z", snap(z, ROW_SPACING, ROW_SPACING / 2.0D)); + Field difference = new Field.Shared(prefix + "hex_diff", new Field.Binary(Field.BinaryOp.SUB, + squaredDistance(x, z, firstX, firstZ), squaredDistance(x, z, secondX, secondZ))); + return new Centers( + new Field.Shared(prefix + "hex_cx", new Field.RangeIf(difference, -RANGE_LIMIT, 0.0D, firstX, secondX)), + new Field.Shared(prefix + "hex_cz", new Field.RangeIf(difference, -RANGE_LIMIT, 0.0D, firstZ, secondZ))); + } + + public static Field centerValue(String instanceId, double hexSizeBlocks, Field warpX, Field warpZ, NoiseRegistry registry) { + Objects.requireNonNull(registry, "registry"); + Centers centers = centers(instanceId, hexSizeBlocks, warpX, warpZ); + IrisSampleFit.Profile vanilla = IrisSampleFit.vanilla(TARGET); + IrisSampleFit.Profile iris = IrisSampleFit.simplexSlice(); + double factor = VALUE_FREQUENCY * vanilla.correlationLength() / iris.correlationLength(); + String prefix = prefix(instanceId); + Field noise = new Field.Noise(LeafNoise.register(registry), 0.0D, 0.0D, + new Field.Binary(Field.BinaryOp.MUL, centers.x(), new Field.Const(factor)), + new Field.Const(CellHashNoise.plane(prefix + "hex_value")), + new Field.Binary(Field.BinaryOp.MUL, centers.z(), new Field.Const(factor))); + return new Field.Shared(prefix + "hex_value", LeafNoise.toMarginal(noise, vanilla, iris)); + } + + private static String prefix(String instanceId) { + return NAMESPACE_PREFIX + VanillaResourceIdentifier.requirePath(instanceId, "hex lattice instance id") + "/"; + } + + private static Field snap(Field coordinate, double spacing, double offset) { + if (offset == 0.0D) { + return new Field.Round(Field.RoundMode.ROUND, coordinate, new Field.Const(spacing)); + } + Field shifted = new Field.Binary(Field.BinaryOp.ADD, coordinate, new Field.Const(-offset)); + Field snapped = new Field.Round(Field.RoundMode.ROUND, shifted, new Field.Const(spacing)); + return new Field.Binary(Field.BinaryOp.ADD, snapped, new Field.Const(offset)); + } + + private static Field squaredDistance(Field x, Field z, Field centerX, Field centerZ) { + Field deltaX = new Field.Binary(Field.BinaryOp.SUB, x, centerX); + Field deltaZ = new Field.Binary(Field.BinaryOp.SUB, z, centerZ); + return new Field.Binary(Field.BinaryOp.ADD, new Field.Unary(Field.UnaryOp.SQUARE, deltaX), new Field.Unary(Field.UnaryOp.SQUARE, deltaZ)); + } + + public record Centers(Field x, Field z) { + public Centers { + Objects.requireNonNull(x, "x"); + Objects.requireNonNull(z, "z"); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/IrisSampleFit.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/IrisSampleFit.java new file mode 100644 index 000000000..95deb8bd6 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/IrisSampleFit.java @@ -0,0 +1,173 @@ +package art.arcane.iris.pack.datapack.export.compile.noise; + +import art.arcane.iris.generation.noise.NoiseStyle; +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.noise.CNG; +import art.arcane.volmlib.util.noise.FastNoiseDouble; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.DoubleBinaryOperator; + +final class IrisSampleFit { + private static final long FIT_SEED = 1_337L; + private static final String FIT_NOISE = "iris_export:cell_leaf"; + private static final double VANILLA_PLANE = 4.375D; + private static final int ROWS = 32; + private static final int STYLE_SEEDS = 4; + private static final int VANILLA_SEEDS = 8; + private static final long SEED_STRIDE = 7_919L; + private static final int ROW_LENGTH = 1024; + private static final int LAGS = 256; + private static final int MARGINAL_AXIS = 64; + private static final double STYLE_STEP = 1.0D; + private static final double STYLE_ROW_SPACING = 211.0D; + private static final double STYLE_MARGINAL_SPACING = 37.0D; + private static final double UNIT_STEP = 0.025D; + private static final double UNIT_ROW_SPACING = 7.31D; + private static final double UNIT_MARGINAL_SPACING = 1.37D; + private static final double CORRELATION_THRESHOLD = 0.5D; + private static final double ROW_SHEAR = 1.618033988749895D; + private static final Map PROFILES = new ConcurrentHashMap<>(); + + private IrisSampleFit() { + } + + static Profile style(NoiseStyle style) { + return PROFILES.computeIfAbsent("style/" + style.name(), ignored -> styleProfile(style)); + } + + static Profile simplexSlice() { + return PROFILES.computeIfAbsent("simplex_slice", ignored -> simplexSliceProfile()); + } + + static Profile vanilla(DataVersion target) { + return PROFILES.computeIfAbsent("vanilla/" + target.name(), ignored -> vanillaProfile(target)); + } + + static double[] quantiles(double[] sorted, int intervals) { + double[] quantiles = new double[intervals + 1]; + for (int index = 0; index <= intervals; index++) { + quantiles[index] = quantile(sorted, (double) index / intervals); + } + return quantiles; + } + + private static Profile styleProfile(NoiseStyle style) { + List samplers = new ArrayList<>(STYLE_SEEDS); + for (int index = 0; index < STYLE_SEEDS; index++) { + CNG generator = style.create(new RNG(seed(index))); + samplers.add(generator::noise); + } + return measure(samplers, STYLE_STEP, STYLE_ROW_SPACING, STYLE_MARGINAL_SPACING); + } + + private static Profile simplexSliceProfile() { + List samplers = new ArrayList<>(STYLE_SEEDS); + for (int index = 0; index < STYLE_SEEDS; index++) { + FastNoiseDouble simplex = new FastNoiseDouble(new RNG(seed(index)).lmax()); + simplex.setFrequency(1.0D); + samplers.add((x, z) -> 0.5D + 0.5D * simplex.GetSimplex(x, 0.0D, z)); + } + return measure(samplers, UNIT_STEP, UNIT_ROW_SPACING, UNIT_MARGINAL_SPACING); + } + + private static Profile vanillaProfile(DataVersion target) { + List samplers = new ArrayList<>(VANILLA_SEEDS); + for (int index = 0; index < VANILLA_SEEDS; index++) { + VanillaNoiseModel model = VanillaNoiseModel.create(FIT_NOISE, 0, List.of(1.0D), seed(index), target); + samplers.add((x, z) -> model.sample(x, VANILLA_PLANE, z)); + } + return measure(samplers, UNIT_STEP, UNIT_ROW_SPACING, UNIT_MARGINAL_SPACING); + } + + private static long seed(int index) { + return FIT_SEED + SEED_STRIDE * index; + } + + private static Profile measure(List samplers, double step, double rowSpacing, double marginalSpacing) { + double[] marginal = new double[MARGINAL_AXIS * MARGINAL_AXIS]; + for (int row = 0; row < MARGINAL_AXIS; row++) { + DoubleBinaryOperator sampler = samplers.get(row % samplers.size()); + for (int column = 0; column < MARGINAL_AXIS; column++) { + marginal[row * MARGINAL_AXIS + column] = sampler.applyAsDouble(column * marginalSpacing, row * marginalSpacing); + } + } + Arrays.sort(marginal); + return new Profile(correlationLength(samplers, step, rowSpacing), deviation(marginal), marginal); + } + + private static double correlationLength(List samplers, double step, double rowSpacing) { + double[][] rows = new double[ROWS][ROW_LENGTH + LAGS]; + double sum = 0.0D; + for (int row = 0; row < ROWS; row++) { + DoubleBinaryOperator sampler = samplers.get(row % samplers.size()); + for (int column = 0; column < ROW_LENGTH + LAGS; column++) { + rows[row][column] = sampler.applyAsDouble(row * rowSpacing * ROW_SHEAR + column * step, row * rowSpacing); + } + for (int column = 0; column < ROW_LENGTH; column++) { + sum += rows[row][column]; + } + } + double mean = sum / (ROWS * ROW_LENGTH); + double previous = 1.0D; + double variance = covariance(rows, mean, 0); + if (variance <= 0.0D) { + return LAGS * step; + } + for (int lag = 1; lag <= LAGS; lag++) { + double correlation = covariance(rows, mean, lag) / variance; + if (correlation < CORRELATION_THRESHOLD) { + double fraction = (previous - CORRELATION_THRESHOLD) / (previous - correlation); + return (lag - 1 + fraction) * step; + } + previous = correlation; + } + return LAGS * step; + } + + private static double covariance(double[][] rows, double mean, int lag) { + double sum = 0.0D; + for (double[] row : rows) { + for (int column = 0; column < ROW_LENGTH; column++) { + sum += (row[column] - mean) * (row[column + lag] - mean); + } + } + return sum / (ROWS * ROW_LENGTH); + } + + private static double deviation(double[] samples) { + double sum = 0.0D; + for (double sample : samples) { + sum += sample; + } + double mean = sum / samples.length; + double squares = 0.0D; + for (double sample : samples) { + squares += (sample - mean) * (sample - mean); + } + return Math.sqrt(squares / samples.length); + } + + private static double quantile(double[] sorted, double fraction) { + double position = fraction * (sorted.length - 1); + int lower = (int) Math.floor(position); + int upper = Math.min(sorted.length - 1, lower + 1); + double weight = position - lower; + return sorted[lower] + (sorted[upper] - sorted[lower]) * weight; + } + + record Profile(double correlationLength, double deviation, double[] sorted) { + double quantile(double fraction) { + return IrisSampleFit.quantile(sorted, fraction); + } + + double[] quantiles(int intervals) { + return IrisSampleFit.quantiles(sorted, intervals); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/LeafNoise.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/LeafNoise.java new file mode 100644 index 000000000..2f3426466 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/LeafNoise.java @@ -0,0 +1,37 @@ +package art.arcane.iris.pack.datapack.export.compile.noise; + +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.NoiseDef; + +import java.util.List; + +final class LeafNoise { + static final String NOISE_ID = "cell_leaf"; + private static final int MARGINAL_INTERVALS = 32; + + private LeafNoise() { + } + + static String register(NoiseRegistry registry) { + return registry.register(new NoiseDef(NOISE_ID, 0, List.of(1.0D))); + } + + static Field sample(String noiseId, double xzScale, String planeKey, Field shiftXBlocks, Field shiftZBlocks) { + return new Field.Noise(noiseId, xzScale, 0.0D, scaled(shiftXBlocks, xzScale), new Field.Const(CellHashNoise.plane(planeKey)), scaled(shiftZBlocks, xzScale)); + } + + static double xzScale(IrisSampleFit.Profile vanilla, double irisCorrelationBlocks) { + return vanilla.correlationLength() / irisCorrelationBlocks; + } + + static Field toMarginal(Field noise, IrisSampleFit.Profile vanilla, IrisSampleFit.Profile iris) { + return CurveKnots.monotone(noise, vanilla.quantiles(MARGINAL_INTERVALS), iris.quantiles(MARGINAL_INTERVALS)); + } + + private static Field scaled(Field shiftBlocks, double xzScale) { + if (shiftBlocks == null) { + return null; + } + return new Field.Binary(Field.BinaryOp.MUL, shiftBlocks, new Field.Const(xzScale)); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/LeafNoiseFit.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/LeafNoiseFit.java new file mode 100644 index 000000000..a953ee341 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/LeafNoiseFit.java @@ -0,0 +1,224 @@ +package art.arcane.iris.pack.datapack.export.compile.noise; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.NoiseDef; +import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.noise.CNG; +import art.arcane.volmlib.util.noise.NoiseType; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.SplittableRandom; +import java.util.function.DoubleBinaryOperator; + +public final class LeafNoiseFit { + static final long FIT_SEED = 0x5EED_1EAFL; + private static final String FIT_NOISE_ID = "iris_fit:leaf"; + private static final int MARGINAL_SAMPLES = 65_536; + private static final int PROFILE_POINTS = 4096; + private static final int INSTANCES = 16; + private static final long INSTANCE_STRIDE = 0x9E37_79B9L; + private static final double IRIS_SPAN = 1 << 20; + private static final double VANILLA_SPAN = 1 << 15; + private static final double SEARCH_RATIO = 3.0; + private static final int SEARCH_ITERATIONS = 40; + private static final double[] IRIS_LAGS = RankCorrelation.geometricLags(1.0, 4096.0, 72, true); + private static final double[] VANILLA_LAGS = RankCorrelation.geometricLags(0.002, 64.0, 160, false); + private static final double[] TAIL_AMPLITUDES = {0.0, 0.25, 0.5, 0.75, 1.0}; + private static final int TOPOLOGY_SIZE = 256; + private static final double TOPOLOGY_PIXELS_PER_HALF = 4.0; + private static final double TOPOLOGY_WEIGHT = 0.05; + private static final double TOPOLOGY_ORIGIN = 4096.0; + private static final double LOWER_TAIL = 0.15; + private static final double UPPER_TAIL = 0.85; + + private LeafNoiseFit() { + } + + public static FittedLeaf fit(NoiseType type, int octaves, DataVersion target) { + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(target, "target"); + if (NoiseLeaves.isCellular(type) || type == NoiseType.FLAT) { + throw new IllegalArgumentException("Noise type " + type + " is not a smooth leaf"); + } + int effectiveOctaves = NoiseLeaves.effectiveOctaves(type, octaves); + double coordinateScale = type.getCoordinateScale(); + CNG generator = new CNG(new RNG(FIT_SEED), type, 1.0, effectiveOctaves); + DoubleBinaryOperator iris = (x, z) -> generator.noise(x * coordinateScale, z * coordinateScale); + double[] irisMarginal = marginal(iris, IRIS_SPAN, FIT_SEED, MARGINAL_SAMPLES); + double[] irisProfile = RankCorrelation.profile(iris, IRIS_LAGS, PROFILE_POINTS, IRIS_SPAN, true, FIT_SEED + 1L); + double irisHalf = RankCorrelation.halfCorrelationLag(IRIS_LAGS, irisProfile); + int[] window = RankCorrelation.window(irisProfile); + double spacing = irisHalf / TOPOLOGY_PIXELS_PER_HALF; + int irisFragments = fragments((x, z) -> iris.applyAsDouble(TOPOLOGY_ORIGIN + x * spacing, TOPOLOGY_ORIGIN + z * spacing)); + Candidate best = null; + for (List profile : profiles(type, effectiveOctaves)) { + Candidate candidate = candidate(profile, target, irisProfile, irisHalf, window, spacing, irisFragments); + if (best == null || candidate.error() < best.error()) { + best = candidate; + } + } + Objects.requireNonNull(best, "candidate"); + List distribution = ValueDistributionFit.fit(best.marginal(), irisMarginal); + NoiseDef noise = new NoiseDef(leafId(type, effectiveOctaves), 0, best.amplitudes()); + return new FittedLeaf(noise, best.frequency() / coordinateScale, distribution); + } + + static String leafId(NoiseType type, int octaves) { + return type.name().toLowerCase(Locale.ROOT) + "_" + octaves; + } + + private static List> profiles(NoiseType type, int octaves) { + List base = NoiseLeaves.isOctaveSum(type) ? Collections.nCopies(octaves, 1.0) : List.of(1.0); + List> profiles = new ArrayList<>(TAIL_AMPLITUDES.length); + for (double tail : TAIL_AMPLITUDES) { + List profile = new ArrayList<>(base); + if (tail > 0.0) { + profile.add(tail); + } + profiles.add(List.copyOf(profile)); + } + return profiles; + } + + private static Candidate candidate(List amplitudes, DataVersion target, double[] irisProfile, double irisHalf, int[] window, + double spacing, int irisFragments) { + Ensemble ensemble = ensemble(amplitudes, target); + double vanillaHalf = RankCorrelation.halfCorrelationLag(VANILLA_LAGS, ensemble.profile()); + double guess = Math.log(vanillaHalf / irisHalf); + double logFrequency = RankCorrelation.goldenMinimum( + candidate -> error(irisProfile, window, ensemble.profile(), Math.exp(candidate)), + guess - Math.log(SEARCH_RATIO), guess + Math.log(SEARCH_RATIO), SEARCH_ITERATIONS); + double frequency = Math.exp(logFrequency); + double topology = Math.log(Math.max(1.0, fragments(ensemble.instances(), spacing * frequency)) / Math.max(1, irisFragments)); + double score = error(irisProfile, window, ensemble.profile(), frequency) + TOPOLOGY_WEIGHT * topology * topology; + return new Candidate(amplitudes, frequency, score, ensemble.marginal()); + } + + private static Ensemble ensemble(List amplitudes, DataVersion target) { + double[] marginal = new double[0]; + double[] profile = new double[VANILLA_LAGS.length]; + List instances = new ArrayList<>(INSTANCES); + for (int instance = 0; instance < INSTANCES; instance++) { + VanillaNoiseModel model = VanillaNoiseModel.create(FIT_NOISE_ID, 0, amplitudes, FIT_SEED + instance * INSTANCE_STRIDE, target); + DoubleBinaryOperator vanilla = (x, z) -> model.sample(x, 0.0, z); + instances.add(vanilla); + marginal = concat(marginal, marginal(vanilla, VANILLA_SPAN, FIT_SEED + 2L + instance, MARGINAL_SAMPLES / INSTANCES)); + double[] sampled = RankCorrelation.profile(vanilla, VANILLA_LAGS, PROFILE_POINTS / INSTANCES, VANILLA_SPAN, false, FIT_SEED + 3L + instance); + for (int index = 0; index < profile.length; index++) { + profile[index] += sampled[index] / INSTANCES; + } + } + return new Ensemble(List.copyOf(instances), marginal, profile); + } + + private static double fragments(List instances, double pixel) { + double total = 0.0; + for (DoubleBinaryOperator vanilla : instances) { + total += fragments((x, z) -> vanilla.applyAsDouble(TOPOLOGY_ORIGIN + x * pixel, TOPOLOGY_ORIGIN + z * pixel)); + } + return total / instances.size(); + } + + private static double error(double[] irisProfile, int[] window, double[] vanillaProfile, double frequency) { + double sum = 0.0; + for (int index : window) { + double expected = RankCorrelation.structure(irisProfile[index]); + double actual = RankCorrelation.structure(VANILLA_LAGS, vanillaProfile, IRIS_LAGS[index] * frequency); + sum += RankCorrelation.structureError(expected, actual); + } + return sum / window.length; + } + + private static double[] marginal(DoubleBinaryOperator field, double span, long seed, int count) { + SplittableRandom random = new SplittableRandom(seed); + double[] samples = new double[count]; + for (int index = 0; index < samples.length; index++) { + double x = (random.nextDouble() * 2.0 - 1.0) * span; + double z = (random.nextDouble() * 2.0 - 1.0) * span; + samples[index] = field.applyAsDouble(x, z); + } + return samples; + } + + private static double[] concat(double[] first, double[] second) { + double[] joined = Arrays.copyOf(first, first.length + second.length); + System.arraycopy(second, 0, joined, first.length, second.length); + return joined; + } + + private static int fragments(DoubleBinaryOperator field) { + double[] values = new double[TOPOLOGY_SIZE * TOPOLOGY_SIZE]; + for (int z = 0; z < TOPOLOGY_SIZE; z++) { + for (int x = 0; x < TOPOLOGY_SIZE; x++) { + values[z * TOPOLOGY_SIZE + x] = field.applyAsDouble(x, z); + } + } + double[] sorted = values.clone(); + Arrays.sort(sorted); + double lower = ValueDistributionFit.quantile(sorted, LOWER_TAIL); + double upper = ValueDistributionFit.quantile(sorted, UPPER_TAIL); + return components(values, upper, true) + components(values, lower, false); + } + + private static int components(double[] values, double threshold, boolean above) { + boolean[] visited = new boolean[values.length]; + ArrayDeque queue = new ArrayDeque<>(); + int count = 0; + for (int start = 0; start < values.length; start++) { + if (visited[start] || !inside(values[start], threshold, above)) { + continue; + } + count++; + visited[start] = true; + queue.add(start); + while (!queue.isEmpty()) { + int cell = queue.poll(); + int x = cell % TOPOLOGY_SIZE; + int z = cell / TOPOLOGY_SIZE; + visit(values, visited, queue, x > 0 ? cell - 1 : -1, threshold, above); + visit(values, visited, queue, x < TOPOLOGY_SIZE - 1 ? cell + 1 : -1, threshold, above); + visit(values, visited, queue, z > 0 ? cell - TOPOLOGY_SIZE : -1, threshold, above); + visit(values, visited, queue, z < TOPOLOGY_SIZE - 1 ? cell + TOPOLOGY_SIZE : -1, threshold, above); + } + } + return count; + } + + private static void visit(double[] values, boolean[] visited, ArrayDeque queue, int cell, double threshold, boolean above) { + if (cell >= 0 && !visited[cell] && inside(values[cell], threshold, above)) { + visited[cell] = true; + queue.add(cell); + } + } + + private static boolean inside(double value, double threshold, boolean above) { + return above ? value > threshold : value < threshold; + } + + public record FittedLeaf(NoiseDef noise, double xzScale, List distribution) { + public FittedLeaf { + Objects.requireNonNull(noise, "noise"); + distribution = List.copyOf(distribution); + if (!(xzScale > 0.0) || !Double.isFinite(xzScale)) { + throw new IllegalArgumentException("Fitted leaf xz scale must be positive and finite, got " + xzScale); + } + } + + public Field.Curve curve(Field input) { + return new Field.Curve(input, distribution); + } + } + + private record Candidate(List amplitudes, double frequency, double error, double[] marginal) { + } + + private record Ensemble(List instances, double[] marginal, double[] profile) { + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/NoiseLeaves.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/NoiseLeaves.java new file mode 100644 index 000000000..27ce587cd --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/NoiseLeaves.java @@ -0,0 +1,101 @@ +package art.arcane.iris.pack.datapack.export.compile.noise; + +import art.arcane.volmlib.util.noise.InterpolatedNoise; +import art.arcane.volmlib.util.noise.NoiseGenerator; +import art.arcane.volmlib.util.noise.NoiseType; +import art.arcane.volmlib.util.noise.OffsetNoiseGenerator; + +import java.util.EnumSet; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +final class NoiseLeaves { + private static final int MAXIMUM_OCTAVES = 16; + private static final Set CELLULAR_BASES = EnumSet.of( + NoiseType.WHITE, NoiseType.CELLULAR, NoiseType.GLOB, NoiseType.CELLULAR_HEIGHT, NoiseType.VASCULAR, + NoiseType.HEXAGON, NoiseType.HEX_JAMES, NoiseType.HEX_SIMPLEX, NoiseType.HEX_RANDOM_SIZE); + private static final Set OCTAVE_SUMS = EnumSet.of(NoiseType.SIMPLEX, NoiseType.PERLIN, NoiseType.FRACTAL_FBM_SIMPLEX); + private static final Set OCTAVE_TYPES = EnumSet.of( + NoiseType.SIMPLEX, NoiseType.PERLIN, NoiseType.FRACTAL_FBM_SIMPLEX, NoiseType.FRACTAL_BILLOW_SIMPLEX, + NoiseType.FRACTAL_BILLOW_PERLIN, NoiseType.FRACTAL_RIGID_MULTI_SIMPLEX, NoiseType.FRACTAL_CUBIC); + + private NoiseLeaves() { + } + + static NoiseType typeOf(NoiseGenerator generator) { + NoiseGenerator base = unwrap(generator); + if (base instanceof InterpolatedNoise interpolated) { + String name = typeOf(interpolated.getGenerator()).name() + "_" + interpolated.getMethod().name(); + return NoiseType.valueOf(name); + } + NoiseType type = Classes.TYPES.get(base.getClass()); + if (type == null) { + throw new IllegalArgumentException("Noise generator " + base.getClass().getName() + " has no exportable noise type"); + } + return type; + } + + static boolean isCellular(NoiseType type) { + return CELLULAR_BASES.contains(baseOf(type)); + } + + static boolean isOctaveSum(NoiseType type) { + return OCTAVE_SUMS.contains(type); + } + + static Fold fold(NoiseType type) { + return switch (type) { + case FRACTAL_BILLOW_SIMPLEX -> new Fold(FoldKind.BILLOW, NoiseType.SIMPLEX); + case FRACTAL_BILLOW_PERLIN -> new Fold(FoldKind.BILLOW, NoiseType.PERLIN); + case FRACTAL_CUBIC -> new Fold(FoldKind.BILLOW, NoiseType.CUBIC); + case FRACTAL_RIGID_MULTI_SIMPLEX -> new Fold(FoldKind.RIGID, NoiseType.SIMPLEX); + default -> new Fold(FoldKind.NONE, type); + }; + } + + static int effectiveOctaves(NoiseType type, int octaves) { + return OCTAVE_TYPES.contains(type) ? Math.clamp(octaves, 1, MAXIMUM_OCTAVES) : 1; + } + + static NoiseGenerator unwrap(NoiseGenerator generator) { + NoiseGenerator current = generator; + while (current instanceof OffsetNoiseGenerator offset) { + current = offset.getBase(); + } + return current; + } + + private static NoiseType baseOf(NoiseType type) { + for (NoiseType candidate : CELLULAR_BASES) { + if (type == candidate || type.name().startsWith(candidate.name() + "_")) { + return candidate; + } + } + return type; + } + + enum FoldKind { + NONE, + BILLOW, + RIGID + } + + record Fold(FoldKind kind, NoiseType base) { + } + + private static final class Classes { + private static final Map, NoiseType> TYPES = scan(); + + private static Map, NoiseType> scan() { + Map, NoiseType> types = new HashMap<>(); + for (NoiseType type : NoiseType.values()) { + NoiseGenerator base = unwrap(type.create(0L)); + if (!(base instanceof InterpolatedNoise)) { + types.putIfAbsent(base.getClass(), type); + } + } + return Map.copyOf(types); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/NoiseRegistry.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/NoiseRegistry.java new file mode 100644 index 000000000..06acfb881 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/NoiseRegistry.java @@ -0,0 +1,44 @@ +package art.arcane.iris.pack.datapack.export.compile.noise; + +import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; +import art.arcane.iris.pack.datapack.export.ir.NoiseDef; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.SortedMap; +import java.util.TreeMap; + +public final class NoiseRegistry { + private final String namespace; + private final SortedMap definitions = new TreeMap<>(); + + public NoiseRegistry(String namespace) { + this.namespace = VanillaResourceIdentifier.requireNamespace(namespace, "noise namespace"); + } + + public String namespace() { + return namespace; + } + + public String register(NoiseDef definition) { + Objects.requireNonNull(definition); + NoiseDef existing = definitions.putIfAbsent(definition.id(), definition); + if (existing != null && !existing.equals(definition)) { + throw new IllegalArgumentException("Noise '" + namespace + ":" + definition.id() + "' is already registered with different parameters"); + } + return namespace + ":" + definition.id(); + } + + public List definitions() { + return List.copyOf(definitions.values()); + } + + public SortedMap byReference() { + SortedMap references = new TreeMap<>(); + for (NoiseDef definition : definitions.values()) { + references.put(namespace + ":" + definition.id(), definition); + } + return Collections.unmodifiableSortedMap(references); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/NoiseStyleCompiler.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/NoiseStyleCompiler.java new file mode 100644 index 000000000..28712d7a8 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/NoiseStyleCompiler.java @@ -0,0 +1,550 @@ +package art.arcane.iris.pack.datapack.export.compile.noise; + +import art.arcane.iris.generation.noise.IrisGenerator; +import art.arcane.iris.generation.noise.IrisGeneratorStyle; +import art.arcane.iris.generation.noise.IrisNoiseGenerator; +import art.arcane.iris.generation.noise.IrisStyledRange; +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ir.CostClass; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.NoiseDef; +import art.arcane.iris.pack.datapack.export.lower.ExportCapabilities; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.noise.CNG; +import art.arcane.volmlib.util.noise.NoiseType; + +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; + +public final class NoiseStyleCompiler implements StyleCompiler { + private static final long BUILD_SEED = 0x1215_57E1L; + private static final long FIT_SALT = 0x0F17_0F17L; + private static final String FIT_NAMESPACE = "iris_fit"; + private static final String NOISE_PREFIX = "style/"; + private static final String SHARED_PREFIX = "style/shared/"; + private static final double SINE_KNOT_SPACING = 1.0 / 16.0; + private static final double SINE_DOMAIN_LIMIT = 16.0; + + private final CellularStyleLowering cellular; + private final StyleCompiler selectors; + private final Map leaves = new ConcurrentHashMap<>(); + private final Map levels = new ConcurrentHashMap<>(); + + public NoiseStyleCompiler(CellularStyleLowering cellular) { + this.cellular = Objects.requireNonNull(cellular, "cellular"); + this.selectors = this; + } + + private NoiseStyleCompiler(Function cellular, StyleCompiler selectors) { + this.cellular = Objects.requireNonNull(cellular.apply(this), "cellular"); + this.selectors = selectors == null ? this : selectors; + } + + public static NoiseStyleCompiler create(List diagnostics) { + Objects.requireNonNull(diagnostics, "diagnostics"); + NoiseStyleCompiler selectors = new NoiseStyleCompiler(nested -> new DefaultCellularStyleLowering(diagnostics, nested::compile, CellularMode.QUADRANT_2X2), null); + return new NoiseStyleCompiler(nested -> new DefaultCellularStyleLowering(diagnostics, nested::compile, CellularMode.GRID_3X3), selectors); + } + + public static CNG generator(IrisGeneratorStyle style) { + return build(prepared(Objects.requireNonNull(style, "style"), 1.0), 1); + } + + @Override + public Field compile(IrisGeneratorStyle style, long seedSalt, NoiseRegistry registry, ExportCapabilities capabilities) { + Emit emit = new Emit(registry, capabilities, seedSalt); + return style(Objects.requireNonNull(style, "style"), 1, Domain.IDENTITY, emit, "s"); + } + + @Override + public StyleCompiler selectors() { + return selectors; + } + + public Field compileLayer(IrisNoiseGenerator layer, long seedSalt, NoiseRegistry registry, ExportCapabilities capabilities) { + Emit emit = new Emit(registry, capabilities, seedSalt); + return layer(Objects.requireNonNull(layer, "layer"), Domain.IDENTITY, emit, "l" + layer.getSeed()); + } + + public Field compileComposite(IrisGenerator generator, long seedSalt, NoiseRegistry registry, ExportCapabilities capabilities) { + Objects.requireNonNull(generator, "generator"); + Emit emit = new Emit(registry, capabilities, seedSalt); + KList composite = generator.getComposite(); + if (composite.isEmpty()) { + return new Field.Const(0.0); + } + Domain domain = new Domain(positive(generator.getZoom(), "generator zoom"), generator.getOffsetX(), generator.getOffsetZ(), null, null); + Field combined = null; + double totalOpacity = 0.0; + for (int index = 0; index < composite.size(); index++) { + IrisNoiseGenerator layer = composite.get(index); + Field value = layer(layer, domain, emit, "g" + index + "_" + layer.getSeed()); + if (generator.isMultiplicitive()) { + combined = combined == null ? value : binary(Field.BinaryOp.MUL, combined, value); + } else { + totalOpacity += layer.getOpacity(); + combined = combined == null ? value : binary(Field.BinaryOp.ADD, combined, value); + } + } + if (generator.isMultiplicitive()) { + return scaled(combined, generator.getOpacity()); + } + if (totalOpacity == 0.0) { + throw new IllegalArgumentException("Generator '" + generator.getLoadKey() + "' composite opacity sums to zero"); + } + return scaled(combined, generator.getOpacity() / totalOpacity); + } + + public Field compileRange(IrisStyledRange range, long seedSalt, NoiseRegistry registry, ExportCapabilities capabilities) { + Objects.requireNonNull(range, "range"); + if (range.getMin() == range.getMax()) { + return new Field.Const(range.getMin()); + } + if (range.getStyle().isFlat()) { + return new Field.Const(range.getMin() + 0.5 * (range.getMax() - range.getMin())); + } + Field unit = compile(range.getStyle(), seedSalt, registry, capabilities); + return binary(Field.BinaryOp.ADD, new Field.Const(range.getMin()), scaled(unit, range.getMax() - range.getMin())); + } + + public Optional compileRootWarp(IrisGeneratorStyle style, long seedSalt, NoiseRegistry registry, ExportCapabilities capabilities) { + Emit emit = new Emit(registry, capabilities, seedSalt); + CNG root = generator(style); + if (!warped(root)) { + return Optional.empty(); + } + String path = "w" + styleName(style); + Field warpX = displacement(node(root.getFracture(), Displacement.NONE, emit, path + "/x"), root.getFscale()); + Field warpZ = displacement(node(root.getFracture(), Displacement.NONE, emit, path + "/z"), root.getFscale()); + return Optional.of(new Warp(warpX, warpZ)); + } + + private Field layer(IrisNoiseGenerator layer, Domain domain, Emit emit, String path) { + if (!layer.isEnabled()) { + return new Field.Const(layer.getOffsetY()); + } + Field warpX = null; + Field warpZ = null; + List fractures = layer.getFracture(); + for (int index = 0; index < fractures.size(); index++) { + IrisNoiseGenerator child = fractures.get(index); + if (!child.isEnabled()) { + continue; + } + Field center = new Field.Const(-child.getOpacity() / 2.0); + String childPath = path + "/f" + index + "_" + child.getSeed(); + warpX = sum(warpX, binary(Field.BinaryOp.ADD, layer(child, domain, emit, childPath + "x"), center)); + warpZ = sum(warpZ, binary(Field.BinaryOp.ADD, layer(child, domain.swapped(), emit, childPath + "z"), center)); + } + Domain sampled = domain.layer(positive(layer.getZoom(), "layer zoom"), layer.getOffsetX(), layer.getOffsetZ(), warpX, warpZ); + Field value = style(layer.getStyle(), layer.getOctaves(), sampled, emit, path); + return layerPost(layer, value, emit, path); + } + + private Field layerPost(IrisNoiseGenerator layer, Field input, Emit emit, String path) { + double opacity = layer.getOpacity(); + Field value = scaled(input, opacity); + if (layer.isNegative()) { + value = binary(Field.BinaryOp.SUB, new Field.Const(opacity), value); + } + if (layer.getExponent() != 1.0) { + value = signedPower(value, layer.getExponent(), emit, path + "/e"); + } + if (layer.getOffsetY() != 0.0) { + value = binary(Field.BinaryOp.ADD, value, new Field.Const(layer.getOffsetY())); + } + if (layer.isParametric()) { + Field shared = emit.shared(path + "/p", value); + Field square = new Field.Unary(Field.UnaryOp.SQUARE, shared); + Field complement = new Field.Unary(Field.UnaryOp.SQUARE, binary(Field.BinaryOp.SUB, new Field.Const(1.0), shared)); + value = binary(Field.BinaryOp.DIV, square, binary(Field.BinaryOp.ADD, square, complement)); + } + if (layer.isBezier()) { + Field shared = emit.shared(path + "/b", value); + Field weight = binary(Field.BinaryOp.SUB, new Field.Const(3.0), scaled(shared, 2.0)); + value = binary(Field.BinaryOp.MUL, new Field.Unary(Field.UnaryOp.SQUARE, shared), weight); + } + if (layer.isSinCentered()) { + value = sineCentered(value); + } + return value; + } + + private Field style(IrisGeneratorStyle style, int octaveMultiplier, Domain domain, Emit emit, String path) { + IrisGeneratorStyle prepared = prepared(Objects.requireNonNull(style, "noise style"), domain.zoom()); + String stylePath = path + "/" + styleName(style); + CNG root = build(prepared, octaveMultiplier); + if (cellular(prepared, root)) { + if (domain.warpX() != null || domain.warpZ() != null) { + throw new IllegalArgumentException("Cellular noise style " + styleName(style) + " cannot be exported under a layer fracture warp"); + } + return cellular.lower(prepared, emit.salt(stylePath), emit.registry(), emit.capabilities()); + } + Displacement displacement = new Displacement(domain.offsetX(), domain.offsetZ(), domain.warpX(), domain.warpZ()); + return node(root, displacement, emit, stylePath); + } + + private Field node(CNG cng, Displacement displacement, Emit emit, String path) { + WarpFit.LevelFit fit = fitted(cng) ? levelFit(cng, emit.capabilities().target()) : null; + return fit == null ? node(cng, displacement, emit, path, 1.0, null) : node(cng, displacement, emit, path, fit.multiplier(), fit.distribution()); + } + + private Field node(CNG cng, Displacement displacement, Emit emit, String path, double multiplier, List distribution) { + requireSupported(cng); + NoiseType type = NoiseLeaves.typeOf(cng.getGenerator()); + Field value; + if (type == NoiseType.FLAT) { + value = new Field.Const(1.0); + } else { + Displacement leafDisplacement = displacement; + if (warped(cng)) { + Field warpX = displacement(node(cng.getFracture(), displacement, emit, path + "/x"), cng.getFscale()); + Field warpZ = displacement(node(cng.getFracture(), displacement.swapped(), emit, path + "/z"), cng.getFscale()); + leafDisplacement = displacement.warped(warpX, warpZ); + } + value = leaf(type, cng.getOct(), cng.getEffectiveScale() * multiplier, leafDisplacement, emit, path); + if (distribution != null) { + value = new Field.Curve(value, distribution); + } + } + return post(cng, value, emit, path); + } + + private Field leaf(NoiseType type, int octaves, double scale, Displacement displacement, Emit emit, String path) { + NoiseLeaves.Fold fold = NoiseLeaves.fold(type); + int effectiveOctaves = NoiseLeaves.effectiveOctaves(type, octaves); + DataVersion target = emit.capabilities().target(); + if (fold.kind() == NoiseLeaves.FoldKind.NONE) { + LeafNoiseFit.FittedLeaf fitted = fitted(type, effectiveOctaves, target); + return fitted.curve(noise(fitted, fitted.xzScale() * scale, displacement, emit, path)); + } + LeafNoiseFit.FittedLeaf base = fitted(fold.base(), 1, target); + List signed = signed(base.distribution()); + Displacement shared = effectiveOctaves > 1 ? displacement.shared(emit, path) : displacement; + double bounding = bounding(effectiveOctaves); + Field sum = fold.kind() == NoiseLeaves.FoldKind.RIGID ? new Field.Const(bounding) : null; + double amplitude = 1.0; + for (int octave = 0; octave < effectiveOctaves; octave++) { + double octaveScale = base.xzScale() * scale * Math.scalb(1.0, octave); + Field noise = noise(base, octaveScale, shared, emit, path + "/o" + octave); + Field folded = new Field.Unary(Field.UnaryOp.ABS, new Field.Curve(noise, signed)); + double weight = fold.kind() == NoiseLeaves.FoldKind.RIGID && octave == 0 ? -bounding : bounding * amplitude; + sum = sum(sum, scaled(folded, weight)); + amplitude *= 0.5; + } + return sum; + } + + private Field noise(LeafNoiseFit.FittedLeaf leaf, double xzScale, Displacement displacement, Emit emit, String path) { + String id = emit.noise(leaf, path); + return new Field.Noise(id, xzScale, 0.0, shift(displacement.offsetX(), displacement.warpX(), xzScale), null, + shift(displacement.offsetZ(), displacement.warpZ(), xzScale)); + } + + private Field post(CNG cng, Field input, Emit emit, String path) { + Field value = scaled(input, cng.getOpacity()); + if (cng.getPower() != 1.0) { + value = signedPower(value, cng.getPower(), emit, path + "/power"); + } + double offset = cng.getUp() - cng.getDown(); + if (offset != 0.0) { + value = binary(Field.BinaryOp.ADD, value, new Field.Const(offset)); + } + return scaled(value, cng.getPatch()); + } + + private WarpFit.LevelFit levelFit(CNG level, DataVersion target) { + LevelSpec spec = LevelSpec.of(level).canonical(); + LevelKey key = new LevelKey(spec, target); + WarpFit.LevelFit cached = levels.get(key); + if (cached != null) { + return cached; + } + NoiseRegistry scratch = new NoiseRegistry(FIT_NAMESPACE); + Emit fitEmit = new Emit(scratch, ExportCapabilities.forTarget(target), FIT_SALT); + CNG iris = spec.build(LeafNoiseFit.FIT_SEED); + WarpFit.LevelFit fit = WarpFit.fit((x, z) -> iris.noise(x, z), + candidate -> node(iris, Displacement.NONE, fitEmit, "fit", candidate, null), scratch, target); + WarpFit.LevelFit existing = levels.putIfAbsent(key, fit); + return existing == null ? fit : existing; + } + + private LeafNoiseFit.FittedLeaf fitted(NoiseType type, int octaves, DataVersion target) { + LeafKey key = new LeafKey(type, octaves, target); + LeafNoiseFit.FittedLeaf cached = leaves.get(key); + if (cached != null) { + return cached; + } + LeafNoiseFit.FittedLeaf fitted = LeafNoiseFit.fit(type, octaves, target); + LeafNoiseFit.FittedLeaf existing = leaves.putIfAbsent(key, fitted); + return existing == null ? fitted : existing; + } + + private static Field signedPower(Field value, double power, Emit emit, String path) { + Field exponent = new Field.Const(power); + if (value.range().min() >= 0.0) { + return binary(Field.BinaryOp.POW, value, exponent); + } + Field shared = emit.shared(path, value); + Field magnitude = binary(Field.BinaryOp.POW, new Field.Unary(Field.UnaryOp.ABS, shared), exponent); + return binary(Field.BinaryOp.MUL, new Field.Unary(Field.UnaryOp.SIGN, shared), magnitude); + } + + private static Field sineCentered(Field value) { + double lower = Math.max(-SINE_DOMAIN_LIMIT, Math.floor(clampFinite(value.range().min(), -1.0) / SINE_KNOT_SPACING) * SINE_KNOT_SPACING); + double upper = Math.min(SINE_DOMAIN_LIMIT, Math.ceil(clampFinite(value.range().max(), 2.0) / SINE_KNOT_SPACING) * SINE_KNOT_SPACING); + int count = (int) Math.round((upper - lower) / SINE_KNOT_SPACING) + 1; + List knots = new ArrayList<>(count); + for (int index = 0; index < count; index++) { + double location = lower + index * SINE_KNOT_SPACING; + knots.add(new Field.Knot(location, new Field.Const(Math.sin(location * Math.PI)), Math.PI * Math.cos(location * Math.PI))); + } + return new Field.Curve(value, knots); + } + + private static double clampFinite(double value, double fallback) { + return Double.isFinite(value) ? value : fallback; + } + + private static List signed(List knots) { + List signed = new ArrayList<>(knots.size()); + for (Field.Knot knot : knots) { + double value = ((Field.Const) knot.value()).value(); + signed.add(new Field.Knot(knot.location(), new Field.Const(2.0 * value - 1.0), 2.0 * knot.derivative())); + } + return List.copyOf(signed); + } + + private static double bounding(int octaves) { + double amplitude = 0.5; + double total = 1.0; + for (int octave = 1; octave < octaves; octave++) { + total += amplitude; + amplitude *= 0.5; + } + return 1.0 / total; + } + + private static Field displacement(Field fracture, double scale) { + return binary(Field.BinaryOp.ADD, scaled(fracture, scale), new Field.Const(-0.5 * scale)); + } + + private static Field shift(double offset, Field warp, double xzScale) { + if (warp == null) { + return offset == 0.0 ? null : new Field.Const(offset * xzScale); + } + Field scaledWarp = scaled(warp, xzScale); + return offset == 0.0 ? scaledWarp : binary(Field.BinaryOp.ADD, scaledWarp, new Field.Const(offset * xzScale)); + } + + private static Field scaled(Field field, double factor) { + if (factor == 1.0) { + return field; + } + return binary(Field.BinaryOp.MUL, new Field.Const(factor), field); + } + + private static Field sum(Field total, Field addition) { + return total == null ? addition : binary(Field.BinaryOp.ADD, total, addition); + } + + private static Field binary(Field.BinaryOp op, Field left, Field right) { + return new Field.Binary(op, left, right); + } + + private static boolean fitted(CNG cng) { + NoiseType type = NoiseLeaves.typeOf(cng.getGenerator()); + return type != NoiseType.FLAT && (warped(cng) || NoiseLeaves.fold(type).kind() != NoiseLeaves.FoldKind.NONE); + } + + private static boolean warped(CNG cng) { + return cng.getFracture() != null && !cng.isNoscale(); + } + + private static boolean cellular(IrisGeneratorStyle style, CNG root) { + for (IrisGeneratorStyle current = style; current != null; current = current.getFracture()) { + if (current.getCellularFrequency() > 0.0) { + return true; + } + } + for (CNG current = root; current != null; current = current.getFracture()) { + if (NoiseLeaves.isCellular(NoiseLeaves.typeOf(current.getGenerator()))) { + return true; + } + } + return false; + } + + private static void requireSupported(CNG cng) { + if (cng.getChildren() != null && !cng.getChildren().isEmpty()) { + throw new IllegalArgumentException("Noise graphs with injected children are not exportable"); + } + if (cng.getCustomGenerator() != null) { + throw new IllegalArgumentException("Noise graphs with custom generators are not exportable"); + } + } + + private static CNG build(IrisGeneratorStyle style, int octaveMultiplier) { + return style.createForLayer(new RNG(BUILD_SEED), null, octaveMultiplier); + } + + private static IrisGeneratorStyle prepared(IrisGeneratorStyle style, double zoom) { + if (style.getExpression() != null) { + throw new IllegalArgumentException("Noise style expression '" + style.getExpression() + "' is not exportable"); + } + IrisGeneratorStyle copy = new IrisGeneratorStyle(style.getStyle()); + copy.setCellularFrequency(style.getCellularFrequency()); + copy.setCellularZoom(style.getCellularZoom()); + copy.setZoom(style.getZoom() * zoom); + copy.setMultiplier(style.getMultiplier()); + copy.setExponent(style.getExponent()); + copy.setCacheSize(0); + copy.setFracture(style.getFracture() == null ? null : prepared(style.getFracture(), 1.0)); + return copy; + } + + private static String styleName(IrisGeneratorStyle style) { + return style.getStyle() == null ? "flat" : style.getStyle().name().toLowerCase(Locale.ROOT); + } + + private static double positive(double value, String role) { + if (!Double.isFinite(value) || value <= 0.0) { + throw new IllegalArgumentException(role + " must be finite and positive, got " + value); + } + return value; + } + + public record Warp(Field x, Field z) { + public Warp { + Objects.requireNonNull(x, "x"); + Objects.requireNonNull(z, "z"); + } + } + + private record LeafKey(NoiseType type, int octaves, DataVersion target) { + } + + private record LevelKey(LevelSpec spec, DataVersion target) { + } + + private record LevelSpec(NoiseType type, int octaves, double scale, double opacity, double power, double offset, double patch, + double fractureScale, LevelSpec fracture) { + private static LevelSpec of(CNG cng) { + NoiseType type = NoiseLeaves.typeOf(cng.getGenerator()); + boolean warped = warped(cng); + return new LevelSpec(type, NoiseLeaves.effectiveOctaves(type, cng.getOct()), cng.getEffectiveScale(), cng.getOpacity(), + cng.getPower(), cng.getUp() - cng.getDown(), cng.getPatch(), warped ? cng.getFscale() : 0.0, + warped ? of(cng.getFracture()) : null); + } + + private LevelSpec canonical() { + double factor = type == NoiseType.FLAT || scale == 0.0 ? 1.0 : type.getCoordinateScale() / scale; + return new LevelSpec(type, octaves, rounded(scale * factor), 1.0, 1.0, 0.0, 1.0, rounded(fractureScale / factor), + fracture == null ? null : fracture.rescaled(factor)); + } + + private LevelSpec rescaled(double factor) { + return new LevelSpec(type, octaves, rounded(scale * factor), opacity, power, offset, patch, rounded(fractureScale / factor), + fracture == null ? null : fracture.rescaled(factor)); + } + + private CNG build(long seed) { + CNG cng = new CNG(new RNG(seed), type, opacity, octaves); + cng.scale(scale).bake(); + cng.pow(power); + cng.up(offset); + cng.patch(patch); + if (fracture != null) { + cng.fractureWith(fracture.build(seed + 1L), fractureScale); + } + return cng; + } + + private static double rounded(double value) { + return (float) value; + } + } + + private record Domain(double zoom, double offsetX, double offsetZ, Field warpX, Field warpZ) { + private static final Domain IDENTITY = new Domain(1.0, 0.0, 0.0, null, null); + + private Domain layer(double layerZoom, double layerOffsetX, double layerOffsetZ, Field layerWarpX, Field layerWarpZ) { + double combined = zoom * layerZoom; + return new Domain(combined, offsetX + combined * layerOffsetX, offsetZ + combined * layerOffsetZ, + sum(warpX, layerWarpX == null ? null : scaled(layerWarpX, zoom)), + sum(warpZ, layerWarpZ == null ? null : scaled(layerWarpZ, zoom))); + } + + private Domain swapped() { + return new Domain(zoom, offsetZ, offsetX, warpZ, warpX); + } + } + + private record Displacement(double offsetX, double offsetZ, Field warpX, Field warpZ) { + private static final Displacement NONE = new Displacement(0.0, 0.0, null, null); + + private Displacement swapped() { + return new Displacement(offsetZ, offsetX, warpZ, warpX); + } + + private Displacement warped(Field addedX, Field addedZ) { + return new Displacement(offsetX, offsetZ, sum(warpX, addedX), sum(warpZ, addedZ)); + } + + private Displacement shared(Emit emit, String path) { + return new Displacement(offsetX, offsetZ, emit.shared(path + "/dx", warpX), emit.shared(path + "/dz", warpZ)); + } + } + + private record Emit(NoiseRegistry registry, ExportCapabilities capabilities, long seedSalt) { + private Emit { + Objects.requireNonNull(registry, "registry"); + Objects.requireNonNull(capabilities, "capabilities"); + } + + private String noise(LeafNoiseFit.FittedLeaf leaf, String path) { + NoiseDef template = leaf.noise(); + String id = NOISE_PREFIX + template.id() + "/" + HexFormat.of().toHexDigits(salt(path)); + return registry.register(new NoiseDef(id, template.firstOctave(), template.amplitudes())); + } + + private Field shared(String path, Field expr) { + if (expr == null || expr.cost() == CostClass.CHEAP) { + return expr; + } + return new Field.Shared(SHARED_PREFIX + HexFormat.of().toHexDigits(mix(salt(path) ^ digest(expr.toString()))), expr); + } + + private static long digest(String text) { + long hash = 0xCBF29CE484222325L; + for (int index = 0; index < text.length(); index++) { + hash = (hash ^ text.charAt(index)) * 0x100000001B3L; + } + return hash; + } + + private long salt(String path) { + long hash = mix(seedSalt); + for (int index = 0; index < path.length(); index++) { + hash = mix(hash ^ path.charAt(index)); + } + return hash; + } + + private static long mix(long value) { + long mixed = value + 0x9E3779B97F4A7C15L; + mixed = (mixed ^ mixed >>> 30) * 0xBF58476D1CE4E5B9L; + mixed = (mixed ^ mixed >>> 27) * 0x94D049BB133111EBL; + return mixed ^ mixed >>> 31; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/RankCorrelation.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/RankCorrelation.java new file mode 100644 index 000000000..52d22eaf2 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/RankCorrelation.java @@ -0,0 +1,194 @@ +package art.arcane.iris.pack.datapack.export.compile.noise; + +import java.util.Arrays; +import java.util.SplittableRandom; +import java.util.TreeSet; +import java.util.function.DoubleBinaryOperator; +import java.util.function.DoubleUnaryOperator; + +final class RankCorrelation { + private static final double HALF_CORRELATION = 0.5; + private static final double MINIMUM_CORRELATION = 0.03; + private static final double MINIMUM_STRUCTURE = 1.0e-4; + private static final double STRUCTURE_FLOOR = 1.0e-6; + private static final double RANK_VARIOGRAM_SCALE = 6.0; + + private RankCorrelation() { + } + + static double[] profile(DoubleBinaryOperator field, double[] lags, int basePoints, double span, boolean integral, long seed) { + int stride = 1 + 2 * lags.length; + double[] values = new double[basePoints * stride]; + SplittableRandom random = new SplittableRandom(seed); + for (int point = 0; point < basePoints; point++) { + double x = coordinate(random, span, integral); + double z = coordinate(random, span, integral); + int offset = point * stride; + values[offset] = field.applyAsDouble(x, z); + for (int lag = 0; lag < lags.length; lag++) { + values[offset + 1 + 2 * lag] = field.applyAsDouble(x + lags[lag], z); + values[offset + 2 + 2 * lag] = field.applyAsDouble(x, z + lags[lag]); + } + } + double[] ranks = ranks(values); + double[] correlations = new double[lags.length]; + for (int lag = 0; lag < lags.length; lag++) { + double sum = 0.0; + for (int point = 0; point < basePoints; point++) { + int offset = point * stride; + double base = ranks[offset]; + double alongX = base - ranks[offset + 1 + 2 * lag]; + double alongZ = base - ranks[offset + 2 + 2 * lag]; + sum += alongX * alongX + alongZ * alongZ; + } + correlations[lag] = 1.0 - RANK_VARIOGRAM_SCALE * sum / (2.0 * basePoints); + } + return correlations; + } + + static double[] geometricLags(double minimum, double maximum, int count, boolean integral) { + double ratio = Math.pow(maximum / minimum, 1.0 / (count - 1)); + TreeSet lags = new TreeSet<>(); + double lag = minimum; + for (int index = 0; index < count; index++) { + lags.add(integral ? Math.max(1.0, Math.rint(lag)) : lag); + lag *= ratio; + } + double[] result = new double[lags.size()]; + int index = 0; + for (double value : lags) { + result[index++] = value; + } + return result; + } + + static double halfCorrelationLag(double[] lags, double[] correlations) { + for (int index = 0; index < lags.length; index++) { + if (correlations[index] < HALF_CORRELATION) { + if (index == 0) { + return lags[0]; + } + double before = correlations[index - 1]; + double fraction = (before - HALF_CORRELATION) / (before - correlations[index]); + return Math.exp(Math.log(lags[index - 1]) + fraction * (Math.log(lags[index]) - Math.log(lags[index - 1]))); + } + } + return lags[lags.length - 1]; + } + + static double structure(double[] lags, double[] correlations, double lag) { + if (lag <= lags[0]) { + double first = structure(correlations[0]); + return Math.exp(Math.log(first) + 2.0 * (Math.log(lag) - Math.log(lags[0]))); + } + if (lag >= lags[lags.length - 1]) { + return structure(correlations[correlations.length - 1]); + } + int upper = Arrays.binarySearch(lags, lag); + if (upper >= 0) { + return structure(correlations[upper]); + } + upper = -upper - 1; + int lower = upper - 1; + double fraction = (Math.log(lag) - Math.log(lags[lower])) / (Math.log(lags[upper]) - Math.log(lags[lower])); + double lowerLog = Math.log(structure(correlations[lower])); + double upperLog = Math.log(structure(correlations[upper])); + return Math.exp(lowerLog + fraction * (upperLog - lowerLog)); + } + + static int[] window(double[] correlations) { + int count = 0; + int[] window = new int[correlations.length]; + for (int index = 0; index < correlations.length; index++) { + if (correlations[index] >= MINIMUM_CORRELATION && 1.0 - correlations[index] >= MINIMUM_STRUCTURE) { + window[count++] = index; + } + } + if (count == 0) { + for (int index = 0; index < correlations.length; index++) { + window[count++] = index; + } + } + return Arrays.copyOf(window, count); + } + + static double structureError(double expectedStructure, double actualStructure) { + double difference = Math.log(Math.max(expectedStructure, STRUCTURE_FLOOR)) - Math.log(Math.max(actualStructure, STRUCTURE_FLOOR)); + return difference * difference; + } + + static double structure(double correlation) { + return Math.max(STRUCTURE_FLOOR, 1.0 - correlation); + } + + static double goldenMinimum(DoubleUnaryOperator objective, double lower, double upper, int iterations) { + double ratio = (Math.sqrt(5.0) - 1.0) / 2.0; + double left = upper - ratio * (upper - lower); + double right = lower + ratio * (upper - lower); + double leftValue = objective.applyAsDouble(left); + double rightValue = objective.applyAsDouble(right); + for (int iteration = 0; iteration < iterations; iteration++) { + if (leftValue <= rightValue) { + upper = right; + right = left; + rightValue = leftValue; + left = upper - ratio * (upper - lower); + leftValue = objective.applyAsDouble(left); + } else { + lower = left; + left = right; + leftValue = rightValue; + right = lower + ratio * (upper - lower); + rightValue = objective.applyAsDouble(right); + } + } + return leftValue <= rightValue ? left : right; + } + + private static double coordinate(SplittableRandom random, double span, boolean integral) { + double value = (random.nextDouble() * 2.0 - 1.0) * span; + return integral ? Math.floor(value) : value; + } + + private static double[] ranks(double[] values) { + double[] sorted = values.clone(); + Arrays.sort(sorted); + double[] ranks = new double[values.length]; + double count = values.length; + for (int index = 0; index < values.length; index++) { + double value = values[index]; + int first = firstIndex(sorted, value); + int last = lastIndex(sorted, value); + ranks[index] = (first + last + 1) / (2.0 * count); + } + return ranks; + } + + private static int firstIndex(double[] sorted, double value) { + int low = 0; + int high = sorted.length; + while (low < high) { + int middle = (low + high) >>> 1; + if (sorted[middle] < value) { + low = middle + 1; + } else { + high = middle; + } + } + return low; + } + + private static int lastIndex(double[] sorted, double value) { + int low = 0; + int high = sorted.length; + while (low < high) { + int middle = (low + high) >>> 1; + if (sorted[middle] <= value) { + low = middle + 1; + } else { + high = middle; + } + } + return low - 1; + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/StyleCompiler.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/StyleCompiler.java new file mode 100644 index 000000000..ccf7d1c4e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/StyleCompiler.java @@ -0,0 +1,13 @@ +package art.arcane.iris.pack.datapack.export.compile.noise; + +import art.arcane.iris.generation.noise.IrisGeneratorStyle; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.lower.ExportCapabilities; + +public interface StyleCompiler { + Field compile(IrisGeneratorStyle style, long seedSalt, NoiseRegistry registry, ExportCapabilities capabilities); + + default StyleCompiler selectors() { + return this; + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/ValueDistributionFit.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/ValueDistributionFit.java new file mode 100644 index 000000000..c5274055d --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/ValueDistributionFit.java @@ -0,0 +1,86 @@ +package art.arcane.iris.pack.datapack.export.compile.noise; + +import art.arcane.iris.pack.datapack.export.ir.Field; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +public final class ValueDistributionFit { + private static final double[] LEVELS = { + 0.0, 0.0005, 0.002, 0.005, 0.01, 0.02, 0.035, 0.05, 0.075, 0.1, 0.15, 0.2, 0.25, 0.3, 0.35, 0.4, 0.45, + 0.5, 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, 0.9, 0.925, 0.95, 0.965, 0.98, 0.99, 0.995, 0.998, 0.9995, 1.0 + }; + private static final int MINIMUM_SAMPLES = 64; + + private ValueDistributionFit() { + } + + public static List fit(double[] source, double[] target) { + double[] sortedSource = sorted(Objects.requireNonNull(source, "source"), "source"); + double[] sortedTarget = sorted(Objects.requireNonNull(target, "target"), "target"); + List locations = new ArrayList<>(LEVELS.length); + List values = new ArrayList<>(LEVELS.length); + for (double level : LEVELS) { + double location = (float) quantile(sortedSource, level); + double value = (float) quantile(sortedTarget, level); + if (!locations.isEmpty() && location <= locations.getLast()) { + continue; + } + locations.add(location); + values.add(value); + } + if (locations.size() < 2) { + throw new IllegalArgumentException("Value distribution fit needs a source with spread, got a constant source of " + sortedSource[0]); + } + double[] derivatives = monotoneDerivatives(locations, values); + List knots = new ArrayList<>(locations.size()); + for (int index = 0; index < locations.size(); index++) { + knots.add(new Field.Knot(locations.get(index), new Field.Const(values.get(index)), derivatives[index])); + } + return List.copyOf(knots); + } + + public static double quantile(double[] sorted, double level) { + if (level <= 0.0) { + return sorted[0]; + } + if (level >= 1.0) { + return sorted[sorted.length - 1]; + } + double position = level * (sorted.length - 1); + int lower = (int) Math.floor(position); + double fraction = position - lower; + return lower + 1 >= sorted.length ? sorted[lower] : sorted[lower] + fraction * (sorted[lower + 1] - sorted[lower]); + } + + private static double[] sorted(double[] samples, String role) { + if (samples.length < MINIMUM_SAMPLES) { + throw new IllegalArgumentException("Value distribution fit needs at least " + MINIMUM_SAMPLES + " " + role + " samples, got " + samples.length); + } + double[] copy = samples.clone(); + for (double sample : copy) { + if (!Double.isFinite(sample)) { + throw new IllegalArgumentException("Value distribution fit " + role + " samples must be finite, got " + sample); + } + } + Arrays.sort(copy); + return copy; + } + + private static double[] monotoneDerivatives(List locations, List values) { + int count = locations.size(); + double[] secants = new double[count - 1]; + for (int index = 0; index < secants.length; index++) { + secants[index] = (values.get(index + 1) - values.get(index)) / (locations.get(index + 1) - locations.get(index)); + } + double[] derivatives = new double[count]; + for (int index = 1; index < count - 1; index++) { + double before = secants[index - 1]; + double after = secants[index]; + derivatives[index] = before <= 0.0 || after <= 0.0 ? 0.0 : (float) (2.0 / (1.0 / before + 1.0 / after)); + } + return derivatives; + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/VanillaNoiseModel.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/VanillaNoiseModel.java new file mode 100644 index 000000000..b4df5c22f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/VanillaNoiseModel.java @@ -0,0 +1,376 @@ +package art.arcane.iris.pack.datapack.export.compile.noise; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.WorldgenExportTargets; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public final class VanillaNoiseModel { + private static final double SECOND_SAMPLER_INPUT_FACTOR = 1.0181268882175227D; + private static final double TARGET_DEVIATION = 0.3333333333333333D; + private static final double OCTAVE_DEVIATION = 0.2702247831245211D; + private static final double MIN_BASE_AMPLITUDE = 9.999999747378752E-6D; + private static final double MAX_AMPLITUDE = 1000000.0D; + private static final int MAX_OCTAVE = 32; + private static final int MAX_OCTAVE_COUNT = 32; + private static final String DEFAULT_NAMESPACE = "minecraft"; + private static final String OCTAVE_SEED_PREFIX = "octave_"; + + private final SinglePrecisionField field; + + private VanillaNoiseModel(SinglePrecisionField field) { + this.field = field; + } + + public static VanillaNoiseModel create(String noiseId, int firstOctave, List amplitudes, long worldSeed, + DataVersion target) { + Objects.requireNonNull(target, "target"); + if (!WorldgenExportTargets.SUPPORTED.contains(target)) { + throw WorldgenExportTargets.unsupported(target.getVersion()); + } + return create(noiseId, OctaveProfile.fromAmplitudes(firstOctave, amplitudes), worldSeed); + } + + public static VanillaNoiseModel create(String noiseId, OctaveProfile profile, long worldSeed) { + Objects.requireNonNull(profile, "profile"); + return new VanillaNoiseModel(SinglePrecisionField.create(noiseSource(noiseId, worldSeed), profile)); + } + + public double sample(double x, double y, double z) { + return field.sample(x, y, z); + } + + private static XoroshiroSource noiseSource(String noiseId, long worldSeed) { + String identifier = canonicalIdentifier(Objects.requireNonNull(noiseId, "noiseId")); + return XoroshiroSource.fromWorldSeed(worldSeed).forkPositional().fromHashOf(identifier); + } + + private static String canonicalIdentifier(String noiseId) { + return noiseId.indexOf(':') >= 0 ? noiseId : DEFAULT_NAMESPACE + ":" + noiseId; + } + + private static double spanDeviation(double[] amplitudes) { + int lowest = Integer.MAX_VALUE; + int highest = Integer.MIN_VALUE; + for (int index = 0; index < amplitudes.length; index++) { + if (amplitudes[index] != 0.0D) { + lowest = Math.min(lowest, index); + highest = Math.max(highest, index); + } + } + return 0.1D * (1.0D + 1.0D / (highest - lowest + 1)); + } + + public enum Normalization { + ENABLED, + DISABLED, + LEGACY + } + + public record OctaveProfile(int baseOctave, int octaveCount, List amplitudeModifiers, double baseAmplitude, + Normalization normalization) { + public OctaveProfile { + Objects.requireNonNull(normalization, "normalization"); + amplitudeModifiers = List.copyOf(Objects.requireNonNull(amplitudeModifiers, "amplitudeModifiers")); + if (baseOctave < -MAX_OCTAVE || baseOctave > MAX_OCTAVE) { + throw new IllegalArgumentException("Noise base octave " + baseOctave + " is outside [-" + MAX_OCTAVE + ", " + + MAX_OCTAVE + "]"); + } + if (octaveCount < 1 || octaveCount > MAX_OCTAVE_COUNT) { + throw new IllegalArgumentException("Noise octave count " + octaveCount + " is outside [1, " + MAX_OCTAVE_COUNT + + "]"); + } + if (!amplitudeModifiers.isEmpty() && amplitudeModifiers.size() != octaveCount) { + throw new IllegalArgumentException("Noise amplitude modifiers had size " + amplitudeModifiers.size() + + ", but octave count was " + octaveCount); + } + for (Double modifier : amplitudeModifiers) { + if (!(modifier >= 0.0D && modifier <= MAX_AMPLITUDE)) { + throw new IllegalArgumentException("Noise amplitude modifier " + modifier + " is outside [0, " + + MAX_AMPLITUDE + "]"); + } + } + if (!(baseAmplitude >= MIN_BASE_AMPLITUDE && baseAmplitude <= MAX_AMPLITUDE)) { + throw new IllegalArgumentException("Noise base amplitude " + baseAmplitude + " is outside [" + + MIN_BASE_AMPLITUDE + ", " + MAX_AMPLITUDE + "]"); + } + } + + public static OctaveProfile fromAmplitudes(int firstOctave, List amplitudes) { + Objects.requireNonNull(amplitudes, "amplitudes"); + return new OctaveProfile(firstOctave, amplitudes.size(), amplitudes, 1.0D, Normalization.DISABLED); + } + + private double modifier(int octave) { + return amplitudeModifiers.isEmpty() ? 1.0D : amplitudeModifiers.get(octave); + } + } + + private static final class SinglePrecisionField { + private final GradientLattice[] lattices; + private final double[] frequencies; + private final float[] amplitudes; + + private SinglePrecisionField(GradientLattice[] lattices, double[] frequencies, float[] amplitudes) { + this.lattices = lattices; + this.frequencies = frequencies; + this.amplitudes = amplitudes; + } + + private static SinglePrecisionField create(XoroshiroSource source, OctaveProfile profile) { + List octaves = activeOctaves(profile); + double normalization = normalizationFactor(profile, octaves); + HashedSeedFactory firstSeeds = source.forkPositional(); + HashedSeedFactory secondSeeds = source.forkPositional(); + int layers = octaves.size() * 2; + GradientLattice[] lattices = new GradientLattice[layers]; + double[] frequencies = new double[layers]; + float[] amplitudes = new float[layers]; + for (int index = 0; index < octaves.size(); index++) { + ActiveOctave octave = octaves.get(index); + String seed = OCTAVE_SEED_PREFIX + octave.octave(); + float layerAmplitude = (float) (normalization * octave.amplitude()); + lattices[index * 2] = new GradientLattice(firstSeeds.fromHashOf(seed)); + lattices[index * 2 + 1] = new GradientLattice(secondSeeds.fromHashOf(seed)); + frequencies[index * 2] = octave.frequency(); + frequencies[index * 2 + 1] = octave.frequency() * SECOND_SAMPLER_INPUT_FACTOR; + amplitudes[index * 2] = layerAmplitude; + amplitudes[index * 2 + 1] = layerAmplitude; + } + return new SinglePrecisionField(lattices, frequencies, amplitudes); + } + + private double sample(double x, double y, double z) { + float value = 0.0F; + for (int index = 0; index < lattices.length; index++) { + double frequency = frequencies[index]; + value += amplitudes[index] * lattices[index].sampleFloat(x * frequency, y * frequency, z * frequency); + } + return value; + } + + private static List activeOctaves(OctaveProfile profile) { + int count = profile.octaveCount(); + List octaves = new ArrayList<>(count); + double frequency = Math.pow(2.0D, profile.baseOctave()); + double amplitude = profile.baseAmplitude(); + if (profile.normalization() != Normalization.DISABLED) { + amplitude *= Math.pow(0.5D, -(count - 1)) / (Math.pow(0.5D, -count) - 1.0D); + } + for (int index = 0; index < count; index++) { + double modifier = profile.modifier(index); + if (modifier != 0.0D) { + octaves.add(new ActiveOctave(profile.baseOctave() + index, frequency, amplitude * modifier)); + } + frequency *= 2.0D; + amplitude *= 0.5D; + } + return octaves; + } + + private static double normalizationFactor(OctaveProfile profile, List octaves) { + double absoluteSum = octaves.stream().mapToDouble(octave -> Math.abs(octave.amplitude())).sum(); + double squaredDeviation = 0.0D; + for (ActiveOctave octave : octaves) { + double deviation = OCTAVE_DEVIATION * Math.abs(octave.amplitude()); + squaredDeviation += deviation * deviation; + } + double deviation = Math.sqrt(squaredDeviation); + if (deviation == 0.0D) { + return 0.0D; + } + double factor = absoluteSum * TARGET_DEVIATION / (deviation * Math.sqrt(2.0D)); + if (profile.normalization() != Normalization.LEGACY) { + return factor; + } + double[] modifiers = new double[profile.octaveCount()]; + for (int index = 0; index < modifiers.length; index++) { + modifiers[index] = profile.modifier(index); + } + return profile.baseAmplitude() * 0.5D * TARGET_DEVIATION / spanDeviation(modifiers); + } + + private record ActiveOctave(int octave, double frequency, double amplitude) { + } + } + + private static final class GradientLattice { + private static final double PERIOD = 33554432.0D; + private static final double HALF_PERIOD = Math.nextDown(16777216.0D); + private static final int[][] GRADIENTS = { + {1, 1, 0}, {-1, 1, 0}, {1, -1, 0}, {-1, -1, 0}, + {1, 0, 1}, {-1, 0, 1}, {1, 0, -1}, {-1, 0, -1}, + {0, 1, 1}, {0, -1, 1}, {0, 1, -1}, {0, -1, -1}, + {1, 1, 0}, {0, -1, 1}, {-1, 1, 0}, {0, -1, -1} + }; + + private final byte[] permutation; + private final double offsetX; + private final double offsetY; + private final double offsetZ; + + private GradientLattice(XoroshiroSource source) { + offsetX = source.nextDouble() * 256.0D; + offsetY = source.nextDouble() * 256.0D; + offsetZ = source.nextDouble() * 256.0D; + permutation = new byte[256]; + for (int index = 0; index < 256; index++) { + permutation[index] = (byte) index; + } + for (int index = 0; index < 256; index++) { + int offset = source.nextInt(256 - index); + byte swap = permutation[index]; + permutation[index] = permutation[index + offset]; + permutation[index + offset] = swap; + } + } + + private float sampleFloat(double x, double y, double z) { + double shiftedX = wrap(x) + offsetX; + double shiftedY = wrap(y) + offsetY; + double shiftedZ = wrap(z) + offsetZ; + int cellX = (int) Math.floor(shiftedX); + int cellY = (int) Math.floor(shiftedY); + int cellZ = (int) Math.floor(shiftedZ); + float localX = (float) (shiftedX - cellX); + float localY = (float) (shiftedY - cellY); + float localZ = (float) (shiftedZ - cellZ); + int low = hash(cellX); + int high = hash(cellX + 1); + int lowLow = hash(low + cellY); + int lowHigh = hash(low + cellY + 1); + int highLow = hash(high + cellY); + int highHigh = hash(high + cellY + 1); + float corner000 = dot(hash(lowLow + cellZ), localX, localY, localZ); + float corner100 = dot(hash(highLow + cellZ), localX - 1.0F, localY, localZ); + float corner010 = dot(hash(lowHigh + cellZ), localX, localY - 1.0F, localZ); + float corner110 = dot(hash(highHigh + cellZ), localX - 1.0F, localY - 1.0F, localZ); + float corner001 = dot(hash(lowLow + cellZ + 1), localX, localY, localZ - 1.0F); + float corner101 = dot(hash(highLow + cellZ + 1), localX - 1.0F, localY, localZ - 1.0F); + float corner011 = dot(hash(lowHigh + cellZ + 1), localX, localY - 1.0F, localZ - 1.0F); + float corner111 = dot(hash(highHigh + cellZ + 1), localX - 1.0F, localY - 1.0F, localZ - 1.0F); + float fadeX = fade(localX); + float fadeY = fade(localY); + float fadeZ = fade(localZ); + float bottom = lerp(fadeY, lerp(fadeX, corner000, corner100), lerp(fadeX, corner010, corner110)); + float top = lerp(fadeY, lerp(fadeX, corner001, corner101), lerp(fadeX, corner011, corner111)); + return lerp(fadeZ, bottom, top); + } + + private int hash(int value) { + return permutation[value & 255] & 255; + } + + private static double wrap(double coordinate) { + if (coordinate >= -HALF_PERIOD && coordinate < HALF_PERIOD) { + return coordinate; + } + return coordinate - Math.floor(coordinate / PERIOD + 0.5D) * PERIOD; + } + + private static float dot(int hash, float x, float y, float z) { + int[] gradient = GRADIENTS[hash & 15]; + return gradient[0] * x + gradient[1] * y + gradient[2] * z; + } + + private static float fade(float value) { + return value * value * value * (value * (value * 6.0F - 15.0F) + 10.0F); + } + + private static float lerp(float alpha, float start, float end) { + return start + alpha * (end - start); + } + } + + private record HashedSeedFactory(long seedLow, long seedHigh) { + private XoroshiroSource fromHashOf(String name) { + byte[] digest = md5(name); + long low = 0L; + long high = 0L; + for (int index = 0; index < 8; index++) { + low = low << 8 | digest[index] & 0xFFL; + high = high << 8 | digest[index + 8] & 0xFFL; + } + return new XoroshiroSource(low ^ seedLow, high ^ seedHigh); + } + + private static byte[] md5(String name) { + try { + return MessageDigest.getInstance("MD5").digest(name.getBytes(StandardCharsets.UTF_8)); + } catch (NoSuchAlgorithmException failure) { + throw new IllegalStateException("MD5 digest is unavailable for noise seed '" + name + "'", failure); + } + } + } + + private static final class XoroshiroSource { + private static final long SILVER_RATIO = 7640891576956012809L; + private static final long GOLDEN_RATIO = -7046029254386353131L; + private static final double DOUBLE_UNIT = 1.1102230246251565E-16D; + + private long low; + private long high; + + private XoroshiroSource(long low, long high) { + if ((low | high) == 0L) { + this.low = GOLDEN_RATIO; + this.high = SILVER_RATIO; + return; + } + this.low = low; + this.high = high; + } + + private static XoroshiroSource fromWorldSeed(long seed) { + long unmixedLow = seed ^ SILVER_RATIO; + long unmixedHigh = unmixedLow + GOLDEN_RATIO; + return new XoroshiroSource(mix(unmixedLow), mix(unmixedHigh)); + } + + private HashedSeedFactory forkPositional() { + long seedLow = nextLong(); + long seedHigh = nextLong(); + return new HashedSeedFactory(seedLow, seedHigh); + } + + private long nextLong() { + long currentLow = low; + long currentHigh = high; + long result = Long.rotateLeft(currentLow + currentHigh, 17) + currentLow; + currentHigh ^= currentLow; + low = Long.rotateLeft(currentLow, 49) ^ currentHigh ^ currentHigh << 21; + high = Long.rotateLeft(currentHigh, 28); + return result; + } + + private int nextInt(int bound) { + long random = Integer.toUnsignedLong((int) nextLong()); + long product = random * bound; + long fraction = product & 0xFFFFFFFFL; + if (fraction < bound) { + int threshold = Integer.remainderUnsigned(~bound + 1, bound); + while (fraction < threshold) { + random = Integer.toUnsignedLong((int) nextLong()); + product = random * bound; + fraction = product & 0xFFFFFFFFL; + } + } + return (int) (product >> 32); + } + + private double nextDouble() { + return (nextLong() >>> 11) * DOUBLE_UNIT; + } + + private static long mix(long value) { + long mixed = (value ^ value >>> 30) * -4658895280553007687L; + mixed = (mixed ^ mixed >>> 27) * -7723592293110705685L; + return mixed ^ mixed >>> 31; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/WarpFit.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/WarpFit.java new file mode 100644 index 000000000..04c32a20d --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/noise/WarpFit.java @@ -0,0 +1,99 @@ +package art.arcane.iris.pack.datapack.export.compile.noise; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.FieldEvaluator; +import art.arcane.iris.pack.datapack.export.ir.NoiseDef; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.SplittableRandom; +import java.util.function.DoubleBinaryOperator; +import java.util.function.DoubleFunction; + +public final class WarpFit { + private static final int BASE_POINTS = 2048; + private static final int INSTANCES = 16; + private static final long INSTANCE_STRIDE = 0x9E37_79B9L; + private static final double SPAN = 1 << 18; + private static final double[] SURVEY_LAGS = RankCorrelation.geometricLags(1.0, 65_536.0, 48, true); + private static final double FIT_LAG_MINIMUM = 0.25; + private static final double FIT_LAG_MAXIMUM = 4.0; + private static final int FIT_LAG_COUNT = 16; + private static final double SEARCH_RATIO = 2.0; + private static final int SEARCH_ITERATIONS = 18; + private static final int MARGINAL_SAMPLES = 65_536; + + private WarpFit() { + } + + public static LevelFit fit(DoubleBinaryOperator iris, DoubleFunction lowered, NoiseRegistry scratch, DataVersion target) { + Objects.requireNonNull(iris, "iris"); + Objects.requireNonNull(lowered, "lowered"); + Objects.requireNonNull(scratch, "scratch"); + Objects.requireNonNull(target, "target"); + double[] survey = RankCorrelation.profile(iris, SURVEY_LAGS, BASE_POINTS, SPAN, true, LeafNoiseFit.FIT_SEED + 11L); + double half = RankCorrelation.halfCorrelationLag(SURVEY_LAGS, survey); + double[] lags = RankCorrelation.geometricLags(Math.max(1.0, half * FIT_LAG_MINIMUM), Math.max(2.0, half * FIT_LAG_MAXIMUM), FIT_LAG_COUNT, true); + double[] irisProfile = RankCorrelation.profile(iris, lags, BASE_POINTS, SPAN, true, LeafNoiseFit.FIT_SEED + 13L); + int[] window = RankCorrelation.window(irisProfile); + double logMultiplier = RankCorrelation.goldenMinimum( + candidate -> error(lowered.apply(Math.exp(candidate)), scratch, target, lags, irisProfile, window), + -Math.log(SEARCH_RATIO), Math.log(SEARCH_RATIO), SEARCH_ITERATIONS); + double multiplier = Math.exp(logMultiplier); + Field fitted = lowered.apply(multiplier); + double[] loweredMarginal = new double[0]; + for (int instance = 0; instance < INSTANCES; instance++) { + double[] sampled = marginal(sampler(fitted, scratch, target, instance), MARGINAL_SAMPLES / INSTANCES, instance); + double[] joined = Arrays.copyOf(loweredMarginal, loweredMarginal.length + sampled.length); + System.arraycopy(sampled, 0, joined, loweredMarginal.length, sampled.length); + loweredMarginal = joined; + } + List distribution = ValueDistributionFit.fit(loweredMarginal, marginal(iris, MARGINAL_SAMPLES, INSTANCES)); + return new LevelFit(multiplier, distribution); + } + + private static double error(Field field, NoiseRegistry scratch, DataVersion target, double[] lags, double[] irisProfile, int[] window) { + double[] profile = new double[lags.length]; + for (int instance = 0; instance < INSTANCES; instance++) { + double[] sampled = RankCorrelation.profile(sampler(field, scratch, target, instance), lags, BASE_POINTS / INSTANCES, SPAN, true, + LeafNoiseFit.FIT_SEED + 13L + instance); + for (int index = 0; index < profile.length; index++) { + profile[index] += sampled[index] / INSTANCES; + } + } + double sum = 0.0; + for (int index : window) { + sum += RankCorrelation.structureError(RankCorrelation.structure(irisProfile[index]), RankCorrelation.structure(profile[index])); + } + return sum / window.length; + } + + private static DoubleBinaryOperator sampler(Field field, NoiseRegistry scratch, DataVersion target, int instance) { + Map noises = scratch.byReference(); + long worldSeed = LeafNoiseFit.FIT_SEED + instance * INSTANCE_STRIDE; + return (x, z) -> FieldEvaluator.sample(field, noises, worldSeed, target, (int) x, 0, (int) z); + } + + private static double[] marginal(DoubleBinaryOperator field, int count, int stream) { + SplittableRandom random = new SplittableRandom(LeafNoiseFit.FIT_SEED + 17L + stream); + double[] samples = new double[count]; + for (int index = 0; index < samples.length; index++) { + double x = Math.floor((random.nextDouble() * 2.0 - 1.0) * SPAN); + double z = Math.floor((random.nextDouble() * 2.0 - 1.0) * SPAN); + samples[index] = field.applyAsDouble(x, z); + } + return samples; + } + + public record LevelFit(double multiplier, List distribution) { + public LevelFit { + if (!(multiplier > 0.0) || !Double.isFinite(multiplier)) { + throw new IllegalArgumentException("Level frequency multiplier must be positive and finite, got " + multiplier); + } + distribution = List.copyOf(distribution); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/BakedVariant.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/BakedVariant.java new file mode 100644 index 000000000..45b34b58a --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/BakedVariant.java @@ -0,0 +1,17 @@ +package art.arcane.iris.pack.datapack.export.compile.object; + +import art.arcane.iris.pack.datapack.export.TemplateVolume; +import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; + +import java.util.Objects; + +public record BakedVariant(String id, TemplateVolume volume, int baseY, TreeRoots roots) { + public BakedVariant { + VanillaResourceIdentifier.requireIdentifier(id, "template id"); + Objects.requireNonNull(volume, "Template volume"); + Objects.requireNonNull(roots, "Template roots"); + if (baseY > 0) { + throw new IllegalArgumentException("Template base offset must not be positive, got " + baseY); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/BlockStateRotation.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/BlockStateRotation.java new file mode 100644 index 000000000..419af7b6f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/BlockStateRotation.java @@ -0,0 +1,221 @@ +package art.arcane.iris.pack.datapack.export.compile.object; + +import art.arcane.iris.pack.datapack.export.VanillaBlockState; + +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.TreeMap; + +final class BlockStateRotation { + private static final int SIGN_STEPS = 16; + private static final int SIGN_QUARTER = 4; + + private final Map directions; + private final int yQuarters; + private final boolean flipped; + + private BlockStateRotation(int xQuarters, int yQuarters, int zQuarters) { + this.yQuarters = Math.floorMod(yQuarters, 4); + directions = new HashMap<>(); + for (Direction direction : Direction.values()) { + int[] vector = {direction.dx, direction.dy, direction.dz}; + vector = turn(vector, Math.floorMod(xQuarters, 4), Axis.X); + vector = turn(vector, Math.floorMod(zQuarters, 4), Axis.Z); + vector = turn(vector, this.yQuarters, Axis.Y); + directions.put(direction, Direction.of(vector[0], vector[1], vector[2])); + } + flipped = directions.get(Direction.UP) == Direction.DOWN; + } + + static BlockStateRotation of(double xDegrees, double yDegrees, double zDegrees) { + return new BlockStateRotation(quarters(xDegrees), quarters(yDegrees), quarters(zDegrees)); + } + + boolean identity() { + for (Map.Entry entry : directions.entrySet()) { + if (entry.getKey() != entry.getValue()) { + return false; + } + } + return true; + } + + VanillaBlockState apply(VanillaBlockState state) { + if (identity() || state.properties().isEmpty()) { + return state; + } + Map source = state.properties(); + Map rotated = new TreeMap<>(); + for (Map.Entry property : source.entrySet()) { + Direction connection = Direction.named(property.getKey()); + if (connection != null) { + rotated.put(directions.get(connection).serialized, property.getValue()); + continue; + } + rotated.put(property.getKey(), value(property.getKey(), property.getValue())); + } + return new VanillaBlockState(state.name(), rotated); + } + + private String value(String key, String value) { + return switch (key) { + case "facing", "vertical_direction", "hopper_facing" -> mapDirection(value); + case "axis" -> mapAxis(value); + case "rotation" -> mapSignRotation(value); + case "orientation", "shape" -> mapCompound(value); + case "half", "type" -> flipped ? flipVertical(value) : value; + case "face", "attach_face" -> flipped ? flipFace(value) : value; + default -> value; + }; + } + + private String mapDirection(String value) { + Direction direction = Direction.named(value); + return direction == null ? value : directions.get(direction).serialized; + } + + private String mapAxis(String value) { + Direction direction = switch (value) { + case "x" -> Direction.EAST; + case "y" -> Direction.UP; + case "z" -> Direction.SOUTH; + default -> null; + }; + if (direction == null) { + return value; + } + return switch (directions.get(direction)) { + case EAST, WEST -> "x"; + case UP, DOWN -> "y"; + case NORTH, SOUTH -> "z"; + }; + } + + private String mapSignRotation(String value) { + try { + int rotation = Integer.parseInt(value); + return Integer.toString(Math.floorMod(rotation - yQuarters * SIGN_QUARTER, SIGN_STEPS)); + } catch (NumberFormatException exception) { + return value; + } + } + + private String mapCompound(String value) { + String[] parts = value.split("_"); + if (parts.length == 2 && parts[0].equals("ascending")) { + Direction direction = Direction.named(parts[1]); + return direction == null ? value : "ascending_" + directions.get(direction).serialized; + } + if (parts.length != 2) { + return value; + } + Direction first = Direction.named(parts[0]); + Direction second = Direction.named(parts[1]); + if (first == null || second == null) { + return value; + } + Direction mappedFirst = directions.get(first); + Direction mappedSecond = directions.get(second); + if (!mappedFirst.horizontal() || !mappedSecond.horizontal() || !first.horizontal() || !second.horizontal()) { + return mappedFirst.serialized + "_" + mappedSecond.serialized; + } + if (mappedFirst.northSouth() && mappedSecond.northSouth()) { + return "north_south"; + } + if (!mappedFirst.northSouth() && !mappedSecond.northSouth()) { + return "east_west"; + } + Direction northSouth = mappedFirst.northSouth() ? mappedFirst : mappedSecond; + Direction eastWest = mappedFirst.northSouth() ? mappedSecond : mappedFirst; + return northSouth.serialized + "_" + eastWest.serialized; + } + + private static String flipVertical(String value) { + return switch (value) { + case "top" -> "bottom"; + case "bottom" -> "top"; + default -> value; + }; + } + + private static String flipFace(String value) { + return switch (value) { + case "floor" -> "ceiling"; + case "ceiling" -> "floor"; + default -> value; + }; + } + + private static int quarters(double degrees) { + return (int) Math.floorMod(Math.round(degrees / 90.0), 4L); + } + + private static int[] turn(int[] vector, int quarters, Axis axis) { + int[] result = vector.clone(); + for (int index = 0; index < quarters; index++) { + int x = result[0]; + int y = result[1]; + int z = result[2]; + result = switch (axis) { + case X -> new int[]{x, -z, y}; + case Y -> new int[]{z, y, -x}; + case Z -> new int[]{-y, x, z}; + }; + } + return result; + } + + private enum Axis { + X, + Y, + Z + } + + private enum Direction { + NORTH(0, 0, -1), + SOUTH(0, 0, 1), + EAST(1, 0, 0), + WEST(-1, 0, 0), + UP(0, 1, 0), + DOWN(0, -1, 0); + + private final int dx; + private final int dy; + private final int dz; + private final String serialized; + + Direction(int dx, int dy, int dz) { + this.dx = dx; + this.dy = dy; + this.dz = dz; + serialized = name().toLowerCase(Locale.ROOT); + } + + private boolean horizontal() { + return dy == 0; + } + + private boolean northSouth() { + return dz != 0; + } + + private static Direction named(String value) { + for (Direction direction : values()) { + if (direction.serialized.equals(value)) { + return direction; + } + } + return null; + } + + private static Direction of(int dx, int dy, int dz) { + for (Direction direction : values()) { + if (direction.dx == dx && direction.dy == dy && direction.dz == dz) { + return direction; + } + } + throw new IllegalStateException("Quarter turn produced a non-axis direction " + dx + "," + dy + "," + dz); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/CompiledPlacement.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/CompiledPlacement.java new file mode 100644 index 000000000..7d3724a93 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/CompiledPlacement.java @@ -0,0 +1,16 @@ +package art.arcane.iris.pack.datapack.export.compile.object; + +import art.arcane.iris.pack.datapack.export.compile.decoration.GenerationStep; +import art.arcane.iris.pack.datapack.export.ir.JsonResource; + +import java.util.List; +import java.util.Objects; + +public record CompiledPlacement(GenerationStep step, List placedFeatures, List structures, List resources) { + public CompiledPlacement { + Objects.requireNonNull(step, "Placement step"); + placedFeatures = List.copyOf(placedFeatures); + structures = List.copyOf(structures); + resources = List.copyOf(resources); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/ContentIds.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/ContentIds.java new file mode 100644 index 000000000..33551b120 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/ContentIds.java @@ -0,0 +1,32 @@ +package art.arcane.iris.pack.datapack.export.compile.object; + +import com.google.gson.JsonElement; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +final class ContentIds { + private static final int HASH_CHARACTERS = 12; + + private ContentIds() { + } + + static String hash(String content) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(content.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(digest).substring(0, HASH_CHARACTERS); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable for export resource ids", exception); + } + } + + static String hash(JsonElement json) { + return hash(json.toString()); + } + + static long seed(String content) { + return Long.parseUnsignedLong(hash(content), 16); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/FeatureJson.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/FeatureJson.java new file mode 100644 index 000000000..b939cf5cc --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/FeatureJson.java @@ -0,0 +1,82 @@ +package art.arcane.iris.pack.datapack.export.compile.object; + +import art.arcane.iris.pack.datapack.export.compile.WorldgenSyntax; +import art.arcane.iris.pack.datapack.export.compile.decoration.Placements; +import art.arcane.iris.pack.datapack.export.lower.ExportCapabilities; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +import java.util.ArrayList; +import java.util.List; + +final class FeatureJson { + private static final int OFFSET_LIMIT = 16; + + private final WorldgenSyntax syntax; + + FeatureJson(ExportCapabilities capabilities) { + syntax = WorldgenSyntax.forTarget(capabilities.target()); + } + + WorldgenSyntax syntax() { + return syntax; + } + + List chanceAndCount(double chance, int count, double share) { + List modifiers = new ArrayList<>(); + if (chance < 1) { + modifiers.add(Placements.randomChance(chance)); + } + if (count > 1) { + modifiers.add(Placements.count(new JsonPrimitive(count))); + } + if (share < 1) { + modifiers.add(Placements.randomChance(share)); + } + return modifiers; + } + + List offset(int x, int yMin, int yMax, int z) { + List modifiers = new ArrayList<>(); + int remainingX = x; + int remainingZ = z; + int remainingY = yMin; + int spread = yMax - yMin; + while (remainingX != 0 || remainingZ != 0 || remainingY != 0 || spread > 0) { + int stepX = clampStep(remainingX); + int stepZ = clampStep(remainingZ); + int stepY = clampStep(remainingY); + int stepSpread = Math.min(spread, OFFSET_LIMIT - Math.abs(stepY)); + JsonObject offset = new JsonObject(); + offset.addProperty("type", "minecraft:offset"); + offset.addProperty("x", stepX); + offset.add("y", stepSpread > 0 ? Placements.uniform(stepY, stepY + stepSpread) : new JsonPrimitive(stepY)); + offset.addProperty("z", stepZ); + modifiers.add(offset); + remainingX -= stepX; + remainingZ -= stepZ; + remainingY -= stepY; + spread -= stepSpread; + } + return modifiers; + } + + static JsonArray vector(int x, int y, int z) { + JsonArray vector = new JsonArray(3); + vector.add(x); + vector.add(y); + vector.add(z); + return vector; + } + + static JsonObject absolute(int y) { + JsonObject anchor = new JsonObject(); + anchor.addProperty("absolute", y); + return anchor; + } + + private static int clampStep(int remaining) { + return Math.max(-OFFSET_LIMIT, Math.min(OFFSET_LIMIT, remaining)); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/HeightAnchor.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/HeightAnchor.java new file mode 100644 index 000000000..916f3121c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/HeightAnchor.java @@ -0,0 +1,124 @@ +package art.arcane.iris.pack.datapack.export.compile.object; + +import art.arcane.iris.pack.datapack.export.TemplateVolume; +import art.arcane.iris.pack.datapack.export.compile.decoration.Placements; +import art.arcane.iris.structure.object.ObjectPlaceMode; +import com.google.gson.JsonObject; + +import java.util.ArrayList; +import java.util.List; + +final class HeightAnchor { + private static final int SCAN_STEPS = 32; + private static final int PROBE_LIMIT = 16; + + private HeightAnchor() { + } + + static Sampling sampling(ObjectPlaceMode mode) { + return switch (mode) { + case MIN_HEIGHT, FAST_MIN_HEIGHT, MIN_STILT, FAST_MIN_STILT -> Sampling.MIN; + case MAX_HEIGHT, FAST_MAX_HEIGHT, STILT, FAST_STILT, ERODE_STILT -> Sampling.MAX; + default -> Sampling.CENTER; + }; + } + + static boolean fast(ObjectPlaceMode mode) { + return switch (mode) { + case FAST_MIN_HEIGHT, FAST_MAX_HEIGHT, FAST_STILT, FAST_MIN_STILT -> true; + default -> false; + }; + } + + static JsonObject scan(Sampling sampling, boolean fast, boolean underwater, TemplateVolume volume, List quarters, + TemplateColumn shift) { + boolean lowest = sampling == Sampling.MIN; + List boxes = new ArrayList<>(); + for (int quarter : quarters) { + Box box = Box.of(volume, quarter, shift); + if (fast) { + boxes.addAll(box.fastSamples()); + } else { + boxes.add(box); + } + } + JsonObject top = top(underwater); + JsonObject match = lowest ? top : Placements.not(top); + int probeY = lowest ? -1 : 0; + List targets = new ArrayList<>(boxes.size()); + for (Box box : outermost(boxes)) { + targets.add(box.matching(probeY, match.deepCopy())); + } + JsonObject scan = Placements.typed("minecraft:environment_scan"); + scan.addProperty("direction_of_search", lowest ? "down" : "up"); + scan.addProperty("max_steps", SCAN_STEPS); + scan.add("target_condition", Placements.allOf(targets)); + return scan; + } + + private static JsonObject top(boolean underwater) { + JsonObject terrain = Placements.allOf(List.of(Placements.solid(0, 0, 0), + Placements.not(Placements.blockTag("minecraft:leaves", 0, 0, 0)), + Placements.not(Placements.blockTag("minecraft:logs", 0, 0, 0)))); + return underwater ? terrain : Placements.anyOf(List.of(terrain, Placements.fluids(PlacementCompiler.FLUIDS, 0, 0, 0))); + } + + private static List outermost(List boxes) { + List kept = new ArrayList<>(boxes.size()); + for (int index = 0; index < boxes.size(); index++) { + Box box = boxes.get(index); + boolean covered = false; + for (int other = 0; other < boxes.size() && !covered; other++) { + Box candidate = boxes.get(other); + covered = other != index && candidate.contains(box) && (!box.contains(candidate) || other < index); + } + if (!covered) { + kept.add(box); + } + } + return kept; + } + + private record Box(int minX, int maxX, int minZ, int maxZ) { + static Box of(TemplateVolume volume, int quarter, TemplateColumn shift) { + TemplateColumn first = TemplateColumn.of(volume, 0, 0, quarter); + TemplateColumn last = TemplateColumn.of(volume, volume.sizeX() - 1, volume.sizeZ() - 1, quarter); + return new Box(clamp(Math.min(first.x(), last.x()) + shift.x()), clamp(Math.max(first.x(), last.x()) + shift.x()), + clamp(Math.min(first.z(), last.z()) + shift.z()), clamp(Math.max(first.z(), last.z()) + shift.z())); + } + + List fastSamples() { + int stepX = (maxX - minX) / 2 + 1; + int stepZ = (maxZ - minZ) / 2 + 1; + List samples = new ArrayList<>(4); + for (int x = minX; x <= maxX; x += stepX) { + for (int z = minZ; z <= maxZ; z += stepZ) { + samples.add(new Box(x, x, z, z)); + } + } + return samples; + } + + boolean contains(Box other) { + return minX <= other.minX && maxX >= other.maxX && minZ <= other.minZ && maxZ >= other.maxZ; + } + + JsonObject matching(int y, JsonObject match) { + JsonObject volume = Placements.typed("minecraft:volume_match"); + volume.add("min", FeatureJson.vector(minX, y, minZ)); + volume.add("max", FeatureJson.vector(maxX, y, maxZ)); + volume.add("match", match); + return volume; + } + + private static int clamp(int value) { + return Math.max(-PROBE_LIMIT, Math.min(PROBE_LIMIT, value)); + } + } + + enum Sampling { + CENTER, + MIN, + MAX + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/LootCompiler.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/LootCompiler.java new file mode 100644 index 000000000..89fc49868 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/LootCompiler.java @@ -0,0 +1,416 @@ +package art.arcane.iris.pack.datapack.export.compile.object; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.block.IrisBlockData; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.generation.terrain.IrisRegion; +import art.arcane.iris.pack.datapack.export.VanillaBlockState; +import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.datapack.export.ir.JsonResource; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.pack.validation.ContentGate; +import art.arcane.iris.structure.object.IObjectLoot; +import art.arcane.iris.structure.object.IrisObjectPlacement; +import art.arcane.iris.world.entity.IrisEnchantment; +import art.arcane.iris.world.loot.InventorySlotType; +import art.arcane.iris.world.loot.IrisLoot; +import art.arcane.iris.world.loot.IrisLootMode; +import art.arcane.iris.world.loot.IrisLootReference; +import art.arcane.iris.world.loot.IrisLootTable; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.TreeMap; + +public final class LootCompiler { + private static final long WEIGHT_CEILING = 1_000_000L; + private static final String TABLE_FOLDER = "iris/"; + private static final String BINDING_FOLDER = "object_loot/"; + private static final Set CONTAINERS = Set.of("minecraft:chest", "minecraft:trapped_chest", "minecraft:barrel", + "minecraft:dispenser", "minecraft:dropper", "minecraft:hopper"); + private static final Set DAMAGEABLE_SUFFIXES = Set.of("_sword", "_pickaxe", "_axe", "_shovel", "_hoe", "_helmet", + "_chestplate", "_leggings", "_boots", "_spear", "bow", "trident", "shield", "fishing_rod", "shears", + "flint_and_steel", "elytra", "mace", "_on_a_stick", "brush"); + + private final String namespace; + private final IrisData data; + private final Map tables = new TreeMap<>(); + private final Map tableIds = new TreeMap<>(); + + public LootCompiler(String namespace, IrisData data) { + this.namespace = VanillaResourceIdentifier.requireNamespace(namespace, "loot namespace"); + this.data = data; + } + + public static boolean container(VanillaBlockState state) { + return CONTAINERS.contains(state.name()) || state.name().endsWith("shulker_box"); + } + + public Optional binding(IrisObjectPlacement placement, String containerBlock, LootScope scope, List diagnostics) { + List pools = new ArrayList<>(); + JsonObject objectPool = objectPool(placement, containerBlock, diagnostics); + if (objectPool != null) { + pools.add(objectPool); + } + if (objectPool == null || !placement.isOverrideGlobalLoot()) { + boolean objectDefined = !placement.getLoot().isEmpty() || !placement.getVanillaLoot().isEmpty(); + for (IrisLootTable table : environment(scope, objectDefined)) { + JsonObject pool = new JsonObject(); + pool.add("entries", single(reference(table, diagnostics))); + pool.addProperty("rolls", 1); + pools.add(pool); + } + } + if (pools.isEmpty()) { + return Optional.empty(); + } + JsonObject binding = chestTable(pools); + String id = namespace + ":" + BINDING_FOLDER + ContentIds.hash(binding); + tables.put(id, binding); + return Optional.of(id); + } + + public JsonObject table(IrisLootTable table, List diagnostics) { + String resource = "loot/" + table.getLoadKey(); + List storage = new ArrayList<>(); + for (IrisLoot loot : table.getLoot()) { + if (loot != null && loot.getSlotTypes() == InventorySlotType.STORAGE) { + storage.add(loot); + } + } + BigInteger common = BigInteger.ONE; + for (IrisLoot loot : storage) { + BigInteger rarity = BigInteger.valueOf(Math.max(1, loot.getRarity())); + common = common.multiply(rarity).divide(common.gcd(rarity)); + } + boolean exact = common.compareTo(BigInteger.valueOf(WEIGHT_CEILING)) <= 0; + JsonArray entries = new JsonArray(); + for (IrisLoot loot : storage) { + Optional item = item(loot.getTypeKey()); + if (item.isEmpty()) { + diagnostics.add(new ExportDiagnostic(ExportDiagnostic.Severity.WARNING, ExportLimit.CUSTOM_ITEM, resource, + "Loot item '" + loot.getTypeKey() + "' is not a Minecraft item and is not exported")); + continue; + } + int rarity = Math.max(1, loot.getRarity()); + long weight = exact ? common.longValue() / rarity : Math.max(1, Math.round(WEIGHT_CEILING / (double) rarity)); + JsonObject entry = new JsonObject(); + entry.addProperty("type", "minecraft:item"); + entry.addProperty("name", item.get()); + entry.addProperty("weight", weight); + List functions = functions(loot, item.get()); + if (!functions.isEmpty()) { + entry.add("modifier", list(functions)); + } + entries.add(entry); + } + JsonObject pool = new JsonObject(); + if (table.getRarity() > 1) { + JsonObject chance = condition("minecraft:random_chance"); + chance.addProperty("chance", 1.0f / table.getRarity()); + pool.add("condition", chance); + } + pool.add("entries", entries); + pool.add("rolls", range(Math.min(table.getMinPicked(), table.getMaxPicked()), Math.max(table.getMinPicked(), table.getMaxPicked()))); + diagnostics.add(new ExportDiagnostic(ExportDiagnostic.Severity.INFO, ExportLimit.LOOT_RARITY, resource, + "Loot rarity exports as proportional entry weight")); + return chestTable(List.of(pool)); + } + + JsonObject appendLoot(Map bindings, FeatureJson json) { + JsonArray rules = new JsonArray(bindings.size()); + for (Map.Entry binding : bindings.entrySet()) { + JsonObject modifier = new JsonObject(); + modifier.addProperty("type", "minecraft:append_loot"); + modifier.addProperty("loot_table", binding.getValue()); + JsonObject input = new JsonObject(); + input.addProperty("predicate_type", "minecraft:blockstate_match"); + input.add("block_state", json.syntax().blockState(binding.getKey())); + JsonObject location = new JsonObject(); + location.addProperty("predicate_type", "minecraft:always_true"); + JsonObject rule = new JsonObject(); + rule.add("block_entity_modifier", modifier); + rule.add("input_predicate", input); + rule.add("location_predicate", location); + rule.add("output_state", json.syntax().blockState(binding.getKey())); + rules.add(rule); + } + JsonObject processor = new JsonObject(); + processor.addProperty("processor_type", "minecraft:rule"); + processor.add("rules", rules); + return processor; + } + + public List resources() { + List resources = new ArrayList<>(tables.size()); + for (Map.Entry table : tables.entrySet()) { + String path = table.getKey().substring(namespace.length() + 1); + resources.add(new JsonResource("data/" + namespace + "/loot_table/" + path + ".json", table.getValue())); + } + return resources; + } + + private JsonObject objectPool(IrisObjectPlacement placement, String containerBlock, List diagnostics) { + List global = new ArrayList<>(); + List filtered = new ArrayList<>(); + List sources = new ArrayList<>(); + sources.addAll(placement.getVanillaLoot()); + sources.addAll(placement.getLoot()); + int vanillaCount = placement.getVanillaLoot().size(); + for (int index = 0; index < sources.size(); index++) { + IObjectLoot loot = sources.get(index); + if (loot == null || loot.getWeight() <= 0 || loot.getName() == null) { + continue; + } + String id; + if (index < vanillaCount) { + id = normalizeId(loot.getName()).orElse(null); + } else { + IrisLootTable table = data == null ? null : data.getLootLoader().load(loot.getName(), false); + id = table == null ? null : reference(table, diagnostics); + } + if (id == null) { + continue; + } + JsonObject entry = new JsonObject(); + entry.addProperty("type", "minecraft:loot_table"); + entry.addProperty("value", id); + entry.addProperty("weight", loot.getWeight()); + if (loot.getFilter().isEmpty()) { + global.add(entry); + } else if (filterMatches(loot, containerBlock)) { + filtered.add(entry); + } + } + List chosen = filtered.isEmpty() ? global : filtered; + if (chosen.isEmpty()) { + return null; + } + JsonObject pool = new JsonObject(); + pool.add("entries", list(chosen)); + pool.addProperty("rolls", 1); + return pool; + } + + private List environment(LootScope scope, boolean objectDefined) { + List sources = new ArrayList<>(); + double multiplier = 1; + List references = new ArrayList<>(3); + if (scope.dimension() != null) { + references.add(scope.dimension().getLoot()); + } + if (scope.region() != null) { + references.add(scope.region().getLoot()); + } + if (scope.biome() != null) { + references.add(scope.biome().getLoot()); + } + for (IrisLootReference reference : references) { + if (reference == null) { + continue; + } + multiplier *= reference.getMultiplier(); + inject(sources, load(reference), reference.getMode(), !objectDefined); + } + int target = (int) Math.round(sources.size() * multiplier); + if (target == sources.size() || sources.isEmpty()) { + return sources; + } + List scaled = new ArrayList<>(Math.max(0, target)); + for (int index = 0; index < target; index++) { + scaled.add(sources.get(index % sources.size())); + } + return scaled; + } + + private static void inject(List sources, List additions, IrisLootMode mode, boolean fallback) { + if (mode == IrisLootMode.FALLBACK && !fallback) { + return; + } + if (mode == IrisLootMode.CLEAR) { + sources.clear(); + return; + } + if (mode == IrisLootMode.REPLACE) { + sources.clear(); + } + sources.addAll(additions); + } + + private List load(IrisLootReference reference) { + List loaded = new ArrayList<>(); + if (data == null || reference.getMode() == IrisLootMode.CLEAR) { + return loaded; + } + for (String key : reference.getTables()) { + IrisLootTable table = key == null ? null : data.getLootLoader().load(key, false); + if (table != null) { + loaded.add(table); + } + } + return loaded; + } + + private String reference(IrisLootTable table, List diagnostics) { + String key = table.getLoadKey(); + String existing = tableIds.get(key); + if (existing != null) { + return existing; + } + String id = namespace + ":" + TABLE_FOLDER + VanillaResourceIdentifier.sanitizePath(key); + tableIds.put(key, id); + tables.put(id, table(table, diagnostics)); + return id; + } + + private static boolean filterMatches(IObjectLoot loot, String containerBlock) { + for (IrisBlockData filter : loot.getFilter()) { + String state = ContentGate.normalizeState(filter.stateKey()); + if (state != null && ContentGate.baseKey(state).equals(containerBlock)) { + return true; + } + } + return false; + } + + private List functions(IrisLoot loot, String item) { + List functions = new ArrayList<>(); + int minAmount = Math.min(loot.getMinAmount(), loot.getMaxAmount()); + int maxAmount = Math.max(loot.getMinAmount(), loot.getMaxAmount()); + if (minAmount != 1 || maxAmount != 1) { + JsonObject count = function("minecraft:set_count"); + count.add("count", range(minAmount, maxAmount)); + functions.add(count); + } + if (damageable(item) && (loot.getMinDurability() != 1 || loot.getMaxDurability() != 1)) { + JsonObject damage = function("minecraft:set_damage"); + JsonObject provider = new JsonObject(); + provider.addProperty("type", "minecraft:uniform"); + provider.addProperty("max", (float) clampUnit(Math.max(loot.getMinDurability(), loot.getMaxDurability()))); + provider.addProperty("min", (float) clampUnit(Math.min(loot.getMinDurability(), loot.getMaxDurability()))); + damage.add("damage", provider); + functions.add(damage); + } + if (loot.getDisplayName() != null && !loot.getDisplayName().isBlank()) { + JsonObject name = function("minecraft:set_name"); + name.addProperty("name", loot.getDisplayName()); + name.addProperty("target", "item_name"); + functions.add(name); + } + if (!loot.getLore().isEmpty()) { + JsonObject lore = function("minecraft:set_lore"); + JsonArray lines = new JsonArray(loot.getLore().size()); + for (String line : loot.getLore()) { + lines.add(line); + } + lore.add("lore", lines); + lore.addProperty("mode", "replace_all"); + functions.add(lore); + } + for (IrisEnchantment enchantment : loot.getEnchantments()) { + Optional id = enchantment == null ? Optional.empty() : normalizeId(enchantment.getEnchantment()); + if (id.isEmpty()) { + continue; + } + JsonObject enchant = function("minecraft:set_enchantments"); + JsonObject levels = new JsonObject(); + levels.add(id.get(), range(Math.min(enchantment.getMinLevel(), enchantment.getMaxLevel()), + Math.max(enchantment.getMinLevel(), enchantment.getMaxLevel()))); + enchant.add("enchantments", levels); + enchant.addProperty("add", true); + if (enchantment.getChance() < 1) { + JsonObject chance = condition("minecraft:random_chance"); + chance.addProperty("chance", (float) Math.max(0, enchantment.getChance())); + enchant.add("condition", chance); + } + functions.add(enchant); + } + return functions; + } + + private JsonObject function(String type) { + JsonObject function = new JsonObject(); + function.addProperty("type", type); + return function; + } + + private JsonObject condition(String type) { + JsonObject condition = new JsonObject(); + condition.addProperty("type", type); + return condition; + } + + private static JsonObject chestTable(List pools) { + JsonObject table = new JsonObject(); + table.add("pools", list(pools)); + table.addProperty("type", "minecraft:chest"); + return table; + } + + private static JsonArray single(String id) { + JsonObject entry = new JsonObject(); + entry.addProperty("type", "minecraft:loot_table"); + entry.addProperty("value", id); + return list(List.of(entry)); + } + + private static JsonElement range(int min, int max) { + if (min == max) { + return new JsonPrimitive(min); + } + JsonObject range = new JsonObject(); + range.addProperty("type", "minecraft:uniform"); + range.addProperty("max", max); + range.addProperty("min", min); + return range; + } + + private static JsonArray list(List values) { + JsonArray array = new JsonArray(values.size()); + for (JsonObject value : values) { + array.add(value); + } + return array; + } + + private static Optional item(String type) { + Optional id = normalizeId(type); + return id.isPresent() && id.get().startsWith("minecraft:") ? id : Optional.empty(); + } + + private static Optional normalizeId(String raw) { + if (raw == null || raw.isBlank()) { + return Optional.empty(); + } + String lowered = raw.trim().toLowerCase(Locale.ROOT); + String id = lowered.indexOf(':') < 0 ? "minecraft:" + lowered : lowered; + return VanillaResourceIdentifier.validIdentifier(id) ? Optional.of(id) : Optional.empty(); + } + + private static boolean damageable(String item) { + for (String suffix : DAMAGEABLE_SUFFIXES) { + if (item.endsWith(suffix)) { + return true; + } + } + return false; + } + + private static double clampUnit(double value) { + return Math.max(0, Math.min(1, value)); + } + + public record LootScope(IrisDimension dimension, IrisRegion region, IrisBiome biome) { + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectEdit.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectEdit.java new file mode 100644 index 000000000..16ec8fcea --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectEdit.java @@ -0,0 +1,65 @@ +package art.arcane.iris.pack.datapack.export.compile.object; + +import art.arcane.iris.pack.datapack.export.VanillaBlockState; +import art.arcane.iris.pack.datapack.export.compile.WeightedState; + +import java.util.List; +import java.util.Map; + +public record ObjectEdit(List find, boolean exact, List replace, double chance) { + public ObjectEdit { + find = List.copyOf(find); + replace = List.copyOf(replace); + if (find.isEmpty() || replace.isEmpty()) { + throw new IllegalArgumentException("Object edits need at least one find and one replace state"); + } + if (!Double.isFinite(chance) || chance < 0 || chance > 1) { + throw new IllegalArgumentException("Object edit chance must be within [0, 1], got " + chance); + } + } + + public boolean deterministic() { + return chance >= 1; + } + + public boolean matches(VanillaBlockState state) { + for (VanillaBlockState candidate : find) { + if (!candidate.name().equals(state.name())) { + continue; + } + if (!exact || propertiesMatch(candidate, state)) { + return true; + } + } + return false; + } + + public int totalWeight() { + int total = 0; + for (WeightedState state : replace) { + total += state.weight(); + } + return total; + } + + public String descriptor() { + StringBuilder builder = new StringBuilder("find="); + for (VanillaBlockState state : find) { + builder.append(state.canonical()).append('|'); + } + builder.append(";exact=").append(exact).append(";replace="); + for (WeightedState state : replace) { + builder.append(state.state().canonical()).append('*').append(state.weight()).append('|'); + } + return builder.append(";chance=").append(chance).toString(); + } + + private static boolean propertiesMatch(VanillaBlockState find, VanillaBlockState state) { + for (Map.Entry property : find.properties().entrySet()) { + if (!property.getValue().equals(state.properties().get(property.getKey()))) { + return false; + } + } + return true; + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectSource.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectSource.java new file mode 100644 index 000000000..3aa6c01af --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectSource.java @@ -0,0 +1,88 @@ +package art.arcane.iris.pack.datapack.export.compile.object; + +import art.arcane.iris.generation.decoration.tree.TreeGrounding; +import art.arcane.iris.pack.datapack.export.StructureTemplateEncoder; +import art.arcane.iris.pack.datapack.export.TemplateBlock; +import art.arcane.iris.pack.datapack.export.TemplateVolume; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.structure.object.IrisObject; +import art.arcane.volmlib.util.math.Vector3i; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.ToIntFunction; + +public record ObjectSource(TemplateVolume volume, int baseY, TreeRoots roots) { + public ObjectSource { + Objects.requireNonNull(volume, "Object volume"); + Objects.requireNonNull(roots, "Object roots"); + if (baseY > 0) { + throw new IllegalArgumentException("Object base offset must not be positive, got " + baseY); + } + } + + public static ObjectSource of(IrisObject object, String resource, List diagnostics) { + List blocks = StructureTemplateEncoder.blocks(object, resource, diagnostics); + Vector3i center = object.getCenter(); + Axis x = Axis.of(blocks, TemplateBlock::x, object.getW(), center.getX()); + Axis z = Axis.of(blocks, TemplateBlock::z, object.getD(), center.getZ()); + int lowestY = 0; + int highestY = object.getH() - 1; + for (TemplateBlock block : blocks) { + lowestY = Math.min(lowestY, block.y()); + highestY = Math.max(highestY, block.y()); + } + List placed = new ArrayList<>(blocks.size()); + Map byPosition = new HashMap<>(blocks.size() * 2); + for (TemplateBlock block : blocks) { + TemplateBlock shifted = new TemplateBlock(x.shift(block.x()), block.y() - lowestY, z.shift(block.z()), + block.state().waterlogged(false), block.nbt()); + placed.add(shifted); + byPosition.put(position(shifted.x(), shifted.y(), shifted.z()), shifted); + } + TreeRoots roots = roots(object.getTreeGrounding(), center, x, z, lowestY, byPosition); + return new ObjectSource(new TemplateVolume(x.size(), highestY - lowestY + 1, z.size(), placed), lowestY, roots); + } + + private static TreeRoots roots(TreeGrounding grounding, Vector3i center, Axis x, Axis z, int lowestY, Map blocks) { + if (grounding == null) { + return TreeRoots.NONE; + } + List cells = new ArrayList<>(grounding.roots().size()); + for (TreeGrounding.Root root : grounding.roots()) { + TemplateBlock block = blocks.get(position(x.shift(root.x() + center.getX()), root.y() + center.getY() - lowestY, + z.shift(root.z() + center.getZ()))); + if (block != null) { + cells.add(block); + } + } + return new TreeRoots(cells, grounding.maxExtension()); + } + + private static long position(int x, int y, int z) { + return ((long) (x & 0x1FFFFF) << 42) | ((long) (y & 0x1FFFFF) << 21) | (z & 0x1FFFFF); + } + + private record Axis(int size, int center, int nominalCenter) { + static Axis of(List blocks, ToIntFunction coordinate, int size, int center) { + int low = 0; + int high = size - 1; + for (TemplateBlock block : blocks) { + low = Math.min(low, coordinate.applyAsInt(block)); + high = Math.max(high, coordinate.applyAsInt(block)); + } + if (low == 0 && high == size - 1) { + return new Axis(size, center, center); + } + int reach = Math.max(center - low, high - center); + return new Axis(reach * 2 + 1, reach, center); + } + + int shift(int value) { + return value - nominalCenter + center; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectStage.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectStage.java new file mode 100644 index 000000000..b0162f8f0 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectStage.java @@ -0,0 +1,462 @@ +package art.arcane.iris.pack.datapack.export.compile.object; + +import art.arcane.iris.configuration.IrisSettings; +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.decoration.IrisProceduralObjects; +import art.arcane.iris.generation.decoration.IrisProceduralPlacement; +import art.arcane.iris.generation.decoration.tree.IrisProceduralTree; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.generation.terrain.IrisRegion; +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.StructureTemplateEncoder; +import art.arcane.iris.pack.datapack.export.TemplateBlock; +import art.arcane.iris.pack.datapack.export.VanillaBlockState; +import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; +import art.arcane.iris.pack.datapack.export.compile.CompileStage; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; +import art.arcane.iris.pack.datapack.export.compile.WeightedState; +import art.arcane.iris.pack.datapack.export.ir.BinaryResource; +import art.arcane.iris.pack.datapack.export.ir.BiomeDef; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.datapack.export.ir.JsonResource; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.structure.object.IrisObject; +import art.arcane.iris.structure.object.IrisObjectPlacement; +import art.arcane.volmlib.util.collection.KList; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; + +public final class ObjectStage implements CompileStage { + private static final String NAME = "objects"; + + @Override + public String name() { + return NAME; + } + + @Override + public void apply(PackCompileContext context) { + if (!context.objects()) { + return; + } + new Run(context).compile(); + } + + private static final class Run { + private static final int MAX_STILT_BASES = 16; + private static final String TREE_FOLDER = "trees/"; + private static final String PROCEDURAL_FOLDER = "procedural/"; + private static final String CAVE_SUFFIX = "_cave"; + + private final PackCompileContext context; + private final String namespace; + private final DataVersion target; + private final IrisData data; + private final List diagnostics; + private final FeatureJson json; + private final ObjectVariantBaker baker; + private final VariantPlanner planner; + private final PlacementCompiler compiler; + private final LootCompiler loot; + private final WideObjectStructures wide; + private final StructureTemplateEncoder encoder = new StructureTemplateEncoder(); + private final Map sources = new HashMap<>(); + private final Map proceduralPlacements = new IdentityHashMap<>(); + private final Map baked = new HashMap<>(); + private final Map plans = new IdentityHashMap<>(); + private final Map> templates = new IdentityHashMap<>(); + private final Map reported = new IdentityHashMap<>(); + private final Map> containers = new IdentityHashMap<>(); + private final Map> stiltBases = new IdentityHashMap<>(); + private final Map> compiled = new IdentityHashMap<>(); + private final Map jsonResources = new TreeMap<>(); + private final Map binaryResources = new TreeMap<>(); + private final Map> structureBiomes = new TreeMap<>(); + private int exported; + + private Run(PackCompileContext context) { + this.context = context; + namespace = context.namespace(); + target = context.capabilities().target(); + data = context.data(); + diagnostics = context.diagnostics(); + json = new FeatureJson(context.capabilities()); + baker = new ObjectVariantBaker(); + planner = new VariantPlanner(context.dimension(), data, preventLeafDecay()); + compiler = new PlacementCompiler(namespace, context.capabilities(), context.dimensionDef()); + loot = new LootCompiler(namespace, data); + wide = new WideObjectStructures(namespace); + } + + private void compile() { + IrisDimension dimension = context.dimension(); + reportSpawners(dimension.getEntitySpawners(), "dimensions/" + dimension.getLoadKey()); + Map regions = regions(dimension); + Set defined = new TreeSet<>(); + for (BiomeDef biome : context.biomes()) { + defined.add(namespace + ":" + biome.id()); + } + for (Map.Entry entry : context.biomeCodes().loadKeyByBiomeId().entrySet()) { + IrisBiome biome = defined.contains(entry.getKey()) ? data.getBiomeLoader().load(entry.getValue(), false) : null; + if (biome == null) { + continue; + } + reportSpawners(biome.getEntitySpawners(), "biomes/" + biome.getLoadKey()); + IrisRegion region = regions.get(entry.getValue()); + LootCompiler.LootScope scope = new LootCompiler.LootScope(dimension, region, biome); + List placements = new ArrayList<>(biome.getObjects()); + placements.addAll(procedural(biome.getProceduralObjects())); + if (region != null) { + placements.addAll(region.getObjects()); + placements.addAll(procedural(region.getProceduralObjects())); + } + for (IrisObjectPlacement placement : placements) { + if (placement == null || placement.getPlace().isEmpty()) { + continue; + } + if (placement.getCarvingSupport().supportsSurface()) { + place(entry.getKey(), placement, PlacementInput.Anchor.SURFACE, scope); + } + if (placement.getCarvingSupport().supportsCarving()) { + place(entry.getKey(), placement, PlacementInput.Anchor.CAVE, scope); + } + } + } + if (exported > 0) { + diagnostics.add(new ExportDiagnostic(ExportDiagnostic.Severity.INFO, ExportLimit.STRUCTURE_AVOIDANCE, + "dimensions/" + dimension.getLoadKey(), "Objects do not avoid Minecraft structures")); + } + emit(); + } + + private List procedural(IrisProceduralObjects objects) { + if (objects == null || objects.isEmpty()) { + return List.of(); + } + List placements = new ArrayList<>(); + for (IrisProceduralPlacement placement : objects.getAllPlacements()) { + placements.add(proceduralPlacements.computeIfAbsent(placement, this::procedural)); + } + return placements; + } + + private IrisObjectPlacement procedural(IrisProceduralPlacement source) { + IrisObjectPlacement placement = source.asPlacement(); + String folder = (source instanceof IrisProceduralTree ? TREE_FOLDER : "") + PROCEDURAL_FOLDER + source.getName() + "-"; + KList keys = new KList<>(); + for (IrisObject variant : source.getVariantObjects(data)) { + ObjectSource object = ObjectSource.of(variant, variant.getLoadKey(), diagnostics); + String key = folder + ContentIds.hash(object.volume() + ";base=" + object.baseY() + + (object.roots().present() ? ";roots=" + object.roots() : "")); + sources.putIfAbsent(key, object); + keys.add(key); + } + placement.setPlace(keys); + return placement; + } + + private void place(String biomeId, IrisObjectPlacement placement, PlacementInput.Anchor anchor, LootCompiler.LootScope scope) { + String name = name(placement, anchor); + VariantPlanner.Plan plan = plans.computeIfAbsent(placement, ignored -> planner.plan(placement, name, diagnostics)); + List objects = templates.computeIfAbsent(placement, ignored -> bake(placement, plan)); + if (objects.isEmpty()) { + return; + } + reportMarkers(placement, name); + Map bindings = bindings(placement, objects, scope); + Map byBinding = compiled.computeIfAbsent(placement, ignored -> new HashMap<>()); + String key = anchor + "|" + bindings; + CompiledPlacement result = byBinding.get(key); + if (result == null) { + result = compile(placement, anchor, plan, objects, bindings, name); + byBinding.put(key, result); + } + for (String placed : result.placedFeatures()) { + context.features().add(biomeId, result.step(), placed); + } + for (String structure : result.structures()) { + structureBiomes.computeIfAbsent(structure, ignored -> new TreeSet<>()).add(biomeId); + } + exported++; + } + + private CompiledPlacement compile(IrisObjectPlacement placement, PlacementInput.Anchor anchor, VariantPlanner.Plan plan, + List objects, Map bindings, String name) { + List processors = new ArrayList<>(); + for (ObjectEdit edit : plan.processorEdits()) { + processors.add(editProcessor(edit)); + } + if (!bindings.isEmpty()) { + processors.add(loot.appendLoot(bindings, json)); + } + List bases = PlacementCompiler.stilted(placement.getMode()) + ? stiltBases.computeIfAbsent(placement, ignored -> stiltBases(objects)) + : List.of(); + PlacementInput input = new PlacementInput(placement, anchor, tree(placement), objects, processors, bases, name); + CompiledPlacement result = compiler.compile(input, diagnostics); + for (JsonResource resource : result.resources()) { + addJson(resource); + } + for (ObjectTemplates object : objects) { + for (TemplateChoice choice : object.choices()) { + addTemplate(choice.variant()); + } + } + return result; + } + + private List bake(IrisObjectPlacement placement, VariantPlanner.Plan plan) { + List keys = new ArrayList<>(); + List> choices = new ArrayList<>(); + List all = new ArrayList<>(); + for (String objectKey : placement.getPlace()) { + ObjectSource source = source(objectKey); + if (source == null) { + continue; + } + String objectPath = VanillaResourceIdentifier.sanitizePath(objectKey); + List objectChoices = new ArrayList<>(plan.variants().size()); + for (VariantPlanner.PlannedVariant planned : plan.variants()) { + String id = ObjectVariantBaker.templateId(namespace, objectPath, planned.spec(), target); + BakedVariant variant = baked.computeIfAbsent(id, + ignored -> baker.bake(id, source, planned.spec())); + objectChoices.add(new TemplateChoice(variant, planned.quarters(), planned.weight())); + all.add(variant); + } + keys.add(objectKey); + choices.add(objectChoices); + } + Map aligned = new HashMap<>(); + List lifted = ObjectVariantBaker.alignBases(all); + for (int index = 0; index < all.size(); index++) { + aligned.put(all.get(index).id(), lifted.get(index)); + } + List objects = new ArrayList<>(keys.size()); + for (int index = 0; index < keys.size(); index++) { + List alignedChoices = new ArrayList<>(); + for (TemplateChoice choice : choices.get(index)) { + alignedChoices.add(new TemplateChoice(aligned.get(choice.variant().id()), choice.quarters(), choice.weight())); + } + objects.add(new ObjectTemplates(keys.get(index), alignedChoices)); + } + return objects; + } + + private ObjectSource source(String objectKey) { + if (sources.containsKey(objectKey)) { + return sources.get(objectKey); + } + IrisObject object = data.getObjectLoader().load(objectKey, false); + ObjectSource source = object == null ? null : ObjectSource.of(object, "objects/" + objectKey, diagnostics); + sources.put(objectKey, source); + return source; + } + + private Map bindings(IrisObjectPlacement placement, List objects, LootCompiler.LootScope scope) { + Set found = containers.computeIfAbsent(placement, ignored -> containers(objects)); + Map byBlock = new TreeMap<>(); + Map bindings = new LinkedHashMap<>(); + for (VanillaBlockState container : found) { + String table = byBlock.computeIfAbsent(container.name(), + block -> loot.binding(placement, block, scope, diagnostics).orElse("")); + if (!table.isEmpty()) { + bindings.put(container, table); + } + } + return bindings; + } + + private static Set containers(List objects) { + Set found = new TreeSet<>((first, second) -> first.canonical().compareTo(second.canonical())); + for (ObjectTemplates object : objects) { + for (TemplateChoice choice : object.choices()) { + for (TemplateBlock block : choice.variant().volume().blocks()) { + if (LootCompiler.container(block.state())) { + found.add(block.state()); + } + } + } + } + return found; + } + + private JsonObject editProcessor(ObjectEdit edit) { + JsonArray rules = new JsonArray(); + int total = edit.totalWeight(); + for (VanillaBlockState find : edit.find()) { + double consumed = 0; + for (WeightedState replacement : edit.replace()) { + double share = edit.chance() * replacement.weight() / total; + double probability = consumed >= 1 ? 1 : Math.min(1, share / (1 - consumed)); + consumed += share; + JsonObject input = new JsonObject(); + if (edit.exact()) { + input.addProperty("predicate_type", "minecraft:random_blockstate_match"); + input.add("block_state", json.syntax().blockState(find)); + } else { + input.addProperty("predicate_type", "minecraft:random_block_match"); + input.addProperty("block", find.name()); + } + input.addProperty("probability", (float) probability); + JsonObject location = new JsonObject(); + location.addProperty("predicate_type", "minecraft:always_true"); + JsonObject rule = new JsonObject(); + rule.add("input_predicate", input); + rule.add("location_predicate", location); + rule.add("output_state", json.syntax().blockState(replacement.state())); + rules.add(rule); + } + } + JsonObject processor = new JsonObject(); + processor.addProperty("processor_type", "minecraft:rule"); + processor.add("rules", rules); + return processor; + } + + private static List stiltBases(List objects) { + Map counts = new TreeMap<>(); + Map states = new HashMap<>(); + for (ObjectTemplates object : objects) { + for (TemplateChoice choice : object.choices()) { + int lowest = Integer.MAX_VALUE; + for (TemplateBlock block : choice.variant().volume().blocks()) { + lowest = Math.min(lowest, block.y()); + } + for (TemplateBlock block : choice.variant().volume().blocks()) { + if (block.y() == lowest) { + counts.merge(block.state().name(), 1, Integer::sum); + states.putIfAbsent(block.state().name(), block.state()); + } + } + } + } + List names = new ArrayList<>(counts.keySet()); + names.sort((first, second) -> Integer.compare(counts.get(second), counts.get(first))); + List bases = new ArrayList<>(Math.min(MAX_STILT_BASES, names.size())); + for (String name : names.subList(0, Math.min(MAX_STILT_BASES, names.size()))) { + bases.add(states.get(name)); + } + return bases; + } + + private Map regions(IrisDimension dimension) { + Map regions = new HashMap<>(); + for (String regionKey : dimension.getRegions()) { + IrisRegion region = data.getRegionLoader().load(regionKey, false); + if (region == null) { + continue; + } + reportSpawners(region.getEntitySpawners(), "regions/" + region.getLoadKey()); + List roots = new ArrayList<>(); + roots.addAll(region.getLandBiomes()); + roots.addAll(region.getSeaBiomes()); + roots.addAll(region.getShoreBiomes()); + roots.addAll(region.getCaveBiomes()); + for (String biome : members(roots)) { + regions.putIfAbsent(biome, region); + } + } + return regions; + } + + private Set members(List roots) { + Set members = new TreeSet<>(); + List pending = new ArrayList<>(roots); + while (!pending.isEmpty()) { + String key = pending.removeLast(); + if (key == null || !members.add(key)) { + continue; + } + IrisBiome biome = data.getBiomeLoader().load(key, false); + if (biome != null) { + pending.addAll(biome.getChildren()); + } + } + return members; + } + + private void reportMarkers(IrisObjectPlacement placement, String name) { + if (placement.getMarkers().isEmpty() || reported.put(placement, Boolean.TRUE) != null) { + return; + } + diagnostics.add(new ExportDiagnostic(ExportDiagnostic.Severity.INFO, ExportLimit.CUSTOM_ENTITY, name, + placement.getMarkers().size() + " object markers are not exported")); + } + + private void reportSpawners(List spawners, String resource) { + if (spawners == null || spawners.isEmpty()) { + return; + } + diagnostics.add(new ExportDiagnostic(ExportDiagnostic.Severity.INFO, ExportLimit.CUSTOM_ENTITY, resource, + spawners.size() + " entity spawner rules are not exported")); + } + + private void addTemplate(BakedVariant variant) { + String path = "data/" + namespace + "/structure/" + variant.id().substring(namespace.length() + 1) + ".nbt"; + if (binaryResources.containsKey(path)) { + return; + } + try { + binaryResources.put(path, new BinaryResource(path, encoder.encode(variant.volume(), target))); + } catch (IOException exception) { + throw new UncheckedIOException("Could not encode object template " + variant.id(), exception); + } + } + + private void addJson(JsonResource resource) { + JsonResource existing = jsonResources.putIfAbsent(resource.path(), resource); + if (existing != null && !existing.json().equals(resource.json())) { + throw new IllegalStateException("Object export produced two different resources at " + resource.path()); + } + } + + private void emit() { + for (JsonResource resource : loot.resources()) { + addJson(resource); + } + for (Map.Entry> structure : structureBiomes.entrySet()) { + addJson(wide.biomeTag(structure.getKey(), structure.getValue())); + } + context.jsonResources().addAll(jsonResources.values()); + context.binaryResources().addAll(binaryResources.values()); + } + + private String name(IrisObjectPlacement placement, PlacementInput.Anchor anchor) { + String first = Objects.requireNonNull(placement.getPlace().getFirst(), "Object placement key"); + String path = VanillaResourceIdentifier.sanitizePath(first); + String leaf = path.substring(path.lastIndexOf('/') + 1); + return anchor == PlacementInput.Anchor.CAVE ? leaf + CAVE_SUFFIX : leaf; + } + + private static boolean tree(IrisObjectPlacement placement) { + if (placement.getTrees() != null && !placement.getTrees().isEmpty()) { + return true; + } + for (String key : placement.getPlace()) { + if (key != null && key.startsWith(TREE_FOLDER)) { + return true; + } + } + return false; + } + + private static boolean preventLeafDecay() { + IrisSettings settings = IrisSettings.settings; + return settings == null || settings.getGenerator().preventLeafDecay; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectTemplates.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectTemplates.java new file mode 100644 index 000000000..a442a902f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectTemplates.java @@ -0,0 +1,27 @@ +package art.arcane.iris.pack.datapack.export.compile.object; + +import java.util.List; +import java.util.Objects; + +public record ObjectTemplates(String objectKey, List choices) { + public ObjectTemplates { + Objects.requireNonNull(objectKey, "Object key"); + choices = List.copyOf(choices); + } + + public int footprint() { + int footprint = 0; + for (TemplateChoice choice : choices) { + footprint = Math.max(footprint, choice.variant().volume().footprint()); + } + return footprint; + } + + public long totalWeight() { + long total = 0; + for (TemplateChoice choice : choices) { + total += choice.weight(); + } + return total; + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectVariantBaker.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectVariantBaker.java new file mode 100644 index 000000000..708445736 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectVariantBaker.java @@ -0,0 +1,399 @@ +package art.arcane.iris.pack.datapack.export.compile.object; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.TemplateBlock; +import art.arcane.iris.pack.datapack.export.TemplateVolume; +import art.arcane.iris.pack.datapack.export.VanillaBlockState; +import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; +import art.arcane.iris.pack.datapack.export.compile.WeightedState; +import art.arcane.volmlib.util.nbt.tag.CompoundTag; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +public final class ObjectVariantBaker { + private static final Set AIR = Set.of("minecraft:air", "minecraft:cave_air", "minecraft:void_air"); + private static final String TALL_SEAGRASS = "minecraft:tall_seagrass"; + private static final Set SEA_PLANTS = Set.of("minecraft:sea_pickle", "minecraft:seagrass", TALL_SEAGRASS); + private static final String CORAL = "_coral"; + private static final String CORAL_FAN = "_coral_fan"; + private static final String WALL_FAN = "_coral_wall_fan"; + private static final String SNOW = "minecraft:snow"; + private static final int SNOW_LAYER_STEPS = 7; + private static final int WARP_SPACING_X = 4099; + private static final int WARP_SPACING_Z = 7919; + private static final long EDIT_SALT = 0x5DEECE66DL; + private static final long PICK_SALT = 0x2545F4914F6CDD1DL; + private static final long SNOW_SALT = 0x9E3779B97F4A7C15L; + private static final Comparator VOXEL_ORDER = Comparator + .comparingInt(Voxel::y) + .thenComparingInt(Voxel::x) + .thenComparingInt(Voxel::z); + + public static String templateId(String namespace, String objectPath, VariantSpec spec, DataVersion target) { + VanillaResourceIdentifier.requireNamespace(namespace, "template namespace"); + return namespace + ":object/" + objectPath + "/" + ContentIds.hash(spec.descriptor() + ";target=" + target.name()); + } + + public BakedVariant bake(String templateId, ObjectSource source, VariantSpec spec) { + long seed = ContentIds.seed(spec.descriptor()); + TemplateVolume template = source.volume(); + List voxels = prepare(template, spec, seed, rootCells(source.roots())); + if (!spec.reshapes()) { + List kept = supported(voxels); + return new BakedVariant(templateId, volume(template.sizeX(), template.sizeY(), template.sizeZ(), kept), source.baseY(), + roots(kept, source.roots().reach())); + } + int sizeX = template.sizeX(); + int sizeY = template.sizeY(); + int sizeZ = template.sizeZ(); + if (spec.scale() != 1) { + sizeX = scaledSize(sizeX, spec.scale()); + sizeY = scaledSize(sizeY, spec.scale()); + sizeZ = scaledSize(sizeZ, spec.scale()); + voxels = scale(voxels, spec.scale(), sizeX, sizeY, sizeZ); + } + voxels = anchor(voxels, spec.rotation(), sizeX / 2, sizeY / 2, sizeZ / 2); + if (spec.warp() != null) { + voxels = warp(voxels, spec.warp()); + } + if (spec.snow() > 0) { + voxels = snow(voxels, spec.snow(), seed); + } + return reshaped(templateId, supported(voxels), source.baseY(), (int) Math.ceil(source.roots().reach() * spec.scale())); + } + + public static List alignBases(List variants) { + int lowest = 0; + for (BakedVariant variant : variants) { + lowest = Math.min(lowest, variant.baseY()); + } + List aligned = new ArrayList<>(variants.size()); + for (BakedVariant variant : variants) { + int lift = variant.baseY() - lowest; + if (lift == 0) { + aligned.add(variant); + continue; + } + TemplateVolume volume = variant.volume(); + List blocks = new ArrayList<>(volume.blocks().size()); + for (TemplateBlock block : volume.blocks()) { + blocks.add(new TemplateBlock(block.x(), block.y() + lift, block.z(), block.state(), block.nbt())); + } + aligned.add(new BakedVariant(variant.id() + "_lift" + lift, + new TemplateVolume(volume.sizeX(), volume.sizeY() + lift, volume.sizeZ(), blocks), lowest, variant.roots().lift(lift))); + } + return aligned; + } + + private List prepare(TemplateVolume source, VariantSpec spec, long seed, Set roots) { + List voxels = new ArrayList<>(source.blocks().size()); + for (TemplateBlock block : source.blocks()) { + if (AIR.contains(block.state().name())) { + continue; + } + VanillaBlockState state = block.state(); + CompoundTag nbt = block.nbt(); + for (int index = 0; index < spec.edits().size(); index++) { + ObjectEdit edit = spec.edits().get(index); + if (!edit.deterministic() && unit(seed, block.x(), block.y(), block.z(), index, EDIT_SALT) >= edit.chance()) { + continue; + } + if (!edit.matches(state)) { + continue; + } + VanillaBlockState replacement = pick(edit, unit(seed, block.x(), block.y(), block.z(), index, PICK_SALT)); + if (replacement.name().equals(state.name())) { + state = merge(state, replacement); + } else { + state = replacement; + nbt = null; + } + } + if (AIR.contains(state.name())) { + continue; + } + if (spec.preventLeafDecay()) { + state = persistentLeaves(state); + } + voxels.add(new Voxel(block.x(), block.y(), block.z(), state, nbt, roots.contains(key(block.x(), block.y(), block.z())))); + } + return voxels; + } + + private static Set rootCells(TreeRoots roots) { + Set cells = new HashSet<>(roots.cells().size() * 2); + for (TemplateBlock cell : roots.cells()) { + cells.add(key(cell.x(), cell.y(), cell.z())); + } + return cells; + } + + private static TreeRoots roots(List voxels, int reach) { + List cells = new ArrayList<>(); + for (Voxel voxel : voxels) { + if (voxel.root()) { + cells.add(new TemplateBlock(voxel.x(), voxel.y(), voxel.z(), voxel.state(), null)); + } + } + return cells.isEmpty() ? TreeRoots.NONE : new TreeRoots(cells, reach); + } + + private static VanillaBlockState pick(ObjectEdit edit, double roll) { + double cursor = roll * edit.totalWeight(); + for (WeightedState state : edit.replace()) { + cursor -= state.weight(); + if (cursor < 0) { + return state.state(); + } + } + return edit.replace().getLast().state(); + } + + private static VanillaBlockState merge(VanillaBlockState original, VanillaBlockState replacement) { + Map properties = new TreeMap<>(original.properties()); + properties.putAll(replacement.properties()); + return new VanillaBlockState(original.name(), properties); + } + + private static VanillaBlockState persistentLeaves(VanillaBlockState state) { + Map properties = state.properties(); + if (!properties.containsKey("distance") || !"false".equals(properties.get("persistent"))) { + return state; + } + Map persistent = new TreeMap<>(properties); + persistent.put("persistent", "true"); + return new VanillaBlockState(state.name(), persistent); + } + + private static List supported(List voxels) { + Map occupied = new LinkedHashMap<>(); + int lowest = Integer.MAX_VALUE; + for (Voxel voxel : voxels) { + occupied.put(key(voxel.x(), voxel.y(), voxel.z()), voxel); + lowest = Math.min(lowest, voxel.y()); + } + List kept = new ArrayList<>(voxels.size()); + for (Voxel voxel : voxels) { + if (anchored(voxel, occupied, lowest)) { + kept.add(voxel); + } + } + return kept; + } + + private static boolean anchored(Voxel voxel, Map occupied, int lowest) { + String name = voxel.state().name(); + if (name.endsWith(WALL_FAN)) { + int[] behind = switch (voxel.state().properties().getOrDefault("facing", "north")) { + case "south" -> new int[]{0, -1}; + case "east" -> new int[]{-1, 0}; + case "west" -> new int[]{1, 0}; + default -> new int[]{0, 1}; + }; + return support(occupied.get(key(voxel.x() + behind[0], voxel.y(), voxel.z() + behind[1]))); + } + if (!SEA_PLANTS.contains(name) && !name.endsWith(CORAL) && !name.endsWith(CORAL_FAN)) { + return true; + } + Voxel below = occupied.get(key(voxel.x(), voxel.y() - 1, voxel.z())); + if (name.equals(TALL_SEAGRASS) && "upper".equals(voxel.state().properties().get("half"))) { + return below != null && below.state().name().equals(TALL_SEAGRASS); + } + return voxel.y() == lowest || support(below); + } + + private static boolean support(Voxel voxel) { + if (voxel == null) { + return false; + } + String name = voxel.state().name(); + return !SEA_PLANTS.contains(name) && !name.endsWith(CORAL) && !name.endsWith(CORAL_FAN) && !name.endsWith(WALL_FAN); + } + + private static int scaledSize(int size, double scale) { + return Math.max(1, (int) Math.ceil(size * scale)); + } + + private static List scale(List voxels, double scale, int sizeX, int sizeY, int sizeZ) { + List sorted = new ArrayList<>(voxels); + sorted.sort(VOXEL_ORDER); + Map scaled = new LinkedHashMap<>(); + for (Voxel voxel : sorted) { + int minX = Math.min(sizeX - 1, (int) Math.floor(voxel.x() * scale)); + int minY = Math.min(sizeY - 1, (int) Math.floor(voxel.y() * scale)); + int minZ = Math.min(sizeZ - 1, (int) Math.floor(voxel.z() * scale)); + int maxX = Math.min(sizeX - 1, Math.max(minX, (int) Math.ceil((voxel.x() + 1) * scale) - 1)); + int maxY = Math.min(sizeY - 1, Math.max(minY, (int) Math.ceil((voxel.y() + 1) * scale) - 1)); + int maxZ = Math.min(sizeZ - 1, Math.max(minZ, (int) Math.ceil((voxel.z() + 1) * scale) - 1)); + for (int y = minY; y <= maxY; y++) { + for (int x = minX; x <= maxX; x++) { + for (int z = minZ; z <= maxZ; z++) { + scaled.put(key(x, y, z), new Voxel(x, y, z, voxel.state(), voxel.nbt(), voxel.root())); + } + } + } + } + return new ArrayList<>(scaled.values()); + } + + private static List anchor(List voxels, VariantSpec.Rotation rotation, int centerX, int centerY, int centerZ) { + BlockStateRotation states = BlockStateRotation.of(rotation.x(), rotation.y(), rotation.z()); + double[] lift = rotate(0, centerY, 0, rotation); + int rotatedCenterY = (int) Math.floor(lift[1]); + List sorted = new ArrayList<>(voxels); + sorted.sort(VOXEL_ORDER); + Map anchored = new LinkedHashMap<>(); + for (Voxel voxel : sorted) { + double[] rotated = rotate(voxel.x() - centerX, voxel.y() - centerY, voxel.z() - centerZ, rotation); + int x = (int) Math.floor(rotated[0]); + int y = (int) Math.floor(rotated[1]) + rotatedCenterY; + int z = (int) Math.floor(rotated[2]); + anchored.put(key(x, y, z), new Voxel(x, y, z, states.apply(voxel.state()), voxel.nbt(), voxel.root())); + } + return new ArrayList<>(anchored.values()); + } + + private static double[] rotate(double x, double y, double z, VariantSpec.Rotation rotation) { + double rx = x; + double ry = y; + double rz = z; + if (rotation.x() != 0) { + double angle = Math.toRadians(rotation.x()); + double cos = cos(angle); + double sin = sin(angle); + double nextY = cos * ry - sin * rz; + rz = sin * ry + cos * rz; + ry = nextY; + } + if (rotation.z() != 0) { + double angle = Math.toRadians(rotation.z()); + double cos = cos(angle); + double sin = sin(angle); + double nextX = cos * rx - sin * ry; + ry = sin * rx + cos * ry; + rx = nextX; + } + if (rotation.y() != 0) { + double angle = Math.toRadians(rotation.y()); + double cos = cos(angle); + double sin = sin(angle); + double nextX = cos * rx + sin * rz; + rz = -sin * rx + cos * rz; + rx = nextX; + } + return new double[]{rx, ry, rz}; + } + + private static double cos(double angle) { + double value = Math.cos(angle); + return Math.abs(value) < 1.0e-12 ? 0 : value; + } + + private static double sin(double angle) { + double value = Math.sin(angle); + return Math.abs(value) < 1.0e-12 ? 0 : value; + } + + private static List warp(List voxels, VariantWarp warp) { + double originX = (double) warp.index() * WARP_SPACING_X; + double originZ = (double) warp.index() * WARP_SPACING_Z; + List sorted = new ArrayList<>(voxels); + sorted.sort(VOXEL_ORDER); + Map warped = new LinkedHashMap<>(); + for (Voxel voxel : sorted) { + double worldX = voxel.x() + originX; + double worldZ = voxel.z() + originZ; + int x = (int) (voxel.x() + warp.field().offset(worldX, voxel.y(), worldZ)); + int z = (int) (voxel.z() + warp.field().offset(worldZ, voxel.y(), worldX)); + warped.put(key(x, voxel.y(), z), new Voxel(x, voxel.y(), z, voxel.state(), voxel.nbt(), voxel.root())); + } + return new ArrayList<>(warped.values()); + } + + private static List snow(List voxels, double snow, long seed) { + Map tops = new TreeMap<>(); + Map occupied = new LinkedHashMap<>(); + for (Voxel voxel : voxels) { + occupied.put(key(voxel.x(), voxel.y(), voxel.z()), voxel); + long column = key(voxel.x(), 0, voxel.z()); + Voxel top = tops.get(column); + if (top == null || top.y() < voxel.y()) { + tops.put(column, voxel); + } + } + int steps = (int) (snow * SNOW_LAYER_STEPS); + List covered = new ArrayList<>(voxels); + for (Voxel top : tops.values()) { + if (occupied.containsKey(key(top.x(), top.y() + 1, top.z()))) { + continue; + } + int index = steps <= 0 ? 0 : (int) Math.floor(unit(seed, top.x(), top.y(), top.z(), 0, SNOW_SALT) * steps); + int layers = Math.max(0, Math.min(index, SNOW_LAYER_STEPS)) + 1; + covered.add(new Voxel(top.x(), top.y() + 1, top.z(), + new VanillaBlockState(SNOW, Map.of("layers", Integer.toString(layers))), null, false)); + } + return covered; + } + + private BakedVariant reshaped(String templateId, List voxels, int baseY, int reach) { + if (voxels.isEmpty()) { + return new BakedVariant(templateId, new TemplateVolume(1, 1, 1, List.of()), 0, TreeRoots.NONE); + } + int extentX = 0; + int extentZ = 0; + int minY = 0; + int maxY = Integer.MIN_VALUE; + for (Voxel voxel : voxels) { + extentX = Math.max(extentX, Math.abs(voxel.x())); + extentZ = Math.max(extentZ, Math.abs(voxel.z())); + minY = Math.min(minY, voxel.y()); + maxY = Math.max(maxY, voxel.y()); + } + List local = new ArrayList<>(voxels.size()); + for (Voxel voxel : voxels) { + local.add(new Voxel(voxel.x() + extentX, voxel.y() - minY, voxel.z() + extentZ, voxel.state(), voxel.nbt(), voxel.root())); + } + TemplateVolume volume = volume(extentX * 2 + 1, maxY - minY + 1, extentZ * 2 + 1, local); + return new BakedVariant(templateId, volume, minY + baseY, roots(local, reach)); + } + + private static TemplateVolume volume(int sizeX, int sizeY, int sizeZ, List voxels) { + List sorted = new ArrayList<>(voxels); + sorted.sort(VOXEL_ORDER); + List blocks = new ArrayList<>(sorted.size()); + for (Voxel voxel : sorted) { + blocks.add(new TemplateBlock(voxel.x(), voxel.y(), voxel.z(), voxel.state(), voxel.nbt())); + } + return new TemplateVolume(sizeX, sizeY, sizeZ, blocks); + } + + private static long key(int x, int y, int z) { + return ((long) (x & 0x1FFFFF) << 42) | ((long) (y & 0x1FFFFF) << 21) | (z & 0x1FFFFF); + } + + private static double unit(long seed, int x, int y, int z, int index, long salt) { + long mixed = mix(seed ^ salt); + mixed = mix(mixed ^ x); + mixed = mix(mixed ^ ((long) y << 21)); + mixed = mix(mixed ^ ((long) z << 42)); + mixed = mix(mixed ^ index); + return (mixed >>> 11) * 0x1.0p-53; + } + + private static long mix(long value) { + long mixed = value + 0x9E3779B97F4A7C15L; + mixed = (mixed ^ (mixed >>> 30)) * 0xBF58476D1CE4E5B9L; + mixed = (mixed ^ (mixed >>> 27)) * 0x94D049BB133111EBL; + return mixed ^ (mixed >>> 31); + } + + private record Voxel(int x, int y, int z, VanillaBlockState state, CompoundTag nbt, boolean root) { + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/PlacementCompiler.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/PlacementCompiler.java new file mode 100644 index 000000000..6bd41245e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/PlacementCompiler.java @@ -0,0 +1,677 @@ +package art.arcane.iris.pack.datapack.export.compile.object; + +import art.arcane.iris.pack.datapack.export.TemplateBlock; +import art.arcane.iris.pack.datapack.export.TemplateVolume; +import art.arcane.iris.pack.datapack.export.VanillaBlockState; +import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; +import art.arcane.iris.pack.datapack.export.compile.WeightedState; +import art.arcane.iris.pack.datapack.export.compile.decoration.GenerationStep; +import art.arcane.iris.pack.datapack.export.compile.decoration.Placements; +import art.arcane.iris.pack.datapack.export.ir.DimensionDef; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.datapack.export.ir.JsonResource; +import art.arcane.iris.pack.datapack.export.lower.ExportCapabilities; +import art.arcane.iris.structure.object.IrisObjectLimit; +import art.arcane.iris.structure.object.IrisObjectPlacement; +import art.arcane.iris.structure.object.ObjectPlaceMode; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +public final class PlacementCompiler { + public static final int MAX_FEATURE_WIDTH = 32; + private static final int TILE_WIDTH = 17; + private static final int WINDOW_REACH = 16; + private static final int MAX_SUPPORT_DROP = 16; + private static final int CAVE_SCAN_STEPS = 32; + private static final int CAVE_FLOOR_MARGIN = 8; + private static final int MIN_ANCHOR = -2032; + private static final int MAX_ANCHOR = 2031; + private static final int DEFAULT_FLUID_HEIGHT = 63; + private static final long WEIGHT_CEILING = 1_000_000L; + private static final long ROTATION_SHARES = 12L; + private static final List QUARTER_NAMES = List.of("none", "counterclockwise_90", "180", "clockwise_90"); + static final List FLUIDS = List.of("minecraft:water", "minecraft:flowing_water", "minecraft:lava", "minecraft:flowing_lava"); + private static final Set STILT_MODES = Set.of(ObjectPlaceMode.STILT, ObjectPlaceMode.FAST_STILT, + ObjectPlaceMode.MIN_STILT, ObjectPlaceMode.FAST_MIN_STILT, ObjectPlaceMode.CENTER_STILT, + ObjectPlaceMode.ERODE_STILT, ObjectPlaceMode.ORGANIC_STILT); + private static final Set VACUUM_MODES = Set.of(ObjectPlaceMode.VACUUM, ObjectPlaceMode.VACUUM_HIGH, + ObjectPlaceMode.VACUUM_FAST, ObjectPlaceMode.VACUUM_ORGANIC, ObjectPlaceMode.VACUUM_WAVY); + + private final String namespace; + private final FeatureJson json; + private final DimensionDef dimension; + private final WideObjectStructures wide; + private final RootExtension roots; + + public PlacementCompiler(String namespace, ExportCapabilities capabilities, DimensionDef dimension) { + this.namespace = VanillaResourceIdentifier.requireNamespace(namespace, "placement namespace"); + json = new FeatureJson(capabilities); + this.dimension = dimension; + wide = new WideObjectStructures(namespace); + roots = new RootExtension(this.namespace, json); + } + + public static boolean stilted(ObjectPlaceMode mode) { + return STILT_MODES.contains(mode); + } + + public CompiledPlacement compile(PlacementInput input, List diagnostics) { + IrisObjectPlacement placement = input.placement(); + GenerationStep step = input.tree() ? GenerationStep.VEGETAL_DECORATION : GenerationStep.SURFACE_STRUCTURES; + int reach = Math.max(Math.abs(placement.getTranslate().getX()), Math.abs(placement.getTranslate().getZ())); + List narrow = new ArrayList<>(); + List wideObjects = new ArrayList<>(); + for (ObjectTemplates object : input.objects()) { + if (object.choices().isEmpty()) { + continue; + } + if (object.footprint() + 2 * reach <= MAX_FEATURE_WIDTH) { + narrow.add(object); + } else { + wideObjects.add(object); + } + } + int total = narrow.size() + wideObjects.size(); + List placed = new ArrayList<>(1); + List structures = new ArrayList<>(1); + List resources = new ArrayList<>(); + if (total == 0) { + return new CompiledPlacement(step, placed, structures, resources); + } + double chance = Math.max(0, Math.min(1, placement.getChance())); + int density = Math.max(1, placement.getDensity()); + if (VACUUM_MODES.contains(placement.getMode())) { + diagnostics.add(new ExportDiagnostic(ExportDiagnostic.Severity.INFO, ExportLimit.OBJECT_VACUUM, input.name(), + "Vacuum terrain shaping is not exported; the object places at its center height")); + } + for (List group : depthGroups(input, narrow)) { + compileNarrow(input, group, chance, density, (double) group.size() / total, resources, placed); + } + if (!narrow.isEmpty() && slopeLimited(input)) { + diagnostics.add(new ExportDiagnostic(ExportDiagnostic.Severity.INFO, ExportLimit.SLOPE, input.name(), + "Object slope conditions measure slope over 3 blocks in the four cardinal directions")); + } + if (!wideObjects.isEmpty()) { + List templates = weighted(wideObjects); + double perChunk = chance * density * wideObjects.size() / total; + int startY = placement.getTranslate().getY() - 1 + lowestBase(templates); + WideObjectStructures.Compiled compiled = wide.compile(input.name(), templates, perChunk, startY, processorList(input.processors())); + resources.addAll(compiled.resources()); + structures.add(compiled.structureId()); + diagnostics.add(new ExportDiagnostic(ExportDiagnostic.Severity.INFO, ExportLimit.WIDE_OBJECT, input.name(), + wideObjects.size() + " objects wider than " + MAX_FEATURE_WIDTH + " blocks export as single-piece structures")); + } + return new CompiledPlacement(step, placed, structures, resources); + } + + private void compileNarrow(PlacementInput input, List narrow, double chance, int density, double share, + List resources, List placed) { + IrisObjectPlacement placement = input.placement(); + List templates = weighted(narrow); + boolean paint = placement.getMode() == ObjectPlaceMode.PAINT && input.anchor() == PlacementInput.Anchor.SURFACE; + List processors = new ArrayList<>(input.processors()); + if (paint) { + processors.add(gravity(placement)); + } + HeightAnchor.Sampling sampling = input.anchor() == PlacementInput.Anchor.SURFACE && !paint + && placement.getMode() != ObjectPlaceMode.FLOATING ? HeightAnchor.sampling(placement.getMode()) : HeightAnchor.Sampling.CENTER; + boolean grounded = grounds(input) && rooted(templates); + boolean branched = grounded || sampling != HeightAnchor.Sampling.CENTER; + JsonObject feature; + if (branched) { + feature = branches(input, templates, processors, sampling, grounded, resources); + } else { + feature = template(templates, processors); + if (stilted(placement.getMode())) { + feature = stilts(feature, templates, input.stiltBases()); + } + } + String name = input.name(); + String featurePath = "object/" + name + "_" + ContentIds.hash(feature); + resources.add(new JsonResource("data/" + namespace + "/" + json.syntax().featureFolder() + "/" + featurePath + ".json", feature)); + JsonObject placedFeature = new JsonObject(); + placedFeature.addProperty("feature", namespace + ":" + featurePath); + placedFeature.add("placement", modifiers(input, templates, chance, density, share, paint, branched)); + String placedPath = "object/" + name + "_" + ContentIds.hash(placedFeature); + resources.add(new JsonResource("data/" + namespace + "/worldgen/placed_feature/" + placedPath + ".json", placedFeature)); + placed.add(namespace + ":" + placedPath); + } + + private JsonArray modifiers(PlacementInput input, List templates, double chance, int density, double share, boolean paint, + boolean branched) { + IrisObjectPlacement placement = input.placement(); + int translateX = placement.getTranslate().getX(); + int translateY = placement.getTranslate().getY(); + int translateZ = placement.getTranslate().getZ(); + int yRandom = placement.getTranslate().getYRandom(); + int spreadLow = yRandom < 0 ? yRandom : 0; + int spreadHigh = yRandom > 0 ? yRandom - 1 : yRandom < 0 ? -1 : 0; + int base = lowestBase(templates); + List modifiers = new ArrayList<>(json.chanceAndCount(chance, density, share)); + modifiers.add(Placements.typed("minecraft:in_square")); + if (input.anchor() == PlacementInput.Anchor.CAVE) { + boolean ceiling = placement.getMode() == ObjectPlaceMode.CEILING_HANG; + modifiers.add(caveRange()); + modifiers.add(environmentScan(ceiling)); + int lift = ceiling ? -tallest(templates) : 0; + modifiers.addAll(json.offset(translateX, lift + translateY + base + spreadLow, lift + translateY + base + spreadHigh, translateZ)); + } else if (placement.getMode() == ObjectPlaceMode.FLOATING) { + modifiers.add(fixedHeight(translateY + base + spreadLow, translateY + base + spreadHigh)); + } else { + boolean underwater = placement.isUnderwater(); + boolean onwater = onwater(placement); + modifiers.add(Placements.heightmap(onwater ? Placements.WORLD_SURFACE : Placements.OCEAN_FLOOR)); + modifiers.add(Placements.filter(onwater ? Placements.water(0, -1, 0) : ground(underwater))); + if (underwater) { + modifiers.add(Placements.filter(belowSurface(fluidCeiling(templates, translateY)))); + } + if (!underwater && !onwater && !placement.isForcePlace() && placement.isRequireSurfaceSupport() && !stilted(placement.getMode())) { + JsonObject support = support(templates, placement); + if (support != null) { + modifiers.add(Placements.filter(support)); + } + } + if (slopeLimited(input)) { + for (JsonObject limit : Placements.slopeLimits(placement.getSlopeCondition())) { + modifiers.add(Placements.filter(limit)); + } + } + if (paint) { + modifiers.addAll(json.offset(translateX, 0, 0, translateZ)); + } else if (!branched) { + modifiers.addAll(surfaceOffset(placement, base)); + } + } + JsonObject clamp = clamp(placement.getClamp()); + if (clamp != null && !branched) { + modifiers.add(Placements.filter(clamp)); + } + modifiers.add(Placements.typed("minecraft:biome")); + JsonArray array = new JsonArray(modifiers.size()); + for (JsonObject modifier : modifiers) { + array.add(modifier); + } + return array; + } + + private List surfaceOffset(IrisObjectPlacement placement, int base) { + int yRandom = placement.getTranslate().getYRandom(); + int spreadLow = yRandom < 0 ? yRandom : 0; + int spreadHigh = yRandom > 0 ? yRandom - 1 : yRandom < 0 ? -1 : 0; + int lift = placement.getTranslate().getY() - 1 + base; + return json.offset(placement.getTranslate().getX(), lift + spreadLow, lift + spreadHigh, placement.getTranslate().getZ()); + } + + private JsonObject branches(PlacementInput input, List templates, List processors, + HeightAnchor.Sampling sampling, boolean grounded, List resources) { + IrisObjectPlacement placement = input.placement(); + Map shared = new TreeMap<>(); + Branching branching = new Branching(input, processors, sampling, grounded, surfaceOffset(placement, lowestBase(templates)), + clamp(placement.getClamp()), shared); + List branches = new ArrayList<>(); + List weights = new ArrayList<>(); + for (TemplateChoice choice : templates) { + long weight = grounded ? choice.weight() * (ROTATION_SHARES / choice.quarters().size()) : choice.weight(); + List> groups = new ArrayList<>(choice.quarters().size()); + if (grounded) { + for (int quarter : choice.quarters()) { + groups.add(List.of(quarter)); + } + } else { + groups.add(choice.quarters()); + } + for (List quarters : groups) { + branches.add(branch(branching, choice.variant(), quarters)); + weights.add(weight); + } + } + resources.addAll(shared.values()); + long divisor = 0; + for (long weight : weights) { + divisor = BigInteger.valueOf(divisor).gcd(BigInteger.valueOf(weight)).longValue(); + } + JsonArray entries = new JsonArray(branches.size()); + for (int index = 0; index < branches.size(); index++) { + JsonObject entry = new JsonObject(); + entry.add("data", branches.get(index)); + entry.addProperty("weight", weights.get(index) / divisor); + entries.add(entry); + } + JsonObject fields = new JsonObject(); + fields.add("features", entries); + return json.syntax().feature("minecraft:weighted_random_selector", fields); + } + + private JsonObject branch(Branching branching, BakedVariant variant, List quarters) { + IrisObjectPlacement placement = branching.input().placement(); + TemplateChoice single = new TemplateChoice(variant, quarters, 1); + JsonObject feature = template(List.of(single), branching.processors()); + List modifiers = new ArrayList<>(); + if (branching.sampling() != HeightAnchor.Sampling.CENTER) { + modifiers.add(HeightAnchor.scan(branching.sampling(), HeightAnchor.fast(placement.getMode()), placement.isUnderwater(), + variant.volume(), quarters, new TemplateColumn(placement.getTranslate().getX(), placement.getTranslate().getZ()))); + } + modifiers.addAll(branching.offset()); + if (branching.grounded() && variant.roots().present()) { + int quarter = quarters.getFirst(); + feature = overlay(feature, roots.pillars(variant.roots(), variant.volume(), quarter, branching.shared())); + JsonObject gap = RootExtension.gap(variant.roots(), variant.volume(), quarter); + if (gap != null) { + modifiers.add(Placements.filter(gap)); + } + } else if (stilted(placement.getMode())) { + feature = stilts(feature, List.of(single), branching.input().stiltBases()); + } + if (branching.clamp() != null) { + modifiers.add(Placements.filter(branching.clamp().deepCopy())); + } + JsonObject branch = new JsonObject(); + branch.add("feature", feature); + branch.add("placement", array(modifiers)); + return branch; + } + + private JsonObject overlay(JsonObject template, List parts) { + JsonArray features = new JsonArray(parts.size() + 1); + JsonObject anchored = new JsonObject(); + anchored.add("feature", template); + anchored.add("placement", new JsonArray()); + features.add(anchored); + for (JsonObject part : parts) { + features.add(part); + } + JsonObject fields = new JsonObject(); + fields.add("features", features); + return json.syntax().feature("minecraft:overlay", fields); + } + + private static boolean grounds(PlacementInput input) { + IrisObjectPlacement placement = input.placement(); + ObjectPlaceMode mode = placement.getMode(); + return input.anchor() == PlacementInput.Anchor.SURFACE && !placement.isForcePlace() && !placement.isFromBottom() + && mode != ObjectPlaceMode.PAINT && mode != ObjectPlaceMode.FLOATING && mode != ObjectPlaceMode.CEILING_HANG + && mode != ObjectPlaceMode.STRUCTURE_PIECE; + } + + private static boolean rooted(List templates) { + for (TemplateChoice template : templates) { + if (template.variant().roots().present()) { + return true; + } + } + return false; + } + + private static JsonArray array(List values) { + JsonArray array = new JsonArray(values.size()); + for (JsonObject value : values) { + array.add(value); + } + return array; + } + + private static boolean slopeLimited(PlacementInput input) { + IrisObjectPlacement placement = input.placement(); + return input.anchor() == PlacementInput.Anchor.SURFACE && placement.getMode() != ObjectPlaceMode.FLOATING + && !placement.isForcePlace() && !Placements.slopeLimits(placement.getSlopeCondition()).isEmpty(); + } + + private JsonObject template(List templates, List processors) { + JsonArray entries = new JsonArray(templates.size()); + for (TemplateChoice template : templates) { + JsonObject data = new JsonObject(); + data.addProperty("id", template.variant().id()); + if (template.quarters().size() < QUARTER_NAMES.size()) { + JsonArray rotations = new JsonArray(template.quarters().size()); + for (int quarter : template.quarters()) { + rotations.add(QUARTER_NAMES.get(quarter)); + } + data.add("rotations", rotations); + } + JsonObject entry = new JsonObject(); + entry.add("data", data); + entry.addProperty("weight", template.weight()); + entries.add(entry); + } + JsonObject fields = new JsonObject(); + fields.add("templates", entries); + if (!processors.isEmpty()) { + fields.add("processors", processorList(processors)); + } + return json.syntax().feature("minecraft:template", fields); + } + + private JsonObject stilts(JsonObject template, List templates, List bases) { + JsonArray features = new JsonArray(); + JsonObject anchored = new JsonObject(); + anchored.add("feature", template); + anchored.add("placement", new JsonArray()); + features.add(anchored); + int footprint = 1; + for (TemplateChoice choice : templates) { + footprint = Math.max(footprint, choice.variant().volume().footprint()); + } + int start = -(footprint / 2); + int end = footprint - 1 - footprint / 2; + int tiles = (int) Math.ceil(footprint / (double) TILE_WIDTH); + int width = (int) Math.ceil(footprint / (double) tiles); + int size = Math.max(1, width - 1); + JsonObject canReplace = canReplace(); + JsonObject pillar = pillar(bases, canReplace); + JsonObject gate = Placements.allOf(List.of(Placements.not(Placements.air(0, 1, 0)), canReplace)); + for (int tileX = 0; tileX < tiles; tileX++) { + for (int tileZ = 0; tileZ < tiles; tileZ++) { + int x = Math.min(start + tileX * width, end - size); + int z = Math.min(start + tileZ * width, end - size); + JsonArray placement = new JsonArray(3); + JsonObject offset = Placements.typed("minecraft:offset"); + offset.addProperty("x", x); + offset.addProperty("y", -2); + offset.addProperty("z", z); + placement.add(offset); + JsonObject cuboid = Placements.typed("minecraft:cuboid"); + cuboid.addProperty("xz_size", size); + cuboid.addProperty("y_size", 1); + placement.add(cuboid); + placement.add(Placements.filter(gate)); + JsonObject tile = new JsonObject(); + tile.add("feature", pillar); + tile.add("placement", placement); + features.add(tile); + } + } + JsonObject fields = new JsonObject(); + fields.add("features", features); + return json.syntax().feature("minecraft:overlay", fields); + } + + private JsonObject pillar(List bases, JsonObject canReplace) { + JsonObject provider; + if (bases.isEmpty()) { + VanillaBlockState fill = VanillaBlockState.parse(dimension == null ? "minecraft:stone" : dimension.defaultBlock()); + provider = json.syntax().provider(List.of(new WeightedState(fill, 1))); + } else { + JsonArray rules = new JsonArray(bases.size()); + for (VanillaBlockState base : bases) { + JsonObject match = Placements.typed("minecraft:matching_blocks"); + match.addProperty("blocks", base.name()); + match.add("offset", FeatureJson.vector(0, 1, 0)); + JsonObject rule = new JsonObject(); + rule.add("if_true", match); + rule.add("then", json.syntax().provider(List.of(new WeightedState(base, 1)))); + rules.add(rule); + } + provider = new JsonObject(); + provider.addProperty("type", "minecraft:rule_based"); + provider.add("fallback", json.syntax().provider(List.of(new WeightedState(bases.getFirst(), 1)))); + provider.add("rules", rules); + } + JsonObject fields = new JsonObject(); + fields.add("block", provider); + fields.add("can_replace", canReplace); + fields.addProperty("direction", "down"); + return json.syntax().feature("minecraft:single_block_pillar", fields); + } + + static JsonObject canReplace() { + return Placements.anyOf(List.of(Placements.air(0, 0, 0), Placements.fluids(FLUIDS, 0, 0, 0), Placements.typed("minecraft:replaceable"))); + } + + private static JsonObject ground(boolean underwater) { + List rules = new ArrayList<>(4); + rules.add(Placements.solid(0, -1, 0)); + rules.add(Placements.not(Placements.blockTag("minecraft:leaves", 0, -1, 0))); + rules.add(Placements.not(Placements.blockTag("minecraft:logs", 0, -1, 0))); + rules.add(underwater ? Placements.water(0, 0, 0) : Placements.not(Placements.fluids(FLUIDS, 0, 0, 0))); + return Placements.allOf(rules); + } + + private static boolean onwater(IrisObjectPlacement placement) { + return placement.isOnwater() && !placement.isUnderwater(); + } + + private List> depthGroups(PlacementInput input, List narrow) { + IrisObjectPlacement placement = input.placement(); + if (narrow.isEmpty() || !placement.isUnderwater() || input.anchor() == PlacementInput.Anchor.CAVE + || placement.getMode() == ObjectPlaceMode.FLOATING) { + return narrow.isEmpty() ? List.of() : List.of(narrow); + } + Map> groups = new TreeMap<>(); + for (ObjectTemplates object : narrow) { + groups.computeIfAbsent(fluidCeiling(object.choices(), placement.getTranslate().getY()), ignored -> new ArrayList<>()).add(object); + } + return new ArrayList<>(groups.values()); + } + + private int fluidCeiling(List templates, int translateY) { + int reach = 0; + for (TemplateChoice template : templates) { + reach = Math.max(reach, 3 * (rows(template.variant().volume()) / 2)); + } + return (dimension == null ? DEFAULT_FLUID_HEIGHT : dimension.fluidHeight()) - reach - translateY; + } + + private JsonObject belowSurface(int ceiling) { + int bottom = dimension == null ? MIN_ANCHOR : dimension.minY(); + JsonObject predicate = Placements.typed("minecraft:height_range"); + predicate.add("max_inclusive", FeatureJson.absolute(Math.max(MIN_ANCHOR, Math.min(MAX_ANCHOR, ceiling)))); + predicate.add("min_inclusive", FeatureJson.absolute(Math.max(MIN_ANCHOR, Math.min(bottom, ceiling)))); + return predicate; + } + + private static int rows(TemplateVolume volume) { + int low = Integer.MAX_VALUE; + int high = Integer.MIN_VALUE; + for (TemplateBlock block : volume.blocks()) { + low = Math.min(low, block.y()); + high = Math.max(high, block.y()); + } + return low > high ? 1 : high - low + 1; + } + + private static JsonObject gravity(IrisObjectPlacement placement) { + JsonObject gravity = new JsonObject(); + gravity.addProperty("processor_type", "minecraft:gravity"); + gravity.addProperty("heightmap", onwater(placement) ? Placements.WORLD_SURFACE : Placements.OCEAN_FLOOR); + gravity.addProperty("offset", placement.getTranslate().getY() - 1); + return gravity; + } + + private static JsonObject processorList(List processors) { + JsonArray list = new JsonArray(processors.size()); + for (JsonObject processor : processors) { + list.add(processor.deepCopy()); + } + JsonObject wrapper = new JsonObject(); + wrapper.add("processors", list); + return wrapper; + } + + private static JsonObject support(List templates, IrisObjectPlacement placement) { + int minX = 0; + int maxX = 0; + int minZ = 0; + int maxZ = 0; + for (TemplateChoice template : templates) { + TemplateVolume volume = template.variant().volume(); + int base = baseRow(volume); + for (TemplateBlock block : volume.blocks()) { + if (block.y() != base || foliage(block.state())) { + continue; + } + int x = block.x() - volume.sizeX() / 2; + int z = block.z() - volume.sizeZ() / 2; + for (int quarter : template.quarters()) { + int rotatedX = switch (quarter) { + case 1 -> z; + case 2 -> -x; + case 3 -> -z; + default -> x; + }; + int rotatedZ = switch (quarter) { + case 1 -> -x; + case 2 -> -z; + case 3 -> x; + default -> z; + }; + minX = Math.min(minX, rotatedX); + maxX = Math.max(maxX, rotatedX); + minZ = Math.min(minZ, rotatedZ); + maxZ = Math.max(maxZ, rotatedZ); + } + } + } + int buffer = Math.max(0, placement.getSurfaceSupportBuffer()); + int thickness = Math.max(1, Math.min(MAX_SUPPORT_DROP, placement.getSurfaceSupportDepth())); + int[] xs = {reach(minX - buffer), reach(maxX + buffer)}; + int[] zs = {reach(minZ - buffer), reach(maxZ + buffer)}; + Set seen = new LinkedHashSet<>(); + List corners = new ArrayList<>(4); + for (int x : xs) { + for (int z : zs) { + if ((x == 0 && z == 0) || !seen.add(((long) x << 32) | (z & 0xFFFFFFFFL))) { + continue; + } + corners.add(groundBelow(x, z, thickness)); + } + } + return corners.isEmpty() ? null : Placements.allOf(corners); + } + + private static JsonObject groundBelow(int x, int z, int thickness) { + List tops = new ArrayList<>(MAX_SUPPORT_DROP - thickness + 1); + for (int top = 1; top + thickness - 1 <= MAX_SUPPORT_DROP; top++) { + if (thickness == 1) { + tops.add(Placements.solid(x, -top, z)); + continue; + } + List layers = new ArrayList<>(thickness); + for (int layer = 0; layer < thickness; layer++) { + layers.add(Placements.solid(x, -(top + layer), z)); + } + tops.add(Placements.allOf(layers)); + } + return Placements.anyOf(tops); + } + + private static int baseRow(TemplateVolume volume) { + int base = Integer.MAX_VALUE; + for (TemplateBlock block : volume.blocks()) { + if (!foliage(block.state())) { + base = Math.min(base, block.y()); + } + } + return base; + } + + private static boolean foliage(VanillaBlockState state) { + return state.name().endsWith("_leaves"); + } + + private static int reach(int offset) { + return Math.max(-WINDOW_REACH, Math.min(WINDOW_REACH, offset)); + } + + private JsonObject clamp(IrisObjectLimit limit) { + if (limit == null) { + return null; + } + int bottom = dimension == null ? MIN_ANCHOR : dimension.minY(); + int top = dimension == null ? MAX_ANCHOR : dimension.minY() + dimension.height() - 1; + int min = Math.max(MIN_ANCHOR, limit.getMinimumHeight()); + int max = Math.min(MAX_ANCHOR, limit.getMaximumHeight()); + if (min <= bottom && max >= top) { + return null; + } + JsonObject predicate = Placements.typed("minecraft:height_range"); + predicate.add("max_inclusive", FeatureJson.absolute(Math.max(min, max))); + predicate.add("min_inclusive", FeatureJson.absolute(Math.min(min, max))); + return predicate; + } + + private JsonObject caveRange() { + int floor = dimension == null ? 0 : dimension.minY() + CAVE_FLOOR_MARGIN; + int ceiling = dimension == null ? DEFAULT_FLUID_HEIGHT : dimension.fluidHeight(); + JsonObject height = new JsonObject(); + height.addProperty("type", "minecraft:uniform"); + height.add("max_inclusive", FeatureJson.absolute(Math.max(floor, ceiling))); + height.add("min_inclusive", FeatureJson.absolute(Math.min(floor, ceiling))); + JsonObject modifier = Placements.typed("minecraft:height_range"); + modifier.add("height", height); + return modifier; + } + + private static JsonObject environmentScan(boolean ceiling) { + JsonObject scan = Placements.typed("minecraft:environment_scan"); + scan.add("allowed_search_condition", Placements.air(0, 0, 0)); + scan.addProperty("direction_of_search", ceiling ? "up" : "down"); + scan.addProperty("max_steps", CAVE_SCAN_STEPS); + scan.add("target_condition", Placements.typed("minecraft:solid")); + return scan; + } + + private static JsonObject fixedHeight(int low, int high) { + JsonObject modifier = Placements.typed("minecraft:height_range"); + int clampedLow = Math.max(MIN_ANCHOR, Math.min(MAX_ANCHOR, low)); + int clampedHigh = Math.max(MIN_ANCHOR, Math.min(MAX_ANCHOR, high)); + if (clampedLow == clampedHigh) { + modifier.add("height", FeatureJson.absolute(clampedLow)); + return modifier; + } + JsonObject height = new JsonObject(); + height.addProperty("type", "minecraft:uniform"); + height.add("max_inclusive", FeatureJson.absolute(clampedHigh)); + height.add("min_inclusive", FeatureJson.absolute(clampedLow)); + modifier.add("height", height); + return modifier; + } + + static List weighted(List objects) { + BigInteger common = BigInteger.ONE; + for (ObjectTemplates object : objects) { + BigInteger total = BigInteger.valueOf(object.totalWeight()); + common = common.multiply(total).divide(common.gcd(total)); + } + boolean exact = common.compareTo(BigInteger.valueOf(WEIGHT_CEILING)) <= 0; + List weighted = new ArrayList<>(); + for (ObjectTemplates object : objects) { + long total = object.totalWeight(); + for (TemplateChoice choice : object.choices()) { + long weight = exact + ? common.longValue() / total * choice.weight() + : Math.max(1, Math.round(WEIGHT_CEILING * (double) choice.weight() / total)); + weighted.add(new TemplateChoice(choice.variant(), choice.quarters(), weight)); + } + } + return weighted; + } + + private static int lowestBase(List templates) { + int base = 0; + for (TemplateChoice template : templates) { + base = Math.min(base, template.variant().baseY()); + } + return base; + } + + private static int tallest(List templates) { + int tallest = 1; + for (TemplateChoice template : templates) { + tallest = Math.max(tallest, template.variant().volume().sizeY()); + } + return tallest; + } + + private record Branching(PlacementInput input, List processors, HeightAnchor.Sampling sampling, boolean grounded, + List offset, JsonObject clamp, Map shared) { + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/PlacementInput.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/PlacementInput.java new file mode 100644 index 000000000..af2573e32 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/PlacementInput.java @@ -0,0 +1,26 @@ +package art.arcane.iris.pack.datapack.export.compile.object; + +import art.arcane.iris.pack.datapack.export.VanillaBlockState; +import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; +import art.arcane.iris.structure.object.IrisObjectPlacement; +import com.google.gson.JsonObject; + +import java.util.List; +import java.util.Objects; + +public record PlacementInput(IrisObjectPlacement placement, Anchor anchor, boolean tree, List objects, + List processors, List stiltBases, String name) { + public PlacementInput { + Objects.requireNonNull(placement, "Object placement"); + Objects.requireNonNull(anchor, "Placement anchor"); + objects = List.copyOf(objects); + processors = List.copyOf(processors); + stiltBases = List.copyOf(stiltBases); + VanillaResourceIdentifier.requirePath(name, "placement name"); + } + + public enum Anchor { + SURFACE, + CAVE + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/RootExtension.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/RootExtension.java new file mode 100644 index 000000000..9f6af038f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/RootExtension.java @@ -0,0 +1,92 @@ +package art.arcane.iris.pack.datapack.export.compile.object; + +import art.arcane.iris.pack.datapack.export.TemplateBlock; +import art.arcane.iris.pack.datapack.export.TemplateVolume; +import art.arcane.iris.pack.datapack.export.VanillaBlockState; +import art.arcane.iris.pack.datapack.export.compile.WeightedState; +import art.arcane.iris.pack.datapack.export.compile.decoration.Placements; +import art.arcane.iris.pack.datapack.export.ir.JsonResource; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +final class RootExtension { + private static final int PROBE_LIMIT = 16; + private static final int QUARTER_DEGREES = 90; + + private final String namespace; + private final FeatureJson json; + + RootExtension(String namespace, FeatureJson json) { + this.namespace = namespace; + this.json = json; + } + + static JsonObject gap(TreeRoots roots, TemplateVolume volume, int quarter) { + List columns = new ArrayList<>(roots.cells().size()); + for (TemplateBlock cell : roots.cells()) { + TemplateColumn column = TemplateColumn.of(volume, cell.x(), cell.z(), quarter); + int top = Math.min(PROBE_LIMIT, cell.y() - 1); + int bottom = Math.max(-PROBE_LIMIT, cell.y() - 1 - roots.reach()); + if (bottom > top || Math.abs(column.x()) > PROBE_LIMIT || Math.abs(column.z()) > PROBE_LIMIT) { + continue; + } + JsonObject open = Placements.typed("minecraft:volume_match"); + open.add("min", FeatureJson.vector(column.x(), bottom, column.z())); + open.add("max", FeatureJson.vector(column.x(), top, column.z())); + open.add("match", Placements.not(Placements.solid(0, 0, 0))); + columns.add(Placements.not(open)); + } + return columns.isEmpty() ? null : Placements.allOf(columns); + } + + List pillars(TreeRoots roots, TemplateVolume volume, int quarter, Map shared) { + BlockStateRotation rotation = BlockStateRotation.of(0, quarter * QUARTER_DEGREES, 0); + List pillars = new ArrayList<>(roots.cells().size()); + for (TemplateBlock cell : roots.cells()) { + TemplateColumn column = TemplateColumn.of(volume, cell.x(), cell.z(), quarter); + JsonObject entry = new JsonObject(); + entry.addProperty("feature", pillar(rotation.apply(cell.state()), shared)); + JsonArray placement = new JsonArray(); + for (JsonObject offset : json.offset(column.x(), cell.y() - 1, cell.y() - 1, column.z())) { + placement.add(offset); + } + entry.add("placement", placement); + pillars.add(entry); + } + return pillars; + } + + private String pillar(VanillaBlockState state, Map shared) { + JsonObject block = json.syntax().provider(List.of(new WeightedState(state, 1))); + JsonObject cap = new JsonObject(); + JsonObject capFields = new JsonObject(); + capFields.add("to_place", block); + cap.add("feature", json.syntax().feature("minecraft:simple_block", capFields)); + JsonArray capPlacement = new JsonArray(1); + capPlacement.add(Placements.verticalOffset(-1)); + cap.add("placement", capPlacement); + JsonObject canReplace = PlacementCompiler.canReplace(); + JsonObject columnFields = new JsonObject(); + columnFields.add("block", block); + columnFields.add("can_replace", canReplace); + columnFields.addProperty("direction", "down"); + columnFields.add("cap_feature", cap); + JsonObject gated = new JsonObject(); + gated.add("feature", json.syntax().feature("minecraft:single_block_pillar", columnFields)); + JsonArray gate = new JsonArray(1); + gate.add(Placements.filter(canReplace)); + gated.add("placement", gate); + JsonArray features = new JsonArray(1); + features.add(gated); + JsonObject overlayFields = new JsonObject(); + overlayFields.add("features", features); + JsonObject feature = json.syntax().feature("minecraft:overlay", overlayFields); + String path = "object/root/" + ContentIds.hash(feature); + shared.putIfAbsent(path, new JsonResource("data/" + namespace + "/" + json.syntax().featureFolder() + "/" + path + ".json", feature)); + return namespace + ":" + path; + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/TemplateChoice.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/TemplateChoice.java new file mode 100644 index 000000000..8feece52b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/TemplateChoice.java @@ -0,0 +1,22 @@ +package art.arcane.iris.pack.datapack.export.compile.object; + +import java.util.List; +import java.util.Objects; + +public record TemplateChoice(BakedVariant variant, List quarters, long weight) { + public TemplateChoice { + Objects.requireNonNull(variant, "Template choice variant"); + quarters = List.copyOf(quarters); + if (quarters.isEmpty()) { + throw new IllegalArgumentException("Template choice " + variant.id() + " needs at least one rotation"); + } + for (int quarter : quarters) { + if (quarter < 0 || quarter > 3) { + throw new IllegalArgumentException("Template rotation quarter must be within [0, 3], got " + quarter); + } + } + if (weight < 1) { + throw new IllegalArgumentException("Template choice " + variant.id() + " needs a positive weight, got " + weight); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/TemplateColumn.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/TemplateColumn.java new file mode 100644 index 000000000..fcce7a6da --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/TemplateColumn.java @@ -0,0 +1,18 @@ +package art.arcane.iris.pack.datapack.export.compile.object; + +import art.arcane.iris.pack.datapack.export.TemplateVolume; + +record TemplateColumn(int x, int z) { + static TemplateColumn of(TemplateVolume volume, int templateX, int templateZ, int quarter) { + return new TemplateColumn(templateX - volume.sizeX() / 2, templateZ - volume.sizeZ() / 2).turn(quarter); + } + + TemplateColumn turn(int quarter) { + return switch (quarter) { + case 1 -> new TemplateColumn(z, -x); + case 2 -> new TemplateColumn(-x, -z); + case 3 -> new TemplateColumn(-z, x); + default -> this; + }; + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/TreeRoots.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/TreeRoots.java new file mode 100644 index 000000000..cfd4c7122 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/TreeRoots.java @@ -0,0 +1,51 @@ +package art.arcane.iris.pack.datapack.export.compile.object; + +import art.arcane.iris.pack.datapack.export.TemplateBlock; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public record TreeRoots(List cells, int reach) { + private static final int MAX_REACH = 16; + private static final Comparator COLUMN_ORDER = Comparator + .comparingInt(TemplateBlock::x) + .thenComparingInt(TemplateBlock::z); + public static final TreeRoots NONE = new TreeRoots(List.of(), 0); + + public TreeRoots { + cells = lowestPerColumn(cells); + reach = Math.max(0, Math.min(MAX_REACH, reach)); + } + + public boolean present() { + return !cells.isEmpty(); + } + + public TreeRoots lift(int dy) { + if (dy == 0 || cells.isEmpty()) { + return this; + } + List lifted = new ArrayList<>(cells.size()); + for (TemplateBlock cell : cells) { + lifted.add(new TemplateBlock(cell.x(), cell.y() + dy, cell.z(), cell.state(), null)); + } + return new TreeRoots(lifted, reach); + } + + private static List lowestPerColumn(List cells) { + Map columns = new HashMap<>(); + for (TemplateBlock cell : cells) { + long key = ((long) cell.x() << 32) | (cell.z() & 0xFFFFFFFFL); + TemplateBlock previous = columns.get(key); + if (previous == null || cell.y() < previous.y()) { + columns.put(key, new TemplateBlock(cell.x(), cell.y(), cell.z(), cell.state(), null)); + } + } + List lowest = new ArrayList<>(columns.values()); + lowest.sort(COLUMN_ORDER); + return List.copyOf(lowest); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/VariantPlanner.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/VariantPlanner.java new file mode 100644 index 000000000..2d7a79d9a --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/VariantPlanner.java @@ -0,0 +1,263 @@ +package art.arcane.iris.pack.datapack.export.compile.object; + +import art.arcane.iris.generation.block.IrisBlockData; +import art.arcane.iris.generation.noise.IrisGeneratorStyle; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.pack.datapack.export.VanillaBlockState; +import art.arcane.iris.pack.datapack.export.compile.WeightedState; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.structure.object.IrisAxisRotationClamp; +import art.arcane.iris.structure.object.IrisObjectPlacement; +import art.arcane.iris.structure.object.IrisObjectReplace; +import art.arcane.iris.structure.object.IrisObjectRotation; +import art.arcane.iris.structure.object.IrisObjectScale; +import art.arcane.iris.structure.object.ObjectPlaceMode; +import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.noise.CNG; +import com.google.gson.Gson; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +final class VariantPlanner { + private static final int MAX_VARIANTS = 32; + private static final int MAX_SCALE_VARIANTS = 32; + private static final int WARP_VARIANTS = 3; + private static final int SPIN_STEPS = 360; + private static final double FULL_TURN = 360; + private static final double QUARTER = 90; + private static final double ANGLE_PRECISION = 1.0e6; + private static final Gson GSON = new Gson(); + + private final IrisDimension dimension; + private final IrisData data; + private final boolean preventLeafDecay; + + VariantPlanner(IrisDimension dimension, IrisData data, boolean preventLeafDecay) { + this.dimension = dimension; + this.data = data; + this.preventLeafDecay = preventLeafDecay; + } + + Plan plan(IrisObjectPlacement placement, String resource, List diagnostics) { + List baked = new ArrayList<>(); + List processed = new ArrayList<>(); + for (IrisObjectReplace replace : placement.getEdit()) { + ObjectEdit edit = edit(replace); + if (edit == null) { + continue; + } + if (edit.deterministic()) { + baked.add(edit); + } else { + processed.add(edit); + } + } + List scales = scales(placement); + IrisObjectRotation rotation = placement.getRotation() == null ? new IrisObjectRotation() : placement.getRotation(); + Map xAngles = angles(rotation.getXAxis(), rotation.isEnabled()); + if (placement.getMode() == ObjectPlaceMode.CEILING_HANG) { + xAngles = flipped(xAngles); + } + Map zAngles = angles(rotation.getZAxis(), rotation.isEnabled()); + List yChoices = yChoices(angles(rotation.getYAxis(), rotation.isEnabled())); + List warps = warps(placement); + List variants = new ArrayList<>(); + boolean reshaped = false; + for (double scale : scales) { + for (Map.Entry x : xAngles.entrySet()) { + for (Map.Entry z : zAngles.entrySet()) { + for (YChoice y : yChoices) { + for (VariantWarp warp : warps) { + VariantSpec.Rotation spin = new VariantSpec.Rotation(x.getKey(), y.residue(), z.getKey()); + VariantSpec spec = VariantSpec.builder() + .scale(scale) + .rotation(spin) + .warp(warp) + .snow(Math.max(0, Math.min(1, placement.getSnow()))) + .edits(baked) + .preventLeafDecay(preventLeafDecay) + .build(); + reshaped |= scale != 1 || !spin.none() || warp != null; + variants.add(new PlannedVariant(spec, y.quarters(), (long) x.getValue() * z.getValue() * y.weight())); + } + } + } + } + } + int planned = variants.size(); + if (planned > MAX_VARIANTS) { + variants = subset(variants); + } + if (reshaped) { + diagnostics.add(new ExportDiagnostic(ExportDiagnostic.Severity.INFO, ExportLimit.OBJECT_VARIANT, resource, + "Rotation, scale and warp export as " + variants.size() + " fixed variants" + + (planned > variants.size() ? " out of " + planned : ""))); + } + return new Plan(variants, processed); + } + + static Map angles(IrisAxisRotationClamp clamp, boolean enabled) { + Map angles = new TreeMap<>(); + if (!enabled || clamp == null || !clamp.isEnabled()) { + angles.put(0.0, 1); + return angles; + } + if (clamp.isForceLock() || clamp.isLocked()) { + angles.put(normalize(clamp.getMax()), 1); + return angles; + } + boolean unlimited = clamp.isUnlimited(); + double interval = unlimited ? Math.max(1, clamp.getInterval()) : clamp.getInterval() <= 0 ? 1 : clamp.getInterval(); + double low = Math.min(clamp.getMin(), clamp.getMax()); + double high = Math.max(clamp.getMin(), clamp.getMax()); + for (int spin = 0; spin < SPIN_STEPS; spin++) { + double angle = (interval * Math.ceil(spin / interval)) % FULL_TURN; + if (!unlimited) { + angle = Math.max(low, Math.min(high, angle)); + } + angles.merge(normalize(angle), 1, Integer::sum); + } + return angles; + } + + static List yChoices(Map angles) { + Map> residues = new TreeMap<>(); + for (Map.Entry angle : angles.entrySet()) { + int quarter = (int) Math.floor(angle.getKey() / QUARTER) % 4; + double residue = round(angle.getKey() - quarter * QUARTER); + residues.computeIfAbsent(residue, ignored -> new TreeMap<>()).merge(quarter, angle.getValue(), Integer::sum); + } + List choices = new ArrayList<>(); + for (Map.Entry> residue : residues.entrySet()) { + Map quarters = residue.getValue(); + int first = quarters.values().iterator().next(); + boolean even = true; + int total = 0; + for (int count : quarters.values()) { + even &= count == first; + total += count; + } + if (even) { + choices.add(new YChoice(residue.getKey(), List.copyOf(quarters.keySet()), total)); + continue; + } + for (Map.Entry quarter : quarters.entrySet()) { + choices.add(new YChoice(residue.getKey(), List.of(quarter.getKey()), quarter.getValue())); + } + } + return choices; + } + + private List scales(IrisObjectPlacement placement) { + IrisObjectScale scale = placement.getScale(); + if (scale == null) { + return List.of(dimension == null ? 1.0 : dimension.getAllObjectScaleFactor()); + } + if (!scale.shouldScale()) { + return List.of(1.0); + } + if (scale.getSize() != 1) { + return List.of(scale.getSize()); + } + if (scale.getMinimumScale() == scale.getMaximumScale()) { + return List.of(scale.getMinimumScale()); + } + int count = Math.max(1, Math.min(scale.getVariations(), MAX_SCALE_VARIANTS)); + List scales = new ArrayList<>(count); + double step = (scale.getMaximumScale() - scale.getMinimumScale()) / count; + for (int index = 0; index < count; index++) { + scales.add(scale.getMinimumScale() + step * index); + } + return scales; + } + + private List warps(IrisObjectPlacement placement) { + IrisGeneratorStyle style = placement.getWarp(); + List warps = new ArrayList<>(WARP_VARIANTS); + if (style == null || style.isFlat()) { + warps.add(null); + return warps; + } + String key = ContentIds.hash(GSON.toJson(style)); + CNG generator = style.create(new RNG(ContentIds.seed(key)), data); + double half = style.getMultiplier() / 2; + WarpField field = (x, y, z) -> generator.fitDouble(-half, half, x, y, z); + for (int index = 0; index < WARP_VARIANTS; index++) { + warps.add(new VariantWarp(key, index, field)); + } + return warps; + } + + private static ObjectEdit edit(IrisObjectReplace replace) { + if (replace == null || replace.getReplace() == null || replace.getReplace().getPalette() == null) { + return null; + } + List find = new ArrayList<>(); + for (IrisBlockData block : replace.getFind()) { + VanillaBlockState state = state(block); + if (state != null) { + find.add(state); + } + } + List palette = new ArrayList<>(); + for (IrisBlockData block : replace.getReplace().getPalette()) { + VanillaBlockState state = state(block); + if (state != null) { + palette.add(new WeightedState(state, Math.max(1, block.getWeight()))); + } + } + if (find.isEmpty() || palette.isEmpty()) { + return null; + } + return new ObjectEdit(find, replace.isExact(), palette, Math.max(0, Math.min(1, replace.getChance()))); + } + + private static VanillaBlockState state(IrisBlockData block) { + if (block == null) { + return null; + } + try { + return VanillaBlockState.parse(block.stateKey()); + } catch (IllegalArgumentException exception) { + return null; + } + } + + private static Map flipped(Map angles) { + Map flipped = new TreeMap<>(); + for (Map.Entry angle : angles.entrySet()) { + flipped.merge(normalize(angle.getKey() + FULL_TURN / 2), angle.getValue(), Integer::sum); + } + return flipped; + } + + private static List subset(List variants) { + List kept = new ArrayList<>(MAX_VARIANTS); + for (int index = 0; index < MAX_VARIANTS; index++) { + kept.add(variants.get((int) ((long) index * variants.size() / MAX_VARIANTS))); + } + return kept; + } + + private static double normalize(double angle) { + return round(((angle % FULL_TURN) + FULL_TURN) % FULL_TURN); + } + + private static double round(double value) { + return Math.round(value * ANGLE_PRECISION) / ANGLE_PRECISION; + } + + record Plan(List variants, List processorEdits) { + } + + record PlannedVariant(VariantSpec spec, List quarters, long weight) { + } + + record YChoice(double residue, List quarters, int weight) { + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/VariantSpec.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/VariantSpec.java new file mode 100644 index 000000000..5bc3c6a02 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/VariantSpec.java @@ -0,0 +1,94 @@ +package art.arcane.iris.pack.datapack.export.compile.object; + +import java.util.List; +import java.util.Objects; + +public record VariantSpec(double scale, Rotation rotation, VariantWarp warp, double snow, List edits, + boolean preventLeafDecay) { + public VariantSpec { + if (!Double.isFinite(scale) || scale <= 0) { + throw new IllegalArgumentException("Variant scale must be positive, got " + scale); + } + Objects.requireNonNull(rotation, "Variant rotation"); + if (!Double.isFinite(snow) || snow < 0 || snow > 1) { + throw new IllegalArgumentException("Variant snow must be within [0, 1], got " + snow); + } + edits = List.copyOf(edits); + } + + public static Builder builder() { + return new Builder(); + } + + public boolean reshapes() { + return scale != 1 || !rotation.none() || warp != null || snow > 0; + } + + public String descriptor() { + StringBuilder builder = new StringBuilder() + .append("scale=").append(scale) + .append(";rotation=").append(rotation.x()).append(',').append(rotation.y()).append(',').append(rotation.z()) + .append(";warp=").append(warp == null ? "none" : warp.key() + "#" + warp.index()) + .append(";snow=").append(snow) + .append(";leaves=").append(preventLeafDecay) + .append(";edits="); + for (ObjectEdit edit : edits) { + builder.append('[').append(edit.descriptor()).append(']'); + } + return builder.toString(); + } + + public record Rotation(double x, double y, double z) { + public static final Rotation NONE = new Rotation(0, 0, 0); + + public boolean none() { + return x == 0 && y == 0 && z == 0; + } + } + + public static final class Builder { + private double scale = 1; + private Rotation rotation = Rotation.NONE; + private VariantWarp warp; + private double snow; + private List edits = List.of(); + private boolean preventLeafDecay = true; + + private Builder() { + } + + public Builder scale(double value) { + scale = value; + return this; + } + + public Builder rotation(Rotation value) { + rotation = Objects.requireNonNull(value); + return this; + } + + public Builder warp(VariantWarp value) { + warp = value; + return this; + } + + public Builder snow(double value) { + snow = value; + return this; + } + + public Builder edits(List value) { + edits = Objects.requireNonNull(value); + return this; + } + + public Builder preventLeafDecay(boolean value) { + preventLeafDecay = value; + return this; + } + + public VariantSpec build() { + return new VariantSpec(scale, rotation, warp, snow, edits, preventLeafDecay); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/VariantWarp.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/VariantWarp.java new file mode 100644 index 000000000..fb4ea9507 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/VariantWarp.java @@ -0,0 +1,13 @@ +package art.arcane.iris.pack.datapack.export.compile.object; + +import java.util.Objects; + +public record VariantWarp(String key, int index, WarpField field) { + public VariantWarp { + Objects.requireNonNull(key, "Warp key"); + Objects.requireNonNull(field, "Warp field"); + if (index < 0) { + throw new IllegalArgumentException("Warp variant index must not be negative, got " + index); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/WarpField.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/WarpField.java new file mode 100644 index 000000000..b14d4e529 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/WarpField.java @@ -0,0 +1,6 @@ +package art.arcane.iris.pack.datapack.export.compile.object; + +@FunctionalInterface +public interface WarpField { + double offset(double x, double y, double z); +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/WideObjectStructures.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/WideObjectStructures.java new file mode 100644 index 000000000..a3a36d7d5 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/object/WideObjectStructures.java @@ -0,0 +1,182 @@ +package art.arcane.iris.pack.datapack.export.compile.object; + +import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; +import art.arcane.iris.pack.datapack.export.ir.JsonResource; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.TreeSet; + +public final class WideObjectStructures { + public static final int MAX_DISTANCE = 128; + private static final int MAX_POOL_WEIGHT = 150; + private static final int MAX_SPACING = 4096; + private static final int MIN_START_Y = -2032; + private static final int MAX_START_Y = 2031; + private static final String TAG_FOLDER = "has_structure/object/"; + private static final String SURFACE_HEIGHTMAP = "WORLD_SURFACE_WG"; + private static final String SURFACE_STEP = "surface_structures"; + + private final String namespace; + + public WideObjectStructures(String namespace) { + this.namespace = VanillaResourceIdentifier.requireNamespace(namespace, "wide object namespace"); + } + + public static Spacing spacing(double perChunk) { + if (!(perChunk > 0)) { + throw new IllegalArgumentException("Wide object rate must be positive, got " + perChunk); + } + if (perChunk >= 1) { + return new Spacing(1, 0, 1); + } + int spacing = Math.min(MAX_SPACING, Math.max(1, (int) Math.floor(Math.sqrt(1 / perChunk)))); + double frequency = Math.min(1, perChunk * spacing * spacing); + return new Spacing(spacing, spacing / 2, frequency); + } + + public static JsonObject jigsaw(SinglePiece piece) { + JsonObject structure = new JsonObject(); + structure.addProperty("type", "minecraft:jigsaw"); + structure.addProperty("biomes", piece.biomes()); + structure.addProperty("max_distance_from_center", piece.maxDistance()); + piece.heightmap().ifPresent(heightmap -> structure.addProperty("project_start_to_heightmap", heightmap)); + structure.addProperty("size", 1); + structure.add("spawn_overrides", new JsonObject()); + structure.add("start_height", piece.startHeight().deepCopy()); + piece.startJigsawName().ifPresent(jigsawName -> structure.addProperty("start_jigsaw_name", jigsawName)); + structure.addProperty("start_pool", piece.startPool()); + structure.addProperty("step", piece.step()); + structure.addProperty("terrain_adaptation", "none"); + structure.addProperty("use_expansion_hack", false); + return structure; + } + + public static JsonObject randomSpread(String structureId, Spacing spacing, int salt) { + JsonObject placement = new JsonObject(); + placement.addProperty("type", "minecraft:random_spread"); + if (spacing.frequency() < 1) { + placement.addProperty("frequency", (float) spacing.frequency()); + } + placement.addProperty("salt", salt); + placement.addProperty("separation", spacing.separation()); + placement.addProperty("spacing", spacing.spacing()); + JsonObject entry = new JsonObject(); + entry.addProperty("structure", structureId); + entry.addProperty("weight", 1); + JsonArray structures = new JsonArray(1); + structures.add(entry); + JsonObject set = new JsonObject(); + set.add("placement", placement); + set.add("structures", structures); + return set; + } + + public Compiled compile(String name, List templates, double perChunk, int startY, JsonObject processors) { + if (templates.isEmpty()) { + throw new IllegalArgumentException("Wide object structure '" + name + "' has no templates"); + } + JsonObject pool = pool(templates, processors); + String hash = ContentIds.hash(pool.toString() + ";rate=" + perChunk + ";y=" + startY); + String path = "object/" + name + "_" + hash; + String id = namespace + ":" + path; + int footprint = 1; + for (TemplateChoice template : templates) { + footprint = Math.max(footprint, template.variant().volume().footprint()); + } + List resources = new ArrayList<>(3); + resources.add(new JsonResource("data/" + namespace + "/worldgen/template_pool/" + path + ".json", pool)); + resources.add(new JsonResource("data/" + namespace + "/worldgen/structure/" + path + ".json", + structure(path, id, footprint, startY))); + resources.add(new JsonResource("data/" + namespace + "/worldgen/structure_set/" + path + ".json", + structureSet(id, perChunk, hash))); + return new Compiled(id, resources); + } + + public JsonResource biomeTag(String structureId, Collection biomeIds) { + String path = structureId.substring(namespace.length() + 1); + JsonArray values = new JsonArray(); + for (String biome : new TreeSet<>(biomeIds)) { + values.add(biome); + } + JsonObject tag = new JsonObject(); + tag.add("values", values); + return new JsonResource("data/" + namespace + "/tags/worldgen/biome/" + tagPath(path) + ".json", tag); + } + + private JsonObject pool(List templates, JsonObject processors) { + long largest = 1; + for (TemplateChoice template : templates) { + largest = Math.max(largest, template.weight()); + } + JsonArray elements = new JsonArray(templates.size()); + for (TemplateChoice template : templates) { + JsonObject element = new JsonObject(); + element.addProperty("element_type", "minecraft:single_pool_element"); + element.addProperty("location", template.variant().id()); + element.add("processors", processors.deepCopy()); + element.addProperty("projection", "rigid"); + JsonObject weighted = new JsonObject(); + weighted.add("element", element); + weighted.addProperty("weight", Math.max(1, Math.round(template.weight() * (double) MAX_POOL_WEIGHT / largest))); + elements.add(weighted); + } + JsonObject pool = new JsonObject(); + pool.addProperty("fallback", "minecraft:empty"); + pool.add("elements", elements); + return pool; + } + + private JsonObject structure(String path, String id, int footprint, int startY) { + JsonObject start = new JsonObject(); + start.addProperty("absolute", Math.max(MIN_START_Y, Math.min(MAX_START_Y, startY))); + return jigsaw(new SinglePiece("#" + namespace + ":" + tagPath(path), id, Math.min(MAX_DISTANCE, footprint), start, + Optional.of(SURFACE_HEIGHTMAP), Optional.empty(), SURFACE_STEP)); + } + + private static JsonObject structureSet(String id, double perChunk, String hash) { + return randomSpread(id, spacing(perChunk), (int) (ContentIds.seed(hash) & Integer.MAX_VALUE)); + } + + private static String tagPath(String structurePath) { + return TAG_FOLDER + structurePath.substring("object/".length()); + } + + public record Spacing(int spacing, int separation, double frequency) { + public Spacing { + if (spacing < 1 || spacing > MAX_SPACING || separation < 0 || separation >= spacing) { + throw new IllegalArgumentException("Random spread needs 0 <= separation < spacing <= " + MAX_SPACING + + ", got spacing " + spacing + " and separation " + separation); + } + if (!(frequency > 0) || frequency > 1) { + throw new IllegalArgumentException("Random spread frequency must be within (0, 1], got " + frequency); + } + } + } + + public record SinglePiece(String biomes, String startPool, int maxDistance, JsonObject startHeight, Optional heightmap, + Optional startJigsawName, String step) { + public SinglePiece { + Objects.requireNonNull(biomes, "Structure biomes"); + Objects.requireNonNull(startPool, "Structure start pool"); + Objects.requireNonNull(startHeight, "Structure start height"); + Objects.requireNonNull(heightmap, "Structure heightmap"); + Objects.requireNonNull(startJigsawName, "Structure start jigsaw name"); + Objects.requireNonNull(step, "Structure generation step"); + if (maxDistance < 1 || maxDistance > MAX_DISTANCE) { + throw new IllegalArgumentException("Structure max distance must be within [1, " + MAX_DISTANCE + "], got " + maxDistance); + } + } + } + + public record Compiled(String structureId, List resources) { + public Compiled { + resources = List.copyOf(resources); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/structure/BundledTags.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/structure/BundledTags.java new file mode 100644 index 000000000..b4ce5033b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/structure/BundledTags.java @@ -0,0 +1,114 @@ +package art.arcane.iris.pack.datapack.export.compile.structure; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.biome.IrisBiomeCustom; +import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; +import art.arcane.iris.pack.datapack.export.compile.ExportedBiomes; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; +import art.arcane.iris.pack.datapack.export.ir.JsonResource; +import com.google.gson.JsonElement; +import com.google.gson.JsonPrimitive; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.SortedMap; +import java.util.SortedSet; +import java.util.TreeSet; + +final class BundledTags { + private static final String BIOME_TAGS = "/tags/worldgen/biome/"; + private static final String JSON_SUFFIX = ".json"; + + private BundledTags() { + } + + static void write(PackCompileContext context, SortedMap tags, ExportedBiomes biomes) { + List resources = context.jsonResources(); + for (Map.Entry entry : tags.entrySet()) { + String path = entry.getKey(); + int index = indexOf(resources, path); + TagFile existing = index < 0 ? null : TagFile.parse(resources.get(index).json()); + JsonResource resource = new JsonResource(path, merge(path, entry.getValue(), existing, biomes).json()); + if (index < 0) { + resources.add(resource); + } else { + resources.set(index, resource); + } + } + } + + private static TagFile merge(String path, TagFile bundled, TagFile existing, ExportedBiomes biomes) { + if (biomeTag(path)) { + return joinBiomes(tagId(path), bundled, existing, biomes); + } + return existing == null ? bundled : bundled.then(existing); + } + + private static TagFile joinBiomes(String tagId, TagFile bundled, TagFile existing, ExportedBiomes biomes) { + Set members = bundled.directMembers(); + List joiners = new ArrayList<>(); + for (Map.Entry biome : biomes.byId().entrySet()) { + String derivative = biome.getValue().getVanillaDerivativeKey(); + if (derivative != null && members.contains(TagFile.member(derivative))) { + joiners.add(new JsonPrimitive(biome.getKey())); + } + } + if (!bundled.replace()) { + return (existing == null ? bundled : existing.then(bundled)).plus(joiners); + } + List authored = new ArrayList<>(); + if (existing != null) { + for (JsonElement value : existing.values()) { + IrisBiome biome = value.isJsonPrimitive() ? biomes.byId().get(value.getAsString()) : null; + if (biome != null && customTags(biome).contains(tagId)) { + authored.add(value); + } + } + } + return bundled.plus(joiners).plus(authored); + } + + private static Set customTags(IrisBiome biome) { + SortedSet tags = new TreeSet<>(); + if (!biome.isCustom()) { + return tags; + } + IrisBiomeCustom custom = biome.getCustomDerivitives().getFirst(); + if (custom.getTags() == null) { + return tags; + } + for (String tag : custom.getTags()) { + if (tag == null || tag.isBlank()) { + continue; + } + String trimmed = tag.trim(); + String normalized = TagFile.member(trimmed.startsWith("#") ? trimmed.substring(1) : trimmed); + if (VanillaResourceIdentifier.validIdentifier(normalized)) { + tags.add(normalized); + } + } + return tags; + } + + private static boolean biomeTag(String path) { + int separator = path.indexOf('/', "data/".length()); + return separator > 0 && path.startsWith(BIOME_TAGS, separator); + } + + private static String tagId(String path) { + int separator = path.indexOf('/', "data/".length()); + String namespace = path.substring("data/".length(), separator); + return namespace + ":" + path.substring(separator + BIOME_TAGS.length(), path.length() - JSON_SUFFIX.length()); + } + + private static int indexOf(List resources, String path) { + for (int index = 0; index < resources.size(); index++) { + if (resources.get(index).path().equals(path)) { + return index; + } + } + return -1; + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/structure/DatapackArchiveReader.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/structure/DatapackArchiveReader.java new file mode 100644 index 000000000..11d4150b0 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/structure/DatapackArchiveReader.java @@ -0,0 +1,169 @@ +package art.arcane.iris.pack.datapack.export.compile.structure; + +import art.arcane.iris.pack.datapack.DataVersion; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonParser; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.SortedMap; +import java.util.TreeMap; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +final class DatapackArchiveReader { + private static final String METADATA = "pack.mcmeta"; + private static final String DATA = "data/"; + private static final int MAX_ENTRIES = 100_000; + private static final long MAX_ENTRY_BYTES = 256L * 1024L * 1024L; + private static final long MAX_TOTAL_BYTES = 1024L * 1024L * 1024L; + + private DatapackArchiveReader() { + } + + static Pack read(Path archive, DataVersion target) throws IOException { + SortedMap entries = entries(archive); + String root = root(entries, archive); + JsonObject metadata = metadata(entries.get(root + METADATA), archive); + JsonElement pack = metadata.get("pack"); + if (pack == null || !pack.isJsonObject()) { + throw new IOException("Datapack archive " + archive.getFileName() + " has no pack section in pack.mcmeta"); + } + SortedMap data = new TreeMap<>(); + copy(entries, root, data); + List overlays = new ArrayList<>(); + for (Overlay overlay : overlays(metadata)) { + if (overlay.formats().contains(target)) { + copy(entries, root + overlay.directory() + "/", data); + overlays.add(overlay.directory()); + } + } + return new Pack(data, PackFormatRange.of(pack.getAsJsonObject(), "supported_formats"), List.copyOf(overlays)); + } + + private static SortedMap entries(Path archive) throws IOException { + SortedMap entries = new TreeMap<>(); + long total = 0L; + int count = 0; + try (ZipFile zip = new ZipFile(archive.toFile())) { + Enumeration enumeration = zip.entries(); + while (enumeration.hasMoreElements()) { + ZipEntry entry = enumeration.nextElement(); + if (++count > MAX_ENTRIES) { + throw new IOException("Datapack archive " + archive.getFileName() + " has more than " + MAX_ENTRIES + " entries"); + } + String name = entry.getName().replace('\\', '/'); + if (entry.isDirectory() || !safeName(name)) { + continue; + } + try (InputStream input = zip.getInputStream(entry)) { + byte[] bytes = bounded(input, archive, name); + total += bytes.length; + if (total > MAX_TOTAL_BYTES) { + throw new IOException("Datapack archive " + archive.getFileName() + " expands beyond " + MAX_TOTAL_BYTES + " bytes"); + } + entries.put(name, bytes); + } + } + } + return entries; + } + + private static byte[] bounded(InputStream input, Path archive, String name) throws IOException { + byte[] bytes = input.readNBytes((int) Math.min(Integer.MAX_VALUE - 8L, MAX_ENTRY_BYTES + 1L)); + if (bytes.length > MAX_ENTRY_BYTES) { + throw new IOException("Datapack archive " + archive.getFileName() + " entry '" + name + "' exceeds " + MAX_ENTRY_BYTES + " bytes"); + } + return bytes; + } + + private static boolean safeName(String name) { + if (name.isEmpty() || name.startsWith("/")) { + return false; + } + for (String segment : name.split("/")) { + if (segment.isEmpty() || segment.equals(".") || segment.equals("..")) { + return false; + } + } + return true; + } + + private static String root(SortedMap entries, Path archive) throws IOException { + if (entries.containsKey(METADATA)) { + return ""; + } + String wrapper = null; + for (String name : entries.keySet()) { + int slash = name.indexOf('/'); + String top = slash < 0 ? "" : name.substring(0, slash + 1); + if (top.isEmpty() || (wrapper != null && !wrapper.equals(top))) { + wrapper = null; + break; + } + wrapper = top; + } + if (wrapper == null || !entries.containsKey(wrapper + METADATA)) { + throw new IOException("Datapack archive " + archive.getFileName() + " has no pack.mcmeta at its root"); + } + return wrapper; + } + + private static JsonObject metadata(byte[] bytes, Path archive) throws IOException { + try { + JsonElement parsed = JsonParser.parseString(new String(bytes, StandardCharsets.UTF_8)); + if (!parsed.isJsonObject()) { + throw new IOException("Datapack archive " + archive.getFileName() + " has a pack.mcmeta that is not a JSON object"); + } + return parsed.getAsJsonObject(); + } catch (JsonParseException exception) { + throw new IOException("Datapack archive " + archive.getFileName() + " has an unreadable pack.mcmeta", exception); + } + } + + private static void copy(SortedMap entries, String prefix, SortedMap data) { + String from = prefix + DATA; + for (Map.Entry entry : entries.tailMap(from).entrySet()) { + if (!entry.getKey().startsWith(from)) { + break; + } + data.put(DATA + entry.getKey().substring(from.length()), entry.getValue()); + } + } + + private static List overlays(JsonObject metadata) { + JsonElement section = metadata.get("overlays"); + if (section == null || !section.isJsonObject() || !section.getAsJsonObject().has("entries") + || !section.getAsJsonObject().get("entries").isJsonArray()) { + return List.of(); + } + List overlays = new ArrayList<>(); + for (JsonElement element : section.getAsJsonObject().getAsJsonArray("entries")) { + if (!element.isJsonObject()) { + continue; + } + JsonObject entry = element.getAsJsonObject(); + JsonElement directory = entry.get("directory"); + Optional formats = PackFormatRange.of(entry, "formats"); + if (directory != null && directory.isJsonPrimitive() && safeName(directory.getAsString()) && formats.isPresent()) { + overlays.add(new Overlay(directory.getAsString(), formats.get())); + } + } + return overlays; + } + + record Pack(SortedMap data, Optional formats, List overlays) { + } + + private record Overlay(String directory, PackFormatRange formats) { + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/structure/DatapackBundle.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/structure/DatapackBundle.java new file mode 100644 index 000000000..e9baffd65 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/structure/DatapackBundle.java @@ -0,0 +1,187 @@ +package art.arcane.iris.pack.datapack.export.compile.structure; + +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.DatapackExportArchives; +import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; +import art.arcane.iris.pack.datapack.export.ir.BinaryResource; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.datapack.export.ir.JsonResource; +import art.arcane.iris.structure.nativegen.IrisImportedStructureControl; +import com.google.gson.JsonParseException; +import com.google.gson.JsonParser; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.SortedMap; +import java.util.TreeMap; + +final class DatapackBundle { + private static final String DATA = "data/"; + private static final String TAGS = "tags"; + private static final String JSON_SUFFIX = ".json"; + private static final List VANILLA_STRUCTURE_TREES = List.of( + "data/minecraft/worldgen/structure_set/", + "data/minecraft/worldgen/structure/", + "data/minecraft/worldgen/template_pool/", + "data/minecraft/structure/"); + + private final SortedMap files; + private final SortedMap tags; + + private DatapackBundle(SortedMap files, SortedMap tags) { + this.files = files; + this.tags = tags; + } + + static DatapackBundle load(PackCompileContext context) { + IrisDimension dimension = context.dimension(); + DataVersion target = context.capabilities().target(); + IrisImportedStructureControl control = dimension.getImportedStructures(); + boolean stripOverrides = control != null && !control.isDatapackOverrides(); + Set claimed = claimedPaths(context); + String reservedPrefix = DATA + context.namespace() + "/"; + String modePath = switch (context.mode()) { + case PRESET -> "data/minecraft/tags/worldgen/world_preset/normal.json"; + case OVERWORLD -> "data/minecraft/dimension/overworld.json"; + case DIMENSION -> null; + }; + SortedMap files = new TreeMap<>(); + SortedMap tags = new TreeMap<>(); + Map owners = new HashMap<>(); + List imports = dimension.getDatapackImports() == null ? List.of() : dimension.getDatapackImports(); + for (String source : imports) { + if (source == null || source.isBlank()) { + continue; + } + DatapackArchiveReader.Pack pack = read(source.trim(), target); + checkFormats(context, source.trim(), pack); + for (Map.Entry file : pack.data().entrySet()) { + String path = file.getKey(); + if (!validDataPath(path)) { + diagnose(context, ExportLimit.DATAPACK_FILE, path, "Datapack import '" + source.trim() + + "' has a file whose path is not a valid resource location; the file is not exported"); + continue; + } + if (stripOverrides && overridesVanillaStructures(path)) { + continue; + } + if (path.startsWith(reservedPrefix) || path.equals(modePath)) { + diagnose(context, ExportLimit.DATAPACK_COLLISION, path, "Datapack import '" + source.trim() + + "' writes a file the export itself writes; the export keeps its own file"); + continue; + } + if (tag(path)) { + mergeTag(context, source.trim(), path, file.getValue(), tags); + } else if (claimed.contains(path)) { + diagnose(context, ExportLimit.DATAPACK_COLLISION, path, "Datapack import '" + source.trim() + + "' writes a file the export itself writes; the export keeps its own file"); + } else { + byte[] previous = files.put(path, file.getValue()); + if (previous != null && !Arrays.equals(previous, file.getValue())) { + diagnose(context, ExportLimit.DATAPACK_COLLISION, path, "Datapack import '" + source.trim() + + "' replaces the file from '" + owners.get(path) + "'"); + } + owners.put(path, source.trim()); + } + } + } + return new DatapackBundle(files, tags); + } + + SortedMap files() { + return files; + } + + SortedMap tags() { + return tags; + } + + void emitFiles(PackCompileContext context) { + for (Map.Entry file : files.entrySet()) { + context.binaryResources().add(new BinaryResource(file.getKey(), file.getValue())); + } + } + + private static DatapackArchiveReader.Pack read(String source, DataVersion target) { + try { + Path archive = DatapackExportArchives.resolve(source, target); + return DatapackArchiveReader.read(archive, target); + } catch (IOException exception) { + throw new IllegalStateException("Datapack import '" + source + "' could not be bundled for Minecraft " + + target.getVersion() + ": " + exception.getMessage(), exception); + } + } + + private static void checkFormats(PackCompileContext context, String source, DatapackArchiveReader.Pack pack) { + DataVersion target = context.capabilities().target(); + if (pack.formats().isEmpty()) { + diagnose(context, ExportLimit.DATAPACK_VERSION, source, "Datapack import declares no readable pack format range; it is bundled as is for Minecraft " + + target.getVersion()); + } else if (!pack.formats().get().contains(target)) { + diagnose(context, ExportLimit.DATAPACK_VERSION, source, "Datapack import supports pack formats " + pack.formats().get().describe() + + " but the export targets format " + target.getPackFormat() + "." + target.getPackFormatMinor() + + " (Minecraft " + target.getVersion() + "); it is bundled as is"); + } + } + + private static void mergeTag(PackCompileContext context, String source, String path, byte[] bytes, SortedMap tags) { + TagFile tag; + try { + tag = TagFile.parse(JsonParser.parseString(new String(bytes, StandardCharsets.UTF_8))); + } catch (JsonParseException | IllegalArgumentException exception) { + diagnose(context, ExportLimit.DATAPACK_FILE, path, "Datapack import '" + source + + "' has an unreadable tag file (" + exception.getMessage() + "); the file is not exported"); + return; + } + tags.merge(path, tag, TagFile::then); + } + + private static Set claimedPaths(PackCompileContext context) { + Set claimed = new HashSet<>(); + for (JsonResource resource : context.jsonResources()) { + claimed.add(resource.path()); + } + for (BinaryResource resource : context.binaryResources()) { + claimed.add(resource.path()); + } + return claimed; + } + + private static boolean validDataPath(String path) { + if (!path.startsWith(DATA)) { + return false; + } + int separator = path.indexOf('/', DATA.length()); + return separator > DATA.length() + && VanillaResourceIdentifier.validNamespace(path.substring(DATA.length(), separator)) + && VanillaResourceIdentifier.validPath(path.substring(separator + 1)); + } + + private static boolean tag(String path) { + String[] segments = path.split("/"); + return segments.length > 3 && segments[2].equals(TAGS) && path.endsWith(JSON_SUFFIX); + } + + private static boolean overridesVanillaStructures(String path) { + for (String tree : VANILLA_STRUCTURE_TREES) { + if (path.startsWith(tree)) { + return true; + } + } + return false; + } + + private static void diagnose(PackCompileContext context, ExportLimit limit, String resource, String message) { + context.diagnostics().add(new ExportDiagnostic(ExportDiagnostic.Severity.WARNING, limit, resource, message)); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/structure/JigsawOverrides.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/structure/JigsawOverrides.java new file mode 100644 index 000000000..805499d41 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/structure/JigsawOverrides.java @@ -0,0 +1,114 @@ +package art.arcane.iris.pack.datapack.export.compile.structure; + +import art.arcane.iris.structure.jigsaw.IrisJigsawConfiguration; +import art.arcane.iris.structure.jigsaw.IrisJigsawHeightmap; +import art.arcane.iris.structure.jigsaw.IrisJigsawLiquidSettings; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +import java.util.Locale; + +final class JigsawOverrides { + private static final String START_JIGSAW_NAME = "start_jigsaw_name"; + private static final String MAX_DISTANCE = "max_distance_from_center"; + private static final String PADDING = "dimension_padding"; + private static final String HEIGHTMAP = "project_start_to_heightmap"; + private static final String REMOVE_START_NAME = "NONE"; + private static final int MAX_HORIZONTAL_DISTANCE = 128; + + private JigsawOverrides() { + } + + static void apply(JsonObject structure, IrisJigsawConfiguration jigsaw) { + if (present(jigsaw.getStartPool())) { + structure.addProperty("start_pool", TagFile.member(jigsaw.getStartPool())); + } + if (present(jigsaw.getStartJigsawName())) { + if (jigsaw.getStartJigsawName().trim().equalsIgnoreCase(REMOVE_START_NAME)) { + structure.remove(START_JIGSAW_NAME); + } else { + structure.addProperty(START_JIGSAW_NAME, TagFile.member(jigsaw.getStartJigsawName())); + } + } + if (jigsaw.getMaxDepth() != null) { + structure.addProperty("size", jigsaw.getMaxDepth()); + } + if (jigsaw.getMaxDistanceHorizontal() != null || jigsaw.getMaxDistanceVertical() != null) { + distance(structure, jigsaw.getMaxDistanceHorizontal(), jigsaw.getMaxDistanceVertical()); + } + if (jigsaw.getUseExpansionHack() != null) { + structure.addProperty("use_expansion_hack", jigsaw.getUseExpansionHack()); + } + heightmap(structure, jigsaw.getProjectStartToHeightmap()); + if (jigsaw.getDimensionPaddingBottom() != null || jigsaw.getDimensionPaddingTop() != null) { + padding(structure, jigsaw.getDimensionPaddingBottom(), jigsaw.getDimensionPaddingTop()); + } + IrisJigsawLiquidSettings liquid = jigsaw.getLiquidSettings(); + if (liquid != null && liquid != IrisJigsawLiquidSettings.SOURCE) { + structure.addProperty("liquid_settings", liquid.name().toLowerCase(Locale.ROOT)); + } + } + + private static void distance(JsonObject structure, Integer horizontal, Integer vertical) { + JsonElement current = structure.get(MAX_DISTANCE); + Integer currentHorizontal = null; + Integer currentVertical = null; + if (number(current)) { + currentHorizontal = current.getAsInt(); + currentVertical = current.getAsInt(); + } else if (current != null && current.isJsonObject()) { + JsonObject bounds = current.getAsJsonObject(); + currentHorizontal = number(bounds.get("horizontal")) ? bounds.get("horizontal").getAsInt() : null; + currentVertical = number(bounds.get("vertical")) ? bounds.get("vertical").getAsInt() : null; + } + int resolvedHorizontal = horizontal != null ? horizontal : currentHorizontal != null ? currentHorizontal : MAX_HORIZONTAL_DISTANCE; + Integer resolvedVertical = vertical != null ? vertical : currentVertical; + if (resolvedVertical != null && resolvedVertical == resolvedHorizontal) { + structure.addProperty(MAX_DISTANCE, resolvedHorizontal); + return; + } + JsonObject bounds = new JsonObject(); + bounds.addProperty("horizontal", resolvedHorizontal); + if (resolvedVertical != null) { + bounds.addProperty("vertical", resolvedVertical); + } + structure.add(MAX_DISTANCE, bounds); + } + + private static void heightmap(JsonObject structure, IrisJigsawHeightmap heightmap) { + if (heightmap == null || heightmap == IrisJigsawHeightmap.SOURCE) { + return; + } + if (heightmap == IrisJigsawHeightmap.NONE) { + structure.remove(HEIGHTMAP); + return; + } + structure.addProperty(HEIGHTMAP, heightmap.name()); + } + + private static void padding(JsonObject structure, Integer bottom, Integer top) { + JsonElement current = structure.get(PADDING); + int currentBottom = 0; + int currentTop = 0; + if (number(current)) { + currentBottom = current.getAsInt(); + currentTop = current.getAsInt(); + } else if (current != null && current.isJsonObject()) { + JsonObject bounds = current.getAsJsonObject(); + currentBottom = number(bounds.get("bottom")) ? bounds.get("bottom").getAsInt() : 0; + currentTop = number(bounds.get("top")) ? bounds.get("top").getAsInt() : 0; + } + JsonObject padding = new JsonObject(); + padding.addProperty("bottom", bottom != null ? bottom : currentBottom); + padding.addProperty("top", top != null ? top : currentTop); + structure.add(PADDING, padding); + } + + private static boolean present(String value) { + return value != null && !value.isBlank(); + } + + private static boolean number(JsonElement value) { + return value != null && value.isJsonPrimitive() && value.getAsJsonPrimitive().isNumber(); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/structure/NativeStructureOverrides.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/structure/NativeStructureOverrides.java new file mode 100644 index 000000000..335d5fcdc --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/structure/NativeStructureOverrides.java @@ -0,0 +1,124 @@ +package art.arcane.iris.pack.datapack.export.compile.structure; + +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.datapack.export.ir.JsonResource; +import art.arcane.iris.structure.nativegen.IrisImportedStructureControl; +import art.arcane.iris.structure.nativegen.IrisNativeStructureDecision; +import art.arcane.volmlib.util.structure.StructureTerrainMode; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.SortedMap; +import java.util.SortedSet; +import java.util.TreeMap; +import java.util.TreeSet; + +final class NativeStructureOverrides { + static final String DISABLED_TAG = "structure/disabled"; + private static final int LISTED_KEYS = 8; + + private final PackCompileContext context; + private final DatapackBundle bundle; + private final StructureDefinitions definitions; + private final IrisImportedStructureControl control; + private final SortedMap> dropped = new TreeMap<>(); + + NativeStructureOverrides(PackCompileContext context, DatapackBundle bundle, StructureDefinitions definitions) { + this.context = context; + this.bundle = bundle; + this.definitions = definitions; + IrisImportedStructureControl configured = context.dimension().getImportedStructures(); + control = configured == null ? new IrisImportedStructureControl() : configured; + } + + void apply(Set suppressed) { + boolean adjusting = !control.getAdjustments().isEmpty() || control.getUndergroundYShift() != 0; + boolean disabledAny = false; + for (String key : definitions.keys()) { + boolean disabled = !control.shouldGenerate(key) || suppressed.contains(key); + if (!disabled && !adjusting) { + continue; + } + Optional source = definitions.read(key); + if (source.isEmpty()) { + continue; + } + JsonObject structure = source.get().deepCopy(); + if (disabled) { + StructureJson.scope(structure, context.namespace() + ":" + DISABLED_TAG); + override(key, structure); + disabledAny = true; + } else if (adjust(key, structure)) { + override(key, structure); + } + } + if (disabledAny) { + JsonObject empty = new JsonObject(); + empty.addProperty("replace", false); + empty.add("values", new JsonArray()); + context.jsonResources().add(new JsonResource("data/" + context.namespace() + "/tags/worldgen/biome/" + DISABLED_TAG + ".json", empty)); + } + reportDropped(); + if (!definitions.vanillaAvailable() && (configuresVanilla(suppressed) || adjusting)) { + context.diagnostics().add(new ExportDiagnostic(ExportDiagnostic.Severity.WARNING, ExportLimit.VANILLA_STRUCTURE_DATA, + "dimensions/" + context.dimension().getLoadKey(), + "Vanilla structure data for Minecraft " + context.capabilities().target().getVersion() + + " is not available on this server; disabled structures and structure adjustments apply only to structures from the bundled datapacks")); + } + } + + private boolean adjust(String key, JsonObject structure) { + IrisNativeStructureDecision decision = control.resolve(key, StructureJson.underground(structure)); + boolean changed = false; + if (decision.yShift() != 0) { + if (StructureJson.jigsaw(structure) && StructureJson.shiftStartHeight(structure, decision.yShift())) { + changed = true; + } else { + drop("vertical shift", key); + } + } + if (decision.yBand() != null) { + drop("Y band", key); + } + if (decision.stilt() != null) { + drop("stilt foundations", key); + } + StructureTerrainMode terrain = decision.terrain() == null ? StructureTerrainMode.SOURCE : decision.terrain().resolvedMode(); + if (terrain == StructureTerrainMode.PRESERVE) { + structure.addProperty("terrain_adaptation", "none"); + changed = true; + } else if (terrain != StructureTerrainMode.SOURCE) { + drop("terrain mode " + terrain.name(), key); + } + return changed; + } + + private void drop(String adjustment, String key) { + dropped.computeIfAbsent(adjustment, ignored -> new TreeSet<>()).add(key); + } + + private void reportDropped() { + for (Map.Entry> entry : dropped.entrySet()) { + SortedSet keys = entry.getValue(); + String listed = String.join(", ", keys.stream().limit(LISTED_KEYS).toList()) + (keys.size() > LISTED_KEYS ? ", ..." : ""); + context.diagnostics().add(new ExportDiagnostic(ExportDiagnostic.Severity.WARNING, ExportLimit.STRUCTURE_ADJUSTMENT, keys.first(), + "The structure adjustment '" + entry.getKey() + "' has no vanilla equivalent and is not exported for " + + keys.size() + " structure(s): " + listed)); + } + } + + private void override(String key, JsonObject structure) { + String path = StructureDefinitions.path(key); + bundle.files().remove(path); + context.jsonResources().add(new JsonResource(path, structure)); + } + + private boolean configuresVanilla(Set suppressed) { + return !control.getDisabled().isEmpty() || !control.getDisabledExact().isEmpty() || !suppressed.isEmpty(); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/structure/PackFormatRange.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/structure/PackFormatRange.java new file mode 100644 index 000000000..27a3fc5d6 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/structure/PackFormatRange.java @@ -0,0 +1,85 @@ +package art.arcane.iris.pack.datapack.export.compile.structure; + +import art.arcane.iris.pack.datapack.DataVersion; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +import java.util.Optional; + +record PackFormatRange(int minMajor, int minMinor, int maxMajor, int maxMinor) { + private static final int ANY_MINOR = Integer.MAX_VALUE; + + static Optional of(JsonObject section, String legacyKey) { + if (section.has("min_format") && section.has("max_format")) { + int[] minimum = format(section.get("min_format"), 0); + int[] maximum = format(section.get("max_format"), ANY_MINOR); + return minimum == null || maximum == null + ? Optional.empty() + : Optional.of(new PackFormatRange(minimum[0], minimum[1], maximum[0], maximum[1])); + } + if (section.has(legacyKey)) { + return legacy(section.get(legacyKey)); + } + JsonElement single = section.get("pack_format"); + return integer(single) ? Optional.of(major(single.getAsInt(), single.getAsInt())) : Optional.empty(); + } + + boolean contains(DataVersion target) { + return compare(minMajor, minMinor, target.getPackFormat(), target.getPackFormatMinor()) <= 0 + && compare(target.getPackFormat(), target.getPackFormatMinor(), maxMajor, maxMinor) <= 0; + } + + String describe() { + return bound(minMajor, minMinor) + " to " + bound(maxMajor, maxMinor); + } + + private static Optional legacy(JsonElement value) { + if (integer(value)) { + return Optional.of(major(value.getAsInt(), value.getAsInt())); + } + if (value.isJsonArray() && value.getAsJsonArray().size() == 2 + && integer(value.getAsJsonArray().get(0)) && integer(value.getAsJsonArray().get(1))) { + return Optional.of(major(value.getAsJsonArray().get(0).getAsInt(), value.getAsJsonArray().get(1).getAsInt())); + } + if (value.isJsonObject()) { + JsonObject bounds = value.getAsJsonObject(); + JsonElement minimum = bounds.get("min_inclusive"); + JsonElement maximum = bounds.get("max_inclusive"); + if (integer(minimum) && integer(maximum)) { + return Optional.of(major(minimum.getAsInt(), maximum.getAsInt())); + } + } + return Optional.empty(); + } + + private static PackFormatRange major(int minimum, int maximum) { + return new PackFormatRange(minimum, 0, maximum, ANY_MINOR); + } + + private static int[] format(JsonElement value, int defaultMinor) { + if (integer(value)) { + return new int[]{value.getAsInt(), defaultMinor}; + } + if (value != null && value.isJsonArray()) { + JsonArray parts = value.getAsJsonArray(); + if (parts.size() == 2 && integer(parts.get(0)) && integer(parts.get(1))) { + return new int[]{parts.get(0).getAsInt(), parts.get(1).getAsInt()}; + } + } + return null; + } + + private static boolean integer(JsonElement value) { + return value != null && value.isJsonPrimitive() && value.getAsJsonPrimitive().isNumber(); + } + + private static int compare(int leftMajor, int leftMinor, int rightMajor, int rightMinor) { + int major = Integer.compare(leftMajor, rightMajor); + return major != 0 ? major : Integer.compare(leftMinor, rightMinor); + } + + private static String bound(int major, int minor) { + return minor == ANY_MINOR ? major + ".*" : major + "." + minor; + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/structure/PlacementCompiler.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/structure/PlacementCompiler.java new file mode 100644 index 000000000..ffc4424f9 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/structure/PlacementCompiler.java @@ -0,0 +1,372 @@ +package art.arcane.iris.pack.datapack.export.compile.structure; + +import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; +import art.arcane.iris.pack.datapack.export.compile.ExportedBiomes; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; +import art.arcane.iris.pack.datapack.export.ir.BinaryResource; +import art.arcane.iris.pack.datapack.export.ir.DimensionDef; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.datapack.export.ir.JsonResource; +import art.arcane.iris.structure.export.VanillaJigsawExportCompiler; +import art.arcane.iris.structure.export.VanillaJigsawExportDiagnostic; +import art.arcane.iris.structure.export.VanillaJigsawExportSettings; +import art.arcane.iris.structure.export.VanillaJigsawExportSource; +import art.arcane.iris.structure.export.VanillaJigsawExportUnit; +import art.arcane.iris.structure.nativegen.IrisNativeStructure; +import art.arcane.iris.structure.nativegen.NativeStructureSuppression; +import art.arcane.iris.structure.placement.IrisStructure; +import art.arcane.iris.structure.placement.IrisStructureAnchorMode; +import art.arcane.iris.structure.placement.IrisStructurePlacement; +import art.arcane.iris.structure.placement.StructureDistribution; +import art.arcane.volmlib.util.structure.StructureTerrainMode; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.SortedSet; +import java.util.TreeMap; +import java.util.TreeSet; + +final class PlacementCompiler { + private static final String ROOT = "placement/"; + private static final String JSON_SUFFIX = ".json"; + private static final int MAX_SPACING = 4096; + private static final int MAX_RING_DISTANCE = 1023; + private static final int MAX_RING_SPREAD = 1023; + private static final int MAX_RING_COUNT = 4095; + private static final int LISTED_ISSUES = 3; + + private final PackCompileContext context; + private final StructureDefinitions definitions; + private final ExportedBiomes biomes; + private final VanillaJigsawExportCompiler jigsaw; + + PlacementCompiler(PackCompileContext context, StructureDefinitions definitions, ExportedBiomes biomes) { + this.context = context; + this.definitions = definitions; + this.biomes = biomes; + jigsaw = new VanillaJigsawExportCompiler(context.capabilities().target()); + } + + static Set suppressedSources(PackCompileContext context) { + SortedSet sources = new TreeSet<>(); + if (context.dimension().getStructures() == null) { + return sources; + } + for (IrisStructurePlacement placement : context.dimension().getStructures()) { + if (placement == null || placement.getNativeSuppression() != NativeStructureSuppression.REPLACE_SOURCE) { + continue; + } + if (placement.hasNativeStructures()) { + for (IrisNativeStructure source : placement.getNativeStructures()) { + if (source != null && source.getStructure() != null && !source.getStructure().isBlank()) { + sources.add(TagFile.member(source.getStructure())); + } + } + } + if (placement.hasIrisStructures()) { + for (String key : placement.getStructures()) { + IrisStructure structure = key == null ? null : VanillaJigsawExportSource.forData(context.data(), key).loadStructure(); + if (structure != null && structure.getVanillaSource() != null && !structure.getVanillaSource().isBlank()) { + sources.add(TagFile.member(structure.getVanillaSource())); + } + } + } + } + return sources; + } + + void compile() { + for (PlacementScopes.Scope scope : PlacementScopes.collect(context, biomes)) { + if (scope.placements().isEmpty()) { + continue; + } + if (scope.biomeIds().isEmpty()) { + diagnose(ExportDiagnostic.Severity.WARNING, ExportLimit.STRUCTURE_PLACEMENT, scope.label(), + "No exported biome lies in this scope, so its structure placements are not exported"); + continue; + } + String tag = ROOT + scope.name(); + boolean tagged = false; + for (int index = 0; index < scope.placements().size(); index++) { + IrisStructurePlacement placement = scope.placements().get(index); + if (placement != null && compile(new Build(scope, index, placement, tag)) && !tagged) { + PlacementTags.write(context, tag, scope.biomeIds()); + tagged = true; + } + } + } + } + + private boolean compile(Build build) { + IrisStructurePlacement placement = build.placement; + if (placement.isAnchoredUnderground() && build.band == null) { + diagnose(ExportDiagnostic.Severity.WARNING, ExportLimit.STRUCTURE_PLACEMENT, build.resource, + "The placement's height band lies outside the dimension, so it is not exported"); + return false; + } + JsonObject spread = spread(build); + if (spread == null) { + reportDropped(build); + return false; + } + if (placement.hasNativeStructures()) { + for (IrisNativeStructure source : placement.getNativeStructures()) { + if (source != null) { + nativeEntry(build, source); + } + } + } + if (placement.hasIrisStructures()) { + StructureTerrainMode terrain = placement.resolvedTerrain().resolvedMode(); + if (terrain != StructureTerrainMode.SOURCE && terrain != StructureTerrainMode.PRESERVE) { + build.dropped.add("terrain mode " + terrain.name()); + } + for (String key : placement.getStructures()) { + if (key != null && !key.isBlank()) { + jigsawEntry(build, key.trim()); + } + } + } + if (placement.getStilt() != null) { + build.dropped.add("stilt foundations"); + } + if (!build.scope.shared().isEmpty()) { + build.dropped.add("region scoping (" + String.join(", ", build.scope.shared()) + + " also belong to other regions, where the structure generates too)"); + } + reportDropped(build); + if (build.entries.isEmpty()) { + return false; + } + JsonArray structures = new JsonArray(build.entries.size()); + for (Map.Entry entry : build.entries.entrySet()) { + JsonObject structure = new JsonObject(); + structure.addProperty("structure", entry.getKey()); + structure.addProperty("weight", entry.getValue()); + structures.add(structure); + } + JsonObject set = new JsonObject(); + set.add("structures", structures); + set.add("placement", spread); + context.jsonResources().add(new JsonResource(VanillaJigsawExportCompiler.structureSetPath(context.namespace(), build.name), set)); + return true; + } + + private void nativeEntry(Build build, IrisNativeStructure source) { + String key = source.getStructure() == null ? "" : TagFile.member(source.getStructure()); + if (!VanillaResourceIdentifier.validIdentifier(key)) { + diagnose(ExportDiagnostic.Severity.WARNING, ExportLimit.STRUCTURE_PLACEMENT, build.resource, + "Native structure '" + source.getStructure() + "' is not a valid structure id; the entry is not exported"); + return; + } + Optional definition = definitions.read(key); + if (definition.isEmpty()) { + diagnose(ExportDiagnostic.Severity.WARNING, ExportLimit.VANILLA_STRUCTURE_DATA, build.resource, "Native structure '" + key + + "' has no definition in the bundled datapacks" + (definitions.vanillaAvailable() ? "" : " and vanilla structure data for Minecraft " + + context.capabilities().target().getVersion() + " is not available on this server") + "; the entry is not exported"); + return; + } + JsonObject structure = definition.get().deepCopy(); + boolean jigsawType = StructureJson.jigsaw(structure); + StructureJson.scope(structure, context.namespace() + ":" + build.tag); + if (source.getJigsaw() != null && source.getJigsaw().hasOverrides()) { + if (jigsawType) { + JigsawOverrides.apply(structure, source.getJigsaw()); + } else { + build.dropped.add("jigsaw overrides on the non-jigsaw structure " + key); + } + } + if (build.band != null) { + if (jigsawType) { + StructureJson.band(structure, build.band.minimum(), build.band.maximum()); + } else { + build.dropped.add("height band on the non-jigsaw structure " + key); + } + } + StructureTerrainMode terrain = build.placement.resolvedTerrain().resolvedMode(); + if (terrain == StructureTerrainMode.PRESERVE) { + structure.addProperty("terrain_adaptation", "none"); + } else if (terrain != StructureTerrainMode.SOURCE) { + build.dropped.add("terrain mode " + terrain.name()); + } + String id = context.namespace() + ":" + build.name + "/" + VanillaResourceIdentifier.sanitizePath(key.replace(':', '/')); + if (build.entries.containsKey(id)) { + build.entries.merge(id, Math.max(1, source.getWeight()), Integer::sum); + return; + } + context.jsonResources().add(new JsonResource(StructureDefinitions.path(id), structure)); + build.entries.put(id, Math.max(1, source.getWeight())); + } + + private void jigsawEntry(Build build, String key) { + String resourcePath = build.name + "/" + VanillaResourceIdentifier.sanitizePath(key); + String id = context.namespace() + ":" + resourcePath; + if (build.entries.containsKey(id)) { + build.entries.merge(id, 1, Integer::sum); + return; + } + VanillaJigsawExportCompiler.Compilation compilation = jigsaw.compile(new VanillaJigsawExportUnit( + VanillaJigsawExportSource.forData(context.data(), key), context.namespace(), resourcePath, settings(build))); + if (!reportJigsaw(build, key, compilation)) { + return; + } + String structurePath = VanillaJigsawExportCompiler.structurePath(context.namespace(), resourcePath); + String skippedSet = VanillaJigsawExportCompiler.structureSetPath(context.namespace(), resourcePath); + String skippedTag = VanillaJigsawExportCompiler.biomeTagPath(context.namespace(), resourcePath); + for (Map.Entry resource : new TreeMap<>(compilation.resources()).entrySet()) { + String path = resource.getKey(); + if (path.equals(skippedSet) || path.equals(skippedTag)) { + continue; + } + if (!path.endsWith(JSON_SUFFIX)) { + context.binaryResources().add(new BinaryResource(path, resource.getValue())); + continue; + } + JsonObject json = JsonParser.parseString(new String(resource.getValue(), StandardCharsets.UTF_8)).getAsJsonObject(); + if (path.equals(structurePath)) { + StructureJson.scope(json, context.namespace() + ":" + build.tag); + if (build.band != null) { + StructureJson.band(json, build.band.minimum(), build.band.maximum()); + } + } + context.jsonResources().add(new JsonResource(path, json)); + } + build.entries.put(id, 1); + } + + private VanillaJigsawExportSettings settings(Build build) { + VanillaJigsawExportSettings.Builder settings = VanillaJigsawExportSettings.builder() + .biomes(List.copyOf(build.scope.biomeIds())) + .terrainAdaptation(VanillaJigsawExportSettings.TerrainAdaptation.NONE); + if (build.band == null) { + return settings.build(); + } + return settings.startHeight(build.band.minimum() + (build.band.maximum() - build.band.minimum()) / 2) + .projectHeightmap(VanillaJigsawExportSettings.ProjectHeightmap.NONE) + .generationStep(VanillaJigsawExportSettings.GenerationStep.UNDERGROUND_STRUCTURES) + .build(); + } + + private boolean reportJigsaw(Build build, String key, VanillaJigsawExportCompiler.Compilation compilation) { + List errors = new ArrayList<>(); + for (VanillaJigsawExportDiagnostic diagnostic : compilation.diagnostics()) { + if (diagnostic.isBlocking()) { + errors.add(diagnostic.message()); + } else { + diagnose(ExportDiagnostic.Severity.INFO, ExportLimit.JIGSAW_STRUCTURE, "structures/" + key, diagnostic.message()); + } + } + if (!compilation.hasErrors()) { + return true; + } + String listed = String.join(" ", errors.stream().limit(LISTED_ISSUES).toList()) + (errors.size() > LISTED_ISSUES ? " ..." : ""); + diagnose(ExportDiagnostic.Severity.WARNING, ExportLimit.JIGSAW_STRUCTURE, "structures/" + key, + "Iris structure '" + key + "' placed by " + build.resource + " cannot be exported as a vanilla jigsaw: " + listed); + return false; + } + + private JsonObject spread(Build build) { + IrisStructurePlacement placement = build.placement; + StructureDistribution distribution = placement.getDistribution() == null ? StructureDistribution.RANDOM_SPREAD : placement.getDistribution(); + JsonObject spread = new JsonObject(); + spread.addProperty("salt", placement.getSalt() & Integer.MAX_VALUE); + switch (distribution) { + case RANDOM_SPREAD -> { + if (placement.getSpacing() < 1 || placement.getSpacing() > MAX_SPACING + || placement.getSeparation() < 0 || placement.getSeparation() >= placement.getSpacing()) { + build.dropped.add("random spread spacing " + placement.getSpacing() + " with separation " + placement.getSeparation() + + " (vanilla needs 0 <= separation < spacing <= " + MAX_SPACING + "), so the placement is not exported"); + return null; + } + spread.addProperty("type", "minecraft:random_spread"); + spread.addProperty("spacing", placement.getSpacing()); + spread.addProperty("separation", placement.getSeparation()); + } + case DENSITY -> { + spread.addProperty("type", "minecraft:random_spread"); + spread.addProperty("spacing", 1); + spread.addProperty("separation", 0); + spread.addProperty("frequency", (float) Math.max(0.0, Math.min(1.0, placement.getDensity()))); + } + case CONCENTRIC_RINGS -> { + spread.addProperty("type", "minecraft:concentric_rings"); + spread.addProperty("distance", ring(build, "ring distance", placement.getRingDistance(), 0, MAX_RING_DISTANCE)); + spread.addProperty("spread", ring(build, "ring spread", placement.getRingSpread(), 0, MAX_RING_SPREAD)); + spread.addProperty("count", ring(build, "ring count", placement.getRingCount(), 1, MAX_RING_COUNT)); + spread.addProperty("preferred_biomes", "#" + context.namespace() + ":" + build.tag); + } + } + return spread; + } + + private static int ring(Build build, String field, int value, int minimum, int maximum) { + int clamped = Math.max(minimum, Math.min(maximum, value)); + if (clamped != value) { + build.dropped.add(field + " " + value + " (clamped to " + clamped + ")"); + } + return clamped; + } + + private void reportDropped(Build build) { + if (!build.dropped.isEmpty()) { + diagnose(ExportDiagnostic.Severity.INFO, ExportLimit.STRUCTURE_PLACEMENT, build.resource, + "Placement settings with no vanilla equivalent are not exported: " + String.join("; ", build.dropped)); + } + } + + private void diagnose(ExportDiagnostic.Severity severity, ExportLimit limit, String resource, String message) { + context.diagnostics().add(new ExportDiagnostic(severity, limit, resource, message)); + } + + private record Band(int minimum, int maximum) { + } + + private final class Build { + private final PlacementScopes.Scope scope; + private final IrisStructurePlacement placement; + private final String name; + private final String tag; + private final String resource; + private final Band band; + private final Map entries = new LinkedHashMap<>(); + private final Set dropped = new LinkedHashSet<>(); + + private Build(PlacementScopes.Scope scope, int index, IrisStructurePlacement placement, String tag) { + this.scope = scope; + this.placement = placement; + this.tag = tag; + name = ROOT + scope.name() + "/" + index; + resource = scope.label() + "/structures[" + index + "]"; + band = band(placement); + } + + private Band band(IrisStructurePlacement source) { + DimensionDef dimension = context.dimensionDef(); + int bottom = dimension.minY(); + int top = dimension.minY() + dimension.height() - 1; + int minimum = Math.max(source.getMinHeight(), bottom); + int maximum = Math.min(source.getMaxHeight(), top); + IrisStructureAnchorMode anchor = source.resolvedAnchor(); + if (!source.isAnchoredUnderground()) { + if (minimum > bottom || maximum < top) { + dropped.add("surface height gate " + source.getMinHeight() + ".." + source.getMaxHeight()); + } + return null; + } + if (anchor.isCave()) { + dropped.add("cave anchor " + anchor.name() + " (exported as a height band)"); + } + return minimum > maximum ? null : new Band(minimum, maximum); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/structure/PlacementScopes.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/structure/PlacementScopes.java new file mode 100644 index 000000000..1ca480d68 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/structure/PlacementScopes.java @@ -0,0 +1,123 @@ +package art.arcane.iris.pack.datapack.export.compile.structure; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.generation.terrain.IrisRegion; +import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; +import art.arcane.iris.pack.datapack.export.compile.ExportedBiomes; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.structure.placement.IrisStructurePlacement; +import art.arcane.volmlib.util.collection.KList; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.SortedMap; +import java.util.SortedSet; +import java.util.TreeMap; +import java.util.TreeSet; + +final class PlacementScopes { + private static final int MAX_CHILD_HOPS = 4; + + private PlacementScopes() { + } + + static List collect(PackCompileContext context, ExportedBiomes biomes) { + IrisDimension dimension = context.dimension(); + IrisData data = context.data(); + Set names = new HashSet<>(); + List scopes = new ArrayList<>(); + scopes.add(new Scope(unique(names, "dimension"), "dimensions/" + dimension.getLoadKey(), biomes.ids(), new TreeSet<>(), + placements(dimension.getStructures()))); + Map regions = new LinkedHashMap<>(); + Map> regionIds = new LinkedHashMap<>(); + for (String regionKey : dimension.getRegions()) { + IrisRegion region = regionKey == null ? null : data.getRegionLoader().load(regionKey, false); + if (region != null && !region.isCompatExcluded() && !regions.containsKey(region.getLoadKey())) { + regions.put(region.getLoadKey(), region); + regionIds.put(region.getLoadKey(), biomes.idsFor(closure(data, region))); + } + } + for (Map.Entry region : regions.entrySet()) { + SortedSet ids = regionIds.get(region.getKey()); + SortedSet shared = new TreeSet<>(); + for (Map.Entry> other : regionIds.entrySet()) { + if (!other.getKey().equals(region.getKey())) { + for (String id : ids) { + if (other.getValue().contains(id)) { + shared.add(id); + } + } + } + } + scopes.add(new Scope(unique(names, "region/" + VanillaResourceIdentifier.sanitizePath(region.getKey())), "regions/" + region.getKey(), + ids, shared, placements(region.getValue().getStructures()))); + } + SortedMap> idsByLoadKey = new TreeMap<>(); + SortedMap biomeByLoadKey = new TreeMap<>(); + for (Map.Entry biome : biomes.byId().entrySet()) { + idsByLoadKey.computeIfAbsent(biome.getValue().getLoadKey(), ignored -> new TreeSet<>()).add(biome.getKey()); + biomeByLoadKey.putIfAbsent(biome.getValue().getLoadKey(), biome.getValue()); + } + for (Map.Entry> biome : idsByLoadKey.entrySet()) { + scopes.add(new Scope(unique(names, "biome/" + VanillaResourceIdentifier.sanitizePath(biome.getKey())), "biomes/" + biome.getKey(), + biome.getValue(), new TreeSet<>(), placements(biomeByLoadKey.get(biome.getKey()).getStructures()))); + } + return List.copyOf(scopes); + } + + private static SortedSet closure(IrisData data, IrisRegion region) { + SortedSet keys = new TreeSet<>(); + List frontier = new ArrayList<>(); + for (KList pool : List.of(region.getLandBiomes(), region.getSeaBiomes(), region.getShoreBiomes(), region.getCaveBiomes())) { + for (IrisBiome biome : data.getBiomeLoader().loadAll(pool)) { + if (!biome.isCompatExcluded() && keys.add(biome.getLoadKey())) { + frontier.add(biome); + } + } + } + for (int hop = 0; hop < MAX_CHILD_HOPS && !frontier.isEmpty(); hop++) { + List next = new ArrayList<>(); + for (IrisBiome parent : frontier) { + for (IrisBiome child : parent.getRealChildren(() -> data)) { + if (child != null && !child.isCompatExcluded() && keys.add(child.getLoadKey())) { + next.add(child); + } + } + } + frontier = next; + } + return keys; + } + + private static List placements(KList placements) { + List present = new ArrayList<>(); + if (placements != null) { + for (IrisStructurePlacement placement : placements) { + present.add(placement); + } + } + return present; + } + + private static String unique(Set names, String name) { + String candidate = name; + for (int suffix = 1; !names.add(candidate); suffix++) { + candidate = name + "_" + suffix; + } + return candidate; + } + + record Scope(String name, String label, SortedSet biomeIds, SortedSet shared, List placements) { + Scope { + biomeIds = new TreeSet<>(biomeIds); + shared = new TreeSet<>(shared); + placements = new ArrayList<>(placements); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/structure/PlacementTags.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/structure/PlacementTags.java new file mode 100644 index 000000000..5c2364715 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/structure/PlacementTags.java @@ -0,0 +1,89 @@ +package art.arcane.iris.pack.datapack.export.compile.structure; + +import art.arcane.iris.pack.datapack.export.compile.ExportedBiomes; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; +import art.arcane.iris.pack.datapack.export.ir.JsonResource; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.SortedSet; +import java.util.TreeSet; + +public final class PlacementTags { + private static final String ROOT = "placement/"; + private static final String BIOME_TAGS = "/tags/worldgen/biome/"; + private static final String JSON_SUFFIX = ".json"; + private static final String DIMENSION_LABEL = "dimensions/"; + private static final String REGION_LABEL = "regions/"; + + private final PackCompileContext context; + private final PlacementScopes.Scope dimension; + private final Map regions; + + private PlacementTags(PackCompileContext context, PlacementScopes.Scope dimension, Map regions) { + this.context = context; + this.dimension = dimension; + this.regions = regions; + } + + public static PlacementTags collect(PackCompileContext context) { + List scopes = PlacementScopes.collect(context, ExportedBiomes.load(context)); + PlacementScopes.Scope dimension = null; + Map regions = new HashMap<>(); + for (PlacementScopes.Scope scope : scopes) { + if (scope.label().startsWith(DIMENSION_LABEL)) { + dimension = scope; + } else if (scope.label().startsWith(REGION_LABEL)) { + regions.put(scope.label().substring(REGION_LABEL.length()), scope); + } + } + if (dimension == null) { + throw new IllegalStateException("Placement scopes have no dimension scope"); + } + return new PlacementTags(context, dimension, regions); + } + + public Optional dimensionTag() { + return ensure(dimension); + } + + public Optional regionTag(String regionKey) { + PlacementScopes.Scope scope = regions.get(regionKey); + return scope == null ? Optional.empty() : ensure(scope); + } + + public SortedSet sharedBiomes(String regionKey) { + PlacementScopes.Scope scope = regions.get(regionKey); + return scope == null ? new TreeSet<>() : new TreeSet<>(scope.shared()); + } + + static void write(PackCompileContext context, String tag, SortedSet ids) { + String path = "data/" + context.namespace() + BIOME_TAGS + tag + JSON_SUFFIX; + for (JsonResource resource : context.jsonResources()) { + if (resource.path().equals(path)) { + return; + } + } + JsonArray values = new JsonArray(ids.size()); + for (String id : ids) { + values.add(id); + } + JsonObject json = new JsonObject(); + json.addProperty("replace", false); + json.add("values", values); + context.jsonResources().add(new JsonResource(path, json)); + } + + private Optional ensure(PlacementScopes.Scope scope) { + if (scope.biomeIds().isEmpty()) { + return Optional.empty(); + } + String tag = ROOT + scope.name(); + write(context, tag, scope.biomeIds()); + return Optional.of(context.namespace() + ":" + tag); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/structure/StructureDefinitions.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/structure/StructureDefinitions.java new file mode 100644 index 000000000..02225dc24 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/structure/StructureDefinitions.java @@ -0,0 +1,108 @@ +package art.arcane.iris.pack.datapack.export.compile.structure; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.VanillaDataSource; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonParser; + +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.SortedMap; +import java.util.SortedSet; +import java.util.TreeMap; +import java.util.TreeSet; + +final class StructureDefinitions { + private static final String STRUCTURE_FOLDER = "/worldgen/structure/"; + private static final String VANILLA_STRUCTURES = "data/minecraft/worldgen/structure"; + private static final String JSON_SUFFIX = ".json"; + + private final SortedMap bundled; + private final Optional vanilla; + + StructureDefinitions(SortedMap bundledFiles, Optional vanilla) { + this.vanilla = Objects.requireNonNull(vanilla); + bundled = new TreeMap<>(); + for (Map.Entry file : bundledFiles.entrySet()) { + if (key(file.getKey()) != null) { + bundled.put(file.getKey(), file.getValue()); + } + } + } + + static StructureDefinitions of(DatapackBundle bundle, DataVersion target) { + return new StructureDefinitions(bundle.files(), vanillaFor(target)); + } + + static String path(String key) { + int separator = key.indexOf(':'); + return "data/" + key.substring(0, separator) + STRUCTURE_FOLDER + key.substring(separator + 1) + JSON_SUFFIX; + } + + boolean vanillaAvailable() { + return vanilla.isPresent(); + } + + SortedSet keys() { + SortedSet keys = new TreeSet<>(); + for (String path : bundled.keySet()) { + keys.add(key(path)); + } + if (vanilla.isPresent()) { + for (String path : vanilla.get().list(VANILLA_STRUCTURES)) { + String key = key(path); + if (key != null) { + keys.add(key); + } + } + } + return keys; + } + + Optional read(String key) { + String path = path(key); + byte[] bytes = bundled.get(path); + if (bytes == null && vanilla.isPresent()) { + bytes = vanilla.get().read(path).orElse(null); + } + if (bytes == null) { + return Optional.empty(); + } + try { + JsonElement json = JsonParser.parseString(new String(bytes, StandardCharsets.UTF_8)); + if (!json.isJsonObject()) { + throw new IllegalStateException("Structure '" + key + "' at " + path + " is not a JSON object"); + } + return Optional.of(json.getAsJsonObject()); + } catch (JsonParseException exception) { + throw new IllegalStateException("Structure '" + key + "' at " + path + " is not valid JSON", exception); + } + } + + private static Optional vanillaFor(DataVersion target) { + if (!IrisPlatforms.isBound()) { + return Optional.empty(); + } + IrisPlatform platform = IrisPlatforms.get(); + boolean running = DataVersion.forMinecraftVersion(platform.minecraftVersion()) == target; + return running ? Optional.ofNullable(platform.vanillaData()) : Optional.empty(); + } + + private static String key(String path) { + if (!path.startsWith("data/") || !path.endsWith(JSON_SUFFIX)) { + return null; + } + int separator = path.indexOf('/', "data/".length()); + if (separator < 0 || !path.startsWith(STRUCTURE_FOLDER, separator)) { + return null; + } + String local = path.substring(separator + STRUCTURE_FOLDER.length(), path.length() - JSON_SUFFIX.length()); + return local.isEmpty() ? null : path.substring("data/".length(), separator) + ":" + local; + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/structure/StructureJson.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/structure/StructureJson.java new file mode 100644 index 000000000..21b1cfd13 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/structure/StructureJson.java @@ -0,0 +1,117 @@ +package art.arcane.iris.pack.datapack.export.compile.structure; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +import java.util.List; +import java.util.Set; + +final class StructureJson { + private static final String START_HEIGHT = "start_height"; + private static final Set JIGSAW_TYPES = Set.of("minecraft:jigsaw", "jigsaw"); + private static final Set UNDERGROUND_STEPS = Set.of("underground_structures", "underground_decoration", "strongholds"); + private static final List RANGE_BOUNDS = List.of("min_inclusive", "max_inclusive"); + + private StructureJson() { + } + + static boolean jigsaw(JsonObject structure) { + JsonElement type = structure.get("type"); + return type != null && type.isJsonPrimitive() && JIGSAW_TYPES.contains(type.getAsString()); + } + + static boolean underground(JsonObject structure) { + JsonElement step = structure.get("step"); + return step != null && step.isJsonPrimitive() && UNDERGROUND_STEPS.contains(step.getAsString()); + } + + static void scope(JsonObject structure, String biomeTag) { + structure.addProperty("biomes", "#" + biomeTag); + } + + static boolean shiftStartHeight(JsonObject structure, int offset) { + JsonElement height = structure.get(START_HEIGHT); + if (height == null) { + return false; + } + JsonElement shifted = height.deepCopy(); + if (!shift(shifted, offset)) { + return false; + } + structure.add(START_HEIGHT, shifted); + return true; + } + + static void band(JsonObject structure, int minimum, int maximum) { + JsonObject height = new JsonObject(); + height.addProperty("type", "minecraft:uniform"); + height.add("min_inclusive", absolute(minimum)); + height.add("max_inclusive", absolute(maximum)); + structure.add(START_HEIGHT, height); + structure.remove("project_start_to_heightmap"); + } + + private static JsonObject absolute(int y) { + JsonObject anchor = new JsonObject(); + anchor.addProperty("absolute", y); + return anchor; + } + + private static boolean shift(JsonElement height, int offset) { + if (!height.isJsonObject()) { + return false; + } + JsonObject provider = height.getAsJsonObject(); + if (provider.has("absolute")) { + return add(provider, "absolute", offset); + } + if (provider.has("above_bottom")) { + return add(provider, "above_bottom", offset); + } + if (provider.has("below_top")) { + return add(provider, "below_top", -offset); + } + JsonElement type = provider.get("type"); + if (type == null || !type.isJsonPrimitive()) { + return false; + } + return switch (type.getAsString()) { + case "minecraft:constant", "constant" -> provider.has("value") && shift(provider.get("value"), offset); + case "minecraft:uniform", "uniform", "minecraft:biased_to_bottom", "biased_to_bottom", + "minecraft:very_biased_to_bottom", "very_biased_to_bottom", "minecraft:trapezoid", "trapezoid" -> shiftBounds(provider, offset); + case "minecraft:weighted_list", "weighted_list" -> shiftWeighted(provider, offset); + default -> false; + }; + } + + private static boolean shiftBounds(JsonObject provider, int offset) { + for (String bound : RANGE_BOUNDS) { + if (!provider.has(bound) || !shift(provider.get(bound), offset)) { + return false; + } + } + return true; + } + + private static boolean shiftWeighted(JsonObject provider, int offset) { + JsonElement distribution = provider.get("distribution"); + if (distribution == null || !distribution.isJsonArray()) { + return false; + } + for (JsonElement entry : distribution.getAsJsonArray()) { + if (!entry.isJsonObject() || !entry.getAsJsonObject().has("data") || !shift(entry.getAsJsonObject().get("data"), offset)) { + return false; + } + } + return true; + } + + private static boolean add(JsonObject anchor, String key, int offset) { + JsonElement value = anchor.get(key); + if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isNumber()) { + return false; + } + anchor.addProperty(key, value.getAsInt() + offset); + return true; + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/structure/StructureStage.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/structure/StructureStage.java new file mode 100644 index 000000000..9173155ed --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/structure/StructureStage.java @@ -0,0 +1,23 @@ +package art.arcane.iris.pack.datapack.export.compile.structure; + +import art.arcane.iris.pack.datapack.export.compile.CompileStage; +import art.arcane.iris.pack.datapack.export.compile.ExportedBiomes; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; + +public final class StructureStage implements CompileStage { + @Override + public String name() { + return "structures"; + } + + @Override + public void apply(PackCompileContext context) { + DatapackBundle bundle = DatapackBundle.load(context); + StructureDefinitions definitions = StructureDefinitions.of(bundle, context.capabilities().target()); + ExportedBiomes biomes = ExportedBiomes.load(context); + new NativeStructureOverrides(context, bundle, definitions).apply(PlacementCompiler.suppressedSources(context)); + new PlacementCompiler(context, definitions, biomes).compile(); + BundledTags.write(context, bundle.tags(), biomes); + bundle.emitFiles(context); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/structure/TagFile.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/structure/TagFile.java new file mode 100644 index 000000000..7e313dd26 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/structure/TagFile.java @@ -0,0 +1,95 @@ +package art.arcane.iris.pack.datapack.export.compile.structure; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.TreeSet; + +record TagFile(boolean replace, List values) { + private static final String VANILLA_NAMESPACE = "minecraft"; + + TagFile { + values = List.copyOf(values); + } + + static TagFile parse(JsonElement json) { + if (json == null || !json.isJsonObject()) { + throw new IllegalArgumentException("Tag file is not a JSON object"); + } + JsonObject root = json.getAsJsonObject(); + JsonElement replace = root.get("replace"); + if (replace != null && !(replace.isJsonPrimitive() && replace.getAsJsonPrimitive().isBoolean())) { + throw new IllegalArgumentException("Tag file 'replace' is not a boolean"); + } + JsonElement values = root.get("values"); + if (values == null || !values.isJsonArray()) { + throw new IllegalArgumentException("Tag file has no 'values' array"); + } + LinkedHashSet entries = new LinkedHashSet<>(); + for (JsonElement value : values.getAsJsonArray()) { + if (id(value) == null) { + throw new IllegalArgumentException("Tag file value '" + value + "' is neither an id nor an {id} object"); + } + entries.add(value.deepCopy()); + } + return new TagFile(replace != null && replace.getAsBoolean(), List.copyOf(entries)); + } + + static String member(String id) { + String trimmed = id.trim().toLowerCase(Locale.ROOT); + return trimmed.indexOf(':') < 0 ? VANILLA_NAMESPACE + ":" + trimmed : trimmed; + } + + TagFile then(TagFile later) { + return later.replace ? later : new TagFile(replace, union(values, later.values)); + } + + TagFile plus(Collection additions) { + return new TagFile(replace, union(values, additions)); + } + + Set directMembers() { + Set members = new TreeSet<>(); + for (JsonElement value : values) { + String id = id(value); + if (!id.startsWith("#")) { + members.add(member(id)); + } + } + return members; + } + + JsonObject json() { + JsonObject root = new JsonObject(); + root.addProperty("replace", replace); + JsonArray array = new JsonArray(values.size()); + for (JsonElement value : values) { + array.add(value.deepCopy()); + } + root.add("values", array); + return root; + } + + private static String id(JsonElement value) { + if (value.isJsonPrimitive() && value.getAsJsonPrimitive().isString()) { + return value.getAsString(); + } + if (value.isJsonObject()) { + JsonElement id = value.getAsJsonObject().get("id"); + return id != null && id.isJsonPrimitive() && id.getAsJsonPrimitive().isString() ? id.getAsString() : null; + } + return null; + } + + private static List union(Collection first, Collection second) { + LinkedHashSet merged = new LinkedHashSet<>(first); + merged.addAll(second); + return List.copyOf(merged); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/subterrain/BakedFeature.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/subterrain/BakedFeature.java new file mode 100644 index 000000000..6ac69bc54 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/subterrain/BakedFeature.java @@ -0,0 +1,25 @@ +package art.arcane.iris.pack.datapack.export.compile.subterrain; + +import java.util.List; +import java.util.Objects; + +public record BakedFeature(String id, List rooms, int lowestAnchorY, int highestAnchorY) { + public BakedFeature { + Objects.requireNonNull(id, "Subterrain feature id"); + rooms = List.copyOf(rooms); + if (rooms.isEmpty()) { + throw new IllegalArgumentException("Subterrain feature " + id + " has no baked rooms"); + } + if (lowestAnchorY > highestAnchorY) { + throw new IllegalArgumentException("Subterrain feature " + id + " has an empty anchor range " + lowestAnchorY + ".." + highestAnchorY); + } + } + + public int reach() { + int reach = 0; + for (BakedRoom room : rooms) { + reach = Math.max(reach, room.reach()); + } + return reach; + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/subterrain/BakedRoom.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/subterrain/BakedRoom.java new file mode 100644 index 000000000..6d4e1c8c3 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/subterrain/BakedRoom.java @@ -0,0 +1,25 @@ +package art.arcane.iris.pack.datapack.export.compile.subterrain; + +import art.arcane.iris.pack.datapack.export.TemplateVolume; +import art.arcane.iris.pack.datapack.export.VanillaBlockState; + +import java.util.Objects; + +public record BakedRoom(int originX, int originY, int originZ, TemplateVolume core, TemplateVolume shell, + int anchorX, int anchorY, int anchorZ, VanillaBlockState anchorState) { + public BakedRoom { + Objects.requireNonNull(core, "Room core volume"); + Objects.requireNonNull(shell, "Room shell volume"); + Objects.requireNonNull(anchorState, "Room anchor state"); + if (core.sizeX() != shell.sizeX() || core.sizeY() != shell.sizeY() || core.sizeZ() != shell.sizeZ()) { + throw new IllegalArgumentException("Room core and shell volumes differ in size"); + } + if (anchorX < 0 || anchorY < 0 || anchorZ < 0 || anchorX >= core.sizeX() || anchorY >= core.sizeY() || anchorZ >= core.sizeZ()) { + throw new IllegalArgumentException("Room anchor " + anchorX + "," + anchorY + "," + anchorZ + " lies outside the room"); + } + } + + public int reach() { + return Math.max(Math.max(anchorX, core.sizeX() - 1 - anchorX), Math.max(anchorZ, core.sizeZ() - 1 - anchorZ)); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/subterrain/RoomStructures.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/subterrain/RoomStructures.java new file mode 100644 index 000000000..9c95f6d15 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/subterrain/RoomStructures.java @@ -0,0 +1,185 @@ +package art.arcane.iris.pack.datapack.export.compile.subterrain; + +import art.arcane.iris.generation.subterrain.IrisSubterrainFeature; +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.StructureTemplateEncoder; +import art.arcane.iris.pack.datapack.export.TemplateBlock; +import art.arcane.iris.pack.datapack.export.TemplateVolume; +import art.arcane.iris.pack.datapack.export.VanillaBlockState; +import art.arcane.iris.pack.datapack.export.compile.WorldgenSyntax; +import art.arcane.iris.pack.datapack.export.compile.object.WideObjectStructures; +import art.arcane.iris.pack.datapack.export.ir.BinaryResource; +import art.arcane.iris.pack.datapack.export.ir.JsonResource; +import art.arcane.volmlib.util.nbt.tag.CompoundTag; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +final class RoomStructures { + static final String FOLDER = "subterrain/"; + private static final String ANCHOR = "subterrain/anchor"; + private static final String EMPTY_POOL = "minecraft:empty"; + private static final String UNDERGROUND_STEP = "underground_structures"; + private static final int GROUND_LEVEL_DELTA = 1; + private static final int CHUNK_WIDTH = 16; + private static final int MAX_SPACING = 4096; + private static final long FNV_OFFSET = 0xcbf29ce484222325L; + private static final long FNV_PRIME = 0x100000001b3L; + private static final VanillaBlockState JIGSAW = VanillaBlockState.parse("minecraft:jigsaw[orientation=north_up]"); + private static final VanillaBlockState CAVE_AIR = VanillaBlockState.parse("minecraft:cave_air"); + private static final VanillaBlockState WATER = VanillaBlockState.parse("minecraft:water"); + private static final VanillaBlockState LAVA = VanillaBlockState.parse("minecraft:lava"); + + private final String namespace; + private final DataVersion target; + private final WorldgenSyntax syntax; + private final StructureTemplateEncoder encoder = new StructureTemplateEncoder(); + + RoomStructures(String namespace, DataVersion target) { + this.namespace = namespace; + this.target = target; + syntax = WorldgenSyntax.forTarget(target); + } + + Compiled compile(IrisSubterrainFeature feature, BakedFeature baked, String path, String biomes) { + String id = namespace + ":" + path; + List templates = new ArrayList<>(2 * baked.rooms().size()); + JsonArray elements = new JsonArray(baked.rooms().size()); + for (int index = 0; index < baked.rooms().size(); index++) { + BakedRoom room = baked.rooms().get(index); + String core = path + "/" + index + "_core"; + templates.add(template(core, anchored(room))); + JsonObject element = single(core, new JsonArray()); + if (!room.shell().blocks().isEmpty()) { + String shell = path + "/" + index + "_shell"; + templates.add(template(shell, room.shell())); + JsonArray parts = new JsonArray(2); + parts.add(element); + parts.add(single(shell, guard())); + element = new JsonObject(); + element.addProperty("element_type", "minecraft:list_pool_element"); + element.add("elements", parts); + element.addProperty("projection", "rigid"); + } + JsonObject weighted = new JsonObject(); + weighted.add("element", element); + weighted.addProperty("weight", 1); + elements.add(weighted); + } + JsonObject pool = new JsonObject(); + pool.addProperty("fallback", EMPTY_POOL); + pool.add("elements", elements); + JsonObject structure = WideObjectStructures.jigsaw(new WideObjectStructures.SinglePiece(biomes, id, Math.max(1, baked.reach()), + startHeight(baked), Optional.empty(), Optional.of(namespace + ":" + ANCHOR), UNDERGROUND_STEP)); + List json = new ArrayList<>(3); + json.add(new JsonResource("data/" + namespace + "/worldgen/template_pool/" + path + ".json", pool)); + json.add(new JsonResource("data/" + namespace + "/worldgen/structure/" + path + ".json", structure)); + json.add(new JsonResource("data/" + namespace + "/worldgen/structure_set/" + path + ".json", + WideObjectStructures.randomSpread(id, spacing(feature), salt(feature.getId())))); + return new Compiled(json, templates); + } + + private static int salt(String featureId) { + long hash = FNV_OFFSET; + for (int index = 0; index < featureId.length(); index++) { + hash = (hash ^ featureId.charAt(index)) * FNV_PRIME; + } + return (int) (hash ^ (hash >>> 32)) & Integer.MAX_VALUE; + } + + private static WideObjectStructures.Spacing spacing(IrisSubterrainFeature feature) { + int spacing = Math.max(1, Math.min(MAX_SPACING, (int) Math.round(feature.getSpacing() / (double) CHUNK_WIDTH))); + return new WideObjectStructures.Spacing(spacing, spacing / 2, Math.min(1.0, feature.getProbability())); + } + + private TemplateVolume anchored(BakedRoom room) { + List blocks = new ArrayList<>(room.core().blocks().size() + 1); + blocks.addAll(room.core().blocks()); + CompoundTag jigsaw = new CompoundTag(); + jigsaw.putString("id", "minecraft:jigsaw"); + jigsaw.putString("name", namespace + ":" + ANCHOR); + jigsaw.putString("target", EMPTY_POOL); + jigsaw.putString("pool", EMPTY_POOL); + jigsaw.putString("final_state", room.anchorState().canonical()); + jigsaw.putString("joint", "rollable"); + jigsaw.putInt("selection_priority", 0); + jigsaw.putInt("placement_priority", 0); + blocks.add(new TemplateBlock(room.anchorX(), room.anchorY(), room.anchorZ(), JIGSAW, jigsaw)); + return new TemplateVolume(room.core().sizeX(), room.core().sizeY(), room.core().sizeZ(), blocks); + } + + private BinaryResource template(String path, TemplateVolume volume) { + try { + return new BinaryResource("data/" + namespace + "/structure/" + path + ".nbt", encoder.encode(volume, target)); + } catch (IOException exception) { + throw new UncheckedIOException("Could not encode subterrain template " + namespace + ":" + path, exception); + } + } + + private JsonObject single(String path, JsonArray processors) { + JsonObject list = new JsonObject(); + list.add("processors", processors); + JsonObject element = new JsonObject(); + element.addProperty("element_type", "minecraft:single_pool_element"); + element.addProperty("location", namespace + ":" + path); + element.add("processors", list); + element.addProperty("projection", "rigid"); + return element; + } + + private JsonArray guard() { + JsonArray rules = new JsonArray(3); + rules.add(rule(location("minecraft:tag_match", "tag", "minecraft:air"), CAVE_AIR)); + rules.add(rule(location("minecraft:block_match", "block", WATER.name()), WATER)); + rules.add(rule(location("minecraft:block_match", "block", LAVA.name()), LAVA)); + JsonObject processor = new JsonObject(); + processor.addProperty("processor_type", "minecraft:rule"); + processor.add("rules", rules); + JsonArray processors = new JsonArray(1); + processors.add(processor); + return processors; + } + + private JsonObject rule(JsonObject location, VanillaBlockState output) { + JsonObject input = new JsonObject(); + input.addProperty("predicate_type", "minecraft:always_true"); + JsonObject rule = new JsonObject(); + rule.add("input_predicate", input); + rule.add("location_predicate", location); + rule.add("output_state", syntax.blockState(output)); + return rule; + } + + private static JsonObject location(String type, String key, String value) { + JsonObject predicate = new JsonObject(); + predicate.addProperty("predicate_type", type); + predicate.addProperty(key, value); + return predicate; + } + + private static JsonObject startHeight(BakedFeature baked) { + JsonObject height = new JsonObject(); + height.addProperty("type", "minecraft:uniform"); + height.add("min_inclusive", absolute(baked.lowestAnchorY() + GROUND_LEVEL_DELTA)); + height.add("max_inclusive", absolute(baked.highestAnchorY() + GROUND_LEVEL_DELTA)); + return height; + } + + private static JsonObject absolute(int y) { + JsonObject anchor = new JsonObject(); + anchor.addProperty("absolute", y); + return anchor; + } + + record Compiled(List json, List templates) { + Compiled { + json = List.copyOf(json); + templates = List.copyOf(templates); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/subterrain/SubterrainBaker.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/subterrain/SubterrainBaker.java new file mode 100644 index 000000000..c3060f77f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/subterrain/SubterrainBaker.java @@ -0,0 +1,225 @@ +package art.arcane.iris.pack.datapack.export.compile.subterrain; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.subterrain.IrisSubterrainFeature; +import art.arcane.iris.generation.subterrain.SubterrainBounds; +import art.arcane.iris.generation.subterrain.SubterrainCell; +import art.arcane.iris.generation.subterrain.SubterrainPlan; +import art.arcane.iris.generation.subterrain.SubterrainPlanner; +import art.arcane.iris.generation.subterrain.SubterrainRasterizer; +import art.arcane.iris.generation.subterrain.SubterrainSurfaceMaterials; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.pack.datapack.export.TemplateBlock; +import art.arcane.iris.pack.datapack.export.TemplateVolume; +import art.arcane.iris.pack.datapack.export.VanillaBlockState; +import art.arcane.iris.pack.datapack.export.compile.BlockStateResolver; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; +import art.arcane.iris.pack.datapack.export.compile.object.WideObjectStructures; +import art.arcane.iris.pack.datapack.export.ir.DimensionDef; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.pack.value.IrisRange; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.stream.ProceduralStream; +import art.arcane.volmlib.util.stream.interpolation.Interpolated; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.TreeMap; +import java.util.TreeSet; + +public final class SubterrainBaker { + public static final int VARIANTS = 3; + public static final int MAX_REACH = WideObjectStructures.MAX_DISTANCE; + public static final VanillaBlockState NO_BLOCK = VanillaBlockState.parse("minecraft:structure_void"); + private static final VanillaBlockState CAVE_AIR = VanillaBlockState.parse("minecraft:cave_air"); + private static final VanillaBlockState WATER = VanillaBlockState.parse("minecraft:water"); + private static final VanillaBlockState LAVA = VanillaBlockState.parse("minecraft:lava"); + private static final ProceduralStream FLAT_SLOPE = ProceduralStream.of((x, z) -> 0.0, Interpolated.DOUBLE); + private static final int SEARCH_COLUMNS = 64; + private static final int SEARCH_ROWS = 64; + + private final long seed; + private final IrisData data; + private final IrisDimension dimension; + private final BlockStateResolver blocks; + private final List diagnostics; + private final int minY; + private final int maxYExclusive; + private final Map biomes = new HashMap<>(); + + public SubterrainBaker(PackCompileContext context, long seed) { + this.seed = seed; + data = context.data(); + dimension = context.dimension(); + blocks = BlockStateResolver.forContext(context); + diagnostics = context.diagnostics(); + DimensionDef exported = context.dimensionDef(); + minY = Math.max(dimension.getMinHeight(), exported.minY()); + maxYExclusive = Math.min(dimension.getMaxHeight(), exported.minY() + exported.height()); + } + + public Optional bake(IrisSubterrainFeature feature, String resource) { + SubterrainPlanner planner = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), seed, minY, maxYExclusive, + regionResolver(feature))); + List plans = search(planner, feature.getSpacing()); + IrisRange range = feature.getWorldYRange(); + if (plans.isEmpty()) { + String reason = feature.getProbability() > 0 + ? "does not fit its world Y range " + range.getMin() + ".." + range.getMax() + " inside the dimension" + : "has probability 0"; + diagnostics.add(new ExportDiagnostic(ExportDiagnostic.Severity.WARNING, ExportLimit.STRUCTURE_PLACEMENT, resource, + "The room never generates because it " + reason + ", so it is not exported")); + return Optional.empty(); + } + RNG random = new RNG(seed); + List rooms = new ArrayList<>(plans.size()); + long clipped = 0; + for (SubterrainPlan plan : plans) { + Bake bake = new Bake(planner, plan, random, resource); + rooms.add(bake.room()); + clipped += bake.clipped; + } + if (clipped > 0) { + diagnostics.add(new ExportDiagnostic(ExportDiagnostic.Severity.WARNING, ExportLimit.OBJECT_BOUNDS, resource, + clipped + " room blocks lie more than " + MAX_REACH + " blocks from the room center and are left out of the templates")); + } + SubterrainPlan first = plans.getFirst(); + int below = first.centerY() - first.bounds().minY() + 1; + int above = first.bounds().maxY() - first.centerY() + 1; + int lowest = Math.max(minY, (int) Math.ceil(range.getMin())) + below; + int highest = Math.min(maxYExclusive - 1, (int) Math.floor(range.getMax())) - above; + return Optional.of(new BakedFeature(feature.getId(), rooms, lowest, highest)); + } + + private static SubterrainPlanner.RegionResolver regionResolver(IrisSubterrainFeature feature) { + TreeSet allowed = new TreeSet<>(feature.getAllowedRegions()); + String region = allowed.isEmpty() ? "" : allowed.first(); + return (x, z) -> region; + } + + private static List search(SubterrainPlanner planner, int spacing) { + Map found = new TreeMap<>(); + int width = spacing * SEARCH_COLUMNS; + for (int row = 0; row < SEARCH_ROWS && found.size() < VARIANTS; row++) { + int minZ = row * spacing; + for (SubterrainPlan plan : planner.plansForBounds(0, minZ, width - 1, minZ + spacing - 1)) { + found.putIfAbsent(plan.id(), plan); + } + } + List plans = new ArrayList<>(VARIANTS); + for (SubterrainPlan plan : found.values()) { + if (plans.size() == VARIANTS) { + break; + } + plans.add(plan); + } + return plans; + } + + private NativeBlockState painted(int x, int y, int z, SubterrainCell cell, SubterrainSurfaceMaterials surfaces, RNG random) { + String biomeKey = cell.room().biome(); + if (biomeKey.isEmpty()) { + return null; + } + SubterrainSurfaceMaterials.Surface surface = surfaces.surface(x, y, z, cell); + IrisBiome biome = biomes.computeIfAbsent(biomeKey, key -> data.getBiomeLoader().load(key, false)); + if (surface == SubterrainSurfaceMaterials.Surface.INTERIOR || biome == null) { + return null; + } + int height = y - dimension.getMinHeight(); + NativeBlockState candidate = switch (surface) { + case WALL -> biome.getWall().get(random, x, height, z, data); + case FLOOR -> first(biome.generateLayersWithSlope(dimension, x, z, random, 1, height + 1, data, FLAT_SLOPE)); + case CEILING -> first(biome.generateCeilingLayers(dimension, x, z, random, 1, height - 1, data, null)); + case INTERIOR -> null; + }; + return SubterrainRasterizer.isRetainedSolid(candidate) ? candidate : null; + } + + private static NativeBlockState first(KList layers) { + return layers.isEmpty() ? null : layers.getFirst(); + } + + private VanillaBlockState vanilla(NativeBlockState painted, NativeBlockState solid, String resource) { + Optional resolved = painted == null ? Optional.empty() : blocks.resolve(painted.key(), resource); + return resolved.or(() -> blocks.resolve(solid.key(), resource)).orElseThrow(() -> + new IllegalStateException("Subterrain solid block '" + solid.key() + "' of " + resource + " has no Minecraft block state")); + } + + private final class Bake { + private final List plans; + private final SubterrainPlanner planner; + private final SubterrainPlan plan; + private final RNG random; + private final String resource; + private final SubterrainSurfaceMaterials surfaces; + private final List core = new ArrayList<>(); + private final List shell = new ArrayList<>(); + private VanillaBlockState anchorState = NO_BLOCK; + private long clipped; + + private Bake(SubterrainPlanner planner, SubterrainPlan plan, RNG random, String resource) { + this.planner = planner; + this.plan = plan; + this.random = random; + this.resource = resource; + plans = List.of(plan); + surfaces = new SubterrainSurfaceMaterials(planner, plans, dimension, null, data, random, dimension.getMinHeight()); + } + + private BakedRoom room() { + SubterrainBounds bounds = plan.bounds(); + int lowX = Math.max(bounds.minX(), plan.centerX() - MAX_REACH); + int highX = Math.min(bounds.maxX(), plan.centerX() + MAX_REACH); + int lowZ = Math.max(bounds.minZ(), plan.centerZ() - MAX_REACH); + int highZ = Math.min(bounds.maxZ(), plan.centerZ() + MAX_REACH); + for (int x = bounds.minX(); x <= bounds.maxX(); x++) { + for (int z = bounds.minZ(); z <= bounds.maxZ(); z++) { + boolean reachable = x >= lowX && x <= highX && z >= lowZ && z <= highZ; + for (int y = bounds.minY(); y <= bounds.maxY(); y++) { + SubterrainCell cell = planner.sample(plans, x, y, z); + if (!cell.owned()) { + continue; + } + if (reachable) { + add(x - lowX, y - bounds.minY(), z - lowZ, x, y, z, cell); + } else { + clipped++; + } + } + } + } + int sizeX = highX - lowX + 1; + int sizeY = bounds.maxY() - bounds.minY() + 1; + int sizeZ = highZ - lowZ + 1; + return new BakedRoom(lowX, bounds.minY(), lowZ, new TemplateVolume(sizeX, sizeY, sizeZ, core), + new TemplateVolume(sizeX, sizeY, sizeZ, shell), plan.centerX() - lowX, plan.centerY() - bounds.minY(), + plan.centerZ() - lowZ, anchorState); + } + + private void add(int localX, int localY, int localZ, int x, int y, int z, SubterrainCell cell) { + VanillaBlockState state = switch (cell.kind()) { + case AIR -> CAVE_AIR; + case WATER -> WATER; + case LAVA -> LAVA; + case SOLID -> vanilla(painted(x, y, z, cell, surfaces, random), SubterrainRasterizer.state(cell), resource); + case OUTSIDE -> throw new IllegalStateException("Outside cells carry no block"); + }; + TemplateBlock block = new TemplateBlock(localX, localY, localZ, state, null); + if (cell.solid() && !cell.room().reservedSolid()) { + shell.add(block); + } else if (x == plan.centerX() && y == plan.centerY() && z == plan.centerZ()) { + anchorState = state; + } else { + core.add(block); + } + } + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/subterrain/SubterrainStage.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/subterrain/SubterrainStage.java new file mode 100644 index 000000000..ad7fbae12 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/subterrain/SubterrainStage.java @@ -0,0 +1,149 @@ +package art.arcane.iris.pack.datapack.export.compile.subterrain; + +import art.arcane.iris.generation.subterrain.IrisSubterrainFeature; +import art.arcane.iris.generation.subterrain.SubterrainRasterizer; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; +import art.arcane.iris.pack.datapack.export.compile.CompileStage; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; +import art.arcane.iris.pack.datapack.export.compile.structure.PlacementTags; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.datapack.export.ir.JsonResource; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.SortedSet; +import java.util.TreeSet; + +public final class SubterrainStage implements CompileStage { + private static final String NAME = "subterrain"; + private static final long BAKE_SEED = 0x5ab7e4a1c0de5eedL; + + @Override + public String name() { + return NAME; + } + + @Override + public void apply(PackCompileContext context) { + IrisDimension dimension = context.dimension(); + if (!dimension.allowsSubterrainFeatures() || dimension.getSubterrainFeatures() == null) { + return; + } + List features = new ArrayList<>(dimension.getSubterrainFeatures().size()); + for (IrisSubterrainFeature feature : dimension.getSubterrainFeatures()) { + if (feature != null && feature.isEnabled()) { + features.add(feature); + } + } + if (features.isEmpty()) { + return; + } + SubterrainRasterizer.validateMaterials(features); + new Run(context).compile(features); + } + + private static final class Run { + private static final String TAG_FOLDER = "has_structure/"; + + private final PackCompileContext context; + private final String dimensionKey; + private final SubterrainBaker baker; + private final RoomStructures structures; + private final PlacementTags tags; + private final Set paths = new HashSet<>(); + + private Run(PackCompileContext context) { + this.context = context; + dimensionKey = context.dimension().getLoadKey(); + baker = new SubterrainBaker(context, BAKE_SEED); + structures = new RoomStructures(context.namespace(), context.capabilities().target()); + tags = PlacementTags.collect(context); + } + + private void compile(List features) { + for (IrisSubterrainFeature feature : features) { + String resource = "dimensions/" + dimensionKey + "/subterrainFeatures/" + feature.getId(); + String path = unique(RoomStructures.FOLDER + VanillaResourceIdentifier.sanitizePath(feature.getId())); + Optional biomes = biomes(feature, path, resource); + if (biomes.isEmpty()) { + continue; + } + Optional baked = baker.bake(feature, resource); + if (baked.isEmpty()) { + continue; + } + RoomStructures.Compiled compiled = structures.compile(feature, baked.get(), path, biomes.get()); + context.jsonResources().addAll(compiled.json()); + context.binaryResources().addAll(compiled.templates()); + report(feature, baked.get(), resource); + } + } + + private Optional biomes(IrisSubterrainFeature feature, String path, String resource) { + SortedSet regions = new TreeSet<>(feature.getAllowedRegions()); + List scopes = new ArrayList<>(Math.max(1, regions.size())); + SortedSet shared = new TreeSet<>(); + if (regions.isEmpty()) { + tags.dimensionTag().ifPresent(scopes::add); + } + for (String region : regions) { + tags.regionTag(region).ifPresent(scopes::add); + shared.addAll(tags.sharedBiomes(region)); + } + if (scopes.isEmpty()) { + diagnose(ExportDiagnostic.Severity.WARNING, ExportLimit.STRUCTURE_PLACEMENT, resource, + "No exported biome lies in the room's allowed regions, so the room is not exported"); + return Optional.empty(); + } + if (!shared.isEmpty()) { + diagnose(ExportDiagnostic.Severity.INFO, ExportLimit.STRUCTURE_PLACEMENT, resource, + "Region scoping follows biomes: " + String.join(", ", shared) + + " also belong to other regions, where the room generates too"); + } + if (scopes.size() == 1) { + return Optional.of("#" + scopes.getFirst()); + } + JsonArray values = new JsonArray(scopes.size()); + for (String scope : scopes) { + values.add("#" + scope); + } + JsonObject tag = new JsonObject(); + tag.addProperty("replace", false); + tag.add("values", values); + context.jsonResources().add(new JsonResource("data/" + context.namespace() + "/tags/worldgen/biome/" + TAG_FOLDER + path + ".json", tag)); + return Optional.of("#" + context.namespace() + ":" + TAG_FOLDER + path); + } + + private void report(IrisSubterrainFeature feature, BakedFeature baked, String resource) { + diagnose(ExportDiagnostic.Severity.INFO, ExportLimit.SUBTERRAIN_VARIANT, resource, + "The room exports as " + baked.rooms().size() + " fixed shapes placed with a random quarter turn, not a new shape per placement"); + diagnose(ExportDiagnostic.Severity.INFO, ExportLimit.SUBTERRAIN_TERRAIN, resource, + "The room is placed as a structure after terrain and caves: its walls are painted only where rock remains, it does not stop below" + + " oceans, rivers or cave pools, rooms do not yield to each other by priority, and later decoration can overlap it"); + if (feature.getBiome() != null && !feature.getBiome().isBlank()) { + diagnose(ExportDiagnostic.Severity.INFO, ExportLimit.SUBTERRAIN_BIOME, resource, + "The room biome '" + feature.getBiome().trim() + "' is not exported: the room keeps the surrounding biome, its mob spawns," + + " ambience, decorators and objects, while the room biome's floor, ceiling and wall blocks are baked into the room"); + } + } + + private String unique(String path) { + String candidate = path; + for (int suffix = 1; !paths.add(candidate); suffix++) { + candidate = path + "_" + suffix; + } + return candidate; + } + + private void diagnose(ExportDiagnostic.Severity severity, ExportLimit limit, String resource, String message) { + context.diagnostics().add(new ExportDiagnostic(severity, limit, resource, message)); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/surface/MaterialProgramCompiler.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/surface/MaterialProgramCompiler.java new file mode 100644 index 000000000..bac9872e5 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/surface/MaterialProgramCompiler.java @@ -0,0 +1,566 @@ +package art.arcane.iris.pack.datapack.export.compile.surface; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.biome.IrisBiomePaletteLayer; +import art.arcane.iris.generation.decoration.IrisOreGenerator; +import art.arcane.iris.generation.terrain.IrisMaterialPalette; +import art.arcane.iris.generation.terrain.IrisRegion; +import art.arcane.iris.pack.datapack.export.VanillaBlockState; +import art.arcane.iris.pack.datapack.export.compile.BlockStateResolver; +import art.arcane.iris.pack.datapack.export.compile.ExportedBiomes; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; +import art.arcane.iris.pack.datapack.export.compile.WeightedState; +import art.arcane.iris.pack.datapack.export.compile.WorldgenSyntax; +import art.arcane.iris.pack.datapack.export.ir.DimensionDef; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.JsonResource; +import art.arcane.iris.pack.datapack.export.ir.MaterialProgram; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +public final class MaterialProgramCompiler { + public static final int ORE_VEIN_BUDGET = 8; + public static final String WALL_SHELL = "cave/wall_shell"; + private static final int WALL_MATERIALS = 3; + private static final int LOCK_PHASE = 512; + private static final String PALETTE_SCOPE = "palette"; + private static final String DATA_PREFIX = "data/"; + private static final String DENSITY_FUNCTION_FOLDER = "/worldgen/density_function/"; + private static final String JSON = ".json"; + private static final VanillaBlockState AIR = VanillaBlockState.parse("minecraft:air"); + private static final Map SUBMERGED_SOIL = Map.of( + "minecraft:grass_block", "minecraft:dirt", + "minecraft:mycelium", "minecraft:dirt", + "minecraft:dirt_path", "minecraft:dirt", + "minecraft:farmland", "minecraft:dirt", + "minecraft:crimson_nylium", "minecraft:netherrack", + "minecraft:warped_nylium", "minecraft:netherrack"); + private static final Comparator HEAVIEST_FIRST = Comparator.comparingInt(WeightedState::weight).reversed() + .thenComparing(entry -> entry.state().canonical()); + private static final Comparator MOST_SIMILAR = Comparator.comparingDouble(Similarity::blocks).thenComparingDouble(Similarity::materials); + private static final Set FORM_WORDS = Set.of("block", "powder"); + + private final PackCompileContext context; + private final SurfacePalettes palettes; + private final DimensionDef dimension; + private final List diagnostics; + private final Set reported = new HashSet<>(); + + public MaterialProgramCompiler(PackCompileContext context, BlockStateResolver blocks) { + this.context = context; + this.palettes = new SurfacePalettes(context.namespace(), context.noises(), blocks, context.capabilities().target()); + this.dimension = context.dimensionDef(); + this.diagnostics = context.diagnostics(); + } + + public MaterialProgram compile() { + Plan plan = plan(ExportedBiomes.load(context)); + if (!plan.walls().isEmpty()) { + context.jsonResources().add(new JsonResource(DATA_PREFIX + context.namespace() + DENSITY_FUNCTION_FOLDER + WALL_SHELL + JSON, wallShell())); + } + return new MaterialProgram(render(plan, WorldgenSyntax.forTarget(context.capabilities().target()))); + } + + private Plan plan(ExportedBiomes biomes) { + Map> floors = new LinkedHashMap<>(); + Map> ceilings = new LinkedHashMap<>(); + Map, List> biomeSurfaceOres = new LinkedHashMap<>(); + Map, List> biomeUndergroundOres = new LinkedHashMap<>(); + Map, List> regionSurfaceOres = new LinkedHashMap<>(); + Map, List> regionUndergroundOres = new LinkedHashMap<>(); + for (String biomeId : biomes.inCodeOrder(biomes.byId().keySet())) { + IrisBiome biome = biomes.byId().get(biomeId); + String resource = biomes.resource(biomeId); + group(floors, floorStack(biome, resource), biomeId); + group(ceilings, layerStack(biome.getCaveCeilingLayers(), false, resource, biome), biomeId); + group(biomeSurfaceOres, ores(biome.getOres(), true, resource), biomeId); + group(biomeUndergroundOres, ores(biome.getOres(), false, resource), biomeId); + IrisRegion region = biomes.region(biomeId); + if (region != null) { + String regionResource = "regions/" + region.getLoadKey(); + group(regionSurfaceOres, ores(region.getOres(), true, regionResource), biomeId); + group(regionUndergroundOres, ores(region.getOres(), false, regionResource), biomeId); + } + if (biome.getSeaLayers() != null && !biome.getSeaLayers().isEmpty()) { + report(ExportDiagnostic.Severity.INFO, ExportLimit.SEA_LAYER, resource, "Sea layers paint inside the water column and are not exported"); + } + } + String dimensionResource = "dimensions/" + context.dimension().getLoadKey(); + List surfaceOres = oreGroups(biomeSurfaceOres, regionSurfaceOres, ores(context.dimension().getOres(), true, dimensionResource)); + List undergroundOres = oreGroups(biomeUndergroundOres, regionUndergroundOres, ores(context.dimension().getOres(), false, dimensionResource)); + VanillaBlockState bedrock = context.dimension().isBedrock() ? VanillaBlockState.parse("minecraft:bedrock") : null; + return new Plan(bedrock, surfaceOres, stackGroups(floors), stackGroups(ceilings), wallShells(biomes), undergroundOres); + } + + private Stack floorStack(IrisBiome biome, String resource) { + if (biome.isLockLayers()) { + return lockStack(biome, resource); + } + return layerStack(biome.getLayers(), true, resource, biome); + } + + private Stack layerStack(List layers, boolean floor, String resource, IrisBiome biome) { + if (layers == null || layers.isEmpty()) { + return null; + } + List bands = new ArrayList<>(layers.size()); + int cumulativeMin = 0; + int cumulativeRange = 0; + boolean variable = false; + for (int index = 0; index < layers.size(); index++) { + IrisBiomePaletteLayer layer = layers.get(index); + if (layer == null || (floor && !flatValid(layer))) { + continue; + } + int min = Math.max(0, layer.getMinHeight()); + int max = Math.max(min, layer.getMaxHeight()); + if (max <= 0) { + continue; + } + Optional palette = palettes.palette(layer.getPalette(), layer.getStyle(), layer.getZoom(), index, resource); + if (palette.isEmpty()) { + continue; + } + cumulativeMin += min; + cumulativeRange += max - min; + variable |= min != max; + SurfacePalette submerged = floor && bands.isEmpty() ? submerged(palette.get()) : null; + bands.add(new LayerBand(cumulativeMin - 1, cumulativeRange, palette.get(), submerged)); + } + if (variable) { + report(ExportDiagnostic.Severity.INFO, ExportLimit.LAYER_THICKNESS, resource, + "Layer thickness in biome '" + biome.getLoadKey() + "' follows Minecraft's surface noise, so its layers vary together"); + } + return bands.isEmpty() ? null : new LayerStack(bands, floor); + } + + private Stack lockStack(IrisBiome biome, String resource) { + List layerPalettes = new ArrayList<>(); + List thicknesses = new ArrayList<>(); + boolean variable = false; + for (int index = 0; index < biome.getLayers().size(); index++) { + IrisBiomePaletteLayer layer = biome.getLayers().get(index); + if (layer == null || !flatValid(layer)) { + continue; + } + int min = Math.max(0, layer.getMinHeight()); + int max = Math.max(min, layer.getMaxHeight()); + int thickness = (int) Math.round((min + max) / 2.0); + Optional palette = palettes.palette(layer.getPalette(), layer.getStyle(), layer.getZoom(), index, resource); + if (thickness <= 0 || palette.isEmpty()) { + continue; + } + variable |= min != max; + layerPalettes.add(palette.get()); + thicknesses.add(thickness); + } + int cycle = 0; + for (int thickness : thicknesses) { + cycle += thickness; + } + if (cycle == 0) { + return null; + } + if (variable) { + report(ExportDiagnostic.Severity.INFO, ExportLimit.LAYER_THICKNESS, resource, + "Locked layer bands in biome '" + biome.getLoadKey() + "' use each layer's mean thickness"); + } + List bands = new ArrayList<>(); + int current = -1; + int top = dimension.minY() + dimension.height() - 1; + for (int y = top; y >= dimension.minY(); y--) { + int layer = lockLayer(thicknesses, Math.floorMod(LOCK_PHASE - (y - dimension.minY()) - 1, cycle)); + if (layer != current && current >= 0) { + bands.add(new LockBand(y + 1, layerPalettes.get(current))); + } + current = layer; + } + bands.add(new LockBand(dimension.minY(), layerPalettes.get(current))); + return new LockStack(Math.max(1, biome.getLockLayersMax()), bands); + } + + private List ores(List generators, boolean surface, String resource) { + List ores = new ArrayList<>(); + if (generators == null) { + return ores; + } + for (int index = 0; index < generators.size(); index++) { + IrisOreGenerator generator = generators.get(index); + if (generator == null || generator.isGenerateSurface() != surface || generator.getThreshold() <= 0 || generator.getRange() == null) { + continue; + } + IrisMaterialPalette source = generator.getPalette(); + if (source == null) { + continue; + } + Optional palette = palettes.palette(source.getPalette(), source.getStyle(), source.getZoom(), index, resource); + int minY = dimension.minY() + (int) Math.ceil(generator.getRange().getMin()); + int maxY = dimension.minY() + (int) Math.floor(generator.getRange().getMax()); + if (palette.isEmpty() || maxY < minY) { + continue; + } + SurfacePalettes.OreChance chance = palettes.chance(Math.min(1.0, generator.getThreshold()), generator.getChanceStyle(), 1.0, index); + ores.add(new OreRule(minY, maxY, chance, palette.get())); + } + return ores; + } + + private List wallShells(ExportedBiomes biomes) { + if (context.caveField().isEmpty()) { + return List.of(); + } + Map, List> groups = new LinkedHashMap<>(); + for (String biomeId : biomes.inCodeOrder(biomes.byId().keySet())) { + IrisBiome biome = biomes.byId().get(biomeId); + IrisBiomePaletteLayer wall = biome.getWall(); + if (wall == null || wall.getPalette() == null || wall.getPalette().isEmpty()) { + continue; + } + List materials = wallMaterials(palettes.resolve(wall.getPalette(), biomes.resource(biomeId))); + if (materials.isEmpty()) { + continue; + } + if (materials.size() > WALL_MATERIALS) { + materials = List.copyOf(materials.subList(0, WALL_MATERIALS)); + report(ExportDiagnostic.Severity.INFO, ExportLimit.WALL_PALETTE, biomes.resource(biomeId), + "The wall palette keeps its " + WALL_MATERIALS + " most common materials"); + } + groups.computeIfAbsent(materials, ignored -> new ArrayList<>()).add(biomeId); + } + List, List>> ranked = new ArrayList<>(groups.entrySet()); + ranked.sort(Comparator.comparingInt((Map.Entry, List> entry) -> entry.getValue().size()).reversed()); + int budget = ORE_VEIN_BUDGET - deepCaveRules(); + List kept = new ArrayList<>(Math.min(budget, ranked.size())); + for (int index = 0; index < ranked.size(); index++) { + Map.Entry, List> group = ranked.get(index); + if (index < budget) { + kept.add(new WallShell(group.getKey(), new ArrayList<>(group.getValue()))); + continue; + } + WallShell nearest = nearest(kept, group.getKey()); + for (String biomeId : group.getValue()) { + nearest.biomeIds().add(biomeId); + report(ExportDiagnostic.Severity.WARNING, ExportLimit.WALL_PALETTE, biomes.resource(biomeId), + "The wall palette exceeds the " + budget + " wall palettes per dimension and uses the nearest kept palette (" + names(nearest.materials()) + ")"); + } + } + List shells = new ArrayList<>(kept.size()); + for (WallShell shell : kept) { + shells.add(new WallShell(shell.materials(), biomes.inCodeOrder(shell.biomeIds()))); + } + return shells; + } + + private JsonObject render(Plan plan, WorldgenSyntax syntax) { + List rules = new ArrayList<>(); + context.deepCaves().ifPresent(opening -> rules.add(deepCaveRule(opening, syntax))); + if (plan.bedrock() != null) { + JsonObject floor = MaterialRules.verticalGradient(context.namespace() + ":surface/bedrock", dimension.minY(), dimension.minY() + 1); + rules.add(MaterialRules.condition(floor, MaterialRules.block(syntax, plan.bedrock()))); + } + int oreIndex = renderOres(plan.surfaceOres(), syntax, 0, rules); + if (!plan.floors().isEmpty()) { + rules.add(MaterialRules.condition(MaterialRules.stoneDepth(depth(plan.floors()) - 1, 0, false), dispatch(plan.floors(), syntax))); + } + if (!plan.ceilings().isEmpty()) { + rules.add(MaterialRules.condition(MaterialRules.stoneDepth(depth(plan.ceilings()) - 1, 0, true), dispatch(plan.ceilings(), syntax))); + } + for (WallShell shell : plan.walls()) { + rules.add(MaterialRules.condition(MaterialRules.biome(shell.biomeIds()), wallRule(shell, syntax))); + } + renderOres(plan.undergroundOres(), syntax, oreIndex, rules); + return MaterialRules.sequence(rules); + } + + private JsonObject dispatch(List groups, WorldgenSyntax syntax) { + int chunk = Math.max(1, (int) Math.ceil(Math.sqrt(groups.size()))); + List outer = new ArrayList<>(); + for (int start = 0; start < groups.size(); start += chunk) { + List slice = groups.subList(start, Math.min(groups.size(), start + chunk)); + List inner = new ArrayList<>(slice.size()); + Set union = new LinkedHashSet<>(); + for (StackGroup group : slice) { + inner.add(MaterialRules.condition(MaterialRules.biome(group.biomeIds()), renderStack(group.stack(), syntax))); + union.addAll(group.biomeIds()); + } + if (slice.size() == 1 || slice.size() == groups.size()) { + outer.addAll(inner); + } else { + outer.add(MaterialRules.condition(MaterialRules.biome(union), MaterialRules.sequence(inner))); + } + } + return outer.size() == 1 ? outer.getFirst() : MaterialRules.sequence(outer); + } + + private JsonObject renderStack(Stack stack, WorldgenSyntax syntax) { + return switch (stack) { + case LayerStack layers -> renderLayers(layers, syntax); + case LockStack lock -> renderLock(lock, syntax); + }; + } + + private JsonObject renderLayers(LayerStack stack, WorldgenSyntax syntax) { + List rules = new ArrayList<>(stack.bands().size()); + for (LayerBand band : stack.bands()) { + JsonObject palette = palettes.render(band.palette(), syntax, PALETTE_SCOPE, dimension.fluidHeight()); + if (band.submerged() != null) { + palette = MaterialRules.sequence(List.of( + MaterialRules.condition(MaterialRules.notUnderwater(), palette), + palettes.render(band.submerged(), syntax, PALETTE_SCOPE, dimension.fluidHeight()))); + } + rules.add(MaterialRules.condition(MaterialRules.stoneDepth(band.offset(), band.secondaryRange(), !stack.floor()), palette)); + } + return rules.size() == 1 ? rules.getFirst() : MaterialRules.sequence(rules); + } + + private JsonObject renderLock(LockStack stack, WorldgenSyntax syntax) { + List rules = new ArrayList<>(stack.bands().size()); + for (LockBand band : stack.bands()) { + JsonObject palette = palettes.render(band.palette(), syntax, PALETTE_SCOPE, dimension.fluidHeight()); + rules.add(MaterialRules.condition(MaterialRules.yAbove(band.fromY()), palette)); + } + return MaterialRules.condition(MaterialRules.stoneDepth(stack.maxDepth() - 1, 0, false), MaterialRules.sequence(rules)); + } + + private int renderOres(List groups, WorldgenSyntax syntax, int firstIndex, List rules) { + int oreIndex = firstIndex; + for (OreGroup group : groups) { + List ores = new ArrayList<>(group.ores().size()); + for (OreRule ore : group.ores()) { + int center = (ore.minY() + ore.maxY()) / 2; + JsonObject palette = palettes.render(ore.palette(), syntax, PALETTE_SCOPE, center); + JsonObject chance = palettes.renderChance(ore.chance(), "ore_" + oreIndex++, center, palette); + List band = new ArrayList<>(2); + if (ore.minY() > dimension.minY()) { + band.add(MaterialRules.yAbove(ore.minY())); + } + if (ore.maxY() < dimension.minY() + dimension.height() - 1) { + band.add(MaterialRules.not(MaterialRules.yAbove(ore.maxY() + 1))); + } + ores.add(MaterialRules.guarded(band, chance)); + } + JsonObject sequence = ores.size() == 1 ? ores.getFirst() : MaterialRules.sequence(ores); + rules.add(group.biomeIds().isEmpty() ? sequence : MaterialRules.condition(MaterialRules.biome(group.biomeIds()), sequence)); + } + return oreIndex; + } + + private JsonObject wallRule(WallShell shell, WorldgenSyntax syntax) { + List materials = shell.materials(); + int total = 0; + for (WeightedState material : materials) { + total += material.weight(); + } + VanillaBlockState filler = materials.getFirst().state(); + VanillaBlockState ore = materials.size() > 1 ? materials.get(1).state() : filler; + VanillaBlockState raw = materials.size() > 2 ? materials.get(2).state() : ore; + int oreWeight = materials.size() > 1 ? materials.get(1).weight() : 0; + int rawWeight = materials.size() > 2 ? materials.get(2).weight() : 0; + float richness = (float) (oreWeight + rawWeight) / total; + float rawChance = oreWeight + rawWeight == 0 ? 0.0f : (float) rawWeight / (oreWeight + rawWeight); + return MaterialRules.oreVein(syntax, new MaterialRules.OreVeinBlocks(filler, ore, raw, richness, rawChance), + new JsonPrimitive(context.namespace() + ":" + WALL_SHELL)); + } + + private JsonObject wallShell() { + JsonPrimitive caves = new JsonPrimitive(context.namespace() + ":" + context.caveField().orElseThrow().name()); + return MaterialRules.rangeChoice(caves, 0.0, context.wallShellBand()); + } + + private JsonObject deepCaveRule(Field.Shared opening, WorldgenSyntax syntax) { + return MaterialRules.oreVein(syntax, new MaterialRules.OreVeinBlocks(AIR, AIR, AIR, 0.0f, 0.0f), + new JsonPrimitive(context.namespace() + ":" + opening.name())); + } + + private int deepCaveRules() { + return context.deepCaves().isPresent() ? 1 : 0; + } + + private List oreGroups(Map, List> biomeOres, Map, List> regionOres, List dimensionOres) { + List groups = new ArrayList<>(biomeOres.size() + regionOres.size() + 1); + for (Map.Entry, List> entry : biomeOres.entrySet()) { + groups.add(new OreGroup(entry.getKey(), entry.getValue())); + } + for (Map.Entry, List> entry : regionOres.entrySet()) { + groups.add(new OreGroup(entry.getKey(), entry.getValue())); + } + if (!dimensionOres.isEmpty()) { + groups.add(new OreGroup(dimensionOres, List.of())); + } + return groups; + } + + private SurfacePalette submerged(SurfacePalette palette) { + boolean changed = false; + List entries = new ArrayList<>(palette.entries().size()); + for (WeightedState entry : palette.entries()) { + String replacement = SUBMERGED_SOIL.get(entry.state().name()); + changed |= replacement != null; + entries.add(replacement == null ? entry : new WeightedState(VanillaBlockState.parse(replacement), entry.weight())); + } + return changed ? new SurfacePalette(entries, palette.selection(), palette.noise(), palette.thresholds()) : null; + } + + private void report(ExportDiagnostic.Severity severity, ExportLimit limit, String resource, String message) { + if (!reported.add(limit + "|" + resource + "|" + message)) { + return; + } + diagnostics.add(new ExportDiagnostic(severity, limit, resource, message)); + } + + private static List stackGroups(Map> grouped) { + List groups = new ArrayList<>(grouped.size()); + for (Map.Entry> entry : grouped.entrySet()) { + groups.add(new StackGroup(entry.getKey(), List.copyOf(entry.getValue()))); + } + return groups; + } + + private static void group(Map> groups, K key, String biomeId) { + if (key == null || (key instanceof List list && list.isEmpty())) { + return; + } + groups.computeIfAbsent(key, ignored -> new ArrayList<>()).add(biomeId); + } + + private static int depth(List groups) { + int depth = 1; + for (StackGroup group : groups) { + depth = Math.max(depth, switch (group.stack()) { + case LayerStack layers -> layers.bands().getLast().offset() + layers.bands().getLast().secondaryRange() + 1; + case LockStack lock -> lock.maxDepth(); + }); + } + return depth; + } + + private static boolean flatValid(IrisBiomePaletteLayer layer) { + return layer.getSlopeCondition() == null || layer.getSlopeCondition().isValid(0.0); + } + + private static int lockLayer(List thicknesses, int position) { + int boundary = 0; + for (int index = 0; index < thicknesses.size(); index++) { + boundary += thicknesses.get(index); + if (position < boundary) { + return index; + } + } + return thicknesses.size() - 1; + } + + private static List wallMaterials(List entries) { + Map weights = new LinkedHashMap<>(); + for (WeightedState entry : entries) { + weights.merge(entry.state(), entry.weight(), Integer::sum); + } + List materials = new ArrayList<>(weights.size()); + for (Map.Entry weight : weights.entrySet()) { + materials.add(new WeightedState(weight.getKey(), weight.getValue())); + } + materials.sort(HEAVIEST_FIRST); + return materials; + } + + private static WallShell nearest(List kept, List materials) { + WallShell nearest = kept.getFirst(); + Similarity best = similarity(materials, nearest.materials()); + for (WallShell shell : kept) { + Similarity candidate = similarity(materials, shell.materials()); + if (MOST_SIMILAR.compare(candidate, best) > 0) { + best = candidate; + nearest = shell; + } + } + return nearest; + } + + private static Similarity similarity(List materials, List candidate) { + return new Similarity(overlap(shares(materials, false), shares(candidate, false)), overlap(shares(materials, true), shares(candidate, true))); + } + + private static Map shares(List materials, boolean byMaterial) { + double total = 0.0; + for (WeightedState material : materials) { + total += material.weight(); + } + Map shares = new HashMap<>(materials.size() * 2); + for (WeightedState material : materials) { + String name = material.state().name(); + shares.merge(byMaterial ? baseMaterial(name) : name, material.weight() / total, Double::sum); + } + return shares; + } + + private static double overlap(Map first, Map second) { + double overlap = 0.0; + for (Map.Entry share : first.entrySet()) { + overlap += Math.min(share.getValue(), second.getOrDefault(share.getKey(), 0.0)); + } + return overlap; + } + + private static String baseMaterial(String block) { + List words = List.of(block.substring(block.indexOf(':') + 1).split("_")); + int last = words.size() - 1; + while (last > 0 && FORM_WORDS.contains(words.get(last))) { + last--; + } + return words.get(last); + } + + private static String names(List materials) { + List names = new ArrayList<>(materials.size()); + for (WeightedState material : materials) { + names.add(material.state().canonical()); + } + return String.join(", ", names); + } + + private sealed interface Stack permits LayerStack, LockStack { + } + + private record LayerStack(List bands, boolean floor) implements Stack { + } + + private record LockStack(int maxDepth, List bands) implements Stack { + } + + private record LayerBand(int offset, int secondaryRange, SurfacePalette palette, SurfacePalette submerged) { + } + + private record LockBand(int fromY, SurfacePalette palette) { + } + + private record StackGroup(Stack stack, List biomeIds) { + } + + private record OreRule(int minY, int maxY, SurfacePalettes.OreChance chance, SurfacePalette palette) { + } + + private record OreGroup(List ores, List biomeIds) { + } + + private record WallShell(List materials, List biomeIds) { + } + + private record Similarity(double blocks, double materials) { + } + + private record Plan(VanillaBlockState bedrock, List surfaceOres, List floors, List ceilings, + List walls, List undergroundOres) { + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/surface/MaterialRules.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/surface/MaterialRules.java new file mode 100644 index 000000000..a57b316b4 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/surface/MaterialRules.java @@ -0,0 +1,148 @@ +package art.arcane.iris.pack.datapack.export.compile.surface; + +import art.arcane.iris.pack.datapack.export.VanillaBlockState; +import art.arcane.iris.pack.datapack.export.compile.WorldgenSyntax; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +import java.util.Collection; +import java.util.List; + +final class MaterialRules { + static final int MIN_ANCHOR = -2032; + static final int MAX_ANCHOR = 2031; + + private MaterialRules() { + } + + static JsonObject sequence(List rules) { + JsonArray sequence = new JsonArray(rules.size()); + for (JsonObject rule : rules) { + sequence.add(rule); + } + JsonObject json = typed("minecraft:sequence"); + json.add("sequence", sequence); + return json; + } + + static JsonObject condition(JsonObject condition, JsonObject rule) { + JsonObject json = typed("minecraft:condition"); + json.add("if_true", condition); + json.add("then_run", rule); + return json; + } + + static JsonObject guarded(List conditions, JsonObject rule) { + JsonObject guarded = rule; + for (int index = conditions.size() - 1; index >= 0; index--) { + guarded = condition(conditions.get(index), guarded); + } + return guarded; + } + + static JsonObject block(WorldgenSyntax syntax, VanillaBlockState state) { + JsonObject json = typed("minecraft:block"); + json.add("result_state", syntax.blockState(state)); + return json; + } + + static JsonObject stoneDepth(int offset, int secondaryDepthRange, boolean ceiling) { + JsonObject json = typed("minecraft:stone_depth"); + json.addProperty("offset", offset); + json.addProperty("add_surface_depth", false); + json.addProperty("secondary_depth_range", secondaryDepthRange); + json.addProperty("surface_type", ceiling ? "ceiling" : "floor"); + return json; + } + + static JsonObject biome(Collection biomeIds) { + JsonArray ids = new JsonArray(biomeIds.size()); + for (String id : biomeIds) { + ids.add(id); + } + JsonObject json = typed("minecraft:biome"); + json.add("biome_is", ids); + return json; + } + + static JsonObject verticalGradient(String randomName, int trueAtAndBelow, int falseAtAndAbove) { + JsonObject json = typed("minecraft:vertical_gradient"); + json.addProperty("random_name", randomName); + json.add("true_at_and_below", absolute(trueAtAndBelow)); + json.add("false_at_and_above", absolute(falseAtAndAbove)); + return json; + } + + static JsonObject yAbove(int anchor) { + JsonObject json = typed("minecraft:y_above"); + json.add("anchor", absolute(anchor)); + json.addProperty("surface_depth_multiplier", 0); + json.addProperty("add_stone_depth", false); + return json; + } + + static JsonObject notUnderwater() { + JsonObject json = typed("minecraft:water"); + json.addProperty("offset", -1); + json.addProperty("surface_depth_multiplier", 0); + json.addProperty("add_stone_depth", false); + return json; + } + + static JsonObject not(JsonObject condition) { + JsonObject json = typed("minecraft:not"); + json.add("invert", condition); + return json; + } + + static JsonObject noiseThreshold(String noise, double min, double max) { + JsonObject json = typed("minecraft:noise_threshold"); + json.addProperty("noise", noise); + json.addProperty("min_threshold", min); + json.addProperty("max_threshold", max); + json.addProperty("is_3d", true); + return json; + } + + static JsonObject oreVein(WorldgenSyntax syntax, OreVeinBlocks blocks, JsonElement density) { + JsonObject json = typed("minecraft:ore_vein"); + json.add("ore_block", syntax.blockState(blocks.ore())); + json.add("raw_ore_block", syntax.blockState(blocks.rawOre())); + json.add("filler_block", syntax.blockState(blocks.filler())); + json.addProperty("raw_ore_chance", blocks.rawOreChance()); + json.add("density", density); + json.addProperty("richness", blocks.richness()); + json.addProperty("filler_gap", -1.0); + return json; + } + + static JsonObject rangeChoice(JsonElement input, double minInclusive, double maxExclusive) { + JsonObject json = typed("minecraft:range_choice"); + json.add("input", input); + json.addProperty("min_inclusive", minInclusive); + json.addProperty("max_exclusive", maxExclusive); + json.addProperty("when_in_range", 1.0); + json.addProperty("when_out_of_range", 0.0); + return json; + } + + static int clampAnchor(long y) { + return (int) Math.max(MIN_ANCHOR, Math.min(MAX_ANCHOR, y)); + } + + private static JsonObject absolute(int y) { + JsonObject anchor = new JsonObject(); + anchor.addProperty("absolute", clampAnchor(y)); + return anchor; + } + + private static JsonObject typed(String type) { + JsonObject json = new JsonObject(); + json.addProperty("type", type); + return json; + } + + record OreVeinBlocks(VanillaBlockState filler, VanillaBlockState ore, VanillaBlockState rawOre, float richness, float rawOreChance) { + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/surface/RepaintFeatureCompiler.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/surface/RepaintFeatureCompiler.java new file mode 100644 index 000000000..600eee0c9 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/surface/RepaintFeatureCompiler.java @@ -0,0 +1,226 @@ +package art.arcane.iris.pack.datapack.export.compile.surface; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.biome.IrisBiomePaletteLayer; +import art.arcane.iris.generation.terrain.IrisSlopeClip; +import art.arcane.iris.pack.datapack.export.compile.BlockStateResolver; +import art.arcane.iris.pack.datapack.export.compile.ExportedBiomes; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; +import art.arcane.iris.pack.datapack.export.compile.WeightedState; +import art.arcane.iris.pack.datapack.export.compile.WorldgenSyntax; +import art.arcane.iris.pack.datapack.export.compile.decoration.FeatureCatalog; +import art.arcane.iris.pack.datapack.export.compile.decoration.GenerationStep; +import art.arcane.iris.pack.datapack.export.compile.decoration.Placements; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeSet; + +public final class RepaintFeatureCompiler { + public static final String SLOPE_MESSAGE = "Slope-dependent layers and decorators measure slope over 3 blocks in the four cardinal directions"; + private static final double SLOPE_CEILING = 10.0; + private static final int WALL_DROP = 3; + private static final int WALL_DEPTH = 32; + private static final int UNBOUNDED = -1; + + private final PackCompileContext context; + private final ExportedBiomes biomes; + private final SurfacePalettes palettes; + private final FeatureCatalog catalog; + private final WorldgenSyntax syntax; + + public RepaintFeatureCompiler(PackCompileContext context, ExportedBiomes biomes, BlockStateResolver blocks, FeatureCatalog catalog) { + this.context = context; + this.biomes = biomes; + this.palettes = new SurfacePalettes(context.namespace(), context.noises(), blocks, context.capabilities().target()); + this.catalog = catalog; + this.syntax = catalog.syntax(); + } + + public void compile() { + Map> rules = new LinkedHashMap<>(); + for (Map.Entry entry : biomes.byId().entrySet()) { + for (RepaintRule rule : rules(entry.getKey(), entry.getValue())) { + rules.computeIfAbsent(rule, ignored -> new ArrayList<>()).add(entry.getKey()); + } + } + if (rules.isEmpty()) { + return; + } + for (Map.Entry> rule : rules.entrySet()) { + String placed = placed(rule.getKey()); + for (String biomeId : rule.getValue()) { + context.features().add(biomeId, GenerationStep.LOCAL_MODIFICATIONS, placed); + } + } + } + + private List rules(String biomeId, IrisBiome biome) { + String resource = biomes.resource(biomeId); + List rules = new ArrayList<>(); + if (!biome.isLockLayers()) { + rules.addAll(slopeRules(biome, resource)); + } + IrisBiomePaletteLayer wall = biome.getWall(); + if (wall != null && wall.getPalette() != null && !wall.getPalette().isEmpty()) { + List materials = palettes.resolve(wall.getPalette(), resource); + if (!materials.isEmpty()) { + rules.add(new RepaintRule(Kind.WALL, 0, UNBOUNDED, materials, WALL_DEPTH, WALL_DEPTH)); + } + } + IrisBiomePaletteLayer slab = biome.getSlab(); + if (slab != null && slab.getPalette() != null && !slab.getPalette().isEmpty()) { + List materials = palettes.resolve(slab.getPalette(), resource); + if (!materials.isEmpty()) { + rules.add(new RepaintRule(Kind.SLAB, 0, UNBOUNDED, materials, 1, 1)); + } + } + return rules; + } + + private List slopeRules(IrisBiome biome, String resource) { + List layers = biome.getLayers(); + TreeSet breakpoints = new TreeSet<>(); + List> resolved = new ArrayList<>(layers == null ? 0 : layers.size()); + boolean gated = false; + for (int index = 0; layers != null && index < layers.size(); index++) { + IrisBiomePaletteLayer layer = layers.get(index); + List entries = layer == null ? List.of() : palettes.resolve(layer.getPalette(), resource); + resolved.add(entries); + IrisSlopeClip clip = layer == null ? null : layer.getSlopeCondition(); + if (clip == null || clip.isDefault()) { + continue; + } + gated = true; + if (clip.getMinimumSlope() > 0) { + breakpoints.add(clip.getMinimumSlope()); + } + if (clip.getMaximumSlope() < SLOPE_CEILING) { + breakpoints.add(clip.getMaximumSlope() + Math.ulp(clip.getMaximumSlope())); + } + } + if (!gated) { + return List.of(); + } + ExportDiagnostic slope = new ExportDiagnostic(ExportDiagnostic.Severity.INFO, ExportLimit.SLOPE, resource, SLOPE_MESSAGE); + if (!context.diagnostics().contains(slope)) { + context.diagnostics().add(slope); + } + int flatTop = topLayer(layers, resolved, 0.0); + List rules = new ArrayList<>(); + List points = new ArrayList<>(breakpoints); + int startRise = 0; + int currentTop = flatTop; + for (int index = 0; index < points.size(); index++) { + double from = points.get(index); + double to = index + 1 < points.size() ? points.get(index + 1) : from + 1.0; + int top = topLayer(layers, resolved, (from + to) / 2.0); + if (top == currentTop) { + continue; + } + addSlopeRule(rules, layers, resolved, currentTop, flatTop, startRise, rise(from)); + currentTop = top; + startRise = rise(from); + } + addSlopeRule(rules, layers, resolved, currentTop, flatTop, startRise, UNBOUNDED); + return rules; + } + + private void addSlopeRule(List rules, List layers, List> resolved, int top, int flatTop, + int fromRise, int toRise) { + if (top < 0 || top == flatTop || fromRise == toRise) { + return; + } + IrisBiomePaletteLayer layer = layers.get(top); + int min = Math.max(1, layer.getMinHeight()); + int max = Math.max(min, layer.getMaxHeight()); + rules.add(new RepaintRule(Kind.SLOPE, fromRise, toRise, resolved.get(top), min, max)); + } + + private String placed(RepaintRule rule) { + String name = "repaint/" + rule.kind().name().toLowerCase() + "_" + rule.materials().getFirst().state().name().substring("minecraft:".length()); + JsonArray placement = new JsonArray(); + for (JsonObject modifier : Placements.everyColumn()) { + placement.add(modifier); + } + if (rule.kind() == Kind.SLAB) { + placement.add(Placements.heightmap(Placements.WORLD_SURFACE)); + placement.add(Placements.biome()); + placement.add(Placements.filter(Placements.allOf(List.of(Placements.air(0, 0, 0), Placements.solid(0, -1, 0), Placements.stepUpBeside())))); + return catalog.placed(name, catalog.feature(name, "minecraft:simple_block", simpleBlock(rule)), placement); + } + placement.add(Placements.heightmap(Placements.OCEAN_FLOOR)); + placement.add(Placements.verticalOffset(-1)); + placement.add(Placements.biome()); + placement.add(Placements.filter(trigger(rule))); + JsonObject allowed = rule.kind() == Kind.WALL ? Placements.exposed() : Placements.solid(0, 0, 0); + return catalog.placed(name, catalog.feature(name, "minecraft:block_column", column(rule, allowed)), placement); + } + + private JsonObject trigger(RepaintRule rule) { + if (rule.kind() == Kind.WALL) { + return Placements.allOf(List.of(Placements.solid(0, 0, 0), Placements.dropAtLeast(WALL_DROP))); + } + List conditions = new ArrayList<>(3); + conditions.add(Placements.solid(0, 0, 0)); + if (rule.fromRise() > 0) { + conditions.add(Placements.steeperThan(rule.fromRise(), 0)); + } + if (rule.toRise() != UNBOUNDED) { + conditions.add(Placements.not(Placements.steeperThan(rule.toRise(), 0))); + } + return Placements.allOf(conditions); + } + + private JsonObject column(RepaintRule rule, JsonObject allowed) { + JsonObject layer = new JsonObject(); + layer.add("height", Placements.intRange(rule.minThickness(), rule.maxThickness())); + layer.add("provider", syntax.provider(rule.materials())); + JsonArray layers = new JsonArray(1); + layers.add(layer); + JsonObject config = new JsonObject(); + config.add("layers", layers); + config.addProperty("direction", "down"); + config.add("allowed_placement", allowed); + config.addProperty("prioritize_tip", false); + return config; + } + + private JsonObject simpleBlock(RepaintRule rule) { + JsonObject config = new JsonObject(); + config.add("to_place", syntax.provider(rule.materials())); + return config; + } + + private static int topLayer(List layers, List> resolved, double slope) { + for (int index = 0; index < layers.size(); index++) { + IrisBiomePaletteLayer layer = layers.get(index); + if (layer == null || resolved.get(index).isEmpty() || Math.max(layer.getMinHeight(), layer.getMaxHeight()) <= 0) { + continue; + } + if (layer.getSlopeCondition() == null || layer.getSlopeCondition().isValid(slope)) { + return index; + } + } + return -1; + } + + private static int rise(double slope) { + return Math.max(1, (int) Math.round(slope)); + } + + private enum Kind { + SLOPE, + WALL, + SLAB + } + + private record RepaintRule(Kind kind, int fromRise, int toRise, List materials, int minThickness, int maxThickness) { + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/surface/SurfacePalette.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/surface/SurfacePalette.java new file mode 100644 index 000000000..5f86c500d --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/surface/SurfacePalette.java @@ -0,0 +1,26 @@ +package art.arcane.iris.pack.datapack.export.compile.surface; + +import art.arcane.iris.pack.datapack.export.compile.WeightedState; + +import java.util.List; +import java.util.Objects; + +record SurfacePalette(List entries, Selection selection, String noise, List thresholds) { + SurfacePalette { + entries = List.copyOf(entries); + Objects.requireNonNull(selection); + thresholds = List.copyOf(thresholds); + if (entries.isEmpty()) { + throw new IllegalArgumentException("A surface palette needs at least one block"); + } + if (selection == Selection.NOISE && (noise == null || thresholds.size() != entries.size() - 1)) { + throw new IllegalArgumentException("A noise palette needs a noise and one threshold between each pair of blocks"); + } + } + + enum Selection { + SINGLE, + STATIC, + NOISE + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/surface/SurfacePalettes.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/surface/SurfacePalettes.java new file mode 100644 index 000000000..d299dbf92 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/surface/SurfacePalettes.java @@ -0,0 +1,229 @@ +package art.arcane.iris.pack.datapack.export.compile.surface; + +import art.arcane.iris.generation.block.IrisBlockData; +import art.arcane.iris.generation.noise.IrisGeneratorStyle; +import art.arcane.iris.generation.noise.NoiseStyle; +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.VanillaBlockState; +import art.arcane.iris.pack.datapack.export.compile.BlockStateResolver; +import art.arcane.iris.pack.datapack.export.compile.WeightedState; +import art.arcane.iris.pack.datapack.export.compile.WorldgenSyntax; +import art.arcane.iris.pack.datapack.export.compile.noise.NoiseRegistry; +import art.arcane.iris.pack.datapack.export.compile.noise.VanillaNoiseModel; +import art.arcane.iris.pack.datapack.export.ir.NoiseDef; +import com.google.gson.JsonObject; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +final class SurfacePalettes { + static final double NEVER = -1.0e6; + private static final List NOISE_AMPLITUDES = List.of(1.0, 1.0); + private static final int MIN_OCTAVE = -12; + private static final int MAX_OCTAVE = -1; + private static final int NOISE_VARIANTS = 8; + private static final int SAMPLE_AXIS = 16; + private static final double SAMPLE_SPACING = 0.731; + private static final double SINGLE_PAIR_FLOOR = 0.25; + private static final Comparator ASCENDING_WEIGHT = Comparator.comparingInt(WeightedState::weight) + .thenComparing(entry -> entry.state().canonical()); + + private final String namespace; + private final NoiseRegistry noises; + private final BlockStateResolver blocks; + private final DataVersion target; + private final Map samples = new HashMap<>(); + + SurfacePalettes(String namespace, NoiseRegistry noises, BlockStateResolver blocks, DataVersion target) { + this.namespace = namespace; + this.noises = noises; + this.blocks = blocks; + this.target = target; + } + + Optional palette(List palette, IrisGeneratorStyle style, double zoom, int variant, String resource) { + List entries = resolve(palette, resource); + if (entries.isEmpty()) { + return Optional.empty(); + } + if (isStatic(style)) { + entries = merged(entries); + } + if (entries.size() == 1) { + return Optional.of(new SurfacePalette(entries, SurfacePalette.Selection.SINGLE, null, List.of())); + } + if (isStatic(style)) { + return Optional.of(new SurfacePalette(entries, SurfacePalette.Selection.STATIC, null, List.of())); + } + String noise = noise(style, zoom, variant); + int total = 0; + for (WeightedState entry : entries) { + total += entry.weight(); + } + List thresholds = new ArrayList<>(entries.size() - 1); + int cumulative = 0; + for (int index = 0; index < entries.size() - 1; index++) { + cumulative += entries.get(index).weight(); + thresholds.add(quantile(noise, (double) cumulative / total)); + } + return Optional.of(new SurfacePalette(entries, SurfacePalette.Selection.NOISE, noise, thresholds)); + } + + List resolve(List palette, String resource) { + List entries = new ArrayList<>(); + if (palette == null) { + return entries; + } + for (IrisBlockData block : palette) { + Optional state = blocks.resolve(block, resource); + if (state.isPresent()) { + entries.add(new WeightedState(state.get(), Math.max(1, block.getWeight()))); + } + } + return entries; + } + + OreChance chance(double probability, IrisGeneratorStyle style, double zoom, int variant) { + if (isStatic(style)) { + return new OreChance(probability, null, 0.0); + } + String noise = noise(style, zoom, variant); + return new OreChance(probability, noise, quantile(noise, probability)); + } + + JsonObject render(SurfacePalette palette, WorldgenSyntax syntax, String scope, int centerY) { + return switch (palette.selection()) { + case SINGLE -> MaterialRules.block(syntax, palette.entries().getFirst().state()); + case STATIC -> renderStatic(palette, syntax, scope, centerY); + case NOISE -> renderNoise(palette, syntax); + }; + } + + JsonObject renderChance(OreChance chance, String scope, int centerY, JsonObject rule) { + if (chance.noise() != null) { + return MaterialRules.condition(MaterialRules.noiseThreshold(chance.noise(), NEVER, chance.threshold()), rule); + } + return MaterialRules.guarded(gradientChance(chance.probability(), randomPrefix(scope), centerY), rule); + } + + static boolean isStatic(IrisGeneratorStyle style) { + return style == null || style.getStyle() == null || style.getStyle() == NoiseStyle.STATIC; + } + + static List gradientChance(double probability, String randomPrefix, int centerY) { + if (probability >= 1.0) { + return List.of(); + } + double clamped = Math.max(probability, 1.0e-9); + int factors = clamped >= SINGLE_PAIR_FLOOR ? 1 : (int) Math.ceil(Math.log(clamped) / Math.log(SINGLE_PAIR_FLOOR)); + double factor = Math.pow(clamped, 1.0 / factors); + int center = Math.max(MaterialRules.MIN_ANCHOR + 1, Math.min(MaterialRules.MAX_ANCHOR - 1, centerY)); + int reach = Math.min(center - MaterialRules.MIN_ANCHOR, MaterialRules.MAX_ANCHOR - center); + int width = (int) Math.min(2L * reach, Math.round(reach / Math.sqrt(factor))); + List conditions = new ArrayList<>(factors * 2); + for (int index = 0; index < factors; index++) { + int falling = center + reach; + int rising = center - reach; + conditions.add(MaterialRules.verticalGradient(randomPrefix + index + "_a", falling - width, falling)); + conditions.add(MaterialRules.not(MaterialRules.verticalGradient(randomPrefix + index + "_b", rising, rising + width))); + } + return conditions; + } + + private JsonObject renderStatic(SurfacePalette palette, WorldgenSyntax syntax, String scope, int centerY) { + List entries = new ArrayList<>(palette.entries()); + entries.sort(ASCENDING_WEIGHT); + int remaining = 0; + for (WeightedState entry : entries) { + remaining += entry.weight(); + } + List rules = new ArrayList<>(entries.size()); + for (int index = 0; index < entries.size() - 1; index++) { + WeightedState entry = entries.get(index); + double probability = (double) entry.weight() / remaining; + rules.add(MaterialRules.guarded(gradientChance(probability, randomPrefix(scope) + index + "_", centerY), + MaterialRules.block(syntax, entry.state()))); + remaining -= entry.weight(); + } + rules.add(MaterialRules.block(syntax, entries.getLast().state())); + return MaterialRules.sequence(rules); + } + + private JsonObject renderNoise(SurfacePalette palette, WorldgenSyntax syntax) { + List rules = new ArrayList<>(palette.entries().size()); + for (int index = 0; index < palette.entries().size() - 1; index++) { + JsonObject condition = MaterialRules.noiseThreshold(palette.noise(), NEVER, palette.thresholds().get(index)); + rules.add(MaterialRules.condition(condition, MaterialRules.block(syntax, palette.entries().get(index).state()))); + } + rules.add(MaterialRules.block(syntax, palette.entries().getLast().state())); + return MaterialRules.sequence(rules); + } + + private String randomPrefix(String scope) { + return namespace + ":surface/" + scope + "/"; + } + + private String noise(IrisGeneratorStyle style, double zoom, int variant) { + double styleZoom = style == null ? 1.0 : Math.max(style.getZoom(), 1.0e-4); + double effectiveZoom = Math.max(1.0, Math.max(zoom, 1.0e-4) * styleZoom); + int octave = (int) Math.max(MIN_OCTAVE, Math.min(MAX_OCTAVE, -Math.round(Math.log(effectiveZoom) / Math.log(2.0)))); + String id = "surface/palette_" + (-octave) + "_" + Math.floorMod(variant, NOISE_VARIANTS); + return noises.register(new NoiseDef(id, octave, NOISE_AMPLITUDES)); + } + + private double quantile(String noise, double fraction) { + double[] sorted = samples.computeIfAbsent(noise, this::sample); + double position = Math.max(0.0, Math.min(1.0, fraction)) * (sorted.length - 1); + int lower = (int) Math.floor(position); + int upper = Math.min(sorted.length - 1, lower + 1); + return sorted[lower] + (sorted[upper] - sorted[lower]) * (position - lower); + } + + private double[] sample(String noise) { + NoiseDef definition = definition(noise); + VanillaNoiseModel model = VanillaNoiseModel.create(noise, definition.firstOctave(), definition.amplitudes(), 0L, target); + double spacing = SAMPLE_SPACING * Math.pow(2.0, -definition.firstOctave()); + double[] values = new double[SAMPLE_AXIS * SAMPLE_AXIS * SAMPLE_AXIS]; + int index = 0; + for (int x = 0; x < SAMPLE_AXIS; x++) { + for (int y = 0; y < SAMPLE_AXIS; y++) { + for (int z = 0; z < SAMPLE_AXIS; z++) { + values[index++] = model.sample(x * spacing + 0.37, y * spacing * 1.13 + 0.71, z * spacing * 0.89 + 0.13); + } + } + } + Arrays.sort(values); + return values; + } + + private NoiseDef definition(String noise) { + String id = noise.substring(namespace.length() + 1); + for (NoiseDef definition : noises.definitions()) { + if (definition.id().equals(id)) { + return definition; + } + } + throw new IllegalStateException("Surface noise '" + noise + "' is not registered"); + } + + private static List merged(List entries) { + Map weights = new LinkedHashMap<>(); + for (WeightedState entry : entries) { + weights.merge(entry.state(), entry.weight(), Integer::sum); + } + List merged = new ArrayList<>(weights.size()); + for (Map.Entry weight : weights.entrySet()) { + merged.add(new WeightedState(weight.getKey(), weight.getValue())); + } + return merged; + } + + record OreChance(double probability, String noise, double threshold) { + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/surface/SurfaceStage.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/surface/SurfaceStage.java new file mode 100644 index 000000000..2e7e6732e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/surface/SurfaceStage.java @@ -0,0 +1,17 @@ +package art.arcane.iris.pack.datapack.export.compile.surface; + +import art.arcane.iris.pack.datapack.export.compile.BlockStateResolver; +import art.arcane.iris.pack.datapack.export.compile.CompileStage; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; + +public final class SurfaceStage implements CompileStage { + @Override + public String name() { + return "surface"; + } + + @Override + public void apply(PackCompileContext context) { + context.setMaterial(new MaterialProgramCompiler(context, BlockStateResolver.forContext(context)).compile()); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/terrain/BandTreeCompiler.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/terrain/BandTreeCompiler.java new file mode 100644 index 000000000..1459a5649 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/terrain/BandTreeCompiler.java @@ -0,0 +1,185 @@ +package art.arcane.iris.pack.datapack.export.compile.terrain; + +import art.arcane.iris.generation.noise.IrisInterpolator; +import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; +import art.arcane.iris.pack.datapack.export.compile.biome.BiomeCodes; +import art.arcane.iris.pack.datapack.export.ir.ClimateLayout; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.volmlib.util.interpolation.InterpolationMethod; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.Deque; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +public final class BandTreeCompiler { + private static final double LATTICE_STARCAST_SPAN = Math.sqrt(7.0); + private static final double POINT_STARCAST_SPAN = Math.sqrt(6.0); + private static final double LADDER_BASE = 4.0; + private static final double LADDER_SPREAD = Math.sqrt(7.0 / 6.0); + private static final int LADDER_FIRST = 1; + private static final int LATTICES_PER_BAND = 3; + private static final Comparator ENTRY_ORDER = Comparator + .comparingDouble(ClimateLayout.ClimateEntry::min) + .thenComparingDouble(ClimateLayout.ClimateEntry::max) + .thenComparing(ClimateLayout.ClimateEntry::biomeId); + + private final Field selector; + private final List entries; + private final List boundaries; + private final String prefix; + private final Map latticeCodes = new HashMap<>(); + + public BandTreeCompiler(BiomeCodes codes, String scope) { + Objects.requireNonNull(codes, "codes"); + entries = surfaceEntries(codes.layout()); + if (entries.isEmpty()) { + throw new IllegalArgumentException("The biome layout has no surface entries on the temperature axis"); + } + boundaries = boundaries(entries); + prefix = "terrain/" + VanillaResourceIdentifier.requirePath(scope, "band scope") + "/blend/"; + selector = FieldMacros.share(prefix + "selector", selector(codes)); + } + + public static Field selector(BiomeCodes codes) { + Field.Shared base = findShared(codes.surfaceCode(), BiomeCodes.BASE_CODE); + Field root = base == null ? codes.surfaceCode() : base; + if (findShared(root, HeightCompiler.HEIGHT) != null) { + throw new IllegalStateException("The surface biome code reads the terrain height; wrap its height-free base code in Shared(\"" + + BiomeCodes.BASE_CODE + "\", ...) so the terrain bands can follow the base biome"); + } + return root; + } + + public static List surfaceBiomeIds(BiomeCodes codes) { + Set ids = new LinkedHashSet<>(); + for (ClimateLayout.ClimateEntry entry : surfaceEntries(codes.layout())) { + ids.add(entry.biomeId()); + } + return List.copyOf(ids); + } + + public static boolean blends(IrisInterpolator interpolator) { + return kernel(interpolator.getFunction()) != Kernel.HARD && interpolator.getHorizontalScale() > 0.0; + } + + public static List lattices(IrisInterpolator interpolator) { + double radius = interpolator.getHorizontalScale(); + return switch (kernel(interpolator.getFunction())) { + case HARD -> List.of(); + case LATTICE -> List.of(Math.max(1, (int) Math.round(radius))); + case POINT_STARCAST -> ladder(POINT_STARCAST_SPAN * radius); + case LATTICE_STARCAST -> ladder(LATTICE_STARCAST_SPAN * radius); + }; + } + + public Field band(Map values, IrisInterpolator interpolator) { + Objects.requireNonNull(values, "values"); + Field hard = hard(values); + if (hard instanceof Field.Const || !blends(interpolator)) { + return hard; + } + List cells = lattices(interpolator); + List terms = new ArrayList<>(cells.size()); + for (int cell : cells) { + terms.add(new Field.Grid(layoutBand(latticeCode(cell), values), cell, 1)); + } + return FieldMacros.scale(FieldMacros.sum(terms), 1.0 / terms.size()); + } + + public Field hard(Map values) { + return layoutBand(selector, values); + } + + private Field latticeCode(int cell) { + return latticeCodes.computeIfAbsent(cell, size -> new Field.Shared(prefix + "lattice/" + size, new Field.Grid(selector, size, 1))); + } + + private Field layoutBand(Field code, Map values) { + List thresholds = new ArrayList<>(boundaries.size()); + List leaves = new ArrayList<>(entries.size()); + leaves.add(new Field.Const(values.getOrDefault(entries.getFirst().biomeId(), 0.0))); + for (int index = 1; index < entries.size(); index++) { + Field leaf = new Field.Const(values.getOrDefault(entries.get(index).biomeId(), 0.0)); + if (leaf.equals(leaves.getLast())) { + continue; + } + thresholds.add(boundaries.get(index - 1)); + leaves.add(leaf); + } + return leaves.size() == 1 ? leaves.getFirst() : new Field.Partition(code, thresholds, leaves); + } + + private static List ladder(double width) { + double center = width / LADDER_SPREAD; + int middle = Math.max(LADDER_FIRST + 1, (int) Math.round(2.0 * Math.log(center / LADDER_BASE) / Math.log(2.0))); + List cells = new ArrayList<>(LATTICES_PER_BAND); + for (int step = middle - LATTICES_PER_BAND / 2; step <= middle + LATTICES_PER_BAND / 2; step++) { + cells.add((int) Math.round(LADDER_BASE * Math.pow(2.0, step / 2.0))); + } + return List.copyOf(cells); + } + + private static List surfaceEntries(ClimateLayout layout) { + List surface = new ArrayList<>(); + for (ClimateLayout.ClimateEntry entry : layout.entries()) { + if (entry.axis() == ClimateLayout.ClimateAxis.TEMPERATURE) { + surface.add(entry); + } + } + surface.sort(ENTRY_ORDER); + return Collections.unmodifiableList(surface); + } + + private static List boundaries(List entries) { + List boundaries = new ArrayList<>(Math.max(0, entries.size() - 1)); + for (int index = 1; index < entries.size(); index++) { + boundaries.add((entries.get(index - 1).max() + entries.get(index).min()) / 2.0); + } + return boundaries; + } + + private static Field.Shared findShared(Field root, String name) { + Set visited = Collections.newSetFromMap(new IdentityHashMap<>()); + Deque pending = new ArrayDeque<>(); + pending.push(root); + while (!pending.isEmpty()) { + Field field = pending.pop(); + if (!visited.add(field)) { + continue; + } + if (field instanceof Field.Shared shared && shared.name().equals(name)) { + return shared; + } + for (Field child : FieldMacros.children(field)) { + pending.push(child); + } + } + return null; + } + + private static Kernel kernel(InterpolationMethod method) { + return switch (method) { + case NONE -> Kernel.HARD; + case STARCAST_3, STARCAST_6, STARCAST_9, STARCAST_12 -> Kernel.POINT_STARCAST; + case BILINEAR_STARCAST_3, BILINEAR_STARCAST_6, BILINEAR_STARCAST_9, BILINEAR_STARCAST_12, + HERMITE_STARCAST_3, HERMITE_STARCAST_6, HERMITE_STARCAST_9, HERMITE_STARCAST_12 -> Kernel.LATTICE_STARCAST; + default -> Kernel.LATTICE; + }; + } + + private enum Kernel { + HARD, + LATTICE, + POINT_STARCAST, + LATTICE_STARCAST + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/terrain/ClimateHeightCompiler.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/terrain/ClimateHeightCompiler.java new file mode 100644 index 000000000..68caf0e8e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/terrain/ClimateHeightCompiler.java @@ -0,0 +1,159 @@ +package art.arcane.iris.pack.datapack.export.compile.terrain; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.noise.IrisGenerator; +import art.arcane.iris.generation.noise.IrisShapedGeneratorStyle; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.pack.datapack.export.compile.biome.BiomeCodes; +import art.arcane.iris.pack.datapack.export.compile.noise.NoiseRegistry; +import art.arcane.iris.pack.datapack.export.compile.noise.StyleCompiler; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.FieldEvaluator; +import art.arcane.iris.pack.datapack.export.ir.FieldValidator; +import art.arcane.iris.pack.datapack.export.ir.NoiseDef; +import art.arcane.iris.pack.datapack.export.lower.ExportCapabilities; +import art.arcane.iris.pack.datapack.export.lower.GenerationCostCounter; +import art.arcane.iris.pack.datapack.export.lower.VanillaBounds; +import art.arcane.iris.pack.loading.IrisData; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.SortedMap; + +public final class ClimateHeightCompiler { + public static final String CLIMATE_HEIGHT = "height/climate"; + private static final String GENERATOR_SCOPE = "height"; + private static final String SELECTOR_SCOPE = "climate"; + private static final String MEAN_PREFIX = "height/climate/mean/"; + private static final double GENERATOR_POINT_LIMIT = 1024.0; + private static final long CALIBRATION_SEED = 0x5EED_C11AL; + private static final int CALIBRATION_SIDE = 16; + private static final int CALIBRATION_STEP = 97; + + private final StyleCompiler styles; + + public ClimateHeightCompiler(StyleCompiler styles) { + this.styles = Objects.requireNonNull(styles, "styles"); + } + + public Field compile(BiomeCodes codes, IrisDimension dimension, IrisData data, NoiseRegistry registry, ExportCapabilities capabilities) { + Map biomes = TerrainBiomes.resolve(codes, data); + List> buckets = new ArrayList<>(HeightCompiler.buckets(biomes, data).values()); + List repeatedDiagnostics = new ArrayList<>(); + Generators generators = new Generators(new GeneratorCompiler(styles, registry, capabilities, repeatedDiagnostics), registry, capabilities); + Map means = new HashMap<>(buckets.size() * 2); + List ids = BandTreeCompiler.surfaceBiomeIds(codes); + Map heightById = new HashMap<>(ids.size() * 2); + for (String id : ids) { + IrisBiome biome = biomes.get(id); + heightById.put(id, biome == null ? new Field.Const(0.0) : biomeHeight(biome, buckets, means, generators)); + } + List terms = new ArrayList<>(); + terms.add(new Field.Const(dimension.getMinHeight() + dimension.getFluidHeight())); + terms.add(select(new BandTreeCompiler(codes, SELECTOR_SCOPE), ids, heightById)); + List overlay = dimension.getOverlayNoise(); + for (int layer = 0; overlay != null && layer < overlay.size(); layer++) { + terms.add(HeightCompiler.overlay(overlay.get(layer), generators.compiler(), layer).height()); + } + Field height = HeightCompiler.clamp(FieldMacros.sum(terms), dimension.getMinHeight(), dimension.getMaxHeight()); + FieldValidator.requireTwoDimensional(height, CLIMATE_HEIGHT); + return new Field.Shared(CLIMATE_HEIGHT, height); + } + + private static Field biomeHeight(IrisBiome biome, List> buckets, Map means, Generators generators) { + List terms = new ArrayList<>(buckets.size() + 1); + double low = 0.0; + for (int index = 0; index < buckets.size(); index++) { + double minimum = 0.0; + double maximum = 0.0; + for (String key : buckets.get(index).keySet()) { + minimum += biome.getGenLinkMin(key); + maximum += biome.getGenLinkMax(key); + } + low += minimum; + if (maximum != minimum) { + terms.add(FieldMacros.scale(mean(index, buckets, means, generators), maximum - minimum)); + } + } + terms.add(new Field.Const(low)); + return FieldMacros.sum(terms); + } + + private static Field mean(int index, List> buckets, Map means, Generators generators) { + Field known = means.get(index); + if (known != null) { + return known; + } + SortedMap members = buckets.get(index); + List heights = new ArrayList<>(members.size()); + for (IrisGenerator generator : members.values()) { + heights.add(generators.proxy(generator)); + } + Field mean = FieldMacros.share(MEAN_PREFIX + index, FieldMacros.scale(FieldMacros.sum(heights), 1.0 / heights.size())); + means.put(index, mean); + return mean; + } + + private static Field select(BandTreeCompiler bands, List ids, Map heightById) { + Map constants = new HashMap<>(ids.size() * 2); + Map positions = new HashMap<>(ids.size() * 2); + List cases = new ArrayList<>(ids.size()); + boolean constant = true; + for (String id : ids) { + Field height = heightById.get(id); + if (height instanceof Field.Const value) { + constants.put(id, value.value()); + } else { + constant = false; + } + positions.put(id, (double) cases.size()); + cases.add(height); + } + if (constant) { + return bands.hard(constants); + } + Field index = bands.hard(positions); + return index instanceof Field.Const ? cases.getFirst() : new Field.Mux(index, cases); + } + + private static final class Generators { + private final GeneratorCompiler compiler; + private final NoiseRegistry registry; + private final ExportCapabilities capabilities; + + private Generators(GeneratorCompiler compiler, NoiseRegistry registry, ExportCapabilities capabilities) { + this.compiler = compiler; + this.registry = registry; + this.capabilities = capabilities; + } + + private GeneratorCompiler compiler() { + return compiler; + } + + private Field proxy(IrisGenerator generator) { + Field raw = compiler.compileRaw(generator, GENERATOR_SCOPE); + if (GenerationCostCounter.worstCase(raw, VanillaBounds.of(registry.namespace(), registry.definitions()), capabilities.target()) <= GENERATOR_POINT_LIMIT) { + return raw; + } + return new Field.Const(calibratedMean(raw)); + } + + private double calibratedMean(Field raw) { + Map noises = registry.byReference(); + double total = 0.0; + int origin = -CALIBRATION_SIDE / 2 * CALIBRATION_STEP; + for (int column = 0; column < CALIBRATION_SIDE; column++) { + for (int row = 0; row < CALIBRATION_SIDE; row++) { + total += FieldEvaluator.sample(raw, noises, CALIBRATION_SEED, capabilities.target(), + origin + column * CALIBRATION_STEP, 0, origin + row * CALIBRATION_STEP); + } + } + return total / (CALIBRATION_SIDE * CALIBRATION_SIDE); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/terrain/FieldMacros.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/terrain/FieldMacros.java new file mode 100644 index 000000000..2d7c21206 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/terrain/FieldMacros.java @@ -0,0 +1,572 @@ +package art.arcane.iris.pack.datapack.export.compile.terrain; + +import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; +import art.arcane.iris.pack.datapack.export.ir.Axis; +import art.arcane.iris.pack.datapack.export.ir.CostClass; +import art.arcane.iris.pack.datapack.export.ir.Field; + +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.function.DoubleUnaryOperator; + +public final class FieldMacros { + public static final double MIN_GRADIENT = 1.0e-6; + private static final Field ZERO = new Field.Const(0.0); + + private FieldMacros() { + } + + public static Field offset(Field expr, double dx, double dz) { + Objects.requireNonNull(expr, "expr"); + requireFinite(dx, "x offset"); + requireFinite(dz, "z offset"); + if (dx == 0.0 && dz == 0.0) { + return expr; + } + String tag = "offset_" + label('x', dx) + "_" + label('z', dz); + return new Displacement(new Field.Const(dx), new Field.Const(dz), tag).apply(expr); + } + + public static Field displace(Field expr, Field dx, Field dz, String tag) { + Objects.requireNonNull(expr, "expr"); + VanillaResourceIdentifier.requirePath(tag, "displacement tag"); + return new Displacement(dx == null ? ZERO : dx, dz == null ? ZERO : dz, tag).apply(expr); + } + + public static Field substitute(Field expr, Field target, Field replacement, String tag) { + Objects.requireNonNull(expr, "expr"); + Objects.requireNonNull(target, "target"); + Objects.requireNonNull(replacement, "replacement"); + VanillaResourceIdentifier.requirePath(tag, "substitution tag"); + return target.equals(replacement) ? expr : new Substitution(target, replacement, tag).apply(expr); + } + + public static Field lift(Field expr, double verticalRatio) { + Objects.requireNonNull(expr, "expr"); + if (!(verticalRatio > 0.0) || !Double.isFinite(verticalRatio)) { + throw new IllegalArgumentException("Vertical ratio must be finite and positive, got " + verticalRatio); + } + return new Lift(verticalRatio, "lift_" + label('y', verticalRatio)).apply(expr); + } + + public static Gradient grad2(Field expr, double delta) { + Objects.requireNonNull(expr, "expr"); + if (!(delta > 0.0) || !Double.isFinite(delta)) { + throw new IllegalArgumentException("Gradient step must be finite and positive, got " + delta); + } + Field x = scale(new Field.Binary(Field.BinaryOp.SUB, offset(expr, delta, 0.0), expr), 1.0 / delta); + Field z = scale(new Field.Binary(Field.BinaryOp.SUB, offset(expr, 0.0, delta), expr), 1.0 / delta); + return new Gradient(x, z); + } + + public static Field norm(Gradient gradient) { + Objects.requireNonNull(gradient, "gradient"); + Field squares = new Field.Binary(Field.BinaryOp.ADD, + new Field.Unary(Field.UnaryOp.SQUARE, gradient.x()), + new Field.Unary(Field.UnaryOp.SQUARE, gradient.z())); + return new Field.Unary(Field.UnaryOp.SQRT, squares); + } + + public static Field signedDistance(Field field, Field threshold, Field gradientNorm) { + Field difference = new Field.Binary(Field.BinaryOp.SUB, field, threshold); + Field denominator = new Field.Binary(Field.BinaryOp.MAX, gradientNorm, new Field.Const(MIN_GRADIENT)); + return new Field.Binary(Field.BinaryOp.DIV, difference, denominator); + } + + public static Field signedDistance(Field field, double threshold, double delta) { + return signedDistance(field, new Field.Const(threshold), norm(grad2(field, delta))); + } + + public static Field product(Field mask, Field payload) { + Objects.requireNonNull(mask, "mask"); + Objects.requireNonNull(payload, "payload"); + if (mask instanceof Field.Const first && payload instanceof Field.Const second) { + return new Field.Const(first.value() * second.value()); + } + if (mask instanceof Field.Const constant) { + return scale(payload, constant.value()); + } + if (payload instanceof Field.Const constant) { + return scale(mask, constant.value()); + } + if (payload.cost() == CostClass.EXPENSIVE) { + return new Field.Binary(Field.BinaryOp.MUL, mask, new Field.Gate(magnitude(mask), payload)); + } + if (mask.cost() == CostClass.EXPENSIVE) { + return new Field.Binary(Field.BinaryOp.MUL, new Field.Gate(magnitude(payload), mask), payload); + } + return new Field.Binary(Field.BinaryOp.MUL, mask, payload); + } + + public static Field scale(Field input, double factor) { + Objects.requireNonNull(input, "input"); + requireFinite(factor, "scale factor"); + if (factor == 1.0) { + return input; + } + if (input instanceof Field.Const constant) { + return new Field.Const(constant.value() * factor); + } + if (factor == 0.0) { + return ZERO; + } + return new Field.Binary(Field.BinaryOp.MUL, new Field.Const(factor), input); + } + + public static Field sum(List terms) { + double constant = 0.0; + List variable = new ArrayList<>(terms.size()); + for (Field term : terms) { + if (term instanceof Field.Const value) { + constant += value.value(); + } else { + variable.add(Objects.requireNonNull(term, "term")); + } + } + if (variable.isEmpty()) { + return new Field.Const(constant); + } + Field balanced = balancedSum(variable, 0, variable.size()); + return constant == 0.0 ? balanced : new Field.Binary(Field.BinaryOp.ADD, balanced, new Field.Const(constant)); + } + + public static Field smoothstep(Field input, double from, double to) { + requireFinite(from, "smoothstep start"); + requireFinite(to, "smoothstep end"); + if (!(to > from)) { + throw new IllegalArgumentException("Smoothstep end " + to + " must exceed its start " + from); + } + return new Field.Curve(input, List.of( + new Field.Knot(from, ZERO, 0.0), + new Field.Knot(to, new Field.Const(1.0), 0.0))); + } + + public static Field lookup(Field input, DoubleUnaryOperator function, DoubleUnaryOperator derivative, double from, double to, int segments) { + requireFinite(from, "lookup start"); + requireFinite(to, "lookup end"); + if (!(to > from) || segments < 1) { + throw new IllegalArgumentException("Lookup needs an increasing range and at least one segment, got [" + from + ", " + to + "] with " + segments); + } + List knots = new ArrayList<>(segments + 1); + for (int index = 0; index <= segments; index++) { + double location = from + (to - from) * index / segments; + knots.add(new Field.Knot(location, new Field.Const(function.applyAsDouble(location)), derivative.applyAsDouble(location))); + } + return new Field.Curve(input, knots); + } + + public static Field knotValue(Field value) { + if (value instanceof Field.Const || value instanceof Field.Curve) { + return value; + } + return new Field.Curve(value, List.of(new Field.Knot(0.0, ZERO, 1.0))); + } + + public static Field share(String name, Field field) { + Objects.requireNonNull(field, "field"); + if (field instanceof Field.Shared || field.cost() == CostClass.CHEAP) { + return field; + } + return new Field.Shared(name, field); + } + + public static boolean isGuard(Field field) { + return field instanceof Field.Gate || field instanceof Field.Mux; + } + + public static List children(Field field) { + return switch (field) { + case Field.Const ignored -> List.of(); + case Field.Coord ignored -> List.of(); + case Field.Ref ignored -> List.of(); + case Field.Noise noise -> present(noise.shiftX(), noise.shiftY(), noise.shiftZ()); + case Field.Binary binary -> List.of(binary.left(), binary.right()); + case Field.Unary unary -> List.of(unary.input()); + case Field.Clamp clamp -> List.of(clamp.input()); + case Field.Lerp lerp -> List.of(lerp.alpha(), lerp.first(), lerp.second()); + case Field.Round round -> List.of(round.input(), round.multiple()); + case Field.Curve curve -> curveChildren(curve); + case Field.Partition partition -> prepend(partition.input(), partition.leaves()); + case Field.Mux mux -> prepend(mux.index(), mux.cases()); + case Field.Gate gate -> List.of(gate.condition(), gate.expr()); + case Field.RangeIf rangeIf -> List.of(rangeIf.input(), rangeIf.inRange(), rangeIf.outOfRange()); + case Field.Grid grid -> List.of(grid.expr()); + case Field.Shared shared -> List.of(shared.expr()); + case Field.Slice slice -> List.of(slice.expr()); + case Field.TopSurface topSurface -> List.of(topSurface.density()); + }; + } + + private static List present(Field... fields) { + List present = new ArrayList<>(fields.length); + for (Field field : fields) { + if (field != null) { + present.add(field); + } + } + return present; + } + + private static List curveChildren(Field.Curve curve) { + List children = new ArrayList<>(curve.knots().size() + 1); + children.add(curve.input()); + for (Field.Knot knot : curve.knots()) { + children.add(knot.value()); + } + return children; + } + + private static List prepend(Field first, List rest) { + List children = new ArrayList<>(rest.size() + 1); + children.add(first); + children.addAll(rest); + return children; + } + + private static Field magnitude(Field field) { + return field.range().min() >= 0.0 ? field : new Field.Unary(Field.UnaryOp.ABS, field); + } + + private static Field balancedSum(List terms, int from, int to) { + if (to - from == 1) { + return terms.get(from); + } + int middle = (from + to) >>> 1; + return new Field.Binary(Field.BinaryOp.ADD, balancedSum(terms, from, middle), balancedSum(terms, middle, to)); + } + + private static String label(char axis, double value) { + String sign = value < 0.0 ? "m" : ""; + double magnitude = Math.abs(value); + if (magnitude == Math.rint(magnitude) && magnitude < 1.0e15) { + return axis + sign + (long) magnitude; + } + return axis + sign + String.format(Locale.ROOT, "%.4f", magnitude).replace('.', 'p'); + } + + private static void requireFinite(double value, String role) { + if (!Double.isFinite(value)) { + throw new IllegalArgumentException("The " + role + " must be finite, got " + value); + } + } + + public record Gradient(Field x, Field z) { + public Gradient { + Objects.requireNonNull(x, "x"); + Objects.requireNonNull(z, "z"); + } + } + + private abstract static class Rewriter { + private final Map memo = new IdentityHashMap<>(); + private final String tag; + + private Rewriter(String tag) { + this.tag = tag; + } + + abstract boolean skips(Field field); + + abstract Field noise(Field.Noise noise); + + abstract Field coord(Field.Coord coord); + + abstract Field slice(Field.Slice slice); + + Field apply(Field field) { + if (field == null) { + return null; + } + Field cached = memo.get(field); + if (cached != null) { + return cached; + } + Field result = skips(field) ? field : rewrite(field); + memo.put(field, result); + return result; + } + + String tag() { + return tag; + } + + private Field rewrite(Field field) { + return switch (field) { + case Field.Const constant -> constant; + case Field.Coord coord -> coord(coord); + case Field.Noise noise -> noise(noise); + case Field.Binary binary -> binary(binary); + case Field.Unary unary -> unary(unary); + case Field.Clamp clamp -> clamp(clamp); + case Field.Lerp lerp -> lerp(lerp); + case Field.Round round -> round(round); + case Field.Curve curve -> curve(curve); + case Field.Partition partition -> partition(partition); + case Field.Mux mux -> mux(mux); + case Field.Gate gate -> gate(gate); + case Field.RangeIf rangeIf -> rangeIf(rangeIf); + case Field.Grid grid -> grid(grid); + case Field.Shared shared -> shared(shared); + case Field.Slice slice -> slice(slice); + case Field.TopSurface topSurface -> topSurface(topSurface); + case Field.Ref ref -> throw new IllegalArgumentException("Density function reference '" + ref.id() + "' cannot be cloned for " + tag); + }; + } + + private Field binary(Field.Binary binary) { + Field left = apply(binary.left()); + Field right = apply(binary.right()); + return left == binary.left() && right == binary.right() ? binary : new Field.Binary(binary.op(), left, right); + } + + private Field unary(Field.Unary unary) { + Field input = apply(unary.input()); + return input == unary.input() ? unary : new Field.Unary(unary.op(), input); + } + + private Field clamp(Field.Clamp clamp) { + Field input = apply(clamp.input()); + return input == clamp.input() ? clamp : new Field.Clamp(input, clamp.min(), clamp.max()); + } + + private Field lerp(Field.Lerp lerp) { + Field alpha = apply(lerp.alpha()); + Field first = apply(lerp.first()); + Field second = apply(lerp.second()); + if (alpha == lerp.alpha() && first == lerp.first() && second == lerp.second()) { + return lerp; + } + return new Field.Lerp(alpha, first, second); + } + + private Field round(Field.Round round) { + Field input = apply(round.input()); + Field multiple = apply(round.multiple()); + if (input == round.input() && multiple == round.multiple()) { + return round; + } + return new Field.Round(round.mode(), input, multiple); + } + + private Field curve(Field.Curve curve) { + Field input = apply(curve.input()); + boolean changed = input != curve.input(); + List knots = new ArrayList<>(curve.knots().size()); + for (Field.Knot knot : curve.knots()) { + Field value = apply(knot.value()); + changed |= value != knot.value(); + knots.add(value == knot.value() ? knot : new Field.Knot(knot.location(), value, knot.derivative())); + } + return changed ? new Field.Curve(input, knots) : curve; + } + + private Field partition(Field.Partition partition) { + Field input = apply(partition.input()); + List leaves = applyAll(partition.leaves()); + if (input == partition.input() && leaves == partition.leaves()) { + return partition; + } + return new Field.Partition(input, partition.thresholds(), leaves); + } + + private Field mux(Field.Mux mux) { + Field index = apply(mux.index()); + List cases = applyAll(mux.cases()); + return index == mux.index() && cases == mux.cases() ? mux : new Field.Mux(index, cases); + } + + private Field gate(Field.Gate gate) { + Field condition = apply(gate.condition()); + Field expr = apply(gate.expr()); + return condition == gate.condition() && expr == gate.expr() ? gate : new Field.Gate(condition, expr); + } + + private Field rangeIf(Field.RangeIf rangeIf) { + Field input = apply(rangeIf.input()); + Field inRange = apply(rangeIf.inRange()); + Field outOfRange = apply(rangeIf.outOfRange()); + if (input == rangeIf.input() && inRange == rangeIf.inRange() && outOfRange == rangeIf.outOfRange()) { + return rangeIf; + } + return new Field.RangeIf(input, rangeIf.min(), rangeIf.max(), inRange, outOfRange); + } + + private Field grid(Field.Grid grid) { + Field expr = apply(grid.expr()); + return expr == grid.expr() ? grid : new Field.Grid(expr, grid.cellXZ(), grid.cellY()); + } + + private Field shared(Field.Shared shared) { + Field expr = apply(shared.expr()); + return expr == shared.expr() ? shared : new Field.Shared(shared.name() + "/" + tag, expr); + } + + private Field topSurface(Field.TopSurface topSurface) { + Field density = apply(topSurface.density()); + if (density == topSurface.density()) { + return topSurface; + } + return new Field.TopSurface(density, topSurface.upper(), topSurface.lower(), topSurface.step()); + } + + private List applyAll(List fields) { + List result = new ArrayList<>(fields.size()); + boolean changed = false; + for (Field field : fields) { + Field rewritten = apply(field); + changed |= rewritten != field; + result.add(rewritten); + } + return changed ? result : fields; + } + } + + private static final class Displacement extends Rewriter { + private final Field dx; + private final Field dz; + + private Displacement(Field dx, Field dz, String tag) { + super(tag); + this.dx = dx; + this.dz = dz; + } + + @Override + boolean skips(Field field) { + EnumSet axes = field.axes(); + return !axes.contains(Axis.X) && !axes.contains(Axis.Z); + } + + @Override + Field noise(Field.Noise noise) { + Field shiftX = combine(apply(noise.shiftX()), noise.xzScale() == 0.0 ? null : scale(dx, noise.xzScale())); + Field shiftY = apply(noise.shiftY()); + Field shiftZ = combine(apply(noise.shiftZ()), noise.xzScale() == 0.0 ? null : scale(dz, noise.xzScale())); + return new Field.Noise(noise.noiseId(), noise.xzScale(), noise.yScale(), shiftX, shiftY, shiftZ); + } + + @Override + Field coord(Field.Coord coord) { + Field displacement = coord.axis() == Axis.X ? dx : dz; + if (displacement instanceof Field.Const constant) { + double shift = Math.rint(constant.value()); + if (shift == 0.0) { + return coord; + } + return new Field.Coord(coord.axis(), coord.tiling(), coord.from() - shift, coord.to() - shift, coord.fromValue(), coord.toValue()); + } + if (coord.tiling() != Field.Tiling.CLAMP) { + throw new IllegalArgumentException("A " + coord.tiling() + " coordinate gradient cannot be displaced by a field for " + tag()); + } + double factor = (coord.toValue() - coord.fromValue()) / (coord.to() - coord.from()); + return new Field.Binary(Field.BinaryOp.ADD, coord, scale(displacement, factor)); + } + + @Override + Field slice(Field.Slice slice) { + Field expr = slice.axis() == Axis.Y + ? apply(slice.expr()) + : new Displacement(slice.axis() == Axis.X ? ZERO : dx, slice.axis() == Axis.Z ? ZERO : dz, tag()).apply(slice.expr()); + return expr == slice.expr() ? slice : new Field.Slice(expr, slice.axis(), slice.value()); + } + + private static Field combine(Field first, Field second) { + if (first == null) { + return second; + } + if (second == null) { + return first; + } + if (first instanceof Field.Const left && second instanceof Field.Const right) { + return new Field.Const(left.value() + right.value()); + } + return new Field.Binary(Field.BinaryOp.ADD, first, second); + } + } + + private static final class Substitution extends Rewriter { + private final Field target; + private final Field replacement; + + private Substitution(Field target, Field replacement, String tag) { + super(tag); + this.target = target; + this.replacement = replacement; + } + + @Override + Field apply(Field field) { + return field == target ? replacement : super.apply(field); + } + + @Override + boolean skips(Field field) { + return field instanceof Field.Const || field instanceof Field.Coord || field instanceof Field.Ref; + } + + @Override + Field noise(Field.Noise noise) { + Field shiftX = apply(noise.shiftX()); + Field shiftY = apply(noise.shiftY()); + Field shiftZ = apply(noise.shiftZ()); + if (shiftX == noise.shiftX() && shiftY == noise.shiftY() && shiftZ == noise.shiftZ()) { + return noise; + } + return new Field.Noise(noise.noiseId(), noise.xzScale(), noise.yScale(), shiftX, shiftY, shiftZ); + } + + @Override + Field coord(Field.Coord coord) { + return coord; + } + + @Override + Field slice(Field.Slice slice) { + Field expr = apply(slice.expr()); + return expr == slice.expr() ? slice : new Field.Slice(expr, slice.axis(), slice.value()); + } + } + + private static final class Lift extends Rewriter { + private final double verticalRatio; + + private Lift(double verticalRatio, String tag) { + super(tag); + this.verticalRatio = verticalRatio; + } + + @Override + boolean skips(Field field) { + EnumSet axes = field.axes(); + return !axes.contains(Axis.X) && !axes.contains(Axis.Z); + } + + @Override + Field noise(Field.Noise noise) { + double yScale = noise.yScale() == 0.0 && noise.xzScale() != 0.0 ? noise.xzScale() * verticalRatio : noise.yScale(); + Field shiftX = apply(noise.shiftX()); + Field shiftY = apply(noise.shiftY()); + Field shiftZ = apply(noise.shiftZ()); + if (yScale == noise.yScale() && shiftX == noise.shiftX() && shiftY == noise.shiftY() && shiftZ == noise.shiftZ()) { + return noise; + } + return new Field.Noise(noise.noiseId(), noise.xzScale(), yScale, shiftX, shiftY, shiftZ); + } + + @Override + Field coord(Field.Coord coord) { + return coord; + } + + @Override + Field slice(Field.Slice slice) { + Field expr = apply(slice.expr()); + return expr == slice.expr() ? slice : new Field.Slice(expr, slice.axis(), slice.value()); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/terrain/GeneratorCompiler.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/terrain/GeneratorCompiler.java new file mode 100644 index 000000000..19dbdf98b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/terrain/GeneratorCompiler.java @@ -0,0 +1,345 @@ +package art.arcane.iris.pack.datapack.export.compile.terrain; + +import art.arcane.iris.generation.noise.IrisGenerator; +import art.arcane.iris.generation.noise.IrisGeneratorStyle; +import art.arcane.iris.generation.noise.IrisNoiseGenerator; +import art.arcane.iris.generation.noise.NoiseStyle; +import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; +import art.arcane.iris.pack.datapack.export.compile.noise.NoiseRegistry; +import art.arcane.iris.pack.datapack.export.compile.noise.StyleCompiler; +import art.arcane.iris.pack.datapack.export.ir.CostClass; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.ValueRange; +import art.arcane.iris.pack.datapack.export.lower.ExportCapabilities; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.DoubleUnaryOperator; + +public final class GeneratorCompiler { + private static final String SHARED_PREFIX = "terrain/generator/"; + private static final String RESOURCE_PREFIX = "generators/"; + private static final String DEFAULT_KEY = "default"; + private static final int SURFACE_GRID = 6; + private static final double CLIFF_RESOLUTION = 255.0; + private static final double PARAMETRIC_ALPHA = 2.0; + private static final int SEGMENTS_PER_UNIT = 16; + private static final double CURVE_REACH = 4.0; + + private final StyleCompiler styles; + private final NoiseRegistry registry; + private final ExportCapabilities capabilities; + private final List diagnostics; + private final Map compiled = new HashMap<>(); + private final Set names = new HashSet<>(); + + public GeneratorCompiler(StyleCompiler styles, NoiseRegistry registry, ExportCapabilities capabilities, List diagnostics) { + this.styles = Objects.requireNonNull(styles, "styles"); + this.registry = Objects.requireNonNull(registry, "registry"); + this.capabilities = Objects.requireNonNull(capabilities, "capabilities"); + this.diagnostics = Objects.requireNonNull(diagnostics, "diagnostics"); + } + + public static long salt(String path) { + long hash = mix(0x1F3D5B79A2C4E6F8L); + for (int index = 0; index < path.length(); index++) { + hash = mix(hash ^ path.charAt(index)); + } + return hash; + } + + public static IrisGeneratorStyle zoomed(IrisGeneratorStyle style, double factor) { + IrisGeneratorStyle source = style == null ? new IrisGeneratorStyle(NoiseStyle.FLAT) : style; + IrisGeneratorStyle copy = new IrisGeneratorStyle(source.getStyle()); + copy.setCellularFrequency(source.getCellularFrequency()); + copy.setCellularZoom(source.getCellularZoom()); + copy.setZoom(source.getZoom() * factor); + copy.setExpression(source.getExpression()); + copy.setMultiplier(source.getMultiplier()); + copy.setFracture(source.getFracture()); + copy.setExponent(source.getExponent()); + copy.setCacheSize(source.getCacheSize()); + return copy; + } + + public Field compile(IrisGenerator generator, String scope) { + Objects.requireNonNull(generator, "generator"); + VanillaResourceIdentifier.requirePath(scope, "generator scope"); + String key = key(generator); + String cacheKey = scope + "\u0000" + key; + Field cached = compiled.get(cacheKey); + if (cached != null) { + return cached; + } + String name = name(scope, key); + Field value = generator(generator, scope + "/" + key, name); + Field result = value.cost() == CostClass.CHEAP ? value : new Field.Shared(name, value); + compiled.put(cacheKey, result); + return result; + } + + public Field compileRaw(IrisGenerator generator, String scope) { + Objects.requireNonNull(generator, "generator"); + VanillaResourceIdentifier.requirePath(scope, "generator scope"); + String key = key(generator); + String cacheKey = scope + "\u0000raw\u0000" + key; + Field cached = compiled.get(cacheKey); + if (cached != null) { + return cached; + } + String name = name(scope, key); + Field result = FieldMacros.share(name + "/raw", shape(generator, scope + "/" + key, name)); + compiled.put(cacheKey, result); + return result; + } + + public Field style(IrisGeneratorStyle style, double zoom, String path) { + return styles.compile(zoomed(style, zoom), salt(path), registry, capabilities); + } + + private Field generator(IrisGenerator generator, String path, String name) { + return surfaceDetail(shape(generator, path, name), generator.getSurfaceDetail(), name); + } + + private Field shape(IrisGenerator generator, String path, String name) { + List composite = generator.getComposite(); + if (composite == null || composite.isEmpty()) { + return new Field.Const(0.0); + } + Domain root = new Domain(positive(generator.getZoom(), "generator zoom"), generator.getOffsetX(), generator.getOffsetZ(), null, null); + String resource = RESOURCE_PREFIX + key(generator); + Field value = combine(generator, root, path, resource, name); + if (generator.hasCliffs()) { + value = new Field.Round(Field.RoundMode.ROUND, value, cliffStep(generator, root, path, resource, name)); + } + if (generator.getCellFractureHeight() != 0.0) { + report(resource, "Generator cell fracture is not exported; the unfractured height is used"); + } + return value; + } + + private Field combine(IrisGenerator generator, Domain root, String path, String resource, String name) { + List composite = generator.getComposite(); + List values = new ArrayList<>(composite.size()); + double opacity = 0.0; + for (int index = 0; index < composite.size(); index++) { + IrisNoiseGenerator layer = composite.get(index); + values.add(layer(layer, root, path + "/l" + index + "_" + layer.getSeed(), resource, name + "/l" + index)); + opacity += layer.getOpacity(); + } + if (generator.isMultiplicitive()) { + Field product = values.getFirst(); + for (int index = 1; index < values.size(); index++) { + product = FieldMacros.product(FieldMacros.share(name + "/product_" + index, product), values.get(index)); + } + return FieldMacros.scale(product, generator.getOpacity()); + } + if (opacity == 0.0) { + return new Field.Const(0.0); + } + return FieldMacros.scale(FieldMacros.sum(values), generator.getOpacity() / opacity); + } + + private Field layer(IrisNoiseGenerator layer, Domain domain, String path, String resource, String name) { + if (!layer.isEnabled()) { + return new Field.Const(layer.getOffsetY()); + } + if (layer.getOctaves() > 1) { + report(resource, "Noise layer octave multiplier " + layer.getOctaves() + " is not exported; the style's own octaves are used"); + } + Domain fractured = fractured(layer, domain, path, resource, name); + double layerZoom = positive(layer.getZoom(), "layer zoom"); + double zoom = domain.zoom() * layerZoom; + Field noise = style(layer.getStyle(), zoom, path); + Field placed = place(noise, fractured.offsetX() + layer.getOffsetX() * zoom, fractured.offsetZ() + layer.getOffsetZ() * zoom, + fractured.warpX(), fractured.warpZ(), path); + return shape(layer, placed, name); + } + + private Domain fractured(IrisNoiseGenerator layer, Domain domain, String path, String resource, String name) { + List fractures = layer.getFracture(); + if (fractures == null || fractures.isEmpty()) { + return domain; + } + List warpX = new ArrayList<>(); + List warpZ = new ArrayList<>(); + int salt = 33; + for (IrisNoiseGenerator child : fractures) { + if (child.isEnabled()) { + Field center = new Field.Const(-child.getOpacity() / 2.0); + String childPath = path + "/f" + salt + "_" + child.getSeed(); + String childName = name + "/f" + salt; + warpX.add(FieldMacros.scale(FieldMacros.sum(List.of(layer(child, domain, childPath + "x", resource, childName + "x"), center)), domain.zoom())); + warpZ.add(FieldMacros.scale(FieldMacros.sum(List.of(layer(child, domain, childPath + "z", resource, childName + "z"), center)), domain.zoom())); + } + salt += 819; + } + if (warpX.isEmpty()) { + return domain; + } + return new Domain(domain.zoom(), domain.offsetX(), domain.offsetZ(), add(domain.warpX(), FieldMacros.sum(warpX)), add(domain.warpZ(), FieldMacros.sum(warpZ))); + } + + private Field place(Field noise, double offsetX, double offsetZ, Field warpX, Field warpZ, String path) { + if (warpX == null && warpZ == null) { + return FieldMacros.offset(noise, offsetX, offsetZ); + } + Field dx = add(warpX, new Field.Const(offsetX)); + Field dz = add(warpZ, new Field.Const(offsetZ)); + String tag = "warp_" + Long.toHexString(salt(path)); + return FieldMacros.displace(noise, dx, dz, tag); + } + + private Field shape(IrisNoiseGenerator layer, Field noise, String name) { + double opacity = layer.getOpacity(); + Field value = FieldMacros.scale(noise, opacity); + if (layer.isNegative()) { + value = new Field.Binary(Field.BinaryOp.SUB, new Field.Const(opacity), value); + } + if (layer.getExponent() != 1.0) { + value = signedPower(value, layer.getExponent(), name); + } + if (layer.getOffsetY() != 0.0) { + value = FieldMacros.sum(List.of(value, new Field.Const(layer.getOffsetY()))); + } + if (layer.isParametric()) { + value = curve(value, GeneratorCompiler::parametric, GeneratorCompiler::parametricSlope); + } + if (layer.isBezier()) { + value = curve(value, GeneratorCompiler::bezier, GeneratorCompiler::bezierSlope); + } + if (layer.isSinCentered()) { + value = curve(value, input -> Math.sin(input * Math.PI), input -> Math.PI * Math.cos(input * Math.PI)); + } + return value; + } + + private Field cliffStep(IrisGenerator generator, Domain root, String path, String resource, String name) { + double minimum = generator.getCliffHeightMin(); + double maximum = generator.getCliffHeightMax(); + IrisNoiseGenerator cliffs = generator.getCliffHeightGenerator(); + if (minimum == maximum || cliffs == null) { + return new Field.Const(maximum / CLIFF_RESOLUTION); + } + Field noise = layer(cliffs, root, path + "/cliff", resource, name + "/cliff"); + Field height = FieldMacros.sum(List.of(new Field.Const(minimum), FieldMacros.scale(noise, maximum - minimum))); + return FieldMacros.scale(height, 1.0 / CLIFF_RESOLUTION); + } + + private Field surfaceDetail(Field value, double detail, String name) { + if (!Double.isFinite(detail) || detail < 0.0 || detail > 1.0) { + throw new IllegalArgumentException("Generator surfaceDetail must be finite and between 0 and 1, got " + detail); + } + if (detail == 1.0 || value.cost() == CostClass.CHEAP) { + return value; + } + if (detail == 0.0) { + return new Field.Grid(value, SURFACE_GRID, 1); + } + Field raw = FieldMacros.share(name + "/raw", value); + Field smooth = new Field.Grid(raw, SURFACE_GRID, 1); + return FieldMacros.sum(List.of(FieldMacros.scale(smooth, 1.0 - detail), FieldMacros.scale(raw, detail))); + } + + private Field signedPower(Field input, double exponent, String name) { + ValueRange range = input.range(); + Field value = range.min() < 0.0 && exponent != 3.0 ? FieldMacros.share(name + "/power", input) : input; + if (exponent == 2.0 && range.min() >= 0.0) { + return new Field.Unary(Field.UnaryOp.SQUARE, value); + } + if (exponent == 3.0) { + return new Field.Unary(Field.UnaryOp.CUBE, value); + } + Field power = new Field.Const(exponent); + Field positive = new Field.Binary(Field.BinaryOp.POW, new Field.Binary(Field.BinaryOp.MAX, value, new Field.Const(0.0)), power); + if (range.min() >= 0.0) { + return positive; + } + Field negated = new Field.Unary(Field.UnaryOp.NEG, value); + Field negative = new Field.Binary(Field.BinaryOp.POW, new Field.Binary(Field.BinaryOp.MAX, negated, new Field.Const(0.0)), power); + return new Field.Binary(Field.BinaryOp.SUB, positive, negative); + } + + private Field curve(Field value, DoubleUnaryOperator function, DoubleUnaryOperator slope) { + ValueRange range = value.range(); + double from = Math.max(-CURVE_REACH, Math.min(0.0, Math.floor(range.min()))); + double to = Math.min(CURVE_REACH + 1.0, Math.max(1.0, Math.ceil(range.max()))); + int segments = (int) Math.round((to - from) * SEGMENTS_PER_UNIT); + return FieldMacros.lookup(value, function, slope, from, to, segments); + } + + private void report(String resource, String message) { + diagnostics.add(new ExportDiagnostic(ExportDiagnostic.Severity.WARNING, ExportLimit.NOISE_STYLE, resource, message)); + } + + private String name(String scope, String key) { + String base = SHARED_PREFIX + scope + "/" + VanillaResourceIdentifier.sanitizePath(key); + String candidate = base; + int suffix = 2; + while (!names.add(candidate)) { + candidate = base + "_" + suffix; + suffix++; + } + return candidate; + } + + private static String key(IrisGenerator generator) { + String key = generator.getLoadKey(); + return key == null || key.isBlank() ? DEFAULT_KEY : key; + } + + private static Field add(Field first, Field second) { + if (first == null) { + return second; + } + if (second == null) { + return first; + } + return FieldMacros.sum(List.of(first, second)); + } + + private static double positive(double value, String role) { + if (!Double.isFinite(value) || value <= 0.0) { + throw new IllegalArgumentException("The " + role + " must be finite and positive, got " + value); + } + return value; + } + + private static double bezier(double value) { + return value * value * (3.0 - 2.0 * value); + } + + private static double bezierSlope(double value) { + return 6.0 * value - 6.0 * value * value; + } + + private static double parametric(double value) { + double forward = Math.pow(value, PARAMETRIC_ALPHA); + double backward = Math.pow(1.0 - value, PARAMETRIC_ALPHA); + return forward / (forward + backward); + } + + private static double parametricSlope(double value) { + double forward = value * value; + double backward = (1.0 - value) * (1.0 - value); + double total = forward + backward; + return (2.0 * value * backward + 2.0 * (1.0 - value) * forward) / (total * total); + } + + private static long mix(long value) { + long mixed = value + 0x9E3779B97F4A7C15L; + mixed = (mixed ^ mixed >>> 30) * 0xBF58476D1CE4E5B9L; + mixed = (mixed ^ mixed >>> 27) * 0x94D049BB133111EBL; + return mixed ^ mixed >>> 31; + } + + private record Domain(double zoom, double offsetX, double offsetZ, Field warpX, Field warpZ) { + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/terrain/HeightCompiler.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/terrain/HeightCompiler.java new file mode 100644 index 000000000..1953dd121 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/terrain/HeightCompiler.java @@ -0,0 +1,157 @@ +package art.arcane.iris.pack.datapack.export.compile.terrain; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.biome.IrisBiomeGeneratorLink; +import art.arcane.iris.generation.noise.IrisGenerator; +import art.arcane.iris.generation.noise.IrisInterpolator; +import art.arcane.iris.generation.noise.IrisShapedGeneratorStyle; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.pack.datapack.export.compile.biome.BiomeCodes; +import art.arcane.iris.pack.datapack.export.compile.noise.NoiseRegistry; +import art.arcane.iris.pack.datapack.export.compile.noise.StyleCompiler; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.FieldValidator; +import art.arcane.iris.pack.datapack.export.lower.ExportCapabilities; +import art.arcane.iris.pack.loading.IrisData; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.SortedMap; +import java.util.TreeMap; + +public final class HeightCompiler { + public static final String HEIGHT = "height"; + public static final String SLOPE = "terrain/height/slope"; + private static final String SCOPE = "height"; + private static final double SLOPE_STEP = 4.0; + private static final Field ZERO = new Field.Const(0.0); + private static final String OVERLAY_PATH = "height/overlay/"; + private static final String BUCKET_PREFIX = "terrain/height/bucket/"; + private static final Comparator INTERPOLATOR_ORDER = Comparator + .comparing((IrisInterpolator interpolator) -> interpolator.getFunction().name()) + .thenComparingDouble(IrisInterpolator::getHorizontalScale); + + private final StyleCompiler styles; + private final List diagnostics; + + public HeightCompiler(StyleCompiler styles, List diagnostics) { + this.styles = Objects.requireNonNull(styles, "styles"); + this.diagnostics = Objects.requireNonNull(diagnostics, "diagnostics"); + } + + public TerrainHeight compile(BiomeCodes codes, IrisDimension dimension, IrisData data, NoiseRegistry registry, ExportCapabilities capabilities) { + Map biomes = TerrainBiomes.resolve(codes, data); + SortedMap> buckets = buckets(biomes, data); + BandTreeCompiler bands = new BandTreeCompiler(codes, SCOPE); + GeneratorCompiler generators = new GeneratorCompiler(styles, registry, capabilities, diagnostics); + int step = dimension.getBiomeBoundsSamplingStep(); + List terms = new ArrayList<>(); + Field base = new Field.Const(dimension.getMinHeight() + dimension.getFluidHeight()); + terms.add(new Term(base, ZERO, ZERO)); + int index = 0; + for (Map.Entry> bucket : buckets.entrySet()) { + terms.add(bucket(bucket.getKey(), bucket.getValue(), biomes, bands, generators, step, BUCKET_PREFIX + index)); + index++; + } + List overlay = dimension.getOverlayNoise(); + for (int layer = 0; overlay != null && layer < overlay.size(); layer++) { + terms.add(overlay(overlay.get(layer), generators, layer)); + } + List heights = new ArrayList<>(terms.size()); + List eastward = new ArrayList<>(terms.size()); + List southward = new ArrayList<>(terms.size()); + for (Term term : terms) { + heights.add(term.height()); + eastward.add(term.eastward()); + southward.add(term.southward()); + } + Field height = clamp(FieldMacros.sum(heights), dimension.getMinHeight(), dimension.getMaxHeight()); + FieldValidator.requireTwoDimensional(height, HEIGHT); + Field slope = FieldMacros.share(SLOPE, FieldMacros.norm(new FieldMacros.Gradient(FieldMacros.sum(eastward), FieldMacros.sum(southward)))); + return new TerrainHeight(new Field.Shared(HEIGHT, height), slope); + } + + private static Term bucket(IrisInterpolator interpolator, SortedMap members, Map biomes, + BandTreeCompiler bands, GeneratorCompiler generators, int step, String name) { + Map low = new HashMap<>(); + Map span = new HashMap<>(); + boolean contributes = false; + boolean spans = false; + for (Map.Entry entry : biomes.entrySet()) { + double minimum = 0.0; + double maximum = 0.0; + for (String key : members.keySet()) { + minimum += entry.getValue().getGenLinkMin(key); + maximum += entry.getValue().getGenLinkMax(key); + } + low.put(entry.getKey(), minimum); + span.put(entry.getKey(), maximum - minimum); + contributes |= minimum != 0.0 || maximum != 0.0; + spans |= maximum != minimum; + } + if (!contributes) { + return new Term(ZERO, ZERO, ZERO); + } + Field lowBand = band(bands, low, interpolator, step); + if (!spans) { + return new Term(lowBand, ZERO, ZERO); + } + Field spanBand = FieldMacros.share(name + "/span", band(bands, span, interpolator, step)); + Field spanHard = bands.hard(span); + List heights = new ArrayList<>(members.size()); + for (IrisGenerator generator : members.values()) { + heights.add(generators.compile(generator, SCOPE)); + } + Field mean = FieldMacros.scale(FieldMacros.sum(heights), 1.0 / heights.size()); + FieldMacros.Gradient gradient = FieldMacros.grad2(mean, SLOPE_STEP); + return new Term(FieldMacros.sum(List.of(lowBand, FieldMacros.product(spanBand, mean))), + FieldMacros.product(spanHard, gradient.x()), FieldMacros.product(spanHard, gradient.z())); + } + + static Term overlay(IrisShapedGeneratorStyle style, GeneratorCompiler generators, int index) { + double minimum = style.getMin(); + double maximum = style.getMax(); + if (minimum == maximum) { + return new Term(new Field.Const(minimum), ZERO, ZERO); + } + Field noise = generators.style(style.getGenerator(), 1.0, OVERLAY_PATH + index); + FieldMacros.Gradient gradient = FieldMacros.grad2(noise, SLOPE_STEP); + Field overlay = FieldMacros.sum(List.of(new Field.Const(minimum), FieldMacros.scale(noise, maximum - minimum))); + return new Term(overlay, FieldMacros.scale(gradient.x(), maximum - minimum), FieldMacros.scale(gradient.z(), maximum - minimum)); + } + + static SortedMap> buckets(Map biomes, IrisData data) { + SortedMap> buckets = new TreeMap<>(INTERPOLATOR_ORDER); + for (IrisBiome biome : biomes.values()) { + for (IrisBiomeGeneratorLink link : TerrainBiomes.links(biome)) { + IrisGenerator generator = TerrainBiomes.generator(link, data); + IrisInterpolator interpolator = generator.getInterpolator() == null ? IrisInterpolator.DEFAULT : generator.getInterpolator(); + buckets.computeIfAbsent(interpolator, ignored -> new TreeMap<>()).putIfAbsent(generator.getLoadKey(), generator); + } + } + return buckets; + } + + private static Field band(BandTreeCompiler bands, Map values, IrisInterpolator interpolator, int step) { + if (BandTreeCompiler.blends(interpolator)) { + return bands.band(values, interpolator); + } + Field hard = bands.hard(values); + return hard instanceof Field.Const ? hard : new Field.Grid(hard, step, 1); + } + + static Field clamp(Field height, double minimum, double maximum) { + if (height instanceof Field.Const constant) { + return new Field.Const(Math.max(minimum, Math.min(maximum, constant.value()))); + } + return new Field.Clamp(height, minimum, maximum); + } + + record Term(Field height, Field eastward, Field southward) { + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/terrain/IslandCompiler.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/terrain/IslandCompiler.java new file mode 100644 index 000000000..951e586a8 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/terrain/IslandCompiler.java @@ -0,0 +1,217 @@ +package art.arcane.iris.pack.datapack.export.compile.terrain; + +import art.arcane.iris.generation.biome.FloatingIslandEdgeProfile; +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.biome.IrisBiomeGeneratorLink; +import art.arcane.iris.generation.biome.IrisFloatingChildBiomes; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.lower.ExportCapabilities; +import art.arcane.iris.pack.loading.IrisData; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +final class IslandCompiler { + private static final String SCOPE = "island"; + private static final String SHARED_PREFIX = "terrain/"; + private static final String RESOURCE_PREFIX = "biomes/"; + private static final double CLOSED = 4096.0; + private static final double EDGE_STEP = 4.0; + private static final double MIN_BOTTOM_EXPONENT = 0.1; + private static final double HALF_BLOCK = 0.5; + private static final Field ONE = new Field.Const(1.0); + + private final Field height; + private final BandTreeCompiler bands; + private final GeneratorCompiler generators; + private final Field blockY; + private final IrisDimension dimension; + private final IrisData data; + private final ExportCapabilities capabilities; + private final List diagnostics; + + IslandCompiler(Field height, BandTreeCompiler bands, GeneratorCompiler generators, Field blockY, IrisDimension dimension, + IrisData data, ExportCapabilities capabilities, List diagnostics) { + this.height = height; + this.bands = bands; + this.generators = generators; + this.blockY = blockY; + this.dimension = dimension; + this.data = data; + this.capabilities = capabilities; + this.diagnostics = diagnostics; + } + + List compile(Map biomes) { + List islands = new ArrayList<>(); + for (Map.Entry entry : biomes.entrySet()) { + IrisBiome parent = entry.getValue(); + List children = parent.getFloatingChildBiomes(); + if (children == null || children.isEmpty()) { + continue; + } + String path = SCOPE + "/" + VanillaResourceIdentifier.sanitizePath(parent.getLoadKey()); + Field presence = FieldMacros.share(SHARED_PREFIX + path + "/presence", bands.hard(Map.of(entry.getKey(), 1.0))); + List terms = new ArrayList<>(children.size()); + for (int index = 0; index < children.size(); index++) { + IrisFloatingChildBiomes child = children.get(index); + report(parent, child); + terms.add(island(parent, child, presence, path + "/" + index)); + } + islands.add(parent.isMergeFloatingChildBiomes() || terms.size() == 1 ? union(terms) : pick(children, terms, path)); + } + return islands; + } + + private Field island(IrisBiome parent, IrisFloatingChildBiomes child, Field presence, String path) { + Field footprint = generators.style(child.getFootprintStyle(), 1.0, path + "/footprint"); + Field signed = FieldMacros.share(SHARED_PREFIX + path + "/footprint", FieldMacros.sum(List.of(FieldMacros.scale(footprint, 2.0), new Field.Const(-1.0)))); + double cut = 2.0 * Math.max(0.0, Math.min(1.0, child.getFootprintThreshold())) - 1.0; + Field edge = FieldMacros.signedDistance(signed, cut, EDGE_STEP); + int width = FloatingIslandEdgeProfile.clampWidth(child.getEdgeTaperWidth()); + Field fade = FieldMacros.smoothstep(edge, 1.0, 1.0 + width); + double exponent = FloatingIslandEdgeProfile.clampExponent(child.getEdgeTaperExponent()); + if (exponent != FloatingIslandEdgeProfile.DEFAULT_EXPONENT) { + fade = new Field.Binary(Field.BinaryOp.POW, fade, new Field.Const(exponent)); + } + fade = FieldMacros.share(SHARED_PREFIX + path + "/fade", fade); + Field base = FieldMacros.share(SHARED_PREFIX + path + "/base", base(child, path)); + Field top = FieldMacros.share(SHARED_PREFIX + path + "/top", + new Field.Binary(Field.BinaryOp.ADD, base, rounded(FieldMacros.product(fade, topHeight(parent, child, path))))); + Field bottom = new Field.Binary(Field.BinaryOp.SUB, base, rounded(FieldMacros.product(fade, depth(child, path)))); + bottom = new Field.Binary(Field.BinaryOp.MAX, bottom, FieldMacros.sum(List.of(top, new Field.Const(1.0 - Math.max(1, child.getMaxThickness()))))); + if (child.getMinAbsoluteY() != null) { + bottom = new Field.Binary(Field.BinaryOp.MAX, bottom, new Field.Const(child.getMinAbsoluteY())); + } + if (child.getMaxAbsoluteY() != null) { + top = new Field.Binary(Field.BinaryOp.MIN, top, new Field.Const(child.getMaxAbsoluteY())); + } + Field aboveBottom = FieldMacros.sum(List.of(new Field.Binary(Field.BinaryOp.SUB, blockY, bottom), new Field.Const(HALF_BLOCK))); + Field belowTop = FieldMacros.sum(List.of(new Field.Binary(Field.BinaryOp.SUB, top, blockY), new Field.Const(HALF_BLOCK))); + Field lens = new Field.Binary(Field.BinaryOp.MIN, aboveBottom, belowTop); + Field terrain = new Field.Binary(Field.BinaryOp.SUB, height, blockY); + Field open = FieldMacros.sum(List.of(new Field.Binary(Field.BinaryOp.SUB, lens, terrain), new Field.Const(CLOSED))); + Field condition = FieldMacros.product(presence, fade); + return FieldMacros.sum(List.of(new Field.Gate(condition, open), new Field.Const(-CLOSED))); + } + + private Field base(IrisFloatingChildBiomes child, String path) { + int worldMin = dimension.getMinHeight(); + int minimum = Math.max(0, child.getMinHeightAboveSurface() - worldMin); + int maximum = Math.max(minimum, child.getMaxHeightAboveSurface() - worldMin); + Field lowest = new Field.Const(worldMin + minimum); + if (maximum == minimum) { + return lowest; + } + Field altitude = unit(generators.style(child.getAltitudeStyle(), 1.0, path + "/altitude")); + return new Field.Binary(Field.BinaryOp.ADD, lowest, rounded(FieldMacros.scale(altitude, maximum - minimum))); + } + + private Field topHeight(IrisBiome parent, IrisFloatingChildBiomes child, String path) { + int maximum = Math.max(0, child.getMaxTopHeight()); + if (maximum == 0) { + return new Field.Const(0.0); + } + return switch (child.getTopShapeMode()) { + case FLAT -> new Field.Const(maximum); + case NOISE -> rounded(FieldMacros.scale(unit(generators.style(child.getTopShapeStyle(), 1.0, path + "/top")), + Math.max(0.0, Math.min(1.0, child.getTopShapeAmp())) * maximum)); + case BIOME -> new Field.Binary(Field.BinaryOp.MIN, new Field.Const(maximum), + new Field.Binary(Field.BinaryOp.MAX, new Field.Const(0.0), rounded(targetHeight(target(parent, child))))); + }; + } + + private Field targetHeight(IrisBiome target) { + List parts = new ArrayList<>(); + for (IrisBiomeGeneratorLink link : TerrainBiomes.links(target)) { + Field noise = unit(generators.compile(TerrainBiomes.generator(link, data), SCOPE)); + parts.add(FieldMacros.sum(List.of(new Field.Const(link.getMin()), FieldMacros.scale(noise, link.getMax() - link.getMin())))); + } + Field sum = FieldMacros.sum(parts); + return new Field.Clamp(sum, 0.0, dimension.getMaxHeight() - dimension.getMinHeight()); + } + + private Field depth(IrisFloatingChildBiomes child, String path) { + int minimum = Math.max(0, child.getBottomDepthMin()); + int maximum = Math.max(minimum, child.getBottomDepthMax()); + if (maximum == minimum) { + return new Field.Const(minimum); + } + Field shaped = unit(generators.style(child.getBottomStyle(), 1.0, path + "/bottom")); + double exponent = Math.max(MIN_BOTTOM_EXPONENT, child.getBottomExponent()); + if (exponent != 1.0) { + shaped = new Field.Binary(Field.BinaryOp.POW, shaped, new Field.Const(exponent)); + } + return FieldMacros.sum(List.of(new Field.Const(minimum), FieldMacros.scale(shaped, maximum - minimum))); + } + + private Field pick(List children, List terms, String path) { + Field picker = unit(generators.style(children.getFirst().getPickerStyle(), 1.0, path + "/picker")); + double total = 0.0; + for (IrisFloatingChildBiomes child : children) { + total += 1.0 / Math.max(1, child.getRarity()); + } + List thresholds = new ArrayList<>(children.size() - 1); + List indices = new ArrayList<>(children.size()); + double cumulative = 0.0; + for (int index = 0; index < children.size(); index++) { + if (index > 0) { + thresholds.add(cumulative / total); + } + cumulative += 1.0 / Math.max(1, children.get(index).getRarity()); + indices.add(new Field.Const(index)); + } + return new Field.Mux(new Field.Partition(picker, thresholds, indices), terms); + } + + private IrisBiome target(IrisBiome parent, IrisFloatingChildBiomes child) { + String key = child.getBiome(); + if (key == null || key.isBlank() || key.equals(parent.getLoadKey())) { + return parent; + } + IrisBiome target = data.getBiomeLoader().load(key, false); + return target == null ? parent : target; + } + + private void report(IrisBiome parent, IrisFloatingChildBiomes child) { + List features = new ArrayList<>(3); + if (child.getWallWarpStyle() != null && child.getWallWarpAmplitude() > 0.0) { + features.add("wall warp"); + } + if (child.hasCarvingReference() || (child.getCarveStyle() != null && child.getCarveThreshold() < 1.0)) { + features.add("interior carving"); + } + if (child.getLocalFluidHeight() != null && child.getLocalFluidHeight() > 0) { + features.add("island water"); + } + if (features.isEmpty()) { + return; + } + diagnostics.add(new ExportDiagnostic(ExportDiagnostic.Severity.WARNING, ExportLimit.FLOATING_ISLAND, + RESOURCE_PREFIX + parent.getLoadKey(), "Floating island " + String.join(", ", features) + " is not exported")); + } + + private static Field union(List terms) { + Field union = terms.getFirst(); + for (int index = 1; index < terms.size(); index++) { + union = new Field.Binary(Field.BinaryOp.MAX, union, terms.get(index)); + } + return union; + } + + private static Field unit(Field value) { + return new Field.Clamp(value, 0.0, 1.0); + } + + private static Field rounded(Field value) { + if (value instanceof Field.Const constant) { + return new Field.Const(Math.round(constant.value())); + } + return new Field.Round(Field.RoundMode.ROUND, value, ONE); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/terrain/RiverCompiler.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/terrain/RiverCompiler.java new file mode 100644 index 000000000..72184b77c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/terrain/RiverCompiler.java @@ -0,0 +1,293 @@ +package art.arcane.iris.pack.datapack.export.compile.terrain; + +import art.arcane.iris.generation.hydrology.config.IrisBankStyle; +import art.arcane.iris.generation.hydrology.config.IrisHydrology; +import art.arcane.iris.generation.hydrology.plan.HydrologySettings; +import art.arcane.iris.generation.hydrology.water.HydrologyTier; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.pack.datapack.export.compile.noise.NoiseRegistry; +import art.arcane.iris.pack.datapack.export.ir.CostClass; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.FieldValidator; +import art.arcane.iris.pack.datapack.export.ir.NoiseDef; +import art.arcane.iris.pack.datapack.export.lower.ExportCapabilities; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public final class RiverCompiler { + public static final String CARVED_HEIGHT = "height/carved"; + private static final String RESOURCE_PREFIX = "dimensions/"; + private static final String RESOURCE_SUFFIX = "/hydrology"; + private static final String TIER_PREFIX = "river/tier_"; + private static final String BED_VARIATION = "river/bed_variation"; + private static final NoiseDef WARP_X = new NoiseDef("river/warp_x", 0, List.of(1.0)); + private static final NoiseDef WARP_Z = new NoiseDef("river/warp_z", 0, List.of(1.0)); + private static final NoiseDef BED = new NoiseDef("river/bed", -5, List.of(1.0, 0.0, 2.0)); + private static final NoiseDef BANK = new NoiseDef("river/bank", 1, List.of(1.0)); + private static final int NETWORK_OCTAVE = 0; + private static final List NETWORK_AMPLITUDES = List.of(1.0); + private static final int NETWORK_CELL = 4; + private static final double GRADIENT_STEP = 4.0; + private static final double ISOLINE_DENSITY = 1.36; + private static final double MEAN_GRADIENT = 1.12; + private static final double GRADIENT_FLOOR_SHARE = 0.25; + private static final double GRADIENT_CEILING_SHARE = 4.0; + private static final double MIN_WAVELENGTH = 16.0; + private static final double WARP_FREQUENCY_RATIO = 0.5; + private static final double WARP_AMPLITUDE_SHARE = 0.6; + private static final double VALLEY_RELIEF_PER_WIDTH = 4.0 / 3.0; + private static final double MIN_VALLEY_RELIEF = 4.0; + private static final double MIN_DEPTH = 1.0; + private static final double BED_VARIATION_SHARE = 0.3; + private static final double MAX_BED_VARIATION = 2.0; + private static final double MAX_CONE_SLOPE = 2.0; + private static final double CONE_EXPONENT = 0.8; + private static final int CONE_SEGMENTS = 16; + private static final double TERRACE = 3.0; + private static final double CLIFF_BENCH = 0.5; + private static final double CLIFF_WALL = 0.2; + private static final Field ZERO = new Field.Const(0.0); + private static final Field ONE = new Field.Const(1.0); + + private final List diagnostics; + + public RiverCompiler(List diagnostics) { + this.diagnostics = Objects.requireNonNull(diagnostics, "diagnostics"); + } + + public Field compile(Field height, IrisDimension dimension, NoiseRegistry registry, ExportCapabilities capabilities) { + Objects.requireNonNull(height, "height"); + IrisHydrology hydrology = dimension.getHydrology(); + if (hydrology == null || !hydrology.isEnabled()) { + return height; + } + int fluidHeight = dimension.getMinHeight() + dimension.getFluidHeight(); + HydrologySettings settings = HydrologySettings.compile(hydrology, 0L, fluidHeight, dimension.getMinHeight(), dimension.getMaxHeight()); + String resource = RESOURCE_PREFIX + dimension.getLoadKey() + RESOURCE_SUFFIX; + report(ExportLimit.ELEVATED_WATER, resource, "Water above sea level is not exported: channels above the lowlands stay dry and ponds, lakes and waterfalls are dropped"); + if (settings.density() <= 0.0F) { + return height; + } + report(ExportLimit.RIVER_DRAINAGE, resource, "Rivers follow seeded noise channels instead of drainage: tributaries cross instead of joining and widths follow elevation"); + Field elevation = FieldMacros.sum(List.of(height, new Field.Const(-fluidHeight))); + Carving carving = new Carving(elevation, variation(registry), bankNoise(settings.deformation(), registry), settings.deformation(), registry, capabilities); + List tiers = settings.tiers(); + List cuts = new ArrayList<>(tiers.size()); + for (int index = 0; index < tiers.size(); index++) { + cuts.add(cut(carving, tiers.get(index), index, settings.density())); + } + Field carved = new Field.Binary(Field.BinaryOp.SUB, height, deepest(cuts, 0, cuts.size())); + FieldValidator.requireTwoDimensional(carved, CARVED_HEIGHT); + return new Field.Shared(CARVED_HEIGHT, carved); + } + + private void report(ExportLimit limit, String resource, String message) { + diagnostics.add(new ExportDiagnostic(ExportDiagnostic.Severity.WARNING, limit, resource, message)); + } + + private static Field cut(Carving carving, HydrologyTier tier, int index, double density) { + String prefix = TIER_PREFIX + index; + double wavelength = Math.max(MIN_WAVELENGTH, Math.sqrt(tier.minCatchment() / density)); + double scale = 1.0 / (wavelength * ISOLINE_DENSITY); + Field distance = new Field.Shared(prefix + "/distance", distance(network(carving, prefix, scale, wavelength), scale, carving.capabilities())); + double valley = Math.max(Math.max(MIN_VALLEY_RELIEF, VALLEY_RELIEF_PER_WIDTH * tier.maxHalfWidth()), tier.inset() + 1.0); + Field halfWidth = halfWidth(tier, carving.elevation(), valley); + Field depth = depth(halfWidth, tier.depthRatio()); + Field ratio = new Field.Clamp(halfWidth instanceof Field.Const constant + ? FieldMacros.scale(distance, 1.0 / constant.value()) + : new Field.Binary(Field.BinaryOp.DIV, distance, halfWidth), 0.0, 1.0); + Field relief = relief(carving.elevation(), valley, tier.inset()); + Field bank = bank(carving, tier, new Field.Binary(Field.BinaryOp.SUB, distance, halfWidth), relief, valley); + Field total = FieldMacros.sum(List.of(bed(ratio, depth, carving.variation()), min(ZERO, carving.elevation()), bank)); + return new Field.Shared(prefix + "/cut", max(ZERO, total)); + } + + private static Field network(Carving carving, String prefix, double scale, double wavelength) { + NoiseRegistry registry = carving.registry(); + String noise = registry.register(new NoiseDef(prefix, NETWORK_OCTAVE, NETWORK_AMPLITUDES)); + double warpScale = scale * WARP_FREQUENCY_RATIO; + double warpShift = WARP_AMPLITUDE_SHARE * wavelength * scale; + Field shiftX = FieldMacros.scale(new Field.Noise(registry.register(WARP_X), warpScale, 0.0, null, null, null), warpShift); + Field shiftZ = FieldMacros.scale(new Field.Noise(registry.register(WARP_Z), warpScale, 0.0, null, null, null), warpShift); + Field warped = new Field.Noise(noise, scale, 0.0, shiftX, null, shiftZ); + return new Field.Shared(prefix + "/network", new Field.Grid(warped, NETWORK_CELL, 1)); + } + + private static Field distance(Field network, double scale, ExportCapabilities capabilities) { + Field east = FieldMacros.offset(network, GRADIENT_STEP, 0.0); + Field south = FieldMacros.offset(network, 0.0, GRADIENT_STEP); + Field diagonal = FieldMacros.offset(network, GRADIENT_STEP, GRADIENT_STEP); + Field x = stencil(List.of(east, diagonal), List.of(network, south), 0.5 / GRADIENT_STEP); + Field z = stencil(List.of(south, diagonal), List.of(network, east), 0.5 / GRADIENT_STEP); + Field center = stencil(List.of(network, east, south, diagonal), List.of(), 0.25); + Field slope = FieldMacros.norm(new FieldMacros.Gradient(x, z)); + double mean = MEAN_GRADIENT * scale; + Field bounded = new Field.Clamp(slope, GRADIENT_FLOOR_SHARE * mean, GRADIENT_CEILING_SHARE * mean); + return new Field.Binary(Field.BinaryOp.DIV, new Field.Unary(Field.UnaryOp.ABS, center), bounded); + } + + private static Field stencil(List added, List subtracted, double weight) { + List terms = new ArrayList<>(added.size() + subtracted.size()); + terms.addAll(added); + for (Field field : subtracted) { + terms.add(FieldMacros.scale(field, -1.0)); + } + return FieldMacros.scale(FieldMacros.sum(terms), weight); + } + + private static Field halfWidth(HydrologyTier tier, Field elevation, double valley) { + double narrow = tier.minHalfWidth(); + double wide = tier.maxHalfWidth(); + if (narrow == wide) { + return new Field.Const(wide); + } + Field upland = new Field.Clamp(FieldMacros.scale(elevation, 1.0 / valley), 0.0, 1.0); + return FieldMacros.sum(List.of(new Field.Const(wide), FieldMacros.scale(upland, narrow - wide))); + } + + private static Field depth(Field halfWidth, double depthRatio) { + if (halfWidth instanceof Field.Const constant) { + return new Field.Const(Math.max(MIN_DEPTH, depthRatio * constant.value())); + } + return max(new Field.Const(MIN_DEPTH), FieldMacros.scale(halfWidth, depthRatio)); + } + + private static Field relief(Field elevation, double valley, int inset) { + double gully = Math.max(0, inset); + Field floorRise = new Field.Curve(elevation, List.of( + new Field.Knot(valley, ZERO, 0.0), + new Field.Knot(2.0 * valley, new Field.Const(2.0 * valley - gully), 1.0))); + return max(ZERO, new Field.Binary(Field.BinaryOp.SUB, elevation, floorRise)); + } + + private static Field bed(Field ratio, Field depth, Field variation) { + Field bowl = FieldMacros.sum(List.of(ONE, FieldMacros.scale(new Field.Unary(Field.UnaryOp.SQUARE, ratio), -1.0))); + Field amplitude = depth instanceof Field.Const constant + ? new Field.Const(Math.min(MAX_BED_VARIATION, BED_VARIATION_SHARE * constant.value())) + : min(new Field.Const(MAX_BED_VARIATION), FieldMacros.scale(depth, BED_VARIATION_SHARE)); + Field deepened = FieldMacros.sum(List.of(depth, weigh(variation, amplitude))); + Field channel = new Field.Partition(ratio, List.of(1.0), List.of(ONE, ZERO)); + return min(depth, max(channel, weigh(bowl, deepened))); + } + + private static Field bank(Carving carving, HydrologyTier tier, Field edge, Field relief, double valley) { + int band = tier.bankBand(); + if (band <= 0) { + return weigh(new Field.Partition(edge, List.of(0.0), List.of(ONE, ZERO)), relief); + } + Field across = FieldMacros.scale(edge, 1.0 / band); + IrisBankStyle style = tier.bankStyle() == null ? IrisBankStyle.SMOOTH : tier.bankStyle(); + return switch (style) { + case SMOOTH, LINEAR, CONCAVE -> sloped(carving, shape(style, across), edge, across, relief, band); + case TERRACED -> terraced(carving, shape(style, across), edge, across, relief, band, valley); + case CLIFF -> weigh(FieldMacros.sum(List.of(ONE, FieldMacros.scale(shape(style, across), -1.0))), relief); + }; + } + + private static Field sloped(Carving carving, Field shape, Field edge, Field across, Field relief, int band) { + Field rise = max(weigh(shape, relief), weigh(cone(relief, band, carving.deformation()), edge)); + if (carving.bankNoise() != null) { + Field outside = new Field.Partition(across, List.of(0.0), List.of(ZERO, ONE)); + rise = FieldMacros.sum(List.of(rise, weigh(outside, carving.bankNoise()))); + } + return max(ZERO, new Field.Binary(Field.BinaryOp.SUB, relief, max(ZERO, rise))); + } + + private static Field terraced(Carving carving, Field shape, Field edge, Field across, Field relief, int band, double valley) { + Field rise = max(weigh(shape, relief), weigh(cone(relief, band, carving.deformation()), edge)); + Field bounded = new Field.Clamp(min(relief, max(ZERO, rise)), 0.0, 2.0 * valley); + Field stepped = new Field.Round(Field.RoundMode.FLOOR, bounded, new Field.Const(TERRACE)); + Field inside = new Field.Partition(across, List.of(1.0), List.of(ONE, ZERO)); + return weigh(inside, new Field.Binary(Field.BinaryOp.SUB, relief, stepped)); + } + + private static Field shape(IrisBankStyle style, Field across) { + return switch (style) { + case SMOOTH, TERRACED -> FieldMacros.smoothstep(across, 0.0, 1.0); + case LINEAR -> new Field.Clamp(across, 0.0, 1.0); + case CONCAVE -> new Field.Curve(new Field.Clamp(across, 0.0, 1.0), List.of( + new Field.Knot(0.0, ZERO, 2.0), + new Field.Knot(1.0, ONE, 0.0))); + case CLIFF -> new Field.Clamp(FieldMacros.scale(FieldMacros.sum(List.of(across, new Field.Const(-CLIFF_BENCH))), 1.0 / CLIFF_WALL), 0.0, 1.0); + }; + } + + private static Field cone(Field relief, int band, HydrologySettings.Deformation deformation) { + double base = Math.max(0.0, deformation.bankSlope()); + double steep = deformation.bankSteepFactor(); + if (steep <= 0.0 || base >= MAX_CONE_SLOPE) { + return new Field.Const(Math.min(MAX_CONE_SLOPE, base)); + } + double saturation = Math.pow((MAX_CONE_SLOPE - base) / steep, 1.0 / CONE_EXPONENT); + double half = saturation / CONE_SEGMENTS / 2.0; + Field slope = new Field.Clamp(FieldMacros.scale(relief, 1.0 / band), 0.0, saturation); + return FieldMacros.lookup(slope, + value -> coneSlope(value, base, steep), + value -> coneDerivative(value, half, saturation, base, steep), + 0.0, saturation, CONE_SEGMENTS); + } + + private static double coneSlope(double slope, double base, double steep) { + return Math.min(MAX_CONE_SLOPE, base + steep * Math.pow(slope, CONE_EXPONENT)); + } + + private static double coneDerivative(double slope, double half, double saturation, double base, double steep) { + double low = Math.max(0.0, slope - half); + double high = Math.min(saturation, slope + half); + return (coneSlope(high, base, steep) - coneSlope(low, base, steep)) / (high - low); + } + + private static Field variation(NoiseRegistry registry) { + Field noise = new Field.Noise(registry.register(BED), 1.0, 0.0, null, null, null); + return new Field.Shared(BED_VARIATION, new Field.Grid(noise, NETWORK_CELL, 1)); + } + + private static Field bankNoise(HydrologySettings.Deformation deformation, NoiseRegistry registry) { + double strength = deformation.bankNoise(); + if (strength <= 0.0) { + return null; + } + return FieldMacros.scale(new Field.Noise(registry.register(BANK), 1.0, 0.0, null, null, null), strength / 2.0); + } + + private static Field weigh(Field weight, Field amount) { + if (weight instanceof Field.Const || amount instanceof Field.Const) { + return FieldMacros.product(weight, amount); + } + if (weight.cost() != CostClass.EXPENSIVE && amount.cost() != CostClass.EXPENSIVE) { + return new Field.Binary(Field.BinaryOp.MUL, weight, amount); + } + Field magnitude = weight.range().min() >= 0.0 ? weight : new Field.Unary(Field.UnaryOp.ABS, weight); + return new Field.Binary(Field.BinaryOp.MUL, weight, new Field.Gate(magnitude, amount)); + } + + private static Field deepest(List cuts, int from, int to) { + if (to - from == 1) { + return cuts.get(from); + } + int middle = (from + to) >>> 1; + return max(deepest(cuts, from, middle), deepest(cuts, middle, to)); + } + + private static Field min(Field first, Field second) { + if (first instanceof Field.Const left && second instanceof Field.Const right) { + return new Field.Const(Math.min(left.value(), right.value())); + } + return new Field.Binary(Field.BinaryOp.MIN, first, second); + } + + private static Field max(Field first, Field second) { + if (first instanceof Field.Const left && second instanceof Field.Const right) { + return new Field.Const(Math.max(left.value(), right.value())); + } + return new Field.Binary(Field.BinaryOp.MAX, first, second); + } + + private record Carving(Field elevation, Field variation, Field bankNoise, HydrologySettings.Deformation deformation, + NoiseRegistry registry, ExportCapabilities capabilities) { + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/terrain/RiverStage.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/terrain/RiverStage.java new file mode 100644 index 000000000..8ee623905 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/terrain/RiverStage.java @@ -0,0 +1,22 @@ +package art.arcane.iris.pack.datapack.export.compile.terrain; + +import art.arcane.iris.pack.datapack.export.compile.CompileStage; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; +import art.arcane.iris.pack.datapack.export.ir.Field; + +public final class RiverStage implements CompileStage { + private static final String ESTIMATE_TAG = "estimate"; + + @Override + public String name() { + return "rivers"; + } + + @Override + public void apply(PackCompileContext context) { + Field natural = context.naturalHeight(); + Field carved = new RiverCompiler(context.diagnostics()).compile(natural, context.dimension(), context.noises(), context.capabilities()); + context.setCarvedHeight(carved); + context.setSurfaceEstimate(FieldMacros.substitute(carved, natural, context.climateHeight(), ESTIMATE_TAG)); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/terrain/TerrainBiomes.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/terrain/TerrainBiomes.java new file mode 100644 index 000000000..f3637ec5d --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/terrain/TerrainBiomes.java @@ -0,0 +1,57 @@ +package art.arcane.iris.pack.datapack.export.compile.terrain; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.biome.IrisBiomeGeneratorLink; +import art.arcane.iris.generation.noise.IrisGenerator; +import art.arcane.iris.pack.datapack.export.compile.biome.BiomeCodes; +import art.arcane.iris.pack.loading.IrisData; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +final class TerrainBiomes { + private static final String DEFAULT_GENERATOR = "default"; + + private TerrainBiomes() { + } + + static Map resolve(BiomeCodes codes, IrisData data) { + Map biomes = new LinkedHashMap<>(); + for (String id : BandTreeCompiler.surfaceBiomeIds(codes)) { + String loadKey = codes.loadKeyByBiomeId().get(id); + if (loadKey == null) { + continue; + } + IrisBiome biome = data.getBiomeLoader().load(loadKey, false); + if (biome == null) { + throw new IllegalStateException("Biome '" + loadKey + "' for exported biome '" + id + "' is missing from the pack"); + } + biomes.put(id, biome); + } + return Collections.unmodifiableMap(biomes); + } + + static IrisGenerator generator(IrisBiomeGeneratorLink link, IrisData data) { + String key = generatorKey(link); + IrisGenerator generator = data.getGeneratorLoader().load(key, false); + if (generator == null) { + generator = new IrisGenerator(); + } + if (generator.getLoadKey() == null || generator.getLoadKey().isBlank()) { + generator.setLoadKey(key); + } + return generator; + } + + static List links(IrisBiome biome) { + List links = biome.getGenerators(); + return links == null ? List.of() : links; + } + + private static String generatorKey(IrisBiomeGeneratorLink link) { + String key = link.getGenerator(); + return key == null || key.isBlank() ? DEFAULT_GENERATOR : key; + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/terrain/TerrainHeight.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/terrain/TerrainHeight.java new file mode 100644 index 000000000..a342c43cd --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/terrain/TerrainHeight.java @@ -0,0 +1,12 @@ +package art.arcane.iris.pack.datapack.export.compile.terrain; + +import art.arcane.iris.pack.datapack.export.ir.Field; + +import java.util.Objects; + +public record TerrainHeight(Field height, Field slope) { + public TerrainHeight { + Objects.requireNonNull(height, "height"); + Objects.requireNonNull(slope, "slope"); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/terrain/TerrainStage.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/terrain/TerrainStage.java new file mode 100644 index 000000000..9f33e879c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/terrain/TerrainStage.java @@ -0,0 +1,24 @@ +package art.arcane.iris.pack.datapack.export.compile.terrain; + +import art.arcane.iris.pack.datapack.export.compile.CompileStage; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; +import art.arcane.iris.pack.datapack.export.compile.biome.BiomeCodes; + +public final class TerrainStage implements CompileStage { + @Override + public String name() { + return "terrain"; + } + + @Override + public void apply(PackCompileContext context) { + BiomeCodes codes = context.baseBiomeCodes(); + TerrainHeight terrain = new HeightCompiler(context.styles(), context.diagnostics()) + .compile(codes, context.dimension(), context.data(), context.noises(), context.capabilities()); + context.setHeight(terrain.height()); + context.setClimateHeight(new ClimateHeightCompiler(context.styles()) + .compile(codes, context.dimension(), context.data(), context.noises(), context.capabilities())); + context.setVolumetric(new VolumetricCompiler(context.styles(), context.diagnostics()) + .compile(terrain, codes, context.dimension(), context.data(), context.noises(), context.capabilities())); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/terrain/VolumetricCompiler.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/terrain/VolumetricCompiler.java new file mode 100644 index 000000000..c92bb5619 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/compile/terrain/VolumetricCompiler.java @@ -0,0 +1,159 @@ +package art.arcane.iris.pack.datapack.export.compile.terrain; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.noise.IrisGeneratorStyle; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.generation.terrain.IrisTerrain3D; +import art.arcane.iris.pack.datapack.export.compile.biome.BiomeCodes; +import art.arcane.iris.pack.datapack.export.compile.noise.NoiseRegistry; +import art.arcane.iris.pack.datapack.export.compile.noise.StyleCompiler; +import art.arcane.iris.pack.datapack.export.compile.noise.ValueDistributionFit; +import art.arcane.iris.pack.datapack.export.ir.Axis; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.FieldEvaluator; +import art.arcane.iris.pack.datapack.export.ir.NoiseDef; +import art.arcane.iris.pack.datapack.export.lower.ExportCapabilities; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.noise.CNG; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.SplittableRandom; + +public final class VolumetricCompiler { + private static final String SCOPE = "volume"; + private static final String VARIANT_PREFIX = "terrain/volume/terrain3d/"; + private static final double STYLE_SCALE = 64.0; + private static final double CRACK_VERTICAL_RATIO = 0.25; + private static final int MARGINAL_SAMPLES = 8192; + private static final int MARGINAL_SPAN = 1 << 20; + private static final long MARGINAL_SEED = 0x7E33A1D5L; + + private final StyleCompiler styles; + private final List diagnostics; + private final Map> marginals = new HashMap<>(); + + public VolumetricCompiler(StyleCompiler styles, List diagnostics) { + this.styles = Objects.requireNonNull(styles, "styles"); + this.diagnostics = Objects.requireNonNull(diagnostics, "diagnostics"); + } + + public Field compile(TerrainHeight terrain, BiomeCodes codes, IrisDimension dimension, IrisData data, NoiseRegistry registry, ExportCapabilities capabilities) { + Field height = Objects.requireNonNull(terrain, "terrain").height(); + Map biomes = TerrainBiomes.resolve(codes, data); + BandTreeCompiler bands = new BandTreeCompiler(codes, SCOPE); + GeneratorCompiler generators = new GeneratorCompiler(styles, registry, capabilities, diagnostics); + Field blockY = new Field.Coord(Axis.Y, Field.Tiling.CLAMP, dimension.getMinHeight(), dimension.getMaxHeight(), + dimension.getMinHeight(), dimension.getMaxHeight()); + double fluidHeight = dimension.getMinHeight() + dimension.getFluidHeight(); + Sampling sampling = new Sampling(data, registry, capabilities, dimension.getMinHeight(), dimension.getMaxHeight()); + Field volume = terrain3d(height, terrain.slope(), biomes, bands, generators, blockY, fluidHeight, sampling); + List islands = new IslandCompiler(height, bands, generators, blockY, dimension, data, capabilities, diagnostics).compile(biomes); + for (Field island : islands) { + volume = new Field.Binary(Field.BinaryOp.MAX, volume, island); + } + return volume; + } + + private Field terrain3d(Field height, Field slope, Map biomes, BandTreeCompiler bands, GeneratorCompiler generators, + Field blockY, double fluidHeight, Sampling sampling) { + Map> variants = new LinkedHashMap<>(); + for (Map.Entry entry : biomes.entrySet()) { + IrisTerrain3D terrain = entry.getValue().getTerrain3D(); + if (terrain == null || !terrain.isEnabled() || terrain.getAmplitude() + terrain.getCrackDepth() <= 0.0) { + continue; + } + terrain.validate(); + variants.computeIfAbsent(terrain, ignored -> new LinkedHashMap<>()).put(entry.getKey(), 1.0); + } + List terms = new ArrayList<>(variants.size()); + int index = 0; + for (Map.Entry> variant : variants.entrySet()) { + IrisTerrain3D terrain = variant.getKey(); + String name = VARIANT_PREFIX + index; + Field presence = FieldMacros.share(name + "/presence", bands.hard(variant.getValue())); + Field mask = FieldMacros.share(name + "/mask", FieldMacros.product(presence, strength(terrain, height, slope, fluidHeight))); + Field fade = fade(terrain, blockY, fluidHeight); + Field shape = shape(terrain, generators, "terrain3d/" + index, sampling); + terms.add(FieldMacros.product(mask, FieldMacros.product(fade, shape))); + index++; + } + return FieldMacros.sum(terms); + } + + private Field signed(Marginal marginal, double reach, GeneratorCompiler generators, String path, Sampling sampling) { + Field unit = FieldMacros.lift(generators.style(marginal.style(), marginal.horizontalScale() / STYLE_SCALE, path), marginal.verticalRatio()); + if (unit instanceof Field.Const) { + return new Field.Clamp(FieldMacros.sum(List.of(FieldMacros.scale(unit, 2.0 * reach), new Field.Const(-reach))), -reach, reach); + } + List knots = marginals.computeIfAbsent(marginal, key -> fit(key, unit, sampling)); + List scaled = new ArrayList<>(knots.size()); + for (Field.Knot knot : knots) { + scaled.add(new Field.Knot(knot.location(), FieldMacros.scale(knot.value(), reach), knot.derivative() * reach)); + } + return new Field.Clamp(new Field.Curve(unit, scaled), -reach, reach); + } + + private static List fit(Marginal marginal, Field unit, Sampling sampling) { + CNG iris = marginal.style().createNoCache(new RNG(MARGINAL_SEED), sampling.data()); + Map noises = sampling.registry().byReference(); + double frequency = STYLE_SCALE / marginal.horizontalScale(); + SplittableRandom random = new SplittableRandom(MARGINAL_SEED); + double[] exported = new double[MARGINAL_SAMPLES]; + double[] expected = new double[MARGINAL_SAMPLES]; + for (int index = 0; index < MARGINAL_SAMPLES; index++) { + int x = random.nextInt(-MARGINAL_SPAN, MARGINAL_SPAN); + int y = random.nextInt(sampling.minY(), sampling.maxY()); + int z = random.nextInt(-MARGINAL_SPAN, MARGINAL_SPAN); + exported[index] = FieldEvaluator.sample(unit, noises, MARGINAL_SEED, sampling.capabilities().target(), x, y, z); + expected[index] = Math.clamp(iris.noiseFastSigned3D(x * frequency, y * frequency * marginal.verticalRatio(), z * frequency), -1.0, 1.0); + } + return ValueDistributionFit.fit(exported, expected); + } + + private static Field strength(IrisTerrain3D terrain, Field height, Field slope, double fluidHeight) { + double start = fluidHeight + terrain.getFluidClearance(); + Field elevation = FieldMacros.smoothstep(height, start, start + terrain.getFluidFade()); + if (terrain.getMinimumSlope() <= 0.0) { + return elevation; + } + Field steepness = FieldMacros.smoothstep(slope, terrain.getMinimumSlope(), terrain.getMinimumSlope() + terrain.getSlopeFade()); + return FieldMacros.product(elevation, steepness); + } + + private static Field fade(IrisTerrain3D terrain, Field blockY, double fluidHeight) { + double start = fluidHeight + terrain.getFluidClearance(); + return FieldMacros.smoothstep(blockY, start, start + terrain.getFluidFade()); + } + + private Field shape(IrisTerrain3D terrain, GeneratorCompiler generators, String path, Sampling sampling) { + List parts = new ArrayList<>(2); + double amplitude = terrain.getAmplitude(); + if (amplitude > 0.0) { + parts.add(signed(new Marginal(terrain.getDensityStyle(), terrain.getHorizontalScale(), terrain.getHorizontalScale() / terrain.getVerticalScale()), + amplitude, generators, path + "/density", sampling)); + } + double depth = terrain.getCrackDepth(); + if (depth > 0.0) { + Field distance = signed(new Marginal(terrain.getCrackStyle(), terrain.getCrackScale(), CRACK_VERTICAL_RATIO), terrain.getCrackScale() / 2.0, + generators, path + "/crack", sampling); + Field closeness = FieldMacros.sum(List.of(new Field.Const(1.0), + FieldMacros.scale(new Field.Unary(Field.UnaryOp.ABS, distance), -1.0 / terrain.getCrackWidth()))); + Field ridge = new Field.Binary(Field.BinaryOp.MAX, new Field.Const(0.0), closeness); + parts.add(FieldMacros.scale(new Field.Unary(Field.UnaryOp.SQUARE, ridge), -depth)); + } + return FieldMacros.sum(parts); + } + + private record Marginal(IrisGeneratorStyle style, double horizontalScale, double verticalRatio) { + } + + private record Sampling(IrisData data, NoiseRegistry registry, ExportCapabilities capabilities, int minY, int maxY) { + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/emit/BaseDensityFunctionEmitter.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/emit/BaseDensityFunctionEmitter.java new file mode 100644 index 000000000..b4864cc04 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/emit/BaseDensityFunctionEmitter.java @@ -0,0 +1,152 @@ +package art.arcane.iris.pack.datapack.export.emit; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; +import art.arcane.iris.pack.datapack.export.ir.Axis; +import art.arcane.iris.pack.datapack.export.ir.Field; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.WeakHashMap; + +abstract class BaseDensityFunctionEmitter implements DensityFunctionEmitter { + private static final int LOWEST_SURFACE_BOUND = -4064; + private static final int HIGHEST_SURFACE_BOUND = 4062; + + private final String namespace; + private final DataVersion target; + private final Map> sharedReferences = new WeakHashMap<>(); + + BaseDensityFunctionEmitter(String namespace, DataVersion target) { + this.namespace = VanillaResourceIdentifier.requireNamespace(namespace, "density function namespace"); + this.target = target; + } + + @Override + public final JsonElement emit(Field field, FunctionTable table) { + Objects.requireNonNull(field); + Objects.requireNonNull(table); + if (!table.namespace().equals(namespace)) { + throw new IllegalArgumentException("Density function table namespace '" + table.namespace() + "' does not match emitter namespace '" + namespace + "'"); + } + return node(field, table); + } + + protected abstract JsonElement node(Field field, FunctionTable table); + + final JsonPrimitive number(double value) { + return JsonNumbers.constant(value, target); + } + + final JsonObject typed(String type) { + JsonObject object = new JsonObject(); + object.addProperty("type", "minecraft:" + type); + return object; + } + + final JsonObject unary(String type, String inputKey, Field input, FunctionTable table) { + JsonObject object = typed(type); + object.add(inputKey, node(input, table)); + return object; + } + + final JsonObject binary(String type, String leftKey, Field left, String rightKey, Field right, FunctionTable table) { + JsonObject object = typed(type); + object.add(leftKey, node(left, table)); + object.add(rightKey, node(right, table)); + return object; + } + + final JsonObject clamp(Field input, double min, double max, FunctionTable table) { + JsonObject object = unary("clamp", "input", input, table); + object.add("min", number(min)); + object.add("max", number(max)); + return object; + } + + final JsonObject spline(Field.Curve curve, FunctionTable table) { + JsonObject object = typed("spline"); + object.add("spline", splineBody(curve, table)); + return object; + } + + final JsonObject intervalSelect(Field input, List thresholds, List functions, FunctionTable table) { + JsonObject object = unary("interval_select", "input", input, table); + JsonArray thresholdArray = new JsonArray(thresholds.size()); + for (double threshold : thresholds) { + thresholdArray.add(number(threshold)); + } + JsonArray functionArray = new JsonArray(functions.size()); + for (Field function : functions) { + functionArray.add(node(function, table)); + } + object.add("thresholds", thresholdArray); + object.add("functions", functionArray); + return object; + } + + final JsonObject rangeChoice(Field input, double min, double max, Field inRange, Field outOfRange, FunctionTable table) { + JsonObject object = unary("range_choice", "input", input, table); + object.add("min_inclusive", number(min)); + object.add("max_exclusive", number(max)); + object.add("when_in_range", node(inRange, table)); + object.add("when_out_of_range", node(outOfRange, table)); + return object; + } + + final JsonObject findTopSurface(Field.TopSurface topSurface, FunctionTable table) { + if (topSurface.lower() < LOWEST_SURFACE_BOUND || topSurface.lower() > HIGHEST_SURFACE_BOUND) { + throw new IllegalArgumentException("Top surface lower bound must be within [-4064, 4062], got " + topSurface.lower()); + } + JsonObject object = typed("find_top_surface"); + object.add("density", node(topSurface.density(), table)); + object.add("upper_bound", number(topSurface.upper())); + object.addProperty("lower_bound", topSurface.lower()); + object.addProperty("cell_height", topSurface.step()); + return object; + } + + final JsonPrimitive shared(Field.Shared shared, String wrapperType, String inputKey, FunctionTable table) { + Map references = sharedReferences.computeIfAbsent(table, ignored -> new IdentityHashMap<>()); + String existing = references.get(shared); + if (existing != null) { + return new JsonPrimitive(existing); + } + String reference = table.register(shared.name(), unary(wrapperType, inputKey, shared.expr(), table)); + references.put(shared, reference); + return new JsonPrimitive(reference); + } + + static String axisName(Axis axis) { + return axis.name().toLowerCase(Locale.ROOT); + } + + static int requireInteger(double value, String role) { + if (value != Math.rint(value) || value < Integer.MIN_VALUE || value > Integer.MAX_VALUE) { + throw new IllegalArgumentException(role + " must be an integer, got " + value); + } + return (int) value; + } + + private JsonObject splineBody(Field.Curve curve, FunctionTable table) { + JsonArray points = new JsonArray(curve.knots().size()); + for (Field.Knot knot : curve.knots()) { + JsonObject point = new JsonObject(); + point.add("location", number(knot.location())); + point.add("value", knot.value() instanceof Field.Curve nested ? splineBody(nested, table) : number(((Field.Const) knot.value()).value())); + point.add("derivative", number(knot.derivative())); + points.add(point); + } + JsonObject body = new JsonObject(); + body.add("coordinate", node(curve.input(), table)); + body.add("points", points); + return body; + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/emit/BaseWorldgenJsonWriter.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/emit/BaseWorldgenJsonWriter.java new file mode 100644 index 000000000..d1b707aad --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/emit/BaseWorldgenJsonWriter.java @@ -0,0 +1,370 @@ +package art.arcane.iris.pack.datapack.export.emit; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ir.BinaryResource; +import art.arcane.iris.pack.datapack.export.ir.BiomeDef; +import art.arcane.iris.pack.datapack.export.ir.ClimateLayout; +import art.arcane.iris.pack.datapack.export.ir.DimensionDef; +import art.arcane.iris.pack.datapack.export.ir.ExportIr; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.JsonResource; +import art.arcane.iris.pack.datapack.export.ir.NoiseDef; +import art.arcane.iris.pack.datapack.export.lower.ExportCapabilities; +import art.arcane.iris.pack.datapack.export.lower.VanillaBounds; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.SortedMap; +import java.util.TreeMap; +import java.util.TreeSet; + +abstract class BaseWorldgenJsonWriter implements WorldgenJsonWriter { + static final int MAX_NESTING = 255; + private static final String PACK_ICON = "pack.png"; + private static final String PACK_ICON_RESOURCE = "/iris/datapack/pack.png"; + private static final Gson GSON = new GsonBuilder().disableHtmlEscaping().create(); + private static final Set DIMENSION_TYPE_KEYS = Set.of("has_skylight", "has_ceiling", "has_ender_dragon_fight", "coordinate_scale", "min_y", + "height", "logical_height", "infiniburn", "ambient_light", "monster_spawn_light_level", "monster_spawn_block_light_limit", "has_fixed_time", + "skybox", "cardinal_light", "attributes", "timelines", "default_clock"); + private static final Set REQUIRED_DIMENSION_TYPE_KEYS = Set.of("has_skylight", "has_ceiling", "has_ender_dragon_fight", "coordinate_scale", + "infiniburn", "ambient_light", "monster_spawn_light_level", "monster_spawn_block_light_limit"); + private static final Set REQUIRED_BIOME_KEYS = Set.of("has_precipitation", "temperature", "downfall", "effects", "carvers", "features"); + private static final Set NOISE_CONSUMERS = Set.of("minecraft:noise", "minecraft:shifted_noise", "minecraft:shift_a", "minecraft:shift_b", + "minecraft:shift", "minecraft:noise_threshold"); + private static final String VANILLA_NETHER = "{\"type\":\"minecraft:the_nether\",\"generator\":{\"type\":\"minecraft:noise\",\"biome_source\":" + + "{\"type\":\"minecraft:multi_noise\",\"preset\":\"minecraft:nether\"},\"settings\":\"minecraft:nether\"}}"; + private static final String VANILLA_END = "{\"type\":\"minecraft:the_end\",\"generator\":{\"type\":\"minecraft:noise\",\"biome_source\":" + + "{\"type\":\"minecraft:the_end\"},\"settings\":\"minecraft:end\"}}"; + + private final DataVersion target; + private final Set biomeKeys; + + BaseWorldgenJsonWriter(DataVersion target, Set biomeKeys) { + this.target = target; + this.biomeKeys = Set.copyOf(biomeKeys); + } + + @Override + public final SortedMap write(ExportIr ir) { + if (ir.target() != target) { + throw new IllegalArgumentException("The " + target.getVersion() + " writer cannot write an export for " + ir.target().getVersion()); + } + String namespace = ir.namespace(); + String dimension = ir.dimension().name(); + DensityFunctionEmitter emitter = DensityFunctionEmitter.forTarget(ExportCapabilities.forTarget(target), namespace, VanillaBounds.of(namespace, ir.noises())); + Roots roots = new Roots(new FunctionTable(namespace), emitter, dimension); + SortedMap files = new TreeMap<>(); + files.put("pack.mcmeta", packMetadata(ir)); + for (NoiseDef noise : ir.noises()) { + files.put(path(namespace, "worldgen/noise", noise.id()), noise(noise)); + } + JsonElement rule = materialRule(ir); + JsonObject settings = noiseSettings(ir, roots, rule); + NestingSplitter.split(roots.table(), MAX_NESTING); + for (Map.Entry function : roots.table().files().entrySet()) { + files.put(path(namespace, "worldgen/density_function", function.getKey()), function.getValue()); + } + files.put(path(namespace, "worldgen/noise_settings", dimension), settings); + files.putAll(materialFiles(ir, rule)); + List noiseConsumers = new ArrayList<>(roots.table().files().values()); + noiseConsumers.add(settings); + noiseConsumers.add(rule); + requireRegisteredNoises(ir, noiseConsumers); + for (BiomeDef biome : ir.biomes()) { + files.put(path(namespace, "worldgen/biome", biome.id()), validatedBiome(biome)); + } + files.put(path(namespace, "dimension_type", dimension), dimensionType(ir.dimension())); + addModeFiles(ir, levelStem(ir), files); + for (JsonResource resource : ir.jsonResources()) { + requireUnclaimed(files.keySet(), resource.path()); + files.put(resource.path(), resource.json()); + } + SortedMap bytes = serialize(files); + for (BinaryResource resource : ir.binaryResources()) { + requireUnclaimed(bytes.keySet(), resource.path()); + bytes.put(resource.path(), resource.bytes()); + } + requireUnclaimed(bytes.keySet(), PACK_ICON); + bytes.put(PACK_ICON, PackIcon.BYTES.clone()); + return bytes; + } + + protected abstract JsonObject noise(NoiseDef noise); + + protected abstract JsonObject noiseSettings(ExportIr ir, Roots roots, JsonElement materialRule); + + protected abstract JsonElement materialRule(ExportIr ir); + + protected abstract Map materialFiles(ExportIr ir, JsonElement rule); + + final DataVersion target() { + return target; + } + + final JsonPrimitive number(double value) { + return JsonNumbers.constant(value, target); + } + + static String path(String namespace, String registry, String id) { + return "data/" + namespace + "/" + registry + "/" + id + ".json"; + } + + static JsonArray span(double min, double max, DataVersion target) { + JsonArray span = new JsonArray(2); + span.add(JsonNumbers.constant(min, target)); + span.add(JsonNumbers.constant(max, target)); + return span; + } + + private JsonObject packMetadata(ExportIr ir) { + JsonObject pack = new JsonObject(); + pack.addProperty("description", "Iris worldgen export " + ir.namespace() + ":" + ir.dimension().name() + ". Keep Default selected"); + pack.addProperty("min_format", target.getPackFormat()); + pack.addProperty("max_format", target.getPackFormat()); + JsonObject metadata = new JsonObject(); + metadata.add("pack", pack); + return metadata; + } + + private static void addModeFiles(ExportIr ir, JsonObject stem, SortedMap files) { + String id = ir.namespace() + ":" + ir.dimension().name(); + switch (ir.mode()) { + case PRESET -> { + JsonObject dimensions = new JsonObject(); + dimensions.add("minecraft:overworld", stem); + dimensions.add("minecraft:the_nether", JsonParser.parseString(VANILLA_NETHER)); + dimensions.add("minecraft:the_end", JsonParser.parseString(VANILLA_END)); + JsonObject preset = new JsonObject(); + preset.add("dimensions", dimensions); + files.put(path(ir.namespace(), "worldgen/world_preset", ir.dimension().name()), preset); + JsonArray values = new JsonArray(1); + values.add(id); + JsonObject tag = new JsonObject(); + tag.add("values", values); + files.put("data/minecraft/tags/worldgen/world_preset/normal.json", tag); + } + case DIMENSION -> files.put(path(ir.namespace(), "dimension", ir.dimension().name()), stem); + case OVERWORLD -> files.put("data/minecraft/dimension/overworld.json", stem); + } + } + + private JsonObject validatedBiome(BiomeDef biome) { + JsonObject json = biome.json(); + requireKeys(json, biomeKeys, REQUIRED_BIOME_KEYS, "Biome '" + biome.id() + "'"); + return json; + } + + private JsonObject dimensionType(DimensionDef dimension) { + JsonObject type = dimension.dimensionType(); + type.addProperty("min_y", dimension.minY()); + type.addProperty("height", dimension.height()); + type.addProperty("logical_height", dimension.logicalHeight()); + requireKeys(type, DIMENSION_TYPE_KEYS, REQUIRED_DIMENSION_TYPE_KEYS, "Dimension type '" + dimension.name() + "'"); + return type; + } + + private JsonObject levelStem(ExportIr ir) { + String id = ir.namespace() + ":" + ir.dimension().name(); + if (ir.climate().entries().isEmpty()) { + throw new IllegalArgumentException("Export '" + id + "' has no climate entries for its biome source"); + } + JsonArray biomes = new JsonArray(ir.climate().entries().size()); + for (ClimateLayout.ClimateEntry entry : ir.climate().entries()) { + JsonObject parameters = new JsonObject(); + for (String axis : List.of("temperature", "humidity", "continentalness", "erosion", "depth", "weirdness")) { + parameters.add(axis, number(0.0)); + } + parameters.add(switch (entry.axis()) { + case TEMPERATURE -> "temperature"; + case HUMIDITY -> "humidity"; + case DEPTH -> "depth"; + }, span(entry.min(), entry.max(), target)); + parameters.add("offset", number(0.0)); + JsonObject point = new JsonObject(); + point.addProperty("biome", entry.biomeId()); + point.add("parameters", parameters); + biomes.add(point); + } + JsonObject source = new JsonObject(); + source.addProperty("type", "minecraft:multi_noise"); + source.add("biomes", biomes); + JsonObject generator = new JsonObject(); + generator.addProperty("type", "minecraft:noise"); + generator.addProperty("settings", id); + generator.add("biome_source", source); + JsonObject stem = new JsonObject(); + stem.addProperty("type", id); + stem.add("generator", generator); + return stem; + } + + private static void requireRegisteredNoises(ExportIr ir, List consumers) { + Set registered = new HashSet<>(ir.noises().size()); + for (NoiseDef noise : ir.noises()) { + registered.add(ir.namespace() + ":" + noise.id()); + } + Set missing = new TreeSet<>(); + for (JsonElement consumer : consumers) { + collectUnregisteredNoises(consumer, ir.namespace(), registered, missing); + } + if (!missing.isEmpty()) { + throw new IllegalStateException("Export references noises that are not registered worldgen/noise files: " + String.join(", ", missing)); + } + } + + private static void collectUnregisteredNoises(JsonElement element, String namespace, Set registered, Set missing) { + if (element.isJsonArray()) { + for (JsonElement item : element.getAsJsonArray()) { + collectUnregisteredNoises(item, namespace, registered, missing); + } + return; + } + if (!element.isJsonObject()) { + return; + } + JsonObject object = element.getAsJsonObject(); + JsonElement type = object.get("type"); + JsonElement noise = object.get("noise"); + if (type != null && type.isJsonPrimitive() && NOISE_CONSUMERS.contains(type.getAsString())) { + if (noise == null || !noise.isJsonPrimitive() || !noise.getAsJsonPrimitive().isString()) { + missing.add("inline noise in " + type.getAsString()); + } else if (!(noise.getAsString().startsWith(namespace + ":") ? registered.contains(noise.getAsString()) : noise.getAsString().startsWith("minecraft:"))) { + missing.add(noise.getAsString()); + } + } + for (Map.Entry member : object.entrySet()) { + collectUnregisteredNoises(member.getValue(), namespace, registered, missing); + } + } + + private static void requireUnclaimed(Set generated, String path) { + if (generated.contains(path)) { + throw new IllegalArgumentException("Export resource '" + path + "' collides with a file the writer generates"); + } + } + + private static void requireKeys(JsonObject json, Set allowed, Set required, String resource) { + Set unknown = new TreeSet<>(json.keySet()); + unknown.removeAll(allowed); + if (!unknown.isEmpty()) { + throw new IllegalArgumentException(resource + " has keys the codec ignores: " + String.join(", ", unknown)); + } + Set absent = new TreeSet<>(required); + absent.removeAll(json.keySet()); + if (!absent.isEmpty()) { + throw new IllegalArgumentException(resource + " is missing required keys: " + String.join(", ", absent)); + } + } + + private static SortedMap serialize(SortedMap files) { + SortedMap bytes = new TreeMap<>(); + for (Map.Entry file : files.entrySet()) { + int depth = NestingSplitter.depth(file.getValue()); + if (depth > MAX_NESTING) { + throw new IllegalStateException("Export file '" + file.getKey() + "' nests " + depth + " levels; the limit is " + MAX_NESTING); + } + bytes.put(file.getKey(), (GSON.toJson(sorted(file.getValue())) + "\n").getBytes(StandardCharsets.UTF_8)); + } + return bytes; + } + + private static JsonElement sorted(JsonElement element) { + if (element.isJsonObject()) { + JsonObject sorted = new JsonObject(); + for (Map.Entry member : new TreeMap<>(element.getAsJsonObject().asMap()).entrySet()) { + sorted.add(member.getKey(), sorted(member.getValue())); + } + return sorted; + } + if (element.isJsonArray()) { + JsonArray sorted = new JsonArray(element.getAsJsonArray().size()); + for (JsonElement item : element.getAsJsonArray()) { + sorted.add(sorted(item)); + } + return sorted; + } + return element; + } + + private static final class PackIcon { + private static final byte[] BYTES = read(); + + private PackIcon() { + } + + private static byte[] read() { + try (InputStream input = BaseWorldgenJsonWriter.class.getResourceAsStream(PACK_ICON_RESOURCE)) { + if (input == null) { + throw new IllegalStateException("Datapack icon resource " + PACK_ICON_RESOURCE + " is missing"); + } + return input.readAllBytes(); + } catch (IOException exception) { + throw new UncheckedIOException("Datapack icon resource " + PACK_ICON_RESOURCE + " could not be read", exception); + } + } + } + + static final class Roots { + private final FunctionTable table; + private final DensityFunctionEmitter emitter; + private final String dimension; + + private Roots(FunctionTable table, DensityFunctionEmitter emitter, String dimension) { + this.table = table; + this.emitter = emitter; + this.dimension = dimension; + } + + FunctionTable table() { + return table; + } + + JsonElement inline(String name, Field field) { + JsonElement emitted = emitter.emit(field, table); + if (emitted.isJsonPrimitive()) { + return emitted; + } + return new JsonPrimitive(table.register(dimension + "/" + name, emitted)); + } + + String named(String name, Field field) { + JsonElement emitted = emitter.emit(field, table); + if (emitted.isJsonPrimitive() && emitted.getAsJsonPrimitive().isString()) { + emitted = localBody(emitted.getAsString(), name); + } + return table.registerNamed(dimension + "/" + name, emitted); + } + + private JsonElement localBody(String reference, String name) { + String prefix = table.namespace() + ":"; + JsonElement body = reference.startsWith(prefix) ? table.files().get(reference.substring(prefix.length())) : null; + if (body == null) { + throw new IllegalStateException("Router slot '" + name + "' of dimension '" + dimension + "' is the external density function '" + reference + + "' and cannot be written under its own name"); + } + return body; + } + + String registered(String name, Field field) { + JsonElement emitted = emitter.emit(field, table); + if (emitted.isJsonPrimitive() && emitted.getAsJsonPrimitive().isString()) { + return emitted.getAsString(); + } + return table.register(dimension + "/" + name, emitted); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/emit/DensityFunctionEmitter.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/emit/DensityFunctionEmitter.java new file mode 100644 index 000000000..eedb28c94 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/emit/DensityFunctionEmitter.java @@ -0,0 +1,18 @@ +package art.arcane.iris.pack.datapack.export.emit; + +import art.arcane.iris.pack.datapack.export.WorldgenExportTargets; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.lower.ExportCapabilities; +import art.arcane.iris.pack.datapack.export.lower.VanillaBounds; +import com.google.gson.JsonElement; + +public interface DensityFunctionEmitter { + JsonElement emit(Field field, FunctionTable table); + + static DensityFunctionEmitter forTarget(ExportCapabilities capabilities, String namespace, VanillaBounds bounds) { + return switch (capabilities.target()) { + case V26_3 -> new DensityFunctionEmitter26_3(namespace, bounds); + default -> throw WorldgenExportTargets.unsupported(capabilities.target().getVersion()); + }; + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/emit/DensityFunctionEmitter26_3.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/emit/DensityFunctionEmitter26_3.java new file mode 100644 index 000000000..4e47e9128 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/emit/DensityFunctionEmitter26_3.java @@ -0,0 +1,145 @@ +package art.arcane.iris.pack.datapack.export.emit; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.lower.FieldDomains; +import art.arcane.iris.pack.datapack.export.lower.SplineLowering; +import art.arcane.iris.pack.datapack.export.lower.VanillaBounds; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +import java.util.Objects; + +final class DensityFunctionEmitter26_3 extends BaseDensityFunctionEmitter { + private final VanillaBounds bounds; + + DensityFunctionEmitter26_3(String namespace, VanillaBounds bounds) { + super(namespace, DataVersion.V26_3); + this.bounds = Objects.requireNonNull(bounds); + } + + @Override + protected JsonElement node(Field field, FunctionTable table) { + return switch (field) { + case Field.Const constant -> number(constant.value()); + case Field.Coord coord -> gradient(coord); + case Field.Noise noise -> noise(noise, table); + case Field.Binary binary -> binary(binary, table); + case Field.Unary unary -> unary(unaryType(unary.op()), "input", FieldDomains.unaryInput(unary), table); + case Field.Clamp clamp -> clamp(clamp.input(), clamp.min(), clamp.max(), table); + case Field.Lerp lerp -> lerp(lerp, table); + case Field.Round round -> round(round, table); + case Field.Curve curve -> spline(curve, table); + case Field.Partition partition -> intervalSelect(partition.input(), partition.thresholds(), partition.leaves(), table); + case Field.Mux mux -> mux.cases().size() == 1 ? node(mux.cases().getFirst(), table) : spline(SplineLowering.mux(mux), table); + case Field.Gate gate -> spline(SplineLowering.gate(gate), table); + case Field.RangeIf rangeIf -> rangeChoice(rangeIf.input(), rangeIf.min(), rangeIf.max(), rangeIf.inRange(), rangeIf.outOfRange(), table); + case Field.Grid grid -> interpolated(grid, table); + case Field.Shared shared -> shared(shared, "cache", "input", table); + case Field.Slice slice -> slice(slice, table); + case Field.TopSurface topSurface -> findTopSurface(topSurface, table); + case Field.Ref ref -> new JsonPrimitive(ref.id()); + }; + } + + private JsonObject gradient(Field.Coord coord) { + JsonObject object = typed("gradient"); + object.addProperty("axis", axisName(coord.axis())); + object.addProperty("tiling", switch (coord.tiling()) { + case CLAMP -> "clamp_to_edge"; + case REPEAT -> "repeat"; + case MIRRORED_REPEAT -> "mirrored_repeat"; + }); + object.addProperty("from_coordinate", requireInteger(coord.from(), "Gradient from coordinate")); + object.addProperty("to_coordinate", requireInteger(coord.to(), "Gradient to coordinate")); + object.add("from_value", number(coord.fromValue())); + object.add("to_value", number(coord.toValue())); + return object; + } + + private JsonObject noise(Field.Noise noise, FunctionTable table) { + JsonObject object = typed("noise"); + object.addProperty("noise", noise.noiseId()); + object.add("xz_scale", JsonNumbers.unbounded(noise.xzScale())); + object.add("y_scale", JsonNumbers.unbounded(noise.yScale())); + if (noise.shiftX() != null) { + object.add("shift_x", node(noise.shiftX(), table)); + } + if (noise.shiftY() != null) { + object.add("shift_y", node(noise.shiftY(), table)); + } + if (noise.shiftZ() != null) { + object.add("shift_z", node(noise.shiftZ(), table)); + } + return object; + } + + private JsonElement binary(Field.Binary binary, FunctionTable table) { + Field folded = bounds.fold(binary); + if (folded != binary) { + return node(folded, table); + } + if (binary.op() == Field.BinaryOp.POW) { + return binary("pow", "base", FieldDomains.clampPowBase(binary.left(), binary.right()), "exponent", binary.right(), table); + } + String type = switch (binary.op()) { + case ADD -> "add"; + case SUB -> "sub"; + case MUL -> "mul"; + case DIV -> "div"; + case MIN -> "min"; + case MAX -> "max"; + case POW -> "pow"; + }; + return binary(type, "left", binary.left(), "right", binary.right(), table); + } + + private JsonObject lerp(Field.Lerp lerp, FunctionTable table) { + JsonObject object = typed("lerp"); + object.add("alpha", node(lerp.alpha(), table)); + object.add("first", node(lerp.first(), table)); + object.add("second", node(lerp.second(), table)); + return object; + } + + private JsonObject round(Field.Round round, FunctionTable table) { + String type = switch (round.mode()) { + case ROUND -> "round"; + case FLOOR -> "floor"; + case CEIL -> "ceil"; + case TRUNCATE -> "truncate"; + }; + return binary(type, "input", round.input(), "multiple", round.multiple(), table); + } + + private JsonObject interpolated(Field.Grid grid, FunctionTable table) { + JsonObject object = unary("interpolated", "input", grid.expr(), table); + object.addProperty("cell_size_xz", grid.cellXZ()); + object.addProperty("cell_size_y", grid.cellY()); + return object; + } + + private JsonObject slice(Field.Slice slice, FunctionTable table) { + JsonObject object = typed("slice"); + object.addProperty("axis", axisName(slice.axis())); + object.addProperty("coordinate", slice.value()); + object.add("input", node(slice.expr(), table)); + return object; + } + + private static String unaryType(Field.UnaryOp op) { + return switch (op) { + case NEG -> "negate"; + case ABS -> "abs"; + case SQUARE -> "square"; + case CUBE -> "cube"; + case SQRT -> "sqrt"; + case LOG -> "log"; + case SIGN -> "sign"; + case SQUEEZE -> "squeeze"; + case HALF_NEG -> "half_negative"; + case QUARTER_NEG -> "quarter_negative"; + }; + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/emit/Format26_3Writer.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/emit/Format26_3Writer.java new file mode 100644 index 000000000..43f527d70 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/emit/Format26_3Writer.java @@ -0,0 +1,140 @@ +package art.arcane.iris.pack.datapack.export.emit; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ir.AquiferDef; +import art.arcane.iris.pack.datapack.export.ir.DimensionDef; +import art.arcane.iris.pack.datapack.export.ir.ExportIr; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.NoiseDef; +import art.arcane.iris.pack.datapack.export.ir.RouterDef; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +import java.util.Map; +import java.util.Set; + +public final class Format26_3Writer extends BaseWorldgenJsonWriter { + private static final Set BIOME_KEYS = Set.of("has_precipitation", "temperature", "temperature_modifier", "downfall", "effects", + "attributes", "carvers", "features"); + private static final Set BLOCK_STATE_SLOTS = Set.of("result_state", "ore_block", "raw_ore_block", "filler_block"); + private static final Set BLOCK_STATE_KEYS = Set.of("id", "properties"); + private static final double SPAWN_ELEVATION_SCALE = 64.0; + private static final double SPAWN_LOWEST_ABOVE_SEA = 1.0; + private static final double SPAWN_HIGHEST_ABOVE_SEA = 48.0; + private static final double CLIMATE_LIMIT = 2.0; + + public Format26_3Writer() { + super(DataVersion.V26_3, BIOME_KEYS); + } + + @Override + protected JsonObject noise(NoiseDef noise) { + JsonArray modifiers = new JsonArray(noise.amplitudes().size()); + for (double amplitude : noise.amplitudes()) { + modifiers.add(JsonNumbers.unbounded(amplitude)); + } + JsonObject json = new JsonObject(); + json.addProperty("base_octave", noise.firstOctave()); + json.addProperty("octave_count", noise.amplitudes().size()); + json.add("amplitude_modifiers", modifiers); + json.addProperty("base_amplitude", 1.0); + json.addProperty("normalize", false); + return json; + } + + @Override + protected JsonObject noiseSettings(ExportIr ir, Roots roots, JsonElement materialRule) { + RouterDef router = ir.router(); + AquiferDef aquifer = router.aquifer(); + DimensionDef dimension = ir.dimension(); + JsonObject noiseRouter = new JsonObject(); + noiseRouter.add("temperature", roots.inline("temperature", router.temperature())); + noiseRouter.add("vegetation", roots.inline("vegetation", router.vegetation())); + noiseRouter.add("continents", roots.inline("continents", router.continents())); + noiseRouter.add("erosion", roots.inline("erosion", router.erosion())); + noiseRouter.add("depth", roots.inline("depth", router.depth())); + noiseRouter.add("ridges", roots.inline("ridges", router.ridges())); + noiseRouter.add("chunk_surface_level", roots.inline("chunk_surface_level", router.chunkSurfaceLevel())); + noiseRouter.addProperty("final_density", roots.named("final_density", router.finalDensity())); + JsonObject aquifers = new JsonObject(); + aquifers.add("barrier", roots.inline("aquifer/barrier", aquifer.barrier())); + aquifers.add("fluid_level_floodedness", roots.inline("aquifer/fluid_level_floodedness", aquifer.floodedness())); + aquifers.add("fluid_level_spread", roots.inline("aquifer/fluid_level_spread", aquifer.spread())); + aquifers.add("lava", roots.inline("aquifer/lava", aquifer.lava())); + aquifers.add("exclusion", roots.inline("aquifer/exclusion", aquifer.exclusion())); + aquifers.add("surface_level", roots.inline("aquifer/surface_level", aquifer.surfaceLevel())); + JsonObject spawn = new JsonObject(); + spawn.add(roots.registered("spawn_elevation", spawnElevation(aquifer.surfaceLevel(), dimension.fluidHeight())), + span(SPAWN_LOWEST_ABOVE_SEA / SPAWN_ELEVATION_SCALE, SPAWN_HIGHEST_ABOVE_SEA / SPAWN_ELEVATION_SCALE, target())); + JsonArray spawnTargets = new JsonArray(1); + spawnTargets.add(spawn); + JsonObject noise = new JsonObject(); + noise.addProperty("min_y", dimension.minY()); + noise.addProperty("height", dimension.height()); + JsonObject settings = new JsonObject(); + settings.add("aquifers", aquifers); + settings.add("default_block", blockState(dimension.defaultBlock())); + settings.add("default_fluid", blockState(dimension.defaultFluid())); + settings.addProperty("disable_mob_generation", false); + settings.addProperty("legacy_random_source", false); + settings.addProperty("material_rule", ir.namespace() + ":" + dimension.name()); + settings.add("noise", noise); + settings.add("noise_router", noiseRouter); + settings.addProperty("sea_level", dimension.seaLevel()); + settings.add("spawn_target", spawnTargets); + return settings; + } + + @Override + protected JsonElement materialRule(ExportIr ir) { + JsonElement rule = ir.material().rule(); + requireBlockStates(rule, "Material rule for '" + ir.dimension().name() + "'"); + return rule; + } + + @Override + protected Map materialFiles(ExportIr ir, JsonElement rule) { + return Map.of(path(ir.namespace(), "worldgen/material_rule", ir.dimension().name()), rule); + } + + private static Field spawnElevation(Field surfaceLevel, int fluidHeight) { + Field aboveSea = new Field.Binary(Field.BinaryOp.SUB, surfaceLevel, new Field.Const(fluidHeight)); + return new Field.Clamp(new Field.Binary(Field.BinaryOp.MUL, aboveSea, new Field.Const(1.0 / SPAWN_ELEVATION_SCALE)), -CLIMATE_LIMIT, CLIMATE_LIMIT); + } + + private static JsonObject blockState(String block) { + JsonObject state = new JsonObject(); + state.addProperty("id", block); + return state; + } + + private static void requireBlockStates(JsonElement element, String resource) { + if (element.isJsonArray()) { + for (JsonElement item : element.getAsJsonArray()) { + requireBlockStates(item, resource); + } + return; + } + if (!element.isJsonObject()) { + return; + } + for (Map.Entry member : element.getAsJsonObject().entrySet()) { + if (BLOCK_STATE_SLOTS.contains(member.getKey())) { + requireBlockState(member.getValue(), resource); + } else { + requireBlockStates(member.getValue(), resource); + } + } + } + + private static void requireBlockState(JsonElement state, String resource) { + if (state.isJsonPrimitive() && state.getAsJsonPrimitive().isString()) { + return; + } + if (state.isJsonObject() && state.getAsJsonObject().has("id") && BLOCK_STATE_KEYS.containsAll(state.getAsJsonObject().keySet())) { + return; + } + throw new IllegalArgumentException(resource + " uses block state " + state + "; 26.3 block states are an id string or {\"id\",\"properties\"}"); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/emit/FunctionTable.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/emit/FunctionTable.java new file mode 100644 index 000000000..6574890df --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/emit/FunctionTable.java @@ -0,0 +1,91 @@ +package art.arcane.iris.pack.datapack.export.emit; + +import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.SortedMap; +import java.util.TreeMap; + +public final class FunctionTable { + private final String namespace; + private final SortedMap files = new TreeMap<>(); + private final Map idsByCanonicalBody = new HashMap<>(); + + public FunctionTable(String namespace) { + this.namespace = VanillaResourceIdentifier.requireNamespace(namespace, "density function namespace"); + } + + public String namespace() { + return namespace; + } + + public String register(String id, JsonElement body) { + VanillaResourceIdentifier.requirePath(id, "density function id"); + String canonicalBody = canonical(body).toString(); + String existingId = idsByCanonicalBody.get(canonicalBody); + if (existingId != null) { + return reference(existingId); + } + if (files.containsKey(id)) { + throw new IllegalArgumentException("Density function '" + reference(id) + "' is already registered with a different body"); + } + files.put(id, body.deepCopy()); + idsByCanonicalBody.put(canonicalBody, id); + return reference(id); + } + + public String registerNamed(String id, JsonElement body) { + VanillaResourceIdentifier.requirePath(id, "density function id"); + String canonicalBody = canonical(body).toString(); + JsonElement existing = files.get(id); + if (existing != null && !canonical(existing).toString().equals(canonicalBody)) { + throw new IllegalArgumentException("Density function '" + reference(id) + "' is already registered with a different body"); + } + files.put(id, body.deepCopy()); + idsByCanonicalBody.putIfAbsent(canonicalBody, id); + return reference(id); + } + + public SortedMap files() { + return Collections.unmodifiableSortedMap(files); + } + + void replace(String id, JsonElement body) { + JsonElement previous = files.get(id); + if (previous == null) { + throw new IllegalArgumentException("Density function '" + reference(id) + "' is not registered"); + } + idsByCanonicalBody.remove(canonical(previous).toString(), id); + files.put(id, body.deepCopy()); + idsByCanonicalBody.putIfAbsent(canonical(body).toString(), id); + } + + private String reference(String id) { + return namespace + ":" + id; + } + + private static JsonElement canonical(JsonElement element) { + if (element.isJsonObject()) { + SortedMap members = new TreeMap<>(element.getAsJsonObject().asMap()); + JsonObject sorted = new JsonObject(); + for (Map.Entry member : members.entrySet()) { + sorted.add(member.getKey(), canonical(member.getValue())); + } + return sorted; + } + if (element.isJsonArray()) { + JsonArray source = element.getAsJsonArray(); + JsonArray sorted = new JsonArray(source.size()); + for (JsonElement item : source) { + sorted.add(canonical(item)); + } + return sorted; + } + return element; + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/emit/JsonNumbers.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/emit/JsonNumbers.java new file mode 100644 index 000000000..03f866136 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/emit/JsonNumbers.java @@ -0,0 +1,36 @@ +package art.arcane.iris.pack.datapack.export.emit; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.WorldgenExportTargets; +import art.arcane.iris.pack.datapack.export.lower.FieldDomains; +import com.google.gson.JsonPrimitive; + +import java.math.BigDecimal; + +public final class JsonNumbers { + private JsonNumbers() { + } + + public static JsonPrimitive constant(double value, DataVersion target) { + if (!Double.isFinite(value) || value < -FieldDomains.CONSTANT_LIMIT || value > FieldDomains.CONSTANT_LIMIT) { + throw new IllegalArgumentException("Density function constant " + value + " is outside [-1e6, 1e6]"); + } + double normalized = value == 0.0 ? 0.0 : value; + return switch (target) { + case V26_3 -> new JsonPrimitive(shortestDecimal((float) normalized)); + default -> throw WorldgenExportTargets.unsupported(target.getVersion()); + }; + } + + public static JsonPrimitive unbounded(double value) { + if (!Double.isFinite(value)) { + throw new IllegalArgumentException("Density function value must be finite, got " + value); + } + return new JsonPrimitive(value == 0.0 ? 0.0 : value); + } + + private static BigDecimal shortestDecimal(float value) { + BigDecimal decimal = new BigDecimal(Float.toString(value)).stripTrailingZeros(); + return decimal.scale() < 1 ? decimal.setScale(1) : decimal; + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/emit/NestingSplitter.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/emit/NestingSplitter.java new file mode 100644 index 000000000..b9ec1e27f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/emit/NestingSplitter.java @@ -0,0 +1,130 @@ +package art.arcane.iris.pack.datapack.export.emit; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; + +public final class NestingSplitter { + private NestingSplitter() { + } + + public static void split(FunctionTable table, int maxDepth) { + if (maxDepth < 1) { + throw new IllegalArgumentException("Nesting limit must be positive, got " + maxDepth); + } + for (String id : List.copyOf(table.files().keySet())) { + JsonElement body = table.files().get(id); + if (depth(body) <= maxDepth) { + continue; + } + JsonElement reduced = body.deepCopy(); + int part = 0; + while (true) { + Map depths = new IdentityHashMap<>(); + if (measure(reduced, depths) <= maxDepth) { + break; + } + Extraction extraction = deepestExtractable(reduced, depths, maxDepth); + if (extraction == null) { + throw new IllegalStateException("Density function '" + table.namespace() + ":" + id + "' nests deeper than " + + maxDepth + " levels with no density function node left to move into its own file"); + } + String partId; + do { + partId = id + "/part" + part++; + } while (table.files().containsKey(partId)); + extraction.replace(new JsonPrimitive(table.register(partId, extraction.subtree()))); + } + table.replace(id, reduced); + } + } + + public static int depth(JsonElement element) { + return measure(element, null); + } + + private static int measure(JsonElement element, Map depths) { + int depth = 0; + if (element.isJsonObject()) { + int children = 0; + for (Map.Entry member : element.getAsJsonObject().entrySet()) { + children = Math.max(children, measure(member.getValue(), depths)); + } + depth = children + 1; + } else if (element.isJsonArray()) { + int children = 0; + for (JsonElement item : element.getAsJsonArray()) { + children = Math.max(children, measure(item, depths)); + } + depth = children + 1; + } + if (depths != null && depth > 0) { + depths.put(element, depth); + } + return depth; + } + + private static Extraction deepestExtractable(JsonElement root, Map depths, int maxDepth) { + JsonElement current = root; + while (current.isJsonObject() || current.isJsonArray()) { + Extraction deepest = deepestChild(current, depths); + if (deepest == null) { + return null; + } + JsonElement child = deepest.subtree(); + if (isDensityFunction(child) && depths.get(child) <= maxDepth) { + return deepest; + } + current = child; + } + return null; + } + + private static Extraction deepestChild(JsonElement container, Map depths) { + Extraction deepest = null; + int deepestDepth = 0; + if (container.isJsonObject()) { + JsonObject object = container.getAsJsonObject(); + for (Map.Entry member : object.entrySet()) { + int depth = depths.getOrDefault(member.getValue(), 0); + if (depth > deepestDepth) { + deepestDepth = depth; + deepest = new Extraction(object, member.getKey(), -1, member.getValue()); + } + } + return deepest; + } + JsonArray array = container.getAsJsonArray(); + for (int index = 0; index < array.size(); index++) { + int depth = depths.getOrDefault(array.get(index), 0); + if (depth > deepestDepth) { + deepestDepth = depth; + deepest = new Extraction(array, null, index, array.get(index)); + } + } + return deepest; + } + + private static boolean isDensityFunction(JsonElement element) { + if (!element.isJsonObject()) { + return false; + } + JsonElement type = element.getAsJsonObject().get("type"); + return type != null && type.isJsonPrimitive() && type.getAsJsonPrimitive().isString(); + } + + private record Extraction(JsonElement parent, String key, int index, JsonElement subtree) { + private void replace(JsonElement replacement) { + if (parent.isJsonObject()) { + parent.getAsJsonObject().add(key, replacement); + } else { + parent.getAsJsonArray().set(index, replacement); + } + } + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/emit/UnusedResourcePruner.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/emit/UnusedResourcePruner.java new file mode 100644 index 000000000..3f872eb73 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/emit/UnusedResourcePruner.java @@ -0,0 +1,440 @@ +package art.arcane.iris.pack.datapack.export.emit; + +import art.arcane.iris.pack.datapack.export.ContentTrim; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.volmlib.util.nbt.io.BoundedNBTReader; +import art.arcane.volmlib.util.nbt.tag.CompoundTag; +import art.arcane.volmlib.util.nbt.tag.ListTag; +import art.arcane.volmlib.util.nbt.tag.StringTag; +import art.arcane.volmlib.util.nbt.tag.Tag; +import com.google.gson.JsonElement; +import com.google.gson.JsonParseException; +import com.google.gson.JsonParser; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.SortedMap; +import java.util.TreeMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.zip.GZIPInputStream; + +public final class UnusedResourcePruner { + private static final String DATA = "data/"; + private static final String VANILLA_NAMESPACE = "minecraft"; + private static final String JSON = ".json"; + private static final String MCMETA = ".mcmeta"; + private static final String NBT = ".nbt"; + private static final String PNG = ".png"; + private static final String TAG_PREFIX = "#"; + private static final String WORLDGEN = "worldgen/"; + private static final String WORLDGEN_TAGS = "tags/worldgen/"; + private static final String TAGS = "tags/"; + private static final String TEMPLATES = "structure"; + private static final String LOOT_TABLES = "loot_table"; + private static final String BLOCK_TAGS = "tags/block"; + private static final String STRUCTURE_SETS = "worldgen/structure_set"; + private static final String STRUCTURES = "worldgen/structure"; + private static final String BIOME_TAGS = "tags/worldgen/biome"; + private static final Set WORLDGEN_REGISTRIES = Set.of( + "biome", + "carver", + "configured_carver", + "feature", + "configured_feature", + "placed_feature", + "structure", + "structure_set", + "processor_list", + "template_pool", + "noise_settings", + "noise", + "density_function", + "material_rule", + "material_condition", + "world_preset", + "flat_level_generator_preset", + "multi_noise_biome_source_parameter_list", + "block_state_provider"); + private static final Set ROOT_FOLDERS = Set.of("worldgen/world_preset", "tags/worldgen/world_preset"); + private static final Pattern REFERENCE = Pattern.compile("#?[a-z0-9_.-]+:[a-z0-9_./-]+"); + private static final Pattern IDENTIFIER = Pattern.compile("[a-z0-9_.-]+:[a-z0-9_./-]+"); + private static final BoundedNBTReader.Limits TEMPLATE_LIMITS = new BoundedNBTReader.Limits(1L << 30, 512, 1 << 26, Integer.MAX_VALUE, 65_535); + private static final int GZIP_MAGIC_FIRST = 0x1f; + private static final int GZIP_MAGIC_SECOND = 0x8b; + private static final int INFLATE_BUFFER = 8192; + + private UnusedResourcePruner() { + } + + public static Pruned prune(SortedMap files) { + return new Reachability(Objects.requireNonNull(files)).prune(); + } + + private static Resource classify(String path) { + if (!path.startsWith(DATA)) { + return null; + } + int separator = path.indexOf('/', DATA.length()); + if (separator < 0) { + return null; + } + String namespace = path.substring(DATA.length(), separator); + String rest = path.substring(separator + 1); + String folder = folder(rest); + if (folder == null) { + return null; + } + String extension = folder.equals(TEMPLATES) ? NBT : JSON; + String id = namespace + ":" + rest.substring(folder.length() + 1, rest.length() - extension.length()); + if (!IDENTIFIER.matcher(id).matches()) { + return null; + } + return new Resource(path, namespace, folder, id, folder.startsWith(TAGS)); + } + + private static String folder(String rest) { + if (rest.startsWith(TEMPLATES + "/")) { + return rest.endsWith(NBT) ? TEMPLATES : null; + } + if (!rest.endsWith(JSON)) { + return null; + } + if (rest.startsWith(LOOT_TABLES + "/")) { + return LOOT_TABLES; + } + if (rest.startsWith(BLOCK_TAGS + "/")) { + return BLOCK_TAGS; + } + String prefix = rest.startsWith(WORLDGEN_TAGS) ? WORLDGEN_TAGS : rest.startsWith(WORLDGEN) ? WORLDGEN : null; + if (prefix == null) { + return null; + } + int separator = rest.indexOf('/', prefix.length()); + if (separator < 0 || !WORLDGEN_REGISTRIES.contains(rest.substring(prefix.length(), separator))) { + return null; + } + return rest.substring(0, separator); + } + + private static String normalize(String id) { + return id.indexOf(':') < 0 ? VANILLA_NAMESPACE + ":" + id : id; + } + + private static boolean string(JsonElement element) { + return element != null && element.isJsonPrimitive() && element.getAsJsonPrimitive().isString(); + } + + private static JsonElement member(JsonElement element, String key) { + return element.isJsonObject() ? element.getAsJsonObject().get(key) : null; + } + + private static JsonElement parse(byte[] bytes) { + return JsonParser.parseString(new String(bytes, StandardCharsets.UTF_8)); + } + + private static Tag readNbt(byte[] bytes) throws IOException { + boolean gzip = bytes.length > 1 && (bytes[0] & 0xff) == GZIP_MAGIC_FIRST && (bytes[1] & 0xff) == GZIP_MAGIC_SECOND; + return BoundedNBTReader.read(new ByteArrayInputStream(gzip ? inflate(bytes) : bytes), TEMPLATE_LIMITS).getTag(); + } + + private static byte[] inflate(byte[] bytes) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream((int) Math.min(TEMPLATE_LIMITS.maxBytes(), bytes.length * 4L)); + byte[] buffer = new byte[INFLATE_BUFFER]; + try (InputStream input = new GZIPInputStream(new ByteArrayInputStream(bytes))) { + int read; + while ((read = input.read(buffer)) >= 0) { + output.write(buffer, 0, read); + if (output.size() > TEMPLATE_LIMITS.maxBytes()) { + throw new IOException("Template inflates beyond " + TEMPLATE_LIMITS.maxBytes() + " bytes"); + } + } + } + return output.toByteArray(); + } + + private static long bytes(Map files) { + long total = 0L; + for (byte[] content : files.values()) { + total += content.length; + } + return total; + } + + public record Pruned(SortedMap files, ContentTrim trim, List diagnostics) { + public Pruned { + files = Collections.unmodifiableSortedMap(new TreeMap<>(files)); + Objects.requireNonNull(trim); + diagnostics = List.copyOf(diagnostics); + } + } + + private record Resource(String path, String namespace, String folder, String id, boolean tag) { + } + + private static final class Reachability { + private final SortedMap files; + private final Map trimmed = new HashMap<>(); + private final Map> byId = new HashMap<>(); + private final Map> tagsById = new HashMap<>(); + private final Set owned = new HashSet<>(); + private final Set reached = new HashSet<>(); + private final ArrayDeque pending = new ArrayDeque<>(); + private final SortedMap unreadable = new TreeMap<>(); + + private Reachability(SortedMap files) { + this.files = files; + } + + private Pruned prune() { + index(); + for (String path : files.keySet()) { + Resource resource = trimmed.get(path); + if (resource == null || root(resource)) { + reach(path); + } + } + while (!pending.isEmpty()) { + scan(pending.poll()); + } + SortedMap kept = new TreeMap<>(); + for (Map.Entry file : files.entrySet()) { + if (reached.contains(file.getKey())) { + kept.put(file.getKey(), file.getValue()); + } + } + ContentTrim trim = new ContentTrim(files.size(), bytes(files), kept.size(), bytes(kept)); + return new Pruned(kept, trim, diagnostics(trim)); + } + + private void index() { + for (String path : files.keySet()) { + Resource resource = classify(path); + if (resource == null) { + continue; + } + trimmed.put(path, resource); + byId.computeIfAbsent(resource.id(), ignored -> new ArrayList<>(1)).add(resource); + if (resource.tag()) { + tagsById.computeIfAbsent(resource.id(), ignored -> new ArrayList<>(1)).add(resource); + } else { + owned.add(resource.namespace()); + } + } + } + + private boolean root(Resource resource) { + if (external(resource) || ROOT_FOLDERS.contains(resource.folder())) { + return true; + } + return resource.folder().equals(STRUCTURE_SETS) && active(resource); + } + + private boolean external(Resource resource) { + return resource.namespace().equals(VANILLA_NAMESPACE) || !owned.contains(resource.namespace()); + } + + private boolean active(Resource set) { + JsonElement structures; + try { + structures = member(parse(files.get(set.path())), "structures"); + } catch (JsonParseException exception) { + return true; + } + if (structures == null || !structures.isJsonArray()) { + return true; + } + for (JsonElement entry : structures.getAsJsonArray()) { + JsonElement structure = member(entry, "structure"); + if (!string(structure) || enabled(structure.getAsString())) { + return true; + } + } + return false; + } + + private boolean enabled(String structureId) { + Resource structure = find(byId, normalize(structureId), STRUCTURES); + if (structure == null) { + return true; + } + JsonElement biomes; + try { + biomes = member(parse(files.get(structure.path())), "biomes"); + } catch (JsonParseException exception) { + return true; + } + if (biomes == null) { + return true; + } + if (biomes.isJsonArray()) { + return !biomes.getAsJsonArray().isEmpty(); + } + if (!string(biomes) || !biomes.getAsString().startsWith(TAG_PREFIX)) { + return true; + } + return !emptyTag(biomes.getAsString().substring(TAG_PREFIX.length()), new HashSet<>()); + } + + private boolean emptyTag(String tagId, Set visiting) { + String id = normalize(tagId); + if (!visiting.add(id)) { + return true; + } + Resource tag = find(tagsById, id, BIOME_TAGS); + if (tag == null || external(tag)) { + return false; + } + JsonElement values; + try { + values = member(parse(files.get(tag.path())), "values"); + } catch (JsonParseException exception) { + return false; + } + if (values == null || !values.isJsonArray()) { + return false; + } + for (JsonElement value : values.getAsJsonArray()) { + JsonElement entry = value.isJsonObject() ? member(value, "id") : value; + if (!string(entry) || !entry.getAsString().startsWith(TAG_PREFIX) + || !emptyTag(entry.getAsString().substring(TAG_PREFIX.length()), visiting)) { + return false; + } + } + return true; + } + + private Resource find(Map> index, String id, String folder) { + for (Resource resource : index.getOrDefault(id, List.of())) { + if (resource.folder().equals(folder)) { + return resource; + } + } + return null; + } + + private void reach(String path) { + if (reached.add(path)) { + pending.add(path); + } + } + + private void scan(String path) { + if (path.endsWith(PNG)) { + return; + } + byte[] bytes = files.get(path); + if (path.endsWith(NBT)) { + scanNbt(path, bytes); + } else if (path.endsWith(JSON) || path.endsWith(MCMETA)) { + scanJson(path, bytes); + } else { + scanText(new String(bytes, StandardCharsets.ISO_8859_1)); + } + } + + private void scanJson(String path, byte[] bytes) { + JsonElement json; + try { + json = parse(bytes); + } catch (JsonParseException exception) { + unreadable.put(path, "JSON (" + exception.getMessage() + ")"); + scanText(new String(bytes, StandardCharsets.UTF_8)); + return; + } + scanElement(json); + } + + private void scanElement(JsonElement element) { + if (element.isJsonObject()) { + for (Map.Entry member : element.getAsJsonObject().entrySet()) { + scanText(member.getKey()); + scanElement(member.getValue()); + } + return; + } + if (element.isJsonArray()) { + for (JsonElement item : element.getAsJsonArray()) { + scanElement(item); + } + return; + } + if (string(element)) { + scanText(element.getAsString()); + } + } + + private void scanNbt(String path, byte[] bytes) { + Tag root; + try { + root = readNbt(bytes); + } catch (IOException exception) { + unreadable.put(path, "NBT (" + exception.getMessage() + ")"); + scanText(new String(bytes, StandardCharsets.ISO_8859_1)); + return; + } + scanTag(root); + } + + private void scanTag(Tag tag) { + if (tag instanceof StringTag text) { + scanText(text.getValue()); + return; + } + if (tag instanceof CompoundTag compound) { + for (Tag value : compound.values()) { + scanTag(value); + } + return; + } + if (tag instanceof ListTag list) { + for (Tag item : list) { + scanTag(item); + } + } + } + + private void scanText(String text) { + if (text.indexOf(':') < 0) { + return; + } + Matcher matcher = REFERENCE.matcher(text); + while (matcher.find()) { + String token = matcher.group(); + List targets = token.startsWith(TAG_PREFIX) ? tagsById.get(token.substring(TAG_PREFIX.length())) : byId.get(token); + if (targets == null) { + continue; + } + for (Resource target : targets) { + reach(target.path()); + } + } + } + + private List diagnostics(ContentTrim trim) { + List diagnostics = new ArrayList<>(unreadable.size() + 1); + for (Map.Entry file : unreadable.entrySet()) { + diagnostics.add(new ExportDiagnostic(ExportDiagnostic.Severity.WARNING, ExportLimit.TRIMMED_CONTENT, file.getKey(), + "Could not be read as " + file.getValue() + "; every identifier in its raw bytes is kept")); + } + diagnostics.add(new ExportDiagnostic(ExportDiagnostic.Severity.INFO, ExportLimit.TRIMMED_CONTENT, "", String.format(Locale.ROOT, + "Removed %d of %d files (%d of %d bytes) that the exported world never references", + trim.filesRemoved(), trim.filesBefore(), trim.bytesRemoved(), trim.bytesBefore()))); + return diagnostics; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/emit/WorldgenJsonWriter.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/emit/WorldgenJsonWriter.java new file mode 100644 index 000000000..36bede5ec --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/emit/WorldgenJsonWriter.java @@ -0,0 +1,18 @@ +package art.arcane.iris.pack.datapack.export.emit; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.WorldgenExportTargets; +import art.arcane.iris.pack.datapack.export.ir.ExportIr; + +import java.util.SortedMap; + +public interface WorldgenJsonWriter { + SortedMap write(ExportIr ir); + + static WorldgenJsonWriter forTarget(DataVersion target) { + return switch (target) { + case V26_3 -> new Format26_3Writer(); + default -> throw WorldgenExportTargets.unsupported(target.getVersion()); + }; + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/AquiferDef.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/AquiferDef.java new file mode 100644 index 000000000..3c96fe7a2 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/AquiferDef.java @@ -0,0 +1,23 @@ +package art.arcane.iris.pack.datapack.export.ir; + +import java.util.Objects; + +public record AquiferDef(Field barrier, Field floodedness, Field spread, Field lava, Field exclusion, Field surfaceLevel) { + private static final Field VANILLA_BARRIER = new Field.Noise("minecraft:aquifer_barrier", 1.0, 0.5, null, null, null); + private static final Field VANILLA_SPREAD = new Field.Noise("minecraft:aquifer_fluid_level_spread", 1.0, 0.7142857142857143, null, null, null); + private static final Field VANILLA_LAVA = new Field.Noise("minecraft:aquifer_lava", 1.0, 1.0, null, null, null); + private static final Field DISABLED = new Field.Const(-1.0); + + public AquiferDef { + Objects.requireNonNull(barrier); + Objects.requireNonNull(floodedness); + Objects.requireNonNull(spread); + Objects.requireNonNull(lava); + Objects.requireNonNull(exclusion); + Objects.requireNonNull(surfaceLevel); + } + + public static AquiferDef dry(Field surfaceLevel) { + return new AquiferDef(VANILLA_BARRIER, DISABLED, VANILLA_SPREAD, VANILLA_LAVA, DISABLED, surfaceLevel); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/Axis.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/Axis.java new file mode 100644 index 000000000..9c3268f26 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/Axis.java @@ -0,0 +1,7 @@ +package art.arcane.iris.pack.datapack.export.ir; + +public enum Axis { + X, + Y, + Z +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/BinaryResource.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/BinaryResource.java new file mode 100644 index 000000000..aa682b18a --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/BinaryResource.java @@ -0,0 +1,32 @@ +package art.arcane.iris.pack.datapack.export.ir; + +import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; + +import java.util.Arrays; + +public record BinaryResource(String path, byte[] bytes) { + public BinaryResource { + VanillaResourceIdentifier.requirePath(path, "binary resource path"); + bytes = bytes.clone(); + } + + @Override + public byte[] bytes() { + return bytes.clone(); + } + + @Override + public boolean equals(Object other) { + return other instanceof BinaryResource resource && path.equals(resource.path) && Arrays.equals(bytes, resource.bytes); + } + + @Override + public int hashCode() { + return 31 * path.hashCode() + Arrays.hashCode(bytes); + } + + @Override + public String toString() { + return "BinaryResource[path=" + path + ", bytes=" + bytes.length + "]"; + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/BiomeDef.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/BiomeDef.java new file mode 100644 index 000000000..1a31bc469 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/BiomeDef.java @@ -0,0 +1,11 @@ +package art.arcane.iris.pack.datapack.export.ir; + +import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; +import com.google.gson.JsonObject; + +public record BiomeDef(String id, JsonObject json) { + public BiomeDef { + VanillaResourceIdentifier.requirePath(id, "biome id"); + json = json.deepCopy(); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/ClimateLayout.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/ClimateLayout.java new file mode 100644 index 000000000..79e1d6005 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/ClimateLayout.java @@ -0,0 +1,30 @@ +package art.arcane.iris.pack.datapack.export.ir; + +import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; + +import java.util.List; +import java.util.Objects; + +public record ClimateLayout(List entries) { + private static final double CLIMATE_LIMIT = 2.0; + + public ClimateLayout { + entries = List.copyOf(entries); + } + + public record ClimateEntry(String biomeId, ClimateAxis axis, double min, double max) { + public ClimateEntry { + VanillaResourceIdentifier.requireIdentifier(biomeId, "climate biome id"); + Objects.requireNonNull(axis); + if (!(min >= -CLIMATE_LIMIT && min <= max && max <= CLIMATE_LIMIT)) { + throw new IllegalArgumentException("Climate entry for '" + biomeId + "' must satisfy -2 <= min <= max <= 2, got [" + min + ", " + max + "]"); + } + } + } + + public enum ClimateAxis { + TEMPERATURE, + HUMIDITY, + DEPTH + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/CostClass.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/CostClass.java new file mode 100644 index 000000000..c893803fe --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/CostClass.java @@ -0,0 +1,7 @@ +package art.arcane.iris.pack.datapack.export.ir; + +public enum CostClass { + CHEAP, + NOISE, + EXPENSIVE +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/DimensionDef.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/DimensionDef.java new file mode 100644 index 000000000..45db7e375 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/DimensionDef.java @@ -0,0 +1,78 @@ +package art.arcane.iris.pack.datapack.export.ir; + +import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; +import com.google.gson.JsonObject; + +import java.util.List; + +public record DimensionDef( + String name, + int minY, + int height, + int logicalHeight, + int fluidHeight, + String defaultBlock, + String defaultFluid, + JsonObject dimensionType +) { + private static final int SECTION = 16; + private static final int LOWEST_Y = -2032; + private static final int HIGHEST_TOP = 2032; + private static final int MAX_HEIGHT = 4064; + + public DimensionDef { + VanillaResourceIdentifier.requirePath(name, "dimension name"); + if (minY % SECTION != 0 || minY < LOWEST_Y) { + throw new IllegalArgumentException("Dimension '" + name + "' min_y must be a multiple of 16 no lower than -2032, got " + minY); + } + if (height % SECTION != 0 || height < SECTION || height > MAX_HEIGHT) { + throw new IllegalArgumentException("Dimension '" + name + "' height must be a multiple of 16 within [16, 4064], got " + height); + } + if (minY + height > HIGHEST_TOP) { + throw new IllegalArgumentException("Dimension '" + name + "' min_y + height must be at most 2032, got " + (minY + height)); + } + if (logicalHeight < 0 || logicalHeight > height) { + throw new IllegalArgumentException("Dimension '" + name + "' logical height must be within [0, " + height + "], got " + logicalHeight); + } + VanillaResourceIdentifier.requireIdentifier(defaultBlock, "default block"); + VanillaResourceIdentifier.requireIdentifier(defaultFluid, "default fluid"); + dimensionType = dimensionType.deepCopy(); + } + + public static DimensionDef aligned( + String name, + int minY, + int height, + int logicalHeight, + int fluidHeight, + String defaultBlock, + String defaultFluid, + JsonObject dimensionType, + List diagnostics + ) { + int alignedMinY = Math.clamp((long) Math.floorDiv(minY, SECTION) * SECTION, LOWEST_Y, HIGHEST_TOP - SECTION); + long requestedTop = (long) minY + Math.max(height, 0); + long alignedTop = Math.min(Math.ceilDiv(requestedTop, SECTION) * SECTION, HIGHEST_TOP); + int alignedHeight = Math.clamp(alignedTop - alignedMinY, SECTION, Math.min(MAX_HEIGHT, HIGHEST_TOP - alignedMinY)); + int alignedLogicalHeight = logicalHeight >= height ? alignedHeight : Math.clamp(logicalHeight, 0, alignedHeight); + if (alignedMinY != minY || alignedHeight != height || alignedLogicalHeight != logicalHeight) { + diagnostics.add(new ExportDiagnostic( + ExportDiagnostic.Severity.WARNING, + ExportLimit.DIMENSION_HEIGHT, + name, + "Dimension min_y " + minY + " height " + height + " logical height " + logicalHeight + + " exports as min_y " + alignedMinY + " height " + alignedHeight + " logical height " + alignedLogicalHeight + + "; min_y and height are multiples of 16, height is at most 4064 and min_y + height is at most 2032" + )); + } + return new DimensionDef(name, alignedMinY, alignedHeight, alignedLogicalHeight, fluidHeight, defaultBlock, defaultFluid, dimensionType); + } + + public static int seaLevel(int fluidHeight) { + return fluidHeight + 1; + } + + public int seaLevel() { + return seaLevel(fluidHeight); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/ExportDiagnostic.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/ExportDiagnostic.java new file mode 100644 index 000000000..8a22bbd27 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/ExportDiagnostic.java @@ -0,0 +1,18 @@ +package art.arcane.iris.pack.datapack.export.ir; + +import java.util.Objects; + +public record ExportDiagnostic(Severity severity, ExportLimit limit, String resource, String message) { + public ExportDiagnostic { + Objects.requireNonNull(severity); + Objects.requireNonNull(limit); + resource = resource == null ? "" : resource; + Objects.requireNonNull(message); + } + + public enum Severity { + INFO, + WARNING, + ERROR + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/ExportIr.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/ExportIr.java new file mode 100644 index 000000000..8e319975e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/ExportIr.java @@ -0,0 +1,71 @@ +package art.arcane.iris.pack.datapack.export.ir; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; +import art.arcane.iris.pack.datapack.export.WorldgenExportMode; +import art.arcane.iris.pack.datapack.export.WorldgenExportTargets; + +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +public record ExportIr( + String namespace, + DataVersion target, + WorldgenExportMode mode, + DimensionDef dimension, + List noises, + RouterDef router, + ClimateLayout climate, + List biomes, + MaterialProgram material, + List diagnostics, + List jsonResources, + List binaryResources +) { + public ExportIr { + VanillaResourceIdentifier.requireNamespace(namespace, "export namespace"); + Objects.requireNonNull(target); + if (!WorldgenExportTargets.SUPPORTED.contains(target)) { + throw WorldgenExportTargets.unsupported(target.getVersion()); + } + Objects.requireNonNull(mode); + Objects.requireNonNull(dimension); + noises = List.copyOf(noises); + Objects.requireNonNull(router); + Objects.requireNonNull(climate); + biomes = List.copyOf(biomes); + Objects.requireNonNull(material); + diagnostics = List.copyOf(diagnostics); + jsonResources = List.copyOf(jsonResources); + binaryResources = List.copyOf(binaryResources); + Set noiseIds = new HashSet<>(noises.size()); + for (NoiseDef noise : noises) { + requireUnique(noiseIds, noise.id(), "noise id"); + } + Set biomeIds = new HashSet<>(biomes.size()); + for (BiomeDef biome : biomes) { + requireUnique(biomeIds, biome.id(), "biome id"); + } + String localPrefix = namespace + ":"; + for (ClimateLayout.ClimateEntry entry : climate.entries()) { + if (entry.biomeId().startsWith(localPrefix) && !biomeIds.contains(entry.biomeId().substring(localPrefix.length()))) { + throw new IllegalArgumentException("Climate entry references undefined biome '" + entry.biomeId() + "'"); + } + } + Set resourcePaths = new HashSet<>(jsonResources.size() + binaryResources.size()); + for (JsonResource resource : jsonResources) { + requireUnique(resourcePaths, resource.path(), "resource path"); + } + for (BinaryResource resource : binaryResources) { + requireUnique(resourcePaths, resource.path(), "resource path"); + } + } + + private static void requireUnique(Set seen, String value, String role) { + if (!seen.add(value)) { + throw new IllegalArgumentException("Duplicate " + role + " '" + value + "'"); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/ExportLimit.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/ExportLimit.java new file mode 100644 index 000000000..67add04c6 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/ExportLimit.java @@ -0,0 +1,47 @@ +package art.arcane.iris.pack.datapack.export.ir; + +public enum ExportLimit { + IMAGE_MAP, + DIMENSION_HEIGHT, + NOISE_STYLE, + LAVA_HEIGHT, + BIOME_VARIANT, + SHORE_WIDTH, + VANILLA_BIOME_DATA, + FLOATING_ISLAND, + BLOCK_STATE, + LAYER_THICKNESS, + SLOPE, + SEA_LAYER, + WALL_PALETTE, + DECORATOR_STYLE, + CAVE_DECORATOR, + SCALE_STACK, + DEPOSIT_SCOPE, + DEPOSIT_SIZE, + TILE_ENTITY, + OBJECT_VARIANT, + WIDE_OBJECT, + OBJECT_VACUUM, + STRUCTURE_AVOIDANCE, + CUSTOM_ENTITY, + CUSTOM_BLOCK, + LOOT_RARITY, + CUSTOM_ITEM, + OBJECT_BOUNDS, + RIVER_DRAINAGE, + ELEVATED_WATER, + DATAPACK_VERSION, + DATAPACK_COLLISION, + DATAPACK_FILE, + STRUCTURE_ADJUSTMENT, + STRUCTURE_PLACEMENT, + VANILLA_STRUCTURE_DATA, + JIGSAW_STRUCTURE, + TRIMMED_CONTENT, + DEEP_CAVE_LAYERS, + GENERATION_COST, + SUBTERRAIN_BIOME, + SUBTERRAIN_VARIANT, + SUBTERRAIN_TERRAIN +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/Field.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/Field.java new file mode 100644 index 000000000..e255c13ca --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/Field.java @@ -0,0 +1,255 @@ +package art.arcane.iris.pack.datapack.export.ir; + +import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; + +import java.util.EnumSet; +import java.util.List; +import java.util.Objects; + +public sealed interface Field { + default EnumSet axes() { + return FieldTraits.axes(this); + } + + default ValueRange range() { + return FieldTraits.range(this); + } + + default CostClass cost() { + return FieldTraits.cost(this); + } + + private static void requireFinite(double value, String role) { + if (!Double.isFinite(value)) { + throw new IllegalArgumentException(role + " must be finite, got " + value); + } + } + + private static void requireOrdered(double min, double max, String role) { + requireFinite(min, role + " minimum"); + requireFinite(max, role + " maximum"); + if (min > max) { + throw new IllegalArgumentException(role + " minimum " + min + " exceeds maximum " + max); + } + } + + record Const(double value) implements Field { + public Const { + requireFinite(value, "Constant"); + } + } + + record Coord(Axis axis, Tiling tiling, double from, double to, double fromValue, double toValue) implements Field { + public Coord { + Objects.requireNonNull(axis); + Objects.requireNonNull(tiling); + requireFinite(from, "Coordinate from"); + requireFinite(to, "Coordinate to"); + requireFinite(fromValue, "Coordinate from value"); + requireFinite(toValue, "Coordinate to value"); + if (from == to) { + throw new IllegalArgumentException("Coordinate gradient needs distinct from and to, got " + from); + } + } + } + + record Noise(String noiseId, double xzScale, double yScale, Field shiftX, Field shiftY, Field shiftZ) implements Field { + public Noise { + VanillaResourceIdentifier.requireIdentifier(noiseId, "noise id"); + requireFinite(xzScale, "Noise xz scale"); + requireFinite(yScale, "Noise y scale"); + } + } + + record Binary(BinaryOp op, Field left, Field right) implements Field { + public Binary { + Objects.requireNonNull(op); + Objects.requireNonNull(left); + Objects.requireNonNull(right); + } + } + + record Unary(UnaryOp op, Field input) implements Field { + public Unary { + Objects.requireNonNull(op); + Objects.requireNonNull(input); + } + } + + record Clamp(Field input, double min, double max) implements Field { + public Clamp { + Objects.requireNonNull(input); + requireOrdered(min, max, "Clamp"); + } + } + + record Lerp(Field alpha, Field first, Field second) implements Field { + public Lerp { + Objects.requireNonNull(alpha); + Objects.requireNonNull(first); + Objects.requireNonNull(second); + } + } + + record Round(RoundMode mode, Field input, Field multiple) implements Field { + public Round { + Objects.requireNonNull(mode); + Objects.requireNonNull(input); + Objects.requireNonNull(multiple); + } + } + + record Curve(Field input, List knots) implements Field { + public Curve { + Objects.requireNonNull(input); + knots = List.copyOf(knots); + if (knots.isEmpty()) { + throw new IllegalArgumentException("Curve needs at least one knot"); + } + for (int index = 1; index < knots.size(); index++) { + if (knots.get(index).location() <= knots.get(index - 1).location()) { + throw new IllegalArgumentException("Curve knot locations must strictly increase, got " + knots.get(index - 1).location() + " then " + knots.get(index).location()); + } + } + } + } + + record Knot(double location, Field value, double derivative) { + public Knot { + requireFinite(location, "Knot location"); + requireFinite(derivative, "Knot derivative"); + Objects.requireNonNull(value); + if (!(value instanceof Const) && !(value instanceof Curve)) { + throw new IllegalArgumentException("Curve knot value must be a constant or a nested curve, got " + value.getClass().getSimpleName()); + } + } + } + + record Partition(Field input, List thresholds, List leaves) implements Field { + public Partition { + Objects.requireNonNull(input); + thresholds = List.copyOf(thresholds); + leaves = List.copyOf(leaves); + if (thresholds.isEmpty()) { + throw new IllegalArgumentException("Partition needs at least one threshold"); + } + if (leaves.size() != thresholds.size() + 1) { + throw new IllegalArgumentException("Partition needs " + (thresholds.size() + 1) + " leaves for " + thresholds.size() + " thresholds, got " + leaves.size()); + } + for (int index = 0; index < thresholds.size(); index++) { + requireFinite(thresholds.get(index), "Partition threshold"); + if (index > 0 && thresholds.get(index) < thresholds.get(index - 1)) { + throw new IllegalArgumentException("Partition thresholds must not decrease, got " + thresholds.get(index - 1) + " then " + thresholds.get(index)); + } + } + for (Field leaf : leaves) { + if (leaf.cost() != CostClass.CHEAP) { + throw new IllegalArgumentException("Partition leaves must be cheap, got a " + leaf.cost() + " " + leaf.getClass().getSimpleName() + " leaf"); + } + } + } + } + + record Mux(Field index, List cases) implements Field { + public Mux { + Objects.requireNonNull(index); + cases = List.copyOf(cases); + if (cases.isEmpty()) { + throw new IllegalArgumentException("Mux needs at least one case"); + } + } + } + + record Gate(Field condition, Field expr) implements Field { + public Gate { + Objects.requireNonNull(condition); + Objects.requireNonNull(expr); + } + } + + record RangeIf(Field input, double min, double max, Field inRange, Field outOfRange) implements Field { + public RangeIf { + Objects.requireNonNull(input); + requireOrdered(min, max, "Range choice"); + Objects.requireNonNull(inRange); + Objects.requireNonNull(outOfRange); + } + } + + record Grid(Field expr, int cellXZ, int cellY) implements Field { + public Grid { + Objects.requireNonNull(expr); + if (cellXZ <= 0 || cellY <= 0) { + throw new IllegalArgumentException("Grid cells must be positive, got " + cellXZ + "x" + cellY); + } + } + } + + record Shared(String name, Field expr) implements Field { + public Shared { + VanillaResourceIdentifier.requirePath(name, "shared field name"); + Objects.requireNonNull(expr); + } + } + + record Slice(Field expr, Axis axis, int value) implements Field { + public Slice { + Objects.requireNonNull(expr); + Objects.requireNonNull(axis); + } + } + + record TopSurface(Field density, int upper, int lower, int step) implements Field { + public TopSurface { + Objects.requireNonNull(density); + if (lower > upper) { + throw new IllegalArgumentException("Top surface lower bound " + lower + " exceeds upper bound " + upper); + } + if (step <= 0) { + throw new IllegalArgumentException("Top surface step must be positive, got " + step); + } + } + } + + record Ref(String id) implements Field { + public Ref { + VanillaResourceIdentifier.requireIdentifier(id, "density function reference"); + } + } + + enum Tiling { + CLAMP, + REPEAT, + MIRRORED_REPEAT + } + + enum RoundMode { + ROUND, + FLOOR, + CEIL, + TRUNCATE + } + + enum BinaryOp { + ADD, + SUB, + MUL, + DIV, + MIN, + MAX, + POW + } + + enum UnaryOp { + NEG, + ABS, + SQUARE, + CUBE, + SQRT, + LOG, + SIGN, + SQUEEZE, + HALF_NEG, + QUARTER_NEG + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/FieldEvaluator.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/FieldEvaluator.java new file mode 100644 index 000000000..e91dba28e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/FieldEvaluator.java @@ -0,0 +1,388 @@ +package art.arcane.iris.pack.datapack.export.ir; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.WorldgenExportTargets; +import art.arcane.iris.pack.datapack.export.compile.noise.VanillaNoiseModel; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; + +import java.util.EnumSet; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public final class FieldEvaluator { + private static final int MODEL_CACHE_SIZE = 4096; + private static final Cache MODELS = Caffeine.newBuilder().maximumSize(MODEL_CACHE_SIZE).build(); + + private final Map noises; + private final long worldSeed; + private final DataVersion target; + + private FieldEvaluator(Map noises, long worldSeed, DataVersion target) { + this.noises = noises; + this.worldSeed = worldSeed; + this.target = target; + if (!WorldgenExportTargets.SUPPORTED.contains(target)) { + throw WorldgenExportTargets.unsupported(target.getVersion()); + } + } + + public static double sample(Field field, Map noises, long worldSeed, DataVersion target, int x, int y, int z) { + Objects.requireNonNull(field, "field"); + Objects.requireNonNull(noises, "noises"); + Objects.requireNonNull(target, "target"); + return new FieldEvaluator(noises, worldSeed, target).evaluate(field, new Point(x, y, z)); + } + + private double evaluate(Field field, Point point) { + return switch (field) { + case Field.Const constant -> narrow(constant.value()); + case Field.Coord coord -> coord(coord, point); + case Field.Noise noise -> noise(noise, point); + case Field.Binary binary -> binary(binary, point); + case Field.Unary unary -> narrow(unary(unary.op(), evaluate(unary.input(), point))); + case Field.Clamp clamp -> clamp(evaluate(clamp.input(), point), narrow(clamp.min()), narrow(clamp.max())); + case Field.Lerp lerp -> lerp(lerp, point); + case Field.Round round -> round(round, point); + case Field.Curve curve -> spline(curve, point); + case Field.Partition partition -> evaluate(partition.leaves().get(intervalIndex(evaluate(partition.input(), point), partition.thresholds())), point); + case Field.Mux mux -> evaluate(mux.cases().get(caseIndex(evaluate(mux.index(), point), mux.cases().size())), point); + case Field.Gate gate -> evaluate(gate.condition(), point) > 0.0 ? evaluate(gate.expr(), point) : 0.0; + case Field.RangeIf rangeIf -> rangeIf(rangeIf, point); + case Field.Grid grid -> grid(grid, point); + case Field.Shared shared -> shared(shared, point); + case Field.Slice slice -> evaluate(slice.expr(), point.pinned(slice.axis(), slice.value())); + case Field.TopSurface topSurface -> topSurface(topSurface, point); + case Field.Ref ref -> throw new IllegalArgumentException("Field evaluation cannot resolve density function reference '" + ref.id() + "'"); + }; + } + + private double coord(Field.Coord coord, Point point) { + int from = blockCoordinate(coord.from(), "from"); + int to = blockCoordinate(coord.to(), "to"); + int range = to - from; + double fromValue = narrow(coord.fromValue()); + double factor = narrow(narrow(narrow(coord.toValue()) - fromValue) / range); + int coordinate = point.get(coord.axis()); + int offset = switch (coord.tiling()) { + case CLAMP -> Math.clamp(coordinate, Math.min(from, to), Math.max(from, to)) - from; + case REPEAT -> Math.floorMod(coordinate - from, range); + case MIRRORED_REPEAT -> mirrored(coordinate - from, range); + }; + return narrow(fromValue + narrow(offset * factor)); + } + + private double noise(Field.Noise noise, Point point) { + NoiseDef definition = noises.get(noise.noiseId()); + if (definition == null) { + throw new IllegalArgumentException("Noise '" + noise.noiseId() + "' has no definition among " + noises.keySet()); + } + VanillaNoiseModel model = MODELS.get(new ModelKey(noise.noiseId(), definition, worldSeed, target), + key -> VanillaNoiseModel.create(key.noiseId(), key.definition().firstOctave(), key.definition().amplitudes(), key.worldSeed(), key.target())); + double x = point.x() * noise.xzScale() + shift(noise.shiftX(), point); + double y = point.y() * noise.yScale() + shift(noise.shiftY(), point); + double z = point.z() * noise.xzScale() + shift(noise.shiftZ(), point); + return narrow(model.sample(x, y, z)); + } + + private double shift(Field shift, Point point) { + return shift == null ? 0.0 : evaluate(shift, point); + } + + private double binary(Field.Binary binary, Point point) { + double left = evaluate(binary.left(), point); + if (binary.op() == Field.BinaryOp.DIV && left == 0.0) { + return 0.0; + } + double right = evaluate(binary.right(), point); + return narrow(switch (binary.op()) { + case ADD -> left + right; + case SUB -> left - right; + case MUL -> left * right; + case DIV -> left / right; + case MIN -> Math.min(left, right); + case MAX -> Math.max(left, right); + case POW -> Math.pow(left, right); + }); + } + + private double lerp(Field.Lerp lerp, Point point) { + double alpha = evaluate(lerp.alpha(), point); + if (alpha == 0.0) { + return evaluate(lerp.first(), point); + } + if (alpha == 1.0) { + return evaluate(lerp.second(), point); + } + double first = evaluate(lerp.first(), point); + double second = evaluate(lerp.second(), point); + return narrow(first + narrow(alpha * narrow(second - first))); + } + + private double round(Field.Round round, Point point) { + double input = evaluate(round.input(), point); + double multiple = evaluate(round.multiple(), point); + if (multiple == 0.0) { + return input; + } + return narrow(roundToInteger(round.mode(), narrow(input / multiple)) * multiple); + } + + private double roundToInteger(Field.RoundMode mode, double value) { + return switch (mode) { + case FLOOR -> Math.floor(value); + case ROUND -> Math.round((float) value); + case CEIL -> Math.ceil(value); + case TRUNCATE -> value > 0.0 ? Math.floor(value) : Math.ceil(value); + }; + } + + private float spline(Field.Curve curve, Point point) { + float coordinate = (float) evaluate(curve.input(), point); + List knots = curve.knots(); + int start = intervalStart(knots, coordinate); + int last = knots.size() - 1; + if (start < 0) { + return extend(coordinate, knots.getFirst(), point); + } + if (start == last) { + return extend(coordinate, knots.get(last), point); + } + Field.Knot lower = knots.get(start); + Field.Knot upper = knots.get(start + 1); + float lowerLocation = (float) lower.location(); + float upperLocation = (float) upper.location(); + float t = (coordinate - lowerLocation) / (upperLocation - lowerLocation); + float lowerValue = knotValue(lower, point); + float upperValue = knotValue(upper, point); + float width = upperLocation - lowerLocation; + float rise = upperValue - lowerValue; + float lowerTangent = (float) lower.derivative() * width - rise; + float upperTangent = -(float) upper.derivative() * width + rise; + return lerp(t, lowerValue, upperValue) + t * (1.0F - t) * lerp(t, lowerTangent, upperTangent); + } + + private float extend(float coordinate, Field.Knot knot, Point point) { + float value = knotValue(knot, point); + float derivative = (float) knot.derivative(); + if (derivative == 0.0F) { + return value; + } + return value + derivative * (coordinate - (float) knot.location()); + } + + private float knotValue(Field.Knot knot, Point point) { + return switch (knot.value()) { + case Field.Const constant -> (float) constant.value(); + case Field.Curve nested -> spline(nested, point); + default -> throw new IllegalStateException("Curve knot value must be a constant or a nested curve"); + }; + } + + private int intervalIndex(double input, List thresholds) { + for (int index = 0; index < thresholds.size(); index++) { + if (input < narrow(thresholds.get(index))) { + return index; + } + } + return thresholds.size(); + } + + private double rangeIf(Field.RangeIf rangeIf, Point point) { + double input = evaluate(rangeIf.input(), point); + boolean inside = input >= narrow(rangeIf.min()) && input < narrow(rangeIf.max()); + return evaluate(inside ? rangeIf.inRange() : rangeIf.outOfRange(), point); + } + + private double grid(Field.Grid grid, Point point) { + EnumSet axes = grid.expr().axes(); + int cellXz = grid.cellXZ(); + int cellY = grid.cellY(); + int offsetX = axes.contains(Axis.X) ? Math.floorMod(point.x(), cellXz) : 0; + int offsetY = axes.contains(Axis.Y) ? Math.floorMod(point.y(), cellY) : 0; + int offsetZ = axes.contains(Axis.Z) ? Math.floorMod(point.z(), cellXz) : 0; + if (offsetX == 0 && offsetY == 0 && offsetZ == 0) { + return evaluate(grid.expr(), point); + } + int baseX = point.x() - offsetX; + int baseY = point.y() - offsetY; + int baseZ = point.z() - offsetZ; + int stepX = offsetX == 0 ? 0 : cellXz; + int stepY = offsetY == 0 ? 0 : cellY; + int stepZ = offsetZ == 0 ? 0 : cellXz; + int collapsed = (stepX == 0 ? 1 : 0) | (stepY == 0 ? 2 : 0) | (stepZ == 0 ? 4 : 0); + double[] corners = new double[8]; + for (int index = 0; index < corners.length; index++) { + int source = index & ~collapsed; + if (source != index) { + corners[index] = corners[source]; + continue; + } + int cornerX = baseX + ((index & 1) == 0 ? 0 : stepX); + int cornerY = baseY + ((index & 2) == 0 ? 0 : stepY); + int cornerZ = baseZ + ((index & 4) == 0 ? 0 : stepZ); + corners[index] = evaluate(grid.expr(), new Point(cornerX, cornerY, cornerZ)); + } + double fractionX = narrow(offsetX / (double) cellXz); + double fractionY = narrow(offsetY / (double) cellY); + double fractionZ = narrow(offsetZ / (double) cellXz); + double bottom = lerp2(fractionX, fractionY, corners[0], corners[1], corners[2], corners[3]); + double top = lerp2(fractionX, fractionY, corners[4], corners[5], corners[6], corners[7]); + return lerpNarrow(fractionZ, bottom, top); + } + + private double lerp2(double fractionX, double fractionY, double lowLow, double highLow, double lowHigh, double highHigh) { + return lerpNarrow(fractionY, lerpNarrow(fractionX, lowLow, highLow), lerpNarrow(fractionX, lowHigh, highHigh)); + } + + private double lerpNarrow(double delta, double start, double end) { + return narrow(start + narrow(delta * narrow(end - start))); + } + + private double shared(Field.Shared shared, Point point) { + Map memo = point.memo(); + Double cached = memo.get(shared); + if (cached != null) { + return cached; + } + double value = evaluate(shared.expr(), point); + memo.put(shared, value); + return value; + } + + private double topSurface(Field.TopSurface topSurface, Point point) { + int step = topSurface.step(); + int lower = topSurface.lower(); + int top = (int) Math.floor((float) topSurface.upper() / (float) step) * step; + if (top <= lower) { + return lower; + } + for (int y = top; y >= lower; y -= step) { + if (evaluate(topSurface.density(), point.pinned(Axis.Y, y)) > 0.0) { + return y; + } + } + return lower; + } + + private static double narrow(double value) { + return (float) value; + } + + private static double unary(Field.UnaryOp op, double value) { + return switch (op) { + case NEG -> -value; + case ABS -> Math.abs(value); + case SQUARE -> value * value; + case CUBE -> value * value * value; + case SQRT -> Math.sqrt(value); + case LOG -> Math.log(value); + case SIGN -> Math.signum(value); + case SQUEEZE -> squeeze(value); + case HALF_NEG -> value > 0.0 ? value : value * 0.5; + case QUARTER_NEG -> value > 0.0 ? value : value * 0.25; + }; + } + + private static double squeeze(double value) { + double clamped = clamp(value, -1.0, 1.0); + return clamped / 2.0 - clamped * clamped * clamped / 24.0; + } + + private static double clamp(double value, double min, double max) { + return value < min ? min : Math.min(value, max); + } + + private static int mirrored(int offset, int range) { + int period = Math.floorDiv(offset, range); + int remainder = offset - period * range; + return (period & 1) == 0 ? remainder : range - remainder; + } + + private static int blockCoordinate(double value, String role) { + if (value != Math.rint(value) || value < Integer.MIN_VALUE || value > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Coordinate gradient " + role + " must be a whole block coordinate, got " + value); + } + return (int) value; + } + + private static int intervalStart(List knots, float coordinate) { + int low = 0; + int length = knots.size(); + while (length > 0) { + int half = length / 2; + int middle = low + half; + if (coordinate < (float) knots.get(middle).location()) { + length = half; + } else { + low = middle + 1; + length -= half + 1; + } + } + return low - 1; + } + + private static int caseIndex(double index, int cases) { + return (int) Math.clamp(Math.round(index), 0L, cases - 1L); + } + + private static float lerp(float delta, float start, float end) { + return start + delta * (end - start); + } + + private record ModelKey(String noiseId, NoiseDef definition, long worldSeed, DataVersion target) { + } + + private static final class Point { + private final int x; + private final int y; + private final int z; + private Map memo; + + private Point(int x, int y, int z) { + this.x = x; + this.y = y; + this.z = z; + } + + private int x() { + return x; + } + + private int y() { + return y; + } + + private int z() { + return z; + } + + private int get(Axis axis) { + return switch (axis) { + case X -> x; + case Y -> y; + case Z -> z; + }; + } + + private Point pinned(Axis axis, int value) { + if (get(axis) == value) { + return this; + } + return switch (axis) { + case X -> new Point(value, y, z); + case Y -> new Point(x, value, z); + case Z -> new Point(x, y, value); + }; + } + + private Map memo() { + if (memo == null) { + memo = new IdentityHashMap<>(); + } + return memo; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/FieldTraits.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/FieldTraits.java new file mode 100644 index 000000000..e960513b1 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/FieldTraits.java @@ -0,0 +1,309 @@ +package art.arcane.iris.pack.datapack.export.ir; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; + +import java.util.EnumSet; +import java.util.List; +import java.util.function.DoubleUnaryOperator; + +final class FieldTraits { + private static final ValueRange NOISE_RANGE = new ValueRange(-1.0, 1.0); + private static final int EXPENSIVE_LOAD = 2; + private static final double HERMITE_OVERSHOOT = 4.0 / 27.0; + private static final Cache CACHE = Caffeine.newBuilder().weakKeys().build(); + + private FieldTraits() { + } + + static EnumSet axes(Field field) { + return EnumSet.copyOf(traits(field).axes()); + } + + static ValueRange range(Field field) { + return traits(field).range(); + } + + static CostClass cost(Field field) { + int load = traits(field).load(); + if (load == 0) { + return CostClass.CHEAP; + } + return load == 1 ? CostClass.NOISE : CostClass.EXPENSIVE; + } + + private static Traits traits(Field field) { + Traits cached = CACHE.getIfPresent(field); + if (cached != null) { + return cached; + } + Traits computed = compute(field); + CACHE.put(field, computed); + return computed; + } + + private static Traits compute(Field field) { + return switch (field) { + case Field.Const constant -> new Traits(EnumSet.noneOf(Axis.class), ValueRange.point(constant.value()), 0); + case Field.Coord coord -> new Traits(EnumSet.of(coord.axis()), ValueRange.hull(coord.fromValue(), coord.toValue()), 0); + case Field.Noise noise -> noise(noise); + case Field.Binary binary -> binary(binary); + case Field.Unary unary -> unary(unary); + case Field.Clamp clamp -> clamp(clamp); + case Field.Lerp lerp -> lerp(lerp); + case Field.Round round -> round(round); + case Field.Curve curve -> curve(curve); + case Field.Partition partition -> partition(partition); + case Field.Mux mux -> mux(mux); + case Field.Gate gate -> gate(gate); + case Field.RangeIf rangeIf -> rangeIf(rangeIf); + case Field.Grid grid -> traits(grid.expr()); + case Field.Shared shared -> traits(shared.expr()); + case Field.Slice slice -> slice(slice); + case Field.TopSurface topSurface -> topSurface(topSurface); + case Field.Ref ignored -> new Traits(EnumSet.allOf(Axis.class), ValueRange.UNBOUNDED, 0); + }; + } + + private static Traits noise(Field.Noise noise) { + EnumSet axes = EnumSet.noneOf(Axis.class); + if (noise.xzScale() != 0.0) { + axes.add(Axis.X); + axes.add(Axis.Z); + } + if (noise.yScale() != 0.0) { + axes.add(Axis.Y); + } + int load = 1; + for (Field shift : new Field[]{noise.shiftX(), noise.shiftY(), noise.shiftZ()}) { + if (shift == null) { + continue; + } + Traits shiftTraits = traits(shift); + axes.addAll(shiftTraits.axes()); + load = sum(load, shiftTraits.load()); + } + return new Traits(axes, NOISE_RANGE, load); + } + + private static Traits binary(Field.Binary binary) { + Traits left = traits(binary.left()); + Traits right = traits(binary.right()); + ValueRange range = switch (binary.op()) { + case ADD -> left.range().add(right.range()); + case SUB -> left.range().sub(right.range()); + case MUL -> left.range().mul(right.range()); + case DIV -> left.range().div(right.range()); + case MIN -> left.range().pointwiseMin(right.range()); + case MAX -> left.range().pointwiseMax(right.range()); + case POW -> left.range().pow(right.range()); + }; + return new Traits(union(left.axes(), right.axes()), range, sum(left.load(), right.load())); + } + + private static Traits unary(Field.Unary unary) { + Traits input = traits(unary.input()); + ValueRange source = input.range(); + ValueRange range = switch (unary.op()) { + case NEG -> source.negate(); + case ABS -> source.abs(); + case SQUARE -> monotone(source.abs(), value -> value * value); + case CUBE -> monotone(source, value -> value * value * value); + case SQRT -> monotone(source, value -> Math.sqrt(Math.max(value, 0.0))); + case LOG -> source.max() <= 0.0 ? ValueRange.UNBOUNDED : monotone(source, value -> value <= 0.0 ? Double.NEGATIVE_INFINITY : Math.log(value)); + case SIGN -> monotone(source, Math::signum); + case SQUEEZE -> monotone(source, FieldTraits::squeeze); + case HALF_NEG -> monotone(source, value -> value > 0.0 ? value : value * 0.5); + case QUARTER_NEG -> monotone(source, value -> value > 0.0 ? value : value * 0.25); + }; + return new Traits(input.axes(), range, input.load()); + } + + private static Traits clamp(Field.Clamp clamp) { + Traits input = traits(clamp.input()); + return new Traits(input.axes(), input.range().clamp(clamp.min(), clamp.max()), input.load()); + } + + private static Traits lerp(Field.Lerp lerp) { + Traits alpha = traits(lerp.alpha()); + Traits first = traits(lerp.first()); + Traits second = traits(lerp.second()); + ValueRange range = alpha.range().min() >= 0.0 && alpha.range().max() <= 1.0 + ? first.range().union(second.range()) + : first.range().add(alpha.range().mul(second.range().sub(first.range()))); + return new Traits(union(alpha.axes(), union(first.axes(), second.axes())), range, sum(alpha.load(), sum(first.load(), second.load()))); + } + + private static Traits round(Field.Round round) { + Traits input = traits(round.input()); + Traits multiple = traits(round.multiple()); + return new Traits(union(input.axes(), multiple.axes()), roundRange(round.mode(), input.range(), multiple.range()), sum(input.load(), multiple.load())); + } + + private static ValueRange roundRange(Field.RoundMode mode, ValueRange input, ValueRange multiple) { + if (multiple.min() == multiple.max()) { + double step = multiple.min(); + if (step == 0.0) { + return ValueRange.UNBOUNDED; + } + return ValueRange.hull(quantise(mode, input.min() / step) * step, quantise(mode, input.max() / step) * step); + } + double margin = Math.max(Math.abs(multiple.min()), Math.abs(multiple.max())); + return Double.isFinite(margin) ? input.widen(margin) : ValueRange.UNBOUNDED; + } + + private static double quantise(Field.RoundMode mode, double value) { + return switch (mode) { + case ROUND -> Math.floor(value + 0.5); + case FLOOR -> Math.floor(value); + case CEIL -> Math.ceil(value); + case TRUNCATE -> value < 0.0 ? Math.ceil(value) : Math.floor(value); + }; + } + + private static Traits curve(Field.Curve curve) { + Traits input = traits(curve.input()); + EnumSet axes = EnumSet.copyOf(input.axes()); + int knotLoad = 0; + for (Field.Knot knot : curve.knots()) { + Traits value = traits(knot.value()); + axes.addAll(value.axes()); + knotLoad = Math.max(knotLoad, value.load()); + } + return new Traits(axes, curveRange(input.range(), curve.knots()), sum(input.load(), knotLoad)); + } + + private static ValueRange curveRange(ValueRange input, List knots) { + Field.Knot first = knots.getFirst(); + Field.Knot last = knots.getLast(); + ValueRange result = null; + if (input.min() < first.location()) { + result = include(result, extension(first, input.min())); + } + if (input.max() > last.location()) { + result = include(result, extension(last, input.max())); + } + for (Field.Knot knot : knots) { + if (input.contains(knot.location())) { + result = include(result, traits(knot.value()).range()); + } + } + for (int index = 0; index + 1 < knots.size(); index++) { + Field.Knot start = knots.get(index); + Field.Knot end = knots.get(index + 1); + if (input.max() < start.location() || input.min() > end.location()) { + continue; + } + double width = end.location() - start.location(); + double overshoot = HERMITE_OVERSHOOT * width * (Math.abs(start.derivative()) + Math.abs(end.derivative())); + ValueRange ends = traits(start.value()).range().union(traits(end.value()).range()); + result = include(result, ends.widen(overshoot)); + } + return result == null ? traits(first.value()).range() : result; + } + + private static ValueRange extension(Field.Knot knot, double coordinate) { + ValueRange value = traits(knot.value()).range(); + if (knot.derivative() == 0.0) { + return value; + } + return value.add(ValueRange.hull(0.0, knot.derivative() * (coordinate - knot.location()))); + } + + private static Traits partition(Field.Partition partition) { + Traits input = traits(partition.input()); + List thresholds = partition.thresholds(); + EnumSet axes = EnumSet.copyOf(input.axes()); + ValueRange range = null; + int load = input.load(); + for (int index = 0; index < partition.leaves().size(); index++) { + Traits leaf = traits(partition.leaves().get(index)); + axes.addAll(leaf.axes()); + load = sum(load, leaf.load()); + boolean belowUpper = index == thresholds.size() || input.range().min() < thresholds.get(index); + boolean aboveLower = index == 0 || input.range().max() >= thresholds.get(index - 1); + if (belowUpper && aboveLower) { + range = include(range, leaf.range()); + } + } + return new Traits(axes, range == null ? traits(partition.leaves().getFirst()).range() : range, load); + } + + private static Traits mux(Field.Mux mux) { + Traits index = traits(mux.index()); + EnumSet axes = EnumSet.copyOf(index.axes()); + ValueRange range = null; + int load = index.load(); + for (Field selected : mux.cases()) { + Traits traits = traits(selected); + axes.addAll(traits.axes()); + range = include(range, traits.range()); + load = Math.max(load, traits.load()); + } + return new Traits(axes, range, load); + } + + private static Traits gate(Field.Gate gate) { + Traits condition = traits(gate.condition()); + Traits expr = traits(gate.expr()); + return new Traits(union(condition.axes(), expr.axes()), expr.range().union(ValueRange.point(0.0)), Math.max(condition.load(), expr.load())); + } + + private static Traits rangeIf(Field.RangeIf rangeIf) { + Traits input = traits(rangeIf.input()); + Traits inRange = traits(rangeIf.inRange()); + Traits outOfRange = traits(rangeIf.outOfRange()); + ValueRange source = input.range(); + ValueRange range; + if (source.min() >= rangeIf.min() && source.max() < rangeIf.max()) { + range = inRange.range(); + } else if (source.max() < rangeIf.min() || source.min() >= rangeIf.max()) { + range = outOfRange.range(); + } else { + range = inRange.range().union(outOfRange.range()); + } + EnumSet axes = union(input.axes(), union(inRange.axes(), outOfRange.axes())); + return new Traits(axes, range, sum(input.load(), sum(inRange.load(), outOfRange.load()))); + } + + private static Traits slice(Field.Slice slice) { + Traits expr = traits(slice.expr()); + EnumSet axes = EnumSet.copyOf(expr.axes()); + axes.remove(slice.axis()); + return new Traits(axes, expr.range(), expr.load()); + } + + private static Traits topSurface(Field.TopSurface topSurface) { + Traits density = traits(topSurface.density()); + EnumSet axes = EnumSet.copyOf(density.axes()); + axes.remove(Axis.Y); + int load = density.load() == 0 ? 0 : EXPENSIVE_LOAD; + return new Traits(axes, new ValueRange(topSurface.lower(), topSurface.upper()), load); + } + + private static ValueRange monotone(ValueRange source, DoubleUnaryOperator function) { + return ValueRange.hull(function.applyAsDouble(source.min()), function.applyAsDouble(source.max())); + } + + private static double squeeze(double value) { + double clamped = Math.max(-1.0, Math.min(1.0, value)); + return clamped / 2.0 - clamped * clamped * clamped / 24.0; + } + + private static ValueRange include(ValueRange current, ValueRange addition) { + return current == null ? addition : current.union(addition); + } + + private static EnumSet union(EnumSet first, EnumSet second) { + EnumSet axes = EnumSet.copyOf(first); + axes.addAll(second); + return axes; + } + + private static int sum(int first, int second) { + return Math.min(EXPENSIVE_LOAD, first + second); + } + + private record Traits(EnumSet axes, ValueRange range, int load) { + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/FieldValidator.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/FieldValidator.java new file mode 100644 index 000000000..300700570 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/FieldValidator.java @@ -0,0 +1,12 @@ +package art.arcane.iris.pack.datapack.export.ir; + +public final class FieldValidator { + private FieldValidator() { + } + + public static void requireTwoDimensional(Field root, String name) { + if (root.axes().contains(Axis.Y)) { + throw new IllegalStateException("2D field '" + name + "' depends on Y"); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/JsonResource.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/JsonResource.java new file mode 100644 index 000000000..bddfa510b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/JsonResource.java @@ -0,0 +1,11 @@ +package art.arcane.iris.pack.datapack.export.ir; + +import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; +import com.google.gson.JsonElement; + +public record JsonResource(String path, JsonElement json) { + public JsonResource { + VanillaResourceIdentifier.requirePath(path, "json resource path"); + json = json.deepCopy(); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/MaterialProgram.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/MaterialProgram.java new file mode 100644 index 000000000..391ab53e5 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/MaterialProgram.java @@ -0,0 +1,25 @@ +package art.arcane.iris.pack.datapack.export.ir; + +import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +public record MaterialProgram(JsonElement rule) { + public MaterialProgram { + rule = rule.deepCopy(); + } + + public static MaterialProgram rock(String block) { + VanillaResourceIdentifier.requireIdentifier(block, "block id"); + return new MaterialProgram(blockRule(block)); + } + + private static JsonObject blockRule(String block) { + JsonObject state = new JsonObject(); + state.addProperty("id", block); + JsonObject rule = new JsonObject(); + rule.addProperty("type", "minecraft:block"); + rule.add("result_state", state); + return rule; + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/NoiseDef.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/NoiseDef.java new file mode 100644 index 000000000..380bb2b18 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/NoiseDef.java @@ -0,0 +1,34 @@ +package art.arcane.iris.pack.datapack.export.ir; + +import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; + +import java.util.ArrayList; +import java.util.List; + +public record NoiseDef(String id, int firstOctave, List amplitudes) { + private static final int MAX_OCTAVE = 32; + private static final double MAX_AMPLITUDE = 1.0e6; + + public NoiseDef { + VanillaResourceIdentifier.requirePath(id, "noise id"); + if (firstOctave < -MAX_OCTAVE || firstOctave > MAX_OCTAVE) { + throw new IllegalArgumentException("Noise '" + id + "' first octave must be within [-32, 32], got " + firstOctave); + } + if (amplitudes.isEmpty() || amplitudes.size() > MAX_OCTAVE) { + throw new IllegalArgumentException("Noise '" + id + "' needs 1 to 32 amplitudes, got " + amplitudes.size()); + } + List normalized = new ArrayList<>(amplitudes.size()); + boolean audible = false; + for (Double amplitude : amplitudes) { + if (amplitude == null || !(amplitude >= 0.0 && amplitude <= MAX_AMPLITUDE)) { + throw new IllegalArgumentException("Noise '" + id + "' amplitudes must be within [0, 1e6], got " + amplitude); + } + normalized.add(amplitude == 0.0 ? 0.0 : amplitude); + audible |= amplitude > 0.0; + } + if (!audible) { + throw new IllegalArgumentException("Noise '" + id + "' needs at least one positive amplitude"); + } + amplitudes = List.copyOf(normalized); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/RouterDef.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/RouterDef.java new file mode 100644 index 000000000..7e344e875 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/RouterDef.java @@ -0,0 +1,27 @@ +package art.arcane.iris.pack.datapack.export.ir; + +import java.util.Objects; + +public record RouterDef( + Field temperature, + Field vegetation, + Field continents, + Field erosion, + Field depth, + Field ridges, + Field chunkSurfaceLevel, + Field finalDensity, + AquiferDef aquifer +) { + public RouterDef { + Objects.requireNonNull(temperature); + Objects.requireNonNull(vegetation); + Objects.requireNonNull(continents); + Objects.requireNonNull(erosion); + Objects.requireNonNull(depth); + Objects.requireNonNull(ridges); + Objects.requireNonNull(chunkSurfaceLevel); + Objects.requireNonNull(finalDensity); + Objects.requireNonNull(aquifer); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/ValueRange.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/ValueRange.java new file mode 100644 index 000000000..59edc228b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/ir/ValueRange.java @@ -0,0 +1,114 @@ +package art.arcane.iris.pack.datapack.export.ir; + +public record ValueRange(double min, double max) { + public static final ValueRange UNBOUNDED = new ValueRange(Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY); + + public ValueRange { + if (Double.isNaN(min) || Double.isNaN(max) || min > max + || min == Double.POSITIVE_INFINITY || max == Double.NEGATIVE_INFINITY) { + throw new IllegalArgumentException("Invalid value range [" + min + ", " + max + "]"); + } + min = min == 0.0 ? 0.0 : min; + max = max == 0.0 ? 0.0 : max; + } + + public static ValueRange point(double value) { + return new ValueRange(value, value); + } + + public static ValueRange hull(double first, double second) { + if (Double.isNaN(first) || Double.isNaN(second)) { + return UNBOUNDED; + } + return new ValueRange(Math.min(first, second), Math.max(first, second)); + } + + public boolean isBounded() { + return Double.isFinite(min) && Double.isFinite(max); + } + + public boolean contains(double value) { + return value >= min && value <= max; + } + + public ValueRange union(ValueRange other) { + return new ValueRange(Math.min(min, other.min), Math.max(max, other.max)); + } + + public ValueRange widen(double margin) { + if (Double.isNaN(margin) || margin < 0.0) { + throw new IllegalArgumentException("Invalid range margin " + margin); + } + return new ValueRange(min - margin, max + margin); + } + + public ValueRange negate() { + return new ValueRange(-max, -min); + } + + public ValueRange abs() { + if (min >= 0.0) { + return this; + } + if (max <= 0.0) { + return negate(); + } + return new ValueRange(0.0, Math.max(-min, max)); + } + + public ValueRange add(ValueRange other) { + return new ValueRange(min + other.min, max + other.max); + } + + public ValueRange sub(ValueRange other) { + return new ValueRange(min - other.max, max - other.min); + } + + public ValueRange mul(ValueRange other) { + double first = product(min, other.min); + double second = product(min, other.max); + double third = product(max, other.min); + double fourth = product(max, other.max); + return new ValueRange(Math.min(Math.min(first, second), Math.min(third, fourth)), Math.max(Math.max(first, second), Math.max(third, fourth))); + } + + public ValueRange div(ValueRange other) { + if (other.contains(0.0)) { + return UNBOUNDED; + } + return mul(hull(1.0 / other.min, 1.0 / other.max)); + } + + public ValueRange pointwiseMin(ValueRange other) { + return new ValueRange(Math.min(min, other.min), Math.min(max, other.max)); + } + + public ValueRange pointwiseMax(ValueRange other) { + return new ValueRange(Math.max(min, other.min), Math.max(max, other.max)); + } + + public ValueRange pow(ValueRange exponent) { + if (min < 0.0) { + return UNBOUNDED; + } + double first = Math.pow(min, exponent.min); + double second = Math.pow(min, exponent.max); + double third = Math.pow(max, exponent.min); + double fourth = Math.pow(max, exponent.max); + if (Double.isNaN(first) || Double.isNaN(second) || Double.isNaN(third) || Double.isNaN(fourth)) { + return UNBOUNDED; + } + return new ValueRange(Math.min(Math.min(first, second), Math.min(third, fourth)), Math.max(Math.max(first, second), Math.max(third, fourth))); + } + + public ValueRange clamp(double lower, double upper) { + return new ValueRange(Math.min(Math.max(min, lower), upper), Math.max(Math.min(max, upper), lower)); + } + + private static double product(double first, double second) { + if (first == 0.0 || second == 0.0) { + return 0.0; + } + return first * second; + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/lower/ExportCapabilities.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/lower/ExportCapabilities.java new file mode 100644 index 000000000..4d8c73591 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/lower/ExportCapabilities.java @@ -0,0 +1,19 @@ +package art.arcane.iris.pack.datapack.export.lower; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.WorldgenExportTargets; + +import java.util.Objects; + +public record ExportCapabilities(DataVersion target) { + public ExportCapabilities { + Objects.requireNonNull(target); + if (!WorldgenExportTargets.SUPPORTED.contains(target)) { + throw WorldgenExportTargets.unsupported(target.getVersion()); + } + } + + public static ExportCapabilities forTarget(DataVersion target) { + return new ExportCapabilities(target); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/lower/FieldDomains.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/lower/FieldDomains.java new file mode 100644 index 000000000..73d8233c5 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/lower/FieldDomains.java @@ -0,0 +1,36 @@ +package art.arcane.iris.pack.datapack.export.lower; + +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.ValueRange; + +public final class FieldDomains { + public static final double POSITIVE_FLOOR = Float.MIN_NORMAL; + public static final double CONSTANT_LIMIT = 1.0e6; + + private FieldDomains() { + } + + public static Field clampToDomain(Field input, double floor) { + ValueRange range = input.range(); + if (range.min() >= floor) { + return input; + } + return new Field.Clamp(input, floor, Math.max(floor, Math.min(range.max(), CONSTANT_LIMIT))); + } + + public static Field unaryInput(Field.Unary unary) { + return switch (unary.op()) { + case SQRT -> clampToDomain(unary.input(), 0.0); + case LOG -> clampToDomain(unary.input(), POSITIVE_FLOOR); + default -> unary.input(); + }; + } + + public static Field clampPowBase(Field base, Field exponent) { + ValueRange exponentRange = exponent.range(); + if (exponentRange.min() == exponentRange.max() && exponentRange.min() == Math.rint(exponentRange.min())) { + return base; + } + return clampToDomain(base, exponentRange.min() < 0.0 ? POSITIVE_FLOOR : 0.0); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/lower/GenerationBudget.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/lower/GenerationBudget.java new file mode 100644 index 000000000..2fe9f3691 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/lower/GenerationBudget.java @@ -0,0 +1,89 @@ +package art.arcane.iris.pack.datapack.export.lower; + +import art.arcane.iris.pack.datapack.export.GenerationCost; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.OptionalDouble; + +public final class GenerationBudget { + public static final double CLIMATE_WORST_CASE_NODES = 5.0e5; + public static final long FINAL_DENSITY_DISTINCT_NODES = 20_000L; + public static final long DENSITY_FUNCTION_JSON_BYTES = 50L * 1024L * 1024L; + private static final double MEGABYTE = 1024.0 * 1024.0; + private static final String DENSITY_FUNCTION_RESOURCE = "worldgen/density_function"; + private static final Map WORST_CASE_LIMITS = worstCaseLimits(); + + private GenerationBudget() { + } + + public static OptionalDouble worstCaseLimit(GenerationCost.Root root) { + Double limit = WORST_CASE_LIMITS.get(root); + return limit == null ? OptionalDouble.empty() : OptionalDouble.of(limit); + } + + public static List check(GenerationCost cost, String dimension) { + List diagnostics = new ArrayList<>(); + checkWorstCase(cost, dimension, diagnostics); + checkFinalDensity(cost, dimension, diagnostics); + checkDensityFunctionJson(cost, diagnostics); + return diagnostics; + } + + private static void checkWorstCase(GenerationCost cost, String dimension, List diagnostics) { + for (Map.Entry entry : cost.roots().entrySet()) { + Double limit = WORST_CASE_LIMITS.get(entry.getKey()); + double nodes = entry.getValue().worstCaseNodes(); + if (limit == null || nodes <= limit) { + continue; + } + String root = entry.getKey().id(); + diagnostics.add(warning(rootResource(dimension, entry.getKey()), + String.format(Locale.ROOT, "Router root '%s' evaluates up to %.2e density function nodes per uncached lookup, over the budget of %.2e;" + + " uncached biome lookups, carver biomes, structure placement and /locate slow down in proportion", root, nodes, limit))); + } + } + + private static void checkFinalDensity(GenerationCost cost, String dimension, List diagnostics) { + GenerationCost.RootCost finalDensity = cost.roots().get(GenerationCost.Root.FINAL_DENSITY); + if (finalDensity == null || finalDensity.distinctNodes() <= FINAL_DENSITY_DISTINCT_NODES) { + return; + } + diagnostics.add(warning(rootResource(dimension, GenerationCost.Root.FINAL_DENSITY), + String.format(Locale.ROOT, "The final density has %d distinct density function nodes, over the budget of %d;" + + " noise fill, material rules and getBaseHeight slow down in proportion", finalDensity.distinctNodes(), FINAL_DENSITY_DISTINCT_NODES))); + } + + private static void checkDensityFunctionJson(GenerationCost cost, List diagnostics) { + if (cost.densityFunctionBytes() <= DENSITY_FUNCTION_JSON_BYTES) { + return; + } + diagnostics.add(warning(DENSITY_FUNCTION_RESOURCE, + String.format(Locale.ROOT, "Density function JSON totals %.1f MB, over the budget of %.1f MB; registry load time and memory grow in proportion", + cost.densityFunctionBytes() / MEGABYTE, DENSITY_FUNCTION_JSON_BYTES / MEGABYTE))); + } + + private static String rootResource(String dimension, GenerationCost.Root root) { + return "noise_settings/" + dimension + "/" + root.id(); + } + + private static ExportDiagnostic warning(String resource, String message) { + return new ExportDiagnostic(ExportDiagnostic.Severity.WARNING, ExportLimit.GENERATION_COST, resource, message); + } + + private static Map worstCaseLimits() { + Map limits = new EnumMap<>(GenerationCost.Root.class); + for (GenerationCost.Root root : GenerationCost.Root.values()) { + if (root.climate()) { + limits.put(root, CLIMATE_WORST_CASE_NODES); + } + } + return Collections.unmodifiableMap(limits); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/lower/GenerationCostCounter.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/lower/GenerationCostCounter.java new file mode 100644 index 000000000..033d29923 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/lower/GenerationCostCounter.java @@ -0,0 +1,418 @@ +package art.arcane.iris.pack.datapack.export.lower; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.GenerationCost; +import art.arcane.iris.pack.datapack.export.WorldgenExportTargets; +import art.arcane.iris.pack.datapack.export.ir.AquiferDef; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.RouterDef; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Deque; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +public final class GenerationCostCounter { + private static final int INTERPOLATION_CORNERS = 8; + private static final int MAX_SURFACE_STEPS = 4096; + + private final Map emissions = new IdentityHashMap<>(); + private final Map expanded = new IdentityHashMap<>(); + private final Map worstCase = new IdentityHashMap<>(); + private final Map inline = new IdentityHashMap<>(); + private final Map shapes = new IdentityHashMap<>(); + private final Map, Integer> shapeIds = new HashMap<>(); + private final VanillaBounds bounds; + + private GenerationCostCounter(VanillaBounds bounds, DataVersion target) { + if (!WorldgenExportTargets.SUPPORTED.contains(target)) { + throw WorldgenExportTargets.unsupported(target.getVersion()); + } + this.bounds = Objects.requireNonNull(bounds); + } + + public static GenerationCost count(RouterDef router, VanillaBounds bounds, DataVersion target) { + GenerationCostCounter counter = new GenerationCostCounter(bounds, target); + Map roots = new EnumMap<>(GenerationCost.Root.class); + for (GenerationCost.Root root : GenerationCost.Root.values()) { + Field field = field(router, root); + double expandedNodes = counter.expanded(field); + roots.put(root, new GenerationCost.RootCost(counter.worstCase(field), expandedNodes, counter.distinct(field))); + } + return new GenerationCost(roots, 0L); + } + + public static double worstCase(Field field, VanillaBounds bounds, DataVersion target) { + GenerationCostCounter counter = new GenerationCostCounter(bounds, target); + counter.expanded(field); + return counter.worstCase(field); + } + + private static Field field(RouterDef router, GenerationCost.Root root) { + AquiferDef aquifer = router.aquifer(); + return switch (root) { + case TEMPERATURE -> router.temperature(); + case VEGETATION -> router.vegetation(); + case CONTINENTS -> router.continents(); + case EROSION -> router.erosion(); + case DEPTH -> router.depth(); + case RIDGES -> router.ridges(); + case CHUNK_SURFACE_LEVEL -> router.chunkSurfaceLevel(); + case FINAL_DENSITY -> router.finalDensity(); + case AQUIFER_BARRIER -> aquifer.barrier(); + case AQUIFER_FLOODEDNESS -> aquifer.floodedness(); + case AQUIFER_SPREAD -> aquifer.spread(); + case AQUIFER_LAVA -> aquifer.lava(); + case AQUIFER_EXCLUSION -> aquifer.exclusion(); + case AQUIFER_SURFACE_LEVEL -> aquifer.surfaceLevel(); + }; + } + + private double expanded(Field root) { + for (Field field : postOrder(root, expanded, false)) { + Emission emission = emission(field); + double total = emission.own(); + for (Field child : emission.children()) { + total += expanded.get(child); + } + expanded.put(field, total); + } + return expanded.get(root); + } + + private double worstCase(Field root) { + for (Field field : postOrder(root, worstCase, false)) { + worstCase.put(field, pointCost(field)); + } + return worstCase.get(root); + } + + private double pointCost(Field field) { + return switch (field) { + case Field.Curve curve -> 1.0 + splineCost(curve); + case Field.Partition partition -> 1.0 + worstCase.get(partition.input()) + worstOf(partition.leaves()); + case Field.RangeIf rangeIf -> 1.0 + worstCase.get(rangeIf.input()) + Math.max(worstCase.get(rangeIf.inRange()), worstCase.get(rangeIf.outOfRange())); + case Field.Mux mux -> muxCost(mux); + case Field.Gate gate -> gateCost(gate); + case Field.Grid grid -> 1.0 + INTERPOLATION_CORNERS * expanded(grid.expr()); + case Field.TopSurface topSurface -> 1.0 + surfaceSteps(topSurface) * worstCase.get(topSurface.density()); + default -> sumCost(field); + }; + } + + private double sumCost(Field field) { + Emission emission = emission(field); + double total = emission.own(); + for (Field child : emission.children()) { + total += worstCase.get(child); + } + return total; + } + + private double splineCost(Field.Curve curve) { + List knots = curve.knots(); + double[] values = new double[knots.size()]; + for (int index = 0; index < values.length; index++) { + values[index] = knots.get(index).value() instanceof Field.Curve nested ? splineCost(nested) : 0.0; + } + double worstPair = values.length == 1 ? values[0] : 0.0; + for (int index = 0; index + 1 < values.length; index++) { + worstPair = Math.max(worstPair, values[index] + values[index + 1]); + } + return 1.0 + worstCase.get(curve.input()) + worstPair; + } + + private double muxCost(Field.Mux mux) { + if (mux.cases().size() == 1) { + return worstCase.get(mux.cases().getFirst()); + } + double worstOption = 0.0; + for (Field option : mux.cases()) { + if (!(option instanceof Field.Const)) { + worstOption = Math.max(worstOption, 1.0 + worstCase.get(option)); + } + } + return 2.0 + worstCase.get(mux.index()) + worstOption; + } + + private double gateCost(Field.Gate gate) { + double open = gate.expr() instanceof Field.Const ? 0.0 : 1.0 + worstCase.get(gate.expr()); + return 2.0 + worstCase.get(gate.condition()) + open; + } + + private double worstOf(List options) { + double worst = 0.0; + for (Field option : options) { + worst = Math.max(worst, worstCase.get(option)); + } + return worst; + } + + private static double surfaceSteps(Field.TopSurface topSurface) { + double steps = Math.floor((double) (topSurface.upper() - topSurface.lower()) / topSurface.step()) + 1.0; + return Math.clamp(steps, 1.0, MAX_SURFACE_STEPS); + } + + private long distinct(Field root) { + Set files = new HashSet<>(); + long total = inline(root); + for (Field field : postOrder(root, Map.of(), false)) { + if (field instanceof Field.Shared shared && files.add(shape(shared))) { + total += 1L + inline(shared.expr()); + } + } + return total; + } + + private long inline(Field root) { + for (Field field : postOrder(root, inline, true)) { + long total = 0L; + if (!(field instanceof Field.Shared)) { + Emission emission = emission(field); + total = emission.own(); + for (Field child : emission.children()) { + total += inline.get(child); + } + } + inline.put(field, total); + } + return inline.get(root); + } + + private int shape(Field root) { + for (Field field : postOrder(root, shapes, false)) { + Field folded = field instanceof Field.Binary binary ? bounds.fold(binary) : field; + if (folded != field) { + shapes.put(field, shapes.get(folded)); + continue; + } + List key = shapeKey(field); + Integer id = shapeIds.get(key); + if (id == null) { + id = shapeIds.size(); + shapeIds.put(key, id); + } + shapes.put(field, id); + } + return shapes.get(root); + } + + private List postOrder(Field root, Map done, boolean stopAtShared) { + List order = new ArrayList<>(); + if (done.containsKey(root)) { + return order; + } + Set visited = Collections.newSetFromMap(new IdentityHashMap<>()); + Deque pending = new ArrayDeque<>(); + Deque ready = new ArrayDeque<>(); + pending.push(root); + ready.push(Boolean.FALSE); + while (!pending.isEmpty()) { + Field field = pending.pop(); + if (ready.pop()) { + order.add(field); + continue; + } + if (!visited.add(field)) { + continue; + } + pending.push(field); + ready.push(Boolean.TRUE); + if (stopAtShared && field instanceof Field.Shared) { + continue; + } + for (Field child : emission(field).children()) { + if (!visited.contains(child) && !done.containsKey(child)) { + pending.push(child); + ready.push(Boolean.FALSE); + } + } + } + return order; + } + + private Emission emission(Field field) { + Emission known = emissions.get(field); + if (known != null) { + return known; + } + Emission computed = emit(field); + emissions.put(field, computed); + return computed; + } + + private Emission emit(Field field) { + return switch (field) { + case Field.Const ignored -> Emission.LEAF_CONSTANT; + case Field.Coord ignored -> Emission.LEAF; + case Field.Ref ignored -> Emission.LEAF; + case Field.Noise noise -> node(1, present(noise.shiftX(), noise.shiftY(), noise.shiftZ())); + case Field.Binary binary -> binary(binary); + case Field.Unary unary -> node(1, List.of(FieldDomains.unaryInput(unary))); + case Field.Clamp clamp -> node(1, List.of(clamp.input())); + case Field.Lerp lerp -> node(1, List.of(lerp.alpha(), lerp.first(), lerp.second())); + case Field.Round round -> node(1, List.of(round.input(), round.multiple())); + case Field.Curve curve -> curve(curve); + case Field.Partition partition -> node(1, prepend(partition.input(), partition.leaves())); + case Field.Mux mux -> mux(mux); + case Field.Gate gate -> gate(gate); + case Field.RangeIf rangeIf -> node(1, List.of(rangeIf.input(), rangeIf.inRange(), rangeIf.outOfRange())); + case Field.Grid grid -> node(1, List.of(grid.expr())); + case Field.Shared shared -> node(1, List.of(shared.expr())); + case Field.Slice slice -> node(1, List.of(slice.expr())); + case Field.TopSurface topSurface -> node(1, List.of(topSurface.density())); + }; + } + + private Emission binary(Field.Binary binary) { + Field folded = bounds.fold(binary); + if (folded != binary) { + return node(0, List.of(folded)); + } + if (binary.op() == Field.BinaryOp.POW) { + return node(1, List.of(FieldDomains.clampPowBase(binary.left(), binary.right()), binary.right())); + } + return node(1, List.of(binary.left(), binary.right())); + } + + private static Emission curve(Field.Curve curve) { + List coordinates = new ArrayList<>(); + int bodies = splineBodies(curve, coordinates); + return node(1 + bodies, coordinates); + } + + private static int splineBodies(Field.Curve curve, List coordinates) { + coordinates.add(curve.input()); + int bodies = 1; + for (Field.Knot knot : curve.knots()) { + if (knot.value() instanceof Field.Curve nested) { + bodies += splineBodies(nested, coordinates); + } + } + return bodies; + } + + private Emission mux(Field.Mux mux) { + if (mux.cases().size() == 1) { + return node(0, List.of(mux.cases().getFirst())); + } + List children = new ArrayList<>(mux.cases().size() + 1); + children.add(mux.index()); + int own = 2; + for (Field option : mux.cases()) { + if (!(option instanceof Field.Const)) { + children.add(option); + own++; + } + } + return node(own, children); + } + + private Emission gate(Field.Gate gate) { + if (gate.expr() instanceof Field.Const) { + return node(2, List.of(gate.condition())); + } + return node(3, List.of(gate.condition(), gate.expr())); + } + + private List shapeKey(Field field) { + List key = new ArrayList<>(8); + key.add(field.getClass()); + switch (field) { + case Field.Const constant -> key.add(constant.value()); + case Field.Coord coord -> key.addAll(List.of(coord.axis(), coord.tiling(), coord.from(), coord.to(), coord.fromValue(), coord.toValue())); + case Field.Ref ref -> key.add(ref.id()); + case Field.Noise noise -> key.addAll(List.of(noise.noiseId(), noise.xzScale(), noise.yScale(), + optionalShape(noise.shiftX()), optionalShape(noise.shiftY()), optionalShape(noise.shiftZ()))); + case Field.Binary binary -> key.add(binary.op()); + case Field.Unary unary -> key.add(unary.op()); + case Field.Clamp clamp -> key.addAll(List.of(clamp.min(), clamp.max())); + case Field.Round round -> key.add(round.mode()); + case Field.Curve curve -> curveKey(curve, key); + case Field.Partition partition -> key.addAll(partition.thresholds()); + case Field.RangeIf rangeIf -> key.addAll(List.of(rangeIf.min(), rangeIf.max())); + case Field.Grid grid -> key.addAll(List.of(grid.cellXZ(), grid.cellY())); + case Field.Slice slice -> key.addAll(List.of(slice.axis(), slice.value())); + case Field.TopSurface topSurface -> key.addAll(List.of(topSurface.upper(), topSurface.lower(), topSurface.step())); + case Field.Lerp ignored -> { + } + case Field.Mux ignored -> { + } + case Field.Gate ignored -> { + } + case Field.Shared ignored -> { + } + } + if (!(field instanceof Field.Noise) && !(field instanceof Field.Curve)) { + for (Field child : emission(field).children()) { + key.add(shapes.get(child)); + } + } + if (field instanceof Field.Mux || field instanceof Field.Gate) { + key.add(constantCases(field)); + } + return key; + } + + private Object optionalShape(Field field) { + return field == null ? -1 : shapes.get(field); + } + + private void curveKey(Field.Curve curve, List key) { + key.add(shapes.get(curve.input())); + for (Field.Knot knot : curve.knots()) { + key.add(knot.location()); + key.add(knot.derivative()); + if (knot.value() instanceof Field.Curve nested) { + List nestedKey = new ArrayList<>(); + curveKey(nested, nestedKey); + key.add(nestedKey); + } else { + key.add(((Field.Const) knot.value()).value()); + } + } + } + + private static List constantCases(Field field) { + List options = field instanceof Field.Mux mux ? mux.cases() : List.of(((Field.Gate) field).expr()); + List constants = new ArrayList<>(options.size()); + for (Field option : options) { + constants.add(option instanceof Field.Const constant ? constant.value() : null); + } + return constants; + } + + private static Emission node(int own, List children) { + return new Emission(own, children); + } + + private static List present(Field... fields) { + List present = new ArrayList<>(fields.length); + for (Field field : fields) { + if (field != null) { + present.add(field); + } + } + return present; + } + + private static List prepend(Field first, List rest) { + List fields = new ArrayList<>(rest.size() + 1); + fields.add(first); + fields.addAll(rest); + return fields; + } + + private record Emission(int own, List children) { + private static final Emission LEAF = new Emission(1, List.of()); + private static final Emission LEAF_CONSTANT = new Emission(0, List.of()); + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/lower/SplineLowering.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/lower/SplineLowering.java new file mode 100644 index 000000000..0a08c1247 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/lower/SplineLowering.java @@ -0,0 +1,59 @@ +package art.arcane.iris.pack.datapack.export.lower; + +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.ValueRange; + +import java.util.ArrayList; +import java.util.List; + +public final class SplineLowering { + private static final double MUX_FLANK = 0.25; + private static final double IDENTITY_MARGIN = 1.0; + private static final double EXACT_IDENTITY_LIMIT = 524_288.0; + private static final double GATE_CLOSED = Float.MIN_VALUE; + private static final double GATE_OPEN = Math.nextUp(Float.MIN_VALUE); + private static final Field ZERO = new Field.Const(0.0); + + private SplineLowering() { + } + + public static Field.Curve mux(Field.Mux mux) { + int last = mux.cases().size() - 1; + List knots = new ArrayList<>(mux.cases().size() * 3); + for (int index = 0; index <= last; index++) { + if (index > 0) { + knots.add(new Field.Knot(index - MUX_FLANK, ZERO, 0.0)); + } + knots.add(new Field.Knot(index, knotValue(mux.cases().get(index)), 0.0)); + if (index < last) { + knots.add(new Field.Knot(index + MUX_FLANK, ZERO, 0.0)); + } + } + return new Field.Curve(mux.index(), knots); + } + + public static Field.Curve gate(Field.Gate gate) { + return new Field.Curve(gate.condition(), List.of(new Field.Knot(GATE_CLOSED, ZERO, 0.0), new Field.Knot(GATE_OPEN, knotValue(gate.expr()), 0.0))); + } + + private static Field knotValue(Field field) { + return field instanceof Field.Const ? field : identity(field); + } + + private static Field.Curve identity(Field field) { + ValueRange range = field.range(); + double bound = identityBound(Math.max(Math.abs(range.min() - IDENTITY_MARGIN), Math.abs(range.max() + IDENTITY_MARGIN))); + return new Field.Curve(field, List.of(new Field.Knot(-bound, new Field.Const(-bound), 1.0), new Field.Knot(bound, new Field.Const(bound), 1.0))); + } + + private static double identityBound(double reach) { + if (!(reach <= EXACT_IDENTITY_LIMIT)) { + return FieldDomains.CONSTANT_LIMIT; + } + double bound = 1.0; + while (bound < reach) { + bound *= 2.0; + } + return bound; + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/lower/VanillaBounds.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/lower/VanillaBounds.java new file mode 100644 index 000000000..e99792c21 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/lower/VanillaBounds.java @@ -0,0 +1,261 @@ +package art.arcane.iris.pack.datapack.export.lower; + +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.NoiseDef; +import art.arcane.iris.pack.datapack.export.ir.ValueRange; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public final class VanillaBounds { + private static final double NOISE_DEVIATION = 0.3333333333333333; + private static final double NOISE_DEVIATIONS = 6.0; + private static final double OCTAVE_PERSISTENCE = 0.5; + + private final Map noises; + private final Map ranges = new IdentityHashMap<>(); + + private VanillaBounds(Map noises) { + this.noises = noises; + } + + public static VanillaBounds of(String namespace, Collection noises) { + Map ranges = new HashMap<>(noises.size() * 2); + for (NoiseDef noise : noises) { + ranges.put(namespace + ":" + noise.id(), noiseRange(noise)); + } + return new VanillaBounds(ranges); + } + + public Field fold(Field.Binary binary) { + if (binary.op() != Field.BinaryOp.MIN && binary.op() != Field.BinaryOp.MAX) { + return binary; + } + VanillaInterval left = interval(binary.left()); + VanillaInterval right = interval(binary.right()); + if (!left.isKnown() || !right.isKnown()) { + return binary; + } + boolean min = binary.op() == Field.BinaryOp.MIN; + if (left.max() < right.min()) { + return min ? binary.left() : binary.right(); + } + if (right.max() < left.min()) { + return min ? binary.right() : binary.left(); + } + return binary; + } + + public Optional range(Field field) { + VanillaInterval interval = interval(field); + if (!interval.isKnown() || interval.isNaI() || interval.min() == Float.POSITIVE_INFINITY || interval.max() == Float.NEGATIVE_INFINITY) { + return Optional.empty(); + } + return Optional.of(new ValueRange(interval.min(), interval.max())); + } + + private VanillaInterval interval(Field field) { + VanillaInterval known = ranges.get(field); + if (known != null) { + return known; + } + VanillaInterval computed = compute(field); + ranges.put(field, computed); + return computed; + } + + private VanillaInterval compute(Field field) { + return switch (field) { + case Field.Const constant -> VanillaInterval.ofExact((float) constant.value()); + case Field.Coord coord -> VanillaInterval.encapsulating((float) coord.fromValue(), (float) coord.toValue()); + case Field.Noise noise -> noises.getOrDefault(noise.noiseId(), VanillaInterval.UNKNOWN); + case Field.Binary binary -> binary(binary); + case Field.Unary unary -> unary(unary); + case Field.Clamp clamp -> clamp(clamp); + case Field.Lerp lerp -> lerp(lerp); + case Field.Round round -> round(round); + case Field.Curve curve -> spline(curve); + case Field.Partition partition -> encapsulating(partition.leaves()); + case Field.Mux mux -> mux.cases().size() == 1 ? interval(mux.cases().getFirst()) : spline(SplineLowering.mux(mux)); + case Field.Gate gate -> spline(SplineLowering.gate(gate)); + case Field.RangeIf rangeIf -> encapsulating(List.of(rangeIf.inRange(), rangeIf.outOfRange())); + case Field.Grid grid -> interval(grid.expr()); + case Field.Shared shared -> interval(shared.expr()); + case Field.Slice slice -> interval(slice.expr()); + case Field.TopSurface topSurface -> VanillaInterval.of(topSurface.lower(), Math.max(topSurface.lower(), (float) topSurface.upper())); + case Field.Ref ignored -> VanillaInterval.UNKNOWN; + }; + } + + private VanillaInterval binary(Field.Binary binary) { + Field folded = fold(binary); + if (folded != binary) { + return interval(folded); + } + Field base = binary.op() == Field.BinaryOp.POW ? FieldDomains.clampPowBase(binary.left(), binary.right()) : binary.left(); + VanillaInterval left = interval(base); + VanillaInterval right = interval(binary.right()); + if (!left.isKnown() || !right.isKnown()) { + return VanillaInterval.UNKNOWN; + } + return switch (binary.op()) { + case ADD -> VanillaInterval.add(left, right); + case SUB -> VanillaInterval.sub(left, right); + case MUL -> VanillaInterval.mul(left, right); + case DIV -> VanillaInterval.div(left, right); + case MIN -> VanillaInterval.min(left, right); + case MAX -> VanillaInterval.max(left, right); + case POW -> VanillaInterval.pow(left, right); + }; + } + + private VanillaInterval unary(Field.Unary unary) { + VanillaInterval input = interval(FieldDomains.unaryInput(unary)); + if (!input.isKnown()) { + return VanillaInterval.UNKNOWN; + } + return switch (unary.op()) { + case NEG -> VanillaInterval.sub(VanillaInterval.ofExact(0.0F), input); + case ABS -> VanillaInterval.abs(input); + case SQUARE -> VanillaInterval.square(input); + case CUBE -> VanillaInterval.mapMonotonic(input, value -> value * value * value); + case SQRT -> VanillaInterval.pow(input, VanillaInterval.ofExact(0.5F)); + case LOG -> VanillaInterval.log(input); + case SIGN -> VanillaInterval.sign(input); + case SQUEEZE -> VanillaInterval.mapMonotonic(input, VanillaBounds::squeeze); + case HALF_NEG -> VanillaInterval.mapMonotonic(input, value -> value > 0.0F ? value : value * 0.5F); + case QUARTER_NEG -> VanillaInterval.mapMonotonic(input, value -> value > 0.0F ? value : value * 0.25F); + }; + } + + private VanillaInterval clamp(Field.Clamp clamp) { + VanillaInterval input = interval(clamp.input()); + return input.isKnown() ? VanillaInterval.clamp(input, (float) clamp.min(), (float) clamp.max()) : VanillaInterval.UNKNOWN; + } + + private VanillaInterval lerp(Field.Lerp lerp) { + VanillaInterval alpha = interval(lerp.alpha()); + VanillaInterval first = interval(lerp.first()); + VanillaInterval second = interval(lerp.second()); + if (!alpha.isKnown() || !first.isKnown() || !second.isKnown()) { + return VanillaInterval.UNKNOWN; + } + return VanillaInterval.lerp(alpha, first, second); + } + + private VanillaInterval round(Field.Round round) { + VanillaInterval input = interval(round.input()); + VanillaInterval multiple = interval(round.multiple()); + if (!input.isKnown() || !multiple.isKnown()) { + return VanillaInterval.UNKNOWN; + } + VanillaInterval steps = VanillaInterval.mapMonotonic(VanillaInterval.div(input, multiple), value -> roundToInteger(value, round.mode())); + return steps.isKnown() ? VanillaInterval.mul(steps, multiple) : VanillaInterval.UNKNOWN; + } + + private VanillaInterval encapsulating(List fields) { + List intervals = new ArrayList<>(fields.size()); + for (Field field : fields) { + intervals.add(interval(field)); + } + return VanillaInterval.encapsulating(intervals); + } + + private VanillaInterval spline(Field.Curve curve) { + VanillaInterval input = interval(curve.input()); + if (!input.isKnown() || input.isNaI()) { + return input; + } + List knots = curve.knots(); + int last = knots.size() - 1; + float[] locations = new float[knots.size()]; + float[] derivatives = new float[knots.size()]; + List values = new ArrayList<>(knots.size()); + for (int index = 0; index <= last; index++) { + Field.Knot knot = knots.get(index); + locations[index] = (float) knot.location(); + derivatives[index] = (float) knot.derivative(); + VanillaInterval value = knot.value() instanceof Field.Curve nested ? spline(nested) : interval(knot.value()); + if (!value.isKnown() || value.isNaI()) { + return VanillaInterval.UNKNOWN; + } + values.add(value); + } + float minValue = Float.POSITIVE_INFINITY; + float maxValue = Float.NEGATIVE_INFINITY; + if (input.min() < locations[0]) { + float lower = linearExtend(input.min(), locations, values.getFirst().min(), derivatives, 0); + float upper = linearExtend(input.min(), locations, values.getFirst().max(), derivatives, 0); + minValue = Math.min(minValue, Math.min(lower, upper)); + maxValue = Math.max(maxValue, Math.max(lower, upper)); + } + if (input.max() > locations[last]) { + float lower = linearExtend(input.max(), locations, values.get(last).min(), derivatives, last); + float upper = linearExtend(input.max(), locations, values.get(last).max(), derivatives, last); + minValue = Math.min(minValue, Math.min(lower, upper)); + maxValue = Math.max(maxValue, Math.max(lower, upper)); + } + for (VanillaInterval value : values) { + minValue = Math.min(minValue, value.min()); + maxValue = Math.max(maxValue, value.max()); + } + for (int index = 0; index < last; index++) { + float first = derivatives[index]; + float second = derivatives[index + 1]; + if (first == 0.0F && second == 0.0F) { + continue; + } + float width = locations[index + 1] - locations[index]; + VanillaInterval start = values.get(index); + VanillaInterval end = values.get(index + 1); + float startSlope = first * width; + float endSlope = second * width; + float minA = startSlope - end.max() + start.min(); + float maxA = startSlope - end.min() + start.max(); + float minB = -endSlope + end.min() - start.max(); + float maxB = -endSlope + end.max() - start.min(); + minValue = Math.min(minValue, Math.min(start.min(), end.min()) + 0.25F * Math.min(minA, minB)); + maxValue = Math.max(maxValue, Math.max(start.max(), end.max()) + 0.25F * Math.max(maxA, maxB)); + } + return VanillaInterval.of(minValue, maxValue); + } + + private static float linearExtend(float input, float[] locations, float value, float[] derivatives, int index) { + float derivative = derivatives[index]; + return derivative == 0.0F ? value : value + derivative * (input - locations[index]); + } + + private static VanillaInterval noiseRange(NoiseDef noise) { + double[] amplitudes = new double[noise.amplitudes().size()]; + double amplitude = 1.0; + int count = 0; + for (double modifier : noise.amplitudes()) { + if (modifier != 0.0) { + amplitudes[count++] = Math.abs(amplitude * modifier); + } + amplitude *= OCTAVE_PERSISTENCE; + } + double target = Arrays.stream(amplitudes, 0, count).sum(); + return VanillaInterval.ofSymmetric((float) (target * NOISE_DEVIATION * NOISE_DEVIATIONS)); + } + + private static float roundToInteger(float value, Field.RoundMode mode) { + return switch (mode) { + case FLOOR -> (float) Math.floor(value); + case ROUND -> Math.round(value); + case CEIL -> (float) Math.ceil(value); + case TRUNCATE -> value > 0.0F ? (float) Math.floor(value) : (float) Math.ceil(value); + }; + } + + private static float squeeze(float value) { + float clamped = value < -1.0F ? -1.0F : Math.min(value, 1.0F); + return clamped / 2.0F - clamped * clamped * clamped / 24.0F; + } +} diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/export/lower/VanillaInterval.java b/core/src/main/java/art/arcane/iris/pack/datapack/export/lower/VanillaInterval.java new file mode 100644 index 000000000..9e1e52d9d --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/datapack/export/lower/VanillaInterval.java @@ -0,0 +1,329 @@ +package art.arcane.iris.pack.datapack.export.lower; + +import java.util.List; + +final class VanillaInterval { + static final VanillaInterval NAI = new VanillaInterval(Float.NaN, Float.NaN); + static final VanillaInterval UNKNOWN = new VanillaInterval(Float.NaN, Float.NaN); + private static final VanillaInterval INFINITE = new VanillaInterval(Float.NEGATIVE_INFINITY, Float.POSITIVE_INFINITY); + private static final VanillaInterval ZERO_TO_ONE = new VanillaInterval(0.0F, 1.0F); + + private final float min; + private final float max; + + private VanillaInterval(float min, float max) { + this.min = min; + this.max = max; + } + + static VanillaInterval of(float min, float max) { + if (Float.isNaN(min) || Float.isNaN(max) || max < min) { + return UNKNOWN; + } + if (min == Float.NEGATIVE_INFINITY && max == Float.POSITIVE_INFINITY) { + return INFINITE; + } + return new VanillaInterval(min, max); + } + + static VanillaInterval ofExact(float value) { + return of(value, value); + } + + static VanillaInterval ofSymmetric(float range) { + return of(-range, range); + } + + static VanillaInterval encapsulating(List intervals) { + float min = Float.POSITIVE_INFINITY; + float max = Float.NEGATIVE_INFINITY; + for (VanillaInterval interval : intervals) { + if (interval == UNKNOWN) { + return UNKNOWN; + } + if (!interval.isNaI()) { + min = Math.min(interval.min, min); + max = Math.max(interval.max, max); + } + } + return max < min ? NAI : of(min, max); + } + + static VanillaInterval encapsulating(float first, float second) { + if (Float.isNaN(first) && Float.isNaN(second)) { + return NAI; + } + if (Float.isNaN(first)) { + return ofExact(second); + } + return Float.isNaN(second) ? ofExact(first) : of(Math.min(first, second), Math.max(first, second)); + } + + static VanillaInterval add(VanillaInterval left, VanillaInterval right) { + float min = left.min + right.min; + float max = left.max + right.max; + return !Float.isNaN(min) && !Float.isNaN(max) ? of(min, max) : NAI; + } + + static VanillaInterval sub(VanillaInterval left, VanillaInterval right) { + float min = left.min - right.max; + float max = left.max - right.min; + return !Float.isNaN(min) && !Float.isNaN(max) ? of(min, max) : NAI; + } + + static VanillaInterval mul(VanillaInterval left, VanillaInterval right) { + if (left.isNaI() || right.isNaI()) { + return NAI; + } + float minMin = mulBound(left.min, right.min); + float minMax = mulBound(left.min, right.max); + float maxMin = mulBound(left.max, right.min); + float maxMax = mulBound(left.max, right.max); + return of(Math.min(Math.min(minMin, minMax), Math.min(maxMin, maxMax)), Math.max(Math.max(minMin, minMax), Math.max(maxMin, maxMax))); + } + + static VanillaInterval div(VanillaInterval left, VanillaInterval right) { + return mul(left, reciprocal(right)); + } + + static VanillaInterval min(VanillaInterval left, VanillaInterval right) { + return !left.isNaI() && !right.isNaI() ? of(Math.min(left.min, right.min), Math.min(left.max, right.max)) : NAI; + } + + static VanillaInterval max(VanillaInterval left, VanillaInterval right) { + return !left.isNaI() && !right.isNaI() ? of(Math.max(left.min, right.min), Math.max(left.max, right.max)) : NAI; + } + + static VanillaInterval clamp(VanillaInterval input, float min, float max) { + if (min > max) { + return UNKNOWN; + } + if (input.isNaI()) { + return NAI; + } + if (input.min >= max) { + return of(max, max); + } + return input.max <= min ? of(min, min) : of(Math.max(input.min, min), Math.min(input.max, max)); + } + + static VanillaInterval abs(VanillaInterval input) { + if (input.isNaI()) { + return NAI; + } + float max = Math.max(Math.abs(input.min), Math.abs(input.max)); + return input.contains(0.0F) ? of(0.0F, max) : of(Math.min(Math.abs(input.min), Math.abs(input.max)), max); + } + + static VanillaInterval square(VanillaInterval input) { + if (input.isNaI()) { + return NAI; + } + float max = Math.max(input.min * input.min, input.max * input.max); + return input.contains(0.0F) ? of(0.0F, max) : of(Math.min(input.min * input.min, input.max * input.max), max); + } + + static VanillaInterval pow(VanillaInterval base, VanillaInterval exponent) { + if (base.isNaI() || exponent.isNaI()) { + return NAI; + } + if (base.min == base.max) { + return pow(base.min, exponent); + } + VanillaInterval result = encapsulating(List.of(pow(base.min, exponent), pow(base.max, exponent))); + if (base.contains(0.0F)) { + if (base.max > 0.0F) { + result = encapsulating(List.of(result, pow(0.0F, exponent))); + } + if (base.min < 0.0F) { + result = encapsulating(List.of(result, pow(-0.0F, exponent))); + } + } + return result; + } + + static VanillaInterval log(VanillaInterval input) { + if (input.max < 0.0F) { + return NAI; + } + return mapMonotonic(max(input, ofExact(0.0F)), value -> (float) Math.log(value)); + } + + static VanillaInterval mapMonotonic(VanillaInterval input, FloatOperator operator) { + if (input.isNaI()) { + return NAI; + } + float mappedMin = operator.apply(input.min); + float mappedMax = operator.apply(input.max); + if (Float.isNaN(mappedMin) || Float.isNaN(mappedMax)) { + return UNKNOWN; + } + return of(Math.min(mappedMin, mappedMax), Math.max(mappedMin, mappedMax)); + } + + static VanillaInterval lerp(VanillaInterval alpha, VanillaInterval first, VanillaInterval second) { + if (alpha.isNaI() || first.isNaI() || second.isNaI()) { + return NAI; + } + return encapsulating(List.of(lerp(alpha, first.min, second.min), lerp(alpha, first.max, second.min), lerp(alpha, first.min, second.max), + lerp(alpha, first.max, second.max))); + } + + static VanillaInterval sign(VanillaInterval input) { + if (input.isNaI()) { + return NAI; + } + if (input.min == input.max) { + return ofExact(Math.signum(input.min)); + } + if (!input.contains(0.0F)) { + return ofExact(input.min > 0.0F ? 1.0F : -1.0F); + } + if (input.min == 0.0F) { + return ZERO_TO_ONE; + } + return input.max == 0.0F ? of(-1.0F, 0.0F) : of(-1.0F, 1.0F); + } + + boolean isNaI() { + return this == NAI; + } + + boolean isKnown() { + return this != UNKNOWN; + } + + boolean contains(float value) { + return value >= min && value <= max; + } + + float min() { + return min; + } + + float max() { + return max; + } + + private static float mulBound(float left, float right) { + return left != 0.0F && right != 0.0F ? left * right : 0.0F; + } + + private static VanillaInterval reciprocal(VanillaInterval input) { + if (input.isNaI() || input.min == 0.0F && input.max == 0.0F) { + return NAI; + } + if (!input.contains(0.0F)) { + return of(1.0F / input.max, 1.0F / input.min); + } + if (input.max == 0.0F) { + return of(Float.NEGATIVE_INFINITY, 1.0F / input.min); + } + return input.min == 0.0F ? of(1.0F / input.max, Float.POSITIVE_INFINITY) : INFINITE; + } + + private static VanillaInterval encapsulating(VanillaInterval first, float second) { + if (Float.isNaN(second)) { + return first; + } + return first.isNaI() ? ofExact(second) : of(Math.min(first.min, second), Math.max(first.max, second)); + } + + private static VanillaInterval pow(float base, VanillaInterval exponent) { + if (Float.isNaN(base) || exponent.isNaI()) { + return NAI; + } + if (exponent.min == exponent.max) { + float value = (float) Math.pow(base, exponent.min); + return Float.isNaN(value) ? NAI : ofExact(value); + } + if (base == 0.0F) { + return mul(powZeroBase(exponent), ofExact(Math.copySign(1.0F, base))); + } + if (base == 1.0F) { + return ofExact(1.0F); + } + return base > 0.0F ? powPositiveBase(base, exponent) : powNegativeBase(base, exponent); + } + + private static VanillaInterval powPositiveBase(float base, VanillaInterval exponent) { + if (Float.isFinite(exponent.min) && Float.isFinite(exponent.max)) { + return encapsulating((float) Math.pow(base, exponent.min), (float) Math.pow(base, exponent.max)); + } + return powInfiniteExponent(base, exponent); + } + + private static VanillaInterval powZeroBase(VanillaInterval exponent) { + if (!exponent.contains(0.0F)) { + return exponent.max < 0.0F ? ofExact(Float.POSITIVE_INFINITY) : ofExact(0.0F); + } + if (exponent.max == 0.0F) { + return of(1.0F, Float.POSITIVE_INFINITY); + } + return exponent.min == 0.0F ? ZERO_TO_ONE : of(0.0F, Float.POSITIVE_INFINITY); + } + + private static VanillaInterval powInfiniteExponent(float base, VanillaInterval exponent) { + if (Float.isInfinite(exponent.min) && Float.isInfinite(exponent.max)) { + return of(0.0F, Float.POSITIVE_INFINITY); + } + if (Float.isInfinite(exponent.min)) { + return base < 1.0F ? of((float) Math.pow(base, exponent.max), Float.POSITIVE_INFINITY) : of(0.0F, (float) Math.pow(base, exponent.max)); + } + return base < 1.0F ? of(0.0F, (float) Math.pow(base, exponent.min)) : of((float) Math.pow(base, exponent.min), Float.POSITIVE_INFINITY); + } + + private static VanillaInterval powNegativeBase(float base, VanillaInterval exponent) { + float exponentMinInt = (float) Math.ceil(exponent.min); + float exponentMaxInt = (float) Math.floor(exponent.max); + if (exponentMaxInt < exponentMinInt) { + return NAI; + } + float baseToMinInt = (float) Math.pow(base, exponentMinInt); + float baseToMaxInt = (float) Math.pow(base, exponentMaxInt); + VanillaInterval result = encapsulating(baseToMinInt, baseToMaxInt); + if (Float.isInfinite(exponentMinInt)) { + result = encapsulating(result, -baseToMinInt); + } else if (exponentMinInt + 1.0F < exponentMaxInt) { + result = encapsulating(result, (float) Math.pow(base, exponentMinInt + 1.0F)); + } + if (Float.isInfinite(exponentMaxInt)) { + result = encapsulating(result, -baseToMaxInt); + } else if (exponentMaxInt - 1.0F > exponentMinInt) { + result = encapsulating(result, (float) Math.pow(base, exponentMaxInt - 1.0F)); + } + return result; + } + + private static VanillaInterval lerp(VanillaInterval alpha, float first, float second) { + if (Float.isFinite(first) && Float.isFinite(second)) { + return encapsulating(first + mulBound(alpha.min, second - first), first + mulBound(alpha.max, second - first)); + } + if (first == second) { + return ofExact(first); + } + float newMin = lerpInfiniteBound(alpha.min, first, second); + float newMax = lerpInfiniteBound(alpha.max, first, second); + return !Float.isNaN(newMin) && !Float.isNaN(newMax) ? encapsulating(newMin, newMax) : NAI; + } + + private static float lerpInfiniteBound(float alpha, float first, float second) { + float firstPart = mulBound(1.0F - alpha, first); + float secondPart = mulBound(alpha, second); + if (!Float.isInfinite(firstPart) || !Float.isInfinite(secondPart)) { + return firstPart + secondPart; + } + if (alpha <= 0.0F) { + return second > first ? Float.NEGATIVE_INFINITY : Float.POSITIVE_INFINITY; + } + if (alpha >= 1.0F) { + return second > first ? Float.POSITIVE_INFINITY : Float.NEGATIVE_INFINITY; + } + return Float.NaN; + } + + @FunctionalInterface + interface FloatOperator { + float apply(float value); + } +} diff --git a/core/src/main/java/art/arcane/iris/platform/ClasspathVanillaDataSource.java b/core/src/main/java/art/arcane/iris/platform/ClasspathVanillaDataSource.java new file mode 100644 index 000000000..2ae2c1adc --- /dev/null +++ b/core/src/main/java/art/arcane/iris/platform/ClasspathVanillaDataSource.java @@ -0,0 +1,138 @@ +package art.arcane.iris.platform; + +import art.arcane.iris.spi.VanillaDataSource; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.FileSystemAlreadyExistsException; +import java.nio.file.FileSystemNotFoundException; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Supplier; +import java.util.stream.Stream; + +public final class ClasspathVanillaDataSource implements VanillaDataSource { + private static final String MARKER = "/data/.mcassetsroot"; + private static final String DATA_DIRECTORY = "data"; + + private final Supplier> anchor; + private volatile Path root; + + public ClasspathVanillaDataSource(Supplier> anchor) { + this.anchor = Objects.requireNonNull(anchor, "anchor"); + } + + @Override + public Optional read(String dataPath) { + Path file = resolve(segments(dataPath)); + if (!Files.isRegularFile(file)) { + return Optional.empty(); + } + try { + return Optional.of(Files.readAllBytes(file)); + } catch (IOException failure) { + throw new UncheckedIOException("Could not read vanilla data " + dataPath, failure); + } + } + + @Override + public List list(String prefix) { + Objects.requireNonNull(prefix, "prefix"); + String directoryPath = prefix.endsWith("/") ? prefix.substring(0, prefix.length() - 1) : prefix; + Path directory = resolve(segments(directoryPath)); + if (!Files.isDirectory(directory)) { + return List.of(); + } + Path base = root(); + try (Stream files = Files.walk(directory)) { + return files.filter(Files::isRegularFile) + .map(file -> dataPath(base, file)) + .sorted() + .toList(); + } catch (IOException failure) { + throw new UncheckedIOException("Could not list vanilla data under " + prefix, failure); + } + } + + private Path resolve(List segments) { + Path resolved = root(); + for (String segment : segments) { + resolved = resolved.resolve(segment); + } + return resolved; + } + + private Path root() { + Path resolved = root; + if (resolved != null) { + return resolved; + } + Class type = Objects.requireNonNull(anchor.get(), "Vanilla data anchor class"); + URL marker = type.getResource(MARKER); + if (marker == null) { + throw new IllegalStateException("Vanilla data marker " + MARKER + " is not visible from " + type.getName()); + } + resolved = markerPath(marker).getParent().getParent(); + root = resolved; + return resolved; + } + + private static Path markerPath(URL marker) { + URI uri = markerUri(marker); + try { + return Path.of(uri); + } catch (FileSystemNotFoundException unopened) { + return openArchivePath(uri); + } + } + + private static URI markerUri(URL marker) { + try { + return marker.toURI(); + } catch (URISyntaxException failure) { + throw new IllegalStateException("Vanilla data marker has an invalid location " + marker, failure); + } + } + + private static Path openArchivePath(URI uri) { + try { + return FileSystems.newFileSystem(uri, Map.of()).provider().getPath(uri); + } catch (FileSystemAlreadyExistsException opened) { + return Path.of(uri); + } catch (IOException failure) { + throw new UncheckedIOException("Could not open the vanilla data archive " + uri, failure); + } + } + + private static List segments(String dataPath) { + Objects.requireNonNull(dataPath, "dataPath"); + String[] parts = dataPath.split("/", -1); + if (parts.length < 2 || !DATA_DIRECTORY.equals(parts[0]) || dataPath.indexOf('\\') >= 0) { + throw new IllegalArgumentException("Vanilla data paths have the form data//: " + dataPath); + } + for (String part : parts) { + if (part.isEmpty() || part.equals(".") || part.equals("..")) { + throw new IllegalArgumentException("Vanilla data path has an empty or relative segment: " + dataPath); + } + } + return List.of(parts); + } + + private static String dataPath(Path base, Path file) { + Path relative = base.relativize(file); + List names = new ArrayList<>(relative.getNameCount()); + for (Path name : relative) { + names.add(name.toString()); + } + return String.join("/", names); + } +} diff --git a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitPlatform.java b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitPlatform.java index c6aad0faf..6bca46e91 100644 --- a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitPlatform.java +++ b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitPlatform.java @@ -19,6 +19,7 @@ package art.arcane.iris.platform.bukkit; import art.arcane.iris.configuration.IrisSettings; +import art.arcane.iris.platform.ClasspathVanillaDataSource; import art.arcane.iris.platform.bukkit.nms.INMS; import art.arcane.iris.platform.bukkit.nms.MinecraftVersion; import art.arcane.iris.pack.value.IrisPosition; @@ -29,6 +30,7 @@ import art.arcane.iris.spi.PlatformBiomeWriter; import art.arcane.iris.spi.PlatformRegistries; import art.arcane.iris.spi.PlatformScheduler; import art.arcane.iris.spi.PlatformStructureHooks; +import art.arcane.iris.spi.VanillaDataSource; import art.arcane.volmlib.nativelib.terrain.NativeWorld; import art.arcane.iris.platform.bukkit.plugin.VolmitPlugin; import art.arcane.iris.platform.bukkit.plugin.VolmitSender; @@ -98,6 +100,7 @@ public final class BukkitPlatform implements IrisPlatform { private final BukkitScheduler scheduler = new BukkitScheduler(); private final BukkitStructureHooks structureHooks = new BukkitStructureHooks(); private final BukkitBiomeWriter biomeWriter = new BukkitBiomeWriter(); + private final ClasspathVanillaDataSource vanillaData = new ClasspathVanillaDataSource(() -> Bukkit.class); public static void hostPlugin(Plugin plugin) { PLUGIN = plugin; @@ -299,6 +302,11 @@ public final class BukkitPlatform implements IrisPlatform { return biomeWriter; } + @Override + public VanillaDataSource vanillaData() { + return vanillaData; + } + @Override public File dataFolder() { return bridge().dataFolder().get(); diff --git a/core/src/main/java/art/arcane/iris/structure/export/VanillaJigsawDatapackExporter.java b/core/src/main/java/art/arcane/iris/structure/export/VanillaJigsawDatapackExporter.java index 0c0329085..4ddaed0d4 100644 --- a/core/src/main/java/art/arcane/iris/structure/export/VanillaJigsawDatapackExporter.java +++ b/core/src/main/java/art/arcane/iris/structure/export/VanillaJigsawDatapackExporter.java @@ -1,14 +1,29 @@ package art.arcane.iris.structure.export; +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.AtomicDatapackPublisher; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; public final class VanillaJigsawDatapackExporter { + private static final String STAGING_PREFIX = ".iris-jigsaw-export-"; + private static final String PACK_METADATA = "pack.mcmeta"; + private static final Gson GSON = new GsonBuilder().disableHtmlEscaping().setPrettyPrinting().create(); + public VanillaJigsawExportValidation validate(VanillaJigsawExportRequest request) { - VanillaJigsawExportCompiler.Compilation compilation = new VanillaJigsawExportCompiler().compile(request); - List resources = new ArrayList<>(compilation.resources().keySet()); + DataVersion target = DataVersion.getRuntime(); + VanillaJigsawExportCompiler.Compilation compilation = new VanillaJigsawExportCompiler(target).compile(request.unit()); + List resources = new ArrayList<>(files(request, target, compilation).keySet()); resources.sort(String::compareTo); return new VanillaJigsawExportValidation(resources, compilation.diagnostics()); } @@ -27,7 +42,8 @@ public final class VanillaJigsawDatapackExporter { List.of(diagnostic)); } - VanillaJigsawExportCompiler.Compilation compilation = new VanillaJigsawExportCompiler().compile(request); + DataVersion target = DataVersion.getRuntime(); + VanillaJigsawExportCompiler.Compilation compilation = new VanillaJigsawExportCompiler(target).compile(request.unit()); if (compilation.hasErrors()) { return new VanillaJigsawExportResult( VanillaJigsawExportResult.Status.REJECTED, @@ -36,13 +52,15 @@ public final class VanillaJigsawDatapackExporter { compilation.diagnostics()); } + Map files = files(request, target, compilation); List diagnostics = new ArrayList<>(compilation.diagnostics()); try { AtomicDatapackPublisher.Publication publication = new AtomicDatapackPublisher().publish( - compilation.resources(), + files, request.output(), request.format(), - request.replaceExisting()); + request.replaceExisting(), + STAGING_PREFIX); for (String warning : publication.cleanupWarnings()) { diagnostics.add(new VanillaJigsawExportDiagnostic( VanillaJigsawExportDiagnostic.Severity.WARNING, @@ -63,7 +81,7 @@ public final class VanillaJigsawDatapackExporter { diagnostics); } - List resources = new ArrayList<>(compilation.resources().keySet()); + List resources = new ArrayList<>(files.keySet()); resources.sort(String::compareTo); return new VanillaJigsawExportResult( VanillaJigsawExportResult.Status.EXPORTED, @@ -71,4 +89,37 @@ public final class VanillaJigsawDatapackExporter { resources, diagnostics); } + + private static Map files( + VanillaJigsawExportRequest request, + DataVersion target, + VanillaJigsawExportCompiler.Compilation compilation + ) { + if (compilation.hasErrors()) { + return Map.of(); + } + Map files = new LinkedHashMap<>(compilation.resources().size() + 1); + files.put(PACK_METADATA, (GSON.toJson(packMetadata(request.description(), target)) + "\n").getBytes(StandardCharsets.UTF_8)); + files.putAll(compilation.resources()); + return files; + } + + private static JsonObject packMetadata(String description, DataVersion target) { + JsonObject pack = new JsonObject(); + pack.addProperty("description", description); + if (target == DataVersion.V1_21_1) { + pack.addProperty("pack_format", target.getPackFormat()); + JsonObject root = new JsonObject(); + root.add("pack", pack); + return root; + } + JsonArray minimum = new JsonArray(); + minimum.add(target.getPackFormat()); + minimum.add(target.getPackFormatMinor()); + pack.add("min_format", minimum); + pack.addProperty("max_format", target.getPackFormat()); + JsonObject root = new JsonObject(); + root.add("pack", pack); + return root; + } } diff --git a/core/src/main/java/art/arcane/iris/structure/export/VanillaJigsawExportCompiler.java b/core/src/main/java/art/arcane/iris/structure/export/VanillaJigsawExportCompiler.java index 1137c40af..91580b887 100644 --- a/core/src/main/java/art/arcane/iris/structure/export/VanillaJigsawExportCompiler.java +++ b/core/src/main/java/art/arcane/iris/structure/export/VanillaJigsawExportCompiler.java @@ -2,6 +2,11 @@ package art.arcane.iris.structure.export; import art.arcane.iris.structure.graph.CompiledStructureGraph; import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.StructureTemplateEncoder; +import art.arcane.iris.pack.datapack.export.TemplateBlock; +import art.arcane.iris.pack.datapack.export.VanillaBlockState; +import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; +import art.arcane.iris.pack.value.IrisDirection; import art.arcane.iris.structure.graph.PlanarJigsawWorkcellResolver; import art.arcane.iris.structure.graph.StructureGraphCompilation; import art.arcane.iris.structure.graph.StructureGraphCompiler; @@ -20,6 +25,7 @@ import art.arcane.iris.structure.placement.IrisStructure; import art.arcane.iris.structure.object.ObjectPlaceMode; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; import art.arcane.iris.generation.geometry.IrisBlockVector; +import art.arcane.volmlib.util.nbt.tag.CompoundTag; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.JsonArray; @@ -31,10 +37,12 @@ import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.Objects; import java.util.Set; -final class VanillaJigsawExportCompiler { +public final class VanillaJigsawExportCompiler { private static final int MAX_DEPTH = 20; private static final int MAX_HORIZONTAL_DISTANCE = 128; private static final int MAX_VERTICAL_DISTANCE = 4064; @@ -43,38 +51,56 @@ final class VanillaJigsawExportCompiler { private static final int MAX_POOL_WEIGHT = 150; private static final Gson GSON = new GsonBuilder().disableHtmlEscaping().setPrettyPrinting().create(); - Compilation compile(VanillaJigsawExportRequest request) { + private final DataVersion target; + + public VanillaJigsawExportCompiler(DataVersion target) { + this.target = Objects.requireNonNull(target); + } + + public static String biomeTagPath(String namespace, String resourcePath) { + return "data/" + namespace + "/tags/worldgen/biome/" + resourcePath + ".json"; + } + + public static String structurePath(String namespace, String resourcePath) { + return "data/" + namespace + "/worldgen/structure/" + resourcePath + ".json"; + } + + public static String structureSetPath(String namespace, String resourcePath) { + return "data/" + namespace + "/worldgen/structure_set/" + resourcePath + ".json"; + } + + public Compilation compile(VanillaJigsawExportUnit unit) { List diagnostics = new ArrayList<>(); - validateIdentity(request, diagnostics); - validateSettings(request.settings(), diagnostics); + validateIdentity(unit, diagnostics); + validateSettings(unit.settings(), diagnostics); IrisStructure structure; try { - structure = request.source().loadStructure(); + structure = unit.source().loadStructure(); } catch (RuntimeException exception) { addError(diagnostics, VanillaJigsawExportDiagnostic.Code.SOURCE_STRUCTURE_MISSING, - request.source().structureKey(), - "Could not load Iris structure '" + request.source().structureKey() + "': " + unit.source().structureKey(), + "Could not load Iris structure '" + unit.source().structureKey() + "': " + exception.getMessage()); return rejected(diagnostics); } if (structure == null) { addError(diagnostics, VanillaJigsawExportDiagnostic.Code.SOURCE_STRUCTURE_MISSING, - request.source().structureKey(), - "Iris structure '" + request.source().structureKey() + "' does not exist."); + unit.source().structureKey(), + "Iris structure '" + unit.source().structureKey() + "' does not exist."); return rejected(diagnostics); } - validateStructure(structure, request.settings(), diagnostics); + validateStructure(structure, unit.settings(), diagnostics); StructureGraphCompilation graphCompilation; try { - graphCompilation = StructureGraphCompiler.compile(structure, request.source().resolver()); + graphCompilation = StructureGraphCompiler.compile(structure, unit.source().resolver()); } catch (RuntimeException exception) { addError(diagnostics, VanillaJigsawExportDiagnostic.Code.GRAPH_VALIDATION, - request.source().structureKey(), + unit.source().structureKey(), "Structure graph compilation failed: " + exception.getMessage()); return rejected(diagnostics); } @@ -86,31 +112,47 @@ final class VanillaJigsawExportCompiler { } try { - Map resources = createResources(request, graph); + Map resources = createResources(unit, graph); return new Compilation(resources, List.copyOf(diagnostics)); } catch (IOException | RuntimeException exception) { addError(diagnostics, VanillaJigsawExportDiagnostic.Code.SERIALIZATION_FAILED, - request.source().structureKey(), + unit.source().structureKey(), "Vanilla datapack serialization failed: " + exception.getMessage()); return rejected(diagnostics); } } + static String orientation(IrisDirection front, IrisDirection top) { + if (front == IrisDirection.UP_POSITIVE_Y || front == IrisDirection.DOWN_NEGATIVE_Y) { + if (top == IrisDirection.NORTH_NEGATIVE_Z + || top == IrisDirection.SOUTH_POSITIVE_Z + || top == IrisDirection.EAST_POSITIVE_X + || top == IrisDirection.WEST_NEGATIVE_X) { + return directionName(front) + "_" + directionName(top); + } + throw new IllegalArgumentException("Vertical jigsaw fronts require a horizontal top direction"); + } + if (top != IrisDirection.UP_POSITIVE_Y) { + throw new IllegalArgumentException("Horizontal jigsaw fronts require an upward top direction"); + } + return directionName(front) + "_up"; + } + private void validateIdentity( - VanillaJigsawExportRequest request, + VanillaJigsawExportUnit unit, List diagnostics ) { - if (!VanillaResourceIdentifier.validNamespace(request.namespace())) { + if (!VanillaResourceIdentifier.validNamespace(unit.namespace())) { addError(diagnostics, VanillaJigsawExportDiagnostic.Code.INVALID_NAMESPACE, - request.namespace(), + unit.namespace(), "Datapack namespace must match [a-z0-9_.-]+."); } - if (!VanillaResourceIdentifier.validPath(request.resourcePath())) { + if (!VanillaResourceIdentifier.validPath(unit.resourcePath())) { addError(diagnostics, VanillaJigsawExportDiagnostic.Code.INVALID_RESOURCE_PATH, - request.resourcePath(), + unit.resourcePath(), "Datapack resource path must use lowercase resource-path characters without traversal."); } } @@ -215,7 +257,7 @@ final class VanillaJigsawExportCompiler { "Minecraft jigsaw size must be within 0..20; Iris export requires 1..20."); } long horizontalDistance = (long) structure.getMaxSizeChunks() * 16L; - if (DataVersion.getRuntime() == DataVersion.V1_21_1 && horizontalDistance != settings.maxDistanceVertical()) { + if (target == DataVersion.V1_21_1 && horizontalDistance != settings.maxDistanceVertical()) { addError(diagnostics, VanillaJigsawExportDiagnostic.Code.INVALID_MAX_DISTANCE, "max_distance_from_center.vertical", @@ -472,7 +514,7 @@ final class VanillaJigsawExportCompiler { "Connector pool '" + connector.getPool() + "' cannot be mapped to an exported template pool."); } try { - VanillaStructureTemplateEncoder.orientation(connector.getDirection(), connector.getTop()); + orientation(connector.getDirection(), connector.getTop()); } catch (IllegalArgumentException | NullPointerException exception) { addError(diagnostics, VanillaJigsawExportDiagnostic.Code.INVALID_CONNECTOR_ORIENTATION, @@ -547,23 +589,22 @@ final class VanillaJigsawExportCompiler { } private Map createResources( - VanillaJigsawExportRequest request, + VanillaJigsawExportUnit unit, CompiledStructureGraph graph ) throws IOException { Map resources = new LinkedHashMap<>(); - putJson(resources, "pack.mcmeta", packMetadata(request)); - putJson(resources, biomeTagPath(request), biomeTag(request)); - putJson(resources, processorPath(request), processorList()); - putJson(resources, structurePath(request), structure(request, graph.getStructure())); - putJson(resources, structureSetPath(request), structureSet(request)); + putJson(resources, biomeTagPath(unit.namespace(), unit.resourcePath()), biomeTag(unit)); + putJson(resources, processorPath(unit), processorList()); + putJson(resources, structurePath(unit.namespace(), unit.resourcePath()), structure(unit, graph.getStructure())); + putJson(resources, structureSetPath(unit.namespace(), unit.resourcePath()), structureSet(unit)); for (Map.Entry poolEntry : graph.getPools().entrySet()) { putJson(resources, - poolPath(request, poolEntry.getKey()), - templatePool(request, graph, poolEntry.getValue())); + poolPath(unit, poolEntry.getKey()), + templatePool(unit, graph, poolEntry.getValue())); } - VanillaStructureTemplateEncoder encoder = new VanillaStructureTemplateEncoder(); + StructureTemplateEncoder encoder = new StructureTemplateEncoder(); Map workcells = resolvedWorkcells(graph.getStructure()); for (Map.Entry pieceEntry : graph.getPieces().entrySet()) { @@ -571,41 +612,53 @@ final class VanillaJigsawExportCompiler { continue; } IrisObject object = graph.getObjects().get(trim(pieceEntry.getValue().getObject())); - byte[] template = encoder.encode( - object, - pieceEntry.getValue(), - poolKey -> poolIdentifier(request, trim(poolKey))); - resources.put(templatePath(request, pieceEntry.getKey()), template); + byte[] template = encoder.encode(object, connectorBlocks(unit, pieceEntry.getValue()), target); + resources.put(templatePath(unit, pieceEntry.getKey()), template); } return resources; } - private JsonObject packMetadata(VanillaJigsawExportRequest request) { - JsonObject pack = new JsonObject(); - pack.addProperty("description", request.description()); - if (DataVersion.getRuntime() == DataVersion.V1_21_1) { - pack.addProperty("pack_format", DataVersion.V1_21_1.getPackFormat()); - JsonObject root = new JsonObject(); - root.add("pack", pack); - return root; + private List connectorBlocks(VanillaJigsawExportUnit unit, IrisJigsawPiece piece) { + List blocks = new ArrayList<>(piece.getConnectors().size()); + for (IrisJigsawConnector connector : piece.getConnectors()) { + IrisPosition position = connector.getPosition(); + VanillaBlockState state = VanillaBlockState.parse( + "minecraft:jigsaw[orientation=" + orientation(connector.getDirection(), connector.getTop()) + "]"); + CompoundTag nbt = connectorNbt(connector, poolIdentifier(unit, trim(connector.getPool()))); + blocks.add(new TemplateBlock(position.getX(), position.getY(), position.getZ(), state, nbt)); } - JsonArray minimum = new JsonArray(); - boolean current = VanillaStructureTemplateEncoder.targets263(); - int packFormat = current ? 121 : 107; - minimum.add(packFormat); - minimum.add(current ? 0 : 1); - pack.add("min_format", minimum); - pack.addProperty("max_format", packFormat); - JsonObject root = new JsonObject(); - root.add("pack", pack); - return root; + return blocks; } - private JsonObject biomeTag(VanillaJigsawExportRequest request) { + private CompoundTag connectorNbt(IrisJigsawConnector connector, String poolIdentifier) { + CompoundTag nbt = new CompoundTag(); + nbt.putString("id", "minecraft:jigsaw"); + nbt.putString("name", VanillaResourceIdentifier.normalizeConnectorIdentifier(connector.getName())); + nbt.putString("target", VanillaResourceIdentifier.normalizeConnectorIdentifier(connector.getTargetName())); + nbt.putString("pool", poolIdentifier); + nbt.putString("final_state", VanillaBlockState.parse(connector.getFinalState()).canonical()); + nbt.putString("joint", connector.getJoint().name().toLowerCase(Locale.ROOT)); + nbt.putInt("selection_priority", connector.getSelectionPriority()); + nbt.putInt("placement_priority", connector.getPlacementPriority()); + return nbt; + } + + private static String directionName(IrisDirection direction) { + return switch (direction) { + case UP_POSITIVE_Y -> "up"; + case DOWN_NEGATIVE_Y -> "down"; + case NORTH_NEGATIVE_Z -> "north"; + case SOUTH_POSITIVE_Z -> "south"; + case EAST_POSITIVE_X -> "east"; + case WEST_NEGATIVE_X -> "west"; + }; + } + + private JsonObject biomeTag(VanillaJigsawExportUnit unit) { JsonObject root = new JsonObject(); root.addProperty("replace", false); JsonArray values = new JsonArray(); - for (String biome : request.settings().biomes()) { + for (String biome : unit.settings().biomes()) { values.add(biome); } root.add("values", values); @@ -618,11 +671,11 @@ final class VanillaJigsawExportCompiler { return root; } - private JsonObject structure(VanillaJigsawExportRequest request, IrisStructure structure) { - VanillaJigsawExportSettings settings = request.settings(); + private JsonObject structure(VanillaJigsawExportUnit unit, IrisStructure structure) { + VanillaJigsawExportSettings settings = unit.settings(); JsonObject root = new JsonObject(); root.addProperty("type", "minecraft:jigsaw"); - root.addProperty("biomes", "#" + request.namespace() + ":" + request.resourcePath()); + root.addProperty("biomes", "#" + unit.namespace() + ":" + unit.resourcePath()); int horizontalDistance = structure.getMaxSizeChunks() * 16; if (horizontalDistance == settings.maxDistanceVertical()) { root.addProperty("max_distance_from_center", horizontalDistance); @@ -640,21 +693,21 @@ final class VanillaJigsawExportCompiler { JsonObject startHeight = new JsonObject(); startHeight.addProperty("absolute", settings.startHeight()); root.add("start_height", startHeight); - root.addProperty("start_pool", poolIdentifier(request, trim(structure.getStartPool()))); + root.addProperty("start_pool", poolIdentifier(unit, trim(structure.getStartPool()))); root.addProperty("step", settings.generationStep().serializedName()); root.addProperty("terrain_adaptation", settings.terrainAdaptation().serializedName()); root.addProperty("use_expansion_hack", settings.expansionHack()); return root; } - private JsonObject structureSet(VanillaJigsawExportRequest request) { + private JsonObject structureSet(VanillaJigsawExportUnit unit) { JsonObject structureEntry = new JsonObject(); - structureEntry.addProperty("structure", structureIdentifier(request)); + structureEntry.addProperty("structure", structureIdentifier(unit)); structureEntry.addProperty("weight", 1); JsonArray structures = new JsonArray(); structures.add(structureEntry); - VanillaJigsawExportSettings settings = request.settings(); + VanillaJigsawExportSettings settings = unit.settings(); JsonObject placement = new JsonObject(); placement.addProperty("type", "minecraft:random_spread"); placement.addProperty("frequency", settings.frequency()); @@ -670,13 +723,13 @@ final class VanillaJigsawExportCompiler { } private JsonObject templatePool( - VanillaJigsawExportRequest request, + VanillaJigsawExportUnit unit, CompiledStructureGraph graph, IrisJigsawPool pool ) { JsonObject root = new JsonObject(); String fallback = trim(pool.getFallback()); - root.addProperty("fallback", fallback.isEmpty() ? "minecraft:empty" : poolIdentifier(request, fallback)); + root.addProperty("fallback", fallback.isEmpty() ? "minecraft:empty" : poolIdentifier(unit, fallback)); JsonArray elements = new JsonArray(); Map workcells = resolvedWorkcells(graph.getStructure()); @@ -692,8 +745,8 @@ final class VanillaJigsawExportCompiler { element.addProperty("element_type", "minecraft:empty_pool_element"); } else { element.addProperty("element_type", "minecraft:single_pool_element"); - element.addProperty("location", templateIdentifier(request, trim(entry.getPiece()))); - element.addProperty("processors", processorIdentifier(request)); + element.addProperty("location", templateIdentifier(unit, trim(entry.getPiece()))); + element.addProperty("processors", processorIdentifier(unit)); element.addProperty("projection", "rigid"); } weightedElement.add("element", element); @@ -725,47 +778,35 @@ final class VanillaJigsawExportCompiler { resources.put(path, (GSON.toJson(value) + "\n").getBytes(StandardCharsets.UTF_8)); } - private String biomeTagPath(VanillaJigsawExportRequest request) { - return "data/" + request.namespace() + "/tags/worldgen/biome/" + request.resourcePath() + ".json"; + private String processorPath(VanillaJigsawExportUnit unit) { + return "data/" + unit.namespace() + "/worldgen/processor_list/" + + unit.resourcePath() + "/empty.json"; } - private String processorPath(VanillaJigsawExportRequest request) { - return "data/" + request.namespace() + "/worldgen/processor_list/" - + request.resourcePath() + "/empty.json"; + private String poolPath(VanillaJigsawExportUnit unit, String poolKey) { + return "data/" + unit.namespace() + "/worldgen/template_pool/" + + unit.resourcePath() + "/pool/" + poolKey + ".json"; } - private String structurePath(VanillaJigsawExportRequest request) { - return "data/" + request.namespace() + "/worldgen/structure/" + request.resourcePath() + ".json"; + private String templatePath(VanillaJigsawExportUnit unit, String pieceKey) { + return "data/" + unit.namespace() + "/structure/" + + unit.resourcePath() + "/piece/" + pieceKey + ".nbt"; } - private String structureSetPath(VanillaJigsawExportRequest request) { - return "data/" + request.namespace() + "/worldgen/structure_set/" + request.resourcePath() + ".json"; + private String structureIdentifier(VanillaJigsawExportUnit unit) { + return unit.namespace() + ":" + unit.resourcePath(); } - private String poolPath(VanillaJigsawExportRequest request, String poolKey) { - return "data/" + request.namespace() + "/worldgen/template_pool/" - + request.resourcePath() + "/pool/" + poolKey + ".json"; + private String processorIdentifier(VanillaJigsawExportUnit unit) { + return unit.namespace() + ":" + unit.resourcePath() + "/empty"; } - private String templatePath(VanillaJigsawExportRequest request, String pieceKey) { - return "data/" + request.namespace() + "/structure/" - + request.resourcePath() + "/piece/" + pieceKey + ".nbt"; + private String poolIdentifier(VanillaJigsawExportUnit unit, String poolKey) { + return unit.namespace() + ":" + unit.resourcePath() + "/pool/" + poolKey; } - private String structureIdentifier(VanillaJigsawExportRequest request) { - return request.namespace() + ":" + request.resourcePath(); - } - - private String processorIdentifier(VanillaJigsawExportRequest request) { - return request.namespace() + ":" + request.resourcePath() + "/empty"; - } - - private String poolIdentifier(VanillaJigsawExportRequest request, String poolKey) { - return request.namespace() + ":" + request.resourcePath() + "/pool/" + poolKey; - } - - private String templateIdentifier(VanillaJigsawExportRequest request, String pieceKey) { - return request.namespace() + ":" + request.resourcePath() + "/piece/" + pieceKey; + private String templateIdentifier(VanillaJigsawExportUnit unit, String pieceKey) { + return unit.namespace() + ":" + unit.resourcePath() + "/piece/" + pieceKey; } private boolean inside(IrisPosition position, IrisObject object) { @@ -809,16 +850,16 @@ final class VanillaJigsawExportCompiler { return value == null ? "" : value.trim(); } - record Compilation( + public record Compilation( Map resources, List diagnostics ) { - Compilation { + public Compilation { resources = Map.copyOf(resources); diagnostics = List.copyOf(diagnostics); } - boolean hasErrors() { + public boolean hasErrors() { for (VanillaJigsawExportDiagnostic diagnostic : diagnostics) { if (diagnostic.isBlocking()) { return true; diff --git a/core/src/main/java/art/arcane/iris/structure/export/VanillaJigsawExportFormat.java b/core/src/main/java/art/arcane/iris/structure/export/VanillaJigsawExportFormat.java deleted file mode 100644 index 0a252a257..000000000 --- a/core/src/main/java/art/arcane/iris/structure/export/VanillaJigsawExportFormat.java +++ /dev/null @@ -1,6 +0,0 @@ -package art.arcane.iris.structure.export; - -public enum VanillaJigsawExportFormat { - DIRECTORY, - ZIP -} diff --git a/core/src/main/java/art/arcane/iris/structure/export/VanillaJigsawExportRequest.java b/core/src/main/java/art/arcane/iris/structure/export/VanillaJigsawExportRequest.java index 0c93101c4..e397bad94 100644 --- a/core/src/main/java/art/arcane/iris/structure/export/VanillaJigsawExportRequest.java +++ b/core/src/main/java/art/arcane/iris/structure/export/VanillaJigsawExportRequest.java @@ -1,5 +1,7 @@ package art.arcane.iris.structure.export; +import art.arcane.iris.pack.datapack.export.DatapackExportFormat; + import java.nio.file.Path; import java.util.Objects; @@ -9,7 +11,7 @@ public final class VanillaJigsawExportRequest { private final String namespace; private final String resourcePath; private final String description; - private final VanillaJigsawExportFormat format; + private final DatapackExportFormat format; private final VanillaJigsawExportSettings settings; private final boolean replaceExisting; @@ -48,7 +50,7 @@ public final class VanillaJigsawExportRequest { return description; } - public VanillaJigsawExportFormat format() { + public DatapackExportFormat format() { return format; } @@ -60,13 +62,17 @@ public final class VanillaJigsawExportRequest { return replaceExisting; } + public VanillaJigsawExportUnit unit() { + return new VanillaJigsawExportUnit(source, namespace, resourcePath, settings); + } + public static final class Builder { private final VanillaJigsawExportSource source; private final Path output; private String namespace = "iris"; private String resourcePath; private String description = "Iris vanilla jigsaw export"; - private VanillaJigsawExportFormat format = VanillaJigsawExportFormat.DIRECTORY; + private DatapackExportFormat format = DatapackExportFormat.DIRECTORY; private VanillaJigsawExportSettings settings = VanillaJigsawExportSettings.defaults(); private boolean replaceExisting; @@ -91,7 +97,7 @@ public final class VanillaJigsawExportRequest { return this; } - public Builder format(VanillaJigsawExportFormat value) { + public Builder format(DatapackExportFormat value) { format = Objects.requireNonNull(value); return this; } diff --git a/core/src/main/java/art/arcane/iris/structure/export/VanillaJigsawExportUnit.java b/core/src/main/java/art/arcane/iris/structure/export/VanillaJigsawExportUnit.java new file mode 100644 index 000000000..04355d278 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/structure/export/VanillaJigsawExportUnit.java @@ -0,0 +1,17 @@ +package art.arcane.iris.structure.export; + +import java.util.Objects; + +public record VanillaJigsawExportUnit( + VanillaJigsawExportSource source, + String namespace, + String resourcePath, + VanillaJigsawExportSettings settings +) { + public VanillaJigsawExportUnit { + Objects.requireNonNull(source); + Objects.requireNonNull(namespace); + Objects.requireNonNull(resourcePath); + Objects.requireNonNull(settings); + } +} diff --git a/core/src/main/java/art/arcane/iris/structure/export/VanillaResourceIdentifier.java b/core/src/main/java/art/arcane/iris/structure/export/VanillaResourceIdentifier.java deleted file mode 100644 index 7487361a7..000000000 --- a/core/src/main/java/art/arcane/iris/structure/export/VanillaResourceIdentifier.java +++ /dev/null @@ -1,56 +0,0 @@ -package art.arcane.iris.structure.export; - -import java.util.regex.Pattern; - -final class VanillaResourceIdentifier { - private static final Pattern NAMESPACE = Pattern.compile("[a-z0-9_.-]+"); - private static final Pattern PATH = Pattern.compile("[a-z0-9/._-]+"); - - private VanillaResourceIdentifier() { - } - - static boolean validNamespace(String value) { - return value != null && NAMESPACE.matcher(value).matches(); - } - - static boolean validPath(String value) { - if (value == null - || !PATH.matcher(value).matches() - || value.startsWith("/") - || value.endsWith("/") - || value.contains("//")) { - return false; - } - for (String segment : value.split("/")) { - if (segment.equals(".") || segment.equals("..")) { - return false; - } - } - return true; - } - - static boolean validIdentifier(String value) { - if (value == null) { - return false; - } - int separator = value.indexOf(':'); - if (separator <= 0 || separator != value.lastIndexOf(':')) { - return false; - } - return validNamespace(value.substring(0, separator)) && validPath(value.substring(separator + 1)); - } - - static String normalizeConnectorIdentifier(String value) { - if (value == null || value.isBlank()) { - return "minecraft:empty"; - } - String normalized = value.trim(); - if (normalized.indexOf(':') < 0) { - normalized = "minecraft:" + normalized; - } - if (!validIdentifier(normalized)) { - throw new IllegalArgumentException("Invalid resource identifier '" + value + "'"); - } - return normalized; - } -} diff --git a/core/src/main/java/art/arcane/iris/structure/export/VanillaStructureTemplateEncoder.java b/core/src/main/java/art/arcane/iris/structure/export/VanillaStructureTemplateEncoder.java deleted file mode 100644 index ad3395168..000000000 --- a/core/src/main/java/art/arcane/iris/structure/export/VanillaStructureTemplateEncoder.java +++ /dev/null @@ -1,187 +0,0 @@ -package art.arcane.iris.structure.export; - -import art.arcane.iris.pack.value.IrisDirection; -import art.arcane.iris.spi.IrisPlatforms; -import art.arcane.volmlib.util.nbt.mca.MCABlockStateCodecSupport; -import art.arcane.iris.structure.jigsaw.IrisJigsawConnector; -import art.arcane.iris.structure.jigsaw.IrisJigsawPiece; -import art.arcane.iris.structure.object.IrisObject; -import art.arcane.iris.pack.value.IrisPosition; -import art.arcane.volmlib.nativelib.terrain.NativeBlockState; -import art.arcane.iris.generation.geometry.IrisBlockVector; -import art.arcane.volmlib.util.math.Vector3i; -import art.arcane.volmlib.util.nbt.io.NBTUtil; -import art.arcane.volmlib.util.nbt.io.NamedTag; -import art.arcane.volmlib.util.nbt.tag.CompoundTag; -import art.arcane.volmlib.util.nbt.tag.IntTag; -import art.arcane.volmlib.util.nbt.tag.ListTag; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.function.Function; - -final class VanillaStructureTemplateEncoder { - private static final int DATA_VERSION_26_2 = 4903; - private static final Comparator BLOCK_ORDER = Comparator - .comparingInt((BlockEntry entry) -> entry.position().y()) - .thenComparingInt(entry -> entry.position().x()) - .thenComparingInt(entry -> entry.position().z()); - - byte[] encode( - IrisObject object, - IrisJigsawPiece piece, - Function poolIdentifier - ) throws IOException { - Map blocks = objectBlocks(object); - addConnectors(blocks, piece, poolIdentifier); - List orderedBlocks = new ArrayList<>(blocks.values()); - orderedBlocks.sort(BLOCK_ORDER); - - Map paletteIndexes = new LinkedHashMap<>(); - List paletteStates = new ArrayList<>(); - for (BlockEntry block : orderedBlocks) { - String key = block.state().canonical(); - if (!paletteIndexes.containsKey(key)) { - paletteIndexes.put(key, paletteStates.size()); - paletteStates.add(block.state()); - } - } - - boolean lowercase = targets263(); - MCABlockStateCodecSupport.Format format = lowercase - ? MCABlockStateCodecSupport.Format.LOWERCASE : MCABlockStateCodecSupport.Format.CAPITALIZED; - CompoundTag root = new CompoundTag(); - root.put("size", intList(object.getW(), object.getH(), object.getD())); - root.put("palette", palette(paletteStates, format)); - root.put("blocks", blocks(orderedBlocks, paletteIndexes)); - root.put("entities", new ListTag<>(CompoundTag.class)); - root.putInt("DataVersion", "1.21.1".equals(IrisPlatforms.get().minecraftVersion()) - ? 3955 : lowercase ? 5023 : DATA_VERSION_26_2); - - ByteArrayOutputStream output = new ByteArrayOutputStream(); - NBTUtil.write(new NamedTag("", root), output, true); - return output.toByteArray(); - } - - static boolean targets263() { - String minecraftVersion = IrisPlatforms.get().minecraftVersion(); - return "26.3".equals(minecraftVersion) || "26.3.0".equals(minecraftVersion); - } - - private Map objectBlocks(IrisObject object) { - Map blocks = new LinkedHashMap<>(); - Vector3i center = object.getCenter(); - for (Map.Entry entry : object.getBlocks()) { - IrisBlockVector signed = entry.getKey(); - BlockPosition position = new BlockPosition( - signed.getBlockX() + center.getX(), - signed.getBlockY() + center.getY(), - signed.getBlockZ() + center.getZ()); - VanillaBlockState state = VanillaBlockState.parse(entry.getValue().key()); - blocks.put(position, new BlockEntry(position, state, null)); - } - return blocks; - } - - private void addConnectors( - Map blocks, - IrisJigsawPiece piece, - Function poolIdentifier - ) { - for (IrisJigsawConnector connector : piece.getConnectors()) { - IrisPosition sourcePosition = connector.getPosition(); - BlockPosition position = new BlockPosition( - sourcePosition.getX(), sourcePosition.getY(), sourcePosition.getZ()); - String orientation = orientation(connector.getDirection(), connector.getTop()); - VanillaBlockState state = VanillaBlockState.parse( - "minecraft:jigsaw[orientation=" + orientation + "]"); - CompoundTag nbt = connectorNbt(connector, poolIdentifier.apply(connector.getPool())); - blocks.put(position, new BlockEntry(position, state, nbt)); - } - } - - private CompoundTag connectorNbt(IrisJigsawConnector connector, String poolIdentifier) { - CompoundTag nbt = new CompoundTag(); - nbt.putString("id", "minecraft:jigsaw"); - nbt.putString("name", VanillaResourceIdentifier.normalizeConnectorIdentifier(connector.getName())); - nbt.putString("target", VanillaResourceIdentifier.normalizeConnectorIdentifier(connector.getTargetName())); - nbt.putString("pool", poolIdentifier); - nbt.putString("final_state", VanillaBlockState.parse(connector.getFinalState()).canonical()); - nbt.putString("joint", connector.getJoint().name().toLowerCase(Locale.ROOT)); - nbt.putInt("selection_priority", connector.getSelectionPriority()); - nbt.putInt("placement_priority", connector.getPlacementPriority()); - return nbt; - } - - static String orientation(IrisDirection front, IrisDirection top) { - if (front == IrisDirection.UP_POSITIVE_Y || front == IrisDirection.DOWN_NEGATIVE_Y) { - if (top == IrisDirection.NORTH_NEGATIVE_Z - || top == IrisDirection.SOUTH_POSITIVE_Z - || top == IrisDirection.EAST_POSITIVE_X - || top == IrisDirection.WEST_NEGATIVE_X) { - return directionName(front) + "_" + directionName(top); - } - throw new IllegalArgumentException("Vertical jigsaw fronts require a horizontal top direction"); - } - if (top != IrisDirection.UP_POSITIVE_Y) { - throw new IllegalArgumentException("Horizontal jigsaw fronts require an upward top direction"); - } - return directionName(front) + "_up"; - } - - private static String directionName(IrisDirection direction) { - return switch (direction) { - case UP_POSITIVE_Y -> "up"; - case DOWN_NEGATIVE_Y -> "down"; - case NORTH_NEGATIVE_Z -> "north"; - case SOUTH_POSITIVE_Z -> "south"; - case EAST_POSITIVE_X -> "east"; - case WEST_NEGATIVE_X -> "west"; - }; - } - - private ListTag palette(List states, MCABlockStateCodecSupport.Format format) { - ListTag palette = new ListTag<>(CompoundTag.class); - for (VanillaBlockState state : states) { - palette.add(state.toNbt(format)); - } - return palette; - } - - private ListTag blocks( - List entries, - Map paletteIndexes - ) { - ListTag blocks = new ListTag<>(CompoundTag.class); - for (BlockEntry entry : entries) { - CompoundTag block = new CompoundTag(); - block.put("pos", intList(entry.position().x(), entry.position().y(), entry.position().z())); - block.putInt("state", paletteIndexes.get(entry.state().canonical())); - if (entry.nbt() != null) { - block.put("nbt", entry.nbt()); - } - blocks.add(block); - } - return blocks; - } - - private ListTag intList(int... values) { - ListTag list = new ListTag<>(IntTag.class); - for (int value : values) { - list.add(new IntTag(value)); - } - return list; - } - - private record BlockPosition(int x, int y, int z) { - } - - private record BlockEntry(BlockPosition position, VanillaBlockState state, CompoundTag nbt) { - } -} diff --git a/core/src/main/resources/iris/datapack/pack.png b/core/src/main/resources/iris/datapack/pack.png new file mode 100644 index 000000000..aadf45975 Binary files /dev/null and b/core/src/main/resources/iris/datapack/pack.png differ diff --git a/core/src/main/resources/languages/de_DE.toml b/core/src/main/resources/languages/de_DE.toml index 48875f67f..1d5661865 100644 --- a/core/src/main/resources/languages/de_DE.toml +++ b/core/src/main/resources/languages/de_DE.toml @@ -434,6 +434,7 @@ operator_instruction = "Führe {command} in der Konsole aus oder aktiviere Cheat "entry.command.list_datapacks" = "Liste konfigurierter und installierter Datenpakete" "entry.command.ingest" = "Bukkit-Ablauf für den Datapack-Import erklären" "entry.command.remove" = "Erläutern Sie den Datapack-Entfernungsworkflow" +"entry.command.export_datapack" = "Eine Dimension als vanilla Worldgen-Datapack exportieren" "entry.command.list_structures" = "structure-index.json neu erstellen" "entry.command.info_2" = "Graph einer Iris-Struktur auflösen und ihre Grenzen anzeigen" "entry.command.place" = "Eine Iris-Struktur zusammensetzen und an deiner Position platzieren" @@ -1130,6 +1131,23 @@ replace_installed_types = "Installierte Packs: {value}" "director.list_configured_datapack_imports_their_installed_versions" = "Liste der konfigurierten Datenpaketimporte und ihrer installierten Versionen" "director.remove_installed_datapack_by_id_also_delete_its_url_from_datapackimports_keep" = "Entfernen Sie ein installiertes Datenpaket nach ID (löschen Sie auch die URL von datapackImports, um es zu entfernen)" "param.datapack_id_folder_name_shown_by_iris_datapack_list" = "Die Datapack-ID (Ordnername) wird angezeigt von /iris datapack list" +"director.export_dimension_vanilla_worldgen_datapack" = "Eine Dimension als vanilla Worldgen-Datapack exportieren" +"param.export_dimension" = "Die zu exportierende Dimension" +"param.export_version" = "Minecraft-Zielversion, 26.3 oder neuer (leer verwendet die neueste)" +"param.export_mode" = "Ausgabemodus: preset, dimension oder overworld" +"param.export_namespace" = "Namespace für die exportierten Ressourcen" +"param.export_output" = "Ausgabename im Datapack-Exportordner (leer verwendet Dimension und Version)" +"param.export_format" = "zip oder directory" +"param.export_objects" = "Objekte als Strukturvorlagen einbeziehen" +"param.export_replace" = "Einen vorhandenen Export mit demselben Namen ersetzen" +"export.started" = "{dimension} wird als Datapack für Minecraft {version} exportiert..." +"export.stage" = "Datapack-Export: {stage}" +"export.resources" = "Datapack-Export: {done}/{total} Ressourcen" +"export.diagnostic" = "{key}: {message}" +"export.exported" = "Datapack exportiert: {path}" +"export.rejected" = "Datapack-Export abgelehnt: {reason}" +"export.failed" = "Datapack-Export fehlgeschlagen: {reason}" +"export.invalid" = "Ungültige Argumente für den Datapack-Export: {reason}" [iris.director.commanddeveloper] "director.iris_world_manager" = "Iris-Weltverwaltung" diff --git a/core/src/main/resources/languages/es_ES.toml b/core/src/main/resources/languages/es_ES.toml index 5861ad541..469536b01 100644 --- a/core/src/main/resources/languages/es_ES.toml +++ b/core/src/main/resources/languages/es_ES.toml @@ -434,6 +434,7 @@ operator_instruction = "Ejecuta {command} desde la consola (o activa los trucos "entry.command.list_datapacks" = "Listar los datapacks configurados e instalados" "entry.command.ingest" = "Explicar el flujo de ingesta de datapacks de Bukkit" "entry.command.remove" = "Explicar el flujo de eliminación de datapacks" +"entry.command.export_datapack" = "Exportar una dimensión como datapack de generación vanilla" "entry.command.list_structures" = "Regenerar structure-index.json" "entry.command.info_2" = "Resolver el grafo de una estructura de Iris e informar de sus límites" "entry.command.place" = "Ensamblar y colocar una estructura de Iris en tu posición" @@ -1130,6 +1131,23 @@ replace_installed_types = "Packs instalados: {value}" "director.list_configured_datapack_imports_their_installed_versions" = "Listar las importaciones de datapacks configuradas y sus versiones instaladas" "director.remove_installed_datapack_by_id_also_delete_its_url_from_datapackimports_keep" = "Eliminar un datapack instalado por id (elimina también su URL de datapackImports para que no vuelva a instalarse)" "param.datapack_id_folder_name_shown_by_iris_datapack_list" = "El id del datapack (nombre de carpeta) que muestra /iris datapack list" +"director.export_dimension_vanilla_worldgen_datapack" = "Exportar una dimensión como datapack de generación vanilla" +"param.export_dimension" = "La dimensión que se exportará" +"param.export_version" = "Versión de Minecraft de destino, 26.3 o posterior (vacío usa la más reciente)" +"param.export_mode" = "Modo de salida: preset, dimension u overworld" +"param.export_namespace" = "Namespace de los recursos exportados" +"param.export_output" = "Nombre de salida en la carpeta de exportación de datapacks (vacío usa la dimensión y la versión)" +"param.export_format" = "zip o directory" +"param.export_objects" = "Incluir objetos como plantillas de estructura" +"param.export_replace" = "Reemplazar una exportación existente con el mismo nombre" +"export.started" = "Exportando {dimension} como datapack para Minecraft {version}..." +"export.stage" = "Exportación de datapack: {stage}" +"export.resources" = "Exportación de datapack: {done}/{total} recursos" +"export.diagnostic" = "{key}: {message}" +"export.exported" = "Datapack exportado: {path}" +"export.rejected" = "Exportación de datapack rechazada: {reason}" +"export.failed" = "La exportación de datapack falló: {reason}" +"export.invalid" = "Argumentos de exportación de datapack no válidos: {reason}" [iris.director.commanddeveloper] "director.iris_world_manager" = "Gestor de mundos de Iris" diff --git a/core/src/main/resources/languages/fi_FI.toml b/core/src/main/resources/languages/fi_FI.toml index 063fca9e1..cd1374c3c 100644 --- a/core/src/main/resources/languages/fi_FI.toml +++ b/core/src/main/resources/languages/fi_FI.toml @@ -439,6 +439,7 @@ operator_instruction = "Juokse {command} konsolilta (tai mahdollista huijaukset "entry.command.list_datapacks" = "Listaa määritetyt ja asennetut datapaketit" "entry.command.ingest" = "Selitä Bukkit datapackin suurin työnkulku" "entry.command.remove" = "Selitä datapaketin poistotyövirta" +"entry.command.export_datapack" = "Vie dimensio vanilla-maailmangeneroinnin datapakettina" "entry.command.list_structures" = "Uusi structure-index.json" "entry.command.info_2" = "Ratkaise a Iris rakennekaavio ja raportin raja-arvot" "entry.command.place" = "Kokoa ja aseta Iris sijaintisi rakenne" @@ -1135,6 +1136,23 @@ replace_installed_types = "Asennetut paketit: {value}" "director.list_configured_datapack_imports_their_installed_versions" = "Listaa konfiguroitu datapaketin tuonti ja sen asennetut versiot" "director.remove_installed_datapack_by_id_also_delete_its_url_from_datapackimports_keep" = "Poista asennettu datapaketti ID:llä (poistaa myös sen URL alkaen datapackImports pitää se poissa)" "param.datapack_id_folder_name_shown_by_iris_datapack_list" = "Datapaketin tunniste (kansionimi) /iris datapack list" +"director.export_dimension_vanilla_worldgen_datapack" = "Vie dimensio vanilla-maailmangeneroinnin datapakettina" +"param.export_dimension" = "Vietävä dimensio" +"param.export_version" = "Kohteena oleva Minecraft-versio, 26.3 tai uudempi (tyhjä käyttää uusinta)" +"param.export_mode" = "Tulostustila: preset, dimension tai overworld" +"param.export_namespace" = "Vietyjen resurssien nimiavaruus" +"param.export_output" = "Tulosteen nimi datapakettien vientikansiossa (tyhjä käyttää dimensiota ja versiota)" +"param.export_format" = "zip tai directory" +"param.export_objects" = "Sisällytä objektit rakennemalleina" +"param.export_replace" = "Korvaa olemassa oleva samanniminen vienti" +"export.started" = "Viedään {dimension} datapakettina Minecraft {version} -versiolle..." +"export.stage" = "Datapaketin vienti: {stage}" +"export.resources" = "Datapaketin vienti: {done}/{total} resurssia" +"export.diagnostic" = "{key}: {message}" +"export.exported" = "Datapaketti viety: {path}" +"export.rejected" = "Datapaketin vienti hylättiin: {reason}" +"export.failed" = "Datapaketin vienti epäonnistui: {reason}" +"export.invalid" = "Virheelliset datapaketin vientiargumentit: {reason}" [iris.director.commanddeveloper] "director.iris_world_manager" = "Iris Maailmanjohtaja" diff --git a/core/src/main/resources/languages/fr_FR.toml b/core/src/main/resources/languages/fr_FR.toml index 6eedd4121..a9ed41314 100644 --- a/core/src/main/resources/languages/fr_FR.toml +++ b/core/src/main/resources/languages/fr_FR.toml @@ -434,6 +434,7 @@ operator_instruction = "Exécutez {command} depuis la console (ou activez les co "entry.command.list_datapacks" = "Lister les datapacks configurés et installés" "entry.command.ingest" = "Expliquer le flux d'ingestion des datapacks Bukkit" "entry.command.remove" = "Expliquer le flux de suppression des datapacks" +"entry.command.export_datapack" = "Exporter une dimension sous forme de datapack de génération vanilla" "entry.command.list_structures" = "Régénérer structure-index.json" "entry.command.info_2" = "Résoudre le graphe d'une structure Iris et afficher ses limites" "entry.command.place" = "Assembler et placer une structure Iris à votre position" @@ -1130,6 +1131,23 @@ replace_installed_types = "Packs installés : {value}" "director.list_configured_datapack_imports_their_installed_versions" = "Répertorier les importations de datapacks configurées et leurs versions installées" "director.remove_installed_datapack_by_id_also_delete_its_url_from_datapackimports_keep" = "Supprimer un datapack installé par id (supprime aussi son URL de datapackImports pour empêcher sa réinstallation)" "param.datapack_id_folder_name_shown_by_iris_datapack_list" = "L'id du datapack (nom du dossier) affiché par /iris datapack list" +"director.export_dimension_vanilla_worldgen_datapack" = "Exporter une dimension sous forme de datapack de génération vanilla" +"param.export_dimension" = "La dimension à exporter" +"param.export_version" = "Version Minecraft cible, 26.3 ou ultérieure (vide utilise la plus récente)" +"param.export_mode" = "Mode de sortie : preset, dimension ou overworld" +"param.export_namespace" = "Namespace des ressources exportées" +"param.export_output" = "Nom de sortie dans le dossier d'export des datapacks (vide utilise la dimension et la version)" +"param.export_format" = "zip ou directory" +"param.export_objects" = "Inclure les objets comme modèles de structure" +"param.export_replace" = "Remplacer un export existant portant le même nom" +"export.started" = "Export de {dimension} en datapack pour Minecraft {version}..." +"export.stage" = "Export du datapack : {stage}" +"export.resources" = "Export du datapack : {done}/{total} ressources" +"export.diagnostic" = "{key}: {message}" +"export.exported" = "Datapack exporté : {path}" +"export.rejected" = "Export du datapack refusé : {reason}" +"export.failed" = "L'export du datapack a échoué : {reason}" +"export.invalid" = "Arguments d'export de datapack invalides : {reason}" [iris.director.commanddeveloper] "director.iris_world_manager" = "Gestionnaire de mondes Iris" diff --git a/core/src/main/resources/languages/he_IL.toml b/core/src/main/resources/languages/he_IL.toml index aaa89cd06..56743f86d 100644 --- a/core/src/main/resources/languages/he_IL.toml +++ b/core/src/main/resources/languages/he_IL.toml @@ -439,6 +439,7 @@ operator_instruction = "לרוץ {command} מן הקונסולה (או לאפש "entry.command.list_datapacks" = "רשימה מוגדרת ומותקנת ערכות נתונים" "entry.command.ingest" = "להסביר Bukkit Datapackingest תהליך עבודה" "entry.command.remove" = "הסבר את זרימת העבודה להסרת נתונים" +"entry.command.export_datapack" = "ייצוא ממד כ-datapack של יצירת עולם vanilla" "entry.command.list_structures" = "חידוש structure-index.json" "entry.command.info_2" = "לפתור Iris מבנה גרפי ודיווח" "entry.command.place" = "להרכיב ולמקם Iris מבנה במיקום שלך" @@ -1135,6 +1136,23 @@ replace_installed_types = "חבילות מותקנות: {value}" "director.list_configured_datapack_imports_their_installed_versions" = "רשימת ייבוא datapack והגרסאות המותקנות שלהם" "director.remove_installed_datapack_by_id_also_delete_its_url_from_datapackimports_keep" = "להסיר חבילת נתונים מותקנת על ידי id (גם למחוק אותה URL מתוך datapackImports כדי להשאיר את זה נעלם)" "param.datapack_id_folder_name_shown_by_iris_datapack_list" = "חבילת הנתונים id (שם כפול) מוצג על ידי /iris datapack list" +"director.export_dimension_vanilla_worldgen_datapack" = "ייצוא ממד כ-datapack של יצירת עולם vanilla" +"param.export_dimension" = "הממד לייצוא" +"param.export_version" = "גרסת Minecraft יעד, 26.3 ומעלה (ריק משתמש בגרסה העדכנית)" +"param.export_mode" = "מצב פלט: preset, dimension או overworld" +"param.export_namespace" = "מרחב שמות למשאבים המיוצאים" +"param.export_output" = "שם הפלט בתיקיית ייצוא ה-datapack (ריק משתמש בממד ובגרסה)" +"param.export_format" = "zip או directory" +"param.export_objects" = "לכלול אובייקטים כתבניות מבנה" +"param.export_replace" = "להחליף ייצוא קיים באותו שם" +"export.started" = "מייצא את {dimension} כ-datapack עבור Minecraft {version}..." +"export.stage" = "ייצוא datapack: {stage}" +"export.resources" = "ייצוא datapack: {done}/{total} משאבים" +"export.diagnostic" = "{key}: {message}" +"export.exported" = "ה-datapack יוצא: {path}" +"export.rejected" = "ייצוא ה-datapack נדחה: {reason}" +"export.failed" = "ייצוא ה-datapack נכשל: {reason}" +"export.invalid" = "ארגומנטים לא תקינים לייצוא datapack: {reason}" [iris.director.commanddeveloper] "director.iris_world_manager" = "Iris מנהל עולמי" diff --git a/core/src/main/resources/languages/it_IT.toml b/core/src/main/resources/languages/it_IT.toml index 290a26fc6..8ed0a1f8a 100644 --- a/core/src/main/resources/languages/it_IT.toml +++ b/core/src/main/resources/languages/it_IT.toml @@ -434,6 +434,7 @@ operator_instruction = "Esegui {command} dalla console oppure abilita i trucchi "entry.command.list_datapacks" = "Elenca i datapack configurati e installati" "entry.command.ingest" = "Spiega il flusso Bukkit per l'importazione dei datapack" "entry.command.remove" = "Spiegare il flusso di lavoro di rimozione del datapack" +"entry.command.export_datapack" = "Esporta una dimensione come datapack di generazione vanilla" "entry.command.list_structures" = "Rigenera structure-index.json" "entry.command.info_2" = "Risolvi il grafo di una struttura Iris e mostrane i limiti" "entry.command.place" = "Assembla e posiziona una struttura Iris nella tua posizione" @@ -1130,6 +1131,23 @@ replace_installed_types = "Pack installati: {value}" "director.list_configured_datapack_imports_their_installed_versions" = "Elenca le importazioni di datapack configurate e le loro versioni installate" "director.remove_installed_datapack_by_id_also_delete_its_url_from_datapackimports_keep" = "Rimuovere un datapack installato da id (eliminarne anche URL da datapackImports per tenerlo lontano)" "param.datapack_id_folder_name_shown_by_iris_datapack_list" = "Il datapack id (nome della cartella) indicato da /iris datapack list" +"director.export_dimension_vanilla_worldgen_datapack" = "Esporta una dimensione come datapack di generazione vanilla" +"param.export_dimension" = "La dimensione da esportare" +"param.export_version" = "Versione di Minecraft di destinazione, 26.3 o successiva (vuoto usa la più recente)" +"param.export_mode" = "Modalità di output: preset, dimension o overworld" +"param.export_namespace" = "Namespace delle risorse esportate" +"param.export_output" = "Nome di output nella cartella di esportazione dei datapack (vuoto usa dimensione e versione)" +"param.export_format" = "zip o directory" +"param.export_objects" = "Includi gli oggetti come modelli di struttura" +"param.export_replace" = "Sostituisci un'esportazione esistente con lo stesso nome" +"export.started" = "Esportazione di {dimension} come datapack per Minecraft {version}..." +"export.stage" = "Esportazione datapack: {stage}" +"export.resources" = "Esportazione datapack: {done}/{total} risorse" +"export.diagnostic" = "{key}: {message}" +"export.exported" = "Datapack esportato: {path}" +"export.rejected" = "Esportazione datapack rifiutata: {reason}" +"export.failed" = "Esportazione datapack non riuscita: {reason}" +"export.invalid" = "Argomenti di esportazione datapack non validi: {reason}" [iris.director.commanddeveloper] "director.iris_world_manager" = "Gestore dei mondi Iris" diff --git a/core/src/main/resources/languages/ja-JP.toml b/core/src/main/resources/languages/ja-JP.toml index 06f31c1a4..7099aa685 100644 --- a/core/src/main/resources/languages/ja-JP.toml +++ b/core/src/main/resources/languages/ja-JP.toml @@ -437,6 +437,7 @@ operator_instruction = "コンソールから {command} を実行してくださ "entry.command.list_datapacks" = "設定済みおよびインストール済みのデータパックを一覧表示します" "entry.command.ingest" = "Bukkit のデータパック取り込み手順を説明します" "entry.command.remove" = "データパックの削除手順を説明します" +"entry.command.export_datapack" = "ディメンションをバニラのワールド生成データパックとしてエクスポートします" "entry.command.list_structures" = "structure-index.json を再生成します" "entry.command.info_2" = "Iris 構造物グラフを解決し、境界を表示します" "entry.command.place" = "現在位置に Iris 構造物を組み立てて配置します" @@ -1133,6 +1134,23 @@ replace_installed_types = "インストール済みのパック: {value}" "director.list_configured_datapack_imports_their_installed_versions" = "設定済みのデータパックインポートとインストール済みバージョンを一覧表示します" "director.remove_installed_datapack_by_id_also_delete_its_url_from_datapackimports_keep" = "ID を指定してインストール済みデータパックを削除します(再インストールを防ぐには datapackImports から URL も削除してください)" "param.datapack_id_folder_name_shown_by_iris_datapack_list" = "/iris datapack list に表示されるデータパック ID(フォルダー名)" +"director.export_dimension_vanilla_worldgen_datapack" = "ディメンションをバニラのワールド生成データパックとしてエクスポートします" +"param.export_dimension" = "エクスポートするディメンション" +"param.export_version" = "対象の Minecraft バージョン、26.3 以降(空欄の場合は最新)" +"param.export_mode" = "出力モード: preset、dimension、overworld のいずれか" +"param.export_namespace" = "エクスポートするリソースの名前空間" +"param.export_output" = "データパックのエクスポートフォルダー内の出力名(空欄の場合はディメンションとバージョンを使用)" +"param.export_format" = "zip または directory" +"param.export_objects" = "オブジェクトをストラクチャーテンプレートとして含めます" +"param.export_replace" = "同じ名前の既存のエクスポートを置き換えます" +"export.started" = "{dimension} を Minecraft {version} 用のデータパックとしてエクスポートしています..." +"export.stage" = "データパックのエクスポート: {stage}" +"export.resources" = "データパックのエクスポート: {done}/{total} リソース" +"export.diagnostic" = "{key}: {message}" +"export.exported" = "データパックをエクスポートしました: {path}" +"export.rejected" = "データパックのエクスポートは拒否されました: {reason}" +"export.failed" = "データパックのエクスポートに失敗しました: {reason}" +"export.invalid" = "データパックのエクスポート引数が無効です: {reason}" [iris.director.commanddeveloper] "director.iris_world_manager" = "Iris ワールドマネージャー" diff --git a/core/src/main/resources/languages/ko_KR.toml b/core/src/main/resources/languages/ko_KR.toml index 9523b463d..f7ba6d887 100644 --- a/core/src/main/resources/languages/ko_KR.toml +++ b/core/src/main/resources/languages/ko_KR.toml @@ -437,6 +437,7 @@ operator_instruction = "콘솔에서 {command}을(를) 실행하세요(싱글 "entry.command.list_datapacks" = "설정되고 설치된 데이터 팩을 나열합니다" "entry.command.ingest" = "Bukkit 데이터 팩 수집 절차를 설명합니다" "entry.command.remove" = "데이터 팩 제거 절차를 설명합니다" +"entry.command.export_datapack" = "차원을 바닐라 월드 생성 데이터 팩으로 내보냅니다" "entry.command.list_structures" = "structure-index.json 다시 생성" "entry.command.info_2" = "Iris 구조물 그래프를 해석하고 경계를 보고합니다" "entry.command.place" = "현재 위치에 Iris 구조물을 조립해 배치합니다" @@ -1133,6 +1134,23 @@ replace_installed_types = "설치된 팩: {value}" "director.list_configured_datapack_imports_their_installed_versions" = "설정된 데이터 팩 가져오기와 설치된 버전을 나열합니다" "director.remove_installed_datapack_by_id_also_delete_its_url_from_datapackimports_keep" = "ID로 설치된 데이터 팩을 제거합니다(다시 설치되지 않게 하려면 datapackImports에서 URL도 삭제하세요)" "param.datapack_id_folder_name_shown_by_iris_datapack_list" = "/iris datapack list에 표시되는 데이터 팩 ID(폴더 이름)" +"director.export_dimension_vanilla_worldgen_datapack" = "차원을 바닐라 월드 생성 데이터 팩으로 내보냅니다" +"param.export_dimension" = "내보낼 차원" +"param.export_version" = "대상 Minecraft 버전, 26.3 이상(비워 두면 최신 버전)" +"param.export_mode" = "출력 모드: preset, dimension 또는 overworld" +"param.export_namespace" = "내보낸 리소스의 네임스페이스" +"param.export_output" = "데이터 팩 내보내기 폴더의 출력 이름(비워 두면 차원과 버전 사용)" +"param.export_format" = "zip 또는 directory" +"param.export_objects" = "오브젝트를 구조물 템플릿으로 포함" +"param.export_replace" = "같은 이름의 기존 내보내기를 교체" +"export.started" = "{dimension}을(를) Minecraft {version}용 데이터 팩으로 내보내는 중..." +"export.stage" = "데이터 팩 내보내기: {stage}" +"export.resources" = "데이터 팩 내보내기: {done}/{total}개 리소스" +"export.diagnostic" = "{key}: {message}" +"export.exported" = "데이터 팩을 내보냈습니다: {path}" +"export.rejected" = "데이터 팩 내보내기가 거부되었습니다: {reason}" +"export.failed" = "데이터 팩 내보내기에 실패했습니다: {reason}" +"export.invalid" = "데이터 팩 내보내기 인수가 잘못되었습니다: {reason}" [iris.director.commanddeveloper] "director.iris_world_manager" = "Iris 월드 관리자" diff --git a/core/src/main/resources/languages/lt_LT.toml b/core/src/main/resources/languages/lt_LT.toml index b99b5c6b0..7b74ccdb5 100644 --- a/core/src/main/resources/languages/lt_LT.toml +++ b/core/src/main/resources/languages/lt_LT.toml @@ -439,6 +439,7 @@ operator_instruction = "Vykdyti {command} iš konsolės (arba leidžia cheats į "entry.command.list_datapacks" = "Sukonfigūruotų ir įdiegtų duomenų aplankų sąrašas" "entry.command.ingest" = "Paaiškinti Bukkit @ info: tooltip" "entry.command.remove" = "Išaiškinti duomenų kaupimo šalinimo darbų srautą" +"entry.command.export_datapack" = "Eksportuoti dimensiją kaip vanilla pasaulio generavimo datapack" "entry.command.list_structures" = "Percentuoti structure-index.json" "entry.command.info_2" = "Išspręsti Iris struktūros grafikas ir ataskaitos ribos" "entry.command.place" = "Surinkti ir vieta Iris struktūra jūsų vietoje" @@ -1135,6 +1136,23 @@ replace_installed_types = "Įdiegti paketai: {value}" "director.list_configured_datapack_imports_their_installed_versions" = "Sąrašas sukonfigūruotų duomenų apie importą ir jų įdiegtas versijas" "director.remove_installed_datapack_by_id_also_delete_its_url_from_datapackimports_keep" = "Pašalinti įdiegtą datapack pagal id (taip pat ištrinti savo URL nuo datapackImports išlaikyti jį dingo)" "param.datapack_id_folder_name_shown_by_iris_datapack_list" = "Datapack id (aplanko pavadinimas) rodo /iris datapack list" +"director.export_dimension_vanilla_worldgen_datapack" = "Eksportuoti dimensiją kaip vanilla pasaulio generavimo datapack" +"param.export_dimension" = "Eksportuojama dimensija" +"param.export_version" = "Tikslinė Minecraft versija, 26.3 arba naujesnė (tuščia naudoja naujausią)" +"param.export_mode" = "Išvesties režimas: preset, dimension arba overworld" +"param.export_namespace" = "Eksportuotų išteklių vardų sritis" +"param.export_output" = "Išvesties pavadinimas datapack eksporto aplanke (tuščias naudoja dimensiją ir versiją)" +"param.export_format" = "zip arba directory" +"param.export_objects" = "Įtraukti objektus kaip struktūrų šablonus" +"param.export_replace" = "Pakeisti esamą eksportą tuo pačiu pavadinimu" +"export.started" = "{dimension} eksportuojama kaip datapack, skirtas Minecraft {version}..." +"export.stage" = "Datapack eksportas: {stage}" +"export.resources" = "Datapack eksportas: {done}/{total} išteklių" +"export.diagnostic" = "{key}: {message}" +"export.exported" = "Datapack eksportuotas: {path}" +"export.rejected" = "Datapack eksportas atmestas: {reason}" +"export.failed" = "Datapack eksportas nepavyko: {reason}" +"export.invalid" = "Netinkami datapack eksporto argumentai: {reason}" [iris.director.commanddeveloper] "director.iris_world_manager" = "Iris Pasaulio vadybininkas" diff --git a/core/src/main/resources/languages/nl_NL.toml b/core/src/main/resources/languages/nl_NL.toml index c10bbc442..2d4d35c15 100644 --- a/core/src/main/resources/languages/nl_NL.toml +++ b/core/src/main/resources/languages/nl_NL.toml @@ -434,6 +434,7 @@ operator_instruction = "Start {command} vanaf de console (of schakel bedrog in s "entry.command.list_datapacks" = "Lijst geconfigureerde en geïnstalleerde datapacks" "entry.command.ingest" = "Leg uit Bukkit datapack opgenomen workflow" "entry.command.remove" = "Leg uit datpack verwijdering workflow" +"entry.command.export_datapack" = "Een dimensie exporteren als vanilla wereldgeneratie-datapack" "entry.command.list_structures" = "Regeneren structure-index.json" "entry.command.info_2" = "Los een Iris structuurgrafiek en rapportagegrenzen" "entry.command.place" = "Verzamel en plaats een Iris structuur op uw locatie" @@ -1130,6 +1131,23 @@ replace_installed_types = "Geïnstalleerde packs: {value}" "director.list_configured_datapack_imports_their_installed_versions" = "Lijst geconfigureerde datapack imports en hun geïnstalleerde versies" "director.remove_installed_datapack_by_id_also_delete_its_url_from_datapackimports_keep" = "Verwijder een geïnstalleerde datapack door id (ook verwijderen van de URL van datapackImports om het weg te houden)" "param.datapack_id_folder_name_shown_by_iris_datapack_list" = "Het datapack id (mapnaam) getoond door /iris datapack list" +"director.export_dimension_vanilla_worldgen_datapack" = "Een dimensie exporteren als vanilla wereldgeneratie-datapack" +"param.export_dimension" = "De dimensie om te exporteren" +"param.export_version" = "Doelversie van Minecraft, 26.3 of nieuwer (leeg gebruikt de nieuwste)" +"param.export_mode" = "Uitvoermodus: preset, dimension of overworld" +"param.export_namespace" = "Namespace voor de geëxporteerde resources" +"param.export_output" = "Uitvoernaam in de exportmap voor datapacks (leeg gebruikt de dimensie en versie)" +"param.export_format" = "zip of directory" +"param.export_objects" = "Objecten opnemen als structuursjablonen" +"param.export_replace" = "Een bestaande export met dezelfde naam vervangen" +"export.started" = "{dimension} wordt geëxporteerd als datapack voor Minecraft {version}..." +"export.stage" = "Datapack-export: {stage}" +"export.resources" = "Datapack-export: {done}/{total} resources" +"export.diagnostic" = "{key}: {message}" +"export.exported" = "Datapack geëxporteerd: {path}" +"export.rejected" = "Datapack-export geweigerd: {reason}" +"export.failed" = "Datapack-export mislukt: {reason}" +"export.invalid" = "Ongeldige argumenten voor datapack-export: {reason}" [iris.director.commanddeveloper] "director.iris_world_manager" = "Iris Wereldmanager" diff --git a/core/src/main/resources/languages/pl_PL.toml b/core/src/main/resources/languages/pl_PL.toml index c2bd4b35e..39f90bf3b 100644 --- a/core/src/main/resources/languages/pl_PL.toml +++ b/core/src/main/resources/languages/pl_PL.toml @@ -439,6 +439,7 @@ operator_instruction = "Biegnij {command} z konsoli (lub włączyć cheats w sin "entry.command.list_datapacks" = "Lista skonfigurowanych i zainstalowanych pakietów danych" "entry.command.ingest" = "Wyjaśnij Bukkit datapack połknięcie przepływu pracy" "entry.command.remove" = "Wyjaśnij proces usuwania datapack" +"entry.command.export_datapack" = "Eksportuj wymiar jako datapack generowania świata vanilla" "entry.command.list_structures" = "Regeneracja structure-index.json" "entry.command.info_2" = "Rozwiąż Iris wykres struktury i granice raportowania" "entry.command.place" = "Zmontuj i umieść Iris struktura w Twojej lokalizacji" @@ -1135,6 +1136,23 @@ replace_installed_types = "Zainstalowane paczki: {value}" "director.list_configured_datapack_imports_their_installed_versions" = "Lista skonfigurowanych importu danych i ich zainstalowanych wersji" "director.remove_installed_datapack_by_id_also_delete_its_url_from_datapackimports_keep" = "Usuń zainstalowany datapack przez id (również usunąć jego URL od datapackImports by go nie było)" "param.datapack_id_folder_name_shown_by_iris_datapack_list" = "Identyfikator datapack (nazwa folderu) pokazany przez /iris datapack list" +"director.export_dimension_vanilla_worldgen_datapack" = "Eksportuj wymiar jako datapack generowania świata vanilla" +"param.export_dimension" = "Wymiar do wyeksportowania" +"param.export_version" = "Docelowa wersja Minecraft, 26.3 lub nowsza (puste używa najnowszej)" +"param.export_mode" = "Tryb wyjścia: preset, dimension lub overworld" +"param.export_namespace" = "Przestrzeń nazw dla eksportowanych zasobów" +"param.export_output" = "Nazwa wyjściowa w folderze eksportu datapacków (puste używa wymiaru i wersji)" +"param.export_format" = "zip lub directory" +"param.export_objects" = "Dołącz obiekty jako szablony struktur" +"param.export_replace" = "Zastąp istniejący eksport o tej samej nazwie" +"export.started" = "Eksportowanie {dimension} jako datapack dla Minecraft {version}..." +"export.stage" = "Eksport datapacka: {stage}" +"export.resources" = "Eksport datapacka: {done}/{total} zasobów" +"export.diagnostic" = "{key}: {message}" +"export.exported" = "Wyeksportowano datapack: {path}" +"export.rejected" = "Eksport datapacka odrzucony: {reason}" +"export.failed" = "Eksport datapacka nie powiódł się: {reason}" +"export.invalid" = "Nieprawidłowe argumenty eksportu datapacka: {reason}" [iris.director.commanddeveloper] "director.iris_world_manager" = "Iris Światowy menedżer" diff --git a/core/src/main/resources/languages/pt_PT.toml b/core/src/main/resources/languages/pt_PT.toml index 48b651e52..d316170a9 100644 --- a/core/src/main/resources/languages/pt_PT.toml +++ b/core/src/main/resources/languages/pt_PT.toml @@ -434,6 +434,7 @@ operator_instruction = "Executar {command} a partir do console (ou habilitar fra "entry.command.list_datapacks" = "Lista de pacotes de dados configurados e instalados" "entry.command.ingest" = "Explicar Bukkit datapack ingere fluxo de trabalho" "entry.command.remove" = "Explique o fluxo de trabalho de remoção do datapack" +"entry.command.export_datapack" = "Exportar uma dimensão como datapack de geração de mundo vanilla" "entry.command.list_structures" = "Regenerar structure-index.json" "entry.command.info_2" = "Resolver uma Iris gráfico de estrutura e limites do relatório" "entry.command.place" = "Montar e colocar um Iris estrutura na sua localização" @@ -1130,6 +1131,23 @@ replace_installed_types = "Packs instalados: {value}" "director.list_configured_datapack_imports_their_installed_versions" = "Lista as importações configuradas do datapack e suas versões instaladas" "director.remove_installed_datapack_by_id_also_delete_its_url_from_datapackimports_keep" = "Remover um pacote de dados instalado por id (também apagar o seu URL de datapackImports para mantê-lo desaparecido)" "param.datapack_id_folder_name_shown_by_iris_datapack_list" = "O ID do pacote de dados (nome da pasta) mostrado por /iris datapack list" +"director.export_dimension_vanilla_worldgen_datapack" = "Exportar uma dimensão como datapack de geração de mundo vanilla" +"param.export_dimension" = "A dimensão a exportar" +"param.export_version" = "Versão de Minecraft de destino, 26.3 ou posterior (vazio usa a mais recente)" +"param.export_mode" = "Modo de saída: preset, dimension ou overworld" +"param.export_namespace" = "Namespace dos recursos exportados" +"param.export_output" = "Nome de saída na pasta de exportação de datapacks (vazio usa a dimensão e a versão)" +"param.export_format" = "zip ou directory" +"param.export_objects" = "Incluir objetos como modelos de estrutura" +"param.export_replace" = "Substituir uma exportação existente com o mesmo nome" +"export.started" = "A exportar {dimension} como datapack para Minecraft {version}..." +"export.stage" = "Exportação do datapack: {stage}" +"export.resources" = "Exportação do datapack: {done}/{total} recursos" +"export.diagnostic" = "{key}: {message}" +"export.exported" = "Datapack exportado: {path}" +"export.rejected" = "Exportação do datapack rejeitada: {reason}" +"export.failed" = "A exportação do datapack falhou: {reason}" +"export.invalid" = "Argumentos de exportação do datapack inválidos: {reason}" [iris.director.commanddeveloper] "director.iris_world_manager" = "Iris Gestor Mundial" diff --git a/core/src/main/resources/languages/ru_RU.toml b/core/src/main/resources/languages/ru_RU.toml index 79a2e558a..2ad3fe489 100644 --- a/core/src/main/resources/languages/ru_RU.toml +++ b/core/src/main/resources/languages/ru_RU.toml @@ -439,6 +439,7 @@ operator_instruction = "Беги. {command} от консоли (или вклю "entry.command.list_datapacks" = "Список настроенных и установленных пакетов данных" "entry.command.ingest" = "Объяснять Bukkit Datapack глотает рабочий процесс" "entry.command.remove" = "Объясните рабочий процесс удаления пакета данных" +"entry.command.export_datapack" = "Экспортировать измерение как датапак генерации мира vanilla" "entry.command.list_structures" = "восстанавливать structure-index.json" "entry.command.info_2" = "Разрешить один Iris График структуры и границы отчета" "entry.command.place" = "Собрать и разместить Iris Структура в вашем месте" @@ -1135,6 +1136,23 @@ replace_installed_types = "Установленные паки: {value}" "director.list_configured_datapack_imports_their_installed_versions" = "Список сконфигурированных импортных пакетов данных и их установленные версии" "director.remove_installed_datapack_by_id_also_delete_its_url_from_datapackimports_keep" = "Удалите установленный пакет данных по идентификатору (также удалите его URL-адрес из datapackImports, чтобы его не было)" "param.datapack_id_folder_name_shown_by_iris_datapack_list" = "ID пакета данных (название папки), показанный /iris datapack list" +"director.export_dimension_vanilla_worldgen_datapack" = "Экспортировать измерение как датапак генерации мира vanilla" +"param.export_dimension" = "Измерение для экспорта" +"param.export_version" = "Целевая версия Minecraft: 26.3 или новее (пусто означает последнюю)" +"param.export_mode" = "Режим вывода: preset, dimension или overworld" +"param.export_namespace" = "Пространство имён для экспортируемых ресурсов" +"param.export_output" = "Имя результата в папке экспорта датапаков (пусто означает измерение и версию)" +"param.export_format" = "zip или directory" +"param.export_objects" = "Включить объекты как шаблоны структур" +"param.export_replace" = "Заменить существующий экспорт с тем же именем" +"export.started" = "Экспорт {dimension} как датапака для Minecraft {version}..." +"export.stage" = "Экспорт датапака: {stage}" +"export.resources" = "Экспорт датапака: {done}/{total} ресурсов" +"export.diagnostic" = "{key}: {message}" +"export.exported" = "Датапак экспортирован: {path}" +"export.rejected" = "Экспорт датапака отклонён: {reason}" +"export.failed" = "Не удалось экспортировать датапак: {reason}" +"export.invalid" = "Недопустимые аргументы экспорта датапака: {reason}" [iris.director.commanddeveloper] "director.iris_world_manager" = "Iris Мировой менеджер" diff --git a/core/src/main/resources/languages/tr_TR.toml b/core/src/main/resources/languages/tr_TR.toml index 89aa5e5e3..45ae96724 100644 --- a/core/src/main/resources/languages/tr_TR.toml +++ b/core/src/main/resources/languages/tr_TR.toml @@ -439,6 +439,7 @@ operator_instruction = "{command} komutunu konsoldan çalıştırın (veya tek o "entry.command.list_datapacks" = "Liste yapılandırılmış ve yüklü datapacks" "entry.command.ingest" = "Açık Açıklama Bukkit Datapack ingest" "entry.command.remove" = "Açıklama Datapack Move iş akışı" +"entry.command.export_datapack" = "Bir boyutu vanilla dünya üretimi datapack'i olarak dışa aktar" "entry.command.list_structures" = "Rejenrate structure-index.json" "entry.command.info_2" = "Resolve a Iris Yapı grafiği ve rapor sınırları" "entry.command.place" = "Assemble ve bir yer Iris Yerinizde Yapı" @@ -1135,6 +1136,23 @@ replace_installed_types = "Yüklü paketler: {value}" "director.list_configured_datapack_imports_their_installed_versions" = "Liste yapılandırılmış veri paketi ithalatı ve onların yüklü versiyonları" "director.remove_installed_datapack_by_id_also_delete_its_url_from_datapackimports_keep" = "Create an installed datapack by id (ayrıca onu sil URL -den datapackImports Onu korumak için)" "param.datapack_id_folder_name_shown_by_iris_datapack_list" = "Datapack id (folder adı) tarafından gösterilen /iris datapack list" +"director.export_dimension_vanilla_worldgen_datapack" = "Bir boyutu vanilla dünya üretimi datapack'i olarak dışa aktar" +"param.export_dimension" = "Dışa aktarılacak boyut" +"param.export_version" = "Hedef Minecraft sürümü, 26.3 veya üstü (boş bırakılırsa en yenisi)" +"param.export_mode" = "Çıktı modu: preset, dimension veya overworld" +"param.export_namespace" = "Dışa aktarılan kaynaklar için ad alanı" +"param.export_output" = "Datapack dışa aktarma klasöründeki çıktı adı (boş bırakılırsa boyut ve sürüm kullanılır)" +"param.export_format" = "zip veya directory" +"param.export_objects" = "Nesneleri yapı şablonları olarak dahil et" +"param.export_replace" = "Aynı adlı mevcut bir dışa aktarmayı değiştir" +"export.started" = "{dimension}, Minecraft {version} için datapack olarak dışa aktarılıyor..." +"export.stage" = "Datapack dışa aktarma: {stage}" +"export.resources" = "Datapack dışa aktarma: {done}/{total} kaynak" +"export.diagnostic" = "{key}: {message}" +"export.exported" = "Datapack dışa aktarıldı: {path}" +"export.rejected" = "Datapack dışa aktarma reddedildi: {reason}" +"export.failed" = "Datapack dışa aktarma başarısız oldu: {reason}" +"export.invalid" = "Geçersiz datapack dışa aktarma argümanları: {reason}" [iris.director.commanddeveloper] "director.iris_world_manager" = "Iris Dünya Yöneticisi" diff --git a/core/src/main/resources/languages/vi_VI.toml b/core/src/main/resources/languages/vi_VI.toml index 3f53831a0..037d86b79 100644 --- a/core/src/main/resources/languages/vi_VI.toml +++ b/core/src/main/resources/languages/vi_VI.toml @@ -437,6 +437,7 @@ operator_instruction = "Chạy {command} từ bàn giao tiếp (hoặc kích ho "entry.command.list_datapacks" = "Name" "entry.command.ingest" = "Giải thích Bukkit Name" "entry.command.remove" = "Name" +"entry.command.export_datapack" = "Xuất một chiều không gian thành datapack tạo thế giới vanilla" "entry.command.list_structures" = "Tái tạo structure-index.json" "entry.command.info_2" = "Giải quyết một Iris giới hạn cấu trúc đồ thị và báo cáo" "entry.command.place" = "Tập hợp và đặt một Iris kết cấu tại vị trí của bạn" @@ -1133,6 +1134,23 @@ replace_installed_types = "Các gói đã cài đặt: {value}" "director.list_configured_datapack_imports_their_installed_versions" = "Liệt kê các tập dữ liệu đã cấu hình và phiên bản đã cài đặt" "director.remove_installed_datapack_by_id_also_delete_its_url_from_datapackimports_keep" = "Gỡ bỏ bộ dữ liệu đã cài đặt bằng id (cũng xóa bỏ nó URL từ datapackImports để giữ nó đi mất)" "param.datapack_id_folder_name_shown_by_iris_datapack_list" = "Mã nhận diện dữ liệu (tên thư mục) được hiển thị bởi /iris datapack list" +"director.export_dimension_vanilla_worldgen_datapack" = "Xuất một chiều không gian thành datapack tạo thế giới vanilla" +"param.export_dimension" = "Chiều không gian cần xuất" +"param.export_version" = "Phiên bản Minecraft đích, 26.3 trở lên (để trống dùng bản mới nhất)" +"param.export_mode" = "Chế độ xuất: preset, dimension hoặc overworld" +"param.export_namespace" = "Không gian tên cho các tài nguyên được xuất" +"param.export_output" = "Tên đầu ra trong thư mục xuất datapack (để trống dùng chiều không gian và phiên bản)" +"param.export_format" = "zip hoặc directory" +"param.export_objects" = "Bao gồm các đối tượng dưới dạng mẫu công trình" +"param.export_replace" = "Thay thế bản xuất hiện có cùng tên" +"export.started" = "Đang xuất {dimension} thành datapack cho Minecraft {version}..." +"export.stage" = "Xuất datapack: {stage}" +"export.resources" = "Xuất datapack: {done}/{total} tài nguyên" +"export.diagnostic" = "{key}: {message}" +"export.exported" = "Đã xuất datapack: {path}" +"export.rejected" = "Xuất datapack bị từ chối: {reason}" +"export.failed" = "Xuất datapack thất bại: {reason}" +"export.invalid" = "Đối số xuất datapack không hợp lệ: {reason}" [iris.director.commanddeveloper] "director.iris_world_manager" = "Iris Quản lý thế giới" diff --git a/core/src/main/resources/languages/zh_CN.toml b/core/src/main/resources/languages/zh_CN.toml index b525f6fdd..a976300c1 100644 --- a/core/src/main/resources/languages/zh_CN.toml +++ b/core/src/main/resources/languages/zh_CN.toml @@ -437,6 +437,7 @@ operator_instruction = "请从控制台执行 {command}(或在单人游戏中 "entry.command.list_datapacks" = "列出已配置和已安装的数据包" "entry.command.ingest" = "说明 Bukkit 数据包导入流程" "entry.command.remove" = "说明数据包移除流程" +"entry.command.export_datapack" = "将维度导出为原版世界生成数据包" "entry.command.list_structures" = "重新生成 structure-index.json" "entry.command.info_2" = "解析 Iris 结构图并报告边界" "entry.command.place" = "在当前位置组装并放置 Iris 结构" @@ -1133,6 +1134,23 @@ replace_installed_types = "已安装的包:{value}" "director.list_configured_datapack_imports_their_installed_versions" = "列出已配置的数据包导入及其安装版本" "director.remove_installed_datapack_by_id_also_delete_its_url_from_datapackimports_keep" = "通过 id 删除一个已安装的数据包( 同时删除其 URL)datapackImports让它消失)" "param.datapack_id_folder_name_shown_by_iris_datapack_list" = "数据包 id (文件夹名称) 显示于 /iris datapack list" +"director.export_dimension_vanilla_worldgen_datapack" = "将维度导出为原版世界生成数据包" +"param.export_dimension" = "要导出的维度" +"param.export_version" = "目标 Minecraft 版本,26.3 或更高(留空则使用最新版本)" +"param.export_mode" = "输出模式:preset、dimension 或 overworld" +"param.export_namespace" = "导出资源使用的命名空间" +"param.export_output" = "数据包导出文件夹中的输出名称(留空则使用维度和版本)" +"param.export_format" = "zip 或 directory" +"param.export_objects" = "将对象作为结构模板一并导出" +"param.export_replace" = "替换同名的现有导出" +"export.started" = "正在将 {dimension} 导出为适用于 Minecraft {version} 的数据包..." +"export.stage" = "数据包导出:{stage}" +"export.resources" = "数据包导出:{done}/{total} 个资源" +"export.diagnostic" = "{key}: {message}" +"export.exported" = "数据包已导出:{path}" +"export.rejected" = "数据包导出被拒绝:{reason}" +"export.failed" = "数据包导出失败:{reason}" +"export.invalid" = "数据包导出参数无效:{reason}" [iris.director.commanddeveloper] "director.iris_world_manager" = "Iris 世界经理" diff --git a/core/src/main/resources/languages/zh_TW.toml b/core/src/main/resources/languages/zh_TW.toml index 564034636..e09a2a837 100644 --- a/core/src/main/resources/languages/zh_TW.toml +++ b/core/src/main/resources/languages/zh_TW.toml @@ -437,6 +437,7 @@ operator_instruction = "請從主控台執行 {command}(或在單人遊戲中 "entry.command.list_datapacks" = "列出已設定和已安裝的資料包" "entry.command.ingest" = "說明 Bukkit 資料包匯入流程" "entry.command.remove" = "說明資料包移除流程" +"entry.command.export_datapack" = "將維度匯出為原版世界生成資料包" "entry.command.list_structures" = "重新生成 structure-index.json" "entry.command.info_2" = "解析 Iris 結構圖並回報邊界" "entry.command.place" = "在目前位置組裝並放置 Iris 結構" @@ -1133,6 +1134,23 @@ replace_installed_types = "已安裝的包:{value}" "director.list_configured_datapack_imports_their_installed_versions" = "列出已配置的資料包匯入及其安裝版本" "director.remove_installed_datapack_by_id_also_delete_its_url_from_datapackimports_keep" = "通過 id 刪除一個已安裝的數據包( 同時刪除其 URL)datapackImports讓它消失)" "param.datapack_id_folder_name_shown_by_iris_datapack_list" = "資料包 id (資料夾名稱) 顯示於 /iris datapack list" +"director.export_dimension_vanilla_worldgen_datapack" = "將維度匯出為原版世界生成資料包" +"param.export_dimension" = "要匯出的維度" +"param.export_version" = "目標 Minecraft 版本,26.3 或更新(留空則使用最新版本)" +"param.export_mode" = "輸出模式:preset、dimension 或 overworld" +"param.export_namespace" = "匯出資源使用的命名空間" +"param.export_output" = "資料包匯出資料夾中的輸出名稱(留空則使用維度和版本)" +"param.export_format" = "zip 或 directory" +"param.export_objects" = "將物件作為結構範本一併匯出" +"param.export_replace" = "取代同名的現有匯出" +"export.started" = "正在將 {dimension} 匯出為適用於 Minecraft {version} 的資料包..." +"export.stage" = "資料包匯出:{stage}" +"export.resources" = "資料包匯出:{done}/{total} 個資源" +"export.diagnostic" = "{key}: {message}" +"export.exported" = "資料包已匯出:{path}" +"export.rejected" = "資料包匯出遭拒:{reason}" +"export.failed" = "資料包匯出失敗:{reason}" +"export.invalid" = "資料包匯出參數無效:{reason}" [iris.director.commanddeveloper] "director.iris_world_manager" = "Iris 世界經理" diff --git a/core/src/test/java/art/arcane/iris/generation/validation/ChunkSpiralScanTest.java b/core/src/test/java/art/arcane/iris/generation/validation/ChunkSpiralScanTest.java new file mode 100644 index 000000000..802a2dcaa --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/validation/ChunkSpiralScanTest.java @@ -0,0 +1,102 @@ +package art.arcane.iris.generation.validation; + +import art.arcane.iris.configuration.IrisSettings; +import art.arcane.volmlib.util.math.ChunkSpiral; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class ChunkSpiralScanTest { + private IrisSettings previousSettings; + + @Before + public void setUp() { + previousSettings = IrisSettings.settings; + IrisSettings.settings = new IrisSettings(); + } + + @After + public void tearDown() { + IrisSettings.settings = previousSettings; + } + + @Test + public void singleThreadVisitsTargetsInSpiralOrder() throws Exception { + List targets = ChunkSpiral.centerOut(3, -2, 3); + List visited = Collections.synchronizedList(new ArrayList<>()); + RecordingListener listener = new RecordingListener(); + + int failures = ChunkSpiralScan.scan(targets, 1, (int chunkX, int chunkZ) -> visited.add(chunkX + "," + chunkZ), listener); + + List expected = new ArrayList<>(); + for (int[] target : targets) { + expected.add(target[0] + "," + target[1]); + } + assertEquals(0, failures); + assertEquals("3,-2", visited.getFirst()); + assertEquals(expected, visited); + assertEquals(expected, listener.done); + } + + @Test + public void parallelScanVisitsEveryTargetOnce() throws Exception { + List targets = ChunkSpiral.centerOut(0, 0, 4); + List visited = Collections.synchronizedList(new ArrayList<>()); + RecordingListener listener = new RecordingListener(); + + int failures = ChunkSpiralScan.scan(targets, 4, (int chunkX, int chunkZ) -> visited.add(chunkX + "," + chunkZ), listener); + + Set unique = new HashSet<>(visited); + assertEquals(0, failures); + assertEquals(81, visited.size()); + assertEquals(81, unique.size()); + assertEquals(81, listener.ok.get()); + } + + @Test + public void failedChunksAreCountedAndReported() throws Exception { + List targets = ChunkSpiral.centerOut(0, 0, 1); + RecordingListener listener = new RecordingListener(); + + int failures = ChunkSpiralScan.scan(targets, 2, (int chunkX, int chunkZ) -> { + if (chunkX == 1) { + throw new IllegalStateException("boom " + chunkX + "," + chunkZ); + } + }, listener); + + assertEquals(3, failures); + assertEquals(3, listener.failed.size()); + assertEquals(9, listener.done.size()); + assertEquals(6, listener.ok.get()); + assertTrue(listener.failed.stream().allMatch((String chunk) -> chunk.startsWith("1,"))); + } + + private static final class RecordingListener implements ChunkSpiralScan.Listener { + private final List done = Collections.synchronizedList(new ArrayList<>()); + private final List failed = Collections.synchronizedList(new ArrayList<>()); + private final AtomicInteger ok = new AtomicInteger(); + + @Override + public void chunkDone(int chunkX, int chunkZ, boolean success) { + done.add(chunkX + "," + chunkZ); + if (success) { + ok.incrementAndGet(); + } + } + + @Override + public void chunkFailed(int chunkX, int chunkZ, Throwable error) { + failed.add(chunkX + "," + chunkZ); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/DataVersionTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/DataVersionTest.java index bca100068..802376996 100644 --- a/core/src/test/java/art/arcane/iris/pack/datapack/DataVersionTest.java +++ b/core/src/test/java/art/arcane/iris/pack/datapack/DataVersionTest.java @@ -31,6 +31,17 @@ public class DataVersionTest { assertEquals(121, DataVersion.V26_3.getPackFormat()); } + @Test + public void exportMetadataMatchesServerVersionJson() { + assertEquals(0, DataVersion.V26_3.getPackFormatMinor()); + assertEquals(5023, DataVersion.V26_3.getWorldDataVersion()); + assertEquals(1, DataVersion.V26_2.getPackFormatMinor()); + assertEquals(4903, DataVersion.V26_2.getWorldDataVersion()); + assertEquals(1, DataVersion.V26_1_2.getPackFormatMinor()); + assertEquals(4790, DataVersion.V26_1_2.getWorldDataVersion()); + assertEquals(3955, DataVersion.V1_21_1.getWorldDataVersion()); + } + @Test public void minSupportedPackFormatIsTheOldestSupportedRuntime() { assertEquals(48, DataVersion.minSupportedPackFormat()); diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/AtomicDatapackPublisherTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/AtomicDatapackPublisherTest.java new file mode 100644 index 000000000..ccc0f6905 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/AtomicDatapackPublisherTest.java @@ -0,0 +1,61 @@ +package art.arcane.iris.pack.datapack.export; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class AtomicDatapackPublisherTest { + private static final String PREFIX = ".iris-worldgen-export-"; + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void stagingDirectoryUsesCallerPrefixAndIsRemoved() throws IOException { + Path parent = temporaryFolder.getRoot().toPath(); + Path destination = parent.resolve("worldgen"); + byte[] metadata = "{}\n".getBytes(StandardCharsets.UTF_8); + + AtomicDatapackPublisher.Publication publication = new AtomicDatapackPublisher().publish( + Map.of("pack.mcmeta", metadata), destination, DatapackExportFormat.DIRECTORY, false, PREFIX); + + assertTrue(publication.cleanupWarnings().isEmpty()); + assertArrayEquals(metadata, Files.readAllBytes(destination.resolve("pack.mcmeta"))); + assertEquals(List.of(), entriesWithPrefix(parent)); + } + + @Test + public void existingDestinationWithoutReplaceIsLeftUntouched() throws IOException { + Path parent = temporaryFolder.getRoot().toPath(); + Path destination = parent.resolve("worldgen.zip"); + byte[] original = "original".getBytes(StandardCharsets.UTF_8); + Files.write(destination, original); + + assertThrows(IOException.class, () -> new AtomicDatapackPublisher().publish( + Map.of("pack.mcmeta", new byte[]{1}), destination, DatapackExportFormat.ZIP, false, PREFIX)); + + assertArrayEquals(original, Files.readAllBytes(destination)); + assertEquals(List.of(), entriesWithPrefix(parent)); + } + + private static List entriesWithPrefix(Path parent) throws IOException { + try (Stream entries = Files.list(parent)) { + return entries.map(path -> path.getFileName().toString()) + .filter(name -> name.startsWith(PREFIX)) + .toList(); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/DatapackExportDestinationsTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/DatapackExportDestinationsTest.java new file mode 100644 index 000000000..b46fdfd53 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/DatapackExportDestinationsTest.java @@ -0,0 +1,78 @@ +package art.arcane.iris.pack.datapack.export; + +import org.junit.Test; + +import java.nio.file.Path; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class DatapackExportDestinationsTest { + private static final String NAME_MESSAGE = + "Export output must be one file name using letters, numbers, '.', '_', or '-'."; + + @Test + public void parsesFormatAliases() { + assertEquals(DatapackExportFormat.ZIP, DatapackExportDestinations.parseFormat("zip")); + assertEquals(DatapackExportFormat.ZIP, DatapackExportDestinations.parseFormat(" ZIP ")); + assertEquals(DatapackExportFormat.DIRECTORY, DatapackExportDestinations.parseFormat("directory")); + assertEquals(DatapackExportFormat.DIRECTORY, DatapackExportDestinations.parseFormat("dir")); + assertEquals(DatapackExportFormat.DIRECTORY, DatapackExportDestinations.parseFormat("folder")); + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, + () -> DatapackExportDestinations.parseFormat("tar")); + assertEquals("Export format must be directory or zip.", failure.getMessage()); + } + + @Test + public void resolvesOneArtifactInsideExportRoot() { + Path root = Path.of("build", "datapack-exports").toAbsolutePath().normalize(); + + assertEquals(root.resolve("village.zip"), + DatapackExportDestinations.resolve(root, "village", DatapackExportFormat.ZIP)); + assertEquals(root.resolve("village.zip"), + DatapackExportDestinations.resolve(root, "village.zip", DatapackExportFormat.ZIP)); + assertEquals(root.resolve("village"), + DatapackExportDestinations.resolve(root, "village", DatapackExportFormat.DIRECTORY)); + assertThrows(IllegalArgumentException.class, + () -> DatapackExportDestinations.resolve(root, "", DatapackExportFormat.DIRECTORY)); + assertThrows(IllegalArgumentException.class, + () -> DatapackExportDestinations.resolve(root, ".", DatapackExportFormat.DIRECTORY)); + assertThrows(IllegalArgumentException.class, + () -> DatapackExportDestinations.resolve(root, " village", DatapackExportFormat.DIRECTORY)); + } + + @Test + public void rejectsPathSeparatorsInOutput() { + Path root = Path.of("build", "datapack-exports").toAbsolutePath().normalize(); + + IllegalArgumentException nested = assertThrows(IllegalArgumentException.class, + () -> DatapackExportDestinations.resolve(root, "nested/export", DatapackExportFormat.ZIP)); + IllegalArgumentException parent = assertThrows(IllegalArgumentException.class, + () -> DatapackExportDestinations.resolve(root, "../all-exports", DatapackExportFormat.DIRECTORY)); + IllegalArgumentException windows = assertThrows(IllegalArgumentException.class, + () -> DatapackExportDestinations.resolve(root, "nested\\export", DatapackExportFormat.ZIP)); + + assertEquals(NAME_MESSAGE, nested.getMessage()); + assertEquals(NAME_MESSAGE, parent.getMessage()); + assertEquals(NAME_MESSAGE, windows.getMessage()); + } + + @Test + public void secondExportToSamePathIsRejected() { + Path destination = Path.of("build", "datapack-exports", "claimed.zip"); + Path sameDestination = Path.of("build", "datapack-exports", ".", "claimed.zip").toAbsolutePath(); + + assertTrue(DatapackExportDestinations.claim(destination)); + try { + assertFalse(DatapackExportDestinations.claim(destination)); + assertFalse(DatapackExportDestinations.claim(sameDestination)); + } finally { + DatapackExportDestinations.release(destination); + } + + assertTrue(DatapackExportDestinations.claim(sameDestination)); + DatapackExportDestinations.release(sameDestination); + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/ExportTestStyles.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/ExportTestStyles.java new file mode 100644 index 000000000..59f1f6e25 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/ExportTestStyles.java @@ -0,0 +1,36 @@ +package art.arcane.iris.pack.datapack.export; + +import art.arcane.iris.generation.noise.IrisGeneratorStyle; +import art.arcane.iris.generation.noise.NoiseStyle; +import art.arcane.iris.pack.datapack.export.compile.noise.NoiseRegistry; +import art.arcane.iris.pack.datapack.export.compile.noise.StyleCompiler; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.NoiseDef; +import art.arcane.iris.pack.datapack.export.lower.ExportCapabilities; + +import java.util.List; +import java.util.Locale; + +public final class ExportTestStyles implements StyleCompiler { + private static final double COHERENT_BLOCKS = 64.0; + private static final double WARP_FREQUENCY = 2.0; + private static final double WARP_AMPLITUDE = 0.35; + private static final List SINGLE_OCTAVE = List.of(1.0); + + @Override + public Field compile(IrisGeneratorStyle style, long seedSalt, NoiseRegistry registry, ExportCapabilities capabilities) { + if (style.isFlat()) { + return new Field.Const(0.5); + } + String name = "test/" + Long.toUnsignedString(seedSalt, 36) + "_" + (style.getStyle() == null ? NoiseStyle.FLAT : style.getStyle()).name().toLowerCase(Locale.ROOT); + String base = registry.register(new NoiseDef(name, 0, SINGLE_OCTAVE)); + double scale = 1.0 / (COHERENT_BLOCKS * style.getZoom()); + if (style.getFracture() == null) { + return new Field.Noise(base, scale, 0.0, null, null, null); + } + String warp = registry.register(new NoiseDef(name + "_warp", 0, SINGLE_OCTAVE)); + Field shiftX = new Field.Binary(Field.BinaryOp.MUL, new Field.Const(WARP_AMPLITUDE), new Field.Noise(warp, scale * WARP_FREQUENCY, 0.0, null, null, null)); + Field shiftZ = new Field.Binary(Field.BinaryOp.MUL, new Field.Const(-WARP_AMPLITUDE), new Field.Noise(warp, scale * WARP_FREQUENCY * 1.5, 0.0, null, null, null)); + return new Field.Noise(base, scale, 0.0, shiftX, null, shiftZ); + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/StructureTemplateEncoderTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/StructureTemplateEncoderTest.java new file mode 100644 index 000000000..5935cd10c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/StructureTemplateEncoderTest.java @@ -0,0 +1,252 @@ +package art.arcane.iris.pack.datapack.export; + +import art.arcane.iris.generation.block.TileData; +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.iris.structure.object.IrisObject; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.collection.KMap; +import art.arcane.volmlib.util.nbt.io.NBTUtil; +import art.arcane.volmlib.util.nbt.mca.MCABlockStateCodecSupport; +import art.arcane.volmlib.util.nbt.tag.ByteTag; +import art.arcane.volmlib.util.nbt.tag.CompoundTag; +import art.arcane.volmlib.util.nbt.tag.IntTag; +import art.arcane.volmlib.util.nbt.tag.ListTag; +import art.arcane.volmlib.util.nbt.tag.LongTag; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.zip.GZIPInputStream; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class StructureTemplateEncoderTest { + @ClassRule + public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + + private static final String STAIRS = "minecraft:oak_stairs[facing=north]"; + + @BeforeClass + public static void bindPlatform() { + IrisPlatforms.unbind(); + NativeBlockState stone = blockState("minecraft:stone"); + PlatformRegistries registries = mock(PlatformRegistries.class); + when(registries.block(anyString())).thenReturn(stone); + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.registries()).thenReturn(registries); + when(platform.minecraftVersion()).thenReturn("1.21.1"); + IrisPlatforms.bind(platform); + } + + @AfterClass + public static void unbindPlatform() { + IrisPlatforms.unbind(); + } + + @Test + public void dataVersionFollowsTarget() throws IOException { + IrisObject object = singleBlock("minecraft:stone"); + + assertEquals(5023, encode(object, List.of(), DataVersion.V26_3).getInt("DataVersion")); + assertEquals(4903, encode(object, List.of(), DataVersion.V26_2).getInt("DataVersion")); + } + + @Test + public void templatesAreCompleteGzipStreams() throws IOException { + IrisObject object = singleBlock(STAIRS); + + for (DataVersion target : List.of(DataVersion.V26_3, DataVersion.V26_2)) { + byte[] bytes = new StructureTemplateEncoder().encode(object, List.of(), target); + byte[] inflated; + try (InputStream input = new GZIPInputStream(new ByteArrayInputStream(bytes))) { + inflated = input.readAllBytes(); + assertEquals(-1, input.read()); + } + CompoundTag root = (CompoundTag) NBTUtil.read(new ByteArrayInputStream(inflated), false).getTag(); + assertEquals(target.getWorldDataVersion(), root.getInt("DataVersion")); + } + } + + @Test + public void paletteFormatFollowsTarget() throws IOException { + IrisObject object = singleBlock(STAIRS); + VanillaBlockState stairs = VanillaBlockState.parse(STAIRS); + + assertEquals(stairs.toNbt(MCABlockStateCodecSupport.Format.LOWERCASE), + encode(object, List.of(), DataVersion.V26_3).getListTag("palette").get(0)); + assertEquals(stairs.toNbt(MCABlockStateCodecSupport.Format.CAPITALIZED), + encode(object, List.of(), DataVersion.V26_2).getListTag("palette").get(0)); + } + + @Test + public void extraBlockOverridesObjectBlockAtSamePosition() throws IOException { + IrisObject object = new IrisObject(2, 1, 1); + object.setUnsigned(0, 0, 0, blockState("minecraft:stone")); + object.setUnsigned(1, 0, 0, blockState("minecraft:stone")); + CompoundTag nbt = new CompoundTag(); + nbt.putString("id", "minecraft:jigsaw"); + TemplateBlock jigsaw = new TemplateBlock(1, 0, 0, + VanillaBlockState.parse("minecraft:jigsaw[orientation=north_up]"), nbt); + + CompoundTag template = encode(object, List.of(jigsaw), DataVersion.V26_3); + + ListTag blocks = template.getListTag("blocks"); + assertEquals(2, blocks.size()); + CompoundTag first = (CompoundTag) blocks.get(0); + CompoundTag second = (CompoundTag) blocks.get(1); + assertEquals(List.of(0, 0, 0), position(first)); + assertNull(first.get("nbt")); + assertEquals(List.of(1, 0, 0), position(second)); + assertEquals(nbt, second.getCompoundTag("nbt")); + CompoundTag paletteEntry = (CompoundTag) template.getListTag("palette").get(second.getInt("state")); + assertEquals("minecraft:jigsaw", paletteEntry.getString("id")); + assertFalse(first.getInt("state") == second.getInt("state")); + } + + @Test + public void tileEntitiesRoundTrip() throws IOException { + IrisObject object = new IrisObject(2, 1, 1); + object.setUnsigned(0, 0, 0, blockState("minecraft:chest[facing=north]")); + object.setUnsigned(1, 0, 0, blockState("minecraft:oak_sign[rotation=4]")); + KMap item = new KMap<>(); + item.put("Slot", 3L); + item.put("id", "minecraft:diamond"); + item.put("count", 5L); + KMap chest = new KMap<>(); + chest.put("Items", new ArrayList<>(List.of(item))); + chest.put("LootTableSeed", 42L); + chest.put("x", 10L); + object.setUnsignedTile(0, 0, 0, new TileData("minecraft:chest", chest)); + KMap sign = new KMap<>(); + sign.put("nbt", "{front_text:{color:\"black\",has_glowing_text:1b,messages:[\"\\\"a\\\"\",\"\\\"\\\"\",\"\\\"\\\"\",\"\\\"\\\"\"]},is_waxed:0b}"); + object.setUnsignedTile(1, 0, 0, new TileData("minecraft:oak_sign", sign)); + List diagnostics = new ArrayList<>(); + + TemplateVolume volume = StructureTemplateEncoder.volume(object, "objects/test", diagnostics); + CompoundTag template = decode(new StructureTemplateEncoder().encode(volume, DataVersion.V26_3)); + + assertTrue(diagnostics.isEmpty()); + ListTag blocks = template.getListTag("blocks"); + CompoundTag chestNbt = ((CompoundTag) blocks.get(0)).getCompoundTag("nbt"); + assertEquals("minecraft:chest", chestNbt.getString("id")); + assertEquals(42L, ((LongTag) chestNbt.get("LootTableSeed")).asLong()); + assertFalse(chestNbt.containsKey("x")); + CompoundTag stack = (CompoundTag) chestNbt.getListTag("Items").get(0); + assertEquals(3, ((ByteTag) stack.get("Slot")).asByte()); + assertEquals(5, ((IntTag) stack.get("count")).asInt()); + assertEquals("minecraft:diamond", stack.getString("id")); + CompoundTag signNbt = ((CompoundTag) blocks.get(1)).getCompoundTag("nbt"); + assertEquals("minecraft:sign", signNbt.getString("id")); + assertEquals(1, ((ByteTag) signNbt.getCompoundTag("front_text").get("has_glowing_text")).asByte()); + assertEquals(4, signNbt.getCompoundTag("front_text").getListTag("messages").size()); + assertEquals(volume.blocks().get(0).nbt(), chestNbt); + } + + @Test + public void unknownTileEntityBecomesExportLimit() { + IrisObject object = new IrisObject(1, 1, 1); + object.setUnsigned(0, 0, 0, blockState("minecraft:stone")); + object.setUnsignedTile(0, 0, 0, new TileData("minecraft:stone", new KMap<>())); + List diagnostics = new ArrayList<>(); + + TemplateVolume volume = StructureTemplateEncoder.volume(object, "objects/rock", diagnostics); + + assertNull(volume.blocks().getFirst().nbt()); + assertEquals(1, diagnostics.size()); + assertSame(ExportLimit.TILE_ENTITY, diagnostics.getFirst().limit()); + assertEquals("objects/rock", diagnostics.getFirst().resource()); + } + + @Test + public void customBlocksAreLeftOutWithExportLimit() { + IrisObject object = new IrisObject(2, 1, 1); + object.setUnsigned(0, 0, 0, blockState("minecraft:stone")); + object.setUnsigned(1, 0, 0, blockState("mymod:crystal")); + List diagnostics = new ArrayList<>(); + + TemplateVolume volume = StructureTemplateEncoder.volume(object, "objects/crystal", diagnostics); + + assertEquals(1, volume.blocks().size()); + assertEquals(1, diagnostics.size()); + assertSame(ExportLimit.CUSTOM_BLOCK, diagnostics.getFirst().limit()); + } + + @Test + public void blocksOutsideTheObjectSizeAreLeftOutWithExportLimit() { + IrisObject object = new IrisObject(2, 2, 1); + object.setUnsigned(0, 0, 0, blockState("minecraft:stone")); + object.setUnsigned(1, -1, 0, blockState("minecraft:dirt")); + object.setUnsigned(-1, 1, 0, blockState("minecraft:dirt")); + List diagnostics = new ArrayList<>(); + + TemplateVolume volume = StructureTemplateEncoder.volume(object, "objects/overhang", diagnostics); + + assertEquals(1, volume.blocks().size()); + assertEquals(1, diagnostics.size()); + assertSame(ExportLimit.OBJECT_BOUNDS, diagnostics.getFirst().limit()); + assertEquals("objects/overhang", diagnostics.getFirst().resource()); + } + + @Test + public void tileEntityMapsKeepTypedArrays() { + KMap profile = new KMap<>(); + profile.put("id", new ArrayList<>(List.of(1L, 2L, 3L, 4L))); + profile.put("name", "Notch"); + KMap head = new KMap<>(); + head.put("profile", profile); + + CompoundTag nbt = TileEntityNbt.toNbt("minecraft:player_head", head).orElseThrow(); + + assertEquals("minecraft:skull", nbt.getString("id")); + assertArrayEquals(new int[]{1, 2, 3, 4}, nbt.getCompoundTag("profile").getIntArray("id")); + assertTrue(TileEntityNbt.toNbt("minecraft:stone", head).isEmpty()); + } + + private static CompoundTag decode(byte[] bytes) throws IOException { + return (CompoundTag) NBTUtil.read(new ByteArrayInputStream(bytes), true).getTag(); + } + + private static CompoundTag encode(IrisObject object, List extraBlocks, DataVersion target) + throws IOException { + byte[] bytes = new StructureTemplateEncoder().encode(object, extraBlocks, target); + return (CompoundTag) NBTUtil.read(new ByteArrayInputStream(bytes), true).getTag(); + } + + private static IrisObject singleBlock(String key) { + IrisObject object = new IrisObject(1, 1, 1); + object.setUnsigned(0, 0, 0, blockState(key)); + return object; + } + + private static List position(CompoundTag block) { + ListTag pos = block.getListTag("pos"); + return List.of(((IntTag) pos.get(0)).asInt(), ((IntTag) pos.get(1)).asInt(), ((IntTag) pos.get(2)).asInt()); + } + + private static NativeBlockState blockState(String key) { + NativeBlockState state = mock(NativeBlockState.class); + when(state.key()).thenReturn(key); + when(state.namespace()).thenReturn(key.substring(0, key.indexOf(':'))); + return state; + } +} diff --git a/core/src/test/java/art/arcane/iris/structure/export/VanillaBlockStateTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/VanillaBlockStateTest.java similarity index 98% rename from core/src/test/java/art/arcane/iris/structure/export/VanillaBlockStateTest.java rename to core/src/test/java/art/arcane/iris/pack/datapack/export/VanillaBlockStateTest.java index d8776bb09..1ba17d0c2 100644 --- a/core/src/test/java/art/arcane/iris/structure/export/VanillaBlockStateTest.java +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/VanillaBlockStateTest.java @@ -1,4 +1,4 @@ -package art.arcane.iris.structure.export; +package art.arcane.iris.pack.datapack.export; import art.arcane.volmlib.util.nbt.tag.CompoundTag; import art.arcane.volmlib.util.nbt.mca.MCABlockStateCodecSupport; diff --git a/core/src/test/java/art/arcane/iris/structure/export/VanillaResourceIdentifierTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/VanillaResourceIdentifierTest.java similarity index 62% rename from core/src/test/java/art/arcane/iris/structure/export/VanillaResourceIdentifierTest.java rename to core/src/test/java/art/arcane/iris/pack/datapack/export/VanillaResourceIdentifierTest.java index eb9aecce1..d3ed714ae 100644 --- a/core/src/test/java/art/arcane/iris/structure/export/VanillaResourceIdentifierTest.java +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/VanillaResourceIdentifierTest.java @@ -1,4 +1,4 @@ -package art.arcane.iris.structure.export; +package art.arcane.iris.pack.datapack.export; import org.junit.Test; @@ -63,4 +63,32 @@ public class VanillaResourceIdentifierTest { assertThrows(IllegalArgumentException.class, () -> VanillaResourceIdentifier.normalizeConnectorIdentifier("iris:../escape")); } + + @Test + public void requireChecksReturnTheValueAndNameTheRoleOnFailure() { + assertEquals("iris_export", VanillaResourceIdentifier.requireNamespace("iris_export", "export namespace")); + assertEquals("caves/deep", VanillaResourceIdentifier.requirePath("caves/deep", "biome id")); + assertEquals("minecraft:stone", VanillaResourceIdentifier.requireIdentifier("minecraft:stone", "block id")); + + IllegalArgumentException path = assertThrows(IllegalArgumentException.class, + () -> VanillaResourceIdentifier.requirePath("Caves", "biome id")); + assertEquals("Invalid biome id 'Caves'", path.getMessage()); + IllegalArgumentException namespace = assertThrows(IllegalArgumentException.class, + () -> VanillaResourceIdentifier.requireNamespace(null, "export namespace")); + assertEquals("Invalid export namespace 'null'", namespace.getMessage()); + assertThrows(IllegalArgumentException.class, + () -> VanillaResourceIdentifier.requireIdentifier("stone", "block id")); + } + + @Test + public void sanitizedPathsAreLowercaseValidAndKeepFolders() { + assertEquals("forest_biomes/dark_oak", VanillaResourceIdentifier.sanitizePath("Forest Biomes/Dark Oak")); + assertEquals("caves/deep", VanillaResourceIdentifier.sanitizePath("caves\\deep")); + assertEquals("a/b", VanillaResourceIdentifier.sanitizePath("/a//b/")); + assertEquals("__/x", VanillaResourceIdentifier.sanitizePath("../x")); + assertEquals("caf_", VanillaResourceIdentifier.sanitizePath("Caf\u00e9")); + assertTrue(VanillaResourceIdentifier.validPath(VanillaResourceIdentifier.sanitizePath("../Odd Name!/x.y"))); + assertThrows(IllegalArgumentException.class, () -> VanillaResourceIdentifier.sanitizePath("///")); + assertThrows(IllegalArgumentException.class, () -> VanillaResourceIdentifier.sanitizePath(" ")); + } } diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/WorldgenDatapackExporterTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/WorldgenDatapackExporterTest.java new file mode 100644 index 000000000..534a7cafb --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/WorldgenDatapackExporterTest.java @@ -0,0 +1,479 @@ +package art.arcane.iris.pack.datapack.export; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.compile.CompileStage; +import art.arcane.iris.pack.datapack.export.compile.DimensionStage; +import art.arcane.iris.pack.datapack.export.compile.ImageMapStage; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; +import art.arcane.iris.pack.datapack.export.compile.PackCompiler; +import art.arcane.iris.pack.datapack.export.compile.biome.BiomeCodes; +import art.arcane.iris.pack.datapack.export.compile.object.ObjectStage; +import art.arcane.iris.pack.datapack.export.emit.WorldgenJsonWriter; +import art.arcane.iris.pack.datapack.export.ir.BiomeDef; +import art.arcane.iris.pack.datapack.export.ir.ClimateLayout; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportIr; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.testsupport.CompatTestPlatform; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.SortedMap; +import java.util.TreeMap; +import java.util.stream.Stream; +import java.util.zip.ZipFile; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public class WorldgenDatapackExporterTest { + @ClassRule + public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Rule + public ExportTestPacks.BoundPlatform platform = new ExportTestPacks.BoundPlatform(); + + private final List writerTargets = new ArrayList<>(); + + @Test + public void existingOutputWithoutReplaceFails() throws Exception { + File pack = ExportTestPacks.basic(temporaryFolder.newFolder("pack")); + Path root = temporaryFolder.newFolder("exports").toPath(); + Path existing = root.resolve("taken.zip"); + Files.writeString(existing, "keep", StandardCharsets.UTF_8); + + WorldgenExportResult result = exporter().export(request(pack, root).output("taken").build(), WorldgenExportProgress.NONE); + + assertSame(WorldgenExportResult.Status.REJECTED, result.status()); + assertEquals(existing, result.output()); + assertTrue(result.message(), result.message().contains("already exists")); + assertEquals("keep", Files.readString(existing, StandardCharsets.UTF_8)); + assertTrue(writerTargets.isEmpty()); + assertEquals(List.of("taken.zip"), entries(root)); + + WorldgenExportResult replaced = exporter().export(request(pack, root).output("taken").replace(true).build(), WorldgenExportProgress.NONE); + + assertTrue(replaced.message(), replaced.isSuccess()); + assertNotNull(summary(existing)); + assertEquals(List.of("taken.zip"), entries(root)); + } + + @Test + public void exportReportsTheLoadKeyOfEveryBiomeId() throws Exception { + File pack = ExportTestPacks.basic(temporaryFolder.newFolder("pack")); + Path root = temporaryFolder.newFolder("exports").toPath(); + + WorldgenExportResult result = exporter().export(request(pack, root).build(), WorldgenExportProgress.NONE); + + assertTrue(result.message(), result.isSuccess()); + assertEquals(Map.of("iris_export:forest_land", "Forest Land"), result.loadKeyByBiomeId()); + } + + @Test + public void claimedDestinationIsRejected() throws Exception { + File pack = ExportTestPacks.basic(temporaryFolder.newFolder("pack")); + Path root = temporaryFolder.newFolder("exports").toPath(); + Path destination = DatapackExportDestinations.resolve(root, "busy", DatapackExportFormat.ZIP); + assertTrue(DatapackExportDestinations.claim(destination)); + try { + WorldgenExportResult result = exporter().export(request(pack, root).output("busy").build(), WorldgenExportProgress.NONE); + + assertSame(WorldgenExportResult.Status.REJECTED, result.status()); + assertTrue(result.message(), result.message().contains("already running")); + assertFalse(Files.exists(destination)); + } finally { + DatapackExportDestinations.release(destination); + } + } + + @Test + public void invalidPackIsRejected() throws Exception { + File pack = ExportTestPacks.withDimension(temporaryFolder.newFolder("pack"), """ + {"regions": ["region"], "hydrology": {"enabled": true, "ponds": {"freeboard": [3, 1]}}} + """); + Path root = temporaryFolder.newFolder("exports").toPath(); + + WorldgenExportResult result = exporter().export(request(pack, root).build(), WorldgenExportProgress.NONE); + + assertSame(WorldgenExportResult.Status.REJECTED, result.status()); + assertTrue(result.message(), result.message().contains("freeboard")); + assertTrue(writerTargets.isEmpty()); + assertTrue(entries(root).isEmpty()); + } + + @Test + public void exportIsByteIdenticalAcrossRuns() throws Exception { + File pack = ExportTestPacks.basic(temporaryFolder.newFolder("pack")); + Path root = temporaryFolder.newFolder("exports").toPath(); + RecordingProgress progress = new RecordingProgress(); + + WorldgenExportResult first = exporter().export(request(pack, root).build(), progress); + WorldgenExportResult second = exporter().export(request(pack, root).output("second").build(), WorldgenExportProgress.NONE); + + assertTrue(first.message(), first.isSuccess()); + assertTrue(second.message(), second.isSuccess()); + assertEquals("main-26.3.zip", first.output().getFileName().toString()); + assertArrayEquals(Files.readAllBytes(first.output()), Files.readAllBytes(second.output())); + assertEquals(List.of(DataVersion.V26_3, DataVersion.V26_3), writerTargets); + assertTrue(summary(first.output()), summary(first.output()).contains("target=26.3")); + assertEquals("validate", progress.stages.getFirst()); + assertTrue(progress.stages.toString(), progress.stages.contains("dimension")); + assertEquals(List.of("write", "prune", "publish"), progress.stages.subList(progress.stages.size() - 3, progress.stages.size())); + assertEquals(List.of(2), progress.resourceTotals); + assertEquals(List.of(first.trim()), progress.trims); + assertEquals(2, first.trim().filesBefore()); + assertEquals(0, first.trim().filesRemoved()); + assertEquals(List.of("main-26.3.zip", "second.zip"), entries(root)); + } + + @Test + public void imageMapPackExportsWithDiagnostic() throws Exception { + File pack = ExportTestPacks.withImageMaps(temporaryFolder.newFolder("pack")); + Path root = temporaryFolder.newFolder("exports").toPath(); + + WorldgenExportResult result = exporter().export(request(pack, root).version("26.3").format("directory").build(), WorldgenExportProgress.NONE); + + assertTrue(result.message() + " " + result.diagnostics(), result.isSuccess()); + assertTrue(Files.isRegularFile(result.output().resolve("summary.txt"))); + assertEquals(List.of(DataVersion.V26_3), writerTargets); + List imageMapResources = new ArrayList<>(); + for (ExportDiagnostic diagnostic : result.diagnostics()) { + if (diagnostic.limit() == ExportLimit.IMAGE_MAP) { + imageMapResources.add(diagnostic.resource()); + } + } + assertEquals(List.of("dimensions/main", "biomes/mapped"), imageMapResources); + } + + @Test + public void missingDimensionIsRejected() throws Exception { + File pack = ExportTestPacks.basic(temporaryFolder.newFolder("pack")); + Path root = temporaryFolder.newFolder("exports").toPath(); + + WorldgenExportResult result = exporter().export(request(pack, root).dimension("absent").build(), WorldgenExportProgress.NONE); + + assertSame(WorldgenExportResult.Status.REJECTED, result.status()); + assertTrue(result.message(), result.message().contains("'absent'")); + assertTrue(entries(root).isEmpty()); + } + + @Test + public void argumentErrorsAreRejectedBeforeThePackIsRead() throws Exception { + File pack = ExportTestPacks.basic(temporaryFolder.newFolder("pack")); + Path root = temporaryFolder.newFolder("exports").toPath(); + + WorldgenExportResult version = exporter().export(request(pack, root).version("1.21.1").build(), WorldgenExportProgress.NONE); + WorldgenExportResult older = exporter().export(request(pack, root).version("26.2").build(), WorldgenExportProgress.NONE); + WorldgenExportResult mode = exporter().export(request(pack, root).mode("flat").build(), WorldgenExportProgress.NONE); + WorldgenExportResult format = exporter().export(request(pack, root).format("tar").build(), WorldgenExportProgress.NONE); + WorldgenExportResult namespace = exporter().export(request(pack, root).namespace("Iris Export").build(), WorldgenExportProgress.NONE); + + assertEquals("Unsupported datapack export version '1.21.1'. Supported: 26.3", version.message()); + assertEquals("Unsupported datapack export version '26.2'. Supported: 26.3", older.message()); + assertEquals("Unknown export mode 'flat'. Expected: preset, dimension, overworld", mode.message()); + assertEquals("Export format must be directory or zip.", format.message()); + assertEquals("Invalid export namespace 'Iris Export'", namespace.message()); + for (WorldgenExportResult result : List.of(version, older, mode, format, namespace)) { + assertSame(WorldgenExportResult.Status.REJECTED, result.status()); + } + assertTrue(entries(root).isEmpty()); + } + + @Test + public void objectsFlagReachesTheCompileContext() throws Exception { + File pack = ExportTestPacks.basic(temporaryFolder.newFolder("pack")); + Path root = temporaryFolder.newFolder("exports").toPath(); + List objects = new ArrayList<>(); + CompileStage recorder = new CompileStage() { + @Override + public String name() { + return "objects"; + } + + @Override + public void apply(PackCompileContext context) { + objects.add(context.objects()); + } + }; + WorldgenDatapackExporter exporter = new WorldgenDatapackExporter( + target -> new SummaryWriter(target, writerTargets), + new PackCompiler(List.of(new DimensionStage(), recorder))); + + WorldgenExportResult without = exporter.export(request(pack, root).objects(false).build(), WorldgenExportProgress.NONE); + WorldgenExportResult with = exporter.export(request(pack, root).output("with").build(), WorldgenExportProgress.NONE); + + assertTrue(without.message(), without.isSuccess()); + assertTrue(with.message(), with.isSuccess()); + assertEquals(List.of(false, true), objects); + } + + @Test + public void exportReportsGenerationCostAndFlagsClimateRootsOverBudget() throws Exception { + File pack = ExportTestPacks.basic(temporaryFolder.newFolder("pack")); + Path root = temporaryFolder.newFolder("exports").toPath(); + CompileStage climate = new CompileStage() { + @Override + public String name() { + return "biomes"; + } + + @Override + public void apply(PackCompileContext context) { + BiomeCodes base = context.biomeCodes(); + Field code = new Field.Noise("minecraft:temperature", 0.25, 0.0, null, null, null); + for (int level = 0; level < 20; level++) { + code = new Field.Shared("climate/level/" + level, new Field.Binary(Field.BinaryOp.MAX, code, code)); + } + context.setBiomeCodes(new BiomeCodes(code, base.caveCode(), base.class3d(), base.layout(), base.loadKeyByBiomeId())); + } + }; + WorldgenDatapackExporter exporter = new WorldgenDatapackExporter(WorldgenJsonWriter::forTarget, + new PackCompiler(List.of(new DimensionStage(), climate))); + + WorldgenExportResult result = exporter.export(request(pack, root).format("directory").build(), WorldgenExportProgress.NONE); + + assertTrue(result.message() + " " + result.diagnostics(), result.isSuccess()); + GenerationCost.RootCost temperature = result.cost().root(GenerationCost.Root.TEMPERATURE).orElseThrow(); + assertEquals(3_145_726.0, temperature.expandedNodes(), 0.0); + assertEquals(3_145_726.0, temperature.worstCaseNodes(), 0.0); + assertEquals(42L, temperature.distinctNodes()); + assertTrue(result.cost().root(GenerationCost.Root.FINAL_DENSITY).isPresent()); + assertEquals(0.0, result.cost().root(GenerationCost.Root.RIDGES).orElseThrow().expandedNodes(), 0.0); + List cost = result.diagnostics().stream().filter(diagnostic -> diagnostic.limit() == ExportLimit.GENERATION_COST).toList(); + assertEquals(cost.toString(), 1, cost.size()); + assertEquals("noise_settings/" + ExportTestPacks.DIMENSION + "/temperature", cost.getFirst().resource()); + assertEquals(densityFunctionBytes(result.output()), result.cost().densityFunctionBytes()); + assertTrue(result.cost().densityFunctionBytes() > 0L); + } + + private static long densityFunctionBytes(Path output) throws IOException { + long bytes = 0L; + try (Stream paths = Files.walk(output)) { + for (Path path : paths.filter(Files::isRegularFile).toList()) { + String relative = output.relativize(path).toString().replace(File.separatorChar, '/'); + if (relative.contains("/worldgen/density_function/") && relative.endsWith(".json")) { + bytes += Files.size(path); + } + } + } + return bytes; + } + + @Test + public void createdExporterWritesTheTargetFileSet() throws Exception { + File pack = ExportTestPacks.basic(temporaryFolder.newFolder("pack")); + Path root = temporaryFolder.newFolder("exports").toPath(); + WorldgenDatapackExporter exporter = WorldgenDatapackExporter.create(); + + WorldgenExportResult current = exporter.export(request(pack, root).format("directory").build(), WorldgenExportProgress.NONE); + + assertTrue(current.message() + " " + current.diagnostics(), current.isSuccess()); + List shared = List.of( + "data/iris_export/dimension_type/main.json", + "data/iris_export/worldgen/biome/forest_land.json", + "data/iris_export/worldgen/biome/mapped.json", + "data/iris_export/worldgen/biome/ocean.json", + "data/iris_export/worldgen/density_function/main/aquifer/barrier.json", + "data/iris_export/worldgen/density_function/main/aquifer/fluid_level_spread.json", + "data/iris_export/worldgen/density_function/main/aquifer/lava.json", + "data/iris_export/worldgen/density_function/main/final_density.json", + "data/iris_export/worldgen/noise_settings/main.json", + "data/minecraft/dimension/overworld.json", + "pack.mcmeta", + "pack.png"); + List currentOnly = List.of( + "data/iris_export/worldgen/density_function/main/chunk_surface_level.json", + "data/iris_export/worldgen/density_function/main/spawn_elevation.json", + "data/iris_export/worldgen/material_rule/main.json"); + List currentFiles = files(current.output()); + assertTrue(currentFiles.toString(), currentFiles.containsAll(shared)); + assertTrue(currentFiles.toString(), currentFiles.containsAll(currentOnly)); + assertTrue(current.diagnostics().toString(), current.diagnostics().stream().noneMatch(diagnostic -> diagnostic.severity() == ExportDiagnostic.Severity.ERROR)); + } + + @Test + public void onlyThePlacedObjectTemplateShips() throws Exception { + File pack = objectPack(temporaryFolder.newFolder("pack")); + Path root = temporaryFolder.newFolder("exports").toPath(); + RecordingProgress progress = new RecordingProgress(); + List written = new ArrayList<>(); + WorldgenDatapackExporter exporter = new WorldgenDatapackExporter(target -> ir -> { + SortedMap output = WorldgenJsonWriter.forTarget(target).write(ir); + written.addAll(output.keySet()); + return output; + }, new PackCompiler(List.of(new DimensionStage(), new UnreachablePlainsStage(), new ObjectStage()))); + + WorldgenExportResult result = exporter.export(request(pack, root).format("directory").build(), progress); + + assertTrue(result.message() + " " + result.diagnostics(), result.isSuccess()); + assertTrue(written.toString(), written.stream().anyMatch(file -> file.endsWith(".nbt") && file.contains("/rocks/pillar"))); + assertTrue(written.toString(), written.contains("data/iris_export/worldgen/biome/plains.json")); + assertEquals(written.size(), result.trim().filesBefore()); + List files = files(result.output()); + List templates = files.stream().filter(file -> file.endsWith(".nbt")).toList(); + assertFalse(files.toString(), templates.isEmpty()); + assertTrue(templates.toString(), templates.stream().allMatch(file -> file.contains("/rocks/boulder"))); + assertTrue(files.toString(), files.stream().noneMatch(file -> file.contains("/rocks/pillar"))); + assertTrue(files.toString(), files.contains("data/iris_export/worldgen/biome/forest.json")); + assertFalse(files.toString(), files.contains("data/iris_export/worldgen/biome/plains.json")); + assertTrue(result.trim().toString(), result.trim().filesRemoved() > 0); + assertEquals(files.size(), result.trim().filesAfter()); + assertEquals(List.of(result.trim()), progress.trims); + List trimmed = result.diagnostics().stream().filter(diagnostic -> diagnostic.limit() == ExportLimit.TRIMMED_CONTENT).toList(); + assertEquals(1, trimmed.size()); + assertSame(ExportDiagnostic.Severity.INFO, trimmed.getFirst().severity()); + assertTrue(trimmed.getFirst().message(), trimmed.getFirst().message().contains(result.trim().filesRemoved() + " of " + result.trim().filesBefore() + " files")); + } + + private WorldgenDatapackExporter exporter() { + return new WorldgenDatapackExporter(target -> new SummaryWriter(target, writerTargets), + new PackCompiler(List.of(new DimensionStage(), new ImageMapStage()))); + } + + private static WorldgenExportRequest.Builder request(File pack, Path root) { + return WorldgenExportRequest.builder() + .pack(pack.toPath()) + .dimension(ExportTestPacks.DIMENSION) + .exportRoot(root); + } + + private static File objectPack(File pack) throws IOException { + CompatTestPlatform.write(pack, "dimensions/" + ExportTestPacks.DIMENSION + ".json", "{\"regions\":[\"region\"]}"); + CompatTestPlatform.write(pack, "regions/region.json", "{\"landBiomes\":[\"forest\",\"plains\"]}"); + CompatTestPlatform.write(pack, "biomes/forest.json", """ + {"name":"Forest","objects":[{"place":["rocks/boulder"],"chance":0.5,"density":1}]} + """); + CompatTestPlatform.write(pack, "biomes/plains.json", """ + {"name":"Plains","objects":[{"place":["rocks/pillar"],"chance":0.5,"density":1}]} + """); + writeObject(pack, "rocks/boulder", "minecraft:stone", "minecraft:grass_block"); + writeObject(pack, "rocks/pillar", "minecraft:stone", "minecraft:stone", "minecraft:stone"); + return pack; + } + + private static void writeObject(File pack, String key, String... column) throws IOException { + File file = new File(pack, "objects/" + key + ".iob"); + if (!file.getParentFile().isDirectory() && !file.getParentFile().mkdirs()) { + throw new IOException("Could not create " + file.getParentFile()); + } + try (DataOutputStream out = new DataOutputStream(new FileOutputStream(file))) { + out.writeInt(1); + out.writeInt(column.length); + out.writeInt(1); + out.writeUTF("Iris V2 IOB;"); + out.writeShort(column.length); + for (String entry : column) { + out.writeUTF(entry); + } + out.writeInt(column.length); + for (int index = 0; index < column.length; index++) { + out.writeShort(0); + out.writeShort(index - column.length / 2); + out.writeShort(0); + out.writeShort(index); + } + out.writeInt(0); + } + } + + private static List entries(Path root) throws IOException { + try (Stream children = Files.list(root)) { + return children.map(path -> path.getFileName().toString()).sorted().toList(); + } + } + + private static List files(Path directory) throws IOException { + try (Stream files = Files.walk(directory)) { + return files.filter(Files::isRegularFile).map(file -> directory.relativize(file).toString().replace('\\', '/')).sorted().toList(); + } + } + + private static String summary(Path zip) throws IOException { + try (ZipFile file = new ZipFile(zip.toFile())) { + return new String(file.getInputStream(file.getEntry("summary.txt")).readAllBytes(), StandardCharsets.UTF_8); + } + } + + private record SummaryWriter(DataVersion target, List targets) implements WorldgenJsonWriter { + @Override + public SortedMap write(ExportIr ir) { + assertSame(target, ir.target()); + targets.add(target); + String summary = "namespace=" + ir.namespace() + "\n" + + "target=" + ir.target().getVersion() + "\n" + + "mode=" + ir.mode() + "\n" + + "dimension=" + ir.dimension() + "\n" + + "noises=" + ir.noises() + "\n" + + "router=" + ir.router() + "\n" + + "climate=" + ir.climate() + "\n" + + "biomes=" + ir.biomes() + "\n" + + "material=" + ir.material() + "\n" + + "diagnostics=" + ir.diagnostics() + "\n"; + SortedMap files = new TreeMap<>(); + files.put("pack.mcmeta", ("{\"pack\":{\"description\":\"" + ir.namespace() + "\"}}\n").getBytes(StandardCharsets.UTF_8)); + files.put("summary.txt", summary.getBytes(StandardCharsets.UTF_8)); + return files; + } + } + + private static final class UnreachablePlainsStage implements CompileStage { + private static final String FOREST = WorldgenExportRequest.DEFAULT_NAMESPACE + ":forest"; + private static final String PLAINS = WorldgenExportRequest.DEFAULT_NAMESPACE + ":plains"; + private static final Field ZERO = new Field.Const(0.0); + + @Override + public String name() { + return "biomes"; + } + + @Override + public void apply(PackCompileContext context) { + DataVersion target = context.capabilities().target(); + context.setBiomes(List.of(new BiomeDef("forest", ExportIrFixtures.biome()), new BiomeDef("plains", ExportIrFixtures.biome()))); + ClimateLayout layout = new ClimateLayout(List.of(new ClimateLayout.ClimateEntry(FOREST, ClimateLayout.ClimateAxis.TEMPERATURE, -1.0, 1.0))); + context.setBiomeCodes(new BiomeCodes(ZERO, ZERO, ZERO, layout, Map.of(FOREST, "forest", PLAINS, "plains"))); + } + } + + private static final class RecordingProgress implements WorldgenExportProgress { + private final List stages = new ArrayList<>(); + private final List resourceTotals = new ArrayList<>(); + private final List trims = new ArrayList<>(); + + @Override + public void stage(String stage) { + stages.add(stage); + } + + @Override + public void resources(int done, int total) { + resourceTotals.add(total); + } + + @Override + public void trimmed(ContentTrim trim) { + trims.add(trim); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/WorldgenExportRequestTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/WorldgenExportRequestTest.java new file mode 100644 index 000000000..97411816f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/WorldgenExportRequestTest.java @@ -0,0 +1,76 @@ +package art.arcane.iris.pack.datapack.export; + +import org.junit.Test; + +import java.nio.file.Path; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class WorldgenExportRequestTest { + private static final Path PACK = Path.of("packs", "overworld"); + private static final Path EXPORT_ROOT = Path.of("exports", "datapacks"); + + @Test + public void builderDefaults() { + WorldgenExportRequest request = WorldgenExportRequest.builder() + .pack(PACK) + .dimension("overworld") + .exportRoot(EXPORT_ROOT) + .build(); + assertEquals(PACK, request.pack()); + assertEquals("overworld", request.dimension()); + assertEquals("", request.version()); + assertEquals("overworld", request.mode()); + assertEquals("iris_export", request.namespace()); + assertEquals("", request.output()); + assertEquals("zip", request.format()); + assertTrue(request.objects()); + assertFalse(request.replace()); + assertEquals(EXPORT_ROOT, request.exportRoot()); + } + + @Test + public void builderOverridesEveryField() { + WorldgenExportRequest request = WorldgenExportRequest.builder() + .pack(PACK) + .dimension(" overworld ") + .version("26.3") + .mode("dimension") + .namespace("my_pack") + .output("custom") + .format("directory") + .objects(false) + .replace(true) + .exportRoot(EXPORT_ROOT) + .build(); + assertEquals("overworld", request.dimension()); + assertEquals("26.3", request.version()); + assertEquals("dimension", request.mode()); + assertEquals("my_pack", request.namespace()); + assertEquals("custom", request.output()); + assertEquals("directory", request.format()); + assertFalse(request.objects()); + assertTrue(request.replace()); + } + + @Test + public void missingPackIsRejected() { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> WorldgenExportRequest.builder().dimension("overworld").exportRoot(EXPORT_ROOT).build()); + assertEquals("Worldgen export request requires a pack directory", error.getMessage()); + } + + @Test + public void blankDimensionIsRejected() { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> WorldgenExportRequest.builder().pack(PACK).dimension(" ").exportRoot(EXPORT_ROOT).build()); + assertEquals("Worldgen export request requires a dimension", error.getMessage()); + } + + @Test + public void missingExportRootIsRejected() { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> WorldgenExportRequest.builder().pack(PACK).dimension("overworld").build()); + assertEquals("Worldgen export request requires an export root", error.getMessage()); + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/WorldgenExportResultTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/WorldgenExportResultTest.java new file mode 100644 index 000000000..5d0e0cc7a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/WorldgenExportResultTest.java @@ -0,0 +1,89 @@ +package art.arcane.iris.pack.datapack.export; + +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import org.junit.Test; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class WorldgenExportResultTest { + private static final Path OUTPUT = Path.of("exports", "datapacks", "overworld-26.3.zip"); + + @Test + public void exportedResultCarriesOutputAndDiagnostics() { + List diagnostics = new ArrayList<>(); + diagnostics.add(new ExportDiagnostic(ExportDiagnostic.Severity.WARNING, ExportLimit.IMAGE_MAP, "overworld", "Image map layer dropped")); + Map loadKeys = new HashMap<>(); + loadKeys.put("iris_export:plains", "Plains"); + ContentTrim trim = new ContentTrim(12, 4096L, 9, 3000L); + Map roots = new EnumMap<>(GenerationCost.Root.class); + roots.put(GenerationCost.Root.TEMPERATURE, new GenerationCost.RootCost(90.0, 120.0, 40L)); + GenerationCost cost = new GenerationCost(roots, 4_096L); + roots.clear(); + WorldgenExportResult result = WorldgenExportResult.exported(OUTPUT, diagnostics, loadKeys, trim, cost); + diagnostics.clear(); + loadKeys.clear(); + assertSame(WorldgenExportResult.Status.EXPORTED, result.status()); + assertTrue(result.isSuccess()); + assertEquals(OUTPUT, result.output()); + assertEquals("", result.message()); + assertEquals(1, result.diagnostics().size()); + assertEquals(Map.of("iris_export:plains", "Plains"), result.loadKeyByBiomeId()); + assertSame(trim, result.trim()); + assertEquals(3, result.trim().filesRemoved()); + assertEquals(1096L, result.trim().bytesRemoved()); + assertEquals(120.0, result.cost().root(GenerationCost.Root.TEMPERATURE).orElseThrow().expandedNodes(), 0.0); + assertTrue(result.cost().root(GenerationCost.Root.DEPTH).isEmpty()); + assertEquals(4_096L, result.cost().densityFunctionBytes()); + } + + @Test + public void exportedResultRequiresOutput() { + assertThrows(NullPointerException.class, () -> WorldgenExportResult.exported(null, List.of(), Map.of(), ContentTrim.NONE, GenerationCost.NONE)); + } + + @Test + public void contentTrimCannotGrow() { + assertThrows(IllegalArgumentException.class, () -> new ContentTrim(1, 10L, 2, 10L)); + assertThrows(IllegalArgumentException.class, () -> new ContentTrim(1, 10L, 1, 11L)); + assertThrows(IllegalArgumentException.class, () -> new ContentTrim(-1, 0L, -1, 0L)); + } + + @Test + public void rejectedResultMayOmitOutput() { + WorldgenExportResult result = WorldgenExportResult.rejected(null, "Pack is invalid", List.of()); + assertSame(WorldgenExportResult.Status.REJECTED, result.status()); + assertFalse(result.isSuccess()); + assertNull(result.output()); + assertEquals("Pack is invalid", result.message()); + assertTrue(result.loadKeyByBiomeId().isEmpty()); + assertSame(ContentTrim.NONE, result.trim()); + assertSame(GenerationCost.NONE, result.cost()); + } + + @Test + public void failedResultKeepsMessage() { + WorldgenExportResult result = WorldgenExportResult.failed(OUTPUT, "Publication failed", List.of()); + assertSame(WorldgenExportResult.Status.FAILED, result.status()); + assertEquals("Publication failed", result.message()); + assertSame(ContentTrim.NONE, result.trim()); + } + + @Test + public void noneProgressIgnoresUpdates() { + WorldgenExportProgress.NONE.stage("compile"); + WorldgenExportProgress.NONE.resources(1, 2); + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/WorldgenExportServicesTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/WorldgenExportServicesTest.java new file mode 100644 index 000000000..eb72d3f04 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/WorldgenExportServicesTest.java @@ -0,0 +1,39 @@ +package art.arcane.iris.pack.datapack.export; + +import org.junit.Test; + +import java.nio.file.Path; +import java.util.List; + +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class WorldgenExportServicesTest { + @Test + public void serviceDefaultsToTheDatapackExporterAndLatestInstallWins() { + WorldgenExportService fallback = WorldgenExportServices.get(); + assertTrue(fallback instanceof WorldgenDatapackExporter); + assertSame(fallback, WorldgenExportServices.get()); + + WorldgenExportService first = new FixedService("first"); + WorldgenExportService second = new FixedService("second"); + WorldgenExportServices.install(first); + assertSame(first, WorldgenExportServices.get()); + + WorldgenExportServices.install(second); + assertSame(second, WorldgenExportServices.get()); + } + + @Test + public void installRejectsMissingService() { + assertThrows(NullPointerException.class, () -> WorldgenExportServices.install(null)); + } + + private record FixedService(String message) implements WorldgenExportService { + @Override + public WorldgenExportResult export(WorldgenExportRequest request, WorldgenExportProgress progress) { + return WorldgenExportResult.rejected(Path.of(message), message, List.of()); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/WorldgenExportTargetsTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/WorldgenExportTargetsTest.java new file mode 100644 index 000000000..9b3079984 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/WorldgenExportTargetsTest.java @@ -0,0 +1,63 @@ +package art.arcane.iris.pack.datapack.export; + +import art.arcane.iris.pack.datapack.DataVersion; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; + +public class WorldgenExportTargetsTest { + @Test + public void supportedTargetsStartAt26_3() { + assertEquals(List.of(DataVersion.V26_3), WorldgenExportTargets.SUPPORTED); + } + + @Test + public void blankResolvesToLatestSupported() { + assertSame(DataVersion.V26_3, WorldgenExportTargets.resolve("")); + assertSame(DataVersion.V26_3, WorldgenExportTargets.resolve(" ")); + assertSame(DataVersion.V26_3, WorldgenExportTargets.resolve(null)); + } + + @Test + public void patchSpellingResolves() { + assertSame(DataVersion.V26_3, WorldgenExportTargets.resolve("26.3.0")); + assertSame(DataVersion.V26_3, WorldgenExportTargets.resolve(" 26.3 ")); + } + + @Test + public void olderVersionsAreRefused() { + for (String older : List.of("26.2", "26.2.0", "26.1.2", "1.21.1")) { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> WorldgenExportTargets.resolve(older)); + assertEquals("Unsupported datapack export version '" + older + "'. Supported: 26.3", error.getMessage()); + } + } + + @Test + public void unknownVersionListsSupported() { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> WorldgenExportTargets.resolve("27.0")); + assertEquals("Unsupported datapack export version '27.0'. Supported: 26.3", error.getMessage()); + } + + @Test + public void blankModeIsOverworld() { + assertSame(WorldgenExportMode.OVERWORLD, WorldgenExportMode.parse("")); + assertSame(WorldgenExportMode.OVERWORLD, WorldgenExportMode.parse(null)); + } + + @Test + public void modeParsingIgnoresCase() { + assertSame(WorldgenExportMode.PRESET, WorldgenExportMode.parse("preset")); + assertSame(WorldgenExportMode.DIMENSION, WorldgenExportMode.parse("Dimension")); + assertSame(WorldgenExportMode.OVERWORLD, WorldgenExportMode.parse(" OVERWORLD ")); + } + + @Test + public void unknownModeMessage() { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> WorldgenExportMode.parse("nether")); + assertEquals("Unknown export mode 'nether'. Expected: preset, dimension, overworld", error.getMessage()); + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/BlockStateResolverTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/BlockStateResolverTest.java new file mode 100644 index 000000000..64027c245 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/BlockStateResolverTest.java @@ -0,0 +1,93 @@ +package art.arcane.iris.pack.datapack.export.compile; + +import art.arcane.iris.pack.datapack.export.ExportTestPacks; +import art.arcane.iris.pack.datapack.export.VanillaBlockState; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import art.arcane.volmlib.nativelib.terrain.NativeBlockProperty; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class BlockStateResolverTest { + @ClassRule + public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Rule + public ExportTestPacks.BoundPlatform platform = new ExportTestPacks.BoundPlatform(); + + @Test + public void invalidPropertyExportsDefaultStateWithDiagnostic() throws Exception { + List diagnostics = new ArrayList<>(); + IrisData data = IrisData.openRuntime(ExportTestPacks.basic(temporaryFolder.newFolder("pack"))); + try { + BlockStateResolver resolver = new BlockStateResolver(data, registries(), diagnostics); + + Optional valid = resolver.resolve("oak_log[axis=x]", "biomes/a"); + Optional invalid = resolver.resolve("OAK_LOG[axis=q]", "biomes/a"); + + assertEquals("minecraft:oak_log[axis=x]", valid.orElseThrow().canonical()); + assertEquals("minecraft:oak_log", invalid.orElseThrow().canonical()); + assertEquals(1, diagnostics.size()); + assertSame(ExportLimit.BLOCK_STATE, diagnostics.getFirst().limit()); + assertEquals("biomes/a", diagnostics.getFirst().resource()); + resolver.resolve("oak_log[axis=q]", "biomes/a"); + assertEquals(1, diagnostics.size()); + } finally { + data.close(); + } + } + + @Test + public void foreignBlocksAreNotExported() throws Exception { + List diagnostics = new ArrayList<>(); + IrisData data = IrisData.openRuntime(ExportTestPacks.basic(temporaryFolder.newFolder("pack"))); + try { + BlockStateResolver resolver = new BlockStateResolver(data, null, diagnostics); + + assertTrue(resolver.resolve("somemod:glowing_rock", "biomes/b").isEmpty()); + assertEquals(1, diagnostics.size()); + assertSame(ExportLimit.BLOCK_STATE, diagnostics.getFirst().limit()); + } finally { + data.close(); + } + } + + @Test + public void legacyNamesFollowTheRenameTable() throws Exception { + IrisData data = IrisData.openRuntime(ExportTestPacks.basic(temporaryFolder.newFolder("pack"))); + try { + BlockStateResolver resolver = new BlockStateResolver(data, registries(), new ArrayList<>()); + + assertEquals("minecraft:short_grass", resolver.resolve("grass", "biomes/c").orElseThrow().canonical()); + } finally { + data.close(); + } + } + + private static PlatformRegistries registries() { + PlatformRegistries registries = mock(PlatformRegistries.class); + when(registries.blockTypeKeys()).thenReturn(List.of("minecraft:oak_log", "minecraft:short_grass", "minecraft:stone")); + when(registries.blockStateProperties()).thenReturn(Map.of("minecraft:oak_log", + List.of(new NativeBlockProperty("axis", "string", "y", List.of("x", "y", "z"), null)))); + return registries; + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/DimensionStageTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/DimensionStageTest.java new file mode 100644 index 000000000..bb2149424 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/DimensionStageTest.java @@ -0,0 +1,114 @@ +package art.arcane.iris.pack.datapack.export.compile; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ExportTestPacks; +import art.arcane.iris.pack.datapack.export.ir.DimensionDef; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import com.google.gson.JsonObject; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class DimensionStageTest { + @ClassRule + public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Rule + public ExportTestPacks.BoundPlatform platform = new ExportTestPacks.BoundPlatform(); + + @Test + public void dimensionDefFollowsTheIrisDimension() throws Exception { + File pack = ExportTestPacks.basic(temporaryFolder.newFolder("pack")); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = ExportTestPacks.context(data, DataVersion.V26_3); + new DimensionStage().apply(context); + + DimensionDef dimension = context.dimensionDef(); + assertEquals("main", dimension.name()); + assertEquals(-64, dimension.minY()); + assertEquals(384, dimension.height()); + assertEquals(256, dimension.logicalHeight()); + assertEquals(63, dimension.fluidHeight()); + assertEquals(64, dimension.seaLevel()); + assertEquals("minecraft:stone", dimension.defaultBlock()); + assertEquals("minecraft:water", dimension.defaultFluid()); + JsonObject type = dimension.dimensionType(); + assertEquals(-64, type.get("min_y").getAsInt()); + assertEquals(384, type.get("height").getAsInt()); + assertEquals(256, type.get("logical_height").getAsInt()); + assertTrue(context.diagnostics().isEmpty()); + } finally { + data.close(); + } + } + + @Test + public void unalignedHeightsExportAlignedWithDiagnostic() throws Exception { + File pack = ExportTestPacks.withDimension(temporaryFolder.newFolder("pack"), """ + {"regions": ["region"], "dimensionHeight": {"min": -60, "max": 250}, "logicalHeight": 310, "fluidHeight": 40} + """); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = ExportTestPacks.context(data, DataVersion.V26_3); + new DimensionStage().apply(context); + + DimensionDef dimension = context.dimensionDef(); + assertEquals(-64, dimension.minY()); + assertEquals(320, dimension.height()); + assertEquals(320, dimension.logicalHeight()); + assertEquals(40, dimension.fluidHeight()); + assertEquals(41, dimension.seaLevel()); + assertEquals(-64, dimension.dimensionType().get("min_y").getAsInt()); + assertEquals(320, dimension.dimensionType().get("height").getAsInt()); + assertEquals(1, context.diagnostics().size()); + assertSame(ExportLimit.DIMENSION_HEIGHT, context.diagnostics().getFirst().limit()); + } finally { + data.close(); + } + } + + @Test + public void paletteBlocksBecomeNamespacedIds() throws Exception { + File pack = ExportTestPacks.withDimension(temporaryFolder.newFolder("pack"), """ + {"regions": ["region"], + "rockPalette": {"palette": [{"block": "DEEPSLATE[axis=y]"}, {"block": "stone"}]}, + "fluidPalette": {"palette": [{"block": "minecraft:lava"}]}} + """); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = ExportTestPacks.context(data, DataVersion.V26_3); + new DimensionStage().apply(context); + + assertEquals("minecraft:deepslate", context.dimensionDef().defaultBlock()); + assertEquals("minecraft:lava", context.dimensionDef().defaultFluid()); + } finally { + data.close(); + } + } + + @Test + public void readingTheDimensionDefBeforeTheStageFails() throws Exception { + File pack = ExportTestPacks.basic(temporaryFolder.newFolder("pack")); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = ExportTestPacks.context(data, DataVersion.V26_3); + assertThrows(IllegalStateException.class, context::dimensionDef); + } finally { + data.close(); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/ImageMapStageTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/ImageMapStageTest.java new file mode 100644 index 000000000..149ffef73 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/ImageMapStageTest.java @@ -0,0 +1,81 @@ +package art.arcane.iris.pack.datapack.export.compile; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ExportTestPacks; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public class ImageMapStageTest { + @ClassRule + public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Rule + public ExportTestPacks.BoundPlatform platform = new ExportTestPacks.BoundPlatform(); + + @Test + public void eachAffectedResourceReportsOneDiagnostic() throws Exception { + File pack = ExportTestPacks.withImageMaps(temporaryFolder.newFolder("pack")); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = ExportTestPacks.context(data, DataVersion.V26_3); + new ImageMapStage().apply(context); + + List resources = new ArrayList<>(); + for (ExportDiagnostic diagnostic : context.diagnostics()) { + assertSame(ExportLimit.IMAGE_MAP, diagnostic.limit()); + assertSame(ExportDiagnostic.Severity.WARNING, diagnostic.severity()); + resources.add(diagnostic.resource()); + } + assertEquals(List.of("dimensions/main", "biomes/mapped"), resources); + } finally { + data.close(); + } + } + + @Test + public void dimensionImageMapLayerIsDropped() throws Exception { + File pack = ExportTestPacks.withImageMaps(temporaryFolder.newFolder("pack")); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = ExportTestPacks.context(data, DataVersion.V26_3); + assertEquals(1, context.dimension().getImageMaps().size()); + + new ImageMapStage().apply(context); + + assertTrue(context.dimension().getImageMaps().isEmpty()); + } finally { + data.close(); + } + } + + @Test + public void packWithoutImageMapsReportsNothing() throws Exception { + File pack = ExportTestPacks.basic(temporaryFolder.newFolder("pack")); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = ExportTestPacks.context(data, DataVersion.V26_3); + new ImageMapStage().apply(context); + + assertTrue(context.diagnostics().toString(), context.diagnostics().isEmpty()); + } finally { + data.close(); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/PackCompilerTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/PackCompilerTest.java new file mode 100644 index 000000000..0fa5d88f2 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/PackCompilerTest.java @@ -0,0 +1,292 @@ +package art.arcane.iris.pack.datapack.export.compile; + +import art.arcane.iris.generation.noise.IrisGeneratorStyle; +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ExportTestPacks; +import art.arcane.iris.pack.datapack.export.ExportTestStyles; +import art.arcane.iris.pack.datapack.export.WorldgenExportMode; +import art.arcane.iris.pack.datapack.export.WorldgenExportProgress; +import art.arcane.iris.pack.datapack.export.compile.decoration.GenerationStep; +import art.arcane.iris.pack.datapack.export.compile.noise.StyleCompiler; +import art.arcane.iris.pack.datapack.export.ir.AquiferDef; +import art.arcane.iris.pack.datapack.export.ir.Axis; +import art.arcane.iris.pack.datapack.export.ir.BinaryResource; +import art.arcane.iris.pack.datapack.export.ir.BiomeDef; +import art.arcane.iris.pack.datapack.export.ir.ClimateLayout; +import art.arcane.iris.pack.datapack.export.ir.ExportIr; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.JsonResource; +import art.arcane.iris.pack.datapack.export.ir.MaterialProgram; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import com.google.gson.JsonArray; +import com.google.gson.JsonParser; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class PackCompilerTest { + private static final Field Y = new Field.Coord(Axis.Y, Field.Tiling.CLAMP, -64, 320, -64.5, 319.5); + + @ClassRule + public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Rule + public ExportTestPacks.BoundPlatform platform = new ExportTestPacks.BoundPlatform(); + + @Test + public void stagesRunInOrderAndReportProgress() throws Exception { + File pack = ExportTestPacks.basic(temporaryFolder.newFolder("pack")); + IrisData data = IrisData.openRuntime(pack); + try { + List applied = new ArrayList<>(); + RecordingProgress progress = new RecordingProgress(); + PackCompiler compiler = new PackCompiler(List.of(new DimensionStage(), new NamedStage("first", applied), new NamedStage("second", applied))); + + compiler.compile(ExportTestPacks.context(data, DataVersion.V26_3), progress); + + assertEquals(List.of("first", "second"), applied); + assertEquals(List.of("dimension", "first", "second", "router"), progress.stages); + } finally { + data.close(); + } + } + + @Test + public void defaultStagesRunInPipelineOrder() throws Exception { + File pack = ExportTestPacks.basic(temporaryFolder.newFolder("pack")); + IrisData data = IrisData.openRuntime(pack); + try { + RecordingProgress progress = new RecordingProgress(); + + ExportIr ir = PackCompiler.create().compile(ExportTestPacks.contextBuilder(data, DataVersion.V26_3).styles(new ExportTestStyles()).build(), progress); + + assertEquals(List.of("dimension", "image-maps", "biomes", "terrain", "rivers", "caves", "surface", "decoration", + "structures", "subterrain", "objects", "router"), progress.stages); + assertEquals(3, ir.biomes().size()); + assertEquals(3, ir.climate().entries().size()); + assertFalse(ir.noises().isEmpty()); + } finally { + data.close(); + } + } + + @Test + public void materialDefaultsToRockWithoutSurfaceStage() throws Exception { + File pack = ExportTestPacks.basic(temporaryFolder.newFolder("pack")); + IrisData data = IrisData.openRuntime(pack); + try { + ExportIr ir = new PackCompiler(List.of(new DimensionStage())).compile(ExportTestPacks.context(data, DataVersion.V26_3), WorldgenExportProgress.NONE); + + assertEquals(MaterialProgram.rock("minecraft:stone"), ir.material()); + } finally { + data.close(); + } + } + + @Test + public void unfilledSlotsUseTheDefaults() throws Exception { + File pack = ExportTestPacks.basic(temporaryFolder.newFolder("pack")); + IrisData data = IrisData.openRuntime(pack); + try { + ExportIr ir = new PackCompiler(List.of(new DimensionStage(), new ImageMapStage())) + .compile(ExportTestPacks.context(data, DataVersion.V26_3), WorldgenExportProgress.NONE); + + Field height = new Field.Const(63.0); + assertEquals("iris_export", ir.namespace()); + assertSame(DataVersion.V26_3, ir.target()); + assertSame(WorldgenExportMode.PRESET, ir.mode()); + assertTrue(ir.noises().isEmpty()); + assertEquals(finalDensity(height, new Field.Const(0.0), new Field.Const(1.0e6)), ir.router().finalDensity()); + assertEquals(new Field.Grid(height, 16, 1), ir.router().chunkSurfaceLevel()); + assertEquals(AquiferDef.dry(height), ir.router().aquifer()); + assertEquals(1, ir.biomes().size()); + BiomeDef biome = ir.biomes().getFirst(); + assertEquals("forest_land", biome.id()); + assertTrue(biome.json().toString(), biome.json().has("effects")); + assertEquals(List.of(new ClimateLayout.ClimateEntry("iris_export:forest_land", ClimateLayout.ClimateAxis.TEMPERATURE, -1.0, 1.0)), + ir.climate().entries()); + assertEquals(new Field.Const(0.0), ir.router().temperature()); + } finally { + data.close(); + } + } + + @Test + public void defaultBiomeCodesMapTheIdToItsLoadKey() throws Exception { + File pack = ExportTestPacks.basic(temporaryFolder.newFolder("pack")); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = ExportTestPacks.context(data, DataVersion.V26_3); + new DimensionStage().apply(context); + + assertEquals(Map.of("iris_export:forest_land", "Forest Land"), context.biomeCodes().loadKeyByBiomeId()); + assertSame(context.biomes(), context.biomes()); + } finally { + data.close(); + } + } + + @Test + public void filledSlotsReplaceTheDefaults() throws Exception { + File pack = ExportTestPacks.basic(temporaryFolder.newFolder("pack")); + IrisData data = IrisData.openRuntime(pack); + try { + Field height = new Field.Noise("iris_export:height", 1.0, 0.0, null, null, null); + Field volumetric = new Field.Noise("iris_export:terrain3d", 1.0, 1.0, null, null, null); + Field caves = new Field.Noise("iris_export:caves", 1.0, 1.0, null, null, null); + CompileStage terrain = new CompileStage() { + @Override + public String name() { + return "terrain"; + } + + @Override + public void apply(PackCompileContext context) { + context.setHeight(height); + context.setVolumetric(volumetric); + context.setCaves(caves); + context.setMaterial(MaterialProgram.rock("minecraft:deepslate")); + } + }; + + ExportIr ir = new PackCompiler(List.of(new DimensionStage(), terrain)) + .compile(ExportTestPacks.context(data, DataVersion.V26_3), WorldgenExportProgress.NONE); + + assertEquals(finalDensity(height, volumetric, caves), ir.router().finalDensity()); + assertEquals(AquiferDef.dry(height), ir.router().aquifer()); + assertEquals(MaterialProgram.rock("minecraft:deepslate"), ir.material()); + } finally { + data.close(); + } + } + + @Test + public void heightSlotRejectsThreeDimensionalFields() throws Exception { + File pack = ExportTestPacks.basic(temporaryFolder.newFolder("pack")); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = ExportTestPacks.context(data, DataVersion.V26_3); + Field volume = new Field.Noise("iris_export:terrain3d", 1.0, 1.0, null, null, null); + + assertThrows(IllegalStateException.class, () -> context.setHeight(volume)); + } finally { + data.close(); + } + } + + @Test + public void compilerCopiesResourcesAndAppliesFeatures() throws Exception { + File pack = ExportTestPacks.basic(temporaryFolder.newFolder("pack")); + IrisData data = IrisData.openRuntime(pack); + try { + JsonResource feature = new JsonResource("data/iris_export/worldgen/feature/tree.json", JsonParser.parseString("{\"type\":\"minecraft:tree\"}")); + BinaryResource template = new BinaryResource("data/iris_export/structure/tree.nbt", new byte[]{10, 0, 0}); + CompileStage decoration = new CompileStage() { + @Override + public String name() { + return "decoration"; + } + + @Override + public void apply(PackCompileContext context) { + context.jsonResources().add(feature); + context.binaryResources().add(template); + context.features().add("iris_export:forest_land", GenerationStep.VEGETAL_DECORATION, "iris_export:tree"); + } + }; + + ExportIr ir = new PackCompiler(List.of(new DimensionStage(), decoration)) + .compile(ExportTestPacks.context(data, DataVersion.V26_3), WorldgenExportProgress.NONE); + + assertEquals(List.of(feature), ir.jsonResources()); + assertEquals(List.of(template), ir.binaryResources()); + JsonArray steps = ir.biomes().getFirst().json().getAsJsonArray("features"); + assertEquals(GenerationStep.values().length, steps.size()); + assertEquals(JsonParser.parseString("[\"iris_export:tree\"]"), steps.get(GenerationStep.VEGETAL_DECORATION.ordinal())); + assertEquals(new JsonArray(), steps.get(GenerationStep.RAW_GENERATION.ordinal())); + } finally { + data.close(); + } + } + + @Test + public void defaultStyleCompilerThrowsUntilInstalled() throws Exception { + File pack = ExportTestPacks.basic(temporaryFolder.newFolder("pack")); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = ExportTestPacks.context(data, DataVersion.V26_3); + StyleCompiler styles = context.styles(); + + IllegalStateException error = assertThrows(IllegalStateException.class, + () -> styles.compile(new IrisGeneratorStyle(), 0L, context.noises(), context.capabilities())); + + assertEquals("No style compiler installed", error.getMessage()); + assertTrue(context.objects()); + } finally { + data.close(); + } + } + + @Test + public void builderInstallsStyleCompilerAndObjectsFlag() throws Exception { + File pack = ExportTestPacks.basic(temporaryFolder.newFolder("pack")); + IrisData data = IrisData.openRuntime(pack); + try { + Field compiled = new Field.Const(2.0); + StyleCompiler styles = (style, seedSalt, registry, capabilities) -> compiled; + + PackCompileContext context = ExportTestPacks.contextBuilder(data, DataVersion.V26_3) + .styles(styles) + .objects(false) + .build(); + + assertSame(styles, context.styles()); + assertSame(compiled, context.styles().compile(new IrisGeneratorStyle(), 1L, context.noises(), context.capabilities())); + assertFalse(context.objects()); + } finally { + data.close(); + } + } + + private static Field finalDensity(Field height, Field volumetric, Field caves) { + Field ramp = new Field.Binary(Field.BinaryOp.MUL, new Field.Const(1.0), new Field.Binary(Field.BinaryOp.SUB, height, Y)); + Field terrain = new Field.Binary(Field.BinaryOp.ADD, ramp, new Field.Grid(volumetric, 4, 8)); + return new Field.Binary(Field.BinaryOp.MIN, terrain, caves); + } + + private record NamedStage(String name, List applied) implements CompileStage { + @Override + public void apply(PackCompileContext context) { + applied.add(name); + } + } + + private static final class RecordingProgress implements WorldgenExportProgress { + private final List stages = new ArrayList<>(); + + @Override + public void stage(String stage) { + stages.add(stage); + } + + @Override + public void resources(int done, int total) { + } + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/RouterCompilerTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/RouterCompilerTest.java new file mode 100644 index 000000000..1546a8bb4 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/RouterCompilerTest.java @@ -0,0 +1,99 @@ +package art.arcane.iris.pack.datapack.export.compile; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.compile.biome.BiomeCodes; +import art.arcane.iris.pack.datapack.export.ir.AquiferDef; +import art.arcane.iris.pack.datapack.export.ir.Axis; +import art.arcane.iris.pack.datapack.export.ir.ClimateLayout; +import art.arcane.iris.pack.datapack.export.ir.DimensionDef; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.FieldEvaluator; +import art.arcane.iris.pack.datapack.export.ir.RouterDef; +import com.google.gson.JsonObject; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class RouterCompilerTest { + private static final Field HEIGHT = new Field.Noise("iris_export:height", 1.0, 0.0, null, null, null); + private static final Field VOLUMETRIC = new Field.Noise("iris_export:terrain3d", 1.0, 1.0, null, null, null); + private static final Field CAVES = new Field.Noise("iris_export:caves", 1.0, 1.0, null, null, null); + private static final Field SURFACE_CODE = new Field.Noise("iris_export:surface_code", 1.0, 0.0, null, null, null); + private static final Field CAVE_CODE = new Field.Noise("iris_export:cave_code", 1.0, 0.0, null, null, null); + private static final Field CLASS_3D = new Field.Noise("iris_export:class3d", 1.0, 1.0, null, null, null); + + @Test + public void finalDensityIsTheMinimumOfTerrainAndCaves() { + RouterDef router = RouterCompiler.compile(HEIGHT, VOLUMETRIC, CAVES, AquiferDef.dry(HEIGHT), codes(), dimension()); + + Field y = new Field.Coord(Axis.Y, Field.Tiling.CLAMP, -64, 320, -64.5, 319.5); + Field ramp = new Field.Binary(Field.BinaryOp.MUL, new Field.Const(1.0), new Field.Binary(Field.BinaryOp.SUB, HEIGHT, y)); + Field terrain = new Field.Binary(Field.BinaryOp.ADD, ramp, new Field.Grid(VOLUMETRIC, 4, 8)); + assertEquals(new Field.Binary(Field.BinaryOp.MIN, terrain, CAVES), router.finalDensity()); + } + + @Test + public void topSolidBlockIsTheRoundedHeight() { + for (double height : new double[]{63.0, 63.4, 63.6, -12.2, 150.7}) { + Field surface = new Field.Const(height); + RouterDef router = RouterCompiler.compile(surface, new Field.Const(0.0), new Field.Const(1.0e6), AquiferDef.dry(surface), codes(), dimension()); + int top = (int) Math.round(height); + + assertTrue(height + " solid at " + top, FieldEvaluator.sample(router.finalDensity(), Map.of(), 0L, DataVersion.V26_3, 0, top, 0) > 0.0); + assertTrue(height + " air at " + (top + 1), FieldEvaluator.sample(router.finalDensity(), Map.of(), 0L, DataVersion.V26_3, 0, top + 1, 0) <= 0.0); + } + } + + @Test + public void cavesCarveFarBelowTheSurface() { + Field surface = new Field.Const(100.0); + RouterDef router = RouterCompiler.compile(surface, new Field.Const(0.0), new Field.Const(-0.25), AquiferDef.dry(surface), codes(), dimension()); + + assertTrue(FieldEvaluator.sample(router.finalDensity(), Map.of(), 0L, DataVersion.V26_3, 0, -40, 0) <= 0.0); + } + + @Test + public void chunkSurfaceLevelIsHeightOnASixteenBlockGrid() { + RouterDef router = RouterCompiler.compile(HEIGHT, VOLUMETRIC, CAVES, AquiferDef.dry(HEIGHT), codes(), dimension()); + + assertEquals(new Field.Grid(HEIGHT, 16, 1), router.chunkSurfaceLevel()); + } + + @Test + public void biomeCodesDriveTheClimateAxesAndTheAquiferPassesThrough() { + AquiferDef aquifer = AquiferDef.dry(HEIGHT); + RouterDef router = RouterCompiler.compile(HEIGHT, VOLUMETRIC, CAVES, aquifer, codes(), dimension()); + + assertSame(SURFACE_CODE, router.temperature()); + assertSame(CAVE_CODE, router.vegetation()); + assertSame(CLASS_3D, router.depth()); + assertEquals(new Field.Const(0.0), router.continents()); + assertEquals(new Field.Const(0.0), router.erosion()); + assertEquals(new Field.Const(0.0), router.ridges()); + assertSame(aquifer, router.aquifer()); + } + + @Test + public void heightMustBeTwoDimensional() { + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> RouterCompiler.compile(VOLUMETRIC, VOLUMETRIC, CAVES, AquiferDef.dry(HEIGHT), codes(), dimension())); + assertEquals("2D field 'height' depends on Y", failure.getMessage()); + } + + private static BiomeCodes codes() { + ClimateLayout layout = new ClimateLayout(List.of( + new ClimateLayout.ClimateEntry("iris_export:plains", ClimateLayout.ClimateAxis.TEMPERATURE, -1.0, 1.0))); + return new BiomeCodes(SURFACE_CODE, CAVE_CODE, CLASS_3D, layout, Map.of("iris_export:plains", "plains")); + } + + private static DimensionDef dimension() { + return DimensionDef.aligned("overworld", -64, 384, 384, 63, "minecraft:stone", "minecraft:water", new JsonObject(), new ArrayList<>()); + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/StageTestPacks.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/StageTestPacks.java new file mode 100644 index 000000000..dcb04cb71 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/StageTestPacks.java @@ -0,0 +1,93 @@ +package art.arcane.iris.pack.datapack.export.compile; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ExportTestPacks; +import art.arcane.iris.pack.datapack.export.compile.biome.BiomeCodes; +import art.arcane.iris.pack.datapack.export.compile.biome.BiomeIdSanitizer; +import art.arcane.iris.pack.datapack.export.ir.ClimateLayout; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.testsupport.CompatTestPlatform; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Predicate; + +public final class StageTestPacks { + public static final String NAMESPACE = "iris_export"; + + private StageTestPacks() { + } + + public static File pack(File folder, String dimensionJson, Map biomes) throws IOException { + CompatTestPlatform.write(folder, "dimensions/" + ExportTestPacks.DIMENSION + ".json", dimensionJson); + StringBuilder land = new StringBuilder(); + for (String key : biomes.keySet()) { + land.append(land.isEmpty() ? "" : ",").append('"').append(key).append('"'); + } + CompatTestPlatform.write(folder, "regions/region.json", "{\"landBiomes\":[" + land + "]}"); + for (Map.Entry biome : biomes.entrySet()) { + CompatTestPlatform.write(folder, "biomes/" + biome.getKey() + ".json", biome.getValue()); + } + return folder; + } + + public static PackCompileContext context(IrisData data, DataVersion target, List biomeKeys) { + PackCompileContext context = ExportTestPacks.context(data, target); + new DimensionStage().apply(context); + Map loadKeys = new LinkedHashMap<>(); + List entries = new ArrayList<>(biomeKeys.size()); + Set taken = new HashSet<>(); + for (int index = 0; index < biomeKeys.size(); index++) { + String id = BiomeIdSanitizer.sanitize(NAMESPACE, biomeKeys.get(index), taken); + loadKeys.put(id, biomeKeys.get(index)); + entries.add(new ClimateLayout.ClimateEntry(id, ClimateLayout.ClimateAxis.TEMPERATURE, -1.0 + index * 0.01, -1.0 + (index + 1) * 0.01)); + } + Field zero = new Field.Const(0.0); + context.setBiomeCodes(new BiomeCodes(zero, zero, zero, new ClimateLayout(entries), loadKeys)); + return context; + } + + public static String biomeId(String key) { + return BiomeIdSanitizer.sanitize(NAMESPACE, key, new HashSet<>()); + } + + public static List find(JsonElement root, Predicate predicate) { + List found = new ArrayList<>(); + collect(root, predicate, found); + return found; + } + + public static List ofType(JsonElement root, String type) { + return find(root, object -> object.has("type") && object.get("type").isJsonPrimitive() && type.equals(object.get("type").getAsString())); + } + + private static void collect(JsonElement element, Predicate predicate, List found) { + if (element.isJsonArray()) { + JsonArray array = element.getAsJsonArray(); + for (JsonElement item : array) { + collect(item, predicate, found); + } + return; + } + if (!element.isJsonObject()) { + return; + } + JsonObject object = element.getAsJsonObject(); + if (predicate.test(object)) { + found.add(object); + } + for (Map.Entry member : object.entrySet()) { + collect(member.getValue(), predicate, found); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/biome/BiomeCodesTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/biome/BiomeCodesTest.java new file mode 100644 index 000000000..be4b0482c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/biome/BiomeCodesTest.java @@ -0,0 +1,41 @@ +package art.arcane.iris.pack.datapack.export.compile.biome; + +import art.arcane.iris.pack.datapack.export.ir.ClimateLayout; +import art.arcane.iris.pack.datapack.export.ir.Field; +import org.junit.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +public class BiomeCodesTest { + private static final Field FLAT = new Field.Noise("iris_export:code", 1.0, 0.0, null, null, null); + private static final Field VOLUME = new Field.Noise("iris_export:class", 1.0, 1.0, null, null, null); + private static final ClimateLayout LAYOUT = new ClimateLayout(List.of( + new ClimateLayout.ClimateEntry("iris_export:plains", ClimateLayout.ClimateAxis.TEMPERATURE, -1.0, 1.0))); + + @Test + public void codeRootsAreTwoDimensional() { + IllegalStateException surface = assertThrows(IllegalStateException.class, + () -> new BiomeCodes(VOLUME, FLAT, VOLUME, LAYOUT, Map.of())); + assertEquals("2D field 'surface code' depends on Y", surface.getMessage()); + IllegalStateException cave = assertThrows(IllegalStateException.class, + () -> new BiomeCodes(FLAT, VOLUME, VOLUME, LAYOUT, Map.of())); + assertEquals("2D field 'cave code' depends on Y", cave.getMessage()); + } + + @Test + public void loadKeysAreCopiedInIdOrderAndMustUseFullIds() { + Map loadKeys = new HashMap<>(); + loadKeys.put("iris_export:zebra", "Zebra"); + loadKeys.put("iris_export:alpha", "Alpha"); + BiomeCodes codes = new BiomeCodes(FLAT, FLAT, VOLUME, LAYOUT, loadKeys); + loadKeys.clear(); + + assertEquals(List.of("iris_export:alpha", "iris_export:zebra"), List.copyOf(codes.loadKeyByBiomeId().keySet())); + assertThrows(IllegalArgumentException.class, () -> new BiomeCodes(FLAT, FLAT, VOLUME, LAYOUT, Map.of("plains", "Plains"))); + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/biome/BiomeDecisionCompilerTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/biome/BiomeDecisionCompilerTest.java new file mode 100644 index 000000000..18673f737 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/biome/BiomeDecisionCompilerTest.java @@ -0,0 +1,407 @@ +package art.arcane.iris.pack.datapack.export.compile.biome; + +import art.arcane.iris.generation.noise.IrisGeneratorStyle; +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ExportTestPacks; +import art.arcane.iris.pack.datapack.export.ExportTestStyles; +import art.arcane.iris.pack.datapack.export.compile.DimensionStage; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; +import art.arcane.iris.pack.datapack.export.compile.noise.NoiseRegistry; +import art.arcane.iris.pack.datapack.export.compile.noise.StyleCompiler; +import art.arcane.iris.pack.datapack.export.compile.terrain.FieldMacros; +import art.arcane.iris.pack.datapack.export.ir.Axis; +import art.arcane.iris.pack.datapack.export.ir.ClimateLayout; +import art.arcane.iris.pack.datapack.export.ir.CostClass; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.FieldEvaluator; +import art.arcane.iris.pack.datapack.export.ir.NoiseDef; +import art.arcane.iris.pack.datapack.export.lower.ExportCapabilities; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class BiomeDecisionCompilerTest { + private static final long WORLD_SEED = 7_331L; + private static final int GRID = 96; + private static final int STRIDE = 24; + + @ClassRule + public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Rule + public ExportTestPacks.BoundPlatform platform = new ExportTestPacks.BoundPlatform(); + + @Test + public void childrenAppliedUpToFourHops() throws Exception { + File pack = SelectionTestPacks.childChain(temporaryFolder.newFolder("pack")); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = context(data); + BiomeDecision decision = new BiomeDecisionCompiler(context.styles()).compile(context); + BiomeCodes codes = decision.bind(new Field.Const(context.dimensionDef().fluidHeight() + 100.0)); + + assertEquals(Set.of("a", "b", "c", "d", "e", "sea"), Set.copyOf(codes.loadKeyByBiomeId().values())); + Map shares = shares(context, codes); + assertEquals(shares.toString(), 0.5, shares.getOrDefault("a", 0.0), 0.15); + assertEquals(shares.toString(), 0.5, shares.getOrDefault("e", 0.0), 0.15); + assertEquals(shares.toString(), 1.0, shares.getOrDefault("a", 0.0) + shares.getOrDefault("e", 0.0), 1.0e-9); + } finally { + data.close(); + } + } + + @Test + public void selectorsCompileThroughTheSelectorStyles() throws Exception { + File pack = SelectionTestPacks.childChain(temporaryFolder.newFolder("pack")); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = context(data); + StyleCompiler selectorsOnly = new StyleCompiler() { + @Override + public Field compile(IrisGeneratorStyle style, long seedSalt, NoiseRegistry registry, ExportCapabilities capabilities) { + throw new AssertionError("Biome selectors compile through the selector styles"); + } + + @Override + public StyleCompiler selectors() { + return context.styles(); + } + }; + + BiomeCodes codes = new BiomeDecisionCompiler(selectorsOnly).compile(context).base(); + + assertFalse(codes.surfaceCode() instanceof Field.Const); + } finally { + data.close(); + } + } + + @Test + public void eachChildHopReadsThePreviousHopOnce() throws Exception { + File pack = SelectionTestPacks.childChainBesideStillBiome(temporaryFolder.newFolder("pack")); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = context(data); + Field base = new BiomeDecisionCompiler(context.styles()).compile(context).base().surfaceCode(); + Map shared = new TreeMap<>(); + collectShared(base, shared, Collections.newSetFromMap(new IdentityHashMap<>())); + + assertTrue(shared.keySet().toString(), shared.containsKey("biome/land/hop2")); + assertEquals(1, references(shared.get("biome/land/hop1").expr(), shared.get("biome/land/pick"))); + assertEquals(1, references(shared.get("biome/land/hop2").expr(), shared.get("biome/land/hop1"))); + } finally { + data.close(); + } + } + + @Test + public void codeRootsAreTwoDimensional() throws Exception { + File pack = SelectionTestPacks.threeRegions(temporaryFolder.newFolder("pack")); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = context(data); + BiomeDecision decision = new BiomeDecisionCompiler(context.styles()).compile(context); + BiomeCodes bound = decision.bind(height(context)); + + for (BiomeCodes codes : List.of(decision.base(), bound)) { + assertFalse(codes.surfaceCode().axes().contains(Axis.Y)); + assertFalse(codes.caveCode().axes().contains(Axis.Y)); + } + assertEquals(EnumSet.of(Axis.X, Axis.Z), bound.surfaceCode().axes()); + assertEquals(EnumSet.of(Axis.X, Axis.Z), bound.caveCode().axes()); + assertTrue(bound.class3d().axes().contains(Axis.Y)); + } finally { + data.close(); + } + } + + @Test + public void expensiveSubtreesSitUnderMuxOrGate() throws Exception { + File pack = SelectionTestPacks.threeRegions(temporaryFolder.newFolder("pack")); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = context(data); + BiomeDecision decision = new BiomeDecisionCompiler(context.styles()).compile(context); + BiomeCodes bound = decision.bind(height(context)); + + List violations = new ArrayList<>(); + for (Field root : List.of(bound.surfaceCode(), bound.caveCode(), bound.class3d(), decision.base().surfaceCode())) { + collectEagerExpensive(root, violations); + } + assertTrue(violations.toString(), violations.isEmpty()); + assertEquals(CostClass.EXPENSIVE, bound.surfaceCode().cost()); + } finally { + data.close(); + } + } + + @Test + public void compilationIsDeterministic() throws Exception { + File pack = SelectionTestPacks.threeRegions(temporaryFolder.newFolder("pack")); + IrisData first = IrisData.openRuntime(pack); + IrisData second = IrisData.openRuntime(pack); + try { + PackCompileContext firstContext = context(first); + PackCompileContext secondContext = context(second); + BiomeDecision firstDecision = new BiomeDecisionCompiler(firstContext.styles()).compile(firstContext); + BiomeDecision secondDecision = new BiomeDecisionCompiler(secondContext.styles()).compile(secondContext); + + assertEquals(firstDecision.base(), secondDecision.base()); + assertEquals(firstDecision.bind(height(firstContext)), secondDecision.bind(height(secondContext))); + assertEquals(firstContext.noises().definitions(), secondContext.noises().definitions()); + } finally { + first.close(); + second.close(); + } + } + + @Test + public void layoutPutsSurfaceCodesOnTemperatureAndUndergroundCodesOnDepth() throws Exception { + File pack = SelectionTestPacks.threeRegions(temporaryFolder.newFolder("pack")); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = context(data); + BiomeCodes codes = new BiomeDecisionCompiler(context.styles()).compile(context).base(); + + Map axes = new TreeMap<>(); + for (ClimateLayout.ClimateEntry entry : codes.layout().entries()) { + axes.put(codes.loadKeyByBiomeId().get(entry.biomeId()), entry.axis()); + assertTrue(entry.toString(), entry.axis() == ClimateLayout.ClimateAxis.TEMPERATURE + ? entry.min() >= -1.0 && entry.max() <= 1.0 + : entry.min() >= 1.25 && entry.max() <= 2.0); + } + assertEquals(ClimateLayout.ClimateAxis.TEMPERATURE, axes.get("birch_forest")); + assertEquals(ClimateLayout.ClimateAxis.DEPTH, axes.get("ice_cave")); + assertEquals(ClimateLayout.ClimateAxis.DEPTH, axes.get("deep_dark")); + assertEquals(18, codes.layout().entries().size()); + } finally { + data.close(); + } + } + + @Test + public void shoreRelabelFollowsHeight() throws Exception { + File pack = SelectionTestPacks.threeRegions(temporaryFolder.newFolder("pack")); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = context(data); + BiomeDecision decision = new BiomeDecisionCompiler(context.styles()).compile(context); + double fluid = context.dimensionDef().fluidHeight(); + + Set deep = Set.copyOf(shares(context, decision.bind(new Field.Const(fluid - 20.0))).keySet()); + Set coast = Set.copyOf(shares(context, decision.bind(new Field.Const(fluid))).keySet()); + Set high = Set.copyOf(shares(context, decision.bind(new Field.Const(fluid + 40.0))).keySet()); + + assertTrue(deep.toString(), Set.of("frozen_ocean", "warm_ocean", "ocean", "deep_ocean").containsAll(deep)); + assertTrue(coast.toString(), Set.of("snowy_beach", "beach").containsAll(coast)); + assertTrue(high.toString(), Set.of("tundra", "taiga", "desert", "savanna", "mesa", "plains", "forest", "birch_forest").containsAll(high)); + assertTrue(high.toString(), high.contains("birch_forest")); + } finally { + data.close(); + } + } + + @Test + public void boundCodesReadTheHeightOncePerComparison() throws Exception { + File pack = SelectionTestPacks.threeRegions(temporaryFolder.newFolder("pack")); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = context(data); + BiomeDecision decision = new BiomeDecisionCompiler(context.styles()).compile(context); + Field height = height(context); + + BiomeCodes bound = decision.bind(height); + + assertEquals(2, references(bound.surfaceCode(), height)); + assertEquals(1, references(bound.class3d(), height)); + assertEquals(0, references(bound.surfaceCode(), decision.base().surfaceCode())); + } finally { + data.close(); + } + } + + @Test + public void caveGateAndCarvingBandSelectUndergroundBiomes() throws Exception { + File pack = SelectionTestPacks.threeRegions(temporaryFolder.newFolder("pack")); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = context(data); + BiomeCodes codes = new BiomeDecisionCompiler(context.styles()).compile(context).bind(new Field.Const(60.0)); + Map noises = noises(context); + + Set carving = new TreeSet<>(); + Set caves = new TreeSet<>(); + Set shallow = new TreeSet<>(); + for (int x = 0; x < GRID; x++) { + for (int z = 0; z < GRID; z++) { + carving.add(biomeAt(codes, noises, x * STRIDE, -48, z * STRIDE)); + caves.add(biomeAt(codes, noises, x * STRIDE, -20, z * STRIDE)); + shallow.add(biomeAt(codes, noises, x * STRIDE, 20, z * STRIDE)); + } + } + assertEquals(Set.of("deep_dark"), carving); + assertTrue(caves.toString(), caves.containsAll(Set.of("ice_cave", "lush_cave", "dripstone_cave"))); + assertFalse(caves.toString(), caves.contains("deep_dark")); + assertTrue(shallow.toString(), shallow.stream().noneMatch(Set.of("ice_cave", "lush_cave", "dripstone_cave", "deep_dark")::contains)); + } finally { + data.close(); + } + } + + static PackCompileContext context(IrisData data) { + PackCompileContext context = ExportTestPacks.contextBuilder(data, DataVersion.V26_3).styles(new ExportTestStyles()).build(); + new DimensionStage().apply(context); + return context; + } + + static Field height(PackCompileContext context) { + String noise = context.noises().register(new NoiseDef("test/height", 0, List.of(1.0))); + return new Field.Binary(Field.BinaryOp.ADD, new Field.Const(context.dimensionDef().fluidHeight()), + new Field.Binary(Field.BinaryOp.MUL, new Field.Const(30.0), new Field.Noise(noise, 1.0 / 300.0, 0.0, null, null, null))); + } + + static Map noises(PackCompileContext context) { + Map noises = new HashMap<>(); + for (NoiseDef definition : context.noises().definitions()) { + noises.put(context.namespace() + ":" + definition.id(), definition); + } + return noises; + } + + static String biomeAt(BiomeCodes codes, Map noises, int x, int y, int z) { + double surface = FieldEvaluator.sample(codes.surfaceCode(), noises, WORLD_SEED, DataVersion.V26_3, x, y, z); + double humidity = FieldEvaluator.sample(codes.caveCode(), noises, WORLD_SEED, DataVersion.V26_3, x, y, z); + double depth = FieldEvaluator.sample(codes.class3d(), noises, WORLD_SEED, DataVersion.V26_3, x, y, z); + String best = null; + double bestFitness = Double.POSITIVE_INFINITY; + for (ClimateLayout.ClimateEntry entry : codes.layout().entries()) { + double fitness = switch (entry.axis()) { + case TEMPERATURE -> square(gap(surface, entry)) + square(humidity) + square(depth); + case HUMIDITY -> square(surface) + square(gap(humidity, entry)) + square(depth); + case DEPTH -> square(surface) + square(humidity) + square(gap(depth, entry)); + }; + if (fitness < bestFitness) { + bestFitness = fitness; + best = entry.biomeId(); + } + } + return codes.loadKeyByBiomeId().get(best); + } + + private static Map shares(PackCompileContext context, BiomeCodes codes) { + Map noises = noises(context); + Map shares = new TreeMap<>(); + double weight = 1.0 / (GRID * GRID); + for (int x = 0; x < GRID; x++) { + for (int z = 0; z < GRID; z++) { + shares.merge(biomeAt(codes, noises, x * STRIDE, 100, z * STRIDE), weight, Double::sum); + } + } + return shares; + } + + private static void collectEagerExpensive(Field field, List violations) { + switch (field) { + case Field.Partition partition -> { + for (Field leaf : partition.leaves()) { + requireNotExpensive(leaf, "partition leaf", violations); + } + collectEagerExpensive(partition.input(), violations); + } + case Field.RangeIf rangeIf -> { + requireNotExpensive(rangeIf.inRange(), "range choice branch", violations); + requireNotExpensive(rangeIf.outOfRange(), "range choice branch", violations); + collectEagerExpensive(rangeIf.input(), violations); + } + case Field.Binary binary -> { + if (binary.op() == Field.BinaryOp.MUL) { + requireNotExpensive(binary.left(), "mul operand", violations); + requireNotExpensive(binary.right(), "mul operand", violations); + } + collectEagerExpensive(binary.left(), violations); + collectEagerExpensive(binary.right(), violations); + } + case Field.Lerp lerp -> { + requireNotExpensive(lerp.first(), "lerp branch", violations); + requireNotExpensive(lerp.second(), "lerp branch", violations); + collectEagerExpensive(lerp.alpha(), violations); + } + case Field.Mux mux -> { + collectEagerExpensive(mux.index(), violations); + for (Field selected : mux.cases()) { + collectEagerExpensive(selected, violations); + } + } + case Field.Gate gate -> { + collectEagerExpensive(gate.condition(), violations); + collectEagerExpensive(gate.expr(), violations); + } + case Field.Shared shared -> collectEagerExpensive(shared.expr(), violations); + case Field.Curve curve -> collectEagerExpensive(curve.input(), violations); + case Field.Unary unary -> collectEagerExpensive(unary.input(), violations); + case Field.Clamp clamp -> collectEagerExpensive(clamp.input(), violations); + case Field.Grid grid -> collectEagerExpensive(grid.expr(), violations); + default -> { + } + } + } + + private static void requireNotExpensive(Field field, String role, List violations) { + if (field.cost() == CostClass.EXPENSIVE) { + violations.add(role + " " + field.getClass().getSimpleName()); + } + } + + private static void collectShared(Field field, Map found, Set visited) { + if (!visited.add(field)) { + return; + } + if (field instanceof Field.Shared shared) { + found.put(shared.name(), shared); + } + for (Field child : FieldMacros.children(field)) { + collectShared(child, found, visited); + } + } + + private static int references(Field field, Field target) { + if (field == target) { + return 1; + } + int count = 0; + for (Field child : FieldMacros.children(field)) { + count += references(child, target); + } + return count; + } + + private static double gap(double value, ClimateLayout.ClimateEntry entry) { + return value < entry.min() ? entry.min() - value : value > entry.max() ? value - entry.max() : 0.0; + } + + private static double square(double value) { + return value * value; + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/biome/BiomeDefinitionCompilerTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/biome/BiomeDefinitionCompilerTest.java new file mode 100644 index 000000000..e2405a09e --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/biome/BiomeDefinitionCompilerTest.java @@ -0,0 +1,203 @@ +package art.arcane.iris.pack.datapack.export.compile.biome; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ExportTestPacks; +import art.arcane.iris.pack.datapack.export.ExportTestStyles; +import art.arcane.iris.pack.datapack.export.compile.DimensionStage; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; +import art.arcane.iris.pack.datapack.export.ir.BiomeDef; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.datapack.export.ir.JsonResource; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.testsupport.CompatTestPlatform; +import art.arcane.iris.testsupport.FakeVanillaDataSource; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.TreeMap; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.doReturn; + +public class BiomeDefinitionCompilerTest { + private static final List LEGACY_KEYS = List.of("surface_builder", "depth", "scale", "precipitation", "category", "starts"); + private static final String MEADOW = "iris_export:meadow"; + private static final String PLAINS_JSON = """ + {"has_precipitation": true, "temperature": 0.8, "downfall": 0.4, + "effects": {"water_color": "#3f76e4", "grass_color": "#91bd59"}, + "attributes": { + "minecraft:audio/background_music": {"default": {"sound": "minecraft:music.overworld.plains"}}, + "minecraft:gameplay/natural_mob_spawns": {"argument": {"spawn_costs": {}, + "spawns_by_category": {"creature": [{"type": "minecraft:sheep", "count": 4, "weight": 12}]}}, "modifier": "overlay"}}, + "carvers": ["minecraft:cave"], + "features": [["minecraft:lake_lava_underground"]]} + """; + + @ClassRule + public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Rule + public ExportTestPacks.BoundPlatform platform = new ExportTestPacks.BoundPlatform(); + + @Test + public void derivativeSpawnsBakedIntoBiome() throws Exception { + IrisData data = IrisData.openRuntime(pack()); + try { + PackCompileContext context = context(data, DataVersion.V26_3); + FakeVanillaDataSource vanilla = FakeVanillaDataSource.of(Map.of( + "data/minecraft/worldgen/biome/plains.json", PLAINS_JSON, + "data/minecraft/tags/worldgen/biome/has_structure/village_plains.json", "{\"values\": [\"minecraft:plains\", \"minecraft:meadow\"]}", + "data/minecraft/tags/worldgen/biome/is_overworld.json", "{\"values\": [{\"id\": \"minecraft:plains\", \"required\": false}, \"#minecraft:is_forest\"]}")); + + BiomeDefinitionCompiler.Definitions definitions = new BiomeDefinitionCompiler(Optional.of(vanilla)) + .compile(context, Map.of(MEADOW, "meadow")); + + JsonObject biome = only(definitions.biomes()).json(); + JsonObject attributes = biome.getAsJsonObject("attributes"); + JsonArray creatures = attributes.getAsJsonObject("minecraft:gameplay/natural_mob_spawns") + .getAsJsonObject("argument").getAsJsonObject("spawns_by_category").getAsJsonArray("creature"); + assertEquals(List.of("minecraft:sheep", "minecraft:fox"), types(creatures)); + assertEquals(JsonParser.parseString("{\"type\": \"minecraft:uniform\", \"min_inclusive\": 1, \"max_inclusive\": 2}"), creatures.get(1).getAsJsonObject().get("count")); + assertTrue(attributes.has("minecraft:audio/background_music")); + assertFalse(attributes.has("minecraft:gameplay/creature_world_gen_spawn_probability")); + assertEquals("#112233", attributes.get("minecraft:visual/sky_color").getAsString()); + assertEquals("#445566", biome.getAsJsonObject("effects").get("grass_color").getAsString()); + assertEquals("#3f76e4", biome.getAsJsonObject("effects").get("water_color").getAsString()); + assertEquals(0.3, biome.get("temperature").getAsDouble(), 1.0e-9); + assertEquals(new JsonArray(), biome.get("carvers")); + assertEquals(new JsonArray(), biome.get("features")); + assertNoLegacyKeys(biome); + Map tags = tags(definitions); + assertEquals(List.of("data/minecraft/tags/worldgen/biome/has_structure/village_plains.json", + "data/minecraft/tags/worldgen/biome/is_forest.json", + "data/minecraft/tags/worldgen/biome/is_overworld.json"), List.copyOf(tags.keySet())); + assertEquals(JsonParser.parseString("{\"replace\": false, \"values\": [\"" + MEADOW + "\"]}"), + tags.get("data/minecraft/tags/worldgen/biome/has_structure/village_plains.json")); + assertTrue(context.diagnostics().isEmpty()); + } finally { + data.close(); + } + } + + @Test + public void withoutVanillaDataBiomesComeFromIrisFields() throws Exception { + IrisData data = IrisData.openRuntime(pack()); + try { + DataVersion target = DataVersion.V26_3; + PackCompileContext context = context(data, target); + + BiomeDefinitionCompiler.Definitions definitions = BiomeDefinitionCompiler.forTarget(target) + .compile(context, Map.of(MEADOW, "meadow", "iris_export:deep", "deep")); + + for (BiomeDef biome : definitions.biomes()) { + assertNoLegacyKeys(biome.json()); + assertTrue(biome.json().toString(), biome.json().has("effects")); + assertTrue(biome.json().toString(), biome.json().has("temperature")); + assertEquals(new JsonArray(), biome.json().get("carvers")); + } + assertFalse(definitions.biomes().getLast().json().has("spawners")); + assertTrue(tags(definitions).containsKey("data/minecraft/tags/worldgen/biome/is_overworld.json")); + assertTrue(tags(definitions).containsKey("data/minecraft/tags/worldgen/biome/is_forest.json")); + assertEquals(List.of(ExportLimit.VANILLA_BIOME_DATA, ExportLimit.BIOME_VARIANT), limits(context.diagnostics())); + } finally { + data.close(); + } + } + + @Test + public void runningVersionSuppliesVanillaData() throws Exception { + IrisData data = IrisData.openRuntime(pack()); + try { + doReturn("26.3").when(IrisPlatforms.get()).minecraftVersion(); + doReturn(FakeVanillaDataSource.of(Map.of("data/minecraft/worldgen/biome/plains.json", PLAINS_JSON))).when(IrisPlatforms.get()).vanillaData(); + PackCompileContext current = context(data, DataVersion.V26_3); + PackCompileContext older = context(data, DataVersion.V26_3); + + JsonObject biome = only(BiomeDefinitionCompiler.forTarget(DataVersion.V26_3).compile(current, Map.of(MEADOW, "meadow")).biomes()).json(); + doReturn("26.2").when(IrisPlatforms.get()).minecraftVersion(); + BiomeDefinitionCompiler.forTarget(DataVersion.V26_3).compile(older, Map.of(MEADOW, "meadow")); + + assertTrue(biome.getAsJsonObject("attributes").has("minecraft:audio/background_music")); + assertTrue(current.diagnostics().isEmpty()); + assertEquals(List.of(ExportLimit.VANILLA_BIOME_DATA), limits(older.diagnostics())); + } finally { + data.close(); + } + } + + private File pack() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + CompatTestPlatform.write(pack, "dimensions/" + ExportTestPacks.DIMENSION + ".json", "{\"regions\": [\"region\"]}"); + CompatTestPlatform.write(pack, "regions/region.json", "{\"landBiomes\": [\"meadow\"], \"seaBiomes\": [\"deep\"]}"); + CompatTestPlatform.write(pack, "biomes/meadow.json", """ + {"name": "Meadow", "derivative": "minecraft:plains", + "customDerivitives": [{"id": "meadow", "temperature": 0.3, "skyColor": "#112233", "grassColor": "#445566", + "tags": ["#minecraft:is_forest"], + "spawns": [{"type": "minecraft:fox", "weight": 3, "minCount": 1, "maxCount": 2, "group": "CREATURE"}]}]} + """); + CompatTestPlatform.write(pack, "biomes/deep.json", """ + {"name": "Deep", "derivative": "minecraft:deep_ocean", "biomeScatter": ["minecraft:deep_ocean", "minecraft:ocean"]} + """); + return pack; + } + + private static PackCompileContext context(IrisData data, DataVersion target) { + PackCompileContext context = ExportTestPacks.contextBuilder(data, target).styles(new ExportTestStyles()).build(); + new DimensionStage().apply(context); + return context; + } + + private static BiomeDef only(List biomes) { + assertEquals(1, biomes.size()); + return biomes.getFirst(); + } + + private static List types(JsonArray spawns) { + List types = new ArrayList<>(); + for (JsonElement spawn : spawns) { + types.add(spawn.getAsJsonObject().get("type").getAsString()); + } + return types; + } + + private static Map tags(BiomeDefinitionCompiler.Definitions definitions) { + Map tags = new TreeMap<>(); + for (JsonResource resource : definitions.tags()) { + tags.put(resource.path(), resource.json()); + } + return tags; + } + + private static List limits(List diagnostics) { + List limits = new ArrayList<>(); + for (ExportDiagnostic diagnostic : diagnostics) { + limits.add(diagnostic.limit()); + } + return limits; + } + + private static void assertNoLegacyKeys(JsonObject biome) { + for (String key : LEGACY_KEYS) { + assertFalse(key + " in " + biome, biome.has(key)); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/biome/BiomeIdSanitizerTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/biome/BiomeIdSanitizerTest.java new file mode 100644 index 000000000..0b16cb770 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/biome/BiomeIdSanitizerTest.java @@ -0,0 +1,68 @@ +package art.arcane.iris.pack.datapack.export.compile.biome; + +import art.arcane.iris.pack.datapack.export.VanillaResourceIdentifier; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class BiomeIdSanitizerTest { + private static final List KEYS = List.of( + "Forest", + "forest", + "forest_2", + "FOREST", + "caves/Dark Cave", + "Caves/dark cave", + "ocean\\deep", + "Odd Name!"); + + @Test + public void collidingKeysGetStableSuffixes() { + List first = sanitizeAll(KEYS); + + assertEquals(List.of( + "iris_export:forest", + "iris_export:forest_2", + "iris_export:forest_2_2", + "iris_export:forest_3", + "iris_export:caves/dark_cave", + "iris_export:caves/dark_cave_2", + "iris_export:ocean/deep", + "iris_export:odd_name_"), first); + assertEquals(first, sanitizeAll(KEYS)); + assertEquals(KEYS.size(), new HashSet<>(first).size()); + for (String id : first) { + assertTrue(id, VanillaResourceIdentifier.validIdentifier(id)); + } + } + + @Test + public void sanitizedIdIsClaimedInTheTakenSet() { + Set taken = new HashSet<>(); + String id = BiomeIdSanitizer.sanitize("iris_export", "Plains", taken); + + assertEquals("iris_export:plains", id); + assertTrue(taken.contains(id)); + } + + @Test + public void invalidNamespaceIsRejected() { + assertThrows(IllegalArgumentException.class, () -> BiomeIdSanitizer.sanitize("Iris Export", "plains", new HashSet<>())); + } + + private static List sanitizeAll(List keys) { + Set taken = new HashSet<>(); + List ids = new ArrayList<>(keys.size()); + for (String key : keys) { + ids.add(BiomeIdSanitizer.sanitize("iris_export", key, taken)); + } + return ids; + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/biome/BiomeShareTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/biome/BiomeShareTest.java new file mode 100644 index 000000000..acb273097 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/biome/BiomeShareTest.java @@ -0,0 +1,170 @@ +package art.arcane.iris.pack.datapack.export.compile.biome; + +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ExportTestPacks; +import art.arcane.iris.pack.datapack.export.ExportTestStyles; +import art.arcane.iris.pack.datapack.export.compile.DimensionStage; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; +import art.arcane.iris.pack.datapack.export.ir.ClimateLayout; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.FieldEvaluator; +import art.arcane.iris.pack.datapack.export.ir.NoiseDef; +import art.arcane.iris.pack.datapack.export.lower.ExportCapabilities; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.testsupport.CompatTestPlatform; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import org.junit.Assume; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.TreeMap; +import java.util.TreeSet; + +import static org.junit.Assert.assertTrue; + +public class BiomeShareTest { + private static final String PROBE_PACK_PROPERTY = "iris.exportProbePack"; + private static final String PROBE_DIMENSION = "overworld"; + private static final int SAMPLES_PER_AXIS = 1024; + private static final double SHARE_TOLERANCE = 0.05; + private static final long WORLD_SEED = 424_242L; + private static final long IRIS_SEED = 9_876_543L; + private static final int REPORTED_BIOMES = 20; + + @ClassRule + public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Rule + public ExportTestPacks.BoundPlatform platform = new ExportTestPacks.BoundPlatform(); + + @Test + public void biomeShareMatchesIrisWithin0_05() throws Exception { + File pack = SelectionTestPacks.threeRegions(temporaryFolder.newFolder("pack")); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = BiomeDecisionCompilerTest.context(data); + Comparison comparison = compare(context, context.dimension(), data, 8); + + assertTrue(comparison.report(), comparison.maximumDelta() <= SHARE_TOLERANCE); + assertTrue(comparison.report(), comparison.iris().size() >= 12); + } finally { + data.close(); + } + } + + @Test + public void overworldBiomeShareMatchesIrisWithin0_05() throws Exception { + String probePack = System.getProperty(PROBE_PACK_PROPERTY); + Assume.assumeTrue("Set -P" + PROBE_PACK_PROPERTY + " to run against a real pack", probePack != null && !probePack.isBlank()); + CompatTestPlatform unrestricted = CompatTestPlatform.bind(temporaryFolder.newFolder("platform"), List.of()); + try { + IrisData data = IrisData.openRuntime(new File(probePack)); + try { + IrisDimension dimension = data.getDimensionLoader().load(PROBE_DIMENSION, false); + PackCompileContext context = PackCompileContext.builder() + .data(data) + .dimension(dimension) + .capabilities(ExportCapabilities.forTarget(DataVersion.V26_3)) + .namespace("iris_export") + .styles(new ExportTestStyles()) + .build(); + new DimensionStage().apply(context); + Comparison comparison = compare(context, dimension, data, 48); + System.out.println(comparison.report()); + + assertTrue(comparison.report(), comparison.maximumDelta() <= SHARE_TOLERANCE); + } finally { + data.close(); + } + } finally { + unrestricted.unbind(); + } + } + + private static Comparison compare(PackCompileContext context, IrisDimension dimension, IrisData data, int stride) { + BiomeDecision decision = new BiomeDecisionCompiler(context.styles()).compile(context); + Field height = BiomeDecisionCompilerTest.height(context); + BiomeCodes codes = decision.bind(height); + Map noises = BiomeDecisionCompilerTest.noises(context); + IrisSelectionReference iris = new IrisSelectionReference(dimension, data, IRIS_SEED); + double fluid = context.dimensionDef().fluidHeight(); + List surface = new ArrayList<>(); + for (ClimateLayout.ClimateEntry entry : codes.layout().entries()) { + if (entry.axis() == ClimateLayout.ClimateAxis.TEMPERATURE) { + surface.add(entry); + } + } + surface.sort(Comparator.comparingDouble(ClimateLayout.ClimateEntry::min)); + Map irisShares = new TreeMap<>(); + Map exportShares = new TreeMap<>(); + double weight = 1.0 / ((double) SAMPLES_PER_AXIS * SAMPLES_PER_AXIS); + int origin = -SAMPLES_PER_AXIS / 2 * stride; + for (int column = 0; column < SAMPLES_PER_AXIS; column++) { + int x = origin + column * stride; + for (int row = 0; row < SAMPLES_PER_AXIS; row++) { + int z = origin + row * stride; + double terrain = FieldEvaluator.sample(height, noises, WORLD_SEED, DataVersion.V26_3, x, 0, z); + irisShares.merge(iris.surfaceBiome(x, z, terrain, fluid), weight, Double::sum); + double code = FieldEvaluator.sample(codes.surfaceCode(), noises, WORLD_SEED, DataVersion.V26_3, x, 0, z); + exportShares.merge(codes.loadKeyByBiomeId().get(entryAt(surface, code).biomeId()), weight, Double::sum); + } + } + return new Comparison(irisShares, exportShares); + } + + private static ClimateLayout.ClimateEntry entryAt(List sorted, double code) { + int low = 0; + int high = sorted.size() - 1; + while (low < high) { + int middle = (low + high + 1) >>> 1; + if (sorted.get(middle).min() <= code) { + low = middle; + } else { + high = middle - 1; + } + } + return sorted.get(low); + } + + private record Comparison(Map iris, Map export) { + double maximumDelta() { + double maximum = 0.0; + for (String biome : biomes()) { + maximum = Math.max(maximum, delta(biome)); + } + return maximum; + } + + String report() { + List biomes = new ArrayList<>(biomes()); + biomes.sort(Comparator.comparingDouble((String biome) -> -iris.getOrDefault(biome, 0.0))); + StringBuilder report = new StringBuilder(String.format(Locale.ROOT, "biomes=%d maxDelta=%.4f%n", biomes.size(), maximumDelta())); + for (String biome : biomes.subList(0, Math.min(REPORTED_BIOMES, biomes.size()))) { + report.append(String.format(Locale.ROOT, " %-40s iris=%.4f export=%.4f%n", biome, iris.getOrDefault(biome, 0.0), export.getOrDefault(biome, 0.0))); + } + return report.toString(); + } + + private TreeSet biomes() { + TreeSet biomes = new TreeSet<>(iris.keySet()); + biomes.addAll(export.keySet()); + return biomes; + } + + private double delta(String biome) { + return Math.abs(iris.getOrDefault(biome, 0.0) - export.getOrDefault(biome, 0.0)); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/biome/BiomeStageTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/biome/BiomeStageTest.java new file mode 100644 index 000000000..750f445a3 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/biome/BiomeStageTest.java @@ -0,0 +1,146 @@ +package art.arcane.iris.pack.datapack.export.compile.biome; + +import art.arcane.iris.pack.datapack.export.ExportTestPacks; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; +import art.arcane.iris.pack.datapack.export.ir.Axis; +import art.arcane.iris.pack.datapack.export.ir.BiomeDef; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.JsonResource; +import art.arcane.iris.pack.datapack.export.ir.NoiseDef; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public class BiomeStageTest { + @ClassRule + public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Rule + public ExportTestPacks.BoundPlatform platform = new ExportTestPacks.BoundPlatform(); + + @Test + public void stageFillsCodesBiomesAndTags() throws Exception { + File pack = SelectionTestPacks.threeRegions(temporaryFolder.newFolder("pack")); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = BiomeDecisionCompilerTest.context(data); + new BiomeStage().apply(context); + + BiomeCodes codes = context.biomeCodes(); + Set defined = new TreeSet<>(); + for (BiomeDef biome : context.biomes()) { + defined.add(context.namespace() + ":" + biome.id()); + } + assertEquals(codes.loadKeyByBiomeId().keySet(), defined); + assertEquals(18, defined.size()); + List tagPaths = new ArrayList<>(); + for (JsonResource resource : context.jsonResources()) { + tagPaths.add(resource.path()); + } + assertTrue(tagPaths.toString(), tagPaths.contains("data/minecraft/tags/worldgen/biome/is_overworld.json")); + assertEquals(new Field.Const(0.0), codes.class3d()); + } finally { + data.close(); + } + } + + @Test + public void codesBindToTheHeightOnceItIsSet() throws Exception { + File pack = SelectionTestPacks.threeRegions(temporaryFolder.newFolder("pack")); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = BiomeDecisionCompilerTest.context(data); + new BiomeStage().apply(context); + BiomeCodes base = context.biomeCodes(); + Field height = BiomeDecisionCompilerTest.height(context); + + context.setHeight(height); + BiomeCodes bound = context.biomeCodes(); + + assertNotEquals(base.surfaceCode(), bound.surfaceCode()); + assertSame(bound, context.biomeCodes()); + assertEquals(base.caveCode(), bound.caveCode()); + assertEquals(base.layout(), bound.layout()); + assertTrue(bound.class3d().axes().contains(Axis.Y)); + assertFalse(bound.surfaceCode().axes().contains(Axis.Y)); + } finally { + data.close(); + } + } + + @Test + public void climateHeightDecidesTheBiomesUntilTheHeightIsReplaced() throws Exception { + File pack = SelectionTestPacks.childChain(temporaryFolder.newFolder("pack")); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = BiomeDecisionCompilerTest.context(data); + new BiomeStage().apply(context); + int seaLevel = context.dimensionDef().fluidHeight(); + Field natural = new Field.Const(seaLevel + 40.0); + Field climate = new Field.Const(seaLevel - 20.0); + Map noises = BiomeDecisionCompilerTest.noises(context); + int seaFloor = seaLevel - 20; + context.setHeight(natural); + + context.setClimateHeight(climate); + + assertSame(climate, context.climateHeight()); + assertSame(natural, context.height()); + assertEquals("sea", BiomeDecisionCompilerTest.biomeAt(context.biomeCodes(), noises, 0, seaFloor, 0)); + context.setCarvedHeight(new Field.Const(seaLevel - 30.0)); + assertEquals("sea", BiomeDecisionCompilerTest.biomeAt(context.biomeCodes(), noises, 0, seaFloor, 0)); + context.setHeight(natural); + assertSame(natural, context.climateHeight()); + assertNotEquals("sea", BiomeDecisionCompilerTest.biomeAt(context.biomeCodes(), noises, 0, seaFloor, 0)); + } finally { + data.close(); + } + } + + @Test + public void carvedHeightKeepsTheBiomesOfTheNaturalHeight() throws Exception { + File pack = SelectionTestPacks.childChain(temporaryFolder.newFolder("pack")); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = BiomeDecisionCompilerTest.context(data); + new BiomeStage().apply(context); + int seaLevel = context.dimensionDef().fluidHeight(); + Field natural = new Field.Const(seaLevel + 40.0); + Field carved = new Field.Const(seaLevel - 20.0); + context.setHeight(natural); + BiomeCodes land = context.biomeCodes(); + + context.setCarvedHeight(carved); + + assertSame(carved, context.height()); + assertSame(natural, context.naturalHeight()); + assertSame(land, context.biomeCodes()); + Map noises = BiomeDecisionCompilerTest.noises(context); + int riverBed = seaLevel - 20; + assertNotEquals("sea", BiomeDecisionCompilerTest.biomeAt(context.biomeCodes(), noises, 0, riverBed, 0)); + context.setHeight(carved); + assertEquals("sea", BiomeDecisionCompilerTest.biomeAt(context.biomeCodes(), noises, 0, riverBed, 0)); + } finally { + data.close(); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/biome/IrisSelectionReference.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/biome/IrisSelectionReference.java new file mode 100644 index 000000000..c17a1818f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/biome/IrisSelectionReference.java @@ -0,0 +1,136 @@ +package art.arcane.iris.pack.datapack.export.compile.biome; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.stream.RarityStream; +import art.arcane.iris.generation.terrain.ContinentGenerator; +import art.arcane.iris.generation.terrain.InferredType; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.generation.terrain.IrisRegion; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.pack.value.IrisRaritySelection; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.noise.CNG; + +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +final class IrisSelectionReference { + private static final int MAX_IMPLODE = 3; + + private final IrisData data; + private final double landChance; + private final RarityStream regions; + private final CNG continent; + private final Map>> picks = new HashMap<>(); + private final Map> plans = new HashMap<>(); + private final long childStyleSeed; + + IrisSelectionReference(IrisDimension dimension, IrisData data, long seed) { + this.data = data; + RNG rng = new RNG(seed); + childStyleSeed = IrisBiome.childStyleSeed(rng, 123); + landChance = dimension.getLandChance(); + KList loaded = new KList<>(); + for (IrisRegion region : data.getRegionLoader().loadAll(dimension.getRegions())) { + if (!region.isCompatExcluded()) { + loaded.add(region); + } + } + regions = new RarityStream<>(dimension.getRegionStyle().create(rng.nextParallelRNG(883), data).stream().zoom(dimension.getRegionZoom()), loaded); + continent = ContinentGenerator.create(dimension, rng, data); + for (IrisRegion region : loaded) { + Map> streams = new EnumMap<>(InferredType.class); + streams.put(InferredType.LAND, new RarityStream<>(dimension.getLandBiomeStyle().create(rng.nextParallelRNG(InferredType.LAND.ordinal()), data).stream() + .zoom(dimension.getBiomeZoom()).zoom(dimension.getLandZoom()).zoom(region.getLandBiomeZoom()), pool(region.getLandBiomes()))); + streams.put(InferredType.SEA, new RarityStream<>(dimension.getSeaBiomeStyle().create(rng.nextParallelRNG(InferredType.SEA.ordinal()), data).stream() + .zoom(dimension.getBiomeZoom()).zoom(dimension.getSeaZoom()).zoom(region.getSeaBiomeZoom()), pool(region.getSeaBiomes()))); + streams.put(InferredType.SHORE, new RarityStream<>(dimension.getShoreBiomeStyle().create(rng.nextParallelRNG(InferredType.SHORE.ordinal()), data).stream() + .zoom(dimension.getBiomeZoom()).zoom(region.getShoreBiomeZoom()), pool(region.getShoreBiomes()))); + picks.put(region, streams); + } + } + + String surfaceBiome(double x, double z, double height, double fluid) { + IrisRegion region = regions.get(x, z); + boolean landBase = continent.noise(x, z) < landChance; + IrisBiome base = implode(pick(region, landBase ? InferredType.LAND : InferredType.SEA, x, z), x, z, MAX_IMPLODE); + InferredType relabel = relabel(height, fluid + region.getShoreHeight(x, z), fluid, landBase); + if (relabel == null) { + return base.getLoadKey(); + } + return implode(pick(region, relabel, x, z), x, z, MAX_IMPLODE).getLoadKey(); + } + + private IrisBiome pick(IrisRegion region, InferredType type, double x, double z) { + IrisBiome picked = picks.get(region).get(type).get(x, z); + if (picked != null) { + return picked; + } + return switch (type) { + case LAND -> picks.get(region).get(InferredType.SEA).get(x, z); + case SEA -> picks.get(region).get(InferredType.LAND).get(x, z); + default -> pick(region, continent.noise(x, z) < landChance ? InferredType.LAND : InferredType.SEA, x, z); + }; + } + + private IrisBiome implode(IrisBiome biome, double x, double z, int remaining) { + if (remaining < 0 || biome.getChildren().isEmpty()) { + return biome; + } + IrisRaritySelection selection = plans.computeIfAbsent(biome.getLoadKey(), ignored -> plan(biome)); + long maximum = selection.size() - 1L; + IrisBiome selected = maximum <= 0L + ? selection.get(0L) + : selection.get(biome.getChildrenGenerator(childStyleSeed, biome.getChildShrinkFactor()).fit2D(0, (int) maximum, x, z)); + if (selected.getLoadKey().equals(biome.getLoadKey())) { + return biome; + } + return implode(selected, x, z, remaining - 1); + } + + private IrisRaritySelection plan(IrisBiome biome) { + KList options = new KList<>(); + for (IrisBiome child : biome.getRealChildren(() -> data)) { + if (child != null) { + options.add(child); + } + } + options.add(biome); + return IrisRaritySelection.create(options); + } + + private List pool(KList keys) { + List pool = new ArrayList<>(); + for (IrisBiome biome : data.getBiomeLoader().loadAll(keys)) { + if (!biome.isCompatExcluded()) { + pool.add(biome); + } + } + return pool; + } + + private static InferredType relabel(double height, double bandTop, double fluid, boolean landBase) { + boolean above = height > bandTop; + if (height >= fluid - 1) { + if (!above) { + return InferredType.SHORE; + } + if (!landBase) { + return InferredType.LAND; + } + } else if (!landBase && above) { + return InferredType.LAND; + } + if (height < fluid && landBase) { + return InferredType.SEA; + } + if (height == fluid) { + return InferredType.SHORE; + } + return null; + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/biome/SelectionTestPacks.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/biome/SelectionTestPacks.java new file mode 100644 index 000000000..46d6b2484 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/biome/SelectionTestPacks.java @@ -0,0 +1,109 @@ +package art.arcane.iris.pack.datapack.export.compile.biome; + +import art.arcane.iris.pack.datapack.export.ExportTestPacks; +import art.arcane.iris.testsupport.CompatTestPlatform; + +import java.io.File; +import java.io.IOException; +import java.util.Map; + +final class SelectionTestPacks { + private static final String STYLES = """ + "regionStyle": {"style": "SIMPLEX"}, + "continentalStyle": {"style": "SIMPLEX"}, + "landBiomeStyle": {"style": "SIMPLEX"}, + "seaBiomeStyle": {"style": "SIMPLEX"}, + "shoreBiomeStyle": {"style": "SIMPLEX"}, + "caveBiomeStyle": {"style": "SIMPLEX", "fracture": {"style": "SIMPLEX", "multiplier": 20}} + """; + + private SelectionTestPacks() { + } + + static File threeRegions(File pack) throws IOException { + dimension(pack, """ + "regions": ["cold", "warm", "temperate"], + "regionZoom": 4, + "continentZoom": 3, + "landChance": 0.6, + "carving": [{"id": "deep", "biome": "deep_dark", "worldYRange": {"min": -60, "max": -40}}] + """); + CompatTestPlatform.write(pack, "regions/cold.json", """ + {"landBiomes": ["tundra", "taiga"], "seaBiomes": ["frozen_ocean"], "shoreBiomes": ["snowy_beach"], "caveBiomes": ["ice_cave"]} + """); + CompatTestPlatform.write(pack, "regions/warm.json", """ + {"rarity": 2, "landBiomeZoom": 2, "landBiomes": ["desert", "savanna", "mesa"], "seaBiomes": ["warm_ocean"], + "shoreBiomes": ["beach"], "shoreHeightMin": 2, "shoreHeightMax": 6} + """); + CompatTestPlatform.write(pack, "regions/temperate.json", """ + {"landBiomes": ["plains", "forest"], "seaBiomes": ["ocean", "deep_ocean"], "shoreBiomes": ["beach"], + "caveBiomes": ["lush_cave", "dripstone_cave"]} + """); + biomes(pack, Map.ofEntries( + Map.entry("tundra", "\"derivative\": \"minecraft:snowy_plains\""), + Map.entry("taiga", "\"derivative\": \"minecraft:taiga\", \"rarity\": 2"), + Map.entry("frozen_ocean", "\"derivative\": \"minecraft:frozen_ocean\""), + Map.entry("snowy_beach", "\"derivative\": \"minecraft:snowy_beach\""), + Map.entry("ice_cave", "\"derivative\": \"minecraft:frozen_peaks\", \"caveMinDepthBelowSurface\": 20"), + Map.entry("desert", "\"derivative\": \"minecraft:desert\""), + Map.entry("savanna", "\"derivative\": \"minecraft:savanna\""), + Map.entry("mesa", "\"derivative\": \"minecraft:badlands\", \"rarity\": 3"), + Map.entry("warm_ocean", "\"derivative\": \"minecraft:warm_ocean\""), + Map.entry("beach", "\"derivative\": \"minecraft:beach\""), + Map.entry("plains", "\"derivative\": \"minecraft:plains\""), + Map.entry("forest", "\"derivative\": \"minecraft:forest\", \"children\": [\"birch_forest\"], \"childShrinkFactor\": 2"), + Map.entry("birch_forest", "\"derivative\": \"minecraft:birch_forest\""), + Map.entry("ocean", "\"derivative\": \"minecraft:ocean\""), + Map.entry("deep_ocean", "\"derivative\": \"minecraft:deep_ocean\", \"rarity\": 2"), + Map.entry("lush_cave", "\"derivative\": \"minecraft:lush_caves\""), + Map.entry("dripstone_cave", "\"derivative\": \"minecraft:dripstone_caves\", \"rarity\": 2"), + Map.entry("deep_dark", "\"derivative\": \"minecraft:deep_dark\""))); + return pack; + } + + static File childChain(File pack) throws IOException { + dimension(pack, """ + "regions": ["chain"], + "landChance": 1.5 + """); + CompatTestPlatform.write(pack, "regions/chain.json", """ + {"landBiomes": ["a"], "seaBiomes": ["sea"], "shoreBiomes": ["sea"]} + """); + biomes(pack, Map.of( + "a", "\"children\": [\"b\"], \"childStyle\": {\"style\": \"SIMPLEX\"}", + "b", "\"children\": [\"c\"], \"childStyle\": {\"style\": \"SIMPLEX\"}", + "c", "\"children\": [\"d\"], \"childStyle\": {\"style\": \"SIMPLEX\"}", + "d", "\"children\": [\"e\"], \"childStyle\": {\"style\": \"SIMPLEX\"}", + "e", "\"children\": [\"f\"], \"childStyle\": {\"style\": \"SIMPLEX\"}", + "f", "\"derivative\": \"minecraft:plains\"", + "sea", "\"derivative\": \"minecraft:ocean\"")); + return pack; + } + + static File childChainBesideStillBiome(File pack) throws IOException { + dimension(pack, """ + "regions": ["chain"], + "landChance": 1.5 + """); + CompatTestPlatform.write(pack, "regions/chain.json", """ + {"landBiomes": ["a", "still"], "seaBiomes": ["sea"], "shoreBiomes": ["sea"]} + """); + biomes(pack, Map.of( + "a", "\"children\": [\"b\"], \"childStyle\": {\"style\": \"SIMPLEX\"}", + "b", "\"children\": [\"c\"], \"childStyle\": {\"style\": \"SIMPLEX\"}", + "c", "\"derivative\": \"minecraft:plains\"", + "still", "\"derivative\": \"minecraft:forest\"", + "sea", "\"derivative\": \"minecraft:ocean\"")); + return pack; + } + + private static void dimension(File pack, String body) throws IOException { + CompatTestPlatform.write(pack, "dimensions/" + ExportTestPacks.DIMENSION + ".json", "{" + STYLES + "," + body + "}"); + } + + private static void biomes(File pack, Map bodies) throws IOException { + for (Map.Entry biome : bodies.entrySet()) { + CompatTestPlatform.write(pack, "biomes/" + biome.getKey() + ".json", "{\"name\": \"" + biome.getKey() + "\", " + biome.getValue() + "}"); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/cave/AquiferCompilerTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/cave/AquiferCompilerTest.java new file mode 100644 index 000000000..a278421d1 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/cave/AquiferCompilerTest.java @@ -0,0 +1,208 @@ +package art.arcane.iris.pack.datapack.export.compile.cave; + +import art.arcane.iris.generation.cave.IrisCaveProfile; +import art.arcane.iris.pack.datapack.export.ExportTestPacks; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; +import art.arcane.iris.pack.datapack.export.ir.AquiferDef; +import art.arcane.iris.pack.datapack.export.ir.Axis; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class AquiferCompilerTest { + private static final Field SURFACE = new Field.Const(120.0); + private static final Field SEA_FLOOR_SLOPE = new Field.Coord(Axis.X, Field.Tiling.CLAMP, -1000, 1000, -100.0, 300.0); + private static final Field HIGH_ESTIMATE = new Field.Const(120.0); + private static final int SEA_X = -275; + private static final int LAND_X = 400; + private static final int DEEP_SEA_X = -800; + private static final int SHORE_X = -165; + private static final int HILL_X = -125; + private static final int SEA_FLOOR = 45; + private static final int FLUID_HEIGHT = 63; + private static final double FULLY_FLOODED = 0.8; + private static final double DRY = -1.0; + private static final double PARTIAL = 0.6; + private static final int FLUID_BAND_HEIGHT = 40; + private static final int FLUID_BAND_CENTER = 20; + + @ClassRule + public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Rule + public ExportTestPacks.BoundPlatform platform = new ExportTestPacks.BoundPlatform(); + + @Test + public void dryProfilesHaveNegativeFloodedness() throws Exception { + PackCompileContext context = compile(CaveTestPacks.dimension(20), CaveTestPacks.constantProfile(-0.1)); + + AquiferDef aquifer = context.aquifer(); + AquiferDef vanilla = AquiferDef.dry(SURFACE); + assertEquals(vanilla.barrier(), aquifer.barrier()); + assertEquals(vanilla.spread(), aquifer.spread()); + assertEquals(vanilla.lava(), aquifer.lava()); + assertEquals(vanilla.exclusion(), aquifer.exclusion()); + assertEquals(vanilla.surfaceLevel(), aquifer.surfaceLevel()); + for (int y = CaveTestPacks.MIN_Y; y < CaveTestPacks.MAX_Y; y += 8) { + assertEquals(DRY, CaveTestPacks.sample(aquifer.floodedness(), context.noises(), 17, y, -9), 0.0); + } + } + + @Test + public void openWaterAboveTheTerrainFloodsToSeaLevel() throws Exception { + PackCompileContext context = compile(CaveTestPacks.dimension(20), CaveTestPacks.constantProfile(-0.1), SEA_FLOOR_SLOPE, SEA_FLOOR_SLOPE); + + Field floodedness = context.aquifer().floodedness(); + assertTrue(CaveTestPacks.sample(floodedness, context.noises(), SEA_X, SEA_FLOOR + 1, 4) > FULLY_FLOODED); + assertTrue(CaveTestPacks.sample(floodedness, context.noises(), SEA_X, FLUID_HEIGHT, 4) > FULLY_FLOODED); + assertTrue(CaveTestPacks.sample(floodedness, context.noises(), SEA_X, FLUID_HEIGHT + 6, 4) > FULLY_FLOODED); + assertTrue(CaveTestPacks.sample(floodedness, context.noises(), SEA_X, SEA_FLOOR - 4, 4) > FULLY_FLOODED); + assertEquals(DRY, CaveTestPacks.sample(floodedness, context.noises(), SEA_X, SEA_FLOOR - 12, 4), 0.0); + assertEquals(DRY, CaveTestPacks.sample(floodedness, context.noises(), LAND_X, FLUID_HEIGHT - 10, 4), 0.0); + assertEquals(DRY, CaveTestPacks.sample(floodedness, context.noises(), LAND_X, SEA_FLOOR + 1, 4), 0.0); + } + + @Test + public void openWaterLiftStaysNearSeaLevel() throws Exception { + PackCompileContext context = compile(CaveTestPacks.dimension(20), CaveTestPacks.constantProfile(-0.1), SEA_FLOOR_SLOPE, SEA_FLOOR_SLOPE); + + Field floodedness = context.aquifer().floodedness(); + assertEquals(DRY, CaveTestPacks.sample(floodedness, context.noises(), DEEP_SEA_X, FLUID_HEIGHT - 40, 4), 0.0); + assertTrue(CaveTestPacks.sample(floodedness, context.noises(), DEEP_SEA_X, FLUID_HEIGHT - 20, 4) > FULLY_FLOODED); + assertEquals(DRY, CaveTestPacks.sample(floodedness, context.noises(), SEA_X, FLUID_HEIGHT + 12, 4), 0.0); + } + + @Test + public void openWaterLiftStopsAtTheShore() throws Exception { + PackCompileContext context = compile(CaveTestPacks.dimension(20), CaveTestPacks.constantProfile(-0.1), SEA_FLOOR_SLOPE, SEA_FLOOR_SLOPE); + + Field floodedness = context.aquifer().floodedness(); + assertTrue(CaveTestPacks.sample(floodedness, context.noises(), SHORE_X, FLUID_HEIGHT, 4) > FULLY_FLOODED); + assertEquals(DRY, CaveTestPacks.sample(floodedness, context.noises(), HILL_X, FLUID_HEIGHT + 7, 4), 0.0); + } + + @Test + public void openWaterLiftSkipsColumnsWithAHighSurfaceEstimate() throws Exception { + PackCompileContext context = compile(CaveTestPacks.dimension(20), CaveTestPacks.constantProfile(-0.1), SEA_FLOOR_SLOPE, HIGH_ESTIMATE); + + assertEquals(DRY, CaveTestPacks.sample(context.aquifer().floodedness(), context.noises(), SEA_X, SEA_FLOOR + 1, 4), 0.0); + } + + @Test + public void wetCavesBelowTheTerrainKeepPartialFlooding() throws Exception { + PackCompileContext context = compile(CaveTestPacks.dimension(20), CaveTestPacks.constantProfile(-0.1).setAllowFluid(true), SEA_FLOOR_SLOPE, SEA_FLOOR_SLOPE); + + Field floodedness = context.aquifer().floodedness(); + assertTrue(CaveTestPacks.sample(floodedness, context.noises(), SEA_X, SEA_FLOOR + 6, 4) > FULLY_FLOODED); + assertEquals(PARTIAL, CaveTestPacks.sample(floodedness, context.noises(), SEA_X, SEA_FLOOR - 24, 4), 1.0e-6); + assertEquals(PARTIAL, CaveTestPacks.sample(floodedness, context.noises(), LAND_X, FLUID_HEIGHT - 10, 4), 1.0e-6); + } + + @Test + public void lavaAboveMinus10ReportsLimit() throws Exception { + PackCompileContext context = compile(CaveTestPacks.dimension(60), CaveTestPacks.constantProfile(-0.1).setAllowLava(true)); + + List limits = context.diagnostics().stream().filter(diagnostic -> diagnostic.limit() == ExportLimit.LAVA_HEIGHT).toList(); + assertEquals(1, limits.size()); + assertEquals("dimensions/" + ExportTestPacks.DIMENSION, limits.getFirst().resource()); + for (int y = CaveTestPacks.MIN_Y; y < 0; y += 4) { + assertEquals(DRY, CaveTestPacks.sample(context.aquifer().floodedness(), context.noises(), 3, y, 5), 0.0); + } + } + + @Test + public void lavaFillsToProfileLavaHeight() throws Exception { + int lavaY = CaveTestPacks.MIN_Y + 20; + PackCompileContext context = compile(CaveTestPacks.dimension(20), CaveTestPacks.constantProfile(-0.1).setAllowLava(true)); + + AquiferDef aquifer = context.aquifer(); + assertFalse(context.diagnostics().stream().anyMatch(diagnostic -> diagnostic.limit() == ExportLimit.LAVA_HEIGHT)); + assertEquals(PARTIAL, CaveTestPacks.sample(aquifer.floodedness(), context.noises(), 4, lavaY - 6, 4), 1.0e-6); + assertEquals(PARTIAL, CaveTestPacks.sample(aquifer.floodedness(), context.noises(), 4, lavaY + 12, 4), 1.0e-6); + assertEquals(DRY, CaveTestPacks.sample(aquifer.floodedness(), context.noises(), 4, lavaY + 13, 4), 0.0); + assertEquals(DRY, CaveTestPacks.sample(aquifer.floodedness(), context.noises(), 4, 40, 4), 0.0); + int lavaBand = Math.floorDiv(lavaY + 12, FLUID_BAND_HEIGHT); + for (int band = Math.floorDiv(CaveTestPacks.MIN_Y, FLUID_BAND_HEIGHT); band <= lavaBand; band++) { + double spread = CaveTestPacks.sample(aquifer.spread(), context.noises(), 7, band, -2); + int level = band * FLUID_BAND_HEIGHT + FLUID_BAND_CENTER + (int) Math.floor(spread * 10.0 / 3.0) * 3; + assertTrue("band " + band + " level " + level, level <= lavaY && level >= lavaY - 2); + assertTrue(Math.abs(CaveTestPacks.sample(aquifer.lava(), context.noises(), 7, band, -2)) > 0.3); + } + assertTrue(Math.abs(CaveTestPacks.sample(aquifer.lava(), context.noises(), 7, lavaBand + 1, -2)) <= 0.3); + } + + @Test + public void wetProfilesFloodBelowSeaLevel() throws Exception { + PackCompileContext context = compile(CaveTestPacks.dimension(20), CaveTestPacks.constantProfile(-0.1).setAllowFluid(true)); + + assertEquals(PARTIAL, CaveTestPacks.sample(context.aquifer().floodedness(), context.noises(), 4, 30, 4), 1.0e-6); + assertEquals(DRY, CaveTestPacks.sample(context.aquifer().floodedness(), context.noises(), 4, 100, 4), 0.0); + assertEquals(0.0, CaveTestPacks.sample(context.aquifer().lava(), context.noises(), 4, 0, 4), 0.0); + } + + @Test + public void bandProfileDecidesFloodednessInsideItsBand() throws Exception { + JsonObject dimension = CaveTestPacks.dimension(20); + JsonArray carving = new JsonArray(); + carving.add(CaveTestPacks.band("deep-band", "cave-band", -60, -30)); + dimension.add("carving", carving); + File pack = CaveTestPacks.pack(temporaryFolder.newFolder("pack"), dimension, CaveTestPacks.region(List.of("cave-dry"), null), + Map.of("cave-dry", CaveTestPacks.constantProfile(-0.1), "cave-band", CaveTestPacks.constantProfile(-0.1).setAllowLava(true))); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = context(data, "cave-dry"); + + new CaveStage().apply(context); + + assertEquals(PARTIAL, CaveTestPacks.sample(context.aquifer().floodedness(), context.noises(), 4, -50, 4), 1.0e-6); + assertEquals(DRY, CaveTestPacks.sample(context.aquifer().floodedness(), context.noises(), 4, -62, 4), 0.0); + } finally { + data.close(); + } + } + + private PackCompileContext compile(JsonObject dimension, IrisCaveProfile profile) throws Exception { + return compile(dimension, profile, SURFACE, SURFACE); + } + + private PackCompileContext compile(JsonObject dimension, IrisCaveProfile profile, Field height, Field estimate) throws Exception { + File pack = CaveTestPacks.pack(temporaryFolder.newFolder("pack"), dimension, CaveTestPacks.region(List.of("cave-only"), null), + Map.of("cave-only", profile)); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = context(data, "cave-only"); + context.setHeight(height); + context.setSurfaceEstimate(estimate); + new CaveStage().apply(context); + return context; + } finally { + data.close(); + } + } + + private static PackCompileContext context(IrisData data, String caveBiome) { + PackCompileContext context = CaveTestPacks.context(data, FakeCaveStyles.volumetric()); + context.setHeight(SURFACE); + context.setBiomeCodes(CaveTestPacks.codes(CaveTestPacks.xGradient(), List.of(new CaveTestPacks.CodeRange(caveBiome, -1.0, 1.0)))); + return context; + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/cave/CaveFieldCompilerTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/cave/CaveFieldCompilerTest.java new file mode 100644 index 000000000..f37281510 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/cave/CaveFieldCompilerTest.java @@ -0,0 +1,550 @@ +package art.arcane.iris.pack.datapack.export.compile.cave; + +import art.arcane.iris.generation.cave.IrisCaveProfile; +import art.arcane.iris.generation.noise.IrisStyledRange; +import art.arcane.iris.generation.noise.NoiseStyle; +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ExportTestPacks; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; +import art.arcane.iris.pack.datapack.export.ir.AquiferDef; +import art.arcane.iris.pack.datapack.export.ir.Axis; +import art.arcane.iris.pack.datapack.export.ir.CostClass; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.FieldEvaluator; +import art.arcane.iris.pack.datapack.export.ir.NoiseDef; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.pack.value.IrisRange; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import art.arcane.volmlib.util.collection.KList; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class CaveFieldCompilerTest { + private static final Field DEEP_SURFACE = new Field.Const(150.0); + private static final double OPEN_SHARE_THRESHOLD = 0.15; + private static final int BAND_HEIGHT = 16; + private static final int FINE_ORIGIN_X = -900; + private static final int BROAD_ORIGIN_X = 120; + private static final int SHELL_BOXES = 6; + private static final int SHELL_BOX = 24; + private static final int SHELL_BOX_HEIGHT = 48; + private static final int SHELL_BOX_Y = -40; + private static final int SHELL_BOX_STEP_X = 130; + private static final int SHELL_BOX_STEP_Z = 230; + private static final long MIN_WALLS = 200L; + private static final double SHELL_TOLERANCE = 0.2; + + @ClassRule + public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Rule + public ExportTestPacks.BoundPlatform platform = new ExportTestPacks.BoundPlatform(); + + @Test + public void profileSelectedByCaveCode() throws Exception { + File pack = CaveTestPacks.pack(temporaryFolder.newFolder("pack"), CaveTestPacks.dimension(8), + CaveTestPacks.region(List.of("cave-open", "cave-closed"), null), + Map.of("cave-open", CaveTestPacks.constantProfile(5.0), "cave-closed", CaveTestPacks.constantProfile(-5.0))); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = deepContext(data, FakeCaveStyles.volumetric(), List.of( + new CaveTestPacks.CodeRange("cave-open", -1.0, 0.0), + new CaveTestPacks.CodeRange("cave-closed", 0.0, 1.0))); + + new CaveStage().apply(context); + + Field caves = context.caves(); + assertTrue(CaveTestPacks.caveField(caves) instanceof Field.Grid); + assertTrue(((Field.Grid) CaveTestPacks.caveField(caves)).expr() instanceof Field.Mux); + assertTrue(CaveTestPacks.sample(caves, context.noises(), -500, 0, 0) <= 0.0); + assertTrue(CaveTestPacks.sample(caves, context.noises(), 500, 0, 0) > 0.0); + } finally { + data.close(); + } + } + + @Test + public void uncoveredCaveCodesUseTheDimensionProfile() throws Exception { + JsonObject dimension = CaveTestPacks.dimension(8); + dimension.add("caveProfile", CaveTestPacks.json(CaveTestPacks.constantProfile(5.0))); + File pack = CaveTestPacks.pack(temporaryFolder.newFolder("pack"), dimension, + CaveTestPacks.region(List.of("cave-closed"), null), + Map.of("cave-closed", CaveTestPacks.constantProfile(-5.0))); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = deepContext(data, FakeCaveStyles.volumetric(), List.of( + new CaveTestPacks.CodeRange("cave-closed", 0.0, 1.0))); + + new CaveStage().apply(context); + + assertTrue(CaveTestPacks.sample(context.caves(), context.noises(), -500, 0, 0) <= 0.0); + assertTrue(CaveTestPacks.sample(context.caves(), context.noises(), 500, 0, 0) > 0.0); + } finally { + data.close(); + } + } + + @Test + public void wallShellBandHoldsTheOneBlockWallOfTheCaveField() throws Exception { + File pack = CaveTestPacks.pack(temporaryFolder.newFolder("pack"), CaveTestPacks.dimension(8), + CaveTestPacks.region(List.of("cave-noise"), null), Map.of("cave-noise", CaveTestPacks.profile())); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = deepContext(data, FakeCaveStyles.volumetric(), List.of(new CaveTestPacks.CodeRange("cave-noise", -1.0, 1.0))); + + new CaveStage().apply(context); + + double band = context.wallShellBand(); + Field field = context.caveField().orElseThrow(); + long solid = 0L; + long walls = 0L; + long shell = 0L; + for (int column = 0; column < 64; column++) { + int x = 37 * column - 1200; + int z = 53 * (column % 8) + 400; + for (int y = -40; y < 120; y += 2) { + double value = CaveTestPacks.sample(field, context.noises(), x, y, z); + if (value < 0.0) { + continue; + } + solid++; + if (value < band) { + shell++; + } + if (CaveTestPacks.sample(field, context.noises(), x + 1, y, z) < 0.0 || CaveTestPacks.sample(field, context.noises(), x - 1, y, z) < 0.0 + || CaveTestPacks.sample(field, context.noises(), x, y, z + 1) < 0.0 || CaveTestPacks.sample(field, context.noises(), x, y, z - 1) < 0.0) { + walls++; + } + } + } + assertTrue("band " + band, band > 0.0 && band < 0.5); + assertTrue(walls + " walls of " + solid, walls > 50L); + assertEquals("shell " + shell + " walls " + walls + " solid " + solid, 1.0, shell / (double) walls, 0.25); + } finally { + data.close(); + } + } + + @Test + public void wallShellPaintsTheOneBlockWallOfEveryProfile() throws Exception { + File pack = CaveTestPacks.pack(temporaryFolder.newFolder("pack"), CaveTestPacks.dimension(8), + CaveTestPacks.region(List.of("cave-fine", "cave-broad"), null), Map.of( + "cave-fine", CaveTestPacks.profile().setBaseDensityStyle(CaveTestPacks.style(NoiseStyle.SIMPLEX, 0.25)), + "cave-broad", CaveTestPacks.profile().setBaseDensityStyle(CaveTestPacks.style(NoiseStyle.SIMPLEX, 1.5)))); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = deepContext(data, FakeCaveStyles.volumetric(), List.of( + new CaveTestPacks.CodeRange("cave-fine", -1.0, 0.0), + new CaveTestPacks.CodeRange("cave-broad", 0.0, 1.0))); + + new CaveStage().apply(context); + + Field field = context.caveField().orElseThrow(); + double band = context.wallShellBand(); + ShellCount fine = shellCount(field, context, band, FINE_ORIGIN_X); + ShellCount broad = shellCount(field, context, band, BROAD_ORIGIN_X); + assertTrue(fine.toString(), fine.walls() > MIN_WALLS); + assertTrue(broad.toString(), broad.walls() > MIN_WALLS); + assertEquals(fine.toString(), 1.0, fine.ratio(), SHELL_TOLERANCE); + assertEquals(broad.toString(), 1.0, broad.ratio(), SHELL_TOLERANCE); + } finally { + data.close(); + } + } + + @Test + public void caveBiomeWithoutProfileUsesItsRegionProfile() throws Exception { + File pack = CaveTestPacks.pack(temporaryFolder.newFolder("pack"), CaveTestPacks.dimension(8), + CaveTestPacks.region(List.of("cave-plain"), CaveTestPacks.constantProfile(5.0)), + Map.of("cave-plain", new IrisCaveProfile())); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = deepContext(data, FakeCaveStyles.volumetric(), List.of( + new CaveTestPacks.CodeRange("cave-plain", -1.0, 1.0))); + + new CaveStage().apply(context); + + assertTrue(CaveTestPacks.sample(context.caves(), context.noises(), 300, 0, 0) <= 0.0); + } finally { + data.close(); + } + } + + @Test + public void threeDimensionalCellularOnlyInsideGrid() throws Exception { + IrisCaveProfile profile = CaveTestPacks.profile() + .setBaseDensityStyle(CaveTestPacks.style(NoiseStyle.CELLULAR_IRIS_DOUBLE, 0.7)) + .setModules(new KList<>(List.of(CaveTestPacks.module(NoiseStyle.CELLULAR, 0.3, 0.0, false, new IrisRange(0, 256))))); + File pack = CaveTestPacks.pack(temporaryFolder.newFolder("pack"), CaveTestPacks.dimension(8), + CaveTestPacks.region(List.of("cave-cells"), null), Map.of("cave-cells", profile)); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = deepContext(data, FakeCaveStyles.expensiveCellular(), List.of( + new CaveTestPacks.CodeRange("cave-cells", -1.0, 1.0))); + + new CaveStage().apply(context); + + List outside = new ArrayList<>(); + List inside = new ArrayList<>(); + collectCellular(context.caves(), false, inside, outside); + assertFalse(inside.isEmpty()); + assertTrue(outside.toString(), outside.isEmpty()); + for (Field.Noise noise : inside) { + assertTrue(noise.axes().contains(Axis.Y)); + assertEquals(CostClass.EXPENSIVE, noise.cost()); + } + } finally { + data.close(); + } + } + + @Test + public void lowWeightCellularModulesUseSmoothSubstitutes() throws Exception { + IrisCaveProfile profile = CaveTestPacks.profile().setModules(new KList<>(List.of( + CaveTestPacks.module(NoiseStyle.CELLULAR_BILINEAR, 0.05, 0.0, false, new IrisRange(0, 256)), + CaveTestPacks.module(NoiseStyle.SIMPLEX_VASCULAR, 0.08, 0.1, true, new IrisRange(0, 256)), + CaveTestPacks.module(NoiseStyle.VASCULAR, 0.2, 0.0, false, new IrisRange(0, 256))))); + File pack = CaveTestPacks.pack(temporaryFolder.newFolder("pack"), CaveTestPacks.dimension(8), + CaveTestPacks.region(List.of("cave-mixed"), null), Map.of("cave-mixed", profile)); + IrisData data = IrisData.openRuntime(pack); + try { + FakeCaveStyles styles = FakeCaveStyles.volumetric(); + PackCompileContext context = deepContext(data, styles, List.of(new CaveTestPacks.CodeRange("cave-mixed", -1.0, 1.0))); + + new CaveStage().apply(context); + + List requests = styles.requests(); + assertFalse(requests.toString(), requests.contains(NoiseStyle.CELLULAR_BILINEAR)); + assertFalse(requests.toString(), requests.contains(NoiseStyle.SIMPLEX_VASCULAR)); + assertTrue(requests.toString(), requests.contains(NoiseStyle.VASCULAR)); + } finally { + data.close(); + } + } + + @Test + public void planarSmoothStylesLiftToVolume() throws Exception { + IrisCaveProfile profile = CaveTestPacks.profile() + .setDensityThreshold(new IrisStyledRange(-0.1, 0.1, CaveTestPacks.style(NoiseStyle.SIMPLEX, 1.0))); + File pack = CaveTestPacks.pack(temporaryFolder.newFolder("pack"), CaveTestPacks.dimension(8), + CaveTestPacks.region(List.of("cave-smooth"), null), Map.of("cave-smooth", profile)); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = deepContext(data, FakeCaveStyles.planar(), List.of(new CaveTestPacks.CodeRange("cave-smooth", -1.0, 1.0))); + + new CaveStage().apply(context); + + Field.Noise base = noiseContaining(context.caves(), "simplex_934447"); + Field.Noise threshold = noiseContaining(context.caves(), "simplex_489112"); + assertEquals(base.xzScale(), base.yScale(), 0.0); + assertTrue(base.yScale() != 0.0); + assertEquals(0.0, threshold.yScale(), 0.0); + } finally { + data.close(); + } + } + + @Test + public void planarCellularStylesExportAsSmoothNoise() throws Exception { + IrisCaveProfile profile = CaveTestPacks.profile().setBaseDensityStyle(CaveTestPacks.style(NoiseStyle.CELLULAR_IRIS_DOUBLE, 0.7)); + File pack = CaveTestPacks.pack(temporaryFolder.newFolder("pack"), CaveTestPacks.dimension(8), + CaveTestPacks.region(List.of("cave-cells"), null), Map.of("cave-cells", profile)); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = deepContext(data, FakeCaveStyles.planar(), List.of(new CaveTestPacks.CodeRange("cave-cells", -1.0, 1.0))); + + new CaveStage().apply(context); + + Field.Noise base = noiseContaining(context.caves(), "simplex_934447"); + assertEquals(base.xzScale(), base.yScale(), 0.0); + assertTrue(context.diagnostics().stream().anyMatch(diagnostic -> diagnostic.limit() == ExportLimit.NOISE_STYLE + && diagnostic.resource().equals("biomes/cave-cells"))); + } finally { + data.close(); + } + } + + @Test + public void caveOpenShareWithinThresholdPerBand() throws Exception { + IrisCaveProfile profile = CaveTestPacks.profile() + .setVerticalRange(new IrisRange(8, 240)) + .setVerticalEdgeFade(24) + .setVerticalEdgeFadeStrength(0.2) + .setDetailWeight(0.15) + .setDensityThreshold(new IrisStyledRange(-0.15, 0.02, CaveTestPacks.style(NoiseStyle.SIMPLEX, 1.0))) + .setThresholdBias(0.08) + .setSurfaceClearance(6) + .setAllowSurfaceBreak(true) + .setSurfaceBreakStyle(CaveTestPacks.style(NoiseStyle.SIMPLEX, 0.08)) + .setSurfaceBreakNoiseThreshold(0.4) + .setSurfaceBreakDepth(18) + .setSurfaceBreakThresholdBoost(0.2) + .setModules(new KList<>(List.of( + CaveTestPacks.module(NoiseStyle.SIMPLEX, 0.08, 0.1, true, new IrisRange(20, 120)), + CaveTestPacks.module(NoiseStyle.FRACTAL_FBM_SIMPLEX, 0.06, 0.0, false, new IrisRange(0, 256))))); + File pack = CaveTestPacks.pack(temporaryFolder.newFolder("pack"), CaveTestPacks.dimension(8), + CaveTestPacks.region(List.of("cave-real"), null), Map.of("cave-real", profile)); + IrisData data = IrisData.openRuntime(pack); + try { + FakeCaveStyles styles = FakeCaveStyles.volumetric(); + PackCompileContext context = CaveTestPacks.context(data, styles); + Field height = rollingSurface(context); + context.setHeight(height); + context.setBiomeCodes(CaveTestPacks.codes(CaveTestPacks.xGradient(), List.of(new CaveTestPacks.CodeRange("cave-real", -1.0, 1.0)))); + + new CaveStage().apply(context); + + IrisCaveReference reference = new IrisCaveReference(profile, styles, context.noises(), context.capabilities(), height); + Field caves = context.caves(); + double referenceTotal = 0.0; + int bands = 0; + for (int bandMin = CaveTestPacks.MIN_Y; bandMin < CaveTestPacks.MAX_Y; bandMin += BAND_HEIGHT) { + int compiledOpen = 0; + int referenceOpen = 0; + int samples = 0; + for (int x = -300; x < 70; x += 37) { + for (int z = -250; z < 120; z += 37) { + for (int y = bandMin; y < bandMin + BAND_HEIGHT; y += 2) { + if (CaveTestPacks.sample(caves, context.noises(), x, y, z) <= 0.0) { + compiledOpen++; + } + if (reference.carved(x, y, z)) { + referenceOpen++; + } + samples++; + } + } + } + double compiledShare = compiledOpen / (double) samples; + double referenceShare = referenceOpen / (double) samples; + assertEquals("band " + bandMin, referenceShare, compiledShare, OPEN_SHARE_THRESHOLD); + referenceTotal += referenceShare; + bands++; + } + assertTrue("reference carves nothing", referenceTotal / bands > 0.05); + } finally { + data.close(); + } + } + + @Test + public void deepDarkBandIsMinUnioned() throws Exception { + JsonObject dimension = CaveTestPacks.dimension(8); + JsonArray carving = new JsonArray(); + carving.add(CaveTestPacks.band("deep-band", "cave-band", -40, -20)); + dimension.add("carving", carving); + File pack = CaveTestPacks.pack(temporaryFolder.newFolder("pack"), dimension, + CaveTestPacks.region(List.of("cave-closed"), null), + Map.of("cave-closed", CaveTestPacks.constantProfile(-5.0), "cave-band", CaveTestPacks.constantProfile(5.0))); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = deepContext(data, FakeCaveStyles.volumetric(), List.of(new CaveTestPacks.CodeRange("cave-closed", -1.0, 1.0))); + + new CaveStage().apply(context); + + Field caves = context.caves(); + Field union = ((Field.Grid) CaveTestPacks.caveField(caves)).expr(); + assertTrue(union instanceof Field.Binary binary && binary.op() == Field.BinaryOp.MIN); + assertTrue(CaveTestPacks.sample(caves, context.noises(), 40, -30, 40) <= 0.0); + assertTrue(CaveTestPacks.sample(caves, context.noises(), 40, 0, 40) > 0.0); + assertTrue(CaveTestPacks.sample(caves, context.noises(), 40, -60, 40) > 0.0); + } finally { + data.close(); + } + } + + @Test + public void expensiveSubtreesOnlySelectedThroughMuxOrGate() throws Exception { + IrisCaveProfile lava = demandingProfile().setAllowFluid(true); + JsonObject dimension = CaveTestPacks.dimension(20); + JsonArray carving = new JsonArray(); + carving.add(CaveTestPacks.band("deep-band", "cave-band", -50, -20)); + dimension.add("carving", carving); + File pack = CaveTestPacks.pack(temporaryFolder.newFolder("pack"), dimension, + CaveTestPacks.region(List.of("cave-lava", "cave-plain"), null), + Map.of("cave-lava", lava, "cave-plain", CaveTestPacks.profile(), "cave-band", demandingProfile())); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = CaveTestPacks.context(data, FakeCaveStyles.expensive()); + context.setHeight(warpedSurface(context)); + context.setBiomeCodes(CaveTestPacks.codes(CaveTestPacks.xGradient(), List.of( + new CaveTestPacks.CodeRange("cave-lava", -1.0, 0.0), + new CaveTestPacks.CodeRange("cave-plain", 0.0, 1.0)))); + + new CaveStage().apply(context); + + AquiferDef aquifer = context.aquifer(); + List roots = List.of(context.caves(), aquifer.barrier(), aquifer.floodedness(), aquifer.spread(), aquifer.lava(), aquifer.exclusion()); + boolean expensive = false; + boolean muxed = false; + for (Field root : roots) { + for (Field node : CaveTestPacks.nodes(root)) { + expensive |= node.cost() == CostClass.EXPENSIVE; + muxed |= node instanceof Field.Mux; + for (Field child : CaveTestPacks.eagerChildren(node)) { + assertTrue(node.getClass().getSimpleName() + " evaluates an expensive child eagerly", child.cost() != CostClass.EXPENSIVE); + } + } + } + assertTrue(expensive); + assertTrue(muxed); + } finally { + data.close(); + } + } + + @Test + public void compiledCavesAreDeterministic() throws Exception { + IrisCaveProfile profile = CaveTestPacks.profile() + .setDensityThreshold(new IrisStyledRange(-0.15, 0.02, CaveTestPacks.style(NoiseStyle.SIMPLEX, 1.0))) + .setAllowSurfaceBreak(true) + .setAllowLava(true) + .setModules(new KList<>(List.of(CaveTestPacks.module(NoiseStyle.SIMPLEX, 0.08, 0.1, true, new IrisRange(20, 120))))); + File pack = CaveTestPacks.pack(temporaryFolder.newFolder("pack"), CaveTestPacks.dimension(20), + CaveTestPacks.region(List.of("cave-one", "cave-two"), null), + Map.of("cave-one", profile, "cave-two", CaveTestPacks.constantProfile(-0.2))); + Compiled first = compileTwoCodes(pack); + Compiled second = compileTwoCodes(pack); + + assertEquals(first.caves(), second.caves()); + assertEquals(first.aquifer(), second.aquifer()); + assertEquals(first.noises(), second.noises()); + assertEquals(first.diagnostics(), second.diagnostics()); + } + + private Compiled compileTwoCodes(File pack) throws IOException { + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = deepContext(data, FakeCaveStyles.volumetric(), List.of( + new CaveTestPacks.CodeRange("cave-one", -1.0, 0.0), + new CaveTestPacks.CodeRange("cave-two", 0.0, 1.0))); + new CaveStage().apply(context); + return new Compiled(context.caves(), context.aquifer(), context.noises().definitions(), List.copyOf(context.diagnostics())); + } finally { + data.close(); + } + } + + private static IrisCaveProfile demandingProfile() { + return CaveTestPacks.profile() + .setVerticalRange(new IrisRange(8, 240)) + .setVerticalEdgeFade(24) + .setDetailWeight(0.15) + .setDensityThreshold(new IrisStyledRange(-0.15, 0.02, CaveTestPacks.style(NoiseStyle.SIMPLEX, 1.0))) + .setSurfaceClearance(6) + .setAllowSurfaceBreak(true) + .setAllowLava(true) + .setModules(new KList<>(List.of( + CaveTestPacks.module(NoiseStyle.SIMPLEX, 0.08, 0.1, true, new IrisRange(20, 120)), + CaveTestPacks.module(NoiseStyle.CELLULAR, 0.3, 0.0, false, new IrisRange(0, 256))))); + } + + private static PackCompileContext deepContext(IrisData data, FakeCaveStyles styles, List ranges) { + PackCompileContext context = CaveTestPacks.context(data, styles); + context.setHeight(DEEP_SURFACE); + context.setBiomeCodes(CaveTestPacks.codes(CaveTestPacks.xGradient(), ranges)); + return context; + } + + private static Field rollingSurface(PackCompileContext context) { + String id = context.noises().register(new NoiseDef("cave_test/surface", 0, List.of(1.0))); + Field noise = new Field.Noise(id, 1.0 / 128.0, 0.0, null, null, null); + return new Field.Curve(noise, List.of( + new Field.Knot(-1.0, new Field.Const(50.0), 20.0), + new Field.Knot(1.0, new Field.Const(90.0), 20.0))); + } + + private static Field warpedSurface(PackCompileContext context) { + String warp = context.noises().register(new NoiseDef("cave_test/surface_warp", 0, List.of(1.0))); + String id = context.noises().register(new NoiseDef("cave_test/surface", 0, List.of(1.0))); + Field shift = new Field.Noise(warp, 1.0 / 128.0, 0.0, null, null, null); + Field noise = new Field.Shared("height", new Field.Noise(id, 1.0 / 128.0, 0.0, shift, null, shift)); + return new Field.Curve(noise, List.of( + new Field.Knot(-1.0, new Field.Const(50.0), 20.0), + new Field.Knot(1.0, new Field.Const(90.0), 20.0))); + } + + private static ShellCount shellCount(Field field, PackCompileContext context, double band, int originX) { + Map noises = CaveTestPacks.noises(context.noises()); + long solid = 0L; + long walls = 0L; + long shell = 0L; + for (int box = 0; box < SHELL_BOXES; box++) { + int x0 = originX + box * SHELL_BOX_STEP_X; + int z0 = box * SHELL_BOX_STEP_Z - SHELL_BOX_STEP_Z; + double[][][] values = new double[SHELL_BOX][SHELL_BOX_HEIGHT][SHELL_BOX]; + for (int x = 0; x < SHELL_BOX; x++) { + for (int y = 0; y < SHELL_BOX_HEIGHT; y++) { + for (int z = 0; z < SHELL_BOX; z++) { + values[x][y][z] = FieldEvaluator.sample(field, noises, CaveTestPacks.SEED, DataVersion.V26_3, x0 + x, SHELL_BOX_Y + y, z0 + z); + } + } + } + for (int x = 1; x < SHELL_BOX - 1; x++) { + for (int y = 0; y < SHELL_BOX_HEIGHT; y++) { + for (int z = 1; z < SHELL_BOX - 1; z++) { + double value = values[x][y][z]; + if (value < 0.0) { + continue; + } + solid++; + if (value < band) { + shell++; + } + if (values[x + 1][y][z] < 0.0 || values[x - 1][y][z] < 0.0 || values[x][y][z + 1] < 0.0 || values[x][y][z - 1] < 0.0) { + walls++; + } + } + } + } + } + return new ShellCount(solid, walls, shell); + } + + private static Field.Noise noiseContaining(Field root, String fragment) { + for (Field node : CaveTestPacks.nodes(root)) { + if (node instanceof Field.Noise noise && noise.noiseId().contains(fragment)) { + return noise; + } + } + throw new AssertionError("No noise containing '" + fragment + "' in " + root); + } + + private static void collectCellular(Field field, boolean insideGrid, List inside, List outside) { + if (field instanceof Field.Noise noise && noise.noiseId().contains(FakeCaveStyles.CELLULAR_PREFIX)) { + (insideGrid ? inside : outside).add(noise); + } + boolean childInside = insideGrid || field instanceof Field.Grid; + for (Field child : CaveTestPacks.children(field)) { + collectCellular(child, childInside, inside, outside); + } + } + + private record Compiled(Field caves, AquiferDef aquifer, List noises, List diagnostics) { + } + + private record ShellCount(long solid, long walls, long shell) { + private double ratio() { + return shell / (double) walls; + } + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/cave/CaveProfileCompilerTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/cave/CaveProfileCompilerTest.java new file mode 100644 index 000000000..b3d9098ca --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/cave/CaveProfileCompilerTest.java @@ -0,0 +1,96 @@ +package art.arcane.iris.pack.datapack.export.compile.cave; + +import art.arcane.iris.generation.cave.IrisCaveProfile; +import art.arcane.iris.generation.noise.IrisStyledRange; +import art.arcane.iris.generation.noise.NoiseStyle; +import art.arcane.iris.pack.datapack.export.ExportTestPacks; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; +import art.arcane.iris.pack.datapack.export.ir.Axis; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.pack.value.IrisRange; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import art.arcane.volmlib.util.collection.KList; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class CaveProfileCompilerTest { + private static final double GAIN = 3.0; + private static final double TOLERANCE = 1.0e-4; + private static final int FLOOR = CaveTestPacks.MIN_Y + 8; + private static final int CEILING = CaveTestPacks.MIN_Y + 240; + private static final Field SLOPED_SURFACE = new Field.Coord(Axis.X, Field.Tiling.CLAMP, -200, 200, 60, 140); + + @ClassRule + public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Rule + public ExportTestPacks.BoundPlatform platform = new ExportTestPacks.BoundPlatform(); + + @Test + public void gainScalesTheFieldWithoutMovingItsCaves() throws Exception { + IrisCaveProfile profile = CaveTestPacks.profile() + .setVerticalRange(new IrisRange(8, 240)) + .setVerticalEdgeFade(24) + .setVerticalEdgeFadeStrength(0.2) + .setDetailWeight(0.15) + .setDensityThreshold(new IrisStyledRange(-0.15, 0.02, CaveTestPacks.style(NoiseStyle.SIMPLEX, 1.0))) + .setThresholdBias(0.08) + .setSurfaceClearance(6) + .setAllowSurfaceBreak(true) + .setSurfaceBreakStyle(CaveTestPacks.style(NoiseStyle.SIMPLEX, 0.08)) + .setSurfaceBreakNoiseThreshold(0.4) + .setSurfaceBreakDepth(18) + .setSurfaceBreakThresholdBoost(0.1) + .setModules(new KList<>(List.of(CaveTestPacks.module(NoiseStyle.SIMPLEX, 0.08, 0.1, true, new IrisRange(20, 120))))); + File pack = CaveTestPacks.pack(temporaryFolder.newFolder("pack"), CaveTestPacks.dimension(8), + CaveTestPacks.region(List.of("cave-gain"), null), Map.of("cave-gain", profile)); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = CaveTestPacks.context(data, FakeCaveStyles.volumetric()); + context.setHeight(SLOPED_SURFACE); + CaveInputs inputs = CaveInputs.of(context); + CaveProfileCompiler compiler = new CaveProfileCompiler(context.styles(), inputs); + CaveSource source = new CaveSource("biomes/cave-gain", profile, inputs.minY(), inputs.maxY()); + + Field unit = compiler.compile(source, 1.0); + Field scaled = compiler.compile(source, GAIN); + + int caves = 0; + for (int x = -240; x <= 240; x += 40) { + for (int y = CaveTestPacks.MIN_Y; y < CaveTestPacks.MAX_Y; y += 5) { + for (int z = -90; z <= 90; z += 60) { + double expected = CaveTestPacks.sample(unit, context.noises(), x, y, z); + double actual = CaveTestPacks.sample(scaled, context.noises(), x, y, z); + if (y < FLOOR || y > CEILING) { + assertEquals(x + "," + y + "," + z, CaveProfileCompiler.SOLID, expected, 0.0); + assertEquals(x + "," + y + "," + z, CaveProfileCompiler.SOLID, actual, 0.0); + continue; + } + assertEquals(x + "," + y + "," + z, GAIN * expected, actual, TOLERANCE * Math.max(1.0, Math.abs(actual))); + if (expected < 0.0) { + caves++; + } + } + } + } + assertTrue(String.valueOf(caves), caves > 0); + assertThrows(IllegalArgumentException.class, () -> compiler.compile(source, 0.0)); + } finally { + data.close(); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/cave/CaveStageTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/cave/CaveStageTest.java new file mode 100644 index 000000000..2e4072068 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/cave/CaveStageTest.java @@ -0,0 +1,156 @@ +package art.arcane.iris.pack.datapack.export.compile.cave; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ExportTestPacks; +import art.arcane.iris.pack.datapack.export.ExportTestStyles; +import art.arcane.iris.pack.datapack.export.WorldgenExportProgress; +import art.arcane.iris.pack.datapack.export.compile.CompileStage; +import art.arcane.iris.pack.datapack.export.compile.DimensionStage; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; +import art.arcane.iris.pack.datapack.export.compile.PackCompiler; +import art.arcane.iris.pack.datapack.export.compile.biome.BiomeStage; +import art.arcane.iris.pack.datapack.export.ir.AquiferDef; +import art.arcane.iris.pack.datapack.export.ir.BiomeDef; +import art.arcane.iris.pack.datapack.export.ir.ExportIr; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.JsonResource; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +public class CaveStageTest { + private static final Field SURFACE = new Field.Const(120.0); + + @ClassRule + public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Rule + public ExportTestPacks.BoundPlatform platform = new ExportTestPacks.BoundPlatform(); + + @Test + public void noVanillaCarversEmitted() throws Exception { + File pack = CaveTestPacks.pack(temporaryFolder.newFolder("pack"), CaveTestPacks.dimension(8), + CaveTestPacks.region(List.of("cave-open"), null), Map.of("cave-open", CaveTestPacks.constantProfile(0.1))); + IrisData data = IrisData.openRuntime(pack); + try { + CompileStage vanillaBiomes = new FixedStage("biomes", context -> { + JsonObject body = JsonParser.parseString("{\"carvers\":[\"minecraft:cave\",\"minecraft:canyon\"],\"features\":[]}").getAsJsonObject(); + context.setBiomes(List.of(new BiomeDef(CaveTestPacks.SURFACE_BIOME, body), new BiomeDef("cave-open", body))); + context.setHeight(SURFACE); + context.setBiomeCodes(CaveTestPacks.codes(CaveTestPacks.xGradient(), List.of(new CaveTestPacks.CodeRange("cave-open", -1.0, 1.0)))); + }); + PackCompileContext context = ExportTestPacks.contextBuilder(data, DataVersion.V26_3).styles(FakeCaveStyles.volumetric()).build(); + + ExportIr ir = new PackCompiler(List.of(new DimensionStage(), vanillaBiomes, new CaveStage())).compile(context, WorldgenExportProgress.NONE); + + for (BiomeDef biome : ir.biomes()) { + assertEquals(new JsonArray(), biome.json().get("carvers")); + } + for (JsonResource resource : ir.jsonResources()) { + assertFalse(resource.path(), resource.path().contains("carver")); + } + } finally { + data.close(); + } + } + + @Test + public void defaultPipelineRunsCavesAfterTerrain() throws Exception { + File pack = ExportTestPacks.basic(temporaryFolder.newFolder("pack")); + IrisData data = IrisData.openRuntime(pack); + try { + List stages = new ArrayList<>(); + WorldgenExportProgress progress = new WorldgenExportProgress() { + @Override + public void stage(String stage) { + stages.add(stage); + } + + @Override + public void resources(int done, int total) { + } + }; + + PackCompiler.create().compile(ExportTestPacks.contextBuilder(data, DataVersion.V26_3).styles(new ExportTestStyles()).build(), progress); + + assertTrue(stages.toString(), stages.indexOf("terrain") >= 0); + assertTrue(stages.toString(), stages.indexOf("terrain") < stages.indexOf("caves")); + } finally { + data.close(); + } + } + + @Test + public void biomeStageCaveCodesSelectCaveProfiles() throws Exception { + File pack = CaveTestPacks.pack(temporaryFolder.newFolder("pack"), CaveTestPacks.dimension(8), + CaveTestPacks.region(List.of("cave-open"), null), Map.of("cave-open", CaveTestPacks.constantProfile(0.1))); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = CaveTestPacks.context(data, FakeCaveStyles.planar()); + context.setHeight(SURFACE); + new BiomeStage().apply(context); + + new CaveStage().apply(context); + + assertNotEquals(new Field.Const(1.0e6), context.caves()); + } finally { + data.close(); + } + } + + @Test + public void disabledCarvingKeepsDefaultSlots() throws Exception { + JsonObject dimension = CaveTestPacks.dimension(20); + dimension.addProperty("carvingEnabled", false); + File pack = CaveTestPacks.pack(temporaryFolder.newFolder("pack"), dimension, + CaveTestPacks.region(List.of("cave-open"), null), Map.of("cave-open", CaveTestPacks.constantProfile(5.0).setAllowLava(true))); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = CaveTestPacks.context(data, FakeCaveStyles.volumetric()); + context.setHeight(SURFACE); + context.setBiomeCodes(CaveTestPacks.codes(CaveTestPacks.xGradient(), List.of(new CaveTestPacks.CodeRange("cave-open", -1.0, 1.0)))); + + new CaveStage().apply(context); + + assertEquals(new Field.Const(1.0e6), context.caves()); + AquiferDef aquifer = context.aquifer(); + AquiferDef vanilla = AquiferDef.dry(SURFACE); + assertEquals(vanilla.barrier(), aquifer.barrier()); + assertEquals(vanilla.spread(), aquifer.spread()); + assertEquals(vanilla.lava(), aquifer.lava()); + assertEquals(vanilla.exclusion(), aquifer.exclusion()); + assertEquals(vanilla.surfaceLevel(), aquifer.surfaceLevel()); + assertEquals(-1.0, CaveTestPacks.sample(aquifer.floodedness(), context.noises(), 4, 40, 4), 0.0); + assertTrue(context.diagnostics().isEmpty()); + } finally { + data.close(); + } + } + + private record FixedStage(String name, Consumer action) implements CompileStage { + @Override + public void apply(PackCompileContext context) { + action.accept(context); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/cave/CaveTestPacks.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/cave/CaveTestPacks.java new file mode 100644 index 000000000..df107f7cc --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/cave/CaveTestPacks.java @@ -0,0 +1,274 @@ +package art.arcane.iris.pack.datapack.export.compile.cave; + +import art.arcane.iris.generation.cave.IrisCaveFieldModule; +import art.arcane.iris.generation.cave.IrisCaveProfile; +import art.arcane.iris.generation.noise.IrisGeneratorStyle; +import art.arcane.iris.generation.noise.IrisStyledRange; +import art.arcane.iris.generation.noise.NoiseStyle; +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ExportTestPacks; +import art.arcane.iris.pack.datapack.export.compile.DimensionStage; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; +import art.arcane.iris.pack.datapack.export.compile.biome.BiomeCodes; +import art.arcane.iris.pack.datapack.export.compile.noise.NoiseRegistry; +import art.arcane.iris.pack.datapack.export.compile.noise.StyleCompiler; +import art.arcane.iris.pack.datapack.export.ir.Axis; +import art.arcane.iris.pack.datapack.export.ir.ClimateLayout; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.FieldEvaluator; +import art.arcane.iris.pack.datapack.export.ir.NoiseDef; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.pack.value.IrisRange; +import art.arcane.iris.testsupport.CompatTestPlatform; +import art.arcane.volmlib.util.collection.KList; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +final class CaveTestPacks { + static final String NAMESPACE = "iris_export"; + static final String SURFACE_BIOME = "surface"; + static final long SEED = 1337L; + static final int MIN_Y = -64; + static final int MAX_Y = 192; + private static final Gson GSON = new Gson(); + + private CaveTestPacks() { + } + + static JsonObject dimension(int lavaHeight) { + JsonObject height = new JsonObject(); + height.addProperty("min", MIN_Y); + height.addProperty("max", MAX_Y); + JsonArray regions = new JsonArray(); + regions.add("region"); + JsonObject dimension = new JsonObject(); + dimension.add("regions", regions); + dimension.add("dimensionHeight", height); + dimension.addProperty("fluidHeight", 63); + dimension.addProperty("caveLavaHeight", lavaHeight); + return dimension; + } + + static JsonObject band(String id, String biome, int min, int max) { + JsonObject range = new JsonObject(); + range.addProperty("min", min); + range.addProperty("max", max); + JsonObject entry = new JsonObject(); + entry.addProperty("id", id); + entry.addProperty("biome", biome); + entry.add("worldYRange", range); + return entry; + } + + static JsonObject region(List caveBiomes, IrisCaveProfile profile) { + JsonArray surface = new JsonArray(); + surface.add(SURFACE_BIOME); + JsonArray caves = new JsonArray(); + for (String key : caveBiomes) { + caves.add(key); + } + JsonObject region = new JsonObject(); + region.add("landBiomes", surface); + region.add("seaBiomes", surface.deepCopy()); + region.add("caveBiomes", caves); + if (profile != null) { + region.add("caveProfile", GSON.toJsonTree(profile)); + } + return region; + } + + static File pack(File folder, JsonObject dimension, JsonObject region, Map biomes) throws IOException { + CompatTestPlatform.write(folder, "dimensions/" + ExportTestPacks.DIMENSION + ".json", GSON.toJson(dimension)); + CompatTestPlatform.write(folder, "regions/region.json", GSON.toJson(region)); + CompatTestPlatform.write(folder, "biomes/" + SURFACE_BIOME + ".json", "{\"name\":\"Surface\"}"); + for (Map.Entry entry : new TreeMap<>(biomes).entrySet()) { + JsonObject biome = new JsonObject(); + biome.addProperty("name", entry.getKey()); + biome.add("caveProfile", GSON.toJsonTree(entry.getValue())); + CompatTestPlatform.write(folder, "biomes/" + entry.getKey() + ".json", GSON.toJson(biome)); + } + return folder; + } + + static JsonElement json(IrisCaveProfile profile) { + return GSON.toJsonTree(profile); + } + + static IrisCaveProfile profile() { + return new IrisCaveProfile() + .setEnabled(true) + .setVerticalRange(new IrisRange(0, MAX_Y - MIN_Y)) + .setVerticalEdgeFade(0) + .setBaseDensityStyle(style(NoiseStyle.SIMPLEX, 0.6)) + .setDetailDensityStyle(style(NoiseStyle.SIMPLEX, 0.3)) + .setBaseWeight(1.0) + .setDetailWeight(0.0) + .setDensityThreshold(new IrisStyledRange(0.0, 0.0, style(NoiseStyle.SIMPLEX, 1.0))) + .setThresholdBias(0.0) + .setSurfaceClearance(0) + .setAllowSurfaceBreak(false) + .setAllowFluid(false) + .setAllowLava(false) + .setModules(new KList<>()); + } + + static IrisCaveProfile constantProfile(double threshold) { + return profile().setDensityThreshold(new IrisStyledRange(threshold, threshold, style(NoiseStyle.SIMPLEX, 1.0))); + } + + static IrisCaveFieldModule module(NoiseStyle style, double weight, double threshold, boolean invert, IrisRange range) { + return new IrisCaveFieldModule() + .setStyle(style(style, 0.5)) + .setWeight(weight) + .setThreshold(threshold) + .setInvert(invert) + .setVerticalRange(range); + } + + static IrisGeneratorStyle style(NoiseStyle style, double zoom) { + return new IrisGeneratorStyle(style).zoomed(zoom); + } + + static PackCompileContext context(IrisData data, StyleCompiler styles) { + PackCompileContext context = ExportTestPacks.contextBuilder(data, DataVersion.V26_3).styles(styles).build(); + new DimensionStage().apply(context); + return context; + } + + static BiomeCodes codes(Field caveCode, List ranges) { + List entries = new ArrayList<>(); + Map loadKeys = new TreeMap<>(); + String surfaceId = NAMESPACE + ":" + SURFACE_BIOME; + entries.add(new ClimateLayout.ClimateEntry(surfaceId, ClimateLayout.ClimateAxis.TEMPERATURE, -1.0, 1.0)); + loadKeys.put(surfaceId, SURFACE_BIOME); + for (CodeRange range : ranges) { + String id = NAMESPACE + ":" + range.loadKey(); + entries.add(new ClimateLayout.ClimateEntry(id, ClimateLayout.ClimateAxis.DEPTH, range.min(), range.max())); + loadKeys.put(id, range.loadKey()); + } + Field zero = new Field.Const(0.0); + return new BiomeCodes(zero, caveCode, zero, new ClimateLayout(entries), loadKeys); + } + + static Field xGradient() { + return new Field.Coord(Axis.X, Field.Tiling.CLAMP, -1000, 1000, -1.0, 1.0); + } + + static Map noises(NoiseRegistry registry) { + Map noises = new TreeMap<>(); + for (NoiseDef definition : registry.definitions()) { + noises.put(registry.namespace() + ":" + definition.id(), definition); + } + return noises; + } + + static double sample(Field field, NoiseRegistry registry, int x, int y, int z) { + return FieldEvaluator.sample(field, noises(registry), SEED, DataVersion.V26_3, x, y, z); + } + + static List children(Field field) { + List children = new ArrayList<>(); + switch (field) { + case Field.Const ignored -> { + } + case Field.Coord ignored -> { + } + case Field.Ref ignored -> { + } + case Field.Noise noise -> { + addPresent(children, noise.shiftX()); + addPresent(children, noise.shiftY()); + addPresent(children, noise.shiftZ()); + } + case Field.Binary binary -> { + children.add(binary.left()); + children.add(binary.right()); + } + case Field.Unary unary -> children.add(unary.input()); + case Field.Clamp clamp -> children.add(clamp.input()); + case Field.Lerp lerp -> children.addAll(List.of(lerp.alpha(), lerp.first(), lerp.second())); + case Field.Round round -> children.addAll(List.of(round.input(), round.multiple())); + case Field.Curve curve -> { + children.add(curve.input()); + for (Field.Knot knot : curve.knots()) { + children.add(knot.value()); + } + } + case Field.Partition partition -> { + children.add(partition.input()); + children.addAll(partition.leaves()); + } + case Field.Mux mux -> { + children.add(mux.index()); + children.addAll(mux.cases()); + } + case Field.Gate gate -> children.addAll(List.of(gate.condition(), gate.expr())); + case Field.RangeIf rangeIf -> children.addAll(List.of(rangeIf.input(), rangeIf.inRange(), rangeIf.outOfRange())); + case Field.Grid grid -> children.add(grid.expr()); + case Field.Shared shared -> children.add(shared.expr()); + case Field.Slice slice -> children.add(slice.expr()); + case Field.TopSurface topSurface -> children.add(topSurface.density()); + } + return children; + } + + static List eagerChildren(Field field) { + return switch (field) { + case Field.Partition partition -> { + List children = new ArrayList<>(); + children.add(partition.input()); + children.addAll(partition.leaves()); + yield children; + } + case Field.RangeIf rangeIf -> List.of(rangeIf.input(), rangeIf.inRange(), rangeIf.outOfRange()); + case Field.Binary binary when binary.op() == Field.BinaryOp.MUL -> List.of(binary.left(), binary.right()); + case Field.Lerp lerp -> List.of(lerp.alpha(), lerp.first(), lerp.second()); + default -> List.of(); + }; + } + + static Field caveField(Field caves) { + Field field = caves instanceof Field.Binary binary && binary.op() == Field.BinaryOp.MAX ? binary.left() : caves; + if (field instanceof Field.Shared shared && shared.name().equals(DeepCaveCompiler.FIELD)) { + return shared.expr(); + } + return caves; + } + + static List nodes(Field root) { + Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + List nodes = new ArrayList<>(); + List pending = new ArrayList<>(); + pending.add(root); + while (!pending.isEmpty()) { + Field next = pending.removeLast(); + if (!seen.add(next)) { + continue; + } + nodes.add(next); + pending.addAll(children(next)); + } + return nodes; + } + + private static void addPresent(List children, Field field) { + if (field != null) { + children.add(field); + } + } + + record CodeRange(String loadKey, double min, double max) { + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/cave/DeepCaveCompilerTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/cave/DeepCaveCompilerTest.java new file mode 100644 index 000000000..1e6c616d4 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/cave/DeepCaveCompilerTest.java @@ -0,0 +1,131 @@ +package art.arcane.iris.pack.datapack.export.compile.cave; + +import art.arcane.iris.generation.cave.IrisCaveProfile; +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ExportTestPacks; +import art.arcane.iris.pack.datapack.export.compile.DimensionStage; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; +import art.arcane.iris.pack.datapack.export.ir.CostClass; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.util.List; +import java.util.Map; + +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 DeepCaveCompilerTest { + private static final Field SURFACE = new Field.Const(150.0); + private static final int LAVA_HEIGHT = 2; + private static final int LAVA_Y = CaveTestPacks.MIN_Y + LAVA_HEIGHT; + private static final int ZONE_Y = -58; + private static final int ABOVE_LINE_Y = -40; + + @ClassRule + public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Rule + public ExportTestPacks.BoundPlatform platform = new ExportTestPacks.BoundPlatform(); + + @Test + public void deepCavesStaySolidInFinalDensityAndOpenThroughTheMaterialPass() throws Exception { + PackCompileContext context = compile(CaveTestPacks.constantProfile(5.0).setAllowLava(true), LAVA_HEIGHT, DataVersion.V26_3); + + Field opening = context.deepCaves().orElseThrow(); + assertTrue(sample(context, context.caves(), ZONE_Y) > 0.0); + assertTrue(sample(context, context.caves(), DeepCaveCompiler.LAVA_LINE - 1) > 0.0); + assertTrue(sample(context, context.caves(), DeepCaveCompiler.LAVA_LINE) <= 0.0); + assertTrue(sample(context, context.caves(), ABOVE_LINE_Y) <= 0.0); + assertTrue(sample(context, context.caves(), LAVA_Y) <= 0.0); + assertEquals(1.0, sample(context, opening, ZONE_Y), 0.0); + assertEquals(1.0, sample(context, opening, LAVA_Y + 1), 0.0); + assertEquals(0.0, sample(context, opening, DeepCaveCompiler.LAVA_LINE), 0.0); + assertEquals(0.0, sample(context, opening, ABOVE_LINE_Y), 0.0); + assertEquals(0.0, sample(context, opening, LAVA_Y), 0.0); + assertTrue(has(context, ExportLimit.DEEP_CAVE_LAYERS)); + } + + @Test + public void solidDeepRockStaysClosed() throws Exception { + PackCompileContext context = compile(CaveTestPacks.constantProfile(-5.0).setAllowLava(true), LAVA_HEIGHT, DataVersion.V26_3); + + Field opening = context.deepCaves().orElseThrow(); + assertEquals(0.0, sample(context, opening, ZONE_Y), 0.0); + assertTrue(sample(context, context.caves(), ZONE_Y) > 0.0); + } + + @Test + public void lavaFreeCavesOpenDownToTheFloor() throws Exception { + PackCompileContext context = compile(CaveTestPacks.constantProfile(5.0), LAVA_HEIGHT, DataVersion.V26_3); + + Field opening = context.deepCaves().orElseThrow(); + assertEquals(1.0, sample(context, opening, CaveTestPacks.MIN_Y), 0.0); + assertTrue(sample(context, context.caves(), CaveTestPacks.MIN_Y) > 0.0); + } + + @Test + public void lavaAboveTheLineLeavesCavesUnchanged() throws Exception { + PackCompileContext context = compile(CaveTestPacks.constantProfile(5.0).setAllowLava(true), 20, DataVersion.V26_3); + + assertTrue(context.deepCaves().isEmpty()); + assertSame(context.caveField().orElseThrow(), context.caves()); + assertTrue(CaveTestPacks.caveField(context.caves()) instanceof Field.Grid); + assertFalse(has(context, ExportLimit.DEEP_CAVE_LAYERS)); + } + + @Test + public void expensiveCavesNeverSitUnderEagerNodes() throws Exception { + PackCompileContext context = compile(CaveTestPacks.constantProfile(5.0).setAllowLava(true), LAVA_HEIGHT, DataVersion.V26_3); + + for (Field root : List.of(context.caves(), context.deepCaves().orElseThrow())) { + for (Field node : CaveTestPacks.nodes(root)) { + for (Field child : CaveTestPacks.eagerChildren(node)) { + assertTrue(node.getClass().getSimpleName() + " evaluates an expensive child eagerly", child.cost() != CostClass.EXPENSIVE); + } + } + } + } + + private PackCompileContext compile(IrisCaveProfile profile, int lavaHeight, DataVersion target) throws Exception { + File pack = CaveTestPacks.pack(temporaryFolder.newFolder("pack"), CaveTestPacks.dimension(lavaHeight), + CaveTestPacks.region(List.of("cave-deep"), null), Map.of("cave-deep", profile)); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = ExportTestPacks.contextBuilder(data, target).styles(FakeCaveStyles.volumetric()).build(); + new DimensionStage().apply(context); + context.setHeight(SURFACE); + context.setBiomeCodes(CaveTestPacks.codes(CaveTestPacks.xGradient(), List.of(new CaveTestPacks.CodeRange("cave-deep", -1.0, 1.0)))); + new CaveStage().apply(context); + return context; + } finally { + data.close(); + } + } + + private static double sample(PackCompileContext context, Field field, int y) { + return CaveTestPacks.sample(field, context.noises(), 40, y, 40); + } + + private static boolean has(PackCompileContext context, ExportLimit limit) { + for (ExportDiagnostic diagnostic : context.diagnostics()) { + if (diagnostic.limit() == limit) { + return true; + } + } + return false; + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/cave/FakeCaveStyles.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/cave/FakeCaveStyles.java new file mode 100644 index 000000000..ff094a6e8 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/cave/FakeCaveStyles.java @@ -0,0 +1,84 @@ +package art.arcane.iris.pack.datapack.export.compile.cave; + +import art.arcane.iris.generation.noise.IrisGeneratorStyle; +import art.arcane.iris.generation.noise.NoiseStyle; +import art.arcane.iris.pack.datapack.export.compile.noise.NoiseRegistry; +import art.arcane.iris.pack.datapack.export.compile.noise.StyleCompiler; +import art.arcane.iris.pack.datapack.export.ir.Axis; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.NoiseDef; +import art.arcane.iris.pack.datapack.export.lower.ExportCapabilities; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +final class FakeCaveStyles implements StyleCompiler { + static final String CELLULAR_PREFIX = "cave_test/cellular/"; + private static final String SMOOTH_PREFIX = "cave_test/smooth/"; + private static final String WARP_PREFIX = "cave_test/warp/"; + private static final double BLOCKS_PER_ZOOM = 64.0; + private static final List SINGLE_OCTAVE = List.of(1.0); + + private final Mode mode; + private final List requests = new ArrayList<>(); + + private FakeCaveStyles(Mode mode) { + this.mode = mode; + } + + static FakeCaveStyles volumetric() { + return new FakeCaveStyles(Mode.VOLUMETRIC); + } + + static FakeCaveStyles planar() { + return new FakeCaveStyles(Mode.PLANAR); + } + + static FakeCaveStyles expensiveCellular() { + return new FakeCaveStyles(Mode.EXPENSIVE_CELLULAR); + } + + static FakeCaveStyles expensive() { + return new FakeCaveStyles(Mode.EXPENSIVE); + } + + static boolean cellular(NoiseStyle style) { + String name = style.name(); + return name.contains("CELL") || name.contains("VASCULAR") || name.contains("GLOB") || name.contains("HEX"); + } + + static Field unit(Field signed) { + return new Field.Curve(signed, List.of( + new Field.Knot(-1.0, new Field.Const(0.0), 0.5), + new Field.Knot(1.0, new Field.Const(1.0), 0.5))); + } + + List requests() { + return List.copyOf(requests); + } + + @Override + public Field compile(IrisGeneratorStyle style, long seedSalt, NoiseRegistry registry, ExportCapabilities capabilities) { + NoiseStyle noiseStyle = style.getStyle(); + requests.add(noiseStyle); + boolean cellular = cellular(noiseStyle); + if (cellular && mode == Mode.PLANAR) { + return new Field.Coord(Axis.X, Field.Tiling.REPEAT, 0, 64, 0, 1); + } + String key = noiseStyle.name().toLowerCase(Locale.ROOT) + "_" + seedSalt; + double scale = 1.0 / (BLOCKS_PER_ZOOM * style.getZoom()); + double yScale = mode == Mode.PLANAR ? 0.0 : scale; + boolean warped = mode == Mode.EXPENSIVE || cellular && mode == Mode.EXPENSIVE_CELLULAR; + Field shift = warped ? new Field.Noise(registry.register(new NoiseDef(WARP_PREFIX + key, 0, SINGLE_OCTAVE)), scale, yScale, null, null, null) : null; + String id = registry.register(new NoiseDef((cellular ? CELLULAR_PREFIX : SMOOTH_PREFIX) + key, 0, SINGLE_OCTAVE)); + return unit(new Field.Noise(id, scale, yScale, shift, null, shift)); + } + + private enum Mode { + VOLUMETRIC, + PLANAR, + EXPENSIVE_CELLULAR, + EXPENSIVE + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/cave/IrisCaveReference.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/cave/IrisCaveReference.java new file mode 100644 index 000000000..0c74ecec9 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/cave/IrisCaveReference.java @@ -0,0 +1,159 @@ +package art.arcane.iris.pack.datapack.export.compile.cave; + +import art.arcane.iris.generation.cave.IrisCaveFieldModule; +import art.arcane.iris.generation.cave.IrisCaveProfile; +import art.arcane.iris.generation.noise.IrisStyledRange; +import art.arcane.iris.pack.datapack.export.compile.noise.NoiseRegistry; +import art.arcane.iris.pack.datapack.export.compile.noise.StyleCompiler; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.lower.ExportCapabilities; +import art.arcane.iris.pack.value.IrisRange; + +import java.util.ArrayList; +import java.util.List; + +final class IrisCaveReference { + private static final long BASE_SALT = 934_447L; + private static final long DETAIL_SALT = 612_991L; + private static final long THRESHOLD_SALT = 489_112L; + private static final long SURFACE_BREAK_SALT = 341_219L; + private static final long MODULE_SALT = 1_000_003L; + private static final long MODULE_SALT_STEP = 65_537L; + private static final int CEILING_FADE_DEPTH = 12; + private static final double CLOSURE_EPSILON = 0.000001D; + + private final IrisCaveProfile profile; + private final NoiseRegistry registry; + private final Field height; + private final Field base; + private final Field detail; + private final List modules; + private final Field threshold; + private final Field surfaceBreak; + private final int worldMinY; + private final int worldHeight; + private final double normalization; + + IrisCaveReference(IrisCaveProfile profile, StyleCompiler styles, NoiseRegistry registry, ExportCapabilities capabilities, Field height) { + this.profile = profile; + this.registry = registry; + this.height = height; + this.worldMinY = CaveTestPacks.MIN_Y; + this.worldHeight = CaveTestPacks.MAX_Y - CaveTestPacks.MIN_Y; + base = styles.compile(profile.getBaseDensityStyle(), BASE_SALT, registry, capabilities); + detail = styles.compile(profile.getDetailDensityStyle(), DETAIL_SALT, registry, capabilities); + modules = new ArrayList<>(); + double weight = Math.abs(profile.getBaseWeight()) + Math.abs(profile.getDetailWeight()); + for (int index = 0; index < profile.getModules().size(); index++) { + IrisCaveFieldModule module = profile.getModules().get(index); + modules.add(styles.compile(module.getStyle(), MODULE_SALT + index * MODULE_SALT_STEP, registry, capabilities)); + weight += Math.abs(module.getWeight()); + } + normalization = weight <= 0 ? 1D : weight; + IrisStyledRange range = profile.getDensityThreshold(); + threshold = range.getMin() == range.getMax() || range.getStyle().isFlat() ? null + : styles.compile(range.getStyle(), THRESHOLD_SALT, registry, capabilities); + surfaceBreak = profile.isAllowSurfaceBreak() ? styles.compile(profile.getSurfaceBreakStyle(), SURFACE_BREAK_SALT, registry, capabilities) : null; + } + + boolean carved(int x, int worldY, int z) { + int y = worldY - worldMinY; + IrisRange range = profile.getVerticalRange(); + int minY = Math.max(0, (int) Math.floor(range.getMin())); + int maxY = Math.min(worldHeight - 1, (int) Math.ceil(range.getMax())); + if (y < minY || y > maxY) { + return false; + } + int surface = (int) Math.round(sample(height, x, 0, z)) - worldMinY; + int unclampedClearanceTop = surface - Math.max(0, profile.getSurfaceClearance()); + int clearanceTop = Math.min(maxY, Math.max(minY, unclampedClearanceTop)); + boolean breakColumn = surfaceBreak != null && signed(sample(surfaceBreak, x, 0, z)) >= profile.getSurfaceBreakNoiseThreshold(); + int columnTop = breakColumn ? Math.min(maxY, Math.max(minY, surface)) : clearanceTop; + if (y > columnTop) { + return false; + } + double local = columnThreshold(x, z) - profile.getThresholdBias(); + if (breakColumn && y >= Math.max(minY, surface - Math.max(0, profile.getSurfaceBreakDepth()))) { + local += Math.max(0, profile.getSurfaceBreakThresholdBoost()); + } + local -= edgeFade(y, minY, maxY); + if (!breakColumn && unclampedClearanceTop <= maxY) { + int distance = columnTop - y; + double closure = closure(y); + if (distance >= 0 && distance < CEILING_FADE_DEPTH && local > closure) { + double progress = distance / (double) CEILING_FADE_DEPTH; + double smooth = progress * progress * (3D - (2D * progress)); + local = closure + ((local - closure) * smooth); + } + } + return density(x, worldY, z) <= local; + } + + private double columnThreshold(int x, int z) { + IrisStyledRange range = profile.getDensityThreshold(); + if (range.getMin() == range.getMax()) { + return range.getMin(); + } + if (threshold == null) { + return range.getMin() + (range.getMax() - range.getMin()) * 0.5; + } + return range.getMin() + (range.getMax() - range.getMin()) * sample(threshold, x, 0, z); + } + + private double density(int x, int worldY, int z) { + int y = worldY - worldMinY; + double density = signed(sample(base, x, worldY, z)) * profile.getBaseWeight(); + density += signed(sample(detail, x, worldY, z)) * profile.getDetailWeight(); + for (int index = 0; index < modules.size(); index++) { + IrisCaveFieldModule module = profile.getModules().get(index); + if (!active(module, y)) { + continue; + } + double moduleDensity = signed(sample(modules.get(index), x, worldY, z)) - module.getThreshold(); + if (module.isInvert()) { + moduleDensity = -moduleDensity; + } + density += moduleDensity * module.getWeight(); + } + return density / normalization; + } + + private double closure(int y) { + double minimum = -Math.abs(profile.getBaseWeight()) - Math.abs(profile.getDetailWeight()); + for (IrisCaveFieldModule module : profile.getModules()) { + if (!active(module, y)) { + continue; + } + double rawMin = module.isInvert() ? module.getThreshold() - 1D : -1D - module.getThreshold(); + double rawMax = module.isInvert() ? module.getThreshold() + 1D : 1D - module.getThreshold(); + minimum += Math.min(rawMin * module.getWeight(), rawMax * module.getWeight()); + } + return minimum / normalization - CLOSURE_EPSILON; + } + + private double edgeFade(int y, int minY, int maxY) { + int fadeRange = Math.max(0, profile.getVerticalEdgeFade()); + double strength = Math.max(0D, profile.getVerticalEdgeFadeStrength()); + if (fadeRange <= 0 || maxY <= minY || strength <= 0D) { + return 0D; + } + int edgeDistance = Math.min(y - minY, maxY - y); + if (edgeDistance >= fadeRange) { + return 0D; + } + double t = Math.max(0D, Math.min(1D, edgeDistance / (double) fadeRange)); + return (1D - t * t * (3D - (2D * t))) * strength; + } + + private static boolean active(IrisCaveFieldModule module, int y) { + return y >= (int) Math.floor(module.getVerticalRange().getMin()) && y <= (int) Math.ceil(module.getVerticalRange().getMax()); + } + + private double sample(Field field, int x, int y, int z) { + return CaveTestPacks.sample(field, registry, x, y, z); + } + + private static double signed(double unit) { + return unit * 2D - 1D; + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/cave/WallShellCalibratorTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/cave/WallShellCalibratorTest.java new file mode 100644 index 000000000..37ab177d1 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/cave/WallShellCalibratorTest.java @@ -0,0 +1,47 @@ +package art.arcane.iris.pack.datapack.export.compile.cave; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ir.Axis; +import art.arcane.iris.pack.datapack.export.ir.Field; +import org.junit.Test; + +import java.util.Map; + +import static org.junit.Assert.assertEquals; + +public class WallShellCalibratorTest { + private static final int MIN_Y = -64; + private static final int MAX_Y = 64; + + @Test + public void bandHoldsTheOneBlockHorizontalWall() { + double band = WallShellCalibrator.band(new CaveInterior(ridges(0.4), MIN_Y, MAX_Y), Map.of(), DataVersion.V26_3); + + assertEquals(0.1, band, 1.0e-6); + } + + @Test + public void bandScalesWithTheFieldGradient() { + double band = WallShellCalibrator.band(new CaveInterior(ridges(1.2), MIN_Y, MAX_Y), Map.of(), DataVersion.V26_3); + + assertEquals(0.3, band, 1.0e-6); + } + + @Test + public void fieldsWithoutCavesHaveAnEmptyBand() { + double band = WallShellCalibrator.band(new CaveInterior(new Field.Const(1.0), MIN_Y, MAX_Y), Map.of(), DataVersion.V26_3); + + assertEquals(0.0, band, 0.0); + } + + @Test + public void calibrationIsDeterministic() { + CaveInterior interior = new CaveInterior(ridges(1.2), 0, 40); + + assertEquals(WallShellCalibrator.band(interior, Map.of(), DataVersion.V26_3), WallShellCalibrator.band(interior, Map.of(), DataVersion.V26_3), 0.0); + } + + private static Field ridges(double amplitude) { + return new Field.Coord(Axis.X, Field.Tiling.MIRRORED_REPEAT, 0.0, 8.0, -amplitude, amplitude); + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/decoration/BiomeFeatureListsTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/decoration/BiomeFeatureListsTest.java new file mode 100644 index 000000000..93af52337 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/decoration/BiomeFeatureListsTest.java @@ -0,0 +1,106 @@ +package art.arcane.iris.pack.datapack.export.compile.decoration; + +import art.arcane.iris.pack.datapack.export.ir.BiomeDef; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +public class BiomeFeatureListsTest { + private static final String NAMESPACE = "iris_export"; + + @Test + public void globalOrderIsSharedAcrossBiomes() { + BiomeFeatureLists lists = new BiomeFeatureLists(); + lists.add("iris_export:plains", GenerationStep.VEGETAL_DECORATION, "iris_export:grass"); + lists.add("iris_export:plains", GenerationStep.VEGETAL_DECORATION, "iris_export:flowers"); + lists.add("iris_export:forest", GenerationStep.VEGETAL_DECORATION, "iris_export:flowers"); + lists.add("iris_export:forest", GenerationStep.VEGETAL_DECORATION, "iris_export:grass"); + + List biomes = lists.apply(List.of(biome("plains"), biome("forest")), NAMESPACE); + + List expected = List.of("iris_export:grass", "iris_export:flowers"); + assertEquals(expected, step(biomes.get(0), GenerationStep.VEGETAL_DECORATION)); + assertEquals(expected, step(biomes.get(1), GenerationStep.VEGETAL_DECORATION)); + } + + @Test + public void biomeListIsFilteredGlobalOrder() { + BiomeFeatureLists lists = new BiomeFeatureLists(); + lists.add("iris_export:plains", GenerationStep.UNDERGROUND_ORES, "iris_export:coal"); + lists.add("iris_export:desert", GenerationStep.UNDERGROUND_ORES, "iris_export:gold"); + lists.add("iris_export:plains", GenerationStep.UNDERGROUND_ORES, "minecraft:ore_iron"); + lists.add("iris_export:desert", GenerationStep.UNDERGROUND_ORES, "minecraft:ore_iron"); + lists.add("iris_export:desert", GenerationStep.UNDERGROUND_ORES, "iris_export:coal"); + + List biomes = lists.apply(List.of(biome("plains"), biome("desert")), NAMESPACE); + + assertEquals(List.of("iris_export:coal", "minecraft:ore_iron"), step(biomes.get(0), GenerationStep.UNDERGROUND_ORES)); + assertEquals(List.of("iris_export:coal", "iris_export:gold", "minecraft:ore_iron"), step(biomes.get(1), GenerationStep.UNDERGROUND_ORES)); + assertEquals(List.of(), step(biomes.get(0), GenerationStep.VEGETAL_DECORATION)); + } + + @Test + public void duplicateAddIsNoOp() { + BiomeFeatureLists lists = new BiomeFeatureLists(); + lists.add("iris_export:plains", GenerationStep.LAKES, "iris_export:pond"); + lists.add("iris_export:plains", GenerationStep.LAKES, "iris_export:pond"); + lists.add("iris_export:plains", GenerationStep.FLUID_SPRINGS, "iris_export:pond"); + + BiomeDef biome = lists.apply(List.of(biome("plains")), NAMESPACE).getFirst(); + + assertEquals(List.of("iris_export:pond"), step(biome, GenerationStep.LAKES)); + assertEquals(List.of("iris_export:pond"), step(biome, GenerationStep.FLUID_SPRINGS)); + } + + @Test + public void elevenStepArraysAlwaysWritten() { + BiomeFeatureLists lists = new BiomeFeatureLists(); + lists.add("iris_export:plains", GenerationStep.TOP_LAYER_MODIFICATION, "minecraft:freeze_top_layer"); + BiomeDef source = biome("desert"); + + List biomes = lists.apply(List.of(biome("plains"), source), NAMESPACE); + + assertEquals(11, GenerationStep.values().length); + assertEquals(GenerationStep.TOP_LAYER_MODIFICATION, GenerationStep.values()[10]); + JsonArray empty = new JsonArray(); + for (int index = 0; index < 11; index++) { + empty.add(new JsonArray()); + } + assertEquals(empty, biomes.get(1).json().get("features")); + assertEquals(11, biomes.get(0).json().getAsJsonArray("features").size()); + assertEquals(List.of("minecraft:freeze_top_layer"), step(biomes.get(0), GenerationStep.TOP_LAYER_MODIFICATION)); + assertEquals(0.8, biomes.get(1).json().get("temperature").getAsDouble(), 0.0); + assertEquals(0, source.json().getAsJsonArray("features").size()); + } + + @Test + public void featureForUndefinedBiomeIsRejected() { + BiomeFeatureLists lists = new BiomeFeatureLists(); + lists.add("iris_export:swamp", GenerationStep.LAKES, "iris_export:pond"); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> lists.apply(List.of(biome("plains")), NAMESPACE)); + + assertEquals("Feature lists reference undefined biome 'iris_export:swamp'", error.getMessage()); + } + + private static BiomeDef biome(String id) { + JsonObject json = JsonParser.parseString("{\"temperature\":0.8,\"features\":[]}").getAsJsonObject(); + return new BiomeDef(id, json); + } + + private static List step(BiomeDef biome, GenerationStep step) { + JsonArray features = biome.json().getAsJsonArray("features").get(step.ordinal()).getAsJsonArray(); + List ids = new ArrayList<>(features.size()); + for (int index = 0; index < features.size(); index++) { + ids.add(features.get(index).getAsString()); + } + return ids; + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/decoration/DecorationStageTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/decoration/DecorationStageTest.java new file mode 100644 index 000000000..640a81189 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/decoration/DecorationStageTest.java @@ -0,0 +1,549 @@ +package art.arcane.iris.pack.datapack.export.compile.decoration; + +import art.arcane.iris.generation.noise.IrisGeneratorStyle; +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ExportTestPacks; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; +import art.arcane.iris.pack.datapack.export.compile.StageTestPacks; +import art.arcane.iris.pack.datapack.export.compile.noise.VanillaNoiseModel; +import art.arcane.iris.pack.datapack.export.compile.surface.MaterialProgramCompiler; +import art.arcane.iris.pack.datapack.export.compile.surface.SurfaceStage; +import art.arcane.iris.pack.datapack.export.ir.BiomeDef; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.JsonResource; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.noise.CNG; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.BiPredicate; +import java.util.function.Consumer; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +public class DecorationStageTest { + @ClassRule + public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + private static final String DIMENSION = "{\"regions\":[\"region\"]}"; + private static final Field.Shared CAVE_FIELD = new Field.Shared("cave/field", new Field.Const(1.0)); + private static final String COAL = "{\"palette\":[{\"block\":\"coal_ore\"}],\"minHeight\":10,\"maxHeight\":40,\"heightDistribution\":\"UNIFORM\"," + + "\"minPerChunk\":2,\"maxPerChunk\":6,\"minSize\":4,\"maxSize\":12,\"shape\":\"VANILLA_ELLIPSOID\",\"surfaceClearance\":7}"; + private static final String IRON = "{\"palette\":[{\"block\":\"iron_ore\"}],\"minHeight\":0,\"maxHeight\":64,\"heightDistribution\":\"TRIANGLE\"," + + "\"minPerChunk\":1,\"maxPerChunk\":3,\"minSize\":6,\"maxSize\":6,\"placementScope\":\"FULL_HEIGHT\"}"; + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Rule + public ExportTestPacks.BoundPlatform platform = new ExportTestPacks.BoundPlatform(); + + @Test + public void featureOrderIsGlobalAndAcyclic() throws Exception { + Map biomes = new LinkedHashMap<>(); + biomes.put("first", "{\"deposits\":[" + COAL + "," + IRON + "],\"decorators\":[" + decorator("short_grass", 0.2) + "," + decorator("fern", 0.1) + "]}"); + biomes.put("second", "{\"deposits\":[" + IRON + "," + COAL + "],\"decorators\":[" + decorator("fern", 0.1) + "," + decorator("short_grass", 0.2) + "]}"); + biomes.put("third", "{\"deposits\":[" + IRON + "],\"decorators\":[" + decorator("poppy", 0.05) + "]}"); + Result result = decorate(biomes, DataVersion.V26_3, context -> { + }); + + Map>> perStep = new HashMap<>(); + for (BiomeDef biome : result.biomes()) { + JsonArray steps = biome.json().getAsJsonArray("features"); + assertEquals(GenerationStep.values().length, steps.size()); + for (GenerationStep step : GenerationStep.values()) { + List features = new ArrayList<>(); + for (JsonElement feature : steps.get(step.ordinal()).getAsJsonArray()) { + features.add(feature.getAsString()); + } + perStep.computeIfAbsent(step, ignored -> new ArrayList<>()).add(features); + } + } + for (Map.Entry>> step : perStep.entrySet()) { + Map rank = new HashMap<>(); + for (List features : step.getValue()) { + for (int index = 0; index < features.size(); index++) { + for (int later = index + 1; later < features.size(); later++) { + String pair = features.get(later) + ">" + features.get(index); + assertFalse("cycle between " + features.get(index) + " and " + features.get(later), rank.containsKey(pair)); + rank.put(features.get(index) + ">" + features.get(later), 1); + } + } + } + } + List firstOres = perStep.get(GenerationStep.UNDERGROUND_ORES).get(0); + List secondOres = perStep.get(GenerationStep.UNDERGROUND_ORES).get(1); + assertEquals(2, firstOres.size()); + assertEquals(firstOres, secondOres); + for (List> lists : perStep.values()) { + for (List features : lists) { + for (String feature : features) { + assertTrue(feature, feature.startsWith(StageTestPacks.NAMESPACE + ":")); + } + } + } + } + + @Test + public void duplicateDecoratorsShareOneFeature() throws Exception { + Map biomes = new LinkedHashMap<>(); + biomes.put("meadow", "{\"decorators\":[" + decorator("short_grass", 0.2) + "]}"); + biomes.put("field", "{\"decorators\":[" + decorator("short_grass", 0.2) + "]}"); + Result result = decorate(biomes, DataVersion.V26_3, context -> { + }); + + List features = result.under("worldgen/feature/decorator/"); + List placed = result.under("worldgen/placed_feature/decorator/"); + assertEquals(1, features.size()); + assertEquals(1, placed.size()); + assertEquals("data/iris_export/worldgen/feature/decorator/short_grass.json", features.getFirst().path()); + String id = "iris_export:decorator/short_grass"; + for (BiomeDef biome : result.biomes()) { + assertEquals(List.of(id), step(biome, GenerationStep.VEGETAL_DECORATION)); + } + } + + @Test + public void fractionalChanceUsesRandomChance() throws Exception { + Map biomes = new LinkedHashMap<>(); + biomes.put("meadow", "{\"decorators\":[" + decorator("short_grass", 0.3) + "]}"); + Result modern = decorate(biomes, DataVersion.V26_3, context -> { + }); + + JsonObject placed = modern.under("worldgen/placed_feature/decorator/").getFirst().json().getAsJsonObject(); + JsonArray placement = placed.getAsJsonArray("placement"); + assertEquals("minecraft:cuboid", placement.get(0).getAsJsonObject().get("type").getAsString()); + JsonObject chance = StageTestPacks.ofType(placement, "minecraft:random_chance").getFirst(); + assertEquals(0.3f, chance.get("chance").getAsFloat(), 0.0f); + assertEquals("{\"type\":\"minecraft:simple_block\",\"to_place\":{\"id\":\"minecraft:short_grass\"}}", + modern.under("worldgen/feature/decorator/").getFirst().json().toString()); + } + + @Test + public void aquaticDecoratorsPlaceOnlyUnderwater() throws Exception { + Map biomes = new LinkedHashMap<>(); + biomes.put("reef", "{\"decorators\":[" + decorator("seagrass", 0.2) + "," + + "{\"palette\":[{\"block\":\"kelp_plant\"}],\"chance\":0.02,\"stackMin\":2,\"stackMax\":6}," + + "{\"palette\":[{\"block\":\"kelp_plant\"}],\"chance\":0.03,\"stackMin\":2,\"stackMax\":6,\"partOf\":\"SEA_FLOOR\"}]}"); + Result result = decorate(biomes, DataVersion.V26_3, context -> { + }); + + List placed = result.under("worldgen/placed_feature/decorator/"); + assertEquals(3, placed.size()); + for (JsonResource resource : placed) { + JsonArray placement = resource.json().getAsJsonObject().getAsJsonArray("placement"); + assertEquals(resource.path(), Placements.OCEAN_FLOOR, + StageTestPacks.ofType(placement, "minecraft:heightmap").getFirst().get("heightmap").getAsString()); + JsonObject filter = StageTestPacks.ofType(placement, "minecraft:block_predicate_filter").getLast(); + assertFalse(resource.path(), StageTestPacks.ofType(filter, "minecraft:matching_fluids").isEmpty()); + assertTrue(resource.path(), StageTestPacks.ofType(filter, "minecraft:matching_block_tag").isEmpty()); + } + for (JsonResource resource : result.under("worldgen/feature/decorator/")) { + JsonObject feature = resource.json().getAsJsonObject(); + if (feature.has("allowed_placement")) { + assertEquals(resource.path(), "minecraft:matching_fluids", + feature.getAsJsonObject("allowed_placement").get("type").getAsString()); + } + } + } + + @Test + public void shoreDecoratorsGrowOnSolidGroundBesideWater() throws Exception { + Map biomes = new LinkedHashMap<>(); + biomes.put("beach", "{\"decorators\":[{\"palette\":[{\"block\":\"sugar_cane\"}],\"partOf\":\"SHORE_LINE\",\"chance\":0.5," + + "\"stackMin\":1,\"stackMax\":4}]}"); + Result result = decorate(biomes, DataVersion.V26_3, context -> { + }); + + JsonArray placement = result.under("worldgen/placed_feature/decorator/").getFirst().json().getAsJsonObject().getAsJsonArray("placement"); + JsonObject filter = StageTestPacks.ofType(placement, "minecraft:block_predicate_filter").getLast(); + List supports = new ArrayList<>(); + for (JsonObject solid : StageTestPacks.ofType(filter, "minecraft:solid")) { + supports.add(solid.get("offset").toString()); + } + assertEquals(List.of("[0,-1,0]"), supports); + assertEquals(4, StageTestPacks.ofType(filter, "minecraft:matching_fluids").size()); + } + + @Test + public void tallPlantsPlaceBothHalves() throws Exception { + Map biomes = new LinkedHashMap<>(); + biomes.put("reef", "{\"decorators\":[{\"palette\":[{\"block\":\"tall_seagrass\",\"data\":{\"half\":\"lower\"}}]," + + "\"topPalette\":[{\"block\":\"tall_seagrass\",\"data\":{\"half\":\"upper\"}}],\"partOf\":\"SEA_FLOOR\",\"chance\":0.2}]}"); + Result result = decorate(biomes, DataVersion.V26_3, context -> { + }); + + JsonObject feature = result.under("worldgen/feature/decorator/").getFirst().json().getAsJsonObject(); + assertEquals(feature.toString(), "minecraft:simple_block", feature.get("type").getAsString()); + assertEquals("{\"id\":\"minecraft:tall_seagrass\",\"properties\":{\"half\":\"lower\"}}", feature.get("to_place").toString()); + JsonArray placement = result.under("worldgen/placed_feature/decorator/").getFirst().json().getAsJsonObject().getAsJsonArray("placement"); + List water = new ArrayList<>(); + for (JsonObject fluid : StageTestPacks.ofType(placement, "minecraft:matching_fluids")) { + water.add(fluid.has("offset") ? fluid.get("offset").toString() : "[0,0,0]"); + } + assertEquals(List.of("[0,0,0]", "[0,1,0]"), water); + } + + @Test + public void waterlogFollowsWhereTheDecoratorPlaces() throws Exception { + Map biomes = new LinkedHashMap<>(); + biomes.put("reef", "{\"decorators\":[" + decorator("sea_pickle[pickles=2,waterlogged=false]", 0.2) + "," + + "{\"palette\":[{\"block\":\"oak_stairs[waterlogged=true]\"},{\"block\":\"short_grass\"}],\"chance\":0.1}]}"); + Result result = decorate(biomes, DataVersion.V26_3, context -> { + }); + + String pickles = result.resource("data/iris_export/worldgen/feature/decorator/sea_pickle.json").toString(); + String stairs = result.resource("data/iris_export/worldgen/feature/decorator/oak_stairs.json").toString(); + assertTrue(pickles, pickles.contains("\"waterlogged\":\"true\"")); + assertTrue(stairs, stairs.contains("\"waterlogged\":\"false\"")); + } + + @Test + public void paletteEndsCarryHalfWeightLikeIrisRounding() throws Exception { + Map biomes = new LinkedHashMap<>(); + biomes.put("meadow", "{\"decorators\":[{\"palette\":[{\"block\":\"bush\"},{\"block\":\"air\",\"weight\":4}],\"chance\":0.5}," + + "{\"palette\":[{\"block\":\"fern\",\"weight\":2},{\"block\":\"short_grass\",\"weight\":3}],\"chance\":0.2,\"partOf\":\"SHORE_LINE\"}]}"); + Result result = decorate(biomes, DataVersion.V26_3, context -> { + }); + + JsonArray placement = placement(result, "data/iris_export/worldgen/placed_feature/decorator/bush.json"); + assertEquals(0.5 / 8.0, StageTestPacks.ofType(placement, "minecraft:random_chance").getFirst().get("chance").getAsDouble(), 1.0e-6); + JsonArray weighted = result.resource("data/iris_export/worldgen/feature/decorator/fern.json").getAsJsonObject() + .getAsJsonObject("to_place").getAsJsonArray("entries"); + assertEquals(3, weighted.get(0).getAsJsonObject().get("weight").getAsInt()); + assertEquals(5, weighted.get(1).getAsJsonObject().get("weight").getAsInt()); + } + + @Test + public void styledDecoratorPlacesAtStyleCoverage() throws Exception { + String style = "{\"style\":\"SIMPLEX\",\"zoom\":0.2,\"fracture\":{\"style\":\"STATIC\",\"multiplier\":8}}"; + Map biomes = new LinkedHashMap<>(); + biomes.put("meadow", "{\"decorators\":[{\"palette\":[{\"block\":\"poppy\"}],\"chance\":0.2,\"style\":" + style + "}]}"); + Result result = decorate(biomes, DataVersion.V26_3, context -> { + }); + + JsonArray placement = placement(result, "data/iris_export/worldgen/placed_feature/decorator/poppy.json"); + double exported = StageTestPacks.ofType(placement, "minecraft:random_chance").getFirst().get("chance").getAsDouble(); + double coverage = irisCoverage(style, 0.2); + assertTrue("coverage " + coverage, coverage < 0.1); + assertEquals(coverage, exported, coverage * 0.2); + assertEquals("{\"type\":\"minecraft:simple_block\",\"to_place\":{\"id\":\"minecraft:poppy\"}}", + result.resource("data/iris_export/worldgen/feature/decorator/poppy.json").toString()); + } + + @Test + public void decoratorWhoseStyleNeverPassesIsLeftOut() throws Exception { + Map biomes = new LinkedHashMap<>(); + biomes.put("meadow", "{\"decorators\":[{\"palette\":[{\"block\":\"poppy\"}],\"chance\":0.5,\"style\":{\"style\":\"FLAT\"}}," + + decorator("short_grass", 0.3) + "]}"); + Result result = decorate(biomes, DataVersion.V26_3, context -> { + }); + + assertEquals(List.of("iris_export:decorator/short_grass"), step(result.biomes().getFirst(), GenerationStep.VEGETAL_DECORATION)); + } + + @Test + public void clusteredStyleFollowsFittedNoisePattern() throws Exception { + String style = "{\"style\":\"SIMPLEX\",\"zoom\":4}"; + Map biomes = new LinkedHashMap<>(); + biomes.put("meadow", "{\"decorators\":[{\"palette\":[{\"block\":\"poppy\"},{\"block\":\"dandelion\",\"weight\":2}],\"chance\":0.3,\"style\":" + + style + "}]}"); + Result result = decorate(biomes, DataVersion.V26_3, context -> { + }); + + JsonObject provider = result.resource("data/iris_export/worldgen/feature/decorator/poppy.json").getAsJsonObject() + .getAsJsonObject("to_place"); + assertEquals("minecraft:noise_threshold", provider.get("type").getAsString()); + assertEquals("minecraft:air", provider.getAsJsonObject("default_state").get("id").getAsString()); + assertEquals(0.0, provider.get("high_chance").getAsDouble(), 0.0); + List low = new ArrayList<>(); + for (JsonElement state : provider.getAsJsonArray("low_states")) { + low.add(state.getAsJsonObject().get("id").getAsString()); + } + assertEquals(List.of("minecraft:poppy", "minecraft:dandelion", "minecraft:dandelion", "minecraft:dandelion"), low); + assertTrue(StageTestPacks.ofType(placement(result, "data/iris_export/worldgen/placed_feature/decorator/poppy.json"), + "minecraft:random_chance").isEmpty()); + + JsonObject noise = provider.getAsJsonObject("noise"); + List amplitudes = new ArrayList<>(); + for (JsonElement amplitude : noise.getAsJsonArray("amplitude_modifiers")) { + amplitudes.add(amplitude.getAsDouble()); + } + VanillaNoiseModel model = VanillaNoiseModel.create("iris_export:test", noise.get("base_octave").getAsInt(), amplitudes, 99L, DataVersion.V26_3); + double scale = provider.get("scale").getAsDouble(); + double threshold = provider.get("threshold").getAsDouble(); + GatePattern iris = irisPattern(style, 0.3); + GatePattern vanilla = pattern((x, z) -> model.sample(x * scale, 70.0 * scale, z * scale) < threshold); + assertTrue("iris coverage " + iris.coverage(), iris.coverage() > 0.1 && iris.coverage() < 0.5); + assertEquals(iris.coverage(), vanilla.coverage(), 0.03); + assertTrue("iris patch " + iris.patch() + " vanilla " + vanilla.patch(), + vanilla.patch() > iris.patch() * 0.7 && vanilla.patch() < iris.patch() * 1.4); + } + + @Test + public void columnWinnerSharesFollowIrisReservoirPick() { + assertArrayEquals(new double[]{0.5, 0.5}, DecoratorCompiler.columnShares(new double[]{1.0, 1.0}), 1.0e-12); + double[] shares = DecoratorCompiler.columnShares(new double[]{0.5, 0.2}); + assertEquals(0.5 * (0.8 + 0.2 / 2.0), shares[0], 1.0e-12); + assertEquals(0.2 * (0.5 + 0.5 / 2.0), shares[1], 1.0e-12); + assertArrayEquals(new double[]{0.5, 1.0}, DecoratorCompiler.sequentialChances(new double[]{0.5, 0.5}), 1.0e-12); + } + + @Test + public void depositHeightRangeExact() throws Exception { + Map biomes = new LinkedHashMap<>(); + biomes.put("hills", "{\"deposits\":[" + COAL + "," + IRON + "]}"); + Result result = decorate(biomes, DataVersion.V26_3, context -> { + }); + + JsonArray coal = placement(result, "data/iris_export/worldgen/placed_feature/deposit/coal_ore.json"); + JsonObject coalHeight = StageTestPacks.ofType(coal, "minecraft:height_range").getFirst().getAsJsonObject("height"); + assertEquals("{\"type\":\"minecraft:uniform\",\"min_inclusive\":{\"above_bottom\":10},\"max_inclusive\":{\"above_bottom\":40}}", coalHeight.toString()); + assertEquals(-8, StageTestPacks.ofType(coal, "minecraft:surface_relative_threshold_filter").getFirst().get("max_inclusive").getAsInt()); + assertEquals("{\"type\":\"minecraft:uniform\",\"min_inclusive\":2,\"max_inclusive\":6}", + StageTestPacks.ofType(coal, "minecraft:count").getFirst().get("count").toString()); + JsonArray iron = placement(result, "data/iris_export/worldgen/placed_feature/deposit/iron_ore.json"); + JsonObject ironHeight = StageTestPacks.ofType(iron, "minecraft:height_range").getFirst().getAsJsonObject("height"); + assertEquals("{\"type\":\"minecraft:trapezoid\",\"min_inclusive\":{\"above_bottom\":0},\"max_inclusive\":{\"above_bottom\":64},\"plateau\":0}", + ironHeight.toString()); + assertTrue(StageTestPacks.ofType(iron, "minecraft:surface_relative_threshold_filter").isEmpty()); + + JsonObject ore = result.resource("data/iris_export/worldgen/feature/deposit/coal_ore.json").getAsJsonObject(); + assertEquals("minecraft:ore", ore.get("type").getAsString()); + assertEquals(8, ore.get("size").getAsInt()); + JsonObject target = ore.getAsJsonArray("targets").get(1).getAsJsonObject(); + assertEquals("{\"predicate_type\":\"minecraft:tag_match\",\"tag\":\"iris_export:deposit_host\"}", target.get("target").toString()); + assertEquals("{\"id\":\"minecraft:coal_ore\"}", target.get("state").toString()); + assertNotNull(result.resource("data/iris_export/tags/block/deposit_host.json")); + } + + @Test + public void depositOresFollowTheirHostStone() throws Exception { + String explicit = "{\"palette\":[{\"block\":\"deepslate_redstone_ore[lit=false]\"}],\"minHeight\":0,\"maxHeight\":40," + + "\"minPerChunk\":1,\"maxPerChunk\":2,\"minSize\":4,\"maxSize\":4,\"replaceableBlocks\":[\"stone\",\"deepslate\",\"tuff\"]}"; + Map biomes = new LinkedHashMap<>(); + biomes.put("hills", "{\"deposits\":[" + COAL + "," + explicit + "]}"); + Result result = decorate(biomes, DataVersion.V26_3, context -> { + }); + + JsonArray coal = result.resource("data/iris_export/worldgen/feature/deposit/coal_ore.json").getAsJsonObject().getAsJsonArray("targets"); + assertEquals(2, coal.size()); + assertEquals("{\"predicate_type\":\"minecraft:block_match\",\"block\":\"minecraft:deepslate\"}", + coal.get(0).getAsJsonObject().get("target").toString()); + assertEquals("{\"id\":\"minecraft:deepslate_coal_ore\"}", coal.get(0).getAsJsonObject().get("state").toString()); + assertEquals("{\"id\":\"minecraft:coal_ore\"}", coal.get(1).getAsJsonObject().get("state").toString()); + + JsonArray redstone = result.resource("data/iris_export/worldgen/feature/deposit/deepslate_redstone_ore.json").getAsJsonObject() + .getAsJsonArray("targets"); + Map byHost = new LinkedHashMap<>(); + for (JsonElement element : redstone) { + JsonObject target = element.getAsJsonObject(); + byHost.put(target.getAsJsonObject("target").get("block").getAsString(), target.get("state").toString()); + } + assertEquals("{\"id\":\"minecraft:redstone_ore\",\"properties\":{\"lit\":\"false\"}}", byHost.get("minecraft:stone")); + assertEquals("{\"id\":\"minecraft:deepslate_redstone_ore\",\"properties\":{\"lit\":\"false\"}}", byHost.get("minecraft:deepslate")); + assertEquals("{\"id\":\"minecraft:redstone_ore\",\"properties\":{\"lit\":\"false\"}}", byHost.get("minecraft:tuff")); + } + + @Test + public void vanillaOreVeinsDisabled() throws Exception { + Map biomes = new LinkedHashMap<>(); + biomes.put("caves", "{\"deposits\":[" + COAL + "],\"layers\":[{\"palette\":[{\"block\":\"stone\"}]}],\"wall\":{\"palette\":[{\"block\":\"tuff\"}]}}"); + DataVersion target = DataVersion.V26_3; + Result result = decorate(biomes, target, context -> { + context.setCaveField(CAVE_FIELD, 0.04); + new SurfaceStage().apply(context); + }); + + for (BiomeDef biome : result.biomes()) { + for (GenerationStep step : GenerationStep.values()) { + for (String feature : step(biome, step)) { + assertTrue(feature, feature.startsWith(StageTestPacks.NAMESPACE + ":")); + } + } + } + JsonElement rule = result.context().material().rule(); + for (JsonObject vein : StageTestPacks.ofType(rule, "minecraft:ore_vein")) { + assertEquals("iris_export:" + MaterialProgramCompiler.WALL_SHELL, vein.get("density").getAsString()); + } + assertFalse(rule.toString().contains("ore_vein/")); + assertFalse(rule.toString().contains("minecraft:overworld")); + assertEquals(1, StageTestPacks.ofType(result.context().material().rule(), "minecraft:ore_vein").size()); + } + + @Test + public void repaintUsesCuboid() throws Exception { + Map biomes = new LinkedHashMap<>(); + biomes.put("cliffs", "{\"layers\":[{\"palette\":[{\"block\":\"grass_block\"}],\"slopeCondition\":{\"maximumSlope\":3}}," + + "{\"palette\":[{\"block\":\"stone\"}],\"minHeight\":2,\"maxHeight\":3,\"slopeCondition\":{\"minimumSlope\":3}}," + + "{\"palette\":[{\"block\":\"dirt\"}],\"minHeight\":2,\"maxHeight\":2}],\"wall\":{\"palette\":[{\"block\":\"andesite\"}]}}"); + PackCompileContext[] holder = new PackCompileContext[1]; + Result modern = decorate(biomes, DataVersion.V26_3, context -> holder[0] = context); + + List repaint = step(modern.biomes().getFirst(), GenerationStep.LOCAL_MODIFICATIONS); + assertEquals(List.of("iris_export:repaint/slope_stone", "iris_export:repaint/wall_andesite"), repaint); + JsonArray slope = placement(modern, "data/iris_export/worldgen/placed_feature/repaint/slope_stone.json"); + assertEquals("minecraft:cuboid", slope.get(0).getAsJsonObject().get("type").getAsString()); + assertEquals("{\"type\":\"minecraft:inside_world_bounds\",\"offset\":[0,-1,0]}", + slope.get(1).getAsJsonObject().getAsJsonObject("predicate").toString()); + assertFalse(StageTestPacks.find(slope, object -> object.has("offset") && object.get("offset").toString().equals("[3,3,0]")).isEmpty()); + JsonObject column = modern.resource("data/iris_export/worldgen/feature/repaint/slope_stone.json").getAsJsonObject(); + assertEquals("minecraft:block_column", column.get("type").getAsString()); + assertEquals("down", column.get("direction").getAsString()); + assertEquals("{\"type\":\"minecraft:uniform\",\"min_inclusive\":2,\"max_inclusive\":3}", + column.getAsJsonArray("layers").get(0).getAsJsonObject().get("height").toString()); + assertTrue(hasDiagnostic(holder[0], ExportLimit.SLOPE)); + } + + @Test + public void decorationIsDeterministic() throws Exception { + Map biomes = new LinkedHashMap<>(); + biomes.put("forest", "{\"deposits\":[" + COAL + "],\"decorators\":[" + decorator("short_grass", 0.4) + "," + decorator("poppy", 0.02) + + ",{\"palette\":[{\"block\":\"sugar_cane\"}],\"partOf\":\"SHORE_LINE\",\"chance\":0.5,\"stackMin\":2,\"stackMax\":4}]," + + "\"wall\":{\"palette\":[{\"block\":\"tuff\"}]},\"slab\":{\"palette\":[{\"block\":\"stone_slab\"}]}}"); + Result first = decorate(biomes, DataVersion.V26_3, context -> { + }); + Result second = decorate(biomes, DataVersion.V26_3, context -> { + }); + + assertEquals(first.resources().size(), second.resources().size()); + for (int index = 0; index < first.resources().size(); index++) { + assertEquals(first.resources().get(index).path(), second.resources().get(index).path()); + assertEquals(first.resources().get(index).json().toString(), second.resources().get(index).json().toString()); + } + assertEquals(first.biomes().toString(), second.biomes().toString()); + } + + private Result decorate(Map biomes, DataVersion target, Consumer before) throws Exception { + File pack = StageTestPacks.pack(temporaryFolder.newFolder(), DIMENSION, biomes); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = StageTestPacks.context(data, target, new ArrayList<>(biomes.keySet())); + before.accept(context); + List definitions = new ArrayList<>(); + for (String key : biomes.keySet()) { + definitions.add(new BiomeDef(StageTestPacks.biomeId(key).substring(StageTestPacks.NAMESPACE.length() + 1), new JsonObject())); + } + context.setBiomes(definitions); + new DecorationStage().apply(context); + return new Result(context, context.features().apply(context.biomes(), context.namespace()), List.copyOf(context.jsonResources())); + } finally { + data.close(); + } + } + + private static String decorator(String block, double chance) { + return "{\"palette\":[{\"block\":\"" + block + "\"}],\"chance\":" + chance + "}"; + } + + private static double irisCoverage(String styleJson, double chance) { + return irisPattern(styleJson, chance).coverage(); + } + + private static GatePattern irisPattern(String styleJson, double chance) { + IrisGeneratorStyle style = new Gson().fromJson(styleJson, IrisGeneratorStyle.class); + CNG generator = style.create(new RNG(4242L), null); + double zoom = style.getZoom(); + return pattern((x, z) -> generator.fitDouble(0.0, 1.0, x / zoom, z / zoom) <= chance); + } + + private static GatePattern pattern(BiPredicate gate) { + int[] lags = {1, 2, 4, 8, 16, 32, 64, 128}; + int hits = 0; + int total = 0; + int[] both = new int[lags.length]; + for (int i = 0; i < 150; i++) { + for (int j = 0; j < 150; j++) { + double x = i * 197.0 + 3.0; + double z = j * 173.0 - 11.0; + total++; + if (!gate.test(x, z)) { + continue; + } + hits++; + for (int lag = 0; lag < lags.length; lag++) { + both[lag] += gate.test(x + lags[lag], z) ? 1 : 0; + both[lag] += gate.test(x, z + lags[lag]) ? 1 : 0; + } + } + } + double coverage = (double) hits / total; + double previousLag = 0.0; + double previousExcess = 1.0; + for (int lag = 0; lag < lags.length; lag++) { + double excess = ((double) both[lag] / (2.0 * hits) - coverage) / (1.0 - coverage); + if (excess < 0.5) { + double low = previousLag == 0.0 ? 0.0 : Math.log(previousLag); + double fraction = (previousExcess - 0.5) / (previousExcess - excess); + return new GatePattern(coverage, previousLag == 0.0 ? lags[lag] * fraction : Math.exp(low + (Math.log(lags[lag]) - low) * fraction)); + } + previousLag = lags[lag]; + previousExcess = excess; + } + return new GatePattern(coverage, lags[lags.length - 1]); + } + + private record GatePattern(double coverage, double patch) { + } + + private static List step(BiomeDef biome, GenerationStep step) { + List features = new ArrayList<>(); + for (JsonElement feature : biome.json().getAsJsonArray("features").get(step.ordinal()).getAsJsonArray()) { + features.add(feature.getAsString()); + } + return features; + } + + private static JsonArray placement(Result result, String path) { + return result.resource(path).getAsJsonObject().getAsJsonArray("placement"); + } + + private static boolean hasDiagnostic(PackCompileContext context, ExportLimit limit) { + for (ExportDiagnostic diagnostic : context.diagnostics()) { + if (diagnostic.limit() == limit) { + return true; + } + } + return false; + } + + private record Result(PackCompileContext context, List biomes, List resources) { + List under(String folder) { + List found = new ArrayList<>(); + for (JsonResource resource : resources) { + if (resource.path().startsWith("data/iris_export/" + folder)) { + found.add(resource); + } + } + return found; + } + + JsonElement resource(String path) { + for (JsonResource resource : resources) { + if (resource.path().equals(path)) { + return resource.json(); + } + } + return null; + } + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/noise/CellularFidelityTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/noise/CellularFidelityTest.java new file mode 100644 index 000000000..e8bae5e89 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/noise/CellularFidelityTest.java @@ -0,0 +1,268 @@ +package art.arcane.iris.pack.datapack.export.compile.noise; + +import art.arcane.iris.generation.noise.IrisGeneratorStyle; +import art.arcane.iris.generation.noise.NoiseStyle; +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.FieldEvaluator; +import art.arcane.iris.pack.datapack.export.ir.NoiseDef; +import art.arcane.iris.pack.datapack.export.lower.ExportCapabilities; +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.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class CellularFidelityTest { + private static final String NAMESPACE = "iris_export"; + private static final int SIZE = 2048; + private static final int HEX_SIZE = 1024; + private static final int REALIZATIONS = 8; + private static final int DISTRIBUTION_STRIDE = 2; + private static final double HEX_BLOCKS = 32.0D; + private static final double CELL = 64.0D; + private static final long WORLD_SEED = 9137L; + private static final long IRIS_SEED = 9137L; + private static final double TIE_TOLERANCE = 1.0e-4D; + private static final double CENTRE_TOLERANCE = 1.0e-3D; + private static RegionStats vanillaCells; + private static RegionStats irisCells; + + @Test + public void cellularFlatAreaWithinHalfPercent() { + assertEquals("Flat area percent", irisCells().flatPercent(), vanillaCells().flatPercent(), 0.5D); + } + + @Test + public void cellCountWithin2Percent() { + double iris = irisCells().regions(); + assertEquals("Cell count", iris, vanillaCells().regions(), 0.02D * iris); + } + + @Test + public void neighbourDegreeWithin0_05() { + assertEquals("Mean neighbour degree", irisCells().meanDegree(), vanillaCells().meanDegree(), 0.05D); + } + + @Test + public void vascularDistributionW1Within2Percent() { + double distance = styleDistance(NoiseStyle.VASCULAR); + assertTrue("VASCULAR W1 is " + distance + " of the Iris deviation", distance <= 0.02D); + } + + @Test + public void globDistributionW1Within2Percent() { + double distance = styleDistance(NoiseStyle.GLOB); + assertTrue("GLOB W1 is " + distance + " of the Iris deviation", distance <= 0.02D); + } + + @Test + public void hexCentresMatchExceptTies() { + NoiseRegistry registry = new NoiseRegistry(NAMESPACE); + HexLattice.Centers centers = HexLattice.centers("fidelity/hex", HEX_BLOCKS, null, null); + Map noises = FieldWalk.noiseDefinitions(registry); + double rootThree = Math.sqrt(3.0D); + int mismatches = 0; + for (int z = 0; z < HEX_SIZE; z++) { + for (int x = 0; x < HEX_SIZE; x++) { + double hexX = x / HEX_BLOCKS; + double hexZ = z / HEX_BLOCKS; + double[] iris = irisCentre(hexX, hexZ, rootThree); + double vanillaX = sample(centers.x(), noises, x, z); + double vanillaZ = sample(centers.z(), noises, x, z); + boolean same = Math.abs(vanillaX - iris[0]) <= CENTRE_TOLERANCE && Math.abs(vanillaZ - iris[1]) <= CENTRE_TOLERANCE; + double vanillaDistance = squared(hexX - vanillaX) + squared(hexZ - vanillaZ); + double irisDistance = squared(hexX - iris[0]) + squared(hexZ - iris[1]); + if (!same && Math.abs(vanillaDistance - irisDistance) > TIE_TOLERANCE) { + mismatches++; + } + } + } + assertTrue("Hex centres differ on " + mismatches + " pixels", mismatches <= 0.0005D * HEX_SIZE * HEX_SIZE); + } + + private static synchronized RegionStats vanillaCells() { + if (vanillaCells == null) { + NoiseRegistry registry = new NoiseRegistry(NAMESPACE); + Field value = CellularLibrary.build("fidelity/cellular", CELL, null, null, CellularMode.GRID_3X3, registry).cellValue(); + Map noises = FieldWalk.noiseDefinitions(registry); + double[][] grid = new double[SIZE][SIZE]; + for (int z = 0; z < SIZE; z++) { + for (int x = 0; x < SIZE; x++) { + grid[z][x] = sample(value, noises, x, z); + } + } + vanillaCells = RegionStats.of(grid); + } + return vanillaCells; + } + + private static synchronized RegionStats irisCells() { + if (irisCells == null) { + CNG iris = NoiseStyle.CELLULAR.create(new RNG(IRIS_SEED)); + double[][] grid = new double[SIZE][SIZE]; + for (int z = 0; z < SIZE; z++) { + for (int x = 0; x < SIZE; x++) { + grid[z][x] = iris.noise(x, z); + } + } + irisCells = RegionStats.of(grid); + } + return irisCells; + } + + private static double styleDistance(NoiseStyle style) { + int samplesPerAxis = SIZE / DISTRIBUTION_STRIDE; + int samples = samplesPerAxis * samplesPerAxis; + double[] vanillaValues = new double[samples * REALIZATIONS]; + double[] irisValues = new double[samples * REALIZATIONS]; + for (int realization = 0; realization < REALIZATIONS; realization++) { + NoiseRegistry registry = new NoiseRegistry(NAMESPACE); + DefaultCellularStyleLowering lowering = new DefaultCellularStyleLowering(new ArrayList<>(), (nested, salt, nestedRegistry, capabilities) -> { + throw new AssertionError("Unexpected nested style " + nested.getStyle()); + }, CellularMode.GRID_3X3); + Field field = lowering.lower(new IrisGeneratorStyle(style), realization + 1L, registry, ExportCapabilities.forTarget(DataVersion.V26_3)); + Map noises = FieldWalk.noiseDefinitions(registry); + CNG iris = style.create(new RNG(IRIS_SEED + realization)); + int offset = realization * samples; + for (int row = 0; row < samplesPerAxis; row++) { + for (int column = 0; column < samplesPerAxis; column++) { + int x = column * DISTRIBUTION_STRIDE; + int z = row * DISTRIBUTION_STRIDE; + vanillaValues[offset + row * samplesPerAxis + column] = sample(field, noises, x, z); + irisValues[offset + row * samplesPerAxis + column] = iris.noise(x, z); + } + } + } + return wasserstein(irisValues, vanillaValues) / deviation(irisValues); + } + + private static double[] irisCentre(double hexX, double hexZ, double rootThree) { + double q = rootThree / 3.0D * hexX - hexZ / 3.0D; + double r = 2.0D / 3.0D * hexZ; + double cube = -q - r; + long roundedQ = Math.round(q); + long roundedR = Math.round(r); + long roundedCube = Math.round(cube); + double qDifference = Math.abs(roundedQ - q); + double rDifference = Math.abs(roundedR - r); + double cubeDifference = Math.abs(roundedCube - cube); + boolean fixQ = qDifference > cubeDifference && qDifference > rDifference; + long centerQ = fixQ ? -roundedCube - roundedR : roundedQ; + long centerR = !fixQ && cubeDifference <= rDifference ? -roundedQ - roundedCube : roundedR; + return new double[]{rootThree * (centerQ + centerR * 0.5D), 1.5D * centerR}; + } + + private static double sample(Field field, Map noises, int x, int z) { + return FieldEvaluator.sample(field, noises, WORLD_SEED, DataVersion.V26_3, x, 0, z); + } + + private static double wasserstein(double[] first, double[] second) { + double[] left = first.clone(); + double[] right = second.clone(); + Arrays.sort(left); + Arrays.sort(right); + double sum = 0.0D; + for (int index = 0; index < left.length; index++) { + sum += Math.abs(left[index] - right[index]); + } + return sum / left.length; + } + + private static double deviation(double[] values) { + double mean = Arrays.stream(values).average().orElse(0.0D); + double squares = 0.0D; + for (double value : values) { + squares += squared(value - mean); + } + return Math.sqrt(squares / values.length); + } + + private static double squared(double value) { + return value * value; + } + + private record RegionStats(int regions, double meanDegree, double flatPercent) { + private static RegionStats of(double[][] grid) { + int size = grid.length; + int[][] labels = new int[size][size]; + int count = 0; + int[] queue = new int[size * size]; + for (int z = 0; z < size; z++) { + for (int x = 0; x < size; x++) { + if (labels[z][x] == 0) { + count++; + flood(grid, labels, queue, x, z, count); + } + } + } + List> neighbours = new ArrayList<>(count + 1); + for (int index = 0; index <= count; index++) { + neighbours.add(new HashSet<>()); + } + boolean[] border = new boolean[count + 1]; + long flat = 0L; + for (int z = 0; z < size; z++) { + for (int x = 0; x < size; x++) { + int label = labels[z][x]; + if (x == 0 || z == 0 || x == size - 1 || z == size - 1) { + border[label] = true; + } + if (x + 1 < size && labels[z][x + 1] != label) { + neighbours.get(label).add(labels[z][x + 1]); + neighbours.get(labels[z][x + 1]).add(label); + } + if (z + 1 < size && labels[z + 1][x] != label) { + neighbours.get(label).add(labels[z + 1][x]); + neighbours.get(labels[z + 1][x]).add(label); + } + if (x + 1 < size && z + 1 < size && grid[z][x] == grid[z][x + 1] && grid[z][x] == grid[z + 1][x]) { + flat++; + } + } + } + long degree = 0L; + int interior = 0; + for (int label = 1; label <= count; label++) { + if (!border[label]) { + interior++; + degree += neighbours.get(label).size(); + } + } + double flatPercent = 100.0D * flat / ((double) (size - 1) * (size - 1)); + return new RegionStats(count, (double) degree / interior, flatPercent); + } + + private static void flood(double[][] grid, int[][] labels, int[] queue, int startX, int startZ, int label) { + int size = grid.length; + int head = 0; + int tail = 0; + labels[startZ][startX] = label; + queue[tail++] = startZ * size + startX; + while (head < tail) { + int cell = queue[head++]; + int x = cell % size; + int z = cell / size; + double value = grid[z][x]; + int[] neighbourX = {x + 1, x - 1, x, x}; + int[] neighbourZ = {z, z, z + 1, z - 1}; + for (int index = 0; index < 4; index++) { + int nextX = neighbourX[index]; + int nextZ = neighbourZ[index]; + if (nextX >= 0 && nextZ >= 0 && nextX < size && nextZ < size && labels[nextZ][nextX] == 0 && grid[nextZ][nextX] == value) { + labels[nextZ][nextX] = label; + queue[tail++] = nextZ * size + nextX; + } + } + } + } + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/noise/CellularLibraryTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/noise/CellularLibraryTest.java new file mode 100644 index 000000000..5a87da12a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/noise/CellularLibraryTest.java @@ -0,0 +1,259 @@ +package art.arcane.iris.pack.datapack.export.compile.noise; + +import art.arcane.iris.pack.datapack.export.ir.Axis; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.FieldValidator; +import art.arcane.iris.pack.datapack.export.ir.NoiseDef; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class CellularLibraryTest { + private static final String NAMESPACE = "iris_export"; + private static final String INSTANCE = "test/cells"; + private static final double CELL = 64.0D; + private static final int LATTICE_LIMIT = 798_000; + + @Test + public void neighbourCellsShareHash() { + NoiseRegistry registry = new NoiseRegistry(NAMESPACE); + CellularOutputs outputs = CellularLibrary.build(INSTANCE, CELL, null, null, CellularMode.GRID_3X3, registry); + Map bodies = FieldWalk.sharedBodies(roots(outputs)); + Field cellX = bodies.get("cell/" + INSTANCE + "/ik"); + Field cellZ = bodies.get("cell/" + INSTANCE + "/jk"); + List hashes = hashSamples(outputs, registry); + assertEquals(9, hashes.size()); + for (Field.Noise hash : hashes) { + assertEquals(0.0D, hash.xzScale(), 0.0D); + assertEquals(0.0D, hash.yScale(), 0.0D); + assertTrue(hash.shiftY() instanceof Field.Const); + assertLatticeShift(hash.shiftX(), cellX); + assertLatticeShift(hash.shiftZ(), cellZ); + } + float spacing = (float) CellHashNoise.LATTICE_SPACING; + assertEquals(CellHashNoise.LATTICE_SPACING, spacing, 0.0D); + assertEquals(0.0D, CellHashNoise.LATTICE_SPACING * 8.0D % 1.0D, 0.0D); + for (int cell = -LATTICE_LIMIT; cell <= LATTICE_LIMIT; cell += 997) { + assertNeighbourHashInputs(cell, spacing); + } + for (int cell = -4096; cell <= 4096; cell++) { + assertNeighbourHashInputs(cell, spacing); + } + } + + @Test + public void quadrantNeighbourShiftsAreDyadic() { + NoiseRegistry registry = new NoiseRegistry(NAMESPACE); + CellularOutputs outputs = CellularLibrary.build(INSTANCE, CELL, null, null, CellularMode.QUADRANT_2X2, registry); + Map bodies = FieldWalk.sharedBodies(roots(outputs)); + Field.Binary shift = (Field.Binary) bodies.get("cell/" + INSTANCE + "/quadrant/ik_s"); + assertEquals(Field.BinaryOp.ADD, shift.op()); + Field.Binary step = (Field.Binary) shift.right(); + assertEquals(Field.BinaryOp.MUL, step.op()); + assertEquals(new Field.Const(CellHashNoise.LATTICE_SPACING), step.right()); + assertEquals(4, hashSamples(outputs, registry).size()); + } + + @Test + public void everyRootIsTwoDimensionalExceptVolume() { + for (CellularMode mode : CellularMode.values()) { + NoiseRegistry registry = new NoiseRegistry(NAMESPACE); + CellularOutputs outputs = CellularLibrary.build(INSTANCE, CELL, null, null, mode, registry); + for (Field root : roots(outputs)) { + if (mode == CellularMode.GRID_3X3X3) { + assertTrue(mode + " output should depend on Y", root.axes().contains(Axis.Y)); + } else { + FieldValidator.requireTwoDimensional(root, mode.name()); + assertTrue(root.axes().contains(Axis.X)); + assertTrue(root.axes().contains(Axis.Z)); + } + } + } + } + + @Test + public void volumeOutputOnlyInsideGrid() { + NoiseRegistry registry = new NoiseRegistry(NAMESPACE); + CellularOutputs outputs = CellularLibrary.build(INSTANCE, CELL, null, null, CellularMode.GRID_3X3X3, registry); + for (Field root : roots(outputs)) { + Field.Grid grid = (Field.Grid) root; + assertEquals(CellularLibrary.VOLUME_CELL_XZ, grid.cellXZ()); + assertEquals(CellularLibrary.VOLUME_CELL_Y, grid.cellY()); + } + assertEquals(27, hashSamples(outputs, registry).size()); + for (Field.Noise hash : hashSamples(outputs, registry)) { + assertTrue(hash.axes().contains(Axis.Y)); + } + for (CellularMode mode : List.of(CellularMode.GRID_3X3, CellularMode.QUADRANT_2X2)) { + CellularOutputs planar = CellularLibrary.build(INSTANCE, CELL, null, null, mode, registry); + for (Field root : roots(planar)) { + assertTrue(FieldWalk.nodesOf(root, Field.Grid.class).isEmpty()); + } + } + } + + @Test + public void tournamentPlaysOneMatchPerEliminatedCandidate() { + assertEquals(8, matches(CellularMode.GRID_3X3)); + assertEquals(3, matches(CellularMode.QUADRANT_2X2)); + assertEquals(26, matches(CellularMode.GRID_3X3X3)); + } + + @Test + public void constantsStayWithinLimit() { + for (CellularMode mode : CellularMode.values()) { + for (double cell : new double[]{0.5D, 64.0D, 137.0D, 4096.0D}) { + NoiseRegistry registry = new NoiseRegistry(NAMESPACE); + CellularOutputs outputs = CellularLibrary.build(INSTANCE, cell, null, null, mode, registry); + for (Field root : roots(outputs)) { + FieldWalk.assertConstantsWithinLimit(root); + } + } + } + } + + @Test + public void everyNoiseIsRegistered() { + for (CellularMode mode : CellularMode.values()) { + NoiseRegistry registry = new NoiseRegistry(NAMESPACE); + CellularOutputs outputs = CellularLibrary.build(INSTANCE, CELL, null, null, mode, registry); + assertEquals(1, registry.definitions().size()); + assertEquals(CellHashNoise.NOISE_ID, registry.definitions().getFirst().id()); + for (Field root : roots(outputs)) { + FieldWalk.assertNoisesRegistered(root, registry); + } + } + } + + @Test + public void hashSamplesAndOutputsAreShared() { + for (CellularMode mode : CellularMode.values()) { + NoiseRegistry registry = new NoiseRegistry(NAMESPACE); + CellularOutputs outputs = CellularLibrary.build(INSTANCE, CELL, null, null, mode, registry); + Set sharedBodies = Collections.newSetFromMap(new IdentityHashMap<>()); + for (Field root : roots(outputs)) { + Field output = root instanceof Field.Grid grid ? grid.expr() : root; + assertTrue(mode + " output is not shared", output instanceof Field.Shared); + for (Field.Shared shared : FieldWalk.nodesOf(root, Field.Shared.class)) { + sharedBodies.add(shared.expr()); + } + } + for (Field.Noise hash : hashSamples(outputs, registry)) { + assertTrue(sharedBodies.contains(hash)); + } + } + } + + @Test + public void identifiersAreDeterministic() { + for (CellularMode mode : CellularMode.values()) { + CellularOutputs first = CellularLibrary.build(INSTANCE, CELL, null, null, mode, new NoiseRegistry(NAMESPACE)); + CellularOutputs second = CellularLibrary.build(INSTANCE, CELL, null, null, mode, new NoiseRegistry(NAMESPACE)); + assertEquals(first, second); + assertEquals(FieldWalk.sharedBodies(roots(first)).keySet(), FieldWalk.sharedBodies(roots(second)).keySet()); + CellularOutputs other = CellularLibrary.build("test/other", CELL, null, null, mode, new NoiseRegistry(NAMESPACE)); + Set overlap = new HashSet<>(FieldWalk.sharedBodies(roots(first)).keySet()); + overlap.retainAll(FieldWalk.sharedBodies(roots(other)).keySet()); + for (String name : overlap) { + assertTrue("Instance names overlap on " + name, name.startsWith("cell/coord/")); + } + } + assertEquals(CellHashNoise.plane(INSTANCE), CellHashNoise.plane(INSTANCE), 0.0D); + assertNotEquals(CellHashNoise.plane(INSTANCE), CellHashNoise.plane("test/other"), 0.0D); + } + + @Test + public void modesOfOneInstanceAgreeOnSharedBodies() { + NoiseRegistry registry = new NoiseRegistry(NAMESPACE); + List roots = new ArrayList<>(); + for (CellularMode mode : CellularMode.values()) { + roots.addAll(roots(CellularLibrary.build(INSTANCE, CELL, null, null, mode, registry))); + } + assertFalse(FieldWalk.sharedBodies(roots).isEmpty()); + } + + @Test + public void cellSizeAndWarpScaleTheCellCoordinate() { + NoiseRegistry registry = new NoiseRegistry(NAMESPACE); + Field warp = new Field.Noise(registry.register(new NoiseDef("test/warp", -5, List.of(1.0D))), 1.0D, 0.0D, null, null, null); + CellularOutputs outputs = CellularLibrary.build(INSTANCE, 128.0D, warp, null, CellularMode.GRID_3X3, registry); + Map bodies = FieldWalk.sharedBodies(roots(outputs)); + Field.Binary xs = (Field.Binary) bodies.get("cell/" + INSTANCE + "/xs"); + assertEquals(Field.BinaryOp.ADD, xs.op()); + Field.Binary scaled = (Field.Binary) xs.left(); + assertEquals(new Field.Const(0.5D), scaled.right()); + Field.Binary warped = (Field.Binary) xs.right(); + assertEquals(warp, warped.left()); + assertEquals(new Field.Const(1.0D / 128.0D), warped.right()); + Field.Binary zs = (Field.Binary) bodies.get("cell/" + INSTANCE + "/zs"); + assertEquals(Field.BinaryOp.MUL, zs.op()); + } + + @Test + public void invalidInputsAreRejected() { + NoiseRegistry registry = new NoiseRegistry(NAMESPACE); + assertThrows(IllegalArgumentException.class, () -> CellularLibrary.build("Bad Id", CELL, null, null, CellularMode.GRID_3X3, registry)); + assertThrows(IllegalArgumentException.class, () -> CellularLibrary.build(INSTANCE, 0.0D, null, null, CellularMode.GRID_3X3, registry)); + assertThrows(IllegalArgumentException.class, () -> CellularLibrary.build(INSTANCE, Double.NaN, null, null, CellularMode.GRID_3X3, registry)); + } + + private static void assertNeighbourHashInputs(int cell, float spacing) { + float base = (float) cell * spacing; + assertEquals(Float.floatToIntBits((float) (cell + 1) * spacing), Float.floatToIntBits(base + spacing)); + assertEquals(Float.floatToIntBits((float) (cell - 1) * spacing), Float.floatToIntBits(base + -spacing)); + } + + private static void assertLatticeShift(Field shift, Field lattice) { + Field.Shared shared = (Field.Shared) shift; + if (shared.expr().equals(lattice)) { + return; + } + Field.Binary neighbour = (Field.Binary) shared.expr(); + assertEquals(Field.BinaryOp.ADD, neighbour.op()); + assertEquals(lattice, ((Field.Shared) neighbour.left()).expr()); + double offset = ((Field.Const) neighbour.right()).value(); + assertEquals(CellHashNoise.LATTICE_SPACING, Math.abs(offset), 0.0D); + } + + private static int matches(CellularMode mode) { + NoiseRegistry registry = new NoiseRegistry(NAMESPACE); + CellularOutputs outputs = CellularLibrary.build(INSTANCE, CELL, null, null, mode, registry); + int count = 0; + for (String name : FieldWalk.sharedBodies(roots(outputs)).keySet()) { + if (name.endsWith("_diff")) { + count++; + } + } + return count; + } + + private static List hashSamples(CellularOutputs outputs, NoiseRegistry registry) { + String hashId = registry.namespace() + ":" + CellHashNoise.NOISE_ID; + List hashes = new ArrayList<>(); + Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + for (Field root : roots(outputs)) { + for (Field.Noise noise : FieldWalk.nodesOf(root, Field.Noise.class)) { + if (noise.noiseId().equals(hashId) && seen.add(noise)) { + hashes.add(noise); + } + } + } + return hashes; + } + + static List roots(CellularOutputs outputs) { + return List.of(outputs.cellValue(), outputs.f1(), outputs.f2(), outputs.winnerX(), outputs.winnerZ()); + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/noise/DefaultCellularStyleLoweringTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/noise/DefaultCellularStyleLoweringTest.java new file mode 100644 index 000000000..5a8c3a59c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/noise/DefaultCellularStyleLoweringTest.java @@ -0,0 +1,305 @@ +package art.arcane.iris.pack.datapack.export.compile.noise; + +import art.arcane.iris.generation.noise.IrisGenerator; +import art.arcane.iris.generation.noise.IrisGeneratorStyle; +import art.arcane.iris.generation.noise.NoiseStyle; +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ir.Axis; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.FieldEvaluator; +import art.arcane.iris.pack.datapack.export.ir.NoiseDef; +import art.arcane.iris.pack.datapack.export.lower.ExportCapabilities; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class DefaultCellularStyleLoweringTest { + private static final String NAMESPACE = "iris_export"; + private static final long QUADRANT_SEED = 1337L; + private static final ExportCapabilities MODERN = ExportCapabilities.forTarget(DataVersion.V26_3); + private static final CellularStyleLowering NO_NESTED_STYLES = (style, salt, registry, capabilities) -> { + throw new AssertionError("Unexpected nested style " + style.getStyle()); + }; + + @Test + public void cellularUsesLibraryOn26_3() { + NoiseRegistry registry = new NoiseRegistry(NAMESPACE); + Field field = lowering(new ArrayList<>()).lower(new IrisGeneratorStyle(NoiseStyle.CELLULAR), 7L, registry, MODERN); + assertFalse(FieldWalk.nodesOf(field, Field.Coord.class).isEmpty()); + assertEquals(9, noisesNamed(field, CellHashNoise.NOISE_ID).size()); + assertTrue(FieldWalk.nodesOf(field, Field.Partition.class).isEmpty()); + } + + @Test + public void quadrantModeLowersCellValuesFromFourCandidatesUnderTheirOwnNames() { + NoiseRegistry registry = new NoiseRegistry(NAMESPACE); + IrisGeneratorStyle style = new IrisGeneratorStyle(NoiseStyle.CELLULAR_IRIS_DOUBLE); + Field grid = lowering(new ArrayList<>()).lower(style, 7L, registry, MODERN); + Field quadrant = new DefaultCellularStyleLowering(new ArrayList<>(), NO_NESTED_STYLES, CellularMode.QUADRANT_2X2).lower(style, 7L, registry, MODERN); + + assertEquals(9, noisesNamed(grid, CellHashNoise.NOISE_ID).size()); + assertEquals(4, noisesNamed(quadrant, CellHashNoise.NOISE_ID).size()); + FieldWalk.assertTwoDimensional(quadrant); + Map gridBodies = new HashMap<>(); + for (Field.Shared shared : FieldWalk.nodesOf(grid, Field.Shared.class)) { + gridBodies.put(shared.name(), shared.expr()); + } + for (Field.Shared shared : FieldWalk.nodesOf(quadrant, Field.Shared.class)) { + Field gridBody = gridBodies.get(shared.name()); + assertTrue(shared.name(), gridBody == null || gridBody.equals(shared.expr())); + } + } + + @Test + public void quadrantCellValuesDrawTheGridCells() { + NoiseRegistry registry = new NoiseRegistry(NAMESPACE); + IrisGeneratorStyle style = new IrisGeneratorStyle(NoiseStyle.CELLULAR_IRIS_DOUBLE).setZoom(0.5D); + Field grid = lowering(new ArrayList<>()).lower(style, 7L, registry, MODERN); + Field quadrant = new DefaultCellularStyleLowering(new ArrayList<>(), NO_NESTED_STYLES, CellularMode.QUADRANT_2X2).lower(style, 7L, registry, MODERN); + Map noises = FieldWalk.noiseDefinitions(registry); + + int points = 0; + int agreeing = 0; + for (int x = -300; x < 300; x += 13) { + for (int z = -300; z < 300; z += 17) { + points++; + double expected = FieldEvaluator.sample(grid, noises, QUADRANT_SEED, DataVersion.V26_3, x, 0, z); + double actual = FieldEvaluator.sample(quadrant, noises, QUADRANT_SEED, DataVersion.V26_3, x, 0, z); + if (Math.abs(expected - actual) < 1.0e-6D) { + agreeing++; + } + } + } + + assertTrue(agreeing + " of " + points, agreeing >= 0.9D * points); + } + + @Test + public void volumeModeIsRejectedForPlanarCellValues() { + assertThrows(IllegalArgumentException.class, () -> new DefaultCellularStyleLowering(new ArrayList<>(), NO_NESTED_STYLES, CellularMode.GRID_3X3X3)); + } + + @Test + public void exoticStyleFallsBackWithDiagnostic() { + ExportCapabilities capabilities = MODERN; + List diagnostics = new ArrayList<>(); + DefaultCellularStyleLowering lowering = lowering(diagnostics); + NoiseRegistry registry = new NoiseRegistry(NAMESPACE); + Field first = lowering.lower(new IrisGeneratorStyle(NoiseStyle.GYROID), 3L, registry, capabilities); + lowering.lower(new IrisGeneratorStyle(NoiseStyle.GYROID), 4L, registry, capabilities); + assertEquals(1, diagnostics.size()); + ExportDiagnostic diagnostic = diagnostics.getFirst(); + assertEquals(ExportLimit.NOISE_STYLE, diagnostic.limit()); + assertEquals(ExportDiagnostic.Severity.WARNING, diagnostic.severity()); + assertEquals("noise style GYROID", diagnostic.resource()); + FieldWalk.assertTwoDimensional(first); + assertEquals(1, noisesNamed(first, LeafNoise.NOISE_ID).size()); + assertTrue(FieldWalk.nodesOf(first, Field.Coord.class).isEmpty()); + } + + @Test + public void everySupportedStyleLowers() { + ExportCapabilities capabilities = MODERN; + NoiseRegistry registry = new NoiseRegistry(NAMESPACE); + DefaultCellularStyleLowering lowering = lowering(new ArrayList<>()); + List roots = new ArrayList<>(); + for (NoiseStyle style : NoiseStyle.values()) { + if (!DefaultCellularStyleLowering.supports(style)) { + continue; + } + Field field = lowering.lower(new IrisGeneratorStyle(style), style.ordinal(), registry, capabilities); + FieldWalk.assertTwoDimensional(field); + FieldWalk.assertConstantsWithinLimit(field); + FieldWalk.assertNoisesRegistered(field, registry); + roots.add(field); + } + FieldWalk.sharedBodies(roots); + } + + @Test + public void smoothStylesAreNotCellular() { + assertFalse(DefaultCellularStyleLowering.supports(NoiseStyle.SIMPLEX)); + assertFalse(DefaultCellularStyleLowering.supports(NoiseStyle.FLAT)); + assertFalse(DefaultCellularStyleLowering.supports(NoiseStyle.CLOVER)); + assertTrue(DefaultCellularStyleLowering.supports(NoiseStyle.CELLULAR_IRIS_DOUBLE)); + assertTrue(DefaultCellularStyleLowering.supports(NoiseStyle.STATIC)); + assertTrue(DefaultCellularStyleLowering.supports(NoiseStyle.HEXAGON)); + NoiseRegistry registry = new NoiseRegistry(NAMESPACE); + assertThrows(IllegalArgumentException.class, () -> lowering(new ArrayList<>()).lower(new IrisGeneratorStyle(NoiseStyle.SIMPLEX), 1L, registry, MODERN)); + IrisGeneratorStyle expression = new IrisGeneratorStyle(NoiseStyle.CELLULAR).setExpression("example"); + assertThrows(IllegalArgumentException.class, () -> lowering(new ArrayList<>()).lower(expression, 1L, registry, MODERN)); + } + + @Test + public void smoothStyleUnderCellularFractureDropsTheCellularParts() { + List nestedStyles = new ArrayList<>(); + Field marker = new Field.Const(0.25D); + CellularStyleLowering nested = (style, salt, registry, capabilities) -> { + nestedStyles.add(style); + return marker; + }; + List diagnostics = new ArrayList<>(); + IrisGeneratorStyle jitter = new IrisGeneratorStyle(NoiseStyle.STATIC).setMultiplier(1.0D); + IrisGeneratorStyle smooth = new IrisGeneratorStyle(NoiseStyle.FRACTAL_WATER).setZoom(0.15D).setMultiplier(9.0D).setFracture(jitter); + smooth.setCellularFrequency(2.0D); + + Field field = new DefaultCellularStyleLowering(diagnostics, nested, CellularMode.GRID_3X3).lower(smooth, 5L, new NoiseRegistry(NAMESPACE), MODERN); + + assertSame(marker, field); + assertEquals(1, nestedStyles.size()); + assertSame(NoiseStyle.FRACTAL_WATER, nestedStyles.getFirst().getStyle()); + assertEquals(0.15D, nestedStyles.getFirst().getZoom(), 0.0D); + assertNull(nestedStyles.getFirst().getFracture()); + assertEquals(0.0D, nestedStyles.getFirst().getCellularFrequency(), 0.0D); + assertEquals(1, diagnostics.size()); + assertSame(ExportLimit.NOISE_STYLE, diagnostics.getFirst().limit()); + } + + @Test + public void signatureVariantsCarryTheirWarp() { + NoiseRegistry registry = new NoiseRegistry(NAMESPACE); + DefaultCellularStyleLowering lowering = lowering(new ArrayList<>()); + assertEquals(0, noisesNamed(lowering.lower(new IrisGeneratorStyle(NoiseStyle.CELLULAR), 1L, registry, MODERN), LeafNoise.NOISE_ID).size()); + assertEquals(6, noisesNamed(lowering.lower(new IrisGeneratorStyle(NoiseStyle.CELLULAR_IRIS), 1L, registry, MODERN), LeafNoise.NOISE_ID).size()); + assertEquals(8, noisesNamed(lowering.lower(new IrisGeneratorStyle(NoiseStyle.CELLULAR_IRIS_DOUBLE), 1L, registry, MODERN), LeafNoise.NOISE_ID).size()); + assertEquals(2, noisesNamed(lowering.lower(new IrisGeneratorStyle(NoiseStyle.NOWHERE_CELLULAR), 1L, registry, MODERN), LeafNoise.NOISE_ID).size()); + assertEquals(6, noisesNamed(lowering.lower(new IrisGeneratorStyle(NoiseStyle.GLOB_IRIS_THICK), 1L, registry, MODERN), LeafNoise.NOISE_ID).size()); + } + + @Test + public void styleFractureReplacesSignatureWarp() { + AtomicInteger calls = new AtomicInteger(); + NoiseRegistry registry = new NoiseRegistry(NAMESPACE); + String fractureNoise = registry.register(new NoiseDef("test/fracture", -6, List.of(1.0D))); + CellularStyleLowering nested = (style, salt, nestedRegistry, capabilities) -> { + calls.incrementAndGet(); + assertEquals(6.0D, style.getZoom(), 1.0e-12D); + return new Field.Noise(fractureNoise, 1.0D, 0.0D, null, null, null); + }; + IrisGeneratorStyle fracture = new IrisGeneratorStyle(NoiseStyle.SIMPLEX).setZoom(3.0D).setMultiplier(9.0D); + IrisGeneratorStyle style = new IrisGeneratorStyle(NoiseStyle.CELLULAR_IRIS_DOUBLE).setZoom(2.0D).setFracture(fracture); + Field field = new DefaultCellularStyleLowering(new ArrayList<>(), nested, CellularMode.GRID_3X3).lower(style, 5L, registry, MODERN); + assertEquals(2, calls.get()); + assertEquals(0, noisesNamed(field, LeafNoise.NOISE_ID).size()); + assertEquals(2, noisesNamed(field, "test/fracture").size()); + FieldWalk.assertTwoDimensional(field); + } + + @Test + public void zoomScalesCellSize() { + NoiseRegistry registry = new NoiseRegistry(NAMESPACE); + Field field = lowering(new ArrayList<>()).lower(new IrisGeneratorStyle(NoiseStyle.CELLULAR).setZoom(2.0D), 1L, registry, MODERN); + Field.Shared xs = sharedEndingWith(field, "/xs"); + Field.Binary scaled = (Field.Binary) xs.expr(); + assertEquals(Field.BinaryOp.MUL, scaled.op()); + assertEquals(new Field.Const(0.5D), scaled.right()); + } + + @Test + public void exponentAppliesPower() { + NoiseRegistry registry = new NoiseRegistry(NAMESPACE); + DefaultCellularStyleLowering lowering = lowering(new ArrayList<>()); + Field squared = lowering.lower(new IrisGeneratorStyle(NoiseStyle.CELLULAR).setExponent(2.0D), 1L, registry, MODERN); + Field.Binary power = (Field.Binary) ((Field.Shared) squared).expr(); + assertEquals(Field.BinaryOp.POW, power.op()); + assertTrue(power.left() instanceof Field.Clamp); + assertEquals(new Field.Const(2.0D), power.right()); + Field thin = lowering.lower(new IrisGeneratorStyle(NoiseStyle.VASCULAR_THIN), 1L, registry, MODERN); + Field.Binary thinPower = (Field.Binary) ((Field.Shared) thin).expr(); + assertEquals(new Field.Const(1.0D / 0.65D), thinPower.right()); + } + + @Test + public void loweringIsDeterministicPerSalt() { + DefaultCellularStyleLowering lowering = lowering(new ArrayList<>()); + Field first = lowering.lower(new IrisGeneratorStyle(NoiseStyle.SIMPLEX_VASCULAR), 11L, new NoiseRegistry(NAMESPACE), MODERN); + Field second = lowering.lower(new IrisGeneratorStyle(NoiseStyle.SIMPLEX_VASCULAR), 11L, new NoiseRegistry(NAMESPACE), MODERN); + Field other = lowering.lower(new IrisGeneratorStyle(NoiseStyle.SIMPLEX_VASCULAR), 12L, new NoiseRegistry(NAMESPACE), MODERN); + assertEquals(first, second); + assertEquals(FieldWalk.sharedBodies(List.of(first)).keySet(), FieldWalk.sharedBodies(List.of(second)).keySet()); + assertNotEquals(FieldWalk.sharedBodies(List.of(first)).keySet(), FieldWalk.sharedBodies(List.of(other)).keySet()); + } + + @Test + public void staticUsesBlockHash() { + ExportCapabilities capabilities = MODERN; + NoiseRegistry registry = new NoiseRegistry(NAMESPACE); + Field field = lowering(new ArrayList<>()).lower(new IrisGeneratorStyle(NoiseStyle.STATIC), 1L, registry, capabilities); + List hashes = noisesNamed(field, CellHashNoise.NOISE_ID); + assertEquals(1, hashes.size()); + assertEquals(CellHashNoise.LATTICE_SPACING, hashes.getFirst().xzScale(), 0.0D); + assertTrue(FieldWalk.nodesOf(field, Field.Grid.class).isEmpty()); + } + + @Test + public void bilinearCellularInterpolatesOnTheIrisLattice() { + NoiseRegistry registry = new NoiseRegistry(NAMESPACE); + Field field = lowering(new ArrayList<>()).lower(new IrisGeneratorStyle(NoiseStyle.CELLULAR_BILINEAR).setZoom(1.5D), 1L, registry, MODERN); + Field.Grid grid = (Field.Grid) ((Field.Shared) field).expr(); + assertEquals(48, grid.cellXZ()); + } + + @Test + public void hexagonUsesLattice() { + List diagnostics = new ArrayList<>(); + DefaultCellularStyleLowering lowering = lowering(diagnostics); + Field modern = lowering.lower(new IrisGeneratorStyle(NoiseStyle.HEXAGON), 1L, new NoiseRegistry(NAMESPACE), MODERN); + assertEquals(4, FieldWalk.nodesOf(modern, Field.Round.class).size()); + assertTrue(diagnostics.isEmpty()); + } + + @Test + public void cellCracksSelectRaisedCells() { + DefaultCellularStyleLowering lowering = lowering(new ArrayList<>()); + Field value = new Field.Const(0.25D); + IrisGenerator flat = new IrisGenerator(); + assertSame(value, lowering.cellCracks(flat, value, 1L, new NoiseRegistry(NAMESPACE), MODERN)); + IrisGenerator cracked = new IrisGenerator().setCellFractureHeight(3.0D).setCellPercentSize(0.6D); + ExportCapabilities capabilities = MODERN; + NoiseRegistry registry = new NoiseRegistry(NAMESPACE); + Field.RangeIf crack = (Field.RangeIf) lowering.cellCracks(cracked, value, 1L, registry, capabilities); + assertEquals(0.6D, crack.min(), 0.0D); + assertSame(value, crack.outOfRange()); + assertEquals(new Field.Binary(Field.BinaryOp.MUL, value, new Field.Const(3.0D)), crack.inRange()); + assertFalse(crack.axes().contains(Axis.Y)); + FieldWalk.assertConstantsWithinLimit(crack); + FieldWalk.assertNoisesRegistered(crack, registry); + } + + private static DefaultCellularStyleLowering lowering(List diagnostics) { + return new DefaultCellularStyleLowering(diagnostics, NO_NESTED_STYLES, CellularMode.GRID_3X3); + } + + private static List noisesNamed(Field root, String localId) { + List matches = new ArrayList<>(); + for (Field.Noise noise : FieldWalk.nodesOf(root, Field.Noise.class)) { + if (noise.noiseId().endsWith(":" + localId)) { + matches.add(noise); + } + } + return matches; + } + + private static Field.Shared sharedEndingWith(Field root, String suffix) { + for (Field.Shared shared : FieldWalk.nodesOf(root, Field.Shared.class)) { + if (shared.name().endsWith(suffix) && !shared.name().contains("/coord/")) { + return shared; + } + } + throw new AssertionError("No shared field ending with " + suffix); + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/noise/FieldWalk.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/noise/FieldWalk.java new file mode 100644 index 000000000..17642f10d --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/noise/FieldWalk.java @@ -0,0 +1,194 @@ +package art.arcane.iris.pack.datapack.export.compile.noise; + +import art.arcane.iris.pack.datapack.export.ir.Axis; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.NoiseDef; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Deque; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +final class FieldWalk { + private static final double LIMIT = 1.0e6D; + + private FieldWalk() { + } + + static List nodes(Field root) { + Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + List ordered = new ArrayList<>(); + Deque pending = new ArrayDeque<>(); + pending.push(root); + while (!pending.isEmpty()) { + Field node = pending.pop(); + if (!seen.add(node)) { + continue; + } + ordered.add(node); + for (Field child : children(node)) { + pending.push(child); + } + } + return ordered; + } + + static List children(Field field) { + List children = new ArrayList<>(); + switch (field) { + case Field.Const ignored -> { + } + case Field.Coord ignored -> { + } + case Field.Ref ignored -> { + } + case Field.Noise noise -> { + addIfPresent(children, noise.shiftX()); + addIfPresent(children, noise.shiftY()); + addIfPresent(children, noise.shiftZ()); + } + case Field.Binary binary -> { + children.add(binary.left()); + children.add(binary.right()); + } + case Field.Unary unary -> children.add(unary.input()); + case Field.Clamp clamp -> children.add(clamp.input()); + case Field.Lerp lerp -> { + children.add(lerp.alpha()); + children.add(lerp.first()); + children.add(lerp.second()); + } + case Field.Round round -> { + children.add(round.input()); + children.add(round.multiple()); + } + case Field.Curve curve -> { + children.add(curve.input()); + for (Field.Knot knot : curve.knots()) { + children.add(knot.value()); + } + } + case Field.Partition partition -> { + children.add(partition.input()); + children.addAll(partition.leaves()); + } + case Field.Mux mux -> { + children.add(mux.index()); + children.addAll(mux.cases()); + } + case Field.Gate gate -> { + children.add(gate.condition()); + children.add(gate.expr()); + } + case Field.RangeIf rangeIf -> { + children.add(rangeIf.input()); + children.add(rangeIf.inRange()); + children.add(rangeIf.outOfRange()); + } + case Field.Grid grid -> children.add(grid.expr()); + case Field.Shared shared -> children.add(shared.expr()); + case Field.Slice slice -> children.add(slice.expr()); + case Field.TopSurface topSurface -> children.add(topSurface.density()); + } + return children; + } + + static List nodesOf(Field root, Class type) { + List matches = new ArrayList<>(); + for (Field node : nodes(root)) { + if (type.isInstance(node)) { + matches.add(type.cast(node)); + } + } + return matches; + } + + static Map sharedBodies(List roots) { + Map bodies = new TreeMap<>(); + for (Field root : roots) { + for (Field.Shared shared : nodesOf(root, Field.Shared.class)) { + Field existing = bodies.putIfAbsent(shared.name(), shared.expr()); + if (existing != null && !existing.equals(shared.expr())) { + fail("Shared field '" + shared.name() + "' has two different bodies"); + } + } + } + return bodies; + } + + static void assertConstantsWithinLimit(Field root) { + for (Field node : nodes(root)) { + switch (node) { + case Field.Const constant -> assertWithin(constant.value(), "constant"); + case Field.Coord coord -> { + assertWithin(coord.fromValue(), "gradient from value"); + assertWithin(coord.toValue(), "gradient to value"); + } + case Field.Clamp clamp -> { + assertWithin(clamp.min(), "clamp minimum"); + assertWithin(clamp.max(), "clamp maximum"); + } + case Field.RangeIf rangeIf -> { + assertWithin(rangeIf.min(), "range minimum"); + assertWithin(rangeIf.max(), "range maximum"); + } + case Field.Curve curve -> { + for (Field.Knot knot : curve.knots()) { + assertWithin(knot.location(), "knot location"); + assertWithin(knot.derivative(), "knot derivative"); + } + } + case Field.Partition partition -> { + for (double threshold : partition.thresholds()) { + assertWithin(threshold, "partition threshold"); + } + } + default -> { + } + } + } + } + + static void assertNoisesRegistered(Field root, NoiseRegistry registry) { + Set registered = new HashSet<>(); + for (NoiseDef definition : registry.definitions()) { + registered.add(registry.namespace() + ":" + definition.id()); + } + for (Field.Noise noise : nodesOf(root, Field.Noise.class)) { + assertTrue("Noise '" + noise.noiseId() + "' is not registered", registered.contains(noise.noiseId())); + } + } + + static void assertTwoDimensional(Field root) { + assertTrue("Field depends on Y: " + root.axes(), !root.axes().contains(Axis.Y)); + } + + static Map noiseDefinitions(NoiseRegistry registry) { + Map definitions = new TreeMap<>(); + for (NoiseDef definition : registry.definitions()) { + definitions.put(registry.namespace() + ":" + definition.id(), definition); + } + assertEquals(registry.definitions().size(), definitions.size()); + return definitions; + } + + private static void addIfPresent(List children, Field child) { + if (child != null) { + children.add(child); + } + } + + private static void assertWithin(double value, String role) { + assertTrue(role + " " + value + " exceeds +-1e6", Double.isFinite(value) && Math.abs(value) <= LIMIT); + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/noise/HexLatticeTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/noise/HexLatticeTest.java new file mode 100644 index 000000000..f03c98d2b --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/noise/HexLatticeTest.java @@ -0,0 +1,64 @@ +package art.arcane.iris.pack.datapack.export.compile.noise; + +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.FieldValidator; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class HexLatticeTest { + private static final String NAMESPACE = "iris_export"; + + @Test + public void centersSnapToTwoRectangularLattices() { + HexLattice.Centers centers = HexLattice.centers("test/hex", 32.0D, null, null); + FieldValidator.requireTwoDimensional(centers.x(), "hex x"); + FieldValidator.requireTwoDimensional(centers.z(), "hex z"); + List rounds = FieldWalk.nodesOf(centers.x(), Field.Round.class); + assertEquals(4, rounds.size()); + int columns = 0; + int rows = 0; + for (Field.Round round : rounds) { + assertEquals(Field.RoundMode.ROUND, round.mode()); + if (round.multiple().equals(new Field.Const(HexLattice.COLUMN_SPACING))) { + columns++; + } else if (round.multiple().equals(new Field.Const(HexLattice.ROW_SPACING))) { + rows++; + } + } + assertEquals(2, columns); + assertEquals(2, rows); + Field.RangeIf pick = (Field.RangeIf) ((Field.Shared) centers.x()).expr(); + assertEquals(0.0D, pick.max(), 0.0D); + FieldWalk.assertConstantsWithinLimit(centers.x()); + FieldWalk.assertConstantsWithinLimit(centers.z()); + } + + @Test + public void valueSamplesRegisteredLeafAtCentre() { + NoiseRegistry registry = new NoiseRegistry(NAMESPACE); + Field value = HexLattice.centerValue("test/hex", 32.0D, null, null, registry); + FieldValidator.requireTwoDimensional(value, "hex value"); + FieldWalk.assertNoisesRegistered(value, registry); + FieldWalk.assertConstantsWithinLimit(value); + List noises = FieldWalk.nodesOf(value, Field.Noise.class); + assertEquals(1, noises.size()); + Field.Noise leaf = noises.getFirst(); + assertEquals(0.0D, leaf.xzScale(), 0.0D); + assertEquals(0.0D, leaf.yScale(), 0.0D); + assertTrue(leaf.shiftY() instanceof Field.Const); + HexLattice.Centers centers = HexLattice.centers("test/hex", 32.0D, null, null); + assertEquals(centers.x(), ((Field.Binary) leaf.shiftX()).left()); + assertEquals(centers.z(), ((Field.Binary) leaf.shiftZ()).left()); + } + + @Test + public void latticeIsDeterministic() { + NoiseRegistry registry = new NoiseRegistry(NAMESPACE); + assertEquals(HexLattice.centerValue("test/hex", 48.0D, null, null, registry), HexLattice.centerValue("test/hex", 48.0D, null, null, registry)); + FieldWalk.sharedBodies(List.of(HexLattice.centerValue("test/hex", 48.0D, null, null, registry), HexLattice.centers("test/hex", 48.0D, null, null).x())); + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/noise/IrisSampleFitTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/noise/IrisSampleFitTest.java new file mode 100644 index 000000000..6ebf3cf7a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/noise/IrisSampleFitTest.java @@ -0,0 +1,87 @@ +package art.arcane.iris.pack.datapack.export.compile.noise; + +import art.arcane.iris.generation.noise.NoiseStyle; +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.WorldgenExportTargets; +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.function.DoubleBinaryOperator; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class IrisSampleFitTest { + private static final int ROWS = 48; + private static final int LENGTH = 512; + private static final int LAGS = 96; + + @Test + public void fittedLeafMatchesIrisCorrelationLength() { + List irisSamplers = new ArrayList<>(); + for (long seed = 0L; seed < 4L; seed++) { + CNG simplex = NoiseStyle.SIMPLEX.create(new RNG(77L + seed)); + irisSamplers.add(simplex::noise); + } + double iris = correlationLength(irisSamplers); + for (DataVersion target : WorldgenExportTargets.SUPPORTED) { + IrisSampleFit.Profile vanilla = IrisSampleFit.vanilla(target); + double xzScale = LeafNoise.xzScale(vanilla, IrisSampleFit.style(NoiseStyle.SIMPLEX).correlationLength()); + List vanillaSamplers = new ArrayList<>(); + for (long seed = 0L; seed < 16L; seed++) { + VanillaNoiseModel model = VanillaNoiseModel.create("iris_export:fit_check", 0, List.of(1.0D), 5L + 101L * seed, target); + vanillaSamplers.add((x, z) -> model.sample(x * xzScale, 3.375D, z * xzScale)); + } + double fitted = correlationLength(vanillaSamplers); + assertEquals(target + " fitted correlation length", iris, fitted, 0.1D * iris); + } + } + + @Test + public void profilesAreDeterministic() { + IrisSampleFit.Profile first = IrisSampleFit.style(NoiseStyle.VASCULAR); + assertTrue(first.correlationLength() > 0.0D); + assertEquals(first.quantile(0.5D), IrisSampleFit.style(NoiseStyle.VASCULAR).quantile(0.5D), 0.0D); + double[] quantiles = first.quantiles(32); + for (int index = 1; index < quantiles.length; index++) { + assertTrue(quantiles[index] >= quantiles[index - 1]); + } + } + + private static double correlationLength(List samplers) { + double[][] rows = new double[ROWS][LENGTH + LAGS]; + double sum = 0.0D; + for (int row = 0; row < ROWS; row++) { + for (int column = 0; column < LENGTH + LAGS; column++) { + rows[row][column] = samplers.get(row % samplers.size()).applyAsDouble(row * 281.0D + column, row * 173.0D); + } + for (int column = 0; column < LENGTH; column++) { + sum += rows[row][column]; + } + } + double mean = sum / (ROWS * LENGTH); + double variance = covariance(rows, mean, 0); + double previous = 1.0D; + for (int lag = 1; lag <= LAGS; lag++) { + double correlation = covariance(rows, mean, lag) / variance; + if (correlation < 0.5D) { + return lag - 1 + (previous - 0.5D) / (previous - correlation); + } + previous = correlation; + } + return LAGS; + } + + private static double covariance(double[][] rows, double mean, int lag) { + double sum = 0.0D; + for (double[] row : rows) { + for (int column = 0; column < LENGTH; column++) { + sum += (row[column] - mean) * (row[column + lag] - mean); + } + } + return sum / (ROWS * LENGTH); + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/noise/NoiseRegistryTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/noise/NoiseRegistryTest.java new file mode 100644 index 000000000..0d33146b5 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/noise/NoiseRegistryTest.java @@ -0,0 +1,73 @@ +package art.arcane.iris.pack.datapack.export.compile.noise; + +import art.arcane.iris.pack.datapack.export.ir.NoiseDef; +import org.junit.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +public class NoiseRegistryTest { + @Test + public void registerReturnsNamespacedId() { + NoiseRegistry registry = new NoiseRegistry("iris_export"); + assertEquals("iris_export:terrain/base", registry.register(new NoiseDef("terrain/base", -7, List.of(1.0, 0.5)))); + assertEquals("iris_export", registry.namespace()); + } + + @Test + public void identicalDefinitionsShareOneEntry() { + NoiseRegistry registry = new NoiseRegistry("iris_export"); + String first = registry.register(new NoiseDef("terrain/base", -7, List.of(1.0, 0.5))); + String second = registry.register(new NoiseDef("terrain/base", -7, List.of(1.0, 0.5))); + assertEquals(first, second); + assertEquals(1, registry.definitions().size()); + } + + @Test + public void sameParametersUnderDifferentIdsStayIndependent() { + NoiseRegistry registry = new NoiseRegistry("iris_export"); + String first = registry.register(new NoiseDef("terrain/a", -7, List.of(1.0))); + String second = registry.register(new NoiseDef("terrain/b", -7, List.of(1.0))); + assertEquals("iris_export:terrain/a", first); + assertEquals("iris_export:terrain/b", second); + assertEquals(2, registry.definitions().size()); + } + + @Test + public void conflictingDefinitionForIdIsRejected() { + NoiseRegistry registry = new NoiseRegistry("iris_export"); + registry.register(new NoiseDef("terrain/base", -7, List.of(1.0))); + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> registry.register(new NoiseDef("terrain/base", -6, List.of(1.0)))); + assertEquals("Noise 'iris_export:terrain/base' is already registered with different parameters", error.getMessage()); + } + + @Test + public void definitionsAreSortedById() { + NoiseRegistry registry = new NoiseRegistry("iris_export"); + registry.register(new NoiseDef("zeta", 0, List.of(1.0))); + registry.register(new NoiseDef("alpha", 0, List.of(1.0))); + registry.register(new NoiseDef("cell_hash", 0, List.of(1.0))); + assertEquals(List.of("alpha", "cell_hash", "zeta"), registry.definitions().stream().map(NoiseDef::id).toList()); + } + + @Test + public void byReferenceKeysDefinitionsByNamespacedId() { + NoiseRegistry registry = new NoiseRegistry("iris_export"); + NoiseDef base = new NoiseDef("terrain/base", -7, List.of(1.0)); + NoiseDef detail = new NoiseDef("terrain/detail", -3, List.of(1.0, 0.5)); + registry.register(detail); + registry.register(base); + Map references = registry.byReference(); + assertEquals(List.of("iris_export:terrain/base", "iris_export:terrain/detail"), List.copyOf(references.keySet())); + assertEquals(base, references.get("iris_export:terrain/base")); + assertThrows(UnsupportedOperationException.class, () -> references.put("iris_export:other", base)); + } + + @Test + public void namespaceIsValidated() { + assertThrows(IllegalArgumentException.class, () -> new NoiseRegistry("Iris Export")); + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/noise/NoiseStyleCompilerTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/noise/NoiseStyleCompilerTest.java new file mode 100644 index 000000000..d01bab761 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/noise/NoiseStyleCompilerTest.java @@ -0,0 +1,296 @@ +package art.arcane.iris.pack.datapack.export.compile.noise; + +import art.arcane.iris.generation.noise.IrisGenerator; +import art.arcane.iris.generation.noise.IrisGeneratorStyle; +import art.arcane.iris.generation.noise.IrisNoiseGenerator; +import art.arcane.iris.generation.noise.IrisStyledRange; +import art.arcane.iris.generation.noise.NoiseStyle; +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.FieldEvaluator; +import art.arcane.iris.pack.datapack.export.ir.FieldValidator; +import art.arcane.iris.pack.datapack.export.lower.ExportCapabilities; +import art.arcane.volmlib.util.collection.KList; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public final class NoiseStyleCompilerTest { + private static final DataVersion TARGET = DataVersion.V26_3; + private static final ExportCapabilities CAPABILITIES = ExportCapabilities.forTarget(TARGET); + private static final long SEED = 1_234L; + private static final long SUPER_SEED = 98_765L; + private static final double FLOAT_TOLERANCE = 1.0e-6; + private static final CellularStyleLowering REJECTING = (style, salt, registry, capabilities) -> { + throw new AssertionError("Smooth style " + style.getStyle() + " reached the cellular seam"); + }; + private static final NoiseStyleCompiler SMOOTH = new NoiseStyleCompiler(REJECTING); + + @Test + public void cellularFamilyStylesGoThroughTheSeam() { + Field marker = new Field.Const(0.25); + List seen = new ArrayList<>(); + NoiseStyleCompiler compiler = new NoiseStyleCompiler((style, salt, registry, capabilities) -> { + seen.add(style.getStyle()); + return marker; + }); + NoiseRegistry registry = new NoiseRegistry("iris_export"); + List cellular = List.of(NoiseStyle.CELLULAR, NoiseStyle.CELLULAR_IRIS_DOUBLE, NoiseStyle.NOWHERE_CELLULAR, + NoiseStyle.SIMPLEX_CELLS, NoiseStyle.SIMPLEX_VASCULAR, NoiseStyle.STATIC, NoiseStyle.HEXAGON, NoiseStyle.VASCULAR_THIN, + NoiseStyle.GLOB_IRIS_THICK, NoiseStyle.CELLULAR_BILINEAR, NoiseStyle.CELLULAR_HEIGHT); + for (NoiseStyle style : cellular) { + assertSame(marker, compiler.compile(new IrisGeneratorStyle(style), 7L, registry, CAPABILITIES)); + } + assertEquals(cellular, seen); + assertTrue(registry.definitions().isEmpty()); + } + + @Test + public void createdCompilerLowersCellularWithItsOwnNestedStyles() { + List diagnostics = new ArrayList<>(); + NoiseRegistry registry = new NoiseRegistry("iris_export"); + IrisGeneratorStyle fracture = new IrisGeneratorStyle(NoiseStyle.SIMPLEX).setZoom(3.0).setMultiplier(9.0); + IrisGeneratorStyle style = new IrisGeneratorStyle(NoiseStyle.CELLULAR_IRIS_DOUBLE).setZoom(2.0).setFracture(fracture); + style.setCellularFrequency(2.0); + + Field field = NoiseStyleCompiler.create(diagnostics).compile(style, 7L, registry, ExportCapabilities.forTarget(DataVersion.V26_3)); + + FieldValidator.requireTwoDimensional(field, "cellular style"); + assertTrue(registry.definitions().toString(), registry.definitions().stream().anyMatch(definition -> definition.id().startsWith("style/"))); + assertTrue(diagnostics.toString(), diagnostics.stream().anyMatch(diagnostic -> diagnostic.limit() == ExportLimit.NOISE_STYLE)); + } + + @Test + public void selectorStylesLowerCellValuesInQuadrants() { + NoiseStyleCompiler compiler = NoiseStyleCompiler.create(new ArrayList<>()); + NoiseRegistry registry = new NoiseRegistry("iris_export"); + ExportCapabilities current = ExportCapabilities.forTarget(DataVersion.V26_3); + IrisGeneratorStyle style = new IrisGeneratorStyle(NoiseStyle.CELLULAR_IRIS_DOUBLE); + + Field terrain = compiler.compile(style, 7L, registry, current); + Field selector = compiler.selectors().compile(style, 7L, registry, current); + + assertEquals(9, hashNoises(terrain)); + assertEquals(4, hashNoises(selector)); + assertSame(compiler.selectors(), compiler.selectors().selectors()); + StyleCompiler plain = (ignored, salt, ignoredRegistry, capabilities) -> new Field.Const(0.0); + assertSame(plain, plain.selectors()); + assertSame(SMOOTH, SMOOTH.selectors()); + } + + @Test + public void cellularizedStylesGoThroughTheSeam() { + Field marker = new Field.Const(0.75); + NoiseStyleCompiler compiler = new NoiseStyleCompiler((style, salt, registry, capabilities) -> marker); + IrisGeneratorStyle style = new IrisGeneratorStyle(NoiseStyle.SIMPLEX); + style.setCellularFrequency(2.0); + assertSame(marker, compiler.compile(style, 7L, new NoiseRegistry("iris_export"), CAPABILITIES)); + } + + @Test + public void smoothStylesStayTwoDimensional() { + NoiseRegistry registry = new NoiseRegistry("iris_export"); + Field field = SMOOTH.compile(new IrisGeneratorStyle(NoiseStyle.NOWHERE), 7L, registry, CAPABILITIES); + FieldValidator.requireTwoDimensional(field, "nowhere"); + assertEquals(3, registry.definitions().size()); + } + + @Test + public void expressionStylesAreRejected() { + IrisGeneratorStyle expression = new IrisGeneratorStyle(NoiseStyle.SIMPLEX); + expression.setExpression("strata"); + NoiseRegistry registry = new NoiseRegistry("iris_export"); + assertThrows(IllegalArgumentException.class, () -> SMOOTH.compile(expression, 7L, registry, CAPABILITIES)); + } + + @Test + public void imageMapStylesCompileTheirBaseNoise() { + IrisGeneratorStyle image = new IrisGeneratorStyle(NoiseStyle.SIMPLEX); + image.setImageMap("heights"); + NoiseRegistry imageRegistry = new NoiseRegistry("iris_export"); + NoiseRegistry baseRegistry = new NoiseRegistry("iris_export"); + + Field imageField = SMOOTH.compile(image, 7L, imageRegistry, CAPABILITIES); + Field baseField = SMOOTH.compile(new IrisGeneratorStyle(NoiseStyle.SIMPLEX), 7L, baseRegistry, CAPABILITIES); + + assertEquals(baseField, imageField); + assertEquals(baseRegistry.definitions(), imageRegistry.definitions()); + } + + @Test + public void flatStyleIsConstant() { + NoiseRegistry registry = new NoiseRegistry("iris_export"); + Field field = SMOOTH.compile(new IrisGeneratorStyle(NoiseStyle.FLAT), 7L, registry, CAPABILITIES); + assertEquals(1.0, sample(field, registry, 31, -17), 0.0); + } + + @Test + public void layerExponentOffsetAndBezierMatchIris() { + IrisNoiseGenerator layer = flatLayer(0.8, -0.1); + layer.setExponent(1.7); + layer.setBezier(true); + assertLayerMatchesIris(layer, FLOAT_TOLERANCE); + } + + @Test + public void layerNegativeAndParametricMatchIris() { + IrisNoiseGenerator layer = flatLayer(0.6, 0.35); + layer.setNegative(true); + layer.setParametric(true); + assertLayerMatchesIris(layer, FLOAT_TOLERANCE); + } + + @Test + public void layerSignedExponentMatchesIris() { + IrisNoiseGenerator layer = flatLayer(-0.5, -0.4); + layer.setExponent(2.5); + assertLayerMatchesIris(layer, FLOAT_TOLERANCE); + } + + @Test + public void layerSineCentringMatchesIrisWithinSplineTolerance() { + IrisNoiseGenerator layer = flatLayer(0.3, 0.05); + layer.setSinCentered(true); + assertLayerMatchesIris(layer, 1.0e-4); + } + + @Test + public void disabledLayerIsItsOffset() { + IrisNoiseGenerator layer = flatLayer(0.5, 0.2); + layer.setEnabled(false); + assertLayerMatchesIris(layer, FLOAT_TOLERANCE); + } + + @Test + public void additiveCompositeMatchesIris() { + IrisGenerator generator = generator(false); + assertCompositeMatchesIris(generator); + } + + @Test + public void multiplicativeCompositeMatchesIris() { + IrisGenerator generator = generator(true); + assertCompositeMatchesIris(generator); + } + + @Test + public void zeroOpacityCompositeIsRejected() { + IrisGenerator generator = new IrisGenerator(); + generator.setComposite(new KList<>(List.of(flatLayer(0.0, 0.1)))); + assertThrows(IllegalArgumentException.class, () -> SMOOTH.compileComposite(generator, SEED, new NoiseRegistry("iris_export"), CAPABILITIES)); + } + + @Test + public void layerOffsetsTranslateTheSampledNoise() { + IrisNoiseGenerator plain = new IrisNoiseGenerator(); + plain.setStyle(new IrisGeneratorStyle(NoiseStyle.PERLIN)); + plain.setZoom(2.0); + IrisNoiseGenerator shifted = new IrisNoiseGenerator(); + shifted.setStyle(new IrisGeneratorStyle(NoiseStyle.PERLIN)); + shifted.setZoom(2.0); + shifted.setOffsetX(3.0); + shifted.setOffsetZ(-2.0); + NoiseRegistry registry = new NoiseRegistry("iris_export"); + Field plainField = SMOOTH.compileLayer(plain, SEED, registry, CAPABILITIES); + Field shiftedField = SMOOTH.compileLayer(shifted, SEED, registry, CAPABILITIES); + for (int x = -40; x <= 40; x += 13) { + for (int z = -40; z <= 40; z += 17) { + assertEquals(sample(plainField, registry, x + 6, z - 4), sample(shiftedField, registry, x, z), 1.0e-9); + } + } + } + + @Test + public void styledRangeLerpsBetweenItsBounds() { + NoiseRegistry registry = new NoiseRegistry("iris_export"); + IrisStyledRange flat = new IrisStyledRange(10.0, 20.0, new IrisGeneratorStyle(NoiseStyle.FLAT)); + assertEquals(15.0, sample(SMOOTH.compileRange(flat, SEED, registry, CAPABILITIES), registry, 0, 0), 0.0); + IrisStyledRange perlin = new IrisStyledRange(10.0, 20.0, new IrisGeneratorStyle(NoiseStyle.PERLIN)); + Field range = SMOOTH.compileRange(perlin, SEED, registry, CAPABILITIES); + Field unit = SMOOTH.compile(perlin.getStyle(), SEED, registry, CAPABILITIES); + for (int x = 0; x < 64; x += 9) { + assertEquals(10.0 + 10.0 * sample(unit, registry, x, 3 * x), sample(range, registry, x, 3 * x), 20.0 * FLOAT_TOLERANCE); + } + } + + @Test + public void rootWarpExistsOnlyForFracturedStyles() { + NoiseRegistry registry = new NoiseRegistry("iris_export"); + Optional warp = SMOOTH.compileRootWarp(new IrisGeneratorStyle(NoiseStyle.CELLULAR_IRIS), SEED, registry, CAPABILITIES); + assertTrue(warp.isPresent()); + FieldValidator.requireTwoDimensional(warp.get().x(), "warp x"); + FieldValidator.requireTwoDimensional(warp.get().z(), "warp z"); + assertTrue(SMOOTH.compileRootWarp(new IrisGeneratorStyle(NoiseStyle.PERLIN), SEED, registry, CAPABILITIES).isEmpty()); + } + + @Test + public void layerFractureUnderCellularStyleIsRejected() { + IrisNoiseGenerator layer = new IrisNoiseGenerator(); + layer.setStyle(new IrisGeneratorStyle(NoiseStyle.CELLULAR)); + layer.setFracture(new KList<>(List.of(flatLayer(1.0, 0.0)))); + NoiseStyleCompiler compiler = new NoiseStyleCompiler((style, salt, registry, capabilities) -> new Field.Const(0.5)); + assertThrows(IllegalArgumentException.class, () -> compiler.compileLayer(layer, SEED, new NoiseRegistry("iris_export"), CAPABILITIES)); + } + + private static void assertLayerMatchesIris(IrisNoiseGenerator layer, double tolerance) { + NoiseRegistry registry = new NoiseRegistry("iris_export"); + Field field = SMOOTH.compileLayer(layer, SEED, registry, CAPABILITIES); + for (int x = -20; x <= 20; x += 10) { + assertEquals(layer.getNoise(SUPER_SEED, x, -x, null), sample(field, registry, x, -x), tolerance); + } + } + + private static void assertCompositeMatchesIris(IrisGenerator generator) { + NoiseRegistry registry = new NoiseRegistry("iris_export"); + Field field = SMOOTH.compileComposite(generator, SEED, registry, CAPABILITIES); + for (int x = -30; x <= 30; x += 15) { + assertEquals(generator.getHeight(x, 2 * x, SUPER_SEED), sample(field, registry, x, 2 * x), FLOAT_TOLERANCE); + } + } + + private static IrisGenerator generator(boolean multiplicative) { + IrisNoiseGenerator first = flatLayer(0.5, 0.1); + first.setExponent(2.0); + IrisNoiseGenerator second = flatLayer(1.5, -0.3); + second.setNegative(true); + second.setOffsetY(0.25); + IrisGenerator generator = new IrisGenerator(); + generator.setComposite(new KList<>(List.of(first, second))); + generator.setOpacity(2.0); + generator.setZoom(1.5); + generator.setOffsetX(12.0); + generator.setMultiplicitive(multiplicative); + return generator; + } + + private static IrisNoiseGenerator flatLayer(double opacity, double offsetY) { + IrisNoiseGenerator layer = new IrisNoiseGenerator(); + layer.setStyle(new IrisGeneratorStyle(NoiseStyle.FLAT)); + layer.setOpacity(opacity); + layer.setOffsetY(offsetY); + return layer; + } + + private static double sample(Field field, NoiseRegistry registry, int x, int z) { + return FieldEvaluator.sample(field, registry.byReference(), SEED, TARGET, x, 0, z); + } + + private static int hashNoises(Field root) { + int count = 0; + for (Field.Noise noise : FieldWalk.nodesOf(root, Field.Noise.class)) { + if (noise.noiseId().endsWith(":" + CellHashNoise.NOISE_ID)) { + count++; + } + } + return count; + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/noise/SmoothNoiseFidelityTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/noise/SmoothNoiseFidelityTest.java new file mode 100644 index 000000000..e9e4f1dbd --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/noise/SmoothNoiseFidelityTest.java @@ -0,0 +1,444 @@ +package art.arcane.iris.pack.datapack.export.compile.noise; + +import art.arcane.iris.generation.noise.IrisGeneratorStyle; +import art.arcane.iris.generation.noise.IrisNoiseGenerator; +import art.arcane.iris.generation.noise.NoiseStyle; +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.FieldEvaluator; +import art.arcane.iris.pack.datapack.export.ir.FieldValidator; +import art.arcane.iris.pack.datapack.export.ir.NoiseDef; +import art.arcane.iris.pack.datapack.export.lower.ExportCapabilities; +import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.noise.CNG; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.Assume; +import org.junit.Test; + +import java.io.IOException; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.EnumSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.DoubleBinaryOperator; +import java.util.stream.Stream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public final class SmoothNoiseFidelityTest { + private static final int SIZE = 512; + private static final int STRIDE = 23; + private static final double PEAK_SMOOTHING = 0.2; + private static final long IRIS_SEED = 90_210L; + private static final long SALT = 77L; + private static final String NAMESPACE = "iris_export"; + private static final String PACK_PROPERTY = "iris.exportProbePack"; + private static final DataVersion TARGET = DataVersion.V26_3; + private static final ExportCapabilities CAPABILITIES = ExportCapabilities.forTarget(TARGET); + private static final int[][] WINDOWS = { + {0, 0}, {131_072, 0}, {0, -131_072}, {-262_144, 65_536}, + {65_536, 65_536}, {-65_536, -131_072}, {196_608, -65_536}, {-131_072, 196_608} + }; + private static final CellularStyleLowering CELLULAR_STUB = (style, salt, registry, capabilities) -> new Field.Const(0.5); + private static final NoiseStyleCompiler COMPILER = new NoiseStyleCompiler(CELLULAR_STUB); + + @Test + public void simplexHistogramWithinW1() { + IrisGeneratorStyle style = new IrisGeneratorStyle(NoiseStyle.SIMPLEX); + NoiseRegistry registry = new NoiseRegistry(NAMESPACE); + Field field = COMPILER.compile(style, SALT, registry, CAPABILITIES); + CNG iris = style.createNoCache(new RNG(IRIS_SEED), null); + double[] irisValues = grid((x, z) -> iris.noise(x, z), 0, 0, SIZE, STRIDE); + double[] lowered = new double[0]; + for (int window = 0; window < 4; window++) { + DoubleBinaryOperator sampler = lowered(field, registry, worldSeed(window)); + lowered = concat(lowered, grid(sampler, WINDOWS[window][0], WINDOWS[window][1], SIZE / 2, STRIDE)); + } + double distance = wasserstein(irisValues, lowered) / deviation(irisValues); + assertTrue("SIMPLEX marginal W1 is " + distance + " standard deviations", distance <= 0.03); + } + + @Test + public void fbmSpectrumPeakWithin10Percent() { + IrisGeneratorStyle style = new IrisGeneratorStyle(NoiseStyle.QUADOCTAVE_FRACTAL_FBM_SIMPLEX).zoomed(0.5); + NoiseRegistry registry = new NoiseRegistry(NAMESPACE); + Field field = COMPILER.compile(style, SALT, registry, CAPABILITIES); + CNG iris = style.createNoCache(new RNG(IRIS_SEED), null); + double[] irisSpectrum = new double[SIZE / 2]; + double[] loweredSpectrum = new double[SIZE / 2]; + for (int window = 0; window < WINDOWS.length; window++) { + int originX = WINDOWS[window][0]; + int originZ = WINDOWS[window][1]; + accumulate(irisSpectrum, spectrum(grid((x, z) -> iris.noise(x, z), originX, originZ, SIZE, 1))); + accumulate(loweredSpectrum, spectrum(grid(lowered(field, registry, worldSeed(window)), originX, originZ, SIZE, 1))); + } + double irisPeak = peak(irisSpectrum); + double loweredPeak = peak(loweredSpectrum); + double ratio = loweredPeak / irisPeak; + assertTrue("FBM dominant frequency " + loweredPeak + " against Iris " + irisPeak, Math.abs(ratio - 1.0) <= 0.10); + } + + @Test + public void ridgedUsesAbsFold() { + IrisGeneratorStyle style = new IrisGeneratorStyle(NoiseStyle.TRIOCTAVE_FRACTAL_RM_SIMPLEX); + NoiseRegistry registry = new NoiseRegistry(NAMESPACE); + Field field = COMPILER.compile(style, SALT, registry, CAPABILITIES); + List nodes = new ArrayList<>(); + collect(field, nodes); + long noises = nodes.stream().filter(Field.Noise.class::isInstance).count(); + long folds = nodes.stream().filter(node -> node instanceof Field.Unary unary && unary.op() == Field.UnaryOp.ABS).count(); + assertEquals(3L, noises); + assertEquals(3L, folds); + assertEquals(3, registry.definitions().size()); + for (Field node : nodes) { + if (node instanceof Field.Unary unary && unary.op() == Field.UnaryOp.ABS) { + assertTrue(unary.input() instanceof Field.Curve curve && curve.input() instanceof Field.Noise); + } + } + } + + @Test + public void warpedStyleFragmentCountWithin15Percent() { + IrisGeneratorStyle style = new IrisGeneratorStyle(NoiseStyle.NOWHERE).zoomed(0.5); + NoiseRegistry registry = new NoiseRegistry(NAMESPACE); + Field field = COMPILER.compile(style, SALT, registry, CAPABILITIES); + CNG iris = style.createNoCache(new RNG(IRIS_SEED), null); + long irisFragments = 0L; + long loweredFragments = 0L; + for (int window = 0; window < WINDOWS.length; window++) { + int originX = WINDOWS[window][0]; + int originZ = WINDOWS[window][1]; + irisFragments += fragments(grid((x, z) -> iris.noise(x, z), originX, originZ, SIZE, 1)); + loweredFragments += fragments(grid(lowered(field, registry, worldSeed(window)), originX, originZ, SIZE, 1)); + } + double ratio = (double) loweredFragments / irisFragments; + assertTrue("NOWHERE fragments " + loweredFragments + " against Iris " + irisFragments, Math.abs(ratio - 1.0) <= 0.15); + } + + @Test + public void fittingIsDeterministic() { + IrisGeneratorStyle style = new IrisGeneratorStyle(NoiseStyle.NOWHERE).zoomed(0.75); + NoiseRegistry first = new NoiseRegistry(NAMESPACE); + NoiseRegistry second = new NoiseRegistry(NAMESPACE); + Field firstField = new NoiseStyleCompiler(CELLULAR_STUB).compile(style, SALT, first, CAPABILITIES); + Field secondField = new NoiseStyleCompiler(CELLULAR_STUB).compile(style, SALT, second, CAPABILITIES); + assertEquals(firstField, secondField); + assertEquals(firstField.toString(), secondField.toString()); + assertEquals(first.definitions(), second.definitions()); + } + + @Test + public void everyOverworldStyleCompiles() throws IOException { + String pack = System.getProperty(PACK_PROPERTY); + Assume.assumeTrue("Set -P" + PACK_PROPERTY + " to the overworld pack folder", pack != null && !pack.isBlank()); + Path root = Path.of(pack); + Assume.assumeTrue("Pack folder " + root + " is missing", Files.isDirectory(root)); + Set styles = new LinkedHashSet<>(); + List generators = new ArrayList<>(); + try (Stream files = Files.walk(root)) { + for (Path file : files.filter(path -> path.toString().endsWith(".json")).sorted().toList()) { + String relative = root.relativize(file).toString(); + if (relative.startsWith("jigsaw") || relative.startsWith("objects") || relative.equals("Schema.json")) { + continue; + } + JsonElement json = parse(file); + collectStyles(json, styles); + if (relative.startsWith("generators") && json.isJsonObject()) { + generators.add(json.getAsJsonObject()); + } + } + } + Gson gson = new Gson(); + Set cellular = EnumSet.noneOf(NoiseStyle.class); + CellularStyleLowering recording = (style, salt, registry, capabilities) -> { + cellular.add(style.getStyle()); + return new Field.Const(0.5); + }; + NoiseStyleCompiler compiler = new NoiseStyleCompiler(recording); + NoiseRegistry registry = new NoiseRegistry(NAMESPACE); + int compiled = 0; + for (String json : styles) { + IrisGeneratorStyle style = gson.fromJson(json, IrisGeneratorStyle.class); + FieldValidator.requireTwoDimensional(compiler.compile(style, SALT + compiled, registry, CAPABILITIES), json); + compiled++; + } + int layers = 0; + for (JsonObject generator : generators) { + JsonArray composite = generator.has("composite") ? generator.getAsJsonArray("composite") : new JsonArray(); + for (JsonElement layer : composite) { + IrisNoiseGenerator noise = gson.fromJson(layer, IrisNoiseGenerator.class); + FieldValidator.requireTwoDimensional(compiler.compileLayer(noise, SALT + layers, registry, CAPABILITIES), layer.toString()); + layers++; + } + } + System.out.println("Compiled " + compiled + " overworld styles and " + layers + " generator layers into " + + registry.definitions().size() + " noise definitions; cellular styles through the seam: " + cellular); + assertTrue(compiled > 0); + assertTrue(layers > 0); + } + + private static void collectStyles(JsonElement element, Set styles) { + if (element.isJsonArray()) { + for (JsonElement child : element.getAsJsonArray()) { + collectStyles(child, styles); + } + return; + } + if (!element.isJsonObject()) { + return; + } + JsonObject object = element.getAsJsonObject(); + if (isStyle(object)) { + styles.add(object.toString()); + } + for (Map.Entry entry : object.entrySet()) { + collectStyles(entry.getValue(), styles); + } + } + + private static boolean isStyle(JsonObject object) { + if (object.has("expression") || object.has("imageMap") || !object.has("style") || !object.get("style").isJsonPrimitive()) { + return false; + } + String name = object.get("style").getAsString(); + return Arrays.stream(NoiseStyle.values()).anyMatch(style -> style.name().equals(name)); + } + + private static JsonElement parse(Path file) throws IOException { + try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) { + return JsonParser.parseReader(reader); + } + } + + private static void collect(Field field, List nodes) { + nodes.add(field); + switch (field) { + case Field.Noise noise -> { + for (Field shift : new Field[]{noise.shiftX(), noise.shiftY(), noise.shiftZ()}) { + if (shift != null) { + collect(shift, nodes); + } + } + } + case Field.Binary binary -> { + collect(binary.left(), nodes); + collect(binary.right(), nodes); + } + case Field.Unary unary -> collect(unary.input(), nodes); + case Field.Curve curve -> collect(curve.input(), nodes); + case Field.Shared shared -> collect(shared.expr(), nodes); + default -> { + } + } + } + + private static long worldSeed(int window) { + return 4_242L + 7_919L * window; + } + + private static DoubleBinaryOperator lowered(Field field, NoiseRegistry registry, long worldSeed) { + Map noises = registry.byReference(); + return (x, z) -> FieldEvaluator.sample(field, noises, worldSeed, TARGET, (int) x, 0, (int) z); + } + + private static double[] grid(DoubleBinaryOperator field, int originX, int originZ, int size, int stride) { + double[] values = new double[size * size]; + for (int z = 0; z < size; z++) { + for (int x = 0; x < size; x++) { + values[z * size + x] = field.applyAsDouble(originX + x * stride, originZ + z * stride); + } + } + return values; + } + + private static double[] concat(double[] first, double[] second) { + double[] joined = Arrays.copyOf(first, first.length + second.length); + System.arraycopy(second, 0, joined, first.length, second.length); + return joined; + } + + private static double wasserstein(double[] first, double[] second) { + double[] a = first.clone(); + double[] b = second.clone(); + Arrays.sort(a); + Arrays.sort(b); + double sum = 0.0; + for (int index = 0; index < a.length; index++) { + int matched = (int) Math.min(b.length - 1, (long) index * b.length / a.length); + sum += Math.abs(a[index] - b[matched]); + } + return sum / a.length; + } + + private static double deviation(double[] values) { + double mean = Arrays.stream(values).average().orElse(0.0); + double sum = 0.0; + for (double value : values) { + sum += (value - mean) * (value - mean); + } + return Math.sqrt(sum / values.length); + } + + private static long fragments(double[] values) { + double[] sorted = values.clone(); + Arrays.sort(sorted); + double low = sorted[(int) (sorted.length * 0.15)]; + double high = sorted[(int) (sorted.length * 0.85)]; + return components(values, high, true) + components(values, low, false); + } + + private static int components(double[] values, double threshold, boolean above) { + boolean[] visited = new boolean[values.length]; + ArrayDeque queue = new ArrayDeque<>(); + int count = 0; + for (int start = 0; start < values.length; start++) { + if (visited[start] || !inside(values[start], threshold, above)) { + continue; + } + count++; + visited[start] = true; + queue.add(start); + while (!queue.isEmpty()) { + int cell = queue.poll(); + int x = cell % SIZE; + int z = cell / SIZE; + int[] neighbours = {x > 0 ? cell - 1 : -1, x < SIZE - 1 ? cell + 1 : -1, z > 0 ? cell - SIZE : -1, z < SIZE - 1 ? cell + SIZE : -1}; + for (int neighbour : neighbours) { + if (neighbour >= 0 && !visited[neighbour] && inside(values[neighbour], threshold, above)) { + visited[neighbour] = true; + queue.add(neighbour); + } + } + } + } + return count; + } + + private static boolean inside(double value, double threshold, boolean above) { + return above ? value > threshold : value < threshold; + } + + private static void accumulate(double[] total, double[] addition) { + for (int index = 0; index < total.length; index++) { + total[index] += addition[index]; + } + } + + private static double[] spectrum(double[] values) { + double mean = Arrays.stream(values).average().orElse(0.0); + double[] real = new double[SIZE * SIZE]; + double[] imaginary = new double[SIZE * SIZE]; + for (int z = 0; z < SIZE; z++) { + for (int x = 0; x < SIZE; x++) { + real[z * SIZE + x] = (values[z * SIZE + x] - mean) * hann(x) * hann(z); + } + } + for (int row = 0; row < SIZE; row++) { + fft(real, imaginary, row * SIZE, 1); + } + for (int column = 0; column < SIZE; column++) { + fft(real, imaginary, column, SIZE); + } + double total = 0.0; + double[] perOctave = new double[SIZE / 2]; + for (int kz = 0; kz < SIZE; kz++) { + for (int kx = 0; kx < SIZE; kx++) { + int fx = kx <= SIZE / 2 ? kx : kx - SIZE; + int fz = kz <= SIZE / 2 ? kz : kz - SIZE; + int radius = (int) Math.round(Math.sqrt(fx * fx + fz * fz)); + if (radius == 0 || radius >= perOctave.length) { + continue; + } + int index = kz * SIZE + kx; + double power = (real[index] * real[index] + imaginary[index] * imaginary[index]) * radius; + perOctave[radius] += power; + total += power; + } + } + for (int radius = 0; radius < perOctave.length; radius++) { + perOctave[radius] /= total; + } + return perOctave; + } + + private static double peak(double[] spectrum) { + double[] smoothed = new double[spectrum.length]; + for (int radius = 2; radius < spectrum.length; radius++) { + double sum = 0.0; + int count = 0; + for (int other = 2; other < spectrum.length; other++) { + if (Math.abs(Math.log((double) other / radius)) <= PEAK_SMOOTHING) { + sum += spectrum[other]; + count++; + } + } + smoothed[radius] = sum / count; + } + int best = 3; + for (int radius = 3; radius < spectrum.length - 1; radius++) { + if (smoothed[radius] > smoothed[best]) { + best = radius; + } + } + double left = Math.log(smoothed[best - 1]); + double centre = Math.log(smoothed[best]); + double right = Math.log(smoothed[best + 1]); + double curvature = left - 2.0 * centre + right; + double offset = curvature == 0.0 ? 0.0 : 0.5 * (left - right) / curvature; + return (best + offset) / SIZE; + } + + private static double hann(int index) { + return 0.5 - 0.5 * Math.cos(2.0 * Math.PI * index / (SIZE - 1)); + } + + private static void fft(double[] real, double[] imaginary, int offset, int stride) { + for (int index = 1, swap = 0; index < SIZE; index++) { + int bit = SIZE >> 1; + for (; (swap & bit) != 0; bit >>= 1) { + swap ^= bit; + } + swap ^= bit; + if (index < swap) { + exchange(real, offset + index * stride, offset + swap * stride); + exchange(imaginary, offset + index * stride, offset + swap * stride); + } + } + for (int length = 2; length <= SIZE; length <<= 1) { + double angle = -2.0 * Math.PI / length; + for (int start = 0; start < SIZE; start += length) { + for (int step = 0; step < length / 2; step++) { + double cos = Math.cos(angle * step); + double sin = Math.sin(angle * step); + int even = offset + (start + step) * stride; + int odd = offset + (start + step + length / 2) * stride; + double oddReal = real[odd] * cos - imaginary[odd] * sin; + double oddImaginary = real[odd] * sin + imaginary[odd] * cos; + real[odd] = real[even] - oddReal; + imaginary[odd] = imaginary[even] - oddImaginary; + real[even] += oddReal; + imaginary[even] += oddImaginary; + } + } + } + } + + private static void exchange(double[] values, int first, int second) { + double value = values[first]; + values[first] = values[second]; + values[second] = value; + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/noise/ValueDistributionFitTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/noise/ValueDistributionFitTest.java new file mode 100644 index 000000000..4fd23cefd --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/noise/ValueDistributionFitTest.java @@ -0,0 +1,75 @@ +package art.arcane.iris.pack.datapack.export.compile.noise; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.FieldEvaluator; +import org.junit.Test; + +import java.util.List; +import java.util.Map; +import java.util.SplittableRandom; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public final class ValueDistributionFitTest { + private static final int SAMPLES = 20_000; + + @Test + public void curveMapsSourceQuantilesOntoTargetQuantiles() { + SplittableRandom random = new SplittableRandom(5L); + double[] source = new double[SAMPLES]; + double[] target = new double[SAMPLES]; + for (int index = 0; index < SAMPLES; index++) { + source[index] = random.nextDouble() * 2.0 - 1.0; + double uniform = random.nextDouble(); + target[index] = uniform * uniform; + } + List knots = ValueDistributionFit.fit(source, target); + for (double input = -0.9; input <= 0.9; input += 0.05) { + double expected = Math.pow((input + 1.0) / 2.0, 2.0); + Field curve = new Field.Curve(new Field.Const(input), knots); + double actual = FieldEvaluator.sample(curve, Map.of(), 0L, DataVersion.V26_3, 0, 0, 0); + assertEquals("quantile map at " + input, expected, actual, 0.02); + } + } + + @Test + public void knotsIncreaseAndStayMonotone() { + SplittableRandom random = new SplittableRandom(9L); + double[] source = new double[SAMPLES]; + double[] target = new double[SAMPLES]; + for (int index = 0; index < SAMPLES; index++) { + source[index] = random.nextGaussian(); + target[index] = Math.exp(random.nextGaussian()); + } + List knots = ValueDistributionFit.fit(source, target); + for (int index = 0; index < knots.size(); index++) { + Field.Knot knot = knots.get(index); + assertTrue(knot.derivative() >= 0.0); + if (index > 0) { + Field.Knot previous = knots.get(index - 1); + assertTrue(knot.location() > previous.location()); + assertTrue(((Field.Const) knot.value()).value() >= ((Field.Const) previous.value()).value()); + } + } + assertEquals(0.0, knots.getFirst().derivative(), 0.0); + assertEquals(0.0, knots.getLast().derivative(), 0.0); + } + + @Test + public void constantSourceIsRejected() { + double[] constant = new double[256]; + double[] target = new double[256]; + for (int index = 0; index < target.length; index++) { + target[index] = index; + } + assertThrows(IllegalArgumentException.class, () -> ValueDistributionFit.fit(constant, target)); + } + + @Test + public void tooFewSamplesAreRejected() { + assertThrows(IllegalArgumentException.class, () -> ValueDistributionFit.fit(new double[8], new double[8])); + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/noise/VanillaNoiseModelTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/noise/VanillaNoiseModelTest.java new file mode 100644 index 000000000..22414d770 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/noise/VanillaNoiseModelTest.java @@ -0,0 +1,195 @@ +package art.arcane.iris.pack.datapack.export.compile.noise; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.WorldgenExportTargets; +import org.junit.Test; + +import java.util.Collections; +import java.util.List; +import java.util.SplittableRandom; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public final class VanillaNoiseModelTest { + private static final int POINTS = 4096; + private static final long WORLD_SEED = 8_675_309L; + private static final String NOISE_ID = "iris_export:model_test"; + private static final List TARGETS = WorldgenExportTargets.SUPPORTED; + private static final List FBM = List.of(1.0D, 1.0D, 0.5D, 0.25D); + private static final double OCTAVE_DEVIATION = 0.2702247831245211D; + private static final double PERLIN_BOUND = 2.0D; + + @Test + public void identicalInputsSampleIdentically() { + for (DataVersion target : TARGETS) { + VanillaNoiseModel first = VanillaNoiseModel.create(NOISE_ID, -8, FBM, WORLD_SEED, target); + VanillaNoiseModel second = VanillaNoiseModel.create(NOISE_ID, -8, FBM, WORLD_SEED, target); + double[][] points = points(11L); + for (double[] point : points) { + assertEquals(Double.doubleToRawLongBits(first.sample(point[0], point[1], point[2])), + Double.doubleToRawLongBits(second.sample(point[0], point[1], point[2]))); + } + } + } + + @Test + public void seedAndIdentifierSelectDifferentFields() { + for (DataVersion target : TARGETS) { + VanillaNoiseModel base = VanillaNoiseModel.create(NOISE_ID, -8, FBM, WORLD_SEED, target); + VanillaNoiseModel reseeded = VanillaNoiseModel.create(NOISE_ID, -8, FBM, WORLD_SEED + 1L, target); + VanillaNoiseModel renamed = VanillaNoiseModel.create("iris_export:model_test_b", -8, FBM, WORLD_SEED, target); + assertTrue(maxDifference(base, reseeded) > 0.0D); + assertTrue(maxDifference(base, renamed) > 0.0D); + } + } + + @Test + public void unqualifiedIdentifierHashesAsDefaultNamespace() { + for (DataVersion target : TARGETS) { + VanillaNoiseModel bare = VanillaNoiseModel.create("model_test", -8, FBM, WORLD_SEED, target); + VanillaNoiseModel qualified = VanillaNoiseModel.create("minecraft:model_test", -8, FBM, WORLD_SEED, target); + assertEquals(0.0D, maxDifference(bare, qualified), 0.0D); + } + } + + @Test + public void zeroAmplitudeOctavesAreSkipped() { + for (DataVersion target : TARGETS) { + VanillaNoiseModel padded = VanillaNoiseModel.create(NOISE_ID, -9, List.of(0.0D, 0.0D, 1.0D), WORLD_SEED, target); + VanillaNoiseModel single = VanillaNoiseModel.create(NOISE_ID, -7, List.of(1.0D), WORLD_SEED, target); + double scale = fittedScale(padded, single); + assertTrue(scale > 0.0D); + for (double[] point : points(23L)) { + double expected = scale * single.sample(point[0], point[1], point[2]); + assertEquals(expected, padded.sample(point[0], point[1], point[2]), 1.0E-6D); + } + } + } + + @Test + public void zeroAmplitudesSampleZero() { + for (DataVersion target : TARGETS) { + VanillaNoiseModel silent = VanillaNoiseModel.create(NOISE_ID, -6, List.of(0.0D, 0.0D), WORLD_SEED, target); + for (double[] point : points(31L)) { + assertEquals(0.0D, silent.sample(point[0], point[1], point[2]), 0.0D); + } + } + } + + @Test + public void modernSamplesStayWithinDeclaredRange() { + VanillaNoiseModel model = VanillaNoiseModel.create(NOISE_ID, -8, FBM, WORLD_SEED, DataVersion.V26_3); + double amplitudeSum = 0.0D; + double squaredDeviation = 0.0D; + for (int index = 0; index < FBM.size(); index++) { + double amplitude = FBM.get(index) / Math.pow(2.0D, index); + amplitudeSum += amplitude; + squaredDeviation += Math.pow(OCTAVE_DEVIATION * amplitude, 2.0D); + } + double normalization = (amplitudeSum / 3.0D) / (Math.sqrt(squaredDeviation) * Math.sqrt(2.0D)); + assertWithinRange(model, 2.0D * PERLIN_BOUND * normalization * amplitudeSum * (1.0D + 1.0E-6D)); + } + + @Test + public void modernSamplesAreSinglePrecision() { + VanillaNoiseModel model = VanillaNoiseModel.create(NOISE_ID, -8, FBM, WORLD_SEED, DataVersion.V26_3); + for (double[] point : points(43L)) { + double value = model.sample(point[0], point[1], point[2]); + assertEquals(value, (float) value, 0.0D); + } + } + + @Test + public void baseAmplitudeScalesOutput() { + VanillaNoiseModel.OctaveProfile unit = new VanillaNoiseModel.OctaveProfile(-8, FBM.size(), FBM, 1.0D, + VanillaNoiseModel.Normalization.ENABLED); + VanillaNoiseModel.OctaveProfile doubled = new VanillaNoiseModel.OctaveProfile(-8, FBM.size(), FBM, 2.0D, + VanillaNoiseModel.Normalization.ENABLED); + VanillaNoiseModel first = VanillaNoiseModel.create(NOISE_ID, unit, WORLD_SEED); + VanillaNoiseModel second = VanillaNoiseModel.create(NOISE_ID, doubled, WORLD_SEED); + for (double[] point : points(59L)) { + assertEquals(2.0D * first.sample(point[0], point[1], point[2]), second.sample(point[0], point[1], point[2]), 1.0E-6D); + } + } + + @Test + public void mappedProfileUsesUnitAmplitudeWithoutNormalization() { + VanillaNoiseModel.OctaveProfile profile = VanillaNoiseModel.OctaveProfile.fromAmplitudes(-8, FBM); + assertEquals(-8, profile.baseOctave()); + assertEquals(FBM.size(), profile.octaveCount()); + assertEquals(FBM, profile.amplitudeModifiers()); + assertEquals(1.0D, profile.baseAmplitude(), 0.0D); + assertEquals(VanillaNoiseModel.Normalization.DISABLED, profile.normalization()); + } + + @Test + public void modernDefinitionsOutsideTheCodecAreRejected() { + assertThrows(IllegalArgumentException.class, + () -> VanillaNoiseModel.create(NOISE_ID, 33, List.of(1.0D), WORLD_SEED, DataVersion.V26_3)); + assertThrows(IllegalArgumentException.class, + () -> VanillaNoiseModel.create(NOISE_ID, -33, List.of(1.0D), WORLD_SEED, DataVersion.V26_3)); + assertThrows(IllegalArgumentException.class, + () -> VanillaNoiseModel.create(NOISE_ID, -8, List.of(1.0D, -0.5D), WORLD_SEED, DataVersion.V26_3)); + assertThrows(IllegalArgumentException.class, + () -> VanillaNoiseModel.create(NOISE_ID, -8, List.of(), WORLD_SEED, DataVersion.V26_3)); + assertThrows(IllegalArgumentException.class, + () -> VanillaNoiseModel.create(NOISE_ID, -8, Collections.nCopies(33, 1.0D), WORLD_SEED, + DataVersion.V26_3)); + assertThrows(IllegalArgumentException.class, + () -> new VanillaNoiseModel.OctaveProfile(-8, 2, List.of(1.0D), 1.0D, VanillaNoiseModel.Normalization.ENABLED)); + assertThrows(IllegalArgumentException.class, + () -> new VanillaNoiseModel.OctaveProfile(-8, 1, List.of(), 0.0D, VanillaNoiseModel.Normalization.ENABLED)); + } + + @Test + public void unsupportedTargetsAreRejected() { + for (DataVersion older : List.of(DataVersion.V1_21_1, DataVersion.V26_2)) { + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, + () -> VanillaNoiseModel.create(NOISE_ID, -8, FBM, WORLD_SEED, older)); + assertTrue(failure.getMessage().contains(older.getVersion())); + } + } + + private static void assertWithinRange(VanillaNoiseModel model, double bound) { + double peak = 0.0D; + for (double[] point : points(37L)) { + double value = model.sample(point[0], point[1], point[2]); + assertTrue(Double.isFinite(value)); + peak = Math.max(peak, Math.abs(value)); + } + assertTrue("peak " + peak + " exceeds " + bound, peak <= bound); + assertTrue("peak " + peak + " is degenerate", peak > bound * 0.05D); + } + + private static double fittedScale(VanillaNoiseModel target, VanillaNoiseModel reference) { + double product = 0.0D; + double square = 0.0D; + for (double[] point : points(61L)) { + double referenceValue = reference.sample(point[0], point[1], point[2]); + product += target.sample(point[0], point[1], point[2]) * referenceValue; + square += referenceValue * referenceValue; + } + return product / square; + } + + private static double maxDifference(VanillaNoiseModel first, VanillaNoiseModel second) { + double difference = 0.0D; + for (double[] point : points(67L)) { + difference = Math.max(difference, + Math.abs(first.sample(point[0], point[1], point[2]) - second.sample(point[0], point[1], point[2]))); + } + return difference; + } + + private static double[][] points(long salt) { + SplittableRandom random = new SplittableRandom(salt); + double[][] points = new double[POINTS][]; + for (int index = 0; index < POINTS; index++) { + points[index] = new double[]{random.nextDouble(-60_000.0D, 60_000.0D), random.nextDouble(-64.0D, 320.0D), + random.nextDouble(-60_000.0D, 60_000.0D)}; + } + return points; + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/object/LootCompilerTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/object/LootCompilerTest.java new file mode 100644 index 000000000..f750d1dab --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/object/LootCompilerTest.java @@ -0,0 +1,58 @@ +package art.arcane.iris.pack.datapack.export.compile.object; + +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.world.loot.IrisLoot; +import art.arcane.iris.world.loot.IrisLootTable; +import art.arcane.volmlib.util.collection.KList; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class LootCompilerTest { + @Test + public void rarityBecomesProportionalWeight() { + IrisLootTable table = table(); + List diagnostics = new ArrayList<>(); + + JsonObject json = new LootCompiler("iris_export", null).table(table, diagnostics); + + JsonObject pool = json.getAsJsonArray("pools").get(0).getAsJsonObject(); + JsonArray entries = pool.getAsJsonArray("entries"); + assertEquals(3, entries.size()); + assertEquals("minecraft:diamond", entries.get(0).getAsJsonObject().get("name").getAsString()); + assertEquals(4, entries.get(0).getAsJsonObject().get("weight").getAsInt()); + assertEquals(2, entries.get(1).getAsJsonObject().get("weight").getAsInt()); + assertEquals(1, entries.get(2).getAsJsonObject().get("weight").getAsInt()); + assertEquals("minecraft:set_count", entries.get(1).getAsJsonObject().getAsJsonArray("modifier").get(0).getAsJsonObject().get("type").getAsString()); + JsonObject rolls = pool.getAsJsonObject("rolls"); + assertEquals(2, rolls.get("min").getAsInt()); + assertEquals(5, rolls.get("max").getAsInt()); + assertEquals(1.0 / 3, pool.getAsJsonObject("condition").get("chance").getAsDouble(), 1e-6); + assertEquals("minecraft:chest", json.get("type").getAsString()); + assertTrue(diagnostics.stream().anyMatch(diagnostic -> diagnostic.limit() == ExportLimit.CUSTOM_ITEM)); + assertTrue(diagnostics.stream().anyMatch(diagnostic -> diagnostic.limit() == ExportLimit.LOOT_RARITY)); + } + + private static IrisLootTable table() { + IrisLootTable table = new IrisLootTable(); + table.setLoadKey("treasure"); + table.setName("Treasure"); + table.setRarity(3); + table.setMinPicked(2); + table.setMaxPicked(5); + KList loot = new KList<>(); + loot.add(new IrisLoot().setType("DIAMOND").setRarity(1)); + loot.add(new IrisLoot().setType("minecraft:gold_ingot").setRarity(2).setMinAmount(1).setMaxAmount(3)); + loot.add(new IrisLoot().setType("emerald").setRarity(4)); + loot.add(new IrisLoot().setType("mymod:relic").setRarity(1)); + table.setLoot(loot); + return table; + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectSourceTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectSourceTest.java new file mode 100644 index 000000000..8f64b3615 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectSourceTest.java @@ -0,0 +1,157 @@ +package art.arcane.iris.pack.datapack.export.compile.object; + +import art.arcane.iris.generation.decoration.tree.TreeGrounding; +import art.arcane.iris.pack.datapack.export.TemplateBlock; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.iris.structure.object.IrisObject; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class ObjectSourceTest { + @ClassRule + public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + + @BeforeClass + public static void bindPlatform() { + IrisPlatforms.unbind(); + NativeBlockState stone = blockState("minecraft:stone"); + PlatformRegistries registries = mock(PlatformRegistries.class); + when(registries.block(anyString())).thenReturn(stone); + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.registries()).thenReturn(registries); + when(platform.minecraftVersion()).thenReturn("26.3"); + IrisPlatforms.bind(platform); + } + + @AfterClass + public static void unbindPlatform() { + IrisPlatforms.unbind(); + } + + @Test + public void containedObjectKeepsItsBox() { + IrisObject object = new IrisObject(3, 3, 3); + object.setUnsigned(1, 0, 1, blockState("minecraft:oak_log[axis=y]")); + object.setUnsigned(0, 2, 2, blockState("minecraft:oak_leaves[distance=1,persistent=true,waterlogged=false]")); + List diagnostics = new ArrayList<>(); + + ObjectSource source = ObjectSource.of(object, "objects/bush", diagnostics); + + assertTrue(diagnostics.isEmpty()); + assertEquals(0, source.baseY()); + assertEquals(3, source.volume().sizeX()); + assertEquals(3, source.volume().sizeY()); + assertEquals(3, source.volume().sizeZ()); + assertEquals(2, source.volume().blocks().size()); + } + + @Test + public void waterlogIsLeftToThePlacementSite() { + IrisObject object = new IrisObject(1, 2, 1); + object.setUnsigned(0, 0, 0, blockState("minecraft:tube_coral_block")); + object.setUnsigned(0, 1, 0, blockState("minecraft:sea_pickle[pickles=2,waterlogged=true]")); + + ObjectSource source = ObjectSource.of(object, "objects/reef", new ArrayList<>()); + + TemplateBlock pickle = blockAt(source, 0, 1, 0); + assertEquals("false", pickle.state().properties().get("waterlogged")); + assertEquals("2", pickle.state().properties().get("pickles")); + } + + @Test + public void rowsAndColumnsPastTheNominalBoxAreKeptAroundTheCenter() { + IrisObject object = new IrisObject(3, 3, 3); + object.setUnsigned(1, -1, 1, blockState("minecraft:oak_log[axis=y]")); + object.setUnsigned(1, 0, 1, blockState("minecraft:oak_log[axis=y]")); + object.setUnsigned(-1, 1, 1, blockState("minecraft:oak_log[axis=x]")); + List diagnostics = new ArrayList<>(); + + ObjectSource source = ObjectSource.of(object, "objects/tree", diagnostics); + + assertTrue(diagnostics.isEmpty()); + assertEquals(3, source.volume().blocks().size()); + assertEquals(-1, source.baseY()); + int centerX = source.volume().sizeX() / 2; + int centerZ = source.volume().sizeZ() / 2; + TemplateBlock root = blockAt(source, centerX, 0, centerZ); + assertEquals("minecraft:oak_log", root.state().name()); + TemplateBlock branch = blockAt(source, centerX - 2, 2, centerZ); + assertEquals("x", branch.state().properties().get("axis")); + + BakedVariant variant = new ObjectVariantBaker().bake("iris_export:object/tree/a", source, VariantSpec.builder().build()); + + assertEquals(-1, variant.baseY()); + assertEquals(3, variant.volume().blocks().size()); + } + + @Test + public void treeRootsFollowTheTemplateShift() { + IrisObject object = new IrisObject(3, 3, 3); + NativeBlockState root = blockState("minecraft:oak_wood[axis=y]"); + object.setUnsigned(1, -1, 1, root); + object.setUnsigned(0, -2, 1, root); + object.setUnsigned(0, -1, 1, root); + object.setUnsigned(1, 0, 1, blockState("minecraft:oak_log[axis=y]")); + object.setTreeGrounding(TreeGrounding.create(List.of( + new TreeGrounding.Root(0, -2, 0, root), + new TreeGrounding.Root(-1, -3, 0, root), + new TreeGrounding.Root(-1, -2, 0, root)), 3, Set.of())); + + ObjectSource source = ObjectSource.of(object, "procedural/tree", new ArrayList<>()); + + assertEquals(-2, source.baseY()); + assertEquals(3, source.roots().reach()); + assertEquals(List.of("0,0,1", "1,1,1"), cells(source.roots())); + assertEquals("minecraft:oak_wood", source.roots().cells().getFirst().state().name()); + } + + @Test + public void objectsWithoutGroundingHaveNoRoots() { + IrisObject object = new IrisObject(1, 1, 1); + object.setUnsigned(0, 0, 0, blockState("minecraft:stone")); + + assertFalse(ObjectSource.of(object, "objects/rock", new ArrayList<>()).roots().present()); + } + + private static List cells(TreeRoots roots) { + List cells = new ArrayList<>(); + for (TemplateBlock cell : roots.cells()) { + cells.add(cell.x() + "," + cell.y() + "," + cell.z()); + } + return cells; + } + + private static TemplateBlock blockAt(ObjectSource source, int x, int y, int z) { + for (TemplateBlock block : source.volume().blocks()) { + if (block.x() == x && block.y() == y && block.z() == z) { + return block; + } + } + throw new AssertionError("No block at " + x + "," + y + "," + z + " in " + source.volume().blocks()); + } + + private static NativeBlockState blockState(String key) { + NativeBlockState state = mock(NativeBlockState.class); + when(state.key()).thenReturn(key); + when(state.namespace()).thenReturn(key.substring(0, key.indexOf(':'))); + return state; + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectStageTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectStageTest.java new file mode 100644 index 000000000..cab898cee --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectStageTest.java @@ -0,0 +1,413 @@ +package art.arcane.iris.pack.datapack.export.compile.object; + +import art.arcane.iris.configuration.IrisSettings; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.WorldgenExportMode; +import art.arcane.iris.pack.datapack.export.WorldgenExportProgress; +import art.arcane.iris.pack.datapack.export.WorldgenExportRequest; +import art.arcane.iris.pack.datapack.export.compile.CompileStage; +import art.arcane.iris.pack.datapack.export.compile.DimensionStage; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; +import art.arcane.iris.pack.datapack.export.compile.PackCompiler; +import art.arcane.iris.pack.datapack.export.compile.biome.BiomeCodes; +import art.arcane.iris.pack.datapack.export.compile.decoration.GenerationStep; +import art.arcane.iris.pack.datapack.export.ir.BinaryResource; +import art.arcane.iris.pack.datapack.export.ir.BiomeDef; +import art.arcane.iris.pack.datapack.export.ir.ClimateLayout; +import art.arcane.iris.pack.datapack.export.ir.ExportIr; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.JsonResource; +import art.arcane.iris.pack.datapack.export.lower.ExportCapabilities; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.testsupport.CompatTestPlatform; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import art.arcane.volmlib.util.nbt.io.NBTUtil; +import art.arcane.volmlib.util.nbt.tag.CompoundTag; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExternalResource; +import org.junit.rules.TemporaryFolder; + +import java.io.ByteArrayInputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.doReturn; + +public class ObjectStageTest { + private static final String NAMESPACE = WorldgenExportRequest.DEFAULT_NAMESPACE; + private static final String FOREST = NAMESPACE + ":forest"; + private static final String PLAINS = NAMESPACE + ":plains"; + private static final Field ZERO = new Field.Const(0.0); + + @ClassRule + public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Rule + public BoundPlatform platform = new BoundPlatform(); + + @Test + public void objectsFalseExportsNoObjects() throws Exception { + File pack = pack(); + IrisData data = IrisData.openRuntime(pack); + try { + ExportIr ir = compile(data, DataVersion.V26_3, false); + + assertTrue(ir.jsonResources().isEmpty()); + assertTrue(ir.binaryResources().isEmpty()); + for (BiomeDef biome : ir.biomes()) { + for (JsonElement step : biome.json().getAsJsonArray("features")) { + assertEquals(0, step.getAsJsonArray().size()); + } + } + } finally { + data.close(); + } + } + + @Test + public void objectsExportTemplatesFeaturesAndBiomeLists() throws Exception { + File pack = pack(); + IrisData data = IrisData.openRuntime(pack); + try { + ExportIr ir = compile(data, DataVersion.V26_3, true); + + assertTrue(paths(ir).stream().anyMatch(path -> path.startsWith("data/iris_export/worldgen/feature/object/"))); + assertTrue(paths(ir).stream().anyMatch(path -> path.startsWith("data/iris_export/worldgen/placed_feature/object/"))); + assertTrue(paths(ir).stream().noneMatch(path -> path.contains("configured_feature"))); + assertFalse(ir.binaryResources().isEmpty()); + for (BinaryResource template : ir.binaryResources()) { + assertTrue(template.path(), template.path().startsWith("data/iris_export/structure/object/")); + assertTrue(template.path().endsWith(".nbt")); + CompoundTag root = (CompoundTag) NBTUtil.read(new ByteArrayInputStream(template.bytes()), true).getTag(); + assertEquals(5023, root.getInt("DataVersion")); + } + JsonArray forest = features(ir, "forest"); + assertEquals(1, forest.get(GenerationStep.VEGETAL_DECORATION.ordinal()).getAsJsonArray().size()); + assertEquals(1, forest.get(GenerationStep.SURFACE_STRUCTURES.ordinal()).getAsJsonArray().size()); + JsonArray plains = features(ir, "plains"); + assertEquals(0, plains.get(GenerationStep.VEGETAL_DECORATION.ordinal()).getAsJsonArray().size()); + assertEquals(1, plains.get(GenerationStep.SURFACE_STRUCTURES.ordinal()).getAsJsonArray().size()); + Set referenced = new HashSet<>(); + for (BiomeDef biome : ir.biomes()) { + for (JsonElement step : biome.json().getAsJsonArray("features")) { + for (JsonElement feature : step.getAsJsonArray()) { + referenced.add("data/iris_export/worldgen/placed_feature/" + feature.getAsString().substring(NAMESPACE.length() + 1) + ".json"); + } + } + } + assertTrue(paths(ir).containsAll(referenced)); + assertTrue(ir.diagnostics().stream().anyMatch(diagnostic -> diagnostic.limit() == ExportLimit.STRUCTURE_AVOIDANCE)); + } finally { + data.close(); + } + } + + @Test + public void proceduralTreesExportAsTemplates() throws Exception { + File pack = pack(); + CompatTestPlatform.write(pack, "biomes/plains.json", """ + {"name":"Plains","proceduralObjects":{"trees":[{"name":"field-oak","chance":0.5,"density":2,"variants":2,"seed":7, + "trunk":"minecraft:oak_log","leaves":"minecraft:oak_leaves","heightMin":5,"heightMax":7}]}} + """); + IrisData data = IrisData.openRuntime(pack); + try { + ExportIr ir = compile(data, DataVersion.V26_3, true); + + JsonArray plains = features(ir, "plains"); + assertEquals(1, plains.get(GenerationStep.VEGETAL_DECORATION.ordinal()).getAsJsonArray().size()); + long templates = ir.binaryResources().stream() + .filter(template -> template.path().contains("/procedural/") && template.path().contains("field-oak")) + .count(); + assertTrue("procedural templates: " + templates, templates >= 2); + } finally { + data.close(); + } + } + + @Test + public void proceduralTreeRootsReachDownToTheTerrain() throws Exception { + File pack = pack(); + CompatTestPlatform.write(pack, "biomes/plains.json", """ + {"name":"Plains","proceduralObjects":{"trees":[{"name":"field-oak","chance":0.5,"density":2,"variants":2,"seed":7, + "trunk":"minecraft:oak_log","leaves":"minecraft:oak_leaves","heightMin":5,"heightMax":7,"rootStyle":"TAPROOT","rootDepth":3}]}} + """); + IrisData data = IrisData.openRuntime(pack); + try { + ExportIr ir = compile(data, DataVersion.V26_3, true); + + String placedId = features(ir, "plains").get(GenerationStep.VEGETAL_DECORATION.ordinal()).getAsJsonArray().get(0).getAsString(); + JsonObject placed = json(ir, "data/iris_export/worldgen/placed_feature/" + placedId.substring(NAMESPACE.length() + 1) + ".json"); + String featureId = placed.get("feature").getAsString(); + JsonObject feature = json(ir, "data/iris_export/worldgen/feature/" + featureId.substring(NAMESPACE.length() + 1) + ".json"); + assertEquals("minecraft:weighted_random_selector", feature.get("type").getAsString()); + Set pillars = new HashSet<>(); + for (JsonElement branch : feature.getAsJsonArray("features")) { + JsonObject entry = branch.getAsJsonObject().getAsJsonObject("data"); + JsonArray parts = entry.getAsJsonObject("feature").getAsJsonArray("features"); + for (int index = 1; index < parts.size(); index++) { + pillars.add(parts.get(index).getAsJsonObject().get("feature").getAsString()); + } + } + assertFalse(pillars.isEmpty()); + for (String pillar : pillars) { + assertTrue(pillar, paths(ir).contains("data/iris_export/worldgen/feature/" + pillar.substring(NAMESPACE.length() + 1) + ".json")); + } + } finally { + data.close(); + } + } + + @Test + public void proceduralObjectsSharedByBiomesShareTemplates() throws Exception { + String procedural = """ + "proceduralObjects":{"trees":[{"name":"field-oak","chance":0.5,"density":2,"variants":2,"seed":7, + "trunk":"minecraft:oak_log","leaves":"minecraft:oak_leaves","heightMin":5,"heightMax":7}]}"""; + File pack = pack(); + CompatTestPlatform.write(pack, "biomes/plains.json", "{\"name\":\"Plains\"," + procedural + "}"); + CompatTestPlatform.write(pack, "biomes/forest.json", "{\"name\":\"Forest\"," + procedural + "}"); + IrisData data = IrisData.openRuntime(pack); + try { + ExportIr ir = compile(data, DataVersion.V26_3, true); + + Set objects = new HashSet<>(); + for (BinaryResource template : ir.binaryResources()) { + if (template.path().contains("field-oak")) { + objects.add(template.path().substring(0, template.path().lastIndexOf('/'))); + } + } + assertEquals(objects.toString(), 2, objects.size()); + assertEquals(1, features(ir, "forest").get(GenerationStep.VEGETAL_DECORATION.ordinal()).getAsJsonArray().size()); + assertEquals(1, features(ir, "plains").get(GenerationStep.VEGETAL_DECORATION.ordinal()).getAsJsonArray().size()); + } finally { + data.close(); + } + } + + @Test + public void lootBoundPerBiome() throws Exception { + File pack = pack(); + IrisData data = IrisData.openRuntime(pack); + try { + ExportIr modern = compile(data, DataVersion.V26_3, true); + + String forestHut = features(modern, "forest").get(GenerationStep.SURFACE_STRUCTURES.ordinal()).getAsJsonArray().get(0).getAsString(); + String plainsHut = features(modern, "plains").get(GenerationStep.SURFACE_STRUCTURES.ordinal()).getAsJsonArray().get(0).getAsString(); + assertNotEquals(forestHut, plainsHut); + String forestTable = appendedLoot(modern, forestHut); + String plainsTable = appendedLoot(modern, plainsHut); + assertNotEquals(forestTable, plainsTable); + assertTrue(lootReferences(modern, forestTable).contains("iris_export:iris/forest_loot")); + assertTrue(lootReferences(modern, plainsTable).contains("iris_export:iris/plains_loot")); + assertTrue(paths(modern).contains("data/iris_export/loot_table/iris/forest_loot.json")); + + } finally { + data.close(); + } + } + + @Test + public void exportIsDeterministic() throws Exception { + File pack = pack(); + IrisData data = IrisData.openRuntime(pack); + try { + ExportIr first = compile(data, DataVersion.V26_3, true); + ExportIr second = compile(data, DataVersion.V26_3, true); + + assertEquals(first.jsonResources(), second.jsonResources()); + assertEquals(first.binaryResources().size(), second.binaryResources().size()); + for (int index = 0; index < first.binaryResources().size(); index++) { + assertEquals(first.binaryResources().get(index).path(), second.binaryResources().get(index).path()); + assertArrayEquals(first.binaryResources().get(index).bytes(), second.binaryResources().get(index).bytes()); + } + assertEquals(first.biomes(), second.biomes()); + } finally { + data.close(); + } + } + + private static ExportIr compile(IrisData data, DataVersion target, boolean objects) { + IrisDimension dimension = data.getDimensionLoader().load("main", false); + PackCompileContext context = PackCompileContext.builder() + .data(data) + .dimension(dimension) + .capabilities(ExportCapabilities.forTarget(target)) + .namespace(NAMESPACE) + .mode(WorldgenExportMode.PRESET) + .objects(objects) + .build(); + CompileStage biomes = new CompileStage() { + @Override + public String name() { + return "biomes"; + } + + @Override + public void apply(PackCompileContext context) { + JsonObject body = new JsonObject(); + body.addProperty("has_precipitation", true); + context.setBiomes(List.of(new BiomeDef("forest", body), new BiomeDef("plains", body))); + ClimateLayout layout = new ClimateLayout(List.of( + new ClimateLayout.ClimateEntry(FOREST, ClimateLayout.ClimateAxis.TEMPERATURE, -1.0, 0.0), + new ClimateLayout.ClimateEntry(PLAINS, ClimateLayout.ClimateAxis.TEMPERATURE, 0.0, 1.0))); + context.setBiomeCodes(new BiomeCodes(ZERO, ZERO, ZERO, layout, Map.of(FOREST, "forest", PLAINS, "plains"))); + } + }; + return new PackCompiler(List.of(new DimensionStage(), biomes, new ObjectStage())).compile(context, WorldgenExportProgress.NONE); + } + + private File pack() throws IOException { + File pack = temporaryFolder.newFolder("pack"); + CompatTestPlatform.write(pack, "dimensions/main.json", "{\"regions\":[\"region\"]}"); + CompatTestPlatform.write(pack, "regions/region.json", """ + {"landBiomes":["forest","plains"], + "objects":[{"place":["huts/hut"],"chance":0.1,"density":1,"rotation":{"yAxis":{"enabled":true,"interval":45}}}]} + """); + CompatTestPlatform.write(pack, "biomes/forest.json", """ + {"name":"Forest","loot":{"tables":["forest_loot"]}, + "objects":[{"place":["trees/oak"],"chance":0.5,"density":3,"scale":{"minimumScale":0.75,"maximumScale":1.5,"variations":3}}]} + """); + CompatTestPlatform.write(pack, "biomes/plains.json", "{\"name\":\"Plains\",\"loot\":{\"tables\":[\"plains_loot\"]}}"); + CompatTestPlatform.write(pack, "loot/forest_loot.json", "{\"name\":\"Forest\",\"loot\":[{\"type\":\"DIAMOND\",\"rarity\":2}]}"); + CompatTestPlatform.write(pack, "loot/plains_loot.json", "{\"name\":\"Plains\",\"loot\":[{\"type\":\"WHEAT\",\"rarity\":1}]}"); + writeObject(pack, "trees/oak", "minecraft:oak_log", "minecraft:oak_leaves", "minecraft:oak_leaves"); + writeObject(pack, "huts/hut", "minecraft:cobblestone", "minecraft:chest", "minecraft:cobblestone"); + return pack; + } + + private static void writeObject(File pack, String key, String... column) throws IOException { + File file = new File(pack, "objects/" + key + ".iob"); + if (!file.getParentFile().isDirectory() && !file.getParentFile().mkdirs()) { + throw new IOException("Could not create " + file.getParentFile()); + } + try (DataOutputStream out = new DataOutputStream(new FileOutputStream(file))) { + out.writeInt(1); + out.writeInt(column.length); + out.writeInt(1); + out.writeUTF("Iris V2 IOB;"); + out.writeShort(column.length); + for (String entry : column) { + out.writeUTF(entry); + } + out.writeInt(column.length); + for (int index = 0; index < column.length; index++) { + out.writeShort(0); + out.writeShort(index - column.length / 2); + out.writeShort(0); + out.writeShort(index); + } + out.writeInt(0); + } + } + + private static JsonArray features(ExportIr ir, String biome) { + for (BiomeDef definition : ir.biomes()) { + if (definition.id().equals(biome)) { + return definition.json().getAsJsonArray("features"); + } + } + throw new AssertionError("No biome " + biome); + } + + private static String appendedLoot(ExportIr ir, String placedFeature) { + JsonObject placed = json(ir, "data/iris_export/worldgen/placed_feature/" + placedFeature.substring(NAMESPACE.length() + 1) + ".json"); + String featureId = placed.get("feature").getAsString(); + JsonObject feature = json(ir, "data/iris_export/worldgen/feature/" + featureId.substring(NAMESPACE.length() + 1) + ".json"); + for (JsonElement processor : feature.getAsJsonObject("processors").getAsJsonArray("processors")) { + for (JsonElement rule : processor.getAsJsonObject().getAsJsonArray("rules")) { + JsonObject modifier = rule.getAsJsonObject().getAsJsonObject("block_entity_modifier"); + if (modifier != null) { + return modifier.get("loot_table").getAsString(); + } + } + } + throw new AssertionError("No append_loot rule in " + featureId); + } + + private static List lootReferences(ExportIr ir, String table) { + JsonObject loot = json(ir, "data/iris_export/loot_table/" + table.substring(NAMESPACE.length() + 1) + ".json"); + List references = new ArrayList<>(); + for (JsonElement pool : loot.getAsJsonArray("pools")) { + for (JsonElement entry : pool.getAsJsonObject().getAsJsonArray("entries")) { + references.add(entry.getAsJsonObject().get("value").getAsString()); + } + } + return references; + } + + private static JsonObject json(ExportIr ir, String path) { + for (JsonResource resource : ir.jsonResources()) { + if (resource.path().equals(path)) { + return resource.json().getAsJsonObject(); + } + } + throw new AssertionError("No resource " + path); + } + + private static Set paths(ExportIr ir) { + Set paths = new HashSet<>(); + for (JsonResource resource : ir.jsonResources()) { + paths.add(resource.path()); + } + for (BinaryResource resource : ir.binaryResources()) { + paths.add(resource.path()); + } + return paths; + } + + public static final class BoundPlatform extends ExternalResource { + private static final List BLOCKS = List.of("minecraft:air", "minecraft:stone", "minecraft:water", + "minecraft:grass_block", "minecraft:oak_log", "minecraft:oak_leaves", "minecraft:cobblestone", "minecraft:chest"); + private IrisSettings previousSettings; + private CompatTestPlatform platform; + private Path folder; + + @Override + protected void before() throws IOException { + previousSettings = IrisSettings.settings; + IrisSettings.settings = new IrisSettings(); + folder = Files.createTempDirectory("iris-object-export-platform"); + platform = CompatTestPlatform.bind(folder.toFile(), BLOCKS); + doReturn("0.9").when(IrisPlatforms.get()).minecraftVersion(); + } + + @Override + protected void after() { + platform.unbind(); + IrisSettings.settings = previousSettings; + try (Stream paths = Files.walk(folder)) { + for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) { + Files.deleteIfExists(path); + } + } catch (IOException exception) { + throw new IllegalStateException("Could not remove test platform folder " + folder, exception); + } + } + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectVariantBakerTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectVariantBakerTest.java new file mode 100644 index 000000000..e552b7c80 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/object/ObjectVariantBakerTest.java @@ -0,0 +1,274 @@ +package art.arcane.iris.pack.datapack.export.compile.object; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.StructureTemplateEncoder; +import art.arcane.iris.pack.datapack.export.TemplateBlock; +import art.arcane.iris.pack.datapack.export.TemplateVolume; +import art.arcane.iris.pack.datapack.export.VanillaBlockState; +import art.arcane.iris.pack.datapack.export.compile.WeightedState; +import org.junit.Test; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +public class ObjectVariantBakerTest { + private static final String NAMESPACE = "iris_export"; + + @Test + public void variantBakingIsDeterministic() throws IOException { + TemplateVolume tree = tree(); + ObjectEdit chanceEdit = new ObjectEdit(List.of(VanillaBlockState.parse("minecraft:oak_leaves")), false, + List.of(new WeightedState(VanillaBlockState.parse("minecraft:birch_leaves"), 1), + new WeightedState(VanillaBlockState.parse("minecraft:azalea_leaves"), 3)), 0.5); + VariantSpec spec = VariantSpec.builder() + .scale(1.5) + .rotation(new VariantSpec.Rotation(0, 45, 0)) + .warp(new VariantWarp("simplex", 2, (x, y, z) -> Math.sin(x * 0.3) * 2)) + .snow(0.6) + .edits(List.of(chanceEdit)) + .build(); + String id = ObjectVariantBaker.templateId(NAMESPACE, "trees/oak", spec, DataVersion.V26_3); + + BakedVariant first = new ObjectVariantBaker().bake(id, new ObjectSource(tree, 0, TreeRoots.NONE), spec); + BakedVariant second = new ObjectVariantBaker().bake(id, new ObjectSource(tree, 0, TreeRoots.NONE), spec); + + assertEquals(first, second); + assertEquals(id, ObjectVariantBaker.templateId(NAMESPACE, "trees/oak", spec, DataVersion.V26_3)); + assertTrue(id.startsWith("iris_export:object/trees/oak/")); + assertArrayEquals(new StructureTemplateEncoder().encode(first.volume(), DataVersion.V26_3), + new StructureTemplateEncoder().encode(second.volume(), DataVersion.V26_3)); + VariantSpec otherAngle = VariantSpec.builder().rotation(new VariantSpec.Rotation(0, 30, 0)).build(); + assertNotEquals(id, ObjectVariantBaker.templateId(NAMESPACE, "trees/oak", otherAngle, DataVersion.V26_3)); + } + + @Test + public void unchangedVariantKeepsSizeAndDropsAir() { + TemplateVolume volume = new TemplateVolume(2, 2, 1, List.of( + block(0, 0, 0, "minecraft:stone"), + block(1, 0, 0, "minecraft:air"), + block(0, 1, 0, "minecraft:cave_air"))); + + BakedVariant variant = new ObjectVariantBaker() + .bake("iris_export:object/rock/a", new ObjectSource(volume, 0, TreeRoots.NONE), VariantSpec.builder().build()); + + assertEquals(List.of("minecraft:stone"), states(variant.volume())); + assertEquals(2, variant.volume().sizeX()); + assertEquals(2, variant.volume().sizeY()); + assertEquals(0, variant.baseY()); + } + + @Test + public void plantsWithNothingUnderThemAreLeftOut() { + TemplateVolume reef = new TemplateVolume(3, 4, 3, List.of( + block(1, 0, 1, "minecraft:sea_pickle[pickles=1,waterlogged=false]"), + block(0, 0, 0, "minecraft:tube_coral_block"), + block(0, 1, 0, "minecraft:tube_coral_block"), + block(0, 2, 0, "minecraft:sea_pickle[pickles=2,waterlogged=false]"), + block(2, 2, 2, "minecraft:sea_pickle[pickles=3,waterlogged=false]"), + block(2, 1, 0, "minecraft:tube_coral_fan[waterlogged=false]"), + block(1, 1, 0, "minecraft:tube_coral_wall_fan[facing=east,waterlogged=false]"), + block(1, 2, 0, "minecraft:tube_coral_wall_fan[facing=west,waterlogged=false]"), + block(2, 3, 2, "minecraft:tube_coral_block"))); + + BakedVariant variant = new ObjectVariantBaker() + .bake("iris_export:object/reef/a", new ObjectSource(reef, 0, TreeRoots.NONE), VariantSpec.builder().build()); + + Set kept = new HashSet<>(); + for (TemplateBlock block : variant.volume().blocks()) { + kept.add(block.x() + "," + block.y() + "," + block.z() + " " + block.state().name()); + } + assertEquals(Set.of("1,0,1 minecraft:sea_pickle", "0,0,0 minecraft:tube_coral_block", "0,1,0 minecraft:tube_coral_block", + "0,2,0 minecraft:sea_pickle", "1,1,0 minecraft:tube_coral_wall_fan", "2,3,2 minecraft:tube_coral_block"), kept); + } + + @Test + public void quarterRotationMovesBlocksAndFacing() { + TemplateVolume volume = new TemplateVolume(3, 1, 3, List.of( + block(1, 0, 1, "minecraft:stone"), + block(1, 0, 0, "minecraft:oak_stairs[facing=north,half=bottom,shape=straight,waterlogged=false]"))); + VariantSpec spec = VariantSpec.builder().rotation(new VariantSpec.Rotation(0, 90, 0)).build(); + + BakedVariant variant = new ObjectVariantBaker().bake("iris_export:object/s/a", new ObjectSource(volume, 0, TreeRoots.NONE), spec); + + TemplateBlock stairs = variant.volume().blocks().stream() + .filter(block -> block.state().name().equals("minecraft:oak_stairs")).findFirst().orElseThrow(); + assertEquals("west", stairs.state().properties().get("facing")); + assertEquals(0, stairs.x()); + assertEquals(0, stairs.z()); + } + + @Test + public void verticalFlipFlipsHalves() { + TemplateVolume volume = new TemplateVolume(1, 2, 1, List.of( + block(0, 0, 0, "minecraft:stone"), + block(0, 1, 0, "minecraft:oak_slab[type=bottom,waterlogged=false]"))); + VariantSpec spec = VariantSpec.builder().rotation(new VariantSpec.Rotation(180, 0, 0)).build(); + + BakedVariant variant = new ObjectVariantBaker().bake("iris_export:object/f/a", new ObjectSource(volume, 0, TreeRoots.NONE), spec); + + TemplateBlock slab = variant.volume().blocks().getFirst(); + assertEquals("minecraft:oak_slab", slab.state().name()); + assertEquals("top", slab.state().properties().get("type")); + assertEquals(0, slab.y()); + } + + @Test + public void scaleGrowsTheTemplate() { + TemplateVolume volume = new TemplateVolume(2, 2, 2, List.of( + block(0, 0, 0, "minecraft:oak_log[axis=y]"), + block(1, 1, 1, "minecraft:oak_log[axis=y]"))); + VariantSpec scaled = VariantSpec.builder().scale(2).build(); + + BakedVariant big = new ObjectVariantBaker().bake("iris_export:object/l/a", new ObjectSource(volume, 0, TreeRoots.NONE), scaled); + + assertEquals(16, big.volume().blocks().size()); + assertEquals(4, big.volume().sizeY()); + } + + @Test + public void deterministicEditsBakeAndChanceEditsRoll() { + List blocks = new ArrayList<>(); + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + blocks.add(block(x, 0, z, "minecraft:cobblestone")); + } + } + TemplateVolume floor = new TemplateVolume(16, 1, 16, blocks); + ObjectEdit always = new ObjectEdit(List.of(VanillaBlockState.parse("minecraft:cobblestone")), false, + List.of(new WeightedState(VanillaBlockState.parse("minecraft:stone_bricks"), 1)), 1); + ObjectEdit half = new ObjectEdit(List.of(VanillaBlockState.parse("minecraft:stone_bricks")), false, + List.of(new WeightedState(VanillaBlockState.parse("minecraft:mossy_stone_bricks"), 1)), 0.5); + + BakedVariant variant = new ObjectVariantBaker().bake("iris_export:object/floor/a", new ObjectSource(floor, 0, TreeRoots.NONE), + VariantSpec.builder().edits(List.of(always, half)).build()); + + Set names = new HashSet<>(); + int mossy = 0; + for (TemplateBlock block : variant.volume().blocks()) { + names.add(block.state().name()); + if (block.state().name().equals("minecraft:mossy_stone_bricks")) { + mossy++; + } + } + assertEquals(Set.of("minecraft:stone_bricks", "minecraft:mossy_stone_bricks"), names); + assertTrue(mossy > 96 && mossy < 160); + } + + @Test + public void basesAlignAcrossVariants() { + BakedVariant low = new BakedVariant("iris_export:object/a/low", new TemplateVolume(1, 3, 1, List.of(block(0, 0, 0, "minecraft:stone"))), -2, TreeRoots.NONE); + BakedVariant flat = new BakedVariant("iris_export:object/a/flat", new TemplateVolume(1, 1, 1, List.of(block(0, 0, 0, "minecraft:stone"))), 0, TreeRoots.NONE); + + List aligned = ObjectVariantBaker.alignBases(List.of(low, flat)); + + assertEquals(low, aligned.get(0)); + assertEquals(-2, aligned.get(1).baseY()); + assertEquals("iris_export:object/a/flat_lift2", aligned.get(1).id()); + assertEquals(2, aligned.get(1).volume().blocks().getFirst().y()); + assertEquals(3, aligned.get(1).volume().sizeY()); + assertFalse(aligned.get(1).volume().blocks().isEmpty()); + } + + @Test + public void rootsSurviveAnUnchangedBake() { + TemplateVolume volume = new TemplateVolume(3, 2, 3, List.of( + block(1, 0, 1, "minecraft:oak_wood[axis=y]"), + block(2, 0, 1, "minecraft:oak_wood[axis=y]"), + block(1, 1, 1, "minecraft:oak_log[axis=y]"))); + TreeRoots roots = new TreeRoots(List.of(block(1, 0, 1, "minecraft:oak_wood[axis=y]"), + block(2, 0, 1, "minecraft:oak_wood[axis=y]")), 3); + + BakedVariant variant = new ObjectVariantBaker() + .bake("iris_export:object/tree/a", new ObjectSource(volume, 0, roots), VariantSpec.builder().build()); + + assertEquals(roots, variant.roots()); + } + + @Test + public void rootsTurnWithTheirBlocks() { + TemplateVolume volume = new TemplateVolume(3, 2, 3, List.of( + block(1, 0, 1, "minecraft:oak_wood[axis=y]"), + block(2, 0, 1, "minecraft:spruce_wood[axis=x]"), + block(1, 1, 1, "minecraft:oak_log[axis=y]"))); + TreeRoots roots = new TreeRoots(List.of(block(2, 0, 1, "minecraft:spruce_wood[axis=x]")), 2); + VariantSpec spec = VariantSpec.builder().rotation(new VariantSpec.Rotation(0, 90, 0)).build(); + + BakedVariant variant = new ObjectVariantBaker().bake("iris_export:object/tree/b", new ObjectSource(volume, 0, roots), spec); + + TemplateBlock moved = variant.volume().blocks().stream() + .filter(block -> block.state().name().equals("minecraft:spruce_wood")).findFirst().orElseThrow(); + assertEquals(1, variant.roots().cells().size()); + TemplateBlock root = variant.roots().cells().getFirst(); + assertEquals(List.of(moved.x(), moved.y(), moved.z()), List.of(root.x(), root.y(), root.z())); + assertEquals("z", root.state().properties().get("axis")); + assertEquals(2, variant.roots().reach()); + } + + @Test + public void scaledRootsKeepTheLowestCellPerColumnAndStretchTheirReach() { + TemplateVolume volume = new TemplateVolume(2, 2, 1, List.of( + block(0, 0, 0, "minecraft:oak_wood[axis=y]"), + block(0, 1, 0, "minecraft:oak_log[axis=y]"), + block(1, 1, 0, "minecraft:oak_log[axis=y]"))); + TreeRoots roots = new TreeRoots(List.of(block(0, 0, 0, "minecraft:oak_wood[axis=y]")), 3); + + BakedVariant variant = new ObjectVariantBaker().bake("iris_export:object/tree/c", new ObjectSource(volume, 0, roots), + VariantSpec.builder().scale(2).build()); + + assertEquals(6, variant.roots().reach()); + assertEquals(4, variant.roots().cells().size()); + for (TemplateBlock cell : variant.roots().cells()) { + assertEquals(0, cell.y()); + assertEquals("minecraft:oak_wood", cell.state().name()); + } + } + + @Test + public void alignedBasesLiftRoots() { + TreeRoots roots = new TreeRoots(List.of(block(0, 0, 0, "minecraft:oak_wood[axis=y]")), 2); + BakedVariant low = new BakedVariant("iris_export:object/r/low", new TemplateVolume(1, 3, 1, List.of(block(0, 0, 0, "minecraft:stone"))), -2, TreeRoots.NONE); + BakedVariant rooted = new BakedVariant("iris_export:object/r/rooted", new TemplateVolume(1, 1, 1, + List.of(block(0, 0, 0, "minecraft:oak_wood[axis=y]"))), 0, roots); + + BakedVariant lifted = ObjectVariantBaker.alignBases(List.of(low, rooted)).get(1); + + assertEquals(2, lifted.roots().cells().getFirst().y()); + assertEquals(2, lifted.roots().reach()); + } + + private static TemplateVolume tree() { + List blocks = new ArrayList<>(); + for (int y = 0; y < 5; y++) { + blocks.add(block(2, y, 2, "minecraft:oak_log[axis=y]")); + } + for (int x = 0; x < 5; x++) { + for (int z = 0; z < 5; z++) { + if (x != 2 || z != 2) { + blocks.add(block(x, 3, z, "minecraft:oak_leaves[distance=1,persistent=false,waterlogged=false]")); + } + } + } + return new TemplateVolume(5, 5, 5, blocks); + } + + private static TemplateBlock block(int x, int y, int z, String state) { + return new TemplateBlock(x, y, z, VanillaBlockState.parse(state), null); + } + + private static List states(TemplateVolume volume) { + List states = new ArrayList<>(); + for (TemplateBlock block : volume.blocks()) { + states.add(block.state().canonical()); + } + return states; + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/object/PlacementCompilerTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/object/PlacementCompilerTest.java new file mode 100644 index 000000000..c34573989 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/object/PlacementCompilerTest.java @@ -0,0 +1,966 @@ +package art.arcane.iris.pack.datapack.export.compile.object; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.TemplateBlock; +import art.arcane.iris.pack.datapack.export.TemplateVolume; +import art.arcane.iris.pack.datapack.export.VanillaBlockState; +import art.arcane.iris.pack.datapack.export.compile.StageTestPacks; +import art.arcane.iris.pack.datapack.export.compile.decoration.GenerationStep; +import art.arcane.iris.pack.datapack.export.ir.DimensionDef; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.datapack.export.ir.JsonResource; +import art.arcane.iris.pack.datapack.export.lower.ExportCapabilities; +import art.arcane.iris.structure.object.IrisObjectPlacement; +import art.arcane.iris.structure.object.IrisObjectTranslate; +import art.arcane.iris.structure.object.ObjectPlaceMode; +import art.arcane.volmlib.util.collection.KList; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public class PlacementCompilerTest { + private static final String NAMESPACE = "iris_export"; + private static final int WINDOW_MIN = -16; + private static final int WINDOW_MAX = 31; + private static final int CHUNK = 16; + private static final DimensionDef DIMENSION = new DimensionDef("main", -64, 384, 384, 63, + "minecraft:stone", "minecraft:water", new JsonObject()); + + @Test + public void narrowObjectUsesTemplateFeature() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.CENTER_HEIGHT, 0.5, 3); + placement.setTranslate(new IrisObjectTranslate().setY(2)); + List diagnostics = new ArrayList<>(); + + CompiledPlacement compiled = compiler(DataVersion.V26_3).compile(input(placement, objects(5, 9), List.of()), diagnostics); + + assertSame(GenerationStep.SURFACE_STRUCTURES, compiled.step()); + assertEquals(1, compiled.placedFeatures().size()); + assertTrue(compiled.structures().isEmpty()); + JsonObject feature = feature(compiled, "worldgen/feature/"); + assertEquals("minecraft:template", feature.get("type").getAsString()); + JsonArray templates = feature.getAsJsonArray("templates"); + assertEquals(2, templates.size()); + assertEquals(templates.get(0).getAsJsonObject().get("weight"), templates.get(1).getAsJsonObject().get("weight")); + assertFalse(templates.get(0).getAsJsonObject().getAsJsonObject("data").has("rotations")); + JsonObject placed = placed(compiled); + assertEquals(List.of("minecraft:random_chance", "minecraft:count", "minecraft:in_square", "minecraft:heightmap", + "minecraft:block_predicate_filter", "minecraft:block_predicate_filter", "minecraft:offset", "minecraft:biome"), types(placed)); + JsonArray modifiers = placed.getAsJsonArray("placement"); + assertEquals(0.5, modifiers.get(0).getAsJsonObject().get("chance").getAsDouble(), 1e-6); + assertEquals(3, modifiers.get(1).getAsJsonObject().get("count").getAsInt()); + assertEquals("OCEAN_FLOOR_WG", modifiers.get(3).getAsJsonObject().get("heightmap").getAsString()); + assertEquals(1, modifiers.get(6).getAsJsonObject().get("y").getAsInt()); + assertTrue(StageTestPacks.ofType(placed, "minecraft:volume_match").isEmpty()); + assertTrue(diagnostics.isEmpty()); + } + + @Test + public void surfaceObjectsStandOnTerrainInsteadOfCanopiesOrWater() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.CENTER_HEIGHT, 1, 1); + + JsonObject placed = placed(compiler(DataVersion.V26_3).compile(input(placement, objects(5), List.of()), new ArrayList<>())); + JsonObject filter = filters(placed).getFirst(); + + assertEquals("OCEAN_FLOOR_WG", StageTestPacks.ofType(placed, "minecraft:heightmap").getFirst().get("heightmap").getAsString()); + List rejected = new ArrayList<>(); + for (JsonObject negated : StageTestPacks.ofType(filter, "minecraft:not")) { + JsonObject inner = negated.getAsJsonObject("predicate"); + rejected.add(inner.has("tag") ? inner.get("tag").getAsString() : inner.get("type").getAsString()); + } + assertTrue(rejected.toString(), rejected.contains("minecraft:leaves")); + assertTrue(rejected.toString(), rejected.contains("minecraft:logs")); + assertTrue(rejected.toString(), rejected.contains("minecraft:matching_fluids")); + assertFalse(StageTestPacks.ofType(filter, "minecraft:solid").isEmpty()); + } + + @Test + public void supportFollowsTheObjectBaseInsteadOfAFlatPlane() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.CENTER_HEIGHT, 1, 1); + placement.setSurfaceSupportBuffer(1); + placement.setSurfaceSupportDepth(2); + TemplateBlock trunk = new TemplateBlock(4, 0, 4, VanillaBlockState.parse("minecraft:oak_log[axis=y]"), null); + TemplateBlock crown = new TemplateBlock(0, 5, 0, VanillaBlockState.parse("minecraft:oak_leaves"), null); + BakedVariant tree = new BakedVariant(NAMESPACE + ":object/tree/v", new TemplateVolume(9, 6, 9, List.of(trunk, crown)), 0, TreeRoots.NONE); + List objects = List.of(new ObjectTemplates("tree", List.of(new TemplateChoice(tree, List.of(0, 1, 2, 3), 1)))); + + JsonObject support = filters(placed(compiler(DataVersion.V26_3).compile(input(placement, objects, List.of()), new ArrayList<>()))).get(1); + + int reach = 0; + int depth = 0; + for (JsonObject solid : StageTestPacks.ofType(support, "minecraft:solid")) { + JsonArray offset = solid.getAsJsonArray("offset"); + reach = Math.max(reach, Math.max(Math.abs(offset.get(0).getAsInt()), Math.abs(offset.get(2).getAsInt()))); + depth = Math.min(depth, offset.get(1).getAsInt()); + } + assertEquals(1, reach); + assertEquals(-16, depth); + assertFalse(StageTestPacks.ofType(support, "minecraft:any_of").isEmpty()); + } + + @Test + public void supportAcceptsSteepGroundAnywhereInTheScanWindow() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.CENTER_HEIGHT, 1, 1); + placement.setSurfaceSupportBuffer(2); + placement.setSurfaceSupportDepth(2); + TemplateBlock trunk = new TemplateBlock(0, 0, 4, VanillaBlockState.parse("minecraft:spruce_log[axis=y]"), null); + BakedVariant tree = new BakedVariant(NAMESPACE + ":object/tree/v", new TemplateVolume(9, 20, 9, List.of(trunk)), 0, TreeRoots.NONE); + List objects = List.of(new ObjectTemplates("tree", List.of(new TemplateChoice(tree, List.of(0, 1, 2, 3), 1)))); + + JsonObject support = filters(placed(compiler(DataVersion.V26_3).compile(input(placement, objects, List.of()), new ArrayList<>()))).get(1); + + Set corners = new HashSet<>(); + for (JsonObject solid : StageTestPacks.ofType(support, "minecraft:solid")) { + JsonArray offset = solid.getAsJsonArray("offset"); + corners.add(offset.get(0).getAsInt() + "," + offset.get(2).getAsInt()); + } + assertEquals(Set.of("-6,-6", "-6,6", "6,-6", "6,6"), corners); + Set steepSlope = new HashSet<>(); + Set thinLedge = new HashSet<>(); + for (String corner : corners) { + for (int y = -12; y >= -40; y--) { + steepSlope.add(cell(corner, y)); + } + thinLedge.add(cell(corner, -12)); + } + JsonObject predicate = support.getAsJsonObject("predicate"); + assertTrue("ground 11 blocks below every corner must support the object", supported(predicate, steepSlope)); + assertFalse("a one block ledge is thinner than surfaceSupportDepth", supported(predicate, thinLedge)); + assertFalse("open air below every corner must not support the object", supported(predicate, Set.of())); + } + + @Test + public void stiltedObjectsRelyOnTheirStiltsInsteadOfCornerSupport() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.FAST_MIN_STILT, 1, 1); + PlacementInput input = new PlacementInput(placement, PlacementInput.Anchor.SURFACE, true, objects(25), List.of(), + List.of(VanillaBlockState.parse("minecraft:dark_oak_wood[axis=y]")), "willow"); + + List filters = filters(placed(compiler(DataVersion.V26_3).compile(input, new ArrayList<>()))); + + assertEquals(1, filters.size()); + assertFalse(StageTestPacks.ofType(filters.getFirst(), "minecraft:matching_block_tag").isEmpty()); + } + + @Test + public void slopeConditionRejectsSteepAnchors() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.CENTER_HEIGHT, 1, 1); + placement.setRequireSurfaceSupport(false); + placement.getSlopeCondition().setMaximumSlope(1.5); + List diagnostics = new ArrayList<>(); + + List filters = filters(placed(compiler(DataVersion.V26_3).compile(input(placement, objects(5), List.of()), diagnostics))); + + assertTrue(diagnostics.stream().anyMatch(diagnostic -> diagnostic.limit() == ExportLimit.SLOPE)); + assertEquals(2, filters.size()); + JsonObject slope = filters.get(1).getAsJsonObject("predicate"); + Set flat = new HashSet<>(); + Set cliff = new HashSet<>(); + for (int x = -3; x <= 3; x++) { + for (int z = -3; z <= 3; z++) { + for (int y = -20; y <= -1; y++) { + flat.add(x + "," + y + "," + z); + cliff.add(x + "," + y + "," + z); + } + } + } + for (int y = 0; y <= 4; y++) { + cliff.add("3," + y + ",0"); + } + assertTrue("flat ground must pass the slope limit", supported(slope, flat)); + assertFalse("a wall 3 blocks higher 3 blocks away is steeper than slope 1.5", supported(slope, cliff)); + } + + @Test + public void forcePlaceIgnoresSlopeCondition() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.CENTER_HEIGHT, 1, 1); + placement.setForcePlace(true); + placement.getSlopeCondition().setMinimumSlope(2); + + List filters = filters(placed(compiler(DataVersion.V26_3).compile(input(placement, objects(5), List.of()), new ArrayList<>()))); + + assertEquals(1, filters.size()); + } + + private static String cell(String corner, int y) { + String[] parts = corner.split(","); + return parts[0] + "," + y + "," + parts[1]; + } + + private static boolean supported(JsonObject predicate, Set solids) { + String type = predicate.get("type").getAsString(); + return switch (type) { + case "minecraft:solid" -> { + JsonArray offset = predicate.getAsJsonArray("offset"); + yield solids.contains(offset.get(0).getAsInt() + "," + offset.get(1).getAsInt() + "," + offset.get(2).getAsInt()); + } + case "minecraft:all_of" -> { + for (JsonElement child : predicate.getAsJsonArray("predicates")) { + if (!supported(child.getAsJsonObject(), solids)) { + yield false; + } + } + yield true; + } + case "minecraft:any_of" -> { + for (JsonElement child : predicate.getAsJsonArray("predicates")) { + if (supported(child.getAsJsonObject(), solids)) { + yield true; + } + } + yield false; + } + case "minecraft:not" -> !supported(predicate.getAsJsonObject("predicate"), solids); + default -> throw new AssertionError("Unexpected support predicate " + type); + }; + } + + @Test + public void underwaterObjectsNeedWaterAndStayBelowTheSurface() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.CENTER_HEIGHT, 1, 1); + placement.setUnderwater(true); + placement.setTranslate(new IrisObjectTranslate().setY(2)); + + JsonObject placed = placed(compiler(DataVersion.V26_3).compile(input(placement, columns(7), List.of()), new ArrayList<>())); + + List filters = filters(placed); + assertEquals(2, filters.size()); + assertTrue(StageTestPacks.ofType(filters.getFirst(), "minecraft:not").stream() + .noneMatch(negated -> !StageTestPacks.ofType(negated, "minecraft:matching_fluids").isEmpty())); + JsonObject water = StageTestPacks.ofType(filters.getFirst(), "minecraft:matching_fluids").getFirst(); + assertFalse(water.toString(), water.has("offset")); + assertEquals(DIMENSION.fluidHeight() - 3 * (7 / 2) - 2, ceiling(placed)); + } + + @Test + public void underwaterObjectsOfDifferentHeightsKeepTheirOwnDepth() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.CENTER_HEIGHT, 0.6, 2); + placement.setUnderwater(true); + + CompiledPlacement compiled = compiler(DataVersion.V26_3).compile(input(placement, columns(4, 9, 4), List.of()), new ArrayList<>()); + + assertEquals(2, compiled.placedFeatures().size()); + List ceilings = new ArrayList<>(); + double perChunk = 0; + for (JsonResource resource : compiled.resources()) { + if (resource.path().contains("worldgen/placed_feature/")) { + JsonObject placed = resource.json().getAsJsonObject(); + ceilings.add(ceiling(placed)); + perChunk += expectedPerChunk(placed); + } + } + ceilings.sort(Integer::compareTo); + assertEquals(List.of(DIMENSION.fluidHeight() - 3 * (9 / 2), DIMENSION.fluidHeight() - 3 * (4 / 2)), ceilings); + assertEquals(0.6 * 2, perChunk, 1e-6); + } + + @Test + public void onwaterObjectsStandOnTheWaterSurface() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.CENTER_HEIGHT, 1, 1); + placement.setOnwater(true); + + JsonObject placed = placed(compiler(DataVersion.V26_3).compile(input(placement, objects(5), List.of()), new ArrayList<>())); + + assertEquals("WORLD_SURFACE_WG", StageTestPacks.ofType(placed, "minecraft:heightmap").getFirst().get("heightmap").getAsString()); + List filters = filters(placed); + assertEquals(1, filters.size()); + assertTrue(StageTestPacks.ofType(filters.getFirst(), "minecraft:solid").isEmpty()); + JsonObject water = StageTestPacks.ofType(filters.getFirst(), "minecraft:matching_fluids").getFirst(); + assertEquals("[0,-1,0]", water.get("offset").toString()); + assertTrue(StageTestPacks.ofType(placed, "minecraft:not").isEmpty()); + } + + @Test + public void onwaterPaintDropsOntoTheWaterSurface() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.PAINT, 1, 1); + placement.setOnwater(true); + placement.setTranslate(new IrisObjectTranslate().setY(1)); + + CompiledPlacement compiled = compiler(DataVersion.V26_3).compile(input(placement, objects(1), List.of()), new ArrayList<>()); + + JsonObject gravity = feature(compiled, "worldgen/feature/").getAsJsonObject("processors").getAsJsonArray("processors").get(0).getAsJsonObject(); + assertEquals("WORLD_SURFACE_WG", gravity.get("heightmap").getAsString()); + assertEquals(0, gravity.get("offset").getAsInt()); + assertEquals("WORLD_SURFACE_WG", StageTestPacks.ofType(placed(compiled), "minecraft:heightmap").getFirst().get("heightmap").getAsString()); + } + + @Test + public void wideObjectBecomesSinglePieceStructure() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.CENTER_HEIGHT, 0.02, 1); + List diagnostics = new ArrayList<>(); + + CompiledPlacement compiled = compiler(DataVersion.V26_3).compile(input(placement, objects(40), List.of()), diagnostics); + + assertTrue(compiled.placedFeatures().isEmpty()); + assertEquals(1, compiled.structures().size()); + JsonObject structure = resource(compiled, "worldgen/structure/"); + assertEquals("minecraft:jigsaw", structure.get("type").getAsString()); + assertEquals(compiled.structures().getFirst(), structure.get("start_pool").getAsString()); + assertTrue(structure.get("biomes").getAsString().startsWith("#iris_export:has_structure/object/")); + assertEquals("WORLD_SURFACE_WG", structure.get("project_start_to_heightmap").getAsString()); + JsonObject pool = resource(compiled, "worldgen/template_pool/"); + JsonArray elements = pool.getAsJsonArray("elements"); + assertEquals(1, elements.size()); + assertEquals("minecraft:single_pool_element", elements.get(0).getAsJsonObject().getAsJsonObject("element").get("element_type").getAsString()); + JsonObject set = resource(compiled, "worldgen/structure_set/"); + JsonObject spread = set.getAsJsonObject("placement"); + int spacing = spread.get("spacing").getAsInt(); + double frequency = spread.has("frequency") ? spread.get("frequency").getAsDouble() : 1; + assertEquals(0.02, frequency / (spacing * spacing), 0.001); + assertTrue(spread.get("separation").getAsInt() < spacing); + assertEquals(1, diagnostics.size()); + assertSame(ExportLimit.WIDE_OBJECT, diagnostics.getFirst().limit()); + } + + @Test + public void mixedPlacementSplitsNarrowAndWideShares() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.CENTER_HEIGHT, 0.4, 2); + + CompiledPlacement compiled = compiler(DataVersion.V26_3).compile(input(placement, objects(8, 50), List.of()), new ArrayList<>()); + + assertEquals(1, compiled.placedFeatures().size()); + assertEquals(1, compiled.structures().size()); + assertEquals(0.4 * 2 * 0.5, expectedPerChunk(placed(compiled)), 1e-6); + } + + @Test + public void writeWindowNeverExceeded() { + DataVersion target = DataVersion.V26_3; + for (ObjectPlaceMode mode : List.of(ObjectPlaceMode.CENTER_HEIGHT, ObjectPlaceMode.STILT, ObjectPlaceMode.PAINT, + ObjectPlaceMode.MIN_HEIGHT, ObjectPlaceMode.FAST_MAX_HEIGHT)) { + for (int footprint = 1; footprint <= 40; footprint++) { + for (int translate : List.of(0, 3, -5, 12)) { + IrisObjectPlacement placement = placement(mode, 1, 1); + placement.setTranslate(new IrisObjectTranslate().setX(translate).setZ(-translate)); + List objects = objects(footprint); + CompiledPlacement compiled = compiler(target).compile(input(placement, objects, List.of()), new ArrayList<>()); + if (footprint + 2 * Math.abs(translate) > PlacementCompiler.MAX_FEATURE_WIDTH) { + assertTrue(compiled.placedFeatures().isEmpty()); + continue; + } + assertWithinWindow(compiled, objects, target + " " + mode + " footprint " + footprint + " translate " + translate); + } + } + } + } + + @Test + public void placementCountMatchesExpectation() { + Random random = new Random(1234); + DataVersion target = DataVersion.V26_3; + for (double chance : List.of(1.0, 0.75, 0.3, 0.004)) { + for (int density : List.of(1, 3, 8)) { + IrisObjectPlacement placement = placement(ObjectPlaceMode.CENTER_HEIGHT, chance, density); + CompiledPlacement compiled = compiler(target).compile(input(placement, objects(6), List.of()), new ArrayList<>()); + JsonObject placed = placed(compiled); + double expected = chance * density; + + assertEquals(target + " chance " + chance + " density " + density, expected, expectedPerChunk(placed), expected * 0.05); + double simulated = simulatePerChunk(placed, random, 20_000); + assertEquals(target + " simulated chance " + chance + " density " + density, expected, simulated, + Math.max(expected * 0.05, 4 * Math.sqrt(expected * density / 20_000.0))); + } + } + } + + @Test + public void paintUsesTemplateProcessors() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.PAINT, 1, 1); + + CompiledPlacement compiled = compiler(DataVersion.V26_3).compile(input(placement, objects(9), List.of(rule())), new ArrayList<>()); + + JsonObject feature = feature(compiled, "worldgen/feature/"); + JsonArray processors = feature.getAsJsonObject("processors").getAsJsonArray("processors"); + assertEquals(2, processors.size()); + assertEquals("minecraft:rule", processors.get(0).getAsJsonObject().get("processor_type").getAsString()); + JsonObject gravity = processors.get(1).getAsJsonObject(); + assertEquals("minecraft:gravity", gravity.get("processor_type").getAsString()); + assertEquals("OCEAN_FLOOR_WG", gravity.get("heightmap").getAsString()); + assertEquals(-1, gravity.get("offset").getAsInt()); + } + + @Test + public void stiltsUseOverlayPillarsOn26_3() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.CENTER_STILT, 1, 1); + PlacementInput input = new PlacementInput(placement, PlacementInput.Anchor.SURFACE, false, objects(20), List.of(), + List.of(VanillaBlockState.parse("minecraft:oak_log[axis=y]")), "hut"); + + CompiledPlacement compiled = compiler(DataVersion.V26_3).compile(input, new ArrayList<>()); + + JsonObject feature = feature(compiled, "worldgen/feature/"); + assertEquals("minecraft:overlay", feature.get("type").getAsString()); + JsonArray features = feature.getAsJsonArray("features"); + assertEquals(5, features.size()); + assertEquals("minecraft:template", features.get(0).getAsJsonObject().getAsJsonObject("feature").get("type").getAsString()); + JsonObject tile = features.get(1).getAsJsonObject(); + JsonObject pillar = tile.getAsJsonObject("feature"); + assertEquals("minecraft:single_block_pillar", pillar.get("type").getAsString()); + assertEquals("down", pillar.get("direction").getAsString()); + assertEquals("minecraft:rule_based", pillar.getAsJsonObject("block").get("type").getAsString()); + assertEquals(List.of("minecraft:offset", "minecraft:cuboid", "minecraft:block_predicate_filter"), types(tile)); + } + + @Test + public void caveAnchorScansForFloor() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.CENTER_HEIGHT, 1, 2); + + PlacementInput input = new PlacementInput(placement, PlacementInput.Anchor.CAVE, true, objects(5), List.of(), List.of(), "shroom"); + CompiledPlacement compiled = compiler(DataVersion.V26_3).compile(input, new ArrayList<>()); + + assertSame(GenerationStep.VEGETAL_DECORATION, compiled.step()); + JsonObject placed = placed(compiled); + assertEquals(List.of("minecraft:count", "minecraft:in_square", "minecraft:height_range", "minecraft:environment_scan", "minecraft:biome"), types(placed)); + JsonObject scan = placed.getAsJsonArray("placement").get(3).getAsJsonObject(); + assertEquals("down", scan.get("direction_of_search").getAsString()); + } + + @Test + public void compiledJsonIsDeterministic() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.STILT, 0.3, 2); + + CompiledPlacement first = compiler(DataVersion.V26_3).compile(input(placement, objects(6, 12, 45), List.of(rule())), new ArrayList<>()); + CompiledPlacement second = compiler(DataVersion.V26_3).compile(input(placement, objects(6, 12, 45), List.of(rule())), new ArrayList<>()); + + assertEquals(first, second); + assertEquals(first.resources().toString(), second.resources().toString()); + } + + @Test + public void minHeightScansDownUntilTheWholeFootprintHasGround() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.MIN_HEIGHT, 1, 1); + + CompiledPlacement compiled = compiler(DataVersion.V26_3).compile(input(placement, objects(9), List.of()), new ArrayList<>()); + + JsonObject placed = placed(compiled); + assertEquals(List.of("minecraft:in_square", "minecraft:heightmap", "minecraft:block_predicate_filter", + "minecraft:block_predicate_filter", "minecraft:biome"), types(placed)); + JsonObject feature = feature(compiled, "worldgen/feature/"); + assertEquals("minecraft:weighted_random_selector", feature.get("type").getAsString()); + JsonArray branches = feature.getAsJsonArray("features"); + assertEquals(1, branches.size()); + JsonObject branch = branches.get(0).getAsJsonObject().getAsJsonObject("data"); + assertEquals("minecraft:template", branch.getAsJsonObject("feature").get("type").getAsString()); + assertEquals(List.of("minecraft:environment_scan", "minecraft:offset"), types(branch)); + JsonObject scan = branch.getAsJsonArray("placement").get(0).getAsJsonObject(); + assertEquals("down", scan.get("direction_of_search").getAsString()); + assertEquals(32, scan.get("max_steps").getAsInt()); + assertEquals(List.of("-4,-1,-4..4,-1,4"), boxes(scan)); + JsonObject match = StageTestPacks.ofType(scan, "minecraft:volume_match").getFirst().getAsJsonObject("match"); + assertFalse(StageTestPacks.ofType(match, "minecraft:solid").isEmpty()); + assertFalse(StageTestPacks.ofType(match, "minecraft:matching_fluids").isEmpty()); + assertEquals(-1, branch.getAsJsonArray("placement").get(1).getAsJsonObject().get("y").getAsInt()); + } + + @Test + public void maxHeightScansUpUntilEveryColumnIsClear() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.MAX_HEIGHT, 1, 1); + placement.setUnderwater(true); + + CompiledPlacement compiled = compiler(DataVersion.V26_3).compile(input(placement, objects(9), List.of()), new ArrayList<>()); + + JsonObject scan = branchScan(feature(compiled, "worldgen/feature/"), 0); + assertEquals("up", scan.get("direction_of_search").getAsString()); + assertEquals(List.of("-4,0,-4..4,0,4"), boxes(scan)); + JsonObject match = StageTestPacks.ofType(scan, "minecraft:volume_match").getFirst().getAsJsonObject("match"); + assertEquals("minecraft:not", match.get("type").getAsString()); + assertTrue(StageTestPacks.ofType(match, "minecraft:matching_fluids").isEmpty()); + } + + @Test + public void fastHeightModesProbeTheRuntimeSamplePoints() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.FAST_MIN_HEIGHT, 1, 1); + + CompiledPlacement compiled = compiler(DataVersion.V26_3).compile(input(placement, objects(9), List.of()), new ArrayList<>()); + + JsonObject scan = branchScan(feature(compiled, "worldgen/feature/"), 0); + assertEquals(List.of("-4,-1,-4..-4,-1,-4", "-4,-1,1..-4,-1,1", "1,-1,-4..1,-1,-4", "1,-1,1..1,-1,1"), boxes(scan)); + } + + @Test + public void heightScanCoversEveryAllowedQuarter() { + TemplateBlock block = new TemplateBlock(0, 0, 0, VanillaBlockState.parse("minecraft:stone"), null); + BakedVariant wall = new BakedVariant(NAMESPACE + ":object/wall/v", new TemplateVolume(5, 4, 9, List.of(block)), 0, TreeRoots.NONE); + IrisObjectPlacement placement = placement(ObjectPlaceMode.MIN_HEIGHT, 1, 1); + + JsonObject turning = feature(compiler(DataVersion.V26_3).compile(input(placement, List.of(new ObjectTemplates("wall", + List.of(new TemplateChoice(wall, List.of(0, 1, 2, 3), 1)))), List.of()), new ArrayList<>()), "worldgen/feature/"); + JsonObject fixed = feature(compiler(DataVersion.V26_3).compile(input(placement, List.of(new ObjectTemplates("wall", + List.of(new TemplateChoice(wall, List.of(0), 1)))), List.of()), new ArrayList<>()), "worldgen/feature/"); + + assertEquals(List.of("-2,-1,-4..2,-1,4", "-4,-1,-2..4,-1,2"), boxes(branchScan(turning, 0))); + assertEquals(List.of("-2,-1,-4..2,-1,4"), boxes(branchScan(fixed, 0))); + } + + @Test + public void everyTemplateAnchorsOnItsOwnFootprint() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.MIN_HEIGHT, 1, 1); + + JsonObject feature = feature(compiler(DataVersion.V26_3).compile(input(placement, objects(5, 9), List.of()), new ArrayList<>()), + "worldgen/feature/"); + + JsonArray branches = feature.getAsJsonArray("features"); + assertEquals(2, branches.size()); + assertEquals(branches.get(0).getAsJsonObject().get("weight"), branches.get(1).getAsJsonObject().get("weight")); + assertEquals(List.of("-2,-1,-2..2,-1,2"), boxes(branchScan(feature, 0))); + assertEquals(List.of("-4,-1,-4..4,-1,4"), boxes(branchScan(feature, 1))); + } + + @Test + public void maxStiltsAnchorHighThenStilt() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.STILT, 1, 1); + PlacementInput input = new PlacementInput(placement, PlacementInput.Anchor.SURFACE, false, objects(20), List.of(), + List.of(VanillaBlockState.parse("minecraft:oak_log[axis=y]")), "hut"); + + JsonObject feature = feature(compiler(DataVersion.V26_3).compile(input, new ArrayList<>()), "worldgen/feature/"); + + JsonObject branch = feature.getAsJsonArray("features").get(0).getAsJsonObject().getAsJsonObject("data"); + assertEquals("up", branchScan(feature, 0).get("direction_of_search").getAsString()); + JsonObject stilts = branch.getAsJsonObject("feature"); + assertEquals("minecraft:overlay", stilts.get("type").getAsString()); + assertEquals(5, stilts.getAsJsonArray("features").size()); + assertEquals("minecraft:single_block_pillar", stilts.getAsJsonArray("features").get(1).getAsJsonObject() + .getAsJsonObject("feature").get("type").getAsString()); + } + + @Test + public void groundedTreesExtendRootsToTheTerrain() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.CENTER_HEIGHT, 1, 1); + + CompiledPlacement compiled = compiler(DataVersion.V26_3).compile(tree(placement, rootedTree()), new ArrayList<>()); + + JsonObject feature = feature(compiled, "worldgen/feature/"); + assertEquals("minecraft:weighted_random_selector", feature.get("type").getAsString()); + JsonArray branches = feature.getAsJsonArray("features"); + assertEquals(4, branches.size()); + List rotations = new ArrayList<>(); + List roots = new ArrayList<>(); + List gaps = new ArrayList<>(); + String pillar = null; + for (JsonElement element : branches) { + assertEquals(branches.get(0).getAsJsonObject().get("weight"), element.getAsJsonObject().get("weight")); + JsonObject branch = element.getAsJsonObject().getAsJsonObject("data"); + assertEquals(List.of("minecraft:offset", "minecraft:block_predicate_filter"), types(branch)); + gaps.addAll(boxes(branch.getAsJsonArray("placement").get(1))); + JsonObject overlay = branch.getAsJsonObject("feature"); + assertEquals("minecraft:overlay", overlay.get("type").getAsString()); + JsonArray parts = overlay.getAsJsonArray("features"); + assertEquals(2, parts.size()); + JsonObject template = parts.get(0).getAsJsonObject().getAsJsonObject("feature"); + rotations.add(template.getAsJsonArray("templates").get(0).getAsJsonObject().getAsJsonObject("data") + .getAsJsonArray("rotations").get(0).getAsString()); + JsonObject root = parts.get(1).getAsJsonObject(); + pillar = root.get("feature").getAsString(); + JsonObject offset = root.getAsJsonArray("placement").get(0).getAsJsonObject(); + roots.add(offset.get("x").getAsInt() + "," + offset.get("y").getAsInt() + "," + offset.get("z").getAsInt()); + } + assertEquals(List.of("none", "counterclockwise_90", "180", "clockwise_90"), rotations); + assertEquals(List.of("1,-1,0", "0,-1,-1", "-1,-1,0", "0,-1,1"), roots); + assertEquals(List.of("1,-4,0..1,-1,0", "0,-4,-1..0,-1,-1", "-1,-4,0..-1,-1,0", "0,-4,1..0,-1,1"), gaps); + JsonObject shared = resourceAt(compiled, pillar); + JsonObject gated = shared.getAsJsonArray("features").get(0).getAsJsonObject(); + assertEquals("minecraft:block_predicate_filter", gated.getAsJsonArray("placement").get(0).getAsJsonObject().get("type").getAsString()); + JsonObject column = gated.getAsJsonObject("feature"); + assertEquals("minecraft:single_block_pillar", column.get("type").getAsString()); + assertEquals("down", column.get("direction").getAsString()); + assertEquals("minecraft:spruce_wood", column.getAsJsonObject("block").get("id").getAsString()); + JsonObject cap = column.getAsJsonObject("cap_feature"); + assertEquals("minecraft:simple_block", cap.getAsJsonObject("feature").get("type").getAsString()); + assertEquals(-1, cap.getAsJsonArray("placement").get(0).getAsJsonObject().get("y").getAsInt()); + } + + @Test + public void groundedRotationBranchesKeepTheTemplateOdds() { + BakedVariant turning = rootedTree().getFirst().choices().getFirst().variant(); + BakedVariant fixed = new BakedVariant(NAMESPACE + ":object/tree/fixed", turning.volume(), 0, turning.roots()); + List objects = List.of(new ObjectTemplates("tree", List.of( + new TemplateChoice(turning, List.of(0, 1, 2, 3), 1), + new TemplateChoice(fixed, List.of(2), 1)))); + + JsonObject feature = feature(compiler(DataVersion.V26_3).compile(tree(placement(ObjectPlaceMode.CENTER_HEIGHT, 1, 1), objects), + new ArrayList<>()), "worldgen/feature/"); + + List weights = new ArrayList<>(); + for (JsonElement branch : feature.getAsJsonArray("features")) { + weights.add(branch.getAsJsonObject().get("weight").getAsInt()); + } + assertEquals(List.of(1, 1, 1, 1, 4), weights); + } + + @Test + public void groundedTreesReplaceStiltsWithRoots() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.CENTER_STILT, 1, 1); + + JsonObject feature = feature(compiler(DataVersion.V26_3).compile(tree(placement, rootedTree()), new ArrayList<>()), "worldgen/feature/"); + + for (JsonElement element : feature.getAsJsonArray("features")) { + JsonObject overlay = element.getAsJsonObject().getAsJsonObject("data").getAsJsonObject("feature"); + assertTrue(StageTestPacks.ofType(overlay, "minecraft:cuboid").isEmpty()); + assertEquals(2, overlay.getAsJsonArray("features").size()); + } + } + + @Test + public void forcedTreesKeepTheirBuiltRoots() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.CENTER_HEIGHT, 1, 1); + placement.setForcePlace(true); + + JsonObject feature = feature(compiler(DataVersion.V26_3).compile(tree(placement, rootedTree()), new ArrayList<>()), "worldgen/feature/"); + + assertEquals("minecraft:template", feature.get("type").getAsString()); + } + + @Test + public void rootedHeightScansStayInsideTheFeatureWindow() { + IrisObjectPlacement placement = placement(ObjectPlaceMode.FAST_MAX_HEIGHT, 1, 1); + placement.setTranslate(new IrisObjectTranslate().setX(3).setZ(-3)); + List objects = rootedTree(); + + CompiledPlacement compiled = compiler(DataVersion.V26_3).compile(tree(placement, objects), new ArrayList<>()); + + assertWithinWindow(compiled, objects, "rooted fast max"); + } + + private static List rootedTree() { + List blocks = new ArrayList<>(); + for (int y = 1; y < 4; y++) { + blocks.add(new TemplateBlock(2, y, 2, VanillaBlockState.parse("minecraft:oak_log[axis=y]"), null)); + } + TemplateBlock root = new TemplateBlock(3, 0, 2, VanillaBlockState.parse("minecraft:spruce_wood[axis=y]"), null); + blocks.add(root); + BakedVariant variant = new BakedVariant(NAMESPACE + ":object/tree/rooted", new TemplateVolume(5, 4, 5, blocks), 0, + new TreeRoots(List.of(root), 3)); + return List.of(new ObjectTemplates("tree", List.of(new TemplateChoice(variant, List.of(0, 1, 2, 3), 1)))); + } + + private static PlacementInput tree(IrisObjectPlacement placement, List objects) { + return new PlacementInput(placement, PlacementInput.Anchor.SURFACE, true, objects, List.of(), + List.of(VanillaBlockState.parse("minecraft:oak_log[axis=y]")), "tree"); + } + + private static JsonObject branchScan(JsonObject selector, int index) { + JsonObject branch = selector.getAsJsonArray("features").get(index).getAsJsonObject().getAsJsonObject("data"); + return StageTestPacks.ofType(branch, "minecraft:environment_scan").getFirst(); + } + + private static List boxes(JsonElement root) { + List boxes = new ArrayList<>(); + for (JsonObject volume : StageTestPacks.ofType(root, "minecraft:volume_match")) { + JsonArray min = volume.getAsJsonArray("min"); + JsonArray max = volume.getAsJsonArray("max"); + boxes.add(min.get(0).getAsInt() + "," + min.get(1).getAsInt() + "," + min.get(2).getAsInt() + ".." + + max.get(0).getAsInt() + "," + max.get(1).getAsInt() + "," + max.get(2).getAsInt()); + } + return boxes; + } + + private static JsonObject resourceAt(CompiledPlacement compiled, String id) { + String path = "/worldgen/feature/" + id.substring(NAMESPACE.length() + 1) + ".json"; + for (JsonResource resource : compiled.resources()) { + if (resource.path().endsWith(path)) { + return resource.json().getAsJsonObject(); + } + } + throw new AssertionError("No resource for " + id); + } + + private static List filters(JsonObject placed) { + List filters = new ArrayList<>(); + for (JsonElement element : placed.getAsJsonArray("placement")) { + if (element.getAsJsonObject().get("type").getAsString().equals("minecraft:block_predicate_filter")) { + filters.add(element.getAsJsonObject()); + } + } + return filters; + } + + private static void assertWithinWindow(CompiledPlacement compiled, List objects, String label) { + Map footprints = new HashMap<>(); + for (ObjectTemplates object : objects) { + for (TemplateChoice choice : object.choices()) { + footprints.put(choice.variant().id(), choice.variant().volume().footprint()); + } + } + int[] offset = probeWindow(placed(compiled).getAsJsonArray("placement"), 0, 0, label); + JsonObject feature = feature(compiled, null); + if (!feature.get("type").getAsString().equals("minecraft:weighted_random_selector")) { + assertFeatureWithinWindow(feature, footprints, offset[0], offset[1], label); + return; + } + for (JsonElement entry : feature.getAsJsonArray("features")) { + JsonObject branch = entry.getAsJsonObject().getAsJsonObject("data"); + int[] branchOffset = probeWindow(branch.getAsJsonArray("placement"), offset[0], offset[1], label); + assertFeatureWithinWindow(branch.getAsJsonObject("feature"), footprints, branchOffset[0], branchOffset[1], label); + } + } + + private static int[] probeWindow(JsonArray modifiers, int startX, int startZ, String label) { + int offsetX = startX; + int offsetZ = startZ; + for (JsonElement element : modifiers) { + JsonObject modifier = element.getAsJsonObject(); + String type = modifier.get("type").getAsString(); + if (type.equals("minecraft:offset")) { + offsetX += modifier.get("x").getAsInt(); + offsetZ += modifier.get("z").getAsInt(); + } else if (type.equals("minecraft:random_offset")) { + offsetX += modifier.get("xz_spread").getAsInt(); + offsetZ += modifier.get("xz_spread").getAsInt(); + } else if (type.equals("minecraft:block_predicate_filter") || type.equals("minecraft:environment_scan")) { + for (JsonObject probe : StageTestPacks.find(modifier, object -> object.has("offset") || object.has("min"))) { + for (String key : List.of("offset", "min", "max")) { + if (!probe.has(key)) { + continue; + } + JsonArray vector = probe.getAsJsonArray(key); + for (int axis = 0; axis < 3; axis++) { + assertTrue(label + " predicate " + key + " " + vector, Math.abs(vector.get(axis).getAsInt()) <= 16); + } + assertRange(label + " probe x", vector.get(0).getAsInt() + offsetX, vector.get(0).getAsInt() + offsetX); + assertRange(label + " probe z", vector.get(2).getAsInt() + offsetZ, vector.get(2).getAsInt() + offsetZ); + } + } + } + } + return new int[]{offsetX, offsetZ}; + } + + private static void assertFeatureWithinWindow(JsonObject feature, Map footprints, int offsetX, int offsetZ, String label) { + String type = feature.get("type").getAsString(); + JsonObject body = feature.has("config") ? feature.getAsJsonObject("config") : feature; + if (type.equals("minecraft:fossil")) { + for (JsonElement id : body.getAsJsonArray("fossil_structures")) { + assertTemplate(label, footprints.get(id.getAsString()), offsetX, offsetZ); + } + return; + } + JsonObject template = type.equals("minecraft:overlay") + ? body.getAsJsonArray("features").get(0).getAsJsonObject().getAsJsonObject("feature") + : feature; + JsonObject templateBody = template.has("config") ? template.getAsJsonObject("config") : template; + for (JsonElement entry : templateBody.getAsJsonArray("templates")) { + Integer footprint = footprints.get(entry.getAsJsonObject().getAsJsonObject("data").get("id").getAsString()); + assertNotNull(label, footprint); + assertTemplate(label, footprint, offsetX, offsetZ); + } + if (!type.equals("minecraft:overlay")) { + return; + } + JsonArray features = body.getAsJsonArray("features"); + for (int index = 1; index < features.size(); index++) { + JsonArray part = features.get(index).getAsJsonObject().getAsJsonArray("placement"); + int partX = offsetX; + int partZ = offsetZ; + int size = 0; + for (JsonElement element : part) { + JsonObject modifier = element.getAsJsonObject(); + String modifierType = modifier.get("type").getAsString(); + if (modifierType.equals("minecraft:offset")) { + partX += modifier.get("x").getAsInt(); + partZ += modifier.get("z").getAsInt(); + } else if (modifierType.equals("minecraft:cuboid")) { + size = modifier.get("xz_size").getAsInt(); + } + } + assertRange(label + " part x", partX, partX + size); + assertRange(label + " part z", partZ, partZ + size); + } + } + + private static void assertTemplate(String label, int footprint, int offsetX, int offsetZ) { + int low = -(footprint / 2); + int high = footprint - 1 - footprint / 2; + assertRange(label + " template x", low + offsetX, high + offsetX); + assertRange(label + " template z", low + offsetZ, high + offsetZ); + } + + private static void assertRange(String label, int low, int high) { + assertTrue(label + " starts at " + low, low >= WINDOW_MIN); + assertTrue(label + " ends at " + (high + CHUNK - 1), high + CHUNK - 1 <= WINDOW_MAX); + } + + private static double expectedPerChunk(JsonObject placed) { + double expected = 1; + for (JsonElement element : placed.getAsJsonArray("placement")) { + JsonObject modifier = element.getAsJsonObject(); + switch (modifier.get("type").getAsString()) { + case "minecraft:random_chance" -> expected *= modifier.get("chance").getAsDouble(); + case "minecraft:count" -> expected *= expectedCount(modifier.get("count")); + default -> { + } + } + } + return expected; + } + + private static double expectedCount(JsonElement count) { + if (count.isJsonPrimitive()) { + return count.getAsInt(); + } + double total = 0; + double weighted = 0; + for (JsonElement entry : count.getAsJsonObject().getAsJsonArray("distribution")) { + int weight = entry.getAsJsonObject().get("weight").getAsInt(); + total += weight; + weighted += weight * entry.getAsJsonObject().get("data").getAsInt(); + } + return weighted / total; + } + + private static double simulatePerChunk(JsonObject placed, Random random, int chunks) { + long positions = 0; + for (int chunk = 0; chunk < chunks; chunk++) { + int stream = 1; + for (JsonElement element : placed.getAsJsonArray("placement")) { + JsonObject modifier = element.getAsJsonObject(); + String type = modifier.get("type").getAsString(); + if (type.equals("minecraft:random_chance")) { + int kept = 0; + for (int index = 0; index < stream; index++) { + if (random.nextFloat() < modifier.get("chance").getAsFloat()) { + kept++; + } + } + stream = kept; + } else if (type.equals("minecraft:count")) { + int expanded = 0; + for (int index = 0; index < stream; index++) { + expanded += sampleCount(modifier.get("count"), random); + } + stream = expanded; + } + } + positions += stream; + } + return positions / (double) chunks; + } + + private static int sampleCount(JsonElement count, Random random) { + if (count.isJsonPrimitive()) { + return count.getAsInt(); + } + JsonArray distribution = count.getAsJsonObject().getAsJsonArray("distribution"); + int total = 0; + for (JsonElement entry : distribution) { + total += entry.getAsJsonObject().get("weight").getAsInt(); + } + int roll = random.nextInt(total); + for (JsonElement entry : distribution) { + roll -= entry.getAsJsonObject().get("weight").getAsInt(); + if (roll < 0) { + return entry.getAsJsonObject().get("data").getAsInt(); + } + } + throw new IllegalStateException("Weighted list roll fell through"); + } + + private static PlacementCompiler compiler(DataVersion target) { + return new PlacementCompiler(NAMESPACE, ExportCapabilities.forTarget(target), DIMENSION); + } + + private static IrisObjectPlacement placement(ObjectPlaceMode mode, double chance, int density) { + IrisObjectPlacement placement = new IrisObjectPlacement(); + placement.setPlace(new KList<>(List.of("objects/a"))); + placement.setMode(mode); + placement.setChance(chance); + placement.setDensity(density); + return placement; + } + + private static PlacementInput input(IrisObjectPlacement placement, List objects, List processors) { + return new PlacementInput(placement, PlacementInput.Anchor.SURFACE, false, objects, processors, List.of(), "thing"); + } + + private static List objects(int... footprints) { + List objects = new ArrayList<>(); + for (int index = 0; index < footprints.length; index++) { + int footprint = footprints[index]; + TemplateBlock block = new TemplateBlock(0, 0, 0, VanillaBlockState.parse("minecraft:stone"), null); + BakedVariant variant = new BakedVariant(NAMESPACE + ":object/o" + index + "/v" + footprint, + new TemplateVolume(footprint, 4, footprint, List.of(block)), 0, TreeRoots.NONE); + objects.add(new ObjectTemplates("o" + index, List.of(new TemplateChoice(variant, List.of(0, 1, 2, 3), 1)))); + } + return objects; + } + + private static List columns(int... heights) { + List objects = new ArrayList<>(); + for (int index = 0; index < heights.length; index++) { + List blocks = new ArrayList<>(); + for (int y = 1; y <= heights[index]; y++) { + blocks.add(new TemplateBlock(1, y, 1, VanillaBlockState.parse("minecraft:tube_coral_block"), null)); + } + BakedVariant variant = new BakedVariant(NAMESPACE + ":object/c" + index + "/h" + heights[index], + new TemplateVolume(3, heights[index] + 1, 3, blocks), 0, TreeRoots.NONE); + objects.add(new ObjectTemplates("c" + index, List.of(new TemplateChoice(variant, List.of(0, 1, 2, 3), 1)))); + } + return objects; + } + + private static int ceiling(JsonObject placed) { + List ranges = new ArrayList<>(); + for (JsonObject filter : filters(placed)) { + ranges.addAll(StageTestPacks.ofType(filter, "minecraft:height_range")); + } + assertEquals(placed.toString(), 1, ranges.size()); + return ranges.getFirst().getAsJsonObject("max_inclusive").get("absolute").getAsInt(); + } + + private static JsonObject rule() { + JsonObject rule = new JsonObject(); + rule.addProperty("processor_type", "minecraft:rule"); + rule.add("rules", new JsonArray()); + return rule; + } + + private static JsonObject feature(CompiledPlacement compiled, String folder) { + String featureId = placed(compiled).get("feature").getAsString(); + String path = featureId.substring(NAMESPACE.length() + 1) + ".json"; + for (JsonResource resource : compiled.resources()) { + if (resource.path().endsWith("/" + path) && !resource.path().contains("placed_feature") + && (folder == null || resource.path().contains(folder))) { + return resource.json().getAsJsonObject(); + } + } + throw new AssertionError("No feature " + featureId + " in " + folder); + } + + private static JsonObject placed(CompiledPlacement compiled) { + return resource(compiled, "worldgen/placed_feature/"); + } + + private static JsonObject resource(CompiledPlacement compiled, String folder) { + JsonObject found = null; + for (JsonResource resource : compiled.resources()) { + if (resource.path().contains(folder)) { + assertNull("Two resources in " + folder, found); + found = resource.json().getAsJsonObject(); + } + } + assertNotNull("No resource in " + folder, found); + return found; + } + + private static List types(JsonObject placed) { + List types = new ArrayList<>(); + for (JsonElement element : placed.getAsJsonArray("placement")) { + types.add(element.getAsJsonObject().get("type").getAsString()); + } + return types; + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/structure/DatapackBundleTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/structure/DatapackBundleTest.java new file mode 100644 index 000000000..79b795931 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/structure/DatapackBundleTest.java @@ -0,0 +1,146 @@ +package art.arcane.iris.pack.datapack.export.compile.structure; + +import art.arcane.iris.pack.datapack.export.ExportTestPacks; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.util.List; +import java.util.Map; +import java.util.SortedMap; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class DatapackBundleTest { + @ClassRule + public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Rule + public ExportTestPacks.BoundPlatform platform = new ExportTestPacks.BoundPlatform(); + + @Test + public void bundledDatapackFilesAreMerged() throws Exception { + File imports = temporaryFolder.newFolder("imports"); + File first = StructureTestPacks.zip(imports, "first.zip", Map.of( + "first/pack.mcmeta", """ + {"pack": {"description": "first", "min_format": 107, "max_format": 121}, + "overlays": {"entries": [ + {"directory": "mc_26_3", "min_format": 121, "max_format": 121}, + {"directory": "mc_26_2", "min_format": 107, "max_format": 107}]}} + """, + "first/pack.png", "png", + "first/data/first/structure/hut.nbt", "hut-template", + "first/data/first/worldgen/processor_list/hut.json", "{\"processors\": []}", + "first/mc_26_3/data/first/worldgen/processor_list/hut.json", "{\"processors\": [{\"processor_type\": \"minecraft:nop\"}]}", + "first/mc_26_2/data/first/worldgen/processor_list/old.json", "{\"processors\": []}")); + File second = StructureTestPacks.zip(imports, "second.zip", Map.of( + "pack.mcmeta", StructureTestPacks.metadata(121, 121), + "data/second/worldgen/structure_set/camp.json", "{\"structures\": []}")); + File pack = StructureTestPacks.pack(temporaryFolder.newFolder("pack"), StructureTestPacks.dimension(List.of(first, second), ""), "[]"); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = StructureTestPacks.compiled(data); + + SortedMap binaries = StructureTestPacks.binaries(context); + assertEquals("hut-template", binaries.get("data/first/structure/hut.nbt")); + assertEquals("{\"processors\": [{\"processor_type\": \"minecraft:nop\"}]}", binaries.get("data/first/worldgen/processor_list/hut.json")); + assertEquals("{\"structures\": []}", binaries.get("data/second/worldgen/structure_set/camp.json")); + assertFalse(binaries.containsKey("data/first/worldgen/processor_list/old.json")); + for (String path : binaries.keySet()) { + assertTrue(path, path.startsWith("data/")); + } + assertTrue(StructureTestPacks.diagnostics(context, ExportLimit.DATAPACK_VERSION).isEmpty()); + assertTrue(StructureTestPacks.diagnostics(context, ExportLimit.DATAPACK_COLLISION).isEmpty()); + } finally { + data.close(); + } + } + + @Test + public void tagFilesAreUnionedRespectingReplace() throws Exception { + File imports = temporaryFolder.newFolder("imports"); + File first = StructureTestPacks.zip(imports, "a.zip", Map.of( + "pack.mcmeta", StructureTestPacks.metadata(121, 121), + "data/shared/tags/worldgen/structure/towns.json", "{\"values\": [\"a:one\"]}", + "data/shared/tags/item/loot.json", "{\"values\": [\"a:x\"]}")); + File second = StructureTestPacks.zip(imports, "b.zip", Map.of( + "pack.mcmeta", StructureTestPacks.metadata(121, 121), + "data/shared/tags/worldgen/structure/towns.json", "{\"replace\": false, \"values\": [\"b:two\", \"a:one\"]}", + "data/shared/tags/item/loot.json", "{\"replace\": true, \"values\": [\"b:y\"]}")); + File third = StructureTestPacks.zip(imports, "c.zip", Map.of( + "pack.mcmeta", StructureTestPacks.metadata(121, 121), + "data/shared/tags/item/loot.json", "{\"values\": [{\"id\": \"c:z\", \"required\": false}]}")); + File pack = StructureTestPacks.pack(temporaryFolder.newFolder("pack"), StructureTestPacks.dimension(List.of(first, second, third), ""), "[]"); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = StructureTestPacks.compiled(data); + + assertEquals(StructureTestPacks.parse("{\"replace\": false, \"values\": [\"a:one\", \"b:two\"]}"), + StructureTestPacks.json(context).get("data/shared/tags/worldgen/structure/towns.json")); + assertEquals(StructureTestPacks.parse("{\"replace\": true, \"values\": [\"b:y\", {\"id\": \"c:z\", \"required\": false}]}"), + StructureTestPacks.json(context).get("data/shared/tags/item/loot.json")); + assertFalse(StructureTestPacks.binaries(context).containsKey("data/shared/tags/item/loot.json")); + } finally { + data.close(); + } + } + + @Test + public void pathCollisionKeepsLaterImportWithDiagnostic() throws Exception { + File imports = temporaryFolder.newFolder("imports"); + File first = StructureTestPacks.zip(imports, "a.zip", Map.of( + "pack.mcmeta", StructureTestPacks.metadata(121, 121), + "data/shared/worldgen/processor_list/rot.json", "{\"processors\": [\"first\"]}", + "data/shared/worldgen/processor_list/same.json", "{\"processors\": []}")); + File second = StructureTestPacks.zip(imports, "b.zip", Map.of( + "pack.mcmeta", StructureTestPacks.metadata(121, 121), + "data/shared/worldgen/processor_list/rot.json", "{\"processors\": [\"second\"]}", + "data/shared/worldgen/processor_list/same.json", "{\"processors\": []}")); + File pack = StructureTestPacks.pack(temporaryFolder.newFolder("pack"), StructureTestPacks.dimension(List.of(first, second), ""), "[]"); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = StructureTestPacks.compiled(data); + + assertEquals("{\"processors\": [\"second\"]}", StructureTestPacks.binaries(context).get("data/shared/worldgen/processor_list/rot.json")); + List collisions = StructureTestPacks.diagnostics(context, ExportLimit.DATAPACK_COLLISION); + assertEquals(collisions.toString(), 1, collisions.size()); + assertEquals("data/shared/worldgen/processor_list/rot.json", collisions.getFirst().resource()); + assertTrue(collisions.getFirst().message(), collisions.getFirst().message().contains(first.getName())); + assertTrue(collisions.getFirst().message(), collisions.getFirst().message().contains(second.getName())); + } finally { + data.close(); + } + } + + @Test + public void unsupportedFormatIsBundledWithDiagnostic() throws Exception { + File imports = temporaryFolder.newFolder("imports"); + File old = StructureTestPacks.zip(imports, "old.zip", Map.of( + "pack.mcmeta", "{\"pack\": {\"description\": \"old\", \"pack_format\": 48}}", + "data/old/worldgen/processor_list/rot.json", "{\"processors\": []}")); + File pack = StructureTestPacks.pack(temporaryFolder.newFolder("pack"), StructureTestPacks.dimension(List.of(old), ""), "[]"); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = StructureTestPacks.compiled(data); + + assertTrue(StructureTestPacks.binaries(context).containsKey("data/old/worldgen/processor_list/rot.json")); + List versions = StructureTestPacks.diagnostics(context, ExportLimit.DATAPACK_VERSION); + assertEquals(versions.toString(), 1, versions.size()); + assertTrue(versions.getFirst().message(), versions.getFirst().message().contains("48.0 to 48.*")); + } finally { + data.close(); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/structure/StructureStageTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/structure/StructureStageTest.java new file mode 100644 index 000000000..96ff8b23c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/structure/StructureStageTest.java @@ -0,0 +1,295 @@ +package art.arcane.iris.pack.datapack.export.compile.structure; + +import art.arcane.iris.pack.datapack.export.ExportTestPacks; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; +import art.arcane.iris.pack.datapack.export.ir.BinaryResource; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.datapack.export.ir.JsonResource; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.testsupport.CompatTestPlatform; +import art.arcane.iris.testsupport.FakeVanillaDataSource; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import art.arcane.volmlib.util.nbt.io.NBTUtil; +import art.arcane.volmlib.util.nbt.tag.CompoundTag; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.SortedMap; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.doReturn; + +public class StructureStageTest { + private static final String TOWER = """ + {"type": "minecraft:jigsaw", "biomes": "#bundle:has_structure/tower", "step": "surface_structures", + "spawn_overrides": {}, "start_pool": "bundle:tower/start", "size": 4, "start_height": {"absolute": 0}, + "project_start_to_heightmap": "WORLD_SURFACE_WG", "max_distance_from_center": 80, "use_expansion_hack": false} + """; + private static final String MINESHAFT = """ + {"type": "minecraft:mineshaft", "biomes": "#minecraft:has_structure/mineshaft", "step": "underground_structures", + "spawn_overrides": {}, "mineshaft_type": "normal"} + """; + private static final String VILLAGE = """ + {"type": "minecraft:jigsaw", "biomes": "#minecraft:has_structure/village_plains", "step": "surface_structures", + "spawn_overrides": {}, "start_pool": "minecraft:village/plains/town_centers", "size": 6, "start_height": {"absolute": 0}, + "project_start_to_heightmap": "WORLD_SURFACE_WG", "max_distance_from_center": 80, "use_expansion_hack": true} + """; + + @ClassRule + public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Rule + public ExportTestPacks.BoundPlatform platform = new ExportTestPacks.BoundPlatform(); + + @Test + public void exportedBiomesJoinDerivativeTags() throws Exception { + runningVanilla(Map.of( + "data/minecraft/tags/worldgen/biome/has_structure/village_plains.json", "{\"values\": [\"minecraft:plains\"]}", + "data/minecraft/tags/worldgen/biome/has_structure/pillager_outpost.json", "{\"values\": [\"minecraft:plains\", \"minecraft:desert\"]}")); + File towns = StructureTestPacks.zip(temporaryFolder.newFolder("imports"), "towns.zip", Map.of( + "pack.mcmeta", StructureTestPacks.metadata(121, 121), + "data/towns/tags/worldgen/biome/has_structure/village_meadow.json", "{\"values\": [\"minecraft:plains\", \"#minecraft:is_forest\"]}", + "data/minecraft/tags/worldgen/biome/has_structure/village_plains.json", "{\"values\": [\"towns:grove\"]}", + "data/minecraft/tags/worldgen/biome/has_structure/pillager_outpost.json", "{\"replace\": true, \"values\": [\"minecraft:desert\"]}")); + File pack = StructureTestPacks.pack(temporaryFolder.newFolder("pack"), StructureTestPacks.dimension(List.of(towns), ""), "[]"); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = StructureTestPacks.context(data); + assertEquals(StructureTestPacks.parse("{\"replace\": false, \"values\": [\"" + StructureTestPacks.MAPPED + "\"]}"), + StructureTestPacks.json(context).get("data/minecraft/tags/worldgen/biome/has_structure/village_plains.json")); + + new StructureStage().apply(context); + + SortedMap json = StructureTestPacks.json(context); + assertEquals(StructureTestPacks.parse("{\"replace\": false, \"values\": [\"minecraft:plains\", \"#minecraft:is_forest\", \"" + + StructureTestPacks.MAPPED + "\"]}"), + json.get("data/towns/tags/worldgen/biome/has_structure/village_meadow.json")); + assertEquals(StructureTestPacks.parse("{\"replace\": false, \"values\": [\"" + StructureTestPacks.MAPPED + "\", \"towns:grove\"]}"), + json.get("data/minecraft/tags/worldgen/biome/has_structure/village_plains.json")); + assertEquals(StructureTestPacks.parse("{\"replace\": true, \"values\": [\"minecraft:desert\", \"" + StructureTestPacks.DESERT + "\"]}"), + json.get("data/minecraft/tags/worldgen/biome/has_structure/pillager_outpost.json")); + } finally { + data.close(); + } + } + + @Test + public void disabledStructurePointsAtEmptyTag() throws Exception { + runningVanilla(Map.of( + "data/minecraft/worldgen/structure/village_plains.json", VILLAGE, + "data/minecraft/worldgen/structure/mineshaft.json", MINESHAFT)); + File bundle = StructureTestPacks.zip(temporaryFolder.newFolder("imports"), "bundle.zip", Map.of( + "pack.mcmeta", StructureTestPacks.metadata(121, 121), + "data/bundle/worldgen/structure/tower.json", TOWER, + "data/bundle/worldgen/structure/tower_big.json", TOWER, + "data/bundle/worldgen/structure/towers.json", TOWER)); + File pack = StructureTestPacks.pack(temporaryFolder.newFolder("pack"), StructureTestPacks.dimension(List.of(bundle), + "\"importedStructures\": {\"disabled\": [\"bundle:tower\", \"minecraft:village\"]}"), "[]"); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = StructureTestPacks.compiled(data); + + SortedMap json = StructureTestPacks.json(context); + SortedMap binaries = StructureTestPacks.binaries(context); + String disabledTag = "#" + StructureTestPacks.NAMESPACE + ":" + NativeStructureOverrides.DISABLED_TAG; + for (String path : List.of("data/bundle/worldgen/structure/tower.json", "data/bundle/worldgen/structure/tower_big.json", + "data/minecraft/worldgen/structure/village_plains.json")) { + assertEquals(path, disabledTag, json.get(path).getAsJsonObject().get("biomes").getAsString()); + assertFalse(path, binaries.containsKey(path)); + } + assertEquals("bundle:tower/start", json.get("data/bundle/worldgen/structure/tower.json").getAsJsonObject().get("start_pool").getAsString()); + assertTrue(binaries.containsKey("data/bundle/worldgen/structure/towers.json")); + assertFalse(json.containsKey("data/minecraft/worldgen/structure/mineshaft.json")); + assertEquals(StructureTestPacks.parse("{\"replace\": false, \"values\": []}"), + json.get("data/iris_export/tags/worldgen/biome/" + NativeStructureOverrides.DISABLED_TAG + ".json")); + } finally { + data.close(); + } + } + + @Test + public void adjustmentsShiftJigsawStartsAndReportTheRest() throws Exception { + runningVanilla(Map.of("data/minecraft/worldgen/structure/mineshaft.json", MINESHAFT)); + File bundle = StructureTestPacks.zip(temporaryFolder.newFolder("imports"), "bundle.zip", Map.of( + "pack.mcmeta", StructureTestPacks.metadata(121, 121), + "data/bundle/worldgen/structure/tower.json", TOWER)); + File pack = StructureTestPacks.pack(temporaryFolder.newFolder("pack"), StructureTestPacks.dimension(List.of(bundle), """ + "importedStructures": {"adjustments": [ + {"match": ["bundle:"], "yShift": -8}, + {"match": ["minecraft:mineshaft"], "yShift": 4, "stilt": {}}]} + """), "[]"); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = StructureTestPacks.compiled(data); + + JsonObject tower = StructureTestPacks.json(context).get("data/bundle/worldgen/structure/tower.json").getAsJsonObject(); + assertEquals(StructureTestPacks.parse("{\"absolute\": -8}"), tower.get("start_height")); + assertEquals("#bundle:has_structure/tower", tower.get("biomes").getAsString()); + assertFalse(StructureTestPacks.json(context).containsKey("data/minecraft/worldgen/structure/mineshaft.json")); + List dropped = new ArrayList<>(); + for (ExportDiagnostic diagnostic : StructureTestPacks.diagnostics(context, ExportLimit.STRUCTURE_ADJUSTMENT)) { + assertEquals("minecraft:mineshaft", diagnostic.resource()); + dropped.add(diagnostic.message()); + } + assertEquals(dropped.toString(), 2, dropped.size()); + } finally { + data.close(); + } + } + + @Test + public void nativePlacementScopedToRegionBiomes() throws Exception { + File bundle = StructureTestPacks.zip(temporaryFolder.newFolder("imports"), "bundle.zip", Map.of( + "pack.mcmeta", StructureTestPacks.metadata(121, 121), + "data/bundle/worldgen/structure/tower.json", TOWER)); + File pack = StructureTestPacks.pack(temporaryFolder.newFolder("pack"), StructureTestPacks.dimension(List.of(bundle), ""), """ + [{"nativeStructures": [{"structure": "bundle:tower", "weight": 3}], "spacing": 24, "separation": 6, "salt": 776215551}] + """); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = StructureTestPacks.compiled(data); + + SortedMap json = StructureTestPacks.json(context); + String copyId = "iris_export:placement/region/second/0/bundle/tower"; + JsonObject copy = json.get("data/iris_export/worldgen/structure/placement/region/second/0/bundle/tower.json").getAsJsonObject(); + assertEquals("#iris_export:placement/region/second", copy.get("biomes").getAsString()); + assertEquals("bundle:tower/start", copy.get("start_pool").getAsString()); + assertEquals(StructureTestPacks.parse("{\"replace\": false, \"values\": [\"" + StructureTestPacks.DESERT + "\", \"" + + StructureTestPacks.OCEAN + "\"]}"), json.get("data/iris_export/tags/worldgen/biome/placement/region/second.json")); + assertEquals(StructureTestPacks.parse(""" + {"structures": [{"structure": "%s", "weight": 3}], + "placement": {"type": "minecraft:random_spread", "spacing": 24, "separation": 6, "salt": 776215551}} + """.formatted(copyId)), json.get("data/iris_export/worldgen/structure_set/placement/region/second/0.json")); + assertTrue(StructureTestPacks.binaries(context).containsKey("data/bundle/worldgen/structure/tower.json")); + List scope = StructureTestPacks.diagnostics(context, ExportLimit.STRUCTURE_PLACEMENT); + assertEquals(scope.toString(), 1, scope.size()); + assertEquals("regions/second/structures[0]", scope.getFirst().resource()); + assertTrue(scope.getFirst().message(), scope.getFirst().message().contains(StructureTestPacks.OCEAN)); + } finally { + data.close(); + } + } + + @Test + public void jigsawPlacementUsesIrisGrid() throws Exception { + File pack = StructureTestPacks.pack(temporaryFolder.newFolder("pack"), StructureTestPacks.dimension(List.of(), """ + "structures": [{"structures": ["test_structure"], "spacing": 40, "separation": 10, "salt": 91834401}] + """), "[]"); + writeJigsawStructure(pack); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = StructureTestPacks.compiled(data); + + SortedMap json = StructureTestPacks.json(context); + String resourcePath = "placement/dimension/0/test_structure"; + assertEquals(StructureTestPacks.parse(""" + {"structures": [{"structure": "iris_export:%s", "weight": 1}], + "placement": {"type": "minecraft:random_spread", "spacing": 40, "separation": 10, "salt": 91834401}} + """.formatted(resourcePath)), json.get("data/iris_export/worldgen/structure_set/placement/dimension/0.json")); + JsonObject structure = json.get("data/iris_export/worldgen/structure/" + resourcePath + ".json").getAsJsonObject(); + assertEquals("minecraft:jigsaw", structure.get("type").getAsString()); + assertEquals("#iris_export:placement/dimension", structure.get("biomes").getAsString()); + assertEquals("iris_export:" + resourcePath + "/pool/pools/start", structure.get("start_pool").getAsString()); + assertEquals("WORLD_SURFACE_WG", structure.get("project_start_to_heightmap").getAsString()); + assertFalse(json.containsKey("data/iris_export/worldgen/structure_set/" + resourcePath + ".json")); + assertFalse(json.containsKey("data/iris_export/tags/worldgen/biome/" + resourcePath + ".json")); + assertTrue(json.containsKey("data/iris_export/worldgen/template_pool/" + resourcePath + "/pool/pools/start.json")); + assertEquals(StructureTestPacks.parse("{\"replace\": false, \"values\": [\"" + StructureTestPacks.DESERT + "\", \"" + + StructureTestPacks.FOREST + "\", \"" + StructureTestPacks.MAPPED + "\", \"" + StructureTestPacks.OCEAN + "\"]}"), + json.get("data/iris_export/tags/worldgen/biome/placement/dimension.json")); + BinaryResource template = binary(context, "data/iris_export/structure/" + resourcePath + "/piece/pieces/start.nbt"); + CompoundTag nbt = (CompoundTag) NBTUtil.read(new ByteArrayInputStream(template.bytes()), true).getTag(); + assertEquals(5023, nbt.getInt("DataVersion")); + assertTrue(StructureTestPacks.diagnostics(context, ExportLimit.JIGSAW_STRUCTURE).toString(), + StructureTestPacks.diagnostics(context, ExportLimit.JIGSAW_STRUCTURE).isEmpty()); + } finally { + data.close(); + } + } + + @Test + public void stageOutputIsDeterministic() throws Exception { + File bundle = StructureTestPacks.zip(temporaryFolder.newFolder("imports"), "bundle.zip", Map.of( + "pack.mcmeta", StructureTestPacks.metadata(121, 121), + "data/bundle/worldgen/structure/tower.json", TOWER, + "data/bundle/worldgen/structure/towers.json", TOWER, + "data/bundle/structure/tower.nbt", "template", + "data/bundle/tags/worldgen/biome/has_structure/tower.json", "{\"values\": [\"minecraft:desert\", \"minecraft:plains\"]}")); + File pack = StructureTestPacks.pack(temporaryFolder.newFolder("pack"), StructureTestPacks.dimension(List.of(bundle), """ + "importedStructures": {"disabled": ["bundle:towers"]}, + "structures": [{"structures": ["test_structure"], "distribution": "DENSITY", "density": 0.05, "underground": true, + "minHeight": -40, "maxHeight": 10}] + """), """ + [{"nativeStructures": [{"structure": "bundle:tower"}], "distribution": "CONCENTRIC_RINGS"}] + """); + writeJigsawStructure(pack); + + List first = snapshot(pack); + List second = snapshot(pack); + + assertEquals(first, second); + assertTrue(first.toString(), first.stream().anyMatch(line -> line.startsWith("data/iris_export/worldgen/structure_set/placement/dimension/0.json"))); + assertTrue(first.toString(), first.stream().anyMatch(line -> line.startsWith("data/iris_export/worldgen/structure_set/placement/region/second/0.json"))); + } + + private List snapshot(File pack) { + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = StructureTestPacks.compiled(data); + List lines = new ArrayList<>(); + for (JsonResource resource : context.jsonResources()) { + lines.add(resource.path() + " " + resource.json()); + } + for (BinaryResource resource : context.binaryResources()) { + lines.add(resource.path() + " " + Arrays.hashCode(resource.bytes())); + } + for (ExportDiagnostic diagnostic : context.diagnostics()) { + lines.add(diagnostic.toString()); + } + return lines; + } finally { + data.close(); + } + } + + private void runningVanilla(Map files) { + doReturn("26.3").when(IrisPlatforms.get()).minecraftVersion(); + doReturn(FakeVanillaDataSource.of(files)).when(IrisPlatforms.get()).vanillaData(); + } + + private static void writeJigsawStructure(File pack) throws Exception { + CompatTestPlatform.write(pack, "structures/test_structure.json", """ + {"startPool": "pools/start", "maxDepth": 4, "maxSizeChunks": 4, + "compatibility": "VANILLA_PORTABLE", "branchFailurePolicy": "TERMINATE_BRANCH"} + """); + CompatTestPlatform.write(pack, "jigsaw-pools/pools/start.json", "{\"pieces\": [{\"piece\": \"pieces/start\", \"weight\": 1}]}"); + CompatTestPlatform.write(pack, "jigsaw-pieces/pieces/start.json", "{\"object\": \"start\"}"); + CompatTestPlatform.writeObjectWithBlocks(pack, "start", "minecraft:stone"); + } + + private static BinaryResource binary(PackCompileContext context, String path) { + for (BinaryResource resource : context.binaryResources()) { + if (resource.path().equals(path)) { + return resource; + } + } + throw new AssertionError("Missing binary resource " + path); + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/structure/StructureTestPacks.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/structure/StructureTestPacks.java new file mode 100644 index 000000000..54761a25e --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/structure/StructureTestPacks.java @@ -0,0 +1,128 @@ +package art.arcane.iris.pack.datapack.export.compile.structure; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ExportTestPacks; +import art.arcane.iris.pack.datapack.export.compile.DimensionStage; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; +import art.arcane.iris.pack.datapack.export.compile.biome.BiomeCodes; +import art.arcane.iris.pack.datapack.export.compile.biome.BiomeDefinitionCompiler; +import art.arcane.iris.pack.datapack.export.ir.BinaryResource; +import art.arcane.iris.pack.datapack.export.ir.ClimateLayout; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.JsonResource; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.testsupport.CompatTestPlatform; +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.SortedMap; +import java.util.TreeMap; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +final class StructureTestPacks { + static final String NAMESPACE = "iris_export"; + static final String FOREST = "iris_export:forest_land"; + static final String MAPPED = "iris_export:mapped"; + static final String OCEAN = "iris_export:ocean"; + static final String DESERT = "iris_export:desert"; + static final Map EXPORTED = Map.of(FOREST, "Forest Land", MAPPED, "mapped", OCEAN, "ocean", DESERT, "desert"); + private static final Field ZERO = new Field.Const(0.0); + + private StructureTestPacks() { + } + + static File pack(File folder, String dimensionJson, String secondRegionStructures) throws IOException { + File pack = ExportTestPacks.withDimension(folder, dimensionJson); + CompatTestPlatform.write(pack, "regions/second.json", + "{\"landBiomes\":[\"desert\"],\"seaBiomes\":[\"ocean\"],\"structures\":" + secondRegionStructures + "}"); + CompatTestPlatform.write(pack, "biomes/Forest Land.json", "{\"name\":\"Forest Land\",\"derivative\":\"minecraft:forest\"}"); + CompatTestPlatform.write(pack, "biomes/mapped.json", "{\"name\":\"Mapped\",\"derivative\":\"minecraft:plains\"}"); + CompatTestPlatform.write(pack, "biomes/desert.json", "{\"name\":\"Desert\",\"derivative\":\"minecraft:desert\"}"); + return pack; + } + + static String dimension(List imports, String extra) { + List sources = new ArrayList<>(imports.size()); + for (File archive : imports) { + sources.add("\"" + archive.toURI().toASCIIString() + "\""); + } + return "{\"regions\":[\"region\",\"second\"],\"datapackImports\":[" + String.join(",", sources) + "]" + + (extra.isEmpty() ? "" : "," + extra) + "}"; + } + + static File zip(File folder, String name, Map entries) throws IOException { + File archive = new File(folder, name); + try (ZipOutputStream output = new ZipOutputStream(new FileOutputStream(archive))) { + for (Map.Entry entry : new TreeMap<>(entries).entrySet()) { + output.putNextEntry(new ZipEntry(entry.getKey())); + output.write(entry.getValue().getBytes(StandardCharsets.UTF_8)); + output.closeEntry(); + } + } + return archive; + } + + static String metadata(int minimum, int maximum) { + return "{\"pack\":{\"description\":\"test\",\"min_format\":" + minimum + ",\"max_format\":" + maximum + "}}"; + } + + static PackCompileContext context(IrisData data) { + PackCompileContext context = ExportTestPacks.context(data, DataVersion.V26_3); + new DimensionStage().apply(context); + context.setBiomeCodes(new BiomeCodes(ZERO, ZERO, ZERO, new ClimateLayout(List.of()), EXPORTED)); + BiomeDefinitionCompiler.Definitions definitions = BiomeDefinitionCompiler.forTarget(DataVersion.V26_3).compile(context, EXPORTED); + context.setBiomes(definitions.biomes()); + context.jsonResources().addAll(definitions.tags()); + return context; + } + + static PackCompileContext compiled(IrisData data) { + PackCompileContext context = context(data); + new StructureStage().apply(context); + return context; + } + + static SortedMap json(PackCompileContext context) { + SortedMap resources = new TreeMap<>(); + for (JsonResource resource : context.jsonResources()) { + if (resources.put(resource.path(), resource.json()) != null) { + throw new AssertionError("Duplicate json resource " + resource.path()); + } + } + return resources; + } + + static SortedMap binaries(PackCompileContext context) { + SortedMap resources = new TreeMap<>(); + for (BinaryResource resource : context.binaryResources()) { + if (resources.put(resource.path(), new String(resource.bytes(), StandardCharsets.UTF_8)) != null) { + throw new AssertionError("Duplicate binary resource " + resource.path()); + } + } + return resources; + } + + static List diagnostics(PackCompileContext context, ExportLimit limit) { + List matching = new ArrayList<>(); + for (ExportDiagnostic diagnostic : context.diagnostics()) { + if (diagnostic.limit() == limit) { + matching.add(diagnostic); + } + } + return matching; + } + + static JsonElement parse(String json) { + return JsonParser.parseString(json); + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/subterrain/SubterrainBakerTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/subterrain/SubterrainBakerTest.java new file mode 100644 index 000000000..98689e726 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/subterrain/SubterrainBakerTest.java @@ -0,0 +1,247 @@ +package art.arcane.iris.pack.datapack.export.compile.subterrain; + +import art.arcane.iris.generation.subterrain.IrisSubterrainFeature; +import art.arcane.iris.generation.subterrain.SubterrainCell; +import art.arcane.iris.generation.subterrain.SubterrainPlan; +import art.arcane.iris.generation.subterrain.SubterrainPlanner; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ExportTestPacks; +import art.arcane.iris.pack.datapack.export.TemplateBlock; +import art.arcane.iris.pack.datapack.export.TemplateVolume; +import art.arcane.iris.pack.datapack.export.VanillaBlockState; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.TreeSet; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +public class SubterrainBakerTest { + private static final long SEED = 7331L; + private static final VanillaBlockState CAVE_AIR = VanillaBlockState.parse("minecraft:cave_air"); + private static final VanillaBlockState WATER = VanillaBlockState.parse("minecraft:water"); + private static final VanillaBlockState LAVA = VanillaBlockState.parse("minecraft:lava"); + private static final VanillaBlockState STONE = VanillaBlockState.parse("minecraft:stone"); + private static final VanillaBlockState GRASS = VanillaBlockState.parse("minecraft:grass_block"); + + @ClassRule + public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Rule + public ExportTestPacks.BoundPlatform platform = new ExportTestPacks.BoundPlatform(); + + @Test + public void bakedRoomsMatchIrisCells() throws Exception { + IrisData data = IrisData.openRuntime(SubterrainTestPacks.pack(temporaryFolder.newFolder("pack"), SubterrainTestPacks.CENOTE)); + try { + IrisSubterrainFeature feature = feature(data, 0); + PackCompileContext context = SubterrainTestPacks.context(data, DataVersion.V26_3); + + BakedFeature baked = new SubterrainBaker(context, SEED).bake(feature, "pools").orElseThrow(); + + assertEquals(SubterrainBaker.VARIANTS, baked.rooms().size()); + SubterrainPlanner planner = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), SEED, + SubterrainTestPacks.MIN_Y, SubterrainTestPacks.MAX_Y, (x, z) -> "")); + Set kinds = new TreeSet<>(); + for (BakedRoom room : baked.rooms()) { + SubterrainPlan plan = plan(planner, room); + Map core = index(room.core()); + Map shell = index(room.shell()); + assertEquals(plan.centerX() - room.originX(), room.anchorX()); + assertEquals(plan.centerY() - room.originY(), room.anchorY()); + assertEquals(plan.centerZ() - room.originZ(), room.anchorZ()); + for (int x = 0; x < room.core().sizeX(); x++) { + for (int y = 0; y < room.core().sizeY(); y++) { + for (int z = 0; z < room.core().sizeZ(); z++) { + SubterrainCell cell = planner.sample(List.of(plan), room.originX() + x, room.originY() + y, room.originZ() + z); + long key = key(x, y, z); + boolean anchor = x == room.anchorX() && y == room.anchorY() && z == room.anchorZ(); + VanillaBlockState expectedCore = switch (cell.kind()) { + case AIR -> CAVE_AIR; + case WATER -> WATER; + case LAVA -> LAVA; + case SOLID -> cell.room().reservedSolid() ? STONE : null; + case OUTSIDE -> null; + }; + VanillaBlockState expectedShell = cell.solid() && !cell.room().reservedSolid() ? STONE : null; + kinds.add(cell.kind() + (cell.solid() && cell.room().reservedSolid() ? "_RESERVED" : "")); + if (anchor) { + assertEquals(expectedCore == null ? SubterrainBaker.NO_BLOCK : expectedCore, room.anchorState()); + assertFalse(core.containsKey(key)); + } else { + assertEquals(x + "," + y + "," + z + " " + cell.kind(), expectedCore, core.get(key)); + } + assertEquals(x + "," + y + "," + z + " " + cell.kind(), expectedShell, shell.get(key)); + } + } + } + } + assertTrue(kinds.toString(), kinds.containsAll(List.of("AIR", "WATER", "SOLID", "SOLID_RESERVED", "OUTSIDE"))); + assertTrue(context.diagnostics().toString(), context.diagnostics().isEmpty()); + } finally { + data.close(); + } + } + + @Test + public void anchorRangeMatchesIrisPlacementBand() throws Exception { + IrisData data = IrisData.openRuntime(SubterrainTestPacks.pack(temporaryFolder.newFolder("pack"), SubterrainTestPacks.CENOTE)); + try { + BakedFeature baked = new SubterrainBaker(SubterrainTestPacks.context(data, DataVersion.V26_3), SEED) + .bake(feature(data, 0), "pools").orElseThrow(); + + int baseDepth = (int) Math.ceil(Math.min(24, 8 * 0.45) * 0.5); + assertEquals(-60 + 12 / 2 + 3 + baseDepth, baked.lowestAnchorY()); + assertEquals(40 - (12 - 12 / 2 + 3), baked.highestAnchorY()); + for (BakedRoom room : baked.rooms()) { + int center = room.originY() + room.anchorY(); + assertTrue(center + " below " + baked.lowestAnchorY(), center >= baked.lowestAnchorY()); + assertTrue(center + " above " + baked.highestAnchorY(), center <= baked.highestAnchorY()); + } + } finally { + data.close(); + } + } + + @Test + public void roomBiomePaintsFloorsWallsAndCeilings() throws Exception { + IrisData data = IrisData.openRuntime(SubterrainTestPacks.pack(temporaryFolder.newFolder("pack"), SubterrainTestPacks.ROOM_CENOTE)); + try { + BakedFeature baked = new SubterrainBaker(SubterrainTestPacks.context(data, DataVersion.V26_3), SEED) + .bake(feature(data, 0), "grottos").orElseThrow(); + + for (BakedRoom room : baked.rooms()) { + Map core = index(room.core()); + Set painted = new TreeSet<>((first, second) -> first.canonical().compareTo(second.canonical())); + for (TemplateBlock block : room.shell().blocks()) { + painted.add(block.state()); + if (block.state().equals(GRASS)) { + assertTrue(touchesRoom(core, block, room)); + } + } + assertTrue(painted.toString(), painted.contains(GRASS)); + assertTrue(painted.toString(), painted.contains(STONE)); + } + } finally { + data.close(); + } + } + + @Test + public void roomsBeyondStructureReachAreClipped() throws Exception { + IrisData data = IrisData.openRuntime(SubterrainTestPacks.pack(temporaryFolder.newFolder("pack"), SubterrainTestPacks.TUBE)); + try { + PackCompileContext context = SubterrainTestPacks.context(data, DataVersion.V26_3); + + BakedFeature baked = new SubterrainBaker(context, SEED).bake(feature(data, 0), "tubes").orElseThrow(); + + for (BakedRoom room : baked.rooms()) { + assertTrue(room.anchorX() <= SubterrainBaker.MAX_REACH && room.core().sizeX() - 1 - room.anchorX() <= SubterrainBaker.MAX_REACH); + assertTrue(room.anchorZ() <= SubterrainBaker.MAX_REACH && room.core().sizeZ() - 1 - room.anchorZ() <= SubterrainBaker.MAX_REACH); + assertTrue(Math.max(room.core().sizeX(), room.core().sizeZ()) == 2 * SubterrainBaker.MAX_REACH + 1); + assertTrue(room.core().blocks().stream().anyMatch(block -> block.state().equals(LAVA))); + } + assertTrue(baked.reach() <= SubterrainBaker.MAX_REACH); + List bounds = SubterrainTestPacks.diagnostics(context.diagnostics(), ExportLimit.OBJECT_BOUNDS); + assertEquals(1, bounds.size()); + assertEquals("tubes", bounds.getFirst().resource()); + } finally { + data.close(); + } + } + + @Test + public void featureThatCannotFitIsNotBaked() throws Exception { + IrisData data = IrisData.openRuntime(SubterrainTestPacks.pack(temporaryFolder.newFolder("pack"), SubterrainTestPacks.SHALLOW)); + try { + PackCompileContext context = SubterrainTestPacks.context(data, DataVersion.V26_3); + + Optional baked = new SubterrainBaker(context, SEED).bake(feature(data, 0), "shallow"); + + assertFalse(baked.isPresent()); + assertEquals(1, SubterrainTestPacks.diagnostics(context.diagnostics(), ExportLimit.STRUCTURE_PLACEMENT).size()); + } finally { + data.close(); + } + } + + @Test + public void bakingIsDeterministic() throws Exception { + IrisData data = IrisData.openRuntime(SubterrainTestPacks.pack(temporaryFolder.newFolder("pack"), SubterrainTestPacks.CENOTE)); + try { + IrisSubterrainFeature feature = feature(data, 0); + + BakedFeature first = new SubterrainBaker(SubterrainTestPacks.context(data, DataVersion.V26_3), SEED).bake(feature, "pools").orElseThrow(); + BakedFeature second = new SubterrainBaker(SubterrainTestPacks.context(data, DataVersion.V26_3), SEED).bake(feature, "pools").orElseThrow(); + + assertEquals(first, second); + } finally { + data.close(); + } + } + + private static IrisSubterrainFeature feature(IrisData data, int index) { + IrisDimension dimension = data.getDimensionLoader().load(ExportTestPacks.DIMENSION, false); + assertNotNull(dimension); + return dimension.getSubterrainFeatures().get(index); + } + + private static SubterrainPlan plan(SubterrainPlanner planner, BakedRoom room) { + for (SubterrainPlan plan : planner.plansForBounds(room.originX(), room.originZ(), + room.originX() + room.core().sizeX() - 1, room.originZ() + room.core().sizeZ() - 1)) { + if (plan.bounds().minX() == room.originX() && plan.bounds().minY() == room.originY() && plan.bounds().minZ() == room.originZ()) { + return plan; + } + } + throw new AssertionError("No Iris plan starts at " + room.originX() + "," + room.originY() + "," + room.originZ()); + } + + private static Map index(TemplateVolume volume) { + Map blocks = new HashMap<>(); + for (TemplateBlock block : volume.blocks()) { + assertTrue(blocks.put(key(block.x(), block.y(), block.z()), block.state()) == null); + } + return blocks; + } + + private static boolean touchesRoom(Map core, TemplateBlock block, BakedRoom room) { + int[][] offsets = {{1, 0, 0}, {-1, 0, 0}, {0, 1, 0}, {0, -1, 0}, {0, 0, 1}, {0, 0, -1}}; + for (int[] offset : offsets) { + int x = block.x() + offset[0]; + int y = block.y() + offset[1]; + int z = block.z() + offset[2]; + if (x == room.anchorX() && y == room.anchorY() && z == room.anchorZ()) { + return true; + } + VanillaBlockState neighbor = core.get(key(x, y, z)); + if (CAVE_AIR.equals(neighbor) || WATER.equals(neighbor)) { + return true; + } + } + return false; + } + + private static long key(int x, int y, int z) { + return ((long) x << 42) ^ ((long) y << 21) ^ z; + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/subterrain/SubterrainStageTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/subterrain/SubterrainStageTest.java new file mode 100644 index 000000000..259001d0d --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/subterrain/SubterrainStageTest.java @@ -0,0 +1,305 @@ +package art.arcane.iris.pack.datapack.export.compile.subterrain; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ExportTestPacks; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; +import art.arcane.iris.pack.datapack.export.compile.structure.PlacementTags; +import art.arcane.iris.pack.datapack.export.emit.UnusedResourcePruner; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import art.arcane.volmlib.util.nbt.io.NBTUtil; +import art.arcane.volmlib.util.nbt.tag.CompoundTag; +import art.arcane.volmlib.util.nbt.tag.ListTag; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.SortedMap; +import java.util.TreeMap; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +public class SubterrainStageTest { + private static final String POOLS_STRUCTURE = "data/iris_export/worldgen/structure/subterrain/pools.json"; + private static final String POOLS_SET = "data/iris_export/worldgen/structure_set/subterrain/pools.json"; + private static final String POOLS_POOL = "data/iris_export/worldgen/template_pool/subterrain/pools.json"; + private static final String TUBES_STRUCTURE = "data/iris_export/worldgen/structure/subterrain/tubes.json"; + private static final String TUBES_SET = "data/iris_export/worldgen/structure_set/subterrain/tubes.json"; + private static final String TUBES_POOL = "data/iris_export/worldgen/template_pool/subterrain/tubes.json"; + private static final String DIMENSION_TAG = "data/iris_export/tags/worldgen/biome/placement/dimension.json"; + private static final String SECOND_TAG = "data/iris_export/tags/worldgen/biome/placement/region/second.json"; + private static final String ANCHOR = "iris_export:subterrain/anchor"; + + @ClassRule + public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Rule + public ExportTestPacks.BoundPlatform platform = new ExportTestPacks.BoundPlatform(); + + @Test + public void roomsExportAsCenteredUndergroundJigsawStructures() throws Exception { + IrisData data = IrisData.openRuntime(SubterrainTestPacks.pack(temporaryFolder.newFolder("pack"), SubterrainTestPacks.CENOTE)); + try { + PackCompileContext context = SubterrainTestPacks.context(data, DataVersion.V26_3); + + new SubterrainStage().apply(context); + + SortedMap json = SubterrainTestPacks.json(context); + JsonObject structure = json.get(POOLS_STRUCTURE).getAsJsonObject(); + assertEquals("minecraft:jigsaw", structure.get("type").getAsString()); + assertEquals("#iris_export:placement/dimension", structure.get("biomes").getAsString()); + assertEquals("underground_structures", structure.get("step").getAsString()); + assertEquals("iris_export:subterrain/pools", structure.get("start_pool").getAsString()); + assertEquals(ANCHOR, structure.get("start_jigsaw_name").getAsString()); + assertEquals("none", structure.get("terrain_adaptation").getAsString()); + assertFalse(structure.has("project_start_to_heightmap")); + assertEquals(10, structure.get("max_distance_from_center").getAsInt()); + int baseDepth = (int) Math.ceil(Math.min(24, 8 * 0.45) * 0.5); + assertEquals(JsonParser.parseString("{\"type\":\"minecraft:uniform\",\"min_inclusive\":{\"absolute\":" + + (-60 + 12 / 2 + 3 + baseDepth + 1) + "},\"max_inclusive\":{\"absolute\":" + (40 - (12 - 12 / 2 + 3) + 1) + "}}"), + structure.get("start_height")); + assertEquals(JsonParser.parseString("{\"placement\":{\"type\":\"minecraft:random_spread\",\"salt\":" + salt("pools") + + ",\"separation\":2,\"spacing\":4},\"structures\":[{\"structure\":\"iris_export:subterrain/pools\",\"weight\":1}]}"), + json.get(POOLS_SET)); + JsonArray dimensionBiomes = json.get(DIMENSION_TAG).getAsJsonObject().getAsJsonArray("values"); + assertEquals(SubterrainTestPacks.EXPORTED.size(), dimensionBiomes.size()); + + JsonArray elements = json.get(POOLS_POOL).getAsJsonObject().getAsJsonArray("elements"); + assertEquals(SubterrainBaker.VARIANTS, elements.size()); + SortedMap binaries = SubterrainTestPacks.binaries(context); + for (int index = 0; index < elements.size(); index++) { + JsonObject element = elements.get(index).getAsJsonObject().getAsJsonObject("element"); + assertEquals("minecraft:list_pool_element", element.get("element_type").getAsString()); + JsonArray parts = element.getAsJsonArray("elements"); + assertEquals(2, parts.size()); + String core = "iris_export:subterrain/pools/" + index + "_core"; + String shell = "iris_export:subterrain/pools/" + index + "_shell"; + assertEquals(core, parts.get(0).getAsJsonObject().get("location").getAsString()); + assertEquals(shell, parts.get(1).getAsJsonObject().get("location").getAsString()); + assertEquals(0, parts.get(0).getAsJsonObject().getAsJsonObject("processors").getAsJsonArray("processors").size()); + assertEquals(guard("{\"id\":\"minecraft:cave_air\"}", "{\"id\":\"minecraft:water\"}", "{\"id\":\"minecraft:lava\"}"), + parts.get(1).getAsJsonObject().get("processors")); + CompoundTag coreTemplate = template(binaries, "data/iris_export/structure/subterrain/pools/" + index + "_core.nbt"); + assertEquals(DataVersion.V26_3.getWorldDataVersion(), coreTemplate.getInt("DataVersion")); + CompoundTag jigsaw = anchor(coreTemplate); + assertEquals(ANCHOR, jigsaw.getString("name")); + assertEquals("minecraft:empty", jigsaw.getString("pool")); + assertNotNull(template(binaries, "data/iris_export/structure/subterrain/pools/" + index + "_shell.nbt")); + } + assertEquals(1, matching(context, ExportLimit.SUBTERRAIN_VARIANT, "pools")); + assertEquals(1, matching(context, ExportLimit.SUBTERRAIN_TERRAIN, "pools")); + assertEquals(0, matching(context, ExportLimit.SUBTERRAIN_BIOME, "pools")); + } finally { + data.close(); + } + } + + @Test + public void regionRestrictedRoomsUseTheRegionPlacementTag() throws Exception { + IrisData data = IrisData.openRuntime(SubterrainTestPacks.pack(temporaryFolder.newFolder("pack"), SubterrainTestPacks.TUBE)); + try { + PackCompileContext context = SubterrainTestPacks.context(data, DataVersion.V26_3); + + new SubterrainStage().apply(context); + + SortedMap json = SubterrainTestPacks.json(context); + JsonObject structure = json.get(TUBES_STRUCTURE).getAsJsonObject(); + assertEquals("#iris_export:placement/region/second", structure.get("biomes").getAsString()); + assertEquals(JsonParser.parseString("{\"replace\":false,\"values\":[\"" + SubterrainTestPacks.DESERT + "\",\"" + + SubterrainTestPacks.OCEAN + "\"]}"), json.get(SECOND_TAG)); + assertFalse(json.containsKey(DIMENSION_TAG)); + JsonObject placement = json.get(TUBES_SET).getAsJsonObject().getAsJsonObject("placement"); + assertEquals(20, placement.get("spacing").getAsInt()); + assertEquals(10, placement.get("separation").getAsInt()); + assertEquals(0.5f, placement.get("frequency").getAsFloat(), 0.0f); + assertEquals(1, matching(context, ExportLimit.STRUCTURE_PLACEMENT, "tubes")); + assertEquals(1, matching(context, ExportLimit.OBJECT_BOUNDS, "tubes")); + } finally { + data.close(); + } + } + + @Test + public void existingPlacementTagIsReused() throws Exception { + IrisData data = IrisData.openRuntime(SubterrainTestPacks.pack(temporaryFolder.newFolder("pack"), SubterrainTestPacks.TUBE)); + try { + PackCompileContext context = SubterrainTestPacks.context(data, DataVersion.V26_3); + PlacementTags.collect(context).regionTag("second").orElseThrow(); + int before = context.jsonResources().size(); + + new SubterrainStage().apply(context); + + SortedMap json = SubterrainTestPacks.json(context); + assertTrue(json.containsKey(SECOND_TAG)); + assertEquals(before + 3, context.jsonResources().size()); + } finally { + data.close(); + } + } + + @Test + public void roomBiomeIsReported() throws Exception { + IrisData data = IrisData.openRuntime(SubterrainTestPacks.pack(temporaryFolder.newFolder("pack"), SubterrainTestPacks.ROOM_CENOTE)); + try { + PackCompileContext context = SubterrainTestPacks.context(data, DataVersion.V26_3); + + new SubterrainStage().apply(context); + + assertEquals(1, matching(context, ExportLimit.SUBTERRAIN_BIOME, "grottos")); + for (ExportDiagnostic diagnostic : SubterrainTestPacks.diagnostics(context.diagnostics(), ExportLimit.SUBTERRAIN_BIOME)) { + assertTrue(diagnostic.message(), diagnostic.message().contains("'room'")); + } + } finally { + data.close(); + } + } + + @Test + public void disabledAndUnplaceableFeaturesExportNothing() throws Exception { + IrisData data = IrisData.openRuntime(SubterrainTestPacks.pack(temporaryFolder.newFolder("pack"), + SubterrainTestPacks.DISABLED, SubterrainTestPacks.SHALLOW)); + try { + PackCompileContext context = SubterrainTestPacks.context(data, DataVersion.V26_3); + + new SubterrainStage().apply(context); + + assertTrue(SubterrainTestPacks.binaries(context).isEmpty()); + for (String path : SubterrainTestPacks.json(context).keySet()) { + assertFalse(path, path.contains("/subterrain/")); + } + assertEquals(1, matching(context, ExportLimit.STRUCTURE_PLACEMENT, "shallow")); + assertEquals(0, matching(context, ExportLimit.SUBTERRAIN_VARIANT, "off")); + } finally { + data.close(); + } + } + + @Test + public void dimensionWithoutCarvingExportsNoRooms() throws Exception { + IrisData data = IrisData.openRuntime(SubterrainTestPacks.packWith(temporaryFolder.newFolder("pack"), "\"carvingEnabled\": false", + SubterrainTestPacks.CENOTE)); + try { + PackCompileContext context = SubterrainTestPacks.context(data, DataVersion.V26_3); + + new SubterrainStage().apply(context); + + assertTrue(context.jsonResources().isEmpty()); + assertTrue(context.binaryResources().isEmpty()); + assertTrue(context.diagnostics().isEmpty()); + } finally { + data.close(); + } + } + + @Test + public void prunerKeepsPlacedRoomsAndDropsUnplacedRooms() throws Exception { + IrisData data = IrisData.openRuntime(SubterrainTestPacks.pack(temporaryFolder.newFolder("pack"), + SubterrainTestPacks.CENOTE, SubterrainTestPacks.TUBE)); + try { + PackCompileContext context = SubterrainTestPacks.context(data, DataVersion.V26_3); + new SubterrainStage().apply(context); + SortedMap files = files(context); + List rooms = rooms(files); + assertEquals(2 * (3 + 2 * SubterrainBaker.VARIANTS) + 2, rooms.size()); + + UnusedResourcePruner.Pruned placed = UnusedResourcePruner.prune(files); + + for (String path : rooms) { + assertTrue(path, placed.files().containsKey(path)); + } + + files.put(SECOND_TAG, "{\"replace\":false,\"values\":[]}".getBytes(StandardCharsets.UTF_8)); + UnusedResourcePruner.Pruned unplaced = UnusedResourcePruner.prune(files); + + for (String path : rooms) { + boolean tubes = path.contains("/subterrain/tubes") || path.equals(SECOND_TAG); + assertEquals(path, !tubes, unplaced.files().containsKey(path)); + } + } finally { + data.close(); + } + } + + private static int salt(String id) { + long salt = 0xcbf29ce484222325L; + for (int index = 0; index < id.length(); index++) { + salt = (salt ^ id.charAt(index)) * 0x100000001b3L; + } + return (int) (salt ^ (salt >>> 32)) & Integer.MAX_VALUE; + } + + private static JsonElement guard(String air, String water, String lava) { + return JsonParser.parseString("{\"processors\":[{\"processor_type\":\"minecraft:rule\",\"rules\":[" + + rule("{\"predicate_type\":\"minecraft:tag_match\",\"tag\":\"minecraft:air\"}", air) + "," + + rule("{\"predicate_type\":\"minecraft:block_match\",\"block\":\"minecraft:water\"}", water) + "," + + rule("{\"predicate_type\":\"minecraft:block_match\",\"block\":\"minecraft:lava\"}", lava) + "]}]}"); + } + + private static String rule(String location, String output) { + return "{\"input_predicate\":{\"predicate_type\":\"minecraft:always_true\"},\"location_predicate\":" + location + + ",\"output_state\":" + output + "}"; + } + + private static CompoundTag template(SortedMap binaries, String path) throws IOException { + byte[] bytes = binaries.get(path); + assertNotNull(path + " in " + binaries.keySet(), bytes); + return (CompoundTag) NBTUtil.read(new ByteArrayInputStream(bytes), true).getTag(); + } + + private static CompoundTag anchor(CompoundTag template) { + ListTag palette = template.getListTag("palette").asCompoundTagList(); + CompoundTag found = null; + for (CompoundTag block : template.getListTag("blocks").asCompoundTagList()) { + CompoundTag state = palette.get(block.getInt("state")); + String name = state.containsKey("Name") ? state.getString("Name") : state.getString("id"); + if (name.equals("minecraft:jigsaw")) { + assertTrue(found == null); + found = block.getCompoundTag("nbt"); + } + } + assertNotNull(found); + return found; + } + + private static int matching(PackCompileContext context, ExportLimit limit, String feature) { + int count = 0; + for (ExportDiagnostic diagnostic : SubterrainTestPacks.diagnostics(context.diagnostics(), limit)) { + if (diagnostic.resource().endsWith("/subterrainFeatures/" + feature)) { + count++; + } + } + return count; + } + + private static SortedMap files(PackCompileContext context) { + SortedMap files = new TreeMap<>(); + files.put("pack.mcmeta", "{\"pack\":{\"description\":\"test\",\"min_format\":121,\"max_format\":121}}".getBytes(StandardCharsets.UTF_8)); + for (Map.Entry entry : SubterrainTestPacks.json(context).entrySet()) { + files.put(entry.getKey(), entry.getValue().toString().getBytes(StandardCharsets.UTF_8)); + } + files.putAll(SubterrainTestPacks.binaries(context)); + return files; + } + + private static List rooms(SortedMap files) { + return files.keySet().stream().filter(path -> path.contains("/subterrain/") || path.contains("/placement/")).toList(); + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/subterrain/SubterrainTestPacks.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/subterrain/SubterrainTestPacks.java new file mode 100644 index 000000000..da1861428 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/subterrain/SubterrainTestPacks.java @@ -0,0 +1,118 @@ +package art.arcane.iris.pack.datapack.export.compile.subterrain; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ExportTestPacks; +import art.arcane.iris.pack.datapack.export.compile.DimensionStage; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; +import art.arcane.iris.pack.datapack.export.compile.biome.BiomeCodes; +import art.arcane.iris.pack.datapack.export.ir.BinaryResource; +import art.arcane.iris.pack.datapack.export.ir.ClimateLayout; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.JsonResource; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.testsupport.CompatTestPlatform; +import com.google.gson.JsonElement; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.SortedMap; +import java.util.TreeMap; + +final class SubterrainTestPacks { + static final String NAMESPACE = "iris_export"; + static final String FOREST = "iris_export:forest_land"; + static final String MAPPED = "iris_export:mapped"; + static final String OCEAN = "iris_export:ocean"; + static final String DESERT = "iris_export:desert"; + static final Map EXPORTED = Map.of(FOREST, "Forest Land", MAPPED, "mapped", OCEAN, "ocean", DESERT, "desert"); + static final int MIN_Y = -64; + static final int MAX_Y = 320; + static final String CENOTE = """ + {"id": "pools", "family": "CENOTE", "worldYRange": {"min": -60, "max": 40}, "spacing": 64, "probability": 1.0, + "radius": 8, "height": 12, "fluid": "WATER", "fluidDepth": 2, "shapeWarp": 0.5, "pillarSpacing": 0, + "formationFraction": 0.0, "solid": "minecraft:stone"} + """; + static final String ROOM_CENOTE = """ + {"id": "grottos", "family": "CENOTE", "biome": "room", "worldYRange": {"min": -60, "max": 40}, "spacing": 64, + "probability": 1.0, "radius": 8, "height": 12, "fluidDepth": 0, "shapeWarp": 0.0, "pillarSpacing": 0, + "formationFraction": 0.0, "solid": "minecraft:stone"} + """; + static final String TUBE = """ + {"id": "tubes", "family": "LAVA_TUBE", "allowedRegions": ["second"], "worldYRange": {"min": -60, "max": 40}, + "spacing": 320, "probability": 0.5, "length": 300, "radius": 6, "height": 8, "fluidDepth": 2, "chimneyHeight": 0, + "shapeWarp": 0.0, "pillarSpacing": 0, "formationFraction": 0.0, "solid": "minecraft:stone"} + """; + static final String SHALLOW = """ + {"id": "shallow", "family": "CENOTE", "worldYRange": {"min": -60, "max": -50}, "spacing": 64, "probability": 1.0, + "radius": 8, "height": 12, "fluidDepth": 2, "solid": "minecraft:stone"} + """; + static final String DISABLED = """ + {"id": "off", "enabled": false, "family": "CENOTE", "spacing": 64, "probability": 1.0, "radius": 8, "height": 12, + "solid": "minecraft:stone"} + """; + private static final Field ZERO = new Field.Const(0.0); + + private SubterrainTestPacks() { + } + + static File pack(File folder, String... features) throws IOException { + return packWith(folder, "", features); + } + + static File packWith(File folder, String extra, String... features) throws IOException { + String dimension = "{\"regions\":[\"region\",\"second\"],\"subterrainFeatures\":[" + String.join(",", features) + "]" + + (extra.isEmpty() ? "" : "," + extra) + "}"; + File pack = ExportTestPacks.withDimension(folder, dimension); + CompatTestPlatform.write(pack, "regions/second.json", "{\"landBiomes\":[\"desert\"],\"seaBiomes\":[\"ocean\"]}"); + CompatTestPlatform.write(pack, "biomes/desert.json", "{\"name\":\"Desert\"}"); + CompatTestPlatform.write(pack, "biomes/room.json", """ + {"name": "Room", + "layers": [{"palette": [{"block": "minecraft:grass_block"}], "minHeight": 1, "maxHeight": 1}], + "caveCeilingLayers": [{"palette": [{"block": "minecraft:grass_block"}], "minHeight": 1, "maxHeight": 1}], + "wall": {"palette": [{"block": "minecraft:grass_block"}]}} + """); + return pack; + } + + static PackCompileContext context(IrisData data, DataVersion target) { + PackCompileContext context = ExportTestPacks.context(data, target); + new DimensionStage().apply(context); + context.setBiomeCodes(new BiomeCodes(ZERO, ZERO, ZERO, new ClimateLayout(List.of()), EXPORTED)); + return context; + } + + static SortedMap json(PackCompileContext context) { + SortedMap resources = new TreeMap<>(); + for (JsonResource resource : context.jsonResources()) { + if (resources.put(resource.path(), resource.json()) != null) { + throw new AssertionError("Duplicate json resource " + resource.path()); + } + } + return resources; + } + + static SortedMap binaries(PackCompileContext context) { + SortedMap resources = new TreeMap<>(); + for (BinaryResource resource : context.binaryResources()) { + if (resources.put(resource.path(), resource.bytes()) != null) { + throw new AssertionError("Duplicate binary resource " + resource.path()); + } + } + return resources; + } + + static List diagnostics(List diagnostics, ExportLimit limit) { + List matching = new ArrayList<>(); + for (ExportDiagnostic diagnostic : diagnostics) { + if (diagnostic.limit() == limit) { + matching.add(diagnostic); + } + } + return matching; + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/surface/MaterialProgramCompilerTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/surface/MaterialProgramCompilerTest.java new file mode 100644 index 000000000..7221486bc --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/surface/MaterialProgramCompilerTest.java @@ -0,0 +1,390 @@ +package art.arcane.iris.pack.datapack.export.compile.surface; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ExportTestPacks; +import art.arcane.iris.pack.datapack.export.compile.BlockStateResolver; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; +import art.arcane.iris.pack.datapack.export.compile.StageTestPacks; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.JsonResource; +import art.arcane.iris.pack.datapack.export.ir.MaterialProgram; +import art.arcane.iris.pack.datapack.export.ir.NoiseDef; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class MaterialProgramCompilerTest { + @ClassRule + public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + private static final String DIMENSION = "{\"regions\":[\"region\"]}"; + private static final String NO_BEDROCK = "{\"regions\":[\"region\"],\"bedrock\":false}"; + private static final Field.Shared DEEP_OPENING = new Field.Shared("cave/deep_opening", new Field.Const(1.0)); + private static final Field.Shared CAVE_FIELD = new Field.Shared("cave/field", new Field.Const(1.0)); + private static final double WALL_BAND = 0.04; + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Rule + public ExportTestPacks.BoundPlatform platform = new ExportTestPacks.BoundPlatform(); + + @Test + public void depthGuardPrecedesBiomeDispatch() throws Exception { + Map biomes = new LinkedHashMap<>(); + biomes.put("plains", layers("{\"palette\":[{\"block\":\"grass_block\"}],\"minHeight\":1,\"maxHeight\":1}", + "{\"palette\":[{\"block\":\"dirt\"}],\"minHeight\":3,\"maxHeight\":3}")); + biomes.put("desert", layers("{\"palette\":[{\"block\":\"sand\"}],\"minHeight\":4,\"maxHeight\":4}")); + MaterialProgram program = compile(DIMENSION, biomes, DataVersion.V26_3); + + JsonObject rule = program.rule().getAsJsonObject(); + List guards = new ArrayList<>(); + for (JsonElement entry : rule.getAsJsonArray("sequence")) { + JsonObject top = entry.getAsJsonObject(); + assertFalse("a biome dispatch sits outside a depth guard", isBiomeCondition(top)); + if (isFloorGuard(top)) { + guards.add(top); + } + } + assertEquals(1, guards.size()); + JsonObject guard = guards.getFirst(); + assertEquals(3, guard.getAsJsonObject("if_true").get("offset").getAsInt()); + assertEquals(0, guard.getAsJsonObject("if_true").get("secondary_depth_range").getAsInt()); + List dispatch = StageTestPacks.ofType(guard.get("then_run"), "minecraft:biome"); + assertEquals(2, dispatch.size()); + assertEquals(StageTestPacks.ofType(rule, "minecraft:biome").size(), dispatch.size()); + } + + @Test + public void layerDepthBandsExact() throws Exception { + Map biomes = new LinkedHashMap<>(); + biomes.put("fixed", layers("{\"palette\":[{\"block\":\"grass_block\"}],\"minHeight\":1,\"maxHeight\":1}", + "{\"palette\":[{\"block\":\"dirt\"}],\"minHeight\":3,\"maxHeight\":3}", + "{\"palette\":[{\"block\":\"gravel\"}],\"minHeight\":2,\"maxHeight\":2}")); + biomes.put("varied", layers("{\"palette\":[{\"block\":\"sand\"}],\"minHeight\":1,\"maxHeight\":1}", + "{\"palette\":[{\"block\":\"sandstone\"}],\"minHeight\":1,\"maxHeight\":4}", + "{\"palette\":[{\"block\":\"clay\"}],\"minHeight\":0,\"maxHeight\":2}")); + PackCompileContext[] holder = new PackCompileContext[1]; + MaterialProgram program = compile(DIMENSION, biomes, DataVersion.V26_3, holder); + + JsonObject rule = program.rule().getAsJsonObject(); + assertEquals(List.of(List.of(0, 0), List.of(3, 0), List.of(5, 0)), bands(rule, StageTestPacks.biomeId("fixed"))); + assertEquals(List.of(List.of(0, 0), List.of(1, 3), List.of(1, 5)), bands(rule, StageTestPacks.biomeId("varied"))); + assertTrue(hasDiagnostic(holder[0], ExportLimit.LAYER_THICKNESS, "biomes/varied")); + assertFalse(hasDiagnostic(holder[0], ExportLimit.LAYER_THICKNESS, "biomes/fixed")); + } + + @Test + public void oreVeinRulesCappedAtEight() throws Exception { + List walls = List.of("stone_bricks", "mossy_cobblestone", "tuff", "calcite", "deepslate", "andesite", "diorite", + "granite", "basalt", "blackstone"); + Map biomes = new LinkedHashMap<>(); + for (int index = 0; index < walls.size(); index++) { + biomes.put("cave_" + index, "{\"layers\":[{\"palette\":[{\"block\":\"stone\"}]}],\"wall\":{\"palette\":[{\"block\":\"" + + walls.get(index) + "\"}]}}"); + } + biomes.put("shared", "{\"layers\":[{\"palette\":[{\"block\":\"stone\"}]}],\"wall\":{\"palette\":[{\"block\":\"stone_bricks\"}]}}"); + PackCompileContext[] holder = new PackCompileContext[1]; + MaterialProgram modern = compile(NO_BEDROCK, biomes, DataVersion.V26_3, holder, context -> context.setCaveField(CAVE_FIELD, WALL_BAND)); + + List veins = StageTestPacks.ofType(modern.rule(), "minecraft:ore_vein"); + assertEquals(MaterialProgramCompiler.ORE_VEIN_BUDGET, veins.size()); + for (JsonObject vein : veins) { + assertEquals("iris_export:" + MaterialProgramCompiler.WALL_SHELL, vein.get("density").getAsString()); + } + List shells = wallShells(holder[0]); + assertEquals(1, shells.size()); + JsonObject shell = shells.getFirst().json().getAsJsonObject(); + assertEquals("minecraft:range_choice", shell.get("type").getAsString()); + assertEquals(1.0, shell.get("when_in_range").getAsDouble(), 0.0); + assertEquals(0.0, shell.get("when_out_of_range").getAsDouble(), 0.0); + assertEquals(0.0, shell.get("min_inclusive").getAsDouble(), 0.0); + assertEquals(WALL_BAND, shell.get("max_exclusive").getAsDouble(), 0.0); + assertEquals("iris_export:" + CAVE_FIELD.name(), shell.get("input").getAsString()); + assertEquals(walls.size() - MaterialProgramCompiler.ORE_VEIN_BUDGET, count(holder[0], ExportLimit.WALL_PALETTE)); + } + + @Test + public void overLimitWallPalettesUseTheNearestKeptPalette() throws Exception { + Map biomes = new LinkedHashMap<>(); + biomes.put("quarry", wall("{\"block\":\"stone\"}")); + biomes.put("cliff", wall("{\"block\":\"stone\"}")); + for (String kept : List.of("gray_terracotta", "packed_ice", "deepslate", "tuff", "calcite", "andesite", "diorite")) { + biomes.put("kept_" + kept, wall("{\"block\":\"" + kept + "\"}")); + } + biomes.put("mesa", wall("{\"block\":\"orange_terracotta\"}")); + biomes.put("frozen", wall("{\"block\":\"blue_ice\"}")); + biomes.put("dark", wall("{\"block\":\"granite\",\"weight\":1},{\"block\":\"deepslate\",\"weight\":3}")); + biomes.put("glassy", wall("{\"block\":\"glass\"}")); + PackCompileContext[] holder = new PackCompileContext[1]; + MaterialProgram program = compile(NO_BEDROCK, biomes, DataVersion.V26_3, holder, context -> context.setCaveField(CAVE_FIELD, WALL_BAND)); + + assertEquals(MaterialProgramCompiler.ORE_VEIN_BUDGET, StageTestPacks.ofType(program.rule(), "minecraft:ore_vein").size()); + assertEquals("minecraft:gray_terracotta", wallFiller(program, "mesa")); + assertEquals("minecraft:packed_ice", wallFiller(program, "frozen")); + assertEquals("minecraft:deepslate", wallFiller(program, "dark")); + assertEquals("minecraft:stone", wallFiller(program, "glassy")); + assertEquals(4, count(holder[0], ExportLimit.WALL_PALETTE)); + for (ExportDiagnostic diagnostic : holder[0].diagnostics()) { + if (diagnostic.limit() == ExportLimit.WALL_PALETTE) { + String fallback = wallFiller(program, diagnostic.resource().substring("biomes/".length())); + assertTrue(diagnostic.message(), diagnostic.message().contains(fallback)); + } + } + } + + @Test + public void wallPalettesNeedCaves() throws Exception { + Map biomes = new LinkedHashMap<>(); + biomes.put("cave", "{\"layers\":[{\"palette\":[{\"block\":\"stone\"}]}],\"wall\":{\"palette\":[{\"block\":\"tuff\"}]}}"); + PackCompileContext[] holder = new PackCompileContext[1]; + MaterialProgram program = compile(NO_BEDROCK, biomes, DataVersion.V26_3, holder); + + assertTrue(StageTestPacks.ofType(program.rule(), "minecraft:ore_vein").isEmpty()); + assertTrue(wallShells(holder[0]).isEmpty()); + assertEquals(0, count(holder[0], ExportLimit.WALL_PALETTE)); + } + + @Test + public void blockRuleUsesIdAndProperties() throws Exception { + Map biomes = new LinkedHashMap<>(); + biomes.put("logs", layers("{\"palette\":[{\"block\":\"oak_log\",\"data\":{\"axis\":\"x\"}}],\"minHeight\":1,\"maxHeight\":1}")); + MaterialProgram program = compile(NO_BEDROCK, biomes, DataVersion.V26_3); + + JsonObject modern = StageTestPacks.ofType(program.rule(), "minecraft:block").getFirst(); + assertEquals("{\"type\":\"minecraft:block\",\"result_state\":{\"id\":\"minecraft:oak_log\",\"properties\":{\"axis\":\"x\"}}}", modern.toString()); + } + + @Test + public void staticPaletteUsesGradientPairsAndSmoothPaletteUsesRegisteredNoise() throws Exception { + Map biomes = new LinkedHashMap<>(); + biomes.put("speckled", layers("{\"palette\":[{\"block\":\"stone\",\"weight\":3},{\"block\":\"andesite\"}],\"minHeight\":1,\"maxHeight\":1}", + "{\"palette\":[{\"block\":\"dirt\"},{\"block\":\"coarse_dirt\"}],\"style\":{\"style\":\"SIMPLEX\"},\"zoom\":8,\"minHeight\":1,\"maxHeight\":1}")); + PackCompileContext[] holder = new PackCompileContext[1]; + MaterialProgram program = compile(NO_BEDROCK, biomes, DataVersion.V26_3, holder); + + List gradients = StageTestPacks.ofType(program.rule(), "minecraft:vertical_gradient"); + assertEquals(2, gradients.size()); + int seaLevel = holder[0].dimensionDef().fluidHeight(); + assertEquals(0.25, ramp(gradients.get(0), seaLevel) * (1.0 - ramp(gradients.get(1), seaLevel)), 1.0e-3); + List thresholds = StageTestPacks.ofType(program.rule(), "minecraft:noise_threshold"); + assertEquals(1, thresholds.size()); + String noise = thresholds.getFirst().get("noise").getAsString(); + assertEquals("iris_export:surface/palette_3_1", noise); + NoiseDef registered = holder[0].noises().definitions().getFirst(); + assertEquals("surface/palette_3_1", registered.id()); + assertEquals(-3, registered.firstOctave()); + assertTrue(thresholds.getFirst().get("is_3d").getAsBoolean()); + } + + @Test + public void gradientChanceHoldsProbabilityAcrossSurfaceBand() { + for (double probability : List.of(0.5, 0.3, 0.1, 0.01, 0.001)) { + List conditions = SurfacePalettes.gradientChance(probability, "iris_export:test/", 63); + for (int y = 63 - 192; y <= 63 + 192; y += 32) { + double product = 1.0; + for (int index = 0; index < conditions.size(); index += 2) { + product *= ramp(conditions.get(index), y) * (1.0 - ramp(conditions.get(index + 1).getAsJsonObject("invert"), y)); + } + assertEquals("p=" + probability + " y=" + y, probability, product, probability * 0.06); + } + } + } + + @Test + public void bedrockAndOreBandsUseExactAnchors() throws Exception { + String dimension = "{\"regions\":[\"region\"],\"ores\":[{\"palette\":{\"palette\":[{\"block\":\"deepslate\"}]},\"threshold\":1,\"range\":{\"min\":1,\"max\":57}}]}"; + Map biomes = new LinkedHashMap<>(); + biomes.put("plains", layers("{\"palette\":[{\"block\":\"grass_block\"}],\"minHeight\":1,\"maxHeight\":1}")); + PackCompileContext[] holder = new PackCompileContext[1]; + MaterialProgram program = compile(dimension, biomes, DataVersion.V26_3, holder); + + int minY = holder[0].dimensionDef().minY(); + JsonObject sequence = program.rule().getAsJsonObject(); + JsonObject bedrock = sequence.getAsJsonArray("sequence").get(0).getAsJsonObject(); + JsonObject floor = bedrock.getAsJsonObject("if_true"); + assertEquals(minY, floor.getAsJsonObject("true_at_and_below").get("absolute").getAsInt()); + assertEquals(minY + 1, floor.getAsJsonObject("false_at_and_above").get("absolute").getAsInt()); + List anchors = StageTestPacks.ofType(program.rule(), "minecraft:y_above"); + assertEquals(List.of(minY + 1, minY + 58), List.of(anchors.get(0).getAsJsonObject("anchor").get("absolute").getAsInt(), + anchors.get(1).getAsJsonObject("anchor").get("absolute").getAsInt())); + JsonElement last = sequence.getAsJsonArray("sequence").get(sequence.getAsJsonArray("sequence").size() - 1); + assertFalse(StageTestPacks.ofType(last, "minecraft:y_above").isEmpty()); + } + + @Test + public void compilationIsDeterministic() throws Exception { + Map biomes = new LinkedHashMap<>(); + biomes.put("plains", layers("{\"palette\":[{\"block\":\"grass_block\"},{\"block\":\"moss_block\"}],\"minHeight\":1,\"maxHeight\":2}", + "{\"palette\":[{\"block\":\"dirt\"},{\"block\":\"gravel\"}],\"style\":{\"style\":\"PERLIN\"},\"minHeight\":2,\"maxHeight\":3}")); + biomes.put("cave", "{\"layers\":[{\"palette\":[{\"block\":\"stone\"}]}],\"caveCeilingLayers\":[{\"palette\":[{\"block\":\"tuff\"}]}]," + + "\"wall\":{\"palette\":[{\"block\":\"tuff\",\"weight\":2},{\"block\":\"calcite\"},{\"block\":\"diorite\"},{\"block\":\"granite\"}]}}"); + MaterialProgram first = compile(DIMENSION, biomes, DataVersion.V26_3, new PackCompileContext[1], context -> context.setCaveField(CAVE_FIELD, WALL_BAND)); + MaterialProgram second = compile(DIMENSION, biomes, DataVersion.V26_3, new PackCompileContext[1], context -> context.setCaveField(CAVE_FIELD, WALL_BAND)); + + assertEquals(first.rule().toString(), second.rule().toString()); + assertEquals(1, StageTestPacks.ofType(first.rule(), "minecraft:ore_vein").size()); + JsonObject vein = StageTestPacks.ofType(first.rule(), "minecraft:ore_vein").getFirst(); + assertEquals("minecraft:tuff", vein.getAsJsonObject("filler_block").get("id").getAsString()); + assertEquals(0.5f, vein.get("richness").getAsFloat(), 1.0e-6f); + assertEquals(0.5f, vein.get("raw_ore_chance").getAsFloat(), 1.0e-6f); + } + + @Test + public void deepCaveOpeningLeadsTheRule() throws Exception { + Map biomes = new LinkedHashMap<>(); + biomes.put("plains", layers("{\"palette\":[{\"block\":\"grass_block\"}],\"minHeight\":1,\"maxHeight\":1}")); + MaterialProgram program = compile(DIMENSION, biomes, DataVersion.V26_3, new PackCompileContext[1], context -> context.setDeepCaves(DEEP_OPENING)); + + JsonObject head = program.rule().getAsJsonObject().getAsJsonArray("sequence").get(0).getAsJsonObject(); + assertEquals("minecraft:ore_vein", head.get("type").getAsString()); + assertEquals("iris_export:" + DEEP_OPENING.name(), head.get("density").getAsString()); + for (String slot : List.of("ore_block", "raw_ore_block", "filler_block")) { + assertEquals("minecraft:air", head.getAsJsonObject(slot).get("id").getAsString()); + } + assertEquals(0.0, head.get("richness").getAsDouble(), 0.0); + assertEquals(1, StageTestPacks.ofType(program.rule(), "minecraft:ore_vein").size()); + } + + @Test + public void deepCaveRuleCountsTowardTheOreVeinBudget() throws Exception { + Map biomes = new LinkedHashMap<>(); + for (String wall : List.of("stone_bricks", "mossy_cobblestone", "tuff", "calcite", "deepslate", "andesite", "diorite", "granite", "basalt")) { + biomes.put("cave_" + wall, "{\"layers\":[{\"palette\":[{\"block\":\"stone\"}]}],\"wall\":{\"palette\":[{\"block\":\"" + wall + "\"}]}}"); + } + PackCompileContext[] holder = new PackCompileContext[1]; + MaterialProgram program = compile(NO_BEDROCK, biomes, DataVersion.V26_3, holder, context -> { + context.setCaveField(CAVE_FIELD, WALL_BAND); + context.setDeepCaves(DEEP_OPENING); + }); + + assertEquals(MaterialProgramCompiler.ORE_VEIN_BUDGET, StageTestPacks.ofType(program.rule(), "minecraft:ore_vein").size()); + assertEquals(biomes.size() - MaterialProgramCompiler.ORE_VEIN_BUDGET + 1, count(holder[0], ExportLimit.WALL_PALETTE)); + } + + private MaterialProgram compile(String dimension, Map biomes, DataVersion target) throws Exception { + return compile(dimension, biomes, target, new PackCompileContext[1]); + } + + private MaterialProgram compile(String dimension, Map biomes, DataVersion target, PackCompileContext[] holder) throws Exception { + return compile(dimension, biomes, target, holder, context -> { + }); + } + + private MaterialProgram compile(String dimension, Map biomes, DataVersion target, PackCompileContext[] holder, + Consumer setup) throws Exception { + File pack = StageTestPacks.pack(temporaryFolder.newFolder(), dimension, biomes); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = StageTestPacks.context(data, target, new ArrayList<>(biomes.keySet())); + setup.accept(context); + holder[0] = context; + return new MaterialProgramCompiler(context, BlockStateResolver.forContext(context)).compile(); + } finally { + data.close(); + } + } + + private static List wallShells(PackCompileContext context) { + String path = "data/iris_export/worldgen/density_function/" + MaterialProgramCompiler.WALL_SHELL + ".json"; + return context.jsonResources().stream().filter(resource -> resource.path().equals(path)).toList(); + } + + private static String layers(String... layers) { + return "{\"layers\":[" + String.join(",", layers) + "]}"; + } + + private static boolean isBiomeCondition(JsonObject rule) { + return "minecraft:condition".equals(rule.get("type").getAsString()) + && "minecraft:biome".equals(rule.getAsJsonObject("if_true").get("type").getAsString()); + } + + private static boolean isFloorGuard(JsonObject rule) { + if (!"minecraft:condition".equals(rule.get("type").getAsString())) { + return false; + } + JsonObject condition = rule.getAsJsonObject("if_true"); + return "minecraft:stone_depth".equals(condition.get("type").getAsString()) && "floor".equals(condition.get("surface_type").getAsString()); + } + + private static List> bands(JsonObject rule, String biomeId) { + JsonObject branch = StageTestPacks.ofType(rule, "minecraft:condition").stream() + .filter(object -> "minecraft:biome".equals(object.getAsJsonObject("if_true").get("type").getAsString()) + && object.getAsJsonObject("if_true").getAsJsonArray("biome_is").size() == 1 + && biomeId.equals(object.getAsJsonObject("if_true").getAsJsonArray("biome_is").get(0).getAsString())).findFirst().orElseThrow(); + List> bands = new ArrayList<>(); + for (JsonObject depth : StageTestPacks.ofType(branch.get("then_run"), "minecraft:stone_depth")) { + bands.add(List.of(depth.get("offset").getAsInt(), depth.get("secondary_depth_range").getAsInt())); + } + return bands; + } + + private static double ramp(JsonObject gradient, int y) { + int trueAt = gradient.getAsJsonObject("true_at_and_below").get("absolute").getAsInt(); + int falseAt = gradient.getAsJsonObject("false_at_and_above").get("absolute").getAsInt(); + if (y <= trueAt) { + return 1.0; + } + if (y >= falseAt) { + return 0.0; + } + return (double) (falseAt - y) / (falseAt - trueAt); + } + + private static String wall(String palette) { + return "{\"layers\":[{\"palette\":[{\"block\":\"stone\"}]}],\"wall\":{\"palette\":[" + palette + "]}}"; + } + + private static String wallFiller(MaterialProgram program, String biome) { + String biomeId = StageTestPacks.biomeId(biome); + for (JsonObject condition : StageTestPacks.ofType(program.rule(), "minecraft:condition")) { + JsonObject test = condition.getAsJsonObject("if_true"); + JsonObject rule = condition.getAsJsonObject("then_run"); + if (!"minecraft:biome".equals(test.get("type").getAsString()) || !"minecraft:ore_vein".equals(rule.get("type").getAsString())) { + continue; + } + for (JsonElement member : test.getAsJsonArray("biome_is")) { + if (member.getAsString().equals(biomeId)) { + return rule.getAsJsonObject("filler_block").get("id").getAsString(); + } + } + } + throw new AssertionError("Biome " + biome + " has no wall rule"); + } + + private static boolean hasDiagnostic(PackCompileContext context, ExportLimit limit, String resource) { + for (ExportDiagnostic diagnostic : context.diagnostics()) { + if (diagnostic.limit() == limit && diagnostic.resource().equals(resource)) { + return true; + } + } + return false; + } + + private static int count(PackCompileContext context, ExportLimit limit) { + int count = 0; + for (ExportDiagnostic diagnostic : context.diagnostics()) { + if (diagnostic.limit() == limit) { + count++; + } + } + return count; + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/terrain/BandTreeCompilerTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/terrain/BandTreeCompilerTest.java new file mode 100644 index 000000000..667bfb3b3 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/terrain/BandTreeCompilerTest.java @@ -0,0 +1,156 @@ +package art.arcane.iris.pack.datapack.export.compile.terrain; + +import art.arcane.iris.generation.noise.IrisInterpolator; +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.compile.biome.BiomeCodes; +import art.arcane.iris.pack.datapack.export.compile.noise.NoiseRegistry; +import art.arcane.iris.pack.datapack.export.ir.Axis; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.volmlib.util.interpolation.InterpolationMethod; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class BandTreeCompilerTest { + private static final Map VALUES = Map.of(TerrainTestSupport.NAMESPACE + ":a", 10.0, TerrainTestSupport.NAMESPACE + ":b", 80.0); + + @Test + public void hardBandFollowsTheLayoutCodes() { + NoiseRegistry registry = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + BiomeCodes codes = TerrainTestSupport.twoBiomeCodes(registry, 0.0); + BandTreeCompiler bands = new BandTreeCompiler(codes, "test"); + + Field hard = bands.hard(VALUES); + + for (int x = -2000; x <= 2000; x += 97) { + double code = TerrainTestSupport.sample(codes.surfaceCode(), registry, DataVersion.V26_3, x, 0, 31); + double expected = code < 0.2 ? 10.0 : 80.0; + assertEquals(expected, TerrainTestSupport.sample(hard, registry, DataVersion.V26_3, x, 0, 31), 0.0); + } + } + + @Test + public void blendMatchesTheLeavesAwayFromTheThreshold() { + NoiseRegistry registry = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + BiomeCodes codes = TerrainTestSupport.twoBiomeCodes(registry, 0.0); + BandTreeCompiler bands = new BandTreeCompiler(codes, "test"); + Field blend = bands.band(VALUES, interpolator(InterpolationMethod.BILINEAR_STARCAST_9, 12.0)); + Field hard = bands.hard(VALUES); + int blended = 0; + + for (int x = -3000; x <= 3000; x += 13) { + double value = TerrainTestSupport.sample(blend, registry, DataVersion.V26_3, x, 0, 7); + double exact = TerrainTestSupport.sample(hard, registry, DataVersion.V26_3, x, 0, 7); + assertTrue(value >= 10.0 - 1.0e-3 && value <= 80.0 + 1.0e-3); + if (Math.abs(value - exact) > 1.0e-3) { + blended++; + } + } + assertTrue("blend should soften some columns, got " + blended, blended > 0); + assertTrue("blend should leave most columns at their leaf value, got " + blended, blended < 300); + assertFalse(blend.axes().contains(Axis.Y)); + } + + @Test + public void starcastBlendsAverageThreeLadderLattices() { + assertEquals(List.of(23, 32, 45), BandTreeCompiler.lattices(interpolator(InterpolationMethod.BILINEAR_STARCAST_9, 12.0))); + assertEquals(List.of(362, 512, 724), BandTreeCompiler.lattices(interpolator(InterpolationMethod.BILINEAR_STARCAST_12, 200.0))); + assertEquals(List.of(64, 91, 128), BandTreeCompiler.lattices(interpolator(InterpolationMethod.STARCAST_12, 42.0))); + assertEquals(List.of(30), BandTreeCompiler.lattices(interpolator(InterpolationMethod.BILINEAR, 30.0))); + assertEquals(List.of(), BandTreeCompiler.lattices(interpolator(InterpolationMethod.NONE, 30.0))); + } + + @Test + public void blendSamplesTheSelectorWithoutOffsetCopies() { + NoiseRegistry registry = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + BiomeCodes codes = TerrainTestSupport.twoBiomeCodes(registry, 0.0); + BandTreeCompiler bands = new BandTreeCompiler(codes, "test"); + Map other = Map.of(TerrainTestSupport.NAMESPACE + ":a", 3.0, TerrainTestSupport.NAMESPACE + ":b", 40.0); + + Field first = bands.band(VALUES, interpolator(InterpolationMethod.BILINEAR_STARCAST_12, 200.0)); + Field second = bands.band(other, interpolator(InterpolationMethod.BILINEAR_STARCAST_9, 26.0)); + + Set noises = new HashSet<>(nodes(first, Field.Noise.class)); + noises.addAll(nodes(second, Field.Noise.class)); + assertEquals(new HashSet<>(nodes(codes.surfaceCode(), Field.Noise.class)), noises); + Set lattices = new TreeSet<>(); + for (Field shared : nodes(new Field.Binary(Field.BinaryOp.ADD, first, second), Field.Shared.class)) { + String name = ((Field.Shared) shared).name(); + if (name.startsWith("terrain/test/blend/lattice/")) { + lattices.add(name); + } + } + assertEquals(Set.of("terrain/test/blend/lattice/362", "terrain/test/blend/lattice/512", "terrain/test/blend/lattice/724", + "terrain/test/blend/lattice/45", "terrain/test/blend/lattice/64", "terrain/test/blend/lattice/91"), lattices); + } + + @Test + public void noneInterpolationIsTheHardBand() { + NoiseRegistry registry = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + BandTreeCompiler bands = new BandTreeCompiler(TerrainTestSupport.twoBiomeCodes(registry, 0.0), "test"); + + assertEquals(bands.hard(VALUES), bands.band(VALUES, interpolator(InterpolationMethod.NONE, 30.0))); + } + + @Test + public void equalValuesCollapseToAConstant() { + NoiseRegistry registry = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + BandTreeCompiler bands = new BandTreeCompiler(TerrainTestSupport.twoBiomeCodes(registry, 0.0), "test"); + Map flat = Map.of(TerrainTestSupport.NAMESPACE + ":a", 5.0, TerrainTestSupport.NAMESPACE + ":b", 5.0); + + assertEquals(new Field.Const(5.0), bands.band(flat, interpolator(InterpolationMethod.BILINEAR_STARCAST_9, 12.0))); + } + + @Test + public void baseCodeSubtreeDrivesTheBands() { + NoiseRegistry registry = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + BiomeCodes plain = TerrainTestSupport.twoBiomeCodes(registry, 0.0); + Field base = new Field.Shared(BiomeCodes.BASE_CODE, plain.surfaceCode()); + Field relabelled = new Field.RangeIf(base, 0.25, 0.35, base, new Field.Const(0.1)); + BiomeCodes codes = new BiomeCodes(relabelled, plain.caveCode(), plain.class3d(), plain.layout(), plain.loadKeyByBiomeId()); + + Field selector = BandTreeCompiler.selector(codes); + + assertEquals(base, selector); + } + + @Test + public void heightDependentCodeWithoutBaseCodeIsRejected() { + NoiseRegistry registry = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + BiomeCodes plain = TerrainTestSupport.twoBiomeCodes(registry, 0.0); + Field height = new Field.Shared(HeightCompiler.HEIGHT, new Field.Const(63.0)); + Field relabelled = new Field.RangeIf(height, 60.0, 70.0, new Field.Const(0.3), plain.surfaceCode()); + BiomeCodes codes = new BiomeCodes(relabelled, plain.caveCode(), plain.class3d(), plain.layout(), plain.loadKeyByBiomeId()); + + IllegalStateException failure = assertThrows(IllegalStateException.class, () -> BandTreeCompiler.selector(codes)); + + assertTrue(failure.getMessage(), failure.getMessage().contains(BiomeCodes.BASE_CODE)); + } + + private static IrisInterpolator interpolator(InterpolationMethod method, double radius) { + IrisInterpolator interpolator = new IrisInterpolator(); + interpolator.setFunction(method); + interpolator.setHorizontalScale(radius); + return interpolator; + } + + private static List nodes(Field root, Class type) { + List found = new ArrayList<>(); + for (Field field : TerrainTestSupport.nodes(root)) { + if (type.isInstance(field)) { + found.add(field); + } + } + return found; + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/terrain/ClimateHeightCompilerTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/terrain/ClimateHeightCompilerTest.java new file mode 100644 index 000000000..ed4ebd5a1 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/terrain/ClimateHeightCompilerTest.java @@ -0,0 +1,185 @@ +package art.arcane.iris.pack.datapack.export.compile.terrain; + +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ExportTestPacks; +import art.arcane.iris.pack.datapack.export.GenerationCost; +import art.arcane.iris.pack.datapack.export.compile.biome.BiomeCodes; +import art.arcane.iris.pack.datapack.export.compile.noise.NoiseRegistry; +import art.arcane.iris.pack.datapack.export.compile.noise.StyleCompiler; +import art.arcane.iris.pack.datapack.export.ir.AquiferDef; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.FieldValidator; +import art.arcane.iris.pack.datapack.export.ir.NoiseDef; +import art.arcane.iris.pack.datapack.export.ir.RouterDef; +import art.arcane.iris.pack.datapack.export.lower.ExportCapabilities; +import art.arcane.iris.pack.datapack.export.lower.GenerationCostCounter; +import art.arcane.iris.pack.datapack.export.lower.VanillaBounds; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Deque; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class ClimateHeightCompilerTest { + private static final ExportCapabilities CAPABILITIES = ExportCapabilities.forTarget(DataVersion.V26_3); + private static final int SEA_LEVEL = 63; + + @ClassRule + public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Rule + public ExportTestPacks.BoundPlatform platform = new ExportTestPacks.BoundPlatform(); + + @Test + public void climateHeightIsTheUnblendedTerrainHeight() throws Exception { + IrisData data = IrisData.openRuntime(pack()); + try { + NoiseRegistry registry = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + BiomeCodes codes = TerrainTestSupport.twoBiomeCodes(registry, 0.0); + IrisDimension dimension = data.getDimensionLoader().load(TerrainTestPacks.DIMENSION, false); + Field generator = new GeneratorCompiler(TerrainTestSupport.noiseStyles(), registry, CAPABILITIES, new ArrayList<>()) + .compile(data.getGeneratorLoader().load("hills", false), "height"); + Field selector = new Field.Noise(registry.namespace() + ":" + TerrainTestSupport.SELECTOR_ID, 1.0, 0.0, null, null, null); + + Field climate = new ClimateHeightCompiler(TerrainTestSupport.noiseStyles()).compile(codes, dimension, data, registry, CAPABILITIES); + + assertEquals(ClimateHeightCompiler.CLIMATE_HEIGHT, ((Field.Shared) climate).name()); + FieldValidator.requireTwoDimensional(climate, "climate height"); + assertFalse(contains(climate, Field.Grid.class)); + for (int x = -2000; x <= 2000; x += 97) { + for (int z = -2000; z <= 2000; z += 89) { + double mean = TerrainTestSupport.sample(generator, registry, DataVersion.V26_3, x, 0, z); + boolean low = TerrainTestSupport.sample(selector, registry, DataVersion.V26_3, x, 0, z) < 0.0; + double expected = SEA_LEVEL + (low ? 20.0 * mean : 60.0 + 40.0 * mean); + double actual = TerrainTestSupport.sample(climate, registry, DataVersion.V26_3, x, 0, z); + assertEquals(x + "," + z, expected, actual, 1.0e-3); + } + } + } finally { + data.close(); + } + } + + @Test + public void climateHeightCostsAFractionOfTheTerrainHeightUncached() throws Exception { + IrisData data = IrisData.openRuntime(pack()); + try { + NoiseRegistry registry = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + BiomeCodes codes = TerrainTestSupport.twoBiomeCodes(registry, 0.0); + IrisDimension dimension = data.getDimensionLoader().load(TerrainTestPacks.DIMENSION, false); + Field terrain = new HeightCompiler(TerrainTestSupport.noiseStyles(), new ArrayList<>()).compile(codes, dimension, data, registry, CAPABILITIES).height(); + Field climate = new ClimateHeightCompiler(TerrainTestSupport.noiseStyles()).compile(codes, dimension, data, registry, CAPABILITIES); + + double terrainCost = worstCase(terrain, registry); + double climateCost = worstCase(climate, registry); + + assertTrue(climateCost + " vs " + terrainCost, climateCost * 10.0 < terrainCost); + assertTrue(String.valueOf(climateCost), climateCost <= 30.0); + } finally { + data.close(); + } + } + + @Test + public void expensiveGeneratorsEnterAsTheirCalibratedMean() throws Exception { + IrisData data = IrisData.openRuntime(pack()); + try { + NoiseRegistry registry = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + BiomeCodes codes = TerrainTestSupport.twoBiomeCodes(registry, 0.0); + IrisDimension dimension = data.getDimensionLoader().load(TerrainTestPacks.DIMENSION, false); + StyleCompiler expensive = (style, seedSalt, styleRegistry, capabilities) -> { + String id = styleRegistry.register(new NoiseDef("test/expensive", -5, List.of(1.0))); + Field value = new Field.Binary(Field.BinaryOp.ADD, new Field.Const(0.5), new Field.Binary(Field.BinaryOp.MUL, new Field.Const(0.5), + new Field.Noise(id, 1.0 / style.getZoom(), 0.0, null, null, null))); + for (int level = 0; level < 12; level++) { + value = new Field.Shared("test/expensive/" + level, new Field.Binary(Field.BinaryOp.MIN, value, value)); + } + return value; + }; + + Field climate = new ClimateHeightCompiler(expensive).compile(codes, dimension, data, registry, CAPABILITIES); + + assertTrue(String.valueOf(worstCase(climate, registry)), worstCase(climate, registry) <= 20.0); + Field selector = new Field.Noise(registry.namespace() + ":" + TerrainTestSupport.SELECTOR_ID, 1.0, 0.0, null, null, null); + for (int x = -2000; x <= 2000; x += 211) { + double value = TerrainTestSupport.sample(climate, registry, DataVersion.V26_3, x, 0, 37); + boolean low = TerrainTestSupport.sample(selector, registry, DataVersion.V26_3, x, 0, 37) < 0.0; + assertTrue(x + " " + value, low ? value > SEA_LEVEL && value < SEA_LEVEL + 20.0 : value > SEA_LEVEL + 60.0 && value < SEA_LEVEL + 100.0); + } + } finally { + data.close(); + } + } + + @Test + public void flatBiomesGiveAConstantLeafSelection() throws Exception { + File pack = TerrainTestPacks.twoBiomes(temporaryFolder.newFolder("pack"), + TerrainTestPacks.link("hills", 10, 10), TerrainTestPacks.link("hills", 50, 50)); + TerrainTestPacks.generator(pack, "hills", TerrainTestPacks.twoLayers("BILINEAR_STARCAST_9", 12)); + IrisData data = IrisData.openRuntime(pack); + try { + NoiseRegistry registry = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + BiomeCodes codes = TerrainTestSupport.twoBiomeCodes(registry, 0.0); + IrisDimension dimension = data.getDimensionLoader().load(TerrainTestPacks.DIMENSION, false); + + Field climate = new ClimateHeightCompiler(TerrainTestSupport.noiseStyles()).compile(codes, dimension, data, registry, CAPABILITIES); + + assertFalse(contains(climate, Field.Mux.class)); + assertTrue(contains(climate, Field.Partition.class)); + assertEquals(1, registry.definitions().size()); + } finally { + data.close(); + } + } + + private File pack() throws Exception { + File pack = TerrainTestPacks.twoBiomes(temporaryFolder.newFolder("pack"), + TerrainTestPacks.link("hills", 0, 20), TerrainTestPacks.link("hills", 60, 100)); + TerrainTestPacks.generator(pack, "hills", TerrainTestPacks.singleLayer("BILINEAR_STARCAST_9", 12, "SIMPLEX")); + return pack; + } + + private static double worstCase(Field field, NoiseRegistry registry) { + Field zero = new Field.Const(0.0); + RouterDef router = new RouterDef(field, zero, zero, zero, zero, zero, zero, zero, AquiferDef.dry(zero)); + return GenerationCostCounter.count(router, VanillaBounds.of(registry.namespace(), registry.definitions()), DataVersion.V26_3) + .root(GenerationCost.Root.TEMPERATURE).orElseThrow().worstCaseNodes(); + } + + private static boolean contains(Field root, Class type) { + Set visited = Collections.newSetFromMap(new IdentityHashMap<>()); + Deque pending = new ArrayDeque<>(); + pending.push(root); + while (!pending.isEmpty()) { + Field field = pending.pop(); + if (!visited.add(field)) { + continue; + } + if (type.isInstance(field)) { + return true; + } + for (Field child : FieldMacros.children(field)) { + pending.push(child); + } + } + return false; + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/terrain/FieldMacrosTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/terrain/FieldMacrosTest.java new file mode 100644 index 000000000..672e025cd --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/terrain/FieldMacrosTest.java @@ -0,0 +1,163 @@ +package art.arcane.iris.pack.datapack.export.compile.terrain; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.compile.noise.NoiseRegistry; +import art.arcane.iris.pack.datapack.export.ir.Axis; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.NoiseDef; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class FieldMacrosTest { + private static final double EPSILON = 1.0e-5; + private static final Field X = new Field.Coord(Axis.X, Field.Tiling.CLAMP, -4096, 4096, -40.96, 40.96); + private static final Field Y = new Field.Coord(Axis.Y, Field.Tiling.CLAMP, -64, 320, -64, 320); + + @Test + public void offsetEvaluatesTheExpressionAtTheShiftedPosition() { + NoiseRegistry registry = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + String id = registry.register(new NoiseDef("macro/base", -4, List.of(1.0, 0.5))); + String warpId = registry.register(new NoiseDef("macro/warp", -6, List.of(1.0))); + Field warp = new Field.Noise(warpId, 0.5, 0.0, null, null, null); + Field noise = new Field.Noise(id, 0.75, 0.0, new Field.Binary(Field.BinaryOp.MUL, new Field.Const(8.0), warp), null, null); + Field expr = new Field.Binary(Field.BinaryOp.ADD, noise, X); + + Field shifted = FieldMacros.offset(expr, 7.0, -3.0); + + for (int x = -40; x <= 40; x += 9) { + for (int z = -30; z <= 30; z += 11) { + assertEquals(TerrainTestSupport.sample(expr, registry, DataVersion.V26_3, x + 7, 0, z - 3), + TerrainTestSupport.sample(shifted, registry, DataVersion.V26_3, x, 0, z), EPSILON); + } + } + } + + @Test + public void offsetRenamesChangedSharedSubtreesOnly() { + NoiseRegistry registry = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + String id = registry.register(new NoiseDef("macro/base", -4, List.of(1.0))); + Field vertical = new Field.Shared("vertical", Y); + Field horizontal = new Field.Shared("horizontal", new Field.Noise(id, 1.0, 0.0, null, null, null)); + + Field shifted = FieldMacros.offset(new Field.Binary(Field.BinaryOp.ADD, vertical, horizontal), 4.0, 0.0); + + Field.Binary sum = (Field.Binary) shifted; + assertSame(vertical, sum.left()); + assertEquals("horizontal/offset_x4_z0", ((Field.Shared) sum.right()).name()); + } + + @Test + public void offsetOfReferenceFails() { + assertThrows(IllegalArgumentException.class, () -> FieldMacros.offset(new Field.Ref("minecraft:overworld/continents"), 1.0, 0.0)); + } + + @Test + public void signedDistanceOfALinearRampIsTheBlockDistance() { + NoiseRegistry registry = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + DataVersion target = DataVersion.V26_3; + Field distance = FieldMacros.signedDistance(X, 0.5, 4.0); + + assertEquals(-20.0, TerrainTestSupport.sample(distance, registry, target, 30, 0, 0), 0.01); + assertEquals(15.0, TerrainTestSupport.sample(distance, registry, target, 65, 0, 9), 0.01); + } + + @Test + public void substituteSwapsTheTargetAndRenamesChangedSharedSubtrees() { + Field target = new Field.Shared("target", X); + Field replacement = new Field.Const(5.0); + Field untouched = new Field.Shared("untouched", new Field.Unary(Field.UnaryOp.ABS, X)); + Field root = new Field.Binary(Field.BinaryOp.ADD, new Field.Shared("wrapper", new Field.Binary(Field.BinaryOp.MUL, target, target)), untouched); + + Field.Binary result = (Field.Binary) FieldMacros.substitute(root, target, replacement, "swap"); + + assertEquals(new Field.Shared("wrapper/swap", new Field.Binary(Field.BinaryOp.MUL, replacement, replacement)), result.left()); + assertTrue(result.right() == untouched); + assertTrue(FieldMacros.substitute(root, target, target, "swap") == root); + } + + @Test + public void productGuardsAnExpensivePayloadBehindTheMask() { + NoiseRegistry registry = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + String first = registry.register(new NoiseDef("macro/first", -4, List.of(1.0))); + String second = registry.register(new NoiseDef("macro/second", -5, List.of(1.0))); + Field mask = new Field.Noise(first, 1.0, 0.0, null, null, null); + Field payload = new Field.Binary(Field.BinaryOp.ADD, mask, new Field.Noise(second, 1.0, 0.0, null, null, null)); + + Field product = FieldMacros.product(mask, payload); + + Field.Binary multiply = (Field.Binary) product; + assertSame(mask, multiply.left()); + assertTrue(multiply.right() instanceof Field.Gate); + assertTrue(TerrainTestSupport.unguardedExpensiveBranches(product).isEmpty()); + for (int x = -50; x <= 50; x += 17) { + double expected = TerrainTestSupport.sample(mask, registry, DataVersion.V26_3, x, 0, 3) + * TerrainTestSupport.sample(payload, registry, DataVersion.V26_3, x, 0, 3); + assertEquals(expected, TerrainTestSupport.sample(product, registry, DataVersion.V26_3, x, 0, 3), 1.0e-6); + } + } + + @Test + public void unguardedExpensiveBranchesAreDetected() { + NoiseRegistry registry = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + Field first = new Field.Noise(registry.register(new NoiseDef("macro/first", -4, List.of(1.0))), 1.0, 0.0, null, null, null); + Field second = new Field.Noise(registry.register(new NoiseDef("macro/second", -5, List.of(1.0))), 1.0, 0.0, null, null, null); + Field expensive = new Field.Binary(Field.BinaryOp.ADD, first, second); + + assertEquals(1, TerrainTestSupport.unguardedExpensiveBranches(new Field.Binary(Field.BinaryOp.MUL, first, expensive)).size()); + assertEquals(1, TerrainTestSupport.unguardedExpensiveBranches(new Field.Lerp(first, expensive, second)).size()); + assertEquals(1, TerrainTestSupport.unguardedExpensiveBranches(new Field.RangeIf(first, 0.0, 1.0, second, expensive)).size()); + assertTrue(TerrainTestSupport.unguardedExpensiveBranches(new Field.Binary(Field.BinaryOp.MUL, new Field.Const(2.0), expensive)).isEmpty()); + assertTrue(TerrainTestSupport.unguardedExpensiveBranches(new Field.Gate(first, new Field.Binary(Field.BinaryOp.ADD, expensive, X))).isEmpty()); + } + + @Test + public void liftGivesHorizontalNoiseAVerticalScale() { + NoiseRegistry registry = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + String id = registry.register(new NoiseDef("macro/base", -4, List.of(1.0))); + Field flat = new Field.Noise(id, 0.5, 0.0, null, null, null); + + Field lifted = FieldMacros.lift(flat, 3.0); + + assertEquals(new Field.Noise(id, 0.5, 1.5, null, null, null), lifted); + assertTrue(lifted.axes().contains(Axis.Y)); + } + + @Test + public void smoothstepIsTheZeroDerivativeHermite() { + NoiseRegistry registry = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + Field ramp = FieldMacros.smoothstep(X, 0.0, 0.4); + + assertEquals(0.0, TerrainTestSupport.sample(ramp, registry, DataVersion.V26_3, -5, 0, 0), 0.0); + assertEquals(0.5, TerrainTestSupport.sample(ramp, registry, DataVersion.V26_3, 20, 0, 0), 1.0e-5); + assertEquals(0.15625, TerrainTestSupport.sample(ramp, registry, DataVersion.V26_3, 10, 0, 0), 1.0e-5); + assertEquals(1.0, TerrainTestSupport.sample(ramp, registry, DataVersion.V26_3, 90, 0, 0), 0.0); + } + + @Test + public void lookupFollowsTheFunction() { + NoiseRegistry registry = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + Field input = FieldMacros.scale(X, 10.0); + Field sine = FieldMacros.lookup(input, value -> Math.sin(Math.PI * value), value -> Math.PI * Math.cos(Math.PI * value), 0.0, 1.0, 16); + + for (int x = 0; x <= 10; x++) { + assertEquals(Math.sin(Math.PI * x / 10.0), TerrainTestSupport.sample(sine, registry, DataVersion.V26_3, x, 0, 0), 1.0e-4); + } + } + + @Test + public void sumFoldsConstantsAndBalancesTerms() { + Field sum = FieldMacros.sum(List.of(new Field.Const(1.0), X, X, new Field.Const(2.0), X, X)); + + Field.Binary root = (Field.Binary) sum; + assertEquals(new Field.Const(3.0), root.right()); + assertEquals(new Field.Binary(Field.BinaryOp.ADD, + new Field.Binary(Field.BinaryOp.ADD, X, X), + new Field.Binary(Field.BinaryOp.ADD, X, X)), root.left()); + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/terrain/GeneratorCompilerTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/terrain/GeneratorCompilerTest.java new file mode 100644 index 000000000..ae5ebb05d --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/terrain/GeneratorCompilerTest.java @@ -0,0 +1,229 @@ +package art.arcane.iris.pack.datapack.export.compile.terrain; + +import art.arcane.iris.generation.noise.IrisGenerator; +import art.arcane.iris.generation.noise.IrisGeneratorStyle; +import art.arcane.iris.generation.noise.IrisNoiseGenerator; +import art.arcane.iris.generation.noise.NoiseStyle; +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.compile.noise.NoiseRegistry; +import art.arcane.iris.pack.datapack.export.compile.noise.StyleCompiler; +import art.arcane.iris.pack.datapack.export.ir.Axis; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.lower.ExportCapabilities; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.interpolation.IrisInterpolation; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +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 GeneratorCompilerTest { + private static final ExportCapabilities CAPABILITIES = ExportCapabilities.forTarget(DataVersion.V26_3); + + @Test + public void cliffStepsAreExact() { + IrisGenerator cliffs = generator("cliffs", layer(NoiseStyle.SIMPLEX, 1.0)); + cliffs.setCliffHeightMin(40); + cliffs.setCliffHeightMax(40); + IrisGenerator smooth = generator("cliffs", layer(NoiseStyle.SIMPLEX, 1.0)); + NoiseRegistry registry = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + GeneratorCompiler compiler = compiler(TerrainTestSupport.noiseStyles(), registry); + Field stepped = compiler.compile(cliffs, "steps"); + Field raw = new GeneratorCompiler(TerrainTestSupport.noiseStyles(), registry, CAPABILITIES, new ArrayList<>()).compile(smooth, "steps"); + double step = 40.0 / 255.0; + int distinct = 0; + double previous = Double.NaN; + + for (int x = -400; x <= 400; x += 7) { + double value = TerrainTestSupport.sample(stepped, registry, DataVersion.V26_3, x, 0, 13); + double steps = value / step; + assertEquals("cliff value " + value + " at x " + x, Math.rint(steps), steps, 1.0e-3); + double expected = Math.round(TerrainTestSupport.sample(raw, registry, DataVersion.V26_3, x, 0, 13) / step) * step; + assertEquals(expected, value, 1.0e-5); + if (value != previous) { + distinct++; + previous = value; + } + } + assertTrue("cliffs should produce several terraces, got " + distinct, distinct > 3); + } + + @Test + public void variableCliffHeightQuantisesByTheLocalStep() { + IrisGenerator cliffs = generator("variable", layer(NoiseStyle.SIMPLEX, 1.0)); + cliffs.setCliffHeightMin(20); + cliffs.setCliffHeightMax(80); + NoiseRegistry registry = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + + Field.Shared compiled = (Field.Shared) compiler(TerrainTestSupport.noiseStyles(), registry).compile(cliffs, "steps"); + + Field.Round round = (Field.Round) compiled.expr(); + assertEquals(Field.RoundMode.ROUND, round.mode()); + for (int x = -300; x <= 300; x += 23) { + double multiple = TerrainTestSupport.sample(round.multiple(), registry, DataVersion.V26_3, x, 0, -5); + double value = TerrainTestSupport.sample(compiled, registry, DataVersion.V26_3, x, 0, -5); + assertTrue(multiple >= 20.0 / 255.0 - 1.0e-6 && multiple <= 80.0 / 255.0 + 1.0e-6); + assertEquals(Math.rint(value / multiple), value / multiple, 1.0e-3); + } + } + + @Test + public void bezierLayerMatchesTheIrisCurve() { + IrisNoiseGenerator curved = layer(NoiseStyle.SIMPLEX, 1.0); + curved.setBezier(true); + NoiseRegistry registry = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + Field shaped = compiler(TerrainTestSupport.noiseStyles(), registry).compile(generator("curve", curved), "curve"); + Field raw = compiler(TerrainTestSupport.noiseStyles(), registry).compile(generator("curve", layer(NoiseStyle.SIMPLEX, 1.0)), "curve"); + + for (int x = -200; x <= 200; x += 19) { + double source = TerrainTestSupport.sample(raw, registry, DataVersion.V26_3, x, 0, 4); + assertEquals(IrisInterpolation.bezier(source), TerrainTestSupport.sample(shaped, registry, DataVersion.V26_3, x, 0, 4), 1.0e-6); + } + } + + @Test + public void layersAverageByOpacity() { + IrisNoiseGenerator heavy = layer(NoiseStyle.SIMPLEX, 1.0); + IrisNoiseGenerator light = layer(NoiseStyle.PERLIN, 0.5); + light.setSeed(9); + IrisGenerator blended = generator("blend", heavy, light); + blended.setOpacity(0.8); + NoiseRegistry registry = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + GeneratorCompiler compiler = compiler(TerrainTestSupport.constantStyles(0.25), registry); + + Field value = compiler.compile(blended, "blend"); + + assertEquals((0.25 + 0.25 * 0.5) / 1.5 * 0.8, TerrainTestSupport.sample(value, registry, DataVersion.V26_3, 3, 0, 3), 1.0e-6); + } + + @Test + public void zoomsCombineIntoTheCompiledStyle() { + List zooms = new ArrayList<>(); + StyleCompiler recording = (style, seedSalt, registry, capabilities) -> { + zooms.add(style.getZoom()); + return new Field.Const(0.5); + }; + IrisNoiseGenerator zoomed = layer(NoiseStyle.SIMPLEX, 1.0); + zoomed.setZoom(3.0); + zoomed.getStyle().setZoom(2.0); + IrisGenerator generator = generator("zoom", zoomed); + generator.setZoom(5.0); + + compiler(recording, new NoiseRegistry(TerrainTestSupport.NAMESPACE)).compile(generator, "zoom"); + + assertEquals(List.of(30.0), zooms); + assertEquals(2.0, zoomed.getStyle().getZoom(), 0.0); + } + + @Test + public void surfaceDetailBlendsTheSixBlockGrid() { + IrisGenerator detailed = generator("detail", layer(NoiseStyle.SIMPLEX, 1.0)); + detailed.setSurfaceDetail(0.5); + NoiseRegistry registry = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + Field blended = compiler(TerrainTestSupport.noiseStyles(), registry).compile(detailed, "detail"); + Field raw = compiler(TerrainTestSupport.noiseStyles(), registry).compile(generator("detail", layer(NoiseStyle.SIMPLEX, 1.0)), "detail"); + + double low = TerrainTestSupport.sample(raw, registry, DataVersion.V26_3, 12, 0, 18); + double high = TerrainTestSupport.sample(raw, registry, DataVersion.V26_3, 18, 0, 18); + double middle = TerrainTestSupport.sample(raw, registry, DataVersion.V26_3, 15, 0, 18); + assertEquals(low, TerrainTestSupport.sample(blended, registry, DataVersion.V26_3, 12, 0, 18), 1.0e-6); + assertEquals(0.5 * middle + 0.25 * (low + high), TerrainTestSupport.sample(blended, registry, DataVersion.V26_3, 15, 0, 18), 1.0e-6); + } + + @Test + public void rawGeneratorSkipsTheSurfaceGridAndSharesTheBlendInput() { + IrisGenerator detailed = generator("detail", layer(NoiseStyle.SIMPLEX, 1.0)); + detailed.setSurfaceDetail(0.5); + NoiseRegistry registry = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + Field blended = compiler(TerrainTestSupport.noiseStyles(), registry).compile(detailed, "detail"); + GeneratorCompiler compiler = compiler(TerrainTestSupport.noiseStyles(), registry); + + Field raw = compiler.compileRaw(detailed, "detail"); + + assertSame(raw, compiler.compileRaw(detailed, "detail")); + assertTrue(TerrainTestSupport.contains(blended, raw)); + assertFalse(containsGrid(raw)); + assertTrue(containsGrid(blended)); + assertEquals(TerrainTestSupport.sample(blended, registry, DataVersion.V26_3, 12, 0, 18), + TerrainTestSupport.sample(raw, registry, DataVersion.V26_3, 12, 0, 18), 1.0e-12); + } + + @Test + public void generatorFieldsAreTwoDimensionalAndShared() { + NoiseRegistry registry = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + + Field compiled = compiler(TerrainTestSupport.noiseStyles(), registry).compile(generator("Mixed Case/hills", layer(NoiseStyle.SIMPLEX, 1.0)), "height"); + + assertEquals("terrain/generator/height/mixed_case/hills", ((Field.Shared) compiled).name()); + assertFalse(compiled.axes().contains(Axis.Y)); + } + + @Test + public void unsupportedLayerFeaturesReportDiagnostics() { + IrisNoiseGenerator octaves = layer(NoiseStyle.SIMPLEX, 1.0); + octaves.setOctaves(3); + IrisGenerator generator = generator("octaves", octaves); + generator.setCellFractureHeight(4.0); + List diagnostics = new ArrayList<>(); + + new GeneratorCompiler(TerrainTestSupport.noiseStyles(), new NoiseRegistry(TerrainTestSupport.NAMESPACE), CAPABILITIES, diagnostics) + .compile(generator, "height"); + + assertEquals(2, diagnostics.size()); + for (ExportDiagnostic diagnostic : diagnostics) { + assertEquals(ExportLimit.NOISE_STYLE, diagnostic.limit()); + assertEquals("generators/octaves", diagnostic.resource()); + } + } + + @Test + public void compileIsDeterministic() { + IrisGenerator first = generator("same", layer(NoiseStyle.SIMPLEX, 1.0), layer(NoiseStyle.PERLIN, 0.5)); + IrisGenerator second = generator("same", layer(NoiseStyle.SIMPLEX, 1.0), layer(NoiseStyle.PERLIN, 0.5)); + NoiseRegistry firstRegistry = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + NoiseRegistry secondRegistry = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + + Field one = compiler(TerrainTestSupport.noiseStyles(), firstRegistry).compile(first, "height"); + Field two = compiler(TerrainTestSupport.noiseStyles(), secondRegistry).compile(second, "height"); + + assertEquals(one, two); + assertEquals(firstRegistry.definitions(), secondRegistry.definitions()); + } + + private static GeneratorCompiler compiler(StyleCompiler styles, NoiseRegistry registry) { + return new GeneratorCompiler(styles, registry, CAPABILITIES, new ArrayList<>()); + } + + private static IrisGenerator generator(String key, IrisNoiseGenerator... layers) { + IrisGenerator generator = new IrisGenerator(); + generator.setLoadKey(key); + generator.setComposite(new KList<>(List.of(layers))); + return generator; + } + + private static IrisNoiseGenerator layer(NoiseStyle style, double opacity) { + IrisNoiseGenerator layer = new IrisNoiseGenerator(); + layer.setStyle(new IrisGeneratorStyle(style)); + layer.setOpacity(opacity); + return layer; + } + + private static boolean containsGrid(Field root) { + if (root instanceof Field.Grid) { + return true; + } + for (Field child : FieldMacros.children(root)) { + if (containsGrid(child)) { + return true; + } + } + return false; + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/terrain/HeightCompilerTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/terrain/HeightCompilerTest.java new file mode 100644 index 000000000..e6ea57e7f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/terrain/HeightCompilerTest.java @@ -0,0 +1,327 @@ +package art.arcane.iris.pack.datapack.export.compile.terrain; + +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ExportTestPacks; +import art.arcane.iris.pack.datapack.export.compile.biome.BiomeCodes; +import art.arcane.iris.pack.datapack.export.compile.noise.NoiseRegistry; +import art.arcane.iris.pack.datapack.export.compile.noise.StyleCompiler; +import art.arcane.iris.pack.datapack.export.ir.Axis; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.FieldValidator; +import art.arcane.iris.pack.datapack.export.lower.ExportCapabilities; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import art.arcane.volmlib.util.interpolation.InterpolationMethod; +import art.arcane.volmlib.util.interpolation.IrisInterpolation; +import art.arcane.volmlib.util.interpolation.NoiseBounds; +import art.arcane.volmlib.util.interpolation.NoiseBoundsProvider; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class HeightCompilerTest { + private static final ExportCapabilities CAPABILITIES = ExportCapabilities.forTarget(DataVersion.V26_3); + private static final int SEA_LEVEL = 63; + private static final int BLEND_RADIUS = 12; + private static final int BOUNDS_STEP = 4; + private static final double LOW_MIDPOINT = 10.0; + private static final double HIGH_MIDPOINT = 80.0; + private static final double STEPS = 4.0; + + @ClassRule + public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Rule + public ExportTestPacks.BoundPlatform platform = new ExportTestPacks.BoundPlatform(); + + @Test + public void heightRootIsTwoDimensional() throws Exception { + File pack = TerrainTestPacks.twoBiomes(temporaryFolder.newFolder("pack"), + TerrainTestPacks.link("hills", 0, 20), TerrainTestPacks.link("hills", 40, 90)); + TerrainTestPacks.generator(pack, "hills", TerrainTestPacks.twoLayers("BILINEAR_STARCAST_9", 12)); + IrisData data = IrisData.openRuntime(pack); + try { + NoiseRegistry registry = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + + Field height = compile(data, registry, TerrainTestSupport.noiseStyles(), TerrainTestSupport.twoBiomeCodes(registry, 0.0)); + + assertEquals(HeightCompiler.HEIGHT, ((Field.Shared) height).name()); + FieldValidator.requireTwoDimensional(height, "height"); + assertFalse(height.axes().contains(Axis.Y)); + for (int x = -500; x <= 500; x += 41) { + double value = TerrainTestSupport.sample(height, registry, DataVersion.V26_3, x, 0, x / 3); + assertTrue("height " + value, value >= SEA_LEVEL && value <= SEA_LEVEL + 90); + } + } finally { + data.close(); + } + } + + @Test + public void bandBlendRmsWithin0_13() throws Exception { + NoiseRegistry registry = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + double rms = blendRms(registry, TerrainTestSupport.twoBiomeCodes(registry, 0.0)); + + assertTrue("band blend rms " + rms, rms <= 0.13); + } + + @Test + public void steppedSelectorBandBlendRmsWithin0_13() throws Exception { + NoiseRegistry registry = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + double rms = blendRms(registry, steppedCodes(registry)); + + assertTrue("stepped band blend rms " + rms, rms <= 0.13); + } + + @Test + public void slopeIsTheForwardDifferenceOfTheRelief() throws Exception { + File pack = TerrainTestPacks.twoBiomes(temporaryFolder.newFolder("pack"), + TerrainTestPacks.link("hills", 0, 40), TerrainTestPacks.link("hills", 0, 40)); + TerrainTestPacks.generator(pack, "hills", TerrainTestPacks.twoLayers("BILINEAR_STARCAST_9", 12)); + IrisData data = IrisData.openRuntime(pack); + try { + NoiseRegistry registry = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + IrisDimension dimension = data.getDimensionLoader().load(TerrainTestPacks.DIMENSION, false); + TerrainHeight terrain = new HeightCompiler(TerrainTestSupport.noiseStyles(), new ArrayList<>()) + .compile(TerrainTestSupport.singleBiomeCodes("a"), dimension, data, registry, CAPABILITIES); + + for (int x = -900; x <= 900; x += 113) { + int z = 2 * x / 3; + double here = TerrainTestSupport.sample(terrain.height(), registry, DataVersion.V26_3, x, 0, z); + double east = (TerrainTestSupport.sample(terrain.height(), registry, DataVersion.V26_3, x + 4, 0, z) - here) / 4.0; + double south = (TerrainTestSupport.sample(terrain.height(), registry, DataVersion.V26_3, x, 0, z + 4) - here) / 4.0; + double slope = TerrainTestSupport.sample(terrain.slope(), registry, DataVersion.V26_3, x, 0, z); + assertEquals("slope at " + x + "," + z, Math.sqrt(east * east + south * south), slope, 1.0e-3); + } + } finally { + data.close(); + } + } + + @Test + public void slopeNeverCopiesTheBiomeSelector() throws Exception { + File pack = TerrainTestPacks.twoBiomes(temporaryFolder.newFolder("pack"), + TerrainTestPacks.link("hills", 0, 20), TerrainTestPacks.link("hills", 40, 90)); + TerrainTestPacks.generator(pack, "hills", TerrainTestPacks.twoLayers("BILINEAR_STARCAST_9", 12)); + IrisData data = IrisData.openRuntime(pack); + try { + NoiseRegistry registry = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + IrisDimension dimension = data.getDimensionLoader().load(TerrainTestPacks.DIMENSION, false); + TerrainHeight terrain = new HeightCompiler(TerrainTestSupport.noiseStyles(), new ArrayList<>()) + .compile(TerrainTestSupport.twoBiomeCodes(registry, 0.0), dimension, data, registry, CAPABILITIES); + String selector = TerrainTestSupport.NAMESPACE + ":" + TerrainTestSupport.SELECTOR_ID; + + for (Field node : TerrainTestSupport.nodes(new Field.Binary(Field.BinaryOp.ADD, terrain.height(), terrain.slope()))) { + if (node instanceof Field.Noise noise && noise.noiseId().equals(selector)) { + assertTrue("selector sampled with a shift: " + noise, noise.shiftX() == null && noise.shiftZ() == null); + } + } + } finally { + data.close(); + } + } + + @Test + public void heightCliffsStepByTheBandSpan() throws Exception { + File pack = TerrainTestPacks.twoBiomes(temporaryFolder.newFolder("pack"), + TerrainTestPacks.link("terraces", 0, 51), TerrainTestPacks.link("terraces", 0, 51)); + TerrainTestPacks.generator(pack, "terraces", """ + {"interpolator":{"function":"BILINEAR_STARCAST_9","horizontalScale":12},"cliffHeightMin":50,"cliffHeightMax":50, + "composite":[{"style":{"style":"SIMPLEX"},"seed":3}]}"""); + IrisData data = IrisData.openRuntime(pack); + try { + NoiseRegistry registry = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + Field height = compile(data, registry, TerrainTestSupport.noiseStyles(), TerrainTestSupport.twoBiomeCodes(registry, 0.0)); + double step = 51.0 * 50.0 / 255.0; + + for (int x = -600; x <= 600; x += 5) { + double rise = (TerrainTestSupport.sample(height, registry, DataVersion.V26_3, x, 0, 17) - SEA_LEVEL) / step; + assertEquals("terrace " + rise + " at x " + x, Math.rint(rise), rise, 1.0e-3); + } + } finally { + data.close(); + } + } + + @Test + public void silentBucketsLeaveSeaLevel() throws Exception { + File pack = TerrainTestPacks.twoBiomes(temporaryFolder.newFolder("pack"), + TerrainTestPacks.link("hills", 0, 0), TerrainTestPacks.link("hills", 0, 0)); + TerrainTestPacks.generator(pack, "hills", TerrainTestPacks.twoLayers("BILINEAR_STARCAST_9", 12)); + IrisData data = IrisData.openRuntime(pack); + try { + NoiseRegistry registry = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + + Field height = compile(data, registry, TerrainTestSupport.noiseStyles(), TerrainTestSupport.twoBiomeCodes(registry, 0.0)); + + assertEquals(new Field.Shared(HeightCompiler.HEIGHT, new Field.Const(SEA_LEVEL)), height); + } finally { + data.close(); + } + } + + @Test + public void wideBucketsBlendOnCoarseLattices() throws Exception { + File pack = TerrainTestPacks.twoBiomes(temporaryFolder.newFolder("pack"), + TerrainTestPacks.link("ranges", 0, 20), TerrainTestPacks.link("ranges", 60, 120)); + TerrainTestPacks.generator(pack, "ranges", TerrainTestPacks.singleLayer("BILINEAR_STARCAST_12", 200, "SIMPLEX")); + IrisData data = IrisData.openRuntime(pack); + try { + NoiseRegistry registry = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + + Field height = compile(data, registry, TerrainTestSupport.noiseStyles(), TerrainTestSupport.twoBiomeCodes(registry, 0.0)); + + assertTrue(containsGrid(height, 512)); + } finally { + data.close(); + } + } + + @Test + public void compileIsDeterministic() throws Exception { + File pack = TerrainTestPacks.twoBiomes(temporaryFolder.newFolder("pack"), + TerrainTestPacks.link("hills", 0, 20) + "," + TerrainTestPacks.link("dunes", 5, 9), + TerrainTestPacks.link("hills", 40, 90)); + TerrainTestPacks.generator(pack, "hills", TerrainTestPacks.twoLayers("BILINEAR_STARCAST_9", 12)); + TerrainTestPacks.generator(pack, "dunes", TerrainTestPacks.singleLayer("BILINEAR_STARCAST_9", 23, "PERLIN")); + IrisData data = IrisData.openRuntime(pack); + try { + NoiseRegistry first = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + NoiseRegistry second = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + + Field one = compile(data, first, TerrainTestSupport.noiseStyles(), TerrainTestSupport.twoBiomeCodes(first, 0.0)); + Field two = compile(data, second, TerrainTestSupport.noiseStyles(), TerrainTestSupport.twoBiomeCodes(second, 0.0)); + + assertEquals(one, two); + assertEquals(first.definitions(), second.definitions()); + } finally { + data.close(); + } + } + + private double blendRms(NoiseRegistry registry, BiomeCodes codes) throws Exception { + File pack = TerrainTestPacks.twoBiomes(temporaryFolder.newFolder("pack"), + TerrainTestPacks.link("hills", 0, 20), TerrainTestPacks.link("hills", 60, 100)); + TerrainTestPacks.generator(pack, "hills", TerrainTestPacks.singleLayer("BILINEAR_STARCAST_9", BLEND_RADIUS, "SIMPLEX")); + IrisData data = IrisData.openRuntime(pack); + try { + Field height = compile(data, registry, TerrainTestSupport.constantStyles(0.5), codes); + IrisBands iris = new IrisBands(codes.surfaceCode(), registry); + double squared = 0.0; + int zone = 0; + for (int x = 0; x < 768; x += 3) { + for (int z = 0; z < 768; z += 3) { + double reference = SEA_LEVEL + iris.height(x, z); + double weight = (reference - SEA_LEVEL - LOW_MIDPOINT) / (HIGH_MIDPOINT - LOW_MIDPOINT); + if (weight < 0.02 || weight > 0.98) { + continue; + } + double exported = TerrainTestSupport.sample(height, registry, DataVersion.V26_3, x, 0, z); + double error = (exported - reference) / (HIGH_MIDPOINT - LOW_MIDPOINT); + squared += error * error; + zone++; + } + } + assertTrue("transition zone too small: " + zone, zone > 500); + return Math.sqrt(squared / zone); + } finally { + data.close(); + } + } + + private static BiomeCodes steppedCodes(NoiseRegistry registry) { + BiomeCodes smooth = TerrainTestSupport.twoBiomeCodes(registry, 0.0); + Field.Partition partition = (Field.Partition) smooth.surfaceCode(); + Field stepped = new Field.Round(Field.RoundMode.ROUND, FieldMacros.scale(partition.input(), STEPS), new Field.Const(1.0)); + Field code = new Field.Partition(stepped, List.of(0.5), partition.leaves()); + return new BiomeCodes(code, smooth.caveCode(), smooth.class3d(), smooth.layout(), smooth.loadKeyByBiomeId()); + } + + private static Field compile(IrisData data, NoiseRegistry registry, StyleCompiler styles, BiomeCodes codes) { + IrisDimension dimension = data.getDimensionLoader().load(TerrainTestPacks.DIMENSION, false); + return new HeightCompiler(styles, new ArrayList<>()).compile(codes, dimension, data, registry, CAPABILITIES).height(); + } + + private static boolean containsGrid(Field root, int cell) { + Deque pending = new ArrayDeque<>(); + pending.push(root); + while (!pending.isEmpty()) { + Field field = pending.pop(); + if (field instanceof Field.Grid grid && grid.cellXZ() == cell) { + return true; + } + for (Field child : FieldMacros.children(field)) { + pending.push(child); + } + } + return false; + } + + private static final class IrisBands implements NoiseBoundsProvider { + private final Field code; + private final NoiseRegistry registry; + private final Map samples = new HashMap<>(); + private final Map corners = new HashMap<>(); + + private IrisBands(Field code, NoiseRegistry registry) { + this.code = code; + this.registry = registry; + } + + @Override + public NoiseBounds noise(double x, double z) { + int blockX = (int) x; + int blockZ = (int) z; + return samples.computeIfAbsent(key(blockX, blockZ), ignored -> { + double value = TerrainTestSupport.sample(code, registry, DataVersion.V26_3, blockX, 0, blockZ); + return value < 0.2 ? new NoiseBounds(0.0, 20.0) : new NoiseBounds(60.0, 100.0); + }); + } + + private double height(int x, int z) { + int gridX = Math.floorDiv(x, BOUNDS_STEP) * BOUNDS_STEP; + int gridZ = Math.floorDiv(z, BOUNDS_STEP) * BOUNDS_STEP; + double fractionX = (x - gridX) / (double) BOUNDS_STEP; + double fractionZ = (z - gridZ) / (double) BOUNDS_STEP; + NoiseBounds northWest = corner(gridX, gridZ); + NoiseBounds northEast = corner(gridX + BOUNDS_STEP, gridZ); + NoiseBounds southWest = corner(gridX, gridZ + BOUNDS_STEP); + NoiseBounds southEast = corner(gridX + BOUNDS_STEP, gridZ + BOUNDS_STEP); + double low = bilinear(northWest.min(), northEast.min(), southWest.min(), southEast.min(), fractionX, fractionZ); + double high = bilinear(northWest.max(), northEast.max(), southWest.max(), southEast.max(), fractionX, fractionZ); + return low + (high - low) * 0.5; + } + + private NoiseBounds corner(int x, int z) { + return corners.computeIfAbsent(key(x, z), + ignored -> IrisInterpolation.getNoiseBounds(InterpolationMethod.BILINEAR_STARCAST_9, x, z, BLEND_RADIUS, this)); + } + + private static double bilinear(double northWest, double northEast, double southWest, double southEast, double fractionX, double fractionZ) { + double north = northWest + (northEast - northWest) * fractionX; + double south = southWest + (southEast - southWest) * fractionX; + return north + (south - north) * fractionZ; + } + + private static long key(int x, int z) { + return ((long) x << 32) ^ (z & 0xffffffffL); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/terrain/OverworldTerrainProbeTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/terrain/OverworldTerrainProbeTest.java new file mode 100644 index 000000000..8febfd49c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/terrain/OverworldTerrainProbeTest.java @@ -0,0 +1,199 @@ +package art.arcane.iris.pack.datapack.export.compile.terrain; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ExportTestPacks; +import art.arcane.iris.pack.datapack.export.compile.biome.BiomeCodes; +import art.arcane.iris.pack.datapack.export.compile.biome.BiomeIdSanitizer; +import art.arcane.iris.pack.datapack.export.compile.noise.NoiseRegistry; +import art.arcane.iris.pack.datapack.export.ir.Axis; +import art.arcane.iris.pack.datapack.export.ir.ClimateLayout; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.lower.ExportCapabilities; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import org.junit.Assume; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.doReturn; + +public class OverworldTerrainProbeTest { + private static final String PROPERTY = "iris.exportProbePack"; + private static final String DIMENSION = "overworld"; + private static final double CODE_SPAN = 0.9; + + @ClassRule + public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + + @Rule + public ExportTestPacks.BoundPlatform platform = new ExportTestPacks.BoundPlatform(); + + @Test + public void overworldTerrainCompiles() { + String pack = System.getProperty(PROPERTY); + Assume.assumeTrue("Set -P" + PROPERTY + " to probe a real pack", pack != null && !pack.isBlank()); + doReturn(DataVersion.V26_3.getVersion()).when(IrisPlatforms.get()).minecraftVersion(); + doReturn(null).when(IrisPlatforms.get()).registries(); + IrisData data = IrisData.openRuntime(new File(pack)); + try { + IrisDimension dimension = data.getDimensionLoader().load(DIMENSION, false); + NoiseRegistry registry = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + BiomeCodes codes = codes(dimension, data, registry); + ExportCapabilities capabilities = ExportCapabilities.forTarget(DataVersion.V26_3); + List diagnostics = new ArrayList<>(); + + TerrainHeight terrain = new HeightCompiler(TerrainTestSupport.noiseStyles(), diagnostics).compile(codes, dimension, data, registry, capabilities); + Field height = terrain.height(); + Field volumetric = new VolumetricCompiler(TerrainTestSupport.noiseStyles(), diagnostics).compile(terrain, codes, dimension, data, registry, capabilities); + + assertFalse(height.axes().contains(Axis.Y)); + List violations = new ArrayList<>(TerrainTestSupport.unguardedExpensiveBranches(height)); + violations.addAll(TerrainTestSupport.unguardedExpensiveBranches(volumetric)); + assertTrue(violations.toString(), violations.isEmpty()); + for (int sample = 0; sample < 8; sample++) { + int x = sample * 977 - 3000; + int z = sample * 613 - 2000; + double value = TerrainTestSupport.sample(height, registry, DataVersion.V26_3, x, 0, z); + assertTrue("height " + value, value >= dimension.getMinHeight() && value <= dimension.getMaxHeight()); + assertTrue(Double.isFinite(TerrainTestSupport.sample(volumetric, registry, DataVersion.V26_3, x, (int) value, z))); + } + Map limits = new TreeMap<>(); + for (ExportDiagnostic diagnostic : diagnostics) { + limits.merge(diagnostic.limit().name(), 1, Integer::sum); + } + System.out.println("[terrain probe] biomes " + codes.loadKeyByBiomeId().size() + " noises " + registry.definitions().size() + + " diagnostics " + limits); + System.out.println("[terrain probe] height " + Shape.of(height)); + System.out.println("[terrain probe] volumetric " + Shape.of(volumetric)); + } finally { + data.close(); + } + } + + private static BiomeCodes codes(IrisDimension dimension, IrisData data, NoiseRegistry registry) { + Map biomes = new TreeMap<>(); + for (IrisBiome biome : dimension.getReachableBiomes(() -> data)) { + biomes.put(biome.getLoadKey(), biome); + } + int count = biomes.size(); + String selector = registry.register(TerrainTestSupport.SELECTOR); + List thresholds = new ArrayList<>(count - 1); + List leaves = new ArrayList<>(count); + List entries = new ArrayList<>(count); + Map loadKeys = new TreeMap<>(); + Set taken = new HashSet<>(); + int index = 0; + for (String loadKey : biomes.keySet()) { + String id = BiomeIdSanitizer.sanitize(TerrainTestSupport.NAMESPACE, loadKey, taken); + double center = -1.0 + (2.0 * index + 1.0) / count; + double half = CODE_SPAN / count; + entries.add(new ClimateLayout.ClimateEntry(id, ClimateLayout.ClimateAxis.TEMPERATURE, center - half, center + half)); + leaves.add(new Field.Const(center)); + loadKeys.put(id, loadKey); + if (index > 0) { + thresholds.add(-0.8 + 1.6 * index / count); + } + index++; + } + Field code = new Field.Partition(new Field.Noise(selector, 1.0, 0.0, null, null, null), thresholds, leaves); + return new BiomeCodes(code, new Field.Const(0.0), new Field.Const(0.0), new ClimateLayout(entries), loadKeys); + } + + private record Shape(long distinct, long emitted, long sharedFiles, int inlineDepth, int fileDepth, long gates, long muxes, long knots) { + private static Shape of(Field root) { + Map sizes = new IdentityHashMap<>(); + Map depths = new IdentityHashMap<>(); + Map fileDepths = new IdentityHashMap<>(); + Set distinct = Collections.newSetFromMap(new IdentityHashMap<>()); + Set shared = new HashSet<>(); + long[] counts = new long[3]; + collect(root, distinct, shared, counts); + int deepestFile = fileDepth(root, fileDepths); + for (Field field : distinct) { + if (field instanceof Field.Shared sharedField) { + deepestFile = Math.max(deepestFile, fileDepth(sharedField.expr(), fileDepths)); + } + } + long emitted = root instanceof Field.Shared ? 0 : emitted(root, sizes); + for (Field field : distinct) { + if (field instanceof Field.Shared sharedField) { + emitted += emitted(sharedField.expr(), sizes); + } + } + return new Shape(distinct.size(), emitted, shared.size(), depth(root, depths), deepestFile, counts[0], counts[1], counts[2]); + } + + private static void collect(Field field, Set distinct, Set shared, long[] counts) { + if (!distinct.add(field)) { + return; + } + switch (field) { + case Field.Shared sharedField -> shared.add(sharedField.name()); + case Field.Gate ignored -> counts[0]++; + case Field.Mux ignored -> counts[1]++; + case Field.Curve curve -> counts[2] += curve.knots().size(); + default -> { + } + } + for (Field child : FieldMacros.children(field)) { + collect(child, distinct, shared, counts); + } + } + + private static long emitted(Field field, Map sizes) { + Long cached = sizes.get(field); + if (cached != null) { + return cached; + } + long total = 1; + for (Field child : FieldMacros.children(field)) { + total += child instanceof Field.Shared ? 1 : emitted(child, sizes); + } + sizes.put(field, total); + return total; + } + + private static int depth(Field field, Map depths) { + Integer cached = depths.get(field); + if (cached != null) { + return cached; + } + int deepest = 0; + for (Field child : FieldMacros.children(field)) { + deepest = Math.max(deepest, depth(child, depths)); + } + depths.put(field, deepest + 1); + return deepest + 1; + } + + private static int fileDepth(Field field, Map depths) { + Integer cached = depths.get(field); + if (cached != null) { + return cached; + } + int deepest = 0; + for (Field child : FieldMacros.children(field)) { + deepest = Math.max(deepest, child instanceof Field.Shared ? 1 : fileDepth(child, depths)); + } + depths.put(field, deepest + 1); + return deepest + 1; + } + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/terrain/RiverCompilerTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/terrain/RiverCompilerTest.java new file mode 100644 index 000000000..d70a7a479 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/terrain/RiverCompilerTest.java @@ -0,0 +1,258 @@ +package art.arcane.iris.pack.datapack.export.compile.terrain; + +import art.arcane.iris.generation.hydrology.config.IrisBankStyle; +import art.arcane.iris.generation.hydrology.config.IrisHydrology; +import art.arcane.iris.generation.hydrology.config.IrisHydrologyDeformation; +import art.arcane.iris.generation.hydrology.config.IrisHydrologyTier; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.compile.noise.NoiseRegistry; +import art.arcane.iris.pack.datapack.export.ir.Axis; +import art.arcane.iris.pack.datapack.export.ir.CostClass; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.FieldEvaluator; +import art.arcane.iris.pack.datapack.export.ir.FieldValidator; +import art.arcane.iris.pack.datapack.export.ir.NoiseDef; +import art.arcane.iris.pack.datapack.export.lower.ExportCapabilities; +import art.arcane.volmlib.util.collection.KList; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public class RiverCompilerTest { + private static final int SEA_LEVEL = 63; + private static final int WIDTH_REGION = 768; + private static final int RUN_LIMIT = 96; + private static final double HALF_WIDTH = 5.0; + private static final double TOLERANCE = 1.0e-3; + + @Test + public void riverWidthConstantAlongIsoline() { + NoiseRegistry registry = registry(); + IrisHydrology hydrology = hydrology(tier(5000, HALF_WIDTH, HALF_WIDTH, 2, 8, IrisBankStyle.SMOOTH)); + Field carved = compile(new Field.Const(SEA_LEVEL + 2.0), hydrology, registry, DataVersion.V26_3, new ArrayList<>()); + Map noises = TerrainTestSupport.noises(registry); + boolean[][] wet = new boolean[WIDTH_REGION][WIDTH_REGION]; + for (int x = 0; x < WIDTH_REGION; x++) { + for (int z = 0; z < WIDTH_REGION; z++) { + wet[x][z] = sample(carved, noises, DataVersion.V26_3, x, z) < SEA_LEVEL - 0.5; + } + } + + List widths = new ArrayList<>(); + for (int z = RUN_LIMIT; z < WIDTH_REGION - RUN_LIMIT; z += 2) { + int x = 0; + while (x < WIDTH_REGION) { + if (!wet[x][z]) { + x++; + continue; + } + int start = x; + while (x < WIDTH_REGION && wet[x][z]) { + x++; + } + if (start == 0 || x == WIDTH_REGION) { + continue; + } + double across = x - start; + int middle = (start + x - 1) / 2; + int low = z; + int high = z; + while (low > z - RUN_LIMIT && wet[middle][low - 1]) { + low--; + } + while (high < z + RUN_LIMIT && wet[middle][high + 1]) { + high++; + } + double along = low == z - RUN_LIMIT || high == z + RUN_LIMIT ? Double.POSITIVE_INFINITY : high - low + 1.0; + widths.add(1.0 / Math.sqrt(1.0 / (across * across) + 1.0 / (along * along))); + } + } + + double mean = widths.stream().mapToDouble(Double::doubleValue).average().orElseThrow(); + double variance = widths.stream().mapToDouble(width -> (width - mean) * (width - mean)).average().orElseThrow(); + double variation = Math.sqrt(variance) / mean; + assertTrue("only " + widths.size() + " channel crossings", widths.size() >= 100); + assertTrue("mean width " + mean, Math.abs(mean - 2.0 * HALF_WIDTH) <= 2.0); + assertTrue("width CV " + variation + " over " + widths.size() + " crossings", variation <= 0.15); + } + + @Test + public void riverBedNeverBelowSeaLevelMinusDepth() { + NoiseRegistry registry = registry(); + Field natural = rollingHeight(registry, 30.0); + Field carved = compile(natural, overworldHydrology(), registry, DataVersion.V26_3, new ArrayList<>()); + Map noises = TerrainTestSupport.noises(registry); + double deepest = 0.5 * 18.0; + int flooded = 0; + for (int x = 0; x < 320; x += 2) { + for (int z = 0; z < 320; z += 2) { + double ground = sample(natural, noises, DataVersion.V26_3, x, z); + double bed = sample(carved, noises, DataVersion.V26_3, x, z); + assertTrue("bed " + bed + " under ground " + ground + " at " + x + "," + z, bed >= Math.min(ground, SEA_LEVEL - deepest) - TOLERANCE); + assertTrue("bed " + bed + " raised above ground " + ground + " at " + x + "," + z, bed <= ground + TOLERANCE); + if (ground > SEA_LEVEL && bed < SEA_LEVEL - 0.5) { + flooded++; + } + } + } + assertTrue("no lowland channel reaches the sea", flooded > 0); + } + + @Test + public void uplandChannelsAreDryGullies() { + NoiseRegistry registry = registry(); + double ground = SEA_LEVEL + 80.0; + Field carved = compile(new Field.Const(ground), overworldHydrology(), registry, DataVersion.V26_3, new ArrayList<>()); + Map noises = TerrainTestSupport.noises(registry); + double gully = 3.0 + 0.5 * 9.0; + int carvedColumns = 0; + int columns = 0; + for (int x = 0; x < 256; x += 2) { + for (int z = 0; z < 256; z += 2) { + double bed = sample(carved, noises, DataVersion.V26_3, x, z); + columns++; + assertTrue("upland bed " + bed + " at " + x + "," + z + " reaches the sea", bed > SEA_LEVEL + 0.5); + assertTrue("gully " + (ground - bed) + " deeper than " + gully, ground - bed <= gully + TOLERANCE); + if (bed < ground - 0.5) { + carvedColumns++; + } + } + } + assertTrue("only " + carvedColumns + " of " + columns + " upland columns carved", carvedColumns >= columns / 100); + } + + @Test + public void riverRootIsTwoDimensional() { + DataVersion target = DataVersion.V26_3; + NoiseRegistry registry = registry(); + Field carved = compile(rollingHeight(registry, 30.0), overworldHydrology(), registry, target, new ArrayList<>()); + + assertEquals(RiverCompiler.CARVED_HEIGHT, ((Field.Shared) carved).name()); + assertEquals(EnumSet.of(Axis.X, Axis.Z), carved.axes()); + FieldValidator.requireTwoDimensional(carved, "height"); + } + + @Test + public void noRiversWhenHydrologyDisabled() { + Field natural = new Field.Const(SEA_LEVEL + 2.0); + for (IrisHydrology hydrology : new IrisHydrology[]{null, overworldHydrology().setEnabled(false)}) { + NoiseRegistry registry = registry(); + List diagnostics = new ArrayList<>(); + + Field result = compile(natural, hydrology, registry, DataVersion.V26_3, diagnostics); + + assertSame(natural, result); + assertTrue(registry.definitions().isEmpty()); + assertTrue(diagnostics.isEmpty()); + } + } + + @Test + public void riversReportDrainageAndElevatedWaterLimits() { + List diagnostics = new ArrayList<>(); + + compile(new Field.Const(SEA_LEVEL + 2.0), overworldHydrology(), registry(), DataVersion.V26_3, diagnostics); + + assertEquals(List.of(ExportLimit.ELEVATED_WATER, ExportLimit.RIVER_DRAINAGE), diagnostics.stream().map(ExportDiagnostic::limit).toList()); + assertEquals("dimensions/rivers/hydrology", diagnostics.getFirst().resource()); + } + + @Test + public void expensiveChildrenOnlyBehindMuxOrGate() { + IrisHydrology hydrology = hydrology( + tier(900, 0.5, 2.5, 1, 8, IrisBankStyle.SMOOTH), + tier(2000, 1.0, 4.0, 1, 12, IrisBankStyle.LINEAR), + tier(5000, 2.5, 9.0, 2, 32, IrisBankStyle.CONCAVE), + tier(20000, 3.0, 10.0, 2, 24, IrisBankStyle.TERRACED), + tier(60000, 4.0, 12.0, 3, 16, IrisBankStyle.CLIFF), + tier(200000, 9.0, 18.0, 3, 0, IrisBankStyle.SMOOTH)); + DataVersion target = DataVersion.V26_3; + NoiseRegistry registry = registry(); + Field natural = rollingHeight(registry, 30.0); + assertEquals(CostClass.EXPENSIVE, natural.cost()); + + Field carved = compile(natural, hydrology, registry, target, new ArrayList<>()); + + List violations = TerrainTestSupport.unguardedExpensiveBranches(carved); + assertTrue(target + " " + violations, violations.isEmpty()); + } + + @Test + public void riverCompileIsDeterministic() { + List fields = new ArrayList<>(); + List> definitions = new ArrayList<>(); + List samples = new ArrayList<>(); + for (int run = 0; run < 2; run++) { + NoiseRegistry registry = registry(); + Field carved = compile(rollingHeight(registry, 30.0), overworldHydrology(), registry, DataVersion.V26_3, new ArrayList<>()); + fields.add(carved); + definitions.add(registry.definitions()); + samples.add(sample(carved, TerrainTestSupport.noises(registry), DataVersion.V26_3, 137, -411)); + } + + assertEquals(fields.getFirst(), fields.getLast()); + assertEquals(definitions.getFirst(), definitions.getLast()); + assertEquals(samples.getFirst(), samples.getLast()); + } + + private static Field compile(Field natural, IrisHydrology hydrology, NoiseRegistry registry, DataVersion target, List diagnostics) { + IrisDimension dimension = new IrisDimension(); + dimension.setLoadKey("rivers"); + dimension.setFluidHeight(SEA_LEVEL); + dimension.setHydrology(hydrology); + return new RiverCompiler(diagnostics).compile(natural, dimension, registry, ExportCapabilities.forTarget(target)); + } + + private static double sample(Field field, Map noises, DataVersion target, int x, int z) { + return FieldEvaluator.sample(field, noises, TerrainTestSupport.SEED, target, x, 0, z); + } + + private static NoiseRegistry registry() { + return new NoiseRegistry(TerrainTestSupport.NAMESPACE); + } + + private static Field rollingHeight(NoiseRegistry registry, double amplitude) { + String warp = registry.register(new NoiseDef("test/height_warp", -8, List.of(1.0))); + String hills = registry.register(new NoiseDef("test/height", -7, List.of(1.0, 0.5))); + Field shift = FieldMacros.scale(new Field.Noise(warp, 1.0, 0.0, null, null, null), 0.5); + Field noise = new Field.Noise(hills, 1.0, 0.0, shift, null, shift); + return new Field.Shared(HeightCompiler.HEIGHT, FieldMacros.sum(List.of(new Field.Const(SEA_LEVEL), FieldMacros.scale(noise, amplitude)))); + } + + private static IrisHydrology overworldHydrology() { + return hydrology( + tier(900, 0.5, 2.5, 1, 8, IrisBankStyle.SMOOTH).setDepthRatio(0.4), + tier(5000, 2.5, 9.0, 2, 32, IrisBankStyle.SMOOTH), + tier(200000, 9.0, 18.0, 3, 48, IrisBankStyle.SMOOTH).setDepthRatio(0.5)); + } + + private static IrisHydrology hydrology(IrisHydrologyTier... tiers) { + return new IrisHydrology() + .setEnabled(true) + .setDensity(0.5) + .setTiers(new KList<>(tiers)) + .setDeformation(new IrisHydrologyDeformation().setBankSlope(0.2).setBankSteepFactor(3.5).setBankNoise(0.2)); + } + + private static IrisHydrologyTier tier(double catchment, double minHalfWidth, double maxHalfWidth, int inset, int bankBand, IrisBankStyle style) { + return new IrisHydrologyTier() + .setName("tier_" + (long) catchment) + .setMinCatchment(catchment) + .setHalfWidth(new KList<>(minHalfWidth, maxHalfWidth)) + .setWidthFactor(1.2) + .setDepthRatio(0.45) + .setInset(inset) + .setBankBand(bankBand) + .setBankStyle(style); + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/terrain/RiverStageTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/terrain/RiverStageTest.java new file mode 100644 index 000000000..ba799f837 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/terrain/RiverStageTest.java @@ -0,0 +1,170 @@ +package art.arcane.iris.pack.datapack.export.compile.terrain; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ExportTestPacks; +import art.arcane.iris.pack.datapack.export.WorldgenExportMode; +import art.arcane.iris.pack.datapack.export.WorldgenExportProgress; +import art.arcane.iris.pack.datapack.export.WorldgenExportRequest; +import art.arcane.iris.pack.datapack.export.compile.DimensionStage; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; +import art.arcane.iris.pack.datapack.export.compile.PackCompiler; +import art.arcane.iris.pack.datapack.export.ir.Axis; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.lower.ExportCapabilities; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.testsupport.CompatTestPlatform; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +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 RiverStageTest { + @ClassRule + public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Rule + public ExportTestPacks.BoundPlatform platform = new ExportTestPacks.BoundPlatform(); + + @Test + public void stageCarvesRiversIntoTheTerrainHeight() throws Exception { + IrisData data = IrisData.openRuntime(pack(true)); + try { + DataVersion target = DataVersion.V26_3; + PackCompileContext context = terrainContext(data, target); + Field natural = context.height(); + + new RiverStage().apply(context); + + Field.Shared carved = (Field.Shared) context.height(); + assertEquals(RiverCompiler.CARVED_HEIGHT, carved.name()); + assertSame(natural, ((Field.Binary) carved.expr()).left()); + assertSame(natural, context.naturalHeight()); + assertFalse(carved.axes().contains(Axis.Y)); + List violations = TerrainTestSupport.unguardedExpensiveBranches(carved); + assertTrue(target + " " + violations, violations.isEmpty()); + List limits = context.diagnostics().stream().map(ExportDiagnostic::limit).toList(); + assertTrue(limits.toString(), limits.contains(ExportLimit.RIVER_DRAINAGE)); + assertTrue(limits.toString(), limits.contains(ExportLimit.ELEVATED_WATER)); + } finally { + data.close(); + } + } + + @Test + public void aquiferSurfaceCarvesTheClimateHeight() throws Exception { + IrisData data = IrisData.openRuntime(pack(true)); + try { + PackCompileContext context = terrainContext(data, DataVersion.V26_3); + Field estimate = context.surfaceEstimate(); + assertSame(context.climateHeight(), estimate); + assertEquals(ClimateHeightCompiler.CLIMATE_HEIGHT, ((Field.Shared) estimate).name()); + + new RiverStage().apply(context); + + assertSame(estimate, context.climateHeight()); + + Field.Shared surface = (Field.Shared) context.surfaceEstimate(); + assertEquals(RiverCompiler.CARVED_HEIGHT + "/estimate", surface.name()); + assertSame(estimate, ((Field.Binary) surface.expr()).left()); + assertTrue(TerrainTestSupport.contains(surface, estimate)); + assertFalse(TerrainTestSupport.contains(surface, context.naturalHeight())); + for (Field node : TerrainTestSupport.nodes(surface)) { + assertFalse("aquifer surface interpolates " + node, node instanceof Field.Grid grid && grid.cellXZ() != 4); + } + for (int x = -900; x <= 900; x += 97) { + double carved = TerrainTestSupport.sample(context.height(), context.noises(), DataVersion.V26_3, x, 0, x / 2); + double natural = TerrainTestSupport.sample(context.naturalHeight(), context.noises(), DataVersion.V26_3, x, 0, x / 2); + double cut = TerrainTestSupport.sample(surface, context.noises(), DataVersion.V26_3, x, 0, x / 2) + - TerrainTestSupport.sample(estimate, context.noises(), DataVersion.V26_3, x, 0, x / 2); + assertTrue("estimate cut " + cut, cut <= 1.0e-3); + assertTrue("carved cut " + (carved - natural), carved - natural <= 1.0e-3); + } + } finally { + data.close(); + } + } + + @Test + public void stageKeepsTheHeightWithoutHydrology() throws Exception { + IrisData data = IrisData.openRuntime(pack(false)); + try { + PackCompileContext context = terrainContext(data, DataVersion.V26_3); + Field natural = context.height(); + int noises = context.noises().definitions().size(); + + new RiverStage().apply(context); + + assertSame(natural, context.height()); + assertEquals(noises, context.noises().definitions().size()); + } finally { + data.close(); + } + } + + @Test + public void defaultCompilerRunsRiversAfterTerrain() throws Exception { + IrisData data = IrisData.openRuntime(pack(true)); + try { + List stages = new ArrayList<>(); + WorldgenExportProgress progress = new WorldgenExportProgress() { + @Override + public void stage(String stage) { + stages.add(stage); + } + + @Override + public void resources(int done, int total) { + } + }; + + PackCompiler.create().compile(context(data, DataVersion.V26_3), progress); + + assertEquals(stages.indexOf("terrain") + 1, stages.indexOf("rivers")); + } finally { + data.close(); + } + } + + private File pack(boolean hydrology) throws Exception { + File pack = TerrainTestPacks.twoBiomes(temporaryFolder.newFolder("pack"), + TerrainTestPacks.link("hills", 0, 40), TerrainTestPacks.link("hills", 20, 90)); + TerrainTestPacks.generator(pack, "hills", TerrainTestPacks.twoLayers("BILINEAR_STARCAST_9", 12)); + String water = hydrology ? ",\"hydrology\":{\"enabled\":true,\"density\":0.5}" : ""; + CompatTestPlatform.write(pack, "dimensions/" + TerrainTestPacks.DIMENSION + ".json", "{\"regions\":[\"region\"]" + water + "}"); + return pack; + } + + private static PackCompileContext terrainContext(IrisData data, DataVersion target) { + PackCompileContext context = context(data, target); + new DimensionStage().apply(context); + context.setBiomeCodes(TerrainTestSupport.twoBiomeCodes(context.noises(), 0.0)); + new TerrainStage().apply(context); + return context; + } + + private static PackCompileContext context(IrisData data, DataVersion target) { + return PackCompileContext.builder() + .data(data) + .dimension(data.getDimensionLoader().load(TerrainTestPacks.DIMENSION, false)) + .capabilities(ExportCapabilities.forTarget(target)) + .namespace(WorldgenExportRequest.DEFAULT_NAMESPACE) + .mode(WorldgenExportMode.PRESET) + .styles(TerrainTestSupport.noiseStyles()) + .build(); + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/terrain/TerrainStageTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/terrain/TerrainStageTest.java new file mode 100644 index 000000000..eb4ad3731 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/terrain/TerrainStageTest.java @@ -0,0 +1,185 @@ +package art.arcane.iris.pack.datapack.export.compile.terrain; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ExportTestPacks; +import art.arcane.iris.pack.datapack.export.ExportTestStyles; +import art.arcane.iris.pack.datapack.export.WorldgenExportMode; +import art.arcane.iris.pack.datapack.export.WorldgenExportProgress; +import art.arcane.iris.pack.datapack.export.WorldgenExportRequest; +import art.arcane.iris.pack.datapack.export.compile.CompileStage; +import art.arcane.iris.pack.datapack.export.compile.DimensionStage; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; +import art.arcane.iris.pack.datapack.export.compile.PackCompiler; +import art.arcane.iris.pack.datapack.export.compile.biome.BiomeCodes; +import art.arcane.iris.pack.datapack.export.compile.biome.BiomeStage; +import art.arcane.iris.pack.datapack.export.ir.Axis; +import art.arcane.iris.pack.datapack.export.ir.ExportIr; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.lower.ExportCapabilities; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class TerrainStageTest { + @ClassRule + public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Rule + public ExportTestPacks.BoundPlatform platform = new ExportTestPacks.BoundPlatform(); + + @Test + public void stageFillsTheHeightAndVolumetricSlots() throws Exception { + IrisData data = IrisData.openRuntime(featurePack()); + try { + PackCompileContext context = context(data, DataVersion.V26_3); + new DimensionStage().apply(context); + context.setBiomeCodes(TerrainTestSupport.twoBiomeCodes(context.noises(), 0.0)); + + new TerrainStage().apply(context); + + assertEquals(HeightCompiler.HEIGHT, ((Field.Shared) context.height()).name()); + assertFalse(context.height().axes().contains(Axis.Y)); + assertTrue(context.volumetric().axes().contains(Axis.Y)); + assertFalse(context.noises().definitions().isEmpty()); + } finally { + data.close(); + } + } + + @Test + public void expensiveChildrenOnlyBehindMuxOrGate() throws Exception { + IrisData data = IrisData.openRuntime(featurePack()); + try { + DataVersion target = DataVersion.V26_3; + PackCompileContext context = context(data, target); + new DimensionStage().apply(context); + BiomeCodes codes = TerrainTestSupport.twoBiomeCodes(context.noises(), 0.0); + context.setBiomeCodes(codes); + + new TerrainStage().apply(context); + + List violations = new ArrayList<>(TerrainTestSupport.unguardedExpensiveBranches(context.height())); + violations.addAll(TerrainTestSupport.unguardedExpensiveBranches(context.volumetric())); + assertTrue(target + " " + violations, violations.isEmpty()); + } finally { + data.close(); + } + } + + @Test + public void defaultCompilerRunsTerrainBetweenBiomesAndRivers() throws Exception { + File pack = ExportTestPacks.basic(temporaryFolder.newFolder("pack")); + IrisData data = IrisData.openRuntime(pack); + try { + List stages = new ArrayList<>(); + WorldgenExportProgress progress = new WorldgenExportProgress() { + @Override + public void stage(String stage) { + stages.add(stage); + } + + @Override + public void resources(int done, int total) { + } + }; + + ExportIr ir = PackCompiler.create().compile(ExportTestPacks.contextBuilder(data, DataVersion.V26_3).styles(new ExportTestStyles()).build(), progress); + + assertEquals(stages.toString(), stages.indexOf("biomes") + 1, stages.indexOf("terrain")); + assertEquals(stages.toString(), stages.indexOf("terrain") + 1, stages.indexOf("rivers")); + assertEquals(stages.toString(), stages.indexOf("rivers") + 1, stages.indexOf("caves")); + assertEquals(new Field.Grid(new Field.Shared(HeightCompiler.HEIGHT, new Field.Const(63.0)), 16, 1), ir.router().chunkSurfaceLevel()); + } finally { + data.close(); + } + } + + @Test + public void terrainRunsOnBaseCodesAndBiomeCodesRebindToTheClimateHeight() throws Exception { + File pack = ExportTestPacks.basic(temporaryFolder.newFolder("pack")); + IrisData data = IrisData.openRuntime(pack); + try { + PackCompileContext context = ExportTestPacks.contextBuilder(data, DataVersion.V26_3).styles(new ExportTestStyles()).build(); + new DimensionStage().apply(context); + new BiomeStage().apply(context); + BiomeCodes base = context.biomeCodes(); + + new TerrainStage().apply(context); + + BiomeCodes bound = context.biomeCodes(); + assertEquals(base, context.baseBiomeCodes()); + assertEquals(ClimateHeightCompiler.CLIMATE_HEIGHT, ((Field.Shared) context.climateHeight()).name()); + assertTrue(TerrainTestSupport.contains(bound.surfaceCode(), context.climateHeight())); + assertFalse(TerrainTestSupport.contains(bound.surfaceCode(), context.height())); + assertFalse(TerrainTestSupport.contains(BandTreeCompiler.selector(base), context.height())); + } finally { + data.close(); + } + } + + @Test + public void stageCompileIsDeterministic() throws Exception { + IrisData data = IrisData.openRuntime(featurePack()); + try { + List heights = new ArrayList<>(); + List volumes = new ArrayList<>(); + List noises = new ArrayList<>(); + for (int run = 0; run < 2; run++) { + PackCompileContext context = context(data, DataVersion.V26_3); + new DimensionStage().apply(context); + context.setBiomeCodes(TerrainTestSupport.twoBiomeCodes(context.noises(), 0.0)); + CompileStage stage = new TerrainStage(); + stage.apply(context); + heights.add(context.height()); + volumes.add(context.volumetric()); + noises.add(context.noises().definitions()); + } + + assertEquals(heights.getFirst(), heights.getLast()); + assertEquals(volumes.getFirst(), volumes.getLast()); + assertEquals(noises.getFirst(), noises.getLast()); + } finally { + data.close(); + } + } + + private File featurePack() throws Exception { + File pack = TerrainTestPacks.twoBiomes(temporaryFolder.newFolder("pack"), "", ""); + TerrainTestPacks.generator(pack, "hills", TerrainTestPacks.twoLayers("BILINEAR_STARCAST_9", 12)); + TerrainTestPacks.generator(pack, "ranges", TerrainTestPacks.twoLayers("BILINEAR_STARCAST_12", 200)); + TerrainTestPacks.generator(pack, "terraces", """ + {"interpolator":{"function":"BILINEAR_STARCAST_9","horizontalScale":26},"cliffHeightMin":20,"cliffHeightMax":80, + "surfaceDetail":0.5,"composite":[{"style":{"style":"SIMPLEX"},"seed":3,"bezier":true},{"style":{"style":"PERLIN"},"seed":4,"exponent":1.5}]}"""); + TerrainTestPacks.biome(pack, "a", "{\"name\":\"A\",\"generators\":[" + TerrainTestPacks.link("hills", 20, 140) + "," + + TerrainTestPacks.link("ranges", 0, 60) + "],\"terrain3D\":" + TerrainTestPacks.TERRAIN3D + "}"); + TerrainTestPacks.biome(pack, "b", "{\"name\":\"B\",\"generators\":[" + TerrainTestPacks.link("terraces", 5, 70) + "]," + + "\"terrain3D\":{\"amplitude\":12,\"horizontalScale\":32,\"verticalScale\":12}," + + "\"floatingChildBiomes\":[{\"biome\":\"a\",\"topShapeMode\":\"BIOME\"},{\"topShapeMode\":\"NOISE\",\"rarity\":3}]}"); + return pack; + } + + private static PackCompileContext context(IrisData data, DataVersion target) { + return PackCompileContext.builder() + .data(data) + .dimension(data.getDimensionLoader().load(TerrainTestPacks.DIMENSION, false)) + .capabilities(ExportCapabilities.forTarget(target)) + .namespace(WorldgenExportRequest.DEFAULT_NAMESPACE) + .mode(WorldgenExportMode.PRESET) + .styles(TerrainTestSupport.noiseStyles()) + .build(); + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/terrain/TerrainTestPacks.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/terrain/TerrainTestPacks.java new file mode 100644 index 000000000..f876e6535 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/terrain/TerrainTestPacks.java @@ -0,0 +1,46 @@ +package art.arcane.iris.pack.datapack.export.compile.terrain; + +import art.arcane.iris.testsupport.CompatTestPlatform; + +import java.io.File; +import java.io.IOException; + +final class TerrainTestPacks { + static final String DIMENSION = "main"; + static final String TERRAIN3D = """ + {"amplitude": 24, "horizontalScale": 64, "verticalScale": 16, "crackDepth": 12, "crackWidth": 3, + "crackScale": 48, "minimumSlope": 0.1, "slopeFade": 0.3, "fluidClearance": 4, "fluidFade": 12}"""; + + private TerrainTestPacks() { + } + + static File twoBiomes(File pack, String generatorA, String generatorB) throws IOException { + CompatTestPlatform.write(pack, "dimensions/" + DIMENSION + ".json", "{\"regions\":[\"region\"]}"); + CompatTestPlatform.write(pack, "regions/region.json", "{\"landBiomes\":[\"a\",\"b\"]}"); + CompatTestPlatform.write(pack, "biomes/a.json", "{\"name\":\"A\",\"generators\":[" + generatorA + "]}"); + CompatTestPlatform.write(pack, "biomes/b.json", "{\"name\":\"B\",\"generators\":[" + generatorB + "]}"); + return pack; + } + + static void biome(File pack, String key, String json) throws IOException { + CompatTestPlatform.write(pack, "biomes/" + key + ".json", json); + } + + static void generator(File pack, String key, String json) throws IOException { + CompatTestPlatform.write(pack, "generators/" + key + ".json", json); + } + + static String singleLayer(String function, double radius, String style) { + return "{\"interpolator\":{\"function\":\"" + function + "\",\"horizontalScale\":" + radius + "}," + + "\"composite\":[{\"style\":{\"style\":\"" + style + "\"},\"seed\":3}]}"; + } + + static String twoLayers(String function, double radius) { + return "{\"interpolator\":{\"function\":\"" + function + "\",\"horizontalScale\":" + radius + "}," + + "\"composite\":[{\"style\":{\"style\":\"SIMPLEX\"},\"seed\":3},{\"style\":{\"style\":\"PERLIN\"},\"seed\":5,\"opacity\":0.5}]}"; + } + + static String link(String generator, int min, int max) { + return "{\"generator\":\"" + generator + "\",\"min\":" + min + ",\"max\":" + max + "}"; + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/terrain/TerrainTestSupport.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/terrain/TerrainTestSupport.java new file mode 100644 index 000000000..4df992d6e --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/terrain/TerrainTestSupport.java @@ -0,0 +1,203 @@ +package art.arcane.iris.pack.datapack.export.compile.terrain; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.compile.biome.BiomeCodes; +import art.arcane.iris.pack.datapack.export.compile.noise.NoiseRegistry; +import art.arcane.iris.pack.datapack.export.compile.noise.StyleCompiler; +import art.arcane.iris.pack.datapack.export.ir.ClimateLayout; +import art.arcane.iris.pack.datapack.export.ir.CostClass; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.FieldEvaluator; +import art.arcane.iris.pack.datapack.export.ir.NoiseDef; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Deque; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +final class TerrainTestSupport { + static final String NAMESPACE = "iris_export"; + static final long SEED = 1337L; + static final String SELECTOR_ID = "test/selector"; + static final NoiseDef SELECTOR = new NoiseDef(SELECTOR_ID, -8, List.of(1.0, 1.0)); + + private TerrainTestSupport() { + } + + static StyleCompiler noiseStyles() { + return (style, seedSalt, registry, capabilities) -> { + String id = registry.register(new NoiseDef("test/style_" + Long.toHexString(seedSalt), -5, List.of(1.0))); + Field noise = new Field.Noise(id, 1.0 / style.getZoom(), 0.0, null, null, null); + return new Field.Binary(Field.BinaryOp.ADD, new Field.Const(0.5), new Field.Binary(Field.BinaryOp.MUL, new Field.Const(0.5), noise)); + }; + } + + static StyleCompiler constantStyles(double value) { + return (style, seedSalt, registry, capabilities) -> new Field.Const(value); + } + + static BiomeCodes twoBiomeCodes(NoiseRegistry registry, double threshold) { + String selector = registry.register(SELECTOR); + Field code = new Field.Partition(new Field.Noise(selector, 1.0, 0.0, null, null, null), List.of(threshold), + List.of(new Field.Const(0.1), new Field.Const(0.3))); + ClimateLayout layout = new ClimateLayout(List.of( + new ClimateLayout.ClimateEntry(NAMESPACE + ":a", ClimateLayout.ClimateAxis.TEMPERATURE, 0.05, 0.15), + new ClimateLayout.ClimateEntry(NAMESPACE + ":b", ClimateLayout.ClimateAxis.TEMPERATURE, 0.25, 0.35))); + return new BiomeCodes(code, new Field.Const(0.0), new Field.Const(0.0), layout, Map.of(NAMESPACE + ":a", "a", NAMESPACE + ":b", "b")); + } + + static BiomeCodes singleBiomeCodes(String loadKey) { + ClimateLayout layout = new ClimateLayout(List.of( + new ClimateLayout.ClimateEntry(NAMESPACE + ":" + loadKey, ClimateLayout.ClimateAxis.TEMPERATURE, -1.0, 1.0))); + return new BiomeCodes(new Field.Const(0.0), new Field.Const(0.0), new Field.Const(0.0), layout, Map.of(NAMESPACE + ":" + loadKey, loadKey)); + } + + static double sample(Field field, NoiseRegistry registry, DataVersion target, int x, int y, int z) { + return FieldEvaluator.sample(field, noises(registry), SEED, target, x, y, z); + } + + static Map noises(NoiseRegistry registry) { + Map noises = new LinkedHashMap<>(); + for (NoiseDef definition : registry.definitions()) { + noises.put(registry.namespace() + ":" + definition.id(), definition); + } + return noises; + } + + static boolean contains(Field root, Field target) { + Set visited = Collections.newSetFromMap(new IdentityHashMap<>()); + Deque pending = new ArrayDeque<>(); + pending.push(root); + while (!pending.isEmpty()) { + Field field = pending.pop(); + if (field.equals(target)) { + return true; + } + if (visited.add(field)) { + for (Field child : FieldMacros.children(field)) { + pending.push(child); + } + } + } + return false; + } + + static List nodes(Field root) { + List nodes = new ArrayList<>(); + Set visited = Collections.newSetFromMap(new IdentityHashMap<>()); + Deque pending = new ArrayDeque<>(); + pending.push(root); + while (!pending.isEmpty()) { + Field field = pending.pop(); + if (visited.add(field)) { + nodes.add(field); + for (Field child : FieldMacros.children(field)) { + pending.push(child); + } + } + } + return nodes; + } + + static List unguardedExpensiveBranches(Field root) { + List violations = new ArrayList<>(); + walkGuards(root, "root", violations, Collections.newSetFromMap(new IdentityHashMap<>())); + return violations; + } + + private static void walkGuards(Field field, String path, List violations, Set visited) { + if (!visited.add(field)) { + return; + } + switch (field) { + case Field.Binary binary -> { + if (binary.op() == Field.BinaryOp.MUL) { + checkProduct(binary, path, violations); + } + walkGuards(binary.left(), path + "/left", violations, visited); + walkGuards(binary.right(), path + "/right", violations, visited); + } + case Field.Lerp lerp -> { + checkBranch(lerp.first(), path + "/lerp.first", violations); + checkBranch(lerp.second(), path + "/lerp.second", violations); + walkGuards(lerp.alpha(), path + "/alpha", violations, visited); + walkGuards(lerp.first(), path + "/first", violations, visited); + walkGuards(lerp.second(), path + "/second", violations, visited); + } + case Field.RangeIf rangeIf -> { + checkBranch(rangeIf.inRange(), path + "/range.in", violations); + checkBranch(rangeIf.outOfRange(), path + "/range.out", violations); + walkGuards(rangeIf.input(), path + "/input", violations, visited); + walkGuards(rangeIf.inRange(), path + "/in", violations, visited); + walkGuards(rangeIf.outOfRange(), path + "/out", violations, visited); + } + case Field.Partition partition -> { + for (Field leaf : partition.leaves()) { + checkBranch(leaf, path + "/partition.leaf", violations); + } + walkGuards(partition.input(), path + "/input", violations, visited); + } + case Field.Unary unary -> walkGuards(unary.input(), path + "/" + unary.op(), violations, visited); + case Field.Clamp clamp -> walkGuards(clamp.input(), path + "/clamp", violations, visited); + case Field.Round round -> { + walkGuards(round.input(), path + "/round", violations, visited); + walkGuards(round.multiple(), path + "/multiple", violations, visited); + } + case Field.Curve curve -> { + walkGuards(curve.input(), path + "/curve", violations, visited); + for (Field.Knot knot : curve.knots()) { + walkGuards(knot.value(), path + "/knot", violations, visited); + } + } + case Field.Mux mux -> { + walkGuards(mux.index(), path + "/index", violations, visited); + for (Field selected : mux.cases()) { + walkGuards(selected, path + "/case", violations, visited); + } + } + case Field.Gate gate -> { + walkGuards(gate.condition(), path + "/condition", violations, visited); + walkGuards(gate.expr(), path + "/gated", violations, visited); + } + case Field.Grid grid -> walkGuards(grid.expr(), path + "/grid", violations, visited); + case Field.Shared shared -> walkGuards(shared.expr(), path + "/" + shared.name(), violations, visited); + case Field.Slice slice -> walkGuards(slice.expr(), path + "/slice", violations, visited); + case Field.TopSurface topSurface -> walkGuards(topSurface.density(), path + "/top", violations, visited); + case Field.Noise noise -> { + for (Field shift : new Field[]{noise.shiftX(), noise.shiftY(), noise.shiftZ()}) { + if (shift != null) { + walkGuards(shift, path + "/shift", violations, visited); + } + } + } + case Field.Const ignored -> { + } + case Field.Coord ignored -> { + } + case Field.Ref ignored -> { + } + } + } + + private static void checkProduct(Field.Binary product, String path, List violations) { + boolean leftShields = product.left() instanceof Field.Const || FieldMacros.isGuard(product.left()); + boolean rightShields = product.right() instanceof Field.Const || FieldMacros.isGuard(product.right()); + if (!rightShields) { + checkBranch(product.left(), path + "/mul.left", violations); + } + if (!leftShields) { + checkBranch(product.right(), path + "/mul.right", violations); + } + } + + private static void checkBranch(Field branch, String path, List violations) { + if (branch.cost() == CostClass.EXPENSIVE && !FieldMacros.isGuard(branch)) { + violations.add(path + " " + branch.getClass().getSimpleName()); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/terrain/VolumetricCompilerTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/terrain/VolumetricCompilerTest.java new file mode 100644 index 000000000..796b92bb3 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/compile/terrain/VolumetricCompilerTest.java @@ -0,0 +1,252 @@ +package art.arcane.iris.pack.datapack.export.compile.terrain; + +import art.arcane.iris.generation.noise.IrisGeneratorStyle; +import art.arcane.iris.generation.noise.NoiseStyle; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ExportTestPacks; +import art.arcane.iris.pack.datapack.export.compile.biome.BiomeCodes; +import art.arcane.iris.pack.datapack.export.compile.noise.NoiseRegistry; +import art.arcane.iris.pack.datapack.export.compile.noise.NoiseStyleCompiler; +import art.arcane.iris.pack.datapack.export.compile.noise.StyleCompiler; +import art.arcane.iris.pack.datapack.export.compile.noise.ValueDistributionFit; +import art.arcane.iris.pack.datapack.export.ir.Axis; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.lower.ExportCapabilities; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.noise.CNG; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.SplittableRandom; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class VolumetricCompilerTest { + private static final ExportCapabilities CAPABILITIES = ExportCapabilities.forTarget(DataVersion.V26_3); + private static final int SEA_LEVEL = 63; + private static final double VOLUME_AMPLITUDE = 24.0; + private static final double VOLUME_SCALE = 96.0; + private static final double CRACK_DEPTH = 12.0; + private static final double CRACK_WIDTH = 15.0; + private static final double CRACK_SCALE = 96.0; + private static final long MARGINAL_SEED = 8191L; + private static final int MARGINAL_SAMPLES = 6000; + private static final int MARGINAL_SPAN = 1 << 20; + private static final int SHAPED_FLOOR = SEA_LEVEL + 8; + private static final int SHAPED_CEILING = 320; + private static final String ISLAND = """ + {"name":"Sky","floatingChildBiomes":[{"topShapeMode":"FLAT","maxTopHeight":10,"minHeightAboveSurface":160, + "maxHeightAboveSurface":160,"bottomDepthMin":4,"bottomDepthMax":4,"footprintThreshold":0.5}]}"""; + + @ClassRule + public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Rule + public ExportTestPacks.BoundPlatform platform = new ExportTestPacks.BoundPlatform(); + + @Test + public void terrain3dVariantsSitBehindGates() throws Exception { + File pack = TerrainTestPacks.twoBiomes(temporaryFolder.newFolder("pack"), + TerrainTestPacks.link("hills", 20, 140), TerrainTestPacks.link("hills", 10, 60)); + TerrainTestPacks.generator(pack, "hills", TerrainTestPacks.singleLayer("BILINEAR_STARCAST_9", 12, "SIMPLEX")); + TerrainTestPacks.biome(pack, "a", "{\"name\":\"A\",\"generators\":[" + TerrainTestPacks.link("hills", 20, 140) + + "],\"terrain3D\":" + TerrainTestPacks.TERRAIN3D + "}"); + TerrainTestPacks.biome(pack, "b", "{\"name\":\"B\",\"generators\":[" + TerrainTestPacks.link("hills", 10, 60) + + "],\"terrain3D\":{\"amplitude\":12,\"horizontalScale\":32,\"verticalScale\":12}}"); + IrisData data = IrisData.openRuntime(pack); + try { + NoiseRegistry registry = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + BiomeCodes codes = TerrainTestSupport.twoBiomeCodes(registry, 0.0); + Compiled compiled = compile(data, registry, TerrainTestSupport.noiseStyles(), codes); + int shaped = 0; + + assertTrue(compiled.volumetric().axes().contains(Axis.Y)); + assertTrue(TerrainTestSupport.unguardedExpensiveBranches(compiled.volumetric()).toString(), + TerrainTestSupport.unguardedExpensiveBranches(compiled.volumetric()).isEmpty()); + for (int x = -800; x <= 800; x += 37) { + for (int z = -800; z <= 800; z += 41) { + assertEquals(0.0, TerrainTestSupport.sample(compiled.volumetric(), registry, DataVersion.V26_3, x, SEA_LEVEL + 4, z), 0.0); + double surface = TerrainTestSupport.sample(compiled.height(), registry, DataVersion.V26_3, x, 0, z); + double displacement = TerrainTestSupport.sample(compiled.volumetric(), registry, DataVersion.V26_3, x, (int) surface, z); + assertTrue("displacement " + displacement, Math.abs(displacement) <= 24.0 + 12.0 + 1.0e-3); + if (Math.abs(displacement) > 0.5) { + shaped++; + } + } + } + assertTrue("terrain3D should shape some columns, got " + shaped, shaped > 0); + } finally { + data.close(); + } + } + + @Test + public void displacementFollowsTheVolumetricNoiseMarginal() throws Exception { + IrisData data = IrisData.openRuntime(uniformTerrain3d(temporaryFolder.newFolder("pack"), VOLUME_AMPLITUDE, 0)); + try { + NoiseRegistry registry = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + Compiled compiled = compile(data, registry, NoiseStyleCompiler.create(new ArrayList<>()), TerrainTestSupport.twoBiomeCodes(registry, 0.0)); + CNG iris = new IrisGeneratorStyle(NoiseStyle.SIMPLEX).createNoCache(new RNG(MARGINAL_SEED), data); + SplittableRandom random = new SplittableRandom(MARGINAL_SEED); + double[] exported = new double[MARGINAL_SAMPLES]; + double[] expected = new double[MARGINAL_SAMPLES]; + for (int index = 0; index < MARGINAL_SAMPLES; index++) { + int x = random.nextInt(-MARGINAL_SPAN, MARGINAL_SPAN); + int y = random.nextInt(SHAPED_FLOOR, SHAPED_CEILING); + int z = random.nextInt(-MARGINAL_SPAN, MARGINAL_SPAN); + exported[index] = TerrainTestSupport.sample(compiled.volumetric(), registry, DataVersion.V26_3, x, y, z) / VOLUME_AMPLITUDE; + expected[index] = Math.clamp(iris.noiseFastSigned3D(x * 64.0 / VOLUME_SCALE, y * 64.0 / VOLUME_SCALE, z * 64.0 / VOLUME_SCALE), -1.0, 1.0); + } + Arrays.sort(exported); + Arrays.sort(expected); + + for (double level : new double[]{0.02, 0.05, 0.25, 0.75, 0.95, 0.98}) { + assertEquals("quantile " + level, ValueDistributionFit.quantile(expected, level), ValueDistributionFit.quantile(exported, level), 0.05); + } + } finally { + data.close(); + } + } + + @Test + public void fissuresOpenAsOftenAsTheVolumetricCrackNoiseAllows() throws Exception { + IrisData data = IrisData.openRuntime(uniformTerrain3d(temporaryFolder.newFolder("pack"), 0, CRACK_DEPTH)); + try { + NoiseRegistry registry = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + Compiled compiled = compile(data, registry, NoiseStyleCompiler.create(new ArrayList<>()), TerrainTestSupport.twoBiomeCodes(registry, 0.0)); + CNG iris = new IrisGeneratorStyle(NoiseStyle.SIMPLEX).createNoCache(new RNG(MARGINAL_SEED), data); + SplittableRandom random = new SplittableRandom(MARGINAL_SEED); + int exported = 0; + int expected = 0; + for (int index = 0; index < MARGINAL_SAMPLES; index++) { + int x = random.nextInt(-MARGINAL_SPAN, MARGINAL_SPAN); + int y = random.nextInt(SHAPED_FLOOR, SHAPED_CEILING); + int z = random.nextInt(-MARGINAL_SPAN, MARGINAL_SPAN); + if (TerrainTestSupport.sample(compiled.volumetric(), registry, DataVersion.V26_3, x, y, z) < 0.0) { + exported++; + } + double frequency = 64.0 / CRACK_SCALE; + double distance = CRACK_SCALE * 0.5 * Math.clamp(iris.noiseFastSigned3D(x * frequency, y * frequency * 0.25, z * frequency), -1.0, 1.0); + if (Math.abs(distance) < CRACK_WIDTH) { + expected++; + } + } + + assertEquals(expected / (double) MARGINAL_SAMPLES, exported / (double) MARGINAL_SAMPLES, 0.02); + } finally { + data.close(); + } + } + + @Test + public void floatingIslandIsSolidBetweenItsBaseAndTop() throws Exception { + File pack = TerrainTestPacks.twoBiomes(temporaryFolder.newFolder("pack"), "", ""); + TerrainTestPacks.biome(pack, "b", ISLAND); + IrisData data = IrisData.openRuntime(pack); + try { + NoiseRegistry registry = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + BiomeCodes codes = TerrainTestSupport.twoBiomeCodes(registry, 0.0); + Compiled compiled = compile(data, registry, TerrainTestSupport.constantStyles(0.75), codes); + int island = 0; + int ground = 0; + + assertTrue(TerrainTestSupport.unguardedExpensiveBranches(compiled.volumetric()).isEmpty()); + for (int x = -1200; x <= 1200; x += 53) { + double code = TerrainTestSupport.sample(codes.surfaceCode(), registry, DataVersion.V26_3, x, 0, 9); + boolean sky = code > 0.2; + for (int y : new int[]{150, 155, 156, 163, 170, 171, 180}) { + double density = SEA_LEVEL - y + TerrainTestSupport.sample(compiled.volumetric(), registry, DataVersion.V26_3, x, y, 9); + boolean solid = density > 0.0; + assertEquals("x " + x + " y " + y + " density " + density, sky && y >= 156 && y <= 170, solid); + } + if (sky) { + island++; + } else { + ground++; + } + } + assertTrue(island > 0 && ground > 0); + } finally { + data.close(); + } + } + + @Test + public void unexportedIslandFeaturesReportDiagnostics() throws Exception { + File pack = TerrainTestPacks.twoBiomes(temporaryFolder.newFolder("pack"), "", ""); + TerrainTestPacks.biome(pack, "b", """ + {"name":"Sky","floatingChildBiomes":[{"topShapeMode":"BIOME","wallWarpStyle":{"style":"SIMPLEX"},"localFluidHeight":3}]}"""); + IrisData data = IrisData.openRuntime(pack); + try { + NoiseRegistry registry = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + List diagnostics = new ArrayList<>(); + IrisDimension dimension = data.getDimensionLoader().load(TerrainTestPacks.DIMENSION, false); + BiomeCodes codes = TerrainTestSupport.twoBiomeCodes(registry, 0.0); + TerrainHeight height = new HeightCompiler(TerrainTestSupport.noiseStyles(), diagnostics).compile(codes, dimension, data, registry, CAPABILITIES); + + new VolumetricCompiler(TerrainTestSupport.noiseStyles(), diagnostics).compile(height, codes, dimension, data, registry, CAPABILITIES); + + assertEquals(1, diagnostics.size()); + assertEquals(ExportLimit.FLOATING_ISLAND, diagnostics.getFirst().limit()); + assertEquals("biomes/b", diagnostics.getFirst().resource()); + } finally { + data.close(); + } + } + + @Test + public void packWithoutVolumetricFeaturesIsNeutral() throws Exception { + File pack = TerrainTestPacks.twoBiomes(temporaryFolder.newFolder("pack"), + TerrainTestPacks.link("hills", 0, 20), TerrainTestPacks.link("hills", 40, 90)); + TerrainTestPacks.generator(pack, "hills", TerrainTestPacks.twoLayers("BILINEAR_STARCAST_9", 12)); + IrisData data = IrisData.openRuntime(pack); + try { + NoiseRegistry registry = new NoiseRegistry(TerrainTestSupport.NAMESPACE); + + Compiled compiled = compile(data, registry, TerrainTestSupport.noiseStyles(), TerrainTestSupport.twoBiomeCodes(registry, 0.0)); + + assertEquals(new Field.Const(0.0), compiled.volumetric()); + } finally { + data.close(); + } + } + + private static File uniformTerrain3d(File folder, double amplitude, double crackDepth) throws Exception { + File pack = TerrainTestPacks.twoBiomes(folder, TerrainTestPacks.link("flat", 100, 100), TerrainTestPacks.link("flat", 100, 100)); + TerrainTestPacks.generator(pack, "flat", TerrainTestPacks.singleLayer("BILINEAR_STARCAST_9", 12, "FLAT")); + String terrain = "{\"amplitude\":" + amplitude + ",\"horizontalScale\":" + VOLUME_SCALE + ",\"verticalScale\":" + VOLUME_SCALE + + ",\"densityStyle\":{\"style\":\"SIMPLEX\"},\"crackDepth\":" + crackDepth + ",\"crackWidth\":" + CRACK_WIDTH + + ",\"crackScale\":" + CRACK_SCALE + ",\"crackStyle\":{\"style\":\"SIMPLEX\"},\"minimumSlope\":0,\"fluidClearance\":0,\"fluidFade\":1}"; + for (String key : new String[]{"a", "b"}) { + TerrainTestPacks.biome(pack, key, "{\"name\":\"" + key + "\",\"generators\":[" + TerrainTestPacks.link("flat", 100, 100) + + "],\"terrain3D\":" + terrain + "}"); + } + return pack; + } + + private static Compiled compile(IrisData data, NoiseRegistry registry, StyleCompiler styles, BiomeCodes codes) { + IrisDimension dimension = data.getDimensionLoader().load(TerrainTestPacks.DIMENSION, false); + List diagnostics = new ArrayList<>(); + TerrainHeight terrain = new HeightCompiler(styles, diagnostics).compile(codes, dimension, data, registry, CAPABILITIES); + Field volumetric = new VolumetricCompiler(styles, diagnostics).compile(terrain, codes, dimension, data, registry, CAPABILITIES); + return new Compiled(terrain.height(), volumetric); + } + + private record Compiled(Field height, Field volumetric) { + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/emit/DensityFunctionEmitter26_3Test.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/emit/DensityFunctionEmitter26_3Test.java new file mode 100644 index 000000000..4d40b7cf2 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/emit/DensityFunctionEmitter26_3Test.java @@ -0,0 +1,363 @@ +package art.arcane.iris.pack.datapack.export.emit; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ir.Axis; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.NoiseDef; +import art.arcane.iris.pack.datapack.export.lower.ExportCapabilities; +import art.arcane.iris.pack.datapack.export.lower.VanillaBounds; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class DensityFunctionEmitter26_3Test { + private static final String NAMESPACE = "iris_export"; + private static final Field NOISE = new Field.Noise("iris_export:base", 0.25, 0.0, null, null, null); + private static final Field NOISE_3D = new Field.Noise("iris_export:base", 0.25, 0.5, null, null, null); + private static final String NOISE_JSON = "{\"type\":\"minecraft:noise\",\"noise\":\"iris_export:base\",\"xz_scale\":0.25,\"y_scale\":0.0}"; + private static final String NOISE_3D_JSON = "{\"type\":\"minecraft:noise\",\"noise\":\"iris_export:base\",\"xz_scale\":0.25,\"y_scale\":0.5}"; + + @Test + public void disjointMinMaxEmitsTheInputThatAlwaysWins() { + Field band = new Field.Clamp(NOISE, 0.25, 0.75); + DensityFunctionEmitter emitter = DensityFunctionEmitter.forTarget(ExportCapabilities.forTarget(DataVersion.V26_3), NAMESPACE, + VanillaBounds.of(NAMESPACE, List.of(new NoiseDef("base", 0, List.of(1.0))))); + JsonElement clamp = emitter.emit(band, new FunctionTable(NAMESPACE)); + assertEquals(clamp, emitter.emit(new Field.Binary(Field.BinaryOp.MAX, band, new Field.Const(0.0)), new FunctionTable(NAMESPACE))); + assertEquals(clamp, emitter.emit(new Field.Binary(Field.BinaryOp.MIN, new Field.Const(2.0), band), new FunctionTable(NAMESPACE))); + assertEquals(json("0.125"), emitter.emit(new Field.Binary(Field.BinaryOp.MIN, new Field.Const(0.125), band), new FunctionTable(NAMESPACE))); + assertEquals("minecraft:max", type(emitter.emit(new Field.Binary(Field.BinaryOp.MAX, band, new Field.Const(0.5)), new FunctionTable(NAMESPACE)))); + } + + @Test + public void sub26_3UsesLeftRight() { + JsonElement emitted = emit(new Field.Binary(Field.BinaryOp.SUB, NOISE, new Field.Const(2.0))); + assertEquals(json("{\"type\":\"minecraft:sub\",\"left\":" + NOISE_JSON + ",\"right\":2.0}"), emitted); + } + + @Test + public void unaryUsesInput() { + JsonElement emitted = emit(new Field.Unary(Field.UnaryOp.HALF_NEG, NOISE)); + assertEquals(json("{\"type\":\"minecraft:half_negative\",\"input\":" + NOISE_JSON + "}"), emitted); + assertEquals("minecraft:negate", type(emit(new Field.Unary(Field.UnaryOp.NEG, NOISE)))); + assertEquals("minecraft:quarter_negative", type(emit(new Field.Unary(Field.UnaryOp.QUARTER_NEG, NOISE)))); + } + + @Test + public void powUsesBaseAndExponent() { + Field base = new Field.Clamp(NOISE, 0.0, 1.0); + JsonObject emitted = emit(new Field.Binary(Field.BinaryOp.POW, base, new Field.Const(1.5))).getAsJsonObject(); + assertEquals("minecraft:pow", emitted.get("type").getAsString()); + assertEquals(emit(base), emitted.get("base")); + assertEquals(1.5, emitted.get("exponent").getAsDouble(), 0.0); + } + + @Test + public void noiseIsAlwaysARegisteredReference() { + Field shifted = new Field.Noise("iris_export:base", 0.25, 0.0, new Field.Const(3.0), null, NOISE); + JsonObject emitted = emit(shifted).getAsJsonObject(); + assertEquals("minecraft:noise", emitted.get("type").getAsString()); + assertEquals("iris_export:base", emitted.get("noise").getAsString()); + assertEquals(3.0, emitted.get("shift_x").getAsDouble(), 0.0); + assertEquals(json(NOISE_JSON), emitted.get("shift_z")); + assertTrue(!emitted.has("shift_y")); + } + + @Test + public void coordinateEmitsGradient() { + Field coord = new Field.Coord(Axis.X, Field.Tiling.REPEAT, 0.0, 512.0, 0.0, 1.0); + assertEquals(json("{\"type\":\"minecraft:gradient\",\"axis\":\"x\",\"tiling\":\"repeat\",\"from_coordinate\":0,\"to_coordinate\":512,\"from_value\":0.0,\"to_value\":1.0}"), emit(coord)); + Field fractional = new Field.Coord(Axis.X, Field.Tiling.CLAMP, 0.5, 512.0, 0.0, 1.0); + assertThrows(IllegalArgumentException.class, () -> emit(fractional)); + } + + @Test + public void gridCustomCell26_3EmitsCellSizes() { + JsonElement emitted = emit(new Field.Grid(NOISE_3D, 8, 4)); + assertEquals(json("{\"type\":\"minecraft:interpolated\",\"input\":" + NOISE_3D_JSON + ",\"cell_size_xz\":8,\"cell_size_y\":4}"), emitted); + } + + @Test + public void muxOn26_3IsSplineWithIdentityKnots() { + Field second = new Field.Noise("iris_export:other", 0.5, 0.0, null, null, null); + JsonObject emitted = emit(new Field.Mux(NOISE, List.of(NOISE_3D, second))).getAsJsonObject(); + assertEquals("minecraft:spline", emitted.get("type").getAsString()); + JsonObject spline = emitted.getAsJsonObject("spline"); + assertEquals(json(NOISE_JSON), spline.get("coordinate")); + JsonArray points = spline.getAsJsonArray("points"); + List locations = new ArrayList<>(); + for (JsonElement point : points) { + locations.add(point.getAsJsonObject().get("location").getAsDouble()); + } + assertEquals(List.of(0.0, 0.25, 0.75, 1.0), locations); + assertEquals(identity(NOISE_3D_JSON, "-2.0", "2.0"), points.get(0).getAsJsonObject().get("value")); + assertEquals(0.0, points.get(1).getAsJsonObject().get("value").getAsDouble(), 0.0); + assertEquals(0.0, points.get(2).getAsJsonObject().get("value").getAsDouble(), 0.0); + assertEquals(identity(emit(second).toString(), "-2.0", "2.0"), points.get(3).getAsJsonObject().get("value")); + for (JsonElement point : points) { + assertEquals(0.0, point.getAsJsonObject().get("derivative").getAsDouble(), 0.0); + } + } + + @Test + public void gateOn26_3IsSplineGate() { + JsonObject emitted = emit(new Field.Gate(NOISE, NOISE_3D)).getAsJsonObject(); + JsonObject spline = emitted.getAsJsonObject("spline"); + assertEquals(json(NOISE_JSON), spline.get("coordinate")); + JsonArray points = spline.getAsJsonArray("points"); + assertEquals(2, points.size()); + assertEquals(Float.MIN_VALUE, points.get(0).getAsJsonObject().get("location").getAsFloat(), 0.0f); + assertEquals(0.0, points.get(0).getAsJsonObject().get("value").getAsDouble(), 0.0); + assertEquals(Math.nextUp(Float.MIN_VALUE), points.get(1).getAsJsonObject().get("location").getAsFloat(), 0.0f); + assertEquals(identity(NOISE_3D_JSON, "-2.0", "2.0"), points.get(1).getAsJsonObject().get("value")); + } + + @Test + public void identitySplineSpansAPowerOfTwoAroundTheCaseRange() { + Field scaled = new Field.Binary(Field.BinaryOp.ADD, new Field.Const(1.0), new Field.Binary(Field.BinaryOp.MUL, new Field.Const(4.0), NOISE)); + Field reference = new Field.Ref("minecraft:overworld/continents"); + JsonArray points = emit(new Field.Mux(NOISE, List.of(scaled, reference))).getAsJsonObject().getAsJsonObject("spline").getAsJsonArray("points"); + + assertEquals(identity(emit(scaled).toString(), "-8.0", "8.0"), points.get(0).getAsJsonObject().get("value")); + assertEquals(identity("\"minecraft:overworld/continents\"", "-1000000.0", "1000000.0"), points.get(3).getAsJsonObject().get("value")); + } + + @Test + public void identitySplineIsExactForCodesInFloat() { + Field code = new Field.Clamp(NOISE, -1.0, 1.0); + JsonObject identity = emit(new Field.Mux(NOISE, List.of(code, code))).getAsJsonObject().getAsJsonObject("spline") + .getAsJsonArray("points").get(0).getAsJsonObject().getAsJsonObject("value"); + + for (float value : new float[]{-0.99f, -0.5f, -0.0123f, 0.0f, 0.2731f, 0.731f, 0.99f}) { + assertEquals(value, vanillaSpline(identity, value), 1.0e-6f); + } + } + + @Test + public void identitySplineIsExactForIndexValuesInFloat() { + List leaves = new ArrayList<>(); + List thresholds = new ArrayList<>(); + for (int index = 0; index <= 312; index++) { + leaves.add(new Field.Const(index)); + if (index > 0) { + thresholds.add(-1.0 + 2.0 * index / 313.0); + } + } + Field index = new Field.Partition(NOISE, thresholds, leaves); + JsonObject identity = emit(new Field.Mux(NOISE, List.of(index, index))).getAsJsonObject().getAsJsonObject("spline") + .getAsJsonArray("points").get(0).getAsJsonObject().getAsJsonObject("value"); + + for (int value = 0; value <= 312; value++) { + assertEquals(value, vanillaSpline(identity, value), 0.0f); + } + } + + @Test + public void muxTakesTheCaseAtTheRoundedClampedIndex() { + JsonObject spline = emit(new Field.Mux(NOISE, List.of(new Field.Clamp(NOISE, -1.0, 1.0), new Field.Clamp(NOISE, -1.0, 1.0), + new Field.Clamp(NOISE, -1.0, 1.0)))).getAsJsonObject().getAsJsonObject("spline"); + JsonArray points = spline.getAsJsonArray("points"); + List locations = new ArrayList<>(); + for (JsonElement point : points) { + locations.add(point.getAsJsonObject().get("location").getAsDouble()); + } + + assertEquals(List.of(0.0, 0.25, 0.75, 1.0, 1.25, 1.75, 2.0), locations); + assertTrue(points.get(0).getAsJsonObject().get("value").isJsonObject()); + assertTrue(points.get(locations.size() - 1).getAsJsonObject().get("value").isJsonObject()); + assertEquals(0.0f, derivative(points, 0), 0.0f); + assertEquals(0.0f, derivative(points, locations.size() - 1), 0.0f); + } + + @Test + public void constantMuxCasesAreKnotValues() { + JsonArray points = emit(new Field.Mux(NOISE, List.of(new Field.Const(3.0), NOISE_3D))).getAsJsonObject() + .getAsJsonObject("spline").getAsJsonArray("points"); + + assertEquals(3.0, points.get(0).getAsJsonObject().get("value").getAsDouble(), 0.0); + assertTrue(points.get(3).getAsJsonObject().get("value").isJsonObject()); + } + + @Test + public void singleCaseMuxEmitsTheCase() { + assertEquals(json(NOISE_3D_JSON), emit(new Field.Mux(NOISE, List.of(NOISE_3D)))); + } + + @Test + public void gateOpensForEveryPositiveCondition() { + JsonObject spline = emit(new Field.Gate(NOISE, new Field.Clamp(NOISE, -1.0, 1.0))).getAsJsonObject().getAsJsonObject("spline"); + JsonArray points = spline.getAsJsonArray("points"); + + assertEquals(0.0f, vanillaSpline(spline, 0.0f), 0.0f); + assertEquals(0.0f, vanillaSpline(spline, -3.0f), 0.0f); + assertTrue(location(points, 0) > 0.0f); + assertEquals(Math.nextUp(Float.MIN_VALUE), location(points, 1), 0.0f); + assertTrue(points.get(1).getAsJsonObject().get("value").isJsonObject()); + } + + @Test + public void curveEmitsNestedSplines() { + Field.Curve inner = new Field.Curve(NOISE_3D, List.of(new Field.Knot(-1.0, new Field.Const(0.0), 0.0), new Field.Knot(1.0, new Field.Const(0.4), 0.1))); + Field.Curve outer = new Field.Curve(NOISE, List.of(new Field.Knot(-1.0, new Field.Const(-0.5), 0.0), new Field.Knot(0.0, inner, 0.0))); + JsonElement expected = json("{\"type\":\"minecraft:spline\",\"spline\":{\"coordinate\":" + NOISE_JSON + ",\"points\":[" + + "{\"location\":-1.0,\"value\":-0.5,\"derivative\":0.0}," + + "{\"location\":0.0,\"value\":{\"coordinate\":" + NOISE_3D_JSON + ",\"points\":[{\"location\":-1.0,\"value\":0.0,\"derivative\":0.0},{\"location\":1.0,\"value\":0.4,\"derivative\":0.1}]},\"derivative\":0.0}]}}"); + assertEquals(expected, emit(outer)); + } + + @Test + public void partitionIsIntervalSelect() { + Field partition = new Field.Partition(NOISE, List.of(-0.2, 0.3), List.of(new Field.Const(-1.0), new Field.Const(0.0), new Field.Const(1.0))); + assertEquals(json("{\"type\":\"minecraft:interval_select\",\"input\":" + NOISE_JSON + ",\"thresholds\":[-0.2,0.3],\"functions\":[-1.0,0.0,1.0]}"), emit(partition)); + } + + @Test + public void sharedEmitsCacheAndNamedFile() { + FunctionTable table = new FunctionTable(NAMESPACE); + Field.Shared shared = new Field.Shared("terrain/height", new Field.Binary(Field.BinaryOp.MUL, NOISE, new Field.Const(16.0))); + JsonElement emitted = emitter().emit(new Field.Binary(Field.BinaryOp.ADD, shared, new Field.Binary(Field.BinaryOp.MUL, shared, new Field.Const(2.0))), table); + assertEquals(1, table.files().size()); + assertEquals(json("{\"type\":\"minecraft:cache\",\"input\":{\"type\":\"minecraft:mul\",\"left\":" + NOISE_JSON + ",\"right\":16.0}}"), table.files().get("terrain/height")); + JsonObject root = emitted.getAsJsonObject(); + assertEquals("iris_export:terrain/height", root.get("left").getAsString()); + assertEquals("iris_export:terrain/height", root.getAsJsonObject("right").get("left").getAsString()); + } + + @Test + public void constantsRoundTripAsFloat26_3() { + assertEquals("0.1", JsonNumbers.constant(0.1, DataVersion.V26_3).toString()); + assertEquals("0.12345679", JsonNumbers.constant(0.1234567890123, DataVersion.V26_3).toString()); + assertEquals("0.0001", JsonNumbers.constant(1.0e-4, DataVersion.V26_3).toString()); + assertEquals("2.0", JsonNumbers.constant(2.0, DataVersion.V26_3).toString()); + assertEquals("0.0", JsonNumbers.constant(-0.0, DataVersion.V26_3).toString()); + assertEquals(0.1, JsonNumbers.constant(0.1, DataVersion.V26_3).getAsDouble(), 0.0); + assertEquals("0.1", emit(new Field.Const(0.1)).toString()); + } + + @Test + public void constantBeyondLimitThrows() { + assertEquals("1000000.0", JsonNumbers.constant(1.0e6, DataVersion.V26_3).toString()); + assertThrows(IllegalArgumentException.class, () -> JsonNumbers.constant(1.0e6 + 1.0, DataVersion.V26_3)); + assertThrows(IllegalArgumentException.class, () -> JsonNumbers.constant(-2.0e6, DataVersion.V26_3)); + assertThrows(IllegalArgumentException.class, () -> emit(new Field.Clamp(NOISE, -2.0e6, 0.0))); + } + + @Test + public void sqrtAndLogInputsAreClampedToTheirDomain() { + JsonObject sqrt = emit(new Field.Unary(Field.UnaryOp.SQRT, NOISE)).getAsJsonObject(); + JsonObject sqrtInput = sqrt.getAsJsonObject("input"); + assertEquals("minecraft:clamp", sqrtInput.get("type").getAsString()); + assertEquals(0.0, sqrtInput.get("min").getAsDouble(), 0.0); + JsonObject log = emit(new Field.Unary(Field.UnaryOp.LOG, NOISE)).getAsJsonObject(); + JsonObject logInput = log.getAsJsonObject("input"); + assertEquals("minecraft:clamp", logInput.get("type").getAsString()); + assertTrue(logInput.get("min").getAsDouble() > 0.0); + Field positive = new Field.Clamp(NOISE, 0.5, 1.0); + assertEquals(emit(positive), emit(new Field.Unary(Field.UnaryOp.SQRT, positive)).getAsJsonObject().get("input")); + } + + @Test + public void powBaseIsClampedWhenExponentIsFractional() { + JsonObject fractional = emit(new Field.Binary(Field.BinaryOp.POW, NOISE, new Field.Const(1.5))).getAsJsonObject(); + assertEquals("minecraft:clamp", fractional.getAsJsonObject("base").get("type").getAsString()); + assertEquals(0.0, fractional.getAsJsonObject("base").get("min").getAsDouble(), 0.0); + JsonObject negative = emit(new Field.Binary(Field.BinaryOp.POW, NOISE, new Field.Const(-0.5))).getAsJsonObject(); + assertTrue(negative.getAsJsonObject("base").get("min").getAsDouble() > 0.0); + JsonObject integral = emit(new Field.Binary(Field.BinaryOp.POW, NOISE, new Field.Const(3.0))).getAsJsonObject(); + assertEquals(json(NOISE_JSON), integral.get("base")); + } + + @Test + public void remainingNodesUseTheirCodecFields() { + assertEquals(json("{\"type\":\"minecraft:clamp\",\"input\":" + NOISE_JSON + ",\"min\":-0.5,\"max\":0.5}"), emit(new Field.Clamp(NOISE, -0.5, 0.5))); + assertEquals(json("{\"type\":\"minecraft:lerp\",\"alpha\":" + NOISE_JSON + ",\"first\":1.0,\"second\":2.0}"), emit(new Field.Lerp(NOISE, new Field.Const(1.0), new Field.Const(2.0)))); + assertEquals(json("{\"type\":\"minecraft:floor\",\"input\":" + NOISE_JSON + ",\"multiple\":2.0}"), emit(new Field.Round(Field.RoundMode.FLOOR, NOISE, new Field.Const(2.0)))); + assertEquals(json("{\"type\":\"minecraft:range_choice\",\"input\":" + NOISE_JSON + ",\"min_inclusive\":0.0,\"max_exclusive\":1.0,\"when_in_range\":1.0,\"when_out_of_range\":0.0}"), + emit(new Field.RangeIf(NOISE, 0.0, 1.0, new Field.Const(1.0), new Field.Const(0.0)))); + assertEquals(json("{\"type\":\"minecraft:slice\",\"axis\":\"y\",\"coordinate\":64,\"input\":" + NOISE_3D_JSON + "}"), emit(new Field.Slice(NOISE_3D, Axis.Y, 64))); + assertEquals(json("{\"type\":\"minecraft:find_top_surface\",\"density\":" + NOISE_3D_JSON + ",\"upper_bound\":320.0,\"lower_bound\":-64,\"cell_height\":8}"), emit(new Field.TopSurface(NOISE_3D, 320, -64, 8))); + assertEquals(json("\"minecraft:overworld/continents\""), emit(new Field.Ref("minecraft:overworld/continents"))); + } + + @Test + public void tableNamespaceMustMatchEmitter() { + assertThrows(IllegalArgumentException.class, () -> emitter().emit(NOISE, new FunctionTable("other"))); + } + + @Test + public void emissionIsDeterministic() { + Field.Shared shared = new Field.Shared("terrain/height", new Field.Binary(Field.BinaryOp.MUL, NOISE, new Field.Const(16.0))); + Field root = new Field.Mux(new Field.Clamp(NOISE, 0.0, 1.0), List.of(shared, new Field.Gate(NOISE, new Field.Unary(Field.UnaryOp.SQRT, shared)))); + FunctionTable first = new FunctionTable(NAMESPACE); + FunctionTable second = new FunctionTable(NAMESPACE); + String firstRoot = emitter().emit(root, first).toString(); + String secondRoot = emitter().emit(root, second).toString(); + assertEquals(firstRoot, secondRoot); + assertEquals(first.files().toString(), second.files().toString()); + } + + private static JsonElement identity(String coordinate, String low, String high) { + return json("{\"coordinate\":" + coordinate + ",\"points\":[{\"location\":" + low + ",\"value\":" + low + ",\"derivative\":1.0},{\"location\":" + + high + ",\"value\":" + high + ",\"derivative\":1.0}]}"); + } + + private static float vanillaSpline(JsonObject spline, float coordinate) { + JsonArray points = spline.getAsJsonArray("points"); + int last = points.size() - 1; + int start = -1; + for (int index = 0; index <= last && coordinate >= location(points, index); index++) { + start = index; + } + if (start < 0) { + return value(points, 0) + derivative(points, 0) * (coordinate - location(points, 0)); + } + if (start == last) { + return value(points, last) + derivative(points, last) * (coordinate - location(points, last)); + } + float low = location(points, start); + float high = location(points, start + 1); + float delta = (coordinate - low) / (high - low); + float first = value(points, start); + float second = value(points, start + 1); + float entry = derivative(points, start) * (high - low) - (second - first); + float exit = -derivative(points, start + 1) * (high - low) + (second - first); + return first + delta * (second - first) + delta * (1.0f - delta) * (entry + delta * (exit - entry)); + } + + private static float location(JsonArray points, int index) { + return points.get(index).getAsJsonObject().get("location").getAsFloat(); + } + + private static float derivative(JsonArray points, int index) { + return points.get(index).getAsJsonObject().get("derivative").getAsFloat(); + } + + private static float value(JsonArray points, int index) { + return points.get(index).getAsJsonObject().get("value").getAsFloat(); + } + + private static String type(JsonElement element) { + return element.getAsJsonObject().get("type").getAsString(); + } + + private static DensityFunctionEmitter emitter() { + return DensityFunctionEmitter.forTarget(ExportCapabilities.forTarget(DataVersion.V26_3), NAMESPACE, VanillaBounds.of(NAMESPACE, List.of())); + } + + private static JsonElement emit(Field field) { + return emitter().emit(field, new FunctionTable(NAMESPACE)); + } + + private static JsonElement json(String text) { + return JsonParser.parseString(text); + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/emit/Format26_3WriterTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/emit/Format26_3WriterTest.java new file mode 100644 index 000000000..97352bc59 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/emit/Format26_3WriterTest.java @@ -0,0 +1,362 @@ +package art.arcane.iris.pack.datapack.export.emit; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ExportIrFixtures; +import art.arcane.iris.pack.datapack.export.WorldgenExportMode; +import art.arcane.iris.pack.datapack.export.ir.AquiferDef; +import art.arcane.iris.pack.datapack.export.ir.BinaryResource; +import art.arcane.iris.pack.datapack.export.ir.BiomeDef; +import art.arcane.iris.pack.datapack.export.ir.ClimateLayout; +import art.arcane.iris.pack.datapack.export.ir.DimensionDef; +import art.arcane.iris.pack.datapack.export.ir.ExportIr; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.JsonResource; +import art.arcane.iris.pack.datapack.export.ir.MaterialProgram; +import art.arcane.iris.pack.datapack.export.ir.RouterDef; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.Test; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.SortedMap; +import java.util.TreeSet; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class Format26_3WriterTest { + private static final String SETTINGS = "data/iris_export/worldgen/noise_settings/flat.json"; + private static final String PRESET = "data/iris_export/worldgen/world_preset/flat.json"; + private static final String NORMAL_TAG = "data/minecraft/tags/worldgen/world_preset/normal.json"; + private static final String DIMENSION = "data/iris_export/dimension/flat.json"; + private static final String OVERWORLD = "data/minecraft/dimension/overworld.json"; + private static final String DENSITY_PREFIX = "data/iris_export/worldgen/density_function/"; + private static final String FEATURE = "data/iris_export/worldgen/feature/ore.json"; + private static final String TEMPLATE = "data/iris_export/structure/ore.nbt"; + + @Test + public void writesTheIrisPackIcon() { + byte[] icon = write(fixture(WorldgenExportMode.OVERWORLD)).get("pack.png"); + + assertArrayEquals(new byte[]{(byte) 0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'}, Arrays.copyOf(icon, 8)); + assertEquals(128, ByteBuffer.wrap(icon, 16, 4).getInt()); + assertEquals(128, ByteBuffer.wrap(icon, 20, 4).getInt()); + } + + @Test + public void forTargetSelectsTheFormatWriter() { + assertTrue(WorldgenJsonWriter.forTarget(DataVersion.V26_3) instanceof Format26_3Writer); + assertThrows(IllegalArgumentException.class, () -> WorldgenJsonWriter.forTarget(DataVersion.V26_2)); + assertThrows(IllegalArgumentException.class, () -> WorldgenJsonWriter.forTarget(DataVersion.V26_1_2)); + } + + @Test + public void packMcmetaDeclaresFormatRange() { + JsonObject pack = json(write(fixture(WorldgenExportMode.PRESET)), "pack.mcmeta").getAsJsonObject("pack"); + assertEquals(121, pack.get("min_format").getAsInt()); + assertEquals(121, pack.get("max_format").getAsInt()); + assertFalse(pack.has("pack_format")); + assertTrue(pack.get("description").getAsString().length() > 0); + } + + @Test + public void packDescriptionTellsPlayersToKeepTheVanillaPack() { + String description = json(write(fixture(WorldgenExportMode.OVERWORLD)), "pack.mcmeta").getAsJsonObject("pack").get("description").getAsString(); + assertTrue(description, description.contains("Keep Default selected")); + } + + @Test + public void noiseFileUsesNormalNoiseParameters() { + JsonObject noise = json(write(fixture(WorldgenExportMode.PRESET)), "data/iris_export/worldgen/noise/flat/height.json"); + assertEquals(JsonParser.parseString("{\"amplitude_modifiers\":[1.0,1.0,1.0],\"base_amplitude\":1.0,\"base_octave\":-7,\"normalize\":false,\"octave_count\":3}"), noise); + } + + @Test + public void noiseSettingsRouterHasEightFields26_3() { + SortedMap files = write(fixture(WorldgenExportMode.PRESET)); + JsonObject settings = json(files, SETTINGS); + JsonObject router = settings.getAsJsonObject("noise_router"); + assertEquals(Set.of("temperature", "vegetation", "continents", "erosion", "depth", "ridges", "chunk_surface_level", "final_density"), router.keySet()); + assertEquals(Set.of("barrier", "fluid_level_floodedness", "fluid_level_spread", "lava", "exclusion", "surface_level"), settings.getAsJsonObject("aquifers").keySet()); + assertEquals(Set.of("aquifers", "default_block", "default_fluid", "disable_mob_generation", "legacy_random_source", "material_rule", "noise", + "noise_router", "sea_level", "spawn_target"), settings.keySet()); + assertEquals(JsonParser.parseString("{\"height\":384,\"min_y\":-64}"), settings.get("noise")); + assertEquals("iris_export:flat", settings.get("material_rule").getAsString()); + assertTrue(files.containsKey("data/iris_export/worldgen/material_rule/flat.json")); + assertEquals(ExportIrFixtures.FLUID_HEIGHT + 1, settings.get("sea_level").getAsInt()); + assertEquals(0.0, router.get("temperature").getAsDouble(), 0.0); + assertResolves(files, router.get("final_density")); + assertResolves(files, router.get("chunk_surface_level")); + assertResolves(files, settings.getAsJsonObject("aquifers").get("surface_level")); + } + + @Test + public void spawnTargetIsKeyedByRegisteredDensityFunctions() { + SortedMap files = write(fixture(WorldgenExportMode.PRESET)); + JsonArray targets = json(files, SETTINGS).getAsJsonArray("spawn_target"); + assertEquals(1, targets.size()); + for (Map.Entry entry : targets.get(0).getAsJsonObject().entrySet()) { + assertTrue(entry.getKey(), files.containsKey(DENSITY_PREFIX + entry.getKey().substring("iris_export:".length()) + ".json")); + JsonArray range = entry.getValue().getAsJsonArray(); + assertTrue(range.get(0).getAsDouble() >= -2.0 && range.get(1).getAsDouble() <= 2.0); + assertTrue(range.get(0).getAsDouble() < range.get(1).getAsDouble()); + } + } + + @Test + public void presetModeWritesWorldPresetAndNormalTag() { + SortedMap files = write(fixture(WorldgenExportMode.PRESET)); + JsonObject dimensions = json(files, PRESET).getAsJsonObject("dimensions"); + assertEquals(Set.of("minecraft:overworld", "minecraft:the_nether", "minecraft:the_end"), dimensions.keySet()); + JsonObject overworld = dimensions.getAsJsonObject("minecraft:overworld"); + assertEquals("iris_export:flat", overworld.get("type").getAsString()); + assertEquals("minecraft:noise", overworld.getAsJsonObject("generator").get("type").getAsString()); + assertEquals("iris_export:flat", overworld.getAsJsonObject("generator").get("settings").getAsString()); + assertEquals(JsonParser.parseString("{\"values\":[\"iris_export:flat\"]}"), json(files, NORMAL_TAG)); + assertFalse(files.containsKey(DIMENSION)); + assertFalse(files.containsKey(OVERWORLD)); + } + + @Test + public void dimensionModeWritesNamespacedDimension() { + SortedMap files = write(fixture(WorldgenExportMode.DIMENSION)); + JsonObject stem = json(files, DIMENSION); + assertEquals("iris_export:flat", stem.get("type").getAsString()); + assertFalse(files.containsKey(PRESET)); + assertFalse(files.containsKey(NORMAL_TAG)); + assertFalse(files.containsKey(OVERWORLD)); + } + + @Test + public void overworldModeOverridesMinecraftDimension() { + SortedMap files = write(fixture(WorldgenExportMode.OVERWORLD)); + JsonObject stem = json(files, OVERWORLD); + assertEquals("iris_export:flat", stem.get("type").getAsString()); + assertEquals("iris_export:flat", stem.getAsJsonObject("generator").get("settings").getAsString()); + assertFalse(files.containsKey(PRESET)); + assertFalse(files.containsKey(DIMENSION)); + } + + @Test + public void climateLayoutPutsCodesOnTemperature() { + ExportIr base = fixture(WorldgenExportMode.DIMENSION); + ClimateLayout climate = new ClimateLayout(List.of(new ClimateLayout.ClimateEntry("iris_export:plains", ClimateLayout.ClimateAxis.TEMPERATURE, 0.1, 0.2))); + ExportIr ir = new ExportIr(base.namespace(), base.target(), base.mode(), base.dimension(), base.noises(), base.router(), climate, base.biomes(), base.material(), base.diagnostics(), base.jsonResources(), base.binaryResources()); + JsonObject source = json(write(ir), DIMENSION).getAsJsonObject("generator").getAsJsonObject("biome_source"); + assertEquals("minecraft:multi_noise", source.get("type").getAsString()); + JsonArray biomes = source.getAsJsonArray("biomes"); + assertEquals(1, biomes.size()); + JsonObject entry = biomes.get(0).getAsJsonObject(); + assertEquals("iris_export:plains", entry.get("biome").getAsString()); + JsonObject parameters = entry.getAsJsonObject("parameters"); + assertEquals(JsonParser.parseString("[0.1,0.2]"), parameters.get("temperature")); + assertEquals("[0.1,0.2]", parameters.get("temperature").toString()); + for (String axis : List.of("humidity", "continentalness", "erosion", "depth", "weirdness", "offset")) { + assertEquals(axis, 0.0, parameters.get(axis).getAsDouble(), 0.0); + } + } + + @Test + public void blockStateSyntaxPerFamily() { + SortedMap files = write(fixture(WorldgenExportMode.PRESET)); + JsonObject settings = json(files, SETTINGS); + assertEquals(JsonParser.parseString("{\"id\":\"minecraft:stone\"}"), settings.get("default_block")); + assertEquals(JsonParser.parseString("{\"id\":\"minecraft:water\"}"), settings.get("default_fluid")); + assertEquals(JsonParser.parseString("{\"type\":\"minecraft:block\",\"result_state\":{\"id\":\"minecraft:stone\"}}"), + json(files, "data/iris_export/worldgen/material_rule/flat.json")); + ExportIr base = fixture(WorldgenExportMode.PRESET); + MaterialProgram legacy = new MaterialProgram(JsonParser.parseString("{\"type\":\"minecraft:block\",\"result_state\":{\"Name\":\"minecraft:stone\"}}")); + ExportIr wrong = new ExportIr(base.namespace(), base.target(), base.mode(), base.dimension(), base.noises(), base.router(), base.climate(), base.biomes(), legacy, base.diagnostics(), base.jsonResources(), base.binaryResources()); + assertThrows(IllegalArgumentException.class, () -> write(wrong)); + } + + @Test + public void dimensionTypeHeightsFollowNoiseSettings() { + ExportIr base = fixture(WorldgenExportMode.PRESET); + JsonObject type = json(write(base), "data/iris_export/dimension_type/flat.json"); + assertEquals(-64, type.get("min_y").getAsInt()); + assertEquals(384, type.get("height").getAsInt()); + assertEquals(384, type.get("logical_height").getAsInt()); + assertEquals(true, type.get("has_skylight").getAsBoolean()); + } + + @Test + public void unknownKeysAreRejected() { + ExportIr base = fixture(WorldgenExportMode.PRESET); + JsonObject biome = ExportIrFixtures.biome(); + biome.add("spawners", new JsonObject()); + ExportIr legacyBiome = new ExportIr(base.namespace(), base.target(), base.mode(), base.dimension(), base.noises(), base.router(), base.climate(), + List.of(new BiomeDef("plains", biome)), base.material(), base.diagnostics(), base.jsonResources(), base.binaryResources()); + IllegalArgumentException biomeFailure = assertThrows(IllegalArgumentException.class, () -> write(legacyBiome)); + assertTrue(biomeFailure.getMessage(), biomeFailure.getMessage().contains("spawners")); + JsonObject type = ExportIrFixtures.dimensionType(); + type.addProperty("bogus", 1); + ExportIr bogusType = new ExportIr(base.namespace(), base.target(), base.mode(), + new DimensionDef("flat", -64, 384, 384, 63, "minecraft:stone", "minecraft:water", type), + base.noises(), base.router(), base.climate(), base.biomes(), base.material(), base.diagnostics(), base.jsonResources(), base.binaryResources()); + assertThrows(IllegalArgumentException.class, () -> write(bogusType)); + } + + @Test + public void unregisteredNoiseIsRejected() { + ExportIr base = fixture(WorldgenExportMode.PRESET); + RouterDef router = base.router(); + Field missing = new Field.Noise("iris_export:missing", 1.0, 0.0, null, null, null); + RouterDef broken = new RouterDef(missing, router.vegetation(), router.continents(), router.erosion(), router.depth(), router.ridges(), + router.chunkSurfaceLevel(), router.finalDensity(), router.aquifer()); + ExportIr ir = new ExportIr(base.namespace(), base.target(), base.mode(), base.dimension(), base.noises(), broken, base.climate(), base.biomes(), base.material(), base.diagnostics(), base.jsonResources(), base.binaryResources()); + IllegalStateException failure = assertThrows(IllegalStateException.class, () -> write(ir)); + assertTrue(failure.getMessage(), failure.getMessage().contains("iris_export:missing")); + } + + @Test + public void noFileExceedsNestingLimit() { + ExportIr base = fixture(WorldgenExportMode.PRESET); + Field chain = new Field.Noise("iris_export:flat/height", 1.0, 0.0, null, null, null); + for (int index = 0; index < 700; index++) { + chain = new Field.Binary(Field.BinaryOp.ADD, chain, new Field.Const(index * 0.001)); + } + RouterDef router = base.router(); + RouterDef deep = new RouterDef(router.temperature(), router.vegetation(), router.continents(), router.erosion(), router.depth(), router.ridges(), + router.chunkSurfaceLevel(), chain, AquiferDef.dry(router.aquifer().surfaceLevel())); + ExportIr ir = new ExportIr(base.namespace(), base.target(), base.mode(), base.dimension(), base.noises(), deep, base.climate(), base.biomes(), base.material(), base.diagnostics(), base.jsonResources(), base.binaryResources()); + SortedMap files = write(ir); + for (String path : files.keySet()) { + if (!isJson(path)) { + continue; + } + JsonElement parsed = JsonParser.parseString(new String(files.get(path), StandardCharsets.UTF_8)); + assertTrue(path, NestingSplitter.depth(parsed) <= 255); + if (path.startsWith(DENSITY_PREFIX)) { + assertReferencesResolve(files, parsed); + } + } + assertResolves(files, json(files, SETTINGS).getAsJsonObject("noise_router").get("final_density")); + } + + @Test + public void finalDensityAlwaysHasItsNamedFile() { + ExportIr base = fixture(WorldgenExportMode.PRESET); + RouterDef router = base.router(); + Field shared = new Field.Shared("flat/terrain", router.finalDensity()); + List routers = List.of( + new RouterDef(router.temperature(), router.vegetation(), router.continents(), router.erosion(), router.depth(), router.ridges(), + router.chunkSurfaceLevel(), new Field.Const(1.0), router.aquifer()), + new RouterDef(router.finalDensity(), router.vegetation(), router.continents(), router.erosion(), router.depth(), router.ridges(), + router.chunkSurfaceLevel(), router.finalDensity(), router.aquifer()), + new RouterDef(router.temperature(), router.vegetation(), router.continents(), router.erosion(), router.depth(), router.ridges(), + router.chunkSurfaceLevel(), shared, router.aquifer())); + for (RouterDef variant : routers) { + ExportIr ir = new ExportIr(base.namespace(), base.target(), base.mode(), base.dimension(), base.noises(), variant, base.climate(), base.biomes(), + base.material(), base.diagnostics(), base.jsonResources(), base.binaryResources()); + SortedMap files = write(ir); + assertEquals("iris_export:flat/final_density", json(files, SETTINGS).getAsJsonObject("noise_router").get("final_density").getAsString()); + assertTrue(files.keySet().toString(), files.containsKey(DENSITY_PREFIX + "flat/final_density.json")); + } + } + + @Test + public void outputIsSortedAndDeterministic() { + SortedMap first = write(fixture(WorldgenExportMode.PRESET)); + SortedMap second = write(fixture(WorldgenExportMode.PRESET)); + assertEquals(first.keySet(), second.keySet()); + for (String path : first.keySet()) { + assertArrayEquals(path, first.get(path), second.get(path)); + if (!isJson(path)) { + continue; + } + String text = new String(first.get(path), StandardCharsets.UTF_8); + assertTrue(path, text.endsWith("\n")); + assertFalse(path, text.contains("\r")); + } + String settings = new String(first.get(SETTINGS), StandardCharsets.UTF_8); + List order = new ArrayList<>(); + for (String key : new TreeSet<>(json(first, SETTINGS).keySet())) { + order.add(settings.indexOf("\"" + key + "\"")); + } + List sorted = new ArrayList<>(order); + sorted.sort(Integer::compare); + assertEquals(sorted, order); + } + + private static void assertResolves(SortedMap files, JsonElement reference) { + assertTrue(reference.toString(), reference.isJsonPrimitive() && reference.getAsJsonPrimitive().isString()); + assertTrue(reference.getAsString(), files.containsKey(DENSITY_PREFIX + reference.getAsString().substring("iris_export:".length()) + ".json")); + } + + private static void assertReferencesResolve(SortedMap files, JsonElement element) { + if (element.isJsonPrimitive()) { + String value = element.getAsJsonPrimitive().isString() ? element.getAsString() : ""; + if (value.startsWith("iris_export:") && !value.equals("iris_export:flat/height")) { + assertResolves(files, element); + } + return; + } + if (element.isJsonObject()) { + for (Map.Entry member : element.getAsJsonObject().entrySet()) { + assertReferencesResolve(files, member.getValue()); + } + } + if (element.isJsonArray()) { + for (JsonElement item : element.getAsJsonArray()) { + assertReferencesResolve(files, item); + } + } + } + + @Test + public void resourcesAreCopiedAtTheirPaths() { + JsonObject feature = JsonParser.parseString("{\"type\":\"minecraft:ore\",\"config\":{\"size\":4,\"discard_chance_on_air_exposure\":0.0}}").getAsJsonObject(); + byte[] template = {10, 0, 0, 3, 1, 2}; + ExportIr ir = ExportIrFixtures.withResources(fixture(WorldgenExportMode.PRESET), + List.of(new JsonResource(FEATURE, feature)), List.of(new BinaryResource(TEMPLATE, template))); + SortedMap files = write(ir); + assertEquals(feature, json(files, FEATURE)); + String text = new String(files.get(FEATURE), StandardCharsets.UTF_8); + assertTrue(text.endsWith("\n")); + assertTrue(text.indexOf("\"config\"") < text.indexOf("\"type\"")); + assertTrue(text.indexOf("\"discard_chance_on_air_exposure\"") < text.indexOf("\"size\"")); + assertArrayEquals(template, files.get(TEMPLATE)); + assertArrayEquals(write(ir).get(FEATURE), files.get(FEATURE)); + } + + @Test + public void resourcesCollidingWithGeneratedFilesAreRejected() { + ExportIr base = fixture(WorldgenExportMode.PRESET); + ExportIr jsonCollision = ExportIrFixtures.withResources(base, List.of(new JsonResource(SETTINGS, new JsonObject())), List.of()); + IllegalArgumentException jsonFailure = assertThrows(IllegalArgumentException.class, () -> write(jsonCollision)); + assertTrue(jsonFailure.getMessage(), jsonFailure.getMessage().contains(SETTINGS)); + ExportIr binaryCollision = ExportIrFixtures.withResources(base, List.of(), List.of(new BinaryResource("pack.mcmeta", new byte[] {1}))); + IllegalArgumentException binaryFailure = assertThrows(IllegalArgumentException.class, () -> write(binaryCollision)); + assertTrue(binaryFailure.getMessage(), binaryFailure.getMessage().contains("pack.mcmeta")); + } + + private static ExportIr fixture(WorldgenExportMode mode) { + return ExportIrFixtures.flatNoise(DataVersion.V26_3, mode); + } + + private static SortedMap write(ExportIr ir) { + return new Format26_3Writer().write(ir); + } + + private static JsonObject json(SortedMap files, String path) { + byte[] bytes = files.get(path); + assertTrue(path, bytes != null); + return JsonParser.parseString(new String(bytes, StandardCharsets.UTF_8)).getAsJsonObject(); + } + + private static boolean isJson(String path) { + return path.endsWith(".json") || path.endsWith(".mcmeta"); + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/emit/FunctionTableTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/emit/FunctionTableTest.java new file mode 100644 index 000000000..f6c3e84c4 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/emit/FunctionTableTest.java @@ -0,0 +1,85 @@ +package art.arcane.iris.pack.datapack.export.emit; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +public class FunctionTableTest { + @Test + public void registerReturnsNamespacedReference() { + FunctionTable table = new FunctionTable("iris_export"); + assertEquals("iris_export:terrain/height", table.register("terrain/height", json("{\"type\":\"minecraft:abs\",\"input\":1.0}"))); + assertEquals(json("{\"type\":\"minecraft:abs\",\"input\":1.0}"), table.files().get("terrain/height")); + assertEquals("iris_export", table.namespace()); + } + + @Test + public void identicalBodiesShareOneFile() { + FunctionTable table = new FunctionTable("iris_export"); + String first = table.register("first", json("{\"type\":\"minecraft:add\",\"left\":1.0,\"right\":{\"type\":\"minecraft:abs\",\"input\":2.0}}")); + String second = table.register("second", json("{\"right\":{\"input\":2.0,\"type\":\"minecraft:abs\"},\"left\":1.0,\"type\":\"minecraft:add\"}")); + assertEquals("iris_export:first", first); + assertEquals(first, second); + assertEquals(1, table.files().size()); + } + + @Test + public void reRegisteringSameIdAndBodyIsIdempotent() { + FunctionTable table = new FunctionTable("iris_export"); + table.register("height", json("{\"type\":\"minecraft:abs\",\"input\":1.0}")); + assertEquals("iris_export:height", table.register("height", json("{\"type\":\"minecraft:abs\",\"input\":1.0}"))); + assertEquals(1, table.files().size()); + } + + @Test + public void conflictingBodyForIdIsRejected() { + FunctionTable table = new FunctionTable("iris_export"); + table.register("height", json("{\"type\":\"minecraft:abs\",\"input\":1.0}")); + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> table.register("height", json("{\"type\":\"minecraft:abs\",\"input\":2.0}"))); + assertEquals("Density function 'iris_export:height' is already registered with a different body", error.getMessage()); + } + + @Test + public void filesAreSortedById() { + FunctionTable table = new FunctionTable("iris_export"); + table.register("zeta", json("1.0")); + table.register("alpha", json("2.0")); + table.register("mid/beta", json("3.0")); + assertEquals(List.of("alpha", "mid/beta", "zeta"), new ArrayList<>(table.files().keySet())); + } + + @Test + public void registeredBodyIsCopied() { + FunctionTable table = new FunctionTable("iris_export"); + JsonObject body = json("{\"type\":\"minecraft:abs\",\"input\":1.0}").getAsJsonObject(); + table.register("height", body); + body.addProperty("input", 5.0); + assertEquals(json("{\"type\":\"minecraft:abs\",\"input\":1.0}"), table.files().get("height")); + assertEquals("iris_export:height", table.register("other", json("{\"type\":\"minecraft:abs\",\"input\":1.0}"))); + } + + @Test + public void filesViewIsReadOnly() { + FunctionTable table = new FunctionTable("iris_export"); + assertThrows(UnsupportedOperationException.class, () -> table.files().put("height", json("1.0"))); + } + + @Test + public void invalidIdentifiersAreRejected() { + assertThrows(IllegalArgumentException.class, () -> new FunctionTable("Iris")); + FunctionTable table = new FunctionTable("iris_export"); + assertThrows(IllegalArgumentException.class, () -> table.register("Height", json("1.0"))); + assertThrows(IllegalArgumentException.class, () -> table.register("iris:height", json("1.0"))); + } + + private static JsonElement json(String text) { + return JsonParser.parseString(text); + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/emit/NestingSplitterTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/emit/NestingSplitterTest.java new file mode 100644 index 000000000..bef4f58bc --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/emit/NestingSplitterTest.java @@ -0,0 +1,123 @@ +package art.arcane.iris.pack.datapack.export.emit; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.WorldgenExportTargets; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.lower.ExportCapabilities; +import art.arcane.iris.pack.datapack.export.lower.VanillaBounds; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class NestingSplitterTest { + private static final String NAMESPACE = "iris_export"; + private static final int LIMIT = 255; + + @Test + public void deepChainSplitsUnderLimit() { + for (DataVersion target : WorldgenExportTargets.SUPPORTED) { + FunctionTable table = new FunctionTable(NAMESPACE); + Field chain = new Field.Noise("iris_export:base", 0.25, 0.0, null, null, null); + for (int index = 0; index < 500; index++) { + chain = new Field.Binary(Field.BinaryOp.ADD, chain, new Field.Const(index)); + } + DensityFunctionEmitter emitter = DensityFunctionEmitter.forTarget(ExportCapabilities.forTarget(target), NAMESPACE, VanillaBounds.of(NAMESPACE, List.of())); + table.register("deep/root", emitter.emit(chain, table)); + assertTrue(NestingSplitter.depth(table.files().get("deep/root")) > LIMIT); + NestingSplitter.split(table, LIMIT); + assertTrue(table.files().size() > 1); + for (Map.Entry file : table.files().entrySet()) { + assertTrue(file.getKey() + " nests too deep", NestingSplitter.depth(file.getValue()) <= LIMIT); + assertReferencesResolve(table, file.getValue()); + } + assertEquals(501, countNodes(table, table.files().get("deep/root"))); + } + } + + @Test + public void shallowFilesAreUntouched() { + FunctionTable table = new FunctionTable(NAMESPACE); + JsonElement body = JsonParser.parseString("{\"type\":\"minecraft:abs\",\"input\":{\"type\":\"minecraft:abs\",\"input\":1.0}}"); + table.register("shallow", body); + NestingSplitter.split(table, LIMIT); + assertEquals(1, table.files().size()); + assertEquals(body, table.files().get("shallow")); + } + + @Test + public void depthCountsObjectsAndArrays() { + assertEquals(0, NestingSplitter.depth(JsonParser.parseString("1.0"))); + assertEquals(1, NestingSplitter.depth(JsonParser.parseString("{\"a\":1}"))); + assertEquals(3, NestingSplitter.depth(JsonParser.parseString("{\"a\":[{\"b\":1}]}"))); + } + + @Test + public void unsplittableNestingFails() { + FunctionTable table = new FunctionTable(NAMESPACE); + JsonElement nested = JsonParser.parseString("1.0"); + for (int index = 0; index < 10; index++) { + JsonArray array = new JsonArray(); + array.add(nested); + nested = array; + } + JsonObject root = new JsonObject(); + root.addProperty("type", "minecraft:spline"); + root.add("spline", nested); + table.register("unsplittable", root); + assertThrows(IllegalStateException.class, () -> NestingSplitter.split(table, 4)); + } + + private static int countNodes(FunctionTable table, JsonElement element) { + if (element.isJsonPrimitive()) { + String value = element.getAsJsonPrimitive().isString() ? element.getAsString() : null; + if (value != null && value.startsWith(NAMESPACE + ":")) { + return countNodes(table, table.files().get(value.substring(NAMESPACE.length() + 1))); + } + return 0; + } + int count = 0; + if (element.isJsonObject()) { + JsonObject object = element.getAsJsonObject(); + if (object.has("type") && "minecraft:add".equals(object.get("type").getAsString())) { + count++; + } + if (object.has("type") && "minecraft:noise".equals(object.get("type").getAsString())) { + count++; + } + for (Map.Entry member : object.entrySet()) { + if (!member.getKey().equals("type") && !member.getKey().equals("noise")) { + count += countNodes(table, member.getValue()); + } + } + } + return count; + } + + private static void assertReferencesResolve(FunctionTable table, JsonElement element) { + if (element.isJsonPrimitive()) { + if (element.getAsJsonPrimitive().isString() && element.getAsString().startsWith(NAMESPACE + ":") && !element.getAsString().equals("iris_export:base")) { + assertTrue(element.getAsString(), table.files().containsKey(element.getAsString().substring(NAMESPACE.length() + 1))); + } + return; + } + if (element.isJsonObject()) { + for (Map.Entry member : element.getAsJsonObject().entrySet()) { + assertReferencesResolve(table, member.getValue()); + } + } + if (element.isJsonArray()) { + for (JsonElement item : element.getAsJsonArray()) { + assertReferencesResolve(table, item); + } + } + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/emit/UnusedResourcePrunerTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/emit/UnusedResourcePrunerTest.java new file mode 100644 index 000000000..cc69b6a1a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/emit/UnusedResourcePrunerTest.java @@ -0,0 +1,510 @@ +package art.arcane.iris.pack.datapack.export.emit; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.WorldgenExportTargets; +import art.arcane.iris.pack.datapack.export.ContentTrim; +import art.arcane.iris.pack.datapack.export.ExportIrFixtures; +import art.arcane.iris.pack.datapack.export.WorldgenExportMode; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import art.arcane.volmlib.util.nbt.io.NBTUtil; +import art.arcane.volmlib.util.nbt.io.NamedTag; +import art.arcane.volmlib.util.nbt.tag.CompoundTag; +import art.arcane.volmlib.util.nbt.tag.ListTag; +import org.junit.Test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.SortedMap; +import java.util.TreeMap; +import java.util.zip.GZIPOutputStream; + +import static org.junit.Assert.assertArrayEquals; +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 UnusedResourcePrunerTest { + private static final String EXPORT = "iris_export"; + private static final String TOWNS = "towns"; + private static final String PACK = "pack.mcmeta"; + private static final String DIMENSION = "data/iris_export/dimension/main.json"; + private static final String DIMENSION_TYPE = "data/iris_export/dimension_type/main.json"; + private static final String SETTINGS = "data/iris_export/worldgen/noise_settings/main.json"; + private static final String FINAL_DENSITY = "data/iris_export/worldgen/density_function/main/final_density.json"; + private static final String HEIGHT_NOISE = "data/iris_export/worldgen/noise/main/height.json"; + private static final String MATERIAL = "data/iris_export/worldgen/material_rule/main.json"; + private static final String FOREST = "data/iris_export/worldgen/biome/forest.json"; + private static final String DISABLED_TAG = "data/iris_export/tags/worldgen/biome/structure/disabled.json"; + private static final String HOUSES = "data/towns/worldgen/structure_set/houses.json"; + private static final String HOUSE = "data/towns/worldgen/structure/house.json"; + private static final String HAS_HOUSE = "data/towns/tags/worldgen/biome/has_house.json"; + private static final String HOUSE_START = "data/towns/worldgen/template_pool/house/start.json"; + private static final String HOUSE_ROOMS = "data/towns/worldgen/template_pool/house/rooms.json"; + private static final String MOSSY = "data/towns/worldgen/processor_list/mossy.json"; + private static final String REPLACEABLE = "data/towns/tags/block/house_replaceable.json"; + private static final String HOUSE_BASE = "data/towns/structure/house/base.nbt"; + private static final String HOUSE_ROOM = "data/towns/structure/house/room.nbt"; + private static final String HOUSE_LOOT = "data/towns/loot_table/chests/house.json"; + private static final String SHARED_LOOT = "data/towns/loot_table/chests/shared.json"; + + @Test + public void unusedTemplateIsDropped() { + SortedMap files = world("iris_export:object/oak"); + object(files, EXPORT, "object/oak", "feature"); + object(files, EXPORT, "object/hut", "feature"); + + UnusedResourcePruner.Pruned pruned = UnusedResourcePruner.prune(files); + + assertKept(pruned, placed(EXPORT, "object/oak"), registry(EXPORT, "feature", "object/oak"), template(EXPORT, "object/oak")); + assertDropped(pruned, placed(EXPORT, "object/hut"), registry(EXPORT, "feature", "object/hut"), template(EXPORT, "object/hut")); + } + + @Test + public void templateReferencedFromPoolIsKept() { + SortedMap files = world(); + houses(files); + files.put(template(TOWNS, "house/unused"), nbt()); + + UnusedResourcePruner.Pruned pruned = UnusedResourcePruner.prune(files); + + assertKept(pruned, HOUSES, HOUSE, HAS_HOUSE, HOUSE_START, HOUSE_ROOMS, MOSSY, HOUSE_BASE, HOUSE_ROOM); + assertDropped(pruned, template(TOWNS, "house/unused")); + } + + @Test + public void lootTableReferencedFromTemplateNbtIsKept() { + SortedMap files = world(); + houses(files); + files.put("data/towns/loot_table/chests/unused.json", json("{\"pools\":[]}")); + + UnusedResourcePruner.Pruned pruned = UnusedResourcePruner.prune(files); + + assertKept(pruned, HOUSE_LOOT, SHARED_LOOT); + assertDropped(pruned, "data/towns/loot_table/chests/unused.json"); + } + + @Test + public void packIconIsKeptWithoutReachingItsBytes() { + SortedMap files = world(); + files.put("data/iris_export/worldgen/noise/main/unused.json", json("{\"firstOctave\":-7,\"amplitudes\":[1.0]}")); + files.put("pack.png", "PNG iris_export:main/unused".getBytes(StandardCharsets.ISO_8859_1)); + + UnusedResourcePruner.Pruned pruned = UnusedResourcePruner.prune(files); + + assertKept(pruned, "pack.png"); + assertDropped(pruned, "data/iris_export/worldgen/noise/main/unused.json"); + } + + @Test + public void unusedNoiseAndDensityFunctionAreDropped() { + SortedMap files = world(); + files.put("data/iris_export/worldgen/density_function/main/unused.json", + json("{\"type\":\"minecraft:noise\",\"noise\":\"iris_export:main/unused\",\"xz_scale\":1.0,\"y_scale\":1.0}")); + files.put("data/iris_export/worldgen/noise/main/unused.json", json("{\"firstOctave\":-7,\"amplitudes\":[1.0]}")); + + UnusedResourcePruner.Pruned pruned = UnusedResourcePruner.prune(files); + + assertKept(pruned, SETTINGS, FINAL_DENSITY, HEIGHT_NOISE, MATERIAL); + assertDropped(pruned, "data/iris_export/worldgen/density_function/main/unused.json", "data/iris_export/worldgen/noise/main/unused.json"); + } + + @Test + public void biomeNotInClimateLayoutIsDropped() { + SortedMap files = world("iris_export:object/oak"); + object(files, EXPORT, "object/oak", "feature"); + files.put("data/iris_export/worldgen/biome/desert.json", biome(List.of("iris_export:canyon"), "iris_export:object/cactus")); + files.put(registry(EXPORT, "carver", "canyon"), json("{\"type\":\"minecraft:canyon\"}")); + object(files, EXPORT, "object/cactus", "feature"); + + UnusedResourcePruner.Pruned pruned = UnusedResourcePruner.prune(files); + + assertKept(pruned, FOREST, template(EXPORT, "object/oak")); + assertDropped(pruned, "data/iris_export/worldgen/biome/desert.json", registry(EXPORT, "carver", "canyon"), + placed(EXPORT, "object/cactus"), registry(EXPORT, "feature", "object/cactus"), template(EXPORT, "object/cactus")); + } + + @Test + public void tagReferencedByHashIsKept() { + SortedMap files = world(); + houses(files); + files.put(HAS_HOUSE, json("{\"values\":[\"#towns:forests\"]}")); + files.put("data/towns/tags/worldgen/biome/forests.json", json("{\"values\":[\"iris_export:forest\"]}")); + files.put("data/towns/tags/worldgen/biome/unused.json", json("{\"values\":[\"iris_export:forest\"]}")); + + UnusedResourcePruner.Pruned pruned = UnusedResourcePruner.prune(files); + + assertKept(pruned, HAS_HOUSE, "data/towns/tags/worldgen/biome/forests.json", REPLACEABLE); + assertDropped(pruned, "data/towns/tags/worldgen/biome/unused.json"); + } + + @Test + public void nonWorldgenDatapackContentIsKept() { + SortedMap files = world(); + houses(files); + List gameplay = List.of( + "data/towns/function/setup.mcfunction", + "data/minecraft/tags/function/load.json", + "data/towns/advancement/root.json", + "data/towns/recipe/brick.json", + "data/towns/tags/item/bricks.json"); + files.put(gameplay.get(0), "loot give @s loot towns:rewards/start\nplace template towns:house/statue ~ ~ ~\n".getBytes(StandardCharsets.UTF_8)); + files.put(gameplay.get(1), json("{\"values\":[\"towns:setup\"]}")); + files.put(gameplay.get(2), json("{\"criteria\":{\"tick\":{\"trigger\":\"minecraft:tick\"}},\"rewards\":{\"loot\":[\"towns:rewards/advancement\"]}}")); + files.put(gameplay.get(3), json("{\"type\":\"minecraft:crafting_shapeless\",\"ingredients\":[\"minecraft:clay_ball\"],\"result\":{\"id\":\"minecraft:brick\"}}")); + files.put(gameplay.get(4), json("{\"values\":[\"minecraft:brick\"]}")); + files.put("data/towns/loot_table/rewards/start.json", json("{\"pools\":[]}")); + files.put("data/towns/loot_table/rewards/advancement.json", json("{\"pools\":[]}")); + files.put("data/towns/loot_table/rewards/unused.json", json("{\"pools\":[]}")); + files.put(template(TOWNS, "house/statue"), nbt()); + + UnusedResourcePruner.Pruned pruned = UnusedResourcePruner.prune(files); + + assertKept(pruned, gameplay.toArray(String[]::new)); + assertKept(pruned, "data/towns/loot_table/rewards/start.json", "data/towns/loot_table/rewards/advancement.json", template(TOWNS, "house/statue")); + assertDropped(pruned, "data/towns/loot_table/rewards/unused.json"); + } + + @Test + public void disabledDatapackStructureChainIsDropped() { + SortedMap files = world(); + houses(files); + towers(files); + + UnusedResourcePruner.Pruned pruned = UnusedResourcePruner.prune(files); + + assertDropped(pruned, "data/towns/worldgen/structure_set/towers.json", "data/towns/worldgen/structure/tower.json", DISABLED_TAG, + "data/towns/worldgen/template_pool/tower/start.json", "data/towns/worldgen/template_pool/tower/top.json", + template(TOWNS, "tower/base"), template(TOWNS, "tower/top")); + assertKept(pruned, HOUSES, HOUSE, HOUSE_START, MOSSY, HOUSE_BASE); + } + + @Test + public void structureSetWithAnEnabledStructureIsKept() { + SortedMap files = world(); + houses(files); + towers(files); + files.put("data/towns/worldgen/structure_set/towers.json", json(""" + {"structures":[{"structure":"towns:tower","weight":1},{"structure":"towns:ruin","weight":1}], + "placement":{"type":"minecraft:random_spread","salt":2,"spacing":32,"separation":8}} + """)); + files.put("data/towns/worldgen/structure/ruin.json", json("{\"type\":\"minecraft:jigsaw\",\"biomes\":[\"iris_export:forest\"],\"start_pool\":\"minecraft:empty\"}")); + files.put("data/towns/worldgen/structure_set/nowhere.json", json(""" + {"structures":[{"structure":"towns:nowhere","weight":1}], + "placement":{"type":"minecraft:random_spread","salt":3,"spacing":32,"separation":8}} + """)); + files.put("data/towns/worldgen/structure/nowhere.json", json("{\"type\":\"minecraft:jigsaw\",\"biomes\":[],\"start_pool\":\"minecraft:empty\"}")); + + UnusedResourcePruner.Pruned pruned = UnusedResourcePruner.prune(files); + + assertKept(pruned, "data/towns/worldgen/structure_set/towers.json", "data/towns/worldgen/structure/tower.json", + "data/towns/worldgen/structure/ruin.json", DISABLED_TAG, template(TOWNS, "tower/top")); + assertDropped(pruned, "data/towns/worldgen/structure_set/nowhere.json", "data/towns/worldgen/structure/nowhere.json"); + } + + @Test + public void vanillaNamespaceContentIsKept() { + SortedMap files = world(); + List vanilla = List.of( + "data/minecraft/worldgen/structure/village_plains.json", + "data/minecraft/worldgen/template_pool/village/plains/houses.json", + "data/minecraft/loot_table/chests/village/village_weaponsmith.json", + "data/minecraft/tags/worldgen/biome/is_forest.json", + "data/minecraft/structure/village/plains/houses/plains_small_house_1.nbt"); + files.put(vanilla.get(0), json("{\"type\":\"minecraft:jigsaw\",\"biomes\":\"#iris_export:structure/disabled\",\"start_pool\":\"minecraft:village/plains/town_centers\"}")); + files.put(vanilla.get(1), json("{\"fallback\":\"minecraft:empty\",\"elements\":[]}")); + files.put(vanilla.get(2), json("{\"pools\":[]}")); + files.put(vanilla.get(3), json("{\"replace\":false,\"values\":[\"iris_export:forest\"]}")); + files.put(vanilla.get(4), nbt()); + files.put(DISABLED_TAG, json("{\"replace\":false,\"values\":[]}")); + + UnusedResourcePruner.Pruned pruned = UnusedResourcePruner.prune(files); + + assertKept(pruned, vanilla.toArray(String[]::new)); + assertKept(pruned, DISABLED_TAG); + } + + @Test + public void tagsOfTagOnlyNamespacesAreKept() { + SortedMap files = world(); + houses(files); + files.put("data/c/tags/worldgen/biome/is_forest.json", json("{\"values\":[\"iris_export:forest\"]}")); + files.put("data/c/tags/block/ores.json", json("{\"values\":[\"minecraft:iron_ore\"]}")); + files.put("data/towns/tags/worldgen/biome/unused.json", json("{\"values\":[\"iris_export:forest\"]}")); + + UnusedResourcePruner.Pruned pruned = UnusedResourcePruner.prune(files); + + assertKept(pruned, "data/c/tags/worldgen/biome/is_forest.json", "data/c/tags/block/ores.json"); + assertDropped(pruned, "data/towns/tags/worldgen/biome/unused.json"); + } + + @Test + public void legacyFeatureAndCarverFoldersAreTrimmed() { + SortedMap files = world(); + files.put(FOREST, biome(List.of("iris_export:cave"), "iris_export:object/oak")); + files.put(registry(EXPORT, "configured_carver", "cave"), json("{\"type\":\"minecraft:cave\",\"config\":{}}")); + files.put(registry(EXPORT, "configured_carver", "canyon"), json("{\"type\":\"minecraft:canyon\",\"config\":{}}")); + object(files, EXPORT, "object/oak", "configured_feature"); + object(files, EXPORT, "object/hut", "configured_feature"); + + UnusedResourcePruner.Pruned pruned = UnusedResourcePruner.prune(files); + + assertKept(pruned, registry(EXPORT, "configured_carver", "cave"), registry(EXPORT, "configured_feature", "object/oak"), template(EXPORT, "object/oak")); + assertDropped(pruned, registry(EXPORT, "configured_carver", "canyon"), registry(EXPORT, "configured_feature", "object/hut"), template(EXPORT, "object/hut")); + } + + @Test + public void unreadableFilesAreScannedAsText() { + SortedMap files = world(); + houses(files); + files.put(HOUSE_ROOM, "corrupt template towns:chests/house".getBytes(StandardCharsets.ISO_8859_1)); + files.put(HOUSE_ROOMS, "{\"elements\":[\"towns:house/room\"".getBytes(StandardCharsets.UTF_8)); + + UnusedResourcePruner.Pruned pruned = UnusedResourcePruner.prune(files); + + assertKept(pruned, HOUSE_ROOMS, HOUSE_ROOM, HOUSE_LOOT, SHARED_LOOT); + List unreadable = new ArrayList<>(); + for (ExportDiagnostic diagnostic : pruned.diagnostics()) { + if (diagnostic.severity() == ExportDiagnostic.Severity.WARNING) { + assertSame(ExportLimit.TRIMMED_CONTENT, diagnostic.limit()); + unreadable.add(diagnostic.resource()); + } + } + assertEquals(List.of(HOUSE_ROOM, HOUSE_ROOMS), unreadable); + } + + @Test + public void trimReportsFileAndByteCounts() { + SortedMap files = world(); + byte[] orphan = nbt(); + files.put(template(EXPORT, "object/orphan"), orphan); + long bytes = 0L; + for (byte[] content : files.values()) { + bytes += content.length; + } + + UnusedResourcePruner.Pruned pruned = UnusedResourcePruner.prune(files); + + assertEquals(new ContentTrim(files.size(), bytes, files.size() - 1, bytes - orphan.length), pruned.trim()); + List summaries = new ArrayList<>(); + for (ExportDiagnostic diagnostic : pruned.diagnostics()) { + if (diagnostic.severity() == ExportDiagnostic.Severity.INFO) { + summaries.add(diagnostic); + } + } + assertEquals(1, summaries.size()); + assertSame(ExportLimit.TRIMMED_CONTENT, summaries.getFirst().limit()); + String message = summaries.getFirst().message(); + assertTrue(message, message.contains("1 of " + files.size() + " files")); + assertTrue(message, message.contains(orphan.length + " of " + bytes + " bytes")); + } + + @Test + public void prunerIsIdempotent() { + SortedMap files = world("iris_export:object/oak"); + object(files, EXPORT, "object/oak", "feature"); + object(files, EXPORT, "object/hut", "feature"); + houses(files); + towers(files); + files.put("data/c/tags/worldgen/biome/is_forest.json", json("{\"values\":[\"iris_export:forest\"]}")); + files.put("data/towns/tags/worldgen/biome/unused.json", json("{\"values\":[\"iris_export:forest\"]}")); + files.put("data/towns/function/setup.mcfunction", "loot give @s loot towns:chests/shared\n".getBytes(StandardCharsets.UTF_8)); + + UnusedResourcePruner.Pruned first = UnusedResourcePruner.prune(files); + UnusedResourcePruner.Pruned second = UnusedResourcePruner.prune(first.files()); + + assertTrue(first.trim().filesRemoved() > 0); + assertEquals(first.files().keySet(), second.files().keySet()); + for (Map.Entry file : first.files().entrySet()) { + assertArrayEquals(file.getKey(), file.getValue(), second.files().get(file.getKey())); + } + assertEquals(0, second.trim().filesRemoved()); + assertEquals(0L, second.trim().bytesRemoved()); + } + + @Test + public void routerAndPresetAlwaysKept() { + for (DataVersion target : WorldgenExportTargets.SUPPORTED) { + for (WorldgenExportMode mode : WorldgenExportMode.values()) { + SortedMap files = WorldgenJsonWriter.forTarget(target).write(ExportIrFixtures.flatNoise(target, mode)); + files.put("data/iris_export/worldgen/density_function/flat/orphan.json", json("{\"type\":\"minecraft:constant\",\"argument\":1.0}")); + + UnusedResourcePruner.Pruned pruned = UnusedResourcePruner.prune(files); + + String label = target.getVersion() + " " + mode; + assertEquals(label, 1, pruned.trim().filesRemoved()); + assertDropped(pruned, "data/iris_export/worldgen/density_function/flat/orphan.json"); + assertKept(pruned, PACK, "data/iris_export/worldgen/noise_settings/flat.json", "data/iris_export/worldgen/density_function/flat/final_density.json", + "data/iris_export/worldgen/noise/flat/height.json", "data/iris_export/worldgen/biome/plains.json", "data/iris_export/dimension_type/flat.json"); + switch (mode) { + case PRESET -> assertKept(pruned, "data/iris_export/worldgen/world_preset/flat.json", "data/minecraft/tags/worldgen/world_preset/normal.json"); + case DIMENSION -> assertKept(pruned, "data/iris_export/dimension/flat.json"); + case OVERWORLD -> assertKept(pruned, "data/minecraft/dimension/overworld.json"); + } + } + } + } + + private static SortedMap world(String... features) { + SortedMap files = new TreeMap<>(); + files.put(PACK, json("{\"pack\":{\"description\":\"test\",\"min_format\":121,\"max_format\":121}}")); + files.put(DIMENSION, json(""" + {"type":"iris_export:main","generator":{"type":"minecraft:noise","settings":"iris_export:main", + "biome_source":{"type":"minecraft:multi_noise","biomes":[{"biome":"iris_export:forest","parameters":{"temperature":[-2.0,2.0]}}]}}} + """)); + files.put(DIMENSION_TYPE, json("{\"infiniburn\":\"#minecraft:infiniburn_overworld\",\"min_y\":-64,\"height\":384}")); + files.put(SETTINGS, json("{\"material_rule\":\"iris_export:main\",\"noise_router\":{\"final_density\":\"iris_export:main/final_density\"}}")); + files.put(FINAL_DENSITY, json("{\"type\":\"minecraft:noise\",\"noise\":\"iris_export:main/height\",\"xz_scale\":1.0,\"y_scale\":1.0}")); + files.put(HEIGHT_NOISE, json("{\"firstOctave\":-7,\"amplitudes\":[1.0]}")); + files.put(MATERIAL, json("{\"type\":\"minecraft:block\",\"result_state\":\"minecraft:stone\"}")); + files.put(FOREST, biome(List.of(), features)); + return files; + } + + private static byte[] biome(List carvers, String... features) { + StringBuilder steps = new StringBuilder("[[],[],[],[],[],[],[],[],[],["); + for (int index = 0; index < features.length; index++) { + steps.append(index == 0 ? "" : ",").append('"').append(features[index]).append('"'); + } + steps.append("],[]]"); + StringBuilder carverList = new StringBuilder("["); + for (int index = 0; index < carvers.size(); index++) { + carverList.append(index == 0 ? "" : ",").append('"').append(carvers.get(index)).append('"'); + } + carverList.append(']'); + return json("{\"has_precipitation\":true,\"temperature\":0.8,\"downfall\":0.4,\"effects\":{\"water_color\":\"#3f76e4\"},\"carvers\":" + + carverList + ",\"features\":" + steps + "}"); + } + + private static void object(SortedMap files, String namespace, String path, String featureFolder) { + String id = namespace + ":" + path; + files.put(placed(namespace, path), json("{\"feature\":\"" + id + "\",\"placement\":[{\"type\":\"minecraft:count\",\"count\":1}]}")); + files.put(registry(namespace, featureFolder, path), json("{\"type\":\"minecraft:template\",\"templates\":[{\"data\":{\"id\":\"" + id + "\"},\"weight\":1}]}")); + files.put(template(namespace, path), nbt()); + } + + private static void houses(SortedMap files) { + files.put(HOUSES, json(""" + {"structures":[{"structure":"towns:house","weight":1}], + "placement":{"type":"minecraft:random_spread","salt":1,"spacing":24,"separation":8}} + """)); + files.put(HOUSE, json(""" + {"type":"minecraft:jigsaw","biomes":"#towns:has_house","step":"surface_structures","spawn_overrides":{}, + "start_pool":"towns:house/start","size":3,"start_height":{"absolute":0},"max_distance_from_center":64,"use_expansion_hack":false} + """)); + files.put(HAS_HOUSE, json("{\"values\":[\"iris_export:forest\"]}")); + files.put(HOUSE_START, pool("towns:house/base", "towns:mossy")); + files.put(MOSSY, json(""" + {"processors":[{"processor_type":"minecraft:rule","rules":[{ + "input_predicate":{"predicate_type":"minecraft:tag_match","tag":"towns:house_replaceable"}, + "location_predicate":{"predicate_type":"minecraft:always_true"},"output_state":{"Name":"minecraft:mossy_cobblestone"}}]}]} + """)); + files.put(REPLACEABLE, json("{\"values\":[\"minecraft:cobblestone\"]}")); + files.put(HOUSE_BASE, nbt(jigsaw("towns:house/rooms"))); + files.put(HOUSE_ROOMS, pool("towns:house/room", "minecraft:empty")); + files.put(HOUSE_ROOM, nbt(chest("towns:chests/house"))); + files.put(HOUSE_LOOT, json("{\"pools\":[{\"rolls\":1,\"entries\":[{\"type\":\"minecraft:loot_table\",\"value\":\"towns:chests/shared\"}]}]}")); + files.put(SHARED_LOOT, json("{\"pools\":[{\"rolls\":1,\"entries\":[{\"type\":\"minecraft:item\",\"name\":\"minecraft:bread\"}]}]}")); + } + + private static void towers(SortedMap files) { + files.put("data/towns/worldgen/structure_set/towers.json", json(""" + {"structures":[{"structure":"towns:tower","weight":1}], + "placement":{"type":"minecraft:random_spread","salt":2,"spacing":32,"separation":8}} + """)); + files.put("data/towns/worldgen/structure/tower.json", json(""" + {"type":"minecraft:jigsaw","biomes":"#iris_export:structure/disabled","step":"surface_structures","spawn_overrides":{}, + "start_pool":"towns:tower/start","size":2,"start_height":{"absolute":0},"max_distance_from_center":64,"use_expansion_hack":false} + """)); + files.put(DISABLED_TAG, json("{\"replace\":false,\"values\":[]}")); + files.put("data/towns/worldgen/template_pool/tower/start.json", pool("towns:tower/base", "towns:mossy")); + files.put(template(TOWNS, "tower/base"), nbt(jigsaw("towns:tower/top"))); + files.put("data/towns/worldgen/template_pool/tower/top.json", pool("towns:tower/top", "minecraft:empty")); + files.put(template(TOWNS, "tower/top"), nbt()); + } + + private static byte[] pool(String location, String processors) { + return json("{\"fallback\":\"minecraft:empty\",\"elements\":[{\"weight\":1,\"element\":{\"element_type\":\"minecraft:single_pool_element\"," + + "\"location\":\"" + location + "\",\"processors\":\"" + processors + "\",\"projection\":\"rigid\"}}]}"); + } + + private static CompoundTag jigsaw(String pool) { + CompoundTag entity = new CompoundTag(); + entity.putString("pool", pool); + entity.putString("target", "towns:door"); + entity.putString("name", "towns:entrance"); + entity.putString("final_state", "minecraft:air"); + entity.putString("joint", "rollable"); + return entity; + } + + private static CompoundTag chest(String lootTable) { + CompoundTag entity = new CompoundTag(); + entity.putString("id", "minecraft:chest"); + entity.putString("LootTable", lootTable); + return entity; + } + + private static byte[] nbt(CompoundTag... blockEntities) { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (GZIPOutputStream gzip = new GZIPOutputStream(output)) { + NBTUtil.write(new NamedTag("", template(blockEntities)), gzip, false); + } catch (IOException exception) { + throw new UncheckedIOException(exception); + } + return output.toByteArray(); + } + + private static CompoundTag template(CompoundTag... blockEntities) { + CompoundTag root = new CompoundTag(); + root.putInt("DataVersion", 5023); + ListTag palette = new ListTag<>(CompoundTag.class); + CompoundTag state = new CompoundTag(); + state.putString("Name", "minecraft:stone"); + palette.add(state); + root.put("palette", palette); + ListTag blocks = new ListTag<>(CompoundTag.class); + for (CompoundTag blockEntity : blockEntities) { + CompoundTag block = new CompoundTag(); + block.putInt("state", 0); + block.put("nbt", blockEntity); + blocks.add(block); + } + root.put("blocks", blocks); + return root; + } + + private static byte[] json(String text) { + return text.getBytes(StandardCharsets.UTF_8); + } + + private static String placed(String namespace, String path) { + return registry(namespace, "placed_feature", path); + } + + private static String registry(String namespace, String folder, String path) { + return "data/" + namespace + "/worldgen/" + folder + "/" + path + ".json"; + } + + private static String template(String namespace, String path) { + return "data/" + namespace + "/structure/" + path + ".nbt"; + } + + private static void assertKept(UnusedResourcePruner.Pruned pruned, String... paths) { + for (String path : paths) { + assertTrue(path + " was dropped", pruned.files().containsKey(path)); + } + } + + private static void assertDropped(UnusedResourcePruner.Pruned pruned, String... paths) { + for (String path : paths) { + assertFalse(path + " was kept", pruned.files().containsKey(path)); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/ir/DimensionDefTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/ir/DimensionDefTest.java new file mode 100644 index 000000000..471aaac9c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/ir/DimensionDefTest.java @@ -0,0 +1,113 @@ +package art.arcane.iris.pack.datapack.export.ir; + +import com.google.gson.JsonObject; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class DimensionDefTest { + @Test + public void unalignedMinYIsAlignedDown() { + List diagnostics = new ArrayList<>(); + DimensionDef dimension = aligned(-60, 384, 384, diagnostics); + assertEquals(-64, dimension.minY()); + assertEquals(400, dimension.height()); + assertEquals(400, dimension.logicalHeight()); + assertEquals(1, diagnostics.size()); + ExportDiagnostic diagnostic = diagnostics.getFirst(); + assertSame(ExportLimit.DIMENSION_HEIGHT, diagnostic.limit()); + assertSame(ExportDiagnostic.Severity.WARNING, diagnostic.severity()); + assertEquals("overworld", diagnostic.resource()); + } + + @Test + public void alignedDimensionHasNoDiagnostic() { + List diagnostics = new ArrayList<>(); + DimensionDef dimension = aligned(-64, 384, 256, diagnostics); + assertEquals(-64, dimension.minY()); + assertEquals(384, dimension.height()); + assertEquals(256, dimension.logicalHeight()); + assertTrue(diagnostics.isEmpty()); + } + + @Test + public void unalignedHeightRoundsUp() { + List diagnostics = new ArrayList<>(); + DimensionDef dimension = aligned(0, 250, 128, diagnostics); + assertEquals(0, dimension.minY()); + assertEquals(256, dimension.height()); + assertEquals(128, dimension.logicalHeight()); + assertEquals(1, diagnostics.size()); + } + + @Test + public void heightIsClampedTo4064() { + List diagnostics = new ArrayList<>(); + DimensionDef dimension = aligned(-2032, 5000, 5000, diagnostics); + assertEquals(-2032, dimension.minY()); + assertEquals(4064, dimension.height()); + assertEquals(4064, dimension.logicalHeight()); + assertEquals(1, diagnostics.size()); + } + + @Test + public void topIsClampedTo2032() { + List diagnostics = new ArrayList<>(); + DimensionDef dimension = aligned(0, 3000, 3000, diagnostics); + assertEquals(0, dimension.minY()); + assertEquals(2032, dimension.height()); + assertTrue(dimension.minY() + dimension.height() <= 2032); + assertEquals(1, diagnostics.size()); + } + + @Test + public void minYBelowLimitIsRaised() { + List diagnostics = new ArrayList<>(); + DimensionDef dimension = aligned(-2100, 512, 512, diagnostics); + assertEquals(-2032, dimension.minY()); + assertEquals(448, dimension.height()); + assertEquals(1, diagnostics.size()); + } + + @Test + public void logicalHeightIsCappedAtHeight() { + List diagnostics = new ArrayList<>(); + DimensionDef dimension = aligned(-64, 384, 1000, diagnostics); + assertEquals(384, dimension.logicalHeight()); + assertEquals(1, diagnostics.size()); + } + + @Test + public void canonicalConstructorRejectsUnrepresentableHeights() { + assertThrows(IllegalArgumentException.class, () -> create(-60, 384, 384)); + assertThrows(IllegalArgumentException.class, () -> create(-64, 380, 380)); + assertThrows(IllegalArgumentException.class, () -> create(0, 4080, 4080)); + assertThrows(IllegalArgumentException.class, () -> create(1024, 1024, 1024)); + assertThrows(IllegalArgumentException.class, () -> create(-64, 384, 400)); + } + + @Test + public void dimensionTypeIsCopied() { + JsonObject type = new JsonObject(); + type.addProperty("has_skylight", true); + DimensionDef dimension = new DimensionDef("overworld", -64, 384, 384, 63, "minecraft:stone", "minecraft:water", type); + type.addProperty("has_ceiling", false); + assertNotSame(type, dimension.dimensionType()); + assertEquals(1, dimension.dimensionType().size()); + } + + private static DimensionDef aligned(int minY, int height, int logicalHeight, List diagnostics) { + return DimensionDef.aligned("overworld", minY, height, logicalHeight, 63, "minecraft:stone", "minecraft:water", new JsonObject(), diagnostics); + } + + private static DimensionDef create(int minY, int height, int logicalHeight) { + return new DimensionDef("overworld", minY, height, logicalHeight, 63, "minecraft:stone", "minecraft:water", new JsonObject()); + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/ir/FieldEvaluatorTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/ir/FieldEvaluatorTest.java new file mode 100644 index 000000000..fd328a678 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/ir/FieldEvaluatorTest.java @@ -0,0 +1,372 @@ +package art.arcane.iris.pack.datapack.export.ir; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.compile.noise.VanillaNoiseModel; +import org.junit.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public final class FieldEvaluatorTest { + private static final double EPSILON = 1.0e-6; + private static final long SEED = 31_337L; + private static final String NOISE_ID = "iris_export:evaluator/base"; + private static final NoiseDef NOISE = new NoiseDef("evaluator/base", -3, List.of(1.0, 0.5)); + private static final Map NOISES = Map.of(NOISE_ID, NOISE); + private static final Field X = new Field.Coord(Axis.X, Field.Tiling.CLAMP, -4064, 4064, -4064, 4064); + private static final Field Y = new Field.Coord(Axis.Y, Field.Tiling.CLAMP, -4064, 4064, -4064, 4064); + private static final Field Z = new Field.Coord(Axis.Z, Field.Tiling.CLAMP, -4064, 4064, -4064, 4064); + + @Test + public void constReturnsItsValue() { + assertEquals(2.5, sample(new Field.Const(2.5), 0, 0, 0), 0.0); + } + + @Test + public void evaluatesInSinglePrecision() { + Field tenth = new Field.Const(0.1); + assertEquals((double) 0.1F, sample(tenth, 0, 0, 0), 0.0); + } + + @Test + public void unsupportedTargetThrows() { + assertThrows(IllegalArgumentException.class, + () -> FieldEvaluator.sample(new Field.Const(1.0), NOISES, SEED, DataVersion.V26_1_2, 0, 0, 0)); + assertThrows(IllegalArgumentException.class, + () -> FieldEvaluator.sample(new Field.Const(1.0), NOISES, SEED, DataVersion.V26_2, 0, 0, 0)); + } + + @Test + public void coordClampTilingClampsOutsideRange() { + Field gradient = new Field.Coord(Axis.X, Field.Tiling.CLAMP, 0, 64, 0, 1); + assertEquals(0.0, sample(gradient, -5, 0, 0), 0.0); + assertEquals(0.5, sample(gradient, 32, 0, 0), 0.0); + assertEquals(1.0, sample(gradient, 100, 0, 0), 0.0); + } + + @Test + public void coordRepeatTilingWrapsWithFloorMod() { + Field gradient = new Field.Coord(Axis.Z, Field.Tiling.REPEAT, 0, 64, 0, 64); + assertEquals(63.0, sample(gradient, 0, 0, -1), 0.0); + assertEquals(2.0, sample(gradient, 0, 0, 130), 0.0); + } + + @Test + public void coordMirroredRepeatTilingReflectsOddPeriods() { + Field gradient = new Field.Coord(Axis.X, Field.Tiling.MIRRORED_REPEAT, 0, 64, 0, 64); + assertEquals(10.0, sample(gradient, 10, 0, 0), 0.0); + assertEquals(58.0, sample(gradient, 70, 0, 0), 0.0); + assertEquals(1.0, sample(gradient, -1, 0, 0), 0.0); + } + + @Test + public void coordFollowsItsAxis() { + assertEquals(-12.0, sample(Y, 3, -12, 7), 0.0); + assertEquals(7.0, sample(Z, 3, -12, 7), 0.0); + } + + @Test + public void coordWithFractionalEndpointThrows() { + Field gradient = new Field.Coord(Axis.X, Field.Tiling.CLAMP, 0.5, 64, 0, 1); + assertThrows(IllegalArgumentException.class, () -> sample(gradient, 0, 0, 0)); + } + + @Test + public void noiseSamplesTheVanillaModelAtScaledCoordinates() { + Field noise = new Field.Noise(NOISE_ID, 0.25, 0.5, null, null, null); + VanillaNoiseModel model = VanillaNoiseModel.create(NOISE_ID, NOISE.firstOctave(), NOISE.amplitudes(), SEED, DataVersion.V26_3); + double expected = model.sample(13 * 0.25, -7 * 0.5, 91 * 0.25); + assertEquals((float) expected, sample(noise, 13, -7, 91), 0.0); + } + + @Test + public void noiseShiftsAddAfterScaling() { + Field noise = new Field.Noise(NOISE_ID, 0.25, 0.0, new Field.Const(10.0), new Field.Const(3.0), X); + VanillaNoiseModel model = VanillaNoiseModel.create(NOISE_ID, NOISE.firstOctave(), NOISE.amplitudes(), SEED, DataVersion.V26_3); + double expected = model.sample(5 * 0.25 + 10.0, 3.0, 9 * 0.25 + 5.0); + assertEquals((float) expected, sample(noise, 5, 40, 9), 0.0); + } + + @Test + public void undefinedNoiseThrows() { + Field noise = new Field.Noise("iris_export:missing", 1.0, 0.0, null, null, null); + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, () -> sample(noise, 0, 0, 0)); + assertTrue(failure.getMessage().contains("iris_export:missing")); + } + + @Test + public void binaryOperatorsFollowVanilla() { + Field three = new Field.Const(3.0); + Field two = new Field.Const(2.0); + assertEquals(5.0, sample(binary(Field.BinaryOp.ADD, three, two), 0, 0, 0), 0.0); + assertEquals(1.0, sample(binary(Field.BinaryOp.SUB, three, two), 0, 0, 0), 0.0); + assertEquals(6.0, sample(binary(Field.BinaryOp.MUL, three, two), 0, 0, 0), 0.0); + assertEquals(1.5, sample(binary(Field.BinaryOp.DIV, three, two), 0, 0, 0), 0.0); + assertEquals(2.0, sample(binary(Field.BinaryOp.MIN, three, two), 0, 0, 0), 0.0); + assertEquals(3.0, sample(binary(Field.BinaryOp.MAX, three, two), 0, 0, 0), 0.0); + assertEquals(9.0, sample(binary(Field.BinaryOp.POW, three, two), 0, 0, 0), 0.0); + } + + @Test + public void divisionWithZeroNumeratorIsZeroWithoutEvaluatingTheDenominator() { + Field division = binary(Field.BinaryOp.DIV, new Field.Const(0.0), missingNoise()); + assertEquals(0.0, sample(division, 0, 0, 0), 0.0); + assertEquals(Double.POSITIVE_INFINITY, sample(binary(Field.BinaryOp.DIV, new Field.Const(1.0), new Field.Const(0.0)), 0, 0, 0), 0.0); + } + + @Test + public void unaryOperatorsFollowVanilla() { + assertEquals(-2.0, unary(Field.UnaryOp.NEG, 2.0), 0.0); + assertEquals(2.0, unary(Field.UnaryOp.ABS, -2.0), 0.0); + assertEquals(4.0, unary(Field.UnaryOp.SQUARE, -2.0), 0.0); + assertEquals(-8.0, unary(Field.UnaryOp.CUBE, -2.0), 0.0); + assertEquals(3.0, unary(Field.UnaryOp.SQRT, 9.0), 0.0); + assertEquals((float) Math.log(5.0), unary(Field.UnaryOp.LOG, 5.0), 0.0); + assertEquals(-1.0, unary(Field.UnaryOp.SIGN, -0.25), 0.0); + assertEquals(0.0, unary(Field.UnaryOp.SIGN, 0.0), 0.0); + assertEquals(-1.0, unary(Field.UnaryOp.HALF_NEG, -2.0), 0.0); + assertEquals(2.0, unary(Field.UnaryOp.HALF_NEG, 2.0), 0.0); + assertEquals(-0.5, unary(Field.UnaryOp.QUARTER_NEG, -2.0), 0.0); + assertEquals(2.0, unary(Field.UnaryOp.QUARTER_NEG, 2.0), 0.0); + } + + @Test + public void squeezeClampsThenAppliesTheCubicDefinition() { + double inside = 0.5; + assertEquals(inside / 2.0 - inside * inside * inside / 24.0, unary(Field.UnaryOp.SQUEEZE, inside), 1.0e-6); + assertEquals(0.5 - 1.0 / 24.0, unary(Field.UnaryOp.SQUEEZE, 4.0), 1.0e-6); + assertEquals(-0.5 + 1.0 / 24.0, unary(Field.UnaryOp.SQUEEZE, -4.0), 1.0e-6); + } + + @Test + public void clampBoundsTheInput() { + Field clamp = new Field.Clamp(X, -2.0, 3.0); + assertEquals(-2.0, sample(clamp, -10, 0, 0), 0.0); + assertEquals(1.0, sample(clamp, 1, 0, 0), 0.0); + assertEquals(3.0, sample(clamp, 10, 0, 0), 0.0); + } + + @Test + public void lerpBlendsAndSkipsTheUnusedEndpoint() { + assertEquals(2.5, sample(new Field.Lerp(new Field.Const(0.25), new Field.Const(2.0), new Field.Const(4.0)), 0, 0, 0), 0.0); + assertEquals(2.0, sample(new Field.Lerp(new Field.Const(0.0), new Field.Const(2.0), missingNoise()), 0, 0, 0), 0.0); + assertEquals(4.0, sample(new Field.Lerp(new Field.Const(1.0), missingNoise(), new Field.Const(4.0)), 0, 0, 0), 0.0); + } + + @Test + public void roundModesQuantiseToTheMultiple() { + Field four = new Field.Const(4.0); + assertEquals(12.0, sample(new Field.Round(Field.RoundMode.ROUND, new Field.Const(10.0), four), 0, 0, 0), 0.0); + assertEquals(8.0, sample(new Field.Round(Field.RoundMode.FLOOR, new Field.Const(10.0), four), 0, 0, 0), 0.0); + assertEquals(12.0, sample(new Field.Round(Field.RoundMode.CEIL, new Field.Const(9.0), four), 0, 0, 0), 0.0); + assertEquals(-8.0, sample(new Field.Round(Field.RoundMode.TRUNCATE, new Field.Const(-10.0), four), 0, 0, 0), 0.0); + assertEquals(8.0, sample(new Field.Round(Field.RoundMode.TRUNCATE, new Field.Const(10.0), four), 0, 0, 0), 0.0); + } + + @Test + public void roundHalvesGoUpLikeMathRound() { + Field one = new Field.Const(1.0); + assertEquals(3.0, sample(new Field.Round(Field.RoundMode.ROUND, new Field.Const(2.5), one), 0, 0, 0), 0.0); + assertEquals(-2.0, sample(new Field.Round(Field.RoundMode.ROUND, new Field.Const(-2.5), one), 0, 0, 0), 0.0); + } + + @Test + public void roundWithZeroMultipleReturnsTheInput() { + assertEquals(2.75, sample(new Field.Round(Field.RoundMode.FLOOR, new Field.Const(2.75), new Field.Const(0.0)), 0, 0, 0), 0.0); + } + + @Test + public void curveIsCubicHermiteBetweenKnots() { + Field curve = new Field.Curve(X, List.of(knot(0.0, 0.0, 2.0), knot(2.0, 1.0, 0.0))); + float t = 0.5F; + float hermite = lerp(t, 0.0F, 1.0F) + t * (1.0F - t) * lerp(t, 2.0F * 2.0F - 1.0F, 1.0F); + assertEquals(hermite, sample(curve, 1, 0, 0), 0.0); + assertEquals(0.0, sample(curve, 0, 0, 0), 0.0); + assertEquals(1.0, sample(curve, 2, 0, 0), 0.0); + } + + @Test + public void curveExtendsLinearlyFromTheEndDerivatives() { + Field curve = new Field.Curve(X, List.of(knot(0.0, 0.0, 2.0), knot(2.0, 1.0, 0.0))); + assertEquals(-6.0, sample(curve, -3, 0, 0), 0.0); + assertEquals(1.0, sample(curve, 9, 0, 0), 0.0); + } + + @Test + public void curveWithOneKnotIsLinear() { + Field curve = new Field.Curve(X, List.of(knot(1.0, 5.0, 0.5))); + assertEquals(6.0, sample(curve, 3, 0, 0), 0.0); + assertEquals(4.0, sample(curve, -1, 0, 0), 0.0); + } + + @Test + public void curveEvaluatesInSinglePrecision() { + Field curve = new Field.Curve(X, List.of(knot(0.0, 0.0, 0.0), knot(3.0, 0.1, 0.0))); + double value = sample(curve, 3, 0, 0); + assertEquals((double) 0.1F, value, 0.0); + } + + @Test + public void nestedCurveKnotsEvaluateOverTheirOwnInput() { + Field inner = new Field.Curve(Z, List.of(knot(0.0, 0.0, 1.0), knot(10.0, 10.0, 1.0))); + Field outer = new Field.Curve(X, List.of( + new Field.Knot(0.0, new Field.Const(0.0), 0.0), + new Field.Knot(1.0, inner, 0.0))); + assertEquals(4.0, sample(outer, 1, 0, 4), 0.0); + assertEquals(4.0, sample(outer, 7, 0, 4), 0.0); + } + + @Test + public void partitionSelectsLikeIntervalSelect() { + Field partition = new Field.Partition(X, List.of(0.0, 1.0), List.of(new Field.Const(10.0), new Field.Const(20.0), new Field.Const(30.0))); + assertEquals(10.0, sample(partition, -1, 0, 0), 0.0); + assertEquals(20.0, sample(partition, 0, 0, 0), 0.0); + assertEquals(30.0, sample(partition, 1, 0, 0), 0.0); + assertEquals(30.0, sample(partition, 5, 0, 0), 0.0); + } + + @Test + public void rangeIfIncludesMinimumAndExcludesMaximum() { + Field choice = new Field.RangeIf(X, 0.0, 2.0, new Field.Const(1.0), new Field.Const(-1.0)); + assertEquals(-1.0, sample(choice, -1, 0, 0), 0.0); + assertEquals(1.0, sample(choice, 0, 0, 0), 0.0); + assertEquals(1.0, sample(choice, 1, 0, 0), 0.0); + assertEquals(-1.0, sample(choice, 2, 0, 0), 0.0); + } + + @Test + public void rangeIfEvaluatesOnlyTheChosenBranch() { + Field choice = new Field.RangeIf(X, 0.0, 2.0, new Field.Const(1.0), missingNoise()); + assertEquals(1.0, sample(choice, 1, 0, 0), 0.0); + } + + @Test + public void muxSelectsTheCaseAtTheNearestIndex() { + Field mux = new Field.Mux(X, List.of(new Field.Const(10.0), new Field.Const(20.0), missingNoise())); + assertEquals(10.0, sample(mux, 0, 0, 0), 0.0); + assertEquals(20.0, sample(mux, 1, 0, 0), 0.0); + assertEquals(10.0, sample(mux, -3, 0, 0), 0.0); + } + + @Test + public void gatePassesTheExpressionOnlyForPositiveConditions() { + Field gate = new Field.Gate(X, new Field.Const(7.0)); + assertEquals(7.0, sample(gate, 1, 0, 0), 0.0); + assertEquals(0.0, sample(gate, 0, 0, 0), 0.0); + assertEquals(0.0, sample(new Field.Gate(new Field.Const(0.0), missingNoise()), 0, 0, 0), 0.0); + } + + @Test + public void gridInterpolatesBetweenAbsoluteLatticeCorners() { + Field squared = new Field.Unary(Field.UnaryOp.SQUARE, X); + Field grid = new Field.Grid(squared, 4, 8); + assertEquals(8.0, sample(grid, 2, 0, 0), 0.0); + assertEquals(16.0, sample(grid, 4, 0, 0), 0.0); + assertEquals(8.0, sample(grid, -2, 0, 0), 0.0); + assertEquals(16.0 + 0.25 * (64.0 - 16.0), sample(grid, 5, 3, 0), 0.0); + } + + @Test + public void gridInterpolatesAlongY() { + Field grid = new Field.Grid(new Field.Unary(Field.UnaryOp.SQUARE, Y), 4, 8); + assertEquals(32.0, sample(grid, 1, 4, 1), 0.0); + assertEquals(64.0, sample(grid, 1, -8, 1), 0.0); + } + + @Test + public void gridMatchesTrilinearInterpolationOfNoise() { + Field noise = new Field.Noise(NOISE_ID, 0.5, 0.25, null, null, null); + Field grid = new Field.Grid(noise, 4, 8); + double expected = trilinear(noise, 5, 13, -6, 4, 8); + assertEquals(expected, sample(grid, 5, 13, -6), 1.0e-6); + } + + @Test + public void sharedIsTransparent() { + Field noise = new Field.Noise(NOISE_ID, 0.5, 0.0, null, null, null); + Field shared = new Field.Shared("evaluator/shared", noise); + Field doubled = binary(Field.BinaryOp.ADD, shared, shared); + assertEquals(sample(noise, 3, 0, 9), sample(shared, 3, 0, 9), 0.0); + assertEquals((float) (2.0 * sample(noise, 3, 0, 9)), sample(doubled, 3, 0, 9), 0.0); + } + + @Test + public void slicePinsTheAxis() { + Field slice = new Field.Slice(binary(Field.BinaryOp.ADD, X, Y), Axis.Y, 10); + assertEquals(13.0, sample(slice, 3, 50, 0), 0.0); + } + + @Test + public void topSurfaceScansDownFromTheUpperBound() { + Field density = binary(Field.BinaryOp.SUB, new Field.Const(64.0), Y); + assertEquals(60.0, sample(new Field.TopSurface(density, 100, 0, 4), 0, 0, 0), 0.0); + assertEquals(70.0, sample(new Field.TopSurface(new Field.Const(-1.0), 103, 70, 4), 0, 0, 0), 0.0); + assertEquals(30.0, sample(new Field.TopSurface(density, 30, 30, 4), 0, 0, 0), 0.0); + } + + @Test + public void refIsUnsupported() { + assertThrows(IllegalArgumentException.class, () -> sample(new Field.Ref("minecraft:overworld/continents"), 0, 0, 0)); + } + + @Test + public void differentSeedsSampleDifferentNoise() { + Field noise = new Field.Noise(NOISE_ID, 0.37, 0.0, null, null, null); + double first = FieldEvaluator.sample(noise, NOISES, SEED, DataVersion.V26_3, 11, 0, 17); + double second = FieldEvaluator.sample(noise, NOISES, SEED + 1L, DataVersion.V26_3, 11, 0, 17); + assertNotEquals(first, second, EPSILON); + } + + private static double trilinear(Field field, int x, int y, int z, int cellXz, int cellY) { + int baseX = Math.floorDiv(x, cellXz) * cellXz; + int baseY = Math.floorDiv(y, cellY) * cellY; + int baseZ = Math.floorDiv(z, cellXz) * cellXz; + double fx = (x - baseX) / (double) cellXz; + double fy = (y - baseY) / (double) cellY; + double fz = (z - baseZ) / (double) cellXz; + double[] corners = new double[8]; + for (int index = 0; index < 8; index++) { + int dx = index & 1; + int dy = index >> 1 & 1; + int dz = index >> 2 & 1; + corners[index] = sample(field, baseX + dx * cellXz, baseY + dy * cellY, baseZ + dz * cellXz); + } + double bottom = lerpDouble(fy, lerpDouble(fx, corners[0], corners[1]), lerpDouble(fx, corners[2], corners[3])); + double top = lerpDouble(fy, lerpDouble(fx, corners[4], corners[5]), lerpDouble(fx, corners[6], corners[7])); + return lerpDouble(fz, bottom, top); + } + + private static double lerpDouble(double delta, double start, double end) { + return start + delta * (end - start); + } + + private static float lerp(float delta, float start, float end) { + return start + delta * (end - start); + } + + private static Field.Knot knot(double location, double value, double derivative) { + return new Field.Knot(location, new Field.Const(value), derivative); + } + + private static Field binary(Field.BinaryOp op, Field left, Field right) { + return new Field.Binary(op, left, right); + } + + private static double unary(Field.UnaryOp op, double input) { + return sample(new Field.Unary(op, new Field.Const(input)), 0, 0, 0); + } + + private static Field missingNoise() { + return new Field.Noise("iris_export:missing", 1.0, 0.0, null, null, null); + } + + private static double sample(Field field, int x, int y, int z) { + return FieldEvaluator.sample(field, NOISES, SEED, DataVersion.V26_3, x, y, z); + } + +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/ir/FieldTypingTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/ir/FieldTypingTest.java new file mode 100644 index 000000000..6d2c45a83 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/ir/FieldTypingTest.java @@ -0,0 +1,287 @@ +package art.arcane.iris.pack.datapack.export.ir; + +import org.junit.Test; + +import java.util.EnumSet; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class FieldTypingTest { + private static final double EPSILON = 1.0e-9; + + @Test + public void noiseWithZeroYScaleIsTwoDimensional() { + Field noise = noise(0.25, 0.0); + assertEquals(EnumSet.of(Axis.X, Axis.Z), noise.axes()); + assertSame(CostClass.NOISE, noise.cost()); + } + + @Test + public void noiseWithYScaleIsThreeDimensional() { + assertEquals(EnumSet.allOf(Axis.class), noise(0.25, 0.5).axes()); + } + + @Test + public void noiseAxesIncludeShiftAxes() { + Field shiftY = coord(Axis.Y); + Field noise = new Field.Noise("iris_export:base", 0.25, 0.0, null, shiftY, null); + assertEquals(EnumSet.allOf(Axis.class), noise.axes()); + } + + @Test + public void latticeNoiseTakesAxesOnlyFromShifts() { + Field noise = new Field.Noise("iris_export:cell_hash", 0.0, 0.0, coord(Axis.X), null, null); + assertEquals(EnumSet.of(Axis.X), noise.axes()); + } + + @Test + public void noiseShiftedByNoiseIsExpensive() { + Field warped = new Field.Noise("iris_export:base", 0.25, 0.0, noise(0.1, 0.0), null, null); + assertSame(CostClass.EXPENSIVE, warped.cost()); + } + + @Test + public void binaryUnionsAxes() { + Field sum = new Field.Binary(Field.BinaryOp.ADD, coord(Axis.X), coord(Axis.Y)); + assertEquals(EnumSet.of(Axis.X, Axis.Y), sum.axes()); + assertSame(CostClass.CHEAP, sum.cost()); + } + + @Test + public void constIsCheapAndAxisFree() { + Field constant = new Field.Const(3.0); + assertTrue(constant.axes().isEmpty()); + assertSame(CostClass.CHEAP, constant.cost()); + assertEquals(new ValueRange(3.0, 3.0), constant.range()); + } + + @Test + public void refIsCheapAndConservative() { + Field ref = new Field.Ref("minecraft:overworld/continents"); + assertSame(CostClass.CHEAP, ref.cost()); + assertEquals(EnumSet.allOf(Axis.class), ref.axes()); + assertEquals(ValueRange.UNBOUNDED, ref.range()); + } + + @Test + public void combiningTwoNoisesIsExpensive() { + Field sum = new Field.Binary(Field.BinaryOp.ADD, noise(0.1, 0.0), noise(0.2, 0.0)); + assertSame(CostClass.EXPENSIVE, sum.cost()); + Field scaled = new Field.Binary(Field.BinaryOp.MUL, noise(0.1, 0.0), new Field.Const(2.0)); + assertSame(CostClass.NOISE, scaled.cost()); + } + + @Test + public void muxOfExpensiveCasesIsExpensive() { + Field expensive = new Field.Binary(Field.BinaryOp.ADD, noise(0.1, 0.0), noise(0.2, 0.0)); + Field index = partition(coord(Axis.X)); + Field mux = new Field.Mux(index, List.of(expensive, expensive)); + assertSame(CostClass.EXPENSIVE, mux.cost()); + } + + @Test + public void muxOfNoiseCasesInheritsMaxNotSum() { + Field index = partition(coord(Axis.X)); + Field mux = new Field.Mux(index, List.of(noise(0.1, 0.0), noise(0.2, 0.0), new Field.Const(0.0))); + assertSame(CostClass.NOISE, mux.cost()); + assertEquals(new ValueRange(-1.0, 1.0), mux.range()); + } + + @Test + public void gateRangeIncludesZero() { + Field gate = new Field.Gate(coord(Axis.X), new Field.Const(5.0)); + assertEquals(new ValueRange(0.0, 5.0), gate.range()); + } + + @Test + public void gridAndSharedInheritTraits() { + Field base = noise(0.1, 0.5); + Field grid = new Field.Grid(base, 4, 8); + Field shared = new Field.Shared("terrain/base", grid); + assertEquals(base.axes(), shared.axes()); + assertEquals(base.range(), shared.range()); + assertSame(base.cost(), shared.cost()); + } + + @Test + public void partitionRejectsNonCheapLeaves() { + assertThrows(IllegalArgumentException.class, () -> new Field.Partition(coord(Axis.X), List.of(0.0), List.of(new Field.Const(1.0), noise(0.1, 0.0)))); + } + + @Test + public void partitionRequiresOneMoreLeafThanThresholds() { + assertThrows(IllegalArgumentException.class, () -> new Field.Partition(coord(Axis.X), List.of(0.0, 1.0), List.of(new Field.Const(1.0), new Field.Const(2.0)))); + } + + @Test + public void partitionRejectsDescendingThresholds() { + assertThrows(IllegalArgumentException.class, () -> new Field.Partition(coord(Axis.X), List.of(1.0, 0.0), List.of(new Field.Const(1.0), new Field.Const(2.0), new Field.Const(3.0)))); + } + + @Test + public void partitionRangeCoversReachableLeavesOnly() { + Field input = new Field.Clamp(coord(Axis.X), 0.0, 0.4); + Field partition = new Field.Partition(input, List.of(0.5, 1.0), List.of(new Field.Const(1.0), new Field.Const(2.0), new Field.Const(3.0))); + assertEquals(new ValueRange(1.0, 1.0), partition.range()); + } + + @Test + public void clampNarrowsRange() { + Field clamp = new Field.Clamp(noise(0.1, 0.0), -0.5, 0.25); + assertEquals(new ValueRange(-0.5, 0.25), clamp.range()); + Field constant = new Field.Clamp(new Field.Const(3.0), 0.0, 1.0); + assertEquals(new ValueRange(1.0, 1.0), constant.range()); + } + + @Test + public void clampRejectsInvertedBounds() { + assertThrows(IllegalArgumentException.class, () -> new Field.Clamp(coord(Axis.X), 1.0, 0.0)); + } + + @Test + public void arithmeticRangesFollowIntervalRules() { + Field a = new Field.Clamp(coord(Axis.X), -1.0, 2.0); + Field b = new Field.Clamp(coord(Axis.Z), 3.0, 4.0); + assertEquals(new ValueRange(2.0, 6.0), new Field.Binary(Field.BinaryOp.ADD, a, b).range()); + assertEquals(new ValueRange(-5.0, -1.0), new Field.Binary(Field.BinaryOp.SUB, a, b).range()); + assertEquals(new ValueRange(-4.0, 8.0), new Field.Binary(Field.BinaryOp.MUL, a, b).range()); + assertEquals(new ValueRange(-1.0 / 3.0, 2.0 / 3.0), new Field.Binary(Field.BinaryOp.DIV, a, b).range()); + assertEquals(ValueRange.UNBOUNDED, new Field.Binary(Field.BinaryOp.DIV, b, a).range()); + assertEquals(new ValueRange(0.0, 2.0), new Field.Unary(Field.UnaryOp.ABS, a).range()); + assertEquals(new ValueRange(0.0, 4.0), new Field.Unary(Field.UnaryOp.SQUARE, a).range()); + assertEquals(new ValueRange(-0.5, 2.0), new Field.Unary(Field.UnaryOp.HALF_NEG, a).range()); + } + + @Test + public void lerpWithUnitAlphaStaysBetweenEnds() { + Field alpha = new Field.Clamp(coord(Axis.X), 0.0, 1.0); + Field lerp = new Field.Lerp(alpha, new Field.Const(-2.0), new Field.Const(6.0)); + assertEquals(new ValueRange(-2.0, 6.0), lerp.range()); + } + + @Test + public void roundByConstantMultipleQuantisesBounds() { + Field input = new Field.Clamp(coord(Axis.X), 0.2, 9.7); + Field round = new Field.Round(Field.RoundMode.FLOOR, input, new Field.Const(2.0)); + assertEquals(new ValueRange(0.0, 8.0), round.range()); + } + + @Test + public void curveKnotValueMustBeConstOrCurve() { + assertThrows(IllegalArgumentException.class, () -> new Field.Knot(0.0, coord(Axis.X), 0.0)); + Field.Curve nested = curve(coord(Axis.Z)); + Field.Knot constantKnot = new Field.Knot(0.0, new Field.Const(1.0), 0.0); + Field.Knot nestedKnot = new Field.Knot(1.0, nested, 0.0); + Field.Curve outer = new Field.Curve(coord(Axis.X), List.of(constantKnot, nestedKnot)); + assertEquals(EnumSet.of(Axis.X, Axis.Z), outer.axes()); + } + + @Test + public void curveRejectsUnorderedKnots() { + Field.Knot first = new Field.Knot(1.0, new Field.Const(0.0), 0.0); + Field.Knot second = new Field.Knot(0.0, new Field.Const(1.0), 0.0); + assertThrows(IllegalArgumentException.class, () -> new Field.Curve(coord(Axis.X), List.of(first, second))); + } + + @Test + public void curveRangeCoversHermiteOvershoot() { + Field input = new Field.Clamp(coord(Axis.X), 0.0, 1.0); + Field.Knot first = new Field.Knot(0.0, new Field.Const(0.0), 3.0); + Field.Knot second = new Field.Knot(1.0, new Field.Const(0.0), 3.0); + ValueRange range = new Field.Curve(input, List.of(first, second)).range(); + double peak = Math.sqrt(3.0) / 6.0; + assertTrue(range.min() <= -peak + EPSILON); + assertTrue(range.max() >= peak - EPSILON); + } + + @Test + public void curveRangeExtendsBeyondEndKnots() { + Field input = new Field.Clamp(coord(Axis.X), -2.0, 0.0); + Field.Knot first = new Field.Knot(-1.0, new Field.Const(1.0), 2.0); + Field.Knot second = new Field.Knot(0.0, new Field.Const(1.0), 0.0); + ValueRange range = new Field.Curve(input, List.of(first, second)).range(); + assertEquals(-1.0, range.min(), EPSILON); + } + + @Test + public void sliceRemovesAxis() { + Field slice = new Field.Slice(noise(0.1, 0.5), Axis.Y, 64); + assertEquals(EnumSet.of(Axis.X, Axis.Z), slice.axes()); + } + + @Test + public void topSurfaceIsColumnFieldWithinBounds() { + Field density = new Field.Binary(Field.BinaryOp.SUB, noise(0.1, 0.0), coord(Axis.Y)); + Field top = new Field.TopSurface(density, 320, -64, 8); + assertEquals(EnumSet.of(Axis.X, Axis.Z), top.axes()); + assertEquals(new ValueRange(-64.0, 320.0), top.range()); + assertSame(CostClass.EXPENSIVE, top.cost()); + } + + @Test + public void rangeIfPicksBranchWhenInputIsDecided() { + Field input = new Field.Clamp(coord(Axis.X), 0.0, 0.5); + Field inside = new Field.RangeIf(input, 0.0, 1.0, new Field.Const(7.0), new Field.Const(9.0)); + assertEquals(new ValueRange(7.0, 7.0), inside.range()); + Field straddling = new Field.RangeIf(input, 0.25, 1.0, new Field.Const(7.0), new Field.Const(9.0)); + assertEquals(new ValueRange(7.0, 9.0), straddling.range()); + } + + @Test + public void sharedDiamondIsAnalysedConsistently() { + Field level = noise(0.1, 0.0); + for (int index = 0; index < 64; index++) { + Field shared = new Field.Shared("chain/" + index, level); + level = new Field.Binary(Field.BinaryOp.MAX, shared, new Field.Unary(Field.UnaryOp.NEG, shared)); + } + assertEquals(EnumSet.of(Axis.X, Axis.Z), level.axes()); + assertSame(CostClass.EXPENSIVE, level.cost()); + } + + @Test + public void identifiersAreValidated() { + assertThrows(IllegalArgumentException.class, () -> new Field.Ref("Minecraft:Bad")); + assertThrows(IllegalArgumentException.class, () -> new Field.Noise("no_namespace", 1.0, 1.0, null, null, null)); + assertThrows(IllegalArgumentException.class, () -> new Field.Shared("bad name", new Field.Const(0.0))); + assertThrows(IllegalArgumentException.class, () -> new Field.Grid(new Field.Const(0.0), 0, 4)); + assertThrows(IllegalArgumentException.class, () -> new Field.Coord(Axis.X, Field.Tiling.CLAMP, 4.0, 4.0, 0.0, 1.0)); + assertThrows(IllegalArgumentException.class, () -> new Field.Const(Double.NaN)); + } + + @Test + public void requireTwoDimensionalRejectsYDependence() { + Field threeDimensional = new Field.Binary(Field.BinaryOp.ADD, noise(0.1, 0.0), coord(Axis.Y)); + IllegalStateException error = assertThrows(IllegalStateException.class, () -> FieldValidator.requireTwoDimensional(threeDimensional, "height")); + assertEquals("2D field 'height' depends on Y", error.getMessage()); + FieldValidator.requireTwoDimensional(noise(0.1, 0.0), "height"); + } + + @Test + public void diagnosticNormalisesMissingResource() { + ExportDiagnostic diagnostic = new ExportDiagnostic(ExportDiagnostic.Severity.WARNING, ExportLimit.IMAGE_MAP, null, "Image map dropped"); + assertEquals("", diagnostic.resource()); + assertThrows(NullPointerException.class, () -> new ExportDiagnostic(ExportDiagnostic.Severity.INFO, null, "x", "y")); + } + + private static Field noise(double xzScale, double yScale) { + return new Field.Noise("iris_export:base", xzScale, yScale, null, null, null); + } + + private static Field coord(Axis axis) { + return new Field.Coord(axis, Field.Tiling.CLAMP, -4064.0, 4062.0, -4064.0, 4062.0); + } + + private static Field partition(Field input) { + return new Field.Partition(input, List.of(0.5), List.of(new Field.Const(0.0), new Field.Const(1.0))); + } + + private static Field.Curve curve(Field input) { + Field.Knot first = new Field.Knot(-1.0, new Field.Const(0.0), 0.0); + Field.Knot second = new Field.Knot(1.0, new Field.Const(1.0), 0.0); + return new Field.Curve(input, List.of(first, second)); + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/ir/ResourceIrTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/ir/ResourceIrTest.java new file mode 100644 index 000000000..4ea062f08 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/ir/ResourceIrTest.java @@ -0,0 +1,173 @@ +package art.arcane.iris.pack.datapack.export.ir; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.WorldgenExportMode; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; + +public class ResourceIrTest { + @Test + public void dryAquiferUsesVanillaAquiferNoises() { + Field surface = new Field.Const(63.0); + AquiferDef aquifer = AquiferDef.dry(surface); + assertEquals(new Field.Noise("minecraft:aquifer_barrier", 1.0, 0.5, null, null, null), aquifer.barrier()); + assertEquals(new Field.Const(-1.0), aquifer.floodedness()); + assertEquals(new Field.Noise("minecraft:aquifer_fluid_level_spread", 1.0, 0.7142857142857143, null, null, null), aquifer.spread()); + assertEquals(new Field.Noise("minecraft:aquifer_lava", 1.0, 1.0, null, null, null), aquifer.lava()); + assertEquals(new Field.Const(-1.0), aquifer.exclusion()); + assertSame(surface, aquifer.surfaceLevel()); + } + + @Test + public void noiseDefValidatesVersionNeutralLimits() { + NoiseDef noise = new NoiseDef("terrain/base", -7, List.of(1.0, 0.5, 0.0)); + assertEquals(List.of(1.0, 0.5, 0.0), noise.amplitudes()); + assertThrows(IllegalArgumentException.class, () -> new NoiseDef("terrain/base", -33, List.of(1.0))); + assertThrows(IllegalArgumentException.class, () -> new NoiseDef("terrain/base", 0, List.of())); + assertThrows(IllegalArgumentException.class, () -> new NoiseDef("terrain/base", 0, List.of(-1.0))); + assertThrows(IllegalArgumentException.class, () -> new NoiseDef("terrain/base", 0, List.of(0.0, 0.0))); + assertThrows(IllegalArgumentException.class, () -> new NoiseDef("Terrain", 0, List.of(1.0))); + assertThrows(IllegalArgumentException.class, () -> new NoiseDef("iris:terrain", 0, List.of(1.0))); + } + + @Test + public void climateEntryStaysInsideClimateRange() { + ClimateLayout.ClimateEntry entry = new ClimateLayout.ClimateEntry("iris_export:plains", ClimateLayout.ClimateAxis.TEMPERATURE, 0.1, 0.2); + assertEquals(List.of(entry), new ClimateLayout(List.of(entry)).entries()); + assertThrows(IllegalArgumentException.class, () -> new ClimateLayout.ClimateEntry("iris_export:plains", ClimateLayout.ClimateAxis.DEPTH, -2.5, 0.0)); + assertThrows(IllegalArgumentException.class, () -> new ClimateLayout.ClimateEntry("iris_export:plains", ClimateLayout.ClimateAxis.HUMIDITY, 0.5, 0.1)); + assertThrows(IllegalArgumentException.class, () -> new ClimateLayout.ClimateEntry("plains", ClimateLayout.ClimateAxis.HUMIDITY, 0.0, 0.1)); + } + + @Test + public void rockEmitsSingleBlockRule() { + MaterialProgram program = MaterialProgram.rock("minecraft:stone"); + assertEquals(JsonParser.parseString("{\"type\":\"minecraft:block\",\"result_state\":{\"id\":\"minecraft:stone\"}}"), program.rule()); + assertThrows(IllegalArgumentException.class, () -> MaterialProgram.rock("stone")); + } + + @Test + public void biomeJsonIsCopied() { + JsonObject json = new JsonObject(); + json.addProperty("temperature", 0.8); + BiomeDef biome = new BiomeDef("temperate/plains", json); + json.addProperty("downfall", 0.4); + assertEquals(1, biome.json().size()); + assertThrows(IllegalArgumentException.class, () -> new BiomeDef("Temperate Plains", json)); + } + + @Test + public void exportIrCopiesLists() { + List diagnostics = new ArrayList<>(); + ExportIr ir = exportIr(DataVersion.V26_3, List.of(biome("plains")), List.of(entry("iris_export:plains")), diagnostics); + diagnostics.add(new ExportDiagnostic(ExportDiagnostic.Severity.INFO, ExportLimit.IMAGE_MAP, "x", "y")); + assertEquals(0, ir.diagnostics().size()); + assertEquals("iris_export", ir.namespace()); + } + + @Test + public void exportIrAcceptsVanillaBiomeReferences() { + ExportIr ir = exportIr(DataVersion.V26_3, List.of(biome("plains")), List.of(entry("iris_export:plains"), entry("minecraft:desert")), List.of()); + assertEquals(2, ir.climate().entries().size()); + } + + @Test + public void exportIrRejectsOlderTargets() { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> exportIr(DataVersion.V26_2, List.of(biome("plains")), List.of(entry("iris_export:plains")), List.of())); + assertEquals("Unsupported datapack export version '26.2'. Supported: 26.3", error.getMessage()); + } + + @Test + public void exportIrRejectsClimateEntryForUndefinedBiome() { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> exportIr(DataVersion.V26_3, List.of(biome("plains")), List.of(entry("iris_export:desert")), List.of())); + assertEquals("Climate entry references undefined biome 'iris_export:desert'", error.getMessage()); + } + + @Test + public void exportIrRejectsDuplicateIds() { + assertThrows(IllegalArgumentException.class, () -> exportIr(DataVersion.V26_3, List.of(biome("plains"), biome("plains")), List.of(entry("iris_export:plains")), List.of())); + } + + @Test + public void exportIrRejectsDuplicateResourcePaths() { + String path = "data/iris_export/worldgen/configured_feature/tree.json"; + JsonResource json = new JsonResource(path, JsonParser.parseString("{\"type\":\"minecraft:tree\"}")); + BinaryResource binary = new BinaryResource(path, new byte[]{1, 2}); + List biomes = List.of(biome("plains")); + List entries = List.of(entry("iris_export:plains")); + + IllegalArgumentException jsonError = assertThrows(IllegalArgumentException.class, + () -> exportIr(DataVersion.V26_3, biomes, entries, List.of(), List.of(json, json), List.of())); + IllegalArgumentException mixedError = assertThrows(IllegalArgumentException.class, + () -> exportIr(DataVersion.V26_3, biomes, entries, List.of(), List.of(json), List.of(binary))); + + assertEquals("Duplicate resource path '" + path + "'", jsonError.getMessage()); + assertEquals("Duplicate resource path '" + path + "'", mixedError.getMessage()); + ExportIr ir = exportIr(DataVersion.V26_3, biomes, entries, List.of(), List.of(json), List.of(new BinaryResource("data/iris_export/structure/tree.nbt", new byte[]{1}))); + assertEquals(List.of(json), ir.jsonResources()); + assertEquals(1, ir.binaryResources().size()); + } + + @Test + public void resourcesCopyTheirContents() { + byte[] bytes = {1, 2, 3}; + BinaryResource binary = new BinaryResource("data/iris_export/structure/tree.nbt", bytes); + bytes[0] = 9; + binary.bytes()[1] = 9; + JsonObject json = new JsonObject(); + JsonResource resource = new JsonResource("data/iris_export/worldgen/placed_feature/tree.json", json); + json.addProperty("feature", "iris_export:tree"); + + assertArrayEquals(new byte[]{1, 2, 3}, binary.bytes()); + assertEquals(new BinaryResource("data/iris_export/structure/tree.nbt", new byte[]{1, 2, 3}), binary); + assertEquals(0, resource.json().getAsJsonObject().size()); + assertThrows(IllegalArgumentException.class, () -> new JsonResource("/data/iris_export/x.json", json)); + assertThrows(IllegalArgumentException.class, () -> new BinaryResource("data/../x.nbt", bytes)); + } + + @Test + public void exportIrRejectsUnsupportedTarget() { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> exportIr(DataVersion.V1_21_1, List.of(biome("plains")), List.of(entry("iris_export:plains")), List.of())); + assertEquals("Unsupported datapack export version '1.21.1'. Supported: 26.3", error.getMessage()); + } + + private static ExportIr exportIr(DataVersion target, List biomes, List entries, List diagnostics) { + return exportIr(target, biomes, entries, diagnostics, List.of(), List.of()); + } + + private static ExportIr exportIr( + DataVersion target, + List biomes, + List entries, + List diagnostics, + List jsonResources, + List binaryResources + ) { + DimensionDef dimension = new DimensionDef("overworld", -64, 384, 384, 63, "minecraft:stone", "minecraft:water", new JsonObject()); + Field height = new Field.Const(64.0); + Field zero = new Field.Const(0.0); + RouterDef router = new RouterDef(zero, zero, zero, zero, zero, zero, height, height, AquiferDef.dry(height)); + List noises = List.of(new NoiseDef("terrain/base", -7, List.of(1.0))); + return new ExportIr("iris_export", target, WorldgenExportMode.PRESET, dimension, noises, router, new ClimateLayout(entries), biomes, MaterialProgram.rock("minecraft:stone"), diagnostics, jsonResources, binaryResources); + } + + private static BiomeDef biome(String id) { + JsonElement json = JsonParser.parseString("{\"has_precipitation\":true}"); + return new BiomeDef(id, json.getAsJsonObject()); + } + + private static ClimateLayout.ClimateEntry entry(String biomeId) { + return new ClimateLayout.ClimateEntry(biomeId, ClimateLayout.ClimateAxis.TEMPERATURE, 0.0, 0.1); + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/lower/ExportCapabilitiesTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/lower/ExportCapabilitiesTest.java new file mode 100644 index 000000000..5d19df5a7 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/lower/ExportCapabilitiesTest.java @@ -0,0 +1,25 @@ +package art.arcane.iris.pack.datapack.export.lower; + +import art.arcane.iris.pack.datapack.DataVersion; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; + +public class ExportCapabilitiesTest { + @Test + public void capabilitiesCarryTheTarget() { + assertSame(DataVersion.V26_3, ExportCapabilities.forTarget(DataVersion.V26_3).target()); + } + + @Test + public void olderTargetsAreRejected() { + for (DataVersion older : List.of(DataVersion.V26_2, DataVersion.V26_1_2, DataVersion.V1_21_1)) { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> ExportCapabilities.forTarget(older)); + assertEquals("Unsupported datapack export version '" + older.getVersion() + "'. Supported: 26.3", error.getMessage()); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/lower/GenerationBudgetTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/lower/GenerationBudgetTest.java new file mode 100644 index 000000000..378716a45 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/lower/GenerationBudgetTest.java @@ -0,0 +1,111 @@ +package art.arcane.iris.pack.datapack.export.lower; + +import art.arcane.iris.pack.datapack.export.GenerationCost; +import art.arcane.iris.pack.datapack.export.ir.ExportDiagnostic; +import art.arcane.iris.pack.datapack.export.ir.ExportLimit; +import org.junit.Test; + +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +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 GenerationBudgetTest { + @Test + public void everyClimateRootHasTheClimateBudget() { + for (GenerationCost.Root root : GenerationCost.Root.values()) { + if (root.climate()) { + assertEquals(root.id(), GenerationBudget.CLIMATE_WORST_CASE_NODES, GenerationBudget.worstCaseLimit(root).orElseThrow(), 0.0); + } + } + } + + @Test + public void climateRootOverBudgetIsReported() { + Map roots = new EnumMap<>(GenerationCost.Root.class); + roots.put(GenerationCost.Root.TEMPERATURE, new GenerationCost.RootCost(4.2e9, 8.4e9, 1_200L)); + roots.put(GenerationCost.Root.VEGETATION, new GenerationCost.RootCost(GenerationBudget.CLIMATE_WORST_CASE_NODES, 1.0e9, 90L)); + + List diagnostics = GenerationBudget.check(new GenerationCost(roots, 0L), "overworld"); + + assertEquals(1, diagnostics.size()); + ExportDiagnostic diagnostic = diagnostics.getFirst(); + assertSame(ExportLimit.GENERATION_COST, diagnostic.limit()); + assertSame(ExportDiagnostic.Severity.WARNING, diagnostic.severity()); + assertEquals("noise_settings/overworld/temperature", diagnostic.resource()); + assertTrue(diagnostic.message(), diagnostic.message().contains("temperature")); + assertTrue(diagnostic.message(), diagnostic.message().contains("4.20e+09")); + assertFalse(diagnostic.message(), diagnostic.message().contains("8.40e+09")); + assertTrue(diagnostic.message(), diagnostic.message().contains("5.00e+05")); + } + + @Test + public void rootsWithoutABudgetAreNotReported() { + Map roots = new EnumMap<>(GenerationCost.Root.class); + roots.put(GenerationCost.Root.AQUIFER_BARRIER, new GenerationCost.RootCost(1.0e12, 1.0e12, 10L)); + + assertTrue(GenerationBudget.check(new GenerationCost(roots, 0L), "overworld").isEmpty()); + assertTrue(GenerationBudget.worstCaseLimit(GenerationCost.Root.AQUIFER_BARRIER).isEmpty()); + assertTrue(GenerationBudget.check(GenerationCost.NONE, "overworld").isEmpty()); + } + + @Test + public void finalDensityOverTheDistinctNodeBudgetIsReported() { + Map roots = new EnumMap<>(GenerationCost.Root.class); + roots.put(GenerationCost.Root.FINAL_DENSITY, new GenerationCost.RootCost(1.0e9, 1.0e10, 39_800L)); + + List diagnostics = GenerationBudget.check(new GenerationCost(roots, 0L), "overworld"); + + assertEquals(diagnostics.toString(), 1, diagnostics.size()); + ExportDiagnostic diagnostic = diagnostics.getFirst(); + assertSame(ExportLimit.GENERATION_COST, diagnostic.limit()); + assertSame(ExportDiagnostic.Severity.WARNING, diagnostic.severity()); + assertEquals("noise_settings/overworld/final_density", diagnostic.resource()); + assertTrue(diagnostic.message(), diagnostic.message().contains("39800")); + assertTrue(diagnostic.message(), diagnostic.message().contains("20000")); + } + + @Test + public void finalDensityAtTheDistinctNodeBudgetIsNotReported() { + Map roots = new EnumMap<>(GenerationCost.Root.class); + roots.put(GenerationCost.Root.FINAL_DENSITY, new GenerationCost.RootCost(1.0e9, 1.0e10, GenerationBudget.FINAL_DENSITY_DISTINCT_NODES)); + + assertTrue(GenerationBudget.check(new GenerationCost(roots, 0L), "overworld").isEmpty()); + } + + @Test + public void densityFunctionJsonOverTheSizeBudgetIsReported() { + long bytes = 60L * 1024L * 1024L; + + List diagnostics = GenerationBudget.check(new GenerationCost(Map.of(), bytes), "overworld"); + + assertEquals(diagnostics.toString(), 1, diagnostics.size()); + ExportDiagnostic diagnostic = diagnostics.getFirst(); + assertSame(ExportLimit.GENERATION_COST, diagnostic.limit()); + assertEquals("worldgen/density_function", diagnostic.resource()); + assertTrue(diagnostic.message(), diagnostic.message().contains("60.0 MB")); + assertTrue(diagnostic.message(), diagnostic.message().contains("50.0 MB")); + } + + @Test + public void densityFunctionJsonAtTheSizeBudgetIsNotReported() { + assertEquals(50L * 1024L * 1024L, GenerationBudget.DENSITY_FUNCTION_JSON_BYTES); + assertTrue(GenerationBudget.check(new GenerationCost(Map.of(), GenerationBudget.DENSITY_FUNCTION_JSON_BYTES), "overworld").isEmpty()); + } + + @Test + public void densityFunctionBytesCountOnlyDensityFunctionJson() { + Map files = Map.of( + "data/iris_export/worldgen/density_function/overworld/final_density.json", new byte[120], + "data/iris_export/worldgen/density_function/height.json", new byte[30], + "data/minecraft/worldgen/density_function/shift_x.json", new byte[7], + "data/iris_export/worldgen/noise/height.json", new byte[500], + "data/iris_export/worldgen/density_function/notes.txt", new byte[900]); + + assertEquals(157L, GenerationCost.densityFunctionBytes(files)); + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/lower/GenerationCostCounterTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/lower/GenerationCostCounterTest.java new file mode 100644 index 000000000..f16995481 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/lower/GenerationCostCounterTest.java @@ -0,0 +1,159 @@ +package art.arcane.iris.pack.datapack.export.lower; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.GenerationCost; +import art.arcane.iris.pack.datapack.export.ir.AquiferDef; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.NoiseDef; +import art.arcane.iris.pack.datapack.export.ir.RouterDef; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class GenerationCostCounterTest { + private static final Field NOISE = new Field.Noise("iris_export:base", 0.25, 0.0, null, null, null); + private static final Field OTHER_NOISE = new Field.Noise("iris_export:other", 0.25, 0.0, null, null, null); + private static final Field ZERO = new Field.Const(0.0); + private static final VanillaBounds NO_NOISES = VanillaBounds.of("iris_export", List.of()); + + @Test + public void disjointMinMaxCountsOnlyTheInputThatAlwaysWins() { + Field band = new Field.Shared("band", new Field.Clamp(NOISE, 0.25, 0.75)); + Field floor = new Field.Binary(Field.BinaryOp.MAX, new Field.Binary(Field.BinaryOp.MUL, band, new Field.Const(2.0)), ZERO); + Field twin = new Field.Shared("twin", new Field.Binary(Field.BinaryOp.MUL, band, new Field.Const(2.0))); + Field root = new Field.Binary(Field.BinaryOp.ADD, new Field.Shared("floor", floor), twin); + VanillaBounds bounds = VanillaBounds.of("iris_export", List.of(new NoiseDef("base", 0, List.of(1.0)))); + RouterDef router = new RouterDef(root, ZERO, ZERO, ZERO, ZERO, ZERO, ZERO, ZERO, AquiferDef.dry(ZERO)); + GenerationCost.RootCost cost = GenerationCostCounter.count(router, bounds, DataVersion.V26_3).root(GenerationCost.Root.TEMPERATURE).orElseThrow(); + + assertEquals(11.0, cost.expandedNodes(), 0.0); + assertEquals(11.0, cost.worstCaseNodes(), 0.0); + assertEquals(6L, cost.distinctNodes()); + assertEquals(12.0, GenerationCostCounter.worstCase(root, NO_NOISES, DataVersion.V26_3), 0.0); + } + + @Test + public void sharedReferencesReExpandButCountOnceAsDistinct() { + Field shared = new Field.Shared("a", NOISE); + GenerationCost.RootCost cost = temperature(new Field.Binary(Field.BinaryOp.ADD, shared, shared), DataVersion.V26_3); + + assertEquals(5.0, cost.expandedNodes(), 0.0); + assertEquals(5.0, cost.worstCaseNodes(), 0.0); + assertEquals(3L, cost.distinctNodes()); + } + + @Test + public void structurallyEqualSharedBodiesShareOneFile() { + Field first = new Field.Shared("a", new Field.Binary(Field.BinaryOp.MUL, NOISE, new Field.Const(2.0))); + Field second = new Field.Shared("b", new Field.Binary(Field.BinaryOp.MUL, NOISE, new Field.Const(2.0))); + GenerationCost.RootCost cost = temperature(new Field.Binary(Field.BinaryOp.ADD, first, second), DataVersion.V26_3); + + assertEquals(7.0, cost.expandedNodes(), 0.0); + assertEquals(4L, cost.distinctNodes()); + } + + @Test + public void inlineDuplicatesAreDistinctNodes() { + Field product = new Field.Binary(Field.BinaryOp.MUL, NOISE, new Field.Const(2.0)); + GenerationCost.RootCost cost = temperature(new Field.Binary(Field.BinaryOp.ADD, product, product), DataVersion.V26_3); + + assertEquals(5.0, cost.expandedNodes(), 0.0); + assertEquals(5L, cost.distinctNodes()); + } + + @Test + public void muxCountsEveryBranchAsSpline() { + Field mux = new Field.Mux(NOISE, List.of(ZERO, OTHER_NOISE, new Field.Shared("c", OTHER_NOISE))); + + assertEquals(8.0, temperature(mux, DataVersion.V26_3).expandedNodes(), 0.0); + assertEquals(6.0, temperature(mux, DataVersion.V26_3).worstCaseNodes(), 0.0); + assertEquals(1.0, temperature(new Field.Mux(NOISE, List.of(OTHER_NOISE)), DataVersion.V26_3).expandedNodes(), 0.0); + } + + @Test + public void gateCurveAndConstantsFollowTheEmittedShape() { + Field gate = new Field.Gate(NOISE, OTHER_NOISE); + Field nested = new Field.Curve(OTHER_NOISE, List.of(new Field.Knot(0.0, ZERO, 0.0), new Field.Knot(1.0, new Field.Const(1.0), 0.0))); + Field curve = new Field.Curve(NOISE, List.of(new Field.Knot(-1.0, nested, 0.0), new Field.Knot(1.0, ZERO, 0.0))); + + assertEquals(5.0, temperature(gate, DataVersion.V26_3).expandedNodes(), 0.0); + assertEquals(5.0, temperature(gate, DataVersion.V26_3).worstCaseNodes(), 0.0); + assertEquals(5.0, temperature(curve, DataVersion.V26_3).expandedNodes(), 0.0); + assertEquals(5.0, temperature(curve, DataVersion.V26_3).worstCaseNodes(), 0.0); + assertEquals(0.0, temperature(ZERO, DataVersion.V26_3).expandedNodes(), 0.0); + assertEquals(0L, temperature(ZERO, DataVersion.V26_3).distinctNodes()); + } + + @Test + public void interpolatedNodesEvaluateEightCornersOffLattice() { + Field grid = new Field.Grid(new Field.Binary(Field.BinaryOp.MUL, NOISE, OTHER_NOISE), 4, 8); + GenerationCost.RootCost cost = temperature(new Field.Binary(Field.BinaryOp.ADD, grid, NOISE), DataVersion.V26_3); + + assertEquals(6.0, cost.expandedNodes(), 0.0); + assertEquals(27.0, cost.worstCaseNodes(), 0.0); + assertEquals(6L, cost.distinctNodes()); + } + + @Test + public void pointEvaluationTakesTheWorstBranchAndCountsSurfaceProbes() { + Field choice = new Field.RangeIf(NOISE, 0.0, 1.0, OTHER_NOISE, new Field.Binary(Field.BinaryOp.ADD, OTHER_NOISE, NOISE)); + Field surface = new Field.TopSurface(new Field.Noise("iris_export:base", 0.25, 0.5, null, null, null), 10, 0, 1); + + assertEquals(6.0, temperature(choice, DataVersion.V26_3).expandedNodes(), 0.0); + assertEquals(5.0, temperature(choice, DataVersion.V26_3).worstCaseNodes(), 0.0); + assertEquals(2.0, temperature(surface, DataVersion.V26_3).expandedNodes(), 0.0); + assertEquals(12.0, temperature(surface, DataVersion.V26_3).worstCaseNodes(), 0.0); + } + + @Test + public void worstCaseOfASingleFieldMatchesItsRouterRoot() { + Field choice = new Field.Mux(NOISE, List.of(OTHER_NOISE, new Field.Grid(new Field.Binary(Field.BinaryOp.ADD, NOISE, OTHER_NOISE), 4, 1))); + + assertEquals(temperature(choice, DataVersion.V26_3).worstCaseNodes(), GenerationCostCounter.worstCase(choice, NO_NOISES, DataVersion.V26_3), 0.0); + assertEquals(29.0, GenerationCostCounter.worstCase(choice, NO_NOISES, DataVersion.V26_3), 0.0); + } + + @Test + public void subtractionIsOneNode() { + Field difference = new Field.Binary(Field.BinaryOp.SUB, NOISE, OTHER_NOISE); + + assertEquals(3.0, temperature(difference, DataVersion.V26_3).expandedNodes(), 0.0); + } + + @Test + public void deepSharingCountsWithoutExpandingTheTree() { + Field current = NOISE; + for (int level = 0; level < 200; level++) { + current = new Field.Shared("level/" + level, new Field.Binary(Field.BinaryOp.ADD, current, current)); + } + GenerationCost.RootCost cost = temperature(current, DataVersion.V26_3); + + assertTrue(Double.isFinite(cost.expandedNodes())); + assertTrue(cost.expandedNodes() > 1.0e60); + assertEquals(402L, cost.distinctNodes()); + } + + @Test + public void everyEmittedRouterSlotIsCounted() { + Field shared = new Field.Shared("height", NOISE); + RouterDef router = new RouterDef(shared, OTHER_NOISE, ZERO, ZERO, new Field.Binary(Field.BinaryOp.ADD, shared, shared), ZERO, + shared, NOISE, AquiferDef.dry(shared)); + + GenerationCost current = GenerationCostCounter.count(router, NO_NOISES, DataVersion.V26_3); + + assertEquals(GenerationCost.Root.values().length, current.roots().size()); + assertEquals(5.0, current.root(GenerationCost.Root.DEPTH).orElseThrow().expandedNodes(), 0.0); + assertEquals(1.0, current.root(GenerationCost.Root.VEGETATION).orElseThrow().expandedNodes(), 0.0); + assertEquals(0.0, current.root(GenerationCost.Root.CONTINENTS).orElseThrow().expandedNodes(), 0.0); + assertTrue(current.root(GenerationCost.Root.CHUNK_SURFACE_LEVEL).isPresent()); + assertTrue(current.root(GenerationCost.Root.AQUIFER_EXCLUSION).isPresent()); + } + + private static GenerationCost.RootCost temperature(Field field, DataVersion target) { + RouterDef router = new RouterDef(field, ZERO, ZERO, ZERO, ZERO, ZERO, ZERO, ZERO, AquiferDef.dry(ZERO)); + return GenerationCostCounter.count(router, NO_NOISES, target).root(GenerationCost.Root.TEMPERATURE).orElseThrow(); + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/export/lower/VanillaBoundsTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/export/lower/VanillaBoundsTest.java new file mode 100644 index 000000000..77005faef --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/datapack/export/lower/VanillaBoundsTest.java @@ -0,0 +1,91 @@ +package art.arcane.iris.pack.datapack.export.lower; + +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.NoiseDef; +import art.arcane.iris.pack.datapack.export.ir.ValueRange; +import org.junit.Test; + +import java.util.List; +import java.util.Optional; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public class VanillaBoundsTest { + private static final String NAMESPACE = "iris_export"; + private static final Field NOISE = new Field.Noise("iris_export:base", 0.25, 0.0, null, null, null); + private static final Field UNREGISTERED = new Field.Noise("iris_export:missing", 0.25, 0.0, null, null, null); + private static final Field BAND = new Field.Clamp(NOISE, 0.25, 0.75); + private static final VanillaBounds BOUNDS = VanillaBounds.of(NAMESPACE, List.of(new NoiseDef("base", -4, List.of(1.0, 0.5)))); + + @Test + public void noiseRangeIsTwiceTheAmplitudeSum() { + assertEquals(Optional.of(new ValueRange(-2.5, 2.5)), BOUNDS.range(NOISE)); + } + + @Test + public void unregisteredNoiseHasNoRange() { + assertEquals(Optional.empty(), BOUNDS.range(UNREGISTERED)); + assertEquals(Optional.empty(), BOUNDS.range(new Field.Binary(Field.BinaryOp.ADD, UNREGISTERED, new Field.Const(1.0)))); + } + + @Test + public void splineRangeIncludesTheHermiteAllowance() { + Field curve = new Field.Curve(new Field.Clamp(NOISE, 0.0, 1.0), List.of( + new Field.Knot(0.0, new Field.Const(0.0), 2.0), new Field.Knot(1.0, new Field.Const(1.0), 2.0))); + assertEquals(Optional.of(new ValueRange(-0.25, 1.25)), BOUNDS.range(curve)); + } + + @Test + public void constantsUseTheirFloatValue() { + assertEquals(Optional.of(ValueRange.point((float) 0.1)), BOUNDS.range(new Field.Const(0.1))); + } + + @Test + public void disjointMaxFoldsToTheInputThatAlwaysWins() { + Field.Binary floor = new Field.Binary(Field.BinaryOp.MAX, BAND, new Field.Const(0.0)); + Field.Binary ceiling = new Field.Binary(Field.BinaryOp.MAX, new Field.Const(1.0), BAND); + assertSame(BAND, BOUNDS.fold(floor)); + assertEquals(1.0, ((Field.Const) BOUNDS.fold(ceiling)).value(), 0.0); + assertEquals(BOUNDS.range(BAND), BOUNDS.range(floor)); + } + + @Test + public void disjointMinFoldsToTheInputThatAlwaysWins() { + Field scaled = new Field.Binary(Field.BinaryOp.MUL, new Field.Const(0.3), BAND); + Field.Binary capped = new Field.Binary(Field.BinaryOp.MIN, new Field.Const(2.0), scaled); + Field.Binary lifted = new Field.Binary(Field.BinaryOp.MIN, BAND, new Field.Const(-1.0)); + assertSame(scaled, BOUNDS.fold(capped)); + assertEquals(-1.0, ((Field.Const) BOUNDS.fold(lifted)).value(), 0.0); + } + + @Test + public void touchingOrOverlappingInputsStay() { + Field.Binary touching = new Field.Binary(Field.BinaryOp.MAX, new Field.Const(0.25), BAND); + Field.Binary overlapping = new Field.Binary(Field.BinaryOp.MIN, new Field.Const(0.5), BAND); + assertSame(touching, BOUNDS.fold(touching)); + assertSame(overlapping, BOUNDS.fold(overlapping)); + } + + @Test + public void foldUsesMinecraftBoundsRatherThanTheFieldEstimate() { + Field.Binary capped = new Field.Binary(Field.BinaryOp.MIN, NOISE, new Field.Const(1.5)); + assertTrue(NOISE.range().max() < 1.5); + assertSame(capped, BOUNDS.fold(capped)); + } + + @Test + public void unknownInputsNeverFold() { + Field.Binary unknown = new Field.Binary(Field.BinaryOp.MAX, new Field.Clamp(UNREGISTERED, 0.25, 0.75), new Field.Const(0.0)); + Field.Binary external = new Field.Binary(Field.BinaryOp.MAX, new Field.Ref("minecraft:overworld/continents"), new Field.Const(1.0e6)); + assertSame(unknown, BOUNDS.fold(unknown)); + assertSame(external, BOUNDS.fold(external)); + } + + @Test + public void otherOperatorsNeverFold() { + Field.Binary sum = new Field.Binary(Field.BinaryOp.ADD, BAND, new Field.Const(5.0)); + assertSame(sum, BOUNDS.fold(sum)); + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java b/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java index c13ec6de9..5d5220f1e 100644 --- a/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java +++ b/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java @@ -52,6 +52,7 @@ import art.arcane.volmlib.nativelib.terrain.NativeNumericRange; import art.arcane.iris.spi.PlatformRegistries; import art.arcane.iris.spi.PlatformScheduler; import art.arcane.iris.spi.PlatformStructureHooks; +import art.arcane.iris.spi.VanillaDataSource; import art.arcane.volmlib.nativelib.terrain.NativeWorld; import art.arcane.iris.testsupport.PlatformLeakGuard; import art.arcane.volmlib.util.collection.KList; @@ -410,6 +411,11 @@ public class SchemaBuilderIdentityTest { return null; } + @Override + public VanillaDataSource vanillaData() { + return null; + } + @Override public File dataFolder() { return new File("."); diff --git a/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderParityTest.java b/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderParityTest.java index 00f9986a5..129d9024f 100644 --- a/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderParityTest.java +++ b/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderParityTest.java @@ -57,6 +57,7 @@ import art.arcane.volmlib.nativelib.item.NativeItem; import art.arcane.iris.spi.PlatformRegistries; import art.arcane.iris.spi.PlatformScheduler; import art.arcane.iris.spi.PlatformStructureHooks; +import art.arcane.iris.spi.VanillaDataSource; import art.arcane.volmlib.nativelib.terrain.NativeWorld; import art.arcane.iris.testsupport.PlatformLeakGuard; import art.arcane.volmlib.util.collection.KList; @@ -593,6 +594,11 @@ public class SchemaBuilderParityTest { return null; } + @Override + public VanillaDataSource vanillaData() { + return null; + } + @Override public File dataFolder() { return new File("."); diff --git a/core/src/test/java/art/arcane/iris/pack/validation/CompatFixtures.java b/core/src/test/java/art/arcane/iris/pack/validation/CompatFixtures.java index 8e1f3117d..e4397e193 100644 --- a/core/src/test/java/art/arcane/iris/pack/validation/CompatFixtures.java +++ b/core/src/test/java/art/arcane/iris/pack/validation/CompatFixtures.java @@ -15,6 +15,7 @@ import art.arcane.volmlib.nativelib.item.NativeItem; import art.arcane.iris.spi.PlatformRegistries; import art.arcane.iris.spi.PlatformScheduler; import art.arcane.iris.spi.PlatformStructureHooks; +import art.arcane.iris.spi.VanillaDataSource; import art.arcane.volmlib.nativelib.terrain.NativeWorld; import java.io.File; @@ -358,6 +359,11 @@ final class CompatFixtures { return null; } + @Override + public VanillaDataSource vanillaData() { + return null; + } + @Override public File dataFolder() { return dataFolder; diff --git a/core/src/test/java/art/arcane/iris/platform/ClasspathVanillaDataSourceTest.java b/core/src/test/java/art/arcane/iris/platform/ClasspathVanillaDataSourceTest.java new file mode 100644 index 000000000..df5c46c8e --- /dev/null +++ b/core/src/test/java/art/arcane/iris/platform/ClasspathVanillaDataSourceTest.java @@ -0,0 +1,63 @@ +package art.arcane.iris.platform; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.net.URLClassLoader; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public final class ClasspathVanillaDataSourceTest { + private static final String PLAINS = "data/minecraft/worldgen/biome/plains.json"; + + @Rule + public final TemporaryFolder folder = new TemporaryFolder(); + + @Test + public void anchorResolvesOnceOnFirstUse() throws Exception { + try (URLClassLoader loader = VanillaDataRoots.directory(folder.newFolder("root").toPath(), Map.of(PLAINS, "{}"))) { + Class anchor = VanillaDataRoots.anchor(loader); + AtomicInteger resolutions = new AtomicInteger(); + ClasspathVanillaDataSource source = new ClasspathVanillaDataSource(() -> { + resolutions.incrementAndGet(); + return anchor; + }); + assertEquals(0, resolutions.get()); + assertTrue(source.read(PLAINS).isPresent()); + assertEquals(List.of(PLAINS), source.list("data/minecraft")); + assertEquals(1, resolutions.get()); + } + } + + @Test + public void unavailableAnchorIsRetriedOnNextUse() throws Exception { + try (URLClassLoader loader = VanillaDataRoots.directory(folder.newFolder("root").toPath(), Map.of(PLAINS, "{}"))) { + Class anchor = VanillaDataRoots.anchor(loader); + AtomicInteger attempts = new AtomicInteger(); + ClasspathVanillaDataSource source = new ClasspathVanillaDataSource(() -> { + if (attempts.incrementAndGet() == 1) { + throw new IllegalStateException("No server is running"); + } + return anchor; + }); + assertThrows(IllegalStateException.class, () -> source.read(PLAINS)); + assertTrue(source.read(PLAINS).isPresent()); + } + } + + @Test + public void missingMarkerNamesTheAnchorClass() throws Exception { + try (URLClassLoader loader = VanillaDataRoots.withoutMarker(folder.newFolder("root").toPath())) { + Class anchor = VanillaDataRoots.anchor(loader); + ClasspathVanillaDataSource source = new ClasspathVanillaDataSource(() -> anchor); + IllegalStateException failure = assertThrows(IllegalStateException.class, () -> source.read(PLAINS)); + assertTrue(failure.getMessage(), failure.getMessage().contains(anchor.getName())); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/platform/VanillaDataRoots.java b/core/src/test/java/art/arcane/iris/platform/VanillaDataRoots.java new file mode 100644 index 000000000..f9b41977a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/platform/VanillaDataRoots.java @@ -0,0 +1,71 @@ +package art.arcane.iris.platform; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; + +final class VanillaDataRoots { + private static final String MARKER = "data/.mcassetsroot"; + + private VanillaDataRoots() { + } + + static URLClassLoader directory(Path folder, Map files) throws IOException { + return directoryLoader(folder, entries(files, true)); + } + + static URLClassLoader withoutMarker(Path folder) throws IOException { + return directoryLoader(folder, entries(Map.of(), false)); + } + + static URLClassLoader jar(Path jar, Map files) throws IOException { + try (OutputStream output = Files.newOutputStream(jar); JarOutputStream archive = new JarOutputStream(output)) { + for (Map.Entry entry : entries(files, true).entrySet()) { + archive.putNextEntry(new JarEntry(entry.getKey())); + archive.write(entry.getValue()); + archive.closeEntry(); + } + } + return new URLClassLoader(new URL[]{jar.toUri().toURL()}, null); + } + + static Class anchor(ClassLoader loader) throws ClassNotFoundException { + return Class.forName(VanillaDataRoots.class.getName(), false, loader); + } + + private static URLClassLoader directoryLoader(Path folder, Map entries) throws IOException { + for (Map.Entry entry : entries.entrySet()) { + Path target = folder.resolve(entry.getKey()); + Files.createDirectories(target.getParent()); + Files.write(target, entry.getValue()); + } + return new URLClassLoader(new URL[]{folder.toUri().toURL()}, null); + } + + private static Map entries(Map files, boolean marker) throws IOException { + Map entries = new LinkedHashMap<>(); + String anchorEntry = VanillaDataRoots.class.getName().replace('.', '/') + ".class"; + try (InputStream input = VanillaDataRoots.class.getClassLoader().getResourceAsStream(anchorEntry)) { + if (input == null) { + throw new IOException("Missing compiled anchor " + anchorEntry); + } + entries.put(anchorEntry, input.readAllBytes()); + } + if (marker) { + entries.put(MARKER, new byte[0]); + } + for (Map.Entry file : files.entrySet()) { + entries.put(file.getKey(), file.getValue().getBytes(StandardCharsets.UTF_8)); + } + return entries; + } +} diff --git a/core/src/test/java/art/arcane/iris/platform/VanillaDataSourceContractTest.java b/core/src/test/java/art/arcane/iris/platform/VanillaDataSourceContractTest.java new file mode 100644 index 000000000..4fe7b5461 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/platform/VanillaDataSourceContractTest.java @@ -0,0 +1,112 @@ +package art.arcane.iris.platform; + +import art.arcane.iris.spi.VanillaDataSource; +import art.arcane.iris.testsupport.FakeVanillaDataSource; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import java.io.IOException; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +@RunWith(Parameterized.class) +public final class VanillaDataSourceContractTest { + private static final String PLAINS = "data/minecraft/worldgen/biome/plains.json"; + private static final String BIOME_TAGS = "data/minecraft/tags/worldgen/biome"; + private static final String OVERWORLD_TAG = BIOME_TAGS + "/is_overworld.json"; + private static final String SWAMP_HUT_TAG = BIOME_TAGS + "/has_structure/swamp_hut.json"; + private static final Map FILES = Map.of( + PLAINS, "{\"temperature\":0.8}", + OVERWORLD_TAG, "{\"values\":[\"minecraft:plains\"]}", + SWAMP_HUT_TAG, "{\"values\":[\"minecraft:swamp\"]}", + "data/minecraft/tags/worldgen/biome_marker.json", "{}", + "data/minecraft/worldgen/noise/temperature.json", "{}"); + + @Rule + public final TemporaryFolder folder = new TemporaryFolder(); + + @Parameterized.Parameter + public Kind kind; + + private URLClassLoader loader; + private VanillaDataSource source; + + @Parameterized.Parameters(name = "{0}") + public static List kinds() { + return List.of(Kind.values()); + } + + @Before + public void createSource() throws Exception { + source = switch (kind) { + case DIRECTORY -> classpath(VanillaDataRoots.directory(folder.newFolder("root").toPath(), FILES)); + case JAR -> classpath(VanillaDataRoots.jar(folder.getRoot().toPath().resolve("vanilla.jar"), FILES)); + case FAKE -> FakeVanillaDataSource.of(FILES); + }; + } + + @After + public void closeLoader() throws IOException { + if (loader != null) { + loader.close(); + } + } + + @Test + public void readsFileBytes() { + assertArrayEquals(FILES.get(PLAINS).getBytes(StandardCharsets.UTF_8), source.read(PLAINS).orElseThrow()); + } + + @Test + public void missingFilesAndDirectoriesReadEmpty() { + assertTrue(source.read("data/minecraft/worldgen/biome/missing.json").isEmpty()); + assertTrue(source.read("data/minecraft/worldgen/biome").isEmpty()); + } + + @Test + public void listsFilesBeneathPrefixInPathOrder() { + assertEquals(List.of(SWAMP_HUT_TAG, OVERWORLD_TAG), source.list(BIOME_TAGS)); + assertEquals(List.of(SWAMP_HUT_TAG, OVERWORLD_TAG), source.list(BIOME_TAGS + "/")); + } + + @Test + public void missingDirectoriesAndFilesListNothing() { + assertEquals(List.of(), source.list("data/minecraft/tags/worldgen/structure")); + assertEquals(List.of(), source.list(PLAINS)); + } + + @Test + public void rejectsPathsOutsideTheDataTree() { + List invalid = List.of("", "data", "data/", "/data/minecraft/x.json", "assets/minecraft/x.json", + "../data/minecraft/x.json", "data/../x.json", "data/./x.json", "data/minecraft//x.json", + "data\\minecraft\\x.json"); + for (String path : invalid) { + assertThrows(path, IllegalArgumentException.class, () -> source.read(path)); + assertThrows(path, IllegalArgumentException.class, () -> source.list(path)); + } + } + + private VanillaDataSource classpath(URLClassLoader created) throws ClassNotFoundException { + loader = created; + Class anchor = VanillaDataRoots.anchor(created); + return new ClasspathVanillaDataSource(() -> anchor); + } + + public enum Kind { + DIRECTORY, + JAR, + FAKE + } +} diff --git a/core/src/test/java/art/arcane/iris/structure/export/VanillaJigsawDatapackExporterTest.java b/core/src/test/java/art/arcane/iris/structure/export/VanillaJigsawDatapackExporterTest.java index 166a91251..04125c65c 100644 --- a/core/src/test/java/art/arcane/iris/structure/export/VanillaJigsawDatapackExporterTest.java +++ b/core/src/test/java/art/arcane/iris/structure/export/VanillaJigsawDatapackExporterTest.java @@ -1,5 +1,7 @@ package art.arcane.iris.structure.export; +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.DatapackExportFormat; import art.arcane.iris.structure.graph.StructureGraphResolver; import art.arcane.iris.pack.value.IrisDirection; import art.arcane.iris.structure.jigsaw.IrisJigsawBranchFailurePolicy; @@ -34,6 +36,7 @@ import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -167,6 +170,33 @@ public class VanillaJigsawDatapackExporterTest { } } + @Test + public void jigsawExportCommandStillWorks() throws Exception { + TestGraph graph = connectorGraph(); + Path output = temporaryFolder.getRoot().toPath().resolve("command-pack"); + VanillaJigsawExportRequest request = request(graph, output) + .namespace("studio") + .resourcePath("village/test") + .build(); + + VanillaJigsawExportResult result = new VanillaJigsawDatapackExporter().export(request); + VanillaJigsawExportCompiler.Compilation explicit = new VanillaJigsawExportCompiler(DataVersion.V26_3).compile(request.unit()); + + assertTrue(result.diagnostics().toString(), result.isSuccess()); + assertTrue(result.resources().contains("pack.mcmeta")); + assertEquals(107, json(output.resolve("pack.mcmeta")).getAsJsonObject("pack").get("max_format").getAsInt()); + CompoundTag runtimeTemplate = (CompoundTag) NBTUtil.read(output.resolve( + "data/studio/structure/village/test/piece/pieces/start.nbt").toFile()).getTag(); + assertEquals(4903, runtimeTemplate.getInt("DataVersion")); + assertFalse(explicit.diagnostics().toString(), explicit.hasErrors()); + assertFalse(explicit.resources().containsKey("pack.mcmeta")); + assertTrue(explicit.resources().containsKey(VanillaJigsawExportCompiler.structureSetPath("studio", "village/test"))); + assertTrue(explicit.resources().containsKey(VanillaJigsawExportCompiler.biomeTagPath("studio", "village/test"))); + CompoundTag explicitTemplate = (CompoundTag) NBTUtil.read(new ByteArrayInputStream(explicit.resources().get( + "data/studio/structure/village/test/piece/pieces/start.nbt")), true).getTag(); + assertEquals(5023, explicitTemplate.getInt("DataVersion")); + } + @Test public void exports1211WithRuntimeDefaultSettingsDerivedFromTheStructure() throws Exception { when(IrisPlatforms.get().minecraftVersion()).thenReturn("1.21.1"); @@ -241,7 +271,7 @@ public class VanillaJigsawDatapackExporterTest { VanillaJigsawExportRequest request = request(graph, output) .namespace("studio") .resourcePath("stronghold") - .format(VanillaJigsawExportFormat.ZIP) + .format(DatapackExportFormat.ZIP) .build(); VanillaJigsawExportResult result = new VanillaJigsawDatapackExporter().export(request); diff --git a/core/src/test/java/art/arcane/iris/structure/export/VanillaJigsawExportRequestTest.java b/core/src/test/java/art/arcane/iris/structure/export/VanillaJigsawExportRequestTest.java index 305b4e4d8..bf0274bac 100644 --- a/core/src/test/java/art/arcane/iris/structure/export/VanillaJigsawExportRequestTest.java +++ b/core/src/test/java/art/arcane/iris/structure/export/VanillaJigsawExportRequestTest.java @@ -1,5 +1,6 @@ package art.arcane.iris.structure.export; +import art.arcane.iris.pack.datapack.export.DatapackExportFormat; import art.arcane.iris.structure.graph.StructureGraphResolver; import art.arcane.iris.structure.placement.IrisStructure; import org.junit.Test; @@ -44,7 +45,7 @@ public class VanillaJigsawExportRequestTest { assertEquals("village", request.resourcePath()); assertEquals("iris", request.namespace()); - assertEquals(VanillaJigsawExportFormat.DIRECTORY, request.format()); + assertEquals(DatapackExportFormat.DIRECTORY, request.format()); assertFalse(request.replaceExisting()); } @@ -65,14 +66,14 @@ public class VanillaJigsawExportRequestTest { .namespace(" mypack ") .resourcePath(" village/plains ") .description(" a description ") - .format(VanillaJigsawExportFormat.ZIP) + .format(DatapackExportFormat.ZIP) .replaceExisting(true) .build(); assertEquals("mypack", request.namespace()); assertEquals("village/plains", request.resourcePath()); assertEquals(" a description ", request.description()); - assertEquals(VanillaJigsawExportFormat.ZIP, request.format()); + assertEquals(DatapackExportFormat.ZIP, request.format()); assertTrue(request.replaceExisting()); } diff --git a/core/src/test/java/art/arcane/iris/structure/export/VanillaStructureTemplateOrientationTest.java b/core/src/test/java/art/arcane/iris/structure/export/VanillaStructureTemplateOrientationTest.java index a08fe0215..f7871bf03 100644 --- a/core/src/test/java/art/arcane/iris/structure/export/VanillaStructureTemplateOrientationTest.java +++ b/core/src/test/java/art/arcane/iris/structure/export/VanillaStructureTemplateOrientationTest.java @@ -10,28 +10,28 @@ import static org.junit.Assert.assertTrue; public class VanillaStructureTemplateOrientationTest { @Test public void aHorizontalFrontIsRenderedAgainstAnUpwardTop() { - assertEquals("north_up", VanillaStructureTemplateEncoder.orientation( + assertEquals("north_up", VanillaJigsawExportCompiler.orientation( IrisDirection.NORTH_NEGATIVE_Z, IrisDirection.UP_POSITIVE_Y)); - assertEquals("south_up", VanillaStructureTemplateEncoder.orientation( + assertEquals("south_up", VanillaJigsawExportCompiler.orientation( IrisDirection.SOUTH_POSITIVE_Z, IrisDirection.UP_POSITIVE_Y)); - assertEquals("east_up", VanillaStructureTemplateEncoder.orientation( + assertEquals("east_up", VanillaJigsawExportCompiler.orientation( IrisDirection.EAST_POSITIVE_X, IrisDirection.UP_POSITIVE_Y)); - assertEquals("west_up", VanillaStructureTemplateEncoder.orientation( + assertEquals("west_up", VanillaJigsawExportCompiler.orientation( IrisDirection.WEST_NEGATIVE_X, IrisDirection.UP_POSITIVE_Y)); } @Test public void averticalFrontIsRenderedAgainstItsHorizontalTop() { - assertEquals("up_north", VanillaStructureTemplateEncoder.orientation( + assertEquals("up_north", VanillaJigsawExportCompiler.orientation( IrisDirection.UP_POSITIVE_Y, IrisDirection.NORTH_NEGATIVE_Z)); - assertEquals("down_east", VanillaStructureTemplateEncoder.orientation( + assertEquals("down_east", VanillaJigsawExportCompiler.orientation( IrisDirection.DOWN_NEGATIVE_Y, IrisDirection.EAST_POSITIVE_X)); } @Test public void averticalFrontCannotTakeAVerticalTop() { IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, - () -> VanillaStructureTemplateEncoder.orientation( + () -> VanillaJigsawExportCompiler.orientation( IrisDirection.UP_POSITIVE_Y, IrisDirection.UP_POSITIVE_Y)); assertTrue(failure.getMessage(), failure.getMessage().contains("horizontal top direction")); @@ -40,11 +40,11 @@ public class VanillaStructureTemplateOrientationTest { @Test public void aHorizontalFrontCannotTakeANonUpwardTop() { IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, - () -> VanillaStructureTemplateEncoder.orientation( + () -> VanillaJigsawExportCompiler.orientation( IrisDirection.NORTH_NEGATIVE_Z, IrisDirection.SOUTH_POSITIVE_Z)); assertTrue(failure.getMessage(), failure.getMessage().contains("upward top direction")); - assertThrows(IllegalArgumentException.class, () -> VanillaStructureTemplateEncoder.orientation( + assertThrows(IllegalArgumentException.class, () -> VanillaJigsawExportCompiler.orientation( IrisDirection.EAST_POSITIVE_X, IrisDirection.DOWN_NEGATIVE_Y)); } } diff --git a/core/src/testFixtures/java/art/arcane/iris/pack/datapack/export/ExportIrFixtures.java b/core/src/testFixtures/java/art/arcane/iris/pack/datapack/export/ExportIrFixtures.java new file mode 100644 index 000000000..a28b095a4 --- /dev/null +++ b/core/src/testFixtures/java/art/arcane/iris/pack/datapack/export/ExportIrFixtures.java @@ -0,0 +1,81 @@ +package art.arcane.iris.pack.datapack.export; + +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.ir.AquiferDef; +import art.arcane.iris.pack.datapack.export.ir.Axis; +import art.arcane.iris.pack.datapack.export.ir.BinaryResource; +import art.arcane.iris.pack.datapack.export.ir.BiomeDef; +import art.arcane.iris.pack.datapack.export.ir.ClimateLayout; +import art.arcane.iris.pack.datapack.export.ir.DimensionDef; +import art.arcane.iris.pack.datapack.export.ir.ExportIr; +import art.arcane.iris.pack.datapack.export.ir.Field; +import art.arcane.iris.pack.datapack.export.ir.JsonResource; +import art.arcane.iris.pack.datapack.export.ir.MaterialProgram; +import art.arcane.iris.pack.datapack.export.ir.NoiseDef; +import art.arcane.iris.pack.datapack.export.ir.RouterDef; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import java.util.List; + +public final class ExportIrFixtures { + public static final String NAMESPACE = "iris_export"; + public static final String DIMENSION = "flat"; + public static final String BIOME = "plains"; + public static final String NOISE = "flat/height"; + public static final int MIN_Y = -64; + public static final int HEIGHT = 384; + public static final int FLUID_HEIGHT = 62; + public static final int SEA_LEVEL = 63; + public static final double BASE_HEIGHT = 63.0; + public static final double HEIGHT_AMPLITUDE = 16.0; + + private ExportIrFixtures() { + } + + public static ExportIr flatNoise(DataVersion target, WorldgenExportMode mode) { + Field zero = new Field.Const(0.0); + Field noise = new Field.Noise(NAMESPACE + ":" + NOISE, 1.0, 0.0, null, null, null); + Field height = new Field.Shared(DIMENSION + "/height", new Field.Binary(Field.BinaryOp.ADD, new Field.Const(BASE_HEIGHT), + new Field.Binary(Field.BinaryOp.MUL, new Field.Const(HEIGHT_AMPLITUDE), noise))); + Field y = new Field.Coord(Axis.Y, Field.Tiling.CLAMP, -4064.0, 4062.0, -4064.0, 4062.0); + Field finalDensity = new Field.Grid(new Field.Binary(Field.BinaryOp.MUL, new Field.Const(0.125), + new Field.Binary(Field.BinaryOp.SUB, height, y)), 4, 8); + RouterDef router = new RouterDef(zero, zero, zero, zero, zero, zero, new Field.Grid(height, 16, 1), finalDensity, AquiferDef.dry(height)); + DimensionDef dimension = new DimensionDef(DIMENSION, MIN_Y, HEIGHT, HEIGHT, FLUID_HEIGHT, "minecraft:stone", "minecraft:water", dimensionType()); + ClimateLayout climate = new ClimateLayout(List.of(new ClimateLayout.ClimateEntry(NAMESPACE + ":" + BIOME, ClimateLayout.ClimateAxis.TEMPERATURE, -2.0, 2.0))); + return new ExportIr(NAMESPACE, target, mode, dimension, List.of(new NoiseDef(NOISE, -7, List.of(1.0, 1.0, 1.0))), router, climate, + List.of(new BiomeDef(BIOME, biome())), MaterialProgram.rock("minecraft:stone"), List.of(), List.of(), List.of()); + } + + public static ExportIr withResources(ExportIr base, List jsonResources, List binaryResources) { + return new ExportIr(base.namespace(), base.target(), base.mode(), base.dimension(), base.noises(), base.router(), base.climate(), base.biomes(), + base.material(), base.diagnostics(), jsonResources, binaryResources); + } + + public static JsonObject dimensionType() { + JsonObject type = new JsonObject(); + type.addProperty("ambient_light", 0.0); + type.addProperty("coordinate_scale", 1.0); + type.addProperty("has_ceiling", false); + type.addProperty("has_ender_dragon_fight", false); + type.addProperty("has_skylight", true); + type.addProperty("infiniburn", "#minecraft:infiniburn_overworld"); + type.addProperty("monster_spawn_block_light_limit", 0); + type.addProperty("monster_spawn_light_level", 0); + return type; + } + + public static JsonObject biome() { + JsonObject effects = new JsonObject(); + effects.addProperty("water_color", "#3f76e4"); + JsonObject biome = new JsonObject(); + biome.addProperty("has_precipitation", true); + biome.addProperty("temperature", 0.8); + biome.addProperty("downfall", 0.4); + biome.add("effects", effects); + biome.add("carvers", new JsonArray()); + biome.add("features", new JsonArray()); + return biome; + } +} diff --git a/core/src/testFixtures/java/art/arcane/iris/pack/datapack/export/ExportTestPacks.java b/core/src/testFixtures/java/art/arcane/iris/pack/datapack/export/ExportTestPacks.java new file mode 100644 index 000000000..6f2e82eb0 --- /dev/null +++ b/core/src/testFixtures/java/art/arcane/iris/pack/datapack/export/ExportTestPacks.java @@ -0,0 +1,112 @@ +package art.arcane.iris.pack.datapack.export; + +import art.arcane.iris.configuration.IrisSettings; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.pack.datapack.export.compile.PackCompileContext; +import art.arcane.iris.pack.datapack.export.lower.ExportCapabilities; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.testsupport.CompatTestPlatform; +import org.junit.rules.ExternalResource; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Stream; + +import static org.mockito.Mockito.doReturn; + +public final class ExportTestPacks { + public static final String DIMENSION = "main"; + private static final List BLOCKS = List.of("minecraft:air", "minecraft:stone", "minecraft:water", "minecraft:grass_block"); + private static final String UNSUPPORTED_RUNTIME_VERSION = "0.9"; + + private ExportTestPacks() { + } + + public static File basic(File pack) throws IOException { + return withDimension(pack, "{\"regions\":[\"region\"]}"); + } + + public static File withDimension(File pack, String dimensionJson) throws IOException { + CompatTestPlatform.write(pack, "dimensions/" + DIMENSION + ".json", dimensionJson); + CompatTestPlatform.write(pack, "regions/region.json", "{\"landBiomes\":[\"Forest Land\",\"mapped\"],\"seaBiomes\":[\"ocean\"]}"); + CompatTestPlatform.write(pack, "biomes/Forest Land.json", "{\"name\":\"Forest Land\"}"); + CompatTestPlatform.write(pack, "biomes/mapped.json", "{\"name\":\"Mapped\"}"); + CompatTestPlatform.write(pack, "biomes/ocean.json", "{\"name\":\"Ocean\"}"); + return pack; + } + + public static File withImageMaps(File pack) throws IOException { + withDimension(pack, """ + { + "regions": ["region"], + "worldBoundary": {"center": {"x": 0, "z": 0}, "size": 16}, + "imageMaps": [{"key": "terrain", "map": "terrain", "application": "TERRAIN_HEIGHT"}] + } + """); + CompatTestPlatform.write(pack, "biomes/mapped.json", "{\"name\":\"Mapped\",\"childStyle\":{\"imageMap\":\"terrain\"}}"); + CompatTestPlatform.write(pack, "image-maps/terrain.json", """ + {"source": "terrain", "type": "GRAYSCALE_HEIGHT", "origin": {"x": -8, "z": -8}, "outOfBounds": "CLAMP"} + """); + File images = new File(pack, "images"); + if (!images.isDirectory() && !images.mkdirs()) { + throw new IOException("Could not create " + images); + } + if (!ImageIO.write(new BufferedImage(16, 16, BufferedImage.TYPE_BYTE_GRAY), "png", new File(images, "terrain.png"))) { + throw new IOException("No PNG writer available"); + } + return pack; + } + + public static PackCompileContext context(IrisData data, DataVersion target) { + return contextBuilder(data, target).build(); + } + + public static PackCompileContext.Builder contextBuilder(IrisData data, DataVersion target) { + IrisDimension dimension = data.getDimensionLoader().load(DIMENSION, false); + if (dimension == null) { + throw new IllegalStateException("Test pack has no dimension '" + DIMENSION + "'"); + } + return PackCompileContext.builder() + .data(data) + .dimension(dimension) + .capabilities(ExportCapabilities.forTarget(target)) + .namespace(WorldgenExportRequest.DEFAULT_NAMESPACE) + .mode(WorldgenExportMode.PRESET); + } + + public static final class BoundPlatform extends ExternalResource { + private IrisSettings previousSettings; + private CompatTestPlatform platform; + private Path folder; + + @Override + protected void before() throws IOException { + previousSettings = IrisSettings.settings; + IrisSettings.settings = new IrisSettings(); + folder = Files.createTempDirectory("iris-export-platform"); + platform = CompatTestPlatform.bind(folder.toFile(), BLOCKS); + doReturn(UNSUPPORTED_RUNTIME_VERSION).when(IrisPlatforms.get()).minecraftVersion(); + } + + @Override + protected void after() { + platform.unbind(); + IrisSettings.settings = previousSettings; + try (Stream paths = Files.walk(folder)) { + for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) { + Files.deleteIfExists(path); + } + } catch (IOException exception) { + throw new IllegalStateException("Could not remove test platform folder " + folder, exception); + } + } + } +} diff --git a/core/src/test/java/art/arcane/iris/testsupport/CompatTestPlatform.java b/core/src/testFixtures/java/art/arcane/iris/testsupport/CompatTestPlatform.java similarity index 100% rename from core/src/test/java/art/arcane/iris/testsupport/CompatTestPlatform.java rename to core/src/testFixtures/java/art/arcane/iris/testsupport/CompatTestPlatform.java diff --git a/core/src/testFixtures/java/art/arcane/iris/testsupport/FakeVanillaDataSource.java b/core/src/testFixtures/java/art/arcane/iris/testsupport/FakeVanillaDataSource.java new file mode 100644 index 000000000..0b5e87991 --- /dev/null +++ b/core/src/testFixtures/java/art/arcane/iris/testsupport/FakeVanillaDataSource.java @@ -0,0 +1,64 @@ +package art.arcane.iris.testsupport; + +import art.arcane.iris.spi.VanillaDataSource; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.TreeMap; + +public final class FakeVanillaDataSource implements VanillaDataSource { + private final TreeMap files; + + private FakeVanillaDataSource(TreeMap files) { + this.files = files; + } + + public static FakeVanillaDataSource of(Map files) { + TreeMap encoded = new TreeMap<>(); + for (Map.Entry file : files.entrySet()) { + requireDataPath(file.getKey()); + encoded.put(file.getKey(), file.getValue().getBytes(StandardCharsets.UTF_8)); + } + return new FakeVanillaDataSource(encoded); + } + + @Override + public Optional read(String dataPath) { + requireDataPath(dataPath); + byte[] content = files.get(dataPath); + return content == null ? Optional.empty() : Optional.of(content.clone()); + } + + @Override + public List list(String prefix) { + Objects.requireNonNull(prefix, "prefix"); + String directory = prefix.endsWith("/") ? prefix.substring(0, prefix.length() - 1) : prefix; + requireDataPath(directory); + String beneath = directory + "/"; + List paths = new ArrayList<>(); + for (String path : files.tailMap(beneath, true).keySet()) { + if (!path.startsWith(beneath)) { + break; + } + paths.add(path); + } + return List.copyOf(paths); + } + + private static void requireDataPath(String dataPath) { + Objects.requireNonNull(dataPath, "dataPath"); + String[] parts = dataPath.split("/", -1); + if (parts.length < 2 || !"data".equals(parts[0]) || dataPath.indexOf('\\') >= 0) { + throw new IllegalArgumentException("Vanilla data paths have the form data//: " + dataPath); + } + for (String part : parts) { + if (part.isEmpty() || part.equals(".") || part.equals("..")) { + throw new IllegalArgumentException("Vanilla data path has an empty or relative segment: " + dataPath); + } + } + } +} 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 41a6ee845..0acdf5567 100644 --- a/probe/src/main/java/art/arcane/iris/probe/RealPackProbeSupport.java +++ b/probe/src/main/java/art/arcane/iris/probe/RealPackProbeSupport.java @@ -54,7 +54,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.concurrent.locks.LockSupport; -final class RealPackProbeSupport { +public final class RealPackProbeSupport { private static final long WORKER_DRAIN_TIMEOUT_NANOS = TimeUnit.SECONDS.toNanos(120); private static final List REPORTED = Collections.synchronizedList(new ArrayList<>()); @@ -208,7 +208,7 @@ final class RealPackProbeSupport { throw new IllegalArgumentException(name + " must be true or false."); } - static GeneratedChunk generateChunk(Engine engine, int chunkX, int chunkZ) { + public static GeneratedChunk generateChunk(Engine engine, int chunkX, int chunkZ) { int height = engine.getTarget().getHeight(); Hunk blocks = Hunk.newArrayHunk(16, height, 16); Hunk biomes = Hunk.newArrayHunk(16, height, 16); @@ -220,14 +220,14 @@ final class RealPackProbeSupport { return new GeneratedChunk(chunkX, chunkZ, height, blocks, biomes); } - record GeneratedChunk( + public record GeneratedChunk( int chunkX, int chunkZ, int height, Hunk blocks, Hunk biomes ) { - GeneratedChunk { + public GeneratedChunk { if (height < 1 || blocks == null || biomes == null) { throw new IllegalArgumentException("Generated chunk requires buffers and a positive height."); } diff --git a/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java b/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java index effcab185..fe0a0c58c 100644 --- a/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java +++ b/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java @@ -34,6 +34,7 @@ import art.arcane.volmlib.nativelib.item.NativeItem; import art.arcane.iris.spi.PlatformRegistries; import art.arcane.iris.spi.PlatformScheduler; import art.arcane.iris.spi.PlatformStructureHooks; +import art.arcane.iris.spi.VanillaDataSource; import art.arcane.volmlib.nativelib.terrain.NativeWorld; import art.arcane.iris.generation.geometry.IrisBlockVector; @@ -43,6 +44,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Consumer; @@ -88,6 +90,7 @@ public final class StubPlatform implements IrisPlatform { private final StubScheduler scheduler = new StubScheduler(); private final StubStructureHooks structureHooks = new StubStructureHooks(); private final StubBiomeWriter biomeWriter = new StubBiomeWriter(); + private final StubVanillaDataSource vanillaData = new StubVanillaDataSource(); private final File dataFolder; public StubPlatform() { @@ -620,6 +623,18 @@ public final class StubPlatform implements IrisPlatform { } } + private static final class StubVanillaDataSource implements VanillaDataSource { + @Override + public Optional read(String dataPath) { + return Optional.empty(); + } + + @Override + public List list(String prefix) { + return List.of(); + } + } + private record StubEntityType(String key) implements NativeEntityType { @Override public String namespace() { @@ -783,6 +798,11 @@ public final class StubPlatform implements IrisPlatform { return biomeWriter; } + @Override + public VanillaDataSource vanillaData() { + return vanillaData; + } + @Override public File dataFolder() { return dataFolder; diff --git a/probe/src/main/java/art/arcane/iris/probe/fidelity/BiomeShareReport.java b/probe/src/main/java/art/arcane/iris/probe/fidelity/BiomeShareReport.java new file mode 100644 index 000000000..6ec56a4f4 --- /dev/null +++ b/probe/src/main/java/art/arcane/iris/probe/fidelity/BiomeShareReport.java @@ -0,0 +1,136 @@ +package art.arcane.iris.probe.fidelity; + +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.SortedMap; +import java.util.TreeMap; +import java.util.TreeSet; + +public record BiomeShareReport(FidelityReport mean, SortedMap seeds, Map irisShares, + Map exportShares) { + private static final String SEED_HEADER = "seed"; + private static final String PASSING_HEADER = "passing"; + private static final String MISSING = "-"; + + public BiomeShareReport { + Objects.requireNonNull(mean, "mean"); + seeds = Collections.unmodifiableSortedMap(new TreeMap<>(seeds)); + if (seeds.isEmpty()) { + throw new IllegalArgumentException("A biome share report needs at least one seed"); + } + irisShares = Collections.unmodifiableSortedMap(new TreeMap<>(irisShares)); + exportShares = Collections.unmodifiableSortedMap(new TreeMap<>(exportShares)); + } + + public boolean passed() { + return mean.passed(); + } + + public int seedsPassing(String metric) { + int passing = 0; + for (FidelityReport report : seeds.values()) { + FidelityMetric found = find(report, metric); + if (found != null && found.pass()) { + passing++; + } + } + return passing; + } + + public int seedsCompared(String metric) { + int compared = 0; + for (FidelityReport report : seeds.values()) { + if (find(report, metric) != null) { + compared++; + } + } + return compared; + } + + public String toJson() { + JsonArray seedEntries = new JsonArray(seeds.size()); + for (Map.Entry entry : seeds.entrySet()) { + JsonObject seed = entry.getValue().toJsonObject(); + seed.addProperty("seed", entry.getKey()); + seedEntries.add(seed); + } + JsonObject passing = new JsonObject(); + for (String metric : metricNames()) { + JsonObject counts = new JsonObject(); + counts.addProperty("passing", seedsPassing(metric)); + counts.addProperty("compared", seedsCompared(metric)); + passing.add(metric, counts); + } + Set biomes = new TreeSet<>(irisShares.keySet()); + biomes.addAll(exportShares.keySet()); + JsonObject shares = new JsonObject(); + for (String biome : biomes) { + JsonObject share = new JsonObject(); + share.addProperty("iris", irisShares.getOrDefault(biome, 0.0D)); + share.addProperty("export", exportShares.getOrDefault(biome, 0.0D)); + shares.add(biome, share); + } + JsonObject root = new JsonObject(); + root.addProperty("passed", passed()); + root.add("mean", mean.toJsonObject()); + root.add("shares", shares); + root.add("seedsPassing", passing); + root.add("seeds", seedEntries); + return new GsonBuilder().setPrettyPrinting().create().toJson(root); + } + + public String toTable() { + List names = metricNames(); + int width = SEED_HEADER.length(); + for (String name : names) { + width = Math.max(width, name.length()); + } + StringBuilder table = new StringBuilder(); + table.append(String.format(Locale.ROOT, "%-" + width + "s", SEED_HEADER)); + for (long seed : seeds.keySet()) { + table.append(String.format(Locale.ROOT, " %10s", seed)); + } + table.append(String.format(Locale.ROOT, " %8s\n", PASSING_HEADER)); + for (String name : names) { + table.append(String.format(Locale.ROOT, "%-" + width + "s", name)); + for (FidelityReport report : seeds.values()) { + FidelityMetric metric = find(report, name); + table.append(metric == null ? String.format(Locale.ROOT, " %10s", MISSING) : String.format(Locale.ROOT, " %10.4f", metric.delta())); + } + table.append(String.format(Locale.ROOT, " %8s\n", seedsPassing(name) + "/" + seedsCompared(name))); + } + return table.append(mean.toTable()).toString(); + } + + private List metricNames() { + Set names = new LinkedHashSet<>(); + for (FidelityMetric metric : mean.metrics()) { + names.add(metric.name()); + } + for (FidelityReport report : seeds.values()) { + for (FidelityMetric metric : report.metrics()) { + names.add(metric.name()); + } + } + return new ArrayList<>(names); + } + + private static FidelityMetric find(FidelityReport report, String name) { + for (FidelityMetric metric : report.metrics()) { + if (metric.name().equals(name)) { + return metric; + } + } + return null; + } +} diff --git a/probe/src/main/java/art/arcane/iris/probe/fidelity/BiomeTypes.java b/probe/src/main/java/art/arcane/iris/probe/fidelity/BiomeTypes.java new file mode 100644 index 000000000..90586a754 --- /dev/null +++ b/probe/src/main/java/art/arcane/iris/probe/fidelity/BiomeTypes.java @@ -0,0 +1,57 @@ +package art.arcane.iris.probe.fidelity; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.terrain.IrisRegion; + +import java.util.ArrayDeque; +import java.util.Collections; +import java.util.Deque; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.SortedMap; +import java.util.SortedSet; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.function.Function; + +public final class BiomeTypes { + private static final String LAND = "land"; + private static final String SEA = "sea"; + private static final String SHORE = "shore"; + private static final String JOINER = "+"; + + private BiomeTypes() { + } + + public static SortedMap of(List regions, Function biomes) { + SortedMap> types = new TreeMap<>(); + for (IrisRegion region : regions) { + visit(region.getLandBiomes(), LAND, biomes, types); + visit(region.getSeaBiomes(), SEA, biomes, types); + visit(region.getShoreBiomes(), SHORE, biomes, types); + } + SortedMap joined = new TreeMap<>(); + for (Map.Entry> entry : types.entrySet()) { + joined.put(entry.getKey(), String.join(JOINER, entry.getValue())); + } + return Collections.unmodifiableSortedMap(joined); + } + + private static void visit(List roots, String type, Function biomes, SortedMap> types) { + Deque pending = new ArrayDeque<>(roots); + Set seen = new HashSet<>(); + while (!pending.isEmpty()) { + String key = pending.pop(); + if (!seen.add(key)) { + continue; + } + types.computeIfAbsent(key, ignored -> new TreeSet<>()).add(type); + IrisBiome biome = biomes.apply(key); + if (biome != null) { + pending.addAll(biome.getChildren()); + } + } + } +} diff --git a/probe/src/main/java/art/arcane/iris/probe/fidelity/CaveWalls.java b/probe/src/main/java/art/arcane/iris/probe/fidelity/CaveWalls.java new file mode 100644 index 000000000..01bbf07a4 --- /dev/null +++ b/probe/src/main/java/art/arcane/iris/probe/fidelity/CaveWalls.java @@ -0,0 +1,23 @@ +package art.arcane.iris.probe.fidelity; + +final class CaveWalls { + private static final int CHUNK_WIDTH = 16; + private static final int[][] SIDES = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; + + private CaveWalls() { + } + + static boolean besideCave(FidelityChunk chunk, int localX, int y, int localZ) { + for (int[] side : SIDES) { + int x = localX + side[0]; + int z = localZ + side[1]; + if (x < 0 || x >= CHUNK_WIDTH || z < 0 || z >= CHUNK_WIDTH) { + continue; + } + if (y < chunk.surfaceY(x, z) && (chunk.open(x, y, z) || chunk.fluid(x, y, z))) { + return true; + } + } + return false; + } +} diff --git a/probe/src/main/java/art/arcane/iris/probe/fidelity/ColumnSource.java b/probe/src/main/java/art/arcane/iris/probe/fidelity/ColumnSource.java new file mode 100644 index 000000000..29952a04b --- /dev/null +++ b/probe/src/main/java/art/arcane/iris/probe/fidelity/ColumnSource.java @@ -0,0 +1,11 @@ +package art.arcane.iris.probe.fidelity; + +public interface ColumnSource extends AutoCloseable { + String label(); + + int minY(); + + int maxY(); + + FidelityColumn column(int x, int z) throws Exception; +} diff --git a/probe/src/main/java/art/arcane/iris/probe/fidelity/ColumnSurvey.java b/probe/src/main/java/art/arcane/iris/probe/fidelity/ColumnSurvey.java new file mode 100644 index 000000000..94e8c3334 --- /dev/null +++ b/probe/src/main/java/art/arcane/iris/probe/fidelity/ColumnSurvey.java @@ -0,0 +1,81 @@ +package art.arcane.iris.probe.fidelity; + +import art.arcane.iris.generation.validation.ChunkSpiralScan; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicReference; + +public final class ColumnSurvey { + private ColumnSurvey() { + } + + public static FidelityStats scan(ColumnSource source, SurveyWindow window, int threads) { + Objects.requireNonNull(source, "source"); + Objects.requireNonNull(window, "window"); + List rows = new ArrayList<>(window.perAxis()); + for (int row = 0; row < window.perAxis(); row++) { + rows.add(new int[]{row, 0}); + } + Accumulator accumulator = new Accumulator(source, window); + int failures = runScan(source, rows, threads, accumulator); + if (failures > 0) { + throw new IllegalStateException(failures + " of " + rows.size() + " survey rows could not be sampled from " + + source.label(), accumulator.firstFailure()); + } + return accumulator.stats(); + } + + private static int runScan(ColumnSource source, List rows, int threads, Accumulator accumulator) { + try { + return ChunkSpiralScan.scan(rows, threads, accumulator::sample, accumulator); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Column survey of " + source.label() + " was interrupted", e); + } + } + + private static final class Accumulator implements ChunkSpiralScan.Listener { + private final ColumnSource source; + private final SurveyWindow window; + private final AtomicReference firstFailure = new AtomicReference<>(); + private FidelityStats stats; + + private Accumulator(ColumnSource source, SurveyWindow window) { + this.source = source; + this.window = window; + this.stats = new FidelityStats(source.minY(), source.maxY()); + } + + @Override + public void chunkDone(int row, int ignored, boolean ok) { + } + + @Override + public void chunkFailed(int row, int ignored, Throwable error) { + firstFailure.compareAndSet(null, error); + } + + private void sample(int row, int ignored) throws Exception { + FidelityStats rowStats = new FidelityStats(source.minY(), source.maxY()); + int x = window.coordinate(window.centerX(), row); + for (int column = 0; column < window.perAxis(); column++) { + rowStats.accept(source.column(x, window.coordinate(window.centerZ(), column))); + } + merge(rowStats); + } + + private synchronized void merge(FidelityStats rowStats) { + stats = stats.merge(rowStats); + } + + private synchronized FidelityStats stats() { + return stats; + } + + private Throwable firstFailure() { + return firstFailure.get(); + } + } +} diff --git a/probe/src/main/java/art/arcane/iris/probe/fidelity/FidelityBlocks.java b/probe/src/main/java/art/arcane/iris/probe/fidelity/FidelityBlocks.java new file mode 100644 index 000000000..e908681a6 --- /dev/null +++ b/probe/src/main/java/art/arcane/iris/probe/fidelity/FidelityBlocks.java @@ -0,0 +1,44 @@ +package art.arcane.iris.probe.fidelity; + +import java.util.List; +import java.util.Set; + +final class FidelityBlocks { + static final String AIR = "minecraft:air"; + static final String LAVA = "minecraft:lava"; + + private static final Set OPEN = Set.of(AIR, "minecraft:cave_air", "minecraft:void_air"); + private static final Set FLUID = Set.of("minecraft:water", LAVA, "minecraft:bubble_column", + "minecraft:kelp", "minecraft:kelp_plant", "minecraft:seagrass", "minecraft:tall_seagrass"); + private static final List DECORATION_SUFFIXES = List.of("_log", "_wood", "_leaves", "_stem", "_hyphae", + "_mushroom_block", "_wart_block"); + + private FidelityBlocks() { + } + + static String id(String stateKey) { + int properties = stateKey.indexOf('['); + return properties < 0 ? stateKey : stateKey.substring(0, properties); + } + + static boolean open(String id) { + return OPEN.contains(id); + } + + static boolean lava(String id) { + return LAVA.equals(id); + } + + static boolean fluid(String id) { + return FLUID.contains(id); + } + + static boolean decoration(String id) { + for (String suffix : DECORATION_SUFFIXES) { + if (id.endsWith(suffix)) { + return true; + } + } + return false; + } +} diff --git a/probe/src/main/java/art/arcane/iris/probe/fidelity/FidelityChunk.java b/probe/src/main/java/art/arcane/iris/probe/fidelity/FidelityChunk.java new file mode 100644 index 000000000..f271554c4 --- /dev/null +++ b/probe/src/main/java/art/arcane/iris/probe/fidelity/FidelityChunk.java @@ -0,0 +1,34 @@ +package art.arcane.iris.probe.fidelity; + +public interface FidelityChunk { + int surfaceY(int localX, int localZ); + + String surfaceBiome(int localX, int localZ); + + boolean open(int localX, int y, int localZ); + + boolean fluid(int localX, int y, int localZ); + + String block(int localX, int y, int localZ); + + default boolean lava(int localX, int y, int localZ) { + String id = block(localX, y, localZ); + return id != null && FidelityBlocks.lava(id); + } + + default boolean terrainMass(int localX, int y, int localZ) { + return true; + } + + default boolean river(int localX, int localZ) { + return false; + } + + default boolean wallsKnown() { + return false; + } + + default boolean wall(int localX, int y, int localZ) { + return false; + } +} diff --git a/probe/src/main/java/art/arcane/iris/probe/fidelity/FidelityColumn.java b/probe/src/main/java/art/arcane/iris/probe/fidelity/FidelityColumn.java new file mode 100644 index 000000000..f6799c19c --- /dev/null +++ b/probe/src/main/java/art/arcane/iris/probe/fidelity/FidelityColumn.java @@ -0,0 +1,4 @@ +package art.arcane.iris.probe.fidelity; + +public record FidelityColumn(String biome, int terrainY, boolean wet) { +} diff --git a/probe/src/main/java/art/arcane/iris/probe/fidelity/FidelityComparison.java b/probe/src/main/java/art/arcane/iris/probe/fidelity/FidelityComparison.java new file mode 100644 index 000000000..03d012c2f --- /dev/null +++ b/probe/src/main/java/art/arcane/iris/probe/fidelity/FidelityComparison.java @@ -0,0 +1,225 @@ +package art.arcane.iris.probe.fidelity; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.SortedMap; +import java.util.TreeMap; +import java.util.TreeSet; + +public final class FidelityComparison { + private static final double MIN_BIOME_AREA = 0.02D; + private static final List HEIGHT_PERCENTILES = List.of(10, 50, 90); + private static final long MIN_BAND_COLUMNS = 64L; + private static final String UNKNOWN_TYPE = "unknown"; + + private FidelityComparison() { + } + + public static FidelityReport compare(FidelitySamples columns, FidelitySamples chunks, FidelityThresholds thresholds) { + Objects.requireNonNull(columns, "columns"); + Objects.requireNonNull(chunks, "chunks"); + Objects.requireNonNull(thresholds, "thresholds"); + List metrics = new ArrayList<>(); + addBiomeMetrics(columns.iris().biomeShares(), columns.export().biomeShares(), thresholds.biomeShare(), metrics); + addHeightMetrics(columns.iris(), columns.export(), thresholds.heightQuantile(), metrics); + List observations = new ArrayList<>(); + addCaveMetrics(chunks.iris(), chunks.export(), thresholds.caveBand(), metrics, observations); + addWallMetric(chunks.iris(), chunks.export(), thresholds.wallShare(), metrics); + addDecorationMetric(chunks.iris(), chunks.export(), thresholds.objectDensity(), metrics); + addLavaObservation(chunks.iris(), chunks.export(), observations); + addOneBlockWallObservation(chunks.iris(), chunks.export(), observations); + return new FidelityReport(metrics, List.of(columns.sampling(), chunks.sampling()), observations); + } + + public static BiomeShareReport compareBiomeShares(List seeds, Map biomeTypes, FidelityThresholds thresholds) { + Objects.requireNonNull(seeds, "seeds"); + Objects.requireNonNull(biomeTypes, "biomeTypes"); + Objects.requireNonNull(thresholds, "thresholds"); + if (seeds.isEmpty()) { + throw new IllegalArgumentException("Biome shares need at least one surveyed seed"); + } + SortedMap reports = new TreeMap<>(); + List> irisShares = new ArrayList<>(seeds.size()); + List> exportShares = new ArrayList<>(seeds.size()); + double irisLand = 0.0D; + double exportLand = 0.0D; + List seedList = new ArrayList<>(seeds.size()); + long columns = 0L; + for (FidelitySamples seed : seeds) { + Map iris = seed.iris().biomeShares(); + Map export = seed.export().biomeShares(); + irisShares.add(iris); + exportShares.add(export); + double seedIrisLand = landFraction(seed.iris()); + double seedExportLand = landFraction(seed.export()); + irisLand += seedIrisLand; + exportLand += seedExportLand; + seedList.addAll(seed.sampling().seeds()); + columns += seed.sampling().columns(); + reports.put(seed.sampling().seeds().getFirst(), biomeReport(iris, export, seedIrisLand, seedExportLand, biomeTypes, List.of(seed.sampling()), + thresholds)); + } + FidelitySampling first = seeds.getFirst().sampling(); + FidelitySampling pooled = new FidelitySampling(first.scope(), first.windowBlocks(), first.strideBlocks(), seedList, columns); + Map irisMean = mean(irisShares); + Map exportMean = mean(exportShares); + FidelityReport mean = biomeReport(irisMean, exportMean, irisLand / seeds.size(), exportLand / seeds.size(), biomeTypes, List.of(pooled), + thresholds); + return new BiomeShareReport(mean, reports, irisMean, exportMean); + } + + private static FidelityReport biomeReport(Map irisShares, Map exportShares, double irisLand, double exportLand, + Map biomeTypes, List sampling, FidelityThresholds thresholds) { + List metrics = new ArrayList<>(); + addBiomeMetrics(irisShares, exportShares, thresholds.biomeShare(), metrics); + List observations = new ArrayList<>(); + Map irisTypes = typeShares(irisShares, biomeTypes); + Map exportTypes = typeShares(exportShares, biomeTypes); + Set types = new TreeSet<>(irisTypes.keySet()); + types.addAll(exportTypes.keySet()); + for (String type : types) { + observations.add(new FidelityObservation("biome.type." + type, irisTypes.getOrDefault(type, 0.0D), exportTypes.getOrDefault(type, 0.0D))); + } + observations.add(new FidelityObservation("height.landFraction", irisLand, exportLand)); + return new FidelityReport(metrics, sampling, observations); + } + + private static double landFraction(FidelityStats stats) { + return stats.heightColumns() == 0L ? 0.0D : stats.landFraction(); + } + + private static Map mean(List> shares) { + SortedMap mean = new TreeMap<>(); + for (Map seed : shares) { + for (Map.Entry entry : seed.entrySet()) { + mean.merge(entry.getKey(), entry.getValue() / shares.size(), Double::sum); + } + } + return mean; + } + + private static Map typeShares(Map shares, Map biomeTypes) { + SortedMap types = new TreeMap<>(); + for (Map.Entry entry : shares.entrySet()) { + types.merge(biomeTypes.getOrDefault(entry.getKey(), UNKNOWN_TYPE), entry.getValue(), Double::sum); + } + return types; + } + + private static void addBiomeMetrics(Map irisShares, Map exportShares, double threshold, List metrics) { + if (irisShares.isEmpty() || exportShares.isEmpty()) { + return; + } + Set biomes = new TreeSet<>(irisShares.keySet()); + biomes.addAll(exportShares.keySet()); + double difference = 0.0D; + for (String biome : biomes) { + difference += Math.abs(exportShares.getOrDefault(biome, 0.0D) - irisShares.getOrDefault(biome, 0.0D)); + } + double distance = difference / 2.0D; + metrics.add(new FidelityMetric("biome.totalVariation", 0.0D, distance, distance, distance <= threshold)); + for (Map.Entry entry : irisShares.entrySet()) { + double share = entry.getValue(); + if (share < MIN_BIOME_AREA) { + continue; + } + double exported = exportShares.getOrDefault(entry.getKey(), 0.0D); + double error = relativeError(share, exported); + metrics.add(new FidelityMetric("biome.share." + entry.getKey(), share, exported, error, error <= threshold)); + } + } + + private static void addHeightMetrics(FidelityStats iris, FidelityStats export, double threshold, List metrics) { + if (iris.heightColumns() == 0L) { + return; + } + if (export.heightColumns() == 0L) { + metrics.add(new FidelityMetric("height.terrainColumns", iris.heightColumns(), 0.0D, 1.0D, false)); + return; + } + double range = iris.maxY() - iris.minY(); + for (int percentile : HEIGHT_PERCENTILES) { + double irisY = iris.heightQuantile(percentile / 100.0D); + double exportY = export.heightQuantile(percentile / 100.0D); + double delta = Math.abs(exportY - irisY) / range; + metrics.add(new FidelityMetric("height.p" + percentile, irisY, exportY, delta, delta <= threshold)); + } + double irisLand = iris.landFraction(); + double exportLand = export.landFraction(); + double landDelta = Math.abs(exportLand - irisLand); + metrics.add(new FidelityMetric("height.landFraction", irisLand, exportLand, landDelta, landDelta <= threshold)); + } + + private static void addCaveMetrics(FidelityStats iris, FidelityStats export, double threshold, List metrics, + List observations) { + long[] irisVoxels = iris.subsurfaceVoxels(); + long[] exportVoxels = export.subsurfaceVoxels(); + long[] irisVoids = iris.voidVoxels(); + long[] exportVoids = export.voidVoxels(); + long minimum = MIN_BAND_COLUMNS * FidelityStats.BAND_HEIGHT; + for (int band = 0; band < irisVoxels.length; band++) { + if (irisVoxels[band] == 0L || exportVoxels[band] == 0L) { + continue; + } + boolean judged = irisVoxels[band] >= minimum && exportVoxels[band] >= minimum; + addBandShare("cave.band." + iris.bandMinY(band), iris.caveShare(band), export.caveShare(band), judged, threshold, metrics, observations); + if (irisVoids[band] > 0L || exportVoids[band] > 0L) { + addBandShare("void.band." + iris.bandMinY(band), iris.voidShare(band), export.voidShare(band), judged, threshold, metrics, observations); + } + } + } + + private static void addBandShare(String name, double irisShare, double exportShare, boolean judged, double threshold, List metrics, + List observations) { + if (!judged) { + observations.add(new FidelityObservation(name, irisShare, exportShare)); + return; + } + double delta = Math.abs(exportShare - irisShare); + metrics.add(new FidelityMetric(name, irisShare, exportShare, delta, delta <= threshold)); + } + + private static void addWallMetric(FidelityStats iris, FidelityStats export, double threshold, List metrics) { + if (iris.wallSolidVoxels() == 0L || export.wallSolidVoxels() == 0L) { + return; + } + double irisShare = iris.wallShare(); + double exportShare = export.wallShare(); + double error = relativeError(irisShare, exportShare); + metrics.add(new FidelityMetric("cave.wallShare", irisShare, exportShare, error, error <= threshold)); + } + + private static void addLavaObservation(FidelityStats iris, FidelityStats export, List observations) { + if (iris.deepVoxels() == 0L || export.deepVoxels() == 0L) { + return; + } + observations.add(new FidelityObservation("cave.lavaShare.belowY" + FidelityStats.DEEP_LAVA_LINE, iris.deepLavaShare(), export.deepLavaShare())); + } + + private static void addOneBlockWallObservation(FidelityStats iris, FidelityStats export, List observations) { + if (iris.wallSolidVoxels() == 0L || export.wallSolidVoxels() == 0L) { + return; + } + observations.add(new FidelityObservation("cave.wallShare.oneBlock", iris.oneBlockWallShare(), export.oneBlockWallShare())); + } + + private static void addDecorationMetric(FidelityStats iris, FidelityStats export, double threshold, List metrics) { + if (iris.blockColumns() == 0L || export.blockColumns() == 0L) { + return; + } + double irisDensity = iris.decorationPerSquareKilometre(); + double exportDensity = export.decorationPerSquareKilometre(); + double error = relativeError(irisDensity, exportDensity); + metrics.add(new FidelityMetric("decoration.columnsPerKm2", irisDensity, exportDensity, error, error <= threshold)); + } + + private static double relativeError(double reference, double measured) { + if (reference == 0.0D) { + return measured == 0.0D ? 0.0D : 1.0D; + } + return Math.abs(measured - reference) / reference; + } +} diff --git a/probe/src/main/java/art/arcane/iris/probe/fidelity/FidelityMetric.java b/probe/src/main/java/art/arcane/iris/probe/fidelity/FidelityMetric.java new file mode 100644 index 000000000..e6e59a876 --- /dev/null +++ b/probe/src/main/java/art/arcane/iris/probe/fidelity/FidelityMetric.java @@ -0,0 +1,13 @@ +package art.arcane.iris.probe.fidelity; + +import java.util.Objects; + +public record FidelityMetric(String name, double iris, double export, double delta, boolean pass) { + public FidelityMetric { + Objects.requireNonNull(name, "name"); + if (!Double.isFinite(iris) || !Double.isFinite(export) || !Double.isFinite(delta)) { + throw new IllegalArgumentException("Fidelity metric " + name + " has a non-finite value: iris=" + iris + + " export=" + export + " delta=" + delta); + } + } +} diff --git a/probe/src/main/java/art/arcane/iris/probe/fidelity/FidelityObservation.java b/probe/src/main/java/art/arcane/iris/probe/fidelity/FidelityObservation.java new file mode 100644 index 000000000..12732c4cb --- /dev/null +++ b/probe/src/main/java/art/arcane/iris/probe/fidelity/FidelityObservation.java @@ -0,0 +1,12 @@ +package art.arcane.iris.probe.fidelity; + +import java.util.Objects; + +public record FidelityObservation(String name, double iris, double export) { + public FidelityObservation { + Objects.requireNonNull(name, "name"); + if (!Double.isFinite(iris) || !Double.isFinite(export)) { + throw new IllegalArgumentException("Fidelity observation " + name + " has a non-finite value: iris=" + iris + " export=" + export); + } + } +} diff --git a/probe/src/main/java/art/arcane/iris/probe/fidelity/FidelityReport.java b/probe/src/main/java/art/arcane/iris/probe/fidelity/FidelityReport.java new file mode 100644 index 000000000..612bb36e7 --- /dev/null +++ b/probe/src/main/java/art/arcane/iris/probe/fidelity/FidelityReport.java @@ -0,0 +1,111 @@ +package art.arcane.iris.probe.fidelity; + +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import java.util.List; +import java.util.Locale; + +public record FidelityReport(List metrics, List sampling, List observations) { + private static final String NAME_HEADER = "metric"; + private static final String OBSERVATION_VERDICT = "INFO"; + + public FidelityReport { + metrics = List.copyOf(metrics); + sampling = List.copyOf(sampling); + observations = List.copyOf(observations); + } + + public boolean passed() { + if (metrics.isEmpty()) { + return false; + } + for (FidelityMetric metric : metrics) { + if (!metric.pass()) { + return false; + } + } + return true; + } + + public String toJson() { + return new GsonBuilder().setPrettyPrinting().create().toJson(toJsonObject()); + } + + public JsonObject toJsonObject() { + JsonArray entries = new JsonArray(metrics.size()); + for (FidelityMetric metric : metrics) { + JsonObject entry = new JsonObject(); + entry.addProperty("name", metric.name()); + entry.addProperty("iris", metric.iris()); + entry.addProperty("export", metric.export()); + entry.addProperty("delta", metric.delta()); + entry.addProperty("pass", metric.pass()); + entries.add(entry); + } + JsonArray samplingEntries = new JsonArray(sampling.size()); + for (FidelitySampling scope : sampling) { + JsonObject entry = new JsonObject(); + entry.addProperty("scope", scope.scope()); + entry.addProperty("windowBlocks", scope.windowBlocks()); + entry.addProperty("strideBlocks", scope.strideBlocks()); + JsonArray seeds = new JsonArray(scope.seeds().size()); + for (long seed : scope.seeds()) { + seeds.add(seed); + } + entry.add("seeds", seeds); + entry.addProperty("columns", scope.columns()); + samplingEntries.add(entry); + } + JsonArray notes = new JsonArray(observations.size()); + for (FidelityObservation observation : observations) { + JsonObject entry = new JsonObject(); + entry.addProperty("name", observation.name()); + entry.addProperty("iris", observation.iris()); + entry.addProperty("export", observation.export()); + notes.add(entry); + } + JsonObject root = new JsonObject(); + root.addProperty("passed", passed()); + root.add("sampling", samplingEntries); + root.add("metrics", entries); + root.add("observations", notes); + return root; + } + + public String toTable() { + int width = NAME_HEADER.length(); + for (FidelityMetric metric : metrics) { + width = Math.max(width, metric.name().length()); + } + for (FidelityObservation observation : observations) { + width = Math.max(width, observation.name().length()); + } + String header = "%-" + width + "s %12s %12s %10s %s\n"; + String row = "%-" + width + "s %12.4f %12.4f %10.4f %s\n"; + StringBuilder table = new StringBuilder(); + for (FidelitySampling scope : sampling) { + table.append(String.format(Locale.ROOT, "sampling %s window=%d stride=%d seeds=%s columns=%d\n", scope.scope(), + scope.windowBlocks(), scope.strideBlocks(), scope.seeds(), scope.columns())); + } + table.append(String.format(Locale.ROOT, header, NAME_HEADER, "iris", "export", "delta", "result")); + int passing = 0; + for (FidelityMetric metric : metrics) { + if (metric.pass()) { + passing++; + } + table.append(String.format(Locale.ROOT, row, metric.name(), metric.iris(), metric.export(), metric.delta(), + metric.pass() ? "PASS" : "FAIL")); + } + String note = "%-" + width + "s %12.4f %12.4f %10s %s\n"; + for (FidelityObservation observation : observations) { + table.append(String.format(Locale.ROOT, note, observation.name(), observation.iris(), observation.export(), "", OBSERVATION_VERDICT)); + } + if (metrics.isEmpty()) { + return table.append("overall FAIL: no metrics were compared\n").toString(); + } + return table.append("overall ").append(passed() ? "PASS" : "FAIL").append(": ").append(passing).append(" of ") + .append(metrics.size()).append(" metrics passed\n").toString(); + } +} diff --git a/probe/src/main/java/art/arcane/iris/probe/fidelity/FidelitySamples.java b/probe/src/main/java/art/arcane/iris/probe/fidelity/FidelitySamples.java new file mode 100644 index 000000000..4dad90574 --- /dev/null +++ b/probe/src/main/java/art/arcane/iris/probe/fidelity/FidelitySamples.java @@ -0,0 +1,15 @@ +package art.arcane.iris.probe.fidelity; + +import java.util.Objects; + +public record FidelitySamples(FidelityStats iris, FidelityStats export, FidelitySampling sampling) { + public FidelitySamples { + Objects.requireNonNull(iris, "iris"); + Objects.requireNonNull(export, "export"); + Objects.requireNonNull(sampling, "sampling"); + if (iris.minY() != export.minY() || iris.maxY() != export.maxY()) { + throw new IllegalArgumentException("Iris height range [" + iris.minY() + ", " + iris.maxY() + + ") differs from export height range [" + export.minY() + ", " + export.maxY() + ")"); + } + } +} diff --git a/probe/src/main/java/art/arcane/iris/probe/fidelity/FidelitySampling.java b/probe/src/main/java/art/arcane/iris/probe/fidelity/FidelitySampling.java new file mode 100644 index 000000000..1076f523e --- /dev/null +++ b/probe/src/main/java/art/arcane/iris/probe/fidelity/FidelitySampling.java @@ -0,0 +1,14 @@ +package art.arcane.iris.probe.fidelity; + +import java.util.List; +import java.util.Objects; + +public record FidelitySampling(String scope, int windowBlocks, int strideBlocks, List seeds, long columns) { + public FidelitySampling { + Objects.requireNonNull(scope, "scope"); + seeds = List.copyOf(seeds); + if (seeds.isEmpty()) { + throw new IllegalArgumentException("Fidelity sampling '" + scope + "' lists no seed"); + } + } +} diff --git a/probe/src/main/java/art/arcane/iris/probe/fidelity/FidelityScanner.java b/probe/src/main/java/art/arcane/iris/probe/fidelity/FidelityScanner.java new file mode 100644 index 000000000..860a38564 --- /dev/null +++ b/probe/src/main/java/art/arcane/iris/probe/fidelity/FidelityScanner.java @@ -0,0 +1,75 @@ +package art.arcane.iris.probe.fidelity; + +import art.arcane.iris.generation.validation.ChunkSpiralScan; +import art.arcane.volmlib.util.math.ChunkSpiral; + +import java.util.List; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicReference; + +public final class FidelityScanner { + private FidelityScanner() { + } + + public static FidelityStats scan(FidelitySource source, int centerChunkX, int centerChunkZ, int radiusChunks, int stride, int threads) { + Objects.requireNonNull(source, "source"); + FidelityStats.requireStride(stride); + List targets = ChunkSpiral.centerOut(centerChunkX, centerChunkZ, Math.max(0, radiusChunks)); + Accumulator accumulator = new Accumulator(source, stride); + int failures = runScan(source, targets, threads, accumulator); + if (failures > 0) { + throw new IllegalStateException(failures + " of " + targets.size() + " chunks could not be sampled from " + + source.label(), accumulator.firstFailure()); + } + return accumulator.stats(); + } + + private static int runScan(FidelitySource source, List targets, int threads, Accumulator accumulator) { + try { + return ChunkSpiralScan.scan(targets, threads, accumulator::sample, accumulator); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Fidelity scan of " + source.label() + " was interrupted", e); + } + } + + private static final class Accumulator implements ChunkSpiralScan.Listener { + private final FidelitySource source; + private final int stride; + private final AtomicReference firstFailure = new AtomicReference<>(); + private FidelityStats stats; + + private Accumulator(FidelitySource source, int stride) { + this.source = source; + this.stride = stride; + this.stats = new FidelityStats(source.minY(), source.maxY()); + } + + @Override + public void chunkDone(int chunkX, int chunkZ, boolean ok) { + } + + @Override + public void chunkFailed(int chunkX, int chunkZ, Throwable error) { + firstFailure.compareAndSet(null, error); + } + + private void sample(int chunkX, int chunkZ) throws Exception { + FidelityStats chunkStats = new FidelityStats(source.minY(), source.maxY()); + chunkStats.accept(source.sample(chunkX, chunkZ), stride); + merge(chunkStats); + } + + private synchronized void merge(FidelityStats chunkStats) { + stats = stats.merge(chunkStats); + } + + private synchronized FidelityStats stats() { + return stats; + } + + private Throwable firstFailure() { + return firstFailure.get(); + } + } +} diff --git a/probe/src/main/java/art/arcane/iris/probe/fidelity/FidelitySource.java b/probe/src/main/java/art/arcane/iris/probe/fidelity/FidelitySource.java new file mode 100644 index 000000000..f8b4c258f --- /dev/null +++ b/probe/src/main/java/art/arcane/iris/probe/fidelity/FidelitySource.java @@ -0,0 +1,11 @@ +package art.arcane.iris.probe.fidelity; + +public interface FidelitySource extends AutoCloseable { + String label(); + + int minY(); + + int maxY(); + + FidelityChunk sample(int chunkX, int chunkZ) throws Exception; +} diff --git a/probe/src/main/java/art/arcane/iris/probe/fidelity/FidelityStats.java b/probe/src/main/java/art/arcane/iris/probe/fidelity/FidelityStats.java new file mode 100644 index 000000000..36e4726cd --- /dev/null +++ b/probe/src/main/java/art/arcane/iris/probe/fidelity/FidelityStats.java @@ -0,0 +1,390 @@ +package art.arcane.iris.probe.fidelity; + +import java.util.Collections; +import java.util.Map; +import java.util.Objects; +import java.util.SortedMap; +import java.util.TreeMap; + +public final class FidelityStats { + public static final int DEEP_LAVA_LINE = -54; + private static final int CHUNK_WIDTH = 16; + static final int BAND_HEIGHT = 16; + private static final int MIN_ROOF_THICKNESS = 4; + private static final double SQUARE_METRES_PER_SQUARE_KILOMETRE = 1_000_000.0D; + + private final int minY; + private final int maxY; + private final long[] heightHistogram; + private final long[] subsurfaceVoxels; + private final long[] caveVoxels; + private final long[] voidVoxels; + private final SortedMap biomeColumns; + private long columns; + private long heightColumns; + private long fluidColumns; + private long blockColumns; + private long decorationColumns; + private long riverColumns; + private long deepVoxels; + private long deepLavaVoxels; + private long wallSolidVoxels; + private long wallVoxels; + private long oneBlockWallVoxels; + + public FidelityStats(int minY, int maxY) { + if (maxY <= minY) { + throw new IllegalArgumentException("Fidelity height range [" + minY + ", " + maxY + ") is empty"); + } + this.minY = minY; + this.maxY = maxY; + int height = maxY - minY; + int bands = (height + BAND_HEIGHT - 1) / BAND_HEIGHT; + this.heightHistogram = new long[height]; + this.subsurfaceVoxels = new long[bands]; + this.caveVoxels = new long[bands]; + this.voidVoxels = new long[bands]; + this.biomeColumns = new TreeMap<>(); + } + + public void accept(FidelityChunk chunk, int stride) { + Objects.requireNonNull(chunk, "chunk"); + requireStride(stride); + for (int x = 0; x < CHUNK_WIDTH; x += stride) { + for (int z = 0; z < CHUNK_WIDTH; z += stride) { + acceptColumn(chunk, x, z); + } + } + } + + public void accept(FidelityColumn column) { + Objects.requireNonNull(column, "column"); + columns++; + if (column.biome() != null) { + biomeColumns.merge(column.biome(), 1L, Long::sum); + } + int terrainY = column.terrainY(); + if (terrainY >= maxY) { + throw new IllegalArgumentException("Terrain Y " + terrainY + " is above the height range [" + minY + ", " + maxY + ")"); + } + if (terrainY < minY) { + return; + } + heightColumns++; + heightHistogram[terrainY - minY]++; + if (column.wet()) { + fluidColumns++; + } + } + + public FidelityStats merge(FidelityStats other) { + Objects.requireNonNull(other, "other"); + if (other.minY != minY || other.maxY != maxY) { + throw new IllegalArgumentException("Cannot merge fidelity stats over [" + minY + ", " + maxY + ") with [" + + other.minY + ", " + other.maxY + ")"); + } + FidelityStats merged = new FidelityStats(minY, maxY); + merged.add(this); + merged.add(other); + return merged; + } + + public int minY() { + return minY; + } + + public int maxY() { + return maxY; + } + + public long columns() { + return columns; + } + + public long heightColumns() { + return heightColumns; + } + + public long fluidColumns() { + return fluidColumns; + } + + public long blockColumns() { + return blockColumns; + } + + public long decorationColumns() { + return decorationColumns; + } + + public long riverColumns() { + return riverColumns; + } + + public long deepVoxels() { + return deepVoxels; + } + + public long deepLavaVoxels() { + return deepLavaVoxels; + } + + public long wallSolidVoxels() { + return wallSolidVoxels; + } + + public long wallVoxels() { + return wallVoxels; + } + + public long oneBlockWallVoxels() { + return oneBlockWallVoxels; + } + + public Map biomeColumns() { + return Collections.unmodifiableSortedMap(biomeColumns); + } + + public long[] heightHistogram() { + return heightHistogram.clone(); + } + + public long[] subsurfaceVoxels() { + return subsurfaceVoxels.clone(); + } + + public long[] caveVoxels() { + return caveVoxels.clone(); + } + + public long[] voidVoxels() { + return voidVoxels.clone(); + } + + public int bandCount() { + return subsurfaceVoxels.length; + } + + public int bandMinY(int band) { + return minY + band * BAND_HEIGHT; + } + + public Map biomeShares() { + long resolved = 0L; + for (long count : biomeColumns.values()) { + resolved += count; + } + SortedMap shares = new TreeMap<>(); + for (Map.Entry entry : biomeColumns.entrySet()) { + shares.put(entry.getKey(), entry.getValue() / (double) resolved); + } + return Collections.unmodifiableSortedMap(shares); + } + + public double heightQuantile(double fraction) { + if (heightColumns == 0L) { + throw new IllegalStateException("No terrain columns were sampled"); + } + if (!(fraction >= 0.0D && fraction <= 1.0D)) { + throw new IllegalArgumentException("Quantile fraction must be within [0, 1], got " + fraction); + } + long rank = Math.max(1L, (long) Math.ceil(fraction * heightColumns)); + long seen = 0L; + for (int index = 0; index < heightHistogram.length; index++) { + seen += heightHistogram[index]; + if (seen >= rank) { + return minY + index; + } + } + return maxY - 1; + } + + public double landFraction() { + if (heightColumns == 0L) { + throw new IllegalStateException("No terrain columns were sampled"); + } + return (heightColumns - fluidColumns) / (double) heightColumns; + } + + public double caveShare(int band) { + return bandShare(caveVoxels, band); + } + + public double voidShare(int band) { + return bandShare(voidVoxels, band); + } + + public double deepLavaShare() { + if (deepVoxels == 0L) { + throw new IllegalStateException("No subsurface voxels were sampled below Y " + DEEP_LAVA_LINE); + } + return deepLavaVoxels / (double) deepVoxels; + } + + public double wallShare() { + if (wallSolidVoxels == 0L) { + throw new IllegalStateException("No solid subsurface voxels reported cave walls"); + } + return wallVoxels / (double) wallSolidVoxels; + } + + public double oneBlockWallShare() { + if (wallSolidVoxels == 0L) { + throw new IllegalStateException("No solid subsurface voxels reported cave walls"); + } + return oneBlockWallVoxels / (double) wallSolidVoxels; + } + + public double decorationPerSquareKilometre() { + if (blockColumns == 0L) { + throw new IllegalStateException("No sampled column reported block ids"); + } + return decorationColumns / (double) blockColumns * SQUARE_METRES_PER_SQUARE_KILOMETRE; + } + + static void requireStride(int stride) { + if (stride < 1 || stride > CHUNK_WIDTH) { + throw new IllegalArgumentException("Column stride must be within [1, " + CHUNK_WIDTH + "], got " + stride); + } + } + + private void acceptColumn(FidelityChunk chunk, int x, int z) { + columns++; + String biome = chunk.surfaceBiome(x, z); + if (biome != null) { + biomeColumns.merge(biome, 1L, Long::sum); + } + if (chunk.river(x, z)) { + riverColumns++; + } + int surfaceY = chunk.surfaceY(x, z); + if (surfaceY >= maxY) { + throw new IllegalArgumentException("Surface Y " + surfaceY + " at column " + x + "," + z + + " is above the height range [" + minY + ", " + maxY + ")"); + } + if (surfaceY < minY) { + return; + } + int terrainY = surfaceY; + String top = chunk.block(x, surfaceY, z); + if (top != null) { + blockColumns++; + if (FidelityBlocks.decoration(top)) { + decorationColumns++; + terrainY = terrainBelow(chunk, x, z, surfaceY); + } + } + if (terrainY < minY) { + return; + } + heightColumns++; + heightHistogram[terrainY - minY]++; + if (terrainY + 1 < maxY && chunk.fluid(x, terrainY + 1, z)) { + fluidColumns++; + } + int caveCeiling = caveCeiling(chunk, x, z, terrainY); + boolean walls = chunk.wallsKnown() && interior(x, z); + for (int y = minY; y < caveCeiling; y++) { + int band = (y - minY) / BAND_HEIGHT; + subsurfaceVoxels[band]++; + boolean open = chunk.open(x, y, z); + if (open) { + if (chunk.terrainMass(x, y, z)) { + caveVoxels[band]++; + } else { + voidVoxels[band]++; + } + } + if (walls && !open && !chunk.fluid(x, y, z)) { + wallSolidVoxels++; + if (chunk.wall(x, y, z)) { + wallVoxels++; + } + if (CaveWalls.besideCave(chunk, x, y, z)) { + oneBlockWallVoxels++; + } + } + if (y < DEEP_LAVA_LINE) { + deepVoxels++; + if (chunk.lava(x, y, z)) { + deepLavaVoxels++; + } + } + } + } + + private double bandShare(long[] voxels, int band) { + if (subsurfaceVoxels[band] == 0L) { + throw new IllegalStateException("No subsurface voxels were sampled in the band starting at Y " + bandMinY(band)); + } + return voxels[band] / (double) subsurfaceVoxels[band]; + } + + private static boolean interior(int x, int z) { + return x > 0 && x < CHUNK_WIDTH - 1 && z > 0 && z < CHUNK_WIDTH - 1; + } + + private int terrainBelow(FidelityChunk chunk, int x, int z, int surfaceY) { + int y = surfaceY - 1; + while (y >= minY && aboveTerrain(chunk, x, y, z)) { + y--; + } + return y; + } + + private int caveCeiling(FidelityChunk chunk, int x, int z, int terrainY) { + int top = terrainY; + while (top >= minY) { + int bottom = top; + while (bottom >= minY && top - bottom < MIN_ROOF_THICKNESS && !aboveTerrain(chunk, x, bottom, z)) { + bottom--; + } + if (top - bottom >= MIN_ROOF_THICKNESS || bottom < minY || !decoration(chunk, x, bottom, z)) { + return top; + } + top = bottom; + while (top >= minY && aboveTerrain(chunk, x, top, z)) { + top--; + } + } + return top; + } + + private static boolean decoration(FidelityChunk chunk, int x, int y, int z) { + String id = chunk.block(x, y, z); + return id != null && FidelityBlocks.decoration(id); + } + + private static boolean aboveTerrain(FidelityChunk chunk, int x, int y, int z) { + if (chunk.open(x, y, z) || chunk.fluid(x, y, z)) { + return true; + } + String id = chunk.block(x, y, z); + return id != null && FidelityBlocks.decoration(id); + } + + private void add(FidelityStats other) { + columns += other.columns; + heightColumns += other.heightColumns; + fluidColumns += other.fluidColumns; + blockColumns += other.blockColumns; + decorationColumns += other.decorationColumns; + riverColumns += other.riverColumns; + deepVoxels += other.deepVoxels; + deepLavaVoxels += other.deepLavaVoxels; + wallSolidVoxels += other.wallSolidVoxels; + wallVoxels += other.wallVoxels; + oneBlockWallVoxels += other.oneBlockWallVoxels; + for (int index = 0; index < heightHistogram.length; index++) { + heightHistogram[index] += other.heightHistogram[index]; + } + for (int band = 0; band < subsurfaceVoxels.length; band++) { + subsurfaceVoxels[band] += other.subsurfaceVoxels[band]; + caveVoxels[band] += other.caveVoxels[band]; + voidVoxels[band] += other.voidVoxels[band]; + } + for (Map.Entry entry : other.biomeColumns.entrySet()) { + biomeColumns.merge(entry.getKey(), entry.getValue(), Long::sum); + } + } +} diff --git a/probe/src/main/java/art/arcane/iris/probe/fidelity/FidelityThresholds.java b/probe/src/main/java/art/arcane/iris/probe/fidelity/FidelityThresholds.java new file mode 100644 index 000000000..95648c2c6 --- /dev/null +++ b/probe/src/main/java/art/arcane/iris/probe/fidelity/FidelityThresholds.java @@ -0,0 +1,21 @@ +package art.arcane.iris.probe.fidelity; + +public record FidelityThresholds(double biomeShare, double heightQuantile, double caveBand, double objectDensity, double wallShare) { + public FidelityThresholds { + requireThreshold("biomeShare", biomeShare); + requireThreshold("heightQuantile", heightQuantile); + requireThreshold("caveBand", caveBand); + requireThreshold("objectDensity", objectDensity); + requireThreshold("wallShare", wallShare); + } + + public static FidelityThresholds defaults() { + return new FidelityThresholds(0.15D, 0.10D, 0.15D, 0.15D, 0.25D); + } + + private static void requireThreshold(String name, double value) { + if (!Double.isFinite(value) || value < 0.0D) { + throw new IllegalArgumentException("Fidelity threshold " + name + " must be a finite non-negative number, got " + value); + } + } +} diff --git a/probe/src/main/java/art/arcane/iris/probe/fidelity/IrisEngineColumnSource.java b/probe/src/main/java/art/arcane/iris/probe/fidelity/IrisEngineColumnSource.java new file mode 100644 index 000000000..34d006b85 --- /dev/null +++ b/probe/src/main/java/art/arcane/iris/probe/fidelity/IrisEngineColumnSource.java @@ -0,0 +1,51 @@ +package art.arcane.iris.probe.fidelity; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.IrisComplex; +import art.arcane.iris.world.IrisWorld; + +import java.util.Objects; + +public final class IrisEngineColumnSource implements ColumnSource { + private final Engine engine; + private final IrisComplex complex; + private final int minY; + private final int maxY; + private final int fluidHeight; + + public IrisEngineColumnSource(Engine engine) { + this.engine = Objects.requireNonNull(engine, "engine"); + this.complex = engine.getComplex(); + IrisWorld world = engine.getTarget().getWorld(); + this.minY = world.minHeight(); + this.maxY = world.maxHeight(); + this.fluidHeight = (int) Math.round(complex.getFluidHeight()); + } + + @Override + public String label() { + return "iris:" + engine.getDimension().getLoadKey(); + } + + @Override + public int minY() { + return minY; + } + + @Override + public int maxY() { + return maxY; + } + + @Override + public FidelityColumn column(int x, int z) { + int height = Math.min(complex.naturalTrueHeight(x, z), maxY - minY - 1); + IrisBiome biome = complex.getNaturalTrueBiomeStream().get(x, z); + return new FidelityColumn(biome == null ? null : biome.getLoadKey(), height + minY, height < fluidHeight); + } + + @Override + public void close() { + } +} diff --git a/probe/src/main/java/art/arcane/iris/probe/fidelity/IrisEngineFidelitySource.java b/probe/src/main/java/art/arcane/iris/probe/fidelity/IrisEngineFidelitySource.java new file mode 100644 index 000000000..7ddc1057d --- /dev/null +++ b/probe/src/main/java/art/arcane/iris/probe/fidelity/IrisEngineFidelitySource.java @@ -0,0 +1,140 @@ +package art.arcane.iris.probe.fidelity; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.IrisComplex; +import art.arcane.iris.generation.terrain.Terrain3DColumn; +import art.arcane.iris.probe.RealPackProbeSupport; +import art.arcane.iris.world.IrisWorld; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; + +import java.util.Arrays; +import java.util.IdentityHashMap; +import java.util.Map; +import java.util.Objects; + +public final class IrisEngineFidelitySource implements FidelitySource { + private static final int CHUNK_WIDTH = 16; + + private final Engine engine; + private final int minY; + private final int maxY; + + public IrisEngineFidelitySource(Engine engine) { + this.engine = Objects.requireNonNull(engine, "engine"); + IrisWorld world = engine.getTarget().getWorld(); + this.minY = world.minHeight(); + this.maxY = world.maxHeight(); + } + + @Override + public String label() { + return "iris:" + engine.getDimension().getLoadKey(); + } + + @Override + public int minY() { + return minY; + } + + @Override + public int maxY() { + return maxY; + } + + @Override + public FidelityChunk sample(int chunkX, int chunkZ) { + return new EngineChunk(RealPackProbeSupport.generateChunk(engine, chunkX, chunkZ)); + } + + @Override + public void close() { + } + + private final class EngineChunk implements FidelityChunk { + private final RealPackProbeSupport.GeneratedChunk chunk; + private final Map ids = new IdentityHashMap<>(); + private final int[] surfaces = new int[CHUNK_WIDTH * CHUNK_WIDTH]; + private final TerrainMass[] masses = new TerrainMass[CHUNK_WIDTH * CHUNK_WIDTH]; + + private EngineChunk(RealPackProbeSupport.GeneratedChunk chunk) { + this.chunk = chunk; + Arrays.fill(surfaces, Integer.MIN_VALUE); + } + + @Override + public int surfaceY(int localX, int localZ) { + int index = localZ * CHUNK_WIDTH + localX; + if (surfaces[index] == Integer.MIN_VALUE) { + surfaces[index] = scanSurface(localX, localZ); + } + return surfaces[index]; + } + + @Override + public String surfaceBiome(int localX, int localZ) { + IrisBiome biome = engine.getComplex().getTrueBiomeStream().get((chunk.chunkX() << 4) + localX, (chunk.chunkZ() << 4) + localZ); + return biome == null ? null : biome.getLoadKey(); + } + + @Override + public boolean open(int localX, int y, int localZ) { + return FidelityBlocks.open(block(localX, y, localZ)); + } + + @Override + public boolean fluid(int localX, int y, int localZ) { + return FidelityBlocks.fluid(block(localX, y, localZ)); + } + + @Override + public String block(int localX, int y, int localZ) { + NativeBlockState state = chunk.blocks().get(localX, y - minY, localZ); + if (state == null) { + return FidelityBlocks.AIR; + } + return ids.computeIfAbsent(state, (NativeBlockState key) -> FidelityBlocks.id(key.key())); + } + + @Override + public boolean terrainMass(int localX, int y, int localZ) { + int index = localZ * CHUNK_WIDTH + localX; + TerrainMass mass = masses[index]; + if (mass == null) { + mass = TerrainMass.of(engine.getComplex(), (chunk.chunkX() << 4) + localX, (chunk.chunkZ() << 4) + localZ); + masses[index] = mass; + } + return mass.solid(y - minY); + } + + @Override + public boolean wallsKnown() { + return true; + } + + @Override + public boolean wall(int localX, int y, int localZ) { + return CaveWalls.besideCave(this, localX, y, localZ); + } + + private int scanSurface(int localX, int localZ) { + for (int y = maxY - 1; y >= minY; y--) { + String id = block(localX, y, localZ); + if (!FidelityBlocks.open(id) && !FidelityBlocks.fluid(id)) { + return y; + } + } + return minY - 1; + } + } + + private record TerrainMass(int top, Terrain3DColumn column) { + private static TerrainMass of(IrisComplex complex, int x, int z) { + return new TerrainMass(complex.getRoundedHeighteightStream().get(x, z), complex.terrainColumn(x, z)); + } + + private boolean solid(int localY) { + return localY <= top && (column == null || column.isSolid(localY)); + } + } +} diff --git a/probe/src/main/java/art/arcane/iris/probe/fidelity/RegionFileFidelitySource.java b/probe/src/main/java/art/arcane/iris/probe/fidelity/RegionFileFidelitySource.java new file mode 100644 index 000000000..b759006da --- /dev/null +++ b/probe/src/main/java/art/arcane/iris/probe/fidelity/RegionFileFidelitySource.java @@ -0,0 +1,207 @@ +package art.arcane.iris.probe.fidelity; + +import art.arcane.volmlib.util.nbt.io.NBTDeserializer; +import art.arcane.volmlib.util.nbt.io.NamedTag; +import art.arcane.volmlib.util.nbt.mca.CompressionType; +import art.arcane.volmlib.util.nbt.mca.MCAFileSupport; +import art.arcane.volmlib.util.nbt.mca.MCAUtilSupport; +import art.arcane.volmlib.util.nbt.tag.CompoundTag; +import art.arcane.volmlib.util.nbt.tag.ListTag; + +import java.io.BufferedInputStream; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.RandomAccessFile; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Objects; +import java.util.Set; + +public final class RegionFileFidelitySource implements FidelitySource { + private static final int CHUNK_WIDTH = 16; + private static final int SECTION_VOLUME = 4096; + private static final long SECTOR_BYTES = 4096L; + private static final int EXTERNAL_PAYLOAD = 0x80; + private static final Set FULL_STATUS = Set.of("minecraft:full", "full"); + + private final Path regionDirectory; + private final int minY; + private final int maxY; + + public RegionFileFidelitySource(Path regionDirectory, int minY, int maxY) { + this.regionDirectory = Objects.requireNonNull(regionDirectory, "regionDirectory"); + if (maxY <= minY) { + throw new IllegalArgumentException("Region height range [" + minY + ", " + maxY + ") is empty"); + } + this.minY = minY; + this.maxY = maxY; + } + + @Override + public String label() { + return regionDirectory.toString(); + } + + @Override + public int minY() { + return minY; + } + + @Override + public int maxY() { + return maxY; + } + + @Override + public FidelityChunk sample(int chunkX, int chunkZ) throws IOException { + CompoundTag root = readChunk(chunkX, chunkZ); + String status = root.getString("Status"); + if (!FULL_STATUS.contains(status)) { + throw new IllegalStateException("Chunk " + chunkX + "," + chunkZ + " in " + regionDirectory + + " has status '" + status + "', not full"); + } + String[] blocks = new String[CHUNK_WIDTH * CHUNK_WIDTH * (maxY - minY)]; + Arrays.fill(blocks, FidelityBlocks.AIR); + ListTag sections = root.getListTag("sections"); + if (sections != null && sections.size() > 0) { + for (CompoundTag section : sections.asCompoundTagList()) { + decodeSection(section, blocks, chunkX, chunkZ); + } + } + return new RegionChunk(blocks); + } + + @Override + public void close() { + } + + private CompoundTag readChunk(int chunkX, int chunkZ) throws IOException { + Path file = regionDirectory.resolve(MCAUtilSupport.createNameFromChunkLocation(chunkX, chunkZ)); + if (!Files.isRegularFile(file)) { + throw new IllegalStateException("Region file " + file + " for chunk " + chunkX + "," + chunkZ + " does not exist"); + } + byte[] payload; + int compression; + try (RandomAccessFile region = new RandomAccessFile(file.toFile(), "r")) { + region.seek(MCAFileSupport.getChunkIndex(chunkX, chunkZ) * 4L); + int location = region.readInt(); + int sector = location >>> 8; + if (sector == 0 || (location & 0xFF) == 0) { + throw new IllegalStateException("Chunk " + chunkX + "," + chunkZ + " is not stored in " + file); + } + region.seek(sector * SECTOR_BYTES); + int length = region.readInt(); + compression = region.readUnsignedByte(); + if (length < 1) { + throw new IOException("Chunk " + chunkX + "," + chunkZ + " in " + file + " has invalid length " + length); + } + payload = new byte[length - 1]; + region.readFully(payload); + } + if ((compression & EXTERNAL_PAYLOAD) != 0) { + payload = Files.readAllBytes(regionDirectory.resolve("c." + chunkX + "." + chunkZ + ".mcc")); + compression &= ~EXTERNAL_PAYLOAD; + } + return parse(payload, compression, chunkX, chunkZ); + } + + private CompoundTag parse(byte[] payload, int compression, int chunkX, int chunkZ) throws IOException { + CompressionType type = CompressionType.getFromID((byte) compression); + if (type == null) { + throw new IOException("Chunk " + chunkX + "," + chunkZ + " in " + regionDirectory + + " uses unsupported compression type " + compression); + } + try (InputStream input = new BufferedInputStream(type.decompress(new ByteArrayInputStream(payload)))) { + NamedTag tag = new NBTDeserializer(false).fromStream(input); + if (tag == null || !(tag.getTag() instanceof CompoundTag root)) { + throw new IOException("Chunk " + chunkX + "," + chunkZ + " in " + regionDirectory + " has no compound root tag"); + } + return root; + } + } + + private void decodeSection(CompoundTag section, String[] blocks, int chunkX, int chunkZ) { + int baseY = section.getByte("Y") * CHUNK_WIDTH; + CompoundTag states = section.getCompoundTag("block_states"); + if (baseY + CHUNK_WIDTH <= minY || baseY >= maxY || states == null) { + return; + } + ListTag paletteTag = states.getListTag("palette"); + if (paletteTag == null || paletteTag.size() == 0) { + return; + } + String[] palette = new String[paletteTag.size()]; + int entry = 0; + for (CompoundTag state : paletteTag.asCompoundTagList()) { + palette[entry++] = state.getString("Name"); + } + long[] data = states.getLongArray("data"); + int bits = Math.max(4, 32 - Integer.numberOfLeadingZeros(palette.length - 1)); + int perLong = 64 / bits; + long mask = (1L << bits) - 1L; + if (palette.length > 1 && data.length < (SECTION_VOLUME + perLong - 1) / perLong) { + throw new IllegalStateException("Section " + section.getByte("Y") + " of chunk " + chunkX + "," + chunkZ + + " in " + regionDirectory + " has " + data.length + " block-state longs for a palette of " + palette.length); + } + for (int index = 0; index < SECTION_VOLUME; index++) { + int y = baseY + (index >> 8); + if (y < minY || y >= maxY) { + continue; + } + int paletteIndex = palette.length == 1 ? 0 : (int) ((data[index / perLong] >>> ((index % perLong) * bits)) & mask); + if (paletteIndex >= palette.length) { + throw new IllegalStateException("Section " + section.getByte("Y") + " of chunk " + chunkX + "," + chunkZ + + " in " + regionDirectory + " references palette entry " + paletteIndex + " of " + palette.length); + } + blocks[offset(index & 15, y, (index >> 4) & 15)] = palette[paletteIndex]; + } + } + + private int offset(int localX, int y, int localZ) { + if (y < minY || y >= maxY) { + throw new IllegalArgumentException("Y " + y + " is outside [" + minY + ", " + maxY + ")"); + } + return ((y - minY) * CHUNK_WIDTH + localZ) * CHUNK_WIDTH + localX; + } + + private final class RegionChunk implements FidelityChunk { + private final String[] blocks; + + private RegionChunk(String[] blocks) { + this.blocks = blocks; + } + + @Override + public int surfaceY(int localX, int localZ) { + for (int y = maxY - 1; y >= minY; y--) { + String id = block(localX, y, localZ); + if (!FidelityBlocks.open(id) && !FidelityBlocks.fluid(id)) { + return y; + } + } + return minY - 1; + } + + @Override + public String surfaceBiome(int localX, int localZ) { + return null; + } + + @Override + public boolean open(int localX, int y, int localZ) { + return FidelityBlocks.open(block(localX, y, localZ)); + } + + @Override + public boolean fluid(int localX, int y, int localZ) { + return FidelityBlocks.fluid(block(localX, y, localZ)); + } + + @Override + public String block(int localX, int y, int localZ) { + return blocks[offset(localX, y, localZ)]; + } + } +} diff --git a/probe/src/main/java/art/arcane/iris/probe/fidelity/SurveyWindow.java b/probe/src/main/java/art/arcane/iris/probe/fidelity/SurveyWindow.java new file mode 100644 index 000000000..ec9737e87 --- /dev/null +++ b/probe/src/main/java/art/arcane/iris/probe/fidelity/SurveyWindow.java @@ -0,0 +1,24 @@ +package art.arcane.iris.probe.fidelity; + +public record SurveyWindow(int centerX, int centerZ, int size, int stride) { + public SurveyWindow { + if (stride < 1) { + throw new IllegalArgumentException("Survey stride must be positive, got " + stride); + } + if (size < stride || size % stride != 0) { + throw new IllegalArgumentException("Survey window " + size + " must be a positive multiple of the stride " + stride); + } + } + + public int perAxis() { + return size / stride; + } + + public long samples() { + return (long) perAxis() * perAxis(); + } + + public int coordinate(int center, int index) { + return center - size / 2 + stride / 2 + index * stride; + } +} diff --git a/probe/src/test/java/art/arcane/iris/probe/IrisEngineColumnSourceTest.java b/probe/src/test/java/art/arcane/iris/probe/IrisEngineColumnSourceTest.java new file mode 100644 index 000000000..0fa276936 --- /dev/null +++ b/probe/src/test/java/art/arcane/iris/probe/IrisEngineColumnSourceTest.java @@ -0,0 +1,63 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.probe.fidelity.ColumnSurvey; +import art.arcane.iris.probe.fidelity.FidelityChunk; +import art.arcane.iris.probe.fidelity.FidelityColumn; +import art.arcane.iris.probe.fidelity.FidelityStats; +import art.arcane.iris.probe.fidelity.IrisEngineColumnSource; +import art.arcane.iris.probe.fidelity.IrisEngineFidelitySource; +import art.arcane.iris.probe.fidelity.SurveyWindow; +import art.arcane.iris.testsupport.IrisRuntimeState; +import org.junit.AfterClass; +import org.junit.Test; + +import java.io.File; +import java.net.URL; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; + +public final class IrisEngineColumnSourceTest { + @AfterClass + public static void releaseRuntime() { + IrisRuntimeState.reset(); + } + + @Test(timeout = 180_000L) + public void columnsMatchTheGeneratedChunkSurface() throws Exception { + URL resource = IrisEngineColumnSourceTest.class.getResource("/seed-cohesion"); + assertNotNull(resource); + File pack = new File(resource.toURI()); + try (RealPackProbeSupport.Workspace workspace = RealPackProbeSupport.openWorkspace(pack, "cohesion", "[fidelity]"); + RealPackProbeSupport.EngineSession session = workspace.openEngine(1337L, false, "fidelity"); + IrisEngineColumnSource columns = new IrisEngineColumnSource(session.engine()); + IrisEngineFidelitySource chunks = new IrisEngineFidelitySource(session.engine())) { + for (int chunkX = -1; chunkX <= 0; chunkX++) { + for (int chunkZ = -1; chunkZ <= 0; chunkZ++) { + assertChunkMatches(columns, chunks, chunkX, chunkZ); + } + } + FidelityStats stats = ColumnSurvey.scan(columns, new SurveyWindow(0, 0, 64, 8), 2); + + assertEquals(64L, stats.columns()); + assertEquals(64L, stats.heightColumns()); + assertEquals(0L, stats.fluidColumns()); + assertEquals(Map.of("terrain", 64L), stats.biomeColumns()); + } + } + + private static void assertChunkMatches(IrisEngineColumnSource columns, IrisEngineFidelitySource chunks, int chunkX, int chunkZ) throws Exception { + FidelityChunk chunk = chunks.sample(chunkX, chunkZ); + for (int x = 0; x < 16; x += 3) { + for (int z = 0; z < 16; z += 3) { + FidelityColumn column = columns.column((chunkX << 4) + x, (chunkZ << 4) + z); + int surface = chunk.surfaceY(x, z); + assertEquals("column " + x + "," + z + " of chunk " + chunkX + "," + chunkZ, surface, column.terrainY()); + assertEquals(chunk.surfaceBiome(x, z), column.biome()); + assertFalse(column.wet()); + } + } + } +} diff --git a/probe/src/test/java/art/arcane/iris/probe/IrisEngineFidelitySourceTest.java b/probe/src/test/java/art/arcane/iris/probe/IrisEngineFidelitySourceTest.java new file mode 100644 index 000000000..d042d7778 --- /dev/null +++ b/probe/src/test/java/art/arcane/iris/probe/IrisEngineFidelitySourceTest.java @@ -0,0 +1,81 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.probe.fidelity.FidelityChunk; +import art.arcane.iris.probe.fidelity.FidelityComparison; +import art.arcane.iris.probe.fidelity.FidelityReport; +import art.arcane.iris.probe.fidelity.FidelitySampling; +import art.arcane.iris.probe.fidelity.FidelitySamples; +import art.arcane.iris.probe.fidelity.FidelityScanner; +import art.arcane.iris.probe.fidelity.FidelityStats; +import art.arcane.iris.probe.fidelity.FidelityThresholds; +import art.arcane.iris.probe.fidelity.IrisEngineFidelitySource; +import art.arcane.iris.testsupport.IrisRuntimeState; +import org.junit.AfterClass; +import org.junit.Test; + +import java.io.File; +import java.net.URL; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +public final class IrisEngineFidelitySourceTest { + @AfterClass + public static void releaseRuntime() { + IrisRuntimeState.reset(); + } + + @Test(timeout = 180_000L) + public void realPackChunksScanIntoComparableStats() throws Exception { + URL resource = IrisEngineFidelitySourceTest.class.getResource("/seed-cohesion"); + assertNotNull(resource); + File pack = new File(resource.toURI()); + try (RealPackProbeSupport.Workspace workspace = RealPackProbeSupport.openWorkspace(pack, "cohesion", "[fidelity]"); + RealPackProbeSupport.EngineSession session = workspace.openEngine(1337L, false, "fidelity"); + IrisEngineFidelitySource source = new IrisEngineFidelitySource(session.engine())) { + FidelityStats stats = FidelityScanner.scan(source, 0, 0, 1, 4, 2); + + assertEquals(0, source.minY()); + assertEquals(64, source.maxY()); + assertEquals(144L, stats.columns()); + assertEquals(144L, stats.heightColumns()); + assertEquals(Map.of("terrain", 144L), stats.biomeColumns()); + assertEquals(144L, stats.blockColumns()); + assertTrue(stats.wallSolidVoxels() > 0L); + assertTrue(stats.wallVoxels() <= stats.wallSolidVoxels()); + double median = stats.heightQuantile(0.5D); + assertTrue("median " + median, median >= 20.0D && median < 40.0D); + FidelitySamples samples = new FidelitySamples(stats, stats, new FidelitySampling("chunks", 48, 4, List.of(1337L), stats.columns())); + FidelityReport report = FidelityComparison.compare(samples, samples, FidelityThresholds.defaults()); + assertTrue(report.toJson(), report.passed()); + } + } + + @Test(timeout = 180_000L) + public void volumetricOverhangsAreTerrainVoidsAndRockIsTerrainMass() throws Exception { + URL resource = IrisEngineFidelitySourceTest.class.getResource("/terrain3d-pack"); + assertNotNull(resource); + File pack = new File(resource.toURI()); + try (RealPackProbeSupport.Workspace workspace = RealPackProbeSupport.openWorkspace(pack, "terrain3d", "[fidelity]"); + RealPackProbeSupport.EngineSession session = workspace.openEngine(1337L, false, "fidelity-voids"); + IrisEngineFidelitySource source = new IrisEngineFidelitySource(session.engine())) { + FidelityChunk chunk = source.sample(0, 0); + long voids = 0L; + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + for (int y = source.minY(); y < chunk.surfaceY(x, z); y++) { + boolean air = chunk.block(x, y, z).endsWith("air"); + assertEquals(x + "," + y + "," + z, !air, chunk.terrainMass(x, y, z)); + if (air) { + voids++; + } + } + } + } + assertTrue(voids > 0L); + } + } +} diff --git a/probe/src/test/java/art/arcane/iris/probe/fidelity/BiomeShareReportTest.java b/probe/src/test/java/art/arcane/iris/probe/fidelity/BiomeShareReportTest.java new file mode 100644 index 000000000..c6ec0b903 --- /dev/null +++ b/probe/src/test/java/art/arcane/iris/probe/fidelity/BiomeShareReportTest.java @@ -0,0 +1,68 @@ +package art.arcane.iris.probe.fidelity; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.Test; + +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.SortedMap; +import java.util.TreeMap; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class BiomeShareReportTest { + @Test + public void tableListsEverySeedDeltaWithThePassingCount() { + BiomeShareReport report = report(); + + List lines = report.toTable().lines().toList(); + + assertTrue(lines.toString(), lines.contains(String.format(Locale.ROOT, "%-18s %10s %10s %8s", "seed", "1337", "1338", "passing"))); + assertTrue(lines.toString(), lines.contains(String.format(Locale.ROOT, "%-18s %10.4f %10.4f %8s", "biome.share.forest", 0.5D, 0.05D, "1/2"))); + assertTrue(lines.toString(), lines.contains(String.format(Locale.ROOT, "%-18s %10s %10.4f %8s", "biome.share.desert", "-", 0.0D, "1/1"))); + assertEquals("overall FAIL: 0 of 1 metrics passed", lines.getLast()); + assertFalse(report.passed()); + } + + @Test + public void jsonCarriesTheMeanAndEverySeed() { + JsonObject json = JsonParser.parseString(report().toJson()).getAsJsonObject(); + + assertFalse(json.get("passed").getAsBoolean()); + JsonObject mean = json.getAsJsonObject("mean"); + assertEquals("biome.share.forest", mean.getAsJsonArray("metrics").get(0).getAsJsonObject().get("name").getAsString()); + JsonArray seeds = json.getAsJsonArray("seeds"); + assertEquals(2, seeds.size()); + assertEquals(1337L, seeds.get(0).getAsJsonObject().get("seed").getAsLong()); + assertEquals(1, seeds.get(0).getAsJsonObject().getAsJsonArray("metrics").size()); + JsonObject passing = json.getAsJsonObject("seedsPassing").getAsJsonObject("biome.share.forest"); + assertEquals(1, passing.get("passing").getAsInt()); + assertEquals(2, passing.get("compared").getAsInt()); + JsonObject desert = json.getAsJsonObject("shares").getAsJsonObject("desert"); + assertEquals(0.05D, desert.get("iris").getAsDouble(), 0.0D); + assertEquals(0.01D, desert.get("export").getAsDouble(), 0.0D); + } + + @Test + public void reportNeedsAtLeastOneSeed() { + FidelityReport mean = new FidelityReport(List.of(), List.of(), List.of()); + + assertThrows(IllegalArgumentException.class, () -> new BiomeShareReport(mean, new TreeMap<>(), Map.of(), Map.of())); + } + + private static BiomeShareReport report() { + FidelityReport mean = new FidelityReport(List.of(new FidelityMetric("biome.share.forest", 0.5D, 0.4D, 0.2D, false)), List.of(), List.of()); + SortedMap seeds = new TreeMap<>(); + seeds.put(1337L, new FidelityReport(List.of(new FidelityMetric("biome.share.forest", 0.5D, 0.25D, 0.5D, false)), List.of(), List.of())); + seeds.put(1338L, new FidelityReport(List.of( + new FidelityMetric("biome.share.forest", 0.5D, 0.525D, 0.05D, true), + new FidelityMetric("biome.share.desert", 0.1D, 0.1D, 0.0D, true)), List.of(), List.of())); + return new BiomeShareReport(mean, seeds, Map.of("forest", 0.5D, "desert", 0.05D), Map.of("forest", 0.4D, "desert", 0.01D)); + } +} diff --git a/probe/src/test/java/art/arcane/iris/probe/fidelity/BiomeTypesTest.java b/probe/src/test/java/art/arcane/iris/probe/fidelity/BiomeTypesTest.java new file mode 100644 index 000000000..b4db5935c --- /dev/null +++ b/probe/src/test/java/art/arcane/iris/probe/fidelity/BiomeTypesTest.java @@ -0,0 +1,46 @@ +package art.arcane.iris.probe.fidelity; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.terrain.IrisRegion; +import art.arcane.volmlib.util.collection.KList; +import org.junit.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; + +public class BiomeTypesTest { + @Test + public void childrenInheritTheListTypeOfTheirParent() { + IrisRegion region = region(List.of("plains"), List.of("ocean"), List.of("beach")); + Map biomes = Map.of( + "plains", biome("meadow"), + "meadow", biome("plains"), + "ocean", biome("reef"), + "reef", biome(), + "beach", biome()); + + Map types = BiomeTypes.of(List.of(region), biomes::get); + + assertEquals(Map.of("plains", "land", "meadow", "land", "ocean", "sea", "reef", "sea", "beach", "shore"), types); + } + + @Test + public void biomesListedUnderSeveralTypesJoinThem() { + IrisRegion first = region(List.of("plains"), List.of(), List.of()); + IrisRegion second = region(List.of(), List.of("plains"), List.of("missing")); + + Map types = BiomeTypes.of(List.of(first, second), key -> key.equals("plains") ? biome() : null); + + assertEquals(Map.of("plains", "land+sea", "missing", "shore"), types); + } + + private static IrisRegion region(List land, List sea, List shore) { + return new IrisRegion().setLandBiomes(new KList<>(land)).setSeaBiomes(new KList<>(sea)).setShoreBiomes(new KList<>(shore)); + } + + private static IrisBiome biome(String... children) { + return new IrisBiome().setChildren(new KList<>(List.of(children))); + } +} diff --git a/probe/src/test/java/art/arcane/iris/probe/fidelity/CaveWallsTest.java b/probe/src/test/java/art/arcane/iris/probe/fidelity/CaveWallsTest.java new file mode 100644 index 000000000..c9b2fb655 --- /dev/null +++ b/probe/src/test/java/art/arcane/iris/probe/fidelity/CaveWallsTest.java @@ -0,0 +1,35 @@ +package art.arcane.iris.probe.fidelity; + +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class CaveWallsTest { + private static final int MIN_Y = -64; + + @Test + public void solidBesideCaveAirIsAWall() { + SyntheticFidelityChunk chunk = new SyntheticFidelityChunk(MIN_Y).surface(64).caves(-20, 0, 4); + + assertTrue(CaveWalls.besideCave(chunk, 4, -10, 7)); + assertFalse(CaveWalls.besideCave(chunk, 5, -10, 7)); + assertFalse(CaveWalls.besideCave(chunk, 4, 5, 7)); + assertFalse(CaveWalls.besideCave(chunk, 4, -21, 7)); + } + + @Test + public void fluidFilledCavesHaveWalls() { + SyntheticFidelityChunk chunk = new SyntheticFidelityChunk(MIN_Y).surface(64).lavaPool(-64, -56, 4); + + assertTrue(CaveWalls.besideCave(chunk, 4, -60, 7)); + assertFalse(CaveWalls.besideCave(chunk, 4, -50, 7)); + } + + @Test + public void chunkCornersOnlyReadNeighboursInsideTheChunk() { + assertTrue(CaveWalls.besideCave(new SyntheticFidelityChunk(MIN_Y).surface(64).caves(-20, 0, 15), 15, -10, 0)); + assertFalse(CaveWalls.besideCave(new SyntheticFidelityChunk(MIN_Y).surface(64), 15, -10, 15)); + assertFalse(CaveWalls.besideCave(new SyntheticFidelityChunk(MIN_Y).surface(64), 0, -10, 0)); + } +} diff --git a/probe/src/test/java/art/arcane/iris/probe/fidelity/ColumnSurveyTest.java b/probe/src/test/java/art/arcane/iris/probe/fidelity/ColumnSurveyTest.java new file mode 100644 index 000000000..ef1801162 --- /dev/null +++ b/probe/src/test/java/art/arcane/iris/probe/fidelity/ColumnSurveyTest.java @@ -0,0 +1,94 @@ +package art.arcane.iris.probe.fidelity; + +import art.arcane.iris.configuration.IrisSettings; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class ColumnSurveyTest { + private static final int MIN_Y = -64; + private static final int MAX_Y = 320; + + private IrisSettings previousSettings; + + @Before + public void setUp() { + previousSettings = IrisSettings.settings; + IrisSettings.settings = new IrisSettings(); + } + + @After + public void tearDown() { + IrisSettings.settings = previousSettings; + } + + @Test + public void surveyVisitsEveryStrideCellCentreOnce() { + SplitSource source = new SplitSource(null); + + FidelityStats stats = ColumnSurvey.scan(source, new SurveyWindow(1000, -2000, 512, 128), 3); + + assertEquals(16L, stats.columns()); + assertEquals(16, source.visited().size()); + assertTrue(source.visited().toString(), source.visited().contains(1000 - 256 + 64 + "," + (-2000 - 256 + 64))); + assertTrue(source.visited().toString(), source.visited().contains(1000 + 128 + 64 + "," + (-2000 + 128 + 64))); + assertEquals(Map.of("land", 8L, "sea", 8L), stats.biomeColumns()); + assertEquals(16L, stats.heightColumns()); + assertEquals(0.5D, stats.landFraction(), 0.0D); + assertEquals(30.0D, stats.heightQuantile(0.25D), 0.0D); + assertEquals(80.0D, stats.heightQuantile(0.75D), 0.0D); + } + + @Test + public void failedColumnFailsTheSurveyWithItsCause() { + IllegalStateException broken = new IllegalStateException("sampler closed"); + + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> ColumnSurvey.scan(new SplitSource(broken), new SurveyWindow(0, 0, 64, 16), 2)); + + assertSame(broken, failure.getCause()); + } + + private record SplitSource(RuntimeException failure, Set visited) implements ColumnSource { + private SplitSource(RuntimeException failure) { + this(failure, ConcurrentHashMap.newKeySet()); + } + + @Override + public String label() { + return "synthetic"; + } + + @Override + public int minY() { + return MIN_Y; + } + + @Override + public int maxY() { + return MAX_Y; + } + + @Override + public FidelityColumn column(int x, int z) { + if (failure != null) { + throw failure; + } + visited.add(x + "," + z); + return x < 1000 ? new FidelityColumn("sea", 30, true) : new FidelityColumn("land", 80, false); + } + + @Override + public void close() { + } + } +} diff --git a/probe/src/test/java/art/arcane/iris/probe/fidelity/FidelityComparisonTest.java b/probe/src/test/java/art/arcane/iris/probe/fidelity/FidelityComparisonTest.java new file mode 100644 index 000000000..7e91a21c6 --- /dev/null +++ b/probe/src/test/java/art/arcane/iris/probe/fidelity/FidelityComparisonTest.java @@ -0,0 +1,420 @@ +package art.arcane.iris.probe.fidelity; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class FidelityComparisonTest { + private static final int MIN_Y = -64; + private static final int MAX_Y = 320; + private static final double RANGE = MAX_Y - MIN_Y; + + @Test + public void identicalStatsPass() { + FidelityReport report = compare(varied(), varied()); + + assertTrue(report.passed()); + for (FidelityMetric metric : report.metrics()) { + assertEquals(metric.name(), 0.0D, metric.delta(), 1.0E-12D); + assertTrue(metric.name(), metric.pass()); + } + assertEquals(0.5D, metric(report, "biome.share.plains").iris(), 1.0E-12D); + assertEquals(40.0D, metric(report, "height.p10").iris(), 0.0D); + assertEquals(0.5D, metric(report, "height.landFraction").iris(), 1.0E-12D); + assertEquals(0.125D, metric(report, "cave.band.-48").iris(), 1.0E-12D); + assertEquals(93_750.0D, metric(report, "decoration.columnsPerKm2").iris(), 1.0E-6D); + } + + @Test + public void biomeShareBeyondThresholdFails() { + FidelityStats iris = stats(chunk().biomes("plains", "forest", 8)); + FidelityStats export = stats(chunk().biomes("plains", "forest", 12)); + + FidelityReport report = compare(iris, export); + + FidelityMetric distance = metric(report, "biome.totalVariation"); + assertEquals(0.25D, distance.delta(), 1.0E-12D); + assertFalse(distance.pass()); + FidelityMetric forest = metric(report, "biome.share.forest"); + assertEquals(0.5D, forest.iris(), 1.0E-12D); + assertEquals(0.25D, forest.export(), 1.0E-12D); + assertEquals(0.5D, forest.delta(), 1.0E-12D); + assertFalse(forest.pass()); + assertTrue(metric(report, "height.p50").pass()); + assertFalse(report.passed()); + } + + @Test + public void biomesBelowTwoPercentAreOnlyCoveredByTotalVariation() { + FidelityStats iris = new FidelityStats(MIN_Y, MAX_Y); + for (int i = 0; i < 9; i++) { + iris.accept(chunk().biomes("plains", "plains", 16), 1); + } + iris.accept(chunk().biomes("swamp", "plains", 1), 1); + FidelityStats export = new FidelityStats(MIN_Y, MAX_Y); + for (int i = 0; i < 10; i++) { + export.accept(chunk().biomes("plains", "plains", 16), 1); + } + + FidelityReport report = compare(iris, export); + + assertNull(metric(report, "biome.share.swamp")); + assertEquals(0.00625D, metric(report, "biome.totalVariation").delta(), 1.0E-12D); + assertTrue(report.passed()); + } + + @Test + public void heightQuantileDeltaNormalisedByRange() { + FidelityStats iris = stats(chunk().surface(64)); + + FidelityReport close = compare(iris, stats(chunk().surface(94))); + FidelityMetric closeMedian = metric(close, "height.p50"); + assertEquals(64.0D, closeMedian.iris(), 0.0D); + assertEquals(94.0D, closeMedian.export(), 0.0D); + assertEquals(30.0D / RANGE, closeMedian.delta(), 1.0E-12D); + assertTrue(closeMedian.pass()); + + FidelityReport far = compare(iris, stats(chunk().surface(112))); + FidelityMetric farMedian = metric(far, "height.p50"); + assertEquals(48.0D / RANGE, farMedian.delta(), 1.0E-12D); + assertFalse(farMedian.pass()); + assertFalse(metric(far, "height.p10").pass()); + assertFalse(metric(far, "height.p90").pass()); + assertFalse(far.passed()); + } + + @Test + public void landFractionUsesHeightThreshold() { + FidelityStats iris = stats(chunk().surface(64), chunk().surface(64)); + FidelityStats export = stats(chunk().surface(64), chunk().surface(60).sea(62)); + + FidelityMetric land = metric(compare(iris, export), "height.landFraction"); + + assertEquals(1.0D, land.iris(), 0.0D); + assertEquals(0.5D, land.export(), 0.0D); + assertEquals(0.5D, land.delta(), 0.0D); + assertFalse(land.pass()); + } + + @Test + public void caveBandDeltaFails() { + FidelityStats iris = stats(chunk().surface(64).caves(0, 16, 2)); + FidelityStats export = stats(chunk().surface(64).caves(0, 16, 8)); + + FidelityReport report = compare(iris, export); + + FidelityMetric band = metric(report, "cave.band.0"); + assertEquals(0.125D, band.iris(), 1.0E-12D); + assertEquals(0.5D, band.export(), 1.0E-12D); + assertEquals(0.375D, band.delta(), 1.0E-12D); + assertFalse(band.pass()); + assertTrue(metric(report, "cave.band.-64").pass()); + assertNull(metric(report, "cave.band.64")); + assertFalse(report.passed()); + } + + @Test + public void terrainVoidBandsAreJudgedApartFromCaveBands() { + FidelityStats iris = stats(chunk().surface(100).overhang(4, 80, 96).caves(0, 16, 4)); + FidelityStats export = stats(chunk().surface(100).caves(0, 16, 4)); + + FidelityReport report = compare(iris, export); + + FidelityMetric caves = metric(report, "cave.band.80"); + assertEquals(0.0D, caves.iris(), 0.0D); + assertEquals(0.0D, caves.export(), 0.0D); + assertTrue(caves.pass()); + assertTrue(metric(report, "cave.band.0").pass()); + FidelityMetric voids = metric(report, "void.band.80"); + assertEquals(0.25D, voids.iris(), 1.0E-12D); + assertEquals(0.0D, voids.export(), 0.0D); + assertEquals(0.25D, voids.delta(), 1.0E-12D); + assertFalse(voids.pass()); + assertNull(metric(report, "void.band.0")); + assertFalse(report.passed()); + } + + @Test + public void voidBandsUseTheCaveBandThreshold() { + FidelityStats iris = stats(chunk().surface(100).overhang(4, 80, 96)); + FidelityStats export = stats(chunk().surface(100).overhang(2, 80, 96)); + + FidelityMetric voids = metric(compare(iris, export), "void.band.80"); + + assertEquals(0.125D, voids.delta(), 1.0E-12D); + assertEquals(voids.delta() <= FidelityThresholds.defaults().caveBand(), voids.pass()); + } + + @Test + public void thinVoidBandsAreObservedNotJudged() { + FidelityStats iris = stats(chunk().surface(66).overhang(16, 64, 66)); + FidelityStats export = stats(chunk().surface(66)); + + FidelityReport report = compare(iris, export); + + assertNull(metric(report, "void.band.64")); + FidelityObservation thin = observation(report, "void.band.64"); + assertTrue(thin.iris() > 0.0D); + assertEquals(0.0D, thin.export(), 0.0D); + } + + @Test + public void decorationDensityUsesRelativeError() { + FidelityStats iris = stats(chunk().trees(4, 5)); + + FidelityMetric dense = metric(compare(iris, stats(chunk().trees(8, 5))), + "decoration.columnsPerKm2"); + assertEquals(1.0D, dense.delta(), 1.0E-12D); + assertFalse(dense.pass()); + + FidelityReport noiseOnly = compare(iris, stats(chunk().trees(8, 5).noBlocks())); + assertNull(metric(noiseOnly, "decoration.columnsPerKm2")); + } + + @Test + public void wallShareUsesRelativeError() { + FidelityStats iris = stats(chunk().surface(64).caves(-20, 0, 4).walls(1)); + + FidelityMetric same = metric(compare(iris, stats(chunk().surface(64).caves(-20, 0, 4).walls(1))), "cave.wallShare"); + assertEquals(280.0D / 24_248.0D, same.iris(), 1.0E-12D); + assertEquals(0.0D, same.delta(), 0.0D); + assertTrue(same.pass()); + + FidelityMetric thick = metric(compare(iris, stats(chunk().surface(64).caves(-20, 0, 4).walls(3))), "cave.wallShare"); + assertEquals(840.0D / 24_248.0D, thick.export(), 1.0E-12D); + assertEquals(2.0D, thick.delta(), 1.0E-12D); + assertFalse(thick.pass()); + + assertNull(metric(compare(iris, stats(chunk().surface(64).caves(-20, 0, 4))), "cave.wallShare")); + } + + @Test + public void differentHeightRangesAreRejected() { + FidelityStats shallow = new FidelityStats(0, 256); + shallow.accept(new SyntheticFidelityChunk(0), 1); + + assertThrows(IllegalArgumentException.class, () -> compare(varied(), shallow)); + } + + @Test + public void reportJsonListsEveryMetric() { + FidelityStats iris = stats(chunk().surface(64)); + FidelityReport report = compare(iris, stats(chunk().surface(112))); + + JsonObject json = JsonParser.parseString(report.toJson()).getAsJsonObject(); + + assertFalse(json.get("passed").getAsBoolean()); + assertEquals(report.metrics().size(), json.getAsJsonArray("metrics").size()); + JsonObject first = json.getAsJsonArray("metrics").get(0).getAsJsonObject(); + assertEquals(report.metrics().getFirst().name(), first.get("name").getAsString()); + assertEquals(report.metrics().getFirst().pass(), first.get("pass").getAsBoolean()); + } + + @Test + public void thinCaveBandsAreObservedNotJudged() { + FidelityStats iris = stats(chunk().surface(66).caves(64, 66, 16)); + FidelityStats export = stats(chunk().surface(66)); + + FidelityReport report = compare(iris, export); + + assertNull(metric(report, "cave.band.64")); + assertTrue(metric(report, "cave.band.48").pass()); + FidelityObservation thin = observation(report, "cave.band.64"); + assertTrue(thin.iris() > 0.0D); + assertEquals(0.0D, thin.export(), 0.0D); + assertTrue(report.passed()); + } + + @Test + public void deepLavaShareIsObservedNotJudged() { + FidelityStats iris = stats(chunk().surface(64).caves(-64, -56, 4)); + FidelityStats export = stats(chunk().surface(64).lavaPool(-64, -56, 4)); + + FidelityReport report = compare(iris, export); + + FidelityObservation lava = report.observations().getFirst(); + assertEquals("cave.lavaShare.belowY-54", lava.name()); + assertEquals(0.0D, lava.iris(), 0.0D); + assertEquals(0.2D, lava.export(), 1.0E-12D); + assertNull(metric(report, "cave.lavaShare.belowY-54")); + } + + @Test + public void oneBlockWallShareIsObservedNotJudged() { + FidelityStats iris = stats(chunk().surface(64).caves(-20, 0, 4).walls(1)); + FidelityStats export = stats(chunk().surface(64).caves(-20, 0, 4).walls(3)); + + FidelityReport report = compare(iris, export); + + FidelityObservation oneBlock = observation(report, "cave.wallShare.oneBlock"); + assertEquals(280.0D / 24_248.0D, oneBlock.iris(), 1.0E-12D); + assertEquals(280.0D / 24_248.0D, oneBlock.export(), 1.0E-12D); + assertNull(metric(report, "cave.wallShare.oneBlock")); + } + + @Test + public void emptyReportDoesNotPass() { + assertFalse(new FidelityReport(List.of(), List.of(), List.of()).passed()); + } + + @Test + public void surfaceMetricsComeFromColumnsAndSubsurfaceMetricsFromChunks() { + FidelityStats columns = stats(chunk().biomes("plains", "forest", 8).surface(70)); + FidelityStats exportColumns = stats(chunk().biomes("plains", "forest", 8).surface(70)); + FidelityStats chunks = stats(chunk().biomes("desert", "desert", 16).surface(20).caves(0, 16, 2).trees(3, 5)); + FidelityStats exportChunks = stats(chunk().biomes("ocean", "ocean", 16).surface(90).caves(0, 16, 2).trees(3, 5)); + + FidelityReport report = FidelityComparison.compare( + new FidelitySamples(columns, exportColumns, sampling("columns")), + new FidelitySamples(chunks, exportChunks, sampling("chunks")), + FidelityThresholds.defaults()); + + assertTrue(report.toTable(), report.passed()); + assertEquals(0.5D, metric(report, "biome.share.plains").iris(), 1.0E-12D); + assertNull(metric(report, "biome.share.desert")); + assertEquals(70.0D, metric(report, "height.p50").export(), 0.0D); + assertEquals(0.125D, metric(report, "cave.band.0").export(), 1.0E-12D); + assertEquals(List.of("columns", "chunks"), report.sampling().stream().map(FidelitySampling::scope).toList()); + } + + @Test + public void biomeSharesAreJudgedOnTheMeanOverEverySeed() { + FidelitySamples first = seed(1337L, columns("plains", 8, "forest", 8), columns("plains", 12, "forest", 4)); + FidelitySamples second = seed(1338L, columns("plains", 8, "forest", 8), columns("plains", 4, "forest", 12)); + + BiomeShareReport report = FidelityComparison.compareBiomeShares(List.of(first, second), Map.of(), FidelityThresholds.defaults()); + + FidelityMetric forest = metric(report.mean(), "biome.share.forest"); + assertEquals(0.5D, forest.iris(), 1.0E-12D); + assertEquals(0.5D, forest.export(), 1.0E-12D); + assertTrue(forest.pass()); + assertEquals(0.0D, metric(report.mean(), "biome.totalVariation").delta(), 1.0E-12D); + assertTrue(report.toTable(), report.passed()); + assertEquals(0.25D, metric(report.seeds().get(1337L), "biome.share.forest").export(), 1.0E-12D); + assertFalse(metric(report.seeds().get(1338L), "biome.share.forest").pass()); + assertEquals(0, report.seedsPassing("biome.share.forest")); + assertEquals(2, report.seedsCompared("biome.share.forest")); + assertNull(metric(report.mean(), "height.p50")); + FidelitySampling sampling = report.mean().sampling().getFirst(); + assertEquals(List.of(1337L, 1338L), sampling.seeds()); + assertEquals(32L, sampling.columns()); + } + + @Test + public void meanSharesWeighEverySeedEqually() { + FidelitySamples small = seed(1337L, columns("plains", 4, "forest", 0), columns("plains", 4, "forest", 0)); + FidelitySamples large = seed(1338L, columns("plains", 0, "forest", 12), columns("plains", 6, "forest", 6)); + + BiomeShareReport report = FidelityComparison.compareBiomeShares(List.of(small, large), Map.of(), FidelityThresholds.defaults()); + + FidelityMetric plains = metric(report.mean(), "biome.share.plains"); + assertEquals(0.5D, plains.iris(), 1.0E-12D); + assertEquals(0.75D, plains.export(), 1.0E-12D); + assertFalse(plains.pass()); + assertFalse(report.passed()); + } + + @Test + public void biomeTypeSharesAreObservedWithoutAffectingTheVerdict() { + FidelitySamples first = seed(1337L, columns("plains", 6, "ocean", 4, "beach", 2), columns("plains", 6, "ocean", 5, "beach", 1)); + FidelitySamples second = seed(1338L, columns("plains", 6, "ocean", 4, "beach", 2), columns("plains", 6, "ocean", 4, "beach", 2)); + Map types = Map.of("plains", "land", "ocean", "sea", "beach", "shore"); + + BiomeShareReport report = FidelityComparison.compareBiomeShares(List.of(first, second), types, FidelityThresholds.defaults()); + + FidelityObservation sea = observation(report.mean(), "biome.type.sea"); + assertEquals(4.0D / 12.0D, sea.iris(), 1.0E-12D); + assertEquals(4.5D / 12.0D, sea.export(), 1.0E-12D); + assertEquals(0.5D, observation(report.mean(), "biome.type.land").export(), 1.0E-12D); + assertEquals(1.0D / 12.0D, observation(report.seeds().get(1337L), "biome.type.shore").export(), 1.0E-12D); + assertEquals(0.5D, observation(report.mean(), "height.landFraction").iris(), 1.0E-12D); + } + + @Test + public void meanSharesListEveryBiomeIncludingTheRareOnes() { + FidelitySamples first = seed(1337L, columns("plains", 99, "swamp", 1), columns("plains", 100)); + FidelitySamples second = seed(1338L, columns("plains", 100), columns("plains", 98, "swamp", 2)); + + BiomeShareReport report = FidelityComparison.compareBiomeShares(List.of(first, second), Map.of(), FidelityThresholds.defaults()); + + assertNull(metric(report.mean(), "biome.share.swamp")); + assertEquals(0.005D, report.irisShares().get("swamp"), 1.0E-12D); + assertEquals(0.01D, report.exportShares().get("swamp"), 1.0E-12D); + assertEquals(0.995D, report.irisShares().get("plains"), 1.0E-12D); + } + + @Test + public void biomeSharesNeedAtLeastOneSeed() { + assertThrows(IllegalArgumentException.class, + () -> FidelityComparison.compareBiomeShares(List.of(), Map.of(), FidelityThresholds.defaults())); + } + + private static FidelityStats varied() { + return stats(chunk().biomes("plains", "forest", 8).caves(-48, -32, 4).trees(3, 5).surface(70), + chunk().biomes("plains", "forest", 8).surface(40).sea(62)); + } + + private static FidelityReport compare(FidelityStats iris, FidelityStats export) { + return FidelityComparison.compare(new FidelitySamples(iris, export, sampling("columns")), + new FidelitySamples(iris, export, sampling("chunks")), FidelityThresholds.defaults()); + } + + private static FidelitySampling sampling(String scope) { + return new FidelitySampling(scope, 16, 1, List.of(1337L), 256L); + } + + private static SyntheticFidelityChunk chunk() { + return new SyntheticFidelityChunk(MIN_Y); + } + + private static FidelityStats stats(SyntheticFidelityChunk... chunks) { + FidelityStats stats = new FidelityStats(MIN_Y, MAX_Y); + for (SyntheticFidelityChunk chunk : chunks) { + stats.accept(chunk, 1); + } + return stats; + } + + private static FidelitySamples seed(long seed, FidelityStats iris, FidelityStats export) { + return new FidelitySamples(iris, export, new FidelitySampling("columns", 64, 16, List.of(seed), iris.columns())); + } + + private static FidelityStats columns(Object... biomesAndCounts) { + FidelityStats stats = new FidelityStats(MIN_Y, MAX_Y); + for (int index = 0; index < biomesAndCounts.length; index += 2) { + String biome = (String) biomesAndCounts[index]; + int count = (Integer) biomesAndCounts[index + 1]; + for (int column = 0; column < count; column++) { + stats.accept(new FidelityColumn(biome, biome.equals("plains") ? 80 : 40, !biome.equals("plains"))); + } + } + return stats; + } + + private static FidelityObservation observation(FidelityReport report, String name) { + for (FidelityObservation observation : report.observations()) { + if (observation.name().equals(name)) { + return observation; + } + } + return null; + } + + private static FidelityMetric metric(FidelityReport report, String name) { + for (FidelityMetric metric : report.metrics()) { + if (metric.name().equals(name)) { + return metric; + } + } + return null; + } +} diff --git a/probe/src/test/java/art/arcane/iris/probe/fidelity/FidelityReportTest.java b/probe/src/test/java/art/arcane/iris/probe/fidelity/FidelityReportTest.java new file mode 100644 index 000000000..1fe37ec0c --- /dev/null +++ b/probe/src/test/java/art/arcane/iris/probe/fidelity/FidelityReportTest.java @@ -0,0 +1,84 @@ +package art.arcane.iris.probe.fidelity; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class FidelityReportTest { + @Test + public void tableListsEveryMetricWithItsVerdict() { + FidelityReport report = new FidelityReport(List.of( + new FidelityMetric("biome.totalVariation", 0.0D, 0.03125D, 0.03125D, true), + new FidelityMetric("height.p50", 64.0D, 80.0D, 0.041666D, false)), List.of(), List.of()); + + List lines = report.toTable().lines().toList(); + + assertEquals(4, lines.size()); + assertTrue(lines.get(0), lines.get(0).startsWith("metric")); + assertTrue(lines.get(1), lines.get(1).startsWith("biome.totalVariation")); + assertTrue(lines.get(1), lines.get(1).contains("0.0313")); + assertTrue(lines.get(1), lines.get(1).endsWith("PASS")); + assertTrue(lines.get(2), lines.get(2).startsWith("height.p50")); + assertTrue(lines.get(2), lines.get(2).contains("64.0000")); + assertTrue(lines.get(2), lines.get(2).contains("80.0000")); + assertTrue(lines.get(2), lines.get(2).endsWith("FAIL")); + assertEquals("overall FAIL: 1 of 2 metrics passed", lines.get(3)); + assertEquals(lines.get(1).indexOf("PASS"), lines.get(2).indexOf("FAIL")); + } + + @Test + public void passingReportEndsWithOverallPass() { + FidelityReport report = new FidelityReport(List.of(new FidelityMetric("height.p10", 40.0D, 40.0D, 0.0D, true)), List.of(), List.of()); + + List lines = report.toTable().lines().toList(); + + assertEquals("overall PASS: 1 of 1 metrics passed", lines.getLast()); + } + + @Test + public void observationsAreListedWithoutAffectingTheVerdict() { + FidelityReport report = new FidelityReport(List.of(new FidelityMetric("height.p10", 40.0D, 40.0D, 0.0D, true)), List.of(), + List.of(new FidelityObservation("cave.lavaShare.belowY-54", 0.01D, 0.5D))); + + List lines = report.toTable().lines().toList(); + JsonObject json = JsonParser.parseString(report.toJson()).getAsJsonObject(); + + assertTrue(report.passed()); + assertTrue(lines.toString(), lines.stream().anyMatch(line -> line.startsWith("cave.lavaShare.belowY-54") && line.endsWith("INFO"))); + assertEquals("overall PASS: 1 of 1 metrics passed", lines.getLast()); + JsonObject observation = json.getAsJsonArray("observations").get(0).getAsJsonObject(); + assertEquals("cave.lavaShare.belowY-54", observation.get("name").getAsString()); + assertEquals(0.5D, observation.get("export").getAsDouble(), 0.0D); + assertFalse(observation.has("pass")); + } + + @Test + public void samplingIsListedInTheTableAndJson() { + FidelityReport report = new FidelityReport(List.of(new FidelityMetric("height.p10", 40.0D, 40.0D, 0.0D, true)), + List.of(new FidelitySampling("columns", 65536, 256, List.of(1337L, 1338L), 65536L)), List.of()); + + List lines = report.toTable().lines().toList(); + JsonObject sampling = JsonParser.parseString(report.toJson()).getAsJsonObject().getAsJsonArray("sampling").get(0).getAsJsonObject(); + + assertEquals("sampling columns window=65536 stride=256 seeds=[1337, 1338] columns=65536", lines.getFirst()); + assertTrue(lines.get(1), lines.get(1).startsWith("metric")); + assertEquals("columns", sampling.get("scope").getAsString()); + assertEquals(65536, sampling.get("windowBlocks").getAsInt()); + assertEquals(256, sampling.get("strideBlocks").getAsInt()); + assertEquals(2, sampling.getAsJsonArray("seeds").size()); + assertEquals(65536L, sampling.get("columns").getAsLong()); + } + + @Test + public void emptyReportFailsInTheTable() { + List lines = new FidelityReport(List.of(), List.of(), List.of()).toTable().lines().toList(); + + assertEquals("overall FAIL: no metrics were compared", lines.getLast()); + } +} diff --git a/probe/src/test/java/art/arcane/iris/probe/fidelity/FidelityScannerTest.java b/probe/src/test/java/art/arcane/iris/probe/fidelity/FidelityScannerTest.java new file mode 100644 index 000000000..0266d2f23 --- /dev/null +++ b/probe/src/test/java/art/arcane/iris/probe/fidelity/FidelityScannerTest.java @@ -0,0 +1,79 @@ +package art.arcane.iris.probe.fidelity; + +import art.arcane.iris.configuration.IrisSettings; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; + +public class FidelityScannerTest { + private static final int MIN_Y = -64; + private static final int MAX_Y = 320; + + private IrisSettings previousSettings; + + @Before + public void setUp() { + previousSettings = IrisSettings.settings; + IrisSettings.settings = new IrisSettings(); + } + + @After + public void tearDown() { + IrisSettings.settings = previousSettings; + } + + @Test + public void scanMergesEveryChunkOfTheSpiral() { + FidelityStats stats = FidelityScanner.scan(new BandedSource(null), 10, -4, 1, 4, 3); + + assertEquals(144L, stats.columns()); + assertEquals(Map.of("plains", 128L, "forest", 16L), stats.biomeColumns()); + assertEquals(144L, stats.heightColumns()); + } + + @Test + public void failedChunkFailsTheScanWithItsCause() { + IllegalStateException broken = new IllegalStateException("region missing"); + + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> FidelityScanner.scan(new BandedSource(broken), 0, 0, 1, 4, 2)); + + assertSame(broken, failure.getCause()); + } + + private record BandedSource(RuntimeException failure) implements FidelitySource { + @Override + public String label() { + return "synthetic"; + } + + @Override + public int minY() { + return MIN_Y; + } + + @Override + public int maxY() { + return MAX_Y; + } + + @Override + public FidelityChunk sample(int chunkX, int chunkZ) { + if (failure != null && chunkX == 1 && chunkZ == 1) { + throw failure; + } + String biome = chunkX == 10 && chunkZ == -4 ? "forest" : "plains"; + return new SyntheticFidelityChunk(MIN_Y).biomes(biome, biome, 16); + } + + @Override + public void close() { + } + } +} diff --git a/probe/src/test/java/art/arcane/iris/probe/fidelity/FidelityStatsTest.java b/probe/src/test/java/art/arcane/iris/probe/fidelity/FidelityStatsTest.java new file mode 100644 index 000000000..a26170519 --- /dev/null +++ b/probe/src/test/java/art/arcane/iris/probe/fidelity/FidelityStatsTest.java @@ -0,0 +1,235 @@ +package art.arcane.iris.probe.fidelity; + +import org.junit.Test; + +import java.util.Map; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +public class FidelityStatsTest { + private static final int MIN_Y = -64; + private static final int MAX_Y = 320; + + @Test + public void strideSamplingCounts() { + int[] strides = {1, 3, 4, 16}; + long[] expected = {256L, 36L, 16L, 1L}; + for (int i = 0; i < strides.length; i++) { + FidelityStats stats = new FidelityStats(MIN_Y, MAX_Y); + stats.accept(chunk(), strides[i]); + assertEquals(expected[i], stats.columns()); + assertEquals(expected[i], stats.heightColumns()); + assertEquals(expected[i], stats.blockColumns()); + assertEquals(expected[i], stats.biomeColumns().values().stream().mapToLong(Long::longValue).sum()); + } + } + + @Test + public void strideOutsideChunkIsRejected() { + FidelityStats stats = new FidelityStats(MIN_Y, MAX_Y); + assertThrows(IllegalArgumentException.class, () -> stats.accept(chunk(), 0)); + assertThrows(IllegalArgumentException.class, () -> stats.accept(chunk(), 17)); + } + + @Test + public void mergeIsAssociative() { + FidelityStats a = stats(chunk().surface(40).biomes("plains", "forest", 4).caves(-20, 0, 3).walls(2)); + FidelityStats b = stats(chunk().surface(40).sea(62).biomes("ocean", "ocean", 16).overhang(3, 20, 30)); + FidelityStats c = stats(chunk().surface(90).trees(5, 6).caves(-64, -30, 16).lavaPool(-64, -60, 3).noBlocks()); + + FidelityStats left = a.merge(b).merge(c); + FidelityStats right = a.merge(b.merge(c)); + FidelityStats combined = new FidelityStats(MIN_Y, MAX_Y); + combined.accept(chunk().surface(40).biomes("plains", "forest", 4).caves(-20, 0, 3).walls(2), 1); + combined.accept(chunk().surface(40).sea(62).biomes("ocean", "ocean", 16).overhang(3, 20, 30), 1); + combined.accept(chunk().surface(90).trees(5, 6).caves(-64, -30, 16).lavaPool(-64, -60, 3).noBlocks(), 1); + + assertSameStats(left, right); + assertSameStats(left, combined); + assertSameStats(a, stats(chunk().surface(40).biomes("plains", "forest", 4).caves(-20, 0, 3).walls(2))); + assertEquals(768L, left.columns()); + } + + @Test + public void columnsCountBiomeHeightAndWater() { + FidelityStats stats = new FidelityStats(MIN_Y, MAX_Y); + stats.accept(new FidelityColumn("plains", 70, false)); + stats.accept(new FidelityColumn("ocean", 20, true)); + stats.accept(new FidelityColumn(null, MIN_Y - 1, false)); + + assertEquals(3L, stats.columns()); + assertEquals(2L, stats.heightColumns()); + assertEquals(1L, stats.fluidColumns()); + assertEquals(0.5D, stats.landFraction(), 0.0D); + assertEquals(20.0D, stats.heightQuantile(0.5D), 0.0D); + assertEquals(70.0D, stats.heightQuantile(1.0D), 0.0D); + assertEquals(Map.of("plains", 1L, "ocean", 1L), stats.biomeColumns()); + assertEquals(0L, stats.blockColumns()); + assertThrows(IllegalArgumentException.class, () -> stats.accept(new FidelityColumn("peak", MAX_Y, false))); + } + + @Test + public void mergeRejectsDifferentHeightRanges() { + FidelityStats overworld = new FidelityStats(MIN_Y, MAX_Y); + FidelityStats shallow = new FidelityStats(0, 256); + assertThrows(IllegalArgumentException.class, () -> overworld.merge(shallow)); + } + + @Test + public void decorationAboveTerrainIsCountedButNotMeasuredAsHeight() { + FidelityStats stats = stats(chunk().surface(70).trees(4, 6)); + + assertEquals(70.0D, stats.heightQuantile(1.0D), 0.0D); + assertEquals(64L, stats.decorationColumns()); + assertEquals(256L, stats.blockColumns()); + assertEquals(250_000.0D, stats.decorationPerSquareKilometre(), 1.0E-6D); + } + + @Test + public void noiseOnlyChunksSkipDecoration() { + FidelityStats stats = stats(chunk().trees(4, 6).noBlocks()); + + assertEquals(0L, stats.blockColumns()); + assertEquals(0L, stats.decorationColumns()); + } + + @Test + public void subsurfaceOpenVoxelsAreSharedPerBand() { + FidelityStats stats = stats(chunk().surface(64).caves(-64, -48, 4)); + + assertEquals(24, stats.bandCount()); + assertEquals(0.25D, stats.caveShare(0), 1.0E-12D); + assertEquals(0.0D, stats.caveShare(1), 0.0D); + assertEquals(0.0D, stats.voidShare(0), 0.0D); + assertEquals(256L * 16L, stats.subsurfaceVoxels()[7]); + assertEquals(0L, stats.subsurfaceVoxels()[8]); + assertEquals(1.0D, stats.landFraction(), 0.0D); + } + + @Test + public void snowCappedCanopyDoesNotRoofCaves() { + FidelityStats stats = stats(chunk().surface(64).canopy(4, 90, "minecraft:snow")); + + assertEquals(0L, sum(stats.caveVoxels()) + sum(stats.voidVoxels())); + assertEquals(0L, stats.subsurfaceVoxels()[(80 - MIN_Y) / 16]); + assertEquals(256L * 16L, stats.subsurfaceVoxels()[(48 - MIN_Y) / 16]); + } + + @Test + public void cavesUnderACanopyStillCount() { + FidelityStats stats = stats(chunk().surface(64).caves(48, 64, 4).canopy(4, 90, "minecraft:snow")); + + assertEquals(0.25D, stats.caveShare((48 - MIN_Y) / 16), 1.0E-12D); + } + + @Test + public void openSpaceUnderAFloatingRoofIsATerrainVoid() { + FidelityStats stats = stats(chunk().surface(64).roof(4, 90, 95)); + + int band = (80 - MIN_Y) / 16; + assertEquals(64L * 15L, stats.subsurfaceVoxels()[band]); + assertEquals(64L * 10L, stats.voidVoxels()[band]); + assertEquals(0L, stats.caveVoxels()[band]); + } + + @Test + public void overhangsBelowTheTerrainTopAreVoidsAndCarvedSpaceIsCave() { + FidelityStats stats = stats(chunk().surface(100).overhang(4, 80, 96).caves(0, 16, 8)); + + int overhangBand = (80 - MIN_Y) / 16; + int caveBand = (0 - MIN_Y) / 16; + assertEquals(0.25D, stats.voidShare(overhangBand), 1.0E-12D); + assertEquals(0.0D, stats.caveShare(overhangBand), 0.0D); + assertEquals(0.5D, stats.caveShare(caveBand), 1.0E-12D); + assertEquals(0.0D, stats.voidShare(caveBand), 0.0D); + assertEquals(256L * 16L, stats.subsurfaceVoxels()[overhangBand]); + } + + @Test + public void deepLavaShareCoversSubsurfaceBelowTheLavaLine() { + FidelityStats stats = stats(chunk().surface(64).lavaPool(-64, -56, 4).caves(-56, -40, 4)); + + assertEquals(256L * (FidelityStats.DEEP_LAVA_LINE - MIN_Y), stats.deepVoxels()); + assertEquals(4L * 16L * 8L, stats.deepLavaVoxels()); + assertEquals(0.2D, stats.deepLavaShare(), 1.0E-12D); + } + + @Test + public void wallShareCountsSolidSubsurfaceVoxelsOfInteriorColumns() { + FidelityStats stats = stats(chunk().surface(64).caves(-20, 0, 4).walls(1)); + + assertEquals(14L * 20L, stats.wallVoxels()); + assertEquals(42L * 108L + 154L * 128L, stats.wallSolidVoxels()); + assertEquals(280.0D / 24_248.0D, stats.wallShare(), 1.0E-12D); + } + + @Test + public void oneBlockWallsCountSolidVoxelsBesideCaveAir() { + FidelityStats stats = stats(chunk().surface(64).caves(-20, 0, 4).walls(3)); + + assertEquals(3L * 14L * 20L, stats.wallVoxels()); + assertEquals(14L * 20L, stats.oneBlockWallVoxels()); + assertEquals(280.0D / 24_248.0D, stats.oneBlockWallShare(), 1.0E-12D); + } + + @Test + public void chunksWithoutWallsAddNoWallVoxels() { + FidelityStats stats = stats(chunk().surface(64).caves(-20, 0, 4)); + + assertEquals(0L, stats.wallSolidVoxels()); + assertEquals(0L, stats.wallVoxels()); + assertEquals(0L, stats.oneBlockWallVoxels()); + assertThrows(IllegalStateException.class, stats::wallShare); + assertThrows(IllegalStateException.class, stats::oneBlockWallShare); + } + + @Test + public void submergedColumnsCountAsFluid() { + FidelityStats stats = stats(chunk().surface(40).sea(62)); + + assertEquals(256L, stats.fluidColumns()); + assertEquals(0.0D, stats.landFraction(), 0.0D); + assertEquals(40.0D, stats.heightQuantile(0.5D), 0.0D); + } + + private static long sum(long[] voxels) { + long total = 0L; + for (long count : voxels) { + total += count; + } + return total; + } + + private static SyntheticFidelityChunk chunk() { + return new SyntheticFidelityChunk(MIN_Y); + } + + private static FidelityStats stats(SyntheticFidelityChunk chunk) { + FidelityStats stats = new FidelityStats(MIN_Y, MAX_Y); + stats.accept(chunk, 1); + return stats; + } + + private static void assertSameStats(FidelityStats expected, FidelityStats actual) { + assertEquals(expected.minY(), actual.minY()); + assertEquals(expected.maxY(), actual.maxY()); + assertEquals(expected.columns(), actual.columns()); + assertEquals(expected.heightColumns(), actual.heightColumns()); + assertEquals(expected.fluidColumns(), actual.fluidColumns()); + assertEquals(expected.blockColumns(), actual.blockColumns()); + assertEquals(expected.decorationColumns(), actual.decorationColumns()); + assertEquals(expected.riverColumns(), actual.riverColumns()); + assertEquals(expected.biomeColumns(), actual.biomeColumns()); + assertArrayEquals(expected.heightHistogram(), actual.heightHistogram()); + assertArrayEquals(expected.subsurfaceVoxels(), actual.subsurfaceVoxels()); + assertArrayEquals(expected.caveVoxels(), actual.caveVoxels()); + assertArrayEquals(expected.voidVoxels(), actual.voidVoxels()); + assertEquals(expected.deepVoxels(), actual.deepVoxels()); + assertEquals(expected.deepLavaVoxels(), actual.deepLavaVoxels()); + assertEquals(expected.wallSolidVoxels(), actual.wallSolidVoxels()); + assertEquals(expected.wallVoxels(), actual.wallVoxels()); + assertEquals(expected.oneBlockWallVoxels(), actual.oneBlockWallVoxels()); + } +} diff --git a/probe/src/test/java/art/arcane/iris/probe/fidelity/RegionFileFidelitySourceTest.java b/probe/src/test/java/art/arcane/iris/probe/fidelity/RegionFileFidelitySourceTest.java new file mode 100644 index 000000000..f3e8cc61e --- /dev/null +++ b/probe/src/test/java/art/arcane/iris/probe/fidelity/RegionFileFidelitySourceTest.java @@ -0,0 +1,140 @@ +package art.arcane.iris.probe.fidelity; + +import art.arcane.volmlib.util.nbt.io.NBTSerializer; +import art.arcane.volmlib.util.nbt.io.NamedTag; +import art.arcane.volmlib.util.nbt.tag.CompoundTag; +import art.arcane.volmlib.util.nbt.tag.ListTag; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.file.Path; +import java.util.List; +import java.util.zip.DeflaterOutputStream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class RegionFileFidelitySourceTest { + private static final int CHUNK_X = 2; + private static final int CHUNK_Z = -1; + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void decodesPackedSectionsFromRegionFiles() throws Exception { + Path regions = temporaryFolder.newFolder("region").toPath(); + writeRegion(regions.resolve("r.0.-1.mca"), chunk("minecraft:full")); + + try (RegionFileFidelitySource source = new RegionFileFidelitySource(regions, -64, 320)) { + FidelityChunk chunk = source.sample(CHUNK_X, CHUNK_Z); + + assertEquals(-40, chunk.surfaceY(0, 0)); + assertEquals(-40, chunk.surfaceY(9, 3)); + assertTrue(chunk.fluid(0, -39, 0)); + assertFalse(chunk.fluid(9, -39, 0)); + assertTrue(chunk.open(9, -39, 0)); + assertTrue(chunk.open(0, -60, 0)); + assertFalse(chunk.open(1, -60, 0)); + assertEquals("minecraft:stone", chunk.block(15, -64, 15)); + assertEquals("minecraft:air", chunk.block(4, 100, 4)); + assertNull(chunk.surfaceBiome(0, 0)); + + FidelityStats stats = new FidelityStats(-64, 320); + stats.accept(chunk, 1); + assertEquals(128L, stats.fluidColumns()); + assertEquals(16L, stats.caveVoxels()[0]); + } + } + + @Test + public void missingChunkFails() throws Exception { + Path regions = temporaryFolder.newFolder("region").toPath(); + writeRegion(regions.resolve("r.0.-1.mca"), chunk("minecraft:full")); + + try (RegionFileFidelitySource source = new RegionFileFidelitySource(regions, -64, 320)) { + assertThrows(IllegalStateException.class, () -> source.sample(CHUNK_X + 1, CHUNK_Z)); + assertThrows(IllegalStateException.class, () -> source.sample(40, 40)); + } + } + + @Test + public void partiallyGeneratedChunkFails() throws Exception { + Path regions = temporaryFolder.newFolder("region").toPath(); + writeRegion(regions.resolve("r.0.-1.mca"), chunk("minecraft:features")); + + try (RegionFileFidelitySource source = new RegionFileFidelitySource(regions, -64, 320)) { + assertThrows(IllegalStateException.class, () -> source.sample(CHUNK_X, CHUNK_Z)); + } + } + + private static CompoundTag chunk(String status) { + CompoundTag root = new CompoundTag(); + root.putInt("DataVersion", 5023); + root.putInt("xPos", CHUNK_X); + root.putInt("zPos", CHUNK_Z); + root.putInt("yPos", -4); + root.putString("Status", status); + ListTag sections = new ListTag<>(CompoundTag.class); + sections.add(section(-4, List.of("minecraft:stone", "minecraft:air"), (int x, int localY, int z) -> localY == 4 && x == 0 ? 1 : 0)); + sections.add(section(-3, List.of("minecraft:stone", "minecraft:water", "minecraft:air"), + (int x, int localY, int z) -> localY <= 8 ? 0 : x < 8 ? 1 : 2)); + sections.add(section(-2, List.of("minecraft:air"), (int x, int localY, int z) -> 0)); + root.put("sections", sections); + return root; + } + + private static CompoundTag section(int sectionY, List palette, VoxelPalette voxels) { + CompoundTag section = new CompoundTag(); + section.putByte("Y", (byte) sectionY); + CompoundTag blockStates = new CompoundTag(); + ListTag paletteTag = new ListTag<>(CompoundTag.class); + for (String name : palette) { + CompoundTag entry = new CompoundTag(); + entry.putString("Name", name); + paletteTag.add(entry); + } + blockStates.put("palette", paletteTag); + if (palette.size() > 1) { + long[] data = new long[256]; + for (int index = 0; index < 4096; index++) { + long value = voxels.index(index & 15, index >> 8, (index >> 4) & 15); + data[index / 16] |= value << ((index % 16) * 4); + } + blockStates.putLongArray("data", data); + } + section.put("block_states", blockStates); + return section; + } + + private static void writeRegion(Path file, CompoundTag chunk) throws IOException { + ByteArrayOutputStream compressed = new ByteArrayOutputStream(); + try (DeflaterOutputStream out = new DeflaterOutputStream(compressed)) { + new NBTSerializer(false).toStream(new NamedTag("", chunk), out); + } + byte[] payload = compressed.toByteArray(); + int sectors = (payload.length + 5 + 4095) / 4096; + int index = (CHUNK_X & 31) + (CHUNK_Z & 31) * 32; + try (RandomAccessFile raf = new RandomAccessFile(file.toFile(), "rw")) { + raf.setLength(8192L + sectors * 4096L); + raf.seek(index * 4L); + raf.writeInt((2 << 8) | sectors); + raf.seek(8192L); + raf.writeInt(payload.length + 1); + raf.writeByte(2); + raf.write(payload); + } + } + + @FunctionalInterface + private interface VoxelPalette { + int index(int x, int localY, int z); + } +} diff --git a/probe/src/test/java/art/arcane/iris/probe/fidelity/SurveyWindowTest.java b/probe/src/test/java/art/arcane/iris/probe/fidelity/SurveyWindowTest.java new file mode 100644 index 000000000..e871140ab --- /dev/null +++ b/probe/src/test/java/art/arcane/iris/probe/fidelity/SurveyWindowTest.java @@ -0,0 +1,25 @@ +package art.arcane.iris.probe.fidelity; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +public class SurveyWindowTest { + @Test + public void coordinatesSitAtStrideCellCentres() { + SurveyWindow window = new SurveyWindow(0, 0, 65536, 256); + + assertEquals(256, window.perAxis()); + assertEquals(65536L, window.samples()); + assertEquals(-32768 + 128, window.coordinate(0, 0)); + assertEquals(32768 - 128, window.coordinate(0, 255)); + } + + @Test + public void windowMustBeAPositiveMultipleOfTheStride() { + assertThrows(IllegalArgumentException.class, () -> new SurveyWindow(0, 0, 1000, 256)); + assertThrows(IllegalArgumentException.class, () -> new SurveyWindow(0, 0, 128, 256)); + assertThrows(IllegalArgumentException.class, () -> new SurveyWindow(0, 0, 256, 0)); + } +} diff --git a/probe/src/test/java/art/arcane/iris/probe/fidelity/SyntheticFidelityChunk.java b/probe/src/test/java/art/arcane/iris/probe/fidelity/SyntheticFidelityChunk.java new file mode 100644 index 000000000..84adf44e3 --- /dev/null +++ b/probe/src/test/java/art/arcane/iris/probe/fidelity/SyntheticFidelityChunk.java @@ -0,0 +1,184 @@ +package art.arcane.iris.probe.fidelity; + +final class SyntheticFidelityChunk implements FidelityChunk { + private static final int CANOPY_DEPTH = 3; + + private final int minY; + private int surfaceY = 64; + private int seaLevel = Integer.MIN_VALUE; + private int biomeSplit = 16; + private String westBiome = "plains"; + private String eastBiome = "forest"; + private int caveMinY; + private int caveMaxY; + private int caveColumns; + private int treeColumns; + private int treeHeight; + private int canopyColumns; + private int canopyY; + private String canopyCap; + private int roofColumns; + private int roofMinY; + private int roofMaxY; + private int lavaMinY; + private int lavaMaxY; + private int lavaColumns; + private int wallThickness; + private int overhangColumns; + private int overhangMinY; + private int overhangMaxY; + private boolean blocks = true; + + SyntheticFidelityChunk(int minY) { + this.minY = minY; + } + + SyntheticFidelityChunk surface(int y) { + surfaceY = y; + return this; + } + + SyntheticFidelityChunk sea(int y) { + seaLevel = y; + return this; + } + + SyntheticFidelityChunk biomes(String west, String east, int split) { + westBiome = west; + eastBiome = east; + biomeSplit = split; + return this; + } + + SyntheticFidelityChunk caves(int fromY, int toY, int columns) { + caveMinY = fromY; + caveMaxY = toY; + caveColumns = columns; + return this; + } + + SyntheticFidelityChunk trees(int columns, int height) { + treeColumns = columns; + treeHeight = height; + return this; + } + + SyntheticFidelityChunk canopy(int columns, int leavesY, String cap) { + canopyColumns = columns; + canopyY = leavesY; + canopyCap = cap; + return this; + } + + SyntheticFidelityChunk roof(int columns, int fromY, int toY) { + roofColumns = columns; + roofMinY = fromY; + roofMaxY = toY; + return this; + } + + SyntheticFidelityChunk lavaPool(int fromY, int toY, int columns) { + lavaMinY = fromY; + lavaMaxY = toY; + lavaColumns = columns; + return this; + } + + SyntheticFidelityChunk walls(int thickness) { + wallThickness = thickness; + return this; + } + + SyntheticFidelityChunk overhang(int columns, int fromY, int toY) { + overhangColumns = columns; + overhangMinY = fromY; + overhangMaxY = toY; + return this; + } + + SyntheticFidelityChunk noBlocks() { + blocks = false; + return this; + } + + @Override + public int surfaceY(int localX, int localZ) { + if (localX < roofColumns) { + return roofMaxY; + } + if (localX < canopyColumns) { + return canopyY + CANOPY_DEPTH; + } + return localX < treeColumns ? surfaceY + treeHeight : surfaceY; + } + + @Override + public String surfaceBiome(int localX, int localZ) { + return localX < biomeSplit ? westBiome : eastBiome; + } + + @Override + public boolean open(int localX, int y, int localZ) { + String id = id(localX, y, localZ); + return id.equals("minecraft:air") || id.equals("minecraft:cave_air"); + } + + @Override + public boolean fluid(int localX, int y, int localZ) { + String id = id(localX, y, localZ); + return id.equals("minecraft:water") || id.equals("minecraft:lava"); + } + + @Override + public String block(int localX, int y, int localZ) { + return blocks ? id(localX, y, localZ) : null; + } + + @Override + public boolean terrainMass(int localX, int y, int localZ) { + return y <= surfaceY && !overhang(localX, y); + } + + @Override + public boolean wallsKnown() { + return wallThickness > 0; + } + + @Override + public boolean wall(int localX, int y, int localZ) { + return localX >= caveColumns && localX < caveColumns + wallThickness && y >= caveMinY && y < caveMaxY; + } + + private String id(int localX, int y, int localZ) { + if (y < minY) { + return "minecraft:void_air"; + } + if (y <= surfaceY) { + if (localX < lavaColumns && y >= lavaMinY && y < lavaMaxY) { + return "minecraft:lava"; + } + if (overhang(localX, y)) { + return "minecraft:air"; + } + boolean cave = localX < caveColumns && y >= caveMinY && y < caveMaxY; + return cave ? "minecraft:cave_air" : y == surfaceY ? "minecraft:grass_block" : "minecraft:stone"; + } + if (localX < roofColumns && y >= roofMinY && y <= roofMaxY) { + return "minecraft:stone"; + } + if (localX < canopyColumns && y >= canopyY && y <= canopyY + CANOPY_DEPTH) { + return y == canopyY + CANOPY_DEPTH ? canopyCap : "minecraft:spruce_leaves"; + } + if (localX < treeColumns && y <= surfaceY + treeHeight) { + return y == surfaceY + treeHeight ? "minecraft:oak_leaves" : "minecraft:oak_log"; + } + if (y <= seaLevel) { + return "minecraft:water"; + } + return "minecraft:air"; + } + + private boolean overhang(int localX, int y) { + return localX < overhangColumns && y >= overhangMinY && y < overhangMaxY; + } +} diff --git a/spi/src/main/java/art/arcane/iris/spi/IrisPlatform.java b/spi/src/main/java/art/arcane/iris/spi/IrisPlatform.java index f0abe7cf9..905be6940 100644 --- a/spi/src/main/java/art/arcane/iris/spi/IrisPlatform.java +++ b/spi/src/main/java/art/arcane/iris/spi/IrisPlatform.java @@ -68,6 +68,8 @@ public interface IrisPlatform { */ PlatformBiomeWriter biomeWriter(); + VanillaDataSource vanillaData(); + default Class classifyMantleValue(Object value) { return value.getClass(); } diff --git a/spi/src/main/java/art/arcane/iris/spi/VanillaDataSource.java b/spi/src/main/java/art/arcane/iris/spi/VanillaDataSource.java new file mode 100644 index 000000000..83298be54 --- /dev/null +++ b/spi/src/main/java/art/arcane/iris/spi/VanillaDataSource.java @@ -0,0 +1,10 @@ +package art.arcane.iris.spi; + +import java.util.List; +import java.util.Optional; + +public interface VanillaDataSource { + Optional read(String dataPath); + + List list(String prefix); +} diff --git a/spi/src/test/java/art/arcane/iris/spi/IrisPlatformsBindContractTest.java b/spi/src/test/java/art/arcane/iris/spi/IrisPlatformsBindContractTest.java index db42bc4d2..cc992b43c 100644 --- a/spi/src/test/java/art/arcane/iris/spi/IrisPlatformsBindContractTest.java +++ b/spi/src/test/java/art/arcane/iris/spi/IrisPlatformsBindContractTest.java @@ -121,6 +121,11 @@ public class IrisPlatformsBindContractTest { throw new UnsupportedOperationException(); } + @Override + public VanillaDataSource vanillaData() { + throw new UnsupportedOperationException(); + } + @Override public File dataFolder() { throw new UnsupportedOperationException();