diff --git a/.gitignore b/.gitignore index f6ab8351f..e6a217e46 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ TreeGenStuff/ dist/ core/plugins/ adapters/bukkit/plugin/plugins/ +adapters/fabric/run/ diff --git a/adapters/fabric/build.gradle b/adapters/fabric/build.gradle index c6b3fdbf9..c50dbc03c 100644 --- a/adapters/fabric/build.gradle +++ b/adapters/fabric/build.gradle @@ -49,6 +49,7 @@ repositories { } maven { url = uri('https://repo.codemc.org/repository/maven-public/') } maven { url = uri('https://jitpack.io') } + maven { url = uri('https://hub.spigotmc.org/nexus/content/repositories/snapshots/') } } configurations { @@ -56,6 +57,10 @@ configurations { canBeConsumed = false canBeResolved = true } + devBundle { + canBeConsumed = false + canBeResolved = true + } } configurations.named('bundle').configure { @@ -71,28 +76,43 @@ configurations.named('bundle').configure { exclude(group: 'io.github.slimjar') } +configurations.compileClasspath.extendsFrom(configurations.devBundle) +configurations.runtimeClasspath.extendsFrom(configurations.devBundle) + dependencies { minecraft("com.mojang:minecraft:${minecraftVersion}") implementation("net.fabricmc:fabric-loader:${fabricLoaderVersion}") + implementation('net.fabricmc.fabric-api:fabric-api-base:2.0.3+ece063234c') + implementation('net.fabricmc.fabric-api:fabric-registry-sync-v0:7.1.0+2fa62b4e4c') + implementation('net.fabricmc.fabric-api:fabric-resource-loader-v1:2.0.10+7c44c7324c') + implementation('net.fabricmc.fabric-api:fabric-lifecycle-events-v1:4.1.0+6d50a0854c') compileOnly('org.slf4j:slf4j-api:2.0.17') + compileOnly(libs.spigot) { + transitive = false + } - bundle("art.arcane:core:${irisVersion}") - bundle("art.arcane:spi:${irisVersion}") - bundle('com.github.VolmitSoftware:VolmLib:master-SNAPSHOT') - - bundle(libs.paralithic) - bundle(libs.lru) - bundle(libs.kotlin.stdlib) - bundle(libs.kotlin.coroutines) - bundle(libs.commons.lang) - bundle(libs.commons.math3) - bundle(libs.caffeine) - bundle(libs.lz4) - bundle(libs.zip) - bundle(libs.sentry) - bundle(libs.oshi) - bundle(libs.byteBuddy.core) - bundle(libs.byteBuddy.agent) + List shared = [ + "art.arcane:core:${irisVersion}".toString(), + "art.arcane:spi:${irisVersion}".toString(), + 'com.github.VolmitSoftware:VolmLib:master-SNAPSHOT', + libs.paralithic, + libs.lru, + libs.kotlin.stdlib, + libs.kotlin.coroutines, + libs.commons.lang, + libs.commons.math3, + libs.caffeine, + libs.lz4, + libs.zip, + libs.sentry, + libs.oshi, + libs.byteBuddy.core, + libs.byteBuddy.agent + ] + shared.each { Object notation -> + add('bundle', notation) + add('devBundle', notation) + } } tasks.named('compileJava', JavaCompile).configure { @@ -100,6 +120,31 @@ tasks.named('compileJava', JavaCompile).configure { options.release.set(25) } +loom { + runs { + server { + String parity = providers.gradleProperty('irisParity').getOrNull() + if (parity != null) { + property('iris.parity', parity) + } + String parityGolden = providers.gradleProperty('irisParityGolden').getOrNull() + if (parityGolden != null) { + property('iris.parity.golden', parityGolden) + } + String parityDeep = providers.gradleProperty('irisParityDeep').getOrNull() + if (parityDeep != null) { + property('iris.parity.deep', parityDeep) + } + String worldCheck = providers.gradleProperty('irisWorldCheck').getOrNull() + if (worldCheck != null) { + property('iris.worldcheck', worldCheck) + } + vmArg('-Xmx8G') + programArgs('nogui') + } + } +} + processResources { inputs.property('version', project.version) inputs.property('minecraftVersion', minecraftVersion) diff --git a/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricBiome.java b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricBiome.java new file mode 100644 index 000000000..eaa4c4210 --- /dev/null +++ b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricBiome.java @@ -0,0 +1,58 @@ +/* + * 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.fabric; + +import art.arcane.iris.spi.PlatformBiome; +import net.minecraft.world.level.biome.Biome; + +import java.util.concurrent.ConcurrentHashMap; + +public final class FabricBiome implements PlatformBiome { + private static final ConcurrentHashMap CACHE = new ConcurrentHashMap<>(); + + private final Biome biome; + private final String key; + private final String namespace; + + private FabricBiome(Biome biome, String key) { + this.biome = biome; + this.key = key; + int colon = key.indexOf(':'); + this.namespace = colon >= 0 ? key.substring(0, colon) : "minecraft"; + } + + public static FabricBiome of(Biome biome, String key) { + return CACHE.computeIfAbsent(key, (String k) -> new FabricBiome(biome, k)); + } + + @Override + public String key() { + return key; + } + + @Override + public String namespace() { + return namespace; + } + + @Override + public Object nativeHandle() { + return biome; + } +} diff --git a/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricBiomeWriter.java b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricBiomeWriter.java new file mode 100644 index 000000000..750846fb2 --- /dev/null +++ b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricBiomeWriter.java @@ -0,0 +1,36 @@ +/* + * 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.fabric; + +import art.arcane.iris.spi.PlatformBiome; +import art.arcane.iris.spi.PlatformBiomeWriter; + +import java.util.List; + +public final class FabricBiomeWriter implements PlatformBiomeWriter { + @Override + public int biomeIdFor(String key) { + return 0; + } + + @Override + public List allBiomes() { + return List.of(); + } +} diff --git a/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricBlockBuffer.java b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricBlockBuffer.java new file mode 100644 index 000000000..00d2eeaa5 --- /dev/null +++ b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricBlockBuffer.java @@ -0,0 +1,90 @@ +/* + * 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.fabric; + +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.project.hunk.Hunk; + +public final class FabricBlockBuffer implements Hunk { + private final PlatformBlockState[] data; + private final PlatformBlockState air; + private final int height; + + public FabricBlockBuffer(int height, PlatformBlockState air) { + this.data = new PlatformBlockState[16 * height * 16]; + this.air = air; + this.height = height; + } + + private int index(int x, int y, int z) { + return (y * 16 + z) * 16 + x; + } + + public boolean isAir(int x, int y, int z) { + return data[index(x, y, z)] == null; + } + + @Override + public int getWidth() { + return 16; + } + + @Override + public int getDepth() { + return 16; + } + + @Override + public int getHeight() { + return height; + } + + @Override + public void set(int x, int y, int z, PlatformBlockState t) { + if (t == null) { + return; + } + if (x < 0 || y < 0 || z < 0 || x >= 16 || y >= height || z >= 16) { + return; + } + data[index(x, y, z)] = t; + } + + @Override + public void setRaw(int x, int y, int z, PlatformBlockState t) { + if (t == null) { + return; + } + data[index(x, y, z)] = t; + } + + @Override + public PlatformBlockState getRaw(int x, int y, int z) { + PlatformBlockState state = data[index(x, y, z)]; + return state == null ? air : state; + } + + @Override + public PlatformBlockState get(int x, int y, int z) { + if (x < 0 || y < 0 || z < 0 || x >= 16 || y >= height || z >= 16) { + return air; + } + return getRaw(x, y, z); + } +} diff --git a/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricBlockResolution.java b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricBlockResolution.java new file mode 100644 index 000000000..869e1b2fe --- /dev/null +++ b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricBlockResolution.java @@ -0,0 +1,497 @@ +/* + * 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.fabric; + +import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.spi.IrisLogging; +import com.mojang.brigadier.StringReader; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import net.minecraft.commands.arguments.blocks.BlockStateParser; +import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.Identifier; +import net.minecraft.world.level.EmptyBlockGetter; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.LeavesBlock; +import net.minecraft.world.level.block.PointedDripstoneBlock; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.properties.BlockStateProperties; +import net.minecraft.world.level.block.state.properties.DripstoneThickness; +import net.minecraft.world.level.block.state.properties.Property; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +public final class FabricBlockResolution { + private static final Set FOLIAGE = blockSet( + "poppy", "dandelion", "cornflower", "sweet_berry_bush", "crimson_roots", "warped_roots", + "nether_sprouts", "allium", "azure_bluet", "blue_orchid", "oxeye_daisy", "lily_of_the_valley", + "wither_rose", "dark_oak_sapling", "acacia_sapling", "jungle_sapling", "birch_sapling", + "spruce_sapling", "oak_sapling", "orange_tulip", "pink_tulip", "red_tulip", "white_tulip", + "fern", "large_fern", "short_grass", "tall_grass"); + private static final Set DECORANT = decorantSet(); + private static final Set LIT = blockSet( + "glowstone", "amethyst_cluster", "small_amethyst_bud", "medium_amethyst_bud", "large_amethyst_bud", + "end_rod", "soul_sand", "torch", "redstone_torch", "soul_torch", "redstone_wall_torch", "wall_torch", + "soul_wall_torch", "lantern", "candle", "jack_o_lantern", "redstone_lamp", "magma_block", "light", + "shroomlight", "sea_lantern", "soul_lantern", "fire", "soul_fire", "sea_pickle", "brewing_stand", + "redstone_ore"); + private static final Set STORAGE = blockSet( + "chest", "smoker", "trapped_chest", "shulker_box", "white_shulker_box", "orange_shulker_box", + "magenta_shulker_box", "light_blue_shulker_box", "yellow_shulker_box", "lime_shulker_box", + "pink_shulker_box", "gray_shulker_box", "light_gray_shulker_box", "cyan_shulker_box", + "purple_shulker_box", "blue_shulker_box", "brown_shulker_box", "green_shulker_box", + "red_shulker_box", "black_shulker_box", "barrel", "dispenser", "dropper", "hopper", "furnace", + "blast_furnace"); + private static final Set STORAGE_CHEST = storageChestSet(); + private static final Set DEEPSLATE = blockSet( + "deepslate", "deepslate_bricks", "deepslate_brick_slab", "deepslate_brick_stairs", + "deepslate_brick_wall", "deepslate_tile_slab", "deepslate_tiles", "deepslate_tile_stairs", + "deepslate_tile_wall", "cracked_deepslate_tiles", "deepslate_coal_ore", "deepslate_iron_ore", + "deepslate_copper_ore", "deepslate_diamond_ore", "deepslate_emerald_ore", "deepslate_gold_ore", + "deepslate_lapis_ore", "deepslate_redstone_ore"); + private static final Map NORMAL_TO_DEEPSLATE = oreMap(false); + private static final Map DEEPSLATE_TO_NORMAL = oreMap(true); + private static final Set FOLIAGE_PLANTABLE_STATE = blockSet( + "grass_block", "moss_block", "rooted_dirt", "dirt", "coarse_dirt", "podzol"); + private static final Set FOLIAGE_PLANTABLE_MATERIAL = blockSet( + "grass_block", "moss_block", "dirt", "tall_grass", "tall_seagrass", "large_fern", "sunflower", + "peony", "lilac", "rose_bush", "rooted_dirt", "coarse_dirt", "podzol"); + private static final Set PLACE_ONTO_LEAVES = blockSet( + "acacia_leaves", "birch_leaves", "dark_oak_leaves", "jungle_leaves", "oak_leaves", "spruce_leaves"); + private static final BlockState AIR = Blocks.AIR.defaultBlockState(); + private static long lastWarnMs; + + private FabricBlockResolution() { + } + + record Parsed(BlockState state, Map, Comparable> properties) { + } + + private static Set blockSet(String... ids) { + Set blocks = new HashSet<>(); + for (String id : ids) { + Identifier identifier = Identifier.parse("minecraft:" + id); + if (BuiltInRegistries.BLOCK.containsKey(identifier)) { + blocks.add(BuiltInRegistries.BLOCK.getValue(identifier)); + } + } + return blocks; + } + + private static Set decorantSet() { + Set blocks = blockSet( + "short_grass", "tall_grass", "fern", "large_fern", "cornflower", "sunflower", "chorus_flower", + "poppy", "dandelion", "oxeye_daisy", "orange_tulip", "pink_tulip", "red_tulip", "white_tulip", + "lilac", "dead_bush", "sweet_berry_bush", "rose_bush", "wither_rose", "allium", "blue_orchid", + "lily_of_the_valley", "crimson_fungus", "warped_fungus", "red_mushroom", "brown_mushroom", + "crimson_roots", "azure_bluet", "weeping_vines", "weeping_vines_plant", "warped_roots", + "nether_sprouts", "twisting_vines", "twisting_vines_plant", "sugar_cane", "wheat", "potatoes", + "carrots", "beetroots", "nether_wart", "sea_pickle", "seagrass", "tall_seagrass", + "acacia_button", "birch_button", "crimson_button", "dark_oak_button", "jungle_button", + "oak_button", "polished_blackstone_button", "spruce_button", "stone_button", "warped_button", + "torch", "soul_torch", "glow_lichen", "vine", "sculk_vein"); + blocks.addAll(FOLIAGE); + return blocks; + } + + private static Set storageChestSet() { + Set blocks = new HashSet<>(STORAGE); + blocks.remove(Blocks.SMOKER); + blocks.remove(Blocks.FURNACE); + blocks.remove(Blocks.BLAST_FURNACE); + return blocks; + } + + private static Map oreMap(boolean deepslateToNormal) { + String[][] pairs = { + {"coal_ore", "deepslate_coal_ore"}, + {"emerald_ore", "deepslate_emerald_ore"}, + {"diamond_ore", "deepslate_diamond_ore"}, + {"copper_ore", "deepslate_copper_ore"}, + {"gold_ore", "deepslate_gold_ore"}, + {"iron_ore", "deepslate_iron_ore"}, + {"lapis_ore", "deepslate_lapis_ore"}, + {"redstone_ore", "deepslate_redstone_ore"} + }; + Map map = new HashMap<>(); + for (String[] pair : pairs) { + Block normal = BuiltInRegistries.BLOCK.getValue(Identifier.parse("minecraft:" + pair[0])); + Block deep = BuiltInRegistries.BLOCK.getValue(Identifier.parse("minecraft:" + pair[1])); + if (deepslateToNormal) { + map.put(deep, normal); + } else { + map.put(normal, deep); + } + } + return map; + } + + private static boolean shouldWarn() { + long now = System.currentTimeMillis(); + if (now - lastWarnMs >= 1000) { + lastWarnMs = now; + return true; + } + return false; + } + + public static BlockState airState() { + return AIR; + } + + public static FabricBlockState getAir() { + return FabricBlockState.of(AIR, null); + } + + public static FabricBlockState get(String bdxf) { + Parsed parsed = resolveGet(bdxf); + return FabricBlockState.of(parsed.state(), parsed.properties()); + } + + public static FabricBlockState getNoCompat(String bdxf) { + Parsed parsed = resolveNoCompat(bdxf); + return FabricBlockState.of(parsed.state(), parsed.properties()); + } + + public static FabricBlockState getOrNull(String bdxf) { + return getOrNull(bdxf, false); + } + + public static FabricBlockState getOrNull(String bdxf, boolean warn) { + Parsed parsed = resolveOrNull(bdxf, warn); + return parsed == null ? null : FabricBlockState.of(parsed.state(), parsed.properties()); + } + + static Parsed resolveGet(String bdxf) { + Parsed parsed = resolveOrNull(bdxf, false); + if (parsed != null) { + return parsed; + } + IrisLogging.error("Can't find block data for " + bdxf); + return resolveNoCompat("STONE"); + } + + static Parsed resolveNoCompat(String bdxf) { + Parsed parsed = resolveOrNull(bdxf, true); + if (parsed != null) { + return parsed; + } + return new Parsed(AIR, null); + } + + static Parsed resolveOrNull(String bdxf, boolean warn) { + try { + String bd = bdxf.trim(); + + if (bd.startsWith("minecraft:cauldron[level=")) { + bd = bd.replaceAll("\\Q:cauldron[\\E", ":water_cauldron["); + } + + if (bd.equals("minecraft:grass_path")) { + return new Parsed(Blocks.DIRT_PATH.defaultBlockState(), null); + } + + Parsed bdx = parseBlockData(bd, warn); + + if (bdx == null) { + if (warn && shouldWarn()) { + IrisLogging.warn("Unknown Block Data '" + bd + "'"); + } + return new Parsed(AIR, null); + } + + return bdx; + } catch (Throwable e) { + e.printStackTrace(); + if (warn && shouldWarn()) { + IrisLogging.warn("Unknown Block Data '" + bdxf + "'"); + } + } + + return null; + } + + public static FabricBlockState strictParse(String key) { + Parsed parsed = parseStrict(key); + return FabricBlockState.of(parsed.state(), parsed.properties()); + } + + private static Parsed parseStrict(String key) { + StringReader reader = new StringReader(key); + BlockStateParser.BlockResult result; + try { + result = BlockStateParser.parseForBlock(BuiltInRegistries.BLOCK, reader, false); + } catch (CommandSyntaxException e) { + throw new IllegalArgumentException("Could not parse data: " + key, e); + } + if (reader.canRead()) { + throw new IllegalArgumentException("Could not parse remainder: " + reader.getRemaining()); + } + return new Parsed(result.blockState(), result.properties()); + } + + private static Parsed createBlockData(String s, boolean warn) { + try { + return parseStrict(s); + } catch (IllegalArgumentException e) { + if (s.contains("[")) { + return createBlockData(s.split("\\Q[\\E")[0], warn); + } + } + + if (warn) { + IrisLogging.warn("Can't find block data for " + s); + } + return null; + } + + private static Parsed materialBlockData(String ix) { + if (ix.contains("[") || ix.contains(":")) { + return null; + } + Identifier identifier = Identifier.tryParse("minecraft:" + ix.toLowerCase(Locale.ROOT)); + if (identifier == null || !BuiltInRegistries.BLOCK.containsKey(identifier)) { + return null; + } + return new Parsed(BuiltInRegistries.BLOCK.getValue(identifier).defaultBlockState(), null); + } + + private static Parsed parseBlockData(String ix, boolean warn) { + try { + Parsed bx = createBlockData(ix.toLowerCase(), warn); + + if (bx == null) { + bx = createBlockData("minecraft:" + ix.toLowerCase(), warn); + } + + if (bx == null) { + bx = materialBlockData(ix); + } + + if (bx == null) { + if (warn && shouldWarn()) { + IrisLogging.warn("Unknown Block Data: " + ix); + } + return null; + } + + if (bx.state().getBlock() instanceof LeavesBlock) { + BlockState mutated = bx.state().setValue(LeavesBlock.PERSISTENT, shouldPreventLeafDecay()); + bx = new Parsed(mutated, bx.properties()); + } + + return bx; + } catch (Throwable e) { + String block = ix.contains(":") ? ix.split(":")[1].toLowerCase() : ix.toLowerCase(); + String state = block.contains("[") ? block.split("\\Q[\\E")[1].split("\\Q]\\E")[0] : ""; + Map stateMap = new HashMap<>(); + if (!state.equals("")) { + Arrays.stream(state.split(",")).forEach((String s) -> stateMap.put(s.split("=")[0], s.split("=")[1])); + } + block = block.split("\\Q[\\E")[0]; + + switch (block) { + case "cauldron" -> block = "water_cauldron"; + case "grass_path" -> block = "dirt_path"; + case "concrete" -> block = "white_concrete"; + case "wool" -> block = "white_wool"; + case "beetroots" -> { + if (stateMap.containsKey("age")) { + String updated = stateMap.get("age"); + switch (updated) { + case "7" -> updated = "3"; + case "3", "4", "5" -> updated = "2"; + case "1", "2" -> updated = "1"; + } + stateMap.put("age", updated); + } + } + } + + Map newStates = new HashMap<>(); + for (String key : stateMap.keySet()) { + createBlockData(block + "[" + key + "=" + stateMap.get(key) + "]", false); + newStates.put(key, stateMap.get(key)); + } + + String joined = newStates.entrySet().stream() + .map((Map.Entry entry) -> entry.getKey() + "=" + entry.getValue()) + .collect(Collectors.joining(",")); + if (!joined.equals("")) { + joined = "[" + joined + "]"; + } + String newBlock = block + joined; + IrisLogging.debug("Converting " + ix + " to " + newBlock); + + try { + return createBlockData(newBlock, false); + } catch (Throwable e1) { + IrisLogging.reportError(e1); + } + + return null; + } + } + + private static boolean shouldPreventLeafDecay() { + return IrisSettings.get().getGenerator().isPreventLeafDecay(); + } + + public static BlockState toDeepSlateOre(BlockState block, BlockState ore) { + Block key = ore.getBlock(); + + if (isDeepSlate(block)) { + Block mapped = NORMAL_TO_DEEPSLATE.get(key); + if (mapped != null) { + return mapped.defaultBlockState(); + } + } else { + Block mapped = DEEPSLATE_TO_NORMAL.get(key); + if (mapped != null) { + return mapped.defaultBlockState(); + } + } + + return ore; + } + + public static boolean isDeepSlate(BlockState state) { + return DEEPSLATE.contains(state.getBlock()); + } + + public static boolean isOre(BlockState state) { + return BuiltInRegistries.BLOCK.getKey(state.getBlock()).getPath().endsWith("_ore"); + } + + public static boolean isAir(BlockState state) { + if (state == null) { + return true; + } + Block block = state.getBlock(); + return block == Blocks.AIR || block == Blocks.CAVE_AIR || block == Blocks.VOID_AIR; + } + + public static boolean isSolid(BlockState state) { + if (state == null) { + return false; + } + return isSolidMaterial(state.getBlock()); + } + + private static boolean isSolidMaterial(Block block) { + return block.defaultBlockState().blocksMotion(); + } + + public static boolean isOccluding(BlockState state) { + return state.getBlock().defaultBlockState().isRedstoneConductor(EmptyBlockGetter.INSTANCE, BlockPos.ZERO); + } + + public static boolean isFluid(BlockState state) { + Block block = state.getBlock(); + return block == Blocks.WATER || block == Blocks.LAVA; + } + + public static boolean isWater(BlockState state) { + return state.getBlock() == Blocks.WATER; + } + + public static boolean isWaterLogged(BlockState state) { + return state.hasProperty(BlockStateProperties.WATERLOGGED) && state.getValue(BlockStateProperties.WATERLOGGED); + } + + public static boolean isLit(BlockState state) { + return LIT.contains(state.getBlock()); + } + + public static boolean isUpdatable(BlockState state) { + return isStorage(state) + || (state.getBlock() instanceof PointedDripstoneBlock + && state.getValue(PointedDripstoneBlock.THICKNESS) == DripstoneThickness.TIP); + } + + public static boolean isFoliage(BlockState state) { + return FOLIAGE.contains(state.getBlock()); + } + + public static boolean isFoliagePlantable(BlockState state) { + return FOLIAGE_PLANTABLE_STATE.contains(state.getBlock()); + } + + public static boolean isDecorant(BlockState state) { + return DECORANT.contains(state.getBlock()); + } + + public static boolean isStorage(BlockState state) { + return STORAGE.contains(state.getBlock()); + } + + public static boolean isStorageChest(BlockState state) { + return STORAGE_CHEST.contains(state.getBlock()); + } + + public static boolean isVineBlock(BlockState state) { + Block block = state.getBlock(); + return block == Blocks.VINE || block == Blocks.SCULK_VEIN || block == Blocks.GLOW_LICHEN; + } + + public static boolean hasTileEntity(BlockState state) { + return state.getBlock().defaultBlockState().hasBlockEntity(); + } + + public static boolean canPlaceOnto(Block mat, Block onto) { + if ((onto == Blocks.CRIMSON_NYLIUM || onto == Blocks.WARPED_NYLIUM) + && (mat == Blocks.CRIMSON_FUNGUS || mat == Blocks.CRIMSON_ROOTS + || mat == Blocks.WARPED_FUNGUS || mat == Blocks.WARPED_ROOTS)) { + return true; + } + + if (FOLIAGE.contains(mat)) { + if (!FOLIAGE_PLANTABLE_MATERIAL.contains(onto)) { + return false; + } + } + + if (onto == Blocks.AIR || onto == Blocks.CAVE_AIR || onto == Blocks.VOID_AIR) { + return false; + } + + if (onto == Blocks.GRASS_BLOCK && mat == Blocks.DEAD_BUSH) { + return false; + } + + if (onto == Blocks.DIRT_PATH) { + if (!isSolidMaterial(mat)) { + return false; + } + } + + if (PLACE_ONTO_LEAVES.contains(onto)) { + return isSolidMaterial(mat); + } + + return true; + } +} diff --git a/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricBlockState.java b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricBlockState.java new file mode 100644 index 000000000..6315595f3 --- /dev/null +++ b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricBlockState.java @@ -0,0 +1,308 @@ +/* + * 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.fabric; + +import art.arcane.iris.spi.PlatformBlockState; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.properties.Property; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +public final class FabricBlockState implements PlatformBlockState { + private static final ConcurrentHashMap CACHE = new ConcurrentHashMap<>(); + + private final BlockState state; + private final Map, Comparable> parsedProperties; + private final String key; + private final String namespace; + private volatile Boolean air; + private volatile Boolean solid; + private volatile Boolean occluding; + private volatile Boolean fluid; + private volatile Boolean water; + private volatile Boolean foliage; + private volatile Boolean decorant; + + private FabricBlockState(BlockState state, Map, Comparable> parsedProperties, String key) { + this.state = state; + this.parsedProperties = parsedProperties; + this.key = key; + this.namespace = parseNamespace(key); + } + + public static FabricBlockState of(BlockState state, Map, Comparable> parsedProperties) { + String key = serialize(state); + return CACHE.computeIfAbsent(key, (String k) -> new FabricBlockState(state, parsedProperties, k)); + } + + public static String serialize(BlockState state) { + StringBuilder result = new StringBuilder(BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString()); + if (!state.isSingletonState()) { + result.append('['); + result.append(state.getValues() + .map((Property.Value value) -> value.property().getName() + "=" + value.valueName()) + .collect(Collectors.joining(","))); + result.append(']'); + } + return result.toString(); + } + + public BlockState handle() { + return state; + } + + Map, Comparable> parsedProperties() { + return parsedProperties; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof FabricBlockState blockState)) { + return false; + } + return state.equals(blockState.state); + } + + @Override + public int hashCode() { + return key.hashCode(); + } + + private static String parseNamespace(String key) { + String base = key; + int bracket = base.indexOf('['); + if (bracket >= 0) { + base = base.substring(0, bracket); + } + int colon = base.indexOf(':'); + return colon >= 0 ? base.substring(0, colon) : "minecraft"; + } + + private static String mergeProperty(String key, String name, String value) { + int bracket = key.indexOf('['); + if (bracket < 0) { + return key + "[" + name + "=" + value + "]"; + } + String base = key.substring(0, bracket); + String body = key.substring(bracket + 1, key.lastIndexOf(']')); + LinkedHashMap properties = new LinkedHashMap<>(); + for (String entry : body.split(",")) { + int equals = entry.indexOf('='); + if (equals < 0) { + continue; + } + properties.put(entry.substring(0, equals).trim(), entry.substring(equals + 1).trim()); + } + properties.put(name, value); + StringBuilder merged = new StringBuilder(base).append('['); + boolean first = true; + for (Map.Entry property : properties.entrySet()) { + if (!first) { + merged.append(','); + } + merged.append(property.getKey()).append('=').append(property.getValue()); + first = false; + } + return merged.append(']').toString(); + } + + @Override + public String key() { + return key; + } + + @Override + public String namespace() { + return namespace; + } + + @Override + public boolean isAir() { + Boolean cached = air; + if (cached == null) { + cached = FabricBlockResolution.isAir(state); + air = cached; + } + return cached; + } + + @Override + public boolean isSolid() { + Boolean cached = solid; + if (cached == null) { + cached = FabricBlockResolution.isSolid(state); + solid = cached; + } + return cached; + } + + @Override + public boolean isOccluding() { + Boolean cached = occluding; + if (cached == null) { + cached = FabricBlockResolution.isOccluding(state); + occluding = cached; + } + return cached; + } + + @Override + public boolean isCustom() { + return false; + } + + @Override + public boolean isFluid() { + Boolean cached = fluid; + if (cached == null) { + cached = FabricBlockResolution.isFluid(state); + fluid = cached; + } + return cached; + } + + @Override + public boolean isWater() { + Boolean cached = water; + if (cached == null) { + cached = FabricBlockResolution.isWater(state); + water = cached; + } + return cached; + } + + @Override + public boolean isWaterLogged() { + return FabricBlockResolution.isWaterLogged(state); + } + + @Override + public boolean isLit() { + return FabricBlockResolution.isLit(state); + } + + @Override + public boolean isUpdatable() { + return FabricBlockResolution.isUpdatable(state); + } + + @Override + public boolean isFoliage() { + Boolean cached = foliage; + if (cached == null) { + cached = FabricBlockResolution.isFoliage(state); + foliage = cached; + } + return cached; + } + + @Override + public boolean isFoliagePlantable() { + return FabricBlockResolution.isFoliagePlantable(state); + } + + @Override + public boolean isDecorant() { + Boolean cached = decorant; + if (cached == null) { + cached = FabricBlockResolution.isDecorant(state); + decorant = cached; + } + return cached; + } + + @Override + public boolean isStorage() { + return FabricBlockResolution.isStorage(state); + } + + @Override + public boolean isStorageChest() { + return FabricBlockResolution.isStorageChest(state); + } + + @Override + public boolean isOre() { + return FabricBlockResolution.isOre(state); + } + + @Override + public boolean isDeepSlate() { + return FabricBlockResolution.isDeepSlate(state); + } + + @Override + public boolean isVineBlock() { + return FabricBlockResolution.isVineBlock(state); + } + + @Override + public boolean canPlaceOnto(PlatformBlockState onto) { + return FabricBlockResolution.canPlaceOnto(state.getBlock(), ((BlockState) onto.nativeHandle()).getBlock()); + } + + @Override + public boolean matches(PlatformBlockState other) { + if (!(other instanceof FabricBlockState blockState)) { + return false; + } + if (state.getBlock() != blockState.state.getBlock()) { + return false; + } + if (state.equals(blockState.state)) { + return true; + } + if (blockState.parsedProperties == null) { + return false; + } + BlockState merged = state; + for (Map.Entry, Comparable> entry : blockState.parsedProperties.entrySet()) { + merged = applyProperty(merged, entry.getKey(), entry.getValue()); + } + return merged.equals(state); + } + + @SuppressWarnings("unchecked") + private static > BlockState applyProperty(BlockState state, Property property, Comparable value) { + return state.setValue((Property) property, (T) value); + } + + @Override + public boolean hasTileEntity() { + return FabricBlockResolution.hasTileEntity(state); + } + + @Override + public PlatformBlockState withProperty(String name, String value) { + String merged = mergeProperty(key, name, value); + return FabricBlockResolution.strictParse(merged); + } + + @Override + public Object nativeHandle() { + return state; + } +} diff --git a/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricDecoratorHooks.java b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricDecoratorHooks.java new file mode 100644 index 000000000..f3cabc57b --- /dev/null +++ b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricDecoratorHooks.java @@ -0,0 +1,117 @@ +/* + * 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.fabric; + +import art.arcane.iris.engine.decorator.DecoratorPlatformHooks; +import art.arcane.iris.engine.mantle.EngineMantle; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.project.hunk.Hunk; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.world.level.EmptyBlockGetter; +import net.minecraft.world.level.block.SupportType; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.properties.BooleanProperty; +import net.minecraft.world.level.block.state.properties.Property; + +import java.util.LinkedHashMap; +import java.util.Map; + +public final class FabricDecoratorHooks implements DecoratorPlatformHooks.FaceFixer, DecoratorPlatformHooks.SurfaceSturdiness { + private static final Direction[] CARTESIAN = {Direction.NORTH, Direction.EAST, Direction.SOUTH, Direction.WEST, Direction.UP, Direction.DOWN}; + private static final String[] FACE_NAMES = {"north", "east", "south", "west", "up", "down"}; + + @Override + public PlatformBlockState fixFaces(PlatformBlockState state, Hunk hunk, int rX, int rZ, int x, int y, int z, EngineMantle mantle) { + FabricBlockState fabric = (FabricBlockState) state; + BlockState cloned = fabric.handle(); + Map allowed = faceProperties(cloned); + + for (Map.Entry entry : allowed.entrySet()) { + if (cloned.getValue(entry.getValue())) { + cloned = cloned.setValue(entry.getValue(), Boolean.FALSE); + } + } + + boolean found = false; + for (Direction f : CARTESIAN) { + int yy = y + f.getStepY(); + + PlatformBlockState rs = null; + if (mantle != null) { + rs = mantle.getMantle().get(x + f.getStepX(), yy, z + f.getStepZ(), PlatformBlockState.class); + } + BlockState r = rs == null ? (BlockState) EngineMantle.AIR.nativeHandle() : (BlockState) rs.nativeHandle(); + if (isFaceSturdy(r, f.getOpposite())) { + BooleanProperty property = allowed.get(f.getSerializedName()); + if (property != null) { + found = true; + cloned = cloned.setValue(property, Boolean.TRUE); + } + continue; + } + + int xx = rX + f.getStepX(); + int zz = rZ + f.getStepZ(); + if (xx < 0 || xx > 15 || zz < 0 || zz > 15 || yy < 0 || yy > hunk.getHeight()) { + continue; + } + + r = (BlockState) hunk.get(xx, yy, zz).nativeHandle(); + if (isFaceSturdy(r, f.getOpposite())) { + BooleanProperty property = allowed.get(f.getSerializedName()); + if (property != null) { + found = true; + cloned = cloned.setValue(property, Boolean.TRUE); + } + } + } + + if (!found) { + String fallback = allowed.containsKey("down") ? "down" : "up"; + BooleanProperty property = allowed.get(fallback); + if (property != null) { + cloned = cloned.setValue(property, Boolean.TRUE); + } + } + + return FabricBlockState.of(cloned, fabric.parsedProperties()); + } + + @Override + public boolean canGoOn(PlatformBlockState surface) { + return isFaceSturdy((BlockState) surface.nativeHandle(), Direction.UP); + } + + private static boolean isFaceSturdy(BlockState state, Direction face) { + return state.isFaceSturdy(EmptyBlockGetter.INSTANCE, BlockPos.ZERO, face, SupportType.FULL); + } + + private static Map faceProperties(BlockState state) { + Map properties = new LinkedHashMap<>(); + for (String name : FACE_NAMES) { + for (Property property : state.getProperties()) { + if (property.getName().equals(name) && property instanceof BooleanProperty bool) { + properties.put(name, bool); + } + } + } + return properties; + } +} diff --git a/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricEngineBootstrap.java b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricEngineBootstrap.java new file mode 100644 index 000000000..6a930ed2d --- /dev/null +++ b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricEngineBootstrap.java @@ -0,0 +1,142 @@ +/* + * 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.fabric; + +import art.arcane.iris.engine.decorator.DecoratorPlatformHooks; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.EngineWorldManager; +import art.arcane.iris.engine.framework.EngineWorldManagerProvider; +import art.arcane.iris.engine.framework.MeteredCache; +import art.arcane.iris.engine.framework.PreservationRegistry; +import art.arcane.iris.engine.object.BlockDataMergeSupport; +import art.arcane.iris.engine.object.IrisObjectRotation; +import art.arcane.iris.engine.object.TileData; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.IrisServices; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.server.MinecraftServer; +import org.bukkit.Chunk; +import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.player.PlayerTeleportEvent; + +import java.util.function.Supplier; + +public final class FabricEngineBootstrap { + private static final Object LOCK = new Object(); + private static volatile FabricPlatform platform; + + private FabricEngineBootstrap() { + } + + public static MinecraftServer currentServer() { + Object instance = FabricLoader.getInstance().getGameInstance(); + return instance instanceof MinecraftServer server ? server : null; + } + + public static FabricPlatform bind() { + return bind(FabricEngineBootstrap::currentServer); + } + + public static FabricPlatform bind(Supplier server) { + FabricPlatform bound = platform; + if (bound != null) { + return bound; + } + synchronized (LOCK) { + if (platform != null) { + return platform; + } + FabricPlatform created = new FabricPlatform(server); + IrisPlatforms.bind(created); + IrisObjectRotation.bindFallbackRotator(new FabricStateRotator()); + BlockDataMergeSupport.bindFallbackMerger(new FabricStateMerger()); + TileData.bindFallbackReader(new FabricTileReader(server)); + FabricDecoratorHooks decoratorHooks = new FabricDecoratorHooks(); + DecoratorPlatformHooks.bind(decoratorHooks, decoratorHooks); + IrisServices.register(PreservationRegistry.class, new InertPreservation()); + IrisServices.register(EngineWorldManagerProvider.class, (EngineWorldManagerProvider) (Engine engine) -> new InertWorldManager()); + platform = created; + return created; + } + } + + private static final class InertPreservation implements PreservationRegistry { + @Override + public void register(Thread thread) { + } + + @Override + public void registerCache(MeteredCache cache) { + } + + @Override + public void dereference() { + } + } + + private static final class InertWorldManager implements EngineWorldManager { + @Override + public void close() { + } + + @Override + public int getEntityCount() { + return 0; + } + + @Override + public int getChunkCount() { + return 0; + } + + @Override + public double getEntitySaturation() { + return 0; + } + + @Override + public void onTick() { + } + + @Override + public void onSave() { + } + + @Override + public void onBlockBreak(BlockBreakEvent e) { + } + + @Override + public void onBlockPlace(BlockPlaceEvent e) { + } + + @Override + public void onChunkLoad(Chunk e, boolean generated) { + } + + @Override + public void onChunkUnload(Chunk e) { + } + + @Override + public void teleportAsync(PlayerTeleportEvent e) { + } + } +} diff --git a/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricEntityType.java b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricEntityType.java new file mode 100644 index 000000000..5bac41a1f --- /dev/null +++ b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricEntityType.java @@ -0,0 +1,58 @@ +/* + * 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.fabric; + +import art.arcane.iris.spi.PlatformEntityType; +import net.minecraft.world.entity.EntityType; + +import java.util.concurrent.ConcurrentHashMap; + +public final class FabricEntityType implements PlatformEntityType { + private static final ConcurrentHashMap CACHE = new ConcurrentHashMap<>(); + + private final EntityType type; + private final String key; + private final String namespace; + + private FabricEntityType(EntityType type, String key) { + this.type = type; + this.key = key; + int colon = key.indexOf(':'); + this.namespace = colon >= 0 ? key.substring(0, colon) : "minecraft"; + } + + public static FabricEntityType of(EntityType type, String key) { + return CACHE.computeIfAbsent(key, (String k) -> new FabricEntityType(type, k)); + } + + @Override + public String key() { + return key; + } + + @Override + public String namespace() { + return namespace; + } + + @Override + public Object nativeHandle() { + return type; + } +} diff --git a/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricItem.java b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricItem.java new file mode 100644 index 000000000..0dfa4e15e --- /dev/null +++ b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricItem.java @@ -0,0 +1,58 @@ +/* + * 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.fabric; + +import art.arcane.iris.spi.PlatformItem; +import net.minecraft.world.item.Item; + +import java.util.concurrent.ConcurrentHashMap; + +public final class FabricItem implements PlatformItem { + private static final ConcurrentHashMap CACHE = new ConcurrentHashMap<>(); + + private final Item item; + private final String key; + private final String namespace; + + private FabricItem(Item item, String key) { + this.item = item; + this.key = key; + int colon = key.indexOf(':'); + this.namespace = colon >= 0 ? key.substring(0, colon) : "minecraft"; + } + + public static FabricItem of(Item item, String key) { + return CACHE.computeIfAbsent(key, (String k) -> new FabricItem(item, k)); + } + + @Override + public String key() { + return key; + } + + @Override + public String namespace() { + return namespace; + } + + @Override + public Object nativeHandle() { + return item; + } +} diff --git a/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricParityProbe.java b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricParityProbe.java new file mode 100644 index 000000000..18423c48b --- /dev/null +++ b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricParityProbe.java @@ -0,0 +1,393 @@ +/* + * 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.fabric; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.IrisEngine; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.EngineTarget; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisWorld; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformBiome; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.project.hunk.Hunk; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.server.MinecraftServer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +public final class FabricParityProbe { + private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + private static final String DIMENSION_KEY = "overworld"; + private static final long SEED = 1337L; + private static final int BIOME_STEP = 4; + private static final String DEFAULT_EXPECTED = "922bbcbe766d"; + private static final List REPORTED = Collections.synchronizedList(new ArrayList<>()); + + private FabricParityProbe() { + } + + public static void schedule(String config) { + Thread thread = new Thread(() -> waitAndRun(config), "Iris Parity Probe"); + thread.setDaemon(true); + thread.start(); + } + + private static void waitAndRun(String config) { + long start = System.currentTimeMillis(); + MinecraftServer server = null; + while (System.currentTimeMillis() - start < 600000L) { + Object instance = FabricLoader.getInstance().getGameInstance(); + if (instance instanceof MinecraftServer candidate && candidate.isReady()) { + server = candidate; + break; + } + try { + Thread.sleep(250L); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + + if (server == null) { + LOGGER.error("[parity] server did not become ready within 10 minutes"); + return; + } + + boolean match = false; + try { + match = run(server, config); + } catch (Throwable e) { + LOGGER.error("[parity] probe failed", e); + } + + LOGGER.info("[parity] shutting down dev server (result={})", match ? "MATCH" : "MISMATCH"); + server.halt(false); + } + + private static boolean run(MinecraftServer server, String config) throws Exception { + String packPath = config; + int radius = 8; + int lastColon = config.lastIndexOf(':'); + if (lastColon > 0) { + String tail = config.substring(lastColon + 1); + try { + radius = Integer.parseInt(tail); + packPath = config.substring(0, lastColon); + } catch (NumberFormatException ignored) { + } + } + + File packSource = new File(packPath); + if (!packSource.isDirectory()) { + LOGGER.error("[parity] pack folder not found: {}", packSource.getAbsolutePath()); + return false; + } + + FabricEngineBootstrap.bind(); + FabricPlatform.errorSink(REPORTED::add); + + File workRoot = Files.createTempDirectory("iris-parity").toFile(); + File pack = clonePack(packSource, workRoot); + LOGGER.info("[parity] pack: {}", packSource.getAbsolutePath()); + LOGGER.info("[parity] work copy: {}", pack.getAbsolutePath()); + LOGGER.info("[parity] radius: {} ({} chunks)", radius, (2 * radius + 1) * (2 * radius + 1)); + + IrisData data = IrisData.get(pack); + IrisDimension dimension = data.getDimensionLoader().load(DIMENSION_KEY); + if (dimension == null) { + LOGGER.error("[parity] dimension '{}' did not load from {}", DIMENSION_KEY, pack.getAbsolutePath()); + return false; + } + + IrisWorld world = IrisWorld.builder() + .name("parity") + .seed(SEED) + .worldFolder(new File(workRoot, "world")) + .minHeight(dimension.getMinHeight()) + .maxHeight(dimension.getMaxHeight()) + .build(); + EngineTarget target = new EngineTarget(world, dimension, data); + Engine engine = new IrisEngine(target, false); + + settle(); + int minY = dimension.getMinHeight(); + int maxY = dimension.getMaxHeight(); + int height = maxY - minY; + LOGGER.info("[parity] engine up: dim={} seed={} minY={} maxY={}", engine.getDimension().getLoadKey(), engine.getSeedManager().getSeed(), minY, maxY); + + Map goldenChunks = new HashMap<>(); + String goldenCombined = null; + int goldenRadius = -1; + String goldenPath = System.getProperty("iris.parity.golden"); + if (goldenPath != null) { + List goldenLines = Files.readAllLines(Path.of(goldenPath), StandardCharsets.UTF_8); + for (String line : goldenLines) { + if (line.startsWith("#")) { + int eq = line.indexOf('='); + if (eq > 0) { + String metaKey = line.substring(1, eq); + String metaValue = line.substring(eq + 1); + if (metaKey.equals("combined")) { + goldenCombined = metaValue; + } + if (metaKey.equals("radius")) { + goldenRadius = Integer.parseInt(metaValue.trim()); + } + } + } else if (!line.isBlank()) { + int second = line.indexOf(' ', line.indexOf(' ') + 1); + goldenChunks.put(line.substring(0, second), line); + } + } + LOGGER.info("[parity] golden: {} ({} chunks, combined={})", goldenPath, goldenChunks.size(), goldenCombined); + } + + PlatformBlockState airState = IrisPlatforms.get().registries().air(); + List targets = orderedTargets(0, 0, radius); + Map lines = new TreeMap<>(); + List mismatches = new ArrayList<>(); + int failed = 0; + + for (int[] at : targets) { + int cx = at[0]; + int cz = at[1]; + drainReported(); + FabricBlockBuffer blocks = new FabricBlockBuffer(height, airState); + Hunk biomes = Hunk.newArrayHunk(16, height, 16); + List failures = new ArrayList<>(); + try { + engine.generate(cx << 4, cz << 4, blocks, biomes, false); + } catch (Throwable e) { + failures.add(e); + } + failures.addAll(drainReported()); + + if (!failures.isEmpty()) { + failed++; + LOGGER.error("[parity] chunk {},{} FAILED ({} error(s))", cx, cz, failures.size()); + for (Throwable failure : failures) { + LOGGER.error("[parity] chunk {},{} error", cx, cz, failure); + } + continue; + } + + String line = hashChunk(cx, cz, blocks, biomes, height); + lines.put(chunkKey(cx, cz), line); + + String key = cx + " " + cz; + String golden = goldenChunks.get(key); + if (golden != null && !golden.equals(line)) { + mismatches.add(key); + LOGGER.warn("[parity] chunk {} MISMATCH", key); + LOGGER.warn("[parity] golden: {}", golden); + LOGGER.warn("[parity] actual: {}", line); + if (mismatches.size() == 1) { + diffDeep(cx, cz, blocks, height, minY); + } + } + } + + List body = new ArrayList<>(lines.values()); + String combined = combinedHash(body); + String expected = goldenCombined != null && (goldenRadius == radius || goldenRadius < 0) + ? goldenCombined.substring(0, 12) + : DEFAULT_EXPECTED; + boolean combinedMatch = combined.startsWith(expected); + boolean chunkMatch = mismatches.isEmpty() && failed == 0; + boolean match = goldenChunks.isEmpty() ? combinedMatch : (chunkMatch && (radius != 8 || combinedMatch)); + + if (!goldenChunks.isEmpty()) { + LOGGER.info("[parity] per-chunk: {}/{} matched golden ({} failed)", body.size() - mismatches.size(), body.size(), failed); + } + LOGGER.info("[parity] combined={} expected={} {} ({}/{})", + combined.substring(0, 12), expected, match ? "MATCH" : "MISMATCH", body.size() - mismatches.size(), targets.size()); + return match; + } + + private static void diffDeep(int cx, int cz, FabricBlockBuffer blocks, int height, int minY) { + String deepDir = System.getProperty("iris.parity.deep"); + if (deepDir == null) { + return; + } + try { + Path goldenDump = Path.of(deepDir, cx + "_" + cz + ".txt"); + if (!Files.exists(goldenDump)) { + LOGGER.warn("[parity] no deep dump for chunk {},{} at {}", cx, cz, goldenDump); + return; + } + List golden = Files.readAllLines(goldenDump, StandardCharsets.UTF_8); + List actual = new ArrayList<>(); + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + for (int y = 0; y < height; y++) { + String state = blocks.get(x, y, z).key(); + if (!state.equals("minecraft:air") && !state.equals("minecraft:cave_air") && !state.equals("minecraft:void_air")) { + actual.add(x + " " + (y + minY) + " " + z + " " + state); + } + } + } + } + int shown = 0; + int max = Math.max(golden.size(), actual.size()); + for (int i = 0; i < max && shown < 20; i++) { + String g = i < golden.size() ? golden.get(i) : ""; + String a = i < actual.size() ? actual.get(i) : ""; + if (!g.equals(a)) { + LOGGER.warn("[parity] deep diff line {}: golden='{}' actual='{}'", i, g, a); + shown++; + } + } + File out = new File(IrisPlatforms.get().dataFolder("parity"), "deep-" + cx + "_" + cz + ".txt"); + Files.write(out.toPath(), actual, StandardCharsets.UTF_8); + LOGGER.warn("[parity] full actual dump: {}", out.getAbsolutePath()); + } catch (Throwable e) { + LOGGER.warn("[parity] deep diff failed", e); + } + } + + private static List orderedTargets(int centerX, int centerZ, int radius) { + List targets = new ArrayList<>(); + for (int dx = -radius; dx <= radius; dx++) { + for (int dz = -radius; dz <= radius; dz++) { + targets.add(new int[]{centerX + dx, centerZ + dz}); + } + } + targets.sort(Comparator.comparingInt((int[] t) -> { + int ox = t[0] - centerX; + int oz = t[1] - centerZ; + return ox * ox + oz * oz; + })); + return targets; + } + + private static String hashChunk(int chunkX, int chunkZ, FabricBlockBuffer blocks, Hunk biomes, int height) { + MessageDigest blockDigest = sha256(); + MessageDigest biomeDigest = sha256(); + Map blockCache = new HashMap<>(); + Map biomeCache = new HashMap<>(); + byte[] plains = "minecraft:plains\n".getBytes(StandardCharsets.UTF_8); + + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + for (int y = 0; y < height; y++) { + PlatformBlockState state = blocks.get(x, y, z); + byte[] bytes = blockCache.computeIfAbsent(state, (PlatformBlockState s) -> (s.key() + "\n").getBytes(StandardCharsets.UTF_8)); + blockDigest.update(bytes); + } + } + } + + for (int x = 0; x < 16; x += BIOME_STEP) { + for (int z = 0; z < 16; z += BIOME_STEP) { + for (int y = 0; y < height; y += BIOME_STEP) { + PlatformBiome biome = biomes.get(x, y, z); + byte[] bytes = biome == null + ? plains + : biomeCache.computeIfAbsent(biome, (PlatformBiome b) -> (b.key() + "\n").getBytes(StandardCharsets.UTF_8)); + biomeDigest.update(bytes); + } + } + } + + return chunkX + " " + chunkZ + " " + + HexFormat.of().formatHex(blockDigest.digest()) + " " + + HexFormat.of().formatHex(biomeDigest.digest()); + } + + private static String combinedHash(List body) { + MessageDigest digest = sha256(); + for (String line : body) { + digest.update((line + "\n").getBytes(StandardCharsets.UTF_8)); + } + return HexFormat.of().formatHex(digest.digest()); + } + + private static long chunkKey(int x, int z) { + return (((long) x) << 32) ^ (z & 0xFFFFFFFFL); + } + + private static File clonePack(File source, File workRoot) throws Exception { + File destination = new File(workRoot, source.getName()); + Process clone = new ProcessBuilder("cp", "-Rc", source.getAbsolutePath(), destination.getAbsolutePath()) + .inheritIO() + .start(); + if (clone.waitFor() != 0) { + Process copy = new ProcessBuilder("cp", "-R", source.getAbsolutePath(), destination.getAbsolutePath()) + .inheritIO() + .start(); + if (copy.waitFor() != 0) { + throw new IllegalStateException("Failed to copy pack to " + destination.getAbsolutePath()); + } + } + return destination; + } + + private static void settle() throws InterruptedException { + long quietSince = System.currentTimeMillis(); + long start = quietSince; + while (System.currentTimeMillis() - start < 15000L) { + List batch = drainReported(); + if (batch.isEmpty()) { + if (System.currentTimeMillis() - quietSince >= 1500L) { + break; + } + } else { + for (Throwable error : batch) { + LOGGER.warn("[parity] engine-init reported error (non-fatal)", error); + } + quietSince = System.currentTimeMillis(); + } + Thread.sleep(50L); + } + } + + private static List drainReported() { + synchronized (REPORTED) { + List drained = new ArrayList<>(REPORTED); + REPORTED.clear(); + return drained; + } + } + + private static MessageDigest sha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricPlatform.java b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricPlatform.java new file mode 100644 index 000000000..4d82c7480 --- /dev/null +++ b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricPlatform.java @@ -0,0 +1,191 @@ +/* + * 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.fabric; + +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.LogLevel; +import art.arcane.iris.spi.PlatformBiomeWriter; +import art.arcane.iris.spi.PlatformCapabilities; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.iris.spi.PlatformScheduler; +import art.arcane.iris.spi.PlatformStructureHooks; +import net.fabricmc.loader.api.FabricLoader; +import net.fabricmc.loader.api.ModContainer; +import net.minecraft.server.MinecraftServer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.util.function.Consumer; +import java.util.function.Supplier; + +public final class FabricPlatform implements IrisPlatform { + private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + private static volatile Consumer ERROR_SINK = null; + + private final Supplier server; + private final FabricRegistries registries; + private final FabricScheduler scheduler; + private final FabricCapabilities capabilities; + private final FabricStructureHooks structureHooks; + private final FabricBiomeWriter biomeWriter; + + public FabricPlatform(Supplier server) { + this.server = server; + this.registries = new FabricRegistries(server); + this.scheduler = new FabricScheduler(); + this.capabilities = new FabricCapabilities(); + this.structureHooks = new FabricStructureHooks(server); + this.biomeWriter = new FabricBiomeWriter(); + } + + public static void errorSink(Consumer sink) { + ERROR_SINK = sink; + } + + public MinecraftServer server() { + return server.get(); + } + + @Override + public String platformName() { + return "fabric"; + } + + @Override + public String minecraftVersion() { + return FabricLoader.getInstance().getModContainer("minecraft") + .map((ModContainer container) -> container.getMetadata().getVersion().getFriendlyString()) + .orElse("unknown"); + } + + @Override + public PlatformRegistries registries() { + return registries; + } + + @Override + public PlatformScheduler scheduler() { + return scheduler; + } + + @Override + public PlatformCapabilities capabilities() { + return capabilities; + } + + @Override + public PlatformStructureHooks structureHooks() { + return structureHooks; + } + + @Override + public PlatformBiomeWriter biomeWriter() { + return biomeWriter; + } + + @Override + public File dataFolder() { + File folder = FabricLoader.getInstance().getConfigDir().resolve("iris").toFile(); + folder.mkdirs(); + return folder; + } + + @Override + public File dataFile(String... path) { + File file = new File(dataFolder(), String.join(File.separator, path)); + file.getParentFile().mkdirs(); + return file; + } + + @Override + public File pluginJar() { + return FabricLoader.getInstance().getModContainer("irisworldgen") + .flatMap((ModContainer container) -> container.getOrigin().getPaths().stream().findFirst()) + .map((java.nio.file.Path p) -> p.toFile()) + .orElse(new File(dataFolder(), "iris-fabric.jar")); + } + + @Override + public int irisVersionNumber() { + return 0; + } + + @Override + public int minecraftVersionNumber() { + return 0; + } + + @Override + public void callEvent(Object event) { + } + + @Override + public void dispatchConsoleCommand(String command) { + } + + @Override + public void log(LogLevel level, String message) { + switch (level) { + case DEBUG -> LOGGER.debug(message); + case INFO -> LOGGER.info(message); + case WARN -> LOGGER.warn(message); + case ERROR -> LOGGER.error(message); + } + } + + @Override + public void msg(String message) { + LOGGER.info(message); + } + + @Override + public void reportError(Throwable error) { + Consumer sink = ERROR_SINK; + if (sink != null && error != null) { + sink.accept(error); + return; + } + if (error != null) { + LOGGER.error("Iris reported error", error); + } + } + + private static final class FabricCapabilities implements PlatformCapabilities { + @Override + public boolean customBiomes() { + return true; + } + + @Override + public boolean dataPacks() { + return true; + } + + @Override + public boolean structurePlacement() { + return true; + } + + @Override + public boolean regionizedThreading() { + return false; + } + } +} diff --git a/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricRegistries.java b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricRegistries.java new file mode 100644 index 000000000..8e82a99d4 --- /dev/null +++ b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricRegistries.java @@ -0,0 +1,154 @@ +/* + * 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.fabric; + +import art.arcane.iris.spi.PlatformBiome; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.spi.PlatformEntityType; +import art.arcane.iris.spi.PlatformItem; +import art.arcane.iris.spi.PlatformRegistries; +import net.minecraft.core.Registry; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.server.MinecraftServer; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.item.Item; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.block.state.BlockState; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.function.Supplier; + +public final class FabricRegistries implements PlatformRegistries { + private final Supplier server; + + public FabricRegistries(Supplier server) { + this.server = server; + } + + @Override + public PlatformBlockState block(String key) { + return FabricBlockResolution.get(key); + } + + @Override + public PlatformBlockState blockOrNull(String key) { + return FabricBlockResolution.getOrNull(key); + } + + @Override + public PlatformBlockState blockOrNull(String key, boolean warn) { + return FabricBlockResolution.getOrNull(key, warn); + } + + @Override + public PlatformBlockState air() { + return FabricBlockResolution.getAir(); + } + + @Override + public PlatformBlockState deepSlateOre(PlatformBlockState block, PlatformBlockState ore) { + BlockState result = FabricBlockResolution.toDeepSlateOre((BlockState) block.nativeHandle(), (BlockState) ore.nativeHandle()); + return FabricBlockState.of(result, null); + } + + @Override + public PlatformBiome biome(String key) { + Identifier identifier = Identifier.tryParse(key); + if (identifier == null) { + return null; + } + Registry registry = biomeRegistry(); + if (registry == null) { + return null; + } + Biome biome = registry.getValue(identifier); + return biome == null ? null : FabricBiome.of(biome, identifier.toString()); + } + + @Override + public PlatformItem item(String key) { + String normalized = key.trim().toLowerCase(Locale.ROOT).replace(' ', '_'); + if (normalized.startsWith("minecraft:")) { + normalized = normalized.substring("minecraft:".length()); + } + Identifier identifier = Identifier.tryParse("minecraft:" + normalized); + if (identifier == null || !BuiltInRegistries.ITEM.containsKey(identifier)) { + return null; + } + Item item = BuiltInRegistries.ITEM.getValue(identifier); + return FabricItem.of(item, identifier.toString()); + } + + @Override + public PlatformEntityType entity(String key) { + Identifier identifier = Identifier.tryParse(key); + if (identifier == null || !BuiltInRegistries.ENTITY_TYPE.containsKey(identifier)) { + return null; + } + EntityType type = BuiltInRegistries.ENTITY_TYPE.getValue(identifier); + return FabricEntityType.of(type, identifier.toString()); + } + + @Override + public List blockKeys() { + List keys = new ArrayList<>(); + for (Identifier identifier : BuiltInRegistries.BLOCK.keySet()) { + keys.add(identifier.toString()); + } + return keys; + } + + @Override + public List biomeKeys() { + List keys = new ArrayList<>(); + Registry registry = biomeRegistry(); + if (registry == null) { + return keys; + } + for (Identifier identifier : registry.keySet()) { + keys.add(identifier.toString()); + } + return keys; + } + + @Override + public List structureKeys() { + List keys = new ArrayList<>(); + MinecraftServer instance = server.get(); + if (instance == null) { + return keys; + } + for (Identifier identifier : instance.registryAccess().lookupOrThrow(Registries.STRUCTURE).keySet()) { + keys.add(identifier.toString()); + } + return keys; + } + + private Registry biomeRegistry() { + MinecraftServer instance = server.get(); + if (instance == null) { + return null; + } + return instance.registryAccess().lookupOrThrow(Registries.BIOME); + } +} diff --git a/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricScheduler.java b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricScheduler.java new file mode 100644 index 000000000..28e98d46c --- /dev/null +++ b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricScheduler.java @@ -0,0 +1,47 @@ +/* + * 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.fabric; + +import art.arcane.iris.spi.PlatformScheduler; +import art.arcane.iris.spi.PlatformWorld; + +public final class FabricScheduler implements PlatformScheduler { + @Override + public void global(Runnable task) { + task.run(); + } + + @Override + public void region(PlatformWorld world, int chunkX, int chunkZ, Runnable task) { + task.run(); + } + + @Override + public void async(Runnable task) { + task.run(); + } + + @Override + public void laterGlobal(Runnable task, int ticks) { + } + + @Override + public void laterRegion(PlatformWorld world, int chunkX, int chunkZ, Runnable task, int ticks) { + } +} diff --git a/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricStateMerger.java b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricStateMerger.java new file mode 100644 index 000000000..6a56b52a0 --- /dev/null +++ b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricStateMerger.java @@ -0,0 +1,74 @@ +/* + * 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.fabric; + +import art.arcane.iris.engine.object.BlockDataMergeSupport; +import art.arcane.iris.spi.PlatformBlockState; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.properties.Property; + +import java.util.Map; + +public final class FabricStateMerger implements BlockDataMergeSupport.StateMerger { + @Override + public PlatformBlockState merge(PlatformBlockState base, PlatformBlockState update) { + FabricBlockState fabricBase = (FabricBlockState) base; + FabricBlockState fabricUpdate = (FabricBlockState) update; + + try { + return FabricBlockState.of(mergeStates(fabricBase.handle(), fabricUpdate.handle(), fabricUpdate.parsedProperties()), null); + } catch (IllegalArgumentException e) { + FabricBlockResolution.Parsed normalizedBase = FabricBlockResolution.resolveGet(FabricBlockState.serialize(fabricBase.handle())); + FabricBlockResolution.Parsed normalizedUpdate = FabricBlockResolution.resolveGet(FabricBlockState.serialize(fabricUpdate.handle())); + + if (normalizedBase != null && normalizedUpdate != null) { + try { + return FabricBlockState.of(mergeStates(normalizedBase.state(), normalizedUpdate.state(), normalizedUpdate.properties()), null); + } catch (IllegalArgumentException ignored) { + return FabricBlockState.of(normalizedUpdate.state(), normalizedUpdate.properties()); + } + } + + if (normalizedUpdate != null) { + return FabricBlockState.of(normalizedUpdate.state(), normalizedUpdate.properties()); + } + + return update; + } + } + + private static BlockState mergeStates(BlockState base, BlockState update, Map, Comparable> parsedProperties) { + if (parsedProperties == null) { + throw new IllegalArgumentException("Block data not created via string parsing"); + } + if (base.getBlock() != update.getBlock()) { + throw new IllegalArgumentException("States have different types (got " + update.getBlock() + ", expected " + base.getBlock() + ")"); + } + BlockState merged = base; + for (Map.Entry, Comparable> entry : parsedProperties.entrySet()) { + merged = apply(merged, entry.getKey(), entry.getValue()); + } + return merged; + } + + @SuppressWarnings("unchecked") + private static > BlockState apply(BlockState state, Property property, Comparable value) { + return state.setValue((Property) property, (T) value); + } +} diff --git a/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricStateRotator.java b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricStateRotator.java new file mode 100644 index 000000000..99d05fffc --- /dev/null +++ b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricStateRotator.java @@ -0,0 +1,350 @@ +/* + * 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.fabric; + +import art.arcane.iris.engine.object.IrisObjectRotation; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.common.math.IrisBlockVector; +import net.minecraft.core.Direction; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.properties.BooleanProperty; +import net.minecraft.world.level.block.state.properties.EnumProperty; +import net.minecraft.world.level.block.state.properties.IntegerProperty; +import net.minecraft.world.level.block.state.properties.Property; +import net.minecraft.world.level.block.state.properties.RedstoneSide; +import net.minecraft.world.level.block.state.properties.RotationSegment; +import net.minecraft.world.level.block.state.properties.WallSide; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class FabricStateRotator implements IrisObjectRotation.StateRotator { + private static final String[] FACE_NAMES = {"north", "east", "south", "west", "up", "down"}; + private static final String[] WALL_FACE_NAMES = {"north", "south", "east", "west"}; + private static final int[][] ROTATION_CYCLE_MODS = { + {0, 1}, {-1, 2}, {-1, 1}, {-2, 1}, {-1, 0}, {-2, -1}, {-1, -1}, {-1, -2}, + {0, -1}, {1, -2}, {1, -1}, {2, -1}, {1, 0}, {2, 1}, {1, 1}, {1, 2} + }; + + @Override + public PlatformBlockState rotate(IrisObjectRotation rotation, PlatformBlockState state, int spinxx, int spinyy, int spinzz) { + FabricBlockState fabric = (FabricBlockState) state; + BlockState d = fabric.handle(); + boolean deleted = false; + + try { + int spinx = (int) (90D * (Math.ceil(Math.abs((spinxx % 360D) / 90D)))); + int spiny = (int) (90D * (Math.ceil(Math.abs((spinyy % 360D) / 90D)))); + int spinz = (int) (90D * (Math.ceil(Math.abs((spinzz % 360D) / 90D)))); + + if (!rotation.canRotate()) { + return state; + } + + Property facing = findFacing(d); + Property rotationProperty = findRotation(d); + Property axis = findAxis(d); + Map multipleFacing = findMultipleFacing(d); + Map> wall = findWall(d); + Map> wire = findWire(d); + + if (facing != null) { + Direction f = (Direction) d.getValue(facing); + IrisBlockVector bv = new IrisBlockVector(f.getStepX(), f.getStepY(), f.getStepZ()); + bv = rotation.rotate(bv.clone(), spinx, spiny, spinz); + Direction t = faceFor(bv); + + if (facing.getPossibleValues().contains(t)) { + d = apply(d, facing, t); + } else if (!FabricBlockResolution.isSolid(d.getBlock().defaultBlockState())) { + deleted = true; + } + } else if (rotationProperty != null) { + int segment = (Integer) d.getValue(rotationProperty); + int[] mods = ROTATION_CYCLE_MODS[segment]; + + IrisBlockVector bv = new IrisBlockVector(mods[0], 0, mods[1]); + bv = rotation.rotate(bv.clone(), spinx, spiny, spinz); + int[] hex = hexMods(bv); + + if (hex[0] == 0 && hex[1] == 0) { + throw new IllegalArgumentException("Invalid face, only horizontal face are allowed for this property!"); + } + double length = Math.sqrt((double) (hex[0] * hex[0] + hex[1] * hex[1])); + float angle = (float) -Math.toDegrees(Math.atan2(hex[0] / length, hex[1] / length)); + d = apply(d, rotationProperty, RotationSegment.convertToSegment(angle)); + } else if (axis != null) { + Direction.Axis current = (Direction.Axis) d.getValue(axis); + IrisBlockVector bv = axisFaceVector(current); + bv = rotation.rotate(bv.clone(), spinx, spiny, spinz); + Direction.Axis a = axisOf(bv); + + if (!a.equals(current) && axis.getPossibleValues().contains(a)) { + d = apply(d, axis, a); + } + } else if (multipleFacing.size() >= 2) { + List trueFaces = new ArrayList<>(); + for (Map.Entry entry : multipleFacing.entrySet()) { + if (d.getValue(entry.getValue())) { + trueFaces.add(entry.getKey()); + } + } + + List faces = new ArrayList<>(); + for (String name : trueFaces) { + IrisBlockVector bv = faceVector(name); + bv = rotation.rotate(bv.clone(), spinx, spiny, spinz); + String r = faceName(faceFor(bv)); + + if (multipleFacing.containsKey(r)) { + faces.add(r); + } + } + + for (String name : trueFaces) { + d = apply(d, multipleFacing.get(name), Boolean.FALSE); + } + + for (String name : faces) { + d = apply(d, multipleFacing.get(name), Boolean.TRUE); + } + } else if (wall.size() == 4) { + Map heights = new LinkedHashMap<>(); + + for (String name : WALL_FACE_NAMES) { + WallSide h = (WallSide) d.getValue(wall.get(name)); + IrisBlockVector bv = faceVector(name); + bv = rotation.rotate(bv.clone(), spinx, spiny, spinz); + String r = faceName(faceFor(bv)); + if (wall.containsKey(r)) { + heights.put(r, h); + } + } + + for (String name : WALL_FACE_NAMES) { + d = apply(d, wall.get(name), heights.getOrDefault(name, WallSide.NONE)); + } + } else if (wire.size() == 4) { + Map connections = new LinkedHashMap<>(); + + for (String name : WALL_FACE_NAMES) { + RedstoneSide connection = (RedstoneSide) d.getValue(wire.get(name)); + IrisBlockVector bv = faceVector(name); + bv = rotation.rotate(bv.clone(), spinx, spiny, spinz); + String r = faceName(faceFor(bv)); + if (wire.containsKey(r)) { + connections.put(r, connection); + } + } + + for (String name : WALL_FACE_NAMES) { + d = apply(d, wire.get(name), connections.getOrDefault(name, RedstoneSide.NONE)); + } + } + } catch (Throwable e) { + IrisLogging.reportError(e); + } + + if (deleted) { + return null; + } + + return d == fabric.handle() ? state : FabricBlockState.of(d, fabric.parsedProperties()); + } + + private static Property findFacing(BlockState state) { + for (Property property : state.getProperties()) { + if (property.getName().equals("facing") && property.getValueClass() == Direction.class) { + return property; + } + } + return null; + } + + private static Property findRotation(BlockState state) { + for (Property property : state.getProperties()) { + if (property.getName().equals("rotation") && property instanceof IntegerProperty) { + return property; + } + } + return null; + } + + private static Property findAxis(BlockState state) { + for (Property property : state.getProperties()) { + if (property.getName().equals("axis") && property.getValueClass() == Direction.Axis.class) { + return property; + } + } + return null; + } + + private static Map findMultipleFacing(BlockState state) { + Map properties = new LinkedHashMap<>(); + for (String name : FACE_NAMES) { + for (Property property : state.getProperties()) { + if (property.getName().equals(name) && property instanceof BooleanProperty bool) { + properties.put(name, bool); + } + } + } + return properties; + } + + @SuppressWarnings("unchecked") + private static Map> findWall(BlockState state) { + Map> properties = new LinkedHashMap<>(); + for (String name : WALL_FACE_NAMES) { + for (Property property : state.getProperties()) { + if (property.getName().equals(name) && property.getValueClass() == WallSide.class) { + properties.put(name, (EnumProperty) property); + } + } + } + return properties; + } + + @SuppressWarnings("unchecked") + private static Map> findWire(BlockState state) { + Map> properties = new LinkedHashMap<>(); + for (String name : WALL_FACE_NAMES) { + for (Property property : state.getProperties()) { + if (property.getName().equals(name) && property.getValueClass() == RedstoneSide.class) { + properties.put(name, (EnumProperty) property); + } + } + } + return properties; + } + + private static Direction faceFor(IrisBlockVector v) { + int x = (int) Math.round(v.getX()); + int y = (int) Math.round(v.getY()); + int z = (int) Math.round(v.getZ()); + + if (x == 0 && z == -1) { + return Direction.NORTH; + } + + if (x == 0 && z == 1) { + return Direction.SOUTH; + } + + if (x == 1 && z == 0) { + return Direction.EAST; + } + + if (x == -1 && z == 0) { + return Direction.WEST; + } + + if (y > 0) { + return Direction.UP; + } + + if (y < 0) { + return Direction.DOWN; + } + + return Direction.SOUTH; + } + + private static int[] hexMods(IrisBlockVector v) { + int x = v.getBlockX(); + int y = v.getBlockY(); + int z = v.getBlockZ(); + + if (x == 0 && z == -1) return new int[]{0, -1}; + if (x == 1 && z == -2) return new int[]{1, -2}; + if (x == 1 && z == -1) return new int[]{1, -1}; + if (x == 2 && z == -1) return new int[]{2, -1}; + if (x == 1 && z == 0) return new int[]{1, 0}; + if (x == 2 && z == 1) return new int[]{2, 1}; + if (x == 1 && z == 1) return new int[]{1, 1}; + if (x == 1 && z == 2) return new int[]{1, 2}; + if (x == 0 && z == 1) return new int[]{0, 1}; + if (x == -1 && z == 2) return new int[]{-1, 2}; + if (x == -1 && z == 1) return new int[]{-1, 1}; + if (x == -2 && z == 1) return new int[]{-2, 1}; + if (x == -1 && z == 0) return new int[]{-1, 0}; + if (x == -2 && z == -1) return new int[]{-2, -1}; + if (x == -1 && z == -1) return new int[]{-1, -1}; + if (x == -1 && z == -2) return new int[]{-1, -2}; + + if (y > 0) { + return new int[]{0, 0}; + } + + if (y < 0) { + return new int[]{0, 0}; + } + + return new int[]{0, 1}; + } + + private static IrisBlockVector axisFaceVector(Direction.Axis axis) { + if (axis == Direction.Axis.X) { + return new IrisBlockVector(1, 0, 0); + } + if (axis == Direction.Axis.Y) { + return new IrisBlockVector(0, 1, 0); + } + return new IrisBlockVector(0, 0, 1); + } + + private static Direction.Axis axisOf(IrisBlockVector v) { + if (Math.abs(v.getBlockX()) > Math.max(Math.abs(v.getBlockY()), Math.abs(v.getBlockZ()))) { + return Direction.Axis.X; + } + + if (Math.abs(v.getBlockY()) > Math.max(Math.abs(v.getBlockX()), Math.abs(v.getBlockZ()))) { + return Direction.Axis.Y; + } + + if (Math.abs(v.getBlockZ()) > Math.max(Math.abs(v.getBlockX()), Math.abs(v.getBlockY()))) { + return Direction.Axis.Z; + } + + return Direction.Axis.Y; + } + + private static IrisBlockVector faceVector(String name) { + return switch (name) { + case "north" -> new IrisBlockVector(0, 0, -1); + case "south" -> new IrisBlockVector(0, 0, 1); + case "east" -> new IrisBlockVector(1, 0, 0); + case "west" -> new IrisBlockVector(-1, 0, 0); + case "up" -> new IrisBlockVector(0, 1, 0); + case "down" -> new IrisBlockVector(0, -1, 0); + default -> new IrisBlockVector(0, 0, 0); + }; + } + + private static String faceName(Direction direction) { + return direction.getSerializedName(); + } + + @SuppressWarnings("unchecked") + private static > BlockState apply(BlockState state, Property property, Object value) { + return state.setValue((Property) property, (T) value); + } +} diff --git a/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricStructureHooks.java b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricStructureHooks.java new file mode 100644 index 000000000..fa8debe45 --- /dev/null +++ b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricStructureHooks.java @@ -0,0 +1,116 @@ +/* + * 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.fabric; + +import art.arcane.iris.spi.PlatformStructureHooks; +import art.arcane.iris.spi.PlatformWorld; +import net.minecraft.core.Holder; +import net.minecraft.core.Registry; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.server.MinecraftServer; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.levelgen.structure.Structure; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; + +public final class FabricStructureHooks implements PlatformStructureHooks { + private final Supplier server; + + public FabricStructureHooks(Supplier server) { + this.server = server; + } + + @Override + public List structureKeys() { + return registryKeys(Registries.STRUCTURE); + } + + @Override + public List structureSetKeys() { + return registryKeys(Registries.STRUCTURE_SET); + } + + @Override + public List structureBiomeKeys(String structureKey) { + List keys = new ArrayList<>(); + MinecraftServer instance = server.get(); + if (instance == null) { + return keys; + } + Identifier identifier = Identifier.tryParse(structureKey); + if (identifier == null) { + return keys; + } + Registry registry = instance.registryAccess().lookupOrThrow(Registries.STRUCTURE); + Structure structure = registry.getValue(identifier); + if (structure == null) { + return keys; + } + for (Holder holder : structure.biomes()) { + holder.unwrapKey().ifPresent((net.minecraft.resources.ResourceKey key) -> keys.add(key.identifier().toString())); + } + return keys; + } + + @Override + public List objectFeatureKeys() { + return registryKeys(Registries.CONFIGURED_FEATURE); + } + + @Override + public List reachableStructureKeys(PlatformWorld world) { + return List.of(); + } + + @Override + public List possibleBiomeKeys(PlatformWorld world) { + return List.of(); + } + + @Override + public boolean placeFeature(PlatformWorld world, int x, int y, int z, String featureKey, long seed) { + return false; + } + + @Override + public int[] placeStructure(PlatformWorld world, int chunkX, int chunkZ, String structureKey, long seed, int maxSpan) { + return null; + } + + @Override + public boolean supportsStructurePlacement() { + return false; + } + + private List registryKeys(net.minecraft.resources.ResourceKey> registryKey) { + List keys = new ArrayList<>(); + MinecraftServer instance = server.get(); + if (instance == null) { + return keys; + } + Registry registry = instance.registryAccess().lookupOrThrow(registryKey); + for (Identifier identifier : registry.keySet()) { + keys.add(identifier.toString()); + } + return keys; + } +} diff --git a/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricTileData.java b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricTileData.java new file mode 100644 index 000000000..6650feb70 --- /dev/null +++ b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricTileData.java @@ -0,0 +1,51 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.fabric; + +import art.arcane.iris.engine.object.TileData; +import art.arcane.volmlib.util.collection.KMap; + +import java.io.DataOutputStream; +import java.io.IOException; + +public final class FabricTileData extends TileData { + private final byte[] raw; + private final KMap tileProperties; + + FabricTileData(byte[] raw, KMap tileProperties) { + super(); + this.raw = raw; + this.tileProperties = tileProperties == null ? new KMap<>() : tileProperties; + } + + @Override + public KMap getProperties() { + return tileProperties; + } + + @Override + public void toBinary(DataOutputStream out) throws IOException { + out.write(raw); + } + + @Override + public TileData clone() { + return this; + } +} diff --git a/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricTileReader.java b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricTileReader.java new file mode 100644 index 000000000..ac6d2a0a9 --- /dev/null +++ b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricTileReader.java @@ -0,0 +1,250 @@ +/* + * 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.fabric; + +import art.arcane.iris.engine.object.TileData; +import art.arcane.volmlib.util.collection.KMap; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.Strictness; +import net.minecraft.core.Registry; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.server.MinecraftServer; +import net.minecraft.world.level.block.entity.BannerPattern; + +import java.io.DataInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; +import java.util.Locale; +import java.util.function.Supplier; + +public final class FabricTileReader implements TileData.TileReader { + private static final Gson GSON = new GsonBuilder().disableHtmlEscaping().setStrictness(Strictness.LENIENT).create(); + private static final int DYE_COLOR_COUNT = 16; + + private final Supplier server; + + public FabricTileReader(Supplier server) { + this.server = server; + } + + private static final class ReplayInputStream extends InputStream { + private final InputStream source; + private byte[] buffer = new byte[256]; + private int size = 0; + private int position = 0; + private int marked = 0; + + private ReplayInputStream(InputStream source) { + this.source = source; + } + + @Override + public int read() throws IOException { + if (position < size) { + int value = buffer[position] & 0xFF; + position++; + return value; + } + int value = source.read(); + if (value < 0) { + return value; + } + if (size == buffer.length) { + buffer = Arrays.copyOf(buffer, buffer.length * 2); + } + buffer[size] = (byte) value; + size++; + position++; + return value; + } + + @Override + public boolean markSupported() { + return true; + } + + @Override + public synchronized void mark(int readLimit) { + marked = position; + } + + @Override + public synchronized void reset() { + position = marked; + } + + private void rewind() { + position = 0; + } + + private int position() { + return position; + } + + private byte[] consumed() { + return Arrays.copyOf(buffer, position); + } + } + + @Override + public TileData read(DataInputStream in) throws IOException { + if (!in.markSupported()) { + throw new IOException("Mark not supported"); + } + in.mark(Integer.MAX_VALUE); + ReplayInputStream replay = new ReplayInputStream(in); + DataInputStream din = new DataInputStream(replay); + try { + return parse(din, replay); + } finally { + in.reset(); + in.skipNBytes(replay.position()); + in.mark(0); + } + } + + private TileData parse(DataInputStream din, ReplayInputStream replay) throws IOException { + try { + String materialKey = din.readUTF(); + boolean materialMatched = matchMaterial(materialKey); + String json = din.readUTF(); + KMap properties = kmapFromJson(json); + if (!materialMatched) { + throw new NullPointerException("material is marked non-null but is null"); + } + if (properties == null) { + throw new NullPointerException("properties is marked non-null but is null"); + } + return new FabricTileData(replay.consumed(), properties); + } catch (Throwable e) { + replay.rewind(); + return parseLegacy(din, replay); + } + } + + @SuppressWarnings("unchecked") + private static KMap kmapFromJson(String json) { + return GSON.fromJson(json, KMap.class); + } + + private TileData parseLegacy(DataInputStream din, ReplayInputStream replay) throws IOException { + int id = din.readShort(); + switch (id) { + case 0 -> readSign(din); + case 1 -> readSpawner(din, replay); + case 2 -> readBanner(din, replay); + case 3 -> readLootable(din); + default -> throw new IOException("Unknown tile type: " + id); + } + return new FabricTileData(replay.consumed(), new KMap<>()); + } + + private static void readSign(DataInputStream din) throws IOException { + din.readUTF(); + din.readUTF(); + din.readUTF(); + din.readUTF(); + byte dye = din.readByte(); + if (dye < 0 || dye >= DYE_COLOR_COUNT) { + throw new ArrayIndexOutOfBoundsException("Index " + dye + " out of bounds for length " + DYE_COLOR_COUNT); + } + } + + private static void readSpawner(DataInputStream din, ReplayInputStream replay) throws IOException { + boolean resolved = false; + replay.mark(Integer.MAX_VALUE); + + try { + String keyString = din.readUTF(); + Identifier key = Identifier.tryParse(keyString); + resolved = key != null && BuiltInRegistries.ENTITY_TYPE.containsKey(key); + if (!resolved) { + replay.reset(); + } + } catch (Throwable ignored) { + replay.reset(); + } + + if (!resolved) { + din.readShort(); + } + } + + private void readBanner(DataInputStream din, ReplayInputStream replay) throws IOException { + din.readUnsignedByte(); + int listSize = din.readUnsignedByte(); + replay.mark(Integer.MAX_VALUE); + + boolean parsedKeyed = false; + try { + for (int i = 0; i < listSize; i++) { + din.readUnsignedByte(); + Identifier patternKey = Identifier.tryParse(din.readUTF()); + if (patternKey == null || !bannerPatternExists(patternKey)) { + throw new IOException("Unknown banner pattern key"); + } + } + parsedKeyed = true; + } catch (Throwable ignored) { + replay.reset(); + } + + if (parsedKeyed) { + return; + } + + for (int i = 0; i < listSize; i++) { + din.readUnsignedByte(); + din.readUnsignedByte(); + } + } + + private boolean bannerPatternExists(Identifier key) { + MinecraftServer instance = server.get(); + if (instance == null) { + return false; + } + Registry registry = instance.registryAccess().lookupOrThrow(Registries.BANNER_PATTERN); + return registry.containsKey(key); + } + + private static void readLootable(DataInputStream din) throws IOException { + din.readUTF(); + din.readUTF(); + din.readLong(); + } + + private static boolean matchMaterial(String name) { + String filtered = name; + if (filtered.startsWith("minecraft:")) { + filtered = filtered.substring("minecraft:".length()); + } + filtered = filtered.toUpperCase(Locale.ROOT); + filtered = filtered.replaceAll("\\s+", "_").replaceAll("\\W", ""); + Identifier identifier = Identifier.tryParse("minecraft:" + filtered.toLowerCase(Locale.ROOT)); + if (identifier == null) { + return false; + } + return BuiltInRegistries.ITEM.containsKey(identifier) || BuiltInRegistries.BLOCK.containsKey(identifier); + } +} diff --git a/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricWorldCheck.java b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricWorldCheck.java new file mode 100644 index 000000000..b5529a190 --- /dev/null +++ b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricWorldCheck.java @@ -0,0 +1,164 @@ +/* + * 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.fabric; + +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.chunk.LevelChunkSection; +import net.minecraft.world.level.levelgen.Heightmap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; + +public final class FabricWorldCheck { + private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + + private FabricWorldCheck() { + } + + public static void schedule() { + Thread thread = new Thread(FabricWorldCheck::waitAndRun, "Iris World Check"); + thread.setDaemon(true); + thread.start(); + } + + private static void waitAndRun() { + long start = System.currentTimeMillis(); + MinecraftServer server = null; + while (System.currentTimeMillis() - start < 600000L) { + Object instance = FabricLoader.getInstance().getGameInstance(); + if (instance instanceof MinecraftServer candidate && candidate.isReady()) { + server = candidate; + break; + } + try { + Thread.sleep(250L); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + + if (server == null) { + LOGGER.error("[worldcheck] server did not become ready within 10 minutes"); + return; + } + + MinecraftServer serverRef = server; + AtomicBoolean pass = new AtomicBoolean(false); + try { + serverRef.submit(() -> pass.set(run(serverRef))).join(); + } catch (Throwable e) { + LOGGER.error("[worldcheck] check failed", e); + } + + LOGGER.info("[worldcheck] shutting down dev server (result={})", pass.get() ? "PASS" : "FAIL"); + serverRef.halt(false); + } + + private static boolean run(MinecraftServer server) { + ServerLevel overworld = server.overworld(); + String generatorClass = overworld.getChunkSource().getGenerator().getClass().getName(); + LOGGER.info("[worldcheck] overworld generator: {}", generatorClass); + boolean irisGenerator = overworld.getChunkSource().getGenerator() instanceof IrisFabricChunkGenerator; + if (!irisGenerator) { + LOGGER.error("[worldcheck] overworld is NOT using IrisFabricChunkGenerator"); + } + + BlockPos spawn = overworld.getRespawnData().pos(); + LOGGER.info("[worldcheck] spawn: {} {} {} (minY={} height={})", spawn.getX(), spawn.getY(), spawn.getZ(), overworld.getMinY(), overworld.getHeight()); + + MessageDigest digest = sha256(); + List samples = new ArrayList<>(); + Set surfaceKeys = new LinkedHashSet<>(); + for (int dx = 0; dx < 4; dx++) { + for (int dz = 0; dz < 4; dz++) { + int x = spawn.getX() + (dx - 2) * 16 + 8; + int z = spawn.getZ() + (dz - 2) * 16 + 8; + overworld.getChunk(x >> 4, z >> 4); + int y = overworld.getHeight(Heightmap.Types.WORLD_SURFACE, x, z); + BlockState surface = overworld.getBlockState(new BlockPos(x, y - 1, z)); + String key = BuiltInRegistries.BLOCK.getKey(surface.getBlock()).toString(); + String line = x + " " + (y - 1) + " " + z + " " + key; + samples.add(line); + surfaceKeys.add(key); + digest.update((line + "\n").getBytes(StandardCharsets.UTF_8)); + } + } + + for (int i = 0; i < Math.min(6, samples.size()); i++) { + LOGGER.info("[worldcheck] surface sample: {}", samples.get(i)); + } + LOGGER.info("[worldcheck] surface digest: {} ({} columns, {} distinct surface blocks: {})", + HexFormat.of().formatHex(digest.digest()).substring(0, 12), samples.size(), surfaceKeys.size(), surfaceKeys); + + ChunkAccess zeroChunk = overworld.getChunk(0, 0); + int nonEmptySections = 0; + for (LevelChunkSection section : zeroChunk.getSections()) { + if (!section.hasOnlyAir()) { + nonEmptySections++; + } + } + Set columnKeys = new LinkedHashSet<>(); + int surfaceY = overworld.getHeight(Heightmap.Types.WORLD_SURFACE, 8, 8); + for (int y = overworld.getMinY(); y < surfaceY; y += 16) { + BlockState state = zeroChunk.getBlockState(new BlockPos(8, y, 8)); + if (!state.isAir()) { + columnKeys.add(BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString()); + } + } + LOGGER.info("[worldcheck] chunk 0,0: {} non-empty sections of {}; column blocks at (8,*,8): {}", + nonEmptySections, zeroChunk.getSections().length, columnKeys); + + boolean sectionsOk = nonEmptySections >= 4; + boolean varietyOk = columnKeys.size() >= 2 || surfaceKeys.size() >= 2; + if (!sectionsOk) { + LOGGER.error("[worldcheck] chunk 0,0 looks empty/vanilla-flat ({} non-empty sections)", nonEmptySections); + } + if (!varietyOk) { + LOGGER.error("[worldcheck] generated terrain has no block variety (flat-world signature)"); + } + + boolean pass = irisGenerator && sectionsOk && varietyOk; + LOGGER.info("[worldcheck] {}", pass ? "PASS" : "FAIL"); + return pass; + } + + private static MessageDigest sha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricWorldEngines.java b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricWorldEngines.java new file mode 100644 index 000000000..4888c3f34 --- /dev/null +++ b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricWorldEngines.java @@ -0,0 +1,137 @@ +/* + * 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.fabric; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.IrisEngine; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.EngineTarget; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisWorld; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.dimension.DimensionType; +import net.minecraft.world.level.storage.LevelResource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public final class FabricWorldEngines { + private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + private static final ConcurrentHashMap ENGINES = new ConcurrentHashMap<>(); + + private FabricWorldEngines() { + } + + public static Engine get(ServerLevel level, String dimensionKey) { + Engine existing = ENGINES.get(level); + if (existing != null) { + return existing; + } + return ENGINES.computeIfAbsent(level, (ServerLevel l) -> create(l, dimensionKey)); + } + + private static Engine create(ServerLevel level, String dimensionKey) { + FabricEngineBootstrap.bind(); + File pack = resolvePack(dimensionKey); + IrisData data = IrisData.get(pack); + IrisDimension dimension = data.getDimensionLoader().load(dimensionKey); + if (dimension == null) { + LOGGER.error("Iris pack at {} does not contain dimension '{}' (expected dimensions/{}.json). Install a matching Iris pack and restart.", + pack.getAbsolutePath(), dimensionKey, dimensionKey); + throw new IllegalStateException("Iris dimension '" + dimensionKey + "' missing from pack " + pack.getAbsolutePath()); + } + + long seed = level.getSeed(); + File worldFolder = DimensionType.getStorageFolder(level.dimension(), level.getServer().getWorldPath(LevelResource.ROOT)).toFile(); + IrisWorld world = IrisWorld.builder() + .name(level.dimension().identifier().toString().replace(':', '_')) + .seed(seed) + .worldFolder(worldFolder) + .minHeight(dimension.getMinHeight()) + .maxHeight(dimension.getMaxHeight()) + .build(); + Engine engine = new IrisEngine(new EngineTarget(world, dimension, data), false); + + int levelMinY = level.getMinY(); + int levelMaxY = level.getMinY() + level.getHeight(); + if (dimension.getMinHeight() != levelMinY || dimension.getMaxHeight() != levelMaxY) { + LOGGER.error("Iris pack height mismatch for {}: pack generates {}..{} but the level is {}..{}. Terrain outside the level range will be clipped; ship a matching dimension_type.", + level.dimension().identifier(), dimension.getMinHeight(), dimension.getMaxHeight(), levelMinY, levelMaxY); + } + + LOGGER.info("Iris engine up for {}: pack={} dim={} seed={} height={}..{}", + level.dimension().identifier(), pack.getAbsolutePath(), dimension.getLoadKey(), seed, dimension.getMinHeight(), dimension.getMaxHeight()); + return engine; + } + + private static File resolvePack(String dimensionKey) { + File pack = FabricLoader.getInstance().getConfigDir() + .resolve("irisworldgen") + .resolve("packs") + .resolve(dimensionKey) + .toFile(); + if (pack.isDirectory()) { + return pack; + } + + String parity = System.getProperty("iris.parity"); + if (parity != null) { + String parityPath = parity; + int lastColon = parity.lastIndexOf(':'); + if (lastColon > 0) { + try { + Integer.parseInt(parity.substring(lastColon + 1)); + parityPath = parity.substring(0, lastColon); + } catch (NumberFormatException ignored) { + } + } + File parityPack = new File(parityPath); + if (parityPack.isDirectory()) { + LOGGER.warn("Iris pack missing at {}; falling back to parity pack {}", pack.getAbsolutePath(), parityPack.getAbsolutePath()); + return parityPack; + } + } + + LOGGER.error("==============================================================="); + LOGGER.error("Iris pack for dimension '{}' is not installed.", dimensionKey); + LOGGER.error("Expected a pack folder at: {}", pack.getAbsolutePath()); + LOGGER.error("Install an Iris pack there (the folder must contain dimensions/{}.json) and restart the server.", dimensionKey); + LOGGER.error("==============================================================="); + throw new IllegalStateException("Iris pack not installed: " + pack.getAbsolutePath()); + } + + public static void shutdown() { + for (Map.Entry entry : ENGINES.entrySet()) { + Engine engine = entry.getValue(); + try { + if (!engine.isClosed()) { + engine.close(); + } + LOGGER.info("Iris engine closed for {}", entry.getKey().dimension().identifier()); + } catch (Throwable e) { + LOGGER.error("Iris engine close failed for {}", entry.getKey().dimension().identifier(), e); + } + } + ENGINES.clear(); + } +} diff --git a/adapters/fabric/src/main/java/art/arcane/iris/fabric/IrisFabricBootstrap.java b/adapters/fabric/src/main/java/art/arcane/iris/fabric/IrisFabricBootstrap.java index 226ec0c21..cd3204c36 100644 --- a/adapters/fabric/src/main/java/art/arcane/iris/fabric/IrisFabricBootstrap.java +++ b/adapters/fabric/src/main/java/art/arcane/iris/fabric/IrisFabricBootstrap.java @@ -19,8 +19,13 @@ package art.arcane.iris.fabric; import net.fabricmc.api.ModInitializer; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; import net.fabricmc.loader.api.FabricLoader; import net.fabricmc.loader.api.ModContainer; +import net.minecraft.core.Registry; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.Identifier; +import net.minecraft.server.MinecraftServer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -58,6 +63,22 @@ public final class IrisFabricBootstrap implements ModInitializer { } LOGGER.info("Iris core loaded ({} classes ok)", loadedClasses); - LOGGER.info("Iris worldgen is not wired on Fabric yet; this build verifies packaging and engine classloading"); + + FabricEngineBootstrap.bind(); + Registry.register(BuiltInRegistries.CHUNK_GENERATOR, Identifier.fromNamespaceAndPath("irisworldgen", "iris"), IrisFabricChunkGenerator.CODEC); + LOGGER.info("Iris chunk generator registered as irisworldgen:iris"); + ServerLifecycleEvents.SERVER_STOPPING.register((MinecraftServer server) -> FabricWorldEngines.shutdown()); + + String parity = System.getProperty("iris.parity"); + if (parity != null) { + LOGGER.info("Iris parity probe armed: {}", parity); + FabricParityProbe.schedule(parity); + } + + String worldCheck = System.getProperty("iris.worldcheck"); + if (worldCheck != null) { + LOGGER.info("Iris world check armed"); + FabricWorldCheck.schedule(); + } } } diff --git a/adapters/fabric/src/main/java/art/arcane/iris/fabric/IrisFabricChunkGenerator.java b/adapters/fabric/src/main/java/art/arcane/iris/fabric/IrisFabricChunkGenerator.java new file mode 100644 index 000000000..972cd0bca --- /dev/null +++ b/adapters/fabric/src/main/java/art/arcane/iris/fabric/IrisFabricChunkGenerator.java @@ -0,0 +1,340 @@ +/* + * 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.fabric; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformBiome; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.project.hunk.Hunk; +import com.mojang.serialization.Codec; +import com.mojang.serialization.MapCodec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; +import net.minecraft.core.QuartPos; +import net.minecraft.core.Registry; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.WorldGenRegion; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.LevelHeightAccessor; +import net.minecraft.world.level.NoiseColumn; +import net.minecraft.world.level.StructureManager; +import net.minecraft.world.level.WorldGenLevel; +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.biome.BiomeSource; +import net.minecraft.world.level.biome.Climate; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.chunk.ChunkGenerator; +import net.minecraft.world.level.chunk.ChunkGeneratorStructureState; +import net.minecraft.world.level.chunk.LevelChunkSection; +import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.level.levelgen.RandomState; +import net.minecraft.world.level.levelgen.blending.Blender; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.EnumSet; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; + +public final class IrisFabricChunkGenerator extends ChunkGenerator { + private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + public static final MapCodec CODEC = RecordCodecBuilder.mapCodec((RecordCodecBuilder.Instance instance) -> instance.group( + BiomeSource.CODEC.fieldOf("biome_source").forGetter((IrisFabricChunkGenerator generator) -> generator.biomeSource), + Codec.STRING.optionalFieldOf("dimension", "overworld").forGetter((IrisFabricChunkGenerator generator) -> generator.dimensionKey) + ).apply(instance, IrisFabricChunkGenerator::new)); + + private final String dimensionKey; + private final ConcurrentHashMap> biomeHolders = new ConcurrentHashMap<>(); + private final AtomicBoolean announced = new AtomicBoolean(false); + private volatile Engine engine; + + public IrisFabricChunkGenerator(BiomeSource biomeSource, String dimensionKey) { + super(biomeSource); + this.dimensionKey = dimensionKey; + } + + @Override + protected MapCodec codec() { + return CODEC; + } + + private ServerLevel boundLevel() { + MinecraftServer server = FabricEngineBootstrap.currentServer(); + if (server == null) { + return null; + } + for (ServerLevel level : server.getAllLevels()) { + if (level.getChunkSource().getGenerator() == this) { + return level; + } + } + return null; + } + + private Engine engine() { + Engine cached = engine; + if (cached != null) { + return cached; + } + synchronized (this) { + if (engine != null) { + return engine; + } + ServerLevel level = boundLevel(); + if (level == null) { + throw new IllegalStateException("Iris generator '" + dimensionKey + "' has no bound ServerLevel yet"); + } + Engine created = FabricWorldEngines.get(level, dimensionKey); + engine = created; + return created; + } + } + + private Engine engineOrNull() { + Engine cached = engine; + if (cached != null) { + return cached; + } + try { + return engine(); + } catch (Throwable ignored) { + return null; + } + } + + @Override + public CompletableFuture fillFromNoise(Blender blender, RandomState randomState, StructureManager structureManager, ChunkAccess chunk) { + Engine generationEngine = engine(); + ChunkPos pos = chunk.getPos(); + if (announced.compareAndSet(false, true)) { + LOGGER.info("Iris generating {} through IrisFabricChunkGenerator (dim={} first chunk {},{})", + dimensionKey, generationEngine.getDimension().getLoadKey(), pos.x(), pos.z()); + } + LOGGER.debug("Iris generating chunk {},{}", pos.x(), pos.z()); + + int dimMinY = generationEngine.getMinHeight(); + int dimMaxY = generationEngine.getMaxHeight(); + int height = dimMaxY - dimMinY; + PlatformBlockState air = IrisPlatforms.get().registries().air(); + FabricBlockBuffer blocks = new FabricBlockBuffer(height, air); + Hunk biomes = Hunk.newArrayHunk(16, height, 16); + try { + generationEngine.generate(pos.getMinBlockX(), pos.getMinBlockZ(), blocks, biomes, false); + } catch (Throwable e) { + LOGGER.error("Iris failed to generate chunk {},{}", pos.x(), pos.z(), e); + throw new IllegalStateException("Iris generation failed for chunk " + pos.x() + "," + pos.z(), e); + } + + writeBlocks(chunk, blocks, dimMinY, height); + Heightmap.primeHeightmaps(chunk, EnumSet.of(Heightmap.Types.WORLD_SURFACE_WG, Heightmap.Types.OCEAN_FLOOR_WG)); + Registry biomeRegistry = structureManager.registryAccess().lookupOrThrow(Registries.BIOME); + chunk.fillBiomesFromNoise(new HunkBiomeResolver(this, biomes, biomeRegistry, pos, dimMinY, height), randomState.sampler()); + return CompletableFuture.completedFuture(chunk); + } + + private Holder fallbackBiome(Registry registry) { + Holder existing = biomeHolders.get("minecraft:plains"); + if (existing != null) { + return existing; + } + Holder resolved = registry.get(Identifier.fromNamespaceAndPath("minecraft", "plains")) + .>map((Holder.Reference reference) -> reference) + .orElseThrow(() -> new IllegalStateException("minecraft:plains missing from biome registry")); + Holder raced = biomeHolders.putIfAbsent("minecraft:plains", resolved); + return raced != null ? raced : resolved; + } + + private Holder holderFor(Registry registry, PlatformBiome biome) { + if (biome == null) { + return fallbackBiome(registry); + } + String key = biome.key(); + Holder existing = biomeHolders.get(key); + if (existing != null) { + return existing; + } + Identifier identifier = Identifier.tryParse(key); + Optional> reference = identifier == null ? Optional.empty() : registry.get(identifier); + Holder resolved = reference.>map((Holder.Reference value) -> value).orElseGet(() -> fallbackBiome(registry)); + Holder raced = biomeHolders.putIfAbsent(key, resolved); + return raced != null ? raced : resolved; + } + + private void writeBlocks(ChunkAccess chunk, FabricBlockBuffer blocks, int dimMinY, int height) { + int chunkMinY = chunk.getMinY(); + int chunkMaxY = chunkMinY + chunk.getHeight(); + int from = Math.max(dimMinY, chunkMinY); + int to = Math.min(dimMinY + height, chunkMaxY); + + for (int y = from; y < to; ) { + int sectionIndex = chunk.getSectionIndex(y); + LevelChunkSection section = chunk.getSection(sectionIndex); + int sectionMinY = chunk.getSectionYFromSectionIndex(sectionIndex) << 4; + int sectionEnd = Math.min(sectionMinY + 16, to); + section.acquire(); + try { + for (int blockY = y; blockY < sectionEnd; blockY++) { + int bufferY = blockY - dimMinY; + int localY = blockY & 15; + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + if (blocks.isAir(x, bufferY, z)) { + continue; + } + PlatformBlockState state = blocks.getRaw(x, bufferY, z); + section.setBlockState(x, localY, z, (BlockState) state.nativeHandle(), false); + } + } + } + } finally { + section.release(); + } + y = sectionEnd; + } + } + + private static final class HunkBiomeResolver implements BiomeResolver { + private final IrisFabricChunkGenerator generator; + private final Hunk biomes; + private final Registry registry; + private final ChunkPos pos; + private final int dimMinY; + private final int height; + + private HunkBiomeResolver(IrisFabricChunkGenerator generator, Hunk biomes, Registry registry, ChunkPos pos, int dimMinY, int height) { + this.generator = generator; + this.biomes = biomes; + this.registry = registry; + this.pos = pos; + this.dimMinY = dimMinY; + this.height = height; + } + + @Override + public Holder getNoiseBiome(int quartX, int quartY, int quartZ, Climate.Sampler sampler) { + int localX = QuartPos.toBlock(quartX) - pos.getMinBlockX(); + int localZ = QuartPos.toBlock(quartZ) - pos.getMinBlockZ(); + int bufferY = Math.max(0, Math.min(height - 1, QuartPos.toBlock(quartY) - dimMinY)); + PlatformBiome biome = biomes.get(localX, bufferY, localZ); + return generator.holderFor(registry, biome); + } + } + + @Override + public void applyCarvers(WorldGenRegion region, long seed, RandomState randomState, BiomeManager biomeManager, StructureManager structureManager, ChunkAccess chunk) { + } + + @Override + public void buildSurface(WorldGenRegion region, StructureManager structureManager, RandomState randomState, ChunkAccess chunk) { + } + + @Override + public void applyBiomeDecoration(WorldGenLevel level, ChunkAccess chunk, StructureManager structureManager) { + } + + @Override + public void createStructures(RegistryAccess registryAccess, ChunkGeneratorStructureState structureState, StructureManager structureManager, ChunkAccess chunk, StructureTemplateManager templateManager, ResourceKey levelKey) { + } + + @Override + public void createReferences(WorldGenLevel level, StructureManager structureManager, ChunkAccess chunk) { + } + + @Override + public void spawnOriginalMobs(WorldGenRegion region) { + } + + @Override + public int getGenDepth() { + Engine current = engineOrNull(); + return current == null ? 384 : current.getMaxHeight() - current.getMinHeight(); + } + + @Override + public int getSeaLevel() { + Engine current = engineOrNull(); + return current == null ? 63 : current.getDimension().getFluidHeight(); + } + + @Override + public int getMinY() { + Engine current = engineOrNull(); + return current == null ? -64 : current.getMinHeight(); + } + + @Override + public int getBaseHeight(int x, int z, Heightmap.Types type, LevelHeightAccessor heightAccessor, RandomState randomState) { + Engine current = engineOrNull(); + if (current == null) { + return heightAccessor.getMinY() + Math.min(heightAccessor.getHeight(), 64); + } + boolean ignoreFluid = type == Heightmap.Types.OCEAN_FLOOR || type == Heightmap.Types.OCEAN_FLOOR_WG; + return current.getMinHeight() + current.getHeight(x, z, ignoreFluid) + 1; + } + + @Override + public NoiseColumn getBaseColumn(int x, int z, LevelHeightAccessor heightAccessor, RandomState randomState) { + int minY = heightAccessor.getMinY(); + BlockState[] states = new BlockState[heightAccessor.getHeight()]; + Engine current = engineOrNull(); + BlockState airState = Blocks.AIR.defaultBlockState(); + if (current == null) { + for (int i = 0; i < states.length; i++) { + states[i] = airState; + } + return new NoiseColumn(minY, states); + } + int surface = current.getMinHeight() + current.getHeight(x, z, true); + int fluid = current.getDimension().getFluidHeight(); + BlockState stone = Blocks.STONE.defaultBlockState(); + BlockState water = Blocks.WATER.defaultBlockState(); + for (int i = 0; i < states.length; i++) { + int y = minY + i; + if (y <= surface) { + states[i] = stone; + } else if (y <= fluid) { + states[i] = water; + } else { + states[i] = airState; + } + } + return new NoiseColumn(minY, states); + } + + @Override + public void addDebugScreenInfo(List info, RandomState randomState, BlockPos pos) { + info.add("Iris dimension: " + dimensionKey); + } +} diff --git a/adapters/fabric/src/main/resources/data/irisworldgen/dimension_type/overworld.json b/adapters/fabric/src/main/resources/data/irisworldgen/dimension_type/overworld.json new file mode 100644 index 000000000..ec5e0d60f --- /dev/null +++ b/adapters/fabric/src/main/resources/data/irisworldgen/dimension_type/overworld.json @@ -0,0 +1,55 @@ +{ + "ambient_light": 0.0, + "attributes": { + "minecraft:audio/ambient_sounds": { + "mood": { + "block_search_extent": 8, + "offset": 2.0, + "sound": "minecraft:ambient.cave", + "tick_delay": 6000 + } + }, + "minecraft:audio/background_music": { + "creative": { + "max_delay": 24000, + "min_delay": 12000, + "sound": "minecraft:music.creative" + }, + "default": { + "max_delay": 24000, + "min_delay": 12000, + "sound": "minecraft:music.game" + } + }, + "minecraft:gameplay/bed_rule": { + "can_set_spawn": "always", + "can_sleep": "when_dark", + "error_message": { + "translate": "block.minecraft.bed.no_sleep" + } + }, + "minecraft:gameplay/nether_portal_spawns_piglin": true, + "minecraft:gameplay/respawn_anchor_works": false, + "minecraft:visual/ambient_light_color": "#0a0a0a", + "minecraft:visual/cloud_color": "#ccffffff", + "minecraft:visual/cloud_height": 192.33, + "minecraft:visual/fog_color": "#c0d8ff", + "minecraft:visual/sky_color": "#78a7ff" + }, + "coordinate_scale": 1.0, + "default_clock": "minecraft:overworld", + "has_ceiling": false, + "has_ender_dragon_fight": false, + "has_skylight": true, + "height": 768, + "infiniburn": "#minecraft:infiniburn_overworld", + "logical_height": 512, + "min_y": -256, + "monster_spawn_block_light_limit": 0, + "monster_spawn_light_level": { + "type": "minecraft:uniform", + "max_inclusive": 7, + "min_inclusive": 0 + }, + "timelines": "#minecraft:in_overworld" +} diff --git a/adapters/fabric/src/main/resources/data/minecraft/dimension/overworld.json b/adapters/fabric/src/main/resources/data/minecraft/dimension/overworld.json new file mode 100644 index 000000000..3a77a41d3 --- /dev/null +++ b/adapters/fabric/src/main/resources/data/minecraft/dimension/overworld.json @@ -0,0 +1,11 @@ +{ + "type": "irisworldgen:overworld", + "generator": { + "type": "irisworldgen:iris", + "dimension": "overworld", + "biome_source": { + "type": "minecraft:fixed", + "biome": "minecraft:plains" + } + } +} diff --git a/adapters/fabric/src/main/resources/fabric.mod.json b/adapters/fabric/src/main/resources/fabric.mod.json index 5767c5fbd..4738cf87c 100644 --- a/adapters/fabric/src/main/resources/fabric.mod.json +++ b/adapters/fabric/src/main/resources/fabric.mod.json @@ -3,7 +3,7 @@ "id": "irisworldgen", "version": "${version}", "name": "Iris", - "description": "Iris World Generation Engine (Fabric adapter - packaging + core bundle; worldgen wiring lands next milestone)", + "description": "Iris World Generation Engine (Fabric adapter - native chunk generator, overworld override datapack, engine lifecycle)", "authors": ["Arcane Arts (Volmit Software)"], "contact": { "sources": "https://github.com/VolmitSoftware/Iris" @@ -15,6 +15,9 @@ }, "depends": { "fabricloader": ">=0.19.0", + "fabric-registry-sync-v0": "*", + "fabric-resource-loader-v1": "*", + "fabric-lifecycle-events-v1": "*", "minecraft": "~${minecraftVersion}", "java": ">=25" } diff --git a/core/src/main/java/art/arcane/iris/engine/decorator/DecoratorCore.java b/core/src/main/java/art/arcane/iris/engine/decorator/DecoratorCore.java index c868fe9cb..28f0da421 100644 --- a/core/src/main/java/art/arcane/iris/engine/decorator/DecoratorCore.java +++ b/core/src/main/java/art/arcane/iris/engine/decorator/DecoratorCore.java @@ -339,9 +339,13 @@ final class DecoratorCore { static PlatformBlockState fixFacesForHunk(PlatformBlockState b, Hunk hunk, int rX, int rZ, int x, int y, int z, EngineMantle mantle) { - if (!BUKKIT_PRESENT || !B.isVineBlock(b)) { + if (!B.isVineBlock(b)) { return b; } + if (!BUKKIT_PRESENT) { + DecoratorPlatformHooks.FaceFixer fixer = DecoratorPlatformHooks.faceFixer(); + return fixer == null ? b : fixer.fixFaces(b, hunk, rX, rZ, x, y, z, mantle); + } BlockData rawB = (BlockData) b.nativeHandle(); BlockData cloned = rawB.clone(); MultipleFacing data = (MultipleFacing) cloned; @@ -392,7 +396,8 @@ final class DecoratorCore { static boolean canGoOn(PlatformBlockState decorator, PlatformBlockState surface) { if (!BUKKIT_PRESENT) { - return B.isSolid(surface); + DecoratorPlatformHooks.SurfaceSturdiness sturdiness = DecoratorPlatformHooks.surfaceSturdiness(); + return sturdiness == null ? B.isSolid(surface) : sturdiness.canGoOn(surface); } return ((BlockData) surface.nativeHandle()).isFaceSturdy(BlockFace.UP, BlockSupport.FULL); } diff --git a/core/src/main/java/art/arcane/iris/engine/decorator/DecoratorPlatformHooks.java b/core/src/main/java/art/arcane/iris/engine/decorator/DecoratorPlatformHooks.java new file mode 100644 index 000000000..537dd614f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/decorator/DecoratorPlatformHooks.java @@ -0,0 +1,52 @@ +/* + * 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.engine.decorator; + +import art.arcane.iris.engine.mantle.EngineMantle; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.project.hunk.Hunk; + +public final class DecoratorPlatformHooks { + private static volatile FaceFixer FACE_FIXER = null; + private static volatile SurfaceSturdiness SURFACE_STURDINESS = null; + + private DecoratorPlatformHooks() { + } + + public interface FaceFixer { + PlatformBlockState fixFaces(PlatformBlockState state, Hunk hunk, int rX, int rZ, int x, int y, int z, EngineMantle mantle); + } + + public interface SurfaceSturdiness { + boolean canGoOn(PlatformBlockState surface); + } + + public static void bind(FaceFixer faceFixer, SurfaceSturdiness surfaceSturdiness) { + FACE_FIXER = faceFixer; + SURFACE_STURDINESS = surfaceSturdiness; + } + + static FaceFixer faceFixer() { + return FACE_FIXER; + } + + static SurfaceSturdiness surfaceSturdiness() { + return SURFACE_STURDINESS; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/framework/StructureAssembler.java b/core/src/main/java/art/arcane/iris/engine/framework/StructureAssembler.java index 0a72f0b56..f2d99bfb6 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/StructureAssembler.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/StructureAssembler.java @@ -137,9 +137,9 @@ public final class StructureAssembler { cb.getPosition().getY() - center.getY(), cb.getPosition().getZ() - center.getZ()); IrisPosition rcr = rot.rotate(cr, 0, 0, 0); - int wcx = c.wx + c.facing.toVector().getBlockX(); - int wcy = c.wy + c.facing.toVector().getBlockY(); - int wcz = c.wz + c.facing.toVector().getBlockZ(); + int wcx = c.wx + c.facing.x(); + int wcy = c.wy + c.facing.y(); + int wcz = c.wz + c.facing.z(); int px = wcx - rcr.getX(); int py = wcy - rcr.getY(); int pz = wcz - rcr.getZ(); diff --git a/core/src/main/java/art/arcane/iris/engine/object/BlockDataMergeSupport.java b/core/src/main/java/art/arcane/iris/engine/object/BlockDataMergeSupport.java index db9e60a52..18e27d93d 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/BlockDataMergeSupport.java +++ b/core/src/main/java/art/arcane/iris/engine/object/BlockDataMergeSupport.java @@ -7,12 +7,21 @@ import org.bukkit.block.data.BlockData; import java.util.function.Function; -final class BlockDataMergeSupport { +public final class BlockDataMergeSupport { private static final boolean BUKKIT_PRESENT = detectBukkit(); + private static volatile StateMerger FALLBACK_MERGER = null; private BlockDataMergeSupport() { } + public interface StateMerger { + PlatformBlockState merge(PlatformBlockState base, PlatformBlockState update); + } + + public static void bindFallbackMerger(StateMerger merger) { + FALLBACK_MERGER = merger; + } + private static boolean detectBukkit() { try { Class.forName("org.bukkit.Bukkit", false, BlockDataMergeSupport.class.getClassLoader()); @@ -24,7 +33,8 @@ final class BlockDataMergeSupport { static PlatformBlockState merge(PlatformBlockState base, PlatformBlockState update) { if (!BUKKIT_PRESENT) { - return mergeByKey(base, update); + StateMerger merger = FALLBACK_MERGER; + return merger == null ? mergeByKey(base, update) : merger.merge(base, update); } BlockData merged = merge((BlockData) base.nativeHandle(), (BlockData) update.nativeHandle(), BukkitBlockResolution::get); return merged == null ? null : BukkitBlockState.of(merged); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisBlockData.java b/core/src/main/java/art/arcane/iris/engine/object/IrisBlockData.java index b55577fcb..5c045552d 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisBlockData.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisBlockData.java @@ -26,7 +26,6 @@ import art.arcane.iris.core.loader.IrisRegistrant; import art.arcane.iris.engine.data.cache.AtomicCache; import art.arcane.iris.engine.object.annotations.*; import art.arcane.iris.platform.bukkit.BukkitBlockState; -import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.volmlib.util.collection.KList; @@ -39,8 +38,6 @@ import lombok.Data; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import lombok.experimental.Accessors; -import org.bukkit.Material; -import org.bukkit.block.data.BlockData; import java.util.Map; @@ -201,8 +198,11 @@ public class IrisBlockData extends IrisRegistrant { public TileData tryGetTile(IrisData data) { //TODO Do like a registry thing with the tile data registry. Also update the parsing of data to include **block** entities. - Material type = ((BlockData) getBlockData(data).nativeHandle()).getMaterial(); - if (type == Material.SPAWNER && this.data.containsKey("entitySpawn")) { + PlatformBlockState state = getBlockData(data); + String stateKey = state.key(); + int bracket = stateKey.indexOf('['); + String blockKey = bracket >= 0 ? stateKey.substring(0, bracket) : stateKey; + if (blockKey.equals("minecraft:spawner") && this.data.containsKey("entitySpawn")) { String id = (String) this.data.get("entitySpawn"); if (tileData == null) tileData = new KMap<>(); KMap spawnData = (KMap) tileData.computeIfAbsent("SpawnData", k -> new KMap<>()); @@ -210,9 +210,9 @@ public class IrisBlockData extends IrisRegistrant { entity.putIfAbsent("id", Identifier.fromString(id).toString()); } - if (!BukkitPlatform.hasTile(type) || tileData == null || tileData.isEmpty()) + if (tileData == null || tileData.isEmpty() || !state.hasTileEntity()) return null; - return new TileData(type, this.tileData); + return TileData.of(state, this.tileData); } private String keyify(String dat) { diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectRotation.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectRotation.java index 2d19d9b91..bd801dd11 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectRotation.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectRotation.java @@ -48,6 +48,15 @@ import java.util.Map; @Data public class IrisObjectRotation { private static final boolean BUKKIT_PRESENT = detectBukkit(); + private static volatile StateRotator FALLBACK_ROTATOR = null; + + public interface StateRotator { + PlatformBlockState rotate(IrisObjectRotation rotation, PlatformBlockState state, int spinx, int spiny, int spinz); + } + + public static void bindFallbackRotator(StateRotator rotator) { + FALLBACK_ROTATOR = rotator; + } private static boolean detectBukkit() { try { @@ -278,7 +287,8 @@ public class IrisObjectRotation { } if (!BUKKIT_PRESENT) { - return state; + StateRotator rotator = FALLBACK_ROTATOR; + return rotator == null ? state : rotator.rotate(this, state, spinx, spiny, spinz); } BlockData raw = ((BlockData) state.nativeHandle()).clone(); diff --git a/core/src/main/java/art/arcane/iris/engine/object/TileData.java b/core/src/main/java/art/arcane/iris/engine/object/TileData.java index 7e6d34d2f..ce266b396 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/TileData.java +++ b/core/src/main/java/art/arcane/iris/engine/object/TileData.java @@ -19,6 +19,7 @@ package art.arcane.iris.engine.object; import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.spi.PlatformBlockState; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.Strictness; @@ -43,6 +44,25 @@ import java.io.IOException; @NoArgsConstructor(access = AccessLevel.PROTECTED) public class TileData implements Cloneable { private static final Gson gson = new GsonBuilder().disableHtmlEscaping().setStrictness(Strictness.LENIENT).create(); + private static final boolean BUKKIT_PRESENT = detectBukkit(); + private static volatile TileReader FALLBACK_READER = null; + + public interface TileReader { + TileData read(DataInputStream in) throws IOException; + } + + public static void bindFallbackReader(TileReader reader) { + FALLBACK_READER = reader; + } + + private static boolean detectBukkit() { + try { + Class.forName("org.bukkit.Bukkit", false, TileData.class.getClassLoader()); + return true; + } catch (ClassNotFoundException e) { + return false; + } + } @NonNull private Material material; @@ -67,7 +87,24 @@ public class TileData implements Cloneable { return new TileData().fromBukkit(block); } + public static TileData of(PlatformBlockState state, KMap properties) { + if (!BUKKIT_PRESENT) { + return null; + } + Object handle = state.nativeHandle(); + if (!(handle instanceof BlockData blockData)) { + return null; + } + return new TileData(blockData.getMaterial(), properties); + } + public static TileData read(DataInputStream in) throws IOException { + if (!BUKKIT_PRESENT) { + TileReader reader = FALLBACK_READER; + if (reader != null) { + return reader.read(in); + } + } if (!in.markSupported()) throw new IOException("Mark not supported"); in.mark(Integer.MAX_VALUE);